// SummaryBar — bottom strip aggregating itinerary metrics. // // Datetime model: // - 開始 = earliest arrival OR departure across all nodes // - 終了 = latest arrival OR departure across all nodes // - 総旅程時間 = 終了 - 開始 (only shown when at least 2 nodes have times) // // Costs: // - 旅費 = sum of edge.cost // - 諸費用 = sum of node.cost // - 合計 = 旅費 + 諸費用 // // Per-group breakdown shows costs only — group time would be misleading // across overnight stays (group "Day 1" might span 18 hours including the // overnight wait, which isn't a useful display). // ── Cost categorization ──────────────────────────────────────── // Auto-derive the expense category from node properties. 'bed' / 'tent' // icons → 宿泊費 (lodging); everything else → 雑費 (misc). Edges are always // 旅費交通費 (transport) by definition. Users can override per-node via // node.costCategory, otherwise we infer from the icon. function nodeCostCategory(node){ if (!node) return 'misc'; if (node.costCategory) return node.costCategory; if (node.icon === 'bed' || node.icon === 'tent') return 'lodging'; return 'misc'; } window.nodeCostCategory = nodeCostCategory; function parseCost(s){ if (!s) return 0; // Strip thousands separators, whitespace, and common currency symbols. // 円 (suffix) and the multi-char prefixes A$ / CHF need to come out too // so a string like "1,200円" or "A$25.50" yields just the digits. const txt = String(s) .replace(/A\$/g, '') .replace(/CHF/gi, '') .replace(/[,¥$€£円元₩฿₫\s]/g, '') .trim(); const n = parseFloat(txt); return isFinite(n) ? n : 0; } // Format an amount with a specific currency prefix. Integer currencies // (yen, won, etc.) display whole numbers; fractional currencies (USD, // EUR, etc.) preserve up to 2 decimal places to avoid rounding loss. function formatCostWith(symbol, amt){ const sym = symbol || '¥'; const v = Math.round((amt || 0) * 100) / 100; if (v === Math.floor(v)) return sym + v.toLocaleString('en-US'); return sym + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } // Backwards-compat alias used by existing call sites that assume yen. function formatCost(amt){ return formatCostWith('¥', amt); } function SummaryBar({ edges, groups, nodes, defaultCurrency }){ if (!edges) return null; nodes = nodes || []; const dt = window.tourDateTime; const fallbackCur = defaultCurrency || '¥'; const extractCur = window.extractCurrency || ((_s, fb) => fb || '¥'); // Trip span — earliest known time → latest known time. let earliest = null, latest = null; nodes.forEach(n => { [n.arrival, n.departure].forEach(s => { const d = dt?.toDate(s); if (!d) return; if (!earliest || d < earliest) earliest = d; if (!latest || d > latest) latest = d; }); }); const tripMins = (earliest && latest) ? Math.round((latest - earliest) / 60000) : null; // Cost totals split into 5 categories AND further broken out per // currency symbol (so a trip mixing ¥ and $ shows two subtotals // instead of an incorrect lumped sum). Each category bucket is a Map // keyed by the detected currency symbol; values are running totals. const addToBucket = (bucket, sym, amt) => { bucket.set(sym, (bucket.get(sym) || 0) + amt); }; const transportBucket = new Map(); const lodgingBucket = new Map(); const miscBucket = new Map(); const meetingBucket = new Map(); const entertainmentBucket = new Map(); const grandBucket = new Map(); edges.forEach(e => { const c = parseCost(e.cost); if (c <= 0) return; const sym = extractCur(e.cost, fallbackCur); addToBucket(transportBucket, sym, c); addToBucket(grandBucket, sym, c); }); nodes.forEach(n => { const c = parseCost(n.cost); if (c <= 0) return; const sym = extractCur(n.cost, fallbackCur); const cat = nodeCostCategory(n); if (cat === 'lodging') addToBucket(lodgingBucket, sym, c); else if (cat === 'meeting') addToBucket(meetingBucket, sym, c); else if (cat === 'entertainment') addToBucket(entertainmentBucket, sym, c); else addToBucket(miscBucket, sym, c); addToBucket(grandBucket, sym, c); }); // Render helper: take a bucket Map and return a "¥3,450 + $25.50"-style // string. Order: defaultCurrency first, then others sorted by symbol. const renderBucket = (bucket) => { const entries = [...bucket.entries()].filter(([_, v]) => v > 0); if (entries.length === 0) return formatCostWith(fallbackCur, 0); entries.sort((a, b) => { if (a[0] === fallbackCur) return -1; if (b[0] === fallbackCur) return 1; return a[0] < b[0] ? -1 : 1; }); return entries.map(([sym, v]) => formatCostWith(sym, v)).join(' + '); }; const hasAny = (bucket) => [...bucket.values()].some(v => v > 0); // Per-group cost chips were previously rendered on the right side of this // bar — removed per design feedback because they overlapped with the // schedule's per-day breakdown and added noise without clear value. return (