@buley/hexgrid-3d 3.5.0 → 3.5.1

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 +1 @@
1
- {"version":3,"file":"HexGrid.d.ts","sourceRoot":"","sources":["../../src/components/HexGrid.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAA;AAYhF,OAAO,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,UAAU,CAAA;AAI/C,YAAY,EAAE,KAAK,EAAE,CAAA;AAWrB,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,OAAO;IAEvC,KAAK,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAA;IACrB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAA;IAGhB,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,IAAI,CAAA;IACzC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;IAEnC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAA;IAC9C,mBAAmB,CAAC,EAAE,CAAC,WAAW,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,KAAK,IAAI,CAAA;IAC5G,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,0BAA0B,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACpD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,KAAK,CAAA;IACZ,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAC1C,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;CACf;AAoLD,eAAO,MAAM,OAAO,GAAI,CAAC,GAAG,OAAO,EAAE,iMAalC,YAAY,CAAC,CAAC,CAAC,4CAwtIjB,CAAA;AAk7DD,eAAe,OAAO,CAAC"}
1
+ {"version":3,"file":"HexGrid.d.ts","sourceRoot":"","sources":["../../src/components/HexGrid.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAA;AAYhF,OAAO,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,UAAU,CAAA;AAI/C,YAAY,EAAE,KAAK,EAAE,CAAA;AAWrB,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,OAAO;IAEvC,KAAK,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAA;IACrB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAA;IAGhB,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,IAAI,CAAA;IACzC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;IAEnC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAA;IAC9C,mBAAmB,CAAC,EAAE,CAAC,WAAW,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,KAAK,IAAI,CAAA;IAC5G,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,0BAA0B,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACpD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,KAAK,CAAA;IACZ,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAC1C,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;CACf;AAoLD,eAAO,MAAM,OAAO,GAAI,CAAC,GAAG,OAAO,EAAE,iMAalC,YAAY,CAAC,CAAC,CAAC,4CA4xIjB,CAAA;AAk7DD,eAAe,OAAO,CAAC"}
@@ -152,6 +152,7 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
152
152
  // Stream control refs to coordinate long-running UI streaming from worker
153
153
  const streamTokenRef = useRef(0);
154
154
  const streamActiveRef = useRef(false);
155
+ const streamTouchesOccupancyRef = useRef(false);
155
156
  // Reactive state for telemetry overlay (mirrors streamActiveRef)
156
157
  const [streamingActive, setStreamingActive] = useState(false);
157
158
  // How many tiles remain to stream in the current streaming run
@@ -1253,6 +1254,8 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
1253
1254
  dlog('HexGrid: Grid dimensions changed, reinitializing infections', { old: prevGridKeyRef.current, new: gridKey });
1254
1255
  const isSpherical = gridMetadataRef.current?.isSpherical ?? false;
1255
1256
  const initState = initializeInfectionSystem(hexPositions, photos, effectiveHexRadius, workerDebugRef.current?.spawnClusterMax ?? 8, logger, isSpherical);
1257
+ blankNeighborCountGenerationRef.current = -1;
1258
+ blankNeighborCountRef.current = new Map();
1256
1259
  setInfectionState(initState);
1257
1260
  infectionStateRef.current = initState;
1258
1261
  // Post to worker immediately (use sendEvolve helper so generation is logged)
@@ -1857,6 +1860,16 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
1857
1860
  const tileAlphaRef = useRef(new Map());
1858
1861
  // Per-tile arrival pulse timestamps (ms) to show a brief highlight when a tile appears/updates
1859
1862
  const tilePulseRef = useRef(new Map());
1863
+ // Worker-precomputed neighbor blank counts (index -> blank-neighbor count)
1864
+ const blankNeighborCountRef = useRef(new Map());
1865
+ const blankNeighborCountGenerationRef = useRef(-1);
1866
+ // Fallback cache for local blank-neighbor computation when worker data is unavailable/mismatched
1867
+ const localBlankNeighborCacheRef = useRef({
1868
+ infectionMap: null,
1869
+ positions: null,
1870
+ hexRadius: -1,
1871
+ counts: new Map()
1872
+ });
1860
1873
  // Runtime telemetry: frame timing buffer for FPS/ms overlay
1861
1874
  const frameTimesRef = useRef([]);
1862
1875
  const lastFrameTimeRef = useRef(performance.now());
@@ -2064,8 +2077,9 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2064
2077
  const lastDrawTimeRef = useRef(0);
2065
2078
  const lastDrawGenRef = useRef(0);
2066
2079
  const cameraDirtyRef = useRef(true);
2067
- // Minimum ms between draws when idle (30fps)
2068
- const minFrameMs = 1000 / 30;
2080
+ // Draw cadence: 30fps when actively animating, lower cadence while static.
2081
+ const activeFrameMs = 1000 / 30;
2082
+ const idleFrameMs = 250;
2069
2083
  // Helper to get effective batchPerFrame (transient override if present)
2070
2084
  const getEffectiveBatch = () => {
2071
2085
  const base = Math.max(0, Math.floor(workerDebugRef.current?.batchPerFrame ?? 0));
@@ -2249,6 +2263,8 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2249
2263
  lastEvolutionTime: infectionStateRef.current.lastEvolutionTime,
2250
2264
  generation: infectionStateRef.current.generation
2251
2265
  };
2266
+ blankNeighborCountGenerationRef.current = -1;
2267
+ blankNeighborCountRef.current = new Map();
2252
2268
  setInfectionState(filteredState);
2253
2269
  infectionStateRef.current = filteredState;
2254
2270
  if (workerRef.current) {
@@ -2276,6 +2292,8 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2276
2292
  // Use the live spawnClusterMax from workerDebug when available so photo reloads honor debug tuning
2277
2293
  const isSpherical = gridMetadataRef.current?.isSpherical ?? false;
2278
2294
  const initState = initializeInfectionSystem(hexPositions, photos, effectiveHexRadius, workerDebugRef.current?.spawnClusterMax ?? 8, logger, isSpherical);
2295
+ blankNeighborCountGenerationRef.current = -1;
2296
+ blankNeighborCountRef.current = new Map();
2279
2297
  setInfectionState(initState);
2280
2298
  infectionStateRef.current = initState;
2281
2299
  if (workerRef.current) {
@@ -2407,6 +2425,20 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2407
2425
  dlog('Processing evolved state with', data.infections.length, 'gossip entries');
2408
2426
  // Convert back from array to Map
2409
2427
  const newInfectionsMap = new Map(data.infections);
2428
+ if (Array.isArray(data.blankNeighborCounts)) {
2429
+ try {
2430
+ blankNeighborCountRef.current = new Map(data.blankNeighborCounts);
2431
+ blankNeighborCountGenerationRef.current = typeof data.generation === 'number' ? data.generation : -1;
2432
+ }
2433
+ catch (err) {
2434
+ // ignore malformed worker payload
2435
+ }
2436
+ }
2437
+ else {
2438
+ // Fallback to local computation when worker payload doesn't include counts.
2439
+ blankNeighborCountGenerationRef.current = -1;
2440
+ blankNeighborCountRef.current = new Map();
2441
+ }
2410
2442
  // Capture tile centers for debug visualization if available
2411
2443
  if (data.tileCenters && Array.isArray(data.tileCenters)) {
2412
2444
  try {
@@ -2476,6 +2508,7 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2476
2508
  });
2477
2509
  // If no changes, just replace state
2478
2510
  if (changes.length === 0) {
2511
+ streamTouchesOccupancyRef.current = false;
2479
2512
  setInfectionState({
2480
2513
  infections: newInfectionsMap,
2481
2514
  availableIndices: data.availableIndices,
@@ -2493,6 +2526,7 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2493
2526
  // Start streaming changes; cancel any previous streamer.
2494
2527
  streamTokenRef.current += 1;
2495
2528
  const token = streamTokenRef.current;
2529
+ streamTouchesOccupancyRef.current = changes.some((ch) => ch.type !== 'update');
2496
2530
  // Mark streaming active so other code (animation loop) can avoid posting new evolves
2497
2531
  streamActiveRef.current = true;
2498
2532
  try {
@@ -2519,6 +2553,7 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2519
2553
  // stop if cancelled
2520
2554
  if (streamTokenRef.current !== token) {
2521
2555
  streamActiveRef.current = false;
2556
+ streamTouchesOccupancyRef.current = false;
2522
2557
  try {
2523
2558
  setStreamingActive(false);
2524
2559
  }
@@ -2566,6 +2601,7 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2566
2601
  }
2567
2602
  else {
2568
2603
  streamActiveRef.current = false;
2604
+ streamTouchesOccupancyRef.current = false;
2569
2605
  try {
2570
2606
  setStreamingActive(false);
2571
2607
  }
@@ -2597,6 +2633,10 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2597
2633
  streamActiveRef.current = false;
2598
2634
  }
2599
2635
  catch (e) { }
2636
+ try {
2637
+ streamTouchesOccupancyRef.current = false;
2638
+ }
2639
+ catch (e) { }
2600
2640
  try {
2601
2641
  setStreamingActive(false);
2602
2642
  }
@@ -2656,6 +2696,7 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2656
2696
  }
2657
2697
  finally {
2658
2698
  streamActiveRef.current = false;
2699
+ streamTouchesOccupancyRef.current = false;
2659
2700
  try {
2660
2701
  setStreamingActive(false);
2661
2702
  }
@@ -2686,25 +2727,54 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2686
2727
  // Clear canvas
2687
2728
  ctx.fillStyle = '#001122';
2688
2729
  ctx.fillRect(0, 0, screenWidth, screenHeight);
2689
- // Precompute which infected hexagons are adjacent to a blank (uninfected) hex
2690
- const infectedIndices = Array.from(infectionState.infections.keys());
2691
- const infectedSet = new Set(infectedIndices);
2692
- const blankNeighborCount = new Map();
2693
- for (const idx of infectedIndices) {
2694
- // Guard: ensure the index is valid before processing
2695
- if (idx < 0 || idx >= hexPositions.length)
2696
- continue;
2697
- const isSpherical = gridMetadataRef.current?.isSpherical ?? false;
2698
- const neighbors = getNeighbors(idx, hexPositions, drawnHexRadius, isSpherical);
2699
- let count = 0;
2700
- for (const n of neighbors) {
2701
- if (!infectedSet.has(n))
2702
- count++;
2730
+ const infections = infectionState.infections;
2731
+ const dbg = workerDebugRef.current;
2732
+ const projectedPositions = projectedPositionsRef.current;
2733
+ const pulseMap = tilePulseRef.current;
2734
+ const prevAlphaMap = tileAlphaRef.current;
2735
+ const smooth = Math.max(0, Math.min(1, dbg?.translucencySmoothing ?? 0.08));
2736
+ const scratchEnabled = !!dbg?.scratchEnabled;
2737
+ const sheenIntensity = dbg?.sheenIntensity ?? 0.12;
2738
+ const sheenEnabled = !!dbg?.sheenEnabled;
2739
+ const seamInset = dbg?.clusterUvInset ?? 0.0;
2740
+ const isSpherical = gridMetadataRef.current?.isSpherical ?? false;
2741
+ const computeLocalBlankNeighborCounts = () => {
2742
+ const cache = localBlankNeighborCacheRef.current;
2743
+ if (cache.infectionMap === infections &&
2744
+ cache.positions === hexPositions &&
2745
+ cache.hexRadius === drawnHexRadius) {
2746
+ return cache.counts;
2747
+ }
2748
+ const infectedIndices = Array.from(infections.keys());
2749
+ const infectedSet = new Set(infectedIndices);
2750
+ const computed = new Map();
2751
+ for (const idx of infectedIndices) {
2752
+ if (idx < 0 || idx >= hexPositions.length)
2753
+ continue;
2754
+ const neighbors = getNeighbors(idx, hexPositions, drawnHexRadius, isSpherical);
2755
+ let count = 0;
2756
+ for (const n of neighbors) {
2757
+ if (!infectedSet.has(n))
2758
+ count++;
2759
+ }
2760
+ computed.set(idx, count);
2703
2761
  }
2704
- blankNeighborCount.set(idx, count);
2705
- }
2762
+ localBlankNeighborCacheRef.current = {
2763
+ infectionMap: infections,
2764
+ positions: hexPositions,
2765
+ hexRadius: drawnHexRadius,
2766
+ counts: computed
2767
+ };
2768
+ return computed;
2769
+ };
2770
+ const canUseWorkerBlankCounts = blankNeighborCountRef.current.size > 0 &&
2771
+ blankNeighborCountGenerationRef.current === infectionState.generation &&
2772
+ !(streamActiveRef.current && streamTouchesOccupancyRef.current);
2773
+ const blankNeighborCount = canUseWorkerBlankCounts
2774
+ ? blankNeighborCountRef.current
2775
+ : computeLocalBlankNeighborCounts();
2706
2776
  // compute sheen progress using configured speed
2707
- const sheenSpeed = Math.max(0.1, workerDebugRef.current?.sheenSpeed || 10);
2777
+ const sheenSpeed = Math.max(0.1, dbg?.sheenSpeed || 10);
2708
2778
  const now = performance.now();
2709
2779
  const sheenProgress = ((now / 1000) % sheenSpeed) / sheenSpeed;
2710
2780
  // Draw hexagons (with alpha smoothing)
@@ -2742,39 +2812,30 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2742
2812
  }
2743
2813
  for (let index = 0; index < hexPositions.length; index++) {
2744
2814
  const position = hexPositions[index];
2745
- // Validate index bounds first
2746
- if (index < 0 || index >= hexPositions.length)
2747
- continue;
2748
- const infection = infectionState.infections.get(index);
2749
- // CRITICAL GUARD: Skip drawing if this is an uninfected cell with no valid position data
2750
- // This prevents "ghost" hexagons from appearing in the background
2751
- if (!infection && !position)
2815
+ if (!position)
2752
2816
  continue;
2817
+ const infection = infections.get(index);
2753
2818
  const blankCount = infection ? (blankNeighborCount.get(index) || 0) : 0;
2754
2819
  // target alpha based on blank count
2755
2820
  const targetAlpha = infection ? (1.0 - Math.min(blankCount, 6) * 0.066) : 1.0;
2756
- const prevAlphaMap = tileAlphaRef.current;
2757
2821
  const prev = prevAlphaMap.get(index) ?? targetAlpha;
2758
- const smooth = Math.max(0, Math.min(1, workerDebugRef.current?.translucencySmoothing ?? 0.08));
2759
2822
  const smoothed = prev + (targetAlpha - prev) * smooth;
2760
2823
  prevAlphaMap.set(index, smoothed);
2761
- const scratchEnabled = !!workerDebugRef.current?.scratchEnabled;
2762
- const sheenIntensity = workerDebugRef.current?.sheenIntensity ?? 0.12;
2763
2824
  // Compute pulse progress for this tile
2764
- const pulseInfo = tilePulseRef.current.get(index);
2825
+ const pulseInfo = pulseMap.get(index);
2765
2826
  let pulseProgress = 0;
2766
2827
  if (pulseInfo) {
2767
- const elapsed = performance.now() - pulseInfo.start;
2828
+ const elapsed = now - pulseInfo.start;
2768
2829
  pulseProgress = Math.max(0, Math.min(1, elapsed / pulseInfo.duration));
2769
2830
  if (pulseProgress >= 1)
2770
- tilePulseRef.current.delete(index);
2831
+ pulseMap.delete(index);
2771
2832
  }
2772
2833
  // Use projected position and scale for drawing
2773
- const proj = projectedPositionsRef.current[index] || [position[0], position[1], 1, 0, 0];
2774
- const projX = proj[0];
2775
- const projY = proj[1];
2776
- const projScale = proj[2];
2777
- const projAngle = proj[3] || 0;
2834
+ const proj = projectedPositions[index];
2835
+ const projX = proj ? proj[0] : position[0];
2836
+ const projY = proj ? proj[1] : position[1];
2837
+ const projScale = proj ? proj[2] : 1;
2838
+ const projAngle = proj ? (proj[3] || 0) : 0;
2778
2839
  // proj[4] is z-depth (not needed for drawing, only for click detection)
2779
2840
  // Selective culling: when low-res mode is active, always draw infected tiles but
2780
2841
  // sample uninfected/background tiles to reduce draw count while preserving layout.
@@ -2789,17 +2850,15 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2789
2850
  }
2790
2851
  }
2791
2852
  // Draw primary - use drawnHexRadius to account for spacing
2792
- const sheenEnabled = !!workerDebugRef.current?.sheenEnabled;
2793
- drawHexagon(ctx, [projX, projY, 0], drawnHexRadius * projScale, infection, textures, index, blankCount, smoothed, sheenProgress, sheenIntensity, sheenEnabled, scratchEnabled, scratchCanvasRef.current, workerDebugRef.current?.clusterUvInset ?? 0.0, pulseProgress, false, false, projAngle);
2853
+ drawHexagon(ctx, [projX, projY, 0], drawnHexRadius * projScale, infection, textures, index, blankCount, smoothed, sheenProgress, sheenIntensity, sheenEnabled, scratchEnabled, scratchCanvasRef.current, seamInset, pulseProgress, false, false, projAngle);
2794
2854
  // Optionally draw antipodal copy (opposite side of sphere).
2795
2855
  // Use the same projection helper as hit-tests so angles/positions match exactly.
2796
- if (workerDebug.renderBothSides) {
2856
+ if (dbg?.renderBothSides) {
2797
2857
  try {
2798
2858
  const anti = mapAndProject(hexPositions[index], true);
2799
2859
  const antiScale = anti.scale || 1;
2800
2860
  const antiAngle = anti.angle || 0;
2801
- const sheenEnabledAnti = !!workerDebugRef.current?.sheenEnabled;
2802
- drawHexagon(ctx, [anti.x, anti.y, 0], drawnHexRadius * antiScale, infection, textures, index, blankCount, smoothed, sheenProgress, sheenIntensity, sheenEnabledAnti, scratchEnabled, scratchCanvasRef.current, workerDebugRef.current?.clusterUvInset ?? 0.0, pulseProgress, false, true, antiAngle);
2861
+ drawHexagon(ctx, [anti.x, anti.y, 0], drawnHexRadius * antiScale, infection, textures, index, blankCount, smoothed, sheenProgress, sheenIntensity, sheenEnabled, scratchEnabled, scratchCanvasRef.current, seamInset, pulseProgress, false, true, antiAngle);
2803
2862
  }
2804
2863
  catch (err) {
2805
2864
  // Skip drawing antipodal hex if projection fails
@@ -2815,10 +2874,11 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2815
2874
  try {
2816
2875
  if (workerDebugRef.current?.debugLogs) {
2817
2876
  // Sample a few random infected hexes to check
2877
+ const infectedIndices = Array.from(infections.keys());
2818
2878
  const sampleSize = Math.min(5, infectedIndices.length);
2819
2879
  for (let s = 0; s < sampleSize; s++) {
2820
2880
  const idx = infectedIndices[Math.floor(Math.random() * infectedIndices.length)];
2821
- const infection = infectionState.infections.get(idx);
2881
+ const infection = infections.get(idx);
2822
2882
  if (!infection)
2823
2883
  continue;
2824
2884
  const texture = textures.get(infection.photo.id);
@@ -2828,7 +2888,7 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
2828
2888
  const isSpherical = gridMetadataRef.current?.isSpherical ?? false;
2829
2889
  const neighbors = getNeighbors(idx, hexPositions, drawnHexRadius, isSpherical);
2830
2890
  for (const nIdx of neighbors) {
2831
- const nInf = infectionState.infections.get(nIdx);
2891
+ const nInf = infections.get(nIdx);
2832
2892
  // Only check neighbors with the same photo (adjacent tiles of same image)
2833
2893
  if (!nInf || nInf.photo.id !== infection.photo.id)
2834
2894
  continue;
@@ -3015,7 +3075,9 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
3015
3075
  // If camera changed recently or infections changed (generation advanced), draw immediately
3016
3076
  const gen = infectionState.generation;
3017
3077
  const genChanged = gen !== lastDrawGenRef.current;
3018
- if (cameraDirtyRef.current || genChanged || timeSinceLast >= minFrameMs) {
3078
+ const hasVisualAnimation = !!workerDebugRef.current?.sheenEnabled || tilePulseRef.current.size > 0 || streamActiveRef.current;
3079
+ const targetFrameMs = hasVisualAnimation ? activeFrameMs : idleFrameMs;
3080
+ if (cameraDirtyRef.current || genChanged || timeSinceLast >= targetFrameMs) {
3019
3081
  draw();
3020
3082
  lastDrawTimeRef.current = now;
3021
3083
  lastDrawGenRef.current = gen;
@@ -3112,6 +3174,8 @@ export const HexGrid = ({ items, photos: photosProp, onItemClick, onHexClick, sp
3112
3174
  logger.error('spawnClusterAt: Invalid center index', centerIndex);
3113
3175
  return;
3114
3176
  }
3177
+ blankNeighborCountGenerationRef.current = -1;
3178
+ blankNeighborCountRef.current = new Map();
3115
3179
  setInfectionState(prevState => {
3116
3180
  const newInfections = new Map(prevState.infections);
3117
3181
  const newAvailableIndices = [...prevState.availableIndices];
@@ -153,6 +153,24 @@ function getNeighborsCached(index, positions, hexRadius) {
153
153
  function calculateUvBoundsFromGridPosition(gridCol, gridRow, tilesX, tilesY) {
154
154
  return _calculateUvBoundsFromGridPosition(gridCol, gridRow, tilesX, tilesY);
155
155
  }
156
+ function buildBlankNeighborCounts(infections, positions, hexRadius) {
157
+ if (!infections || infections.size === 0)
158
+ return [];
159
+ const infectedSet = new Set(infections.keys());
160
+ const out = [];
161
+ for (const idx of infectedSet) {
162
+ if (idx < 0 || idx >= positions.length)
163
+ continue;
164
+ const neighbors = getNeighborsCached(idx, positions, hexRadius);
165
+ let blankCount = 0;
166
+ for (const n of neighbors) {
167
+ if (!infectedSet.has(n))
168
+ blankCount++;
169
+ }
170
+ out.push([idx, blankCount]);
171
+ }
172
+ return out;
173
+ }
156
174
  function findConnectedComponents(indices, positions, hexRadius) {
157
175
  // Immediate synchronous check - if this doesn't log, the function isn't being called or is blocked
158
176
  const startMarker = performance.now();
@@ -1854,6 +1872,7 @@ self.onmessage = function (ev) {
1854
1872
  availableIndices: res.availableIndices,
1855
1873
  lastEvolutionTime: res.lastEvolutionTime,
1856
1874
  generation: res.generation,
1875
+ blankNeighborCounts: buildBlankNeighborCounts(res.infections, positions, hexRadius),
1857
1876
  };
1858
1877
  if (res.tileCenters && res.tileCenters.length > 0) {
1859
1878
  payload.tileCenters = res.tileCenters;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buley/hexgrid-3d",
3
- "version": "3.5.0",
3
+ "version": "3.5.1",
4
4
  "description": "3D hexagonal grid visualization component for React",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",