PageSourceSearch

https://openmaptiles.org/assets/js/map-styles.js

js openmaptiles.org collected 2026-10-02 04:11:27 UTC 12,836 bytes, 354 lines download raw bytes

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.