// 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 === '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 (
);
}
// 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 (
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 (
);
}
// ── 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 */}
{/* 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).
{/* 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=>(
))}
{/* 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