@cfasim-ui/docs 0.7.5 → 0.7.6

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.
@@ -370,6 +370,41 @@ before and after zooming in.
370
370
  </template>
371
371
  </ComponentDemo>
372
372
 
373
+ ### Tighter national fit (`tight-fit`)
374
+
375
+ The Albers USA projection places Alaska and Hawaii in the bottom-left corner, and by default the whole composite is fit into view — so Alaska's western tail pushes the contiguous US in from the edges. Set `tight-fit` to crop that overhang and let the lower-48 fill more of the frame: Alaska's tail (and Hawaii) clip into the lower-left corner. Pass a number in `0`–`1` (e.g. `:tight-fit="0.5"`) to crop only partway.
376
+
377
+ Only affects the national (multi-state) view — it's a no-op in single-state mode and on national HSA maps (HSA ids aren't FIPS codes, so Alaska/Hawaii can't be split out).
378
+
379
+ <ComponentDemo>
380
+ <ChoroplethMap
381
+ :topology="statesTopo"
382
+ tight-fit
383
+ :data="[
384
+ { id: '06', value: 100 },
385
+ { id: '36', value: 80 },
386
+ { id: '48', value: 90 },
387
+ { id: '12', value: 70 },
388
+ { id: '17', value: 60 },
389
+ ]"
390
+ title="Cases by State (tight fit)"
391
+ :legend-title="'Cases'"
392
+ :height="400"
393
+ />
394
+
395
+ <template #code>
396
+
397
+ ```vue
398
+ <!-- Crop Alaska's overhang so the lower-48 fills the frame -->
399
+ <ChoroplethMap :topology="statesTopo" tight-fit :data="data" />
400
+
401
+ <!-- ...or crop only partway -->
402
+ <ChoroplethMap :topology="statesTopo" :tight-fit="0.5" :data="data" />
403
+ ```
404
+
405
+ </template>
406
+ </ComponentDemo>
407
+
373
408
  ### HSA-level map
374
409
 
375
410
  Set `geoType="hsas"` to render Health Service Area boundaries. HSAs are dissolved from county boundaries using a built-in FIPS-to-HSA mapping. Use 6-digit HSA codes as IDs. State borders are overlaid for context.
@@ -918,6 +953,7 @@ tech).
918
953
  | `state` | `string` | No | — |
919
954
  | `width` | `number` | No | — |
920
955
  | `height` | `number` | No | — |
956
+ | `tightFit` | `boolean \| number` | No | `false` |
921
957
  | `colorScale` | `ChoroplethColorScale \| ThresholdStop[] \| CategoricalStop[]` | No | — |
922
958
  | `title` | `string` | No | — |
923
959
  | `titleStyle` | `TitleStyle` | No | — |
@@ -142,6 +142,16 @@ const props = withDefaults(
142
142
  state?: string;
143
143
  width?: number;
144
144
  height?: number;
145
+ /**
146
+ * Tighten the national (multi-state) fit by cropping the Alaska/Hawaii
147
+ * overhang so the contiguous US fills more of the frame. `false`/`0`
148
+ * (default) fits every region into view; `true`/`1` fits the lower-48 to
149
+ * the frame and lets Alaska's western tail (and Hawaii) clip into the
150
+ * lower-left corner. A number in between (e.g. `0.5`) crops partway. No
151
+ * effect in single-state mode or on a national HSA map (HSA ids aren't
152
+ * FIPS, so AK/HI can't be split out).
153
+ */
154
+ tightFit?: boolean | number;
145
155
  colorScale?: ChoroplethColorScale | ThresholdStop[] | CategoricalStop[];
146
156
  /**
147
157
  * Map title. `\n` in the string creates additional lines, each
@@ -301,6 +311,7 @@ const props = withDefaults(
301
311
  tooltipClamp: "chart",
302
312
  focusZoomLevel: 4,
303
313
  focusZoom: true,
314
+ tightFit: false,
304
315
  },
305
316
  );
306
317
 
@@ -1357,6 +1368,31 @@ const stateBordersPath = computed(() => {
1357
1368
  // flush against the SVG edge. Only applied in single-state mode.
1358
1369
  const STATE_FIT_INSET = 12;
1359
1370
 
1371
+ // Resolved `tightFit` amount in [0,1]: false/0 → 0 (full fit), true → 1.
1372
+ const tightFitAmount = computed(() => {
1373
+ const v = props.tightFit;
1374
+ if (v === true) return 1;
1375
+ if (!v) return 0;
1376
+ return Math.max(0, Math.min(1, Number(v)));
1377
+ });
1378
+
1379
+ // The national fit target with Alaska (FIPS 02) and Hawaii (15) removed, so
1380
+ // the projection can be re-fit to just the contiguous US. Null when the crop
1381
+ // is off, in single-state mode, on an HSA map (ids aren't FIPS), or when
1382
+ // nothing was removed — the projection then uses the plain full fit.
1383
+ const conusFeaturesGeo = computed(() => {
1384
+ if (tightFitAmount.value <= 0 || stateFips.value) return null;
1385
+ if (props.geoType === "hsas") return null;
1386
+ const fc = featuresGeo.value;
1387
+ const pad = props.geoType === "counties" ? 5 : 2;
1388
+ const kept = fc.features.filter((f) => {
1389
+ const st = String(f.id).padStart(pad, "0").slice(0, 2);
1390
+ return st !== "02" && st !== "15";
1391
+ });
1392
+ if (kept.length === fc.features.length) return null;
1393
+ return { type: "FeatureCollection" as const, features: kept };
1394
+ });
1395
+
1360
1396
  const projection = computed(() => {
1361
1397
  const outline = stateOutlineFeature.value;
1362
1398
  if (stateFips.value && outline) {
@@ -1376,13 +1412,27 @@ const projection = computed(() => {
1376
1412
  if (Number.isFinite(c[0]) && Number.isFinite(c[1])) return albers;
1377
1413
  return geoMercator().fitExtent(extent, outline);
1378
1414
  }
1379
- return geoAlbersUsa().fitExtent(
1380
- [
1381
- [0, 0],
1382
- [width.value, height.value],
1383
- ],
1384
- featuresGeo.value,
1385
- );
1415
+ const extent: [[number, number], [number, number]] = [
1416
+ [0, 0],
1417
+ [width.value, height.value],
1418
+ ];
1419
+ const full = geoAlbersUsa().fitExtent(extent, featuresGeo.value);
1420
+ const conus = conusFeaturesGeo.value;
1421
+ if (!conus) return full;
1422
+ // Tighten the fit by interpolating the projection's scale + translate from
1423
+ // the full fit toward the contiguous-US fit. At amount 1 CONUS fills the
1424
+ // frame and Alaska's western tail (and Hawaii) clip into the lower-left
1425
+ // corner, cropped by the SVG/canvas viewport. Everything downstream (paths,
1426
+ // picking, tooltip anchors, focus zoom) derives from this projection, so
1427
+ // both renderers stay consistent.
1428
+ const tight = geoAlbersUsa().fitExtent(extent, conus);
1429
+ const t = tightFitAmount.value;
1430
+ const lerp = (a: number, b: number) => a + (b - a) * t;
1431
+ const [fx, fy] = full.translate();
1432
+ const [tx, ty] = tight.translate();
1433
+ return geoAlbersUsa()
1434
+ .scale(lerp(full.scale(), tight.scale()))
1435
+ .translate([lerp(fx, tx), lerp(fy, ty)]);
1386
1436
  });
1387
1437
 
1388
1438
  const pathGenerator = computed(() => geoPath(projection.value));
@@ -1623,7 +1673,8 @@ function titleText(name: string, value: number | string | undefined): string {
1623
1673
  // using lastTooltipSize (possibly stale by one frame) → visibility:visible
1624
1674
  // 2. tooltipObserver fires when the slot DOM has actually committed → we
1625
1675
  // refresh lastTooltipSize and re-apply the position if still visible.
1626
- // 3. mousemove → rAF-throttled direct DOM write of transform; no reactivity.
1676
+ // 3. mousemove → rAF-throttled; re-runs the same flip/clamp placement as
1677
+ // the initial hover (direct DOM write of transform, no reactivity).
1627
1678
  // 4. mouseout (leaving the map) → visibility:hidden.
1628
1679
  //
1629
1680
  // There is no `await` and no token: out-of-order completion is impossible
@@ -1723,11 +1774,14 @@ function moveTooltip(clientX: number, clientY: number) {
1723
1774
  if (pendingMoveFrame) return;
1724
1775
  pendingMoveFrame = requestAnimationFrame(() => {
1725
1776
  pendingMoveFrame = 0;
1726
- const el = tooltipChildRef.value?.getEl();
1727
- if (!el || !tooltipVisible) return;
1777
+ if (!tooltipVisible) return;
1728
1778
  lastPointer = { x: pendingMoveX, y: pendingMoveY };
1729
- // Mid-hover: don't re-run flip/clamp on every pixel; just translate.
1730
- el.style.transform = `translate3d(${pendingMoveX + 16}px, ${pendingMoveY}px, 0) translateY(-50%)`;
1779
+ // Re-run the same flip/clamp as the initial hover. This is already
1780
+ // rAF-coalesced (at most once per frame), so the flip/clamp cost is
1781
+ // negligible. Hardcoding the right side here instead made the tooltip
1782
+ // ignore the boundary while moving and fight showTooltip's per-feature
1783
+ // flip on dense maps (the tooltip appeared to switch sides).
1784
+ applyTooltipPosition(pendingMoveX, pendingMoveY);
1731
1785
  });
1732
1786
  }
1733
1787
 
@@ -2244,8 +2298,10 @@ function onTouchEnd(event: TouchEvent) {
2244
2298
  !fullscreen.isFullscreen.value
2245
2299
  ) {
2246
2300
  // Suppress the synthetic click/hover the browser would replay from
2247
- // this tap (and iOS's hover-first tap).
2248
- event.preventDefault();
2301
+ // this tap (and iOS's hover-first tap). Guard on cancelable: a
2302
+ // touchend fired while the page is mid-scroll is non-cancelable, and
2303
+ // preventDefault there is a no-op that only logs a console intervention.
2304
+ if (event.cancelable) event.preventDefault();
2249
2305
  const prev = lastTap;
2250
2306
  lastTap = { x: t.clientX, y: t.clientY, time: event.timeStamp };
2251
2307
  const isDoubleTap =
@@ -2287,8 +2343,10 @@ function onTouchEnd(event: TouchEvent) {
2287
2343
  const data = tooltipDataById.get(start.featId);
2288
2344
  if (!data) return;
2289
2345
  // Suppress the synthetic click/hover the browser would replay from this
2290
- // tap, so selection fires exactly once and never via iOS's hover-first tap.
2291
- event.preventDefault();
2346
+ // tap, so selection fires exactly once and never via iOS's hover-first
2347
+ // tap. Guard on cancelable — a touchend fired mid page-scroll is
2348
+ // non-cancelable, and preventDefault there only logs an intervention.
2349
+ if (event.cancelable) event.preventDefault();
2292
2350
  touchHover(data, t.clientX, t.clientY);
2293
2351
  emitSelection(data);
2294
2352
  }
@@ -39,7 +39,13 @@ export function placeTooltip(
39
39
  };
40
40
 
41
41
  const flip = clientX + GAP + tipW > bounds.right - PAD;
42
- const left = flip ? clientX - GAP - tipW : clientX + GAP;
42
+ const rawLeft = flip ? clientX - GAP - tipW : clientX + GAP;
43
+ // Clamp horizontally too: a left-flip near a narrow boundary's left edge
44
+ // (or a right placement in a chart narrower than the tooltip) must not
45
+ // overflow the far side. The upper bound is floored to the lower one so a
46
+ // tooltip wider than the bounds still pins to the left edge.
47
+ const maxLeft = Math.max(bounds.left + PAD, bounds.right - PAD - tipW);
48
+ const left = Math.min(Math.max(rawLeft, bounds.left + PAD), maxLeft);
43
49
  const halfH = tipH / 2;
44
50
  const top = Math.min(
45
51
  Math.max(centerY, bounds.top + PAD + halfH),
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.5",
2
+ "version": "0.7.6",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {