@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 +23 -8
- package/dist/bin/render-c4.d.ts +49 -0
- package/dist/bin/render-c4.js +256 -0
- package/dist/core/edge-clipping.js +3 -2
- package/dist/core/layout.js +97 -31
- package/dist/core/precision.d.ts +5 -0
- package/dist/core/precision.js +30 -0
- package/dist/react/C4InteractiveRenderer.d.ts +24 -1
- package/dist/react/C4InteractiveRenderer.js +8 -5
- package/dist/react/renderer.d.ts +23 -1
- package/dist/react/renderer.js +45 -7
- package/package.json +6 -3
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](
|
|
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
|
-
[
|
|
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](
|
|
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](
|
|
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 `` 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
|
-
[
|
|
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? [
|
|
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.
|
|
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 `` 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 `` 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;
|
package/dist/core/layout.js
CHANGED
|
@@ -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
|
-
|
|
760
|
-
|
|
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
|
|
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
|
-
|
|
772
|
-
|
|
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
|
-
|
|
779
|
-
|
|
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
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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,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: {
|
package/dist/react/renderer.d.ts
CHANGED
|
@@ -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
|
package/dist/react/renderer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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.
|
|
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
|
|
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
|
|
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",
|