diff --git a/fes.js b/fes.js index e7b1272..4c5d4bb 100644 --- a/fes.js +++ b/fes.js @@ -20,9 +20,10 @@ 5: 'data/gym.csv' }; -// DOM -const img = document.getElementById('mapImage'); +/* ---------------- DOM ---------------- */ +const mapImage = document.getElementById('mapImage'); const map = document.getElementById('map'); +const pinsContainer = document.getElementById('pins'); const tabButtons = document.querySelectorAll('.tab-button'); const listToggle = document.getElementById('listToggle'); const listPanel = document.getElementById('listPanel'); @@ -30,68 +31,97 @@ const closeListBtn = document.getElementById('closeList'); let currentFloor = 1; -let currentData = []; -let pinEls = []; -let tooltipEls = []; +let currentData = []; // array of spots for current floor +let pinEls = []; // array of DOM pin elements +let tooltipEls = []; // array of tooltip els -/* ---------- CSV parser ---------- */ +/* ================= CSV パーサ(引用・CRLF対応の堅牢版) ================= */ function parseCSV(text) { const rows = []; - let cur = '', row = [], inQuotes = false; - for (let i=0;i0) { row.push(cur); rows.push(row); } - return rows.map(r => r.map(cell => cell.replace(/^\uFEFF/, '').trim())); + if (cur !== '' || row.length > 0) { + row.push(cur); + rows.push(row); + } + // trim BOM and whitespace on each cell + 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)=>{ - 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; + const headerMap = {}; + const norm = headers.map(h => (h || '').toLowerCase()); + norm.forEach((h, i) => { + if (['name','名前','title'].includes(h)) headerMap.name = i; + else if (['description','説明','comment','desc'].includes(h)) headerMap.description = i; + else if (['image','画像','画像ファイル','img','imagefile'].includes(h)) headerMap.image = i; + else if (['x','x座標','横','left'].includes(h)) headerMap.x = i; + else if (['y','y座標','縦','top'].includes(h)) headerMap.y = i; + else if (['url','link','リンク','ウェブ','website'].includes(h)) headerMap.url = i; }); - const fallback = ['name','description','image','x','y']; - fallback.forEach((k,idx)=>{ if (map[k]===undefined && headers[idx] !== undefined) map[k]=idx; }); - return map; + // fallback by order if some fields missing + const fallback = ['name','description','image','x','y','url']; + fallback.forEach((k, idx) => { + if (headerMap[k] === undefined && headers[idx] !== undefined) headerMap[k] = idx; + }); + return headerMap; } + +/* 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 cell && cell.trim() !== '')) continue; - const obj = { - name: (r[map.name]||'').trim(), - description: (r[map.description]||'').trim(), - image: (r[map.image]||'').trim(), + if (!r.some(cell => cell && cell.trim() !== '')) continue; // skip blank rows + data.push({ + 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 - }; - data.push(obj); + y: parseFloat(r[map.y]) || 0, + url: (r[map.url] || '').trim() + }); } return data; } + +/* CSV読み込み */ async function loadCSV(url) { try { - const res = await fetch(url, {cache:'no-cache'}); + 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); @@ -101,327 +131,333 @@ } } -/* ---------- Helpers ---------- */ +/* 画像ロード待ちユーティリティ */ 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); }); } -function tick() { return new Promise(resolve => requestAnimationFrame(resolve)); } -/* get current scale from map transform */ -function getCurrentScaleFromTransform() { - const st = window.getComputedStyle(map).transform; - if (!st || st === 'none') return 1; - 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; -} - -/* toggle wrapper touch-action: multi-touch or zoomed -> none (map handles gestures), - otherwise pan-y (page scroll allowed) */ -function updateWrapperTouchAction() { - const scale = getCurrentScaleFromTransform(); - if (activePointers.size > 1 || scale > 1.001) { - wrapper.style.touchAction = 'none'; - } else { - wrapper.style.touchAction = 'pan-y'; - } -} - -/* adjust tooltip so it doesn't overflow viewport (calculations in viewport, apply in map-relative px) */ -function adjustTooltipPosition(tooltip, desiredLeftPxMap, desiredTopPxMap) { +/* ================= UI: ツールチップ位置調整 ================= */ +/* + tooltip は map 内で absolute 位置(px)で配置する。 + desiredLeftMapPx / desiredTopMapPx は map.getBoundingClientRect() を基準にした px。 + adjustTooltipPosition はビューポートに対するはみ出しを考慮して左右補正・上下反転を行い、 + 最終的に map 内でのピクセル位置 (left/top) をセットします。 +*/ +function adjustTooltipPosition(tooltipEl, desiredLeftMapPx, desiredTopMapPx) { const mapRect = map.getBoundingClientRect(); - const desiredLeftViewport = mapRect.left + desiredLeftPxMap; - const desiredTopViewport = mapRect.top + desiredTopPxMap; + const desiredLeftViewport = mapRect.left + desiredLeftMapPx; + const desiredTopViewport = mapRect.top + desiredTopMapPx; - tooltip.style.display = 'block'; - tooltip.style.visibility = 'hidden'; - tooltip.style.transform = 'translate(-50%, -120%)'; + // temporarily show hidden (invisible) to measure + tooltipEl.style.display = 'block'; + tooltipEl.style.visibility = 'hidden'; + tooltipEl.style.transform = 'translate(-50%, -120%)'; - const tRect = tooltip.getBoundingClientRect(); + const tRect = tooltipEl.getBoundingClientRect(); const margin = 8; + // horizontal shift in viewport coordinates 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 check + let flipped = false; let newTopViewport = desiredTopViewport; if (tRect.top < margin) { - tooltip.style.transform = 'translate(-50%, 8px)'; - newTopViewport = desiredTopViewport + 44 + 10; + flipped = true; + tooltipEl.style.transform = 'translate(-50%, 8px)'; // show below pin + newTopViewport = desiredTopViewport + 44 + 10; // pin height + gap } else { - tooltip.style.transform = 'translate(-50%, -120%)'; + tooltipEl.style.transform = 'translate(-50%, -120%)'; } + // convert viewport shift back to map-relative px const shiftXMap = shiftXViewport; - const newLeftMap = desiredLeftPxMap + shiftXMap; + const newLeftMap = desiredLeftMapPx + shiftXMap; const newTopMap = newTopViewport - mapRect.top; - tooltip.style.left = newLeftMap + 'px'; - tooltip.style.top = newTopMap + 'px'; - tooltip.style.visibility = 'visible'; + // apply + tooltipEl.style.left = `${newLeftMap}px`; + tooltipEl.style.top = `${newTopMap}px`; + tooltipEl.style.visibility = 'visible'; } /* close all tooltips */ function closeAllTooltips() { tooltipEls.forEach(t => { - if (t) { t.style.display = 'none'; t.setAttribute('aria-hidden','true'); } + if (t) { t.style.display = 'none'; t.setAttribute('aria-hidden', 'true'); } }); } -/* ---------- Pin & Tooltip rendering ---------- */ +/* ================= ピン設置 ================= */ +/* data: array of spot objects */ function placePins(data) { + // clear old pinEls.forEach(p => p.remove()); tooltipEls.forEach(t => t.remove()); - pinEls = []; tooltipEls = []; + pinEls = []; + tooltipEls = []; - // base image size measured when scale == 1 - const width = baseImageWidth || img.getBoundingClientRect().width; - const height = baseImageHeight || img.getBoundingClientRect().height; - + // for each spot create pin element data.forEach((d, idx) => { - const xPx = d.x * width; - const yPx = d.y * height; - + // pin element (position using percent so responsive) const pin = document.createElement('div'); pin.className = 'pin'; - pin.setAttribute('role','button'); + pin.setAttribute('role', 'button'); pin.setAttribute('aria-label', d.name || `スポット ${idx+1}`); - pin.setAttribute('tabindex','0'); - pin.dataset.index = String(idx); - pin.style.left = xPx + 'px'; - pin.style.top = yPx + 'px'; + pin.dataset.index = idx; + // percent position relative to map container + pin.style.left = `${(d.x * 100).toFixed(4)}%`; + pin.style.top = `${(d.y * 100).toFixed(4)}%`; const pinImg = document.createElement('img'); pinImg.src = 'images/pin.png'; pinImg.alt = ''; pin.appendChild(pinImg); - 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 (card) const tooltip = document.createElement('div'); tooltip.className = 'tooltip'; - tooltip.dataset.index = String(idx); - tooltip.setAttribute('aria-hidden','true'); + tooltip.dataset.index = 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'; 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 card = document.createElement('div'); + card.className = 'tooltip-card'; - body.appendChild(thumb); body.appendChild(p); - card.appendChild(closeBtn); card.appendChild(header); card.appendChild(body); + 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'; + + // thumbnail image + const thumb = document.createElement('img'); + thumb.className = 'tooltip-thumb'; + // support relative path or absolute; if CSV image empty, use placeholder + thumb.src = d.image ? d.image : 'images/placeholder.png'; + thumb.alt = d.name || ''; + + const descP = document.createElement('p'); + descP.textContent = d.description || ''; + + body.appendChild(thumb); + body.appendChild(descP); + + // optional link + if (d.url) { + const a = document.createElement('a'); + a.href = d.url; + a.textContent = '公式ページを見る'; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.className = 'link'; + body.appendChild(a); + } + + card.appendChild(closeBtn); + card.appendChild(header); + card.appendChild(body); tooltip.appendChild(card); - tooltip.style.left = xPx + 'px'; - tooltip.style.top = yPx + 'px'; + // initial placement (will be adjusted on open) + tooltip.style.left = `0px`; + tooltip.style.top = `0px`; tooltip.style.display = 'none'; + // events pin.addEventListener('click', (e) => { e.stopPropagation(); const isVisible = tooltip.style.display === 'block'; closeAllTooltips(); if (!isVisible) { + // compute pixel coords relative to map + const mapRect = map.getBoundingClientRect(); + const desiredLeftMapPx = d.x * mapRect.width; + const desiredTopMapPx = d.y * mapRect.height; tooltip.style.display = 'block'; - adjustTooltipPosition(tooltip, xPx, yPx); - tooltip.setAttribute('aria-hidden','false'); + adjustTooltipPosition(tooltip, desiredLeftMapPx, desiredTopMapPx); + tooltip.setAttribute('aria-hidden', 'false'); } else { tooltip.style.display = 'none'; - tooltip.setAttribute('aria-hidden','true'); + tooltip.setAttribute('aria-hidden', 'true'); + } + }); + + // keyboard support + pin.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + pin.click(); } }); closeBtn.addEventListener('click', (ev) => { ev.stopPropagation(); tooltip.style.display = 'none'; - tooltip.setAttribute('aria-hidden','true'); + tooltip.setAttribute('aria-hidden', 'true'); }); + // stop propagation for tooltip so clicking inside doesn't close it tooltip.addEventListener('pointerdown', ev => ev.stopPropagation()); tooltip.addEventListener('touchstart', ev => ev.stopPropagation(), {passive:true}); - map.appendChild(pin); - map.appendChild(tooltip); + // append to map (pin then tooltip so tooltip overlays) + pinsContainer.appendChild(pin); + pinsContainer.appendChild(tooltip); pinEls.push(pin); tooltipEls.push(tooltip); }); + // render list panel renderListPanel(data); } -/* ---------- List panel rendering ---------- */ +/* ================= 一覧パネル ================= */ function renderListPanel(data) { listContent.innerHTML = ''; data.forEach((d, idx) => { - const item = document.createElement('div'); - item.className = 'shop-item'; - item.dataset.index = String(idx); + const row = document.createElement('div'); + row.className = 'shop-item'; + row.dataset.index = 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'; + 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); + row.appendChild(thumb); + row.appendChild(meta); - item.addEventListener('click', () => { + // click => open tooltip and close list + row.addEventListener('click', () => { openTooltipByIndex(idx); closeList(); }); - listContent.appendChild(item); + listContent.appendChild(row); }); } + function openTooltipByIndex(index) { if (!pinEls[index]) return; - // scroll page so map top visible - const topY = Math.max(0, map.getBoundingClientRect().top + window.scrollY - 80); - window.scrollTo({ top: topY, behavior: 'smooth' }); - pinEls[index].click(); + // scroll map into view first + const mapTop = map.getBoundingClientRect().top + window.scrollY; + window.scrollTo({ top: Math.max(0, mapTop - 80), behavior: 'smooth' }); + + // open tooltip (simulate click) + setTimeout(() => pinEls[index].click(), 300); } -/* ---------- load floor (reset panzoom, measure base image size, load CSV, place pins) ---------- */ +/* ================= フロア切替 ================= */ async function loadFloor(floor) { - if (currentFloor === floor) return; + currentFloor = Number(floor); + + // update active tab UI 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 === currentFloor) { + btn.classList.add('active'); + btn.setAttribute('aria-selected', 'true'); + } else { + btn.classList.remove('active'); + btn.setAttribute('aria-selected', 'false'); + } }); - currentFloor = floor; - img.src = IMAGE_PATH[floor]; - await waitImageLoad(img); + // set image + const imgSrc = IMAGE_PATH[currentFloor] || IMAGE_PATH[1]; + mapImage.src = imgSrc; - // reset pan to identity so base image size is measured reliably - 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; + // wait image load (so map size known) + await waitImageLoad(mapImage); - const data = await loadCSV(CSV_PATH[floor]); + // load CSV and place pins + const csvPath = CSV_PATH[currentFloor] || CSV_PATH[1]; + const data = await loadCSV(csvPath); currentData = data; placePins(data); - - updateWrapperTouchAction(); } -/* ---------- list 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(); } +/* ================= UI: list open/close & global handlers ================= */ +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(); +} -/* ---------- init ---------- */ -async function init() { - // Panzoom on map - panzoomInstance = Panzoom(map, { - maxScale: 4, - minScale: 1, - step: 0.3, - contain: 'outside' - }); - - // wheel zoom using wrapper (so page scroll possible when not zooming) - wrapper.addEventListener('wheel', (ev) => { - panzoomInstance.zoomWithWheel(ev); - requestAnimationFrame(updateWrapperTouchAction); - }, { passive: false }); - - // pointer tracking (to detect 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 scheduling - let rafId = null; - function scheduleUpdate() { - if (rafId) return; - rafId = requestAnimationFrame(() => { rafId = null; updateWrapperTouchAction(); }); +/* click outside closes tooltips */ +document.addEventListener('pointerdown', (ev) => { + const t = ev.target; + if (t.closest('.pin') || t.closest('.tooltip') || t.closest('.list-panel') || t.closest('.list-toggle')) { + return; } - map.addEventListener('pointermove', scheduleUpdate, { passive: true }); - map.addEventListener('pointerup', scheduleUpdate); - map.addEventListener('pointercancel', scheduleUpdate); + closeAllTooltips(); +}); - // tabs - tabButtons.forEach(btn => btn.addEventListener('click', () => { - const floor = Number(btn.dataset.floor); - if (!Number.isNaN(floor)) loadFloor(floor); - })); - - // global pointerdown closes tooltips (unless inside interactive areas) - document.addEventListener('pointerdown', (ev) => { - const t = ev.target; - if (t.closest('.pin') || t.closest('.tooltip') || t.closest('.list-panel') || t.closest('.list-toggle')) return; +/* ================= resize / orientation handlers ================= */ +/* On resize, because pins use percent positions, they follow automatically. + But if a tooltip was open, recalc its position (lazy approach: close all tooltips on resize). +*/ +let resizeTimer = null; +window.addEventListener('resize', () => { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + // close tooltips to avoid them being misplaced on layout change closeAllTooltips(); + }, 180); +}); + +/* ================= initialization ================= */ +async function init() { + // tab buttons + tabButtons.forEach(btn => { + btn.addEventListener('click', () => { + const floor = Number(btn.dataset.floor); + loadFloor(floor); + }); }); - // list handlers + // list toggle listToggle.addEventListener('click', openList); closeListBtn.addEventListener('click', closeList); - // resize: reset pan, remeasure base size and re-place pins - let resizeTimer = null; - window.addEventListener('resize', () => { - if (resizeTimer) clearTimeout(resizeTimer); - resizeTimer = setTimeout(async () => { - 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); - }); - - // initial load (1F) - img.src = IMAGE_PATH[1]; - await waitImageLoad(img); - - 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); - - updateWrapperTouchAction(); + // initial load + await loadFloor(currentFloor || 1); } +// run document.addEventListener('DOMContentLoaded', init);