Last active
August 1, 2026 22:09
-
-
Save redraw/3b9c120cb121290c11d9daa125a2828f to your computer and use it in GitHub Desktop.
IMAX map
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <meta charset="utf-8" /> | |
| <title>IMAX Atlas — Global Screen Directory</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> | |
| <style> | |
| :root { | |
| --bg: #0b0d12; | |
| --panel: #14171f; | |
| --panel-2: #191d27; | |
| --land: #1e2330; | |
| --land-line: #2b3142; | |
| --ocean: #0e1017; | |
| --hair: #2a2f3c; | |
| --text-hi: #ece9e2; | |
| --text-lo: #90959f; | |
| --text-faint: #565b68; | |
| --marquee: #f2a93b; | |
| --marquee-dim: #6b5326; | |
| --reel: #4fd1c5; | |
| --dot-standard: #545b6b; | |
| --focus: #f2a93b; | |
| --shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.6); | |
| } | |
| :root[data-theme="light"] { | |
| --bg: #eceef2; | |
| --panel: #ffffff; | |
| --panel-2: #f4f5f8; | |
| --land: #d7dae3; | |
| --land-line: #b7bbc8; | |
| --ocean: #e4e7ee; | |
| --hair: #d6d9e0; | |
| --text-hi: #191b22; | |
| --text-lo: #565c6b; | |
| --text-faint: #9aa0ad; | |
| --marquee: #b8720f; | |
| --marquee-dim: #e8cfa1; | |
| --reel: #16847a; | |
| --dot-standard: #a6abb8; | |
| --focus: #b8720f; | |
| --shadow: 0 20px 50px -20px rgba(30, 34, 48, 0.25); | |
| } | |
| @media (prefers-color-scheme: light) { | |
| :root:not([data-theme="dark"]) { | |
| --bg: #eceef2; | |
| --panel: #ffffff; | |
| --panel-2: #f4f5f8; | |
| --land: #d7dae3; | |
| --land-line: #b7bbc8; | |
| --ocean: #e4e7ee; | |
| --hair: #d6d9e0; | |
| --text-hi: #191b22; | |
| --text-lo: #565c6b; | |
| --text-faint: #9aa0ad; | |
| --marquee: #b8720f; | |
| --marquee-dim: #e8cfa1; | |
| --reel: #16847a; | |
| --dot-standard: #a6abb8; | |
| --focus: #b8720f; | |
| --shadow: 0 20px 50px -20px rgba(30, 34, 48, 0.25); | |
| } | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| background: var(--bg); | |
| color: var(--text-hi); | |
| font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| .label { | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.11em; | |
| font-size: 11px; | |
| color: var(--text-lo); | |
| } | |
| .num { font-variant-numeric: tabular-nums; } | |
| .app { position: relative; width: 100vw; height: 100vh; isolation: isolate; } | |
| #map { | |
| position: absolute; | |
| inset: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: var(--ocean); | |
| z-index: 0; | |
| } | |
| #map .leaflet-control-attribution { | |
| background: var(--panel); | |
| color: var(--text-faint); | |
| border-top-left-radius: 6px; | |
| } | |
| #map .leaflet-control-attribution a { color: var(--text-lo); } | |
| .dot { | |
| fill: var(--dot-standard); | |
| stroke: var(--ocean); | |
| stroke-width: 1; | |
| cursor: pointer; | |
| } | |
| .dot.laser { fill: var(--marquee); } | |
| .dot.dome { fill: var(--reel); } | |
| .dot.f70 { stroke: var(--reel); stroke-width: 2; } | |
| .dot.dim { opacity: 0.08; pointer-events: none; } | |
| .dot.active { stroke: var(--text-hi); stroke-width: 2; } | |
| /* ---------- Control deck ---------- */ | |
| .deck { | |
| position: absolute; | |
| top: 18px; | |
| left: 18px; | |
| width: 320px; | |
| max-height: calc(100vh - 36px); | |
| background: var(--panel); | |
| border: 1px solid var(--hair); | |
| border-radius: 10px; | |
| box-shadow: var(--shadow); | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| z-index: 1100; | |
| } | |
| .deck-head { | |
| padding: 16px 18px 14px; | |
| border-bottom: 1px solid var(--hair); | |
| flex-shrink: 0; | |
| position: relative; | |
| padding-right: 52px; | |
| } | |
| .deck.collapsed .deck-head { border-bottom: none; } | |
| .deck-toggle { | |
| position: absolute; | |
| top: 50%; | |
| right: 12px; | |
| width: 30px; | |
| height: 30px; | |
| transform: translateY(-50%); | |
| border: 1px solid var(--hair); | |
| border-radius: 6px; | |
| background: var(--panel-2); | |
| color: var(--text-hi); | |
| cursor: pointer; | |
| font-size: 17px; | |
| line-height: 1; | |
| } | |
| .deck-toggle:hover { border-color: var(--text-lo); } | |
| .deck-toggle:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } | |
| .deck.collapsed .deck-body { display: none; } | |
| .brand { | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: 0.14em; | |
| font-size: 15px; | |
| color: var(--text-hi); | |
| display: flex; | |
| align-items: baseline; | |
| gap: 8px; | |
| } | |
| .brand .dot-mark { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--marquee); | |
| display: inline-block; | |
| margin-bottom: 1px; | |
| } | |
| .brand-sub { | |
| margin-top: 3px; | |
| font-size: 12px; | |
| color: var(--text-lo); | |
| } | |
| .deck-body { | |
| padding: 14px 18px 18px; | |
| overflow-y: auto; | |
| flex: 1; | |
| } | |
| .deck-body::-webkit-scrollbar { width: 6px; } | |
| .deck-body::-webkit-scrollbar-thumb { background: var(--hair); border-radius: 3px; } | |
| .group { margin-bottom: 18px; } | |
| .group:last-child { margin-bottom: 0; } | |
| .group-title { | |
| margin: 0 0 8px; | |
| } | |
| .search-wrap { position: relative; } | |
| input[type="text"] { | |
| width: 100%; | |
| background: var(--panel-2); | |
| border: 1px solid var(--hair); | |
| color: var(--text-hi); | |
| border-radius: 7px; | |
| padding: 9px 11px; | |
| font-size: 13.5px; | |
| outline: none; | |
| font-family: inherit; | |
| } | |
| input[type="text"]:focus { border-color: var(--focus); } | |
| input[type="text"]::placeholder { color: var(--text-faint); } | |
| .suggestions { | |
| position: absolute; | |
| top: calc(100% + 4px); | |
| left: 0; | |
| right: 0; | |
| background: var(--panel-2); | |
| border: 1px solid var(--hair); | |
| border-radius: 7px; | |
| box-shadow: var(--shadow); | |
| max-height: 220px; | |
| overflow-y: auto; | |
| z-index: 30; | |
| display: none; | |
| } | |
| .suggestions.open { display: block; } | |
| .sugg-item { | |
| padding: 8px 11px; | |
| cursor: pointer; | |
| border-bottom: 1px solid var(--hair); | |
| } | |
| .sugg-item:last-child { border-bottom: none; } | |
| .sugg-item:hover, .sugg-item.hi { background: var(--panel); } | |
| .sugg-name { font-size: 13px; color: var(--text-hi); } | |
| .sugg-meta { font-size: 11px; color: var(--text-lo); margin-top: 1px; } | |
| .chip-row { display: flex; flex-wrap: wrap; gap: 6px; } | |
| .chip { | |
| border: 1px solid var(--hair); | |
| background: var(--panel-2); | |
| color: var(--text-lo); | |
| border-radius: 999px; | |
| padding: 6px 11px; | |
| font-size: 12px; | |
| cursor: pointer; | |
| user-select: none; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| transition: border-color 0.12s, color 0.12s; | |
| } | |
| .chip .n { font-variant-numeric: tabular-nums; color: var(--text-faint); } | |
| .chip:hover { border-color: var(--text-lo); } | |
| .chip.on { | |
| background: var(--marquee-dim); | |
| border-color: var(--marquee); | |
| color: var(--text-hi); | |
| } | |
| .chip.on .n { color: var(--text-hi); opacity: 0.7; } | |
| .fmt-row { display: flex; flex-direction: column; gap: 7px; } | |
| .fmt-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 9px; | |
| padding: 7px 9px; | |
| border-radius: 7px; | |
| cursor: pointer; | |
| border: 1px solid transparent; | |
| } | |
| .fmt-item:hover { background: var(--panel-2); } | |
| .fmt-item.on { border-color: var(--hair); background: var(--panel-2); } | |
| .swatch { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; } | |
| .swatch.laser { background: var(--marquee); } | |
| .swatch.dome { background: var(--reel); } | |
| .swatch.f70 { background: var(--dot-standard); box-shadow: inset 0 0 0 1.6px var(--reel); } | |
| .fmt-text { font-size: 13px; color: var(--text-hi); flex: 1; } | |
| .fmt-count { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; } | |
| select { | |
| width: 100%; | |
| background: var(--panel-2); | |
| border: 1px solid var(--hair); | |
| color: var(--text-hi); | |
| border-radius: 7px; | |
| padding: 9px 11px; | |
| font-size: 13px; | |
| outline: none; | |
| font-family: inherit; | |
| } | |
| select:focus { border-color: var(--focus); } | |
| .clear-btn { | |
| width: 100%; | |
| margin-top: 4px; | |
| background: transparent; | |
| border: 1px solid var(--hair); | |
| color: var(--text-lo); | |
| border-radius: 7px; | |
| padding: 8px 0; | |
| font-size: 12px; | |
| letter-spacing: 0.05em; | |
| text-transform: uppercase; | |
| font-weight: 700; | |
| cursor: pointer; | |
| } | |
| .clear-btn:hover { color: var(--text-hi); border-color: var(--text-lo); } | |
| /* ---------- Marquee readout ---------- */ | |
| .marquee-box { | |
| position: absolute; | |
| left: 18px; | |
| bottom: 18px; | |
| background: var(--panel); | |
| border: 1px solid var(--hair); | |
| border-radius: 10px; | |
| box-shadow: var(--shadow); | |
| padding: 12px 16px; | |
| z-index: 1100; | |
| min-width: 200px; | |
| } | |
| .marquee-count { | |
| font-size: 28px; | |
| font-weight: 800; | |
| letter-spacing: 0.01em; | |
| color: var(--marquee); | |
| line-height: 1; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .marquee-total { | |
| font-size: 12px; | |
| color: var(--text-lo); | |
| margin-top: 4px; | |
| } | |
| /* ---------- Zoom controls ---------- */ | |
| .zoom-ctl { | |
| position: absolute; | |
| right: 18px; | |
| bottom: 18px; | |
| display: flex; | |
| flex-direction: column; | |
| background: var(--panel); | |
| border: 1px solid var(--hair); | |
| border-radius: 10px; | |
| box-shadow: var(--shadow); | |
| overflow: hidden; | |
| z-index: 1100; | |
| } | |
| .zoom-btn { | |
| width: 38px; | |
| height: 38px; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-hi); | |
| font-size: 18px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .zoom-btn:hover { background: var(--panel-2); } | |
| .zoom-btn:not(:last-child) { border-bottom: 1px solid var(--hair); } | |
| /* ---------- Popover ---------- */ | |
| .popover { | |
| position: absolute; | |
| width: 250px; | |
| background: var(--panel); | |
| border: 1px solid var(--hair); | |
| border-radius: 10px; | |
| box-shadow: var(--shadow); | |
| padding: 14px 15px; | |
| z-index: 1200; | |
| display: none; | |
| } | |
| .popover.open { display: block; } | |
| .pop-close { | |
| position: absolute; | |
| top: 8px; | |
| right: 10px; | |
| background: none; | |
| border: none; | |
| color: var(--text-faint); | |
| font-size: 15px; | |
| cursor: pointer; | |
| line-height: 1; | |
| } | |
| .pop-close:hover { color: var(--text-hi); } | |
| .pop-name { | |
| font-size: 14.5px; | |
| font-weight: 700; | |
| color: var(--text-hi); | |
| padding-right: 16px; | |
| margin-bottom: 3px; | |
| } | |
| .pop-loc { font-size: 12.5px; color: var(--text-lo); margin-bottom: 10px; } | |
| .pop-badges { display: flex; flex-wrap: wrap; gap: 5px; } | |
| .badge { | |
| font-size: 10.5px; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| padding: 3px 7px; | |
| border-radius: 5px; | |
| background: var(--panel-2); | |
| color: var(--text-lo); | |
| border: 1px solid var(--hair); | |
| } | |
| .badge.laser { color: var(--marquee); border-color: var(--marquee-dim); } | |
| .badge.dome, .badge.f70 { color: var(--reel); border-color: var(--reel); } | |
| @media (max-width: 640px) { | |
| .deck { width: calc(100vw - 24px); max-height: 46vh; } | |
| .marquee-box { left: 12px; bottom: 12px; padding: 9px 12px; } | |
| .marquee-count { font-size: 22px; } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| * { transition: none !important; } | |
| } | |
| /* ---------- Loading / error overlay ---------- */ | |
| .loading-overlay { | |
| position: absolute; | |
| inset: 0; | |
| z-index: 1300; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: var(--bg); | |
| transition: opacity 0.25s ease; | |
| } | |
| .loading-overlay.hidden { opacity: 0; pointer-events: none; } | |
| .loading-box { display: flex; flex-direction: column; align-items: center; gap: 10px; max-width: 320px; text-align: center; padding: 0 20px; } | |
| .loading-spinner { | |
| width: 30px; | |
| height: 30px; | |
| border-radius: 50%; | |
| border: 2.5px solid var(--hair); | |
| border-top-color: var(--marquee); | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| @media (prefers-reduced-motion: reduce) { | |
| .loading-spinner { animation: none; border-top-color: var(--hair); } | |
| } | |
| .loading-text { font-size: 13.5px; color: var(--text-hi); } | |
| .loading-sub { font-size: 12px; color: var(--text-lo); } | |
| .retry-btn { | |
| margin-top: 6px; | |
| background: var(--panel-2); | |
| border: 1px solid var(--hair); | |
| color: var(--text-hi); | |
| border-radius: 7px; | |
| padding: 7px 14px; | |
| font-size: 12.5px; | |
| cursor: pointer; | |
| } | |
| .retry-btn:hover { border-color: var(--text-lo); } | |
| .source-note { font-size: 11px; color: var(--text-faint); margin-top: 10px; text-align: center; } | |
| .source-note a { color: var(--text-lo); text-decoration: none; border-bottom: 1px dotted var(--hair); } | |
| .source-note a:hover { color: var(--text-hi); } | |
| </style> | |
| <div class="app"> | |
| <div id="map"></div> | |
| <div class="deck" id="deck"> | |
| <div class="deck-head"> | |
| <div class="brand"><span class="dot-mark"></span>IMAX Atlas</div> | |
| <div class="brand-sub">Global screen directory — <span class="num" id="totalCount">0</span> venues</div> | |
| <button class="deck-toggle" id="deckToggle" type="button" aria-expanded="true" aria-controls="deckBody" title="Collapse filters">−</button> | |
| </div> | |
| <div class="deck-body" id="deckBody"> | |
| <div class="group"> | |
| <div class="search-wrap"> | |
| <input type="text" id="searchInput" placeholder="Search theatre, city or country…" autocomplete="off" /> | |
| <div class="suggestions" id="suggestions"></div> | |
| </div> | |
| </div> | |
| <div class="group"> | |
| <div class="label group-title">Region</div> | |
| <div class="chip-row" id="regionChips"></div> | |
| </div> | |
| <div class="group"> | |
| <div class="label group-title">Country</div> | |
| <select id="countrySelect"></select> | |
| </div> | |
| <div class="group"> | |
| <div class="label group-title">Screen format</div> | |
| <div class="fmt-row" id="formatRow"></div> | |
| </div> | |
| <button class="clear-btn" id="clearBtn">Clear all filters</button> | |
| <div class="source-note">Live from <a href="https://github.com/sMkr8geh/imax_global_database" target="_blank" rel="noopener">imax_global_database</a></div> | |
| </div> | |
| </div> | |
| <div class="marquee-box"> | |
| <div class="marquee-count num" id="shownCount">0</div> | |
| <div class="marquee-total">of <span class="num" id="totalCount2">0</span> IMAX theatres shown</div> | |
| </div> | |
| <div class="zoom-ctl"> | |
| <button class="zoom-btn" id="zoomIn" title="Zoom in">+</button> | |
| <button class="zoom-btn" id="zoomOut" title="Zoom out">−</button> | |
| <button class="zoom-btn" id="zoomReset" title="Reset view" style="font-size:13px;">⤢</button> | |
| </div> | |
| <div class="popover" id="popover"> | |
| <button class="pop-close" id="popClose">×</button> | |
| <div class="pop-name" id="popName"></div> | |
| <div class="pop-loc" id="popLoc"></div> | |
| <div class="pop-badges" id="popBadges"></div> | |
| </div> | |
| <div class="loading-overlay" id="loadingOverlay"> | |
| <div class="loading-box"> | |
| <div class="loading-spinner" id="loadingSpinner"></div> | |
| <div class="loading-text" id="loadingText">Fetching live theatre data…</div> | |
| <div class="loading-sub" id="loadingSub">imax_global_database (GitHub)</div> | |
| <button class="retry-btn" id="retryBtn" style="display:none;">Retry</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> | |
| <script> | |
| (function () { | |
| "use strict"; | |
| var CSV_URL = "https://raw.githubusercontent.com/sMkr8geh/imax_global_database/refs/heads/main/imax_global.csv"; | |
| // Keep the map usable when the live GitHub source is blocked (for example by | |
| // an offline preview or a restrictive network). The live dataset replaces | |
| // this on normal loads. | |
| var FALLBACK_DATA = [ | |
| ["TCL Chinese Theatre IMAX", "Los Angeles", "CA", "United States", "NA", 34.1022, -118.3407, 1, 0, 0, null], | |
| ["AMC Lincoln Square 13 IMAX", "New York", "NY", "United States", "NA", 40.7750, -73.9830, 1, 0, 1, null], | |
| ["BFI IMAX", "London", null, "United Kingdom", "EMEA", 51.5020, -0.1134, 1, 0, 0, null], | |
| ["Cineplex Scotiabank Theatre Toronto", "Toronto", "ON", "Canada", "NA", 43.6487, -79.3917, 1, 0, 0, null], | |
| ["IMAX Melbourne", "Melbourne", "VIC", "Australia", "APAC", -37.8034, 144.9719, 1, 0, 1, null], | |
| ["TGV Sunway Velocity IMAX", "Kuala Lumpur", null, "Malaysia", "APAC", 3.1277, 101.7264, 1, 0, 0, null], | |
| ["Cinepolis IMAX Perisur", "Mexico City", null, "Mexico", "LATAM", 19.3038, -99.1883, 1, 0, 0, null], | |
| ["IMAX Sydney", "Sydney", "NSW", "Australia", "APAC", -33.8592, 151.2093, 1, 0, 0, null] | |
| ]; | |
| var NAME_FIX = { | |
| "United States of America": "United States", | |
| "Korea, Republic of": "South Korea", | |
| "Russian Federation": "Russia", | |
| "Taiwan, Province of China": "Taiwan", | |
| "Viet Nam": "Vietnam", | |
| "United Kingdom of Great Britain and Northern Ireland": "United Kingdom" | |
| }; | |
| function parseCSV(text) { | |
| if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); | |
| var rows = [], row = [], field = "", inQuotes = false; | |
| for (var i = 0; i < text.length; i++) { | |
| var c = text[i]; | |
| if (inQuotes) { | |
| if (c === '"') { | |
| if (text[i + 1] === '"') { field += '"'; i++; } | |
| else { inQuotes = false; } | |
| } else { | |
| field += c; | |
| } | |
| } else if (c === '"') { | |
| inQuotes = true; | |
| } else if (c === ",") { | |
| row.push(field); field = ""; | |
| } else if (c === "\r") { | |
| // skip | |
| } else if (c === "\n") { | |
| row.push(field); rows.push(row); row = []; field = ""; | |
| } else { | |
| field += c; | |
| } | |
| } | |
| if (field.length || row.length) { row.push(field); rows.push(row); } | |
| return rows; | |
| } | |
| // row: [name, city, state, countryName, region, lat, lng, laser, dome, f70, operator] | |
| function normalizeRows(table) { | |
| if (!table.length) return []; | |
| var idx = {}; | |
| table[0].forEach(function (h, i) { idx[h.trim()] = i; }); | |
| var out = []; | |
| for (var r = 1; r < table.length; r++) { | |
| var row = table[r]; | |
| if (!row || row.length < 2) continue; | |
| var name = (row[idx["name"]] || "").trim(); | |
| if (!name) continue; | |
| var lat = parseFloat(row[idx["_geoloc.lat"]]); | |
| var lng = parseFloat(row[idx["_geoloc.lng"]]); | |
| if (isNaN(lat) || isNaN(lng)) continue; | |
| var countryName = (row[idx["countryName"]] || "").trim(); | |
| countryName = NAME_FIX[countryName] || countryName; | |
| var region = (row[idx["region"]] || "").trim(); | |
| var isTrue = function (v) { return /^true$/i.test((v || "").trim()); }; | |
| out.push([ | |
| name, | |
| (row[idx["city"]] || "").trim() || null, | |
| (row[idx["state"]] || "").trim() || null, | |
| countryName, | |
| region, | |
| lat, | |
| lng, | |
| isTrue(row[idx["Laser"]]) ? 1 : 0, | |
| isTrue(row[idx["Dome"]]) ? 1 : 0, | |
| isTrue(row[idx["70mm"]]) ? 1 : 0, | |
| (row[idx["operator"]] || "").trim() || null | |
| ]); | |
| } | |
| return out; | |
| } | |
| var loadingOverlay = document.getElementById("loadingOverlay"); | |
| var loadingText = document.getElementById("loadingText"); | |
| var loadingSub = document.getElementById("loadingSub"); | |
| var loadingSpinner = document.getElementById("loadingSpinner"); | |
| var retryBtn = document.getElementById("retryBtn"); | |
| var deck = document.getElementById("deck"); | |
| var deckToggle = document.getElementById("deckToggle"); | |
| function setDeckCollapsed(collapsed) { | |
| deck.classList.toggle("collapsed", collapsed); | |
| deckToggle.textContent = collapsed ? "+" : "−"; | |
| deckToggle.setAttribute("aria-expanded", String(!collapsed)); | |
| deckToggle.title = collapsed ? "Expand filters" : "Collapse filters"; | |
| } | |
| deckToggle.addEventListener("click", function () { | |
| setDeckCollapsed(!deck.classList.contains("collapsed")); | |
| }); | |
| if (window.matchMedia && window.matchMedia("(max-width: 640px)").matches) { | |
| setDeckCollapsed(true); | |
| } | |
| function showError(err) { | |
| // Do not leave an opaque loading layer above the whole application. A | |
| // working local dataset is more useful than an error screen that prevents | |
| // access to the controls and markers. | |
| loadingOverlay.classList.add("hidden"); | |
| initApp(FALLBACK_DATA); | |
| } | |
| function loadData() { | |
| loadingSpinner.style.display = "block"; | |
| loadingText.textContent = "Fetching live theatre data…"; | |
| loadingSub.textContent = "imax_global_database (GitHub)"; | |
| retryBtn.style.display = "none"; | |
| var controller = window.AbortController ? new AbortController() : null; | |
| var timeout = window.setTimeout(function () { | |
| if (controller) controller.abort(); | |
| }, 10000); | |
| fetch(CSV_URL, { cache: "no-store", signal: controller ? controller.signal : undefined }) | |
| .then(function (res) { | |
| if (!res.ok) throw new Error("HTTP " + res.status); | |
| return res.text(); | |
| }) | |
| .then(function (text) { | |
| var data = normalizeRows(parseCSV(text)); | |
| if (!data.length) throw new Error("No rows parsed from source"); | |
| window.clearTimeout(timeout); | |
| loadingOverlay.classList.add("hidden"); | |
| initApp(data); | |
| }) | |
| .catch(function (err) { | |
| window.clearTimeout(timeout); | |
| showError(err); | |
| }); | |
| } | |
| retryBtn.addEventListener("click", loadData); | |
| loadData(); | |
| function initApp(DATA) { | |
| // row: [name, city, state, country, region, lat, lng, laser, dome, f70, operator] | |
| var INITIAL_CENTER = [20, 12]; | |
| var INITIAL_ZOOM = 2; | |
| var map = L.map("map", { | |
| center: INITIAL_CENTER, | |
| zoom: INITIAL_ZOOM, | |
| minZoom: 2, | |
| maxZoom: 18, | |
| zoomControl: false, | |
| worldCopyJump: false, | |
| maxBoundsViscosity: 1.0 | |
| }); | |
| map.setMaxBounds([[-85, -180], [85, 180]]); | |
| // ---------- Theme-aware basemap ---------- | |
| var TILE_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'; | |
| var TILE_URL = { | |
| dark: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", | |
| light: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" | |
| }; | |
| function isDarkTheme() { | |
| var t = document.documentElement.getAttribute("data-theme"); | |
| if (t === "light") return false; | |
| if (t === "dark") return true; | |
| return !(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches); | |
| } | |
| var tileLayer = null; | |
| function syncTileLayer() { | |
| var url = isDarkTheme() ? TILE_URL.dark : TILE_URL.light; | |
| if (tileLayer) map.removeLayer(tileLayer); | |
| tileLayer = L.tileLayer(url, { subdomains: "abcd", attribution: TILE_ATTR, minZoom: 2, maxZoom: 19 }); | |
| tileLayer.addTo(map); | |
| tileLayer.bringToBack(); | |
| } | |
| syncTileLayer(); | |
| new MutationObserver(syncTileLayer).observe(document.documentElement, { | |
| attributes: true, | |
| attributeFilter: ["data-theme"] | |
| }); | |
| if (window.matchMedia) { | |
| window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", syncTileLayer); | |
| } | |
| // ---------- Build markers ---------- | |
| var markers = new Array(DATA.length); | |
| var markersLayer = L.layerGroup().addTo(map); | |
| for (var i = 0; i < DATA.length; i++) { | |
| var r = DATA[i]; | |
| var cls = "dot"; | |
| if (r[7]) cls += " laser"; | |
| if (r[8]) cls += " dome"; | |
| if (r[9]) cls += " f70"; | |
| var m = L.circleMarker([r[5], r[6]], { | |
| radius: 4, | |
| className: cls, | |
| fillOpacity: 1, | |
| opacity: 1, | |
| bubblingMouseEvents: false | |
| }); | |
| m._idx = i; | |
| m.on("click", function (e) { | |
| var rect = map.getContainer().getBoundingClientRect(); | |
| openPopoverFor(this._idx, rect.left + e.containerPoint.x, rect.top + e.containerPoint.y); | |
| }); | |
| m.addTo(markersLayer); | |
| markers[i] = m; | |
| } | |
| // ---------- Counts ---------- | |
| var regionOrder = ["APAC", "EMEA", "LATAM", "NA"]; | |
| var regionLabels = { NA: "North America" }; | |
| var regionCounts = {}; | |
| var countryByRegion = {}; // region -> Set of country | |
| var countryCounts = {}; | |
| var fmtCounts = { laser: 0, dome: 0, f70: 0 }; | |
| DATA.forEach(function (r) { | |
| var region = r[4] || "Other"; | |
| regionCounts[region] = (regionCounts[region] || 0) + 1; | |
| countryCounts[r[3]] = (countryCounts[r[3]] || 0) + 1; | |
| if (!countryByRegion[region]) countryByRegion[region] = {}; | |
| countryByRegion[region][r[3]] = true; | |
| if (r[7]) fmtCounts.laser++; | |
| if (r[8]) fmtCounts.dome++; | |
| if (r[9]) fmtCounts.f70++; | |
| }); | |
| document.getElementById("totalCount").textContent = DATA.length.toLocaleString(); | |
| document.getElementById("totalCount2").textContent = DATA.length.toLocaleString(); | |
| // ---------- Region chips ---------- | |
| var selectedRegions = new Set(); | |
| var regionChipsEl = document.getElementById("regionChips"); | |
| var regionKeys = Object.keys(regionCounts).sort(function (a, b) { | |
| var ia = regionOrder.indexOf(a), ib = regionOrder.indexOf(b); | |
| if (ia === -1) ia = 99; if (ib === -1) ib = 99; | |
| return ia - ib; | |
| }); | |
| regionKeys.forEach(function (region) { | |
| var chip = document.createElement("div"); | |
| chip.className = "chip"; | |
| chip.dataset.region = region; | |
| chip.innerHTML = (regionLabels[region] || region) + ' <span class="n">' + regionCounts[region].toLocaleString() + "</span>"; | |
| chip.addEventListener("click", function () { | |
| if (selectedRegions.has(region)) selectedRegions.delete(region); | |
| else selectedRegions.add(region); | |
| chip.classList.toggle("on"); | |
| rebuildCountryOptions(); | |
| applyFilters(true); | |
| }); | |
| regionChipsEl.appendChild(chip); | |
| }); | |
| // ---------- Country select ---------- | |
| var countrySelect = document.getElementById("countrySelect"); | |
| function rebuildCountryOptions() { | |
| var prev = countrySelect.value; | |
| var countries; | |
| if (selectedRegions.size === 0) { | |
| countries = Object.keys(countryCounts); | |
| } else { | |
| var set = {}; | |
| selectedRegions.forEach(function (region) { | |
| Object.keys(countryByRegion[region] || {}).forEach(function (c) { set[c] = true; }); | |
| }); | |
| countries = Object.keys(set); | |
| } | |
| countries.sort(); | |
| countrySelect.innerHTML = '<option value="">All countries</option>'; | |
| countries.forEach(function (c) { | |
| var opt = document.createElement("option"); | |
| opt.value = c; | |
| opt.textContent = c + " (" + countryCounts[c] + ")"; | |
| countrySelect.appendChild(opt); | |
| }); | |
| if (countries.indexOf(prev) !== -1) countrySelect.value = prev; | |
| } | |
| rebuildCountryOptions(); | |
| countrySelect.addEventListener("change", function () { applyFilters(true); }); | |
| // ---------- Format checkboxes ---------- | |
| var formats = [ | |
| { key: "laser", label: "Laser projection", swatch: "laser", count: fmtCounts.laser }, | |
| { key: "dome", label: "Dome screen", swatch: "dome", count: fmtCounts.dome }, | |
| { key: "f70", label: "70mm film", swatch: "f70", count: fmtCounts.f70 } | |
| ]; | |
| var activeFormats = {}; | |
| var formatRowEl = document.getElementById("formatRow"); | |
| formats.forEach(function (f) { | |
| var item = document.createElement("div"); | |
| item.className = "fmt-item"; | |
| item.innerHTML = | |
| '<span class="swatch ' + f.swatch + '"></span>' + | |
| '<span class="fmt-text">' + f.label + "</span>" + | |
| '<span class="fmt-count">' + f.count.toLocaleString() + "</span>"; | |
| item.addEventListener("click", function () { | |
| activeFormats[f.key] = !activeFormats[f.key]; | |
| item.classList.toggle("on"); | |
| applyFilters(true); | |
| }); | |
| formatRowEl.appendChild(item); | |
| }); | |
| // ---------- Filtering ---------- | |
| var shownCountEl = document.getElementById("shownCount"); | |
| var fmtIndex = { laser: 7, dome: 8, f70: 9 }; | |
| function matchesFilters(r) { | |
| if (selectedRegions.size > 0 && !selectedRegions.has(r[4])) return false; | |
| if (countrySelect.value && r[3] !== countrySelect.value) return false; | |
| for (var key in activeFormats) { | |
| if (activeFormats[key] && !r[fmtIndex[key]]) return false; | |
| } | |
| return true; | |
| } | |
| function applyFilters(fitToResults) { | |
| var shown = 0; | |
| var visibleBounds = []; | |
| for (var i = 0; i < DATA.length; i++) { | |
| var ok = matchesFilters(DATA[i]); | |
| if (ok) { | |
| shown++; | |
| visibleBounds.push([DATA[i][5], DATA[i][6]]); | |
| } | |
| var el = markers[i].getElement(); | |
| if (el) el.classList.toggle("dim", !ok); | |
| } | |
| shownCountEl.textContent = shown.toLocaleString(); | |
| if (fitToResults && visibleBounds.length) { | |
| map.fitBounds(visibleBounds, { | |
| paddingTopLeft: [356, 32], | |
| paddingBottomRight: [72, 32], | |
| maxZoom: 12, | |
| animate: true | |
| }); | |
| } | |
| } | |
| applyFilters(); | |
| document.getElementById("clearBtn").addEventListener("click", function () { | |
| selectedRegions.clear(); | |
| document.querySelectorAll(".chip.on").forEach(function (c) { c.classList.remove("on"); }); | |
| Object.keys(activeFormats).forEach(function (k) { activeFormats[k] = false; }); | |
| document.querySelectorAll(".fmt-item.on").forEach(function (c) { c.classList.remove("on"); }); | |
| countrySelect.value = ""; | |
| searchInput.value = ""; | |
| closeSuggestions(); | |
| rebuildCountryOptions(); | |
| applyFilters(true); | |
| }); | |
| // ---------- Search ---------- | |
| var searchInput = document.getElementById("searchInput"); | |
| var suggestionsEl = document.getElementById("suggestions"); | |
| function closeSuggestions() { | |
| suggestionsEl.classList.remove("open"); | |
| suggestionsEl.innerHTML = ""; | |
| } | |
| searchInput.addEventListener("input", function () { | |
| var q = searchInput.value.trim().toLowerCase(); | |
| if (!q) { closeSuggestions(); return; } | |
| var matches = []; | |
| for (var i = 0; i < DATA.length && matches.length < 8; i++) { | |
| var r = DATA[i]; | |
| var hay = (r[0] + " " + r[1] + " " + r[3]).toLowerCase(); | |
| if (hay.indexOf(q) !== -1) matches.push(i); | |
| } | |
| if (!matches.length) { closeSuggestions(); return; } | |
| suggestionsEl.innerHTML = ""; | |
| matches.forEach(function (idx) { | |
| var r = DATA[idx]; | |
| var item = document.createElement("div"); | |
| item.className = "sugg-item"; | |
| item.innerHTML = | |
| '<div class="sugg-name">' + escapeHtml(r[0]) + "</div>" + | |
| '<div class="sugg-meta">' + escapeHtml([r[1], r[3]].filter(Boolean).join(", ")) + "</div>"; | |
| item.addEventListener("click", function () { | |
| closeSuggestions(); | |
| searchInput.value = r[0]; | |
| flyTo(idx); | |
| }); | |
| suggestionsEl.appendChild(item); | |
| }); | |
| suggestionsEl.classList.add("open"); | |
| }); | |
| document.addEventListener("click", function (e) { | |
| if (!e.target.closest(".search-wrap")) closeSuggestions(); | |
| }); | |
| function escapeHtml(s) { | |
| return String(s).replace(/[&<>"']/g, function (c) { | |
| return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; | |
| }); | |
| } | |
| // ---------- Zoom controls ---------- | |
| document.getElementById("zoomIn").addEventListener("click", function () { map.zoomIn(); }); | |
| document.getElementById("zoomOut").addEventListener("click", function () { map.zoomOut(); }); | |
| document.getElementById("zoomReset").addEventListener("click", function () { | |
| closePopover(); | |
| map.setView(INITIAL_CENTER, INITIAL_ZOOM); | |
| }); | |
| // ---------- Popover ---------- | |
| var popover = document.getElementById("popover"); | |
| var popName = document.getElementById("popName"); | |
| var popLoc = document.getElementById("popLoc"); | |
| var popBadges = document.getElementById("popBadges"); | |
| var activeMarkerEl = null; | |
| function closePopover() { | |
| popover.classList.remove("open"); | |
| if (activeMarkerEl) { activeMarkerEl.classList.remove("active"); activeMarkerEl = null; } | |
| } | |
| document.getElementById("popClose").addEventListener("click", closePopover); | |
| function openPopoverFor(idx, clientX, clientY) { | |
| var r = DATA[idx]; | |
| if (activeMarkerEl) activeMarkerEl.classList.remove("active"); | |
| activeMarkerEl = markers[idx].getElement(); | |
| if (activeMarkerEl) activeMarkerEl.classList.add("active"); | |
| popName.textContent = r[0]; | |
| var locBits = [r[1], r[2], r[3]].filter(Boolean); | |
| popLoc.textContent = locBits.join(", ") + (r[10] ? " · " + r[10] : ""); | |
| var badges = []; | |
| if (r[7]) badges.push('<span class="badge laser">Laser</span>'); | |
| if (r[8]) badges.push('<span class="badge dome">Dome</span>'); | |
| if (r[9]) badges.push('<span class="badge f70">70mm</span>'); | |
| if (!badges.length) badges.push('<span class="badge">Digital IMAX</span>'); | |
| popBadges.innerHTML = badges.join(""); | |
| popover.classList.add("open"); | |
| var vw = window.innerWidth, vh = window.innerHeight; | |
| var pw = popover.offsetWidth, ph = popover.offsetHeight; | |
| var left = Math.min(Math.max(8, clientX + 14), vw - pw - 8); | |
| var top = Math.min(Math.max(8, clientY - ph / 2), vh - ph - 8); | |
| popover.style.left = left + "px"; | |
| popover.style.top = top + "px"; | |
| } | |
| map.on("click", closePopover); | |
| map.on("movestart zoomstart", closePopover); | |
| function flyTo(idx) { | |
| var r = DATA[idx]; | |
| var latlng = [r[5], r[6]]; | |
| map.flyTo(latlng, Math.max(map.getZoom(), 8), { duration: 0.8 }); | |
| map.once("moveend", function () { | |
| var pt = map.latLngToContainerPoint(latlng); | |
| var rect = map.getContainer().getBoundingClientRect(); | |
| openPopoverFor(idx, rect.left + pt.x, rect.top + pt.y); | |
| }); | |
| } | |
| } // end initApp | |
| })(); | |
| </script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment