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 ``;
}
-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) {