diff --git a/fes.js b/fes.js index 56df3f3..e694fe5 100644 --- a/fes.js +++ b/fes.js @@ -1,7 +1,6 @@ -/* script.js - - スマホ優先で作り込んだマップロジック + Panzoom対応 - - CSV: data/floor1.csv / data/floor2.csv を想定 - - CSVヘッダ(日本語 or 英語): 名前,name / 説明,description / 画像,image / X座標,x / Y座標,y +/* script.js — 完成版 + - CSV: data/floor1.csv / data/floor2.csv を想定 + - 期待ヘッダ: 名前 / 説明 / 画像ファイル / X座標 / Y座標 */ 'use strict'; @@ -11,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; @@ -24,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; @@ -100,72 +107,99 @@ }); } -/* ツールチップ位置調整(map 内の座標 -> ビューポートに合わせて補正) */ +/* ---------------- 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) { - // mapRect = map 要素のビューポート座標 const mapRect = map.getBoundingClientRect(); const desiredLeftViewport = mapRect.left + desiredLeftPxMap; const desiredTopViewport = mapRect.top + desiredTopPxMap; - // temporarily show (hidden) to measure size tooltip.style.display = 'block'; tooltip.style.visibility = 'hidden'; tooltip.style.transform = 'translate(-50%, -120%)'; - // measure tooltip in viewport coords const tRect = tooltip.getBoundingClientRect(); const margin = 8; - // horizontal shift (viewport) + // 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 flip if top would go above viewport margin - let flipped = false; + // vertical: if tooltip top would go above viewport, flip below pin let newTopViewport = desiredTopViewport; if (tRect.top < margin) { - flipped = true; - tooltip.style.transform = 'translate(-50%, 8px)'; // put below - // put new top slightly below pin (pin height ~44) + tooltip.style.transform = 'translate(-50%, 8px)'; // below pin newTopViewport = desiredTopViewport + 44 + 10; } else { tooltip.style.transform = 'translate(-50%, -120%)'; } - // convert shiftXViewport to map-relative px (mapRect left offset) - const shiftXMap = shiftXViewport; // because left in px is same units + // convert shiftXViewport -> map-relative px + const shiftXMap = shiftXViewport; const newLeftMap = desiredLeftPxMap + shiftXMap; const newTopMap = newTopViewport - mapRect.top; - // apply 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'); } + }); } -/* ピン配置(data は csvToObjects の配列) */ +/* ---------------- ピン描画(基準サイズ baseImageWidth/baseImageHeight を使う) ---------------- */ function placePins(data) { // remove existing pinEls.forEach(p => p.remove()); tooltipEls.forEach(t => t.remove()); pinEls = []; tooltipEls = []; - // img の表示サイズ取得(縦長で height 基準) - 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 + // pin const pin = document.createElement('div'); pin.className = 'pin'; pin.setAttribute('role','button'); @@ -180,7 +214,7 @@ pinImg.alt = ''; pin.appendChild(pinImg); - // stop propagation so Panzoom doesn't begin pan when tapping a pin + // stop propagation to avoid accidental pan start when tapping pin pin.addEventListener('pointerdown', e => e.stopPropagation()); pin.addEventListener('touchstart', e => e.stopPropagation(), {passive:true}); @@ -188,51 +222,32 @@ if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); pin.click(); } }); - // Tooltip (card) + // 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 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'; + 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 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 p = document.createElement('p'); p.className='desc'; p.textContent = d.description || ''; - 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); + body.appendChild(thumb); body.appendChild(p); + card.appendChild(closeBtn); card.appendChild(header); card.appendChild(body); tooltip.appendChild(card); - // initial hidden position (map-relative) + // initial hidden position relative to base coords tooltip.style.left = xPx + 'px'; tooltip.style.top = yPx + 'px'; tooltip.style.display = 'none'; - // events + // pin click toggles tooltip pin.addEventListener('click', (e) => { e.stopPropagation(); const isVisible = tooltip.style.display === 'block'; @@ -253,13 +268,14 @@ tooltip.setAttribute('aria-hidden','true'); }); - // prevent tooltip pointer events from panning map (stop propagation) + // prevent tooltip pointer events from causing map pan tooltip.addEventListener('pointerdown', ev => ev.stopPropagation()); tooltip.addEventListener('touchstart', ev => ev.stopPropagation(), {passive:true}); - // append + // append to map (pin below tooltip so tooltip overlays) map.appendChild(pin); map.appendChild(tooltip); + pinEls.push(pin); tooltipEls.push(tooltip); }); @@ -267,7 +283,7 @@ renderListPanel(data); } -/* 一覧パネルに描画 */ +/* ---------------- 一覧(ボトムシート)描画 ---------------- */ function renderListPanel(data) { listContent.innerHTML = ''; data.forEach((d, idx) => { @@ -275,27 +291,16 @@ 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 || ''; - 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 || ''; - - meta.appendChild(title); - meta.appendChild(desc); - - item.appendChild(thumb); - item.appendChild(meta); + meta.appendChild(title); meta.appendChild(desc); + item.appendChild(thumb); item.appendChild(meta); item.addEventListener('click', () => { openTooltipByIndex(idx); @@ -306,20 +311,22 @@ }); } -/* 指定 index の tooltip を開く */ +/* open tooltip by index (and scroll page so map visible) */ function openTooltipByIndex(index) { if (!pinEls[index]) return; - // ensure visible on screen: scroll to map + // ensure map top visible const topY = Math.max(0, map.getBoundingClientRect().top + window.scrollY - 80); window.scrollTo({ top: topY, behavior: 'smooth' }); - - // show tooltip 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'); } @@ -329,22 +336,32 @@ 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); - // reset zoom so new floor displays consistently - if (panzoomInstance && typeof panzoomInstance.reset === 'function') { - panzoomInstance.reset(); - } + // update touch-action state based on current transform + updateWrapperTouchAction(); } -/* 一覧 open/close */ +/* ---------------- 一覧の open/close ---------------- */ function openList() { listPanel.classList.add('open'); listPanel.setAttribute('aria-hidden','false'); listToggle.setAttribute('aria-expanded','true'); - setTimeout(() => listContent.focus(), 160); + setTimeout(()=> listContent.focus(), 160); } function closeList() { listPanel.classList.remove('open'); @@ -353,9 +370,14 @@ listToggle.focus(); } -/* 初期化 */ +/* small helper: wait one animation frame */ +function tick() { + return new Promise(resolve => requestAnimationFrame(resolve)); +} + +/* ---------------- 初期化 ---------------- */ async function init() { - // Panzoom を map 要素に適用(map 内の img + pin を一緒に拡大縮小) + // create Panzoom instance applied to the entire map element panzoomInstance = Panzoom(map, { maxScale: 4, minScale: 1, @@ -363,42 +385,95 @@ contain: 'outside' }); - // wheel は親(map-wrapper)でキャプチャして zoomWithWheel を使う - const wrapper = map.parentElement; - wrapper.addEventListener('wheel', panzoomInstance.zoomWithWheel); + // 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); + // ensure pan is reset and measure base + 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; + currentData = await loadCSV(CSV_PATH[1]); placePins(currentData); + + // final touch-action update + updateWrapperTouchAction(); } -// 起動 +// run document.addEventListener('DOMContentLoaded', init);