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)">✕</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">✎ 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">✕</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">⬆</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">✥ 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">🗑</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">✕</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">✕ 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">✕</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…</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,'"') + '">' 1219 + String(it.label || '').replace(/</g,'<') + '</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,'&').replace(/</g,'<').replace(/"/g,'"'); 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">▾</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">☰</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: '© <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 © 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 © OpenStreetMap contributors, SRTM | Style © <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,'&').replace(/</g,'<'); 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>→</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">✕</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,'&').replace(/"/g, '"').replace(/'/g,'''); } 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); 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.