@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.
Files changed (36) hide show
  1. package/dist/index.d.ts +38 -6
  2. package/dist/index.js +1040 -521
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +31 -4
  6. package/src/__tests__/axes.test.ts +101 -3
  7. package/src/__tests__/legend.test.ts +2 -2
  8. package/src/annotations/__tests__/compute.test.ts +175 -0
  9. package/src/annotations/position.ts +37 -1
  10. package/src/annotations/resolve-range.ts +5 -5
  11. package/src/barlist/__tests__/compile-barlist.test.ts +200 -0
  12. package/src/barlist/compile-barlist.ts +380 -0
  13. package/src/barlist/types.ts +28 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +222 -0
  15. package/src/charts/bar/compute.ts +77 -44
  16. package/src/charts/bar/index.ts +1 -0
  17. package/src/charts/bar/labels.ts +3 -2
  18. package/src/charts/column/compute.ts +60 -27
  19. package/src/charts/column/index.ts +1 -0
  20. package/src/charts/column/labels.ts +2 -1
  21. package/src/charts/line/__tests__/compute.test.ts +2 -2
  22. package/src/charts/line/area.ts +25 -4
  23. package/src/charts/line/compute.ts +15 -5
  24. package/src/compile.ts +26 -1
  25. package/src/compiler/normalize.ts +25 -1
  26. package/src/compiler/types.ts +5 -3
  27. package/src/compiler/validate.ts +120 -5
  28. package/src/index.ts +5 -0
  29. package/src/layout/axes/ticks.ts +37 -8
  30. package/src/layout/axes.ts +11 -4
  31. package/src/layout/dimensions.ts +10 -4
  32. package/src/layout/scales.ts +10 -0
  33. package/src/legend/wrap.ts +1 -1
  34. package/src/tilemap/__tests__/compile-tilemap.test.ts +5 -2
  35. package/src/tilemap/compile-tilemap.ts +41 -29
  36. 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.27.0",
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.27.0",
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": 2.5,
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": 2.5,
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": 2.5,
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": 2.5,
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 (< 120px)', () => {
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
- // Even though the strategy says 'full', height < 120 forces minimal (3 ticks)
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 (120-200px)', () => {
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 4px above the chart area at standard width', () => {
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(4);
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 { resolvePosition } from './position';
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 = resolvePosition(annotation.x1, scales.x);
32
- const x2px = resolvePosition(annotation.x2, scales.x);
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 = resolvePosition(annotation.y1, scales.y);
42
- const y2px = resolvePosition(annotation.y2, scales.y);
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);