/** * 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; group?: string } export interface Edge { points: Pt[]; tick?: boolean; faint?: boolean; dashed?: boolean; group?: string } 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; group?: string } /* `lit` names, per group, the other groups a hover on it keeps lit: a path that leaves one node and ends at another belongs to both. */ export interface Layout { w: number; h: number; nodes: Node[]; edges: Edge[]; boxes?: Box[]; texts?: Text[]; fontMain?: number; fontSub?: number; lit?: Record } 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, group?: string): { 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, group }]; const texts: Text[] = sub ? [{ x, y: y + ring + 22 + lines * LINE + 2, text: sub, anchor: 'middle', group }] : []; return { nodes, texts }; } /* A plain dot with its label centred below. */ function dot(x: number, y: number, label: Label, group?: string): Node { return { x, y, label, dot: 'main', anchor: 'middle', dx: 0, dy: 28, group }; } /* Satellites fanning from a node to a column of dots. */ function fan(from: Pt, xs: number, ys: number[], labels: string[], anchor: 'start' | 'end', group?: string): { 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, group })); const edges: Edge[] = ys.map((y) => ({ points: [from, [xs, y]], faint: true, group })); 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 = 90, y2 = 300, ym = 195, fork = 190, join = 1050; const sbom = main(340, y1, L.sbom, L.sbomSub, RING, 'sbom'), dt = main(640, y1, L.dt, L.dtSub, RING, 'dt'); const sast = main(340, y2, L.sast, L.sastSub, RING, 'sast'), poly = main(640, y2, L.poly, L.polySub, RING, 'poly'), prism = main(900, y2, L.prism, L.prismSub, RING, 'prism'); const wide: Layout = { w: 1200, h: 430, nodes: [dot(80, ym, L.repo, 'repo'), ...sbom.nodes, ...dt.nodes, ...sast.nodes, ...poly.nodes, ...prism.nodes, dot(1120, ym, L.report, '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 cy = 232; const focal: Pt = [500, cy]; const ys = g.tools.map((_, i) => 40 + i * 24); const tools = fan(focal, 210, ys, g.tools, 'end', 'tools'); const one = main(500, cy, L.one, L.oneSub, RING, 'one'), cwe = main(700, cy, L.cwe, L.cweSub, RING, 'cwe'), merge = main(900, cy, L.merge, L.mergeSub, RING, 'merge'), resolve = main(1090, cy, L.resolve, L.resolveSub, RING, 'resolve'); const wide: Layout = { w: 1200, h: 450, nodes: [...tools.nodes, ...one.nodes, ...cwe.nodes, ...merge.nodes, ...resolve.nodes], edges: [...tools.edges, ...chain([[500, cy], [700, cy], [900, cy], [1090, cy]])], 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 = 260, top = 80; const ctx = fan([380, y], 230, [60, 100, 140, 180], L.ctx, 'end', 'context'); const tools = fan([920, y], 1040, [110, 150, 190, 230], L.tools, 'start', 'escalation'); const outcomes = fan([1220, y], 1330, [200, 240, 280], L.outcomes, 'start', 'verdict'); const context = main(380, y, L.context, undefined, RING, 'context'), s1 = main(640, y, L.stage1, undefined, RING, 'static'), s2 = main(920, y, L.stage2, undefined, RING, 'escalation'), v = main(1220, y, L.verdict, L.carries, RING, 'verdict'); const wide: Layout = { w: 1460, h: 380, nodes: [dot(140, y, L.finding, '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]]), { points: [[920, y], [1220, y]], tick: true, group: 'escalation' }, { points: [[640, y], [640, top], [1220, top], [1220, y]], tick: true, group: 'static' }, ], texts: [...v.texts, { x: 930, y: top - 12, text: L.certain, anchor: 'middle', group: 'static' }, { x: 780, y: y - 52, text: splitTwo(L.unsure), anchor: 'middle' }], fontMain: 19, fontSub: 17, lit: { static: ['verdict'], escalation: ['verdict'], verdict: ['static'] }, }; 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 = 170, ya = 80, yb = 260, mergeX = 300; const sbom = main(430, y, L.sbom, L.sbomSub, RING, 'sbom'), dt = main(700, y, L.dt, L.dtSub, RING, 'dt'), vdr = main(960, y, L.vdr, L.vdrSub, RING, 'vdr'); const wide: Layout = { w: 1200, h: 320, nodes: [ { x: 150, y: ya, label: L.syft, dot: 'main', anchor: 'end', dx: -16, dy: 6, group: 'generators' }, { x: 150, y: yb, label: L.cdxgen, dot: 'main', anchor: 'end', dx: -16, dy: 6, group: 'generators' }, ...sbom.nodes, ...dt.nodes, ...vdr.nodes, dot(1130, y, L.report, '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, 200]; const ys = [100, 150, 200, 250, 300]; const sources = fan(hub, 200, ys, L.sources, 'end', 'sources'); const outputs = fan(hub, 920, ys, L.outputs, 'start', 'outputs'); const sy = 410; const wide: Layout = { w: 1200, h: 470, nodes: [ { x: hub[0], y: hub[1], label: L.hub, dot: 'main', ring: 44, anchor: 'middle', dx: 0, dy: -58, group: 'hub' }, ...sources.nodes, ...outputs.nodes, dot(420, sy, L.scopes[0], 'scopes'), dot(560, sy, L.scopes[1], 'scopes'), dot(700, sy, L.scopes[2], 'scopes'), ], edges: [ ...sources.edges, ...outputs.edges, { points: [[560, 244], [560, sy]], faint: true, group: 'scopes' }, ...chain([[420, sy], [560, sy], [700, sy]]).map((e) => ({ ...e, group: 'scopes' })), ], texts: [{ x: 540, y: sy - 35, text: L.scopesTitle, anchor: 'end', group: 'scopes' }], }; 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;