`).join('');
}
/* ---------- filters (multi-select dropdowns) ---------- */
/**
* Wire the cost-centre + assigned-city filter widgets.
*
* Returns an `updateOptions(features)` callback that the caller invokes
* after each /api/views/live refresh — the widgets repopulate from the
* loaded feature properties, so we don't need a separate enum endpoint.
*
* The widget itself is a button that toggles a popover. The popover has
* an "All …" checkbox at the top and a checkbox per distinct value.
* `cost_centre_color` from the feature is rendered as a swatch beside the
* cost-centre options — turning the filter into a live colour legend.
*
* onChange receives a flat filter object compatible with serve.fn_live_view:
* { cost_centre: 'isp', assigned_city: 'Nairobi' } ← single select OR
* no key ← when "All" picked
*
* Multiple selections within one widget collapse to the first value (the
* SQL function takes one) — we treat additional selections client-side
* 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) {
const ccWidget = _buildMultiSelect(
root.querySelector('#flt-cost-centre'),
{ label: 'cost centre', plural: 'cost centres', showSwatch: true },
);
const cityWidget = _buildMultiSelect(
root.querySelector('#flt-assigned-city'),
{ label: 'assigned city', plural: 'cities', showSwatch: false },
);
const emit = () => {
const filters = {};
const cc = ccWidget.getValues();
const city = cityWidget.getValues();
if (cc.length === 1) filters.cost_centre = cc[0];
if (city.length === 1) filters.assigned_city = city[0];
// For multi-selection client-side narrowing, stash so renderer can filter
onChange(filters, { costCentres: cc, cities: city });
};
ccWidget.onChange(emit);
cityWidget.onChange(emit);
return {
updateOptions(features) {
const cc = new Map();
const city = new Set();
for (const f of features) {
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);
}
ccWidget.setOptions([...cc.entries()].sort()
.map(([value, color]) => ({ value, color })));
cityWidget.setOptions([...city].sort()
.map(value => ({ value })));
},
getActive() {
return { costCentres: ccWidget.getValues(), cities: cityWidget.getValues() };
},
};
}
function _buildMultiSelect(root, { label, plural, showSwatch }) {
// root is a
we own. Render: a button + a hidden popover.
root.classList.add('ms');
root.innerHTML = `
`;
const btn = root.querySelector('.ms-btn');
const btnLabel = root.querySelector('.ms-btn-label');
const pop = root.querySelector('.ms-pop');
const allBox = root.querySelector('.ms-all');
const optsRoot = root.querySelector('.ms-options');
const listeners = [];
let options = []; // [{value, color?}]
const updateLabel = () => {
const checked = [...optsRoot.querySelectorAll('input:checked')];
if (checked.length === 0 || checked.length === options.length) {
btnLabel.textContent = `All ${plural}`;
allBox.checked = true;
} else if (checked.length === 1) {
btnLabel.textContent = checked[0].value;
allBox.checked = false;
} else {
btnLabel.textContent = `${checked.length} ${plural}`;
allBox.checked = false;
}
};
const fire = () => listeners.forEach(fn => fn());
btn.addEventListener('click', (e) => {
e.stopPropagation();
const open = pop.hasAttribute('hidden');
if (open) {
pop.removeAttribute('hidden');
btn.setAttribute('aria-expanded', 'true');
} else {
pop.setAttribute('hidden', '');
btn.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('click', (e) => {
if (!root.contains(e.target) && !pop.hasAttribute('hidden')) {
pop.setAttribute('hidden', '');
btn.setAttribute('aria-expanded', 'false');
}
});
allBox.addEventListener('change', () => {
const checked = allBox.checked;
optsRoot.querySelectorAll('input').forEach(cb => { cb.checked = checked; });
if (!checked) allBox.checked = false; // "All" un-check = clear
updateLabel();
fire();
});
return {
setOptions(opts) {
options = opts;
// Preserve current selections by value when re-rendering
const prevChecked = new Set(
[...optsRoot.querySelectorAll('input:checked')].map(cb => cb.value),
);
const wasAll = prevChecked.size === 0 || allBox.checked;
optsRoot.innerHTML = opts.map(({ value, color }) => `
`).join('');
optsRoot.querySelectorAll('input').forEach(cb => {
cb.addEventListener('change', () => { updateLabel(); fire(); });
});
updateLabel();
},
getValues() {
const checked = [...optsRoot.querySelectorAll('input:checked')];
if (checked.length === 0 || checked.length === options.length) return [];
return checked.map(cb => cb.value);
},
onChange(fn) { listeners.push(fn); },
};
}
/**
* 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
* a subset (the SQL accepts only single-value filters in P1).
*
* Note: this hides via MapLibre `setFilter` so the source data is intact —
* counts in the FLEET NOW tiles still reflect everything fetched.
*/
export function applyClientFilter(map, { costCentres = [], cities = [] } = {}) {
const layers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label'];
const conds = [];
if (costCentres.length > 0 && costCentres.length > 1) {
conds.push(['in', ['get', 'cost_centre'], ['literal', costCentres]]);
}
if (cities.length > 0 && cities.length > 1) {
conds.push(['in', ['get', 'assigned_city'], ['literal', cities]]);
}
const filter = conds.length === 0 ? null
: conds.length === 1 ? conds[0]
: ['all', ...conds];
// Preserve the show_arrow filter on the arrow layer
for (const id of layers) {
if (!map.getLayer(id)) continue;
if (id === 'vehicles-arrow') {
const base = ['==', ['get', 'show_arrow'], true];
map.setFilter(id, filter ? ['all', base, filter] : base);
} else {
map.setFilter(id, filter);
}
}
}
/* ---------- trip panel ---------- */
const TRIP_ANIM_MS = 10000;
// Single-trip animation overlays (used in single-vehicle mode only).
const TRIP_PATH_SOURCE = 'trip-path-source';
const TRIP_MARKER_SOURCE = 'trip-marker-source';
const TRIP_PATH_LAYER = 'trip-path-line';
const TRIP_MARKER_LAYER = 'trip-marker-dot';
// Distinct palette for selected vehicles in multi-mode (kept separate from
// cost-centre tints so a multi-comparison reads cleanly even when picked
// vehicles share a cost centre).
const SELECTION_PALETTE = [
'#10b981', // emerald (single-mode default)
'#3b82f6', // blue
'#ef4444', // red
'#f59e0b', // amber
'#a855f7', // purple
'#06b6d4', // cyan
'#ec4899', // pink
'#84cc16', // lime
];
let _tripAnimRAF = null;
// vehicleId → { plate, driver, color, payload | null }
const _selection = new Map();
let _currentDate = null;
function _nextColor() {
const used = new Set([..._selection.values()].map(v => v.color));
for (const c of SELECTION_PALETTE) {
if (!used.has(c)) return c;
}
return SELECTION_PALETTE[_selection.size % SELECTION_PALETTE.length];
}
export function initTripPanel(map, panelRoot) {
const els = {
plate: panelRoot.querySelector('#trip-plate'),
driver: panelRoot.querySelector('#trip-driver'),
date: panelRoot.querySelector('#trip-date'),
csv: panelRoot.querySelector('#trip-csv'),
totals: panelRoot.querySelector('#trip-totals'),
list: panelRoot.querySelector('#trip-list'),
close: panelRoot.querySelector('#trip-close'),
};
els.close.addEventListener('click', () => _closeTripPanel(map, panelRoot, els));
els.date.addEventListener('change', async () => {
_currentDate = els.date.value;
// Re-fetch every currently-selected vehicle for the new date.
for (const vid of [..._selection.keys()]) {
await _fetchAndDraw(map, vid);
}
_renderDock(map, els);
});
els.csv.addEventListener('click', () => {
for (const vid of _selection.keys()) {
_downloadTripsCsv(vid, _currentDate);
}
});
const hoverLayers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label'];
map.on('click', async (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: hoverLayers });
if (!features || features.length === 0) return;
const f = features[0];
const vid = f.properties.vehicle_id;
if (!vid) return;
const plate = f.properties.plate || `Vehicle ${vid}`;
const driver = f.properties.driver_name || '';
const multi = e.originalEvent.metaKey || e.originalEvent.ctrlKey || e.originalEvent.shiftKey;
if (!els.date.value) els.date.value = _todayEat();
_currentDate = els.date.value;
panelRoot.classList.add('open');
panelRoot.setAttribute('aria-hidden', 'false');
if (multi) {
if (_selection.has(vid)) {
_removeVehicle(map, vid);
_renderDock(map, els);
} else {
_addVehicle(vid, plate, driver);
await _fetchAndDraw(map, vid);
_renderDock(map, els);
}
} else {
// Plain click → reset to this single vehicle
_clearSelection(map);
_addVehicle(vid, plate, driver);
await _fetchAndDraw(map, vid);
_renderDock(map, els);
}
});
}
function _addVehicle(vid, plate, driver) {
_selection.set(vid, { plate, driver, color: _nextColor(), payload: null });
}
function _removeVehicle(map, vid) {
_clearVehicleLayers(map, vid);
_selection.delete(vid);
}
function _clearSelection(map) {
for (const vid of [..._selection.keys()]) _clearVehicleLayers(map, vid);
_selection.clear();
_cancelTripAnim();
_clearSingleTripLayers(map);
}
async function _fetchAndDraw(map, vid) {
const entry = _selection.get(vid);
if (!entry) return;
try {
entry.payload = await apiFetch(
`/api/views/vehicle/${vid}/trips`,
{ params: { date: _currentDate } },
);
} catch (err) {
entry.payload = { error: err.message || String(err), trips: [] };
return;
}
// Draw this vehicle's all-day routes as a static overlay.
_drawVehicleDayPaths(map, vid, entry.payload, entry.color);
}
function _drawVehicleDayPaths(map, vid, payload, color) {
_clearVehicleLayers(map, vid);
const trips = (payload.trips || []).filter(t => t.path && t.path.coordinates);
if (trips.length === 0) return;
const features = trips.map(t => ({
type: 'Feature',
geometry: t.path,
properties: { trip_id: t.trip_id, vehicle_id: vid },
}));
const srcId = `vroute-${vid}`;
const layerId = `vroute-line-${vid}`;
map.addSource(srcId, {
type: 'geojson',
data: { type: 'FeatureCollection', features },
});
map.addLayer({
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': color,
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1.5, 14, 3, 17, 5],
'line-opacity': 0.85,
},
});
}
function _clearVehicleLayers(map, vid) {
const layerId = `vroute-line-${vid}`;
const srcId = `vroute-${vid}`;
if (map.getLayer(layerId)) map.removeLayer(layerId);
if (map.getSource(srcId)) map.removeSource(srcId);
}
function _renderDock(map, els) {
if (_selection.size === 0) {
els.plate.textContent = '—';
els.driver.textContent = '';
els.totals.innerHTML = 'Click a vehicle to see its trips.';
els.list.innerHTML = '';
return;
}
if (_selection.size === 1) {
_renderSingle(map, els);
} else {
_renderMulti(map, els);
}
// Fit map to union of all selected vehicles' route bounds
_fitSelectionBounds(map);
}
function _renderSingle(map, els) {
const [[vid, entry]] = _selection;
els.plate.textContent = entry.plate;
els.driver.textContent = entry.driver;
if (!entry.payload || entry.payload.error) {
els.totals.innerHTML = entry.payload?.error
? `${_esc(entry.payload.error)}`
: 'Loading…';
els.list.innerHTML = '';
return;
}
const payload = entry.payload;
const t = payload.totals || {};
const q = payload.data_quality || {};
const rep = payload.reporting_time ? _formatTimeOnly(payload.reporting_time) : '—';
els.totals.innerHTML = `