Compare commits

..

No commits in common. "main" and "feat/specialist-no-cluster" have entirely different histories.

4 changed files with 11 additions and 176 deletions

View file

@ -11,9 +11,6 @@ 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

@ -3,17 +3,13 @@
A single-file map console that **merges live vehicle positions and historical A single-file map console that **merges live vehicle positions and historical
trips** into one view for the Fireside Communications / Tracksolid fleet. trips** into one view for the Fireside Communications / Tracksolid fleet.
> **Status:** v2 — live at <https://fleetnow.rahamafresh.com>. Deployed from this > **Status:** v2 — **feature-frozen 2026-06-07** (two-tier bottom dock + clustering
> repo via Coolify (Dockerfile → nginx). Reads the `dashboard_api` read-API at > + tracker/camera dedup). Live at <https://fleetnow.rahamafresh.com>. Deployed
> `fleetapi.rahamafresh.com`. > from this repo via Coolify (Dockerfile → nginx). Reads the `dashboard_api`
> read-API at `fleetapi.rahamafresh.com`.
> >
> **2026-06-08 additions:** fleet segmentation (specialist vehicle icons that > Deploy is **manual** — push, then hit Redeploy in Coolify (no auto-deploy
> never cluster), per-department colour coordination + collapsible **Key** legend, > webhook wired yet).
> 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 ## What it does
@ -34,15 +30,6 @@ trips** into one view for the Fireside Communications / Tracksolid fleet.
Leaflet.MarkerCluster style, via `supercluster`); click a bubble to zoom and Leaflet.MarkerCluster style, via `supercluster`); click a bubble to zoom and
expand it. Clusters disband into individual pins at ~city zoom (z11). Clustering 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). 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*: - **Filters** (bottom-right card) apply to the live map *instantly*:
- **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its - **Number plate** — multi-select, sorted A→Z; picking a vehicle auto-fills its
cost centre + city. cost centre + city.
@ -77,10 +64,9 @@ GL JS loaded from a CDN). It has no build step and no local assets. It reads fro
the existing dashboard read-API — it does **not** talk to the database directly. the existing dashboard read-API — it does **not** talk to the database directly.
``` ```
index.html → the entire SPA index.html → the entire SPA
layers/*.geojson → static overlay data (gas stations, …), served at /layers/ Dockerfile → bakes index.html into an nginx:alpine image (port 80)
Dockerfile → bakes index.html + layers/ into an nginx:alpine image (port 80) nginx.conf → static serve + /healthz + no-cache on index.html
nginx.conf → static serve + /healthz + no-cache on index.html
``` ```
### Backend it depends on ### Backend it depends on
@ -100,38 +86,6 @@ exposes:
(`DASHBOARD_CORS_ORIGINS`). It is in the code default; make sure the deployed (`DASHBOARD_CORS_ORIGINS`). It is in the code default; make sure the deployed
`dashboard_api` container's env includes it, then restart that container. `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) ## Deploy (Coolify, git-based)
1. In Coolify, create a new **Application** from this git repo 1. In Coolify, create a new **Application** from this git repo

View file

@ -296,23 +296,6 @@
.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. */
@ -421,11 +404,6 @@
<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) -->
@ -500,10 +478,7 @@ const POLL_INTERVAL_MS = 15000;
const EAST_AFRICA = { center: [37.5, -3.0], zoom: 5.2 }; const EAST_AFRICA = { center: [37.5, -3.0], zoom: 5.2 };
const STALE_GPS_MS = 10 * 60 * 1000; const STALE_GPS_MS = 10 * 60 * 1000;
const OFFLINE_THRESHOLD_MS = 24 * 60 * 60 * 1000; const OFFLINE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
const POIS = [ const POIS = [{ name: 'Fireside HQ', lng: 36.728785, lat: -1.2411485 }];
{ 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 // 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 // one colour and different centres are easy to tell apart at a glance. Keys are
@ -597,7 +572,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); OVERLAYS.forEach(addOverlay); buildLayersControl(); updateVehScale(); }); map.on('load', () => { POIS.forEach(addPoiMarker); 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(); });
@ -616,93 +591,6 @@ 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.
];
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) // Vehicle state (tri-state)
// ============================================================================ // ============================================================================
@ -1592,9 +1480,6 @@ 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