@cfasim-ui/docs 0.4.11 → 0.4.13
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/charts/BarChart/BarChart.md +54 -1
- package/charts/BarChart/BarChart.vue +180 -28
- package/charts/ChoroplethMap/ChoroplethMap.md +8 -2
- package/charts/ChoroplethMap/ChoroplethMap.vue +89 -7
- package/charts/LineChart/LineChart.md +138 -9
- package/charts/LineChart/LineChart.vue +295 -87
- package/charts/_shared/ChartAnnotations.vue +56 -17
- package/charts/_shared/annotations.ts +14 -9
- package/charts/_shared/chartProps.ts +39 -0
- package/charts/_shared/dateAxis.ts +507 -0
- package/charts/_shared/index.ts +22 -0
- package/charts/_shared/useChartFoundation.ts +3 -0
- package/charts/_shared/useChartPadding.ts +53 -5
- package/charts/hsa-mapping.ts +1 -0
- package/charts/index.ts +0 -1
- package/index.json +1 -1
- package/package.json +1 -1
- package/shared/index.ts +6 -1
- package/shared/useUrlParams.ts +320 -42
|
@@ -160,6 +160,54 @@ Use `value-tick-format` to format the value-axis labels. `tooltip-value-format`
|
|
|
160
160
|
</template>
|
|
161
161
|
</ComponentDemo>
|
|
162
162
|
|
|
163
|
+
### Date categories
|
|
164
|
+
|
|
165
|
+
When every entry of `categories` parses as a date (`YYYY-MM-DD` string
|
|
166
|
+
or `Date` instance), labels switch to date-aware formatting and the
|
|
167
|
+
chart thins them to clean date boundaries (week / month / year
|
|
168
|
+
depending on range). Bar positions stay ordinal — every category gets
|
|
169
|
+
a bar — only the labels are thinned.
|
|
170
|
+
|
|
171
|
+
<ComponentDemo>
|
|
172
|
+
<BarChart
|
|
173
|
+
:data="[12, 18, 24, 35, 41, 38, 29, 21, 14, 9, 7, 4]"
|
|
174
|
+
:categories="['2026-01-05','2026-01-12','2026-01-19','2026-01-26','2026-02-02','2026-02-09','2026-02-16','2026-02-23','2026-03-02','2026-03-09','2026-03-16','2026-03-23']"
|
|
175
|
+
:height="220"
|
|
176
|
+
y-label="% ED visits"
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
<template #code>
|
|
180
|
+
|
|
181
|
+
```vue
|
|
182
|
+
<BarChart
|
|
183
|
+
:data="[12, 18, 24, 35, 41, 38, 29, 21, 14, 9, 7, 4]"
|
|
184
|
+
:categories="[
|
|
185
|
+
'2026-01-05',
|
|
186
|
+
'2026-01-12',
|
|
187
|
+
'2026-01-19',
|
|
188
|
+
'2026-01-26',
|
|
189
|
+
'2026-02-02',
|
|
190
|
+
'2026-02-09',
|
|
191
|
+
'2026-02-16',
|
|
192
|
+
'2026-02-23',
|
|
193
|
+
'2026-03-02',
|
|
194
|
+
'2026-03-09',
|
|
195
|
+
'2026-03-16',
|
|
196
|
+
'2026-03-23',
|
|
197
|
+
]"
|
|
198
|
+
:height="220"
|
|
199
|
+
y-label="% ED visits"
|
|
200
|
+
/>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
</template>
|
|
204
|
+
</ComponentDemo>
|
|
205
|
+
|
|
206
|
+
Use `date-format` to override the auto-picked preset. Same values as
|
|
207
|
+
LineChart's `x-tick-format` (e.g. `"iso"`, `"month-year"`, an
|
|
208
|
+
`Intl.DateTimeFormatOptions` object, or a `(ms, unit?) => string`
|
|
209
|
+
function).
|
|
210
|
+
|
|
163
211
|
### Logarithmic value axis
|
|
164
212
|
|
|
165
213
|
Set `value-scale-type="log"` to switch the value axis to base-10 log
|
|
@@ -313,6 +361,10 @@ anchor. `lineColor`, `lineWidth`, and `lineDash` style the line.
|
|
|
313
361
|
| `width` | `number` | No | — |
|
|
314
362
|
| `height` | `number` | No | — |
|
|
315
363
|
| `title` | `string` | No | — |
|
|
364
|
+
| `titleStyle` | `TitleStyle` | No | — |
|
|
365
|
+
| `axisLabelStyle` | `LabelStyle` | No | — |
|
|
366
|
+
| `tickLabelStyle` | `LabelStyle` | No | — |
|
|
367
|
+
| `legendStyle` | `LabelStyle` | No | — |
|
|
316
368
|
| `xLabel` | `string` | No | — |
|
|
317
369
|
| `yLabel` | `string` | No | — |
|
|
318
370
|
| `debounce` | `number` | No | — |
|
|
@@ -329,7 +381,7 @@ anchor. `lineColor`, `lineWidth`, and `lineDash` style the line.
|
|
|
329
381
|
| `data` | `BarChartData` | No | — |
|
|
330
382
|
| `y` | `BarChartData` | No | — |
|
|
331
383
|
| `series` | `BarSeries[]` | No | — |
|
|
332
|
-
| `categories` | `readonly string[]` | No | — |
|
|
384
|
+
| `categories` | `readonly (string \| Date)[]` | No | — |
|
|
333
385
|
| `orientation` | `"vertical" \| "horizontal"` | No | `"vertical"` |
|
|
334
386
|
| `layout` | `"grouped" \| "stacked"` | No | `"grouped"` |
|
|
335
387
|
| `valueMin` | `number` | No | `0` |
|
|
@@ -337,6 +389,7 @@ anchor. `lineColor`, `lineWidth`, and `lineDash` style the line.
|
|
|
337
389
|
| `valueTicks` | `number \| number[]` | No | — |
|
|
338
390
|
| `valueTickFormat` | `NumberFormat` | No | — |
|
|
339
391
|
| `categoryFormat` | `(label: string, index: number) => string` | No | — |
|
|
392
|
+
| `dateFormat` | `DateFormat` | No | — |
|
|
340
393
|
| `barPadding` | `number` | No | `0.2` |
|
|
341
394
|
| `groupGap` | `number` | No | `1` |
|
|
342
395
|
| `valueGrid` | `boolean` | No | `true` |
|
|
@@ -14,7 +14,20 @@ import {
|
|
|
14
14
|
makeTooltipValueFormatter,
|
|
15
15
|
ChartAnnotations,
|
|
16
16
|
INLINE_LEGEND_ROW_HEIGHT,
|
|
17
|
+
TITLE_LINE_HEIGHT,
|
|
18
|
+
TITLE_FONT_SIZE,
|
|
19
|
+
TITLE_FONT_WEIGHT,
|
|
20
|
+
AXIS_LABEL_FONT_SIZE,
|
|
21
|
+
TICK_LABEL_FONT_SIZE,
|
|
22
|
+
TICK_LABEL_OPACITY,
|
|
23
|
+
LEGEND_FONT_SIZE,
|
|
24
|
+
resolveLabelStyle,
|
|
25
|
+
parseDate,
|
|
26
|
+
isAllDates,
|
|
27
|
+
pickDateTicks,
|
|
28
|
+
formatDate,
|
|
17
29
|
type ChartData,
|
|
30
|
+
type DateFormat,
|
|
18
31
|
type ChartCommonProps,
|
|
19
32
|
type ChartHoverPayload,
|
|
20
33
|
type ChartTooltipBaseProps,
|
|
@@ -53,8 +66,11 @@ interface BarChartProps extends ChartCommonProps {
|
|
|
53
66
|
/**
|
|
54
67
|
* Category labels for the categorical axis. Length should match the
|
|
55
68
|
* longest series. When omitted, indices (0, 1, 2, ...) are used.
|
|
69
|
+
* Accepts date strings or `Date` objects: when every category parses
|
|
70
|
+
* as a date, label formatting switches to date mode (bar positions
|
|
71
|
+
* stay ordinal — they are not time-proportional).
|
|
56
72
|
*/
|
|
57
|
-
categories?: readonly string[];
|
|
73
|
+
categories?: readonly (string | Date)[];
|
|
58
74
|
/** "vertical" (default, aka column) draws upright bars; "horizontal" draws sideways. */
|
|
59
75
|
orientation?: "vertical" | "horizontal";
|
|
60
76
|
/** "grouped" (default) places series side-by-side; "stacked" stacks them. */
|
|
@@ -83,6 +99,16 @@ interface BarChartProps extends ChartCommonProps {
|
|
|
83
99
|
valueTickFormat?: NumberFormat;
|
|
84
100
|
/** Formatter for category-axis labels. Receives the resolved category string. */
|
|
85
101
|
categoryFormat?: (label: string, index: number) => string;
|
|
102
|
+
/**
|
|
103
|
+
* Date-tick formatter for date-mode category labels (auto-detected
|
|
104
|
+
* when every entry of `categories` parses as a date). Ignored unless
|
|
105
|
+
* the axis is in date mode. Accepts a `DateFormat` — preset name,
|
|
106
|
+
* `Intl.DateTimeFormatOptions`, or `(ms, unit?) => string`. When
|
|
107
|
+
* omitted, the formatter is picked from the chosen tick unit (e.g.
|
|
108
|
+
* monthly ticks → `"month-year"`). Has no effect when
|
|
109
|
+
* `categoryFormat` is also set.
|
|
110
|
+
*/
|
|
111
|
+
dateFormat?: DateFormat;
|
|
86
112
|
/**
|
|
87
113
|
* Fraction of each category slot reserved as gap between groups (0..1).
|
|
88
114
|
* Default 0.2 — i.e. bars/groups fill 80% of their slot.
|
|
@@ -158,11 +184,34 @@ const categoryLabels = computed<string[]>(() => {
|
|
|
158
184
|
const n = categoryCount.value;
|
|
159
185
|
const labels = new Array<string>(n);
|
|
160
186
|
for (let i = 0; i < n; i++) {
|
|
161
|
-
|
|
187
|
+
const c = props.categories?.[i];
|
|
188
|
+
if (c instanceof Date) {
|
|
189
|
+
// Stable string form for CSV / tooltip. Date axis formatting
|
|
190
|
+
// (visible labels) goes through `categoryTickItems` separately.
|
|
191
|
+
labels[i] = c.toISOString().slice(0, 10);
|
|
192
|
+
} else {
|
|
193
|
+
labels[i] = c ?? String(i);
|
|
194
|
+
}
|
|
162
195
|
}
|
|
163
196
|
return labels;
|
|
164
197
|
});
|
|
165
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Parallel array of epoch-ms timestamps when every category parses as
|
|
201
|
+
* a date, otherwise `null`. Drives both date-aware tick thinning and
|
|
202
|
+
* the default tick label formatter.
|
|
203
|
+
*/
|
|
204
|
+
const categoryDatesMs = computed<number[] | null>(() => {
|
|
205
|
+
const cats = props.categories;
|
|
206
|
+
if (!cats || cats.length === 0) return null;
|
|
207
|
+
if (!isAllDates(cats, "utc")) return null;
|
|
208
|
+
const out = new Array<number>(cats.length);
|
|
209
|
+
for (let i = 0; i < cats.length; i++) {
|
|
210
|
+
out[i] = parseDate(cats[i], "utc") ?? NaN;
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
});
|
|
214
|
+
|
|
166
215
|
const isVertical = computed(() => props.orientation === "vertical");
|
|
167
216
|
|
|
168
217
|
/** Extent of the value axis (across all series, accounting for stacking). */
|
|
@@ -453,8 +502,49 @@ interface CategoryTickItem {
|
|
|
453
502
|
}
|
|
454
503
|
|
|
455
504
|
const categoryTickItems = computed<CategoryTickItem[]>(() => {
|
|
456
|
-
const out: CategoryTickItem[] = [];
|
|
457
505
|
const n = categoryCount.value;
|
|
506
|
+
const dateMs = categoryDatesMs.value;
|
|
507
|
+
|
|
508
|
+
// Date mode: thin labels to the closest category index for each
|
|
509
|
+
// tick boundary picked by `pickDateTicks`. Bar positions stay
|
|
510
|
+
// ordinal — only the labels become date-aware.
|
|
511
|
+
if (dateMs && dateMs.length > 0) {
|
|
512
|
+
let min = Infinity;
|
|
513
|
+
let max = -Infinity;
|
|
514
|
+
for (const m of dateMs) {
|
|
515
|
+
if (!Number.isFinite(m)) continue;
|
|
516
|
+
if (m < min) min = m;
|
|
517
|
+
if (m > max) max = m;
|
|
518
|
+
}
|
|
519
|
+
if (!Number.isFinite(min) || min === max) return [];
|
|
520
|
+
const span = isVertical.value ? innerW.value : innerH.value;
|
|
521
|
+
const target = Math.max(2, Math.floor(span / 80));
|
|
522
|
+
const picked = pickDateTicks(min, max, target, "utc");
|
|
523
|
+
const unit = picked.unit;
|
|
524
|
+
const items: CategoryTickItem[] = [];
|
|
525
|
+
const seen = new Set<number>();
|
|
526
|
+
for (const tickMs of picked.values) {
|
|
527
|
+
let nearest = -1;
|
|
528
|
+
let best = Infinity;
|
|
529
|
+
for (let i = 0; i < dateMs.length; i++) {
|
|
530
|
+
const d = Math.abs(dateMs[i] - tickMs);
|
|
531
|
+
if (d < best) {
|
|
532
|
+
best = d;
|
|
533
|
+
nearest = i;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (nearest < 0 || seen.has(nearest)) continue;
|
|
537
|
+
seen.add(nearest);
|
|
538
|
+
const center = slotStart(nearest) + slotSize.value / 2;
|
|
539
|
+
const label = props.categoryFormat
|
|
540
|
+
? props.categoryFormat(categoryLabels.value[nearest], nearest)
|
|
541
|
+
: formatDate(dateMs[nearest], props.dateFormat, "utc", unit);
|
|
542
|
+
items.push({ label, pos: center, anchor: "middle" });
|
|
543
|
+
}
|
|
544
|
+
return items;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const out: CategoryTickItem[] = [];
|
|
458
548
|
const fmt = (label: string, i: number) =>
|
|
459
549
|
props.categoryFormat ? props.categoryFormat(label, i) : label;
|
|
460
550
|
for (let i = 0; i < n; i++) {
|
|
@@ -549,6 +639,7 @@ const {
|
|
|
549
639
|
width: () => props.width,
|
|
550
640
|
height: () => props.height,
|
|
551
641
|
title: () => props.title,
|
|
642
|
+
titleStyle: () => props.titleStyle,
|
|
552
643
|
xLabel: () => props.xLabel,
|
|
553
644
|
yLabel: () => props.yLabel,
|
|
554
645
|
debounce: () => props.debounce,
|
|
@@ -565,9 +656,56 @@ const {
|
|
|
565
656
|
onHover: (payload) => emit("hover", payload),
|
|
566
657
|
});
|
|
567
658
|
|
|
659
|
+
/** Resolved style for the x/y axis labels. */
|
|
660
|
+
const axisLabelResolved = computed(() =>
|
|
661
|
+
resolveLabelStyle(props.axisLabelStyle, { fontSize: AXIS_LABEL_FONT_SIZE }),
|
|
662
|
+
);
|
|
663
|
+
/** Resolved style for the axis tick labels. */
|
|
664
|
+
const tickLabelResolved = computed(() =>
|
|
665
|
+
resolveLabelStyle(props.tickLabelStyle, {
|
|
666
|
+
fontSize: TICK_LABEL_FONT_SIZE,
|
|
667
|
+
fillOpacity: TICK_LABEL_OPACITY,
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
670
|
+
/** Resolved style for inline legend item labels. */
|
|
671
|
+
const legendResolved = computed(() =>
|
|
672
|
+
resolveLabelStyle(props.legendStyle, { fontSize: LEGEND_FONT_SIZE }),
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
/** Resolved title style with defaults applied. */
|
|
676
|
+
const titleResolved = computed(() => {
|
|
677
|
+
const s = props.titleStyle;
|
|
678
|
+
const align = s?.align ?? "left";
|
|
679
|
+
const b = bounds.value;
|
|
680
|
+
const x =
|
|
681
|
+
align === "left"
|
|
682
|
+
? b.left
|
|
683
|
+
: align === "right"
|
|
684
|
+
? b.right
|
|
685
|
+
: b.left + b.width / 2;
|
|
686
|
+
const anchor =
|
|
687
|
+
align === "left" ? "start" : align === "right" ? "end" : "middle";
|
|
688
|
+
return {
|
|
689
|
+
lines: (props.title ?? "").split("\n"),
|
|
690
|
+
fontSize: s?.fontSize ?? TITLE_FONT_SIZE,
|
|
691
|
+
lineHeight: s?.lineHeight ?? TITLE_LINE_HEIGHT,
|
|
692
|
+
fontWeight: s?.fontWeight ?? TITLE_FONT_WEIGHT,
|
|
693
|
+
color: s?.color ?? "currentColor",
|
|
694
|
+
x,
|
|
695
|
+
anchor,
|
|
696
|
+
};
|
|
697
|
+
});
|
|
698
|
+
|
|
568
699
|
const hoveredCategoryLabel = computed(() => {
|
|
569
700
|
const i = hoverIndex.value;
|
|
570
701
|
if (i === null) return undefined;
|
|
702
|
+
// In date mode, format the tooltip label so it honors `dateFormat`
|
|
703
|
+
// (otherwise the user would see the raw ISO string while the ticks
|
|
704
|
+
// render with a nicer preset).
|
|
705
|
+
const dateMs = categoryDatesMs.value;
|
|
706
|
+
if (dateMs && Number.isFinite(dateMs[i])) {
|
|
707
|
+
return formatDate(dateMs[i], props.dateFormat, "utc");
|
|
708
|
+
}
|
|
571
709
|
return categoryLabels.value[i];
|
|
572
710
|
});
|
|
573
711
|
|
|
@@ -647,14 +785,21 @@ const positionedLegendItems = computed(() => {
|
|
|
647
785
|
<!-- title -->
|
|
648
786
|
<text
|
|
649
787
|
v-if="title"
|
|
650
|
-
:x="
|
|
651
|
-
:y="
|
|
652
|
-
text-anchor="
|
|
653
|
-
font-size="
|
|
654
|
-
font-weight="
|
|
655
|
-
fill="
|
|
788
|
+
:x="titleResolved.x"
|
|
789
|
+
:y="titleResolved.lineHeight"
|
|
790
|
+
:text-anchor="titleResolved.anchor"
|
|
791
|
+
:font-size="titleResolved.fontSize"
|
|
792
|
+
:font-weight="titleResolved.fontWeight"
|
|
793
|
+
:fill="titleResolved.color"
|
|
656
794
|
>
|
|
657
|
-
|
|
795
|
+
<tspan
|
|
796
|
+
v-for="(line, i) in titleResolved.lines"
|
|
797
|
+
:key="i"
|
|
798
|
+
:x="titleResolved.x"
|
|
799
|
+
:dy="i === 0 ? 0 : titleResolved.lineHeight"
|
|
800
|
+
>
|
|
801
|
+
{{ line }}
|
|
802
|
+
</tspan>
|
|
658
803
|
</text>
|
|
659
804
|
<!-- inline legend -->
|
|
660
805
|
<g v-if="positionedLegendItems.length > 0">
|
|
@@ -669,8 +814,9 @@ const positionedLegendItems = computed(() => {
|
|
|
669
814
|
<text
|
|
670
815
|
:x="item.x + 18"
|
|
671
816
|
:y="item.y + 4"
|
|
672
|
-
font-size="
|
|
673
|
-
fill="
|
|
817
|
+
:font-size="legendResolved.fontSize"
|
|
818
|
+
:fill="legendResolved.fill"
|
|
819
|
+
:font-weight="legendResolved.fontWeight"
|
|
674
820
|
>
|
|
675
821
|
{{ item.label }}
|
|
676
822
|
</text>
|
|
@@ -727,9 +873,10 @@ const positionedLegendItems = computed(() => {
|
|
|
727
873
|
:y="tick.pos"
|
|
728
874
|
text-anchor="end"
|
|
729
875
|
dominant-baseline="middle"
|
|
730
|
-
font-size="
|
|
731
|
-
fill="
|
|
732
|
-
|
|
876
|
+
:font-size="tickLabelResolved.fontSize"
|
|
877
|
+
:fill="tickLabelResolved.fill"
|
|
878
|
+
:font-weight="tickLabelResolved.fontWeight"
|
|
879
|
+
:fill-opacity="tickLabelResolved.fillOpacity"
|
|
733
880
|
>
|
|
734
881
|
{{ tick.value }}
|
|
735
882
|
</text>
|
|
@@ -742,9 +889,10 @@ const positionedLegendItems = computed(() => {
|
|
|
742
889
|
:x="tick.pos"
|
|
743
890
|
:y="padding.top + innerH + 16"
|
|
744
891
|
text-anchor="middle"
|
|
745
|
-
font-size="
|
|
746
|
-
fill="
|
|
747
|
-
|
|
892
|
+
:font-size="tickLabelResolved.fontSize"
|
|
893
|
+
:fill="tickLabelResolved.fill"
|
|
894
|
+
:font-weight="tickLabelResolved.fontWeight"
|
|
895
|
+
:fill-opacity="tickLabelResolved.fillOpacity"
|
|
748
896
|
>
|
|
749
897
|
{{ tick.value }}
|
|
750
898
|
</text>
|
|
@@ -756,8 +904,9 @@ const positionedLegendItems = computed(() => {
|
|
|
756
904
|
:y="0"
|
|
757
905
|
:transform="`translate(14, ${padding.top + innerH / 2}) rotate(-90)`"
|
|
758
906
|
text-anchor="middle"
|
|
759
|
-
font-size="
|
|
760
|
-
fill="
|
|
907
|
+
:font-size="axisLabelResolved.fontSize"
|
|
908
|
+
:fill="axisLabelResolved.fill"
|
|
909
|
+
:font-weight="axisLabelResolved.fontWeight"
|
|
761
910
|
>
|
|
762
911
|
{{ yLabel }}
|
|
763
912
|
</text>
|
|
@@ -770,9 +919,10 @@ const positionedLegendItems = computed(() => {
|
|
|
770
919
|
:x="tick.pos"
|
|
771
920
|
:y="padding.top + innerH + 16"
|
|
772
921
|
:text-anchor="tick.anchor"
|
|
773
|
-
font-size="
|
|
774
|
-
fill="
|
|
775
|
-
|
|
922
|
+
:font-size="tickLabelResolved.fontSize"
|
|
923
|
+
:fill="tickLabelResolved.fill"
|
|
924
|
+
:font-weight="tickLabelResolved.fontWeight"
|
|
925
|
+
:fill-opacity="tickLabelResolved.fillOpacity"
|
|
776
926
|
>
|
|
777
927
|
{{ tick.label }}
|
|
778
928
|
</text>
|
|
@@ -786,9 +936,10 @@ const positionedLegendItems = computed(() => {
|
|
|
786
936
|
:y="tick.pos"
|
|
787
937
|
text-anchor="end"
|
|
788
938
|
dominant-baseline="middle"
|
|
789
|
-
font-size="
|
|
790
|
-
fill="
|
|
791
|
-
|
|
939
|
+
:font-size="tickLabelResolved.fontSize"
|
|
940
|
+
:fill="tickLabelResolved.fill"
|
|
941
|
+
:font-weight="tickLabelResolved.fontWeight"
|
|
942
|
+
:fill-opacity="tickLabelResolved.fillOpacity"
|
|
792
943
|
>
|
|
793
944
|
{{ tick.label }}
|
|
794
945
|
</text>
|
|
@@ -799,8 +950,9 @@ const positionedLegendItems = computed(() => {
|
|
|
799
950
|
:x="padding.left + innerW / 2"
|
|
800
951
|
:y="height - 4"
|
|
801
952
|
text-anchor="middle"
|
|
802
|
-
font-size="
|
|
803
|
-
fill="
|
|
953
|
+
:font-size="axisLabelResolved.fontSize"
|
|
954
|
+
:fill="axisLabelResolved.fill"
|
|
955
|
+
:font-weight="axisLabelResolved.fontWeight"
|
|
804
956
|
>
|
|
805
957
|
{{ xLabel }}
|
|
806
958
|
</text>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, ref } from "vue";
|
|
3
3
|
import countiesTopoForPerf from "us-atlas/counties-10m.json";
|
|
4
|
-
import { fipsToHsa } from "@cfasim-ui/charts";
|
|
4
|
+
import { fipsToHsa } from "@cfasim-ui/charts/hsa-mapping";
|
|
5
5
|
|
|
6
6
|
// Build one row per county (~3,143) with a deterministic-ish value so the
|
|
7
7
|
// perf example can render every region with a custom tooltip.
|
|
@@ -478,6 +478,10 @@ feature (e.g. an HSA from a county via `fipsToHsa`). Pass both as a
|
|
|
478
478
|
parent HSA renders on top as a dashed overlay (`stroke: "#666"` here —
|
|
479
479
|
default is white).
|
|
480
480
|
|
|
481
|
+
`fipsToHsa` and `hsaNames` ship from the `@cfasim-ui/charts/hsa-mapping`
|
|
482
|
+
subpath so consumers that don't need HSA lookups don't pay for the
|
|
483
|
+
~25 KB of mapping data.
|
|
484
|
+
|
|
481
485
|
<ComponentDemo>
|
|
482
486
|
<ChoroplethMap
|
|
483
487
|
:topology="countiesTopo"
|
|
@@ -512,7 +516,7 @@ default is white).
|
|
|
512
516
|
```vue
|
|
513
517
|
<script setup>
|
|
514
518
|
import { ref, computed } from "vue";
|
|
515
|
-
import { fipsToHsa } from "@cfasim-ui/charts";
|
|
519
|
+
import { fipsToHsa } from "@cfasim-ui/charts/hsa-mapping";
|
|
516
520
|
|
|
517
521
|
const focusedCounty = ref(null);
|
|
518
522
|
const focus = computed(() => {
|
|
@@ -691,6 +695,8 @@ set `tooltip-trigger`.
|
|
|
691
695
|
| `height` | `number` | No | — |
|
|
692
696
|
| `colorScale` | `ChoroplethColorScale \| ThresholdStop[] \| CategoricalStop[]` | No | — |
|
|
693
697
|
| `title` | `string` | No | — |
|
|
698
|
+
| `titleStyle` | `TitleStyle` | No | — |
|
|
699
|
+
| `legendStyle` | `LabelStyle` | No | — |
|
|
694
700
|
| `noDataColor` | `string` | No | `"#ddd"` |
|
|
695
701
|
| `strokeColor` | `string` | No | `"#fff"` |
|
|
696
702
|
| `strokeWidth` | `number` | No | `0.5` |
|
|
@@ -17,11 +17,17 @@ import "d3-transition";
|
|
|
17
17
|
import { feature, mesh, merge } from "topojson-client";
|
|
18
18
|
import type { Topology, GeometryCollection } from "topojson-specification";
|
|
19
19
|
import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
|
|
20
|
-
import { fipsToHsa, hsaNames } from "./hsaMapping.js";
|
|
21
20
|
import ChartMenu from "../ChartMenu/ChartMenu.vue";
|
|
22
21
|
import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
23
22
|
import { saveSvg, savePng } from "../ChartMenu/download.js";
|
|
24
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
useChartFullscreen,
|
|
25
|
+
TITLE_FONT_SIZE,
|
|
26
|
+
TITLE_LINE_HEIGHT,
|
|
27
|
+
TITLE_FONT_WEIGHT,
|
|
28
|
+
type TitleStyle,
|
|
29
|
+
type LabelStyle,
|
|
30
|
+
} from "../_shared/index.js";
|
|
25
31
|
import { placeTooltip } from "../tooltip-position.js";
|
|
26
32
|
import ChoroplethTooltip from "./ChoroplethTooltip.vue";
|
|
27
33
|
|
|
@@ -104,7 +110,15 @@ const props = withDefaults(
|
|
|
104
110
|
width?: number;
|
|
105
111
|
height?: number;
|
|
106
112
|
colorScale?: ChoroplethColorScale | ThresholdStop[] | CategoricalStop[];
|
|
113
|
+
/**
|
|
114
|
+
* Map title. `\n` in the string creates additional lines, each
|
|
115
|
+
* adding `titleStyle.lineHeight` (default 18px) of vertical space.
|
|
116
|
+
*/
|
|
107
117
|
title?: string;
|
|
118
|
+
/** Styling for the map title. See `TitleStyle`. */
|
|
119
|
+
titleStyle?: TitleStyle;
|
|
120
|
+
/** Styling for the legend (title, swatch labels, and continuous-scale ticks). */
|
|
121
|
+
legendStyle?: LabelStyle;
|
|
108
122
|
noDataColor?: string;
|
|
109
123
|
strokeColor?: string;
|
|
110
124
|
strokeWidth?: number;
|
|
@@ -231,6 +245,33 @@ const slots = useSlots();
|
|
|
231
245
|
const hasInteractiveTooltip = computed(
|
|
232
246
|
() => !!props.tooltipTrigger || !!props.tooltipFormat || !!slots.tooltip,
|
|
233
247
|
);
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Inline style for the legend container. Font properties cascade to
|
|
251
|
+
* children (legend title, swatch labels, continuous-scale ticks).
|
|
252
|
+
*/
|
|
253
|
+
const legendInlineStyle = computed(() => {
|
|
254
|
+
const s = props.legendStyle;
|
|
255
|
+
const style: Record<string, string> = {};
|
|
256
|
+
if (s?.fontSize != null) style["font-size"] = `${s.fontSize}px`;
|
|
257
|
+
if (s?.fontWeight != null) style["font-weight"] = String(s.fontWeight);
|
|
258
|
+
if (s?.color != null) style.color = s.color;
|
|
259
|
+
return style;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/** Inline style for the title element, applying TitleStyle overrides. */
|
|
263
|
+
const titleInlineStyle = computed(() => {
|
|
264
|
+
const s = props.titleStyle;
|
|
265
|
+
const style: Record<string, string> = {
|
|
266
|
+
"font-size": `${s?.fontSize ?? TITLE_FONT_SIZE}px`,
|
|
267
|
+
"line-height": `${s?.lineHeight ?? TITLE_LINE_HEIGHT}px`,
|
|
268
|
+
"font-weight": String(s?.fontWeight ?? TITLE_FONT_WEIGHT),
|
|
269
|
+
"text-align": s?.align ?? "left",
|
|
270
|
+
width: "100%",
|
|
271
|
+
};
|
|
272
|
+
if (s?.color) style.color = s.color;
|
|
273
|
+
return style;
|
|
274
|
+
});
|
|
234
275
|
// Imperative path bookkeeping. Plain Maps rather than refs — Vue never reads
|
|
235
276
|
// these from a render scope, so mutating them does not trigger re-renders.
|
|
236
277
|
const pathsByFeatureId = new Map<string, SVGPathElement>();
|
|
@@ -612,7 +653,39 @@ type CountiesTopo = Topology<{
|
|
|
612
653
|
states: NamedGeometry;
|
|
613
654
|
}>;
|
|
614
655
|
|
|
656
|
+
// HSA mapping is loaded lazily — it's ~25KB gzipped and only needed when
|
|
657
|
+
// geoType or dataGeoType is "hsas". Keeps the main bundle small for users
|
|
658
|
+
// who only need states/counties maps.
|
|
659
|
+
type HsaModule = typeof import("./hsaMapping.js");
|
|
660
|
+
const hsaModule = ref<HsaModule | null>(null);
|
|
661
|
+
let hsaModulePromise: Promise<HsaModule> | null = null;
|
|
662
|
+
function loadHsaModule() {
|
|
663
|
+
if (!hsaModulePromise) {
|
|
664
|
+
hsaModulePromise = import("./hsaMapping.js").then((m) => {
|
|
665
|
+
hsaModule.value = m;
|
|
666
|
+
return m;
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return hsaModulePromise;
|
|
670
|
+
}
|
|
671
|
+
watch(
|
|
672
|
+
() => {
|
|
673
|
+
if (props.geoType === "hsas" || props.dataGeoType === "hsas") return true;
|
|
674
|
+
const focus = props.focus;
|
|
675
|
+
if (!focus) return false;
|
|
676
|
+
const items = Array.isArray(focus) ? focus : [focus];
|
|
677
|
+
return items.some((f) => typeof f !== "string" && f.geoType === "hsas");
|
|
678
|
+
},
|
|
679
|
+
(needsHsa) => {
|
|
680
|
+
if (needsHsa) loadHsaModule();
|
|
681
|
+
},
|
|
682
|
+
{ immediate: true },
|
|
683
|
+
);
|
|
684
|
+
|
|
615
685
|
const hsaFeaturesGeo = computed(() => {
|
|
686
|
+
const mod = hsaModule.value;
|
|
687
|
+
if (!mod) return { type: "FeatureCollection" as const, features: [] };
|
|
688
|
+
const { fipsToHsa, hsaNames } = mod;
|
|
616
689
|
const topo = toRaw(props.topology) as unknown as CountiesTopo;
|
|
617
690
|
const countyGeometries = topo.objects.counties.geometries;
|
|
618
691
|
const groups = new Map<string, typeof countyGeometries>();
|
|
@@ -696,7 +769,7 @@ function baseToDataId(baseId: string): string | undefined {
|
|
|
696
769
|
const dataGt = props.dataGeoType;
|
|
697
770
|
if (!dataGt || dataGt === props.geoType) return baseId;
|
|
698
771
|
if (props.geoType === "counties" && dataGt === "hsas") {
|
|
699
|
-
return fipsToHsa[baseId];
|
|
772
|
+
return hsaModule.value?.fipsToHsa[baseId];
|
|
700
773
|
}
|
|
701
774
|
if (props.geoType === "counties" && dataGt === "states") {
|
|
702
775
|
return baseId.slice(0, 2);
|
|
@@ -1355,9 +1428,11 @@ watch(
|
|
|
1355
1428
|
// `flush: "post"` so any pending path rebuild from the watcher above has
|
|
1356
1429
|
// already run; we still use the GeoJSON pathGenerator directly so the SVG
|
|
1357
1430
|
// path tree isn't actually required, but keeping the order avoids stacking
|
|
1358
|
-
// two zoom transforms in the same tick.
|
|
1431
|
+
// two zoom transforms in the same tick. `hsaModule` is included so a
|
|
1432
|
+
// cross-geoType focus on an hsa item re-applies once the lazy module
|
|
1433
|
+
// resolves (the base pathGenerator doesn't depend on hsa data).
|
|
1359
1434
|
watch(
|
|
1360
|
-
() => [normalizedFocus.value, pathGenerator.value],
|
|
1435
|
+
() => [normalizedFocus.value, pathGenerator.value, hsaModule.value],
|
|
1361
1436
|
() => applyFocus(),
|
|
1362
1437
|
{ flush: "post" },
|
|
1363
1438
|
);
|
|
@@ -1381,8 +1456,14 @@ watch(
|
|
|
1381
1456
|
viewBox to fit the container.
|
|
1382
1457
|
-->
|
|
1383
1458
|
<div v-if="title || showLegend" class="choropleth-header">
|
|
1384
|
-
<div v-if="title" class="choropleth-title"
|
|
1385
|
-
|
|
1459
|
+
<div v-if="title" class="choropleth-title" :style="titleInlineStyle">
|
|
1460
|
+
{{ title }}
|
|
1461
|
+
</div>
|
|
1462
|
+
<div
|
|
1463
|
+
v-if="showLegend"
|
|
1464
|
+
class="choropleth-legend"
|
|
1465
|
+
:style="legendInlineStyle"
|
|
1466
|
+
>
|
|
1386
1467
|
<span v-if="legendTitle" class="choropleth-legend-title">
|
|
1387
1468
|
{{ legendTitle }}
|
|
1388
1469
|
</span>
|
|
@@ -1539,6 +1620,7 @@ watch(
|
|
|
1539
1620
|
font-size: 14px;
|
|
1540
1621
|
font-weight: 600;
|
|
1541
1622
|
line-height: 1.2;
|
|
1623
|
+
white-space: pre-line;
|
|
1542
1624
|
}
|
|
1543
1625
|
|
|
1544
1626
|
.choropleth-legend {
|