diff --git a/app/static/index.html b/app/static/index.html index e06d8d3..98c44a0 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -213,6 +213,19 @@ .leaflet-overlay-pane svg { filter: drop-shadow(0 0 3px rgba(0,0,0,0.6)); } .leaflet-div-icon { background: transparent; border: none; } + /* ── Heading-aware live-feed glyph markers (aircraft/trains/vessels) ── */ + .hdg-marker { + width: 26px; height: 26px; + display: flex; align-items: center; justify-content: center; + } + .hdg-glyph { + display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; + will-change: transform; + filter: drop-shadow(0 0 4px currentColor) drop-shadow(0 1px 2px rgba(0,0,0,0.8)); + } + .hdg-glyph svg { width: 100%; height: 100%; display: block; } + /* ── Camera marker clusters (neon green, matches the dots) ──── */ .marker-cluster-small, .marker-cluster-medium, .marker-cluster-large { background-color: rgba(74, 222, 128, 0.22); @@ -2194,7 +2207,51 @@ function pointPopup(p) { .map(k => `${esc(k)}${esc(extra[k])}`).join(''); return `
${esc(p.label || p.id)}${rows}
`; } -function renderPoints(existing, points, colorFn, cluster) { + +/* ── Heading-aware live-feed glyph markers ─────────────────────────────── + Each feed gets a distinct SVG silhouette (pointing north = up). The glyph + is wrapped in a .hdg-glyph span whose CSS transform rotates it to the + object's heading. Icons are cached per (feed, color, heading) so thousands + of markers reuse a handful of L.divIcon instances instead of one per marker. + Rotation lives on the inner glyph span only, never the marker container, + so clustering + iconAnchor stay intact. */ +const FEED_GLYPHS = { + ac: '', + train: '', + vessel: '', +}; +const feedIconCache = new Map(); +function sanitizeColor(c, fallback) { + const s = String(c || '').trim(); + // Hex colors, rgb()/rgba() triplets, or plain alphabetic CSS keywords. + // Anything with quotes/angle brackets/url()/semicolons is rejected so a + // malicious API value can never break out of the inline style attribute. + if (/^#[0-9a-fA-F]{3,8}$/.test(s)) return s; + if (/^[a-z]{3,20}$/.test(s)) return s; + if (/^rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(,\s*[\d.]+\s*)?\)$/.test(s)) return s; + return fallback; +} +function feedIcon(feed, color, heading) { + // Normalize heading to [0,360) integer so the cache stays bounded. + // Missing/empty/NaN heading -> -1 sentinel -> glyph rendered upright. + const hnum = Number(heading); + const h = (heading === null || heading === '' || heading === undefined || !Number.isFinite(hnum)) + ? -1 + : (Math.round(hnum % 360) + 360) % 360; + const key = `${feed}|${color}|${h}`; + let ic = feedIconCache.get(key); + if (!ic) { + const rot = h >= 0 ? `transform:rotate(${h}deg);` : ''; + ic = L.divIcon({ + className: '', + html: `${FEED_GLYPHS[feed]}`, + iconSize: [26, 26], iconAnchor: [13, 13], + }); + feedIconCache.set(key, ic); + } + return ic; +} +function renderPoints(existing, points, colorFn, cluster, feed) { if (existing) map.removeLayer(existing); const zoom = map.getZoom(); const useCluster = cluster && (zoom < 7 || points.length > 200); @@ -2204,14 +2261,21 @@ function renderPoints(existing, points, colorFn, cluster) { const renderer = pointCanvas(); points.forEach(p => { if (p.lat == null || p.lon == null) return; - const col = colorFn(p); - const heading = Number(p.heading); - const m = L.circleMarker([p.lat, p.lon], { - radius: 5, color: col, fillColor: col, fillOpacity: 0.9, weight: 1, - renderer, - }).bindPopup(pointPopup(p)); - if (!Number.isNaN(heading)) m.setStyle({ className: 'hdg' }); - group.addLayer(m); + const col = sanitizeColor(colorFn(p), '#35e0ff'); + if (feed) { + const heading = Number(p.heading); + const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading); + const m = L.marker([p.lat, p.lon], { icon }).bindPopup(pointPopup(p)); + group.addLayer(m); + } else { + const heading = Number(p.heading); + const m = L.circleMarker([p.lat, p.lon], { + radius: 5, color: col, fillColor: col, fillOpacity: 0.9, weight: 1, + renderer, + }).bindPopup(pointPopup(p)); + if (!Number.isNaN(heading)) m.setStyle({ className: 'hdg' }); + group.addLayer(m); + } }); group.addTo(map); return group; @@ -2367,7 +2431,7 @@ async function loadAircraft() { const r = await fetch(`${API}/api/aircraft?bbox=${currentBBox()}`); const pts = await r.json(); if (req !== overlayReq.ac) return; - acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => altColor((p.extra || {}).alt_baro), true); + acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => altColor((p.extra || {}).alt_baro), true, 'ac'); document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString(); addExtraAttrib('ADSB.lol ODbL'); } catch (e) { @@ -2387,7 +2451,7 @@ async function loadTrains() { const r = await fetch(`${API}/api/trains?bbox=${currentBBox()}`); const pts = await r.json(); if (req !== overlayReq.trains) return; - trainsGroup = renderPoints(trainsGroup, Array.isArray(pts) ? pts : [], p => (p.extra || {}).iconColor || '#c084fc', false); + trainsGroup = renderPoints(trainsGroup, Array.isArray(pts) ? pts : [], p => (p.extra || {}).iconColor || '#c084fc', false, 'train'); document.getElementById('lp-trains-count').textContent = (pts.length || 0).toLocaleString(); addExtraAttrib('Amtraker'); } catch (e) { @@ -2414,7 +2478,7 @@ async function loadVessels() { vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => { const sog = Number((p.speed) || 0); return sog > 0.5 ? '#2dd4bf' : '#64748b'; - }, true); + }, true, 'vessel'); document.getElementById('lp-vessels-count').textContent = (pts.length || 0).toLocaleString(); addExtraAttrib('AISStream'); } catch (e) {