presentation/components/learning.component.ts
presentation/components/learning.component.ts is a file in Bane's Lab Site. 151 lines of code and 37 definitions.
import {
COARSE_POINTER_QUERY,
FRAME_CAP,
GLIDE_POWER,
HOLD,
LIT_CLASS,
MILLISECONDS,
RETURN,
SLOW_PACE,
SPEED,
VISIBLE_FRACTION,
} from "#configuration/constants/learning.constants";
import type { LearningPoint, LearningView } from "#types/learning.types";
import {
aimOf,
cameraFit,
framedOf,
marksOf,
paceAt,
spansOf,
touchTargetOf,
widestOf,
} from "#presentation/components/learning.fragment.component";
import type { Disposer } from "#types/base.types";
import { HALF } from "#configuration/constants/element.constants";
interface Step {
readonly focus: LearningPoint | null;
readonly lift: number;
readonly traveled: number;
}
const ease = function ease(t: number): number {
return t * t * (3 - 2 * t);
};
const numberAt = function numberAt(record: Record<string, unknown>, key: string): number {
const value = record[key];
return typeof value === "number" ? value : 0;
};
export const attachLearningMap = function attachLearningMap(shell: Element, view: LearningView): Disposer {
const framed = framedOf(view);
if (framed === null) {
return () => {};
}
const { box, geometry, style } = framed;
const total = geometry.getTotalLength();
style["strokeDasharray"] = String(total);
style["strokeDashoffset"] = String(total);
const marks = marksOf(geometry, view.stops, total);
const spans = spansOf(marks);
const aspect = shell.clientHeight / shell.clientWidth;
const coarse = typeof globalThis.matchMedia === "function" && globalThis.matchMedia(COARSE_POINTER_QUERY).matches;
const aim = aimOf(touchTargetOf(document.documentElement), globalThis.devicePixelRatio);
const fit = cameraFit(shell.clientWidth, view.stops, aim, coarse);
const viewWidth = widestOf(view.stops) * fit.zoom;
const viewHeight = viewWidth * aspect;
const opening = geometry.getPointAtLength(0);
const closing = geometry.getPointAtLength(total);
let elapsed = 0;
let held = 0;
let back = 0;
let last = 0;
let running = false;
const glideBack = function glideBack(tick: number): Step {
back = Math.min(RETURN, back + tick);
const t = back / RETURN;
if (back >= RETURN) {
elapsed = 0;
held = 0;
back = 0;
return { focus: null, lift: SLOW_PACE, traveled: 0 };
}
const glide = ease(t);
return {
focus: { x: closing.x + (opening.x - closing.x) * glide, y: closing.y + (opening.y - closing.y) * glide },
lift: SLOW_PACE + (fit.lift - SLOW_PACE) * Math.sin(Math.PI * t ** GLIDE_POWER),
traveled: total * (1 - t) * (1 - t),
};
};
const settle = function settle(tick: number): Step {
if (elapsed < total) {
elapsed = Math.min(total, elapsed + tick * SPEED * paceAt(spans, elapsed));
return { focus: null, lift: SLOW_PACE, traveled: elapsed };
}
if (held < HOLD) {
held += tick;
return { focus: null, lift: SLOW_PACE, traveled: total };
}
return glideBack(tick);
};
const light = function light(traveled: number): void {
for (const mark of marks) {
const lit = traveled >= mark.at && traveled > 0;
if (mark.lit !== lit) {
mark.lit = lit;
mark.stop.node.classList.toggle(LIT_CLASS, lit);
}
}
};
const frame = function frame(now: number): void {
if (!running) {
return;
}
const tick = Math.min(FRAME_CAP, (now - last) / MILLISECONDS);
last = now;
const step = settle(tick);
const head = step.focus ?? geometry.getPointAtLength(step.traveled);
style["strokeDashoffset"] = String(total - step.traveled);
box["width"] = viewWidth * step.lift;
box["height"] = viewHeight * step.lift;
box["x"] = head.x - numberAt(box, "width") / HALF;
box["y"] = head.y - numberAt(box, "height") / HALF;
light(step.traveled);
requestAnimationFrame(frame);
};
const play = function play(): void {
if (running || document.hidden) {
return;
}
running = true;
last = performance.now();
requestAnimationFrame(frame);
};
const pause = function pause(): void {
running = false;
};
const onVisibility = function onVisibility(): void {
if (document.hidden) {
pause();
return;
}
play();
};
document.addEventListener("visibilitychange", onVisibility);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
play();
} else {
pause();
}
}
},
{ threshold: VISIBLE_FRACTION },
);
observer.observe(shell);
return () => {
pause();
observer.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
};