fesmap / pj
forked from Ray8/pj
Newer
Older
pj / fes.js
/* 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.png';
    pinImg.alt = '';
    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/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);

    // 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/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);
    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);