presentation/components/learning.fragment.component.ts

presentation/components/learning.fragment.component.ts is a file in Bane's Lab Site. 174 lines of code and 44 definitions.

import type {
    CameraFit,
    Framed,
    Geometry,
    LearningMark,
    LearningSample,
    LearningSpan,
    LearningView,
    PlacedStop,
} from "#types/learning.types";
import {
    FAST_PACE,
    FOCUSED_CLASS,
    HIDE_EVENTS,
    LIFT,
    PIXEL_UNIT,
    REM_UNIT,
    REQUIRED_CLASS,
    SAMPLE_STEP,
    SHOW_EVENTS,
    SLOW_PACE,
    TOUCH_TARGET_TOKEN,
    ZOOM,
} from "#configuration/constants/learning.constants";
import type { Disposer } from "#types/base.types";

const isRecord = function isRecord(value: unknown): value is Record<string, unknown> {
    return typeof value === "object" && value !== null;
};

const isGeometry = function isGeometry(value: unknown): value is Geometry {
    return (
        isRecord(value) &&
        typeof value["getTotalLength"] === "function" &&
        typeof value["getPointAtLength"] === "function"
    );
};

const isBox = function isBox(value: unknown): boolean {
    return isRecord(value) && typeof value["width"] === "number" && typeof value["height"] === "number";
};

const isDashed = function isDashed(value: unknown): boolean {
    return isRecord(value) && "strokeDasharray" in value;
};

const measurable = function measurable(box: unknown, style: unknown): boolean {
    return isBox(box) && isDashed(style);
};

export const framedOf = function framedOf(view: LearningView): Framed | null {
    const geometry: unknown = view.trace;
    const style: unknown = isRecord(view.trace) ? view.trace["style"] : null;
    const viewBox: unknown = isRecord(view.vector) ? view.vector["viewBox"] : null;
    const box: unknown = isRecord(viewBox) ? viewBox["baseVal"] : null;
    if (!isGeometry(geometry) || !isRecord(box) || !isRecord(style) || !measurable(box, style)) {
        return null;
    }
    return { box, geometry, style };
};

const samplesOf = function samplesOf(geometry: Geometry, total: number): readonly LearningSample[] {
    const samples: LearningSample[] = [];
    for (let at = 0; at <= total; at += SAMPLE_STEP) {
        samples.push({ at, point: geometry.getPointAtLength(at) });
    }
    return samples;
};

export const marksOf = function marksOf(
    geometry: Geometry,
    stops: readonly PlacedStop[],
    total: number,
): LearningMark[] {
    const samples = samplesOf(geometry, total);
    return stops.map((stop) => {
        let best = 0;
        let nearest = Infinity;
        for (const { at, point } of samples) {
            const gap = Math.hypot(point.x - stop.cx, point.y - stop.cy);
            if (gap < nearest) {
                nearest = gap;
                best = at;
            }
        }
        return { at: best, lit: false, stop };
    });
};

export const spansOf = function spansOf(marks: readonly LearningMark[]): LearningSpan[] {
    const spans: LearningSpan[] = [];
    for (let index = 1; index < marks.length; index += 1) {
        const before = marks[index - 1];
        const here = marks[index];
        if (before !== undefined && here !== undefined) {
            spans.push({ fast: before.stop.block !== here.stop.block, from: before.at, to: here.at });
        }
    }
    return spans;
};

export const paceAt = function paceAt(spans: readonly LearningSpan[], at: number): number {
    const span = spans.find((held) => at >= held.from && at < held.to);
    return span?.fast === true ? FAST_PACE : SLOW_PACE;
};

export const widestOf = function widestOf(stops: readonly PlacedStop[]): number {
    return stops.reduce((held, stop) => Math.max(held, stop.width), 0);
};

export const shortestOf = function shortestOf(stops: readonly PlacedStop[]): number {
    return stops.length === 0 ? 0 : Math.min(...stops.map((stop) => stop.height));
};

export const pixelsOfLength = function pixelsOfLength(length: string, rootPixels: number): number {
    const value = length.trim();
    if (value.endsWith(REM_UNIT)) {
        return Number(value.slice(0, -REM_UNIT.length)) * rootPixels;
    }
    return value.endsWith(PIXEL_UNIT) ? Number(value.slice(0, -PIXEL_UNIT.length)) : 0;
};

export const touchTargetOf = function touchTargetOf(root: Element): number {
    const style = getComputedStyle(root);
    const pixels = pixelsOfLength(style.getPropertyValue(TOUCH_TARGET_TOKEN), pixelsOfLength(style.fontSize, 0));
    return Number.isFinite(pixels) ? pixels : 0;
};

export const aimOf = function aimOf(target: number, pixelRatio: number): number {
    return target > 0 ? target + 1 / (pixelRatio > 0 ? pixelRatio : 1) : target;
};

export const cameraFit = function cameraFit(
    shellWidth: number,
    stops: readonly PlacedStop[],
    target: number,
    coarse: boolean,
): CameraFit {
    const widest = widestOf(stops);
    const shortest = shortestOf(stops);
    if (target <= 0 || widest <= 0 || shortest <= 0) {
        return { lift: LIFT, zoom: ZOOM };
    }
    const fitting = (shellWidth * shortest) / (target * widest);
    const zoom = Math.max(1, Math.min(ZOOM, fitting));
    const lift = coarse ? Math.max(SLOW_PACE, Math.min(LIFT, fitting / zoom)) : LIFT;
    return { lift, zoom };
};

const lightRequired = function lightRequired(
    stop: PlacedStop,
    nodes: ReadonlyMap<string, Element>,
    shown: boolean,
): void {
    stop.node.classList.toggle(FOCUSED_CLASS, shown);
    for (const id of stop.requires) {
        nodes.get(id)?.classList.toggle(REQUIRED_CLASS, shown);
    }
};

export const attachPrerequisites = function attachPrerequisites(view: LearningView): Disposer {
    const nodes = new Map(view.stops.map((stop) => [stop.id, stop.node]));
    const detachers = view.stops.map((stop): Disposer => {
        const target = stop.node.parentElement ?? stop.node;
        const show = (): void => {
            lightRequired(stop, nodes, true);
        };
        const hide = (): void => {
            lightRequired(stop, nodes, false);
        };
        for (const name of SHOW_EVENTS) {
            target.addEventListener(name, show);
        }
        for (const name of HIDE_EVENTS) {
            target.addEventListener(name, hide);
        }
        return () => {
            for (const name of SHOW_EVENTS) {
                target.removeEventListener(name, show);
            }
            for (const name of HIDE_EVENTS) {
                target.removeEventListener(name, hide);
            }
            hide();
        };
    });
    return () => {
        for (const detach of detachers) {
            detach();
        }
    };
};