core/renderers/grid.renderer.ts
core/renderers/grid.renderer.ts is a file in Bane's Lab Social Share. 183 lines of code and 37 definitions.
import {
CAPTION_CLASS,
FINDINGS_CLASS,
FRAME_CLASS,
GRID_CLASS,
HEADER_CLASS,
HEADING_CLASS,
KICKER_CLASS,
SPREAD_CLASS,
SPREAD_META_CLASS,
SPREAD_ROW_CLASS,
SPREAD_TITLE_CLASS,
TILE_CLASS,
} from "#configuration/constants/card.constants";
import {
CAPTION_SEPARATOR,
DIMENSION_SEPARATOR,
STAGE_HEADING,
SUBJECT_SEPARATOR,
} from "#configuration/strings/card.strings";
import type { CardFinding, CardPage, CardSpec, Profile, RegisteredCard } from "#types/card.types";
import { OUTPUT_RATE, PROFILES } from "#configuration/configs/card.config";
import type { Disposer } from "@banes-lab/web/types/base.types.ts";
import { ICON_BASE_CLASS } from "@banes-lab/web/configuration/icons/element.icons.ts";
import type { MountedCard } from "#types/stage.types";
import { SITE_NAME } from "@banes-lab/web/configuration/strings/page.strings.ts";
import { createElement } from "@banes-lab/web/core/factories/element.factory.ts";
import { declareStyle } from "@banes-lab/web/core/registries/style.registry.ts";
import { mountCard } from "#core/renderers/stage.renderer";
import { pixelValue } from "#core/evaluators/card.fragment.evaluator";
import { playWhileVisible } from "#core/timers/stage.timer";
import { resolveCard } from "#core/evaluators/card.evaluator";
const CLASS_SEPARATOR = " ";
interface Tile {
readonly element: HTMLElement;
readonly aspect: number;
readonly fit: (height: number) => void;
readonly stop: Disposer;
}
interface Spread {
readonly element: HTMLElement;
readonly stop: Disposer;
}
const pixels = function pixels(value: number): string {
return `${String(Math.round(value))}px`;
};
const messageOf = function messageOf(failure: unknown): string {
return failure instanceof Error ? failure.message : String(failure);
};
const findingNote = function findingNote(text: string): HTMLElement {
console.error(text);
return createElement("p", { className: FINDINGS_CLASS, text });
};
const playTile = function playTile(
mounted: MountedCard,
spec: CardSpec,
profile: Profile,
frame: HTMLElement,
): Disposer {
const report = (failure: unknown): void => {
mounted.report(failure);
};
mounted.ready().catch(report);
return playWhileVisible(
frame,
spec.timeline,
OUTPUT_RATE,
async (index) => mounted.paint(resolveCard(spec, profile, index)),
report,
);
};
const mountTile = function mountTile(frame: HTMLElement, spec: CardSpec, profile: Profile): MountedCard | null {
try {
return mountCard(frame, resolveCard(spec, profile, spec.timeline.keyFrame));
} catch (error) {
frame.append(findingNote(spec.id + SUBJECT_SEPARATOR + messageOf(error)));
return null;
}
};
const tileOf = function tileOf(spec: CardSpec, profile: Profile): Tile {
const frame = createElement("div", { className: FRAME_CLASS });
const mounted = mountTile(frame, spec, profile);
const stop = mounted === null ? (): void => undefined : playTile(mounted, spec, profile, frame);
const caption = [profile.id, String(profile.width) + DIMENSION_SEPARATOR + String(profile.height)].join(
CAPTION_SEPARATOR,
);
const element = createElement("figure", {
children: [createElement("figcaption", { className: CAPTION_CLASS, text: caption }), frame],
className: TILE_CLASS,
});
const fit = (height: number): void => {
const scale = height / profile.height;
declareStyle(frame, "width", pixels(profile.width * scale));
declareStyle(frame, "height", pixels(height));
if (mounted !== null) {
declareStyle(mounted.element, "transform", `scale(${String(scale)})`);
}
};
return { aspect: profile.width / profile.height, element, fit, stop };
};
const fitRow = function fitRow(row: HTMLElement, tiles: readonly Tile[]): void {
const gap = pixelValue(getComputedStyle(row).columnGap) || 0;
const room = row.clientWidth - gap * Math.max(0, tiles.length - 1);
const aspects = tiles.reduce((sum, tile) => sum + tile.aspect, 0);
const height = aspects > 0 && room > 0 ? Math.floor(room / aspects) : 0;
for (const tile of tiles) {
tile.fit(height);
}
};
const spreadOf = function spreadOf(
card: RegisteredCard,
page: CardPage | undefined,
findings: readonly CardFinding[],
): Spread {
const tiles = PROFILES.filter((profile) => card.spec.profiles.includes(profile.id)).map((profile) =>
tileOf(card.spec, profile),
);
const notes = findings
.filter((finding) => finding.card === card.id)
.map((finding) => findingNote(finding.card + SUBJECT_SEPARATOR + finding.message));
const meta = [page?.address ?? card.spec.page, card.id].join(CAPTION_SEPARATOR);
const row = createElement("div", { children: tiles.map((tile) => tile.element), className: SPREAD_ROW_CLASS });
const icon = createElement("i", { className: [ICON_BASE_CLASS, page?.icon ?? ""].join(CLASS_SEPARATOR) });
const element = createElement("section", {
children: [
createElement("h2", {
children: [icon, createElement("span", { text: page?.headline ?? card.id })],
className: SPREAD_TITLE_CLASS,
}),
createElement("p", { className: SPREAD_META_CLASS, text: meta }),
...notes,
row,
],
className: `${SPREAD_CLASS} ${card.spec.tone}`,
});
const sizes = new ResizeObserver(() => {
fitRow(row, tiles);
});
sizes.observe(row);
return {
element,
stop: () => {
sizes.disconnect();
for (const tile of tiles) {
tile.stop();
}
},
};
};
export const renderGrid = function renderGrid(
host: HTMLElement,
cards: readonly RegisteredCard[],
pages: readonly CardPage[],
findings: readonly CardFinding[],
): Disposer {
const known = new Set(cards.map((card) => card.id));
const orphans = findings
.filter((finding) => !known.has(finding.card))
.map((finding) => findingNote(finding.card + SUBJECT_SEPARATOR + finding.message));
const byPage = new Map(pages.map((page) => [page.id, page]));
const rank = new Map(pages.map((page, index) => [page.id, index]));
const ordered = cards.toSorted(
(left, right) => (rank.get(left.spec.page) ?? pages.length) - (rank.get(right.spec.page) ?? pages.length),
);
const spreads = ordered.map((card) => spreadOf(card, byPage.get(card.spec.page), findings));
const header = createElement("header", {
children: [
createElement("p", { className: KICKER_CLASS, text: SITE_NAME }),
createElement("h1", { className: HEADING_CLASS, text: STAGE_HEADING }),
],
className: HEADER_CLASS,
});
host.replaceChildren(
header,
...orphans,
createElement("div", { children: spreads.map((spread) => spread.element), className: GRID_CLASS }),
);
return () => {
for (const spread of spreads) {
spread.stop();
}
};
};