@opendata-ai/openchart-engine 6.25.1 → 6.25.3
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 +73 -29
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +75 -0
- package/src/compile.ts +18 -2
- package/src/layout/axes/ticks.ts +64 -9
- package/src/layout/axes.ts +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.25.
|
|
3
|
+
"version": "6.25.3",
|
|
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.25.
|
|
51
|
+
"@opendata-ai/openchart-core": "6.25.3",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -809,3 +809,78 @@ describe('ticksOverlap with vertical orientation', () => {
|
|
|
809
809
|
expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(false);
|
|
810
810
|
});
|
|
811
811
|
});
|
|
812
|
+
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
// Horizontal bar chart: y-axis category label regression
|
|
815
|
+
// Mobile/compact viewports must show all category labels on horizontal bar
|
|
816
|
+
// charts, regardless of axisLabelDensity. Thinning is only valid on x-axis
|
|
817
|
+
// band scales where many category names can overlap horizontally.
|
|
818
|
+
// ---------------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
describe('horizontal bar y-axis label thinning regression', () => {
|
|
821
|
+
const countries = [
|
|
822
|
+
'USA',
|
|
823
|
+
'Germany',
|
|
824
|
+
'France',
|
|
825
|
+
'Japan',
|
|
826
|
+
'UK',
|
|
827
|
+
'Canada',
|
|
828
|
+
'Australia',
|
|
829
|
+
'Netherlands',
|
|
830
|
+
'Sweden',
|
|
831
|
+
'Switzerland',
|
|
832
|
+
];
|
|
833
|
+
|
|
834
|
+
const hBarSpec: NormalizedChartSpec = {
|
|
835
|
+
markType: 'bar',
|
|
836
|
+
markDef: { type: 'bar', orient: 'horizontal' },
|
|
837
|
+
data: countries.map((country, i) => ({ country, value: (i + 1) * 100 })),
|
|
838
|
+
encoding: {
|
|
839
|
+
x: { field: 'value', type: 'quantitative' },
|
|
840
|
+
y: { field: 'country', type: 'nominal' },
|
|
841
|
+
},
|
|
842
|
+
chrome: {},
|
|
843
|
+
annotations: [],
|
|
844
|
+
responsive: true,
|
|
845
|
+
theme: {},
|
|
846
|
+
darkMode: 'off',
|
|
847
|
+
labels: { density: 'auto', format: '' },
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
it('shows all category labels on y-axis at minimal density (mobile regression)', () => {
|
|
851
|
+
const scales = computeScales(hBarSpec, chartArea, hBarSpec.data);
|
|
852
|
+
const axes = computeAxes(scales, chartArea, minimalStrategy, theme);
|
|
853
|
+
|
|
854
|
+
// Every bar must have a label -- thinning to 3 on mobile was the bug
|
|
855
|
+
expect(axes.y!.ticks.length).toBe(countries.length);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('shows all category labels on y-axis at reduced density', () => {
|
|
859
|
+
const reducedStrategy: LayoutStrategy = {
|
|
860
|
+
...minimalStrategy,
|
|
861
|
+
axisLabelDensity: 'reduced',
|
|
862
|
+
};
|
|
863
|
+
const scales = computeScales(hBarSpec, chartArea, hBarSpec.data);
|
|
864
|
+
const axes = computeAxes(scales, chartArea, reducedStrategy, theme);
|
|
865
|
+
|
|
866
|
+
expect(axes.y!.ticks.length).toBe(countries.length);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('still thins x-axis band scale labels at minimal density (column chart)', () => {
|
|
870
|
+
const vBarSpec: NormalizedChartSpec = {
|
|
871
|
+
...hBarSpec,
|
|
872
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
873
|
+
encoding: {
|
|
874
|
+
x: { field: 'country', type: 'nominal' },
|
|
875
|
+
y: { field: 'value', type: 'quantitative' },
|
|
876
|
+
},
|
|
877
|
+
};
|
|
878
|
+
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
|
|
879
|
+
const scales = computeScales(vBarSpec, narrowArea, vBarSpec.data);
|
|
880
|
+
const axes = computeAxes(scales, narrowArea, minimalStrategy, theme);
|
|
881
|
+
|
|
882
|
+
// X-axis band scale with 10 categories at minimal density on a narrow chart
|
|
883
|
+
// should thin -- showing all 10 on 200px would overlap
|
|
884
|
+
expect(axes.x!.ticks.length).toBeLessThan(countries.length);
|
|
885
|
+
});
|
|
886
|
+
});
|
package/src/compile.ts
CHANGED
|
@@ -500,7 +500,15 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
|
|
|
500
500
|
seenLabels.add(entry.label);
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
-
for
|
|
503
|
+
// Sort leaves by zIndex for render order while preserving original indices
|
|
504
|
+
// for axis assignment. Default zIndex is the array position.
|
|
505
|
+
const indexedLeaves = leaves.map((leaf, i) => ({
|
|
506
|
+
leaf,
|
|
507
|
+
zIndex: (leaf as ChartSpec).zIndex ?? i,
|
|
508
|
+
}));
|
|
509
|
+
indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
|
|
510
|
+
|
|
511
|
+
for (const { leaf } of indexedLeaves) {
|
|
504
512
|
const leafLayout = compileChart(leaf as unknown, options);
|
|
505
513
|
|
|
506
514
|
allMarks.push(...leafLayout.marks);
|
|
@@ -821,6 +829,14 @@ function compileLayerIndependent(
|
|
|
821
829
|
}
|
|
822
830
|
}
|
|
823
831
|
|
|
832
|
+
// Determine mark render order. By default, layer 0 paints first (behind),
|
|
833
|
+
// layer 1 paints second (on top). zIndex on the original leaf specs can
|
|
834
|
+
// reverse this so e.g. a line in layer 0 renders on top of bars in layer 1.
|
|
835
|
+
const z0 = leaf0.zIndex ?? 0;
|
|
836
|
+
const z1 = leaf1.zIndex ?? 1;
|
|
837
|
+
const marks =
|
|
838
|
+
z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
|
|
839
|
+
|
|
824
840
|
return {
|
|
825
841
|
...layout0,
|
|
826
842
|
axes: {
|
|
@@ -828,7 +844,7 @@ function compileLayerIndependent(
|
|
|
828
844
|
y: layout0.axes.y,
|
|
829
845
|
y2: y2Axis,
|
|
830
846
|
},
|
|
831
|
-
marks
|
|
847
|
+
marks,
|
|
832
848
|
legend: {
|
|
833
849
|
...layout0.legend,
|
|
834
850
|
entries: mergedLegendEntries,
|
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* not from the chart area. Density thinning lives in ./thinning.ts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { AxisLabelDensity, AxisTick } from '@opendata-ai/openchart-core';
|
|
8
|
+
import type { AxisLabelDensity, AxisTick, MeasureTextFn } from '@opendata-ai/openchart-core';
|
|
9
9
|
import {
|
|
10
10
|
abbreviateNumber,
|
|
11
11
|
buildD3Formatter,
|
|
12
12
|
buildTemporalFormatter,
|
|
13
|
+
estimateTextWidth,
|
|
13
14
|
formatDate,
|
|
14
15
|
formatNumber,
|
|
15
16
|
} from '@opendata-ai/openchart-core';
|
|
@@ -201,24 +202,78 @@ export function scaleSupportsTickCount(resolvedScale: ResolvedScale): boolean {
|
|
|
201
202
|
return 'ticks' in scale && typeof scale.ticks === 'function';
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Generate ticks for a band/point/ordinal scale.
|
|
207
|
+
*
|
|
208
|
+
* For horizontal x-axis band scales, thinning is geometry-aware: if
|
|
209
|
+
* `bandwidth` and `fontSize`/`fontWeight` are provided, labels are only
|
|
210
|
+
* thinned when the estimated label footprint (accounting for `labelAngle`)
|
|
211
|
+
* actually exceeds the bandwidth. When labels are rotated, their horizontal
|
|
212
|
+
* footprint shrinks by |cos(angle)|, so far fewer need to be removed.
|
|
213
|
+
* Falls back to a density-count cap when geometry info is unavailable.
|
|
214
|
+
*/
|
|
205
215
|
export function categoricalTicks(
|
|
206
216
|
resolvedScale: ResolvedScale,
|
|
207
217
|
density: AxisLabelDensity,
|
|
218
|
+
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
219
|
+
bandwidth?: number,
|
|
220
|
+
labelAngle?: number,
|
|
221
|
+
fontSize?: number,
|
|
222
|
+
fontWeight?: number,
|
|
223
|
+
measureText?: MeasureTextFn,
|
|
208
224
|
): AxisTick[] {
|
|
209
225
|
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
210
226
|
const domain: string[] = scale.domain();
|
|
211
227
|
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
212
|
-
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
213
228
|
|
|
214
|
-
// Band scales show all labels at full density but thin at reduced/minimal
|
|
215
|
-
// to prevent overlap on narrow containers (e.g. 17 bars on mobile).
|
|
216
229
|
let selectedValues = domain;
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
230
|
+
|
|
231
|
+
if (resolvedScale.type === 'band' && orientation === 'horizontal') {
|
|
232
|
+
// Geometry-based thinning: check whether labels actually fit within the
|
|
233
|
+
// bandwidth before deciding to thin. Rotated labels have a smaller
|
|
234
|
+
// horizontal footprint (width * |cos(angle)|), so they can be much denser.
|
|
235
|
+
if (bandwidth !== undefined && bandwidth > 0 && fontSize !== undefined) {
|
|
236
|
+
const maxLabelWidth = domain.reduce((max, v) => {
|
|
237
|
+
const w = measureText
|
|
238
|
+
? measureText(v, fontSize, fontWeight ?? 400).width
|
|
239
|
+
: estimateTextWidth(v, fontSize, fontWeight ?? 400);
|
|
240
|
+
return Math.max(max, w);
|
|
241
|
+
}, 0);
|
|
242
|
+
|
|
243
|
+
// At non-zero angles, horizontal footprint per label = width * |cos(angle)|
|
|
244
|
+
const angleRad = labelAngle !== undefined ? (Math.abs(labelAngle) * Math.PI) / 180 : 0;
|
|
245
|
+
const footprint = angleRad > 0 ? maxLabelWidth * Math.abs(Math.cos(angleRad)) : maxLabelWidth;
|
|
246
|
+
const minGap = fontSize * 0.5;
|
|
247
|
+
|
|
248
|
+
if (footprint + minGap > bandwidth) {
|
|
249
|
+
// Labels don't fit -- thin proportionally to bandwidth, not density tier
|
|
250
|
+
const maxFitting = Math.max(1, Math.floor(bandwidth / (footprint + minGap)));
|
|
251
|
+
// Still respect explicit tickCount as an upper bound
|
|
252
|
+
const cap =
|
|
253
|
+
explicitTickCount ?? Math.min(domain.length, Math.max(maxFitting, TICK_COUNTS[density]));
|
|
254
|
+
if (domain.length > cap) {
|
|
255
|
+
const step = Math.ceil(domain.length / cap);
|
|
256
|
+
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// else: labels fit at this bandwidth -- show all of them
|
|
260
|
+
} else {
|
|
261
|
+
// No geometry info: fall back to density-count cap (original behavior)
|
|
262
|
+
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
263
|
+
if ((explicitTickCount || density !== 'full') && domain.length > maxTicks) {
|
|
264
|
+
const step = Math.ceil(domain.length / maxTicks);
|
|
265
|
+
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} else if (resolvedScale.type !== 'band') {
|
|
269
|
+
// Point/ordinal scales: thin by density count
|
|
270
|
+
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
271
|
+
if (domain.length > maxTicks) {
|
|
272
|
+
const step = Math.ceil(domain.length / maxTicks);
|
|
273
|
+
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
274
|
+
}
|
|
221
275
|
}
|
|
276
|
+
// vertical band scale (horizontal bar y-axis): always show all labels
|
|
222
277
|
|
|
223
278
|
const ticks = selectedValues.map((value: string) => {
|
|
224
279
|
// Band scales: use the center of the band
|
package/src/layout/axes.ts
CHANGED
|
@@ -259,7 +259,18 @@ export function computeAxes(
|
|
|
259
259
|
if (axisConfig?.values) {
|
|
260
260
|
allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
|
|
261
261
|
} else if (!isContinuousX) {
|
|
262
|
-
|
|
262
|
+
const xBandwidth =
|
|
263
|
+
scales.x.type === 'band' ? (scales.x.scale as ScaleBand<string>).bandwidth() : undefined;
|
|
264
|
+
allTicks = categoricalTicks(
|
|
265
|
+
scales.x,
|
|
266
|
+
xDensity,
|
|
267
|
+
'horizontal',
|
|
268
|
+
xBandwidth,
|
|
269
|
+
axisConfig?.labelAngle,
|
|
270
|
+
fontSize,
|
|
271
|
+
fontWeight,
|
|
272
|
+
measureText,
|
|
273
|
+
);
|
|
263
274
|
} else {
|
|
264
275
|
allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
|
|
265
276
|
}
|
|
@@ -351,7 +362,7 @@ export function computeAxes(
|
|
|
351
362
|
if (axisConfig?.values) {
|
|
352
363
|
allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
|
|
353
364
|
} else if (!isContinuousY) {
|
|
354
|
-
allTicks = categoricalTicks(scales.y, yDensity);
|
|
365
|
+
allTicks = categoricalTicks(scales.y, yDensity, 'vertical');
|
|
355
366
|
} else {
|
|
356
367
|
allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
|
|
357
368
|
}
|