@diagrammo/dgmo 0.25.3 → 0.25.4

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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +0 -9
  3. package/src/d3.ts +180 -46
  4. package/src/render.ts +80 -39
  5. package/dist/advanced.cjs +0 -62454
  6. package/dist/advanced.d.cts +0 -5684
  7. package/dist/advanced.d.ts +0 -5684
  8. package/dist/advanced.js +0 -62207
  9. package/dist/auto.cjs +0 -59764
  10. package/dist/auto.css +0 -214
  11. package/dist/auto.d.cts +0 -39
  12. package/dist/auto.d.ts +0 -39
  13. package/dist/auto.js +0 -437
  14. package/dist/auto.mjs +0 -59774
  15. package/dist/cli.cjs +0 -465
  16. package/dist/editor.cjs +0 -432
  17. package/dist/editor.d.cts +0 -26
  18. package/dist/editor.d.ts +0 -26
  19. package/dist/editor.js +0 -401
  20. package/dist/highlight.cjs +0 -720
  21. package/dist/highlight.d.cts +0 -32
  22. package/dist/highlight.d.ts +0 -32
  23. package/dist/highlight.js +0 -690
  24. package/dist/index.cjs +0 -58950
  25. package/dist/index.d.cts +0 -375
  26. package/dist/index.d.ts +0 -375
  27. package/dist/index.js +0 -58954
  28. package/dist/internal.cjs +0 -62456
  29. package/dist/internal.d.cts +0 -5684
  30. package/dist/internal.d.ts +0 -5684
  31. package/dist/internal.js +0 -62207
  32. package/dist/map-data/PROVENANCE.json +0 -1
  33. package/dist/map-data/gazetteer.json +0 -1
  34. package/dist/map-data/lakes.json +0 -1
  35. package/dist/map-data/mountain-ranges.json +0 -1
  36. package/dist/map-data/na-lakes.json +0 -1
  37. package/dist/map-data/na-land.json +0 -1
  38. package/dist/map-data/region-names.json +0 -1
  39. package/dist/map-data/rivers.json +0 -1
  40. package/dist/map-data/us-states.json +0 -1
  41. package/dist/map-data/water-bodies.json +0 -1
  42. package/dist/map-data/world-coarse.json +0 -1
  43. package/dist/map-data/world-detail.json +0 -1
  44. package/dist/pert.cjs +0 -325
  45. package/dist/pert.d.cts +0 -554
  46. package/dist/pert.d.ts +0 -554
  47. package/dist/pert.js +0 -294
  48. package/src/editor/dgmo.grammar.js +0 -18
  49. package/src/editor/dgmo.grammar.terms.js +0 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.25.3",
3
+ "version": "0.25.4",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.ts CHANGED
@@ -1506,15 +1506,6 @@ async function main(): Promise<void> {
1506
1506
  opts.theme === 'dark' ? 'dark' : 'light'
1507
1507
  ];
1508
1508
 
1509
- // Word clouds require Canvas APIs (HTMLCanvasElement.getContext('2d'))
1510
- // which are unavailable in Node.js — check before attempting render.
1511
- const wordcloudRe = /^\s*chart\s*:\s*wordcloud\b/im;
1512
- if (wordcloudRe.test(content)) {
1513
- exitWithJsonError(
1514
- 'Error: Word clouds are not supported in the CLI (requires Canvas). Use the desktop app or browser instead.'
1515
- );
1516
- }
1517
-
1518
1509
  // Parse first to collect diagnostics
1519
1510
  const { diagnostics } = parseDgmo(content);
1520
1511
  const errors = diagnostics.filter((d) => d.severity === 'error');
package/src/d3.ts CHANGED
@@ -5771,6 +5771,106 @@ function getRotateFn(mode: WordCloudRotate): () => number {
5771
5771
  return () => 0;
5772
5772
  }
5773
5773
 
5774
+ /**
5775
+ * d3-cloud rasterizes each glyph to a canvas sprite for pixel-perfect
5776
+ * collision detection. In headless Node (jsdom), `getContext('2d')` returns
5777
+ * null, so d3-cloud throws (`getImageData` on null). This detects whether a
5778
+ * usable 2D canvas exists; when it doesn't, we fall back to a canvas-free
5779
+ * spiral packer so word clouds still render in SSG (remark wrappers), the MCP
5780
+ * server, and the CLI.
5781
+ */
5782
+ function hasCanvas2d(): boolean {
5783
+ try {
5784
+ if (typeof document === 'undefined') return false;
5785
+ const canvas = document.createElement('canvas');
5786
+ return typeof canvas.getContext === 'function' && !!canvas.getContext('2d');
5787
+ } catch {
5788
+ return false;
5789
+ }
5790
+ }
5791
+
5792
+ /** Average glyph advance for Inter as a fraction of the font size. Slightly
5793
+ * generous so estimated boxes err toward not overlapping. */
5794
+ const WORDCLOUD_GLYPH_ADVANCE = 0.62;
5795
+
5796
+ function estimateWordWidth(text: string, size: number): number {
5797
+ return text.length * size * WORDCLOUD_GLYPH_ADVANCE;
5798
+ }
5799
+
5800
+ type PlacedCloudWord = WordCloudWord & {
5801
+ size: number;
5802
+ x: number;
5803
+ y: number;
5804
+ rotate: number;
5805
+ };
5806
+
5807
+ /**
5808
+ * Canvas-free word-cloud layout. Places the largest words first at the centre
5809
+ * and walks an Archimedean spiral outward, using axis-aligned bounding-box
5810
+ * overlap tests (text width estimated from font metrics). Returns words in
5811
+ * placement order with `x`/`y` relative to the cloud centre — the same shape
5812
+ * d3-cloud hands to its `end` callback — so the existing draw code is reused.
5813
+ * Words that can't be placed within the box are dropped, matching d3-cloud.
5814
+ */
5815
+ function layoutWordsNoCanvas(
5816
+ words: Array<WordCloudWord & { size: number }>,
5817
+ width: number,
5818
+ height: number,
5819
+ padding: number,
5820
+ rotateFn: () => number
5821
+ ): PlacedCloudWord[] {
5822
+ const sorted = [...words].sort((a, b) => b.size - a.size);
5823
+ const placed: Array<PlacedCloudWord & { halfW: number; halfH: number }> = [];
5824
+ const maxR = Math.sqrt(width * width + height * height) / 2;
5825
+ // Bias the spiral to the box aspect so wide clouds spread horizontally.
5826
+ const aspect = width > 0 ? height / width : 1;
5827
+
5828
+ for (const w of sorted) {
5829
+ const rotate = rotateFn();
5830
+ const rawW = estimateWordWidth(w.text, w.size) + padding * 2;
5831
+ const rawH = w.size + padding * 2;
5832
+ const rad = (rotate * Math.PI) / 180;
5833
+ const cos = Math.abs(Math.cos(rad));
5834
+ const sin = Math.abs(Math.sin(rad));
5835
+ const halfW = (rawW * cos + rawH * sin) / 2;
5836
+ const halfH = (rawW * sin + rawH * cos) / 2;
5837
+
5838
+ let spot: { x: number; y: number } | null = null;
5839
+ for (let t = 0; t < 4000; t++) {
5840
+ const a = t * 0.25;
5841
+ const r = a * 1.4;
5842
+ if (r > maxR) break;
5843
+ const x = Math.cos(a) * r;
5844
+ const y = Math.sin(a) * r * aspect;
5845
+ if (
5846
+ x - halfW < -width / 2 ||
5847
+ x + halfW > width / 2 ||
5848
+ y - halfH < -height / 2 ||
5849
+ y + halfH > height / 2
5850
+ ) {
5851
+ continue;
5852
+ }
5853
+ let collides = false;
5854
+ for (const p of placed) {
5855
+ if (
5856
+ Math.abs(x - p.x) < halfW + p.halfW &&
5857
+ Math.abs(y - p.y) < halfH + p.halfH
5858
+ ) {
5859
+ collides = true;
5860
+ break;
5861
+ }
5862
+ }
5863
+ if (!collides) {
5864
+ spot = { x, y };
5865
+ break;
5866
+ }
5867
+ }
5868
+ if (!spot) continue;
5869
+ placed.push({ ...w, rotate, x: spot.x, y: spot.y, halfW, halfH });
5870
+ }
5871
+ return placed;
5872
+ }
5873
+
5774
5874
  // ============================================================
5775
5875
  // Word Cloud Renderer
5776
5876
  // ============================================================
@@ -5839,40 +5939,55 @@ export function renderWordCloud(
5839
5939
  `translate(${width / 2},${sTitleHeight + cloudHeight / 2})`
5840
5940
  );
5841
5941
 
5942
+ const sized = words.map((w) => ({ ...w, size: fontSize(w.weight) }));
5943
+
5944
+ const draw = (
5945
+ layoutWords: Array<{
5946
+ text?: string;
5947
+ size?: number;
5948
+ x?: number;
5949
+ y?: number;
5950
+ rotate?: number;
5951
+ }>
5952
+ ): void => {
5953
+ g.selectAll('text')
5954
+ .data(layoutWords)
5955
+ .join('text')
5956
+ .style('font-size', (d) => `${d.size}px`)
5957
+ .style('font-family', FONT_FAMILY)
5958
+ .style('font-weight', '600')
5959
+ // colors is non-empty; modulo guarantees in-bounds.
5960
+ .style('fill', (_d, i) => colors[i % colors.length]!)
5961
+ .style('cursor', (d) =>
5962
+ onClickItem && (d as WordCloudWord).lineNumber ? 'pointer' : 'default'
5963
+ )
5964
+ .attr('text-anchor', 'middle')
5965
+ .attr('transform', (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`)
5966
+ .attr('data-line-number', (d) => {
5967
+ const ln = (d as WordCloudWord).lineNumber;
5968
+ return ln ? String(ln) : null;
5969
+ })
5970
+ .text((d) => d.text!)
5971
+ .on('click', (_event, d) => {
5972
+ const ln = (d as WordCloudWord).lineNumber;
5973
+ if (onClickItem && ln) onClickItem(ln);
5974
+ });
5975
+ };
5976
+
5977
+ // No real 2D canvas (headless Node) → fall back to the spiral packer.
5978
+ if (!hasCanvas2d()) {
5979
+ draw(layoutWordsNoCanvas(sized, width, cloudHeight, sPadding, rotateFn));
5980
+ return;
5981
+ }
5982
+
5842
5983
  cloud<WordCloudWord & cloud.Word>()
5843
5984
  .size([width, cloudHeight])
5844
- .words(words.map((w) => ({ ...w, size: fontSize(w.weight) })))
5985
+ .words(sized)
5845
5986
  .padding(sPadding)
5846
5987
  .rotate(rotateFn)
5847
5988
  .fontSize((d) => d.size!)
5848
5989
  .font(FONT_FAMILY)
5849
- .on('end', (layoutWords) => {
5850
- g.selectAll('text')
5851
- .data(layoutWords)
5852
- .join('text')
5853
- .style('font-size', (d) => `${d.size}px`)
5854
- .style('font-family', FONT_FAMILY)
5855
- .style('font-weight', '600')
5856
- // colors is non-empty; modulo guarantees in-bounds.
5857
- .style('fill', (_d, i) => colors[i % colors.length]!)
5858
- .style('cursor', (d) =>
5859
- onClickItem && (d as WordCloudWord).lineNumber ? 'pointer' : 'default'
5860
- )
5861
- .attr('text-anchor', 'middle')
5862
- .attr(
5863
- 'transform',
5864
- (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`
5865
- )
5866
- .attr('data-line-number', (d) => {
5867
- const ln = (d as WordCloudWord).lineNumber;
5868
- return ln ? String(ln) : null;
5869
- })
5870
- .text((d) => d.text!)
5871
- .on('click', (_event, d) => {
5872
- const ln = (d as WordCloudWord).lineNumber;
5873
- if (onClickItem && ln) onClickItem(ln);
5874
- });
5875
- })
5990
+ .on('end', draw)
5876
5991
  .start();
5877
5992
  }
5878
5993
 
@@ -5942,30 +6057,49 @@ function renderWordCloudAsync(
5942
6057
  `translate(${width / 2},${titleHeight + cloudHeight / 2})`
5943
6058
  );
5944
6059
 
6060
+ const sized = words.map((w) => ({ ...w, size: fontSize(w.weight) }));
6061
+
6062
+ const draw = (
6063
+ layoutWords: Array<{
6064
+ text?: string;
6065
+ size?: number;
6066
+ x?: number;
6067
+ y?: number;
6068
+ rotate?: number;
6069
+ }>
6070
+ ): void => {
6071
+ g.selectAll('text')
6072
+ .data(layoutWords)
6073
+ .join('text')
6074
+ .style('font-size', (d) => `${d.size}px`)
6075
+ .style('font-family', FONT_FAMILY)
6076
+ .style('font-weight', '600')
6077
+ // colors is non-empty; modulo guarantees in-bounds.
6078
+ .style('fill', (_d, i) => colors[i % colors.length]!)
6079
+ .attr('text-anchor', 'middle')
6080
+ .attr(
6081
+ 'transform',
6082
+ (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`
6083
+ )
6084
+ .text((d) => d.text!);
6085
+ resolve();
6086
+ };
6087
+
6088
+ // No real 2D canvas (headless Node: SSG wrappers, MCP, CLI) → d3-cloud's
6089
+ // sprite rasterization can't run. Use the canvas-free spiral packer.
6090
+ if (!hasCanvas2d()) {
6091
+ draw(layoutWordsNoCanvas(sized, width, cloudHeight, 2, rotateFn));
6092
+ return;
6093
+ }
6094
+
5945
6095
  cloud<WordCloudWord & cloud.Word>()
5946
6096
  .size([width, cloudHeight])
5947
- .words(words.map((w) => ({ ...w, size: fontSize(w.weight) })))
6097
+ .words(sized)
5948
6098
  .padding(2)
5949
6099
  .rotate(rotateFn)
5950
6100
  .fontSize((d) => d.size!)
5951
6101
  .font(FONT_FAMILY)
5952
- .on('end', (layoutWords) => {
5953
- g.selectAll('text')
5954
- .data(layoutWords)
5955
- .join('text')
5956
- .style('font-size', (d) => `${d.size}px`)
5957
- .style('font-family', FONT_FAMILY)
5958
- .style('font-weight', '600')
5959
- // colors is non-empty; modulo guarantees in-bounds.
5960
- .style('fill', (_d, i) => colors[i % colors.length]!)
5961
- .attr('text-anchor', 'middle')
5962
- .attr(
5963
- 'transform',
5964
- (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`
5965
- )
5966
- .text((d) => d.text!);
5967
- resolve();
5968
- })
6102
+ .on('end', draw)
5969
6103
  .start();
5970
6104
  });
5971
6105
  }
package/src/render.ts CHANGED
@@ -5,45 +5,81 @@ import type { DgmoError } from './diagnostics';
5
5
  import { getPalette } from './palettes/registry';
6
6
  import type { CompactViewState } from './sharing';
7
7
 
8
- /**
9
- * Ensures DOM globals are available for D3 renderers.
10
- * No-ops in browser environments where `document` already exists.
11
- * Dynamically imports jsdom only in Node.js to avoid bundling it for browsers.
12
- */
13
- async function ensureDom(): Promise<void> {
14
- if (typeof document !== 'undefined') return;
8
+ // DOM globals installed for Node-side D3 rendering, scoped with ref-counting.
9
+ //
10
+ // These need to exist on `globalThis` while a D3 renderer runs (it reaches for
11
+ // `document`). The naive approach install them once and leave them leaks a
12
+ // jsdom `window` into the host Node process forever. That breaks hosts that run
13
+ // their OWN SSR/SSG in the same process after calling render(): notably
14
+ // Docusaurus static export, whose theme then believes it is in a browser
15
+ // (`canUseDOM` true) and crashes on bare globals this shim does NOT define
16
+ // (`requestAnimationFrame`, `MutationObserver`) or on opaque-origin
17
+ // `localStorage`. So we install on the first concurrent render and tear down
18
+ // once the last one finishes, leaving the host a clean Node environment.
19
+ const DOM_GLOBALS = [
20
+ 'document',
21
+ 'window',
22
+ 'navigator',
23
+ 'HTMLElement',
24
+ 'SVGElement',
25
+ ] as const;
26
+ let domRefCount = 0;
27
+ let domInstallPromise: Promise<void> | null = null;
28
+ let domInstalledByUs = false;
15
29
 
30
+ async function installDom(): Promise<void> {
16
31
  const { JSDOM } = await loadJsdom();
17
- const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
32
+ // Concrete URL non-opaque origin, so host code that touches
33
+ // window.localStorage during a same-process render doesn't throw.
34
+ const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
35
+ url: 'http://localhost/',
36
+ });
18
37
  const win = dom.window;
38
+ const values: Record<(typeof DOM_GLOBALS)[number], unknown> = {
39
+ document: win.document,
40
+ window: win,
41
+ navigator: win.navigator,
42
+ HTMLElement: win.HTMLElement,
43
+ SVGElement: win.SVGElement,
44
+ };
45
+ for (const key of DOM_GLOBALS) {
46
+ Object.defineProperty(globalThis, key, {
47
+ value: values[key],
48
+ configurable: true,
49
+ });
50
+ }
51
+ domInstalledByUs = true;
52
+ }
19
53
 
20
- Object.defineProperty(globalThis, 'document', {
21
- value: win.document,
22
- configurable: true,
23
- });
24
- Object.defineProperty(globalThis, 'window', {
25
- value: win,
26
- configurable: true,
27
- });
28
- Object.defineProperty(globalThis, 'navigator', {
29
- value: win.navigator,
30
- configurable: true,
31
- });
32
- Object.defineProperty(globalThis, 'HTMLElement', {
33
- value: win.HTMLElement,
34
- configurable: true,
35
- });
36
- Object.defineProperty(globalThis, 'SVGElement', {
37
- value: win.SVGElement,
38
- configurable: true,
39
- });
54
+ /**
55
+ * Make DOM globals available for the duration of a render. No-ops in a real
56
+ * browser or any host that already provides `document` (we never touch globals
57
+ * we did not install). Pair every successful call with `releaseDom()`.
58
+ */
59
+ async function acquireDom(): Promise<void> {
60
+ if (typeof document !== 'undefined' && !domInstalledByUs) return;
61
+ domRefCount++;
62
+ if (!domInstallPromise) domInstallPromise = installDom();
63
+ await domInstallPromise;
64
+ }
65
+
66
+ /** Tear down the jsdom globals once no render is in flight. */
67
+ function releaseDom(): void {
68
+ if (!domInstalledByUs) return;
69
+ if (--domRefCount > 0) return;
70
+ for (const key of DOM_GLOBALS) {
71
+ delete (globalThis as Record<string, unknown>)[key];
72
+ }
73
+ domInstalledByUs = false;
74
+ domInstallPromise = null;
75
+ domRefCount = 0;
40
76
  }
41
77
 
42
78
  /**
43
79
  * Load jsdom server-side. The specifier is constructed at runtime so
44
80
  * downstream bundlers (Vite, Rollup, esbuild, webpack) cannot statically
45
81
  * resolve it. Without this indirection, every browser bundle of
46
- * @diagrammo/dgmo emits a 5+ MB jsdom chunk even though `ensureDom()`
82
+ * @diagrammo/dgmo emits a 5+ MB jsdom chunk even though `acquireDom()`
47
83
  * guards execution with a `typeof document` check — the guard prevents
48
84
  * runtime evaluation, but the static dependency edge still pulls jsdom
49
85
  * into the bundle.
@@ -128,16 +164,21 @@ export async function render(
128
164
  }
129
165
 
130
166
  // Visualization/diagram and unknown/null types all go through the unified renderer
131
- await ensureDom();
132
- const svg = await renderForExport(content, theme, paletteColors, viewState, {
133
- ...(options?.c4Level !== undefined && { c4Level: options.c4Level }),
134
- ...(options?.c4System !== undefined && { c4System: options.c4System }),
135
- ...(options?.c4Container !== undefined && {
136
- c4Container: options.c4Container,
137
- }),
138
- ...(options?.tagGroup !== undefined && { tagGroup: options.tagGroup }),
139
- ...(options?.mapData !== undefined && { mapData: options.mapData }),
140
- });
167
+ await acquireDom();
168
+ let svg: string;
169
+ try {
170
+ svg = await renderForExport(content, theme, paletteColors, viewState, {
171
+ ...(options?.c4Level !== undefined && { c4Level: options.c4Level }),
172
+ ...(options?.c4System !== undefined && { c4System: options.c4System }),
173
+ ...(options?.c4Container !== undefined && {
174
+ c4Container: options.c4Container,
175
+ }),
176
+ ...(options?.tagGroup !== undefined && { tagGroup: options.tagGroup }),
177
+ ...(options?.mapData !== undefined && { mapData: options.mapData }),
178
+ });
179
+ } finally {
180
+ releaseDom();
181
+ }
141
182
 
142
183
  // The map pipeline resolves names AFTER parsing (gazetteer/ISO lookup), so its
143
184
  // unknown-place / unknown-subdivision errors live on the ResolvedMap, not the