@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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { FigureChrome } from '../lib/frame.js';
|
|
3
|
+
import { DataflowSpec } from '../spec.js';
|
|
4
|
+
import '../lib/geometry.js';
|
|
5
|
+
|
|
6
|
+
interface DataflowFigureProps extends FigureChrome {
|
|
7
|
+
spec: DataflowSpec;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A pipeline: where data comes from, what happens to it, and who ends up with
|
|
11
|
+
* it.
|
|
12
|
+
*
|
|
13
|
+
* Structurally close to an architecture map and read for a completely different
|
|
14
|
+
* question, which is why it is a separate renderer rather than a preset. An
|
|
15
|
+
* architecture diagram is read for "what talks to what". A data-flow diagram is
|
|
16
|
+
* read for "what is IN this arrow" — and that question is why `classification`
|
|
17
|
+
* gets its own mono chip under the label rather than being folded into the
|
|
18
|
+
* wording. `clickstream / PII touch` and `clickstream` are two different facts,
|
|
19
|
+
* and a governance reviewer is looking for the second one.
|
|
20
|
+
*
|
|
21
|
+
* Stages are printed as column headings on a rule across the top. They are the
|
|
22
|
+
* axis of the figure: a node's `stage` is its position along the pipeline, and
|
|
23
|
+
* a reader should be able to answer "how far has this got" by looking up rather
|
|
24
|
+
* than by tracing arrows.
|
|
25
|
+
*/
|
|
26
|
+
declare function DataflowFigure({ spec, className, legend, cards, heading, activeIds, onSelectNode, }: DataflowFigureProps): react.JSX.Element;
|
|
27
|
+
|
|
28
|
+
export { DataflowFigure, type DataflowFigureProps, DataflowFigure as default };
|
|
@@ -0,0 +1,129 @@
|
|
|
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, round, TYPE, union, wrapText } from "../lib/geometry.js";
|
|
6
|
+
import { kindLegend, resolveLegend } from "../lib/legend.js";
|
|
7
|
+
import { 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 = { originX: 40, originY: 92, colW: 184, gapX: 78, rowH: 52, nodeH: 74 };
|
|
19
|
+
function DataflowFigure({
|
|
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(
|
|
30
|
+
() => buildModel(spec, uid, activeIds, onSelectNode),
|
|
31
|
+
[spec, uid, activeIds, onSelectNode]
|
|
32
|
+
);
|
|
33
|
+
return /* @__PURE__ */ jsx(
|
|
34
|
+
DiagramFrame,
|
|
35
|
+
{
|
|
36
|
+
meta: spec.meta,
|
|
37
|
+
model,
|
|
38
|
+
cards: spec.cards,
|
|
39
|
+
showCards: cards,
|
|
40
|
+
heading,
|
|
41
|
+
legend,
|
|
42
|
+
className,
|
|
43
|
+
activeIds,
|
|
44
|
+
onSelectNode
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
function buildModel(spec, uid, activeIds, onSelectNode) {
|
|
49
|
+
const boxes = /* @__PURE__ */ new Map();
|
|
50
|
+
const placed = spec.nodes.map((node) => {
|
|
51
|
+
const w = node.width ?? GRID.colW;
|
|
52
|
+
const lines = wrapText(node.label, TYPE.label, w - PLATE.padX * 2, 2);
|
|
53
|
+
const h = plateHeight(lines.length, Boolean(node.sublabel), true, node.height ?? 0);
|
|
54
|
+
const x = GRID.originX + node.stage * (GRID.colW + GRID.gapX);
|
|
55
|
+
const y = GRID.originY + node.row * (GRID.rowH + GRID.nodeH) + (node.yOffset ?? 0);
|
|
56
|
+
const box = { x, y, w, h };
|
|
57
|
+
boxes.set(node.id, box);
|
|
58
|
+
return { node, box };
|
|
59
|
+
});
|
|
60
|
+
const wires = wiresFor(spec.flows, boxes);
|
|
61
|
+
const { paths, labels } = renderWires(wires, uid);
|
|
62
|
+
const extent = inflate(union([...boxes.values()]) ?? { x: 0, y: 0, w: 500, h: 240 }, 30);
|
|
63
|
+
const headerY = Math.min(GRID.originY - 30, extent.y + 28);
|
|
64
|
+
const active = activeIds && activeIds.length > 0 ? new Set(activeIds) : null;
|
|
65
|
+
const artwork = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
66
|
+
/* @__PURE__ */ jsx(
|
|
67
|
+
"path",
|
|
68
|
+
{
|
|
69
|
+
d: `M ${round(extent.x + 14)} ${round(headerY + 8)} H ${round(extent.x + extent.w - 14)}`,
|
|
70
|
+
className: "stroke-(--diagram-rule) [stroke-width:1]",
|
|
71
|
+
"aria-hidden": "true"
|
|
72
|
+
}
|
|
73
|
+
),
|
|
74
|
+
spec.stages.map((stage, index) => /* @__PURE__ */ jsx(
|
|
75
|
+
"text",
|
|
76
|
+
{
|
|
77
|
+
x: round(GRID.originX + index * (GRID.colW + GRID.gapX)),
|
|
78
|
+
y: round(headerY),
|
|
79
|
+
className: "fill-(--diagram-ink-3) font-mono [font-size:10.5px] [letter-spacing:0.08em] uppercase",
|
|
80
|
+
"aria-hidden": "true",
|
|
81
|
+
children: stage.label
|
|
82
|
+
},
|
|
83
|
+
`${stage.label}-${index}`
|
|
84
|
+
)),
|
|
85
|
+
paths,
|
|
86
|
+
placed.map(({ node, box }) => /* @__PURE__ */ jsx(
|
|
87
|
+
NodePlate,
|
|
88
|
+
{
|
|
89
|
+
nodeId: node.id,
|
|
90
|
+
box,
|
|
91
|
+
label: node.label,
|
|
92
|
+
sublabel: node.sublabel,
|
|
93
|
+
tag: node.tag,
|
|
94
|
+
kind: node.type,
|
|
95
|
+
active: active?.has(node.id),
|
|
96
|
+
dimmed: active ? !active.has(node.id) : false,
|
|
97
|
+
onSelect: onSelectNode ? () => onSelectNode(node.id) : void 0
|
|
98
|
+
},
|
|
99
|
+
node.id
|
|
100
|
+
)),
|
|
101
|
+
labels
|
|
102
|
+
] });
|
|
103
|
+
const used = [...new Set(spec.nodes.map((node) => node.type))];
|
|
104
|
+
return {
|
|
105
|
+
extent: { ...extent, y: headerY - 22, h: extent.h + (extent.y - (headerY - 22)) },
|
|
106
|
+
artwork,
|
|
107
|
+
nodes: spec.nodes.map((node) => ({
|
|
108
|
+
id: node.id,
|
|
109
|
+
label: node.label,
|
|
110
|
+
sublabel: node.sublabel,
|
|
111
|
+
kind: node.type
|
|
112
|
+
})),
|
|
113
|
+
edges: (spec.flows ?? []).map((flow) => ({
|
|
114
|
+
from: flow.from,
|
|
115
|
+
to: flow.to,
|
|
116
|
+
label: [flow.label, flow.classification].filter(Boolean).join(" \u2014 ") || void 0
|
|
117
|
+
})),
|
|
118
|
+
legend: kindLegend(
|
|
119
|
+
resolveLegend(used, spec.meta.legend?.mode, KINDS, spec.meta.legend?.entries),
|
|
120
|
+
spec.meta.legend?.entries
|
|
121
|
+
)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
var DataflowFigure_default = DataflowFigure;
|
|
125
|
+
export {
|
|
126
|
+
DataflowFigure,
|
|
127
|
+
DataflowFigure_default as default
|
|
128
|
+
};
|
|
129
|
+
//# sourceMappingURL=DataflowFigure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/diagrams/DataflowFigure/DataflowFigure.tsx"],"sourcesContent":["'use client'\n\nimport { useId, useMemo } from 'react'\nimport { DiagramFrame, type FigureChrome, type FigureModel } from '../lib/frame'\nimport { inflate, round, TYPE, union, wrapText, type Box } from '../lib/geometry'\nimport { kindLegend, resolveLegend } from '../lib/legend'\nimport { NodePlate, PLATE, plateHeight } from '../lib/marks'\nimport { renderWires, wiresFor } from '../lib/wires'\nimport type { DataflowSpec, NodeKind } from '../spec'\n\nconst KINDS: NodeKind[] = [\n 'frontend',\n 'backend',\n 'database',\n 'cloud',\n 'security',\n 'messagebus',\n 'external',\n]\n\nconst GRID = { originX: 40, originY: 92, colW: 184, gapX: 78, rowH: 52, nodeH: 74 }\n\nexport interface DataflowFigureProps extends FigureChrome {\n spec: DataflowSpec\n}\n\n/**\n * A pipeline: where data comes from, what happens to it, and who ends up with\n * it.\n *\n * Structurally close to an architecture map and read for a completely different\n * question, which is why it is a separate renderer rather than a preset. An\n * architecture diagram is read for \"what talks to what\". A data-flow diagram is\n * read for \"what is IN this arrow\" — and that question is why `classification`\n * gets its own mono chip under the label rather than being folded into the\n * wording. `clickstream / PII touch` and `clickstream` are two different facts,\n * and a governance reviewer is looking for the second one.\n *\n * Stages are printed as column headings on a rule across the top. They are the\n * axis of the figure: a node's `stage` is its position along the pipeline, and\n * a reader should be able to answer \"how far has this got\" by looking up rather\n * than by tracing arrows.\n */\nexport function DataflowFigure({\n spec,\n className,\n legend = 'auto',\n cards = true,\n heading = true,\n activeIds,\n onSelectNode,\n}: DataflowFigureProps) {\n const uid = useId().replace(/:/g, '')\n const model = useMemo(\n () => buildModel(spec, uid, activeIds, onSelectNode),\n [spec, uid, activeIds, onSelectNode],\n )\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: DataflowSpec,\n uid: string,\n activeIds: string[] | undefined,\n onSelectNode: ((id: string) => void) | undefined,\n): FigureModel {\n const boxes = new Map<string, Box>()\n const placed = spec.nodes.map((node) => {\n const w = node.width ?? GRID.colW\n const lines = wrapText(node.label, TYPE.label, w - PLATE.padX * 2, 2)\n const h = plateHeight(lines.length, Boolean(node.sublabel), true, node.height ?? 0)\n\n const x = GRID.originX + node.stage * (GRID.colW + GRID.gapX)\n const y = GRID.originY + node.row * (GRID.rowH + GRID.nodeH) + (node.yOffset ?? 0)\n\n const box: Box = { x, y, w, h }\n boxes.set(node.id, box)\n return { node, box }\n })\n\n // Waypoints refer to the author's own grid, not this one — see\n // `Wire.keepWaypoints`. A data-flow specification always places by stage and\n // row, so there is never a case where honouring them would be correct.\n const wires = wiresFor(spec.flows, boxes)\n const { paths, labels } = renderWires(wires, uid)\n\n const extent = inflate(union([...boxes.values()]) ?? { x: 0, y: 0, w: 500, h: 240 }, 30)\n const headerY = Math.min(GRID.originY - 30, extent.y + 28)\n const active = activeIds && activeIds.length > 0 ? new Set(activeIds) : null\n\n const artwork = (\n <>\n {/* Stage headings, on one rule across the whole figure. */}\n <path\n d={`M ${round(extent.x + 14)} ${round(headerY + 8)} H ${round(extent.x + extent.w - 14)}`}\n className=\"stroke-(--diagram-rule) [stroke-width:1]\"\n aria-hidden=\"true\"\n />\n {spec.stages.map((stage, index) => (\n <text\n key={`${stage.label}-${index}`}\n x={round(GRID.originX + index * (GRID.colW + GRID.gapX))}\n y={round(headerY)}\n className=\"fill-(--diagram-ink-3) font-mono [font-size:10.5px] [letter-spacing:0.08em] uppercase\"\n aria-hidden=\"true\"\n >\n {stage.label}\n </text>\n ))}\n\n {paths}\n\n {placed.map(({ node, box }) => (\n <NodePlate\n key={node.id}\n nodeId={node.id}\n box={box}\n label={node.label}\n sublabel={node.sublabel}\n tag={node.tag}\n kind={node.type}\n active={active?.has(node.id)}\n dimmed={active ? !active.has(node.id) : false}\n onSelect={onSelectNode ? () => onSelectNode(node.id) : undefined}\n />\n ))}\n\n {labels}\n </>\n )\n\n const used = [...new Set(spec.nodes.map((node) => node.type))]\n\n return {\n extent: { ...extent, y: headerY - 22, h: extent.h + (extent.y - (headerY - 22)) },\n artwork,\n nodes: spec.nodes.map((node) => ({\n id: node.id,\n label: node.label,\n sublabel: node.sublabel,\n kind: node.type,\n })),\n edges: (spec.flows ?? []).map((flow) => ({\n from: flow.from,\n to: flow.to,\n label: [flow.label, flow.classification].filter(Boolean).join(' — ') || undefined,\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 DataflowFigure\n"],"mappings":";AA2DI,SA6CA,UA7CA,KA6CA,YA7CA;AAzDJ,SAAS,OAAO,eAAe;AAC/B,SAAS,oBAAyD;AAClE,SAAS,SAAS,OAAO,MAAM,OAAO,gBAA0B;AAChE,SAAS,YAAY,qBAAqB;AAC1C,SAAS,WAAW,OAAO,mBAAmB;AAC9C,SAAS,aAAa,gBAAgB;AAGtC,MAAM,QAAoB;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,MAAM,OAAO,EAAE,SAAS,IAAI,SAAS,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,IAAI,OAAO,GAAG;AAuB3E,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,UAAU;AAAA,EACV;AAAA,EACA;AACF,GAAwB;AACtB,QAAM,MAAM,MAAM,EAAE,QAAQ,MAAM,EAAE;AACpC,QAAM,QAAQ;AAAA,IACZ,MAAM,WAAW,MAAM,KAAK,WAAW,YAAY;AAAA,IACnD,CAAC,MAAM,KAAK,WAAW,YAAY;AAAA,EACrC;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAM,KAAK;AAAA,MACX;AAAA,MACA,OAAO,KAAK;AAAA,MACZ,WAAW;AAAA,MACX;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;AAEA,SAAS,WACP,MACA,KACA,WACA,cACa;AACb,QAAM,QAAQ,oBAAI,IAAiB;AACnC,QAAM,SAAS,KAAK,MAAM,IAAI,CAAC,SAAS;AACtC,UAAM,IAAI,KAAK,SAAS,KAAK;AAC7B,UAAM,QAAQ,SAAS,KAAK,OAAO,KAAK,OAAO,IAAI,MAAM,OAAO,GAAG,CAAC;AACpE,UAAM,IAAI,YAAY,MAAM,QAAQ,QAAQ,KAAK,QAAQ,GAAG,MAAM,KAAK,UAAU,CAAC;AAElF,UAAM,IAAI,KAAK,UAAU,KAAK,SAAS,KAAK,OAAO,KAAK;AACxD,UAAM,IAAI,KAAK,UAAU,KAAK,OAAO,KAAK,OAAO,KAAK,UAAU,KAAK,WAAW;AAEhF,UAAM,MAAW,EAAE,GAAG,GAAG,GAAG,EAAE;AAC9B,UAAM,IAAI,KAAK,IAAI,GAAG;AACtB,WAAO,EAAE,MAAM,IAAI;AAAA,EACrB,CAAC;AAKD,QAAM,QAAQ,SAAS,KAAK,OAAO,KAAK;AACxC,QAAM,EAAE,OAAO,OAAO,IAAI,YAAY,OAAO,GAAG;AAEhD,QAAM,SAAS,QAAQ,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,CAAC,KAAK,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,KAAK,GAAG,IAAI,GAAG,EAAE;AACvF,QAAM,UAAU,KAAK,IAAI,KAAK,UAAU,IAAI,OAAO,IAAI,EAAE;AACzD,QAAM,SAAS,aAAa,UAAU,SAAS,IAAI,IAAI,IAAI,SAAS,IAAI;AAExE,QAAM,UACJ,iCAEE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,GAAG,KAAK,MAAM,OAAO,IAAI,EAAE,CAAC,IAAI,MAAM,UAAU,CAAC,CAAC,MAAM,MAAM,OAAO,IAAI,OAAO,IAAI,EAAE,CAAC;AAAA,QACvF,WAAU;AAAA,QACV,eAAY;AAAA;AAAA,IACd;AAAA,IACC,KAAK,OAAO,IAAI,CAAC,OAAO,UACvB;AAAA,MAAC;AAAA;AAAA,QAEC,GAAG,MAAM,KAAK,UAAU,SAAS,KAAK,OAAO,KAAK,KAAK;AAAA,QACvD,GAAG,MAAM,OAAO;AAAA,QAChB,WAAU;AAAA,QACV,eAAY;AAAA,QAEX,gBAAM;AAAA;AAAA,MANF,GAAG,MAAM,KAAK,IAAI,KAAK;AAAA,IAO9B,CACD;AAAA,IAEA;AAAA,IAEA,OAAO,IAAI,CAAC,EAAE,MAAM,IAAI,MACvB;AAAA,MAAC;AAAA;AAAA,QAEC,QAAQ,KAAK;AAAA,QACb;AAAA,QACA,OAAO,KAAK;AAAA,QACZ,UAAU,KAAK;AAAA,QACf,KAAK,KAAK;AAAA,QACV,MAAM,KAAK;AAAA,QACX,QAAQ,QAAQ,IAAI,KAAK,EAAE;AAAA,QAC3B,QAAQ,SAAS,CAAC,OAAO,IAAI,KAAK,EAAE,IAAI;AAAA,QACxC,UAAU,eAAe,MAAM,aAAa,KAAK,EAAE,IAAI;AAAA;AAAA,MATlD,KAAK;AAAA,IAUZ,CACD;AAAA,IAEA;AAAA,KACH;AAGF,QAAM,OAAO,CAAC,GAAG,IAAI,IAAI,KAAK,MAAM,IAAI,CAAC,SAAS,KAAK,IAAI,CAAC,CAAC;AAE7D,SAAO;AAAA,IACL,QAAQ,EAAE,GAAG,QAAQ,GAAG,UAAU,IAAI,GAAG,OAAO,KAAK,OAAO,KAAK,UAAU,KAAK;AAAA,IAChF;AAAA,IACA,OAAO,KAAK,MAAM,IAAI,CAAC,UAAU;AAAA,MAC/B,IAAI,KAAK;AAAA,MACT,OAAO,KAAK;AAAA,MACZ,UAAU,KAAK;AAAA,MACf,MAAM,KAAK;AAAA,IACb,EAAE;AAAA,IACF,QAAQ,KAAK,SAAS,CAAC,GAAG,IAAI,CAAC,UAAU;AAAA,MACvC,MAAM,KAAK;AAAA,MACX,IAAI,KAAK;AAAA,MACT,OAAO,CAAC,KAAK,OAAO,KAAK,cAAc,EAAE,OAAO,OAAO,EAAE,KAAK,UAAK,KAAK;AAAA,IAC1E,EAAE;AAAA,IACF,QAAQ;AAAA,MACN,cAAc,MAAM,KAAK,KAAK,QAAQ,MAAM,OAAO,KAAK,KAAK,QAAQ,OAAO;AAAA,MAC5E,KAAK,KAAK,QAAQ;AAAA,IACpB;AAAA,EACF;AACF;AAEA,IAAO,yBAAQ;","names":[]}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode, Ref } from 'react';
|
|
3
|
+
|
|
4
|
+
interface CanvasView {
|
|
5
|
+
scale: number;
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
}
|
|
9
|
+
/** What a caller can do to the canvas from outside it. */
|
|
10
|
+
interface DiagramCanvasHandle {
|
|
11
|
+
zoomIn: () => void;
|
|
12
|
+
zoomOut: () => void;
|
|
13
|
+
reset: () => void;
|
|
14
|
+
/** Pans so a point in CONTENT coordinates sits in the middle of the frame. */
|
|
15
|
+
centerOn: (x: number, y: number) => void;
|
|
16
|
+
view: CanvasView;
|
|
17
|
+
}
|
|
18
|
+
interface DiagramCanvasProps {
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
/** How tall the frame is. Anything CSS accepts. */
|
|
22
|
+
height?: string;
|
|
23
|
+
/** Told about every view change, for a minimap or a percentage readout. */
|
|
24
|
+
onViewChange?: (view: CanvasView) => void;
|
|
25
|
+
/** Hides the built-in zoom controls, for a caller supplying its own. */
|
|
26
|
+
controls?: boolean;
|
|
27
|
+
/** Names the region for assistive tech. */
|
|
28
|
+
label?: string;
|
|
29
|
+
ref?: Ref<DiagramCanvasHandle>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A frame that a picture larger than it can be moved around inside.
|
|
33
|
+
*
|
|
34
|
+
* Pan with a drag, zoom with the controls or with ⌘/Ctrl and the wheel, and
|
|
35
|
+
* reset with a key. That is the whole of it — this is deliberately a VIEWPORT
|
|
36
|
+
* and not a diagram editor: nothing here knows what a node is, so it works for
|
|
37
|
+
* any oversized figure, an SVG, an image, a table that will not fold.
|
|
38
|
+
*
|
|
39
|
+
* Three decisions here are not obvious.
|
|
40
|
+
*
|
|
41
|
+
* **A plain wheel scrolls the page, not the diagram.** A canvas that swallows
|
|
42
|
+
* the wheel is a scroll trap: a reader flicking down an article hits the figure
|
|
43
|
+
* and the page stops moving for no reason they can see. Zooming needs the
|
|
44
|
+
* modifier — which is also the platform gesture for zoom everywhere else, and
|
|
45
|
+
* is what a trackpad pinch already sends.
|
|
46
|
+
*
|
|
47
|
+
* **The transform is on a wrapper, not on the content.** The child keeps its
|
|
48
|
+
* own coordinate space, so a figure inside can still be measured, exported and
|
|
49
|
+
* read by anything that walks it. Scaling the child directly would make every
|
|
50
|
+
* `getBoundingClientRect` inside it a lie.
|
|
51
|
+
*
|
|
52
|
+
* **Keyboard first, and the frame is a real tab stop.** `+` / `-` / `0` and the
|
|
53
|
+
* arrow keys move the view; the frame takes focus so they can be pressed at
|
|
54
|
+
* all. A canvas that only answers a drag is a canvas half the readers cannot
|
|
55
|
+
* operate.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* <DiagramCanvas height="26rem" label="Request path">
|
|
59
|
+
* <ArchitectureFigure spec={spec} heading={false} legend="hidden" cards={false} />
|
|
60
|
+
* </DiagramCanvas>
|
|
61
|
+
*/
|
|
62
|
+
declare function DiagramCanvas({ children, className, height, onViewChange, controls, label, ref, }: DiagramCanvasProps): react.JSX.Element;
|
|
63
|
+
|
|
64
|
+
export { type CanvasView, DiagramCanvas, type DiagramCanvasHandle, type DiagramCanvasProps, DiagramCanvas as default };
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
useRef,
|
|
9
|
+
useState
|
|
10
|
+
} from "react";
|
|
11
|
+
import { cn } from "../../lib/cn.js";
|
|
12
|
+
const MIN_SCALE = 0.35;
|
|
13
|
+
const MAX_SCALE = 4;
|
|
14
|
+
const STEP = 1.25;
|
|
15
|
+
function DiagramCanvas({
|
|
16
|
+
children,
|
|
17
|
+
className,
|
|
18
|
+
height = "24rem",
|
|
19
|
+
onViewChange,
|
|
20
|
+
controls = true,
|
|
21
|
+
label = "Diagram canvas",
|
|
22
|
+
ref
|
|
23
|
+
}) {
|
|
24
|
+
const uid = useId().replace(/:/g, "");
|
|
25
|
+
const frame = useRef(null);
|
|
26
|
+
const [view, setView] = useState({ scale: 1, x: 0, y: 0 });
|
|
27
|
+
const drag = useRef(null);
|
|
28
|
+
const apply = useCallback(
|
|
29
|
+
(next) => {
|
|
30
|
+
setView(next);
|
|
31
|
+
onViewChange?.(next);
|
|
32
|
+
},
|
|
33
|
+
[onViewChange]
|
|
34
|
+
);
|
|
35
|
+
const zoomAbout = useCallback(
|
|
36
|
+
(factor, originX, originY) => {
|
|
37
|
+
const box = frame.current?.getBoundingClientRect();
|
|
38
|
+
const cx = originX ?? (box ? box.width / 2 : 0);
|
|
39
|
+
const cy = originY ?? (box ? box.height / 2 : 0);
|
|
40
|
+
setView((current) => {
|
|
41
|
+
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, current.scale * factor));
|
|
42
|
+
const ratio = scale / current.scale;
|
|
43
|
+
const next = {
|
|
44
|
+
scale,
|
|
45
|
+
x: cx - (cx - current.x) * ratio,
|
|
46
|
+
y: cy - (cy - current.y) * ratio
|
|
47
|
+
};
|
|
48
|
+
onViewChange?.(next);
|
|
49
|
+
return next;
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
[onViewChange]
|
|
53
|
+
);
|
|
54
|
+
useImperativeHandle(
|
|
55
|
+
ref,
|
|
56
|
+
() => ({
|
|
57
|
+
zoomIn: () => zoomAbout(STEP),
|
|
58
|
+
zoomOut: () => zoomAbout(1 / STEP),
|
|
59
|
+
reset: () => apply({ scale: 1, x: 0, y: 0 }),
|
|
60
|
+
centerOn: (x, y) => {
|
|
61
|
+
const box = frame.current?.getBoundingClientRect();
|
|
62
|
+
if (!box) return;
|
|
63
|
+
setView((current) => {
|
|
64
|
+
const next = {
|
|
65
|
+
...current,
|
|
66
|
+
x: box.width / 2 - x * current.scale,
|
|
67
|
+
y: box.height / 2 - y * current.scale
|
|
68
|
+
};
|
|
69
|
+
onViewChange?.(next);
|
|
70
|
+
return next;
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
view
|
|
74
|
+
}),
|
|
75
|
+
[apply, onViewChange, view, zoomAbout]
|
|
76
|
+
);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const node = frame.current;
|
|
79
|
+
if (!node) return;
|
|
80
|
+
const onWheel = (event) => {
|
|
81
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
const box = node.getBoundingClientRect();
|
|
84
|
+
zoomAbout(
|
|
85
|
+
event.deltaY < 0 ? STEP : 1 / STEP,
|
|
86
|
+
event.clientX - box.left,
|
|
87
|
+
event.clientY - box.top
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
node.addEventListener("wheel", onWheel, { passive: false });
|
|
91
|
+
return () => node.removeEventListener("wheel", onWheel);
|
|
92
|
+
}, [zoomAbout]);
|
|
93
|
+
const onPointerDown = (event) => {
|
|
94
|
+
if (event.button !== 0) return;
|
|
95
|
+
drag.current = { pointer: event.pointerId, x: event.clientX - view.x, y: event.clientY - view.y };
|
|
96
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
97
|
+
};
|
|
98
|
+
const onPointerMove = (event) => {
|
|
99
|
+
const state = drag.current;
|
|
100
|
+
if (!state || state.pointer !== event.pointerId) return;
|
|
101
|
+
apply({ ...view, x: event.clientX - state.x, y: event.clientY - state.y });
|
|
102
|
+
};
|
|
103
|
+
const endDrag = (event) => {
|
|
104
|
+
if (drag.current?.pointer !== event.pointerId) return;
|
|
105
|
+
drag.current = null;
|
|
106
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
107
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return /* @__PURE__ */ jsxs(
|
|
111
|
+
"div",
|
|
112
|
+
{
|
|
113
|
+
className: cn(
|
|
114
|
+
"relative overflow-hidden rounded-(--radius-lg) border border-(--rule) bg-(--diagram-surface)",
|
|
115
|
+
className
|
|
116
|
+
),
|
|
117
|
+
style: { height },
|
|
118
|
+
children: [
|
|
119
|
+
/* @__PURE__ */ jsx(
|
|
120
|
+
"div",
|
|
121
|
+
{
|
|
122
|
+
ref: frame,
|
|
123
|
+
role: "group",
|
|
124
|
+
"aria-label": label,
|
|
125
|
+
"aria-describedby": `${uid}-hint`,
|
|
126
|
+
tabIndex: 0,
|
|
127
|
+
className: "size-full cursor-grab touch-none select-none outline-none focus-visible:ring-2 focus-visible:ring-(--ring) focus-visible:ring-inset active:cursor-grabbing",
|
|
128
|
+
onPointerDown,
|
|
129
|
+
onPointerMove,
|
|
130
|
+
onPointerUp: endDrag,
|
|
131
|
+
onPointerCancel: endDrag,
|
|
132
|
+
onKeyDown: (event) => {
|
|
133
|
+
const nudge = event.shiftKey ? 80 : 24;
|
|
134
|
+
switch (event.key) {
|
|
135
|
+
case "+":
|
|
136
|
+
case "=":
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
zoomAbout(STEP);
|
|
139
|
+
break;
|
|
140
|
+
case "-":
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
zoomAbout(1 / STEP);
|
|
143
|
+
break;
|
|
144
|
+
case "0":
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
apply({ scale: 1, x: 0, y: 0 });
|
|
147
|
+
break;
|
|
148
|
+
case "ArrowLeft":
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
apply({ ...view, x: view.x + nudge });
|
|
151
|
+
break;
|
|
152
|
+
case "ArrowRight":
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
apply({ ...view, x: view.x - nudge });
|
|
155
|
+
break;
|
|
156
|
+
case "ArrowUp":
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
apply({ ...view, y: view.y + nudge });
|
|
159
|
+
break;
|
|
160
|
+
case "ArrowDown":
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
apply({ ...view, y: view.y - nudge });
|
|
163
|
+
break;
|
|
164
|
+
default:
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
children: /* @__PURE__ */ jsx(
|
|
169
|
+
"div",
|
|
170
|
+
{
|
|
171
|
+
className: "origin-top-left will-change-transform",
|
|
172
|
+
style: { transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` },
|
|
173
|
+
"data-diagram-stage": "",
|
|
174
|
+
children
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
),
|
|
179
|
+
/* @__PURE__ */ jsx("p", { id: `${uid}-hint`, className: "sr-only", children: "Drag to pan. Plus and minus zoom, zero resets, the arrow keys pan." }),
|
|
180
|
+
controls && /* @__PURE__ */ jsxs("div", { className: "absolute bottom-3 end-3 flex items-center gap-px rounded-(--radius) border border-(--rule-2) bg-(--panel-bg) p-0.5", children: [
|
|
181
|
+
/* @__PURE__ */ jsx(CanvasButton, { label: "Zoom out", onClick: () => zoomAbout(1 / STEP), children: /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "\u2212" }) }),
|
|
182
|
+
/* @__PURE__ */ jsxs(
|
|
183
|
+
"button",
|
|
184
|
+
{
|
|
185
|
+
type: "button",
|
|
186
|
+
onClick: () => apply({ scale: 1, x: 0, y: 0 }),
|
|
187
|
+
"aria-label": "Reset the view",
|
|
188
|
+
className: "min-w-12 rounded-(--radius-xs) px-1.5 py-1 mono-meta text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)",
|
|
189
|
+
children: [
|
|
190
|
+
Math.round(view.scale * 100),
|
|
191
|
+
"%"
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
),
|
|
195
|
+
/* @__PURE__ */ jsx(CanvasButton, { label: "Zoom in", onClick: () => zoomAbout(STEP), children: /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "+" }) })
|
|
196
|
+
] })
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
function CanvasButton({
|
|
202
|
+
label,
|
|
203
|
+
onClick,
|
|
204
|
+
children
|
|
205
|
+
}) {
|
|
206
|
+
return /* @__PURE__ */ jsx(
|
|
207
|
+
"button",
|
|
208
|
+
{
|
|
209
|
+
type: "button",
|
|
210
|
+
onClick,
|
|
211
|
+
"aria-label": label,
|
|
212
|
+
className: "flex size-7 items-center justify-center rounded-(--radius-xs) font-mono text-[13px] leading-none text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)",
|
|
213
|
+
children
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
var DiagramCanvas_default = DiagramCanvas;
|
|
218
|
+
export {
|
|
219
|
+
DiagramCanvas,
|
|
220
|
+
DiagramCanvas_default as default
|
|
221
|
+
};
|
|
222
|
+
//# sourceMappingURL=DiagramCanvas.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/diagrams/DiagramCanvas/DiagramCanvas.tsx"],"sourcesContent":["'use client'\n\nimport {\n useCallback,\n useEffect,\n useId,\n useImperativeHandle,\n useRef,\n useState,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n type Ref,\n} from 'react'\nimport { cn } from '../../lib/cn'\n\nconst MIN_SCALE = 0.35\nconst MAX_SCALE = 4\nconst STEP = 1.25\n\nexport interface CanvasView {\n scale: number\n x: number\n y: number\n}\n\n/** What a caller can do to the canvas from outside it. */\nexport interface DiagramCanvasHandle {\n zoomIn: () => void\n zoomOut: () => void\n reset: () => void\n /** Pans so a point in CONTENT coordinates sits in the middle of the frame. */\n centerOn: (x: number, y: number) => void\n view: CanvasView\n}\n\nexport interface DiagramCanvasProps {\n children: ReactNode\n className?: string\n /** How tall the frame is. Anything CSS accepts. */\n height?: string\n /** Told about every view change, for a minimap or a percentage readout. */\n onViewChange?: (view: CanvasView) => void\n /** Hides the built-in zoom controls, for a caller supplying its own. */\n controls?: boolean\n /** Names the region for assistive tech. */\n label?: string\n ref?: Ref<DiagramCanvasHandle>\n}\n\n/**\n * A frame that a picture larger than it can be moved around inside.\n *\n * Pan with a drag, zoom with the controls or with ⌘/Ctrl and the wheel, and\n * reset with a key. That is the whole of it — this is deliberately a VIEWPORT\n * and not a diagram editor: nothing here knows what a node is, so it works for\n * any oversized figure, an SVG, an image, a table that will not fold.\n *\n * Three decisions here are not obvious.\n *\n * **A plain wheel scrolls the page, not the diagram.** A canvas that swallows\n * the wheel is a scroll trap: a reader flicking down an article hits the figure\n * and the page stops moving for no reason they can see. Zooming needs the\n * modifier — which is also the platform gesture for zoom everywhere else, and\n * is what a trackpad pinch already sends.\n *\n * **The transform is on a wrapper, not on the content.** The child keeps its\n * own coordinate space, so a figure inside can still be measured, exported and\n * read by anything that walks it. Scaling the child directly would make every\n * `getBoundingClientRect` inside it a lie.\n *\n * **Keyboard first, and the frame is a real tab stop.** `+` / `-` / `0` and the\n * arrow keys move the view; the frame takes focus so they can be pressed at\n * all. A canvas that only answers a drag is a canvas half the readers cannot\n * operate.\n *\n * @example\n * <DiagramCanvas height=\"26rem\" label=\"Request path\">\n * <ArchitectureFigure spec={spec} heading={false} legend=\"hidden\" cards={false} />\n * </DiagramCanvas>\n */\nexport function DiagramCanvas({\n children,\n className,\n height = '24rem',\n onViewChange,\n controls = true,\n label = 'Diagram canvas',\n ref,\n}: DiagramCanvasProps) {\n const uid = useId().replace(/:/g, '')\n const frame = useRef<HTMLDivElement>(null)\n const [view, setView] = useState<CanvasView>({ scale: 1, x: 0, y: 0 })\n const drag = useRef<{ pointer: number; x: number; y: number } | null>(null)\n\n const apply = useCallback(\n (next: CanvasView) => {\n setView(next)\n onViewChange?.(next)\n },\n [onViewChange],\n )\n\n /**\n * Zooms about a fixed point in FRAME coordinates.\n *\n * The arithmetic is what makes the pointer stay on the thing it is over:\n * the content point under the cursor must land back under the cursor, so the\n * offset moves by the difference the scale change opened up. Without it, a\n * zoom drifts toward the origin and a reader chases the node they were\n * looking at.\n */\n const zoomAbout = useCallback(\n (factor: number, originX?: number, originY?: number) => {\n const box = frame.current?.getBoundingClientRect()\n const cx = originX ?? (box ? box.width / 2 : 0)\n const cy = originY ?? (box ? box.height / 2 : 0)\n\n setView((current) => {\n const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, current.scale * factor))\n const ratio = scale / current.scale\n const next = {\n scale,\n x: cx - (cx - current.x) * ratio,\n y: cy - (cy - current.y) * ratio,\n }\n onViewChange?.(next)\n return next\n })\n },\n [onViewChange],\n )\n\n useImperativeHandle(\n ref,\n () => ({\n zoomIn: () => zoomAbout(STEP),\n zoomOut: () => zoomAbout(1 / STEP),\n reset: () => apply({ scale: 1, x: 0, y: 0 }),\n centerOn: (x: number, y: number) => {\n const box = frame.current?.getBoundingClientRect()\n if (!box) return\n setView((current) => {\n const next = {\n ...current,\n x: box.width / 2 - x * current.scale,\n y: box.height / 2 - y * current.scale,\n }\n onViewChange?.(next)\n return next\n })\n },\n view,\n }),\n [apply, onViewChange, view, zoomAbout],\n )\n\n // Registered imperatively because React marks `wheel` passive, and a passive\n // listener cannot call preventDefault — so the modifier-wheel zoom would also\n // scroll the page underneath it.\n useEffect(() => {\n const node = frame.current\n if (!node) return\n\n const onWheel = (event: WheelEvent) => {\n if (!event.ctrlKey && !event.metaKey) return\n event.preventDefault()\n const box = node.getBoundingClientRect()\n zoomAbout(\n event.deltaY < 0 ? STEP : 1 / STEP,\n event.clientX - box.left,\n event.clientY - box.top,\n )\n }\n\n node.addEventListener('wheel', onWheel, { passive: false })\n return () => node.removeEventListener('wheel', onWheel)\n }, [zoomAbout])\n\n const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n if (event.button !== 0) return\n drag.current = { pointer: event.pointerId, x: event.clientX - view.x, y: event.clientY - view.y }\n event.currentTarget.setPointerCapture(event.pointerId)\n }\n\n const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n const state = drag.current\n if (!state || state.pointer !== event.pointerId) return\n apply({ ...view, x: event.clientX - state.x, y: event.clientY - state.y })\n }\n\n const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n if (drag.current?.pointer !== event.pointerId) return\n drag.current = null\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n }\n\n return (\n <div\n className={cn(\n 'relative overflow-hidden rounded-(--radius-lg) border border-(--rule) bg-(--diagram-surface)',\n className,\n )}\n style={{ height }}\n >\n <div\n ref={frame}\n role=\"group\"\n aria-label={label}\n aria-describedby={`${uid}-hint`}\n tabIndex={0}\n className=\"size-full cursor-grab touch-none select-none outline-none focus-visible:ring-2 focus-visible:ring-(--ring) focus-visible:ring-inset active:cursor-grabbing\"\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={endDrag}\n onPointerCancel={endDrag}\n onKeyDown={(event) => {\n const nudge = event.shiftKey ? 80 : 24\n switch (event.key) {\n case '+':\n case '=':\n event.preventDefault()\n zoomAbout(STEP)\n break\n case '-':\n event.preventDefault()\n zoomAbout(1 / STEP)\n break\n case '0':\n event.preventDefault()\n apply({ scale: 1, x: 0, y: 0 })\n break\n case 'ArrowLeft':\n event.preventDefault()\n apply({ ...view, x: view.x + nudge })\n break\n case 'ArrowRight':\n event.preventDefault()\n apply({ ...view, x: view.x - nudge })\n break\n case 'ArrowUp':\n event.preventDefault()\n apply({ ...view, y: view.y + nudge })\n break\n case 'ArrowDown':\n event.preventDefault()\n apply({ ...view, y: view.y - nudge })\n break\n default:\n break\n }\n }}\n >\n <div\n className=\"origin-top-left will-change-transform\"\n style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}\n data-diagram-stage=\"\"\n >\n {children}\n </div>\n </div>\n\n <p id={`${uid}-hint`} className=\"sr-only\">\n Drag to pan. Plus and minus zoom, zero resets, the arrow keys pan.\n </p>\n\n {controls && (\n <div className=\"absolute bottom-3 end-3 flex items-center gap-px rounded-(--radius) border border-(--rule-2) bg-(--panel-bg) p-0.5\">\n <CanvasButton label=\"Zoom out\" onClick={() => zoomAbout(1 / STEP)}>\n <span aria-hidden>−</span>\n </CanvasButton>\n <button\n type=\"button\"\n onClick={() => apply({ scale: 1, x: 0, y: 0 })}\n aria-label=\"Reset the view\"\n className=\"min-w-12 rounded-(--radius-xs) px-1.5 py-1 mono-meta text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)\"\n >\n {Math.round(view.scale * 100)}%\n </button>\n <CanvasButton label=\"Zoom in\" onClick={() => zoomAbout(STEP)}>\n <span aria-hidden>+</span>\n </CanvasButton>\n </div>\n )}\n </div>\n )\n}\n\nfunction CanvasButton({\n label,\n onClick,\n children,\n}: {\n label: string\n onClick: () => void\n children: ReactNode\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-label={label}\n className=\"flex size-7 items-center justify-center rounded-(--radius-xs) font-mono text-[13px] leading-none text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)\"\n >\n {children}\n </button>\n )\n}\n\nexport default DiagramCanvas\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { RefObject, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/** The files a figure can be taken away as. */
|
|
5
|
+
type ExportFormat = 'png' | 'jpeg' | 'webp' | 'svg' | 'share-card';
|
|
6
|
+
/** What came of an export, for a page that wants to say so. */
|
|
7
|
+
interface ExportResult {
|
|
8
|
+
format: ExportFormat;
|
|
9
|
+
ok: boolean;
|
|
10
|
+
error?: Error;
|
|
11
|
+
}
|
|
12
|
+
interface DiagramExportMenuProps {
|
|
13
|
+
/**
|
|
14
|
+
* The `<svg>` to export, or an element containing exactly one.
|
|
15
|
+
*
|
|
16
|
+
* A ref rather than a selector, because a page can hold several figures and a
|
|
17
|
+
* selector would export whichever the document happened to reach first.
|
|
18
|
+
*/
|
|
19
|
+
targetRef: RefObject<HTMLElement | SVGSVGElement | null>;
|
|
20
|
+
/** Names the file, and is printed on the share card. */
|
|
21
|
+
title: string;
|
|
22
|
+
/** Replaces the trigger. */
|
|
23
|
+
trigger?: ReactNode;
|
|
24
|
+
className?: string;
|
|
25
|
+
/** Runs instead of the built-in export — for a caller with its own pipeline. */
|
|
26
|
+
onExport?: (format: ExportFormat) => void | Promise<void>;
|
|
27
|
+
/** Told what happened, so a page can raise a toast. */
|
|
28
|
+
onResult?: (result: ExportResult) => void;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Taking the figure off the page: five files, one menu.
|
|
32
|
+
*
|
|
33
|
+
* WHY THE MENU DOES THE WORK rather than handing back a format: every one of
|
|
34
|
+
* these exports is the same six steps — find the `<svg>`, walk it with
|
|
35
|
+
* `getComputedStyle` to bake the custom properties into real colours,
|
|
36
|
+
* serialise, rasterise, name the file, hand it to the browser — and only the
|
|
37
|
+
* last two differ between them. A menu that emitted `'png'` and left the caller
|
|
38
|
+
* to do the rest would be a menu that every consumer reimplements, badly, and
|
|
39
|
+
* the interesting half (a serialised SVG resolves `var(--ink)` to nothing and
|
|
40
|
+
* comes out invisible) is exactly the half a caller would not know to write.
|
|
41
|
+
*
|
|
42
|
+
* `onExport` is still there for a page with its own pipeline — a server-side
|
|
43
|
+
* renderer, a different frame size — and taking it turns everything below into
|
|
44
|
+
* a no-op.
|
|
45
|
+
*
|
|
46
|
+
* What each format actually is, stated rather than implied.
|
|
47
|
+
*
|
|
48
|
+
* **SVG** is the artwork with resolved colours. It is editable and it is the
|
|
49
|
+
* only lossless one, but it carries no web fonts: a machine without the family
|
|
50
|
+
* renders it in a fallback, so type metrics will differ.
|
|
51
|
+
*
|
|
52
|
+
* **PNG, JPEG and WebP** are the browser's own rasteriser re-drawing that SVG
|
|
53
|
+
* at 2×. Not a screenshot — antialiasing and any effect a page stylesheet
|
|
54
|
+
* applied from OUTSIDE the `<svg>` are not in it. **JPEG has no alpha**, so it
|
|
55
|
+
* is flattened onto the resolved paper colour rather than onto black, which is
|
|
56
|
+
* what a transparent PNG becomes when a format with no transparency is asked
|
|
57
|
+
* to hold it.
|
|
58
|
+
*
|
|
59
|
+
* **The share card** is a 1200 × 630 frame with the title on it and the whole
|
|
60
|
+
* diagram letterboxed inside — never cropped. A card that cropped to fill the
|
|
61
|
+
* frame would be a picture of a different diagram.
|
|
62
|
+
*/
|
|
63
|
+
declare function DiagramExportMenu({ targetRef, title, trigger, className, onExport, onResult, }: DiagramExportMenuProps): react.JSX.Element;
|
|
64
|
+
/**
|
|
65
|
+
* One figure, one format, one file.
|
|
66
|
+
*
|
|
67
|
+
* Exported so a page can wire a keyboard shortcut or its own button to exactly
|
|
68
|
+
* what the menu does, without reimplementing the six steps.
|
|
69
|
+
*/
|
|
70
|
+
declare function exportFigure(target: HTMLElement | SVGSVGElement | null, title: string, format: ExportFormat): Promise<void>;
|
|
71
|
+
|
|
72
|
+
export { DiagramExportMenu, type DiagramExportMenuProps, type ExportFormat, type ExportResult, DiagramExportMenu as default, exportFigure };
|