Compare commits
2 commits
cc03778a87
...
dd7a09aadd
| Author | SHA1 | Date | |
|---|---|---|---|
| dd7a09aadd | |||
|
|
ce5207e060 |
1 changed files with 76 additions and 12 deletions
|
|
@ -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 => `<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>`;
|
||||
}
|
||||
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);
|
||||
const zoom = map.getZoom();
|
||||
const useCluster = cluster && (zoom < 7 || points.length > 200);
|
||||
|
|
@ -2204,7 +2261,13 @@ 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 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,
|
||||
|
|
@ -2212,6 +2275,7 @@ function renderPoints(existing, points, colorFn, cluster) {
|
|||
}).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('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> 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('<a href="https://amtraker.com/about">Amtraker</a>');
|
||||
} 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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue