diff --git a/fes.js b/fes.js index 5d3d1e4..56df3f3 100644 --- a/fes.js +++ b/fes.js @@ -1,23 +1,13 @@ /* script.js - - スマホ優先で作り込んだマップロジック - - CSVは data/floor1.csv / data/floor2.csv を想定 - - 期待されるCSVヘッダ(日本語または英語のどちらでも対応) - 例: - 名前,説明,画像ファイル,X座標,Y座標 - または - name,description,image,x,y + - スマホ優先で作り込んだマップロジック + Panzoom対応 + - CSV: data/floor1.csv / data/floor2.csv を想定 + - CSVヘッダ(日本語 or 英語): 名前,name / 説明,description / 画像,image / X座標,x / Y座標,y */ 'use strict'; -const IMAGE_PATH = { - 1: 'images/1F.png', - 2: 'images/floor2.png' -}; -const CSV_PATH = { - 1: 'data/floor1.csv', - 2: 'data/floor2.csv' -}; +const IMAGE_PATH = { 1: 'images/1F.png', 2: 'images/floor2.png' }; +const CSV_PATH = { 1: 'data/floor1.csv', 2: 'data/floor2.csv' }; // DOM const img = document.getElementById('mapImage'); @@ -29,104 +19,59 @@ const closeListBtn = document.getElementById('closeList'); let currentFloor = 1; -let currentData = []; // parsed CSV objects for current floor -let pinEls = []; // DOM elements for pins -let tooltipEls = []; // DOM elements for tooltips - -// Panzoomインスタンス保持 +let currentData = []; +let pinEls = []; +let tooltipEls = []; let panzoomInstance = null; -/* ========== CSV パーサ(簡易だが引用対応) ========== */ +/* ================= CSV パーサ ================= */ function parseCSV(text) { - // 正しいCSVパーシング:引用符内のカンマ、改行を考慮 const rows = []; - let cur = ''; - let row = []; - let inQuotes = false; - - for (let i = 0; i < text.length; i++) { + let cur = '', row = [], inQuotes = false; + for (let i=0;i 0) { - row.push(cur); - rows.push(row); - } - - // trim rows (remove possible starting BOM etc.) + if (cur !== '' || row.length>0) { row.push(cur); rows.push(row); } return rows.map(r => r.map(cell => cell.replace(/^\uFEFF/, '').trim())); } - -/* ヘッダをキーにマッピング(日本語/英語混在を許容) */ function mapHeaders(headers) { const map = {}; const norm = headers.map(h => (h||'').toLowerCase()); - - norm.forEach((h, i) => { + norm.forEach((h,i)=>{ if (['name','名前','title'].includes(h)) map.name = i; else if (['description','説明','comment'].includes(h)) map.description = i; else if (['image','画像','画像ファイル','img','imagefile'].includes(h)) map.image = i; else if (['x','x座標','横','left'].includes(h)) map.x = i; else if (['y','y座標','縦','top'].includes(h)) map.y = i; }); - - // fallback: position by order if some keys missing const fallback = ['name','description','image','x','y']; - fallback.forEach((k, idx) => { - if (map[k] === undefined && headers[idx] !== undefined) map[k] = idx; - }); - + fallback.forEach((k,idx)=>{ if (map[k]===undefined && headers[idx] !== undefined) map[k]=idx; }); return map; } - -/* CSVをオブジェクト配列に */ function csvToObjects(text) { - const rows = parseCSV(text).filter(r => r.length > 0 && !(r.length === 1 && r[0] === '')); - if (rows.length === 0) return []; - + const rows = parseCSV(text).filter(r => r.length>0 && !(r.length===1 && r[0]==='')); + if (rows.length===0) return []; const headers = rows[0]; const map = mapHeaders(headers); const data = []; - - for (let i = 1; i < rows.length; i++) { + for (let i=1;i cell && cell.trim() !== '')) continue; - const obj = { - name: (r[map.name] || '').trim(), - description: (r[map.description] || '').trim(), - image: (r[map.image] || '').trim(), + name: (r[map.name]||'').trim(), + description: (r[map.description]||'').trim(), + image: (r[map.image]||'').trim(), x: parseFloat(r[map.x]) || 0, y: parseFloat(r[map.y]) || 0 }; @@ -134,12 +79,10 @@ } return data; } - -/* fetch CSV(エラーハンドリングを含む) */ async function loadCSV(url) { try { - const res = await fetch(url, {cache: "no-cache"}); - if (!res.ok) throw new Error('CSVの読み込みに失敗しました: ' + res.status); + const res = await fetch(url, {cache:'no-cache'}); + if (!res.ok) throw new Error('CSV読み込み失敗: ' + res.status); const text = await res.text(); return csvToObjects(text); } catch (err) { @@ -148,70 +91,72 @@ } } -/* 画像ロードを待つヘルパー */ +/* 画像ロード待ち */ function waitImageLoad(imgEl) { return new Promise(resolve => { if (imgEl.complete && imgEl.naturalWidth !== 0) return resolve(); - const onLoad = () => { - imgEl.removeEventListener('load', onLoad); - resolve(); - }; + const onLoad = () => { imgEl.removeEventListener('load', onLoad); resolve(); }; imgEl.addEventListener('load', onLoad); }); } -/* tooltip の位置を調整して画面内に収める */ -function adjustTooltipPosition(tooltip, desiredLeftPx, desiredTopPx) { - // 初期位置をセットして、視覚的に非表示で計測 - tooltip.style.left = desiredLeftPx + 'px'; - tooltip.style.top = desiredTopPx + 'px'; +/* ツールチップ位置調整(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; - let shiftX = 0; - if (tRect.left < margin) shiftX = margin - tRect.left; - if (tRect.right > (window.innerWidth - margin)) shiftX = (window.innerWidth - margin) - tRect.right; - // 縦方向:上側に収まらない場合はピンの下に出す + // 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 flip if top would go above viewport margin let flipped = false; - let newTop = desiredTopPx; - const topMargin = 8; - if (tRect.top < topMargin) { - // put below the pin + let newTopViewport = desiredTopViewport; + if (tRect.top < margin) { flipped = true; - newTop = desiredTopPx + (40 + 10); // pin 高さ + 余白 - tooltip.style.transform = 'translate(-50%, 8px)'; + 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%)'; } - // apply horizontal shift (convert viewport shift to map-relative px) - if (shiftX !== 0) { - // 現在leftは相対マップだが shiftX はビュー基準 -> 変換不要(leftは計算通りに補正して良い) - const prevLeft = parseFloat(tooltip.style.left || 0); - tooltip.style.left = (prevLeft + shiftX) + 'px'; - } + // 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; - tooltip.style.top = newTop + 'px'; + // apply + tooltip.style.left = newLeftMap + 'px'; + tooltip.style.top = newTopMap + 'px'; tooltip.style.visibility = 'visible'; } -/* ページ内の全吹き出しを閉じる */ +/* 閉じる */ function closeAllTooltips() { - tooltipEls.forEach(t => { if (t) t.style.display = 'none'; }); + tooltipEls.forEach(t => { if (t) { t.style.display = 'none'; t.setAttribute('aria-hidden','true'); } }); } -/* ピン・吹き出しを(CSVのデータから)描画する */ +/* ピン配置(data は csvToObjects の配列) */ function placePins(data) { - // 既存削除 + // remove existing pinEls.forEach(p => p.remove()); tooltipEls.forEach(t => t.remove()); - pinEls = []; - tooltipEls = []; + pinEls = []; tooltipEls = []; + // img の表示サイズ取得(縦長で height 基準) const rect = img.getBoundingClientRect(); const width = rect.width; const height = rect.height; @@ -220,7 +165,7 @@ const xPx = d.x * width; const yPx = d.y * height; - // --- Pin element --- + // Pin const pin = document.createElement('div'); pin.className = 'pin'; pin.setAttribute('role','button'); @@ -228,25 +173,27 @@ pin.setAttribute('tabindex','0'); pin.dataset.index = String(idx); pin.style.left = xPx + 'px'; - pin.style.top = yPx + 'px'; + pin.style.top = yPx + 'px'; const pinImg = document.createElement('img'); pinImg.src = 'images/pin.png'; pinImg.alt = ''; pin.appendChild(pinImg); - // keyboard accessibility (Enter / Space to open) + // 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}); + pin.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); pin.click(); } }); - // --- Tooltip (カード) --- + // Tooltip (card) const tooltip = document.createElement('div'); tooltip.className = 'tooltip'; - tooltip.setAttribute('aria-hidden','true'); tooltip.dataset.index = String(idx); + tooltip.setAttribute('aria-hidden','true'); - // build card DOM (safe - no innerHTML injection) const card = document.createElement('div'); card.className = 'tooltip-card'; @@ -265,7 +212,6 @@ const thumb = document.createElement('img'); thumb.className = 'tooltip-thumb'; - // 画像が指定されていない場合は透明なプレースホルダにする(または非表示) thumb.src = d.image ? d.image : 'images/placeholder.png'; thumb.alt = d.name || ''; @@ -281,14 +227,12 @@ card.appendChild(body); tooltip.appendChild(card); - // initial positioning + // initial hidden position (map-relative) tooltip.style.left = xPx + 'px'; - tooltip.style.top = yPx + 'px'; + tooltip.style.top = yPx + 'px'; tooltip.style.display = 'none'; - tooltip.style.visibility = 'visible'; // visible for measurement in adjustTooltipPosition - tooltip.style.display = 'none'; // hide initially - // イベント + // events pin.addEventListener('click', (e) => { e.stopPropagation(); const isVisible = tooltip.style.display === 'block'; @@ -309,24 +253,23 @@ tooltip.setAttribute('aria-hidden','true'); }); - // tooltip自体をタップしても閉じないように伝播停止 - tooltip.addEventListener('pointerdown', (ev) => ev.stopPropagation()); + // prevent tooltip pointer events from panning map (stop propagation) + tooltip.addEventListener('pointerdown', ev => ev.stopPropagation()); + tooltip.addEventListener('touchstart', ev => ev.stopPropagation(), {passive:true}); - // add to DOM (order: pin below tooltip so tooltip overlays) + // append map.appendChild(pin); map.appendChild(tooltip); - pinEls.push(pin); tooltipEls.push(tooltip); }); - // update list panel renderListPanel(data); } -/* 一覧パネルに項目を描画 */ +/* 一覧パネルに描画 */ function renderListPanel(data) { - listContent.innerHTML = ''; // clear + listContent.innerHTML = ''; data.forEach((d, idx) => { const item = document.createElement('div'); item.className = 'shop-item'; @@ -355,39 +298,32 @@ item.appendChild(meta); item.addEventListener('click', () => { - // 列挙から選んだらツールチップを表示 openTooltipByIndex(idx); - closeList(); // 一覧閉じる + closeList(); }); listContent.appendChild(item); }); } -/* 指定インデックスのピンの吹き出しを開く */ +/* 指定 index の tooltip を開く */ function openTooltipByIndex(index) { - // ensure current floor pins exist if (!pinEls[index]) return; - // simulate click (or ensure tooltip shown) - pinEls[index].click(); - // スクロールして map が見えるようにする + // 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(); } -/* タブ切替でフロア読み込み */ +/* フロア読み込み */ 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'); - } else { - btn.classList.remove('active'); - btn.setAttribute('aria-selected','false'); - } + if (f === floor) { btn.classList.add('active'); btn.setAttribute('aria-selected','true'); } + else { btn.classList.remove('active'); btn.setAttribute('aria-selected','false'); } }); currentFloor = floor; @@ -397,67 +333,72 @@ currentData = data; placePins(data); - // フロア切り替え時はズームをリセット - if (panzoomInstance) { + // reset zoom so new floor displays consistently + if (panzoomInstance && typeof panzoomInstance.reset === 'function') { panzoomInstance.reset(); } } -/* 初期読み込み(1階) */ +/* 一覧 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(); +} + +/* 初期化 */ async function init() { - // Panzoomの適用 + // Panzoom を map 要素に適用(map 内の img + pin を一緒に拡大縮小) panzoomInstance = Panzoom(map, { maxScale: 4, minScale: 1, - contain: 'outside', - step: 0.3 - }); - // ジェスチャー対応(スマホのピンチイン/アウト) - map.parentElement.addEventListener('wheel', panzoomInstance.zoomWithWheel); - map.parentElement.addEventListener('pointerdown', (e) => { - // 吹き出しクリック時はパン開始しない - if (e.target.closest('.tooltip') || e.target.closest('.pin')) return; + step: 0.3, + contain: 'outside' }); - // tab listeners + // wheel は親(map-wrapper)でキャプチャして zoomWithWheel を使う + const wrapper = map.parentElement; + wrapper.addEventListener('wheel', panzoomInstance.zoomWithWheel); + + // タブ tabButtons.forEach(btn => { btn.addEventListener('click', () => { const floor = Number(btn.dataset.floor); - if (!Number.isNaN(floor)) { - loadFloor(floor); - } + if (!Number.isNaN(floor)) loadFloor(floor); }); }); - // global pointerdown で外部タップ→吹き出しを閉じる + // グローバルタップでツールチップを閉じる 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(); }); - listToggle.addEventListener('click', () => { - openList(); - }); + // 一覧操作 + listToggle.addEventListener('click', openList); closeListBtn.addEventListener('click', closeList); + // resize -> 再配置(デバウンス) let resizeTimer = null; window.addEventListener('resize', () => { if (resizeTimer) clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - if (currentData && currentData.length) placePins(currentData); - }, 180); + resizeTimer = setTimeout(() => { if (currentData && currentData.length) placePins(currentData); }, 200); }); - // 最初は1階読み込み + // 最初に 1F を読み込み img.src = IMAGE_PATH[1]; await waitImageLoad(img); currentData = await loadCSV(CSV_PATH[1]); placePins(currentData); } -/* List関連関数 (openList, closeList) はそのまま */ - +// 起動 document.addEventListener('DOMContentLoaded', init);