Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>World Map – Touch-Friendly Canvas</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> <!-- D3 & TopoJSON --> <script src="https://d3js.org/d3.v7.min.js"></script> <script src="https://unpkg.com/topojson@3"></script> <style> | |
| :root { | |
| --water: #a0d3ff; | |
| --country: #f7f7f7; | |
| --country-hover: #d0d0d0; | |
| --country-selected: #7d7d7d; | |
| --stroke: #444; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| background: var(--water); | |
| overflow: hidden; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| canvas { display: block; touch-action: none; } | |
| .info-box { | |
| position: absolute; top: 1rem; left: 1rem; | |
| background: rgba(255,255,255,.85); backdrop-filter: blur(6px); | |
| padding: .75rem 1rem; border-radius: .5rem; | |
| box-shadow: 0 4px 15px rgba(0,0,0,.12); | |
| font-size: .9rem; user-select: none; | |
| } | |
| .info-box strong { font-weight: 600; color:#333; } | |
| #country-id { | |
| font-family: "Courier New", monospace; background:#e9ecef; | |
| padding:0 .35rem; border-radius:4px; margin-left:.35rem; color:#c7254e; | |
| } | |
| </style></head> | |
| <body> | |
| <div class="info-box"><strong>Selected Country ID:</strong><span id="country-id">None</span></div> | |
| <canvas id="mapCanvas"></canvas><script> | |
| (async function () { | |
| /* Hi-DPI Canvas ------------------------------------------------------ */ | |
| const canvas = document.getElementById('mapCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| let dpr = window.devicePixelRatio || 1; | |
| function resizeCanvas () { | |
| const w = window.innerWidth, h = window.innerHeight; | |
| canvas.style.width = w + 'px'; | |
| canvas.style.height = h + 'px'; | |
| canvas.width = w * dpr; | |
| canvas.height = h * dpr; | |
| ctx.setTransform(dpr,0,0,dpr,0,0); | |
| projection.translate([w/2, h/2]).scale(Math.min(w/5.5, h/3)); | |
| render(); | |
| } | |
| /* Projection & Path -------------------------------------------------- */ | |
| const projection = d3.geoNaturalEarth1(); | |
| const path = d3.geoPath(projection, ctx); | |
| /* State -------------------------------------------------------------- */ | |
| let countries = [], hovered=null, selected=null, currentT=d3.zoomIdentity; | |
| window.SELECTED_COUNTRY_ID = null; | |
| /* Robust data loader (jsDelivr primary, unpkg fallback) -------------- */ | |
| async function fetchWorld () { | |
| const sources = [ | |
| 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json', | |
| 'https://unpkg.com/world-atlas@2/world/110m.json' | |
| ]; | |
| for (const src of sources) { | |
| try { return await d3.json(src); } catch (_) { /* continue */ } | |
| } | |
| throw new Error('All sources failed'); | |
| } | |
| try { | |
| const world = await fetchWorld(); | |
| countries = topojson.feature(world, world.objects.countries).features; | |
| resizeCanvas(); | |
| } catch (err) { | |
| console.error('Map data load failed', err); | |
| document.querySelector('.info-box').textContent = 'Error loading map data'; | |
| } | |
| /* Zoom & Pan --------------------------------------------------------- */ | |
| d3.select(canvas) | |
| .call( | |
| d3.zoom().scaleExtent([1,16]).on('zoom', (ev)=>{ currentT=ev.transform; render(); }) | |
| ) | |
| .on('dblclick.zoom', null); // disable default dblclick | |
| canvas.addEventListener('dblclick', ()=>{ | |
| currentT=d3.zoomIdentity; | |
| d3.select(canvas).transition().duration(250).call(d3.zoom().transform,d3.zoomIdentity); | |
| render(); | |
| }); | |
| /* Pointer helpers ---------------------------------------------------- */ | |
| function pointerLatLon (ev) { | |
| const [x,y]=d3.pointer(ev, canvas); const [ux,uy]=currentT.invert([x,y]); | |
| return projection.invert([ux,uy]); | |
| } | |
| const countryAt=(ev)=>countries.find(c=>d3.geoContains(c, pointerLatLon(ev))); | |
| /* Events ------------------------------------------------------------- */ | |
| d3.select(canvas) | |
| .on('mousemove', (ev)=>{ const c=countryAt(ev); if(c!==hovered){ hovered=c; canvas.style.cursor=c?'pointer':'default'; render(); } }) | |
| .on('mouseout', ()=>{ hovered=null; canvas.style.cursor='default'; render(); }) | |
| .on('click', (ev)=>{ | |
| const c=countryAt(ev); if(!c) return; | |
| selected = selected && selected.id===c.id ? null : c; | |
| window.SELECTED_COUNTRY_ID = selected ? selected.id : null; | |
| document.getElementById('country-id').textContent = window.SELECTED_COUNTRY_ID ?? 'None'; | |
| render(); | |
| }); | |
| /* Render ------------------------------------------------------------- */ | |
| function render () { | |
| if(!countries.length) return; | |
| const w=canvas.width/dpr, h=canvas.height/dpr; | |
| ctx.save(); ctx.clearRect(0,0,w,h); | |
| ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--water'); | |
| ctx.fillRect(0,0,w,h); | |
| ctx.translate(currentT.x, currentT.y); ctx.scale(currentT.k, currentT.k); | |
| ctx.beginPath(); path({type:'FeatureCollection', features:countries}); | |
| ctx.fillStyle=getComputedStyle(document.documentElement).getPropertyValue('--country'); ctx.fill(); | |
| ctx.strokeStyle=getComputedStyle(document.documentElement).getPropertyValue('--stroke'); ctx.lineWidth=.18; ctx.stroke(); | |
| if(hovered){ ctx.beginPath(); path(hovered); ctx.fillStyle=getComputedStyle(document.documentElement).getPropertyValue('--country-hover'); ctx.fill(); } | |
| if(selected){ ctx.beginPath(); path(selected); ctx.fillStyle=getComputedStyle(document.documentElement).getPropertyValue('--country-selected'); ctx.fill(); } | |
| ctx.restore(); | |
| } | |
| /* Resize listener ---------------------------------------------------- */ | |
| window.addEventListener('resize', ()=>{ if(window.devicePixelRatio!==dpr) dpr=window.devicePixelRatio||1; resizeCanvas(); }); | |
| })(); | |
| </script></body> | |
| </html> |