const fs = require('fs'); const escapeHTML = (s) => String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); function generate(stations, lines, fares, nameByCode, transfers) { const enNameByCode = (() => { try { const map = {}; for (const s of stations) { map[String(s.code || '')] = s.en_name || s.enName || ''; } return map; } catch (_) { return {}; } })(); // Build linesForStation for coloring (still needed for visualization) const linesForStation = {}; (lines || []).forEach(li => { const id = li.id || ''; const name = li.name || li.cn_name || ''; const color = li.color || '#93a2b7'; const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x)); for (const code of arr) { if (!linesForStation[code]) linesForStation[code] = []; linesForStation[code].push({ id, name, color, stations: arr }); } }); const groupsMap = {}; // Optional: We can still use groups for coloring logic, but NOT for graph edges if we want strict mode. // But let's keep groupsMap for color logic (lines 30-46) as it helps determine if lines connect visually. stations.forEach(s => { const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim(); if (!k) return; (groupsMap[k] ||= []).push(String(s.code || '')); }); const groupsForColors = Object.values(groupsMap).filter(arr => arr.length > 1); const colorsForRoute = (a, b) => { const la = linesForStation[a] || []; const lb = linesForStation[b] || []; const same = la.find(x => lb.some(y => y.id === x.id)); if (same) return [same.color || '#93a2b7']; // Logic to find connecting colors via transfer groups for (const g of groupsForColors) { const startLine = la.find(li => (li.stations || []).some(code => g.includes(code))); const endLine = lb.find(li => (li.stations || []).some(code => g.includes(code))); if (startLine && endLine) { const c1 = startLine.color || '#93a2b7'; const c2 = endLine.color || '#93a2b7'; return (startLine.id === endLine.id) ? [c1] : [c1, c2]; } } return []; }; const buildGraph = () => { const adj = new Map(); const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); }; const addEdge = (u, v, w) => { addNode(u); addNode(v); adj.get(u).set(v, w); adj.get(v).set(u, w); }; // Create a set of valid station codes for fast lookup const validStationCodes = new Set(stations.map(s => String(s.code || ''))); // 1. Edges from explicit FARES only (fares || []).forEach(f => { const a = String(f.from || ''), b = String(f.to || ''); if (!a || !b) return; if (!validStationCodes.has(a) || !validStationCodes.has(b)) return; const w = Number(f.cost_regular ?? f.cost) || 0; addEdge(a, b, w); }); // 2. Edges from TRANSFERS (0 cost) if (Array.isArray(transfers)) { transfers.forEach(p => { const a = String(p[1] || ''), b = String(p[2] || ''); // startup.lua used index 1,2? No, lua is 1-based. JS is 0-based. // Wait, config transfers is [[a,b], [c,d]]. // Let's check how it's stored. usually JSON array of arrays. // In startup.lua: p[1], p[2]. // In JS: p[0], p[1]. // But let's check standard. // If I look at startup.lua: `local a, b = tostring(p[1] or ''), tostring(p[2] or '')` (Lua 1-based index) // So in JSON it is `["A", "B"]`. // In JS `p[0]` is "A", `p[1]` is "B". // Let's be safe and check both or iterate. const u = String(p[0] || ''); const v = String(p[1] || ''); if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) { addEdge(u, v, 0); } }); } // REMOVED: Edges from Lines (implicit 0 cost) // REMOVED: Edges from Groups (implicit 0 cost) - unless we want to keep it as fallback? // User said "Strictly use database data". If transfers are in DB, we use them. // If user relies on name grouping but didn't put it in DB transfers, they might be unhappy. // But "Strictly use database data" implies we shouldn't automagically connect things. // I will remove the auto-grouping for graph edges to be consistent with startup.lua changes. stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); }); return adj; }; const adj = buildGraph(); const codes = Array.from(adj.keys()).sort((a, b) => a.localeCompare(b)); const neighborsOf = (u) => adj.get(u) || new Map(); const dijkstra = (src) => { const dist = {}; const visited = new Set(); for (const n of adj.keys()) dist[n] = Infinity; dist[src] = 0; while (true) { let u = null; let best = Infinity; for (const n of adj.keys()) { if (!visited.has(n) && dist[n] < best) { best = dist[n]; u = n; } } if (u === null) break; visited.add(u); for (const [v, w] of neighborsOf(u)) { const nd = dist[u] + Number(w || 0); if (nd < dist[v]) dist[v] = nd; } } return dist; }; const distAll = {}; codes.forEach(c => { distAll[c] = dijkstra(c); }); let maxFare = 1; for (const r of codes) { for (const c of codes) { if (r === c) continue; const v = Number(distAll[r][c] || 0); if (v > maxFare) maxFare = v; } } const margin = 8; const cellW = 56; const cellH = 40; const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10)); const maxCnW = Math.max(1, ...codes.map(c => estimateWidth(nameByCode[c] || c, 14))); const maxEnW = Math.max(1, ...codes.map(c => estimateWidth(enNameByCode[c] || '', 10))); const maxCodeW = Math.max(1, ...codes.map(c => estimateWidth(c, 10))); const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW)); const topH = 64; const n = codes.length; const width = margin + leftW + n * cellW + margin; const height = margin + topH + n * cellH + margin; let svg = ``; // Column headers for (let j = 0; j < n; j++) { const code = codes[j]; const name = nameByCode[code] || code; const en = enNameByCode[code] || ''; const x = margin + leftW + j * cellW + cellW / 2; const y = margin + 20; svg += `${escapeHTML(name)}`; if (en) svg += `${escapeHTML(en)}`; svg += `${escapeHTML(code)}`; } for (let i = 0; i < n; i++) { const code = codes[i]; const name = nameByCode[code] || code; const en = enNameByCode[code] || ''; const xText = margin + 6; const baseY = margin + topH + i * cellH + 22; svg += `${escapeHTML(name)}`; if (en) svg += `${escapeHTML(en)}`; svg += `${escapeHTML(code)}`; for (let j = 0; j < n; j++) { const x0 = margin + leftW + j * cellW; const y0 = margin + topH + i * cellH; svg += ``; if (i === j) { svg += `-`; } else { const val = Math.round(Number(distAll[code][codes[j]] || 0)); const intensity = maxFare > 0 ? Math.min(1, val / maxFare) : 0; // Get route colors const routeColors = colorsForRoute(code, codes[j]); // Draw background strips for multiple lines if (routeColors.length > 0) { const stripW = cellW / routeColors.length; for (let k = 0; k < routeColors.length; k++) { const c = routeColors[k] || '#93a2b7'; const stripX = x0 + k * stripW; // Use fill-opacity for robustness against any color format svg += ``; } } else { svg += ``; } // Add cell border svg += ``; svg += `${escapeHTML(String(val))}`; // Show small color indicators at top left const dots = routeColors.slice(0, 4); for (let k = 0; k < dots.length; k++) { const cx = x0 + 8 + k * 10; const cy = y0 + 10; const dotColor = dots[k] || '#93a2b7'; svg += ``; } } } } svg += ``; return svg; } module.exports = { generate };