@diagrammo/dgmo 0.25.3 → 0.25.5

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/dist/internal.js CHANGED
@@ -56117,6 +56117,59 @@ function getRotateFn(mode) {
56117
56117
  if (mode === "angled") return () => Math.round(Math.random() * 30 - 15);
56118
56118
  return () => 0;
56119
56119
  }
56120
+ function hasCanvas2d() {
56121
+ try {
56122
+ if (typeof document === "undefined") return false;
56123
+ const canvas = document.createElement("canvas");
56124
+ return typeof canvas.getContext === "function" && !!canvas.getContext("2d");
56125
+ } catch {
56126
+ return false;
56127
+ }
56128
+ }
56129
+ function estimateWordWidth(text, size) {
56130
+ return text.length * size * WORDCLOUD_GLYPH_ADVANCE;
56131
+ }
56132
+ function layoutWordsNoCanvas(words, width, height, padding, rotateFn) {
56133
+ const sorted = [...words].sort((a, b) => b.size - a.size);
56134
+ const placed = [];
56135
+ const maxR = Math.sqrt(width * width + height * height) / 2;
56136
+ const aspect = width > 0 ? height / width : 1;
56137
+ for (const w of sorted) {
56138
+ const rotate = rotateFn();
56139
+ const rawW = estimateWordWidth(w.text, w.size) + padding * 2;
56140
+ const rawH = w.size + padding * 2;
56141
+ const rad = rotate * Math.PI / 180;
56142
+ const cos = Math.abs(Math.cos(rad));
56143
+ const sin = Math.abs(Math.sin(rad));
56144
+ const halfW = (rawW * cos + rawH * sin) / 2;
56145
+ const halfH = (rawW * sin + rawH * cos) / 2;
56146
+ let spot = null;
56147
+ for (let t = 0; t < 4e3; t++) {
56148
+ const a = t * 0.25;
56149
+ const r = a * 1.4;
56150
+ if (r > maxR) break;
56151
+ const x = Math.cos(a) * r;
56152
+ const y = Math.sin(a) * r * aspect;
56153
+ if (x - halfW < -width / 2 || x + halfW > width / 2 || y - halfH < -height / 2 || y + halfH > height / 2) {
56154
+ continue;
56155
+ }
56156
+ let collides = false;
56157
+ for (const p of placed) {
56158
+ if (Math.abs(x - p.x) < halfW + p.halfW && Math.abs(y - p.y) < halfH + p.halfH) {
56159
+ collides = true;
56160
+ break;
56161
+ }
56162
+ }
56163
+ if (!collides) {
56164
+ spot = { x, y };
56165
+ break;
56166
+ }
56167
+ }
56168
+ if (!spot) continue;
56169
+ placed.push({ ...w, rotate, x: spot.x, y: spot.y, halfW, halfH });
56170
+ }
56171
+ return placed;
56172
+ }
56120
56173
  function renderWordCloud(container, parsed, palette, _isDark, onClickItem, exportDims) {
56121
56174
  const { words, cloudOptions } = parsed;
56122
56175
  const title = parsed.noTitle ? null : parsed.title;
@@ -56157,21 +56210,24 @@ function renderWordCloud(container, parsed, palette, _isDark, onClickItem, expor
56157
56210
  "transform",
56158
56211
  `translate(${width / 2},${sTitleHeight + cloudHeight / 2})`
56159
56212
  );
56160
- cloud().size([width, cloudHeight]).words(words.map((w) => ({ ...w, size: fontSize(w.weight) }))).padding(sPadding).rotate(rotateFn).fontSize((d) => d.size).font(FONT_FAMILY).on("end", (layoutWords) => {
56213
+ const sized = words.map((w) => ({ ...w, size: fontSize(w.weight) }));
56214
+ const draw = (layoutWords) => {
56161
56215
  g.selectAll("text").data(layoutWords).join("text").style("font-size", (d) => `${d.size}px`).style("font-family", FONT_FAMILY).style("font-weight", "600").style("fill", (_d, i) => colors[i % colors.length]).style(
56162
56216
  "cursor",
56163
56217
  (d) => onClickItem && d.lineNumber ? "pointer" : "default"
56164
- ).attr("text-anchor", "middle").attr(
56165
- "transform",
56166
- (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`
56167
- ).attr("data-line-number", (d) => {
56218
+ ).attr("text-anchor", "middle").attr("transform", (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`).attr("data-line-number", (d) => {
56168
56219
  const ln = d.lineNumber;
56169
56220
  return ln ? String(ln) : null;
56170
56221
  }).text((d) => d.text).on("click", (_event, d) => {
56171
56222
  const ln = d.lineNumber;
56172
56223
  if (onClickItem && ln) onClickItem(ln);
56173
56224
  });
56174
- }).start();
56225
+ };
56226
+ if (!hasCanvas2d()) {
56227
+ draw(layoutWordsNoCanvas(sized, width, cloudHeight, sPadding, rotateFn));
56228
+ return;
56229
+ }
56230
+ cloud().size([width, cloudHeight]).words(sized).padding(sPadding).rotate(rotateFn).fontSize((d) => d.size).font(FONT_FAMILY).on("end", draw).start();
56175
56231
  }
56176
56232
  function renderWordCloudAsync(container, parsed, palette, _isDark, exportDims) {
56177
56233
  return new Promise((resolve) => {
@@ -56209,13 +56265,19 @@ function renderWordCloudAsync(container, parsed, palette, _isDark, exportDims) {
56209
56265
  "transform",
56210
56266
  `translate(${width / 2},${titleHeight + cloudHeight / 2})`
56211
56267
  );
56212
- cloud().size([width, cloudHeight]).words(words.map((w) => ({ ...w, size: fontSize(w.weight) }))).padding(2).rotate(rotateFn).fontSize((d) => d.size).font(FONT_FAMILY).on("end", (layoutWords) => {
56268
+ const sized = words.map((w) => ({ ...w, size: fontSize(w.weight) }));
56269
+ const draw = (layoutWords) => {
56213
56270
  g.selectAll("text").data(layoutWords).join("text").style("font-size", (d) => `${d.size}px`).style("font-family", FONT_FAMILY).style("font-weight", "600").style("fill", (_d, i) => colors[i % colors.length]).attr("text-anchor", "middle").attr(
56214
56271
  "transform",
56215
56272
  (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`
56216
56273
  ).text((d) => d.text);
56217
56274
  resolve();
56218
- }).start();
56275
+ };
56276
+ if (!hasCanvas2d()) {
56277
+ draw(layoutWordsNoCanvas(sized, width, cloudHeight, 2, rotateFn));
56278
+ return;
56279
+ }
56280
+ cloud().size([width, cloudHeight]).words(sized).padding(2).rotate(rotateFn).fontSize((d) => d.size).font(FONT_FAMILY).on("end", draw).start();
56219
56281
  });
56220
56282
  }
56221
56283
  function fitCirclesToContainerAsymmetric(circles, w, h, mLeft, mRight, mTop, mBottom) {
@@ -58026,7 +58088,7 @@ async function renderForExport(content, theme, palette, viewState, options) {
58026
58088
  }
58027
58089
  return finalizeSvgExport(container, theme, effectivePalette);
58028
58090
  }
58029
- var DEFAULT_CLOUD_OPTIONS, STOP_WORDS, SLOPE_MARGIN, SLOPE_LABEL_FONT_SIZE, SLOPE_CHAR_WIDTH, ARC_MARGIN_TOP, ARC_MARGIN_RIGHT, ARC_MARGIN_BOTTOM, ARC_MARGIN_LEFT, ARC_MARGIN_LEFT_VERTICAL, ARC_NODE_RADIUS, ARC_NODE_STROKE_WIDTH, ARC_NODE_LABEL_FONT, ARC_GROUP_LABEL_FONT, ARC_BAND_HALF_W, ARC_BAND_HALF_H, ARC_BAND_RADIUS, ARC_BAND_LABEL_X_OFFSET, ARC_BAND_LABEL_Y_OFFSET, ARC_BAND_LABEL_BOTTOM_OFFSET, ARC_NODE_LABEL_X_OFFSET, ARC_NODE_LABEL_Y_OFFSET, ARC_STROKE_MIN, ARC_STROKE_MAX, ARC_BASELINE_STROKE_WIDTH, timelineCollapseState, tlBandClipCounter, EXPORT_WIDTH, EXPORT_HEIGHT;
58091
+ var DEFAULT_CLOUD_OPTIONS, STOP_WORDS, SLOPE_MARGIN, SLOPE_LABEL_FONT_SIZE, SLOPE_CHAR_WIDTH, ARC_MARGIN_TOP, ARC_MARGIN_RIGHT, ARC_MARGIN_BOTTOM, ARC_MARGIN_LEFT, ARC_MARGIN_LEFT_VERTICAL, ARC_NODE_RADIUS, ARC_NODE_STROKE_WIDTH, ARC_NODE_LABEL_FONT, ARC_GROUP_LABEL_FONT, ARC_BAND_HALF_W, ARC_BAND_HALF_H, ARC_BAND_RADIUS, ARC_BAND_LABEL_X_OFFSET, ARC_BAND_LABEL_Y_OFFSET, ARC_BAND_LABEL_BOTTOM_OFFSET, ARC_NODE_LABEL_X_OFFSET, ARC_NODE_LABEL_Y_OFFSET, ARC_STROKE_MIN, ARC_STROKE_MAX, ARC_BASELINE_STROKE_WIDTH, timelineCollapseState, tlBandClipCounter, WORDCLOUD_GLYPH_ADVANCE, EXPORT_WIDTH, EXPORT_HEIGHT;
58030
58092
  var init_d3 = __esm({
58031
58093
  "src/d3.ts"() {
58032
58094
  "use strict";
@@ -58188,6 +58250,7 @@ var init_d3 = __esm({
58188
58250
  ARC_BASELINE_STROKE_WIDTH = 1;
58189
58251
  timelineCollapseState = /* @__PURE__ */ new WeakMap();
58190
58252
  tlBandClipCounter = 0;
58253
+ WORDCLOUD_GLYPH_ADVANCE = 0.62;
58191
58254
  EXPORT_WIDTH = 1200;
58192
58255
  EXPORT_HEIGHT = 800;
58193
58256
  }
@@ -58659,31 +58722,52 @@ init_d3();
58659
58722
  init_echarts();
58660
58723
  init_dgmo_router();
58661
58724
  init_registry();
58662
- async function ensureDom() {
58663
- if (typeof document !== "undefined") return;
58725
+ var DOM_GLOBALS = [
58726
+ "document",
58727
+ "window",
58728
+ "navigator",
58729
+ "HTMLElement",
58730
+ "SVGElement"
58731
+ ];
58732
+ var domRefCount = 0;
58733
+ var domInstallPromise = null;
58734
+ var domInstalledByUs = false;
58735
+ async function installDom() {
58664
58736
  const { JSDOM } = await loadJsdom();
58665
- const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
58666
- const win = dom.window;
58667
- Object.defineProperty(globalThis, "document", {
58668
- value: win.document,
58669
- configurable: true
58670
- });
58671
- Object.defineProperty(globalThis, "window", {
58672
- value: win,
58673
- configurable: true
58674
- });
58675
- Object.defineProperty(globalThis, "navigator", {
58676
- value: win.navigator,
58677
- configurable: true
58678
- });
58679
- Object.defineProperty(globalThis, "HTMLElement", {
58680
- value: win.HTMLElement,
58681
- configurable: true
58682
- });
58683
- Object.defineProperty(globalThis, "SVGElement", {
58684
- value: win.SVGElement,
58685
- configurable: true
58737
+ const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
58738
+ url: "http://localhost/"
58686
58739
  });
58740
+ const win = dom.window;
58741
+ const values = {
58742
+ document: win.document,
58743
+ window: win,
58744
+ navigator: win.navigator,
58745
+ HTMLElement: win.HTMLElement,
58746
+ SVGElement: win.SVGElement
58747
+ };
58748
+ for (const key of DOM_GLOBALS) {
58749
+ Object.defineProperty(globalThis, key, {
58750
+ value: values[key],
58751
+ configurable: true
58752
+ });
58753
+ }
58754
+ domInstalledByUs = true;
58755
+ }
58756
+ async function acquireDom() {
58757
+ if (typeof document !== "undefined" && !domInstalledByUs) return;
58758
+ domRefCount++;
58759
+ if (!domInstallPromise) domInstallPromise = installDom();
58760
+ await domInstallPromise;
58761
+ }
58762
+ function releaseDom() {
58763
+ if (!domInstalledByUs) return;
58764
+ if (--domRefCount > 0) return;
58765
+ for (const key of DOM_GLOBALS) {
58766
+ delete globalThis[key];
58767
+ }
58768
+ domInstalledByUs = false;
58769
+ domInstallPromise = null;
58770
+ domRefCount = 0;
58687
58771
  }
58688
58772
  async function loadJsdom() {
58689
58773
  const spec = ["js", "dom"].join("");
@@ -58717,16 +58801,21 @@ async function render(content, options) {
58717
58801
  );
58718
58802
  return { svg: svg2, diagnostics };
58719
58803
  }
58720
- await ensureDom();
58721
- const svg = await renderForExport(content, theme, paletteColors, viewState, {
58722
- ...options?.c4Level !== void 0 && { c4Level: options.c4Level },
58723
- ...options?.c4System !== void 0 && { c4System: options.c4System },
58724
- ...options?.c4Container !== void 0 && {
58725
- c4Container: options.c4Container
58726
- },
58727
- ...options?.tagGroup !== void 0 && { tagGroup: options.tagGroup },
58728
- ...options?.mapData !== void 0 && { mapData: options.mapData }
58729
- });
58804
+ await acquireDom();
58805
+ let svg;
58806
+ try {
58807
+ svg = await renderForExport(content, theme, paletteColors, viewState, {
58808
+ ...options?.c4Level !== void 0 && { c4Level: options.c4Level },
58809
+ ...options?.c4System !== void 0 && { c4System: options.c4System },
58810
+ ...options?.c4Container !== void 0 && {
58811
+ c4Container: options.c4Container
58812
+ },
58813
+ ...options?.tagGroup !== void 0 && { tagGroup: options.tagGroup },
58814
+ ...options?.mapData !== void 0 && { mapData: options.mapData }
58815
+ });
58816
+ } finally {
58817
+ releaseDom();
58818
+ }
58730
58819
  if (chartType === "map") {
58731
58820
  try {
58732
58821
  const [{ parseMap: parseMap2 }, { resolveMap: resolveMap2 }, { loadMapData: loadMapData2 }] = await Promise.all(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.25.3",
3
+ "version": "0.25.5",
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