1920 lines
80 KiB
HTML
1920 lines
80 KiB
HTML
<!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 (<1.20×)</span></div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:var(--yellow)"></div><span style="color:#ccc">Worsened (1.20–1.50×)</span></div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div><span style="color:#ccc">Bad (1.50–1.70×)</span></div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:var(--dred)"></div><span style="color:#ccc">Reconsider (>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
|
||
<span class="kb">1–9</span> jump to location
|
||
<span class="kb">Tab</span> switch tab
|
||
<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ── 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>
|