@opendata-ai/openchart-engine 8.0.0-rc.9 → 8.1.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 +175 -12
- package/dist/index.js +917 -168
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/__test-fixtures__/specs.ts +0 -182
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
- package/src/__tests__/annotation-behavior.test.ts +0 -852
- package/src/__tests__/axes.test.ts +0 -1579
- package/src/__tests__/compile-animation.test.ts +0 -161
- package/src/__tests__/compile-chart.test.ts +0 -1303
- package/src/__tests__/compile-footnotes.test.ts +0 -277
- package/src/__tests__/compile-layer.test.ts +0 -969
- package/src/__tests__/compile-snapshot.test.ts +0 -156
- package/src/__tests__/compound-labels.test.ts +0 -147
- package/src/__tests__/dimensions.test.ts +0 -918
- package/src/__tests__/encoding-sugar.test.ts +0 -390
- package/src/__tests__/facet.test.ts +0 -215
- package/src/__tests__/helpers/invariants.ts +0 -299
- package/src/__tests__/known-bugs.test.ts +0 -566
- package/src/__tests__/layout-fuzz.test.ts +0 -358
- package/src/__tests__/layout-invariants.test.ts +0 -535
- package/src/__tests__/legend.test.ts +0 -721
- package/src/__tests__/mark-keys.test.ts +0 -294
- package/src/__tests__/ranking-recipes.test.ts +0 -260
- package/src/__tests__/rotation.test.ts +0 -83
- package/src/__tests__/scales.test.ts +0 -323
- package/src/__tests__/series-search.test.ts +0 -92
- package/src/__tests__/spec-sugar.test.ts +0 -801
- package/src/__tests__/you-draw-it.test.ts +0 -160
- package/src/annotations/__tests__/compute.test.ts +0 -1672
- package/src/annotations/__tests__/geometry.test.ts +0 -66
- package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
- package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
- package/src/annotations/__tests__/placement.test.ts +0 -316
- package/src/annotations/__tests__/resolve-text.test.ts +0 -788
- package/src/annotations/__tests__/rich-text.test.ts +0 -91
- package/src/annotations/__tests__/thinning.test.ts +0 -285
- package/src/annotations/collisions.ts +0 -326
- package/src/annotations/compute.ts +0 -333
- package/src/annotations/constants.ts +0 -162
- package/src/annotations/geometry.ts +0 -376
- package/src/annotations/move.ts +0 -135
- package/src/annotations/placement.ts +0 -636
- package/src/annotations/position.ts +0 -131
- package/src/annotations/resolve-range.ts +0 -117
- package/src/annotations/resolve-refline.ts +0 -157
- package/src/annotations/resolve-text.ts +0 -545
- package/src/annotations/rich-text.ts +0 -65
- package/src/annotations/thinning.ts +0 -190
- package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
- package/src/barlist/compile-barlist.ts +0 -391
- package/src/barlist/types.ts +0 -28
- package/src/charts/__tests__/post-process.test.ts +0 -237
- package/src/charts/__tests__/registry.test.ts +0 -116
- package/src/charts/__tests__/utils.test.ts +0 -195
- package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
- package/src/charts/_shared/density-filter.ts +0 -26
- package/src/charts/_shared/format-label-value.ts +0 -16
- package/src/charts/bar/__tests__/compute.test.ts +0 -934
- package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
- package/src/charts/bar/__tests__/labels.test.ts +0 -292
- package/src/charts/bar/compute.ts +0 -539
- package/src/charts/bar/index.ts +0 -60
- package/src/charts/bar/labels.ts +0 -311
- package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
- package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
- package/src/charts/beeswarm/compute.ts +0 -203
- package/src/charts/beeswarm/dodge.ts +0 -155
- package/src/charts/beeswarm/index.ts +0 -32
- package/src/charts/builtin.ts +0 -75
- package/src/charts/calendar/__tests__/compute.test.ts +0 -417
- package/src/charts/calendar/compute.ts +0 -319
- package/src/charts/calendar/index.ts +0 -32
- package/src/charts/column/__tests__/compute.test.ts +0 -540
- package/src/charts/column/__tests__/labels.test.ts +0 -179
- package/src/charts/column/compute.ts +0 -493
- package/src/charts/column/index.ts +0 -53
- package/src/charts/column/labels.ts +0 -229
- package/src/charts/dot/__tests__/compute.test.ts +0 -380
- package/src/charts/dot/__tests__/labels.test.ts +0 -98
- package/src/charts/dot/compute.ts +0 -285
- package/src/charts/dot/index.ts +0 -62
- package/src/charts/dot/labels.ts +0 -121
- package/src/charts/line/__tests__/compute.test.ts +0 -1194
- package/src/charts/line/__tests__/curves.test.ts +0 -51
- package/src/charts/line/__tests__/labels.test.ts +0 -94
- package/src/charts/line/__tests__/mark-options.test.ts +0 -175
- package/src/charts/line/area.ts +0 -484
- package/src/charts/line/compute.ts +0 -356
- package/src/charts/line/curves.ts +0 -85
- package/src/charts/line/index.ts +0 -176
- package/src/charts/line/labels.ts +0 -177
- package/src/charts/parliament/__tests__/compute.test.ts +0 -411
- package/src/charts/parliament/compute.ts +0 -313
- package/src/charts/parliament/index.ts +0 -32
- package/src/charts/pie/__tests__/compute.test.ts +0 -384
- package/src/charts/pie/__tests__/labels.test.ts +0 -132
- package/src/charts/pie/compute.ts +0 -324
- package/src/charts/pie/index.ts +0 -49
- package/src/charts/pie/labels.ts +0 -143
- package/src/charts/post-process.ts +0 -229
- package/src/charts/range/__tests__/compute.test.ts +0 -486
- package/src/charts/range/compute.ts +0 -273
- package/src/charts/range/index.ts +0 -82
- package/src/charts/range/labels.ts +0 -169
- package/src/charts/rect/__tests__/compute.test.ts +0 -120
- package/src/charts/rect/index.ts +0 -127
- package/src/charts/registry.ts +0 -72
- package/src/charts/rule/index.ts +0 -139
- package/src/charts/scatter/__tests__/compute.test.ts +0 -488
- package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
- package/src/charts/scatter/compute.ts +0 -192
- package/src/charts/scatter/index.ts +0 -46
- package/src/charts/scatter/trendline.ts +0 -100
- package/src/charts/text/__tests__/compute.test.ts +0 -307
- package/src/charts/text/index.ts +0 -141
- package/src/charts/tick/__tests__/compute.test.ts +0 -75
- package/src/charts/tick/index.ts +0 -99
- package/src/charts/utils.ts +0 -170
- package/src/charts/waffle/__tests__/compute.test.ts +0 -305
- package/src/charts/waffle/compute.ts +0 -197
- package/src/charts/waffle/index.ts +0 -31
- package/src/compile/__tests__/color-scale-range.test.ts +0 -158
- package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
- package/src/compile/__tests__/data-clip.test.ts +0 -127
- package/src/compile/__tests__/fill-patterns.test.ts +0 -154
- package/src/compile/__tests__/size-scale.test.ts +0 -108
- package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
- package/src/compile/color-scale-range.ts +0 -101
- package/src/compile/data-clip.ts +0 -48
- package/src/compile/fill-patterns.ts +0 -74
- package/src/compile/layer.ts +0 -630
- package/src/compile/size-scale.ts +0 -141
- package/src/compile/spec-sugar.ts +0 -758
- package/src/compile/watermark-obstacle.ts +0 -54
- package/src/compile/you-draw-it.ts +0 -123
- package/src/compile.ts +0 -1503
- package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
- package/src/compiler/__tests__/animation.test.ts +0 -177
- package/src/compiler/__tests__/compile.test.ts +0 -87
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
- package/src/compiler/__tests__/keys.test.ts +0 -83
- package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
- package/src/compiler/__tests__/normalize.test.ts +0 -399
- package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
- package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
- package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
- package/src/compiler/__tests__/validate.test.ts +0 -629
- package/src/compiler/__tests__/vl-parity.test.ts +0 -68
- package/src/compiler/a11y-warnings.ts +0 -110
- package/src/compiler/animation.ts +0 -142
- package/src/compiler/index.ts +0 -47
- package/src/compiler/keys.ts +0 -55
- package/src/compiler/normalize.ts +0 -734
- package/src/compiler/sparkline-defaults.ts +0 -138
- package/src/compiler/types.ts +0 -241
- package/src/compiler/validate.ts +0 -2041
- package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
- package/src/endpoint-labels/compute.ts +0 -621
- package/src/endpoint-labels/constants.ts +0 -54
- package/src/endpoint-labels/format.ts +0 -60
- package/src/endpoint-labels/predict.ts +0 -139
- package/src/format/__tests__/field-format.test.ts +0 -164
- package/src/format/field-format.ts +0 -27
- package/src/geo/__tests__/compile-map.test.ts +0 -1128
- package/src/geo/compile-map.ts +0 -1376
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -65
- package/src/geo/types.ts +0 -26
- package/src/graphs/__tests__/community.test.ts +0 -228
- package/src/graphs/__tests__/compile-graph.test.ts +0 -396
- package/src/graphs/__tests__/encoding.test.ts +0 -415
- package/src/graphs/community.ts +0 -92
- package/src/graphs/compile-graph.ts +0 -329
- package/src/graphs/encoding.ts +0 -356
- package/src/graphs/types.ts +0 -106
- package/src/index.ts +0 -177
- package/src/layout/__tests__/metrics.test.ts +0 -97
- package/src/layout/__tests__/scales-highlight.test.ts +0 -192
- package/src/layout/axes/rotation.ts +0 -152
- package/src/layout/axes/thinning.ts +0 -98
- package/src/layout/axes/ticks.ts +0 -409
- package/src/layout/axes.ts +0 -757
- package/src/layout/dimensions.ts +0 -912
- package/src/layout/facet.ts +0 -91
- package/src/layout/gridlines.ts +0 -84
- package/src/layout/metrics.ts +0 -132
- package/src/layout/plan.ts +0 -631
- package/src/layout/scales.ts +0 -1053
- package/src/layout/shared.ts +0 -44
- package/src/legend/__tests__/continuous.test.ts +0 -318
- package/src/legend/__tests__/redundancy.test.ts +0 -108
- package/src/legend/__tests__/size.test.ts +0 -348
- package/src/legend/__tests__/suppression.test.ts +0 -382
- package/src/legend/compute.ts +0 -748
- package/src/legend/continuous.ts +0 -327
- package/src/legend/size.ts +0 -407
- package/src/legend/suppression.ts +0 -226
- package/src/legend/wrap.ts +0 -117
- package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
- package/src/sankey/__tests__/layout.test.ts +0 -165
- package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
- package/src/sankey/__tests__/node-sort.test.ts +0 -45
- package/src/sankey/compile-sankey.ts +0 -706
- package/src/sankey/layout.ts +0 -184
- package/src/sankey/types.ts +0 -41
- package/src/tables/__tests__/bar-column.test.ts +0 -147
- package/src/tables/__tests__/category-colors.test.ts +0 -230
- package/src/tables/__tests__/compile-table.test.ts +0 -236
- package/src/tables/__tests__/format-cells.test.ts +0 -143
- package/src/tables/__tests__/heatmap.test.ts +0 -148
- package/src/tables/__tests__/pagination.test.ts +0 -78
- package/src/tables/__tests__/search.test.ts +0 -94
- package/src/tables/__tests__/sort.test.ts +0 -107
- package/src/tables/__tests__/sparkline.test.ts +0 -122
- package/src/tables/bar-column.ts +0 -94
- package/src/tables/category-colors.ts +0 -78
- package/src/tables/compile-table.ts +0 -427
- package/src/tables/format-cells.ts +0 -115
- package/src/tables/heatmap.ts +0 -141
- package/src/tables/pagination.ts +0 -46
- package/src/tables/search.ts +0 -66
- package/src/tables/sort.ts +0 -69
- package/src/tables/sparkline.ts +0 -113
- package/src/tables/utils.ts +0 -16
- package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
- package/src/tilemap/compile-tilemap.ts +0 -755
- package/src/tilemap/layout.ts +0 -172
- package/src/tilemap/types.ts +0 -33
- package/src/tooltips/__tests__/compute.test.ts +0 -582
- package/src/tooltips/compute.ts +0 -700
- package/src/transforms/__tests__/aggregate.test.ts +0 -159
- package/src/transforms/__tests__/bin.test.ts +0 -88
- package/src/transforms/__tests__/calculate.test.ts +0 -146
- package/src/transforms/__tests__/conditional.test.ts +0 -127
- package/src/transforms/__tests__/filter-relative.test.ts +0 -202
- package/src/transforms/__tests__/filter.test.ts +0 -59
- package/src/transforms/__tests__/fold.test.ts +0 -79
- package/src/transforms/__tests__/index.test.ts +0 -93
- package/src/transforms/__tests__/predicates.test.ts +0 -193
- package/src/transforms/__tests__/timeunit.test.ts +0 -129
- package/src/transforms/__tests__/window.test.ts +0 -286
- package/src/transforms/aggregate.ts +0 -131
- package/src/transforms/bin.ts +0 -87
- package/src/transforms/calculate.ts +0 -60
- package/src/transforms/conditional.ts +0 -46
- package/src/transforms/filter.ts +0 -122
- package/src/transforms/fold.ts +0 -49
- package/src/transforms/index.ts +0 -60
- package/src/transforms/predicates.ts +0 -123
- package/src/transforms/timeunit.ts +0 -88
- package/src/transforms/window.ts +0 -185
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auto-rotation and thinning policy for band-scale x-axis tick labels.
|
|
3
|
-
*
|
|
4
|
-
* Band categories are data: every bar should keep its label. The policy
|
|
5
|
-
* steepens the label angle until everything fits — flat, then -45°, then -90°
|
|
6
|
-
* (vertical) — and only thins (uniform every-Nth) when even the steepest
|
|
7
|
-
* usable angle cannot fit every label, which takes dozens of categories at
|
|
8
|
-
* tiny steps.
|
|
9
|
-
*
|
|
10
|
-
* The collision geometry differs by orientation, and getting it right is what
|
|
11
|
-
* makes thinning almost never necessary:
|
|
12
|
-
*
|
|
13
|
-
* - FLAT labels share a baseline, so adjacent labels collide when the widest
|
|
14
|
-
* label's width exceeds the anchor spacing.
|
|
15
|
-
* - ROTATED labels are PARALLEL RIBBONS: every label leans at the same angle
|
|
16
|
-
* from its own anchor, so adjacent labels are parallel segments. They clear
|
|
17
|
-
* each other when EITHER the perpendicular separation between the ribbons
|
|
18
|
-
* (`spacing * |sin θ|`) exceeds one line height, OR the offset along the
|
|
19
|
-
* text direction (`spacing * |cos θ|`) exceeds the label length — finite
|
|
20
|
-
* segments slide past each other end-to-end at shallow angles. Earlier
|
|
21
|
-
* revisions modeled rotated labels as 1-D horizontal spans, which reported
|
|
22
|
-
* phantom collisions for long labels (dropping "2025" beside
|
|
23
|
-
* "2026 (to wk 17)") even though the rendered diagonals never touch.
|
|
24
|
-
*
|
|
25
|
-
* The layout planner and computeAxes must agree on these decisions: the
|
|
26
|
-
* planner reserves the bottom margin for the rotated label footprint, and
|
|
27
|
-
* computeAxes emits the same angle. Sharing this module keeps the reserved
|
|
28
|
-
* space matched to the rendered labels (a mismatch spilled rotated ticks into
|
|
29
|
-
* the source line).
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import { TICK_LINE_HEIGHT_FACTOR, X_AXIS_ROTATED_EXTENT_CAP } from '@opendata-ai/openchart-core';
|
|
33
|
-
|
|
34
|
-
/** Band fraction above which flat labels are considered too wide to fit. */
|
|
35
|
-
const BAND_FIT_FRACTION = 0.85;
|
|
36
|
-
|
|
37
|
-
/** Diagonal angle tried first when flat labels don't fit. */
|
|
38
|
-
const AUTO_ROTATE_ANGLE = -45;
|
|
39
|
-
|
|
40
|
-
/** Vertical angle used when diagonal ribbons would touch. */
|
|
41
|
-
const STEEP_ROTATE_ANGLE = -90;
|
|
42
|
-
|
|
43
|
-
/** Minimum visible gap between adjacent label ribbons, in px. */
|
|
44
|
-
const LABEL_GAP = 4;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Perpendicular separation between adjacent rotated label ribbons.
|
|
48
|
-
* Labels anchor at points `spacing` apart; leaning at `angleDeg` makes them
|
|
49
|
-
* parallel lines whose perpendicular distance is `spacing * |sin(angle)|`.
|
|
50
|
-
*/
|
|
51
|
-
function ribbonSeparation(spacing: number, angleDeg: number): number {
|
|
52
|
-
return spacing * Math.abs(Math.sin((angleDeg * Math.PI) / 180));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Offset between adjacent labels measured along the text direction.
|
|
57
|
-
* Finite labels clear each other end-to-end when this exceeds the label
|
|
58
|
-
* length, even if the ribbons are closer than a line height — the escape
|
|
59
|
-
* route that matters at shallow angles.
|
|
60
|
-
*/
|
|
61
|
-
function alongTextClearance(spacing: number, angleDeg: number): number {
|
|
62
|
-
return spacing * Math.abs(Math.cos((angleDeg * Math.PI) / 180));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Whether every label fits at the given rotation without touching. */
|
|
66
|
-
function rotatedLabelsFit(
|
|
67
|
-
spacing: number,
|
|
68
|
-
angleDeg: number,
|
|
69
|
-
fontSize: number,
|
|
70
|
-
maxLabelWidth: number,
|
|
71
|
-
): boolean {
|
|
72
|
-
return (
|
|
73
|
-
ribbonSeparation(spacing, angleDeg) >= fontSize * TICK_LINE_HEIGHT_FACTOR + LABEL_GAP ||
|
|
74
|
-
alongTextClearance(spacing, angleDeg) >= maxLabelWidth + LABEL_GAP
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Decide the effective x tick angle for a band scale: the shallowest angle in
|
|
80
|
-
* the ladder (flat → -45° → -90°) at which adjacent labels don't touch.
|
|
81
|
-
*
|
|
82
|
-
* The flat trigger compares the widest label against the bandwidth (does the
|
|
83
|
-
* flat label fit its band?) — the long-standing rule, kept for stability.
|
|
84
|
-
* The rotated steps use the parallel-ribbon fit test above.
|
|
85
|
-
*
|
|
86
|
-
* -90° projects the full label length vertically, so labels longer than the
|
|
87
|
-
* reserved-extent cap would clip into the source line. For those the ladder
|
|
88
|
-
* stays at -45° (smaller vertical footprint) and lets the stride safety net
|
|
89
|
-
* thin the ribbons instead.
|
|
90
|
-
*
|
|
91
|
-
* @param explicitAngle - User-specified labelAngle (wins if defined).
|
|
92
|
-
* @param maxLabelWidth - Widest tick label width in pixels.
|
|
93
|
-
* @param bandwidth - Band width in pixels (excludes inner padding).
|
|
94
|
-
* @param labelCount - Number of category labels (rotation needs >= 2).
|
|
95
|
-
* @param spacing - Distance between the closest adjacent DRAWN tick anchors.
|
|
96
|
-
* This is the band step when every category gets a tick, but a multiple of
|
|
97
|
-
* it when an explicit tickCount capped the tick set upstream.
|
|
98
|
-
* @param fontSize - Tick label font size (ribbon thickness term).
|
|
99
|
-
* @returns The explicit angle, `-45`, `-90`, or `undefined` (no rotation).
|
|
100
|
-
*/
|
|
101
|
-
export function resolveBandTickAngle(
|
|
102
|
-
explicitAngle: number | undefined,
|
|
103
|
-
maxLabelWidth: number,
|
|
104
|
-
bandwidth: number,
|
|
105
|
-
labelCount: number,
|
|
106
|
-
spacing: number,
|
|
107
|
-
fontSize: number,
|
|
108
|
-
): number | undefined {
|
|
109
|
-
if (explicitAngle !== undefined) return explicitAngle;
|
|
110
|
-
if (labelCount < 2) return undefined;
|
|
111
|
-
if (maxLabelWidth <= bandwidth * BAND_FIT_FRACTION) return undefined;
|
|
112
|
-
if (rotatedLabelsFit(spacing, AUTO_ROTATE_ANGLE, fontSize, maxLabelWidth)) {
|
|
113
|
-
return AUTO_ROTATE_ANGLE;
|
|
114
|
-
}
|
|
115
|
-
return maxLabelWidth <= X_AXIS_ROTATED_EXTENT_CAP ? STEEP_ROTATE_ANGLE : AUTO_ROTATE_ANGLE;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Uniform every-Nth retention stride for band tick labels — the safety net
|
|
120
|
-
* for when even the resolved angle cannot fit every label (dozens of
|
|
121
|
-
* categories at tiny steps, an explicit shallow labelAngle on a crowded axis,
|
|
122
|
-
* or labels too long for the -90° escalation). Returns 1 (keep every label)
|
|
123
|
-
* whenever labels fit.
|
|
124
|
-
*
|
|
125
|
-
* Callers keep indices counting back from the LAST label
|
|
126
|
-
* (`(lastIdx - i) % stride === 0`) so the most recent category — usually the
|
|
127
|
-
* most editorially important — is always labeled.
|
|
128
|
-
*
|
|
129
|
-
* Flat labels collide along their shared baseline, so the stride is driven by
|
|
130
|
-
* the widest label's width. Rotated labels escape by either route — ribbon
|
|
131
|
-
* separation or along-text clearance — and both scale linearly with the
|
|
132
|
-
* stride, so the cheaper one wins.
|
|
133
|
-
*/
|
|
134
|
-
export function bandLabelStride(
|
|
135
|
-
maxLabelWidth: number,
|
|
136
|
-
angleDeg: number | undefined,
|
|
137
|
-
fontSize: number,
|
|
138
|
-
spacing: number,
|
|
139
|
-
): number {
|
|
140
|
-
if (spacing <= 0) return 1;
|
|
141
|
-
if (angleDeg === undefined || angleDeg === 0) {
|
|
142
|
-
return Math.max(1, Math.ceil((maxLabelWidth + LABEL_GAP) / spacing));
|
|
143
|
-
}
|
|
144
|
-
const separation = ribbonSeparation(spacing, angleDeg);
|
|
145
|
-
const clearance = alongTextClearance(spacing, angleDeg);
|
|
146
|
-
const required = fontSize * TICK_LINE_HEIGHT_FACTOR + LABEL_GAP;
|
|
147
|
-
const byRibbon = separation > 0 ? Math.ceil(required / separation) : Number.POSITIVE_INFINITY;
|
|
148
|
-
const byLength =
|
|
149
|
-
clearance > 0 ? Math.ceil((maxLabelWidth + LABEL_GAP) / clearance) : Number.POSITIVE_INFINITY;
|
|
150
|
-
const stride = Math.min(byRibbon, byLength);
|
|
151
|
-
return Number.isFinite(stride) ? Math.max(1, stride) : 1;
|
|
152
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tick label overlap detection and density thinning.
|
|
3
|
-
*
|
|
4
|
-
* Horizontal orientation (x-axis): checks label width against adjacent
|
|
5
|
-
* positions. Vertical orientation (y-axis): checks font-based label height
|
|
6
|
-
* against adjacent positions, ignoring text width so wide numeric labels
|
|
7
|
-
* don't trigger aggressive thinning.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { AxisTick, MeasureTextFn } from '@opendata-ai/openchart-core';
|
|
11
|
-
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Minimum gap between adjacent tick labels as a multiple of font size.
|
|
15
|
-
* At the default 11px axis font, this yields ~5-6px of breathing room.
|
|
16
|
-
* Reduced from 1.0 to 0.5 to prevent over-aggressive thinning on charts
|
|
17
|
-
* with a small number of categories that clearly have room for all labels.
|
|
18
|
-
*/
|
|
19
|
-
const MIN_TICK_GAP_FACTOR = 0.5;
|
|
20
|
-
|
|
21
|
-
/** Always show at least this many ticks, even if they overlap. */
|
|
22
|
-
const MIN_TICK_COUNT = 2;
|
|
23
|
-
|
|
24
|
-
/** Measure a single label's width using real measurement or heuristic fallback. */
|
|
25
|
-
export function measureLabel(
|
|
26
|
-
text: string,
|
|
27
|
-
fontSize: number,
|
|
28
|
-
fontWeight: number,
|
|
29
|
-
measureText?: MeasureTextFn,
|
|
30
|
-
): number {
|
|
31
|
-
return measureText
|
|
32
|
-
? measureText(text, fontSize, fontWeight).width
|
|
33
|
-
: estimateTextWidth(text, fontSize, fontWeight);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Check whether any adjacent tick labels overlap along the axis direction. */
|
|
37
|
-
export function ticksOverlap(
|
|
38
|
-
ticks: AxisTick[],
|
|
39
|
-
fontSize: number,
|
|
40
|
-
fontWeight: number,
|
|
41
|
-
measureText?: MeasureTextFn,
|
|
42
|
-
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
43
|
-
): boolean {
|
|
44
|
-
if (ticks.length < 2) return false;
|
|
45
|
-
const minGap = fontSize * MIN_TICK_GAP_FACTOR;
|
|
46
|
-
|
|
47
|
-
if (orientation === 'vertical') {
|
|
48
|
-
// Y-axis: labels are stacked vertically. Check if vertical extent
|
|
49
|
-
// (based on font height) overlaps between adjacent ticks.
|
|
50
|
-
// Positions decrease going up in SVG coords, so sort ascending.
|
|
51
|
-
const sorted = [...ticks].sort((a, b) => a.position - b.position);
|
|
52
|
-
const labelHeight = fontSize * 1.2; // lineHeight
|
|
53
|
-
for (let i = 0; i < sorted.length - 1; i++) {
|
|
54
|
-
const aBottom = sorted[i].position + labelHeight / 2;
|
|
55
|
-
const bTop = sorted[i + 1].position - labelHeight / 2;
|
|
56
|
-
if (aBottom + minGap > bTop) return true;
|
|
57
|
-
}
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
for (let i = 0; i < ticks.length - 1; i++) {
|
|
62
|
-
const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
|
|
63
|
-
const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
|
|
64
|
-
const aRight = ticks[i].position + aWidth / 2;
|
|
65
|
-
const bLeft = ticks[i + 1].position - bWidth / 2;
|
|
66
|
-
if (aRight + minGap > bLeft) return true;
|
|
67
|
-
}
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Thin a tick array by removing every other tick until labels don't overlap.
|
|
73
|
-
* Always keeps first and last tick. O(log n) iterations max.
|
|
74
|
-
* Returns the original array if no thinning is needed.
|
|
75
|
-
*/
|
|
76
|
-
export function thinTicksUntilFit(
|
|
77
|
-
ticks: AxisTick[],
|
|
78
|
-
fontSize: number,
|
|
79
|
-
fontWeight: number,
|
|
80
|
-
measureText?: MeasureTextFn,
|
|
81
|
-
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
82
|
-
): AxisTick[] {
|
|
83
|
-
if (!ticksOverlap(ticks, fontSize, fontWeight, measureText, orientation)) return ticks;
|
|
84
|
-
|
|
85
|
-
let current = ticks;
|
|
86
|
-
while (current.length > MIN_TICK_COUNT) {
|
|
87
|
-
// Keep first, last, and every other tick in between
|
|
88
|
-
const thinned = [current[0]];
|
|
89
|
-
for (let i = 2; i < current.length - 1; i += 2) {
|
|
90
|
-
thinned.push(current[i]);
|
|
91
|
-
}
|
|
92
|
-
if (current.length > 1) thinned.push(current[current.length - 1]);
|
|
93
|
-
current = thinned;
|
|
94
|
-
|
|
95
|
-
if (!ticksOverlap(current, fontSize, fontWeight, measureText, orientation)) break;
|
|
96
|
-
}
|
|
97
|
-
return current;
|
|
98
|
-
}
|
package/src/layout/axes/ticks.ts
DELETED
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tick generation: produces raw AxisTick[] from a resolved scale.
|
|
3
|
-
*
|
|
4
|
-
* Pure with respect to layout dimensions — positions come from the scale,
|
|
5
|
-
* not from the chart area. Density thinning lives in ./thinning.ts.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { AxisLabelDensity, AxisTick, DataRow } from '@opendata-ai/openchart-core';
|
|
9
|
-
import {
|
|
10
|
-
buildTemporalFormatter,
|
|
11
|
-
formatDate,
|
|
12
|
-
resolveNumberFormatter,
|
|
13
|
-
} from '@opendata-ai/openchart-core';
|
|
14
|
-
import type { ScaleBand } from 'd3-scale';
|
|
15
|
-
import { resolveFieldFormatter } from '../../format/field-format';
|
|
16
|
-
import type { D3CategoricalScale, D3ContinuousScale, ResolvedScale } from '../scales';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Target pixels-per-tick for continuous axes. The target count is computed as
|
|
20
|
-
* `axisLength / PX_PER_TICK[density]` and then clamped into the count range.
|
|
21
|
-
*
|
|
22
|
-
* Rationale:
|
|
23
|
-
* - Observable Plot uses 50px/tick on y, 80px/tick on x as its baseline.
|
|
24
|
-
* - ONS editorial guidance recommends 6-10 y-gridlines at desktop, 3-6 mobile.
|
|
25
|
-
* - The Economist / FT / NYT typically show 4-6 labeled y-ticks on finished charts.
|
|
26
|
-
*
|
|
27
|
-
* Y gets tighter spacing than X because vertical label extent is the font height
|
|
28
|
-
* (~14px) versus horizontal label extent which can be 60-100px for dates/abbreviated
|
|
29
|
-
* numbers. X uses wider spacing so labels don't need aggressive rotation or thinning.
|
|
30
|
-
*
|
|
31
|
-
* "full" is the publication-ready default; "reduced" and "minimal" step down as the
|
|
32
|
-
* responsive breakpoint system shifts to smaller containers.
|
|
33
|
-
*
|
|
34
|
-
* Y full is set to 40px/tick (tighter than Observable Plot's 50) because chart areas
|
|
35
|
-
* are measured after chrome subtraction. A 400px container with title+subtitle leaves
|
|
36
|
-
* ~270px of chart area; 55px/tick would only produce 4 ticks. 40px/tick reaches 5-6
|
|
37
|
-
* on typical chart areas (150-300px) and the overlap check acts as a safety net.
|
|
38
|
-
*
|
|
39
|
-
* @internal — these are tuning constants, not part of the configuration API.
|
|
40
|
-
* Consumers should configure tick density through `axis.tickCount` on the spec.
|
|
41
|
-
*/
|
|
42
|
-
const Y_PX_PER_TICK: Record<AxisLabelDensity, number> = {
|
|
43
|
-
full: 40,
|
|
44
|
-
reduced: 70,
|
|
45
|
-
minimal: 120,
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const X_PX_PER_TICK: Record<AxisLabelDensity, number> = {
|
|
49
|
-
full: 110,
|
|
50
|
-
reduced: 160,
|
|
51
|
-
minimal: 220,
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Count clamps per density. The lower bound keeps a chart from collapsing to
|
|
56
|
-
* a single label on very short axes; the upper bound stops tall/wide charts
|
|
57
|
-
* from growing a ladder of ticks past the point of editorial usefulness.
|
|
58
|
-
*
|
|
59
|
-
* The upper bound is deliberately <=6 for y on standard tiers: D3's
|
|
60
|
-
* `scale.ticks(n)` only produces "nice" step sizes (1, 2, 5 × 10^k), and for
|
|
61
|
-
* many domains the jump from step=10 to step=5 happens between count 6 and 7.
|
|
62
|
-
* Requesting 7 can give back 10, which reads as visually dense. Capping at 6
|
|
63
|
-
* keeps the editorial ~5 gridline average regardless of domain shape.
|
|
64
|
-
*
|
|
65
|
-
* @internal — see PX_PER_TICK comment.
|
|
66
|
-
*/
|
|
67
|
-
const Y_TICK_COUNT_RANGE: Record<AxisLabelDensity, [number, number]> = {
|
|
68
|
-
full: [4, 6],
|
|
69
|
-
reduced: [3, 5],
|
|
70
|
-
minimal: [2, 3],
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const X_TICK_COUNT_RANGE: Record<AxisLabelDensity, [number, number]> = {
|
|
74
|
-
full: [3, 6],
|
|
75
|
-
reduced: [3, 5],
|
|
76
|
-
minimal: [2, 3],
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Fallback tick counts for callers that don't have an axis length handy
|
|
81
|
-
* (categorical band-scale thinning uses this as a cap, and `continuousTicks`
|
|
82
|
-
* uses it when no `targetCount` is provided).
|
|
83
|
-
*
|
|
84
|
-
* @internal
|
|
85
|
-
*/
|
|
86
|
-
const TICK_COUNTS: Record<AxisLabelDensity, number> = {
|
|
87
|
-
full: 7,
|
|
88
|
-
reduced: 5,
|
|
89
|
-
minimal: 3,
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Compute a target tick count for a continuous axis from its pixel length and
|
|
94
|
-
* density tier. Uses the Plot-style pixels-per-tick heuristic, then clamps
|
|
95
|
-
* into the density's count range.
|
|
96
|
-
*/
|
|
97
|
-
export function targetTickCount(
|
|
98
|
-
axisLength: number,
|
|
99
|
-
density: AxisLabelDensity,
|
|
100
|
-
orientation: 'x' | 'y',
|
|
101
|
-
): number {
|
|
102
|
-
const pxPerTick = orientation === 'y' ? Y_PX_PER_TICK[density] : X_PX_PER_TICK[density];
|
|
103
|
-
const [min, max] =
|
|
104
|
-
orientation === 'y' ? Y_TICK_COUNT_RANGE[density] : X_TICK_COUNT_RANGE[density];
|
|
105
|
-
const raw = Math.round(axisLength / pxPerTick);
|
|
106
|
-
return Math.max(min, Math.min(max, raw));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Set of continuous numeric scale types that should format as numbers. */
|
|
110
|
-
const NUMERIC_SCALE_TYPES = new Set([
|
|
111
|
-
'linear',
|
|
112
|
-
'log',
|
|
113
|
-
'pow',
|
|
114
|
-
'sqrt',
|
|
115
|
-
'symlog',
|
|
116
|
-
'quantile',
|
|
117
|
-
'quantize',
|
|
118
|
-
'threshold',
|
|
119
|
-
]);
|
|
120
|
-
|
|
121
|
-
/** Set of temporal scale types. */
|
|
122
|
-
const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
|
|
123
|
-
|
|
124
|
-
interface TickContext {
|
|
125
|
-
step?: number;
|
|
126
|
-
maxAbsTick?: number;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Format a tick value based on the scale type. */
|
|
130
|
-
function formatTickLabel(
|
|
131
|
-
value: unknown,
|
|
132
|
-
resolvedScale: ResolvedScale,
|
|
133
|
-
compact = false,
|
|
134
|
-
tickContext?: TickContext,
|
|
135
|
-
): string {
|
|
136
|
-
const axisConfig = resolvedScale.channel.axis || undefined;
|
|
137
|
-
const formatStr = axisConfig?.format;
|
|
138
|
-
const suffix = axisConfig?.labelSuffix ?? '';
|
|
139
|
-
|
|
140
|
-
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
141
|
-
// Explicit user axis.format always wins, compact or not.
|
|
142
|
-
const temporalFmt = buildTemporalFormatter(formatStr);
|
|
143
|
-
if (temporalFmt) return temporalFmt(value as Date) + suffix;
|
|
144
|
-
const useUtc = resolvedScale.type === 'utc';
|
|
145
|
-
return formatDate(value as Date, undefined, undefined, useUtc, compact) + suffix;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
|
|
149
|
-
const num = value as number;
|
|
150
|
-
if (formatStr) {
|
|
151
|
-
const ctx = {
|
|
152
|
-
...resolvedScale.formatContext,
|
|
153
|
-
step: tickContext?.step,
|
|
154
|
-
stepReference: tickContext?.maxAbsTick,
|
|
155
|
-
};
|
|
156
|
-
const fmt = resolveNumberFormatter(formatStr, ctx);
|
|
157
|
-
if (fmt) return fmt(num) + suffix;
|
|
158
|
-
}
|
|
159
|
-
const fmt = resolveFieldFormatter({
|
|
160
|
-
channelFormat: resolvedScale.channel.format,
|
|
161
|
-
context: {
|
|
162
|
-
...resolvedScale.formatContext,
|
|
163
|
-
step: tickContext?.step,
|
|
164
|
-
stepReference: tickContext?.maxAbsTick,
|
|
165
|
-
},
|
|
166
|
-
});
|
|
167
|
-
return fmt(num) + suffix;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return String(value) + suffix;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function computeTickContext(ticks: unknown[]): TickContext | undefined {
|
|
174
|
-
if (ticks.length < 2) return undefined;
|
|
175
|
-
const nums = ticks.filter((v) => typeof v === 'number' && Number.isFinite(v)) as number[];
|
|
176
|
-
if (nums.length < 2) return undefined;
|
|
177
|
-
|
|
178
|
-
const firstGap = Math.abs(nums[1] - nums[0]);
|
|
179
|
-
if (firstGap === 0) return undefined;
|
|
180
|
-
|
|
181
|
-
let uniform = true;
|
|
182
|
-
for (let i = 2; i < nums.length; i++) {
|
|
183
|
-
const gap = Math.abs(nums[i] - nums[i - 1]);
|
|
184
|
-
const relDiff = Math.abs(gap - firstGap) / Math.max(firstGap, 1e-15);
|
|
185
|
-
if (relDiff > 1e-9) {
|
|
186
|
-
uniform = false;
|
|
187
|
-
break;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (!uniform) return undefined;
|
|
192
|
-
|
|
193
|
-
return {
|
|
194
|
-
step: firstGap,
|
|
195
|
-
maxAbsTick: Math.max(Math.abs(nums[0]), Math.abs(nums[nums.length - 1])),
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Generate ticks for a continuous scale (linear, time, log, pow, sqrt, symlog).
|
|
201
|
-
*
|
|
202
|
-
* `targetCount` lets callers that know the axis pixel length pass a
|
|
203
|
-
* density-appropriate count (see `targetTickCount`). When omitted, falls back
|
|
204
|
-
* to the coarse `TICK_COUNTS` tier, which is only used by tests and callers
|
|
205
|
-
* that don't have an axis length.
|
|
206
|
-
*/
|
|
207
|
-
export function continuousTicks(
|
|
208
|
-
resolvedScale: ResolvedScale,
|
|
209
|
-
density: AxisLabelDensity,
|
|
210
|
-
targetCount?: number,
|
|
211
|
-
): AxisTick[] {
|
|
212
|
-
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
213
|
-
|
|
214
|
-
// Discretizing scales (quantile, quantize, threshold) don't have .ticks().
|
|
215
|
-
// Use their domain thresholds as ticks instead.
|
|
216
|
-
if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
|
|
217
|
-
const domain = scale.domain() as unknown[];
|
|
218
|
-
return domain.map((value: unknown) => ({
|
|
219
|
-
value,
|
|
220
|
-
position: (scale as D3ContinuousScale)(value as number & Date) as number,
|
|
221
|
-
label: formatTickLabel(value, resolvedScale),
|
|
222
|
-
}));
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const axCfg = resolvedScale.channel.axis || undefined;
|
|
226
|
-
const count = axCfg?.tickCount ?? targetCount ?? TICK_COUNTS[density];
|
|
227
|
-
return buildContinuousTicks(resolvedScale, count);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Build positioned, labeled ticks for a continuous scale at an exact count.
|
|
232
|
-
* Exposed so callers that need to re-request ticks at a lower count (for
|
|
233
|
-
* overlap-driven density adaptation) can regenerate without manual pruning.
|
|
234
|
-
* D3's `scale.ticks(n)` always returns evenly-spaced round values, so
|
|
235
|
-
* requesting a smaller `n` never produces squished neighbors — unlike
|
|
236
|
-
* "keep first+last, drop middle" pruning which can stack the last tick
|
|
237
|
-
* next to an endpoint and cascade to 2 ticks.
|
|
238
|
-
*
|
|
239
|
-
* `compact` switches temporal labels to the compact format table (bare '%b'
|
|
240
|
-
* months etc.); numeric and string labels ignore it.
|
|
241
|
-
*/
|
|
242
|
-
export function buildContinuousTicks(
|
|
243
|
-
resolvedScale: ResolvedScale,
|
|
244
|
-
count: number,
|
|
245
|
-
compact = false,
|
|
246
|
-
): AxisTick[] {
|
|
247
|
-
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
248
|
-
if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
|
|
249
|
-
return continuousTicks(resolvedScale, 'full');
|
|
250
|
-
}
|
|
251
|
-
const raw: unknown[] = scale.ticks(count);
|
|
252
|
-
|
|
253
|
-
// D3 log scales ignore the count hint and return ticks at every sub-power
|
|
254
|
-
// position (e.g. 5, 6, 7, 8, 9, 10, 20, 30... for a domain of [5, 25000]).
|
|
255
|
-
// Filter down to powers of the base only when the raw set overshoots.
|
|
256
|
-
let ticks = raw;
|
|
257
|
-
if (resolvedScale.type === 'log' && raw.length > count) {
|
|
258
|
-
const base = resolvedScale.channel.scale?.base ?? 10;
|
|
259
|
-
const logBase = Math.log(base);
|
|
260
|
-
const powered = raw.filter((v) => {
|
|
261
|
-
const n = v as number;
|
|
262
|
-
if (n <= 0) return false;
|
|
263
|
-
const exp = Math.log(n) / logBase;
|
|
264
|
-
return Math.abs(exp - Math.round(exp)) < 1e-9;
|
|
265
|
-
});
|
|
266
|
-
// Only use the filtered set if it has at least 2 ticks; otherwise fall back
|
|
267
|
-
// to raw ticks. This handles domains like [5, 9] (no powers of 10 at all) or
|
|
268
|
-
// [5, 50] (only one power: 10) where filtering would leave too few meaningful ticks.
|
|
269
|
-
if (powered.length >= 2) {
|
|
270
|
-
ticks = powered;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const tickCtx = computeTickContext(ticks);
|
|
275
|
-
|
|
276
|
-
return ticks.map((value: unknown) => ({
|
|
277
|
-
value,
|
|
278
|
-
position: scale(value as number & Date) as number,
|
|
279
|
-
label: formatTickLabel(value, resolvedScale, compact, tickCtx),
|
|
280
|
-
}));
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/** True if this scale supports regenerating ticks at an arbitrary count. */
|
|
284
|
-
export function scaleSupportsTickCount(resolvedScale: ResolvedScale): boolean {
|
|
285
|
-
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
286
|
-
return 'ticks' in scale && typeof scale.ticks === 'function';
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Generate ticks for a band/point/ordinal scale.
|
|
291
|
-
*
|
|
292
|
-
* For horizontal x-axis band scales, thinning is geometry-aware: if
|
|
293
|
-
* `bandwidth` and `fontSize`/`fontWeight` are provided, labels are only
|
|
294
|
-
* thinned when the estimated label footprint (accounting for `labelAngle`)
|
|
295
|
-
* actually exceeds the bandwidth. When labels are rotated, their horizontal
|
|
296
|
-
* footprint shrinks by |cos(angle)|, so far fewer need to be removed.
|
|
297
|
-
* Falls back to a density-count cap when geometry info is unavailable.
|
|
298
|
-
*/
|
|
299
|
-
export function categoricalTicks(
|
|
300
|
-
resolvedScale: ResolvedScale,
|
|
301
|
-
density: AxisLabelDensity,
|
|
302
|
-
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
303
|
-
subtitleContext?: { data: DataRow[]; fieldName: string; labelField: string },
|
|
304
|
-
): AxisTick[] {
|
|
305
|
-
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
306
|
-
const domain: string[] = scale.domain();
|
|
307
|
-
const catAxisCfg = resolvedScale.channel.axis || undefined;
|
|
308
|
-
const explicitTickCount = catAxisCfg?.tickCount;
|
|
309
|
-
|
|
310
|
-
let selectedValues = domain;
|
|
311
|
-
|
|
312
|
-
if (resolvedScale.type === 'band' && orientation === 'horizontal') {
|
|
313
|
-
// Horizontal band scales delegate thinning to the caller (computeAxes)
|
|
314
|
-
// which knows the effective label angle after auto-rotation. Only apply
|
|
315
|
-
// an explicit tickCount cap here; the angle ladder + stride safety net
|
|
316
|
-
// (see ./rotation) handle collisions downstream.
|
|
317
|
-
if (explicitTickCount && domain.length > explicitTickCount) {
|
|
318
|
-
const step = Math.ceil(domain.length / explicitTickCount);
|
|
319
|
-
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
320
|
-
}
|
|
321
|
-
} else if (resolvedScale.type !== 'band') {
|
|
322
|
-
// Point/ordinal scales: thin by density count
|
|
323
|
-
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
324
|
-
if (domain.length > maxTicks) {
|
|
325
|
-
const step = Math.ceil(domain.length / maxTicks);
|
|
326
|
-
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
// vertical band scale (horizontal bar y-axis): always show all labels
|
|
330
|
-
|
|
331
|
-
let subtitleMap: Map<string, string> | undefined;
|
|
332
|
-
if (subtitleContext) {
|
|
333
|
-
const { data, fieldName, labelField } = subtitleContext;
|
|
334
|
-
if (data.length > 0) {
|
|
335
|
-
subtitleMap = new Map();
|
|
336
|
-
for (const row of data) {
|
|
337
|
-
const key = String(row[fieldName] ?? '');
|
|
338
|
-
if (!subtitleMap.has(key)) {
|
|
339
|
-
const val = row[labelField];
|
|
340
|
-
if (val != null) {
|
|
341
|
-
subtitleMap.set(key, String(val));
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const ticks = selectedValues.map((value: string) => {
|
|
349
|
-
// Band scales: use the center of the band
|
|
350
|
-
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
351
|
-
const pos = bandScale
|
|
352
|
-
? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2
|
|
353
|
-
: ((scale(value) as number | undefined) ?? 0);
|
|
354
|
-
|
|
355
|
-
const tick: AxisTick = {
|
|
356
|
-
value,
|
|
357
|
-
position: pos,
|
|
358
|
-
label: value,
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
if (subtitleMap) {
|
|
362
|
-
const subtitle = subtitleMap.get(value);
|
|
363
|
-
if (subtitle !== undefined) {
|
|
364
|
-
tick.subtitle = subtitle;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
return tick;
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
return ticks;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Resolve explicit tick values from axis config into positioned ticks.
|
|
376
|
-
*
|
|
377
|
-
* Deliberately never compact: this renders the layout planner's precomputed
|
|
378
|
-
* ticks, and planner/renderer gutter agreement holds only because both sides
|
|
379
|
-
* format identically. Making this compact without making the planner compact
|
|
380
|
-
* recreates the reserved-space divergence bug class behind the 7.9.x saga.
|
|
381
|
-
*/
|
|
382
|
-
export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
|
|
383
|
-
const scale = resolvedScale.scale;
|
|
384
|
-
const tickCtx = computeTickContext(values);
|
|
385
|
-
return values.map((value) => {
|
|
386
|
-
let position: number;
|
|
387
|
-
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
388
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
389
|
-
position = (scale as D3ContinuousScale)(d as number & Date) as number;
|
|
390
|
-
} else if (
|
|
391
|
-
resolvedScale.type === 'band' ||
|
|
392
|
-
resolvedScale.type === 'point' ||
|
|
393
|
-
resolvedScale.type === 'ordinal'
|
|
394
|
-
) {
|
|
395
|
-
const s = String(value);
|
|
396
|
-
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
397
|
-
position = bandScale
|
|
398
|
-
? (bandScale(s) ?? 0) + bandScale.bandwidth() / 2
|
|
399
|
-
: ((scale(s as string & number) as number | undefined) ?? 0);
|
|
400
|
-
} else {
|
|
401
|
-
position = (scale as D3ContinuousScale)(value as number & Date) as number;
|
|
402
|
-
}
|
|
403
|
-
return {
|
|
404
|
-
value,
|
|
405
|
-
position,
|
|
406
|
-
label: formatTickLabel(value, resolvedScale, false, tickCtx),
|
|
407
|
-
};
|
|
408
|
-
});
|
|
409
|
-
}
|