`).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_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';
const TRIP_ANIM_MS = 10000;
let _tripAnimRAF = null;
let _tripState = { vehicleId: null, date: null, payload: null };
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', () => {
if (_tripState.vehicleId) {
_loadTrips(map, panelRoot, els, _tripState.vehicleId, els.date.value);
}
});
els.csv.addEventListener('click', () => {
if (_tripState.vehicleId) _downloadTripsCsv(_tripState.vehicleId, els.date.value);
});
const layers = ['vehicles-circle', 'vehicles-arrow', 'vehicles-label'];
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers });
if (!features || features.length === 0) return;
const f = features[0];
const vid = f.properties.vehicle_id;
if (!vid) return;
els.plate.textContent = f.properties.plate || `Vehicle ${vid}`;
els.driver.textContent = f.properties.driver_name || '';
if (!els.date.value) els.date.value = _todayEat();
panelRoot.classList.add('open');
panelRoot.setAttribute('aria-hidden', 'false');
_loadTrips(map, panelRoot, els, vid, els.date.value);
});
}
async function _loadTrips(map, panelRoot, els, vehicleId, dateStr) {
_tripState.vehicleId = vehicleId;
_tripState.date = dateStr;
_tripState.payload = null;
_cancelTripAnim();
_clearTripLayers(map);
els.totals.innerHTML = 'Loading…';
els.list.innerHTML = '';
try {
const payload = await apiFetch(
`/api/views/vehicle/${vehicleId}/trips`,
{ params: { date: dateStr } },
);
_tripState.payload = payload;
_renderTripPanel(map, els, payload);
} catch (err) {
els.totals.innerHTML = `${_esc(err.message || err)}`;
}
}
function _renderTripPanel(map, els, payload) {
const t = payload.totals || {};
const q = payload.data_quality || {};
const reportingTime = payload.reporting_time
? _formatTimeOnly(payload.reporting_time) : '—';
els.totals.innerHTML = `