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