import { createClusterIndex } from '~/utils/mapCluster.js' import { MAX_BBOX_DEGREES, bboxFetchKey, bboxToTileKey, tileKey, tilesNearCenter, } from '~/utils/alprViewport.js' const STORAGE_KEY = 'kestrelos:showAlprLayer' const MIN_FETCH_ZOOM = 10 const MAX_TILE_FETCHES = 16 const MAX_CACHED_TILES = 64 const TILE_RETENTION = 3 const EMPTY_TILES = Object.freeze({}) const mergeFeatures = lists => Object.values( lists.flat().reduce((acc, feature) => { const id = feature.id ?? feature.properties?.osmId return id == null ? acc : { ...acc, [id]: feature } }, {}), ) const offsets = radius => Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius) const retentionKeys = bounds => new Set( tilesNearCenter(bounds, MAX_TILE_FETCHES).flatMap((tile) => { const r0 = Math.floor(tile.south / MAX_BBOX_DEGREES) const c0 = Math.floor(tile.west / MAX_BBOX_DEGREES) return offsets(TILE_RETENTION).flatMap(dr => offsets(TILE_RETENTION).map(dc => tileKey(r0 + dr, c0 + dc)), ) }), ) const pruneTileCache = (cache, bounds) => { const keep = retentionKeys(bounds) const retained = Object.fromEntries( Object.entries(cache).filter(([key]) => keep.has(key)), ) const overflow = Object.keys(retained).length - MAX_CACHED_TILES if (overflow <= 0) return Object.freeze(retained) const cr = Math.floor((bounds.south + bounds.north) / 2 / MAX_BBOX_DEGREES) const cc = Math.floor((bounds.west + bounds.east) / 2 / MAX_BBOX_DEGREES) const drop = new Set( Object.keys(retained) .map((key) => { const [r, c] = key.split(',').map(Number) return { key, dist: Math.hypot(r - cr, c - cc) } }) .sort((a, b) => b.dist - a.dist) .slice(0, overflow) .map(({ key }) => key), ) return Object.freeze( Object.fromEntries(Object.entries(retained).filter(([key]) => !drop.has(key))), ) } const cacheChanged = (before, after) => Object.keys(before).length !== Object.keys(after).length export function useAlprCameras() { const showAlpr = useState('showAlprLayer', () => { if (!import.meta.client) return true return localStorage.getItem(STORAGE_KEY) !== '0' }) const view = ref(null) const tiles = ref(EMPTY_TILES) const cluster = createClusterIndex({ radius: 50, maxZoom: 17 }) const debounceTimer = ref(null) const requestId = ref(0) const lastFetchKey = ref('') const applyTiles = (next) => { tiles.value = next cluster.load(mergeFeatures(Object.values(next))) } watch(showAlpr, (enabled) => { if (import.meta.client) localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0') if (!enabled) { applyTiles(EMPTY_TILES) view.value = null lastFetchKey.value = '' } }) const alprMarkers = computed(() => view.value ? cluster.query(view.value) : []) const toggleAlpr = () => { showAlpr.value = !showAlpr.value } const onBoundsChange = (bounds) => { if (!showAlpr.value || !bounds) return view.value = bounds const pruned = pruneTileCache(tiles.value, bounds) if (cacheChanged(tiles.value, pruned)) applyTiles(pruned) if ((bounds.zoom ?? 14) < MIN_FETCH_ZOOM) return const key = bboxFetchKey(bounds) if (key === lastFetchKey.value) return if (debounceTimer.value) clearTimeout(debounceTimer.value) debounceTimer.value = setTimeout(() => { lastFetchKey.value = key fetchViewport(bounds) }, 400) } const fetchViewport = async (bounds) => { const id = requestId.value + 1 requestId.value = id const missing = tilesNearCenter(bounds, MAX_TILE_FETCHES) .filter(tile => !(bboxToTileKey(tile) in tiles.value)) try { const fetched = missing.length ? await Promise.all(missing.map(async (tile) => { const params = new URLSearchParams({ south: String(tile.south), west: String(tile.west), north: String(tile.north), east: String(tile.east), }) const data = await $fetch(`/api/alpr?${params}`).catch(() => null) return [bboxToTileKey(tile), data?.features ?? []] })) : [] if (id !== requestId.value) return const merged = Object.freeze({ ...tiles.value, ...Object.fromEntries(fetched), }) const pruned = pruneTileCache(merged, bounds) applyTiles(pruned) } catch { /* keep last good index */ } } return Object.freeze({ showAlpr, toggleAlpr, alprMarkers, onBoundsChange }) }