added product page

This commit is contained in:
Felix Klement committed 2026-09-18 16:56:44 +02:00
1 parent 4cda1ca41b
commit d97f541738
27 files changed
+1004 -69

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
/**
* Layouts of the CipherVerifier schematics. Each builder takes the labels of its group from the
* copy file and returns a wide composition (hand-placed) and a tall one (a spine list), both in
* the same primitives that Schematic.astro renders.
*/
import type { HowGroups } from './types';
type Groups = HowGroups;
type Pt = [number, number];
type Label = string | string[];
export interface Node { x: number; y: number; label?: Label; dot?: 'main' | 'sub' | 'none'; ring?: number; anchor?: 'start' | 'end' | 'middle'; dx?: number; dy?: number }
export interface Edge { points: Pt[]; tick?: boolean; faint?: boolean; dashed?: boolean }
export interface Box { x: number; y: number; w: number; h: number; dashed?: boolean }
export interface Text { x: number; y: number; text: Label; anchor?: 'start' | 'end' | 'middle'; strong?: boolean }
export interface Layout { w: number; h: number; nodes: Node[]; edges: Edge[]; boxes?: Box[]; texts?: Text[]; fontMain?: number; fontSub?: number }
export interface Pair { wide: Layout; tall: Layout }
const RING = 36;
const LINE = 21; // one line of a 17px label, in viewBox units
/* A ringed main node with its label centred below it and an optional muted line under that. */
function main(x: number, y: number, label: Label, sub?: Label, ring = RING): { nodes: Node[]; texts: Text[] } {
const lines = Array.isArray(label) ? label.length : 1;
const nodes: Node[] = [{ x, y, label, dot: 'main', ring, anchor: 'middle', dx: 0, dy: ring + 22 }];
const texts: Text[] = sub ? [{ x, y: y + ring + 22 + lines * LINE + 2, text: sub, anchor: 'middle' }] : [];
return { nodes, texts };
}
/* A plain dot with its label centred below. */
function dot(x: number, y: number, label: Label): Node {
return { x, y, label, dot: 'main', anchor: 'middle', dx: 0, dy: 28 };
}
/* Satellites fanning from a node to a column of dots. */
function fan(from: Pt, xs: number, ys: number[], labels: string[], anchor: 'start' | 'end'): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = ys.map((y, i) => ({ x: xs, y, label: labels[i], dot: 'sub', anchor, dx: anchor === 'end' ? -12 : 12 }));
const edges: Edge[] = ys.map((y) => ({ points: [from, [xs, y]], faint: true }));
return { nodes, edges };
}
const chain = (pts: Pt[], tick = true): Edge[] => pts.slice(1).map((p, i) => ({ points: [pts[i], p], tick }));
/* Tall composition: a spine of ringed nodes, each with its instruments hanging below its title. */
function spine(groups: { title: Label; items?: string[]; note?: string }[]): Layout {
const x = 60, ring = 28, labelX = 40, satX = 44, satY = 36, dy = 26;
const nodes: Node[] = [];
const edges: Edge[] = [];
const texts: Text[] = [];
let y = 56;
const ys: number[] = [];
groups.forEach((g) => {
ys.push(y);
const lines = Array.isArray(g.title) ? g.title.length : 1;
nodes.push({ x, y, label: g.title, dot: 'main', ring, dx: labelX, dy: 6 });
let bottom = y + (lines - 1) * 20;
if (g.note) { texts.push({ x: x + labelX, y: bottom + 22, text: g.note }); bottom += 22; }
(g.items ?? []).forEach((item, k) => {
const sy = bottom + satY + k * dy;
nodes.push({ x: x + satX, y: sy, label: item, dot: 'sub', dx: 12 });
edges.push({ points: [[x, y], [x + satX, sy]], faint: true });
});
const items = g.items?.length ?? 0;
bottom = items ? bottom + satY + (items - 1) * dy : bottom;
y = Math.max(bottom + 70, y + 110);
});
ys.slice(1).forEach((yy, i) => edges.unshift({ points: [[x, ys[i]], [x, yy]], tick: true }));
return { w: 400, h: y - 40, nodes, edges, texts, fontMain: 16, fontSub: 14 };
}
export function overview(g: Groups[0]): Pair {
const L = g.labels;
const y1 = 110, y2 = 330, ym = 220, fork = 190, join = 1050;
const sbom = main(340, y1, L.sbom, L.sbomSub), dt = main(640, y1, L.dt, L.dtSub);
const sast = main(340, y2, L.sast, L.sastSub), poly = main(640, y2, L.poly, L.polySub), prism = main(900, y2, L.prism, L.prismSub);
const wide: Layout = {
w: 1200, h: 470,
nodes: [dot(80, ym, L.repo), ...sbom.nodes, ...dt.nodes, ...sast.nodes, ...poly.nodes, ...prism.nodes, dot(1120, ym, L.report)],
edges: [
{ points: [[80, ym], [fork, ym], [fork, y1], [340, y1]], tick: true },
{ points: [[fork, ym], [fork, y2], [340, y2]], tick: true },
...chain([[340, y1], [640, y1]]),
{ points: [[640, y1], [join, y1], [join, ym], [1120, ym]], tick: true },
...chain([[340, y2], [640, y2], [900, y2]]),
{ points: [[900, y2], [join, y2], [join, ym]] },
],
texts: [...sbom.texts, ...dt.texts, ...sast.texts, ...poly.texts, ...prism.texts],
};
const tall = spine([
{ title: L.repo },
{ title: L.sbom, note: L.sbomSub }, { title: L.dt, note: L.dtSub },
{ title: L.sast, note: L.sastSub }, { title: L.poly, note: L.polySub }, { title: L.prism, note: L.prismSub },
{ title: L.report },
]);
return { wide, tall };
}
export function scanners(g: Groups[1]): Pair {
const L = g.labels;
const focal: Pt = [500, 280];
const ys = g.tools.map((_, i) => 60 + i * 27.5);
const tools = fan(focal, 210, ys, g.tools, 'end');
const one = main(500, 280, L.one, L.oneSub), cwe = main(700, 280, L.cwe, L.cweSub), merge = main(900, 280, L.merge, L.mergeSub), resolve = main(1090, 280, L.resolve, L.resolveSub);
const wide: Layout = {
w: 1200, h: 560,
nodes: [...tools.nodes, ...one.nodes, ...cwe.nodes, ...merge.nodes, ...resolve.nodes],
edges: [...tools.edges, ...chain([[500, 280], [700, 280], [900, 280], [1090, 280]])],
texts: [...one.texts, ...cwe.texts, ...merge.texts, ...resolve.texts],
};
const tall = spine([
{ title: g.title.split(',')[0], items: g.tools },
{ title: L.one, note: L.oneSub }, { title: L.cwe, note: L.cweSub }, { title: L.merge, note: L.mergeSub }, { title: L.resolve, note: L.resolveSub },
]);
return { wide, tall };
}
export function verdict(g: Groups[2]): Pair {
const L = g.labels;
const y = 300, top = 120;
const ctx = fan([380, y], 230, [100, 140, 180, 220], L.ctx, 'end');
const tools = fan([920, y], 1040, [150, 190, 230, 270], L.tools, 'start');
const outcomes = fan([1220, y], 1330, [240, 280, 320], L.outcomes, 'start');
const context = main(380, y, L.context), s1 = main(640, y, L.stage1), s2 = main(920, y, L.stage2), v = main(1220, y, L.verdict, L.carries);
const wide: Layout = {
w: 1460, h: 420,
nodes: [dot(140, y, L.finding), ...context.nodes, ...ctx.nodes, ...s1.nodes, ...s2.nodes, ...tools.nodes, ...v.nodes, ...outcomes.nodes],
edges: [
...ctx.edges, ...tools.edges, ...outcomes.edges,
...chain([[140, y], [380, y], [640, y], [920, y], [1220, y]]),
{ points: [[640, y], [640, top], [1220, top], [1220, y]], tick: true },
],
texts: [...v.texts, { x: 930, y: top - 12, text: L.certain, anchor: 'middle' }, { x: 780, y: y + 30, text: splitTwo(L.unsure), anchor: 'middle' }],
fontMain: 19, fontSub: 17,
};
const tall = spine([
{ title: L.finding },
{ title: L.context, items: L.ctx },
{ title: L.stage1, items: [L.certain, L.unsure] },
{ title: L.stage2, items: L.tools },
{ title: L.verdict, note: L.carries, items: L.outcomes },
]);
return { wide, tall };
}
export function dependencies(g: Groups[3]): Pair {
const L = g.labels;
const y = 220, ya = 130, yb = 310, mergeX = 300;
const sbom = main(430, y, L.sbom, L.sbomSub), dt = main(700, y, L.dt, L.dtSub), vdr = main(960, y, L.vdr, L.vdrSub);
const wide: Layout = {
w: 1200, h: 400,
nodes: [
{ x: 150, y: ya, label: L.syft, dot: 'main', anchor: 'end', dx: -16, dy: 6 },
{ x: 150, y: yb, label: L.cdxgen, dot: 'main', anchor: 'end', dx: -16, dy: 6 },
...sbom.nodes, ...dt.nodes, ...vdr.nodes, dot(1130, y, L.report),
],
edges: [
{ points: [[150, ya], [mergeX, ya], [mergeX, y]] },
{ points: [[150, yb], [mergeX, yb], [mergeX, y]] },
{ points: [[mergeX, y], [430, y]], tick: true },
...chain([[430, y], [700, y], [960, y], [1130, y]]),
],
texts: [...sbom.texts, ...dt.texts, ...vdr.texts],
};
const tall = spine([
{ title: `${L.syft}, ${L.cdxgen}` },
{ title: L.sbom, items: toList(L.sbomSub) }, { title: L.dt, items: toList(L.dtSub) }, { title: L.vdr, items: toList(L.vdrSub) },
{ title: L.report },
]);
return { wide, tall };
}
export function operate(g: Groups[4]): Pair {
const L = g.labels;
const hub: Pt = [560, 220];
const ys = [100, 160, 220, 280, 340];
const sources = fan(hub, 200, ys, L.sources, 'end');
const outputs = fan(hub, 920, ys, L.outputs, 'start');
const wide: Layout = {
w: 1200, h: 540,
nodes: [
{ x: hub[0], y: hub[1], label: L.hub, dot: 'main', ring: 44, anchor: 'middle', dx: 0, dy: -58 },
...sources.nodes, ...outputs.nodes,
dot(420, 460, L.scopes[0]), dot(560, 460, L.scopes[1]), dot(700, 460, L.scopes[2]),
],
edges: [
...sources.edges, ...outputs.edges,
{ points: [[560, 264], [560, 460]], faint: true },
...chain([[420, 460], [560, 460], [700, 460]]),
],
texts: [{ x: 540, y: 425, text: L.scopesTitle, anchor: 'end' }],
};
const tall = spine([
{ title: L.hub, items: L.sources },
{ title: g.title, items: L.outputs },
{ title: L.scopesTitle, items: L.scopes },
]);
return { wide, tall };
}
function toList(l: Label): string[] { return Array.isArray(l) ? l : [l]; }
/* Break a sentence into two roughly equal lines at a word boundary. */
function splitTwo(s: string): string[] {
const words = s.split(' ');
if (words.length < 3) return [s];
let best = 1, diff = Infinity;
for (let i = 1; i < words.length; i++) {
const a = words.slice(0, i).join(' ').length, b = words.slice(i).join(' ').length;
if (Math.abs(a - b) < diff) { diff = Math.abs(a - b); best = i; }
}
return [words.slice(0, best).join(' '), words.slice(best).join(' ')];
}
export const builders = [overview, scanners, verdict, dependencies, operate] as const;