diff --git a/fes.js b/fes.js index 8e84559..d1c7aed 100644 --- a/fes.js +++ b/fes.js @@ -1,6 +1,6 @@ -/* script.js - - スマホ優先で作り込んだマップロジック + Panzoom対応 - - CSV: data/floor1.csv / data/floor2.csv を想定 +/* script.js — 完成版 + - CSV: data/floor1.csv / data/floor2.csv を想定 + - 期待ヘッダ: 名前 / 説明 / 画像ファイル / X座標 / Y座標 */ 'use strict'; @@ -10,11 +10,12 @@ // DOM const img = document.getElementById('mapImage'); -const map = document.getElementById('map'); +const map = document.getElementById('map'); // Panzoom 対象 +const wrapper = map.parentElement; // touch-action 切替のため const tabButtons = document.querySelectorAll('.tab-button'); const listToggle = document.getElementById('listToggle'); -const listPanel = document.getElementById('listPanel'); -const listContent = document.getElementById('listContent'); +const listPanel = document.getElementById('listPanel'); +const listContent= document.getElementById('listContent'); const closeListBtn = document.getElementById('closeList'); let currentFloor = 1; @@ -23,7 +24,14 @@ let tooltipEls = []; let panzoomInstance = null; -/* ---------------- CSV パーサなど(前と同様) ---------------- */ +// base image displayed size (measured when scale == 1) +let baseImageWidth = 0; +let baseImageHeight = 0; + +// track active pointer ids (for deciding multi-touch) +const activePointers = new Set(); + +/* ---------------- CSV パーサ(引用対応) ---------------- */ function parseCSV(text) { const rows = []; let cur = '', row = [], inQuotes = false; @@ -89,6 +97,8 @@ return []; } } + +/* 画像ロード待ち */ function waitImageLoad(imgEl) { return new Promise(resolve => { if (imgEl.complete && imgEl.naturalWidth !== 0) return resolve(); @@ -97,6 +107,39 @@ }); } +/* ---------------- transform 行列から scale を読む(安全) ---------------- */ +function getCurrentScaleFromTransform() { + const st = window.getComputedStyle(map).transform; + if (!st || st === 'none') return 1; + // matrix(a,b,c,d,e,f) + const m = st.match(/^matrix\((.+)\)$/); + if (m) { + const parts = m[1].split(',').map(Number); + const a = parts[0], b = parts[1]; + return Math.sqrt(a*a + b*b); + } + const m3 = st.match(/^matrix3d\((.+)\)$/); + if (m3) { + const parts = m3[1].split(',').map(Number); + const a = parts[0], b = parts[1]; + return Math.sqrt(a*a + b*b); + } + return 1; +} + +/* ---------------- touch-action の切替ロジック ---------------- + - scale > 1 または 複数指操作中 は map の操作を優先(touch-action: none) + - scale == 1 and single-finger -> ページ縦スクロール優先 (pan-y) +*/ +function updateWrapperTouchAction() { + const scale = getCurrentScaleFromTransform(); + if (activePointers.size > 1 || scale > 1.001) { + wrapper.style.touchAction = 'none'; + } else { + wrapper.style.touchAction = 'pan-y'; + } +} + /* ---------------- ツールチップの位置調整(viewport基準) ---------------- */ function adjustTooltipPosition(tooltip, desiredLeftPxMap, desiredTopPxMap) { const mapRect = map.getBoundingClientRect(); @@ -110,46 +153,53 @@ const tRect = tooltip.getBoundingClientRect(); const margin = 8; + // horizontal adjustment in viewport coords let shiftXViewport = 0; if (tRect.left < margin) shiftXViewport = margin - tRect.left; if (tRect.right > window.innerWidth - margin) shiftXViewport = (window.innerWidth - margin) - tRect.right; + // vertical: if tooltip top would go above viewport, flip below pin let newTopViewport = desiredTopViewport; if (tRect.top < margin) { - tooltip.style.transform = 'translate(-50%, 8px)'; + tooltip.style.transform = 'translate(-50%, 8px)'; // below pin newTopViewport = desiredTopViewport + 44 + 10; } else { tooltip.style.transform = 'translate(-50%, -120%)'; } + // convert shiftXViewport -> map-relative px const shiftXMap = shiftXViewport; const newLeftMap = desiredLeftPxMap + shiftXMap; const newTopMap = newTopViewport - mapRect.top; tooltip.style.left = newLeftMap + 'px'; - tooltip.style.top = newTopMap + 'px'; + tooltip.style.top = newTopMap + 'px'; tooltip.style.visibility = 'visible'; } /* 閉じる */ function closeAllTooltips() { - tooltipEls.forEach(t => { if (t) { t.style.display = 'none'; t.setAttribute('aria-hidden','true'); } }); + tooltipEls.forEach(t => { + if (t) { t.style.display = 'none'; t.setAttribute('aria-hidden','true'); } + }); } -/* ---------------- ピン配置 ---------------- */ +/* ---------------- ピン描画(基準サイズ baseImageWidth/baseImageHeight を使う) ---------------- */ function placePins(data) { + // remove existing pinEls.forEach(p => p.remove()); tooltipEls.forEach(t => t.remove()); pinEls = []; tooltipEls = []; - const rect = img.getBoundingClientRect(); - const width = rect.width; - const height = rect.height; + // base sizes must be set (measured when scale == 1) + const width = baseImageWidth || img.getBoundingClientRect().width; + const height = baseImageHeight || img.getBoundingClientRect().height; data.forEach((d, idx) => { const xPx = d.x * width; const yPx = d.y * height; + // pin const pin = document.createElement('div'); pin.className = 'pin'; pin.setAttribute('role','button'); @@ -164,7 +214,7 @@ pinImg.alt = ''; pin.appendChild(pinImg); - // stop pointer propagation to avoid accidental pan start + // stop propagation to avoid accidental pan start when tapping pin pin.addEventListener('pointerdown', e => e.stopPropagation()); pin.addEventListener('touchstart', e => e.stopPropagation(), {passive:true}); @@ -172,27 +222,32 @@ if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); pin.click(); } }); - // Tooltip + // tooltip card const tooltip = document.createElement('div'); tooltip.className = 'tooltip'; tooltip.dataset.index = String(idx); tooltip.setAttribute('aria-hidden','true'); const card = document.createElement('div'); card.className = 'tooltip-card'; - const closeBtn = document.createElement('button'); closeBtn.className = 'close-btn'; closeBtn.type='button'; closeBtn.textContent='✕'; closeBtn.setAttribute('aria-label','閉じる'); + const closeBtn = document.createElement('button'); closeBtn.className = 'close-btn'; closeBtn.type='button'; + closeBtn.textContent = '✕'; closeBtn.setAttribute('aria-label','閉じる'); const header = document.createElement('div'); header.className='tooltip-header'; header.textContent = d.name || '(無題)'; const body = document.createElement('div'); body.className='tooltip-body'; - const thumb = document.createElement('img'); thumb.className='tooltip-thumb'; thumb.src = d.image ? d.image : 'images/placeholder.png'; thumb.alt = d.name || ''; + const thumb = document.createElement('img'); thumb.className='tooltip-thumb'; + thumb.src = d.image ? d.image : 'images/placeholder.png'; + thumb.alt = d.name || ''; const p = document.createElement('p'); p.className='desc'; p.textContent = d.description || ''; body.appendChild(thumb); body.appendChild(p); card.appendChild(closeBtn); card.appendChild(header); card.appendChild(body); tooltip.appendChild(card); + // initial hidden position relative to base coords tooltip.style.left = xPx + 'px'; tooltip.style.top = yPx + 'px'; tooltip.style.display = 'none'; + // pin click toggles tooltip pin.addEventListener('click', (e) => { e.stopPropagation(); const isVisible = tooltip.style.display === 'block'; @@ -213,11 +268,14 @@ tooltip.setAttribute('aria-hidden','true'); }); + // prevent tooltip pointer events from causing map pan tooltip.addEventListener('pointerdown', ev => ev.stopPropagation()); tooltip.addEventListener('touchstart', ev => ev.stopPropagation(), {passive:true}); + // append to map (pin below tooltip so tooltip overlays) map.appendChild(pin); map.appendChild(tooltip); + pinEls.push(pin); tooltipEls.push(tooltip); }); @@ -225,7 +283,7 @@ renderListPanel(data); } -/* 一覧パネル */ +/* ---------------- 一覧(ボトムシート)描画 ---------------- */ function renderListPanel(data) { listContent.innerHTML = ''; data.forEach((d, idx) => { @@ -233,12 +291,11 @@ item.className = 'shop-item'; item.dataset.index = String(idx); - const thumb = document.createElement('img'); - thumb.className = 'shop-thumb'; + const thumb = document.createElement('img'); thumb.className = 'shop-thumb'; thumb.src = d.image ? d.image : 'images/placeholder.png'; thumb.alt = d.name || ''; - const meta = document.createElement('div'); meta.className='shop-meta'; + const meta = document.createElement('div'); meta.className = 'shop-meta'; const title = document.createElement('p'); title.className='shop-title'; title.textContent = d.name || `スポット ${idx+1}`; const desc = document.createElement('p'); desc.className='shop-desc'; desc.textContent = d.description || ''; @@ -253,14 +310,23 @@ listContent.appendChild(item); }); } + +/* open tooltip by index (and scroll page so map visible) */ function openTooltipByIndex(index) { if (!pinEls[index]) return; + // ensure map top visible const topY = Math.max(0, map.getBoundingClientRect().top + window.scrollY - 80); window.scrollTo({ top: topY, behavior: 'smooth' }); pinEls[index].click(); } + +/* ---------------- フロア読み込み ---------------- + ※ important: we reset panzoom BEFORE measuring base size, + so that baseImageWidth/height correspond to scale=1 displayed size. +*/ async function loadFloor(floor) { if (currentFloor === floor) return; + tabButtons.forEach(btn => { const f = Number(btn.dataset.floor); if (f === floor) { btn.classList.add('active'); btn.setAttribute('aria-selected','true'); } @@ -270,27 +336,48 @@ currentFloor = floor; img.src = IMAGE_PATH[floor]; await waitImageLoad(img); + + // reset pan & measure base size (so pins placed relative to untransformed size) + if (panzoomInstance && typeof panzoomInstance.reset === 'function') { + panzoomInstance.reset(); + try { panzoomInstance.pan(0,0); } catch(e) {} + } + // measure base displayed image size (after reset) + await tick(); // wait a frame + const r = img.getBoundingClientRect(); + baseImageWidth = r.width; + baseImageHeight = r.height; + const data = await loadCSV(CSV_PATH[floor]); currentData = data; placePins(data); - // リセット(左揃えを保つため transform を初期化) - if (panzoomInstance && typeof panzoomInstance.reset === 'function') { - panzoomInstance.reset(); - // さらに明示的に pan を 0,0 にする(左上を基準に) - try { panzoomInstance.pan(0, 0); } catch (e) { /* ignore if not supported */ } - } -} -function openList() { - listPanel.classList.add('open'); listPanel.setAttribute('aria-hidden','false'); listToggle.setAttribute('aria-expanded','true'); setTimeout(()=>listContent.focus(),160); -} -function closeList() { - listPanel.classList.remove('open'); listPanel.setAttribute('aria-hidden','true'); listToggle.setAttribute('aria-expanded','false'); listToggle.focus(); + // update touch-action state based on current transform + updateWrapperTouchAction(); } -/* 初期化 */ +/* ---------------- 一覧の open/close ---------------- */ +function openList() { + listPanel.classList.add('open'); + listPanel.setAttribute('aria-hidden','false'); + listToggle.setAttribute('aria-expanded','true'); + setTimeout(()=> listContent.focus(), 160); +} +function closeList() { + listPanel.classList.remove('open'); + listPanel.setAttribute('aria-hidden','true'); + listToggle.setAttribute('aria-expanded','false'); + listToggle.focus(); +} + +/* small helper: wait one animation frame */ +function tick() { + return new Promise(resolve => requestAnimationFrame(resolve)); +} + +/* ---------------- 初期化 ---------------- */ async function init() { - // Panzoom を map 要素に適用 + // create Panzoom instance applied to the entire map element panzoomInstance = Panzoom(map, { maxScale: 4, minScale: 1, @@ -298,46 +385,85 @@ contain: 'outside' }); - // wheel は wrapper 側で扱う - const wrapper = map.parentElement; - wrapper.addEventListener('wheel', panzoomInstance.zoomWithWheel, {passive:false}); + // wheel zoom using wrapper (so page scroll works when appropriate) + wrapper.addEventListener('wheel', (ev) => { + // if ctrlKey (desktop pinch) or user wheel, zoom + panzoomInstance.zoomWithWheel(ev); + // update touch action after wheel + requestAnimationFrame(updateWrapperTouchAction); + }, { passive: false }); - // タブ - tabButtons.forEach(btn => { - btn.addEventListener('click', () => { - const floor = Number(btn.dataset.floor); - if (!Number.isNaN(floor)) loadFloor(floor); - }); + // pointer tracking to know multi-touch + map.addEventListener('pointerdown', (ev) => { + activePointers.add(ev.pointerId); + updateWrapperTouchAction(); + }); + map.addEventListener('pointerup', (ev) => { + activePointers.delete(ev.pointerId); + updateWrapperTouchAction(); + }); + map.addEventListener('pointercancel', (ev) => { + activePointers.delete(ev.pointerId); + updateWrapperTouchAction(); }); - // グローバルタップで吹き出し閉じる + // pointermove -> schedule checking of transform (for pinch zoom in progress) + let rafId = null; + function scheduleUpdate() { + if (rafId) return; + rafId = requestAnimationFrame(() => { rafId = null; updateWrapperTouchAction(); }); + } + map.addEventListener('pointermove', scheduleUpdate, { passive: true }); + map.addEventListener('pointerup', scheduleUpdate); + map.addEventListener('pointercancel', scheduleUpdate); + + // tab buttons + tabButtons.forEach(btn => btn.addEventListener('click', () => { + const floor = Number(btn.dataset.floor); + if (!Number.isNaN(floor)) loadFloor(floor); + })); + + // global tap closes tooltips (unless tapping inside map/pin/tooltip/list) document.addEventListener('pointerdown', (ev) => { const target = ev.target; - if (target.closest('.pin') || target.closest('.tooltip') || target.closest('.list-panel') || target.closest('.list-toggle')) return; + if (target.closest('.pin') || target.closest('.tooltip') || target.closest('.list-panel') || target.closest('.list-toggle')) { + return; + } closeAllTooltips(); }); + // list toggle handlers listToggle.addEventListener('click', openList); closeListBtn.addEventListener('click', closeList); - // resize -> 再配置(デバウンス) + // resize handler — on resize, reset pan & recalc base size & re-place pins let resizeTimer = null; window.addEventListener('resize', () => { if (resizeTimer) clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { if (currentData && currentData.length) placePins(currentData); }, 200); + resizeTimer = setTimeout(async () => { + // reset pan to avoid transform interfering with measurements + if (panzoomInstance && typeof panzoomInstance.reset === 'function') { + panzoomInstance.reset(); + try { panzoomInstance.pan(0,0); } catch(e) {} + } + await tick(); + const r = img.getBoundingClientRect(); + baseImageWidth = r.width; + baseImageHeight = r.height; + if (currentData && currentData.length) placePins(currentData); + updateWrapperTouchAction(); + }, 220); }); - // 初期ロード(1F) + // initial load: 1F img.src = IMAGE_PATH[1]; await waitImageLoad(img); - currentData = await loadCSV(CSV_PATH[1]); - placePins(currentData); - - // 最初は左揃えを確実にする(reset + pan(0,0)) - if (panzoomInstance) { + // ensure pan is reset and measure base + if (panzoomInstance && typeof panzoomInstance.reset === 'function') { panzoomInstance.reset(); - try { panzoomInstance.pan(0,0); } catch (e) {} + try { panzoomInstance.pan(0,0); } catch(e) {} } -} - -document.addEventListener('DOMContentLoaded', init); + await tick(); + const r = img.getBoundingClientRect(); + baseImageWidth = r.width; + baseImageHeight = r.h