@opendata-ai/openchart-engine 2.4.0 → 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "2.4.0",
48
+ "@opendata-ai/openchart-core": "2.6.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -67,6 +67,7 @@ export function makeLineSpec(): NormalizedChartSpec {
67
67
  darkMode: 'off',
68
68
  labels: { density: 'auto', format: '' },
69
69
  hiddenSeries: [],
70
+ seriesStyles: {},
70
71
  };
71
72
  }
72
73
 
@@ -94,6 +95,7 @@ export function makeBarSpec(): NormalizedChartSpec {
94
95
  darkMode: 'off',
95
96
  labels: { density: 'auto', format: '' },
96
97
  hiddenSeries: [],
98
+ seriesStyles: {},
97
99
  };
98
100
  }
99
101
 
@@ -123,5 +125,6 @@ export function makeScatterSpec(): NormalizedChartSpec {
123
125
  darkMode: 'off',
124
126
  labels: { density: 'auto', format: '' },
125
127
  hiddenSeries: [],
128
+ seriesStyles: {},
126
129
  };
127
130
  }
@@ -356,18 +356,33 @@ function resolveRangeAnnotation(
356
356
 
357
357
  const rect: Rect = { x, y, width, height };
358
358
 
359
- // Label at the top-left of the range, with optional offset
359
+ // Label positioned within the range, with optional offset.
360
+ // labelAnchor controls horizontal placement:
361
+ // "left" (default): left edge, text-anchor start
362
+ // "top"/"auto": horizontally centered, text-anchor middle
363
+ // "right": right edge, text-anchor end
360
364
  let label: ResolvedLabel | undefined;
361
365
  if (annotation.label) {
362
- const baseDx = 4;
366
+ const anchor = annotation.labelAnchor ?? 'left';
367
+ const centered = anchor === 'top' || anchor === 'bottom' || anchor === 'auto';
368
+ const baseDx = centered ? 0 : anchor === 'right' ? -4 : 4;
363
369
  const baseDy = 14;
364
370
  const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
365
371
 
372
+ const style = makeAnnotationLabelStyle(11, 500, undefined, isDark);
373
+ if (centered) {
374
+ style.textAnchor = 'middle';
375
+ } else if (anchor === 'right') {
376
+ style.textAnchor = 'end';
377
+ }
378
+
379
+ const baseX = centered ? x + width / 2 : anchor === 'right' ? x + width : x;
380
+
366
381
  label = {
367
382
  text: annotation.label,
368
- x: x + labelDelta.dx,
383
+ x: baseX + labelDelta.dx,
369
384
  y: y + labelDelta.dy,
370
- style: makeAnnotationLabelStyle(11, 500, undefined, isDark),
385
+ style,
371
386
  visible: true,
372
387
  };
373
388
  }
@@ -428,11 +443,15 @@ function resolveRefLineAnnotation(
428
443
 
429
444
  // Label at the right end for horizontal, top end for vertical, with optional offset.
430
445
  // Horizontal refline labels use text-anchor 'end' so text stays inside the chart.
446
+ // labelAnchor controls which side of the line the label sits on:
447
+ // "top" (default): above horizontal, left of vertical
448
+ // "bottom": below horizontal, right of vertical
431
449
  let label: ResolvedLabel | undefined;
432
450
  if (annotation.label) {
433
451
  const isHorizontal = annotation.y !== undefined;
452
+ const anchor = annotation.labelAnchor ?? 'top';
434
453
  const baseDx = isHorizontal ? -4 : 4;
435
- const baseDy = -4;
454
+ const baseDy = anchor === 'bottom' ? 14 : -4;
436
455
  const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
437
456
 
438
457
  const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
@@ -17,7 +17,7 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
17
17
  const marks = computeBarMarks(spec, scales, chartArea, strategy);
18
18
 
19
19
  // Compute and attach value labels (respects spec.labels.density)
20
- const labels = computeBarLabels(marks, chartArea, spec.labels.density);
20
+ const labels = computeBarLabels(marks, chartArea, spec.labels.density, spec.labels.format);
21
21
  for (let i = 0; i < marks.length && i < labels.length; i++) {
22
22
  marks[i].label = labels[i];
23
23
  }
@@ -18,6 +18,7 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
21
+ import { format as d3Format } from 'd3-format';
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // Constants
@@ -43,6 +44,7 @@ export function computeBarLabels(
43
44
  marks: RectMark[],
44
45
  _chartArea: { x: number; y: number; width: number; height: number },
45
46
  density: LabelDensity = 'auto',
47
+ labelFormat?: string,
46
48
  ): ResolvedLabel[] {
47
49
  // 'none': no labels at all
48
50
  if (density === 'none') return [];
@@ -53,14 +55,43 @@ export function computeBarLabels(
53
55
 
54
56
  const candidates: LabelCandidate[] = [];
55
57
 
58
+ // Build a d3 formatter if a label format string was provided.
59
+ // Supports a literal suffix after the d3 format, e.g. ".1f%" formats as "12.5%"
60
+ // (the trailing "%" is appended literally, not d3's multiply-by-100 percent type).
61
+ let formatter: ((v: number) => string) | null = null;
62
+ if (labelFormat) {
63
+ try {
64
+ formatter = d3Format(labelFormat);
65
+ } catch {
66
+ // If d3-format rejects it, try stripping a trailing suffix
67
+ const suffixMatch = labelFormat.match(/^(.+[a-z])([^a-z]+)$/i);
68
+ if (suffixMatch) {
69
+ try {
70
+ const d3Fmt = d3Format(suffixMatch[1]);
71
+ const suffix = suffixMatch[2];
72
+ formatter = (v: number) => d3Fmt(v) + suffix;
73
+ } catch {
74
+ // Give up on formatting
75
+ }
76
+ }
77
+ }
78
+ }
79
+
56
80
  for (const mark of targetMarks) {
57
81
  // Extract the display value from the aria label.
58
82
  // Format is "category: value" or "category, group: value".
59
83
  // Use the last colon to split, which handles colons in category names.
60
84
  const ariaLabel = mark.aria.label;
61
85
  const lastColon = ariaLabel.lastIndexOf(':');
62
- const valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
63
- if (!valuePart) continue;
86
+ const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
87
+ if (!rawValue) continue;
88
+
89
+ // Apply label format if provided (re-parse the number from the aria string)
90
+ let valuePart = rawValue;
91
+ if (formatter) {
92
+ const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
93
+ if (!Number.isNaN(num)) valuePart = formatter(num);
94
+ }
64
95
 
65
96
  const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
66
97
  const textHeight = LABEL_FONT_SIZE * 1.2;
@@ -799,3 +799,112 @@ describe('computeLineLabels', () => {
799
799
  expect(labelMap.size).toBe(0);
800
800
  });
801
801
  });
802
+
803
+ // ---------------------------------------------------------------------------
804
+ // seriesStyles tests
805
+ // ---------------------------------------------------------------------------
806
+
807
+ describe('seriesStyles', () => {
808
+ it('applies dashed line style to a specific series', () => {
809
+ const spec = makeMultiSeriesSpec();
810
+ spec.seriesStyles = { UK: { lineStyle: 'dashed' } };
811
+ const scales = computeScales(spec, chartArea, spec.data);
812
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
813
+
814
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
815
+ const ukLine = lineMarks.find((m) => m.seriesKey === 'UK');
816
+ const usLine = lineMarks.find((m) => m.seriesKey === 'US');
817
+
818
+ expect(ukLine?.strokeDasharray).toBe('6 4');
819
+ expect(usLine?.strokeDasharray).toBeUndefined();
820
+ });
821
+
822
+ it('applies dotted line style', () => {
823
+ const spec = makeMultiSeriesSpec();
824
+ spec.seriesStyles = { US: { lineStyle: 'dotted' } };
825
+ const scales = computeScales(spec, chartArea, spec.data);
826
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
827
+
828
+ const usLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'US');
829
+ expect(usLine?.strokeDasharray).toBe('2 3');
830
+ });
831
+
832
+ it('hides point markers when showPoints is false', () => {
833
+ const spec = makeMultiSeriesSpec();
834
+ spec.seriesStyles = { UK: { showPoints: false } };
835
+ const scales = computeScales(spec, chartArea, spec.data);
836
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
837
+
838
+ const ukPoints = marks.filter(
839
+ (m): m is PointMark => m.type === 'point' && m.data.country === 'UK',
840
+ );
841
+ const usPoints = marks.filter(
842
+ (m): m is PointMark => m.type === 'point' && m.data.country === 'US',
843
+ );
844
+
845
+ // UK points should have r=0 (hidden)
846
+ expect(ukPoints.every((p) => p.r === 0)).toBe(true);
847
+ // US points should still have default radius
848
+ expect(usPoints.every((p) => p.r > 0)).toBe(true);
849
+ });
850
+
851
+ it('overrides strokeWidth for a series', () => {
852
+ const spec = makeMultiSeriesSpec();
853
+ spec.seriesStyles = { UK: { strokeWidth: 1.5 } };
854
+ const scales = computeScales(spec, chartArea, spec.data);
855
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
856
+
857
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
858
+ const ukLine = lineMarks.find((m) => m.seriesKey === 'UK');
859
+ const usLine = lineMarks.find((m) => m.seriesKey === 'US');
860
+
861
+ expect(ukLine?.strokeWidth).toBe(1.5);
862
+ expect(usLine?.strokeWidth).toBe(2.5); // default
863
+ });
864
+
865
+ it('sets opacity on a series', () => {
866
+ const spec = makeMultiSeriesSpec();
867
+ spec.seriesStyles = { UK: { opacity: 0.5 } };
868
+ const scales = computeScales(spec, chartArea, spec.data);
869
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
870
+
871
+ const ukLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'UK');
872
+ const usLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'US');
873
+
874
+ expect(ukLine?.opacity).toBe(0.5);
875
+ expect(usLine?.opacity).toBeUndefined();
876
+ });
877
+
878
+ it('combines multiple style overrides on the same series', () => {
879
+ const spec = makeMultiSeriesSpec();
880
+ spec.seriesStyles = {
881
+ UK: { lineStyle: 'dashed', showPoints: false, strokeWidth: 1, opacity: 0.6 },
882
+ };
883
+ const scales = computeScales(spec, chartArea, spec.data);
884
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
885
+
886
+ const ukLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'UK');
887
+ expect(ukLine?.strokeDasharray).toBe('6 4');
888
+ expect(ukLine?.strokeWidth).toBe(1);
889
+ expect(ukLine?.opacity).toBe(0.6);
890
+
891
+ const ukPoints = marks.filter(
892
+ (m): m is PointMark => m.type === 'point' && m.data.country === 'UK',
893
+ );
894
+ expect(ukPoints.every((p) => p.r === 0)).toBe(true);
895
+ });
896
+
897
+ it('does not apply styles when seriesStyles is empty', () => {
898
+ const spec = makeMultiSeriesSpec();
899
+ spec.seriesStyles = {};
900
+ const scales = computeScales(spec, chartArea, spec.data);
901
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
902
+
903
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
904
+ for (const line of lineMarks) {
905
+ expect(line.strokeDasharray).toBeUndefined();
906
+ expect(line.opacity).toBeUndefined();
907
+ expect(line.strokeWidth).toBe(2.5);
908
+ }
909
+ });
910
+ });
@@ -140,6 +140,15 @@ export function computeLineMarks(
140
140
  // segments are created by starting a new M command.
141
141
  const combinedPath = pathParts.join(' ');
142
142
 
143
+ // Look up per-series style overrides
144
+ const seriesStyleKey = seriesKey === '__default__' ? undefined : seriesKey;
145
+ const styleOverride = seriesStyleKey ? spec.seriesStyles?.[seriesStyleKey] : undefined;
146
+
147
+ // Map lineStyle to SVG strokeDasharray
148
+ let strokeDasharray: string | undefined;
149
+ if (styleOverride?.lineStyle === 'dashed') strokeDasharray = '6 4';
150
+ else if (styleOverride?.lineStyle === 'dotted') strokeDasharray = '2 3';
151
+
143
152
  // Create the LineMark with the combined path points.
144
153
  // The points array includes all valid points across all segments.
145
154
  const lineMark: LineMark = {
@@ -147,25 +156,28 @@ export function computeLineMarks(
147
156
  points: allPoints,
148
157
  path: combinedPath,
149
158
  stroke: color,
150
- strokeWidth: DEFAULT_STROKE_WIDTH,
151
- seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
159
+ strokeWidth: styleOverride?.strokeWidth ?? DEFAULT_STROKE_WIDTH,
160
+ strokeDasharray,
161
+ opacity: styleOverride?.opacity,
162
+ seriesKey: seriesStyleKey,
152
163
  data: pointsWithData.map((p) => p.row),
153
164
  aria,
154
165
  };
155
166
 
156
167
  marks.push(lineMark);
157
168
 
158
- // Create point marks for hover targets
169
+ // Create point marks for hover targets (skip if showPoints is false)
170
+ const showPoints = styleOverride?.showPoints !== false;
159
171
  for (let i = 0; i < pointsWithData.length; i++) {
160
172
  const p = pointsWithData[i];
161
173
  const pointMark: PointMark = {
162
174
  type: 'point',
163
175
  cx: p.x,
164
176
  cy: p.y,
165
- r: DEFAULT_POINT_RADIUS,
177
+ r: showPoints ? DEFAULT_POINT_RADIUS : 0,
166
178
  fill: color,
167
- stroke: '#ffffff',
168
- strokeWidth: 1.5,
179
+ stroke: showPoints ? '#ffffff' : 'transparent',
180
+ strokeWidth: showPoints ? 1.5 : 0,
169
181
  fillOpacity: 0,
170
182
  data: p.row,
171
183
  aria: {
@@ -197,6 +197,7 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
197
197
  theme: spec.theme ?? {},
198
198
  darkMode: spec.darkMode ?? 'off',
199
199
  hiddenSeries: spec.hiddenSeries ?? [],
200
+ seriesStyles: spec.seriesStyles ?? {},
200
201
  };
201
202
  }
202
203
 
@@ -73,6 +73,8 @@ export interface NormalizedChartSpec {
73
73
  darkMode: DarkMode;
74
74
  /** Series names to hide from rendering. */
75
75
  hiddenSeries: string[];
76
+ /** Per-series visual style overrides. */
77
+ seriesStyles: Record<string, import('@opendata-ai/openchart-core').SeriesStyle>;
76
78
  }
77
79
 
78
80
  /** A TableSpec with all optional fields filled with sensible defaults. */
@@ -16,6 +16,7 @@ import type {
16
16
  TextStyle,
17
17
  } from '@opendata-ai/openchart-core';
18
18
  import { abbreviateNumber, formatDate, formatNumber } from '@opendata-ai/openchart-core';
19
+ import { format as d3Format } from 'd3-format';
19
20
  import type { ScaleBand } from 'd3-scale';
20
21
  import type {
21
22
  D3CategoricalScale,
@@ -147,7 +148,21 @@ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
147
148
 
148
149
  if (resolvedScale.type === 'linear' || resolvedScale.type === 'log') {
149
150
  const num = value as number;
150
- if (formatStr) return formatNumber(num);
151
+ if (formatStr) {
152
+ try {
153
+ return d3Format(formatStr)(num);
154
+ } catch {
155
+ // Support literal suffix after d3 format, e.g. ".1f%" → d3(".1f") + "%"
156
+ const suffixMatch = formatStr.match(/^(.+[a-z])([^a-z]+)$/i);
157
+ if (suffixMatch) {
158
+ try {
159
+ return d3Format(suffixMatch[1])(num) + suffixMatch[2];
160
+ } catch {
161
+ // Fall through to default formatting
162
+ }
163
+ }
164
+ }
165
+ }
151
166
  // Abbreviate large numbers for axis labels
152
167
  if (Math.abs(num) >= 1000) return abbreviateNumber(num);
153
168
  return formatNumber(num);
@@ -127,8 +127,10 @@ export function computeDimensions(
127
127
  left: padding + (isRadial ? padding : axisMargin),
128
128
  };
129
129
 
130
- // Dynamic right margin for line/area end-of-line labels
131
- if (spec.type === 'line' || spec.type === 'area') {
130
+ // Dynamic right margin for line/area end-of-line labels.
131
+ // Only reserve space when labels will actually render (density != 'none').
132
+ const labelDensity = spec.labels.density;
133
+ if ((spec.type === 'line' || spec.type === 'area') && labelDensity !== 'none') {
132
134
  // Estimate label width from longest series name (color encoding domain)
133
135
  const colorField = encoding.color?.field;
134
136
  if (colorField) {