// 出張・旅程ジェネレーター — Top-level App
function App(){
  const initial = {
    title: '',
    subtitle: '',
    showTitleOnCanvas: false,
    bgPattern: 'bg-grid',
    bgColor: '#FFFFFF',
    showSeq: true,             // sequence numbers on edges
    showNotes: true,           // memo cards visibility toggle
    showAnnotations: true,     // duration/cost chips visibility toggle
    // Default currency for the trip — the symbol auto-prefixed onto bare
    // numbers entered in the cost field. JP version defaults to '¥'; the
    // user can switch via the toolbar picker for foreign trips. Existing
    // entries with explicit symbols ($, €, etc.) are preserved verbatim
    // and surface as separate per-currency totals in the summary bar.
    defaultCurrency: '¥',
    nodes: [],
    edges: [],
    groups: [],
    notes: [],                 // memo / sticky notes (can anchor to a node/edge)
  };

  const hist = useHistory(initial);
  const { state, set } = hist;
  const [selection, setSelection] = React.useState(null);
  const [view, setView] = React.useState({ zoom: 1, tx: 200, ty: 100 });
  const [editingEdgeId, setEditingEdgeId] = React.useState(null);
  const [toast, setToast] = React.useState(null);
  const [shotMode, setShotMode] = React.useState(false);
  const [composingGroupId, setComposingGroupId] = React.useState(null);
  // (seqSwapMode / seqSwapFirst removed — sequence is auto-derived by time
  // now; users no longer hand-shuffle edge numbers.)
  // 工程表 (Schedule view) overlay
  const [showSchedule, setShowSchedule] = React.useState(false);
  // Note anchor pick mode: when set, the next click on a node/edge becomes
  // the anchor target for that note. Same UX pattern as composingGroupId.
  // Shape: { noteId: string, type: 'node' | 'edge' }
  const [notePickMode, setNotePickMode] = React.useState(null);
  // Edge re-target pick mode: clicking a node sets that edge's from or to
  // endpoint to the chosen node. Useful for inserting/rerouting.
  // Shape: { edgeId: string, endpoint: 'from' | 'to' }
  const [edgePickMode, setEdgePickMode] = React.useState(null);
  // ── Custom confirm dialog (replaces native confirm()) ──
  // Shape: { title, message, confirmLabel, cancelLabel, tone, onConfirm, onCancel } | null
  // Tone: 'primary' (green / default), 'danger' (red — for destructive ops).
  const [confirmDialog, setConfirmDialog] = React.useState(null);
  // Promise-based wrapper so callers can `await askConfirm(...)` instead of
  // restructuring around callbacks. The dialog handlers resolve the promise.
  const askConfirm = (opts) => new Promise((resolve) => {
    setConfirmDialog({
      title: opts.title || '確認',
      message: opts.message || '',
      confirmLabel: opts.confirmLabel || 'はい',
      cancelLabel: opts.cancelLabel || 'キャンセル',
      tone: opts.tone || 'primary',
      onConfirm: () => { setConfirmDialog(null); resolve(true); },
      onCancel:  () => { setConfirmDialog(null); resolve(false); },
    });
  });
  const shotPrevViewRef = React.useRef(null);
  const canvasRef = React.useRef(null);

  const flash = (msg)=>{ setToast(msg); setTimeout(()=>setToast(null), 1800); };

  // ── id helpers ─────────────────────────────────────────────
  const nextId = (prefix) => prefix + '_' + Math.random().toString(36).slice(2,8);

  const getViewCenter = () => {
    const stage = canvasRef.current;
    const rect = stage?.getBoundingClientRect();
    return {
      x: rect ? (rect.width/2 - view.tx)/view.zoom : 400,
      y: rect ? (rect.height/2 - view.ty)/view.zoom : 300,
    };
  };

  // ── Nodes ──────────────────────────────────────────────────
  const addNode = (partial={}) => {
    const c = getViewCenter();
    const type = partial.type ? NODE_TYPES.find(t => t.id === partial.type) : null;
    const id = nextId('n');
    set(s => ({
      ...s,
      nodes:[...s.nodes, {
        id, x: c.x + (Math.random()-0.5)*40, y: c.y + (Math.random()-0.5)*40,
        name: type ? type.label : '新しい地点',
        color: type?.color || '#FFFFFF',
        icon: type?.icon || 'map-pin',
        emoji: null, image: null,
        shape: type?.shape || 'square',
        // Datetime fields. Both optional — the user fills only what they know.
        //   arrival   — 到着日時 (ISO local "YYYY-MM-DDTHH:MM")
        //   departure — 出発日時
        //   cost      — 諸費用 (入場料・宿泊費・etc.)
        // Edge transit time is auto-derived from prev.departure → next.arrival.
        arrival: '', departure: '', cost: '',
        ...partial,
      }]
    }));
    setSelection({type:'node', id});
  };

  const updateNode = (id, patch, opts) => {
    set(s => ({
      ...s,
      nodes: s.nodes.map(n => n.id===id ? { ...n, ...patch } : n),
    }), opts);
  };

  const deleteNode = (id) => {
    set(s => {
      const groups = (s.groups || [])
        .map(g => ({ ...g, nodeIds: (g.nodeIds || []).filter(nid => nid !== id) }))
        .filter(g => (g.nodeIds || []).length > 0);
      // Detach any notes anchored to this node.
      const notes = (s.notes || []).map(n =>
        (n.anchor && n.anchor.type === 'node' && n.anchor.id === id)
          ? { ...n, anchor: null } : n
      );
      return {
        ...s,
        nodes: s.nodes.filter(n=>n.id!==id),
        edges: s.edges.filter(e=>e.from!==id && e.to!==id),
        groups, notes,
      };
    });
    setSelection(null);
  };

  // Duplicate a node — creates a copy at a slight offset (so it's visible)
  // with a fresh id. Edges, groups, and notes are NOT copied (those reference
  // ids; the duplicate starts as a free node).
  const duplicateNode = (id) => {
    const orig = state.nodes.find(n => n.id === id);
    if (!orig) return;
    const newId = nextId('n');
    const dup = { ...orig, id: newId, x: orig.x + 40, y: orig.y + 40 };
    set(s => ({ ...s, nodes: [...s.nodes, dup] }));
    setSelection({ type: 'node', id: newId });
    flash('地点を複製しました');
  };

  // ── Edges ──────────────────────────────────────────────────
  // edge.seq is the planned-order number, INDEPENDENT of array position.
  // Array position controls z-order only (later edges paint over earlier).
  const nextSeq = (edges) => {
    const max = (edges || []).reduce((m, e) => Math.max(m, e.seq || 0), 0);
    return max + 1;
  };

  const addEdge = (fromId, toId, partial={}) => {
    const id = nextId('e');
    const rel = RELATIONS.find(r => r.id === 'jr') || RELATIONS[0];
    set(s => ({
      ...s,
      edges:[...s.edges, {
        id, from:fromId, to:toId,
        relation: rel.id, style: rel.defaultStyle || 'oneway',
        color: rel.color, label: null, stamp: rel.stamp,
        seq: nextSeq(s.edges),
        // duration is no longer stored; it's derived from from.departure → to.arrival.
        cost: '',
        ...partial,
      }]
    }));
    setSelection({type:'edge', id});
  };

  const updateEdge = (id, patch, opts) => {
    set(s => ({
      ...s,
      edges: s.edges.map(e => e.id===id ? { ...e, ...patch } : e),
    }), opts);
  };

  const deleteEdge = (id) => {
    set(s => {
      // Detach any notes anchored to this edge.
      const notes = (s.notes || []).map(n =>
        (n.anchor && n.anchor.type === 'edge' && n.anchor.id === id)
          ? { ...n, anchor: null } : n
      );
      return {
        ...s,
        edges: s.edges.filter(e=>e.id!==id),
        notes,
      };
    });
    setSelection(null);
  };

  // (moveEdgeOrder / swapEdgeSeq / setEdgeSeq removed — sequence is auto-
  // derived from each node's arrival/departure times, so manual reorder
  // helpers no longer have a UI surface to attach to.)

  // Z-order swap (Shift+click on edges). Swaps array positions ONLY — used
  // when two edges visually overlap and the user wants to bring one forward.
  const swapEdgeZ = (id1, id2) => {
    set(s => {
      const i1 = s.edges.findIndex(e => e.id === id1);
      const i2 = s.edges.findIndex(e => e.id === id2);
      if (i1 < 0 || i2 < 0 || i1 === i2) return s;
      const edges = [...s.edges];
      [edges[i1], edges[i2]] = [edges[i2], edges[i1]];
      return {...s, edges};
    });
  };

  // ── Groups (Day 1 / Day 2 / etc.) ──────────────────────────
  const GROUP_COLORS = ['#10A37F','#3B82F6','#F59E0B','#7C3AED','#EF4444','#0891B2','#84CC16'];

  const createGroupFromNode = (nodeId) => {
    const id = nextId('g');
    const usedColors = (state.groups || []).map(g=>g.color);
    const color = GROUP_COLORS.find(c => !usedColors.includes(c)) || GROUP_COLORS[0];
    set(s => ({
      ...s,
      groups: [...(s.groups || []), {
        id, name:`Day ${(s.groups||[]).length + 1}`,
        color, showName:true, nodeIds:[nodeId],
      }],
    }));
    return id;
  };

  const updateGroup = (id, patch, opts) => {
    set(s => ({
      ...s,
      groups: (s.groups || []).map(g => g.id === id ? { ...g, ...patch } : g),
    }), opts);
  };

  const deleteGroup = (id) => {
    set(s => ({
      ...s,
      groups: (s.groups || []).filter(g => g.id !== id),
    }));
    setSelection(null);
    if (composingGroupId === id) setComposingGroupId(null);
  };

  // ── Auto-connect by time (reactive) ───────────────────────────
  // Whenever a node's arrival/departure changes, re-derive the chain of
  // edges from the time-sorted node list and apply the diff against the
  // current edge set:
  //   - Pairs that already exist as edges → preserved (keeps user-customised
  //     color, label, cost, etc.).
  //   - Pairs missing in current → new edges are created and flagged in
  //     whooshEdgeIds so Edge.jsx plays the "drawing on" animation.
  //   - Pairs in current but NOT in the new chain → those represent the
  //     "rewiring" case (e.g. inserting a new node BETWEEN two connected
  //     ones). We confirm with the user before removing them, naming the
  //     affected legs ("「東京駅」→「箱根」 の区間が変更されます").
  // Edges between untimed nodes are passed through untouched.
  const [whooshEdgeIds, setWhooshEdgeIds] = React.useState(new Set());
  // A signature string that changes ONLY when a node's time fields move.
  // Keying the effect on this avoids spurious re-runs on position drags.
  const timeSig = React.useMemo(
    () => (state.nodes || []).map(n => `${n.id}|${n.arrival||''}|${n.departure||''}`).sort().join(';'),
    [state.nodes]
  );
  // Suppresses re-prompting the same confirmation after the user cancels.
  const ignoredTimeSigRef = React.useRef('');
  // Skips the very first effect run — we don't want a confirmation dialog
  // popping up just because the user loaded a save from localStorage.
  const initialMountRef = React.useRef(true);

  // Single source of truth for the auto-sync logic. Called both:
  //   • debounced from the useEffect below (reactive, on time changes)
  //   • directly from the toolbar button (force = bypass cancel memory)
  // Async because the rewiring confirmation now uses a Promise-based
  // custom dialog rather than the browser's native confirm().
  const syncEdgesByTime = async (opts = {}) => {
    const force = opts.force === true;
    const timed = state.nodes
      .map(n => ({ n, t: n.departure || n.arrival || null }))
      .filter(x => x.t)
      .sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0));
    if (timed.length < 2){
      if (force) flash('時刻が入ったノードを2つ以上配置してから実行してください');
      return;
    }
    const timedIds = new Set(timed.map(x => x.n.id));
    const currentTimedEdges = (state.edges || []).filter(e =>
      timedIds.has(e.from) && timedIds.has(e.to)
    );
    const currentPairSet = new Set(currentTimedEdges.map(e => `${e.from}→${e.to}`));
    // Desired chain pairs in time order.
    const desiredPairs = [];
    for (let i = 0; i < timed.length - 1; i++){
      desiredPairs.push([timed[i].n, timed[i + 1].n]);
    }
    const desiredPairSet = new Set(desiredPairs.map(([a, b]) => `${a.id}→${b.id}`));
    // toAdd / toRemove drive whether we even need to act, and whether
    // it counts as "rewiring" (= asking for confirmation).
    const toAdd    = desiredPairs.filter(([a, b]) => !currentPairSet.has(`${a.id}→${b.id}`));
    const toRemove = currentTimedEdges.filter(e => !desiredPairSet.has(`${e.from}→${e.to}`));
    if (toAdd.length === 0 && toRemove.length === 0){
      if (force) flash('既に時刻順に並んでいます');
      return;
    }
    // Confirm BEFORE applying when an existing edge would be removed
    // (the "in-between insertion" scenario the user called out).
    if (toRemove.length > 0){
      const nameOf = (id) => state.nodes.find(n => n.id === id)?.name || '?';
      const legs = toRemove.map(e => `「${nameOf(e.from)}」→「${nameOf(e.to)}」`).join('、');
      const ok = await askConfirm({
        title: '経路を組み直しますか？',
        message: `時刻順が変わったため、${legs} の区間が変更されます。`,
        confirmLabel: 'はい、変更する',
        cancelLabel: 'やめる',
        tone: 'primary',
      });
      if (!ok){
        // Remember this exact time signature so we don't re-prompt for
        // the same change. A later edit creates a new sig and re-engages.
        ignoredTimeSigRef.current = timeSig;
        return;
      }
    }
    // Build new edges. Preserve existing edges where the (from→to) pair
    // matches the desired chain (keeps user-customised fields), only
    // creating fresh edges for the truly new pairs (those get whooshed).
    const guessRel = (a, b) => {
      const transit = (n) => n.icon === 'train-front' || n.icon === 'tram-front'
        || n.icon === 'plane' || n.icon === 'ship' || n.icon === 'sailboat'
        || n.icon === 'bus' || n.icon === 'taxi' || n.icon === 'car';
      if (transit(a) && transit(b)) return RELATIONS.find(r => r.id === 'jr') || RELATIONS[0];
      return RELATIONS.find(r => r.id === 'walk') || RELATIONS[0];
    };
    const existingByPair = new Map();
    (state.edges || []).forEach(e => existingByPair.set(`${e.from}→${e.to}`, e));
    const newChain = [];
    const freshIds = new Set();
    desiredPairs.forEach(([a, b], i) => {
      const pair = `${a.id}→${b.id}`;
      const existing = existingByPair.get(pair);
      if (existing){
        newChain.push({ ...existing, seq: i + 1 });
      } else {
        const id = nextId('e');
        const rel = guessRel(a, b);
        newChain.push({
          id, from: a.id, to: b.id,
          relation: rel.id, style: rel.defaultStyle || 'oneway',
          color: rel.color, label: null, stamp: rel.stamp,
          seq: i + 1,
          cost: '',
        });
        freshIds.add(id);
      }
    });
    // Preserve edges between untimed nodes (or where one endpoint is
    // missing a time) — we only touch edges between two timed nodes.
    const untimedEdges = (state.edges || []).filter(e =>
      !timedIds.has(e.from) || !timedIds.has(e.to)
    );
    set(s => ({ ...s, edges: [...untimedEdges, ...newChain] }));
    // Whoosh animation only on the freshly-added pairs.
    if (freshIds.size > 0){
      setWhooshEdgeIds(freshIds);
      setTimeout(() => setWhooshEdgeIds(new Set()), 1600);
    }
    if (force) flash(`矢印を時刻順に再配線しました`);
  };

  // Reactive trigger: 500ms debounce on time changes. Skips the very first
  // mount (load from localStorage shouldn't pop a dialog).
  React.useEffect(() => {
    if (initialMountRef.current){
      initialMountRef.current = false;
      return;
    }
    if (timeSig === ignoredTimeSigRef.current) return;
    const h = setTimeout(() => { syncEdgesByTime(); }, 500);
    return () => clearTimeout(h);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [timeSig]);

  // Toolbar button — same logic but bypasses the "user cancelled" memory
  // and always shows a flash so the action feels responsive.
  const autoConnectByTime = () => {
    ignoredTimeSigRef.current = '';
    syncEdgesByTime({ force: true });
  };

  // Auto-create groups from each unique date present in the nodes' arrival /
  // departure datetimes. A node belongs to EVERY date it touches (so a hotel
  // with arrival 4/28 and departure 4/29 ends up in BOTH "1日目" and "2日目"
  // groups — the natural "stayover bridges two days" semantics).
  const autoGroupByDate = async () => {
    const dt = window.tourDateTime;
    if (!dt){ flash('日付ヘルパーが読み込まれていません'); return; }
    const dateToNodes = new Map();
    state.nodes.forEach(n => {
      const dates = new Set();
      [n.arrival, n.departure].forEach(s => {
        const d = dt.toDate(s);
        if (d) dates.add(`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`);
      });
      dates.forEach(key => {
        if (!dateToNodes.has(key)) dateToNodes.set(key, new Set());
        dateToNodes.get(key).add(n.id);
      });
    });
    if (dateToNodes.size === 0){
      flash('日付が入力されたノードがありません'); return;
    }
    if ((state.groups || []).length > 0){
      const ok = await askConfirm({
        title: 'グループを置き換えますか？',
        message: `現在の${state.groups.length}個のグループを削除して、日付別グループ（${dateToNodes.size}日分）を生成します。`,
        confirmLabel: 'はい、置き換える',
        cancelLabel: 'やめる',
        tone: 'primary',
      });
      if (!ok) return;
    }
    const sortedDates = Array.from(dateToNodes.keys()).sort();
    const newGroups = sortedDates.map((key, i) => {
      const [y, m, d] = key.split('-').map(Number);
      return {
        id: `g_auto_${i+1}_${Math.random().toString(36).slice(2,6)}`,
        name: `${i+1}日目 (${m}/${d})`,
        color: GROUP_COLORS[i % GROUP_COLORS.length],
        showName: true,
        nodeIds: Array.from(dateToNodes.get(key)),
        labelOffset: { dx: 0, dy: -30 },
      };
    });
    set(s => ({ ...s, groups: newGroups }));
    flash(`${newGroups.length}日分のグループを自動生成しました`);
  };

  const toggleGroupMember = (groupId, nodeId) => {
    set(s => {
      const groups = (s.groups || []).map(g => {
        if (g.id !== groupId) return g;
        const has = (g.nodeIds || []).includes(nodeId);
        const nodeIds = has
          ? g.nodeIds.filter(x => x !== nodeId)
          : [...(g.nodeIds || []), nodeId];
        return { ...g, nodeIds };
      })
      .filter(g => g.id === groupId || (g.nodeIds || []).length > 0);
      return { ...s, groups };
    });
  };

  // ── Notes ──────────────────────────────────────────────────
  // Notes can optionally anchor to a node or edge. The anchor causes a
  // dotted line to be drawn from the note to the target's center.
  const addNote = (partial={}) => {
    const c = getViewCenter();
    const id = nextId('note');
    set(s => ({
      ...s,
      notes: [...(s.notes || []), {
        id, x: c.x, y: c.y,
        text: '',
        color: '#FFFBEB',
        anchor: null,
        ...partial,
      }],
    }));
    setSelection({type:'note', id});
  };

  const updateNote = (id, patch, opts) => {
    set(s => ({
      ...s,
      notes: (s.notes || []).map(n => n.id===id ? { ...n, ...patch } : n),
    }), opts);
  };

  const deleteNote = (id) => {
    set(s => ({
      ...s,
      notes: (s.notes || []).filter(n => n.id !== id),
    }));
    setSelection(null);
  };

  // Duplicate a note — copy text/color/anchor at slight offset.
  const duplicateNote = (id) => {
    const orig = (state.notes || []).find(n => n.id === id);
    if (!orig) return;
    const newId = nextId('note');
    const dup = { ...orig, id: newId, x: orig.x + 24, y: orig.y + 24 };
    set(s => ({ ...s, notes: [...(s.notes || []), dup] }));
    setSelection({ type: 'note', id: newId });
    flash('メモを複製しました');
  };

  // Enter pick mode for a note's anchor. Canvas intercepts the next node /
  // edge click depending on `type` and routes it to setNoteAnchorTo.
  const startNoteAnchorPick = (noteId, type) => {
    setNotePickMode({ noteId, type });
    flash(type === 'node'
      ? '紐づけ先となる地点をクリックしてください（Esc で取消）'
      : '紐づけ先となる矢印をクリックしてください（Esc で取消）');
  };
  const cancelNoteAnchorPick = () => setNotePickMode(null);
  const setNoteAnchorTo = (targetId) => {
    if (!notePickMode) return;
    const { noteId, type } = notePickMode;
    updateNote(noteId, { anchor: { type, id: targetId } });
    setNotePickMode(null);
    flash('メモを紐づけました');
  };

  // Edge re-target pick mode handlers — change an existing edge's from / to
  // by clicking a different node. Used for fast re-routing when plans change.
  const startEdgeRetargetPick = (edgeId, endpoint) => {
    setEdgePickMode({ edgeId, endpoint });
    flash(endpoint === 'from'
      ? '新しい出発地点をクリックしてください（Esc で取消）'
      : '新しい行き先をクリックしてください（Esc で取消）');
  };
  const cancelEdgeRetargetPick = () => setEdgePickMode(null);
  const setEdgeEndpointTo = (nodeId) => {
    if (!edgePickMode) return;
    const { edgeId, endpoint } = edgePickMode;
    const edge = state.edges.find(e => e.id === edgeId);
    if (!edge) { setEdgePickMode(null); return; }
    // Self-loop guard: can't pick the same node as the other endpoint
    const otherId = endpoint === 'from' ? edge.to : edge.from;
    if (nodeId === otherId){
      flash('反対側と同じ地点は選べません');
      return;
    }
    updateEdge(edgeId, { [endpoint]: nodeId });
    setEdgePickMode(null);
    flash(endpoint === 'from' ? '出発地点を変更しました' : '行き先を変更しました');
  };

  const clearAll = async () => {
    const ok = await askConfirm({
      title: 'すべてクリアしますか？',
      message: 'すべてのノード・矢印・メモを削除します。この操作は元に戻せません（Undo は効きます）。',
      confirmLabel: 'はい、クリアする',
      cancelLabel: 'やめる',
      tone: 'danger',
    });
    if (!ok) return;
    set(s => ({...s, nodes:[], edges:[], groups:[], notes:[],
                title:'', subtitle:'', showTitleOnCanvas:false,
                bgPattern:'bg-grid', bgColor:'#FFFFFF'}));
    setSelection(null);
    setComposingGroupId(null);
    try { localStorage.removeItem('tour_saved_data'); } catch(e) {}
  };

  // ── Demo data ──────────────────────────────────────────────
  // Two flavors:
  //   'travel'   — 旅行 (箱根 1泊2日, default)
  //   'business' — 出張 (大阪 1泊2日 商談)
  const loadDemo = (which) => {
    const demo = which === 'business' ? buildBusinessDemoData() : buildTravelDemoData();
    set(s => ({
      ...s,
      nodes: demo.nodes, edges: demo.edges,
      groups: demo.groups || [],
      notes: demo.notes || [],
      title: demo.title, subtitle: demo.subtitle,
      showTitleOnCanvas: true,
      bgPattern: 'bg-grid', bgColor: '#FFFFFF',
    }));
    setView({ zoom: 0.85, tx: 80, ty: 60 });
    setSelection(null);
    flash(which === 'business' ? 'サンプル出張を読み込みました' : 'サンプル旅程を読み込みました');
  };

  // ── Settings ───────────────────────────────────────────────
  const setTitle = (t)=> set(s=>({...s,title:t}));
  const setSubtitle = (t)=> set(s=>({...s,subtitle:t}));
  const setDefaultCurrency = (c)=> set(s=>({...s,defaultCurrency:c}));
  const setShowTitleOnCanvas = (v)=> set(s=>({...s,showTitleOnCanvas:v}));
  const setBgPattern = (v)=> set(s=>({...s,bgPattern:v}));
  const setBgColor = (v)=> set(s=>({...s,bgColor:v}));
  const setShowSeq = (v)=> set(s=>({...s,showSeq:v}));
  const setShowNotes = (v)=> set(s=>({...s,showNotes:v}));
  const setShowAnnotations = (v)=> set(s=>({...s,showAnnotations:v}));

  // ── Project file save / load ───────────────────────────────
  const saveProject = () => {
    const data = JSON.stringify(state, null, 2);
    const blob = new Blob([data], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = (state.title || '出張・旅程') + '.tour.json';
    a.click();
    URL.revokeObjectURL(url);
    flash('作業ファイルを保存しました');
  };

  const loadProjectFileRef = React.useRef(null);
  const loadProject = () => { loadProjectFileRef.current?.click(); };
  const onLoadProjectFile = (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    const reader = new FileReader();
    reader.onload = async () => {
      try {
        const data = JSON.parse(reader.result);
        const ok = await askConfirm({
          title: '作業を上書きしますか？',
          message: '現在の作業内容は読み込んだファイルで上書きされます。',
          confirmLabel: 'はい、読み込む',
          cancelLabel: 'やめる',
          tone: 'primary',
        });
        if (!ok) return;
        // Migrate older saves: ensure each edge has a .seq value.
        const edges = (data.edges ?? []).map((e, i) => ({
          ...e,
          seq: (typeof e.seq === 'number' ? e.seq : i + 1),
        }));
        set(s => ({
          ...s,
          title: data.title ?? '',
          subtitle: data.subtitle ?? '',
          showTitleOnCanvas: data.showTitleOnCanvas ?? false,
          bgPattern: data.bgPattern ?? 'bg-grid',
          bgColor: data.bgColor ?? '#FFFFFF',
          showSeq: data.showSeq ?? true,
          showNotes: data.showNotes ?? true,
          showAnnotations: data.showAnnotations ?? true,
          // defaultCurrency must be restored from the file too — without this,
          // saving a USD/EUR trip and reloading it would silently revert to
          // the local default ('¥' on JP / '$' on EN), and existing cost rows
          // would suddenly be totalled under the wrong symbol.
          defaultCurrency: data.defaultCurrency ?? s.defaultCurrency ?? '¥',
          nodes: data.nodes ?? [],
          edges,
          groups: data.groups ?? [],
          notes: data.notes ?? [],
        }));
        setSelection(null);
        setComposingGroupId(null);
        setView({ zoom: 1, tx: 200, ty: 100 });
        flash('作業ファイルを読み込みました');
      } catch(err) {
        flash('ファイルの読み込みに失敗しました');
        console.error(err);
      }
    };
    reader.readAsText(f);
    e.target.value = '';
  };

  // ── Screenshot mode ────────────────────────────────────────
  const enterShotMode = () => {
    if (state.nodes.length === 0) { flash('ノードを追加してください'); return; }
    setSelection(null);
    const pad = 80;
    let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
    state.nodes.forEach(n=>{
      minX = Math.min(minX, n.x-60); minY = Math.min(minY, n.y-60);
      maxX = Math.max(maxX, n.x+60); maxY = Math.max(maxY, n.y+60);
    });
    if (state.showNotes !== false){
      (state.notes || []).forEach(n => {
        minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
        maxX = Math.max(maxX, n.x + 200); maxY = Math.max(maxY, n.y + 60);
      });
    }
    if (state.showTitleOnCanvas) minY = Math.min(minY, 0);
    minX-=pad; minY-=pad; maxX+=pad; maxY+=pad;
    const bw = maxX-minX, bh = maxY-minY;
    const vw = window.innerWidth, vh = window.innerHeight;
    const zoom = Math.min(1.5, Math.max(0.3, Math.min(vw/bw, vh/bh)));
    const tx = (vw - bw*zoom)/2 - minX*zoom;
    const ty = (vh - bh*zoom)/2 - minY*zoom;
    shotPrevViewRef.current = view;
    setView({ zoom, tx, ty });
    setShotMode(true);
    document.body.classList.add('is-shot-mode');
  };
  const exitShotMode = () => {
    setShotMode(false);
    document.body.classList.remove('is-shot-mode');
    if (shotPrevViewRef.current) {
      setView(shotPrevViewRef.current);
      shotPrevViewRef.current = null;
    }
  };

  // ── Keyboard ───────────────────────────────────────────────
  React.useEffect(()=>{
    const onKey = (e) => {
      if (shotMode && e.key==='Escape') { exitShotMode(); return; }
      if (e.key==='Escape' && composingGroupId) { setComposingGroupId(null); return; }
      if (e.key==='Escape' && notePickMode) { cancelNoteAnchorPick(); return; }
      if (e.key==='Escape' && edgePickMode) { cancelEdgeRetargetPick(); return; }
      if (e.target.matches('input,textarea')) return;
      if ((e.metaKey||e.ctrlKey) && e.key==='z' && !e.shiftKey){ e.preventDefault(); hist.undo(); }
      else if ((e.metaKey||e.ctrlKey) && (e.key==='y' || (e.shiftKey && e.key==='z'))){ e.preventDefault(); hist.redo(); }
      else if (e.key==='Delete' || e.key==='Backspace'){
        if (selection?.type==='node') deleteNode(selection.id);
        if (selection?.type==='edge') deleteEdge(selection.id);
        if (selection?.type==='group') deleteGroup(selection.id);
        if (selection?.type==='note') deleteNote(selection.id);
      }
    };
    window.addEventListener('keydown', onKey);
    return ()=> window.removeEventListener('keydown', onKey);
  }, [selection, hist, shotMode, composingGroupId, notePickMode, edgePickMode]);

  // (onSeqSwapClick removed — sequence numbers are now auto-derived from
  // node times, no manual swap mode.)

  return (
    <div className="app">
      <Toolbar
        title={state.title} setTitle={setTitle}
        subtitle={state.subtitle} setSubtitle={setSubtitle}
        defaultCurrency={state.defaultCurrency || '¥'} setDefaultCurrency={setDefaultCurrency}
        showTitleOnCanvas={state.showTitleOnCanvas} setShowTitleOnCanvas={setShowTitleOnCanvas}
        bgPattern={state.bgPattern} setBgPattern={setBgPattern}
        bgColor={state.bgColor} setBgColor={setBgColor}
        showSeq={state.showSeq} setShowSeq={setShowSeq}
        showNotes={state.showNotes} setShowNotes={setShowNotes}
        showAnnotations={state.showAnnotations} setShowAnnotations={setShowAnnotations}
        canUndo={hist.canUndo} canRedo={hist.canRedo}
        onUndo={hist.undo} onRedo={hist.redo}
        onAddNode={addNode}
        onAddNote={addNote}
        onShotMode={enterShotMode}
        onSaveProject={saveProject}
        onLoadProject={loadProject}
        onLoadDemo={loadDemo}
        onClear={clearAll}
        onShowSchedule={()=>setShowSchedule(true)}
        onAutoGroupByDate={autoGroupByDate}
        onAutoConnectByTime={autoConnectByTime}
      />
      <input ref={loadProjectFileRef} className="file-hidden" type="file"
             accept=".json,.tour.json,application/json" onChange={onLoadProjectFile}/>

      <div className="canvas-wrap">
        <Canvas
          nodes={state.nodes} edges={state.edges} groups={state.groups || []} notes={state.notes || []}
          defaultCurrency={state.defaultCurrency || '¥'}
          whooshEdgeIds={whooshEdgeIds}
          showSeq={state.showSeq}
          showNotes={state.showNotes}
          showAnnotations={state.showAnnotations}
          notePickMode={notePickMode}
          onNoteAnchorPickTarget={setNoteAnchorTo}
          onCancelNotePick={cancelNoteAnchorPick}
          edgePickMode={edgePickMode}
          onEdgeEndpointPickTarget={setEdgeEndpointTo}
          onCancelEdgePick={cancelEdgeRetargetPick}
          selection={selection} setSelection={setSelection}
          updateNode={updateNode} deleteNode={deleteNode}
          addEdge={addEdge} updateEdge={updateEdge}
          updateNote={updateNote} deleteNote={deleteNote}
          swapEdgeZ={swapEdgeZ} flash={flash}
          composingGroupId={composingGroupId} setComposingGroupId={setComposingGroupId}
          toggleGroupMember={toggleGroupMember}
          updateGroup={updateGroup}
          view={view} setView={setView} canvasRef={canvasRef}
          bgPattern={state.bgPattern} bgColor={state.bgColor}
          title={state.title} subtitle={state.subtitle}
          showTitleOnCanvas={state.showTitleOnCanvas}
          onEditEdge={(id)=>setEditingEdgeId(id)}
        />
        {state.nodes.length === 0 && state.notes.length === 0 && (
          <div className="welcome">
            <div className="card">
              <div className="icon-wrap">
                <window.TourIcon name="route" size={26}/>
              </div>
              <h1>出張・旅程ジェネレーター</h1>
              <p>地点を置いて<b style={{color:'var(--green-deep)'}}>到着・出発時刻</b>を入れるだけ。<br/>矢印は<b style={{color:'var(--green-deep)'}}>自動で時刻順に</b>つながり、工程がそのまま見える化されます。<br/>カレンダー連携・経費精算CSV・<b style={{color:'var(--green-deep)'}}>多通貨</b>（¥/$/€）対応。</p>
              <div className="row">
                <button className="btn primary" onClick={()=>addNode({type:'station'})}>
                  <window.TourIcon name="plus" size={14} stroke="#fff"/> 最初の地点を追加
                </button>
                <button className="btn" onClick={()=>loadDemo('travel')}>
                  <window.TourIcon name="sparkles" size={14}/> サンプル旅程
                </button>
                <button className="btn" onClick={()=>loadDemo('business')}>
                  <window.TourIcon name="briefcase" size={14}/> サンプル出張
                </button>
              </div>
            </div>
          </div>
        )}
      </div>

      <Inspector
        selection={selection} setSelection={setSelection}
        nodes={state.nodes} edges={state.edges} groups={state.groups || []} notes={state.notes || []}
        defaultCurrency={state.defaultCurrency || '¥'}
        updateNode={updateNode} updateEdge={updateEdge} updateNote={updateNote}
        deleteNode={deleteNode} deleteEdge={deleteEdge} deleteNote={deleteNote}
        duplicateNode={duplicateNode} duplicateNote={duplicateNote}
        addNode={addNode} addNote={addNote}
        clearAll={clearAll}
        onLoadDemo={loadDemo}
        onAutoGroupByDate={autoGroupByDate}
        createGroupFromNode={createGroupFromNode}
        updateGroup={updateGroup} deleteGroup={deleteGroup}
        composingGroupId={composingGroupId} setComposingGroupId={setComposingGroupId}
        notePickMode={notePickMode}
        startNoteAnchorPick={startNoteAnchorPick}
        edgePickMode={edgePickMode}
        startEdgeRetargetPick={startEdgeRetargetPick}
      />

      <SummaryBar edges={state.edges} groups={state.groups || []} nodes={state.nodes}
                  defaultCurrency={state.defaultCurrency || '¥'}/>

      {showSchedule && window.Schedule && (
        <window.Schedule
          nodes={state.nodes}
          edges={state.edges}
          groups={state.groups || []}
          notes={state.notes || []}
          defaultCurrency={state.defaultCurrency || '¥'}
          title={state.title}
          subtitle={state.subtitle}
          onClose={()=>setShowSchedule(false)}
        />
      )}

      {/* Credit footer (rendered here so it sits inside .app's stacking context;
          schedule overlay and toast can paint on top of it via z-index). */}
      <div className="credit">
        <a className="about-link" href="about.html">使い方・このツールについて</a>
        <span className="sep">/</span>
        制作: <a href="https://amix-design.com/" target="_blank" rel="noopener">AMIX</a>
        <span className="sep">/</span>
        <a href="https://amix-design.com/tools" target="_blank" rel="noopener">ツール一覧</a>
        <span className="sep">/</span>
        <a href="https://amix-design.com/term/tools" target="_blank" rel="noopener">利用規約</a>
        <span className="sep">/</span>
        <a href="license.html">ライセンス</a>
        <span className="sep">/</span>
        <a href="../en/tool-dia-tour/" hrefLang="en" title="English version">EN</a>
      </div>

      {toast && <div className="toast">{toast}</div>}

      {/* Custom confirm dialog — replaces native confirm() throughout the
          app so the wording, buttons, and motion match the tool's tone. */}
      {confirmDialog && (
        <div className="confirm-overlay"
             onClick={(e)=>{ if (e.target === e.currentTarget) confirmDialog.onCancel(); }}
             onKeyDown={(e)=>{
               if (e.key === 'Escape') confirmDialog.onCancel();
               if (e.key === 'Enter') confirmDialog.onConfirm();
             }}
             tabIndex={-1}>
          <div className={`confirm-card${confirmDialog.tone === 'danger' ? ' danger' : ''}`}>
            <div className="confirm-icon" aria-hidden="true">
              <window.TourIcon
                name={confirmDialog.tone === 'danger' ? 'circle-alert' : 'circle-help'}
                size={20} stroke="currentColor"/>
            </div>
            <div className="confirm-title">{confirmDialog.title}</div>
            <div className="confirm-message">{confirmDialog.message}</div>
            <div className="confirm-actions">
              <button className="btn" onClick={confirmDialog.onCancel}>
                {confirmDialog.cancelLabel}
              </button>
              <button className={confirmDialog.tone === 'danger' ? 'btn danger' : 'btn primary'}
                      onClick={confirmDialog.onConfirm} autoFocus>
                {confirmDialog.confirmLabel}
              </button>
            </div>
          </div>
        </div>
      )}

      {shotMode && (
        <>
          <button className="shot-exit" onClick={exitShotMode} title="スクショモードを終了">
            <window.TourIcon name="x" size={18} stroke="currentColor"/>
          </button>
          <div className="shot-hint" key={Date.now()}>
            <window.TourIcon name="camera" size={14} stroke="currentColor"/>
            <span>スクリーンショットを撮ってください —</span>
            {navigator.platform?.includes('Mac')
              ? <><kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>4</kbd></>
              : <><kbd>Win</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd></>}
          </div>
        </>
      )}
    </div>
  );
}

// ── Demo data ──────────────────────────────────────────────────
// 1泊2日 箱根旅程をベースに、ラベル位置と note 配置を散らして見やすく。
//   labelPos: 上の段は'top'、右下は'right'、下の段は'bottom'、左端は'left'
//   annoOffset: 大涌谷だけ手動で右側へ寄せ、矢印 e5 と被らないように
//   notes: ノード・矢印の3種類に紐づけて、点線リンクの動作をひと目で把握できるよう配置
function buildTravelDemoData(){
  const D1 = '2026-04-28';  // Day 1
  const D2 = '2026-04-29';  // Day 2
  const nodes = [
    // Day 1 — top horizontal row, labels above (out of edge path)
    { id:'n1', name:'東京駅',     subtitle:'出発',
      icon:'train-front', shape:'square', x:180,  y:200, color:'#ECFDF5',
      labelPos:'left',
      arrival:'',                  departure:`${D1}T09:00`, cost:'' },

    { id:'n2', name:'品川駅',     subtitle:'乗換',
      icon:'train-front', shape:'square', x:440,  y:200, color:'#ECFDF5',
      labelPos:'top',
      arrival:`${D1}T09:08`,       departure:`${D1}T09:18`, cost:'' },

    { id:'n3', name:'小田原駅',   subtitle:'新幹線で約30分',
      icon:'train-front', shape:'square', x:700,  y:200, color:'#ECFDF5',
      labelPos:'top',
      annoOffset:{ dx: 100, dy: 0 },   // 時刻チップを右側へ
      arrival:`${D1}T09:48`,       departure:`${D1}T09:53`, cost:'' },

    // Day 1 — descending right side
    { id:'n4', name:'箱根湯本駅', subtitle:'登山鉄道',
      icon:'tram-front',  shape:'square', x:700,  y:400, color:'#F0F9FF',
      labelPos:'left',
      arrival:`${D1}T10:08`,       departure:`${D1}T10:18`, cost:'' },

    { id:'n5', name:'大涌谷',     subtitle:'観光・名物の黒卵',
      icon:'mountain',    shape:'circle', x:1000, y:280, color:'#F0FDF4',
      labelPos:'top',
      annoOffset:{ dx: 140, dy: 8 },  // chip をもう少し右側へ（縦線回避＋ノートと干渉回避）
      highlight:'#EAB308',  // 今回の目玉スポット — 黄色のハイライト
      arrival:`${D1}T11:03`,       departure:`${D1}T12:33`, cost:'¥1,000' },

    { id:'n6', name:'箱根の旅館', subtitle:'1泊',
      icon:'bed',         shape:'circle', x:1000, y:540, color:'#FAF5FF',
      labelPos:'right',
      arrival:`${D1}T12:45`,       departure:`${D2}T08:00`, cost:'¥18,000' },

    // Day 2 — leftward bottom row
    { id:'n7', name:'芦ノ湖 海賊船', subtitle:'湖上クルーズ',
      icon:'sailboat',    shape:'circle', x:700,  y:540, color:'#ECFEFF',
      labelPos:'bottom',
      arrival:`${D2}T08:20`,       departure:`${D2}T09:00`, cost:'' },

    { id:'n8', name:'箱根神社',   subtitle:'パワースポット',
      icon:'landmark',    shape:'square', x:440,  y:540, color:'#F8FAFC',
      labelPos:'left',                       // 名前を左側へ
      annoOffset:{ dx: -100, dy: -50 },     // 時刻チップを左上へ
      arrival:`${D2}T09:30`,       departure:`${D2}T10:30`, cost:'¥500' },

    { id:'n9', name:'帰路',       subtitle:'ロマンスカーで都内へ',
      icon:'flag-triangle-right', shape:'circle', x:180, y:660, color:'#FEF3C7',
      labelPos:'left',
      arrival:`${D2}T10:55`,       departure:'', cost:'' },
  ];

  // Edges — duration is auto-derived from each node's arrival/departure.
  // e4 (orth-h) and e8 (orth-v) showcase L-shape arrows with rounded corners.
  const edges = [
    { id:'e1', seq:1, from:'n1', to:'n2', relation:'jr',         style:'rail-solid',  color:'#10A37F', label:'山手線',      stamp:'train-front', cost:'¥170'   },
    { id:'e2', seq:2, from:'n2', to:'n3', relation:'shinkansen', style:'rail-double', color:'#0E8C5A', label:'こだま',      stamp:'train-front', cost:'¥3,280' },
    { id:'e3', seq:3, from:'n3', to:'n4', relation:'private',    style:'rail-solid',  color:'#3B82F6', label:'箱根登山鉄道',stamp:'tram-front',  cost:'¥360'   },
    { id:'e4', seq:4, from:'n4', to:'n5', relation:'bus',        style:'orth-h',      color:'#F59E0B', label:'観光バス',    stamp:'bus',         cost:'¥780', cornerR:18 },
    { id:'e5', seq:5, from:'n5', to:'n6', relation:'walk',       style:'dotted',      color:'#64748B', label:'徒歩',        stamp:'footprints',  cost:'',
      customOffset: -45 },  // 右側へ少し湾曲
    { id:'e6', seq:6, from:'n6', to:'n7', relation:'walk',       style:'dotted',      color:'#64748B', label:'朝の散歩',    stamp:'footprints',  cost:''       },
    { id:'e7', seq:7, from:'n7', to:'n8', relation:'sea',        style:'wavy',        color:'#0891B2', label:'海賊船',      stamp:'ship',        cost:'¥1,200' },
    { id:'e8', seq:8, from:'n8', to:'n9', relation:'bus',        style:'orth-v',      color:'#F59E0B', label:'路線バス',    stamp:'bus',         cost:'¥520', cornerR:22,
      annoOffset:{ dx: 0, dy: 60 } },  // 時刻チップをもう少し下へ
  ];

  const groups = [
    { id:'g1', name:'1日目 (4/28)', color:'#10A37F', showName:true,
      nodeIds:['n1','n2','n3','n4','n5','n6'],
      labelOffset:{ dx: -260, dy: -30 } },  // pull the label far left so it doesn't overlap n2/n3
    { id:'g2', name:'2日目 (4/29)', color:'#3B82F6', showName:true,
      nodeIds:['n6','n7','n8','n9'],
      labelOffset:{ dx: 280, dy: 150 } },   // もう少し下へ
  ];

  // Three notes anchored to varied targets — node, edge, node — so the
  // dotted-link feature is immediately legible.
  const notes = [
    { id:'note1', x: 1170, y: 130, color:'#FFFBEB',
      text: '🍳 大涌谷の黒卵\n寿命が7年延びると言われる\n名物。1袋500円。',
      anchor: { type:'node', id:'n5' } },

    { id:'note2', x: 380, y: 300, color:'#F0F9FF',   // 新幹線ラインの下側へ
      text: '💡 こだまは指定席が安心\n えきねっとで事前購入可能',
      anchor: { type:'edge', id:'e2' } },

    { id:'note3', x: 30,   y: 740, color:'#F0FDF4',
      text: '🚄 ロマンスカー予約\n小田原 → 新宿 約75分\n指定席は要予約',
      anchor: { type:'node', id:'n9' } },
  ];

  return {
    title:'箱根 1泊2日 旅程',
    subtitle:'東京 → 箱根（新幹線・登山鉄道・海賊船コース）',
    nodes, edges, groups, notes,
  };
}

// ── Demo: business trip (Osaka 1泊2日 商談) ───────────────────
// Round-trip layout that mirrors the travel demo's structure but with
// business-specific node types (offices, hotel, station) and realistic
// expense values. Showcases the 5-category cost split: 旅費交通費 (shinkansen,
// taxi), 宿泊費 (hotel), 雑費 (default), 会議費, 交際費 (lunch meeting).
function buildBusinessDemoData(){
  const D1 = '2026-04-15';
  const D2 = '2026-04-16';
  const nodes = [
    // Day 1: home → Tokyo → Osaka → meeting → hotel
    { id:'b1', name:'自宅',          subtitle:'出発',
      icon:'home', shape:'circle', x:160, y:200, color:'#ECFDF5',
      labelPos:'left',
      arrival:'', departure:`${D1}T06:30`, cost:'' },

    { id:'b2', name:'東京駅',         subtitle:'のぞみ乗車',
      icon:'train-front', shape:'square', x:380, y:200, color:'#ECFDF5',
      labelPos:'top',
      arrival:`${D1}T07:00`, departure:`${D1}T07:33`, cost:'' },

    { id:'b3', name:'新大阪駅',       subtitle:'到着',
      icon:'train-front', shape:'square', x:640, y:200, color:'#ECFDF5',
      labelPos:'top',
      arrival:`${D1}T09:55`, departure:`${D1}T10:10`, cost:'' },

    { id:'b4', name:'取引先A社',      subtitle:'商談 + ランチ会食',
      icon:'building-2', shape:'square', x:900, y:200, color:'#F0F9FF',
      labelPos:'top',
      highlight:'#EF4444',  // 外せない商談相手 — 赤のハイライト
      arrival:`${D1}T10:25`, departure:`${D1}T17:30`, cost:'¥4,500' },

    { id:'b5', name:'大阪のホテル',   subtitle:'1泊',
      icon:'bed', shape:'circle', x:900, y:400, color:'#FAF5FF',
      labelPos:'right',
      arrival:`${D1}T18:00`, departure:`${D2}T08:00`, cost:'¥10,800' },

    // Day 2: hotel → meeting B → return
    { id:'b6', name:'取引先B社',      subtitle:'商談',
      icon:'building-2', shape:'square', x:640, y:400, color:'#F0F9FF',
      labelPos:'left',                       // 名前を左側へ
      annoOffset:{ dx: -100, dy: -45 },     // 滞在時間チップを左上へ
      highlight:'#3B82F6',  // 重要会議 — 青のハイライト
      arrival:`${D2}T08:30`, departure:`${D2}T11:30`, cost:'' },

    { id:'b7', name:'新大阪駅',       subtitle:'のぞみ乗車',
      icon:'train-front', shape:'square', x:640, y:560, color:'#ECFDF5',
      labelPos:'bottom',
      arrival:`${D2}T11:50`, departure:`${D2}T12:18`, cost:'' },

    { id:'b8', name:'東京駅',         subtitle:'到着',
      icon:'train-front', shape:'square', x:380, y:560, color:'#ECFDF5',
      labelPos:'bottom',
      arrival:`${D2}T14:48`, departure:`${D2}T15:00`, cost:'' },

    { id:'b9', name:'自宅',           subtitle:'帰着',
      icon:'home', shape:'circle', x:160, y:560, color:'#FEF3C7',
      labelPos:'left',
      arrival:`${D2}T15:45`, departure:'', cost:'' },
  ];

  const edges = [
    { id:'be1', seq:1, from:'b1', to:'b2', relation:'jr',         style:'rail-solid',  color:'#10A37F', label:'山手線',           stamp:'train-front', cost:'¥190' },
    { id:'be2', seq:2, from:'b2', to:'b3', relation:'shinkansen', style:'rail-double', color:'#0E8C5A', label:'のぞみ',           stamp:'train-front', cost:'¥14,720' },
    { id:'be3', seq:3, from:'b3', to:'b4', relation:'car',        style:'oneway',      color:'#475569', label:'タクシー',         stamp:'taxi',        cost:'¥1,200' },
    { id:'be4', seq:4, from:'b4', to:'b5', relation:'car',        style:'oneway',      color:'#475569', label:'タクシー',         stamp:'taxi',        cost:'¥800' },
    { id:'be5', seq:5, from:'b5', to:'b6', relation:'walk',       style:'dotted',      color:'#64748B', label:'徒歩',             stamp:'footprints',  cost:'' },
    { id:'be6', seq:6, from:'b6', to:'b7', relation:'car',        style:'oneway',      color:'#475569', label:'タクシー',         stamp:'taxi',        cost:'¥800',
      annoOffset:{ dx: -60, dy: 0 } },   // 料金チップを左側へ
    { id:'be7', seq:7, from:'b7', to:'b8', relation:'shinkansen', style:'rail-double', color:'#0E8C5A', label:'のぞみ',           stamp:'train-front', cost:'¥14,720' },
    { id:'be8', seq:8, from:'b8', to:'b9', relation:'jr',         style:'rail-solid',  color:'#10A37F', label:'山手線',           stamp:'train-front', cost:'¥190' },
  ];

  const groups = [
    { id:'bg1', name:'1日目 (4/15)', color:'#10A37F', showName:true,
      nodeIds:['b1','b2','b3','b4','b5'],
      labelOffset:{ dx: -260, dy: -30 } },
    { id:'bg2', name:'2日目 (4/16)', color:'#3B82F6', showName:true,
      nodeIds:['b5','b6','b7','b8','b9'],
      labelOffset:{ dx: 280, dy: 150 } },     // もう少し下へ
  ];

  const notes = [
    { id:'bnote1', x: 1080, y: 130, color:'#FFFBEB',
      text: '🤝 取引先A社\n会議室は3階\n受付で予約済み',
      anchor: { type:'node', id:'b4' } },

    { id:'bnote2', x: 220, y: 300, color:'#F0F9FF',   // 新幹線ラインの下・左側へ
      text: '🎫 EX予約で事前購入済み\n往復 ¥29,440',
      anchor: { type:'edge', id:'be2' } },

    { id:'bnote3', x: 1080, y: 480, color:'#F5F3FF',
      text: '🏨 領収書必須\n¥10,800 (税込)\n朝食付きプラン',
      anchor: { type:'node', id:'b5' } },
  ];

  return {
    title:'大阪 1泊2日 出張',
    subtitle:'商談 → 1泊 → 商談 → 帰京（経費精算 CSV 対応サンプル）',
    nodes, edges, groups, notes,
  };
}

window.App = App;
window.buildTravelDemoData = buildTravelDemoData;
window.buildBusinessDemoData = buildBusinessDemoData;
