feat(ui): copy the flow or the map as an image, for a PR comment or a README (CG-55)

"Copy image" and "Download SVG" on the Flow strip's header and in the Map's
side panel. The image is the distribution loop: a flow pasted into a review, a
map pasted into a README, read by somebody with no viewer open.

The exporter serialises the LAYOUT OBJECT rather than scraping the DOM — no
html-to-image, no foreignObject, no new dependency. buildFlowLayout and
buildMapLayout already compute every rectangle, port and curve before a
component renders, so the image and the screen come from one piece of
arithmetic and cannot drift apart, and the whole exporter is a pure function a
test runs with no browser. Output is presentation-only SVG (rect, line, path,
polygon, text, tspan, clipPath) — no script, no external reference, no data:
URL — which is what GitHub's sanitiser accepts in a README.

Light theme is forced whatever the viewer is set to: a dark strip on GitHub's
white comment background reads as a mistake, not a preference. 24px of paper
around the drawing, a caption naming the path or the root at the bottom left,
a CodeGraph mark at the bottom right.

Fonts travel as family stacks, not bytes (spec). An SVG loaded as an image may
not fetch a webfont, so a raster falls back to the platform's own monospace —
every fallback in the stack advances at ~0.6em like IBM Plex Mono, so the code
grid survives and only the letterforms change. Text is truncated
arithmetically with an ellipsis and clipped as well, so a wider fallback
cannot spill a source line out of a card.

`scale` multiplies only the root width/height while the viewBox stays in CSS
pixels, so the raster draws an image whose intrinsic size is already 2x
instead of upscaling a 1x bitmap. The clipboard write uses the ClipboardItem
promise form (Safari discards the gesture across an await) and falls back to
downloading the PNG, saying which happened rather than claiming a copy it did
not make.

Measured on this repo: execute -> rowToFileRecord (8 hops) exports 3690x253
CSS px, 491 kB PNG at 2x / 38 kB SVG; the 16-module map reproduces the canvas
exactly — 16 boxes, 52 links, 9 layer rules, both band labels, and with
src/index.ts selected 15 links and 4 dimmed boxes.
This commit is contained in:
Colby McHenry
2026-08-27 05:41:34 -05:00
parent 94f4e287e6
commit 8ac0138940
12 changed files with 1837 additions and 1 deletions
+106
View File
@@ -0,0 +1,106 @@
/**
* Getting an SVG string out of the browser: as a PNG on the clipboard, or as a
* file on disk.
*
* `export-svg.ts` does the drawing and has no browser in it at all; everything
* that needs a `document` is here, and it is deliberately thin — a canvas, an
* `<img>`, and two ways of handing the result over.
*
* ## Why an `<img>` and not `foreignObject`
*
* A canvas rasterises an SVG by loading it as an image, which is a strictly
* sandboxed context: no scripts, no network, and **no webfonts**. That is why
* the export draws real `<text>` in a font *stack* rather than embedding
* anything — the raster falls back to the platform's own monospace, which
* advances at the same ~0.6em, so the code grid survives. It is also why the
* canvas is never tainted and `toBlob` works: nothing external is referenced.
*
* The scale trick matters. The SVG is asked for at `scale`, which multiplies
* only the root `width`/`height` while the `viewBox` stays in CSS pixels — so
* the image's *intrinsic* size is already 2x and `drawImage` copies it 1:1
* instead of upscaling a 1x bitmap. Text comes out rasterised at 2x, not blurry.
*/
/** Device-pixel multiplier for the PNG (design spec §3.9). */
export const PNG_SCALE = 2;
export function svgDataUrl(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
/** The SVG's own root width/height, which the raster canvas has to match. */
export function svgPixelSize(svg: string): { width: number; height: number } {
const width = Number(/\bwidth="(\d+(?:\.\d+)?)"/.exec(svg)?.[1] ?? 0);
const height = Number(/\bheight="(\d+(?:\.\d+)?)"/.exec(svg)?.[1] ?? 0);
return { width: Math.max(1, Math.round(width)), height: Math.max(1, Math.round(height)) };
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('The image could not be rendered.'));
img.src = url;
});
}
/** Rasterise an already-scaled SVG string to a PNG blob. */
export async function svgToPng(svg: string): Promise<Blob> {
const { width, height } = svgPixelSize(svg);
const img = await loadImage(svgDataUrl(svg));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx === null) throw new Error('This browser would not give up a 2D canvas.');
ctx.drawImage(img, 0, 0, width, height);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('The image could not be encoded.'))),
'image/png'
);
});
}
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
// Revoked on the next tick: Safari has not finished with the URL when click()
// returns, and a revoked object URL downloads a zero-byte file.
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
export function downloadSvg(svg: string, filename: string): void {
downloadBlob(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }), filename);
}
/**
* Put a PNG on the clipboard, falling back to a download.
*
* `ClipboardItem` is constructed with the *promise*, not the awaited blob:
* Safari discards the user gesture across an `await`, and a copy that silently
* does nothing is the worst outcome of the three. Returns what actually
* happened so the caller can say so rather than claim a copy it did not make.
*/
export async function copyPngToClipboard(
render: () => Promise<Blob>,
filename: string
): Promise<'copied' | 'downloaded'> {
const write = navigator.clipboard?.write;
if (typeof write === 'function' && typeof ClipboardItem === 'function') {
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': render() })]);
return 'copied';
} catch {
// Denied permission, an unfocused document, or a browser that will not
// take a promise. Fall through — the reader still gets their image.
}
}
downloadBlob(await render(), filename);
return 'downloaded';
}
+966
View File
@@ -0,0 +1,966 @@
/**
* The Flow strip and the Map, as a standalone SVG (design spec §3.9).
*
* This is the distribution loop: a flow pasted into a PR review, a map pasted
* into a README. Both have to survive leaving the app — the reader has no
* viewer, no hover, no side panel and no way to ask a follow-up question, so
* what the picture says has to be everything it claims.
*
* ## Serialised from the layout, never scraped from the DOM
*
* The obvious way to do this is `html-to-image`: walk the rendered nodes, inline
* every computed style, foreignObject the result. It was not taken. The strip
* and the map are already **pure functions of a layout object** — `buildFlowLayout`
* and `buildMapLayout` compute every rectangle, port and curve before anything
* renders, and both are unit-tested without a browser. Serialising that object
* gives an export that:
*
* - cannot disagree with the screen, because the same arithmetic produced both;
* - contains real `<text>`, so an SVG in a README is selectable and scales,
* rather than a `foreignObject` GitHub's sanitiser drops on sight;
* - costs no dependency, and works in a test with no DOM at all.
*
* The price is that every visual rule the components carry in CSS has to be
* restated here as numbers. That is the one thing to know before changing a
* card's padding or a module box's type scale: **it is stated twice**, in the
* component's `<style>` and in this file, and the two have to move together.
* The measurements that actually place things — heights, widths, columns — are
* NOT restated; they are imported from the layout models.
*
* ## Light, always
*
* An image pasted into a PR is read by people whose editors are set both ways,
* and a dark-mode screenshot on GitHub's white comment background reads as a
* mistake. So the export inlines the light token set as literal hex regardless
* of the viewer's theme — there is no `prefers-color-scheme` in a file someone
* else opens.
*
* ## Fonts are stacks, not bytes
*
* Per the spec: no embedding. The consequence is honest and worth stating —
* an exported SVG opened on a machine without IBM Plex Mono falls back through
* the stack to the platform's own monospace, and a PNG rasterised through an
* `<img>` always does, because an SVG loaded as an image may not fetch a
* webfont. Every fallback in the mono stack advances at ~0.6em like Plex Mono
* does, so the monospace grid the code windows depend on survives the swap;
* only the letterforms change. Embedding Plex Mono would add ~90 kB of base64
* to every export and put us over the PNG budget for nothing.
*
* Tested in `__tests__/ui-export-svg.test.ts`.
*/
import {
CARD_WIDTH,
CODE_LINE_HEIGHT,
CODE_PADDING,
END_CAP_LINE,
END_CAP_PADDING,
END_CAP_ROW,
END_CAP_GAP,
HEADER_HEIGHT,
endCapText,
type FlowCardLayout,
type FlowEndCapLayout,
type FlowLayout,
type FlowLinkLayout,
} from './flow-model';
import {
isEdgeVisible,
moduleMetaLabel,
type MapEdgeLayout,
type MapLayout,
type MapNodeLayout,
} from './map-model';
import { tokensByLine, type Token } from './highlight';
import { assignRefs, basename, type LineRef } from './symbol-model';
import { kindLetter, FILLED_KINDS } from './kinds';
/* ---------------------------------------------------------------- tokens -- */
/**
* The light token set (`ui/src/app.css`, the bare `:root` block), as literal
* hex. Copied deliberately rather than read from `getComputedStyle`: an export
* must not depend on a stylesheet having loaded, and must not follow the
* reader's theme into a dark image on a white page.
*/
export const EXPORT_COLORS = {
paper: '#f7f6f2',
paper2: '#f1efe8',
press: '#e8e6dd',
ink: '#16150f',
ink2: '#56544a',
ink3: '#87847a',
ink4: '#b4b1a5',
ruleSoft: '#d6d3c8',
ruleFaint: '#e6e3d9',
accent: '#7a2230',
accentSoft: '#f0e3e5',
accentLine: '#d9b3b9',
codeComment: '#6a675d',
} as const;
export const MONO_STACK =
"'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace";
export const SANS_STACK =
"'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif";
/** Clear space between the drawing and the edge of the image (design spec §3.9). */
export const EXPORT_PADDING = 24;
/** Space between the bottom of the drawing and the mark under it. */
export const MARK_GAP = 14;
export const MARK_SIZE = 11;
export const MARK_TEXT = 'CodeGraph';
/** Device-pixel multiplier for a rasterised export. */
export const EXPORT_SCALE = 2;
/** Advance of a monospace character, as a fraction of the font size. */
const MONO_ADVANCE = 0.6;
/** Rough advance of the sans stack, for truncation guards only. */
const SANS_ADVANCE = 0.53;
const monoWidth = (chars: number, size: number): number => chars * size * MONO_ADVANCE;
/* ------------------------------------------------------------ primitives -- */
export function esc(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
interface TextOptions {
x: number;
y: number;
size: number;
fill: string;
family?: string;
weight?: number;
anchor?: 'start' | 'middle' | 'end';
/** Keep runs of spaces — every code line needs this. */
preserve?: boolean;
}
function textOpen(o: TextOptions): string {
const parts = [
`x="${round(o.x)}"`,
`y="${round(o.y)}"`,
`font-family="${o.family ?? SANS_STACK}"`,
`font-size="${o.size}"`,
`fill="${o.fill}"`,
];
if (o.weight && o.weight !== 400) parts.push(`font-weight="${o.weight}"`);
if (o.anchor && o.anchor !== 'start') parts.push(`text-anchor="${o.anchor}"`);
if (o.preserve) parts.push('xml:space="preserve"');
return `<text ${parts.join(' ')}>`;
}
function textEl(o: TextOptions, content: string): string {
return `${textOpen(o)}${content}</text>`;
}
/** Numbers are rounded to a tenth: an SVG full of 17 decimals is unreadable. */
function round(n: number): number {
return Math.round(n * 10) / 10;
}
function rect(
x: number,
y: number,
w: number,
h: number,
attrs: {
fill?: string;
stroke?: string;
strokeWidth?: number;
dash?: string;
} = {}
): string {
const parts = [
`x="${round(x)}"`,
`y="${round(y)}"`,
`width="${round(w)}"`,
`height="${round(h)}"`,
`fill="${attrs.fill ?? 'none'}"`,
];
if (attrs.stroke) {
parts.push(`stroke="${attrs.stroke}"`, `stroke-width="${attrs.strokeWidth ?? 1}"`);
if (attrs.dash) parts.push(`stroke-dasharray="${attrs.dash}"`);
}
return `<rect ${parts.join(' ')} />`;
}
/**
* Cut a string to what fits, with an ellipsis — the arithmetic twin of the
* components' `text-overflow: ellipsis`.
*
* Done here rather than left to a clip path because a clip cuts mid-glyph and
* says nothing about having cut; the ellipsis is the same admission the screen
* makes. Clip paths are still applied over the top, as insurance against a
* fallback font that advances wider than the stack's first choice.
*/
export function truncate(text: string, maxWidth: number, size: number, advance = MONO_ADVANCE): string {
const per = size * advance;
const fits = Math.floor(maxWidth / per);
if (fits >= text.length) return text;
if (fits <= 1) return text.slice(0, Math.max(0, fits));
return `${text.slice(0, fits - 1)}…`;
}
/**
* Greedy word wrap at a character count.
*
* The end cap's height was estimated by `endCapHeight` at
* `ceil(length / END_CAP_CHARS)` lines, which is a *character* count and can
* come out one line short of a real word wrap. The export therefore measures
* its caps from these lines rather than from that estimate — the screen can let
* a `min-height` box grow, an image cannot.
*/
export function wrapText(text: string, chars: number): string[] {
const words = text.split(/\s+/).filter(Boolean);
if (words.length === 0) return [''];
const lines: string[] = [];
let line = '';
for (const word of words) {
if (line.length === 0) line = word;
else if (line.length + 1 + word.length <= chars) line = `${line} ${word}`;
else {
lines.push(line);
line = word;
}
}
lines.push(line);
return lines;
}
/* ------------------------------------------------------------- the frame -- */
export interface ExportOptions {
/**
* Device-pixel multiplier written into the root `width`/`height`.
*
* The `viewBox` always stays in CSS pixels, so the geometry inside is
* identical at every scale — 1 for a file someone will open, 2 for the raster
* step, which then draws the image at its own intrinsic size and gets crisp
* text instead of an upscaled bitmap.
*/
scale?: number;
/** A line of context under the drawing, left of the mark. */
caption?: string | null;
}
interface Frame {
/** Tight bounds of the drawing, before the export's own padding. */
minX: number;
minY: number;
width: number;
height: number;
}
/**
* Wrap a drawing in paper, padding and the mark.
*
* `body` is emitted inside a translate that moves the drawing's own origin to
* the padded corner, so every caller can keep working in layout coordinates.
*/
function document_(frame: Frame, defs: string, body: string, options: ExportOptions): string {
const scale = options.scale ?? 1;
const markRow = MARK_GAP + MARK_SIZE;
const width = Math.max(1, Math.round(frame.width + EXPORT_PADDING * 2));
const height = Math.max(1, Math.round(frame.height + EXPORT_PADDING * 2 + markRow));
const markY = height - EXPORT_PADDING;
const caption =
options.caption == null || options.caption === ''
? ''
: textEl(
{
x: EXPORT_PADDING,
y: markY,
size: MARK_SIZE,
fill: EXPORT_COLORS.ink3,
family: MONO_STACK,
},
esc(
truncate(
options.caption,
width - EXPORT_PADDING * 2 - monoWidth(MARK_TEXT.length + 3, MARK_SIZE),
MARK_SIZE
)
)
);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(width * scale)}" height="${Math.round(height * scale)}" viewBox="0 0 ${width} ${height}" font-family="${SANS_STACK}">`,
defs === '' ? '' : `<defs>${defs}</defs>`,
rect(0, 0, width, height, { fill: EXPORT_COLORS.paper }),
`<g transform="translate(${round(EXPORT_PADDING - frame.minX)},${round(EXPORT_PADDING - frame.minY)})">`,
body,
'</g>',
caption,
textEl(
{
x: width - EXPORT_PADDING,
y: markY,
size: MARK_SIZE,
fill: EXPORT_COLORS.ink3,
family: MONO_STACK,
anchor: 'end',
},
MARK_TEXT
),
'</svg>',
]
.filter(Boolean)
.join('\n');
}
/* ------------------------------------------------------------ flow strip -- */
/** Card header: glyph box, name, `file:line` — mirrors `FlowCard.svelte`. */
const CARD_PAD_X = 12;
const GLYPH_SIZE = 16;
const GLYPH_TOP = 9;
const HEAD_BASELINE = 21.5;
const NAME_SIZE = 13;
const LOC_SIZE = 11;
/** `.ln` grid: `40px | 1fr | 6px`, line numbers right-aligned 10px inside. */
const GUTTER = 40;
const CODE_RIGHT = 6;
const CODE_SIZE = 12;
const CODE_BASELINE = 13.5;
const LINE_NO_SIZE = 11;
/** `.flabel text` — 11px mono, stacked 13px apart above the connector. */
const LINK_LABEL_SIZE = 11;
export interface FlowExportOptions extends ExportOptions {
/** The picked flow's id — its cards keep the accent border, as on screen. */
activeFlowId?: string | null;
/** More than one path is drawn, so off-path cards dim. */
showAll?: boolean;
}
/** Where a link leaves and arrives: the vertical middle of each card's side. */
function portOf(node: { x: number; y: number; width: number; height: number }): {
right: [number, number];
left: [number, number];
} {
return {
right: [node.x + node.width, node.y + node.height / 2],
left: [node.x, node.y + node.height / 2],
};
}
function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): string {
const hop = card.hop;
const source = hop.source;
const out: string[] = [];
const clip = `c${card.column}-${Math.round(card.y)}`;
out.push(
rect(card.x, card.y, card.width, card.height, {
fill: EXPORT_COLORS.paper,
stroke: current ? EXPORT_COLORS.accent : EXPORT_COLORS.ruleSoft,
})
);
// --- header --------------------------------------------------------------
const letter = kindLetter(hop.node.kind);
const gx = card.x + CARD_PAD_X;
const gy = card.y + GLYPH_TOP;
out.push(
rect(gx, gy, GLYPH_SIZE, GLYPH_SIZE, {
fill: FILLED_KINDS.has(hop.node.kind) ? EXPORT_COLORS.press : 'none',
stroke: EXPORT_COLORS.ink3,
dash: hop.node.kind === 'file' ? '2 2' : undefined,
})
);
if (letter !== '') {
const glyphSize = letter.length > 1 ? 8.5 : 9.5;
out.push(
textEl(
{
x: gx + GLYPH_SIZE / 2,
y: gy + GLYPH_SIZE / 2 + glyphSize * 0.36,
size: glyphSize,
fill: EXPORT_COLORS.ink2,
family: MONO_STACK,
weight: 500,
anchor: 'middle',
},
esc(letter)
)
);
}
const loc = `${basename(hop.node.file)}:${hop.node.line}`;
const locWidth = monoWidth(loc.length, LOC_SIZE);
const nameX = gx + GLYPH_SIZE + 8;
const nameRoom = card.x + card.width - CARD_PAD_X - locWidth - 8 - nameX;
out.push(
textEl(
{
x: nameX,
y: card.y + HEAD_BASELINE,
size: NAME_SIZE,
fill: EXPORT_COLORS.ink,
family: MONO_STACK,
weight: 600,
},
esc(truncate(hop.node.name, nameRoom, NAME_SIZE))
),
textEl(
{
x: card.x + card.width - CARD_PAD_X,
y: card.y + HEAD_BASELINE,
size: LOC_SIZE,
fill: EXPORT_COLORS.ink3,
family: MONO_STACK,
anchor: 'end',
},
esc(loc)
),
`<line x1="${round(card.x)}" y1="${round(card.y + HEADER_HEIGHT)}" x2="${round(card.x + card.width)}" y2="${round(card.y + HEADER_HEIGHT)}" stroke="${EXPORT_COLORS.ruleFaint}" stroke-width="1" />`
);
// --- source window -------------------------------------------------------
const lines = source?.lines ?? [];
if (lines.length === 0) {
const why = source?.drift
? 'Changed on disk after the last index sync — source is not shown.'
: (source?.reason ?? 'Source outside this slice or this index.');
out.push(
textEl(
{
x: card.x + CARD_PAD_X,
y: card.y + HEADER_HEIGHT + 6 + CODE_BASELINE,
size: CODE_SIZE,
fill: EXPORT_COLORS.ink3,
},
esc(truncate(why, card.width - CARD_PAD_X * 2, CODE_SIZE, SANS_ADVANCE))
)
);
return `<g${dimmed ? ' opacity="0.4"' : ''}>${out.join('')}</g>`;
}
const refs = new Map<number, LineRef[]>();
const callRef = hop.callRef;
if (callRef) {
refs.set(callRef.line, [
{
ident: callRef.name,
col: callRef.col,
targetId: callRef.targetId,
uncertain: false,
outside: false,
title: '',
},
]);
}
const tokens = tokensByLine(lines, source?.from ?? 1, source?.highlight);
const textX = card.x + GUTTER;
const textRoom = card.width - GUTTER - CODE_RIGHT;
const maxChars = Math.floor(textRoom / (CODE_SIZE * MONO_ADVANCE));
const body: string[] = [];
lines.forEach((text, offset) => {
const n = (source?.from ?? 1) + offset;
const top = card.y + HEADER_HEIGHT + CODE_PADDING / 2 + offset * CODE_LINE_HEIGHT;
const lineTokens: Token[] = tokens.get(n) ?? [{ cls: 'other', text, col: 0 }];
const claimed = assignRefs(lineTokens, refs.get(n) ?? []);
if (n === callRef?.line || n === card.stopLine) {
body.push(
rect(card.x, top, card.width, CODE_LINE_HEIGHT, { fill: EXPORT_COLORS.accentSoft })
);
}
body.push(
textEl(
{
x: card.x + GUTTER - 10,
y: top + CODE_BASELINE,
size: LINE_NO_SIZE,
fill: EXPORT_COLORS.ink4,
family: MONO_STACK,
anchor: 'end',
},
String(n)
)
);
// One <text> per line with a tspan per token: monospace flows naturally, so
// nothing has to be positioned by column — which is also what keeps the
// indentation intact under `xml:space="preserve"`.
const spans: string[] = [];
let used = 0;
let underline: { from: number; length: number } | null = null;
lineTokens.forEach((token, index) => {
if (used >= maxChars) return;
const room = maxChars - used;
const cut = token.text.length > room;
const shown = cut ? `${token.text.slice(0, Math.max(0, room - 1))}…` : token.text;
const ref = claimed.get(index) ?? null;
if (ref) underline = { from: used, length: shown.length };
spans.push(tokenSpan(shown, token, ref !== null));
used += token.text.length;
});
body.push(
textEl(
{
x: textX,
y: top + CODE_BASELINE,
size: CODE_SIZE,
fill: EXPORT_COLORS.ink,
family: MONO_STACK,
preserve: true,
},
spans.join('')
)
);
// The call site's underline, drawn rather than declared: `text-decoration`
// on a tspan is not reliably honoured by SVG rasterisers, and this is the
// one piece of colour in the window.
if (underline !== null) {
const u = underline as { from: number; length: number };
const x1 = textX + monoWidth(u.from, CODE_SIZE);
body.push(
`<line x1="${round(x1)}" y1="${round(top + CODE_BASELINE + 3)}" x2="${round(x1 + monoWidth(u.length, CODE_SIZE))}" y2="${round(top + CODE_BASELINE + 3)}" stroke="${EXPORT_COLORS.accentLine}" stroke-width="1" />`
);
}
});
out.push(`<g clip-path="url(#${clip})">${body.join('')}</g>`);
return `<g${dimmed ? ' opacity="0.4"' : ''}>${out.join('')}</g>`;
}
/** A code token as a tspan — the near-monochrome ramp of design spec §2.2. */
function tokenSpan(text: string, token: Token, isRef: boolean): string {
if (text === '') return '';
const escaped = esc(text);
if (isRef) return `<tspan fill="${EXPORT_COLORS.accent}">${escaped}</tspan>`;
switch (token.cls) {
case 'comment':
return `<tspan fill="${EXPORT_COLORS.codeComment}">${escaped}</tspan>`;
case 'string':
case 'number':
return `<tspan fill="${EXPORT_COLORS.ink2}">${escaped}</tspan>`;
case 'keyword':
return `<tspan font-weight="500">${escaped}</tspan>`;
default:
return `<tspan>${escaped}</tspan>`;
}
}
/** The cap's text as drawn rows, and the height they actually need. */
export interface CapRows {
rows: Array<{ text: string; kind: 'lead' | 'form' | 'mono' | 'soft' | 'body'; gapBefore: boolean }>;
height: number;
}
const END_CAP_CHARS_SANS = 32;
const END_CAP_CHARS_MONO = 26;
export function capRows(cap: FlowEndCapLayout): CapRows {
const text = endCapText(cap.boundary);
const rows: CapRows['rows'] = [];
const push = (
value: string,
kind: CapRows['rows'][number]['kind'],
gapBefore = false,
chars = END_CAP_CHARS_SANS
): void => {
wrapText(value, chars).forEach((line, i) =>
rows.push({ text: line, kind, gapBefore: gapBefore && i === 0 })
);
};
// On screen the bold lead and the sentence after it share one paragraph. At
// 32 characters a line the lead fills one on its own anyway, so the export
// gives it its own row and keeps the bold ink without a mid-line tspan.
push('Where the graph stops.', 'lead');
push(text.intro, 'body');
for (const site of text.sites) {
push(site.headline, 'form', true);
if (site.key !== null) push(`key ${site.key}`, 'mono', false, END_CAP_CHARS_MONO);
for (const note of site.notes) push(note, 'soft');
if (site.candidateHeading !== null) {
push(site.candidateHeading, 'soft');
for (const candidate of site.candidates) {
push(
`${candidate.display} ${basename(candidate.node.file)}:${candidate.node.line}`,
'mono',
false,
END_CAP_CHARS_MONO
);
}
} else if (site.candidateNote !== null) {
push(site.candidateNote, 'soft');
}
}
if (text.quiet !== null) push(text.quiet, 'soft', true);
if (text.uncertainHeading !== null) {
push(text.uncertainHeading, 'soft', true);
for (const next of text.uncertain) {
push(
`${next.node.name} ${next.confidence === null ? '' : next.confidence.toFixed(2)}`,
'mono',
false,
END_CAP_CHARS_MONO
);
}
}
if (text.further !== null) push(text.further, 'body', true);
if (text.missed !== null) push(text.missed, 'body', true);
let height = END_CAP_PADDING * 2;
for (const row of rows) {
if (row.gapBefore) height += END_CAP_GAP;
height += row.kind === 'mono' ? END_CAP_ROW : END_CAP_LINE;
}
return { rows, height: Math.round(height) };
}
function flowCapSvg(cap: FlowEndCapLayout, dimmed: boolean): string {
const { rows, height } = capRows(cap);
const out: string[] = [
rect(cap.x, cap.y, cap.width, Math.max(cap.height, height), {
fill: EXPORT_COLORS.paper,
stroke: EXPORT_COLORS.ruleSoft,
dash: '3 3',
}),
];
let y = cap.y + END_CAP_PADDING;
const x = cap.x + END_CAP_PADDING;
const room = cap.width - END_CAP_PADDING * 2;
for (const row of rows) {
if (row.gapBefore) y += END_CAP_GAP;
const mono = row.kind === 'mono';
const step = mono ? END_CAP_ROW : END_CAP_LINE;
const fill =
row.kind === 'form' || row.kind === 'lead'
? EXPORT_COLORS.ink
: row.kind === 'soft'
? EXPORT_COLORS.ink3
: EXPORT_COLORS.ink2;
out.push(
textEl(
{
x,
y: y + step * 0.75,
size: mono ? 11.5 : 12,
fill,
family: mono ? MONO_STACK : SANS_STACK,
weight: row.kind === 'lead' ? 600 : 400,
},
esc(truncate(row.text, room, mono ? 11.5 : 12, mono ? MONO_ADVANCE : SANS_ADVANCE))
)
);
y += step;
}
return `<g${dimmed ? ' opacity="0.4"' : ''}>${out.join('')}</g>`;
}
function flowLinkSvg(
link: FlowLinkLayout,
from: { x: number; y: number; width: number; height: number },
to: { x: number; y: number; width: number; height: number },
dimmed: boolean
): string {
const [sx, sy] = portOf(from).right;
const [tx, ty] = portOf(to).left;
const path =
Math.abs(sy - ty) < 0.5
? `M${round(sx)},${round(sy)} L${round(tx)},${round(ty)}`
: `M${round(sx)},${round(sy)} C${round((sx + tx) / 2)},${round(sy)} ${round((sx + tx) / 2)},${round(ty)} ${round(tx)},${round(ty)}`;
const out: string[] = [
`<path d="${path}" fill="none" stroke="${EXPORT_COLORS.ink3}" stroke-width="1"${link.dash ? ` stroke-dasharray="${link.dash}"` : ''} />`,
];
if (!link.cap) {
out.push(
`<polygon points="${round(tx - 10)},${round(ty - 4)} ${round(tx - 2)},${round(ty)} ${round(tx - 10)},${round(ty + 4)}" fill="${EXPORT_COLORS.ink3}" />`
);
}
const labelX = (sx + tx) / 2;
const labelY = (sy + ty) / 2;
link.labelLines.forEach((line, i) => {
out.push(
textEl(
{
x: labelX,
y: labelY - 8 - (link.labelLines.length - 1 - i) * 13,
size: LINK_LABEL_SIZE,
fill: EXPORT_COLORS.ink3,
family: MONO_STACK,
anchor: 'middle',
},
esc(line)
)
);
});
if (link.lineLabel) {
out.push(
textEl(
{
x: labelX,
y: labelY + 17,
size: LINK_LABEL_SIZE,
fill: EXPORT_COLORS.ink3,
family: MONO_STACK,
anchor: 'middle',
},
esc(link.lineLabel)
)
);
}
return `<g${dimmed ? ' opacity="0.4"' : ''}>${out.join('')}</g>`;
}
/** The Flow strip as a standalone SVG. */
export function flowSvg(layout: FlowLayout, options: FlowExportOptions = {}): string {
const showAll = options.showAll ?? false;
const active = options.activeFlowId ?? null;
const onActive = (flows: string[]): boolean => active === null || flows.includes(active);
const boxes = new Map<string, { x: number; y: number; width: number; height: number }>();
for (const card of layout.cards) boxes.set(card.id, card);
const capHeights = new Map<string, number>();
for (const cap of layout.endCaps) {
const height = Math.max(cap.height, capRows(cap).height);
capHeights.set(cap.id, height);
boxes.set(cap.id, { x: cap.x, y: cap.y, width: cap.width, height });
}
const body: string[] = [];
const defs: string[] = [];
for (const link of layout.links) {
const from = boxes.get(link.source);
const to = boxes.get(link.target);
if (!from || !to) continue;
body.push(flowLinkSvg(link, from, to, showAll && !onActive(link.flows)));
}
for (const cap of layout.endCaps) {
body.push(flowCapSvg(cap, showAll && !onActive(cap.flows)));
}
for (const card of layout.cards) {
defs.push(
`<clipPath id="c${card.column}-${Math.round(card.y)}">${rect(card.x, card.y + HEADER_HEIGHT, card.width, card.height - HEADER_HEIGHT)}</clipPath>`
);
body.push(
flowCardSvg(card, showAll && card.step < 0, showAll && card.step >= 0)
);
}
// Tight bounds over everything drawn, including the label stacks that sit
// above and below a connector — the layout's own width/height cover the cards
// but not a two-line synthesized label on the top row.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const grow = (x: number, y: number, w = 0, h = 0): void => {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + w);
maxY = Math.max(maxY, y + h);
};
for (const box of boxes.values()) grow(box.x, box.y, box.width, box.height);
for (const link of layout.links) {
const from = boxes.get(link.source);
const to = boxes.get(link.target);
if (!from || !to) continue;
const y = (from.y + from.height / 2 + to.y + to.height / 2) / 2;
grow((from.x + from.width + to.x) / 2, y - 8 - link.labelLines.length * 13);
if (link.lineLabel) grow((from.x + from.width + to.x) / 2, y + 21);
}
if (!Number.isFinite(minX)) {
minX = 0;
minY = 0;
maxX = 1;
maxY = 1;
}
return document_(
{ minX, minY, width: maxX - minX, height: maxY - minY },
defs.join(''),
body.join('\n'),
options
);
}
/* -------------------------------------------------------------------- map -- */
const MODULE_NAME_SIZE = 13;
const MODULE_META_SIZE = 11;
const MODULE_PAD_X = 9;
const LAYER_LABEL_SIZE = 12;
/** How far a layer rule runs past the boxes it sits under. */
const LAYER_RULE_BLEED = 12;
export interface MapExportOptions extends ExportOptions {
/** The selected module, so the export draws the same edges the screen does. */
selected?: string | null;
}
function mapNodeSvg(node: MapNodeLayout, selected: boolean, dimmed: boolean): string {
const module = node.module;
const strokeWidth = selected ? 2 : 1;
const out: string[] = [
rect(node.x, node.y, node.width, node.height, {
fill: selected ? EXPORT_COLORS.press : EXPORT_COLORS.paper,
stroke: dimmed
? EXPORT_COLORS.ink4
: module.test
? EXPORT_COLORS.ink3
: EXPORT_COLORS.ink,
strokeWidth,
dash: module.test ? '4 3' : undefined,
}),
];
const room = node.width - MODULE_PAD_X * 2;
out.push(
textEl(
{
x: node.x + MODULE_PAD_X,
y: node.y + 17,
size: MODULE_NAME_SIZE,
fill: dimmed ? EXPORT_COLORS.ink4 : EXPORT_COLORS.ink,
family: MONO_STACK,
weight: 500,
},
esc(truncate(module.id, room, MODULE_NAME_SIZE))
),
textEl(
{
x: node.x + MODULE_PAD_X,
y: node.y + 31.5,
size: MODULE_META_SIZE,
fill: dimmed ? EXPORT_COLORS.ink4 : EXPORT_COLORS.ink3,
},
esc(truncate(moduleMetaLabel(module), room, MODULE_META_SIZE, SANS_ADVANCE))
)
);
return out.join('');
}
/** A port along a box's edge: `x = left + width x (i+1)/(n+1)`. */
function portX(node: MapNodeLayout, handles: readonly string[], id: string): number {
const index = handles.indexOf(id);
const total = handles.length;
if (index < 0 || total === 0) return node.x + node.width / 2;
return node.x + (node.width * (index + 1)) / (total + 1);
}
function mapEdgeSvg(
edge: MapEdgeLayout,
from: MapNodeLayout,
to: MapNodeLayout,
hot: boolean
): string {
const sx = portX(from, from.sourceHandles, edge.id);
const sy = from.y + from.height;
const tx = portX(to, to.targetHandles, edge.id);
const ty = to.y;
const midY = (sy + ty) / 2;
const path = `M${round(sx)},${round(sy)} C${round(sx)},${round(midY)} ${round(tx)},${round(midY)} ${round(tx)},${round(ty)}`;
if (edge.back) {
return `<path d="${path}" fill="none" stroke="${EXPORT_COLORS.accent}" stroke-opacity="0.6" stroke-dasharray="4 3" stroke-width="${round(edge.width)}" />`;
}
return `<path d="${path}" fill="none" stroke="${EXPORT_COLORS.ink}" stroke-opacity="${hot ? 0.95 : 0.28}" stroke-width="${round(edge.width)}" />`;
}
/** The Map as a standalone SVG. */
export function mapSvg(layout: MapLayout, options: MapExportOptions = {}): string {
const selected = options.selected ?? null;
const nodes = new Map(layout.nodes.map((n) => [n.id, n]));
const neighbours =
selected === null
? null
: new Set<string>([
selected,
...layout.edges.flatMap((e) =>
e.source === selected ? [e.target] : e.target === selected ? [e.source] : []
),
]);
// Bounds come from the boxes, and the layer rules are then drawn to fit THEM
// — not to `layout.width`, which carries the canvas' own generous padding and
// would run the hairlines past the edge of the image.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const node of layout.nodes) {
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + node.width);
maxY = Math.max(maxY, node.y + node.height);
}
if (!Number.isFinite(minX)) {
minX = 0;
minY = 0;
maxX = 1;
maxY = 1;
}
const ruleLeft = minX - LAYER_RULE_BLEED;
const ruleRight = maxX + LAYER_RULE_BLEED;
const body: string[] = [];
// Layer rules first: they sit behind the boxes they explain, exactly as the
// canvas' back viewport portal puts them.
for (const row of layout.layers) {
body.push(
`<line x1="${round(ruleLeft)}" y1="${round(row.y)}" x2="${round(ruleRight)}" y2="${round(row.y)}" stroke="${EXPORT_COLORS.ruleFaint}" stroke-width="1" />`
);
if (row.label !== null) {
const y = row.index === 0 ? row.y + 40 : row.y - 36;
body.push(
textEl(
{ x: ruleLeft, y, size: LAYER_LABEL_SIZE, fill: EXPORT_COLORS.ink3 },
esc(row.label)
)
);
minY = Math.min(minY, y - LAYER_LABEL_SIZE);
maxY = Math.max(maxY, y + 4);
}
}
for (const edge of layout.edges) {
if (!isEdgeVisible(edge, selected)) continue;
const from = nodes.get(edge.source);
const to = nodes.get(edge.target);
if (!from || !to) continue;
body.push(mapEdgeSvg(edge, from, to, selected !== null && !edge.back));
}
for (const node of layout.nodes) {
body.push(
mapNodeSvg(node, selected === node.id, neighbours !== null && !neighbours.has(node.id))
);
}
return document_(
{ minX: ruleLeft, minY, width: ruleRight - ruleLeft, height: maxY - minY },
'',
body.join('\n'),
options
);
}
/* ------------------------------------------------------------- filenames -- */
/**
* A safe file stem — `codegraph-flow-execute-getfile`. No extension: the caller
* adds one, because the same picture goes out as both `.svg` and `.png`.
*/
export function exportFilename(kind: 'flow' | 'map', label: string): string {
const slug = label
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60)
.replace(/-+$/g, '');
return `codegraph-${kind}${slug ? `-${slug}` : ''}`;
}
export { CARD_WIDTH };