// 出張・旅程ジェネレーター — 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 (
setShowSchedule(true)} onAutoGroupByDate={autoGroupByDate} onAutoConnectByTime={autoConnectByTime} />
setEditingEdgeId(id)} /> {state.nodes.length === 0 && state.notes.length === 0 && (

出張・旅程ジェネレーター

地点を置いて到着・出発時刻を入れるだけ。
矢印は自動で時刻順につながり、工程がそのまま見える化されます。
カレンダー連携・経費精算CSV・多通貨(¥/$/€)対応。

)}
{showSchedule && window.Schedule && ( 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). */} {toast &&
{toast}
} {/* Custom confirm dialog — replaces native confirm() throughout the app so the wording, buttons, and motion match the tool's tone. */} {confirmDialog && (
{ if (e.target === e.currentTarget) confirmDialog.onCancel(); }} onKeyDown={(e)=>{ if (e.key === 'Escape') confirmDialog.onCancel(); if (e.key === 'Enter') confirmDialog.onConfirm(); }} tabIndex={-1}>
{confirmDialog.title}
{confirmDialog.message}
)} {shotMode && ( <>
スクリーンショットを撮ってください — {navigator.platform?.includes('Mac') ? <>++4 : <>Win+Shift+S}
)}
); } // ── 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;