@opendata-ai/openchart-engine 8.0.0-rc.10 → 8.0.0-rc.12
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 +7 -0
- package/dist/index.js +123 -54
- 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 -359
- 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 -767
- package/src/compile/watermark-obstacle.ts +0 -54
- package/src/compile/you-draw-it.ts +0 -123
- package/src/compile.ts +0 -1548
- 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 -731
- 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 -2093
- 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
package/src/legend/compute.ts
DELETED
|
@@ -1,748 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Legend computation.
|
|
3
|
-
*
|
|
4
|
-
* Derives legend entries from the color encoding's unique values in data,
|
|
5
|
-
* computes position based on layout strategy, and returns a LegendLayout
|
|
6
|
-
* that dimensions.ts uses to reserve space in the chart area.
|
|
7
|
-
*
|
|
8
|
-
* The legend is computed early (before marks) so the chartArea accounts
|
|
9
|
-
* for legend space. Entries come from data + encoding, not marks.
|
|
10
|
-
*
|
|
11
|
-
* Overflow protection: when there are too many entries for the available
|
|
12
|
-
* space, entries are truncated and a "+N more" indicator is appended.
|
|
13
|
-
*
|
|
14
|
-
* The computation is split into two phases:
|
|
15
|
-
* - `computeLegendContent`: everything width-dependent (entry derivation,
|
|
16
|
-
* truncation, wrapping, row count, dimensions). No chart-area positioning.
|
|
17
|
-
* - `placeLegend`: pure placement (computes `bounds` from chartArea).
|
|
18
|
-
*
|
|
19
|
-
* `computeLegend` is a thin compat wrapper that calls both.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import type {
|
|
23
|
-
ContinuousLegendLayout,
|
|
24
|
-
LayoutStrategy,
|
|
25
|
-
LegendEntry,
|
|
26
|
-
LegendEntryPosition,
|
|
27
|
-
LegendLayout,
|
|
28
|
-
Rect,
|
|
29
|
-
ResolvedTheme,
|
|
30
|
-
TextStyle,
|
|
31
|
-
} from '@opendata-ai/openchart-core';
|
|
32
|
-
import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
33
|
-
|
|
34
|
-
import { categoricalColorsForDomain } from '../compile/color-scale-range';
|
|
35
|
-
import type { NormalizedChartSpec } from '../compiler/types';
|
|
36
|
-
import type { MeasureFn } from '../layout/plan';
|
|
37
|
-
import { applyCategoricalSort } from '../layout/scales';
|
|
38
|
-
import {
|
|
39
|
-
CONTINUOUS_LABEL_GAP,
|
|
40
|
-
type ContinuousLegendContent,
|
|
41
|
-
computeContinuousLegendContent,
|
|
42
|
-
hasContinuousColorScale,
|
|
43
|
-
} from './continuous';
|
|
44
|
-
import { countColorSeries, resolveSuppression } from './suppression';
|
|
45
|
-
import {
|
|
46
|
-
ENTRY_GAP,
|
|
47
|
-
ENTRY_GAP_COMPACT,
|
|
48
|
-
legendGap,
|
|
49
|
-
measureLegendWrap,
|
|
50
|
-
SWATCH_GAP,
|
|
51
|
-
SWATCH_SIZE,
|
|
52
|
-
} from './wrap';
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
// Constants
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
const LEGEND_PADDING = 8;
|
|
59
|
-
const LEGEND_RIGHT_WIDTH = 120;
|
|
60
|
-
|
|
61
|
-
/** Max fraction of chart area height for right-positioned legends. */
|
|
62
|
-
const RIGHT_LEGEND_MAX_HEIGHT_RATIO = 0.4;
|
|
63
|
-
|
|
64
|
-
/** Max number of rows for top-positioned legends before truncation. */
|
|
65
|
-
const TOP_LEGEND_MAX_ROWS = 2;
|
|
66
|
-
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
// LegendContent -- pre-computed content before placement
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
|
|
71
|
-
/** Pre-computed legend content produced before final layout. */
|
|
72
|
-
export interface LegendContent {
|
|
73
|
-
entries: LegendEntry[];
|
|
74
|
-
position: 'top' | 'bottom' | 'right' | 'bottom-right' | 'inline';
|
|
75
|
-
labelStyle: TextStyle;
|
|
76
|
-
rowCount: number;
|
|
77
|
-
totalWidth: number;
|
|
78
|
-
height: number;
|
|
79
|
-
entryGap: number;
|
|
80
|
-
swatchSize: number;
|
|
81
|
-
swatchGap: number;
|
|
82
|
-
swatchChipFill: string;
|
|
83
|
-
/** Width of the legend box (for right legends this is the column width). */
|
|
84
|
-
legendWidth: number;
|
|
85
|
-
/** User-provided legend offset, threaded through for placement. */
|
|
86
|
-
offset?: { dx?: number; dy?: number };
|
|
87
|
-
/** Relative entry positions (origin 0,0). Offset by bounds in placeLegend. */
|
|
88
|
-
relativePositions?: LegendEntryPosition[];
|
|
89
|
-
/** Row advance used to build entry positions. */
|
|
90
|
-
rowHeight?: number;
|
|
91
|
-
/**
|
|
92
|
-
* Continuous legend payload (gradient bar / binned swatches). When set,
|
|
93
|
-
* `entries` is empty and `height`/`legendWidth` describe the bar block.
|
|
94
|
-
*/
|
|
95
|
-
continuous?: ContinuousLegendContent;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* True when the legend has something to render (categorical entries or a
|
|
100
|
-
* continuous bar). Space-reservation predicates must use this instead of
|
|
101
|
-
* checking `entries.length` directly.
|
|
102
|
-
*/
|
|
103
|
-
export function hasLegendContent(content: LegendContent): boolean {
|
|
104
|
-
return content.entries.length > 0 || content.continuous != null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ---------------------------------------------------------------------------
|
|
108
|
-
// Helpers
|
|
109
|
-
// ---------------------------------------------------------------------------
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Resolve the constant fields every CategoricalLegendLayout return shares
|
|
113
|
-
* (swatch geometry + chip fill). Pulled out so each early-return branch
|
|
114
|
-
* doesn't re-spell the same five fields.
|
|
115
|
-
*/
|
|
116
|
-
function categoricalDefaults(theme: ResolvedTheme): {
|
|
117
|
-
swatchSize: number;
|
|
118
|
-
swatchGap: number;
|
|
119
|
-
entryGap: number;
|
|
120
|
-
swatchChipFill: string;
|
|
121
|
-
} {
|
|
122
|
-
return {
|
|
123
|
-
swatchSize: SWATCH_SIZE,
|
|
124
|
-
swatchGap: SWATCH_GAP,
|
|
125
|
-
entryGap: ENTRY_GAP,
|
|
126
|
-
swatchChipFill: theme.colors.annotationFill,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function resolveEntryPositions(
|
|
131
|
-
relative: LegendEntryPosition[] | undefined,
|
|
132
|
-
bounds: Rect,
|
|
133
|
-
): LegendEntryPosition[] | undefined {
|
|
134
|
-
if (!relative) return undefined;
|
|
135
|
-
return relative.map((p) => ({
|
|
136
|
-
x: p.x + bounds.x,
|
|
137
|
-
y: p.y + bounds.y,
|
|
138
|
-
labelX: p.labelX + bounds.x,
|
|
139
|
-
labelY: p.labelY + bounds.y,
|
|
140
|
-
width: p.width,
|
|
141
|
-
row: p.row,
|
|
142
|
-
}));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Determine the swatch shape based on mark type. */
|
|
146
|
-
function swatchShapeForType(markType: string): LegendEntry['shape'] {
|
|
147
|
-
switch (markType) {
|
|
148
|
-
case 'line':
|
|
149
|
-
return 'line';
|
|
150
|
-
case 'point':
|
|
151
|
-
case 'circle':
|
|
152
|
-
case 'lollipop':
|
|
153
|
-
case 'beeswarm':
|
|
154
|
-
case 'range':
|
|
155
|
-
return 'circle';
|
|
156
|
-
default:
|
|
157
|
-
return 'square';
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Extract unique color values from data based on the color encoding. */
|
|
162
|
-
function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): LegendEntry[] {
|
|
163
|
-
const colorEnc = spec.encoding.color;
|
|
164
|
-
if (!colorEnc) return [];
|
|
165
|
-
|
|
166
|
-
// Conditional color definitions don't produce legend entries
|
|
167
|
-
if ('condition' in colorEnc) return [];
|
|
168
|
-
|
|
169
|
-
// Bare value defs (constant colors) don't produce legend entries either
|
|
170
|
-
if (!('field' in colorEnc)) return [];
|
|
171
|
-
|
|
172
|
-
// Sequential (quantitative) color doesn't produce discrete legend entries
|
|
173
|
-
if (colorEnc.type === 'quantitative') return [];
|
|
174
|
-
|
|
175
|
-
// Rows missing the color field (e.g. a sibling layer in a layered spec whose
|
|
176
|
-
// marks don't participate in the color encoding) must not manufacture a legend
|
|
177
|
-
// category. Without this guard, `String(undefined)` seeds a phantom "undefined"
|
|
178
|
-
// entry and — with an explicit domain — appends it past the authored entries.
|
|
179
|
-
const dataValues = [
|
|
180
|
-
...new Set(
|
|
181
|
-
spec.data.filter((d) => d[colorEnc.field] != null).map((d) => String(d[colorEnc.field])),
|
|
182
|
-
),
|
|
183
|
-
];
|
|
184
|
-
const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
|
|
185
|
-
const explicitRange = colorEnc.scale?.range as string[] | undefined;
|
|
186
|
-
const shape = swatchShapeForType(spec.markType);
|
|
187
|
-
|
|
188
|
-
// Reproduce the ordinal color scale's domain so swatch colors index the
|
|
189
|
-
// palette exactly like the mark colors do. `buildOrdinalColorScale` takes an
|
|
190
|
-
// explicit domain verbatim, otherwise sorts the data values by `sort`.
|
|
191
|
-
const scaleDomain = explicitDomain
|
|
192
|
-
? explicitDomain.map(String)
|
|
193
|
-
: applyCategoricalSort(dataValues, colorEnc.sort);
|
|
194
|
-
|
|
195
|
-
// Colors come from the same resolver the scale uses, so `highlight` muting
|
|
196
|
-
// and the accent-neutral series strategy show up in the legend too. An
|
|
197
|
-
// explicit range always wins (the scale honours it verbatim).
|
|
198
|
-
const domainColors =
|
|
199
|
-
explicitRange ?? categoricalColorsForDomain(scaleDomain, theme, spec.highlight);
|
|
200
|
-
|
|
201
|
-
// Order legend entries by explicit domain when provided so the author
|
|
202
|
-
// controls which entries render first (and which get truncated last when
|
|
203
|
-
// symbolLimit applies). Without explicit domain, preserve data order.
|
|
204
|
-
const uniqueValues = explicitDomain
|
|
205
|
-
? [
|
|
206
|
-
...explicitDomain.filter((v) => dataValues.includes(v)),
|
|
207
|
-
...dataValues.filter((v) => !explicitDomain.includes(v)),
|
|
208
|
-
]
|
|
209
|
-
: dataValues;
|
|
210
|
-
|
|
211
|
-
const excludeSet = new Set(spec.legend?.exclude ?? []);
|
|
212
|
-
// Hidden series stay in the legend (dimmed) so the user can toggle them
|
|
213
|
-
// back on. `active: false` is the renderer's signal to apply the dimmed
|
|
214
|
-
// visual state.
|
|
215
|
-
const hiddenSet = new Set(spec.hiddenSeries);
|
|
216
|
-
|
|
217
|
-
return uniqueValues
|
|
218
|
-
.map((value, i) => {
|
|
219
|
-
// Look the value up in the scale domain so the color matches the mark
|
|
220
|
-
// even when legend order differs from domain order. Values outside the
|
|
221
|
-
// domain (a data value not in an explicit domain) fall back to position.
|
|
222
|
-
const domainIdx = scaleDomain.indexOf(value);
|
|
223
|
-
const colorIndex = domainIdx >= 0 ? domainIdx : i;
|
|
224
|
-
return {
|
|
225
|
-
label: value,
|
|
226
|
-
color: domainColors[colorIndex % domainColors.length],
|
|
227
|
-
shape,
|
|
228
|
-
active: !hiddenSet.has(value),
|
|
229
|
-
};
|
|
230
|
-
})
|
|
231
|
-
.filter((entry) => !excludeSet.has(entry.label));
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Truncate entries and add a "+N more" indicator if needed.
|
|
236
|
-
*/
|
|
237
|
-
function truncateEntries(entries: LegendEntry[], maxCount: number): LegendEntry[] {
|
|
238
|
-
if (maxCount >= entries.length || maxCount <= 0) return entries;
|
|
239
|
-
|
|
240
|
-
const truncated = entries.slice(0, maxCount);
|
|
241
|
-
const remaining = entries.length - maxCount;
|
|
242
|
-
truncated.push({
|
|
243
|
-
label: `+${remaining} more`,
|
|
244
|
-
color: '#999999',
|
|
245
|
-
shape: 'square',
|
|
246
|
-
active: false,
|
|
247
|
-
overflow: true,
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
return truncated;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Build an empty LegendContent for suppressed/hidden legends. */
|
|
254
|
-
function emptyContent(position: LegendContent['position'], theme: ResolvedTheme): LegendContent {
|
|
255
|
-
return {
|
|
256
|
-
entries: [],
|
|
257
|
-
position,
|
|
258
|
-
labelStyle: {
|
|
259
|
-
fontFamily: theme.fonts.family,
|
|
260
|
-
fontSize: theme.fonts.sizes.small,
|
|
261
|
-
fontWeight: theme.fonts.weights.normal,
|
|
262
|
-
fill: theme.colors.text,
|
|
263
|
-
lineHeight: 1.3,
|
|
264
|
-
},
|
|
265
|
-
rowCount: 0,
|
|
266
|
-
totalWidth: 0,
|
|
267
|
-
height: 0,
|
|
268
|
-
legendWidth: 0,
|
|
269
|
-
...categoricalDefaults(theme),
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ---------------------------------------------------------------------------
|
|
274
|
-
// Public API
|
|
275
|
-
// ---------------------------------------------------------------------------
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Compute legend content (entries, dimensions, wrapping) without placement.
|
|
279
|
-
*
|
|
280
|
-
* This is the "measure" half of legend computation. It determines which
|
|
281
|
-
* entries to show, how they wrap/truncate, and the resulting dimensions,
|
|
282
|
-
* but does not compute a bounding box in chart coordinates.
|
|
283
|
-
*
|
|
284
|
-
* @param spec - Normalized chart spec.
|
|
285
|
-
* @param strategy - Responsive layout strategy.
|
|
286
|
-
* @param theme - Resolved theme.
|
|
287
|
-
* @param availableWidth - Width constraint for layout (e.g. chartArea.width).
|
|
288
|
-
* @param availableHeight - Height constraint for right-legend truncation.
|
|
289
|
-
* @param watermark - Whether the brand watermark is shown (reserves space for bottom legends).
|
|
290
|
-
* @param measure - Optional text measurement function; defaults to estimateTextWidth.
|
|
291
|
-
* @returns LegendContent with entries, dimensions, but no bounds.
|
|
292
|
-
*/
|
|
293
|
-
export function computeLegendContent(
|
|
294
|
-
spec: NormalizedChartSpec,
|
|
295
|
-
strategy: LayoutStrategy,
|
|
296
|
-
theme: ResolvedTheme,
|
|
297
|
-
availableWidth: number,
|
|
298
|
-
availableHeight: number,
|
|
299
|
-
watermark: boolean = true,
|
|
300
|
-
measure?: MeasureFn,
|
|
301
|
-
endpointLabelsDemoted?: boolean,
|
|
302
|
-
): LegendContent {
|
|
303
|
-
const measureWidth = measure ?? estimateTextWidth;
|
|
304
|
-
|
|
305
|
-
// Sparkline mode: legend hidden by default unless the user opted in. Color
|
|
306
|
-
// scales still resolve normally (legend hidden != no colors), so multi-series
|
|
307
|
-
// sparklines retain their categorical palette.
|
|
308
|
-
const sparklineHidden = spec.display === 'sparkline' && !spec.userExplicit.legend;
|
|
309
|
-
|
|
310
|
-
// Legend explicitly hidden via show: false, or height strategy says no legend
|
|
311
|
-
if (sparklineHidden || spec.legend?.show === false || strategy.legendMaxHeight === 0) {
|
|
312
|
-
return emptyContent('top', theme);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Continuous color scale (quantitative color encoding): gradient bar or
|
|
316
|
-
// binned swatch legend, on by default. Horizontal-bar geometry only, so the
|
|
317
|
-
// position resolves to top (newsroom map-key convention) unless the user
|
|
318
|
-
// asks for bottom; right-column positions can't fit the bar.
|
|
319
|
-
if (hasContinuousColorScale(spec)) {
|
|
320
|
-
const continuous = computeContinuousLegendContent(spec, theme, availableWidth);
|
|
321
|
-
const continuousPosition = spec.legend?.position === 'bottom' ? 'bottom' : 'top';
|
|
322
|
-
if (!continuous) {
|
|
323
|
-
return emptyContent(continuousPosition, theme);
|
|
324
|
-
}
|
|
325
|
-
const labelStyle: TextStyle = {
|
|
326
|
-
fontFamily: theme.fonts.family,
|
|
327
|
-
fontSize: theme.fonts.sizes.small,
|
|
328
|
-
fontWeight: theme.fonts.weights.normal,
|
|
329
|
-
fill: theme.colors.text,
|
|
330
|
-
lineHeight: 1.3,
|
|
331
|
-
fontVariant: 'tabular-nums',
|
|
332
|
-
};
|
|
333
|
-
const labelRowHeight = Math.ceil(labelStyle.fontSize * labelStyle.lineHeight);
|
|
334
|
-
const height = continuous.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
|
|
335
|
-
return {
|
|
336
|
-
entries: [],
|
|
337
|
-
position: continuousPosition,
|
|
338
|
-
labelStyle,
|
|
339
|
-
rowCount: 1,
|
|
340
|
-
totalWidth: continuous.barWidth,
|
|
341
|
-
height,
|
|
342
|
-
legendWidth: continuous.barWidth,
|
|
343
|
-
offset: spec.legend?.offset,
|
|
344
|
-
...categoricalDefaults(theme),
|
|
345
|
-
continuous,
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
let entries = extractColorEntries(spec, theme);
|
|
350
|
-
|
|
351
|
-
// Consult the shared suppression truth table so the legend, endpoint column,
|
|
352
|
-
// and end-of-line labels stay in sync. The helper returns
|
|
353
|
-
// `showTraditionalLegend: false` when the endpoint column auto-takes over
|
|
354
|
-
// for >=2-series line/area charts (and the user hasn't forced the legend on).
|
|
355
|
-
const seriesCount = countColorSeries(spec);
|
|
356
|
-
const suppression = resolveSuppression(spec, {
|
|
357
|
-
seriesCount,
|
|
358
|
-
labelsHiddenByStrategy: strategy.labelMode === 'none',
|
|
359
|
-
labelsDensityNone: spec.labels.density === 'none',
|
|
360
|
-
endpointLabelsDemoted,
|
|
361
|
-
});
|
|
362
|
-
if (!suppression.showTraditionalLegend) {
|
|
363
|
-
entries = [];
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Bar/column/lollipop/beeswarm redundancy rule: when color.field matches the
|
|
367
|
-
// category axis field, the legend just repeats the axis labels. Hide it
|
|
368
|
-
// unless the user explicitly configured the legend.
|
|
369
|
-
const hasRedundantLegend =
|
|
370
|
-
spec.markType === 'bar' || spec.markType === 'lollipop' || spec.markType === 'beeswarm';
|
|
371
|
-
if (hasRedundantLegend && entries.length > 0 && !spec.userExplicit?.legend) {
|
|
372
|
-
const colorEnc = spec.encoding.color;
|
|
373
|
-
const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
|
|
374
|
-
if (colorField) {
|
|
375
|
-
const categoryField =
|
|
376
|
-
spec.encoding.y?.type === 'quantitative' ? spec.encoding.x?.field : spec.encoding.y?.field;
|
|
377
|
-
if (colorField === categoryField) {
|
|
378
|
-
entries = [];
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const labelStyle: TextStyle = {
|
|
384
|
-
fontFamily: theme.fonts.family,
|
|
385
|
-
fontSize: theme.fonts.sizes.small,
|
|
386
|
-
fontWeight: theme.fonts.weights.normal,
|
|
387
|
-
fill: theme.colors.text,
|
|
388
|
-
lineHeight: 1.3,
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
// Resolve position: spec-level override wins, then responsive strategy.
|
|
392
|
-
// 'top-left' is a map-only overlay position; charts fold it into 'top'.
|
|
393
|
-
const specPosition = spec.legend?.position === 'top-left' ? 'top' : spec.legend?.position;
|
|
394
|
-
const resolvedPosition = specPosition ?? (strategy.legendPosition === 'right' ? 'right' : 'top');
|
|
395
|
-
|
|
396
|
-
// No entries = empty legend with no space
|
|
397
|
-
if (entries.length === 0) {
|
|
398
|
-
return emptyContent(resolvedPosition, theme);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const offset = spec.legend?.offset;
|
|
402
|
-
|
|
403
|
-
if (resolvedPosition === 'right' || resolvedPosition === 'bottom-right') {
|
|
404
|
-
// Right-positioned legend: vertical stack
|
|
405
|
-
const maxLabelWidth = Math.max(
|
|
406
|
-
...entries.map((e) => measureWidth(e.label, labelStyle.fontSize, labelStyle.fontWeight)),
|
|
407
|
-
);
|
|
408
|
-
const legendWidth = Math.min(
|
|
409
|
-
LEGEND_RIGHT_WIDTH,
|
|
410
|
-
SWATCH_SIZE + SWATCH_GAP + maxLabelWidth + LEGEND_PADDING * 2,
|
|
411
|
-
);
|
|
412
|
-
const entryHeight = Math.max(SWATCH_SIZE, labelStyle.fontSize * labelStyle.lineHeight);
|
|
413
|
-
|
|
414
|
-
// Apply max height ratio (default 40% of chart area, strategy can override)
|
|
415
|
-
const maxHeightRatio =
|
|
416
|
-
strategy.legendMaxHeight > 0 ? strategy.legendMaxHeight : RIGHT_LEGEND_MAX_HEIGHT_RATIO;
|
|
417
|
-
const maxLegendHeight = availableHeight * maxHeightRatio;
|
|
418
|
-
|
|
419
|
-
// Calculate how many entries fit
|
|
420
|
-
const maxFromSpace = Math.max(
|
|
421
|
-
1,
|
|
422
|
-
Math.floor((maxLegendHeight - LEGEND_PADDING * 2) / (entryHeight + 4)),
|
|
423
|
-
);
|
|
424
|
-
// symbolLimit overrides the space-based limit when explicitly set (minimum 1)
|
|
425
|
-
const maxEntries =
|
|
426
|
-
spec.legend?.symbolLimit != null ? Math.max(1, spec.legend.symbolLimit) : maxFromSpace;
|
|
427
|
-
if (entries.length > maxEntries) {
|
|
428
|
-
entries = truncateEntries(entries, maxEntries);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const legendHeight =
|
|
432
|
-
entries.length * entryHeight + (entries.length - 1) * 4 + LEGEND_PADDING * 2;
|
|
433
|
-
const clampedHeight = Math.min(legendHeight, availableHeight);
|
|
434
|
-
|
|
435
|
-
const rightEntryGap = 4;
|
|
436
|
-
const relativePositions: LegendEntryPosition[] = [];
|
|
437
|
-
for (let i = 0; i < entries.length; i++) {
|
|
438
|
-
const ex = 0;
|
|
439
|
-
const ey = i * (entryHeight + rightEntryGap);
|
|
440
|
-
const labelWidth = measureWidth(entries[i].label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
441
|
-
relativePositions.push({
|
|
442
|
-
x: ex,
|
|
443
|
-
y: ey,
|
|
444
|
-
labelX: ex + SWATCH_SIZE + SWATCH_GAP,
|
|
445
|
-
labelY: ey + SWATCH_SIZE / 2,
|
|
446
|
-
width: SWATCH_SIZE + SWATCH_GAP + labelWidth + rightEntryGap,
|
|
447
|
-
row: i,
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
return {
|
|
452
|
-
entries,
|
|
453
|
-
position: resolvedPosition,
|
|
454
|
-
labelStyle,
|
|
455
|
-
rowCount: entries.length,
|
|
456
|
-
totalWidth: legendWidth,
|
|
457
|
-
height: clampedHeight,
|
|
458
|
-
legendWidth,
|
|
459
|
-
offset,
|
|
460
|
-
...categoricalDefaults(theme),
|
|
461
|
-
entryGap: rightEntryGap,
|
|
462
|
-
relativePositions,
|
|
463
|
-
rowHeight: entryHeight + rightEntryGap,
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Top/bottom-positioned legend: horizontal flow with overflow protection.
|
|
468
|
-
// Reserve space on the right for bottom legends so they don't overlap the brand
|
|
469
|
-
// watermark. Top legends don't need this since the brand renders at the bottom.
|
|
470
|
-
const reserveBrand = watermark && resolvedPosition === 'bottom';
|
|
471
|
-
// Tighten gaps on narrow viewports so horizontal legends keep fitting on one row.
|
|
472
|
-
const isCompact = availableWidth < COMPACT_WIDTH;
|
|
473
|
-
const effectivePadding = isCompact ? 2 : LEGEND_PADDING;
|
|
474
|
-
const effectiveEntryGap = isCompact ? ENTRY_GAP_COMPACT : ENTRY_GAP;
|
|
475
|
-
const contentWidth =
|
|
476
|
-
availableWidth - effectivePadding * 2 - (reserveBrand ? BRAND_RESERVE_WIDTH : 0);
|
|
477
|
-
|
|
478
|
-
// Apply symbolLimit first if set (minimum 1), then fit remaining entries to available rows.
|
|
479
|
-
if (spec.legend?.symbolLimit != null) {
|
|
480
|
-
const limit = Math.max(1, spec.legend.symbolLimit);
|
|
481
|
-
if (limit < entries.length) {
|
|
482
|
-
entries = truncateEntries(entries, limit);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Resolve max rows: explicit maxRows wins, then columns-derived, then default.
|
|
487
|
-
const maxRows =
|
|
488
|
-
spec.legend?.maxRows != null
|
|
489
|
-
? Math.max(1, spec.legend.maxRows)
|
|
490
|
-
: spec.legend?.columns != null
|
|
491
|
-
? Math.ceil(entries.length / spec.legend.columns)
|
|
492
|
-
: TOP_LEGEND_MAX_ROWS;
|
|
493
|
-
const { fittingCount } = measureLegendWrap(
|
|
494
|
-
entries,
|
|
495
|
-
contentWidth,
|
|
496
|
-
labelStyle,
|
|
497
|
-
maxRows,
|
|
498
|
-
effectiveEntryGap,
|
|
499
|
-
measureWidth,
|
|
500
|
-
);
|
|
501
|
-
|
|
502
|
-
if (fittingCount < entries.length) {
|
|
503
|
-
entries = truncateEntries(entries, fittingCount);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const totalWidth = entries.reduce((sum, entry) => {
|
|
507
|
-
const labelWidth = measureWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
508
|
-
return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + effectiveEntryGap;
|
|
509
|
-
}, 0);
|
|
510
|
-
|
|
511
|
-
// Calculate actual row count for height (recompute after truncation).
|
|
512
|
-
const finalWrap = measureLegendWrap(
|
|
513
|
-
entries,
|
|
514
|
-
contentWidth,
|
|
515
|
-
labelStyle,
|
|
516
|
-
undefined,
|
|
517
|
-
effectiveEntryGap,
|
|
518
|
-
measureWidth,
|
|
519
|
-
);
|
|
520
|
-
const { rowCount } = finalWrap;
|
|
521
|
-
|
|
522
|
-
const rowHeight = SWATCH_SIZE + 4;
|
|
523
|
-
const legendHeight = rowCount * rowHeight + effectivePadding * 2;
|
|
524
|
-
|
|
525
|
-
// Build relative positions (origin 0,0), offset by bounds in placeLegend
|
|
526
|
-
const relativePositions: LegendEntryPosition[] = [];
|
|
527
|
-
for (let i = 0; i < entries.length; i++) {
|
|
528
|
-
const placement = finalWrap.placements[i];
|
|
529
|
-
if (!placement) break;
|
|
530
|
-
const ex = placement.xOffset;
|
|
531
|
-
const ey = placement.row * rowHeight;
|
|
532
|
-
const labelWidth = measureWidth(entries[i].label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
533
|
-
relativePositions.push({
|
|
534
|
-
x: ex,
|
|
535
|
-
y: ey,
|
|
536
|
-
labelX: ex + SWATCH_SIZE + SWATCH_GAP,
|
|
537
|
-
labelY: ey + SWATCH_SIZE / 2,
|
|
538
|
-
width: SWATCH_SIZE + SWATCH_GAP + labelWidth + effectiveEntryGap,
|
|
539
|
-
row: placement.row,
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
return {
|
|
544
|
-
entries,
|
|
545
|
-
position: resolvedPosition,
|
|
546
|
-
labelStyle,
|
|
547
|
-
rowCount,
|
|
548
|
-
totalWidth,
|
|
549
|
-
height: legendHeight,
|
|
550
|
-
legendWidth: Math.min(totalWidth, contentWidth),
|
|
551
|
-
offset,
|
|
552
|
-
...categoricalDefaults(theme),
|
|
553
|
-
entryGap: effectiveEntryGap,
|
|
554
|
-
relativePositions,
|
|
555
|
-
rowHeight,
|
|
556
|
-
};
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Place a pre-computed legend into chart coordinates.
|
|
561
|
-
*
|
|
562
|
-
* This is the "layout" half of legend computation. It takes a `LegendContent`
|
|
563
|
-
* (from `computeLegendContent`) and produces a full `LegendLayout` with a
|
|
564
|
-
* positioned bounding box.
|
|
565
|
-
*
|
|
566
|
-
* @param content - Pre-computed legend content.
|
|
567
|
-
* @param chartArea - The final chart area (legend space already reserved in margins).
|
|
568
|
-
* @param containerWidth - Full container width, used for responsive legend gap.
|
|
569
|
-
* @param _theme - Resolved theme (reserved for future use).
|
|
570
|
-
* @param xAxisHeight - Height of the x-axis, used for bottom legend positioning.
|
|
571
|
-
* @param axisGapBelowLegend - The `effectiveAxisGap` the margin stack reserved
|
|
572
|
-
* between a top legend and the chart area (inline y-tick labels draw in that
|
|
573
|
-
* zone, above `chartArea.y`). The top legend is lifted above it so it
|
|
574
|
-
* occupies its reserved slot instead of colliding with the topmost tick
|
|
575
|
-
* label. Defaults to 0 so other callers/positions are unaffected.
|
|
576
|
-
* @returns Full LegendLayout with bounds.
|
|
577
|
-
*/
|
|
578
|
-
export function placeLegend(
|
|
579
|
-
content: LegendContent,
|
|
580
|
-
chartArea: Rect,
|
|
581
|
-
containerWidth: number,
|
|
582
|
-
_theme: ResolvedTheme,
|
|
583
|
-
xAxisHeight: number,
|
|
584
|
-
axisGapBelowLegend: number = 0,
|
|
585
|
-
): LegendLayout {
|
|
586
|
-
const { position, entries, labelStyle, legendWidth, height, offset } = content;
|
|
587
|
-
|
|
588
|
-
// Continuous legend: gradient bar / binned swatches. Placement mirrors the
|
|
589
|
-
// categorical top/bottom math; the bar-relative geometry from the content
|
|
590
|
-
// phase is offset into chart coordinates here.
|
|
591
|
-
if (content.continuous) {
|
|
592
|
-
const c = content.continuous;
|
|
593
|
-
const cDx = offset?.dx ?? 0;
|
|
594
|
-
const cDy = offset?.dy ?? 0;
|
|
595
|
-
const cGap = legendGap(containerWidth);
|
|
596
|
-
const boundsX = chartArea.x + cDx;
|
|
597
|
-
const boundsY =
|
|
598
|
-
position === 'bottom'
|
|
599
|
-
? chartArea.y + chartArea.height + xAxisHeight + cGap + cDy
|
|
600
|
-
: chartArea.y - axisGapBelowLegend - cGap - height + cDy;
|
|
601
|
-
const bounds = { x: boundsX, y: boundsY, width: legendWidth, height };
|
|
602
|
-
const bar = { x: bounds.x, y: bounds.y, width: c.barWidth, height: c.barHeight };
|
|
603
|
-
return {
|
|
604
|
-
type: 'continuous' as const,
|
|
605
|
-
mode: c.mode,
|
|
606
|
-
position,
|
|
607
|
-
bounds,
|
|
608
|
-
labelStyle,
|
|
609
|
-
bar,
|
|
610
|
-
colorStops: c.colorStops,
|
|
611
|
-
bins: c.bins.map((b) => ({ ...b, x: b.x + bar.x })),
|
|
612
|
-
ticks: c.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
|
|
613
|
-
labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize,
|
|
614
|
-
} satisfies ContinuousLegendLayout;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// Empty legend = no bounds
|
|
618
|
-
if (entries.length === 0) {
|
|
619
|
-
return {
|
|
620
|
-
type: 'categorical' as const,
|
|
621
|
-
position,
|
|
622
|
-
entries: [],
|
|
623
|
-
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
624
|
-
labelStyle,
|
|
625
|
-
swatchSize: content.swatchSize,
|
|
626
|
-
swatchGap: content.swatchGap,
|
|
627
|
-
entryGap: content.entryGap,
|
|
628
|
-
swatchChipFill: content.swatchChipFill,
|
|
629
|
-
};
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
const offsetDx = offset?.dx ?? 0;
|
|
633
|
-
const offsetDy = offset?.dy ?? 0;
|
|
634
|
-
const gap = legendGap(containerWidth);
|
|
635
|
-
|
|
636
|
-
let boundsX: number;
|
|
637
|
-
let boundsY: number;
|
|
638
|
-
|
|
639
|
-
if (position === 'right' || position === 'bottom-right') {
|
|
640
|
-
boundsX = chartArea.x + chartArea.width + 8 + offsetDx;
|
|
641
|
-
boundsY =
|
|
642
|
-
(position === 'bottom-right' ? chartArea.y + chartArea.height - height : chartArea.y) +
|
|
643
|
-
offsetDy;
|
|
644
|
-
} else if (position === 'bottom') {
|
|
645
|
-
boundsX = chartArea.x + offsetDx;
|
|
646
|
-
boundsY = chartArea.y + chartArea.height + xAxisHeight + gap + offsetDy;
|
|
647
|
-
} else {
|
|
648
|
-
// top
|
|
649
|
-
boundsX = chartArea.x + offsetDx;
|
|
650
|
-
boundsY = chartArea.y - axisGapBelowLegend - gap - height + offsetDy;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
const bounds = { x: boundsX, y: boundsY, width: legendWidth, height };
|
|
654
|
-
|
|
655
|
-
const entryPositions = resolveEntryPositions(content.relativePositions, bounds);
|
|
656
|
-
|
|
657
|
-
return {
|
|
658
|
-
type: 'categorical' as const,
|
|
659
|
-
position,
|
|
660
|
-
entries,
|
|
661
|
-
bounds,
|
|
662
|
-
labelStyle,
|
|
663
|
-
swatchSize: content.swatchSize,
|
|
664
|
-
swatchGap: content.swatchGap,
|
|
665
|
-
entryGap: content.entryGap,
|
|
666
|
-
swatchChipFill: content.swatchChipFill,
|
|
667
|
-
entryPositions,
|
|
668
|
-
rowHeight: content.rowHeight,
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/**
|
|
673
|
-
* Compute legend layout for a chart spec.
|
|
674
|
-
*
|
|
675
|
-
* Thin wrapper that calls `computeLegendContent` then `placeLegend`.
|
|
676
|
-
* Existing callers are untouched.
|
|
677
|
-
*
|
|
678
|
-
* @param spec - Normalized chart spec.
|
|
679
|
-
* @param strategy - Responsive layout strategy.
|
|
680
|
-
* @param theme - Resolved theme.
|
|
681
|
-
* @param chartArea - The available chart area (before legend space is reserved).
|
|
682
|
-
* @returns LegendLayout with position, entries, and bounds.
|
|
683
|
-
*/
|
|
684
|
-
export function computeLegend(
|
|
685
|
-
spec: NormalizedChartSpec,
|
|
686
|
-
strategy: LayoutStrategy,
|
|
687
|
-
theme: ResolvedTheme,
|
|
688
|
-
chartArea: Rect,
|
|
689
|
-
watermark: boolean = true,
|
|
690
|
-
): LegendLayout {
|
|
691
|
-
const content = computeLegendContent(
|
|
692
|
-
spec,
|
|
693
|
-
strategy,
|
|
694
|
-
theme,
|
|
695
|
-
chartArea.width,
|
|
696
|
-
chartArea.height,
|
|
697
|
-
watermark,
|
|
698
|
-
);
|
|
699
|
-
// Continuous legends share placeLegend's placement math.
|
|
700
|
-
if (content.continuous) {
|
|
701
|
-
return placeLegend(content, chartArea, chartArea.width, theme, 0);
|
|
702
|
-
}
|
|
703
|
-
const { position, entries, labelStyle, legendWidth, height, offset: off } = content;
|
|
704
|
-
const offsetDx = off?.dx ?? 0;
|
|
705
|
-
const offsetDy = off?.dy ?? 0;
|
|
706
|
-
let boundsX = chartArea.x + offsetDx;
|
|
707
|
-
let boundsY = chartArea.y + offsetDy;
|
|
708
|
-
const boundsW = legendWidth;
|
|
709
|
-
if (entries.length === 0) {
|
|
710
|
-
return {
|
|
711
|
-
type: 'categorical' as const,
|
|
712
|
-
position,
|
|
713
|
-
entries: [],
|
|
714
|
-
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
715
|
-
labelStyle,
|
|
716
|
-
swatchSize: content.swatchSize,
|
|
717
|
-
swatchGap: content.swatchGap,
|
|
718
|
-
entryGap: content.entryGap,
|
|
719
|
-
swatchChipFill: content.swatchChipFill,
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
if (position === 'right' || position === 'bottom-right') {
|
|
723
|
-
boundsX = chartArea.x + chartArea.width - legendWidth + offsetDx;
|
|
724
|
-
boundsY =
|
|
725
|
-
(position === 'bottom-right' ? chartArea.y + chartArea.height - height : chartArea.y) +
|
|
726
|
-
offsetDy;
|
|
727
|
-
} else if (position === 'bottom') {
|
|
728
|
-
boundsY = chartArea.y + chartArea.height - height + offsetDy;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
const bounds = { x: boundsX, y: boundsY, width: boundsW, height };
|
|
732
|
-
|
|
733
|
-
const entryPositions = resolveEntryPositions(content.relativePositions, bounds);
|
|
734
|
-
|
|
735
|
-
return {
|
|
736
|
-
type: 'categorical' as const,
|
|
737
|
-
position,
|
|
738
|
-
entries,
|
|
739
|
-
bounds,
|
|
740
|
-
labelStyle,
|
|
741
|
-
swatchSize: content.swatchSize,
|
|
742
|
-
swatchGap: content.swatchGap,
|
|
743
|
-
entryGap: content.entryGap,
|
|
744
|
-
swatchChipFill: content.swatchChipFill,
|
|
745
|
-
entryPositions,
|
|
746
|
-
rowHeight: content.rowHeight,
|
|
747
|
-
};
|
|
748
|
-
}
|