@cfasim-ui/docs 0.4.2 → 0.4.4

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.
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed } from "vue";
2
+ import { computed, ref } from "vue";
3
3
  import countiesTopoForPerf from "us-atlas/counties-10m.json";
4
4
 
5
5
  // Build one row per county (~3,143) with a deterministic-ish value so the
@@ -11,6 +11,11 @@ const denseCountyData = computed(() => {
11
11
  value: (i * 37) % 100,
12
12
  }));
13
13
  });
14
+
15
+ // Focus demo state — bound directly to v-model:focus. The component
16
+ // handles click-to-toggle and emits null when the focused feature is
17
+ // re-clicked.
18
+ const focused = ref(null);
14
19
  </script>
15
20
 
16
21
  # ChoroplethMap
@@ -337,6 +342,71 @@ Set `geoType="hsas"` to render Health Service Area boundaries. HSAs are dissolve
337
342
  </template>
338
343
  </ComponentDemo>
339
344
 
345
+ ### Click to focus (`v-model:focus`)
346
+
347
+ Bind the `focus` prop to pan and zoom to a specific feature. Pass a feature
348
+ id (FIPS code, HSA code, or name) — or an array of ids to focus on a region.
349
+ With `v-model:focus`, clicking an unfocused feature focuses it and clicking
350
+ the focused feature toggles back off. If a tooltip is configured, focusing
351
+ shows that feature's tooltip. Users can pan/zoom freely around the focused
352
+ area; the built-in **Reset** button clears focus and snaps back.
353
+
354
+ Counties are tiny without a zoom — focus is a natural fit for drill-down.
355
+
356
+ <ComponentDemo>
357
+ <ChoroplethMap
358
+ :topology="countiesTopo"
359
+ geo-type="counties"
360
+ v-model:focus="focused"
361
+ :focus-zoom-level="8"
362
+ :data="[
363
+ { id: '06037', value: 100 },
364
+ { id: '06073', value: 80 },
365
+ { id: '36061', value: 90 },
366
+ { id: '17031', value: 85 },
367
+ { id: '48201', value: 65 },
368
+ { id: '04013', value: 60 },
369
+ { id: '12086', value: 55 },
370
+ { id: '53033', value: 50 },
371
+ ]"
372
+ title="Click a county to focus"
373
+ :legend-title="'Cases'"
374
+ :height="400"
375
+ >
376
+ <template #tooltip="{ name, value }">
377
+ <div style="font-weight: 600">{{ name }}</div>
378
+ <div v-if="value != null">Cases: {{ value }}</div>
379
+ <div v-else style="opacity: 0.6">No data</div>
380
+ </template>
381
+ </ChoroplethMap>
382
+
383
+ <template #code>
384
+
385
+ ```vue
386
+ <script setup>
387
+ import { ref } from "vue";
388
+ const focused = ref(null);
389
+ </script>
390
+
391
+ <ChoroplethMap
392
+ :topology="countiesTopo"
393
+ geo-type="counties"
394
+ v-model:focus="focused"
395
+ :focus-zoom-level="8"
396
+ :data="data"
397
+ title="Click a county to focus"
398
+ >
399
+ <template #tooltip="{ name, value }">
400
+ <div style="font-weight: 600">{{ name }}</div>
401
+ <div v-if="value != null">Cases: {{ value }}</div>
402
+ <div v-else style="opacity: 0.6">No data</div>
403
+ </template>
404
+ </ChoroplethMap>
405
+ ```
406
+
407
+ </template>
408
+ </ComponentDemo>
409
+
340
410
  ### Custom tooltip number format
341
411
 
342
412
  Pass `tooltip-value-format` to format numeric values shown in the tooltip
@@ -377,13 +447,9 @@ Pass `tooltip-value-format` to format numeric values shown in the tooltip
377
447
  </template>
378
448
  </ComponentDemo>
379
449
 
380
- ### Dense county map (~3,143 features) for tooltip perf profiling
450
+ ### Dense county map
381
451
 
382
- Renders every US county with a value and a custom tooltip slot. Useful as a
383
- manual perf harness — open DevTools Performance, record a hover/sweep across
384
- many counties, and inspect tooltip update cost. The tooltip element is
385
- mounted once and patched in place; `mousemove` writes the position
386
- straight to the DOM.
452
+ Renders every US county with a value and a custom tooltip slot.
387
453
 
388
454
  <ComponentDemo>
389
455
  <ChoroplethMap
@@ -507,6 +573,8 @@ set `tooltip-trigger`.
507
573
  | `value` | `number \| string` | No | — |
508
574
  | `tooltipValueFormat` | `(value: number) =&gt; string` | No | — |
509
575
  | `tooltipClamp` | `"none" \| "chart" \| "window"` | No | `"chart"` |
576
+ | `focus` | `string \| string[] \| null` | No | — |
577
+ | `focusZoomLevel` | `number` | No | `4` |
510
578
 
511
579
 
512
580
  ### StateData
@@ -11,6 +11,9 @@ import {
11
11
  import { geoPath, geoAlbersUsa } from "d3-geo";
12
12
  import { zoom as d3Zoom, zoomIdentity } from "d3-zoom";
13
13
  import { select } from "d3-selection";
14
+ // Side-effect import: enables `selection.transition()` on d3 selections so
15
+ // `applyFocus` can animate the zoom transform.
16
+ import "d3-transition";
14
17
  import { feature, mesh, merge } from "topojson-client";
15
18
  import type { Topology, GeometryCollection } from "topojson-specification";
16
19
  import { fipsToHsa, hsaNames } from "./hsaMapping.js";
@@ -102,6 +105,18 @@ const props = withDefaults(
102
105
  * container's bounding box. `"window"` uses the viewport.
103
106
  */
104
107
  tooltipClamp?: "none" | "chart" | "window";
108
+ /**
109
+ * Feature id(s) (FIPS code, HSA code, or feature name) to pan/zoom to.
110
+ * Pass `null` or an empty array to clear. Works with `v-model:focus`:
111
+ * clicking an unfocused feature emits its id; clicking a focused
112
+ * feature emits `null` (toggle off). Users can pan/zoom away from the
113
+ * focused area even when `zoom` and `pan` are disabled, and the
114
+ * built-in Reset button also clears focus. If a tooltip is configured,
115
+ * focusing a feature shows its tooltip.
116
+ */
117
+ focus?: string | string[] | null;
118
+ /** Scale factor applied when `focus` is set. Default: 4 */
119
+ focusZoomLevel?: number;
105
120
  }>(),
106
121
  {
107
122
  geoType: "states",
@@ -113,6 +128,7 @@ const props = withDefaults(
113
128
  zoom: false,
114
129
  pan: false,
115
130
  tooltipClamp: "chart",
131
+ focusZoomLevel: 4,
116
132
  },
117
133
  );
118
134
 
@@ -125,6 +141,7 @@ const emit = defineEmits<{
125
141
  e: "stateHover",
126
142
  state: { id: string; name: string; value?: number | string } | null,
127
143
  ): void;
144
+ (e: "update:focus", focus: string | null): void;
128
145
  }>();
129
146
 
130
147
  type ChoroplethFeature = GeoJSON.Feature<
@@ -170,6 +187,10 @@ const pathsByFeatureId = new Map<string, SVGPathElement>();
170
187
  const tooltipDataById = new Map<string, TooltipPayload>();
171
188
  let bordersPathEl: SVGPathElement | null = null;
172
189
  let hoveredEl: SVGPathElement | null = null;
190
+ // Paths currently styled as focused. Tracked separately from hover so the
191
+ // two states compose: hovering a focused path keeps the highlight on
192
+ // un-hover, and clearing focus while still hovering keeps the hover style.
193
+ const focusedPathEls = new Set<SVGPathElement>();
173
194
  let isZooming = false;
174
195
  // TODO: map hover/tooltip causes performance issues on mobile (SVG stroke-width
175
196
  // changes + compositing layers degrade zoom/pan). Disabled on touch devices.
@@ -219,6 +240,7 @@ onMounted(() => {
219
240
  setupZoom();
220
241
  setupInteraction();
221
242
  rebuildPaths();
243
+ applyFocus();
222
244
  attachTooltipObserver();
223
245
  window.addEventListener("scroll", dismissOnViewportChange, {
224
246
  passive: true,
@@ -240,11 +262,14 @@ onUnmounted(() => {
240
262
 
241
263
  function setupZoom() {
242
264
  if (!svgRef.value || !mapGroupRef.value) return;
243
- if (!props.zoom && !props.pan) return;
244
265
 
245
266
  const svg = select(svgRef.value);
267
+ // Always span focusZoomLevel and at least the standard 12× ceiling so the
268
+ // user can wheel further in/out of a focused view. Programmatic
269
+ // `.transform()` calls are clamped to this range too.
270
+ const maxScale = Math.max(12, props.focusZoomLevel);
246
271
  zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
247
- .scaleExtent(props.zoom ? [1, 12] : [1, 1])
272
+ .scaleExtent([1, maxScale])
248
273
  .on("start", () => {
249
274
  isZooming = true;
250
275
  clearHover();
@@ -260,11 +285,25 @@ function setupZoom() {
260
285
  isZooming = false;
261
286
  });
262
287
 
263
- if (!props.pan) {
264
- zoomBehavior.filter(
265
- (event) => event.type === "wheel" || event.type === "dblclick",
266
- );
267
- }
288
+ // Dynamic filter: re-evaluated per event, so toggling `focus`,
289
+ // `zoom`, or `pan` doesn't require tearing down the zoom behavior.
290
+ // When focus is active we always allow drag + wheel so users can
291
+ // explore away from the focused area regardless of `zoom`/`pan`.
292
+ // Programmatic `.transform()` calls bypass this filter entirely.
293
+ zoomBehavior.filter((event) => {
294
+ const focused = normalizedFocus.value.length > 0;
295
+ const allowZoom = !!props.zoom || focused;
296
+ const allowPan = !!props.pan || focused;
297
+ if (event.type === "wheel" || event.type === "dblclick") {
298
+ if (!allowZoom) return false;
299
+ } else if (event.type === "mousedown" || event.type === "touchstart") {
300
+ if (!allowPan) return false;
301
+ } else if (!allowZoom && !allowPan) {
302
+ return false;
303
+ }
304
+ // Mirror d3-zoom's default rejections (ctrl-click, non-primary buttons).
305
+ return (!event.ctrlKey || event.type === "wheel") && !event.button;
306
+ });
268
307
 
269
308
  svg.call(zoomBehavior);
270
309
  }
@@ -276,20 +315,148 @@ function teardownZoom() {
276
315
  }
277
316
  }
278
317
 
318
+ // Resolve user-facing focus identifiers (FIPS, HSA codes, or feature names)
319
+ // to canonical feature ids. Used both for highlighting/zoom and for the
320
+ // click-to-toggle "is this feature currently focused?" check.
321
+ function resolveFocusIds(rawIds: string[]): Set<string> {
322
+ const byId = featuresById.value;
323
+ const nameIdx = nameToFeatureId.value;
324
+ const out = new Set<string>();
325
+ for (const raw of rawIds) {
326
+ const id = byId.has(raw) ? raw : nameIdx.get(raw);
327
+ if (id != null) out.add(id);
328
+ }
329
+ return out;
330
+ }
331
+
332
+ function resolveFocusFeatures(rawIds: string[]): ChoroplethFeature[] {
333
+ const byId = featuresById.value;
334
+ const out: ChoroplethFeature[] = [];
335
+ for (const id of resolveFocusIds(rawIds)) {
336
+ const f = byId.get(id);
337
+ if (f) out.push(f);
338
+ }
339
+ return out;
340
+ }
341
+
342
+ // Duration of the focus zoom transition (ms). Initial mount and explicit
343
+ // "clear focus" still snap instantly; only focus-prop changes animate.
344
+ const FOCUS_ANIM_MS = 450;
345
+ // Tracks whether applyFocus has been called once — initial mount apply
346
+ // is instant, subsequent updates animate.
347
+ let focusApplied = false;
348
+
349
+ function applyFocus() {
350
+ if (!svgRef.value || !zoomBehavior) return;
351
+ const ids = normalizedFocus.value;
352
+ const features = ids.length > 0 ? resolveFocusFeatures(ids) : [];
353
+
354
+ // Compute the new highlight set first so we can diff against the
355
+ // previous one without re-resolving twice.
356
+ const nextFocused = new Set<SVGPathElement>();
357
+ for (const f of features) {
358
+ const p = pathsByFeatureId.get(String(f.id));
359
+ if (p) nextFocused.add(p);
360
+ }
361
+
362
+ // Restore strokes on paths that are no longer focused. Skip those still
363
+ // hovered — hover keeps its own highlight.
364
+ for (const p of focusedPathEls) {
365
+ if (nextFocused.has(p) || p === hoveredEl) continue;
366
+ restoreDefaultStroke(p);
367
+ }
368
+ // Apply highlight to newly-focused paths (skip those already hovered:
369
+ // hover style is visually identical, no DOM churn needed).
370
+ for (const p of nextFocused) {
371
+ if (!focusedPathEls.has(p) && p !== hoveredEl) applyHighlightStroke(p);
372
+ }
373
+ focusedPathEls.clear();
374
+ for (const p of nextFocused) focusedPathEls.add(p);
375
+
376
+ const svg = select(svgRef.value);
377
+ // Always cancel any in-flight transition first — d3-transition queues
378
+ // same-named transitions rather than replacing them, so rapid focus
379
+ // changes would otherwise chain animations end-to-end. Also lets a
380
+ // straight-snap path actually take effect mid-animation.
381
+ svg.interrupt();
382
+ // First apply (initial mount) is instant. Only zoom-IN animates;
383
+ // clearing snaps back. Matches resetZoom's instant feel.
384
+ const animate = focusApplied && features.length > 0;
385
+ focusApplied = true;
386
+
387
+ if (features.length === 0) {
388
+ zoomBehavior.transform(svg, zoomIdentity);
389
+ clearHover();
390
+ return;
391
+ }
392
+
393
+ // Compute pan + scale onto the focused features' bounding box, in
394
+ // viewBox (canonical) coordinates.
395
+ const [[x0, y0], [x1, y1]] = pathGenerator.value.bounds({
396
+ type: "FeatureCollection",
397
+ features,
398
+ });
399
+ const cx = (x0 + x1) / 2;
400
+ const cy = (y0 + y1) / 2;
401
+ const k = props.focusZoomLevel;
402
+ const target = zoomIdentity
403
+ .translate(width.value / 2 - k * cx, height.value / 2 - k * cy)
404
+ .scale(k);
405
+
406
+ const showFocusTooltip = () => {
407
+ if (!hasInteractiveTooltip.value) return;
408
+ const firstId = String(features[0].id);
409
+ const pathEl = pathsByFeatureId.get(firstId);
410
+ if (!pathEl) return;
411
+ // Read the rect *after* the transform commits so the tooltip lands at
412
+ // the focused feature's on-screen position.
413
+ const rect = pathEl.getBoundingClientRect();
414
+ showTooltip(
415
+ firstId,
416
+ rect.left + rect.width / 2,
417
+ rect.top + rect.height / 2,
418
+ );
419
+ };
420
+
421
+ if (animate) {
422
+ // d3-zoom + d3-transition: `transition.call(zoomBehavior.transform,
423
+ // target)` interpolates the transform smoothly, firing the zoom
424
+ // callback per frame so pan + scale animate together. Hide any prior
425
+ // tooltip up front so it doesn't track the moving viewport; re-show
426
+ // once the new target is reached.
427
+ hideTooltip();
428
+ svg
429
+ .transition()
430
+ .duration(FOCUS_ANIM_MS)
431
+ .call(zoomBehavior.transform, target)
432
+ .on("end", showFocusTooltip);
433
+ } else {
434
+ zoomBehavior.transform(svg, target);
435
+ showFocusTooltip();
436
+ }
437
+ }
438
+
279
439
  function resetZoom() {
280
440
  if (!svgRef.value || !zoomBehavior) return;
281
- // Snap straight back to identity; the zoom callback fires and clears
282
- // isZoomed, which hides the button.
283
- zoomBehavior.transform(select(svgRef.value), zoomIdentity);
441
+ const svg = select(svgRef.value);
442
+ // Cancel any in-flight focus animation before snapping so the transition
443
+ // can't keep writing transforms after we set identity.
444
+ svg.interrupt();
445
+ zoomBehavior.transform(svg, zoomIdentity);
446
+ // Keep v-model:focus in sync when the user resets a focused view.
447
+ if (normalizedFocus.value.length > 0) emit("update:focus", null);
284
448
  }
285
449
 
450
+ // `focusZoomLevel` only affects scaleExtent + the next focus apply. The
451
+ // d3-zoom filter reads `props.zoom` / `props.pan` dynamically, so we don't
452
+ // need to tear down zoom on those changes.
286
453
  watch(
287
- () => [props.zoom, props.pan],
454
+ () => props.focusZoomLevel,
288
455
  () => {
289
- teardownZoom();
290
- teardownInteraction();
291
- setupZoom();
292
- setupInteraction();
456
+ if (zoomBehavior) {
457
+ zoomBehavior.scaleExtent([1, Math.max(12, props.focusZoomLevel)]);
458
+ }
459
+ applyFocus();
293
460
  },
294
461
  );
295
462
 
@@ -390,6 +557,26 @@ const nameToFeatureId = computed(() => {
390
557
  return m;
391
558
  });
392
559
 
560
+ // id → feature lookup used by `applyFocus`. Cached so focus changes don't
561
+ // trigger a linear scan through 3k+ features per apply.
562
+ const featuresById = computed(() => {
563
+ const m = new Map<string, ChoroplethFeature>();
564
+ for (const f of featuresGeo.value.features) {
565
+ if (f.id != null) m.set(String(f.id), f as ChoroplethFeature);
566
+ }
567
+ return m;
568
+ });
569
+
570
+ // Stable, deduped array form of `props.focus`. Drives the focus watcher;
571
+ // scalar `string` and `string[]` collapse to the same shape so the watcher
572
+ // only fires on a real change.
573
+ const normalizedFocus = computed<string[]>(() => {
574
+ const f = props.focus;
575
+ if (f == null) return [];
576
+ if (Array.isArray(f)) return f;
577
+ return [f];
578
+ });
579
+
393
580
  const dataMap = computed(() => {
394
581
  const map = new Map<string, number | string>();
395
582
  if (!props.data) return map;
@@ -602,23 +789,32 @@ function hideTooltip() {
602
789
  if (el) el.style.visibility = "hidden";
603
790
  }
604
791
 
792
+ function applyHighlightStroke(pathEl: SVGPathElement) {
793
+ // Bring path to top so its thicker border isn't clipped by neighbors.
794
+ pathEl.parentNode?.appendChild(pathEl);
795
+ pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value + 1));
796
+ pathEl.setAttribute("stroke", "#555");
797
+ }
798
+
799
+ function restoreDefaultStroke(pathEl: SVGPathElement) {
800
+ pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
801
+ pathEl.setAttribute("stroke", props.strokeColor);
802
+ }
803
+
605
804
  function setHover(pathEl: SVGPathElement) {
606
805
  if (hoveredEl === pathEl) return;
607
- if (hoveredEl) {
608
- hoveredEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
609
- hoveredEl.setAttribute("stroke", props.strokeColor);
806
+ if (hoveredEl && !focusedPathEls.has(hoveredEl)) {
807
+ // Restore previous hover unless it's also focused — focus keeps the
808
+ // highlight on its own.
809
+ restoreDefaultStroke(hoveredEl);
610
810
  }
611
811
  hoveredEl = pathEl;
612
- // Bring hovered path to top so its thicker border is not clipped by neighbors.
613
- pathEl.parentNode?.appendChild(pathEl);
614
- pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value + 1));
615
- pathEl.setAttribute("stroke", "#555");
812
+ applyHighlightStroke(pathEl);
616
813
  }
617
814
 
618
815
  function clearHover() {
619
816
  if (hoveredEl) {
620
- hoveredEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
621
- hoveredEl.setAttribute("stroke", props.strokeColor);
817
+ if (!focusedPathEls.has(hoveredEl)) restoreDefaultStroke(hoveredEl);
622
818
  hoveredEl = null;
623
819
  emit("stateHover", null);
624
820
  }
@@ -643,6 +839,13 @@ function onDelegatedEvent(event: Event) {
643
839
  const payload = { id: data.id, name: data.name, value: data.value };
644
840
  if (event.type === "click") {
645
841
  emit("stateClick", payload);
842
+ // Click-to-focus toggle, baked in so `v-model:focus="ref"` Just Works:
843
+ // clicking the currently focused feature clears focus (emits null);
844
+ // clicking any other feature emits its id. With a `focus` array, any
845
+ // click on a member clears everything — parents wanting fine-grained
846
+ // multi-select handle merging themselves via `@update:focus`.
847
+ const wasFocused = resolveFocusIds(normalizedFocus.value).has(data.id);
848
+ emit("update:focus", wasFocused ? null : data.id);
646
849
  } else if (event.type === "mouseover") {
647
850
  setHover(pathsByFeatureId.get(featId)!);
648
851
  if (hasInteractiveTooltip.value)
@@ -682,6 +885,9 @@ function rebuildPaths() {
682
885
  tooltipDataById.clear();
683
886
  bordersPathEl = null;
684
887
  hoveredEl = null;
888
+ // Old focused paths are about to be detached — drop refs so applyFocus
889
+ // can re-resolve against the new path tree.
890
+ focusedPathEls.clear();
685
891
 
686
892
  const path = pathGenerator.value;
687
893
  const features = featuresGeo.value.features;
@@ -755,14 +961,12 @@ function updateFills() {
755
961
  }
756
962
 
757
963
  function updateStrokes() {
758
- const stroke = props.strokeColor;
759
- const sw = String(effectiveStrokeWidth.value);
760
964
  for (const p of pathsByFeatureId.values()) {
761
- if (p === hoveredEl) continue;
762
- p.setAttribute("stroke", stroke);
763
- p.setAttribute("stroke-width", sw);
965
+ // Highlighted paths (hover / focus) keep their #555 + thicker stroke.
966
+ if (p === hoveredEl || focusedPathEls.has(p)) continue;
967
+ restoreDefaultStroke(p);
764
968
  }
765
- if (bordersPathEl) bordersPathEl.setAttribute("stroke", stroke);
969
+ if (bordersPathEl) bordersPathEl.setAttribute("stroke", props.strokeColor);
766
970
  }
767
971
 
768
972
  function menuFilename() {
@@ -883,6 +1087,17 @@ watch(
883
1087
  () => [props.strokeColor, effectiveStrokeWidth.value],
884
1088
  () => updateStrokes(),
885
1089
  );
1090
+
1091
+ // Focus or projection changed → re-apply the focus transform imperatively.
1092
+ // `flush: "post"` so any pending path rebuild from the watcher above has
1093
+ // already run; we still use the GeoJSON pathGenerator directly so the SVG
1094
+ // path tree isn't actually required, but keeping the order avoids stacking
1095
+ // two zoom transforms in the same tick.
1096
+ watch(
1097
+ () => [normalizedFocus.value, pathGenerator.value],
1098
+ () => applyFocus(),
1099
+ { flush: "post" },
1100
+ );
886
1101
  </script>
887
1102
 
888
1103
  <template>
@@ -943,7 +1158,7 @@ watch(
943
1158
  <g ref="mapGroupRef" />
944
1159
  </svg>
945
1160
  <button
946
- v-if="(zoom || pan) && isZoomed"
1161
+ v-if="isZoomed"
947
1162
  type="button"
948
1163
  class="choropleth-reset"
949
1164
  aria-label="Reset zoom"
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.2",
2
+ "version": "0.4.4",
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.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,5 +1,5 @@
1
1
  import { execSync } from "node:child_process";
2
- import { readdirSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { readdirSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
 
5
5
  /**
@@ -38,6 +38,10 @@ export function cfasimPyodide(options) {
38
38
  function build(root) {
39
39
  const publicDir = resolve(root, "public");
40
40
  mkdirSync(publicDir, { recursive: true });
41
+ // Clear stale wheels so wheels.json only lists this build's output.
42
+ for (const f of readdirSync(publicDir)) {
43
+ if (f.endsWith(".whl")) rmSync(resolve(publicDir, f));
44
+ }
41
45
  for (const dep of options?.pypiDeps ?? []) {
42
46
  execSync(
43
47
  `${pipCommand} download ${dep} --dest public --no-deps --python-version ${pythonVersion} --platform any`,