From 3bd9ee07cd4446321142aa7b21732705582a6a3c Mon Sep 17 00:00:00 2001 From: kianiadee Date: Fri, 29 May 2026 18:00:39 +0300 Subject: [PATCH] Top-bar vehicle finder: search -> match filters, fly, open trips Adds a searchable single-select vehicle/plate pulldown. Picking a vehicle sets the cost-centre & assigned-city filters to match, frames its day's route on the map, and opens its trip dock. Backed by a persistent session registry so every vehicle stays findable after a filter narrows the view. Co-Authored-By: Claude Opus 4.8 --- web/fleet-core.js | 214 +++++++++++++++++++++++++++++++++++++++++++- web/index-live.html | 32 +++++-- 2 files changed, 237 insertions(+), 9 deletions(-) diff --git a/web/fleet-core.js b/web/fleet-core.js index a4b014f..b3e717e 100644 --- a/web/fleet-core.js +++ b/web/fleet-core.js @@ -486,7 +486,7 @@ function _renderSummary(root, summary) { * as an OR by sending no filter and letting the renderer hide the rest. * That keeps the SQL contract unchanged for P1. */ -export function initFilters(root, onChange) { +export function initFilters(root, onChange, onVehiclePick) { const ccWidget = _buildMultiSelect( root.querySelector('#flt-cost-centre'), { label: 'cost centre', plural: 'cost centres', showSwatch: true }, @@ -509,6 +509,24 @@ export function initFilters(root, onChange) { ccWidget.onChange(emit); cityWidget.onChange(emit); + // Persistent fleet registry so the finder always lists every vehicle seen + // this session, even after a cost-centre/city filter narrows the live view. + const vehReg = new Map(); + const vehFinder = _buildVehicleSelect(root.querySelector('#flt-vehicle'), { + onSelect(meta) { + // Match the two filters to the vehicle, then emit once (single refresh). + ccWidget.setSelection(meta.cost_centre ? [meta.cost_centre] : []); + cityWidget.setSelection(meta.assigned_city ? [meta.assigned_city] : []); + emit(); + onVehiclePick && onVehiclePick(meta); + }, + onClear() { + ccWidget.setSelection([]); + cityWidget.setSelection([]); + emit(); + }, + }); + return { updateOptions(features) { const cc = new Map(); @@ -517,11 +535,26 @@ export function initFilters(root, onChange) { const p = f.properties || {}; if (p.cost_centre) cc.set(p.cost_centre, p.cost_centre_color || '#94a3b8'); if (p.assigned_city) city.add(p.assigned_city); + if (p.vehicle_id != null) { + const coords = (f.geometry && f.geometry.coordinates) || []; + vehReg.set(p.vehicle_id, { + vehicle_id: p.vehicle_id, + plate: p.plate, + driver_name: p.driver_name, + cost_centre: p.cost_centre, + assigned_city: p.assigned_city, + occurred_at: p.occurred_at, + lng: coords[0] ?? null, + lat: coords[1] ?? null, + }); + } } ccWidget.setOptions([...cc.entries()].sort() .map(([value, color]) => ({ value, color }))); cityWidget.setOptions([...city].sort() .map(value => ({ value }))); + vehFinder.setOptions([...vehReg.values()] + .sort((a, b) => (a.plate || '').localeCompare(b.plate || ''))); }, getActive() { return { costCentres: ccWidget.getValues(), cities: cityWidget.getValues() }; @@ -554,8 +587,18 @@ function _buildMultiSelect(root, { label, plural, showSwatch }) { const listeners = []; let options = []; // [{value, color?}] let lastSig = ''; // skip no-op rebuilds on the 15s live refresh + // Non-null = a selection forced programmatically (by the vehicle finder). + // Enforced across rebuilds until the user touches the widget themselves. + let _forced = null; const updateLabel = () => { + if (_forced && _forced.length) { + // A forced (vehicle-matched) selection stays labelled by its value even + // when the live view collapses the option list down to just that value. + btnLabel.textContent = _forced.length === 1 ? _forced[0] : `${_forced.length} ${plural}`; + allBox.checked = false; + return; + } const checked = [...optsRoot.querySelectorAll('input:checked')]; if (checked.length === 0 || checked.length === options.length) { btnLabel.textContent = `All ${plural}`; @@ -589,6 +632,7 @@ function _buildMultiSelect(root, { label, plural, showSwatch }) { }); allBox.addEventListener('change', () => { + _forced = null; // manual interaction takes back control const checked = allBox.checked; optsRoot.querySelectorAll('input').forEach(cb => { cb.checked = checked; }); if (!checked) allBox.checked = false; // "All" un-check = clear @@ -609,20 +653,39 @@ function _buildMultiSelect(root, { label, plural, showSwatch }) { const prevChecked = new Set( [...optsRoot.querySelectorAll('input:checked')].map(cb => cb.value), ); + const forced = _forced ? new Set(_forced) : null; const wasAll = prevChecked.size === 0 || allBox.checked; + const isChecked = (value) => forced + ? forced.has(value) + : (wasAll || prevChecked.has(value)); optsRoot.innerHTML = opts.map(({ value, color }) => ` `).join(''); optsRoot.querySelectorAll('input').forEach(cb => { - cb.addEventListener('change', () => { updateLabel(); fire(); }); + cb.addEventListener('change', () => { _forced = null; updateLabel(); fire(); }); + }); + updateLabel(); + }, + // Force a selection from outside (the vehicle finder). Pass [] to reset to + // "All". Updates the DOM + label now and is re-applied on later rebuilds; + // does not fire onChange — the caller emits once after setting both widgets. + setSelection(values) { + _forced = (values && values.length) ? [...values] : null; + const want = _forced ? new Set(_forced) : null; + optsRoot.querySelectorAll('input').forEach(cb => { + cb.checked = want ? want.has(cb.value) : true; }); updateLabel(); }, getValues() { + // Forced selection is authoritative until the user touches the widget, + // so the cost-centre/city filter survives even after the option list + // collapses to the single matched value on the next refresh. + if (_forced && _forced.length) return [..._forced]; const checked = [...optsRoot.querySelectorAll('input:checked')]; if (checked.length === 0 || checked.length === options.length) return []; return checked.map(cb => cb.value); @@ -631,6 +694,104 @@ function _buildMultiSelect(root, { label, plural, showSwatch }) { }; } +/** + * Single-select vehicle finder with a search box. Lists the whole known fleet + * (plate + driver/device name); typing narrows by plate or driver. Picking a + * row calls onSelect(meta); the "All vehicles" row calls onClear(). + * + * meta = { vehicle_id, plate, driver_name, cost_centre, assigned_city, + * occurred_at, lng, lat } — enough for the caller to set the matching + * cost-centre/city filters and fly to / open the vehicle. + */ +function _buildVehicleSelect(root, { onSelect, onClear }) { + root.classList.add('ms', 'ms-vehicle'); + root.innerHTML = ` + + + `; + const btn = root.querySelector('.ms-btn'); + const btnLabel = root.querySelector('.ms-btn-label'); + const pop = root.querySelector('.ms-pop'); + const search = root.querySelector('.ms-search'); + const allRow = root.querySelector('.ms-veh-all'); + const optsRoot = root.querySelector('.ms-options'); + + let options = []; // [{vehicle_id, plate, driver_name, ...meta}] + let selectedId = null; + + const renderList = (q) => { + const needle = (q || '').trim().toLowerCase(); + const rows = options.filter(o => !needle + || (o.plate || '').toLowerCase().includes(needle) + || (o.driver_name || '').toLowerCase().includes(needle)); + optsRoot.innerHTML = rows.length ? rows.map(o => ` +
+ ${_esc(o.plate || ('Vehicle ' + o.vehicle_id))} + ${o.driver_name ? `${_esc(o.driver_name)}` : ''} +
+ `).join('') : '
No match
'; + optsRoot.querySelectorAll('.ms-vehicle-row').forEach(el => { + el.addEventListener('click', () => { + const vid = Number(el.getAttribute('data-vid')); + const meta = options.find(o => o.vehicle_id === vid); + if (!meta) return; + selectedId = vid; + btnLabel.textContent = meta.plate || (`Vehicle ${vid}`); + close(); + onSelect && onSelect(meta); + }); + }); + }; + + const open = () => { + pop.removeAttribute('hidden'); + btn.setAttribute('aria-expanded', 'true'); + search.value = ''; + renderList(''); + search.focus(); + }; + const close = () => { + pop.setAttribute('hidden', ''); + btn.setAttribute('aria-expanded', 'false'); + }; + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + pop.hasAttribute('hidden') ? open() : close(); + }); + document.addEventListener('click', (e) => { + if (!root.contains(e.target) && !pop.hasAttribute('hidden')) close(); + }); + search.addEventListener('click', (e) => e.stopPropagation()); + search.addEventListener('input', () => renderList(search.value)); + allRow.addEventListener('click', () => { + selectedId = null; + btnLabel.textContent = 'Find vehicle…'; + close(); + onClear && onClear(); + }); + + return { + // Refresh the backing list. Don't rebuild the open popover mid-search; + // the next keystroke re-renders from the updated data. + setOptions(list) { + options = list; + if (selectedId != null) { + const m = list.find(o => o.vehicle_id === selectedId); + if (m) btnLabel.textContent = m.plate || (`Vehicle ${selectedId}`); + } + }, + getValue() { return selectedId; }, + }; +} + /** * Client-side narrowing: after rendering, hide markers whose cost_centre * or assigned_city isn't in the multi-select set. Used when the user picks @@ -790,6 +951,53 @@ export function initTripPanel(map, panelRoot) { _renderDock(map, els); } }); + + // Open a single vehicle programmatically (from the vehicle finder). Mirrors a + // plain map-click: jumps to the vehicle's last-active day unless the user has + // already picked a date, draws its day, and frames the route on the map. + async function openVehicle(vid, meta = {}) { + if (!_dateUserPicked) { + els.date.value = _eatDate(meta.occurred_at); + } else if (!els.date.value) { + els.date.value = _todayEat(); + } + _currentDate = els.date.value; + panelRoot.classList.add('open'); + panelRoot.setAttribute('aria-hidden', 'false'); + _clearSelection(map); + _addVehicle(vid, meta.plate || `Vehicle ${vid}`, meta.driver_name || ''); + await _fetchAndDraw(map, vid); + _renderDock(map, els); + _fitToVehicle(map, _selection.get(vid), meta); + } + + return { openVehicle }; +} + +// Frame a vehicle's day on the map: fit to its day-track/trip path when there +// is one, else fly to its live position. +function _fitToVehicle(map, entry, meta) { + let coords = []; + const p = entry && entry.payload; + if (p && p.day_track && Array.isArray(p.day_track.coordinates)) { + coords = p.day_track.coordinates; + } else if (p && Array.isArray(p.trips)) { + for (const t of p.trips) { + if (t.path && Array.isArray(t.path.coordinates)) { + coords = coords.concat(t.path.coordinates); + } + } + } + // eslint-disable-next-line no-undef + if (coords.length >= 2 && typeof maplibregl !== 'undefined') { + const bounds = coords.reduce( + // eslint-disable-next-line no-undef + (b, c) => b.extend(c), new maplibregl.LngLatBounds(coords[0], coords[0]), + ); + map.fitBounds(bounds, { padding: 80, maxZoom: 15, duration: 600 }); + } else if (meta && meta.lng != null && meta.lat != null) { + map.flyTo({ center: [meta.lng, meta.lat], zoom: Math.max(map.getZoom(), 14), duration: 600 }); + } } function _addVehicle(vid, plate, driver) { diff --git a/web/index-live.html b/web/index-live.html index 31dce63..3d8c494 100644 --- a/web/index-live.html +++ b/web/index-live.html @@ -90,6 +90,19 @@ } .ms-row-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + /* vehicle finder: single-select with a search box */ + .ms-vehicle { min-width: 210px; } + .ms-search { + width: 100%; box-sizing: border-box; margin-bottom: 6px; + background: var(--panel-2); color: var(--text); + border: 1px solid var(--panel-2); border-radius: 4px; + padding: 6px 8px; font-size: 12px; font-family: inherit; + } + .ms-search:focus { outline: none; border-color: var(--muted); } + .ms-vehicle-row { justify-content: space-between; } + .ms-row-sub { color: var(--muted); font-size: 11px; margin-left: 8px; flex-shrink: 0; } + .ms-empty { padding: 8px; color: var(--muted); font-size: 12px; } + /* ─────────── map (fills remaining height) ─────────── */ #map-container { position: relative; min-height: 0; } #map { position: absolute; inset: 0; } @@ -209,6 +222,7 @@
Filters
+
@@ -267,13 +281,19 @@ } } - const filters = initFilters(document.getElementById('filters'), (serverFilters, selection) => { - currentFilters = serverFilters; - activeSelection = selection; - refresh(); - }); + const tripApi = initTripPanel(map, document.getElementById('trip-panel')); - initTripPanel(map, document.getElementById('trip-panel')); + const filters = initFilters( + document.getElementById('filters'), + (serverFilters, selection) => { + currentFilters = serverFilters; + activeSelection = selection; + refresh(); + }, + // Vehicle picked from the finder → its cost-centre/city filters are set by + // initFilters; here we locate it on the map and open its trips. + (meta) => { tripApi.openVehicle(meta.vehicle_id, meta); }, + ); refresh(); setInterval(refresh, 15000);