@opendata-ai/openchart-engine 7.1.2 → 7.1.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 +103 -93
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +3 -3
- package/src/__tests__/dimensions.test.ts +108 -0
- package/src/charts/bar/__tests__/labels.test.ts +72 -0
- package/src/charts/bar/index.ts +2 -1
- package/src/charts/bar/labels.ts +4 -3
- package/src/layout/axes/ticks.ts +8 -47
- package/src/layout/axes.ts +79 -41
- package/src/layout/dimensions.ts +13 -7
- package/src/layout/scales.ts +22 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.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": "7.1.
|
|
51
|
+
"@opendata-ai/openchart-core": "7.1.4",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -227,7 +227,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
|
|
|
227
227
|
"connector": undefined,
|
|
228
228
|
"style": {
|
|
229
229
|
"dominantBaseline": "central",
|
|
230
|
-
"fill": "#
|
|
230
|
+
"fill": "#ffffff",
|
|
231
231
|
"fontFamily": "system-ui, -apple-system, sans-serif",
|
|
232
232
|
"fontSize": 11,
|
|
233
233
|
"fontWeight": 600,
|
|
@@ -260,7 +260,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
|
|
|
260
260
|
"connector": undefined,
|
|
261
261
|
"style": {
|
|
262
262
|
"dominantBaseline": "central",
|
|
263
|
-
"fill": "#
|
|
263
|
+
"fill": "#ffffff",
|
|
264
264
|
"fontFamily": "system-ui, -apple-system, sans-serif",
|
|
265
265
|
"fontSize": 11,
|
|
266
266
|
"fontWeight": 600,
|
|
@@ -293,7 +293,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
|
|
|
293
293
|
"connector": undefined,
|
|
294
294
|
"style": {
|
|
295
295
|
"dominantBaseline": "central",
|
|
296
|
-
"fill": "#
|
|
296
|
+
"fill": "#ffffff",
|
|
297
297
|
"fontFamily": "system-ui, -apple-system, sans-serif",
|
|
298
298
|
"fontSize": 11,
|
|
299
299
|
"fontWeight": 600,
|
|
@@ -126,6 +126,84 @@ describe('computeDimensions', () => {
|
|
|
126
126
|
expect(withLegend.chartArea.height).toBeLessThan(withoutLegend.chartArea.height);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
it('reserves enough left margin for y-axis title to clear tick labels', () => {
|
|
130
|
+
const specWithYTitle: NormalizedChartSpec = {
|
|
131
|
+
...baseSpec,
|
|
132
|
+
encoding: {
|
|
133
|
+
x: { field: 'date', type: 'temporal' },
|
|
134
|
+
y: { field: 'value', type: 'quantitative', axis: { title: 'Share of districts' } },
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
const specWithoutYTitle: NormalizedChartSpec = {
|
|
138
|
+
...baseSpec,
|
|
139
|
+
encoding: {
|
|
140
|
+
x: { field: 'date', type: 'temporal' },
|
|
141
|
+
y: { field: 'value', type: 'quantitative' },
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const dimsWithTitle = computeDimensions(
|
|
146
|
+
specWithYTitle,
|
|
147
|
+
{ width: 600, height: 400 },
|
|
148
|
+
emptyLegend,
|
|
149
|
+
lightTheme,
|
|
150
|
+
);
|
|
151
|
+
const dimsWithoutTitle = computeDimensions(
|
|
152
|
+
specWithoutYTitle,
|
|
153
|
+
{ width: 600, height: 400 },
|
|
154
|
+
emptyLegend,
|
|
155
|
+
lightTheme,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// A chart with a y-axis title needs more left margin than one without
|
|
159
|
+
expect(dimsWithTitle.margins.left).toBeGreaterThan(dimsWithoutTitle.margins.left);
|
|
160
|
+
// The difference should be at least enough for the rotated title glyph
|
|
161
|
+
// plus breathing room (halfGlyph ~7 + trailing pad 4 = 11px minimum)
|
|
162
|
+
expect(dimsWithTitle.margins.left - dimsWithoutTitle.margins.left).toBeGreaterThanOrEqual(11);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('y-axis title margin scales with tick label width', () => {
|
|
166
|
+
const smallValues: NormalizedChartSpec = {
|
|
167
|
+
...baseSpec,
|
|
168
|
+
data: [
|
|
169
|
+
{ date: '2020-01-01', value: 5 },
|
|
170
|
+
{ date: '2021-01-01', value: 9 },
|
|
171
|
+
],
|
|
172
|
+
encoding: {
|
|
173
|
+
x: { field: 'date', type: 'temporal' },
|
|
174
|
+
y: { field: 'value', type: 'quantitative', axis: { title: 'Count' } },
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
const largeValues: NormalizedChartSpec = {
|
|
178
|
+
...baseSpec,
|
|
179
|
+
data: [
|
|
180
|
+
{ date: '2020-01-01', value: 1_500_000 },
|
|
181
|
+
{ date: '2021-01-01', value: 2_000_000 },
|
|
182
|
+
],
|
|
183
|
+
encoding: {
|
|
184
|
+
x: { field: 'date', type: 'temporal' },
|
|
185
|
+
y: { field: 'value', type: 'quantitative', axis: { title: 'Revenue ($)' } },
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const dimsSmall = computeDimensions(
|
|
190
|
+
smallValues,
|
|
191
|
+
{ width: 600, height: 400 },
|
|
192
|
+
emptyLegend,
|
|
193
|
+
lightTheme,
|
|
194
|
+
);
|
|
195
|
+
const dimsLarge = computeDimensions(
|
|
196
|
+
largeValues,
|
|
197
|
+
{ width: 600, height: 400 },
|
|
198
|
+
emptyLegend,
|
|
199
|
+
lightTheme,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Larger numeric values produce wider tick labels (e.g. "1.5M" vs "0.0"),
|
|
203
|
+
// so the y-axis title margin should grow to keep clearance
|
|
204
|
+
expect(dimsLarge.margins.left).toBeGreaterThan(dimsSmall.margins.left);
|
|
205
|
+
});
|
|
206
|
+
|
|
129
207
|
it('applies dark mode theme adaptation', () => {
|
|
130
208
|
const lightDims = computeDimensions(
|
|
131
209
|
baseSpec,
|
|
@@ -382,6 +460,36 @@ describe('computeDimensions', () => {
|
|
|
382
460
|
});
|
|
383
461
|
});
|
|
384
462
|
|
|
463
|
+
it('avoids doubling axisMargin and legendGap when top legend is present', () => {
|
|
464
|
+
const dimsWithTopLegend = computeDimensions(
|
|
465
|
+
baseSpec,
|
|
466
|
+
{ width: 600, height: 400 },
|
|
467
|
+
topLegend,
|
|
468
|
+
lightTheme,
|
|
469
|
+
);
|
|
470
|
+
const dimsNoLegend = computeDimensions(
|
|
471
|
+
baseSpec,
|
|
472
|
+
{ width: 600, height: 400 },
|
|
473
|
+
emptyLegend,
|
|
474
|
+
lightTheme,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
// Without a top legend, the full topAxisGap (axisMargin + inlineTickOverhang)
|
|
478
|
+
// separates chrome from chart area. With a top legend, legendGap already
|
|
479
|
+
// provides separation, so only inlineTickOverhang is added (not the full
|
|
480
|
+
// topAxisGap). This means the chart area gains back ~axisMargin (6px)
|
|
481
|
+
// that would otherwise be redundant spacing.
|
|
482
|
+
//
|
|
483
|
+
// The top margin with legend includes: legendHeight(28) + legendGap(8)
|
|
484
|
+
// + inlineTickOverhang(17) instead of the no-legend topAxisGap(23).
|
|
485
|
+
// Net: margin delta = 28 + 8 + 17 - 23 = 30px.
|
|
486
|
+
// If axisMargin were doubling up: 28 + 8 + 23 - 23 = 36px.
|
|
487
|
+
const topMarginDelta = dimsWithTopLegend.margins.top - dimsNoLegend.margins.top;
|
|
488
|
+
expect(topMarginDelta).toBeLessThan(topLegend.bounds.height + legendGap(600) + 1);
|
|
489
|
+
// With a legend present, the chart area should still be shorter
|
|
490
|
+
expect(dimsWithTopLegend.chartArea.height).toBeLessThan(dimsNoLegend.chartArea.height);
|
|
491
|
+
});
|
|
492
|
+
|
|
385
493
|
it('tightens legend gap on narrow viewports', () => {
|
|
386
494
|
const wideDims = computeDimensions(
|
|
387
495
|
baseSpec,
|
|
@@ -217,3 +217,75 @@ describe('computeBarLabels with Unicode minus (U+2212) in aria values', () => {
|
|
|
217
217
|
expect(labels[1].text).toBe('\u22125%'); // −5%
|
|
218
218
|
});
|
|
219
219
|
});
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Dark-mode inside-label color
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
describe('computeBarLabels inside-label color by mode', () => {
|
|
226
|
+
// Wide bar (>= 40px) so the label is placed inside and uses pickLabelColor.
|
|
227
|
+
function makeFilledMark(fill: string): RectMark {
|
|
228
|
+
return {
|
|
229
|
+
type: 'rect',
|
|
230
|
+
x: 0,
|
|
231
|
+
y: 0,
|
|
232
|
+
width: 200,
|
|
233
|
+
height: 25,
|
|
234
|
+
fill,
|
|
235
|
+
data: { category: 'A', value: 100 },
|
|
236
|
+
aria: { label: 'A: 100' },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
it('mid-tone fill gets white inside-label in light mode, dark in dark mode', () => {
|
|
241
|
+
const slate = [makeFilledMark('#94a3b8')];
|
|
242
|
+
const light = computeBarLabels(
|
|
243
|
+
slate,
|
|
244
|
+
chartArea,
|
|
245
|
+
'all',
|
|
246
|
+
undefined,
|
|
247
|
+
undefined,
|
|
248
|
+
undefined,
|
|
249
|
+
undefined,
|
|
250
|
+
false,
|
|
251
|
+
);
|
|
252
|
+
const dark = computeBarLabels(
|
|
253
|
+
slate,
|
|
254
|
+
chartArea,
|
|
255
|
+
'all',
|
|
256
|
+
undefined,
|
|
257
|
+
undefined,
|
|
258
|
+
undefined,
|
|
259
|
+
undefined,
|
|
260
|
+
true,
|
|
261
|
+
);
|
|
262
|
+
expect(light[0].style.fill).toBe('#ffffff');
|
|
263
|
+
expect(dark[0].style.fill).toBe('#111111');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('saturated fill keeps white inside-label in both modes', () => {
|
|
267
|
+
const red = [makeFilledMark('#c0392b')];
|
|
268
|
+
const light = computeBarLabels(
|
|
269
|
+
red,
|
|
270
|
+
chartArea,
|
|
271
|
+
'all',
|
|
272
|
+
undefined,
|
|
273
|
+
undefined,
|
|
274
|
+
undefined,
|
|
275
|
+
undefined,
|
|
276
|
+
false,
|
|
277
|
+
);
|
|
278
|
+
const dark = computeBarLabels(
|
|
279
|
+
red,
|
|
280
|
+
chartArea,
|
|
281
|
+
'all',
|
|
282
|
+
undefined,
|
|
283
|
+
undefined,
|
|
284
|
+
undefined,
|
|
285
|
+
undefined,
|
|
286
|
+
true,
|
|
287
|
+
);
|
|
288
|
+
expect(light[0].style.fill).toBe('#ffffff');
|
|
289
|
+
expect(dark[0].style.fill).toBe('#ffffff');
|
|
290
|
+
});
|
|
291
|
+
});
|
package/src/charts/bar/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { computeBarLabels } from './labels';
|
|
|
13
13
|
// Bar chart renderer
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
|
|
16
|
-
export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy,
|
|
16
|
+
export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
|
|
17
17
|
const marks = computeBarMarks(spec, scales, chartArea, strategy);
|
|
18
18
|
|
|
19
19
|
// Compute and attach value labels (respects spec.labels.density)
|
|
@@ -27,6 +27,7 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
|
|
|
27
27
|
spec.labels.prefix,
|
|
28
28
|
valueField,
|
|
29
29
|
spec.labels.color,
|
|
30
|
+
theme.isDark,
|
|
30
31
|
);
|
|
31
32
|
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
32
33
|
marks[i].label = labels[i];
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -95,6 +95,7 @@ export function computeBarLabels(
|
|
|
95
95
|
labelPrefix?: string,
|
|
96
96
|
valueField?: string,
|
|
97
97
|
labelColor?: string,
|
|
98
|
+
darkMode = false,
|
|
98
99
|
): ResolvedLabel[] {
|
|
99
100
|
const targetMarks = filterByDensity(marks, density);
|
|
100
101
|
|
|
@@ -150,18 +151,18 @@ export function computeBarLabels(
|
|
|
150
151
|
if (isStacked && isInside) {
|
|
151
152
|
// Stacked: centered within segment
|
|
152
153
|
anchorX = mark.x + mark.width / 2;
|
|
153
|
-
fill = pickLabelColor(bgColor);
|
|
154
|
+
fill = pickLabelColor(bgColor, darkMode);
|
|
154
155
|
textAnchor = 'middle';
|
|
155
156
|
} else if (isInside) {
|
|
156
157
|
if (isNegative) {
|
|
157
158
|
// Negative bar: left-aligned within bar (bar extends leftward)
|
|
158
159
|
anchorX = mark.x + LABEL_PADDING;
|
|
159
|
-
fill = pickLabelColor(bgColor);
|
|
160
|
+
fill = pickLabelColor(bgColor, darkMode);
|
|
160
161
|
textAnchor = 'start';
|
|
161
162
|
} else {
|
|
162
163
|
// Positive bar: right-aligned within bar
|
|
163
164
|
anchorX = mark.x + mark.width - LABEL_PADDING;
|
|
164
|
-
fill = pickLabelColor(bgColor);
|
|
165
|
+
fill = pickLabelColor(bgColor, darkMode);
|
|
165
166
|
textAnchor = 'end';
|
|
166
167
|
}
|
|
167
168
|
} else {
|
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -5,17 +5,11 @@
|
|
|
5
5
|
* not from the chart area. Density thinning lives in ./thinning.ts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
9
|
-
AxisLabelDensity,
|
|
10
|
-
AxisTick,
|
|
11
|
-
DataRow,
|
|
12
|
-
MeasureTextFn,
|
|
13
|
-
} from '@opendata-ai/openchart-core';
|
|
8
|
+
import type { AxisLabelDensity, AxisTick, DataRow } from '@opendata-ai/openchart-core';
|
|
14
9
|
import {
|
|
15
10
|
abbreviateNumber,
|
|
16
11
|
buildD3Formatter,
|
|
17
12
|
buildTemporalFormatter,
|
|
18
|
-
estimateTextWidth,
|
|
19
13
|
formatDate,
|
|
20
14
|
formatNumber,
|
|
21
15
|
} from '@opendata-ai/openchart-core';
|
|
@@ -249,11 +243,6 @@ export function categoricalTicks(
|
|
|
249
243
|
resolvedScale: ResolvedScale,
|
|
250
244
|
density: AxisLabelDensity,
|
|
251
245
|
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
252
|
-
bandwidth?: number,
|
|
253
|
-
labelAngle?: number,
|
|
254
|
-
fontSize?: number,
|
|
255
|
-
fontWeight?: number,
|
|
256
|
-
measureText?: MeasureTextFn,
|
|
257
246
|
subtitleContext?: { data: DataRow[]; fieldName: string; labelField: string },
|
|
258
247
|
): AxisTick[] {
|
|
259
248
|
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
@@ -264,41 +253,13 @@ export function categoricalTicks(
|
|
|
264
253
|
let selectedValues = domain;
|
|
265
254
|
|
|
266
255
|
if (resolvedScale.type === 'band' && orientation === 'horizontal') {
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
: estimateTextWidth(v, fontSize, fontWeight ?? 400);
|
|
275
|
-
return Math.max(max, w);
|
|
276
|
-
}, 0);
|
|
277
|
-
|
|
278
|
-
// At non-zero angles, horizontal footprint per label = width * |cos(angle)|
|
|
279
|
-
const angleRad = labelAngle !== undefined ? (Math.abs(labelAngle) * Math.PI) / 180 : 0;
|
|
280
|
-
const footprint = angleRad > 0 ? maxLabelWidth * Math.abs(Math.cos(angleRad)) : maxLabelWidth;
|
|
281
|
-
const minGap = fontSize * 0.5;
|
|
282
|
-
|
|
283
|
-
if (footprint + minGap > bandwidth) {
|
|
284
|
-
// Labels don't fit -- thin proportionally to bandwidth, not density tier
|
|
285
|
-
const maxFitting = Math.max(1, Math.floor(bandwidth / (footprint + minGap)));
|
|
286
|
-
// Still respect explicit tickCount as an upper bound
|
|
287
|
-
const cap =
|
|
288
|
-
explicitTickCount ?? Math.min(domain.length, Math.max(maxFitting, TICK_COUNTS[density]));
|
|
289
|
-
if (domain.length > cap) {
|
|
290
|
-
const step = Math.ceil(domain.length / cap);
|
|
291
|
-
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
// else: labels fit at this bandwidth -- show all of them
|
|
295
|
-
} else {
|
|
296
|
-
// No geometry info: fall back to density-count cap (original behavior)
|
|
297
|
-
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
298
|
-
if ((explicitTickCount || density !== 'full') && domain.length > maxTicks) {
|
|
299
|
-
const step = Math.ceil(domain.length / maxTicks);
|
|
300
|
-
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
301
|
-
}
|
|
256
|
+
// Horizontal band scales delegate thinning to the caller (computeAxes)
|
|
257
|
+
// which knows the effective label angle after auto-rotation. Only apply
|
|
258
|
+
// an explicit tickCount cap here; density-based thinning happens
|
|
259
|
+
// downstream in thinBandTicksIfNeeded where rotation is accounted for.
|
|
260
|
+
if (explicitTickCount && domain.length > explicitTickCount) {
|
|
261
|
+
const step = Math.ceil(domain.length / explicitTickCount);
|
|
262
|
+
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
302
263
|
}
|
|
303
264
|
} else if (resolvedScale.type !== 'band') {
|
|
304
265
|
// Point/ordinal scales: thin by density count
|
package/src/layout/axes.ts
CHANGED
|
@@ -190,6 +190,60 @@ function fitContinuousTicks(
|
|
|
190
190
|
return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Check whether band-scale tick labels overlap at a given rotation angle,
|
|
195
|
+
* using position-based detection. Rotated labels extend diagonally past
|
|
196
|
+
* their band boundaries without colliding, so we check actual footprint
|
|
197
|
+
* along the axis rather than comparing against bandwidth.
|
|
198
|
+
*/
|
|
199
|
+
function bandTicksOverlapAtAngle(
|
|
200
|
+
ticks: AxisTick[],
|
|
201
|
+
angleDeg: number,
|
|
202
|
+
fontSize: number,
|
|
203
|
+
fontWeight: number,
|
|
204
|
+
measureText?: MeasureTextFn,
|
|
205
|
+
): boolean {
|
|
206
|
+
if (ticks.length < 2) return false;
|
|
207
|
+
const angleRad = (Math.abs(angleDeg) * Math.PI) / 180;
|
|
208
|
+
const cosA = angleRad > 0 ? Math.abs(Math.cos(angleRad)) : 1;
|
|
209
|
+
const minGap = fontSize * 0.5;
|
|
210
|
+
for (let i = 0; i < ticks.length - 1; i++) {
|
|
211
|
+
const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText) * cosA;
|
|
212
|
+
const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText) * cosA;
|
|
213
|
+
const aRight = ticks[i].position + aWidth / 2;
|
|
214
|
+
const bLeft = ticks[i + 1].position - bWidth / 2;
|
|
215
|
+
if (aRight + minGap > bLeft) return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Thin band-scale tick labels only when they actually overlap at their
|
|
222
|
+
* effective angle. Most grouped bar charts keep every label even at -45°.
|
|
223
|
+
* Only extremely dense charts (50+ categories) will thin.
|
|
224
|
+
*/
|
|
225
|
+
function thinBandTicksIfNeeded(
|
|
226
|
+
ticks: AxisTick[],
|
|
227
|
+
angleDeg: number,
|
|
228
|
+
fontSize: number,
|
|
229
|
+
fontWeight: number,
|
|
230
|
+
measureText?: MeasureTextFn,
|
|
231
|
+
): AxisTick[] {
|
|
232
|
+
if (!bandTicksOverlapAtAngle(ticks, angleDeg, fontSize, fontWeight, measureText)) return ticks;
|
|
233
|
+
|
|
234
|
+
let current = ticks;
|
|
235
|
+
while (current.length > 2) {
|
|
236
|
+
const thinned = [current[0]];
|
|
237
|
+
for (let i = 2; i < current.length - 1; i += 2) {
|
|
238
|
+
thinned.push(current[i]);
|
|
239
|
+
}
|
|
240
|
+
if (current.length > 1) thinned.push(current[current.length - 1]);
|
|
241
|
+
current = thinned;
|
|
242
|
+
if (!bandTicksOverlapAtAngle(current, angleDeg, fontSize, fontWeight, measureText)) break;
|
|
243
|
+
}
|
|
244
|
+
return current;
|
|
245
|
+
}
|
|
246
|
+
|
|
193
247
|
// ---------------------------------------------------------------------------
|
|
194
248
|
// Public API
|
|
195
249
|
// ---------------------------------------------------------------------------
|
|
@@ -291,18 +345,9 @@ export function computeAxes(
|
|
|
291
345
|
if (axisConfig?.values) {
|
|
292
346
|
allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
|
|
293
347
|
} else if (!isContinuousX) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
allTicks = categoricalTicks(
|
|
297
|
-
scales.x,
|
|
298
|
-
xDensity,
|
|
299
|
-
'horizontal',
|
|
300
|
-
xBandwidth,
|
|
301
|
-
axisConfig?.labelAngle,
|
|
302
|
-
fontSize,
|
|
303
|
-
fontWeight,
|
|
304
|
-
measureText,
|
|
305
|
-
);
|
|
348
|
+
// For band scales, generate all ticks first (no thinning). Rotation and
|
|
349
|
+
// thinning are resolved below once we know the effective label angle.
|
|
350
|
+
allTicks = categoricalTicks(scales.x, xDensity, 'horizontal');
|
|
306
351
|
} else {
|
|
307
352
|
allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
|
|
308
353
|
}
|
|
@@ -314,18 +359,32 @@ export function computeAxes(
|
|
|
314
359
|
major: true,
|
|
315
360
|
}));
|
|
316
361
|
|
|
317
|
-
//
|
|
318
|
-
// auto-
|
|
319
|
-
|
|
320
|
-
|
|
362
|
+
// Determine rotation before thinning so we know the effective label
|
|
363
|
+
// footprint. Band scales auto-rotate when horizontal labels don't fit.
|
|
364
|
+
let tickAngle = axisConfig?.labelAngle;
|
|
365
|
+
if (tickAngle === undefined && scales.x.type === 'band' && allTicks.length > 1) {
|
|
366
|
+
const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
|
|
367
|
+
let maxLabelWidth = 0;
|
|
368
|
+
for (const t of allTicks) {
|
|
369
|
+
const w = measureLabel(t.label, fontSize, fontWeight, measureText);
|
|
370
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
371
|
+
}
|
|
372
|
+
if (maxLabelWidth > bandwidth * 0.85) {
|
|
373
|
+
tickAngle = -45;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Thin tick labels to prevent overlap (skip for explicit tick values).
|
|
321
378
|
const hasExplicitValues = !!axisConfig?.values;
|
|
322
|
-
const shouldThin = scales.x.type !== 'band' && !hasExplicitValues;
|
|
323
379
|
let ticks: AxisTick[];
|
|
324
|
-
if (
|
|
380
|
+
if (hasExplicitValues) {
|
|
325
381
|
ticks = allTicks;
|
|
382
|
+
} else if (scales.x.type === 'band') {
|
|
383
|
+
// Band scales: thin only when labels actually overlap at their
|
|
384
|
+
// effective angle. After rotation, most charts have room for every label.
|
|
385
|
+
const effectiveAngle = tickAngle ?? 0;
|
|
386
|
+
ticks = thinBandTicksIfNeeded(allTicks, effectiveAngle, fontSize, fontWeight, measureText);
|
|
326
387
|
} else if (isContinuousX) {
|
|
327
|
-
// Continuous x-axis: re-request ticks at a lower count on overlap so
|
|
328
|
-
// time-scale quartile/monthly jumps don't leave a too-dense axis.
|
|
329
388
|
ticks = fitContinuousTicks(
|
|
330
389
|
scales.x,
|
|
331
390
|
allTicks,
|
|
@@ -340,22 +399,6 @@ export function computeAxes(
|
|
|
340
399
|
ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
|
|
341
400
|
}
|
|
342
401
|
|
|
343
|
-
// Auto-rotate labels when band scale labels would overlap.
|
|
344
|
-
// Uses max label width (not average) since one long label is enough to overlap.
|
|
345
|
-
let tickAngle = axisConfig?.labelAngle;
|
|
346
|
-
if (tickAngle === undefined && scales.x.type === 'band' && ticks.length > 1) {
|
|
347
|
-
const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
|
|
348
|
-
let maxLabelWidth = 0;
|
|
349
|
-
for (const t of ticks) {
|
|
350
|
-
const w = measureLabel(t.label, fontSize, fontWeight, measureText);
|
|
351
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
352
|
-
}
|
|
353
|
-
// If the widest label exceeds 85% of the bandwidth, rotate to avoid overlap
|
|
354
|
-
if (maxLabelWidth > bandwidth * 0.85) {
|
|
355
|
-
tickAngle = -45;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
402
|
const axisTitle = axisConfig?.title;
|
|
360
403
|
const xLabelColor = axisConfig?.labelColor;
|
|
361
404
|
// X-axis defaults to gutter (no inline mode is sensible for the x axis
|
|
@@ -404,11 +447,6 @@ export function computeAxes(
|
|
|
404
447
|
scales.y,
|
|
405
448
|
yDensity,
|
|
406
449
|
'vertical',
|
|
407
|
-
undefined,
|
|
408
|
-
undefined,
|
|
409
|
-
undefined,
|
|
410
|
-
undefined,
|
|
411
|
-
undefined,
|
|
412
450
|
yFieldName && yLabelField && dataContext
|
|
413
451
|
? { data: dataContext.data, fieldName: yFieldName, labelField: yLabelField }
|
|
414
452
|
: undefined,
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
ResolvedTheme,
|
|
24
24
|
} from '@opendata-ai/openchart-core';
|
|
25
25
|
import {
|
|
26
|
+
AXIS_TITLE_GAP,
|
|
26
27
|
AXIS_TITLE_TRAILING_PAD,
|
|
27
28
|
BREAKPOINT_COMPACT_MAX,
|
|
28
29
|
computeChrome,
|
|
@@ -633,9 +634,8 @@ export function computeDimensions(
|
|
|
633
634
|
);
|
|
634
635
|
}
|
|
635
636
|
// Mirror the renderer's dynamic offset formula:
|
|
636
|
-
// dynamicOffset = TICK_LABEL_OFFSET(6) + maxTickLabelWidth +
|
|
637
|
-
// titleOffset = max(dynamicOffset,
|
|
638
|
-
const AXIS_TITLE_GAP = 8;
|
|
637
|
+
// dynamicOffset = TICK_LABEL_OFFSET(6) + maxTickLabelWidth + AXIS_TITLE_GAP(14)
|
|
638
|
+
// titleOffset = max(dynamicOffset, getAxisTitleOffset(width))
|
|
639
639
|
const dynamicTitleOffset = TICK_LABEL_OFFSET + estTickLabelWidth + AXIS_TITLE_GAP;
|
|
640
640
|
const axisTitleOffset = Math.max(dynamicTitleOffset, getAxisTitleOffset(width));
|
|
641
641
|
const halfGlyph = Math.ceil(theme.fonts.sizes.body / 2);
|
|
@@ -658,6 +658,8 @@ export function computeDimensions(
|
|
|
658
658
|
// here. The legend lands below the x-axis tick row (which is reserved via
|
|
659
659
|
// `xAxisHeight` in the base bottom margin) and source/byline/footer chrome
|
|
660
660
|
// stacks underneath the legend band rather than colliding with it.
|
|
661
|
+
const hasTopLegend =
|
|
662
|
+
'entries' in legendLayout && legendLayout.entries.length > 0 && legendLayout.position === 'top';
|
|
661
663
|
if ('entries' in legendLayout && legendLayout.entries.length > 0) {
|
|
662
664
|
const gap = legendGap(width);
|
|
663
665
|
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
@@ -669,9 +671,12 @@ export function computeDimensions(
|
|
|
669
671
|
// above.
|
|
670
672
|
}
|
|
671
673
|
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
|
|
674
|
+
// topAxisGap sits between the legend (or chrome, if no legend) and the
|
|
675
|
+
// chart area. When a top legend is present, the legendGap already provides
|
|
676
|
+
// breathing room, so only the inlineTickOverhang is needed (the axisMargin
|
|
677
|
+
// component would double up with legendGap). Without a top legend, the
|
|
678
|
+
// full topAxisGap (axisMargin + inlineTickOverhang) applies.
|
|
679
|
+
margins.top += hasTopLegend ? inlineTickOverhang : topAxisGap;
|
|
675
680
|
|
|
676
681
|
// Chart area is what's left after margins
|
|
677
682
|
let chartArea: Rect = {
|
|
@@ -707,6 +712,7 @@ export function computeDimensions(
|
|
|
707
712
|
// until resolveMetrics decides otherwise).
|
|
708
713
|
const fallbackTopAxisGap =
|
|
709
714
|
isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
|
|
715
|
+
const fallbackEffectiveAxisGap = hasTopLegend ? inlineTickOverhang : fallbackTopAxisGap;
|
|
710
716
|
const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
|
|
711
717
|
const topDelta = margins.top - newTop;
|
|
712
718
|
const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
|
|
@@ -721,7 +727,7 @@ export function computeDimensions(
|
|
|
721
727
|
legendLayout.position === 'top'
|
|
722
728
|
? legendLayout.bounds.height + gap
|
|
723
729
|
: 0) +
|
|
724
|
-
|
|
730
|
+
fallbackEffectiveAxisGap;
|
|
725
731
|
margins.bottom = newBottom;
|
|
726
732
|
|
|
727
733
|
chartArea = {
|
package/src/layout/scales.ts
CHANGED
|
@@ -679,8 +679,17 @@ export function computeScales(
|
|
|
679
679
|
xStackEnabled
|
|
680
680
|
) {
|
|
681
681
|
if (encoding.x.stack === 'normalize') {
|
|
682
|
-
// Normalize: domain is [0, 1]
|
|
683
|
-
|
|
682
|
+
// Normalize: domain is [0, 1], default to percentage axis
|
|
683
|
+
const existingAxis = encoding.x.axis;
|
|
684
|
+
const axis =
|
|
685
|
+
existingAxis === false || existingAxis?.format
|
|
686
|
+
? existingAxis
|
|
687
|
+
: { ...(typeof existingAxis === 'object' ? existingAxis : {}), format: '.0%' };
|
|
688
|
+
xChannel = {
|
|
689
|
+
...encoding.x,
|
|
690
|
+
scale: { ...encoding.x.scale, domain: [0, 1], nice: false },
|
|
691
|
+
axis,
|
|
692
|
+
};
|
|
684
693
|
} else if (encoding.x.stack === 'center') {
|
|
685
694
|
// Center: compute max half-sum for symmetric domain
|
|
686
695
|
const yField = encoding.y?.field;
|
|
@@ -785,8 +794,17 @@ export function computeScales(
|
|
|
785
794
|
}
|
|
786
795
|
if ((isBarStacked || isAreaStacked) && encoding.color && encoding.y.type === 'quantitative') {
|
|
787
796
|
if (encoding.y.stack === 'normalize') {
|
|
788
|
-
// Normalize: domain is [0, 1] (VL convention)
|
|
789
|
-
|
|
797
|
+
// Normalize: domain is [0, 1] (VL convention), default to percentage axis
|
|
798
|
+
const existingAxis = encoding.y.axis;
|
|
799
|
+
const axis =
|
|
800
|
+
existingAxis === false || existingAxis?.format
|
|
801
|
+
? existingAxis
|
|
802
|
+
: { ...(typeof existingAxis === 'object' ? existingAxis : {}), format: '.0%' };
|
|
803
|
+
yChannel = {
|
|
804
|
+
...encoding.y,
|
|
805
|
+
scale: { ...encoding.y.scale, domain: [0, 1], nice: false },
|
|
806
|
+
axis,
|
|
807
|
+
};
|
|
790
808
|
} else if (encoding.y.stack === 'center') {
|
|
791
809
|
// Center: compute max half-sum for symmetric domain
|
|
792
810
|
const xField = encoding.x?.field;
|