@diagrammo/dgmo 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/d3.ts CHANGED
@@ -182,6 +182,64 @@ import type { DgmoError } from './diagnostics';
182
182
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
183
183
  import { collectIndentedValues } from './utils/parsing';
184
184
 
185
+ // ============================================================
186
+ // Shared Rendering Helpers
187
+ // ============================================================
188
+
189
+ /**
190
+ * Renders a chart title on the SVG with optional click interaction.
191
+ */
192
+ function renderChartTitle(
193
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
194
+ title: string | undefined | null,
195
+ titleLineNumber: number | undefined | null,
196
+ width: number,
197
+ textColor: string,
198
+ onClickItem?: (lineNumber: number) => void
199
+ ): void {
200
+ if (!title) return;
201
+ const titleEl = svg.append('text')
202
+ .attr('class', 'chart-title')
203
+ .attr('x', width / 2)
204
+ .attr('y', 30)
205
+ .attr('text-anchor', 'middle')
206
+ .attr('fill', textColor)
207
+ .attr('font-size', '20px')
208
+ .attr('font-weight', '700')
209
+ .style('cursor', onClickItem && titleLineNumber ? 'pointer' : 'default')
210
+ .text(title);
211
+ if (titleLineNumber) {
212
+ titleEl.attr('data-line-number', titleLineNumber);
213
+ if (onClickItem) {
214
+ titleEl
215
+ .on('click', () => onClickItem(titleLineNumber))
216
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
217
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
218
+ }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Initializes a D3 chart: clears existing content, creates SVG, resolves palette colors.
224
+ * Returns null if the container has zero dimensions.
225
+ */
226
+ function initD3Chart(
227
+ container: HTMLDivElement,
228
+ palette: PaletteColors,
229
+ exportDims?: D3ExportDimensions
230
+ ): { svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>; width: number; height: number; textColor: string; mutedColor: string; bgColor: string; colors: string[] } | null {
231
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
232
+ const width = exportDims?.width ?? container.clientWidth;
233
+ const height = exportDims?.height ?? container.clientHeight;
234
+ if (width <= 0 || height <= 0) return null;
235
+ const textColor = palette.text;
236
+ const mutedColor = palette.border;
237
+ const bgColor = palette.bg;
238
+ const colors = getSeriesColors(palette);
239
+ const svg = d3Selection.select(container).append('svg').attr('width', width).attr('height', height).style('background', bgColor);
240
+ return { svg, width, height, textColor, mutedColor, bgColor, colors };
241
+ }
242
+
185
243
  // ============================================================
186
244
  // Timeline Date Helper
187
245
  // ============================================================
@@ -1077,10 +1135,12 @@ function tokenizeFreeformText(text: string): WordCloudWord[] {
1077
1135
  /**
1078
1136
  * Resolves vertical label collisions by nudging overlapping items apart.
1079
1137
  * Takes items with a naturalY (center) and height, returns adjusted center Y positions.
1138
+ * Optional maxY clamps the bottom edge so labels don't overflow the chart area.
1080
1139
  */
1081
- function resolveVerticalCollisions(
1140
+ export function resolveVerticalCollisions(
1082
1141
  items: { naturalY: number; height: number }[],
1083
- minGap: number
1142
+ minGap: number,
1143
+ maxY?: number
1084
1144
  ): number[] {
1085
1145
  if (items.length === 0) return [];
1086
1146
  const sorted = items
@@ -1090,7 +1150,11 @@ function resolveVerticalCollisions(
1090
1150
  let prevBottom = -Infinity;
1091
1151
  for (const item of sorted) {
1092
1152
  const halfH = item.height / 2;
1093
- const top = Math.max(item.naturalY - halfH, prevBottom + minGap);
1153
+ let top = Math.max(item.naturalY - halfH, prevBottom + minGap);
1154
+ // Clamp so the label bottom doesn't exceed maxY
1155
+ if (maxY !== undefined) {
1156
+ top = Math.min(top, maxY - item.height);
1157
+ }
1094
1158
  adjustedY[item.idx] = top + halfH;
1095
1159
  prevBottom = top + item.height;
1096
1160
  }
@@ -1112,15 +1176,12 @@ export function renderSlopeChart(
1112
1176
  onClickItem?: (lineNumber: number) => void,
1113
1177
  exportDims?: D3ExportDimensions
1114
1178
  ): void {
1115
- // Clear existing content
1116
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1117
-
1118
1179
  const { periods, data, title } = parsed;
1119
1180
  if (data.length === 0 || periods.length < 2) return;
1120
1181
 
1121
- const width = exportDims?.width ?? container.clientWidth;
1122
- const height = exportDims?.height ?? container.clientHeight;
1123
- if (width <= 0 || height <= 0) return;
1182
+ const init = initD3Chart(container, palette, exportDims);
1183
+ if (!init) return;
1184
+ const { svg, width, height, textColor, mutedColor, bgColor, colors } = init;
1124
1185
 
1125
1186
  // Compute right margin from the longest end-of-line label
1126
1187
  const maxLabelText = data.reduce((longest, item) => {
@@ -1137,12 +1198,6 @@ export function renderSlopeChart(
1137
1198
  const innerWidth = width - SLOPE_MARGIN.left - rightMargin;
1138
1199
  const innerHeight = height - SLOPE_MARGIN.top - SLOPE_MARGIN.bottom;
1139
1200
 
1140
- // Theme colors
1141
- const textColor = palette.text;
1142
- const mutedColor = palette.border;
1143
- const bgColor = palette.bg;
1144
- const colors = getSeriesColors(palette);
1145
-
1146
1201
  // Scales
1147
1202
  const allValues = data.flatMap((d) => d.values);
1148
1203
  const [minVal, maxVal] = d3Array.extent(allValues) as [number, number];
@@ -1159,14 +1214,6 @@ export function renderSlopeChart(
1159
1214
  .range([0, innerWidth])
1160
1215
  .padding(0);
1161
1216
 
1162
- // SVG
1163
- const svg = d3Selection
1164
- .select(container)
1165
- .append('svg')
1166
- .attr('width', width)
1167
- .attr('height', height)
1168
- .style('background', bgColor);
1169
-
1170
1217
  const g = svg
1171
1218
  .append('g')
1172
1219
  .attr('transform', `translate(${SLOPE_MARGIN.left},${SLOPE_MARGIN.top})`);
@@ -1175,29 +1222,7 @@ export function renderSlopeChart(
1175
1222
  const tooltip = createTooltip(container, palette, isDark);
1176
1223
 
1177
1224
  // Title
1178
- if (title) {
1179
- const titleEl = svg
1180
- .append('text')
1181
- .attr('class', 'chart-title')
1182
- .attr('x', width / 2)
1183
- .attr('y', 30)
1184
- .attr('text-anchor', 'middle')
1185
- .attr('fill', textColor)
1186
- .attr('font-size', '20px')
1187
- .attr('font-weight', '700')
1188
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
1189
- .text(title);
1190
-
1191
- if (parsed.titleLineNumber) {
1192
- titleEl.attr('data-line-number', parsed.titleLineNumber);
1193
- if (onClickItem) {
1194
- titleEl
1195
- .on('click', () => onClickItem(parsed.titleLineNumber!))
1196
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
1197
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
1198
- }
1199
- }
1200
- }
1225
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
1201
1226
 
1202
1227
  // Period column headers
1203
1228
  for (const period of periods) {
@@ -1284,7 +1309,7 @@ export function renderSlopeChart(
1284
1309
  naturalY: yScale(item.values[pi]),
1285
1310
  height: leftLabelHeight,
1286
1311
  }));
1287
- leftLabelCollisions.set(pi, resolveVerticalCollisions(entries, 4));
1312
+ leftLabelCollisions.set(pi, resolveVerticalCollisions(entries, 4, innerHeight));
1288
1313
  }
1289
1314
 
1290
1315
  // --- Resolve right-side label collisions ---
@@ -1292,7 +1317,7 @@ export function renderSlopeChart(
1292
1317
  naturalY: yScale(si.lastVal),
1293
1318
  height: Math.max(si.labelHeight, SLOPE_LABEL_FONT_SIZE * 1.4),
1294
1319
  }));
1295
- const rightAdjustedY = resolveVerticalCollisions(rightEntries, 4);
1320
+ const rightAdjustedY = resolveVerticalCollisions(rightEntries, 4, innerHeight);
1296
1321
 
1297
1322
  // Render each data series
1298
1323
  data.forEach((item, idx) => {
@@ -1540,14 +1565,12 @@ export function renderArcDiagram(
1540
1565
  onClickItem?: (lineNumber: number) => void,
1541
1566
  exportDims?: D3ExportDimensions
1542
1567
  ): void {
1543
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1544
-
1545
1568
  const { links, title, orientation, arcOrder, arcNodeGroups } = parsed;
1546
1569
  if (links.length === 0) return;
1547
1570
 
1548
- const width = exportDims?.width ?? container.clientWidth;
1549
- const height = exportDims?.height ?? container.clientHeight;
1550
- if (width <= 0 || height <= 0) return;
1571
+ const init = initD3Chart(container, palette, exportDims);
1572
+ if (!init) return;
1573
+ const { svg, width, height, textColor, mutedColor, bgColor, colors } = init;
1551
1574
 
1552
1575
  const isVertical = orientation === 'vertical';
1553
1576
  const margin = isVertical
@@ -1562,12 +1585,6 @@ export function renderArcDiagram(
1562
1585
  const innerWidth = width - margin.left - margin.right;
1563
1586
  const innerHeight = height - margin.top - margin.bottom;
1564
1587
 
1565
- // Theme colors
1566
- const textColor = palette.text;
1567
- const mutedColor = palette.border;
1568
- const bgColor = palette.bg;
1569
- const colors = getSeriesColors(palette);
1570
-
1571
1588
  // Order nodes by selected strategy
1572
1589
  const nodes = orderArcNodes(links, arcOrder, arcNodeGroups);
1573
1590
 
@@ -1597,42 +1614,12 @@ export function renderArcDiagram(
1597
1614
  .domain([minVal, maxVal])
1598
1615
  .range([1.5, 6]);
1599
1616
 
1600
- // SVG
1601
- const svg = d3Selection
1602
- .select(container)
1603
- .append('svg')
1604
- .attr('width', width)
1605
- .attr('height', height)
1606
- .style('background', bgColor);
1607
-
1608
1617
  const g = svg
1609
1618
  .append('g')
1610
1619
  .attr('transform', `translate(${margin.left},${margin.top})`);
1611
1620
 
1612
1621
  // Title
1613
- if (title) {
1614
- const titleEl = svg
1615
- .append('text')
1616
- .attr('class', 'chart-title')
1617
- .attr('x', width / 2)
1618
- .attr('y', 30)
1619
- .attr('text-anchor', 'middle')
1620
- .attr('fill', textColor)
1621
- .attr('font-size', '20px')
1622
- .attr('font-weight', '700')
1623
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
1624
- .text(title);
1625
-
1626
- if (parsed.titleLineNumber) {
1627
- titleEl.attr('data-line-number', parsed.titleLineNumber);
1628
- if (onClickItem) {
1629
- titleEl
1630
- .on('click', () => onClickItem(parsed.titleLineNumber!))
1631
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
1632
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
1633
- }
1634
- }
1635
- }
1622
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
1636
1623
 
1637
1624
  // Build adjacency map for hover interactions
1638
1625
  const neighbors = new Map<string, Set<string>>();
@@ -2871,29 +2858,7 @@ export function renderTimeline(
2871
2858
  .append('g')
2872
2859
  .attr('transform', `translate(${margin.left},${margin.top})`);
2873
2860
 
2874
- if (title) {
2875
- const titleEl = svg
2876
- .append('text')
2877
- .attr('class', 'chart-title')
2878
- .attr('x', width / 2)
2879
- .attr('y', 30)
2880
- .attr('text-anchor', 'middle')
2881
- .attr('fill', textColor)
2882
- .attr('font-size', '20px')
2883
- .attr('font-weight', '700')
2884
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
2885
- .text(title);
2886
-
2887
- if (parsed.titleLineNumber) {
2888
- titleEl.attr('data-line-number', parsed.titleLineNumber);
2889
- if (onClickItem) {
2890
- titleEl
2891
- .on('click', () => onClickItem(parsed.titleLineNumber!))
2892
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
2893
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
2894
- }
2895
- }
2896
- }
2861
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
2897
2862
 
2898
2863
  renderEras(
2899
2864
  g,
@@ -3103,29 +3068,7 @@ export function renderTimeline(
3103
3068
  .append('g')
3104
3069
  .attr('transform', `translate(${margin.left},${margin.top})`);
3105
3070
 
3106
- if (title) {
3107
- const titleEl = svg
3108
- .append('text')
3109
- .attr('class', 'chart-title')
3110
- .attr('x', width / 2)
3111
- .attr('y', 30)
3112
- .attr('text-anchor', 'middle')
3113
- .attr('fill', textColor)
3114
- .attr('font-size', '20px')
3115
- .attr('font-weight', '700')
3116
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
3117
- .text(title);
3118
-
3119
- if (parsed.titleLineNumber) {
3120
- titleEl.attr('data-line-number', parsed.titleLineNumber);
3121
- if (onClickItem) {
3122
- titleEl
3123
- .on('click', () => onClickItem(parsed.titleLineNumber!))
3124
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
3125
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
3126
- }
3127
- }
3128
- }
3071
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
3129
3072
 
3130
3073
  renderEras(
3131
3074
  g,
@@ -3396,29 +3339,7 @@ export function renderTimeline(
3396
3339
  .append('g')
3397
3340
  .attr('transform', `translate(${margin.left},${margin.top})`);
3398
3341
 
3399
- if (title) {
3400
- const titleEl = svg
3401
- .append('text')
3402
- .attr('class', 'chart-title')
3403
- .attr('x', width / 2)
3404
- .attr('y', 30)
3405
- .attr('text-anchor', 'middle')
3406
- .attr('fill', textColor)
3407
- .attr('font-size', '20px')
3408
- .attr('font-weight', '700')
3409
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
3410
- .text(title);
3411
-
3412
- if (parsed.titleLineNumber) {
3413
- titleEl.attr('data-line-number', parsed.titleLineNumber);
3414
- if (onClickItem) {
3415
- titleEl
3416
- .on('click', () => onClickItem(parsed.titleLineNumber!))
3417
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
3418
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
3419
- }
3420
- }
3421
- }
3342
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
3422
3343
 
3423
3344
  renderEras(
3424
3345
  g,
@@ -3693,29 +3614,7 @@ export function renderTimeline(
3693
3614
  .append('g')
3694
3615
  .attr('transform', `translate(${margin.left},${margin.top})`);
3695
3616
 
3696
- if (title) {
3697
- const titleEl = svg
3698
- .append('text')
3699
- .attr('class', 'chart-title')
3700
- .attr('x', width / 2)
3701
- .attr('y', 30)
3702
- .attr('text-anchor', 'middle')
3703
- .attr('fill', textColor)
3704
- .attr('font-size', '20px')
3705
- .attr('font-weight', '700')
3706
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
3707
- .text(title);
3708
-
3709
- if (parsed.titleLineNumber) {
3710
- titleEl.attr('data-line-number', parsed.titleLineNumber);
3711
- if (onClickItem) {
3712
- titleEl
3713
- .on('click', () => onClickItem(parsed.titleLineNumber!))
3714
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
3715
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
3716
- }
3717
- }
3718
- }
3617
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
3719
3618
 
3720
3619
  renderEras(
3721
3620
  g,
@@ -3965,22 +3864,16 @@ export function renderWordCloud(
3965
3864
  onClickItem?: (lineNumber: number) => void,
3966
3865
  exportDims?: D3ExportDimensions
3967
3866
  ): void {
3968
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
3969
-
3970
3867
  const { words, title, cloudOptions } = parsed;
3971
3868
  if (words.length === 0) return;
3972
3869
 
3973
- const width = exportDims?.width ?? container.clientWidth;
3974
- const height = exportDims?.height ?? container.clientHeight;
3975
- if (width <= 0 || height <= 0) return;
3870
+ const init = initD3Chart(container, palette, exportDims);
3871
+ if (!init) return;
3872
+ const { svg, width, height, textColor, colors } = init;
3976
3873
 
3977
3874
  const titleHeight = title ? 40 : 0;
3978
3875
  const cloudHeight = height - titleHeight;
3979
3876
 
3980
- const textColor = palette.text;
3981
- const bgColor = palette.bg;
3982
- const colors = getSeriesColors(palette);
3983
-
3984
3877
  const { minSize, maxSize } = cloudOptions;
3985
3878
  const weights = words.map((w) => w.weight);
3986
3879
  const minWeight = Math.min(...weights);
@@ -3994,36 +3887,7 @@ export function renderWordCloud(
3994
3887
 
3995
3888
  const rotateFn = getRotateFn(cloudOptions.rotate);
3996
3889
 
3997
- const svg = d3Selection
3998
- .select(container)
3999
- .append('svg')
4000
- .attr('width', width)
4001
- .attr('height', height)
4002
- .style('background', bgColor);
4003
-
4004
- if (title) {
4005
- const titleEl = svg
4006
- .append('text')
4007
- .attr('class', 'chart-title')
4008
- .attr('x', width / 2)
4009
- .attr('y', 30)
4010
- .attr('text-anchor', 'middle')
4011
- .attr('fill', textColor)
4012
- .attr('font-size', '20px')
4013
- .attr('font-weight', '700')
4014
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
4015
- .text(title);
4016
-
4017
- if (parsed.titleLineNumber) {
4018
- titleEl.attr('data-line-number', parsed.titleLineNumber);
4019
- if (onClickItem) {
4020
- titleEl
4021
- .on('click', () => onClickItem(parsed.titleLineNumber!))
4022
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
4023
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
4024
- }
4025
- }
4026
- }
3890
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
4027
3891
 
4028
3892
  const g = svg
4029
3893
  .append('g')
@@ -4122,22 +3986,7 @@ function renderWordCloudAsync(
4122
3986
  .attr('height', height)
4123
3987
  .style('background', bgColor);
4124
3988
 
4125
- if (title) {
4126
- const titleEl = svg
4127
- .append('text')
4128
- .attr('class', 'chart-title')
4129
- .attr('x', width / 2)
4130
- .attr('y', 30)
4131
- .attr('text-anchor', 'middle')
4132
- .attr('fill', textColor)
4133
- .attr('font-size', '20px')
4134
- .attr('font-weight', '700')
4135
- .text(title);
4136
-
4137
- if (parsed.titleLineNumber) {
4138
- titleEl.attr('data-line-number', parsed.titleLineNumber);
4139
- }
4140
- }
3989
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor);
4141
3990
 
4142
3991
  const g = svg
4143
3992
  .append('g')
@@ -4359,18 +4208,12 @@ export function renderVenn(
4359
4208
  onClickItem?: (lineNumber: number) => void,
4360
4209
  exportDims?: D3ExportDimensions
4361
4210
  ): void {
4362
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
4363
-
4364
4211
  const { vennSets, vennOverlaps, vennShowValues, title } = parsed;
4365
4212
  if (vennSets.length < 2) return;
4366
4213
 
4367
- const width = exportDims?.width ?? container.clientWidth;
4368
- const height = exportDims?.height ?? container.clientHeight;
4369
- if (width <= 0 || height <= 0) return;
4370
-
4371
- const textColor = palette.text;
4372
- const bgColor = palette.bg;
4373
- const colors = getSeriesColors(palette);
4214
+ const init = initD3Chart(container, palette, exportDims);
4215
+ if (!init) return;
4216
+ const { svg, width, height, textColor, colors } = init;
4374
4217
  const titleHeight = title ? 40 : 0;
4375
4218
 
4376
4219
  // Compute radii
@@ -4477,41 +4320,11 @@ export function renderVenn(
4477
4320
  marginBottom
4478
4321
  ).map((c) => ({ ...c, y: c.y + titleHeight }));
4479
4322
 
4480
- // SVG
4481
- const svg = d3Selection
4482
- .select(container)
4483
- .append('svg')
4484
- .attr('width', width)
4485
- .attr('height', height)
4486
- .style('background', bgColor);
4487
-
4488
4323
  // Tooltip
4489
4324
  const tooltip = createTooltip(container, palette, isDark);
4490
4325
 
4491
4326
  // Title
4492
- if (title) {
4493
- const titleEl = svg
4494
- .append('text')
4495
- .attr('class', 'chart-title')
4496
- .attr('x', width / 2)
4497
- .attr('y', 30)
4498
- .attr('text-anchor', 'middle')
4499
- .attr('fill', textColor)
4500
- .attr('font-size', '20px')
4501
- .attr('font-weight', '700')
4502
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
4503
- .text(title);
4504
-
4505
- if (parsed.titleLineNumber) {
4506
- titleEl.attr('data-line-number', parsed.titleLineNumber);
4507
- if (onClickItem) {
4508
- titleEl
4509
- .on('click', () => onClickItem(parsed.titleLineNumber!))
4510
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
4511
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
4512
- }
4513
- }
4514
- }
4327
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
4515
4328
 
4516
4329
  // ── Semi-transparent filled circles ──
4517
4330
  const circleEls: d3Selection.Selection<SVGCircleElement, unknown, null, undefined>[] = [];
@@ -4784,8 +4597,6 @@ export function renderQuadrant(
4784
4597
  onClickItem?: (lineNumber: number) => void,
4785
4598
  exportDims?: D3ExportDimensions
4786
4599
  ): void {
4787
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
4788
-
4789
4600
  const {
4790
4601
  title,
4791
4602
  quadrantLabels,
@@ -4799,13 +4610,10 @@ export function renderQuadrant(
4799
4610
 
4800
4611
  if (quadrantPoints.length === 0) return;
4801
4612
 
4802
- const width = exportDims?.width ?? container.clientWidth;
4803
- const height = exportDims?.height ?? container.clientHeight;
4804
- if (width <= 0 || height <= 0) return;
4805
-
4806
- const textColor = palette.text;
4613
+ const init = initD3Chart(container, palette, exportDims);
4614
+ if (!init) return;
4615
+ const { svg, width, height, textColor } = init;
4807
4616
  const mutedColor = palette.textMuted;
4808
- const bgColor = palette.bg;
4809
4617
  const borderColor = palette.border;
4810
4618
 
4811
4619
  // Default quadrant colors with alpha
@@ -4827,49 +4635,11 @@ export function renderQuadrant(
4827
4635
  const xScale = d3Scale.scaleLinear().domain([0, 1]).range([0, chartWidth]);
4828
4636
  const yScale = d3Scale.scaleLinear().domain([0, 1]).range([chartHeight, 0]);
4829
4637
 
4830
- // Create SVG
4831
- const svg = d3Selection
4832
- .select(container)
4833
- .append('svg')
4834
- .attr('width', width)
4835
- .attr('height', height)
4836
- .style('background', bgColor);
4837
-
4838
4638
  // Tooltip
4839
4639
  const tooltip = createTooltip(container, palette, isDark);
4840
4640
 
4841
4641
  // Title
4842
- if (title) {
4843
- const titleText = svg
4844
- .append('text')
4845
- .attr('class', 'chart-title')
4846
- .attr('x', width / 2)
4847
- .attr('y', 30)
4848
- .attr('text-anchor', 'middle')
4849
- .attr('fill', textColor)
4850
- .attr('font-size', '20px')
4851
- .attr('font-weight', '700')
4852
- .style(
4853
- 'cursor',
4854
- onClickItem && quadrantTitleLineNumber ? 'pointer' : 'default'
4855
- )
4856
- .text(title);
4857
-
4858
- if (quadrantTitleLineNumber) {
4859
- titleText.attr('data-line-number', quadrantTitleLineNumber);
4860
- }
4861
-
4862
- if (onClickItem && quadrantTitleLineNumber) {
4863
- titleText
4864
- .on('click', () => onClickItem(quadrantTitleLineNumber))
4865
- .on('mouseenter', function () {
4866
- d3Selection.select(this).attr('opacity', 0.7);
4867
- })
4868
- .on('mouseleave', function () {
4869
- d3Selection.select(this).attr('opacity', 1);
4870
- });
4871
- }
4872
- }
4642
+ renderChartTitle(svg, title, quadrantTitleLineNumber, width, textColor, onClickItem);
4873
4643
 
4874
4644
  // Chart group (translated by margins)
4875
4645
  const chartG = svg
@@ -5327,6 +5097,55 @@ export function renderQuadrant(
5327
5097
  const EXPORT_WIDTH = 1200;
5328
5098
  const EXPORT_HEIGHT = 800;
5329
5099
 
5100
+ /**
5101
+ * Resolves the palette for export, falling back to Nord light/dark.
5102
+ */
5103
+ async function resolveExportPalette(theme: string, palette?: PaletteColors): Promise<PaletteColors> {
5104
+ if (palette) return palette;
5105
+ const { getPalette } = await import('./palettes');
5106
+ return theme === 'dark' ? getPalette('nord').dark : getPalette('nord').light;
5107
+ }
5108
+
5109
+ /**
5110
+ * Creates an offscreen container for export rendering.
5111
+ */
5112
+ function createExportContainer(width: number, height: number): HTMLDivElement {
5113
+ const container = document.createElement('div');
5114
+ container.style.width = `${width}px`;
5115
+ container.style.height = `${height}px`;
5116
+ container.style.position = 'absolute';
5117
+ container.style.left = '-9999px';
5118
+ document.body.appendChild(container);
5119
+ return container;
5120
+ }
5121
+
5122
+ /**
5123
+ * Extracts the SVG from a container, applies common export styling, and cleans up.
5124
+ */
5125
+ function finalizeSvgExport(
5126
+ container: HTMLDivElement,
5127
+ theme: string,
5128
+ palette: PaletteColors,
5129
+ options?: { branding?: boolean }
5130
+ ): string {
5131
+ const svgEl = container.querySelector('svg');
5132
+ if (!svgEl) return '';
5133
+ if (theme === 'transparent') {
5134
+ svgEl.style.background = 'none';
5135
+ } else if (!svgEl.style.background) {
5136
+ svgEl.style.background = palette.bg;
5137
+ }
5138
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5139
+ svgEl.style.fontFamily = FONT_FAMILY;
5140
+ const svgHtml = svgEl.outerHTML;
5141
+ document.body.removeChild(container);
5142
+ if (options?.branding !== false) {
5143
+ const brandColor = theme === 'transparent' ? '#888' : palette.textMuted;
5144
+ return injectBranding(svgHtml, brandColor);
5145
+ }
5146
+ return svgHtml;
5147
+ }
5148
+
5330
5149
  /**
5331
5150
  * Renders a D3 chart to an SVG string for export.
5332
5151
  * Creates a detached DOM element, renders into it, extracts the SVG, then cleans up.
@@ -5353,9 +5172,7 @@ export async function renderD3ForExport(
5353
5172
  const { renderOrg } = await import('./org/renderer');
5354
5173
 
5355
5174
  const isDark = theme === 'dark';
5356
- const { getPalette } = await import('./palettes');
5357
- const effectivePalette =
5358
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5175
+ const effectivePalette = await resolveExportPalette(theme, palette);
5359
5176
 
5360
5177
  const orgParsed = parseOrg(content, effectivePalette);
5361
5178
  if (orgParsed.error) return '';
@@ -5377,96 +5194,32 @@ export async function renderD3ForExport(
5377
5194
  hiddenAttributes
5378
5195
  );
5379
5196
 
5380
- // Size container to fit the diagram content
5381
5197
  const PADDING = 20;
5382
5198
  const titleOffset = effectiveParsed.title ? 30 : 0;
5383
5199
  const exportWidth = orgLayout.width + PADDING * 2;
5384
5200
  const exportHeight = orgLayout.height + PADDING * 2 + titleOffset;
5201
+ const container = createExportContainer(exportWidth, exportHeight);
5385
5202
 
5386
- const container = document.createElement('div');
5387
- container.style.width = `${exportWidth}px`;
5388
- container.style.height = `${exportHeight}px`;
5389
- container.style.position = 'absolute';
5390
- container.style.left = '-9999px';
5391
- document.body.appendChild(container);
5392
-
5393
- try {
5394
- renderOrg(
5395
- container,
5396
- effectiveParsed,
5397
- orgLayout,
5398
- effectivePalette,
5399
- isDark,
5400
- undefined,
5401
- { width: exportWidth, height: exportHeight },
5402
- activeTagGroup,
5403
- hiddenAttributes
5404
- );
5405
-
5406
- const svgEl = container.querySelector('svg');
5407
- if (!svgEl) return '';
5408
-
5409
- if (theme === 'transparent') {
5410
- svgEl.style.background = 'none';
5411
- } else if (!svgEl.style.background) {
5412
- svgEl.style.background = effectivePalette.bg;
5413
- }
5414
-
5415
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5416
- svgEl.style.fontFamily = FONT_FAMILY;
5417
-
5418
- const svgHtml = svgEl.outerHTML;
5419
- if (options?.branding !== false) {
5420
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5421
- return injectBranding(svgHtml, brandColor);
5422
- }
5423
- return svgHtml;
5424
- } finally {
5425
- document.body.removeChild(container);
5426
- }
5203
+ renderOrg(container, effectiveParsed, orgLayout, effectivePalette, isDark, undefined, { width: exportWidth, height: exportHeight }, activeTagGroup, hiddenAttributes);
5204
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5427
5205
  }
5428
5206
 
5429
5207
  if (detectedType === 'kanban') {
5430
5208
  const { parseKanban } = await import('./kanban/parser');
5431
5209
  const { renderKanban } = await import('./kanban/renderer');
5432
5210
 
5433
- const isDark = theme === 'dark';
5434
- const { getPalette } = await import('./palettes');
5435
- const effectivePalette =
5436
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5437
-
5211
+ const effectivePalette = await resolveExportPalette(theme, palette);
5438
5212
  const kanbanParsed = parseKanban(content, effectivePalette);
5439
5213
  if (kanbanParsed.error || kanbanParsed.columns.length === 0) return '';
5440
5214
 
5215
+ // Kanban renderer self-sizes — no explicit width/height needed
5441
5216
  const container = document.createElement('div');
5442
5217
  container.style.position = 'absolute';
5443
5218
  container.style.left = '-9999px';
5444
5219
  document.body.appendChild(container);
5445
5220
 
5446
- try {
5447
- renderKanban(container, kanbanParsed, effectivePalette, isDark);
5448
-
5449
- const svgEl = container.querySelector('svg');
5450
- if (!svgEl) return '';
5451
-
5452
- if (theme === 'transparent') {
5453
- svgEl.style.background = 'none';
5454
- } else if (!svgEl.style.background) {
5455
- svgEl.style.background = effectivePalette.bg;
5456
- }
5457
-
5458
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5459
- svgEl.style.fontFamily = FONT_FAMILY;
5460
-
5461
- const svgHtml = svgEl.outerHTML;
5462
- if (options?.branding !== false) {
5463
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5464
- return injectBranding(svgHtml, brandColor);
5465
- }
5466
- return svgHtml;
5467
- } finally {
5468
- document.body.removeChild(container);
5469
- }
5221
+ renderKanban(container, kanbanParsed, effectivePalette, theme === 'dark');
5222
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5470
5223
  }
5471
5224
 
5472
5225
  if (detectedType === 'class') {
@@ -5474,11 +5227,7 @@ export async function renderD3ForExport(
5474
5227
  const { layoutClassDiagram } = await import('./class/layout');
5475
5228
  const { renderClassDiagram } = await import('./class/renderer');
5476
5229
 
5477
- const isDark = theme === 'dark';
5478
- const { getPalette } = await import('./palettes');
5479
- const effectivePalette =
5480
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5481
-
5230
+ const effectivePalette = await resolveExportPalette(theme, palette);
5482
5231
  const classParsed = parseClassDiagram(content, effectivePalette);
5483
5232
  if (classParsed.error || classParsed.classes.length === 0) return '';
5484
5233
 
@@ -5487,46 +5236,10 @@ export async function renderD3ForExport(
5487
5236
  const titleOffset = classParsed.title ? 40 : 0;
5488
5237
  const exportWidth = classLayout.width + PADDING * 2;
5489
5238
  const exportHeight = classLayout.height + PADDING * 2 + titleOffset;
5239
+ const container = createExportContainer(exportWidth, exportHeight);
5490
5240
 
5491
- const container = document.createElement('div');
5492
- container.style.width = `${exportWidth}px`;
5493
- container.style.height = `${exportHeight}px`;
5494
- container.style.position = 'absolute';
5495
- container.style.left = '-9999px';
5496
- document.body.appendChild(container);
5497
-
5498
- try {
5499
- renderClassDiagram(
5500
- container,
5501
- classParsed,
5502
- classLayout,
5503
- effectivePalette,
5504
- isDark,
5505
- undefined,
5506
- { width: exportWidth, height: exportHeight }
5507
- );
5508
-
5509
- const svgEl = container.querySelector('svg');
5510
- if (!svgEl) return '';
5511
-
5512
- if (theme === 'transparent') {
5513
- svgEl.style.background = 'none';
5514
- } else if (!svgEl.style.background) {
5515
- svgEl.style.background = effectivePalette.bg;
5516
- }
5517
-
5518
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5519
- svgEl.style.fontFamily = FONT_FAMILY;
5520
-
5521
- const svgHtml = svgEl.outerHTML;
5522
- if (options?.branding !== false) {
5523
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5524
- return injectBranding(svgHtml, brandColor);
5525
- }
5526
- return svgHtml;
5527
- } finally {
5528
- document.body.removeChild(container);
5529
- }
5241
+ renderClassDiagram(container, classParsed, classLayout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
5242
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5530
5243
  }
5531
5244
 
5532
5245
  if (detectedType === 'er') {
@@ -5534,11 +5247,7 @@ export async function renderD3ForExport(
5534
5247
  const { layoutERDiagram } = await import('./er/layout');
5535
5248
  const { renderERDiagram } = await import('./er/renderer');
5536
5249
 
5537
- const isDark = theme === 'dark';
5538
- const { getPalette } = await import('./palettes');
5539
- const effectivePalette =
5540
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5541
-
5250
+ const effectivePalette = await resolveExportPalette(theme, palette);
5542
5251
  const erParsed = parseERDiagram(content, effectivePalette);
5543
5252
  if (erParsed.error || erParsed.tables.length === 0) return '';
5544
5253
 
@@ -5547,46 +5256,10 @@ export async function renderD3ForExport(
5547
5256
  const titleOffset = erParsed.title ? 40 : 0;
5548
5257
  const exportWidth = erLayout.width + PADDING * 2;
5549
5258
  const exportHeight = erLayout.height + PADDING * 2 + titleOffset;
5259
+ const container = createExportContainer(exportWidth, exportHeight);
5550
5260
 
5551
- const container = document.createElement('div');
5552
- container.style.width = `${exportWidth}px`;
5553
- container.style.height = `${exportHeight}px`;
5554
- container.style.position = 'absolute';
5555
- container.style.left = '-9999px';
5556
- document.body.appendChild(container);
5557
-
5558
- try {
5559
- renderERDiagram(
5560
- container,
5561
- erParsed,
5562
- erLayout,
5563
- effectivePalette,
5564
- isDark,
5565
- undefined,
5566
- { width: exportWidth, height: exportHeight }
5567
- );
5568
-
5569
- const svgEl = container.querySelector('svg');
5570
- if (!svgEl) return '';
5571
-
5572
- if (theme === 'transparent') {
5573
- svgEl.style.background = 'none';
5574
- } else if (!svgEl.style.background) {
5575
- svgEl.style.background = effectivePalette.bg;
5576
- }
5577
-
5578
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5579
- svgEl.style.fontFamily = FONT_FAMILY;
5580
-
5581
- const svgHtml = svgEl.outerHTML;
5582
- if (options?.branding !== false) {
5583
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5584
- return injectBranding(svgHtml, brandColor);
5585
- }
5586
- return svgHtml;
5587
- } finally {
5588
- document.body.removeChild(container);
5589
- }
5261
+ renderERDiagram(container, erParsed, erLayout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
5262
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5590
5263
  }
5591
5264
 
5592
5265
  if (detectedType === 'initiative-status') {
@@ -5594,11 +5267,7 @@ export async function renderD3ForExport(
5594
5267
  const { layoutInitiativeStatus } = await import('./initiative-status/layout');
5595
5268
  const { renderInitiativeStatus } = await import('./initiative-status/renderer');
5596
5269
 
5597
- const isDark = theme === 'dark';
5598
- const { getPalette } = await import('./palettes');
5599
- const effectivePalette =
5600
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5601
-
5270
+ const effectivePalette = await resolveExportPalette(theme, palette);
5602
5271
  const isParsed = parseInitiativeStatus(content);
5603
5272
  if (isParsed.error || isParsed.nodes.length === 0) return '';
5604
5273
 
@@ -5607,46 +5276,10 @@ export async function renderD3ForExport(
5607
5276
  const titleOffset = isParsed.title ? 40 : 0;
5608
5277
  const exportWidth = isLayout.width + PADDING * 2;
5609
5278
  const exportHeight = isLayout.height + PADDING * 2 + titleOffset;
5279
+ const container = createExportContainer(exportWidth, exportHeight);
5610
5280
 
5611
- const container = document.createElement('div');
5612
- container.style.width = `${exportWidth}px`;
5613
- container.style.height = `${exportHeight}px`;
5614
- container.style.position = 'absolute';
5615
- container.style.left = '-9999px';
5616
- document.body.appendChild(container);
5617
-
5618
- try {
5619
- renderInitiativeStatus(
5620
- container,
5621
- isParsed,
5622
- isLayout,
5623
- effectivePalette,
5624
- isDark,
5625
- undefined,
5626
- { width: exportWidth, height: exportHeight }
5627
- );
5628
-
5629
- const svgEl = container.querySelector('svg');
5630
- if (!svgEl) return '';
5631
-
5632
- if (theme === 'transparent') {
5633
- svgEl.style.background = 'none';
5634
- } else if (!svgEl.style.background) {
5635
- svgEl.style.background = effectivePalette.bg;
5636
- }
5637
-
5638
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5639
- svgEl.style.fontFamily = FONT_FAMILY;
5640
-
5641
- const svgHtml = svgEl.outerHTML;
5642
- if (options?.branding !== false) {
5643
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5644
- return injectBranding(svgHtml, brandColor);
5645
- }
5646
- return svgHtml;
5647
- } finally {
5648
- document.body.removeChild(container);
5649
- }
5281
+ renderInitiativeStatus(container, isParsed, isLayout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
5282
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5650
5283
  }
5651
5284
 
5652
5285
  if (detectedType === 'c4') {
@@ -5654,11 +5287,7 @@ export async function renderD3ForExport(
5654
5287
  const { layoutC4Context, layoutC4Containers, layoutC4Components, layoutC4Deployment } = await import('./c4/layout');
5655
5288
  const { renderC4Context, renderC4Containers } = await import('./c4/renderer');
5656
5289
 
5657
- const isDark = theme === 'dark';
5658
- const { getPalette } = await import('./palettes');
5659
- const effectivePalette =
5660
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5661
-
5290
+ const effectivePalette = await resolveExportPalette(theme, palette);
5662
5291
  const c4Parsed = parseC4(content, effectivePalette);
5663
5292
  if (c4Parsed.error || c4Parsed.elements.length === 0) return '';
5664
5293
 
@@ -5681,50 +5310,14 @@ export async function renderD3ForExport(
5681
5310
  const titleOffset = c4Parsed.title ? 40 : 0;
5682
5311
  const exportWidth = c4Layout.width + PADDING * 2;
5683
5312
  const exportHeight = c4Layout.height + PADDING * 2 + titleOffset;
5313
+ const container = createExportContainer(exportWidth, exportHeight);
5684
5314
 
5685
- const container = document.createElement('div');
5686
- container.style.width = `${exportWidth}px`;
5687
- container.style.height = `${exportHeight}px`;
5688
- container.style.position = 'absolute';
5689
- container.style.left = '-9999px';
5690
- document.body.appendChild(container);
5691
-
5692
- try {
5693
- const renderFn = c4Level === 'deployment' || (c4Level === 'components' && c4System && c4Container) || (c4Level === 'containers' && c4System)
5694
- ? renderC4Containers
5695
- : renderC4Context;
5696
-
5697
- renderFn(
5698
- container,
5699
- c4Parsed,
5700
- c4Layout,
5701
- effectivePalette,
5702
- isDark,
5703
- undefined,
5704
- { width: exportWidth, height: exportHeight }
5705
- );
5706
-
5707
- const svgEl = container.querySelector('svg');
5708
- if (!svgEl) return '';
5709
-
5710
- if (theme === 'transparent') {
5711
- svgEl.style.background = 'none';
5712
- } else if (!svgEl.style.background) {
5713
- svgEl.style.background = effectivePalette.bg;
5714
- }
5715
-
5716
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5717
- svgEl.style.fontFamily = FONT_FAMILY;
5315
+ const renderFn = c4Level === 'deployment' || (c4Level === 'components' && c4System && c4Container) || (c4Level === 'containers' && c4System)
5316
+ ? renderC4Containers
5317
+ : renderC4Context;
5718
5318
 
5719
- const svgHtml = svgEl.outerHTML;
5720
- if (options?.branding !== false) {
5721
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5722
- return injectBranding(svgHtml, brandColor);
5723
- }
5724
- return svgHtml;
5725
- } finally {
5726
- document.body.removeChild(container);
5727
- }
5319
+ renderFn(container, c4Parsed, c4Layout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
5320
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5728
5321
  }
5729
5322
 
5730
5323
  if (detectedType === 'flowchart') {
@@ -5732,49 +5325,15 @@ export async function renderD3ForExport(
5732
5325
  const { layoutGraph } = await import('./graph/layout');
5733
5326
  const { renderFlowchart } = await import('./graph/flowchart-renderer');
5734
5327
 
5735
- const isDark = theme === 'dark';
5736
- const { getPalette } = await import('./palettes');
5737
- const effectivePalette =
5738
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5739
-
5328
+ const effectivePalette = await resolveExportPalette(theme, palette);
5740
5329
  const fcParsed = parseFlowchart(content, effectivePalette);
5741
5330
  if (fcParsed.error || fcParsed.nodes.length === 0) return '';
5742
5331
 
5743
5332
  const layout = layoutGraph(fcParsed);
5744
- const container = document.createElement('div');
5745
- container.style.width = `${EXPORT_WIDTH}px`;
5746
- container.style.height = `${EXPORT_HEIGHT}px`;
5747
- container.style.position = 'absolute';
5748
- container.style.left = '-9999px';
5749
- document.body.appendChild(container);
5750
-
5751
- try {
5752
- renderFlowchart(container, fcParsed, layout, effectivePalette, isDark, undefined, {
5753
- width: EXPORT_WIDTH,
5754
- height: EXPORT_HEIGHT,
5755
- });
5756
-
5757
- const svgEl = container.querySelector('svg');
5758
- if (!svgEl) return '';
5759
-
5760
- if (theme === 'transparent') {
5761
- svgEl.style.background = 'none';
5762
- } else if (!svgEl.style.background) {
5763
- svgEl.style.background = effectivePalette.bg;
5764
- }
5765
-
5766
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5767
- svgEl.style.fontFamily = FONT_FAMILY;
5333
+ const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
5768
5334
 
5769
- const svgHtml = svgEl.outerHTML;
5770
- if (options?.branding !== false) {
5771
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5772
- return injectBranding(svgHtml, brandColor);
5773
- }
5774
- return svgHtml;
5775
- } finally {
5776
- document.body.removeChild(container);
5777
- }
5335
+ renderFlowchart(container, fcParsed, layout, effectivePalette, theme === 'dark', undefined, { width: EXPORT_WIDTH, height: EXPORT_HEIGHT });
5336
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5778
5337
  }
5779
5338
 
5780
5339
  const parsed = parseD3(content, palette);
@@ -5796,67 +5355,32 @@ export async function renderD3ForExport(
5796
5355
  if (parsed.type === 'quadrant' && parsed.quadrantPoints.length === 0)
5797
5356
  return '';
5798
5357
 
5358
+ const effectivePalette = await resolveExportPalette(theme, palette);
5799
5359
  const isDark = theme === 'dark';
5800
-
5801
- // Fall back to Nord palette if none provided
5802
- const { getPalette } = await import('./palettes');
5803
- const effectivePalette =
5804
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5805
-
5806
- // Create a temporary offscreen container
5807
- const container = document.createElement('div');
5808
- container.style.width = `${EXPORT_WIDTH}px`;
5809
- container.style.height = `${EXPORT_HEIGHT}px`;
5810
- container.style.position = 'absolute';
5811
- container.style.left = '-9999px';
5812
- document.body.appendChild(container);
5813
-
5360
+ const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
5814
5361
  const dims: D3ExportDimensions = { width: EXPORT_WIDTH, height: EXPORT_HEIGHT };
5815
5362
 
5816
- try {
5817
- if (parsed.type === 'sequence') {
5818
- const { parseSequenceDgmo } = await import('./sequence/parser');
5819
- const { renderSequenceDiagram } = await import('./sequence/renderer');
5820
- const seqParsed = parseSequenceDgmo(content);
5821
- if (seqParsed.error || seqParsed.participants.length === 0) return '';
5822
- renderSequenceDiagram(container, seqParsed, effectivePalette, isDark, undefined, {
5823
- exportWidth: EXPORT_WIDTH,
5824
- });
5825
- } else if (parsed.type === 'wordcloud') {
5826
- await renderWordCloudAsync(container, parsed, effectivePalette, isDark, dims);
5827
- } else if (parsed.type === 'arc') {
5828
- renderArcDiagram(container, parsed, effectivePalette, isDark, undefined, dims);
5829
- } else if (parsed.type === 'timeline') {
5830
- renderTimeline(container, parsed, effectivePalette, isDark, undefined, dims);
5831
- } else if (parsed.type === 'venn') {
5832
- renderVenn(container, parsed, effectivePalette, isDark, undefined, dims);
5833
- } else if (parsed.type === 'quadrant') {
5834
- renderQuadrant(container, parsed, effectivePalette, isDark, undefined, dims);
5835
- } else {
5836
- renderSlopeChart(container, parsed, effectivePalette, isDark, undefined, dims);
5837
- }
5838
-
5839
- const svgEl = container.querySelector('svg');
5840
- if (!svgEl) return '';
5841
-
5842
- // Ensure all chart types have a consistent background
5843
- if (theme === 'transparent') {
5844
- svgEl.style.background = 'none';
5845
- } else if (!svgEl.style.background) {
5846
- svgEl.style.background = effectivePalette.bg;
5847
- }
5848
-
5849
- // Add xmlns for standalone SVG
5850
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5851
- svgEl.style.fontFamily = FONT_FAMILY;
5852
-
5853
- const svgHtml = svgEl.outerHTML;
5854
- if (options?.branding !== false) {
5855
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5856
- return injectBranding(svgHtml, brandColor);
5857
- }
5858
- return svgHtml;
5859
- } finally {
5860
- document.body.removeChild(container);
5363
+ if (parsed.type === 'sequence') {
5364
+ const { parseSequenceDgmo } = await import('./sequence/parser');
5365
+ const { renderSequenceDiagram } = await import('./sequence/renderer');
5366
+ const seqParsed = parseSequenceDgmo(content);
5367
+ if (seqParsed.error || seqParsed.participants.length === 0) return '';
5368
+ renderSequenceDiagram(container, seqParsed, effectivePalette, isDark, undefined, {
5369
+ exportWidth: EXPORT_WIDTH,
5370
+ });
5371
+ } else if (parsed.type === 'wordcloud') {
5372
+ await renderWordCloudAsync(container, parsed, effectivePalette, isDark, dims);
5373
+ } else if (parsed.type === 'arc') {
5374
+ renderArcDiagram(container, parsed, effectivePalette, isDark, undefined, dims);
5375
+ } else if (parsed.type === 'timeline') {
5376
+ renderTimeline(container, parsed, effectivePalette, isDark, undefined, dims);
5377
+ } else if (parsed.type === 'venn') {
5378
+ renderVenn(container, parsed, effectivePalette, isDark, undefined, dims);
5379
+ } else if (parsed.type === 'quadrant') {
5380
+ renderQuadrant(container, parsed, effectivePalette, isDark, undefined, dims);
5381
+ } else {
5382
+ renderSlopeChart(container, parsed, effectivePalette, isDark, undefined, dims);
5861
5383
  }
5384
+
5385
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5862
5386
  }