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