217 lines
11 KiB
TypeScript
217 lines
11 KiB
TypeScript
/**
|
|
* 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<string, string[]> }
|
|
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;
|