@diagrammo/dgmo 0.3.1 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/d3.ts CHANGED
@@ -1074,6 +1074,29 @@ function tokenizeFreeformText(text: string): WordCloudWord[] {
1074
1074
  // Slope Chart Renderer
1075
1075
  // ============================================================
1076
1076
 
1077
+ /**
1078
+ * Resolves vertical label collisions by nudging overlapping items apart.
1079
+ * Takes items with a naturalY (center) and height, returns adjusted center Y positions.
1080
+ */
1081
+ function resolveVerticalCollisions(
1082
+ items: { naturalY: number; height: number }[],
1083
+ minGap: number
1084
+ ): number[] {
1085
+ if (items.length === 0) return [];
1086
+ const sorted = items
1087
+ .map((it, i) => ({ ...it, idx: i }))
1088
+ .sort((a, b) => a.naturalY - b.naturalY);
1089
+ const adjustedY = new Array<number>(items.length);
1090
+ let prevBottom = -Infinity;
1091
+ for (const item of sorted) {
1092
+ const halfH = item.height / 2;
1093
+ const top = Math.max(item.naturalY - halfH, prevBottom + minGap);
1094
+ adjustedY[item.idx] = top + halfH;
1095
+ prevBottom = top + item.height;
1096
+ }
1097
+ return adjustedY;
1098
+ }
1099
+
1077
1100
  const SLOPE_MARGIN = { top: 80, bottom: 40, left: 80 };
1078
1101
  const SLOPE_LABEL_FONT_SIZE = 14;
1079
1102
  const SLOPE_CHAR_WIDTH = 8; // approximate px per character at 14px
@@ -1205,28 +1228,83 @@ export function renderSlopeChart(
1205
1228
  .x((_d, i) => xScale(periods[i])!)
1206
1229
  .y((d) => yScale(d));
1207
1230
 
1208
- // Render each data series
1209
- data.forEach((item, idx) => {
1231
+ // Pre-compute per-series data for label collision resolution
1232
+ const seriesInfo = data.map((item, idx) => {
1210
1233
  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
1234
  const firstVal = item.values[0];
1220
1235
  const lastVal = item.values[item.values.length - 1];
1221
1236
  const absChange = lastVal - firstVal;
1222
1237
  const pctChange = firstVal !== 0 ? (absChange / firstVal) * 100 : null;
1223
1238
  const sign = absChange > 0 ? '+' : '';
1224
- const pctPart =
1225
- pctChange !== null ? ` (${sign}${pctChange.toFixed(1)}%)` : '';
1226
- const tipLines = [`${sign}${absChange}`];
1239
+ const tipLines = [`${sign}${parseFloat(absChange.toFixed(2))}`];
1227
1240
  if (pctChange !== null) tipLines.push(`${sign}${pctChange.toFixed(1)}%`);
1228
1241
  const tipHtml = tipLines.join('<br>');
1229
1242
 
1243
+ // Compute right-side label text and wrapping info
1244
+ const lastX = xScale(periods[periods.length - 1])!;
1245
+ const labelText = `${lastVal} — ${item.label}`;
1246
+ const availableWidth = rightMargin - 15;
1247
+ const maxChars = Math.floor(availableWidth / SLOPE_CHAR_WIDTH);
1248
+
1249
+ let labelLineCount = 1;
1250
+ let wrappedLines: string[] | null = null;
1251
+ if (labelText.length > maxChars) {
1252
+ const words = labelText.split(/\s+/);
1253
+ const lines: string[] = [];
1254
+ let current = '';
1255
+ for (const word of words) {
1256
+ const test = current ? `${current} ${word}` : word;
1257
+ if (test.length > maxChars && current) {
1258
+ lines.push(current);
1259
+ current = word;
1260
+ } else {
1261
+ current = test;
1262
+ }
1263
+ }
1264
+ if (current) lines.push(current);
1265
+ labelLineCount = lines.length;
1266
+ wrappedLines = lines;
1267
+ }
1268
+ const lineHeight = SLOPE_LABEL_FONT_SIZE * 1.2;
1269
+ const labelHeight = labelLineCount === 1
1270
+ ? SLOPE_LABEL_FONT_SIZE
1271
+ : labelLineCount * lineHeight;
1272
+
1273
+ return {
1274
+ item, idx, color, firstVal, lastVal, tipHtml,
1275
+ lastX, labelText, maxChars, wrappedLines, labelHeight,
1276
+ };
1277
+ });
1278
+
1279
+ // --- Resolve left-side label collisions per non-last period column ---
1280
+ const leftLabelHeight = 20; // 16px font needs ~20px to avoid glyph overlap
1281
+ const leftLabelCollisions: Map<number, number[]> = new Map();
1282
+ for (let pi = 0; pi < periods.length - 1; pi++) {
1283
+ const entries = data.map((item) => ({
1284
+ naturalY: yScale(item.values[pi]),
1285
+ height: leftLabelHeight,
1286
+ }));
1287
+ leftLabelCollisions.set(pi, resolveVerticalCollisions(entries, 4));
1288
+ }
1289
+
1290
+ // --- Resolve right-side label collisions ---
1291
+ const rightEntries = seriesInfo.map((si) => ({
1292
+ naturalY: yScale(si.lastVal),
1293
+ height: Math.max(si.labelHeight, SLOPE_LABEL_FONT_SIZE * 1.4),
1294
+ }));
1295
+ const rightAdjustedY = resolveVerticalCollisions(rightEntries, 4);
1296
+
1297
+ // Render each data series
1298
+ data.forEach((item, idx) => {
1299
+ const si = seriesInfo[idx];
1300
+ const color = si.color;
1301
+
1302
+ // Wrap each series in a group with data-line-number for sync adapter
1303
+ const seriesG = g
1304
+ .append('g')
1305
+ .attr('class', 'slope-series')
1306
+ .attr('data-line-number', String(item.lineNumber));
1307
+
1230
1308
  // Line
1231
1309
  seriesG.append('path')
1232
1310
  .datum(item.values)
@@ -1244,10 +1322,10 @@ export function renderSlopeChart(
1244
1322
  .attr('d', lineGen)
1245
1323
  .style('cursor', onClickItem ? 'pointer' : 'default')
1246
1324
  .on('mouseenter', (event: MouseEvent) =>
1247
- showTooltip(tooltip, tipHtml, event)
1325
+ showTooltip(tooltip, si.tipHtml, event)
1248
1326
  )
1249
1327
  .on('mousemove', (event: MouseEvent) =>
1250
- showTooltip(tooltip, tipHtml, event)
1328
+ showTooltip(tooltip, si.tipHtml, event)
1251
1329
  )
1252
1330
  .on('mouseleave', () => hideTooltip(tooltip))
1253
1331
  .on('click', () => {
@@ -1269,10 +1347,10 @@ export function renderSlopeChart(
1269
1347
  .attr('stroke-width', 1.5)
1270
1348
  .style('cursor', onClickItem ? 'pointer' : 'default')
1271
1349
  .on('mouseenter', (event: MouseEvent) =>
1272
- showTooltip(tooltip, tipHtml, event)
1350
+ showTooltip(tooltip, si.tipHtml, event)
1273
1351
  )
1274
1352
  .on('mousemove', (event: MouseEvent) =>
1275
- showTooltip(tooltip, tipHtml, event)
1353
+ showTooltip(tooltip, si.tipHtml, event)
1276
1354
  )
1277
1355
  .on('mouseleave', () => hideTooltip(tooltip))
1278
1356
  .on('click', () => {
@@ -1283,59 +1361,41 @@ export function renderSlopeChart(
1283
1361
  const isFirst = i === 0;
1284
1362
  const isLast = i === periods.length - 1;
1285
1363
  if (!isLast) {
1364
+ const adjustedY = leftLabelCollisions.get(i)![idx];
1286
1365
  seriesG.append('text')
1287
1366
  .attr('x', isFirst ? x - 10 : x)
1288
- .attr('y', y)
1367
+ .attr('y', adjustedY)
1289
1368
  .attr('dy', '0.35em')
1290
1369
  .attr('text-anchor', isFirst ? 'end' : 'middle')
1291
- .attr('fill', textColor)
1370
+ .attr('fill', color)
1292
1371
  .attr('font-size', '16px')
1293
1372
  .text(val.toString());
1294
1373
  }
1295
1374
  });
1296
1375
 
1297
1376
  // 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);
1377
+ const adjustedLastY = rightAdjustedY[idx];
1303
1378
 
1304
1379
  const labelEl = seriesG
1305
1380
  .append('text')
1306
- .attr('x', lastX + 10)
1307
- .attr('y', lastY)
1381
+ .attr('x', si.lastX + 10)
1382
+ .attr('y', adjustedLastY)
1308
1383
  .attr('text-anchor', 'start')
1309
1384
  .attr('fill', color)
1310
1385
  .attr('font-size', `${SLOPE_LABEL_FONT_SIZE}px`)
1311
1386
  .attr('font-weight', '500');
1312
1387
 
1313
- if (labelText.length <= maxChars) {
1314
- labelEl.attr('dy', '0.35em').text(labelText);
1388
+ if (!si.wrappedLines) {
1389
+ labelEl.attr('dy', '0.35em').text(si.labelText);
1315
1390
  } 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
1391
  const lineHeight = SLOPE_LABEL_FONT_SIZE * 1.2;
1332
- const totalHeight = (lines.length - 1) * lineHeight;
1392
+ const totalHeight = (si.wrappedLines.length - 1) * lineHeight;
1333
1393
  const startDy = -totalHeight / 2;
1334
1394
 
1335
- lines.forEach((line, li) => {
1395
+ si.wrappedLines.forEach((line, li) => {
1336
1396
  labelEl
1337
1397
  .append('tspan')
1338
- .attr('x', lastX + 10)
1398
+ .attr('x', si.lastX + 10)
1339
1399
  .attr(
1340
1400
  'dy',
1341
1401
  li === 0
package/src/echarts.ts CHANGED
@@ -1309,21 +1309,45 @@ function makeGridAxis(
1309
1309
  gridOpacity: number,
1310
1310
  label?: string,
1311
1311
  data?: string[],
1312
- nameGapOverride?: number
1312
+ nameGapOverride?: number,
1313
+ chartWidthHint?: number
1313
1314
  ): Record<string, unknown> {
1314
1315
  const defaultGap = type === 'value' ? 75 : 40;
1316
+
1317
+ // Compute category label sizing: font size and width constraint
1318
+ let catFontSize = 16;
1319
+ let catLabelExtras: Record<string, unknown> = {};
1320
+ if (type === 'category' && data && data.length > 0) {
1321
+ const maxLabelLen = Math.max(...data.map((l) => l.length));
1322
+ const count = data.length;
1323
+ // Reduce font size based on density and label length
1324
+ if (count > 10 || maxLabelLen > 20) catFontSize = 10;
1325
+ else if (count > 5 || maxLabelLen > 14) catFontSize = 11;
1326
+ else if (maxLabelLen > 8) catFontSize = 12;
1327
+
1328
+ // Constrain labels to their allotted slot width so ECharts wraps instead of hiding
1329
+ if (chartWidthHint && count > 0) {
1330
+ const availPerLabel = Math.floor((chartWidthHint * 0.85) / count);
1331
+ catLabelExtras = {
1332
+ width: availPerLabel,
1333
+ overflow: 'break',
1334
+ };
1335
+ }
1336
+ }
1337
+
1315
1338
  return {
1316
1339
  type,
1317
1340
  ...(data && { data }),
1318
1341
  axisLine: { lineStyle: { color: axisLineColor } },
1319
1342
  axisLabel: {
1320
1343
  color: textColor,
1321
- fontSize: type === 'category' && data ? (data.length > 10 ? 11 : data.length > 5 ? 12 : 16) : 16,
1344
+ fontSize: type === 'category' && data ? catFontSize : 16,
1322
1345
  fontFamily: FONT_FAMILY,
1323
1346
  ...(type === 'category' && {
1324
1347
  interval: 0,
1325
1348
  formatter: (value: string) =>
1326
- value.replace(/([a-z])([A-Z])/g, '$1\n$2').replace(/ /g, '\n'),
1349
+ value.replace(/([a-z])([A-Z])/g, '$1\n$2'),
1350
+ ...catLabelExtras,
1327
1351
  }),
1328
1352
  },
1329
1353
  splitLine: { lineStyle: { color: splitLineColor, opacity: gridOpacity } },
@@ -1343,7 +1367,8 @@ function makeGridAxis(
1343
1367
  export function buildEChartsOptionFromChart(
1344
1368
  parsed: ParsedChart,
1345
1369
  palette: PaletteColors,
1346
- isDark: boolean
1370
+ isDark: boolean,
1371
+ chartWidth?: number
1347
1372
  ): EChartsOption {
1348
1373
  if (parsed.error) return {};
1349
1374
 
@@ -1375,15 +1400,15 @@ export function buildEChartsOptionFromChart(
1375
1400
 
1376
1401
  switch (parsed.type) {
1377
1402
  case 'bar':
1378
- return buildBarOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme);
1403
+ return buildBarOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme, chartWidth);
1379
1404
  case 'bar-stacked':
1380
- return buildBarStackedOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme);
1405
+ return buildBarStackedOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme, chartWidth);
1381
1406
  case 'line':
1382
1407
  return parsed.seriesNames
1383
- ? buildMultiLineOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme)
1384
- : buildLineOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme);
1408
+ ? buildMultiLineOption(parsed, textColor, axisLineColor, splitLineColor, gridOpacity, colors, titleConfig, tooltipTheme, chartWidth)
1409
+ : buildLineOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme, chartWidth);
1385
1410
  case 'area':
1386
- return buildAreaOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme);
1411
+ return buildAreaOption(parsed, palette, textColor, axisLineColor, splitLineColor, gridOpacity, titleConfig, tooltipTheme, chartWidth);
1387
1412
  case 'pie':
1388
1413
  return buildPieOption(parsed, textColor, getSegmentColors(palette, parsed.data.length), titleConfig, tooltipTheme, false);
1389
1414
  case 'doughnut':
@@ -1405,7 +1430,8 @@ function buildBarOption(
1405
1430
  gridOpacity: number,
1406
1431
  colors: string[],
1407
1432
  titleConfig: EChartsOption['title'],
1408
- tooltipTheme: Record<string, unknown>
1433
+ tooltipTheme: Record<string, unknown>,
1434
+ chartWidth?: number
1409
1435
  ): EChartsOption {
1410
1436
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1411
1437
  const isHorizontal = parsed.orientation === 'horizontal';
@@ -1420,7 +1446,7 @@ function buildBarOption(
1420
1446
  const hCatGap = isHorizontal && yLabel
1421
1447
  ? Math.max(40, Math.max(...labels.map((l) => l.length)) * 8 + 16)
1422
1448
  : undefined;
1423
- const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap);
1449
+ const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap, !isHorizontal ? chartWidth : undefined);
1424
1450
  const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel);
1425
1451
 
1426
1452
  // xAxis is always the bottom axis, yAxis is always the left axis in ECharts
@@ -1466,7 +1492,8 @@ function buildLineOption(
1466
1492
  splitLineColor: string,
1467
1493
  gridOpacity: number,
1468
1494
  titleConfig: EChartsOption['title'],
1469
- tooltipTheme: Record<string, unknown>
1495
+ tooltipTheme: Record<string, unknown>,
1496
+ chartWidth?: number
1470
1497
  ): EChartsOption {
1471
1498
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1472
1499
  const lineColor = parsed.color ?? parsed.seriesNameColors?.[0] ?? palette.primary;
@@ -1489,7 +1516,7 @@ function buildLineOption(
1489
1516
  top: parsed.title ? '15%' : '5%',
1490
1517
  containLabel: true,
1491
1518
  },
1492
- xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels),
1519
+ xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels, undefined, chartWidth),
1493
1520
  yAxis: makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, yLabel),
1494
1521
  series: [
1495
1522
  {
@@ -1518,7 +1545,8 @@ function buildMultiLineOption(
1518
1545
  gridOpacity: number,
1519
1546
  colors: string[],
1520
1547
  titleConfig: EChartsOption['title'],
1521
- tooltipTheme: Record<string, unknown>
1548
+ tooltipTheme: Record<string, unknown>,
1549
+ chartWidth?: number
1522
1550
  ): EChartsOption {
1523
1551
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1524
1552
  const seriesNames = parsed.seriesNames ?? [];
@@ -1565,7 +1593,7 @@ function buildMultiLineOption(
1565
1593
  top: parsed.title ? '15%' : '5%',
1566
1594
  containLabel: true,
1567
1595
  },
1568
- xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels),
1596
+ xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels, undefined, chartWidth),
1569
1597
  yAxis: makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, yLabel),
1570
1598
  series,
1571
1599
  };
@@ -1581,7 +1609,8 @@ function buildAreaOption(
1581
1609
  splitLineColor: string,
1582
1610
  gridOpacity: number,
1583
1611
  titleConfig: EChartsOption['title'],
1584
- tooltipTheme: Record<string, unknown>
1612
+ tooltipTheme: Record<string, unknown>,
1613
+ chartWidth?: number
1585
1614
  ): EChartsOption {
1586
1615
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1587
1616
  const lineColor = parsed.color ?? parsed.seriesNameColors?.[0] ?? palette.primary;
@@ -1604,7 +1633,7 @@ function buildAreaOption(
1604
1633
  top: parsed.title ? '15%' : '5%',
1605
1634
  containLabel: true,
1606
1635
  },
1607
- xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels),
1636
+ xAxis: makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, xLabel, labels, undefined, chartWidth),
1608
1637
  yAxis: makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, yLabel),
1609
1638
  series: [
1610
1639
  {
@@ -1808,7 +1837,8 @@ function buildBarStackedOption(
1808
1837
  gridOpacity: number,
1809
1838
  colors: string[],
1810
1839
  titleConfig: EChartsOption['title'],
1811
- tooltipTheme: Record<string, unknown>
1840
+ tooltipTheme: Record<string, unknown>,
1841
+ chartWidth?: number
1812
1842
  ): EChartsOption {
1813
1843
  const { xLabel, yLabel } = resolveAxisLabels(parsed);
1814
1844
  const isHorizontal = parsed.orientation === 'horizontal';
@@ -1845,8 +1875,11 @@ function buildBarStackedOption(
1845
1875
  const hCatGap = isHorizontal && yLabel
1846
1876
  ? Math.max(40, Math.max(...labels.map((l) => l.length)) * 8 + 16)
1847
1877
  : undefined;
1848
- const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap);
1849
- const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel);
1878
+ const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap, !isHorizontal ? chartWidth : undefined);
1879
+ // For horizontal bars with a legend, use a smaller nameGap so the xlabel
1880
+ // stays close to the axis ticks rather than drifting toward the legend.
1881
+ const hValueGap = isHorizontal && xLabel ? 40 : undefined;
1882
+ const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel, undefined, hValueGap);
1850
1883
 
1851
1884
  return {
1852
1885
  backgroundColor: 'transparent',
@@ -1919,7 +1952,7 @@ export async function renderEChartsForExport(
1919
1952
  if (chartType && STANDARD_CHART_TYPES.has(chartType)) {
1920
1953
  const parsed = parseChart(content, effectivePalette);
1921
1954
  if (parsed.error) return '';
1922
- option = buildEChartsOptionFromChart(parsed, effectivePalette, isDark);
1955
+ option = buildEChartsOptionFromChart(parsed, effectivePalette, isDark, ECHART_EXPORT_WIDTH);
1923
1956
  } else {
1924
1957
  const parsed = parseEChart(content, effectivePalette);
1925
1958
  if (parsed.error) return '';