import { syncFeatureMarkers } from './mapMarkerSync.js' const ICON_SIZE = 28 const CONE_SIZE = 52 const ALPR_COLOR = '#ef4444' const DEFAULT_FOV = 60 function escapeHtml(text) { const div = document.createElement('div') div.textContent = text return div.innerHTML } function conePath(fov) { const half = Math.min(Math.max(fov / 2, 10), 85) const cx = 26 const cy = 26 const r = 24 const toRad = deg => ((deg - 90) * Math.PI) / 180 const x1 = cx + r * Math.cos(toRad(-half)) const y1 = cy + r * Math.sin(toRad(-half)) const x2 = cx + r * Math.cos(toRad(half)) const y2 = cy + r * Math.sin(toRad(half)) return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2} Z` } function compassLabel(deg) { if (!Number.isFinite(deg)) return null const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] const idx = Math.round((((deg % 360) + 360) % 360) / 45) % 8 return dirs[idx] } function wikidataLink(label, qid) { if (!qid) return escapeHtml(label) const id = escapeHtml(qid) return `${escapeHtml(label)}` } function popupLine(parts) { const line = parts.filter(Boolean).join(' · ') return line ? `
${line}
` : '' } function titleHtml(props) { if (props.name) return escapeHtml(props.name) if (props.model) { const mfr = props.manufacturer ? escapeHtml(props.manufacturer) : null return mfr ? `${mfr} ${escapeHtml(props.model)}` : escapeHtml(props.model) } const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ') if (makeModel) { if (props.manufacturerWikidata && !props.model) return wikidataLink(makeModel, props.manufacturerWikidata) return escapeHtml(makeModel) } if (props.operator) return wikidataLink(props.operator, props.operatorWikidata) if (props.ref) return escapeHtml(props.ref) return 'ALPR camera' } function titleText(props) { if (props.name) return props.name if (props.model) { return [props.manufacturer, props.model].filter(Boolean).join(' ') } const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ') if (makeModel) return makeModel if (props.operator) return props.operator if (props.ref) return props.ref return 'ALPR camera' } function modelLineHtml(props) { if (props.model) { return `
Model ${escapeHtml(props.model)}
` } if (props.modelUnknown) { return '
Model not recorded in OpenStreetMap
' } return '' } /** Human-readable ALPR popup (GeoJSON properties in, HTML out). */ export function formatAlprPopup(props) { const title = titleHtml(props) const headline = titleText(props) const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ') const identity = [] if (props.name && makeModel) identity.push(escapeHtml(makeModel)) if (props.operator && headline !== props.operator) { identity.push(wikidataLink(props.operator, props.operatorWikidata)) } if (props.ref && headline !== props.ref) identity.push(escapeHtml(props.ref)) if (props.brand) identity.push(escapeHtml(props.brand)) const view = [] if (props.direction != null) { const deg = Math.round(props.direction) const compass = compassLabel(props.direction) view.push(compass ? `Facing ${compass} (${deg}°)` : `Facing ${deg}°`) } if (props.fov != null && props.direction != null) view.push(`~${Math.round(props.fov)}° view`) const note = props.description || props.note const noteHtml = note ? `
${escapeHtml(note)}
` : '' const identityHtml = identity.length ? `
${identity.join(' · ')}
` : '' const modelHtml = (props.model || props.modelUnknown) ? modelLineHtml(props) : '' const foot = `
View on OpenStreetMap
` return `
${title} License plate reader${modelHtml}${identityHtml}${popupLine(view)}${noteHtml}${foot}
` } function popupHtml(props) { return formatAlprPopup(props) } function pointIcon(L, direction, fov) { const hasDirection = Number.isFinite(direction) if (!hasDirection) { const html = `` return L.divIcon({ className: 'poi-div-icon alpr-icon', html, iconSize: [ICON_SIZE, ICON_SIZE], iconAnchor: [ICON_SIZE / 2, ICON_SIZE], }) } const spread = Number.isFinite(fov) ? fov : DEFAULT_FOV const html = `` return L.divIcon({ className: 'poi-div-icon alpr-icon', html, iconSize: [CONE_SIZE, CONE_SIZE], iconAnchor: [CONE_SIZE / 2, CONE_SIZE / 2], }) } function clusterIcon(L, count) { const size = count < 10 ? 28 : count < 100 ? 34 : 40 return L.divIcon({ className: 'alpr-cluster-icon', html: `${count}`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }) } export function createAlprControl(L, { showAlpr, onToggle }) { const control = L.control({ position: 'topleft' }) control.onAdd = function () { const el = document.createElement('button') el.type = 'button' el.className = 'leaflet-bar leaflet-control-alpr' el.title = 'Toggle ALPR cameras (OSM)' el.setAttribute('aria-label', 'Toggle ALPR cameras') el.setAttribute('aria-pressed', showAlpr ? 'true' : 'false') el.innerHTML = '' el.addEventListener('click', onToggle) control._button = el return el } return control } export function setAlprControlPressed(control, pressed) { control?._button?.setAttribute('aria-pressed', pressed ? 'true' : 'false') } function featureKey(feature) { const props = feature.properties ?? {} if (props.cluster) return `c:${props.cluster_id}` const id = props.osmId return id != null ? `a:${id}` : null } function coords(feature) { const [lng, lat] = feature.geometry.coordinates return { lat, lng } } function attachClusterClick(marker, feature, map) { marker.on('click', () => { const { lat, lng } = coords(feature) const props = feature.properties ?? {} const zoom = props.expansionZoom ?? map.getZoom() + 2 map.setView([lat, lng], Math.min(zoom, 19), { animate: true }) }) } export function createAlprLayer(L, map) { return L.layerGroup().addTo(map) } export function syncAlprLayer(L, map, layer, features) { syncFeatureMarkers(layer, features, { keyFor: featureKey, create: (feature) => { const { lat, lng } = coords(feature) const props = feature.properties ?? {} const isCluster = Boolean(props.cluster) const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov) const marker = L.marker([lat, lng], { icon }) if (isCluster) attachClusterClick(marker, feature, map) else marker.bindPopup(popupHtml(props), { className: 'kestrel-live-popup-wrap', maxWidth: 320 }) return marker }, update: (marker, feature) => { const { lat, lng } = coords(feature) const props = feature.properties ?? {} const isCluster = Boolean(props.cluster) marker.setLatLng([lat, lng]) const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov) marker.setIcon(icon) if (!isCluster) marker.setPopupContent(popupHtml(props)) }, }) }