refactor(ui): fixed filter dock + bottom trip-card bar (no floating panels)
Replaces the floating filter + trip-list cards (which drifted/overlapped the map) with fixed chrome: filters dock on the right (full body height, same controls), and trips render as horizontally-scrolling cards in a fixed bottom bar that mirrors the top bar. Body is a grid (map | filter dock / trips bar); used minmax(0,1fr) + min-width:0 + .app overflow:hidden so the 150-card scroller can't widen the page. map.resize() on mode switch. Verified: no overflow (1440=1170+270), filters right of map, trips bar below map, cards scroll, click animates. No errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
cd627b4f9a
commit
48631b6a38
2 changed files with 97 additions and 68 deletions
|
|
@ -41,6 +41,9 @@ trips** into one view for the Fireside Communications / Tracksolid fleet.
|
||||||
(vehicle / cost centre / city) plus the **first trip** and **last trip** —
|
(vehicle / cost centre / city) plus the **first trip** and **last trip** —
|
||||||
each with its **reverse-geocoded location and timestamp** — alongside the KPI
|
each with its **reverse-geocoded location and timestamp** — alongside the KPI
|
||||||
totals (trips, km, driving/idle hours, vehicles, drivers, date range).
|
totals (trips, km, driving/idle hours, vehicles, drivers, date range).
|
||||||
|
- **Fixed chrome (no floating panels).** Filters are a **fixed dock on the right**;
|
||||||
|
in trips view the trips are **cards in a fixed bottom bar** (horizontally
|
||||||
|
scrollable) that mirrors the top bar. Click a card to fit + animate that route.
|
||||||
|
|
||||||
Live: <https://fleetnow.rahamafresh.com>
|
Live: <https://fleetnow.rahamafresh.com>
|
||||||
|
|
||||||
|
|
|
||||||
158
index.html
158
index.html
|
|
@ -44,7 +44,11 @@
|
||||||
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
background: var(--bg); color: var(--text); overflow: hidden;
|
background: var(--bg); color: var(--text); overflow: hidden;
|
||||||
}
|
}
|
||||||
.app { display: grid; grid-template-rows: auto auto 1fr; height: 100vh; }
|
.app {
|
||||||
|
display: grid; height: 100vh; width: 100vw; overflow: hidden;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
grid-template-columns: minmax(0, 1fr); /* clamp so the trip-card scroller can't widen the page */
|
||||||
|
}
|
||||||
|
|
||||||
/* Trips context bar (second row, trips mode only): active filter + first/last
|
/* Trips context bar (second row, trips mode only): active filter + first/last
|
||||||
trip bookends with reverse-geocoded location + timestamp. */
|
trip bookends with reverse-geocoded location + timestamp. */
|
||||||
|
|
@ -117,29 +121,34 @@
|
||||||
.clock .label { font-size: 9.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
|
.clock .label { font-size: 9.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
|
||||||
.clock b { font-weight: 600; }
|
.clock b { font-weight: 600; }
|
||||||
|
|
||||||
/* ── Map + floating cards ──────────────────────────────────────────── */
|
/* ── Body: map + fixed right filter dock + fixed bottom trips bar ───── */
|
||||||
/* Pin to row 3 so the map always takes the 1fr track whether or not the
|
|
||||||
tripbar (row 2) is shown — otherwise a hidden tripbar lets the map fall
|
|
||||||
into the auto row and collapse. */
|
|
||||||
#map { position: relative; min-height: 0; height: 100%; grid-row: 3; }
|
|
||||||
header { grid-row: 1; }
|
header { grid-row: 1; }
|
||||||
.tripbar { grid-row: 2; }
|
.tripbar { grid-row: 2; }
|
||||||
|
.body {
|
||||||
|
grid-row: 3; min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
/* minmax(0,…) so the horizontal trip-card scroller can't blow out the track */
|
||||||
|
grid-template-columns: minmax(0, 1fr) 270px; /* map | fixed filter dock */
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto; /* map | fixed trips bar */
|
||||||
|
}
|
||||||
|
#map { grid-column: 1; grid-row: 1; position: relative; min-height: 0; }
|
||||||
.placeholder {
|
.placeholder {
|
||||||
position: absolute; inset: 0; display: grid; place-items: center;
|
position: absolute; inset: 0; display: grid; place-items: center;
|
||||||
color: var(--muted); font-size: 13px; text-align: center; padding: 32px;
|
color: var(--muted); font-size: 13px; text-align: center; padding: 32px;
|
||||||
pointer-events: none; z-index: 1;
|
pointer-events: none; z-index: 1;
|
||||||
}
|
}
|
||||||
.floating {
|
|
||||||
position: absolute; z-index: 5; background: rgba(30,35,46,.95);
|
/* Fixed right filter dock (full body height) */
|
||||||
border: 1px solid var(--border); border-radius: 10px;
|
.filters {
|
||||||
box-shadow: 0 8px 28px rgba(0,0,0,.45); backdrop-filter: blur(6px);
|
grid-column: 2; grid-row: 1 / span 2;
|
||||||
|
background: var(--panel); border-left: 1px solid var(--border);
|
||||||
|
padding: 16px 16px 18px; overflow-y: auto;
|
||||||
}
|
}
|
||||||
.filters { bottom: 22px; right: 16px; width: 250px; padding: 14px 14px 16px; }
|
.filters h3 {
|
||||||
.filters h3, .triplist h3 {
|
|
||||||
font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
|
font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
|
||||||
color: var(--muted); margin: 0 0 12px; font-weight: 600;
|
color: var(--muted); margin: 0 0 14px; font-weight: 600;
|
||||||
}
|
}
|
||||||
.field { display: flex; flex-direction: column; margin-bottom: 11px; }
|
.field { display: flex; flex-direction: column; margin-bottom: 13px; }
|
||||||
.field label {
|
.field label {
|
||||||
font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px;
|
font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px;
|
||||||
color: var(--muted); margin-bottom: 4px;
|
color: var(--muted); margin-bottom: 4px;
|
||||||
|
|
@ -149,46 +158,57 @@
|
||||||
border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; width: 100%;
|
border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; width: 100%;
|
||||||
}
|
}
|
||||||
select:focus, input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
select:focus, input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||||
select[multiple] { min-height: 84px; max-height: 150px; padding: 5px; }
|
select[multiple] { min-height: 120px; max-height: 200px; padding: 5px; }
|
||||||
select[multiple] option { padding: 4px 6px; border-radius: 3px; }
|
select[multiple] option { padding: 4px 6px; border-radius: 3px; }
|
||||||
select[multiple] option:checked { background: var(--accent); color: #1a1009; }
|
select[multiple] option:checked { background: var(--accent); color: #1a1009; }
|
||||||
.hint { font-size: 9.5px; color: var(--muted); margin-top: 3px; line-height: 1.3; }
|
.hint { font-size: 9.5px; color: var(--muted); margin-top: 3px; line-height: 1.3; }
|
||||||
.custom { display: none; }
|
.custom { display: none; }
|
||||||
.custom.show { display: block; }
|
.custom.show { display: block; }
|
||||||
.btn {
|
.btn {
|
||||||
width: 100%; padding: 9px; margin-top: 4px; background: var(--accent);
|
width: 100%; padding: 10px; margin-top: 6px; background: var(--accent);
|
||||||
color: #1a1009; border: 0; border-radius: 6px; font: 600 13px system-ui; cursor: pointer;
|
color: #1a1009; border: 0; border-radius: 6px; font: 600 13px system-ui; cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn:hover { background: var(--accent-hover); }
|
.btn:hover { background: var(--accent-hover); }
|
||||||
.btn:disabled { background: #4b5563; color: #cbd5e1; cursor: wait; }
|
.btn:disabled { background: #4b5563; color: #cbd5e1; cursor: wait; }
|
||||||
|
|
||||||
.triplist {
|
/* Fixed bottom trips bar — horizontal scrolling cards (mirrors the top bar) */
|
||||||
bottom: 22px; left: 16px; width: 280px; max-height: calc(100% - 44px);
|
.tripsbar {
|
||||||
display: none; flex-direction: column; padding: 12px 12px 8px;
|
grid-column: 1; grid-row: 2; display: none;
|
||||||
|
background: var(--panel); border-top: 1px solid var(--border);
|
||||||
|
flex-direction: column; min-width: 0;
|
||||||
}
|
}
|
||||||
.triplist.show { display: flex; }
|
.tripsbar.show { display: flex; }
|
||||||
.triplist .back {
|
.tripsbar-head {
|
||||||
|
display: flex; align-items: center; gap: 14px; padding: 7px 16px 0;
|
||||||
|
}
|
||||||
|
.tripsbar-head .back {
|
||||||
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
|
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
|
||||||
color: var(--accent); font: 600 12px system-ui; background: none; border: 0;
|
color: var(--accent); font: 600 12px system-ui; background: none; border: 0; padding: 0;
|
||||||
padding: 0 0 10px; margin-bottom: 6px; border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
.triplist .back:hover { color: var(--accent-hover); }
|
.tripsbar-head .back:hover { color: var(--accent-hover); }
|
||||||
.triplist .scroll { overflow-y: auto; min-height: 0; }
|
.tripsbar-title {
|
||||||
.trip-row {
|
font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); font-weight: 600;
|
||||||
display: grid; grid-template-columns: 7px 24px 1fr auto; gap: 8px;
|
|
||||||
align-items: center; padding: 7px 0; border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 12px; cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.trip-row:hover { background: rgba(255,255,255,.03); }
|
.tripsbar-title span { color: var(--text); }
|
||||||
.trip-row.active { background: rgba(232,149,74,.14); }
|
.trip-cards {
|
||||||
.trip-row.active .seq, .trip-row.active .time { color: var(--accent); }
|
display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden;
|
||||||
.trip-row .swatch { width: 4px; height: 22px; border-radius: 2px; }
|
padding: 10px 16px 14px; scrollbar-width: thin; min-width: 0;
|
||||||
.trip-row .seq { color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
|
}
|
||||||
.trip-row .veh { color: var(--text); font-weight: 500; }
|
.trip-card {
|
||||||
.trip-row .time { color: var(--muted); font-size: 11px; }
|
flex: 0 0 auto; width: 150px; cursor: pointer;
|
||||||
.trip-row .km { font-variant-numeric: tabular-nums; color: var(--text); white-space: nowrap; }
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
.trips-empty { color: var(--muted); padding: 24px 0; text-align: center; font-size: 12px; }
|
border-left: 4px solid var(--swatch, var(--muted)); border-radius: 8px;
|
||||||
.trips-more { color: var(--muted); padding: 10px 0 2px; text-align: center; font-size: 11px; }
|
padding: 8px 10px; display: flex; flex-direction: column; gap: 3px;
|
||||||
|
}
|
||||||
|
.trip-card:hover { border-color: var(--accent); background: var(--panel-2); }
|
||||||
|
.trip-card.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||||
|
.trip-card .tc-top { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; }
|
||||||
|
.trip-card .tc-seq { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 11px; }
|
||||||
|
.trip-card .tc-km { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap; }
|
||||||
|
.trip-card .tc-veh { color: var(--text); font-weight: 600; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.trip-card .tc-time { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||||
|
.trips-empty { color: var(--muted); padding: 18px 4px; font-size: 12px; }
|
||||||
|
.trips-more { flex: 0 0 auto; align-self: center; color: var(--muted); font-size: 11px; padding: 0 12px; white-space: nowrap; }
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
position: absolute; top: 16px; left: 50%; transform: translateX(-50%);
|
position: absolute; top: 16px; left: 50%; transform: translateX(-50%);
|
||||||
|
|
@ -338,24 +358,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
<div id="map">
|
<div id="map">
|
||||||
<div class="placeholder" id="placeholder">Loading live fleet…</div>
|
<div class="placeholder" id="placeholder">Loading live fleet…</div>
|
||||||
|
|
||||||
<!-- Trip list (bottom-left, trips mode only) -->
|
|
||||||
<div class="floating triplist" id="triplist">
|
|
||||||
<button class="back" id="trips-back" type="button">◀ Live</button>
|
|
||||||
<h3>Trips <span id="trip-count"></span></h3>
|
|
||||||
<div class="scroll" id="trip-scroll">
|
|
||||||
<div class="trips-empty">No trips.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters (bottom-right, always) -->
|
<!-- Filters — fixed right dock (always) -->
|
||||||
<div class="floating filters" id="filters">
|
<aside class="filters" id="filters">
|
||||||
<h3>Filters</h3>
|
<h3>Filters</h3>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="f-vehicle">Number plate <span class="hint" style="text-transform:none">— ⌘/Ctrl-click for more</span></label>
|
<label for="f-vehicle">Number plate <span class="hint" style="text-transform:none">— ⌘/Ctrl-click for more</span></label>
|
||||||
<select id="f-vehicle" multiple size="4"></select>
|
<select id="f-vehicle" multiple size="6"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="f-cc">Cost centre</label>
|
<label for="f-cc">Cost centre</label>
|
||||||
|
|
@ -379,6 +392,17 @@
|
||||||
<div class="field"><label for="f-end">End date</label><input type="date" id="f-end"></div>
|
<div class="field"><label for="f-end">End date</label><input type="date" id="f-end"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" id="show-trips" type="button">Show trips</button>
|
<button class="btn" id="show-trips" type="button">Show trips</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Trips — fixed bottom bar of horizontal cards (trips mode only) -->
|
||||||
|
<div class="tripsbar" id="tripsbar">
|
||||||
|
<div class="tripsbar-head">
|
||||||
|
<button class="back" id="trips-back" type="button">◀ Live</button>
|
||||||
|
<span class="tripsbar-title">TRIPS <span id="trip-count"></span></span>
|
||||||
|
</div>
|
||||||
|
<div class="trip-cards" id="trip-scroll">
|
||||||
|
<div class="trips-empty">No trips.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1038,11 +1062,12 @@ function switchToTripsMode() {
|
||||||
clearTrail(); trailedVehicle = null;
|
clearTrail(); trailedVehicle = null;
|
||||||
liveMarkers.forEach(m => m.getElement().style.display = 'none');
|
liveMarkers.forEach(m => m.getElement().style.display = 'none');
|
||||||
clusterMarkers.forEach(m => m.remove()); clusterMarkers.clear();
|
clusterMarkers.forEach(m => m.remove()); clusterMarkers.clear();
|
||||||
document.getElementById('triplist').classList.add('show');
|
document.getElementById('tripsbar').classList.add('show');
|
||||||
document.getElementById('tripbar').classList.add('show');
|
document.getElementById('tripbar').classList.add('show');
|
||||||
document.getElementById('live-pill').classList.add('show');
|
document.getElementById('live-pill').classList.add('show');
|
||||||
document.getElementById('stale-chip').style.display = 'none';
|
document.getElementById('stale-chip').style.display = 'none';
|
||||||
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
||||||
|
if (map) setTimeout(() => map.resize(), 0); // map column shrank — let it reflow
|
||||||
}
|
}
|
||||||
|
|
||||||
// "YYYY-MM-DD HH:MM:SS" → "DD Mon HH:MM" (EAT, compact) for the bookends.
|
// "YYYY-MM-DD HH:MM:SS" → "DD Mon HH:MM" (EAT, compact) for the bookends.
|
||||||
|
|
@ -1130,7 +1155,7 @@ function renderTrips(payload, sel) {
|
||||||
}
|
}
|
||||||
const b = new maplibregl.LngLatBounds();
|
const b = new maplibregl.LngLatBounds();
|
||||||
g.features.forEach(f => Array.isArray(f.geometry?.coordinates) && f.geometry.coordinates.forEach(c => b.extend(c)));
|
g.features.forEach(f => Array.isArray(f.geometry?.coordinates) && f.geometry.coordinates.forEach(c => b.extend(c)));
|
||||||
if (!b.isEmpty()) map.fitBounds(b, { padding: { top: 50, right: 290, bottom: 50, left: 310 }, duration: 600, maxZoom: 14 });
|
if (!b.isEmpty()) map.fitBounds(b, { padding: 50, duration: 600, maxZoom: 14 });
|
||||||
};
|
};
|
||||||
if (map.isStyleLoaded()) draw(); else map.once('load', draw);
|
if (map.isStyleLoaded()) draw(); else map.once('load', draw);
|
||||||
}
|
}
|
||||||
|
|
@ -1157,24 +1182,24 @@ function renderTripList(features) {
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
features.slice(0, MAX_TRIPS_IN_LIST).forEach(f => {
|
features.slice(0, MAX_TRIPS_IN_LIST).forEach(f => {
|
||||||
const p = f.properties || {};
|
const p = f.properties || {};
|
||||||
const row = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
row.className = 'trip-row';
|
card.className = 'trip-card';
|
||||||
row.innerHTML = `
|
card.style.setProperty('--swatch', p.color || 'var(--muted)');
|
||||||
<span class="swatch" style="background:${p.color}"></span>
|
card.innerHTML = `
|
||||||
<span class="seq">#${p.daily_seq ?? '?'}</span>
|
<div class="tc-top"><span class="tc-seq">#${p.daily_seq ?? '?'}</span>
|
||||||
<span class="meta"><span class="veh">${escapeHtml(p.vehicle_number || '—')}</span>
|
<span class="tc-km">${Number(p.distance_km ?? 0).toLocaleString()} km</span></div>
|
||||||
<span class="time"> ${escapeHtml((p.start_time || '').slice(11, 16))}→${escapeHtml((p.end_time || '').slice(11, 16))}</span></span>
|
<div class="tc-veh">${escapeHtml(p.vehicle_number || '—')}</div>
|
||||||
<span class="km">${Number(p.distance_km ?? 0).toLocaleString()} km</span>`;
|
<div class="tc-time">${escapeHtml((p.start_time || '').slice(11, 16))} → ${escapeHtml((p.end_time || '').slice(11, 16))}</div>`;
|
||||||
row.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
const coords = f.geometry?.coordinates;
|
const coords = f.geometry?.coordinates;
|
||||||
if (!Array.isArray(coords) || !coords.length || !map) return;
|
if (!Array.isArray(coords) || !coords.length || !map) return;
|
||||||
document.querySelectorAll('.trip-row.active').forEach(r => r.classList.remove('active'));
|
document.querySelectorAll('.trip-card.active').forEach(r => r.classList.remove('active'));
|
||||||
row.classList.add('active');
|
card.classList.add('active');
|
||||||
const b = new maplibregl.LngLatBounds(); coords.forEach(c => b.extend(c));
|
const b = new maplibregl.LngLatBounds(); coords.forEach(c => b.extend(c));
|
||||||
map.fitBounds(b, { padding: { top: 60, right: 300, bottom: 60, left: 320 }, duration: 500, maxZoom: 15 });
|
map.fitBounds(b, { padding: 60, duration: 500, maxZoom: 15 });
|
||||||
setTimeout(() => animateTrip(f), 550);
|
setTimeout(() => animateTrip(f), 550);
|
||||||
});
|
});
|
||||||
frag.appendChild(row);
|
frag.appendChild(card);
|
||||||
});
|
});
|
||||||
scroll.appendChild(frag);
|
scroll.appendChild(frag);
|
||||||
if (features.length > MAX_TRIPS_IN_LIST) {
|
if (features.length > MAX_TRIPS_IN_LIST) {
|
||||||
|
|
@ -1236,11 +1261,12 @@ function showTripPlaceholder(msg) {
|
||||||
function backToLive() {
|
function backToLive() {
|
||||||
mode = 'live';
|
mode = 'live';
|
||||||
clearAnim(); removeTripLayers();
|
clearAnim(); removeTripLayers();
|
||||||
document.getElementById('triplist').classList.remove('show');
|
document.getElementById('tripsbar').classList.remove('show');
|
||||||
document.getElementById('tripbar').classList.remove('show');
|
document.getElementById('tripbar').classList.remove('show');
|
||||||
document.getElementById('live-pill').classList.remove('show');
|
document.getElementById('live-pill').classList.remove('show');
|
||||||
document.getElementById('stale-chip').style.display = '';
|
document.getElementById('stale-chip').style.display = '';
|
||||||
liveMarkers.forEach(m => m.getElement().style.display = '');
|
liveMarkers.forEach(m => m.getElement().style.display = '');
|
||||||
|
if (map) setTimeout(() => map.resize(), 0); // bottom bar gone — map grows back
|
||||||
if (liveFeatures.length) applyLiveFilters(); // rebuild cluster bubbles/pins
|
if (liveFeatures.length) applyLiveFilters(); // rebuild cluster bubbles/pins
|
||||||
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
|
||||||
startPolling();
|
startPolling();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue