Add ADS-B, AIS, and ALPR map layers with live CoT streaming.
Ingest aircraft and vessel tracks via OSINT feeds and tactical CoT, expose viewport-filtered SSE to the map, and add an OSM ALPR layer with tiled caching and performant marker sync.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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 `<a href="https://www.wikidata.org/wiki/${id}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
|
||||
}
|
||||
|
||||
function popupLine(parts) {
|
||||
const line = parts.filter(Boolean).join(' · ')
|
||||
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
|
||||
}
|
||||
|
||||
function titleHtml(props) {
|
||||
if (props.name) return escapeHtml(props.name)
|
||||
if (props.model) {
|
||||
const mfr = props.manufacturer ? escapeHtml(props.manufacturer) : null
|
||||
return mfr ? `${mfr} <span class="text-kestrel-accent">${escapeHtml(props.model)}</span>` : 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 `<div class="text-sm mt-0.5"><span class="text-kestrel-muted">Model</span> <strong>${escapeHtml(props.model)}</strong></div>`
|
||||
}
|
||||
if (props.modelUnknown) {
|
||||
return '<div class="text-kestrel-muted text-xs mt-0.5">Model not recorded in OpenStreetMap</div>'
|
||||
}
|
||||
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 ? `<div class="text-kestrel-muted text-xs mt-1">${escapeHtml(note)}</div>` : ''
|
||||
const identityHtml = identity.length
|
||||
? `<div class="text-kestrel-muted text-xs mt-0.5">${identity.join(' · ')}</div>`
|
||||
: ''
|
||||
const modelHtml = (props.model || props.modelUnknown) ? modelLineHtml(props) : ''
|
||||
const foot = `<div class="text-kestrel-muted text-xs mt-2"><a href="https://www.openstreetmap.org/node/${props.osmId}" target="_blank" rel="noopener">View on OpenStreetMap</a></div>`
|
||||
|
||||
return `<div class="kestrel-live-popup"><strong>${title}</strong> <span class="text-kestrel-muted">License plate reader</span>${modelHtml}${identityHtml}${popupLine(view)}${noteHtml}${foot}</div>`
|
||||
}
|
||||
|
||||
function popupHtml(props) {
|
||||
return formatAlprPopup(props)
|
||||
}
|
||||
|
||||
function pointIcon(L, direction, fov) {
|
||||
const hasDirection = Number.isFinite(direction)
|
||||
if (!hasDirection) {
|
||||
const html = `<span class="poi-icon-svg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ALPR_COLOR}" stroke-width="2"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/><path d="M8 21h8"/></svg></span>`
|
||||
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 = `<span class="alpr-cone" style="transform:rotate(${direction}deg)"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" width="${CONE_SIZE}" height="${CONE_SIZE}"><path d="${conePath(spread)}" fill="${ALPR_COLOR}" fill-opacity="0.3" stroke="${ALPR_COLOR}" stroke-width="1.5"/><circle cx="26" cy="26" r="3" fill="${ALPR_COLOR}"/></svg></span>`
|
||||
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: `<span class="alpr-cluster">${count}</span>`,
|
||||
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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/></svg>'
|
||||
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))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export const MAX_BBOX_DEGREES = 0.5
|
||||
|
||||
export function tileKey(row, col) {
|
||||
return `${row},${col}`
|
||||
}
|
||||
|
||||
export function bboxToTileKey(bbox) {
|
||||
const row = Math.floor(bbox.south / MAX_BBOX_DEGREES)
|
||||
const col = Math.floor(bbox.west / MAX_BBOX_DEGREES)
|
||||
return tileKey(row, col)
|
||||
}
|
||||
|
||||
function tileBox(row, col, step = MAX_BBOX_DEGREES) {
|
||||
const south = row * step
|
||||
const west = col * step
|
||||
return { south, west, north: south + step, east: west + step }
|
||||
}
|
||||
|
||||
export function bboxFetchKey(bounds) {
|
||||
const zoom = bounds.zoom ?? 14
|
||||
const step = zoom >= 14 ? 0.025 : zoom >= 11 ? 0.1 : zoom >= 8 ? 0.25 : 1
|
||||
const q = v => Math.round(v / step) * step
|
||||
return [q(bounds.south), q(bounds.west), q(bounds.north), q(bounds.east)].join(',')
|
||||
}
|
||||
|
||||
function ringOffsets(radius) {
|
||||
if (radius === 0) return [[0, 0]]
|
||||
return Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
|
||||
.flatMap(dr => Array.from({ length: 2 * radius + 1 }, (_, j) => j - radius)
|
||||
.filter(dc => Math.abs(dr) === radius || Math.abs(dc) === radius)
|
||||
.map(dc => [dr, dc]))
|
||||
}
|
||||
|
||||
function collectTiles(state) {
|
||||
const {
|
||||
centerRow, centerCol, minRow, maxRow, minCol, maxCol, step, limit, radius, seen, tiles,
|
||||
} = state
|
||||
if (tiles.length >= limit) return tiles
|
||||
|
||||
const inBounds = (row, col) => row >= minRow && row <= maxRow && col >= minCol && col <= maxCol
|
||||
const { nextSeen, added } = ringOffsets(radius)
|
||||
.map(([dr, dc]) => [centerRow + dr, centerCol + dc])
|
||||
.filter(([row, col]) => inBounds(row, col))
|
||||
.reduce((acc, [row, col]) => {
|
||||
const key = `${row},${col}`
|
||||
if (acc.nextSeen.has(key)) return acc
|
||||
return {
|
||||
nextSeen: new Set([...acc.nextSeen, key]),
|
||||
added: [...acc.added, tileBox(row, col, step)],
|
||||
}
|
||||
}, { nextSeen: seen, added: [] })
|
||||
|
||||
if (added.length === 0 && radius > 0) return tiles
|
||||
|
||||
const nextTiles = [...tiles, ...added].slice(0, limit)
|
||||
if (nextTiles.length >= limit) return nextTiles
|
||||
return collectTiles({ ...state, radius: radius + 1, seen: nextSeen, tiles: nextTiles })
|
||||
}
|
||||
|
||||
export function tilesNearCenter(bounds, limit) {
|
||||
const step = MAX_BBOX_DEGREES
|
||||
const lat = (bounds.south + bounds.north) / 2
|
||||
const lng = (bounds.west + bounds.east) / 2
|
||||
return collectTiles({
|
||||
centerRow: Math.floor(lat / step),
|
||||
centerCol: Math.floor(lng / step),
|
||||
minRow: Math.floor(bounds.south / step),
|
||||
maxRow: Math.ceil(bounds.north / step) - 1,
|
||||
minCol: Math.floor(bounds.west / step),
|
||||
maxCol: Math.ceil(bounds.east / step) - 1,
|
||||
step,
|
||||
limit,
|
||||
radius: 0,
|
||||
seen: new Set(),
|
||||
tiles: [],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** Map CoT / ADS-B entity display: icons and popups. */
|
||||
|
||||
export const COT_COLORS = {
|
||||
air: '#60a5fa',
|
||||
helicopter: '#fbbf24',
|
||||
surface: '#38bdf8',
|
||||
ground: '#f59e0b',
|
||||
}
|
||||
|
||||
export function cotCategory(type) {
|
||||
const t = typeof type === 'string' ? type : ''
|
||||
if (t.startsWith('a-f-A-')) return 'air'
|
||||
if (t.startsWith('a-f-S-')) return 'surface'
|
||||
return 'ground'
|
||||
}
|
||||
|
||||
/** Whether the entity is a helicopter or fixed-wing aircraft. @returns {'helicopter' | 'fixedWing'} */
|
||||
export function cotAirIconKind(entity) {
|
||||
const type = entity?.type ?? ''
|
||||
if (type.endsWith('-C-H') || type.endsWith('-M-H')) return 'helicopter'
|
||||
return 'fixedWing'
|
||||
}
|
||||
|
||||
function iconWrap(heading, inner) {
|
||||
const rotate = Number.isFinite(heading) ? ` style="transform:rotate(${heading}deg)"` : ''
|
||||
return `<span class="poi-icon-svg cot-icon-rotatable"${rotate}>${inner}</span>`
|
||||
}
|
||||
|
||||
const PLANE_SVG = color =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${color}"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`
|
||||
|
||||
const HELI_SVG = color =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.75" stroke-linecap="round"><circle cx="12" cy="12" r="2.5" fill="${color}"/><path d="M3 8h18M3 12h18"/><path d="M12 8v8"/><path d="M9 16h6"/></svg>`
|
||||
|
||||
const SHIP_SVG = color =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><path d="M2 20c2-4 6-6 10-6s8 2 10 6"/><path d="M12 14V4"/><path d="m8 8 4-4 4 4"/></svg>`
|
||||
|
||||
const GROUND_SVG = color =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${color}"/></svg>`
|
||||
|
||||
export function getCotIconHtml(entity) {
|
||||
const category = cotCategory(entity?.type)
|
||||
const heading = Number(entity?.heading)
|
||||
if (category === 'air') {
|
||||
const kind = cotAirIconKind(entity)
|
||||
const color = kind === 'helicopter' ? COT_COLORS.helicopter : COT_COLORS.air
|
||||
const svg = kind === 'helicopter' ? HELI_SVG(color) : PLANE_SVG(color)
|
||||
return { html: iconWrap(heading, svg), className: `cot-entity-${kind}` }
|
||||
}
|
||||
if (category === 'surface') {
|
||||
return { html: iconWrap(heading, SHIP_SVG(COT_COLORS.surface)), className: 'cot-entity-surface' }
|
||||
}
|
||||
return { html: iconWrap(undefined, GROUND_SVG(COT_COLORS.ground)), className: 'cot-entity-ground' }
|
||||
}
|
||||
|
||||
function msToKnots(ms) {
|
||||
return Number.isFinite(ms) ? Math.round(ms * 1.94384) : null
|
||||
}
|
||||
|
||||
function metersToFeet(m) {
|
||||
return Number.isFinite(m) ? Math.round(m * 3.28084) : null
|
||||
}
|
||||
|
||||
function fmtHeading(deg) {
|
||||
return Number.isFinite(deg) ? `${Math.round(deg)}°` : null
|
||||
}
|
||||
|
||||
function fmtVerticalFpm(ms) {
|
||||
if (!Number.isFinite(ms) || ms === 0) return null
|
||||
const fpm = Math.round(ms * 196.85)
|
||||
return `${fpm > 0 ? '+' : ''}${fpm} fpm`
|
||||
}
|
||||
|
||||
function icaoFromEntity(entity) {
|
||||
if (entity?.icao) return String(entity.icao).toUpperCase()
|
||||
if (typeof entity?.id === 'string' && entity.id.startsWith('ICAO.')) {
|
||||
return entity.id.slice(5).toUpperCase()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mmsiFromEntity(entity) {
|
||||
if (entity?.mmsi) return String(entity.mmsi)
|
||||
if (typeof entity?.id === 'string' && entity.id.startsWith('MMSI.')) return entity.id.slice(5)
|
||||
return null
|
||||
}
|
||||
|
||||
function popupLine(escape, parts) {
|
||||
const line = parts.filter(Boolean).join(' · ')
|
||||
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} entity
|
||||
* @param {(s: string) => string} escape
|
||||
*/
|
||||
export function formatCotPopup(entity, escape) {
|
||||
const category = cotCategory(entity?.type)
|
||||
const label = escape(entity?.label || entity?.id || 'Unknown')
|
||||
|
||||
if (entity?.source === 'adsb' || category === 'air') {
|
||||
const tag = cotAirIconKind(entity) === 'helicopter' ? 'Helicopter' : 'Aircraft'
|
||||
const icao = icaoFromEntity(entity)
|
||||
const meta = [
|
||||
icao ? `ICAO ${icao}` : null,
|
||||
entity?.originCountry ? escape(String(entity.originCountry)) : null,
|
||||
].filter(Boolean).join(' · ')
|
||||
const alt = metersToFeet(entity?.altitude)
|
||||
const stats = [
|
||||
alt != null ? `${alt.toLocaleString()} ft` : null,
|
||||
entity?.onGround ? 'On ground' : null,
|
||||
msToKnots(entity?.speed) != null ? `${msToKnots(entity.speed)} kt` : null,
|
||||
fmtHeading(entity?.heading),
|
||||
fmtVerticalFpm(entity?.verticalRate),
|
||||
entity?.squawk ? `Squawk ${escape(String(entity.squawk))}` : null,
|
||||
]
|
||||
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">${tag}</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
|
||||
}
|
||||
|
||||
if (entity?.source === 'ais' || category === 'surface') {
|
||||
const mmsi = mmsiFromEntity(entity)
|
||||
const meta = mmsi ? `MMSI ${escape(mmsi)}` : ''
|
||||
const stats = [
|
||||
Number.isFinite(entity?.speed) ? `${Number(entity.speed).toFixed(1)} kt` : null,
|
||||
fmtHeading(entity?.heading),
|
||||
]
|
||||
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Vessel</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
|
||||
}
|
||||
|
||||
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Team</span></div>`
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createClusterIndex } from './mapCluster.js'
|
||||
import { syncFeatureMarkers } from './mapMarkerSync.js'
|
||||
import { cotCategory, formatCotPopup, getCotIconHtml } from './cotDisplay.js'
|
||||
|
||||
const ICON_SIZE = 28
|
||||
const CLUSTER = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
export function entitiesToFeatures(entities) {
|
||||
return (entities || [])
|
||||
.filter(e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id)
|
||||
.map(e => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [e.lng, e.lat] },
|
||||
properties: { entity: e, cotCategory: cotCategory(e.type) },
|
||||
}))
|
||||
}
|
||||
|
||||
export function loadCotCluster(entities) {
|
||||
CLUSTER.load(entitiesToFeatures(entities))
|
||||
}
|
||||
|
||||
export function getCotClusters(view) {
|
||||
return CLUSTER.query(view)
|
||||
}
|
||||
|
||||
function featureKey(feature) {
|
||||
const props = feature.properties ?? {}
|
||||
if (props.cluster) return `c:${props.cluster_id}`
|
||||
const id = props.entity?.id
|
||||
return id != null ? `e:${id}` : null
|
||||
}
|
||||
|
||||
function clusterIcon(L, count) {
|
||||
const size = count < 10 ? 28 : count < 100 ? 34 : 40
|
||||
return L.divIcon({
|
||||
className: 'cot-cluster-icon',
|
||||
html: `<span class="cot-cluster">${count}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
})
|
||||
}
|
||||
|
||||
function entityIcon(L, entity) {
|
||||
const { html, className } = getCotIconHtml(entity)
|
||||
return L.divIcon({
|
||||
className: `poi-div-icon cot-entity-icon ${className}`,
|
||||
html,
|
||||
iconSize: [ICON_SIZE, ICON_SIZE],
|
||||
iconAnchor: [ICON_SIZE / 2, ICON_SIZE / 2],
|
||||
})
|
||||
}
|
||||
|
||||
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 createCotLayer(L, map) {
|
||||
return L.layerGroup().addTo(map)
|
||||
}
|
||||
|
||||
export function syncCotLayer(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) : entityIcon(L, props.entity)
|
||||
const marker = L.marker([lat, lng], { icon })
|
||||
if (isCluster) attachClusterClick(marker, feature, map)
|
||||
else if (props.entity) {
|
||||
marker.bindPopup(
|
||||
formatCotPopup(props.entity, escapeHtml),
|
||||
{ className: 'kestrel-live-popup-wrap', maxWidth: 360 },
|
||||
)
|
||||
}
|
||||
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) : entityIcon(L, props.entity)
|
||||
marker.setIcon(icon)
|
||||
if (!isCluster && props.entity) {
|
||||
marker.setPopupContent(formatCotPopup(props.entity, escapeHtml))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Supercluster from 'supercluster'
|
||||
|
||||
export function createClusterIndex(options = {}) {
|
||||
const index = new Supercluster(options)
|
||||
const state = { features: Object.freeze([]) }
|
||||
|
||||
return {
|
||||
load(features) {
|
||||
const list = Object.freeze([...(features ?? [])])
|
||||
index.load(list)
|
||||
state.features = list
|
||||
},
|
||||
query(view) {
|
||||
if (!view || state.features.length === 0) return []
|
||||
const { west, south, east, north, zoom } = view
|
||||
return index.getClusters(
|
||||
[west, south, east, north],
|
||||
Math.floor(zoom ?? 14),
|
||||
).map((feature) => {
|
||||
if (!feature.properties?.cluster) return feature
|
||||
return {
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
expansionZoom: index.getClusterExpansionZoom(feature.properties.cluster_id),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
const SYNC_KEY = '_kestrelMarkerSync'
|
||||
|
||||
const pointFeatures = (features, keyFor) => (features ?? [])
|
||||
.filter(f => f?.geometry?.type === 'Point')
|
||||
.map(f => ({ feature: f, key: keyFor(f) }))
|
||||
.filter(({ key }) => key != null)
|
||||
|
||||
export function syncFeatureMarkers(layer, features, { keyFor, create, update }) {
|
||||
const prev = layer[SYNC_KEY] ?? new Map()
|
||||
const next = pointFeatures(features, keyFor).reduce((map, { feature, key }) => {
|
||||
const existing = prev.get(key)
|
||||
if (existing) {
|
||||
update(existing, feature)
|
||||
return new Map([...map, [key, existing]])
|
||||
}
|
||||
const marker = create(feature)
|
||||
layer.addLayer(marker)
|
||||
return new Map([...map, [key, marker]])
|
||||
}, new Map())
|
||||
|
||||
Array.from(prev.entries())
|
||||
.filter(([key]) => !next.has(key))
|
||||
.forEach(([, marker]) => layer.removeLayer(marker))
|
||||
|
||||
layer[SYNC_KEY] = next
|
||||
}
|
||||
|
||||
export function clearFeatureMarkers(layer) {
|
||||
if (!layer) return
|
||||
const prev = layer[SYNC_KEY]
|
||||
if (prev) {
|
||||
Array.from(prev.values()).forEach(marker => layer.removeLayer(marker))
|
||||
layer[SYNC_KEY] = new Map()
|
||||
return
|
||||
}
|
||||
layer.clearLayers()
|
||||
}
|
||||
Reference in New Issue
Block a user