presentation/renderers/learning.renderer.ts

presentation/renderers/learning.renderer.ts is a file in Bane's Lab Site. 145 lines of code and 31 definitions.

import {
    BLOCK_RADIUS,
    EVEN_ODD,
    FILL_ATTRIBUTE,
    ICON_OFFSET,
    ICON_VIEWBOX,
    LABEL_OFFSET,
    LINE_HEIGHT,
    MARK_SIZE,
    PAD_X,
    PAD_Y,
    TAG_HEIGHT,
    TAG_LIFT,
    TAG_PAD,
    TAG_RADIUS,
    TEXT_BASELINE,
} from "#configuration/constants/learning.constants";
import type {
    LaidBlock,
    LaidStop,
    LearningModel,
    LearningPoint,
    LearningView,
    PlacedStop,
} from "#types/learning.types";
import { orthogonal, tracePoints } from "#domain/converters/learning.fragment.converter";
import { GRAMMAR_SIDEBAR_MARK } from "#core/assets/image.assets";
import { ICON_PATHS } from "#core/generated/glyph.generated";
import { createVectorNode } from "#core/factories/element.factory";
import { gridFor } from "#domain/converters/learning.grid.converter";
import { unfold } from "#domain/converters/learning.route.converter";
import { widthOf } from "#domain/converters/learning.converter";

const DECIMALS = 1;

const numbers = function numbers(record: Readonly<Record<string, number | string>>): Record<string, string> {
    return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, String(value)]));
};

const TONE_PREFIX = "tone-";

const toneDefs = function toneDefs(accents: readonly string[]): Element {
    const gradients = accents.map((accent) =>
        createVectorNode("linearGradient", {
            attributes: { id: TONE_PREFIX + accent, x1: "0", x2: "0", y1: "0", y2: "1" },
            children: [
                createVectorNode("stop", { attributes: { offset: "0%" }, className: "learning-tone-head" }),
                createVectorNode("stop", { attributes: { offset: "100%" }, className: "learning-tone-tail" }),
            ],
            className: accent,
        }),
    );
    return createVectorNode("defs", { children: gradients });
};

const markFor = function markFor(block: LaidBlock, x: number): Element {
    const paths = ICON_PATHS.get(block.icon);
    const box = numbers({ height: MARK_SIZE, width: MARK_SIZE, x, y: block.y + ICON_OFFSET - MARK_SIZE });
    if (paths === undefined) {
        return createVectorNode("image", {
            attributes: { ...box, href: GRAMMAR_SIDEBAR_MARK },
            className: "learning-mark",
        });
    }
    return createVectorNode("svg", {
        attributes: { ...box, viewBox: ICON_VIEWBOX },
        children: paths.map((path) =>
            createVectorNode("path", {
                attributes: path.evenOdd ? { d: path.d, [FILL_ATTRIBUTE]: EVEN_ODD } : { d: path.d },
            }),
        ),
        className: "learning-icon",
    });
};

const blockShell = function blockShell(block: LaidBlock): readonly Element[] {
    const shell = createVectorNode("rect", {
        attributes: {
            ...numbers({ height: block.height, rx: BLOCK_RADIUS, width: block.width, x: block.x, y: block.y }),
            fill: `url(#${TONE_PREFIX}${block.accent})`,
        },
        className: `learning-block ${block.accent}`,
    });
    const tagWidth = widthOf([block.label]) + TAG_PAD;
    const tagX = block.x + (block.width - tagWidth) / 2;
    const tag = createVectorNode("rect", {
        attributes: numbers({ height: TAG_HEIGHT, rx: TAG_RADIUS, width: tagWidth, x: tagX, y: block.y + 1 }),
        className: `learning-tag ${block.accent}`,
    });
    const mark = markFor(block, tagX + TAG_LIFT);
    const caption = createVectorNode("text", {
        attributes: numbers({ x: tagX + TAG_LIFT + LABEL_OFFSET, y: block.y + ICON_OFFSET }),
        className: "learning-label",
        text: block.label,
    });
    return [shell, tag, mark, caption];
};

const stopNode = function stopNode(stop: LaidStop, accent: string): Element {
    const box = createVectorNode("rect", {
        attributes: numbers({ height: stop.height, rx: TAG_RADIUS, width: stop.width, x: stop.x, y: stop.y }),
    });
    const label = createVectorNode("text", {
        attributes: numbers({ x: stop.x + PAD_X, y: stop.y + PAD_Y + TEXT_BASELINE }),
        children: stop.rows.map((text, row) =>
            createVectorNode("tspan", {
                attributes: numbers({ x: stop.x + PAD_X, y: stop.y + PAD_Y + TEXT_BASELINE + row * LINE_HEIGHT }),
                text,
            }),
        ),
    });
    return createVectorNode("g", {
        attributes: { id: stop.id },
        children: [box, label],
        className: `learning-stop ${accent}`,
    });
};

const stopLink = function stopLink(stop: LaidStop, group: Element): Element {
    return createVectorNode("a", { attributes: { href: stop.path }, children: [group], className: "learning-jump" });
};

const pathData = function pathData(points: readonly LearningPoint[]): string {
    return points
        .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(DECIMALS)} ${point.y.toFixed(DECIMALS)}`)
        .join(" ");
};

export const renderLearning = function renderLearning(model: LearningModel): LearningView {
    const parts = createVectorNode("g");
    const placed: PlacedStop[] = [];
    for (const block of model.blocks) {
        for (const element of blockShell(block)) {
            parts.append(element);
        }
        for (const stop of block.stops) {
            const node = stopNode(stop, block.accent);
            parts.append(stopLink(stop, node));
            placed.push({ ...stop, block, node });
        }
    }
    const grid = gridFor(model);
    const straight = orthogonal(grid, tracePoints(grid, placed));
    const data = pathData(unfold(straight, placed));
    const rail = createVectorNode("path", { attributes: { d: data }, className: "learning-rail" });
    const trace = createVectorNode("path", { attributes: { d: data }, className: "learning-trace" });
    const lines = createVectorNode("g", { children: [rail, trace] });
    const accents = [...new Set(model.blocks.map((block) => block.accent))];
    const vector = createVectorNode("svg", {
        attributes: { viewBox: `0 0 ${String(model.width)} ${String(model.height)}` },
        children: [toneDefs(accents), lines, parts],
        className: "learning-vector",
    });
    return { stops: placed, trace, vector };
};