/* script.js
- スマホ優先で作り込んだマップロジック
- CSVは data/floor1.csv / data/floor2.csv を想定
- 期待されるCSVヘッダ(日本語または英語のどちらでも対応)
例:
名前,説明,画像ファイル,X座標,Y座標
または
name,description,image,x,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'
};
// 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);