460 lines
14 KiB
JavaScript
460 lines
14 KiB
JavaScript
// 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();
|