@@ -91,16 +91,39 @@ const breadcrumb = {
9191 .lookup-note { margin-top: 2rem; font-size: .9rem; }
9292 .lk-spin { display:inline-block; width:14px; height:14px; border:2px solid rgba(79,140,255,.3); border-top-color: var(--accent); border-radius:50%; animation: lkspin .7s linear infinite; vertical-align:-2px; margin-right:.5rem; }
9393 @keyframes lkspin { to { transform: rotate(360deg); } }
94+ .lkm-wrap { border:1px solid var(--line); border-radius:12px; overflow:hidden; background:#070d1c; min-height:90px; }
95+ .lkm-svg { display:block; width:100%; height:auto; }
96+ .lkm-ocean { fill:#070d1c; }
97+ .lkm-land { fill: rgba(255,255,255,.07); stroke: rgba(255,255,255,.14); stroke-width:.5; }
98+ .lkm-cur { fill: rgba(45,212,191,.16); stroke: rgba(45,212,191,.55); stroke-width:.9; }
99+ .lkm-dot { fill: var(--accent); }
100+ .lkm-ring { fill:none; stroke: var(--accent); stroke-width:1.5; }
101+ .lkm-pulse { fill: var(--accent); opacity:.5; transform-box: fill-box; transform-origin:center; animation: lkmpulse 1.8s ease-out infinite; }
102+ @keyframes lkmpulse { 0%{ transform:scale(.5); opacity:.55; } 70%{ transform:scale(2.8); opacity:0; } 100%{ opacity:0; } }
103+ .lkm-cap { margin-top:.6rem; font-size:.85rem; }
104+ .lkm-fail { padding:1rem; }
94105 </style >
95106
96107 <script is:inline type =" module" >
97108 import { parquetReadObjects } from 'https://esm.sh/hyparquet@1';
98109 import { compressors } from 'https://esm.sh/hyparquet-compressors@1';
110+ import { geoMercator, geoPath, geoContains } from 'https://esm.sh/d3-geo@3';
111+ import { feature as topoFeature } from 'https://esm.sh/topojson-client@3';
99112
100113 const cfg = document.getElementById('lookupConfig').dataset;
101114 const RELEASE = cfg.release;
102115 const BASE = cfg.base;
103116
117+ // Natural Earth country outlines (public domain) — for the location pin map. Lazy-loaded once.
118+ const WORLD_URL = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
119+ let WORLD = null;
120+ async function ensureWorld() {
121+ if (WORLD) return WORLD;
122+ const tj = await fetch(WORLD_URL).then((r) => r.json());
123+ WORLD = topoFeature(tj, tj.objects.countries).features;
124+ return WORLD;
125+ }
126+
104127 const form = document.getElementById('lookupForm');
105128 const input = document.getElementById('lookupInput');
106129 const statusEl = document.getElementById('lookupStatus');
@@ -169,9 +192,48 @@ const breadcrumb = {
169192
170193 const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
171194
195+ // IPInfo-style location map: auto-zoom to the block's country (point-in-polygon, no code tables),
196+ // pulsing pin at the exact /24 coordinate. No tiles, no API — Natural Earth outline only.
197+ async function renderMap(row) {
198+ const mount = document.getElementById('lkmMount');
199+ const cap = document.getElementById('lkmCap');
200+ if (!mount) return;
201+ const lat = Number(val(row.Latitude));
202+ const lon = Number(val(row.Longitude));
203+ if (!isFinite(lat) || !isFinite(lon)) return;
204+ let feats;
205+ try { feats = await ensureWorld(); }
206+ catch { mount.innerHTML = '<div class="lkm-fail muted">Map unavailable right now.</div>'; return; }
207+ const W = 860, H = 340, PAD = 18;
208+ let country = null;
209+ for (const f of feats) { if (geoContains(f, [lon, lat])) { country = f; break; } }
210+ const proj = geoMercator();
211+ if (country) {
212+ proj.fitExtent([[PAD, PAD], [W - PAD, H - PAD]], country);
213+ if (proj.scale() > 2400) proj.scale(2400).center([lon, lat]).translate([W / 2, H / 2]);
214+ } else {
215+ proj.center([lon, lat]).scale(820).translate([W / 2, H / 2]);
216+ }
217+ proj.clipExtent([[0, 0], [W, H]]); // clip to viewport — keeps off-screen path data tiny
218+ const path = geoPath(proj);
219+ const land = feats.map((f) => { const d = path(f); return d ? `<path d="${d}" class="lkm-land${f === country ? ' lkm-cur' : ''}"/>` : ''; }).join('');
220+ const p = proj([lon, lat]);
221+ const px = p ? p[0] : W / 2, py = p ? p[1] : H / 2;
222+ mount.innerHTML =
223+ `<svg viewBox =" 0 0 ${W} ${H}" class =" lkm-svg" role =" img" aria-label =" Approximate location of the block" >` +
224+ `<rect width =" ${W}" height =" ${H}" class =" lkm-ocean" /><g >${ land } </g >` +
225+ `<g class =" lkm-pin" transform =" translate(${px.toFixed(1)},${py.toFixed(1)})" >` +
226+ `<circle class =" lkm-pulse" r =" 7" /><circle class =" lkm-ring" r =" 7" /><circle class =" lkm-dot" r =" 3.4" /></g ></svg >`;
227+ const parts = [val(row.CityName), val(row.RegionName), val(row.CountryCode)].filter(Boolean);
228+ cap.textContent = (parts.length ? parts.join(', ') + ' · ' : '') + `${ lat .toFixed (4 )} , ${ lon .toFixed (4 )} `;
229+ }
230+
172231 function render(prefix, row) {
232+ const _lat = Number (val (row .Latitude )), _lon = Number (val (row .Longitude ));
233+ const hasCoord = isFinite (_lat ) && isFinite (_lon );
173234 const head = ` <div class="lk-card"><div class="lk-head">${esc (prefix )}</div>
174235 <div class="muted" style="margin-top:.2rem">Snapshot ${esc (val (row .snapshot_month ) || RELEASE )}</div></div> ` ;
236+ const mapCard = hasCoord ? ` <div class="lk-card lkm-card"><h2>Approx. location</h2><div id="lkmMount" class="lkm-wrap"></div><div id="lkmCap" class="lkm-cap muted"></div></div> ` : ' ' ;
175237 const cards = FIELDS .map (([title , fields ]) => {
176238 const rows = fields .map (([key , label ]) => {
177239 let v = val (row [key ]);
@@ -188,8 +250,9 @@ const breadcrumb = {
188250 }).join (' ' );
189251 return rows ? ` <div class="lk-card"><h2>${title }</h2><div class="lk-grid">${rows }</div></div> ` : ' ' ;
190252 }).join (' ' );
191- resultEl.innerHTML = head + cards;
253+ resultEl .innerHTML = head + mapCard + cards ;
192254 resultEl .hidden = false ;
255+ if (hasCoord ) renderMap (row );
193256 }
194257
195258 async function doLookup(e) {
0 commit comments