@opendata-ai/openchart-engine 6.2.1 → 6.3.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.js CHANGED
@@ -388,7 +388,7 @@ function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, ch
388
388
  if (stillCollides) continue;
389
389
  const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
390
390
  const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
391
- const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 100 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
391
+ const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 10 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
392
392
  if (inBounds) {
393
393
  if (candidateLabel.connector && dx === 0 && dy !== 0) {
394
394
  candidateLabel.connector = {
@@ -436,7 +436,7 @@ function resolveAnnotationCollisions(annotations, originalSpecs, scales, chartAr
436
436
  if (stillCollides) continue;
437
437
  const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
438
438
  const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
439
- const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 100 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize;
439
+ const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 10 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize;
440
440
  if (inBounds) {
441
441
  let newConnector = annotation.label.connector;
442
442
  if (newConnector) {
@@ -470,7 +470,54 @@ function resolveAnnotationCollisions(annotations, originalSpecs, scales, chartAr
470
470
  placedBounds.push(estimateLabelBounds(annotation.label));
471
471
  }
472
472
  }
473
- function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, obstacles = []) {
473
+ var CLAMP_MARGIN = 4;
474
+ function clampAnnotationsToBounds(annotations, svgWidth, svgHeight) {
475
+ for (const annotation of annotations) {
476
+ if (annotation.type !== "text" || !annotation.label) continue;
477
+ const bounds = estimateLabelBounds(annotation.label);
478
+ let dx = 0;
479
+ let dy = 0;
480
+ if (bounds.x + bounds.width > svgWidth - CLAMP_MARGIN) {
481
+ dx = svgWidth - CLAMP_MARGIN - (bounds.x + bounds.width);
482
+ }
483
+ if (bounds.x + dx < CLAMP_MARGIN) {
484
+ dx = CLAMP_MARGIN - bounds.x;
485
+ }
486
+ if (bounds.y < CLAMP_MARGIN) {
487
+ dy = CLAMP_MARGIN - bounds.y;
488
+ }
489
+ if (bounds.y + bounds.height + dy > svgHeight - CLAMP_MARGIN) {
490
+ dy = svgHeight - CLAMP_MARGIN - (bounds.y + bounds.height);
491
+ }
492
+ if (dx === 0 && dy === 0) continue;
493
+ const newX = annotation.label.x + dx;
494
+ const newY = annotation.label.y + dy;
495
+ let newConnector = annotation.label.connector;
496
+ if (newConnector) {
497
+ const fontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
498
+ const fontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
499
+ const connStyle = newConnector.style === "curve" ? "curve" : "straight";
500
+ const newFrom = computeConnectorOrigin(
501
+ newX,
502
+ newY,
503
+ annotation.label.text,
504
+ fontSize,
505
+ fontWeight,
506
+ newConnector.to.x,
507
+ newConnector.to.y,
508
+ connStyle
509
+ );
510
+ newConnector = { ...newConnector, from: newFrom };
511
+ }
512
+ annotation.label = {
513
+ ...annotation.label,
514
+ x: newX,
515
+ y: newY,
516
+ connector: newConnector
517
+ };
518
+ }
519
+ }
520
+ function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, obstacles = [], svgDimensions) {
474
521
  if (strategy.annotationPosition === "tooltip-only") {
475
522
  return [];
476
523
  }
@@ -496,6 +543,9 @@ function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, o
496
543
  }
497
544
  }
498
545
  resolveAnnotationCollisions(annotations, spec.annotations, scales, chartArea);
546
+ if (svgDimensions) {
547
+ clampAnnotationsToBounds(annotations, svgDimensions.width, svgDimensions.height);
548
+ }
499
549
  annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
500
550
  return annotations;
501
551
  }
@@ -772,6 +822,24 @@ import {
772
822
  estimateTextWidth as estimateTextWidth2,
773
823
  resolveCollisions
774
824
  } from "@opendata-ai/openchart-core";
825
+ var SUFFIX_MULTIPLIERS = {
826
+ K: 1e3,
827
+ M: 1e6,
828
+ B: 1e9,
829
+ T: 1e12
830
+ };
831
+ function parseDisplayNumber(raw) {
832
+ const trimmed = raw.trim();
833
+ if (!trimmed) return NaN;
834
+ const last = trimmed[trimmed.length - 1].toUpperCase();
835
+ const multiplier = SUFFIX_MULTIPLIERS[last];
836
+ if (multiplier) {
837
+ const numPart = trimmed.slice(0, -1).replace(/,/g, "");
838
+ const n = Number(numPart);
839
+ return Number.isNaN(n) ? NaN : n * multiplier;
840
+ }
841
+ return Number(trimmed.replace(/,/g, ""));
842
+ }
775
843
  var LABEL_FONT_SIZE = 11;
776
844
  var LABEL_FONT_WEIGHT = 600;
777
845
  var LABEL_PADDING = 6;
@@ -780,6 +848,7 @@ function computeBarLabels(marks, _chartArea, density = "auto", labelFormat) {
780
848
  if (density === "none") return [];
781
849
  const targetMarks = density === "endpoints" && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
782
850
  const candidates = [];
851
+ const fitsInSegment = [];
783
852
  const formatter = buildD3Formatter(labelFormat);
784
853
  for (const mark of targetMarks) {
785
854
  const ariaLabel = mark.aria.label;
@@ -788,7 +857,7 @@ function computeBarLabels(marks, _chartArea, density = "auto", labelFormat) {
788
857
  if (!rawValue) continue;
789
858
  let valuePart = rawValue;
790
859
  if (formatter) {
791
- const num = Number(rawValue.replace(/[^0-9.-]/g, ""));
860
+ const num = parseDisplayNumber(rawValue);
792
861
  if (!Number.isNaN(num)) valuePart = formatter(num);
793
862
  }
794
863
  const textWidth = estimateTextWidth2(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
@@ -812,6 +881,8 @@ function computeBarLabels(marks, _chartArea, density = "auto", labelFormat) {
812
881
  textAnchor = "start";
813
882
  }
814
883
  const anchorY = mark.y + mark.height / 2;
884
+ const fits = !(isStacked && textWidth > mark.width - 2 * LABEL_PADDING);
885
+ fitsInSegment.push(fits);
815
886
  candidates.push({
816
887
  text: valuePart,
817
888
  anchorX,
@@ -832,15 +903,40 @@ function computeBarLabels(marks, _chartArea, density = "auto", labelFormat) {
832
903
  }
833
904
  if (candidates.length === 0) return [];
834
905
  if (density === "all") {
835
- return candidates.map((c) => ({
906
+ return candidates.map((c, i) => ({
836
907
  text: c.text,
837
908
  x: c.anchorX,
838
909
  y: c.anchorY,
839
910
  style: c.style,
840
- visible: true
911
+ visible: fitsInSegment[i] !== false
841
912
  }));
842
913
  }
843
- return resolveCollisions(candidates);
914
+ const fittingCandidates = [];
915
+ const unfittingIndices = /* @__PURE__ */ new Set();
916
+ for (let i = 0; i < candidates.length; i++) {
917
+ if (fitsInSegment[i] === false) {
918
+ unfittingIndices.add(i);
919
+ } else {
920
+ fittingCandidates.push(candidates[i]);
921
+ }
922
+ }
923
+ const resolved = resolveCollisions(fittingCandidates);
924
+ const results = [];
925
+ let resolvedIdx = 0;
926
+ for (let i = 0; i < candidates.length; i++) {
927
+ if (unfittingIndices.has(i)) {
928
+ results.push({
929
+ text: candidates[i].text,
930
+ x: candidates[i].anchorX,
931
+ y: candidates[i].anchorY,
932
+ style: candidates[i].style,
933
+ visible: false
934
+ });
935
+ } else {
936
+ results.push(resolved[resolvedIdx++]);
937
+ }
938
+ }
939
+ return results;
844
940
  }
845
941
 
846
942
  // src/charts/bar/index.ts
@@ -2487,7 +2583,11 @@ function _getMidValue(rows, field) {
2487
2583
  }
2488
2584
 
2489
2585
  // src/charts/line/labels.ts
2490
- import { estimateTextWidth as estimateTextWidth5, resolveCollisions as resolveCollisions4 } from "@opendata-ai/openchart-core";
2586
+ import {
2587
+ EXTENDED_OFFSET_STRATEGIES,
2588
+ estimateTextWidth as estimateTextWidth5,
2589
+ resolveCollisions as resolveCollisions4
2590
+ } from "@opendata-ai/openchart-core";
2491
2591
  var LABEL_FONT_SIZE4 = 11;
2492
2592
  var LABEL_FONT_WEIGHT4 = 600;
2493
2593
  var LABEL_OFFSET_X2 = 6;
@@ -2541,7 +2641,7 @@ function computeLineLabels(marks, strategy, density = "auto", labelOffsets) {
2541
2641
  }
2542
2642
  return result;
2543
2643
  }
2544
- const resolved = resolveCollisions4(candidates);
2644
+ const resolved = resolveCollisions4(candidates, EXTENDED_OFFSET_STRATEGIES);
2545
2645
  for (let i = 0; i < resolved.length; i++) {
2546
2646
  const seriesKey = seriesOrder[i];
2547
2647
  const label = resolved[i];
@@ -5494,6 +5594,30 @@ function sequential() {
5494
5594
  var DEFAULT_POINT_RADIUS2 = 5;
5495
5595
  var MIN_BUBBLE_RADIUS = 3;
5496
5596
  var MAX_BUBBLE_RADIUS = 30;
5597
+ function resolvePosition2(value, channelType, scale) {
5598
+ switch (channelType) {
5599
+ case "nominal":
5600
+ case "ordinal": {
5601
+ const s = String(value);
5602
+ if ("bandwidth" in scale && typeof scale.bandwidth === "function") {
5603
+ const bw = scale.bandwidth();
5604
+ const pos = scale(s);
5605
+ if (pos === void 0) return void 0;
5606
+ return bw > 0 ? pos + bw / 2 : pos;
5607
+ }
5608
+ return scale(s);
5609
+ }
5610
+ case "temporal": {
5611
+ const px = scale(new Date(value));
5612
+ return Number.isNaN(px) ? void 0 : px;
5613
+ }
5614
+ default: {
5615
+ const num = Number(value);
5616
+ if (!Number.isFinite(num)) return void 0;
5617
+ return scale(num);
5618
+ }
5619
+ }
5620
+ }
5497
5621
  function computeScatterMarks(spec, scales, _chartArea, _strategy) {
5498
5622
  const encoding = spec.encoding;
5499
5623
  const xChannel = encoding.x;
@@ -5503,6 +5627,8 @@ function computeScatterMarks(spec, scales, _chartArea, _strategy) {
5503
5627
  }
5504
5628
  const xScale = scales.x.scale;
5505
5629
  const yScale = scales.y.scale;
5630
+ const xType = xChannel.type;
5631
+ const yType = yChannel.type;
5506
5632
  const colorEnc = encoding.color && "field" in encoding.color ? encoding.color : void 0;
5507
5633
  const isSequentialColor = colorEnc?.type === "quantitative";
5508
5634
  const colorField = colorEnc?.field;
@@ -5516,11 +5642,11 @@ function computeScatterMarks(spec, scales, _chartArea, _strategy) {
5516
5642
  }
5517
5643
  const marks = [];
5518
5644
  for (const row of spec.data) {
5519
- const xVal = Number(row[xChannel.field]);
5520
- const yVal = Number(row[yChannel.field]);
5521
- if (!Number.isFinite(xVal) || !Number.isFinite(yVal)) continue;
5522
- const cx = xScale(xVal);
5523
- const cy = yScale(yVal);
5645
+ const rawX = row[xChannel.field];
5646
+ const rawY = row[yChannel.field];
5647
+ const cx = resolvePosition2(rawX, xType, xScale);
5648
+ const cy = resolvePosition2(rawY, yType, yScale);
5649
+ if (cx === void 0 || cy === void 0) continue;
5524
5650
  const category = colorField && !isSequentialColor ? String(row[colorField] ?? "") : void 0;
5525
5651
  let color2;
5526
5652
  if (isSequentialColor && colorField) {
@@ -5536,7 +5662,7 @@ function computeScatterMarks(spec, scales, _chartArea, _strategy) {
5536
5662
  radius = sizeScale(sizeVal);
5537
5663
  }
5538
5664
  }
5539
- const labelParts = [`${xChannel.field}=${xVal}`, `${yChannel.field}=${yVal}`];
5665
+ const labelParts = [`${xChannel.field}=${rawX}`, `${yChannel.field}=${rawY}`];
5540
5666
  if (category) labelParts.push(`${colorField}=${category}`);
5541
5667
  if (sizeField && row[sizeField] != null) {
5542
5668
  labelParts.push(`${sizeField}=${row[sizeField]}`);
@@ -6026,6 +6152,38 @@ function validateChartSpec(spec, errors) {
6026
6152
  }
6027
6153
  for (const [channel, channelSpec] of Object.entries(encoding)) {
6028
6154
  if (!channelSpec || typeof channelSpec !== "object") continue;
6155
+ if (channel === "tooltip" && Array.isArray(channelSpec)) {
6156
+ for (let i = 0; i < channelSpec.length; i++) {
6157
+ const elem = channelSpec[i];
6158
+ if (!elem || typeof elem !== "object") continue;
6159
+ if (!elem.field || typeof elem.field !== "string") {
6160
+ errors.push({
6161
+ message: `Spec error: encoding.tooltip[${i}] must have a "field" string`,
6162
+ path: `encoding.tooltip[${i}].field`,
6163
+ code: "MISSING_FIELD",
6164
+ suggestion: `Add a field name from your data columns: ${availableColumns}`
6165
+ });
6166
+ continue;
6167
+ }
6168
+ if (!dataColumns.has(elem.field) && !transformFields.has(elem.field)) {
6169
+ errors.push({
6170
+ message: `Spec error: encoding.tooltip[${i}].field "${elem.field}" does not exist in data. Available columns: ${availableColumns}`,
6171
+ path: `encoding.tooltip[${i}].field`,
6172
+ code: "DATA_FIELD_MISSING",
6173
+ suggestion: `Use one of the available data columns: ${availableColumns}`
6174
+ });
6175
+ }
6176
+ if (elem.type && !VALID_FIELD_TYPES.has(elem.type)) {
6177
+ errors.push({
6178
+ message: `Spec error: encoding.tooltip[${i}].type "${elem.type}" is not valid. Must be one of: ${[...VALID_FIELD_TYPES].join(", ")}`,
6179
+ path: `encoding.tooltip[${i}].type`,
6180
+ code: "INVALID_VALUE",
6181
+ suggestion: `Use one of: ${[...VALID_FIELD_TYPES].join(", ")}`
6182
+ });
6183
+ }
6184
+ }
6185
+ continue;
6186
+ }
6029
6187
  const channelObj = channelSpec;
6030
6188
  const channelRule = rules[channel];
6031
6189
  if ("condition" in channelObj) continue;
@@ -7276,7 +7434,7 @@ function computeDimensions(spec, options, legendLayout, theme, strategy) {
7276
7434
  }
7277
7435
  }
7278
7436
  if (encoding.y && !isRadial) {
7279
- if (spec.markType === "bar" || spec.markType === "circle" || encoding.y.type === "nominal" || encoding.y.type === "ordinal") {
7437
+ if (spec.markType === "bar" || spec.markType === "circle" || spec.markType === "lollipop" || encoding.y.type === "nominal" || encoding.y.type === "ordinal") {
7280
7438
  const yField = encoding.y.field;
7281
7439
  let maxLabelWidth = 0;
7282
7440
  for (const row of spec.data) {
@@ -7658,7 +7816,7 @@ function buildPositionalScale(channel, data, rangeStart, rangeEnd, chartType, ax
7658
7816
  return buildLinearScale(channel, data, rangeStart, rangeEnd);
7659
7817
  case "nominal":
7660
7818
  case "ordinal":
7661
- if (chartType === "bar" || chartType === "circle" && axis === "y") {
7819
+ if (chartType === "bar" || (chartType === "circle" || chartType === "lollipop") && axis === "y") {
7662
7820
  return buildBandScale(channel, data, rangeStart, rangeEnd);
7663
7821
  }
7664
7822
  return buildPointScale(channel, data, rangeStart, rangeEnd);
@@ -7779,6 +7937,7 @@ function swatchShapeForType(markType) {
7779
7937
  return "line";
7780
7938
  case "point":
7781
7939
  case "circle":
7940
+ case "lollipop":
7782
7941
  return "circle";
7783
7942
  default:
7784
7943
  return "square";
@@ -7881,10 +8040,11 @@ function computeLegend(spec, strategy, theme, chartArea) {
7881
8040
  const entryHeight = Math.max(SWATCH_SIZE2, labelStyle.fontSize * labelStyle.lineHeight);
7882
8041
  const maxHeightRatio = strategy.legendMaxHeight > 0 ? strategy.legendMaxHeight : RIGHT_LEGEND_MAX_HEIGHT_RATIO;
7883
8042
  const maxLegendHeight = chartArea.height * maxHeightRatio;
7884
- const maxEntries = Math.max(
8043
+ const maxFromSpace = Math.max(
7885
8044
  1,
7886
8045
  Math.floor((maxLegendHeight - LEGEND_PADDING * 2) / (entryHeight + 4))
7887
8046
  );
8047
+ const maxEntries = spec.legend?.symbolLimit != null ? Math.min(Math.max(1, spec.legend.symbolLimit), maxFromSpace) : maxFromSpace;
7888
8048
  if (entries.length > maxEntries) {
7889
8049
  entries = truncateEntries(entries, maxEntries);
7890
8050
  }
@@ -7909,7 +8069,14 @@ function computeLegend(spec, strategy, theme, chartArea) {
7909
8069
  };
7910
8070
  }
7911
8071
  const availableWidth = chartArea.width - LEGEND_PADDING * 2 - BRAND_RESERVE_WIDTH;
7912
- const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
8072
+ if (spec.legend?.symbolLimit != null) {
8073
+ const limit = Math.max(1, spec.legend.symbolLimit);
8074
+ if (limit < entries.length) {
8075
+ entries = truncateEntries(entries, limit);
8076
+ }
8077
+ }
8078
+ const maxRows = spec.legend?.columns != null ? Math.ceil(entries.length / spec.legend.columns) : TOP_LEGEND_MAX_ROWS;
8079
+ const maxFit = entriesThatFit(entries, availableWidth, maxRows, labelStyle);
7913
8080
  if (maxFit < entries.length) {
7914
8081
  entries = truncateEntries(entries, maxFit);
7915
8082
  }
@@ -8602,7 +8769,17 @@ function formatValue(value, fieldType, format2) {
8602
8769
  }
8603
8770
  return String(value);
8604
8771
  }
8772
+ function buildExplicitTooltipFields(row, channels) {
8773
+ return channels.map((ch) => ({
8774
+ label: ch.axis?.label ?? ch.field,
8775
+ value: formatValue(row[ch.field], ch.type, ch.axis?.format)
8776
+ }));
8777
+ }
8605
8778
  function buildFields(row, encoding, color2) {
8779
+ if (encoding.tooltip) {
8780
+ const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
8781
+ return buildExplicitTooltipFields(row, channels);
8782
+ }
8606
8783
  const fields = [];
8607
8784
  if (encoding.y) {
8608
8785
  fields.push({
@@ -8922,6 +9099,8 @@ var builtinRenderers = {
8922
9099
  // old 'donut'
8923
9100
  circle: dotRenderer,
8924
9101
  // old 'dot'
9102
+ lollipop: dotRenderer,
9103
+ // semantic alias for dot/circle
8925
9104
  text: textRenderer,
8926
9105
  rule: ruleRenderer,
8927
9106
  tick: tickRenderer,
@@ -9157,7 +9336,8 @@ function compileChart(spec, options) {
9157
9336
  chartArea,
9158
9337
  strategy,
9159
9338
  theme.isDark,
9160
- obstacles
9339
+ obstacles,
9340
+ { width: dims.total.width, height: dims.total.height }
9161
9341
  );
9162
9342
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
9163
9343
  const altText = generateAltText(