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
+101
View File
@@ -0,0 +1,101 @@
<!--
Take the picture with you (design spec §3.9).
Two buttons, because there are exactly two destinations: a PR comment, which
wants a PNG on the clipboard, and a README, which wants an SVG on disk. Both
render the light theme whatever the viewer is set to — an image is read on
somebody else's screen, and a dark strip on GitHub's white comment background
reads as a mistake rather than a preference.
The SVG is built lazily, at click time, from the layout the canvas is already
drawing. Nothing is measured, nothing is scraped, and the button costs nothing
until it is pressed.
-->
<script lang="ts">
import { copyPngToClipboard, downloadSvg, svgToPng, PNG_SCALE } from '../lib/export-image';
import { toast } from '../lib/toast.svelte';
interface Props {
/** Builds the SVG at a given device-pixel scale. */
build: (scale: number) => string;
/** File stem for the download, without an extension. */
filename: string;
disabled?: boolean;
}
let { build, filename, disabled = false }: Props = $props();
let busy = $state(false);
async function copyImage(): Promise<void> {
if (busy) return;
busy = true;
try {
const where = await copyPngToClipboard(
() => svgToPng(build(PNG_SCALE)),
`${filename}.png`
);
toast.show(
where === 'copied'
? 'Image copied · paste it into a comment'
: 'Clipboard unavailable · image saved instead'
);
} catch (error) {
toast.show(error instanceof Error ? error.message : 'The image could not be made.');
} finally {
busy = false;
}
}
function saveSvg(): void {
try {
downloadSvg(build(1), `${filename}.svg`);
toast.show('SVG saved');
} catch (error) {
toast.show(error instanceof Error ? error.message : 'The file could not be saved.');
}
}
</script>
<div class="exp">
<button type="button" onclick={copyImage} disabled={disabled || busy}>
{busy ? 'Rendering…' : 'Copy image'}
</button>
<button type="button" onclick={saveSvg} {disabled}>Download SVG</button>
</div>
<style>
.exp {
display: flex;
flex: 0 0 auto;
/* Pushed to the trailing edge of a flex header; inert in a block panel. */
margin-left: auto;
gap: 6px;
}
.exp button {
padding: 3px 8px;
background: var(--paper-2);
border: 1px solid var(--rule-soft);
border-radius: 0;
color: var(--ink-2);
cursor: pointer;
font: 12.5px var(--sans);
white-space: nowrap;
}
.exp button:hover:not(:disabled) {
background: var(--press);
border-color: var(--ink-3);
color: var(--ink);
}
.exp button:disabled {
color: var(--ink-4);
cursor: default;
}
.exp button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
</style>
+11
View File
@@ -11,6 +11,7 @@
can audit is a diagram that gets believed too much.
-->
<script lang="ts">
import ExportButtons from '../ExportButtons.svelte';
import { fileHref } from '../../lib/router.svelte';
import { plural } from '../../lib/symbol-model';
import type { WireMapLink, WireMapPayload } from '../../lib/api';
@@ -25,6 +26,10 @@
onToggleTests: (value: boolean) => void;
onSelectRoot: (root: string) => void;
onSelect: (id: string | null) => void;
/** Builds the map as an SVG at a given device-pixel scale. */
buildSvg: (scale: number) => string;
/** File stem for a downloaded map, without an extension. */
exportName: string;
}
let {
@@ -36,6 +41,8 @@
onToggleTests,
onSelectRoot,
onSelect,
buildSvg,
exportName,
}: Props = $props();
const selectedModule = $derived(
@@ -70,6 +77,10 @@
calls, imports and type references cross the link.
</p>
<!-- The map is the thing people paste into a README, so the way out sits
directly under the sentence explaining what it is. -->
<ExportButtons build={buildSvg} filename={exportName} />
<label class="field">
<span>Showing</span>
<select
+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 };
+29
View File
@@ -19,6 +19,8 @@
import FlowCard from '../components/flow/FlowCard.svelte';
import FlowLink from '../components/flow/FlowLink.svelte';
import FlowEndCap from '../components/flow/FlowEndCap.svelte';
import ExportButtons from '../components/ExportButtons.svelte';
import { exportFilename, flowSvg } from '../lib/export-svg';
import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
import { live } from '../lib/live.svelte';
import { navigate, symbolHref } from '../lib/router.svelte';
@@ -223,6 +225,30 @@
}
return 'The longest call path among the symbols you named, the same one codegraph_explore leads with.';
}
/**
* The strip as it stands, for a PR comment or a README.
*
* Built from `layout` — the same object the canvas is drawing — so the image
* cannot say something the screen does not. The caption names the path,
* because an image pasted into a review has lost the header that did.
*/
const exportLabel = $derived(
showAll && flows.length > 1
? `all ${flows.length} paths`
: (activeFlow?.label ?? 'flow')
);
function buildSvg(scale: number): string {
if (layout === null) throw new Error('There is no strip to export yet.');
const hops = activeFlow?.hops.length ?? 0;
return flowSvg(layout, {
scale,
activeFlowId: picked,
showAll,
caption: showAll ? exportLabel : `${exportLabel}${hops > 1 ? ` · ${hops} hops` : ''}`,
});
}
</script>
<div class="flowview">
@@ -251,6 +277,9 @@
{#if payload}
<p class="note">{note(payload)}</p>
{/if}
{#if layout !== null}
<ExportButtons build={buildSvg} filename={exportFilename('flow', exportLabel)} />
{/if}
</header>
<div class="fstage">
+22
View File
@@ -18,6 +18,7 @@
import ModuleNode from '../components/map/ModuleNode.svelte';
import ModuleEdge from '../components/map/ModuleEdge.svelte';
import MapSidePanel from '../components/map/MapSidePanel.svelte';
import { exportFilename, mapSvg } from '../lib/export-svg';
import { fetchMap, type WireMapPayload } from '../lib/api';
import { live } from '../lib/live.svelte';
import { mapHref, navigate } from '../lib/router.svelte';
@@ -167,6 +168,25 @@
navigate(mapHref({ root: next, depth, tests }));
}
/**
* The map as it stands, for a README.
*
* Serialised from `layout` — the object the canvas is drawing — so the file
* carries the same layering, the same hidden thin links and the same
* selection the reader is looking at. SVG rather than PNG is the point here:
* a forty-module map is a wide, mostly-empty drawing that scales, and GitHub
* renders SVG in a README.
*/
function buildSvg(scale: number): string {
if (layout === null) throw new Error('There is no map to export yet.');
const root = payload?.root ?? '';
return mapSvg(layout, {
scale,
selected,
caption: `${root || 'the project'} · ${layout.nodes.length} modules${selected ? ` · ${selected} selected` : ''}`,
});
}
function setTests(next: boolean): void {
selected = null;
navigate(mapHref({ root, depth, tests: next }));
@@ -267,6 +287,8 @@
{selected}
includeTests={tests}
files={selectedFiles}
buildSvg={buildSvg}
exportName={exportFilename('map', payload.root ?? '')}
onToggleTests={setTests}
onSelectRoot={setRoot}
onSelect={(id) => (selected = id)}