@opendata-ai/openchart-engine 6.26.0 → 6.27.2
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.d.ts +37 -1
- package/dist/index.js +206 -21
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +33 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +6 -0
- package/src/__tests__/axes.test.ts +101 -3
- package/src/__tests__/compile-chart.test.ts +301 -0
- package/src/annotations/__tests__/compute.test.ts +175 -0
- package/src/annotations/position.ts +37 -1
- package/src/annotations/resolve-range.ts +5 -5
- package/src/charts/bar/__tests__/compute.test.ts +102 -0
- package/src/charts/bar/compute.ts +1 -0
- package/src/charts/line/area.ts +1 -1
- package/src/charts/line/compute.ts +7 -1
- package/src/compile.ts +175 -4
- package/src/compiler/normalize.ts +26 -0
- package/src/compiler/types.ts +38 -0
- package/src/layout/axes/ticks.ts +31 -4
- package/src/layout/axes.ts +18 -4
- package/src/layout/dimensions.ts +77 -2
- package/src/legend/compute.ts +6 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Annotation, LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
|
|
2
|
+
import type { ScaleBand, ScalePoint } from 'd3-scale';
|
|
2
3
|
import { describe, expect, it } from 'vitest';
|
|
3
4
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
4
5
|
import { computeScales } from '../../layout/scales';
|
|
5
6
|
import { computeAnnotations } from '../compute';
|
|
7
|
+
import { resolvePosition } from '../position';
|
|
6
8
|
|
|
7
9
|
// ---------------------------------------------------------------------------
|
|
8
10
|
// Fixtures
|
|
@@ -236,6 +238,179 @@ describe('computeAnnotations', () => {
|
|
|
236
238
|
// Non-numeric domain can't interpolate, annotation is dropped
|
|
237
239
|
expect(annotations).toHaveLength(0);
|
|
238
240
|
});
|
|
241
|
+
|
|
242
|
+
it('two adjacent point-scale ranges have a non-zero pixel gap between them', () => {
|
|
243
|
+
// Regression: ranges ending/starting at point-scale centers would share the same
|
|
244
|
+
// pixel boundary, visually merging. resolvePositionEdge now extends each range
|
|
245
|
+
// by half a step so adjacent ranges are truly separated.
|
|
246
|
+
const ordinalSpec: NormalizedChartSpec = {
|
|
247
|
+
markType: 'line',
|
|
248
|
+
markDef: { type: 'line' },
|
|
249
|
+
data: [
|
|
250
|
+
{ year: '2006', value: 10 },
|
|
251
|
+
{ year: '2008', value: 20 },
|
|
252
|
+
{ year: '2010', value: 30 },
|
|
253
|
+
{ year: '2012', value: 40 },
|
|
254
|
+
{ year: '2020', value: 50 },
|
|
255
|
+
{ year: '2022', value: 60 },
|
|
256
|
+
],
|
|
257
|
+
encoding: {
|
|
258
|
+
x: { field: 'year', type: 'ordinal' },
|
|
259
|
+
y: { field: 'value', type: 'quantitative' },
|
|
260
|
+
},
|
|
261
|
+
chrome: {},
|
|
262
|
+
annotations: [
|
|
263
|
+
{ type: 'range', x1: '2008', x2: '2010', label: 'First range' },
|
|
264
|
+
{ type: 'range', x1: '2020', x2: '2022', label: 'Second range' },
|
|
265
|
+
],
|
|
266
|
+
responsive: true,
|
|
267
|
+
theme: {},
|
|
268
|
+
darkMode: 'off',
|
|
269
|
+
labels: { density: 'auto', format: '' },
|
|
270
|
+
};
|
|
271
|
+
const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
|
|
272
|
+
const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
|
|
273
|
+
|
|
274
|
+
expect(annotations).toHaveLength(2);
|
|
275
|
+
const rect1 = annotations[0].rect!;
|
|
276
|
+
const rect2 = annotations[1].rect!;
|
|
277
|
+
|
|
278
|
+
// First range must end before second range starts (non-zero gap)
|
|
279
|
+
expect(rect1.x + rect1.width).toBeLessThan(rect2.x);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('single range on point-scale ordinal x covers at least a full step', () => {
|
|
283
|
+
// A range spanning a single domain value should be at least step-wide,
|
|
284
|
+
// since resolvePositionEdge extends by half a step on each side.
|
|
285
|
+
const domainValues = ['2006', '2008', '2010', '2012'];
|
|
286
|
+
const ordinalSpec: NormalizedChartSpec = {
|
|
287
|
+
markType: 'line',
|
|
288
|
+
markDef: { type: 'line' },
|
|
289
|
+
data: domainValues.map((year, i) => ({ year, value: i * 10 })),
|
|
290
|
+
encoding: {
|
|
291
|
+
x: { field: 'year', type: 'ordinal' },
|
|
292
|
+
y: { field: 'value', type: 'quantitative' },
|
|
293
|
+
},
|
|
294
|
+
chrome: {},
|
|
295
|
+
annotations: [{ type: 'range', x1: '2008', x2: '2010', label: 'Single step' }],
|
|
296
|
+
responsive: true,
|
|
297
|
+
theme: {},
|
|
298
|
+
darkMode: 'off',
|
|
299
|
+
labels: { density: 'auto', format: '' },
|
|
300
|
+
};
|
|
301
|
+
const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
|
|
302
|
+
const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
|
|
303
|
+
|
|
304
|
+
expect(annotations).toHaveLength(1);
|
|
305
|
+
const rect = annotations[0].rect!;
|
|
306
|
+
|
|
307
|
+
// step = chartArea.width / (numPoints - 1) roughly. With 4 points and default 0.5 padding,
|
|
308
|
+
// the step on a point scale = width / (n - 1 + 2*padding) -- but the key property is that
|
|
309
|
+
// the range width should be at least as wide as the distance between two domain centers.
|
|
310
|
+
const xScale = scales.x!.scale as ScalePoint<string>;
|
|
311
|
+
const step = xScale.step();
|
|
312
|
+
expect(rect.width).toBeGreaterThanOrEqual(step);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('band-scale (bar chart) range covers full bands, not band centers', () => {
|
|
316
|
+
// On a bar chart the x scale is a band scale. resolvePositionEdge extends from the
|
|
317
|
+
// center (what resolvePosition returns) to the band edge.
|
|
318
|
+
const barSpec: NormalizedChartSpec = {
|
|
319
|
+
markType: 'bar',
|
|
320
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
321
|
+
data: [
|
|
322
|
+
{ year: '2008', value: 10 },
|
|
323
|
+
{ year: '2010', value: 20 },
|
|
324
|
+
{ year: '2012', value: 30 },
|
|
325
|
+
{ year: '2014', value: 40 },
|
|
326
|
+
],
|
|
327
|
+
encoding: {
|
|
328
|
+
x: { field: 'year', type: 'ordinal' },
|
|
329
|
+
y: { field: 'value', type: 'quantitative' },
|
|
330
|
+
},
|
|
331
|
+
chrome: {},
|
|
332
|
+
annotations: [{ type: 'range', x1: '2010', x2: '2012', label: 'Band range' }],
|
|
333
|
+
responsive: true,
|
|
334
|
+
theme: {},
|
|
335
|
+
darkMode: 'off',
|
|
336
|
+
labels: { density: 'auto', format: '' },
|
|
337
|
+
};
|
|
338
|
+
const scales = computeScales(barSpec, chartArea, barSpec.data);
|
|
339
|
+
const annotations = computeAnnotations(barSpec, scales, chartArea, fullStrategy);
|
|
340
|
+
|
|
341
|
+
expect(annotations).toHaveLength(1);
|
|
342
|
+
const rect = annotations[0].rect!;
|
|
343
|
+
|
|
344
|
+
const bandScale = scales.x!.scale as ScaleBand<string>;
|
|
345
|
+
const bandwidth = bandScale.bandwidth();
|
|
346
|
+
const x1BandStart = bandScale('2010')!;
|
|
347
|
+
const x2BandStart = bandScale('2012')!;
|
|
348
|
+
|
|
349
|
+
// Left edge should be at the start of the 2010 band (not the center)
|
|
350
|
+
expect(rect.x).toBeCloseTo(x1BandStart, 1);
|
|
351
|
+
// Right edge should be at the end of the 2012 band
|
|
352
|
+
expect(rect.x + rect.width).toBeCloseTo(x2BandStart + bandwidth, 1);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('linear-scale range is unaffected by edge extension', () => {
|
|
356
|
+
// For linear scales, resolvePositionEdge is identical to resolvePosition.
|
|
357
|
+
// This ensures the fix doesn't introduce any drift on continuous axes.
|
|
358
|
+
const linearSpec = makeSpec([{ type: 'range', x1: '2020-01-01', x2: '2021-01-01' }]);
|
|
359
|
+
const scales = computeScales(linearSpec, chartArea, linearSpec.data);
|
|
360
|
+
const annotations = computeAnnotations(linearSpec, scales, chartArea, fullStrategy);
|
|
361
|
+
|
|
362
|
+
expect(annotations).toHaveLength(1);
|
|
363
|
+
const rect = annotations[0].rect!;
|
|
364
|
+
|
|
365
|
+
// The x positions should exactly match what resolvePosition would return
|
|
366
|
+
const x1Expected = resolvePosition('2020-01-01', scales.x)!;
|
|
367
|
+
const x2Expected = resolvePosition('2021-01-01', scales.x)!;
|
|
368
|
+
|
|
369
|
+
expect(rect.x).toBeCloseTo(Math.min(x1Expected, x2Expected), 1);
|
|
370
|
+
expect(rect.x + rect.width).toBeCloseTo(Math.max(x1Expected, x2Expected), 1);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('y1/y2 range on ordinal point-scale y-axis has a non-zero pixel gap between two annotations', () => {
|
|
374
|
+
// Horizontal band: y-axis is ordinal (point scale), x-axis is quantitative.
|
|
375
|
+
// Two y-range annotations with a gap should produce two distinct rects.
|
|
376
|
+
const ordinalYSpec: NormalizedChartSpec = {
|
|
377
|
+
markType: 'line',
|
|
378
|
+
markDef: { type: 'line' },
|
|
379
|
+
data: [
|
|
380
|
+
{ year: '2006', value: 10 },
|
|
381
|
+
{ year: '2008', value: 20 },
|
|
382
|
+
{ year: '2010', value: 30 },
|
|
383
|
+
{ year: '2015', value: 40 },
|
|
384
|
+
{ year: '2020', value: 50 },
|
|
385
|
+
{ year: '2022', value: 60 },
|
|
386
|
+
],
|
|
387
|
+
encoding: {
|
|
388
|
+
x: { field: 'value', type: 'quantitative' },
|
|
389
|
+
y: { field: 'year', type: 'ordinal' },
|
|
390
|
+
},
|
|
391
|
+
chrome: {},
|
|
392
|
+
annotations: [
|
|
393
|
+
{ type: 'range', y1: '2006', y2: '2008' },
|
|
394
|
+
{ type: 'range', y1: '2020', y2: '2022' },
|
|
395
|
+
],
|
|
396
|
+
responsive: true,
|
|
397
|
+
theme: {},
|
|
398
|
+
darkMode: 'off',
|
|
399
|
+
labels: { density: 'auto', format: '' },
|
|
400
|
+
};
|
|
401
|
+
const scales = computeScales(ordinalYSpec, chartArea, ordinalYSpec.data);
|
|
402
|
+
const annotations = computeAnnotations(ordinalYSpec, scales, chartArea, fullStrategy);
|
|
403
|
+
|
|
404
|
+
expect(annotations).toHaveLength(2);
|
|
405
|
+
const rects = annotations.map((a) => a.rect!);
|
|
406
|
+
|
|
407
|
+
// In SVG, y increases downward. Sort by y so rect[0] is the top one.
|
|
408
|
+
rects.sort((a, b) => a.y - b.y);
|
|
409
|
+
|
|
410
|
+
// There must be a non-zero pixel gap between the bottom of rect[0] and top of rect[1]
|
|
411
|
+
const bottomOfFirst = rects[0].y + rects[0].height;
|
|
412
|
+
expect(bottomOfFirst).toBeLessThan(rects[1].y);
|
|
413
|
+
});
|
|
239
414
|
});
|
|
240
415
|
|
|
241
416
|
describe('reference line annotations', () => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Data-coordinate to pixel-coordinate resolution for annotations.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { ScaleBand, ScaleLinear, ScaleTime } from 'd3-scale';
|
|
5
|
+
import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
|
|
6
6
|
import type { ResolvedScales } from '../layout/scales';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -93,3 +93,39 @@ export function resolvePosition(
|
|
|
93
93
|
|
|
94
94
|
return null;
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Like resolvePosition, but extends the result to the visual edge of the column
|
|
99
|
+
* for band and point scales. Used by range annotations to span complete columns.
|
|
100
|
+
*
|
|
101
|
+
* For point scales: shifts by ±step/2 using scalePoint.step()
|
|
102
|
+
* For band scales: shifts from center (what resolvePosition returns) to the band edge
|
|
103
|
+
* For all other scales: identical to resolvePosition
|
|
104
|
+
*/
|
|
105
|
+
export function resolvePositionEdge(
|
|
106
|
+
value: string | number,
|
|
107
|
+
scale: ResolvedScales['x'] | ResolvedScales['y'],
|
|
108
|
+
edge: 'start' | 'end',
|
|
109
|
+
): number | null {
|
|
110
|
+
const center = resolvePosition(value, scale);
|
|
111
|
+
if (center === null || !scale) return null;
|
|
112
|
+
|
|
113
|
+
const type = scale.type;
|
|
114
|
+
|
|
115
|
+
if (type === 'point') {
|
|
116
|
+
const s = scale.scale as ScalePoint<string>;
|
|
117
|
+
const halfStep = (s.step?.() ?? 0) / 2;
|
|
118
|
+
return edge === 'start' ? center - halfStep : center + halfStep;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (type === 'band') {
|
|
122
|
+
// center = bandStart + bandwidth/2
|
|
123
|
+
// edge 'start' => bandStart = center - bandwidth/2
|
|
124
|
+
// edge 'end' => bandEnd = center + bandwidth/2
|
|
125
|
+
const s = scale.scale as ScaleBand<string>;
|
|
126
|
+
const halfBw = (s.bandwidth?.() ?? 0) / 2;
|
|
127
|
+
return edge === 'start' ? center - halfBw : center + halfBw;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return center;
|
|
131
|
+
}
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
import type { ResolvedScales } from '../layout/scales';
|
|
13
13
|
import { DEFAULT_RANGE_FILL, DEFAULT_RANGE_OPACITY } from './constants';
|
|
14
14
|
import { applyOffset } from './geometry';
|
|
15
|
-
import {
|
|
15
|
+
import { resolvePositionEdge } from './position';
|
|
16
16
|
import { makeAnnotationLabelStyle } from './resolve-text';
|
|
17
17
|
|
|
18
18
|
export function resolveRangeAnnotation(
|
|
@@ -28,8 +28,8 @@ export function resolveRangeAnnotation(
|
|
|
28
28
|
|
|
29
29
|
// X-range (vertical band)
|
|
30
30
|
if (annotation.x1 !== undefined && annotation.x2 !== undefined) {
|
|
31
|
-
const x1px =
|
|
32
|
-
const x2px =
|
|
31
|
+
const x1px = resolvePositionEdge(annotation.x1, scales.x, 'start');
|
|
32
|
+
const x2px = resolvePositionEdge(annotation.x2, scales.x, 'end');
|
|
33
33
|
if (x1px === null || x2px === null) return null;
|
|
34
34
|
|
|
35
35
|
x = Math.min(x1px, x2px);
|
|
@@ -38,8 +38,8 @@ export function resolveRangeAnnotation(
|
|
|
38
38
|
|
|
39
39
|
// Y-range (horizontal band)
|
|
40
40
|
if (annotation.y1 !== undefined && annotation.y2 !== undefined) {
|
|
41
|
-
const y1px =
|
|
42
|
-
const y2px =
|
|
41
|
+
const y1px = resolvePositionEdge(annotation.y1, scales.y, 'end');
|
|
42
|
+
const y2px = resolvePositionEdge(annotation.y2, scales.y, 'start');
|
|
43
43
|
if (y1px === null || y2px === null) return null;
|
|
44
44
|
|
|
45
45
|
y = Math.min(y1px, y2px);
|
|
@@ -386,6 +386,108 @@ describe('computeBarMarks', () => {
|
|
|
386
386
|
});
|
|
387
387
|
});
|
|
388
388
|
|
|
389
|
+
describe('stacked vs grouped (wage data reproduction)', () => {
|
|
390
|
+
// 2 years × 2 firm-size categories — the canonical grouped-bar use case
|
|
391
|
+
const wageData = [
|
|
392
|
+
{ size: '<5 employees', year: '2018', pay: 48200 },
|
|
393
|
+
{ size: '<5 employees', year: '2022', pay: 56400 },
|
|
394
|
+
{ size: '5,000+ employees', year: '2018', pay: 62300 },
|
|
395
|
+
{ size: '5,000+ employees', year: '2022', pay: 74800 },
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
function makeWageSpec(stackNull = false): NormalizedChartSpec {
|
|
399
|
+
return {
|
|
400
|
+
markType: 'bar',
|
|
401
|
+
markDef: { type: 'bar' },
|
|
402
|
+
data: wageData,
|
|
403
|
+
encoding: {
|
|
404
|
+
x: {
|
|
405
|
+
field: 'pay',
|
|
406
|
+
type: 'quantitative',
|
|
407
|
+
...(stackNull ? { stack: null } : {}),
|
|
408
|
+
},
|
|
409
|
+
y: { field: 'size', type: 'nominal' },
|
|
410
|
+
color: { field: 'year', type: 'nominal' },
|
|
411
|
+
},
|
|
412
|
+
chrome: {},
|
|
413
|
+
annotations: [],
|
|
414
|
+
responsive: true,
|
|
415
|
+
theme: {},
|
|
416
|
+
darkMode: 'off',
|
|
417
|
+
labels: { density: 'auto', format: '' },
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
it('stacks by default: segments are contiguous end-to-end within each category', () => {
|
|
422
|
+
const spec = makeWageSpec(false);
|
|
423
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
424
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
425
|
+
|
|
426
|
+
// 2 firm sizes × 2 years = 4 bars
|
|
427
|
+
expect(marks).toHaveLength(4);
|
|
428
|
+
|
|
429
|
+
// For stacked bars, the second segment starts exactly where the first ends.
|
|
430
|
+
const smallFirmMarks = marks
|
|
431
|
+
.filter((m) => m.aria.label.includes('<5'))
|
|
432
|
+
.sort((a, b) => a.x - b.x);
|
|
433
|
+
expect(smallFirmMarks).toHaveLength(2);
|
|
434
|
+
expect(smallFirmMarks[1].x).toBeCloseTo(smallFirmMarks[0].x + smallFirmMarks[0].width, 1);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('stacks by default: segments share the same y position (stacked on same row)', () => {
|
|
438
|
+
const spec = makeWageSpec(false);
|
|
439
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
440
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
441
|
+
|
|
442
|
+
const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
|
|
443
|
+
expect(smallFirmMarks).toHaveLength(2);
|
|
444
|
+
expect(smallFirmMarks[0].y).toBe(smallFirmMarks[1].y);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('grouped with stack:null: bar widths match individual pay values (not cumulative)', () => {
|
|
448
|
+
const stackedSpec = makeWageSpec(false);
|
|
449
|
+
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
450
|
+
const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
451
|
+
|
|
452
|
+
const groupedSpec = makeWageSpec(true);
|
|
453
|
+
const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
|
|
454
|
+
const groupedMarks = computeBarMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
|
|
455
|
+
|
|
456
|
+
expect(groupedMarks).toHaveLength(4);
|
|
457
|
+
|
|
458
|
+
// Grouped bars each start from the baseline (same x for both years within a firm size)
|
|
459
|
+
const smallFirmGrouped = groupedMarks.filter((m) => m.aria.label.includes('<5'));
|
|
460
|
+
expect(smallFirmGrouped[0].x).toBe(smallFirmGrouped[1].x);
|
|
461
|
+
|
|
462
|
+
// Stacked bars for the same category have different x positions (end-to-end)
|
|
463
|
+
const smallFirmStacked = stackedMarks.filter((m) => m.aria.label.includes('<5'));
|
|
464
|
+
expect(smallFirmStacked[0].x).not.toBe(smallFirmStacked[1].x);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('grouped with stack:null: bars sit at different y positions within each category', () => {
|
|
468
|
+
const spec = makeWageSpec(true);
|
|
469
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
470
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
471
|
+
|
|
472
|
+
const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
|
|
473
|
+
expect(smallFirmMarks).toHaveLength(2);
|
|
474
|
+
expect(smallFirmMarks[0].y).not.toBe(smallFirmMarks[1].y);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('grouped with stack:null: scale domain covers max individual value, not stacked sum', () => {
|
|
478
|
+
const spec = makeWageSpec(true);
|
|
479
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
480
|
+
|
|
481
|
+
// Max individual pay is 74800. Stacked sum for 5000+ employees = 62300 + 74800 = 137100.
|
|
482
|
+
// With stack:null the domain should NOT reach 137100.
|
|
483
|
+
const xScale = scales.x!.scale;
|
|
484
|
+
const domain = xScale.domain() as number[];
|
|
485
|
+
expect(domain[1]).toBeLessThan(137100);
|
|
486
|
+
// But it should cover the max individual value
|
|
487
|
+
expect(domain[1]).toBeGreaterThanOrEqual(74800);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
389
491
|
describe('edge cases', () => {
|
|
390
492
|
it('returns empty array when no x encoding', () => {
|
|
391
493
|
const spec: NormalizedChartSpec = {
|
|
@@ -118,6 +118,7 @@ export function computeBarMarks(
|
|
|
118
118
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
119
119
|
|
|
120
120
|
if (needsStacking) {
|
|
121
|
+
// stack: null or false -> grouped (side-by-side) bars
|
|
121
122
|
const stackDisabled = xChannel.stack === null || xChannel.stack === false;
|
|
122
123
|
|
|
123
124
|
if (stackDisabled) {
|
package/src/charts/line/area.ts
CHANGED
|
@@ -151,7 +151,7 @@ function computeSingleArea(
|
|
|
151
151
|
fill: fillValue,
|
|
152
152
|
fillOpacity: fillOpacity,
|
|
153
153
|
stroke: getRepresentativeColor(isGradientDef(fillValue) ? color : fillValue),
|
|
154
|
-
strokeWidth: 2,
|
|
154
|
+
strokeWidth: spec.display === 'sparkline' ? 1.25 : 2,
|
|
155
155
|
seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
|
|
156
156
|
data: validPoints.map((p) => p.row),
|
|
157
157
|
dataPoints: validPoints.map((p) => ({ x: p.x, y: p.yTop, datum: p.row })),
|
|
@@ -32,6 +32,10 @@ import { resolveCurve } from './curves';
|
|
|
32
32
|
/** Default stroke width for line marks. */
|
|
33
33
|
const DEFAULT_STROKE_WIDTH = 2.5;
|
|
34
34
|
|
|
35
|
+
/** Sparkline mode uses a thinner stroke since the chart area is tiny and a
|
|
36
|
+
* 2.5px line reads as clunky. 1.25px keeps the trend legible without dominating. */
|
|
37
|
+
const SPARKLINE_STROKE_WIDTH = 1.25;
|
|
38
|
+
|
|
35
39
|
/** Default radius for point marks (hover targets). */
|
|
36
40
|
const DEFAULT_POINT_RADIUS = 3;
|
|
37
41
|
|
|
@@ -174,7 +178,9 @@ export function computeLineMarks(
|
|
|
174
178
|
points: allPoints,
|
|
175
179
|
path: combinedPath,
|
|
176
180
|
stroke: strokeColor,
|
|
177
|
-
strokeWidth:
|
|
181
|
+
strokeWidth:
|
|
182
|
+
styleOverride?.strokeWidth ??
|
|
183
|
+
(spec.display === 'sparkline' ? SPARKLINE_STROKE_WIDTH : DEFAULT_STROKE_WIDTH),
|
|
178
184
|
strokeDasharray,
|
|
179
185
|
opacity: styleOverride?.opacity,
|
|
180
186
|
seriesKey: seriesStyleKey,
|
package/src/compile.ts
CHANGED
|
@@ -199,7 +199,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
199
199
|
|
|
200
200
|
// Resolve watermark: explicit spec value wins, then options fallback, then default true.
|
|
201
201
|
const rawWatermark = (expandedSpec as Record<string, unknown>).watermark;
|
|
202
|
-
|
|
202
|
+
let watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true);
|
|
203
203
|
|
|
204
204
|
// Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
|
|
205
205
|
// Transforms are defined on the expanded spec (which includes any auto-generated
|
|
@@ -223,10 +223,49 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
223
223
|
| Partial<
|
|
224
224
|
Record<
|
|
225
225
|
string,
|
|
226
|
-
{
|
|
226
|
+
{
|
|
227
|
+
chrome?: unknown;
|
|
228
|
+
labels?: unknown;
|
|
229
|
+
legend?: unknown;
|
|
230
|
+
annotations?: unknown;
|
|
231
|
+
animation?: unknown;
|
|
232
|
+
display?: unknown;
|
|
233
|
+
encoding?: unknown;
|
|
234
|
+
watermark?: unknown;
|
|
235
|
+
crosshair?: unknown;
|
|
236
|
+
}
|
|
227
237
|
>
|
|
228
238
|
>
|
|
229
239
|
| undefined;
|
|
240
|
+
|
|
241
|
+
// Build userExplicit descriptor BEFORE applying any overrides so we capture
|
|
242
|
+
// the union of "user wrote this at top-level" and "user wrote this in the
|
|
243
|
+
// active breakpoint override." Sparkline display mode reads this to decide
|
|
244
|
+
// whether to suppress chrome/axes/legend/etc. by default vs. respecting an
|
|
245
|
+
// explicit user opt-in. Precedence: explicit at any level wins.
|
|
246
|
+
const rawEncoding = rawSpec.encoding as
|
|
247
|
+
| { x?: { axis?: unknown }; y?: { axis?: unknown } }
|
|
248
|
+
| undefined;
|
|
249
|
+
const bpForExplicit = overrides?.[breakpoint];
|
|
250
|
+
const bpEncoding = bpForExplicit?.encoding as
|
|
251
|
+
| { x?: { axis?: unknown }; y?: { axis?: unknown } }
|
|
252
|
+
| undefined;
|
|
253
|
+
// chrome: {} (empty object) is not "explicit" — it's an idiom users write to
|
|
254
|
+
// silence defaults. Require at least one chrome key set to count as opt-in.
|
|
255
|
+
const hasChromeKeys = (v: unknown): boolean =>
|
|
256
|
+
!!v && typeof v === 'object' && Object.keys(v as Record<string, unknown>).length > 0;
|
|
257
|
+
const userExplicit = {
|
|
258
|
+
chrome: hasChromeKeys(rawSpec.chrome) || hasChromeKeys(bpForExplicit?.chrome),
|
|
259
|
+
legend: rawSpec.legend !== undefined || bpForExplicit?.legend !== undefined,
|
|
260
|
+
xAxis: rawEncoding?.x?.axis !== undefined || bpEncoding?.x?.axis !== undefined,
|
|
261
|
+
yAxis: rawEncoding?.y?.axis !== undefined || bpEncoding?.y?.axis !== undefined,
|
|
262
|
+
labels: rawSpec.labels !== undefined || bpForExplicit?.labels !== undefined,
|
|
263
|
+
animation: rawSpec.animation !== undefined || bpForExplicit?.animation !== undefined,
|
|
264
|
+
watermark: rawSpec.watermark !== undefined || bpForExplicit?.watermark !== undefined,
|
|
265
|
+
crosshair: rawSpec.crosshair !== undefined || bpForExplicit?.crosshair !== undefined,
|
|
266
|
+
};
|
|
267
|
+
chartSpec = { ...chartSpec, userExplicit };
|
|
268
|
+
|
|
230
269
|
if (overrides?.[breakpoint]) {
|
|
231
270
|
const bp = overrides[breakpoint]!;
|
|
232
271
|
if (bp.chrome) {
|
|
@@ -274,14 +313,138 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
274
313
|
// responsive strategy so they render inline instead of being stripped.
|
|
275
314
|
strategy = { ...strategy, annotationPosition: 'inline' };
|
|
276
315
|
}
|
|
316
|
+
// New override branches for sparkline mode and related fields:
|
|
317
|
+
if (bp.display !== undefined) {
|
|
318
|
+
chartSpec = {
|
|
319
|
+
...chartSpec,
|
|
320
|
+
display: bp.display as NormalizedChartSpec['display'],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (bp.encoding !== undefined) {
|
|
324
|
+
// Merge encoding so a breakpoint can flip on/off encoding.x.axis or
|
|
325
|
+
// encoding.y.axis (used by sparkline display mode to opt back in to
|
|
326
|
+
// axes at a specific breakpoint). Channels merge per-key, and `axis`
|
|
327
|
+
// and `scale` deep-merge one level so a breakpoint can set
|
|
328
|
+
// `axis: { title: 'foo' }` without dropping the base spec's
|
|
329
|
+
// `axis.tickCount` / `axis.format`.
|
|
330
|
+
const bpEnc = bp.encoding as Record<string, Record<string, unknown> | undefined>;
|
|
331
|
+
const mergedEncoding = { ...chartSpec.encoding } as Record<
|
|
332
|
+
string,
|
|
333
|
+
Record<string, unknown> | undefined
|
|
334
|
+
>;
|
|
335
|
+
const NESTED_CHANNEL_KEYS = ['axis', 'scale'];
|
|
336
|
+
for (const channel of Object.keys(bpEnc)) {
|
|
337
|
+
const baseCh = mergedEncoding[channel];
|
|
338
|
+
const bpCh = bpEnc[channel];
|
|
339
|
+
if (bpCh && baseCh) {
|
|
340
|
+
const merged: Record<string, unknown> = { ...baseCh, ...bpCh };
|
|
341
|
+
for (const key of NESTED_CHANNEL_KEYS) {
|
|
342
|
+
const baseNested = baseCh[key];
|
|
343
|
+
const bpNested = bpCh[key];
|
|
344
|
+
if (
|
|
345
|
+
baseNested &&
|
|
346
|
+
bpNested &&
|
|
347
|
+
typeof baseNested === 'object' &&
|
|
348
|
+
typeof bpNested === 'object' &&
|
|
349
|
+
!Array.isArray(baseNested) &&
|
|
350
|
+
!Array.isArray(bpNested)
|
|
351
|
+
) {
|
|
352
|
+
merged[key] = { ...baseNested, ...bpNested };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
mergedEncoding[channel] = merged;
|
|
356
|
+
} else if (bpCh) {
|
|
357
|
+
mergedEncoding[channel] = bpCh;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
chartSpec = {
|
|
361
|
+
...chartSpec,
|
|
362
|
+
encoding: mergedEncoding as unknown as NormalizedChartSpec['encoding'],
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (typeof bp.watermark === 'boolean') {
|
|
366
|
+
// Update the resolved watermark value used downstream. ChartSpec carries
|
|
367
|
+
// this in its normalized shape; the local `watermark` variable controls
|
|
368
|
+
// chrome computation and rendering.
|
|
369
|
+
watermark = bp.watermark;
|
|
370
|
+
chartSpec = { ...chartSpec, watermark };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Sparkline mode: default labels off. Mark renderers draw value labels per
|
|
375
|
+
// labels.density (default 'auto'), which fills tiny sparklines with text and
|
|
376
|
+
// is never what you want. Explicit user labels at any level wins via
|
|
377
|
+
// userExplicit.labels.
|
|
378
|
+
if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.labels) {
|
|
379
|
+
chartSpec = {
|
|
380
|
+
...chartSpec,
|
|
381
|
+
labels: { ...chartSpec.labels, density: 'none' },
|
|
382
|
+
};
|
|
277
383
|
}
|
|
278
384
|
|
|
279
385
|
// Resolve animation spec. Breakpoint override wins over base spec (matching
|
|
280
386
|
// chrome, labels, legend, and annotation override precedence).
|
|
281
|
-
|
|
387
|
+
// Precedence rule for sparkline mode: an explicit user animation at ANY
|
|
388
|
+
// level (top-level OR breakpoint) always wins, regardless of display mode.
|
|
389
|
+
// resolveAnimation handles the explicit-user value; the sparkline default-off
|
|
390
|
+
// behavior is applied below when no explicit value exists.
|
|
391
|
+
let rawAnimationSpec = ((overrides?.[breakpoint] as Record<string, unknown> | undefined)
|
|
282
392
|
?.animation ?? rawSpec.animation) as AnimationSpec | undefined;
|
|
393
|
+
if (rawAnimationSpec === undefined && chartSpec.display === 'sparkline') {
|
|
394
|
+
// Sparkline mode: animation defaults to false. User-explicit (top OR bp)
|
|
395
|
+
// already short-circuits this branch via userExplicit.animation.
|
|
396
|
+
rawAnimationSpec = false;
|
|
397
|
+
}
|
|
398
|
+
// Sparkline mode: when animation is on but the user didn't specify duration,
|
|
399
|
+
// bump to 1100ms so the line/area reveal feels paced rather than mechanical.
|
|
400
|
+
// The CSS override pairs this with an expo-out easing curve. AnimationConfig
|
|
401
|
+
// nests duration under `enter`, so we set it there.
|
|
402
|
+
if (
|
|
403
|
+
chartSpec.display === 'sparkline' &&
|
|
404
|
+
rawAnimationSpec !== false &&
|
|
405
|
+
rawAnimationSpec !== undefined
|
|
406
|
+
) {
|
|
407
|
+
const SPARK_DURATION = 1100;
|
|
408
|
+
if (rawAnimationSpec === true) {
|
|
409
|
+
rawAnimationSpec = { enter: { duration: SPARK_DURATION } } as AnimationSpec;
|
|
410
|
+
} else if (typeof rawAnimationSpec === 'object') {
|
|
411
|
+
const cfg = rawAnimationSpec as { enter?: unknown; annotationDelay?: number };
|
|
412
|
+
const enter = cfg.enter;
|
|
413
|
+
if (enter === undefined || enter === true) {
|
|
414
|
+
rawAnimationSpec = {
|
|
415
|
+
...cfg,
|
|
416
|
+
enter: { duration: SPARK_DURATION },
|
|
417
|
+
} as AnimationSpec;
|
|
418
|
+
} else if (
|
|
419
|
+
typeof enter === 'object' &&
|
|
420
|
+
enter !== null &&
|
|
421
|
+
(enter as { duration?: number }).duration === undefined
|
|
422
|
+
) {
|
|
423
|
+
rawAnimationSpec = {
|
|
424
|
+
...cfg,
|
|
425
|
+
enter: { ...(enter as object), duration: SPARK_DURATION },
|
|
426
|
+
} as AnimationSpec;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
283
430
|
const resolvedAnimation = resolveAnimation(rawAnimationSpec);
|
|
284
431
|
|
|
432
|
+
// Crosshair: explicit user value at any level wins. In sparkline mode the
|
|
433
|
+
// default is off, otherwise default is off too (crosshair is opt-in). The
|
|
434
|
+
// value is plumbed through ChartLayout so the renderer doesn't need to
|
|
435
|
+
// re-inspect the raw spec.
|
|
436
|
+
const rawCrosshair = (bpForExplicit?.crosshair ?? rawSpec.crosshair) as boolean | undefined;
|
|
437
|
+
const crosshair =
|
|
438
|
+
chartSpec.display === 'sparkline' && !chartSpec.userExplicit.crosshair
|
|
439
|
+
? false
|
|
440
|
+
: rawCrosshair === true;
|
|
441
|
+
|
|
442
|
+
// Watermark default-off in sparkline mode unless user-explicit.
|
|
443
|
+
if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.watermark) {
|
|
444
|
+
watermark = false;
|
|
445
|
+
chartSpec = { ...chartSpec, watermark: false };
|
|
446
|
+
}
|
|
447
|
+
|
|
285
448
|
// Resolve theme: merge spec-level theme with options-level overrides
|
|
286
449
|
const mergedThemeConfig = options.theme
|
|
287
450
|
? { ...chartSpec.theme, ...options.theme }
|
|
@@ -365,12 +528,18 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
365
528
|
// Arc charts (pie/donut) don't use axes or gridlines
|
|
366
529
|
const isRadial = chartSpec.markType === 'arc';
|
|
367
530
|
|
|
368
|
-
// Compute axes (skip for radial charts)
|
|
531
|
+
// Compute axes (skip for radial charts).
|
|
532
|
+
// Sparkline mode skips axes by default unless the user explicitly opted into
|
|
533
|
+
// an axis on a specific channel.
|
|
534
|
+
const skipX = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.xAxis;
|
|
535
|
+
const skipY = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.yAxis;
|
|
369
536
|
const axes = isRadial
|
|
370
537
|
? { x: undefined, y: undefined }
|
|
371
538
|
: computeAxes(scales, chartArea, strategy, theme, options.measureText, {
|
|
372
539
|
data: renderSpec.data,
|
|
373
540
|
encoding: renderSpec.encoding as Encoding,
|
|
541
|
+
skipX,
|
|
542
|
+
skipY,
|
|
374
543
|
});
|
|
375
544
|
|
|
376
545
|
// INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
|
|
@@ -464,6 +633,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
464
633
|
},
|
|
465
634
|
animation: resolvedAnimation,
|
|
466
635
|
watermark,
|
|
636
|
+
display: chartSpec.display,
|
|
637
|
+
crosshair,
|
|
467
638
|
measureText: options.measureText,
|
|
468
639
|
};
|
|
469
640
|
}
|