@opendata-ai/openchart-engine 2.3.5 → 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.3.5",
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.3.5",
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
  }
@@ -2,7 +2,7 @@ import type { LayoutStrategy } from '@opendata-ai/openchart-core';
2
2
  import { resolveTheme } from '@opendata-ai/openchart-core';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import type { NormalizedChartSpec } from '../compiler/types';
5
- import { computeAxes } from '../layout/axes';
5
+ import { computeAxes, effectiveDensity } from '../layout/axes';
6
6
  import { computeScales } from '../layout/scales';
7
7
 
8
8
  const lineSpec: NormalizedChartSpec = {
@@ -111,4 +111,188 @@ describe('computeAxes', () => {
111
111
  expect(axes.y!.start.x).toBe(chartArea.x);
112
112
  expect(axes.y!.end.x).toBe(chartArea.x);
113
113
  });
114
+
115
+ // -------------------------------------------------------------------------
116
+ // Height-aware y-axis tick reduction
117
+ // -------------------------------------------------------------------------
118
+
119
+ it('reduces y-axis ticks for very short chart areas (< 120px)', () => {
120
+ const shortArea = { x: 50, y: 50, width: 500, height: 80 };
121
+ const scales = computeScales(lineSpec, shortArea, lineSpec.data);
122
+ const axesShort = computeAxes(scales, shortArea, fullStrategy, theme);
123
+
124
+ // Even though the strategy says 'full', height < 120 forces minimal (3 ticks)
125
+ expect(axesShort.y!.ticks.length).toBeLessThanOrEqual(3);
126
+ });
127
+
128
+ it('reduces y-axis ticks for medium-short chart areas (120-200px)', () => {
129
+ const mediumArea = { x: 50, y: 50, width: 500, height: 160 };
130
+ const tallArea = { x: 50, y: 50, width: 500, height: 400 };
131
+
132
+ const scalesMedium = computeScales(lineSpec, mediumArea, lineSpec.data);
133
+ const scalesTall = computeScales(lineSpec, tallArea, lineSpec.data);
134
+
135
+ const axesMedium = computeAxes(scalesMedium, mediumArea, fullStrategy, theme);
136
+ const axesTall = computeAxes(scalesTall, tallArea, fullStrategy, theme);
137
+
138
+ // Medium height should have fewer ticks than a tall chart with same 'full' density
139
+ expect(axesMedium.y!.ticks.length).toBeLessThanOrEqual(axesTall.y!.ticks.length);
140
+ });
141
+
142
+ it('does not increase y-axis ticks beyond base density for short charts', () => {
143
+ const shortArea = { x: 50, y: 50, width: 500, height: 80 };
144
+ const scales = computeScales(lineSpec, shortArea, lineSpec.data);
145
+
146
+ // Strategy already says minimal - short height shouldn't change anything
147
+ const axes = computeAxes(scales, shortArea, minimalStrategy, theme);
148
+ expect(axes.y!.ticks.length).toBeLessThanOrEqual(3);
149
+ });
150
+
151
+ // -------------------------------------------------------------------------
152
+ // Width-aware x-axis tick reduction
153
+ // -------------------------------------------------------------------------
154
+
155
+ it('reduces x-axis ticks for very narrow chart areas (< 150px)', () => {
156
+ const narrowArea = { x: 50, y: 50, width: 100, height: 300 };
157
+ const scales = computeScales(lineSpec, narrowArea, lineSpec.data);
158
+ const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
159
+
160
+ // Width < 150 forces minimal density for x-axis
161
+ expect(axes.x!.ticks.length).toBeLessThanOrEqual(3);
162
+ });
163
+
164
+ it('reduces x-axis ticks for medium-narrow chart areas (150-300px)', () => {
165
+ const mediumArea = { x: 50, y: 50, width: 250, height: 300 };
166
+ const wideArea = { x: 50, y: 50, width: 600, height: 300 };
167
+
168
+ const scalesMedium = computeScales(lineSpec, mediumArea, lineSpec.data);
169
+ const scalesWide = computeScales(lineSpec, wideArea, lineSpec.data);
170
+
171
+ const axesMedium = computeAxes(scalesMedium, mediumArea, fullStrategy, theme);
172
+ const axesWide = computeAxes(scalesWide, wideArea, fullStrategy, theme);
173
+
174
+ expect(axesMedium.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
175
+ });
176
+
177
+ // -------------------------------------------------------------------------
178
+ // Both axes constrained simultaneously (thumbnail scenario)
179
+ // -------------------------------------------------------------------------
180
+
181
+ it('reduces ticks on both axes for thumbnail-sized charts', () => {
182
+ const thumbnailArea = { x: 10, y: 10, width: 120, height: 80 };
183
+ const fullArea = { x: 50, y: 50, width: 600, height: 400 };
184
+
185
+ const scalesThumb = computeScales(lineSpec, thumbnailArea, lineSpec.data);
186
+ const scalesFull = computeScales(lineSpec, fullArea, lineSpec.data);
187
+
188
+ const axesThumb = computeAxes(scalesThumb, thumbnailArea, fullStrategy, theme);
189
+ const axesFull = computeAxes(scalesFull, fullArea, fullStrategy, theme);
190
+
191
+ // Both axes should have minimal ticks in a thumbnail
192
+ expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(3);
193
+ expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(3);
194
+
195
+ // And fewer than full-size
196
+ expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(axesFull.x!.ticks.length);
197
+ expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(axesFull.y!.ticks.length);
198
+ });
199
+
200
+ // -------------------------------------------------------------------------
201
+ // tickAngle propagation
202
+ // -------------------------------------------------------------------------
203
+
204
+ it('propagates tickAngle from encoding to x-axis layout', () => {
205
+ const specWithAngle: NormalizedChartSpec = {
206
+ ...lineSpec,
207
+ type: 'column',
208
+ data: [
209
+ { cat: 'California', val: 10 },
210
+ { cat: 'New York', val: 20 },
211
+ ],
212
+ encoding: {
213
+ x: { field: 'cat', type: 'nominal', axis: { tickAngle: -90 } },
214
+ y: { field: 'val', type: 'quantitative' },
215
+ },
216
+ };
217
+ const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
218
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
219
+
220
+ expect(axes.x!.tickAngle).toBe(-90);
221
+ });
222
+
223
+ it('leaves tickAngle undefined when not specified', () => {
224
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
225
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
226
+
227
+ expect(axes.x!.tickAngle).toBeUndefined();
228
+ expect(axes.y!.tickAngle).toBeUndefined();
229
+ });
230
+
231
+ it('propagates tickAngle to y-axis layout', () => {
232
+ const specWithAngle: NormalizedChartSpec = {
233
+ ...lineSpec,
234
+ encoding: {
235
+ x: { field: 'date', type: 'temporal' },
236
+ y: { field: 'value', type: 'quantitative', axis: { tickAngle: -45 } },
237
+ },
238
+ };
239
+ const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
240
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
241
+
242
+ expect(axes.y!.tickAngle).toBe(-45);
243
+ });
244
+ });
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // effectiveDensity unit tests
248
+ // ---------------------------------------------------------------------------
249
+
250
+ describe('effectiveDensity', () => {
251
+ const MINIMAL_THRESHOLD = 120;
252
+ const REDUCED_THRESHOLD = 200;
253
+
254
+ it('returns base density when axis length exceeds all thresholds', () => {
255
+ expect(effectiveDensity('full', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('full');
256
+ expect(effectiveDensity('reduced', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
257
+ expect(effectiveDensity('minimal', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
258
+ });
259
+
260
+ it('forces minimal density below the minimal threshold', () => {
261
+ expect(effectiveDensity('full', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
262
+ expect(effectiveDensity('reduced', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
263
+ expect(effectiveDensity('minimal', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
264
+ });
265
+
266
+ it('caps at reduced density between thresholds', () => {
267
+ // 'full' base should step down to 'reduced'
268
+ expect(effectiveDensity('full', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
269
+ });
270
+
271
+ it('does not increase density beyond base when between thresholds', () => {
272
+ // 'minimal' base should stay 'minimal' even between thresholds
273
+ expect(effectiveDensity('minimal', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
274
+ // 'reduced' base should stay 'reduced'
275
+ expect(effectiveDensity('reduced', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
276
+ });
277
+
278
+ it('handles exact threshold boundaries', () => {
279
+ // At exactly the minimal threshold, we're NOT below it
280
+ expect(effectiveDensity('full', 120, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
281
+ // At exactly the reduced threshold, we're NOT below it
282
+ expect(effectiveDensity('full', 200, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('full');
283
+ });
284
+
285
+ it('handles zero and negative lengths', () => {
286
+ expect(effectiveDensity('full', 0, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
287
+ expect(effectiveDensity('full', -10, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
288
+ });
289
+
290
+ it('works with custom thresholds (x-axis uses different values)', () => {
291
+ const X_MINIMAL = 150;
292
+ const X_REDUCED = 300;
293
+
294
+ expect(effectiveDensity('full', 100, X_MINIMAL, X_REDUCED)).toBe('minimal');
295
+ expect(effectiveDensity('full', 200, X_MINIMAL, X_REDUCED)).toBe('reduced');
296
+ expect(effectiveDensity('full', 400, X_MINIMAL, X_REDUCED)).toBe('full');
297
+ });
114
298
  });
@@ -457,4 +457,56 @@ describe('compileGraph', () => {
457
457
  ),
458
458
  ).toThrow('compileGraph received a scatter spec');
459
459
  });
460
+
461
+ it('propagates tickAngle through the full compilation pipeline', () => {
462
+ const columnSpec = {
463
+ type: 'column' as const,
464
+ data: [
465
+ { state: 'California', pop: 39000000 },
466
+ { state: 'Texas', pop: 29000000 },
467
+ { state: 'Florida', pop: 22000000 },
468
+ { state: 'New York', pop: 20000000 },
469
+ { state: 'Pennsylvania', pop: 13000000 },
470
+ ],
471
+ encoding: {
472
+ x: { field: 'state', type: 'nominal' as const, axis: { tickAngle: -90 } },
473
+ y: { field: 'pop', type: 'quantitative' as const },
474
+ },
475
+ };
476
+
477
+ const layout = compileChart(columnSpec, { width: 400, height: 300 });
478
+
479
+ // tickAngle should be propagated to the x-axis layout
480
+ expect(layout.axes.x!.tickAngle).toBe(-90);
481
+ // y-axis should not have a tickAngle
482
+ expect(layout.axes.y!.tickAngle).toBeUndefined();
483
+ });
484
+
485
+ it('reserves extra bottom margin for rotated x-axis labels', () => {
486
+ const baseColumnSpec = {
487
+ type: 'column' as const,
488
+ data: [
489
+ { state: 'California', pop: 39000000 },
490
+ { state: 'Texas', pop: 29000000 },
491
+ { state: 'Massachusetts', pop: 7000000 },
492
+ ],
493
+ encoding: {
494
+ x: { field: 'state', type: 'nominal' as const },
495
+ y: { field: 'pop', type: 'quantitative' as const },
496
+ },
497
+ };
498
+ const rotatedColumnSpec = {
499
+ ...baseColumnSpec,
500
+ encoding: {
501
+ x: { field: 'state', type: 'nominal' as const, axis: { tickAngle: -90 } },
502
+ y: { field: 'pop', type: 'quantitative' as const },
503
+ },
504
+ };
505
+
506
+ const layoutNormal = compileChart(baseColumnSpec, { width: 400, height: 300 });
507
+ const layoutRotated = compileChart(rotatedColumnSpec, { width: 400, height: 300 });
508
+
509
+ // Rotated labels should shrink the chart area height
510
+ expect(layoutRotated.area.height).toBeLessThan(layoutNormal.area.height);
511
+ });
460
512
  });
@@ -148,4 +148,72 @@ describe('computeDimensions', () => {
148
148
  expect(dims.chartArea.width).toBeGreaterThanOrEqual(0);
149
149
  expect(dims.chartArea.height).toBeGreaterThanOrEqual(0);
150
150
  });
151
+
152
+ it('reserves extra bottom space for rotated x-axis labels', () => {
153
+ const rotatedSpec: NormalizedChartSpec = {
154
+ ...baseSpec,
155
+ type: 'column',
156
+ data: [
157
+ { category: 'California', value: 10 },
158
+ { category: 'New York', value: 20 },
159
+ { category: 'Massachusetts', value: 15 },
160
+ ],
161
+ encoding: {
162
+ x: { field: 'category', type: 'nominal', axis: { tickAngle: -90 } },
163
+ y: { field: 'value', type: 'quantitative' },
164
+ },
165
+ };
166
+ const normalSpec: NormalizedChartSpec = {
167
+ ...baseSpec,
168
+ type: 'column',
169
+ data: rotatedSpec.data,
170
+ encoding: {
171
+ x: { field: 'category', type: 'nominal' },
172
+ y: { field: 'value', type: 'quantitative' },
173
+ },
174
+ };
175
+
176
+ const dimsRotated = computeDimensions(
177
+ rotatedSpec,
178
+ { width: 600, height: 400 },
179
+ emptyLegend,
180
+ lightTheme,
181
+ );
182
+ const dimsNormal = computeDimensions(
183
+ normalSpec,
184
+ { width: 600, height: 400 },
185
+ emptyLegend,
186
+ lightTheme,
187
+ );
188
+
189
+ // Rotated labels should reserve more bottom space, shrinking the chart area
190
+ expect(dimsRotated.chartArea.height).toBeLessThan(dimsNormal.chartArea.height);
191
+ expect(dimsRotated.margins.bottom).toBeGreaterThan(dimsNormal.margins.bottom);
192
+ });
193
+
194
+ it('does not change bottom space for small tick angles', () => {
195
+ const smallAngleSpec: NormalizedChartSpec = {
196
+ ...baseSpec,
197
+ encoding: {
198
+ x: { field: 'date', type: 'temporal', axis: { tickAngle: 5 } },
199
+ y: { field: 'value', type: 'quantitative' },
200
+ },
201
+ };
202
+
203
+ const dimsSmall = computeDimensions(
204
+ smallAngleSpec,
205
+ { width: 600, height: 400 },
206
+ emptyLegend,
207
+ lightTheme,
208
+ );
209
+ const dimsNone = computeDimensions(
210
+ baseSpec,
211
+ { width: 600, height: 400 },
212
+ emptyLegend,
213
+ lightTheme,
214
+ );
215
+
216
+ // Small angles (< 10 degrees) should not trigger rotated label logic
217
+ expect(dimsSmall.margins.bottom).toBe(dimsNone.margins.bottom);
218
+ });
151
219
  });
@@ -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. */
@@ -35,6 +35,60 @@ const TICK_COUNTS: Record<AxisLabelDensity, number> = {
35
35
  minimal: 3,
36
36
  };
37
37
 
38
+ /**
39
+ * Height thresholds for reducing y-axis tick density.
40
+ * Below these pixel heights, we step down the density regardless of the
41
+ * width-based strategy. This prevents overlapping y-axis labels in short
42
+ * containers like thumbnail previews.
43
+ */
44
+ const HEIGHT_MINIMAL_THRESHOLD = 120;
45
+ const HEIGHT_REDUCED_THRESHOLD = 200;
46
+
47
+ /**
48
+ * Width thresholds for reducing x-axis tick density.
49
+ * Mirrors the height logic for the x-axis: narrow containers get fewer ticks.
50
+ */
51
+ const WIDTH_MINIMAL_THRESHOLD = 150;
52
+ const WIDTH_REDUCED_THRESHOLD = 300;
53
+
54
+ /** Ordered densities from most to fewest ticks. */
55
+ const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
56
+
57
+ /**
58
+ * Compute effective axis tick density by considering available space.
59
+ *
60
+ * The width-based breakpoint system sets a base density, but it doesn't know
61
+ * about the actual chart area dimensions (which shrink after chrome/legend
62
+ * allocation). This function steps density down further when the axis
63
+ * dimension is too small for the requested tick count.
64
+ *
65
+ * @param baseDensity - The density from the responsive layout strategy.
66
+ * @param axisLength - Available pixels along this axis (height for y, width for x).
67
+ * @param minimalThreshold - Below this pixel size, force minimal density.
68
+ * @param reducedThreshold - Below this pixel size, cap at reduced density.
69
+ * @returns The effective density, never looser than the base.
70
+ */
71
+ export function effectiveDensity(
72
+ baseDensity: AxisLabelDensity,
73
+ axisLength: number,
74
+ minimalThreshold: number,
75
+ reducedThreshold: number,
76
+ ): AxisLabelDensity {
77
+ let density = baseDensity;
78
+
79
+ if (axisLength < minimalThreshold) {
80
+ density = 'minimal';
81
+ } else if (axisLength < reducedThreshold) {
82
+ // Don't increase density beyond what the base strategy allows.
83
+ // If base is already 'minimal', keep it.
84
+ const baseIdx = DENSITY_ORDER.indexOf(baseDensity);
85
+ const reducedIdx = DENSITY_ORDER.indexOf('reduced');
86
+ density = DENSITY_ORDER[Math.max(baseIdx, reducedIdx)];
87
+ }
88
+
89
+ return density;
90
+ }
91
+
38
92
  // ---------------------------------------------------------------------------
39
93
  // Tick generation
40
94
  // ---------------------------------------------------------------------------
@@ -127,7 +181,22 @@ export function computeAxes(
127
181
  theme: ResolvedTheme,
128
182
  ): AxesResult {
129
183
  const result: AxesResult = {};
130
- const density = strategy.axisLabelDensity;
184
+ const baseDensity = strategy.axisLabelDensity;
185
+
186
+ // Compute per-axis density based on available space.
187
+ // Y-axis density adapts to chart height; X-axis density adapts to chart width.
188
+ const yDensity = effectiveDensity(
189
+ baseDensity,
190
+ chartArea.height,
191
+ HEIGHT_MINIMAL_THRESHOLD,
192
+ HEIGHT_REDUCED_THRESHOLD,
193
+ );
194
+ const xDensity = effectiveDensity(
195
+ baseDensity,
196
+ chartArea.width,
197
+ WIDTH_MINIMAL_THRESHOLD,
198
+ WIDTH_REDUCED_THRESHOLD,
199
+ );
131
200
 
132
201
  const tickLabelStyle: TextStyle = {
133
202
  fontFamily: theme.fonts.family,
@@ -149,8 +218,8 @@ export function computeAxes(
149
218
  if (scales.x) {
150
219
  const ticks =
151
220
  scales.x.type === 'band' || scales.x.type === 'point' || scales.x.type === 'ordinal'
152
- ? categoricalTicks(scales.x, density)
153
- : continuousTicks(scales.x, density);
221
+ ? categoricalTicks(scales.x, xDensity)
222
+ : continuousTicks(scales.x, xDensity);
154
223
 
155
224
  const gridlines: Gridline[] = ticks.map((t) => ({
156
225
  position: t.position,
@@ -163,6 +232,7 @@ export function computeAxes(
163
232
  label: scales.x.channel.axis?.label,
164
233
  labelStyle: axisLabelStyle,
165
234
  tickLabelStyle,
235
+ tickAngle: scales.x.channel.axis?.tickAngle,
166
236
  start: { x: chartArea.x, y: chartArea.y + chartArea.height },
167
237
  end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
168
238
  };
@@ -171,8 +241,8 @@ export function computeAxes(
171
241
  if (scales.y) {
172
242
  const ticks =
173
243
  scales.y.type === 'band' || scales.y.type === 'point' || scales.y.type === 'ordinal'
174
- ? categoricalTicks(scales.y, density)
175
- : continuousTicks(scales.y, density);
244
+ ? categoricalTicks(scales.y, yDensity)
245
+ : continuousTicks(scales.y, yDensity);
176
246
 
177
247
  const gridlines: Gridline[] = ticks.map((t) => ({
178
248
  position: t.position,
@@ -186,6 +256,7 @@ export function computeAxes(
186
256
  label: scales.y.channel.axis?.label,
187
257
  labelStyle: axisLabelStyle,
188
258
  tickLabelStyle,
259
+ tickAngle: scales.y.channel.axis?.tickAngle,
189
260
  start: { x: chartArea.x, y: chartArea.y },
190
261
  end: { x: chartArea.x, y: chartArea.y + chartArea.height },
191
262
  };