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}
-
- 公式サイトへ
- `;
-
- // クリックで表示切替
- 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);