// 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 */ const middleware = [ function disableSelect(_, action) { if (action === kAction.startTurningWheel || action === kAction.startPullingLever) { document.querySelector('.machine')?.classList.add('no-select'); } }, function enableSelect(_, action) { if (action === kAction.stopTurningWheel || action === kAction.stopPullingLever) { document.querySelector('.machine')?.classList.remove('no-select'); } }, function completeLoading(state, action) { if (action === kAction.turnWheel && state.currentState === kState.readyToLoad && parseFloat(state.load) >= configuration.maxLoad) { send(kAction.completeLoading); } }, 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`); } }, 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 }); } }, function createSausageIfWaiting(state, action, data) { if (action === kAction.createSausage && state.currentState === kState.waiting) { createSausage(data.href, data.label); } }, function log(state, action, data) { //console.log(state.load); } ]; 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; }; 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.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 ///////////////////////////////////////////////////////////// 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); 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; }; 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); }; const removeSausages = function removeSausages() { const $graterBed = document.querySelector('.grater-bed'); const $existingSausages = $graterBed.querySelectorAll('.sausage'); for (const $existingSausage of $existingSausages) { $graterBed?.removeChild($existingSausage); } } initialize();