@reuters-graphics/graphics-components 3.4.1 → 3.5.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.
@@ -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>