<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Library Proximity · spin-geospatial-demo</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,500;0,600;1,400&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
  --bg:        #f7f1e3;
  --panel:    #efe8d4;
  --card:     #fbf7ec;
  --card-2:   #ece3cc;
  --border:   #d9ceac;
  --ink:      #23313a;
  --ink-2:    #4a5a63;
  --muted:    #7d8b92;
  --accent:   #3a6b49;
  --accent-2: #b9543a;
  --gold:     #a47148;
  --you:      #b9543a;
  --venue:    #3a6b49;

  --sec-kids: #7da87a; --sec-fic: #a67ca6; --sec-ref: #c58a3a; --sec-per: #8a8a8a;
  --sec-000: #6a8cae; --sec-100: #b98c5a; --sec-200: #8e7aa3; --sec-300: #b47060;
  --sec-400: #718c6a; --sec-500: #5a8aa6; --sec-600: #a67450; --sec-700: #c49b5e;
  --sec-800: #7a7aa3; --sec-900: #8a6a5a; --sec-av: #5a8a74;  --sec-comm: #a05858;

  --font-ui:   'Inter', -apple-system, sans-serif;
  --font-serif:'Lora', Georgia, serif;
  --font-mono: 'JetBrains Mono', monospace;
  --transition: 450ms cubic-bezier(.4,0,.2,1);
}
html, body { height: 100%; }
body { font-family: var(--font-ui); background: var(--bg); color: var(--ink); display: flex; flex-direction: column; overflow: hidden; }

/* --- HEADER --- */
.header { display: flex; align-items: center; justify-content: space-between; padding: 0 22px; height: 58px; background: var(--panel); border-bottom: 1px solid var(--border); }
.brand { display: flex; align-items: baseline; gap: 10px; }
.brand-title { font-family: var(--font-serif); font-weight: 600; font-size: 19px; color: var(--ink); }
.brand-sub { color: var(--ink-2); font-size: 13px; font-style: italic; font-family: var(--font-serif); }
.header-right { display: flex; align-items: center; gap: 10px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.header-btn {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 6px 11px; border: 1px solid var(--border); border-radius: 4px;
  color: var(--ink-2); text-decoration: none;
  font-size: 11px; font-family: var(--font-mono);
  background: var(--card); cursor: pointer; transition: 0.18s ease;
}
.header-btn:hover { color: var(--accent); border-color: var(--accent); }
.header-btn svg { width: 14px; height: 14px; fill: currentColor; }
.city-select {
  padding: 5px 10px; border: 1px solid var(--border); border-radius: 4px;
  background: var(--card); color: var(--ink);
  font-family: var(--font-ui); font-size: 12px;
  cursor: pointer;
}

/* --- MAP / FLOOR PANEL --- */
.layout { flex: 1; display: flex; overflow: hidden; }
.view { flex: 1; position: relative; background: var(--bg); overflow: hidden; min-width: 300px; }
#map, #floorplan { position: absolute; inset: 0; transition: opacity var(--transition); }
#map { opacity: 1; z-index: 1; }
#floorplan { opacity: 0; pointer-events: none; background: var(--card); z-index: 2; width: 100%; height: 100%; }
.view.inside #map { opacity: 0; pointer-events: none; }
.view.inside #floorplan { opacity: 1; pointer-events: auto; }
.leaflet-container { background: #f2ebd3; font-family: var(--font-ui); }
.leaflet-control-attribution { background: rgba(247,241,227,0.9) !important; color: var(--ink-2) !important; font-size: 10px !important; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: var(--card); color: var(--ink); box-shadow: 0 2px 10px rgba(35,49,58,0.12); border: 1px solid var(--border); }
.leaflet-popup-content { font-family: var(--font-ui); font-size: 13px; }
.leaflet-popup-content b { font-family: var(--font-serif); color: var(--accent); }

/* --- STAGE BANNER --- */
.stage-banner {
  position: absolute; top: 18px; left: 50%; transform: translateX(-50%);
  z-index: 500; max-width: 640px;
  background: rgba(251,247,236,0.96); border: 1px solid var(--border);
  border-radius: 8px; padding: 10px 18px;
  font-size: 13px; color: var(--ink);
  box-shadow: 0 2px 10px rgba(35,49,58,0.10);
  display: flex; align-items: center; gap: 12px;
  transition: opacity 0.3s ease, transform 0.3s ease;
}
.stage-banner.hidden { opacity: 0; pointer-events: none; transform: translateX(-50%) translateY(-8px); }
.stage-banner .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.stage-banner .dot.out { background: var(--gold); }
.stage-banner .dot.in  { background: var(--accent); box-shadow: 0 0 8px rgba(58,107,73,0.5); }
.stage-banner b { font-family: var(--font-serif); }
.stage-banner-hint { color: var(--muted); font-size: 11px; font-style: italic; }
.stage-exit {
  margin-left: 4px; padding: 4px 10px;
  background: var(--accent-2); color: #fff;
  border: none; border-radius: 4px;
  font-size: 11px; font-family: var(--font-ui); cursor: pointer;
}
.stage-exit:hover { background: #a34b33; }

/* --- LEGEND --- */
.legend {
  position: absolute; bottom: 18px; left: 18px; z-index: 500;
  background: rgba(251, 247, 236, 0.94);
  border: 1px solid var(--border); border-radius: 6px;
  padding: 11px 14px; font-size: 12px; color: var(--ink-2);
  display: flex; flex-direction: column; gap: 6px;
  box-shadow: 0 1px 4px rgba(35,49,58,0.08);
}
.legend-row { display: flex; align-items: center; gap: 8px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.legend-dot.you   { background: var(--you); box-shadow: 0 0 6px rgba(185,84,58,0.5); }
.legend-dot.lib   { background: var(--venue); }
.legend-hint { color: var(--muted); font-size: 11px; font-style: italic; }

/* --- SIDE PANEL --- */
.side { width: 440px; min-width: 440px; background: var(--panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
.side-section { padding: 18px 22px; border-bottom: 1px solid var(--border); }
.side-section h3 { font-family: var(--font-serif); font-size: 13px; font-weight: 600; color: var(--accent); letter-spacing: 0.2px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.side-section h3::before { content: ''; width: 18px; height: 1px; background: var(--accent); }
.intro { font-size: 13.5px; color: var(--ink); line-height: 1.6; }
.intro b { color: var(--accent-2); }
.intro code { font-family: var(--font-mono); font-size: 12px; color: var(--accent); background: var(--card); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--border); }
.tip { color: var(--ink-2); font-size: 12px; margin-top: 6px; font-style: italic; }

.status { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-family: var(--font-mono); font-size: 12px; }
.status-box { background: var(--card); padding: 9px 11px; border-radius: 4px; border: 1px solid var(--border); }
.status-label { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; font-family: var(--font-ui); }
.status-value { color: var(--ink); margin-top: 3px; font-weight: 500; font-family: var(--font-mono); font-size: 13px; }
.status-value.ok { color: var(--accent); }
.status-value.miss { color: var(--accent-2); }

.reading { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 14px 16px; min-height: 70px; }
.reading-empty { color: var(--muted); font-size: 13px; font-style: italic; font-family: var(--font-serif); }
.reading-zone-tag { display: inline-block; padding: 2px 9px; border-radius: 3px; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.6px; text-transform: uppercase; margin-bottom: 8px; background: rgba(58,107,73,0.1); color: var(--accent); border: 1px solid rgba(58,107,73,0.25); }
.reading-hero { font-family: var(--font-serif); font-size: 15px; font-weight: 500; color: var(--ink); line-height: 1.4; margin-bottom: 10px; }
.reading-list { list-style: none; margin: 12px 0 4px; padding-top: 10px; border-top: 1px dashed var(--border); }
.reading-list li { padding: 7px 0; border-bottom: 1px dashed var(--border); display: grid; grid-template-columns: 1fr auto; gap: 6px 14px; cursor: pointer; transition: background 0.15s; }
.reading-list li:last-child { border-bottom: none; }
.reading-list li:hover { background: rgba(58,107,73,0.06); }
.book-title { font-family: var(--font-serif); font-weight: 500; font-size: 14px; color: var(--ink); font-style: italic; }
.book-author { font-family: var(--font-ui); font-size: 12px; color: var(--ink-2); }
.book-call { font-family: var(--font-mono); font-size: 11px; color: var(--accent); white-space: nowrap; align-self: center; padding: 2px 7px; background: rgba(58,107,73,0.08); border-radius: 3px; }
.reading-meta { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 10px; color: var(--muted); }

.api-calls { flex: 1; overflow-y: auto; padding: 14px 22px 18px; }
.api-calls > h3 { font-family: var(--font-serif); font-size: 13px; font-weight: 600; color: var(--accent); margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.api-calls > h3::before { content: ''; width: 18px; height: 1px; background: var(--accent); }
.call { background: var(--card); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 9px; overflow: hidden; }
.call-head { padding: 8px 12px; display: flex; align-items: center; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; cursor: pointer; }
.call-head .meth { display: inline-flex; gap: 6px; align-items: center; }
.call-head .badge { padding: 1px 6px; border-radius: 3px; font-weight: 600; font-size: 10px; background: rgba(58,107,73,0.12); color: var(--accent); }
.call-head .path { color: var(--ink); word-break: break-all; }
.call-head .ms { color: var(--muted); font-size: 10px; white-space: nowrap; }
.call-body { padding: 0 12px 12px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; border-top: 1px solid var(--border); padding-top: 10px; display: none; background: var(--card-2); }
.call.open .call-body { display: block; }

.footer { padding: 14px 22px; border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); font-family: var(--font-ui); }
.footer a { color: var(--accent); text-decoration: none; }
.footer a:hover { text-decoration: underline; }

.side ::-webkit-scrollbar, .api-calls::-webkit-scrollbar { width: 8px; }
.side ::-webkit-scrollbar-track, .api-calls::-webkit-scrollbar-track { background: var(--panel); }
.side ::-webkit-scrollbar-thumb, .api-calls::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
</style>
</head>
<body>

<header class="header">
  <div class="brand">
    <span class="brand-title">Library Proximity</span>
    <span class="brand-sub">· 2,274 U.S. public libraries · geohash · Spin KV on the edge</span>
  </div>
  <div class="header-right">
    <select id="citySelect" class="city-select" title="Fly to a city">
      <option value="">Fly to a city…</option>
      <option value="40.7589,-73.9851">New York City</option>
      <option value="42.3601,-71.0589">Boston</option>
      <option value="41.8781,-87.6298">Chicago</option>
      <option value="37.7749,-122.4194">San Francisco</option>
      <option value="34.0522,-118.2437">Los Angeles</option>
      <option value="47.6062,-122.3321">Seattle</option>
      <option value="30.2672,-97.7431">Austin</option>
      <option value="39.7392,-104.9903">Denver</option>
      <option value="38.9072,-77.0369">Washington, D.C.</option>
      <option value="35.7796,-78.6382">Raleigh</option>
    </select>
    <button id="locateBtn" class="header-btn" title="Use my location">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3A8.994 8.994 0 0013 3.06V1h-2v2.06A8.994 8.994 0 003.06 11H1v2h2.06A8.994 8.994 0 0011 20.94V23h2v-2.06A8.994 8.994 0 0020.94 13H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/></svg>
      Use my location
    </button>
    <a class="header-btn" href="https://github.com/ccie7599/spin-geospatial-demo" target="_blank" rel="noopener">
      <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
      Source
    </a>
    <span id="status">ready</span>
  </div>
</header>

<div class="layout">

  <div class="view" id="view">
    <div id="map"></div>
    <canvas id="floorplan"></canvas>

    <div id="stageBanner" class="stage-banner hidden">
      <span id="stageDot" class="dot out"></span>
      <div>
        <div id="stageTitle"><b>—</b></div>
        <div id="stageHint" class="stage-banner-hint">—</div>
      </div>
      <button id="stageExit" class="stage-exit" style="display:none" onclick="leaveVenue(true)">Leave</button>
    </div>

    <div class="legend" id="legend">
      <div class="legend-row"><span class="legend-dot you"></span> You (drag anywhere)</div>
      <div class="legend-row"><span class="legend-dot lib"></span> Library branch</div>
      <div class="legend-hint" id="legendHint">Zoom in and drop onto a library to enter</div>
    </div>
  </div>

  <aside class="side">

    <div class="side-section">
      <h3>How to browse</h3>
      <div class="intro">
        Drag the <b>terracotta pin</b> onto any library marker, or use <b>Use my location</b> to start from where you are.
        When you're within ~45 m of a branch the view flips to the <b>interior floor plan</b> — drag inside to move
        between sections, and the reading list updates live.
        <div class="tip">The interior is a canonical public-library layout: 4×4 grid of the 10 Dewey classes plus children, fiction, reference, periodicals, A/V, and community room. Dashed lines on the floor plan show distance from you to each section.</div>
      </div>
    </div>

    <div class="side-section">
      <h3>Current check-in</h3>
      <div class="status">
        <div class="status-box"><div class="status-label">in_venue</div><div class="status-value" id="inStore">—</div></div>
        <div class="status-box"><div class="status-label">section</div><div class="status-value" id="zone">—</div></div>
        <div class="status-box" style="grid-column: 1 / 3;"><div class="status-label">venue</div><div class="status-value" id="venueName" style="font-size: 12px; font-family: var(--font-serif); font-style: italic;">—</div></div>
        <div class="status-box" style="grid-column: 1 / 3;"><div class="status-label">nearest</div><div class="status-value" id="nearest" style="font-size: 12px;">—</div></div>
      </div>
    </div>

    <div class="side-section">
      <h3>Reading list for this section</h3>
      <div class="reading" id="reading">
        <div class="reading-empty">Stand inside a section to see its reading list.</div>
      </div>
    </div>

    <div class="api-calls" id="calls"><h3>Recent API calls</h3></div>

    <div class="footer">
      Open source · <a href="https://github.com/ccie7599/spin-geospatial-demo" target="_blank" rel="noopener">ccie7599/spin-geospatial-demo</a> · library data from OpenStreetMap (ODbL 1.0)
    </div>

  </aside>

</div>

<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ============================================================
// STATE + CONSTANTS
// ============================================================
const DEVICE_ID = 'demo-' + Math.random().toString(36).slice(2, 8);
const VENUE_RADIUS_M = 40;
const M_PER_DEG_LAT = 111320;
const m_per_deg_lon = lat => 111320 * Math.abs(Math.cos(lat * Math.PI / 180));

// Canonical floor plan (must match load-libraries.sh).
// dx meters east, dy meters north from venue center. r is section radius in m.
const SECTION_LAYOUT = [
  { id: 'children',    label: 'Children',    topic: "Ages 3–12",          dx: -18, dy:  22, r: 6, color: '--sec-kids' },
  { id: 'fiction',     label: 'Fiction',     topic: "Novels & stories",    dx:  -6, dy:  22, r: 6, color: '--sec-fic'  },
  { id: 'reference',   label: 'Reference',   topic: "Research desk",       dx:   6, dy:  22, r: 6, color: '--sec-ref'  },
  { id: 'periodicals', label: 'Periodicals', topic: "Magazines & papers",  dx:  18, dy:  22, r: 6, color: '--sec-per'  },
  { id: 'dewey-000',   label: '000',         topic: "Computers & general", dx: -18, dy:   7, r: 6, color: '--sec-000'  },
  { id: 'dewey-100',   label: '100',         topic: "Philosophy & psych.", dx:  -6, dy:   7, r: 6, color: '--sec-100'  },
  { id: 'dewey-200',   label: '200',         topic: "Religion",            dx:   6, dy:   7, r: 6, color: '--sec-200'  },
  { id: 'dewey-300',   label: '300',         topic: "Social sciences",     dx:  18, dy:   7, r: 6, color: '--sec-300'  },
  { id: 'dewey-400',   label: '400',         topic: "Language",            dx: -18, dy:  -7, r: 6, color: '--sec-400'  },
  { id: 'dewey-500',   label: '500',         topic: "Science & nature",    dx:  -6, dy:  -7, r: 6, color: '--sec-500'  },
  { id: 'dewey-600',   label: '600',         topic: "Tech & everyday",     dx:   6, dy:  -7, r: 6, color: '--sec-600'  },
  { id: 'dewey-700',   label: '700',         topic: "Arts & recreation",   dx:  18, dy:  -7, r: 6, color: '--sec-700'  },
  { id: 'dewey-800',   label: '800',         topic: "Literature",          dx: -18, dy: -22, r: 6, color: '--sec-800'  },
  { id: 'dewey-900',   label: '900',         topic: "History & biography", dx:  -6, dy: -22, r: 6, color: '--sec-900'  },
  { id: 'audiovisual', label: 'A/V',         topic: "Audiobooks & DVDs",   dx:   6, dy: -22, r: 6, color: '--sec-av'   },
  { id: 'community',   label: 'Community',   topic: "Events & book clubs", dx:  18, dy: -22, r: 6, color: '--sec-comm' },
];

// Start on a Central Park path — open area, no libraries within ~400m
const DEFAULT_COORD = [40.7811, -73.9663];

const state = {
  inside: false,
  venue: null,
  venueLatLon: null,
  dotLatLon: DEFAULT_COORD.slice(),
  canvasSize: { w: 0, h: 0 },
  currentZoneId: null,
  hoverSectionId: null,
  // Prevent immediate re-entry after Leave:
  coolingDown: false,
};

// ============================================================
// DOM
// ============================================================
const statusEl = document.getElementById('status');
const callsEl  = document.getElementById('calls');
const viewEl   = document.getElementById('view');
const mapEl    = document.getElementById('map');
const canvas   = document.getElementById('floorplan');
const stageBanner = document.getElementById('stageBanner');
const stageDot    = document.getElementById('stageDot');
const stageTitle  = document.getElementById('stageTitle');
const stageHint   = document.getElementById('stageHint');
const stageExit   = document.getElementById('stageExit');
const legendHint  = document.getElementById('legendHint');

// ============================================================
// LEAFLET MAP
// ============================================================
const map = L.map(mapEl, { zoomControl: true }).setView(state.dotLatLon, 14);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
  attribution: '&copy; OpenStreetMap &copy; CARTO',
  subdomains: 'abcd', maxZoom: 20,
}).addTo(map);

const youIcon = L.divIcon({
  className: 'you-icon',
  html: '<div style="width:18px;height:18px;border-radius:50%;background:#b9543a;box-shadow:0 0 10px rgba(185,84,58,0.6);border:2px solid #fbf7ec;"></div>',
  iconSize: [18, 18], iconAnchor: [9, 9],
});
const you = L.marker(state.dotLatLon, { icon: youIcon, draggable: true }).addTo(map);

const libIcon = L.divIcon({
  className: 'lib-icon',
  html: '<div style="width:12px;height:12px;border-radius:50%;background:#3a6b49;border:1.5px solid #fbf7ec;box-shadow:0 0 2px rgba(0,0,0,0.2);"></div>',
  iconSize: [12, 12], iconAnchor: [6, 6],
});
const libMarkers = new Map();

async function loadLibrariesInView() {
  if (map.getZoom() < 11) {
    libMarkers.forEach(m => map.removeLayer(m));
    libMarkers.clear();
    return;
  }
  const c = map.getCenter();
  const resp = await apiCall(`/query/point/${c.lat.toFixed(4)}/${c.lng.toFixed(4)}/5`, 'preview');
  if (!resp || !resp.objects) return;
  const bounds = map.getBounds();
  const seen = new Set();
  for (const o of resp.objects) {
    if (o.type !== 'store') continue;
    if (!bounds.contains([o.lat, o.lon])) continue;
    seen.add(o.id);
    if (libMarkers.has(o.id)) continue;
    const m = L.marker([o.lat, o.lon], { icon: libIcon, title: o.metadata?.name || o.id });
    m.bindPopup(`<b>${o.metadata?.name || o.id}</b><br><small>${o.metadata?.address || ''}</small>`);
    m.addTo(map);
    libMarkers.set(o.id, m);
  }
  libMarkers.forEach((m, id) => {
    if (!seen.has(id)) { map.removeLayer(m); libMarkers.delete(id); }
  });
}

// ============================================================
// CANVAS FLOOR PLAN
// ============================================================
let ctx = null;
function sizeCanvas() {
  const rect = canvas.getBoundingClientRect();
  if (rect.width < 20 || rect.height < 20) return false;
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.floor(rect.width * dpr);
  canvas.height = Math.floor(rect.height * dpr);
  canvas.style.width = rect.width + 'px';
  canvas.style.height = rect.height + 'px';
  ctx = canvas.getContext('2d');
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  state.canvasSize = { w: rect.width, h: rect.height };
  return true;
}

function cssVar(name) {
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}

// World→canvas transform. We show a 56m-wide × 64m-tall view centered on venue,
// fit letter-boxed into the canvas. Sections live within ±24m×±28m so there's
// visible margin for walls.
function transform() {
  const { w, h } = state.canvasSize;
  const VIEW_W_M = 56, VIEW_H_M = 64;
  const pad = 40;
  const availW = w - pad * 2, availH = h - pad * 2;
  const pxPerM = Math.min(availW / VIEW_W_M, availH / VIEW_H_M);
  const cx = w / 2, cy = h / 2;
  return { pxPerM, cx, cy };
}
function m2px(dx, dy) {
  const t = transform();
  // dy in meters is NORTH. Canvas y grows DOWNWARD. Flip.
  return { x: t.cx + dx * t.pxPerM, y: t.cy - dy * t.pxPerM };
}
function px2m(px, py) {
  const t = transform();
  return { dx: (px - t.cx) / t.pxPerM, dy: -(py - t.cy) / t.pxPerM };
}

function drawFloorPlan() {
  if (!ctx) return;
  const { w, h } = state.canvasSize;
  const t = transform();

  // --- background ---
  ctx.fillStyle = cssVar('--card');
  ctx.fillRect(0, 0, w, h);

  // --- subtle grid every 5m ---
  ctx.strokeStyle = cssVar('--border');
  ctx.globalAlpha = 0.35;
  ctx.lineWidth = 0.5;
  const gridMax = 28;
  for (let m = -gridMax; m <= gridMax; m += 5) {
    const a = m2px(m, -gridMax), b = m2px(m, gridMax);
    ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
    const c2 = m2px(-gridMax, m), d = m2px(gridMax, m);
    ctx.beginPath(); ctx.moveTo(c2.x, c2.y); ctx.lineTo(d.x, d.y); ctx.stroke();
  }
  ctx.globalAlpha = 1;

  // --- venue outline (24m half-width, 28m half-height box = walls) ---
  const nw = m2px(-25, 28), se = m2px(25, -28);
  ctx.strokeStyle = cssVar('--ink-2');
  ctx.lineWidth = 2;
  ctx.strokeRect(nw.x, nw.y, se.x - nw.x, se.y - nw.y);

  // --- entrance on south wall ---
  const entW_m = 8;
  const entY_m = -28;
  const l = m2px(-entW_m/2, entY_m);
  const r = m2px( entW_m/2, entY_m);
  ctx.strokeStyle = cssVar('--card');
  ctx.lineWidth = 4;
  ctx.beginPath(); ctx.moveTo(l.x, l.y); ctx.lineTo(r.x, r.y); ctx.stroke();
  ctx.fillStyle = cssVar('--ink-2');
  ctx.font = '11px ' + cssVar('--font-ui') + ', sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('ENTRANCE', (l.x + r.x) / 2, l.y + 16);

  // Find user's dot in meter coords (relative to venue center)
  let dotDx = 0, dotDy = 0, hasDot = false;
  if (state.inside && state.venueLatLon) {
    const [vlat, vlon] = state.venueLatLon;
    dotDx = (state.dotLatLon[1] - vlon) * m_per_deg_lon(vlat);
    dotDy = (state.dotLatLon[0] - vlat) * M_PER_DEG_LAT;
    hasDot = true;
  }

  // --- distance lines from user dot to every section (drawn BEFORE sections) ---
  if (hasDot) {
    for (const s of SECTION_LAYOUT) {
      const isCurrent = s.id === state.currentZoneId;
      const isHover   = s.id === state.hoverSectionId;
      const from = m2px(dotDx, dotDy);
      const to   = m2px(s.dx, s.dy);
      const dx_m = s.dx - dotDx, dy_m = s.dy - dotDy;
      const dist_m = Math.sqrt(dx_m*dx_m + dy_m*dy_m);

      ctx.save();
      ctx.strokeStyle = cssVar(s.color);
      ctx.globalAlpha = isCurrent ? 0.75 : (isHover ? 0.7 : 0.22);
      ctx.lineWidth   = isCurrent ? 2 : (isHover ? 1.8 : 1);
      ctx.setLineDash(isCurrent ? [] : [5, 4]);
      ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke();
      ctx.restore();

      // Distance label at midpoint — only when bold-ish
      if (isCurrent || isHover) {
        const mx = (from.x + to.x) / 2, my = (from.y + to.y) / 2;
        const label = `${Math.round(dist_m)} m`;
        ctx.font = '500 11px ' + cssVar('--font-mono') + ', monospace';
        ctx.textAlign = 'center';
        const tw = ctx.measureText(label).width + 10;
        ctx.fillStyle = 'rgba(251,247,236,0.95)';
        ctx.fillRect(mx - tw/2, my - 8, tw, 16);
        ctx.strokeStyle = cssVar(s.color);
        ctx.lineWidth = 1;
        ctx.strokeRect(mx - tw/2, my - 8, tw, 16);
        ctx.fillStyle = cssVar('--ink');
        ctx.fillText(label, mx, my + 3);
      }
    }
  }

  // --- section zones ---
  for (const s of SECTION_LAYOUT) {
    const c = m2px(s.dx, s.dy);
    const r = s.r * t.pxPerM;
    const isCurrent = s.id === state.currentZoneId;
    const isHover   = s.id === state.hoverSectionId;
    const color = cssVar(s.color);

    ctx.fillStyle = color;
    ctx.globalAlpha = isCurrent ? 0.32 : (isHover ? 0.22 : 0.12);
    ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI * 2); ctx.fill();
    ctx.globalAlpha = 1;

    ctx.strokeStyle = color;
    ctx.lineWidth = isCurrent ? 2.5 : (isHover ? 2 : 1);
    ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI * 2); ctx.stroke();

    // Label stacked: main label, topic below
    ctx.fillStyle = isCurrent ? cssVar('--ink') : cssVar('--ink-2');
    ctx.font = (isCurrent ? '600 ' : '500 ') + '13px ' + cssVar('--font-serif') + ', serif';
    ctx.textAlign = 'center';
    ctx.fillText(s.label, c.x, c.y - 2);
    ctx.fillStyle = cssVar('--muted');
    ctx.font = '10px ' + cssVar('--font-ui') + ', sans-serif';
    ctx.fillText(s.topic, c.x, c.y + 12);
  }

  // --- user's blue dot on top ---
  if (hasDot) {
    const p = m2px(dotDx, dotDy);
    const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 22);
    grad.addColorStop(0, 'rgba(185,84,58,0.45)');
    grad.addColorStop(1, 'rgba(185,84,58,0)');
    ctx.fillStyle = grad;
    ctx.beginPath(); ctx.arc(p.x, p.y, 22, 0, Math.PI * 2); ctx.fill();

    ctx.fillStyle = cssVar('--you');
    ctx.beginPath(); ctx.arc(p.x, p.y, 9, 0, Math.PI * 2); ctx.fill();
    ctx.strokeStyle = cssVar('--card');
    ctx.lineWidth = 3;
    ctx.stroke();
  }
}

// ---- canvas pointer ----
let dragging = false;
function canvasPosFromEvent(ev) {
  const rect = canvas.getBoundingClientRect();
  return { x: ev.clientX - rect.left, y: ev.clientY - rect.top };
}
canvas.addEventListener('mousedown', ev => { dragging = true; canvasMove(ev); });
canvas.addEventListener('mousemove', ev => { if (dragging) canvasMove(ev); });
window.addEventListener('mouseup',   () => { dragging = false; });
canvas.addEventListener('touchstart', ev => { ev.preventDefault(); dragging = true; canvasMove(ev.touches[0]); }, { passive: false });
canvas.addEventListener('touchmove',  ev => { ev.preventDefault(); if (dragging) canvasMove(ev.touches[0]); }, { passive: false });
canvas.addEventListener('touchend',   () => { dragging = false; });

let canvasDebounce;
function canvasMove(ev) {
  if (!state.inside || !state.venueLatLon) return;
  const p = canvasPosFromEvent(ev);
  const { dx, dy } = px2m(p.x, p.y);
  // Walking outside the box? Trigger Leave.
  if (Math.abs(dx) > 26 || Math.abs(dy) > 30) { leaveVenue(true); return; }
  const [vlat, vlon] = state.venueLatLon;
  const lat = vlat + (dy / M_PER_DEG_LAT);
  const lon = vlon + (dx / m_per_deg_lon(vlat));
  state.dotLatLon = [lat, lon];
  you.setLatLng([lat, lon]);
  drawFloorPlan();
  clearTimeout(canvasDebounce);
  canvasDebounce = setTimeout(checkin, 100);
}

// ============================================================
// VENUE TRANSITION
// ============================================================
function enterVenue(venueObj) {
  state.inside = true;
  state.venue = venueObj;
  state.venueLatLon = [venueObj.lat, venueObj.lon];
  viewEl.classList.add('inside');
  // Canvas dimensions are now correct — layout has run since the .inside class
  // flipped the opacity. Call twice to catch any lingering race.
  requestAnimationFrame(() => {
    sizeCanvas();
    requestAnimationFrame(() => { sizeCanvas(); drawFloorPlan(); });
  });
  stageBanner.classList.remove('hidden');
  stageDot.className = 'dot in';
  stageTitle.innerHTML = `<b>${esc(venueObj.metadata?.name || venueObj.id)}</b>`;
  stageHint.textContent = `Drag anywhere on the floor plan to walk around — each circle is a section.`;
  stageExit.style.display = '';
  legendHint.textContent = 'Inside venue — drag to walk between sections';
}

function leaveVenue(teleport = false) {
  const vll = state.venueLatLon;
  state.inside = false;
  state.venue = null;
  state.venueLatLon = null;
  state.currentZoneId = null;
  viewEl.classList.remove('inside');
  stageBanner.classList.add('hidden');
  stageExit.style.display = 'none';
  legendHint.textContent = 'Drop onto a library to enter';

  if (teleport && vll) {
    // Teleport 220m south to escape the ~150m false-positive upward-paint halo,
    // and set a cooldown so checkin() doesn't immediately pull us back in.
    const [vlat, vlon] = vll;
    const lat = vlat - 0.002;
    state.dotLatLon = [lat, vlon];
    you.setLatLng([lat, vlon]);
    map.setView([lat, vlon], 17);
    state.coolingDown = true;
    setTimeout(() => { state.coolingDown = false; }, 3500);
  }
  // Still fire checkin to refresh the "nearest" panel:
  checkin();
}

// ============================================================
// API
// ============================================================
async function apiCall(path, tag = '') {
  const started = performance.now();
  statusEl.textContent = 'loading…';
  let ok = false, body = null;
  try {
    const r = await fetch(path);
    body = await r.json();
    ok = r.ok;
  } catch (e) {
    body = { error: e.message };
  }
  const ms = Math.round(performance.now() - started);
  statusEl.textContent = `ready · ${ms} ms`;
  if (tag !== 'preview') logCall(path, ms, ok, body);
  return body;
}

function logCall(path, ms, ok, body) {
  const el = document.createElement('div');
  el.className = 'call';
  el.innerHTML = `
    <div class="call-head">
      <div class="meth"><span class="badge">GET</span><span class="path">${path}</span></div>
      <span class="ms" style="color: ${ok ? 'var(--accent)' : 'var(--accent-2)'}">${ms} ms</span>
    </div>
    <pre class="call-body">${JSON.stringify(body, null, 2)}</pre>
  `;
  el.querySelector('.call-head').addEventListener('click', () => el.classList.toggle('open'));
  const first = callsEl.querySelector('.call');
  if (first) callsEl.insertBefore(el, first);
  else callsEl.appendChild(el);
  while (callsEl.querySelectorAll('.call').length > 6) {
    callsEl.querySelectorAll('.call')[callsEl.querySelectorAll('.call').length - 1].remove();
  }
}

function updateStatus(result) {
  const inEl = document.getElementById('inStore');
  inEl.textContent = result.inStore ? 'yes' : 'no';
  inEl.className = 'status-value ' + (result.inStore ? 'ok' : 'miss');
  document.getElementById('zone').textContent = result.zone || '—';
  document.getElementById('venueName').textContent =
    result.store ? (result.store.name + (result.store.address ? ' · ' + result.store.address : '')) : '—';
  document.getElementById('nearest').textContent =
    result.nearestStore ? `${result.nearestStore.name} · ${result.nearestStore.distanceText}` : '—';
}

function esc(s) { return (s || '').replace(/[&<>]/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[m])); }

function updateReading(ctxResp) {
  const el = document.getElementById('reading');
  const hero = ctxResp && ctxResp.content && ctxResp.content.hero;
  const books = (ctxResp && ctxResp.content && ctxResp.content.readingList) || [];
  if (!hero && books.length === 0) {
    el.innerHTML = '<div class="reading-empty">Stand inside a section to see its reading list.</div>';
    return;
  }
  const zone = ctxResp.zone || 'section';
  const list = books.length === 0 ? '' : `
    <ul class="reading-list">
      ${books.map(b => `
        <li data-section="${esc(zone)}">
          <div>
            <div class="book-title">${esc(b.title)}</div>
            <div class="book-author">${esc(b.author)}</div>
          </div>
          <div class="book-call">${esc(b.callNumber)}</div>
        </li>
      `).join('')}
    </ul>
  `;
  const meta = hero?.couponCode ? `<div class="reading-meta">list id · ${esc(hero.couponCode)}</div>` : '';
  el.innerHTML = `
    <span class="reading-zone-tag">${esc(zone)}</span>
    <div class="reading-hero">${hero ? esc(hero.title) : ''}</div>
    ${list}
    ${meta}
  `;
  // Hovering a book highlights that book's section on the floor plan
  el.querySelectorAll('.reading-list li').forEach(li => {
    li.addEventListener('mouseenter', () => { state.hoverSectionId = li.dataset.section; drawFloorPlan(); });
    li.addEventListener('mouseleave', () => { state.hoverSectionId = null; drawFloorPlan(); });
  });
}

async function checkin() {
  const [lat, lng] = state.dotLatLon;
  const resp = await apiCall(`/api/v1/checkin?lat=${lat.toFixed(6)}&lon=${lng.toFixed(6)}&deviceId=${DEVICE_ID}`);
  if (!resp) return;
  updateStatus(resp);

  if (resp.inStore && resp.store?.storeId && !state.coolingDown) {
    if (!state.inside || (state.venue && state.venue.id !== resp.store.storeId)) {
      const venueResp = await apiCall(`/query/store/${resp.store.storeId}`, 'preview');
      if (venueResp && venueResp.lat) enterVenue(venueResp);
    }
    const ctx = await apiCall(`/api/v1/stores/${resp.store.storeId}/context?lat=${lat.toFixed(6)}&lon=${lng.toFixed(6)}`);
    updateReading(ctx);
    state.currentZoneId = resp.zone || null;
    if (state.inside) drawFloorPlan();
  } else {
    if (state.inside) leaveVenue(false);
    updateReading(null);
    state.currentZoneId = null;
  }
}

// ============================================================
// MAP + HEADER CONTROLS
// ============================================================
let mapDebounce;
you.on('drag', () => {
  const ll = you.getLatLng();
  state.dotLatLon = [ll.lat, ll.lng];
  clearTimeout(mapDebounce);
  mapDebounce = setTimeout(checkin, 140);
});
map.on('moveend', loadLibrariesInView);

document.getElementById('citySelect').addEventListener('change', ev => {
  const v = ev.target.value;
  if (!v) return;
  const [lat, lon] = v.split(',').map(Number);
  map.setView([lat, lon], 15);
  state.dotLatLon = [lat, lon];
  you.setLatLng([lat, lon]);
  ev.target.value = '';
  checkin();
});

document.getElementById('locateBtn').addEventListener('click', () => {
  if (!navigator.geolocation) { alert('Geolocation not available in this browser.'); return; }
  statusEl.textContent = 'requesting location…';
  navigator.geolocation.getCurrentPosition(pos => {
    const lat = pos.coords.latitude, lon = pos.coords.longitude;
    map.setView([lat, lon], 15);
    state.dotLatLon = [lat, lon];
    you.setLatLng([lat, lon]);
    checkin();
  }, err => { statusEl.textContent = 'location denied'; }, { enableHighAccuracy: true, timeout: 10000 });
});

window.addEventListener('resize', () => {
  if (state.inside) { sizeCanvas(); drawFloorPlan(); }
});

// ============================================================
// BOOT
// ============================================================
(async () => {
  // Preload library markers in the Central Park area (default start) so the
  // exterior map has visible pins to drag onto. Do NOT auto-enter on boot —
  // show the exterior map and wait for the user.
  await loadLibrariesInView();
  // Kick one checkin to populate the "nearest" field without triggering entry
  // (position is far from any library).
  await checkin();
})();
</script>
</body>
</html>
