@opendata-ai/openchart-engine 6.7.1 → 6.9.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/dist/index.d.ts CHANGED
@@ -298,11 +298,14 @@ interface NormalizedSankeySpec {
298
298
  nodeAlign: SankeyNodeAlign;
299
299
  iterations: number;
300
300
  linkStyle: SankeyLinkColor;
301
+ nodeLabelAlign: 'auto' | 'left' | 'right';
301
302
  chrome: NormalizedChrome;
302
303
  legend?: LegendConfig;
303
304
  theme: ThemeConfig;
304
305
  darkMode: DarkMode;
305
306
  animation?: AnimationSpec;
307
+ valueFormat?: string;
308
+ linkOpacity?: number;
306
309
  }
307
310
 
308
311
  /**
package/dist/index.js CHANGED
@@ -296,19 +296,61 @@ function resolveRefLineAnnotation(annotation, scales, chartArea, isDark) {
296
296
  let label;
297
297
  if (annotation.label) {
298
298
  const isHorizontal = annotation.y !== void 0;
299
- const anchor = annotation.labelAnchor ?? "top";
300
- const baseDx = isHorizontal ? -4 : 4;
301
- const baseDy = anchor === "bottom" ? 14 : -4;
299
+ const anchor = annotation.labelAnchor ?? (isHorizontal ? "top" : "left");
300
+ let baseDx;
301
+ let baseDy;
302
+ let labelX;
303
+ let labelY;
304
+ let textAnchor;
305
+ if (isHorizontal) {
306
+ if (anchor === "left") {
307
+ baseDx = 4;
308
+ baseDy = -4;
309
+ labelX = start.x;
310
+ labelY = start.y;
311
+ textAnchor = "start";
312
+ } else if (anchor === "bottom") {
313
+ baseDx = -4;
314
+ baseDy = 14;
315
+ labelX = end.x;
316
+ labelY = end.y;
317
+ textAnchor = "end";
318
+ } else {
319
+ baseDx = -4;
320
+ baseDy = -4;
321
+ labelX = end.x;
322
+ labelY = end.y;
323
+ textAnchor = "end";
324
+ }
325
+ } else {
326
+ if (anchor === "right") {
327
+ baseDx = -4;
328
+ baseDy = 14;
329
+ labelX = start.x;
330
+ labelY = start.y;
331
+ textAnchor = "end";
332
+ } else if (anchor === "bottom") {
333
+ baseDx = 4;
334
+ baseDy = -4;
335
+ labelX = start.x;
336
+ labelY = end.y;
337
+ textAnchor = "start";
338
+ } else {
339
+ baseDx = 4;
340
+ baseDy = 14;
341
+ labelX = start.x;
342
+ labelY = start.y;
343
+ textAnchor = "start";
344
+ }
345
+ }
302
346
  const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
303
347
  const defaultStroke2 = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
304
348
  const style = makeAnnotationLabelStyle(11, 400, annotation.stroke ?? defaultStroke2, isDark);
305
- if (isHorizontal) {
306
- style.textAnchor = "end";
307
- }
349
+ style.textAnchor = textAnchor;
308
350
  label = {
309
351
  text: annotation.label,
310
- x: (isHorizontal ? end.x : start.x) + labelDelta.dx,
311
- y: (isHorizontal ? end.y : start.y) + labelDelta.dy,
352
+ x: labelX + labelDelta.dx,
353
+ y: labelY + labelDelta.dy,
312
354
  style,
313
355
  visible: true
314
356
  };
@@ -729,7 +771,22 @@ function computeBarMarks(spec, scales, _chartArea, _strategy) {
729
771
  conditionalColor
730
772
  );
731
773
  }
732
- return computeStackedBars(
774
+ const categoryGroups = groupByField(spec.data, yChannel.field);
775
+ const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
776
+ if (needsStacking) {
777
+ return computeStackedBars(
778
+ spec.data,
779
+ xChannel.field,
780
+ yChannel.field,
781
+ colorField,
782
+ xScale,
783
+ yScale,
784
+ bandwidth,
785
+ baseline,
786
+ scales
787
+ );
788
+ }
789
+ return computeColoredBars(
733
790
  spec.data,
734
791
  xChannel.field,
735
792
  yChannel.field,
@@ -777,6 +834,36 @@ function computeStackedBars(data, valueField, categoryField, colorField, xScale,
777
834
  }
778
835
  return marks;
779
836
  }
837
+ function computeColoredBars(data, valueField, categoryField, colorField, xScale, yScale, bandwidth, baseline, scales) {
838
+ const marks = [];
839
+ for (const row of data) {
840
+ const category = String(row[categoryField] ?? "");
841
+ const value2 = Number(row[valueField] ?? 0);
842
+ if (!Number.isFinite(value2)) continue;
843
+ const bandY = yScale(category);
844
+ if (bandY === void 0) continue;
845
+ const groupKey = String(row[colorField] ?? "");
846
+ const color2 = getColor(scales, groupKey);
847
+ const xPos = value2 >= 0 ? baseline : xScale(value2);
848
+ const barWidth = Math.max(Math.abs(xScale(value2) - baseline), MIN_BAR_WIDTH);
849
+ const aria = {
850
+ label: `${category}, ${groupKey}: ${formatBarValue(value2)}`
851
+ };
852
+ marks.push({
853
+ type: "rect",
854
+ x: xPos,
855
+ y: bandY,
856
+ width: barWidth,
857
+ height: bandwidth,
858
+ fill: color2,
859
+ cornerRadius: 2,
860
+ data: row,
861
+ aria,
862
+ orient: "horizontal"
863
+ });
864
+ }
865
+ return marks;
866
+ }
780
867
  function computeSimpleBars(data, valueField, categoryField, xScale, yScale, bandwidth, baseline, scales, sequentialColor = false, conditionalColor) {
781
868
  const marks = [];
782
869
  for (const row of data) {
@@ -6024,11 +6111,14 @@ function normalizeSankeySpec(spec, _warnings) {
6024
6111
  nodeAlign: spec.nodeAlign ?? "justify",
6025
6112
  iterations: spec.iterations ?? 6,
6026
6113
  linkStyle: spec.linkStyle ?? "gradient",
6114
+ nodeLabelAlign: spec.nodeLabelAlign ?? "auto",
6027
6115
  chrome: normalizeChrome(spec.chrome),
6028
6116
  legend: spec.legend,
6029
6117
  theme: spec.theme ?? {},
6030
6118
  darkMode: spec.darkMode ?? "off",
6031
- animation: spec.animation
6119
+ animation: spec.animation,
6120
+ valueFormat: spec.valueFormat,
6121
+ linkOpacity: spec.linkOpacity
6032
6122
  };
6033
6123
  }
6034
6124
  function normalizeGraphSpec(spec, _warnings) {
@@ -8270,7 +8360,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
8270
8360
  entries = truncateEntries(entries, limit);
8271
8361
  }
8272
8362
  }
8273
- const maxRows = spec.legend?.columns != null ? Math.ceil(entries.length / spec.legend.columns) : TOP_LEGEND_MAX_ROWS;
8363
+ const maxRows = spec.legend?.maxRows != null ? Math.max(1, spec.legend.maxRows) : spec.legend?.columns != null ? Math.ceil(entries.length / spec.legend.columns) : TOP_LEGEND_MAX_ROWS;
8274
8364
  const maxFit = entriesThatFit(entries, availableWidth, maxRows, labelStyle);
8275
8365
  if (maxFit < entries.length) {
8276
8366
  entries = truncateEntries(entries, maxFit);
@@ -8314,6 +8404,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
8314
8404
  // src/sankey/compile-sankey.ts
8315
8405
  import {
8316
8406
  adaptTheme as adaptTheme2,
8407
+ buildD3Formatter as buildD3Formatter4,
8317
8408
  computeChrome as computeChrome3,
8318
8409
  estimateTextWidth as estimateTextWidth10,
8319
8410
  formatNumber as formatNumber4,
@@ -8789,7 +8880,8 @@ var SWATCH_SIZE3 = 12;
8789
8880
  var SWATCH_GAP3 = 6;
8790
8881
  var ENTRY_GAP3 = 16;
8791
8882
  var LABEL_GAP = 6;
8792
- var LINK_OPACITY = 0.35;
8883
+ var LINK_OPACITY_LIGHT = 0.5;
8884
+ var LINK_OPACITY_DARK = 0.75;
8793
8885
  var NODE_CORNER_RADIUS = 2;
8794
8886
  function pickColor(palette, index) {
8795
8887
  return palette[index % palette.length];
@@ -8833,9 +8925,16 @@ function getLinkColors(linkStyle, sourceColor, targetColor, neutralColor) {
8833
8925
  return { sourceColor, targetColor };
8834
8926
  }
8835
8927
  }
8836
- function computeNodeLabel(node, maxDepth, theme, nodeWidth) {
8928
+ function computeNodeLabel(node, maxDepth, theme, nodeWidth, nodeLabelAlign = "auto") {
8837
8929
  const depth = node.depth ?? 0;
8838
- const isRightmost = depth === maxDepth;
8930
+ let placeLeft;
8931
+ if (nodeLabelAlign === "left") {
8932
+ placeLeft = true;
8933
+ } else if (nodeLabelAlign === "right") {
8934
+ placeLeft = false;
8935
+ } else {
8936
+ placeLeft = depth === maxDepth;
8937
+ }
8839
8938
  const style = {
8840
8939
  fontFamily: theme.fonts.family,
8841
8940
  fontSize: theme.fonts.sizes.small,
@@ -8848,7 +8947,7 @@ function computeNodeLabel(node, maxDepth, theme, nodeWidth) {
8848
8947
  const y0 = node.y0 ?? 0;
8849
8948
  const y1 = node.y1 ?? 0;
8850
8949
  const midY = (y0 + y1) / 2;
8851
- if (isRightmost) {
8950
+ if (placeLeft) {
8852
8951
  return {
8853
8952
  text: node.label ?? node.id,
8854
8953
  x: x0 - LABEL_GAP,
@@ -8874,9 +8973,14 @@ function compileSankey(spec, options) {
8874
8973
  }
8875
8974
  const sankeySpec = normalized;
8876
8975
  const mergedThemeConfig = options.theme ? { ...sankeySpec.theme, ...options.theme } : sankeySpec.theme;
8877
- let theme = resolveTheme2(mergedThemeConfig);
8976
+ const lightTheme = resolveTheme2(mergedThemeConfig);
8977
+ let theme = lightTheme;
8878
8978
  if (options.darkMode) {
8879
8979
  theme = adaptTheme2(theme);
8980
+ theme = {
8981
+ ...theme,
8982
+ colors: { ...theme.colors, categorical: lightTheme.colors.categorical }
8983
+ };
8880
8984
  }
8881
8985
  const chrome = computeChrome3(
8882
8986
  {
@@ -8936,17 +9040,55 @@ function compileSankey(spec, options) {
8936
9040
  if (area.height <= 0) {
8937
9041
  return emptyLayout(area, chrome, theme, options);
8938
9042
  }
8939
- const { nodes, links } = computeSankeyLayout(
9043
+ const labelFontSize = theme.fonts.sizes.small;
9044
+ const labelFontWeight = theme.fonts.weights.normal;
9045
+ const nodeWidth = sankeySpec.nodeWidth ?? 12;
9046
+ let layoutArea = { ...area };
9047
+ let { nodes, links } = computeSankeyLayout(
8940
9048
  sankeySpec.data,
8941
9049
  sourceField,
8942
9050
  targetField,
8943
9051
  valueField,
8944
- area,
9052
+ layoutArea,
8945
9053
  sankeySpec.nodeWidth,
8946
9054
  sankeySpec.nodePadding,
8947
9055
  sankeySpec.nodeAlign,
8948
9056
  sankeySpec.iterations
8949
9057
  );
9058
+ const nodeLabelAlign = sankeySpec.nodeLabelAlign ?? "auto";
9059
+ const maxDepthFirst = nodes.reduce((max4, n) => Math.max(max4, n.depth ?? 0), 0);
9060
+ const rightEdge = area.x + area.width;
9061
+ let maxOverflow = 0;
9062
+ for (const node of nodes) {
9063
+ const depth = node.depth ?? 0;
9064
+ const labelsLeft = nodeLabelAlign === "left" || nodeLabelAlign === "auto" && depth === maxDepthFirst;
9065
+ if (labelsLeft) continue;
9066
+ const labelX = (node.x1 ?? nodeWidth) + LABEL_GAP;
9067
+ const labelText = node.label ?? node.id;
9068
+ const labelWidth = estimateTextWidth10(labelText, labelFontSize, labelFontWeight);
9069
+ const overflow = labelX + labelWidth - rightEdge;
9070
+ if (overflow > maxOverflow) maxOverflow = overflow;
9071
+ }
9072
+ if (maxOverflow > 0) {
9073
+ const margin = Math.ceil(maxOverflow) + 4;
9074
+ layoutArea = {
9075
+ x: area.x,
9076
+ y: area.y,
9077
+ width: Math.max(area.width - margin, 40),
9078
+ height: area.height
9079
+ };
9080
+ ({ nodes, links } = computeSankeyLayout(
9081
+ sankeySpec.data,
9082
+ sourceField,
9083
+ targetField,
9084
+ valueField,
9085
+ layoutArea,
9086
+ sankeySpec.nodeWidth,
9087
+ sankeySpec.nodePadding,
9088
+ sankeySpec.nodeAlign,
9089
+ sankeySpec.iterations
9090
+ ));
9091
+ }
8950
9092
  const nodeColorMap = buildNodeColorMap(
8951
9093
  nodes,
8952
9094
  theme.colors.categorical,
@@ -8967,14 +9109,14 @@ function compileSankey(spec, options) {
8967
9109
  height: (node.y1 ?? 0) - (node.y0 ?? 0),
8968
9110
  fill,
8969
9111
  cornerRadius: NODE_CORNER_RADIUS,
8970
- label: computeNodeLabel(node, maxDepth, theme, sankeySpec.nodeWidth),
9112
+ label: computeNodeLabel(node, maxDepth, theme, sankeySpec.nodeWidth, nodeLabelAlign),
8971
9113
  nodeId: node.id,
8972
9114
  value: node.value ?? 0,
8973
9115
  depth,
8974
9116
  data: { id: node.id, label: node.label },
8975
9117
  aria: {
8976
9118
  role: "img",
8977
- label: `${node.label}: ${formatNumber4(node.value ?? 0)}`
9119
+ label: `${node.label}: ${formatFlowValue(node.value ?? 0, sankeySpec.valueFormat)}`
8978
9120
  },
8979
9121
  animationIndex: 0
8980
9122
  // Reassigned below after sorting by depth
@@ -8996,7 +9138,7 @@ function compileSankey(spec, options) {
8996
9138
  path: generateLinkPath(link),
8997
9139
  sourceColor: colors.sourceColor,
8998
9140
  targetColor: colors.targetColor,
8999
- fillOpacity: LINK_OPACITY,
9141
+ fillOpacity: sankeySpec.linkOpacity ?? (options.darkMode ? LINK_OPACITY_DARK : LINK_OPACITY_LIGHT),
9000
9142
  sourceId: sourceNode.id,
9001
9143
  targetId: targetNode.id,
9002
9144
  width: link.width ?? 0,
@@ -9004,7 +9146,7 @@ function compileSankey(spec, options) {
9004
9146
  data: link.data ?? {},
9005
9147
  aria: {
9006
9148
  role: "img",
9007
- label: `${sourceNode.label} to ${targetNode.label}: ${formatNumber4(link.value)}`
9149
+ label: `${sourceNode.label} to ${targetNode.label}: ${formatFlowValue(link.value, sankeySpec.valueFormat)}`
9008
9150
  },
9009
9151
  // Links animate after nodes
9010
9152
  animationIndex: nodeMarks.length + i
@@ -9019,7 +9161,7 @@ function compileSankey(spec, options) {
9019
9161
  theme,
9020
9162
  fullArea
9021
9163
  );
9022
- const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks);
9164
+ const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks, sankeySpec.valueFormat);
9023
9165
  const a11y = {
9024
9166
  altText: `Sankey diagram with ${nodeMarks.length} nodes and ${linkMarks.length} links`,
9025
9167
  dataTableFallback: linkMarks.map((l) => [l.sourceId, l.targetId, String(l.value)]),
@@ -9111,13 +9253,20 @@ function buildSankeyLegend(nodeColorMap, colorField, data, sourceField, targetFi
9111
9253
  entryGap: ENTRY_GAP3
9112
9254
  };
9113
9255
  }
9114
- function buildTooltipDescriptors(nodes, links) {
9256
+ function formatFlowValue(value2, valueFormat) {
9257
+ if (valueFormat) {
9258
+ const fmt = buildD3Formatter4(valueFormat);
9259
+ if (fmt) return fmt(value2);
9260
+ }
9261
+ return formatNumber4(value2);
9262
+ }
9263
+ function buildTooltipDescriptors(nodes, links, valueFormat) {
9115
9264
  const descriptors = /* @__PURE__ */ new Map();
9116
9265
  for (const node of nodes) {
9117
9266
  const fields = [
9118
9267
  {
9119
9268
  label: "Total flow",
9120
- value: formatNumber4(node.value)
9269
+ value: formatFlowValue(node.value, valueFormat)
9121
9270
  }
9122
9271
  ];
9123
9272
  descriptors.set(`node-${node.nodeId}`, {
@@ -9125,14 +9274,15 @@ function buildTooltipDescriptors(nodes, links) {
9125
9274
  fields
9126
9275
  });
9127
9276
  }
9128
- for (const link of links) {
9277
+ for (let i = 0; i < links.length; i++) {
9278
+ const link = links[i];
9129
9279
  const fields = [
9130
9280
  {
9131
9281
  label: "Flow",
9132
- value: formatNumber4(link.value)
9282
+ value: formatFlowValue(link.value, valueFormat)
9133
9283
  }
9134
9284
  ];
9135
- descriptors.set(`link-${link.sourceId}-${link.targetId}`, {
9285
+ descriptors.set(`link-${link.sourceId}-${link.targetId}-${i}`, {
9136
9286
  title: `${link.sourceId} \u2192 ${link.targetId}`,
9137
9287
  fields
9138
9288
  });
@@ -9284,7 +9434,7 @@ function computeCategoryColors(data, column, theme, darkMode) {
9284
9434
  }
9285
9435
 
9286
9436
  // src/tables/format-cells.ts
9287
- import { formatDate as formatDate2, formatNumber as formatNumber5 } from "@opendata-ai/openchart-core";
9437
+ import { buildD3Formatter as buildD3Formatter5, formatDate as formatDate2, formatNumber as formatNumber5 } from "@opendata-ai/openchart-core";
9288
9438
  function isNumericValue(value2) {
9289
9439
  if (typeof value2 === "number") return Number.isFinite(value2);
9290
9440
  return false;
@@ -9303,14 +9453,13 @@ function formatCell(value2, column) {
9303
9453
  };
9304
9454
  }
9305
9455
  if (column.format && isNumericValue(value2)) {
9306
- try {
9307
- const formatter = format(column.format);
9456
+ const formatter = buildD3Formatter5(column.format);
9457
+ if (formatter) {
9308
9458
  return {
9309
9459
  value: value2,
9310
9460
  formattedValue: formatter(value2),
9311
9461
  style
9312
9462
  };
9313
- } catch {
9314
9463
  }
9315
9464
  }
9316
9465
  if (isNumericValue(value2)) {
@@ -9336,9 +9485,9 @@ function formatCell(value2, column) {
9336
9485
  function formatValueForSearch(value2, column) {
9337
9486
  if (value2 == null) return "";
9338
9487
  if (column.format && isNumericValue(value2)) {
9339
- try {
9340
- return format(column.format)(value2);
9341
- } catch {
9488
+ const formatter = buildD3Formatter5(column.format);
9489
+ if (formatter) {
9490
+ return formatter(value2);
9342
9491
  }
9343
9492
  }
9344
9493
  if (isNumericValue(value2)) {