diff --git a/fes.js b/fes.js new file mode 100644 index 0000000..2bf900b --- /dev/null +++ b/fes.js @@ -0,0 +1,459 @@ +/* script.js + - スマホ優先で作り込んだマップロジック + - CSVは data/floor1.csv / data/floor2.csv を想定 + - 期待されるCSVヘッダ(日本語または英語のどちらでも対応) + 例: + 名前,説明,画像ファイル,X座標,Y座標 + または + name,description,image,x,y +*/ + +'use strict'; + +const IMAGE_PATH = { + 1: 'images/floor1.png', + 2: 'images/floor2.png' +}; +const CSV_PATH = { + 1: 'data/floor1.csv', + 2: 'data/floor2.csv' +}; + +// DOM +const img = document.getElementById('mapImage'); +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 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 + +/* ========== CSV パーサ(簡易だが引用対応) ========== */ +function parseCSV(text) { + // 正しいCSVパーシング:引用符内のカンマ、改行を考慮 + const rows = []; + let cur = ''; + let row = []; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + + if (ch === '"') { + // 連続する二重引用符はエスケープされた引用符 + if (inQuotes && text[i+1] === '"') { + cur += '"'; + i++; // skip next + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (ch === ',' && !inQuotes) { + row.push(cur); + cur = ''; + continue; + } + + if ((ch === '\n' || ch === '\r') && !inQuotes) { + // handle CRLF + if (ch === '\r' && text[i+1] === '\n') i++; + row.push(cur); + rows.push(row); + row = []; + cur = ''; + continue; + } + + cur += ch; + } + + // push remainder + if (cur !== '' || row.length > 0) { + row.push(cur); + rows.push(row); + } + + // trim rows (remove possible starting BOM etc.) + 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; + }); + + // 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; + }); + + 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 headers = rows[0]; + const map = mapHeaders(headers); + const data = []; + + for (let i = 1; i < rows.length; i++) { + const r = rows[i]; + // skip empty-line-like rows + if (!r.some(cell => cell && cell.trim() !== '')) continue; + + const obj = { + 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); + } + 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 text = await res.text(); + return csvToObjects(text); + } catch (err) { + console.error(err); + return []; + } +} + +/* 画像ロードを待つヘルパー */ +function waitImageLoad(imgEl) { + return new Promise(resolve => { + if (imgEl.complete && imgEl.naturalWidth !== 0) return 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'; + tooltip.style.display = 'block'; + tooltip.style.visibility = 'hidden'; + + // 必要な計測 + 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; + + // 縦方向:上側に収まらない場合はピンの下に出す + let flipped = false; + let newTop = desiredTopPx; + const topMargin = 8; + if (tRect.top < topMargin) { + // put below the pin + flipped = true; + newTop = desiredTopPx + (40 + 10); // pin 高さ + 余白 + tooltip.style.transform = 'translate(-50%, 8px)'; + } 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'; + } + + tooltip.style.top = newTop + 'px'; + tooltip.style.visibility = 'visible'; +} + +/* ページ内の全吹き出しを閉じる */ +function closeAllTooltips() { + tooltipEls.forEach(t => { if (t) t.style.display = 'none'; }); +} + +/* ピン・吹き出しを(CSVのデータから)描画する */ +function placePins(data) { + // 既存削除 + pinEls.forEach(p => p.remove()); + tooltipEls.forEach(t => t.remove()); + pinEls = []; + tooltipEls = []; + + 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 element --- + const pin = document.createElement('div'); + pin.className = 'pin'; + 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'; + + const pinImg = document.createElement('img'); + pinImg.src = 'images/pin.png'; + pinImg.alt = ''; + pin.appendChild(pinImg); + + // keyboard accessibility (Enter / Space to open) + pin.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); pin.click(); } + }); + + // --- Tooltip (カード) --- + const tooltip = document.createElement('div'); + tooltip.className = 'tooltip'; + tooltip.setAttribute('aria-hidden','true'); + tooltip.dataset.index = String(idx); + + // build card DOM (safe - no innerHTML injection) + 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); + tooltip.appendChild(card); + + // initial positioning + tooltip.style.left = xPx + '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 + + // イベント + pin.addEventListener('click', (e) => { + e.stopPropagation(); + const isVisible = tooltip.style.display === 'block'; + closeAllTooltips(); + if (!isVisible) { + tooltip.style.display = 'block'; + adjustTooltipPosition(tooltip, xPx, yPx); + tooltip.setAttribute('aria-hidden','false'); + } else { + tooltip.style.display = 'none'; + tooltip.setAttribute('aria-hidden','true'); + } + }); + + closeBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + tooltip.style.display = 'none'; + tooltip.setAttribute('aria-hidden','true'); + }); + + // tooltip自体をタップしても閉じないように伝播停止 + tooltip.addEventListener('pointerdown', (ev) => ev.stopPropagation()); + + // add to DOM (order: pin below tooltip so tooltip overlays) + map.appendChild(pin); + map.appendChild(tooltip); + + pinEls.push(pin); + tooltipEls.push(tooltip); + }); + + // update list panel + renderListPanel(data); +} + +/* 一覧パネルに項目を描画 */ +function renderListPanel(data) { + listContent.innerHTML = ''; // clear + data.forEach((d, idx) => { + const item = document.createElement('div'); + item.className = 'shop-item'; + item.dataset.index = String(idx); + + 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 || ''; + + meta.appendChild(title); + meta.appendChild(desc); + + item.appendChild(thumb); + item.appendChild(meta); + + item.addEventListener('click', () => { + // 列挙から選んだらツールチップを表示 + openTooltipByIndex(idx); + closeList(); // 一覧閉じる + }); + + listContent.appendChild(item); + }); +} + +/* 指定インデックスのピンの吹き出しを開く */ +function openTooltipByIndex(index) { + // ensure current floor pins exist + if (!pinEls[index]) return; + // simulate click (or ensure tooltip shown) + pinEls[index].click(); + // スクロールして map が見えるようにする + const topY = Math.max(0, map.getBoundingClientRect().top + window.scrollY - 80); + window.scrollTo({ top: topY, behavior: 'smooth' }); +} + +/* タブ切替でフロア読み込み */ +async function loadFloor(floor) { + if (currentFloor === floor) return; + // active class + 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'); + } + }); + + currentFloor = floor; + // change image + img.src = IMAGE_PATH[floor]; + await waitImageLoad(img); + // load csv + const data = await loadCSV(CSV_PATH[floor]); + currentData = data; + placePins(data); +} + +/* 初期読み込み(1階) */ +async function init() { + // tab listeners + tabButtons.forEach(btn => { + btn.addEventListener('click', () => { + const floor = Number(btn.dataset.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')) { + // pin/tooltip/list 内は閉じない + return; + } + closeAllTooltips(); + }); + + // 一覧パネル操作 + listToggle.addEventListener('click', () => { + openList(); + }); + closeListBtn.addEventListener('click', closeList); + + // resize/rotate 対応(デバウンス) + let resizeTimer = null; + window.addEventListener('resize', () => { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + if (currentData && currentData.length) placePins(currentData); + }, 180); + }); + + // 最初は1階読み込み + img.src = IMAGE_PATH[1]; + await waitImageLoad(img); + currentData = await loadCSV(CSV_PATH[1]); + placePins(currentData); +} + +function openList() { + listPanel.classList.add('open'); + listPanel.setAttribute('aria-hidden','false'); + listToggle.setAttribute('aria-expanded','true'); + // focus to list content for accessibility + setTimeout(() => listContent.focus(), 160); +} +function closeList() { + listPanel.classList.remove('open'); + listPanel.setAttribute('aria-hidden','true'); + listToggle.setAttribute('aria-expanded','false'); + listToggle.focus(); +} + +// kick off +document.addEventListener('DOMContentLoaded', init); diff --git a/pai.js b/pai.js deleted file mode 100644 index 098444e..0000000 --- a/pai.js +++ /dev/null @@ -1,66 +0,0 @@ -const map = document.getElementById('map'); -const img = document.getElementById('mapImage'); - -// CSV読み込み関数 -async function loadCSV(url) { - const response = await fetch(url); - const text = await response.text(); - const rows = text.trim().split("\n").map(r => r.split(",")); - const headers = rows.shift(); - return rows.map(r => Object.fromEntries(r.map((v,i) => [headers[i], v]))); -} - -// ピンを配置する関数 -function placePins(pins) { - document.querySelectorAll('.pin, .tooltip').forEach(e => e.remove()); - - const rect = img.getBoundingClientRect(); - const width = rect.width; - const height = rect.height; - - pins.forEach(pos => { - const x = parseFloat(pos.x); - const y = parseFloat(pos.y); - - // ピン本体 - const pin = document.createElement('div'); - pin.className = 'pin'; - pin.style.left = (x * width) + 'px'; - pin.style.top = (y * height) + 'px'; - - const iconImg = document.createElement('img'); - iconImg.src = pos.icon; - pin.appendChild(iconImg); - - // 吹き出し - const tooltip = document.createElement('div'); - tooltip.className = 'tooltip'; - tooltip.style.left = (x * width) + 'px'; - tooltip.style.top = (y * height) + 'px'; - tooltip.innerHTML = ` - ${pos.title}
- ${pos.description}
- ${pos.title} - 公式サイトへ - `; - - // クリックで表示切替 - pin.addEventListener('click', () => { - const isVisible = tooltip.style.display === 'block'; - document.querySelectorAll('.tooltip').forEach(t => t.style.display = 'none'); - tooltip.style.display = isVisible ? 'none' : 'block'; - }); - - map.appendChild(pin); - map.appendChild(tooltip); - }); -} - -// 初期化 -async function init() { - const pins = await loadCSV('spots.csv'); - placePins(pins); - window.addEventListener('resize', () => placePins(pins)); -} - -img.addEventListener('load', init);