@opendata-ai/openchart-engine 8.0.0-rc.8 → 8.0.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 +149 -12
- package/dist/index.js +894 -171
- 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 -2339
- 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 -626
- 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 -1450
- 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 -1004
- package/src/geo/compile-map.ts +0 -1341
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -45
- 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 -747
- 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
package/src/layout/axes.ts
DELETED
|
@@ -1,757 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Axis computation: tick positions, labels, and axis lines.
|
|
3
|
-
*
|
|
4
|
-
* Generates ticks manually (no d3-axis) so we have full control over
|
|
5
|
-
* responsive tick density and formatting. Tick generation and label
|
|
6
|
-
* thinning live in sibling modules under ./axes/.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type {
|
|
10
|
-
AxisLabelDensity,
|
|
11
|
-
AxisLayout,
|
|
12
|
-
AxisTick,
|
|
13
|
-
DataRow,
|
|
14
|
-
Encoding,
|
|
15
|
-
Gridline,
|
|
16
|
-
LayoutStrategy,
|
|
17
|
-
MeasureTextFn,
|
|
18
|
-
Rect,
|
|
19
|
-
ResolvedTheme,
|
|
20
|
-
TextStyle,
|
|
21
|
-
} from '@opendata-ai/openchart-core';
|
|
22
|
-
import {
|
|
23
|
-
axisTitleOffset,
|
|
24
|
-
computeXAxisExtentFromLabels,
|
|
25
|
-
estimateTextWidth,
|
|
26
|
-
getAxisTitleOffset,
|
|
27
|
-
truncateRotatedLabel,
|
|
28
|
-
X_AXIS_TITLE_BAND,
|
|
29
|
-
X_AXIS_TITLE_BAND_ROTATED,
|
|
30
|
-
} from '@opendata-ai/openchart-core';
|
|
31
|
-
import type { ScaleBand } from 'd3-scale';
|
|
32
|
-
import { bandLabelStride, resolveBandTickAngle } from './axes/rotation';
|
|
33
|
-
import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
34
|
-
import {
|
|
35
|
-
buildContinuousTicks,
|
|
36
|
-
categoricalTicks,
|
|
37
|
-
continuousTicks,
|
|
38
|
-
resolveExplicitTicks,
|
|
39
|
-
scaleSupportsTickCount,
|
|
40
|
-
targetTickCount,
|
|
41
|
-
} from './axes/ticks';
|
|
42
|
-
import type { ResolvedScales } from './scales';
|
|
43
|
-
|
|
44
|
-
// Re-export pure helpers so external consumers (and tests) continue to import
|
|
45
|
-
// them from './layout/axes'.
|
|
46
|
-
export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
47
|
-
|
|
48
|
-
/** Scale types that render as a categorical (non-continuous) y-axis. */
|
|
49
|
-
const CATEGORICAL_SCALE_TYPES = new Set(['band', 'point', 'ordinal']);
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Whether the y-axis renders tick labels inline (above their gridline inside
|
|
53
|
-
* the plot) rather than in a left gutter.
|
|
54
|
-
*
|
|
55
|
-
* This is the single source of truth shared by margin reservation (plan.ts,
|
|
56
|
-
* dimensions.ts) and the renderer's title placement. All three must agree or
|
|
57
|
-
* the reserved left margin won't match where the title is drawn.
|
|
58
|
-
*
|
|
59
|
-
* Editorial line/area charts with a continuous y-axis default to inline ticks.
|
|
60
|
-
* Continuity is decided by the RESOLVED scale type, not the field type: an
|
|
61
|
-
* explicit `scale.type` override (e.g. a band scale forced onto a quantitative
|
|
62
|
-
* field) wins, mirroring buildPositionalScale() in scales.ts. Right-side
|
|
63
|
-
* (dual-axis) y-axes always use the gutter. An explicit axis.tickPosition wins
|
|
64
|
-
* over the default.
|
|
65
|
-
*/
|
|
66
|
-
export function yTickPositionIsInline(
|
|
67
|
-
yChannel:
|
|
68
|
-
| {
|
|
69
|
-
type?: string;
|
|
70
|
-
scale?: { type?: string };
|
|
71
|
-
axis?: unknown;
|
|
72
|
-
}
|
|
73
|
-
| undefined,
|
|
74
|
-
markType: string,
|
|
75
|
-
/**
|
|
76
|
-
* Resolved continuity of the y-scale, when the caller already has the built
|
|
77
|
-
* scale (computeAxes). When omitted, continuity is inferred from the encoding
|
|
78
|
-
* (explicit scale.type override, else field type) so the reservation paths
|
|
79
|
-
* predict the same answer computeAxes will produce.
|
|
80
|
-
*/
|
|
81
|
-
resolvedContinuous?: boolean,
|
|
82
|
-
): boolean {
|
|
83
|
-
if (!yChannel) return false;
|
|
84
|
-
const axisCfg = (yChannel.axis as Record<string, unknown> | undefined) ?? undefined;
|
|
85
|
-
const explicit = axisCfg?.tickPosition as 'inline' | 'gutter' | undefined;
|
|
86
|
-
if (explicit) return explicit === 'inline';
|
|
87
|
-
|
|
88
|
-
const isLineOrArea = markType === 'line' || markType === 'area';
|
|
89
|
-
if (!isLineOrArea) return false;
|
|
90
|
-
if ((axisCfg?.orient as string | undefined) === 'right') return false;
|
|
91
|
-
|
|
92
|
-
if (resolvedContinuous !== undefined) return resolvedContinuous;
|
|
93
|
-
|
|
94
|
-
// Predict the resolved scale type: explicit override wins, else field type.
|
|
95
|
-
const explicitScaleType = yChannel.scale?.type;
|
|
96
|
-
return explicitScaleType
|
|
97
|
-
? !CATEGORICAL_SCALE_TYPES.has(explicitScaleType)
|
|
98
|
-
: yChannel.type === 'quantitative' || yChannel.type === 'temporal';
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
// Constants
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Height thresholds for reducing y-axis tick density.
|
|
107
|
-
* Below these pixel heights, we step down the density regardless of the
|
|
108
|
-
* width-based strategy. This prevents overlapping y-axis labels in short
|
|
109
|
-
* containers like thumbnail previews.
|
|
110
|
-
*
|
|
111
|
-
* These thresholds apply to the chart area height (after chrome/margins),
|
|
112
|
-
* not the total container height. A 400px container with title+subtitle
|
|
113
|
-
* leaves ~270px of chart area; a 320px container leaves ~186px. The old
|
|
114
|
-
* HEIGHT_REDUCED_THRESHOLD of 200 kicked in on nearly every common chart
|
|
115
|
-
* size, producing only 3 ticks. Lowering to 100 keeps 'full' density for
|
|
116
|
-
* all but the most compact thumbnail-style containers.
|
|
117
|
-
*/
|
|
118
|
-
const HEIGHT_MINIMAL_THRESHOLD = 80;
|
|
119
|
-
const HEIGHT_REDUCED_THRESHOLD = 100;
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Width thresholds for reducing x-axis tick density.
|
|
123
|
-
* Mirrors the height logic for the x-axis: narrow containers get fewer ticks.
|
|
124
|
-
*/
|
|
125
|
-
const WIDTH_MINIMAL_THRESHOLD = 150;
|
|
126
|
-
const WIDTH_REDUCED_THRESHOLD = 300;
|
|
127
|
-
|
|
128
|
-
/** Ordered densities from most to fewest ticks. */
|
|
129
|
-
const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
|
|
130
|
-
|
|
131
|
-
/** Marks grounded on the x baseline that warrant a domain line. */
|
|
132
|
-
const GROUNDED_MARKS = new Set(['bar', 'tick', 'lollipop']);
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Compute effective axis tick density by considering available space.
|
|
136
|
-
*
|
|
137
|
-
* The width-based breakpoint system sets a base density, but it doesn't know
|
|
138
|
-
* about the actual chart area dimensions (which shrink after chrome/legend
|
|
139
|
-
* allocation). This function steps density down further when the axis
|
|
140
|
-
* dimension is too small for the requested tick count.
|
|
141
|
-
*
|
|
142
|
-
* @param baseDensity - The density from the responsive layout strategy.
|
|
143
|
-
* @param axisLength - Available pixels along this axis (height for y, width for x).
|
|
144
|
-
* @param minimalThreshold - Below this pixel size, force minimal density.
|
|
145
|
-
* @param reducedThreshold - Below this pixel size, cap at reduced density.
|
|
146
|
-
* @returns The effective density, never looser than the base.
|
|
147
|
-
*/
|
|
148
|
-
export function effectiveDensity(
|
|
149
|
-
baseDensity: AxisLabelDensity,
|
|
150
|
-
axisLength: number,
|
|
151
|
-
minimalThreshold: number,
|
|
152
|
-
reducedThreshold: number,
|
|
153
|
-
): AxisLabelDensity {
|
|
154
|
-
let density = baseDensity;
|
|
155
|
-
|
|
156
|
-
if (axisLength < minimalThreshold) {
|
|
157
|
-
density = 'minimal';
|
|
158
|
-
} else if (axisLength < reducedThreshold) {
|
|
159
|
-
// Don't increase density beyond what the base strategy allows.
|
|
160
|
-
// If base is already 'minimal', keep it.
|
|
161
|
-
const baseIdx = DENSITY_ORDER.indexOf(baseDensity);
|
|
162
|
-
const reducedIdx = DENSITY_ORDER.indexOf('reduced');
|
|
163
|
-
density = DENSITY_ORDER[Math.max(baseIdx, reducedIdx)];
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return density;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Floor tick count for continuous axes when the axis is long enough to show
|
|
171
|
-
* more than a min/max pair. Keeps the editorial ~5 target when possible.
|
|
172
|
-
* Very short axes bypass this floor and can legitimately fall to 2.
|
|
173
|
-
*/
|
|
174
|
-
const CONTINUOUS_TICK_FLOOR = 4;
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* How much D3 is allowed to overshoot what we asked for before we treat the
|
|
178
|
-
* output as "too dense" and step down. D3's `scale.ticks(n)` only produces
|
|
179
|
-
* nice step sizes (1, 2, 5 × 10^k, or calendar units for time), so a request
|
|
180
|
-
* for n=6 can come back with 12 quarterly dates or 10 step-5 values. Accepting
|
|
181
|
-
* up to 1.5× target catches the obvious overshoots without trimming acceptable
|
|
182
|
-
* ones.
|
|
183
|
-
*
|
|
184
|
-
* The reference point stays fixed to the initial requested count even as we
|
|
185
|
-
* iterate downward — we're measuring "did this candidate land near the target
|
|
186
|
-
* the caller actually wanted?", not "is the candidate near what we just asked
|
|
187
|
-
* for on this iteration?". If a candidate at n=3 returns 8 ticks, it's still
|
|
188
|
-
* a 1.3× overshoot of the target-6, which is fine.
|
|
189
|
-
*/
|
|
190
|
-
const OVERSHOOT_TOLERANCE = 1.5;
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Quantitative axes must always render at least this many tick labels. A lone
|
|
194
|
-
* tick reads as a broken axis (a real iPhone blog chart degenerated to a
|
|
195
|
-
* single "80" when D3's `scale.ticks(2)` collapsed a tight domain like
|
|
196
|
-
* [73, 88] to one nice value). This floor holds at every width.
|
|
197
|
-
*/
|
|
198
|
-
const MIN_QUANTITATIVE_TICKS = 2;
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Ensure a continuous scale yields at least MIN_QUANTITATIVE_TICKS ticks.
|
|
202
|
-
*
|
|
203
|
-
* D3's `scale.ticks(n)` picks nice step sizes, so a tight domain can collapse
|
|
204
|
-
* to a single value at low counts (`[73, 88].ticks(2)` → `[80]`). When that
|
|
205
|
-
* happens, request progressively higher counts until at least two ticks
|
|
206
|
-
* appear. Prefers the smallest count that clears the floor, which keeps the
|
|
207
|
-
* ticks spread toward the domain endpoints rather than clustered.
|
|
208
|
-
*/
|
|
209
|
-
function ensureMinContinuousTicks(
|
|
210
|
-
scale: ResolvedScales['x' | 'y'],
|
|
211
|
-
ticks: AxisTick[],
|
|
212
|
-
requestedCount: number,
|
|
213
|
-
compact = false,
|
|
214
|
-
): AxisTick[] {
|
|
215
|
-
if (ticks.length >= MIN_QUANTITATIVE_TICKS) return ticks;
|
|
216
|
-
if (!scale || !scaleSupportsTickCount(scale)) return ticks;
|
|
217
|
-
|
|
218
|
-
let best = ticks;
|
|
219
|
-
for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
|
|
220
|
-
const candidate = buildContinuousTicks(scale, n, compact);
|
|
221
|
-
if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
|
|
222
|
-
if (candidate.length > best.length) best = candidate;
|
|
223
|
-
}
|
|
224
|
-
return best;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Fit continuous ticks by re-requesting progressively fewer ticks from the
|
|
229
|
-
* scale. D3's `scale.ticks(n)` always returns evenly-spaced round values, so
|
|
230
|
-
* stepping `n` down keeps spacing uniform — unlike middle-pruning which can
|
|
231
|
-
* strand the last tick next to an endpoint and cascade to 2 ticks.
|
|
232
|
-
*
|
|
233
|
-
* Two conditions trigger a step-down:
|
|
234
|
-
* 1. The label heuristic detects overlap at the initial count.
|
|
235
|
-
* 2. D3 overshot the requested count by more than OVERSHOOT_TOLERANCE.
|
|
236
|
-
* (Time scales jump between calendar units; linear scales jump between
|
|
237
|
-
* nice step sizes. Either can return 2× what we asked for.)
|
|
238
|
-
*
|
|
239
|
-
* Falls back to overlap-safe thinning on the best-so-far candidate if no
|
|
240
|
-
* count produces a clean fit. The fallback starts from the smallest candidate
|
|
241
|
-
* that still meets the floor, so `thinTicksUntilFit` never receives the
|
|
242
|
-
* overshot initial set (which was the bug this function exists to avoid).
|
|
243
|
-
*/
|
|
244
|
-
function fitContinuousTicks(
|
|
245
|
-
scale: ResolvedScales['x' | 'y'],
|
|
246
|
-
initialTicks: AxisTick[],
|
|
247
|
-
initialCount: number,
|
|
248
|
-
fontSize: number,
|
|
249
|
-
fontWeight: number,
|
|
250
|
-
axisLength: number,
|
|
251
|
-
orientation: 'horizontal' | 'vertical',
|
|
252
|
-
measureText?: MeasureTextFn,
|
|
253
|
-
): AxisTick[] {
|
|
254
|
-
if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
|
|
255
|
-
|
|
256
|
-
// Floor the initial set: a tight domain can make D3 return a single nice
|
|
257
|
-
// value, which would otherwise pass straight through (no overlap, no
|
|
258
|
-
// overshoot) and render as a lone tick.
|
|
259
|
-
const flooredTicks = ensureMinContinuousTicks(scale, initialTicks, initialCount);
|
|
260
|
-
|
|
261
|
-
const tolerance = initialCount * OVERSHOOT_TOLERANCE;
|
|
262
|
-
const overshoots = flooredTicks.length > tolerance;
|
|
263
|
-
const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
|
|
264
|
-
if (!overshoots && !overlaps) return flooredTicks;
|
|
265
|
-
|
|
266
|
-
// Compact temporal formats are a rung between "full labels overlap" and
|
|
267
|
-
// "drop a tick": at each candidate count, prefer the full format, then try
|
|
268
|
-
// the compact table at the SAME count before stepping the count down. Every
|
|
269
|
-
// returned set is internally consistent — one format family per layout pass.
|
|
270
|
-
// X-axes only: the planner/renderer y pair must keep formatting identically
|
|
271
|
-
// (see resolveExplicitTicks), so vertical axes never compact — including
|
|
272
|
-
// the fallback path below.
|
|
273
|
-
const useCompact =
|
|
274
|
-
(scale.type === 'time' || scale.type === 'utc') && orientation === 'horizontal';
|
|
275
|
-
|
|
276
|
-
const fitsCleanly = (candidate: AxisTick[]): boolean =>
|
|
277
|
-
candidate.length <= tolerance &&
|
|
278
|
-
!ticksOverlap(candidate, fontSize, fontWeight, measureText, orientation);
|
|
279
|
-
|
|
280
|
-
// Compact-saves-the-count case: full format overlapped at the initial
|
|
281
|
-
// count, but compact labels may fit without giving up a single tick.
|
|
282
|
-
if (useCompact) {
|
|
283
|
-
const compactAtInitial = buildContinuousTicks(scale, initialCount, true);
|
|
284
|
-
if (fitsCleanly(compactAtInitial)) return compactAtInitial;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Enforce the floor only when the axis is long enough to fit that many
|
|
288
|
-
// labels without overlap. Very short axes can fall below.
|
|
289
|
-
const minThreshold =
|
|
290
|
-
orientation === 'vertical' ? HEIGHT_MINIMAL_THRESHOLD : WIDTH_MINIMAL_THRESHOLD;
|
|
291
|
-
const floor = axisLength >= minThreshold ? CONTINUOUS_TICK_FLOOR : 2;
|
|
292
|
-
|
|
293
|
-
// Track the smallest candidate that meets the floor. If no candidate fits
|
|
294
|
-
// cleanly, we thin this instead of the overshot `initialTicks` so the
|
|
295
|
-
// fallback doesn't reintroduce the cascading-to-2-ticks bug. Bookkeeping
|
|
296
|
-
// runs against the full-format candidate, exactly as before the compact rung.
|
|
297
|
-
let bestWithinFloor: AxisTick[] | undefined;
|
|
298
|
-
let bestWithinFloorCount: number | undefined;
|
|
299
|
-
for (let n = initialCount - 1; n >= 2; n--) {
|
|
300
|
-
const candidate = buildContinuousTicks(scale, n);
|
|
301
|
-
if (fitsCleanly(candidate)) return candidate;
|
|
302
|
-
if (useCompact) {
|
|
303
|
-
const compact = buildContinuousTicks(scale, n, true);
|
|
304
|
-
if (fitsCleanly(compact)) return compact;
|
|
305
|
-
}
|
|
306
|
-
if (candidate.length >= floor) {
|
|
307
|
-
bestWithinFloor = candidate;
|
|
308
|
-
bestWithinFloorCount = n;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// No candidate fit cleanly. Thin whatever most recently met the floor; if
|
|
313
|
-
// nothing did, synthesize a floor-count set directly from the scale so we
|
|
314
|
-
// never hand the overshot initialTicks to the middle-pruning thinner.
|
|
315
|
-
// Compacting axes rebuild the fallback compact: if the axis is so starved
|
|
316
|
-
// that we're thinning below the floor, shorter labels are strictly better.
|
|
317
|
-
const rawFallback = useCompact
|
|
318
|
-
? buildContinuousTicks(scale, bestWithinFloorCount ?? floor, true)
|
|
319
|
-
: (bestWithinFloor ?? buildContinuousTicks(scale, floor));
|
|
320
|
-
const fallback = ensureMinContinuousTicks(scale, rawFallback, floor, useCompact);
|
|
321
|
-
return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Apply the uniform band-label stride (see bandLabelStride in ./axes/rotation):
|
|
326
|
-
* keep every Nth label counting back from the LAST one, so the most recent
|
|
327
|
-
* category is always labeled. Stride 1 — the overwhelmingly common case once
|
|
328
|
-
* the angle ladder has run — keeps everything.
|
|
329
|
-
*/
|
|
330
|
-
function applyBandLabelStride(ticks: AxisTick[], stride: number): AxisTick[] {
|
|
331
|
-
if (stride <= 1 || ticks.length < 3) return ticks;
|
|
332
|
-
const lastIdx = ticks.length - 1;
|
|
333
|
-
return ticks.filter((_, i) => (lastIdx - i) % stride === 0);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ---------------------------------------------------------------------------
|
|
337
|
-
// Public API
|
|
338
|
-
// ---------------------------------------------------------------------------
|
|
339
|
-
|
|
340
|
-
/** Output of computeAxes. */
|
|
341
|
-
export interface AxesResult {
|
|
342
|
-
x?: AxisLayout;
|
|
343
|
-
y?: AxisLayout;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Optional data context for axis computation (enables labelField subtitles). */
|
|
347
|
-
export interface AxesDataContext {
|
|
348
|
-
/** The data rows for subtitle lookup. */
|
|
349
|
-
data: DataRow[];
|
|
350
|
-
/** The encoding object to resolve field names. */
|
|
351
|
-
encoding: Encoding;
|
|
352
|
-
/**
|
|
353
|
-
* When true, skip generating ticks/labels/title for the x-axis. Used by
|
|
354
|
-
* sparkline display mode when the user hasn't explicitly opted into axes.
|
|
355
|
-
*/
|
|
356
|
-
skipX?: boolean;
|
|
357
|
-
/** Same as skipX, for the y-axis. */
|
|
358
|
-
skipY?: boolean;
|
|
359
|
-
/**
|
|
360
|
-
* The chart's primary mark type. Used to default tickPosition: line and area
|
|
361
|
-
* y-axes default to `'inline'` (labels above gridlines, no gutter); other
|
|
362
|
-
* marks default to `'gutter'`.
|
|
363
|
-
*/
|
|
364
|
-
markType?: import('@opendata-ai/openchart-core').MarkType;
|
|
365
|
-
/** Total container width for axis title offset threshold checks. */
|
|
366
|
-
totalWidth?: number;
|
|
367
|
-
/**
|
|
368
|
-
* Pinned y-axis tick values from the layout plan. When provided, the y-axis
|
|
369
|
-
* uses these values (via resolveExplicitTicks) instead of generating fresh
|
|
370
|
-
* ticks, so the labels match exactly what the gutter was measured for.
|
|
371
|
-
* User-specified `axis.values` still takes priority.
|
|
372
|
-
*/
|
|
373
|
-
precomputedYTicks?: unknown[];
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Compute axis layouts with tick positions, labels, and axis lines.
|
|
378
|
-
*
|
|
379
|
-
* @param scales - Resolved scales from computeScales.
|
|
380
|
-
* @param chartArea - The chart drawing area.
|
|
381
|
-
* @param strategy - Responsive layout strategy.
|
|
382
|
-
* @param theme - Resolved theme for styling.
|
|
383
|
-
* @param measureText - Optional real text measurement from the adapter.
|
|
384
|
-
* @param dataContext - Optional data context for labelField subtitle support.
|
|
385
|
-
*/
|
|
386
|
-
export function computeAxes(
|
|
387
|
-
scales: ResolvedScales,
|
|
388
|
-
chartArea: Rect,
|
|
389
|
-
strategy: LayoutStrategy,
|
|
390
|
-
theme: ResolvedTheme,
|
|
391
|
-
measureText?: MeasureTextFn,
|
|
392
|
-
dataContext?: AxesDataContext,
|
|
393
|
-
): AxesResult {
|
|
394
|
-
const result: AxesResult = {};
|
|
395
|
-
const baseDensity = strategy.axisLabelDensity;
|
|
396
|
-
|
|
397
|
-
// Compute per-axis density based on available space.
|
|
398
|
-
// Y-axis density adapts to chart height; X-axis density adapts to chart width.
|
|
399
|
-
const yDensity = effectiveDensity(
|
|
400
|
-
baseDensity,
|
|
401
|
-
chartArea.height,
|
|
402
|
-
HEIGHT_MINIMAL_THRESHOLD,
|
|
403
|
-
HEIGHT_REDUCED_THRESHOLD,
|
|
404
|
-
);
|
|
405
|
-
const xDensity = effectiveDensity(
|
|
406
|
-
baseDensity,
|
|
407
|
-
chartArea.width,
|
|
408
|
-
WIDTH_MINIMAL_THRESHOLD,
|
|
409
|
-
WIDTH_REDUCED_THRESHOLD,
|
|
410
|
-
);
|
|
411
|
-
|
|
412
|
-
const tickLabelStyle: TextStyle = {
|
|
413
|
-
fontFamily: theme.fonts.family,
|
|
414
|
-
fontSize: theme.fonts.sizes.axisTick,
|
|
415
|
-
fontWeight: theme.fonts.weights.normal,
|
|
416
|
-
fill: theme.colors.axis,
|
|
417
|
-
lineHeight: 1.2,
|
|
418
|
-
fontVariant: 'tabular-nums',
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
const axisLabelStyle: TextStyle = {
|
|
422
|
-
fontFamily: theme.fonts.family,
|
|
423
|
-
fontSize: theme.fonts.sizes.body,
|
|
424
|
-
fontWeight: theme.fonts.weights.medium,
|
|
425
|
-
fill: theme.colors.text,
|
|
426
|
-
lineHeight: 1.3,
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
const { fontSize } = tickLabelStyle;
|
|
430
|
-
const { fontWeight } = tickLabelStyle;
|
|
431
|
-
|
|
432
|
-
if (scales.x && !dataContext?.skipX && scales.x.channel.axis !== false) {
|
|
433
|
-
const axisConfig = scales.x.channel.axis;
|
|
434
|
-
const isContinuousX =
|
|
435
|
-
scales.x.type !== 'band' && scales.x.type !== 'point' && scales.x.type !== 'ordinal';
|
|
436
|
-
|
|
437
|
-
const xTargetCount = isContinuousX
|
|
438
|
-
? targetTickCount(chartArea.width, xDensity, 'x')
|
|
439
|
-
: undefined;
|
|
440
|
-
|
|
441
|
-
// Use explicit tick values from axis config if provided
|
|
442
|
-
let allTicks: AxisTick[];
|
|
443
|
-
if (axisConfig?.values) {
|
|
444
|
-
allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
|
|
445
|
-
} else if (!isContinuousX) {
|
|
446
|
-
// For band scales, generate all ticks first (no thinning). Rotation and
|
|
447
|
-
// thinning are resolved below once we know the effective label angle.
|
|
448
|
-
allTicks = categoricalTicks(scales.x, xDensity, 'horizontal');
|
|
449
|
-
} else {
|
|
450
|
-
allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// Gridlines use the full tick set so they remain visible even when labels
|
|
454
|
-
// are thinned to prevent overlap.
|
|
455
|
-
const gridlines: Gridline[] = allTicks.map((t) => ({
|
|
456
|
-
position: t.position,
|
|
457
|
-
major: true,
|
|
458
|
-
}));
|
|
459
|
-
|
|
460
|
-
// Band-label policy: choose the shallowest angle at which every label
|
|
461
|
-
// fits (flat → -45° → -90°), then apply the uniform stride safety net —
|
|
462
|
-
// stride 1 (keep everything) except at extreme category counts. See
|
|
463
|
-
// ./axes/rotation for the geometry.
|
|
464
|
-
let tickAngle = axisConfig?.labelAngle;
|
|
465
|
-
let bandStride = 1;
|
|
466
|
-
if (scales.x.type === 'band' && allTicks.length > 1) {
|
|
467
|
-
const bandScale = scales.x.scale as ScaleBand<string>;
|
|
468
|
-
const bandwidth = bandScale.bandwidth();
|
|
469
|
-
// Anchor spacing between the ticks actually drawn — not the raw band
|
|
470
|
-
// step. categoricalTicks may have applied an explicit tickCount cap, so
|
|
471
|
-
// retained ticks can sit a multiple of the step apart, and explicit
|
|
472
|
-
// axis.values subsets can be non-uniform: collision geometry cares
|
|
473
|
-
// about the closest adjacent pair.
|
|
474
|
-
let anchorSpacing = Number.POSITIVE_INFINITY;
|
|
475
|
-
for (let i = 1; i < allTicks.length; i++) {
|
|
476
|
-
const d = Math.abs(allTicks[i].position - allTicks[i - 1].position);
|
|
477
|
-
if (d > 0 && d < anchorSpacing) anchorSpacing = d;
|
|
478
|
-
}
|
|
479
|
-
if (!Number.isFinite(anchorSpacing)) anchorSpacing = bandScale.step();
|
|
480
|
-
let maxLabelWidth = 0;
|
|
481
|
-
for (const t of allTicks) {
|
|
482
|
-
const w = measureLabel(t.label, fontSize, fontWeight, measureText);
|
|
483
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
484
|
-
}
|
|
485
|
-
tickAngle = resolveBandTickAngle(
|
|
486
|
-
axisConfig?.labelAngle,
|
|
487
|
-
maxLabelWidth,
|
|
488
|
-
bandwidth,
|
|
489
|
-
allTicks.length,
|
|
490
|
-
anchorSpacing,
|
|
491
|
-
fontSize,
|
|
492
|
-
);
|
|
493
|
-
bandStride = bandLabelStride(maxLabelWidth, tickAngle, fontSize, anchorSpacing);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// Thin tick labels to prevent overlap (skip for explicit tick values).
|
|
497
|
-
const hasExplicitValues = !!axisConfig?.values;
|
|
498
|
-
let ticks: AxisTick[];
|
|
499
|
-
if (hasExplicitValues) {
|
|
500
|
-
ticks = allTicks;
|
|
501
|
-
} else if (scales.x.type === 'band') {
|
|
502
|
-
ticks = applyBandLabelStride(allTicks, bandStride);
|
|
503
|
-
} else if (isContinuousX) {
|
|
504
|
-
ticks = fitContinuousTicks(
|
|
505
|
-
scales.x,
|
|
506
|
-
allTicks,
|
|
507
|
-
xTargetCount ?? allTicks.length,
|
|
508
|
-
fontSize,
|
|
509
|
-
fontWeight,
|
|
510
|
-
chartArea.width,
|
|
511
|
-
'horizontal',
|
|
512
|
-
measureText,
|
|
513
|
-
);
|
|
514
|
-
} else {
|
|
515
|
-
ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// Truncate rotated labels that would overflow the capped reservation.
|
|
519
|
-
// computeXAxisExtentFromLabels clamps the reserved band to
|
|
520
|
-
// X_AXIS_ROTATED_EXTENT_CAP, but nothing shortened the drawn string, so a
|
|
521
|
-
// long label rendered straight past the axis and through the source line.
|
|
522
|
-
// Clip to the same budget the reservation is computed from. `fullLabel`
|
|
523
|
-
// carries the original text so tooltips and the a11y table stay complete.
|
|
524
|
-
if (tickAngle && Math.abs(tickAngle) > 10) {
|
|
525
|
-
// MeasureTextFn returns { width }, not a number — adapt it the same way
|
|
526
|
-
// measureLabel does, or every comparison against the width budget is NaN
|
|
527
|
-
// and the search collapses every label to a bare ellipsis.
|
|
528
|
-
const measureWidth = (text: string, size: number, weight?: number) =>
|
|
529
|
-
measureLabel(text, size, weight ?? fontWeight, measureText);
|
|
530
|
-
ticks = ticks.map((t) => {
|
|
531
|
-
const truncated = truncateRotatedLabel(
|
|
532
|
-
t.label,
|
|
533
|
-
tickAngle,
|
|
534
|
-
fontSize,
|
|
535
|
-
fontWeight,
|
|
536
|
-
measureWidth,
|
|
537
|
-
);
|
|
538
|
-
return truncated === t.label ? t : { ...t, label: truncated, fullLabel: t.label };
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const axisTitle = axisConfig?.title;
|
|
543
|
-
const xLabelColor = axisConfig?.labelColor;
|
|
544
|
-
// X-axis defaults to gutter (no inline mode is sensible for the x axis
|
|
545
|
-
// because tick labels need horizontal room around their x position).
|
|
546
|
-
const xTickPosition = axisConfig?.tickPosition ?? 'gutter';
|
|
547
|
-
|
|
548
|
-
const xDomainLine =
|
|
549
|
-
axisConfig?.domain ?? (GROUNDED_MARKS.has(dataContext?.markType ?? '') ? undefined : false);
|
|
550
|
-
|
|
551
|
-
result.x = {
|
|
552
|
-
ticks,
|
|
553
|
-
gridlines: (axisConfig?.grid ?? dataContext?.markType === 'point') ? gridlines : [],
|
|
554
|
-
label: axisTitle,
|
|
555
|
-
labelStyle: xLabelColor ? { ...axisLabelStyle, fill: xLabelColor } : axisLabelStyle,
|
|
556
|
-
tickLabelStyle: xLabelColor ? { ...tickLabelStyle, fill: xLabelColor } : tickLabelStyle,
|
|
557
|
-
tickAngle,
|
|
558
|
-
start: { x: chartArea.x, y: chartArea.y + chartArea.height },
|
|
559
|
-
end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
|
|
560
|
-
orient: axisConfig?.orient,
|
|
561
|
-
domainLine: xDomainLine,
|
|
562
|
-
tickMarks: axisConfig?.ticks,
|
|
563
|
-
offset: axisConfig?.offset,
|
|
564
|
-
titlePadding: axisConfig?.titlePadding,
|
|
565
|
-
labelPadding: axisConfig?.labelPadding,
|
|
566
|
-
labelOverlap: axisConfig?.labelOverlap,
|
|
567
|
-
labelFlush: axisConfig?.labelFlush,
|
|
568
|
-
tickPosition: xTickPosition,
|
|
569
|
-
};
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
if (scales.y && !dataContext?.skipY && scales.y.channel.axis !== false) {
|
|
573
|
-
const axisConfig = scales.y.channel.axis;
|
|
574
|
-
const isContinuousY =
|
|
575
|
-
scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
|
|
576
|
-
|
|
577
|
-
// Target count from pixels-per-tick when we have a continuous y-axis.
|
|
578
|
-
const yTargetCount = isContinuousY
|
|
579
|
-
? targetTickCount(chartArea.height, yDensity, 'y')
|
|
580
|
-
: undefined;
|
|
581
|
-
|
|
582
|
-
// Use explicit tick values from axis config if provided
|
|
583
|
-
let allTicks: AxisTick[];
|
|
584
|
-
if (axisConfig?.values) {
|
|
585
|
-
allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
|
|
586
|
-
} else if (
|
|
587
|
-
dataContext?.precomputedYTicks &&
|
|
588
|
-
dataContext.precomputedYTicks.length > 0 &&
|
|
589
|
-
isContinuousY
|
|
590
|
-
) {
|
|
591
|
-
// Pinned ticks from the layout plan -- use the same resolution path as
|
|
592
|
-
// explicit user values so the labels match exactly what the gutter was
|
|
593
|
-
// measured for.
|
|
594
|
-
allTicks = resolveExplicitTicks(dataContext.precomputedYTicks, scales.y);
|
|
595
|
-
} else if (!isContinuousY) {
|
|
596
|
-
const yFieldName = dataContext?.encoding.y?.field;
|
|
597
|
-
const yLabelField = axisConfig?.labelField;
|
|
598
|
-
allTicks = categoricalTicks(
|
|
599
|
-
scales.y,
|
|
600
|
-
yDensity,
|
|
601
|
-
'vertical',
|
|
602
|
-
yFieldName && yLabelField && dataContext
|
|
603
|
-
? { data: dataContext.data, fieldName: yFieldName, labelField: yLabelField }
|
|
604
|
-
: undefined,
|
|
605
|
-
);
|
|
606
|
-
} else {
|
|
607
|
-
allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// Thin tick labels to prevent overlap (skip for band scales and explicit tick values).
|
|
611
|
-
// When tickCount is set, we still thin if D3 overshot the requested count
|
|
612
|
-
// (common with log scales where ticks(4) can return 26 values).
|
|
613
|
-
const shouldThinY =
|
|
614
|
-
scales.y.type !== 'band' && !axisConfig?.values && !dataContext?.precomputedYTicks?.length;
|
|
615
|
-
let ticks: AxisTick[];
|
|
616
|
-
if (!shouldThinY) {
|
|
617
|
-
ticks = allTicks;
|
|
618
|
-
} else if (isContinuousY) {
|
|
619
|
-
// Continuous y-axis: re-request ticks at a lower count on overlap so
|
|
620
|
-
// spacing stays uniform and we don't collapse to min/max.
|
|
621
|
-
ticks = fitContinuousTicks(
|
|
622
|
-
scales.y,
|
|
623
|
-
allTicks,
|
|
624
|
-
yTargetCount ?? allTicks.length,
|
|
625
|
-
fontSize,
|
|
626
|
-
fontWeight,
|
|
627
|
-
chartArea.height,
|
|
628
|
-
'vertical',
|
|
629
|
-
measureText,
|
|
630
|
-
);
|
|
631
|
-
} else {
|
|
632
|
-
ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText, 'vertical');
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// Gridlines match the tick set so every gridline has a label.
|
|
636
|
-
const gridlines: Gridline[] = ticks.map((t) => ({
|
|
637
|
-
position: t.position,
|
|
638
|
-
major: true,
|
|
639
|
-
}));
|
|
640
|
-
|
|
641
|
-
const axisTitle = axisConfig?.title;
|
|
642
|
-
const tickAngle = axisConfig?.labelAngle;
|
|
643
|
-
const yLabelColor = axisConfig?.labelColor;
|
|
644
|
-
// Editorial line/area y-axes default to inline tick labels above their
|
|
645
|
-
// gridlines. Other mark types keep the classic gutter placement. Right-side
|
|
646
|
-
// y-axes (dual-axis) always use gutter. Uses the shared predicate so the
|
|
647
|
-
// margin-reservation paths (plan.ts, dimensions.ts) agree with this; here
|
|
648
|
-
// we pass the RESOLVED scale continuity since the scale is already built.
|
|
649
|
-
const isContinuousYAxis =
|
|
650
|
-
scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
|
|
651
|
-
const yTickPosition: 'inline' | 'gutter' = yTickPositionIsInline(
|
|
652
|
-
dataContext?.encoding.y,
|
|
653
|
-
dataContext?.markType ?? '',
|
|
654
|
-
isContinuousYAxis,
|
|
655
|
-
)
|
|
656
|
-
? 'inline'
|
|
657
|
-
: 'gutter';
|
|
658
|
-
|
|
659
|
-
// Inline mode hides the axis line and tick marks by default; the gridlines
|
|
660
|
-
// themselves serve as the visual axis. Explicit user overrides win.
|
|
661
|
-
const yDomainLine = axisConfig?.domain ?? (yTickPosition === 'inline' ? false : undefined);
|
|
662
|
-
const yTickMarks = axisConfig?.ticks ?? (yTickPosition === 'inline' ? false : undefined);
|
|
663
|
-
|
|
664
|
-
result.y = {
|
|
665
|
-
ticks,
|
|
666
|
-
// Y-axis gridlines are shown by default (standard editorial practice).
|
|
667
|
-
// Not for heatmaps: the cells tile the plot, so a gridline can only draw
|
|
668
|
-
// *through* them. The cell grid already is the grid. Explicit `grid: true`
|
|
669
|
-
// still wins.
|
|
670
|
-
gridlines: (axisConfig?.grid ?? dataContext?.markType !== 'rect') ? gridlines : [],
|
|
671
|
-
label: axisTitle,
|
|
672
|
-
labelStyle: yLabelColor ? { ...axisLabelStyle, fill: yLabelColor } : axisLabelStyle,
|
|
673
|
-
tickLabelStyle: yLabelColor ? { ...tickLabelStyle, fill: yLabelColor } : tickLabelStyle,
|
|
674
|
-
tickAngle,
|
|
675
|
-
start: { x: chartArea.x, y: chartArea.y },
|
|
676
|
-
end: { x: chartArea.x, y: chartArea.y + chartArea.height },
|
|
677
|
-
orient: axisConfig?.orient,
|
|
678
|
-
domainLine: yDomainLine,
|
|
679
|
-
tickMarks: yTickMarks,
|
|
680
|
-
offset: axisConfig?.offset,
|
|
681
|
-
titlePadding: axisConfig?.titlePadding,
|
|
682
|
-
labelPadding: axisConfig?.labelPadding,
|
|
683
|
-
labelOverlap: axisConfig?.labelOverlap,
|
|
684
|
-
labelFlush: axisConfig?.labelFlush,
|
|
685
|
-
tickPosition: yTickPosition,
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
const totalWidth = dataContext?.totalWidth ?? chartArea.x + chartArea.width;
|
|
690
|
-
|
|
691
|
-
if (result.x) {
|
|
692
|
-
const isRotated = !!result.x.tickAngle && Math.abs(result.x.tickAngle) > 10;
|
|
693
|
-
|
|
694
|
-
// Single source of truth for the x-axis vertical extent (rotated or flat).
|
|
695
|
-
// computeXAxisExtentFromLabels applies the correct rotated-extent math
|
|
696
|
-
// (textWidth*|sin θ| + lineHeight*|cos θ|), so the reserved bottom margin
|
|
697
|
-
// matches what the labels actually occupy.
|
|
698
|
-
result.x.extent = computeXAxisExtentFromLabels({
|
|
699
|
-
labels: result.x.ticks.map((t) => t.label),
|
|
700
|
-
tickAngle: result.x.tickAngle,
|
|
701
|
-
hasTitle: !!result.x.label,
|
|
702
|
-
tickFontSize: result.x.tickLabelStyle.fontSize,
|
|
703
|
-
tickFontWeight: result.x.tickLabelStyle.fontWeight,
|
|
704
|
-
xAxisHeight: theme.spacing.xAxisHeight,
|
|
705
|
-
measure: (t, fs, fw) => measureLabel(t, fs, fw, measureText),
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
if (result.x.label) {
|
|
709
|
-
const titleBand = isRotated ? X_AXIS_TITLE_BAND_ROTATED : X_AXIS_TITLE_BAND;
|
|
710
|
-
const tickBand = result.x.extent - titleBand;
|
|
711
|
-
const titleY = isRotated ? tickBand + 14 : tickBand + 9;
|
|
712
|
-
result.x.titlePosition = {
|
|
713
|
-
x: chartArea.x + chartArea.width / 2,
|
|
714
|
-
y: chartArea.y + chartArea.height + titleY,
|
|
715
|
-
};
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
if (result.y?.label && result.y.labelStyle) {
|
|
720
|
-
const isRight = result.y.orient === 'right';
|
|
721
|
-
if (isRight) {
|
|
722
|
-
const titleOff = getAxisTitleOffset(totalWidth);
|
|
723
|
-
result.y.titlePosition = {
|
|
724
|
-
x: chartArea.x + chartArea.width + titleOff,
|
|
725
|
-
y: chartArea.y + chartArea.height / 2,
|
|
726
|
-
angle: 90,
|
|
727
|
-
};
|
|
728
|
-
} else {
|
|
729
|
-
const maxTickLabelWidth = result.y.ticks.reduce((max, t) => {
|
|
730
|
-
const w = estimateTextWidth(
|
|
731
|
-
t.label,
|
|
732
|
-
result.y!.tickLabelStyle.fontSize,
|
|
733
|
-
result.y!.tickLabelStyle.fontWeight ?? 400,
|
|
734
|
-
);
|
|
735
|
-
return Math.max(max, w);
|
|
736
|
-
}, 0);
|
|
737
|
-
// Inline y-ticks live inside the plot (no left gutter), so the title
|
|
738
|
-
// clears only the chart edge — pass the inline flag so the offset drops
|
|
739
|
-
// the tick-label width and the viewport floor. Omitting it reserved a
|
|
740
|
-
// gutter-sized offset that pushed the rotated title off the left edge of
|
|
741
|
-
// the container on narrow/large-font charts.
|
|
742
|
-
const titleOff = axisTitleOffset(
|
|
743
|
-
maxTickLabelWidth,
|
|
744
|
-
result.y.labelStyle.fontSize,
|
|
745
|
-
totalWidth,
|
|
746
|
-
result.y.tickPosition === 'inline',
|
|
747
|
-
);
|
|
748
|
-
result.y.titlePosition = {
|
|
749
|
-
x: chartArea.x - titleOff,
|
|
750
|
-
y: chartArea.y + chartArea.height / 2,
|
|
751
|
-
angle: -90,
|
|
752
|
-
};
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
return result;
|
|
757
|
-
}
|