// 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 (

Getting started

• Use the toolbar's + Place to add a place
+ Place ▾ picks from stations, airports, hotels, etc.
• Drag the handle on a place to another place to connect them
• Click any arrow / place / note to edit it
{/* 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 && ( )}
Nothing selected.
Click any place, arrow, or note on the canvas.
); } 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 ───────────────────────────────── // 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 && (
No number found. e.g. {symbol}420 / 1,200 / 500
)}
); } // ── 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 ( <>

Edit place

updateNode(node.id,{name:e.target.value})} placeholder="Station / sights name, etc."/>
updateNode(node.id,{subtitle:e.target.value})} placeholder="e.g. Transfer / 1 night / famous ○○"/>

Arrival · Departure · Cost

{(() => { 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 = '⚠ Departure is earlier than arrival'; } if (minArr && arr && dt){ const m = dt.minutesBetween(minArr, arr); if (m != null && m < 0) arrErr = `⚠ Arrival is earlier than the previous step's departure (${dt.formatTime(minArr)})`; } // Quick-fill action sets const arrQuick = [ { label: prev?.name ? `Same as "${prev.name}" departure` : 'Same as previous departure', value: prev?.departure || null, title: prev?.departure ? dt?.formatTime(prev.departure) : '' }, arr ? { label: 'Clear', value: '' } : null, ].filter(Boolean); const depQuick = arr ? [ { label: 'Arr +15m', value: dt?.addMinutes(arr, 15) }, { label: '+30m', value: dt?.addMinutes(arr, 30) }, { label: '+1h', value: dt?.addMinutes(arr, 60) }, { label: '+2h', value: dt?.addMinutes(arr, 120) }, dep ? { label: 'Clear', value: '' } : null, ].filter(Boolean) : (dep ? [{ label:'Clear', value:'' }] : []); // Compose hints — surface the constraint reason so the user // understands why earlier dates are unavailable. const arrHintBoundary = minArr && dt ? `Only ${dt.formatTime(minArr)} (previous departure) and later` : null; const depHintBoundary = arr && dt ? `Only ${dt.formatTime(arr)} (arrival) and later` : (minArr && dt ? `Only ${dt.formatTime(minArr)} (previous departure) and later` : null); return ( <>
updateNode(node.id, {arrival:v})} quickActions={arrQuick} error={arrErr} min={minArr} hint={!arr ? (arrHintBoundary || 'Leave empty if this is the starting point') : arrHintBoundary}/>
updateNode(node.id, {departure:v})} quickActions={depQuick} error={depErr} min={minDep} hint={!dep ? (depHintBoundary || 'Leave empty if this is the final stop') : depHintBoundary}/>
{arr && dep && !depErr && dt && (() => { const m = dt.minutesBetween(arr, dep); if (m == null || m <= 0) return null; return (
Stay at this place: {dt.formatDuration(m)}
); })()} ); })()}
{(() => { // Auto-detected category from icon (bed/tent → Lodging, else → Misc). // node.costCategory is an optional explicit override that supports // 4 expense buckets: Lodging / Misc / Meeting / Entertainment. 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:'Lodging', misc:'Misc', meeting:'Meeting', entertainment:'Entertainment'}[k] || 'Misc'); 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'}/>
Default: Hotel / Tent icons → Lodging, otherwise Misc. Override to Meeting / Entertainment manually. {isOverride && (manually overridden)}
); })()}
Edge transit time is auto-derived from the previous node's departure and the next node's arrival.
Chips on the canvas can be dragged freely.

Icon

{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=>(
Mark special places with a colored ring — "trip highlight", "must-do meeting", etc. Schedule view picks this up too.
{NODE_COLORS.map(c=>(
{[['circle','Circle'],['square','Square'],['bubble','Bubble']].map(([v,l])=>( ))}
{[['top','Top'], ['left','Left'], ['bottom','Bottom'], ['right','Right']].map(([v,l])=>( ))}
{duplicateNode && ( )}
{/* Group membership */} {nodeGroups.length > 0 ? (
Member of ({nodeGroups.length})
{nodeGroups.map(ng => (
{ng.name || '(no 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 = `Reversing would make ${to?.name || 'destination'} (${dt.formatTime(newFromTime)}) → ${from?.name || 'origin'} (${dt.formatTime(newToTime)}) — that goes backward in time`; } } 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 ( <>

Edit edge

{/* Endpoint editing — click-to-pick a new from/to node, or swap. */}
{/* min-width:0 + display:flex on the button is required for the inner span to ellipsis-truncate inside a 1fr grid cell — without them, a long node name expands the cell and breaks the layout. */}
Click ✎ then tap a new place on the map to re-route this edge — handy for plan changes or insertions.
{/* (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="e.g. Yamanote Line / Kodama / Walk"/>
{STAMPS.map(name=>( ))}
{/* Cost only — transit time is auto-derived from connected nodes' arrival/departure */}

Travel cost / duration

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 ? Set the departure of "{from?.name||'?'}" and the arrival of "{to?.name||'?'}" to auto-calculate : transitMins <= 0 ? Departure is later than arrival : {window.tourDateTime?.formatDuration(transitMins)} ({window.tourDateTime?.formatTime(from.departure)} → {window.tourDateTime?.formatTime(to.arrival)}) }
Chips can be dragged freely (a dashed link appears when detached).
{edge.annoOffset && (Math.abs(edge.annoOffset.dx)>2 || Math.abs(edge.annoOffset.dy)>2) && ( )}

Arrow style

{/* twoway (Two-way ⇄) is deprecated — rendering still works for legacy data, but the picker hides it so new edges can't use it. */} {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 = right angle; larger = smoother corner.
Or drag the ○ corner handle on the canvas inward.
)}
{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. */}
{' '} Arrows are auto-derived from each stop's times. Individual arrows can't be deleted directly.
To remove a leg, change the connected times or delete a stop.
💡 Shift+click another arrow to copy its settings (transit / color / fare / label …) onto this one
); } // ── 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 ( <>

Edit group

updateGroup(group.id, {name:e.target.value})} placeholder="Day 1 / Morning / etc."/>

Color

{presets.map(c=>(

Members ({members.length})

{members.map(n => (
{n.icon ? : (n.emoji || (n.name||'?').charAt(0).toUpperCase())} {n.name || '(no 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 || '(deleted)'; } 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 = '(deleted)'; } const isPickingThis = notePickMode && notePickMode.noteId === note.id; return ( <>

Edit note