fesmap / pj
forked from Ray8/pj
Newer
Older
pj / fes.js
/* script.js — 完成版
   - CSV: data/floor1.csv / data/floor2.csv を想定
   - 期待ヘッダ: 名前 / 説明 / 画像ファイル / 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');            // Panzoom 対象
const wrapper = map.parentElement;                    // touch-action 切替のため
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 = [];
let pinEls = [];
let tooltipEls = [];
let panzoomInstance = null;

// base image displayed size (measured when scale == 1)
let baseImageWidth = 0;
let baseImageHeight = 0;

// track active pointer ids (for deciding multi-touch)
const activePointers = new Set();

/* ---------------- CSV パーサ(引用対応) ---------------- */
function parseCSV(text) {
  const rows = [];
  let cur = '', row = [], inQuotes = false;
  for (let i=0;i<text.length;i++){
    const ch = text[i];
    if (ch === '"') {
      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); }
  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;
  });
  const fallback = ['name','description','image','x','y'];
  fallback.forEach((k,idx)=>{ if (map[k]===undefined && headers[idx] !== undefined) map[k]=idx; });
  return map;
}
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;
    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;
}
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);
  });
}

/* ---------------- transform 行列から scale を読む(安全) ---------------- */
function getCurrentScaleFromTransform() {
  const st = window.getComputedStyle(map).transform;
  if (!st || st === 'none') return 1;
  // matrix(a,b,c,d,e,f)
  const m = st.match(/^matrix\((.+)\)$/);
  if (m) {
    const parts = m[1].split(',').map(Number);
    const a = parts[0], b = parts[1];
    return Math.sqrt(a*a + b*b);
  }
  const m3 = st.match(/^matrix3d\((.+)\)$/);
  if (m3) {
    const parts = m3[1].split(',').map(Number);
    const a = parts[0], b = parts[1];
    return Math.sqrt(a*a + b*b);
  }
  return 1;
}

/* ---------------- touch-action の切替ロジック ----------------
   - scale > 1 または 複数指操作中 は map の操作を優先(touch-action: none)
   - scale == 1 and single-finger -> ページ縦スクロール優先 (pan-y)
*/
function updateWrapperTouchAction() {
  const scale = getCurrentScaleFromTransform();
  if (activePointers.size > 1 || scale > 1.001) {
    wrapper.style.touchAction = 'none';
  } else {
    wrapper.style.touchAction = 'pan-y';
  }
}

/* ---------------- ツールチップの位置調整(viewport基準) ---------------- */
function adjustTooltipPosition(tooltip, desiredLeftPxMap, desiredTopPxMap) {
  const mapRect = map.getBoundingClientRect();
  const desiredLeftViewport = mapRect.left + desiredLeftPxMap;
  const desiredTopViewport  = mapRect.top  + desiredTopPxMap;

  tooltip.style.display = 'block';
  tooltip.style.visibility = 'hidden';
  tooltip.style.transform = 'translate(-50%, -120%)';

  const tRect = tooltip.getBoundingClientRect();
  const margin = 8;

  // horizontal adjustment in viewport coords
  let shiftXViewport = 0;
  if (tRect.left < margin) shiftXViewport = margin - tRect.left;
  if (tRect.right > window.innerWidth - margin) shiftXViewport = (window.innerWidth - margin) - tRect.right;

  // vertical: if tooltip top would go above viewport, flip below pin
  let newTopViewport = desiredTopViewport;
  if (tRect.top < margin) {
    tooltip.style.transform = 'translate(-50%, 8px)'; // below pin
    newTopViewport = desiredTopViewport + 44 + 10;
  } else {
    tooltip.style.transform = 'translate(-50%, -120%)';
  }

  // convert shiftXViewport -> map-relative px
  const shiftXMap = shiftXViewport;
  const newLeftMap = desiredLeftPxMap + shiftXMap;
  const newTopMap = newTopViewport - mapRect.top;

  tooltip.style.left = newLeftMap + 'px';
  tooltip.style.top  = newTopMap + 'px';
  tooltip.style.visibility = 'visible';
}

/* 閉じる */
function closeAllTooltips() {
  tooltipEls.forEach(t => {
    if (t) { t.style.display = 'none'; t.setAttribute('aria-hidden','true'); }
  });
}

/* ---------------- ピン描画(基準サイズ baseImageWidth/baseImageHeight を使う) ---------------- */
function placePins(data) {
  // remove existing
  pinEls.forEach(p => p.remove());
  tooltipEls.forEach(t => t.remove());
  pinEls = []; tooltipEls = [];

  // base sizes must be set (measured when scale == 1)
  const width = baseImageWidth || img.getBoundingClientRect().width;
  const height = baseImageHeight || img.getBoundingClientRect().height;

  data.forEach((d, idx) => {
    const xPx = d.x * width;
    const yPx = d.y * height;

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

    // stop propagation to avoid accidental pan start when tapping pin
    pin.addEventListener('pointerdown', e => e.stopPropagation());
    pin.addEventListener('touchstart', e => e.stopPropagation(), {passive:true});

    pin.addEventListener('keydown', (ev) => {
      if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); pin.click(); }
    });

    // tooltip card
    const tooltip = document.createElement('div');
    tooltip.className = 'tooltip';
    tooltip.dataset.index = String(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';
    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 hidden position relative to base coords
    tooltip.style.left = xPx + 'px';
    tooltip.style.top  = yPx + 'px';
    tooltip.style.display = 'none';

    // pin click toggles tooltip
    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');
    });

    // prevent tooltip pointer events from causing map pan
    tooltip.addEventListener('pointerdown', ev => ev.stopPropagation());
    tooltip.addEventListener('touchstart', ev => ev.stopPropagation(), {passive:true});

    // append to map (pin below tooltip so tooltip overlays)
    map.appendChild(pin);
    map.appendChild(tooltip);

    pinEls.push(pin);
    tooltipEls.push(tooltip);
  });

  renderListPanel(data);
}

/* ---------------- 一覧(ボトムシート)描画 ---------------- */
function renderListPanel(data) {
  listContent.innerHTML = '';
  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);
  });
}

/* open tooltip by index (and scroll page so map visible) */
function openTooltipByIndex(index) {
  if (!pinEls[index]) return;
  // ensure map top visible
  const topY = Math.max(0, map.getBoundingClientRect().top + window.scrollY - 80);
  window.scrollTo({ top: topY, behavior: 'smooth' });
  pinEls[index].click();
}

/* ---------------- フロア読み込み ----------------
   ※ important: we reset panzoom BEFORE measuring base size,
      so that baseImageWidth/height correspond to scale=1 displayed size.
*/
async function loadFloor(floor) {
  if (currentFloor === floor) return;

  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;
  img.src = IMAGE_PATH[floor];
  await waitImageLoad(img);

  // reset pan & measure base size (so pins placed relative to untransformed size)
  if (panzoomInstance && typeof panzoomInstance.reset === 'function') {
    panzoomInstance.reset();
    try { panzoomInstance.pan(0,0); } catch(e) {}
  }
  // measure base displayed image size (after reset)
  await tick(); // wait a frame
  const r = img.getBoundingClientRect();
  baseImageWidth = r.width;
  baseImageHeight = r.height;

  const data = await loadCSV(CSV_PATH[floor]);
  currentData = data;
  placePins(data);

  // update touch-action state based on current transform
  updateWrapperTouchAction();
}

/* ---------------- 一覧の open/close ---------------- */
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();
}

/* small helper: wait one animation frame */
function tick() {
  return new Promise(resolve => requestAnimationFrame(resolve));
}

/* ---------------- 初期化 ---------------- */
async function init() {
  // create Panzoom instance applied to the entire map element
  panzoomInstance = Panzoom(map, {
    maxScale: 4,
    minScale: 1,
    step: 0.3,
    contain: 'outside'
  });

  // wheel zoom using wrapper (so page scroll works when appropriate)
  wrapper.addEventListener('wheel', (ev) => {
    // if ctrlKey (desktop pinch) or user wheel, zoom
    panzoomInstance.zoomWithWheel(ev);
    // update touch action after wheel
    requestAnimationFrame(updateWrapperTouchAction);
  }, { passive: false });

  // pointer tracking to know multi-touch
  map.addEventListener('pointerdown', (ev) => {
    activePointers.add(ev.pointerId);
    updateWrapperTouchAction();
  });
  map.addEventListener('pointerup', (ev) => {
    activePointers.delete(ev.pointerId);
    updateWrapperTouchAction();
  });
  map.addEventListener('pointercancel', (ev) => {
    activePointers.delete(ev.pointerId);
    updateWrapperTouchAction();
  });

  // pointermove -> schedule checking of transform (for pinch zoom in progress)
  let rafId = null;
  function scheduleUpdate() {
    if (rafId) return;
    rafId = requestAnimationFrame(() => { rafId = null; updateWrapperTouchAction(); });
  }
  map.addEventListener('pointermove', scheduleUpdate, { passive: true });
  map.addEventListener('pointerup', scheduleUpdate);
  map.addEventListener('pointercancel', scheduleUpdate);

  // tab buttons
  tabButtons.forEach(btn => btn.addEventListener('click', () => {
    const floor = Number(btn.dataset.floor);
    if (!Number.isNaN(floor)) loadFloor(floor);
  }));

  // global tap closes tooltips (unless tapping inside map/pin/tooltip/list)
  document.addEventListener('pointerdown', (ev) => {
    const target = ev.target;
    if (target.closest('.pin') || target.closest('.tooltip') || target.closest('.list-panel') || target.closest('.list-toggle')) {
      return;
    }
    closeAllTooltips();
  });

  // list toggle handlers
  listToggle.addEventListener('click', openList);
  closeListBtn.addEventListener('click', closeList);

  // resize handler — on resize, reset pan & recalc base size & re-place pins
  let resizeTimer = null;
  window.addEventListener('resize', () => {
    if (resizeTimer) clearTimeout(resizeTimer);
    resizeTimer = setTimeout(async () => {
      // reset pan to avoid transform interfering with measurements
      if (panzoomInstance && typeof panzoomInstance.reset === 'function') {
        panzoomInstance.reset();
        try { panzoomInstance.pan(0,0); } catch(e) {}
      }
      await tick();
      const r = img.getBoundingClientRect();
      baseImageWidth = r.width;
      baseImageHeight = r.height;
      if (currentData && currentData.length) placePins(currentData);
      updateWrapperTouchAction();
    }, 220);
  });

  // initial load: 1F
  img.src = IMAGE_PATH[1];
  await waitImageLoad(img);
  // ensure pan is reset and measure base
  if (panzoomInstance && typeof panzoomInstance.reset === 'function') {
    panzoomInstance.reset();
    try { panzoomInstance.pan(0,0); } catch(e) {}
  }
  await tick();
  const r = img.getBoundingClientRect();
  baseImageWidth = r.width;
  baseImageHeight = r.h