@opendata-ai/openchart-engine 6.2.1 → 6.4.1

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