PageSourceSearch

https://www.cambsnews.co.uk/360/index.html

html cambsnews.co.uk collected 2026-10-02 13:21:24 UTC 307,155 bytes, 5,462 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>Explore Peterborough — 360° tours, photos and video</title>
7<link rel="icon" href="favicon.php">
8<meta name="description" content="An interactive map of Peterborough's landmarks, streets and river — in 360°, photos and video.">
9
10<!--
11  Deliberately nameless.
12
13  This file is the shared engine: deploy.sh copies it unchanged to all three
14  sites, so anything written here that names one of them is wrong on the other
15  two — which is exactly what happened, and why links shared from Discover
16  Peterborough previewed as Peterborough Interactive. The script below rewrites
17  these from brand.php on load, which fixes the browser tab and fixes nothing
18  for a crawler: Facebook, WhatsApp and Slack read this HTML and never run it.
19
20  Shared links therefore point at share.php, which is PHP, knows which site it
21  is on and what was shared, and writes the real tags — title, words and
22  picture. These are the fallback for somebody linking the map itself.
23-->
24<meta property="og:title" content="Explore Peterborough — 360° tours, photos and video">
25<meta property="og:description" content="An interactive map of Peterborough's landmarks, streets and river — in 360°, photos and video.">
26<meta property="og:type" content="website">
27
28<!-- Leaflet (outer map) — served locally from /lib/ (no CDN) -->
29<link rel="stylesheet" href="lib/leaflet/leaflet.css"/>
30<script src="lib/leaflet/leaflet.js"></script>
30
31
32<!-- Leaflet.markercluster — clusters nearby pins, expand on zoom -->
33<link rel="stylesheet" href="lib/leaflet/MarkerCluster.css"/>
34<link rel="stylesheet" href="lib/leaflet/MarkerCluster.Default.css"/>
35<script src="lib/leaflet/leaflet.markercluster.js"></script>
35
36
37<!-- Photo Sphere Viewer 5 -->
38<link rel="stylesheet" href="lib/psv/core/index.min.css"/>
39<link rel="stylesheet" href="lib/psv/markers-plugin/index.min.css"/>
40<link rel="stylesheet" href="lib/psv/plan-plugin/index.min.css"/>
41<link rel="stylesheet" href="lib/psv/video-plugin/index.min.css"/>
42
43<style>
44  :root{
45    --red:#1f8a5b; --red-dark:#16734a;   /* green theme (kept var names to avoid churn) */
46    --green-900:#0a3322;
47    --t360:#1f8a5b; --tphoto:#1f7a8c; --tvideo:#cc7a00;
48    --ink:#13211b; --muted:#5b6b62; --line:#e2eae5; --bg:#f3f8f5;
49    --font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
50    --serif:Georgia,"Times New Roman",serif;
51  }
52  *{box-sizing:border-box}
53  html,body{margin:0;padding:0;height:100%;background:var(--bg);font-family:var(--font);color:var(--ink)}
54
55  /* The document's own scrollbar, hidden.
56
57     The page does still scroll — the site footer sits below the map, and that
58     is deliberate — but a scrollbar running down the side of a full-bleed map
59     reads as a broken layout rather than as an invitation. Scoped to the
60     document: the results rail and the popover keep theirs, because inside a
61     list you genuinely need to know there is more below. */
62  html{scrollbar-width:none;-ms-overflow-style:none}
63  html::-webkit-scrollbar{width:0;height:0;display:none}
64  body{scrollbar-width:none}
65  body::-webkit-scrollbar{width:0;height:0;display:none}
66
67  /* Header */
68  header.cn360-head{background:var(--green-900);color:#fff;padding:14px 24px;display:flex;align-items:center;gap:16px;border-bottom:3px solid var(--red)}
69  header.cn360-head .brand{display:flex;align-items:center;gap:10px;text-decoration:none;color:#fff}
70  header.cn360-head .brand-mark{width:38px;height:38px;background:linear-gradient(135deg,var(--brand-mark2,#2bb277),var(--green-900));border:1px solid rgba(255,255,255,.25);border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-weight:900;font-size:15px;letter-spacing:-.02em}
71  header.cn360-head h1{font-family:var(--serif);font-weight:700;font-size:20px;margin:0;letter-spacing:-.01em}
72  header.cn360-head .tagline{color:#aaa;font-size:12px;margin-left:2px;display:none}
73  @media (min-width:720px){ header.cn360-head .tagline{display:inline} }
74  header.cn360-head .site-nav{margin-left:auto;display:flex;align-items:center;gap:18px;flex-wrap:wrap;justify-content:flex-end}
75  header.cn360-head .crumb{color:#ddd;font-size:13.5px;font-weight:600;text-decoration:none;white-space:nowrap}
76  header.cn360-head .crumb:hover{color:#fff}
77  @media (max-width:880px){ header.cn360-head .site-nav{gap:12px} header.cn360-head .crumb{font-size:12px} }
78
79  /* Layout */
80  main.cn360-main{display:grid;grid-template-columns:1fr 360px;height:calc(100vh - var(--chrome-h,68px));height:calc(100dvh - var(--chrome-h,68px))}
81  @media (max-width:880px){
82    main.cn360-main{grid-template-columns:1fr;grid-template-rows:55vh 45vh;height:calc(100vh - var(--chrome-h,60px));height:calc(100dvh - var(--chrome-h,60px));grid-template-rows:55dvh 45dvh}
83    header.cn360-head{padding:10px 16px}
84    header.cn360-head .brand-mark{width:32px;height:32px;font-size:13px}
85    header.cn360-head h1{font-size:17px}
86    header.cn360-head .crumb{font-size:12px}
87  }
88
89  .cn360-mapcol{position:relative;width:100%;height:100%;display:grid;grid-template-columns:1fr;grid-template-rows:1fr;overflow:hidden}
90  .cn360-mapcol > *{grid-area:1/1}
91
92  #leaflet-map{width:100%;height:100%;background:#dadcdf;z-index:1}
93  #leaflet-map.hidden{display:none}
94
95  /* ---------- Map pins (type-coloured teardrop) ---------- */
96  .leaflet-marker-icon.cn360-pin-wrap{background:none;border:0;text-align:center}
97  .cn360-pin{display:inline-block;position:relative;background:var(--t360);border:3px solid #fff;border-radius:50% 50% 50% 0;width:32px;height:32px;transform:rotate(-45deg);box-shadow:0 3px 10px rgba(0,0,0,.35);cursor:pointer;transition:transform .15s, box-shadow .15s}
98  .cn360-pin:hover{transform:rotate(-45deg) scale(1.1);box-shadow:0 5px 14px rgba(0,0,0,.45)}
99  .cn360-pin.active{transform:rotate(-45deg) scale(1.15);filter:brightness(.85)}
100  .cn360-pin.is-photo{background:var(--tphoto)}
101  .cn360-pin.is-video{background:var(--tvideo)}
102  .cn360-pin::after{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:900;transform:rotate(45deg)}
103  .cn360-pin.is-360::after{content:'360';font-size:9px;letter-spacing:.03em}
104  .cn360-pin.is-photo::after{content:'\25C9';font-size:14px}
105  .cn360-pin.is-video::after{content:'\25B6';font-size:12px}
106  /* Imported-kind pins: distinct colour + an inline icon (counter-rotated) */
107  .cn360-pin.is-story{background:#2c3e50}
108  .cn360-pin.is-event{background:#e67e22}
109  .cn360-pin.is-venue{background:#1f7a8c}
110  .cn360-pin.is-seasonal{background:#8e44ad}
111  .cn360-pin.is-business{background:#b7791f}
112  .cn360-thumbpin.is-business .tp-badge{background:#b7791f}
113  .cn360-pin .pin-ico{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;transform:rotate(45deg);pointer-events:none}
114  .cn360-pin .pin-ico svg{width:17px;height:17px;stroke:#fff;fill:none;stroke-width:2}
115  /* gentle "newsflash" pulse on story pins */
116  .cn360-pin.is-story::before{content:'';position:absolute;inset:-4px;border-radius:50% 50% 50% 0;border:2px solid #2c3e50;opacity:.55;animation:pinPulse 2.4s ease-out infinite;pointer-events:none}
117  @keyframes pinPulse{0%{transform:scale(1);opacity:.55}70%{opacity:0}
117100%{transform:scale(1.9);opacity:0}}
118  @media (prefers-reduced-motion: reduce){ .cn360-pin.is-story::before{animation:none;opacity:0} }
119  /* Admin-only: 360s whose compass bearing hasn't been confirmed yet get an
120     amber dashed halo on the map, so you can see at a glance what's left to do. */
121  body.is-admin .cn360-pin.needs-align::before{content:'';position:absolute;inset:-5px;border-radius:50% 50% 50% 0;border:3px dashed #ffd400;pointer-events:none;animation:none}
122  /* Items show as mini preview cards (thumbnail + type badge + title) instead
123     of pins. --tps scales the whole card (smaller on phones); the transform
124     origin sits at the tail tip so the card stays anchored to its spot. */
125  .cn360-pin.cn360-thumbpin{--tps:1;transform:scale(var(--tps));transform-origin:50% calc(100% + 9px);width:96px;height:88px;border-radius:10px;background:#fff;border:0;box-shadow:0 4px 14px rgba(0,0,0,.35)}
126  .cn360-pin.cn360-thumbpin:hover{transform:scale(calc(var(--tps)*1.07));box-shadow:0 6px 18px rgba(0,0,0,.5)}
127  .cn360-pin.cn360-thumbpin.active{transform:scale(calc(var(--tps)*1.1));filter:none}
128  .cn360-pin.cn360-thumbpin::before,.cn360-pin.cn360-thumbpin::after{content:none;animation:none}
129  @media (max-width:680px){ .cn360-pin.cn360-thumbpin{--tps:.72} }
130  /* one-time "here I am" pulse for deep-linked pins */
131  .cn360-pin.pulse{transform:rotate(-45deg) scale(1.35)}
132  .cn360-pin.cn360-thumbpin.pulse{transform:scale(calc(var(--tps)*1.35))}
133  .cn360-thumbpin .tp-img{position:absolute;inset:4px;border-radius:7px;background:#333 center/cover no-repeat;overflow:hidden}
134  .cn360-thumbpin .tp-img::after{content:'';position:absolute;left:0;right:0;bottom:0;height:58%;background:linear-gradient(180deg,transparent,rgba(0,0,0,.78))}
135  .cn360-thumbpin .tp-badge{position:absolute;top:9px;left:9px;z-index:2;background:var(--t360);color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:99px;letter-spacing:.03em}
136  .cn360-thumbpin.is-photo .tp-badge{background:var(--tphoto)}
137  .cn360-thumbpin.is-video .tp-badge{background:var(--tvideo)}
138  .cn360-thumbpin.is-story .tp-badge{background:#2c3e50}
139  .cn360-thumbpin.is-event .tp-badge{background:#e67e22}
140  .cn360-thumbpin.is-venue .tp-badge{background:#1f7a8c}
141  .cn360-thumbpin.is-seasonal .tp-badge{background:#8e44ad}
142  .cn360-thumbpin .tp-title{position:absolute;left:10px;right:10px;bottom:9px;z-index:2;color:#fff;font-size:10.5px;font-weight:700;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-shadow:0 1px 2px rgba(0,0,0,.8)}
143  .cn360-thumbpin .tp-tail{position:absolute;left:50%;bottom:-8px;transform:translateX(-50%);width:0;height:0;border:8px solid transparent;border-top:9px solid #fff;border-bottom:0}
144  body.is-admin .cn360-pin.cn360-thumbpin.needs-align::before{inset:-4px;border-radius:13px}
145  /* Admin-only: green tick in the viewer title once this pano's arrows are aligned */
146  #psv-panel .psv-cal-tick{color:#2bb277;font-weight:900;text-shadow:0 1px 2px rgba(0,0,0,.6)}
147  /* ---------- In-viewer editor drawer (admin) ---------- */
148  #ed-drawer{position:absolute;top:0;right:0;bottom:0;width:320px;max-width:88vw;background:rgba(16,20,25,.97);color:#e8ecef;z-index:90;transform:translateX(105%);transition:transform .22s ease;display:flex;flex-direction:column;box-shadow:-10px 0 28px rgba(0,0,0,.5);font-family:var(--font);font-size:13px}
149  #ed-drawer.open{transform:none}
150  #ed-drawer .ed-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.08);font-size:14px}
151  #ed-drawer .ed-head button{background:none;border:0;color:#aaa;font-size:16px;cursor:pointer;width:auto;height:auto;padding:2px 6px}
152  #ed-drawer .ed-head button:hover{color:#fff;background:none}
153  #ed-drawer .ed-body{flex:1;overflow-y:auto;padding:4px 16px 16px}
154  #ed-drawer .ed-sec{padding:12px 0;border-bottom:1px solid rgba(255,255,255,.07)}
155  #ed-drawer h4{margin:0 0 10px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#9fb3c8}
156  #ed-drawer label{display:block;margin:0 0 10px;color:#cfd8e0}
157  #ed-drawer input:not([type=checkbox]):not([type=range]),#ed-drawer select{width:100%;margin-top:4px;background:#0c0f13;border:1px solid rgba(255,255,255,.14);color:#fff;border-radius:6px;padding:7px 9px;font-size:13px;font-family:inherit;box-sizing:border-box}
158  #ed-drawer .ed-check{display:flex;align-items:center;gap:8px}
159  #ed-drawer .ed-check input{width:auto;margin:0}
160  #ed-drawer .ed-note{color:#8a97a5;font-size:12.5px;line-height:1.5}
161  #ed-drawer .ed-target{font-weight:700;margin-bottom:8px;font-size:14px}
162  #ed-drawer .ed-icons{display:grid;grid-template-columns:repeat(6,1fr);gap:6px;margin-bottom:10px}
163  #ed-drawer .ed-ic{background:#0c0f13;border:1px solid rgba(255,255,255,.14);border-radius:6px;color:#fff;font-size:15px;height:34px;
163cursor:pointer;display:flex;align-items:center;justify-content:center;width:auto;padding:0;font-family:inherit}
164  #ed-drawer .ed-ic:hover{border-color:#8fc3f0;background:#0c0f13}
165  #ed-drawer .ed-ic.on{border-color:#2bb277;box-shadow:0 0 0 1px #2bb277 inset}
166  #ed-drawer .ed-ic:nth-child(1),#ed-drawer .ed-ic:nth-child(2){font-size:9px;font-weight:900;letter-spacing:.03em}
167  #ed-drawer .ed-logos:empty{display:none}
168  #ed-drawer .ed-logos .ed-ic{font-size:0;letter-spacing:0;font-weight:400}
169  #ed-drawer .ed-logos .ed-ic img{max-width:26px;max-height:26px;display:block;pointer-events:none}
170  #ed-drawer input[type=range]{width:72%;vertical-align:middle;margin-top:6px}
171  #ed-drawer .ed-btn{display:block;width:100%;border:0;border-radius:8px;padding:10px;font-weight:800;font-size:13px;cursor:pointer;font-family:inherit;height:auto}
172  #ed-drawer .ed-btn.primary{background:#2bb277;color:#fff}
173  #ed-drawer .ed-btn.primary:hover{background:#249a66}
174  #ed-drawer .ed-btn.ghost{background:none;border:1px solid rgba(255,255,255,.25);color:#cfd8e0}
175  #ed-drawer .ed-btn.ghost:hover{border-color:#fff;color:#fff;background:none}
176  #ed-drawer .ed-foot{padding:12px 16px;border-top:1px solid rgba(255,255,255,.08)}
177  @media (max-width:680px){ #ed-drawer{width:100%;max-width:100%} }
178  /* Admin-only sidebar alignment state: per-360 tick + group tick box
179     (solid = all 360s in the group aligned, struck = only some). */
180  .cal-tick,.cat-cal{display:none}
181  body.is-admin .cal-tick{display:inline;margin-left:7px;color:#2bb277;font-weight:900}
182  body.is-admin .cat-cal{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:4px;margin:0 8px;font-size:12px;font-weight:900;position:relative;flex-shrink:0}
183  body.is-admin .cat-cal.all{background:#2bb277;color:#fff}
184  body.is-admin .cat-cal.some{border:2px solid #2bb277;color:#2bb277;background:transparent}
185  body.is-admin .cat-cal.some::after{content:'';position:absolute;left:-4px;right:-4px;top:50%;border-top:2px solid #ffd400;transform:rotate(-18deg)}
186  #psv-panel #psv-align.todo{background:rgba(255,212,0,.25);color:#ffd400;border-color:#ffd400}
187  #psv-panel #psv-align.todo:hover{background:#ffd400;color:#1a1a1a}
188
189  /* Labels are hidden by default to avoid clutter where pins overlap; they
190     appear on hover and for the selected pin only. */
191  .cn360-pin-label{display:none;position:absolute;left:50%;top:34px;transform:translateX(-50%);background:#fff;color:#1a1a1a;padding:3px 8px 2px;border-radius:3px;font-size:10px;font-weight:700;line-height:1.2;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.25);pointer-events:none;border:1px solid var(--line);max-width:160px;overflow:hidden;text-overflow:ellipsis;z-index:5}
192  .cn360-pin-label::before{content:'';position:absolute;top:-5px;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top:0;border-bottom:5px solid #fff}
193  .cn360-pin-wrap:hover .cn360-pin-label{display:block}
194  .cn360-pin.active + .cn360-pin-label{display:block}
195
196  /* ---------- Calibration UI (admin-only) ---------- */
197  .calib-prompt{position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2000;background:#ffd400;color:#1a1a1a;padding:10px 18px;border-radius:99px;font-family:var(--font);font-weight:800;font-size:13px;box-shadow:0 4px 14px rgba(0,0,0,.4);display:none;align-items:center;gap:10px;letter-spacing:.02em;max-width:90vw;text-align:center}
198  .calib-prompt.show{display:flex}
199  .calib-prompt button{background:#1a1a1a!important;color:#fff!important;border:0!important;width:24px!important;height:24px!important;border-radius:50%!important;cursor:pointer;font-size:13px;line-height:1;padding:0!important}
200  .calib-modal{position:fixed;inset:0;background:rgba(0,0,0,0.78);z-index:2001;display:none;align-items:center;justify-content:center;padding:14px}
201  .calib-modal.show{display:flex}
202  .calib-card{background:var(--card,#fff);border-radius:10px;padding:22px 22px 18px;max-width:440px;width:100%;box-shadow:0 18px 50px rgba(0,0,0,.6);font-family:var(--font);font-size:14px;line-height:1.5;color:var(--ink);max-height:90%;overflow-y:auto}
203  .calib-card h3{margin:0 0 10px;font-family:var(--serif);font-size:20px;color:var(--red)}
204  .calib-card p{margin:8px 0;color:var(--ink)}
205  .calib-card label{display:block;margin-top:14px;font-weight:700;font-size:12px;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)}
206  .calib-card select{width:100%;padding:10px;font-size:14px;border:1px solid var(--line);border-radius:4px;margin-top:6px;font-family:var(--font);background:var(--card,#fff);color:var(--ink)}
207  .calib-card .actions{display:flex;gap:8px;justify-content:flex-end;margin-top:18px}
208  #psv-panel .calib-card button,
209  .calib-modal .calib-card button{width:auto!important;height:auto!important;border-radius:4px!important;padding:10px 18px!important;font-family:var(--font);font-weight:700;font-size:13px;
209cursor:pointer;letter-spacing:.02em;text-transform:uppercase;border:0!important;background:color-mix(in srgb,var(--ink) 12%,transparent);color:var(--ink);display:inline-flex!important;align-items:center;justify-content:center}
210  #psv-panel .calib-card .btn-primary,
211  .calib-modal .calib-card .btn-primary{background:var(--red)!important;color:#fff!important}
212  #psv-panel .calib-card .btn-primary:hover,
213  .calib-modal .calib-card .btn-primary:hover{background:var(--red-dark)!important}
214  #psv-panel .calib-card .btn-secondary,
215  .calib-modal .calib-card .btn-secondary{background:color-mix(in srgb,var(--ink) 12%,transparent)!important;color:var(--ink)!important}
216  .calib-card .result-box{background:color-mix(in srgb,var(--ink) 8%,transparent);color:var(--ink);border-left:3px solid var(--red);padding:12px 14px;margin-top:14px;border-radius:4px;font-family:Menlo,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
217  .calib-card .ok-line{color:#0f4a30;font-weight:700;margin-top:10px}
218
219  /* Align-arrows (admin) */
220  #psv-panel #psv-align{display:none;background:rgba(43,178,119,.28);color:#7ef0b8;border:1px solid #2bb277;width:auto;padding:0 14px;font-size:11px;letter-spacing:.06em;font-weight:800;text-transform:uppercase;border-radius:99px;height:32px;flex-shrink:0}
221  #psv-panel #psv-align.show{display:inline-flex;align-items:center;justify-content:center}
222  #psv-panel #psv-align:hover{background:#2bb277;color:#fff;border-color:#2bb277}
223  #psv-panel.aligning #psv-viewer{cursor:crosshair}
224  .align-bar{position:absolute;top:56px;left:50%;transform:translateX(-50%);z-index:60;display:none;
225    flex-direction:column;align-items:center;gap:9px;max-width:94%;
226    background:rgba(12,18,16,.9);color:#fff;border:1px solid rgba(255,255,255,.16);border-radius:12px;
227    padding:10px 14px;box-shadow:0 8px 24px rgba(0,0,0,.45)}
228  .align-bar.on{display:flex}
229  .align-bar .align-hint{font-size:12.5px;font-weight:600;color:#dfe9e3;text-align:center}
230  .align-bar .align-controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
231  .align-bar button{flex:0 0 auto;border:0;cursor:pointer;border-radius:99px;font-weight:800;font-size:13px;height:34px;padding:0 16px;white-space:nowrap}
232  .align-bar .align-nudge{width:38px;padding:0;font-size:16px;background:rgba(255,255,255,.16);color:#fff}
233  .align-bar .align-nudge:hover{background:rgba(255,255,255,.28)}
234  .align-bar .align-save{width:42px;padding:0;background:#2bb277;color:#fff;display:inline-flex;align-items:center;justify-content:center}
235  .align-bar .align-save:hover{background:#249a66}
236  .align-bar .align-save svg{width:19px;height:19px;stroke:#fff;fill:none;stroke-width:2;stroke-linejoin:round}
237  .align-bar .align-cancel{width:42px;padding:0;font-size:17px;background:rgba(255,255,255,.16);color:#fff}
238  .align-bar .align-cancel:hover{background:rgba(255,255,255,.3)}
239  #psv-panel #psv-calibrate{display:none;background:rgba(255,212,0,.25);color:#ffd400;border:1px solid #ffd400;width:auto;padding:0 14px;font-size:11px;letter-spacing:.06em;font-weight:800;text-transform:uppercase;border-radius:99px;height:32px;flex-shrink:0}
240  #psv-panel #psv-calibrate.show{display:inline-flex;align-items:center;justify-content:center}
241  #psv-panel #psv-calibrate:hover{background:#ffd400;color:#1a1a1a;border-color:#ffd400}
242  @media (max-width:560px){ #psv-panel #psv-calibrate{padding:0 10px;font-size:14px} }
243
244  /* ---------- In-panorama hotspots ---------- */
245  .psv-hotspot-pin{display:inline-block;background:var(--red);border:3px solid #fff;border-radius:50%;width:42px;height:42px;color:#fff;font-size:10px;font-weight:900;line-height:38px;letter-spacing:.04em;box-shadow:0 4px 14px rgba(0,0,0,.55);position:relative}
246  @keyframes psv-pulse{0%{transform:scale(0.9);opacity:.9}100%{transform:scale(1.7);opacity:0}}
247
248  /* ---------- Discover More: in-pano info points ---------- */
249  /* Deliberately a different shape and colour from the red navigation arrows:
250     round + amber = "read about this", arrow + red = "travel there". */
251  /* The pin stands above the thing and points down at it.
252     A disc centred on the spot hides the spot: put one on a gargoyle and the
253     gargoyle is the one thing in the photograph nobody can see. So it is
254     anchored by the tip of its tail, the way a map pin is, and the body sits
255     clear above. Everything is measured from the top of the box — disc 0-46,
256     tail 44-78 — and dmMarkerConfig declares the box 78 tall, anch
256ored bottom
257     centre, so the tip lands on the stored yaw and pitch. The tail is long
258     enough that the disc clears an object about its own size; shorter and the
259     body still sits on the top of whatever it points at. */
260  .dm-pin{position:relative;width:100%;height:100%;display:flex;align-items:flex-start;justify-content:center;cursor:pointer;
261    --dm-pin:#ffd400;--dm-ink:#1a1a1a}
262  /* The tail hangs off .dm-pin rather than off b, so the hover scale grows the
263     disc without dragging the tip off what it is pointing at. */
264  .dm-pin::after{content:'';position:absolute;left:50%;top:44px;width:0;height:0;margin-left:-7px;
265    border-left:7px solid transparent;border-right:7px solid transparent;border-top:34px solid var(--dm-pin);
266    filter:drop-shadow(0 3px 3px rgba(0,0,0,.45));transition:filter .18s ease}
267  .dm-pin:hover::after,.dm-pin.is-lit::after,.is-lit .dm-pin::after{filter:brightness(1.18) drop-shadow(0 4px 6px rgba(0,0,0,.55))}
268  .dm-pin.is-editing::after{border-top-color:#2bb277}
269  /* Ripples ring the disc, not the whole box, which is now taller than round. */
270  .dm-pin i{position:absolute;top:0;left:50%;width:46px;height:46px;margin-left:-23px;border-radius:50%;background:var(--dm-pin);opacity:.35;animation:dm-ripple 2.4s cubic-bezier(.2,.6,.3,1) infinite}
271  .dm-pin i:nth-child(2){animation-delay:1.2s}
272  .dm-pin b{position:relative;width:46px;height:46px;border-radius:50%;background:var(--dm-pin);border:3px solid #fff;color:var(--dm-ink);
273    font-family:var(--serif,Georgia,serif);font-weight:700;font-size:24px;line-height:40px;text-align:center;
274    display:flex;align-items:center;justify-content:center;
275    box-shadow:0 6px 18px rgba(0,0,0,.5);transition:transform .18s ease,box-shadow .18s ease,filter .18s ease}
276  /* A named icon, drawn rather than typed. Falls back to <em> for the single
277     character these pins used to carry. */
278  .dm-pin b svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
279  .dm-pin b em{font-style:normal}
280  /* Lit: on hover, and while this point's panel is open. Brightness rather
281     than a colour change, so the tone still reads as itself. */
282  .dm-pin:hover b,.dm-pin.is-lit b,.is-lit .dm-pin b,.psv-marker:hover .dm-pin b{transform:scale(1.16);filter:brightness(1.18);box-shadow:0 8px 26px rgba(0,0,0,.6)}
283  .dm-pin.is-editing b{background:#2bb277;color:#fff;border-color:#fff}
284  @keyframes dm-ripple{0%{transform:scale(.75);opacity:.5}70%{opacity:0}100%{transform:scale(2.1);opacity:0}}
285  @media (prefers-reduced-motion:reduce){ .dm-pin i{animation:none;opacity:.28} }
286
287  /* The region drawn around what a point is about.
288
289     Invisible at rest — its fill opacity is a hair above zero rather than zero,
290     because a shape with no fill is not hit-tested, and the area being
291     hoverable is the whole mechanism.
292
293     The class the plugin applies lands on the SVG shape itself, not on a
294     wrapper around it, so these selectors have to match the element directly as
295     well as any child. The values are then set as inline styles by dmHighlight:
296     the marker's own `svgStyle` is written inline too, and a stylesheet rule
297     cannot win against that. All this does is declare the transition. */
298  .dm-region,.dm-region path,.dm-region polygon{cursor:pointer;
299    transition:fill-opacity .3s ease,stroke-opacity .3s ease,stroke-width .3s ease}
300  @media (prefers-reduced-motion:reduce){
301    .dm-region,.dm-region path,.dm-region polygon{transition:none}
302  }
303
304  /* The reading window.
305
306     A centred modal rather than the side drawer it started as. A drawer leaves
307     the panorama live beside it, so the thing you just clicked is still being
308     dragged around while you read about it; the overlay below covers the
309     viewer, which both freezes the 360 — every pointer event lands on the
310     overlay, never on the canvas — and says plainly that this is the one thing
311     to deal with before carrying on. */
312  #dm-panel{position:absolute;inset:0;z-index:70;display:flex;align-items:center;justify-content:center;padding:28px;
313    background:rgba(6,10,16,.66);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
314    opacity:0;visibility:hidden;transition:opacity .3s ease,visibility 0s linear .3s}
315  #dm-panel.open{opacity:1;visibility:visible;transition:opacity .26s ease,visibility 0s}
316
317  /* The window itself. Picture on the left, the reading on the right. */
318  /* `grid-template-rows:minmax(0,1fr)` is not decoration: without it the row
319     keeps its content height, the body grows past the card's max-height, and
320     the footer — the share button — is quietly clipped by the overflow rule
321     below. A grid row does not shrink to its container's max-height on its own. */
322  #dm-panel .dm-card{position:relative;display:grid;grid-template-columns:minmax(0,1.08fr) minmax(0,1fr);
323    grid-template-rows:minmax(0,1fr);
324    width:min(940px,100%);max-height:min(580px,100%);background:var(--card,#fff);color:var(--ink,#1a1a1a);
325    border-radius:18px;overflow:hidden;box-shadow:0 30px 90px rgba(0,0,0,.6),0 2px 0 rgba(255,255,255,.06) inset;
326    transform:translateY(22px) scale(.96);opacity:0;
327    transition:transform .46s cubic-bezier(.22,1,.36,1),opacity .3s ease}
328  #dm-panel.open .dm-card{transform:none;opacity:1}
329  /* Text only: no empty column where a picture would have been. */
330  #dm-panel .dm-card.no-media{grid-template-columns:1fr;width:min(620px,100%)}
331  #dm-panel .dm-card.no-media .dm-media{display:none}
332
333  /* Frosted rather than solid: on a wide window it sits on the white column, on
334     a narrow one it sits on the photograph, and one colour cannot be legible on
335     both. */
336  #dm-panel .dm-close{position:absolute;top:12px;right:12px;z-index:6;width:36px;height:36px;border-radius:50%;border:0;
337    background:rgba(255,255,255,.88);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
338    color:#1a1a1a;font-size:15px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;
339    box-shadow:0 2px 12px rgba(0,0,0,.28);transition:background .15s,color .15s,transform .2s}
340  #dm-panel .dm-close:hover{background:var(--red,#c8102e);color:#fff;transform:rotate(90deg)}
341
342  #dm-panel .dm-media{position:relative;background:#000;line-height:0;min-height:260px;overflow:hidden}
343  #dm-panel .dm-media img{width:100%;height:100%;object-fit:cover;display:block;
344    animation:dm-media-in .9s cubic-bezier(.22,1,.36,1) both}
345  #dm-panel .dm-media .dm-embed{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
346  #dm-panel .dm-media .dm-embed iframe{width:100%;height:100%;border:0}
347
348  #dm-panel .dm-body{display:flex;flex-direction:column;min-width:0;min-height:0}
349  #dm-panel .dm-scroll{flex:1;overflow-y:auto;padding:30px 30px 26px;-webkit-overflow-scrolling:touch}
350  /* The right-hand padding keeps the heading out from under the close button. */
351  #dm-panel .dm-kicker{font-size:11px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--red,#c8102e);margin:0 0 8px;padding-right:38px}
352  #dm-panel h3{font-family:var(--serif,Georgia,serif);font-size:27px;line-height:1.18;margin:0 0 14px;font-weight:700;padding-right:38px}
353  #dm-panel .dm-text{font-size:15px;line-height:1.62;margin:0;white-space:pre-wrap;word-wrap:break-word}
354  #dm-panel .dm-links{display:flex;flex-direction:column;gap:8px;margin-top:22px}
355  #dm-panel .dm-links a{display:flex;align-items:center;justify-content:space-between;gap:10px;text-decoration:none;
356    background:var(--red,#c8102e);color:#fff;font-weight:700;font-size:14px;padding:12px 16px;border-radius:10px;transition:transform .14s,filter .14s}
357  #dm-panel .dm-links a:hover{transform:translateY(-1px);filter:brightness(1.12)}
358  #dm-panel .dm-links a span{opacity:.8}
359  #dm-panel .dm-foot{border-top:1px solid var(--line,#e3e3e3);padding:12px 18px;display:flex;gap:8px;align-items:center;flex-shrink:0}
360  #dm-panel .dm-foot button{flex:1;border:1px solid var(--line,#e3e3e3);background:transparent;color:inherit;font:inherit;font-weight:700;font-size:13px;
361    padding:10px;border-radius:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:7px;transition:background .15s,color .15s}
362  #dm-panel .dm-foot button:hover{background:var(--red,#c8102e);color:#fff;border-color:var(--red,#c8102e)}
363  #dm-panel .dm-foot button svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
364  #dm-panel .dm-count{font-size:12px;color:var(--muted,#666);flex:0 0 auto;padding:0 4px}
365
366  /* The reading arrives a beat after the window, which is what makes it feel
367     like something opening rather than something appearing. */
368  #dm-panel.open .dm-kicker{animation:dm-rise .5s cubic-bezier(.22,1,.36,1) .1s both}
369  #dm-panel.open h3{animation:dm-rise .5s cubic-bezier(.22,1,.36,1) .16s both}
370  #dm-panel.open .dm-text{animation:dm-rise .5s cubic-bezier(.22,1,.36,1) .22s both}
371  #dm-panel.open .dm-links{animation:dm-rise .5s cubic-bezier(.22,1,.36,1) .28s both}
372  @keyframes dm-rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
373  @keyframes dm-media-in{from{opacity:0;transform:scale(1.06)}to{opacity:1;transform:none}}
374
375  @media (max-width:760px){
376    #dm-panel{padding:14px;align-items:flex-end}
377    #dm-panel .dm-card{grid-template-columns:1fr;grid-template-rows:auto minmax(0,1fr);width:100%;max-height:88%;
378      transform:translateY(40px);border-radius:16px}
379    #dm-panel .dm-card.no-media{width:100%}
380    #dm-panel .dm-media{min-height:0;height:190px}
381    #dm-panel .dm-media .dm-embed{position:relative;inset:auto;height:190px}
382    #dm-panel .dm-scroll{padding:22px 20px 20px}
383    #dm-panel h3{font-size:23px}
384  }
385
386  @media (prefers-reduced-motion:reduce){
387    #dm-panel .dm-card{transform:none;transition:opacity .2s ease}
388    #dm-panel.open .dm-kicker,#dm-panel.open h3,#dm-panel.open .dm-text,#dm-panel.open .dm-links,
389    #dm-panel .dm-media img{animation:none}
390    #dm-panel .dm-close:hover{transform:none}
391  }
392
393  /* Admin: the Discover More editing form */
394  #dm-editor{position:absolute;top:0;right:0;bottom:0;width:min(430px,100%);z-index:80;background:var(--card,#fff);color:var(--ink,#1a1a1a);
395    box-shadow:-14px 0 40px rgba(0,0,0,.5);display:none;flex-direction:column}
396  #dm-editor.open{display:flex}
397  #dm-editor .dm-ed-head{display:flex;align-items:center;gap:8px;padding:13px 16px;border-bottom:1px solid var(--line,#e3e3e3);flex-shrink:0}
398  #dm-editor .dm-ed-head strong{flex:1;font-size:14px}
399  #dm-editor .dm-ed-head button{background:transparent;border:0;font-size:16px;cursor:pointer;color:inherit;width:30px;height:30px;border-radius:50%}
400  #dm-editor .dm-ed-head button:hover{background:rgba(0,0,0,.08)}
401  #dm-editor .dm-ed-body{flex:1;overflow-y:auto;padding:14px 16px}
402  #dm-editor label{display:block;font-size:12px;font-weight:700;margin:0 0 11px}
403  #dm-editor input[type=text],#dm-editor input[type=url],#dm-editor textarea,#dm-editor select{
404    width:100%;box-sizing:border-box;margin-top:4px;font:inherit;font-size:13px;font-weight:400;padding:9px 10px;
405    border:1px solid var(--line,#ccc);border-radius:7px;background:var(--bg,#fff);color:inherit}
406  #dm-editor textarea{min-height:110px;resize:vertical;line-height:1.5}
407  #dm-editor .dm-ed-note{font-size:11.5px;color:var(--muted,#666);line-height:1.5;margin:-6px 0 12px}
408  #dm-editor .dm-ed-row{display:flex;gap:8px}
409  #dm-editor .dm-ed-row input{flex:1;min-width:0}
410  #dm-editor .dm-ed-row button{flex-shrink:0;width:38px;border:1px solid var(--line,#ccc);background:transparent;color:inherit;border-radius:7px;cursor:pointer;font-size:15px}
411  #dm-editor .dm-ed-row button:hover{background:var(--red,#c8102e);color:#fff;border-color:var(--red,#c8102e)}
412  #dm-editor .dm-ed-foot{border-top:1px solid var(--line,#e3e3e3);padding:12px 16px;display:flex;gap:8px;flex-shrink:0}
413  #dm-editor .dm-ed-foot button{flex:1;font:inherit;font-weight:800;font-size:13px;padding:11px;border-radius:8px;
413cursor:pointer;border:1px solid var(--line,#ccc);background:transparent;color:inherit}
414  #dm-editor .dm-ed-foot .primary{background:var(--red,#c8102e);color:#fff;border-color:var(--red,#c8102e)}
415  #dm-editor .dm-ed-foot .danger{flex:0 0 auto;width:44px;color:#c8102e}
416  #dm-editor .dm-ed-foot button:disabled{opacity:.5;cursor:default}
417  #dm-editor .dm-thumbs{display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:6px;max-height:190px;overflow-y:auto;
418    border:1px solid var(--line,#e3e3e3);border-radius:8px;padding:6px;margin:0 0 12px}
419  #dm-editor .dm-thumbs img{width:100%;height:56px;object-fit:cover;border-radius:5px;cursor:pointer;border:2px solid transparent;display:block}
420  #dm-editor .dm-thumbs img:hover{border-color:#ffd400}
421  #dm-editor .dm-thumbs img.sel{border-color:var(--red,#c8102e)}
422  /* While picking a spot the form slides out of the way — you can't click a
423     part of the panorama that's hidden behind it. */
424  #dm-editor.placing{transform:translateX(102%);opacity:0;pointer-events:none;transition:transform .3s cubic-bezier(.22,1,.36,1),opacity .2s ease}
425  #dm-place-banner{display:none;position:absolute;top:56px;left:50%;transform:translateX(-50%);z-index:90;
426    background:#ffd400;color:#1a1a1a;font-weight:800;font-size:13px;padding:11px 14px;border-radius:99px;
427    box-shadow:0 6px 20px rgba(0,0,0,.45);align-items:center;gap:10px;max-width:calc(100% - 24px)}
428  #psv-panel.dm-placing #dm-place-banner{display:flex}
429  #dm-place-banner button{background:rgba(0,0,0,.15);border:0;color:#1a1a1a;font:inherit;font-size:12px;font-weight:800;
430    padding:5px 11px;border-radius:99px;cursor:pointer;flex-shrink:0}
431  #dm-place-banner button:hover{background:#1a1a1a;color:#ffd400}
432  @media (max-width:560px){ #dm-place-banner{font-size:12px;padding:9px 11px;top:50px} }
433  #psv-panel.dm-placing #psv-viewer{cursor:crosshair}
434  #psv-dm-add{display:none;align-items:center;justify-content:center;background:#ffd400;color:#1a1a1a;border:0;width:auto;padding:0 12px;height:32px;
435    border-radius:99px;font-size:11px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;flex-shrink:0}
436  #psv-dm-add.show{display:inline-flex}
437  #psv-dm-add:hover{background:#fff}
438
439  /* ---------- Cluster pin ---------- */
440  .leaflet-marker-icon.cn360-cluster-wrap{background:none;border:0}
441  .cn360-cluster{background:var(--red);color:#fff;border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-weight:700;letter-spacing:-.02em;box-shadow:0 3px 14px rgba(0,0,0,.4);cursor:pointer;transition:transform .12s;position:relative}
442  .cn360-cluster:hover{transform:scale(1.08)}
443  .cn360-cluster.small {width:38px;height:38px;font-size:14px}
444  .cn360-cluster.medium{width:46px;height:46px;font-size:17px;background:linear-gradient(135deg,var(--red) 0%,var(--red-dark) 100%)}
445  .cn360-cluster.large {width:54px;height:54px;font-size:20px;background:linear-gradient(135deg,var(--brand-mark2,#2bb277) 0%,var(--green-900) 100%)}
446
447  /* Leaflet popup — themeable so it isn't light-on-light in dark mode */
448  .leaflet-popup-content-wrapper{border-radius:6px;padding:0;overflow:hidden;background:var(--card,#fff);color:var(--ink)}
449  .leaflet-popup-tip{background:var(--card,#fff)}
450  .leaflet-popup-close-button{color:var(--muted)!important}
451  .leaflet-popup-content{margin:0;width:240px!important}
452  /* District overlay popup + tooltip */
453  .district-pop{padding:12px 14px}
454  .district-pop h3{margin:0 0 6px;font-family:var(--serif);font-size:17px;line-height:1.2}
455  .district-pop p{margin:0 0 10px;font-size:13px;line-height:1.5;color:var(--ink)}
456  .district-more{display:inline-block;font-weight:800;font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--brand,#1f8a5b);text-decoration:none}
457  .leaflet-tooltip.district-tip{background:rgba(20,20,20,.88);color:#fff;border:0;font-weight:700;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.4)}
458  .leaflet-tooltip.district-tip::before{border-top-color:rgba(20,20,20,.88)}
459  /* District info modal */
460  .dist-modal{position:fixed;inset:0;z-index:4200;display:flex;align-items:center;justify-content:center;padding:16px;
461    background:rgba(0,0,0,0);opacity:0;visibility:hidden;pointer-events:none;
462    transition:opacity .28s ease,visibility .28s linear,background .3s ease}
463  .dist-modal.open{opacity:1;visibility:visible;pointer-events:auto;background:rgba(0,0,0,.55)}
464  @supports (backdrop-filter:blur(2px)){.dist-modal.open{backdrop-filter:blur(3px)}}
465  .dist-card{position:relative;background:var(--card,#fff);color:var(--ink);border-radius:16px;max-width:440px;width:100%;max-height:92%;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 22px 60px rgba(0,0,0,.55);
466    transform:translateY(14px) scale(.94);opacity:0;will-change:transform,opacity;
467    transition:transform .36s cubic-bezier(.2,.85,.25,1.15),opacity .24s ease}
468  .dist-modal.open .dist-card{transform:none;opacity:1}
469  .dist-grip{display:none}
470  @media (prefers-reduced-motion:reduce){.dist-card,.dist-modal.open .dist-card{transform:none;transition:opacity .2s}}
471  @media (max-width:640px){
472    .dist-modal{align-items:flex-end;padding:0}
473    .dist-card{max-width:none;border-radius:20px 20px 0 0;max-height:90%;transform:translateY(100%);opacity:1;
474      transition:transform .42s cubic-bezier(.2,.85,.25,1)}
475    .dist-modal.open .dist-card{transform:translateY(0)}
476    .dist-card.dragging{transition:none}
477    .dist-grip{display:block;position:absolute;top:9px;left:50%;transform:translateX(-50%);width:42px;height:4px;border-radius:99px;background:rgba(255,255,255,.75);z-index:5}
478    .dist-hero{height:172px}
479    .dist-hero-text h3{font-size:25px}
480  }
481  .dist-hero{position:relative;height:208px;overflow:hidden;background:#2a2a2a;flex:0 0 auto}
482  .dist-hero-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transform:scale(1.05);animation:distKen 18s ease-in-out infinite alternate}
483  .dist-hero.nopic .dist-hero-img{display:none}
484  .dist-hero-scrim{position:absolute;inset:0}
485  .dist-hero-text{position:absolute;
485left:0;right:0;bottom:0;padding:18px 22px;color:#fff;text-shadow:0 1px 4px rgba(0,0,0,.5)}
486  .dist-kicker{display:inline-block;font-size:11px;font-weight:800;letter-spacing:.16em;text-transform:uppercase;opacity:.92;margin-bottom:7px}
487  .dist-hero-text h3{font-family:var(--serif);font-weight:700;font-size:28px;line-height:1.06;margin:0 0 6px;letter-spacing:-.015em}
488  .dist-tag{margin:0;font-size:14.5px;line-height:1.45;opacity:.96;max-width:36ch}
489  .dist-close{position:absolute;top:12px;right:12px;width:34px;height:34px;border-radius:50%;border:0;cursor:pointer;background:rgba(0,0,0,.4);color:#fff;font-size:15px;line-height:1;transition:background .15s,transform .15s;z-index:4}
490  @supports (backdrop-filter:blur(4px)){.dist-close{backdrop-filter:blur(4px)}}
491  .dist-close:hover{background:rgba(0,0,0,.62);transform:scale(1.08)}
492  .dist-body{padding:20px 24px 24px;overflow-y:auto}
493  .dist-intro{margin:0 0 16px;font-size:15px;line-height:1.62;color:var(--ink)}
494  .dist-hl-title{font-size:11px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);margin:0 0 9px}
495  .dist-hl{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 20px}
496  .dist-chip{font-size:13px;font-weight:600;padding:6px 13px;border-radius:99px;background:color-mix(in srgb,var(--ink) 7%,transparent);border:0.5px solid color-mix(in srgb,var(--ink) 14%,transparent);color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;gap:5px;transition:background .14s,border-color .14s,transform .14s}
497  .dist-chip.lnk{cursor:pointer}
498  .dist-chip.lnk:hover{background:color-mix(in srgb,var(--ink) 15%,transparent);border-color:color-mix(in srgb,var(--ink) 30%,transparent);transform:translateY(-1px)}
499  .dist-chip .ch-arrow{font-size:10px;opacity:.55}
500  .dist-cta{display:inline-flex;align-items:center;gap:8px;color:#fff;text-decoration:none;font-weight:800;font-size:14px;letter-spacing:.01em;padding:13px 24px;border-radius:99px;box-shadow:0 8px 22px rgba(0,0,0,.16);transition:transform .16s,box-shadow .16s,filter .16s}
501  .dist-cta:hover{transform:translateY(-2px);box-shadow:0 12px 26px rgba(0,0,0,.22);filter:brightness(1.05)}
502  .dist-cta-arrow{transition:transform .18s}
503  .dist-cta:hover .dist-cta-arrow{transform:translateX(4px)}
504  #dist-meta:empty,.dist-intro:empty{display:none}
505  #dist-meta{margin:0 0 18px}
506  .dist-body .open-badge{display:inline-block;font-weight:800;font-size:12px;padding:4px 11px;border-radius:99px}
507  .dist-body .open-badge.open{background:#1f8a5b;color:#fff}
508  .dist-body .open-badge.closed{background:#a11;color:#fff}
509  .info-oh{white-space:pre-line;background:color-mix(in srgb,var(--ink) 7%,transparent);border-radius:10px;padding:10px 14px;font-size:13.5px;line-height:1.6;margin-top:8px}
510  .info-row{font-size:13.5px;color:var(--ink);margin-top:9px;line-height:1.5}
511  .date-pills{display:flex;flex-wrap:wrap;gap:7px;margin-top:8px}
512  .date-pill{display:inline-block;padding:6px 13px;border-radius:99px;background:color-mix(in srgb,var(--brand,#1763b6) 12%,transparent);color:var(--brand,#1763b6);font-size:12.5px;font-weight:700;text-decoration:none;white-space:nowrap}
513  a.date-pill:hover{background:var(--brand,#1763b6);color:#fff}
514  .date-pill.more{background:transparent;border:1px dashed color-mix(in srgb,var(--brand,#1763b6) 45%,transparent)}
515  .info-row strong{font-weight:700}
516  .info-row a{color:var(--brand,#1763b6);text-decoration:none;font-weight:700}
517  .dist-modal.open .dist-intro{animation:distRise .5s both}
518  .dist-modal.open #dist-highlights{animation:distRise .5s .08s both}
519  .dist-modal.open .dist-cta{animation:distRise .5s .16s both}
520  @keyframes distKen{from{transform:scale(1.05)}to{transform:scale(1.16) translate(-1.5%,-2%)}}
521  @keyframes distRise{from{opacity:0;transform:translateY(9px)}to{opacity:1;transform:none}}
522  @media (prefers-reduced-motion:reduce){.dist-hero-img{animation:none;transform:none}.dist-modal.open .dist-intro,.dist-modal.open #dist-highlights,.dist-modal.open .dist-cta{animation:none}}
523  .cn360-popup-img{display:block;width:100%;height:140px;background:#eee center/cover no-repeat;position:relative}
524  .cn360-popup-img .ptype{position:absolute;top:6px;left:6px;background:rgba(0,0,0,.72);color:#fff;font-size:9px;font-weight:800;padding:2px 6px;border-radius:3px;letter-spacing:.06em;text-transform:uppercase}
525  .cn360-popup-body{padding:10px 12px 12px}
526  .cn360-popup-title{font-family:var(--serif);font-weight:700;font-size:14px;line-height:1.25;color:var(--ink);margin-bottom:4px}
527  .cn360-popup-desc{font-size:11px;color:var(--muted);line-height:1.4;margin-bottom:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
528  .cn360-popup-cta{display:inline-block;background:var(--red);color:#fff!important;padding:6px 12px;border-radius:4px;font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;text-decoration:none;cursor:pointer}
529  .cn360-popup-cta:hover{background:var(--red-dark)}
530
531  /* Sidebar list */
532  aside.cn360-list{background:var(--card,#fff);border-left:1px solid var(--line);overflow-y:auto;padding:18px 16px}
533  @media (max-width:880px){ aside.cn360-list{border-left:0;border-top:1px solid var(--line)} }
534  aside.cn360-list h2{font-family:var(--serif);font-size:18px;font-weight:700;margin:0 0 4px;letter-spacing:-.01em}
535  aside.cn360-list .sub{color:var(--muted);font-size:13px;margin-bottom:12px}
536
537  /* About this project (driven by BRAND.about) */
538  #aboutPanel{background:var(--card,#fff);border:1px solid var(--line);border-radius:12px;padding:0 14px;margin:0 0 14px;overflow:hidden}
539  #aboutPanel > summary{list-style:none;cursor:pointer;padding:12px 0;font-weight:800;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:8px}
540  #aboutPanel > summary::-webkit-details-marker{display:none}
541  #aboutPanel > summary::after{content:"▾";margin-left:auto;transition:transform .15s}
542  #aboutPanel[open] > summary::after{transform:rotate(180deg)}
543  #aboutPanel .ab-body{padding:2px 0 14px;font-size:13.5px;line-height:1.55;color:var(--ink)}
544  #aboutPanel .ab-body h3{font-family:var(--serif);font-size:16px;margin:6px 0 8px}
545  #aboutPanel .ab-body h4{font-size:13px;margin:14px 0 4px;color:var(--ink)}
546  #aboutPanel .ab-body p{margin:0 0 10px}
547  #aboutPanel .ab-body .ab-callout{background:color-mix(in srgb,var(--brand,#1f8a5b) 10%,transparent);border-left:3px solid var(--brand,#1f8a5b);border-radius:8px;padding:10px 12px;margin:10px 0}
548  #aboutPanel .ab-body a.ab-more{display:inline-block;margin-top:4px;font-weight:700;color:var(--brand,#1f8a5b);text-decoration:none}
549
550  /* Layers panel (checkbox filters, driven by BRAND.layers / storyTags) */
551  /* Collection picker (import batches) */
552  /* Select all / Clear all. Quiet: they are shortcuts, not the point of
553     the panel, and should not out-shout the layers themselves. */
554  .lp-bulk{display:flex;gap:8px;margin:0 0 8px}
555  .lp-bulk button{flex:1;font-family:inherit;font-size:12px;font-weight:700;letter-spacing:.04em;
556    color:var(--muted);background:transparent;border:1px solid var(--line);border-radius:8px;
557    padding:5px 8px;cursor:pointer}
558  .lp-bulk button:hover{color:var(--ink);border-color:var(--muted)}
559  #layers-panel{background:var(--card,#fff);border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin:0 0 14px}
560  #layers-panel .lp-head{display:flex;align-items:center;gap:8px;font-weight:800;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:0 0 8px}
561  #layers-panel .lp-head svg{width:15px;height:15px;fill:var(--brand,#1f8a5b)}
562  #layers-panel .lp-group + .lp-group{margin-top:10px;padding-top:10px;border-top:1px solid var(--line)}
563  #layers-panel .lp-group h4{margin:0 0 6px;font-size:11px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)}
564  /* Bus routes group */
565  #layers-panel details > summary{cursor:pointer;font-weight:800;font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
566  #layers-panel details[open] > summary{color:var(--brand,#1f8a5b);margin-bottom:6px}
567  #bus-list{max-height:240px;overflow:auto;margin-top:8px}
568  .bus-row{display:flex;align-items:center;gap:7px;padding:3px 0;font-size:12.5px;cursor:pointer}
569  .bus-row .bus-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
570  .bus-chip{display:inline-block;min-width:26px;padding:2px 6px;border-radius:5px;color:#fff;font-weight:800;font-size:11px;text-align:center;border:0}
571  .busfinder{margin:8px 0 10px;padding:8px;border:1px solid var(--line);border-radius:8px}
572  .busfinder h4{margin:0 0 6px}
573  .busfinder input{width:100%;box-sizing:border-box;margin:0 0 6px;padding:6px 8px;border:1px solid var(--line);border-radius:6px;font:inherit;font-size:12.5px}
574  .busfinder button#bus-find{width:100%;padding:6px 0;border:0;border-radius:6px;background:var(--brand,#1763b6);color:#fff;font-weight:700;cursor:pointer}
575  #bus-answer{margin-top:7px;font-size:12.5px;line-height:1.9}
576  #bus-answer a.bus-times{color:var(--accent,#8fc3f0);font-weight:700;text-decoration:underline;text-underline-offset:2px}
577  #bus-answer a.bus-times:hover{color:#fff}
578  .bus-go{cursor:pointer}
579  .bus-combo{cursor:pointer;padding:5px 7px;margin-top:5px;border:1px solid var(--line);border-radius:7px;line-height:1.7}
580  .bus-combo:hover{background:var(--bg,#f3f6fa)}
581  #jp-details summary{cursor:pointer;font-weight:800;font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
582  .jp-modes{display:flex;gap:6px;margin-top:2px}
583  .jp-modes button{flex:1;padding:6px 0;border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:12.5px;font-weight:700;cursor:pointer}
584  .jp-modes button:hover{background:var(--brand,#1763b6);color:#fff}
585  #jp-answer{margin-top:7px;font-size:12.5px;line-height:1.7}
586  #jp-answer a{color:var(--accent,#8fc3f0)}
587  #jp-steps{margin:6px 0 0;padding-left:18px;max-height:190px;overflow:auto;font-size:12px;line-height:1.5}
588  #jp-steps li{margin:3px 0;cursor:pointer}
589  #jp-steps li:hover{text-decoration:underline}
590  #jp-steps li.cur{font-weight:800;color:var(--accent,#8fc3f0)}
591  #jp-start{width:100%;margin-top:7px;padding:8px 0;border:0;border-radius:6px;background:#1f8a3b;color:#fff;font-weight:800;font-size:13px;cursor:pointer}
592  /* ---- full-screen guidance (CarPlay-style) ---- */
593  body.navving{overflow:hidden}
594  /* guidance = the ONLY thing on screen: hide every other body child outright */
595  body.navving > :not(#nav-map-wrap):not(#nav-top):not(#nav-next):not(#nav-bottom){display:none!important}
596  #nav-map-wrap{position:fixed;inset:0;z-index:19000;overflow:hidden;background:#0b0f14}
597  body.navving #leaflet-map{position:absolute;left:-100%;top:-100%;width:300%;height:300%;transform-origin:50% 53%;transition:transform .4s linear;background:#0b0f14}
598  .jp-unit{float:right;border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:11px;font-weight:800;padding:2px 8px;cursor:pointer}
599  #jp-add{width:100%;margin:0 0 6px;padding:5px 0;border:1px dashed var(--line);border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:12px;font-weight:700;cursor:pointer;opacity:.85}
600  #jp-add:hover{opacity:1}
601  .ctx-wrap .leaflet-popup-content-wrapper{background:transparent;box-shadow:none;border-radius:0}
602  .ctx-wrap .leaflet-popup-content{margin:0}
603  .ctx-wrap .leaflet-popup-tip-container{display:none}
604  .ctx-pop{display:flex;flex-direction:column;gap:6px;min-width:170px}
605  .ctx-pop button{box-shadow:0 3px 14px rgba(0,0,0,.45)}
606  .ctx-pop button{padding:8px 10px;border:0;border-radius:7px;background:var(--brand,#1763b6);color:#fff;font:inherit;font-size:13px;font-weight:700;cursor:pointer;text-align:left}
607  .ctx-pop button.alt{background:#555}
608  /* custom picker (iOS datalist is unusable) */
609  .pick-list{max-height:200px;overflow:auto;border:1px solid var(--line);border-radius:8px;margin:-3px 0 7px;background:rgba(22,32,43,.98);-webkit-overflow-scrolling:touch}
610  .pick-item{padding:9px 11px;font-size:13px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);color:#fff}
611  .pick-item:last-child{border-bottom:0}
612  .pick-item:hover,.pick-item:active{background:var(--brand,#1763b6)}
613  #nav-top{position:fixed;top:max(10px,env(safe-area-inset-top));left:10px;right:10px;z-index:19600;background:linear-gradient(180deg,#118a3e,#0d6e31);color:#fff;border-radius:18px;padding:14px 18px;display:flex;align-items:center;gap:16px;box-shadow:0 8px 30px rgba(0,0,0,.45)}
614  #nav-top .nav-a{text-align:center;flex:none;min-width:74px}
615  #nav-top .nav-arrow{font-size:46px;line-height:1;font-weight:900}
616  #nav-top .nav-dist{font-size:22px;font-weight:900;letter-spacing:-.02em;margin-top:2px}
617  #nav-top .nav-road{font-size:18px;font-weight:800;line-height:1.3;flex:1;min-width:0}
618  #nav-next{position:fixed;top:118px;left:10px;z-index:19600;background:rgba(15,25,35,.92);color:#fff;border-radius:12px;padding:8px 14px;font-size:14px;font-weight:700;box-shadow:0 4px 16px rgba(0,0,0,.4)}
619  #nav-next:empty{display:none}
620  #nav-bottom{position:fixed;bottom:max(10px,env(safe-area-inset-bottom));left:10px;right:10px;z-index:19600;background:rgba(15,25,35,.95);color:#fff;border-radius:18px;display:flex;align-items:center;gap:12px;padding:12px 16px;box-shadow:0 8px 30px rgba(0,0,0,.45)}
621  #nav-bottom .nav-eta{line-height:1.15}
622  #nav-bottom .nav-eta b{font-size:20px;font-weight:900}
623  #nav-bottom .nav-eta span{display:block;font-size:12px;opacity:.8}
624  #nav-bottom .nav-fill{flex:1}
625  #nav-bottom button{background:rgba(255,255,255,.14);border:0;color:#fff;font-size:15px;font-weight:800;height:44px;border-radius:12px;cursor:pointer;flex:none;padding:0 14px}
626  #nav-bottom button.off{opacity:.45}
627  #nav-bottom #nav-end{background:#c22e2e}
628  @media (max-width:680px){
629    #nav-top .nav-road{font-size:16px}
630    #nav-top .nav-arrow{font-size:40px}
631  }
632  .bus-pop{font-size:13px;line-height:1.45}
633  #layers-panel label{display:flex;align-items:center;gap:9px;padding:5px 4px;border-radius:8px;font-size:14px;cursor:pointer;color:var(--ink)}
634  #layers-panel label:hover{background:color-mix(in srgb,var(--brand,#1f8a5b) 8%,transparent)}
635  #layers-panel input[type=checkbox]{width:16px;height:16px;accent-color:var(--brand,#1f8a5b);cursor:pointer}
636
637  /* Category → section accordion menu */
638  .cat-group{border-bottom:1px solid var(--line)}
639  .cat-toggle{width:100%;display:flex;align-items:center;gap:8px;background:none;border:0;cursor:pointer;padding:12px 2px;font-family:var(--serif);font-size:16px;font-weight:700;color:var(--ink);text-align:left}
640  .cat-toggle:hover{color:var(--red)}
641  .cat-toggle .chev{display:inline-block;transition:transform .15s;color:var(--red);font-size:12px}
642  .cat-group.open .cat-toggle .chev{transform:rotate(90deg)}
643  .cat-toggle .cat-name{flex:1}
644  .cat-toggle .count{background:color-mix(in srgb,var(--ink) 10%,transparent);color:var(--muted);border-radius:99px;font-family:var(--font);font-size:11px;font-weight:800;padding:2px 9px}
645  .cat-body{display:none;padding:0 0 10px}
646  .cat-group.open .cat-body{display:block}
647  .sec-head{width:100%;text-align:left;background:none;border:0;cursor:pointer;padding:8px 4px 6px;font-size:12px;font-weight:800;letter-spacing:.03em;text-transform:uppercase;color:var(--muted)}
648  .sec-head:hover{color:var(--red)}
649  .sec-group{border-left:2px solid var(--line);margin:2px 0 8px;padding-left:8px}
650
651  /* Compact rows: you scan titles, not essays. Descriptions are clamped to one
652     line and only expand on the item you're on (hover / selected). */
653  .cn360-card{display:grid;grid-template-columns:56px 1fr;gap:10px;padding:6px 0;border-bottom:1px solid var(--line);cursor:pointer;transition:background .12s;align-items:center}
654  .cn360-card:hover{background:color-mix(in srgb,var(--ink) 7%,transparent)}
655  .cn360-card .thumb{width:56px;height:42px;background:#e6e6e6 center/cover no-repeat;border-radius:4px;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center}
656  .cn360-card .thumb .badge{position:absolute;bottom:2px;right:2px;background:rgba(0,0,0,.72);color:#fff;font-size:8px;font-weight:800;padding:1px 4px;border-radius:2px;letter-spacing:.05em}
657  .cn360-card .thumb .play{color:#fff;font-size:18px;text-shadow:0 1px 4px rgba(0,0,0,.6)}
658  .cn360-card .title{font-weight:600;font-size:13px;line-height:1.25;color:var(--ink);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
659  .cn360-card .meta{font-size:11px;color:var(--muted);margin-top:2px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
660  .cn360-card:hover .meta,.cn360-card.active .meta{-webkit-line-clamp:4}
661  .cn360-card.active{background:color-mix(in srgb,var(--red) 13%,transparent)}
662  .cn360-card.active .title{color:var(--red);-webkit-line-clamp:unset}
663
664  .cn360-empty{padding:30px 14px;text-align:center;color:var(--muted);font-size:13px;line-height:1.5}
665  .cn360-empty strong{display:block;font-family:var(--serif);font-size:16px;color:var(--ink);margin-bottom:8px}
666
667  /* Panorama panel (360s) */
668  #psv-panel{display:none;position:relative;width:100%;height:100%;background:#000;z-index:5}
669  /* Fixed crest overlay on the 360 viewer (white-on-black crest; screen blend drops the black) */
670  #psv-panel .psv-crest{position:absolute;top:54px;right:16px;height:60px;width:auto;z-index:25;pointer-events:none;mix-blend-mode:screen;opacity:.95}
671  @media (max-width:560px){ #psv-panel .psv-crest{height:46px;top:50px;right:10px} }
672  #psv-panel.open{display:block}
673  #psv-panel .psv-bar{background:linear-gradient(180deg,rgba(0,0,0,.85) 0%,transparent 100%);color:#fff;padding:10px 12px;display:flex;align-items:center;gap:8px;position:absolute;top:0;left:0;right:0;z-index:10;pointer-events:none}
674  #psv-panel .psv-bar > *{pointer-events:auto}
675  #psv-panel .psv-title{font-family:var(--serif);font-weight:700;font-size:14px;flex:1;text-shadow:0 1px 2px rgba(0,0,0,.6);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
676  #psv-panel .psv-credit{font-size:11px;color:#bbb;display:none}
677  @media (min-width:720px){ #psv-panel .psv-credit{display:inline} }
678  #psv-panel button{background:rgba(0,0,0,.5);color:#fff;border:0;width:32px;height:32px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:15px;transition:background .12s;flex-shrink:0}
679  #psv-panel button:hover{background:var(--red)}
680  #psv-viewer{position:absolute;top:0;left:0;width:100%;height:100%;background:#000}
681
682  #psv-back-to-map{display:none;position:absolute;bottom:60px;left:14px;z-index:60;background:#fff;
682color:#1a1a1a;border:1px solid var(--line);padding:8px 14px;border-radius:99px;font-family:var(--font);font-weight:700;font-size:12px;letter-spacing:.04em;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.25);text-transform:uppercase}
683  #psv-back-to-map:hover{background:var(--red);color:#fff;border-color:var(--red)}
684  @media (max-width:880px){ #psv-back-to-map{display:none!important} }
685
686  /* ---------- Lightbox modal (photos + video) ---------- */
687  .lb-modal{position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:3000;display:none;flex-direction:column;align-items:center;justify-content:center;padding:0}
688  .lb-modal.open{display:flex}
689  .lb-bar{position:absolute;top:calc(var(--x-top,14px) - 14px);left:0;right:0;display:flex;align-items:center;gap:8px;padding:12px 14px;background:linear-gradient(180deg,rgba(0,0,0,.7) 0%,transparent 100%);z-index:5}
690  .lb-bar .lb-title{font-family:var(--serif);color:#fff;font-weight:700;font-size:16px;flex:1;text-shadow:0 1px 3px rgba(0,0,0,.7);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
691  .lb-bar button{background:rgba(0,0,0,.5);color:#fff;border:0;width:38px;height:38px;border-radius:50%;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:background .12s;flex-shrink:0}
692  .lb-bar button:hover{background:var(--red)}
693  .lb-stage{flex:1;width:100%;display:flex;align-items:center;justify-content:center;padding:calc(64px + var(--x-top,14px) - 14px) 16px 16px;min-height:0}
694  .lb-stage img,.lb-stage video{max-width:100%;max-height:100%;border-radius:6px;box-shadow:0 10px 40px rgba(0,0,0,.5);background:#000}
695  .lb-foot{width:100%;max-width:900px;padding:14px 18px 22px;color:#eee;text-align:center}
696  .lb-desc{font-size:14px;line-height:1.55;color:#ddd;margin:0 auto 14px;max-width:760px}
697  .lb-meta{font-size:13.5px;line-height:1.6;color:#eee;margin:0 auto 14px;max-width:760px}
698  .lb-meta .oh{white-space:pre-line;background:rgba(255,255,255,.08);border-radius:8px;padding:10px 14px;display:inline-block;text-align:left;margin-top:6px}
699  .lb-meta a{color:var(--lime,#9ad97a)}
700  .lb-meta .open-badge{display:inline-block;font-weight:800;font-size:12px;padding:3px 10px;border-radius:99px;margin-left:8px}
701  .lb-meta .open-badge.open{background:#1f8a5b;color:#fff}
702  .lb-meta .open-badge.closed{background:#a11;color:#fff}
703  .lb-noimg{display:flex;align-items:center;justify-content:center;text-align:center;padding:40px 24px;font-family:var(--serif);font-size:clamp(20px,4vw,34px);color:#fff;background:linear-gradient(135deg,var(--brand-mark2,#2bb277),var(--green-900));border-radius:10px;min-height:200px}
704  .lb-cat{display:inline-block;font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:#ffd400;margin-bottom:6px}
705  .lb-explore{display:inline-block;background:var(--red);color:#fff;padding:11px 22px;border-radius:99px;font-weight:800;font-size:13px;letter-spacing:.05em;text-transform:uppercase;text-decoration:none;cursor:pointer;border:0}
706  .lb-explore:hover{background:var(--red-dark)}
707  .lb-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,.45);color:#fff;border:0;width:46px;height:46px;border-radius:50%;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:5;transition:background .12s}
708  .lb-nav:hover{background:var(--red)}
709  .lb-prev{left:14px}
710  .lb-next{right:14px}
711  @media (max-width:560px){ .lb-nav{width:38px;height:38px;font-size:20px} .lb-stage{padding:calc(60px + var(--x-top,14px) - 14px) 8px 8px} }
712  #lb-x{position:fixed;right:18px;bottom:18px;z-index:3100;width:56px;height:56px;border-radius:50%;border:3px solid #fff;background:var(--red,#c8102e);color:#fff;font-size:22px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 6px 20px rgba(0,0,0,.5)}
713  #lb-x:hover{background:#fff;color:#111}
714  .lb-credit{font-size:11px;color:#9a9a9a;letter-spacing:.05em;text-transform:uppercase;margin:0 0 12px}
715
716  /* ---------- "Request to use" button + modal ---------- */
717  .req-btn{background:var(--red);color:#fff;border:0;border-radius:99px;padding:10px 18px;font-size:12px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;white-space:nowrap}
718  .req-btn:hover{background:var(--red-dark)}
719  #psv-panel #psv-request{display:inline-flex;align-items:center;justify-content:center;background:var(--red);color:#fff;border:0;width:auto;padding:0 12px;
719height:32px;border-radius:99px;font-size:11px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;flex-shrink:0}
720  #psv-panel #psv-request:hover{background:var(--red-dark)}
721  #psv-panel #psv-explore{display:none;align-items:center;justify-content:center;background:#fff;color:#1a1a1a;border:0;width:auto;padding:0 12px;height:32px;border-radius:99px;font-size:11px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;flex-shrink:0}
722  #psv-panel #psv-explore.show{display:inline-flex}
723  #psv-panel #psv-explore:hover{background:var(--red);color:#fff}
724  #psv-panel #psv-vr{display:none;align-items:center;justify-content:center;background:#9ad97a;color:#0a3322;border:0;width:auto;padding:0 14px;height:32px;border-radius:99px;font-size:11px;font-weight:900;letter-spacing:.06em;flex-shrink:0}
725  #psv-panel #psv-vr.show{display:inline-flex}
726  #psv-panel #psv-vr:hover{background:#b6e89a}
727  .req-modal{position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:4000;display:none;align-items:center;justify-content:center;padding:14px}
728  .share-row{display:flex;gap:8px;align-items:center;margin:12px 0 2px}
729  .share-row input{flex:1;min-width:0;font:inherit;font-size:13px;padding:11px 12px;border:1px solid #ccc;border-radius:8px;background:#f6f6f6;color:#222}
730  #share-copy{flex-shrink:0;width:44px;height:44px;border:0;border-radius:8px;background:var(--red,#c8102e);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center}
731  #share-copy:hover{background:var(--red-dark,#8f0b20)}
732  #share-copy svg{width:19px;height:19px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
733
734  .req-modal.open{display:flex}
735  .req-card{background:var(--card,#fff);color:var(--ink);border-radius:10px;max-width:460px;width:100%;max-height:92%;overflow-y:auto;padding:24px;position:relative;box-shadow:0 18px 50px rgba(0,0,0,.6);font-family:var(--font)}
736  .req-card h3{font-family:var(--serif);color:var(--red);margin:0 0 4px;font-size:21px}
737  .req-card .req-img{color:var(--muted);font-size:13px;margin:0 0 14px;word-break:break-word}
738  .req-card label{display:block;font-weight:700;font-size:13px;margin:12px 0 4px;color:var(--ink)}
739  .req-card input,.req-card textarea{width:100%;padding:11px;font-size:15px;border:1px solid var(--line);border-radius:7px;font-family:var(--font);box-sizing:border-box;background:var(--card,#fff);color:var(--ink)}
740  .req-card textarea{min-height:80px;resize:vertical}
741  .req-card .hp{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}
742  .req-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:18px}
743  .req-actions button{border:0;border-radius:7px;padding:11px 18px;font-weight:800;font-size:14px;cursor:pointer}
744  .req-actions .btn-primary{background:var(--red);color:#fff}
745  .req-actions .btn-primary:hover{background:var(--red-dark)}
746  .req-actions .btn-secondary{background:color-mix(in srgb,var(--ink) 12%,transparent);color:var(--ink)}
747  .req-close{position:absolute;top:12px;right:12px;background:color-mix(in srgb,var(--ink) 12%,transparent);color:var(--ink);border:0;width:32px;height:32px;border-radius:50%;cursor:pointer;font-size:15px}
748  .req-status{margin-top:12px;font-weight:600;font-size:14px}
749  .req-status.ok{color:#0f4a30}
750  .req-status.err{color:var(--red-dark)}
751
752  /* Embedded in an iframe (Divi code module): the WP site provides the
753     header/footer, so ours are hidden and the map takes the full frame. */
754  body.embedded header.cn360-head{display:none}
755  body.embedded main.cn360-main{height:100vh;height:100dvh}
756  body.embedded{--x-top:22px}
757  @media (max-width:880px){ body.embedded{--x-top:14px} }
758
759  /* ==================== EXPLORE UI (brand.explore_ui) ==================== */
760  body.xui main.cn360-main{grid-template-columns:1fr}
761  body.xui aside.cn360-list{display:none}
762  html.xui-pre main.cn360-main{grid-template-columns:1fr}
763  html.xui-pre aside.cn360-list{display:none}
764  #x-col{position:fixed;top:var(--x-top,14px);left:14px;bottom:14px;width:372px;max-width:calc(100% - 28px);z-index:900;display:flex;flex-direction:column;gap:10px;pointer-events:none}
765  #x-col > *{pointer-events:auto}
766  /* site skin: deep navy surfaces, gold accents, squared 2-3px bevels */
767  .x-pill{display:flex;align-items:center;gap:8px;background:rgba(12,33,54,.94);backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.18);border-radius:9px;padding:7px 9px;box-shadow:0 6px 24px rgba(0,0,0,.35)}
768  .x-pill input{flex:1;border:0;outline:0;background:transparent;font:inherit;font-size:14.5px;color:#fff;min-width:0}
769  .x-pill input::placeholder{color:#8fa3b5}
770  .x-dir{width:36px;height:34px;border-radius:6px;background:#f0b32b;color:#0d2438;border:0;font-size:15px;font-weight:900;cursor:pointer;flex:none;box-shadow:0 3px 10px rgba(0,0,0,.3)}
771  .x-dir:hover{filter:brightness(1.08)}
772  .x-chips{display:flex;flex-wrap:wrap;gap:6px;padding:2px}
773  .x-chip{flex:1 1 auto;display:inline-flex;align-items:center;justify-content:center;background:rgba(12,33,54,.92);backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.16);border-radius:3px;padding:7px 10px;font-size:12.5px;font-weight:700;color:#fff;box-shadow:0 3px 12px rgba(0,0,0,.28);cursor:pointer;white-space:nowrap;user-select:none}
774  .x-chip:hover{border-color:rgba(255,255,255,.4)}
775  .x-chip.on{background:#f0b32b;color:#0d2438;border-color:#f0b32b}
776  #x-rail{flex:1;min-height:0;background:rgba(12,33,54,.95);backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.12);border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.35);display:flex;flex-direction:column;overflow:hidden;color:#e8eef4}
777  .x-grab{display:none;width:38px;height:4px;border-radius:2px;background:#3d5470;margin:8px auto 2px;flex:none}
778  .x-rail-h{padding:12px 16px 8px;font-size:11.5px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;color:#f0b32b;display:flex;justify-content:space-between;align-items:center;flex:none}
779  .x-rail-h b{cursor:pointer;color:#f0b32b;font-size:11.5px;letter-spacing:.06em}
780  .x-rail-h b:hover{color:#fff}
781  /* Scrolls, but without a bar down the middle of the panel. The cards are
782     cut off mid-row at the fold, which is the honest signal that there is more
783     below — a chrome scrollbar over a dark translucent panel is not. */
784  #x-results{flex:1;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:none;-ms-overflow-style:none}
785  #x-results::-webkit-scrollbar{width:0;height:0;display:none}
786  #x-pop{scrollbar-width:none;-ms-overflow-style:none}
787  #x-pop::-webkit-scrollbar{width:0;height:0;display:none}
788  .x-card{display:flex;gap:12px;padding:10px 14px;border-top:1px solid rgba(255,255,255,.09);cursor:pointer;align-items:center}
789  .x-card:hover{background:rgba(255,255,255,.06)}
790  .x-card .xim{width:74px;height:56px;border-radius:4px;background-color:#1d3958;background-size:cover;background-position:center;position:relative;flex:none}
791  .x-card .xbadge{position:absolute;bottom:4px;left:4px;background:#f0b32b;color:#0d2438;font-size:8px;font-weight:900;padding:2px 5px;border-radius:2px;letter-spacing:.04em}
792  .x-card h4{font-size:14px;font-weight:700;margin:0;line-height:1.25;color:#fff}
793  .x-card p{font-size:11.5px;color:#9db0c2;margin:2px 0 0}
794  #x-pop{position:fixed;top:var(--x-top,14px);right:14px;bottom:auto;max-height:calc(100vh - var(--x-top,14px) - 14px);width:340px;max-width:calc(100% - 28px);z-index:950;background:rgba(12,33,54,.97);backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,.12);border-radius:10px;box-shadow:0 10px 36px rgba(0,0,0,.45);overflow-y:auto;padding:16px;display:none;color:#e8eef4;
795    --ink:#e8eef4;--muted:#9db0c2;--line:rgba(255,255,255,.16);--card:transparent;--bg:rgba(255,255,255,.08)}
796  #x-pop.open{display:block}
797  /* the 360 viewer owns the screen when open */
798  #psv-panel.open ~ #x-col, #psv-panel.open ~ #x-pop{display:none}
799  /* the navy/gold skin is the same in light and dark themes */
800  #x-pop h3{margin:0 0 10px;font-size:12px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;color:#f0b32b}
801  #x-pop-close{float:right;border:0;background:rgba(255,255,255,.14);width:28px;height:28px;border-radius:4px;font-size:13px;cursor:pointer;color:#fff}
802  body.xui #x-pop #layers-panel{display:block;position:static;background:transparent;box-shadow:none;border:0;padding:0;margin-top:8px}
803  /*
804   * In the Explore panel the collection picker is a row, not a card, and it
805   * has to clear the close button.
806   *
807   * Two separate faults made this look wrong. It kept the classic UI's border,
808   * background and 14px padding, so it read as a boxed control among flush
809   * rows. And it was 28px narrower than every row below it — because
810   * `overflow:hidden` makes it establish a block formatting context, a BFC box
811   * refuses to overlap a float, and #x-pop-close is a 28px button floated
812   * right whose bottom edge sat two pixels below this row's top. Two pixels of
813   * overlap cost the whole 28px of width.
814   *
815   * `clear:both` drops it below the button and it fills the panel like
816   * everything else. Measured: 278.7px before, 306.7px after, which is exactly
817   * the width of the MAP STYLE row.
818   */
819  /*
820   * Breathing room at the top of the panel.
821   *
822   * The first control sat 42px from the panel's edge, most of which was the
823   * heading — so with the heading scrolled or cropped out of view the picker
824   * looked jammed against the top. 30px of padding and a margin above the row
825   * put roughly 63px between the panel edge and the picker, which reads as a
826   * margin rather than an accident.
827   */
828  body.xui #x-pop{padding-top:30px}
829  body.xui #x-pop h3{margin:0 0 16px}
830  #x-pop select,#x-pop input{color-scheme:dark}
831  #x-pop select option{background:#0e2237;color:#e8eef4}
832  #x-pop details[open] > summary{color:#f0b32b}
833  /* floating Map-options button (mobile only) */
834  #x-gear-fab{display:none;position:fixed;right:12px;bottom:calc(122px + env(safe-area-inset-bottom));z-index:1150;width:44px;height:44px;border-radius:10px;border:1px solid rgba(255,255,255,.2);background:rgba(12,33,54,.95);color:#f0b32b;font-size:19px;align-items:center;justify-content:center;box-shadow:0 4px 16px rgba(0,0,0,.45);cursor:pointer}
835  @media (max-width:880px){
836    body.xui main.cn360-main{grid-template-rows:1fr;height:calc(100vh - var(--chrome-h,60px));height:calc(100dvh - var(--chrome-h,60px))}
837    #x-col{top:10px;left:10px;right:10px;width:auto;bottom:auto;max-width:none}
838    .x-pill{padding:6px 8px}
839    .x-dir{width:32px;height:32px;font-size:13px}
840    /* one swipeable chip row — no wrapping on phones */
841    .x-chips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch;padding:2px 22px 4px 2px;
842      -webkit-mask-image:linear-gradient(90deg,#000 90%,transparent);mask-image:linear-gradient(90deg,#000 90%,transparent)}
843    .x-chips::-webkit-scrollbar{display:none}
844    .x-chip{flex:0 0 auto;padding:6px 11px;font-size:12px}
845    /* bottom carousel: one floating card at a time, swipe left/right */
846    #x-rail{position:fixed;left:0;right:0;top:auto;bottom:calc(10px + env(safe-area-inset-bottom));height:auto!important;background:transparent;border:0;box-shadow:none;border-radius:0;overflow:visible;z-index:1100}
847    .x-grab,.x-rail-h{display:none}
848    #x-results{display:flex;flex-direction:row;overflow-x:auto;overflow-y:hidden;scroll-snap-type:x mandatory;gap:10px;padding:4px 26px;scrollbar-width:none}
849    #x-results::-webkit-scrollbar{display:none}
850    .x-card{flex:0 0 calc(100vw - 96px);scroll-snap-align:center;background:rgba(12,33,54,.96);border:1px solid rgba(255,255,255,.16)!important;border-radius:12px;box-shadow:0 6px 22px rgba(0,0,0,.45);padding:10px 12px}
851    #x-gear-fab{display:flex}
852    #x-pop{position:fixed;top:70px;left:10px;right:10px;bottom:auto;max-height:calc(100dvh - 84px);width:auto;z-index:1200}
853    /* embedded on a phone: hoist above Safari's bottom toolbar (safe-area
854       insets don't reach inside iframes) */
855    body.embedded #x-rail{bottom:78px}
856    body.embedded #x-gear-fab{bottom:196px}
857  }
858</style>
859<link rel="stylesheet" href="brand.css.php"><!-- white-label colours from brand.php -->
860<script src="brand.js.php"></script>
860<!-- window.BRAND (name/links) -->
861<script src="theme.js"></script>
861<!-- dark / light theme -->
862<script>/* pre-paint: hide the classic sidebar before first render on Explore sites */
863if (window.BRAND && window.BRAND.exploreUi) document.documentElement.classList.add('xui-pre');
864/* Direct top-level visits to this page bounce to the site's embed page.
865   Skipped inside the iframe (no loop), for API files (separate), and for
866   admins (?admin=… once, or an existing admin token) so editing still works. */
867(function(){
868  var B = window.BRAND || {};
869  if (!B.canonicalPage) return;
870  var inFrame = false; try { inFrame = window.self !== window.top; } catch(e){ inFrame = true; }
871  if (inFrame) return;
872  var isAdmin = /[?&]admin=/.test(location.search);
873  try { isAdmin = isAdmin || !!localStorage.getItem('cn360_admin'); } catch(e){}
874  if (isAdmin) return;
875  location.replace(B.canonicalPage.replace(/\/+$/, '/') +
876    (location.search || '') + (location.hash || ''));
877})();</script>
877
878</head>
879<body>
880
881<header class="cn360-head">
882  <a href="./" class="brand">
883    <span class="brand-mark" id="brandMark">Pi</span>
884    <div>
885      <h1 id="brandName">Peterborough Interactive</h1>
886    </div>
887  </a>
888  <span class="tagline" id="brandTagline">with the Peterborough Civic Society</span>
889  <nav class="site-nav" id="siteNav">
890    <a href="about.php" class="crumb">About</a>
891    <a href="./" class="crumb">Home</a>
892  </nav>
893  <span data-theme-mount style="margin-left:14px;display:inline-flex"></span>
894</header>
895
896<main class="cn360-main">
897  <div class="cn360-mapcol">
898    <div id="leaflet-map"></div>
899    <div id="psv-panel" aria-hidden="true">
900      <div class="psv-bar">
901        <button class="psv-close" id="psv-close" aria-label="Close panorama" title="Close (Esc)">✕</button>
902        <button id="psv-prev"  aria-label="Previous location" title="Previous (←)">‹</button>
903        <button id="psv-next"  aria-label="Next location" title="Next (→)">›</button>
904        <div class="psv-title" id="psv-title"></div>
905        <span class="psv-credit" id="psv-credit"></span>
906        <button id="psv-edit" title="Edit this 360 (admin)" style="display:none">✎</button>
907        <button id="psv-dm-add" title="Add a Discover More point to this panorama">+ Discover More</button>
908        <button id="psv-calibrate" title="Calibrate the panorama's compass bearing">📐 Calibrate</button>
909        <button id="psv-vr" title="View in VR (headset)">⛶ VR</button>
910        <button id="psv-explore" title="Explore (opens in a new tab)">Explore</button>
911        <button id="psv-request" title="Request to use this image">Use this image</button>
912        <button id="psv-fullscreen" aria-label="Toggle fullscreen" title="Fullscreen (F)">⛶</button>
913      </div>
914      <div id="psv-viewer"></div>
915      <img class="psv-crest" src="crest.png" alt="" onerror="this.remove()">
916
917
918      <!-- Hotspot editor (admin): hold and drag an arrow to its true spot, then Save -->
919      <div class="align-bar" id="align-bar">
920        <div class="align-hint">Hold and drag any arrow to its true spot, then Save.</div>
921        <div class="align-controls">
922          <button type="button" class="align-save"   id="align-save" title="Save" aria-label="Save"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3h12l4 4v14H3V3zM8 3v6h8V3M7 13h10v8H7z"/></svg></button>
923          <button type="button" class="align-cancel" id="align-cancel" title="Cancel" aria-label="Cancel">✕</button>
924        </div>
925      </div>
926
927      <!-- In-viewer editor drawer (admin): item settings + selected-arrow styling -->
928      <aside id="ed-drawer" aria-hidden="true">
929        <div class="ed-head"><strong>✎ Editor</strong><button id="ed-close" title="Close editor">✕</button></div>
930        <div class="ed-body">
931          <section class="ed-sec">
932            <h4>This image</h4>
933            <label>Shown as
934              <select id="ed-type">
935                <option value="360">360° panorama</option>
936                <option value="photo">Photo</option>
937                <option value="video">Video</option>
938              </select>
939            </label>
940            <label>Group
941              <input id="ed-category" list="ed-cats" placeholder="e.g. Peterborough Landmarks">
942              <datalist id="ed-cats"></datalist>
943            </label>
944            <label class="ed-check"><input type="checkbox" id="ed-interior"> Interior — single Exit arrow</label>
945          </section>
946          <section class="ed-sec">
947            <h4>Selected arrow</h4>
948            <div class="ed-note" id="ed-arrow-note">Click an arrow in the panorama to select it. Drag arrows any time to move them.</div>
949            <div id="ed-arrow-tools" style="display:none">
950              <div class="ed-target" id="ed-target-name"></div>
951              <div class="ed-icons" id="ed-icons"></div>
952              <div class="ed-icons ed-logos" id="ed-logos"></div>
953              <label>Venue logo URL <input id="ed-logo" placeholder="https://…/logo.png"></label>
954              <button id="ed-logo-upload" class="ed-btn ghost" type="button">⬆ Upload logo image…</button>
955              <input type="file" id="ed-logo-file" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
956              <div class="ed-note" id="ed-logo-note" style="margin:-4px 0 10px"></div>
957              <label>Size <input type="range" id="ed-size" min="50" max="250" value="100"> <span id="ed-size-val">100%</span></label>
958              <button id="ed-reset-pos" class="ed-btn ghost" type="button">Reset position to GPS</button>
959            </div>
960          </section>
961        </div>
962        <div class="ed-foot"><button id="ed-save" class="ed-btn primary" type="button">Save changes</button></div>
963      </aside>
964
965      <!-- Discover More: the reading window a visitor sees when they click an info point.
966           The outer element is the overlay: it covers the panorama, so the 360 is
967           frozen for as long as this is open and a click anywhere off the card
968           closes it. -->
969      <aside id="dm-panel" aria-hidden="true" role="dialog" aria-modal="true" aria-label="More information">
970        <div class="dm-card" id="dm-card">
971          <button class="dm-close" id="dm-close" aria-label="Close" title="Close (Esc)">&#10005;</button>
972          <div class="dm-media" id="dm-media"></div>
973          <div class="dm-body">
974            <div class="dm-scroll">
975              <p class="dm-kicker" id="dm-kicker">Discover more</p>
976              <h3 id="dm-title"></h3>
977              <p class="dm-text" id="dm-text"></p>
978              <div class="dm-links" id="dm-links"></div>
979            </div>
980            <div class="dm-foot">
981              <span class="dm-count" id="dm-count"></span>
982              <button type="button" id="dm-share">
983                <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
984                Share
985              </button>
986              <button type="button" id="dm-edit" style="display:none">&#9998; Edit</button>
987            </div>
988          </div>
989        </div>
990      </aside>
991
992      <div id="dm-place-banner">
993        <span>Click the spot in the panorama where this point belongs.</span>
994        <button type="button" id="dm-place-cancel">Cancel</button>
995      </div>
996
997      <!-- Discover More: admin editing form -->
998      <aside id="dm-editor" aria-hidden="true">
999        <div class="dm-ed-head">
1000          <strong id="dm-ed-heading">Discover More point</strong>
1001          <button id="dm-ed-close" title="Close">&#10005;</button>
1002        </div>
1003        <div class="dm-ed-body">
1004          <label>Title
1005            <input type="text" id="dm-f-title" maxlength="120" placeholder="e.g. The Hedda Stone">
1006          </label>
1007          <label>Description
1008            <textarea id="dm-f-text" maxlength="4000" placeholder="What should visitors know about this?"></textarea>
1009          </label>
1010          <label>Picture or video
1011            <select id="dm-f-kind">
1012              <option value="">Nothing — text only</option>
1013              <option value="image">Picture</option>
1014              <option value="youtube">YouTube video</option>
1015            </select>
1016          </label>
1017          <div id="dm-f-image-wrap" style="display:none">
1018            <div class="dm-ed-note">Pick one from the library below, or upload a new one.</div>
1019            <div class="dm-thumbs" id="dm-f-library"></div>
1020            <div class="dm-ed-row" style="margin-bottom:6px">
1021              <input type="text" id="dm-f-image" placeholder="images/… or https://…">
1022              <button type="button" id="dm-f-upload" title="Upload a picture">&#11014;</button>
1023            </div>
1024            <input type="file" id="dm-f-file" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
1025            <div class="dm-ed-note" id="dm-f-upload-note"></div>
1026          </div>
1027          <div id="dm-f-yt-wrap" style="display:none">
1028            <label>YouTube link or video ID
1029              <input type="text" id="dm-f-yt" placeholder="https://www.youtube.com/watch?v=…">
1030            </label>
1031          </div>
1032          <label>Links</label>
1033          <div class="dm-ed-note">Buttons shown at the bottom of the panel. Leave blank if you don't need any.</div>
1034          <div id="dm-f-links"></div>
1035          <button type="button" id="dm-f-addlink" style="width:100%;font:inherit;font-size:12px;font-weight:700;padding:9px;border:1px dashed var(--line,#ccc);background:transparent;color:inherit;border-radius:7px;cursor:pointer;margin-bottom:12px">+ Add a link</button>
1036          <button type="button" id="dm-f-move" style="width:100%;font:inherit;font-size:12px;font-weight:700;padding:9px;border:1px solid var(--line,#ccc);background:transparent;color:inherit;border-radius:7px;cursor:pointer">&#10021; Move this point</button>
1037        </div>
1038        <div class="dm-ed-foot">
1039          <button type="button" class="danger" id="dm-ed-delete" title="Delete this point">&#128465;</button>
1040          <button type="button" id="dm-ed-cancel">Cancel</button>
1041          <button type="button" class="primary" id="dm-ed-save">Save</button>
1042        </div>
1043      </aside>
1044
1045      <div class="calib-prompt" id="calib-prompt">
1046        <span id="calib-prompt-text">Click directly on the landmark in the panorama →</span>
1047        <button id="calib-cancel" title="Cancel calibration">✕</button>
1048      </div>
1049      <div class="calib-modal" id="calib-modal">
1050        <div class="calib-card">
1051          <h3 id="calib-modal-title">Calibrate panorama bearing</h3>
1052          <p>Pick a landmark you can see in the panorama. Then you'll click on its actual position to teach the system which way this panorama is facing.</p>
1053          <label for="calib-target">Landmark to align to:</label>
1054          <select id="calib-target"></select>
1055          <div id="calib-result-wrap"></div>
1056          <div class="actions">
1057            <button class="btn-secondary" id="calib-modal-cancel">Cancel</button>
1058            <button class="btn-primary"   id="calib-modal-start">Start picking</button>
1059          </div>
1060        </div>
1061      </div>
1062    </div>
1063    <button id="psv-back-to-map" aria-label="Show map">← Back to map</button>
1064  </div>
1065  <aside class="cn360-list">
1066    <h2 id="listHeading">Explore the map</h2>
1067    <p class="sub" id="listIntro">Pick a pin on the map, or an item from the list, to take a closer look.</p>
1068    <details id="aboutPanel" class="about-panel" hidden></details>
1069    <div id="layers-panel" hidden></div>
1070    <div id="location-list">
1071      <div class="cn360-empty"><strong>Loading…</strong>Reading the media library.</div>
1072    </div>
1073  </aside>
1074</main>
1075
1076<!-- Lightbox modal for flat photos + video -->
1077<div class="lb-modal" id="lb-modal" aria-hidden="true">
1078  <div class="lb-bar">
1079    <div class="lb-title" id="lb-title"></div>
1080    <button id="lb-close" title="Close (Esc)" aria-label="Close">✕</button>
1081  </div>
1082  <button class="lb-nav lb-prev" id="lb-prev" title="Previous (←)" aria-label="Previous">‹</button>
1083  <div class="lb-stage" id="lb-stage"></div>
1084  <button class="lb-nav lb-next" id="lb-next" title="Next (→)" aria-label="Next">›</button>
1085  <button id="lb-x" type="button" title="Close (Esc)" aria-label="Close">&#10005;</button>
1086  <div class="lb-foot">
1087    <div class="lb-cat" id="lb-cat"></div>
1088    <p class="lb-desc" id="lb-desc"></p>
1089    <div class="lb-meta" id="lb-meta"></div>
1090    <div class="lb-credit" id="lb-credit"></div>
1091    <a class="lb-explore" id="lb-explore" target="_blank" rel="noopener" style="display:none">Explore →</a>
1092    <button class="req-btn" id="lb-request" type="button" style="margin-left:8px">Request to use this image</button>
1093    <button class="req-btn" id="lb-share" type="button" style="margin-left:8px;display:none">Share link</button>
1094    <!-- The X lives in the top bar, which a host site's fixed header can sit
1095         over. This one is under the picture, where you are already looking. -->
1096    <button class="req-btn" id="lb-done" type="button" style="margin-left:8px;background:#ffffff;color:#111111">&#10005; Close</button>
1097  </div>
1098</div>
1099
1100<!-- Request-to-use form -->
1101<!--
1102  Share.
1103
1104  Reuses the request-form's card so it inherits a dialog that already sits above
1105  the lightbox (z-index 4000 against 3000) and already closes on backdrop and
1106  Esc. The link is shown, not just copied: clipboard writes are blocked in a
1107  cross-origin frame and silently do nothing, and "it said copied but nothing
1108  pasted" is worse than a box you can select by hand.
1109-->
1110<div class="req-modal" id="share-modal" aria-hidden="true">
1111  <div class="req-card">
1112    <button class="req-close" id="share-close" aria-label="Close">&#10005;</button>
1113    <h3>Share this</h3>
1114    <p class="req-img" id="share-name"></p>
1115    <div class="share-row">
1116      <input type="text" id="share-url" readonly aria-label="Link to share">
1117      <button type="button" id="share-copy" title="Copy link" aria-label="Copy link">
1118        <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
1119      </button>
1120    </div>
1121    <div class="req-actions">
1122      <button type="button" class="btn-secondary" id="share-native" style="display:none">Share&hellip;</button>
1123      <button type="button" class="btn-primary" id="share-copy2">Copy link</button>
1124    </div>
1125    <div class="req-status" id="share-status"></div>
1126  </div>
1127</div>
1128
1129<div class="req-modal" id="req-modal" aria-hidden="true">
1130  <div class="req-card">
1131    <button class="req-close" id="req-close" aria-label="Close">✕</button>
1132    <h3>Request to use this image</h3>
1133    <p class="req-img" id="req-image-name"></p>
1134    <p style="font-size:13px;color:#555;margin:0 0 4px">This image is <strong>© All rights reserved</strong> by its owner (see the credit shown). Community, non‑profit, media and press are welcome to ask — tell us how you'd like to use it and we'll pass it to the owner.</p>
1135    <form id="req-form">
1136      <input type="text" name="website" class="hp" tabindex="-1" autocomplete="off" aria-hidden="true">
1137      <label>Your name *<input type="text" name="name" required></label>
1138      <label>Organisation <span style="font-weight:400;color:#888">(if applicable)</span><input type="text" name="organisation"></label>
1139      <label>Mobile<input type="text" name="mobile"></label>
1140      <label>Email *<input type="email" name="email" required></label>
1141      <label>Reason for use *<textarea name="reason" required placeholder="e.g. parish newsletter, school project, local history talk…"></textarea></label>
1142      <input type="hidden" name="image" id="req-image">
1143      <input type="hidden" name="image_url" id="req-image-url">
1144      <div class="req-actions">
1145        <button type="button" class="btn-secondary" id="req-cancel">Cancel</button>
1146        <button type="submit" class="btn-primary" id="req-submit">Send request</button>
1147      </div>
1148      <div class="req-status" id="req-status"></div>
1149    </form>
1150  </div>
1151</div>
1152
1153<!-- District info modal -->
1154<div class="dist-modal" id="dist-modal" aria-hidden="true">
1155  <div class="dist-card">
1156    <div class="dist-grip" aria-hidden="true"></div>
1157    <div class="dist-hero" id="dist-hero">
1158      <img class="dist-hero-img" id="dist-hero-img" alt="" referrerpolicy="no-referrer">
1159      <div class="dist-hero-scrim" id="dist-hero-scrim"></div>
1160      <button class="dist-close" id="dist-close" aria-label="Close">✕</button>
1161      <div class="dist-hero-text">
1162        <span class="dist-kicker" id="dist-kicker">Discover Peterborough</span>
1163        <h3 id="dist-name"></h3>
1164        <p class="dist-tag" id="dist-tag"></p>
1165      </div>
1166    </div>
1167    <div class="dist-body">
1168      <p class="dist-intro" id="dist-intro"></p>
1169      <div id="dist-meta"></div>
1170      <div id="dist-highlights"></div>
1171      <a class="dist-cta" id="dist-cta" target="_blank" rel="noopener">Explore <span class="dist-cta-arrow">→</span></a>
1172      <button id="dist-share" type="button" style="display:none;margin-top:12px;background:transparent;border:1px solid rgba(255,255,255,.4);color:inherit;font:inherit;font-weight:700;padding:8px 15px;border-radius:99px;cursor:pointer">Share link</button>
1173    </div>
1174  </div>
1175</div>
1176
1177<script type="importmap">
1178{
1179  "imports": {
1180    "three":                                   "./lib/three/three.module.js",
1181    "leaflet":                                 "./lib/leaflet/leaflet-src.esm.js",
1182    "@photo-sphere-viewer/core":               "./lib/psv/core/index.module.js",
1183    "@photo-sphere-viewer/markers-plugin":     "./lib/psv/markers-plugin/index.module.js",
1184    "@photo-sphere-viewer/plan-plugin":        "./lib/psv/plan-plugin/index.module.js",
1185    "@photo-sphere-viewer/autorotate-plugin":  "./lib/psv/autorotate-plugin/index.module.js",
1186    "@photo-sphere-viewer/equirectangular-video-adapter": "./lib/psv/equirectangular-video-adapter/index.module.js",
1187    "@photo-sphere-viewer/video-plugin":       "./lib/psv/video-plugin/index.module.js"
1188  }
1189}
1190</script>
1190
1191
1192<script type="module">
1193import { Viewer }            from '@photo-sphere-viewer/core';
1194import { MarkersPlugin }     from '@photo-sphere-viewer/markers-plugin';
1195import { PlanPlugin }        from '@photo-sphere-viewer/plan-plugin';
1196import { AutorotatePlugin }  from '@photo-sphere-viewer/autorotate-plugin';
1197import { EquirectangularVideoAdapter } from '@photo-sphere-viewer/equirectangular-video-adapter';
1198import { VideoPlugin }       from '@photo-sphere-viewer/video-plugin';
1199
1200// White-label: colours come from CSS variables (brand.css.php); text from window.BRAND (brand.js.php)
1201const _cs = getComputedStyle(document.documentElement);
1202const COL = {
1203  primary: (_cs.getPropertyValue('--brand').trim()      || '#1f8a5b'),
1204  accent:  (_cs.getPropertyValue('--lime').trim()       || '#9ad97a'),
1205  deep:    (_cs.getPropertyValue('--green-900').trim()  || '#0a3322'),
1206};
1207(function(){ const B = window.BRAND || {};
1208  const m = document.getElementById('brandMark');    if (m && B.initials) m.textContent = B.initials;
1209  const n = document.getElementById('brandName');    if (n && B.name) n.textContent = B.name;
1210  const t = document.getElementById('brandTagline'); if (t) t.textContent = B.partner ? ('with the ' + B.partner) : (B.tagline || '');
1211  if (B.name) document.title = B.name + (B.tagline ? ' — ' + B.tagline : ' — Explore');
1212
1213  // Header navigation from brand.php 'nav' (matches the site's own menu).
1214  // Sites without a nav keep the default About / Home links.
1215  const navEl = document.getElementById('siteNav');
1216  if (navEl && Array.isArray(B.nav) && B.nav.length){
1217    navEl.innerHTML = B.nav.map(it =>
1218      '<a class="crumb" href="' + String(it.url || '#').replace(/"/g,'&quot;') + '">'
1219      + String(it.label || '').replace(/</g,'&lt;') + '</a>').join('');
1220  }
1221
1222  // Full WordPress site chrome (per-site 'header' block in brand.php):
1223  // replicates the site's own top bar + menu (with dropdowns) + search + footer,
1224  // replacing the standard map header. Sites without it are untouched.
1225  // When embedded in the WordPress site via an iframe (Divi code module), the
1226  // page already sits under the real site header — skip injecting our copy.
1227  const IN_IFRAME = (() => { try { return window.self !== window.top; } catch(e){ return true; } })();
1228  const H = IN_IFRAME ? null : (B.header || null);
1229  if (IN_IFRAME) document.body.classList.add('embedded');
1230  if (H && Array.isArray(H.menu) && H.menu.length){
1231    if (H.google_font){ const gf = document.createElement('link'); gf.rel = 'stylesheet'; gf.href = H.google_font; document.head.appendChild(gf); }
1232    const esc = s => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
1233    const ICONS = {
1234      facebook:  'M14 8h2V5h-2a4 4 0 0 0-4 4v2H8v3h2v7h3v-7h2.2l.8-3h-3V9a1 1 0 0 1 1-1z',
1235      instagram: 'M8 3h8a5 5 0 0 1 5 5v8a5 5 0 0 1-5 5H8a5 5 0 0 1-5-5V8a5 5 0 0 1 5-5zm0 2a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h8a3 3 0 0 0 3-3V8a3 3 0 0 0-3-3H8zm4 3.5A3.5 3.5 0 1 1 8.5 12 3.5 3.5 0 0 1 12 8.5zm0 2A1.5 1.5 0 1 0 13.5 12 1.5 1.5 0 0 0 12 10.5zM16.8 6.4a.8.8 0 1 1-.8.8.8.8 0 0 1 .8-.8z',
1236      youtube:   'M21.6 7.2a2.6 2.6 0 0 0-1.8-1.8C18.2 5 12 5 12 5s-6.2 0-7.8.4A2.6 2.6 0 0 0 2.4 7.2 27 27 0 0 0 2 12a27 27 0 0 0 .4 4.8 2.6 2.6 0 0 0 1.8 1.8C5.8 19 12 19 12 19s6.2 0 7.8-.4a2.6 2.6 0 0 0 1.8-1.8A27 27 0 0 0 22 12a27 27 0 0 0-.4-4.8zM10 15V9l5.2 3z',
1237      linkedin:  'M6.94 5a2 2 0 1 1-4-.03 2 2 0 0 1 4 .03zM7 8.48H3V21h4zm6.32 0H9.34V21h3.94v-6.57c0-3.66 4.77-4 4.77 0V21H22v-7.93c0-6.17-7.06-5.94-8.72-2.91z'
1238    };
1239    const icons = (list, color) => (list || []).map(s =>
1240      '<a href="' + esc(s.url) + '" target="_blank" rel="noopener" aria-label="' + esc(s.icon) + '">'
1241      + '<svg viewBox="0 0 24 24" width="18" height="18" fill="' + color + '"><path d="' + (ICONS[s.icon] || '') + '"/></svg></a>').join('');
1242
1243    const bg = H.bar_bg || '#0b455e', tb = H.topbar_bg || '#d1f2eb', font = H.font || 'inherit';
1244    const st = document.createElement('style');
1245    st.textContent =
1246      '.wpch{font-family:' + font + '}' +
1247      '.wpch a{text-decoration:none}' +
1248      '.wpch-in{width:min(96%,1800px);margin:0 auto;display:flex;align-items:center}' +
1249      '.wpch-top{background:' + tb + '}' +
1250      '.wpch-top .wpch-in{justify-content:flex-end;
1250gap:14px;height:45px}' +
1251      '.wpch-top a{display:inline-flex}' +
1252      '.wpch-bar{background:' + bg + ';background-image:linear-gradient(' + bg + ' 63%, rgba(0,0,0,.52) 100%);position:relative;z-index:1400}' +
1253      '.wpch-bar .wpch-in{height:81px;gap:34px}' +
1254      '.wpch-logo img{display:block;height:50px;width:auto}' +
1255      '.wpch-menu ul{list-style:none;margin:0;padding:0;display:flex;align-items:center;gap:30px}' +
1256      '.wpch-menu li{position:relative}' +
1257      '.wpch-menu li>a{color:#fff;font-size:16px;font-weight:500;padding:30px 0;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}' +
1258      '.wpch-menu li>a:hover{opacity:.75}' +
1259      '.wpch-menu li ul{display:none;position:absolute;top:100%;left:-20px;flex-direction:column;align-items:stretch;gap:0;background:' + bg + ';min-width:255px;padding:10px 0;box-shadow:0 6px 18px rgba(0,0,0,.25);z-index:1500}' +
1260      '.wpch-menu li:hover>ul{display:flex}' +
1261      '.wpch-menu li ul li>a{display:block;padding:9px 20px;font-size:15px}' +
1262      '.wpch-caret{font-size:10px;opacity:.85}' +
1263      '.wpch-search{margin-left:auto;display:flex}' +
1264      '.wpch-search input{height:36px;width:min(300px,24vw);border:0;padding:0 12px;font-size:14px;background:#fff;color:#333;border-radius:5px 0 0 5px;font-family:inherit}' +
1265      '.wpch-search button{height:36px;padding:0 18px;border:0;background:#ddd;color:#666;font-size:14px;cursor:pointer;border-radius:0 5px 5px 0;font-family:inherit}' +
1266      '.wpch-burger{display:none;margin-left:auto;background:none;border:0;color:#fff;font-size:26px;cursor:pointer;padding:4px 8px}' +
1267      '.wpch-mobile{display:none;background:' + bg + ';padding:8px 0 14px}' +
1268      '.wpch-mobile a{display:block;color:#fff;font-size:16px;padding:9px 5%;border-bottom:1px solid rgba(255,255,255,.08)}' +
1269      '.wpch-mobile a.wpch-kid{padding-left:10%;font-size:15px;opacity:.9}' +
1270      '.wpch-f-main{background:' + bg + '}' +
1271      '.wpch-f-main .wpch-in{height:109px;gap:26px}' +
1272      '.wpch-f-main a{color:#fff;font-size:15px}' +
1273      '.wpch-f-main a:hover{opacity:.75}' +
1274      '.wpch-f-soc{margin-left:auto;display:flex;gap:14px}' +
1275      '.wpch-f-bottom{background:#000;color:#fff}' +
1276      '.wpch-f-bottom .wpch-in{height:81px;font-size:14px}' +
1277      'header.cn360-head{display:none}' +
1278      '@media (max-width:980px){.wpch-menu,.wpch-search{display:none}.wpch-burger{display:block}.wpch-mobile.open{display:block}}';
1279    document.head.appendChild(st);
1280
1281    const menuHtml = H.menu.map(it => {
1282      const kids = Array.isArray(it.children) ? it.children : [];
1283      return '<li><a href="' + esc(it.url) + '">' + esc(it.label)
1284        + (kids.length ? ' <span class="wpch-caret">&#9662;</span>' : '') + '</a>'
1285        + (kids.length ? '<ul>' + kids.map(k => '<li><a href="' + esc(k.url) + '">' + esc(k.label) + '</a></li>').join('') + '</ul>' : '')
1286        + '</li>';
1287    }).join('');
1288
1289    const wrap = document.createElement('div');
1290    wrap.className = 'wpch';
1291    wrap.innerHTML =
1292      '<div class="wpch-top"><div class="wpch-in">' + icons(H.socials, bg) + '</div></div>' +
1293      '<div class="wpch-bar"><div class="wpch-in">' +
1294        '<a class="wpch-logo" href="' + esc(H.home || './') + '"><img src="' + esc(H.logo) + '" alt="' + esc(B.name) + '"></a>' +
1295        '<nav class="wpch-menu"><ul>' + menuHtml + '</ul></nav>' +
1296        (H.search ? '<form class="wpch-search" action="' + esc(H.search) + '" method="get"><input type="search" name="s" aria-label="Search"><button type="submit">Search</button></form>' : '') +
1297        '<button class="wpch-burger" aria-label="Menu">&#9776;</button>' +
1298      '</div>' +
1299      '<div class="wpch-mobile">' + H.menu.map(it =>
1300        '<a href="' + esc(it.url) + '">' + esc(it.label) + '</a>' +
1301        (it.children || []).map(k => '<a class="wpch-kid" href="' + esc(k.url) + '">' + esc(k.label) + '</a>').join('')
1302      ).join('') + '</div></div>';
1303    const oldHead = document.querySelector('header.cn360-head');
1304    oldHead.parentNode.insertBefore(wrap, oldHead);
1305    wrap.querySelector('.wpch-burger').addEventListener('click', () =>
1306      wrap.querySelector('.wpch-mobile').classList.toggle('open'));
1307    // keep the light/dark toggle available on the new bar
1308    const tm = document.querySelector('[data-theme-mount]');
1309    if (tm) wrap.querySelector('.wpch-bar .wpch-in').appendChild(tm);
1310
1311    // Footer (below the map — scroll to see it, like any site footer)
1312    const F = H.footer || {};
1313    const ft = document.createElement('div');
1314    ft.className = 'wpch';
1315    ft.innerHTML =
1316      '<div class="wpch-f-main"><div class="wpch-in">' +
1317        (F.links || []).map(l => '<a href="' + esc(l.url) + '">' + esc(l.label) + '</a>').join('') +
1318        '<span class="wpch-f-soc">' + icons(H.socials, '#ffffff') + '</span>' +
1319      '</div></div>' +
1320      '<div class="wpch-f-bottom"><div class="wpch-in">' + esc(F.copyright || '') + '</div></div>';
1321    document.querySelector('main.cn360-main').after(ft);
1322  }
1323
1324  /*
1325   * How tall the chrome above the map actually is.
1326   *
1327   * It used to be three hard-coded guesses — 68px for our own header, 126px for
1328   * the site's injected one, none at all when embedded — and each of them was
1329   * wrong the moment a logo changed or a menu wrapped to a second line. When the
1330   * guess is too small the map is taller than the room left for it, the page
1331   * grows a scrollbar nobody asked for, and the search box and the filter chips
1332   * ride up underneath the header.
1333   *
1334   * Measured instead: on load, on resize, and whenever the header itself changes
1335   * size. Two things come out of it — how much viewport the map may have, and
1336   * where the floating panels start.
1337   *
1338   * The panels are also fixed to the window now rather than to the map. That is
1339   * the half that actually stops them sliding: the page may still scroll on
1340   * purpose, to reach the site footer below the map, and when it does the
1341   * controls should stay where they are instead of travelling under the header.
1342   */
1343  function measureChrome(){
1344    const above = [
1345      document.querySelector('.wpch'),
1346      document.querySelector('header.cn360-head'),
1347    ];
1348
1349    let height = 0;
1350
1351    for (const el of above){
1352      if (!el) continue;
1353      // A display:none header still has a box; offsetParent says whether it is
1354      // actually drawn. Fixed elements report null there, hence the second test.
1355      if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed') continue;
1356      height += el.getBoundingClientRect().height;
1357    }
1358
1359    const root = document.documentElement;
1360
1361    root.style.setProperty('--chrome-h', Math.round(height) + 'px');
1362
1363    /*
1364     * Embedded, the header above the frame belongs to the parent site and
1365     * cannot be measured from in here, so the stylesheet's own offset stands.
1366     * `body` wins over `:root` for anything inside it, so this is belt and
1367     * braces rather than the mechanism.
1368     */
1369    if (!document.body.classList.contains('embedded')){
1370      root.style.setProperty('--x-top', (Math.round(height) + 14) + 'px');
1371
1372      return;
1373    }
1374
1375    /*
1376     * Embedded, the header above us belongs to the parent page.
1377     *
1378     * Discover Peterborough's is `position:fixed`, so it sits *over* the top of
1379     * this frame rather than above it — the frame begins at the top of the
1380     * content area and the header is painted across the first sixty-odd pixels
1381     * of it. A flat 22px offset therefore puts the search box behind it, which
1382     * is exactly what it looked like.
1383     *
1384     * The frame is same-origin, so how much is covered can simply be measured:
1385     * find our own iframe in the parent, then any fixed or sticky bar that
1386     * overlaps its top edge. Wrapped in try/catch because another site may
1387     * embed this cross-origin one day, and there the stylesheet's offset is the
1388     * best that can be done.
1389     */
1390    try {
1391      const outer = window.top.document;
1392      const frame = [...outer.querySelectorAll('iframe')].find((f) => f.contentWindow === window);
1393
1394      if (!frame) return;
1395
1396      /*
1397       * Hide the host page's scrollbar as well as our own.
1398       *
1399       * The frame is as tall as the viewport, so the page around it scrolls by
1400       * a few pixels and Chrome draws a full-height bar down the side of what
1401       * is meant to read as a full-bleed map. Our own stylesheet cannot reach
1402       * it — it belongs to the document above this one.
1403       *
1404       * Same-origin, so it can be styled directly. Scrolling still works; only
1405       * the bar is hidden, and the flag stops a second style element being
1406       * appended every time this runs on resize.
1407       */
1408      if (!outer.documentElement.dataset.piScrollbarHidden){
1409        outer.documentElement.dataset.piScrollbarHidden = '1';
1410
1411        const hide = outer.createElement('style');
1412
1413        hide.textContent =
1414          'html,body{scrollbar-width:none;-ms-overflow-style:none}' +
1415          'html::-webkit-scrollbar,body::-webkit-scrollbar{width:0;height:0;display:none}';
1416
1417        outer.head.appendChild(hide);
1418      }
1419
1420      const box = frame.getBoundingClientRect();
1421      let covered = 0;
1422
1423      const bars = outer.querySelectorAll('header, nav, .et-l--header, #main-header, #page-container > div');
1424
1425      for (const bar of bars){
1426        const style = outer.defaultView.getComputedStyle(bar);
1427
1428        if (style.position !== 'fixed' && style.position !== 'sticky') continue;
1429
1430        const rect = bar.getBoundingClientRect();
1431
1432        // Only bars that actually cover the top of the frame count. One sitting
1433        // below it, or a zero-height wrapper, tells us nothing.
1434        if (rect.height === 0 || rect.bottom <= box.top) continue;
1435
1436        covered = Math.max(covered, rect.bottom - box.top);
1437      }
1438
1439      /*
1440       * Set on `body`, not on `:root`. The `body.embedded` rule declares this
1441       * variable on the body element, and a declaration there beats one
1442       * inherited from the root for everything inside it — so writing to the
1443       * root would have had no visible effect at all.
1444       */
1445      if (covered > 0) document.body.style.setProperty('--x-top', (Math.round(covered) + 14) + 'px');
1446    } catch (e) {
1447      /* Cross-origin, or no parent to read: the stylesheet's offset stands. */
1448    }
1449  }
1450
1451  measureChrome();
1452  addEventListener('resize', measureChrome);
1453  // Fonts and the logo image land after DOMContentLoaded and both change the
1454  // header's height, so the first measurement is not the last word.
1455  addEventListener('load', measureChrome);
1456
1457  if (window.ResizeObserver){
1458    const watched = document.querySelector('.wpch') || document.querySelector('header.cn360-head');
1459    if (watched) new ResizeObserver(measureChrome).observe(watched);
1460  }
1461
1462  // Site flag: hide the "Use this image" request buttons entirely
1463  if (B.imageRequests === false){
1464    const st2 = document.createElement('style');
1465    st2.textContent = '#psv-request,#lb-request{display:none!important}';
1466    document.head.appendChild(st2);
1467  }
1468  // Sidebar heading + intro reflect the site
1469  const A = B.about || {};
1470  const head = document.getElementById('listHeading'); if (head && B.tagline) head.textContent = B.tagline;
1471  const intro = document.getElementById('listIntro');  if (intro && A.lead) intro.innerHTML = A.lead;
1472
1473  // Per-site meta (one index.html serves every site)
1474  const setMeta = (sel, val) => { const el = document.querySelector(sel); if (el && val) el.setAttribute('content', val); };
1475  if (B.name) setMeta('meta[property="og:title"]', B.name + (B.tagline ? ' — ' + B.tagline : ''));
1476  if (A.lead){ const plain = A.lead.replace(/<[^>]+>/g,''); setMeta('meta[name="description"]', plain); setMeta('meta[property="og:description"]', plain); }
1477
1478  // "About this project" panel on the map page, from BRAND.about
1479  const panel = document.getElementById('aboutPanel');
1480  if (panel && A && (A.title || (A.blocks && A.blocks.length))){
1481    let body = '<div class="ab-body">';
1482    if (A.title) body += '<h3>' + escapeHtml(A.title) + '</h3>';
1483    (A.blocks || []).forEach(blk => {
1484      const ty = blk.type || 'p';
1485      if (ty === 'h2')           body += '<h4>' + escapeHtml(blk.text || '') + '</h4>';
1486      else if (ty === 'callout') body += '<div class="ab-callout">' + (blk.html || '') + '</div>';
1487      else                       body += '<p>' + (blk.html || escapeHtml(blk.text || '')) + '</p>';
1488    });
1489    body += '<a class="ab-more" href="about.php">Read the full About page →</a></div>';
1490    panel.innerHTML = '<summary>About this project</summary>' + body;
1491    panel.hidden = false;
1492  }
1493})();
1494
1495let locations = [];        // all items
1496let order = [];            // indices currently visible (after category filter)
1497let currentIdx = -1;
1498let viewer = null;
1499let map = null;
1500let cluster = null;
1501const markers = {};        // idx -> Leaflet marker (only for visible items)
1502let districtsLayer = null; // optional district/quarter overlay (GeoJSON)
1503// Drag-to-align (admin) state for the open 360
1504let viewerLoc = null, viewerIdx = -1, viewerMP = null, viewerAutorotate = null;
1505let hotspotRefs = [];      // [{ id, otherIdx }]
1506let aligning = false, alignBearing = 0, alignPitch = 0;
1507
1508/* ============================================================
1509 * Admin mode — visit ?admin=SECRET once to enable calibration.
1510 * Keep ADMIN_TOKEN in sync with save-bearing.php.
1511 * ============================================================ */
1512const ADMIN_TOKEN = 'cn360-admin-2026';     // ← change to your own secret
1513const urlAdminToken = new URLSearchParams(location.search).get('admin');
1514if (urlAdminToken === ADMIN_TOKEN) localStorage.setItem('cn360_admin', urlAdminToken);
1515const adminToken = localStorage.getItem('cn360_admin') || '';
1516const isAdmin    = adminToken === ADMIN_TOKEN;
1517
1518// Real login state (session cookie), checked server-side. Admin tools (Align
1519// arrows) only appear when actually signed in — signing out hides them.
1520let serverAdmin = false;
1521function refreshAdminTools(){
1522  const b = document.getElementById('psv-edit');
1523  if (b) b.style.display = serverAdmin ? '' : 'none';
1524  const d = document.getElementById('psv-dm-add');
1525  if (d) d.classList.toggle('show', !!serverAdmin && (typeof dmEnabled !== 'function' || dmEnabled()));
1526}
1527fetch('whoami.php', { headers: { 'Accept': 'application/json' } })
1528  .then(r => r.json())
1529  .then(j => { serverAdmin = !!(j && j.loggedIn); document.body.classList.toggle('is-admin', serverAdmin); refreshAdminTools(); })
1530  .catch(() => {});
1531
1532let calibration = null;
1533
1534/* ---------- Geo helpers ---------- */
1535const EARTH_R = 6371000;
1536const toRad = d => d * Math.PI / 180;
1537const toDeg = r => r * 180 / Math.PI;
1538function distanceM(lat1, lng1, lat2, lng2){
1539  const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1);
1540  const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLng/2)**2;
1541  return 2 * EARTH_R * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
1542}
1543/* Pins saved at the exact same spot (same venue / identical EXIF GPS) would
1544   stack on top of each other. Fan each such group out in a small phyllotaxis
1545   spiral (a few metres apart) so every pin is individually visible/clickable.
1546   At low zoom they still re-cluster; only the display position is nudged. */
1547function spreadColocated(list){
1548  const groups = {};
1549  list.forEach((l, i) => {
1550    if (l.lat == null || l.lng == null) return;
1551    const key = l.lat.toFixed(5) + ',' + l.lng.toFixed(5);   // ~1 m buckets
1552    (groups[key] = groups[key] || []).push(i);
1553  });
1554  const GOLDEN = Math.PI * (3 - Math.sqrt(5));
1555  const SPACING = 5;   // metres between successive pins in the spiral
1556  Object.values(groups).forEach(idxs => {
1557    if (idxs.length < 2) return;
1558    const baseLat = list[idxs[0]].lat;
1559    const mPerDegLat = 111320;
1560    const mPerDegLng = 111320 * Math.cos(baseLat * Math.PI / 180) || 111320;
1561    idxs.forEach((li, k) => {
1562      const radius = SPACING * Math.sqrt(k);     // k=0 stays put, others spiral out
1563      const ang = k * GOLDEN;
1564      list[li].lat += (radius * Math.cos(ang)) / mPerDegLat;
1565      list[li].lng += (radius * Math.sin(ang)) / mPerDegLng;
1566    });
1567  });
1568}
1569
1570function bearingDeg(lat1, lng1, lat2, lng2){
1571  const f1 = toRad(lat1), f2 = toRad(lat2), dl = toRad(lng2 - lng1);
1572  const y = Math.sin(dl) * Math.cos(f2);
1573  const x = Math.cos(f1)*Math.sin(f2) - Math.sin(f1)*Math.cos(f2)*Math.cos(dl);
1574  return (toDeg(Math.atan2(y, x)) + 360) % 360;
1575}
1576
1577function shortLabel(title){
1578  if(!title) return '';
1579  let s = String(title)
1580    .replace(/^THS[\s\-_]+/i, '')
1581    .replace(/\bCivic\b[\s\-_]*/i, '')
1582    .replace(/\s*\d{3,}\s*$/g, '')
1583    .replace(/\s*(VR|360°?|360|360 VR)\s*$/i, '')
1584    .replace(/[\-_]+/g, ' ')
1585    .replace(/\s+/g, ' ')
1586    .trim();
1587  if(!s) s = title;
1588  if(s.length > 28) s = s.slice(0, 26).trimEnd() + '…';
1589  return s;
1590}
1591const TYPE_BADGE = { '360':'360°', 'photo':'PHOTO', 'video':'VIDEO', '360video':'360° VIDEO',
1592                     'story':'NEWS', 'event':'EVENT', 'venue':'ATTRACTION', 'seasonal':'POP-UP',
1593                     'business':'LOCAL' };
1594function badgeFor(loc){ return loc.badge || TYPE_BADGE[loc.kind] || TYPE_BADGE[loc.type] || ''; }
1595// Inline pin icons for imported kinds (rendered white, counter-rotated by CSS)
1596const PIN_ICON = {
1597  business: '<span class="pin-ico"><svg viewBox="0 0 24 24">
1597<path d="M4 9l1.5-4h13L20 9M4 9h16M4 9v11h16V9M9 20v-6h6v6"/></svg></span>',
1598  story:    '<span class="pin-ico"><svg viewBox="0 0 24 24"><path d="M4 5h11v15H6a2 2 0 0 1-2-2zM15 8h4v10a2 2 0 0 1-2 2"/><path d="M7 9h5M7 12h5M7 15h3"/></svg></span>',
1599  event:    '<span class="pin-ico"><svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M8 3v4M16 3v4M4 10h16"/></svg></span>',
1600  venue:    '<span class="pin-ico"><svg viewBox="0 0 24 24"><path d="M4 20h16M6 20V9l6-4 6 4v11M10 20v-5h4v5"/></svg></span>',
1601  seasonal: '<span class="pin-ico"><svg viewBox="0 0 24 24"><path d="M12 3l2.5 5.5L20 9l-4 4 1 6-5-3-5 3 1-6-4-4 5.5-.5z"/></svg></span>'
1602};
1603function pinClassFor(loc){
1604  if (loc.type === '360' || loc.type === '360video') return 'is-360';
1605  if (loc.type === 'video') return 'is-video';
1606  const k = loc.kind || loc.type;
1607  if (PIN_ICON[k]) return 'is-' + k;
1608  return 'is-photo';
1609}
1610
1611/* Copyright line: always "© All rights reserved", plus the holder from the
1612   sidecar 'credit' field if present. Strips a stray leading © / "all rights
1613   reserved" so legacy credits don't duplicate. */
1614function copyrightText(loc){
1615  let owner = (loc && loc.credit ? String(loc.credit) : '').trim();
1616  owner = owner.replace(/^©\s*/, '').replace(/^all rights reserved\b[\s·\-—]*/i, '').trim();
1617  return '© All rights reserved' + (owner ? ' · ' + owner : '');
1618}
1619
1620/* ---------- Shareable deep links ---------- */
1621function toast(msg){
1622  let el = document.getElementById('pi-toast');
1623  if (!el){ el = document.createElement('div'); el.id = 'pi-toast';
1624    el.style.cssText = 'position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:100000;background:#111;color:#fff;padding:11px 18px;border-radius:10px;font-weight:600;font-size:14px;box-shadow:0 8px 24px rgba(0,0,0,.35);opacity:0;transition:opacity .2s;max-width:90vw;text-align:center';
1625    document.body.appendChild(el); }
1626  el.textContent = msg; el.style.opacity = '1';
1627  clearTimeout(el._t); el._t = setTimeout(() => { el.style.opacity = '0'; }, 2200);
1628}
1629/*
1630 * Open the share dialog for an item.
1631 *
1632 * This used to hand straight to navigator.share, or copy silently and claim it
1633 * had. Both are wrong here: the native sheet only exists on phones, and a
1634 * clipboard write from inside an iframe needs a permission the host page has to
1635 * grant, so on a desktop the button did nothing at all and said it had worked.
1636 * Showing the link solves both — the copy button is a convenience on top of a
1637 * box you can always select yourself.
1638 */
1639function shareItem(id, title, extra){
1640  if (!id) return;
1641  /* Through share.php, not straight at the map.
1642     This page's Open Graph tags are the same on all three sites — it is one
1643     file — so a link to it previews under whichever name is written into it.
1644     share.php reads brand.php and the shared item, and writes tags that say
1645     the right site, the right title and show the right picture. It sends a
1646     person on to this page immediately. */
1647  const dir = location.pathname.replace(/[^/]*$/, '');
1648  const url = location.origin + dir + 'share.php?item=' + encodeURIComponent(id) + (extra || '');
1649  const box = document.getElementById('share-url');
1650  box.value = url;
1651  document.getElementById('share-name').textContent = title || '';
1652  const st = document.getElementById('share-status'); st.textContent = ''; st.className = 'req-status';
1653  // The native sheet stays available where it exists, as a second option
1654  // rather than the only one.
1655  const nat = document.getElementById('share-native');
1656  if (navigator.share){
1657    nat.style.display = '';
1658    nat.onclick = () => navigator.share({ title: title || document.title, url }).catch(()=>{});
1659  } else { nat.style.display = 'none'; nat.onclick = null; }
1660  const modal = document.getElementById('share-modal');
1661  modal.classList.add('open'); modal.setAttribute('aria-hidden', 'false');
1662  setTimeout(() => { try { box.focus(); box.select(); } catch(e){} }, 30);
1663}
1664function closeShareModal(){
1665  const modal = document.getElementById('share-modal');
1666  modal.classList.remove('open'); modal.setAttribute('aria-hidden', 'true');
1667}
1668function copyShareUrl(){
1669  const box = document.getElementById('share-url');
1670  const st  = document.getElementById('share-status');
1671  const done = () => { st.className = 'req-status ok'; st.textContent = 'Link copied.'; };
1672  try { box.focus(); box.select(); box.setSelectionRange(0, box.value.length); } catch(e){}
1673  if (navigator.clipboard && navigator.clipboard.writeText){
1674    navigator.clipboard.writeText(box.value).then(done).catch(() => {
1675      // Blocked (usually a frame without clipboard-write). The old selection
1676      // trick still works in that case.
1677      if (document.execCommand && document.execCommand('copy')) done();
1678      else { st.className = 'req-status'; st.textContent = 'Press Ctrl+C to copy the highlighted link.'; }
1679    });
1680    return;
1681  }
1682  if (document.execCommand && document.execCommand('copy')) done();
1683  else { st.className = 'req-status'; st.textContent = 'Press Ctrl+C to copy the highlighted link.'; }
1684}
1685
1686// --- YouTube 360 videos (designated in youtube-360.json) ------------------
1687// These stream from YouTube (no buffering) and open in YouTube's spherical
1688// player (drag to look + its own VR mode). They are merged in as normal pins.
1689function ytId(s){
1690  s = String(s || '').trim();
1691  const m = s.match(/(?:youtu\.be\/|[?&]v=|\/embed\/|\/shorts\/)([A-Za-z0-9_-]{11})/);
1692  if (m) return m[1];
1693  return /^[A-Za-z0-9_-]{11}$/.test(s) ? s : '';
1694}
1695function loadYouTube360(){
1696  return fetch('youtube-360.json')
1697    .then(r => r.ok ? r.json() : { items: [] })
1698    .then(d => (d.items || []).map(it => {
1699      const vid = ytId(it.id || it.url || it.video || '');
1700      if (!vid || it.lat == null || it.lng == null) return null;
1701      const thumb = 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg';
1702      return { id: 'yt-' + vid, title: it.title || '360° video', description: it.description || '',
1703               category: it.category || '360° Video', section: it.section || '',
1704               type: '360video', kind: 'video', lat: +it.lat, lng: +it.lng, bearing: 0,
1705               youtube: vid, image: thumb, thumbnail: thumb,
1706               link: it.link || ('https://www.youtube.com/watch?v=' + vid), credit: it.credit || '' };
1707    }).filter(Boolean))
1708    .catch(() => []);
1709}
1710
1711// CambsNews geo-tagged posts, pulled live from the WordPress endpoint (same-origin,
1712// no auth). Editors pin a post in WP -> it appears here automatically. 404s
1713// harmlessly on the other sites, so this shared file is safe everywhere.
1714function loadStoryPosts(){
1715  const decode = s => { const d = document.createElement('textarea'); d.innerHTML = String(s || ''); return d.value; };
1716  return fetch('/wp-json/cn/v1/map-posts?_=' + Date.now())
1717    .then(r => r.ok ? r.json() : [])
1718    .then(list => {
1719      const items = (Array.isArray(list) ? list : []).filter(p => p && p.lat != null && p.lng != null).map(p => {
1720        const slug = String(p.slug || '').replace(/^\/+|\/+$/g, '');
1721        const link = p.url || (slug ? 'https://www.cambsnews.co.uk/' + slug + '/' : '');
1722        return { id: 'cn-' + p.id, _pid: p.id, title: p.title || 'Story',
1723                 description: p.excerpt || p.description || '',
1724                 category: 'News stories', section: '', type: 'story', kind: 'story',
1725                 lat: +p.lat, lng: +p.lng, bearing: 0,
1726                 image: p.img || '', thumbnail: p.img || '', link: link, credit: '' };
1727      });
1728      // The map-posts feed has no blurb, so pull excerpts from the standard WP REST API.
1729      const need = items.filter(it => !it.description && it._pid != null).map(it => it._pid);
1730      if (!need.length) return items;
1731      return fetch('/wp-json/wp/v2/posts?_fields=id,excerpt&per_page=100&include=' + need.join(','))
1732        .then(r => r.ok ? r.json() : [])
1733        .then(rows => {
1734          const ex = {};
1735          (Array.isArray(rows) ? rows : []).forEach(row => {
1736            let t = String((row.excerpt && row.excerpt.rendered) || '').replace(/<[^>]*>/g, '');
1737            t = decode(t).replace(/\[…\]|\[\.\.\.\]/g, '').replace(/\s+/g, ' ').trim();
1738            if (row.id != null) ex[row.id] = t;
1739          });
1740          items.forEach(it => { if (!it.description && ex[it._pid]) it.description = ex[it._pid]; });
1741          return items;
1742        }).catch(() => items);
1743    })
1744    .catch(() => []);
1745}
1746
1747// Auto-pinned events from the brand website's events calendar (server-side feed,
1748// placed by venue postcode). Empty on sites without an events source.
1749function loadAutoEvents(){
1750  /*
1751   * Discover reads its events from the toolkit, where they are authored, and
1752   * gets the ticket link, the categories and the all-day flag with them. The
1753   * other two sites keep the local scraper until they have a toolkit of their
1754   * own, so the fallback is not dead code.
1755   */
1756  const cfg = (window.BRAND || {}).events_feed;
1757  const url = (cfg && cfg.enabled && cfg.url) ? cfg.url : 'events-feed.php';
1758  return fetch(url, { cache: 'no-cache' })
1759    .then(r => r.ok ? r.json() : { locations: [] })
1760    .then(d => (d && Array.isArray(d.locations)) ? d.locations : [])
1761    .catch(() => []);
1762}
1763
1764// Local business listings, exported from the CRM (brand 'businesses' feed).
1765// Clickable pins with opening times, address, phone and website.
1766function loadBusinesses(){
1767  const cfg = (window.BRAND || {}).businesses;
1768  if (!cfg || !cfg.enabled || !cfg.url) return Promise.resolve([]);
1769  return fetch(cfg.url, { cache: 'no-cache' })
1770    .then(r => r.ok ? r.json() : { businesses: [] })
1771    .then(d => {
1772      // Accept either GeoJSON (CRM export: FeatureCollection of Points with
1773      // properties) or the plain {businesses:[…]} array.
1774      if (d && d.type === 'FeatureCollection'){
1775        return (d.features || []).map(f => {
1776          const p = (f && f.properties) || {};
1777          const c = (f && f.geometry && f.geometry.coordinates) || [];
1778          return Object.assign({}, p, {
1779            lat: p.lat != null ? p.lat : c[1],
1780            lng: p.lng != null ? p.lng : c[0],
1781            address: [p.address, p.postcode].filter(Boolean).join(', '),
1782            link: p.booking || p.booking_url || p.link || ''
1783          });
1784        });
1785      }
1786      return (d && d.businesses) || [];
1787    })
1788    .then(rows => rows.filter(b => b && b.lat != null && b.lng != null && b.name).map(b => ({
1789      id: 'biz-' + (b.id || b.name.toLowerCase().replace(/\W+/g, '-')),
1790      title: b.name, description: b.description || '',
1791      category: b.category || 'Local businesses', section: '',
1792      type: 'business', kind: 'business',
1793      badge: (function(c){ c = String(c || 'Local');
1794        return (c.length <= 12 ? c : c.split(/\s+/).pop()).toUpperCase().slice(0, 12); })(b.category),
1795      lat: +b.lat, lng: +b.lng, bearing: 0,
1796      image: b.image || '', thumbnail: b.image || '',
1797      link: b.link || b.website || '', credit: '',
1798      address: b.address || '', phone: b.phone || '',
1799      website: b.website || '', opening_hours: b.opening_hours || b.hours || ''
1800    })))
1801    .catch(() => []);
1802}
1803/*
1804 * A second media list, merged into the first.
1805 *
1806 * `locations.php` scans the images folder on this server, which finds every
1807 * file that was uploaded by FTP or through the old admin panel — but not one
1808 * uploaded through the toolkit, whose files live in the toolkit's own storage
1809 * and were never in the folder at all.
1810 *
1811 * Merged rather than swapped. The obvious move was to point this site at the
1812 * toolkit's feed instead, and it would have cost eleven attractions and an
1813 * event: they come from `attractions.json` here and have no equivalent in the
1814 * toolkit's tables yet. Additive means nothing on the map can disappear when
1815 * the second feed is wrong, empty or unreachable, which is the only property
1816 * worth having in a loader that a public map cannot run without.
1817 *
1818 * Anything already present wins — the file on disk has the sidecar, so it
1819 * carries the alignment and the arrows somebody sat and did.
1820 *
1821 * Per-site via BRAND, because one index.html serves all three.
1822 */
1823function loadExtraLocations(){
1824  const url = (window.BRAND || {}).extraLocationsUrl;
1825  if (!url) return Promise.resolve([]);
1826  return fetch(url)
1827    .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
1828    .then(d => (d && d.locations) || [])
1829    .catch(() => []);
1830}
1831
1832Promise.all([
1833  fetch((window.BRAND || {}).locationsUrl || 'locations.php').then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }),
1834  loadExtraLocations(),
1835  loadYouTube360(),
1836  loadStoryPosts(),
1837  loadAutoEvents(),
1838  loadBusinesses()
1839])
1840  .then(([data, extra, yt, sp, ev, biz]) => {
1841    data = data || {};
1842    if (data.error) {
1843      document.getElementById('location-list').innerHTML =
1844        '<div class="cn360-empty"><strong>Couldn’t scan the library</strong>' + escapeHtml(data.error) + '</div>';
1845      buildMap([[52.5733, -0.2386]]);
1846      return;
1847    }
1848    const _base = (data.locations || []).filter(l => l.lat && l.lng && l.image);
1849    // Merge the second feed, dropping anything the folder scan already found.
1850    const _haveId = new Set(_base.map(l => l.id));
1851    const _extra = (extra || []).filter(l => l.lat && l.lng && l.image && !_haveId.has(l.id));
1852    locations = _base.concat(_extra).concat(yt || []).concat(sp || []).concat(ev || []).concat(biz || []);
1853    // A story/event can be both auto-pinned (feed) and manually imported — keep one.
1854    const _seenLink = new Set();
1855    locations = locations.filter(l => {
1856      const k0 = l.kind || l.type;
1857      if ((k0 !== 'story' && k0 !== 'event') || !l.link) return true;
1858      const k = l.link.replace(/\/+$/, '').toLowerCase();
1859      if (_seenLink.has(k)) return false;
1860      _seenLink.add(k); return true;
1861    });
1862    // Recurring events (same title at the same venue, different dates) collapse
1863    // into ONE pin; the modal then offers the full list of dates to pick from.
1864    (function groupEventSeries(){
1865      const groups = {};
1866      locations.forEach(l => {
1867        if ((l.kind || l.type) !== 'event' || !l.starts_at) return;
1868        const key = (l.title || '').toLowerCase().trim() + '|' + (l.address || '').toLowerCase().trim();
1869        (groups[key] = groups[key] || []).push(l);
1870      });
1871      const drop = new Set();
1872      Object.values(groups).forEach(g => {
1873        if (g.length < 2) return;
1874        g.sort((a, b) => String(a.starts_at).localeCompare(String(b.starts_at)));
1875        const master = g[0];
1876        // `website` travels with each occurrence so a date pill can go to the
1877        // organiser's own page for that date, where they publish one.
1878        /*
1879         * The run's span, not a list to pick from.
1880         *
1881         * A seven-night circus was seven pills somebody had to read before they
1882         * knew what they were looking at. One pin reading "9 - 13 September"
1883         * answers what the card is actually asked. The toolkit's feed already
1884         * sends a span per event; this keeps the same result for the older
1885         * feed, which still sends one row per date.
1886         */
1887        const last = g[g.length - 1];
1888        master.ends_at = last.ends_at || last.starts_at;
1889        master.dates_count = g.length;
1890        g.slice(1).forEach(x => drop.add(x));
1891      });
1892      if (drop.size) locations = locations.filter(l => !drop.has(l));
1893    })();
1894    spreadColocated(locations);   // fan out pins that share an identical point
1895    if (!locations.length) {
1896      document.getElementById('location-list').innerHTML =
1897        '<div class="cn360-empty"><strong>No media yet</strong>Add photos, panoramas or video under <code>/images/</code> — they’ll appear here automatically.</div>';
1898      buildMap([[52.5733, -0.2386]]);
1899      return;
1900    }
1901    buildMap(locations.map(l => [l.lat, l.lng]));
1902    order = locations.map((_, i) => i);
1903    buildLayers();
1904    loadDistricts();
1905    addAllMarkers();
1906    buildMenu();
1907    initExploreUI();
1908    // Returning from VR/360 (?focus=<id>) — land on the MAP centred on that
1909    // spot (don't reopen the viewer, which made "Back to map" feel like two clicks).
1910    const focusId = new URLSearchParams(location.search).get('focus');
1911    if (focusId){
1912      const fl = locations.find(l => l.id === focusId);
1913      if (fl && fl.lat != null && fl.lng != null){
1914        setTimeout(() =>
1914 { try { map && map.setView([fl.lat, fl.lng], Math.max(map.getZoom() || 0, 16)); } catch(e){} }, 250);
1915      }
1916    }
1917    // Deep link to a specific item (?item=<id>) — open it directly.
1918    const openId = new URLSearchParams(location.search).get('item');
1919    if (openId){
1920      // ?more=<pointId> arrives with a shared Discover More link: open the pano,
1921      // then let the viewer's ready handler turn to the point and show it.
1922      const wantMore = new URLSearchParams(location.search).get('more');
1923      if (wantMore) dmPendingDeep = wantMore;
1924      const oi = locations.findIndex(l => l.id === openId);
1925      if (oi >= 0) setTimeout(() => openItem(oi), 350);
1926    }
1927    // Deep link from a CambsNews story badge (?post=<id> or #p<id>).
1928    // Arrivals land zoomed on the pin's area — no story card popup (they've
1929    // just come FROM the story), just a brief pulse so the pin identifies itself.
1930    const wantPost = new URLSearchParams(location.search).get('post') || (location.hash.match(/^#p(\d+)/) || [])[1];
1931    if (wantPost){
1932      const pi = locations.findIndex(l => l.id === 'cn-' + wantPost);
1933      if (pi >= 0){
1934        const pl = locations[pi];
1935        setTimeout(() => {
1936          try { map && map.setView([pl.lat, pl.lng], 16); } catch(e){}
1937          setTimeout(() => {
1938            const m = markers[pi]; const el = m && m.getElement && m.getElement();
1939            const pin = el && el.querySelector('.cn360-pin');
1940            if (pin){ pin.classList.add('pulse'); setTimeout(() => pin.classList.remove('pulse'), 900); }
1941          }, 700);
1942        }, 400);
1943      }
1944    }
1945  })
1946  .catch(err => {
1947    document.getElementById('location-list').innerHTML =
1948      '<div class="cn360-empty"><strong>Couldn’t load locations</strong>'
1949      + escapeHtml(err.message || 'locations.php fetch failed') + '</div>';
1950    buildMap([[52.5733, -0.2386]]);
1951  });
1952
1953/* ---------- Base maps (streets / satellite / topo), choice remembered ---- */
1954const BASEMAPS = {
1955  streets: { label: 'Streets', layer: () => L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
1956    maxZoom: 19,
1957    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' }) },
1958  satellite: { label: 'Satellite', layer: () => L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
1959    maxZoom: 19,
1960    attribution: 'Imagery &copy; Esri, Maxar, Earthstar Geographics' }) },
1961  topo: { label: 'Terrain', layer: () => L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
1962    maxZoom: 17,
1963    attribution: 'Map data &copy; OpenStreetMap contributors, SRTM | Style &copy; <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)' }) },
1964};
1965let baseLayer = null;
1966let activeBase = localStorage.getItem('pi_basemap') || 'streets';
1967if (!BASEMAPS[activeBase]) activeBase = 'streets';
1968function setBaseMap(key){
1969  if (!BASEMAPS[key] || !map) return;
1970  if (baseLayer) map.removeLayer(baseLayer);
1971  baseLayer = BASEMAPS[key].layer().addTo(map);
1972  activeBase = key;
1973  try { localStorage.setItem('pi_basemap', key); } catch(e){}
1974}
1975
1976function buildMap(latlngs){
1977  map = L.map('leaflet-map', { zoomControl: true, scrollWheelZoom: true });
1978  setBaseMap(activeBase);
1979  fitToLatLngs(latlngs);
1980
1981  cluster = L.markerClusterGroup({
1982    maxClusterRadius: 60,
1983    showCoverageOnHover: false,
1984    spiderfyOnMaxZoom: true,
1985    spiderfyDistanceMultiplier: 3,   // fan co-located cards out further when opened
1986    zoomToBoundsOnClick: true,
1987    maxClusterRadius: 110,           // cards are ~96px wide: closer than this bunches into a number
1988    iconCreateFunction: function(c){
1989      const n = c.getChildCount();
1990      const sizeClass = n < 5 ? 'small' : n < 15 ? 'medium' : 'large';
1991      return L.divIcon({ className:'cn360-cluster-wrap', html:`<div class="cn360-cluster ${sizeClass}">${n}</div>`, iconSize:[50,50] });
1992    }
1993  });
1994  map.addLayer(cluster);
1995
1996  const updateZoomClass = () => {
1997    const col = document.querySelector('.cn360-mapcol');
1998    if(col) col.classList.toggle('zoomed-out', map.getZoom() < 14);
1999  };
2000  map.on('zoomend', updateZoomClass);
2001  updateZoomClass();
2002}
2003
2004function fitToLatLngs(latlngs){
2005  if (!latlngs.length){ map.setView([52.5733,-0.2386], 13); return; }
2006  if (latlngs.length === 1){ map.setView(latlngs[0], 15); return; }
2007  const isMobile = window.matchMedia('(max-width: 880px)').matches;
2008  map.fitBounds(latlngs, {
2009    paddingTopLeft:     [isMobile?60:80, isMobile?80:60],
2010    paddingBottomRight: [40, 40]
2011  });
2012}
2013
2014/* ---------- Sidebar menu: Category ▸ Section ▸ items (accordion) ---------- */
2015/* Lazy thumbnails: cards carry data-thumb and the image is only fetched when
2016   the card scrolls near the viewport — so a large imported library no longer
2017   downloads every thumbnail (tens of MB) on page load. */
2018const thumbIO = ('IntersectionObserver' in window)
2019  ? new IntersectionObserver(es => es.forEach(e => {
2020      if (!e.isIntersecting) return;
2021      e.target.style.backgroundImage = "url('" + e.target.dataset.thumb + "')";
2022      thumbIO.unobserve(e.target);
2023    }), { rootMargin: '400px' })
2024  : null;
2025function lazyThumbs(root){
2026  (root || document).querySelectorAll('[data-thumb]').forEach(el => {
2027    if (el.dataset.thumbDone) return;
2028    el.dataset.thumbDone = '1';
2029    if (thumbIO) thumbIO.observe(el);
2030    else el.style.backgroundImage = "url('" + el.dataset.thumb + "')";
2031  });
2032}
2033
2034function cardHtml(i){
2035  const loc = locations[i];
2036  const hasThumb = !!loc.thumbnail;
2037  const thumbAttr = hasThumb ? ` data-thumb="${escapeAttr(loc.thumbnail)}"` : '';
2038  const playGlyph = (loc.type === 'video' && !hasThumb) ? '<span class="play">▶</span>' : '';
2039  const meta = loc.description ? escapeHtml(loc.description) : '';
2040  const tick = (loc.type === '360' && loc.calibrated) ? '<span class="cal-tick" title="Hotspots aligned">✓</span>' : '';
2041  return `
2042    <div class="cn360-card" data-idx="${i}">
2043      <div class="thumb"${thumbAttr}>${playGlyph}<span class="badge">${TYPE_BADGE[loc.type]||''}</span></div>
2044      <div>
2045        <div class="title">${escapeHtml(loc.title)}${tick}</div>
2046        ${meta ? `<div class="meta">${meta}</div>` : ''}
2047      </div>
2048    </div>`;
2049}
2050
2051function buildMenu(){
2052  const list = document.getElementById('location-list');
2053  if (!order.length){
2054    list.innerHTML = '<div class="cn360-empty"><strong>Nothing on the map yet</strong>Items appear here once they have a location.</div>';
2055    return;
2056  }
2057  // Group: category -> section -> [idx], preserving order.
2058  // The list honours the collection picker (other imports are hidden entirely).
2059  const cats = {};
2060  order.forEach(i => {
2061    const loc = locations[i];
2062    if (activeCollection){ const cc = collectionOf(loc); if (cc && cc !== activeCollection) return; }
2063    const c = loc.category || 'Other';
2064    const s = loc.section || '';
2065    (cats[c] = cats[c] || {});
2066    (cats[c][s] = cats[c][s] || []).push(i);
2067  });
2068  const catNames = Object.keys(cats);
2069  let html = '';
2070  catNames.forEach((c, ci) => {
2071    const sections = cats[c];
2072    const total = Object.values(sections).reduce((n, a) => n + a.length, 0);
2073    const openCls = (catNames.length === 1 || ci === 0) ? ' open' : '';   // open first by default
2074    const secNames = Object.keys(sections);
2075    const hasSections = secNames.some(s => s !== '');
2076    // Admin: group-level alignment state for the 360s inside (solid tick = all
2077    // aligned, struck tick = only some, nothing = none yet / no 360s).
2078    const catIdxs = Object.values(sections).flat();
2079    const cat360  = catIdxs.filter(i => locations[i].type === '360');
2080    const catDone = cat360.filter(i => locations[i].calibrated).length;
2081    let calBadge = '';
2082    if (cat360.length){
2083      if (catDone === cat360.length) calBadge = `<span class="cat-cal all" title="All ${cat360.length} 360s aligned">✓</span>`;
2084      else if (catDone > 0)          calBadge = `<span class="cat-cal some" title="${catDone} of ${cat360.length} 360s aligned">✓</span>`;
2085    }
2086    html += `<div class="cat-group${openCls}" data-cat="${escapeAttr(c)}">`;
2087    html +=   `<button class="cat-toggle" type="button"><span class="chev">▸</span><span class="cat-name">${escapeHtml(c)}</span>${calBadge}<span class="count">${total}</span></button>`;
2088    html +=   `<div class="cat-body">`;
2089    secNames.forEach(s => {
2090      if (hasSections){
2091        html += `<div class="sec-group" data-sec="${escapeAttr(s)}">`;
2092        html +=   `<button class="sec-head" type="button">${escapeHtml(s || 'General')}</button>`;
2093      }
2094      sections[s].forEach(i => { html += cardHtml(i); });
2095      if (hasSections) html += `</div>`;
2096    });
2097    html +=   `</div></div>`;
2098  });
2099  list.innerHTML = html;
2100  lazyThumbs(list);
2101
2102  // Wire interactions
2103  list.querySelectorAll('.cat-group').forEach(group => {
2104    const cat = group.dataset.cat;
2105    group.querySelector('.cat-toggle').onclick = () => {
2106      const opening = !group.classList.contains('open');
2107      // Accordion: opening one closes the others, so the list stays short.
2108      if (opening) list.querySelectorAll('.cat-group.open').forEach(g => { if (g !== group) g.classList.remove('open'); });
2109      group.classList.toggle('open');
2110      if (opening) fitToCat(cat);
2111    };
2112    group.querySelectorAll('.sec-group').forEach(sg => {
2113      sg.querySelector('.sec-head').onclick = () => fitToSection(cat, sg.dataset.sec);
2114    });
2115  });
2116  list.querySelectorAll('.cn360-card').forEach(card => {
2117    card.onclick = () => openItem(parseInt(card.dataset.idx, 10));
2118  });
2119}
2120
2121function fitToCat(cat){
2122  const pts = order.filter(i => locations[i].category === cat).map(i => [locations[i].lat, locations[i].lng]);
2123  if (pts.length && map) fitToLatLngs(pts);
2124}
2125function fitToSection(cat, sec){
2126  const pts = order.filter(i => locations[i].category === cat && (locations[i].section || '') === sec)
2127                   .map(i => [locations[i].lat, locations[i].lng]);
2128  if (pts.length && map) fitToLatLngs(pts);
2129}
2130
2131/* ==================== EXPLORE UI (brand.explore_ui) ====================
2132   Full-bleed map with a floating search pill, category chips and a results
2133   rail (bottom sheet on mobile). The classic sidebar is hidden; the old
2134   Layers panel + journey planner live on inside the "Map options" popover. */
2135let xCurrent = [];
2136function initExploreUI(){
2137  const B = window.BRAND || {};
2138  if (!B.exploreUi || !map || document.getElementById('x-col')) return;
2139  document.body.classList.add('xui');
2140  // the sidebar column just collapsed: tell Leaflet the map got wider
2141  setTimeout(() => map.invalidateSize(), 60);
2142  setTimeout(() => map.invalidateSize(), 450);
2143  if (map.zoomControl) map.zoomControl.setPosition('topright');   // search pill owns top-left
2144  const mapcol = document.querySelector('.cn360-mapcol');
2145  if (!mapcol) return;
2146
2147  const col = document.createElement('div');
2148  col.id = 'x-col';
2149  col.innerHTML =
2150      '<div class="x-pill"><span style="display:inline-flex;padding-left:8px">'
2151    + '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round"><circle cx="10.5" cy="10.5" r="6.5"/><line x1="15.5" y1="15.5" x2="21" y2="21"/></svg></span>'
2152    + '<input id="x-q" placeholder="Search places, 360° tours, events…" autocomplete="off">'
2153    + '<button id="x-dir" class="x-dir" title="Show these on the map">➤</button></div>'
2154    + '<div class="x-chips" id="x-chips"></div>'
2155    + '<div id="x-rail" data-snap="half"><div class="x-grab"></div>'
2156    + '<div class="x-rail-h"><span id="x-count">…</span><b id="x-gear">⚙ Map options</b></div>'
2157    + '<div id="x-results"></div></div>';
2158  mapcol.appendChild(col);
2159
2160  const pop = document.createElement('div');
2161  pop.id = 'x-pop';
2162  pop.innerHTML = '<button id="x-pop-close">✕</button><h3>Map options</h3>';
2163  mapcol.appendChild(pop);
2164  const fab = document.createElement('button');
2165  fab.id = 'x-gear-fab';
2166  fab.type = 'button';
2167  fab.title = 'Map options';
2168  fab.textContent = '⚙';
2169  fab.addEventListener('click', () => pop.classList.toggle('open'));
2170  mapcol.appendChild(fab);
2171  // the old controls move into the popover — nothing is lost, just re-homed
2172  const lp = document.getElementById('layers-panel');
2173  if (lp){ pop.appendChild(lp); lp.hidden = false; }
2174
2175  // ---- chips (from brand layers) ----
2176  const chipsEl = document.getElementById('x-chips');
2177  const chipDefs = [{ key: '', label: '✦ All' }]
2178    .concat((B.layers || []).map(l => ({ key: l.key, label: l.label })));
2179  chipsEl.innerHTML = chipDefs.map(c =>
2180    '<span class="x-chip" data-k="' + escapeAttr(c.key) + '">' + escapeHtml(c.label) + '</span>').join('');
2181  const paintChips = () => {
2182    const all = [...LAYER_KEYS].every(k => activeLayers.has(k));
2183    chipsEl.querySelectorAll('.x-chip').forEach(el => {
2184      const k = el.dataset.k;
2185      el.classList.toggle('on', all ? k === '' : (k !== '' && activeLayers.has(k)));
2186    });
2187  };
2188  // mouse support: drag the strip sideways, or use the scroll wheel
2189  let chipDX = null, chipSL = 0;
2190  chipsEl.addEventListener('pointerdown', e => { chipDX = e.clientX; chipSL = chipsEl.scrollLeft; });
2191  chipsEl.addEventListener('pointermove', e => {
2192    if (chipDX === null) return;
2193    if (Math.abs(e.clientX - chipDX) > 5){
2194      chipsEl.scrollLeft = chipSL - (e.clientX - chipDX);
2195      chipsEl._dragged = true;
2196    }
2197  });
2198  window.addEventListener('pointerup', () => { chipDX = null; setTimeout(() => { chipsEl._dragged = false; }, 0); });
2199  chipsEl.addEventListener('wheel', e => {
2200    if (e.deltaY){ chipsEl.scrollLeft += e.deltaY; e.preventDefault(); }
2201  }, { passive: false });
2202  chipsEl.addEventListener('click', e => {
2203    if (chipsEl._dragged) return;   // that was a drag, not a pick
2204    const el = e.target.closest('.x-chip');
2205    if (!el) return;
2206    const k = el.dataset.k;
2207    let soloed = false;
2208    if (k === '' || (activeLayers.has(k) && activeLayers.size === 1)){
2209      LAYER_KEYS.forEach(x => activeLayers.add(x));      // back to everything
2210    } else {
2211      activeLayers.clear(); activeLayers.add(k);          // solo this layer
2212      soloed = true;
2213    }
2214    document.querySelectorAll('#layers-panel input[data-layer]')
2215      .forEach(c => { c.checked = activeLayers.has(c.dataset.layer); });
2216    addAllMarkers();
2217    paintChips();
2218    if (soloed){
2219      // zoom to fit everything in this category so it's all on show
2220      const pts = [];
2221      locations.forEach(l => { if (l.lat && l.lng && passesFilters(l)) pts.push([l.lat, l.lng]); });
2222      if (pts.length) fitToLatLngs(pts);
2223    }
2224    setTimeout(xRefresh, 120);   // let the clusters settle first
2225  });
2226  paintChips();
2227
2228  // ---- popover + directions ----
2229  const togglePop = on => pop.classList.toggle('open', on);
2230  document.getElementById('x-gear').addEventListener('click', () => pop.classList.toggle('open'));
2231  document.getElementById('x-pop-close').addEventListener('click', () => togglePop(false));
2232  /*
2233   * The arrow beside the search box moves the map to what you searched for.
2234   *
2235   * It used to open the journey planner, which is a reasonable thing for an
2236   * arrow to mean and the wrong thing for an arrow *inside a search box* to
2237   * mean: type "cathedral", press the button next to the words you typed, and
2238   * a directions panel appears over a map that has not moved. Journey planning
2239   * is still one click away under Map options, where the rest of the tools are.
2240   *
2241   * One result zooms to it. Several fit them all in view, which is the honest
2242   * answer to "where are these" and stops a search for a category throwing the
2243   * map at whichever match happened to sort first.
2244   */
2245  function showResultsOnMap(){
2246    if (!xCurrent.length) return;
2247
2248    const points = xCurrent
2249      .map(i => locations[i])
2250      .filter(l => l && l.lat != null && l.lng != null)
2251      .map(l => [l.lat, l.lng]);
2252
2253    if (!points.length) return;
2254
2255    if (points.length === 1){
2256      map.setView(points[0], Math.max(map.getZoom(), 15));
2257
2258      return;
2259    }
2260
2261    // maxZoom so two pins in the same car park do not slam the map to street
2262    // level; padding so the results are not tucked under the search panel.
2263    map.fitBounds(L.latLngBounds(points), { paddingTopLeft: [400, 90], paddingBottomRight: [60, 60], maxZoom: 16 });
2264  }
2265
2266  document.getElementById('x-dir').addEventListener('click', showResultsOnMap);
2267
2268  // ---- search ----
2269  const q = document.getElementById('x-q');
2270  q.addEventListener('input', xRefresh);
2271  q.addEventListener('keydown', e => {
2272    // Enter and the arrow are the same action. They were not, and the
2273    // difference — first result versus all of them — was invisible.
2274    if (e.key === 'Enter') showResultsOnMap();
2275  });
2276  map.on('moveend', xRefresh);
2277  if (cluster){
2278    cluster.on('spiderfied', () => setTimeout(xRefresh, 60));     // cluster fanned open
2279    cluster.on('unspiderfied', () => setTimeout(xRefresh, 60));
2280    cluster.on('animationend', () => setTimeout(xRefresh, 60));   // clusters split/merge after zoom
2281  }
2282
2283  // ---- mobile bottom sheet snapping ----
2284  const rail = document.getElementById('x-rail');
2285  const grab = rail.querySelector('.x-grab');
2286  const snaps = ['peek', 'half', 'full'];
2287  let sy = null;
2288  grab.addEventListener('touchstart', e => { sy = e.touches[0].clientY; }, { passive: true });
2289  grab.addEventListener('touchend', e => {
2290    if (sy === null) return;
2291    const dy = e.changedTouches[0].clientY - sy; sy = null;
2292    let i = snaps.indexOf(rail.dataset.snap || 'half');
2293    if (dy < -25) i = Math.min(2, i + 1);
2294    else if (dy > 25) i = Math.max(0, i - 1);
2295    rail.dataset.snap = snaps[i];
2296  });
2297  grab.addEventListener('click', () => {
2298    rail.dataset.snap = rail.dataset.snap === 'peek' ? 'half'
2299                      : rail.dataset.snap === 'half' ? 'full' : 'peek';
2300  });
2301  if (matchMedia('(max-width:880px)').matches) rail.dataset.snap = 'peek';
2302
2303  xRefresh();
2304}
2305function xRefresh(){
2306  const res = document.getElementById('x-results');
2307  if (!res || !map) return;
2308  const qv = (document.getElementById('x-q').value || '').trim().toLowerCase();
2309  const b = map.getBounds(), c = map.getCenter();
2310  const scored = [];
2311  locations.forEach((l, i) => {
2312    if (!l.lat || !l.lng) return;
2313    if (!passesFilters(l)) return;
2314    if (qv){
2315      if (!((l.title || '') + ' ' + (l.description || '') + ' ' + (l.category || '')).toLowerCase().includes(qv)) return;
2316    } else {
2317      if (!b.contains([l.lat, l.lng])) return;
2318      // In "All" mode, only list pins actually visible — not ones inside a
2319      // numbered cluster. When a single category is soloed, list everything
2320      // in view (that's a browse of the category).
2321      const allMode = [...LAYER_KEYS].every(k => activeLayers.has(k));
2322      if (allMode){
2323        const m = markers[i];
2324        if (!m || !m._icon) return;
2325      }
2326    }
2327    scored.push([Math.hypot((l.lat - c.lat) * 111320, (l.lng - c.lng) * 67800), i]);
2328  });
2329  scored.sort((a, z) => a[0] - z[0]);
2330  const top = scored.slice(0, 60);
2331  xCurrent = top.map(s => s[1]);
2332  const cnt = document.getElementById('x-count');
2333  if (cnt) cnt.textContent = qv
2334    ? scored.length + (scored.length === 1 ? ' match' : ' matches')
2335    : scored.length + (scored.length === 1 ? ' place in view' : ' places in view');
2336  res.innerHTML = top.map(([d, i]) => {
2337    const l = locations[i];
2338    const badge = badgeFor(l);
2339    const im = l.thumbnail ? ' data-thumb="' + escapeAttr(l.thumbnail) + '"' : '';
2340    const sub = [l.category || '', typeof jpFmtDist === 'function' ? jpFmtDist(d) : '']
2341      .filter(Boolean).join(' · ');
2342    return '<div class="x-card" data-i="' + i + '"><div class="xim"' + im + '>'
2343      + (badge ? '<span class="xbadge">' + escapeHtml(badge) + '</span>' : '') + '</div>'
2344      + '<div><h4>' + escapeHtml(l.title || '') + '</h4>'
2345      + (sub ? '<p>' + escapeHtml(sub) + '</p>' : '') + '</div></div>';
2346  }).join('') || '<div style="padding:18px;font-size:13px;color:#5b6470">Nothing here — zoom out, clear the search, or tap ✦ All.</div>';
2347  lazyThumbs(res);
2348  res.querySelectorAll('.x-card').forEach(el =>
2349    el.addEventListener('click', () => openItem(+el.dataset.i)));
2350}
2351
2352/* ---------- Layer filters (BRAND.layers + BRAND.storyTags) ---------- */
2353const activeLayers = new Set();
2354const activeTags   = new Set();
2355let   LAYER_KEYS   = new Set();   // which keys the panel actually controls
2356
2357/* Which layer a location belongs to. loc.kind (event/story/seasonal/…) wins
2358   when present; otherwise fall back to its media type. */
2359function layerKeyOf(loc){
2360  if (loc.kind) return loc.kind;
2361  if (loc.type === '360' || loc.type === '360video') return '360';
2362  if (loc.type === 'video') return 'video';
2363  return 'photo';
2364}
2365/* ---------- Collections (import batches: top-level folders under /images/) ---------- */
2366/*
2367 * The collection picker is gone — one folder of imports is not something
2368 * anybody wanted to filter by, and it read as a setting you had to
2369 * understand before the map made sense.
2370 *
2371 * Kept as a constant rather than deleted, because passesFilters() and the
2372 * hotspot code still guard on it; permanently empty, those guards are
2373 * short-circuits and everything shows. Removing them as well would mean
2374 * editing the arrow logic, which is not worth the risk for a dropdown.
2375 *
2376 * The stored value is cleared on sight. Anyone who had left the picker on
2377 * "Peterborough Landmarks" would otherwise stay filtered for good, with the
2378 * only control for it now deleted — which would have looked like missing
2379 * photography and been very hard to trace.
2380 */
2381const activeCollection = '';
2382try { localStorage.removeItem('pi_collection'); } catch (e) {}
2383function collectionOf(loc){
2384  const m = String(loc.image || '').match(/^images\/([^/]+)\//);
2385  return m ? decodeURIComponent(m[1]) : null;   // null = not a library item (events, stories…)
2386}
2387function passesFilters(loc){
2388  if (activeCollection){
2389    const c = collectionOf(loc);
2390    if (c && c !== activeCollection) return false;   // other imports hidden; live pins (events etc.) stay
2391  }
2392  const key = layerKeyOf(loc);
2393  if (LAYER_KEYS.has(key) && !activeLayers.has(key)) return false;
2394  // Story-tag filter (e.g. hide Crime). Hide only if the item has tags and none are on.
2395  if (Array.isArray(loc.tags) && loc.tags.length){
2396    if (!loc.tags.some(t => activeTags.has(t))) return false;
2397  }
2398  return true;
2399}
2400function buildLayers(){
2401  const panel  = document.getElementById('layers-panel');
2402  if (!panel) return;
2403  const B      = window.BRAND || {};
2404  const layers = Array.isArray(B.layers)    ? B.layers    : [];
2405  const tags   = Array.isArray(B.storyTags) ? B.storyTags : [];
2406  const dist   = (B.districts && B.districts.enabled) ? B.districts : null;
2407  const ovls   = Array.isArray(B.overlays)  ? B.overlays  : [];
2408  // base-map choice is always offered, so the panel always shows
2409
2410  LAYER_KEYS = new Set(layers.map(l => l.key));
2411  layers.forEach(l => { if (l.on !== false) activeLayers.add(l.key); });
2412  tags.forEach(t   => { if (t.on !== false) activeTags.add(t.key);  });
2413
2414  // Sub-menus, all folded: Map style / Map layers / Overlays / Bus / Journey.
2415  const fold = (id, label, inner) =>
2416    '<div class="lp-group"><details id="' + id + '"><summary>' + label + '</summary>' + inner + '</details></div>';
2417  let styleHtml = '<div class="lp-group">';
2418  Object.keys(BASEMAPS).forEach(k => {
2419    styleHtml += '<label><input type="radio" name="basemap" data-basemap="' + k + '"' + (k === activeBase ? ' checked' : '') + '>' + escapeHtml(BASEMAPS[k].label) + '</label>';
2420  });
2421  styleHtml += '</div>';
2422  let layersHtml = '';
2423  if (layers.length){
2424    layersHtml += '<div class="lp-group">';
2425    // Eight checkboxes is enough that "show me only one thing" and "put it all
2426    // back" are both several clicks. These make each of them one.
2427    layersHtml += '<div class="lp-bulk"><button type="button" data-layers-all>Select all</button>'
2428               +  '<button type="button" data-layers-none>Clear all</button></div>';
2429    layers.forEach(l => { layersHtml += '<label><input type="checkbox" data-layer="' + escapeAttr(l.key) + '"' + (l.on !== false ? ' checked' : '') + '>' + escapeHtml(l.label) + '</label>'; });
2430    layersHtml += '</div>';
2431  }
2432  if (tags.length){
2433    layersHtml += '<div class="lp-group"><h4>Story types</h4>';
2434    tags.forEach(t => { layersHtml += '<label><input type="checkbox" data-tag="' + escapeAttr(t.key) + '"' + (t.on !== false ? ' checked' : '') + '>' + escapeHtml(t.label) + '</label>'; });
2435    layersHtml += '</div>';
2436  }
2437  let ovlHtml = '<div class="lp-group">';
2438  if (dist) ovlHtml += '<label><input type="checkbox" data-districts' + (dist.on ? ' checked' : '') + '>' + escapeHtml(dist.label || 'Districts') + '</label>';
2439  ovls.forEach(o => { ovlHtml += '<label><input type="checkbox" data-overlay="' + escapeAttr(o.key) + '"' + (o.on ? ' checked' : '') + '>' + escapeHtml(o.label) + '</label>'; });
2440  ovlHtml += '</div>';
2441  let html = fold('style-details', 'Map style', styleHtml)
2442           + (layersHtml ? fold('layers-details', 'Map layers', layersHtml) : '')
2443           + ((dist || ovls.length) ? fold('ovl-details', 'Overlays', ovlHtml) : '');
2444  const busCfg = (B.busroutes && B.busroutes.enabled) ? B.busroutes : null;
2445  if (busCfg){
2446    html += '<div class="lp-group"><details id="bus-details"><summary>' + escapeHtml(busCfg.label || 'Bus routes') + '</summary>'
2447         +  '<div class="busfinder"><h4>Which bus?</h4>'
2448         +  '<input id="bus-from" list="bus-places" placeholder="From — e.g. Yaxley">'
2449         +  '<input id="bus-to" list="bus-places" placeholder="To — e.g. Cardea">'
2450         +  '<datalist id="bus-places"></datalist>'
2451         +  '<button id="bus-find" type="button">Find my bus</button>'
2452         +  '<div id="bus-answer"></div></div>'
2453         +  '<label class="bus-row" style="margin-top:6px"><input type="checkbox" id="bus-stops-cb"> All bus stops</label>'
2454         +  '<div id="bus-list"><em class="bus-loading">Loading routes…</em></div>'
2455         +  '</details></div>';
2456  }
2457  html += '<div class="lp-group"><details id="jp-details"><summary>Journey planner</summary>'
2458       +  '<div class="busfinder"><h4>Between two places on the map <button type="button" class="jp-unit" id="jp-unit"></button></h4>'
2459       +  '<div id="jp-stops">'
2460       +  '<input class="jp-stop" placeholder="From — start typing a venue">'
2461       +  '<input class="jp-stop" placeholder="To — start typing a venue">'
2462       +  '</div>'
2463       +  '<button type="button" id="jp-add">+ Add another stop</button>'
2464       +  '<div class="jp-modes">'
2465       +  '<button type="button" data-jpmode="walk">🚶 Walk</button>'
2466       +  '<button type="button" data-jpmode="cycle">🚲 Cycle</button>'
2467       +  (busCfg ? '<button type="button" data-jpmode="bus">🚌 Bus</button>' : '')
2468       +  '<button type="button" data-jpmode="drive">🚗 Drive</button></div>'
2469       +  '<div id="jp-answer"></div></div></details></div>';
2470  panel.innerHTML = html;
2471  panel.hidden = false;
2472  // accordion: opening one section folds the others (keeps the panel short)
2473  const accs = ['style-details', 'layers-details', 'ovl-details', 'bus-details', 'jp-details']
2474    .map(id => panel.querySelector('#' + id)).filter(Boolean);
2475  accs.forEach(d => d.addEventListener('toggle', () => {
2476    if (d.open) accs.forEach(o => { if (o !== d) o.open = false; });
2477  }));
2478  panel.querySelectorAll('input[data-basemap]').forEach(rb => rb.addEventListener('change', () => {
2479    if (rb.checked) setBaseMap(rb.dataset.basemap);
2480  }));
2481  panel.querySelectorAll('input[data-layer]').forEach(cb => cb.addEventListener('change', () => {
2482    cb.checked ? activeLayers.add(cb.dataset.layer) : activeLayers.delete(cb.dataset.layer);
2483    addAllMarkers();
2484  }));
2485
2486  /*
2487   * Select all / Clear all.
2488   *
2489   * `paintChips()` as well as `addAllMarkers()`, because the chips along the
2490   * top are the same state seen from a different angle: clearing every layer
2491   * here and leaving "All" lit up there would be the panel and the chip row
2492   * disagreeing about what the map is showing.
2493   */
2494  const setAllLayers = (on) => {
2495    activeLayers.clear();
2496    if (on) LAYER_KEYS.forEach(k => activeLayers.add(k));
2497    panel.querySelectorAll('input[data-layer]').forEach(c => { c.checked = on; });
2498    addAllMarkers();
2499    if (typeof paintChips === 'function') paintChips();
2500  };
2501
2502  const allBtn  = panel.querySelector('[data-layers-all]');
2503  const noneBtn = panel.querySelector('[data-layers-none]');
2504  if (allBtn)  allBtn.addEventListener('click',  () => setAllLayers(true));
2505  if (noneBtn) noneBtn.addEventListener('click', () => setAllLayers(false));
2506  panel.querySelectorAll('input[data-tag]').forEach(cb => cb.addEventListener('change', () => {
2507    cb.checked ? activeTags.add(cb.dataset.tag) : activeTags.delete(cb.dataset.tag);
2508    addAllMarkers();
2509  }));
2510  const dcb = panel.querySelector('input[data-districts]');
2511  if (dcb) dcb.addEventListener('change', () => toggleDistricts(dcb.checked));
2512  panel.querySelectorAll('input[data-overlay]').forEach(cb => {
2513    const cfg = ovls.find(o => o.key === cb.dataset.overlay);
2514    if (!cfg) return;
2515    if (cfg.on) toggleOverlay(cfg, true);
2516    cb.addEventListener('change', () => toggleOverlay(cfg, cb.checked));
2517  });
2518  if (busCfg) wireBusRoutes(busCfg);
2519  wireJourney();
2520}
2521
2522/* ---------- Bus routes: per-route toggles + journey finder ---------- */
2523let busData = null;                 // geojson FeatureCollection
2524const busLayers = {};               // ref -> L.geoJSON
2525let _busLoading = null;
2526function busEnsureData(cb){
2527  if (busData) return cb(null);
2528  const cfg = (window.BRAND || {}).busroutes;
2529  if (!cfg || !cfg.enabled || !cfg.url) return cb('no config');
2530  if (_busLoading){ _busLoading.push(cb); return; }
2531  _busLoading = [cb];
2532  fetch(cfg.url, { cache: 'no-cache' }).then(r => r.json()).then(gj => {
2533    busData = gj;
2534    buildBusList(); buildBusPlaces();
2535    _busLoading.forEach(f => f(null)); _busLoading = null;
2536  }).catch(() => { _busLoading.forEach(f => f('fail')); _busLoading = null; });
2537}
2538function busLocality(name){
2539  return String(name).split(',')[0]
2540    .replace(/\s*\([^)]*\)/g, '')          // drop "(Bay 3)", "(SE-bound)" etc
2541    .replace(/\bStation\b/i, 'Stn')        // unify Stn/Station
2542    .trim();
2543}
2544/* Local names people use that differ from official stop names (NaPTAN calls
2545   the Cardea estate "Stanground", etc.) */
2546const BUS_ALIAS = {
2547  'cardea': 'stanground', 'the hamptons': 'hampton', 'ortons': 'orton',
2548  'city centre': 'peterborough', 'town': 'peterborough', 'city': 'peterborough',
2549  'queensgate': 'peterborough queensgate', 'hospital': 'city hospital',
2550  'showground': 'alwalton', 'east of england showground': 'alwalton',
2551};
2552function busTerm(q){ q = q.trim().toLowerCase(); return BUS_ALIAS[q] || q; }
2553function busNorm(s){ return String(s).toLowerCase().replace(/[’'.]/g, ''); }
2554function busDist2(a, b){ const dx = (a[1]-b[1]) * 0.61, dy = a[0]-b[0]; return dx*dx + dy*dy; }
2555/* bustimes.org service pages = live vehicle map + full timetable per route */
2556const BUS_SLUGS = {
2557 '1':'1-orton-city-centre-werrington','2':'2-paston-city-centre-bretton','3':'3-bretton-park-farm',
2558 '4':'4-parnwell-city-centre-city-hospital','5':'5-yaxley-dogsthorpe-lavender-crescent',
2559 '5A':'5a-stanground-amazon-herald-way','6':'6-peterborough-hampton','23':'23-peterborough-lynch-wood',
2560 '24':'24-peterborough-lynch-wood-2','25':'25-peterborough-alwalton-hill','27':'27-stamford-peterborough',
2561 '33':'33-peterborough-march','37':'37-peterborough-spalding','60':'60-peterborough-orton-northgate',
2562 '61':'61-peterborough-city-centre-newark-sainsburys','62':'62-peterborough-city-centre-maxey',
2563 '63':'63-peterborough-keys-park','101':'101-bourne-market-deeping-peterborough',
2564 '102':'102-bourne-deeping-st-james-peterborough','201/202':'201-peterborough-stamford-bourne',
2565 '415':'415-peterborough-upwood','904':'904-st-ives-morrisons-huntingdon-bus-station',
2566 'X1':'x1-blue-arrow-peterborough-huntingdon-cambridge','X4':'x4-northampton-wellingborough-kettering-corby-pete',
2567 'X31':'x31-peterborough-ramsey','Excel':'a-excel-peterborough-wisbech-kings-lynn-swaffham-d',
2568};
2569/* Matcher: prefer precise locality matches ("Hampton" ≠ "Hampton Court,
2570   Westwood"); only fall back to full stop names for landmark terms like
2571   "Sainsbury's" that aren't a locality anywhere on the network. */
2572function busMatcher(qNorm){
2573  const isLocality = busData && busData.features.some(f =>
2574    (f.properties.stops || []).some(s => busNorm(busLocality(s[0])).includes(qNorm)));
2575  return isLocality
2576    ? s => busNorm(busLocality(s[0])).includes(qNorm)
2577    : s => busNorm(s[0]).includes(qNorm);
2578}
2579function busTimesLink(ref, label){
2580  const slug = BUS_SLUGS[ref];
2581  if (!slug) return '';
2582  return '<a class="bus-times" href="https://bustimes.org/services/' + slug + '" target="_blank" rel="noopener">'
2583       + escapeHtml(label || 'times') + '</a>';
2584}
2585function wireBusRoutes(cfg){
2586  const det = document.getElementById('bus-details');
2587  if (!det) return;
2588  det.addEventListener('toggle', () => {
2589    if (det.open) busEnsureData(err => {
2590      if (err) document.getElementById('bus-list').innerHTML = '<em>Could not load bus routes.</em>';
2591    });
2592  });
2593  document.getElementById('bus-find').addEventListener('click', busFind);
2594  attachPicker(document.getElementById('bus-from'), () => busPlacesList);
2595  attachPicker(document.getElementById('bus-to'), () => busPlacesList);
2596  const scb = document.getElementById('bus-stops-cb');
2597  if (scb) scb.addEventListener('change', () => toggleBusStops(scb.checked));
2598  ['bus-from','bus-to'].forEach(id => document.getElementById(id).addEventListener('keydown', e => {
2599    if (e.key === 'Enter') busFind();
2600  }));
2601}
2602function buildBusList(){
2603  const box = document.getElementById('bus-list');
2604  if (!box || !busData) return;
2605  box.innerHTML = busData.features.map(f => {
2606    const p = f.properties;
2607    return '<label class="bus-row"><input type="checkbox" data-bus="' + escapeAttr(p.ref) + '">'
2608         + '<span class="bus-chip" style="background:' + escapeAttr(p.color) + '">' + escapeHtml(p.ref) + '</span>'
2609         + '<span class="bus-name">' + escapeHtml(p.name) + '</span></label>';
2610  }).join('');
2611  box.querySelectorAll('input[data-bus]').forEach(cb =>
2612    cb.addEventListener('change', () =>
2612 toggleBusRoute(cb.dataset.bus, cb.checked)));
2613}
2614function toggleBusRoute(ref, on, zoom){
2615  if (!busData || !map) return;
2616  let lyr = busLayers[ref];
2617  if (!lyr){
2618    const f = busData.features.find(x => x.properties.ref === ref);
2619    if (!f) return;
2620    const p = f.properties;
2621    const areas = [];
2622    (p.stops || []).forEach(s => { const a = busLocality(s[0]); if (!areas.includes(a)) areas.push(a); });
2623    lyr = L.geoJSON(f, { style: { color: p.color, weight: 5, opacity: .9 } });
2624    lyr.bindPopup('<div class="bus-pop"><b><span class="bus-chip" style="background:' + escapeAttr(p.color) + '">'
2625      + escapeHtml(p.ref) + '</span> ' + escapeHtml(p.name) + '</b><br>'
2626      + escapeHtml(p.operator) + ' · ' + escapeHtml(p.freq) + '<br><small>Serves: '
2627      + escapeHtml(areas.slice(0, 14).join(' · ')) + (areas.length > 14 ? ' …' : '') + '</small></div>');
2628    busLayers[ref] = lyr;
2629  }
2630  if (on){ lyr.addTo(map); if (zoom) map.fitBounds(lyr.getBounds().pad(0.1)); }
2631  else map.removeLayer(lyr);
2632  const cb = document.querySelector('input[data-bus="' + ref.replace(/"/g,'\\"') + '"]');
2633  if (cb) cb.checked = !!on;
2634}
2635/* ---- journey path extraction: shortest path through the route's geometry
2636   from your stop to your destination, so the map shows YOUR trip, not the
2637   whole route in both directions. ---- */
2638let busJourney = null;
2639function busClearJourney(){ if (busJourney && map){ map.removeLayer(busJourney); busJourney = null; } }
2640/* Route geometry comes as fragments; endpoints are snapped onto nearby
2641   segments of sibling fragments (same road, simplified differently), giving a
2642   connected graph. Shortest path through it = your journey. */
2643const busGraphs = {};
2644function busGraphFor(f){
2645  const ref = f.properties.ref;
2646  if (busGraphs[ref]) return busGraphs[ref];
2647  const lines = f.geometry.coordinates || [];
2648  const adj = new Map();
2649  const key = p => p[0] + ',' + p[1];
2650  const add = (a, b) => {
2651    const ka = key(a), kb = key(b);
2652    if (ka === kb) return;
2653    const w = Math.hypot((a[0]-b[0]) * 0.61, a[1]-b[1]);
2654    if (!adj.has(ka)) adj.set(ka, []);
2655    if (!adj.has(kb)) adj.set(kb, []);
2656    adj.get(ka).push([kb, w]); adj.get(kb).push([ka, w]);
2657  };
2658  lines.forEach(line => { for (let i = 0; i < line.length - 1; i++) add(line[i], line[i+1]); });
2659  const SNAP = 0.0011;   // ~120m
2660  const eps = [];
2661  lines.forEach((line, li) => { if (line.length > 1){ eps.push([li, line[0]]); eps.push([li, line[line.length-1]]); } });
2662  eps.forEach(([li, ep]) => {
2663    let best = null;
2664    lines.forEach((line, lj) => {
2665      if (lj === li) return;
2666      for (let i = 0; i < line.length - 1; i++){
2667        const a = line[i], b = line[i+1];
2668        const ax = a[0]*0.61, ay = a[1], bx = b[0]*0.61, by = b[1], px = ep[0]*0.61, py = ep[1];
2669        const vx = bx-ax, vy = by-ay, L2 = vx*vx + vy*vy;
2670        const t = L2 ? Math.max(0, Math.min(1, ((px-ax)*vx + (py-ay)*vy) / L2)) : 0;
2671        const cx = ax + t*vx, cy = ay + t*vy;
2672        const dd = Math.hypot(px-cx, py-cy);
2673        if (dd < SNAP && (!best || dd < best.dd))
2674          best = { dd, a, b, c: [Math.round(cx/0.61*1e6)/1e6, Math.round(cy*1e6)/1e6] };
2675      }
2676    });
2677    if (best){ add(best.a, best.c); add(best.c, best.b); add(ep, best.c); }
2678  });
2679  busGraphs[ref] = adj;
2680  return adj;
2681}
2682function busPath(f, fromLL, toLL){
2683  const adj = busGraphFor(f);
2684  if (!adj.size) return null;
2685  const nearK = ll => {
2686    let bk = null, bd = Infinity;
2687    adj.forEach((_, k) => {
2688      const c = k.split(','), lo = +c[0], la = +c[1];
2689      const d = (la-ll[0])*(la-ll[0]) + (lo-ll[1])*0.61*(lo-ll[1])*0.61;
2690      if (d < bd){ bd = d; bk = k; }
2691    });
2692    return bk;
2693  };
2694  const ks = nearK(fromLL), kd = nearK(toLL);
2695  if (!ks || !kd || ks === kd) return null;
2696  const dist = new Map([[ks, 0]]), prev = new Map(), done = new Set();
2697  while (true){
2698    let cur = null, cd = Infinity;
2699    dist.forEach((v, k) => { if (!done.has(k) && v < cd){ cd = v; cur = k; } });
2700    if (cur === null) return null;                 // disconnected: caller draws dashed fallback
2701    if (cur === kd) break;
2702    done.add(cur);
2703    (adj.get(cur) || []).forEach(([nk, w]) => {
2704      const nd = cd + w;
2705      if (nd < (dist.has(nk) ? dist.get(nk) : Infinity)){ dist.set(nk, nd); prev.set(nk, cur); }
2706    });
2707  }
2708  const path = []; let cur = kd;
2709  while (cur !== undefined){ const c = cur.split(','); path.push([+c[1], +c[0]]); cur = prev.get(cur); }
2710  return path.reverse();
2711}
2712function busShowJourney(journeys){
2713  /* journeys = array of alternatives; each alternative = array of legs */
2714  busClearJourney();
2715  const g = L.layerGroup();
2716  const dot = (ll, cls) => L.circleMarker(ll, { radius: 7, color: '#fff', weight: 2.5,
2717    fillColor: cls === 'start' ? '#1f8a3b' : cls === 'end' ? '#c22e2e' : '#333', fillOpacity: 1 });
2718  const gap = (a, b) => Math.hypot(a[0]-b[0], (a[1]-b[1]) * 0.61);
2719  const walk = (a, b) => L.polyline([a, b], { color: '#555', weight: 3, opacity: .8, dashArray: '4 7' });
2720  journeys.forEach(legs => {
2721    legs.forEach((leg, i) => {
2722      const p = leg.f.properties;
2723      const path = busPath(leg.f, leg.from, leg.to);
2724      const line = path
2725        ? L.polyline(path, { color: p.color, weight: 6, opacity: .95 })
2726        : L.polyline([leg.from, leg.to], { color: p.color, weight: 4, opacity: .8, dashArray: '8 7' });
2727      line.bindPopup('<div class="bus-pop"><b><span class="bus-chip" style="background:' + escapeAttr(p.color) + '">'
2728        + escapeHtml(p.ref) + '</span> ' + escapeHtml(p.name) + '</b><br>'
2729        + escapeHtml(p.operator) + ' · ' + escapeHtml(p.freq) + '<br>' + busTimesLink(p.ref, 'Live times ↗') + '</div>');
2730      g.addLayer(line);
2731      if (path){   // dashed connectors where the drawn track stops short of a stop
2732        if (gap(leg.from, path[0]) > 0.0014) g.addLayer(walk(leg.from, path[0]));
2733        if (gap(leg.to, path[path.length-1]) > 0.0014) g.addLayer(walk(path[path.length-1], leg.to));
2734      }
2735      if (i > 0 && gap(legs[i-1].to, leg.from) > 0.0007) g.addLayer(walk(legs[i-1].to, leg.from));
2736      if (i === 0) g.addLayer(dot(leg.from, 'start'));
2737      g.addLayer(dot(leg.to, i === legs.length - 1 ? 'end' : 'change'));
2738    });
2739  });
2740  busJourney = g.addTo(map);
2741  const pts = journeys.flatMap(legs => legs.flatMap(l => [l.from, l.to]));
2742  map.fitBounds(L.latLngBounds(pts).pad(0.2));
2743}
2744/* Where does a place name actually cluster? Median of every matching stop,
2745   with a compactness-scaled radius — so "Yaxley" means the village, and the
2746   lone "Yaxley (parish) Waterworth Road" stop out at Gateway doesn't count. */
2747function busAnchor(matchFn){
2748  const pts = [];
2749  busData.features.forEach(f => (f.properties.stops || []).forEach(s => { if (matchFn(s)) pts.push(s); }));
2750  if (!pts.length) return null;
2751  const med = arr => arr.slice().sort((a, b) => a - b)[Math.floor(arr.length / 2)];
2752  const lat = med(pts.map(s => s[1])), lng = med(pts.map(s => s[2]));
2753  const dists = pts.map(s => Math.hypot(s[1]-lat, (s[2]-lng)*0.61));
2754  const thresh = Math.max(0.011, med(dists) * 3);   // at least ~1.2km
2755  return { center: [lat, lng], thresh };
2756}
2757function busInAnchor(s, anc){
2758  return !anc || Math.hypot(s[1]-anc.center[0], (s[2]-anc.center[1])*0.61) <= anc.thresh;
2759}
2760function busBestStop(list, anc){
2761  if (!anc) return list[0];
2762  let best = null;
2763  list.forEach(s => {
2764    const d = Math.hypot(s[1]-anc.center[0], (s[2]-anc.center[1])*0.61);
2765    if (!best || d < best.d) best = { s, d };
2766  });
2767  return best && best.s;
2768}
2769/* ---- all bus stops layer: every stop on every route, canvas-rendered ---- */
2770let busStopsLayer = null;
2771function toggleBusStops(on){
2772  if (!map) return;
2773  if (busStopsLayer){
2774    on ? busStopsLayer.addTo(map) : map.removeLayer(busStopsLayer);
2775    return;
2776  }
2777  if (!on || !busData) return;
2778  const stops = new Map();   // name -> {ll, refs:Set}
2779  busData.features.forEach(f => (f.properties.stops || []).forEach(s => {
2780    const e = stops.get(s[0]) || { ll: [s[1], s[2]], refs: new Set() };
2781    e.refs.add(f.properties.ref);
2782    stops.set(s[0], e);
2783  }));
2784  const canvas = L.canvas({ padding: 0.3 });
2785  const g = L.layerGroup();
2786  stops.forEach((e, name) => {
2787    const mk = L.circleMarker(e.ll, { renderer: canvas, radius: 4.5,
2788      color: '#fff', weight: 1.5, fillColor: '#0b455e', fillOpacity: .95 });
2789    mk.bindTooltip(name, { direction: 'top' });
2790    const refs = [...e.refs];
2791    const chips = refs.map(r => {
2792      const f = busData.features.find(x => x.properties.ref === r);
2793      return '<span class="bus-chip" style="background:' + escapeAttr(f.properties.color) + '">' + escapeHtml(r) + '</span>';
2794    }).join(' ');
2795    mk.bindPopup('<div class="bus-pop"><b>' + escapeHtml(name) + '</b><br>Served by: ' + chips
2796      + '<br><small>' + refs.map(r => busTimesLink(r, r)).join(' · ') + '</small></div>');
2797    g.addLayer(mk);
2798  });
2799  busStopsLayer = g.addTo(map);
2800}
2801let busPlacesList = [];
2802function buildBusPlaces(){
2803  if (!busData) return;
2804  const seen = new Set();
2805  busData.features.forEach(f => (f.properties.stops || []).forEach(s => seen.add(busLocality(s[0]))));
2806  busPlacesList = [...seen].sort();
2807}
2808function busFind(){
2809  const out = document.getElementById('bus-answer');
2810  if (!busData){ out.textContent = 'Routes are still loading…'; return; }
2811  busClearJourney();
2812  const from = busTerm(document.getElementById('bus-from').value || '');
2813  const to   = busTerm(document.getElementById('bus-to').value || '');
2814  if (!from || !to){ out.textContent = 'Type where you’re starting and where you’re going.'; return; }
2815  const mFrom = busMatcher(busNorm(from)), mTo = busMatcher(busNorm(to));
2816  const aF = busAnchor(mFrom), aT = busAnchor(mTo);
2817  const hits = [];
2818  busData.features.forEach(f => {
2819    const stops = f.properties.stops || [];
2820    const a = stops.filter(s => mFrom(s) && busInAnchor(s, aF));
2821    const b = stops.filter(s => mTo(s) && busInAnchor(s, aT));
2822    if (a.length && b.length) hits.push({ f, a, b });
2823  });
2824  if (hits.length){
2825    out.innerHTML = 'Direct: ' + hits.map(h =>
2826      '<button type="button" class="bus-chip bus-go" style="background:' + escapeAttr(h.f.properties.color) + '" data-go="'
2827      + escapeAttr(h.f.properties.ref) + '">' + escapeHtml(h.f.properties.ref) + '</button>').join(' ')
2828      + '<br><small>Live times: ' + hits.map(h => busTimesLink(h.f.properties.ref, h.f.properties.ref)).join(' · ') + '</small>';
2829    const legFor = h => {
2830      const sa = busBestStop(h.a, aF), sb = busBestStop(h.b, aT);
2831      return { f: h.f, from: [sa[1], sa[2]], to: [sb[1], sb[2]] };
2832    };
2833    out.querySelectorAll('.bus-go').forEach(btn => btn.addEventListener('click', () => {
2834      const h = hits.find(x => x.f.properties.ref === btn.dataset.go);
2835      if (h) busShowJourney([[legFor(h)]]);
2836    }));
2837    busShowJourney(hits.map(h => [legFor(h)]));   // draw every direct option immediately
2838    return;
2839  }
2840  // ---- no direct bus: try one change at a shared interchange ----
2841  const combos = busOneChange(from, to, aF, aT);
2842  if (!combos.length){
2843    out.innerHTML = 'No route found — check the place names (the datalist suggests what the buses call each area).';
2844    return;
2845  }
2846  out.innerHTML = 'No direct bus — change once:<br>' + combos.map((c, i) =>
2847    '<div class="bus-combo" data-combo="' + i + '">Take '
2848    + '<span class="bus-chip" style="background:' + escapeAttr(c.A.properties.color) + '">' + escapeHtml(c.A.properties.ref) + '</span>'
2849    + ' → change at <b>' + escapeHtml(c.at) + '</b> → '
2850    + '<span class="bus-chip" style="background:' + escapeAttr(c.B.properties.color) + '">' + escapeHtml(c.B.properties.ref) + '</span>'
2851    + '<br><small>Live times: ' + busTimesLink(c.A.properties.ref, c.A.properties.ref) + ' · '
2852    + busTimesLink(c.B.properties.ref, c.B.properties.ref) + '</small></div>').join('');
2853  const comboLegs = c => [
2854    { f: c.A, from: c.pts[0], to: c.pts[1] },
2855    { f: c.B, from: c.pts[2], to: c.pts[3] },
2856  ];
2857  out.querySelectorAll('.bus-combo').forEach(el => el.addEventListener('click', e => {
2858    if (e.target.closest('a')) return;   // let the live-times links click through
2859    busShowJourney([comboLegs(combos[+el.dataset.combo])]);
2860  }));
2861  busShowJourney([comboLegs(combos[0])]);   // draw the best option immediately
2862}
2863/* Find A -> (shared interchange) -> B combos. The change point is scored by
2864   real distance along the journey (origin→change→destination), so a local
2865   interchange beats dragging everyone through Queensgate — with a small
2866   preference for Queensgate only when it's genuinely on the way. */
2867function busOneChange(from, to, aF, aT){
2868  const F = busNorm(from), T = busNorm(to);
2869  const locSet = f => {
2870    const m = new Map();   // normalised locality -> {pt:[lat,lng], name}
2871    (f.properties.stops || []).forEach(s => {
2872      const name = busLocality(s[0]);
2873      const l = busNorm(name);
2874      if (!m.has(l)) m.set(l, { pt: [s[1], s[2]], name });
2875    });
2876    return m;
2877  };
2878  const mF = busMatcher(F), mT = busMatcher(T);
2879  const stopHit = (f, q) => {
2880    const m = q === F ? mF : mT, anc = q === F ? aF : aT;
2881    const list = (f.properties.stops || []).filter(s => m(s) && busInAnchor(s, anc));
2882    return list.length ? busBestStop(list, anc) : undefined;
2883  };
2884  const freqW = f => {
2885    const fr = f.properties.freq || '';
2886    return /10-15/.test(fr) ? 0.90 : /60-120/.test(fr) ? 1.18   // check 60-120 BEFORE "20 min"
2887         : /20 min/.test(fr) ? 0.95 : /30 min/.test(fr) ? 1.0 : 1.3;
2888  };
2889  const A = busData.features.filter(f => stopHit(f, F));
2890  const B = busData.features.filter(f => stopHit(f, T));
2891  const seen = new Set(); const combos = [];
2892  const WALK_MAX = 0.004;   // ~450m: further than that isn't a "change"
2893  A.forEach(fa => B.forEach(fb => {
2894    if (fa === fb) return;
2895    const key = fa.properties.ref + '>' + fb.properties.ref;
2896    if (seen.has(key)) return;
2897    const saF = stopHit(fa, F), sbT = stopHit(fb, T);
2898    const aFrom = [saF[1], saF[2]], bTo = [sbT[1], sbT[2]];
2899    // best PHYSICAL pair of stops to change at: ride + heavy walk penalty + ride
2900    let best = null;
2901    (fa.properties.stops || []).forEach(sa => {
2902      const la = busNorm(busLocality(sa[0]));
2903      if (la.includes(F) || la.includes(T)) return;   // never change where you already are
2904      const pa = [sa[1], sa[2]];
2905      (fb.properties.stops || []).forEach(sb => {
2906        const walk = Math.hypot(sa[1]-sb[1], (sa[2]-sb[2]) * 0.61);
2907        if (walk > WALK_MAX) return;
2908        const pb = [sb[1], sb[2]];
2909        const score = Math.sqrt(busDist2(aFrom, pa)) + walk * 8 + Math.sqrt(busDist2(pb, bTo));
2910        if (!best || score < best.score) best = { sa, sb, score };
2911      });
2912    });
2913    if (!best) return;
2914    seen.add(key);
2915    combos.push({ A: fa, B: fb, at: busLocality(best.sa[0]),
2916                  score: best.score * freqW(fa) * freqW(fb),
2917                  pts: [aFrom, [best.sa[1], best.sa[2]], [best.sb[1], best.sb[2]], bTo] });
2918  }));
2919  combos.sort((x, y) => x.score - y.score);
2920  return combos.slice(0, 3);
2921}
2922
2923/* ---------- Journey planner: walk / bus / drive between two venues -------- */
2924/* Tap-friendly dropdown: shows everything on focus, filters as you type. */
2925function attachPicker(input, getItems){
2926  if (!input) return;
2927  input.removeAttribute('list');           // kill the native datalist (broken on iOS)
2928  input.setAttribute('autocomplete', 'off');
2929  let box = null;
2930  const close = () => { if (box){ box.remove(); box = null; } };
2931  const open = () => {
2932    const q = input.value.trim().toLowerCase();
2933    const items = (getItems() || []).filter(t => !q || t.toLowerCase().includes(q)).slice(0, 250);
2934    close();
2935    if (!items.length) return;
2936    box = document.createElement('div');
2937    box.className = 'pick-list';
2938    items.forEach(t => {
2939      const d = document.createElement('div');
2940      d.className = 'pick-item';
2941      d.textContent = t;
2942      d.addEventListener('pointerdown', ev => {
2943        ev.preventDefault();
2944        input.value = t;
2945        close();
2946      });
2947      box.appendChild(d);
2948    });
2949    input.parentNode.insertBefore(box, input.nextSibling);
2950  };
2951  input.addEventListener('focus', open);
2952  input.addEventListener('input', open);
2953  input.addEventListener('blur', () => setTimeout(close, 250));
2954}
2955let jpPool = [];
2956function wireJourney(){
2957  const det = document.getElementById('jp-details');
2958  if (!det) return;
2959  det.addEventListener('toggle', () => {
2960    if (!det.open) return;
2961    if (!jpPool.length && Array.isArray(locations)){
2962      // venues, events and markets only — not every photo/360 upload
2963      let pool = locations.filter(l => l.title && l.lat && l.lng
2964        && ['venue', 'event', 'seasonal'].includes(l.kind));
2965      if (pool.length < 5) pool = locations.filter(l => l.title && l.lat && l.lng);
2966      const seen = new Set();
2967      jpPool = pool
2968        .filter(l => { const k = l.title.toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; })
2969        .sort((a, b) => a.title.localeCompare(b.title))
2970        .map(l => l.title);
2971    }
2972  });
2973  document.querySelectorAll('.jp-stop').forEach(inp => attachPicker(inp, () => jpPool));
2974  const addBtn = document.getElementById('jp-add');
2975  if (addBtn) addBtn.addEventListener('click', () => jpAddInput(''));
2976  det.querySelectorAll('[data-jpmode]').forEach(btn =>
2977    btn.addEventListener('click', () => jpGo(btn.dataset.jpmode)));
2978  const ub = document.getElementById('jp-unit');
2979  if (ub){
2980    const paint = () => ub.textContent = jpUnits() === 'mi' ? 'miles ▸ km' : 'km ▸ miles';
2981    paint();
2982    ub.addEventListener('click', () => { jpSetUnits(jpUnits() === 'mi' ? 'km' : 'mi'); paint(); });
2983  }
2984}
2985function jpRelabel(){
2986  const inputs = [...document.querySelectorAll('.jp-stop')];
2987  inputs.forEach((inp, i) => {
2988    inp.placeholder = i === 0 ? 'From — start typing a venue'
2989      : i === inputs.length - 1 ? 'To — start typing a venue' : 'Via…';
2990  });
2991}
2992function jpAddInput(value){
2993  const box = document.getElementById('jp-stops');
2994  if (!box) return null;
2995  const inputs = box.querySelectorAll('.jp-stop');
2996  if (inputs.length >= 5) return null;
2997  const inp = document.createElement('input');
2998  inp.className = 'jp-stop';
2999  inp.value = value || '';
3000  box.appendChild(inp);
3001  attachPicker(inp, () => jpPool);
3002  jpRelabel();
3003  return inp;
3004}
3005/* called from the pin right-click menu */
3006function jpAddPlace(title){
3007  const det = document.getElementById('jp-details');
3008  if (det) det.open = true;
3009  const empty = [...document.querySelectorAll('.jp-stop')].find(i => !i.value.trim());
3010  if (empty) empty.value = title;
3011  else if (!jpAddInput(title)){
3012    const inputs = document.querySelectorAll('.jp-stop');
3013    inputs[inputs.length - 1].value = title;   // full: replace the destination
3014  }
3015  jpRelabel();
3016  if (det){
3017    const sc = det.closest('#x-pop, aside.cn360-list');   // scroll our panel, not the page
3018    if (sc) sc.scrollTo({ top: Math.max(0, det.offsetTop - 16), behavior: 'smooth' });
3019    const xp = document.getElementById('x-pop');
3020    if (xp) xp.classList.add('open');
3021  }
3022}
3023function jpResolve(q){
3024  q = (q || '').trim().toLowerCase();
3025  if (!q || !Array.isArray(locations)) return null;
3026  return locations.find(l => l.title && l.title.toLowerCase() === q && l.lat && l.lng)
3027      || locations.find(l => l.title && l.title.toLowerCase().includes(q) && l.lat && l.lng)
3028      || null;
3029}
3030function jpUnits(){ return localStorage.getItem('pi_units') || 'mi'; }
3031function jpSetUnits(u){ try { localStorage.setItem('pi_units', u); } catch(e){} }
3032function jpFmtDist(m){
3033  if (jpUnits() === 'mi'){
3034    const mi = m / 1609.34;
3035    if (mi >= 0.095) return (mi < 10 ? mi.toFixed(1) : Math.round(mi)) + ' mi';
3036    return Math.round(m / 0.9144 / 10) * 10 + ' yds';
3037  }
3038  return m >= 950 ? (m/1000).toFixed(1) + ' km' : Math.round(m/10)*10 + ' m';
3039}
3040function jpFmt(m, s){
3041  const mins = Math.max(1, Math.round(s / 60));
3042  return jpFmtDist(m) + ' · about ' + (mins >= 60 ? Math.floor(mins/60) + ' h ' + (mins%60) + ' min' : mins + ' min');
3043}
3044function jpGo(mode){
3045  const out = document.getElementById('jp-answer');
3046  const vals = [...document.querySelectorAll('.jp-stop')].map(i => i.value).filter(v => v.trim());
3047  if (!vals.length){ out.textContent = 'Pick a place (start typing for suggestions).'; return; }
3048  const typed = vals.map(jpResolve);
3049  const bad = typed.findIndex(s => !s);
3050  if (bad >= 0){ out.textContent = 'Couldn’t find “' + vals[bad] + '” — pick it from the list.'; return; }
3051  for (let i = 1; i < typed.length; i++)
3052    if (typed[i] === typed[i-1]){ out.textContent = 'Same place twice in a row — remove one.'; return; }
3053  busClearJourney();
3054  out.textContent = 'Checking where you are…';
3055  const start = fs => jpRoute(mode, fs, out);
3056  if (navigator.geolocation){
3057    navigator.geolocation.getCurrentPosition(pos => {
3058      const my = { title: 'My location', lat: pos.coords.latitude, lng: pos.coords.longitude };
3059      const inArea = Math.hypot((my.lat-52.573)*111320, (my.lng+0.24)*67800) < 45000;
3060      const dFirst = Math.hypot((my.lat-typed[0].lat)*111320, (my.lng-typed[0].lng)*67800);
3061      // not at the first venue (and within the map area) → journey starts from you
3062      start(inArea && dFirst > 200 ? [my].concat(typed) : typed);
3063    }, () => start(typed), { timeout: 7000, maximumAge: 60000 });
3064  } else start(typed);
3065}
3066function jpRoute(mode, stops, out){
3067  if (mode === 'bus'){
3068    // bus is two-place: your location + venue, or venue + venue
3069    if (stops.length > 2 && stops[0].title === 'My location') stops = stops.slice(0, 1).concat(stops[stops.length-1]);
3070    if (stops.length < 2){ out.textContent = 'Pick two places, or allow location and pick one.'; return; }
3071    if (stops.length > 2){ out.textContent = 'Bus journeys work between two places — remove the extra stops (or go leg by leg).'; return; }
3072    out.textContent = 'Planning…';
3073    busEnsureData(err => {
3074      if (err){ out.textContent = 'Bus data unavailable.'; return; }
3075      jpBus(stops[0], stops[1], out);
3076    });
3077    return;
3078  }
3079  if (stops.length < 2){ out.textContent = 'Pick two places — or allow location access and pick just your destination.'; return; }
3080  const B = stops[stops.length - 1];
3081  out.textContent = 'Routing…';
3082  const prof = mode === 'walk' ? 'routed-foot' : mode === 'cycle' ? 'routed-bike' : 'routed-car';
3083  const url = 'https://routing.openstreetmap.de/' + prof + '/route/v1/driving/'
3084    + stops.map(s => s.lng + ',' + s.lat).join(';') + '?overview=full&geometries=geojson&steps=true';
3085  fetch(url).then(r => r.json()).then(j => {
3086    const r0 = j.routes && j.routes[0];
3087    if (!r0) throw new Error('no route');
3088    const g = L.layerGroup();
3089    const col = mode === 'walk' ? '#1f8a3b' : mode === 'cycle' ? '#e0742e' : '#1763b6';
3090    g.addLayer(L.polyline(r0.geometry.coordinates.map(p => [p[1], p[0]]),
3091      { color: col, weight: 5, opacity: .9, dashArray: mode === 'walk' ? '1 9' : mode === 'cycle' ? '10 6' : null }));
3092    stops.forEach((s, i) => {
3093      const fill = i === 0 ? '#1f8a3b' : i === stops.length - 1 ? '#c22e2e' : '#1763b6';
3094      g.addLayer(L.circleMarker([s.lat, s.lng], { radius: 7, color:'#fff', weight:2.5, fillColor: fill, fillOpacity:1 }).bindTooltip(s.title));
3095    });
3096    busJourney = g.addTo(map);
3097    map.fitBounds(L.latLngBounds(stops.map(s => [s.lat, s.lng])).pad(0.25));
3098    out.innerHTML = (mode === 'walk' ? '🚶 ' : mode === 'cycle' ? '🚲 ' : '🚗 ') + jpFmt(r0.distance, r0.duration)
3099      + (stops.length > 2 ? ' · ' + stops.length + ' stops' : '')
3100      + ' <small>route © OpenStreetMap/FOSSGIS</small>';
3101    jpOfferNav(mode, r0, B, out);
3102  }).catch(() => {
3103    // offline fallback: crow-fly estimate
3104    const m = stops.slice(1).reduce((a, s, i) =>
3105      a + Math.hypot((stops[i].lat-s.lat)*111320, (stops[i].lng-s.lng)*67800), 0);
3106    const g = L.layerGroup();
3107    g.addLayer(L.polyline(stops.map(s => [s.lat, s.lng]), { color:'#666', weight:4, dashArray:'8 7' }));
3108    busJourney = g.addTo(map);
3109    map.fitBounds(L.latLngBounds(stops.map(s => [s.lat, s.lng])).pad(0.25));
3110    out.textContent = 'Routing service unreachable — straight-line estimate: '
3111      + jpFmt(m * 1.3, m * 1.3 / (mode === 'walk' ? 1.39 : mode === 'cycle' ? 4.2 : 11));
3112  });
3113}
3114function jpFoot(a, b, cb){
3115  fetch('https://routing.openstreetmap.de/routed-foot/route/v1/driving/'
3116    + a[1] + ',' + a[0] + ';' + b[1] + ',' + b[0] + '?overview=full&geometries=geojson')
3117    .then(r => r.json()).then(j => cb(j.routes && j.routes[0] || null)).catch(() => cb(null));
3118}
3119/* ---- turn-by-turn navigation (walk / cycle / drive) ---------------------- */
3120const NAV_ARROWS = { left:'↰', right:'↱', 'slight left':'↖', 'slight right':'↗',
3121  'sharp left':'⬅', 'sharp right':'➡', straight:'↑', uturn:'↩' };
3122function jpInstr(step){
3123  const t = step.maneuver.type, m = step.maneuver.modifier || 'straight';
3124  const road = step.name ? ' onto ' + step.name : '';
3125  if (t === 'depart')  return { a:'↑', txt:'Head off' + (step.name ? ' along ' + step.name : '') };
3126  if (t === 'arrive')  return { a:'🏁', txt:'Arrive at your destination' };
3127  if (t === 'roundabout' || t === 'rotary')
3128    return { a:'⟳', txt:'At the roundabout take exit ' + (step.maneuver.exit || 1) + road };
3129  if (t === 'merge')   return { a:'⇗', txt:'Merge ' + m + road };
3130  if (t === 'on ramp') return { a:'⇗', txt:'Take the slip road' + road };
3131  if (t === 'off ramp')return { a:'⇘', txt:'Take the exit' + road };
3132  if (t === 'fork')    return { a: NAV_ARROWS[m] || '↑', txt:'Keep ' + m + ' at the fork' + road };
3133  if (t === 'continue' || t === 'new name')
3134    return { a:'↑', txt:'Continue' + (step.name ? ' on ' + step.name : '') };
3135  return { a: NAV_ARROWS[m] || '↑', txt:'Turn ' + m + road };
3136}
3137/* Printable itinerary: stitched OSM map + numbered steps, then the browser's
3138   print dialog (where Save as PDF lives). */
3139function jpPrint(mode, r0, items){
3140  const sIn = [...document.querySelectorAll('.jp-stop')].map(i => i.value.trim()).filter(Boolean);
3141  const from = sIn[0] || 'Start';
3142  const to   = sIn[sIn.length - 1] || 'Destination';
3143  const vias = sIn.slice(1, -1);
3144  const routeLL = r0.geometry.coordinates.map(p => [p[1], p[0]]);
3145  const lats = routeLL.map(p => p[0]), lngs = routeLL.map(p => p[1]);
3146  const pad = 0.12;
3147  const s = Math.min(...lats), n = Math.max(...lats), w = Math.min(...lngs), e = Math.max(...lngs);
3148  const dLat = Math.max(0.004, (n-s)) * pad, dLng = Math.max(0.004, (e-w)) * pad;
3149  const bb = { s: s-dLat, n: n+dLat, w: w-dLng, e: e+dLng };
3150  const px = (lat, lng, z) => {
3151    const scale = 256 * Math.pow(2, z);
3152    return [ (lng + 180) / 360 * scale,
3153      (1 - Math.log(Math.tan(lat*Math.PI/180) + 1/Math.cos(lat*Math.PI/180)) / Math.PI) / 2 * scale ];
3154  };
3155  let z = 16;
3156  while (z > 9){
3157    const a = px(bb.n, bb.w, z), b = px(bb.s, bb.e, z);
3158    if (b[0]-a[0] <= 1050 && b[1]-a[1] <= 720) break;
3159    z--;
3160  }
3161  const tl = px(bb.n, bb.w, z), br = px(bb.s, bb.e, z);
3162  const W = Math.ceil(br[0]-tl[0]), H = Math.ceil(br[1]-tl[1]);
3163  const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
3164  const ctx = cv.getContext('2d');
3165  const t0x = Math.floor(tl[0]/256), t0y = Math.floor(tl[1]/256);
3166  const t1x = Math.floor(br[0]/256), t1y = Math.floor(br[1]/256);
3167  const jobs = [];
3168  for (let tx = t0x; tx <= t1x; tx++) for (let ty = t0y; ty <= t1y; ty++){
3169    jobs.push(new Promise(res => {
3170      const im = new Image();
3171      im.crossOrigin = 'anonymous';
3172      im.onload = () => { ctx.drawImage(im, tx*256-tl[0], ty*256-tl[1]); res(); };
3173      im.onerror = () => res();
3174      im.src = 'https://tile.openstreetmap.org/' + z + '/' + tx + '/' + ty + '.png';
3175    }));
3176  }
3177  const win = window.open('', '_blank');
3178  if (!win){ alert('Allow pop-ups to save the PDF.'); return; }
3179  win.document.write('<title>Journey</title><p style="font-family:sans-serif">Preparing journey sheet…</p>');
3180  Promise.all(jobs).then(() => {
3181    const cxy = ll => { const p = px(ll[0], ll[1], z); return [p[0]-tl[0], p[1]-tl[1]]; };
3182    ctx.lineWidth = 5; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
3183    ctx.strokeStyle = mode === 'walk' ? '#1f8a3b' : mode === 'cycle' ? '#e0742e' : '#1763b6';
3184    ctx.beginPath();
3185    routeLL.forEach((ll, i) => { const c = cxy(ll); i ? ctx.lineTo(c[0], c[1]) : ctx.moveTo(c[0], c[1]); });
3186    ctx.stroke();
3187    const dot = (ll, col) => { const c = cxy(ll);
3188      ctx.beginPath(); ctx.arc(c[0], c[1], 8, 0, 7); ctx.fillStyle = col; ctx.fill();
3189      ctx.lineWidth = 3; ctx.strokeStyle = '#fff'; ctx.stroke(); };
3190    dot(routeLL[0], '#1f8a3b'); dot(routeLL[routeLL.length-1], '#c22e2e');
3191    let img = '';
3192    try { img = cv.toDataURL('image/png'); } catch(err){ img = ''; }
3193    const fmt = jpFmtDist;
3194    const mins = Math.max(1, Math.round(r0.duration/60));
3195    const modeTxt = mode === 'walk' ? '🚶 Walking' : mode === 'cycle' ? '🚲 Cycling' : '🚗 Driving';
3196    const esc2 = t => String(t).replace(/&/g,'&amp;').replace(/</g,'&lt;');
3197    win.document.open();
3198    win.document.write('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Journey — ' + esc2(from) + ' to ' + esc2(to) + '</title>'
3199      + '<style>body{font-family:Georgia,serif;margin:28px;color:#1c2430}h1{font-size:22px;margin:0 0 2px}'
3200      + '.sub{color:#556;font-size:14px;margin:0 0 14px}img{width:100%;border-radius:8px;border:1px solid #ccc}'
3201      + 'ol{font-size:14px;line-height:1.7;padding-left:22px}li small{color:#667}'
3202      + '.foot{margin-top:14px;font-size:11px;color:#889}@media print{.noprint{display:none}}'
3203      + '.noprint{background:#1763b6;color:#fff;border:0;border-radius:8px;padding:10px 18px;font-size:15px;cursor:pointer;margin-bottom:14px}</style></head><body>'
3204      + '<button class="noprint" onclick="print()">🖨 Print / Save as PDF</button>'
3205      + '<h1>' + esc2(from) + (vias.length ? ' → ' + vias.map(esc2).join(' → ') : '') + ' → ' + esc2(to) + '</h1>'
3206      + '<p class="sub">' + modeTxt + ' · ' + fmt(r0.distance) + ' · about ' + mins + ' min</p>'
3207      + (img ? '<img src="' + img + '">' : '')
3208      + '<ol>' + items.map(it => '<li>' + it.a + ' ' + esc2(it.txt)
3209          + (it.dist > 15 ? ' <small>— ' + fmt(it.dist) + '</small>' : '') + '</li>').join('') + '</ol>'
3210      + '<div class="foot">Map © OpenStreetMap contributors · Routing © FOSSGIS/OSRM · ' + esc2((window.BRAND||{}).name || '') + '</div>'
3211      + '</body></html>');
3212    win.document.close();
3213  });
3214}
3215let jpNavState = null;
3216function jpD(a, b){ return Math.hypot((a[0]-b[0]) * 111320, (a[1]-b[1]) * 67800); }   // metres
3217function jpBearing(a, b){ return Math.atan2((b[1]-a[1]) * 0.61, b[0]-a[0]) * 180 / Math.PI; } // 0=N cw
3218function jpOfferNav(mode, r0, B, out){
3219  const steps = (r0.legs || []).flatMap(l => l.steps || []);   // all legs of a multi-stop trip
3220  if (!steps.length) return;
3221  const items = steps.map(s => Object.assign(jpInstr(s), {
3222    loc: [s.maneuver.location[1], s.maneuver.location[0]], dist: s.distance }));
3223  const fmt = jpFmtDist;
3224  const JP_NAV_ENABLED = false;   // live guidance parked for now — route + instructions only
3225  const mobile = matchMedia('(pointer: coarse)').matches;
3226  out.innerHTML += '<ol id="jp-steps">' + items.map(it =>
3227    '<li>' + it.a + ' ' + escapeHtml(it.txt) + (it.dist > 15 ? ' <small>— ' + fmt(it.dist) + '</small>' : '') + '</li>').join('')
3228    + '</ol>'
3229    + (JP_NAV_ENABLED && mobile && navigator.geolocation ? '<button id="jp-start" type="button">▶ Start turn-by-turn</button>' : '')
3230    + '<button id="jp-pdf" type="button" style="width:100%;margin-top:6px;padding:7px 0;border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit;font:inherit;font-weight:700;cursor:pointer">🖨 Save journey as PDF</button>';
3231  document.querySelectorAll('#jp-steps li').forEach((li, i) =>
3232    li.addEventListener('click', () => map.setView(items[i].loc, 17)));
3233  const sb = document.getElementById('jp-start');
3234  if (sb) sb.addEventListener('click', () => jpStartNav(mode, items, r0, B));
3235  const pb = document.getElementById('jp-pdf');
3236  if (pb) pb.addEventListener('click', () => jpPrint(mode, r0, items));
3237}
3238function jpSpeak(txt){
3239  if (!jpNavState || !jpNavState.voice || !window.speechSynthesis) return;
3240  const u = new SpeechSynthesisUtterance(txt);
3241  u.lang = 'en-GB';
3242  speechSynthesis.cancel(); speechSynthesis.speak(u);
3243}
3244function jpStartNav(mode, items, r0, B){
3245  jpEndNav();
3246  const top = document.createElement('div');
3247  top.id = 'nav-top';
3248  top.innerHTML = '<div class="nav-a"><div class="nav-arrow"></div><div class="nav-dist"></div></div><div class="nav-road"></div>';
3249  const nxt = document.createElement('div'); nxt.id = 'nav-next';
3250  const bot = document.createElement('div');
3251  bot.id = 'nav-bottom';
3252  bot.innerHTML = '<div class="nav-eta"><b id="nav-eta-t"></b><span id="nav-eta-s"></span></div>'
3253    + '<div class="nav-fill"></div>'
3254    + '<button id="nav-zo">-</button><button id="nav-zi">+</button>'
3255    + '<button id="nav-3d">3D</button><button id="nav-voice">🔊</button><button id="nav-end">End</button>';
3256  document.body.append(top, nxt, bot);
3257  // fixed, untransformed wrapper owns the screen; the rotated map sits inside
3258  // (iOS: transform on a fixed element breaks its fixedness)
3259  const wrap = document.createElement('div');
3260  wrap.id = 'nav-map-wrap';
3261  document.body.appendChild(wrap);
3262  const mapEl = document.getElementById('leaflet-map');
3263  const mapParent = mapEl.parentNode, mapNext = mapEl.nextSibling;
3264  wrap.appendChild(mapEl);
3265  document.body.classList.add('navving');   // AFTER the map has moved out of main
3266  setTimeout(() => map.invalidateSize(), 120);
3267  setTimeout(() => map.invalidateSize(), 600);
3268  const routeLL = r0.geometry.coordinates.map(p => [p[1], p[0]]);
3269  const cum = [0];
3270  for (let i = 1; i < routeLL.length; i++) cum[i] = cum[i-1] + jpD(routeLL[i-1], routeLL[i]);
3271  const total = cum[cum.length-1] || 1;
3272  const speed = Math.max(0.5, total / Math.max(1, r0.duration));   // m/s from the route estimate
3273  const me = L.circleMarker(routeLL[0], { radius: 10, color:'#fff', weight: 3, fillColor:'#1763b6', fillOpacity: 1 }).addTo(map);
3274  jpNavState = { mode, items, idx: 0, said: -1, routeLL, cum, total, speed, B, me,
3275                 voice: 'speechSynthesis' in window, tilt: true, brg: 0, offSince: 0, watch: null, lock: null,
3276                 mapParent, mapNext, zoom: mode === 'drive' ? 17 : 18, lastCenter: routeLL[0] };
3277  // the app owns the camera during guidance: gestures fight the 3D transform
3278  ['dragging','touchZoom','doubleClickZoom','scrollWheelZoom','boxZoom','keyboard'].forEach(h => {
3279    if (map[h] && map[h].enabled()){ map[h].disable(); (jpNavState.reEnable = jpNavState.reEnable || []).push(h); }
3280  });
3281  if (navigator.wakeLock) navigator.wakeLock.request('screen').then(l => { if (jpNavState) jpNavState.lock = l; }).catch(() => {});
3282  bot.querySelector('#nav-end').addEventListener('click', jpEndNav);
3283  bot.querySelector('#nav-voice').addEventListener('click', e => {
3284    jpNavState.voice = !jpNavState.voice;
3285    e.target.classList.toggle('off', !jpNavState.voice);
3286  });
3287  bot.querySelector('#nav-3d').addEventListener('click', e => {
3288    jpNavState.tilt = !jpNavState.tilt;
3289    e.target.textContent = jpNavState.tilt ? '3D' : '2D';
3290    cam();
3291  });
3292  const zoomBy = dz => {
3293    jpNavState.zoom = Math.max(14, Math.min(19, jpNavState.zoom + dz));
3294    map.setView(jpNavState.lastCenter, jpNavState.zoom, { animate: false });
3295  };
3296  bot.querySelector('#nav-zi').addEventListener('click', () => zoomBy(1));
3297  bot.querySelector('#nav-zo').addEventListener('click', () => zoomBy(-1));
3298  const target = () => jpNavState.items[Math.min(jpNavState.idx + 1, jpNavState.items.length - 1)];
3299  const then   = () => jpNavState.items[jpNavState.idx + 2];
3300  const fmtM = jpFmtDist;
3301  const cam = () => {
3302    mapEl.style.transform = 'perspective(1300px) rotateX(' + (jpNavState.tilt ? 40 : 0) + 'deg) rotateZ(' + (-jpNavState.brg) + 'deg)';
3303  };
3304  const paintBanner = here => {
3305    const t = target();
3306    top.querySelector('.nav-arrow').textContent = t.a;
3307    top.querySelector('.nav-dist').textContent = here ? fmtM(jpD(here, t.loc)) : fmtM(t.dist || 0);
3308    top.querySelector('.nav-road').textContent = t.txt;
3309    const t2 = then();
3310    nxt.textContent = t2 ? 'Then ' + t2.a + ' ' + t2.txt : '';
3311    document.querySelectorAll('#jp-steps li').forEach((li, i) => li.classList.toggle('cur', i === jpNavState.idx));
3312  };
3313  const paintEta = here => {
3314    // remaining distance along the route from the nearest route point
3315    let ni = 0, nd = Infinity;
3316    if (here) jpNavState.routeLL.forEach((p, i) => { const d = jpD(here, p); if (d < nd){ nd = d; ni = i; } });
3317    const remain = jpNavState.total - jpNavState.cum[ni];
3318    const mins = Math.max(1, Math.round(remain / jpNavState.speed / 60));
3319    const eta = new Date(Date.now() + mins * 60000);
3320    document.getElementById('nav-eta-t').textContent =
3321      eta.getHours() + ':' + String(eta.getMinutes()).padStart(2, '0');
3322    document.getElementById('nav-eta-s').textContent = fmtM(remain) + ' · ' + mins + ' min';
3323    return { ni, nd };
3324  };
3325  paintBanner(null); paintEta(routeLL[0]); cam();
3326  jpSpeak(jpNavState.items[0].txt + '. ' + target().txt);
3327  map.setView(routeLL[0], jpNavState.zoom, { animate: false });
3328  jpNavState.watch = navigator.geolocation.watchPosition(pos => {
3329    if (!jpNavState) return;
3330    const st = jpNavState;
3331    const here = [pos.coords.latitude, pos.coords.longitude];
3332    st.me.setLatLng(here);
3333    // advance manoeuvres as we reach them
3334    while (st.idx < st.items.length - 2 && jpD(here, st.items[st.idx + 1].loc) < 28){
3335      st.idx++;
3336      jpSpeak(target().txt);
3337    }
3338    // pre-announce upcoming turn once, ~90m out
3339    const t = target();
3340    const dT = jpD(here, t.loc);
3341    if (dT < 95 && st.said !== st.idx){ st.said = st.idx; jpSpeak(t.txt); }
3342    paintBanner(here);
3343    const near = paintEta(here);
3344    // heading-up camera: bearing of route segment we're on, gently smoothed
3345    const i2 = Math.min(near.ni + 1, st.routeLL.length - 1);
3346    if (i2 > near.ni){
3347      const want = jpBearing(st.routeLL[near.ni], st.routeLL[i2]);
3348      let dlt = ((want - st.brg + 540) % 360) - 180;
3349      st.brg += dlt * 0.35;
3350      cam();
3351    }
3352    // camera centre slightly ahead so you sit low in the view
3353    const ahead = st.routeLL[Math.min(near.ni + 3, st.routeLL.length - 1)];
3354    st.lastCenter = [(here[0]*2 + ahead[0])/3, (here[1]*2 + ahead[1])/3];
3355    map.setView(st.lastCenter, st.zoom, { animate: false });
3356    // arrival
3357    const last = st.items[st.items.length - 1];
3358    if (jpD(here, last.loc) < 25){
3359      top.querySelector('.nav-road').textContent = '🏁 You have arrived';
3360      top.querySelector('.nav-arrow').textContent = '🏁';
3361      nxt.textContent = '';
3362      jpSpeak('You have arrived.');
3363      navigator.geolocation.clearWatch(st.watch);
3364      return;
3365    }
3366    // off-route → recalculate
3367    if (near.nd > 80){
3368      if (!st.offSince) st.offSince = Date.now();
3369      if (Date.now() - st.offSince > 8000){
3370        st.offSince = 0;
3371        top.querySelector('.nav-road').textContent = 'Recalculating…';
3372        jpSpeak('Recalculating.');
3373        const prof = st.mode === 'walk' ? 'routed-foot' : st.mode === 'cycle' ? 'routed-bike' : 'routed-car';
3374        fetch('https://routing.openstreetmap.de/' + prof + '/route/v1/driving/'
3375          + here[1] + ',' + here[0] + ';' + st.B.lng + ',' + st.B.lat + '?overview=full&geometries=geojson&steps=true')
3376          .then(r => r.json()).then(j => {
3377            const nr = j.routes && j.routes[0];
3378            if (!nr || !jpNavState) return;
3379            jpNavState.routeLL = nr.geometry.coordinates.map(p => [p[1], p[0]]);
3380            jpNavState.cum = [0];
3381            for (let i = 1; i < jpNavState.routeLL.length; i++)
3382              jpNavState.cum[i] = jpNavState.cum[i-1] + jpD(jpNavState.routeLL[i-1], jpNavState.routeLL[i]);
3383            jpNavState.total = jpNavState.cum[jpNavState.cum.length-1] || 1;
3384            jpNavState.items = ((nr.legs && nr.legs[0] && nr.legs[0].steps) || []).map(s2 => Object.assign(jpInstr(s2), {
3385              loc: [s2.maneuver.location[1], s2.maneuver.location[0]], dist: s2.distance }));
3386            jpNavState.idx = 0; jpNavState.said = -1;
3387            busClearJourney();
3388            const col = st.mode === 'walk' ? '#1f8a3b' : st.mode === 'cycle' ? '#e0742e' : '#1763b6';
3389            busJourney = L.layerGroup([L.polyline(jpNavState.routeLL, { color: col, weight: 6, opacity: .9 })]).addTo(map);
3390            paintBanner(here);
3391          }).catch(() => {});
3392      }
3393    } else st.offSince = 0;
3394  }, () => {
3395    top.querySelector('.nav-road').textContent = 'Allow location access to navigate';
3396  }, { enableHighAccuracy: true, maximumAge: 1500, timeout: 15000 });
3397}
3398function jpEndNav(){
3399  let parent = null, next = null;
3400  if (jpNavState){
3401    if (jpNavState.watch !== null) navigator.geolocation.clearWatch(jpNavState.watch);
3402    if (jpNavState.me && map) map.removeLayer(jpNavState.me);
3403    if (jpNavState.lock) jpNavState.lock.release().catch(() => {});
3404    if (window.speechSynthesis) speechSynthesis.cancel();
3405    (jpNavState.reEnable || []).forEach(h => { if (map && map[h]) map[h].enable(); });
3406    parent = jpNavState.mapParent; next = jpNavState.mapNext;
3407    jpNavState = null;
3408  }
3409  const mapEl = document.getElementById('leaflet-map');
3410  if (mapEl){
3411    mapEl.style.cssText = '';                        // scrub transform/position leftovers
3412    if (parent) parent.insertBefore(mapEl, next);
3412    // put the map back where it lives
3413  }
3414  ['nav-top', 'nav-next', 'nav-bottom', 'nav-map-wrap'].forEach(id => { const el = document.getElementById(id); if (el) el.remove(); });
3415  document.body.classList.remove('navving');
3416  void document.body.offsetHeight;                   // force reflow (iOS gets stuck otherwise)
3417  window.scrollTo(0, 0);
3418  if (map){
3419    setTimeout(() => { map.invalidateSize(); window.dispatchEvent(new Event('resize')); }, 100);
3420    setTimeout(() => map.invalidateSize(), 500);
3421  }
3422}
3423function jpBus(A, B, out){
3424  const aLL = [A.lat, A.lng], bLL = [B.lat, B.lng];
3425  const NEAR = 0.02;   // ~2.2km: walk further if that's what it takes
3426  const nearest = (f, ll) => {
3427    let best = null;
3428    (f.properties.stops || []).forEach(s => {
3429      const d = Math.hypot(s[1]-ll[0], (s[2]-ll[1]) * 0.61);
3430      if (d < NEAR && (!best || d < best.d)) best = { s, d };
3431    });
3432    return best;
3433  };
3434  const walkLegs = [];
3435  let plan = null;
3436  // direct routes
3437  busData.features.forEach(f => {
3438    const na = nearest(f, aLL), nb = nearest(f, bLL);
3439    if (!na || !nb || na.s === nb.s) return;
3440    const ride = Math.hypot(na.s[1]-nb.s[1], (na.s[2]-nb.s[2]) * 0.61);
3441    const score = (na.d + nb.d) * 4 + ride;
3442    if (!plan || score < plan.score) plan = { type:'direct', f, na, nb, score };
3443  });
3444  if (!plan){
3445    // one change: closest physical pair between a route near A and a route near B
3446    busData.features.forEach(fa => {
3447      const na = nearest(fa, aLL);
3448      if (!na) return;
3449      busData.features.forEach(fb => {
3450        if (fa === fb) return;
3451        const nb = nearest(fb, bLL);
3452        if (!nb) return;
3453        let bestC = null;
3454        (fa.properties.stops || []).forEach(s1 => (fb.properties.stops || []).forEach(s2 => {
3455          const w = Math.hypot(s1[1]-s2[1], (s1[2]-s2[2]) * 0.61);
3456          if (w > 0.004) return;
3457          const sc = Math.hypot(na.s[1]-s1[1], (na.s[2]-s1[2])*0.61) + w*8
3458                   + Math.hypot(s2[1]-nb.s[1], (s2[2]-nb.s[2])*0.61);
3459          if (!bestC || sc < bestC.sc) bestC = { s1, s2, sc };
3460        }));
3461        if (!bestC) return;
3462        const score = (na.d + nb.d) * 4 + bestC.sc;
3463        if (!plan || score < plan.score) plan = { type:'change', fa, fb, na, nb, c: bestC, score };
3464      });
3465    });
3466  }
3467  if (!plan){ out.textContent = 'No bus stops within walking range of both places — try walk or drive.'; return; }
3468  const legs = plan.type === 'direct'
3469    ? [{ f: plan.f, from: [plan.na.s[1], plan.na.s[2]], to: [plan.nb.s[1], plan.nb.s[2]] }]
3470    : [{ f: plan.fa, from: [plan.na.s[1], plan.na.s[2]], to: [plan.c.s1[1], plan.c.s1[2]] },
3471       { f: plan.fb, from: [plan.c.s2[1], plan.c.s2[2]], to: [plan.nb.s[1], plan.nb.s[2]] }];
3472  busShowJourney([legs]);
3473  // walk legs: straight dashes now, upgraded to real foot routes as they arrive
3474  const walkStyle = { color:'#555', weight:3, opacity:.85, dashArray:'4 7' };
3475  let wa = L.polyline([aLL, legs[0].from], walkStyle);
3476  let wb = L.polyline([legs[legs.length-1].to, bLL], walkStyle);
3477  busJourney.addLayer(wa); busJourney.addLayer(wb);
3478  busJourney.addLayer(L.circleMarker(aLL, { radius:7, color:'#fff', weight:2.5, fillColor:'#1f8a3b', fillOpacity:1 }).bindTooltip(A.title));
3479  busJourney.addLayer(L.circleMarker(bLL, { radius:7, color:'#fff', weight:2.5, fillColor:'#c22e2e', fillOpacity:1 }).bindTooltip(B.title));
3480  map.fitBounds(L.latLngBounds([aLL, bLL]).pad(0.25));
3481  const chip = f => '<span class="bus-chip" style="background:' + escapeAttr(f.properties.color) + '">' + escapeHtml(f.properties.ref) + '</span>';
3482  const stopName = s => s[0].split(',')[0].replace(/\s*\([^)]*\)/g, '');
3483  const walkInfo = { a: { m: plan.na.d * 111000 }, b: { m: plan.nb.d * 111000 } };
3484  const wtxt = w => jpFmtDist(w.m) + (w.min ? ' (' + w.min + ' min)' : '');
3485  const render = () => {
3486    const core = plan.type === 'direct'
3487      ? '🚌 ' + chip(plan.f) + ' from <b>' + escapeHtml(stopName(plan.na.s)) + '</b> to <b>' + escapeHtml(stopName(plan.nb.s)) + '</b>'
3488      : '🚌 ' + chip(plan.fa) + ' → change at <b>' + escapeHtml(stopName(plan.c.s1)) + '</b> → 🚌 ' + chip(plan.fb);
3489    const times = plan.type === 'direct'
3490      ? busTimesLink(plan.f.properties.ref, plan.f.properties.ref)
3491      : busTimesLink(plan.fa.properties.ref, plan.fa.properties.ref) + ' · ' + busTimesLink(plan.fb.properties.ref, plan.fb.properties.ref);
3492    const totalWalk = walkInfo.a.m + walkInfo.b.m;
3493    const crow = Math.hypot((A.lat-B.lat)*111320, (A.lng-B.lng)*67800);
3494    const hint = totalWalk > crow * 0.75
3495      ? '<br><small>⚠ Most of this is on foot — Walk may be simpler.</small>' : '';
3496    out.innerHTML = '🚶 ' + wtxt(walkInfo.a) + ' → ' + core + ' → 🚶 ' + wtxt(walkInfo.b)
3497      + '<br><small>Live times: ' + times + '</small>' + hint;
3498  };
3499  render();
3500  jpFoot(aLL, legs[0].from, r => {
3501    if (r && busJourney && busJourney.hasLayer(wa)){
3502      busJourney.removeLayer(wa);
3503      busJourney.addLayer(L.polyline(r.geometry.coordinates.map(p => [p[1], p[0]]), walkStyle));
3504      walkInfo.a = { m: r.distance, min: Math.max(1, Math.round(r.duration/60)) };
3505      render();
3506    }
3507  });
3508  jpFoot(legs[legs.length-1].to, bLL, r => {
3509    if (r && busJourney && busJourney.hasLayer(wb)){
3510      busJourney.removeLayer(wb);
3511      busJourney.addLayer(L.polyline(r.geometry.coordinates.map(p => [p[1], p[0]]), walkStyle));
3512      walkInfo.b = { m: r.distance, min: Math.max(1, Math.round(r.duration/60)) };
3513      render();
3514    }
3515  });
3516}
3517
3518/* ---------- Generic GeoJSON overlays (brand 'overlays': boundary, footpaths…)
3519   Fetched lazily the first time they're switched on. ---------- */
3520const overlayLayers = {};
3521function toggleOverlay(cfg, on){
3522  if (!cfg || !map) return;
3523  const have = overlayLayers[cfg.key];
3524  if (have){ on ? have.addTo(map) : map.removeLayer(have); return; }
3525  if (!on) return;
3526  fetch(cfg.url, { cache: 'no-cache' }).then(r => r.json()).then(gj => {
3527    const col = cfg.color || COL.primary;
3528    const lyr = L.geoJSON(gj, {
3529      style: f => (f.geometry && /Polygon/.test(f.geometry.type))
3530        ? { color: col, weight: 2, opacity: .85, fillColor: col, fillOpacity: .06, dashArray: cfg.dash || null }
3531        : { color: col, weight: cfg.weight || 3, opacity: .85, dashArray: cfg.dash || null },
3532      onEachFeature: (f, l) => {
3533        const p = f.properties || {};
3534        const bits = [p.name, p.status, p.parish].filter(Boolean);
3535        if (bits.length) l.bindTooltip(bits.join(' · '), { sticky: true });
3536      }
3537    });
3538    overlayLayers[cfg.key] = lyr;
3539    lyr.addTo(map);
3540  }).catch(e => console.error('[PI] overlay ' + cfg.key, e));
3541}
3542
3543/* ---------- District overlay (GeoJSON polygons) ---------- */
3544function toggleDistricts(on){
3545  if (!districtsLayer || !map) return;
3546  if (on) districtsLayer.addTo(map); else map.removeLayer(districtsLayer);
3547}
3548function hexRgb(h){ h = String(h || '').replace('#',''); if (h.length === 3) h = h.split('').map(c=>c+c).join(''); const n = parseInt(h,16); return isNaN(n) ? [42,42,42] : [(n>>16)&255,(n>>8)&255,n&255]; }
3549const brandName = () => (window.BRAND && window.BRAND.name) || 'Discover Peterborough';
3550
3551/* Shared "info" modal (districts + featured attractions). d = {name,color,
3552   image,kicker,tagline,intro,highlights[],openingHours,address,phone,website,
3553   link,ctaLabel}. */
3554function openInfoModal(d){
3555  const col = d.color || (COL && COL.primary) || '#1763b6';
3556  const [r,g,b] = hexRgb(col);
3557  const hero  = document.getElementById('dist-hero');
3558  const img   = document.getElementById('dist-hero-img');
3559  const scrim = document.getElementById('dist-hero-scrim');
3560  const solidScrim = 'linear-gradient(180deg,rgba(255,255,255,.10),rgba(0,0,0,0) 45%,rgba(0,0,0,.22))';
3561  const imgUrl = safeUrl(d.image);
3562  if (imgUrl){
3563    hero.classList.remove('nopic'); hero.style.background = '#2a2a2a';
3564    img.style.display = ''; img.src = imgUrl;
3565    img.onerror = () => { hero.classList.add('nopic'); hero.style.background = col; scrim.style.background = solidScrim; };
3566    scrim.style.background = 'linear-gradient(180deg, rgba(0,0,0,.40) 0%, rgba(0,0,0,0) 34%, rgba('+r+','+g+','+b+',.20) 56%, rgba('+r+','+g+','+b+',.94) 100%)';
3567  } else {
3568    hero.classList.add('nopic'); img.removeAttribute('src'); hero.style.background = col; scrim.style.background = solidScrim;
3569  }
3570  document.getElementById('dist-kicker').textContent = d.kicker || brandName();
3571  document.getElementById('dist-name').textContent = d.name || '';
3572  const tag = document.getElementById('dist-tag'); tag.textContent = d.tagline || ''; tag.style.display = d.tagline ? 'block' : 'none';
3573  document.getElementById('dist-intro').textContent = d.intro || '';
3574
3575  // highlights (districts) — string or {name,link}
3576  const hl = document.getElementById('dist-highlights');
3577  if (Array.isArray(d.highlights) && d.highlights.length){
3578    hl.innerHTML = '<p class="dist-hl-title">What to see</p><div class="dist-hl">'
3579      + d.highlights.map(h => {
3580          const name = (typeof h === 'string') ? h : (h.name || '');
3581          const url  = (typeof h === 'object') ? safeUrl(h.link || h.url) : '';
3582          return url
3583            ? '<a class="dist-chip lnk" href="' + escapeAttr(url) + '" target="_blank" rel="noopener">' + escapeHtml(name) + '<span class="ch-arrow">↗</span></a>'
3584            : '<span class="dist-chip">' + escapeHtml(name) + '</span>';
3585        }).join('') + '</div>';
3586  } else hl.innerHTML = '';
3587
3588  // meta (events / attractions) — a condensed details block
3589  let mh = '';
3590  if (d.when){ mh += '<div class="info-row"><strong>When:</strong> ' + escapeHtml(d.when) + '</div>'; }
3591  if (Array.isArray(d.dates) && d.dates.length){
3592    mh += '<div class="info-row" style="margin-bottom:2px"><strong>Pick a date</strong></div><div class="date-pills">';
3593    const MAXD = 12;
3594    d.dates.slice(0, MAXD).forEach(dt => {
3595      const lbl = escapeHtml(fmtDateTime(dt.s));
3596      const u = safeUrl(dt.link);
3597      mh += u ? '<a class="date-pill" href="' + escapeAttr(u) + '" target="_blank" rel="noopener">' + lbl + '</a>'
3598              : '<span class="date-pill">' + lbl + '</span>';
3599    });
3600    if (d.dates.length > MAXD){
3601      const u = safeUrl(d.link);
3602      mh += u ? '<a class="date-pill more" href="' + escapeAttr(u) + '" target="_blank" rel="noopener">+' + (d.dates.length - MAXD) + ' more</a>'
3603              : '<span class="date-pill more">+' + (d.dates.length - MAXD) + ' more</span>';
3604    }
3605    mh += '</div>';
3606  }
3607  if (d.openingHours){ mh += '<div class="info-row" style="margin-bottom:2px"><strong>Opening times</strong> ' + openNowBadge(d.openingHours) + '</div><div class="info-oh">' + escapeHtml(d.openingHours) + '</div>'; }
3608  // Venue before address, and category after, so the block reads in the same
3609  // order as the event cards on the website: what it is, where, what kind.
3610  if (d.venue){   mh += '<div class="info-row"><strong>Venue:</strong> ' + escapeHtml(d.venue) + '</div>'; }
3611  if (d.address){ mh += '<div class="info-row"><strong>Where:</strong> ' + escapeHtml(d.address) + '</div>'; }
3612  if (Array.isArray(d.categories) && d.categories.length){
3613    // Either plain names or {name,url}. The toolkit sends the archive URL so a
3614    // category is a way through to everything else of that kind, exactly as it
3615    // is on the website's own cards; the older feed sends names only.
3616    const cats = d.categories.map(c => {
3617      const name = (typeof c === 'string') ? c : (c && c.name) || '';
3618      const url  = (typeof c === 'object' && c) ? safeUrl(c.url) : '';
3619      if (!name) return '';
3620      return url
3621        ? '<a href="' + escapeAttr(url) + '">' + escapeHtml(name) + '</a>'
3622        : escapeHtml(name);
3623    }).filter(Boolean);
3624    if (cats.length) mh += '<div class="info-row"><strong>Category:</strong> ' + cats.join(', ') + '</div>';
3625  }
3626  if (d.phone){   mh += '<div class="info-row"><strong>Phone:</strong> <a href="tel:' + escapeAttr(d.phone.replace(/[^0-9+]/g,'')) + '">' + escapeHtml(d.phone) + '</a></div>'; }
3627  if (safeUrl(d.website)){ mh += '<div class="info-row"><a href="' + escapeAttr(safeUrl(d.website)) + '" target="_blank" rel="noopener">Visit website ↗</a></div>'; }
3628  document.getElementById('dist-meta').innerHTML = mh;
3629
3630  const cta = document.getElementById('dist-cta'); const url = safeUrl(d.link);
3631  if (url){ cta.href = url; cta.style.display = 'inline-flex'; cta.style.background = col;
3632    cta.innerHTML = escapeHtml(d.ctaLabel || 'Find out more') + ' <span class="dist-cta-arrow">→</span>'; }
3633  else cta.style.display = 'none';
3634
3635  const dsh = document.getElementById('dist-share');
3636  if (dsh){ if (d.shareId){ dsh.style.display = ''; dsh.onclick = () => shareItem(d.shareId, d.name); } else { dsh.style.display = 'none'; dsh.onclick = null; } }
3637
3638  const m = document.getElementById('dist-modal');
3639  m.classList.add('open'); m.setAttribute('aria-hidden', 'false');
3640}
3641function openDistrictModal(p){
3642  openInfoModal({ kicker: brandName(), name: p.name, color: p.color, image: p.image,
3643    tagline: p.tagline, intro: p.intro || p.description, highlights: p.highlights,
3644    link: p.link, ctaLabel: 'Explore on ' + brandName() });
3645}
3646function openAttractionModal(loc){
3647  openInfoModal({ kicker: brandName(), name: loc.title, color: (COL && COL.primary),
3648    image: loc.image, tagline: loc.tagline || '', intro: loc.description, openingHours: loc.opening_hours,
3649    address: loc.address, phone: loc.phone, website: loc.website,
3650    link: loc.link, ctaLabel: 'Find out more' });
3651}
3652function openEventModal(loc){
3653  // A run of dates rather than one night. The feed says how many; the older
3654  // feed's rows are collapsed into a span before they get here.
3655  const runs = Number(loc.dates_count || 0) > 1;
3656  const spansDays = loc.ends_at && fmtDate(loc.starts_at) !== fmtDate(loc.ends_at);
3657
3658  /*
3659   * All-day events.
3660   *
3661   * The calendar stores one as midnight to 23:59, which rendered literally as
3662   * "0:00 - 23:59" — technically true, and read by a visitor as a fault. The
3663   * feed's own flag is preferred; the clock check is the fallback for anything
3664   * imported before that flag existed.
3665   */
3666  const allDay = loc.all_day === true || (function(){
3667    if (!loc.starts_at || !loc.ends_at) return false;
3668    const s = new Date(String(loc.starts_at).replace(' ', 'T'));
3669    const e = new Date(String(loc.ends_at).replace(' ', 'T'));
3670    if (isNaN(s) || isNaN(e)) return false;
3671    return s.getHours() === 0 && s.getMinutes() === 0 && e.getHours() === 23 && e.getMinutes() >= 58;
3672  })();
3673
3674  let when = '';
3675  if (loc.starts_at){
3676    if (allDay){
3677      const sameDay = !loc.ends_at || fmtDate(loc.starts_at) === fmtDate(loc.ends_at);
3678      when = sameDay ? 'All day' : fmtDate(loc.starts_at) + ' \u2013 ' + fmtDate(loc.ends_at) + ', all day';
3679    } else if (spansDays) {
3680      // Across days, the clock times are the first start and the last finish,
3681      // which together describe no session anybody attends. The dates alone.
3682      when = fmtDate(loc.starts_at) + ' \u2013 ' + fmtDate(loc.ends_at);
3683    } else {
3684      when = fmtDateTime(loc.starts_at) + (loc.ends_at ? ' \u2013 ' + fmtDateTime(loc.ends_at) : '');
3685    }
3686  }
3687
3688  /*
3689   * The line under the title, matching the website's cards: one date for one
3690   * night, a span for a run, and the count only when it adds something.
3691   */
3692  let tag = fmtDate(loc.starts_at);
3693  if (spansDays) tag = fmtDate(loc.starts_at) + ' \u2013 ' + fmtDate(loc.ends_at);
3694  if (runs) tag += ' \u00b7 ' + loc.dates_count + ' dates';
3695
3696  /*
3697   * The button goes to the organiser's own site where there is one — that is
3698   * where somebody after times and tickets needs to end up — and falls back to
3699   * the listing page where there is not.
3700   *
3701   * Whichever it is, it is not also offered as a "Visit website" line above the
3702   * button. One destination, named once.
3703   */
3704  const external = safeUrl(loc.website) || '';
3705  const target = external || loc.link;
3706
3707  openInfoModal({ kicker: brandName(), name: loc.title, color: (COL && COL.primary),
3708    image: loc.image, tagline: tag, intro: loc.description,
3709    when: when, dates: null,
3710    venue: loc.venue_name || '', categories: loc.categories || [],
3711    address: loc.address, phone: loc.phone,
3712    website: (external && external === safeUrl(target)) ? '' : loc.website,
3713    // "Times & Ticket Prices" when there is somewhere to buy one — what the
3714    // website's own cards say, and what the visitor is actually after.
3715    link: target, ctaLabel: loc.cta_label || (external ? 'Times & Ticket Prices' : 'Event details') });
3716}
3717function openStoryModal(loc){
3718  openInfoModal({ kicker: 'News', name: loc.title, color: (COL && COL.primary),
3719    image: loc.image, intro: loc.description, link: loc.link, ctaLabel: 'Read the full article' });
3720}
3721function closeDistrictModal(){
3722  const m = document.getElementById('dist-modal');
3723  m.classList.remove('open'); m.setAttribute('aria-hidden', 'true');
3724}
3725(function wireDistrictModal(){
3726  const m = document.getElementById('dist-modal');
3727  if (!m) return;
3728  const card = m.querySelector('.dist-card');
3729  document.getElementById('dist-close').addEventListener('click', closeDistrictModal);
3730  m.addEventListener('click', e => { if (e.target === m) closeDistrictModal(); });
3731  document.addEventListener('keydown', e => { if (e.key === 'Escape' && m.classList.contains('open')) closeDistrictModal(); });
3732
3733  // Mobile: swipe the sheet down (from its header) to dismiss
3734  let y0 = 0, drag = false;
3735  const isPhone = () => window.matchMedia('(max-width:640px)').matches;
3736  card.addEventListener('touchstart', e => {
3737    if (!isPhone() || !e.target.closest('.dist-hero')) return;
3738    y0 = e.touches[0].clientY; drag = true; card.classList.add('dragging');
3739  }, { passive: true });
3740  card.addEventListener('touchmove', e => {
3741    if (!drag) return;
3742    const dy = e.touches[0].clientY - y0;
3743    if (dy > 0){ card.style.transform = 'translateY(' + dy + 'px)'; m.style.background = 'rgba(0,0,0,' + Math.max(0.15, 0.55 - dy / 600) + ')'; }
3744  }, { passive: true });
3745  card.addEventListener('touchend', e => {
3746    if (!drag) return; drag = false; card.classList.remove('dragging');
3747    const dy = e.changedTouches[0].clientY - y0;
3748    card.style.transform = ''; m.style.background = '';
3749    if (dy > 110) closeDistrictModal();
3750  }, { passive: true });
3751})();
3752function loadDistricts(){
3753  const D = (window.BRAND || {}).districts;
3754  if (!D || !D.enabled || !D.url || !map) return;
3755  fetch(D.url, { cache: 'no-cache' }).then(r => r.ok ? r.json() : null).then(geo => {
3756    if (!geo || !geo.features) return;
3757    districtsLayer = L.geoJSON(geo, {
3758      style: f => { const c = (f.properties && f.properties.color) || '#777';
3759        return { color: c, weight: 1.5, opacity: 0.9, fillColor: c, fillOpacity: 0.32 }; },
3760      onEachFeature: (f, layer) => {
3761        const p = f.properties || {};
3762        if (p.name) layer.bindTooltip(p.name, { sticky: true, direction: 'top', className: 'district-tip' });
3763        layer.on('mouseover', () => layer.setStyle({ fillOpacity: 0.5, weight: 2.5 }));
3764        layer.on('mouseout',  () => layer.setStyle({ fillOpacity: 0.32, weight: 1.5 }));
3765        layer.on('click', () => openDistrictModal(p));
3766      }
3767    });
3768    // honour the panel checkbox (default off)
3769    const cb = document.querySelector('#layers-panel input[data-districts]');
3770    if ((cb && cb.checked) || D.on) districtsLayer.addTo(map);
3771  }).catch(() => {});
3772}
3773
3774/* ---------- Map markers ---------- */
3775function addAllMarkers(){
3776  if (!cluster) return;
3777  cluster.clearLayers();
3778  for (const k in markers) delete markers[k];
3779  order.forEach(addMarker);
3780}
3781function popupHtml(loc, idx){
3782  const thumb = escapeAttr(loc.thumbnail || loc.image);
3783  const bg = loc.thumbnail ? `style="background-image:url('${thumb}')"` : 'style="background:#333"';
3784  const k = loc.kind || loc.type;
3785  const cta = loc.type === '360' ? 'Step inside 360° →' : (loc.type==='360video' ? 'Play 360° video →' : (loc.type==='video' ? 'Watch video →'
3786            : (k==='story' ? 'Read the story →' : (k==='event' ? 'See event →' : (k==='venue' ? 'More info →' : 'View photo →')))));
3787  // Stories & events live on the source site — link straight out in a new tab.
3788  const extLink = (k === 'story' || k === 'event') ? safeUrl(loc.link) : '';
3789  const ctaTag = extLink
3790    ? `<a class="cn360-popup-cta" href="${escapeAttr(extLink)}" target="_blank" rel="noopener">${cta}</a>`
3791    : `<a class="cn360-popup-cta" href="#" data-open="${idx}">${cta}</a>`;
3792  return ''
3793    + `<div class="cn360-popup-img" ${bg}><span class="ptype">${TYPE_BADGE[loc.type]||''}</span></div>`
3794    + '<div class="cn360-popup-body">'
3795    +   '<div class="cn360-popup-title">' + escapeHtml(loc.title) + '</div>'
3796    +   (loc.description ? '<div class="cn360-popup-desc">' + escapeHtml(loc.description) + '</div>' : '')
3797    +   ctaTag
3798    + '</div>';
3799}
3800function addMarker(idx){
3801  const loc = locations[idx];
3802  if (!passesFilters(loc)) return;            // respect layer/tag toggles
3803  const label = shortLabel(loc.title);
3804  const typeClass = pinClassFor(loc);
3805  const alignCls = (loc.type === '360' && !loc.calibrated) ? ' needs-align' : '';
3806  const inner = PIN_ICON[loc.kind || loc.type] || '';
3807  let icon;
3808  if (loc.thumbnail){
3809    // Everything with a thumbnail gets a mini preview card (image + type badge
3810    // + title) instead of a teardrop pin. Clustering still bunches them.
3811    icon = L.divIcon({
3812      className: 'cn360-pin-wrap',
3813      html: `<div class="cn360-pin cn360-thumbpin ${typeClass}${alignCls}">`
3814          + `<span class="tp-badge">${escapeHtml(badgeFor(loc))}</span>`
3815          + `<div class="tp-img" style="background-image:url('${escapeAttr(loc.thumbnail)}')"></div>`
3816          + `<div class="tp-title">${escapeHtml(label)}</div>`
3817          + `<span class="tp-tail"></span></div>`,
3818      iconSize: [96, 96],
3819      iconAnchor: [48, 96]
3820    });
3821  } else {
3822    icon = L.divIcon({
3823      className: 'cn360-pin-wrap',
3824      html: `<div class="cn360-pin ${typeClass}${alignCls}">${inner}</div><div class="cn360-pin-label">${escapeHtml(label)}</div>`,
3825      iconSize: [120, 56],
3826      iconAnchor: [60, 32]
3827    });
3828  }
3829  const m = L.marker([loc.lat, loc.lng], { icon, title: loc.title, riseOnHover: true });
3830  m.on('contextmenu', (e) => {
3831    if (e.originalEvent && e.originalEvent.preventDefault) e.originalEvent.preventDefault();
3832    const pop = L.popup({ closeButton: false, offset: [0, -18], className: 'ctx-wrap' })
3833      .setLatLng([loc.lat, loc.lng])
3834      .setContent('<div class="ctx-pop"><button class="ctx-add">➕ Add to journey</button>'
3835        + '<button class="ctx-share alt">🔗 Share link</button></div>')
3836      .openOn(map);
3837    setTimeout(() => {
3838      const el = pop.getElement();
3839      if (!el) return;
3840      const ab = el.querySelector('.ctx-add'), sb2 = el.querySelector('.ctx-share');
3841      if (ab) ab.addEventListener('click', () => { map.closePopup(pop); jpAddPlace(loc.title); });
3842      if (sb2) sb2.addEventListener('click', () => { map.closePopup(pop); shareItem(loc.id, loc.title); });
3843    }, 0);
3844  });
3845  const kind = loc.kind || loc.type;
3846  // Imported items (attractions/stories/events/pop-ups) open straight to their
3847  // action (modal or source page), exactly like the sidebar list — no preview popup.
3848  if (['story','event','venue','seasonal','business'].indexOf(kind) !== -1){
3849    m.on('click', () => openItem(idx));
3850  } else {
3851    m.bindPopup(popupHtml(loc, idx), { minWidth: 240, maxWidth: 240, autoPanPadding: [40,40] });
3852    m.on('popupopen', (e) => {
3853      const cta = e.popup._contentNode.querySelector('[data-open]');
3854      if (cta) cta.onclick = (ev) => { ev.preventDefault(); openItem(idx); };
3855    });
3856  }
3857  cluster.addLayer(m);
3858  markers[idx] = m;
3859}
3860
3861/* Centre the map on a placed pin (revealing it from its cluster) and lift it a
3862   little above the modal/card so it isn't hidden behind it. */
3863/*
3864 * Put an item's pin where it can be seen.
3865 *
3866 * Normally lifted above the card that is about to cover the bottom of the map.
3867 * `centred` is for closing a modal instead of opening one: nothing is covering
3868 * the map any more, and what you want is the pin you were just looking at in
3869 * the middle of the screen, not two-thirds up it.
3870 */
3871function focusMapOn(idx, centred){
3872  const loc = locations[idx];
3873  if (!map || !loc || loc.lat == null || loc.lng == null) return;
3874  try {
3875    const zoom = map.getZoom() || 13;                 // keep the current zoom — just recentre, don't zoom in tight
3876    const pt = map.project([loc.lat, loc.lng], zoom);
3877    if (!centred) pt.y += Math.min(map.getSize().y * 0.22, 170);   // lift the pin above the card
3878    map.setView(map.unproject(pt, zoom), zoom, { animate: true });
3879  } catch(e){ try { map.setView([loc.lat, loc.lng], map.getZoom() || 13, { animate: true }); } catch(_){} }
3880}
3881
3882/* ---------- Dispatcher ---------- */
3883function openItem(idx){
3884  const loc = locations[idx];
3885  if (!loc) return;
3886  selectIndex(idx);
3887  const kind = loc.kind || loc.type;
3888  // YouTube-hosted 360 videos open in their embedded spherical player (drag + VR, no buffering).
3889  if (loc.youtube){ closeViewerPanel(); openYouTube360(loc); return; }
3890  // News stories open the article directly in a new tab.
3891  if (kind === 'story'){ closeViewerPanel(); focusMapOn(idx); openStoryModal(loc); return; }
3892  // Events open the info modal (date + blurb + "Event details" button).
3893  if (kind === 'event'){ closeViewerPanel(); focusMapOn(idx); openEventModal(loc); return; }
3894  // Featured attractions / venues / local businesses open the slick info modal.
3895  if (kind === 'venue' || kind === 'seasonal' || kind === 'business'){ closeViewerPanel(); focusMapOn(idx); openAttractionModal(loc); return; }
3896  if (loc.type === '360' || loc.type === '360video') { closeModal(true); openViewer(idx); }
3897  else                                               { closeViewerPanel(); focusMapOn(idx); openModal(idx); }
3898}
3899
3900function selectIndex(idx){
3901  currentIdx = idx;
3902  Object.entries(markers).forEach(([i, m]) => {
3903    if (!m._icon) return;
3904    const pinInner = m._icon.querySelector('.cn360-pin');
3905    if (pinInner) pinInner.classList.toggle('active', parseInt(i,10) === idx);
3906  });
3907  document.querySelectorAll('.cn360-card.active').forEach(c => c.classList.remove('active'));
3908  const card = document.querySelector('.cn360-card[data-idx="' + idx + '"]');
3909  if (card){
3910    card.classList.add('active');
3911    const g = card.closest('.cat-group'); if (g) g.classList.add('open');   // expand so it's visible
3912    card.scrollIntoView({ block:'nearest', behavior:'smooth' });
3913  }
3914  if (map) map.closePopup();
3915}
3916
3917/* ============================================================
3918 * 360 panorama panel (Photo Sphere Viewer)
3919 * ============================================================ */
3920function openViewer(idx){
3921  const loc = locations[idx];
3922  if (!loc) return;
3923  hsPending = {}; _hsDrag = null; hsBar(false); edItem = {}; edSelRef = null;
3924  viewerLoc = loc; viewerIdx = idx; hotspotRefs = []; viewerMP = null; viewerAutorotate = null;
3925  const isVid = loc.type === '360video';
3926
3927  const panel = document.getElementById('psv-panel');
3928  panel.classList.add('open');
3929  panel.setAttribute('aria-hidden', 'false');
3930  document.getElementById('leaflet-map').classList.add('hidden');
3931  // Title, plus (admin-only) a green tick once this pano's arrows are aligned.
3932  document.getElementById('psv-title').innerHTML = escapeHtml(loc.title)
3933    + ((serverAdmin && loc.type === '360' && loc.calibrated) ? ' <span class="psv-cal-tick" title="Hotspots aligned">✓</span>' : '');
3934  document.getElementById('psv-credit').textContent = copyrightText(loc);
3935  const psvExplore = document.getElementById('psv-explore');
3936  if (loc.link){ psvExplore.classList.add('show'); psvExplore.onclick = () => window.open(loc.link, '_blank', 'noopener'); }
3937  else { psvExplore.classList.remove('show'); psvExplore.onclick = null; }
3938
3939  // VR button — only on a browser that can enter immersive VR (e.g. Quest headset)
3940  const vrBtn = document.getElementById('psv-vr');
3941  vrBtn.classList.remove('show');
3942  vrBtn.onclick = () => { window.location.href =
3943    'vr.html?type=' + encodeURIComponent(loc.type) +
3944    '&src='    + encodeURIComponent(loc.image) +
3945    '&title='  + encodeURIComponent(loc.title || '') +
3946    '&desc='   + encodeURIComponent(loc.description || '') +
3947    '&credit=' + encodeURIComponent(copyrightText(loc)); };
3948  if (navigator.xr && navigator.xr.isSessionSupported){
3949    navigator.xr.isSessionSupported('immersive-vr').then(ok => { if (ok) vrBtn.classList.add('show'); }).catch(()=>{});
3950  }
3951  document.getElementById('psv-back-to-map').style.display = 'inline-flex';
3952
3953  const HOTSPOT_RADIUS_M = 500;   // only arrow to 360s within this range
3954  const HOTSPOT_MIN_M    = 15;    // …but skip ones you're basically standing in
3955  const baseTiltDeg = +(loc.tilt || 0);   // saved vertical offset for the arrows
3956  const allMarkers = [];
3957  const hotspotCand = [];
3958  const TRANSPARENT_PIXEL ='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
3959
3960  locations.forEach((other, otherIdx) => {
3961    if (otherIdx === idx) return;
3962    // Respect the collection picker: hidden imports get no arrows or plan pins.
3963    if (activeCollection){ const cc = collectionOf(other); if (cc && cc !== activeCollection) return; }
3964
3965    // Plan-map pin for every other location (any type)
3966    allMarkers.push({
3967      id: 'plan-' + otherIdx, tooltip: other.title,
3968      position: { yaw: 0, pitch: 0 }, visible: false,
3969      image: TRANSPARENT_PIXEL, size: { width: 1, height: 1 },
3970      data: { gotoIdx: otherIdx, plan: {
3971        coordinates: [other.lng, other.lat], size: 28,
3972        image: 'data:image/svg+xml;utf8,' + encodeURIComponent(planPinSvg(other.type))
3973      }}
3974    });
3975
3976    // In-pano floating hotspot — only towards OTHER 360s within range
3977    if (other.type !== '360') return;
3978    const distM = distanceM(loc.lat, loc.lng, other.lat, other.lng);
3979    if (distM > HOTSPOT_RADIUS_M || distM < HOTSPOT_MIN_M) return;   // skip far + on-top-of-you
3980    hotspotCand.push({ otherIdx, other, distM,
3981      trueBearing: bearingDeg(loc.lat, loc.lng, other.lat, other.lng) });
3982  });
3983
3984  // Interior 360s show a single EXIT arrow back to the nearest exterior 360
3985  // (position it on the door by dragging it as admin) instead of auto hotspots.
3986  if (loc.interior){
3987    hotspotCand.length = 0;
3988    let best = null, bestD = Infinity;
3989    locations.forEach((other, otherIdx) => {
3990      if (otherIdx === idx || other.type !== '360' || other.interior) return;
3991      if (other.lat == null || other.lng == null) return;
3992      if (activeCollection){ const cc = collectionOf(other); if (cc && cc !== activeCollection) return; }
3993      const d = distanceM(loc.lat, loc.lng, other.lat, other.lng);
3994      if (d < bestD){ bestD = d; best = { otherIdx, other, distM: d,
3995        trueBearing: bearingDeg(loc.lat, loc.lng, other.lat, other.lng) }; }
3996    });
3997    if (best) hotspotCand.push(best);
3998  }
3999  // Site flag: hotspot arrows hidden from visitors until they're aligned
4000  // (admins keep them so the drag editor still works).
4001  if (((window.BRAND || {}).viewerHotspots === false) && !serverAdmin) hotspotCand.length = 0;
4002
4003  // Keep the skyline tidy: manually-placed arrows always show; the rest are
4004  // nearest-first, dropping any pano that sits behind a nearer one in almost
4005  // the same direction (you can hop onward from the nearer one, Street View
4006  // style), capped in number.
4007  const HOTSPOT_MAX = 5, HOTSPOT_MIN_SEP_DEG = 15;
4008  const hotspotKept = [], hotspotAuto = [];
4009  for (const c of hotspotCand){
4010    if (loc.hotspots && loc.hotspots[c.other.id]) hotspotKept.push(c);   // hand-placed
4011    else hotspotAuto.push(c);
4012  }
4013  hotspotAuto.sort((a, b) => a.distM - b.distM);
4014  for (const c of hotspotAuto){
4015    if (hotspotKept.length >= HOTSPOT_MAX) break;
4016    const clash = hotspotKept.some(k => {
4017      const d = Math.abs(((c.trueBearing - k.trueBearing) % 360 + 540) % 360 - 180);
4018      return d < HOTSPOT_MIN_SEP_DEG;
4019    });
4020    if (!clash) hotspotKept.push(c);
4021  }
4022  hotspotKept.forEach(entry => {
4023    const ov = (loc.hotspots && loc.hotspots[entry.other.id]) || null;   // manual placement/style wins
4024    const cfg = hotspotMarkerConfig(loc, entry, ov);
4025    allMarkers.push(cfg);
4026    hotspotRefs.push({ id: cfg.id, otherIdx: entry.otherIdx, targetId: entry.other.id,
4027                       title: shortLabel(entry.other.title), manual: !!ov, entry });
4028  });
4029
4030  // Discover More info points ride along as ordinary markers.
4031  dmList = dmEnabled() ? dmPointsOf(loc) : [];
4032  dmList.forEach(p => {
4033    const region = dmRegionConfig(p);
4034    if (region) allMarkers.push(region);       // under the pin, so the pin stays clickable
4035    allMarkers.push(dmMarkerConfig(p));
4036  });
4037
4038  if (viewer) { viewer.destroy(); viewer = null; }
4039
4040  requestAnimationFrame(() => requestAnimationFrame(() => {
4041    const psvPlugins = [
4042      MarkersPlugin.withConfig({ markers: allMarkers }),
4043      PlanPlugin.withConfig({
4044        defaultZoom: 16, coordinates: [loc.lng, loc.lat],
4045        bearing: (loc.bearing || 0) + 'deg',
4046        size: { width: '220px', height: '220px' }, position: 'bottom left',
4047        spotStyle: { size: 25,
4048          image: 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="10" fill="#ffd400" stroke="#1a1a1a" stroke-width="2"/></svg>'),
4049          hoverImage: 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="12" fill="#ffd400" stroke="' + COL.primary + '" stroke-width="3"/></svg>')
4050        }
4051      })
4052    ];
4053    if (isVid) psvPlugins.push(VideoPlugin.withConfig({}));
4054    // (autorotate deliberately not enabled — panos stay still until the visitor moves them)
4055
4056    const psvOpts = {
4057      container: document.getElementById('psv-viewer'),
4058      panorama: isVid ? { source: loc.image } : loc.image,
4059      caption: loc.title,
4060      navbar: isVid ? ['zoom','move','video','caption','fullscreen'] : ['zoom','move','caption','fullscreen'],
4061      plugins: psvPlugins
4062    };
4063    if (isVid) psvOpts.adapter = [ EquirectangularVideoAdapter, { muted: false } ];
4064    viewer = new Viewer(psvOpts);
4065
4066    viewer.addEventListener('click', (e) => {
4067      if (calibration && e.data && typeof e.data.yaw === 'number'){ handleCalibrationClick(e.data); return; }
4068      if (dmPlacing && e.data && typeof e.data.yaw === 'number'){ dmHandlePlacementClick(e.data); return; }
4069    });
4070    viewer.addEventListener('ready', () => {
4071      const mp = viewer.getPlugin(MarkersPlugin);
4072      viewerMP = mp;
4073      try { viewerAutorotate = viewer.getPlugin(AutorotatePlugin); } catch(e){ viewerAutorotate = null; }
4074      if (mp) {
4075        try { mp.setMarkers(allMarkers); } catch(err){ console.error('[PI] setMarkers', err); }
4076        /*
4077         * Moving onto a point — its pin or anywhere in its region — lights both.
4078         * This is the whole interaction: at rest the photograph is uncluttered,
4079         * and the thing a point is about only appears when somebody is looking
4080         * at it.
4081         */
4082        mp.addEventListener('enter-marker', ({ marker }) => {
4083          const d = marker.config && marker.config.data;
4084          if (d && d.isDiscover) dmHighlight(d.dmId, true);
4085        });
4086        mp.addEventListener('leave-marker', ({ marker }) => {
4087          const d = marker.config && marker.config.data;
4088          // Left lit while its panel is open, so it is obvious what is being read about.
4089          if (d && d.isDiscover && d.dmId !== dmOpenId) dmHighlight(d.dmId, false);
4090        });
4091
4092        mp.addEventListener('select-marker', ({ marker }) => {
4093          const data = marker.config && marker.config.data;
4094          if (data && data.isDiscover){
4095            if (serverAdmin && dmEditorIsOpen()){
4096              const p = dmList.find(x => x.id === data.dmId);
4097              if (p) dmEditorOpen(p);
4098            } else {
4099              dmOpenPoint(data.dmId, { animate: true });
4100            }
4101            return;
4102          }
4103          const gotoIdx = data && data.gotoIdx;
4104          if (typeof gotoIdx === 'number') openItem(gotoIdx);
4105        });
4106      }
4107      // Admins edit arrows by simply dragging them; the bar appears when this
4108      // pano hasn't been confirmed yet (fresh pano = fresh pending state).
4109      hsPending = {}; hsBar(false);
4110      refreshAdminTools();
4111      // A shared Discover More link lands here: face the point and open it.
4112      if (dmPendingDeep){
4113        const want = dmPendingDeep; dmPendingDeep = null;
4114        if (dmList.some(p => p.id === want)) setTimeout(() => dmOpenPoint(want, { animate: true }), 400);
4115      }
4116      if (edOpen()) edPopulate();   // keep the editor in step when hopping panos
4117      else if (serverAdmin && hotspotRefs.length && !loc.calibrated){
4118        hsHint('Hold and drag any arrow to its true spot, then Save.');
4119        hsBar(true);
4120      }
4121      setTimeout(() => {
4122        try { viewer.notifyResize && viewer.notifyResize(); } catch(e){}
4123        try { viewer.autoSize && viewer.autoSize(); } catch(e){}
4124        const pp = viewer.getPlugin(PlanPlugin);
4125        if (pp && pp.map && pp.map.invalidateSize) pp.map.invalidateSize();
4126        // On phones, start the helper map collapsed so it doesn't cover the view.
4127        if (pp && window.matchMedia('(max-width:680px)').matches){ try { pp.close(); } catch(e){} }
4128      }, 150);
4129    });
4130  }));
4131}
4132function planPinSvg(type){
4133  const fill = type==='video' ? '#cc7a00' : (type==='photo' ? '#1f7a8c' : COL.primary);
4134  const glyph = type==='video' ? '▶' : (type==='photo' ? '◉' : '360');
4135  const fs = type==='360' ? 9 : 13;
4136  return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
4137    + '<circle cx="16" cy="16" r="13" fill="'+fill+'" stroke="#fff" stroke-width="3"/>'
4138    + '<text x="16" y="20" text-anchor="middle" font-size="'+fs+'" font-weight="900" fill="#fff" font-family="sans-serif">'+glyph+'</text></svg>';
4139}
4140function closeViewerPanel(){
4141  const panel = document.getElementById('psv-panel');
4142  if (!panel.classList.contains('open')) return;
4143  hsPending = {}; _hsDrag = null; hsBar(false); edItem = {}; edSelRef = null;
4144  try { edToggle(false); } catch(e){}
4145  try { dmClosePanel(); dmEditorClose(); } catch(e){}
4146  panel.classList.remove('open');
4147  panel.setAttribute('aria-hidden', 'true');
4148  document.getElementById('leaflet-map').classList.remove('hidden');
4149  document.getElementById('psv-back-to-map').style.display = 'none';
4150  if (viewer) { viewer.destroy(); viewer = null; }
4151  if (map) setTimeout(() => map.invalidateSize(), 50);
4152}
4153function closeViewer(){
4154  closeViewerPanel();
4155  clearActive();
4156}
4157
4158/* ============================================================
4159 * Drag-to-align (admin) — rotate every in-pano direction arrow at once by
4160 * dragging the panorama, then Save. The drag sets the panorama's bearing;
4161 * each arrow sits at (trueBearingToPlace − bearing).
4162 * ============================================================ */
4163const normDeg = d => ((d % 360) + 360) % 360;
4164const clampPitch = d => Math.max(-80, Math.min(80, d));
4165function placeHotspots(bearing, basePitchDeg){
4166  if (!viewerMP || !viewerLoc) return;
4167  const base = toRad(basePitchDeg || 0);
4168  hotspotRefs.forEach(ref => {
4169    const other = locations[ref.otherIdx]; if (!other) return;
4170    const tb  = bearingDeg(viewerLoc.lat, viewerLoc.lng, other.lat, other.lng);
4171    const yaw = toRad(normDeg(tb - bearing));
4172    const pitch = base + toRad(ref.pitchDeg || 0);   // base tilt + per-arrow fan
4173    try { viewerMP.updateMarker({ id: ref.id, position: { yaw, pitch } }); } catch(e){}
4174  });
4175}
4176/* Build a hotspot marker config from geometry plus an optional per-target
4177 * override set via the in-viewer editor: position (yaw/pitch), icon glyph,
4178 * venue logo (img), size multiplier and label text. */
4179function hotspotMarkerConfig(loc, entry, ov){
4180  const { otherIdx, other, distM, trueBearing } = entry;
4181  ov = ov || null;
4182  const isExit = !!loc.interior;
4183  const relBearingDeg = trueBearing - (loc.bearing || 0);
4184  const yaw   = (ov && ov.yaw != null) ? toRad(((+ov.yaw % 360) + 360) % 360) : toRad(((relBearingDeg % 360) + 360) % 360);
4185  const pitch = (ov && ov.pitch != null) ? toRad(+ov.pitch) : toRad(+(loc.tilt || 0));
4186  const scale = (ov && +ov.size) ? Math.max(0.4, Math.min(3, +ov.size)) : 1;
4187  const distLabel = distM < 1000 ? Math.round(distM) + 'm away' : (distM/1000).toFixed(1) + 'km away';
4188  const labelXml = xmlEscape((ov && ov.label) ? ov.label : (isExit ? 'Exit' : shortLabel(other.title)));
4189  const distXml  = xmlEscape(isExit ? 'BACK OUTSIDE' : distLabel.toUpperCase());
4190  let image, w = 165, h = 98, opacity = 0.6;
4191  if (ov && ov.img){
4192    image = ov.img; w = 76; h = 76; opacity = 1;   // venue logo marker
4193  } else {
4194    const glyph = (ov && ov.icon) ? ov.icon : (isExit ? 'EXIT' : '360');
4195    const fs = glyph === '360' ? 13 : (glyph === 'EXIT' ? 11 : 22);
4196    image = 'data:image/svg+xml;utf8,' + encodeURIComponent(
4197      '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 130">'
4198      + '<circle cx="110" cy="32" r="30" fill="' + COL.primary + '" fill-opacity="0.20"/>'
4199      + '<circle cx="110" cy="32" r="22" fill="' + COL.primary + '" stroke="#ffffff" stroke-width="4"/>'
4200      + '<text x="110" y="' + (fs > 20 ? 40 : 37) + '" text-anchor="middle" font-size="' + fs + '" font-weight="900" fill="#ffffff" font-family="Arial,sans-serif">' + xmlEscape(glyph) + '</text>'
4201      + '<rect x="6" y="68" width="208" height="54" rx="6" fill="#000000" fill-opacity="0.82" stroke="#ffffff" stroke-opacity="0.18"/>'
4202      + '<text x="110" y="88" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff" font-family="Arial,sans-serif">' + labelXml + '</text>'
4203      + '<text x="110" y="108" text-anchor="middle" font-size="11" font-weight="800" fill="#ffd400" font-family="Arial,sans-serif">' + distXml + '</text>'
4204      + '</svg>');
4205  }
4206  return {
4207    id: 'hotspot-' + otherIdx,
4208    position: { yaw, pitch }, anchor: 'top center',
4209    size: { width: Math.round(w * scale), height: Math.round(h * scale) },
4210    opacity, image,
4211    tooltip: { content: isExit ? 'Step back outside' : (shortLabel(other.title) + ' · click to step inside'), position: 'top' },
4212    hoverScale: { amount: 1.25, duration: 200 },
4213    data: { gotoIdx: otherIdx, isHotspot: true }
4214  };
4215}
4216
4217/* ============================================================
4218 * DISCOVER MORE — information points placed inside a panorama.
4219 *
4220 * Deliberately separate from the navigation arrows above: an arrow means
4221 * "travel to another 360", a Discover More point means "read about the thing
4222 * you are looking at". They are stored on the parent pano's sidecar under
4223 * `discover` (a JSON array), written through save-bearing.php exactly like
4224 * `hotspots`, so nothing new had to be invented server-side.
4225 *
4226 * Each point: { id, yaw, pitch, title, text, media:{kind,src}, links:[], icon }
4227 * Share links are `?item=<panoId>&more=<pointId>`.
4228 * ============================================================ */
4229let dmList     = [];      // the current pano's points (working copy)
4230let dmOpenId   = null;    // id of the point whose reading panel is open
4231let dmDraft    = null;    // point currently being edited (admin)
4232let dmPlacing  = false;   // waiting for a click in the pano to set yaw/pitch
4233let dmPendingDeep = null; // point id from a share link, opened once the pano is ready
4234
4235function dmEnabled(){ return ((window.BRAND || {}).discoverMore !== false); }
4236function dmPanelIsOpen(){ const p = document.getElementById('dm-panel'); return !!(p && p.classList.contains('open')); }
4237function dmEditorIsOpen(){ const p = document.getElementById('dm-editor'); return !!(p && p.classList.contains('open')); }
4238
4239function dmPointsOf(loc){
4240  const a = loc && loc.discover;
4241  return Array.isArray(a) ? a.filter(p => p && p.id) : [];
4242}
4243/* Images may be a full URL or a path relative to the app (images/…, logos/…). */
4244function dmMediaUrl(src){
4245  const s = String(src || '').trim();
4246  if (/^https?:\/\//i.test(s)) return s;
4247  if (/^(images|logos|discover)\//i.test(s)) return s;
4248  return '';
4249}
4250/* ---------- Discover More: colours, icons and regions ---------- */
4251
4252/*
4253 * The named colours a point can use.
4254 *
4255 * Names rather than hex values in the data, for two reasons. A map where every
4256 * point is a slightly different shade of orange looks like nobody was in charge
4257 * of it; and a name means the shade itself can be changed here, once, and every
4258 * point written in the last two years restyles. `info` is the default and is
4259 * the amber these pins have always been, so points written before tones existed
4260 * look exactly as they did.
4261 *
4262 * Kept in step with DM_TONES in save-bearing.php and the palette in the
4263 * toolkit's tour editor. All three must agree.
4264 */
4265const DM_TONES = {
4266  info:         { pin: '#ffd400', ink: '#1a1a1a', line: '#ffe873' },
4267  history:      { pin: '#d99b2b', ink: '#1a1a1a', line: '#f0c778' },
4268  architecture: { pin: '#3f8fe0', ink: '#ffffff', line: '#8dc0f0' },
4269  nature:       { pin: '#2e9e6b', ink: '#ffffff', line: '#7fd3ae' },
4270  food:         { pin: '#cf5c36', ink: '#ffffff', line: '#eda089' },
4271  access:       { pin: '#7c5cd6', ink: '#ffffff', line: '#b4a1ec' },
4272  event:        { pin: '#d6478f', ink: '#ffffff', line: '#ef9cc5' },
4273};
4274function dmTone(p){ return DM_TONES[(p && p.tone) || 'info'] || DM_TONES.info; }
4275
4276/*
4277 * The icons a pin can carry, as single SVG paths on a 24-unit grid.
4278 *
4279 * A closed set rather than an upload field: ten recognisable shapes drawn to
4280 * the same weight read as one family, and a folder of PNGs somebody assembled
4281 * over two years does not. Anything not in this list is drawn as text, which is
4282 * what every point used before named icons existed — `icon` used to hold a
4283 * single character, and those still work.
4284 */
4285const DM_ICONS = {
4286  info:     'M12 16v-4m0-4h.01M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
4287  plaque:   'M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm3 5h8M8 12h8M8 16h4',
4288  statue:   'M12 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-3 6h6l-1 5h1l1 7H8l1-7h1L9 9Z',
4289  building: 'M3 21h18M6 21V5a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v16M9 8h2m2 0h2M9 12h2m2 0h2M9 16h6',
4290  door:     'M6 21V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v17M4 21h16M14 12h.01',
4291  tree:     'M12 21v-6m0 0 4.5-3.5M12 15 7.5 11.5M12 3a4.5 4.5 0 0 1 4 6.6A3.5 3.5 0 0 1 14 16h-4a3.5 3.5 0 0 1-2-6.4A4.5 4.5 0 0 1 12 3Z',
4292  food:     'M6 3v8a2 2 0 0 0 2 2v8M6 3v5m3-5v5M18 3c-1.5 1.5-2 3.5-2 6v4h3v8',
4293  view:     'M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z',
4294  clock:    'M12 7v5l3 2m6-2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z',
4295  ticket:   'M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2 2 2 0 0 0 0 4 2 2 0 0 1 0 4 2 2 0 0 1-2 2H5a2 2 0 0 1-2-2 2 2 0 0 0 0-4 2 2 0 0 1 0-4Zm11-2v12',
4296};
4297
4298/* The glyph inside a pin: a named icon, or the old single character. */
4299function dmPinGlyph(p){
4300  const key = String((p && p.icon) || '').trim();
4301  const path = DM_ICONS[key];
4302
4303  if (path){
4304    return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="' + path + '"/></svg>';
4305  }
4306
4307  // Anything else is text, which is what `icon` meant before this existed.
4308  return '<em>' + escapeHtml(key || 'i') + '</em>';
4309}
4310
4311/* ---------- the geometry of a region ---------- */
4312
4313/* yaw/pitch in degrees → a unit vector, and back. Only used to draw circles. */
4314function dmToVec(yawDeg, pitchDeg){
4315  const y = toRad(yawDeg), p = toRad(pitchDeg);
4316  return [Math.cos(p) * Math.sin(y), Math.sin(p), Math.cos(p) * Math.cos(y)];
4317}
4318function dmFromVec(v){
4319  return [Math.atan2(v[0], v[2]), Math.asin(Math.max(-1, Math.min(1, v[1])))];   // radians
4320}
4321
4322/*
4323 * A circle on the sphere, as a ring of points.
4324 *
4325 * The markers plugin draws circles flat on the screen at a fixed pixel size,
4326 * which is the wrong thing entirely here: a region has to sit on the
4327 * photograph and grow as you zoom into it, the way the arrows do. So a circle
4328 * is stored as a radius in degrees and expanded into a polygon at draw time —
4329 * the data stays small and honest, and the drawing wraps the sphere properly.
4330 */
4331function dmCircleRing(yawDeg, pitchDeg, radiusDeg, steps){
4332  const c = dmToVec(yawDeg, pitchDeg);
4333  const r = toRad(radiusDeg);
4334  const n = steps || 32;
4335
4336  // Any vector not parallel to the centre gives a basis for the ring.
4337  const helper = Math.abs(c[1]) > 0.9 ? [1, 0, 0] : [0, 1, 0];
4338  const u = dmNorm(dmCross(helper, c));
4339  const v = dmCross(c, u);
4340
4341  const ring = [];
4342  for (let i = 0; i < n; i++){
4343    const a = (i / n) * Math.PI * 2;
4344    const s = Math.sin(r), k = Math.cos(r);
4345    ring.push(dmFromVec([
4346      c[0] * k + (u[0] * Math.cos(a) + v[0] * Math.sin(a)) * s,
4347      c[1] * k + (u[1] * Math.cos(a) + v[1] * Math.sin(a)) * s,
4348      c[2] * k + (u[2] * Math.cos(a) + v[2] * Math.sin(a)) * s,
4349    ]));
4350  }
4351  return ring;
4352}
4353function dmCross(a, b){ return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]; }
4354function dmNorm(a){ const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0]/l, a[1]/l, a[2]/l]; }
4355
4356/* The vertices of a point's region, in radians, or null if it has none. */
4357function dmRegionPath(p){
4358  const r = p && p.region;
4359  if (!r) return null;
4360
4361  if (r.kind === 'circle'){
4362    const rad = +r.radius;
4363    if (!(rad > 0)) return null;
4364    return dmCircleRing(((+p.yaw % 360) + 360) % 360, +p.pitch || 0, Math.min(60, rad));
4365  }
4366
4367  if (r.kind === 'polygon' && Array.isArray(r.points) && r.points.length >= 3){
4368    return r.points.slice(0, 24).map(v => [toRad(((+v[0] % 360) + 360) % 360), toRad(+v[1] || 0)]);
4369  }
4370
4371  return null;
4372}
4373
4374/*
4375 * The region marker.
4376 *
4377 * Invisible at rest and visible on approach, which is the whole idea: a sphere
4378 * covered in translucent colour is more crowded than one covered in pins, not
4379 * less — you lose the photograph behind it. At rest there are a few quiet dots;
4380 * move onto one and the thing it is about lights up.
4381 *
4382 * `fill-opacity` is a hair above zero rather than zero, because a shape with no
4383 * fill is not hit-tested — and the area being hoverable is what makes the
4384 * lighting-up happen at all. It also gives a click target the size of the
4385 * building rather than the size of a pin.
4386 */
4387function dmRegionConfig(p){
4388  const path = dmRegionPath(p);
4389  if (!path) return null;
4390
4391  const tone = dmTone(p);
4392  const op = Math.max(0.05, Math.min(0.85, +((p.region && p.region.opacity)) || 0.35));
4393
4394  return {
4395    id: 'dmr-' + p.id,
4396    polygon: path,
4397    className: 'dm-region',
4398    svgStyle: {
4399      fill: tone.pin,
4400      fillOpacity: '0.001',
4401      stroke: tone.line,
4402      strokeWidth: '0px',
4403      strokeOpacity: '0',
4404    },
4405    zIndex: 5,                     // under the arrows, which must stay clickable
4406    tooltip: { content: escapeHtml(p.title || 'Discover more'), position: 'top' },
4407    data: { dmId: p.id, isDiscover: true, isRegion: true, dmOpacity: op },
4408  };
4409}
4410
4411function dmMarkerConfig(p){
4412  const tone = dmTone(p);
4413
4414  return {
4415    id: 'dm-' + p.id,
4416    position: { yaw: toRad(((+p.yaw % 360) + 360) % 360), pitch: toRad(+p.pitch || 0) },
4417    html: '<span class="dm-pin" style="--dm-pin:' + tone.pin + ';--dm-ink:' + tone.ink + '">'
4418        + '<i></i><i></i><b>' + dmPinGlyph(p) + '</b></span>',
4419    /*
4420     * Anchored by the tip of its tail, not by its middle.
4421     *
4422     * `center center` put the disc directly over the thing the point is about,
4423     * which is the one arrangement guaranteed to hide it. The box is 78 tall —
4424     * disc 0-46, tail 44-78 — so bottom centre is the tip, and the tip is what
4425     * sits on the stored yaw and pitch. Nothing else reads this geometry; the
4426     * panel opens from `data.dmId`, not from where the marker is drawn.
4427     */
4428    size: { width: 76, height: 78 },
4429    anchor: 'bottom center',
4430    zIndex: 20,
4431    tooltip: { content: escapeHtml(p.title || 'Discover more'), position: 'top' },
4432    data: { dmId: p.id, isDiscover: true }
4433  };
4434}
4435
4436/*
4437 * Light a point up, or put it out.
4438 *
4439 * The class goes on the marker's own element and the transition lives in CSS,
4440 * so the fade costs nothing per frame and the plugin is not asked to re-render
4441 * a polygon sixty times a second while somebody sweeps the mouse across a wall.
4442 */
4443function dmHighlight(id, on){
4444  if (!viewerMP) return;
4445
4446  ['dm-' + id, 'dmr-' + id].forEach(mid => {
4447    try {
4448      const m = viewerMP.getMarker(mid);
4449      const el = m && m.domElement;
4450      if (!el) return;
4451
4452      el.classList.toggle('is-lit', !!on);
4453
4454      if (!mid.startsWith('dmr-')) return;
4455
4456      const op = (m.config && m.config.data && m.config.data.dmOpacity) || 0.35;
4457
4458      /*
4459       * Inline, not a class.
4460       *
4461       * The marker's `svgStyle` is written as an inline style by the plugin, and
4462       * no stylesheet rule beats that — a class that set fill-opacity was simply
4463       * ignored, which is why the shape sat there doing nothing. Applied to the
4464       * element and to any shape inside it, because the plugin has been known to
4465       * hand back either the <path> itself or a group wrapping one, and which of
4466       * those it is should not decide whether this feature works.
4467       */
4468      const shapes = [el].concat(
4469        el.querySelectorAll ? Array.from(el.querySelectorAll('path,polygon,polyline')) : [],
4470      );
4471
4472      shapes.forEach(shape => {
4473        if (!shape.style) return;
4474        shape.style.fillOpacity = on ? String(op) : '0.001';
4475        shape.style.strokeOpacity = on ? '0.95' : '0';
4476        shape.style.strokeWidth = on ? '2.5px' : '0px';
4477      });
4478    } catch(e){}
4479  });
4480}
4481/* Rebuild just the info-point markers, leaving the navigation arrows alone. */
4482function dmRefreshMarkers(){
4483  if (!viewerMP) return;
4484  dmList.forEach(p => { try { viewerMP.removeMarker('dm-' + p.id); } catch(e){} });
4485  try {
4486    (viewerMP.getMarkers() || []).forEach(m => {
4487      if (m.config && m.config.data && m.config.data.isDiscover){ try { viewerMP.removeMarker(m.id); } catch(e){} }
4488    });
4489  } catch(e){}
4490  /*
4491   * The region goes in first so it sits under its own pin: an invisible shape
4492   * drawn on top of the dot would swallow the click that opens the panel.
4493   */
4494  dmList.forEach(p => {
4495    const region = dmRegionConfig(p);
4496    if (region){ try { viewerMP.addMarker(region); } catch(e){} }
4497    try { viewerMP.addMarker(dmMarkerConfig(p)); } catch(e){}
4498  });
4499}
4500
4501/* Turn the camera to face a point.
4502
4503   Dead centre, with no offset. The drawer this was written for sat down one
4504   side and the target had to be nudged clear of it; the reading window is
4505   centred and opaque, so nothing is visible behind it either way — and when it
4506   closes, the thing you clicked should be exactly where you are looking. */
4507function dmFocus(p, animate){
4508  if (!viewer || !p) return;
4509  let yaw = toRad(((+p.yaw % 360) + 360) % 360);
4510  let pitch = toRad(+p.pitch || 0);
4511  if (pitch < toRad(-80)) pitch = toRad(-80);
4512  if (pitch > toRad(80))  pitch = toRad(80);
4513  try {
4514    if (animate === false) viewer.rotate({ yaw, pitch });
4515    else viewer.animate({ yaw, pitch, speed: '4rpm' });
4516  } catch(e){ try { viewer.rotate({ yaw, pitch }); } catch(e2){} }
4517}
4518
4519function dmOpenPoint(id, opts){
4520  opts = opts || {};
4521  const p = dmList.find(x => x.id === id);
4522  if (!p) return;
4523  dmOpenId = id;
4524
4525  const media = document.getElementById('dm-media');
4526  const mu = p.media ? dmMediaUrl(p.media.src) : '';
4527  if (p.media && p.media.kind === 'image' && mu){
4528    media.innerHTML = '<img src="' + escapeAttr(mu) + '" alt="' + escapeAttr(p.title || '') + '" loading="lazy">';
4529  } else if (p.media && p.media.kind === 'youtube' && p.media.src){
4530    const vid = String(p.media.src).replace(/[^A-Za-z0-9_\-]/g, '');
4531    media.innerHTML = '<div class="dm-embed"><iframe src="https://www.youtube-nocookie.com/embed/' + encodeURIComponent(vid)
4532      + '?rel=0&modestbranding=1" title="' + escapeAttr(p.title || 'Video') + '" loading="lazy" allowfullscreen '
4533      + 'allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe></div>';
4534  } else {
4535    media.innerHTML = '';
4536  }
4537
4538  /* One column when there is nothing to show. A picture frame standing empty
4539     beside the text looks like a picture that failed to load. */
4540  const card = document.getElementById('dm-card');
4541  if (card) card.classList.toggle('no-media', media.innerHTML === '');
4542
4543  document.getElementById('dm-title').textContent = p.title || 'Discover more';
4544  const txt = document.getElementById('dm-text');
4545  txt.textContent = p.text || '';
4546  txt.style.display = p.text ? '' : 'none';
4547
4548  const links = document.getElementById('dm-links');
4549  links.innerHTML = '';
4550  (p.links || []).forEach(l => {
4551    const u = safeUrl(l.url);
4552    if (!u) return;
4553    const a = document.createElement('a');
4554    a.href = u; a.target = '_blank'; a.rel = 'noopener noreferrer';
4555    a.innerHTML = escapeHtml(l.label || 'Find out more') + '<span>&rarr;</span>';
4556    links.appendChild(a);
4557  });
4558
4559  const n = dmList.findIndex(x => x.id === id);
4560  const cnt = document.getElementById('dm-count');
4561  cnt.textContent = dmList.length > 1 ? (n + 1) + ' / ' + dmList.length : '';
4562
4563  const eb = document.getElementById('dm-edit');
4564  eb.style.display = serverAdmin ? '' : 'none';
4565
4566  // Lit for as long as it is being read about, so it is obvious which thing
4567  // in the photograph the window is describing.
4568  dmHighlight(id, true);
4569
4570  const panel = document.getElementById('dm-panel');
4571  panel.classList.add('open');
4572  panel.setAttribute('aria-hidden', 'false');
4573  panel.querySelector('.dm-scroll').scrollTop = 0;
4574  if (opts.focus !== false) dmFocus(p, opts.animate);
4575}
4576
4577function dmClosePanel(){
4578  if (dmOpenId) dmHighlight(dmOpenId, false);
4579
4580  const panel = document.getElementById('dm-panel');
4581  if (!panel) return;
4582  panel.classList.remove('open');
4583  panel.setAttribute('aria-hidden', 'true');
4584  // Stop a playing video dead rather than leaving it running behind the scenes.
4585  setTimeout(() => { if (!dmPanelIsOpen()) document.getElementById('dm-media').innerHTML = ''; }, 450);
4586  dmOpenId = null;
4587}
4588
4589/* ---------- Admin: creating and editing points ---------- */
4590
4591function dmNewId(){
4592  return 'dm-' + Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4);
4593}
4594function dmEditorOpen(point){
4595  if (!serverAdmin) return;
4596  dmDraft = point ? JSON.parse(JSON.stringify(point)) : null;
4597  const isNew = !dmDraft;
4598  if (isNew) dmDraft = { id: dmNewId(), yaw: null, pitch: null, title: '', text: '', links: [] };
4599
4600  document.getElementById('dm-ed-heading').textContent = isNew ? 'New Discover More point' : 'Edit Discover More point';
4601  document.getElementById('dm-f-title').value = dmDraft.title || '';
4602  document.getElementById('dm-f-text').value  = dmDraft.text || '';
4603
4604  const kind = (dmDraft.media && dmDraft.media.kind) || '';
4605  document.getElementById('dm-f-kind').value = kind;
4606  document.getElementById('dm-f-image').value = kind === 'image' ? (dmDraft.media.src || '') : '';
4607  document.getElementById('dm-f-yt').value    = kind === 'youtube' ? (dmDraft.media.src || '') : '';
4608  dmSyncKind();
4609  dmBuildLibrary();
4610  dmBuildLinkRows(dmDraft.links || []);
4611  document.getElementById('dm-f-upload-note').textContent = '';
4612  document.getElementById('dm-ed-delete').style.display = isNew ? 'none' : '';
4613
4614  dmClosePanel();
4615  const ed = document.getElementById('dm-editor');
4616  ed.classList.add('open');
4617  ed.setAttribute('aria-hidden', 'false');
4618
4619  if (isNew) dmStartPlacing();
4620  else { dmStopPlacing(); dmFocus(dmDraft, true); }
4621}
4622function dmEditorClose(){
4623  dmStopPlacing();
4624  const ed = document.getElementById('dm-editor');
4625  ed.classList.remove('open');
4626  ed.setAttribute('aria-hidden', 'true');
4627  dmDraft = null;
4628  dmRefreshMarkers();
4629}
4630function dmSyncKind(){
4631  const k = document.getElementById('dm-f-kind').value;
4632  document.getElementById('dm-f-image-wrap').style.display = k === 'image'   ? '' : 'none';
4633  document.getElementById('dm-f-yt-wrap').style.display    = k === 'youtube' ? '' : 'none';
4634}
4635/* The picture library is simply the map's own media — already loaded, no extra request. */
4636function dmBuildLibrary(){
4637  const box = document.getElementById('dm-f-library');
4638  if (!box || box.dataset.built === '1'){ dmMarkLibrarySelection(); return; }
4639  const pics = (locations || []).filter(l => l.thumbnail && (l.type === 'photo' || l.type === '360')).slice(0, 220);
4640  box.innerHTML = '';
4641  pics.forEach(l => {
4642    const im = document.createElement('img');
4643    im.src = l.thumbnail; im.alt = l.title || ''; im.title = l.title || '';
4644    im.dataset.src = l.image;
4645    im.addEventListener('click', () => {
4646      document.getElementById('dm-f-image').value = l.image;
4647      dmMarkLibrarySelection();
4648    });
4649    box.appendChild(im);
4650  });
4651  box.dataset.built = '1';
4652  dmMarkLibrarySelection();
4653}
4654function dmMarkLibrarySelection(){
4655  const cur = document.getElementById('dm-f-image').value.trim();
4656  document.querySelectorAll('#dm-f-library img').forEach(im => im.classList.toggle('sel', im.dataset.src === cur));
4657}
4658function dmBuildLinkRows(links){
4659  const box = document.getElementById('dm-f-links');
4660  box.innerHTML = '';
4661  (links && links.length ? links : []).forEach(l => dmAddLinkRow(l.label, l.url));
4662}
4663function dmAddLinkRow(label, url){
4664  const box = document.getElementById('dm-f-links');
4665  const row = document.createElement('div');
4666  row.style.cssText = 'border:1px solid var(--line,#e3e3e3);border-radius:8px;padding:8px;margin-bottom:8px';
4667  row.innerHTML =
4668    '<div class="dm-ed-row" style="margin-bottom:6px">'
4669    + '<input type="text" class="dm-l-label" maxlength="60" placeholder="Button text">'
4670    + '<button type="button" class="dm-l-del" title="Remove this link">&#10005;</button>'
4671    + '</div>'
4672    + '<input type="text" class="dm-l-url" placeholder="https://…">';
4673  box.appendChild(row);
4674  row.querySelector('.dm-l-label').value = label || '';
4675  row.querySelector('.dm-l-url').value = url || '';
4676  row.querySelector('.dm-l-del').addEventListener('click', () => row.remove());
4677}
4678function dmCollectForm(){
4679  if (!dmDraft) return null;
4680  const p = {
4681    id: dmDraft.id,
4682    yaw: dmDraft.yaw, pitch: dmDraft.pitch,
4683    title: document.getElementById('dm-f-title').value.trim(),
4684    text:  document.getElementById('dm-f-text').value.trim(),
4685    links: []
4686  };
4687  const kind = document.getElementById('dm-f-kind').value;
4688  if (kind === 'image'){
4689    const src = document.getElementById('dm-f-image').value.trim();
4690    if (src) p.media = { kind: 'image', src };
4691  } else if (kind === 'youtube'){
4692    const src = document.getElementById('dm-f-yt').value.trim();
4693    if (src) p.media = { kind: 'youtube', src };
4694  }
4695  document.querySelectorAll('#dm-f-links > div').forEach(row => {
4696    const url = row.querySelector('.dm-l-url').value.trim();
4697    if (!url) return;
4698    p.links.push({ label: row.querySelector('.dm-l-label').value.trim(), url });
4699  });
4700  return p;
4701}
4702function dmStartPlacing(){
4703  dmPlacing = true;
4704  document.getElementById('dm-editor').classList.add('placing');
4705  document.getElementById('psv-panel').classList.add('dm-placing');
4706}
4707function dmStopPlacing(){
4708  dmPlacing = false;
4709  document.getElementById('dm-editor').classList.remove('placing');
4710  document.getElementById('psv-panel').classList.remove('dm-placing');
4711}
4712/* Called from the viewer's click handler when we're waiting for a placement. */
4713function dmHandlePlacementClick(data){
4714  if (!dmPlacing || !dmDraft) return false;
4715  let y = (data.yaw * 180 / Math.PI) % 360; if (y < 0) y += 360;
4716  dmDraft.yaw = Math.round(y * 10) / 10;
4717  dmDraft.pitch = Math.round((data.pitch * 180 / Math.PI) * 10) / 10;
4718  dmStopPlacing();
4719  // Show it straight away so the admin can see where it landed.
4720  const preview = dmList.filter(x => x.id !== dmDraft.id).concat([Object.assign({}, dmDraft, { title: dmDraft.title || 'New point' })]);
4721  const keep = dmList; dmList = preview; dmRefreshMarkers(); dmList = keep;
4722  return true;
4723}
4724function dmSaveAll(list, okMsg){
4725  if (!viewerLoc) return Promise.resolve(false);
4726  const rel = decodeURIComponent(String(viewerLoc.image || '').replace(/^\/?images\//, ''));
4727  const btns = ['dm-ed-save','dm-ed-delete','dm-ed-cancel'].map(i => document.getElementById(i)).filter(Boolean);
4728  btns.forEach(b => b.disabled = true);
4729  return fetch('save-bearing.php', {
4730    method: 'POST', headers: { 'Content-Type': 'application/json' },
4731    body: JSON.stringify({ rel, discover: list })
4732  })
4733    .then(r => r.json().then(j => ({ status: r.status, body: j })))
4734    .then(({ status, body }) => {
4735      btns.forEach(b => b.disabled = false);
4736      if (status === 200 && body.ok){
4737        // Trust our own list; the server normalises but doesn't reorder.
4738        dmList = list.slice();
4739        viewerLoc.discover = dmList.length ? dmList : null;
4740        dmRefreshMarkers();
4741        return true;
4742      }
4743      if (status === 403) alert('Please sign in to the admin panel (open admin.php) first, then try again.');
4744      else alert('Could not save: ' + (body.error || ('HTTP ' + status)));
4745      return false;
4746    })
4747    .catch(() => { btns.forEach(b => b.disabled = false); alert('Could not reach the server.'); return false; });
4748}
4749
4750(function wireDiscoverMore(){
4751  const on = (id, ev, fn) => { const el = document.getElementById(id); if (el) el.addEventListener(ev, fn); };
4752
4753  on('dm-close', 'click', dmClosePanel);
4754  /* Clicking the darkened panorama closes it. Guarded on the target so a click
4755     that starts inside the card and drifts onto the overlay — selecting text,
4756     mostly — does not count. */
4757  on('dm-panel', 'click', e => { if (e.target && e.target.id === 'dm-panel') dmClosePanel(); });
4758  on('dm-share', 'click', () => {
4759    if (!viewerLoc || !dmOpenId) return;
4760    const p = dmList.find(x => x.id === dmOpenId);
4761    shareItem(viewerLoc.id, p ? (p.title || viewerLoc.title) : viewerLoc.title, '&more=' + encodeURIComponent(dmOpenId));
4762  });
4763  on('dm-edit', 'click', () => {
4764    const p = dmList.find(x => x.id === dmOpenId);
4765    if (p) dmEditorOpen(p);
4766  });
4767
4768  on('psv-dm-add', 'click', () => dmEditorOpen(null));
4769  on('dm-ed-close', 'click', dmEditorClose);
4770  on('dm-ed-cancel', 'click', dmEditorClose);
4771  on('dm-f-kind', 'change', dmSyncKind);
4772  on('dm-f-image', 'input', dmMarkLibrarySelection);
4773  on('dm-f-addlink', 'click', () => dmAddLinkRow('', ''));
4774  on('dm-f-move', 'click', () => { if (dmDraft) dmStartPlacing(); });
4775  on('dm-place-cancel', 'click', dmStopPlacing);
4776
4777  on('dm-f-upload', 'click', () => document.getElementById('dm-f-file').click());
4778  on('dm-f-file', 'change', function(){
4779    const f = this.files && this.files[0];
4780    if (!f) return;
4781    const note = document.getElementById('dm-f-upload-note');
4782    note.textContent = 'Uploading…';
4783    const fd = new FormData();
4784    fd.append('logo', f);            // same receiver as the venue-logo picker
4785    fd.append('dest', 'discover');   // …but filed under /discover/
4786    fetch('upload-logo.php', { method: 'POST', body: fd })
4787      .then(r => r.json())
4788      .then(j => {
4789        if (j && j.ok && j.url){
4790          document.getElementById('dm-f-image').value = j.url;
4791          dmMarkLibrarySelection();
4792          note.textContent = 'Uploaded.';
4793        } else note.textContent = (j && j.error) || 'Upload failed.';
4794      })
4795      .catch(() => { note.textContent = 'Upload failed — could not reach the server.'; });
4796    this.value = '';
4797  });
4798
4799  on('dm-ed-save', 'click', () => {
4800    const p = dmCollectForm();
4801    if (!p) return;
4802    if (p.yaw == null || p.pitch == null){
4803      alert('Click the spot in the panorama first so the point knows where it belongs.');
4804      dmStartPlacing();
4805      return;
4806    }
4807    if (!p.title && !p.text && !p.media){
4808      alert('Give it at least a title, some text or a picture.');
4809      return;
4810    }
4811    const next = dmList.filter(x => x.id !== p.id).concat([p]);
4812    dmSaveAll(next).then(ok => {
4813      if (!ok) return;
4814      dmEditorClose();
4815      dmOpenPoint(p.id, { animate: true });
4816    });
4817  });
4818
4819  on('dm-ed-delete', 'click', () => {
4820    if (!dmDraft) return;
4821    if (!confirm('Delete this Discover More point? This cannot be undone.')) return;
4822    const next = dmList.filter(x => x.id !== dmDraft.id);
4823    dmSaveAll(next).then(ok => { if (ok) dmEditorClose(); });
4824  });
4825})();
4826
4827/* Hotspot editor (admin): hold the mouse down on any arrow and DRAG it to
4828 * where it belongs — direction and height per arrow. Release, then Save.
4829 * A plain click (no drag) still steps into the linked 360 as normal.
4830 * Placements are stored per pano and override the GPS-computed positions
4831 * for just those arrows; new 360s still get automatic arrows. */
4832let hsPending = {};   // targetId -> {yaw,pitch} degrees, absolute in the image
4833let _hsDrag = null;   // { ref, moved, startX, startY }
4834
4835function hsHint(msg){
4836  const h = document.querySelector('#align-bar .align-hint');
4837  if (h) h.textContent = msg;
4838}
4839function hsBar(on){
4840  const bar = document.getElementById('align-bar');
4841  if (bar) bar.classList.toggle('on', !!on);
4842}
4843function _viewerSph(clientX, clientY){
4844  const el = document.getElementById('psv-viewer'); if (!el || !viewer) return null;
4845  const r = el.getBoundingClientRect();
4846  try {
4847    const sph = viewer.dataHelper.viewerCoordsToSphericalCoords({ x: clientX - r.left, y: clientY - r.top });
4848    if (sph && typeof sph.yaw === 'number') return sph;
4849  } catch(e){}
4850  return null;
4851}
4852function _hsHit(clientX, clientY){
4853  if (!viewer || !viewerMP || !hotspotRefs.length) return null;
4854  const el = document.getElementById('psv-viewer'); if (!el) return null;
4855  const r = el.getBoundingClientRect();
4856  const px = clientX - r.left, py = clientY - r.top;
4857  let best = null, bestD = Infinity;
4858  hotspotRefs.forEach(ref => {
4859    try {
4860      const m = viewerMP.getMarker(ref.id);
4861      const pos = m && m.config && m.config.position; if (!pos) return;
4862      const vc = viewer.dataHelper.sphericalCoordsToViewerCoords(pos);
4863      if (!vc) return;
4864      const d = Math.hypot(vc.x - px, (vc.y + 40) - py);   // chip hangs below its anchor
4865      if (d < bestD){ bestD = d; best = ref; }
4866    } catch(e){}
4867  });
4868  return (best && bestD < 95) ? best : null;
4869}
4870function onHsDown(e){
4871  if (!serverAdmin || !viewerLoc || calibration) return;
4872  if (e.button != null && e.button !== 0) return;
4873  const ref = _hsHit(e.clientX, e.clientY);
4874  if (!ref) return;
4875  e.stopPropagation(); e.preventDefault();   // keep PSV from panning while we drag the chip
4876  _hsDrag = { ref, moved: false, startX: e.clientX, startY: e.clientY };
4877  try { document.getElementById('psv-viewer').setPointerCapture(e.pointerId); } catch(_){}
4878}
4879function onHsMove(e){
4880  if (!_hsDrag) return;
4881  e.stopPropagation(); e.preventDefault();
4882  if (!_hsDrag.moved && Math.hypot(e.clientX - _hsDrag.startX, e.clientY - _hsDrag.startY) < 5) return;
4883  const sph = _viewerSph(e.clientX, e.clientY); if (!sph) return;
4884  _hsDrag.moved = true;
4885  try { viewerMP.updateMarker({ id: _hsDrag.ref.id, position: { yaw: sph.yaw, pitch: sph.pitch }, opacity: 1 }); } catch(err){}
4886}
4887function onHsUp(e){
4888  if (!_hsDrag) return;
4889  const { ref, moved } = _hsDrag; _hsDrag = null;
4890  if (!moved){
4891    // plain click: with the editor open it selects the arrow, otherwise step inside
4892    if (edOpen()) edSelect(ref);
4892 else openItem(ref.otherIdx);
4893    return;
4894  }
4895  try {
4896    const m = viewerMP.getMarker(ref.id);
4897    const pos = m && m.config && m.config.position;
4898    if (pos){
4899      edMerge(ref.targetId, {
4900        yaw:   Math.round(normDeg(toDeg(pos.yaw)) * 10) / 10,
4901        pitch: Math.round(Math.max(-85, Math.min(85, toDeg(pos.pitch))) * 10) / 10
4902      });
4903      if (edOpen()){ edSelect(ref); }
4904      else { hsBar(true); hsHint('“' + (ref.title || 'Arrow') + '” moved — drag others too if needed, then Save.'); }
4905    }
4906  } catch(err){}
4907}
4908function buildMergedHotspots(){
4909  const base = (viewerLoc && viewerLoc.hotspots) || {};
4910  const out = {};
4911  Object.keys(base).forEach(t => out[t] = Object.assign({}, base[t]));
4912  Object.keys(hsPending).forEach(t => {
4913    const dst = Object.assign({}, out[t], hsPending[t]);
4914    if (dst._resetPos){ delete dst.yaw; delete dst.pitch; }
4915    delete dst._resetPos;
4916    ['icon','img','label'].forEach(k => { if (dst[k] === '' || dst[k] == null) delete dst[k]; });
4917    if (dst.yaw == null || dst.yaw === '') delete dst.yaw;
4918    if (dst.pitch == null || dst.pitch === '') delete dst.pitch;
4919    if (!(+dst.size) || Math.abs(+dst.size - 1) < 0.005) delete dst.size;
4920    if (Object.keys(dst).length) out[t] = dst; else delete out[t];
4921  });
4922  return out;
4923}
4924function saveAlign(){
4925  if (!viewerLoc) return;
4926  const arrowsChanged = Object.keys(hsPending).length > 0;
4927  const itemChanged   = Object.keys(edItem).length > 0;
4928  if (!arrowsChanged && !itemChanged){ hsHint('Nothing changed yet.'); return; }
4929  const rel = decodeURIComponent(String(viewerLoc.image || '').replace(/^\/?images\//, ''));
4930  const payload = { rel };
4931  if (arrowsChanged){
4932    payload.hotspots = buildMergedHotspots();
4933    payload.bearing  = +(viewerLoc.bearing || 0);
4934    payload.tilt     = +(viewerLoc.tilt || 0);
4935  }
4936  if (edItem.type) payload.type = edItem.type;
4937  if (edItem.category) payload.category = edItem.category;
4938  if (edItem.interior != null) payload.interior = edItem.interior;
4939  const btns = [document.getElementById('align-save'), document.getElementById('ed-save')].filter(Boolean);
4940  btns.forEach(b => b.disabled = true);
4941  fetch('save-bearing.php', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) })
4942    .then(r => r.json().then(j => ({ status: r.status, body: j })))
4943    .then(({ status, body }) => {
4944      btns.forEach(b => b.disabled = false);
4945      if (status === 200 && body.ok){
4946        if (itemChanged){ location.reload(); return; }   // type/group/interior reshape pins & lists
4947        const merged = payload.hotspots || {};
4948        viewerLoc.hotspots = Object.keys(merged).length ? merged : null;
4949        viewerLoc.calibrated = 1; hsPending = {}; hsBar(false);
4950        try { buildMenu(); } catch(e){}   // sidebar ticks update straight away
4951        setTimeout(() => openViewer(viewerIdx), 150);
4952      }
4953      else if (status === 403){ alert('Please sign in to the admin panel (open admin.php) first, then try again.'); }
4954      else { alert('Could not save: ' + (body.error || ('HTTP ' + status))); }
4955    })
4956    .catch(() => { btns.forEach(b => b.disabled = false); alert('Could not reach the server.'); });
4957}
4958(function wireHotspotEditor(){
4959  document.getElementById('align-save')?.addEventListener('click', saveAlign);
4960  document.getElementById('align-cancel')?.addEventListener('click', () => {
4961    hsPending = {}; hsBar(false);
4962    setTimeout(() => openViewer(viewerIdx), 100);   // reload discards unsaved moves
4963  });
4964  const vw = document.getElementById('psv-viewer');
4965  if (vw){
4966    vw.addEventListener('pointerdown', onHsDown, true);   // capture: act before PSV
4967    vw.addEventListener('pointermove', onHsMove, true);
4968    window.addEventListener('pointerup', onHsUp, true);
4969  }
4970})();
4971
4972/* ---------- Editor drawer (admin): item settings + arrow styling ---------- */
4973const ED_ICONS = ['360','EXIT','🚪','ℹ️','📷','🏛️','⛪','🍽️','🍺','☕','🛍️','🎭','🎵','⚽','🌳','🏨'];
4974let edItem = {};        // pending item-level changes: type / category / interior
4975let edSelRef = null;    // currently selected hotspot ref
4976let edLogos = [];       // previously uploaded logos (reusable from the grid)
4977let edLogosLoaded = false;
4978function edLoadLogos(){
4979  fetch('upload-logo.php', { headers: { 'Accept': 'application/json' } })
4980    .then(r => r.json())
4981    .then(j => { if (j && j.ok && Array.isArray(j.logos)){ edLogos = j.logos; edLogosLoaded = true;
4982                 if (edSelRef) edPaintLogos(edCurOv(edSelRef.targetId)); } })
4983    .catch(() => {});
4984}
4985
4986function edOpen(){ const d = document.getElementById('ed-drawer'); return !!(d && d.classList.contains('open')); }
4987function edToggle(on){
4988  const d = document.getElementById('ed-drawer'); if (!d) return;
4989  d.classList.toggle('open', on);
4990  d.setAttribute('aria-hidden', on ? 'false' : 'true');
4991  if (on){ edPopulate(); if (!edLogosLoaded) edLoadLogos(); }
4992  else edDeselect();
4993}
4994function edPopulate(){
4995  if (!viewerLoc) return;
4996  edItem = {};
4997  const t = document.getElementById('ed-type');
4998  t.value = (viewerLoc.type === '360video') ? '360' : (viewerLoc.type || '360');
4999  t.disabled = viewerLoc.type === '360video';
5000  document.getElementById('ed-category').value = viewerLoc.category || '';
5001  document.getElementById('ed-cats').innerHTML =
5002    [...new Set(locations.map(l => l.category).filter(Boolean))].sort()
5003      .map(c => '<option value="' + escapeAttr(c) + '"></option>').join('');
5004  document.getElementById('ed-interior').checked = !!viewerLoc.interior;
5005  edDeselect();
5006}
5007function edCurOv(tid){ return Object.assign({}, ((viewerLoc && viewerLoc.hotspots) || {})[tid], hsPending[tid]); }
5008function edDeselect(){
5009  if (edSelRef) edApplyPreview(edSelRef);
5010  edSelRef = null;
5011  const tools = document.getElementById('ed-arrow-tools'); if (tools) tools.style.display = 'none';
5012  const note  = document.getElementById('ed-arrow-note');  if (note)  note.style.display = '';
5013}
5014function edSelect(ref){
5015  if (!ref || !ref.entry) return;
5016  if (!edOpen()) edToggle(true);
5017  if (edSelRef && edSelRef !== ref) edApplyPreview(edSelRef);
5018  edSelRef = ref;
5019  document.getElementById('ed-arrow-note').style.display = 'none';
5020  document.getElementById('ed-arrow-tools').style.display = '';
5021  document.getElementById('ed-target-name').textContent = ref.title || 'Arrow';
5022  const ov = edCurOv(ref.targetId);
5023  document.getElementById('ed-logo').value = ov.img || '';
5024  const sz = Math.round(((+ov.size) || 1) * 100);
5025  document.getElementById('ed-size').value = sz;
5026  document.getElementById('ed-size-val').textContent = sz + '%';
5027  edPaintIcons(ov);
5028  try { viewerMP.updateMarker({ id: ref.id, opacity: 1 }); } catch(e){}
5029}
5030function edPaintIcons(ov){
5031  const cur = ov.img ? null : (ov.icon || ((viewerLoc && viewerLoc.interior) ? 'EXIT' : '360'));
5032  const wrap = document.getElementById('ed-icons'); if (!wrap) return;
5033  wrap.innerHTML = ED_ICONS.map(g =>
5034    '<button type="button" class="ed-ic' + (g === cur ? ' on' : '') + '" data-g="' + escapeAttr(g) + '">' + escapeHtml(g) + '</button>').join('');
5035  wrap.querySelectorAll('.ed-ic').forEach(b => b.onclick = () => {
5036    if (!edSelRef) return;
5037    const g = b.dataset.g;
5038    edMerge(edSelRef.targetId, { icon: (g === '360' || g === 'EXIT') ? '' : g, img: '' });
5039    document.getElementById('ed-logo').value = '';
5040    edApplyPreview(edSelRef);
5041    edPaintIcons(edCurOv(edSelRef.targetId));
5042  });
5043  edPaintLogos(ov);
5044}
5045function edPaintLogos(ov){
5046  const wrap = document.getElementById('ed-logos'); if (!wrap) return;
5047  wrap.innerHTML = edLogos.map(u =>
5048    '<button type="button" class="ed-ic' + (ov.img === u ? ' on' : '') + '" data-u="' + escapeAttr(u) + '" title="' + escapeAttr(u.split('/').pop()) + '">'
5049    + '<img src="' + escapeAttr(u) + '" alt=""></button>').join('');
5050  wrap.querySelectorAll('.ed-ic').forEach(b => b.onclick = () => {
5051    if (!edSelRef) return;
5052    const u = b.dataset.u;
5053    document.getElementById('ed-logo').value = u;
5054    edMerge(edSelRef.targetId, { img: u, icon: '' });
5055    edApplyPreview(edSelRef);
5056    edPaintIcons(edCurOv(edSelRef.targetId));
5057  });
5058}
5059function edMerge(tid, props){
5060  hsPending[tid] = Object.assign({}, hsPending[tid], props);
5061}
5062function edApplyPreview(ref){
5063  if (!viewerMP || !ref || !ref.entry || !viewerLoc) return;
5064  const ov = edCurOv(ref.targetId);
5065  if (ov._resetPos){ delete ov.yaw; delete ov.pitch; }
5066  delete ov._resetPos;
5067  ['icon','img','label'].forEach(k => { if (ov[k] === '') delete ov[k]; });
5068  try { viewerMP.updateMarker(hotspotMarkerConfig(viewerLoc, ref.entry, Object.keys(ov).length ? ov : null)); } catch(e){}
5069}
5070(function wireEditorDrawer(){
5071  document.getElementById('psv-edit')?.addEventListener('click', () => edToggle(!edOpen()));
5072  document.getElementById('ed-close')?.addEventListener('click', () => {
5073    if (Object.keys(hsPending).length || Object.keys(edItem).length){
5074      if (!confirm('Discard unsaved changes?')) return;
5075      hsPending = {}; edItem = {};
5076      edToggle(false);
5077      setTimeout(() => openViewer(viewerIdx), 100);
5078      return;
5079    }
5080    edToggle(false);
5081  });
5082  document.getElementById('ed-save')?.addEventListener('click', saveAlign);
5083  document.getElementById('ed-type')?.addEventListener('change', e => { edItem.type = e.target.value; });
5084  document.getElementById('ed-category')?.addEventListener('input', e => { edItem.category = e.target.value.trim(); });
5085  document.getElementById('ed-interior')?.addEventListener('change', e => { edItem.interior = e.target.checked ? 1 : 0; });
5086  document.getElementById('ed-logo')?.addEventListener('change', e => {
5087    if (!edSelRef) return;
5088    edMerge(edSelRef.targetId, { img: e.target.value.trim(), icon: '' });
5089    edApplyPreview(edSelRef);
5090    edPaintIcons(edCurOv(edSelRef.targetId));
5091  });
5092  // Logo upload: pick a file, it lands in /logos/ and applies to the arrow at once.
5093  document.getElementById('ed-logo-upload')?.addEventListener('click', () => {
5094    if (!edSelRef){ alert('Select an arrow first, then upload its logo.'); return; }
5095    document.getElementById('ed-logo-file').click();
5096  });
5097  document.getElementById('ed-logo-file')?.addEventListener('change', e => {
5098    const file = e.target.files && e.target.files[0];
5099    e.target.value = '';
5100    if (!file || !edSelRef) return;
5101    const note = document.getElementById('ed-logo-note');
5102    note.textContent = 'Uploading ' + file.name + '…';
5103    const fd = new FormData();
5104    fd.append('logo', file);
5105    fetch('upload-logo.php', { method: 'POST', body: fd })
5106      .then(r => r.json().then(j => ({ status: r.status, body: j })))
5107      .then(({ status, body }) => {
5108        if (status === 200 && body.ok && body.url){
5109          note.textContent = '';
5110          if (!edLogos.includes(body.url)) edLogos.unshift(body.url);   // reusable from the grid
5111          document.getElementById('ed-logo').value = body.url;
5112          edMerge(edSelRef.targetId, { img: body.url, icon: '' });
5113          edApplyPreview(edSelRef);
5114          edPaintIcons(edCurOv(edSelRef.targetId));
5115        } else {
5116          note.textContent = body.error || ('Upload failed (HTTP ' + status + ')');
5117        }
5118      })
5119      .catch(() => { note.textContent = 'Upload failed — could not reach the server.'; });
5120  });
5121  document.getElementById('ed-size')?.addEventListener('input', e => {
5122    if (!edSelRef) return;
5123    const v = Math.max(50, Math.min(250, +e.target.value || 100));
5124    document.getElementById('ed-size-val').textContent = v + '%';
5125    edMerge(edSelRef.targetId, { size: v / 100 });
5126    edApplyPreview(edSelRef);
5127  });
5128  document.getElementById('ed-reset-pos')?.addEventListener('click', () => {
5129    if (!edSelRef) return;
5130    edMerge(edSelRef.targetId, { _resetPos: true });
5131    edApplyPreview(edSelRef);
5132  });
5133})();
5134
5135/* ============================================================
5136 * Lightbox modal (photos + video)
5137 * ============================================================ */
5138const INFO_KINDS = ['story','event','venue','seasonal','business'];
5139function fmtDate(s){ if(!s) return '';
5139 const d=new Date((s||'').replace(' ','T')); return isNaN(d)?String(s):d.toLocaleDateString(undefined,{weekday:'short',day:'numeric',month:'long',year:'numeric'}); }
5140function fmtDateTime(s){ if(!s) return ''; const d=new Date((s||'').replace(' ','T')); if(isNaN(d)) return String(s); const date=d.toLocaleDateString(undefined,{day:'numeric',month:'long'}); const hasTime=/\d{1,2}:\d{2}/.test(s); return hasTime? date+', '+d.toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}) : date; }
5141/* YouTube 360 video — opens in the lightbox using YouTube's spherical player.
5142   Drag to look on desktop/mobile; the player's own goggles button gives VR.
5143   Streams from YouTube, so it doesn't buffer like a self-hosted file. */
5144function openYouTube360(loc){
5145  const vid = loc.youtube;
5146  const src = 'https://www.youtube.com/embed/' + encodeURIComponent(vid)
5147            + '?rel=0&modestbranding=1&playsinline=1&autoplay=1&mute=1';
5148  const stage = document.getElementById('lb-stage');
5149  stage.innerHTML =
5150    '<div style="position:relative;width:min(100%,1100px);aspect-ratio:16/9;max-height:80vh;margin:auto;background:#000;border-radius:8px;overflow:hidden">'
5151    + '<iframe src="' + escapeAttr(src) + '" style="position:absolute;inset:0;width:100%;height:100%;border:0" '
5152    + 'allow="accelerometer; autoplay; encrypted-media; gyroscope; fullscreen; xr-spatial-tracking" '
5153    + 'allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe></div>';
5154  document.getElementById('lb-title').textContent  = loc.title || '360° video';
5155  document.getElementById('lb-cat').textContent    = [loc.category, loc.section].filter(Boolean).join(' · ');
5156  document.getElementById('lb-desc').textContent   = loc.description || 'Drag to look around. Use the player’s goggles button for VR.';
5157  document.getElementById('lb-credit').textContent = loc.credit ? ('© ' + loc.credit) : '';
5158  document.getElementById('lb-meta').innerHTML     = '';
5159  const explore = document.getElementById('lb-explore');
5160  const watch = safeUrl(loc.link) || ('https://www.youtube.com/watch?v=' + vid);
5161  explore.href = watch; explore.textContent = 'Watch in VR on YouTube →'; explore.style.display = 'inline-block';
5162  const req = document.getElementById('lb-request'); if (req) req.style.display = 'none';
5163  const shb = document.getElementById('lb-share'); if (shb) shb.style.display = '';
5164  const modal = document.getElementById('lb-modal');
5165  modal.classList.add('open'); modal.setAttribute('aria-hidden', 'false');
5166}
5167
5168function openModal(idx){
5169  const loc = locations[idx];
5170  if (!loc) return;
5171  const kind = loc.kind || loc.type;
5172  const isInfo = INFO_KINDS.indexOf(kind) !== -1;
5173  const stage = document.getElementById('lb-stage');
5174  if (loc.type === 'video'){
5175    stage.innerHTML = `<video src="${escapeAttr(loc.image)}" controls autoplay playsinline ${loc.thumbnail?`poster="${escapeAttr(loc.thumbnail)}"`:''}></video>`;
5176  } else if (loc.image){
5177    stage.innerHTML = `<img src="${escapeAttr(loc.image)}" alt="${escapeAttr(loc.title)}">`;
5178  } else {
5179    stage.innerHTML = `<div class="lb-noimg">${escapeHtml(loc.title)}</div>`;
5180  }
5181  document.getElementById('lb-title').textContent = loc.title;
5182  document.getElementById('lb-cat').textContent   = [loc.category, loc.section].filter(Boolean).join(' · ');
5183  document.getElementById('lb-desc').textContent  = loc.description || '';
5184  document.getElementById('lb-credit').textContent = isInfo ? '' : copyrightText(loc);
5185
5186  // Extra meta for stories / events / venues
5187  const meta = document.getElementById('lb-meta');
5188  let m = '';
5189  if (kind === 'event' && loc.starts_at){
5190    m += '<div><strong>When:</strong> ' + escapeHtml(fmtDateTime(loc.starts_at))
5191       + (loc.ends_at ? ' – ' + escapeHtml(fmtDateTime(loc.ends_at)) : '') + '</div>';
5192    if (loc.address) m += '<div><strong>Where:</strong> ' + escapeHtml(loc.address) + '</div>';
5193  }
5194  if (kind === 'venue'){
5195    if (loc.opening_hours){ m += openNowBadge(loc.opening_hours) + '<div class="oh">' + escapeHtml(loc.opening_hours) + '</div>'; }
5196    if (loc.address) m += '<div><strong>Address:</strong> ' + escapeHtml(loc.address) + '</div>';
5197    if (loc.phone)   m += '<div><strong>Phone:</strong> ' + escapeHtml(loc.phone) + '</div>';
5198    if (safeUrl(loc.website)) m += '<div><a href="' + escapeAttr(safeUrl(loc.website)) + '" target="_blank" rel="noopener">Visit website ↗</a></div>';
5199  }
5200  meta.innerHTML = m;
5201
5202  const explore = document.getElementById('lb-explore');
5203  const label = kind==='story' ? 'Read the full article →' : kind==='event' ? 'Event details →' : kind==='venue' ? 'More information →' : 'Explore →';
5204  const exploreUrl = safeUrl(loc.link);
5205  if (exploreUrl){ explore.href = exploreUrl; explore.textContent = label; explore.style.display = 'inline-block'; }
5206  else { explore.style.display = 'none'; explore.removeAttribute('href'); }
5207  // The "request to use this image" button only applies to our own media
5208  const req = document.getElementById('lb-request');
5209  if (req) req.style.display = isInfo ? 'none' : '';
5210  const shb = document.getElementById('lb-share');
5211  if (shb) shb.style.display = '';
5212
5213  const modal = document.getElementById('lb-modal');
5214  modal.classList.add('open');
5215  modal.setAttribute('aria-hidden', 'false');
5216}
5217/* Open now / Closed from a free-text opening-hours block (best-effort). */
5218function openNowBadge(text){
5219  try{
5220    const days=['sun','mon','tue','wed','thu','fri','sat'];
5221    const now=new Date(); const dn=days[now.getDay()]; const mins=now.getHours()*60+now.getMinutes();
5222    const lines=String(text).split(/\n|;/);
5223    for(const ln of lines){
5224      const low=ln.toLowerCase();
5225      if(low.indexOf(dn)===-1) continue;
5226      if(/closed/.test(low)) return '<span class="open-badge closed">Closed today</span>';
5227      const t=low.match(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*[-–to]+\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/);
5228      if(t){ const to24=(h,m,ap)=>{h=+h;m=m?+m:0;if(ap==='pm'&&h<12)h+=12;if(ap==='am'&&h===12)h=0;return h*60+m;};
5229        const o=to24(t[1],t[2],t[3]), c=to24(t[4],t[5],t[6]);
5230        return (mins>=o&&mins<=c)?'<span class="open-badge open">Open now</span>':'<span class="open-badge closed">Closed now</span>';
5231      }
5232    }
5233  }catch(e){}
5234  return '';
5235}
5236function closeModal(keepActive){
5237  const modal = document.getElementById('lb-modal');
5238  if (!modal.classList.contains('open')) { if(!keepActive) clearActive(); return; }
5239  modal.classList.remove('open');
5240  modal.setAttribute('aria-hidden', 'true');
5241  document.getElementById('lb-stage').innerHTML = '';  // stop video playback
5242  /*
5243   * Come out of the picture onto its pin.
5244   *
5245   * You reach the lightbox from a search result or a card as often as from the
5246   * map, so closing it used to drop you wherever the map happened to be — a
5247   * different part of the city from the thing you were just looking at, with
5248   * nothing to say where it had been. `keepActive` means we are handing over to
5249   * the panorama viewer rather than going back to the map, so leave it alone.
5250   */
5251  if (!keepActive){ focusMapOn(currentIdx, true); clearActive(); }
5252}
5253function clearActive(){
5254  Object.values(markers).forEach(m => {
5255    if (!m._icon) return;
5256    const pin = m._icon.querySelector('.cn360-pin');
5257    if (pin) pin.classList.remove('active');
5258  });
5259  document.querySelectorAll('.cn360-card.active').forEach(c => c.classList.remove('active'));
5260}
5261
5262/* ---------- Prev/next within the visible (filtered) set ---------- */
5263function goRel(dir){
5264  if (!order.length) return;
5265  let pos = order.indexOf(currentIdx);
5266  if (pos === -1) pos = 0;
5267  const next = order[(pos + dir + order.length) % order.length];
5268  openItem(next);
5269}
5270
5271/* ============================================================
5272 * CALIBRATION (admin-only) — 360s only
5273 * ============================================================ */
5274function openCalibrateModal(){
5275  if (currentIdx < 0) return;
5276  const loc = locations[currentIdx];
5277  const select = document.getElementById('calib-target');
5278  const candidates = locations
5279    .map((other, i) => ({ other, i, dist: distanceM(loc.lat, loc.lng, other.lat, other.lng) }))
5280    .filter(p => p.i !== currentIdx && p.dist > 30)
5281    .sort((a, b) => a.dist - b.dist).slice(0, 20);
5282  select.innerHTML = candidates.map(p =>
5283    `<option value="${p.i}">${escapeHtml(shortLabel(p.other.title))} — ${Math.round(p.dist)} m</option>`
5284  ).join('');
5285  document.getElementById('calib-result-wrap').innerHTML = '';
5286  document.getElementById('calib-modal').classList.add('show');
5287  document.getElementById('calib-modal-start').textContent = 'Start picking';
5288}
5289function closeCalibrateModal(){
5290  document.getElementById('calib-modal').classList.remove('show');
5291  document.getElementById('calib-prompt').classList.remove('show');
5292  calibration = null;
5293}
5294function startCalibratePicking(){
5295  const targetIdx = parseInt(document.getElementById('calib-target').value, 10);
5296  if (isNaN(targetIdx)) return;
5297  const loc = locations[currentIdx], target = locations[targetIdx];
5298  const trueBearing = bearingDeg(loc.lat, loc.lng, target.lat, target.lng);
5299  calibration = { targetIdx, targetLoc: target, trueBearing };
5300  document.getElementById('calib-modal').classList.remove('show');
5301  const prompt = document.getElementById('calib-prompt');
5302  document.getElementById('calib-prompt-text').textContent =
5303    'Now click directly on ' + shortLabel(target.title) + ' in the panorama →';
5304  prompt.classList.add('show');
5305}
5306function handleCalibrationClick(eventData){
5307  if (!calibration) return false;
5308  const clickYawDeg = ((eventData.yaw * 180 / Math.PI) + 360) % 360;
5309  let bearing = (calibration.trueBearing - clickYawDeg + 360) % 360;
5310  bearing = Math.round(bearing * 10) / 10;
5311  saveBearing(locations[currentIdx].id, bearing, calibration.targetLoc, clickYawDeg);
5312  return true;
5313}
5314function saveBearing(id, bearing, target, clickYawDeg){
5315  document.getElementById('calib-prompt').classList.remove('show');
5316  document.getElementById('calib-modal').classList.add('show');
5317  document.getElementById('calib-modal-title').textContent = 'Calibration result';
5318  const sidecarBody = JSON.stringify({ bearing }, null, 2);
5319  document.getElementById('calib-result-wrap').innerHTML =
5320      '<p><strong>Bearing computed: ' + bearing + '°</strong> '
5321    + '(you clicked at ' + clickYawDeg.toFixed(1) + '°, target is at ' + calibration.trueBearing.toFixed(1) + '° true bearing)</p>'
5322    + '<div class="result-box" id="calib-save-status">Working…</div>';
5323  fetch('save-bearing.php?admin=' + encodeURIComponent(adminToken), {
5324    method: 'POST', headers: { 'Content-Type': 'application/json' },
5325    body: JSON.stringify({ id, bearing })
5326  })
5327    .then(r => r.json().then(j => ({ status: r.status, body: j })))
5328    .then(({ status, body }) => {
5329      const box = document.getElementById('calib-save-status');
5330      if (status === 200 && body.ok) {
5331        box.innerHTML = '<span class="ok-line">✓ Saved. Reloading…</span>';
5332        setTimeout(() => { closeCalibrateModal(); openViewer(currentIdx); }, 1500);
5333      } else {
5334        box.innerHTML = '<strong>Save failed:</strong> ' + escapeHtml(body.error || ('HTTP ' + status))
5335          + '<br><br>Manual sidecar:\n' + escapeHtml(sidecarBody);
5336      }
5337    })
5338    .catch(err => {
5339      document.getElementById('calib-save-status').innerHTML =
5340        '<strong>Couldn\'t reach save-bearing.php.</strong> ' + escapeHtml(err.message)
5341        + '<br><br>Manual sidecar:\n' + escapeHtml(sidecarBody);
5342    });
5343}
5344
5345function toggleFullscreen(){
5346  const panel = document.getElementById('psv-panel');
5347  if (!document.fullscreenElement) panel.requestFullscreen?.() || panel.webkitRequestFullscreen?.();
5348  else document.exitFullscreen?.() || document.webkitExitFullscreen?.();
5349}
5350
5351/* ---------- Wiring ---------- */
5352document.getElementById('psv-close').onclick       = closeViewer;
5353document.getElementById('psv-prev').onclick        = () => goRel(-1);
5354document.getElementById('psv-next').onclick        = () => goRel(+1);
5355document.getElementById('psv-fullscreen').onclick  = toggleFullscreen;
5356document.getElementById('psv-back-to-map').onclick = closeViewer;
5357
5358document.getElementById('lb-close').onclick = () => closeModal(false);
5359document.getElementById('lb-done').onclick  = () => closeModal(false);
5360document.getElementById('lb-x').onclick     = () => closeModal(false);
5361/* Share dialog */
5362document.getElementById('share-close').onclick = closeShareModal;
5363document.getElementById('share-copy').onclick  = copyShareUrl;
5364document.getElementById('share-copy2').onclick = copyShareUrl;
5365document.getElementById('share-modal').addEventListener('click', e => { if (e.target.id === 'share-modal') closeShareModal(); });
5366document.getElementById('lb-share').onclick    = () => { if (currentIdx >= 0) shareItem(locations[currentIdx].id, locations[currentIdx].title); };
5367document.getElementById('lb-prev').onclick  = () => goRel(-1);
5368document.getElementById('lb-next').onclick  = () => goRel(+1);
5369document.getElementById('lb-modal').addEventListener('click', (e) => {
5370  if (e.target.id === 'lb-modal') closeModal(false);   // click backdrop to close
5371});
5372
5373/* ---- Request to use this image ---- */
5374function openRequestModal(){
5375  if (currentIdx < 0) return;
5376  const loc = locations[currentIdx];
5377  const form = document.getElementById('req-form');
5378  form.reset();
5379  document.getElementById('req-image').value     = loc.title || loc.id || '';
5380  document.getElementById('req-image-url').value = location.origin + (loc.image || '');
5381  document.getElementById('req-image-name').textContent = loc.title ? ('Image: ' + loc.title) : '';
5382  const st = document.getElementById('req-status'); st.textContent = ''; st.className = 'req-status';
5383  form.querySelectorAll('input,textarea,button').forEach(el => el.disabled = false);
5384  const modal = document.getElementById('req-modal');
5385  modal.classList.add('open'); modal.setAttribute('aria-hidden', 'false');
5386}
5387function closeRequestModal(){
5388  const modal = document.getElementById('req-modal');
5389  modal.classList.remove('open'); modal.setAttribute('aria-hidden', 'true');
5390}
5391document.getElementById('lb-request').onclick  = openRequestModal;
5392document.getElementById('psv-request').onclick = openRequestModal;
5393document.getElementById('req-close').onclick   = closeRequestModal;
5394document.getElementById('req-cancel').onclick  = closeRequestModal;
5395document.getElementById('req-modal').addEventListener('click', e => { if (e.target.id === 'req-modal') closeRequestModal(); });
5396document.getElementById('req-form').addEventListener('submit', async (e) => {
5397  e.preventDefault();
5398  const form = e.target, btn = document.getElementById('req-submit'), st = document.getElementById('req-status');
5399  const old = btn.textContent; btn.disabled = true; btn.textContent = 'Sending…';
5400  try {
5401    const r = await fetch('request-image.php', { method: 'POST', body: new FormData(form) });
5402    const j = await r.json();
5403    if (j.ok){
5404      st.className = 'req-status ok';
5405      st.textContent = 'Thank you — your request has been received. We’ll be in touch by email.';
5406      btn.textContent = 'Sent ✓';
5407      form.querySelectorAll('input,textarea').forEach(el => el.disabled = true);
5408      document.getElementById('req-cancel').textContent = 'Close';
5409    } else {
5410      st.className = 'req-status err'; st.textContent = j.error || 'Could not send your request.';
5411      btn.disabled = false; btn.textContent = old;
5412    }
5413  } catch(err){
5414    st.className = 'req-status err';
5415    st.textContent = 'Could not send — please email [email protected] directly.';
5416    btn.disabled = false; btn.textContent = old;
5417  }
5418});
5419
5420if (isAdmin) {
5421  // Old landmark-based calibrate kept wired but hidden — the drag "Align arrows"
5422  // tool replaces it (and works with the category/360 sub-folder layout).
5423  document.getElementById('psv-calibrate').onclick      = openCalibrateModal;
5424  document.getElementById('calib-modal-cancel').onclick = closeCalibrateModal;
5425  document.getElementById('calib-modal-start').onclick  = startCalibratePicking;
5426  document.getElementById('calib-cancel').onclick       = () => { calibration = null; document.getElementById('calib-prompt').classList.remove('show'); };
5427}
5428
5429document.addEventListener('keydown', e => {
5430  if (document.getElementById('share-modal').classList.contains('open')){
5431    if (e.key === 'Escape') closeShareModal();
5432    return;
5433  }
5434  if (document.getElementById('req-modal').classList.contains('open')){
5435    if (e.key === 'Escape') closeRequestModal();
5436    return;   // don't navigate underneath while the request form is open
5437  }
5438  const panelOpen = document.getElementById('psv-panel').classList.contains('open');
5439  const modalOpen = document.getElementById('lb-modal').classList.contains('open');
5440  if (!panelOpen && !modalOpen) return;
5441  // A Discover More panel or its editor takes Escape first — closing the whole
5442  // panorama because someone wanted to shut an info panel is infuriating.
5443  if (e.key === 'Escape' && dmEditorIsOpen()){ dmEditorClose(); return; }
5444  if (e.key === 'Escape' && dmPanelIsOpen()){ dmClosePanel(); return; }
5445  if (e.key === 'Escape')     { modalOpen ? closeModal(false) : closeViewer(); }
5446  if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && (dmPanelIsOpen() || dmEditorIsOpen())) return;
5447  if (e.key === 'ArrowLeft')  goRel(-1);
5448  if (e.key === 'ArrowRight') goRel(+1);
5449  if (panelOpen && e.key.toLowerCase() === 'f') toggleFullscreen();
5450});
5451
5452function escapeHtml(s){ const d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }
5453function escapeAttr(s){ return (s||'').replace(/&/g,'&amp;').replace(/"/g, '&quot;').replace(/'/g,'&#39;'); }
5454/* Only allow http(s) links/images — blocks javascript:/data: in hrefs. */
5455function safeUrl(u){ return /^https?:\/\//i.test(String(u||'').trim()) ? String(u).trim() : ''; }
5456function xmlEscape(s){
5457  return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;');
5458}
5459</script>
5459
5460
5461</body>
5462</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.