Files
kestrelos/server/utils/alprGeo.js
T
keligrubb bb01e9a06c
Push / release (push) Successful in 13s
Push / publish (push) Successful in 1m4s
Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
## Summary

- **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles.
- **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones.
- **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn.

## Docs

- `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys
- `docs/map-and-cameras.md` — ALPR layer and map behavior updates

---------

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #36
2026-06-24 20:54:50 +00:00

176 lines
4.8 KiB
JavaScript

const ATTRIBUTION = '© OpenStreetMap contributors'
/** Longest match first — Flock, Genetec, Leonardo, etc. */
const KNOWN_ALPR_MODELS = [
'Falcon Flex',
'AutoVu CR-H2',
'AutoVu Sharp',
'UnicamVELOCITY3',
'Falcon',
'Sparrow',
'Raven',
'Condor',
'Wing',
'Pelican',
]
const GENERIC_CAMERA_NAMES = new Set([
'flock alpr camera',
'flock alpr cameera',
'flock camera',
'flock alpr',
'flock',
'flock camera',
'automatic license plate reader (alpr)',
'alpr camera',
'alpr',
])
const SKIP_EXTRA_TAGS = new Set([
'surveillance:type',
'man_made',
'camera:direction',
'direction',
'camera:angle',
'surveillance:angle',
'manufacturer',
'brand',
'model',
'operator',
'name',
'ref',
'surveillance',
'camera:type',
'description',
'note',
'colour',
'color',
'manufacturer:wikidata',
'model:wikidata',
'operator:wikidata',
])
function escapeRegex(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** @param {string} value */
export function normalizeModelName(value) {
const trimmed = value.trim()
if (!trimmed) return null
const lower = trimmed.toLowerCase()
for (const model of KNOWN_ALPR_MODELS) {
if (model.toLowerCase() === lower) return model
}
return trimmed.split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
).join(' ')
}
/** @param {string} text */
function matchKnownModel(text) {
for (const model of KNOWN_ALPR_MODELS) {
const re = new RegExp(`\\b${escapeRegex(model)}\\b`, 'i')
if (re.test(text)) return model
}
const flock = text.match(/\bFlock\s+([a-z][\w -]*)/i)
if (flock) {
const fragment = flock[1].trim()
if (!fragment || /^safety$/i.test(fragment)) return null
return matchKnownModel(fragment)
}
return null
}
/** @param {Record<string, string>} tags */
export function inferModelFromTags(tags) {
const t = tags ?? {}
if (t.model?.trim()) return normalizeModelName(t.model)
for (const key of ['name', 'description', 'note']) {
const value = t[key]
if (!value?.trim()) continue
const model = matchKnownModel(value)
if (model) return model
}
return null
}
/** @param {string | null | undefined} name */
export function isGenericCameraName(name) {
if (!name?.trim()) return false
const normalized = name.trim().toLowerCase()
if (GENERIC_CAMERA_NAMES.has(normalized)) return true
if (/^flock\s+alpr\b/i.test(name) && !matchKnownModel(name)) return true
if (matchKnownModel(name) && /\bflock\b/i.test(name)) return true
return false
}
/** @param {Record<string, string>} tags */
export function displayNameFromTags(tags) {
const name = tags?.name?.trim()
if (!name || isGenericCameraName(name)) return null
if (matchKnownModel(name)) return null
return name
}
/** @param {Record<string, string>} tags */
export function identifyingProperties(tags, manufacturer = null) {
const t = tags ?? {}
const mfr = manufacturer ?? t.manufacturer ?? t.brand ?? null
const model = inferModelFromTags(t)
const name = displayNameFromTags(t)
/** @type {Record<string, string | boolean>} */
const props = {}
if (mfr) props.manufacturer = mfr
if (model) props.model = model
if (mfr && !model) props.modelUnknown = true
if (t.brand && t.brand !== mfr) props.brand = t.brand
if (t.operator) props.operator = t.operator
if (name) props.name = name
if (t.ref) props.ref = t.ref
if (t.surveillance) props.surveillance = t.surveillance
if (t['camera:type']) props.cameraType = t['camera:type']
if (t.description) props.description = t.description
if (t.note) props.note = t.note
const colour = t.colour ?? t.color
if (colour) props.colour = colour
if (t['manufacturer:wikidata']) props.manufacturerWikidata = t['manufacturer:wikidata']
if (t['model:wikidata']) props.modelWikidata = t['model:wikidata']
if (t['operator:wikidata']) props.operatorWikidata = t['operator:wikidata']
const extra = {}
for (const [key, value] of Object.entries(t)) {
if (SKIP_EXTRA_TAGS.has(key) || !value?.trim()) continue
extra[key] = value
}
if (Object.keys(extra).length) props.tags = extra
return props
}
/** @param {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, fov: number | null, tags: Record<string, string> }} camera */
export function cameraToFeature(camera) {
return {
type: 'Feature',
id: camera.osmId,
geometry: { type: 'Point', coordinates: [camera.lng, camera.lat] },
properties: {
osmId: camera.osmId,
direction: camera.direction,
fov: camera.fov,
...identifyingProperties(camera.tags, camera.manufacturer),
},
}
}
/** @param {ReturnType<typeof cameraToFeature>[]} features */
export function featureCollection(features, source) {
return {
type: 'FeatureCollection',
features,
attribution: ATTRIBUTION,
source,
}
}
export { ATTRIBUTION }