@diagrammo/dgmo 0.3.1 → 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
  // ============================================================
@@ -1074,6 +1132,35 @@ function tokenizeFreeformText(text: string): WordCloudWord[] {
1074
1132
  // Slope Chart Renderer
1075
1133
  // ============================================================
1076
1134
 
1135
+ /**
1136
+ * Resolves vertical label collisions by nudging overlapping items apart.
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.
1139
+ */
1140
+ export function resolveVerticalCollisions(
1141
+ items: { naturalY: number; height: number }[],
1142
+ minGap: number,
1143
+ maxY?: number
1144
+ ): number[] {
1145
+ if (items.length === 0) return [];
1146
+ const sorted = items
1147
+ .map((it, i) => ({ ...it, idx: i }))
1148
+ .sort((a, b) => a.naturalY - b.naturalY);
1149
+ const adjustedY = new Array<number>(items.length);
1150
+ let prevBottom = -Infinity;
1151
+ for (const item of sorted) {
1152
+ const halfH = item.height / 2;
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
+ }
1158
+ adjustedY[item.idx] = top + halfH;
1159
+ prevBottom = top + item.height;
1160
+ }
1161
+ return adjustedY;
1162
+ }
1163
+
1077
1164
  const SLOPE_MARGIN = { top: 80, bottom: 40, left: 80 };
1078
1165
  const SLOPE_LABEL_FONT_SIZE = 14;
1079
1166
  const SLOPE_CHAR_WIDTH = 8; // approximate px per character at 14px
@@ -1089,15 +1176,12 @@ export function renderSlopeChart(
1089
1176
  onClickItem?: (lineNumber: number) => void,
1090
1177
  exportDims?: D3ExportDimensions
1091
1178
  ): void {
1092
- // Clear existing content
1093
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1094
-
1095
1179
  const { periods, data, title } = parsed;
1096
1180
  if (data.length === 0 || periods.length < 2) return;
1097
1181
 
1098
- const width = exportDims?.width ?? container.clientWidth;
1099
- const height = exportDims?.height ?? container.clientHeight;
1100
- 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;
1101
1185
 
1102
1186
  // Compute right margin from the longest end-of-line label
1103
1187
  const maxLabelText = data.reduce((longest, item) => {
@@ -1114,12 +1198,6 @@ export function renderSlopeChart(
1114
1198
  const innerWidth = width - SLOPE_MARGIN.left - rightMargin;
1115
1199
  const innerHeight = height - SLOPE_MARGIN.top - SLOPE_MARGIN.bottom;
1116
1200
 
1117
- // Theme colors
1118
- const textColor = palette.text;
1119
- const mutedColor = palette.border;
1120
- const bgColor = palette.bg;
1121
- const colors = getSeriesColors(palette);
1122
-
1123
1201
  // Scales
1124
1202
  const allValues = data.flatMap((d) => d.values);
1125
1203
  const [minVal, maxVal] = d3Array.extent(allValues) as [number, number];
@@ -1136,14 +1214,6 @@ export function renderSlopeChart(
1136
1214
  .range([0, innerWidth])
1137
1215
  .padding(0);
1138
1216
 
1139
- // SVG
1140
- const svg = d3Selection
1141
- .select(container)
1142
- .append('svg')
1143
- .attr('width', width)
1144
- .attr('height', height)
1145
- .style('background', bgColor);
1146
-
1147
1217
  const g = svg
1148
1218
  .append('g')
1149
1219
  .attr('transform', `translate(${SLOPE_MARGIN.left},${SLOPE_MARGIN.top})`);
@@ -1152,29 +1222,7 @@ export function renderSlopeChart(
1152
1222
  const tooltip = createTooltip(container, palette, isDark);
1153
1223
 
1154
1224
  // Title
1155
- if (title) {
1156
- const titleEl = svg
1157
- .append('text')
1158
- .attr('class', 'chart-title')
1159
- .attr('x', width / 2)
1160
- .attr('y', 30)
1161
- .attr('text-anchor', 'middle')
1162
- .attr('fill', textColor)
1163
- .attr('font-size', '20px')
1164
- .attr('font-weight', '700')
1165
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
1166
- .text(title);
1167
-
1168
- if (parsed.titleLineNumber) {
1169
- titleEl.attr('data-line-number', parsed.titleLineNumber);
1170
- if (onClickItem) {
1171
- titleEl
1172
- .on('click', () => onClickItem(parsed.titleLineNumber!))
1173
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
1174
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
1175
- }
1176
- }
1177
- }
1225
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
1178
1226
 
1179
1227
  // Period column headers
1180
1228
  for (const period of periods) {
@@ -1205,28 +1253,83 @@ export function renderSlopeChart(
1205
1253
  .x((_d, i) => xScale(periods[i])!)
1206
1254
  .y((d) => yScale(d));
1207
1255
 
1208
- // Render each data series
1209
- data.forEach((item, idx) => {
1256
+ // Pre-compute per-series data for label collision resolution
1257
+ const seriesInfo = data.map((item, idx) => {
1210
1258
  const color = item.color ?? colors[idx % colors.length];
1211
-
1212
- // Wrap each series in a group with data-line-number for sync adapter
1213
- const seriesG = g
1214
- .append('g')
1215
- .attr('class', 'slope-series')
1216
- .attr('data-line-number', String(item.lineNumber));
1217
-
1218
- // Tooltip content – overall change for this series
1219
1259
  const firstVal = item.values[0];
1220
1260
  const lastVal = item.values[item.values.length - 1];
1221
1261
  const absChange = lastVal - firstVal;
1222
1262
  const pctChange = firstVal !== 0 ? (absChange / firstVal) * 100 : null;
1223
1263
  const sign = absChange > 0 ? '+' : '';
1224
- const pctPart =
1225
- pctChange !== null ? ` (${sign}${pctChange.toFixed(1)}%)` : '';
1226
- const tipLines = [`${sign}${absChange}`];
1264
+ const tipLines = [`${sign}${parseFloat(absChange.toFixed(2))}`];
1227
1265
  if (pctChange !== null) tipLines.push(`${sign}${pctChange.toFixed(1)}%`);
1228
1266
  const tipHtml = tipLines.join('<br>');
1229
1267
 
1268
+ // Compute right-side label text and wrapping info
1269
+ const lastX = xScale(periods[periods.length - 1])!;
1270
+ const labelText = `${lastVal} — ${item.label}`;
1271
+ const availableWidth = rightMargin - 15;
1272
+ const maxChars = Math.floor(availableWidth / SLOPE_CHAR_WIDTH);
1273
+
1274
+ let labelLineCount = 1;
1275
+ let wrappedLines: string[] | null = null;
1276
+ if (labelText.length > maxChars) {
1277
+ const words = labelText.split(/\s+/);
1278
+ const lines: string[] = [];
1279
+ let current = '';
1280
+ for (const word of words) {
1281
+ const test = current ? `${current} ${word}` : word;
1282
+ if (test.length > maxChars && current) {
1283
+ lines.push(current);
1284
+ current = word;
1285
+ } else {
1286
+ current = test;
1287
+ }
1288
+ }
1289
+ if (current) lines.push(current);
1290
+ labelLineCount = lines.length;
1291
+ wrappedLines = lines;
1292
+ }
1293
+ const lineHeight = SLOPE_LABEL_FONT_SIZE * 1.2;
1294
+ const labelHeight = labelLineCount === 1
1295
+ ? SLOPE_LABEL_FONT_SIZE
1296
+ : labelLineCount * lineHeight;
1297
+
1298
+ return {
1299
+ item, idx, color, firstVal, lastVal, tipHtml,
1300
+ lastX, labelText, maxChars, wrappedLines, labelHeight,
1301
+ };
1302
+ });
1303
+
1304
+ // --- Resolve left-side label collisions per non-last period column ---
1305
+ const leftLabelHeight = 20; // 16px font needs ~20px to avoid glyph overlap
1306
+ const leftLabelCollisions: Map<number, number[]> = new Map();
1307
+ for (let pi = 0; pi < periods.length - 1; pi++) {
1308
+ const entries = data.map((item) => ({
1309
+ naturalY: yScale(item.values[pi]),
1310
+ height: leftLabelHeight,
1311
+ }));
1312
+ leftLabelCollisions.set(pi, resolveVerticalCollisions(entries, 4, innerHeight));
1313
+ }
1314
+
1315
+ // --- Resolve right-side label collisions ---
1316
+ const rightEntries = seriesInfo.map((si) => ({
1317
+ naturalY: yScale(si.lastVal),
1318
+ height: Math.max(si.labelHeight, SLOPE_LABEL_FONT_SIZE * 1.4),
1319
+ }));
1320
+ const rightAdjustedY = resolveVerticalCollisions(rightEntries, 4, innerHeight);
1321
+
1322
+ // Render each data series
1323
+ data.forEach((item, idx) => {
1324
+ const si = seriesInfo[idx];
1325
+ const color = si.color;
1326
+
1327
+ // Wrap each series in a group with data-line-number for sync adapter
1328
+ const seriesG = g
1329
+ .append('g')
1330
+ .attr('class', 'slope-series')
1331
+ .attr('data-line-number', String(item.lineNumber));
1332
+
1230
1333
  // Line
1231
1334
  seriesG.append('path')
1232
1335
  .datum(item.values)
@@ -1244,10 +1347,10 @@ export function renderSlopeChart(
1244
1347
  .attr('d', lineGen)
1245
1348
  .style('cursor', onClickItem ? 'pointer' : 'default')
1246
1349
  .on('mouseenter', (event: MouseEvent) =>
1247
- showTooltip(tooltip, tipHtml, event)
1350
+ showTooltip(tooltip, si.tipHtml, event)
1248
1351
  )
1249
1352
  .on('mousemove', (event: MouseEvent) =>
1250
- showTooltip(tooltip, tipHtml, event)
1353
+ showTooltip(tooltip, si.tipHtml, event)
1251
1354
  )
1252
1355
  .on('mouseleave', () => hideTooltip(tooltip))
1253
1356
  .on('click', () => {
@@ -1269,10 +1372,10 @@ export function renderSlopeChart(
1269
1372
  .attr('stroke-width', 1.5)
1270
1373
  .style('cursor', onClickItem ? 'pointer' : 'default')
1271
1374
  .on('mouseenter', (event: MouseEvent) =>
1272
- showTooltip(tooltip, tipHtml, event)
1375
+ showTooltip(tooltip, si.tipHtml, event)
1273
1376
  )
1274
1377
  .on('mousemove', (event: MouseEvent) =>
1275
- showTooltip(tooltip, tipHtml, event)
1378
+ showTooltip(tooltip, si.tipHtml, event)
1276
1379
  )
1277
1380
  .on('mouseleave', () => hideTooltip(tooltip))
1278
1381
  .on('click', () => {
@@ -1283,59 +1386,41 @@ export function renderSlopeChart(
1283
1386
  const isFirst = i === 0;
1284
1387
  const isLast = i === periods.length - 1;
1285
1388
  if (!isLast) {
1389
+ const adjustedY = leftLabelCollisions.get(i)![idx];
1286
1390
  seriesG.append('text')
1287
1391
  .attr('x', isFirst ? x - 10 : x)
1288
- .attr('y', y)
1392
+ .attr('y', adjustedY)
1289
1393
  .attr('dy', '0.35em')
1290
1394
  .attr('text-anchor', isFirst ? 'end' : 'middle')
1291
- .attr('fill', textColor)
1395
+ .attr('fill', color)
1292
1396
  .attr('font-size', '16px')
1293
1397
  .text(val.toString());
1294
1398
  }
1295
1399
  });
1296
1400
 
1297
1401
  // Series label with value at end of line — wraps if it exceeds available space
1298
- const lastX = xScale(periods[periods.length - 1])!;
1299
- const lastY = yScale(lastVal);
1300
- const labelText = `${lastVal} — ${item.label}`;
1301
- const availableWidth = rightMargin - 15;
1302
- const maxChars = Math.floor(availableWidth / SLOPE_CHAR_WIDTH);
1402
+ const adjustedLastY = rightAdjustedY[idx];
1303
1403
 
1304
1404
  const labelEl = seriesG
1305
1405
  .append('text')
1306
- .attr('x', lastX + 10)
1307
- .attr('y', lastY)
1406
+ .attr('x', si.lastX + 10)
1407
+ .attr('y', adjustedLastY)
1308
1408
  .attr('text-anchor', 'start')
1309
1409
  .attr('fill', color)
1310
1410
  .attr('font-size', `${SLOPE_LABEL_FONT_SIZE}px`)
1311
1411
  .attr('font-weight', '500');
1312
1412
 
1313
- if (labelText.length <= maxChars) {
1314
- labelEl.attr('dy', '0.35em').text(labelText);
1413
+ if (!si.wrappedLines) {
1414
+ labelEl.attr('dy', '0.35em').text(si.labelText);
1315
1415
  } else {
1316
- // Wrap into lines that fit the available width
1317
- const words = labelText.split(/\s+/);
1318
- const lines: string[] = [];
1319
- let current = '';
1320
- for (const word of words) {
1321
- const test = current ? `${current} ${word}` : word;
1322
- if (test.length > maxChars && current) {
1323
- lines.push(current);
1324
- current = word;
1325
- } else {
1326
- current = test;
1327
- }
1328
- }
1329
- if (current) lines.push(current);
1330
-
1331
1416
  const lineHeight = SLOPE_LABEL_FONT_SIZE * 1.2;
1332
- const totalHeight = (lines.length - 1) * lineHeight;
1417
+ const totalHeight = (si.wrappedLines.length - 1) * lineHeight;
1333
1418
  const startDy = -totalHeight / 2;
1334
1419
 
1335
- lines.forEach((line, li) => {
1420
+ si.wrappedLines.forEach((line, li) => {
1336
1421
  labelEl
1337
1422
  .append('tspan')
1338
- .attr('x', lastX + 10)
1423
+ .attr('x', si.lastX + 10)
1339
1424
  .attr(
1340
1425
  'dy',
1341
1426
  li === 0
@@ -1480,14 +1565,12 @@ export function renderArcDiagram(
1480
1565
  onClickItem?: (lineNumber: number) => void,
1481
1566
  exportDims?: D3ExportDimensions
1482
1567
  ): void {
1483
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1484
-
1485
1568
  const { links, title, orientation, arcOrder, arcNodeGroups } = parsed;
1486
1569
  if (links.length === 0) return;
1487
1570
 
1488
- const width = exportDims?.width ?? container.clientWidth;
1489
- const height = exportDims?.height ?? container.clientHeight;
1490
- 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;
1491
1574
 
1492
1575
  const isVertical = orientation === 'vertical';
1493
1576
  const margin = isVertical
@@ -1502,12 +1585,6 @@ export function renderArcDiagram(
1502
1585
  const innerWidth = width - margin.left - margin.right;
1503
1586
  const innerHeight = height - margin.top - margin.bottom;
1504
1587
 
1505
- // Theme colors
1506
- const textColor = palette.text;
1507
- const mutedColor = palette.border;
1508
- const bgColor = palette.bg;
1509
- const colors = getSeriesColors(palette);
1510
-
1511
1588
  // Order nodes by selected strategy
1512
1589
  const nodes = orderArcNodes(links, arcOrder, arcNodeGroups);
1513
1590
 
@@ -1537,42 +1614,12 @@ export function renderArcDiagram(
1537
1614
  .domain([minVal, maxVal])
1538
1615
  .range([1.5, 6]);
1539
1616
 
1540
- // SVG
1541
- const svg = d3Selection
1542
- .select(container)
1543
- .append('svg')
1544
- .attr('width', width)
1545
- .attr('height', height)
1546
- .style('background', bgColor);
1547
-
1548
1617
  const g = svg
1549
1618
  .append('g')
1550
1619
  .attr('transform', `translate(${margin.left},${margin.top})`);
1551
1620
 
1552
1621
  // Title
1553
- if (title) {
1554
- const titleEl = svg
1555
- .append('text')
1556
- .attr('class', 'chart-title')
1557
- .attr('x', width / 2)
1558
- .attr('y', 30)
1559
- .attr('text-anchor', 'middle')
1560
- .attr('fill', textColor)
1561
- .attr('font-size', '20px')
1562
- .attr('font-weight', '700')
1563
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
1564
- .text(title);
1565
-
1566
- if (parsed.titleLineNumber) {
1567
- titleEl.attr('data-line-number', parsed.titleLineNumber);
1568
- if (onClickItem) {
1569
- titleEl
1570
- .on('click', () => onClickItem(parsed.titleLineNumber!))
1571
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
1572
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
1573
- }
1574
- }
1575
- }
1622
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
1576
1623
 
1577
1624
  // Build adjacency map for hover interactions
1578
1625
  const neighbors = new Map<string, Set<string>>();
@@ -2811,29 +2858,7 @@ export function renderTimeline(
2811
2858
  .append('g')
2812
2859
  .attr('transform', `translate(${margin.left},${margin.top})`);
2813
2860
 
2814
- if (title) {
2815
- const titleEl = svg
2816
- .append('text')
2817
- .attr('class', 'chart-title')
2818
- .attr('x', width / 2)
2819
- .attr('y', 30)
2820
- .attr('text-anchor', 'middle')
2821
- .attr('fill', textColor)
2822
- .attr('font-size', '20px')
2823
- .attr('font-weight', '700')
2824
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
2825
- .text(title);
2826
-
2827
- if (parsed.titleLineNumber) {
2828
- titleEl.attr('data-line-number', parsed.titleLineNumber);
2829
- if (onClickItem) {
2830
- titleEl
2831
- .on('click', () => onClickItem(parsed.titleLineNumber!))
2832
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
2833
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
2834
- }
2835
- }
2836
- }
2861
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
2837
2862
 
2838
2863
  renderEras(
2839
2864
  g,
@@ -3043,29 +3068,7 @@ export function renderTimeline(
3043
3068
  .append('g')
3044
3069
  .attr('transform', `translate(${margin.left},${margin.top})`);
3045
3070
 
3046
- if (title) {
3047
- const titleEl = svg
3048
- .append('text')
3049
- .attr('class', 'chart-title')
3050
- .attr('x', width / 2)
3051
- .attr('y', 30)
3052
- .attr('text-anchor', 'middle')
3053
- .attr('fill', textColor)
3054
- .attr('font-size', '20px')
3055
- .attr('font-weight', '700')
3056
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
3057
- .text(title);
3058
-
3059
- if (parsed.titleLineNumber) {
3060
- titleEl.attr('data-line-number', parsed.titleLineNumber);
3061
- if (onClickItem) {
3062
- titleEl
3063
- .on('click', () => onClickItem(parsed.titleLineNumber!))
3064
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
3065
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
3066
- }
3067
- }
3068
- }
3071
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
3069
3072
 
3070
3073
  renderEras(
3071
3074
  g,
@@ -3336,29 +3339,7 @@ export function renderTimeline(
3336
3339
  .append('g')
3337
3340
  .attr('transform', `translate(${margin.left},${margin.top})`);
3338
3341
 
3339
- if (title) {
3340
- const titleEl = svg
3341
- .append('text')
3342
- .attr('class', 'chart-title')
3343
- .attr('x', width / 2)
3344
- .attr('y', 30)
3345
- .attr('text-anchor', 'middle')
3346
- .attr('fill', textColor)
3347
- .attr('font-size', '20px')
3348
- .attr('font-weight', '700')
3349
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
3350
- .text(title);
3351
-
3352
- if (parsed.titleLineNumber) {
3353
- titleEl.attr('data-line-number', parsed.titleLineNumber);
3354
- if (onClickItem) {
3355
- titleEl
3356
- .on('click', () => onClickItem(parsed.titleLineNumber!))
3357
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
3358
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
3359
- }
3360
- }
3361
- }
3342
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
3362
3343
 
3363
3344
  renderEras(
3364
3345
  g,
@@ -3633,29 +3614,7 @@ export function renderTimeline(
3633
3614
  .append('g')
3634
3615
  .attr('transform', `translate(${margin.left},${margin.top})`);
3635
3616
 
3636
- if (title) {
3637
- const titleEl = svg
3638
- .append('text')
3639
- .attr('class', 'chart-title')
3640
- .attr('x', width / 2)
3641
- .attr('y', 30)
3642
- .attr('text-anchor', 'middle')
3643
- .attr('fill', textColor)
3644
- .attr('font-size', '20px')
3645
- .attr('font-weight', '700')
3646
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
3647
- .text(title);
3648
-
3649
- if (parsed.titleLineNumber) {
3650
- titleEl.attr('data-line-number', parsed.titleLineNumber);
3651
- if (onClickItem) {
3652
- titleEl
3653
- .on('click', () => onClickItem(parsed.titleLineNumber!))
3654
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
3655
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
3656
- }
3657
- }
3658
- }
3617
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
3659
3618
 
3660
3619
  renderEras(
3661
3620
  g,
@@ -3905,22 +3864,16 @@ export function renderWordCloud(
3905
3864
  onClickItem?: (lineNumber: number) => void,
3906
3865
  exportDims?: D3ExportDimensions
3907
3866
  ): void {
3908
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
3909
-
3910
3867
  const { words, title, cloudOptions } = parsed;
3911
3868
  if (words.length === 0) return;
3912
3869
 
3913
- const width = exportDims?.width ?? container.clientWidth;
3914
- const height = exportDims?.height ?? container.clientHeight;
3915
- 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;
3916
3873
 
3917
3874
  const titleHeight = title ? 40 : 0;
3918
3875
  const cloudHeight = height - titleHeight;
3919
3876
 
3920
- const textColor = palette.text;
3921
- const bgColor = palette.bg;
3922
- const colors = getSeriesColors(palette);
3923
-
3924
3877
  const { minSize, maxSize } = cloudOptions;
3925
3878
  const weights = words.map((w) => w.weight);
3926
3879
  const minWeight = Math.min(...weights);
@@ -3934,36 +3887,7 @@ export function renderWordCloud(
3934
3887
 
3935
3888
  const rotateFn = getRotateFn(cloudOptions.rotate);
3936
3889
 
3937
- const svg = d3Selection
3938
- .select(container)
3939
- .append('svg')
3940
- .attr('width', width)
3941
- .attr('height', height)
3942
- .style('background', bgColor);
3943
-
3944
- if (title) {
3945
- const titleEl = svg
3946
- .append('text')
3947
- .attr('class', 'chart-title')
3948
- .attr('x', width / 2)
3949
- .attr('y', 30)
3950
- .attr('text-anchor', 'middle')
3951
- .attr('fill', textColor)
3952
- .attr('font-size', '20px')
3953
- .attr('font-weight', '700')
3954
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
3955
- .text(title);
3956
-
3957
- if (parsed.titleLineNumber) {
3958
- titleEl.attr('data-line-number', parsed.titleLineNumber);
3959
- if (onClickItem) {
3960
- titleEl
3961
- .on('click', () => onClickItem(parsed.titleLineNumber!))
3962
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
3963
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
3964
- }
3965
- }
3966
- }
3890
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
3967
3891
 
3968
3892
  const g = svg
3969
3893
  .append('g')
@@ -4062,22 +3986,7 @@ function renderWordCloudAsync(
4062
3986
  .attr('height', height)
4063
3987
  .style('background', bgColor);
4064
3988
 
4065
- if (title) {
4066
- const titleEl = svg
4067
- .append('text')
4068
- .attr('class', 'chart-title')
4069
- .attr('x', width / 2)
4070
- .attr('y', 30)
4071
- .attr('text-anchor', 'middle')
4072
- .attr('fill', textColor)
4073
- .attr('font-size', '20px')
4074
- .attr('font-weight', '700')
4075
- .text(title);
4076
-
4077
- if (parsed.titleLineNumber) {
4078
- titleEl.attr('data-line-number', parsed.titleLineNumber);
4079
- }
4080
- }
3989
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor);
4081
3990
 
4082
3991
  const g = svg
4083
3992
  .append('g')
@@ -4299,18 +4208,12 @@ export function renderVenn(
4299
4208
  onClickItem?: (lineNumber: number) => void,
4300
4209
  exportDims?: D3ExportDimensions
4301
4210
  ): void {
4302
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
4303
-
4304
4211
  const { vennSets, vennOverlaps, vennShowValues, title } = parsed;
4305
4212
  if (vennSets.length < 2) return;
4306
4213
 
4307
- const width = exportDims?.width ?? container.clientWidth;
4308
- const height = exportDims?.height ?? container.clientHeight;
4309
- if (width <= 0 || height <= 0) return;
4310
-
4311
- const textColor = palette.text;
4312
- const bgColor = palette.bg;
4313
- const colors = getSeriesColors(palette);
4214
+ const init = initD3Chart(container, palette, exportDims);
4215
+ if (!init) return;
4216
+ const { svg, width, height, textColor, colors } = init;
4314
4217
  const titleHeight = title ? 40 : 0;
4315
4218
 
4316
4219
  // Compute radii
@@ -4417,41 +4320,11 @@ export function renderVenn(
4417
4320
  marginBottom
4418
4321
  ).map((c) => ({ ...c, y: c.y + titleHeight }));
4419
4322
 
4420
- // SVG
4421
- const svg = d3Selection
4422
- .select(container)
4423
- .append('svg')
4424
- .attr('width', width)
4425
- .attr('height', height)
4426
- .style('background', bgColor);
4427
-
4428
4323
  // Tooltip
4429
4324
  const tooltip = createTooltip(container, palette, isDark);
4430
4325
 
4431
4326
  // Title
4432
- if (title) {
4433
- const titleEl = svg
4434
- .append('text')
4435
- .attr('class', 'chart-title')
4436
- .attr('x', width / 2)
4437
- .attr('y', 30)
4438
- .attr('text-anchor', 'middle')
4439
- .attr('fill', textColor)
4440
- .attr('font-size', '20px')
4441
- .attr('font-weight', '700')
4442
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
4443
- .text(title);
4444
-
4445
- if (parsed.titleLineNumber) {
4446
- titleEl.attr('data-line-number', parsed.titleLineNumber);
4447
- if (onClickItem) {
4448
- titleEl
4449
- .on('click', () => onClickItem(parsed.titleLineNumber!))
4450
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
4451
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
4452
- }
4453
- }
4454
- }
4327
+ renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
4455
4328
 
4456
4329
  // ── Semi-transparent filled circles ──
4457
4330
  const circleEls: d3Selection.Selection<SVGCircleElement, unknown, null, undefined>[] = [];
@@ -4724,8 +4597,6 @@ export function renderQuadrant(
4724
4597
  onClickItem?: (lineNumber: number) => void,
4725
4598
  exportDims?: D3ExportDimensions
4726
4599
  ): void {
4727
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
4728
-
4729
4600
  const {
4730
4601
  title,
4731
4602
  quadrantLabels,
@@ -4739,13 +4610,10 @@ export function renderQuadrant(
4739
4610
 
4740
4611
  if (quadrantPoints.length === 0) return;
4741
4612
 
4742
- const width = exportDims?.width ?? container.clientWidth;
4743
- const height = exportDims?.height ?? container.clientHeight;
4744
- if (width <= 0 || height <= 0) return;
4745
-
4746
- const textColor = palette.text;
4613
+ const init = initD3Chart(container, palette, exportDims);
4614
+ if (!init) return;
4615
+ const { svg, width, height, textColor } = init;
4747
4616
  const mutedColor = palette.textMuted;
4748
- const bgColor = palette.bg;
4749
4617
  const borderColor = palette.border;
4750
4618
 
4751
4619
  // Default quadrant colors with alpha
@@ -4767,49 +4635,11 @@ export function renderQuadrant(
4767
4635
  const xScale = d3Scale.scaleLinear().domain([0, 1]).range([0, chartWidth]);
4768
4636
  const yScale = d3Scale.scaleLinear().domain([0, 1]).range([chartHeight, 0]);
4769
4637
 
4770
- // Create SVG
4771
- const svg = d3Selection
4772
- .select(container)
4773
- .append('svg')
4774
- .attr('width', width)
4775
- .attr('height', height)
4776
- .style('background', bgColor);
4777
-
4778
4638
  // Tooltip
4779
4639
  const tooltip = createTooltip(container, palette, isDark);
4780
4640
 
4781
4641
  // Title
4782
- if (title) {
4783
- const titleText = svg
4784
- .append('text')
4785
- .attr('class', 'chart-title')
4786
- .attr('x', width / 2)
4787
- .attr('y', 30)
4788
- .attr('text-anchor', 'middle')
4789
- .attr('fill', textColor)
4790
- .attr('font-size', '20px')
4791
- .attr('font-weight', '700')
4792
- .style(
4793
- 'cursor',
4794
- onClickItem && quadrantTitleLineNumber ? 'pointer' : 'default'
4795
- )
4796
- .text(title);
4797
-
4798
- if (quadrantTitleLineNumber) {
4799
- titleText.attr('data-line-number', quadrantTitleLineNumber);
4800
- }
4801
-
4802
- if (onClickItem && quadrantTitleLineNumber) {
4803
- titleText
4804
- .on('click', () => onClickItem(quadrantTitleLineNumber))
4805
- .on('mouseenter', function () {
4806
- d3Selection.select(this).attr('opacity', 0.7);
4807
- })
4808
- .on('mouseleave', function () {
4809
- d3Selection.select(this).attr('opacity', 1);
4810
- });
4811
- }
4812
- }
4642
+ renderChartTitle(svg, title, quadrantTitleLineNumber, width, textColor, onClickItem);
4813
4643
 
4814
4644
  // Chart group (translated by margins)
4815
4645
  const chartG = svg
@@ -5267,6 +5097,55 @@ export function renderQuadrant(
5267
5097
  const EXPORT_WIDTH = 1200;
5268
5098
  const EXPORT_HEIGHT = 800;
5269
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
+
5270
5149
  /**
5271
5150
  * Renders a D3 chart to an SVG string for export.
5272
5151
  * Creates a detached DOM element, renders into it, extracts the SVG, then cleans up.
@@ -5293,9 +5172,7 @@ export async function renderD3ForExport(
5293
5172
  const { renderOrg } = await import('./org/renderer');
5294
5173
 
5295
5174
  const isDark = theme === 'dark';
5296
- const { getPalette } = await import('./palettes');
5297
- const effectivePalette =
5298
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5175
+ const effectivePalette = await resolveExportPalette(theme, palette);
5299
5176
 
5300
5177
  const orgParsed = parseOrg(content, effectivePalette);
5301
5178
  if (orgParsed.error) return '';
@@ -5317,96 +5194,32 @@ export async function renderD3ForExport(
5317
5194
  hiddenAttributes
5318
5195
  );
5319
5196
 
5320
- // Size container to fit the diagram content
5321
5197
  const PADDING = 20;
5322
5198
  const titleOffset = effectiveParsed.title ? 30 : 0;
5323
5199
  const exportWidth = orgLayout.width + PADDING * 2;
5324
5200
  const exportHeight = orgLayout.height + PADDING * 2 + titleOffset;
5201
+ const container = createExportContainer(exportWidth, exportHeight);
5325
5202
 
5326
- const container = document.createElement('div');
5327
- container.style.width = `${exportWidth}px`;
5328
- container.style.height = `${exportHeight}px`;
5329
- container.style.position = 'absolute';
5330
- container.style.left = '-9999px';
5331
- document.body.appendChild(container);
5332
-
5333
- try {
5334
- renderOrg(
5335
- container,
5336
- effectiveParsed,
5337
- orgLayout,
5338
- effectivePalette,
5339
- isDark,
5340
- undefined,
5341
- { width: exportWidth, height: exportHeight },
5342
- activeTagGroup,
5343
- hiddenAttributes
5344
- );
5345
-
5346
- const svgEl = container.querySelector('svg');
5347
- if (!svgEl) return '';
5348
-
5349
- if (theme === 'transparent') {
5350
- svgEl.style.background = 'none';
5351
- } else if (!svgEl.style.background) {
5352
- svgEl.style.background = effectivePalette.bg;
5353
- }
5354
-
5355
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5356
- svgEl.style.fontFamily = FONT_FAMILY;
5357
-
5358
- const svgHtml = svgEl.outerHTML;
5359
- if (options?.branding !== false) {
5360
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5361
- return injectBranding(svgHtml, brandColor);
5362
- }
5363
- return svgHtml;
5364
- } finally {
5365
- document.body.removeChild(container);
5366
- }
5203
+ renderOrg(container, effectiveParsed, orgLayout, effectivePalette, isDark, undefined, { width: exportWidth, height: exportHeight }, activeTagGroup, hiddenAttributes);
5204
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5367
5205
  }
5368
5206
 
5369
5207
  if (detectedType === 'kanban') {
5370
5208
  const { parseKanban } = await import('./kanban/parser');
5371
5209
  const { renderKanban } = await import('./kanban/renderer');
5372
5210
 
5373
- const isDark = theme === 'dark';
5374
- const { getPalette } = await import('./palettes');
5375
- const effectivePalette =
5376
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5377
-
5211
+ const effectivePalette = await resolveExportPalette(theme, palette);
5378
5212
  const kanbanParsed = parseKanban(content, effectivePalette);
5379
5213
  if (kanbanParsed.error || kanbanParsed.columns.length === 0) return '';
5380
5214
 
5215
+ // Kanban renderer self-sizes — no explicit width/height needed
5381
5216
  const container = document.createElement('div');
5382
5217
  container.style.position = 'absolute';
5383
5218
  container.style.left = '-9999px';
5384
5219
  document.body.appendChild(container);
5385
5220
 
5386
- try {
5387
- renderKanban(container, kanbanParsed, effectivePalette, isDark);
5388
-
5389
- const svgEl = container.querySelector('svg');
5390
- if (!svgEl) return '';
5391
-
5392
- if (theme === 'transparent') {
5393
- svgEl.style.background = 'none';
5394
- } else if (!svgEl.style.background) {
5395
- svgEl.style.background = effectivePalette.bg;
5396
- }
5397
-
5398
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5399
- svgEl.style.fontFamily = FONT_FAMILY;
5400
-
5401
- const svgHtml = svgEl.outerHTML;
5402
- if (options?.branding !== false) {
5403
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5404
- return injectBranding(svgHtml, brandColor);
5405
- }
5406
- return svgHtml;
5407
- } finally {
5408
- document.body.removeChild(container);
5409
- }
5221
+ renderKanban(container, kanbanParsed, effectivePalette, theme === 'dark');
5222
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5410
5223
  }
5411
5224
 
5412
5225
  if (detectedType === 'class') {
@@ -5414,11 +5227,7 @@ export async function renderD3ForExport(
5414
5227
  const { layoutClassDiagram } = await import('./class/layout');
5415
5228
  const { renderClassDiagram } = await import('./class/renderer');
5416
5229
 
5417
- const isDark = theme === 'dark';
5418
- const { getPalette } = await import('./palettes');
5419
- const effectivePalette =
5420
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5421
-
5230
+ const effectivePalette = await resolveExportPalette(theme, palette);
5422
5231
  const classParsed = parseClassDiagram(content, effectivePalette);
5423
5232
  if (classParsed.error || classParsed.classes.length === 0) return '';
5424
5233
 
@@ -5427,46 +5236,10 @@ export async function renderD3ForExport(
5427
5236
  const titleOffset = classParsed.title ? 40 : 0;
5428
5237
  const exportWidth = classLayout.width + PADDING * 2;
5429
5238
  const exportHeight = classLayout.height + PADDING * 2 + titleOffset;
5239
+ const container = createExportContainer(exportWidth, exportHeight);
5430
5240
 
5431
- const container = document.createElement('div');
5432
- container.style.width = `${exportWidth}px`;
5433
- container.style.height = `${exportHeight}px`;
5434
- container.style.position = 'absolute';
5435
- container.style.left = '-9999px';
5436
- document.body.appendChild(container);
5437
-
5438
- try {
5439
- renderClassDiagram(
5440
- container,
5441
- classParsed,
5442
- classLayout,
5443
- effectivePalette,
5444
- isDark,
5445
- undefined,
5446
- { width: exportWidth, height: exportHeight }
5447
- );
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
- }
5241
+ renderClassDiagram(container, classParsed, classLayout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
5242
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5470
5243
  }
5471
5244
 
5472
5245
  if (detectedType === 'er') {
@@ -5474,11 +5247,7 @@ export async function renderD3ForExport(
5474
5247
  const { layoutERDiagram } = await import('./er/layout');
5475
5248
  const { renderERDiagram } = await import('./er/renderer');
5476
5249
 
5477
- const isDark = theme === 'dark';
5478
- const { getPalette } = await import('./palettes');
5479
- const effectivePalette =
5480
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5481
-
5250
+ const effectivePalette = await resolveExportPalette(theme, palette);
5482
5251
  const erParsed = parseERDiagram(content, effectivePalette);
5483
5252
  if (erParsed.error || erParsed.tables.length === 0) return '';
5484
5253
 
@@ -5487,46 +5256,10 @@ export async function renderD3ForExport(
5487
5256
  const titleOffset = erParsed.title ? 40 : 0;
5488
5257
  const exportWidth = erLayout.width + PADDING * 2;
5489
5258
  const exportHeight = erLayout.height + PADDING * 2 + titleOffset;
5259
+ const container = createExportContainer(exportWidth, exportHeight);
5490
5260
 
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
- renderERDiagram(
5500
- container,
5501
- erParsed,
5502
- erLayout,
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
- }
5261
+ renderERDiagram(container, erParsed, erLayout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
5262
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5530
5263
  }
5531
5264
 
5532
5265
  if (detectedType === 'initiative-status') {
@@ -5534,11 +5267,7 @@ export async function renderD3ForExport(
5534
5267
  const { layoutInitiativeStatus } = await import('./initiative-status/layout');
5535
5268
  const { renderInitiativeStatus } = await import('./initiative-status/renderer');
5536
5269
 
5537
- const isDark = theme === 'dark';
5538
- const { getPalette } = await import('./palettes');
5539
- const effectivePalette =
5540
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5541
-
5270
+ const effectivePalette = await resolveExportPalette(theme, palette);
5542
5271
  const isParsed = parseInitiativeStatus(content);
5543
5272
  if (isParsed.error || isParsed.nodes.length === 0) return '';
5544
5273
 
@@ -5547,46 +5276,10 @@ export async function renderD3ForExport(
5547
5276
  const titleOffset = isParsed.title ? 40 : 0;
5548
5277
  const exportWidth = isLayout.width + PADDING * 2;
5549
5278
  const exportHeight = isLayout.height + PADDING * 2 + titleOffset;
5279
+ const container = createExportContainer(exportWidth, exportHeight);
5550
5280
 
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
- renderInitiativeStatus(
5560
- container,
5561
- isParsed,
5562
- isLayout,
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
- }
5281
+ renderInitiativeStatus(container, isParsed, isLayout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
5282
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5590
5283
  }
5591
5284
 
5592
5285
  if (detectedType === 'c4') {
@@ -5594,11 +5287,7 @@ export async function renderD3ForExport(
5594
5287
  const { layoutC4Context, layoutC4Containers, layoutC4Components, layoutC4Deployment } = await import('./c4/layout');
5595
5288
  const { renderC4Context, renderC4Containers } = await import('./c4/renderer');
5596
5289
 
5597
- const isDark = theme === 'dark';
5598
- const { getPalette } = await import('./palettes');
5599
- const effectivePalette =
5600
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5601
-
5290
+ const effectivePalette = await resolveExportPalette(theme, palette);
5602
5291
  const c4Parsed = parseC4(content, effectivePalette);
5603
5292
  if (c4Parsed.error || c4Parsed.elements.length === 0) return '';
5604
5293
 
@@ -5621,50 +5310,14 @@ export async function renderD3ForExport(
5621
5310
  const titleOffset = c4Parsed.title ? 40 : 0;
5622
5311
  const exportWidth = c4Layout.width + PADDING * 2;
5623
5312
  const exportHeight = c4Layout.height + PADDING * 2 + titleOffset;
5313
+ const container = createExportContainer(exportWidth, exportHeight);
5624
5314
 
5625
- const container = document.createElement('div');
5626
- container.style.width = `${exportWidth}px`;
5627
- container.style.height = `${exportHeight}px`;
5628
- container.style.position = 'absolute';
5629
- container.style.left = '-9999px';
5630
- document.body.appendChild(container);
5631
-
5632
- try {
5633
- const renderFn = c4Level === 'deployment' || (c4Level === 'components' && c4System && c4Container) || (c4Level === 'containers' && c4System)
5634
- ? renderC4Containers
5635
- : renderC4Context;
5636
-
5637
- renderFn(
5638
- container,
5639
- c4Parsed,
5640
- c4Layout,
5641
- effectivePalette,
5642
- isDark,
5643
- undefined,
5644
- { width: exportWidth, height: exportHeight }
5645
- );
5646
-
5647
- const svgEl = container.querySelector('svg');
5648
- if (!svgEl) return '';
5315
+ const renderFn = c4Level === 'deployment' || (c4Level === 'components' && c4System && c4Container) || (c4Level === 'containers' && c4System)
5316
+ ? renderC4Containers
5317
+ : renderC4Context;
5649
5318
 
5650
- if (theme === 'transparent') {
5651
- svgEl.style.background = 'none';
5652
- } else if (!svgEl.style.background) {
5653
- svgEl.style.background = effectivePalette.bg;
5654
- }
5655
-
5656
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5657
- svgEl.style.fontFamily = FONT_FAMILY;
5658
-
5659
- const svgHtml = svgEl.outerHTML;
5660
- if (options?.branding !== false) {
5661
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5662
- return injectBranding(svgHtml, brandColor);
5663
- }
5664
- return svgHtml;
5665
- } finally {
5666
- document.body.removeChild(container);
5667
- }
5319
+ renderFn(container, c4Parsed, c4Layout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
5320
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5668
5321
  }
5669
5322
 
5670
5323
  if (detectedType === 'flowchart') {
@@ -5672,49 +5325,15 @@ export async function renderD3ForExport(
5672
5325
  const { layoutGraph } = await import('./graph/layout');
5673
5326
  const { renderFlowchart } = await import('./graph/flowchart-renderer');
5674
5327
 
5675
- const isDark = theme === 'dark';
5676
- const { getPalette } = await import('./palettes');
5677
- const effectivePalette =
5678
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5679
-
5328
+ const effectivePalette = await resolveExportPalette(theme, palette);
5680
5329
  const fcParsed = parseFlowchart(content, effectivePalette);
5681
5330
  if (fcParsed.error || fcParsed.nodes.length === 0) return '';
5682
5331
 
5683
5332
  const layout = layoutGraph(fcParsed);
5684
- const container = document.createElement('div');
5685
- container.style.width = `${EXPORT_WIDTH}px`;
5686
- container.style.height = `${EXPORT_HEIGHT}px`;
5687
- container.style.position = 'absolute';
5688
- container.style.left = '-9999px';
5689
- document.body.appendChild(container);
5690
-
5691
- try {
5692
- renderFlowchart(container, fcParsed, layout, effectivePalette, isDark, undefined, {
5693
- width: EXPORT_WIDTH,
5694
- height: EXPORT_HEIGHT,
5695
- });
5696
-
5697
- const svgEl = container.querySelector('svg');
5698
- if (!svgEl) return '';
5699
-
5700
- if (theme === 'transparent') {
5701
- svgEl.style.background = 'none';
5702
- } else if (!svgEl.style.background) {
5703
- svgEl.style.background = effectivePalette.bg;
5704
- }
5705
-
5706
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5707
- svgEl.style.fontFamily = FONT_FAMILY;
5333
+ const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
5708
5334
 
5709
- const svgHtml = svgEl.outerHTML;
5710
- if (options?.branding !== false) {
5711
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5712
- return injectBranding(svgHtml, brandColor);
5713
- }
5714
- return svgHtml;
5715
- } finally {
5716
- document.body.removeChild(container);
5717
- }
5335
+ renderFlowchart(container, fcParsed, layout, effectivePalette, theme === 'dark', undefined, { width: EXPORT_WIDTH, height: EXPORT_HEIGHT });
5336
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5718
5337
  }
5719
5338
 
5720
5339
  const parsed = parseD3(content, palette);
@@ -5736,67 +5355,32 @@ export async function renderD3ForExport(
5736
5355
  if (parsed.type === 'quadrant' && parsed.quadrantPoints.length === 0)
5737
5356
  return '';
5738
5357
 
5358
+ const effectivePalette = await resolveExportPalette(theme, palette);
5739
5359
  const isDark = theme === 'dark';
5740
-
5741
- // Fall back to Nord palette if none provided
5742
- const { getPalette } = await import('./palettes');
5743
- const effectivePalette =
5744
- palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5745
-
5746
- // Create a temporary offscreen container
5747
- const container = document.createElement('div');
5748
- container.style.width = `${EXPORT_WIDTH}px`;
5749
- container.style.height = `${EXPORT_HEIGHT}px`;
5750
- container.style.position = 'absolute';
5751
- container.style.left = '-9999px';
5752
- document.body.appendChild(container);
5753
-
5360
+ const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
5754
5361
  const dims: D3ExportDimensions = { width: EXPORT_WIDTH, height: EXPORT_HEIGHT };
5755
5362
 
5756
- try {
5757
- if (parsed.type === 'sequence') {
5758
- const { parseSequenceDgmo } = await import('./sequence/parser');
5759
- const { renderSequenceDiagram } = await import('./sequence/renderer');
5760
- const seqParsed = parseSequenceDgmo(content);
5761
- if (seqParsed.error || seqParsed.participants.length === 0) return '';
5762
- renderSequenceDiagram(container, seqParsed, effectivePalette, isDark, undefined, {
5763
- exportWidth: EXPORT_WIDTH,
5764
- });
5765
- } else if (parsed.type === 'wordcloud') {
5766
- await renderWordCloudAsync(container, parsed, effectivePalette, isDark, dims);
5767
- } else if (parsed.type === 'arc') {
5768
- renderArcDiagram(container, parsed, effectivePalette, isDark, undefined, dims);
5769
- } else if (parsed.type === 'timeline') {
5770
- renderTimeline(container, parsed, effectivePalette, isDark, undefined, dims);
5771
- } else if (parsed.type === 'venn') {
5772
- renderVenn(container, parsed, effectivePalette, isDark, undefined, dims);
5773
- } else if (parsed.type === 'quadrant') {
5774
- renderQuadrant(container, parsed, effectivePalette, isDark, undefined, dims);
5775
- } else {
5776
- renderSlopeChart(container, parsed, effectivePalette, isDark, undefined, dims);
5777
- }
5778
-
5779
- const svgEl = container.querySelector('svg');
5780
- if (!svgEl) return '';
5781
-
5782
- // Ensure all chart types have a consistent background
5783
- if (theme === 'transparent') {
5784
- svgEl.style.background = 'none';
5785
- } else if (!svgEl.style.background) {
5786
- svgEl.style.background = effectivePalette.bg;
5787
- }
5788
-
5789
- // Add xmlns for standalone SVG
5790
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5791
- svgEl.style.fontFamily = FONT_FAMILY;
5792
-
5793
- const svgHtml = svgEl.outerHTML;
5794
- if (options?.branding !== false) {
5795
- const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5796
- return injectBranding(svgHtml, brandColor);
5797
- }
5798
- return svgHtml;
5799
- } finally {
5800
- 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);
5801
5383
  }
5384
+
5385
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5802
5386
  }