1// Open Map Styles: carousel plus the full-screen preview modal. 2// 3// Open/selected state lives in the URL: /styles/:slug/ selects a style, and the 4// modal renders from that slug. The cards are real links, so they work with JS off, 5// and initStyleModal() intercepts them to open in place. 6import { gitHubRepo } from "./tracking.js"; 7import { withBase } from "./baseurl.js"; 8 9// The snippet offered by the modal's copy button. The </script> inside is split so 10// it cannot close this module's own tag if the file is ever inlined. 11function generateMapCode(styleId, styleName) { 12 const close = "</scr" + "ipt>"; 13 return `<!DOCTYPE html> 14<html lang="en"> 15<head> 16 <meta charset="UTF-8"> 17 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 18 <title>${styleName} Map</title> 19 <script src="https://cdn.maptiler.com/maptiler-sdk-js/v4.1.0/maptiler-sdk.umd.min.js">${close} 20 <link href="https://cdn.maptiler.com/maptiler-sdk-js/v4.1.0/maptiler-sdk.css" rel="stylesheet" /> 21 <style> 22 body { margin: 0; padding: 0; } 23 #map { position: absolute; top: 0; bottom: 0; width: 100%; } 24 </style> 25</head> 26<body> 27 <div id="map"></div> 28 <script> 29 maptilersdk.config.apiKey = 'YOUR_MAPTILER_API_KEY'; // Get your free key at https://www.maptiler.com/cloud/ 30 const map = new maptilersdk.Map({ 31 container: 'map', 32 style: "${styleId}", 33 }); 34 ${close} 35</body> 36</html>`; 37} 38 39// The modal is server-rendered open on /styles/:slug/ and hidden everywhere else. 40// It is emitted by the layout ahead of every section â not inside this one â so that 41// its title is the first heading in the document on /styles/:slug/. Hence the 42// document-level lookup; the cards below still come from the section. 43function initStyleModal(section, config) { 44 const modal = document.querySelector("[data-style-modal]"); 45 const cards = Array.from(section.querySelectorAll("[data-style-card]")); 46 if (!modal || !cards.length) return; 47 48 let iframe = modal.querySelector("[data-style-iframe]"); 49 const titleEl = modal.querySelector("[data-style-modal-title]"); 50 const descEl = modal.querySelector("[data-style-modal-desc]"); 51 const githubEl = modal.querySelector("[data-style-modal-github]"); 52 const cloudEl = modal.querySelector("[data-style-modal-cloud]"); 53 const copyBtn = modal.querySelector("[data-style-copy]"); 54 55 const slugOf = (card) => card.getAttribute("data-style-slug"); 56 const cardFor = (slug) => cards.find((card) => slugOf(card) === slug) || null; 57 58 // Opening and closing both derive the slug from the path. 59 const slugFromLocation = () => { 60 const match = window.location.pathname.match(/\/styles\/([^/]+)/); 61 return match ? match[1] : null; 62 }; 63 64 // Every style change replaces the iframe element rather than reassigning its src. 65 // Reassigning would NAVIGATE the existing frame, and a frame navigation lands in 66 // the joint session history â one preview switch and the browser Back button stops 67 // returning to the page you came from. Swapping the element keeps history clean, 68 // because the first navigation of a fresh browsing context is a replace, not a push. 69 const mountIframe = (src, title) => { 70 if ((iframe.getAttribute("src") || null) === (src || null)) { 71 if (title) iframe.setAttribute("title", title); 72 return; 73 } 74 const fresh = iframe.cloneNode(false); 75 if (src) fresh.setAttribute("src", src); 76 else fresh.removeAttribute("src"); 77 if (title) fresh.setAttribute("title", title); 78 iframe.replaceWith(fresh); 79 iframe = fresh; 80 }; 81 82 let copyTimer; 83 84 const resetCopy = () => { 85 clearTimeout(copyTimer); 86 copyBtn.classList.remove("style-modal__copy--copied"); 87 copyBtn.classList.add("btn-primary-omt"); 88 }; 89 90 const fill = (card) => { 91 const title = card.getAttribute("data-style-title"); 92 const preview = card.getAttribute("data-style-preview"); 93 const github = card.getAttribute("data-style-github"); 94 const cloud = card.getAttribute("data-style-cloud"); 95 const styleId = card.getAttribute("data-style-id"); 96 const description = card.querySelector(".mapstyles__card-text").textContent; 97 98 titleEl.textContent = title; 99 descEl.textContent = description; 100 101 mountIframe(preview, `${title} live preview`); 102 103 githubEl.setAttribute("href", github); 104 const repo = gitHubRepo(github); 105 if (repo) githubEl.setAttribute("data-track-repo", repo); 106 else githubEl.removeAttribute("data-track-repo"); 107 108 // Fiord Color has no cloud map, so the button is omitted rather than disabled. 109 cloudEl.toggleAttribute("hidden", !cloud); 110 cloudEl.setAttribute("href", cloud || ""); 111 cloudEl.setAttribute("data-track-style-id", styleId); 112 113 resetCopy(); 114 }; 115 116 const setBodyScroll = (locked) => { 117 document.body.style.overflow = locked ? "hidden" : ""; 118 }; 119 120 // On a /styles route, changing the selected style scrolls the section into view. 121 const scrollSectionIntoView = () => { 122 if (window.location.pathname.includes("/styles")) { 123 section.scrollIntoView({ behavior: "smooth" }); 124 } 125 }; 126 127 const open = (card, { push = true } = {}) => { 128 fill(card); 129 modal.removeAttribute("hidden"); 130 setBodyScroll(true); 131 if (push) { 132 window.history.pushState({}, "", withBase(config, `/styles/${slugOf(card)}/`)); 133 } 134 scrollSectionIntoView(); 135 }; 136 137 const close = ({ push = true } = {}) => { 138 modal.setAttribute("hidden", ""); 139 setBodyScroll(false); 140 resetCopy(); 141 // Tears the preview map down so it stops rendering while hidden. 142 mountIframe(null); 143 if (push) window.history.pushState({}, "", withBase(config, "/#map-styles")); 144 }; 145 146 const navigate = (direction) => { 147 const current = cards.findIndex((card) => slugOf(card) === slugFromLocation()); 148 if (current === -1) return; 149 const next = 150 direction === "next" 151 ? (current + 1) % cards.length 152 : (current - 1 + cards.length) % cards.length; 153 open(cards[next]); 154 }; 155 156 cards.forEach((card) => { 157 card.addEventListener("click", (event) => { 158 // Modifier-clicks and middle-clicks stay real navigations. 159 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; 160 event.preventDefault(); 161 open(card); 162 }); 163 }); 164 165 modal.querySelectorAll("[data-style-modal-close]").forEach((el) => { 166 el.addEventListener("click", () => close()); 167 }); 168 169 modal.querySelector("[data-style-prev]").addEventListener("click", () => navigate("prev")); 170 modal.querySelector("[data-style-next]").addEventListener("click", () => navigate("next")); 171 172 copyBtn.addEventListener("click", async () => { 173 const card = cardFor(slugFromLocation()); 174 if (!card) return; 175 try { 176 await navigator.clipboard.writeText( 177 generateMapCode(card.getAttribute("data-style-id"), card.getAttribute("data-style-title")) 178 ); 179 copyBtn.classList.remove("btn-primary-omt"); 180 copyBtn.classList.add("style-modal__copy--copied"); 181 clearTimeout(copyTimer); 182 copyTimer = setTimeout(resetCopy, 2000); 183 } catch (err) { 184 console.error("Failed to copy map code", err); 185 } 186 }); 187 188 // Arrow keys navigate and Escape closes, but only while the modal is open. 189 document.addEventListener("keydown", (event) => { 190 if (modal.hasAttribute("hidden")) return; 191 if (event.key === "ArrowLeft") { 192 event.preventDefault(); 193 navigate("prev"); 194 } else if (event.key === "ArrowRight") { 195 event.preventDefault(); 196 navigate("next"); 197 } else if (event.key === "Escape") { 198 event.preventDefault(); 199 close(); 200 } 201 }); 202 203 // Back/forward re-derives the state from the URL, as handlePopState did. 204 window.addEventListener("popstate", () => { 205 const card = cardFor(slugFromLocation()); 206 if (card) open(card, { push: false }); 207 else close({ push: false }); 208 }); 209 210 // Server-rendered open state is already correct; this only locks body scroll 211 // and stamps the per-style tracking attributes onto the two links. 212 const initial = cardFor(slugFromLocation()); 213 if (initial && !modal.hasAttribute("hidden")) { 214 fill(initial); 215 setBodyScroll(true); 216 } 217} 218 219export function initMapStyles(config) { 220 const section = document.querySelector("[data-map-styles]"); 221 if (!section) return; 222 223 const track = section.querySelector("[data-carousel-track]"); 224 const prev = section.querySelector("[data-carousel-prev]"); 225 const next = section.querySelector("[data-carousel-next]"); 226 const dotsHost = section.querySelector("[data-carousel-dots]"); 227 if (!track) return; 228 229 // children[0] is the leading spacer; children[1] is the first real card. 230 // The original measured both to turn scrollLeft into an index. 231 const metrics = () => { 232 const spacer = track.children[0]; 233 const card = track.children[1]; 234 if (!card || !spacer) return null; 235 const style = window.getComputedStyle(card); 236 const cardWidth = card.offsetWidth + parseFloat(style.marginRight || "0"); 237 return { cardWidth, spacerWidth: spacer.offsetWidth }; 238 }; 239 240 // Cards plus the "discover more" card â the spacers are not counted. 241 const totalItems = track.querySelectorAll("[data-style-card]").length + 1; 242 let activeIndex = 0; 243 let maxIndex = 0; 244 245 // One dot per reachable scroll position, not per card: with several cards 246 // visible the track stops scrolling long before the last card reaches the left 247 // edge, so a dot per card leaves the tail dots unreachable. 248 const maxScroll = () => Math.max(0, track.scrollWidth - track.clientWidth); 249 250 const positionFor = (index, m) => 251 Math.min(m.spacerWidth + index * m.cardWidth, maxScroll()); 252 253 const dots = []; 254 255 const syncDots = () => { 256 const m = metrics(); 257 if (!m) return; 258 // ceil, not floor: the last position is the end of the track, which may sit 259 // mid-card. 260 const positions = Math.min( 261 totalItems, 262 Math.max(1, Math.ceil((maxScroll() - m.spacerWidth) / m.cardWidth) + 1) 263 ); 264 maxIndex = positions - 1; 265 if (!dotsHost || dots.length === positions) return; 266 267 while (dots.length > positions) dots.pop().remove(); 268 while (dots.length < positions) { 269 const index = dots.length; 270 const dot = document.createElement("button"); 271 dot.type = "button"; 272 dot.className = "mapstyles__dot"; 273 dot.setAttribute("aria-label", `Go to style ${index + 1}`); 274 dot.addEventListener("click", () => scrollToCard(index)); 275 dotsHost.appendChild(dot); 276 dots.push(dot); 277 } 278 }; 279 280 const render = () => {
281 dots.forEach((dot, i) => 282 dot.setAttribute("aria-current", String(i === activeIndex)) 283 ); 284 if (prev) prev.disabled = activeIndex === 0; 285 if (next) next.disabled = activeIndex >= maxIndex; 286 }; 287 288 const updateActiveIndex = () => { 289 const m = metrics(); 290 if (!m) return; 291 // Sub-pixel widths leave a scroll to the end a fraction short of maxScroll. 292 if (track.scrollLeft >= maxScroll() - 1) { 293 activeIndex = maxIndex; 294 } else { 295 const index = Math.round((track.scrollLeft - m.spacerWidth) / m.cardWidth); 296 activeIndex = Math.min(Math.max(0, index), maxIndex); 297 } 298 render(); 299 }; 300 301 function scrollToCard(index) { 302 const m = metrics(); 303 if (!m) return; 304 track.scrollTo({ left: positionFor(index, m), behavior: "smooth" }); 305 } 306 307 // Stepping from the active index, rather than scrollBy()-ing a card width, keeps 308 // the arrows on the dots' positions even after a drag has left the track between 309 // two cards. 310 const step = (delta) => { 311 scrollToCard(Math.min(Math.max(activeIndex + delta, 0), maxIndex)); 312 }; 313 314 track.addEventListener("scroll", updateActiveIndex, { passive: true }); 315 if (prev) prev.addEventListener("click", () => step(-1)); 316 if (next) next.addEventListener("click", () => step(1)); 317 318 // Card and spacer widths are viewport-relative, so the dot count changes with 319 // the window. 320 window.addEventListener("resize", () => { 321 syncDots(); 322 updateActiveIndex(); 323 }); 324 325 // Each card reports the style_preview_open event 326 // before navigating. 327 section.querySelectorAll("[data-style-card]").forEach((card) => { 328 card.addEventListener("click", () => { 329 window.dataLayer = window.dataLayer || []; 330 window.dataLayer.push({ 331 event: "style_preview_open", 332 style_id: card.getAttribute("data-style-id"), 333 style_title: card.getAttribute("data-style-title"), 334 timestamp: new Date().toISOString(), 335 }); 336 }); 337 }); 338 339 syncDots(); 340 updateActiveIndex(); 341 342 // Lazy-loaded card images change scrollWidth after first paint. 343 window.addEventListener("load", () => { 344 syncDots(); 345 updateActiveIndex(); 346 }); 347 348 initStyleModal(section, config); 349 350 // On a /styles/:slug/ page, scroll the section into view on load. 351 if (window.location.pathname.includes("/styles")) { 352 section.scrollIntoView({ behavior: "smooth" }); 353 } 354}
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.