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);