merge: tickets map (live vehicles + INC/CRQ layers) into staging
This commit is contained in:
commit
6bfb72751f
1 changed files with 296 additions and 36 deletions
332
src/index.html
332
src/index.html
|
|
@ -21,6 +21,9 @@
|
||||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
|
||||||
<script src="/env.js"></script>
|
<script src="/env.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
<!-- MapLibre GL — Tickets tab map (FleetNow-style live map + INC/CRQ layers). -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
|
||||||
|
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
/* Shared with FleetNow (warm dark ops palette) */
|
/* Shared with FleetNow (warm dark ops palette) */
|
||||||
|
|
@ -175,6 +178,78 @@
|
||||||
.banner ul { margin: 6px 0 0; padding-left: 18px; }
|
.banner ul { margin: 6px 0 0; padding-left: 18px; }
|
||||||
.banner.error { background: var(--error-bg); border-color: rgba(239,91,91,.45); color: var(--danger); }
|
.banner.error { background: var(--error-bg); border-color: rgba(239,91,91,.45); color: var(--danger); }
|
||||||
.loading { opacity: .45; pointer-events: none; }
|
.loading { opacity: .45; pointer-events: none; }
|
||||||
|
|
||||||
|
/* ── Tickets map (FleetNow-style) ────────────────────────────────────── */
|
||||||
|
.map-wrap { position: relative; height: calc(100vh - 50px); }
|
||||||
|
#tk-map { position: absolute; inset: 0; }
|
||||||
|
.map-ctl { position: absolute; z-index: 5; font: 600 11px system-ui; color: #fff; user-select: none; }
|
||||||
|
#tk-layers { right: 10px; top: 10px; }
|
||||||
|
#tk-statusbar {
|
||||||
|
left: 10px; top: 10px; background: rgba(15,18,23,.92); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 5px 9px; box-shadow: 0 2px 8px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
#tk-statusbar label { display: flex; align-items: center; gap: 7px; color: var(--muted); }
|
||||||
|
#tk-statusbar select {
|
||||||
|
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
||||||
|
border-radius: 5px; padding: 4px 7px; font: 600 11px system-ui;
|
||||||
|
}
|
||||||
|
.layers-toggle {
|
||||||
|
cursor: pointer; border: 1px solid var(--border); background: rgba(15,18,23,.92);
|
||||||
|
color: #fff; font: 600 11px system-ui; padding: 4px 10px; border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
.layers-toggle::before { content: '▣'; color: var(--accent); margin-right: 5px; }
|
||||||
|
.layers-body {
|
||||||
|
margin-top: 6px; background: rgba(15,18,23,.92); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 7px 9px; box-shadow: 0 4px 14px rgba(0,0,0,.5);
|
||||||
|
display: grid; gap: 5px; min-width: 160px;
|
||||||
|
}
|
||||||
|
#tk-layers.collapsed .layers-body { display: none; }
|
||||||
|
.layers-row { display: flex; align-items: center; gap: 7px; cursor: pointer; }
|
||||||
|
.layers-row input { accent-color: var(--accent); margin: 0; }
|
||||||
|
.layers-row .legend-dot { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; border: 1px solid rgba(255,255,255,.5); }
|
||||||
|
.layers-n { margin-left: auto; color: var(--muted); font-weight: 700; }
|
||||||
|
|
||||||
|
/* Live vehicle DOM marker (ported from FleetNow) */
|
||||||
|
.veh-marker { cursor: pointer; will-change: transform; }
|
||||||
|
.veh-inner { position: relative; width: 32px; height: 32px; transform: scale(var(--veh-scale, 1)); transform-origin: center center; }
|
||||||
|
.veh-pin {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%; background: var(--c, var(--parked));
|
||||||
|
border: 2px solid rgba(255,255,255,.92); box-shadow: 0 2px 7px rgba(0,0,0,.5);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
}
|
||||||
|
.veh-arrow {
|
||||||
|
width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent;
|
||||||
|
border-bottom: 12px solid #fff; transform: rotate(var(--dir, 0deg));
|
||||||
|
filter: drop-shadow(0 0 1px rgba(0,0,0,.65));
|
||||||
|
}
|
||||||
|
.veh-pin .idle-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,.92); }
|
||||||
|
.veh-marker.parked .veh-pin { border-radius: 4px; transform: scale(0.5); transform-origin: center center; }
|
||||||
|
.veh-marker.offline .veh-pin { opacity: .5; border-color: rgba(255,255,255,.4); }
|
||||||
|
.veh-plate {
|
||||||
|
position: absolute; top: 33px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: rgba(15,18,23,.92); color: #fff; font: 600 10px system-ui;
|
||||||
|
padding: 1px 6px; border-radius: 4px; white-space: nowrap; border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.veh-marker.offline .veh-plate { color: var(--muted); }
|
||||||
|
|
||||||
|
/* MapLibre popup (warm, ported from FleetNow) */
|
||||||
|
.maplibregl-popup-content {
|
||||||
|
background: var(--panel) !important; color: var(--text) !important;
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; padding: 11px 13px !important;
|
||||||
|
font: 12px/1.45 system-ui; box-shadow: 0 6px 18px rgba(0,0,0,.55);
|
||||||
|
}
|
||||||
|
.maplibregl-popup-tip { border-top-color: var(--panel) !important; }
|
||||||
|
.pop b { display: block; margin-bottom: 4px; font-size: 13px; }
|
||||||
|
.pop .muted { color: var(--muted); font-size: 11px; }
|
||||||
|
.pop .row { margin-top: 4px; }
|
||||||
|
.pop .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; letter-spacing: .3px; text-transform: uppercase; }
|
||||||
|
.pop .badge.moving { background: rgba(45,212,167,.18); color: var(--live); }
|
||||||
|
.pop .badge.idling { background: rgba(240,169,59,.18); color: var(--warn); }
|
||||||
|
.pop .badge.parked { background: rgba(107,114,128,.22); color: #d1d5db; }
|
||||||
|
.pop .badge.offline { background: rgba(180,121,31,.25); color: var(--offline); }
|
||||||
|
.maplibregl-ctrl-attrib { background: rgba(30,35,46,.7) !important; }
|
||||||
|
.maplibregl-ctrl-attrib a { color: var(--accent); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -240,26 +315,21 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="view" id="view-tickets">
|
<section class="view" id="view-tickets">
|
||||||
<main id="main-tickets">
|
<div class="map-wrap">
|
||||||
<div class="card span12">
|
<div id="tk-map"></div>
|
||||||
<div class="banner" id="tk-banner">
|
<div id="tk-statusbar" class="map-ctl">
|
||||||
Tickets data source not connected yet — this tab is scaffolded and ready to wire.
|
<label>Status
|
||||||
<ul>
|
<select id="tk-status">
|
||||||
<li>Ticket data must be served via <code>dashboard_api</code> (proxied / presigned from the rustfs <code>tickets</code> bucket); credentials are never embedded in this static SPA.</li>
|
<option value="open" selected>Open (actionable)</option>
|
||||||
</ul>
|
<option value="all">All statuses</option>
|
||||||
</div>
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tk-layers" class="map-ctl collapsed">
|
||||||
<div class="card span8">
|
<button type="button" class="layers-toggle" id="tk-layers-toggle">Layers</button>
|
||||||
<h2>Recent tickets <span class="count" id="tk-count"></span></h2>
|
<div class="layers-body" id="tk-layers-body"></div>
|
||||||
<div class="tbl-scroll" id="tk-wrap"><div class="empty">Loading…</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card span4">
|
|
||||||
<h2>By status</h2>
|
|
||||||
<div id="tk-status"><div class="empty">Loading…</div></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -502,37 +572,25 @@ async function loadAll() {
|
||||||
// The header KPI strip is shared, so we cache the last logistics totals and
|
// The header KPI strip is shared, so we cache the last logistics totals and
|
||||||
// re-render them when switching back from Tickets.
|
// re-render them when switching back from Tickets.
|
||||||
let lastTotals = null, lastFuelL = 0;
|
let lastTotals = null, lastFuelL = 0;
|
||||||
const ticketStats = {}; // populated once a tickets data source is wired
|
const ticketStats = {}; // { inc, crq, vehicles, moving } — filled by the tickets map
|
||||||
|
|
||||||
function renderTicketKpis() {
|
function renderTicketKpis() {
|
||||||
const k = [
|
const k = [
|
||||||
['accent', ticketStats.open ?? '—', 'Open'],
|
['', ticketStats.inc ?? '—', 'INC open'],
|
||||||
['warn', ticketStats.in_progress ?? '—', 'In progress'],
|
['', ticketStats.crq ?? '—', 'CRQ open'],
|
||||||
['live', ticketStats.resolved ?? '—', 'Resolved'],
|
['accent', ticketStats.vehicles ?? '—', 'Vehicles'],
|
||||||
['', ticketStats.avg_resolution_h != null ? num(ticketStats.avg_resolution_h, 1) + 'h' : '—', 'Avg resolution'],
|
['live', ticketStats.moving ?? '—', 'Moving'],
|
||||||
];
|
];
|
||||||
$('kpis').innerHTML = k.map(([c, v, l]) =>
|
$('kpis').innerHTML = k.map(([c, v, l]) =>
|
||||||
`<div class="kpi"><b class="${c}">${v}</b><span>${l}</span></div>`).join('');
|
`<div class="kpi"><b class="${c}">${v}</b><span>${l}</span></div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
let ticketsLoaded = false;
|
|
||||||
async function loadTickets() {
|
|
||||||
// Integration point. FleetOps is a credential-less static SPA, so ticket data
|
|
||||||
// must arrive through dashboard_api (proxied / presigned from the rustfs
|
|
||||||
// `tickets` bucket) — never by embedding S3 keys here. Wire the fetch + the
|
|
||||||
// renderers below once that endpoint exists. Until then, show empty states.
|
|
||||||
ticketsLoaded = true;
|
|
||||||
$('tk-count').textContent = '';
|
|
||||||
$('tk-wrap').innerHTML = '<div class="empty">No ticket data source connected yet.</div>';
|
|
||||||
$('tk-status').innerHTML = '<div class="empty">—</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchTab(name) {
|
function switchTab(name) {
|
||||||
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
|
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
|
||||||
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === `view-${name}`));
|
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === `view-${name}`));
|
||||||
if (name === 'tickets') {
|
if (name === 'tickets') {
|
||||||
renderTicketKpis();
|
renderTicketKpis();
|
||||||
if (!ticketsLoaded) loadTickets();
|
initTicketsMap(); // lazy — builds once, then just resizes
|
||||||
} else {
|
} else {
|
||||||
renderKpis(lastTotals, lastFuelL);
|
renderKpis(lastTotals, lastFuelL);
|
||||||
}
|
}
|
||||||
|
|
@ -540,6 +598,208 @@ function switchTab(name) {
|
||||||
document.querySelectorAll('.tab').forEach(b =>
|
document.querySelectorAll('.tab').forEach(b =>
|
||||||
b.addEventListener('click', () => switchTab(b.dataset.tab)));
|
b.addEventListener('click', () => switchTab(b.dataset.tab)));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TICKETS MAP — FleetNow-style live map + INC/CRQ ticket layers
|
||||||
|
// ============================================================================
|
||||||
|
// Reads the same read-API as FleetNow/Logistics (API_BASE):
|
||||||
|
// GET /webhook/live-positions → vehicle DOM markers (ported from FleetNow)
|
||||||
|
// GET /webhook/tickets → INC (red) + CRQ (blue) circle layers
|
||||||
|
// Tickets are geocoded server-side (cluster gazetteer, migration 21); only
|
||||||
|
// rows with a geom come back. Map is lazy-initialised on first Tickets open.
|
||||||
|
const BASEMAP = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
|
||||||
|
const OFFLINE_THRESHOLD_MS = 24 * 3600 * 1000;
|
||||||
|
const STALE_GPS_MS = 10 * 60 * 1000;
|
||||||
|
const LIVE_POLL_MS = 15000;
|
||||||
|
const TICKET_COLORS = { inc: '#ef5b5b', crq: '#3b82f6' };
|
||||||
|
|
||||||
|
const COST_CENTRE_COLORS = {
|
||||||
|
'isp': '#3b82f6', 'osp': '#E8954A', 'osp patrol': '#f97316', 'fds': '#22c55e',
|
||||||
|
'roll out': '#a855f7', 'general': '#fbbf24', 'regional': '#ec4899',
|
||||||
|
'planning': '#06b6d4', 'deliveries': '#84cc16', 'qehs': '#14b8a6', 'airtel': '#ef4444',
|
||||||
|
};
|
||||||
|
const CC_PALETTE = ['#2dd4a7', '#8b5cf6', '#f43f5e', '#0ea5e9', '#eab308', '#d946ef', '#10b981'];
|
||||||
|
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g,
|
||||||
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
function ccColor(cc) {
|
||||||
|
if (!cc) return '#9ca3af';
|
||||||
|
const key = String(cc).trim().toLowerCase();
|
||||||
|
if (COST_CENTRE_COLORS[key]) return COST_CENTRE_COLORS[key];
|
||||||
|
let h = 0; for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
|
||||||
|
return CC_PALETTE[Math.abs(h) % CC_PALETTE.length];
|
||||||
|
}
|
||||||
|
function pastel(hex, mix = 0.58) {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
||||||
|
const t = (c) => Math.round(c + (255 - c) * mix);
|
||||||
|
return `rgb(${t(r)}, ${t(g)}, ${t(b)})`;
|
||||||
|
}
|
||||||
|
function plateTail(v) { const s = String(v || '').replace(/\s+/g, ''); return s ? s.slice(-4) : '—'; }
|
||||||
|
function vehState(p) {
|
||||||
|
if (!p) return 'offline';
|
||||||
|
const age = (typeof p.source_age_hours === 'number') ? p.source_age_hours * 3600000
|
||||||
|
: (p.gps_time_utc ? Date.now() - new Date(p.gps_time_utc).getTime() : 0);
|
||||||
|
if (age >= OFFLINE_THRESHOLD_MS) return 'offline';
|
||||||
|
if (p.acc_status !== '1') return 'parked';
|
||||||
|
if (age >= STALE_GPS_MS) return 'parked';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
let tkMap = null, tkPopup = null, tkLivePoll = null;
|
||||||
|
const tkMarkers = new Map(); // imei → maplibregl.Marker
|
||||||
|
const tkLayerState = { vehicles: true, inc: true, crq: true };
|
||||||
|
let tkStatusOpenOnly = true;
|
||||||
|
|
||||||
|
function initTicketsMap() {
|
||||||
|
if (tkMap) { tkMap.resize(); return; } // already built — just fix sizing
|
||||||
|
tkMap = new maplibregl.Map({
|
||||||
|
container: 'tk-map', style: BASEMAP, center: [37.5, -3.0], zoom: 5.2,
|
||||||
|
attributionControl: { compact: true },
|
||||||
|
});
|
||||||
|
tkMap.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
|
||||||
|
tkPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 14 });
|
||||||
|
|
||||||
|
tkMap.on('load', () => {
|
||||||
|
tkMap.addSource('tk-tickets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||||
|
for (const t of ['inc', 'crq']) {
|
||||||
|
tkMap.addLayer({
|
||||||
|
id: 'tk-' + t, type: 'circle', source: 'tk-tickets',
|
||||||
|
filter: ['==', ['get', 'service_type'], t],
|
||||||
|
paint: {
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 4, 11, 6, 16, 9],
|
||||||
|
'circle-color': TICKET_COLORS[t],
|
||||||
|
'circle-stroke-color': '#fff', 'circle-stroke-width': 1.5, 'circle-opacity': 0.9,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tkMap.on('mouseenter', 'tk-' + t, () => { tkMap.getCanvas().style.cursor = 'pointer'; });
|
||||||
|
tkMap.on('mouseleave', 'tk-' + t, () => { tkMap.getCanvas().style.cursor = ''; tkPopup.remove(); });
|
||||||
|
tkMap.on('mousemove', 'tk-' + t, (e) => showTicketPopup(e.features[0]));
|
||||||
|
}
|
||||||
|
tkMap.on('zoom', updateVehScale); updateVehScale();
|
||||||
|
loadTickets();
|
||||||
|
loadLive();
|
||||||
|
tkLivePoll = setInterval(loadLive, LIVE_POLL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('tk-status').addEventListener('change', () => {
|
||||||
|
tkStatusOpenOnly = $('tk-status').value === 'open';
|
||||||
|
loadTickets();
|
||||||
|
});
|
||||||
|
$('tk-layers-toggle').addEventListener('click', () => $('tk-layers').classList.toggle('collapsed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVehScale() {
|
||||||
|
if (!tkMap) return;
|
||||||
|
const t = Math.max(0, Math.min(1, (tkMap.getZoom() - 5) / 9));
|
||||||
|
document.getElementById('tk-map').style.setProperty('--veh-scale', (0.42 + t * 0.78).toFixed(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTickets() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE}/webhook/tickets?open_only=${tkStatusOpenOnly}`,
|
||||||
|
{ headers: { 'Accept': 'application/json' } });
|
||||||
|
const j = await r.json();
|
||||||
|
const gj = j.geojson || { type: 'FeatureCollection', features: [] };
|
||||||
|
if (tkMap.getSource('tk-tickets')) tkMap.getSource('tk-tickets').setData(gj);
|
||||||
|
const s = j.summary || {};
|
||||||
|
ticketStats.inc = s.inc ?? 0; ticketStats.crq = s.crq ?? 0;
|
||||||
|
renderTicketKpis(); buildTkLayers();
|
||||||
|
} catch (e) { console.warn('tickets', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLive() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE}/webhook/live-positions`, { headers: { 'Accept': 'application/json' } });
|
||||||
|
const j = await r.json();
|
||||||
|
const feats = (j.geojson && j.geojson.features) || [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const f of feats) {
|
||||||
|
const p = f.properties || {}; if (!p.imei) continue;
|
||||||
|
seen.add(p.imei); upsertVeh(p, f.geometry.coordinates);
|
||||||
|
}
|
||||||
|
for (const [imei, m] of tkMarkers) if (!seen.has(imei)) { m.remove(); tkMarkers.delete(imei); }
|
||||||
|
const s = j.summary || {};
|
||||||
|
ticketStats.vehicles = s.vehicle_count ?? feats.length;
|
||||||
|
ticketStats.moving = s.moving ?? 0;
|
||||||
|
renderTicketKpis(); buildTkLayers();
|
||||||
|
} catch (e) { console.warn('live', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertVeh(p, coords) {
|
||||||
|
const state = vehState(p);
|
||||||
|
const base = ccColor(p.cost_centre);
|
||||||
|
const color = state === 'offline' ? '#374151' : state === 'parked' ? pastel(base) : base;
|
||||||
|
const speed = Number(p.speed || 0), dir = Number(p.direction || 0);
|
||||||
|
let m = tkMarkers.get(p.imei);
|
||||||
|
if (!m) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'veh-marker';
|
||||||
|
el.innerHTML = '<div class="veh-inner"><div class="veh-pin"><span class="glyph"></span></div><div class="veh-plate"></div></div>';
|
||||||
|
el.addEventListener('mouseenter', () => showVehPopup(p, coords));
|
||||||
|
el.addEventListener('mouseleave', () => tkPopup.remove());
|
||||||
|
m = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat(coords).addTo(tkMap);
|
||||||
|
tkMarkers.set(p.imei, m);
|
||||||
|
} else { m.setLngLat(coords); }
|
||||||
|
const el = m.getElement();
|
||||||
|
el.classList.remove('active', 'parked', 'offline'); el.classList.add(state);
|
||||||
|
el.style.display = tkLayerState.vehicles ? '' : 'none';
|
||||||
|
el.querySelector('.veh-pin').style.setProperty('--c', color);
|
||||||
|
const glyph = el.querySelector('.glyph');
|
||||||
|
if (state === 'active' && speed > 0) {
|
||||||
|
glyph.className = 'glyph veh-arrow'; glyph.innerHTML = ''; glyph.style.setProperty('--dir', dir + 'deg');
|
||||||
|
} else if (state === 'parked') {
|
||||||
|
glyph.className = 'glyph'; glyph.innerHTML = ''; glyph.style.removeProperty('--dir');
|
||||||
|
} else {
|
||||||
|
glyph.className = 'glyph idle-dot'; glyph.innerHTML = ''; glyph.style.removeProperty('--dir');
|
||||||
|
}
|
||||||
|
el.querySelector('.veh-plate').textContent = plateTail(p.vehicle_number);
|
||||||
|
el.style.zIndex = state === 'active' ? 3 : (state === 'parked' ? 2 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showVehPopup(p, coords) {
|
||||||
|
const state = vehState(p), speed = Number(p.speed || 0);
|
||||||
|
const label = state === 'offline' ? 'offline' : state === 'parked' ? 'parked'
|
||||||
|
: speed > 0 ? `moving · ${speed.toFixed(0)} kmh` : 'idling';
|
||||||
|
const cls = state === 'active' && speed > 0 ? 'moving' : state === 'active' ? 'idling' : state;
|
||||||
|
tkPopup.setLngLat(coords).setHTML(`<div class="pop">
|
||||||
|
<b>${escapeHtml(p.vehicle_number || '—')} <span class="badge ${cls}">${label}</span></b>
|
||||||
|
${p.driver ? `<div class="row">${escapeHtml(p.driver)}</div>` : ''}
|
||||||
|
${p.cost_centre ? `<div class="row muted">${escapeHtml(p.cost_centre)}${p.assigned_city ? ' · ' + escapeHtml(p.assigned_city) : ''}</div>` : ''}
|
||||||
|
<div class="row muted">last fix ${escapeHtml(p.gps_time || '—')}</div></div>`).addTo(tkMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTicketPopup(f) {
|
||||||
|
const p = f.properties || {};
|
||||||
|
const t = (p.service_type || '').toUpperCase();
|
||||||
|
tkPopup.setLngLat(f.geometry.coordinates).setHTML(`<div class="pop">
|
||||||
|
<b>${escapeHtml(p.ticket_id || '—')} <span class="badge" style="color:${TICKET_COLORS[p.service_type] || '#fff'}">${t}</span></b>
|
||||||
|
<div class="row">${escapeHtml(p.status || '—')}</div>
|
||||||
|
${p.cluster ? `<div class="row muted">${escapeHtml(p.cluster)}${p.region ? ' · ' + escapeHtml(p.region) : ''}</div>` : ''}
|
||||||
|
${p.owner ? `<div class="row muted">${escapeHtml(p.owner)}</div>` : ''}
|
||||||
|
${p.department ? `<div class="row muted">${escapeHtml(p.department)}${p.sla_status ? ' · ' + escapeHtml(p.sla_status) : ''}</div>` : ''}
|
||||||
|
${p.geo_source === 'cluster' ? '<div class="row muted" style="font-size:10px">approx — cluster location</div>' : ''}
|
||||||
|
</div>`).addTo(tkMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTkLayers() {
|
||||||
|
const rows = [
|
||||||
|
{ id: 'vehicles', label: 'Vehicles', color: '#E8954A', n: tkMarkers.size },
|
||||||
|
{ id: 'inc', label: 'INC incidents', color: TICKET_COLORS.inc, n: ticketStats.inc ?? 0 },
|
||||||
|
{ id: 'crq', label: 'CRQ installs', color: TICKET_COLORS.crq, n: ticketStats.crq ?? 0 },
|
||||||
|
];
|
||||||
|
$('tk-layers-body').innerHTML = rows.map((r) =>
|
||||||
|
`<label class="layers-row"><input type="checkbox" data-lyr="${r.id}"${tkLayerState[r.id] ? ' checked' : ''}>
|
||||||
|
<span class="legend-dot" style="background:${r.color}"></span><span>${r.label}</span><span class="layers-n">${r.n}</span></label>`).join('');
|
||||||
|
$('tk-layers-body').querySelectorAll('input[type=checkbox]').forEach((cb) =>
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const id = cb.getAttribute('data-lyr'); tkLayerState[id] = cb.checked;
|
||||||
|
if (id === 'vehicles') {
|
||||||
|
for (const [, m] of tkMarkers) m.getElement().style.display = cb.checked ? '' : 'none';
|
||||||
|
} else if (tkMap.getLayer('tk-' + id)) {
|
||||||
|
tkMap.setLayoutProperty('tk-' + id, 'visibility', cb.checked ? 'visible' : 'none');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// BOOT
|
// BOOT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue