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:
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user