fesmap / pj
forked from Ray8/pj
Newer
Older
pj / fes.js
/* 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

// Panzoomインスタンス保持
let panzoomInstance = null;

/* ========== 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;

  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);
  const data = await loadCSV(CSV_PATH[floor]);
  currentData = data;
  placePins(data);

  // フロア切り替え時はズームをリセット
  if (panzoomInstance) {
    panzoomInstance.reset();
  }
}

/* 初期読み込み(1階) */
async function init() {
  // Panzoomの適用
  panzoomInstance = Panzoom(map, {
    maxScale: 4,
    minScale: 1,
    contain: 'outside',
    step: 0.3
  });
  // ジェスチャー対応(スマホのピンチイン/アウト)
  map.parentElement.addEventListener('wheel', panzoomInstance.zoomWithWheel);
  map.parentElement.addEventListener('pointerdown', (e) => {
    // 吹き出しクリック時はパン開始しない
    if (e.target.closest('.tooltip') || e.target.closest('.pin')) return;
  });

  // 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')) {
      return;
    }
    closeAllTooltips();
  });

  listToggle.addEventListener('click', () => {
    openList();
  });
  closeListBtn.addEventListener('click', closeList);

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

/* List関連関数 (openList, closeList) はそのまま */

document.addEventListener('DOMContentLoaded', init);