Distinct heading-aware marker symbols for live feeds (aircraft/trains/vessels) #3

Merged
sirius merged 1 commit from feat/distinct-live-markers into master 2026-08-27 20:50:05 -04:00

View file

@ -213,6 +213,19 @@
.leaflet-overlay-pane svg { filter: drop-shadow(0 0 3px rgba(0,0,0,0.6)); } .leaflet-overlay-pane svg { filter: drop-shadow(0 0 3px rgba(0,0,0,0.6)); }
.leaflet-div-icon { background: transparent; border: none; } .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) ──── */ /* ── Camera marker clusters (neon green, matches the dots) ──── */
.marker-cluster-small, .marker-cluster-medium, .marker-cluster-large { .marker-cluster-small, .marker-cluster-medium, .marker-cluster-large {
background-color: rgba(74, 222, 128, 0.22); background-color: rgba(74, 222, 128, 0.22);
@ -2194,7 +2207,51 @@ function pointPopup(p) {
.map(k => `<tr><td class="k">${esc(k)}</td><td>${esc(extra[k])}</td></tr>`).join(''); .map(k => `<tr><td class="k">${esc(k)}</td><td>${esc(extra[k])}</td></tr>`).join('');
return `<div class="cam-pop"><b>${esc(p.label || p.id)}</b><table>${rows}</table></div>`; return `<div class="cam-pop"><b>${esc(p.label || p.id)}</b><table>${rows}</table></div>`;
} }
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: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2L13.5 7L20 8.5V10.5L13.5 11L14.5 15L17 16.5V18L12 16.8L7 18V16.5L9.5 15L10.5 11L4 10.5V8.5L10.5 7L12 2Z"/></svg>',
train: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.5 3.5h15a1.5 1.5 0 0 1 1.5 1.5v10.5h-18V5a1.5 1.5 0 0 1 1.5-1.5ZM3 15.5h18l-1.5 2.5h-15ZM6.5 6.5h3.5v3.5H6.5ZM13 6.5h3.5v3.5H13ZM7 20.5h2v1H7ZM15 20.5h2v1h-2Z"/></svg>',
vessel: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2 18 13H6Z"/><path d="M12 4v9"/><path d="M3.5 13h17l-2.5 5h-12Z"/></svg>',
};
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: `<span class="hdg-marker"><span class="hdg-glyph hdg-${feed}" style="color:${color};${rot}">${FEED_GLYPHS[feed]}</span></span>`,
iconSize: [26, 26], iconAnchor: [13, 13],
});
feedIconCache.set(key, ic);
}
return ic;
}
function renderPoints(existing, points, colorFn, cluster, feed) {
if (existing) map.removeLayer(existing); if (existing) map.removeLayer(existing);
const zoom = map.getZoom(); const zoom = map.getZoom();
const useCluster = cluster && (zoom < 7 || points.length > 200); const useCluster = cluster && (zoom < 7 || points.length > 200);
@ -2204,14 +2261,21 @@ function renderPoints(existing, points, colorFn, cluster) {
const renderer = pointCanvas(); const renderer = pointCanvas();
points.forEach(p => { points.forEach(p => {
if (p.lat == null || p.lon == null) return; if (p.lat == null || p.lon == null) return;
const col = colorFn(p); const col = sanitizeColor(colorFn(p), '#35e0ff');
const heading = Number(p.heading); if (feed) {
const m = L.circleMarker([p.lat, p.lon], { const heading = Number(p.heading);
radius: 5, color: col, fillColor: col, fillOpacity: 0.9, weight: 1, const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading);
renderer, const m = L.marker([p.lat, p.lon], { icon }).bindPopup(pointPopup(p));
}).bindPopup(pointPopup(p)); group.addLayer(m);
if (!Number.isNaN(heading)) m.setStyle({ className: 'hdg' }); } else {
group.addLayer(m); 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); group.addTo(map);
return group; return group;
@ -2367,7 +2431,7 @@ async function loadAircraft() {
const r = await fetch(`${API}/api/aircraft?bbox=${currentBBox()}`); const r = await fetch(`${API}/api/aircraft?bbox=${currentBBox()}`);
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.ac) return; 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(); document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL'); addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
} catch (e) { } catch (e) {
@ -2387,7 +2451,7 @@ async function loadTrains() {
const r = await fetch(`${API}/api/trains?bbox=${currentBBox()}`); const r = await fetch(`${API}/api/trains?bbox=${currentBBox()}`);
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.trains) return; 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(); document.getElementById('lp-trains-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('<a href="https://amtraker.com/about">Amtraker</a>'); addExtraAttrib('<a href="https://amtraker.com/about">Amtraker</a>');
} catch (e) { } catch (e) {
@ -2414,7 +2478,7 @@ async function loadVessels() {
vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => { vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => {
const sog = Number((p.speed) || 0); const sog = Number((p.speed) || 0);
return sog > 0.5 ? '#2dd4bf' : '#64748b'; return sog > 0.5 ? '#2dd4bf' : '#64748b';
}, true); }, true, 'vessel');
document.getElementById('lp-vessels-count').textContent = (pts.length || 0).toLocaleString(); document.getElementById('lp-vessels-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('AISStream'); addExtraAttrib('AISStream');
} catch (e) { } catch (e) {