@opendata-ai/openchart-engine 7.3.0 → 7.4.0
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.d.ts +4 -3
- package/dist/index.js +145 -80
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +4 -15
- package/src/__tests__/axes.test.ts +62 -0
- package/src/__tests__/dimensions.test.ts +63 -0
- package/src/__tests__/helpers/invariants.ts +4 -6
- package/src/__tests__/layout-fuzz.test.ts +24 -8
- package/src/__tests__/layout-invariants.test.ts +4 -2
- package/src/charts/bar/__tests__/compute.test.ts +131 -45
- package/src/charts/bar/compute.ts +44 -4
- package/src/charts/bar/index.ts +9 -2
- package/src/charts/bar/labels.ts +77 -26
- package/src/charts/column/labels.ts +29 -16
- package/src/charts/registry.ts +4 -2
- package/src/compile.ts +3 -1
- package/src/layout/axes/rotation.ts +36 -0
- package/src/layout/axes.ts +65 -35
- package/src/layout/plan.ts +28 -2
- package/src/layout/scales.ts +22 -0
package/src/charts/bar/labels.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* if the bar is wide enough, or outside (to the right) otherwise.
|
|
6
6
|
*
|
|
7
7
|
* Respects the spec's label density setting:
|
|
8
|
-
* - 'all':
|
|
9
|
-
*
|
|
8
|
+
* - 'all': consider every bar's label (no density pre-filter), but still run
|
|
9
|
+
* collision resolution so colliding losers are hidden rather than drawn on
|
|
10
|
+
* top of each other. Composes with the stacked-segment pre-hide pass below.
|
|
11
|
+
* - 'auto': existing behavior (density pre-filter + collision detection)
|
|
10
12
|
* - 'endpoints': first and last bars only
|
|
11
13
|
* - 'none': return empty array
|
|
12
14
|
*/
|
|
@@ -106,6 +108,10 @@ export function computeBarLabels(
|
|
|
106
108
|
// Track whether each candidate fits within its stacked segment.
|
|
107
109
|
// Non-stacked bars are always considered fitting (undefined = fits).
|
|
108
110
|
const fitsInSegment: boolean[] = [];
|
|
111
|
+
// The display anchor x (start/end/middle reference point) for each candidate.
|
|
112
|
+
// Candidates carry the box's LEFT edge for collision math; on emit we map any
|
|
113
|
+
// horizontal nudge back onto this display anchor so textAnchor stays honored.
|
|
114
|
+
const displayAnchorX: number[] = [];
|
|
109
115
|
|
|
110
116
|
const formatter = buildD3Formatter(labelFormat);
|
|
111
117
|
|
|
@@ -183,17 +189,32 @@ export function computeBarLabels(
|
|
|
183
189
|
}
|
|
184
190
|
}
|
|
185
191
|
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
192
|
+
// Collision math works in top-left-edge space so the AABB
|
|
193
|
+
// (rect = { x: anchorX, y: anchorY, width, height }) matches the label's
|
|
194
|
+
// real footprint. The renderer anchors text by textAnchor (start/end/
|
|
195
|
+
// middle) and centers it vertically (dominant-baseline: central), so we
|
|
196
|
+
// convert the display anchor to the box's top-left corner here and shift
|
|
197
|
+
// back on emit. Feeding the display anchor directly desynced the collision
|
|
198
|
+
// box from the drawn text (by width for end/middle, by textHeight/2
|
|
199
|
+
// vertically), letting adjacent bar labels pass the overlap check while
|
|
200
|
+
// visibly colliding.
|
|
201
|
+
const centerY = mark.y + mark.height / 2;
|
|
202
|
+
const anchorY = centerY - textHeight / 2;
|
|
203
|
+
const leftEdgeX =
|
|
204
|
+
textAnchor === 'end'
|
|
205
|
+
? anchorX - textWidth
|
|
206
|
+
: textAnchor === 'middle'
|
|
207
|
+
? anchorX - textWidth / 2
|
|
208
|
+
: anchorX;
|
|
189
209
|
|
|
190
210
|
// Check if label text fits within the stacked segment
|
|
191
211
|
const fits = !(isStacked && textWidth > mark.width - 2 * LABEL_PADDING);
|
|
192
212
|
|
|
213
|
+
displayAnchorX.push(anchorX);
|
|
193
214
|
fitsInSegment.push(fits);
|
|
194
215
|
candidates.push({
|
|
195
216
|
text: valuePart,
|
|
196
|
-
anchorX,
|
|
217
|
+
anchorX: leftEdgeX,
|
|
197
218
|
anchorY,
|
|
198
219
|
width: textWidth,
|
|
199
220
|
height: textHeight,
|
|
@@ -212,46 +233,76 @@ export function computeBarLabels(
|
|
|
212
233
|
|
|
213
234
|
if (candidates.length === 0) return [];
|
|
214
235
|
|
|
215
|
-
// 'all'
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
text: c.text,
|
|
220
|
-
x: c.anchorX,
|
|
221
|
-
y: c.anchorY,
|
|
222
|
-
style: c.style,
|
|
223
|
-
visible: fitsInSegment[i] !== false,
|
|
224
|
-
}));
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// For 'auto' and 'endpoints': pre-mark candidates that don't fit their
|
|
228
|
-
// stacked segment as hidden before running collision detection.
|
|
236
|
+
// Every density that reaches here ('all' and 'auto') pre-hides candidates
|
|
237
|
+
// that don't fit their stacked segment, then runs collision resolution.
|
|
238
|
+
// 'all' just skips the density pre-filter above so every mark is a
|
|
239
|
+
// candidate; colliding losers are still hidden by resolveCollisions.
|
|
229
240
|
const fittingCandidates: LabelCandidate[] = [];
|
|
230
241
|
const unfittingIndices = new Set<number>();
|
|
231
242
|
for (let i = 0; i < candidates.length; i++) {
|
|
232
243
|
if (fitsInSegment[i] === false) {
|
|
233
244
|
unfittingIndices.add(i);
|
|
234
245
|
} else {
|
|
235
|
-
|
|
246
|
+
// Carry the original candidate index so we can zip the resolved output
|
|
247
|
+
// back to it regardless of any reordering resolveCollisions performs.
|
|
248
|
+
fittingCandidates.push({ ...candidates[i], index: i });
|
|
236
249
|
}
|
|
237
250
|
}
|
|
238
251
|
|
|
239
|
-
|
|
252
|
+
// resolveCollisions may reorder its output (it sorts by priority), so key the
|
|
253
|
+
// result by the source index we carried rather than by position.
|
|
254
|
+
const resolvedByOrig = new Map<number, ResolvedLabel>();
|
|
255
|
+
for (const r of resolveCollisions(fittingCandidates)) {
|
|
256
|
+
if (r.index !== undefined) resolvedByOrig.set(r.index, r);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Shift resolved y back from top-edge space to the vertical center that the
|
|
260
|
+
// central-baseline renderer expects (see anchorY derivation above).
|
|
261
|
+
const halfHeight = FONT_SIZE * 1.2 * 0.5;
|
|
240
262
|
|
|
241
263
|
// Re-insert hidden labels for candidates that didn't fit, preserving order
|
|
242
264
|
const results: ResolvedLabel[] = [];
|
|
243
|
-
let resolvedIdx = 0;
|
|
244
265
|
for (let i = 0; i < candidates.length; i++) {
|
|
245
266
|
if (unfittingIndices.has(i)) {
|
|
246
267
|
results.push({
|
|
247
268
|
text: candidates[i].text,
|
|
248
|
-
x:
|
|
249
|
-
y: candidates[i].anchorY,
|
|
269
|
+
x: displayAnchorX[i],
|
|
270
|
+
y: candidates[i].anchorY + halfHeight,
|
|
250
271
|
style: candidates[i].style,
|
|
251
272
|
visible: false,
|
|
252
273
|
});
|
|
253
274
|
} else {
|
|
254
|
-
|
|
275
|
+
const r = resolvedByOrig.get(i);
|
|
276
|
+
if (!r) continue;
|
|
277
|
+
// Collision math ran in left-edge/top space; renderers draw at the display
|
|
278
|
+
// anchor with central baseline. The inverse mapping shifts every point by
|
|
279
|
+
// dx (left-edge -> display anchor) on x and +halfHeight on y. Apply it to
|
|
280
|
+
// the label AND both connector endpoints, or the leader line for an
|
|
281
|
+
// end/middle-anchored label points to the box's top-left corner (up to
|
|
282
|
+
// textWidth + halfHeight away from the text it's calling out).
|
|
283
|
+
const dx = displayAnchorX[i] - candidates[i].anchorX;
|
|
284
|
+
// Drop the internal zip index so it doesn't leak into the label output.
|
|
285
|
+
const { index: _index, ...rest } = r;
|
|
286
|
+
results.push({
|
|
287
|
+
...rest,
|
|
288
|
+
x: r.x + dx,
|
|
289
|
+
y: r.y + halfHeight,
|
|
290
|
+
connector: r.connector
|
|
291
|
+
? {
|
|
292
|
+
...r.connector,
|
|
293
|
+
from: {
|
|
294
|
+
...r.connector.from,
|
|
295
|
+
x: r.connector.from.x + dx,
|
|
296
|
+
y: r.connector.from.y + halfHeight,
|
|
297
|
+
},
|
|
298
|
+
to: {
|
|
299
|
+
...r.connector.to,
|
|
300
|
+
x: r.connector.to.x + dx,
|
|
301
|
+
y: r.connector.to.y + halfHeight,
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
: undefined,
|
|
305
|
+
});
|
|
255
306
|
}
|
|
256
307
|
}
|
|
257
308
|
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* or below (for negative values).
|
|
6
6
|
*
|
|
7
7
|
* Respects the spec's label density setting:
|
|
8
|
-
* - 'all':
|
|
9
|
-
*
|
|
8
|
+
* - 'all': consider every column's label (no density pre-filter), but still
|
|
9
|
+
* run collision resolution so colliding losers are hidden rather than drawn
|
|
10
|
+
* on top of each other
|
|
11
|
+
* - 'auto': existing behavior (density pre-filter + collision detection)
|
|
10
12
|
* - 'endpoints': first and last columns only
|
|
11
13
|
* - 'none': return empty array
|
|
12
14
|
*/
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
estimateTextWidth,
|
|
23
25
|
getRepresentativeColor,
|
|
24
26
|
resolveCollisions,
|
|
27
|
+
textAscent,
|
|
25
28
|
} from '@opendata-ai/openchart-core';
|
|
26
29
|
import { filterByDensity } from '../_shared/density-filter';
|
|
27
30
|
import { formatLabelValue } from '../_shared/format-label-value';
|
|
@@ -97,7 +100,8 @@ export function computeColumnLabels(
|
|
|
97
100
|
|
|
98
101
|
// anchorY is the TOP of the label bounding box so the collision system's
|
|
99
102
|
// AABB check (rect = { y: anchorY, height: textHeight }) is geometrically
|
|
100
|
-
// correct.
|
|
103
|
+
// correct. Collision math stays in top-coordinate space; the ascent shift
|
|
104
|
+
// to the alphabetic baseline is applied only at emission (see below).
|
|
101
105
|
// Positive bar: top = barTop - LABEL_OFFSET_Y - textHeight, text floats above
|
|
102
106
|
// Negative bar: top = barBottom + LABEL_OFFSET_Y, text hangs below
|
|
103
107
|
const anchorX = mark.x + mark.width / 2;
|
|
@@ -119,23 +123,32 @@ export function computeColumnLabels(
|
|
|
119
123
|
fill: labelColor ?? getRepresentativeColor(mark.fill),
|
|
120
124
|
lineHeight: 1.2,
|
|
121
125
|
textAnchor: 'middle',
|
|
122
|
-
dominantBaseline: 'hanging',
|
|
123
126
|
},
|
|
124
127
|
});
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
if (candidates.length === 0) return [];
|
|
128
131
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
// Collision resolution keeps y in top-edge coordinates; renderers position
|
|
133
|
+
// text on the alphabetic baseline, so shift every emitted y down by the
|
|
134
|
+
// ascent instead of relying on dominant-baseline:hanging (WebKit computes
|
|
135
|
+
// hanging from different font metrics than Blink, clipping labels on iOS).
|
|
136
|
+
const ascent = textAscent(FONT_SIZE);
|
|
137
|
+
|
|
138
|
+
// Every density that reaches here ('all' and 'auto') runs collision
|
|
139
|
+
// resolution. 'all' just skips the density pre-filter above so every mark is
|
|
140
|
+
// a candidate; colliding losers are still hidden by resolveCollisions rather
|
|
141
|
+
// than stacked on top of each other.
|
|
142
|
+
return resolveCollisions(candidates).map((label) => ({
|
|
143
|
+
...label,
|
|
144
|
+
y: label.y + ascent,
|
|
145
|
+
// The connector originates at the label; shift its start to the baseline
|
|
146
|
+
// too so the leader stays glued to the shifted text.
|
|
147
|
+
connector: label.connector
|
|
148
|
+
? {
|
|
149
|
+
...label.connector,
|
|
150
|
+
from: { ...label.connector.from, y: label.connector.from.y + ascent },
|
|
151
|
+
}
|
|
152
|
+
: undefined,
|
|
153
|
+
}));
|
|
141
154
|
}
|
package/src/charts/registry.ts
CHANGED
|
@@ -19,8 +19,9 @@ import type { ResolvedScales } from '../layout/scales';
|
|
|
19
19
|
* A chart renderer function.
|
|
20
20
|
*
|
|
21
21
|
* Takes a normalized spec, resolved scales, chart area, layout strategy,
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* the resolved theme for theme-aware styling (e.g. label colors), and the
|
|
23
|
+
* full container width (used by geometry gates that must key off the
|
|
24
|
+
* container, not the derived plot width). Returns an array of marks to render.
|
|
24
25
|
*/
|
|
25
26
|
export type ChartRenderer = (
|
|
26
27
|
spec: NormalizedChartSpec,
|
|
@@ -28,6 +29,7 @@ export type ChartRenderer = (
|
|
|
28
29
|
chartArea: Rect,
|
|
29
30
|
strategy: LayoutStrategy,
|
|
30
31
|
theme: ResolvedTheme,
|
|
32
|
+
containerWidth: number,
|
|
31
33
|
) => Mark[];
|
|
32
34
|
|
|
33
35
|
// ---------------------------------------------------------------------------
|
package/src/compile.ts
CHANGED
|
@@ -683,7 +683,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
683
683
|
renderSpec.markDef,
|
|
684
684
|
);
|
|
685
685
|
const renderer = getChartRenderer(rendererKey);
|
|
686
|
-
const marks: Mark[] = renderer
|
|
686
|
+
const marks: Mark[] = renderer
|
|
687
|
+
? renderer(renderSpec, scales, chartArea, strategy, theme, options.width)
|
|
688
|
+
: [];
|
|
687
689
|
|
|
688
690
|
// Compute the right-side endpoint labels column for multi-series line/area
|
|
689
691
|
// charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-rotation decision for band-scale x-axis tick labels.
|
|
3
|
+
*
|
|
4
|
+
* Vertical band x-axes rotate their tick labels to -45° when horizontal
|
|
5
|
+
* labels are wider than the available band. The layout planner and
|
|
6
|
+
* computeAxes must agree on this decision: the planner reserves the bottom
|
|
7
|
+
* margin for the rotated label footprint, and computeAxes emits the same
|
|
8
|
+
* angle. Sharing this rule keeps the reserved space matched to the rendered
|
|
9
|
+
* labels (a mismatch spilled rotated ticks into the source line).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Band fraction above which horizontal labels are considered too wide to fit. */
|
|
13
|
+
const BAND_FIT_FRACTION = 0.85;
|
|
14
|
+
|
|
15
|
+
/** Angle used when band labels don't fit horizontally. */
|
|
16
|
+
const AUTO_ROTATE_ANGLE = -45;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decide the effective x tick angle for a band scale.
|
|
20
|
+
*
|
|
21
|
+
* @param explicitAngle - User-specified labelAngle (wins if defined).
|
|
22
|
+
* @param maxLabelWidth - Widest tick label width in pixels.
|
|
23
|
+
* @param bandwidth - Band width in pixels.
|
|
24
|
+
* @param labelCount - Number of category labels (rotation needs >= 2).
|
|
25
|
+
* @returns The explicit angle, `-45` if auto-rotation triggers, or `undefined`.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveBandTickAngle(
|
|
28
|
+
explicitAngle: number | undefined,
|
|
29
|
+
maxLabelWidth: number,
|
|
30
|
+
bandwidth: number,
|
|
31
|
+
labelCount: number,
|
|
32
|
+
): number | undefined {
|
|
33
|
+
if (explicitAngle !== undefined) return explicitAngle;
|
|
34
|
+
if (labelCount < 2) return undefined;
|
|
35
|
+
return maxLabelWidth > bandwidth * BAND_FIT_FRACTION ? AUTO_ROTATE_ANGLE : undefined;
|
|
36
|
+
}
|
package/src/layout/axes.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
X_AXIS_TITLE_BAND_ROTATED,
|
|
28
28
|
} from '@opendata-ai/openchart-core';
|
|
29
29
|
import type { ScaleBand } from 'd3-scale';
|
|
30
|
+
import { resolveBandTickAngle } from './axes/rotation';
|
|
30
31
|
import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
31
32
|
import {
|
|
32
33
|
buildContinuousTicks,
|
|
@@ -130,6 +131,40 @@ const CONTINUOUS_TICK_FLOOR = 4;
|
|
|
130
131
|
*/
|
|
131
132
|
const OVERSHOOT_TOLERANCE = 1.5;
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Quantitative axes must always render at least this many tick labels. A lone
|
|
136
|
+
* tick reads as a broken axis (a real iPhone blog chart degenerated to a
|
|
137
|
+
* single "80" when D3's `scale.ticks(2)` collapsed a tight domain like
|
|
138
|
+
* [73, 88] to one nice value). This floor holds at every width.
|
|
139
|
+
*/
|
|
140
|
+
const MIN_QUANTITATIVE_TICKS = 2;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Ensure a continuous scale yields at least MIN_QUANTITATIVE_TICKS ticks.
|
|
144
|
+
*
|
|
145
|
+
* D3's `scale.ticks(n)` picks nice step sizes, so a tight domain can collapse
|
|
146
|
+
* to a single value at low counts (`[73, 88].ticks(2)` → `[80]`). When that
|
|
147
|
+
* happens, request progressively higher counts until at least two ticks
|
|
148
|
+
* appear. Prefers the smallest count that clears the floor, which keeps the
|
|
149
|
+
* ticks spread toward the domain endpoints rather than clustered.
|
|
150
|
+
*/
|
|
151
|
+
function ensureMinContinuousTicks(
|
|
152
|
+
scale: ResolvedScales['x' | 'y'],
|
|
153
|
+
ticks: AxisTick[],
|
|
154
|
+
requestedCount: number,
|
|
155
|
+
): AxisTick[] {
|
|
156
|
+
if (ticks.length >= MIN_QUANTITATIVE_TICKS) return ticks;
|
|
157
|
+
if (!scale || !scaleSupportsTickCount(scale)) return ticks;
|
|
158
|
+
|
|
159
|
+
let best = ticks;
|
|
160
|
+
for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
|
|
161
|
+
const candidate = buildContinuousTicks(scale, n);
|
|
162
|
+
if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
|
|
163
|
+
if (candidate.length > best.length) best = candidate;
|
|
164
|
+
}
|
|
165
|
+
return best;
|
|
166
|
+
}
|
|
167
|
+
|
|
133
168
|
/**
|
|
134
169
|
* Fit continuous ticks by re-requesting progressively fewer ticks from the
|
|
135
170
|
* scale. D3's `scale.ticks(n)` always returns evenly-spaced round values, so
|
|
@@ -159,10 +194,15 @@ function fitContinuousTicks(
|
|
|
159
194
|
): AxisTick[] {
|
|
160
195
|
if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
|
|
161
196
|
|
|
197
|
+
// Floor the initial set: a tight domain can make D3 return a single nice
|
|
198
|
+
// value, which would otherwise pass straight through (no overlap, no
|
|
199
|
+
// overshoot) and render as a lone tick.
|
|
200
|
+
const flooredTicks = ensureMinContinuousTicks(scale, initialTicks, initialCount);
|
|
201
|
+
|
|
162
202
|
const tolerance = initialCount * OVERSHOOT_TOLERANCE;
|
|
163
|
-
const overshoots =
|
|
164
|
-
const overlaps = ticksOverlap(
|
|
165
|
-
if (!overshoots && !overlaps) return
|
|
203
|
+
const overshoots = flooredTicks.length > tolerance;
|
|
204
|
+
const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
|
|
205
|
+
if (!overshoots && !overlaps) return flooredTicks;
|
|
166
206
|
|
|
167
207
|
// Enforce the floor only when the axis is long enough to fit that many
|
|
168
208
|
// labels without overlap. Very short axes can fall below.
|
|
@@ -193,7 +233,8 @@ function fitContinuousTicks(
|
|
|
193
233
|
// No candidate fit cleanly. Thin whatever most recently met the floor; if
|
|
194
234
|
// nothing did, synthesize a floor-count set directly from the scale so we
|
|
195
235
|
// never hand the overshot initialTicks to the middle-pruning thinner.
|
|
196
|
-
const
|
|
236
|
+
const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
|
|
237
|
+
const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
|
|
197
238
|
return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
|
|
198
239
|
}
|
|
199
240
|
|
|
@@ -385,9 +426,7 @@ export function computeAxes(
|
|
|
385
426
|
const w = measureLabel(t.label, fontSize, fontWeight, measureText);
|
|
386
427
|
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
387
428
|
}
|
|
388
|
-
|
|
389
|
-
tickAngle = -45;
|
|
390
|
-
}
|
|
429
|
+
tickAngle = resolveBandTickAngle(undefined, maxLabelWidth, bandwidth, allTicks.length);
|
|
391
430
|
}
|
|
392
431
|
|
|
393
432
|
// Thin tick labels to prevent overlap (skip for explicit tick values).
|
|
@@ -554,38 +593,29 @@ export function computeAxes(
|
|
|
554
593
|
const totalWidth = dataContext?.totalWidth ?? chartArea.x + chartArea.width;
|
|
555
594
|
|
|
556
595
|
if (result.x) {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
if (rotatedHeight != null) {
|
|
573
|
-
result.x.extent = result.x.label ? rotatedHeight + X_AXIS_TITLE_BAND_ROTATED : rotatedHeight;
|
|
574
|
-
} else {
|
|
575
|
-
result.x.extent = computeXAxisExtentFromLabels({
|
|
576
|
-
labels: result.x.ticks.map((t) => t.label),
|
|
577
|
-
tickAngle: result.x.tickAngle,
|
|
578
|
-
hasTitle: !!result.x.label,
|
|
579
|
-
tickFontSize: result.x.tickLabelStyle.fontSize,
|
|
580
|
-
tickFontWeight: result.x.tickLabelStyle.fontWeight,
|
|
581
|
-
xAxisHeight: theme.spacing.xAxisHeight,
|
|
582
|
-
});
|
|
583
|
-
}
|
|
596
|
+
const isRotated = !!result.x.tickAngle && Math.abs(result.x.tickAngle) > 10;
|
|
597
|
+
|
|
598
|
+
// Single source of truth for the x-axis vertical extent (rotated or flat).
|
|
599
|
+
// computeXAxisExtentFromLabels applies the correct rotated-extent math
|
|
600
|
+
// (textWidth*|sin θ| + lineHeight*|cos θ|), so the reserved bottom margin
|
|
601
|
+
// matches what the labels actually occupy.
|
|
602
|
+
result.x.extent = computeXAxisExtentFromLabels({
|
|
603
|
+
labels: result.x.ticks.map((t) => t.label),
|
|
604
|
+
tickAngle: result.x.tickAngle,
|
|
605
|
+
hasTitle: !!result.x.label,
|
|
606
|
+
tickFontSize: result.x.tickLabelStyle.fontSize,
|
|
607
|
+
tickFontWeight: result.x.tickLabelStyle.fontWeight,
|
|
608
|
+
xAxisHeight: theme.spacing.xAxisHeight,
|
|
609
|
+
measure: (t, fs, fw) => measureLabel(t, fs, fw, measureText),
|
|
610
|
+
});
|
|
584
611
|
|
|
585
612
|
if (result.x.label) {
|
|
613
|
+
// Rotated: place the title just below the tick band (extent already
|
|
614
|
+
// includes the rotated label height and the title band). Flat: fixed 35px.
|
|
615
|
+
const rotatedBand = isRotated ? result.x.extent - X_AXIS_TITLE_BAND_ROTATED + 14 : 35;
|
|
586
616
|
result.x.titlePosition = {
|
|
587
617
|
x: chartArea.x + chartArea.width / 2,
|
|
588
|
-
y: chartArea.y + chartArea.height +
|
|
618
|
+
y: chartArea.y + chartArea.height + rotatedBand,
|
|
589
619
|
};
|
|
590
620
|
}
|
|
591
621
|
}
|
package/src/layout/plan.ts
CHANGED
|
@@ -32,8 +32,9 @@ import { format as d3Format } from 'd3-format';
|
|
|
32
32
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
33
33
|
import { computeLegendContent, type LegendContent } from '../legend/compute';
|
|
34
34
|
import { legendGap } from '../legend/wrap';
|
|
35
|
+
import { resolveBandTickAngle } from './axes/rotation';
|
|
35
36
|
import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
|
|
36
|
-
import { computeScales } from './scales';
|
|
37
|
+
import { computeScales, estimateBandwidth } from './scales';
|
|
37
38
|
import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
|
|
38
39
|
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
@@ -319,13 +320,38 @@ export function resolveLayoutPlan(
|
|
|
319
320
|
bottomLegendReservation,
|
|
320
321
|
);
|
|
321
322
|
|
|
323
|
+
// Effective x tick angle. Band x-axes auto-rotate to -45° when horizontal
|
|
324
|
+
// labels don't fit their band; computeAxes makes the same call later. The
|
|
325
|
+
// planner must know the angle now so it reserves the rotated-label
|
|
326
|
+
// footprint in the bottom margin instead of the (smaller) flat one.
|
|
327
|
+
let effectiveXTickAngle = xTickAngle;
|
|
328
|
+
if (xTickAngle === undefined && xLabels.length > 1) {
|
|
329
|
+
const rightMarginEst = hPad + (isRadial ? hPad : axisMargin);
|
|
330
|
+
const plotWidth = Math.max(0, width - leftGutter - Math.max(rightMarginEst, hPad));
|
|
331
|
+
// Mirror d3 scaleBand: step = plotWidth / (n - paddingInner + 2*paddingOuter),
|
|
332
|
+
// bandwidth = step * (1 - paddingInner). Uses the same override resolution
|
|
333
|
+
// as buildBandScale so paddingInner/paddingOuter are honored here too.
|
|
334
|
+
const bandwidth = estimateBandwidth(encoding.x?.scale, plotWidth, xLabels.length);
|
|
335
|
+
let maxXLabelWidth = 0;
|
|
336
|
+
for (const label of xLabels) {
|
|
337
|
+
const w = measure(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
338
|
+
if (w > maxXLabelWidth) maxXLabelWidth = w;
|
|
339
|
+
}
|
|
340
|
+
effectiveXTickAngle = resolveBandTickAngle(
|
|
341
|
+
undefined,
|
|
342
|
+
maxXLabelWidth,
|
|
343
|
+
bandwidth,
|
|
344
|
+
xLabels.length,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
322
348
|
// X-axis extent
|
|
323
349
|
const xAxisExtent =
|
|
324
350
|
isRadial || xAxisSuppressed
|
|
325
351
|
? 0
|
|
326
352
|
: computeXAxisExtentFromLabels({
|
|
327
353
|
labels: xLabels,
|
|
328
|
-
tickAngle:
|
|
354
|
+
tickAngle: effectiveXTickAngle,
|
|
329
355
|
hasTitle: hasXAxisTitle,
|
|
330
356
|
tickFontSize: theme.fonts.sizes.axisTick,
|
|
331
357
|
tickFontWeight: theme.fonts.weights.normal,
|
package/src/layout/scales.ts
CHANGED
|
@@ -463,6 +463,28 @@ function evenRange(start: number, end: number, count: number): number[] {
|
|
|
463
463
|
return Array.from({ length: count }, (_, i) => start + step * i);
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Estimate d3 scaleBand bandwidth for a given plot width and category count,
|
|
468
|
+
* mirroring buildBandScale's padding resolution. d3 sets both paddingInner and
|
|
469
|
+
* paddingOuter to `padding`, then paddingInner/paddingOuter overrides adjust
|
|
470
|
+
* each independently. step = width / (n - paddingInner + 2*paddingOuter);
|
|
471
|
+
* bandwidth = step * (1 - paddingInner).
|
|
472
|
+
*/
|
|
473
|
+
export function estimateBandwidth(
|
|
474
|
+
scaleConfig: EncodingChannel['scale'] | undefined,
|
|
475
|
+
plotWidth: number,
|
|
476
|
+
n: number,
|
|
477
|
+
): number {
|
|
478
|
+
if (n <= 0) return 0;
|
|
479
|
+
const padding = scaleConfig?.padding ?? 0.35;
|
|
480
|
+
const paddingInner = scaleConfig?.paddingInner ?? padding;
|
|
481
|
+
const paddingOuter = scaleConfig?.paddingOuter ?? padding;
|
|
482
|
+
const denom = n - paddingInner + 2 * paddingOuter;
|
|
483
|
+
if (denom <= 0) return 0;
|
|
484
|
+
const step = plotWidth / denom;
|
|
485
|
+
return step * (1 - paddingInner);
|
|
486
|
+
}
|
|
487
|
+
|
|
466
488
|
function buildBandScale(
|
|
467
489
|
channel: EncodingChannel,
|
|
468
490
|
data: DataRow[],
|