Merge: toggleable map layers + Shell stations overlay
This commit is contained in:
commit
1532ef6ae0
3 changed files with 112 additions and 1 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
109
index.html
109
index.html
|
|
@ -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
|
||||||
|
|
|
||||||
1
layers/shell_stations.geojson
Normal file
1
layers/shell_stations.geojson
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue