/* script.js — 中央揃え・5フロア対応 完成版
- CSV: data/floor1.csv ... data/floor4.csv / data/gym.csv
- CSVヘッダ(日本語 or 英語): 名前,name / 説明,description / 画像,image / X座標,x / Y座標,y
*/
'use strict';
const IMAGE_PATH = {
1: 'images/1floor.png',
2: 'images/2floor.png',
3: 'images/3floor.png',
4: 'images/4floor.png',
5: 'images/gym.png' // 体育館
};
const CSV_PATH = {
1: 'data/floor1.csv',
2: 'data/floor2.csv',
3: 'data/floor3.csv',
4: 'data/floor4.csv',
5: 'data/gym.csv'
};
/* ---------------- 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');
const listContent = document.getElementById('listContent');
const closeListBtn = document.getElementById('closeList');
let currentFloor = 1;
let currentData = []; // array of spots for current floor
let pinEls = []; // array of DOM pin elements
let tooltipEls = []; // array of tooltip els
/* ================= CSV パーサ(引用・CRLF対応の堅牢版) ================= */
function parseCSV(text) {
const rows = [];
let cur = '';
let row = [];
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '"') {
// escaped quote
if (inQuotes && text[i+1] === '"') {
cur += '"';
i++;
} else {
inQuotes = !inQuotes;
}
continue;
}
if (ch === ',' && !inQuotes) {
row.push(cur);
cur = '';
continue;
}
if ((ch === '\n' || ch === '\r') && !inQuotes) {
if (ch === '\r' && text[i+1] === '\n') i++;
row.push(cur);
rows.push(row);
row = [];
cur = '';
continue;
}
cur += ch;
}
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 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;
});
// 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 headers = rows[0];
const map = mapHeaders(headers);
const data = [];
for (let i = 1; i < rows.length; i++) {
const r = rows[i];
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,
url: (r[map.url] || '').trim()
});
}
return data;
}
/* 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);
});
}
/* ================= 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 + desiredLeftMapPx;
const desiredTopViewport = mapRect.top + desiredTopMapPx;
// temporarily show hidden (invisible) to measure
tooltipEl.style.display = 'block';
tooltipEl.style.visibility = 'hidden';
tooltipEl.style.transform = 'translate(-50%, -120%)';
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) {
flipped = true;
tooltipEl.style.transform = 'translate(-50%, 8px)'; // show below pin
newTopViewport = desiredTopViewport + 44 + 10; // pin height + gap
} else {
tooltipEl.style.transform = 'translate(-50%, -120%)';
}
// convert viewport shift back to map-relative px
const shiftXMap = shiftXViewport;
const newLeftMap = desiredLeftMapPx + shiftXMap;
const newTopMap = newTopViewport - mapRect.top;
// 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'); }
});
}
/* ================= ピン設置 ================= */
/* data: array of spot objects */
function placePins(data) {
// clear old
pinEls.forEach(p => p.remove());
tooltipEls.forEach(t => t.remove());
pinEls = [];
tooltipEls = [];
// for each spot create pin element
data.forEach((d, idx) => {
// pin element (position using percent so responsive)
const pin = document.createElement('div');
pin.className = 'pin';
pin.setAttribute('role', 'button');
pin.setAttribute('aria-label', d.name || `スポット ${idx+1}`);
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-512x512.png';
pinImg.alt = 'images/pin-512x512.png';
pin.appendChild(pinImg);
// tooltip (card)
const tooltip = document.createElement('div');
tooltip.className = 'tooltip';
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';
// 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/drink-16x16.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);
// 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, desiredLeftMapPx, desiredTopMapPx);
tooltip.setAttribute('aria-hidden', 'false');
} else {
tooltip.style.display = 'none';
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');
});
// 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});
// 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);
}
/* ================= 一覧パネル ================= */
function renderListPanel(data) {
listContent.innerHTML = '';
data.forEach((d, idx) => {
const row = document.createElement('div');
row.className = 'shop-item';
row.dataset.index = idx;
const thumb = document.createElement('img');
thumb.className = 'shop-thumb';
thumb.src = d.image ? d.image : 'images/drink-16x16.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);
row.appendChild(thumb);
row.appendChild(meta);
// click => open tooltip and close list
row.addEventListener('click', () => {
openTooltipByIndex(idx);
closeList();
});
listContent.appendChild(row);
});
}
function openTooltipByIndex(index) {
if (!pinEls[index]) return;
// 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);
}
/* ================= フロア切替 ================= */
async function loadFloor(floor) {
currentFloor = Number(floor);
// update active tab UI
tabButtons.forEach(btn => {
const f = Number(btn.dataset.floor);
if (f === currentFloor) {
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
} else {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
}
});
// set image
const imgSrc = IMAGE_PATH[currentFloor] || IMAGE_PATH[1];
mapImage.src = imgSrc;
// wait image load (so map size known)
await waitImageLoad(mapImage);
// load CSV and place pins
const csvPath = CSV_PATH[currentFloor] || CSV_PATH[1];
const data = await loadCSV(csvPath);
currentData = data;
placePins(data);
}
/* ================= 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();
}
/* 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;
}
closeAllTooltips();
});
/* ================= 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 toggle
listToggle.addEventListener('click', openList);
closeListBtn.addEventListener('click', closeList);
// initial load
await loadFloor(currentFloor || 1);
}
// run
document.addEventListener('DOMContentLoaded', init);