@opendata-ai/openchart-engine 7.9.0 → 7.9.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 +1459 -1404
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +108 -0
- package/src/__tests__/dimensions.test.ts +157 -5
- package/src/layout/axes.ts +142 -35
- package/src/layout/dimensions.ts +10 -11
- package/src/layout/plan.ts +11 -12
- package/src/tilemap/compile-tilemap.ts +93 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.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": "7.9.
|
|
51
|
+
"@opendata-ai/openchart-core": "7.9.2",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -482,6 +482,114 @@ describe('text-aware tick density', () => {
|
|
|
482
482
|
expect(axes.x!.ticks.length).toBe(categories.length);
|
|
483
483
|
});
|
|
484
484
|
|
|
485
|
+
it('keeps narrow band labels when one label is much wider (no global decimation)', () => {
|
|
486
|
+
// Regression: 5 rotated year labels where "2026 (to wk 17)" is much wider
|
|
487
|
+
// than the others. The old every-other thinning dropped BOTH "2023" and
|
|
488
|
+
// "2025" even though the narrow labels had ample room — a single wide label
|
|
489
|
+
// forced global decimation. Greedy per-label retention keeps the narrow
|
|
490
|
+
// ones; only the neighbor immediately crowded by the wide final label drops.
|
|
491
|
+
const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
|
|
492
|
+
const barSpec: NormalizedChartSpec = {
|
|
493
|
+
...lineSpec,
|
|
494
|
+
markType: 'bar',
|
|
495
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
496
|
+
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
497
|
+
encoding: {
|
|
498
|
+
x: { field: 'cat', type: 'nominal' },
|
|
499
|
+
y: { field: 'val', type: 'quantitative' },
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
// measureText reports the wide label as genuinely wide and the year labels
|
|
504
|
+
// as narrow, matching real glyph metrics.
|
|
505
|
+
const measure = (text: string) => ({
|
|
506
|
+
width: text.length * 8,
|
|
507
|
+
height: 12,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Narrow area (280px) forces -45° rotation. The middle narrow labels survive
|
|
511
|
+
// — old every-other thinning would have dropped 2023 too.
|
|
512
|
+
const narrowArea = { x: 50, y: 50, width: 280, height: 300 };
|
|
513
|
+
const narrowScales = computeScales(barSpec, narrowArea, barSpec.data);
|
|
514
|
+
const narrowAxes = computeAxes(narrowScales, narrowArea, fullStrategy, theme, measure);
|
|
515
|
+
const narrowKept = narrowAxes.x!.ticks.map((t) => t.label);
|
|
516
|
+
expect(narrowAxes.x!.tickAngle).toBe(-45);
|
|
517
|
+
expect(narrowKept).toContain('2023');
|
|
518
|
+
expect(narrowKept).toContain('2024');
|
|
519
|
+
expect(narrowKept).toContain('2026 (to wk 17)');
|
|
520
|
+
|
|
521
|
+
// Wider area (500px) has room for every label — none are thinned.
|
|
522
|
+
const wideArea = { x: 50, y: 50, width: 500, height: 300 };
|
|
523
|
+
const wideScales = computeScales(barSpec, wideArea, barSpec.data);
|
|
524
|
+
const wideAxes = computeAxes(wideScales, wideArea, fullStrategy, theme, measure);
|
|
525
|
+
expect(wideAxes.x!.ticks.length).toBe(categories.length);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('keeps a short neighbor that a wide final label only barely reaches', () => {
|
|
529
|
+
// Regression (mobile): with a short label whose footprint clears the wide
|
|
530
|
+
// final label by a couple pixels — less than the comfort minGap but with no
|
|
531
|
+
// real overlap — the last-label protection must NOT drop it. At the phone
|
|
532
|
+
// width where "2026 (to wk 17)" sits ~2px from "2025", dropping "2025" to
|
|
533
|
+
// seat the wide endpoint reads as arbitrary. The protection pass keys on
|
|
534
|
+
// true pixel overlap, not the comfort gap, so "2025" survives.
|
|
535
|
+
const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
|
|
536
|
+
const barSpec: NormalizedChartSpec = {
|
|
537
|
+
...lineSpec,
|
|
538
|
+
markType: 'bar',
|
|
539
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
540
|
+
data: categories.map((cat, i) => ({ cat, val: [122, 47, 266, 2000, 1600][i] })),
|
|
541
|
+
encoding: {
|
|
542
|
+
x: { field: 'cat', type: 'nominal' },
|
|
543
|
+
y: { field: 'val', type: 'quantitative' },
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Uses default (real) text measurement. At 300px the band centers sit ~56px
|
|
548
|
+
// apart; the year labels project to ~17px at -45° and the wide label to
|
|
549
|
+
// ~54px, leaving "2025" and "2026 (to wk 17)" ~2px apart — a near miss.
|
|
550
|
+
const area = { x: 60, y: 50, width: 300, height: 300 };
|
|
551
|
+
const scales = computeScales(barSpec, area, barSpec.data);
|
|
552
|
+
const axes = computeAxes(scales, area, fullStrategy, theme);
|
|
553
|
+
expect(axes.x!.tickAngle).toBe(-45);
|
|
554
|
+
const kept = axes.x!.ticks.map((t) => t.label);
|
|
555
|
+
expect(kept).toContain('2025');
|
|
556
|
+
expect(kept).toContain('2026 (to wk 17)');
|
|
557
|
+
expect(kept.length).toBe(categories.length);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('drops every neighbor a very wide final label overlaps, not just the nearest', () => {
|
|
561
|
+
// A final category long enough to project across several bands at -45° must
|
|
562
|
+
// clear ALL kept labels its footprint overlaps. The last-label protection
|
|
563
|
+
// pass drops each colliding neighbor, not only the immediate one — otherwise
|
|
564
|
+
// an earlier kept label still overlaps the forced final label.
|
|
565
|
+
const categories = ['A', 'B', 'C', 'D', 'This Is A Very Long Final Category Name'];
|
|
566
|
+
const barSpec: NormalizedChartSpec = {
|
|
567
|
+
...lineSpec,
|
|
568
|
+
markType: 'bar',
|
|
569
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
570
|
+
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
571
|
+
encoding: {
|
|
572
|
+
x: { field: 'cat', type: 'nominal' },
|
|
573
|
+
y: { field: 'val', type: 'quantitative' },
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
const measure = (text: string) => ({ width: text.length * 8, height: 12 });
|
|
577
|
+
const narrowArea = { x: 50, y: 50, width: 260, height: 300 };
|
|
578
|
+
const scales = computeScales(barSpec, narrowArea, barSpec.data);
|
|
579
|
+
const axes = computeAxes(scales, narrowArea, fullStrategy, theme, measure);
|
|
580
|
+
expect(axes.x!.tickAngle).toBe(-45);
|
|
581
|
+
|
|
582
|
+
const kept = axes.x!.ticks.map((t) => t.label);
|
|
583
|
+
// The forced final label is always retained.
|
|
584
|
+
expect(kept).toContain('This Is A Very Long Final Category Name');
|
|
585
|
+
// Every middle label the wide final label's footprint overlaps is dropped —
|
|
586
|
+
// not just the nearest one. Before the fix, "B" and "C" would survive and
|
|
587
|
+
// overlap the final label; only "D" (the immediate neighbor) was dropped.
|
|
588
|
+
expect(kept).not.toContain('B');
|
|
589
|
+
expect(kept).not.toContain('C');
|
|
590
|
+
expect(kept).not.toContain('D');
|
|
591
|
+
});
|
|
592
|
+
|
|
485
593
|
it('y-axis gridlines match ticks so every gridline has a label', () => {
|
|
486
594
|
// Force thinning by using a measureText that reports wide labels
|
|
487
595
|
const wideMeasure = () => ({ width: 200, height: 12 });
|
|
@@ -162,7 +162,65 @@ describe('computeDimensions', () => {
|
|
|
162
162
|
expect(dimsWithTitle.margins.left - dimsWithoutTitle.margins.left).toBeGreaterThanOrEqual(11);
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
it('y-axis title margin scales with tick label width', () => {
|
|
165
|
+
it('y-axis title margin scales with tick label width (gutter ticks)', () => {
|
|
166
|
+
// Bar chart: y-tick labels sit in a left gutter, so the title must clear
|
|
167
|
+
// them. Wider tick labels ("2,000,000" vs "9") push the title further out.
|
|
168
|
+
const barBase: NormalizedChartSpec = {
|
|
169
|
+
...baseSpec,
|
|
170
|
+
markType: 'bar',
|
|
171
|
+
markDef: { type: 'bar' },
|
|
172
|
+
};
|
|
173
|
+
const smallValues: NormalizedChartSpec = {
|
|
174
|
+
...barBase,
|
|
175
|
+
data: [
|
|
176
|
+
{ cat: 'a', value: 5 },
|
|
177
|
+
{ cat: 'b', value: 9 },
|
|
178
|
+
],
|
|
179
|
+
encoding: {
|
|
180
|
+
x: { field: 'cat', type: 'nominal' },
|
|
181
|
+
y: { field: 'value', type: 'quantitative', axis: { title: 'Count' } },
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
const largeValues: NormalizedChartSpec = {
|
|
185
|
+
...barBase,
|
|
186
|
+
data: [
|
|
187
|
+
{ cat: 'a', value: 1_500_000 },
|
|
188
|
+
{ cat: 'b', value: 2_000_000 },
|
|
189
|
+
],
|
|
190
|
+
encoding: {
|
|
191
|
+
x: { field: 'cat', type: 'nominal' },
|
|
192
|
+
y: {
|
|
193
|
+
field: 'value',
|
|
194
|
+
type: 'quantitative',
|
|
195
|
+
axis: { title: 'Revenue ($)', format: ',.0f' },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const dimsSmall = computeDimensions(
|
|
201
|
+
smallValues,
|
|
202
|
+
{ width: 600, height: 400 },
|
|
203
|
+
emptyLegend,
|
|
204
|
+
lightTheme,
|
|
205
|
+
);
|
|
206
|
+
const dimsLarge = computeDimensions(
|
|
207
|
+
largeValues,
|
|
208
|
+
{ width: 600, height: 400 },
|
|
209
|
+
emptyLegend,
|
|
210
|
+
lightTheme,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Explicit format ",.0f" on large values produces "2,000,000" tick labels
|
|
214
|
+
// that are much wider than the abbreviated "0.0" from small values,
|
|
215
|
+
// so the y-axis title margin should grow to keep clearance
|
|
216
|
+
expect(dimsLarge.margins.left).toBeGreaterThan(dimsSmall.margins.left);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('y-axis title margin ignores tick label width for inline ticks (line)', () => {
|
|
220
|
+
// Line chart with continuous y: tick labels render inline (above their
|
|
221
|
+
// gridline inside the plot), not in a left gutter. The title only needs to
|
|
222
|
+
// clear the chart edge, so wide tick values must NOT inflate the margin —
|
|
223
|
+
// that dead padding is the exact bug this guards against.
|
|
166
224
|
const smallValues: NormalizedChartSpec = {
|
|
167
225
|
...baseSpec,
|
|
168
226
|
data: [
|
|
@@ -203,10 +261,63 @@ describe('computeDimensions', () => {
|
|
|
203
261
|
lightTheme,
|
|
204
262
|
);
|
|
205
263
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
264
|
+
expect(dimsLarge.margins.left).toBe(dimsSmall.margins.left);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('reserves gutter margin when an explicit band scale forces gutter ticks (line)', () => {
|
|
268
|
+
// A line chart's y-tick position is decided by the RESOLVED scale type, not
|
|
269
|
+
// the field type. An explicit `scale.type: 'band'` on a quantitative field
|
|
270
|
+
// resolves to a categorical (gutter) axis, so computeAxes draws a gutter
|
|
271
|
+
// title. The margin reservation must agree — reserving the inline (narrow)
|
|
272
|
+
// margin here would let the title collide with the gutter tick labels.
|
|
273
|
+
const bandScaleLine: NormalizedChartSpec = {
|
|
274
|
+
...baseSpec,
|
|
275
|
+
data: [
|
|
276
|
+
{ date: '2020-01-01', value: 1_500_000 },
|
|
277
|
+
{ date: '2021-01-01', value: 2_000_000 },
|
|
278
|
+
],
|
|
279
|
+
encoding: {
|
|
280
|
+
x: { field: 'date', type: 'temporal' },
|
|
281
|
+
y: {
|
|
282
|
+
field: 'value',
|
|
283
|
+
type: 'quantitative',
|
|
284
|
+
scale: { type: 'band' },
|
|
285
|
+
axis: { title: 'Revenue ($)', format: ',.0f' },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
const inlineLine: NormalizedChartSpec = {
|
|
290
|
+
...baseSpec,
|
|
291
|
+
data: [
|
|
292
|
+
{ date: '2020-01-01', value: 1_500_000 },
|
|
293
|
+
{ date: '2021-01-01', value: 2_000_000 },
|
|
294
|
+
],
|
|
295
|
+
encoding: {
|
|
296
|
+
x: { field: 'date', type: 'temporal' },
|
|
297
|
+
y: {
|
|
298
|
+
field: 'value',
|
|
299
|
+
type: 'quantitative',
|
|
300
|
+
axis: { title: 'Revenue ($)', format: ',.0f' },
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const bandDims = computeDimensions(
|
|
306
|
+
bandScaleLine,
|
|
307
|
+
{ width: 600, height: 400 },
|
|
308
|
+
emptyLegend,
|
|
309
|
+
lightTheme,
|
|
310
|
+
);
|
|
311
|
+
const inlineDims = computeDimensions(
|
|
312
|
+
inlineLine,
|
|
313
|
+
{ width: 600, height: 400 },
|
|
314
|
+
emptyLegend,
|
|
315
|
+
lightTheme,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// The gutter (band-scale) reservation includes the wide tick-label width, so
|
|
319
|
+
// it must exceed the inline reservation that omits it.
|
|
320
|
+
expect(bandDims.margins.left).toBeGreaterThan(inlineDims.margins.left);
|
|
210
321
|
});
|
|
211
322
|
|
|
212
323
|
it('applies dark mode theme adaptation', () => {
|
|
@@ -718,4 +829,45 @@ describe('bottom legend placement (defect-3 regression)', () => {
|
|
|
718
829
|
);
|
|
719
830
|
});
|
|
720
831
|
});
|
|
832
|
+
|
|
833
|
+
it('keeps the inline y-axis title inside the container on narrow, large-font charts', () => {
|
|
834
|
+
// Regression (mobile): the inline y-tick fix reserved a narrow left margin,
|
|
835
|
+
// but computeAxes still positioned the rotated y-title with the GUTTER
|
|
836
|
+
// offset (tick-label width + viewport floor). On a phone at deck-scale
|
|
837
|
+
// fonts that pushed the title's rotation center to x ≈ -1, clipping the
|
|
838
|
+
// whole title off the left edge. The title offset now honors the inline
|
|
839
|
+
// flag, so titlePosition.x stays a comfortable distance inside x = 0.
|
|
840
|
+
const spec = {
|
|
841
|
+
mark: { type: 'line' as const, point: true },
|
|
842
|
+
data: [
|
|
843
|
+
{ year: '2009-10', pct: 94.2 },
|
|
844
|
+
{ year: '2015-16', pct: 93.8 },
|
|
845
|
+
{ year: '2020-21', pct: 95.0 },
|
|
846
|
+
],
|
|
847
|
+
encoding: {
|
|
848
|
+
x: { field: 'year', type: 'ordinal' as const },
|
|
849
|
+
y: {
|
|
850
|
+
field: 'pct',
|
|
851
|
+
type: 'quantitative' as const,
|
|
852
|
+
axis: { title: 'Kindergartners with 2-dose MMR', grid: true, format: '.0f' },
|
|
853
|
+
scale: { domain: [90, 96], nice: false },
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
theme: { fonts: { sizes: { body: 21, axisTick: 18 } } },
|
|
857
|
+
chrome: { title: 'T', subtitle: 'S', source: 'Src' },
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
for (const width of [320, 360, 390]) {
|
|
861
|
+
const layout = compileChart(spec, { width, height: 600 });
|
|
862
|
+
const yAxis = layout.axes.y;
|
|
863
|
+
expect(yAxis?.tickPosition).toBe('inline');
|
|
864
|
+
const title = yAxis?.titlePosition;
|
|
865
|
+
expect(title).toBeDefined();
|
|
866
|
+
// The title is rotated -90° anchored middle; its glyph box straddles the
|
|
867
|
+
// rotation center by ~half the title font. The left edge must stay on the
|
|
868
|
+
// canvas (x >= 0). Assert the rotation center clears half a glyph.
|
|
869
|
+
const halfGlyph = Math.ceil(layout.theme.fonts.sizes.body / 2);
|
|
870
|
+
expect(title!.x - halfGlyph).toBeGreaterThanOrEqual(0);
|
|
871
|
+
}
|
|
872
|
+
});
|
|
721
873
|
});
|
package/src/layout/axes.ts
CHANGED
|
@@ -43,6 +43,59 @@ import type { ResolvedScales } from './scales';
|
|
|
43
43
|
// them from './layout/axes'.
|
|
44
44
|
export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
45
45
|
|
|
46
|
+
/** Scale types that render as a categorical (non-continuous) y-axis. */
|
|
47
|
+
const CATEGORICAL_SCALE_TYPES = new Set(['band', 'point', 'ordinal']);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether the y-axis renders tick labels inline (above their gridline inside
|
|
51
|
+
* the plot) rather than in a left gutter.
|
|
52
|
+
*
|
|
53
|
+
* This is the single source of truth shared by margin reservation (plan.ts,
|
|
54
|
+
* dimensions.ts) and the renderer's title placement. All three must agree or
|
|
55
|
+
* the reserved left margin won't match where the title is drawn.
|
|
56
|
+
*
|
|
57
|
+
* Editorial line/area charts with a continuous y-axis default to inline ticks.
|
|
58
|
+
* Continuity is decided by the RESOLVED scale type, not the field type: an
|
|
59
|
+
* explicit `scale.type` override (e.g. a band scale forced onto a quantitative
|
|
60
|
+
* field) wins, mirroring buildPositionalScale() in scales.ts. Right-side
|
|
61
|
+
* (dual-axis) y-axes always use the gutter. An explicit axis.tickPosition wins
|
|
62
|
+
* over the default.
|
|
63
|
+
*/
|
|
64
|
+
export function yTickPositionIsInline(
|
|
65
|
+
yChannel:
|
|
66
|
+
| {
|
|
67
|
+
type?: string;
|
|
68
|
+
scale?: { type?: string };
|
|
69
|
+
axis?: unknown;
|
|
70
|
+
}
|
|
71
|
+
| undefined,
|
|
72
|
+
markType: string,
|
|
73
|
+
/**
|
|
74
|
+
* Resolved continuity of the y-scale, when the caller already has the built
|
|
75
|
+
* scale (computeAxes). When omitted, continuity is inferred from the encoding
|
|
76
|
+
* (explicit scale.type override, else field type) so the reservation paths
|
|
77
|
+
* predict the same answer computeAxes will produce.
|
|
78
|
+
*/
|
|
79
|
+
resolvedContinuous?: boolean,
|
|
80
|
+
): boolean {
|
|
81
|
+
if (!yChannel) return false;
|
|
82
|
+
const axisCfg = (yChannel.axis as Record<string, unknown> | undefined) ?? undefined;
|
|
83
|
+
const explicit = axisCfg?.tickPosition as 'inline' | 'gutter' | undefined;
|
|
84
|
+
if (explicit) return explicit === 'inline';
|
|
85
|
+
|
|
86
|
+
const isLineOrArea = markType === 'line' || markType === 'area';
|
|
87
|
+
if (!isLineOrArea) return false;
|
|
88
|
+
if ((axisCfg?.orient as string | undefined) === 'right') return false;
|
|
89
|
+
|
|
90
|
+
if (resolvedContinuous !== undefined) return resolvedContinuous;
|
|
91
|
+
|
|
92
|
+
// Predict the resolved scale type: explicit override wins, else field type.
|
|
93
|
+
const explicitScaleType = yChannel.scale?.type;
|
|
94
|
+
return explicitScaleType
|
|
95
|
+
? !CATEGORICAL_SCALE_TYPES.has(explicitScaleType)
|
|
96
|
+
: yChannel.type === 'quantitative' || yChannel.type === 'temporal';
|
|
97
|
+
}
|
|
98
|
+
|
|
46
99
|
// ---------------------------------------------------------------------------
|
|
47
100
|
// Constants
|
|
48
101
|
// ---------------------------------------------------------------------------
|
|
@@ -242,36 +295,46 @@ function fitContinuousTicks(
|
|
|
242
295
|
}
|
|
243
296
|
|
|
244
297
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
298
|
+
* Horizontal footprint `[left, right]` of a rotated band tick label, in axis
|
|
299
|
+
* pixels. Rotated labels are anchored at the tick position (the rotation
|
|
300
|
+
* pivot), not centered on it: at a negative angle the renderer right-anchors
|
|
301
|
+
* the text so it extends left of the pivot; at a positive angle it left-anchors
|
|
302
|
+
* so it extends right. The horizontal projection is `width * cos(angle)`.
|
|
303
|
+
*
|
|
304
|
+
* Modeling the footprint as centered (the old behavior) over-estimates each
|
|
305
|
+
* label's reach by half its width in the wrong direction, which made a single
|
|
306
|
+
* wide label (e.g. "2026 (to wk 17)") report overlaps against neighbors that
|
|
307
|
+
* actually clear it — triggering needless thinning of the whole axis.
|
|
249
308
|
*/
|
|
250
|
-
function
|
|
251
|
-
|
|
309
|
+
function labelSpanAtAngle(
|
|
310
|
+
tick: AxisTick,
|
|
252
311
|
angleDeg: number,
|
|
253
312
|
fontSize: number,
|
|
254
313
|
fontWeight: number,
|
|
255
314
|
measureText?: MeasureTextFn,
|
|
256
|
-
):
|
|
257
|
-
if (ticks.length < 2) return false;
|
|
315
|
+
): { left: number; right: number } {
|
|
258
316
|
const angleRad = (Math.abs(angleDeg) * Math.PI) / 180;
|
|
259
317
|
const cosA = angleRad > 0 ? Math.abs(Math.cos(angleRad)) : 1;
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const aRight = ticks[i].position + aWidth / 2;
|
|
265
|
-
const bLeft = ticks[i + 1].position - bWidth / 2;
|
|
266
|
-
if (aRight + minGap > bLeft) return true;
|
|
318
|
+
const projected = measureLabel(tick.label, fontSize, fontWeight, measureText) * cosA;
|
|
319
|
+
if (angleDeg === 0) {
|
|
320
|
+
// Horizontal labels are centered on the tick.
|
|
321
|
+
return { left: tick.position - projected / 2, right: tick.position + projected / 2 };
|
|
267
322
|
}
|
|
268
|
-
|
|
323
|
+
if (angleDeg < 0) {
|
|
324
|
+
// Right-anchored: pivot is the right edge, text extends left.
|
|
325
|
+
return { left: tick.position - projected, right: tick.position };
|
|
326
|
+
}
|
|
327
|
+
// Left-anchored: pivot is the left edge, text extends right.
|
|
328
|
+
return { left: tick.position, right: tick.position + projected };
|
|
269
329
|
}
|
|
270
330
|
|
|
271
331
|
/**
|
|
272
|
-
* Thin band-scale tick labels only
|
|
273
|
-
* effective angle
|
|
274
|
-
*
|
|
332
|
+
* Thin band-scale tick labels only where they actually collide at their
|
|
333
|
+
* effective angle, greedily keeping every label that clears the last one
|
|
334
|
+
* retained. This preserves narrow labels that have room even when a single
|
|
335
|
+
* wide label elsewhere on the axis forces some thinning near it, instead of
|
|
336
|
+
* decimating the whole axis every-other. First and last labels are always
|
|
337
|
+
* kept. Most grouped bar charts keep every label even at -45°.
|
|
275
338
|
*/
|
|
276
339
|
function thinBandTicksIfNeeded(
|
|
277
340
|
ticks: AxisTick[],
|
|
@@ -280,19 +343,48 @@ function thinBandTicksIfNeeded(
|
|
|
280
343
|
fontWeight: number,
|
|
281
344
|
measureText?: MeasureTextFn,
|
|
282
345
|
): AxisTick[] {
|
|
283
|
-
if (
|
|
346
|
+
if (ticks.length < 3) return ticks;
|
|
347
|
+
const minGap = fontSize * 0.5;
|
|
284
348
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
349
|
+
const spans = ticks.map((t) => labelSpanAtAngle(t, angleDeg, fontSize, fontWeight, measureText));
|
|
350
|
+
|
|
351
|
+
// Greedy left-to-right retention: keep a label only if its footprint clears
|
|
352
|
+
// the last kept label's right edge by minGap. Always keep the first label.
|
|
353
|
+
const keep = new Array<boolean>(ticks.length).fill(false);
|
|
354
|
+
keep[0] = true;
|
|
355
|
+
let lastRight = spans[0].right;
|
|
356
|
+
for (let i = 1; i < ticks.length; i++) {
|
|
357
|
+
if (spans[i].left >= lastRight + minGap) {
|
|
358
|
+
keep[i] = true;
|
|
359
|
+
lastRight = spans[i].right;
|
|
290
360
|
}
|
|
291
|
-
if (current.length > 1) thinned.push(current[current.length - 1]);
|
|
292
|
-
current = thinned;
|
|
293
|
-
if (!bandTicksOverlapAtAngle(current, angleDeg, fontSize, fontWeight, measureText)) break;
|
|
294
361
|
}
|
|
295
|
-
|
|
362
|
+
|
|
363
|
+
// Always keep the last label. If keeping it collides with previously kept
|
|
364
|
+
// labels, drop each colliding neighbor so the endpoint stays labeled. A very
|
|
365
|
+
// wide final label (long category name) projects across several bands at an
|
|
366
|
+
// angle, so clear every kept label its footprint overlaps, not just the
|
|
367
|
+
// nearest one — otherwise an earlier kept label still overlaps the endpoint.
|
|
368
|
+
//
|
|
369
|
+
// Use true pixel overlap here (no minGap comfort buffer) so a neighbor that
|
|
370
|
+
// merely falls a few pixels short of the comfort gap — but does not actually
|
|
371
|
+
// touch the endpoint label — is kept rather than sacrificed. Dropping a
|
|
372
|
+
// readable short label to seat a near-miss wide endpoint reads as arbitrary
|
|
373
|
+
// (e.g. losing "2025" beside "2026 (to wk 17)" that clears it by 2px).
|
|
374
|
+
const lastIdx = ticks.length - 1;
|
|
375
|
+
if (!keep[lastIdx]) {
|
|
376
|
+
keep[lastIdx] = true;
|
|
377
|
+
for (let i = lastIdx - 1; i > 0; i--) {
|
|
378
|
+
if (!keep[i]) continue;
|
|
379
|
+
if (spans[i].right > spans[lastIdx].left) {
|
|
380
|
+
keep[i] = false;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return ticks.filter((_, i) => keep[i]);
|
|
296
388
|
}
|
|
297
389
|
|
|
298
390
|
// ---------------------------------------------------------------------------
|
|
@@ -561,13 +653,18 @@ export function computeAxes(
|
|
|
561
653
|
const yLabelColor = axisConfig?.labelColor;
|
|
562
654
|
// Editorial line/area y-axes default to inline tick labels above their
|
|
563
655
|
// gridlines. Other mark types keep the classic gutter placement. Right-side
|
|
564
|
-
// y-axes (dual-axis) always use gutter.
|
|
656
|
+
// y-axes (dual-axis) always use gutter. Uses the shared predicate so the
|
|
657
|
+
// margin-reservation paths (plan.ts, dimensions.ts) agree with this; here
|
|
658
|
+
// we pass the RESOLVED scale continuity since the scale is already built.
|
|
565
659
|
const isContinuousYAxis =
|
|
566
660
|
scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
661
|
+
const yTickPosition: 'inline' | 'gutter' = yTickPositionIsInline(
|
|
662
|
+
dataContext?.encoding.y,
|
|
663
|
+
dataContext?.markType ?? '',
|
|
664
|
+
isContinuousYAxis,
|
|
665
|
+
)
|
|
666
|
+
? 'inline'
|
|
667
|
+
: 'gutter';
|
|
571
668
|
|
|
572
669
|
// Inline mode hides the axis line and tick marks by default; the gridlines
|
|
573
670
|
// themselves serve as the visual axis. Explicit user overrides win.
|
|
@@ -644,7 +741,17 @@ export function computeAxes(
|
|
|
644
741
|
);
|
|
645
742
|
return Math.max(max, w);
|
|
646
743
|
}, 0);
|
|
647
|
-
|
|
744
|
+
// Inline y-ticks live inside the plot (no left gutter), so the title
|
|
745
|
+
// clears only the chart edge — pass the inline flag so the offset drops
|
|
746
|
+
// the tick-label width and the viewport floor. Omitting it reserved a
|
|
747
|
+
// gutter-sized offset that pushed the rotated title off the left edge of
|
|
748
|
+
// the container on narrow/large-font charts.
|
|
749
|
+
const titleOff = axisTitleOffset(
|
|
750
|
+
maxTickLabelWidth,
|
|
751
|
+
result.y.labelStyle.fontSize,
|
|
752
|
+
totalWidth,
|
|
753
|
+
result.y.tickPosition === 'inline',
|
|
754
|
+
);
|
|
648
755
|
result.y.titlePosition = {
|
|
649
756
|
x: chartArea.x - titleOff,
|
|
650
757
|
y: chartArea.y + chartArea.height / 2,
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type { NormalizedChartSpec } from '../compiler/types';
|
|
|
45
45
|
import { isEndsBoth, predictEndpointLabelsWidth } from '../endpoint-labels/predict';
|
|
46
46
|
import { countColorSeries, resolveSuppression } from '../legend/suppression';
|
|
47
47
|
import { legendGap } from '../legend/wrap';
|
|
48
|
+
import { yTickPositionIsInline } from './axes';
|
|
48
49
|
import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
|
|
49
50
|
import type { LayoutPlan } from './plan';
|
|
50
51
|
import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
|
|
@@ -306,16 +307,9 @@ export function computeDimensions(
|
|
|
306
307
|
// gridline inside the chart area; the topmost tick text extends roughly
|
|
307
308
|
// (tickFontSize + INLINE_TICK_OVERHANG_PAD) pixels above area.y, which
|
|
308
309
|
// would otherwise crowd the chrome→chart gap.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
const yIsLineOrAreaPre = spec.markType === 'line' || spec.markType === 'area';
|
|
313
|
-
const yAxisOrientPre = yAxisCfgPre?.orient as 'left' | 'right' | 'top' | 'bottom' | undefined;
|
|
314
|
-
const yIsInlinePre =
|
|
315
|
-
(yTickPositionExplicitPre ??
|
|
316
|
-
(yIsLineOrAreaPre && yIsContinuousPre && yAxisOrientPre !== 'right'
|
|
317
|
-
? 'inline'
|
|
318
|
-
: 'gutter')) === 'inline';
|
|
310
|
+
// Inline y-tick placement must match computeAxes exactly (shared predicate),
|
|
311
|
+
// or the reserved left margin won't line up with where the title is drawn.
|
|
312
|
+
const yIsInlinePre = yTickPositionIsInline(encoding.y, spec.markType);
|
|
319
313
|
const inlineTickOverhang = yIsInlinePre
|
|
320
314
|
? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
|
|
321
315
|
: 0;
|
|
@@ -571,8 +565,13 @@ export function computeDimensions(
|
|
|
571
565
|
const yFieldForTitle = encoding.y?.field;
|
|
572
566
|
const yAxisFormatForTitle = yAxis?.format as string | undefined;
|
|
573
567
|
let estTickLabelWidth = 0;
|
|
568
|
+
// Inline y-tick labels render inside the chart area (above their gridlines),
|
|
569
|
+
// not in a left gutter, so they add no width the rotated title must clear.
|
|
570
|
+
// Counting them here would reserve a phantom gutter and leave a dead gap
|
|
571
|
+
// between the title and the plot.
|
|
574
572
|
if (
|
|
575
573
|
yFieldForTitle &&
|
|
574
|
+
!yIsInlinePre &&
|
|
576
575
|
(encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal')
|
|
577
576
|
) {
|
|
578
577
|
let maxAbsValForTitle = 0;
|
|
@@ -609,7 +608,7 @@ export function computeDimensions(
|
|
|
609
608
|
// We add another halfGlyph here for the title glyph extending the other way,
|
|
610
609
|
// toward the container edge, so the margin reaches the title's outer edge.
|
|
611
610
|
const titleFontSize = theme.fonts.sizes.body;
|
|
612
|
-
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
|
|
611
|
+
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width, yIsInlinePre);
|
|
613
612
|
const halfGlyph = Math.ceil(titleFontSize / 2);
|
|
614
613
|
const rotatedLabelMargin =
|
|
615
614
|
offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
|
package/src/layout/plan.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type { NormalizedChartSpec } from '../compiler/types';
|
|
|
33
33
|
import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
|
|
34
34
|
import { computeLegendContent, type LegendContent } from '../legend/compute';
|
|
35
35
|
import { legendGap } from '../legend/wrap';
|
|
36
|
+
import { yTickPositionIsInline } from './axes';
|
|
36
37
|
import { resolveBandTickAngle } from './axes/rotation';
|
|
37
38
|
import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
|
|
38
39
|
import { computeScales, estimateBandwidth } from './scales';
|
|
@@ -177,17 +178,11 @@ export function resolveLayoutPlan(
|
|
|
177
178
|
// Resolve y-axis inline status
|
|
178
179
|
// -----------------------------------------------------------------------
|
|
179
180
|
const yAxisCfg = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
|
|
180
|
-
const yTickPositionExplicit = yAxisCfg?.tickPosition as 'inline' | 'gutter' | undefined;
|
|
181
181
|
const yIsContinuous = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// Sparkline display already returned early above, so no sparkline guard needed
|
|
185
|
-
const yIsInline =
|
|
186
|
-
yTickPositionExplicit === 'inline' ||
|
|
187
|
-
(yTickPositionExplicit === undefined &&
|
|
188
|
-
yIsLineOrArea &&
|
|
189
|
-
yIsContinuous &&
|
|
190
|
-
yAxisOrient !== 'right');
|
|
182
|
+
// Inline y-tick placement must match computeAxes exactly (shared predicate),
|
|
183
|
+
// or the reserved left margin won't line up with where the title is drawn.
|
|
184
|
+
// Sparkline display already returned early above, so no sparkline guard needed.
|
|
185
|
+
const yIsInline = yTickPositionIsInline(encoding.y, renderSpec.markType);
|
|
191
186
|
const yAxisSuppressed = encoding.y?.axis === false;
|
|
192
187
|
|
|
193
188
|
// -----------------------------------------------------------------------
|
|
@@ -517,7 +512,11 @@ export function resolveLayoutPlan(
|
|
|
517
512
|
const yAxisDef = encoding.y?.axis as Record<string, unknown> | undefined;
|
|
518
513
|
if (yAxisDef && (yAxisDef.title || yAxisDef.label) && !isRadial) {
|
|
519
514
|
let estTickLabelWidth = 0;
|
|
520
|
-
|
|
515
|
+
// Inline y-tick labels sit above their gridlines inside the chart area,
|
|
516
|
+
// not in a left gutter, so they add no width the title must clear.
|
|
517
|
+
// Reserving their width here would leave a dead gap between the title
|
|
518
|
+
// and the plot. Only gutter (non-inline) tick labels count.
|
|
519
|
+
if (encoding.y?.field && yIsContinuous && !yIsInline) {
|
|
521
520
|
if (yTickValues.length > 0) {
|
|
522
521
|
const titleProvArea: Rect = {
|
|
523
522
|
x: finalGutter,
|
|
@@ -536,7 +535,7 @@ export function resolveLayoutPlan(
|
|
|
536
535
|
}
|
|
537
536
|
}
|
|
538
537
|
const titleFontSize = theme.fonts.sizes.body;
|
|
539
|
-
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
|
|
538
|
+
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width, yIsInline);
|
|
540
539
|
const halfGlyph = Math.ceil(titleFontSize / 2);
|
|
541
540
|
const rotatedLabelMargin =
|
|
542
541
|
offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
|