@opendata-ai/openchart-engine 6.27.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.js +40 -10
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +101 -3
- 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/layout/axes/ticks.ts +31 -4
- package/src/layout/axes.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.27.
|
|
3
|
+
"version": "6.27.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.
|
|
51
|
+
"@opendata-ai/openchart-core": "6.27.2",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -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
|
+
});
|
|
@@ -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/layout/axes/ticks.ts
CHANGED
|
@@ -38,13 +38,18 @@ import type { D3CategoricalScale, D3ContinuousScale, ResolvedScale } from '../sc
|
|
|
38
38
|
* "full" is the publication-ready default; "reduced" and "minimal" step down as the
|
|
39
39
|
* responsive breakpoint system shifts to smaller containers.
|
|
40
40
|
*
|
|
41
|
+
* Y full is set to 40px/tick (tighter than Observable Plot's 50) because chart areas
|
|
42
|
+
* are measured after chrome subtraction. A 400px container with title+subtitle leaves
|
|
43
|
+
* ~270px of chart area; 55px/tick would only produce 4 ticks. 40px/tick reaches 5-6
|
|
44
|
+
* on typical chart areas (150-300px) and the overlap check acts as a safety net.
|
|
45
|
+
*
|
|
41
46
|
* @internal — these are tuning constants, not part of the configuration API.
|
|
42
47
|
* Consumers should configure tick density through `axis.tickCount` on the spec.
|
|
43
48
|
*/
|
|
44
49
|
const Y_PX_PER_TICK: Record<AxisLabelDensity, number> = {
|
|
45
|
-
full:
|
|
46
|
-
reduced:
|
|
47
|
-
minimal:
|
|
50
|
+
full: 40,
|
|
51
|
+
reduced: 70,
|
|
52
|
+
minimal: 120,
|
|
48
53
|
};
|
|
49
54
|
|
|
50
55
|
const X_PX_PER_TICK: Record<AxisLabelDensity, number> = {
|
|
@@ -194,7 +199,29 @@ export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number
|
|
|
194
199
|
return continuousTicks(resolvedScale, 'full');
|
|
195
200
|
}
|
|
196
201
|
const raw: unknown[] = scale.ticks(count);
|
|
197
|
-
|
|
202
|
+
|
|
203
|
+
// D3 log scales ignore the count hint and return ticks at every sub-power
|
|
204
|
+
// position (e.g. 5, 6, 7, 8, 9, 10, 20, 30... for a domain of [5, 25000]).
|
|
205
|
+
// Filter down to powers of the base only when the raw set overshoots.
|
|
206
|
+
let ticks = raw;
|
|
207
|
+
if (resolvedScale.type === 'log' && raw.length > count) {
|
|
208
|
+
const base = resolvedScale.channel.scale?.base ?? 10;
|
|
209
|
+
const logBase = Math.log(base);
|
|
210
|
+
const powered = raw.filter((v) => {
|
|
211
|
+
const n = v as number;
|
|
212
|
+
if (n <= 0) return false;
|
|
213
|
+
const exp = Math.log(n) / logBase;
|
|
214
|
+
return Math.abs(exp - Math.round(exp)) < 1e-9;
|
|
215
|
+
});
|
|
216
|
+
// Only use the filtered set if it has at least 2 ticks; otherwise fall back
|
|
217
|
+
// to raw ticks. This handles domains like [5, 9] (no powers of 10 at all) or
|
|
218
|
+
// [5, 50] (only one power: 10) where filtering would leave too few meaningful ticks.
|
|
219
|
+
if (powered.length >= 2) {
|
|
220
|
+
ticks = powered;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return ticks.map((value: unknown) => ({
|
|
198
225
|
value,
|
|
199
226
|
position: scale(value as number & Date) as number,
|
|
200
227
|
label: formatTickLabel(value, resolvedScale),
|
package/src/layout/axes.ts
CHANGED
|
@@ -44,9 +44,16 @@ export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
|
44
44
|
* Below these pixel heights, we step down the density regardless of the
|
|
45
45
|
* width-based strategy. This prevents overlapping y-axis labels in short
|
|
46
46
|
* containers like thumbnail previews.
|
|
47
|
+
*
|
|
48
|
+
* These thresholds apply to the chart area height (after chrome/margins),
|
|
49
|
+
* not the total container height. A 400px container with title+subtitle
|
|
50
|
+
* leaves ~270px of chart area; a 320px container leaves ~186px. The old
|
|
51
|
+
* HEIGHT_REDUCED_THRESHOLD of 200 kicked in on nearly every common chart
|
|
52
|
+
* size, producing only 3 ticks. Lowering to 100 keeps 'full' density for
|
|
53
|
+
* all but the most compact thumbnail-style containers.
|
|
47
54
|
*/
|
|
48
|
-
const HEIGHT_MINIMAL_THRESHOLD =
|
|
49
|
-
const HEIGHT_REDUCED_THRESHOLD =
|
|
55
|
+
const HEIGHT_MINIMAL_THRESHOLD = 80;
|
|
56
|
+
const HEIGHT_REDUCED_THRESHOLD = 100;
|
|
50
57
|
|
|
51
58
|
/**
|
|
52
59
|
* Width thresholds for reducing x-axis tick density.
|