@reuters-graphics/graphics-components 3.4.1 → 3.6.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/components/BioBox/Bio.svelte +134 -0
- package/dist/components/BioBox/Bio.svelte.d.ts +9 -0
- package/dist/components/BioBox/BioBox.svelte +50 -0
- package/dist/components/BioBox/BioBox.svelte.d.ts +13 -0
- package/dist/components/BioBox/SocialLinks.svelte +168 -0
- package/dist/components/BioBox/SocialLinks.svelte.d.ts +11 -0
- package/dist/components/BioBox/types.d.ts +32 -0
- package/dist/components/BioBox/types.js +1 -0
- package/dist/components/InfoBox/InfoBox.svelte +5 -0
- package/dist/components/Legend/Legend.svelte +1245 -0
- package/dist/components/Legend/Legend.svelte.d.ts +100 -0
- package/dist/components/ReferralBlock/Referral.svelte +143 -0
- package/dist/components/ReferralBlock/Referral.svelte.d.ts +17 -0
- package/dist/components/ReferralBlock/ReferralBlock.svelte +41 -158
- package/dist/components/ReferralBlock/ReferralBlock.svelte.d.ts +11 -3
- package/dist/components/ReferralBlock/getReferrals.d.ts +26 -0
- package/dist/components/ReferralBlock/getReferrals.js +58 -0
- package/dist/components/ReferralBlock/types.d.ts +28 -1
- package/dist/components/TileMap/TileMap.svelte +17 -0
- package/dist/components/TileMap/TileMap.svelte.d.ts +6 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
<!-- @component `Legend` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-graphics-legend--docs)
|
|
2
|
+
|
|
3
|
+
Quantitative and categorical legend for maps and data displays.
|
|
4
|
+
|
|
5
|
+
Renders one of five legend types:
|
|
6
|
+
- `threshold`: discrete bins with color swatches
|
|
7
|
+
- `continuous`: a continuous gradient with ticks
|
|
8
|
+
- `diverging`: threshold bins with a highlighted midpoint
|
|
9
|
+
- `categorical`: square swatches with labels in a horizontal row
|
|
10
|
+
- `proportional-symbols`: nested circles with leader lines and labels
|
|
11
|
+
|
|
12
|
+
The component is presentational and can be placed anywhere in page layout. The
|
|
13
|
+
optional `noData` prop renders a separate fallback swatch for missing, unknown,
|
|
14
|
+
or unavailable values across all legend modes.
|
|
15
|
+
-->
|
|
16
|
+
<script lang="ts" module>
|
|
17
|
+
export type LegendMode =
|
|
18
|
+
| 'threshold'
|
|
19
|
+
| 'continuous'
|
|
20
|
+
| 'diverging'
|
|
21
|
+
| 'categorical'
|
|
22
|
+
| 'proportional-symbols';
|
|
23
|
+
|
|
24
|
+
/** A formatter that turns a numeric value into a display string. */
|
|
25
|
+
export type LegendFormatter = (value: number) => string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A threshold/diverging bin. Define at least one of `from`/`to`. Colors are
|
|
29
|
+
* passed through directly, so use any CSS color or design token.
|
|
30
|
+
*/
|
|
31
|
+
export interface LegendItem {
|
|
32
|
+
/** CSS color for the bin or category swatch. */
|
|
33
|
+
color: string;
|
|
34
|
+
/** Lower bound of the bin (inclusive). Omit for an open-ended first bin. */
|
|
35
|
+
from?: number | null;
|
|
36
|
+
/** Upper bound of the bin (exclusive). Omit for an open-ended last bin. */
|
|
37
|
+
to?: number | null;
|
|
38
|
+
/** Optional explicit label. Generated from the bounds when omitted. */
|
|
39
|
+
label?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A continuous gradient stop. */
|
|
43
|
+
export interface LegendStop {
|
|
44
|
+
/** Numeric position of the stop along the domain. */
|
|
45
|
+
value: number;
|
|
46
|
+
/** CSS color at this stop. */
|
|
47
|
+
color: string;
|
|
48
|
+
/** Optional explicit label. */
|
|
49
|
+
label?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A continuous-mode axis tick. */
|
|
53
|
+
export interface LegendTick {
|
|
54
|
+
/** Numeric position of the tick along the domain. */
|
|
55
|
+
value: number;
|
|
56
|
+
/** Optional explicit label. Formatted from `value` when omitted. */
|
|
57
|
+
label?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The highlighted center of a diverging legend. */
|
|
61
|
+
export interface LegendMidpoint {
|
|
62
|
+
/** Numeric value of the midpoint within the legend domain. */
|
|
63
|
+
value: number;
|
|
64
|
+
/** Optional explicit label. Formatted from `value` when omitted. */
|
|
65
|
+
label?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A proportional-symbols entry. */
|
|
69
|
+
export interface LegendSymbolItem {
|
|
70
|
+
/** Non-negative numeric value mapped to circle area. */
|
|
71
|
+
value: number;
|
|
72
|
+
/** Optional explicit label. Formatted from `value` when omitted. */
|
|
73
|
+
label?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Fallback swatch for missing/unknown values. */
|
|
77
|
+
export interface LegendNoData {
|
|
78
|
+
/** Label shown next to the fallback swatch. */
|
|
79
|
+
label: string;
|
|
80
|
+
/** Optional CSS color for the fallback swatch. */
|
|
81
|
+
color?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Internal, mode-specific shapes produced by `buildLegendState`.
|
|
85
|
+
interface PositionedTick {
|
|
86
|
+
value: number;
|
|
87
|
+
label: string;
|
|
88
|
+
position: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ThresholdSegment {
|
|
92
|
+
key: string;
|
|
93
|
+
color: string;
|
|
94
|
+
width: number;
|
|
95
|
+
position: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ThresholdState {
|
|
99
|
+
entries: ThresholdSegment[];
|
|
100
|
+
ticks: PositionedTick[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface ContinuousStop {
|
|
104
|
+
key: string;
|
|
105
|
+
value: number;
|
|
106
|
+
color: string;
|
|
107
|
+
label: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ContinuousState {
|
|
111
|
+
entries: ContinuousStop[];
|
|
112
|
+
gradient: string;
|
|
113
|
+
ticks: PositionedTick[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface DivergingSegment {
|
|
117
|
+
key: string;
|
|
118
|
+
color: string;
|
|
119
|
+
width: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface DivergingState {
|
|
123
|
+
entries: DivergingSegment[];
|
|
124
|
+
midpoint: { value: number; label: string; position: number };
|
|
125
|
+
ticks: PositionedTick[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface CategoricalEntry {
|
|
129
|
+
key: string;
|
|
130
|
+
color: string;
|
|
131
|
+
label: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface CategoricalState {
|
|
135
|
+
entries: CategoricalEntry[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface ProportionalEntry {
|
|
139
|
+
key: string;
|
|
140
|
+
value: number;
|
|
141
|
+
label: string;
|
|
142
|
+
radius: number;
|
|
143
|
+
centerX: number;
|
|
144
|
+
centerY: number;
|
|
145
|
+
lineY: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface ProportionalState {
|
|
149
|
+
entries: ProportionalEntry[];
|
|
150
|
+
chartWidth: number;
|
|
151
|
+
chartHeight: number;
|
|
152
|
+
baselineY: number;
|
|
153
|
+
centerX: number;
|
|
154
|
+
leaderStartX: number;
|
|
155
|
+
labelX: number;
|
|
156
|
+
}
|
|
157
|
+
</script>
|
|
158
|
+
|
|
159
|
+
<script lang="ts">
|
|
160
|
+
import type { ContainerWidth } from '../@types/global';
|
|
161
|
+
import Block from '../Block/Block.svelte';
|
|
162
|
+
|
|
163
|
+
const VALID_MODES = new Set<LegendMode>([
|
|
164
|
+
'threshold',
|
|
165
|
+
'continuous',
|
|
166
|
+
'diverging',
|
|
167
|
+
'categorical',
|
|
168
|
+
'proportional-symbols',
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
const numberFormatter = new Intl.NumberFormat('en-US', {
|
|
172
|
+
maximumFractionDigits: 2,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
function buildKey(...parts: Array<string | number>) {
|
|
176
|
+
return parts.join('-');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
180
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function formatValue(value: number, formatter: LegendFormatter | null) {
|
|
184
|
+
if (typeof formatter === 'function') {
|
|
185
|
+
return formatter(value);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return numberFormatter.format(value);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function sortThresholdItems(items: LegendItem[]) {
|
|
192
|
+
return [...items].sort((left, right) => {
|
|
193
|
+
const leftStart = left.from ?? Number.NEGATIVE_INFINITY;
|
|
194
|
+
const rightStart = right.from ?? Number.NEGATIVE_INFINITY;
|
|
195
|
+
return leftStart - rightStart;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function normalizeThresholdItems(
|
|
200
|
+
items: LegendItem[],
|
|
201
|
+
formatter: LegendFormatter | null
|
|
202
|
+
) {
|
|
203
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
'Legend requires a non-empty "items" array for threshold and diverging modes.'
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sortedItems = sortThresholdItems(items);
|
|
210
|
+
let previousUpperBound = Number.NEGATIVE_INFINITY;
|
|
211
|
+
|
|
212
|
+
return sortedItems.map((item, index) => {
|
|
213
|
+
if (!item || typeof item !== 'object') {
|
|
214
|
+
throw new Error(
|
|
215
|
+
'Legend items must be objects with color and numeric bounds.'
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const { color, from = null, to = null, label = '' } = item;
|
|
220
|
+
if (typeof color !== 'string' || color.trim() === '') {
|
|
221
|
+
throw new Error(
|
|
222
|
+
'Legend items require a non-empty string "color" value.'
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (from === null && to === null) {
|
|
227
|
+
throw new Error('Legend items must define at least one numeric bound.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (from !== null && !isFiniteNumber(from)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
'Legend item "from" values must be finite numbers when provided.'
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (to !== null && !isFiniteNumber(to)) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
'Legend item "to" values must be finite numbers when provided.'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (from !== null && to !== null && from >= to) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
'Legend items must have "from" values lower than "to" values.'
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const lowerBound = from ?? Number.NEGATIVE_INFINITY;
|
|
249
|
+
const upperBound = to ?? Number.POSITIVE_INFINITY;
|
|
250
|
+
|
|
251
|
+
if (index > 0 && lowerBound < previousUpperBound) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
'Legend items must be ordered without overlapping ranges.'
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
previousUpperBound = upperBound;
|
|
258
|
+
|
|
259
|
+
let generatedLabel = label;
|
|
260
|
+
if (!generatedLabel) {
|
|
261
|
+
if (from === null) {
|
|
262
|
+
generatedLabel = `Under ${formatValue(to as number, formatter)}`;
|
|
263
|
+
} else if (to === null) {
|
|
264
|
+
generatedLabel = `${formatValue(from, formatter)}+`;
|
|
265
|
+
} else {
|
|
266
|
+
generatedLabel = `${formatValue(from, formatter)}–${formatValue(to, formatter)}`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
key: buildKey('range', color, lowerBound, upperBound),
|
|
272
|
+
color,
|
|
273
|
+
from,
|
|
274
|
+
to,
|
|
275
|
+
label: generatedLabel,
|
|
276
|
+
lowerBound,
|
|
277
|
+
upperBound,
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function normalizeStops(
|
|
283
|
+
stops: LegendStop[],
|
|
284
|
+
formatter: LegendFormatter | null
|
|
285
|
+
) {
|
|
286
|
+
if (!Array.isArray(stops) || stops.length < 2) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
'Legend requires at least two stops for continuous mode.'
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const normalizedStops = [...stops].map((stop) => {
|
|
293
|
+
if (!stop || typeof stop !== 'object') {
|
|
294
|
+
throw new Error(
|
|
295
|
+
'Legend stops must be objects with value and color properties.'
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const { value, color, label = '' } = stop;
|
|
300
|
+
if (!isFiniteNumber(value)) {
|
|
301
|
+
throw new Error('Legend stop values must be finite numbers.');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (typeof color !== 'string' || color.trim() === '') {
|
|
305
|
+
throw new Error(
|
|
306
|
+
'Legend stops require a non-empty string "color" value.'
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
key: buildKey('stop', value, color),
|
|
312
|
+
value,
|
|
313
|
+
color,
|
|
314
|
+
label: label || formatValue(value, formatter),
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
for (let index = 1; index < normalizedStops.length; index += 1) {
|
|
319
|
+
if (normalizedStops[index].value <= normalizedStops[index - 1].value) {
|
|
320
|
+
throw new Error('Legend stops must be ordered by ascending value.');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return normalizedStops;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function normalizeCategoricalItems(items: LegendItem[]) {
|
|
328
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
'Legend requires a non-empty "items" array for categorical mode.'
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return items.map((item) => {
|
|
335
|
+
if (!item || typeof item !== 'object') {
|
|
336
|
+
throw new Error(
|
|
337
|
+
'Categorical legend items must be objects with color and label values.'
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const { color, label } = item;
|
|
342
|
+
if (typeof color !== 'string' || color.trim() === '') {
|
|
343
|
+
throw new Error(
|
|
344
|
+
'Categorical legend items require a non-empty string "color" value.'
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (typeof label !== 'string' || label.trim() === '') {
|
|
349
|
+
throw new Error(
|
|
350
|
+
'Categorical legend items require a non-empty string "label" value.'
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
key: buildKey('category', color, label),
|
|
356
|
+
color,
|
|
357
|
+
label,
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function normalizeProportionalItems(
|
|
363
|
+
items: LegendSymbolItem[],
|
|
364
|
+
formatter: LegendFormatter | null
|
|
365
|
+
) {
|
|
366
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
'Legend requires a non-empty "items" array for proportional-symbols mode.'
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const normalizedItems = items.map((item) => {
|
|
373
|
+
if (!item || typeof item !== 'object') {
|
|
374
|
+
throw new Error(
|
|
375
|
+
'Proportional symbol legend items must be objects with numeric values.'
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const { value, label = '' } = item;
|
|
380
|
+
if (!isFiniteNumber(value) || value < 0) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
'Proportional symbol legend items require a non-negative numeric "value".'
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
key: buildKey('symbol', value, label || formatValue(value, formatter)),
|
|
388
|
+
value,
|
|
389
|
+
label: label || formatValue(value, formatter),
|
|
390
|
+
};
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
return [...normalizedItems].sort((left, right) => right.value - left.value);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function normalizeNoData(noData: LegendNoData | null) {
|
|
397
|
+
if (!noData) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (typeof noData !== 'object') {
|
|
402
|
+
throw new Error(
|
|
403
|
+
'Legend noData must be an object with label and optional color.'
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const { label, color = 'var(--grey-200)' } = noData;
|
|
408
|
+
|
|
409
|
+
if (typeof label !== 'string' || label.trim() === '') {
|
|
410
|
+
throw new Error(
|
|
411
|
+
'Legend noData objects require a non-empty string "label" value.'
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (typeof color !== 'string' || color.trim() === '') {
|
|
416
|
+
throw new Error(
|
|
417
|
+
'Legend noData objects require a non-empty string "color" value when provided.'
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
label,
|
|
423
|
+
color,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function normalizeTicks(
|
|
428
|
+
ticks: LegendTick[],
|
|
429
|
+
min: number,
|
|
430
|
+
max: number,
|
|
431
|
+
formatter: LegendFormatter | null
|
|
432
|
+
) {
|
|
433
|
+
if (!Array.isArray(ticks) || ticks.length === 0) {
|
|
434
|
+
return [
|
|
435
|
+
{ value: min, label: formatValue(min, formatter), position: 0 },
|
|
436
|
+
{ value: max, label: formatValue(max, formatter), position: 100 },
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const normalizedTicks = [...ticks].map((tick) => {
|
|
441
|
+
if (!tick || typeof tick !== 'object') {
|
|
442
|
+
throw new Error('Legend ticks must be objects with numeric values.');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const { value, label = '' } = tick;
|
|
446
|
+
if (!isFiniteNumber(value)) {
|
|
447
|
+
throw new Error('Legend tick values must be finite numbers.');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (value < min || value > max) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
'Legend tick values must fall within the continuous legend domain.'
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
value,
|
|
458
|
+
label: label || formatValue(value, formatter),
|
|
459
|
+
position: ((value - min) / (max - min)) * 100,
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const sortedTicks = [...normalizedTicks].sort(
|
|
464
|
+
(left, right) => left.value - right.value
|
|
465
|
+
);
|
|
466
|
+
for (let index = 1; index < sortedTicks.length; index += 1) {
|
|
467
|
+
if (sortedTicks[index].value <= sortedTicks[index - 1].value) {
|
|
468
|
+
throw new Error('Legend ticks must be ordered by ascending value.');
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return sortedTicks;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function buildGradient(
|
|
476
|
+
stops: Array<{ value: number; color: string }>,
|
|
477
|
+
min: number,
|
|
478
|
+
max: number
|
|
479
|
+
) {
|
|
480
|
+
const segments = stops.map((stop) => {
|
|
481
|
+
const position = ((stop.value - min) / (max - min)) * 100;
|
|
482
|
+
return `${stop.color} ${position}%`;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
return `linear-gradient(90deg, ${segments.join(', ')})`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function getDomainFromItems(
|
|
489
|
+
items: Array<{ from: number | null; to: number | null }>
|
|
490
|
+
) {
|
|
491
|
+
const finiteBounds = items
|
|
492
|
+
.flatMap((item) => [item.from, item.to])
|
|
493
|
+
.filter((value): value is number => isFiniteNumber(value));
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
min: Math.min(...finiteBounds),
|
|
497
|
+
max: Math.max(...finiteBounds),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function buildDivergingSegments<
|
|
502
|
+
T extends { from: number | null; to: number | null },
|
|
503
|
+
>(items: T[], min: number, max: number) {
|
|
504
|
+
const finiteSpan = max - min;
|
|
505
|
+
|
|
506
|
+
return items.map((item) => {
|
|
507
|
+
const lower = item.from ?? min;
|
|
508
|
+
const upper = item.to ?? max;
|
|
509
|
+
const span = upper - lower;
|
|
510
|
+
const width =
|
|
511
|
+
finiteSpan > 0 ? (span / finiteSpan) * 100 : 100 / items.length;
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
...item,
|
|
515
|
+
width,
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function buildBoundaryTicks(
|
|
521
|
+
items: Array<{ from: number | null; to: number | null }>,
|
|
522
|
+
formatter: LegendFormatter | null,
|
|
523
|
+
midpoint: { value: number; label: string } | null = null
|
|
524
|
+
) {
|
|
525
|
+
const values = items
|
|
526
|
+
.flatMap((item) => [item.from, item.to])
|
|
527
|
+
.filter((value): value is number => isFiniteNumber(value));
|
|
528
|
+
|
|
529
|
+
const uniqueValues = [...new Set(values)].sort(
|
|
530
|
+
(left, right) => left - right
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
return uniqueValues.map((value) => ({
|
|
534
|
+
value,
|
|
535
|
+
label:
|
|
536
|
+
midpoint !== null && value === midpoint.value ?
|
|
537
|
+
midpoint.label
|
|
538
|
+
: formatValue(value, formatter),
|
|
539
|
+
}));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function buildThresholdSegments<T>(items: T[]) {
|
|
543
|
+
const segmentWidth = 100 / items.length;
|
|
544
|
+
|
|
545
|
+
return items.map((item, index) => ({
|
|
546
|
+
...item,
|
|
547
|
+
width: segmentWidth,
|
|
548
|
+
position: segmentWidth * index + segmentWidth / 2,
|
|
549
|
+
}));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function buildThresholdBreakTicks(
|
|
553
|
+
items: Array<{ from: number | null; to: number | null }>,
|
|
554
|
+
formatter: LegendFormatter | null
|
|
555
|
+
) {
|
|
556
|
+
const segmentWidth = 100 / items.length;
|
|
557
|
+
const finiteBounds = items
|
|
558
|
+
.flatMap((item) => [item.from, item.to])
|
|
559
|
+
.filter((value): value is number => isFiniteNumber(value));
|
|
560
|
+
const isNonNegativeScale = finiteBounds.every((value) => value >= 0);
|
|
561
|
+
|
|
562
|
+
const startTick =
|
|
563
|
+
items[0]?.from !== null && isFiniteNumber(items[0]?.from) ?
|
|
564
|
+
{
|
|
565
|
+
value: items[0].from as number,
|
|
566
|
+
label: formatValue(items[0].from as number, formatter),
|
|
567
|
+
position: 0,
|
|
568
|
+
}
|
|
569
|
+
: isNonNegativeScale ?
|
|
570
|
+
{
|
|
571
|
+
value: 0,
|
|
572
|
+
label: formatValue(0, formatter),
|
|
573
|
+
position: 0,
|
|
574
|
+
}
|
|
575
|
+
: null;
|
|
576
|
+
|
|
577
|
+
const breakTicks = items
|
|
578
|
+
.slice(0, -1)
|
|
579
|
+
.map((item, index) => {
|
|
580
|
+
if (!isFiniteNumber(item.to)) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
value: item.to,
|
|
586
|
+
label: formatValue(item.to, formatter),
|
|
587
|
+
position: segmentWidth * (index + 1),
|
|
588
|
+
};
|
|
589
|
+
})
|
|
590
|
+
.filter((tick): tick is NonNullable<typeof tick> => Boolean(tick));
|
|
591
|
+
|
|
592
|
+
return startTick ? [startTick, ...breakTicks] : breakTicks;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function buildProportionalSymbolLayout(
|
|
596
|
+
items: Array<{ key: string; value: number; label: string }>
|
|
597
|
+
) {
|
|
598
|
+
const nonZeroItems = items.filter((item) => item.value > 0);
|
|
599
|
+
const maxValue = nonZeroItems[0]?.value ?? 0;
|
|
600
|
+
const maxRadius = 58;
|
|
601
|
+
const leftPad = 8;
|
|
602
|
+
const topPad = 6;
|
|
603
|
+
const rightPad = 96;
|
|
604
|
+
const bottomPad = 6;
|
|
605
|
+
const centerX = leftPad + maxRadius;
|
|
606
|
+
const baselineY = topPad + maxRadius * 2;
|
|
607
|
+
const chartWidth = centerX + maxRadius + rightPad;
|
|
608
|
+
const chartHeight = baselineY + bottomPad;
|
|
609
|
+
const leaderStartX = centerX + maxRadius + 4;
|
|
610
|
+
const labelX = leaderStartX + 8;
|
|
611
|
+
|
|
612
|
+
const entries = items.map((item) => {
|
|
613
|
+
if (item.value === 0 || maxValue === 0) {
|
|
614
|
+
return {
|
|
615
|
+
...item,
|
|
616
|
+
radius: 0,
|
|
617
|
+
centerX,
|
|
618
|
+
centerY: baselineY,
|
|
619
|
+
lineY: baselineY,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const radius = maxRadius * Math.sqrt(item.value / maxValue);
|
|
624
|
+
return {
|
|
625
|
+
...item,
|
|
626
|
+
radius,
|
|
627
|
+
centerX,
|
|
628
|
+
centerY: baselineY - radius,
|
|
629
|
+
lineY: baselineY - radius * 2,
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
entries,
|
|
635
|
+
chartWidth,
|
|
636
|
+
chartHeight,
|
|
637
|
+
baselineY,
|
|
638
|
+
centerX,
|
|
639
|
+
leaderStartX,
|
|
640
|
+
labelX,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function normalizeMidpoint(
|
|
645
|
+
midpoint: LegendMidpoint | null,
|
|
646
|
+
domain: { min: number; max: number },
|
|
647
|
+
formatter: LegendFormatter | null
|
|
648
|
+
) {
|
|
649
|
+
if (!midpoint || typeof midpoint !== 'object') {
|
|
650
|
+
throw new Error(
|
|
651
|
+
'Legend diverging mode requires a "midpoint" object with a numeric "value".'
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const { value, label = '' } = midpoint;
|
|
656
|
+
const normalizedLabel = typeof label === 'string' ? label.trim() : label;
|
|
657
|
+
|
|
658
|
+
if (
|
|
659
|
+
normalizedLabel !== '' &&
|
|
660
|
+
(typeof normalizedLabel !== 'string' || normalizedLabel === '')
|
|
661
|
+
) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
'Legend midpoint labels must be non-empty strings when provided.'
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (!isFiniteNumber(value) || value < domain.min || value > domain.max) {
|
|
668
|
+
throw new Error(
|
|
669
|
+
'Legend diverging mode requires a midpoint value within the legend domain.'
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
value,
|
|
675
|
+
label: normalizedLabel || formatValue(value, formatter),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function buildLegendState({
|
|
680
|
+
mode,
|
|
681
|
+
items,
|
|
682
|
+
stops,
|
|
683
|
+
ticks,
|
|
684
|
+
midpoint,
|
|
685
|
+
formatter,
|
|
686
|
+
}: {
|
|
687
|
+
mode: LegendMode;
|
|
688
|
+
items: LegendItem[];
|
|
689
|
+
stops: LegendStop[];
|
|
690
|
+
ticks: LegendTick[];
|
|
691
|
+
midpoint: LegendMidpoint | null;
|
|
692
|
+
formatter: LegendFormatter | null;
|
|
693
|
+
}) {
|
|
694
|
+
switch (mode) {
|
|
695
|
+
case 'threshold': {
|
|
696
|
+
const normalizedItems = normalizeThresholdItems(items, formatter);
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
entries: buildThresholdSegments(normalizedItems),
|
|
700
|
+
ticks: buildThresholdBreakTicks(normalizedItems, formatter),
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
case 'continuous': {
|
|
705
|
+
const entries = normalizeStops(stops, formatter);
|
|
706
|
+
const domain = {
|
|
707
|
+
min: entries[0].value,
|
|
708
|
+
max: entries[entries.length - 1].value,
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
entries,
|
|
713
|
+
gradient: buildGradient(entries, domain.min, domain.max),
|
|
714
|
+
ticks: normalizeTicks(ticks, domain.min, domain.max, formatter),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
case 'diverging': {
|
|
719
|
+
const normalizedItems = normalizeThresholdItems(items, formatter);
|
|
720
|
+
const domain = getDomainFromItems(normalizedItems);
|
|
721
|
+
const normalizedMidpoint = normalizeMidpoint(
|
|
722
|
+
midpoint,
|
|
723
|
+
domain,
|
|
724
|
+
formatter
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
entries: buildDivergingSegments(
|
|
729
|
+
normalizedItems,
|
|
730
|
+
domain.min,
|
|
731
|
+
domain.max
|
|
732
|
+
),
|
|
733
|
+
midpoint: {
|
|
734
|
+
...normalizedMidpoint,
|
|
735
|
+
position:
|
|
736
|
+
((normalizedMidpoint.value - domain.min) /
|
|
737
|
+
(domain.max - domain.min || 1)) *
|
|
738
|
+
100,
|
|
739
|
+
},
|
|
740
|
+
ticks: buildBoundaryTicks(
|
|
741
|
+
normalizedItems,
|
|
742
|
+
formatter,
|
|
743
|
+
normalizedMidpoint
|
|
744
|
+
).map((tick) => ({
|
|
745
|
+
...tick,
|
|
746
|
+
position:
|
|
747
|
+
((tick.value - domain.min) / (domain.max - domain.min || 1)) *
|
|
748
|
+
100,
|
|
749
|
+
})),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
case 'categorical':
|
|
754
|
+
return {
|
|
755
|
+
entries: normalizeCategoricalItems(items),
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
case 'proportional-symbols':
|
|
759
|
+
return buildProportionalSymbolLayout(
|
|
760
|
+
normalizeProportionalItems(
|
|
761
|
+
items as unknown as LegendSymbolItem[],
|
|
762
|
+
formatter
|
|
763
|
+
)
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
default:
|
|
767
|
+
return {};
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
interface Props {
|
|
772
|
+
/** Optional legend heading displayed above the scale. */
|
|
773
|
+
title?: string;
|
|
774
|
+
/** Optional subtitle displayed beneath the title. */
|
|
775
|
+
subtitle?: string;
|
|
776
|
+
/** Legend rendering mode. */
|
|
777
|
+
mode?: LegendMode;
|
|
778
|
+
/**
|
|
779
|
+
* Legend entries. Threshold/diverging modes use numeric bounds and colors;
|
|
780
|
+
* categorical mode uses label and color pairs; proportional-symbols mode
|
|
781
|
+
* uses numeric values and optional labels.
|
|
782
|
+
*/
|
|
783
|
+
items?: LegendItem[] | LegendSymbolItem[];
|
|
784
|
+
/** Ordered continuous color stops with numeric values. */
|
|
785
|
+
stops?: LegendStop[];
|
|
786
|
+
/** Optional tick values and labels for continuous mode. */
|
|
787
|
+
ticks?: LegendTick[];
|
|
788
|
+
/** Diverging midpoint with a numeric value and optional label. */
|
|
789
|
+
midpoint?: LegendMidpoint | null;
|
|
790
|
+
/** Optional formatter applied to numeric values. */
|
|
791
|
+
formatter?: LegendFormatter | null;
|
|
792
|
+
/** Optional fallback swatch for missing values. */
|
|
793
|
+
noData?: LegendNoData | null;
|
|
794
|
+
/** Width of the legend within the text well. */
|
|
795
|
+
width?: ContainerWidth;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
let {
|
|
799
|
+
title = '',
|
|
800
|
+
subtitle = '',
|
|
801
|
+
mode = 'threshold',
|
|
802
|
+
items = [],
|
|
803
|
+
stops = [],
|
|
804
|
+
ticks = [],
|
|
805
|
+
midpoint = null,
|
|
806
|
+
formatter = null,
|
|
807
|
+
noData = null,
|
|
808
|
+
width = 'normal',
|
|
809
|
+
}: Props = $props();
|
|
810
|
+
|
|
811
|
+
const validatedMode = $derived.by(() => {
|
|
812
|
+
if (!VALID_MODES.has(mode)) {
|
|
813
|
+
throw new Error(
|
|
814
|
+
'Legend mode must be one of "threshold", "continuous", "diverging", "categorical", or "proportional-symbols".'
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return mode;
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
const headingId = $derived(
|
|
822
|
+
title ?
|
|
823
|
+
`legend-${title
|
|
824
|
+
.toLowerCase()
|
|
825
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
826
|
+
.replace(/^-|-$/g, '')}`
|
|
827
|
+
: null
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
const legendState = $derived.by(() =>
|
|
831
|
+
buildLegendState({
|
|
832
|
+
mode: validatedMode,
|
|
833
|
+
items: items as LegendItem[],
|
|
834
|
+
stops,
|
|
835
|
+
ticks,
|
|
836
|
+
midpoint,
|
|
837
|
+
formatter,
|
|
838
|
+
})
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
// The `legendState` shape varies by mode. Each markup block below is already
|
|
842
|
+
// guarded by `validatedMode`, so we narrow to the matching state type here to
|
|
843
|
+
// keep the template type-safe.
|
|
844
|
+
const thresholdState = $derived(legendState as unknown as ThresholdState);
|
|
845
|
+
const continuousState = $derived(legendState as unknown as ContinuousState);
|
|
846
|
+
const divergingState = $derived(legendState as unknown as DivergingState);
|
|
847
|
+
const categoricalState = $derived(legendState as unknown as CategoricalState);
|
|
848
|
+
const proportionalState = $derived(
|
|
849
|
+
legendState as unknown as ProportionalState
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
const noDataItem = $derived(normalizeNoData(noData));
|
|
853
|
+
</script>
|
|
854
|
+
|
|
855
|
+
<Block {width}>
|
|
856
|
+
<section
|
|
857
|
+
class="legend"
|
|
858
|
+
class:is-threshold={validatedMode === 'threshold'}
|
|
859
|
+
class:is-continuous={validatedMode === 'continuous'}
|
|
860
|
+
class:is-diverging={validatedMode === 'diverging'}
|
|
861
|
+
class:is-categorical={validatedMode === 'categorical'}
|
|
862
|
+
class:is-proportional={validatedMode === 'proportional-symbols'}
|
|
863
|
+
aria-labelledby={headingId}
|
|
864
|
+
>
|
|
865
|
+
{#if title}
|
|
866
|
+
<div class="legend-heading">
|
|
867
|
+
<h3 class="legend-title" id={headingId}>{title}</h3>
|
|
868
|
+
{#if subtitle}
|
|
869
|
+
<div class="legend-subtitle">{subtitle}</div>
|
|
870
|
+
{/if}
|
|
871
|
+
</div>
|
|
872
|
+
{/if}
|
|
873
|
+
|
|
874
|
+
{#if validatedMode === 'threshold'}
|
|
875
|
+
<div class="threshold-legend" aria-label={title || 'Threshold legend'}>
|
|
876
|
+
<div class="threshold-bar" aria-hidden="true">
|
|
877
|
+
{#each thresholdState.entries as segment (segment.key)}
|
|
878
|
+
<span
|
|
879
|
+
class="threshold-segment"
|
|
880
|
+
style:width={`${segment.width}%`}
|
|
881
|
+
style:background-color={segment.color}
|
|
882
|
+
></span>
|
|
883
|
+
{/each}
|
|
884
|
+
</div>
|
|
885
|
+
<div class="scale-labels threshold-scale-labels">
|
|
886
|
+
{#each thresholdState.ticks as tick (tick.value)}
|
|
887
|
+
<span class="scale-label" style:left={`${tick.position}%`}
|
|
888
|
+
>{tick.label}</span
|
|
889
|
+
>
|
|
890
|
+
{/each}
|
|
891
|
+
</div>
|
|
892
|
+
</div>
|
|
893
|
+
{/if}
|
|
894
|
+
|
|
895
|
+
{#if validatedMode === 'continuous'}
|
|
896
|
+
<div class="continuous-legend" aria-label={title || 'Continuous legend'}>
|
|
897
|
+
<div
|
|
898
|
+
class="continuous-bar"
|
|
899
|
+
style:background-image={continuousState.gradient}
|
|
900
|
+
aria-hidden="true"
|
|
901
|
+
></div>
|
|
902
|
+
<div class="legend-axis" aria-hidden="true">
|
|
903
|
+
{#each continuousState.ticks as tick (tick.value)}
|
|
904
|
+
<span class="legend-tick" style:left={`${tick.position}%`}></span>
|
|
905
|
+
{/each}
|
|
906
|
+
</div>
|
|
907
|
+
<div class="legend-tick-labels">
|
|
908
|
+
{#each continuousState.ticks as tick (tick.value)}
|
|
909
|
+
<span class="legend-tick-label" style:left={`${tick.position}%`}
|
|
910
|
+
>{tick.label}</span
|
|
911
|
+
>
|
|
912
|
+
{/each}
|
|
913
|
+
</div>
|
|
914
|
+
</div>
|
|
915
|
+
{/if}
|
|
916
|
+
|
|
917
|
+
{#if validatedMode === 'diverging'}
|
|
918
|
+
<div class="diverging-legend" aria-label={title || 'Diverging legend'}>
|
|
919
|
+
<div class="diverging-bar" aria-hidden="true">
|
|
920
|
+
{#each divergingState.entries as segment (segment.key)}
|
|
921
|
+
<span
|
|
922
|
+
class="diverging-segment"
|
|
923
|
+
style:width={`${segment.width}%`}
|
|
924
|
+
style:background-color={segment.color}
|
|
925
|
+
></span>
|
|
926
|
+
{/each}
|
|
927
|
+
<span
|
|
928
|
+
class="diverging-midpoint"
|
|
929
|
+
style:left={`${divergingState.midpoint.position}%`}
|
|
930
|
+
></span>
|
|
931
|
+
</div>
|
|
932
|
+
<div class="scale-labels diverging-scale-labels">
|
|
933
|
+
{#each divergingState.ticks as tick (tick.value)}
|
|
934
|
+
<span class="scale-label" style:left={`${tick.position}%`}
|
|
935
|
+
>{tick.label}</span
|
|
936
|
+
>
|
|
937
|
+
{/each}
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
{/if}
|
|
941
|
+
|
|
942
|
+
{#if validatedMode === 'categorical'}
|
|
943
|
+
<div
|
|
944
|
+
class="categorical-legend"
|
|
945
|
+
aria-label={title || 'Categorical legend'}
|
|
946
|
+
>
|
|
947
|
+
{#each categoricalState.entries as item (item.key)}
|
|
948
|
+
<div class="categorical-item">
|
|
949
|
+
<span
|
|
950
|
+
class="categorical-swatch"
|
|
951
|
+
style:background-color={item.color}
|
|
952
|
+
aria-hidden="true"
|
|
953
|
+
></span>
|
|
954
|
+
<span class="categorical-label">{item.label}</span>
|
|
955
|
+
</div>
|
|
956
|
+
{/each}
|
|
957
|
+
{#if noDataItem}
|
|
958
|
+
<div class="categorical-item categorical-no-data-item">
|
|
959
|
+
<span
|
|
960
|
+
class="categorical-swatch"
|
|
961
|
+
style:background-color={noDataItem.color}
|
|
962
|
+
aria-hidden="true"
|
|
963
|
+
></span>
|
|
964
|
+
<span class="categorical-label">{noDataItem.label}</span>
|
|
965
|
+
</div>
|
|
966
|
+
{/if}
|
|
967
|
+
</div>
|
|
968
|
+
{/if}
|
|
969
|
+
|
|
970
|
+
{#if validatedMode === 'proportional-symbols'}
|
|
971
|
+
<div
|
|
972
|
+
class="proportional-legend"
|
|
973
|
+
aria-label={title || 'Proportional symbol legend'}
|
|
974
|
+
>
|
|
975
|
+
<svg
|
|
976
|
+
class="proportional-chart"
|
|
977
|
+
viewBox={`0 0 ${proportionalState.chartWidth} ${proportionalState.chartHeight}`}
|
|
978
|
+
role="img"
|
|
979
|
+
aria-label={title || 'Proportional symbol legend'}
|
|
980
|
+
>
|
|
981
|
+
{#each proportionalState.entries as symbol (symbol.key)}
|
|
982
|
+
{#if symbol.radius > 0}
|
|
983
|
+
<circle
|
|
984
|
+
cx={symbol.centerX}
|
|
985
|
+
cy={symbol.centerY}
|
|
986
|
+
r={symbol.radius}
|
|
987
|
+
class="proportional-circle"
|
|
988
|
+
/>
|
|
989
|
+
{/if}
|
|
990
|
+
<line
|
|
991
|
+
x1={proportionalState.centerX}
|
|
992
|
+
y1={symbol.lineY}
|
|
993
|
+
x2={proportionalState.leaderStartX}
|
|
994
|
+
y2={symbol.lineY}
|
|
995
|
+
class="proportional-leader"
|
|
996
|
+
/>
|
|
997
|
+
<text
|
|
998
|
+
x={proportionalState.labelX}
|
|
999
|
+
y={symbol.lineY}
|
|
1000
|
+
class="proportional-label"
|
|
1001
|
+
dominant-baseline="middle"
|
|
1002
|
+
>
|
|
1003
|
+
{symbol.label}
|
|
1004
|
+
</text>
|
|
1005
|
+
{/each}
|
|
1006
|
+
</svg>
|
|
1007
|
+
</div>
|
|
1008
|
+
{/if}
|
|
1009
|
+
|
|
1010
|
+
{#if noDataItem && validatedMode !== 'categorical'}
|
|
1011
|
+
<div class="legend-no-data-item">
|
|
1012
|
+
<span
|
|
1013
|
+
class="categorical-swatch"
|
|
1014
|
+
style:background-color={noDataItem.color}
|
|
1015
|
+
aria-hidden="true"
|
|
1016
|
+
></span>
|
|
1017
|
+
<span class="legend-no-data-label">{noDataItem.label}</span>
|
|
1018
|
+
</div>
|
|
1019
|
+
{/if}
|
|
1020
|
+
</section>
|
|
1021
|
+
</Block>
|
|
1022
|
+
|
|
1023
|
+
<style>/* Generated from
|
|
1024
|
+
https://utopia.fyi/space/calculator/?c=320,18,1.125,1280,21,1.25,7,3,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12
|
|
1025
|
+
*/
|
|
1026
|
+
/* Generated from
|
|
1027
|
+
https://utopia.fyi/space/calculator/?c=320,18,1.125,1280,21,1.25,7,3,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12
|
|
1028
|
+
*/
|
|
1029
|
+
/* Scales by 1.125 */
|
|
1030
|
+
.legend {
|
|
1031
|
+
width: 100%;
|
|
1032
|
+
background: var(--theme-colour-background);
|
|
1033
|
+
padding: 0.5rem 0;
|
|
1034
|
+
display: flex;
|
|
1035
|
+
flex-direction: column;
|
|
1036
|
+
gap: 0.5rem;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
.legend-title {
|
|
1040
|
+
font-family: var(--theme-font-family-sans-serif);
|
|
1041
|
+
font-weight: 700;
|
|
1042
|
+
font-size: var(--theme-font-size-sm);
|
|
1043
|
+
line-height: 1.15;
|
|
1044
|
+
text-align: left;
|
|
1045
|
+
color: var(--theme-colour-text-primary);
|
|
1046
|
+
margin-top: 0;
|
|
1047
|
+
margin-bottom: 0;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
.legend-heading {
|
|
1051
|
+
display: flex;
|
|
1052
|
+
flex-direction: column;
|
|
1053
|
+
gap: 0.0625rem;
|
|
1054
|
+
margin-bottom: 0.25rem;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
.legend-subtitle {
|
|
1058
|
+
font-family: var(--theme-font-family-sans-serif);
|
|
1059
|
+
font-weight: 400;
|
|
1060
|
+
font-size: var(--theme-font-size-xs);
|
|
1061
|
+
line-height: 1.2;
|
|
1062
|
+
color: var(--theme-colour-text-secondary);
|
|
1063
|
+
margin-bottom: 0;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
.threshold-legend,
|
|
1067
|
+
.continuous-legend,
|
|
1068
|
+
.diverging-legend,
|
|
1069
|
+
.categorical-legend,
|
|
1070
|
+
.proportional-legend {
|
|
1071
|
+
display: flex;
|
|
1072
|
+
flex-direction: column;
|
|
1073
|
+
gap: 0.5rem;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
.categorical-legend {
|
|
1077
|
+
flex-direction: row;
|
|
1078
|
+
flex-wrap: wrap;
|
|
1079
|
+
align-items: center;
|
|
1080
|
+
gap: 1rem;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
.legend-no-data-item {
|
|
1084
|
+
display: inline-flex;
|
|
1085
|
+
align-items: center;
|
|
1086
|
+
gap: 0.5rem;
|
|
1087
|
+
margin-top: 0.125rem;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
.legend-no-data-label {
|
|
1091
|
+
font-family: var(--theme-font-family-sans-serif);
|
|
1092
|
+
font-size: var(--theme-font-size-xs);
|
|
1093
|
+
line-height: 1.2;
|
|
1094
|
+
color: var(--theme-colour-text-secondary);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
.threshold-bar,
|
|
1098
|
+
.continuous-bar,
|
|
1099
|
+
.diverging-bar {
|
|
1100
|
+
position: relative;
|
|
1101
|
+
width: 100%;
|
|
1102
|
+
height: 1.125rem;
|
|
1103
|
+
border-radius: 0.1875rem;
|
|
1104
|
+
overflow: hidden;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
.threshold-bar,
|
|
1108
|
+
.continuous-bar {
|
|
1109
|
+
border: 0;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
.threshold-bar {
|
|
1113
|
+
display: flex;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
.diverging-bar {
|
|
1117
|
+
display: flex;
|
|
1118
|
+
border: 0;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
.threshold-segment,
|
|
1122
|
+
.diverging-segment {
|
|
1123
|
+
display: block;
|
|
1124
|
+
height: 100%;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
.categorical-item {
|
|
1128
|
+
display: inline-flex;
|
|
1129
|
+
align-items: center;
|
|
1130
|
+
gap: 0.375rem;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
.categorical-no-data-item {
|
|
1134
|
+
color: var(--theme-colour-text-secondary);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
.categorical-swatch {
|
|
1138
|
+
width: 1.125rem;
|
|
1139
|
+
height: 1.125rem;
|
|
1140
|
+
flex: 0 0 1.125rem;
|
|
1141
|
+
border-radius: 0.1875rem;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
.categorical-label {
|
|
1145
|
+
font-family: var(--theme-font-family-sans-serif);
|
|
1146
|
+
font-size: var(--theme-font-size-xs);
|
|
1147
|
+
line-height: 1.2;
|
|
1148
|
+
color: var(--theme-colour-text-secondary);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
.categorical-no-data-item .categorical-label {
|
|
1152
|
+
color: var(--theme-colour-text-secondary);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
.proportional-chart {
|
|
1156
|
+
width: 100%;
|
|
1157
|
+
max-width: 17rem;
|
|
1158
|
+
overflow: visible;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
.proportional-circle {
|
|
1162
|
+
fill: none;
|
|
1163
|
+
stroke: var(--theme-colour-text-primary);
|
|
1164
|
+
stroke-width: 1.5;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
.proportional-leader {
|
|
1168
|
+
stroke: var(--theme-colour-text-secondary);
|
|
1169
|
+
stroke-width: 1;
|
|
1170
|
+
stroke-dasharray: 4 3;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
.proportional-label {
|
|
1174
|
+
font-family: var(--theme-font-family-sans-serif);
|
|
1175
|
+
font-size: 12px;
|
|
1176
|
+
fill: var(--theme-colour-text-secondary);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
.diverging-midpoint {
|
|
1180
|
+
position: absolute;
|
|
1181
|
+
top: 0;
|
|
1182
|
+
bottom: 0;
|
|
1183
|
+
width: 1px;
|
|
1184
|
+
background: var(--theme-colour-background);
|
|
1185
|
+
transform: translateX(-50%);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
.legend-axis {
|
|
1189
|
+
position: relative;
|
|
1190
|
+
height: 0.375rem;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
.legend-tick {
|
|
1194
|
+
position: absolute;
|
|
1195
|
+
top: 0;
|
|
1196
|
+
width: 1px;
|
|
1197
|
+
height: 0.375rem;
|
|
1198
|
+
background: var(--theme-colour-brand-rules);
|
|
1199
|
+
transform: translateX(-50%);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
.legend-tick-labels,
|
|
1203
|
+
.scale-labels {
|
|
1204
|
+
font-family: var(--theme-font-family-sans-serif);
|
|
1205
|
+
font-size: var(--theme-font-size-xs);
|
|
1206
|
+
position: relative;
|
|
1207
|
+
min-height: 1.25rem;
|
|
1208
|
+
color: var(--theme-colour-text-secondary);
|
|
1209
|
+
line-height: 1.2;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
.legend-tick-label,
|
|
1213
|
+
.scale-label {
|
|
1214
|
+
position: absolute;
|
|
1215
|
+
transform: translateX(-50%);
|
|
1216
|
+
white-space: nowrap;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
.diverging-scale-labels {
|
|
1220
|
+
margin-top: -0.125rem;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
.threshold-scale-labels {
|
|
1224
|
+
margin-top: -0.125rem;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
.is-diverging .legend-title {
|
|
1228
|
+
text-align: center;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
.is-diverging .legend-heading {
|
|
1232
|
+
align-items: center;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
@media (max-width: 767.9px) {
|
|
1236
|
+
.legend-tick-labels,
|
|
1237
|
+
.scale-labels {
|
|
1238
|
+
min-height: 1.125rem;
|
|
1239
|
+
}
|
|
1240
|
+
.threshold-bar,
|
|
1241
|
+
.continuous-bar,
|
|
1242
|
+
.diverging-bar {
|
|
1243
|
+
height: 1rem;
|
|
1244
|
+
}
|
|
1245
|
+
}</style>
|