@opendata-ai/openchart-engine 6.25.2 → 6.25.4
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 +89 -41
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +75 -0
- package/src/compile.ts +17 -8
- package/src/compiler/normalize.ts +17 -6
- 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.4",
|
|
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.4",
|
|
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
|
@@ -237,14 +237,23 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
237
237
|
},
|
|
238
238
|
};
|
|
239
239
|
}
|
|
240
|
-
if (bp.labels) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
240
|
+
if (bp.labels !== undefined) {
|
|
241
|
+
if (typeof bp.labels === 'boolean') {
|
|
242
|
+
chartSpec = {
|
|
243
|
+
...chartSpec,
|
|
244
|
+
labels: bp.labels
|
|
245
|
+
? { density: 'auto', format: '', prefix: '' }
|
|
246
|
+
: { density: 'none', format: '', prefix: '' },
|
|
247
|
+
};
|
|
248
|
+
} else {
|
|
249
|
+
chartSpec = {
|
|
250
|
+
...chartSpec,
|
|
251
|
+
labels: {
|
|
252
|
+
...chartSpec.labels,
|
|
253
|
+
...(bp.labels as NormalizedChartSpec['labels']),
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
248
257
|
}
|
|
249
258
|
if (bp.legend) {
|
|
250
259
|
chartSpec = {
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
Encoding,
|
|
18
18
|
FieldType,
|
|
19
19
|
GraphSpec,
|
|
20
|
+
LabelSpec,
|
|
20
21
|
LayerSpec,
|
|
21
22
|
SankeySpec,
|
|
22
23
|
TableSpec,
|
|
@@ -189,6 +190,21 @@ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation
|
|
|
189
190
|
});
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Label normalization
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
function normalizeLabels(labels?: LabelSpec): NormalizedChartSpec['labels'] {
|
|
198
|
+
if (labels === false) return { density: 'none', format: '', prefix: '' };
|
|
199
|
+
if (labels === true || labels === undefined) return { density: 'auto', format: '', prefix: '' };
|
|
200
|
+
return {
|
|
201
|
+
density: labels.density ?? 'auto',
|
|
202
|
+
format: labels.format ?? '',
|
|
203
|
+
prefix: labels.prefix ?? '',
|
|
204
|
+
offsets: labels.offsets,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
192
208
|
// ---------------------------------------------------------------------------
|
|
193
209
|
// Spec-level normalization
|
|
194
210
|
// ---------------------------------------------------------------------------
|
|
@@ -205,12 +221,7 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
|
|
|
205
221
|
encoding,
|
|
206
222
|
chrome: normalizeChrome(spec.chrome),
|
|
207
223
|
annotations: normalizeAnnotations(spec.annotations),
|
|
208
|
-
labels:
|
|
209
|
-
density: spec.labels?.density ?? 'auto',
|
|
210
|
-
format: spec.labels?.format ?? '',
|
|
211
|
-
prefix: spec.labels?.prefix ?? '',
|
|
212
|
-
offsets: spec.labels?.offsets,
|
|
213
|
-
},
|
|
224
|
+
labels: normalizeLabels(spec.labels),
|
|
214
225
|
legend: spec.legend,
|
|
215
226
|
responsive: spec.responsive ?? true,
|
|
216
227
|
theme: spec.theme ?? {},
|
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
|
}
|