feat: add zoom controls to map levels 5 and 6

- Add +/−/⊠ zoom buttons overlay on the SVG map container
- Implement viewBox-based zoom (1×–8×) centered on map midpoint
- Add drag-to-pan when zoomed in (pointer events, ignores comarca clicks)
- Add pinch-to-zoom gesture support for touch devices
- Zoom resets to full view at the start of each new game
This commit is contained in:
Jaume Garriga Maestre
2026-05-06 13:03:34 +02:00
parent 0a9bdd72cc
commit 4e3a3b4393

View File

@@ -166,8 +166,17 @@
.map-q-box{background:#fff;border-radius:15px;padding:14px 18px; .map-q-box{background:#fff;border-radius:15px;padding:14px 18px;
box-shadow:var(--shadow);margin-bottom:14px;text-align:center;} box-shadow:var(--shadow);margin-bottom:14px;text-align:center;}
#map-svg-container{width:100%;background:#D8EEF8;border-radius:16px; #map-svg-container{width:100%;background:#D8EEF8;border-radius:16px;
overflow:hidden;box-shadow:var(--shadow);} overflow:hidden;box-shadow:var(--shadow);position:relative;}
#map-svg{width:100%;height:auto;display:block;cursor:pointer;} #map-svg{width:100%;height:auto;display:block;cursor:pointer;}
/* Zoom controls overlay */
#map-zoom-controls{position:absolute;bottom:10px;right:10px;display:flex;
flex-direction:column;gap:4px;z-index:10;}
.map-zoom-btn{width:36px;height:36px;border-radius:50%;border:none;
background:rgba(255,255,255,.88);box-shadow:0 2px 6px rgba(0,0,0,.22);
font-size:1.3rem;line-height:1;cursor:pointer;display:flex;
align-items:center;justify-content:center;transition:background .15s,transform .1s;}
.map-zoom-btn:hover{background:#fff;transform:scale(1.12);}
.map-zoom-btn:active{transform:scale(.95);}
.comarca-bg{fill:#C5DEB0;stroke:#a8c890;stroke-width:.8;transition:fill .2s;} .comarca-bg{fill:#C5DEB0;stroke:#a8c890;stroke-width:.8;transition:fill .2s;}
.comarca-mountain{stroke:#fff;stroke-width:1.4;cursor:pointer; .comarca-mountain{stroke:#fff;stroke-width:1.4;cursor:pointer;
transition:fill .25s,filter .2s;filter:drop-shadow(0 1px 2px rgba(0,0,0,.18));} transition:fill .25s,filter .2s;filter:drop-shadow(0 1px 2px rgba(0,0,0,.18));}
@@ -518,6 +527,11 @@
</div> </div>
<div id="map-svg-container"> <div id="map-svg-container">
<svg id="map-svg" viewBox="0 0 820 600" xmlns="http://www.w3.org/2000/svg"></svg> <svg id="map-svg" viewBox="0 0 820 600" xmlns="http://www.w3.org/2000/svg"></svg>
<div id="map-zoom-controls">
<button class="map-zoom-btn" onclick="mapZoomIn()" title="Apropar">+</button>
<button class="map-zoom-btn" onclick="mapZoomOut()" title="Allunyar"></button>
<button class="map-zoom-btn" onclick="mapZoomReset()" title="Vista completa" style="font-size:.9rem;"></button>
</div>
</div> </div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px;justify-content:center;font-size:.8rem;color:#888;"> <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px;justify-content:center;font-size:.8rem;color:#888;">
<span>🟫 Comarques de muntanya &nbsp;·&nbsp; 🟩 Resta de Catalunya</span> <span>🟫 Comarques de muntanya &nbsp;·&nbsp; 🟩 Resta de Catalunya</span>
@@ -877,6 +891,9 @@ let sessionStart = null;
let currentLevel=1, currentQ=0, score=0, questions=[]; let currentLevel=1, currentQ=0, score=0, questions=[];
let fcFlipped=false, fcIndex=0; let fcFlipped=false, fcIndex=0;
let mapMode='name', mapTarget=null, mapAnswered=false, mapBuilt=false, mapBlind=false; let mapMode='name', mapTarget=null, mapAnswered=false, mapBuilt=false, mapBlind=false;
// Zoom state: viewBox is 820×600 at scale 1. Pan centre starts at (410, 300).
const MAP_W=820, MAP_H=600;
let mapZoom=1, mapPanX=MAP_W/2, mapPanY=MAP_H/2;
let l3Round=0, l3Sel=null, l3BTotal=0, l3BDone=0; let l3Round=0, l3Sel=null, l3BTotal=0, l3BDone=0;
const L3B=5; const L3B=5;
@@ -1513,6 +1530,51 @@ function buildMap(){
addTextSVG(svg,36,310,'ARAGÓ',7,'#999','middle','rotate(-90,36,310)'); addTextSVG(svg,36,310,'ARAGÓ',7,'#999','middle','rotate(-90,36,310)');
addTextSVG(svg,790,430,'Mar Mediterrani',7,'#6BAFD6','middle','rotate(90,790,430)'); addTextSVG(svg,790,430,'Mar Mediterrani',7,'#6BAFD6','middle','rotate(90,790,430)');
addTextSVG(svg,320,575,'COMUNITAT VALENCIANA',7,'#999','middle'); addTextSVG(svg,320,575,'COMUNITAT VALENCIANA',7,'#999','middle');
// Drag-to-pan (only when zoomed in)
let _drag=null;
svg.addEventListener('pointerdown',e=>{
if(mapZoom<=1)return;
// Ignore clicks on comarca paths (handled separately)
if(e.target.classList.contains('comarca-mountain'))return;
_drag={x:e.clientX,y:e.clientY,px:mapPanX,py:mapPanY};
svg.style.cursor='grabbing';
e.preventDefault();
},{passive:false});
svg.addEventListener('pointermove',e=>{
if(!_drag)return;
// Convert screen delta to SVG coordinate delta
const rect=svg.getBoundingClientRect();
const scaleX=MAP_W/(mapZoom*rect.width);
const scaleY=MAP_H/(mapZoom*rect.height);
mapPanX=_drag.px-(e.clientX-_drag.x)*scaleX;
mapPanY=_drag.py-(e.clientY-_drag.y)*scaleY;
_applyMapViewBox();
});
const _endDrag=()=>{_drag=null;svg.style.cursor='pointer';};
svg.addEventListener('pointerup',_endDrag);
svg.addEventListener('pointerleave',_endDrag);
// Pinch-to-zoom (touch)
let _pinchDist=0;
svg.addEventListener('touchstart',e=>{
if(e.touches.length===2){
const dx=e.touches[0].clientX-e.touches[1].clientX;
const dy=e.touches[0].clientY-e.touches[1].clientY;
_pinchDist=Math.hypot(dx,dy);
}
},{passive:true});
svg.addEventListener('touchmove',e=>{
if(e.touches.length===2){
const dx=e.touches[0].clientX-e.touches[1].clientX;
const dy=e.touches[0].clientY-e.touches[1].clientY;
const d=Math.hypot(dx,dy);
if(_pinchDist>0){
mapZoom=Math.max(1,Math.min(8,mapZoom*(d/_pinchDist)));
_applyMapViewBox();
}
_pinchDist=d;
e.preventDefault();
}
},{passive:false});
} }
function addLabel(svg,x,y,text,small){ function addLabel(svg,x,y,text,small){
const t=document.createElementNS('http://www.w3.org/2000/svg','text'); const t=document.createElementNS('http://www.w3.org/2000/svg','text');
@@ -1529,6 +1591,7 @@ function resetMapPaths(){
} }
function initMap(){ function initMap(){
currentQ=0;score=0;mapAnswered=false; currentQ=0;score=0;mapAnswered=false;
mapZoomReset(); // always start at full-view
// Both levels show mode tabs — level 6 just removes labels from the map // Both levels show mode tabs — level 6 just removes labels from the map
const tabs=document.querySelector('.map-mode-tabs'); const tabs=document.querySelector('.map-mode-tabs');
tabs.style.display='flex'; tabs.style.display='flex';
@@ -1538,6 +1601,28 @@ function initMap(){
nextMapQ(); nextMapQ();
} }
function setMapMode(mode){mapMode=mode;document.getElementById('tab-name').classList.toggle('active',mode==='name');document.getElementById('tab-cap').classList.toggle('active',mode==='capital');initMap();} function setMapMode(mode){mapMode=mode;document.getElementById('tab-name').classList.toggle('active',mode==='name');document.getElementById('tab-cap').classList.toggle('active',mode==='capital');initMap();}
/* ── Zoom helpers ───────────────────────────────────────────────── */
function _applyMapViewBox(){
const svg=document.getElementById('map-svg');
const w=MAP_W/mapZoom, h=MAP_H/mapZoom;
const x=Math.max(0,Math.min(MAP_W-w, mapPanX-w/2));
const y=Math.max(0,Math.min(MAP_H-h, mapPanY-h/2));
svg.setAttribute('viewBox',`${x} ${y} ${w} ${h}`);
}
function mapZoomIn(){
mapZoom=Math.min(mapZoom*1.5, 8); // max 8× zoom
_applyMapViewBox();
}
function mapZoomOut(){
mapZoom=Math.max(mapZoom/1.5, 1); // min 1× (full view)
_applyMapViewBox();
}
function mapZoomReset(){
mapZoom=1; mapPanX=MAP_W/2; mapPanY=MAP_H/2;
const svg=document.getElementById('map-svg');
if(svg) svg.setAttribute('viewBox',`0 0 ${MAP_W} ${MAP_H}`);
}
function nextMapQ(){ function nextMapQ(){
if(currentQ>=questions.length){showResult(5);return;} if(currentQ>=questions.length){showResult(5);return;}
mapAnswered=false;mapTarget=questions[currentQ]; mapAnswered=false;mapTarget=questions[currentQ];