@opendata-ai/openchart-engine 6.7.0 → 6.8.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
@@ -303,6 +303,8 @@ interface NormalizedSankeySpec {
303
303
  theme: ThemeConfig;
304
304
  darkMode: DarkMode;
305
305
  animation?: AnimationSpec;
306
+ valueFormat?: string;
307
+ linkOpacity?: number;
306
308
  }
307
309
 
308
310
  /**
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) {
@@ -6028,7 +6115,9 @@ function normalizeSankeySpec(spec, _warnings) {
6028
6115
  legend: spec.legend,
6029
6116
  theme: spec.theme ?? {},
6030
6117
  darkMode: spec.darkMode ?? "off",
6031
- animation: spec.animation
6118
+ animation: spec.animation,
6119
+ valueFormat: spec.valueFormat,
6120
+ linkOpacity: spec.linkOpacity
6032
6121
  };
6033
6122
  }
6034
6123
  function normalizeGraphSpec(spec, _warnings) {
@@ -8270,7 +8359,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
8270
8359
  entries = truncateEntries(entries, limit);
8271
8360
  }
8272
8361
  }
8273
- const maxRows = spec.legend?.columns != null ? Math.ceil(entries.length / spec.legend.columns) : TOP_LEGEND_MAX_ROWS;
8362
+ 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
8363
  const maxFit = entriesThatFit(entries, availableWidth, maxRows, labelStyle);
8275
8364
  if (maxFit < entries.length) {
8276
8365
  entries = truncateEntries(entries, maxFit);
@@ -8314,6 +8403,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
8314
8403
  // src/sankey/compile-sankey.ts
8315
8404
  import {
8316
8405
  adaptTheme as adaptTheme2,
8406
+ buildD3Formatter as buildD3Formatter4,
8317
8407
  computeChrome as computeChrome3,
8318
8408
  estimateTextWidth as estimateTextWidth10,
8319
8409
  formatNumber as formatNumber4,
@@ -8789,7 +8879,8 @@ var SWATCH_SIZE3 = 12;
8789
8879
  var SWATCH_GAP3 = 6;
8790
8880
  var ENTRY_GAP3 = 16;
8791
8881
  var LABEL_GAP = 6;
8792
- var LINK_OPACITY = 0.35;
8882
+ var LINK_OPACITY_LIGHT = 0.5;
8883
+ var LINK_OPACITY_DARK = 0.75;
8793
8884
  var NODE_CORNER_RADIUS = 2;
8794
8885
  function pickColor(palette, index) {
8795
8886
  return palette[index % palette.length];
@@ -8874,9 +8965,14 @@ function compileSankey(spec, options) {
8874
8965
  }
8875
8966
  const sankeySpec = normalized;
8876
8967
  const mergedThemeConfig = options.theme ? { ...sankeySpec.theme, ...options.theme } : sankeySpec.theme;
8877
- let theme = resolveTheme2(mergedThemeConfig);
8968
+ const lightTheme = resolveTheme2(mergedThemeConfig);
8969
+ let theme = lightTheme;
8878
8970
  if (options.darkMode) {
8879
8971
  theme = adaptTheme2(theme);
8972
+ theme = {
8973
+ ...theme,
8974
+ colors: { ...theme.colors, categorical: lightTheme.colors.categorical }
8975
+ };
8880
8976
  }
8881
8977
  const chrome = computeChrome3(
8882
8978
  {
@@ -8936,17 +9032,53 @@ function compileSankey(spec, options) {
8936
9032
  if (area.height <= 0) {
8937
9033
  return emptyLayout(area, chrome, theme, options);
8938
9034
  }
8939
- const { nodes, links } = computeSankeyLayout(
9035
+ const labelFontSize = theme.fonts.sizes.small;
9036
+ const labelFontWeight = theme.fonts.weights.normal;
9037
+ const nodeWidth = sankeySpec.nodeWidth ?? 12;
9038
+ let layoutArea = { ...area };
9039
+ let { nodes, links } = computeSankeyLayout(
8940
9040
  sankeySpec.data,
8941
9041
  sourceField,
8942
9042
  targetField,
8943
9043
  valueField,
8944
- area,
9044
+ layoutArea,
8945
9045
  sankeySpec.nodeWidth,
8946
9046
  sankeySpec.nodePadding,
8947
9047
  sankeySpec.nodeAlign,
8948
9048
  sankeySpec.iterations
8949
9049
  );
9050
+ const maxDepthFirst = nodes.reduce((max4, n) => Math.max(max4, n.depth ?? 0), 0);
9051
+ const rightEdge = area.x + area.width;
9052
+ let maxOverflow = 0;
9053
+ for (const node of nodes) {
9054
+ const depth = node.depth ?? 0;
9055
+ if (depth === maxDepthFirst) continue;
9056
+ const labelX = (node.x1 ?? nodeWidth) + LABEL_GAP;
9057
+ const labelText = node.label ?? node.id;
9058
+ const labelWidth = estimateTextWidth10(labelText, labelFontSize, labelFontWeight);
9059
+ const overflow = labelX + labelWidth - rightEdge;
9060
+ if (overflow > maxOverflow) maxOverflow = overflow;
9061
+ }
9062
+ if (maxOverflow > 0) {
9063
+ const margin = Math.ceil(maxOverflow) + 4;
9064
+ layoutArea = {
9065
+ x: area.x,
9066
+ y: area.y,
9067
+ width: Math.max(area.width - margin, 40),
9068
+ height: area.height
9069
+ };
9070
+ ({ nodes, links } = computeSankeyLayout(
9071
+ sankeySpec.data,
9072
+ sourceField,
9073
+ targetField,
9074
+ valueField,
9075
+ layoutArea,
9076
+ sankeySpec.nodeWidth,
9077
+ sankeySpec.nodePadding,
9078
+ sankeySpec.nodeAlign,
9079
+ sankeySpec.iterations
9080
+ ));
9081
+ }
8950
9082
  const nodeColorMap = buildNodeColorMap(
8951
9083
  nodes,
8952
9084
  theme.colors.categorical,
@@ -8974,7 +9106,7 @@ function compileSankey(spec, options) {
8974
9106
  data: { id: node.id, label: node.label },
8975
9107
  aria: {
8976
9108
  role: "img",
8977
- label: `${node.label}: ${formatNumber4(node.value ?? 0)}`
9109
+ label: `${node.label}: ${formatFlowValue(node.value ?? 0, sankeySpec.valueFormat)}`
8978
9110
  },
8979
9111
  animationIndex: 0
8980
9112
  // Reassigned below after sorting by depth
@@ -8996,7 +9128,7 @@ function compileSankey(spec, options) {
8996
9128
  path: generateLinkPath(link),
8997
9129
  sourceColor: colors.sourceColor,
8998
9130
  targetColor: colors.targetColor,
8999
- fillOpacity: LINK_OPACITY,
9131
+ fillOpacity: sankeySpec.linkOpacity ?? (options.darkMode ? LINK_OPACITY_DARK : LINK_OPACITY_LIGHT),
9000
9132
  sourceId: sourceNode.id,
9001
9133
  targetId: targetNode.id,
9002
9134
  width: link.width ?? 0,
@@ -9004,7 +9136,7 @@ function compileSankey(spec, options) {
9004
9136
  data: link.data ?? {},
9005
9137
  aria: {
9006
9138
  role: "img",
9007
- label: `${sourceNode.label} to ${targetNode.label}: ${formatNumber4(link.value)}`
9139
+ label: `${sourceNode.label} to ${targetNode.label}: ${formatFlowValue(link.value, sankeySpec.valueFormat)}`
9008
9140
  },
9009
9141
  // Links animate after nodes
9010
9142
  animationIndex: nodeMarks.length + i
@@ -9019,7 +9151,7 @@ function compileSankey(spec, options) {
9019
9151
  theme,
9020
9152
  fullArea
9021
9153
  );
9022
- const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks);
9154
+ const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks, sankeySpec.valueFormat);
9023
9155
  const a11y = {
9024
9156
  altText: `Sankey diagram with ${nodeMarks.length} nodes and ${linkMarks.length} links`,
9025
9157
  dataTableFallback: linkMarks.map((l) => [l.sourceId, l.targetId, String(l.value)]),
@@ -9111,13 +9243,20 @@ function buildSankeyLegend(nodeColorMap, colorField, data, sourceField, targetFi
9111
9243
  entryGap: ENTRY_GAP3
9112
9244
  };
9113
9245
  }
9114
- function buildTooltipDescriptors(nodes, links) {
9246
+ function formatFlowValue(value2, valueFormat) {
9247
+ if (valueFormat) {
9248
+ const fmt = buildD3Formatter4(valueFormat);
9249
+ if (fmt) return fmt(value2);
9250
+ }
9251
+ return formatNumber4(value2);
9252
+ }
9253
+ function buildTooltipDescriptors(nodes, links, valueFormat) {
9115
9254
  const descriptors = /* @__PURE__ */ new Map();
9116
9255
  for (const node of nodes) {
9117
9256
  const fields = [
9118
9257
  {
9119
9258
  label: "Total flow",
9120
- value: formatNumber4(node.value)
9259
+ value: formatFlowValue(node.value, valueFormat)
9121
9260
  }
9122
9261
  ];
9123
9262
  descriptors.set(`node-${node.nodeId}`, {
@@ -9125,14 +9264,15 @@ function buildTooltipDescriptors(nodes, links) {
9125
9264
  fields
9126
9265
  });
9127
9266
  }
9128
- for (const link of links) {
9267
+ for (let i = 0; i < links.length; i++) {
9268
+ const link = links[i];
9129
9269
  const fields = [
9130
9270
  {
9131
9271
  label: "Flow",
9132
- value: formatNumber4(link.value)
9272
+ value: formatFlowValue(link.value, valueFormat)
9133
9273
  }
9134
9274
  ];
9135
- descriptors.set(`link-${link.sourceId}-${link.targetId}`, {
9275
+ descriptors.set(`link-${link.sourceId}-${link.targetId}-${i}`, {
9136
9276
  title: `${link.sourceId} \u2192 ${link.targetId}`,
9137
9277
  fields
9138
9278
  });
@@ -9284,7 +9424,7 @@ function computeCategoryColors(data, column, theme, darkMode) {
9284
9424
  }
9285
9425
 
9286
9426
  // src/tables/format-cells.ts
9287
- import { formatDate as formatDate2, formatNumber as formatNumber5 } from "@opendata-ai/openchart-core";
9427
+ import { buildD3Formatter as buildD3Formatter5, formatDate as formatDate2, formatNumber as formatNumber5 } from "@opendata-ai/openchart-core";
9288
9428
  function isNumericValue(value2) {
9289
9429
  if (typeof value2 === "number") return Number.isFinite(value2);
9290
9430
  return false;
@@ -9303,14 +9443,13 @@ function formatCell(value2, column) {
9303
9443
  };
9304
9444
  }
9305
9445
  if (column.format && isNumericValue(value2)) {
9306
- try {
9307
- const formatter = format(column.format);
9446
+ const formatter = buildD3Formatter5(column.format);
9447
+ if (formatter) {
9308
9448
  return {
9309
9449
  value: value2,
9310
9450
  formattedValue: formatter(value2),
9311
9451
  style
9312
9452
  };
9313
- } catch {
9314
9453
  }
9315
9454
  }
9316
9455
  if (isNumericValue(value2)) {
@@ -9336,9 +9475,9 @@ function formatCell(value2, column) {
9336
9475
  function formatValueForSearch(value2, column) {
9337
9476
  if (value2 == null) return "";
9338
9477
  if (column.format && isNumericValue(value2)) {
9339
- try {
9340
- return format(column.format)(value2);
9341
- } catch {
9478
+ const formatter = buildD3Formatter5(column.format);
9479
+ if (formatter) {
9480
+ return formatter(value2);
9342
9481
  }
9343
9482
  }
9344
9483
  if (isNumericValue(value2)) {