Spaces:
Sleeping
Sleeping
| <!-- =========================================================================== | |
| DEPLOYED FILE — canonical source of truth for the Tuas Crossing FRONTEND. | |
| Ships to munyew/tuas-crossing as static/index.html via: | |
| python C:\TuasData\deploy_to_hf.py | |
| Edit THIS file only. Do NOT edit the archived decoys: | |
| C:\TuasData\tuas_index_content.html.UNUSED and *.UNUSED-decoy-with-alerts | |
| =========================================================================== --> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> | |
| <meta name="theme-color" content="#0c1016" /> | |
| <title>Tuas Crossing</title> | |
| <link rel="manifest" href="/manifest.json" /> | |
| <link rel="icon" href="/icon-192.png" /> | |
| <link rel="apple-touch-icon" href="/icon-192.png" /> | |
| <style> | |
| :root{ | |
| --bg:#0c1016; --panel:#141b24; --panel2:#1b2530; --line:#243140; | |
| --txt:#e6edf3; --muted:#8da2b5; --accent:#38bdf8; | |
| --green:#22c55e; --lime:#84cc16; --amber:#f59e0b; --red:#ef4444; --grey:#475569; | |
| } | |
| *{box-sizing:border-box} | |
| body{margin:0;background:var(--bg);color:var(--txt); | |
| font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; | |
| -webkit-font-smoothing:antialiased;padding-bottom:env(safe-area-inset-bottom)} | |
| header{padding:18px 16px 8px;position:sticky;top:0;background:linear-gradient(180deg,#0c1016 70%,rgba(12,16,22,0));z-index:5} | |
| h1{font-size:20px;margin:0;letter-spacing:.3px} | |
| .sub{color:var(--muted);font-size:12px;margin-top:3px} | |
| .wrap{max-width:760px;margin:0 auto;padding:0 12px 40px} | |
| .tabs{display:flex;gap:8px;margin:14px 0 6px} | |
| .tab{flex:1;text-align:center;padding:10px;border-radius:12px;background:var(--panel); | |
| color:var(--muted);font-weight:600;font-size:14px;border:1px solid var(--line);cursor:pointer} | |
| .tab.active{color:var(--txt);background:var(--panel2);border-color:var(--accent)} | |
| .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;margin:10px 0} | |
| .eta{display:flex;align-items:baseline;gap:10px} | |
| .eta .num{font-size:46px;font-weight:800;line-height:1} | |
| .eta .unit{font-size:15px;color:var(--muted)} | |
| .eta .lbl{font-size:13px;color:var(--muted);margin-bottom:4px} | |
| .corridor{display:grid;grid-template-columns:1fr;gap:8px;margin-top:6px} | |
| .road{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:10px;background:var(--panel2)} | |
| .dot{width:14px;height:14px;border-radius:50%;flex:none;box-shadow:0 0 10px currentColor} | |
| .road .name{font-weight:600;font-size:14px} | |
| .road .meta{color:var(--muted);font-size:12px;margin-top:2px} | |
| .road .state{margin-left:auto;font-weight:700;font-size:13px} | |
| .cams{display:grid;grid-template-columns:1fr 1fr;gap:8px} | |
| @media(max-width:520px){.cams{grid-template-columns:1fr}} | |
| .cam{border-radius:10px;overflow:hidden;background:#000;border:1px solid var(--line);position:relative} | |
| .cam img{width:100%;display:block;aspect-ratio:16/10;object-fit:cover} | |
| .cam .tag{position:absolute;left:6px;top:6px;background:rgba(0,0,0,.6);font-size:11px;padding:2px 6px;border-radius:6px} | |
| .inc{font-size:13px;padding:8px 10px;border-left:3px solid var(--grey);background:var(--panel2);border-radius:6px;margin:6px 0;color:#cdd9e5} | |
| .inc.near{border-left-color:var(--amber)} | |
| .inc .ty{font-weight:700;color:var(--amber);font-size:11px;text-transform:uppercase;letter-spacing:.4px} | |
| h2{font-size:14px;color:var(--muted);margin:6px 2px;font-weight:600;text-transform:uppercase;letter-spacing:.5px} | |
| .heat{overflow-x:auto} | |
| table.hm{border-collapse:collapse;font-size:10px} | |
| table.hm td,table.hm th{width:18px;height:18px;text-align:center;color:#0c1016;font-weight:700} | |
| table.hm th{color:var(--muted);font-weight:600;background:none;width:auto;padding:0 4px} | |
| table.hm td.empty{background:var(--panel2);color:var(--muted)} | |
| .legend{display:flex;gap:10px;align-items:center;font-size:11px;color:var(--muted);margin:8px 2px;flex-wrap:wrap} | |
| .legend i{display:inline-block;width:12px;height:12px;border-radius:3px;margin-right:4px;vertical-align:-2px} | |
| /* sparkline chart */ | |
| .spark-wrap{position:relative;padding:0 2px} | |
| .spark-yaxis{position:absolute;left:0;top:0;bottom:24px;width:54px;display:flex;flex-direction:column;justify-content:space-between;pointer-events:none} | |
| .spark-yaxis .yl{font-size:10px;color:var(--muted);line-height:1;white-space:nowrap} | |
| .spark-yaxis .yl.top{color:#22c55e} | |
| .spark-yaxis .yl.bot{color:#ef4444} | |
| .spark-inner{margin-left:56px;position:relative} | |
| .spark-reflines{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none} | |
| .spark-refline{position:absolute;left:0;right:0;border-top:1px dashed rgba(255,255,255,0.10)} | |
| .spark{display:flex;align-items:flex-end;gap:1px;height:80px;padding:0} | |
| .spark .b{flex:1;border-radius:2px 2px 0 0;min-height:2px;background:var(--accent)} | |
| .spark .b.empty{background:var(--panel2)} | |
| .spark .b.quiet{background:repeating-linear-gradient(45deg,#243140 0px,#243140 3px,#1b2530 3px,#1b2530 6px);opacity:0.5} | |
| .spark-xaxis{display:flex;margin-left:56px} | |
| .spark-xaxis .xl{font-size:10px;color:var(--muted);text-align:center;flex:1} | |
| .spark-xaxis .xl.lbl{color:var(--muted)} | |
| .spark-xaxis .xl.blank{visibility:hidden} | |
| .spark-caption{font-size:11px;color:var(--muted);margin-top:6px;text-align:center} | |
| .muted{color:var(--muted);font-size:12px} | |
| .hide{display:none} | |
| .pill{display:inline-block;font-size:11px;padding:2px 8px;border-radius:20px;background:var(--panel2);color:var(--muted);border:1px solid var(--line)} | |
| /* live dot inside the status pill */ | |
| .live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;vertical-align:1px;background:var(--green);animation:pulse-dot 1.8s ease-in-out infinite} | |
| .live-dot.stale{background:var(--amber)} | |
| @keyframes pulse-dot{ | |
| 0%,100%{opacity:1;transform:scale(1)} | |
| 50%{opacity:.45;transform:scale(.7)} | |
| } | |
| /* ETT chart */ | |
| .ett-wrap{position:relative;margin:0 0 4px} | |
| .ett-canvas-box{position:relative;height:110px;margin-left:44px} | |
| .ett-canvas-box canvas{position:absolute;top:0;left:0;width:100%;height:100%} | |
| .ett-yaxis{position:absolute;left:0;top:0;bottom:0;width:42px;display:flex;flex-direction:column;justify-content:space-between;pointer-events:none} | |
| .ett-yl{font-size:10px;color:var(--muted);line-height:1;text-align:right;padding-right:4px} | |
| .ett-xaxis{display:flex;margin-left:44px;margin-top:2px} | |
| .ett-xl{font-size:10px;color:var(--muted);text-align:center;flex:1} | |
| .ett-xl.blank{visibility:hidden} | |
| .ett-legend{display:flex;gap:14px;justify-content:center;font-size:11px;color:var(--muted);margin:6px 0 2px} | |
| .ett-legend span{display:flex;align-items:center;gap:5px} | |
| .ett-legend i{display:inline-block;width:22px;height:2px;vertical-align:middle} | |
| .ett-legend i.solid{background:var(--accent)} | |
| .ett-legend i.dotted{background:none;border-top:2px dashed var(--lime)} | |
| .err{color:var(--red);font-size:12px} | |
| .freshbar{margin-top:8px;font-size:12px;padding:7px 11px;border-radius:10px;display:flex;align-items:center;gap:8px} | |
| .freshbar.ok{background:rgba(34,197,94,.10);border:1px solid rgba(34,197,94,.35);color:#bbf7d0} | |
| .freshbar.warn{background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.5);color:#fecaca;font-weight:600} | |
| .freshbar .ico{font-size:13px} | |
| .alert-btns{display:flex;gap:8px;flex-wrap:wrap} | |
| .btn{appearance:none;border:1px solid var(--accent);background:var(--accent);color:#06121c; | |
| font-weight:700;font-size:14px;padding:11px 16px;border-radius:10px;cursor:pointer;flex:1;min-width:160px} | |
| .btn.ghost{background:transparent;color:var(--accent)} | |
| .btn:disabled{opacity:.55;cursor:default} | |
| .btn.ok{border-color:var(--green);background:var(--green);color:#06121c} | |
| .rule-box{margin-top:14px;border-top:1px solid var(--line);padding-top:12px} | |
| .rule-title{font-weight:700;font-size:14px;margin-bottom:8px} | |
| .rule-days{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px} | |
| .rule-day{padding:8px 0;flex:1;min-width:38px;text-align:center;border-radius:8px;background:var(--panel2); | |
| border:1px solid var(--line);color:var(--muted);font-size:13px;font-weight:600;cursor:pointer;user-select:none} | |
| .rule-day.on{background:var(--accent);color:#06121c;border-color:var(--accent)} | |
| .rule-row{display:flex;gap:14px;margin-bottom:6px;flex-wrap:wrap} | |
| .rule-row label,.rule-thr{font-size:13px;color:var(--txt);display:flex;align-items:center;gap:7px;flex-wrap:wrap} | |
| .rule-thr{margin:8px 0} | |
| .rule-box input[type=time],.rule-box input[type=number]{background:var(--panel2);border:1px solid var(--line); | |
| color:var(--txt);border-radius:8px;padding:8px;font-size:14px} | |
| .rule-box input[type=number]{width:64px;text-align:center} | |
| .rule-thr input[type=checkbox]{width:18px;height:18px;accent-color:var(--accent);flex:none} | |
| .rule-summary{font-size:13px;color:#bbf7d0;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.3); | |
| border-radius:8px;padding:8px 10px;margin-bottom:10px} | |
| footer{text-align:center;color:var(--muted);font-size:11px;margin-top:20px} | |
| a{color:var(--accent)} | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="wrap"> | |
| <h1>🚗 Tuas Crossing <span class="pill" id="status"><span class="live-dot" id="liveDot"></span><span id="statusTxt">…</span></span></h1> | |
| <div class="sub">SG → JB outbound · Second Link corridor · <span id="updated">loading…</span></div> | |
| <div id="freshbar" class="freshbar hide"></div> | |
| <div class="tabs"> | |
| <div class="tab active" data-view="now">Now</div> | |
| <div class="tab" data-view="when">When to cross</div> | |
| </div> | |
| </div> | |
| </header> | |
| <div class="wrap"> | |
| <!-- NOW --> | |
| <section id="now"> | |
| <div class="card" id="totalCard"> | |
| <div class="lbl">Total crossing — drive + booth queue</div> | |
| <div class="eta"><span class="num" id="totalNum">–</span><span class="unit">min</span></div> | |
| <div id="totalBreakdown" class="muted" style="margin-top:6px;font-size:12.5px;line-height:1.5"></div> | |
| </div> | |
| <div class="card"> | |
| <div class="lbl">Drive — AYE → Tuas Checkpoint <span style="color:var(--green)">(measured, live)</span></div> | |
| <div class="eta"><span class="num" id="eta">–</span><span class="unit">min</span></div> | |
| <div class="corridor" id="corridor"></div> | |
| </div> | |
| <h2>Live cameras</h2> | |
| <div class="cams" id="cams"><div class="muted">loading cameras…</div></div> | |
| <h2>Incidents</h2> | |
| <div id="incidents"><div class="muted">loading…</div></div> | |
| <h2>🔔 Crossing alerts <span style="text-transform:none;font-weight:400">(beta)</span></h2> | |
| <div class="card" id="alertsCard"> | |
| <div class="muted" id="alertMsg" style="margin-bottom:10px">Get a buzz on this phone when the crossing is good (or bad) to go.</div> | |
| <div class="alert-btns"> | |
| <button class="btn" id="btnEnable">Enable alerts on this phone</button> | |
| <button class="btn ghost hide" id="btnTest">Send test notification</button> | |
| </div> | |
| <div class="muted" id="alertHint" style="margin-top:10px;font-size:11px"></div> | |
| <div class="rule-box hide" id="ruleBox"> | |
| <div class="rule-title">Alert rule</div> | |
| <div class="rule-summary hide" id="ruleSummary"></div> | |
| <div class="muted" style="font-size:11px;margin-bottom:6px">Days</div> | |
| <div class="rule-days" id="ruleDays"></div> | |
| <div class="rule-row"> | |
| <label>From <input type="time" id="ruleFrom" value="13:00"></label> | |
| <label>To <input type="time" id="ruleTo" value="20:00"></label> | |
| </div> | |
| <label class="rule-thr"><input type="checkbox" id="goOn"> | |
| Alert <b style="color:var(--green)">GO</b> when ETA ≤ | |
| <input type="number" id="goVal" min="1" max="180" value="25"> min</label> | |
| <label class="rule-thr"><input type="checkbox" id="waitOn"> | |
| Alert <b style="color:var(--amber)">WAIT</b> when ETA ≥ | |
| <input type="number" id="waitVal" min="1" max="180" value="45"> min</label> | |
| <div class="alert-btns" style="margin-top:10px"> | |
| <button class="btn" id="btnSaveRule">Save rule</button> | |
| <button class="btn ghost" id="btnDeleteRule">Delete rule</button> | |
| </div> | |
| <div class="muted" id="ruleMsg" style="margin-top:8px;font-size:11px"> | |
| Numbers are provisional — tune them once ~3–4 weeks of history reveal your real baselines. | |
| </div> | |
| </div> | |
| </div> | |
| <h2>📝 Log your crossing <span style="text-transform:none;font-weight:400">(teaches the estimate)</span></h2> | |
| <div class="card" id="logCard"> | |
| <div class="muted" id="logMsg" style="margin-bottom:10px">Tell the app how long your crossing really took, so the camera estimate learns your real times.</div> | |
| <div class="alert-btns"> | |
| <button class="btn" id="btnStartCross">Start crossing (joining the queue)</button> | |
| </div> | |
| <div class="rule-row" style="margin-top:12px;align-items:center"> | |
| <label>Or just log total minutes: | |
| <input type="number" id="logMins" min="1" max="300" placeholder="e.g. 55"></label> | |
| <button class="btn ghost" id="btnLogMins" style="flex:0 0 auto;min-width:90px">Save</button> | |
| </div> | |
| <div class="muted" id="logCount" style="margin-top:10px;font-size:11px"></div> | |
| </div> | |
| </section> | |
| <!-- WHEN TO CROSS --> | |
| <section id="when" class="hide"> | |
| <div class="card" id="ettCard"> | |
| <h2 style="margin-top:0">Today vs last week — predicted crossing minutes</h2> | |
| <div class="muted" id="ettMeta" style="margin-bottom:8px">loading…</div> | |
| <div class="ett-wrap"> | |
| <div class="ett-yaxis" id="ettYAxis"></div> | |
| <div class="ett-canvas-box" id="ettBox"> | |
| <canvas id="ettCanvas"></canvas> | |
| </div> | |
| </div> | |
| <div class="ett-xaxis" id="ettXAxis"></div> | |
| <div class="ett-legend" id="ettLegend"></div> | |
| <div class="spark-caption">Predicted outbound minutes, AYE → Tuas Checkpoint. Dotted = same day last week (appears once history reaches back a week).</div> | |
| </div> | |
| <div class="card"> | |
| <h2 style="margin-top:0">Today so far</h2> | |
| <div class="muted" id="todayLbl"></div> | |
| <div class="spark-wrap" id="sparkWrap"> | |
| <div class="spark-yaxis"> | |
| <span class="yl top">8=free</span> | |
| <span class="yl">4</span> | |
| <span class="yl bot">1=crawl</span> | |
| </div> | |
| <div class="spark-inner"> | |
| <div class="spark-reflines"> | |
| <div class="spark-refline" style="top:0%"></div> | |
| <div class="spark-refline" style="top:50%"></div> | |
| <div class="spark-refline" style="top:87.5%"></div> | |
| </div> | |
| <div class="spark" id="spark"></div> | |
| </div> | |
| <div class="spark-xaxis" id="sparkX"></div> | |
| </div> | |
| <div class="spark-caption">avg outbound corridor speed band · 1 = crawling · 8 = free-flow</div> | |
| </div> | |
| <div class="card"> | |
| <h2 style="margin-top:0">Average traffic by day & hour</h2> | |
| <div class="muted" id="histMeta">building from history…</div> | |
| <div class="legend"> | |
| <span><i style="background:var(--red)"></i>Heavy</span> | |
| <span><i style="background:var(--amber)"></i>Slow</span> | |
| <span><i style="background:var(--lime)"></i>Moderate</span> | |
| <span><i style="background:var(--green)"></i>Free flow</span> | |
| <span>· greener = faster · rows = days, cols = hour</span> | |
| </div> | |
| <div class="heat"><table class="hm" id="heat"></table></div> | |
| </div> | |
| </section> | |
| <footer> | |
| Data: <a href="https://datamall.lta.gov.sg/" target="_blank" rel="noopener">LTA DataMall</a>. | |
| Live cached ~60s. Camera links expire ~5 min. | |
| </footer> | |
| </div> | |
| <script> | |
| const DOW = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]; | |
| // SpeedBand (1 worst .. 8 free-flow) -> colour | |
| function bandColor(b){ | |
| if(b==null||isNaN(b)) return null; | |
| if(b>=6.5) return "#22c55e"; | |
| if(b>=5) return "#84cc16"; | |
| if(b>=3) return "#f59e0b"; | |
| return "#ef4444"; | |
| } | |
| // tabs | |
| document.querySelectorAll(".tab").forEach(t=>{ | |
| t.onclick=()=>{ | |
| document.querySelectorAll(".tab").forEach(x=>x.classList.remove("active")); | |
| t.classList.add("active"); | |
| const v=t.dataset.view; | |
| document.getElementById("now").classList.toggle("hide", v!=="now"); | |
| document.getElementById("when").classList.toggle("hide", v!=="when"); | |
| if(v==="when"){ loadHistory(); loadEtt(); } | |
| }; | |
| }); | |
| function _setStatus(txt, color, stale){ | |
| const st = document.getElementById("status"); | |
| const lbl = document.getElementById("statusTxt"); | |
| const dot = document.getElementById("liveDot"); | |
| if(lbl) lbl.textContent = txt; | |
| if(st) st.style.color = color; | |
| if(dot) dot.classList.toggle("stale", !!stale); | |
| } | |
| async function loadLive(){ | |
| const st=document.getElementById("status"); | |
| try{ | |
| const r=await fetch("/api/live",{cache:"no-store"}); | |
| const d=await r.json(); | |
| if(d.warming){ _setStatus("warming up…","var(--amber)",true); | |
| setTimeout(loadLive, 5000); return; } | |
| if(d.error){ _setStatus("error","var(--red)",true); return; } | |
| window.__live = d; // share live data (the ETT chart's "now" point reads this) | |
| const old = d.ageSec!=null && d.ageSec>180; | |
| const isStale = d.stale||old; | |
| _setStatus(isStale?"stale":"live", isStale?"var(--amber)":"var(--green)", isStale); | |
| document.getElementById("updated").textContent = "updated "+d.updated+" SGT"; | |
| document.getElementById("eta").textContent = (d.etaMinutes==null?"–":d.etaMinutes); | |
| // Total crossing = measured drive ETA + camera-estimated booth queue band. | |
| // No fake number if the queue frame is missing/stale (>20 min). | |
| const q=d.queueEstimate, eta=d.etaMinutes, tn=document.getElementById("totalNum"), | |
| tb=document.getElementById("totalBreakdown"), QSTALE=20*60; | |
| if(q && q.available && q.minutesBand && eta!=null && q.ageSec!=null && q.ageSec<=QSTALE){ | |
| const lo=q.minutesBand[0], hi=q.minutesBand[1], mid=Math.round((lo+hi)/2); | |
| const bU=(q.bucket||"").toUpperCase(), qm=Math.round(q.ageSec/60); | |
| tn.textContent="~"+(eta+mid); | |
| tb.innerHTML=eta+" min drive <span style=\"color:var(--green)\">(measured)</span> + ~"+lo+"–"+hi+ | |
| " min queue <span style=\"color:var(--amber)\">("+bU+", camera-estimated)</span>"+ | |
| "<br><span style=\"font-size:11px\">queue from cam 4713 · "+qm+" min ago · provisional bands (calibrating against your real crossings)</span>"; | |
| } else { | |
| tn.textContent=(eta!=null?eta:"–"); | |
| let why=(!q||!q.available)?"camera unavailable":(q.ageSec>QSTALE?"camera frame stale ("+Math.round(q.ageSec/60)+" min old)":"no live ETA"); | |
| tb.innerHTML=(eta!=null?eta+" min drive <span style=\"color:var(--green)\">(measured)</span> · ":"")+ | |
| "<span style=\"color:var(--amber)\">queue: "+why+"</span> — showing drive only"; | |
| } | |
| // keep the chart's live "now" point fresh while the When-to-cross tab is open | |
| if(window.__ettData && !document.getElementById("when").classList.contains("hide")) renderEtt(); | |
| // data-collection freshness badge (detects a silent collector stall) | |
| const fb=document.getElementById("freshbar"); const cf=d.collected; | |
| if(cf && cf.ageMin!=null){ | |
| fb.classList.remove("hide"); | |
| const ago = cf.ageMin<60 ? cf.ageMin+" min ago" | |
| : Math.floor(cf.ageMin/60)+"h "+(cf.ageMin%60)+"m ago"; | |
| if(cf.stale){ | |
| fb.className="freshbar warn"; | |
| fb.innerHTML='<span class="ico">⚠️</span><span>History data may be stale — last collected '+ago+ | |
| ' (expected < '+(cf.staleAfterMin||90)+' min). The collector or hourly push may have stalled.</span>'; | |
| }else{ | |
| fb.className="freshbar ok"; | |
| fb.innerHTML='<span class="ico">✅</span><span>History up to date — last collected '+ago+'.</span>'; | |
| } | |
| } else { fb.classList.add("hide"); } | |
| // corridor — speed bands sourced from collected history (dataset), with age | |
| const cc=document.getElementById("corridor"); cc.innerHTML=""; | |
| if(!d.corridorAvailable){ | |
| cc.innerHTML='<div class="muted" style="padding:8px 2px">corridor speed bands awaiting collector…</div>'; | |
| } else { | |
| (d.corridor||[]).forEach(c=>{ | |
| const div=document.createElement("div"); div.className="road"; | |
| const nice=c.road.replace("AYER RAJAH EXPRESSWAY","Ayer Rajah Expwy (AYE)") | |
| .replace("TUAS SECOND CROSSING","Tuas Second Crossing") | |
| .replace("TUAS VIADUCT","Tuas Viaduct"); | |
| div.innerHTML=`<span class="dot" style="color:${c.color}"></span> | |
| <div><div class="name">${nice}</div> | |
| <div class="meta">avg band ${c.avgBand==null?"–":c.avgBand} · ${c.n} links</div></div> | |
| <span class="state" style="color:${c.color}">${c.state}</span>`; | |
| cc.appendChild(div); | |
| }); | |
| if(d.corridorAgeMin!=null){ | |
| const age=d.corridorAgeMin<60 ? d.corridorAgeMin+" min ago" | |
| : Math.floor(d.corridorAgeMin/60)+"h "+(d.corridorAgeMin%60)+"m ago"; | |
| const note=document.createElement("div"); note.className="muted"; | |
| note.style.cssText="padding:6px 2px 0;font-size:11px"; | |
| note.textContent="speed bands as of "+age+" (from collected history)"; | |
| cc.appendChild(note); | |
| } | |
| } | |
| // cameras — update in place keyed by id so the previous frame stays | |
| // visible until the new one decodes (avoids a black flash each poll). | |
| const cm=document.getElementById("cams"); | |
| const cams=(d.cameras||[]); | |
| if(!cams.length){ cm.innerHTML='<div class="muted">no cameras in range</div>'; } | |
| else{ | |
| if(cm.querySelector(".muted")) cm.innerHTML=""; | |
| const seen=new Set(); | |
| cams.forEach(c=>{ | |
| seen.add("cam-"+c.id); | |
| let wrap=document.getElementById("cam-"+c.id); | |
| if(!wrap){ | |
| wrap=document.createElement("div"); wrap.className="cam"; wrap.id="cam-"+c.id; | |
| const img=document.createElement("img"); img.alt="camera "+c.id; img.decoding="async"; | |
| const camLabels={"4703":"Second Link bridge / checkpoint","4707":"AYE / Tuas West approach","4712":"Tuas Viaduct","4713":"Second Link bridge"}; | |
| const tag=document.createElement("div"); tag.className="tag"; tag.textContent=(camLabels[c.id]||("Second Link corridor (cam "+c.id+")"))+" · cam "+c.id; | |
| wrap.appendChild(img); wrap.appendChild(tag); cm.appendChild(wrap); | |
| } | |
| const img=wrap.querySelector("img"); | |
| if(img.getAttribute("src")!==c.url) img.src=c.url; // swap only when changed | |
| }); | |
| // drop cameras no longer present | |
| Array.from(cm.children).forEach(ch=>{ if(ch.id && !seen.has(ch.id)) ch.remove(); }); | |
| } | |
| // incidents | |
| const ic=document.getElementById("incidents"); ic.innerHTML=""; | |
| const list=(d.incidents||[]); | |
| if(!list.length){ ic.innerHTML='<div class="muted">no incidents reported</div>'; } | |
| list.slice(0,12).forEach(i=>{ | |
| const div=document.createElement("div"); div.className="inc"+(i.near?" near":""); | |
| div.innerHTML=`<span class="ty">${i.type||""}${i.near?" · near corridor":""}</span><br>${i.msg||""}`; | |
| ic.appendChild(div); | |
| }); | |
| }catch(e){ _setStatus("offline","var(--red)",true); } | |
| } | |
| let histLoaded=false; | |
| async function loadHistory(force){ | |
| if(histLoaded && !force) return; | |
| try{ | |
| const r=await fetch("/api/history",{cache:"no-store"}); | |
| const d=await r.json(); | |
| histLoaded=true; | |
| document.getElementById("histMeta").textContent = | |
| `${d.rows||0} samples over ${d.days||0} day(s). Fills in as history accumulates.`; | |
| // today sparkline | |
| const sp=document.getElementById("spark"); sp.innerHTML=""; | |
| const sx=document.getElementById("sparkX"); sx.innerHTML=""; | |
| const tl=document.getElementById("todayLbl"); | |
| const today=d.today||{}; tl.textContent = today.dow ? today.dow+" — avg corridor speed band by hour" : "no data yet today"; | |
| // Quiet hours: 1–5am (collector is paused, no data by design) | |
| const QUIET_START=1, QUIET_END=5; | |
| for(let h=0;h<24;h++){ | |
| const b=document.createElement("div"); | |
| const v=today.hours? today.hours[h]:null; | |
| const isQuiet = h>=QUIET_START && h<QUIET_END; | |
| if(isQuiet){ | |
| // Hatched bar for quiet-hours gap — distinguishes "no collection" from "clear road" | |
| b.className="b quiet"; b.style.height="30%"; | |
| b.title=`${h}:00 quiet hours — no collection`; | |
| } else if(v==null){ | |
| b.className="b empty"; b.style.height="3px"; | |
| b.title=`${h}:00 no data`; | |
| } else { | |
| b.className="b"; | |
| // Y-scale: band 1 = 12.5% height, band 8 = 100%; keep a visible floor | |
| b.style.height=Math.max(6,((v-1)/7)*100)+"%"; | |
| b.style.background=bandColor(v); | |
| b.title=`${h}:00 band ${v}`; | |
| } | |
| sp.appendChild(b); | |
| } | |
| // X-axis: label every 3 hours (0,3,6,...,21); blank cells in between | |
| for(let h=0;h<24;h++){ | |
| const xl=document.createElement("div"); | |
| xl.className = (h%3===0) ? "xl lbl" : "xl blank"; | |
| xl.textContent = (h%3===0) ? h : ""; | |
| sx.appendChild(xl); | |
| } | |
| // heatmap | |
| const hm=document.getElementById("heat"); hm.innerHTML=""; | |
| const head=document.createElement("tr"); head.innerHTML="<th></th>"; | |
| for(let h=0;h<24;h++){ const th=document.createElement("th"); th.textContent=h; head.appendChild(th); } | |
| hm.appendChild(head); | |
| const order=d.dowOrder||DOW; | |
| order.forEach(day=>{ | |
| const tr=document.createElement("tr"); | |
| const th=document.createElement("th"); th.textContent=day.slice(0,3); th.style.textAlign="right"; tr.appendChild(th); | |
| const row=(d.heatmap||{})[day]||{}; | |
| for(let h=0;h<24;h++){ | |
| const td=document.createElement("td"); | |
| const v=row[h]; | |
| if(v==null){ td.className="empty"; td.textContent=""; } | |
| else{ td.style.background=bandColor(v); td.title=`${day} ${h}:00 · band ${v}`; } | |
| tr.appendChild(td); | |
| } | |
| hm.appendChild(tr); | |
| }); | |
| }catch(e){ | |
| document.getElementById("histMeta").innerHTML='<span class="err">could not load history</span>'; | |
| } | |
| } | |
| // --- ETT chart (today vs last week, predicted crossing minutes) --- | |
| let ettLoaded = false; | |
| // Fetch the historical (15-min dataset) ETT series, store it, then render. The | |
| // render() overlays the LATEST point with the live ~60s ETA so "now" doesn't lag. | |
| async function loadEtt(force){ | |
| if(ettLoaded && !force) return; | |
| const meta = document.getElementById("ettMeta"); | |
| try{ | |
| const r = await fetch("/api/ett",{cache:"no-store"}); | |
| const d = await r.json(); | |
| if(d.error){ meta.innerHTML='<span class="err">'+d.error+'</span>'; return; } | |
| ettLoaded = true; | |
| window.__ettData = d; | |
| renderEtt(); | |
| }catch(e){ | |
| const meta2 = document.getElementById("ettMeta"); | |
| if(meta2) meta2.innerHTML='<span class="err">could not load ETT data</span>'; | |
| } | |
| } | |
| // Draw the chart from stored dataset hours + a live override for the current hour. | |
| function renderEtt(){ | |
| const d = window.__ettData; | |
| if(!d) return; | |
| const meta = document.getElementById("ettMeta"); | |
| const lwHours = d.lastWeekHours || {}; | |
| const hasLW = d.lastWeek && Object.keys(lwHours).length > 0; | |
| // Clone so the live override never mutates the stored dataset series. | |
| const todayHours = Object.assign({}, d.todayHours || {}); | |
| // Live override: replace the current hour's point with the live (~60s) ETA so | |
| // the chart's "now" matches the headline number. Historical points untouched. | |
| let liveHour = null, liveEta = null; | |
| const live = window.__live; | |
| if(live && live.etaMinutes != null && live.updated){ | |
| liveHour = parseInt(String(live.updated).split(" ")[1].split(":")[0], 10); | |
| liveEta = live.etaMinutes; | |
| if(!isNaN(liveHour) && liveEta != null) todayHours[liveHour] = liveEta; | |
| } | |
| const allVals = [...Object.values(todayHours), ...Object.values(lwHours)]; | |
| const dataMax = allVals.length ? Math.max(...allVals) : 60; | |
| const yMax = Math.max(120, Math.ceil((dataMax + 10) / 10) * 10); | |
| const yAxis = document.getElementById("ettYAxis"); | |
| yAxis.innerHTML = ""; | |
| const ySteps = []; | |
| for(let v = yMax; v >= 0; v -= 30) ySteps.push(v); | |
| ySteps.forEach(v => { | |
| const el = document.createElement("div"); el.className = "ett-yl"; el.textContent = v; | |
| yAxis.appendChild(el); | |
| }); | |
| const xAxis = document.getElementById("ettXAxis"); | |
| xAxis.innerHTML = ""; | |
| for(let h = 0; h < 24; h++){ | |
| const el = document.createElement("div"); | |
| el.className = (h % 3 === 0) ? "ett-xl" : "ett-xl blank"; | |
| el.textContent = (h % 3 === 0) ? h : ""; | |
| xAxis.appendChild(el); | |
| } | |
| const box = document.getElementById("ettBox"); | |
| const canvas = document.getElementById("ettCanvas"); | |
| const W = box.clientWidth || 300; | |
| const H = box.clientHeight || 110; | |
| canvas.width = W; canvas.height = H; | |
| const ctx = canvas.getContext("2d"); | |
| ctx.clearRect(0, 0, W, H); | |
| const PAD_TOP = 6, PAD_BOT = 6; | |
| const usableH = H - PAD_TOP - PAD_BOT; | |
| function toY(val){ return PAD_TOP + usableH * (1 - val / yMax); } | |
| function toX(hour){ return (hour / 23) * (W - 1); } | |
| ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.lineWidth = 1; | |
| ySteps.forEach(v => { const y = toY(v); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }); | |
| function drawLine(hoursObj, color, dashed){ | |
| ctx.strokeStyle = color; ctx.lineWidth = 2; | |
| ctx.setLineDash(dashed ? [5, 5] : []); | |
| ctx.lineJoin = "round"; ctx.lineCap = "round"; | |
| // Separate segment per contiguous run — no interpolation across honest gaps. | |
| let inSeg = false; ctx.beginPath(); | |
| for(let h = 0; h < 24; h++){ | |
| if(hoursObj[h] != null){ | |
| const x = toX(h), y = toY(hoursObj[h]); | |
| if(!inSeg){ ctx.moveTo(x, y); inSeg = true; } else { ctx.lineTo(x, y); } | |
| } else if(inSeg){ ctx.stroke(); ctx.beginPath(); inSeg = false; } | |
| } | |
| if(inSeg) ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| if(hasLW) drawLine(lwHours, "#84cc16", true); // dotted lime = last week | |
| drawLine(todayHours, "#38bdf8", false); // solid accent = today | |
| // "now" marker — always visible, even if it's a lone point inside a gap. | |
| if(liveHour != null && liveEta != null){ | |
| const x = toX(liveHour), y = toY(liveEta); | |
| ctx.fillStyle = "#38bdf8"; ctx.beginPath(); ctx.arc(x, y, 4, 0, 2*Math.PI); ctx.fill(); | |
| ctx.strokeStyle = "#e6edf3"; ctx.lineWidth = 1.5; | |
| ctx.beginPath(); ctx.arc(x, y, 4, 0, 2*Math.PI); ctx.stroke(); | |
| } | |
| const leg = document.getElementById("ettLegend"); | |
| leg.innerHTML = '<span><i class="solid"></i>Today ('+d.today+')</span>' | |
| + (hasLW ? '<span><i class="dotted"></i>Last '+d.lastWeek+'</span>' : '') | |
| + (liveEta != null ? '<span><i style="width:8px;height:8px;border-radius:50%;background:#38bdf8;border:1px solid #e6edf3"></i>now '+liveEta+'m (live)</span>' : ''); | |
| const todayCount = Object.keys(d.todayHours || {}).length; | |
| meta.textContent = (todayCount || liveEta != null) | |
| ? "Historical points = 15-min collected data; the “now” dot = live ETA." | |
| : "No data yet for today."; | |
| } | |
| // --- Web Push alerts ------------------------------------------------------ | |
| const VAPID_PUBLIC = "BAsP8y5UZmnV_9GW8X1N6xejAFiyNN2TmFrBP-IGLxzlzR8yonuEQx1xe_EPiRFu3kztuXuHdLPNfzYJxOB9Hsk"; | |
| function urlB64ToUint8Array(b64){ | |
| const pad="=".repeat((4-b64.length%4)%4); | |
| const base=(b64+pad).replace(/-/g,"+").replace(/_/g,"/"); | |
| const raw=atob(base); const arr=new Uint8Array(raw.length); | |
| for(let i=0;i<raw.length;i++) arr[i]=raw.charCodeAt(i); | |
| return arr; | |
| } | |
| const btnEnable=document.getElementById("btnEnable"); | |
| const btnTest=document.getElementById("btnTest"); | |
| const alertMsg=document.getElementById("alertMsg"); | |
| const alertHint=document.getElementById("alertHint"); | |
| function alertsSupported(){ | |
| return ("serviceWorker" in navigator)&&("PushManager" in window)&&("Notification" in window); | |
| } | |
| async function refreshAlertState(){ | |
| if(!alertsSupported()){ | |
| alertMsg.textContent="This browser can't do push. On the Honor, install the app (Add to Home Screen) and open it in Chrome, then enable alerts."; | |
| if(btnEnable) btnEnable.disabled=true; return; | |
| } | |
| try{ | |
| const reg=await navigator.serviceWorker.ready; | |
| const sub=await reg.pushManager.getSubscription(); | |
| if(sub && Notification.permission==="granted"){ | |
| btnEnable.textContent="Alerts enabled ✓"; btnEnable.classList.add("ok"); btnEnable.disabled=true; | |
| btnTest.classList.remove("hide"); | |
| alertMsg.textContent="Alerts are enabled on this phone."; | |
| alertHint.textContent="Set a rule below to get GO/WAIT buzzes. Keep the app installed (Add to Home Screen)."; | |
| document.getElementById("ruleBox").classList.remove("hide"); | |
| loadRule(); | |
| }else{ | |
| btnEnable.textContent="Enable alerts on this phone"; btnEnable.classList.remove("ok"); btnEnable.disabled=false; | |
| btnTest.classList.add("hide"); | |
| document.getElementById("ruleBox").classList.add("hide"); | |
| } | |
| }catch(e){} | |
| } | |
| if(btnEnable) btnEnable.onclick=async()=>{ | |
| try{ | |
| btnEnable.disabled=true; alertMsg.textContent="Requesting permission…"; | |
| const perm=await Notification.requestPermission(); | |
| if(perm!=="granted"){ alertMsg.textContent="Permission denied. Allow notifications for this app in phone settings, then retry."; btnEnable.disabled=false; return; } | |
| const reg=await navigator.serviceWorker.ready; | |
| let sub=await reg.pushManager.getSubscription(); | |
| if(!sub) sub=await reg.pushManager.subscribe({userVisibleOnly:true, applicationServerKey:urlB64ToUint8Array(VAPID_PUBLIC)}); | |
| const res=await fetch("/api/subscribe",{method:"POST",headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify({subscription:sub.toJSON(), ua:navigator.userAgent})}); | |
| if(!res.ok) throw new Error("save failed ("+res.status+")"); | |
| alertMsg.textContent="Alerts enabled on this phone ✓"; | |
| await refreshAlertState(); | |
| }catch(e){ alertMsg.textContent="Could not enable alerts: "+e.message; btnEnable.disabled=false; } | |
| }; | |
| if(btnTest) btnTest.onclick=async()=>{ | |
| try{ | |
| btnTest.disabled=true; alertHint.textContent="Requesting a test push…"; | |
| const res=await fetch("/api/request-test",{method:"POST"}); | |
| if(!res.ok) throw new Error("request failed ("+res.status+")"); | |
| alertHint.textContent="Test requested ✓ — lock the phone / close the app and wait up to ~3 min for the buzz."; | |
| }catch(e){ alertHint.textContent="Test request failed: "+e.message; } | |
| finally{ setTimeout(()=>{btnTest.disabled=false;},5000); } | |
| }; | |
| // --- Alert rule (day/time window + GO/WAIT thresholds) -------------------- | |
| const RULE_DAYS = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]; | |
| (function buildDayChips(){ | |
| const wrap=document.getElementById("ruleDays"); if(!wrap) return; | |
| RULE_DAYS.forEach(d=>{ | |
| const el=document.createElement("div"); el.className="rule-day"; el.textContent=d; el.dataset.day=d; | |
| el.onclick=()=>el.classList.toggle("on"); | |
| wrap.appendChild(el); | |
| }); | |
| })(); | |
| function selectedDays(){ return [...document.querySelectorAll(".rule-day.on")].map(e=>e.dataset.day); } | |
| function setDays(days){ document.querySelectorAll(".rule-day").forEach(e=>e.classList.toggle("on",(days||[]).includes(e.dataset.day))); } | |
| function ruleSummaryText(r){ | |
| if(!r) return ""; | |
| const thr=[]; if(r.go!=null) thr.push("GO ≤ "+r.go+"m"); if(r.wait!=null) thr.push("WAIT ≥ "+r.wait+"m"); | |
| return (r.days||[]).join(", ")+" · "+r.from+"–"+r.to+" · "+thr.join(" · "); | |
| } | |
| async function loadRule(){ | |
| try{ | |
| const r=await fetch("/api/rule",{cache:"no-store"}); const d=await r.json(); | |
| const rule=d.rule; | |
| const sum=document.getElementById("ruleSummary"); | |
| if(rule){ | |
| setDays(rule.days); | |
| document.getElementById("ruleFrom").value=rule.from; | |
| document.getElementById("ruleTo").value=rule.to; | |
| document.getElementById("goOn").checked=rule.go!=null; | |
| if(rule.go!=null) document.getElementById("goVal").value=rule.go; | |
| document.getElementById("waitOn").checked=rule.wait!=null; | |
| if(rule.wait!=null) document.getElementById("waitVal").value=rule.wait; | |
| sum.textContent="Active rule: "+ruleSummaryText(rule); | |
| sum.classList.remove("hide"); | |
| } else { sum.classList.add("hide"); } | |
| }catch(e){} | |
| } | |
| if(document.getElementById("btnSaveRule")) document.getElementById("btnSaveRule").onclick=async()=>{ | |
| const msg=document.getElementById("ruleMsg"); | |
| const body={ days:selectedDays(), | |
| from:document.getElementById("ruleFrom").value, to:document.getElementById("ruleTo").value, | |
| go: document.getElementById("goOn").checked ? parseInt(document.getElementById("goVal").value,10) : null, | |
| wait: document.getElementById("waitOn").checked ? parseInt(document.getElementById("waitVal").value,10) : null }; | |
| if(!body.days.length){ msg.innerHTML='<span class="err">Pick at least one day.</span>'; return; } | |
| if(body.go==null && body.wait==null){ msg.innerHTML='<span class="err">Enable a GO and/or WAIT threshold.</span>'; return; } | |
| msg.textContent="Saving…"; | |
| try{ | |
| const r=await fetch("/api/rule",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); | |
| const d=await r.json(); | |
| if(!r.ok) throw new Error(d.error||r.status); | |
| msg.textContent="Rule saved ✓ — you'll buzz when it trips (max once / 2h)."; | |
| loadRule(); | |
| }catch(e){ msg.innerHTML='<span class="err">Save failed: '+e.message+'</span>'; } | |
| }; | |
| if(document.getElementById("btnDeleteRule")) document.getElementById("btnDeleteRule").onclick=async()=>{ | |
| const msg=document.getElementById("ruleMsg"); | |
| try{ | |
| const r=await fetch("/api/rule",{method:"DELETE"}); | |
| if(!r.ok) throw new Error(r.status); | |
| setDays([]); document.getElementById("goOn").checked=false; document.getElementById("waitOn").checked=false; | |
| document.getElementById("ruleSummary").classList.add("hide"); | |
| msg.textContent="Rule deleted — no alerts will fire."; | |
| }catch(e){ msg.innerHTML='<span class="err">Delete failed: '+e.message+'</span>'; } | |
| }; | |
| // --- Calibration: log my actual crossing time ---------------------------- | |
| const btnStart=document.getElementById("btnStartCross"); | |
| const logMsg=document.getElementById("logMsg"); | |
| const logCount=document.getElementById("logCount"); | |
| let crossTimer=null; | |
| function curQueue(){ const q=(window.__live||{}).queueEstimate||{}; return {bucket:q.bucket||null, score:(q.score!=null?q.score:null)}; } | |
| function elapsedMin(ms){ return Math.max(1, Math.round(ms/60000)); } | |
| function renderStartBtn(){ | |
| if(!btnStart) return; | |
| const raw=localStorage.getItem("tuasCrossing"); | |
| if(raw){ const c=JSON.parse(raw); | |
| btnStart.textContent="Cleared booths — "+elapsedMin(Date.now()-c.startMs)+" min so far (tap when through)"; | |
| btnStart.classList.add("ok"); | |
| } else { btnStart.textContent="Start crossing (joining the queue)"; btnStart.classList.remove("ok"); } | |
| } | |
| function showLogCount(n){ | |
| if(n==null) return; | |
| const need=Math.max(0,12-n); | |
| logCount.textContent=n+" crossing"+(n===1?"":"s")+" logged"+ | |
| (need>0?" · ~"+need+" more to calibrate the bands (need ~10–15)":" · enough to calibrate — bands can now use your real data"); | |
| } | |
| async function postLog(payload){ | |
| try{ logMsg.textContent="Saving…"; | |
| const r=await fetch("/api/crossing-log",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}); | |
| const d=await r.json(); if(!r.ok) throw new Error(d.error||r.status); | |
| logMsg.innerHTML="Logged ✓ "+payload.minutes+" min"+(payload.bucket?" (camera said "+String(payload.bucket).toUpperCase()+")":"")+". Thanks — this sharpens the estimate."; | |
| showLogCount(d.count); | |
| }catch(e){ logMsg.innerHTML='<span class="err">Log failed: '+e.message+'</span>'; } | |
| } | |
| if(btnStart) btnStart.onclick=async()=>{ | |
| const raw=localStorage.getItem("tuasCrossing"); | |
| if(!raw){ | |
| const q=curQueue(); | |
| localStorage.setItem("tuasCrossing",JSON.stringify({startMs:Date.now(),startTs:new Date().toISOString(),bucket:q.bucket,score:q.score})); | |
| renderStartBtn(); if(!crossTimer) crossTimer=setInterval(renderStartBtn,30000); | |
| logMsg.textContent="Crossing started (camera now: "+((q.bucket||"?").toUpperCase())+"). Tap again when you clear the booths."; | |
| } else { | |
| const c=JSON.parse(raw); const minutes=elapsedMin(Date.now()-c.startMs); | |
| await postLog({minutes:minutes,bucket:c.bucket,score:c.score,startTs:c.startTs,endTs:new Date().toISOString(),mode:"timer"}); | |
| localStorage.removeItem("tuasCrossing"); if(crossTimer){clearInterval(crossTimer);crossTimer=null;} renderStartBtn(); | |
| } | |
| }; | |
| if(document.getElementById("btnLogMins")) document.getElementById("btnLogMins").onclick=async()=>{ | |
| const v=parseInt(document.getElementById("logMins").value,10); | |
| if(!v||v<1){ logMsg.innerHTML='<span class="err">Enter how many minutes it took.</span>'; return; } | |
| const q=curQueue(); await postLog({minutes:v,bucket:q.bucket,score:q.score,mode:"manual"}); | |
| document.getElementById("logMins").value=""; | |
| }; | |
| async function loadLogCount(){ try{ const d=await (await fetch("/api/crossing-log",{cache:"no-store"})).json(); showLogCount(d.count); }catch(e){} renderStartBtn(); } | |
| loadLive(); | |
| setInterval(loadLive, 60000); | |
| loadLogCount(); | |
| if("serviceWorker" in navigator){ | |
| window.addEventListener("load",()=>navigator.serviceWorker.register("/sw.js") | |
| .then(refreshAlertState).catch(()=>{})); | |
| } | |
| </script> | |
| </body> | |
| </html> | |