Compare commits

..

11 commits

Author SHA1 Message Date
david kiania
a6d5a19273 Merge: document map-overlay layers + recent features 2026-06-08 22:01:54 +03:00
david kiania
4eef23677b docs: document map-overlay layers + recent map features
README: add a "Map overlay layers" section (how the toggleable layer system
works + how to add a layer in ~2 min), document fleet segmentation / department
colours / legend / POIs, refresh the file tree (layers/), and correct the deploy
note (Coolify auto-deploys on push to main).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:01:52 +03:00
david kiania
58525ec73d Merge: overlay hover label (single popup) 2026-06-08 21:53:09 +03:00
david kiania
18501f00d3 feat(map): show overlay station label on hover, single popup
Switch the Shell layer from click→popup to hover: mousemove shows one reused
popup that follows whichever station is under the cursor, mouseleave clears it
— so only ever one label is visible. No click needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:53:07 +03:00
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
david kiania
09e12a7a58 Merge: add Safaricom HQ POI (Waiyaki Way) 2026-06-08 17:06:47 +03:00
david kiania
b25272cff3 feat(map): add Safaricom HQ (Waiyaki Way) as a POI
Second persistent POI marker alongside Fireside HQ, at Safaricom House on
Waiyaki Way (-1.2589726, 36.7854625, geocoded via OpenStreetMap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:06:45 +03:00
david kiania
b678f1ecaa Merge: exempt specialist vehicles from clustering 2026-06-08 15:59:49 +03:00
david kiania
4f25fae6c8 feat(map): exempt specialist vehicles from clustering
Crane/motorbike/pick-up are held out of the supercluster index and always
rendered as individual markers at every zoom, so they never fold into a
cluster bubble and always stand out. The rest of the fleet clusters as before.
KPIs and legend are unaffected (still computed from the full filtered set).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:59:47 +03:00
david kiania
fc5a7ed31b Merge: coordinated cost-centre colours + collapsible legend 2026-06-08 15:10:14 +03:00
4 changed files with 194 additions and 12 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).
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
# Coolify reads this; also handy for `docker ps` health.

View file

@ -3,13 +3,17 @@
A single-file map console that **merges live vehicle positions and historical
trips** into one view for the Fireside Communications / Tracksolid fleet.
> **Status:** v2 — **feature-frozen 2026-06-07** (two-tier bottom dock + clustering
> + tracker/camera dedup). Live at <https://fleetnow.rahamafresh.com>. Deployed
> from this repo via Coolify (Dockerfile → nginx). Reads the `dashboard_api`
> read-API at `fleetapi.rahamafresh.com`.
> **Status:** v2 — live at <https://fleetnow.rahamafresh.com>. Deployed from this
> repo via Coolify (Dockerfile → nginx). Reads the `dashboard_api` read-API at
> `fleetapi.rahamafresh.com`.
>
> Deploy is **manual** — push, then hit Redeploy in Coolify (no auto-deploy
> webhook wired yet).
> **2026-06-08 additions:** fleet segmentation (specialist vehicle icons that
> never cluster), per-department colour coordination + collapsible **Key** legend,
> persistent POIs (Fireside HQ, Safaricom HQ), and a **toggleable map-overlay
> layer system** (first layer: 232 Shell fuel stations). See *Map overlay layers*.
>
> **Deploy auto-fires on push to `main`** via Coolify (~23 min build); if it
> lags, hit Redeploy in Coolify.
## What it does
@ -30,6 +34,15 @@ trips** into one view for the Fireside Communications / Tracksolid fleet.
Leaflet.MarkerCluster style, via `supercluster`); click a bubble to zoom and
expand it. Clusters disband into individual pins at ~city zoom (z11). Clustering
honours the active filter and applies to the live view only (not trip routes).
- **Fleet segmentation & department colours.** Specialist vehicles (crane /
motorbike / pick-up) get their own marker icons and are **never clustered**
(always individually visible); every cost centre has a fixed, distinct colour,
with a collapsible **Key** legend (bottom-left) listing only the centres on
screen. Non-operational vehicles (personal / management / Uganda-MTN) are
filtered out upstream in the read-API, so the live map shows the operational
fleet only.
- **Persistent POIs** — Fireside HQ and Safaricom HQ (Waiyaki Way) as labelled
reference markers.
- **Filters** (bottom-right card) apply to the live map *instantly*:
- **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its
cost centre + city.
@ -65,7 +78,8 @@ the existing dashboard read-API — it does **not** talk to the database directl
```
index.html → the entire SPA
Dockerfile → bakes index.html into an nginx:alpine image (port 80)
layers/*.geojson → static overlay data (gas stations, …), served at /layers/
Dockerfile → bakes index.html + layers/ into an nginx:alpine image (port 80)
nginx.conf → static serve + /healthz + no-cache on index.html
```
@ -86,6 +100,38 @@ exposes:
(`DASHBOARD_CORS_ORIGINS`). It is in the code default; make sure the deployed
`dashboard_api` container's env includes it, then restart that container.
## Map overlay layers
Toggleable reference overlays (gas stations, etc.) sit behind the **Layers**
control (top-right, collapsed, all **off** by default). Each is a static GeoJSON
in `layers/`, rendered as a MapLibre **symbol** layer that **auto-declutters**
(`icon-allow-overlap: false` — sparse when zoomed out, all points reveal as you
zoom in), with a zoom-scaled icon (~8→16 px) and a **hover** label (one reused
popup, so only ever one is visible). Overlays render *under* the vehicle markers.
Shipped layers:
| Layer | Data | Points |
|---|---|---|
| Shell stations | `layers/shell_stations.geojson` (OSM `kenya-260605`) | 232 |
**To add a layer (≈2 min):**
1. Drop its point GeoJSON in `layers/<name>.geojson`.
2. Add one entry to the `OVERLAYS` array near the top of the `<script>` in
`index.html`:
```js
{ id: '<name>', label: '<Label>', url: 'layers/<name>.geojson',
iconSvg: <40×40 SVG string>, nameKey: 'name', defaultOn: false }
```
`iconSvg` is registered as the marker image (reuse `SHELL_ICON_SVG` as a
template). Nothing else to wire — `addOverlay()` builds the source + symbol
layer, and the Layers control lists it automatically.
3. Commit + push. The `Dockerfile` already `COPY`s `layers/` into nginx.
> The Shell layer was extracted from a Kenya OSM `.pbf` — the reproducible
> workflow (filter `amenity=fuel`, `brand=Shell`) lives in the `tracksolid` repo:
> `scripts/export_osm_pois.py` + `docs/OSM_POI_EXPORT.md`.
## Deploy (Coolify, git-based)
1. In Coolify, create a new **Application** from this git repo

View file

@ -296,6 +296,23 @@
.legend-lbl { flex: 1; text-transform: uppercase; letter-spacing: .02em; }
.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) ───────────────────────────── */
/* Cluster bubble (zoomed-out): amber circle + white count, tiered by size.
Click zooms to expand into the individual pins. */
@ -404,6 +421,11 @@
<button type="button" class="legend-toggle" id="legend-toggle">Key</button>
<div class="legend-body" id="legend-body"></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>
<!-- Bottom dock: two tiers — filter tier (top) + trip-card tier (beneath) -->
@ -478,7 +500,10 @@ const POLL_INTERVAL_MS = 15000;
const EAST_AFRICA = { center: [37.5, -3.0], zoom: 5.2 };
const STALE_GPS_MS = 10 * 60 * 1000;
const OFFLINE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
const POIS = [{ name: 'Fireside HQ', lng: 36.728785, lat: -1.2411485 }];
const POIS = [
{ name: 'Fireside HQ', lng: 36.728785, lat: -1.2411485 },
{ name: 'Safaricom HQ', lng: 36.7854625, lat: -1.2589726 }, // Safaricom House, Waiyaki Way (OSM)
];
// Deliberate, distinct colour per cost centre so all vehicles in a centre share
// one colour and different centres are easy to tell apart at a glance. Keys are
@ -539,6 +564,7 @@ const liveMarkers = new Map(); // imei → maplibregl.Marker (individual vehicl
const clusterMarkers = new Map(); // cluster_id → maplibregl.Marker (count bubbles)
let liveFeatures = []; // deduped (one device per vehicle) — see dedupeLiveFeatures
let cluster = null; // Supercluster index of the currently-filtered fleet
let liveSpecialists = []; // crane/motorbike/pick-up — drawn individually, never clustered
const CLUSTER_RADIUS = 60; // px cluster radius
const CLUSTER_MAXZOOM = 11; // above this, clusters disband into individual pins (~city zoom)
const VEHICLE_META = new Map(); // plate → {cost_centre, assigned_city}
@ -571,7 +597,7 @@ function ensureMap() {
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, offset: 20 });
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);
// Re-cluster after any pan/zoom settles (live mode only).
map.on('moveend', () => { if (mode === 'live') renderClusters(); });
@ -590,6 +616,93 @@ function addPoiMarker(poi) {
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.
];
let overlayPopup = null; // single reused hover popup for overlay points (only one ever shown)
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',
},
});
// Hover (not click) shows a single label — one reused popup, so only ever
// one is visible; mousemove keeps it on whichever station is under the cursor.
map.on('mouseenter', lyrId, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mousemove', lyrId, e => {
const f = e.features[0]; if (!f) return;
const p = f.properties || {};
if (!overlayPopup) overlayPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 12 });
overlayPopup.setLngLat(f.geometry.coordinates)
.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('mouseleave', lyrId, () => { map.getCanvas().style.cursor = ''; if (overlayPopup) overlayPopup.remove(); });
}
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)
// ============================================================================
@ -735,12 +848,22 @@ function filteredLiveFeatures() {
});
}
// A vehicle is exempt from clustering exactly when it carries a specialist icon
// (crane / motorbike / pick-up) — keyed off the feed's vehicle_type / fleet_segment.
function isSpecialist(p) {
return !!(p && (SPECIALIST_ICONS[p.vehicle_type] || p.fleet_segment === 'specialist'));
}
// Load the filtered fleet into supercluster, redraw bubbles+pins, recompute KPIs.
function applyLiveFilters() {
if (mode !== 'live') return;
const filtered = filteredLiveFeatures();
// Specialists are never clustered — they always render as individual icons so
// they stand out. Only the rest of the fleet feeds supercluster.
liveSpecialists = filtered.filter(f => isSpecialist(f.properties));
const clusterable = filtered.filter(f => !isSpecialist(f.properties));
cluster = new Supercluster({ radius: CLUSTER_RADIUS, maxZoom: CLUSTER_MAXZOOM });
cluster.load(filtered.map(f => ({
cluster.load(clusterable.map(f => ({
type: 'Feature',
properties: { ...f.properties },
geometry: { type: 'Point', coordinates: f.geometry.coordinates },
@ -799,6 +922,12 @@ function renderClusters() {
upsertLiveMarker(it.properties, c, it);
}
});
// Specialists: always drawn individually, at every zoom (never clustered).
liveSpecialists.forEach(f => {
if (!Array.isArray(f.geometry && f.geometry.coordinates)) return;
seenVeh.add(f.properties.imei);
upsertLiveMarker(f.properties, f.geometry.coordinates, f);
});
for (const [imei, m] of liveMarkers) { if (!seenVeh.has(imei)) { m.remove(); liveMarkers.delete(imei); } }
for (const [id, m] of clusterMarkers) { if (!seenClu.has(id)) { m.remove(); clusterMarkers.delete(id); } }
// If the popped-open vehicle got absorbed into a cluster, close its popup.
@ -1463,6 +1592,9 @@ document.getElementById('live-pill').addEventListener('click', backToLive);
document.getElementById('legend-toggle').addEventListener('click', () => {
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

File diff suppressed because one or more lines are too long