@opendata-ai/openchart-engine 2.4.0 → 2.5.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.5.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.5.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
  }
@@ -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. */