diff --git a/layouts/shortcodes/world-map.html b/layouts/shortcodes/world-map.html index 3865e69..e1c53f2 100644 --- a/layouts/shortcodes/world-map.html +++ b/layouts/shortcodes/world-map.html @@ -6,10 +6,8 @@ .ifk-map-wrap h3 { text-align: center; margin-bottom: 0.5rem; font-size: 1.25rem; } .ifk-map-subtitle { text-align: center; color: #666; font-size: 0.85rem; margin-bottom: 1rem; } .ifk-map-container { position: relative; width: 100%; height: 420px; border-radius: 8px; overflow: hidden; } -.ifk-map-svg { width: 100%; height: 100%; display: block; cursor: grab; background: #c8d3df; } -.ifk-map-svg:active { cursor: grabbing; } -.ifk-map-svg .country { stroke: #94a3b8; stroke-width: 0.3; transition: stroke-width 0.15s; } -.ifk-map-svg .country.has-data:hover { stroke: #667eea; stroke-width: 1.2; cursor: pointer; } +.ifk-map-canvas { width: 100%; height: 100%; display: block; cursor: grab; image-rendering: auto; } +.ifk-map-canvas:active { cursor: grabbing; } .ifk-map-controls { position: absolute; top: 10px; right: 10px; display: flex; flex-direction: column; gap: 4px; z-index: 10; } .ifk-map-controls button { width: 30px; height: 30px; background: #fff; border: 1px solid #ccc; border-radius: 4px; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #333; } .ifk-map-controls button:hover { background: #f0f0f0; } @@ -33,7 +31,7 @@

{{ if eq $lang "de" }}Interaktive Weltkarte: Kinderschutzgesetze{{ else if eq $lang "fr" }}Carte interactive : lois de protection de l'enfance{{ else }}Interactive Map: Child Protection Laws{{ end }}

{{ if eq $lang "de" }}Klicken Sie auf ein Land für Details{{ else if eq $lang "fr" }}Cliquez sur un pays pour plus de détails{{ else }}Click a country for details{{ end }}

- +
@@ -68,187 +66,262 @@ var byIsoNum = {}; countries.forEach(function(c) { byIsoNum[c.isoNum] = c; }); - // Map dimensions (Equirectangular projection) var W = 960, H = 480; - var svg = document.getElementById('ifk-map-svg'); + var canvas = document.getElementById('ifk-map-canvas'); var container = document.getElementById('ifk-map-container'); var popup = document.getElementById('ifk-map-popup'); var popupContent = document.getElementById('ifk-popup-content'); + var dpr = window.devicePixelRatio || 1; // ViewBox state var vb = { x: 0, y: 0, w: W, h: H }; var VB_INIT = { x: 0, y: 0, w: W, h: H }; - var MIN_W = W / 16; // max zoom in + var MIN_W = W / 16; - function setViewBox() { - svg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h); + // Sizing: match exact device pixels to prevent GPU scaling + function sizeCanvas() { + var rect = container.getBoundingClientRect(); + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.height * dpr); + canvas.style.width = rect.width + 'px'; + canvas.style.height = rect.height + 'px'; } - // Equirectangular projection function projectCoord(lon, lat) { return [(lon + 180) * (W / 360), (90 - lat) * (H / 180)]; } - function projectRing(ring) { - var d = ''; - for (var i = 0; i < ring.length; i++) { - var p = projectCoord(ring[i][0], ring[i][1]); - d += (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ',' + p[1].toFixed(1); - } - return d + 'Z'; + // Pre-computed paths for hit testing + var countryPaths = []; + var geoData = null; + + function drawMap() { + var ctx = canvas.getContext('2d'); + var cw = canvas.width, ch = canvas.height; + // Transform from viewBox to canvas + var sx = cw / vb.w, sy = ch / vb.h; + + ctx.clearRect(0, 0, cw, ch); + + // Ocean background + ctx.fillStyle = '#c8d3df'; + ctx.fillRect(0, 0, cw, ch); + + if (!geoData) return; + + // Draw land base fill + ctx.fillStyle = '#ffffff'; + drawGeometry(ctx, geoData.land, sx, sy, true); + + // Draw country fills + geoData.features.forEach(function(f) { + var id = String(f.id).padStart(3, '0'); + var c = byIsoNum[id]; + if (c) { + ctx.globalAlpha = 0.65; + ctx.fillStyle = STATUS_COLORS[c.status]; + } else { + ctx.globalAlpha = 1; + ctx.fillStyle = '#ffffff'; + } + drawGeometry(ctx, f.geometry, sx, sy, true); + }); + + ctx.globalAlpha = 1; + + // Draw borders + ctx.strokeStyle = '#94a3b8'; + ctx.lineWidth = 0.5 * sx / (cw / vb.w); + drawGeometry(ctx, geoData.borders, sx, sy, false); } - function projectGeometry(geom) { - var d = ''; + function drawGeometry(ctx, geom, sx, sy, fill) { + ctx.beginPath(); + var coords; if (geom.type === 'Polygon') { - geom.coordinates.forEach(function(ring) { d += projectRing(ring); }); + geom.coordinates.forEach(function(ring) { traceRing(ctx, ring, sx, sy); }); } else if (geom.type === 'MultiPolygon') { geom.coordinates.forEach(function(poly) { - poly.forEach(function(ring) { d += projectRing(ring); }); + poly.forEach(function(ring) { traceRing(ctx, ring, sx, sy); }); }); + } else if (geom.type === 'MultiLineString') { + geom.coordinates.forEach(function(line) { traceLine(ctx, line, sx, sy); }); + } else if (geom.type === 'LineString') { + traceLine(ctx, geom.coordinates, sx, sy); } - return d; + if (fill) ctx.fill('evenodd'); + else ctx.stroke(); } - // Build SVG + function traceRing(ctx, ring, sx, sy) { + for (var i = 0; i < ring.length; i++) { + var p = projectCoord(ring[i][0], ring[i][1]); + var x = (p[0] - vb.x) * sx; + var y = (p[1] - vb.y) * sy; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + } + + function traceLine(ctx, line, sx, sy) { + for (var i = 0; i < line.length; i++) { + var p = projectCoord(line[i][0], line[i][1]); + var x = (p[0] - vb.x) * sx; + var y = (p[1] - vb.y) * sy; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + } + + // Load data fetch('{{ "data/countries-50m.json" | relURL }}') .then(function(r) { return r.json(); }) .then(function(topo) { var geo = topojson.feature(topo, topo.objects.countries); var borders = topojson.mesh(topo, topo.objects.countries, function(a, b) { return a !== b; }); - - // Land base fill (covers gaps at poles between country polygons) var land = topojson.feature(topo, topo.objects.land); - var landPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - landPath.setAttribute('d', projectGeometry(land.features ? land.features[0].geometry : land.geometry)); - landPath.setAttribute('fill', '#ffffff'); - landPath.setAttribute('stroke', 'none'); - landPath.setAttribute('pointer-events', 'none'); - svg.appendChild(landPath); + var landGeom = land.features ? land.features[0].geometry : land.geometry; - // Country fills (ocean is CSS background on container) + geoData = { features: geo.features, borders: borders, land: landGeom }; + + // Pre-build hit-test data geo.features.forEach(function(f) { var id = String(f.id).padStart(3, '0'); var c = byIsoNum[id]; - var path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - var d = projectGeometry(f.geometry); - if (!d) return; - path.setAttribute('d', d); - path.setAttribute('fill', c ? STATUS_COLORS[c.status] : '#ffffff'); - path.setAttribute('fill-opacity', c ? '0.65' : '1'); - path.setAttribute('class', 'country' + (c ? ' has-data' : '')); - path.setAttribute('data-iso', id); - svg.appendChild(path); + if (c) countryPaths.push({ id: id, geometry: f.geometry, data: c }); }); - // Borders (shared edges only) - var bordersD = projectGeometry(borders); - if (bordersD) { - var borderPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - borderPath.setAttribute('d', bordersD); - borderPath.setAttribute('fill', 'none'); - borderPath.setAttribute('stroke', '#94a3b8'); - borderPath.setAttribute('stroke-width', '0.3'); - borderPath.setAttribute('pointer-events', 'none'); - svg.appendChild(borderPath); - } - - setViewBox(); - - // Click handler - svg.addEventListener('click', function(e) { - var target = e.target; - if (!target.classList.contains('has-data')) { popup.style.display = 'none'; return; } - var id = target.getAttribute('data-iso'); - var c = byIsoNum[id]; - if (!c) return; - - var loc = c[LANG] || c.en; - var labels = STATUS_LABELS[LANG] || STATUS_LABELS.en; - var ageLabel = AGE_LABELS[LANG] || AGE_LABELS.en; - var html = '' + c.flag + ' ' + loc.name + '
' + - '' + loc.law + '
' + - '' + labels[c.status] + ' (' + c.year + ')' + - '
\u{1F464} ' + ageLabel + ': ' + c.ageLimitSocial + '+
' + - '
' + loc.detail + '
'; - var anchor = ARTICLE_ANCHORS[c.iso3]; - if (anchor) { - var readMore = { en: 'Read more \u2193', de: 'Weiterlesen \u2193', fr: 'En savoir plus \u2193' }; - html += '' + (readMore[LANG] || readMore.en) + ''; - } - popupContent.innerHTML = html; - - // Position popup near click - var rect = container.getBoundingClientRect(); - var px = e.clientX - rect.left; - var py = e.clientY - rect.top; - // Keep popup within container - popup.style.display = 'block'; - var pw = popup.offsetWidth; - var ph = popup.offsetHeight; - var left = Math.min(px + 10, rect.width - pw - 10); - var top = Math.max(py - ph - 10, 10); - if (top < 10) top = py + 10; - popup.style.left = left + 'px'; - popup.style.top = top + 'px'; - }); - - // Close popup - document.getElementById('ifk-popup-close').addEventListener('click', function(e) { - e.stopPropagation(); - popup.style.display = 'none'; - }); - - // Zoom controls - function zoomBy(factor) { - var cx = vb.x + vb.w / 2; - var cy = vb.y + vb.h / 2; - var nw = Math.max(MIN_W, Math.min(VB_INIT.w, vb.w * factor)); - var nh = nw * (H / W); - vb.w = nw; vb.h = nh; - vb.x = Math.max(0, Math.min(W - vb.w, cx - vb.w / 2)); - vb.y = Math.max(0, Math.min(H - vb.h, cy - vb.h / 2)); - setViewBox(); - } - - document.getElementById('ifk-zoom-in').addEventListener('click', function() { zoomBy(0.5); }); - document.getElementById('ifk-zoom-out').addEventListener('click', function() { zoomBy(2); }); - document.getElementById('ifk-zoom-reset').addEventListener('click', function() { - vb.x = VB_INIT.x; vb.y = VB_INIT.y; vb.w = VB_INIT.w; vb.h = VB_INIT.h; - setViewBox(); - }); - - // Pan via drag - var dragging = false, dragStart = null; - svg.addEventListener('mousedown', function(e) { - if (e.target.closest('.ifk-map-controls') || e.target.closest('.ifk-map-popup')) return; - dragging = true; - dragStart = { x: e.clientX, y: e.clientY, vbx: vb.x, vby: vb.y }; - e.preventDefault(); - }); - window.addEventListener('mousemove', function(e) { - if (!dragging) return; - var scale = vb.w / svg.clientWidth; - vb.x = Math.max(0, Math.min(W - vb.w, dragStart.vbx - (e.clientX - dragStart.x) * scale)); - vb.y = Math.max(0, Math.min(H - vb.h, dragStart.vby - (e.clientY - dragStart.y) * scale)); - setViewBox(); - }); - window.addEventListener('mouseup', function() { dragging = false; }); - - // Touch support - svg.addEventListener('touchstart', function(e) { - if (e.touches.length !== 1) return; - dragging = true; - dragStart = { x: e.touches[0].clientX, y: e.touches[0].clientY, vbx: vb.x, vby: vb.y }; - }, { passive: true }); - svg.addEventListener('touchmove', function(e) { - if (!dragging || e.touches.length !== 1) return; - var scale = vb.w / svg.clientWidth; - vb.x = Math.max(0, Math.min(W - vb.w, dragStart.vbx - (e.touches[0].clientX - dragStart.x) * scale)); - vb.y = Math.max(0, Math.min(H - vb.h, dragStart.vby - (e.touches[0].clientY - dragStart.y) * scale)); - setViewBox(); - }, { passive: true }); - svg.addEventListener('touchend', function() { dragging = false; }, { passive: true }); + sizeCanvas(); + drawMap(); }); + + // Resize handler + var resizeTimer; + window.addEventListener('resize', function() { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(function() { sizeCanvas(); drawMap(); }, 100); + }); + + // Hit test: check if point is inside a country polygon + function hitTest(clientX, clientY) { + var rect = canvas.getBoundingClientRect(); + var mx = clientX - rect.left; + var my = clientY - rect.top; + // Convert to map coords + var mapX = vb.x + (mx / rect.width) * vb.w; + var mapY = vb.y + (my / rect.height) * vb.h; + // Convert to lon/lat + var lon = (mapX / W) * 360 - 180; + var lat = 90 - (mapY / H) * 180; + + // Use canvas isPointInPath for hit testing + var ctx = canvas.getContext('2d'); + var sx = canvas.width / vb.w, sy = canvas.height / vb.h; + for (var i = 0; i < countryPaths.length; i++) { + ctx.beginPath(); + var geom = countryPaths[i].geometry; + if (geom.type === 'Polygon') { + geom.coordinates.forEach(function(ring) { traceRing(ctx, ring, sx, sy); }); + } else if (geom.type === 'MultiPolygon') { + geom.coordinates.forEach(function(poly) { + poly.forEach(function(ring) { traceRing(ctx, ring, sx, sy); }); + }); + } + if (ctx.isPointInPath(mx * dpr, my * dpr, 'evenodd')) { + return countryPaths[i]; + } + } + return null; + } + + // Hover cursor + canvas.addEventListener('mousemove', function(e) { + var hit = hitTest(e.clientX, e.clientY); + canvas.style.cursor = hit ? 'pointer' : 'grab'; + }); + + // Click handler + canvas.addEventListener('click', function(e) { + var hit = hitTest(e.clientX, e.clientY); + if (!hit) { popup.style.display = 'none'; return; } + var c = hit.data; + var loc = c[LANG] || c.en; + var labels = STATUS_LABELS[LANG] || STATUS_LABELS.en; + var ageLabel = AGE_LABELS[LANG] || AGE_LABELS.en; + var html = '' + c.flag + ' ' + loc.name + '
' + + '' + loc.law + '
' + + '' + labels[c.status] + ' (' + c.year + ')' + + '
\u{1F464} ' + ageLabel + ': ' + c.ageLimitSocial + '+
' + + '
' + loc.detail + '
'; + var anchor = ARTICLE_ANCHORS[c.iso3]; + if (anchor) { + var readMore = { en: 'Read more \u2193', de: 'Weiterlesen \u2193', fr: 'En savoir plus \u2193' }; + html += '' + (readMore[LANG] || readMore.en) + ''; + } + popupContent.innerHTML = html; + var rect = container.getBoundingClientRect(); + var px = e.clientX - rect.left; + var py = e.clientY - rect.top; + popup.style.display = 'block'; + var pw = popup.offsetWidth, ph = popup.offsetHeight; + popup.style.left = Math.min(px + 10, rect.width - pw - 10) + 'px'; + popup.style.top = Math.max(py - ph - 10, 10) + 'px'; + }); + + document.getElementById('ifk-popup-close').addEventListener('click', function(e) { + e.stopPropagation(); + popup.style.display = 'none'; + }); + + // Zoom + function zoomBy(factor) { + var cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2; + var nw = Math.max(MIN_W, Math.min(VB_INIT.w, vb.w * factor)); + var nh = nw * (H / W); + vb.w = nw; vb.h = nh; + vb.x = Math.max(0, Math.min(W - vb.w, cx - vb.w / 2)); + vb.y = Math.max(0, Math.min(H - vb.h, cy - vb.h / 2)); + drawMap(); + } + document.getElementById('ifk-zoom-in').addEventListener('click', function() { zoomBy(0.5); }); + document.getElementById('ifk-zoom-out').addEventListener('click', function() { zoomBy(2); }); + document.getElementById('ifk-zoom-reset').addEventListener('click', function() { + vb = { x: 0, y: 0, w: W, h: H }; + drawMap(); + }); + + // Pan + var dragging = false, dragStart = null; + canvas.addEventListener('mousedown', function(e) { + dragging = true; + dragStart = { x: e.clientX, y: e.clientY, vbx: vb.x, vby: vb.y }; + e.preventDefault(); + }); + window.addEventListener('mousemove', function(e) { + if (!dragging) return; + var rect = canvas.getBoundingClientRect(); + var scale = vb.w / rect.width; + vb.x = Math.max(0, Math.min(W - vb.w, dragStart.vbx - (e.clientX - dragStart.x) * scale)); + vb.y = Math.max(0, Math.min(H - vb.h, dragStart.vby - (e.clientY - dragStart.y) * scale)); + drawMap(); + }); + window.addEventListener('mouseup', function() { dragging = false; }); + + // Touch + canvas.addEventListener('touchstart', function(e) { + if (e.touches.length !== 1) return; + dragging = true; + dragStart = { x: e.touches[0].clientX, y: e.touches[0].clientY, vbx: vb.x, vby: vb.y }; + }, { passive: true }); + canvas.addEventListener('touchmove', function(e) { + if (!dragging || e.touches.length !== 1) return; + var rect = canvas.getBoundingClientRect(); + var scale = vb.w / rect.width; + vb.x = Math.max(0, Math.min(W - vb.w, dragStart.vbx - (e.touches[0].clientX - dragStart.x) * scale)); + vb.y = Math.max(0, Math.min(H - vb.h, dragStart.vby - (e.touches[0].clientY - dragStart.y) * scale)); + drawMap(); + }, { passive: true }); + canvas.addEventListener('touchend', function() { dragging = false; }, { passive: true }); })();