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);
    };
};