// Constants /////////////////////////////////////////////////////////////////// const kDimensions = { wheelSize: 96, leverWidth: 98, sausageHeight: 48, }; const kState = { waiting: "waiting", readyToGrate: "readyToGrate", readyToLoad: "readyToLoad", }; const kAction = { completeLoading: Symbol("completeLoading"), startPullingLever: Symbol("startPullingLever"), stopPullingLever: Symbol("stopPullingLever"), pullLever: Symbol("pullLever"), startTurningWheel: Symbol("startTurningWheel"), stopTurningWheel: Symbol("stopTurningWheel"), turnWheel: Symbol("turnWheel"), setIsWaitingForFrame: Symbol("setIsWaitingForFrame"), createSausage: Symbol("createSausage"), completeGrating: Symbol("completeGrating"), }; const kRotationDirection = { clockwise: Symbol("clockwise"), counterClockwise: Symbol("counterClockwise"), }; const kPullDirection = { right: Symbol("right"), left: Symbol("left"), }; // Configuration /////////////////////////////////////////////////////////////// const configuration = { wheelStrength: 1, maxLoad: 100, leverTravel: 80.72, graterTravel: 69, chainSpeed: 0.5, grateStrength: 0.01, defaultUrl: "/example.html", }; // App State /////////////////////////////////////////////////////////////////// /** * The main app state */ let appState = null; /** * Functions to handle side effects: Creation or removal of HTML elements, * actions that occur as a result of a change in state. */ const middleware = [ /** * Makes text unselectable while we move the lever or wheel. */ function disableSelect(_, action) { if ( action === kAction.startTurningWheel || action === kAction.startPullingLever ) { document.querySelector(".machine")?.classList.add("no-select"); } }, /** * Makes text selectable when we're done moving the lever or wheel. */ function enableSelect(_, action) { if ( action === kAction.stopTurningWheel || action === kAction.stopPullingLever ) { document.querySelector(".machine")?.classList.remove("no-select"); } }, /** * Cleans up the old iFrames when we load the new one, and triggers a complete. */ function completeLoading(state, action) { if ( action === kAction.turnWheel && state.currentState === kState.readyToLoad && parseFloat(state.load) >= configuration.maxLoad ) { const iframes = $iframeContainer.querySelectorAll("iframe"); for (const iframe of iframes) { if (iframe !== appState.currentIframe) { $iframeContainer.removeChild(iframe); } } send(kAction.completeLoading); } }, /** * Sets the individual load state to the current iframe. */ function updateIframe(state, action) { if ( action === kAction.turnWheel && state.currentState === kState.readyToLoad && parseFloat(state.load) <= configuration.maxLoad ) { state.currentIframe?.style.setProperty( "--loadProgress", `${100 - state.load}vh`, ); } }, /** * Cleans up the sausage and loads the iframe once the grating is complete. */ function completeGrating(state, action) { if ( action === kAction.pullLever && state.currentState === kState.readyToGrate && parseFloat(state.grateLevel) >= kDimensions.sausageHeight ) { removeSausages(); const $iframe = loadIframe(state.currentUrl, 0); send(kAction.completeGrating, { iframe: $iframe }); } }, /** * Creates the sausage element if we are at the right state. */ function createSausageIfWaiting(state, action, data) { if ( action === kAction.createSausage && state.currentState === kState.waiting ) { createSausage(data.href, data.label); } }, ]; /** * We store the state as CSS variables and data attributes in an element * declared as the $stateContainer. UI updates as a result of this. We * try to avoid manipulating the elements with JS, except for creating and * removing elements. */ const $stateContainer = document.querySelector(".machine"); const updateState = function updateState(state) { for (const [key, value] of Object.entries(state)) { $stateContainer.style.setProperty(`--${key}`, value); $stateContainer.dataset[key] = value; } appState = state; }; /** * We modify a copy of the state and return it. */ const reduce = function reduce(stateReference, action, data) { let state = Object.assign({}, stateReference); switch (action) { case kAction.turnWheel: state.wheelRotation = `${data.angle}rad`; if (state.currentState === kState.readyToLoad) { const sign = data.direction === kRotationDirection.clockwise ? 1 : -1; const load = Math.max( Math.min( state.load - sign * data.magnitude * configuration.wheelStrength, configuration.maxLoad, ), 0, ); state.load = load; if (load < configuration.maxLoad && load > 0) { const chainOffset = parseFloat(state.chainOffset) + sign * data.magnitude * configuration.chainSpeed; state.chainOffset = `${chainOffset}%`; } } break; case kAction.pullLever: const diff = Math.abs(state.leverTravel - data.magnitude); state.leverTravel = data.magnitude; const rotation = (data.magnitude * configuration.leverTravel) / 100; state.leverRotation = `${rotation}deg`; const gratingTravel = (data.magnitude * configuration.graterTravel) / 100; state.graterTravel = `${gratingTravel}px`; if (state.currentState === kState.readyToGrate) { const grateLevel = Math.min( parseFloat(state.grateLevel) + diff * configuration.grateStrength, kDimensions.sausageHeight, ); state.grateLevel = `${grateLevel}px`; } break; case kAction.createSausage: state.currentUrl = data.href; state.grateLevel = "0%"; state.currentState = kState.readyToGrate; break; case kAction.startTurningWheel: state.isTurningWheel = true; break; case kAction.stopTurningWheel: state.isTurningWheel = false; break; case kAction.startPullingLever: state.isPullingLever = true; break; case kAction.stopPullingLever: state.isPullingLever = false; break; case kAction.completeLoading: state.currentState = kState.waiting; state.currentIframe = null; state.load = 0; break; case kAction.completeGrating: state.currentState = kState.readyToLoad; state.currentIframe = data.iframe; state.grateLevel = "0%"; state.load = 0; break; } return state; }; /** * The main mechanism for view changes to trigger updates. * It receives an action and its payload. * It queues the middleware to run after this. */ const send = function send(action, data) { const state = Object.assign({}, appState); for (const m of middleware) { setTimeout(() => m(state, action, data), 0); } const newState = reduce(state, action, data); updateState(newState); }; // Initializzation ///////////////////////////////////////////////////////////// /** * Initializes state and events. */ const initialize = function initialize() { const state = { currentState: kState.waiting, isPullingLever: false, isTurningWheel: false, leverTravel: 0, grateLevel: 0, leverRotation: "0deg", graterTravel: "0rem", wheelRotation: "0deg", chainOffset: "0%", load: 0, }; updateState(state); // Reset interaction with widgets if we lift a mouse button. document.addEventListener("mouseup", () => { send(kAction.stopTurningWheel); send(kAction.stopPullingLever); }); // The lever handle initiates interactions with the lever. document .querySelector(".lever .handle") ?.addEventListener("mousedown", () => { send(kAction.startPullingLever); }); // The wheel handle initiates interactions with the wheel. document .querySelector(".wheel .handle") ?.addEventListener("mousedown", () => { send(kAction.startTurningWheel); }); // Rotation is calculated relatively to the whole wheel. // And travel based no the full arc of the lever const $wheel = document.querySelector(".wheel"); const $lever = document.querySelector(".lever"); document.addEventListener("mousemove", (event) => { if (appState.isTurningWheel && !appState.waitingForFrame) { requestAnimationFrame(() => { const elementPosition = $wheel.getBoundingClientRect(); const x = event.clientX - elementPosition.left; const y = event.clientY - elementPosition.top; const angle = angleFromCenter( x, y, kDimensions.wheelSize, kDimensions.wheelSize, ); const oldAngle = parseFloat(appState.wheelRotation); const { direction, magnitude } = rotationDirection(oldAngle, angle); send(kAction.turnWheel, { angle, direction, magnitude }); send(kAction.setIsWaitingForFrame, { state: false }); }); send(kAction.setIsWaitingForFrame, { state: true }); } if (appState.isPullingLever && !appState.waitingForFrame) { requestAnimationFrame(() => { const elementPosition = $lever.getBoundingClientRect(); const x = event.clientX - elementPosition.left; const magnitude = Math.max( Math.min((x * 100) / kDimensions.leverWidth, 100), 0, ); const direction = magnitude >= appState.leverTravel ? kPullDirection.right : kPullDirection.left; send(kAction.pullLever, { magnitude, direction }); send(kAction.setIsWaitingForFrame, { state: false }); }); send(kAction.setIsWaitingForFrame, { state: true }); } }); // The URL Grater is the drop area. const $grater = document.querySelector(".grater-bed"); $grater.addEventListener("dragenter", (event) => { event.preventDefault(); if (appState.currentState === kState.waiting) { $grater.classList.add("drag-over"); } }); $grater.addEventListener("dragover", (event) => event.preventDefault()); $grater.addEventListener("dragleave", (event) => { if (!$grater.contains(event.relatedTarget)) { $grater.classList.remove("drag-over"); } }); $grater.addEventListener("drop", (event) => { event.preventDefault(); if (appState.currentState !== kState.waiting) { return; } const href = event.dataTransfer.getData("text/uri-list"); const label = event.dataTransfer.getData("text/plain"); $grater.classList.remove("drag-over"); send(kAction.createSausage, { href, label }); }); const params = new URLSearchParams(window.location.search); const urlFromQuery = params.get("url"); const url = urlFromQuery ?? configuration.defaultUrl; loadIframe(url, 100); }; // Utility Functions /////////////////////////////////////////////////////////// /** * Calculates the angle of a point (x,y) relative to the center of a square * with dimensions (w, h). The result is in radians. */ const angleFromCenter = function angleFromCenter(x, y, w, h) { const centerX = w / 2; const centerY = h / 2; const dx = x - centerX; const dy = y - centerY; return Math.atan2(dy, dx); }; /** * Given two angles, calculates the direction of the rotation. */ const rotationDirection = function rotationDirection(angle1, angle2) { let diff = angle2 - angle1; if (diff > Math.PI) diff -= 2 * Math.PI; if (diff < -Math.PI) diff += 2 * Math.PI; const magnitude = Math.abs(diff); const direction = diff > 0 ? kRotationDirection.counterClockwise : kRotationDirection.clockwise; return { direction, magnitude, }; }; /** * Loads an iframe and appends it into the body. */ const $iframeContainer = document.querySelector(".web-content"); const loadIframe = function loadIframe(url, loadProgress) { const $iframe = document.createElement("iframe"); $iframe.src = url; $iframe.style.setProperty("--loadProgress", `${100 - loadProgress}vh`); $iframeContainer.appendChild($iframe); $iframe.addEventListener("load", () => { const iframeDocument = $iframe.contentDocument; iframeDocument.querySelectorAll("a").forEach(($link) => { $link.addEventListener("click", (event) => event.preventDefault()); $link.draggable = true; $link.addEventListener("dragstart", (event) => { event.dataTransfer.setData("text/uri-list", $link.href); event.dataTransfer.setData("text/plain", $link.textContent.trim()); const ghost = $link.cloneNode(true); ghost.style.background = "url('/images/Sausage.svg')"; ghost.style.backgroundRepeat = "no-repeat"; ghost.style.cursor = "grabbing"; ghost.style.display = "block"; ghost.style.height = "3rem"; ghost.style.width = "8.125rem"; ghost.style.color = "#fff"; ghost.style.textAlign = "center"; ghost.style.fontSize = "0.625rem"; ghost.style.fontFamily = "sans-serif"; ghost.style.textTransform = "uppercase"; ghost.style.paddingTop = "1rem"; document.body.appendChild(ghost); event.dataTransfer.setDragImage(ghost, 10, 10); }); }); }); return $iframe; }; /** * Creates a sausage link for grating. */ const createSausage = function createSausage(href, label) { const $sausage = document.createElement("article"); $sausage.classList.add("sausage"); $sausage.dataset.href = href; $sausage.innerText = label; const $graterBed = document.querySelector(".grater-bed"); $graterBed?.appendChild($sausage); }; /** * Removes a completely grated sausage link. */ const removeSausages = function removeSausages() { const $graterBed = document.querySelector(".grater-bed"); const $existingSausages = $graterBed.querySelectorAll(".sausage"); for (const $existingSausage of $existingSausages) { $graterBed?.removeChild($existingSausage); } }; initialize();