Compare commits
5 commits
feat/map-l
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6d5a19273 | ||
|
|
4eef23677b | ||
|
|
58525ec73d | ||
|
|
18501f00d3 | ||
|
|
1532ef6ae0 |
2 changed files with 65 additions and 14 deletions
64
README.md
64
README.md
|
|
@ -3,13 +3,17 @@
|
||||||
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 — **feature-frozen 2026-06-07** (two-tier bottom dock + clustering
|
> **Status:** v2 — live at <https://fleetnow.rahamafresh.com>. Deployed from this
|
||||||
> + tracker/camera dedup). Live at <https://fleetnow.rahamafresh.com>. Deployed
|
> repo via Coolify (Dockerfile → nginx). Reads the `dashboard_api` read-API at
|
||||||
> from this repo via Coolify (Dockerfile → nginx). Reads the `dashboard_api`
|
> `fleetapi.rahamafresh.com`.
|
||||||
> read-API at `fleetapi.rahamafresh.com`.
|
|
||||||
>
|
>
|
||||||
> Deploy is **manual** — push, then hit Redeploy in Coolify (no auto-deploy
|
> **2026-06-08 additions:** fleet segmentation (specialist vehicle icons that
|
||||||
> webhook wired yet).
|
> 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 (~2–3 min build); if it
|
||||||
|
> lags, hit Redeploy in Coolify.
|
||||||
|
|
||||||
## What it does
|
## 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
|
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.
|
||||||
|
|
@ -64,9 +77,10 @@ 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
|
||||||
Dockerfile → bakes index.html into an nginx:alpine image (port 80)
|
layers/*.geojson → static overlay data (gas stations, …), served at /layers/
|
||||||
nginx.conf → static serve + /healthz + no-cache on index.html
|
Dockerfile → bakes index.html + layers/ into an nginx:alpine image (port 80)
|
||||||
|
nginx.conf → static serve + /healthz + no-cache on index.html
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend it depends on
|
### Backend it depends on
|
||||||
|
|
@ -86,6 +100,38 @@ 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
|
||||||
|
|
|
||||||
15
index.html
15
index.html
|
|
@ -635,6 +635,7 @@ const OVERLAYS = [
|
||||||
iconSvg: SHELL_ICON_SVG, nameKey: 'name', defaultOn: false },
|
iconSvg: SHELL_ICON_SVG, nameKey: 'name', defaultOn: false },
|
||||||
// future layers: add { id, label, url, iconSvg, nameKey, defaultOn } here.
|
// 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) {
|
function registerOverlayIcon(def) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
|
|
@ -667,14 +668,18 @@ async function addOverlay(def) {
|
||||||
'visibility': def.defaultOn ? 'visible' : 'none',
|
'visibility': def.defaultOn ? 'visible' : 'none',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
map.on('click', lyrId, e => {
|
// Hover (not click) shows a single label — one reused popup, so only ever
|
||||||
const p = (e.features[0] && e.features[0].properties) || {};
|
// one is visible; mousemove keeps it on whichever station is under the cursor.
|
||||||
popup.setLngLat(e.lngLat)
|
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>`)
|
.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);
|
.addTo(map);
|
||||||
});
|
});
|
||||||
map.on('mouseenter', lyrId, () => { map.getCanvas().style.cursor = 'pointer'; });
|
map.on('mouseleave', lyrId, () => { map.getCanvas().style.cursor = ''; if (overlayPopup) overlayPopup.remove(); });
|
||||||
map.on('mouseleave', lyrId, () => { map.getCanvas().style.cursor = ''; });
|
|
||||||
}
|
}
|
||||||
buildLayersControl();
|
buildLayersControl();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue