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