@liminis/diagrams 0.1.1 → 0.1.3

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 CHANGED
@@ -7,13 +7,15 @@ editing.
7
7
  Extracted from [`@liminis/editor`](https://github.com/verveguy/liminis-editor), where it
8
8
  renders ` ```c4 ` fenced code blocks. Nothing here is bound to that editor.
9
9
 
10
+ Documentation: **[v3rv.com/liminis-diagrams](https://v3rv.com/liminis-diagrams/)**.
11
+
10
12
  ## Demo
11
13
 
12
- **[https://v3rv.com/liminis-diagrams/](https://v3rv.com/liminis-diagrams/)** — edit
14
+ **[https://v3rv.com/liminis-diagrams/demo/](https://v3rv.com/liminis-diagrams/demo/)** — edit
13
15
  C4-PlantUML source and see it re-render live, drag nodes to reposition them, toggle dark
14
16
  mode, and switch between a few preset diagrams. The demo keeps dragged positions in
15
17
  memory only, for as long as the tab is open — this package has no persistence of its
16
- own (see [Recipe 3](docs/recipes.md#recipe-3-position-persistence--the-hosts-choice)),
18
+ own (see [Recipe 3](https://v3rv.com/liminis-diagrams/recipes/#recipe-3-position-persistence--the-hosts-choice)),
17
19
  and neither does this demo.
18
20
 
19
21
  ## Install
@@ -25,12 +27,12 @@ npm install @liminis/diagrams
25
27
  `react` and `react-dom` are **optional** peers. Installing the package gets you
26
28
  `@dagrejs/dagre` and nothing else, so `@liminis/diagrams/core` works in a CLI or CI job
27
29
  with no React on disk. Install the peers if you use `/react` or `/server` — see
28
- [`docs/architecture.md`](docs/architecture.md) for why the split exists and which entry
30
+ [Architecture](https://v3rv.com/liminis-diagrams/architecture/) for why the split exists and which entry
29
31
  point to pick.
30
32
 
31
33
  ## Not sure this package does what you're assuming?
32
34
 
33
- Read [Limitations](docs/README.md#limitations--read-this-first) before you build
35
+ Read [Limitations](https://v3rv.com/liminis-diagrams/#limitations--read-this-first) before you build
34
36
  against this package. In short: no editing UI, no persistence, element IDs aren't
35
37
  stable across diagrams, no cross-diagram links.
36
38
 
@@ -85,22 +87,35 @@ import { C4InteractiveRenderer } from '@liminis/diagrams/react';
85
87
 
86
88
  Pass `manualPositions` to `layoutC4Diagram` to bypass dagre for the elements you have
87
89
  positions for. Persisting them is entirely your call — see
88
- [Recipe 3](docs/recipes.md#recipe-3-position-persistence--the-hosts-choice) for a worked
90
+ [Recipe 3](https://v3rv.com/liminis-diagrams/recipes/#recipe-3-position-persistence--the-hosts-choice) for a worked
89
91
  example (including how `@liminis/editor` does it) and why this package itself never
90
92
  writes them anywhere.
91
93
 
94
+ ## Render on the command line
95
+
96
+ ```bash
97
+ npx --package=@liminis/diagrams --package=react --package=react-dom -- render-c4 diagram.puml
98
+ # diagram.puml -> diagram.svg
99
+ ```
100
+
101
+ Useful for pre-rendering diagrams so a plain `![Diagram](diagram.svg)` is enough for
102
+ GitHub (or any markdown renderer) to show them — see
103
+ [Rendering diagrams on GitHub](https://v3rv.com/liminis-diagrams/github-integration/) for the CI recipe, and
104
+ [Rendering diagrams in Claude Code](https://v3rv.com/liminis-diagrams/claude-code-integration/) for getting Claude
105
+ to render real diagrams instead of hand-drawing them.
106
+
92
107
  ## Supported syntax
93
108
 
94
109
  `Person`, `System`, `Container`, `Component` and their `_Ext` / `Db` / `Queue` variants,
95
110
  plus `Deployment_Node`, `Node`, and `InfrastructureNode` variants; boundary macros;
96
111
  `Rel` (with directional variants) and `BiRel`. See
97
- [`docs/dsl-reference.md`](docs/dsl-reference.md) for the full macro table and exactly
112
+ [the C4-PlantUML reference](https://v3rv.com/liminis-diagrams/dsl-reference/) for the full macro table and exactly
98
113
  which directives (`@startuml`, `!include`, `SHOW_LEGEND()`, `LAYOUT_*`, …) are applied
99
114
  versus silently stripped.
100
115
 
101
116
  ## Documentation
102
117
 
103
- Building a tool on top of this package? [`docs/`](docs/README.md) covers the
118
+ Building a tool on top of this package? [the documentation site](https://v3rv.com/liminis-diagrams/) covers the
104
119
  entry-point boundary, the full DSL reference, the data model, and runnable recipes for
105
120
  headless rendering, embedding the interactive renderer, and position persistence.
106
121
 
@@ -109,7 +124,7 @@ headless rendering, embedding the interactive renderer, and position persistence
109
124
  The commit history predates this repository: it was recovered from
110
125
  `verveguy/liminis` (`liminis-app/src/editor/app/editor/c4/`, later
111
126
  `packages/editor/src/app/editor/c4/`) and carries development from 2026-03-18 onward.
112
- `git log --follow` works across the move. See `docs/EXTRACTION-PLAN.md`.
127
+ `git log --follow` works across the move.
113
128
 
114
129
  ## License
115
130
 
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `render-c4` — render C4-PlantUML source files to SVG on disk.
4
+ *
5
+ * This is the CLI form of `renderC4DiagramToSVG` (`@liminis/diagrams/server`),
6
+ * meant for pre-rendering diagrams in CI so that a plain `![Diagram](x.svg)` in
7
+ * a markdown file is enough for GitHub (or any other markdown renderer) to show
8
+ * it — no live rendering service, no image-provider proxy, nothing to host.
9
+ * See https://v3rv.com/liminis-diagrams/github-integration/ for the recipe this exists for.
10
+ *
11
+ * No dependency is added for argument parsing: flags are hand-rolled to match
12
+ * the style of the other scripts in this repo (guard-publish.mjs,
13
+ * verify-package.mjs), and the surface here is small enough not to need one.
14
+ */
15
+ export interface Options {
16
+ files: string[];
17
+ /** Extract ```c4 fences from markdown inputs instead of treating the whole file as source. */
18
+ fromMarkdown: boolean;
19
+ dark: boolean;
20
+ out?: string;
21
+ outDir?: string;
22
+ check: boolean;
23
+ stdin: boolean;
24
+ }
25
+ export declare function parseArgs(argv: string[]): Options | null;
26
+ export declare function outputPathFor(inputPath: string, options: Options): string;
27
+ /**
28
+ * Null when `options` is a valid combination; otherwise the message to report
29
+ * (without the `render-c4: ` prefix `main` adds).
30
+ */
31
+ export declare function validateStdinCombination(options: Options): string | null;
32
+ /**
33
+ * ```c4 fences in a markdown file, with the 1-based line the fence body starts on
34
+ * so errors point at the real location in the file rather than at the snippet.
35
+ *
36
+ * Diagrams live in fenced blocks far more often than in standalone .puml files —
37
+ * that is how @liminis/editor stores them, how this package's own docs are
38
+ * written, and what a markdown-based diagram wiki produces. A renderer that only
39
+ * understood whole files would not fit the common case.
40
+ *
41
+ * A fence tagged `invalid` is skipped: a page documenting parse errors needs
42
+ * source that does not parse, and that is content rather than a defect.
43
+ */
44
+ export declare function extractC4Fences(markdown: string): {
45
+ source: string;
46
+ line: number;
47
+ index: number;
48
+ }[];
49
+ export declare function renderFiles(options: Options): number;
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `render-c4` — render C4-PlantUML source files to SVG on disk.
4
+ *
5
+ * This is the CLI form of `renderC4DiagramToSVG` (`@liminis/diagrams/server`),
6
+ * meant for pre-rendering diagrams in CI so that a plain `![Diagram](x.svg)` in
7
+ * a markdown file is enough for GitHub (or any other markdown renderer) to show
8
+ * it — no live rendering service, no image-provider proxy, nothing to host.
9
+ * See https://v3rv.com/liminis-diagrams/github-integration/ for the recipe this exists for.
10
+ *
11
+ * No dependency is added for argument parsing: flags are hand-rolled to match
12
+ * the style of the other scripts in this repo (guard-publish.mjs,
13
+ * verify-package.mjs), and the surface here is small enough not to need one.
14
+ */
15
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
+ import { dirname, basename, extname, join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { renderC4DiagramToSVG } from '../server/render-to-string.js';
19
+ function printUsage() {
20
+ console.log(`Usage: render-c4 [options] <files...>
21
+
22
+ Render C4-PlantUML source files to SVG.
23
+
24
+ Options:
25
+ --from-markdown Read fenced c4 blocks out of markdown inputs rather than
26
+ treating each whole file as diagram source
27
+ --dark Render in dark mode
28
+ -o, --out <file> Output path (only valid with exactly one input file)
29
+ --out-dir <dir> Write outputs here, preserving basenames (.svg extension)
30
+ --check Validate only — write nothing, exit non-zero on any error
31
+ --stdin Read source from stdin, write SVG to stdout
32
+ -h, --help Show this help
33
+ `);
34
+ }
35
+ export function parseArgs(argv) {
36
+ const options = { files: [], fromMarkdown: false, dark: false, check: false, stdin: false };
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const arg = argv[i];
39
+ switch (arg) {
40
+ case '-h':
41
+ case '--help':
42
+ return null;
43
+ case '--dark':
44
+ options.dark = true;
45
+ break;
46
+ case '--from-markdown':
47
+ options.fromMarkdown = true;
48
+ break;
49
+ case '--check':
50
+ options.check = true;
51
+ break;
52
+ case '--stdin':
53
+ options.stdin = true;
54
+ break;
55
+ case '-o':
56
+ case '--out':
57
+ options.out = argv[++i];
58
+ break;
59
+ case '--out-dir':
60
+ options.outDir = argv[++i];
61
+ break;
62
+ default:
63
+ options.files.push(arg);
64
+ }
65
+ }
66
+ return options;
67
+ }
68
+ export function outputPathFor(inputPath, options) {
69
+ if (options.out)
70
+ return options.out;
71
+ const svgName = `${basename(inputPath, extname(inputPath))}.svg`;
72
+ return options.outDir ? join(options.outDir, svgName) : join(dirname(inputPath), svgName);
73
+ }
74
+ function renderStdin(dark) {
75
+ const chunks = [];
76
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
77
+ process.stdin.on('end', () => {
78
+ const source = Buffer.concat(chunks).toString('utf-8');
79
+ const { svg, errors } = renderC4DiagramToSVG(source, dark);
80
+ if (errors.length > 0) {
81
+ for (const error of errors) {
82
+ console.error(`<stdin>:${error.line}:${error.column}: ${error.message}`);
83
+ }
84
+ process.exitCode = 2;
85
+ return;
86
+ }
87
+ process.stdout.write(svg);
88
+ });
89
+ }
90
+ /**
91
+ * Null when `options` is a valid combination; otherwise the message to report
92
+ * (without the `render-c4: ` prefix `main` adds).
93
+ */
94
+ export function validateStdinCombination(options) {
95
+ if (options.stdin && (options.check || options.out !== undefined || options.outDir !== undefined || options.files.length > 0)) {
96
+ return '--stdin cannot be combined with --check, -o/--out, --out-dir, or file arguments';
97
+ }
98
+ return null;
99
+ }
100
+ /**
101
+ * ```c4 fences in a markdown file, with the 1-based line the fence body starts on
102
+ * so errors point at the real location in the file rather than at the snippet.
103
+ *
104
+ * Diagrams live in fenced blocks far more often than in standalone .puml files —
105
+ * that is how @liminis/editor stores them, how this package's own docs are
106
+ * written, and what a markdown-based diagram wiki produces. A renderer that only
107
+ * understood whole files would not fit the common case.
108
+ *
109
+ * A fence tagged `invalid` is skipped: a page documenting parse errors needs
110
+ * source that does not parse, and that is content rather than a defect.
111
+ */
112
+ export function extractC4Fences(markdown) {
113
+ const out = [];
114
+ const fence = /^```c4([^\n]*)\n([\s\S]*?)\n```$/gm;
115
+ let index = 0;
116
+ for (const match of markdown.matchAll(fence)) {
117
+ if (/\binvalid\b/.test(match[1] ?? ''))
118
+ continue;
119
+ index++;
120
+ const before = markdown.slice(0, match.index ?? 0);
121
+ out.push({
122
+ source: match[2],
123
+ line: before.split('\n').length + 1,
124
+ index,
125
+ });
126
+ }
127
+ return out;
128
+ }
129
+ function renderMarkdownFences(options) {
130
+ let failures = 0;
131
+ for (const inputPath of options.files) {
132
+ let markdown;
133
+ try {
134
+ markdown = readFileSync(inputPath, 'utf-8');
135
+ }
136
+ catch (err) {
137
+ failures++;
138
+ console.error(`${inputPath}: ${err instanceof Error ? err.message : String(err)}`);
139
+ continue;
140
+ }
141
+ const fences = extractC4Fences(markdown);
142
+ if (fences.length === 0)
143
+ continue;
144
+ for (const fence of fences) {
145
+ const { svg, errors } = renderC4DiagramToSVG(fence.source, options.dark);
146
+ if (errors.length > 0) {
147
+ failures++;
148
+ for (const error of errors) {
149
+ // Offset into the containing file, so the message is navigable.
150
+ console.error(`${inputPath}:${fence.line + error.line - 1}:${error.column}: ${error.message}`);
151
+ }
152
+ continue;
153
+ }
154
+ if (options.check)
155
+ continue;
156
+ const base = basename(inputPath, extname(inputPath));
157
+ const svgName = `${base}-${fence.index}.svg`;
158
+ const outPath = options.outDir
159
+ ? join(options.outDir, svgName)
160
+ : join(dirname(inputPath), svgName);
161
+ try {
162
+ mkdirSync(dirname(outPath), { recursive: true });
163
+ writeFileSync(outPath, svg);
164
+ }
165
+ catch (err) {
166
+ failures++;
167
+ console.error(`${inputPath}: failed to write ${outPath}: ${err instanceof Error ? err.message : String(err)}`);
168
+ continue;
169
+ }
170
+ console.log(`${inputPath} [${fence.index}] -> ${outPath}`);
171
+ }
172
+ }
173
+ return failures > 0 ? 2 : 0;
174
+ }
175
+ export function renderFiles(options) {
176
+ if (options.fromMarkdown)
177
+ return renderMarkdownFences(options);
178
+ if (options.out && options.files.length > 1) {
179
+ console.error('render-c4: -o/--out only applies with a single input file');
180
+ return 1;
181
+ }
182
+ let failures = 0;
183
+ for (const inputPath of options.files) {
184
+ let source;
185
+ try {
186
+ source = readFileSync(inputPath, 'utf-8');
187
+ }
188
+ catch (err) {
189
+ failures++;
190
+ console.error(`${inputPath}: ${err instanceof Error ? err.message : String(err)}`);
191
+ continue;
192
+ }
193
+ const { svg, errors } = renderC4DiagramToSVG(source, options.dark);
194
+ if (errors.length > 0) {
195
+ failures++;
196
+ for (const error of errors) {
197
+ console.error(`${inputPath}:${error.line}:${error.column}: ${error.message}`);
198
+ }
199
+ continue;
200
+ }
201
+ if (options.check)
202
+ continue;
203
+ const outPath = outputPathFor(inputPath, options);
204
+ try {
205
+ mkdirSync(dirname(outPath), { recursive: true });
206
+ writeFileSync(outPath, svg);
207
+ }
208
+ catch (err) {
209
+ failures++;
210
+ console.error(`${inputPath}: failed to write ${outPath}: ${err instanceof Error ? err.message : String(err)}`);
211
+ continue;
212
+ }
213
+ console.log(`${inputPath} -> ${outPath}`);
214
+ }
215
+ return failures > 0 ? 2 : 0;
216
+ }
217
+ function main() {
218
+ const rawArgs = process.argv.slice(2);
219
+ const options = parseArgs(rawArgs);
220
+ if (!options) {
221
+ printUsage();
222
+ return;
223
+ }
224
+ const stdinConflict = validateStdinCombination(options);
225
+ if (stdinConflict) {
226
+ console.error(`render-c4: ${stdinConflict}`);
227
+ process.exitCode = 1;
228
+ return;
229
+ }
230
+ if (options.files.length === 0 && !options.stdin) {
231
+ if (rawArgs.length === 0) {
232
+ printUsage();
233
+ process.exitCode = 1;
234
+ return;
235
+ }
236
+ // Flags were given (e.g. `--check` over a glob that matched nothing) but no
237
+ // files resolved — "nothing to do" is success, not a usage error, so a CI
238
+ // step like `render-c4 --check $(git ls-files '*.puml')` doesn't fail a repo
239
+ // that has no diagrams yet.
240
+ console.log('render-c4: no input files');
241
+ return;
242
+ }
243
+ if (options.stdin) {
244
+ renderStdin(options.dark);
245
+ }
246
+ else {
247
+ process.exitCode = renderFiles(options);
248
+ }
249
+ }
250
+ // Only run when executed directly (`node render-c4.js`), not when imported —
251
+ // e.g. by the test file below, which exercises `parseArgs`/`outputPathFor` in
252
+ // isolation without wanting a real CLI invocation as a side effect of import.
253
+ const isMain = process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1];
254
+ if (isMain) {
255
+ main();
256
+ }
@@ -4,6 +4,7 @@
4
4
  * Computes where edge polylines intersect label bounding boxes and splits
5
5
  * them into visible segments, creating clean gaps around label text.
6
6
  */
7
+ import { svgNumber } from './precision.js';
7
8
  // =============================================================================
8
9
  // CONSTANTS
9
10
  // =============================================================================
@@ -127,12 +128,12 @@ export function buildClippedEdgePaths(points, labelCenter, labelHalfW, labelHalf
127
128
  }
128
129
  const paths = visibleSegments
129
130
  .filter((seg) => seg.length >= 2)
130
- .map((seg) => seg.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '));
131
+ .map((seg) => seg.map((p, i) => `${i === 0 ? 'M' : 'L'} ${svgNumber(p.x)} ${svgNumber(p.y)}`).join(' '));
131
132
  // Fallback: if clipping consumed the entire edge, draw the original path
132
133
  // rather than leaving a floating arrowhead with no line
133
134
  if (paths.length === 0 && points.length >= 2) {
134
135
  return [
135
- points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '),
136
+ points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${svgNumber(p.x)} ${svgNumber(p.y)}`).join(' '),
136
137
  ];
137
138
  }
138
139
  return paths;
@@ -6,6 +6,7 @@
6
6
  * with proper boundary group padding.
7
7
  */
8
8
  import dagre from '@dagrejs/dagre';
9
+ import { svgNumber } from './precision.js';
9
10
  // =============================================================================
10
11
  // CONSTANTS
11
12
  // =============================================================================
@@ -756,29 +757,63 @@ function layoutWithManualPositions(diagram, options, manualPositions) {
756
757
  }
757
758
  // Calculate edges using existing function
758
759
  const edges = calculateEdges(diagram.relationships, nodeMap);
759
- // Calculate diagram bounding box (nodes may have negative coordinates
760
- // when dragged past the top/left edge)
760
+ return { nodes: allNodes, edges, ...boundsFor(allNodes, edges) };
761
+ }
762
+ /**
763
+ * The viewBox and dimensions that actually contain the laid-out diagram.
764
+ *
765
+ * Shared by both layout paths, because they were only ever different by
766
+ * accident. The manual path always computed a real bounding box — dragging a
767
+ * node above or left of the origin produces negative coordinates, and the
768
+ * viewBox origin moves to cover them rather than shifting every node, which
769
+ * would desynchronise rendered positions from the positions a host persists.
770
+ *
771
+ * The auto path hardcoded `viewBoxX: 0, viewBoxY: 0` on the premise that dagre
772
+ * never emits negative coordinates. That premise was wrong, and documented as
773
+ * fact in data-model.md. Dagre's own output is non-negative, but the
774
+ * cross-boundary alignment pass that runs after it shifts elements outside a
775
+ * boundary to line up with their targets inside it, and that can move them left
776
+ * of the origin. The result was a `viewBox` starting at 0 with content at
777
+ * negative x: clipped, and unreachable by scrolling, because a viewBox is a
778
+ * window rather than a canvas. Four lines of C4 reproduce it — a Person outside
779
+ * a System_Boundary with a Rel to something inside.
780
+ *
781
+ * Edge points are included as well as node rects: an edge routed around a
782
+ * boundary can leave the union of the node rectangles.
783
+ *
784
+ * Diagrams that were already correct are unaffected. Where nothing sits within
785
+ * BOUNDARY_PADDING of the origin, `Math.min(0, …)` yields 0 and the width and
786
+ * height are what they were.
787
+ */
788
+ function boundsFor(nodes, edges) {
761
789
  let minX = Infinity;
762
790
  let minY = Infinity;
763
791
  let maxX = 0;
764
792
  let maxY = 0;
765
- for (const node of allNodes) {
793
+ for (const node of nodes) {
766
794
  minX = Math.min(minX, node.x);
767
795
  minY = Math.min(minY, node.y);
768
796
  maxX = Math.max(maxX, node.x + node.width);
769
797
  maxY = Math.max(maxY, node.y + node.height);
770
798
  }
771
- // Use viewBox origin to handle negative coordinates instead of shifting
772
- // nodes this keeps stored positions and rendered positions in sync
799
+ for (const edge of edges) {
800
+ for (const point of edge.points) {
801
+ minX = Math.min(minX, point.x);
802
+ minY = Math.min(minY, point.y);
803
+ maxX = Math.max(maxX, point.x);
804
+ maxY = Math.max(maxY, point.y);
805
+ }
806
+ }
807
+ // An empty diagram leaves the minima at Infinity; treat it as the origin.
808
+ if (!Number.isFinite(minX))
809
+ minX = 0;
810
+ if (!Number.isFinite(minY))
811
+ minY = 0;
773
812
  const viewBoxX = Math.min(0, minX - BOUNDARY_PADDING);
774
813
  const viewBoxY = Math.min(0, minY - BOUNDARY_PADDING);
775
- const width = maxX + BOUNDARY_PADDING - viewBoxX;
776
- const height = maxY + BOUNDARY_PADDING - viewBoxY;
777
814
  return {
778
- nodes: allNodes,
779
- edges,
780
- width,
781
- height,
815
+ width: maxX + BOUNDARY_PADDING - viewBoxX,
816
+ height: maxY + BOUNDARY_PADDING - viewBoxY,
782
817
  viewBoxX,
783
818
  viewBoxY,
784
819
  };
@@ -800,9 +835,16 @@ export function layoutC4Diagram(diagram, options, manualPositions) {
800
835
  ...DEFAULT_OPTIONS,
801
836
  ...options,
802
837
  };
803
- // Use manual layout if positions are provided
838
+ // Use manual layout if positions are provided. Rounded here rather than
839
+ // inside that function so the two layout paths cannot diverge on it: this is
840
+ // the branch the drag renderer takes, and the one `renderC4DiagramToSVG`
841
+ // takes when given `manualPositions`, so leaving it unrounded would have left
842
+ // the platform drift in place for exactly the diagrams a user had arranged
843
+ // by hand.
804
844
  if (manualPositions && Object.keys(manualPositions).length > 0) {
805
- return layoutWithManualPositions(diagram, mergedOptions, manualPositions);
845
+ const manual = layoutWithManualPositions(diagram, mergedOptions, manualPositions);
846
+ roundGeometryInPlace(manual);
847
+ return manual;
806
848
  }
807
849
  const topLevelElements = getTopLevelElements(diagram.elements);
808
850
  // Layout top-level elements, using diagram direction if specified
@@ -818,22 +860,46 @@ export function layoutC4Diagram(diagram, options, manualPositions) {
818
860
  alignCrossBoundaryElements(layoutNodes, diagram.relationships, nodeMap);
819
861
  // Calculate edges
820
862
  const edges = calculateEdges(diagram.relationships, nodeMap);
821
- // Calculate total diagram dimensions
822
- let width = 0;
823
- let height = 0;
824
- for (const node of allNodes) {
825
- width = Math.max(width, node.x + node.width);
826
- height = Math.max(height, node.y + node.height);
827
- }
828
- // Add margin
829
- width += BOUNDARY_PADDING;
830
- height += BOUNDARY_PADDING;
831
- return {
832
- nodes: allNodes,
833
- edges,
834
- width,
835
- height,
836
- viewBoxX: 0,
837
- viewBoxY: 0,
838
- };
863
+ const result = { nodes: allNodes, edges, ...boundsFor(allNodes, edges) };
864
+ roundGeometryInPlace(result);
865
+ return result;
866
+ }
867
+ /**
868
+ * Round every coordinate in a finished layout, in place.
869
+ *
870
+ * Done once here rather than at each of the ~80 places the renderer writes a
871
+ * coordinate into an attribute: the layout *is* the geometry, so producing it
872
+ * to a fixed precision means every consumer — the React renderer, the headless
873
+ * SVG serialiser, anything a host builds — inherits the same numbers without
874
+ * having to remember to round.
875
+ *
876
+ * In place rather than by copying, because a node appears both in the flat
877
+ * `nodes` list and in its parent's `children`, and rebuilding those would break
878
+ * the identity between them. `svgNumber` is idempotent, so visiting a node
879
+ * twice is harmless.
880
+ *
881
+ * See ./precision.ts for why this exists at all — the short version is that
882
+ * `atan2` is not bit-identical across platforms, and rendered SVGs get
883
+ * committed and diffed.
884
+ */
885
+ function roundGeometryInPlace(result) {
886
+ // `result.nodes` comes from flattenLayoutNodes, so it already contains every
887
+ // descendant — a node reached through `children` is the same object, and one
888
+ // pass over the flat list covers the tree.
889
+ for (const node of result.nodes) {
890
+ node.x = svgNumber(node.x);
891
+ node.y = svgNumber(node.y);
892
+ node.width = svgNumber(node.width);
893
+ node.height = svgNumber(node.height);
894
+ }
895
+ for (const edge of result.edges) {
896
+ edge.points = edge.points.map((point) => ({
897
+ x: svgNumber(point.x),
898
+ y: svgNumber(point.y),
899
+ }));
900
+ }
901
+ result.width = svgNumber(result.width);
902
+ result.height = svgNumber(result.height);
903
+ result.viewBoxX = svgNumber(result.viewBoxX);
904
+ result.viewBoxY = svgNumber(result.viewBoxY);
839
905
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Numeric precision at the boundary where geometry becomes SVG.
3
+ */
4
+ /** Round for emission into SVG, without leaving `1.500` where `1.5` will do. */
5
+ export declare function svgNumber(value: number): number;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Numeric precision at the boundary where geometry becomes SVG.
3
+ */
4
+ /**
5
+ * Decimal places kept when a computed number reaches the SVG.
6
+ *
7
+ * This is not cosmetic. IEEE 754 does not require `sin`, `cos` or `atan2` to be
8
+ * correctly rounded, so a platform's libm may return a result one unit in the
9
+ * last place away from another's. macOS and Linux disagree in exactly that way,
10
+ * and the disagreement reaches the output: an edge-label rotation came out as
11
+ * `-9.005931656396022` on one and `-9.005931656396024` on the other. Identical
12
+ * input, identical library version, different bytes.
13
+ *
14
+ * That matters because rendered SVGs get committed and checked for drift in CI.
15
+ * A check that fails depending on who ran it is worse than no check — it trains
16
+ * people to ignore it. Rounding at the boundary makes the output a function of
17
+ * the input alone.
18
+ *
19
+ * Three places is far below anything visible: at this diagram's scale a
20
+ * thousandth of a unit is a thousandth of a pixel, and a thousandth of a degree
21
+ * moves the end of a 200px label by 0.0035px.
22
+ */
23
+ const SVG_PRECISION = 3;
24
+ /** Round for emission into SVG, without leaving `1.500` where `1.5` will do. */
25
+ export function svgNumber(value) {
26
+ const factor = 10 ** SVG_PRECISION;
27
+ // `+0` rather than the bare result: -0 serialises as "-0", which differs from
28
+ // "0" bytewise while being the same number.
29
+ return Math.round(value * factor) / factor + 0;
30
+ }
@@ -25,6 +25,29 @@ export interface C4InteractiveRendererProps {
25
25
  x: number;
26
26
  y: number;
27
27
  }>) => void;
28
+ /**
29
+ * Scale factor for the rendered diagram. 1 is actual size; 2 draws it twice as
30
+ * large. Defaults to 1, so a host that does not care is unaffected.
31
+ *
32
+ * This scales the SVG's rendered `width`/`height` and leaves the `viewBox`
33
+ * alone, rather than applying a CSS transform. Three things follow, and they
34
+ * are the reason for doing it this way:
35
+ *
36
+ * - Dragging keeps working untouched. Screen coordinates are mapped through
37
+ * `getScreenCTM().inverse()`, and that matrix already carries the ratio
38
+ * between the viewBox and the rendered size — so a node still lands under
39
+ * the pointer at any zoom, with no arithmetic anywhere in the drag code.
40
+ * - The diagram genuinely occupies more space, so a scrolling container
41
+ * scrolls it. A CSS `transform` is painted after layout: the element still
42
+ * reports its unscaled size, and the overflow it appears to produce cannot
43
+ * be scrolled to.
44
+ * - Text stays sharp. It is re-rendered at the new scale rather than being a
45
+ * bitmap stretched over it.
46
+ *
47
+ * Controls are deliberately not included. What a zoom control should look like
48
+ * is the host's business — this package renders diagrams.
49
+ */
50
+ zoom?: number;
28
51
  }
29
52
  /**
30
53
  * Interactive C4 diagram renderer with drag support.
@@ -32,5 +55,5 @@ export interface C4InteractiveRendererProps {
32
55
  * When isEditMode is true, nodes can be dragged to new positions.
33
56
  * Edges and boundaries are recalculated in real-time during drag.
34
57
  */
35
- export declare function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualPositions, onPositionChange, }: C4InteractiveRendererProps): JSX.Element;
58
+ export declare function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualPositions, onPositionChange, zoom, }: C4InteractiveRendererProps): JSX.Element;
36
59
  export default C4InteractiveRenderer;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
- import { C4RendererContent, computeLegendInfo } from './renderer.js';
3
+ import { C4RendererContent, computeLegendInfo, normaliseZoom } from './renderer.js';
4
4
  import { layoutC4Diagram } from '../core/layout.js';
5
5
  import { useC4DiagramDrag } from './hooks/useC4DiagramDrag.js';
6
6
  /** Synthetic ID used to store legend position in manual positions map */
@@ -51,7 +51,7 @@ function collectDescendantIds(elements) {
51
51
  * When isEditMode is true, nodes can be dragged to new positions.
52
52
  * Edges and boundaries are recalculated in real-time during drag.
53
53
  */
54
- export function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualPositions, onPositionChange, }) {
54
+ export function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualPositions, onPositionChange, zoom = 1, }) {
55
55
  const svgRef = useRef(null);
56
56
  // Local positions during drag (merged with persisted positions)
57
57
  const [dragPositions, setDragPositions] = useState({});
@@ -222,12 +222,15 @@ export function C4InteractiveRenderer({ diagram, isDarkMode, isEditMode, manualP
222
222
  return pos ?? null;
223
223
  }, [effectivePositions]);
224
224
  // Render with interactive wrappers
225
- return (_jsx(C4InteractiveSvg, { layout: layout, isDarkMode: isDarkMode, isEditMode: isEditMode, draggedNodeId: draggedNodeId, svgRef: svgRef, onNodeMouseDown: startNodeDrag, legendInfo: legendInfo, legendPositionOverride: legendPositionOverride }));
225
+ return (_jsx(C4InteractiveSvg, { layout: layout, isDarkMode: isDarkMode, isEditMode: isEditMode, draggedNodeId: draggedNodeId, svgRef: svgRef, onNodeMouseDown: startNodeDrag, legendInfo: legendInfo, legendPositionOverride: legendPositionOverride, zoom: zoom }));
226
226
  }
227
227
  /**
228
228
  * SVG wrapper that adds interactive overlays for drag handling.
229
229
  */
230
- function C4InteractiveSvg({ layout, isDarkMode, isEditMode, draggedNodeId, svgRef, onNodeMouseDown, legendInfo, legendPositionOverride, }) {
230
+ function C4InteractiveSvg({ layout, isDarkMode, isEditMode, draggedNodeId, svgRef, onNodeMouseDown, legendInfo, legendPositionOverride, zoom, }) {
231
+ // Guarded here as well as in C4Renderer: both are public entry points, and a
232
+ // host reaching this one never passes through the other.
233
+ const scale = normaliseZoom(zoom);
231
234
  // Get colors based on theme
232
235
  const handleColor = isDarkMode ? '#a0a0a0' : '#505050';
233
236
  // Create hit areas for each node (and legend if present)
@@ -276,7 +279,7 @@ function C4InteractiveSvg({ layout, isDarkMode, isEditMode, draggedNodeId, svgRe
276
279
  }
277
280
  return { svgWidth: w, svgHeight: h };
278
281
  }, [layout.width, layout.height, layout.viewBoxX, layout.viewBoxY, legendInfo, legendPositionOverride]);
279
- return (_jsx("div", { style: { position: 'relative' }, children: _jsxs("svg", { ref: svgRef, width: svgWidth, height: svgHeight, viewBox: `${layout.viewBoxX} ${layout.viewBoxY} ${svgWidth} ${svgHeight}`, xmlns: "http://www.w3.org/2000/svg", "data-diagram": "c4", style: {
282
+ return (_jsx("div", { style: { position: 'relative' }, children: _jsxs("svg", { ref: svgRef, width: svgWidth * scale, height: svgHeight * scale, viewBox: `${layout.viewBoxX} ${layout.viewBoxY} ${svgWidth} ${svgHeight}`, xmlns: "http://www.w3.org/2000/svg", "data-diagram": "c4", style: {
280
283
  fontFamily: 'system-ui, -apple-system, sans-serif',
281
284
  cursor: isEditMode ? (draggedNodeId ? 'grabbing' : 'default') : 'default',
282
285
  }, children: [_jsx(C4RendererContent, { layout: layout, isDarkMode: isDarkMode, legendPositionOverride: legendPositionOverride }), isEditMode && (_jsx("g", { className: "interactive-layer", children: hitAreas.map((area) => (_jsx("rect", { "data-node-id": area.id, x: area.x, y: area.y, width: area.width, height: area.height, fill: "transparent", stroke: "transparent", style: {
@@ -27,11 +27,33 @@ export declare function computeLegendInfo(layout: LayoutResult): {
27
27
  width: number;
28
28
  height: number;
29
29
  } | null;
30
+ /**
31
+ * A zoom factor that cannot produce an invalid SVG.
32
+ *
33
+ * `width`/`height` are attributes on a public entry point, so whatever a host
34
+ * passes ends up in the DOM. `0` collapses the diagram, a negative number is
35
+ * invalid, and `NaN` — the likely one, arriving from an uninitialised state
36
+ * variable or a division — renders nothing at all with no error to explain it.
37
+ * Anything that is not a positive finite number falls back to actual size.
38
+ *
39
+ * The upper bound is a guard against a browser being asked to lay out a
40
+ * hundred-thousand-pixel SVG, not a considered maximum; the lower bound is the
41
+ * point past which a diagram is a smudge. A host wanting more can scale the
42
+ * container.
43
+ */
44
+ export declare function normaliseZoom(zoom: number): number;
30
45
  export interface C4RendererProps {
31
46
  /** Layout result from the layout engine */
32
47
  layout: LayoutResult;
33
48
  /** Whether dark mode is enabled */
34
49
  isDarkMode: boolean;
50
+ /**
51
+ * Scale factor for the rendered diagram. 1 is actual size. Scales the SVG's
52
+ * width/height and leaves the viewBox alone, so text is re-rendered at the new
53
+ * scale rather than stretched, and a scrolling container can actually scroll
54
+ * the result. Defaults to 1.
55
+ */
56
+ zoom?: number;
35
57
  }
36
58
  /**
37
59
  * C4 Diagram SVG Renderer.
@@ -39,7 +61,7 @@ export interface C4RendererProps {
39
61
  * Pure component: takes layout + theme, returns SVG.
40
62
  * Used directly in the editor and via renderToStaticMarkup for publishing.
41
63
  */
42
- export declare function C4Renderer({ layout, isDarkMode }: C4RendererProps): JSX.Element;
64
+ export declare function C4Renderer({ layout, isDarkMode, zoom }: C4RendererProps): JSX.Element;
43
65
  /**
44
66
  * Renders C4 diagram content (nodes, edges, legend) as SVG group elements.
45
67
  * Exported for use by C4InteractiveRenderer to embed diagram content
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { buildClippedEdgePaths } from '../core/edge-clipping.js';
3
+ import { svgNumber } from '../core/precision.js';
3
4
  // =============================================================================
4
5
  // CONSTANTS
5
6
  // =============================================================================
@@ -75,7 +76,11 @@ function calculateArrowheadPoints(startPoint, endPoint) {
75
76
  const baseX = endPoint.x - ux * ARROW_SIZE;
76
77
  const baseY = endPoint.y - uy * ARROW_SIZE;
77
78
  const halfW = ARROW_SIZE * 0.5;
78
- return `${tipX},${tipY} ${baseX + px * halfW},${baseY + py * halfW} ${baseX - px * halfW},${baseY - py * halfW}`;
79
+ // Rounded here rather than left raw: these coordinates come out of a sqrt-
80
+ // normalised direction vector, and the SVG they land in gets committed. See
81
+ // ../core/precision.
82
+ const n = svgNumber;
83
+ return `${n(tipX)},${n(tipY)} ${n(baseX + px * halfW)},${n(baseY + py * halfW)} ${n(baseX - px * halfW)},${n(baseY - py * halfW)}`;
79
84
  }
80
85
  function shortenEdgeEnd(points) {
81
86
  if (points.length < 2)
@@ -400,8 +405,18 @@ function EdgeComponent({ edge, colors }) {
400
405
  angleDeg += 180;
401
406
  if (Math.abs(angleDeg) > 60)
402
407
  angleDeg = 0;
403
- const labelX = midpoint.x;
404
- const labelY = midpoint.y;
408
+ // Rounded after the normalisation and the cutoff, so those decisions are made
409
+ // on the same value as before, and once rather than at each of the two places
410
+ // the angle is used — the transform attribute and the label-clipping geometry
411
+ // must agree on one angle. See ../core/precision: atan2 is not bit-identical
412
+ // across platforms.
413
+ angleDeg = svgNumber(angleDeg);
414
+ // Halving a pair of rounded coordinates is exact arithmetic, so this is not a
415
+ // drift risk — but binary representation still turns 249.7775 into
416
+ // 249.77749999999997, which then appears three times in the output. Rounded
417
+ // so the transform and the tspan read as the number they are.
418
+ const labelX = svgNumber(midpoint.x);
419
+ const labelY = svgNumber(midpoint.y);
405
420
  const labelTransform = `rotate(${angleDeg}, ${labelX}, ${labelY})`;
406
421
  // Compute edge paths with label clipping
407
422
  let edgePaths;
@@ -426,7 +441,7 @@ function EdgeComponent({ edge, colors }) {
426
441
  }
427
442
  else if (edge.isStepNumber || edge.isLegendRef) {
428
443
  // Circle/square shapes are opaque — no line clipping needed
429
- edgePaths = [shortenedPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')];
444
+ edgePaths = [shortenedPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${svgNumber(p.x)} ${svgNumber(p.y)}`).join(' ')];
430
445
  }
431
446
  else {
432
447
  // Clip around text label
@@ -457,7 +472,7 @@ function EdgeComponent({ edge, colors }) {
457
472
  }
458
473
  }
459
474
  else {
460
- edgePaths = [shortenedPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')];
475
+ edgePaths = [shortenedPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${svgNumber(p.x)} ${svgNumber(p.y)}`).join(' ')];
461
476
  }
462
477
  // Render label content
463
478
  let labelContent = null;
@@ -508,13 +523,36 @@ function renderNode(node, colors, allNodes, parentMap) {
508
523
  return _jsx(Container, { node: node, colors: colors, allNodes: allNodes, parentMap: parentMap }, node.id);
509
524
  }
510
525
  }
526
+ // =============================================================================
527
+ // MAIN COMPONENT
528
+ // =============================================================================
529
+ /**
530
+ * A zoom factor that cannot produce an invalid SVG.
531
+ *
532
+ * `width`/`height` are attributes on a public entry point, so whatever a host
533
+ * passes ends up in the DOM. `0` collapses the diagram, a negative number is
534
+ * invalid, and `NaN` — the likely one, arriving from an uninitialised state
535
+ * variable or a division — renders nothing at all with no error to explain it.
536
+ * Anything that is not a positive finite number falls back to actual size.
537
+ *
538
+ * The upper bound is a guard against a browser being asked to lay out a
539
+ * hundred-thousand-pixel SVG, not a considered maximum; the lower bound is the
540
+ * point past which a diagram is a smudge. A host wanting more can scale the
541
+ * container.
542
+ */
543
+ export function normaliseZoom(zoom) {
544
+ if (!Number.isFinite(zoom) || zoom <= 0)
545
+ return 1;
546
+ return Math.min(Math.max(zoom, 0.05), 50);
547
+ }
511
548
  /**
512
549
  * C4 Diagram SVG Renderer.
513
550
  *
514
551
  * Pure component: takes layout + theme, returns SVG.
515
552
  * Used directly in the editor and via renderToStaticMarkup for publishing.
516
553
  */
517
- export function C4Renderer({ layout, isDarkMode }) {
554
+ export function C4Renderer({ layout, isDarkMode, zoom = 1 }) {
555
+ const scale = normaliseZoom(zoom);
518
556
  const colors = getColors(isDarkMode);
519
557
  const boundaryNodes = layout.nodes.filter((n) => n.element.type === 'system' &&
520
558
  ((n.children?.length ?? 0) > 0 || n.element.properties.style === 'boundary'));
@@ -531,7 +569,7 @@ export function C4Renderer({ layout, isDarkMode }) {
531
569
  totalWidth = lp.totalWidth;
532
570
  totalHeight = lp.totalHeight;
533
571
  }
534
- return (_jsxs("svg", { width: totalWidth, height: totalHeight, viewBox: `${layout.viewBoxX} ${layout.viewBoxY} ${totalWidth} ${totalHeight}`, xmlns: "http://www.w3.org/2000/svg", "data-diagram": "c4", style: { fontFamily: 'system-ui, -apple-system, sans-serif' }, children: [_jsx("g", { className: "boundaries-layer", children: boundaryNodes.map((node) => renderNode(node, colors, layout.nodes, parentMap)) }), _jsx("g", { className: "nodes-layer", children: regularNodes.map((node) => renderNode(node, colors, layout.nodes, parentMap)) }), _jsx("g", { className: "edges-layer", children: processedEdges.map((edge, i) => (_jsx(EdgeComponent, { edge: edge, colors: colors }, `${edge.source}-${edge.target}-${i}`))) }), legendPlacement && legendEntries.length > 0 && (_jsx(Legend, { entries: legendEntries, placement: legendPlacement, colors: colors }))] }));
572
+ return (_jsxs("svg", { width: totalWidth * scale, height: totalHeight * scale, viewBox: `${layout.viewBoxX} ${layout.viewBoxY} ${totalWidth} ${totalHeight}`, xmlns: "http://www.w3.org/2000/svg", "data-diagram": "c4", style: { fontFamily: 'system-ui, -apple-system, sans-serif' }, children: [_jsx("g", { className: "boundaries-layer", children: boundaryNodes.map((node) => renderNode(node, colors, layout.nodes, parentMap)) }), _jsx("g", { className: "nodes-layer", children: regularNodes.map((node) => renderNode(node, colors, layout.nodes, parentMap)) }), _jsx("g", { className: "edges-layer", children: processedEdges.map((edge, i) => (_jsx(EdgeComponent, { edge: edge, colors: colors }, `${edge.source}-${edge.target}-${i}`))) }), legendPlacement && legendEntries.length > 0 && (_jsx(Legend, { entries: legendEntries, placement: legendPlacement, colors: colors }))] }));
535
573
  }
536
574
  export function C4RendererContent({ layout, isDarkMode, legendPositionOverride }) {
537
575
  const colors = getColors(isDarkMode);
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@liminis/diagrams",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "C4 architecture diagrams: parse C4-PlantUML, lay out with dagre, render to SVG",
5
5
  "license": "MIT",
6
- "//repository": "Not cosmetic, and not optional. npm matches this URL against the GitHub Actions OIDC claim when publishing with --provenance; without it the registry rejects the publish outright (E422) after the release tag has already been cut. That is exactly how 0.1.0's first release attempt failed (#6). The `git+https://` scheme and the `.git` suffix are both part of the match the SSH form does not work.",
6
+ "//repository": "Not cosmetic, and not optional. npm matches this URL against the GitHub Actions OIDC claim when publishing with --provenance; without it the registry rejects the publish outright (E422) after the release tag has already been cut. That is exactly how 0.1.0's first release attempt failed (#6). The `git+https://` scheme and the `.git` suffix are both part of the match \u2014 the SSH form does not work.",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/verveguy/liminis-diagrams.git"
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "//publishing": "Carried over from @liminis/editor deliberately. `prepublishOnly` -> scripts/guard-publish.mjs refuses unless LIMINIS_ALLOW_PUBLISH=1, which is set at step scope in the release workflow and nowhere else, so a release is the only path that publishes. A `private: true` flag would not do this job: `npm publish --dry-run` does NOT report a private package as blocked (npm 10.8.2), so the guard has to be a script.",
26
26
  "packageManager": "pnpm@10.33.0",
27
- "//engines": "A support statement, not a technical floor @dagrejs/dagre declares no engines at all. It says which runtimes this package is maintained against, matching @liminis/editor.",
27
+ "//engines": "A support statement, not a technical floor \u2014 @dagrejs/dagre declares no engines at all. It says which runtimes this package is maintained against, matching @liminis/editor.",
28
28
  "engines": {
29
29
  "node": ">=22"
30
30
  },
@@ -32,6 +32,9 @@
32
32
  "//entrypoints": "These point at dist/ here, in the checked-in manifest, and must stay that way. @liminis/editor shipped a broken 0.1.0 by putting them under `publishConfig`: manifest-field overrides there are a pnpm/yarn feature, and npm honours `publishConfig` only for values like access/registry/tag, so it published src/ paths while `files` shipped only dist/. See that package's ADR-078.",
33
33
  "main": "./dist/index.js",
34
34
  "types": "./dist/index.d.ts",
35
+ "bin": {
36
+ "render-c4": "./dist/bin/render-c4.js"
37
+ },
35
38
  "exports": {
36
39
  ".": {
37
40
  "types": "./dist/index.d.ts",