Compare commits
No commits in common. "main" and "feat/poi-safaricom-hq" have entirely different histories.
main
...
feat/poi-s
4 changed files with 10 additions and 172 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
60
README.md
60
README.md
|
|
@ -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 (~2–3 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.
|
||||||
|
|
@ -78,8 +65,7 @@ the existing dashboard read-API — it does **not** talk to the database directl
|
||||||
|
|
||||||
```
|
```
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
114
index.html
114
index.html
|
|
@ -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) -->
|
||||||
|
|
@ -597,7 +575,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 +594,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 +1483,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
Loading…
Reference in a new issue