@cfasim-ui/docs 0.4.11 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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) =&gt; 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
- labels[i] = props.categories?.[i] ?? String(i);
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="width / 2"
651
- :y="18"
652
- text-anchor="middle"
653
- font-size="14"
654
- font-weight="600"
655
- fill="currentColor"
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
- {{ title }}
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="11"
673
- fill="currentColor"
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="10"
731
- fill="currentColor"
732
- fill-opacity="0.6"
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="10"
746
- fill="currentColor"
747
- fill-opacity="0.6"
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="13"
760
- fill="currentColor"
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="10"
774
- fill="currentColor"
775
- fill-opacity="0.6"
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="10"
790
- fill="currentColor"
791
- fill-opacity="0.6"
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="13"
803
- fill="currentColor"
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 { useChartFullscreen } from "../_shared/index.js";
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">{{ title }}</div>
1385
- <div v-if="showLegend" class="choropleth-legend">
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 {