// Right-side inspector — shows different fields per selection type // (node / edge / group / note). function Inspector({ selection, setSelection, nodes, edges, groups, notes, defaultCurrency, updateNode, updateEdge, updateNote, deleteNode, deleteEdge, deleteNote, duplicateNode, duplicateNote, addNode, addNote, clearAll, onLoadDemo, onAutoGroupByDate, createGroupFromNode, updateGroup, deleteGroup, composingGroupId, setComposingGroupId, notePickMode, startNoteAnchorPick, edgePickMode, startEdgeRetargetPick, }){ groups = groups || []; notes = notes || []; if (!selection){ return (

はじめかた

・ツールバーの+ノードで地点を追加
+ノード ▾で駅・空港・ホテルなどから選んで追加
・地点アイコン右上のを別の地点へドラッグ → 矢印で接続
・矢印・ノード・メモをクリックで編集
{/* Auto-grouping by date — duplicates the toolbar's ・・・ menu entry, surfaced here for discoverability since users typically come back to the sidebar after entering all datetimes. */} {onAutoGroupByDate && ( )}
何も選択されていません。
キャンバスのノード・矢印・メモをクリックしてください。
); } if (selection.type === 'node'){ const node = nodes.find(n=>n.id===selection.id); if (!node) return null; const nodeGroups = groups.filter(g => (g.nodeIds || []).includes(node.id)); return (
); } if (selection.type === 'edge'){ const edge = edges.find(e=>e.id===selection.id); if (!edge) return null; return (
); } if (selection.type === 'group'){ const group = groups.find(g=>g.id===selection.id); if (!group) return null; return (
); } if (selection.type === 'note'){ const note = notes.find(n=>n.id===selection.id); if (!note) return null; return (
); } return null; } // ── DateTimeField — HTML5 datetime-local with quick-fill chips ─ // `quickActions` is an array of { label, value } where value is the // datetime ISO to set on click (or '' to clear). Quick actions disable // when their `value` is empty / unavailable. // `min` is the earliest selectable datetime — used to block picking times // that would travel BACKWARD through the edge graph (e.g. setting a // destination's arrival to a date earlier than the origin's departure). // HTML5 datetime-local respects `min` in the picker UI; we also show an // explicit error if a typed value violates it. function DateTimeField({ value, onChange, quickActions, error, hint, min }){ const id = React.useMemo(()=>'dt_' + Math.random().toString(36).slice(2,8), []); return (
onChange(e.target.value)} style={{ border:'1px solid var(--line)', background:'var(--paper)', borderRadius:8, padding:'7px 10px', fontSize:13, fontFamily:'inherit', width:'100%', outline:'none', borderColor: error ? 'var(--danger)' : undefined, }}/> {(quickActions || []).length > 0 && (
{quickActions.map((qa, i) => ( ))}
)} {error && (
{error.replace(/^⚠\s*/, '')}
)} {hint && !error && (
{hint}
)}
); } // Find the "previous" node — i.e. the from-node of the lowest-seq incoming edge. // Used to derive sensible quick-fill defaults like "same as prev departure". function findPrevNode(nodeId, nodes, edges){ const incoming = edges.filter(e => e.to === nodeId) .sort((a,b)=>(a.seq||999)-(b.seq||999)); if (!incoming.length) return null; return nodes.find(n => n.id === incoming[0].from) || null; } // Latest "must-be-after" boundary time for a node's ARRIVAL. Walks all // incoming edges and returns the maximum departure (or arrival as fallback) // among predecessor nodes. Returns null if there is no constraint — e.g. // the node has no incoming edges, or no predecessor has any datetime set. // The returned ISO string is suitable for the HTML5 datetime-local `min` // attribute and our internal validation messages. function getEarliestArrivalBoundary(nodeId, nodes, edges){ let latest = null; edges.filter(e => e.to === nodeId).forEach(e => { const fromNode = nodes.find(n => n.id === e.from); if (!fromNode) return; // Prefer the predecessor's departure (real "moment they left"); fall // back to its arrival if departure isn't recorded. const candidate = fromNode.departure || fromNode.arrival; if (!candidate) return; if (!latest || candidate > latest) latest = candidate; }); return latest; } // (SeqDirectInput removed — sequence numbers are auto-derived from each // node's arrival/departure times, so manual entry is no longer needed.) // ── Validated input components ───────────────────────────────── // StayTimeInput: number field + unit picker (分 / 時間 / 日). Compose the // text value as `${num}${unit}`. Free-form compound text like "1時間30分" is // preserved as-is — we mark it valid since the SummaryBar parser handles it. function StayTimeInput({ value, onChange }){ const initial = parseStayTimeForInput(value); const [num, setNum] = React.useState(initial.num); const [unit, setUnit] = React.useState(initial.unit); const [freeForm, setFreeForm] = React.useState(initial.freeForm); const [err, setErr] = React.useState(false); // Re-sync from outside (e.g. when user switches between nodes) React.useEffect(()=>{ const p = parseStayTimeForInput(value); setNum(p.num); setUnit(p.unit); setFreeForm(p.freeForm); setErr(false); }, [value]); const commit = (newNum, newUnit) => { if (newNum === '' || newNum == null){ onChange(''); setErr(false); return; } const n = Number(newNum); if (!isFinite(n) || isNaN(n)) { setErr(true); return; } setErr(false); onChange(`${n}${newUnit}`); }; if (freeForm){ // Compound entry like "1時間30分" — keep editable as plain text, no unit picker. return (
onChange(e.target.value)} style={{width:'100%'}} placeholder="30分"/>
複合表記が検出されました。
); } return (
{ setNum(e.target.value); commit(e.target.value, unit); }} style={{width:90, borderColor: err ? 'var(--danger)' : undefined}} placeholder="30"/>
{['分','時間','日'].map(u=>( ))}
{err && (
数値で入力してください(例: 30 / 1.5 / 2)
)}
); } // Parse a stayTime string into { num, unit, freeForm }. // "30分" → {num:'30', unit:'分', freeForm:false} // "1.5時間" → {num:'1.5', unit:'時間', freeForm:false} // "1時間30分" → {num:'', unit:'分', freeForm:true} ← compound form // "" → {num:'', unit:'分', freeForm:false} function parseStayTimeForInput(s){ if (!s) return { num:'', unit:'分', freeForm:false }; const txt = String(s).trim(); // Compound form: contains 時間 AND a 分/分後 OR 時 AND 分 if (/(時間|h).*(分|m)/.test(txt) || /(\d+).*(時間|h).*(\d+).*(分|m)/.test(txt)){ return { num:'', unit:'分', freeForm:true }; } const m = txt.match(/^(\d+(?:\.\d+)?)\s*(分|時間|日|h\b|m\b|d\b|min)?$/); if (m){ let unit = m[2] || '分'; if (unit === 'h') unit = '時間'; else if (unit === 'm' || unit === 'min') unit = '分'; else if (unit === 'd') unit = '日'; return { num: m[1], unit, freeForm:false }; } // Unparseable — but treat as free-form so the user sees their text. return { num:'', unit:'分', freeForm:true }; } // Validated free-form duration input for edge.duration (transit time on a leg). // Accepts compound forms like "1時間30分". Highlights the field if input // can't be parsed at all. function DurationInput({ value, onChange, placeholder }){ const [v, setV] = React.useState(value || ''); React.useEffect(()=>setV(value || ''), [value]); const parsed = parseDurationLocal(v); const looksValid = !v || parsed > 0; return (
{ setV(e.target.value); onChange(e.target.value); }} placeholder={placeholder || '45分 / 1時間30分 / 2h'} style={{borderColor: !looksValid ? 'var(--danger)' : undefined, width:'100%'}}/> {!looksValid && (
認識できません。例: 45分 / 1時間30分 / 2h
)}
); } // Validated cost input. Accepts ¥420, 420円, 1,200, $8.50 etc. Just checks // that there's a number in there. // // Convenience: when the user types digits with no currency symbol // (e.g. "420" or "1,200"), we auto-prefix the trip's defaultCurrency on // blur. Only kicks in when no symbol is present AND the input is purely // numeric — leaves "$8.50" / "420円" etc. untouched. Triggered on blur // (not onChange) so typing isn't disrupted. function CostInput({ value, onChange, placeholder, defaultCurrency }){ const [v, setV] = React.useState(value || ''); React.useEffect(()=>setV(value || ''), [value]); const cleaned = (v || '').replace(/[,¥$€£\s円元₩฿₫]/g, '').replace(/^A\$/,'').replace(/^CHF/i,'').trim(); const num = Number(cleaned); const looksValid = !v || (cleaned !== '' && isFinite(num) && !isNaN(num)); const symbol = defaultCurrency || '¥'; const onBlur = () => { const t = (v || '').trim(); if (!t) return; // Has any currency symbol (¥/$/€/£/円/元/₩/฿/₫/A$/CHF)? Leave it alone. if (/[¥$€£円元₩฿₫]/.test(t) || /A\$/.test(t) || /CHF/i.test(t)) return; // Pure number (digits, commas, optional single decimal point)? if (/^\d{1,3}(,\d{3})*(\.\d+)?$|^\d+(\.\d+)?$/.test(t)){ const next = symbol + t; setV(next); onChange(next); } }; return (
{ setV(e.target.value); onChange(e.target.value); }} onBlur={onBlur} placeholder={placeholder || (symbol + '420')} style={{borderColor: !looksValid ? 'var(--danger)' : undefined, width:'100%'}}/> {!looksValid && (
数値が見つかりません。例: {symbol}420 / 1,200円 / 500
)}
); } function parseDurationLocal(s){ if (!s) return 0; const txt = String(s).trim().toLowerCase(); const hm = txt.match(/(\d+(?:\.\d+)?)\s*h(?:ours?|r)?\s*(\d+(?:\.\d+)?)?/); if (hm) return parseFloat(hm[1])*60 + (hm[2] ? parseFloat(hm[2]) : 0); const h2 = txt.match(/(\d+(?:\.\d+)?)\s*(?:h\b|時間)/); if (h2) return parseFloat(h2[1]) * 60; const d2 = txt.match(/(\d+(?:\.\d+)?)\s*(?:d\b|day|日)/); if (d2) return parseFloat(d2[1]) * 1440; const m2 = txt.match(/(\d+(?:\.\d+)?)\s*(?:m(?:in)?\b|分)/); if (m2) return parseFloat(m2[1]); // Compound 1時間30分 const cm = txt.match(/(\d+(?:\.\d+)?)\s*(?:時間|h)\s*(\d+(?:\.\d+)?)\s*(?:分|m)/); if (cm) return parseFloat(cm[1])*60 + parseFloat(cm[2]); const n = parseFloat(txt); return isFinite(n) ? n : 0; } // ── Node inspector ───────────────────────────────────────────── function NodeInspector({node, allNodes, allEdges, defaultCurrency, updateNode, deleteNode, duplicateNode, nodeGroups, createGroupFromNode, updateGroup, deleteGroup, setComposingGroupId, setSelection}){ nodeGroups = nodeGroups || []; const fileRef = React.useRef(null); const onFile = async (e) => { const f = e.target.files?.[0]; if (!f) return; try { const dataUrl = await compressImage(f); updateNode(node.id, { image: dataUrl }); } catch(err) { console.error('Image compression failed', err); } e.target.value = ''; }; return ( <>

地点(ノード)編集

updateNode(node.id,{name:e.target.value})} placeholder="駅名・観光地名など"/>
updateNode(node.id,{subtitle:e.target.value})} placeholder="例: 乗換 / 1泊 / 名物の○○"/>

到着・出発・諸費用

{(() => { const prev = findPrevNode(node.id, allNodes, allEdges); const arr = node.arrival || ''; const dep = node.departure || ''; const dt = window.tourDateTime; // Lower bounds for the date pickers — block picking times that // would travel backward through the edge graph (a frequent typo // when manually entering datetimes). // - arrival's min = the latest predecessor's departure (across // ALL incoming edges, not just the lowest-seq one) // - departure's min = max(this node's arrival, arrival's min) const minArr = getEarliestArrivalBoundary(node.id, allNodes, allEdges); const minDep = arr || minArr || null; // Validation (still surfaces errors for typed-in values that bypass // the picker's `min` enforcement, especially on older browsers). let arrErr = null, depErr = null; if (arr && dep && dt){ const m = dt.minutesBetween(arr, dep); if (m != null && m < 0) depErr = '⚠ 出発が到着より前になっています'; } if (minArr && arr && dt){ const m = dt.minutesBetween(minArr, arr); if (m != null && m < 0) arrErr = `⚠ 到着が前ノードの出発(${dt.formatTime(minArr)})より前になっています`; } // Quick-fill action sets const arrQuick = [ { label: prev?.name ? `「${prev.name}」の出発と同じ` : '前ノードの出発と同じ', value: prev?.departure || null, title: prev?.departure ? dt?.formatTime(prev.departure) : '' }, arr ? { label: 'クリア', value: '' } : null, ].filter(Boolean); const depQuick = arr ? [ { label: '到着+15分', value: dt?.addMinutes(arr, 15) }, { label: '+30分', value: dt?.addMinutes(arr, 30) }, { label: '+1時間', value: dt?.addMinutes(arr, 60) }, { label: '+2時間', value: dt?.addMinutes(arr, 120) }, dep ? { label: 'クリア', value: '' } : null, ].filter(Boolean) : (dep ? [{ label:'クリア', value:'' }] : []); // Compose hints — surface the constraint reason so the user // understands why earlier dates are unavailable. const arrHintBoundary = minArr && dt ? `前の地点の出発(${dt.formatTime(minArr)})以降のみ選択可` : null; const depHintBoundary = arr && dt ? `到着(${dt.formatTime(arr)})以降のみ選択可` : (minArr && dt ? `前の地点の出発(${dt.formatTime(minArr)})以降のみ選択可` : null); return ( <>
updateNode(node.id, {arrival:v})} quickActions={arrQuick} error={arrErr} min={minArr} hint={!arr ? (arrHintBoundary || '出発地点なら空のままでOK') : arrHintBoundary}/>
updateNode(node.id, {departure:v})} quickActions={depQuick} error={depErr} min={minDep} hint={!dep ? (depHintBoundary || '到着地点(最終地)なら空のままでOK') : depHintBoundary}/>
{arr && dep && !depErr && dt && (() => { const m = dt.minutesBetween(arr, dep); if (m == null || m <= 0) return null; return (
この地点での所要時間: {dt.formatDuration(m)}
); })()} ); })()}
{(() => { // Auto-detected category from icon (bed/tent → 宿泊費, else → 雑費). // node.costCategory is an optional explicit override that supports // 4 expense buckets: 宿泊費 / 雑費 / 会議費 / 交際費. const auto = (node.icon === 'bed' || node.icon === 'tent') ? 'lodging' : 'misc'; const current = node.costCategory || auto; const isOverride = node.costCategory && node.costCategory !== auto; const labelOf = (k) => ({lodging:'宿泊費', misc:'雑費', meeting:'会議費', entertainment:'交際費'}[k] || '雑費'); const colorOf = (k) => ({ lodging: { bg:'#F5F3FF', fg:'#7C3AED' }, misc: { bg:'#FFFBEB', fg:'#B45309' }, meeting: { bg:'#EFF6FF', fg:'#2563EB' }, entertainment: { bg:'#FDF2F8', fg:'#DB2777' }, }[k] || { bg:'#FFFBEB', fg:'#B45309' }); const c = colorOf(current); // Toggle helper: clicking the auto-derived category clears the // explicit override (returns to auto-mode). const setCat = (k) => updateNode(node.id, { costCategory: k === auto ? undefined : k }); return ( <> updateNode(node.id, {cost:v})} defaultCurrency={defaultCurrency} placeholder={(defaultCurrency || '¥') + '500'}/>
デフォルトは アイコンが ホテル / テント なら「宿泊費」、それ以外は「雑費」。会議費・交際費にしたい場合は手動で切替できます。 {isOverride && (手動指定中)}
); })()}
矢印(区間)の移動時間は、前ノードの出発と次ノードの到着から自動算出されます。
キャンバスのチップはドラッグで自由に移動できます。

アイコン

{node.image && }
updateNode(node.id,{icon:name, image:null, emoji:null})} onClear={()=>updateNode(node.id,{icon:null})} />
{['#EAB308','#EF4444','#F97316','#10A37F','#3B82F6','#7C3AED','#EC4899','#1F2937'].map(c=>(
「今回の目玉スポット」「外せない商談相手」など、強調したい地点に色枠を付けられます。工程表にも反映されます。
{NODE_COLORS.map(c=>(
{[['circle','まる'],['square','四角'],['bubble','吹き出し']].map(([v,l])=>( ))}
{[['top','上'], ['left','左'], ['bottom','下'], ['right','右']].map(([v,l])=>( ))}
{duplicateNode && ( )}
{/* Day group membership */} {nodeGroups.length > 0 ? (
所属グループ ({nodeGroups.length})
{nodeGroups.map(ng => (
{ng.name || '(無名)'}
))}
) : ( )} ); } // ── Icon picker (Lucide) ─────────────────────────────────────── function IconPicker({ selected, onSelect, onClear }){ return (
{window.TOUR_ICON_GROUPS?.map(group => (
{group.label}
{group.icons.map(name => ( ))}
))}
); } // ── Edge inspector ───────────────────────────────────────────── // Reverse-direction button — looks grayed-out (not strictly disabled) when // reversal would produce a backward-in-time edge. Clicking the grayed // version reveals the warning instead of always showing it inline. function ReverseEdgeButton({ edge, from, to, updateEdge }){ const dt = window.tourDateTime; const newFromTime = to?.departure || to?.arrival; const newToTime = from?.arrival || from?.departure; let blocked = false, blockReason = ''; if (dt && newFromTime && newToTime){ const m = dt.minutesBetween(newFromTime, newToTime); if (m != null && m < 0){ blocked = true; blockReason = `反転すると ${to?.name || '行き先'}(${dt.formatTime(newFromTime)})→ ${from?.name || '出発地点'}(${dt.formatTime(newToTime)})になり、時間が逆行します`; } } const [showReason, setShowReason] = React.useState(false); // Hide the warning if blocked-state changes (e.g. user fixed the times). React.useEffect(() => { if (!blocked) setShowReason(false); }, [blocked]); return ( <>
{blocked && showReason && (
{blockReason}
)} ); } function EdgeInspector({edge, totalEdges, edges, nodes, defaultCurrency, updateEdge, deleteEdge, edgePickMode, startEdgeRetargetPick}){ const from = nodes.find(n=>n.id===edge.from); const to = nodes.find(n=>n.id===edge.to); const rel = RELATIONS.find(r=>r.id===edge.relation) || RELATIONS[0]; const isOrth = edge.style === 'orth-h' || edge.style === 'orth-v'; // Auto-derived transit minutes from the from-node's departure to the // to-node's arrival. Read-only — the user fills these times on the nodes. const transitMins = (window.tourDateTime && from?.departure && to?.arrival) ? window.tourDateTime.minutesBetween(from.departure, to.arrival) : null; return ( <>

矢印(区間)編集

{/* Endpoint editing — click-to-pick a new from/to node, or swap. */}
{/* Grid cells default to min-width=auto, so a long node name was expanding the cell and pushing the layout. min-width:0 on the button (grid item) lets the inner span ellipsis-truncate. display:flex makes flex-direction:column actually take effect (without it, the spans fall back to inline-block layout). */}
✎ ボタンを押してから、地図上の新しい地点をクリックすると経路が変更されます。予定変更や挿入に便利です。
{/* (Manual sequence-number UI removed — order is auto-derived from each node's arrival/departure times. Numbers shuffle when you edit times.) */}
{RELATIONS.map(r=>( ))}
updateEdge(edge.id,{label:e.target.value})} placeholder="例: 山手線・こだま号・徒歩"/>
{STAMPS.map(name=>( ))}
{/* Cost only — transit time is auto-derived from connected nodes' arrival/departure */}

旅費・所要時間

updateEdge(edge.id,{cost:v})} defaultCurrency={defaultCurrency} placeholder={(defaultCurrency || '¥') + '420 / 1,200円'}/>
{/* Auto-derived transit time — read-only readout */}
0 ? 'var(--ink)' : 'var(--ink-muted)', }}> {transitMins == null ? 「{from?.name||'?'}」の出発と「{to?.name||'?'}」の到着を入力すると自動計算されます : transitMins <= 0 ? 出発が到着より後ろになっています : {window.tourDateTime?.formatDuration(transitMins)} ({window.tourDateTime?.formatTime(from.departure)} → {window.tourDateTime?.formatTime(to.arrival)}) }
チップはドラッグで自由に動かせます(離れると点線でリンク)。
{edge.annoOffset && (Math.abs(edge.annoOffset.dx)>2 || Math.abs(edge.annoOffset.dy)>2) && ( )}

矢印のスタイル

{/* twoway (両方向 ⇄) は廃止 — 既存データのために描画は残すが、 ピッカーには出さない(新規作成は不可)。 */} {ARROW_STYLES.filter(s => s.id !== 'twoway').map(s=>( ))}
{isOrth && (
updateEdge(edge.id,{cornerR: Number(e.target.value)},{skipHistory:true})} onMouseUp={e=>updateEdge(edge.id,{cornerR: Number(e.target.value)})} onTouchEnd={e=>updateEdge(edge.id,{cornerR: Number(e.target.value)})} style={{flex:1}}/> {Math.round(typeof edge.cornerR === 'number' ? edge.cornerR : 8)}
0 で直角、大きくするほどなめらかに丸まります。
キャンバスの角の○ハンドルを内側にドラッグしても調整できます。
)}
{EDGE_COLOR_PRESETS.map(p=>(
{!isOrth && edge.customOffset !== undefined && edge.customOffset !== 0 && ( )}
{/* Arrow-delete button removed — arrows are auto-managed from each node's times. To remove a leg, change the connected nodes' times (or delete a node), and the arrow disappears automatically. */}
{' '} 矢印は地点の時刻から自動生成されるため、矢印自体は削除できません。
消したい場合は前後の地点の時刻を変えるか、地点を削除してください。
💡 別の矢印を Shift+クリック でその矢印の設定(交通手段・色・運賃・ラベル等)をこちらにコピー
); } // ── Group inspector ──────────────────────────────────────────── function GroupInspector({group, nodes, updateGroup, deleteGroup, setComposingGroupId, setSelection}){ const members = (group.nodeIds || []) .map(nid => nodes.find(n => n.id === nid)) .filter(Boolean); const presets = ['#10A37F','#3B82F6','#F59E0B','#7C3AED','#EF4444','#0891B2','#84CC16','#1F2937']; return ( <>

グループ編集

updateGroup(group.id, {name:e.target.value})} placeholder="Day 1 / 1日目 / 朝の部 など"/>

{presets.map(c=>(

メンバー ({members.length})

{members.map(n => (
{n.icon ? : (n.emoji || (n.name||'?').charAt(0).toUpperCase())} {n.name || '(無名)'}
))}
); } // ── Note inspector ───────────────────────────────────────────── function NoteInspector({note, nodes, edges, updateNote, deleteNote, duplicateNote, notePickMode, startNoteAnchorPick}){ const colors = ['#FFFBEB','#F0FDF4','#F0F9FF','#FAF5FF','#FEF2F2','#FFF7ED','#F8FAFC']; const anchorType = note.anchor?.type || ''; const anchorId = note.anchor?.id || ''; // Resolve the anchor target's display name for the summary card. let anchorName = ''; if (anchorType === 'node'){ anchorName = nodes.find(n => n.id === anchorId)?.name || '(削除済み)'; } else if (anchorType === 'edge'){ const e = edges.find(x => x.id === anchorId); if (e){ const f = nodes.find(n => n.id === e.from)?.name || '?'; const t = nodes.find(n => n.id === e.to)?.name || '?'; anchorName = `#${e.seq ?? ''} ${e.label || ''} (${f} → ${t})`; } else anchorName = '(削除済み)'; } const isPickingThis = notePickMode && notePickMode.noteId === note.id; return ( <>

メモ編集