← back to Charge And Explore

public/index.html

984 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Charge &amp; Explore — the best stop while your EV charges</title>
<meta name="description" content="Charge & Explore ranks reachable EV charging stops by the quality of the break you can take while your car charges — detour, charger, restrooms, walking access, and activities that fit the charge window." />
<!-- Google Analytics 4. Loader activates ONLY when a real Measurement ID is set
     (placeholder is inert), and track() is always a safe no-op until GA loads. -->
<script>
  window.GA_ID = 'G-8T0365H380'; // Charge & Explore GA4 (steveabramsdesigns)
  (function(){ if(!GA_ID || GA_ID[0]!=='G' || GA_ID.indexOf('X')>-1) return;
    var s=document.createElement('script'); s.async=1; s.src='https://www.googletagmanager.com/gtag/js?id='+GA_ID; document.head.appendChild(s);
    window.dataLayer=window.dataLayer||[]; window.gtag=function(){dataLayer.push(arguments)};
    gtag('js', new Date()); gtag('config', GA_ID);
  })();
  window.track=function(name,params){ try{ if(window.gtag) gtag('event', name, params||{}); }catch(e){} };
</script>
<style>
  :root{
    --ink:#0f1720; --sub:#5b6b7a; --line:#e6ebf0; --bg:#f7f9fb; --card:#fff;
    --accent:#0a7d5a; --accent-soft:#e6f5ef; --amber:#b8860b; --cols:3;
  }
  *{box-sizing:border-box} html,body{margin:0}
  body{font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
  header{display:flex;align-items:center;justify-content:space-between;padding:18px 24px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:5}
  .brand{display:flex;align-items:center;gap:10px;font-weight:700;letter-spacing:-.01em}
  .brand .mk{width:30px;height:30px;border-radius:9px;background:linear-gradient(135deg,var(--accent),#0bb37f);display:grid;place-items:center;color:#fff;font-size:18px}
  nav{display:flex;align-items:center}
  nav a{color:var(--sub);text-decoration:none;margin-left:18px;font-size:14px}
  .signin{display:inline-flex;align-items:center;gap:7px;margin-left:18px;padding:7px 14px;border:1px solid var(--line);border-radius:999px;color:var(--ink);text-decoration:none;font-size:14px;font-weight:600}
  .signin:hover{border-color:var(--accent);color:var(--accent)}
  @media(max-width:640px){ .signin .silabel{display:none} .signin{padding:7px} }
  .hero{max-width:900px;margin:0 auto;padding:64px 24px 28px;text-align:center}
  .hero h1{font-size:clamp(30px,5vw,50px);line-height:1.05;letter-spacing:-.02em;margin:0 0 14px}
  .hero p{font-size:18px;color:var(--sub);max-width:640px;margin:0 auto}
  .pill{display:inline-block;margin-top:22px;padding:11px 22px;border-radius:999px;background:var(--accent);color:#fff;text-decoration:none;font-weight:600}
  .hero-cta{display:flex;gap:12px;flex-wrap:wrap;justify-content:center;align-items:center}
  .demo-pill{background:linear-gradient(135deg,#b8860b,#e0a92e);color:#fff;box-shadow:0 6px 18px rgba(184,134,11,.32);border:0}
  .demo-pill:hover{filter:brightness(1.06)}
  .demo-note{font-size:14px;color:var(--sub);max-width:560px;margin:12px auto 0}
  .wrap{max-width:1040px;margin:0 auto;padding:20px 24px 60px}
  .bar{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:space-between;margin:26px 0 14px}
  .bar h2{font-size:15px;text-transform:uppercase;letter-spacing:.08em;color:var(--sub);margin:0}
  .controls{display:flex;gap:14px;align-items:center}
  .controls label{font-size:13px;color:var(--sub)}
  select,input[type=range]{accent-color:var(--accent)}
  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px}
  .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px}
  .card h3{margin:0 0 2px;font-size:17px;letter-spacing:-.01em}
  .score{float:right;font-weight:800;font-size:22px}
  .score.hi{color:var(--accent)} .score.mid{color:var(--amber)} .score.lo{color:#b23b3b}
  .conf{font-size:12px;color:var(--sub);text-transform:uppercase;letter-spacing:.05em}
  .meta{margin:10px 0;font-size:14px;color:var(--ink)}
  .meta b{font-variant-numeric:tabular-nums}
  .acts{margin:10px 0 0;padding:8px 0 0;border-top:1px dashed var(--line);font-size:13px;color:var(--sub)}
  .acts .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--sub);margin-bottom:2px}
  .act{display:flex;justify-content:space-between;gap:8px;align-items:baseline;padding:4px 0;border-top:1px solid #f2f5f8}
  .act .an{font-weight:600;color:var(--ink)}
  .act .ad{color:var(--sub);font-variant-numeric:tabular-nums;flex:1;text-align:right}
  .act .af{font-size:11px;white-space:nowrap}
  .act.fit .af{color:var(--accent)} .act.nofit{opacity:.55} .act.nofit .af{color:#b23b3b}
  .none{color:#b23b3b;font-size:12.5px;margin-top:6px}
  .best{background:linear-gradient(135deg,#0a7d5a,#0bb37f);color:#fff;border-radius:16px;padding:20px;margin:18px 0 6px;box-shadow:0 8px 24px rgba(10,125,90,.28)}
  .best .lbl2{font-size:11px;text-transform:uppercase;letter-spacing:.1em;opacity:.9}
  .best h3{margin:3px 0 8px;font-size:23px;letter-spacing:-.01em}
  .best .stat{display:inline-block;margin:0 18px 4px 0;font-size:14px;opacity:.95}
  .best .stat b{font-size:17px}
  .best a.act-btn{display:inline-block;margin:12px 8px 0 0;padding:10px 16px;background:#fff;color:#0a7d5a;border-radius:10px;text-decoration:none;font-weight:700;font-size:14px}
  .best .todo{margin-top:14px;padding-top:10px;border-top:1px solid rgba(255,255,255,.28);font-size:13px;line-height:1.9}
  .best .todo a{color:#fff;text-decoration:underline;margin-right:12px}
  /* place chips (ranked things-to-do) — click into the popup place card */
  .pchips{display:flex;flex-wrap:wrap;gap:7px;margin-top:7px}
  .pchip{display:inline-flex;align-items:center;gap:5px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.5);color:#fff;border-radius:16px;padding:5px 12px;font-size:12.5px;font-weight:600;cursor:pointer;line-height:1.3;user-select:none}
  .pchip:hover{background:rgba(255,255,255,.3)}
  .pchip .pcat{opacity:.8;font-weight:400}
  /* popup place card */
  .pc-ovl{position:fixed;inset:0;background:rgba(8,18,15,.55);z-index:4000;display:flex;align-items:center;justify-content:center;padding:18px}
  .pc-card{background:#fff;color:var(--ink);border-radius:16px;max-width:430px;width:100%;overflow:hidden;box-shadow:0 24px 70px rgba(0,0,0,.35);position:relative;animation:pcin .18s ease}
  @keyframes pcin{from{transform:translateY(10px);opacity:0}to{transform:none;opacity:1}}
  .pc-thumb{width:100%;height:210px;object-fit:cover;display:block;background:#e9f0ee}
  .pc-x{position:absolute;top:10px;right:10px;width:32px;height:32px;border-radius:50%;border:0;background:rgba(0,0,0,.55);color:#fff;font-size:16px;cursor:pointer;line-height:1}
  .pc-body{padding:14px 18px 18px}
  .pc-body h3{margin:0 0 2px;font-size:19px;letter-spacing:-.01em}
  .pc-sub{color:var(--sub);font-size:13px;margin-bottom:8px}
  .pc-meta{font-size:13.5px;line-height:1.8;margin-bottom:4px}
  .pc-acts{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
  .pc-acts a{display:inline-block;padding:8px 14px;border-radius:10px;background:var(--accent);color:#fff;font-weight:700;font-size:13px;text-decoration:none}
  .pc-acts a.sec{background:#eef3f1;color:var(--ink)}
  .card[data-gi]{cursor:pointer;transition:box-shadow .12s ease}
  .card[data-gi]:hover{box-shadow:0 6px 22px rgba(0,0,0,.09)}
  .best .bhead{display:flex;justify-content:space-between;align-items:center;gap:10px}
  .best .bnav{display:flex;align-items:center;gap:8px;flex-shrink:0}
  .best .bnav button{width:34px;height:34px;border-radius:50%;border:0;background:rgba(255,255,255,.22);color:#fff;font-size:22px;line-height:1;cursor:pointer}
  .best .bnav button:disabled{opacity:.35;cursor:default}
  .best .bcount{font-size:12px;opacity:.9;min-width:30px;text-align:center;font-variant-numeric:tabular-nums}
  .pulse-red{width:16px;height:16px;border-radius:50%;background:#e11d1d;border:2px solid #fff;box-shadow:0 0 0 0 rgba(225,29,29,.7);animation:pulseRed 1.1s infinite}
  @keyframes pulseRed{0%{box-shadow:0 0 0 0 rgba(225,29,29,.7)}70%{box-shadow:0 0 0 16px rgba(225,29,29,0)}100%{box-shadow:0 0 0 0 rgba(225,29,29,0)}}
  .trip{max-width:640px}
  .trip input{width:100%;padding:14px 16px;font-size:16px;border:1px solid var(--line);border-radius:12px;box-sizing:border-box}
  .destsug{background:#fff;border:1px solid var(--line);border-radius:12px;margin-top:6px;overflow:hidden;display:none}
  .destsug div{padding:12px 14px;font-size:14px;border-top:1px solid #f2f5f8;cursor:pointer}
  .destsug div:first-child{border-top:0}
  .destsug div:active,.destsug div:hover{background:var(--accent-soft)}
  .tripcard{background:#fff;border:1px solid var(--line);border-radius:14px;padding:16px;margin-top:12px}
  .tripcard .big{font-size:20px;font-weight:700}
  .tripcard .ok{color:var(--accent)} .tripcard .no{color:#b23b3b}
  .tripchg{display:flex;justify-content:space-between;gap:10px;align-items:baseline;padding:9px 0;border-top:1px solid #f2f5f8;font-size:14px;flex-wrap:wrap}
  .tripchg a{color:var(--accent);font-weight:600;text-decoration:none}
  /* mobile-first */
  @media(max-width:600px){
    #grid{grid-template-columns:1fr !important}
    .bar{flex-direction:column;align-items:stretch}
    .controls{flex-wrap:wrap;gap:10px}
    .best .stat{display:block;margin:3px 0}
    .best a.act-btn{display:block;text-align:center;margin:8px 0 0}
    #map{height:280px}
  }
  .evpanel{background:#fff;border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:14px 0}
  .evpanel select{padding:7px;border-radius:8px;border:1px solid var(--line);font-size:14px}
  footer{border-top:1px solid var(--line);background:#fff;color:var(--sub);font-size:12.5px;padding:22px 24px;text-align:center}
  .disc{max-width:760px;margin:0 auto}
  /* best-time-to-charge banner inside the hero */
  .best .chargetime{margin-top:12px;padding:9px 12px;background:rgba(255,255,255,.16);border-radius:10px;font-size:13.5px;line-height:1.5}
  /* availability estimate — real stall count + time-of-day estimate */
  .estflag{opacity:.7;font-size:.85em;font-weight:400}
  .avail-hi b{color:#0a7d5a} .avail-mid b{color:#b8860b} .avail-lo b{color:#b23b3b}
  .best .avail-hi b,.best .avail-mid b,.best .avail-lo b{color:#fff}
  .card .meta .avail-hi b{color:#0a7d5a}.card .meta .avail-mid b{color:#b8860b}.card .meta .avail-lo b{color:#b23b3b}
  /* "things near this charger" pill buttons */
  .card .nearby{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}
  .card .nearby .nb{font-size:12px;font-weight:600;color:#0a7d5a;background:#eef7f2;border:1px solid #d6ebe0;border-radius:999px;padding:5px 11px;text-decoration:none;white-space:nowrap;transition:background .12s ease}
  .card .nearby .nb:hover{background:#dcefe5}
  .best .nearby{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}
  .best .nearby .nb{font-size:12.5px;font-weight:700;color:#0a7d5a;background:#fff;border-radius:999px;padding:6px 12px;text-decoration:none;white-space:nowrap}
  .best .nearby .nb:hover{background:#eef7f2}
  /* My Garage (connected-vehicle charge) */
  .gv{display:flex;flex-wrap:wrap;gap:10px}
  .gv .gcard{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px;min-width:170px}
  .gv .gcard b{font-size:15px}
  .gv .bat{font-weight:800;font-size:18px}
  .gv .bat.hi{color:#0a7d5a}.gv .bat.mid{color:#b8860b}.gv .bat.lo{color:#b23b3b}
  .gbtn{display:inline-block;padding:9px 15px;border:0;border-radius:10px;background:#0a7d5a;color:#fff;font-weight:700;font-size:14px;cursor:pointer;text-decoration:none}
  .gbtn.sec{background:#eef7f2;color:#0a7d5a;border:1px solid #d6ebe0}
  /* Tesla stats grid + level graphs */
  .tstats{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px}
  .tstats .tchip{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-size:13px}
  .tstats .tchip span{display:block;font-size:11px;color:var(--sub);text-transform:uppercase;letter-spacing:.05em}
  .tstats .tchip b{font-size:15px;font-variant-numeric:tabular-nums}
  .tgsel{display:inline-flex;gap:6px}
  .tg-chip{border:1px solid var(--line);background:#fff;color:var(--sub);border-radius:999px;padding:4px 12px;font-size:12.5px;font-weight:600;cursor:pointer}
  .tg-chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
  .tgcard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:12px 14px;margin-top:8px}
  .glabel{font-size:12px;color:var(--sub);text-transform:uppercase;letter-spacing:.06em;margin:2px 0 4px}
</style>
<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>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0a7d5a" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Charge & Explore" />
<link rel="apple-touch-icon" href="/icon.svg" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<script>if('serviceWorker' in navigator){window.addEventListener('load',function(){navigator.serviceWorker.register('/sw.js').catch(function(){});});}</script>
<script type="importmap">{"imports":{"three":"https://unpkg.com/three@0.160.0/build/three.module.js"}}</script>
<script type="module" src="/charge-viz.js"></script>
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5278231299883833" crossorigin="anonymous"></script>
</head>
<body>
<header>
  <div class="brand"><span class="mk"><svg viewBox="0 0 1024 1024" width="20" height="20" fill="#fff" aria-hidden="true"><path fill-rule="evenodd" d="M512 168 C 651 168 764 281 764 420 C 764 610 512 864 512 864 C 512 864 260 610 260 420 C 260 281 373 168 512 168 Z M556 300 L430 470 L505 470 L470 596 L600 404 L520 404 L556 300 Z"/></svg></span> Charge &amp; Explore</div>
  <nav>
    <a href="#demo">Live demo</a>
    <span id="account">
      <a class="signin" href="/auth/google/login" title="Sign in with Google" aria-label="Sign in with Google">
        <svg width="17" height="17" viewBox="0 0 48 48"><path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9.1 3.6l6.8-6.8C35.8 2.4 30.3 0 24 0 14.6 0 6.5 5.4 2.6 13.2l7.9 6.2C12.4 13.6 17.7 9.5 24 9.5z"/><path fill="#4285F4" d="M46.5 24.5c0-1.6-.1-3.1-.4-4.5H24v9h12.7c-.6 3-2.3 5.5-4.8 7.2l7.7 6c4.5-4.2 6.9-10.3 6.9-17.7z"/><path fill="#FBBC05" d="M10.5 28.6a14.5 14.5 0 0 1 0-9.2l-7.9-6.2a24 24 0 0 0 0 21.6l7.9-6.2z"/><path fill="#34A853" d="M24 48c6.3 0 11.6-2.1 15.5-5.7l-7.7-6c-2.1 1.4-4.8 2.3-7.8 2.3-6.3 0-11.6-4.1-13.5-9.9l-7.9 6.2C6.5 42.6 14.6 48 24 48z"/></svg>
        <span class="silabel">Sign in with Google</span>
      </a>
      <a class="signin" id="appleSignin" hidden href="/auth/apple/login" title="Sign in with Apple" aria-label="Sign in with Apple" style="margin-left:10px">
        <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.51 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>
        <span class="silabel">Sign in with Apple</span>
      </a>
      <a class="signin" href="#" id="emailSignin" hidden title="Sign in with email" aria-label="Sign in with email" style="margin-left:10px">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 7 10 6 10-6"/></svg>
        <span class="silabel">Email sign-in</span>
      </a>
      <script>
      (function(){
        // Reveal Apple / email sign-in only when their creds are configured
        // server-side (else they'd 500 / dev-fallback). Google stays always-on.
        fetch('/api/auth-config').then(function(r){return r.json();}).then(function(c){
          if(c && c.apple){ var a=document.getElementById('appleSignin'); if(a) a.hidden=false; }
          if(c && c.email){ var e=document.getElementById('emailSignin'); if(e) e.hidden=false; }
        }).catch(function(){});
        var b=document.getElementById('emailSignin'); if(!b) return;
        b.addEventListener('click', async function(e){
          e.preventDefault();
          var email=(window.prompt('Enter your email — we’ll send you a sign-in link:')||'').trim();
          if(!email) return;
          try{
            var r=await fetch('/auth/email/request',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({email:email})});
            var d=await r.json();
            if(!r.ok){ alert(d.error||'Could not send the link. Try again.'); return; }
            if(d.link){ if(confirm('Dev mode (email not configured yet): open your sign-in link now?')) location.href=d.link; }
            else { alert('Check your email — we sent a sign-in link to '+email+'.'); }
          }catch(err){ alert('Could not send the link. Try again.'); }
        });
      })();
      </script>
      <a class="signin" id="teslaConnect" href="/auth/tesla/login" title="Connect your Tesla" aria-label="Connect your Tesla" style="margin-left:10px">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 11l1.5-4h11L19 11"/><path d="M4 11h16v4H4z"/><circle cx="7.5" cy="15.5" r="1.5"/><circle cx="16.5" cy="15.5" r="1.5"/></svg>
        <span class="silabel">Connect Tesla</span>
      </a>
      <script>
      (function(){
        function markConnected(){
          var a=document.getElementById('teslaConnect'); if(!a) return;
          a.querySelector('.silabel').textContent='Tesla connected ✓';
          // Keep the href: a connected-but-stale session must still offer a
          // way back into Tesla login (re-auth just mints fresh tokens).
          a.title=a.ariaLabel='Tesla connected — click to re-connect';
          a.style.color='#0a7d5a';
        }
        if(new URLSearchParams(location.search).has('tesla')){
          markConnected();
          history.replaceState(null,'',location.pathname+location.hash);
        } else {
          fetch('/api/tesla/status').then(function(r){return r.json();})
            .then(function(s){ if(s.connected) markConnected(); }).catch(function(){});
        }
      })();
      </script>
    </span>
  </nav>
</header>

<section class="hero">
  <h1>The best stop while your car charges.</h1>
  <p>Not another route planner. Charge &amp; Explore ranks the charging stops you can actually reach by the quality of the <em>break</em> you can take — detour, charger, restrooms, walking access, and activities that fit your charge window.</p>
  <div class="hero-cta">
    <a class="pill" href="#demo">See it rank stops →</a>
    <a class="pill demo-pill" id="demoMode" href="/auth/demo/login" aria-label="Explore in Demo Mode">⚡ Explore in Demo Mode</a>
  </div>
  <p class="demo-note">No account or password needed — Demo Mode loads a <b>sample</b> vehicle with sample telemetry, route &amp; charging stations so every feature (including <b>My Tesla</b> and <b>Stats</b>) is instantly reviewable.</p>
</section>

<div class="wrap" style="padding-bottom:0">
  <div id="beststop"></div>
  <div class="bar">
    <h2>Chargers near you</h2>
    <div class="controls">
      <label>Show
        <select id="netfilter">
          <option value="all">All chargers</option>
          <option value="tesla">Superchargers / Tesla</option>
          <option value="standard">Standard (non-Tesla)</option>
        </select>
      </label>
      <button id="locbtn" class="pill" style="margin-top:0;padding:8px 16px;border:0;cursor:pointer">📍 Use my location</button>
      <span class="locmanual" style="display:inline-flex;gap:6px;align-items:center">
        <input id="locq" type="text" inputmode="search" placeholder="or type city / ZIP / address"
          style="padding:8px 10px;border:1px solid var(--line);border-radius:10px;font:inherit;min-width:180px">
        <button id="locgo" class="pill" style="margin-top:0;padding:8px 14px;border:0;cursor:pointer">Go</button>
      </span>
    </div>
  </div>
  <div id="map" style="height:360px;border:1px solid var(--line);border-radius:14px;overflow:hidden;background:#eef2f5"></div>
  <div id="locnote" class="conf" style="margin:8px 2px"></div>
</div>

<div class="wrap" id="garage-wrap" style="padding-bottom:0;display:none">
  <div class="bar"><h2>My Garage</h2><span id="garage-sub" class="conf" style="text-transform:none;letter-spacing:0"></span></div>
  <div id="garage"></div>
</div>

<!-- "Your charge" 3D viz is injected here ONLY when signed in with real vehicle SOC (see renderBestStop). -->

<div class="wrap" style="padding-bottom:0">
  <div class="bar"><h2>Plan a trip</h2></div>
  <div class="trip">
    <input id="destq" type="search" placeholder="Where to? (address or place)" autocomplete="off" enterkeyhint="search" />
    <div id="destsug" class="destsug"></div>
    <div id="tripresult"></div>
  </div>
</div>

<div class="wrap" id="demo">
  <div class="bar">
    <h2>Ranked charging stops near you</h2>
    <div class="controls">
      <label>Sort
        <select id="sort">
          <option value="score">Best stop</option>
          <option value="detour">Least detour</option>
          <option value="charge">Shortest charge</option>
        </select>
      </label>
      <label>Density <input id="density" type="range" min="1" max="4" value="3" /></label>
    </div>
  </div>
  <div class="grid" id="grid">Loading…</div>
</div>

<footer>
  <p class="disc"><strong>Charge &amp; Explore is an independent product.</strong> It is not affiliated with, endorsed by, or sponsored by Tesla, Inc. "Tesla" and related marks are the property of their respective owners. Charging estimates are approximate and are not guarantees of real-time charger availability.</p>
</footer>

<script>
const grid = document.getElementById('grid');
const sortSel = document.getElementById('sort');
const density = document.getElementById('density');
let stops = [];
// Hoisted: render() (below) reads bestStops, and render() is CALLED before the
// best-stop section runs — declaring these here avoids a temporal-dead-zone
// ReferenceError that would otherwise halt the whole script (live grid + map dead).
let bestStops = [], bestIdx = 0, suggestMarker, userLoc = null;

// ---- remembered settings ----
// localStorage always (works signed-out); when signed in, changes ALSO sync to
// the account (/api/me/settings) so they follow the user across devices and
// come back on every sign-in. Server values win over localStorage at restore.
let __signedIn=false, __prefsTimer=null, __prefsPending={};
function savePref(k,v){
  try{ localStorage.setItem('ce-'+k, v) }catch{}
  if(!__signedIn) return;
  __prefsPending[k]=v; clearTimeout(__prefsTimer);
  __prefsTimer=setTimeout(()=>{ const b=__prefsPending; __prefsPending={};
    fetch('/api/me/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(b)}).catch(()=>{});
  },600);
}
function applyPrefs(p){
  if(!p) return;
  if(p.density){ density.value=p.density; density.oninput(); try{localStorage.setItem('ce-density',p.density)}catch{} }
  if(p.sort){ sortSel.value=p.sort; try{localStorage.setItem('ce-sort',p.sort)}catch{}; render(); }
  const nf=document.getElementById('netfilter');
  if(p.netfilter&&nf&&nf.value!==p.netfilter){ nf.value=p.netfilter; try{localStorage.setItem('ce-netfilter',p.netfilter)}catch{}; if(map) loadChargers(); }
  if(p.tgHours){ try{localStorage.setItem('ce-tgHours',p.tgHours)}catch{} }
}
density.oninput = () => document.documentElement.style.setProperty('--cols', density.value);
try { density.value = localStorage.getItem('ce-density') || 3; density.oninput(); } catch {}
density.addEventListener('input', () => savePref('density', density.value));
sortSel.value = (()=>{ try { return localStorage.getItem('ce-sort') || 'score' } catch { return 'score' } })();
sortSel.onchange = () => { savePref('sort', sortSel.value); render(); };

function cls(score){ return score>=85?'hi':score>=70?'mid':'lo'; }
function fmtDist(m){ const mi=m/1609.34; return mi<0.1 ? Math.round(m*3.28084)+' ft' : mi.toFixed(1)+' mi'; }

// ---- popup place card ----
// Ranked chips (things-to-do) and ranked stop cards click into this: a card with
// info about the place + a thumbnail of the location. Thumb priority: real place
// photo (Google Places via /api/places/photo, or Wikipedia page image) → OSM map
// tile of the location as the always-available fallback.
function osmTileUrl(lat,lon,z){ z=z||16; const n=Math.pow(2,z),
  x=Math.floor((lon+180)/360*n),
  y=Math.floor((1-Math.log(Math.tan(lat*Math.PI/180)+1/Math.cos(lat*Math.PI/180))/Math.PI)/2*n);
  return `https://tile.openstreetmap.org/${z}/${x}/${y}.png`; }
function pcEsc(e){ if(e.key==='Escape') closePlaceCard(); }
function closePlaceCard(){ const o=document.getElementById('pcOvl'); if(o) o.remove(); document.removeEventListener('keydown', pcEsc); }
function openPlaceCard(o){
  closePlaceCard();
  const fallback=osmTileUrl(o.lat,o.lon);
  const ovl=document.createElement('div'); ovl.className='pc-ovl'; ovl.id='pcOvl';
  ovl.innerHTML=`<div class="pc-card" role="dialog" aria-modal="true">
    <img class="pc-thumb" src="${o.thumb||fallback}" alt="${o.name}" loading="lazy">
    <button class="pc-x" aria-label="Close">✕</button>
    <div class="pc-body">
      <h3>${o.name}</h3>
      ${o.sub?`<div class="pc-sub">${o.sub}</div>`:''}
      <div class="pc-meta">${o.metaHtml||''}</div>
      <div class="pc-acts">${(o.actions||[]).map(a=>`<a class="${a.sec?'sec':''}"${a.ga?` data-ga="${a.ga}"`:''} href="${a.href}" target="_blank" rel="noopener">${a.label}</a>`).join('')}</div>
    </div></div>`;
  const img=ovl.querySelector('.pc-thumb');
  img.onerror=()=>{ img.onerror=null; img.src=fallback; };
  ovl.addEventListener('click',e=>{ if(e.target===ovl||e.target.closest('.pc-x')) closePlaceCard(); });
  document.addEventListener('keydown', pcEsc);
  document.body.appendChild(ovl);
  try{ track('place_card_open',{name:o.name}); }catch{}
}

// ---- Best time to charge (uses the viewer's LOCAL time) ----
// Based on standard CA time-of-use rates + typical station congestion: the
// 4–9pm window is both the priciest (peak TOU) and the busiest; overnight and
// midday are cheapest + emptiest. Advisory, not a live rate feed.
function bestChargeAdvice(d){
  const h = (d||new Date()).getHours();
  if (h>=16 && h<21) return { level:'peak', text:'⏰ Peak hours now — power costs most & stations are busiest. Cheaper & emptier after 9pm or before noon.' };
  if (h>=21 || h<6)  return { level:'best', text:'⏰ Great time to charge — off-peak rates and stations are usually wide open.' };
  if (h>=10 && h<15) return { level:'good', text:'⏰ Good time to charge — midday is off-peak and usually quiet.' };
  return { level:'ok', text:'⏰ Fine time to charge — approaching peak (4–9pm) is the window to avoid.' };
}
// ---- Availability estimate for a stop (REAL stall count + time-of-day estimate) ----
// stalls = real DC-fast port count from NREL. Live occupancy isn't in the feed,
// so busy-ness is an ESTIMATE by local hour (labeled as such). Switches to live
// when a network API (Tesla Fleet) is connected.
function availability(stalls, d){
  const h=(d||new Date()).getHours();
  const busy = (h>=16 && h<21) || (h>=7 && h<9); // commute + evening peak
  const mid  = (h>=9 && h<16);
  const n = Number.isFinite(stalls)&&stalls>0 ? stalls : null;
  const est = busy ? {cls:'lo', label:'likely busy'} : mid ? {cls:'mid', label:'moderate'} : {cls:'hi', label:'usually open'};
  const stallTxt = n ? (n+' stall'+(n>1?'s':'')) : 'stall count n/a';
  return { text:`🅿️ ${stallTxt} · <b>~${est.label}</b> <span class="estflag">(est.)</span>`, cls:est.cls, stalls:n };
}
// Client mirror of server chargerKw() — Supercharger (DC) vs Destination (L2).
function stationKw(s){
  const c=s.conn||[], isT=c.includes('TESLA')||c.includes('J3400'), dc=s.dc||0;
  if(isT&&dc>0) return 250; if(isT) return 11;
  if(c.includes('J1772COMBO')&&dc>0) return 150; if(dc>0) return 50; return 7;
}
// "Things near this charger" — open Google Maps searches centered on the stop's
// coordinates (falls back to its address). One tap → restaurants / stores /
// trails within walking distance of where the car charges. Every card surface
// (best-stop hero, ranked grid, place-card popup, map pin popup) shows these,
// always keyed to the SELECTED charger's location — never the user's.
function nearbyUrl(cat, r){
  let loc='';
  if(r.lat!=null && r.lon!=null) loc=' near '+r.lat+','+r.lon;
  else if(r.addr) loc=' near '+r.addr;
  return 'https://www.google.com/maps/search/?api=1&query='+encodeURIComponent(cat+loc);
}
const NEARBY=[
  {cat:'restaurants',   label:'🍽 Restaurants', ga:'restaurants_click'},
  {cat:'stores',        label:'🛍 Stores',      ga:'stores_click'},
  {cat:'trails',        label:'🥾 Trails',      ga:'trails_click'},
];
function nearbyBtnsHtml(r){
  return '<div class="nearby">'+NEARBY.map(n=>
    `<a class="nb" data-ga="${n.ga}" href="${nearbyUrl(n.cat,r)}" target="_blank" rel="noopener">${n.label}</a>`
  ).join('')+'</div>';
}
function render(){
  if(!bestStops.length){ grid.innerHTML='<p class="conf">Tap “📍 Use my location” to see live ranked stops near you.</p>'; return; }
  const s = [...bestStops];
  if (sortSel.value==='detour') s.sort((a,b)=>a.distanceMiles-b.distanceMiles);
  else if (sortSel.value==='charge') s.sort((a,b)=>(a.chargeMinutes.mid||0)-(b.chargeMinutes.mid||0));
  else s.sort((a,b)=>b.score-a.score);
  window.__gridStops = s; // click → popup place card (see grid click delegation below)
  grid.innerHTML = s.map((r,i)=>{
    const cm=r.chargeMinutes||{low:'?',high:'?'};
    const isT=/tesla|supercharger/i.test(r.network||'');
    return `<div class="card" data-gi="${i}" title="Tap for details">
      <span class="score ${cls(r.score)}">${r.score}</span>
      <h3>${r.name}</h3>
      ${r.addr?`<div class="conf" style="text-transform:none;letter-spacing:0">${r.addr}</div>`:''}
      <div class="meta">📍 <b>${r.distanceMiles} mi</b> away<br>🔌 to 80%: <b>${cm.low}–${cm.high} min</b><br>⚡ ~${r.maxKw} kW${isT?' · Supercharger':''}<br><span class="avail-${availability(r.stalls).cls}">${availability(r.stalls).text}</span></div>
      <a href="${r.directions}" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600;text-decoration:none;font-size:14px">Directions →</a>
      <a href="https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(r.name+' '+(r.addr||''))}" target="_blank" rel="noopener" style="color:var(--sub);font-weight:600;text-decoration:none;font-size:13px;margin-left:10px" title="Google Maps live popular times + traffic">🚦 Crowd</a>
      ${nearbyBtnsHtml(r)}
    </div>`;
  }).join('');
}
render(); // grid renders from live bestStops once location resolves

// Ranked stop cards → popup place card (inner Directions/Crowd links still navigate).
grid.addEventListener('click', e=>{
  if(e.target.closest('a')) return;
  const c=e.target.closest('.card[data-gi]'); if(!c) return;
  const r=(window.__gridStops||[])[+c.dataset.gi]; if(!r) return;
  const cm=r.chargeMinutes||{low:'?',high:'?'}; const av=availability(r.stalls);
  const isT=/tesla|supercharger/i.test(r.network||'');
  openPlaceCard({
    name:r.name, lat:r.lat, lon:r.lon, thumb:null,
    sub:(r.network||'Charging station')+(r.addr?' · '+r.addr:''),
    metaHtml:`🏆 Score <b>${r.score}</b> · 📍 <b>${r.distanceMiles} mi</b> away<br>`+
      `🔌 to 80%: <b>${cm.low}–${cm.high} min</b> · ⚡ ~${r.maxKw} kW${isT?' · Supercharger':''}<br>`+
      `<span class="avail-${av.cls}">${av.text}</span>`,
    actions:[
      {href:r.directions,label:'Directions →'},
      {href:`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${r.lat},${r.lon}`,label:'👀 Look around',sec:true},
      {href:`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(r.name+' '+(r.addr||''))}`,label:'🚦 Crowd',sec:true},
      ...NEARBY.map(n=>({href:nearbyUrl(n.cat,r),label:n.label,ga:n.ga,sec:true})),
    ],
  });
});

// ---- map + current location + live charger pins ----
let map, youMarker, chargerLayer;
const note = m => { document.getElementById('locnote').textContent = m; };
// isUser=true  → a confirmed user location: drops the "you are here" pin, sets
//                userLoc, and fires the best-stop + ranked grid.
// isUser=false → a default map VIEW only (renders tiles + charger pins so the map
//                is never blank on load) WITHOUT poisoning userLoc or showing
//                LA-based "best stops" as if they were the user's.
function initMap(lat, lon, label, isUser){
  if(isUser===undefined) isUser=true;
  if(!map){
    map = L.map('map').setView([lat,lon], isUser?13:11);
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OpenStreetMap'}).addTo(map);
    chargerLayer = L.layerGroup().addTo(map);
    map.on('moveend', loadChargers);
  } else map.setView([lat,lon], isUser?13:11);
  if(isUser){
    if(youMarker) youMarker.remove();
    youMarker = L.marker([lat,lon]).addTo(map).bindPopup(label||'You are here').openPopup();
  }
  loadChargers();
  if(isUser){ userLoc={latitude:lat,longitude:lon}; renderBestStop(lat,lon); }
}
function loadChargers(){
  if(!map) return;
  const b = map.getBounds();
  const nf = (document.getElementById('netfilter')||{}).value || 'all';
  fetch(`/api/stations/bbox?minLat=${b.getSouth()}&maxLat=${b.getNorth()}&minLon=${b.getWest()}&maxLon=${b.getEast()}&network=${nf}`)
    .then(r=>r.json()).then(d=>{
      chargerLayer.clearLayers();
      (d.stations||[]).forEach(s=>{
        const isT=/tesla/i.test(s.net||'')||(s.conn||[]).some(c=>c==='TESLA'||c==='J3400');
        const kw = stationKw(s);
        const av = availability(s.dc);
        const origin = userLoc?(userLoc.latitude+','+userLoc.longitude):'';
        const dir = `https://www.google.com/maps/dir/?api=1&origin=${origin}&destination=${s.lat},${s.lon}`;
        const crowd = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(s.name+' '+(s.addr||''))}`;
        const conns=(s.conn||[]).map(c=>c.replace('J1772COMBO','CCS').replace('J3400','NACS')).join(', ');
        const popup =
          `<div style="min-width:180px">`+
          `<b>${s.name}</b>`+
          (s.addr?`<div style="color:#5b6b7a;font-size:12px;margin:2px 0 6px">${s.addr}</div>`:'<br>')+
          `<div style="font-size:12.5px;line-height:1.7">`+
            `⚡ ~${kw} kW · ${s.dc>0?'DC fast':'Level 2'}${isT?' · Supercharger/Tesla':''}<br>`+
            `${av.text}`+
            (conns?`<br>🔌 ${conns}`:'')+
          `</div>`+
          `<div style="margin-top:8px">`+
            `<a href="${dir}" target="_blank" rel="noopener" style="color:#0a7d5a;font-weight:700;text-decoration:none">Directions →</a>`+
            ` &nbsp;·&nbsp; <a href="${crowd}" target="_blank" rel="noopener" style="color:#5b6b7a;font-weight:600;text-decoration:none">🚦 Crowd</a>`+
          `</div>`+
          `<div style="margin-top:6px;font-size:12px;line-height:1.9">`+
            NEARBY.map(n=>`<a href="${nearbyUrl(n.cat,{lat:s.lat,lon:s.lon,addr:s.addr})}" target="_blank" rel="noopener" data-ga="${n.ga}" style="color:#0a7d5a;font-weight:600;text-decoration:none;margin-right:10px;white-space:nowrap">${n.label}</a>`).join('')+
          `</div></div>`;
        L.circleMarker([s.lat,s.lon],{radius:5,color:isT?'#b23b3b':'#0a7d5a',fillColor:isT?'#e0392b':'#0a7d5a',fillOpacity:.85,weight:1})
          .addTo(chargerLayer)
          .bindTooltip(`${s.name}${s.addr?' — '+s.addr:''}`, {direction:'top',opacity:.95})
          .bindPopup(popup);
      });
      if(d.total!=null) note(`${d.shown} of ${d.total.toLocaleString()} EV chargers shown in this view — pan to load more.`);
      else if(d.error) note('Charger data still populating…');
    }).catch(()=> note('Charger lookup failed.'));
}
// ---- Hardened locate(): fast feedback, permission-aware, no 25s dead wait.
// Root cause of "breaks a lot": the old two-stage cascade could burn 25s before
// dumping to LA, and it auto-fired on every load. This version pre-checks
// permission, uses ONE 8s attempt (retry only on transient code-2), branches on
// the spec error code, and on failure sends the user to the reliable manual box.
(function(){
  let _locating = false;
  const btn = document.getElementById('locbtn');
  function setBusy(b){ if(!btn) return; btn.disabled=b; btn.textContent = b?'📡 Locating…':'📍 Use my location'; }
  function focusManual(msg){ if(msg) note(msg); const q=document.getElementById('locq'); if(!q) return; q.scrollIntoView({behavior:'smooth',block:'center'}); setTimeout(()=>q.focus(),350); }

  function startGPS(alreadyGranted){
    if(!('geolocation' in navigator)){ focusManual('Geolocation not supported — type a city or ZIP below.'); return; }
    _locating=true; setBusy(true); note('Locating you…');
    const isMobile=/Mobi|Android/i.test(navigator.userAgent);
    const opts={ enableHighAccuracy: isMobile||alreadyGranted, timeout:8000, maximumAge:120000 };
    navigator.geolocation.getCurrentPosition(
      pos=>{ _locating=false; setBusy(false); note(''); track('locate',{method:'gps'}); initMap(pos.coords.latitude,pos.coords.longitude,'You are here'); },
      err=>{ _locating=false; setBusy(false); track('locate_fail',{code:err.code});
        if(err.code===1){ focusManual('Location access denied. Allow this browser in System Settings → Privacy & Security → Location Services, or type a city/ZIP below.'); }
        else if(err.code===2){ // POSITION_UNAVAILABLE — often transient; one retry with opposite accuracy
          note('Getting your location…');
          navigator.geolocation.getCurrentPosition(
            pos=>{ note(''); track('locate',{method:'gps_retry'}); initMap(pos.coords.latitude,pos.coords.longitude,'You are here'); },
            ()=> focusManual('Couldn’t get a location fix. Type a city, ZIP, or address below — it works great on desktop.'),
            { enableHighAccuracy:!opts.enableHighAccuracy, timeout:6000, maximumAge:300000 });
        } else { focusManual('Location timed out. Type a city, ZIP, or address below — it’s faster on desktop.'); }
      }, opts);
  }
  function doLocate(){
    if(_locating) return;
    const p=navigator.permissions;
    if(p&&p.query){ p.query({name:'geolocation'}).then(r=>{
        if(r.state==='denied'){ track('locate_fail',{code:1,state:'denied'}); focusManual('Location is blocked for this browser. Allow it in System Settings → Privacy & Security → Location Services, then retry — or type a city/ZIP below.'); return; }
        startGPS(r.state==='granted');
      }).catch(()=>startGPS(false));
    } else startGPS(false);
  }
  window._locate = doLocate;
  if(btn) btn.addEventListener('click', doLocate);
})();
// Delegated analytics: outbound actions + sign-in (works for dynamically-rendered cards too).
document.addEventListener('click', function(e){
  const a = e.target.closest('a,button'); if(!a) return;
  const href = a.getAttribute('href')||'', txt=(a.textContent||'').trim();
  if(a.dataset && a.dataset.ga) return track(a.dataset.ga);
  if(href.indexOf('/auth/google')>-1) return track('signin_click',{provider:'google'});
  if(href.indexOf('/auth/tesla')>-1) return track('signin_click',{provider:'tesla'});
  if(/maps\/dir/.test(href)) return track('directions_click');
  if(/map_action=pano/.test(href)) return track('lookaround_click');
  if(/maps\/search/.test(href)) return track('crowd_traffic_click');
  if(a.classList.contains('todo')||a.closest('.todo')) return track('activity_click',{name:txt.split(' · ')[0]});
}, true);
// Manual location — the reliable path on desktop (many desktops have no GPS /
// Location Services off). Geocode the typed place and route it through initMap,
// which sets userLoc and fires the best-stop + ranked grid just like a GPS fix.
(function(){
  const q=document.getElementById('locq'), go=document.getElementById('locgo'); if(!q||!go) return;
  const submit=()=>{ const t=q.value.trim(); if(t.length<2){ q.focus(); return; }
    note('Finding “'+t+'”…');
    fetch('/api/geocode?q='+encodeURIComponent(t)).then(r=>r.json()).then(d=>{
      const hit=(d.results||[])[0];
      if(!hit){ note('Couldn’t find “'+t+'”. Try a city, ZIP, or full address.'); return; }
      track('locate', {method:'manual'});
      initMap(hit.lat, hit.lon, hit.name.split(',').slice(0,2).join(','));
    }).catch(()=> note('Location lookup failed — try again.'));
  };
  go.addEventListener('click', submit);
  q.addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); submit(); } });
})();
// Auto-locate on load ONLY if permission is already granted — never fire a blind
// getCurrentPosition on load (that was the 25s-timeout-on-every-desktop-load bug).
(function(){
  // ALWAYS paint the default LA map first so it's never blank, regardless of the
  // geolocation outcome. Then enhance: if permission is already granted, locate
  // for real (which re-centers + adds the "you are here" pin + best-stop).
  initMap(34.0522,-118.2437,'',false); // LA view, no userLoc, no fake best-stop
  const p=navigator.permissions;
  if(!p||!p.query) return; // no Permissions API → default map shown; wait for a tap
  p.query({name:'geolocation'}).then(r=>{
    if(r.state==='granted' && window._locate) window._locate();
  }).catch(()=>{});
})();

// ---- account slot: swap the Google button for the signed-in user ----
fetch('/api/me').then(r=>r.ok?r.json():null).then(d=>{
  if(!d||!d.user) return;
  __signedIn=true;
  // Restore this account's remembered settings (sort/density/filter/graph window).
  fetch('/api/me/settings').then(r=>r.ok?r.json():null).then(s=>{ if(s&&s.settings) applyPrefs(s.settings); }).catch(()=>{});
  const u=d.user, first=(u.name||u.email).split(' ')[0];
  document.getElementById('account').innerHTML =
    `<span class="signin" style="cursor:default" title="${u.email}">`+
    (u.picture?`<img src="${u.picture}" alt="" width="20" height="20" style="border-radius:50%" referrerpolicy="no-referrer">`:'')+
    `<span class="silabel">${first}</span></span>`+
    `<a class="signin" href="/auth/logout" style="margin-left:8px">Sign out</a>`;
}).catch(()=>{});

// ---- Tesla stats grid + "levels" graphs (Dust2026: full stats, remembered window, SVG charts) ----
function fmtTempF(c){ return c==null?null:Math.round(c*9/5+32)+'°F'; }
function fmtMins(m){ if(m==null) return null; const h=Math.floor(m/60); return h?h+'h '+(m%60)+'m':m+' min'; }
function teslaStatsHtml(v){
  const s=v.stats||{}, charging=v.chargingState==='Charging';
  const chip=(l,val)=>val==null?'':`<div class="tchip"><span>${l}</span><b>${val}</b></div>`;
  return chip('Battery', v.soc!=null?v.soc+'%':null)+
    chip('Usable', s.usableSoc!=null?s.usableSoc+'%':null)+
    chip('Range', v.rangeMiles!=null?v.rangeMiles+' mi':null)+
    chip('Est. range', s.estRangeMiles!=null?s.estRangeMiles+' mi':null)+
    chip('Charge limit', s.chargeLimit!=null?s.chargeLimit+'%':null)+
    (charging?chip('Charger power', s.chargerPowerKw!=null?s.chargerPowerKw+' kW':null):'')+
    (charging?chip('Charge rate', s.chargeRateMph!=null?'+'+s.chargeRateMph+' mi/hr':null):'')+
    (charging?chip('Time to limit', fmtMins(s.minutesToFull)):'')+
    (charging?chip('Energy added', s.energyAddedKWh!=null?s.energyAddedKWh+' kWh':null):'')+
    chip('Inside', fmtTempF(s.insideTempC))+
    chip('Outside', fmtTempF(s.outsideTempC))+
    chip('Odometer', s.odometerMiles!=null?s.odometerMiles.toLocaleString()+' mi':null)+
    chip('Locked', s.locked==null?null:(s.locked?'🔒 Yes':'🔓 No'))+
    chip('Sentry', s.sentry==null?null:(s.sentry?'👁 On':'Off'))+
    chip('Software', s.version||null);
}
// Dependency-free SVG line chart for a level series over time.
function levelChart(pts, key, o){
  const vals=pts.filter(p=>p[key]!=null);
  if(vals.length<2) return '<div class="conf" style="text-transform:none;letter-spacing:0">Not enough history yet — points are recorded on each read; check back soon.</div>';
  const W=600,H=150,PL=36,PR=10,PT=12,PB=20;
  const t0=vals[0].t, t1=vals[vals.length-1].t;
  const ymax=o.max||Math.ceil(Math.max(...vals.map(p=>p[key]))*1.1);
  const x=t=>PL+(W-PL-PR)*((t-t0)/Math.max(1,t1-t0));
  const y=v=>PT+(H-PT-PB)*(1-v/ymax);
  const line=vals.map((p,i)=>(i?'L':'M')+x(p.t).toFixed(1)+' '+y(p[key]).toFixed(1)).join(' ');
  const area=line+` L${x(t1).toFixed(1)} ${y(0).toFixed(1)} L${x(t0).toFixed(1)} ${y(0).toFixed(1)} Z`;
  const gl=(o.grid&&o.grid.length?o.grid:[Math.round(ymax/3),Math.round(2*ymax/3)]).filter(g=>g>0&&g<ymax);
  const grid=gl.map(g=>`<line x1="${PL}" x2="${W-PR}" y1="${y(g).toFixed(1)}" y2="${y(g).toFixed(1)}" stroke="#e6ebf0"/><text x="4" y="${(y(g)+4).toFixed(1)}" font-size="10" fill="#5b6b7a">${g}${o.unit}</text>`).join('');
  const last=vals[vals.length-1];
  const fmt=d=>new Date(d).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric'});
  return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${o.label} over time" style="width:100%;display:block">
    ${grid}
    <path d="${area}" fill="${o.color}" fill-opacity=".1"/>
    <path d="${line}" fill="none" stroke="${o.color}" stroke-width="2"/>
    <circle cx="${x(last.t).toFixed(1)}" cy="${y(last[key]).toFixed(1)}" r="3.5" fill="${o.color}"/>
    <text x="${W-PR}" y="${Math.max(12,y(last[key])-8).toFixed(1)}" text-anchor="end" font-size="12" font-weight="700" fill="${o.color}">${last[key]}${o.unit}</text>
    <text x="${PL}" y="${H-4}" font-size="10" fill="#5b6b7a">${fmt(t0)}</text>
    <text x="${W-PR}" y="${H-4}" text-anchor="end" font-size="10" fill="#5b6b7a">${fmt(t1)}</text>
  </svg>`;
}
function renderTeslaGraphs(vin, hours){
  const el=document.getElementById('tgraphs'); if(!el) return;
  el.innerHTML='<div class="conf" style="text-transform:none;letter-spacing:0">Loading history…</div>';
  fetch('/api/tesla/history?vin='+encodeURIComponent(vin||'')+'&hours='+hours).then(r=>r.json()).then(d=>{
    const pts=d.points||[];
    el.innerHTML='<div class="tgcard"><div class="glabel">🔋 Battery level</div>'+
        levelChart(pts,'soc',{max:100,unit:'%',color:'#0a7d5a',grid:[20,50,80],label:'Battery level'})+
      '</div><div class="tgcard"><div class="glabel">🛣 Range</div>'+
        levelChart(pts,'range',{unit:' mi',color:'#4285F4',label:'Range'})+
      '</div>';
  }).catch(()=>{ el.innerHTML='<div class="conf" style="text-transform:none;letter-spacing:0">History unavailable right now.</div>'; });
}

// ---- My Garage (Tesla Fleet API + Smartcar telematics: live charge % → best-stop SOC) ----
async function renderGarage(){
  const wrap=document.getElementById('garage-wrap'); if(!wrap) return;
  let me=null,g=null,t=null;
  try{ me=await fetch('/api/me').then(r=>r.ok?r.json():null); }catch{}
  try{ t=await fetch('/api/tesla/summary').then(r=>r.ok?r.json():null); }catch{}
  const teslaOn=!!(t&&t.connected);
  // Garage shows for signed-in users OR anyone with a linked Tesla (separate cookie).
  if((!me||!me.user)&&!teslaOn){ wrap.style.display='none'; return; }
  if(me&&me.user){ try{ g=await fetch('/api/garage').then(r=>r.json()); }catch{ g={configured:false,vehicles:[]}; } }
  wrap.style.display='';
  const el=document.getElementById('garage'), sub=document.getElementById('garage-sub');
  const bc=p=>p==null?'':(p>=60?'hi':p>=25?'mid':'lo');
  let html='', liveSoc=null;

  // Tesla card — live battery/charging/range from the Fleet API; a sleeping car
  // shows the cached last-known read (never force-wake: it costs the owner range).
  if(teslaOn){
    const v=t.vehicle;
    if(v&&v.soc!=null){
      const when=v.asleep&&v.cachedAt?new Date(v.cachedAt).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):null;
      html+=`<div class="gcard"><b>${v.name||'My Tesla'}</b> <span class="conf" style="text-transform:none;letter-spacing:0">· Tesla</span><br>`+
        `<span class="bat ${bc(v.soc)}">${v.soc}%</span>`+
        (v.chargingState==='Charging'?' ⚡ charging':'')+
        (v.asleep?` <span class="conf" style="text-transform:none;letter-spacing:0">😴 asleep${when?' · as of '+when:''}</span>`:'')+
        (v.rangeMiles!=null?`<br><span class="conf" style="text-transform:none;letter-spacing:0">~${v.rangeMiles} mi range</span>`:'')+
      `</div>`;
      liveSoc=v.soc;
    } else {
      // First-run empty cache: connected but no reading yet. Branch on the car's
      // reachability — "offline" means no LTE/WiFi (waking won't help).
      const offline=t.state==='offline';
      html+=`<div class="gcard"><b>${t.name||'My Tesla'}</b> <span class="conf" style="text-transform:none;letter-spacing:0">· Tesla</span><br>`+
        `<span class="conf" style="text-transform:none;letter-spacing:0">${offline
          ?'📡 car is offline (no LTE/WiFi where it\'s parked) — data appears once it reconnects'
          :'😴 car is asleep — open the Tesla app, drive, or charge to seed the first reading'}</span>`+
        (offline?'':`<br><button class="gbtn sec" id="gWake" style="margin-top:8px">⚡ Wake my car</button>`)+
      `</div>`;
    }
  }

  // Smartcar cards (signed-in users; ~40 other brands).
  if(g&&g.connected){
    html+=g.vehicles.map(v=>{
      const c=v.charge||{}, p=c.batteryPercent;
      return `<div class="gcard"><b>${[v.year,v.make,v.model].filter(Boolean).join(' ')||'Vehicle'}</b><br>`+
        (p!=null?`<span class="bat ${bc(p)}">${p}%</span>${c.isCharging?' ⚡ charging':''}`:'<span class="conf">charge unavailable</span>')+
        (c.rangeMiles!=null?`<br><span class="conf" style="text-transform:none;letter-spacing:0">~${c.rangeMiles} mi range</span>`:'')+
      `</div>`;
    }).join('');
    if(liveSoc==null){ const wv=g.vehicles.find(v=>v.charge&&v.charge.batteryPercent!=null); if(wv) liveSoc=wv.charge.batteryPercent; }
  }

  if(html){
    sub.textContent='Live charge from your vehicle feeds your best-stop ranking.';
    const tv=(teslaOn&&t.vehicle&&t.vehicle.soc!=null)?t.vehicle:null;
    const tgHours=(()=>{ try{ return Number(localStorage.getItem('ce-tgHours'))||24 }catch{ return 24 } })();
    el.innerHTML='<div class="gv">'+html+'</div>'+
      '<div style="margin-top:10px;display:flex;gap:8px"><button class="gbtn sec" id="gRefresh">↻ Refresh</button>'+
      (g&&g.connected?'<button class="gbtn sec" id="gDisc">Disconnect</button>':'')+'</div>'+
      (tv?
        '<div class="bar" style="margin:18px 0 10px"><h2>Tesla stats</h2><span class="conf" style="text-transform:none;letter-spacing:0">'+(tv.asleep?'last known — car asleep':'live from your car')+
          ' · <a href="/dashboard" style="color:var(--accent);font-weight:700;text-decoration:none">🚗 Dashboard →</a>'+
          ' · <a href="/stats" style="color:var(--accent);font-weight:700;text-decoration:none">Full stats →</a></span></div>'+
        '<div class="tstats">'+teslaStatsHtml(tv)+'</div>'+
        '<div class="bar" style="margin:16px 0 6px"><h2>Levels</h2><span class="tgsel">'+
          [[24,'24h'],[168,'7d'],[720,'30d']].map(([h,l])=>'<button class="tg-chip'+(h===tgHours?' on':'')+'" data-h="'+h+'">'+l+'</button>').join('')+
        '</span></div><div id="tgraphs"></div>'
      :'');
    if(tv){
      renderTeslaGraphs(tv.vin, tgHours);
      el.querySelectorAll('.tg-chip').forEach(b=>b.onclick=()=>{
        el.querySelectorAll('.tg-chip').forEach(x=>x.classList.toggle('on',x===b));
        savePref('tgHours', b.dataset.h);
        renderTeslaGraphs(tv.vin, +b.dataset.h);
      });
    }
  } else if(g&&!g.configured){
    sub.textContent=''; el.innerHTML='<span class="signin soon" style="opacity:.7;cursor:default">🚗 Vehicle sync — coming soon</span>';
    return;
  } else {
    sub.textContent='Connect any EV (Tesla, Ford, GM, Hyundai, VW…) for live charge %.';
    el.innerHTML='<a class="gbtn" href="/auth/smartcar/connect">🚗 Connect a vehicle</a>';
    return;
  }

  // real SOC → best-stop ranking
  if(liveSoc!=null){ userSoc=liveSoc; window.__hasRealSoc=true; if(userLoc) renderBestStop(userLoc.latitude,userLoc.longitude); }
  const rb=document.getElementById('gRefresh'), db=document.getElementById('gDisc');
  if(rb) rb.onclick=async()=>{ rb.textContent='↻ …'; if(g&&g.connected) await fetch('/api/garage/refresh',{method:'POST'}).catch(()=>{}); renderGarage(); };
  if(db) db.onclick=async()=>{ await fetch('/api/garage/disconnect',{method:'POST'}).catch(()=>{}); renderGarage(); };
  const wk=document.getElementById('gWake');
  if(wk) wk.onclick=async()=>{
    if(!confirm('Waking the car uses a small amount of battery range. Wake now?')) return;
    wk.disabled=true; wk.textContent='⚡ Waking… (~20s)';
    try{ await fetch('/api/tesla/wake',{method:'POST'}); }catch{}
    // Tesla takes ~10–30s to come online; poll the summary, then re-render.
    for(let i=0;i<6;i++){
      await new Promise(r=>setTimeout(r,10000));
      try{ const s=await fetch('/api/tesla/summary').then(r=>r.json()); if(s&&s.vehicle&&s.vehicle.soc!=null&&!s.vehicle.asleep){ renderGarage(); return; } }catch{}
    }
    wk.disabled=false; wk.textContent='Still waking — try ↻ in a minute';
  };
}
renderGarage();

// ---- network filter → reload pins ----
(function(){ const nf=document.getElementById('netfilter'); if(!nf) return;
  try{ nf.value=localStorage.getItem('ce-netfilter')||'all'; }catch{}
  nf.addEventListener('change',()=>{ savePref('netfilter', nf.value); if(map) loadChargers(); });
})();

// ---- best-stop hero (phone on-open): auto-pick + location + charge time + things to do ----
let userVehicleId=''; let userSoc=30;
// (bestStops/bestIdx/suggestMarker/userLoc hoisted to top of script — TDZ fix)
function renderBestStop(lat,lon){
  const el=document.getElementById('beststop'); if(!el) return;
  el.innerHTML='<div class="best"><div class="lbl2">Finding your best stops…</div></div>';
  const q=`lat=${lat}&lon=${lon}&soc=${userSoc}`+(userVehicleId?`&vehicleId=${encodeURIComponent(userVehicleId)}`:'');
  fetch('/api/best-stop?'+q).then(r=>r.json()).then(d=>{
    bestStops=d.stops||[]; bestIdx=0;
    track('best_stops_loaded', {count:bestStops.length, top:bestStops[0]?.name});
    if(!bestStops.length){ el.innerHTML=''; render(); return; }
    renderBestCard();
    render(); // live ranked-stops grid
  }).catch(()=>{ el.innerHTML=''; });
}
function renderBestCard(){
  const el=document.getElementById('beststop'); if(!el||!bestStops.length) return;
  const b=bestStops[bestIdx], cm=b.chargeMinutes||{low:'?',high:'?'};
  const isT=/tesla|supercharger/i.test(b.network||'');
  const rank = bestIdx===0 ? 'Your best stop right now' : ('Suggested stop #'+(bestIdx+1));
  el.innerHTML=`<div class="best">
    <div class="bhead">
      <div class="lbl2">${rank} · ${b.vehicle}</div>
      <div class="bnav">
        <button id="bprev" title="Previous" aria-label="Previous stop">‹</button>
        <span class="bcount">${bestIdx+1}/${bestStops.length}</span>
        <button id="bnext" title="Next" aria-label="Next stop">›</button>
      </div>
    </div>
    <h3>${b.name}</h3>
    ${b.addr?'<div style="font-size:12px;opacity:.9;margin:-4px 0 8px">'+b.addr+'</div>':''}
    <div>
      <span class="stat">📍 <b>${b.distanceMiles} mi</b> away</span>
      <span class="stat">🔌 to 80%: <b>${cm.low}–${cm.high} min</b></span>
      <span class="stat">⚡ <b>~${b.maxKw} kW</b>${isT?' · Supercharger/Tesla':''}</span>
      <span class="stat avail-${availability(b.stalls).cls}">${availability(b.stalls).text}</span>
      <span class="stat" id="btraffic">🚗 <b>checking traffic…</b></span>
    </div>
    <div class="chargetime">${bestChargeAdvice().text}</div>
    <a class="act-btn" href="${b.directions}" target="_blank" rel="noopener">Directions →</a>
    <a class="act-btn" href="https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(b.name+' '+(b.addr||''))}" target="_blank" rel="noopener">🚦 Live crowd & traffic</a>
    <a class="act-btn" href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${b.lat},${b.lon}" target="_blank" rel="noopener">👀 Look around</a>
    ${nearbyBtnsHtml(b)}
    <div class="todo" id="btodo">Loading things to do…</div>
  </div>`;
  const prev=document.getElementById('bprev'), next=document.getElementById('bnext');
  if(prev) prev.onclick=()=>{ bestIdx=(bestIdx-1+bestStops.length)%bestStops.length; renderBestCard(); };
  if(next) next.onclick=()=>{ bestIdx=(bestIdx+1)%bestStops.length; renderBestCard(); };
  // Live traffic-aware drive time (Google Routes) — on-demand + server-cached.
  (function(){ const tEl=document.getElementById('btraffic'), tIdx=bestIdx;
    if(!tEl||!userLoc){ if(tEl) tEl.style.display='none'; return; }
    fetch(`/api/traffic-eta?fromLat=${userLoc.latitude}&fromLon=${userLoc.longitude}&toLat=${b.lat}&toLon=${b.lon}`)
      .then(r=>r.json()).then(t=>{ if(tIdx!==bestIdx) return;
        if(t.unavailable||t.minutes==null){ tEl.style.display='none'; return; }
        const emoji=t.level==='heavy'?'🔴':t.level==='moderate'?'🟠':'🟢';
        const delay=t.delayMin>0?` · +${t.delayMin} min ${t.level} traffic`:' · clear';
        tEl.innerHTML=`🚗 <b>${t.minutes} min drive</b> ${emoji}${delay}`;
      }).catch(()=>{ if(tIdx===bestIdx) tEl.style.display='none'; });
  })();
  if(map){ try{
    map.setView([b.lat,b.lon], Math.max(map.getZoom()||13,13));
    if(suggestMarker) suggestMarker.remove();
    suggestMarker = L.marker([b.lat,b.lon], { zIndexOffset:1000, icon: L.divIcon({ className:'', html:'<div class="pulse-red"></div>', iconSize:[16,16], iconAnchor:[8,8] }) })
      .addTo(map).bindPopup('<b>'+b.name+'</b><br>suggested stop');
  }catch(e){} }
  // 3D charge viz — ONLY when signed in with a REAL vehicle SOC (window.__hasRealSoc).
  // Anonymous users don't get a fake battery; the section (+its container) is injected
  // on demand right after the best-stop hero so there's no empty space when hidden.
  try{
    if(window.__hasRealSoc){
      let cvsec=document.getElementById('chargeviz-sec');
      if(!cvsec){
        cvsec=document.createElement('div'); cvsec.className='wrap'; cvsec.id='chargeviz-sec'; cvsec.style.paddingBottom='0';
        cvsec.innerHTML='<div class="bar"><h2>Your charge</h2></div>'+
          '<div id="chargeviz" style="height:260px;border:1px solid var(--line);border-radius:14px;overflow:hidden;background:linear-gradient(180deg,#f7f9fb,#e9f4ef)"></div>';
        const hero=document.getElementById('beststop'); (hero?.closest('.wrap')||document.body).insertAdjacentElement('afterend', cvsec);
      }
      window.__chargeData={ arrivalSocPercent:userSoc, targetSocPercent:80, minutes:(cm.low+'–'+cm.high) };
      const cvel=document.getElementById('chargeviz');
      if(cvel && window.ChargeViz){ if(!window.__cvMounted){ window.ChargeViz.mount(cvel, window.__chargeData); window.__cvMounted=true; } else window.ChargeViz.update(window.__chargeData); }
    }
  }catch(e){}
  const td=document.getElementById('btodo'), myIdx=bestIdx;
  fetch(`/api/stations/activities?lat=${b.lat}&lon=${b.lon}`).then(r=>r.json()).then(a=>{
    if(myIdx!==bestIdx) return;
    const items=(a.activities||[]).slice(0,6);
    // Ranked place chips — each opens the popup place card (info + location thumb).
    td.innerHTML = items.length
      ? '<b>Things to do while you charge:</b><div class="pchips">'+items.map((x,i)=>
          `<span class="pchip" data-ai="${i}" role="button" tabindex="0" title="Tap for details">${x.name}${x.rating?' ★'+x.rating:''}<span class="pcat">· ${fmtDist(x.distanceMeters)}</span></span>`
        ).join('')+'</div>'
      : 'No listed spots within a short walk.';
    const openAct=(el)=>{
      const x=items[+el.dataset.ai]; if(!x) return;
      const walkMin=Math.max(1,Math.round(x.distanceMeters/80)); // ~80 m/min walking pace
      const thumb=x.photoUrl||(x.photoRef?('/api/places/photo?ref='+encodeURIComponent(x.photoRef)):null);
      openPlaceCard({
        name:x.name, lat:x.lat, lon:x.lon, thumb,
        sub:(x.category||'point of interest')+(x.rating?` · ★${x.rating}`:''),
        metaHtml:`📍 ${fmtDist(x.distanceMeters)} from the charger · 🚶 ~${walkMin} min walk`,
        actions:[
          {href:`https://www.google.com/maps/dir/?api=1&origin=${b.lat},${b.lon}&destination=${x.lat},${x.lon}&travelmode=walking`,label:'🚶 Walk there →'},
          x.website?{href:x.website,label:x.food?'Menu / site':'Website',sec:true}:null,
        ].filter(Boolean),
      });
    };
    td.querySelectorAll('.pchip').forEach(el=>{
      el.onclick=()=>openAct(el);
      el.onkeydown=e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); openAct(el); } };
    });
  }).catch(()=>{ if(myIdx===bestIdx) td.textContent=''; });
}

// ---- EV profile: non-Tesla drivers add their car; feeds the best-stop estimate ----
fetch('/api/me').then(r=>r.ok?r.json():null).then(d=>{
  if(!d||!d.user) return;
  // Real charge level only when signed in AND we actually have a live SOC (Tesla Fleet, or a saved EV that reports soc).
  if(typeof d.vehicle?.soc==='number'){ userSoc=d.vehicle.soc; window.__hasRealSoc=true; }
  else if(d.teslaConnected){ window.__hasRealSoc=true; }
  if(d.vehicle){ userVehicleId=d.vehicle.id; if(youMarker){const p=youMarker.getLatLng();renderBestStop(p.lat,p.lng);} return; }
  if(d.teslaConnected) return; // Tesla drivers: car comes from the Fleet API
  const host=document.getElementById('demo');
  const panel=document.createElement('div'); panel.className='wrap evpanel';
  panel.innerHTML='<b>Add your EV</b> <span class="conf">so charge times match your car</span><br>'+
    '<select id="evsel"><option value="">Select your EV…</option></select> '+
    '<button id="evsave" class="pill" style="margin-top:0;padding:7px 16px;border:0;cursor:pointer">Save</button>';
  host.parentNode.insertBefore(panel, host);
  fetch('/api/vehicles/catalog').then(r=>r.json()).then(c=>{
    const sel=panel.querySelector('#evsel');
    (c.vehicles||[]).forEach(v=>{ const o=document.createElement('option'); o.value=v.id; o.textContent=`${v.make} ${v.model} · ${v.usableBatteryKWh} kWh · ${v.maxDcKw} kW`; sel.appendChild(o); });
  });
  panel.querySelector('#evsave').addEventListener('click',()=>{
    const id=panel.querySelector('#evsel').value; if(!id) return;
    fetch('/api/me/vehicle',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({modelId:id})})
      .then(r=>r.json()).then(v=>{ if(!v.vehicle) return; userVehicleId=v.vehicle.id;
        panel.innerHTML=`<b>Your EV:</b> ${v.vehicle.make} ${v.vehicle.model} <span class="conf">(charge times now match your car)</span>`;
        if(youMarker){const p=youMarker.getLatLng();renderBestStop(p.lat,p.lng);} });
  });
}).catch(()=>{});

// ---- trip planner (mobile-first): geocode-as-you-type -> plan on select ----
(function(){
  const q=document.getElementById('destq'), sug=document.getElementById('destsug'), out=document.getElementById('tripresult');
  if(!q) return;
  let t;
  q.addEventListener('input',()=>{ clearTimeout(t); const v=q.value.trim(); if(v.length<3){sug.style.display='none';return;} t=setTimeout(()=>doGeocode(v),350); });
  function doGeocode(v){
    fetch('/api/geocode?q='+encodeURIComponent(v)).then(r=>r.json()).then(d=>{
      const rs=d.results||[]; if(!rs.length){sug.style.display='none';return;}
      sug.innerHTML=rs.map((r,i)=>'<div data-i="'+i+'">'+r.name+'</div>').join('');
      sug.style.display='block';
      sug.querySelectorAll('div').forEach(el=>el.onclick=()=>{ const r=rs[+el.dataset.i]; q.value=r.name.split(',')[0]; sug.style.display='none'; planTrip(r); });
    }).catch(()=>{ sug.style.display='none'; });
  }
  function planTrip(dest){
    if(!userLoc){ out.innerHTML='<div class="tripcard no">Tap “📍 Use my location” first so I know your starting point.</div>'; return; }
    out.innerHTML='<div class="tripcard">Planning…</div>';
    const p='fromLat='+userLoc.latitude+'&fromLon='+userLoc.longitude+'&destLat='+dest.lat+'&destLon='+dest.lon+'&soc='+userSoc+(userVehicleId?'&vehicleId='+encodeURIComponent(userVehicleId):'');
    fetch('/api/trip?'+p).then(r=>r.json()).then(d=>{
      const fc=d.fullChargeMinutes||{low:'?',high:'?'};
      const chg=(d.chargersNearDestination||[]).map(c=>'<div class="tripchg"><span><b>'+c.name+'</b> · ~'+c.maxKw+'kW · '+c.distanceMiles+'mi from dest'+(c.addr?'<br><span style="color:var(--sub)">'+c.addr+'</span>':'')+'</span><a href="'+c.directions+'" target="_blank" rel="noopener">Directions →</a></div>').join('');
      out.innerHTML='<div class="tripcard">'+
        '<div class="big">'+d.distanceMiles+' mi away · <span class="'+(d.reachable?'ok':'no')+'">'+(d.reachable?'reachable now':'charge first (~'+d.safeRangeMiles+' mi safe range)')+'</span></div>'+
        '<div class="conf" style="margin:6px 0">Full charge (10→100%) on your '+d.vehicle+': <b>'+fc.low+'–'+fc.high+' min</b></div>'+
        '<a href="'+d.directions+'" target="_blank" rel="noopener" style="background:var(--accent);color:#fff;display:inline-block;padding:11px 16px;border-radius:10px;text-decoration:none;font-weight:700">Directions to destination →</a>'+
        (chg?'<div style="margin-top:12px"><div class="conf">Charging near your destination:</div>'+chg+'</div>':'<div class="conf" style="margin-top:10px">No chargers within ~15 mi of the destination.</div>')+
      '</div>';
    }).catch(()=>{ out.innerHTML='<div class="tripcard no">Trip planning failed — try again.</div>'; });
  }
  document.addEventListener('click',e=>{ if(!sug.contains(e.target)&&e.target!==q) sug.style.display='none'; });
})();
</script>
</body>
</html>