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 <noreply@anthropic.com>
This commit is contained in:
parent
4632990143
commit
3bd9ee07cd
2 changed files with 237 additions and 9 deletions
|
|
@ -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 }) => `
|
||||
<label class="ms-row">
|
||||
<input type="checkbox" value="${_esc(value)}" ${wasAll || prevChecked.has(value) ? 'checked' : ''} />
|
||||
<input type="checkbox" value="${_esc(value)}" ${isChecked(value) ? 'checked' : ''} />
|
||||
${showSwatch && color ? `<span class="ms-swatch" style="background:${_esc(color)}"></span>` : ''}
|
||||
<span class="ms-row-label">${_esc(value)}</span>
|
||||
</label>
|
||||
`).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 = `
|
||||
<button type="button" class="ms-btn" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="ms-btn-label">Find vehicle…</span>
|
||||
<span class="ms-caret">▾</span>
|
||||
</button>
|
||||
<div class="ms-pop" role="listbox" hidden>
|
||||
<input type="text" class="ms-search" placeholder="Search plate or driver…" autocomplete="off" />
|
||||
<div class="ms-row ms-row-all ms-veh-all" role="option"><span>All vehicles</span></div>
|
||||
<div class="ms-options"></div>
|
||||
</div>
|
||||
`;
|
||||
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 => `
|
||||
<div class="ms-row ms-vehicle-row" role="option" data-vid="${_esc(String(o.vehicle_id))}">
|
||||
<span class="ms-row-label">${_esc(o.plate || ('Vehicle ' + o.vehicle_id))}</span>
|
||||
${o.driver_name ? `<span class="ms-row-sub">${_esc(o.driver_name)}</span>` : ''}
|
||||
</div>
|
||||
`).join('') : '<div class="ms-empty">No match</div>';
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<div class="band-block">
|
||||
<div class="band-title">Filters</div>
|
||||
<div class="band-row" id="filters">
|
||||
<div id="flt-vehicle"></div>
|
||||
<div id="flt-cost-centre"></div>
|
||||
<div id="flt-assigned-city"></div>
|
||||
</div>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue