Files
2026-07-11 19:30:52 +02:00

1920 lines
80 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Travel Monitor</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--bg2: #181c25;
--bg3: #1e2330;
--border: #2a3040;
--text: #e2e8f0;
--muted: #8892a0;
--accent: #3b82f6;
--green: #22863a;
--green-dim: #0d2818;
--green-row: #0d2215;
--yellow: #a06c10;
--yellow-dim: #241a04;
--yellow-row: #1e1504;
--red: #922424;
--red-dim: #250a0a;
--red-row: #200808;
--dred: #5a1414;
--dred-dim: #170404;
--dred-row: #130303;
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, sans-serif; font-size: 14px; }
#app { display: flex; flex-direction: column; height: 100vh; }
/* ── Header ── */
#header {
display: flex; align-items: center; gap: 14px;
padding: 0 18px; height: 54px; flex-shrink: 0;
background: var(--bg2); border-bottom: 1px solid var(--border);
}
#header h1 { font-size: 17px; font-weight: 600; white-space: nowrap; }
#hq-badge { background: #1e3a5f; color: #7db3f5; font-size: 12px; padding: 4px 11px; border-radius: 4px; white-space: nowrap; }
#last-updated { font-size: 11px; color: var(--muted); white-space: nowrap; }
.spacer { flex: 1; }
/* header-actions: inline flex on desktop, full-width row on mobile */
#header-actions { display: flex; align-items: center; gap: 8px; }
.hdr-btn {
display: flex; align-items: center; gap: 6px;
background: transparent; border: 1px solid var(--border);
color: var(--text); font-size: 13px; padding: 6px 13px;
border-radius: 6px; cursor: pointer; white-space: nowrap;
transition: background 0.12s;
}
.hdr-btn:hover { background: var(--bg3); }
.hdr-btn.danger { border-color: #5a1a1a; color: #e57373; }
.hdr-btn.danger:hover { background: var(--red-dim); }
.hdr-btn.active { background: var(--bg3); border-color: var(--accent); color: var(--accent); }
/* ── Main ── */
#main { display: flex; flex: 1; overflow: hidden; }
#map { flex: 1; }
/* ── Sidebar ── */
#sidebar {
width: 400px; flex-shrink: 0;
display: flex; flex-direction: column;
background: var(--bg2); border-left: 1px solid var(--border);
overflow: hidden;
}
#sidebar-header {
display: flex; align-items: center; justify-content: space-between;
padding: 11px 16px; border-bottom: 1px solid var(--border);
font-size: 13px; color: var(--muted); flex-shrink: 0;
}
#tab-bar { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.tab-btn {
flex: 1; padding: 10px 0; text-align: center; font-size: 13px;
background: transparent; border: none; color: var(--muted);
border-bottom: 2px solid transparent; cursor: pointer; transition: color 0.15s;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--text); border-bottom-color: var(--accent); background: var(--bg3); }
#stats-panel { display: none; flex-direction: column; flex: 1; overflow: hidden; }
#stats-panel.visible { display: flex; }
#location-list.hidden { display: none; }
.sort-bar { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg3); flex-shrink: 0; flex-wrap: wrap; }
.sort-bar span { font-size: 11px; color: var(--muted); }
.sort-btn { padding: 4px 10px; border-radius: 5px; font-size: 11px; border: 1px solid var(--border); background: transparent; color: var(--muted); cursor: pointer; }
.sort-btn:hover { color: var(--text); }
.sort-btn.active { border-color: var(--accent); color: #7db3f5; background: #1e3a5f33; }
#stats-table-wrap { flex: 1; overflow-y: auto; }
.stats-tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.stats-tbl th { position: sticky; top: 0; padding: 8px 12px; color: var(--muted); font-weight: 500; text-align: left; border-bottom: 1px solid var(--border); background: var(--bg2); font-size: 11px; cursor: pointer; user-select: none; white-space: nowrap; }
.stats-tbl th:hover { color: var(--text); }
.stats-tbl th.sorted { color: var(--accent); }
.stats-tbl th .arr { margin-left: 3px; font-size: 10px; }
.stats-tbl td { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--text); white-space: nowrap; }
.stats-tbl tr:hover td { background: var(--bg3); cursor: pointer; }
.stats-tbl tr.highlighted td { background: #1e2d45; }
.tbl-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 6px; vertical-align: middle; flex-shrink: 0; }
.delay-green { color: #44b06a; }
.delay-yellow { color: #d4a017; }
.delay-red { color: #e57373; }
.delay-dark_red { color: #c0392b; }
/* ── Location rows ── */
#location-list { flex: 1; overflow-y: auto; }
.loc-item {
display: flex; align-items: center; gap: 0;
border-bottom: 1px solid var(--border);
cursor: pointer; transition: filter 0.1s;
min-height: 62px;
}
.loc-item:hover { filter: brightness(1.15); }
.loc-item.highlighted { outline: 2px solid var(--accent); outline-offset: -2px; }
/* Left color bar */
.loc-bar { width: 6px; flex-shrink: 0; align-self: stretch; }
.bar-green { background: var(--green); }
.bar-yellow { background: var(--yellow); }
.bar-red { background: var(--red); }
.bar-dark_red { background: var(--dred); }
.bar-unknown { background: #333; }
/* Row background tint by status */
.row-green { background: var(--green-row); }
.row-yellow { background: var(--yellow-row); }
.row-red { background: var(--red-row); }
.row-dark_red { background: var(--dred-row); }
.row-unknown { background: var(--bg2); }
.loc-inner { display: flex; align-items: center; gap: 12px; flex: 1; padding: 12px 14px; min-width: 0; }
.loc-checkbox { display: none; width: 16px; height: 16px; flex-shrink: 0; accent-color: #e57373; cursor: pointer; }
.remove-mode .loc-checkbox { display: block; }
.loc-num {
width: 24px; height: 24px; border-radius: 50%;
background: var(--border); color: var(--text);
font-size: 12px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.loc-num.error { background: #6b1a1a; color: #ffb3b3; }
.loc-name-block { flex: 1; min-width: 0; }
.loc-name { font-size: 16px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.loc-time { font-size: 12px; color: var(--muted); margin-top: 2px; }
.loc-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.status-pill {
font-size: 13px; font-weight: 700; padding: 4px 10px;
border-radius: 5px; white-space: nowrap; color: #fff;
}
.pill-green { background: var(--green); }
.pill-yellow { background: var(--yellow); }
.pill-red { background: var(--red); }
.pill-dark_red { background: var(--dred); }
.pill-unknown { background: #333; color: var(--muted); }
.status-pill.failed { background: #5a1414; color: #ffb3b3; }
.edit-btn {
background: transparent; border: none; color: var(--muted);
cursor: pointer; font-size: 15px; padding: 4px 6px; border-radius: 4px;
line-height: 1; flex-shrink: 0;
}
.edit-btn:hover { color: var(--text); background: rgba(255,255,255,0.07); }
/* ── Inline rename ── */
.loc-name-input {
font-size: 16px; font-weight: 600; background: rgba(255,255,255,0.08);
border: 1px solid var(--accent); border-radius: 4px; color: var(--text);
padding: 2px 6px; width: 100%;
}
.loc-name-input:focus { outline: none; }
/* ── Remove bar ── */
#remove-bar {
display: none; align-items: center; justify-content: space-between;
padding: 10px 16px; background: var(--red-dim);
border-top: 1px solid #5a1a1a; flex-shrink: 0;
}
#remove-bar.visible { display: flex; }
#remove-count { font-size: 13px; color: #e57373; }
#confirm-remove-btn {
background: var(--red); color: #fff; border: none;
padding: 7px 16px; border-radius: 6px; font-size: 13px; cursor: pointer;
}
#confirm-remove-btn:hover { filter: brightness(1.15); }
/* ── Legend ── */
#legend {
display: flex; flex-wrap: wrap; gap: 6px;
padding: 10px 14px; border-top: 1px solid var(--border); flex-shrink: 0;
}
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
/* ── Add dropdown ── */
#add-dropdown, #map-style-dropdown {
display: none; position: absolute; z-index: 1000;
background: var(--bg2); border: 1px solid var(--border);
border-radius: 8px; padding: 6px; min-width: 220px;
box-shadow: 0 6px 24px rgba(0,0,0,0.6);
}
#add-dropdown.visible, #map-style-dropdown.visible { display: block; }
.add-opt { display: flex; align-items: center; gap: 9px; padding: 10px 13px; border-radius: 6px; cursor: pointer; font-size: 14px; }
.add-opt:hover { background: var(--bg3); }
.map-style-opt.active { background: #1e3a5f33; color: #7db3f5; }
/* ── Status indicator button + panel ── */
.hdr-btn.warn { border-color: #6b4a10; color: #d4a017; background: #1e1504; }
.hdr-btn.warn:hover { background: #261e08; }
#status-panel {
display: none; position: absolute; z-index: 1500;
background: var(--bg2); border: 1px solid var(--border);
border-radius: 8px; width: 340px; max-height: 400px;
box-shadow: 0 6px 24px rgba(0,0,0,0.6); overflow: hidden;
flex-direction: column;
}
#status-panel.visible { display: flex; }
#status-panel-header { padding: 10px 14px; font-size: 13px; font-weight: 600; border-bottom: 1px solid var(--border); flex-shrink: 0; }
#status-panel-body { padding: 10px 14px; overflow-y: auto; font-size: 12px; color: var(--muted); line-height: 1.6; }
.status-row { display: flex; justify-content: space-between; padding: 5px 0; }
.status-ok { color: #44b06a; }
.status-bad { color: #e57373; }
.status-fail-item { padding: 8px 0; border-top: 1px solid var(--border); }
.status-fail-name { color: var(--text); font-weight: 500; font-size: 13px; }
.status-fail-msg { color: var(--muted); font-size: 11px; margin-top: 2px; word-break: break-word; }
/* ── Confirm modal (delete) ── */
#confirm-modal {
display: none; position: fixed; inset: 0; z-index: 2500;
background: rgba(0,0,0,0.65); align-items: center; justify-content: center;
}
#confirm-modal.visible { display: flex; }
#confirm-modal-box { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 22px; width: 360px; }
#confirm-modal-box h3 { font-size: 16px; font-weight: 600; margin-bottom: 10px; }
#confirm-modal-box p { font-size: 13px; color: var(--muted); line-height: 1.5; }
/* ── Modal ── */
#add-modal {
display: none; position: fixed; inset: 0; z-index: 2000;
background: rgba(0,0,0,0.65); align-items: center; justify-content: center;
}
#add-modal.visible { display: flex; }
#add-modal-box { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 24px; width: 370px; }
#add-modal-box h3 { font-size: 16px; font-weight: 600; margin-bottom: 18px; }
.form-field { margin-bottom: 14px; }
.form-field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; }
.form-field input { width: 100%; background: var(--bg3); border: 1px solid var(--border); color: var(--text); padding: 9px 12px; border-radius: 6px; font-size: 14px; }
.form-field input:focus { outline: none; border-color: var(--accent); }
.form-row { display: flex; gap: 10px; }
.form-row .form-field { flex: 1; }
.modal-actions { display: flex; gap: 8px; margin-top: 20px; justify-content: flex-end; }
.modal-btn { padding: 8px 18px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
.modal-btn.cancel { background: var(--bg3); color: var(--muted); }
.modal-btn.cancel:hover { color: var(--text); }
.modal-btn.submit { background: var(--accent); color: #fff; }
.modal-btn.submit:hover { filter: brightness(1.1); }
.modal-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.form-error { font-size: 12px; color: #e57373; margin-top: 8px; display: none; }
.form-error.visible { display: block; }
/* ── 3-dot context menu ── */
.loc-menu-btn {
background: transparent; border: none; color: var(--muted);
cursor: pointer; font-size: 18px; padding: 4px 6px; border-radius: 4px;
line-height: 1; flex-shrink: 0; letter-spacing: 1px;
}
.loc-menu-btn:hover { color: var(--text); background: rgba(255,255,255,0.07); }
#ctx-menu {
display: none; position: fixed; z-index: 3000;
background: var(--bg2); border: 1px solid var(--border);
border-radius: 8px; padding: 5px; min-width: 180px;
box-shadow: 0 6px 24px rgba(0,0,0,0.6);
}
#ctx-menu.visible { display: block; }
.ctx-item {
display: flex; align-items: center; gap: 9px;
padding: 9px 12px; border-radius: 6px; cursor: pointer;
font-size: 13px; color: var(--text);
}
.ctx-item:hover { background: var(--bg3); }
.ctx-item.danger { color: #e57373; }
.ctx-item.danger:hover { background: var(--red-dim); }
/* ── Drag to reorder ── */
.loc-item.dragging { opacity: 0.4; }
.loc-item.drag-over { border-top: 2px solid var(--accent); }
.drag-handle {
cursor: grab; color: var(--muted); font-size: 16px;
padding: 0 6px 0 10px; flex-shrink: 0; user-select: none;
}
.drag-handle:active { cursor: grabbing; }
/* ── Resizable sidebar ── */
#resize-handle {
width: 5px; flex-shrink: 0; cursor: col-resize;
background: transparent; transition: background 0.15s;
}
#resize-handle:hover, #resize-handle.dragging { background: var(--accent); }
/* ── Keyboard shortcut toast ── */
#kb-toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--bg2); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 16px; font-size: 12px; color: var(--muted);
z-index: 4000; display: none; white-space: nowrap;
}
#kb-toast.visible { display: block; }
.kb { display: inline-block; background: var(--bg3); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px; font-size: 11px; color: var(--text); margin: 0 2px; }
/* ── Loading ── */
#loading-overlay {
position: fixed; inset: 0; z-index: 9999;
background: var(--bg); display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 14px;
}
#loading-overlay.hidden { display: none; }
#loading-overlay p { color: var(--muted); font-size: 15px; }
.spinner { width: 34px; height: 34px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Leaflet dark ── */
.leaflet-container { background: #0f1117; }
.leaflet-control-zoom a { background: var(--bg2); color: var(--text); border-color: var(--border); }
.leaflet-control-zoom a:hover { background: var(--bg3); }
.leaflet-popup-content-wrapper { background: var(--bg2); color: var(--text); border: 1px solid var(--border); box-shadow: none; }
.leaflet-popup-tip { background: var(--bg2); }
.leaflet-popup-content { margin: 10px 14px; font-size: 13px; line-height: 1.6; }
/* ════════════════════════════════════════════════════════════════
MOBILE LAYOUT (≤768px)
════════════════════════════════════════════════════════════════ */
@media (max-width: 768px) {
/* Header: two rows */
#header {
flex-wrap: wrap; height: auto; padding: 8px 12px; gap: 6px;
}
#header h1 { font-size: 15px; }
#hq-badge { font-size: 11px; padding: 3px 8px; }
#last-updated { font-size: 10px; }
#status-btn { font-size: 11px; padding: 4px 8px; }
.spacer { display: none; }
/* Action buttons: icon-only strip, full width row */
#header-actions {
display: flex; width: 100%; gap: 6px; padding-top: 2px; flex-wrap: wrap;
}
#header-actions .hdr-btn {
flex: 1; justify-content: center;
padding: 7px 6px; font-size: 12px;
}
/* Main: vertical stack */
#main { flex-direction: column; }
/* Map: collapsible, starts at 42% of viewport height */
#map {
height: 42vh; width: 100%; flex: none;
transition: height 0.25s ease;
}
#map.collapsed { height: 0 !important; }
/* Map toggle button (floating) */
#map-toggle {
display: flex !important;
position: fixed; bottom: 16px; right: 16px; z-index: 900;
background: var(--bg2); border: 1px solid var(--border);
color: var(--text); border-radius: 50%; width: 42px; height: 42px;
align-items: center; justify-content: center;
font-size: 18px; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,0.5);
}
/* Hide resize handle on mobile */
#resize-handle { display: none; }
/* Sidebar: fills remaining space, full width */
#sidebar {
width: 100% !important; flex: 1; border-left: none;
border-top: 1px solid var(--border); min-height: 0;
}
/* Hide the in-sidebar tab bar — replaced by bottom nav */
#sidebar-header { display: none; }
#tab-bar { display: none; }
/* Bigger touch targets for rows */
.loc-item { min-height: 68px; }
.loc-inner { padding: 14px 12px; gap: 10px; }
.loc-name { font-size: 15px; }
.loc-time { font-size: 12px; }
.loc-num { width: 28px; height: 28px; font-size: 13px; }
.loc-menu-btn { font-size: 22px; padding: 6px 8px; }
.drag-handle { font-size: 20px; padding: 0 8px 0 4px; }
/* Status pill slightly smaller to fit */
.status-pill { font-size: 12px; padding: 3px 8px; }
/* Stats table: horizontal scroll */
#stats-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.stats-tbl { min-width: 400px; }
.stats-tbl th, .stats-tbl td { padding: 10px 10px; }
/* Legend: smaller, wraps tighter */
#legend { padding: 7px 10px; gap: 4px; }
.legend-item { font-size: 11px; }
/* Dropdowns/panels: full width safe */
#add-dropdown, #map-style-dropdown { min-width: 0; width: calc(100vw - 24px); left: 12px !important; }
#status-panel { width: calc(100vw - 24px); left: 12px !important; }
#ctx-menu { width: calc(100vw - 24px); }
/* Modals: full width on mobile */
#add-modal-box, #confirm-modal-box { width: calc(100vw - 32px); padding: 18px; }
/* Bottom navigation bar */
#bottom-nav {
display: flex !important;
position: fixed; bottom: 0; left: 0; right: 0; z-index: 800;
background: var(--bg2); border-top: 1px solid var(--border);
height: 56px;
}
/* Push list content above bottom nav */
#location-list, #stats-panel { padding-bottom: 56px; }
#remove-bar { bottom: 56px; }
/* Keyboard toast: above bottom nav */
#kb-toast { bottom: 72px; }
/* Map toggle: above bottom nav */
#map-toggle { bottom: 72px; }
}
/* Bottom nav: hidden on desktop */
#bottom-nav { display: none; }
#map-toggle { display: none; }
.bnav-btn {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 3px; background: transparent; border: none;
color: var(--muted); font-size: 10px; cursor: pointer; padding: 8px 4px;
transition: color 0.12s;
}
.bnav-btn .bnav-icon { font-size: 20px; line-height: 1; }
.bnav-btn.active { color: var(--accent); }
.bnav-btn:hover { color: var(--text); }
/* ── Kiosk mode ── */
body.kiosk #header-actions { display: none !important; }
body.kiosk #status-btn { display: none !important; }
body.kiosk #resize-handle { display: none !important; }
body.kiosk #sidebar-header { display: none !important; }
body.kiosk #tab-bar { display: none !important; }
body.kiosk #legend { padding: 8px 14px; }
/* Kiosk: larger text throughout */
body.kiosk .loc-name { font-size: 22px; }
body.kiosk .loc-time { font-size: 15px; }
body.kiosk .loc-num { width: 32px; height: 32px; font-size: 16px; }
body.kiosk .status-pill { font-size: 15px; padding: 5px 12px; }
body.kiosk .loc-item { min-height: 76px; }
body.kiosk .loc-inner { padding: 14px 16px; }
body.kiosk .legend-item { font-size: 13px; }
body.kiosk #header { height: 60px; }
body.kiosk #header h1 { font-size: 20px; }
body.kiosk #hq-badge { font-size: 13px; padding: 5px 13px; }
/* Kiosk clock */
#kiosk-clock {
display: none; font-size: 20px; font-weight: 600;
color: var(--text); white-space: nowrap; font-variant-numeric: tabular-nums;
}
body.kiosk #kiosk-clock { display: block; }
/* Offline banner */
#offline-banner {
display: none; position: fixed; top: 0; left: 0; right: 0; z-index: 8000;
background: #5a1414; color: #ffb3b3;
padding: 10px 18px; font-size: 13px; font-weight: 500;
text-align: center; border-bottom: 1px solid #8a2020;
}
#offline-banner.visible { display: block; }
body.kiosk #offline-banner { font-size: 15px; padding: 12px 18px; }
/* Stale data — last-updated goes yellow then red */
#last-updated.stale-warn { color: #d4a017 !important; }
#last-updated.stale-error { color: #e57373 !important; }
/* Stale row indicator */
.loc-item.stale .loc-time::after {
content: ' ⏱'; color: var(--muted); font-size: 11px;
}
/* Status change pulse animation */
@keyframes status-pulse {
0% { filter: brightness(1); }
30% { filter: brightness(1.8); }
60% { filter: brightness(1.3); }
100% { filter: brightness(1); }
}
.loc-item.pulsing { animation: status-pulse 0.8s ease-out; }
</style>
</head>
<body>
<div id="offline-banner">⚠ Connection lost — data may be stale. Retrying…</div>
<div id="loading-overlay">
<div class="spinner"></div>
<p>Loading travel data…</p>
</div>
<div id="app">
<div id="header">
<h1 id="app-title">🗺 Travel Monitor</h1>
<span id="hq-badge">HQ: —</span>
<span id="kiosk-clock"></span>
<span id="last-updated"></span>
<button class="hdr-btn" id="status-btn" style="display:none;">⚠ Issues</button>
<div class="spacer"></div>
<div id="header-actions">
<button class="hdr-btn" id="refresh-btn">↻ Refresh</button>
<button class="hdr-btn" id="map-style-btn">🗺 Map</button>
<button class="hdr-btn" id="kiosk-btn">⛶ Kiosk</button>
<button class="hdr-btn" id="add-btn"> Add</button>
<button class="hdr-btn danger" id="remove-btn">✕ Remove</button>
</div>
</div>
<div id="main">
<div id="map"></div>
<div id="resize-handle"></div>
<div id="sidebar">
<div id="sidebar-header">
<span>Locations</span>
<span id="loc-count"></span>
</div>
<div id="tab-bar">
<button class="tab-btn active" id="tab-locations">Locations</button>
<button class="tab-btn" id="tab-stats">Stats</button>
</div>
<div id="location-list"></div>
<div id="stats-panel">
<div class="sort-bar">
<span>Sort by</span>
<button class="sort-btn active" id="sort-time" data-key="one_way_min" data-dir="asc">Time ↕</button>
<button class="sort-btn" id="sort-dist" data-key="distance_km" data-dir="asc">Distance ↕</button>
<button class="sort-btn" id="sort-delay" data-key="ratio" data-dir="desc">Delay ↕</button>
</div>
<div id="stats-table-wrap">
<table class="stats-tbl" id="stats-table">
<thead>
<tr>
<th data-key="name">Location <span class="arr"></span></th>
<th data-key="one_way_min">One way <span class="arr"></span></th>
<th data-key="round_trip_min">Round trip <span class="arr"></span></th>
<th data-key="distance_km">Distance <span class="arr"></span></th>
<th data-key="ratio">Delay <span class="arr"></span></th>
</tr>
</thead>
<tbody id="stats-tbody"></tbody>
</table>
</div>
</div>
<div id="remove-bar">
<span id="remove-count">0 selected</span>
<button id="confirm-remove-btn">Confirm remove</button>
</div>
<div id="legend">
<div class="legend-item"><div class="legend-dot" style="background:var(--green)"></div><span style="color:#ccc">Normal (&lt;1.20×)</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--yellow)"></div><span style="color:#ccc">Worsened (1.201.50×)</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div><span style="color:#ccc">Bad (1.501.70×)</span></div>
<div class="legend-item"><div class="legend-dot" style="background:var(--dred)"></div><span style="color:#ccc">Reconsider (&gt;1.70×)</span></div>
</div>
</div>
</div>
</div>
<!-- Mobile: floating map toggle -->
<button id="map-toggle" title="Toggle map">🗺</button>
<!-- Mobile: bottom navigation bar -->
<div id="bottom-nav">
<button class="bnav-btn active" id="bnav-locations">
<span class="bnav-icon">📍</span>
<span>Locations</span>
</button>
<button class="bnav-btn" id="bnav-stats">
<span class="bnav-icon">📊</span>
<span>Stats</span>
</button>
<button class="bnav-btn" id="bnav-refresh">
<span class="bnav-icon"></span>
<span>Refresh</span>
</button>
</div>
<div id="ctx-menu">
<div class="ctx-item" id="ctx-retry" style="display:none;">↻ Retry fetch</div>
<div class="ctx-item" id="ctx-rename">✏ Rename</div>
<div class="ctx-item" id="ctx-reset">↺ Reset baseline</div>
<div id="ctx-debug-section" style="display:none;">
<div style="height:1px; background:var(--border); margin:5px 4px;"></div>
<div style="font-size:10px; color:var(--muted); padding:4px 12px 2px; text-transform:uppercase; letter-spacing:0.05em;">Debug</div>
<div class="ctx-item" id="ctx-sim-yellow">🟡 Simulate: Worsened (+30%)</div>
<div class="ctx-item" id="ctx-sim-red">🔴 Simulate: Bad (+60%)</div>
<div class="ctx-item" id="ctx-sim-dred">⬛ Simulate: Reconsider (+80%)</div>
<div class="ctx-item" id="ctx-sim-clear">✓ Clear simulation</div>
</div>
</div>
<div id="kb-toast">
<span class="kb">R</span> refresh &nbsp;
<span class="kb">19</span> jump to location &nbsp;
<span class="kb">Tab</span> switch tab &nbsp;
<span class="kb">Esc</span> close
</div>
<div id="add-dropdown">
<div class="add-opt" id="add-by-address">📍 By address</div>
<div class="add-opt" id="add-by-coords">🎯 By coordinates</div>
</div>
<div id="map-style-dropdown">
<div class="add-opt map-style-opt" data-key="dark">🌑 Dark Matter</div>
<div class="add-opt map-style-opt" data-key="voyager">☀️ Voyager (light)</div>
<div class="add-opt map-style-opt" data-key="positron">⚪ Positron (light, minimal)</div>
<div class="add-opt map-style-opt" data-key="osm">🗺 OpenStreetMap (standard)</div>
<div class="add-opt map-style-opt" data-key="satellite">🛰 Satellite</div>
</div>
<div id="status-panel">
<div id="status-panel-header">Provider status</div>
<div id="status-panel-body"></div>
</div>
<div id="confirm-modal">
<div id="confirm-modal-box">
<h3 id="confirm-modal-title">Remove locations?</h3>
<p id="confirm-modal-text"></p>
<div class="modal-actions">
<button class="modal-btn cancel" id="confirm-modal-cancel">Cancel</button>
<button class="modal-btn submit" id="confirm-modal-submit" style="background:#922424;">Remove</button>
</div>
</div>
</div>
<div id="add-modal">
<div id="add-modal-box">
<h3 id="modal-title">Add location</h3>
<div class="form-field">
<label>Display name</label>
<input type="text" id="input-name" placeholder="e.g. Drammen office" />
</div>
<div id="address-field" class="form-field">
<label>Address</label>
<input type="text" id="input-address" placeholder="e.g. Storgata 1, Drammen" />
</div>
<div id="coords-fields" style="display:none">
<div class="form-row">
<div class="form-field"><label>Latitude</label><input type="number" step="any" id="input-lat" placeholder="59.7441" /></div>
<div class="form-field"><label>Longitude</label><input type="number" step="any" id="input-lng" placeholder="10.2045" /></div>
</div>
</div>
<div class="form-error" id="form-error"></div>
<div class="modal-actions">
<button class="modal-btn cancel" id="modal-cancel">Cancel</button>
<button class="modal-btn submit" id="modal-submit">Add location</button>
</div>
</div>
</div>
<script>
let map, hqMarker, currentTileLayer;
const MAP_STYLES = {
dark: {
label: 'Dark Matter',
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CARTO',
subdomains: 'abcd', maxZoom: 19,
},
voyager: {
label: 'Voyager (light)',
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CARTO',
subdomains: 'abcd', maxZoom: 19,
},
positron: {
label: 'Positron (light, minimal)',
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CARTO',
subdomains: 'abcd', maxZoom: 19,
},
osm: {
label: 'OpenStreetMap (standard)',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap',
subdomains: 'abc', maxZoom: 19,
},
satellite: {
label: 'Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© Esri',
subdomains: '', maxZoom: 19,
},
};
function setMapStyle(key, persist = true) {
const style = MAP_STYLES[key] || MAP_STYLES.dark;
if (currentTileLayer) currentTileLayer.remove();
currentTileLayer = L.tileLayer(style.url, {
attribution: style.attribution,
subdomains: style.subdomains,
maxZoom: style.maxZoom,
}).addTo(map);
if (persist) localStorage.setItem('map-style', key);
document.querySelectorAll('.map-style-opt').forEach(el => {
el.classList.toggle('active', el.dataset.key === key);
});
}
let locations = [];
let markers = {};
let activeRoute = null;
let config = {};
let addMode = null;
let removeMode = false;
let refreshTimer = null;
let activeLocationId = null;
let editingId = null;
let previousStatuses = {}; // loc id → last known status, for pulse detection
let consecutiveFailures = 0; // counts refresh cycles with no successful API response
let autoReloadTimer = null; // handle for the page auto-reload timer
// ── Kiosk mode ───────────────────────────────────────────────────────────────
const urlParams = new URLSearchParams(window.location.search);
const KIOSK = urlParams.get('kiosk') === 'true';
const AUTO_RELOAD_MINUTES = parseInt(urlParams.get('reload') || '360', 10);
if (KIOSK) {
document.body.classList.add('kiosk');
// Clock — updates every second
const clockEl = document.getElementById('kiosk-clock');
function updateClock() {
const now = new Date();
clockEl.textContent = now.toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
}
updateClock();
setInterval(updateClock, 1000);
// Auto-reload — recovers from crashes/memory leaks on the wallboard
autoReloadTimer = setTimeout(() => {
window.location.reload();
}, AUTO_RELOAD_MINUTES * 60 * 1000);
console.log(`[kiosk] Auto-reload in ${AUTO_RELOAD_MINUTES} minutes`);
// Disable keyboard shortcuts in kiosk except Escape to exit
// (Escape exits kiosk by removing the URL param and reloading)
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
const url = new URL(window.location.href);
url.searchParams.delete('kiosk');
window.location.href = url.toString();
}
}, { capture: true });
}
// ── Init ───────────────────────────────────────────────────────────────────
async function init() {
try {
config = await api('/api/config');
document.getElementById('app-title').textContent = '🗺 ' + config.app_title;
document.title = config.app_title;
document.getElementById('hq-badge').textContent = 'HQ: ' + config.hq_label;
map = L.map('map').setView([config.hq_lat, config.hq_lng], 10);
setMapStyle(localStorage.getItem('map-style') || 'dark', false);
hqMarker = L.marker([config.hq_lat, config.hq_lng], {
icon: hqIcon(), zIndexOffset: 1000
}).addTo(map).bindPopup('<strong>HQ</strong><br>' + config.hq_label);
map.on('click', clearRoute);
await refreshLocations();
scheduleRefresh();
document.getElementById('loading-overlay').classList.add('hidden');
} catch (e) {
document.querySelector('#loading-overlay p').textContent = 'Error: ' + e.message;
console.error(e);
}
}
function scheduleRefresh() {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(async () => {
await api('/api/refresh', 'POST');
await refreshLocations();
scheduleRefresh();
}, (config.refresh_interval || 300) * 1000);
}
// ── API ────────────────────────────────────────────────────────────────────
async function api(url, method = 'GET', body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== null) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
if (!r.ok) {
const err = await r.json().catch(() => ({ detail: r.statusText }));
throw new Error(err.detail || r.statusText);
}
return r.json();
}
// ── Refresh ────────────────────────────────────────────────────────────────
async function refreshLocations() {
try {
const fresh = await api('/api/locations');
// Detect status changes for pulse animation before updating locations
fresh.forEach(loc => {
const prev = previousStatuses[loc.id];
if (prev && loc.status && prev !== loc.status) {
const statusRank = { green: 0, yellow: 1, red: 2, dark_red: 3, unknown: -1 };
if ((statusRank[loc.status] || 0) > (statusRank[prev] || 0)) {
// Status worsened — mark for pulse
loc._pulse = true;
}
}
if (loc.status) previousStatuses[loc.id] = loc.status;
});
locations = fresh;
consecutiveFailures = 0;
document.getElementById('offline-banner').classList.remove('visible');
// Reset auto-reload timer on successful fetch — only reload during quiet periods
if (KIOSK && autoReloadTimer) {
clearTimeout(autoReloadTimer);
autoReloadTimer = setTimeout(() => window.location.reload(), AUTO_RELOAD_MINUTES * 60 * 1000);
}
} catch (e) {
consecutiveFailures++;
console.warn(`[refresh] Failed (${consecutiveFailures} consecutive):`, e.message);
if (consecutiveFailures >= 2) {
document.getElementById('offline-banner').classList.add('visible');
}
// Keep showing stale data — don't clear locations
updateStaleIndicator();
return;
}
renderList();
updateMarkers();
updateFavicon();
updateStaleIndicator();
if (activeTab === 'stats') renderStats();
const now = new Date();
const lastUpdatedEl = document.getElementById('last-updated');
lastUpdatedEl.textContent = 'Updated ' + now.toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit', hour12: false });
lastUpdatedEl.className = ''; // clear stale classes on successful refresh
await refreshStatus();
}
// ── Stale data indicator ───────────────────────────────────────────────────
function updateStaleIndicator() {
const now = Date.now() / 1000;
const interval = config.refresh_interval || 300;
const lastUpdatedEl = document.getElementById('last-updated');
// Find the oldest last_updated across all locations
const timestamps = locations.map(l => l.last_updated).filter(Boolean);
if (!timestamps.length) return;
const oldest = Math.min(...timestamps);
const age = now - oldest;
// Update header timestamp color
if (age > interval * 3) {
lastUpdatedEl.classList.add('stale-error');
lastUpdatedEl.classList.remove('stale-warn');
} else if (age > interval * 1.5) {
lastUpdatedEl.classList.add('stale-warn');
lastUpdatedEl.classList.remove('stale-error');
} else {
lastUpdatedEl.classList.remove('stale-warn', 'stale-error');
}
// Mark individual stale rows
locations.forEach(loc => {
if (!loc.last_updated) return;
const locAge = now - loc.last_updated;
const el = document.querySelector(`.loc-item[data-id="${loc.id}"]`);
if (el) el.classList.toggle('stale', locAge > interval * 2);
});
}
// ── Provider status indicator ─────────────────────────────────────────────
let lastStatus = null;
async function refreshStatus() {
try {
lastStatus = await api('/api/status');
} catch (e) {
console.warn('Status check failed:', e);
return;
}
const btn = document.getElementById('status-btn');
const hasIssues = !lastStatus.healthy;
btn.style.display = hasIssues ? 'flex' : 'none';
btn.classList.toggle('warn', hasIssues);
if (hasIssues) {
const n = lastStatus.failing_locations.length;
btn.textContent = lastStatus.hq_error
? '⚠ HQ error'
: `⚠ ${n} issue${n === 1 ? '' : 's'}`;
}
}
function renderStatusPanel() {
const body = document.getElementById('status-panel-body');
if (!lastStatus) { body.innerHTML = 'Loading…'; return; }
const lastOk = lastStatus.last_refresh_ok
? new Date(lastStatus.last_refresh_ok * 1000).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit', hour12: false })
: 'never';
const lastAttempt = lastStatus.last_refresh_attempt
? new Date(lastStatus.last_refresh_attempt * 1000).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit', hour12: false })
: '—';
let html = `
<div class="status-row"><span>Routing provider</span><span style="color:var(--text)">${lastStatus.routing_provider}</span></div>
<div class="status-row"><span>Refresh interval</span><span style="color:var(--text)">${lastStatus.refresh_interval}s</span></div>
<div class="status-row"><span>Last attempt</span><span style="color:var(--text)">${lastAttempt}</span></div>
<div class="status-row"><span>Last success</span><span style="color:var(--text)">${lastOk}</span></div>
`;
if (lastStatus.hq_error) {
html += `
<div class="status-fail-item">
<div class="status-fail-name">⚠ HQ could not be resolved</div>
<div class="status-fail-msg">${escHtml(lastStatus.hq_error)}</div>
<div class="status-fail-msg" style="margin-top:6px;">Check HQ_ADDRESS or HQ_LAT/HQ_LNG in your .env, and that your routing provider's API key is valid.</div>
</div>
`;
} else if (lastStatus.failing_locations.length === 0) {
html += `<div class="status-row" style="margin-top:4px;"><span class="status-ok">✓ All locations fetching normally</span></div>`;
} else {
html += `<div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.05em; margin-top:10px; margin-bottom:2px;">Failing locations</div>`;
lastStatus.failing_locations.forEach(f => {
const t = new Date(f.at * 1000).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit', hour12: false });
html += `
<div class="status-fail-item">
<div class="status-fail-name">${escHtml(f.name)}</div>
<div class="status-fail-msg">${escHtml(f.message)}</div>
<div class="status-fail-msg" style="opacity:0.6;">Last attempt: ${t}</div>
</div>
`;
});
html += `<div class="status-fail-msg" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
Common causes: invalid or expired API key, exceeded free-tier quota, no route found between HQ and this address, or a temporary provider outage. Use the ⋯ menu on the location to retry.
</div>`;
}
body.innerHTML = html;
}
const statusBtn = document.getElementById('status-btn');
const statusPanel = document.getElementById('status-panel');
statusBtn.addEventListener('click', e => {
e.stopPropagation();
const r = statusBtn.getBoundingClientRect();
statusPanel.style.top = (r.bottom + 4) + 'px';
statusPanel.style.left = Math.min(r.left, window.innerWidth - 360) + 'px';
const willOpen = !statusPanel.classList.contains('visible');
statusPanel.classList.toggle('visible');
if (willOpen) renderStatusPanel();
});
document.addEventListener('click', e => {
if (!statusPanel.contains(e.target) && e.target !== statusBtn) statusPanel.classList.remove('visible');
});
// ── Sidebar ────────────────────────────────────────────────────────────────
function renderList() {
const list = document.getElementById('location-list');
document.getElementById('loc-count').textContent = locations.length + ' total';
list.innerHTML = '';
if (!locations.length) {
list.innerHTML = '<div style="padding:28px 16px;color:var(--muted);font-size:14px;">No locations yet. Click Add to get started.</div>';
initTouchDragAll();
return;
}
// Sort by worst status first, then by highest delay ratio within same status.
// Preserves insertion order in locations.json — this is display-only.
const statusRank = { dark_red: 0, red: 1, yellow: 2, green: 3, unknown: 4 };
const sorted = [...locations].sort((a, b) => {
const ra = statusRank[a.status] ?? 4;
const rb = statusRank[b.status] ?? 4;
if (ra !== rb) return ra - rb;
return (b.ratio || 0) - (a.ratio || 0);
});
sorted.forEach((loc, idx) => {
const status = loc.status || 'unknown';
const item = document.createElement('div');
item.className = `loc-item row-${status}`;
item.dataset.id = loc.id;
if (loc.id === activeLocationId) item.classList.add('highlighted');
const oneWay = loc.one_way_min != null ? loc.one_way_min : '—';
const rt = loc.round_trip_min != null ? loc.round_trip_min : '—';
const timeText = `${oneWay} min → ${rt} min round trip`;
let pillLabel = '…';
if (loc.error) {
pillLabel = 'Failed';
} else if (loc.status) {
pillLabel = (loc.ratio != null && loc.ratio >= 1.005)
? '+' + Math.round((loc.ratio - 1) * 100) + '%'
: 'Normal';
}
if (loc.simulated) pillLabel += ' 🧪';
const isEditing = editingId === loc.id;
const rowNum = idx + 1;
const numDisplay = loc.error ? '⚠' : rowNum;
const numClass = loc.error ? 'loc-num error' : 'loc-num';
const displayTimeText = loc.error ? 'Fetch failed — see ⋯ menu to retry' : timeText;
item.innerHTML = `
<div class="loc-bar bar-${status}"></div>
<div class="loc-inner">
<input type="checkbox" class="loc-checkbox${removeMode ? '' : ' remove-mode-hide'}" data-id="${loc.id}" />
${!removeMode ? `<span class="drag-handle" title="Drag to reorder">⠿</span>` : ''}
<div class="${numClass}" title="${loc.error ? 'Last fetch failed' : ''}">${numDisplay}</div>
<div class="loc-name-block">
${isEditing
? `<input class="loc-name-input" id="rename-input-${loc.id}" value="${escHtml(loc.name)}" />`
: `<div class="loc-name">${escHtml(loc.name)}</div>`}
<div class="loc-time">${displayTimeText}</div>
</div>
<div class="loc-right">
<div class="status-pill pill-${status}${loc.error ? ' failed' : ''}">${pillLabel}</div>
${isEditing
? `<button class="edit-btn" data-action="save" data-id="${loc.id}" title="Save">✓</button>
<button class="edit-btn" data-action="cancel" data-id="${loc.id}" title="Cancel">✕</button>`
: `<button class="loc-menu-btn" data-id="${loc.id}" title="More options">⋯</button>`}
</div>
</div>
`;
// Checkbox
const cb = item.querySelector('.loc-checkbox');
if (removeMode) cb.style.display = 'block';
cb.addEventListener('change', updateRemoveCount);
// Edit save/cancel (when in rename mode)
item.querySelectorAll('.edit-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const action = btn.dataset.action;
const id = btn.dataset.id;
if (action === 'save') saveRename(id);
else if (action === 'cancel') { editingId = null; renderList(); }
});
});
// 3-dot context menu
const menuBtn = item.querySelector('.loc-menu-btn');
if (menuBtn) {
menuBtn.addEventListener('click', e => {
e.stopPropagation();
openCtxMenu(loc.id, menuBtn);
});
}
// Drag to reorder
if (!removeMode) {
const handle = item.querySelector('.drag-handle');
if (handle) {
handle.addEventListener('mousedown', () => { item.draggable = true; });
handle.addEventListener('mouseleave', () => { if (!item.classList.contains('dragging')) item.draggable = false; });
}
item.addEventListener('dragstart', e => {
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', loc.id);
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
item.draggable = false;
document.querySelectorAll('.loc-item').forEach(el => el.classList.remove('drag-over'));
});
item.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.loc-item').forEach(el => el.classList.remove('drag-over'));
item.classList.add('drag-over');
});
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', async e => {
e.preventDefault();
item.classList.remove('drag-over');
const draggedId = e.dataTransfer.getData('text/plain');
if (draggedId === loc.id) return;
const list = document.getElementById('location-list');
const items = [...list.querySelectorAll('.loc-item')];
const draggedEl = list.querySelector(`.loc-item[data-id="${draggedId}"]`);
if (!draggedEl) return;
// Reorder in DOM
list.insertBefore(draggedEl, item);
// Persist new order
const newOrder = [...list.querySelectorAll('.loc-item')].map(el => el.dataset.id);
await api('/api/locations/reorder', 'POST', { ids: newOrder });
await refreshLocations();
});
}
// Enter/Escape in rename input
if (isEditing) {
const inp = item.querySelector(`#rename-input-${loc.id}`);
if (inp) {
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') saveRename(loc.id);
if (e.key === 'Escape') { editingId = null; renderList(); }
});
}
}
// Row click → select (not when editing or removing)
item.addEventListener('click', e => {
if (e.target.classList.contains('loc-checkbox') ||
e.target.classList.contains('edit-btn') ||
e.target.classList.contains('loc-name-input')) return;
if (removeMode) {
cb.checked = !cb.checked;
updateRemoveCount();
return;
}
if (editingId) return;
selectLocation(loc.id);
});
list.appendChild(item);
// Trigger pulse if status worsened this cycle
if (loc._pulse) {
requestAnimationFrame(() => {
const el = list.querySelector(`.loc-item[data-id="${loc.id}"]`);
if (el) {
el.classList.remove('pulsing');
void el.offsetWidth; // force reflow so animation re-triggers
el.classList.add('pulsing');
el.addEventListener('animationend', () => el.classList.remove('pulsing'), { once: true });
}
});
}
});
initTouchDragAll();
}
function focusRenameInput(id) {
const inp = document.getElementById(`rename-input-${id}`);
if (inp) { inp.focus(); inp.select(); }
}
async function saveRename(id) {
const inp = document.getElementById(`rename-input-${id}`);
if (!inp) return;
const newName = inp.value.trim();
if (!newName) return;
try {
await api(`/api/locations/${id}/rename`, 'PATCH', { name: newName });
editingId = null;
await refreshLocations();
} catch (e) {
alert('Rename failed: ' + e.message);
}
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Select / route ─────────────────────────────────────────────────────────
function selectLocation(id) {
activeLocationId = id;
const loc = locations.find(l => l.id === id);
if (!loc) return;
document.querySelectorAll('.loc-item').forEach(el => el.classList.remove('highlighted'));
const item = document.querySelector(`.loc-item[data-id="${id}"]`);
if (item) { item.classList.add('highlighted'); item.scrollIntoView({ block: 'nearest' }); }
if (markers[id]) {
markers[id].openPopup();
}
if (loc.geometry) {
drawRoute(loc.geometry, loc.geometry_type || 'polyline', loc.status || 'unknown');
} else {
// No geometry yet — just pan to the marker
if (markers[id]) map.setView(markers[id].getLatLng(), 12, { animate: true });
}
}
// ── Route ──────────────────────────────────────────────────────────────────
const routeColors = {
green: '#22863a', yellow: '#a06c10',
red: '#922424', dark_red: '#5a1414', unknown: '#555'
};
function drawRoute(geometry, geometryType, status) {
clearRoute();
try {
const latlngs = geometryType === 'here_polyline'
? decodeHerePolyline(geometry)
: decodePolyline(geometry);
activeRoute = L.polyline(latlngs, {
color: routeColors[status] || '#555',
weight: 6, opacity: 0.9,
}).addTo(map);
map.fitBounds(activeRoute.getBounds(), { padding: [50, 50] });
} catch (e) {
console.warn('Route draw failed:', e);
}
}
function clearRoute() {
if (activeRoute) { activeRoute.remove(); activeRoute = null; }
}
// Google-encoded polyline decoder (precision 5) — ORS, Google, Mapbox
function decodePolyline(encoded) {
const pts = [];
let i = 0, lat = 0, lng = 0;
while (i < encoded.length) {
let b, shift = 0, result = 0;
do { b = encoded.charCodeAt(i++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20);
lat += (result & 1) ? ~(result >> 1) : (result >> 1);
shift = 0; result = 0;
do { b = encoded.charCodeAt(i++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20);
lng += (result & 1) ? ~(result >> 1) : (result >> 1);
pts.push([lat / 1e5, lng / 1e5]);
}
return pts;
}
// HERE Flexible Polyline decoder
function decodeHerePolyline(encoded) {
const TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
function decodeVar(s, i) {
let r = 0, sh = 0, v;
do { v = TABLE.indexOf(s[i++]); r |= (v & 0x1f) << sh; sh += 5; } while (v >= 0x20 && i < s.length);
return { val: (r & 1) ? ~(r >> 1) : r >> 1, i };
}
// skip version byte + header
let pos = 1;
let hlen = TABLE.indexOf(encoded[pos++]);
pos += hlen;
const pts = [];
let lat = 0, lng = 0;
while (pos < encoded.length) {
let dl = decodeVar(encoded, pos); pos = dl.i;
let dg = decodeVar(encoded, pos); pos = dg.i;
lat += dl.val; lng += dg.val;
pts.push([lat / 1e5, lng / 1e5]);
}
return pts;
}
// ── Markers ────────────────────────────────────────────────────────────────
const statusHex = {
green: '#22863a', yellow: '#a06c10',
red: '#922424', dark_red: '#5a1414', unknown: '#444'
};
function coloredIcon(status, num) {
const c = statusHex[status] || statusHex.unknown;
const label = num != null ? num : '';
const fontSize = num > 9 ? '9' : '11';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="40" viewBox="0 0 28 40">
<path d="M14 0C6.3 0 0 6.3 0 14c0 10.5 14 26 14 26S28 24.5 28 14C28 6.3 21.7 0 14 0z" fill="${c}" stroke="#111" stroke-width="1.5"/>
<text x="14" y="19" text-anchor="middle" font-size="${fontSize}" fill="white" font-weight="bold" font-family="sans-serif">${label}</text>
</svg>`;
return L.divIcon({ html: svg, className: '', iconSize: [28, 40], iconAnchor: [14, 40], popupAnchor: [0, -40] });
}
function hqIcon() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="30" height="42" viewBox="0 0 30 42">
<path d="M15 0C6.7 0 0 6.7 0 15c0 11.25 15 27 15 27S30 26.25 30 15C30 6.7 23.3 0 15 0z" fill="#3b82f6" stroke="#1e3a5f" stroke-width="2"/>
<text x="15" y="20" text-anchor="middle" font-size="11" fill="white" font-weight="bold" font-family="sans-serif">HQ</text>
</svg>`;
return L.divIcon({ html: svg, className: '', iconSize: [30, 42], iconAnchor: [15, 42], popupAnchor: [0, -42] });
}
function updateMarkers() {
const currentIds = new Set(locations.map(l => l.id));
Object.keys(markers).forEach(id => {
if (!currentIds.has(id)) { markers[id].remove(); delete markers[id]; }
});
locations.forEach((loc, idx) => {
const num = idx + 1;
const status = loc.status || 'unknown';
const timeStr = loc.one_way_min != null
? `${loc.one_way_min} min → ${loc.round_trip_min} min round trip`
: 'Fetching…';
const popup = `<strong>${num}. ${escHtml(loc.name)}</strong><br>${timeStr}`;
if (markers[loc.id]) {
markers[loc.id].setIcon(coloredIcon(status, num)).setPopupContent(popup);
} else {
markers[loc.id] = L.marker([loc.lat, loc.lng], { icon: coloredIcon(status, num) })
.addTo(map).bindPopup(popup)
.on('click', () => selectLocation(loc.id));
}
});
}
// ── Remove mode ────────────────────────────────────────────────────────────
document.getElementById('remove-btn').addEventListener('click', () => {
removeMode = !removeMode;
editingId = null;
if (removeMode && activeTab === 'stats') switchTab('locations');
document.getElementById('remove-btn').classList.toggle('active', removeMode);
renderList();
document.getElementById('remove-bar').classList.toggle('visible', removeMode);
if (!removeMode) updateRemoveCount();
});
function updateRemoveCount() {
const n = document.querySelectorAll('.loc-checkbox:checked').length;
document.getElementById('remove-count').textContent = n + ' selected';
}
document.getElementById('confirm-remove-btn').addEventListener('click', () => {
const ids = [...document.querySelectorAll('.loc-checkbox:checked')].map(cb => cb.dataset.id);
if (!ids.length) return;
openDeleteConfirm(ids);
});
// ── Delete confirmation modal (Enter to approve) ──────────────────────────
let pendingDeleteIds = [];
const confirmModal = document.getElementById('confirm-modal');
function openDeleteConfirm(ids) {
pendingDeleteIds = ids;
const names = ids
.map(id => locations.find(l => l.id === id))
.filter(Boolean)
.map(l => l.name);
const text = names.length === 1
? `This will permanently remove "${names[0]}" and its travel time history.`
: `This will permanently remove ${names.length} locations: ${names.join(', ')}.`;
document.getElementById('confirm-modal-text').textContent = text;
confirmModal.classList.add('visible');
document.getElementById('confirm-modal-submit').focus();
}
function closeDeleteConfirm() {
confirmModal.classList.remove('visible');
pendingDeleteIds = [];
}
async function executeDelete() {
const ids = pendingDeleteIds;
if (!ids.length) return;
closeDeleteConfirm();
if (ids.includes(activeLocationId)) { clearRoute(); activeLocationId = null; }
await api('/api/locations', 'DELETE', ids);
removeMode = false;
document.getElementById('remove-btn').classList.remove('active');
document.getElementById('remove-bar').classList.remove('visible');
await refreshLocations();
}
document.getElementById('confirm-modal-submit').addEventListener('click', executeDelete);
document.getElementById('confirm-modal-cancel').addEventListener('click', closeDeleteConfirm);
confirmModal.addEventListener('click', e => { if (e.target === confirmModal) closeDeleteConfirm(); });
confirmModal.addEventListener('keydown', e => {
if (e.key === 'Enter') executeDelete();
if (e.key === 'Escape') closeDeleteConfirm();
});
// ── Add dropdown ───────────────────────────────────────────────────────────
const addBtn = document.getElementById('add-btn');
const addDropdown = document.getElementById('add-dropdown');
addBtn.addEventListener('click', e => {
e.stopPropagation();
const r = addBtn.getBoundingClientRect();
addDropdown.style.top = (r.bottom + 4) + 'px';
addDropdown.style.left = r.left + 'px';
addDropdown.classList.toggle('visible');
});
document.addEventListener('click', () => addDropdown.classList.remove('visible'));
document.getElementById('add-by-address').addEventListener('click', () => openModal('address'));
document.getElementById('add-by-coords').addEventListener('click', () => openModal('coords'));
// ── Map style dropdown ───────────────────────────────────────────────────────
const mapStyleBtn = document.getElementById('map-style-btn');
const mapStyleDropdown = document.getElementById('map-style-dropdown');
mapStyleBtn.addEventListener('click', e => {
e.stopPropagation();
const r = mapStyleBtn.getBoundingClientRect();
mapStyleDropdown.style.top = (r.bottom + 4) + 'px';
mapStyleDropdown.style.left = r.left + 'px';
mapStyleDropdown.classList.toggle('visible');
});
document.addEventListener('click', () => mapStyleDropdown.classList.remove('visible'));
document.querySelectorAll('.map-style-opt').forEach(opt => {
opt.addEventListener('click', () => {
setMapStyle(opt.dataset.key);
mapStyleDropdown.classList.remove('visible');
});
});
// ── Kiosk mode button ──────────────────────────────────────────────────────
document.getElementById('kiosk-btn').addEventListener('click', () => {
const url = new URL(window.location.href);
url.searchParams.set('kiosk', 'true');
window.location.href = url.toString();
});
// ── Add modal ──────────────────────────────────────────────────────────────
function openModal(mode) {
addMode = mode;
addDropdown.classList.remove('visible');
document.getElementById('add-modal').classList.add('visible');
document.getElementById('modal-title').textContent = mode === 'address' ? 'Add by address' : 'Add by coordinates';
document.getElementById('address-field').style.display = mode === 'address' ? '' : 'none';
document.getElementById('coords-fields').style.display = mode === 'coords' ? '' : 'none';
['input-name','input-address','input-lat','input-lng'].forEach(id => { const el = document.getElementById(id); if(el) el.value = ''; });
hideError();
document.getElementById('input-name').focus();
}
function closeModal() { document.getElementById('add-modal').classList.remove('visible'); addMode = null; }
document.getElementById('modal-cancel').addEventListener('click', closeModal);
document.getElementById('add-modal').addEventListener('click', e => { if (e.target === document.getElementById('add-modal')) closeModal(); });
document.getElementById('modal-submit').addEventListener('click', async () => {
const btn = document.getElementById('modal-submit');
const name = document.getElementById('input-name').value.trim();
if (!name) { showError('Name is required.'); return; }
btn.disabled = true; btn.textContent = 'Adding…'; hideError();
try {
if (addMode === 'address') {
const address = document.getElementById('input-address').value.trim();
if (!address) { showError('Address is required.'); return; }
await api('/api/locations/add/address', 'POST', { name, address });
} else {
const lat = parseFloat(document.getElementById('input-lat').value);
const lng = parseFloat(document.getElementById('input-lng').value);
if (isNaN(lat) || isNaN(lng)) { showError('Valid lat/lng required.'); return; }
await api('/api/locations/add/coords', 'POST', { name, lat, lng });
}
closeModal();
await refreshLocations();
} catch (e) {
showError(e.message);
} finally {
btn.disabled = false; btn.textContent = 'Add location';
}
});
document.getElementById('add-modal-box').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('modal-submit').click();
if (e.key === 'Escape') closeModal();
});
function showError(msg) { const el = document.getElementById('form-error'); el.textContent = msg; el.classList.add('visible'); }
function hideError() { document.getElementById('form-error').classList.remove('visible'); }
// ── Manual refresh ─────────────────────────────────────────────────────────
document.getElementById('refresh-btn').addEventListener('click', async () => {
const btn = document.getElementById('refresh-btn');
btn.textContent = '↻ Refreshing…'; btn.disabled = true;
try {
await api('/api/refresh', 'POST');
await refreshLocations();
scheduleRefresh();
} finally { btn.textContent = '↻ Refresh'; btn.disabled = false; }
});
// ── Context menu ──────────────────────────────────────────────────────────
let ctxLocationId = null;
const ctxMenu = document.getElementById('ctx-menu');
function openCtxMenu(id, anchor) {
ctxLocationId = id;
const loc = locations.find(l => l.id === id);
document.getElementById('ctx-retry').style.display = (loc && loc.error) ? 'flex' : 'none';
const rect = anchor.getBoundingClientRect();
ctxMenu.style.top = (rect.bottom + 4) + 'px';
ctxMenu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px';
ctxMenu.classList.add('visible');
}
function closeCtxMenu() { ctxMenu.classList.remove('visible'); ctxLocationId = null; }
document.addEventListener('click', e => {
if (!ctxMenu.contains(e.target)) closeCtxMenu();
});
document.getElementById('ctx-retry').addEventListener('click', async () => {
if (!ctxLocationId) return;
closeCtxMenu();
// Retries all locations in one pass (the backend refreshes everything in parallel anyway)
await api('/api/refresh', 'POST');
await refreshLocations();
});
document.getElementById('ctx-rename').addEventListener('click', () => {
if (!ctxLocationId) return;
const id = ctxLocationId;
closeCtxMenu();
editingId = id;
renderList();
focusRenameInput(id);
});
document.getElementById('ctx-reset').addEventListener('click', async () => {
if (!ctxLocationId) return;
const id = ctxLocationId;
closeCtxMenu();
await api(`/api/locations/${id}/reset-baseline`, 'POST');
await refreshLocations();
});
async function simulateDelay(ratio) {
if (!ctxLocationId) return;
const id = ctxLocationId;
closeCtxMenu();
try {
await api(`/api/locations/${id}/simulate-delay`, 'POST', { ratio });
await refreshLocations();
} catch (e) {
alert('Simulation failed: ' + e.message);
}
}
document.getElementById('ctx-sim-yellow').addEventListener('click', () => simulateDelay(1.30));
document.getElementById('ctx-sim-red').addEventListener('click', () => simulateDelay(1.60));
document.getElementById('ctx-sim-dred').addEventListener('click', () => simulateDelay(1.80));
document.getElementById('ctx-sim-clear').addEventListener('click', () => simulateDelay(1.0));
// ── Favicon ────────────────────────────────────────────────────────────────
function updateFavicon() {
const counts = { dark_red: 0, red: 0, yellow: 0, green: 0, unknown: 0 };
locations.forEach(l => { if (l.status) counts[l.status] = (counts[l.status] || 0) + 1; });
let color = '#444';
if (counts.dark_red > 0) color = '#5a1414';
else if (counts.red > 0) color = '#922424';
else if (counts.yellow > 0) color = '#a06c10';
else if (counts.green > 0) color = '#22863a';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="${color}"/>
<circle cx="16" cy="16" r="8" fill="white" opacity="0.9"/>
</svg>`;
const url = 'data:image/svg+xml,' + encodeURIComponent(svg);
let link = document.querySelector("link[rel='icon']");
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
link.href = url;
// Update tab title with worst status indicator
const worst = counts.dark_red ? '🔴' : counts.red ? '🔴' : counts.yellow ? '🟡' : '';
document.title = (worst ? worst + ' ' : '') + config.app_title;
}
// ── Resizable sidebar ──────────────────────────────────────────────────────
(function() {
const handle = document.getElementById('resize-handle');
const sidebar = document.getElementById('sidebar');
let dragging = false, startX = 0, startWidth = 0;
const MIN_WIDTH = 280, MAX_WIDTH = 600;
handle.addEventListener('mousedown', e => {
dragging = true;
startX = e.clientX;
startWidth = sidebar.offsetWidth;
handle.classList.add('dragging');
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
const delta = startX - e.clientX; // dragging left = wider sidebar
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
sidebar.style.width = newWidth + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
handle.classList.remove('dragging');
document.body.style.userSelect = '';
document.body.style.cursor = '';
// Persist width in localStorage
localStorage.setItem('sidebar-width', sidebar.offsetWidth);
});
// Restore saved width
const saved = localStorage.getItem('sidebar-width');
if (saved) sidebar.style.width = saved + 'px';
})();
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
let kbToastTimer = null;
const KB_TOAST_DEFAULT = document.getElementById('kb-toast').innerHTML;
function showKbToast(message) {
const toast = document.getElementById('kb-toast');
toast.innerHTML = message
? `<span style="color:var(--text);">${message}</span>`
: KB_TOAST_DEFAULT;
toast.classList.add('visible');
if (kbToastTimer) clearTimeout(kbToastTimer);
kbToastTimer = setTimeout(() => {
toast.classList.remove('visible');
toast.innerHTML = KB_TOAST_DEFAULT;
}, 3000);
}
// ── Hidden debug mode unlock ───────────────────────────────────────────────
// Type "thisisdebug" anywhere on the page (not while focused in an input)
// to reveal debug/simulation options in the location context menu.
let debugMode = (localStorage.getItem('debug-mode') === 'true');
let keyBuffer = '';
const DEBUG_PHRASE = 'thisisdebug';
function applyDebugMode() {
document.getElementById('ctx-debug-section').style.display = debugMode ? 'block' : 'none';
}
applyDebugMode();
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key.length === 1) {
keyBuffer = (keyBuffer + e.key.toLowerCase()).slice(-DEBUG_PHRASE.length);
if (keyBuffer === DEBUG_PHRASE) {
debugMode = !debugMode;
localStorage.setItem('debug-mode', debugMode);
applyDebugMode();
showKbToast(debugMode ? '🧪 Debug mode enabled' : 'Debug mode disabled');
}
}
});
document.addEventListener('keydown', e => {
// Don't fire shortcuts when typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Don't fire global shortcuts while the delete confirmation modal is open —
// its own keydown listener handles Enter/Escape for that modal.
if (confirmModal.classList.contains('visible')) return;
switch (e.key) {
case 'r':
case 'R':
e.preventDefault();
document.getElementById('refresh-btn').click();
break;
case '?':
showKbToast();
break;
case 'Tab':
e.preventDefault();
switchTab(activeTab === 'locations' ? 'stats' : 'locations');
break;
case 'Escape':
closeCtxMenu();
addDropdown.classList.remove('visible');
closeModal();
editingId = null;
renderList();
break;
default:
// 1-9: jump to that location
if (e.key >= '1' && e.key <= '9') {
const idx = parseInt(e.key) - 1;
if (idx < locations.length) {
if (activeTab !== 'locations') switchTab('locations');
selectLocation(locations[idx].id);
}
}
}
});
// ── Tabs ───────────────────────────────────────────────────────────────────
let activeTab = 'locations';
let sortKey = 'one_way_min';
let sortDir = 'asc';
document.getElementById('tab-locations').addEventListener('click', () => switchTab('locations'));
document.getElementById('tab-stats').addEventListener('click', () => switchTab('stats'));
function switchTab(tab) {
activeTab = tab;
document.getElementById('tab-locations').classList.toggle('active', tab === 'locations');
document.getElementById('tab-stats').classList.toggle('active', tab === 'stats');
document.getElementById('location-list').classList.toggle('hidden', tab === 'stats');
document.getElementById('remove-bar').style.display = tab === 'stats' ? 'none' : '';
document.getElementById('stats-panel').classList.toggle('visible', tab === 'stats');
if (tab === 'stats') renderStats();
}
// Sort buttons
document.querySelectorAll('.sort-btn').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.key;
const dir = btn.dataset.dir;
sortKey = key;
sortDir = dir;
btn.dataset.dir = dir === 'asc' ? 'desc' : 'asc';
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderStats();
});
});
// Column header sort
document.querySelectorAll('.stats-tbl th[data-key]').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.key;
if (sortKey === key) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDir = key === 'ratio' ? 'desc' : 'asc';
}
renderStats();
});
});
function renderStats() {
const tbody = document.getElementById('stats-tbody');
// Update header arrows
document.querySelectorAll('.stats-tbl th[data-key]').forEach(th => {
th.classList.toggle('sorted', th.dataset.key === sortKey);
const arr = th.querySelector('.arr');
if (arr) arr.textContent = th.dataset.key === sortKey ? (sortDir === 'asc' ? '↑' : '↓') : '';
});
const sorted = [...locations].sort((a, b) => {
let av = a[sortKey], bv = b[sortKey];
if (av == null) av = sortDir === 'asc' ? Infinity : -Infinity;
if (bv == null) bv = sortDir === 'asc' ? Infinity : -Infinity;
if (typeof av === 'string') return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
return sortDir === 'asc' ? av - bv : bv - av;
});
tbody.innerHTML = '';
sorted.forEach(loc => {
const status = loc.status || 'unknown';
const dotColor = { green:'#22863a', yellow:'#a06c10', red:'#922424', dark_red:'#5a1414', unknown:'#444' }[status];
const delayClass = 'delay-' + status;
const delayText = loc.ratio != null
? (loc.ratio >= 1.005 ? '+' + Math.round((loc.ratio - 1) * 100) + '%' : 'Normal')
: '—';
const tr = document.createElement('tr');
if (loc.id === activeLocationId) tr.classList.add('highlighted');
tr.innerHTML = `
<td><span class="tbl-dot" style="background:${dotColor}"></span>${escHtml(loc.name)}</td>
<td>${loc.one_way_min != null ? loc.one_way_min + ' min' : '—'}</td>
<td>${loc.round_trip_min != null ? loc.round_trip_min + ' min' : '—'}</td>
<td>${loc.distance_km != null ? loc.distance_km + ' km' : '—'}</td>
<td class="${delayClass}">${delayText}</td>
`;
tr.addEventListener('click', () => {
switchTab('locations');
selectLocation(loc.id);
});
tbody.appendChild(tr);
});
}
// ── Mobile: detect ────────────────────────────────────────────────────────────
function isMobile() { return window.innerWidth <= 768; }
// ── Mobile: map toggle ────────────────────────────────────────────────────────
let mapCollapsed = false;
const mapToggleBtn = document.getElementById('map-toggle');
mapToggleBtn.addEventListener('click', () => {
mapCollapsed = !mapCollapsed;
document.getElementById('map').classList.toggle('collapsed', mapCollapsed);
mapToggleBtn.textContent = mapCollapsed ? '▲' : '▼';
mapToggleBtn.title = mapCollapsed ? 'Show map' : 'Hide map';
setTimeout(() => map && map.invalidateSize(), 260);
});
// ── Mobile: bottom nav ────────────────────────────────────────────────────────
function syncBottomNav() {
document.getElementById('bnav-locations').classList.toggle('active', activeTab === 'locations');
document.getElementById('bnav-stats').classList.toggle('active', activeTab === 'stats');
}
document.getElementById('bnav-locations').addEventListener('click', () => {
switchTab('locations'); syncBottomNav();
});
document.getElementById('bnav-stats').addEventListener('click', () => {
switchTab('stats'); syncBottomNav();
});
document.getElementById('bnav-refresh').addEventListener('click', () => {
document.getElementById('refresh-btn').click();
});
// Keep bottom nav in sync with tab switches triggered from desktop tab bar
const origSwitchTab = switchTab;
// switchTab is already defined above — patch sync in after each call
const _tabBtns = document.querySelectorAll('.tab-btn');
_tabBtns.forEach(btn => {
btn.addEventListener('click', () => syncBottomNav());
});
// ── Mobile: touch drag-to-reorder ────────────────────────────────────────────
// HTML5 drag API doesn't work reliably on iOS/Android, so we use touch events
// on the drag handle and manually calculate position + swap rows.
let touchDragEl = null;
let touchDragGhost = null;
let touchDragStartY = 0;
let touchDragOverEl = null;
function initTouchDrag(item, handle) {
handle.addEventListener('touchstart', e => {
if (e.touches.length !== 1) return;
e.preventDefault();
touchDragEl = item;
touchDragStartY = e.touches[0].clientY;
item.classList.add('dragging');
// Create a ghost clone that follows the finger
touchDragGhost = item.cloneNode(true);
touchDragGhost.style.cssText = `
position: fixed; left: 0; right: 0; z-index: 5000; opacity: 0.85;
pointer-events: none; background: var(--bg3);
border: 1px solid var(--accent);
top: ${item.getBoundingClientRect().top}px;
`;
document.body.appendChild(touchDragGhost);
}, { passive: false });
handle.addEventListener('touchmove', e => {
if (!touchDragEl || e.touches.length !== 1) return;
e.preventDefault();
const y = e.touches[0].clientY;
const dy = y - touchDragStartY;
// Move ghost
const origTop = touchDragEl.getBoundingClientRect().top;
touchDragGhost.style.top = (origTop + dy) + 'px';
// Find which row we're hovering over
document.querySelectorAll('.loc-item').forEach(el => el.classList.remove('drag-over'));
const els = [...document.querySelectorAll('.loc-item')];
for (const el of els) {
if (el === touchDragEl) continue;
const rect = el.getBoundingClientRect();
if (y >= rect.top && y <= rect.bottom) {
el.classList.add('drag-over');
touchDragOverEl = el;
break;
}
}
}, { passive: false });
handle.addEventListener('touchend', async e => {
if (!touchDragEl) return;
e.preventDefault();
touchDragEl.classList.remove('dragging');
document.querySelectorAll('.loc-item').forEach(el => el.classList.remove('drag-over'));
if (touchDragGhost) { touchDragGhost.remove(); touchDragGhost = null; }
if (touchDragOverEl && touchDragOverEl !== touchDragEl) {
const list = document.getElementById('location-list');
list.insertBefore(touchDragEl, touchDragOverEl);
const newOrder = [...list.querySelectorAll('.loc-item')].map(el => el.dataset.id);
await api('/api/locations/reorder', 'POST', { ids: newOrder });
await refreshLocations();
}
touchDragEl = null;
touchDragOverEl = null;
}, { passive: false });
}
// initTouchDragAll: called at end of renderList (see renderList function)
function initTouchDragAll() {
if (!isMobile()) return;
document.querySelectorAll('.loc-item').forEach(item => {
const handle = item.querySelector('.drag-handle');
if (handle) initTouchDrag(item, handle);
});
}
// ── Mobile: invalidate map on resize ─────────────────────────────────────────
window.addEventListener('resize', () => {
if (map) map.invalidateSize();
syncBottomNav();
});
init();
</script>
</body>
</html>