@opendata-ai/openchart-engine 6.27.0 → 6.28.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 +38 -6
- package/dist/index.js +1040 -521
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +31 -4
- package/src/__tests__/axes.test.ts +101 -3
- package/src/__tests__/legend.test.ts +2 -2
- 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/barlist/__tests__/compile-barlist.test.ts +200 -0
- package/src/barlist/compile-barlist.ts +380 -0
- package/src/barlist/types.ts +28 -0
- package/src/charts/bar/__tests__/compute.test.ts +222 -0
- package/src/charts/bar/compute.ts +77 -44
- package/src/charts/bar/index.ts +1 -0
- package/src/charts/bar/labels.ts +3 -2
- package/src/charts/column/compute.ts +60 -27
- package/src/charts/column/index.ts +1 -0
- package/src/charts/column/labels.ts +2 -1
- package/src/charts/line/__tests__/compute.test.ts +2 -2
- package/src/charts/line/area.ts +25 -4
- package/src/charts/line/compute.ts +15 -5
- package/src/compile.ts +26 -1
- package/src/compiler/normalize.ts +25 -1
- package/src/compiler/types.ts +5 -3
- package/src/compiler/validate.ts +120 -5
- package/src/index.ts +5 -0
- package/src/layout/axes/ticks.ts +37 -8
- package/src/layout/axes.ts +11 -4
- package/src/layout/dimensions.ts +10 -4
- package/src/layout/scales.ts +10 -0
- package/src/legend/wrap.ts +1 -1
- package/src/tilemap/__tests__/compile-tilemap.test.ts +5 -2
- package/src/tilemap/compile-tilemap.ts +41 -29
- package/src/tooltips/compute.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.28.2",
|
|
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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"typecheck": "tsc --noEmit"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@opendata-ai/openchart-core": "6.
|
|
51
|
+
"@opendata-ai/openchart-core": "6.28.2",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -413,6 +413,15 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
|
|
|
413
413
|
"#756bb1",
|
|
414
414
|
"#54278f",
|
|
415
415
|
],
|
|
416
|
+
"teal": [
|
|
417
|
+
"#06b6d4",
|
|
418
|
+
"#05a3be",
|
|
419
|
+
"#0490a8",
|
|
420
|
+
"#037d92",
|
|
421
|
+
"#026a7c",
|
|
422
|
+
"#015766",
|
|
423
|
+
"#004450",
|
|
424
|
+
],
|
|
416
425
|
},
|
|
417
426
|
"text": "#1d1d1d",
|
|
418
427
|
},
|
|
@@ -890,7 +899,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
|
|
|
890
899
|
"seriesKey": "US",
|
|
891
900
|
"stroke": "#1b7fa3",
|
|
892
901
|
"strokeDasharray": undefined,
|
|
893
|
-
"strokeWidth":
|
|
902
|
+
"strokeWidth": 1.5,
|
|
894
903
|
"type": "line",
|
|
895
904
|
},
|
|
896
905
|
{
|
|
@@ -998,7 +1007,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
|
|
|
998
1007
|
"seriesKey": "UK",
|
|
999
1008
|
"stroke": "#c44e52",
|
|
1000
1009
|
"strokeDasharray": undefined,
|
|
1001
|
-
"strokeWidth":
|
|
1010
|
+
"strokeWidth": 1.5,
|
|
1002
1011
|
"type": "line",
|
|
1003
1012
|
},
|
|
1004
1013
|
{
|
|
@@ -1106,7 +1115,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
|
|
|
1106
1115
|
"seriesKey": "FR",
|
|
1107
1116
|
"stroke": "#6a9f58",
|
|
1108
1117
|
"strokeDasharray": undefined,
|
|
1109
|
-
"strokeWidth":
|
|
1118
|
+
"strokeWidth": 1.5,
|
|
1110
1119
|
"type": "line",
|
|
1111
1120
|
},
|
|
1112
1121
|
{
|
|
@@ -1214,7 +1223,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
|
|
|
1214
1223
|
"seriesKey": "DE",
|
|
1215
1224
|
"stroke": "#d47215",
|
|
1216
1225
|
"strokeDasharray": undefined,
|
|
1217
|
-
"strokeWidth":
|
|
1226
|
+
"strokeWidth": 1.5,
|
|
1218
1227
|
"type": "line",
|
|
1219
1228
|
},
|
|
1220
1229
|
],
|
|
@@ -1323,6 +1332,15 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
|
|
|
1323
1332
|
"#756bb1",
|
|
1324
1333
|
"#54278f",
|
|
1325
1334
|
],
|
|
1335
|
+
"teal": [
|
|
1336
|
+
"#06b6d4",
|
|
1337
|
+
"#05a3be",
|
|
1338
|
+
"#0490a8",
|
|
1339
|
+
"#037d92",
|
|
1340
|
+
"#026a7c",
|
|
1341
|
+
"#015766",
|
|
1342
|
+
"#004450",
|
|
1343
|
+
],
|
|
1326
1344
|
},
|
|
1327
1345
|
"text": "#1d1d1d",
|
|
1328
1346
|
},
|
|
@@ -1891,6 +1909,15 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
|
|
|
1891
1909
|
"#756bb1",
|
|
1892
1910
|
"#54278f",
|
|
1893
1911
|
],
|
|
1912
|
+
"teal": [
|
|
1913
|
+
"#06b6d4",
|
|
1914
|
+
"#05a3be",
|
|
1915
|
+
"#0490a8",
|
|
1916
|
+
"#037d92",
|
|
1917
|
+
"#026a7c",
|
|
1918
|
+
"#015766",
|
|
1919
|
+
"#004450",
|
|
1920
|
+
],
|
|
1894
1921
|
},
|
|
1895
1922
|
"text": "#1d1d1d",
|
|
1896
1923
|
},
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { AxisTick, LayoutStrategy } from '@opendata-ai/openchart-core';
|
|
2
2
|
import { resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
|
+
import { scaleLinear, scaleLog } from 'd3-scale';
|
|
3
4
|
import { describe, expect, it } from 'vitest';
|
|
4
5
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
5
6
|
import { computeAxes, effectiveDensity, thinTicksUntilFit, ticksOverlap } from '../layout/axes';
|
|
7
|
+
import { buildContinuousTicks } from '../layout/axes/ticks';
|
|
8
|
+
import type { ResolvedScale } from '../layout/scales';
|
|
6
9
|
import { computeScales } from '../layout/scales';
|
|
7
10
|
|
|
8
11
|
const lineSpec: NormalizedChartSpec = {
|
|
@@ -121,16 +124,16 @@ describe('computeAxes', () => {
|
|
|
121
124
|
// Height-aware y-axis tick reduction
|
|
122
125
|
// -------------------------------------------------------------------------
|
|
123
126
|
|
|
124
|
-
it('reduces y-axis ticks for very short chart areas (<
|
|
127
|
+
it('reduces y-axis ticks for very short chart areas (< 80px)', () => {
|
|
125
128
|
const shortArea = { x: 50, y: 50, width: 500, height: 80 };
|
|
126
129
|
const scales = computeScales(lineSpec, shortArea, lineSpec.data);
|
|
127
130
|
const axesShort = computeAxes(scales, shortArea, fullStrategy, theme);
|
|
128
131
|
|
|
129
|
-
//
|
|
132
|
+
// Very short chart area -- tick count clamped to at most 4
|
|
130
133
|
expect(axesShort.y!.ticks.length).toBeLessThanOrEqual(4);
|
|
131
134
|
});
|
|
132
135
|
|
|
133
|
-
it('reduces y-axis ticks for medium-short chart areas (
|
|
136
|
+
it('reduces y-axis ticks for medium-short chart areas (80-100px)', () => {
|
|
134
137
|
const mediumArea = { x: 50, y: 50, width: 500, height: 160 };
|
|
135
138
|
const tallArea = { x: 50, y: 50, width: 500, height: 400 };
|
|
136
139
|
|
|
@@ -884,3 +887,98 @@ describe('horizontal bar y-axis label thinning regression', () => {
|
|
|
884
887
|
expect(axes.x!.ticks.length).toBeLessThan(countries.length);
|
|
885
888
|
});
|
|
886
889
|
});
|
|
890
|
+
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
// Log scale tick filtering — buildContinuousTicks
|
|
893
|
+
// D3 log scales ignore the count hint and return ticks at every sub-power
|
|
894
|
+
// position. The engine must filter these down to powers of the base only.
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Build a ResolvedScale backed by a D3 log scale, matching what buildLogScale produces.
|
|
899
|
+
* Using a single `base` parameter keeps the D3 scale and channel config in sync,
|
|
900
|
+
* mirroring how they're always derived from the same spec field.
|
|
901
|
+
*/
|
|
902
|
+
function makeLogScale(domain: [number, number], base = 10): ResolvedScale {
|
|
903
|
+
const scale = scaleLog().domain(domain).range([400, 0]);
|
|
904
|
+
scale.base(base);
|
|
905
|
+
return {
|
|
906
|
+
scale,
|
|
907
|
+
type: 'log',
|
|
908
|
+
channel: {
|
|
909
|
+
field: 'value',
|
|
910
|
+
type: 'quantitative',
|
|
911
|
+
scale: base !== 10 ? { base } : undefined,
|
|
912
|
+
},
|
|
913
|
+
} as ResolvedScale;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
describe('buildContinuousTicks — log scale power filtering', () => {
|
|
917
|
+
it('returns only power-of-10 ticks for [5, 25000] at tickCount 5', () => {
|
|
918
|
+
const resolved = makeLogScale([5, 25000]);
|
|
919
|
+
const ticks = buildContinuousTicks(resolved, 5);
|
|
920
|
+
const values = ticks.map((t) => t.value as number);
|
|
921
|
+
// Should be exactly the powers of 10 in domain: 10, 100, 1000, 10000
|
|
922
|
+
expect(values).toEqual([10, 100, 1000, 10000]);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it('returns only power-of-10 ticks for [1, 1000000] at tickCount 5', () => {
|
|
926
|
+
const resolved = makeLogScale([1, 1_000_000]);
|
|
927
|
+
const ticks = buildContinuousTicks(resolved, 5);
|
|
928
|
+
const values = ticks.map((t) => t.value as number);
|
|
929
|
+
// Assert invariants rather than exact output: every tick is a power of 10,
|
|
930
|
+
// and we get at least 5 (the domain spans 6 decades).
|
|
931
|
+
for (const v of values) {
|
|
932
|
+
const exp = Math.log10(v);
|
|
933
|
+
expect(Math.abs(exp - Math.round(exp))).toBeLessThan(1e-9);
|
|
934
|
+
}
|
|
935
|
+
expect(values.length).toBeGreaterThanOrEqual(5);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it('returns only powers-of-2 for [1, 64] base-2 at tickCount 5', () => {
|
|
939
|
+
const resolved = makeLogScale([1, 64], 2);
|
|
940
|
+
const ticks = buildContinuousTicks(resolved, 5);
|
|
941
|
+
const values = ticks.map((t) => t.value as number);
|
|
942
|
+
expect(values).toEqual([1, 2, 4, 8, 16, 32, 64]);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it('handles fractional powers for [0.001, 100] at tickCount 5', () => {
|
|
946
|
+
const resolved = makeLogScale([0.001, 100]);
|
|
947
|
+
const ticks = buildContinuousTicks(resolved, 5);
|
|
948
|
+
const values = ticks.map((t) => t.value as number);
|
|
949
|
+
// Tolerance check prevents floating-point false negatives on 0.001, 0.01, 0.1
|
|
950
|
+
expect(values).toEqual([0.001, 0.01, 0.1, 1, 10, 100]);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('still produces ticks at tickCount 3 (regression guard)', () => {
|
|
954
|
+
const resolved = makeLogScale([1, 1000]);
|
|
955
|
+
const ticks = buildContinuousTicks(resolved, 3);
|
|
956
|
+
expect(ticks.length).toBeGreaterThanOrEqual(2);
|
|
957
|
+
const values = ticks.map((t) => t.value as number);
|
|
958
|
+
// Every value must be a power of 10
|
|
959
|
+
for (const v of values) {
|
|
960
|
+
const exp = Math.log10(v);
|
|
961
|
+
expect(Math.abs(exp - Math.round(exp))).toBeLessThan(1e-6);
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it('does not over-filter linear scales with the same domain', () => {
|
|
966
|
+
const scale = scaleLinear().domain([5, 25000]).range([400, 0]);
|
|
967
|
+
const resolved: ResolvedScale = {
|
|
968
|
+
scale,
|
|
969
|
+
type: 'linear',
|
|
970
|
+
channel: { field: 'value', type: 'quantitative' },
|
|
971
|
+
} as ResolvedScale;
|
|
972
|
+
const ticks = buildContinuousTicks(resolved, 5);
|
|
973
|
+
// Linear scale should return normal D3 ticks — not just power-of-10 values
|
|
974
|
+
expect(ticks.length).toBeGreaterThanOrEqual(4);
|
|
975
|
+
// At least one tick should NOT be a power of 10 (e.g. 5000, 10000, 15000, 20000, 25000)
|
|
976
|
+
const values = ticks.map((t) => t.value as number);
|
|
977
|
+
const nonPowerOf10 = values.filter((v) => {
|
|
978
|
+
if (v <= 0) return true;
|
|
979
|
+
const exp = Math.log10(v);
|
|
980
|
+
return Math.abs(exp - Math.round(exp)) >= 0.01;
|
|
981
|
+
});
|
|
982
|
+
expect(nonPowerOf10.length).toBeGreaterThan(0);
|
|
983
|
+
});
|
|
984
|
+
});
|
|
@@ -447,7 +447,7 @@ describe('computeLegend', () => {
|
|
|
447
447
|
legend: { position: 'top' as const },
|
|
448
448
|
};
|
|
449
449
|
|
|
450
|
-
it('places the legend exactly
|
|
450
|
+
it('places the legend exactly 8px above the chart area at standard width', () => {
|
|
451
451
|
const layout = compileChart(topLegendSpec, { width: 600, height: 400 });
|
|
452
452
|
|
|
453
453
|
expect(layout.legend.position).toBe('top');
|
|
@@ -456,7 +456,7 @@ describe('computeLegend', () => {
|
|
|
456
456
|
|
|
457
457
|
const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
|
|
458
458
|
const gap = layout.area.y - legendBottom;
|
|
459
|
-
expect(gap).toBe(
|
|
459
|
+
expect(gap).toBe(8);
|
|
460
460
|
});
|
|
461
461
|
|
|
462
462
|
it('eliminates legend gap on narrow viewports (< 420px)', () => {
|
|
@@ -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);
|