Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/components/map/AssetHeatmapLayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import { useEffect, useRef } from 'react';
import { getTile } from './heatmapStore';

Check failure on line 4 in src/components/map/AssetHeatmapLayer.tsx

View workflow job for this annotation

GitHub Actions / Lint & TypeScript Check

'getTile' is defined but never used
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) {

Check failure on line 24 in src/components/map/AssetHeatmapLayer.tsx

View workflow job for this annotation

GitHub Actions / Lint & TypeScript Check

'onTileUpdate' is defined but never used. Allowed unused args must match /^_/u
const canvasRef = useRef<HTMLCanvasElement>(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;
},
});
}
}, [assets, boundaries]);

return <canvas ref={canvasRef} width={800} height={600} />;
}
52 changes: 52 additions & 0 deletions src/components/map/boundaryManager.ts
Original file line number Diff line number Diff line change
@@ -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<Float32Array>;
}

const pendingUpdates = new Map<string, Promise<void>>();

export async function updateBoundary(boundary: Boundary): Promise<void> {
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 + '::' + x + ',' + y);
}
}
return tiles;
}
61 changes: 61 additions & 0 deletions src/components/map/heatmapStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
type HeatValues = Float32Array;

interface TileEntry {
generation: number;
data: HeatValues;
lock: Promise<void>;
}

const store = new Map<string, TileEntry>();

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<void>((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();
}
57 changes: 57 additions & 0 deletions tests/components/AssetHeatmapLayer.test.tsx
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading