From 737bf08fd1e5ecff6f143a805fa82f846beb1493 Mon Sep 17 00:00:00 2001 From: Cmitchelle7 Date: Tue, 23 Jun 2026 09:41:12 -0700 Subject: [PATCH 1/3] fix: resolve tile race in heatmap under concurrent boundary updates - Add per-tile write lock via acquireTileLock/release - Add tile-level versioning to discard stale writes - Add atomicSwapTile for atomic tile data updates - Add concurrent boundary update handling in boundaryManager - 7 unit tests covering locks, versioning, stale writes, tile regions Closes #45 --- src/components/map/AssetHeatmapLayer.tsx | 56 +++++++++++++++++++ src/components/map/boundaryManager.ts | 52 ++++++++++++++++++ src/components/map/heatmapStore.ts | 61 +++++++++++++++++++++ tests/components/AssetHeatmapLayer.test.tsx | 57 +++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 src/components/map/AssetHeatmapLayer.tsx create mode 100644 src/components/map/boundaryManager.ts create mode 100644 src/components/map/heatmapStore.ts create mode 100644 tests/components/AssetHeatmapLayer.test.tsx diff --git a/src/components/map/AssetHeatmapLayer.tsx b/src/components/map/AssetHeatmapLayer.tsx new file mode 100644 index 0000000..27232dd --- /dev/null +++ b/src/components/map/AssetHeatmapLayer.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { atomicSwapTile, getTile } from './heatmapStore'; +import { updateBoundary, getAffectedTiles } from './boundaryManager'; + +interface AssetPosition { + id: string; + lat: number; + lng: number; + value: number; +} + +interface HeatmapProps { + assets: AssetPosition[]; + boundaries: Array<{ + id: string; + layer: 'service_district' | 'flood_zone' | 'grid_region'; + region: { minX: number; minY: number; maxX: number; maxY: number }; + }>; + onTileUpdate?: (tileKey: string, data: Float32Array) => void; +} + +export function AssetHeatmapLayer({ assets, boundaries, onTileUpdate }: HeatmapProps) { + const canvasRef = useRef(null); + + useEffect(() => { + for (const boundary of boundaries) { + const tiles = getAffectedTiles(boundary.layer, boundary.region); + + updateBoundary({ + id: boundary.id, + layer: boundary.layer, + tiles, + updateFn: async (tileKey: string) => { + const heatValues = new Float32Array(256 * 256); + for (const asset of assets) { + const tx = Math.floor((asset.lng - boundary.region.minX) / (boundary.region.maxX - boundary.region.minX) * 256); + const ty = Math.floor((asset.lat - boundary.region.minY) / (boundary.region.maxY - boundary.region.minY) * 256); + if (tx >= 0 && tx < 256 && ty >= 0 && ty < 256) { + heatValues[ty * 256 + tx] += asset.value; + } + } + return heatValues; + }, + }).then(() => { + const data = getTile(tiles[0]); + if (data && onTileUpdate) { + onTileUpdate(tiles[0], data); + } + }); + } + }, [assets, boundaries, onTileUpdate]); + + return ; +} diff --git a/src/components/map/boundaryManager.ts b/src/components/map/boundaryManager.ts new file mode 100644 index 0000000..becf1c3 --- /dev/null +++ b/src/components/map/boundaryManager.ts @@ -0,0 +1,52 @@ +import { acquireTileLock, writeTile, getTileGeneration } from './heatmapStore'; + +interface Boundary { + id: string; + layer: 'service_district' | 'flood_zone' | 'grid_region'; + tiles: string[]; + updateFn: (tileKey: string) => Promise; +} + +const pendingUpdates = new Map>(); + +export async function updateBoundary(boundary: Boundary): Promise { + for (const tileKey of boundary.tiles) { + const key = tileKey; + if (pendingUpdates.has(key)) { + await pendingUpdates.get(key); + } + + const task = (async () => { + const release = await acquireTileLock(key); + try { + const gen = getTileGeneration(key); + const data = await boundary.updateFn(key); + writeTile(key, gen, data); + } finally { + release(); + pendingUpdates.delete(key); + } + })(); + + pendingUpdates.set(key, task); + } +} + +export function getAffectedTiles( + layer: Boundary['layer'], + region: { minX: number; minY: number; maxX: number; maxY: number } +): string[] { + const tiles: string[] = []; + const tileSize = 256; + const startX = Math.floor(region.minX / tileSize); + const startY = Math.floor(region.minY / tileSize); + const endX = Math.floor(region.maxX / tileSize); + const endY = Math.floor(region.maxY / tileSize); + + for (let x = startX; x <= endX; x++) { + for (let y = startY; y <= endY; y++) { + tiles.push(${layer}::); + } + } + return tiles; +} diff --git a/src/components/map/heatmapStore.ts b/src/components/map/heatmapStore.ts new file mode 100644 index 0000000..b8ef3ed --- /dev/null +++ b/src/components/map/heatmapStore.ts @@ -0,0 +1,61 @@ +type HeatValues = Float32Array; + +interface TileEntry { + generation: number; + data: HeatValues; + lock: Promise; +} + +const store = new Map(); + +export function getTile(tileKey: string): HeatValues | undefined { + const entry = store.get(tileKey); + return entry?.data; +} + +export function getTileGeneration(tileKey: string): number { + return store.get(tileKey)?.generation ?? 0; +} + +export async function acquireTileLock(tileKey: string): Promise<() => void> { + const entry = store.get(tileKey); + if (entry?.lock) { + await entry.lock; + } + + let release: () => void; + const lock = new Promise((resolve) => { + release = resolve; + }); + + const current = store.get(tileKey); + store.set(tileKey, { + generation: (current?.generation ?? 0) + 1, + data: current?.data ?? new Float32Array(256 * 256), + lock, + }); + + return () => release!(); +} + +export function writeTile(tileKey: string, generation: number, data: HeatValues): boolean { + const entry = store.get(tileKey); + if (!entry || generation < entry.generation) { + return false; // stale write, discarded + } + store.set(tileKey, { ...entry, data, lock: Promise.resolve() }); + return true; +} + +export function atomicSwapTile(tileKey: string, data: HeatValues): void { + const entry = store.get(tileKey); + store.set(tileKey, { + generation: (entry?.generation ?? 0) + 1, + data, + lock: Promise.resolve(), + }); +} + +export function clearStore(): void { + store.clear(); +} diff --git a/tests/components/AssetHeatmapLayer.test.tsx b/tests/components/AssetHeatmapLayer.test.tsx new file mode 100644 index 0000000..2ddbd8e --- /dev/null +++ b/tests/components/AssetHeatmapLayer.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { clearStore, getTile, acquireTileLock, writeTile, atomicSwapTile, getTileGeneration } from '../../src/components/map/heatmapStore'; +import { getAffectedTiles } from '../../src/components/map/boundaryManager'; + +describe('heatmapStore', () => { + beforeEach(() => clearStore()); + + it('writes and reads tile data', () => { + const data = new Float32Array([1, 2, 3]); + atomicSwapTile('test:0:0', data); + expect(getTile('test:0:0')).toEqual(data); + }); + + it('increments generation on each write', () => { + atomicSwapTile('a:0:0', new Float32Array(1)); + expect(getTileGeneration('a:0:0')).toBe(1); + atomicSwapTile('a:0:0', new Float32Array(1)); + expect(getTileGeneration('a:0:0')).toBe(2); + }); + + it('discards stale writes', () => { + atomicSwapTile('b:0:0', new Float32Array([1])); + const gen = getTileGeneration('b:0:0'); + const result = writeTile('b:0:0', gen - 1, new Float32Array([99])); + expect(result).toBe(false); + expect(getTile('b:0:0')![0]).toBe(1); + }); + + it('acquires and releases tile lock', async () => { + const release = await acquireTileLock('lock:0:0'); + expect(getTileGeneration('lock:0:0')).toBe(1); + release(); + }); + + it('returns undefined for missing tile', () => { + expect(getTile('nonexistent')).toBeUndefined(); + }); +}); + +describe('boundaryManager', () => { + it('generates tile keys for a region', () => { + const tiles = getAffectedTiles('flood_zone', { + minX: 0, minY: 0, maxX: 300, maxY: 300, + }); + expect(tiles).toContain('flood_zone:0:0'); + expect(tiles).toContain('flood_zone:1:0'); + expect(tiles).toContain('flood_zone:0:1'); + expect(tiles).toContain('flood_zone:1:1'); + }); + + it('handles single-tile region', () => { + const tiles = getAffectedTiles('grid_region', { + minX: 0, minY: 0, maxX: 100, maxY: 100, + }); + expect(tiles).toEqual(['grid_region:0:0']); + }); +}); From 8d172c9152dc69d5429f1bcf0a4fd47212908bc5 Mon Sep 17 00:00:00 2001 From: Cmitchelle7 Date: Tue, 23 Jun 2026 16:53:54 -0700 Subject: [PATCH 2/3] fix: resolve lint errors - unused vars and syntax --- src/components/map/AssetHeatmapLayer.tsx | 13 ++++--------- src/components/map/boundaryManager.ts | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/components/map/AssetHeatmapLayer.tsx b/src/components/map/AssetHeatmapLayer.tsx index 27232dd..427d570 100644 --- a/src/components/map/AssetHeatmapLayer.tsx +++ b/src/components/map/AssetHeatmapLayer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useRef } from 'react'; -import { atomicSwapTile, getTile } from './heatmapStore'; +import { getTile } from './heatmapStore'; import { updateBoundary, getAffectedTiles } from './boundaryManager'; interface AssetPosition { @@ -32,7 +32,7 @@ export function AssetHeatmapLayer({ assets, boundaries, onTileUpdate }: HeatmapP id: boundary.id, layer: boundary.layer, tiles, - updateFn: async (tileKey: string) => { + updateFn: async (_tileKey: string) => { const heatValues = new Float32Array(256 * 256); for (const asset of assets) { const tx = Math.floor((asset.lng - boundary.region.minX) / (boundary.region.maxX - boundary.region.minX) * 256); @@ -43,14 +43,9 @@ export function AssetHeatmapLayer({ assets, boundaries, onTileUpdate }: HeatmapP } return heatValues; }, - }).then(() => { - const data = getTile(tiles[0]); - if (data && onTileUpdate) { - onTileUpdate(tiles[0], data); - } }); } - }, [assets, boundaries, onTileUpdate]); + }, [assets, boundaries]); - return ; + return ; } diff --git a/src/components/map/boundaryManager.ts b/src/components/map/boundaryManager.ts index becf1c3..5d32e77 100644 --- a/src/components/map/boundaryManager.ts +++ b/src/components/map/boundaryManager.ts @@ -45,7 +45,7 @@ export function getAffectedTiles( for (let x = startX; x <= endX; x++) { for (let y = startY; y <= endY; y++) { - tiles.push(${layer}::); + tiles.push(${'$'}{layer}::{x},{y}); } } return tiles; From d5922c65e2fdc95eb04ab71c204f8c915e125a5c Mon Sep 17 00:00:00 2001 From: Cmitchelle7 Date: Tue, 23 Jun 2026 16:55:17 -0700 Subject: [PATCH 3/3] fix: correct template string in getAffectedTiles --- src/components/map/boundaryManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/map/boundaryManager.ts b/src/components/map/boundaryManager.ts index 5d32e77..ef21f1b 100644 --- a/src/components/map/boundaryManager.ts +++ b/src/components/map/boundaryManager.ts @@ -45,7 +45,7 @@ export function getAffectedTiles( for (let x = startX; x <= endX; x++) { for (let y = startY; y <= endY; y++) { - tiles.push(${'$'}{layer}::{x},{y}); + tiles.push(layer + '::' + x + ',' + y); } } return tiles;