← back to Ventura Bus 3d
public/index.html
185 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Ventura Bus 3D</title>
<script>
/* anti-flash: apply saved theme before paint */
(function () {
try {
var t = localStorage.getItem('vb3d-theme');
if (!t) t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t);
} catch (e) { document.documentElement.setAttribute('data-theme', 'dark'); }
})();
</script>
<style>
:root[data-theme="dark"] { --bg: #0b0d12; --fg: #e7ecf2; --muted: rgba(231,236,242,0.65); --accent: #ffb000; --panel: rgba(10,12,18,0.72); --line: rgba(255,255,255,0.12); --btn-bg: rgba(255,255,255,0.08); --active-fg: #1a1100; --highlight-bg: rgba(255,176,0,0.10); --highlight-border: rgba(255,176,0,0.25); --tile-bg: rgba(255,255,255,0.04); --tile-bg-hover: rgba(255,255,255,0.08); --toggle-off: rgba(255,255,255,0.18); --header-grad: linear-gradient(to bottom, rgba(10,12,18,0.88), rgba(10,12,18,0.55)); }
:root[data-theme="light"] { --bg: #f4f6fb; --fg: #14181f; --muted: rgba(20,24,31,0.6); --accent: #c46b00; --panel: rgba(255,255,255,0.86); --line: rgba(20,24,31,0.14); --btn-bg: rgba(20,24,31,0.06); --active-fg: #fffaf0; --highlight-bg: rgba(196,107,0,0.10); --highlight-border: rgba(196,107,0,0.25); --tile-bg: rgba(20,24,31,0.04); --tile-bg-hover: rgba(20,24,31,0.08); --toggle-off: rgba(20,24,31,0.22); --header-grad: linear-gradient(to bottom, rgba(244,246,251,0.95), rgba(244,246,251,0.65)); }
:root { --header-h: 56px; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, Segoe UI, sans-serif; overflow: hidden; transition: background 200ms ease, color 200ms ease; }
button, input, select { font: inherit; color: inherit; }
/* ── 3D canvas ───────────────────────────────── */
#app { position: fixed; inset: var(--header-h) 0 0 0; }
/* ── Top header (search + brand + theme) ─────── */
#header { position: fixed; top: 0; left: 0; right: 0; height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 16px; background: var(--header-grad); border-bottom: 1px solid var(--line); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); z-index: 20; }
#header .brand { font-weight: 700; letter-spacing: 0.02em; font-size: 14px; white-space: nowrap; }
#header .brand small { display: block; font-weight: 400; opacity: 0.6; font-size: 11px; }
#search-wrap { flex: 1; max-width: 420px; position: relative; }
#search { width: 100%; background: var(--btn-bg); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px 8px 36px; outline: none; }
#search:focus { border-color: var(--accent); }
#search::placeholder { color: var(--muted); }
#search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; opacity: 0.6; }
#search-hint { font-size: 11px; opacity: 0.55; white-space: nowrap; }
#theme-toggle { width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--line); background: var(--btn-bg); color: var(--fg); cursor: pointer; font-size: 14px; line-height: 1; flex: 0 0 auto; }
#theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
/* ── Panel chrome ────────────────────────────── */
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.panel h2 { margin: 0 0 8px; font-size: 11px; font-weight: 700; letter-spacing: 0.10em; text-transform: uppercase; opacity: 0.75; }
/* ── Left aside: route legend ────────────────── */
#legend { position: fixed; top: calc(var(--header-h) + 12px); left: 12px; width: 220px; padding: 12px 14px; max-height: calc(100vh - var(--header-h) - 24px); overflow-y: auto; z-index: 10; }
#legend ul.routes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
#legend li.route { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--tile-bg); border: 1px solid transparent; border-radius: 6px; cursor: pointer; user-select: none; }
#legend li.route:hover { background: var(--tile-bg-hover); border-color: var(--line); }
#legend li.route.off { opacity: 0.42; }
#legend li.route .swatch { flex: 0 0 14px; width: 14px; height: 14px; border-radius: 50%; box-shadow: 0 0 0 2px rgba(0,0,0,0.45); }
#legend li.route .label { flex: 1; min-width: 0; }
#legend li.route .line { font-weight: 700; font-size: 13px; }
#legend li.route .name { font-size: 11px; opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#legend li.route .toggle { flex: 0 0 auto; width: 28px; height: 16px; border-radius: 999px; background: var(--toggle-off); position: relative; transition: background 0.15s; }
#legend li.route .toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: #fff; transition: left 0.15s; box-shadow: 0 1px 2px rgba(0,0,0,0.3); }
#legend li.route:not(.off) .toggle { background: var(--accent); }
#legend li.route:not(.off) .toggle::after { left: 14px; }
/* ── Right aside: info panel ─────────────────── */
#info { position: fixed; top: calc(var(--header-h) + 12px); right: 12px; width: 260px; padding: 12px 14px; max-height: calc(100vh - var(--header-h) - 140px); overflow-y: auto; z-index: 10; }
#info .empty { font-size: 12px; opacity: 0.6; }
#info .route-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-weight: 700; font-size: 11px; color: var(--active-fg); }
#info .bus-name { margin-top: 6px; font-size: 14px; font-weight: 600; }
#info .next-stop { margin-top: 10px; padding: 8px 10px; background: var(--highlight-bg); border: 1px solid var(--highlight-border); border-radius: 6px; }
#info .next-stop .lbl { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.65; }
#info .next-stop .val { font-size: 13px; font-weight: 600; color: var(--accent); }
#info ol.stops { list-style: none; margin: 12px 0 0; padding: 0; position: relative; }
#info ol.stops li { position: relative; padding: 5px 0 5px 22px; font-size: 12px; color: var(--muted); }
#info ol.stops li::before { content: ""; position: absolute; left: 3px; top: 9px; width: 10px; height: 10px; border-radius: 50%; background: var(--info-color, #4ea1ff); box-shadow: 0 0 0 2px rgba(255,255,255,0.10); }
#info ol.stops::before { content: ""; position: absolute; left: 7px; top: 8px; bottom: 8px; width: 2px; background: var(--info-color, #4ea1ff); opacity: 0.4; }
#info ol.stops li.next { color: var(--fg); font-weight: 600; }
#info ol.stops li.next::before { background: var(--accent); box-shadow: 0 0 0 3px rgba(255,176,0,0.35); }
/* ── Bottom controls (sliders) ───────────────── */
#controls { position: fixed; bottom: 12px; left: 12px; right: 12px; padding: 12px 16px; display: flex; gap: 18px; align-items: center; z-index: 10; }
#controls .ctrl { flex: 1; min-width: 0; }
#controls .ctrl-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
#controls .ctrl-row .lbl { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.75; }
#controls .ctrl-row .val { font-size: 12px; color: var(--accent); font-variant-numeric: tabular-nums; }
#controls input[type=range] { width: 100%; accent-color: var(--accent); }
#controls .ticks { display: flex; justify-content: space-between; font-size: 10px; opacity: 0.5; margin-top: 2px; }
#controls .phase { font-size: 11px; opacity: 0.65; margin-top: 2px; }
/* ── Mobile (≤768px): stacked layout ─────────── */
@media (max-width: 768px) {
:root { --header-h: 96px; }
html, body { overflow: auto; }
#header { flex-wrap: wrap; padding: 8px 12px; height: auto; }
#header .brand { flex: 1; }
#search-wrap { max-width: none; flex: 1 1 100%; order: 3; }
#search-hint { display: none; }
#app { position: relative; inset: auto; height: 50vh; min-height: 320px; width: 100%; }
#legend, #info, #controls { position: static; width: auto; max-width: none; max-height: none; margin: 8px 12px; left: auto; right: auto; top: auto; bottom: auto; }
#legend { margin-top: 12px; }
#info ol.stops { max-height: 200px; overflow: auto; }
#controls { flex-direction: column; gap: 12px; align-items: stretch; padding: 14px 16px 18px; }
#controls .ctrl { width: 100%; }
}
</style>
<!-- Meta Pixel — INERT until set-fb-pixel.sh is run (does NOT load fbevents.js or hit Meta until flipped) -->
<script>
(function(){
var PIXEL_ID = '{{FACEBOOK_PIXEL_ID}}';
// Inert until flipped — the brace check prevents BOTH the loader IIFE
// AND the init call from running, so fbevents.js is never fetched.
if (PIXEL_ID.indexOf('{{') !== -1) return;
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
document,'script','https://connect.facebook.net/en_US/fbevents.js');
fbq('init', PIXEL_ID);
fbq('track', 'PageView');
})();
</script>
<!-- noscript fallback is added by set-fb-pixel.sh on flip (omitted here to avoid unflipped img phone-home) -->
<!-- End Meta Pixel -->
</head>
<body>
<header id="header">
<div class="brand">Ventura Bus 3D <small>Live corridor preview</small></div>
<div id="search-wrap">
<svg id="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="search" type="search" inputmode="numeric" autocomplete="off" placeholder="Search Metro line (150, 750, 234, 233)…" aria-label="Search Metro line number" />
</div>
<span id="search-hint">↵ to fly to nearest bus</span>
<button id="theme-toggle" type="button" title="Toggle light / dark" aria-label="Toggle theme">☾</button>
</header>
<div id="app"></div>
<aside id="legend" class="panel" aria-label="Route legend">
<h2>Routes</h2>
<ul class="routes" id="route-list"></ul>
</aside>
<aside id="info" class="panel" aria-label="Selected bus info">
<h2>Selected bus</h2>
<div id="info-body" class="empty">Click a bus or search a line to inspect.</div>
</aside>
<div id="controls" class="panel">
<div class="ctrl">
<div class="ctrl-row"><span class="lbl">Time of day</span><span class="val" id="time-val">12:00</span></div>
<input type="range" id="time-slider" min="0" max="1440" step="5" value="720" aria-label="Time of day in minutes" />
<div class="ticks"><span>00</span><span>06</span><span>12</span><span>18</span><span>24</span></div>
<div class="phase"><span id="time-phase">Midday</span> · <span id="time-headlights">headlights off</span></div>
</div>
<div class="ctrl">
<div class="ctrl-row"><span class="lbl">Traffic density</span><span class="val" id="traffic-val">50%</span></div>
<input type="range" id="traffic-slider" min="0" max="100" step="1" value="50" aria-label="Traffic density percent" />
<div class="ticks"><span>empty</span><span>light</span><span>medium</span><span>heavy</span><span>jam</span></div>
<div class="phase" id="traffic-phase">Medium flow</div>
</div>
</div>
<script>
(function () {
var btn = document.getElementById('theme-toggle');
var root = document.documentElement;
function paint() { btn.textContent = root.getAttribute('data-theme') === 'light' ? '☀' : '☾'; }
paint();
btn.addEventListener('click', function () {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
root.setAttribute('data-theme', next);
try { localStorage.setItem('vb3d-theme', next); } catch (e) {}
paint();
window.dispatchEvent(new CustomEvent('vb3d:theme', { detail: next }));
});
})();
</script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module" src="/js/main.js"></script>
</body>
</html>