bb01e9a06c
## 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
176 lines
4.8 KiB
JavaScript
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 }
|