Compare commits

..

2 commits

Author SHA1 Message Date
david kiania
1532ef6ae0 Merge: toggleable map layers + Shell stations overlay 2026-06-08 21:46:32 +03:00
david kiania
25f49a0f27 feat(map): toggleable overlay layers + Shell fuel stations (232)
Add a data-driven overlay system: an OVERLAYS registry + generic addOverlay()
that renders each layer as a MapLibre symbol layer (auto-declutter via
icon-allow-overlap:false, ~8->16px zoom-scaled icon), plus a collapsible
"Layers" control to toggle each on/off (all OFF by default). First layer:
Shell stations from layers/shell_stations.geojson (232 pts, OSM kenya-260605),
11px Shell-yellow pump icon. Dockerfile now copies layers/ into nginx. Adding
the next layer = drop a .geojson + one registry entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:46:30 +03:00
3 changed files with 112 additions and 1 deletions

View file

@ -11,6 +11,9 @@ COPY nginx.conf /etc/nginx/conf.d/fleetnow.conf
# The whole app is one self-contained file (inline CSS/JS; MapLibre from a CDN). # The whole app is one self-contained file (inline CSS/JS; MapLibre from a CDN).
COPY index.html /usr/share/nginx/html/index.html COPY index.html /usr/share/nginx/html/index.html
# Static map-overlay data (toggleable layers: gas stations, etc.), served at /layers/.
COPY layers/ /usr/share/nginx/html/layers/
EXPOSE 80 EXPOSE 80
# Coolify reads this; also handy for `docker ps` health. # Coolify reads this; also handy for `docker ps` health.

View file

@ -296,6 +296,23 @@
.legend-lbl { flex: 1; text-transform: uppercase; letter-spacing: .02em; } .legend-lbl { flex: 1; text-transform: uppercase; letter-spacing: .02em; }
.legend-n { color: var(--muted); font-weight: 700; } .legend-n { color: var(--muted); font-weight: 700; }
/* ── Map layers control (toggleable overlays: gas stations, …) ──────── */
#layers { position: absolute; right: 10px; top: 10px; z-index: 5;
font: 600 11px system-ui; color: #fff; user-select: none; }
.layers-toggle { cursor: pointer; border: 1px solid var(--border);
background: rgba(15,18,23,.92); color: #fff; font: 600 11px system-ui;
padding: 4px 10px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,.45); }
.layers-toggle::before { content: '▣'; color: var(--accent); margin-right: 5px; }
.layers-body { margin-top: 6px; background: rgba(15,18,23,.92);
border: 1px solid var(--border); border-radius: 8px; padding: 7px 9px;
box-shadow: 0 4px 14px rgba(0,0,0,.5); display: grid; gap: 5px; min-width: 150px; }
#layers.collapsed .layers-body { display: none; }
.layers-row { display: flex; align-items: center; gap: 7px; cursor: pointer; }
.layers-row input { accent-color: var(--accent); margin: 0; }
.layers-n { margin-left: auto; color: var(--muted); font-weight: 700; }
.ov-pop b { color: #fff; }
.ov-pop .ov-sub { color: var(--muted); font-weight: 600; font-size: 10px; margin-top: 2px; }
/* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */ /* ── Persistent POI marker (Fireside HQ) ───────────────────────────── */
/* Cluster bubble (zoomed-out): amber circle + white count, tiered by size. /* Cluster bubble (zoomed-out): amber circle + white count, tiered by size.
Click zooms to expand into the individual pins. */ Click zooms to expand into the individual pins. */
@ -404,6 +421,11 @@
<button type="button" class="legend-toggle" id="legend-toggle">Key</button> <button type="button" class="legend-toggle" id="legend-toggle">Key</button>
<div class="legend-body" id="legend-body"></div> <div class="legend-body" id="legend-body"></div>
</div> </div>
<!-- Toggleable map overlays (gas stations, …); collapsed by default. -->
<div id="layers" class="collapsed" aria-label="Map layers">
<button type="button" class="layers-toggle" id="layers-toggle">Layers</button>
<div class="layers-body" id="layers-body"></div>
</div>
</div> </div>
<!-- Bottom dock: two tiers — filter tier (top) + trip-card tier (beneath) --> <!-- Bottom dock: two tiers — filter tier (top) + trip-card tier (beneath) -->
@ -575,7 +597,7 @@ function ensureMap() {
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, offset: 20 }); popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, offset: 20 });
popup.on('close', () => { openPopupImei = null; popupStuck = false; }); popup.on('close', () => { openPopupImei = null; popupStuck = false; });
map.on('load', () => { POIS.forEach(addPoiMarker); updateVehScale(); }); map.on('load', () => { POIS.forEach(addPoiMarker); OVERLAYS.forEach(addOverlay); buildLayersControl(); updateVehScale(); });
map.on('zoom', updateVehScale); map.on('zoom', updateVehScale);
// Re-cluster after any pan/zoom settles (live mode only). // Re-cluster after any pan/zoom settles (live mode only).
map.on('moveend', () => { if (mode === 'live') renderClusters(); }); map.on('moveend', () => { if (mode === 'live') renderClusters(); });
@ -594,6 +616,88 @@ function addPoiMarker(poi) {
new maplibregl.Marker({ element: el, anchor: 'bottom' }).setLngLat([poi.lng, poi.lat]).addTo(map); new maplibregl.Marker({ element: el, anchor: 'bottom' }).setLngLat([poi.lng, poi.lat]).addTo(map);
} }
// ============================================================================
// Toggleable map overlays (reference layers: gas stations, …)
// ----------------------------------------------------------------------------
// Each entry = a static GeoJSON in /layers/ rendered as a MapLibre SYMBOL layer
// that auto-declutters (icon-allow-overlap:false) and is OFF until toggled in
// the "Layers" control. To add another layer: drop its .geojson in layers/ and
// add one entry here (+ an iconSvg). Vehicle/cluster markers (DOM) stay on top.
// ============================================================================
const GAS_PUMP_PATH = 'M19.77 7.23l.01-.01-3.72-3.72L15 4.56l2.11 2.11c-.94.36-1.61 1.26-1.61 2.33 0 1.38 1.12 2.5 2.5 2.5.36 0 .69-.08 1-.21v7.21c0 .55-.45 1-1 1s-1-.45-1-1V14c0-1.1-.9-2-2-2h-1V5c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2v16h10v-7.5h1.5v5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V9c0-.69-.28-1.32-.73-1.77zM12 10H6V5h6v5z';
const SHELL_ICON_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">' +
'<circle cx="20" cy="20" r="18" fill="#FBCE07" stroke="#7a5f00" stroke-width="1.5"/>' +
'<g transform="translate(8,8)" fill="#b3121f"><path d="' + GAS_PUMP_PATH + '"/></g></svg>';
const OVERLAYS = [
{ id: 'shell', label: 'Shell stations', url: 'layers/shell_stations.geojson',
iconSvg: SHELL_ICON_SVG, nameKey: 'name', defaultOn: false },
// future layers: add { id, label, url, iconSvg, nameKey, defaultOn } here.
];
function registerOverlayIcon(def) {
return new Promise(resolve => {
const imgId = 'ov-icon-' + def.id;
if (map.hasImage(imgId)) return resolve(imgId);
const img = new Image(40, 40);
img.onload = () => { if (!map.hasImage(imgId)) map.addImage(imgId, img, { pixelRatio: 2 }); resolve(imgId); };
img.onerror = () => resolve(imgId);
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(def.iconSvg);
});
}
async function addOverlay(def) {
const srcId = 'ov-' + def.id, lyrId = 'ov-layer-' + def.id;
let data;
try { data = await (await fetch(def.url)).json(); }
catch (e) { console.warn('overlay load failed:', def.id, e); return; }
def._count = (data.features || []).length;
const imgId = await registerOverlayIcon(def);
if (!map.getSource(srcId)) map.addSource(srcId, { type: 'geojson', data });
if (!map.getLayer(lyrId)) {
map.addLayer({
id: lyrId, type: 'symbol', source: srcId,
layout: {
'icon-image': imgId,
// ~8px zoomed out → ~16px zoomed in (image is 20 CSS px at icon-size 1).
'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.42, 11, 0.55, 16, 0.8],
'icon-allow-overlap': false, // auto-declutter: hide overlapping icons at low zoom
'icon-ignore-placement': false,
'visibility': def.defaultOn ? 'visible' : 'none',
},
});
map.on('click', lyrId, e => {
const p = (e.features[0] && e.features[0].properties) || {};
popup.setLngLat(e.lngLat)
.setHTML(`<div class="ov-pop"><b>${escapeHtml(p[def.nameKey] || def.label)}</b><div class="ov-sub">${escapeHtml(p.brand || 'fuel station')}</div></div>`)
.addTo(map);
});
map.on('mouseenter', lyrId, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', lyrId, () => { map.getCanvas().style.cursor = ''; });
}
buildLayersControl();
}
function buildLayersControl() {
const body = document.getElementById('layers-body');
const wrap = document.getElementById('layers');
if (!body || !wrap) return;
wrap.style.display = OVERLAYS.length ? '' : 'none';
body.innerHTML = OVERLAYS.map(d => {
const lyrId = 'ov-layer-' + d.id;
const on = map.getLayer(lyrId) && map.getLayoutProperty(lyrId, 'visibility') === 'visible';
const n = d._count != null ? ` <span class="layers-n">${d._count}</span>` : '';
return `<label class="layers-row"><input type="checkbox" data-lyr="${lyrId}"${on ? ' checked' : ''}><span>${escapeHtml(d.label)}</span>${n}</label>`;
}).join('');
body.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => {
const lyrId = cb.getAttribute('data-lyr');
if (map.getLayer(lyrId)) map.setLayoutProperty(lyrId, 'visibility', cb.checked ? 'visible' : 'none');
});
});
}
// ============================================================================ // ============================================================================
// Vehicle state (tri-state) // Vehicle state (tri-state)
// ============================================================================ // ============================================================================
@ -1483,6 +1587,9 @@ document.getElementById('live-pill').addEventListener('click', backToLive);
document.getElementById('legend-toggle').addEventListener('click', () => { document.getElementById('legend-toggle').addEventListener('click', () => {
document.getElementById('legend').classList.toggle('collapsed'); document.getElementById('legend').classList.toggle('collapsed');
}); });
document.getElementById('layers-toggle').addEventListener('click', () => {
document.getElementById('layers').classList.toggle('collapsed');
});
// ============================================================================ // ============================================================================
// Reverse-geocoding (Nominatim) — queued, 1 req/sec, in-memory cache // Reverse-geocoding (Nominatim) — queued, 1 req/sec, in-memory cache

File diff suppressed because one or more lines are too long