@misoto22/design 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/agent/Accordion.md +1 -1
- package/dist/agent/Alert.md +1 -1
- package/dist/agent/AppShell.md +1 -1
- package/dist/agent/ArchitectureFigure.md +48 -0
- package/dist/agent/AreaChart.md +2 -2
- package/dist/agent/Article.md +1 -1
- package/dist/agent/Avatar.md +1 -1
- package/dist/agent/Badge.md +1 -1
- package/dist/agent/BarChart.md +2 -2
- package/dist/agent/BarList.md +2 -2
- package/dist/agent/BigNumber.md +2 -2
- package/dist/agent/BoxPlot.md +2 -2
- package/dist/agent/Breadcrumb.md +1 -1
- package/dist/agent/BulletChart.md +2 -2
- package/dist/agent/Button.md +1 -1
- package/dist/agent/Calendar.md +1 -1
- package/dist/agent/Card.md +1 -1
- package/dist/agent/Checkbox.md +1 -1
- package/dist/agent/Collapsible.md +1 -1
- package/dist/agent/Combobox.md +1 -1
- package/dist/agent/Command.md +1 -1
- package/dist/agent/ComposedChart.md +2 -2
- package/dist/agent/ContextMenu.md +1 -1
- package/dist/agent/DataflowFigure.md +27 -0
- package/dist/agent/DatePicker.md +1 -1
- package/dist/agent/Diagram.md +1 -1
- package/dist/agent/DiagramCanvas.md +47 -0
- package/dist/agent/DiagramExportMenu.md +38 -0
- package/dist/agent/DiagramInspector.md +50 -0
- package/dist/agent/DiagramLegend.md +34 -0
- package/dist/agent/DiagramMinimap.md +48 -0
- package/dist/agent/DiagramToolbar.md +47 -0
- package/dist/agent/Dialog.md +1 -1
- package/dist/agent/DropdownMenu.md +1 -1
- package/dist/agent/EmptyState.md +1 -1
- package/dist/agent/ErrorState.md +1 -1
- package/dist/agent/Facet.md +2 -2
- package/dist/agent/Field.md +1 -1
- package/dist/agent/FigureBand.md +1 -1
- package/dist/agent/FloatingIconButton.md +1 -1
- package/dist/agent/FunnelChart.md +2 -2
- package/dist/agent/Heatmap.md +2 -2
- package/dist/agent/Histogram.md +2 -2
- package/dist/agent/Input.md +1 -1
- package/dist/agent/Kbd.md +1 -1
- package/dist/agent/LifecycleFigure.md +28 -0
- package/dist/agent/LineChart.md +2 -2
- package/dist/agent/LinkArrow.md +1 -1
- package/dist/agent/NativeSelect.md +1 -1
- package/dist/agent/NavItem.md +1 -1
- package/dist/agent/Pagination.md +1 -1
- package/dist/agent/PieChart.md +2 -2
- package/dist/agent/Popover.md +1 -1
- package/dist/agent/Progress.md +1 -1
- package/dist/agent/RadarChart.md +2 -2
- package/dist/agent/RadialChart.md +2 -2
- package/dist/agent/RadioGroup.md +1 -1
- package/dist/agent/SankeyChart.md +2 -2
- package/dist/agent/ScatterChart.md +2 -2
- package/dist/agent/ScrollArea.md +1 -1
- package/dist/agent/SearchableMenu.md +1 -1
- package/dist/agent/Select.md +1 -1
- package/dist/agent/Separator.md +1 -1
- package/dist/agent/SequenceFigure.md +28 -0
- package/dist/agent/Sheet.md +1 -1
- package/dist/agent/Skeleton.md +1 -1
- package/dist/agent/Slider.md +1 -1
- package/dist/agent/Sparkline.md +2 -2
- package/dist/agent/Spinner.md +1 -1
- package/dist/agent/StatusDot.md +1 -1
- package/dist/agent/StatusPill.md +1 -1
- package/dist/agent/Steps.md +1 -1
- package/dist/agent/Switch.md +1 -1
- package/dist/agent/Table.md +1 -1
- package/dist/agent/Tabs.md +1 -1
- package/dist/agent/Tag.md +1 -1
- package/dist/agent/Textarea.md +1 -1
- package/dist/agent/Toast.md +1 -1
- package/dist/agent/ToggleGroup.md +1 -1
- package/dist/agent/Tooltip.md +1 -1
- package/dist/agent/TreemapChart.md +2 -2
- package/dist/agent/WaterfallChart.md +2 -2
- package/dist/agent/WorkflowFigure.md +28 -0
- package/dist/agent/catalog.json +295 -3
- package/dist/agent/index.md +15 -1
- package/dist/charts/index.d.ts +2 -1
- package/dist/charts/lib/export.d.ts +33 -43
- package/dist/charts/lib/export.js +16 -158
- package/dist/charts/lib/export.js.map +1 -1
- package/dist/diagrams/ArchitectureFigure/ArchitectureFigure.d.ts +49 -0
- package/dist/diagrams/ArchitectureFigure/ArchitectureFigure.js +135 -0
- package/dist/diagrams/ArchitectureFigure/ArchitectureFigure.js.map +1 -0
- package/dist/diagrams/DataflowFigure/DataflowFigure.d.ts +28 -0
- package/dist/diagrams/DataflowFigure/DataflowFigure.js +129 -0
- package/dist/diagrams/DataflowFigure/DataflowFigure.js.map +1 -0
- package/dist/diagrams/DiagramCanvas/DiagramCanvas.d.ts +64 -0
- package/dist/diagrams/DiagramCanvas/DiagramCanvas.js +222 -0
- package/dist/diagrams/DiagramCanvas/DiagramCanvas.js.map +1 -0
- package/dist/diagrams/DiagramExportMenu/DiagramExportMenu.d.ts +72 -0
- package/dist/diagrams/DiagramExportMenu/DiagramExportMenu.js +125 -0
- package/dist/diagrams/DiagramExportMenu/DiagramExportMenu.js.map +1 -0
- package/dist/diagrams/DiagramInspector/DiagramInspector.d.ts +70 -0
- package/dist/diagrams/DiagramInspector/DiagramInspector.js +94 -0
- package/dist/diagrams/DiagramInspector/DiagramInspector.js.map +1 -0
- package/dist/diagrams/DiagramLegend/DiagramLegend.d.ts +42 -0
- package/dist/diagrams/DiagramLegend/DiagramLegend.js +35 -0
- package/dist/diagrams/DiagramLegend/DiagramLegend.js.map +1 -0
- package/dist/diagrams/DiagramMinimap/DiagramMinimap.d.ts +61 -0
- package/dist/diagrams/DiagramMinimap/DiagramMinimap.js +79 -0
- package/dist/diagrams/DiagramMinimap/DiagramMinimap.js.map +1 -0
- package/dist/diagrams/DiagramToolbar/DiagramToolbar.d.ts +48 -0
- package/dist/diagrams/DiagramToolbar/DiagramToolbar.js +53 -0
- package/dist/diagrams/DiagramToolbar/DiagramToolbar.js.map +1 -0
- package/dist/diagrams/LifecycleFigure/LifecycleFigure.d.ts +31 -0
- package/dist/diagrams/LifecycleFigure/LifecycleFigure.js +202 -0
- package/dist/diagrams/LifecycleFigure/LifecycleFigure.js.map +1 -0
- package/dist/diagrams/SequenceFigure/SequenceFigure.d.ts +38 -0
- package/dist/diagrams/SequenceFigure/SequenceFigure.js +231 -0
- package/dist/diagrams/SequenceFigure/SequenceFigure.js.map +1 -0
- package/dist/diagrams/WorkflowFigure/WorkflowFigure.d.ts +34 -0
- package/dist/diagrams/WorkflowFigure/WorkflowFigure.js +211 -0
- package/dist/diagrams/WorkflowFigure/WorkflowFigure.js.map +1 -0
- package/dist/diagrams/index.d.ts +16 -0
- package/dist/diagrams/index.js +19 -0
- package/dist/diagrams/index.js.map +1 -0
- package/dist/diagrams/lib/frame.d.ts +97 -0
- package/dist/diagrams/lib/frame.js +116 -0
- package/dist/diagrams/lib/frame.js.map +1 -0
- package/dist/diagrams/lib/geometry.d.ts +114 -0
- package/dist/diagrams/lib/geometry.js +113 -0
- package/dist/diagrams/lib/geometry.js.map +1 -0
- package/dist/diagrams/lib/legend.d.ts +32 -0
- package/dist/diagrams/lib/legend.js +84 -0
- package/dist/diagrams/lib/legend.js.map +1 -0
- package/dist/diagrams/lib/marks.d.ts +207 -0
- package/dist/diagrams/lib/marks.js +480 -0
- package/dist/diagrams/lib/marks.js.map +1 -0
- package/dist/diagrams/lib/route.d.ts +85 -0
- package/dist/diagrams/lib/route.js +178 -0
- package/dist/diagrams/lib/route.js.map +1 -0
- package/dist/diagrams/lib/wires.d.ts +59 -0
- package/dist/diagrams/lib/wires.js +90 -0
- package/dist/diagrams/lib/wires.js.map +1 -0
- package/dist/diagrams/spec.d.ts +353 -0
- package/dist/diagrams/spec.js +1 -0
- package/dist/diagrams/spec.js.map +1 -0
- package/dist/lib/svg-export.d.ts +138 -0
- package/dist/lib/svg-export.js +198 -0
- package/dist/lib/svg-export.js.map +1 -0
- package/dist/semantic.css +34 -0
- package/dist/styles.css +1 -1
- package/dist/tokens.d.ts +16 -0
- package/dist/tokens.js +82 -0
- package/dist/tokens.json +82 -0
- package/package.json +5 -1
- package/skills/misoto22-design/SKILL.md +58 -6
package/dist/agent/index.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @misoto22/design 0.
|
|
1
|
+
# @misoto22/design 0.7.0
|
|
2
2
|
|
|
3
3
|
> Monochrome design system: portable CSS tokens and accessible React 19
|
|
4
4
|
> primitives. Components ship compiled — import them, do not copy them.
|
|
@@ -129,3 +129,17 @@ re-pointed in a stylesheet.
|
|
|
129
129
|
- **Histogram** — The shape of one distribution.
|
|
130
130
|
- **WaterfallChart** — How a total got from one figure to another.
|
|
131
131
|
- **Facet** — The same chart once per group, on one shared scale.
|
|
132
|
+
|
|
133
|
+
### Diagrams
|
|
134
|
+
|
|
135
|
+
- **ArchitectureFigure** — A component map: services, datastores, trust boundaries, and what talks to what.
|
|
136
|
+
- **WorkflowFigure** — A process in lanes and phases, with the main path drawn heavier than its branches.
|
|
137
|
+
- **SequenceFigure** — A call chain over time: who asks whom, in what order, and what comes back.
|
|
138
|
+
- **DataflowFigure** — A pipeline: where data comes from, what happens to it, and who ends up with it.
|
|
139
|
+
- **LifecycleFigure** — A state machine: what something can be, and what moves it between states.
|
|
140
|
+
- **DiagramCanvas** — A frame that a picture larger than it can be panned and zoomed inside.
|
|
141
|
+
- **DiagramToolbar** — A bar of actions belonging to the surface underneath them.
|
|
142
|
+
- **DiagramExportMenu** — Taking the figure off the page: PNG, JPEG, WebP, SVG and a 1200×630 share card.
|
|
143
|
+
- **DiagramInspector** — What the reader just picked, written out beside the picture.
|
|
144
|
+
- **DiagramMinimap** — Where you are in something bigger than the window.
|
|
145
|
+
- **DiagramLegend** — The key: which drawn form means which kind of thing.
|
package/dist/charts/index.d.ts
CHANGED
|
@@ -31,11 +31,12 @@ export { SONIFY_DEFAULTS, SonifyController, SonifyOptions, SonifyPoint, SonifySe
|
|
|
31
31
|
export { ChartSonifyButton, ChartSonifyButtonProps, Sonify, SonifyAlign, SonifyProps, UseSonifyOptions, UseSonifyResult, chartSonifySeries, useChartSonifySeries, useSonify } from './lib/sonify-control.js';
|
|
32
32
|
export { ChartControls, ChartControlsProps, ChartExportFormat, ChartToolbar, ChartToolbarProps, Toolbar, ToolbarProps } from './lib/toolbar.js';
|
|
33
33
|
export { ChartZoom, ChartZoomOptions, ChartZoomSurface, ChartZoomSurfaceProps, clampWindow, panWindow, useChartZoom, zoomWindow } from './lib/zoom.js';
|
|
34
|
-
export { ChartPngOptions, chartToCsv, chartToPng,
|
|
34
|
+
export { ChartPngOptions, chartToCsv, chartToPng, exportFilename } from './lib/export.js';
|
|
35
35
|
export { ANNOTATION_LAYER, Annotation, AnnotationProps, AnnotationWeight, ReferenceBand, ReferenceBandProps, ReferenceLine, ReferenceLineProps } from './lib/annotations.js';
|
|
36
36
|
export { ValueLabelMode, Values, ValuesProps } from './lib/values.js';
|
|
37
37
|
export { ChartEmpty, ChartEmptyProps } from './lib/empty.js';
|
|
38
38
|
export { NumberFormatOptions, NumberStyle, defaultTick, formatNumber } from './lib/format.js';
|
|
39
|
+
export { downloadBlob } from '../lib/svg-export.js';
|
|
39
40
|
import 'react';
|
|
40
41
|
import 'recharts';
|
|
41
42
|
import 'recharts/types/component/DefaultTooltipContent';
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { ChartColumn } from './figure.js';
|
|
2
|
+
export { downloadBlob } from '../../lib/svg-export.js';
|
|
2
3
|
import 'react';
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Taking a chart off the page: a PNG of the plot, or a CSV of its rows.
|
|
7
|
+
*
|
|
8
|
+
* The general half — walking a live tree to inline its resolved paint,
|
|
9
|
+
* serialising a standalone SVG document, rasterising one through a canvas — is
|
|
10
|
+
* not chart-shaped at all, and lives in `src/lib/svg-export.ts` shared with the
|
|
11
|
+
* diagrams entry. What is left here is what only a Recharts chart knows: which
|
|
12
|
+
* `<svg>` in the subtree is the plot, what a row of chart data looks like as a
|
|
13
|
+
* CSV record, and that the ground behind an exported plot is `--chart-surface`.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here touches the DOM at module scope. Every browser API is reached
|
|
16
|
+
* for inside a function body, so this module is safe to import from a server
|
|
17
|
+
* component and only fails when a consumer actually asks for a file on a
|
|
18
|
+
* server, which is a call that cannot succeed anywhere.
|
|
19
|
+
*/
|
|
4
20
|
interface ChartPngOptions {
|
|
5
21
|
/**
|
|
6
22
|
* Printed above the plot in the exported image. Pass the figure's title: a
|
|
@@ -21,46 +37,27 @@ interface ChartPngOptions {
|
|
|
21
37
|
/**
|
|
22
38
|
* The chart's `<svg>`, rasterised to a PNG blob.
|
|
23
39
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* of its own. That document has none of the page's stylesheets, so every
|
|
28
|
-
* `var(--color-desktop-0)`, `var(--chart-fill)` and `var(--ink)` in the markup
|
|
29
|
-
* resolves to nothing — and a paint that resolves to nothing is not a fallback
|
|
30
|
-
* colour, it is an invisible mark. Serialise a chart naively and you get an
|
|
31
|
-
* empty rectangle with axes on it.
|
|
32
|
-
*
|
|
33
|
-
* ## The solution
|
|
40
|
+
* Three chart-specific decisions and nothing else. `serializeSvg` does the
|
|
41
|
+
* work, and its comment carries the reason a naive `XMLSerializer` export of a
|
|
42
|
+
* themed chart comes out as an empty rectangle with axes on it.
|
|
34
43
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* argument anywhere in this file.
|
|
42
|
-
*
|
|
43
|
-
* `<stop>` elements are walked too, which is the half that is easy to miss: a
|
|
44
|
-
* series is painted `fill="url(#…-colors-desktop)"`, the url survives
|
|
45
|
-
* serialisation because the gradient is inside the same `<svg>`, and it is the
|
|
46
|
-
* gradient's STOPS that carry the custom properties.
|
|
44
|
+
* 1. WHICH `<svg>` — the plot, not the first one in the subtree. See
|
|
45
|
+
* `findPlotSvg`.
|
|
46
|
+
* 2. WHAT GROUND — `--chart-surface`, resolved against the live element, which
|
|
47
|
+
* is what makes a dark-mode export come out dark rather than transparent.
|
|
48
|
+
* 3. WHAT INK AND FACE for the caption — `--ink` and the element's own
|
|
49
|
+
* resolved family, so the exported title matches the page it came from.
|
|
47
50
|
*
|
|
48
51
|
* ## What this cannot capture, honestly
|
|
49
52
|
*
|
|
50
53
|
* - **Anything drawn in HTML.** The legend, the tooltip, the brush strip and
|
|
51
54
|
* the hidden data table are DOM, not SVG. The PNG is the plot.
|
|
52
|
-
* - **Web fonts
|
|
53
|
-
*
|
|
54
|
-
* locally. Type metrics will differ from the screen.
|
|
55
|
-
* - **Cross-origin images.** A chart with an external `<image>` in it taints
|
|
56
|
-
* the canvas and `toBlob` throws; there is no way around it from script.
|
|
57
|
-
* - **The exact rendering.** This is the browser's SVG rasteriser re-drawing
|
|
58
|
-
* the markup, not a screenshot. Antialiasing, `backdrop-filter` and any
|
|
59
|
-
* effect a page stylesheet applied from outside the `<svg>` are not in it.
|
|
55
|
+
* - **Web fonts, cross-origin images, and the exact rendering.** See
|
|
56
|
+
* `serializeSvg` and `rasterize`.
|
|
60
57
|
*
|
|
61
58
|
* @example
|
|
62
59
|
* const blob = await chartToPng(plotRef.current, { title: 'Visitors per month' })
|
|
63
|
-
* downloadBlob(blob, '
|
|
60
|
+
* downloadBlob(blob, exportFilename('Visitors per month', 'png'))
|
|
64
61
|
*/
|
|
65
62
|
declare function chartToPng(element: HTMLElement | SVGSVGElement, options?: ChartPngOptions): Promise<Blob>;
|
|
66
63
|
/**
|
|
@@ -83,21 +80,14 @@ declare function chartToPng(element: HTMLElement | SVGSVGElement, options?: Char
|
|
|
83
80
|
* downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), 'visitors.csv')
|
|
84
81
|
*/
|
|
85
82
|
declare function chartToCsv(rows: Record<string, unknown>[], columns: ChartColumn[]): string;
|
|
86
|
-
|
|
87
|
-
* Hands a blob to the browser as a download.
|
|
88
|
-
*
|
|
89
|
-
* Throws rather than no-oping without a `document`: a download is something a
|
|
90
|
-
* person just asked for, and a silent return would look to them like a click
|
|
91
|
-
* that did nothing.
|
|
92
|
-
*/
|
|
93
|
-
declare function downloadBlob(blob: Blob, filename: string): void;
|
|
83
|
+
|
|
94
84
|
/**
|
|
95
85
|
* A figure's title as a filename.
|
|
96
86
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
87
|
+
* The shared slugger, with `chart` rather than `figure` as the fallback for a
|
|
88
|
+
* title that slugs to nothing — an export from this entry is a chart, and the
|
|
89
|
+
* name a reader finds in their downloads folder should say so.
|
|
100
90
|
*/
|
|
101
91
|
declare function exportFilename(title: string, extension: string): string;
|
|
102
92
|
|
|
103
|
-
export { type ChartPngOptions, chartToCsv, chartToPng,
|
|
93
|
+
export { type ChartPngOptions, chartToCsv, chartToPng, exportFilename };
|
|
@@ -1,154 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"fill-opacity",
|
|
9
|
-
"fill-rule",
|
|
10
|
-
"stroke",
|
|
11
|
-
"stroke-opacity",
|
|
12
|
-
"stroke-width",
|
|
13
|
-
"stroke-linecap",
|
|
14
|
-
"stroke-linejoin",
|
|
15
|
-
"stroke-dasharray",
|
|
16
|
-
"stroke-dashoffset",
|
|
17
|
-
"stop-color",
|
|
18
|
-
"stop-opacity",
|
|
19
|
-
"flood-color",
|
|
20
|
-
"flood-opacity",
|
|
21
|
-
"mask",
|
|
22
|
-
"filter",
|
|
23
|
-
"clip-path",
|
|
24
|
-
"clip-rule",
|
|
25
|
-
"paint-order",
|
|
26
|
-
"mix-blend-mode",
|
|
27
|
-
"shape-rendering",
|
|
28
|
-
"text-anchor",
|
|
29
|
-
"dominant-baseline",
|
|
30
|
-
"font-family",
|
|
31
|
-
"font-size",
|
|
32
|
-
"font-style",
|
|
33
|
-
"font-weight",
|
|
34
|
-
"letter-spacing",
|
|
35
|
-
"text-transform"
|
|
36
|
-
];
|
|
1
|
+
import {
|
|
2
|
+
downloadBlob,
|
|
3
|
+
exportFilename as figureFilename,
|
|
4
|
+
rasterize,
|
|
5
|
+
readToken,
|
|
6
|
+
serializeSvg
|
|
7
|
+
} from "../../lib/svg-export.js";
|
|
37
8
|
async function chartToPng(element, options = {}) {
|
|
38
9
|
const { title, scale = 2, background, padding = 16 } = options;
|
|
39
10
|
const svg = findPlotSvg(element);
|
|
40
11
|
if (!svg) throw new Error("chartToPng: found no <svg> to export inside the element it was given");
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
clone.setAttribute("width", String(width));
|
|
50
|
-
clone.setAttribute("height", String(height));
|
|
51
|
-
const titleBand = title ? 30 : 0;
|
|
52
|
-
const totalWidth = width + padding * 2;
|
|
53
|
-
const totalHeight = height + padding * 2 + titleBand;
|
|
54
|
-
const page = document.createElementNS(SVG_NS, "svg");
|
|
55
|
-
page.setAttribute("xmlns", SVG_NS);
|
|
56
|
-
page.setAttribute("width", String(totalWidth));
|
|
57
|
-
page.setAttribute("height", String(totalHeight));
|
|
58
|
-
page.setAttribute("viewBox", `0 0 ${totalWidth} ${totalHeight}`);
|
|
59
|
-
const plate = document.createElementNS(SVG_NS, "rect");
|
|
60
|
-
plate.setAttribute("width", String(totalWidth));
|
|
61
|
-
plate.setAttribute("height", String(totalHeight));
|
|
62
|
-
plate.setAttribute("fill", background ?? readToken(element, "--chart-surface", "#ffffff"));
|
|
63
|
-
page.append(plate);
|
|
64
|
-
if (title) {
|
|
65
|
-
const caption = document.createElementNS(SVG_NS, "text");
|
|
66
|
-
caption.setAttribute("x", String(padding));
|
|
67
|
-
caption.setAttribute("y", String(padding + 14));
|
|
68
|
-
caption.setAttribute("fill", readToken(element, "--ink", "#101010"));
|
|
69
|
-
caption.setAttribute("font-size", "13");
|
|
70
|
-
caption.setAttribute("font-weight", "500");
|
|
71
|
-
caption.setAttribute("font-family", getComputedStyle(element).fontFamily || "sans-serif");
|
|
72
|
-
caption.textContent = title;
|
|
73
|
-
page.append(caption);
|
|
74
|
-
}
|
|
75
|
-
const group = document.createElementNS(SVG_NS, "g");
|
|
76
|
-
group.setAttribute("transform", `translate(${padding}, ${padding + titleBand})`);
|
|
77
|
-
group.append(clone);
|
|
78
|
-
page.append(group);
|
|
79
|
-
const markup = new XMLSerializer().serializeToString(page);
|
|
80
|
-
const url = URL.createObjectURL(
|
|
81
|
-
new Blob([`<?xml version="1.0" encoding="UTF-8"?>
|
|
82
|
-
${markup}`], {
|
|
83
|
-
type: "image/svg+xml;charset=utf-8"
|
|
84
|
-
})
|
|
85
|
-
);
|
|
86
|
-
try {
|
|
87
|
-
const image = await loadImage(url);
|
|
88
|
-
const canvas = document.createElement("canvas");
|
|
89
|
-
canvas.width = Math.ceil(totalWidth * scale);
|
|
90
|
-
canvas.height = Math.ceil(totalHeight * scale);
|
|
91
|
-
const context = canvas.getContext("2d");
|
|
92
|
-
if (!context) throw new Error("chartToPng: this browser gave back no 2D canvas context");
|
|
93
|
-
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
94
|
-
return await canvasToBlob(canvas);
|
|
95
|
-
} finally {
|
|
96
|
-
URL.revokeObjectURL(url);
|
|
97
|
-
}
|
|
12
|
+
const markup = serializeSvg(svg, {
|
|
13
|
+
padding,
|
|
14
|
+
background: background ?? readToken(element, "--chart-surface", "#ffffff"),
|
|
15
|
+
title,
|
|
16
|
+
titleColor: readToken(element, "--ink", "#101010"),
|
|
17
|
+
titleFont: getComputedStyle(element).fontFamily || "sans-serif"
|
|
18
|
+
});
|
|
19
|
+
return rasterize(markup, "png", { scale });
|
|
98
20
|
}
|
|
99
21
|
function findPlotSvg(element) {
|
|
100
22
|
if (element.tagName.toLowerCase() === "svg") return element;
|
|
101
23
|
const scope = element.querySelector('[data-slot="chart"]') ?? element;
|
|
102
24
|
return scope.querySelector("svg.recharts-surface") ?? scope.querySelector("svg");
|
|
103
25
|
}
|
|
104
|
-
function inlinePaint(live, clone) {
|
|
105
|
-
const computed = getComputedStyle(live);
|
|
106
|
-
let css = "";
|
|
107
|
-
for (const property of PAINTED_PROPERTIES) {
|
|
108
|
-
const value = computed.getPropertyValue(property);
|
|
109
|
-
if (value) css += `${property}:${value};`;
|
|
110
|
-
}
|
|
111
|
-
if (css) clone.setAttribute("style", css);
|
|
112
|
-
const children = live.children;
|
|
113
|
-
const clones = clone.children;
|
|
114
|
-
for (let index = 0; index < children.length; index += 1) {
|
|
115
|
-
const liveChild = children[index];
|
|
116
|
-
const cloneChild = clones[index];
|
|
117
|
-
if (liveChild && cloneChild) inlinePaint(liveChild, cloneChild);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
function readToken(element, name, fallback) {
|
|
121
|
-
const value = getComputedStyle(element).getPropertyValue(name).trim();
|
|
122
|
-
return value && !value.includes("var(") ? value : fallback;
|
|
123
|
-
}
|
|
124
|
-
function loadImage(url) {
|
|
125
|
-
return new Promise((resolve, reject) => {
|
|
126
|
-
const image = new Image();
|
|
127
|
-
image.addEventListener("load", () => resolve(image));
|
|
128
|
-
image.addEventListener(
|
|
129
|
-
"error",
|
|
130
|
-
() => reject(new Error("chartToPng: the browser refused to decode the serialised chart"))
|
|
131
|
-
);
|
|
132
|
-
image.src = url;
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
function canvasToBlob(canvas) {
|
|
136
|
-
return new Promise((resolve, reject) => {
|
|
137
|
-
try {
|
|
138
|
-
canvas.toBlob((blob) => {
|
|
139
|
-
if (blob) resolve(blob);
|
|
140
|
-
else reject(new Error("chartToPng: the canvas produced no PNG data"));
|
|
141
|
-
}, "image/png");
|
|
142
|
-
} catch (cause) {
|
|
143
|
-
reject(
|
|
144
|
-
new Error(
|
|
145
|
-
"chartToPng: the canvas is tainted, which means the chart contains a cross-origin image",
|
|
146
|
-
{ cause }
|
|
147
|
-
)
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
26
|
const UTF8_BOM = "\uFEFF";
|
|
153
27
|
function chartToCsv(rows, columns) {
|
|
154
28
|
if (columns.length === 0) return UTF8_BOM;
|
|
@@ -176,24 +50,8 @@ function cellText(value) {
|
|
|
176
50
|
if (value instanceof Date) return value.toISOString();
|
|
177
51
|
return "";
|
|
178
52
|
}
|
|
179
|
-
function downloadBlob(blob, filename) {
|
|
180
|
-
if (typeof document === "undefined") {
|
|
181
|
-
throw new Error("downloadBlob: needs a browser document \u2014 call it from an event handler");
|
|
182
|
-
}
|
|
183
|
-
const url = URL.createObjectURL(blob);
|
|
184
|
-
const anchor = document.createElement("a");
|
|
185
|
-
anchor.href = url;
|
|
186
|
-
anchor.download = filename;
|
|
187
|
-
anchor.rel = "noopener";
|
|
188
|
-
anchor.style.display = "none";
|
|
189
|
-
document.body.append(anchor);
|
|
190
|
-
anchor.click();
|
|
191
|
-
anchor.remove();
|
|
192
|
-
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
193
|
-
}
|
|
194
53
|
function exportFilename(title, extension) {
|
|
195
|
-
|
|
196
|
-
return `${slug || "chart"}.${extension}`;
|
|
54
|
+
return figureFilename(title, extension, "chart");
|
|
197
55
|
}
|
|
198
56
|
export {
|
|
199
57
|
chartToCsv,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/charts/lib/export.ts"],"sourcesContent":["import type { ChartColumn } from './figure'\n\n/**\n * Taking a chart off the page: a PNG of the plot, or a CSV of its rows.\n *\n * Nothing here touches the DOM at module scope. Every browser API — `document`,\n * `canvas`, `URL.createObjectURL` — is reached for inside a function body, so\n * this module is safe to import from a server component and only fails when a\n * consumer actually asks for a file on a server, which is a call that cannot\n * succeed anywhere.\n */\n\nconst SVG_NS = 'http://www.w3.org/2000/svg'\n\n/**\n * The properties copied from the live DOM onto the serialised clone.\n *\n * Paint, geometry-adjacent paint, and type. Layout is deliberately absent: an\n * SVG's geometry is in its attributes, which the clone already carries, and\n * copying computed `width`/`height`/`transform` onto every node would fight\n * them.\n */\nconst PAINTED_PROPERTIES = [\n 'display',\n 'visibility',\n 'opacity',\n 'color',\n 'fill',\n 'fill-opacity',\n 'fill-rule',\n 'stroke',\n 'stroke-opacity',\n 'stroke-width',\n 'stroke-linecap',\n 'stroke-linejoin',\n 'stroke-dasharray',\n 'stroke-dashoffset',\n 'stop-color',\n 'stop-opacity',\n 'flood-color',\n 'flood-opacity',\n 'mask',\n 'filter',\n 'clip-path',\n 'clip-rule',\n 'paint-order',\n 'mix-blend-mode',\n 'shape-rendering',\n 'text-anchor',\n 'dominant-baseline',\n 'font-family',\n 'font-size',\n 'font-style',\n 'font-weight',\n 'letter-spacing',\n 'text-transform',\n] as const\n\nexport interface ChartPngOptions {\n /**\n * Printed above the plot in the exported image. Pass the figure's title: a\n * PNG travels into a slide deck with none of the page's context around it,\n * and an unnamed plot in a deck is a plot nobody can cite.\n */\n title?: string\n /** Multiplier on the on-screen size. 2 is a retina-sharp export. */\n scale?: number\n /**\n * Painted behind the plot. Defaults to the resolved `--chart-surface`, which\n * is what makes a dark-mode export come out dark rather than transparent.\n */\n background?: string\n /** Space around the plot, in CSS pixels. */\n padding?: number\n}\n\n/**\n * The chart's `<svg>`, rasterised to a PNG blob.\n *\n * ## The problem this function exists to solve\n *\n * Serialising the `<svg>` and handing it to an `<img>` puts it in a document\n * of its own. That document has none of the page's stylesheets, so every\n * `var(--color-desktop-0)`, `var(--chart-fill)` and `var(--ink)` in the markup\n * resolves to nothing — and a paint that resolves to nothing is not a fallback\n * colour, it is an invisible mark. Serialise a chart naively and you get an\n * empty rectangle with axes on it.\n *\n * ## The solution\n *\n * Walk the live tree and the clone in step, and on every node write\n * `getComputedStyle`'s answer for the painted properties into an inline\n * `style`. Computed values have already had `var()` substituted by the engine,\n * so `fill: var(--color-desktop-0)` arrives as `fill: rgb(16, 16, 16)` — and,\n * because the walk reads the LIVE element, it reads it under whichever theme\n * is currently applied. Light and dark come out right for free, with no theme\n * argument anywhere in this file.\n *\n * `<stop>` elements are walked too, which is the half that is easy to miss: a\n * series is painted `fill=\"url(#…-colors-desktop)\"`, the url survives\n * serialisation because the gradient is inside the same `<svg>`, and it is the\n * gradient's STOPS that carry the custom properties.\n *\n * ## What this cannot capture, honestly\n *\n * - **Anything drawn in HTML.** The legend, the tooltip, the brush strip and\n * the hidden data table are DOM, not SVG. The PNG is the plot.\n * - **Web fonts.** The isolated document cannot fetch the page's `@font-face`\n * sources, so text is rasterised in whatever the resolved family stack finds\n * locally. Type metrics will differ from the screen.\n * - **Cross-origin images.** A chart with an external `<image>` in it taints\n * the canvas and `toBlob` throws; there is no way around it from script.\n * - **The exact rendering.** This is the browser's SVG rasteriser re-drawing\n * the markup, not a screenshot. Antialiasing, `backdrop-filter` and any\n * effect a page stylesheet applied from outside the `<svg>` are not in it.\n *\n * @example\n * const blob = await chartToPng(plotRef.current, { title: 'Visitors per month' })\n * downloadBlob(blob, 'visitors-per-month.png')\n */\nexport async function chartToPng(\n element: HTMLElement | SVGSVGElement,\n options: ChartPngOptions = {},\n): Promise<Blob> {\n const { title, scale = 2, background, padding = 16 } = options\n\n const svg = findPlotSvg(element)\n if (!svg) throw new Error('chartToPng: found no <svg> to export inside the element it was given')\n\n const box = svg.getBoundingClientRect()\n const width = Math.ceil(box.width)\n const height = Math.ceil(box.height)\n if (width === 0 || height === 0) {\n // A chart that has not been measured yet has no size to export. Silently\n // producing a 0×0 PNG would look like a broken download rather than a\n // too-early call.\n throw new Error('chartToPng: the chart has no measured size yet — export it after it renders')\n }\n\n const clone = svg.cloneNode(true) as SVGSVGElement\n inlinePaint(svg, clone)\n clone.setAttribute('width', String(width))\n clone.setAttribute('height', String(height))\n\n const titleBand = title ? 30 : 0\n const totalWidth = width + padding * 2\n const totalHeight = height + padding * 2 + titleBand\n\n const page = document.createElementNS(SVG_NS, 'svg')\n page.setAttribute('xmlns', SVG_NS)\n page.setAttribute('width', String(totalWidth))\n page.setAttribute('height', String(totalHeight))\n page.setAttribute('viewBox', `0 0 ${totalWidth} ${totalHeight}`)\n\n const plate = document.createElementNS(SVG_NS, 'rect')\n plate.setAttribute('width', String(totalWidth))\n plate.setAttribute('height', String(totalHeight))\n plate.setAttribute('fill', background ?? readToken(element, '--chart-surface', '#ffffff'))\n page.append(plate)\n\n if (title) {\n const caption = document.createElementNS(SVG_NS, 'text')\n caption.setAttribute('x', String(padding))\n caption.setAttribute('y', String(padding + 14))\n caption.setAttribute('fill', readToken(element, '--ink', '#101010'))\n caption.setAttribute('font-size', '13')\n caption.setAttribute('font-weight', '500')\n caption.setAttribute('font-family', getComputedStyle(element).fontFamily || 'sans-serif')\n caption.textContent = title\n page.append(caption)\n }\n\n const group = document.createElementNS(SVG_NS, 'g')\n group.setAttribute('transform', `translate(${padding}, ${padding + titleBand})`)\n group.append(clone)\n page.append(group)\n\n const markup = new XMLSerializer().serializeToString(page)\n const url = URL.createObjectURL(\n new Blob([`<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n${markup}`], {\n type: 'image/svg+xml;charset=utf-8',\n }),\n )\n\n try {\n const image = await loadImage(url)\n const canvas = document.createElement('canvas')\n canvas.width = Math.ceil(totalWidth * scale)\n canvas.height = Math.ceil(totalHeight * scale)\n\n const context = canvas.getContext('2d')\n if (!context) throw new Error('chartToPng: this browser gave back no 2D canvas context')\n context.drawImage(image, 0, 0, canvas.width, canvas.height)\n\n return await canvasToBlob(canvas)\n } finally {\n URL.revokeObjectURL(url)\n }\n}\n\n/**\n * The plot's `<svg>`, not the first `<svg>` in the subtree.\n *\n * The distinction matters because the toolbar sits inside the same wrapper and\n * every icon in it is an `<svg>`. Narrowing to the measured chart box first is\n * what stops an export of a chart from being a picture of a magnifying glass.\n */\nfunction findPlotSvg(element: HTMLElement | SVGSVGElement): SVGSVGElement | null {\n if (element.tagName.toLowerCase() === 'svg') return element as SVGSVGElement\n const scope = element.querySelector('[data-slot=\"chart\"]') ?? element\n return scope.querySelector<SVGSVGElement>('svg.recharts-surface') ?? scope.querySelector('svg')\n}\n\n/** Copies the resolved paint of every node in `live` onto the matching node in `clone`. */\nfunction inlinePaint(live: Element, clone: Element): void {\n const computed = getComputedStyle(live)\n let css = ''\n for (const property of PAINTED_PROPERTIES) {\n const value = computed.getPropertyValue(property)\n if (value) css += `${property}:${value};`\n }\n if (css) clone.setAttribute('style', css)\n\n // The clone is a faithful deep copy, so child lists line up by index.\n const children = live.children\n const clones = clone.children\n for (let index = 0; index < children.length; index += 1) {\n const liveChild = children[index]\n const cloneChild = clones[index]\n if (liveChild && cloneChild) inlinePaint(liveChild, cloneChild)\n }\n}\n\n/**\n * A custom property, resolved against the live element.\n *\n * `var()` inside a custom property is substituted at computed-value time, so\n * `--chart-surface: var(--paper)` reads back as a colour. The guard is for the\n * case where it did not — an unregistered name, or a value the engine left as\n * a token stream — because writing `var(--paper)` into a serialised SVG paints\n * nothing at all.\n */\nfunction readToken(element: Element, name: string, fallback: string): string {\n const value = getComputedStyle(element).getPropertyValue(name).trim()\n return value && !value.includes('var(') ? value : fallback\n}\n\nfunction loadImage(url: string): Promise<HTMLImageElement> {\n return new Promise((resolve, reject) => {\n const image = new Image()\n image.addEventListener('load', () => resolve(image))\n image.addEventListener('error', () =>\n reject(new Error('chartToPng: the browser refused to decode the serialised chart')),\n )\n image.src = url\n })\n}\n\nfunction canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {\n return new Promise((resolve, reject) => {\n try {\n canvas.toBlob((blob) => {\n if (blob) resolve(blob)\n else reject(new Error('chartToPng: the canvas produced no PNG data'))\n }, 'image/png')\n } catch (cause) {\n // A cross-origin image anywhere in the plot taints the canvas and this\n // throws SecurityError. Nothing in script can untaint it, so the honest\n // move is to say which failure it was.\n reject(\n new Error(\n 'chartToPng: the canvas is tainted, which means the chart contains a cross-origin image',\n { cause },\n ),\n )\n }\n })\n}\n\n/**\n * A UTF-8 byte order mark.\n *\n * Excel does not sniff encodings. Handed a CSV with no BOM it decodes the file\n * with the machine's legacy code page, so \"Müller\", \"東京\" and \"€\" arrive as\n * mojibake on most Windows installs — and the reader's conclusion is that the\n * export is broken, not that the encoding was guessed. The BOM is the only\n * in-band signal Excel honours, and every other tool skips it silently.\n */\nconst UTF8_BOM = '\\uFEFF'\n\n/**\n * The chart's rows as RFC 4180 CSV.\n *\n * Quotes a field only when it needs quoting — a comma, a double quote, or a\n * line break in it — doubles any quote inside, and separates records with CRLF\n * as the RFC requires rather than with the `\\n` a Unix habit would reach for.\n *\n * Numbers are written unformatted on purpose. `1,234` would need quoting and\n * would then land in the spreadsheet as text, which is the one thing a numeric\n * export must not do; formatting belongs to the chart, not to the file.\n *\n * A column's `label` is a `ReactNode`, so only a string or a number can be a\n * header. Anything else falls back to the column's key, which is a worse\n * header than the label but a better one than an empty cell.\n *\n * @example\n * const csv = chartToCsv(data, [{ key: 'desktop', label: 'Desktop' }])\n * downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), 'visitors.csv')\n */\nexport function chartToCsv(rows: Record<string, unknown>[], columns: ChartColumn[]): string {\n if (columns.length === 0) return UTF8_BOM\n\n const lines = [columns.map((column) => csvField(headerText(column))).join(',')]\n\n for (const row of rows) {\n lines.push(columns.map((column) => csvField(cellText(row[column.key]))).join(','))\n }\n\n // The RFC makes the last CRLF optional; writing it matches what a\n // spreadsheet emits and keeps the file from ending mid-record in a terminal.\n return `${UTF8_BOM}${lines.join('\\r\\n')}\\r\\n`\n}\n\n/** One field, quoted only when the RFC says it has to be. */\nfunction csvField(value: string): string {\n if (!/[\",\\r\\n]/.test(value)) return value\n return `\"${value.replace(/\"/g, '\"\"')}\"`\n}\n\n/** A column's header text, falling back to its key when the label is markup. */\nfunction headerText(column: ChartColumn): string {\n const { label } = column\n if (typeof label === 'string') return label\n if (typeof label === 'number') return String(label)\n return column.key\n}\n\n/** A cell value as text. Anything that is not a primitive reads as empty. */\nfunction cellText(value: unknown): string {\n if (value === null || value === undefined) return ''\n if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''\n if (typeof value === 'string' || typeof value === 'boolean') return String(value)\n if (value instanceof Date) return value.toISOString()\n return ''\n}\n\n/**\n * Hands a blob to the browser as a download.\n *\n * Throws rather than no-oping without a `document`: a download is something a\n * person just asked for, and a silent return would look to them like a click\n * that did nothing.\n */\nexport function downloadBlob(blob: Blob, filename: string): void {\n if (typeof document === 'undefined') {\n throw new Error('downloadBlob: needs a browser document — call it from an event handler')\n }\n\n const url = URL.createObjectURL(blob)\n const anchor = document.createElement('a')\n anchor.href = url\n anchor.download = filename\n anchor.rel = 'noopener'\n anchor.style.display = 'none'\n document.body.append(anchor)\n anchor.click()\n anchor.remove()\n\n // Revoking in the same tick cancels the download in Safari; one turn of the\n // event loop is enough for the navigation to have started.\n setTimeout(() => URL.revokeObjectURL(url), 0)\n}\n\n/**\n * A figure's title as a filename.\n *\n * Lowercase, ASCII-safe and hyphenated, because a downloaded file crosses into\n * shells, zip archives and Windows paths where a title's spaces and slashes\n * are someone else's problem.\n */\nexport function exportFilename(title: string, extension: string): string {\n const slug = title\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n return `${slug || 'chart'}.${extension}`\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/charts/lib/export.ts"],"sourcesContent":["import type { ChartColumn } from './figure'\nimport {\n downloadBlob,\n exportFilename as figureFilename,\n rasterize,\n readToken,\n serializeSvg,\n} from '../../lib/svg-export'\n\n/**\n * Taking a chart off the page: a PNG of the plot, or a CSV of its rows.\n *\n * The general half — walking a live tree to inline its resolved paint,\n * serialising a standalone SVG document, rasterising one through a canvas — is\n * not chart-shaped at all, and lives in `src/lib/svg-export.ts` shared with the\n * diagrams entry. What is left here is what only a Recharts chart knows: which\n * `<svg>` in the subtree is the plot, what a row of chart data looks like as a\n * CSV record, and that the ground behind an exported plot is `--chart-surface`.\n *\n * Nothing here touches the DOM at module scope. Every browser API is reached\n * for inside a function body, so this module is safe to import from a server\n * component and only fails when a consumer actually asks for a file on a\n * server, which is a call that cannot succeed anywhere.\n */\n\nexport interface ChartPngOptions {\n /**\n * Printed above the plot in the exported image. Pass the figure's title: a\n * PNG travels into a slide deck with none of the page's context around it,\n * and an unnamed plot in a deck is a plot nobody can cite.\n */\n title?: string\n /** Multiplier on the on-screen size. 2 is a retina-sharp export. */\n scale?: number\n /**\n * Painted behind the plot. Defaults to the resolved `--chart-surface`, which\n * is what makes a dark-mode export come out dark rather than transparent.\n */\n background?: string\n /** Space around the plot, in CSS pixels. */\n padding?: number\n}\n\n/**\n * The chart's `<svg>`, rasterised to a PNG blob.\n *\n * Three chart-specific decisions and nothing else. `serializeSvg` does the\n * work, and its comment carries the reason a naive `XMLSerializer` export of a\n * themed chart comes out as an empty rectangle with axes on it.\n *\n * 1. WHICH `<svg>` — the plot, not the first one in the subtree. See\n * `findPlotSvg`.\n * 2. WHAT GROUND — `--chart-surface`, resolved against the live element, which\n * is what makes a dark-mode export come out dark rather than transparent.\n * 3. WHAT INK AND FACE for the caption — `--ink` and the element's own\n * resolved family, so the exported title matches the page it came from.\n *\n * ## What this cannot capture, honestly\n *\n * - **Anything drawn in HTML.** The legend, the tooltip, the brush strip and\n * the hidden data table are DOM, not SVG. The PNG is the plot.\n * - **Web fonts, cross-origin images, and the exact rendering.** See\n * `serializeSvg` and `rasterize`.\n *\n * @example\n * const blob = await chartToPng(plotRef.current, { title: 'Visitors per month' })\n * downloadBlob(blob, exportFilename('Visitors per month', 'png'))\n */\nexport async function chartToPng(\n element: HTMLElement | SVGSVGElement,\n options: ChartPngOptions = {},\n): Promise<Blob> {\n const { title, scale = 2, background, padding = 16 } = options\n\n const svg = findPlotSvg(element)\n if (!svg) throw new Error('chartToPng: found no <svg> to export inside the element it was given')\n\n const markup = serializeSvg(svg, {\n padding,\n background: background ?? readToken(element, '--chart-surface', '#ffffff'),\n title,\n titleColor: readToken(element, '--ink', '#101010'),\n titleFont: getComputedStyle(element).fontFamily || 'sans-serif',\n })\n\n return rasterize(markup, 'png', { scale })\n}\n\n/**\n * The plot's `<svg>`, not the first `<svg>` in the subtree.\n *\n * Two narrowings, and both are load-bearing. The toolbar sits outside the plot\n * wrapper and every control in it is an `<svg>`, so the scope comes first;\n * INSIDE the wrapper the legend draws its own swatches, so `.recharts-surface`\n * comes second. Either one missing exports a picture of an icon, which looks\n * like a working download until somebody opens the file.\n */\nfunction findPlotSvg(element: HTMLElement | SVGSVGElement): SVGSVGElement | null {\n if (element.tagName.toLowerCase() === 'svg') return element as SVGSVGElement\n const scope = element.querySelector('[data-slot=\"chart\"]') ?? element\n return scope.querySelector<SVGSVGElement>('svg.recharts-surface') ?? scope.querySelector('svg')\n}\n\n/**\n * A UTF-8 byte order mark.\n *\n * Excel does not sniff encodings. Handed a CSV with no BOM it decodes the file\n * with the machine's legacy code page, so \"Müller\", \"東京\" and \"€\" arrive as\n * mojibake on most Windows installs — and the reader's conclusion is that the\n * export is broken, not that the encoding was guessed. The BOM is the only\n * in-band signal Excel honours, and every other tool skips it silently.\n */\nconst UTF8_BOM = '\\uFEFF'\n\n/**\n * The chart's rows as RFC 4180 CSV.\n *\n * Quotes a field only when it needs quoting — a comma, a double quote, or a\n * line break in it — doubles any quote inside, and separates records with CRLF\n * as the RFC requires rather than with the `\\n` a Unix habit would reach for.\n *\n * Numbers are written unformatted on purpose. `1,234` would need quoting and\n * would then land in the spreadsheet as text, which is the one thing a numeric\n * export must not do; formatting belongs to the chart, not to the file.\n *\n * A column's `label` is a `ReactNode`, so only a string or a number can be a\n * header. Anything else falls back to the column's key, which is a worse\n * header than the label but a better one than an empty cell.\n *\n * @example\n * const csv = chartToCsv(data, [{ key: 'desktop', label: 'Desktop' }])\n * downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), 'visitors.csv')\n */\nexport function chartToCsv(rows: Record<string, unknown>[], columns: ChartColumn[]): string {\n if (columns.length === 0) return UTF8_BOM\n\n const lines = [columns.map((column) => csvField(headerText(column))).join(',')]\n\n for (const row of rows) {\n lines.push(columns.map((column) => csvField(cellText(row[column.key]))).join(','))\n }\n\n // The RFC makes the last CRLF optional; writing it matches what a\n // spreadsheet emits and keeps the file from ending mid-record in a terminal.\n return `${UTF8_BOM}${lines.join('\\r\\n')}\\r\\n`\n}\n\n/** One field, quoted only when the RFC says it has to be. */\nfunction csvField(value: string): string {\n if (!/[\",\\r\\n]/.test(value)) return value\n return `\"${value.replace(/\"/g, '\"\"')}\"`\n}\n\n/** A column's header text, falling back to its key when the label is markup. */\nfunction headerText(column: ChartColumn): string {\n const { label } = column\n if (typeof label === 'string') return label\n if (typeof label === 'number') return String(label)\n return column.key\n}\n\n/** A cell value as text. Anything that is not a primitive reads as empty. */\nfunction cellText(value: unknown): string {\n if (value === null || value === undefined) return ''\n if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''\n if (typeof value === 'string' || typeof value === 'boolean') return String(value)\n if (value instanceof Date) return value.toISOString()\n return ''\n}\n\nexport { downloadBlob }\n\n/**\n * A figure's title as a filename.\n *\n * The shared slugger, with `chart` rather than `figure` as the fallback for a\n * title that slugs to nothing — an export from this entry is a chart, and the\n * name a reader finds in their downloads folder should say so.\n */\nexport function exportFilename(title: string, extension: string): string {\n return figureFilename(title, extension, 'chart')\n}\n"],"mappings":"AACA;AAAA,EACE;AAAA,EACA,kBAAkB;AAAA,EAClB;AAAA,EACA;AAAA,EACA;AAAA,OACK;AA6DP,eAAsB,WACpB,SACA,UAA2B,CAAC,GACb;AACf,QAAM,EAAE,OAAO,QAAQ,GAAG,YAAY,UAAU,GAAG,IAAI;AAEvD,QAAM,MAAM,YAAY,OAAO;AAC/B,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,sEAAsE;AAEhG,QAAM,SAAS,aAAa,KAAK;AAAA,IAC/B;AAAA,IACA,YAAY,cAAc,UAAU,SAAS,mBAAmB,SAAS;AAAA,IACzE;AAAA,IACA,YAAY,UAAU,SAAS,SAAS,SAAS;AAAA,IACjD,WAAW,iBAAiB,OAAO,EAAE,cAAc;AAAA,EACrD,CAAC;AAED,SAAO,UAAU,QAAQ,OAAO,EAAE,MAAM,CAAC;AAC3C;AAWA,SAAS,YAAY,SAA4D;AAC/E,MAAI,QAAQ,QAAQ,YAAY,MAAM,MAAO,QAAO;AACpD,QAAM,QAAQ,QAAQ,cAAc,qBAAqB,KAAK;AAC9D,SAAO,MAAM,cAA6B,sBAAsB,KAAK,MAAM,cAAc,KAAK;AAChG;AAWA,MAAM,WAAW;AAqBV,SAAS,WAAW,MAAiC,SAAgC;AAC1F,MAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,QAAM,QAAQ,CAAC,QAAQ,IAAI,CAAC,WAAW,SAAS,WAAW,MAAM,CAAC,CAAC,EAAE,KAAK,GAAG,CAAC;AAE9E,aAAW,OAAO,MAAM;AACtB,UAAM,KAAK,QAAQ,IAAI,CAAC,WAAW,SAAS,SAAS,IAAI,OAAO,GAAG,CAAC,CAAC,CAAC,EAAE,KAAK,GAAG,CAAC;AAAA,EACnF;AAIA,SAAO,GAAG,QAAQ,GAAG,MAAM,KAAK,MAAM,CAAC;AAAA;AACzC;AAGA,SAAS,SAAS,OAAuB;AACvC,MAAI,CAAC,WAAW,KAAK,KAAK,EAAG,QAAO;AACpC,SAAO,IAAI,MAAM,QAAQ,MAAM,IAAI,CAAC;AACtC;AAGA,SAAS,WAAW,QAA6B;AAC/C,QAAM,EAAE,MAAM,IAAI;AAClB,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,SAAU,QAAO,OAAO,KAAK;AAClD,SAAO,OAAO;AAChB;AAGA,SAAS,SAAS,OAAwB;AACxC,MAAI,UAAU,QAAQ,UAAU,OAAW,QAAO;AAClD,MAAI,OAAO,UAAU,SAAU,QAAO,OAAO,SAAS,KAAK,IAAI,OAAO,KAAK,IAAI;AAC/E,MAAI,OAAO,UAAU,YAAY,OAAO,UAAU,UAAW,QAAO,OAAO,KAAK;AAChF,MAAI,iBAAiB,KAAM,QAAO,MAAM,YAAY;AACpD,SAAO;AACT;AAWO,SAAS,eAAe,OAAe,WAA2B;AACvE,SAAO,eAAe,OAAO,WAAW,OAAO;AACjD;","names":[]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { FigureChrome } from '../lib/frame.js';
|
|
3
|
+
import { ArchitectureSpec } from '../spec.js';
|
|
4
|
+
import '../lib/geometry.js';
|
|
5
|
+
|
|
6
|
+
interface ArchitectureFigureProps extends FigureChrome {
|
|
7
|
+
spec: ArchitectureSpec;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A component map: services, datastores, boundaries, and what talks to what.
|
|
11
|
+
*
|
|
12
|
+
* Takes the same JSON an archify `architecture` specification carries —
|
|
13
|
+
* `components`, `boundaries`, `connections` — and draws it in this system's own
|
|
14
|
+
* terms: paper plates on a hairline, one reversed plate for the component the
|
|
15
|
+
* diagram is about, and seven drawn sigils where archify uses seven hues.
|
|
16
|
+
*
|
|
17
|
+
* IT RENDERS ON A SERVER because every position is already in the
|
|
18
|
+
* specification. A component gives a `row` and a `col` into a grid whose cell
|
|
19
|
+
* size is either declared or defaulted, or it gives an absolute `pos` — nothing
|
|
20
|
+
* here is solved for, relaxed, or measured. So the markup is a pure function of
|
|
21
|
+
* the input, it is identical on the server and in the browser, and there is no
|
|
22
|
+
* layout shift on hydration because there is no layout to do.
|
|
23
|
+
*
|
|
24
|
+
* BOUNDARIES ARE DRAWN FIRST, AND DRAWN DIFFERENTLY. A `region` is where
|
|
25
|
+
* something RUNS — a VPC, a zone, a cluster — and is a solid frame. A
|
|
26
|
+
* `security-group` is what may REACH it, and is dashed. That is not decoration:
|
|
27
|
+
* an infrastructure diagram is very often read for exactly one of those two
|
|
28
|
+
* questions, and a reader should be able to tell which line answers which
|
|
29
|
+
* without reading either label.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* <ArchitectureFigure
|
|
33
|
+
* spec={{
|
|
34
|
+
* meta: { title: 'Request path' },
|
|
35
|
+
* components: [
|
|
36
|
+
* { id: 'cdn', type: 'cloud', label: 'CloudFront', row: 0, col: 0 },
|
|
37
|
+
* { id: 'api', type: 'backend', label: 'FastAPI', sublabel: 'ECS', row: 0, col: 1 },
|
|
38
|
+
* { id: 'db', type: 'database', label: 'Postgres', row: 0, col: 2 },
|
|
39
|
+
* ],
|
|
40
|
+
* connections: [
|
|
41
|
+
* { from: 'cdn', to: 'api', label: 'HTTPS' },
|
|
42
|
+
* { from: 'api', to: 'db', label: 'SQL' },
|
|
43
|
+
* ],
|
|
44
|
+
* }}
|
|
45
|
+
* />
|
|
46
|
+
*/
|
|
47
|
+
declare function ArchitectureFigure({ spec, className, legend, cards, heading, activeIds, onSelectNode, }: ArchitectureFigureProps): react.JSX.Element;
|
|
48
|
+
|
|
49
|
+
export { ArchitectureFigure, type ArchitectureFigureProps, ArchitectureFigure as default };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useMemo } from "react";
|
|
4
|
+
import { DiagramFrame } from "../lib/frame.js";
|
|
5
|
+
import { inflate, TYPE, union, wrapText } from "../lib/geometry.js";
|
|
6
|
+
import { kindLegend, resolveLegend } from "../lib/legend.js";
|
|
7
|
+
import { BandFrame, NodePlate, PLATE, plateHeight } from "../lib/marks.js";
|
|
8
|
+
import { renderWires, wiresFor } from "../lib/wires.js";
|
|
9
|
+
const KINDS = [
|
|
10
|
+
"frontend",
|
|
11
|
+
"backend",
|
|
12
|
+
"database",
|
|
13
|
+
"cloud",
|
|
14
|
+
"security",
|
|
15
|
+
"messagebus",
|
|
16
|
+
"external"
|
|
17
|
+
];
|
|
18
|
+
const GRID = { origin: [40, 48], cols: 4, cellW: 184, cellH: 72, gapX: 64, gapY: 76 };
|
|
19
|
+
function ArchitectureFigure({
|
|
20
|
+
spec,
|
|
21
|
+
className,
|
|
22
|
+
legend = "auto",
|
|
23
|
+
cards = true,
|
|
24
|
+
heading = true,
|
|
25
|
+
activeIds,
|
|
26
|
+
onSelectNode
|
|
27
|
+
}) {
|
|
28
|
+
const uid = useId().replace(/:/g, "");
|
|
29
|
+
const model = useMemo(() => buildModel(spec, uid, activeIds, onSelectNode), [spec, uid, activeIds, onSelectNode]);
|
|
30
|
+
return /* @__PURE__ */ jsx(
|
|
31
|
+
DiagramFrame,
|
|
32
|
+
{
|
|
33
|
+
meta: spec.meta,
|
|
34
|
+
model,
|
|
35
|
+
cards: spec.cards,
|
|
36
|
+
showCards: cards,
|
|
37
|
+
heading,
|
|
38
|
+
legend,
|
|
39
|
+
className,
|
|
40
|
+
activeIds,
|
|
41
|
+
onSelectNode
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
function buildModel(spec, uid, activeIds, onSelectNode) {
|
|
46
|
+
const layout = { ...GRID, ...spec.layout };
|
|
47
|
+
const [originX, originY] = layout.origin;
|
|
48
|
+
const boxes = /* @__PURE__ */ new Map();
|
|
49
|
+
const placed = spec.components.map((component) => {
|
|
50
|
+
const labelWidth = component.size?.[0] ?? component.width ?? GRID.cellW;
|
|
51
|
+
const lines = wrapText(component.label, TYPE.label, labelWidth - PLATE.padX * 2, 2);
|
|
52
|
+
const height = plateHeight(
|
|
53
|
+
lines.length,
|
|
54
|
+
Boolean(component.sublabel),
|
|
55
|
+
Boolean(component.type || component.tag),
|
|
56
|
+
component.size?.[1] ?? component.height ?? 0
|
|
57
|
+
);
|
|
58
|
+
const x = component.pos ? component.pos[0] : originX + (component.col ?? 0) * (layout.cellW + layout.gapX);
|
|
59
|
+
const y = component.pos ? component.pos[1] : originY + (component.row ?? 0) * (layout.cellH + layout.gapY);
|
|
60
|
+
const box = { x, y, w: labelWidth, h: height };
|
|
61
|
+
boxes.set(component.id, box);
|
|
62
|
+
return { component, box };
|
|
63
|
+
});
|
|
64
|
+
const boundaries = (spec.boundaries ?? []).map((boundary) => {
|
|
65
|
+
const wrapped = boundary.wraps.map((id) => boxes.get(id)).filter((box) => Boolean(box));
|
|
66
|
+
const extent2 = union(wrapped);
|
|
67
|
+
return { boundary, box: extent2 ? inflate(extent2, boundary.pad ?? 28) : null };
|
|
68
|
+
});
|
|
69
|
+
const absolute = new Set(
|
|
70
|
+
spec.components.filter((component) => component.pos).map((component) => component.id)
|
|
71
|
+
);
|
|
72
|
+
const wires = wiresFor(spec.connections, boxes, (connection) => ({
|
|
73
|
+
keepWaypoints: absolute.has(connection.from) && absolute.has(connection.to)
|
|
74
|
+
}));
|
|
75
|
+
const { paths, labels } = renderWires(wires, uid);
|
|
76
|
+
const drawn = [...boxes.values(), ...boundaries.map((entry) => entry.box).filter((box) => Boolean(box))];
|
|
77
|
+
const extent = inflate(union(drawn) ?? { x: 0, y: 0, w: 400, h: 200 }, 22);
|
|
78
|
+
const active = activeIds && activeIds.length > 0 ? new Set(activeIds) : null;
|
|
79
|
+
const artwork = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
80
|
+
boundaries.map(
|
|
81
|
+
({ boundary, box }, index) => box ? /* @__PURE__ */ jsx(
|
|
82
|
+
BandFrame,
|
|
83
|
+
{
|
|
84
|
+
box,
|
|
85
|
+
label: boundary.label,
|
|
86
|
+
dashed: boundary.kind === "security-group"
|
|
87
|
+
},
|
|
88
|
+
`${boundary.label}-${index}`
|
|
89
|
+
) : null
|
|
90
|
+
),
|
|
91
|
+
paths,
|
|
92
|
+
placed.map(({ component, box }) => /* @__PURE__ */ jsx(
|
|
93
|
+
NodePlate,
|
|
94
|
+
{
|
|
95
|
+
nodeId: component.id,
|
|
96
|
+
box,
|
|
97
|
+
label: component.label,
|
|
98
|
+
sublabel: component.sublabel,
|
|
99
|
+
tag: component.tag,
|
|
100
|
+
kind: component.type,
|
|
101
|
+
active: active?.has(component.id),
|
|
102
|
+
dimmed: active ? !active.has(component.id) : false,
|
|
103
|
+
onSelect: onSelectNode ? () => onSelectNode(component.id) : void 0
|
|
104
|
+
},
|
|
105
|
+
component.id
|
|
106
|
+
)),
|
|
107
|
+
labels
|
|
108
|
+
] });
|
|
109
|
+
const used = [...new Set(spec.components.map((component) => component.type))];
|
|
110
|
+
return {
|
|
111
|
+
extent,
|
|
112
|
+
artwork,
|
|
113
|
+
nodes: spec.components.map((component) => ({
|
|
114
|
+
id: component.id,
|
|
115
|
+
label: component.label,
|
|
116
|
+
sublabel: component.sublabel,
|
|
117
|
+
kind: component.type
|
|
118
|
+
})),
|
|
119
|
+
edges: (spec.connections ?? []).map((connection) => ({
|
|
120
|
+
from: connection.from,
|
|
121
|
+
to: connection.to,
|
|
122
|
+
label: connection.label
|
|
123
|
+
})),
|
|
124
|
+
legend: kindLegend(
|
|
125
|
+
resolveLegend(used, spec.meta.legend?.mode, KINDS, spec.meta.legend?.entries),
|
|
126
|
+
spec.meta.legend?.entries
|
|
127
|
+
)
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
var ArchitectureFigure_default = ArchitectureFigure;
|
|
131
|
+
export {
|
|
132
|
+
ArchitectureFigure,
|
|
133
|
+
ArchitectureFigure_default as default
|
|
134
|
+
};
|
|
135
|
+
//# sourceMappingURL=ArchitectureFigure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/diagrams/ArchitectureFigure/ArchitectureFigure.tsx"],"sourcesContent":["'use client'\n\nimport { useId, useMemo } from 'react'\nimport { DiagramFrame, type FigureChrome, type FigureModel } from '../lib/frame'\nimport { inflate, TYPE, union, wrapText, type Box } from '../lib/geometry'\nimport { kindLegend, resolveLegend } from '../lib/legend'\nimport { BandFrame, NodePlate, PLATE, plateHeight } from '../lib/marks'\nimport { renderWires, wiresFor } from '../lib/wires'\nimport type { ArchitectureSpec, NodeKind } from '../spec'\n\nconst KINDS: NodeKind[] = [\n 'frontend',\n 'backend',\n 'database',\n 'cloud',\n 'security',\n 'messagebus',\n 'external',\n]\n\n/** What a cell is, when the specification does not say. */\nconst GRID = { origin: [40, 48] as [number, number], cols: 4, cellW: 184, cellH: 72, gapX: 64, gapY: 76 }\n\nexport interface ArchitectureFigureProps extends FigureChrome {\n spec: ArchitectureSpec\n}\n\n/**\n * A component map: services, datastores, boundaries, and what talks to what.\n *\n * Takes the same JSON an archify `architecture` specification carries —\n * `components`, `boundaries`, `connections` — and draws it in this system's own\n * terms: paper plates on a hairline, one reversed plate for the component the\n * diagram is about, and seven drawn sigils where archify uses seven hues.\n *\n * IT RENDERS ON A SERVER because every position is already in the\n * specification. A component gives a `row` and a `col` into a grid whose cell\n * size is either declared or defaulted, or it gives an absolute `pos` — nothing\n * here is solved for, relaxed, or measured. So the markup is a pure function of\n * the input, it is identical on the server and in the browser, and there is no\n * layout shift on hydration because there is no layout to do.\n *\n * BOUNDARIES ARE DRAWN FIRST, AND DRAWN DIFFERENTLY. A `region` is where\n * something RUNS — a VPC, a zone, a cluster — and is a solid frame. A\n * `security-group` is what may REACH it, and is dashed. That is not decoration:\n * an infrastructure diagram is very often read for exactly one of those two\n * questions, and a reader should be able to tell which line answers which\n * without reading either label.\n *\n * @example\n * <ArchitectureFigure\n * spec={{\n * meta: { title: 'Request path' },\n * components: [\n * { id: 'cdn', type: 'cloud', label: 'CloudFront', row: 0, col: 0 },\n * { id: 'api', type: 'backend', label: 'FastAPI', sublabel: 'ECS', row: 0, col: 1 },\n * { id: 'db', type: 'database', label: 'Postgres', row: 0, col: 2 },\n * ],\n * connections: [\n * { from: 'cdn', to: 'api', label: 'HTTPS' },\n * { from: 'api', to: 'db', label: 'SQL' },\n * ],\n * }}\n * />\n */\nexport function ArchitectureFigure({\n spec,\n className,\n legend = 'auto',\n cards = true,\n heading = true,\n activeIds,\n onSelectNode,\n}: ArchitectureFigureProps) {\n const uid = useId().replace(/:/g, '')\n const model = useMemo(() => buildModel(spec, uid, activeIds, onSelectNode), [spec, uid, activeIds, onSelectNode])\n\n return (\n <DiagramFrame\n meta={spec.meta}\n model={model}\n cards={spec.cards}\n showCards={cards}\n heading={heading}\n legend={legend}\n className={className}\n activeIds={activeIds}\n onSelectNode={onSelectNode}\n />\n )\n}\n\nfunction buildModel(\n spec: ArchitectureSpec,\n uid: string,\n activeIds: string[] | undefined,\n onSelectNode: ((id: string) => void) | undefined,\n): FigureModel {\n const layout = { ...GRID, ...spec.layout }\n const [originX, originY] = layout.origin\n\n const boxes = new Map<string, Box>()\n const placed = spec.components.map((component) => {\n const labelWidth = component.size?.[0] ?? component.width ?? GRID.cellW\n const lines = wrapText(component.label, TYPE.label, labelWidth - PLATE.padX * 2, 2)\n const height = plateHeight(\n lines.length,\n Boolean(component.sublabel),\n Boolean(component.type || component.tag),\n component.size?.[1] ?? component.height ?? 0,\n )\n\n const x = component.pos ? component.pos[0] : originX + (component.col ?? 0) * (layout.cellW + layout.gapX)\n const y = component.pos ? component.pos[1] : originY + (component.row ?? 0) * (layout.cellH + layout.gapY)\n\n const box: Box = { x, y, w: labelWidth, h: height }\n boxes.set(component.id, box)\n return { component, box }\n })\n\n const boundaries = (spec.boundaries ?? []).map((boundary) => {\n const wrapped = boundary.wraps.map((id) => boxes.get(id)).filter((box): box is Box => Boolean(box))\n const extent = union(wrapped)\n return { boundary, box: extent ? inflate(extent, boundary.pad ?? 28) : null }\n })\n\n // A waypoint is only meaningful when both ends were placed absolutely — see\n // `Wire.keepWaypoints`. In this diagram type that is common, which is why an\n // archify architecture specification's hand-tuned routes survive the move.\n const absolute = new Set(\n spec.components.filter((component) => component.pos).map((component) => component.id),\n )\n const wires = wiresFor(spec.connections, boxes, (connection) => ({\n keepWaypoints: absolute.has(connection.from) && absolute.has(connection.to),\n }))\n const { paths, labels } = renderWires(wires, uid)\n\n const drawn = [...boxes.values(), ...boundaries.map((entry) => entry.box).filter((box): box is Box => Boolean(box))]\n const extent = inflate(union(drawn) ?? { x: 0, y: 0, w: 400, h: 200 }, 22)\n\n const active = activeIds && activeIds.length > 0 ? new Set(activeIds) : null\n\n const artwork = (\n <>\n {boundaries.map(({ boundary, box }, index) =>\n box ? (\n <BandFrame\n key={`${boundary.label}-${index}`}\n box={box}\n label={boundary.label}\n dashed={boundary.kind === 'security-group'}\n />\n ) : null,\n )}\n {paths}\n {placed.map(({ component, box }) => (\n <NodePlate\n key={component.id}\n nodeId={component.id}\n box={box}\n label={component.label}\n sublabel={component.sublabel}\n tag={component.tag}\n kind={component.type}\n active={active?.has(component.id)}\n dimmed={active ? !active.has(component.id) : false}\n onSelect={onSelectNode ? () => onSelectNode(component.id) : undefined}\n />\n ))}\n {labels}\n </>\n )\n\n const used = [...new Set(spec.components.map((component) => component.type))]\n\n return {\n extent,\n artwork,\n nodes: spec.components.map((component) => ({\n id: component.id,\n label: component.label,\n sublabel: component.sublabel,\n kind: component.type,\n })),\n edges: (spec.connections ?? []).map((connection) => ({\n from: connection.from,\n to: connection.to,\n label: connection.label,\n })),\n legend: kindLegend(\n resolveLegend(used, spec.meta.legend?.mode, KINDS, spec.meta.legend?.entries),\n spec.meta.legend?.entries,\n ),\n }\n}\n\nexport default ArchitectureFigure\n"],"mappings":"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","names":["extent"]}
|