diff --git a/server/services/svg-generator.js b/server/services/svg-generator.js index 5ae12f4..b896f35 100644 --- a/server/services/svg-generator.js +++ b/server/services/svg-generator.js @@ -3,187 +3,197 @@ 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 = {}; - stations.forEach(s => { enNameByCode[String(s.code || '')] = s.en_name || s.enName || ''; }); + 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 }); + } + }); - // 1. Build Graph - const validStationCodes = new Set(stations.map(s => String(s.code || ''))); - 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); }; + 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 []; + }; - stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); }); + 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 || ''))); - const edgeCosts = new Map(); - (fares || []).forEach(f => { - const a = String(f.from || ''), b = String(f.to || ''); - if (a && b && validStationCodes.has(a) && validStationCodes.has(b)) { + // 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); - edgeCosts.set(`${a}|${b}`, w); - edgeCosts.set(`${b}|${a}`, w); - } - }); - - const transferSet = new Set(); - if (Array.isArray(transfers)) { - transfers.forEach(p => { - const u = String(p[0] || ''), v = String(p[1] || ''); - if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) { - addEdge(u, v, 0); - transferSet.add(`${u}|${v}`); - transferSet.add(`${v}|${u}`); - } }); + + // 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; } } - // 2. Force Directed Layout - const nodes = Array.from(validStationCodes).map(code => ({ - code, - x: Math.random() * 800, - y: Math.random() * 600, - vx: 0, vy: 0, - name: nameByCode[code] || code - })); - const nodeMap = new Map(nodes.map(n => [n.code, n])); - - // Add line connections to edges for layout purposes - const layoutEdges = []; - (lines || []).forEach(li => { - const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x)); - for (let i = 0; i < arr.length - 1; i++) { - if (nodeMap.has(arr[i]) && nodeMap.has(arr[i+1])) { - layoutEdges.push({ source: nodeMap.get(arr[i]), target: nodeMap.get(arr[i+1]), type: 'line' }); - } - } - }); - - for (const [u, edges] of adj.entries()) { - for (const [v, w] of edges.entries()) { - if (u < v && nodeMap.has(u) && nodeMap.has(v)) { - layoutEdges.push({ source: nodeMap.get(u), target: nodeMap.get(v), type: 'fare' }); - } - } + 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)}`; } - const iterations = 300; - const k = Math.sqrt((1200 * 800) / nodes.length) || 100; - for (let i = 0; i < iterations; i++) { - const t = 1 - i / iterations; - - // Repulsion - for (let a = 0; a < nodes.length; a++) { - for (let b = a + 1; b < nodes.length; b++) { - const dx = nodes[a].x - nodes[b].x; - const dy = nodes[a].y - nodes[b].y; - let dist = Math.sqrt(dx * dx + dy * dy); - if (dist === 0) { dist = 0.01; } - if (dist < k * 2) { - const force = (k * k) / dist; - const fx = (dx / dist) * force; - const fy = (dy / dist) * force; - nodes[a].vx += fx; nodes[a].vy += fy; - nodes[b].vx -= fx; nodes[b].vy -= fy; - } - } - } - - // Attraction - for (const e of layoutEdges) { - const dx = e.target.x - e.source.x; - const dy = e.target.y - e.source.y; - let dist = Math.sqrt(dx * dx + dy * dy); - if (dist === 0) dist = 0.01; - const idealDist = e.type === 'line' ? k * 0.8 : k * 1.5; - const force = (dist * dist) / idealDist; - const fx = (dx / dist) * force; - const fy = (dy / dist) * force; - e.source.vx += fx; e.source.vy += fy; - e.target.vx -= fx; e.target.vy -= fy; - } - - // Gravity - for (const n of nodes) { - const dx = 600 - n.x; - const dy = 400 - n.y; - n.vx += dx * 0.05; - n.vy += dy * 0.05; - } - - for (const n of nodes) { - n.x += n.vx * t * 0.1; - n.y += n.vy * t * 0.1; - n.vx *= 0.8; - n.vy *= 0.8; - } - } - - // Normalize coordinates - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - nodes.forEach(n => { - minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); - maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y); - }); - - const margin = 100; - nodes.forEach(n => { - n.x = n.x - minX + margin; - n.y = n.y - minY + margin; - }); - - const width = maxX - minX + margin * 2; - const height = maxY - minY + margin * 2; - - let svg = ``; - - // Draw lines - (lines || []).forEach(li => { - const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x)); - const color = li.color || '#93a2b7'; - if (arr.length < 2) return; - let d = ''; - for (let i = 0; i < arr.length; i++) { - const n = nodeMap.get(arr[i]); - if (n) { - d += (i === 0 ? 'M' : 'L') + ` ${n.x} ${n.y}`; - } - } - if (d) { - svg += ``; - } - }); - - // Draw edges (fares and transfers) - const drawnEdges = new Set(); - for (const [u, edges] of adj.entries()) { - for (const [v, w] of edges.entries()) { - if (u < v && nodeMap.has(u) && nodeMap.has(v)) { - const nu = nodeMap.get(u), nv = nodeMap.get(v); - const isTransfer = transferSet.has(`${u}|${v}`); + 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; - if (isTransfer) { - svg += ``; + // 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 += ``; } - if (w > 0) { - const cx = (nu.x + nv.x) / 2; - const cy = (nu.y + nv.y) / 2; - svg += ``; - svg += `${w}`; + // 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 += ``; } } } } - - // Draw nodes - nodes.forEach(n => { - svg += ``; - svg += ``; - svg += `${escapeHTML(n.name)}`; - svg += `${escapeHTML(n.code)}`; - svg += ``; - }); - svg += ``; return svg; } diff --git a/web/ticket-order.js b/web/ticket-order.js index 6ce9f48..a2a7b72 100644 --- a/web/ticket-order.js +++ b/web/ticket-order.js @@ -56,14 +56,49 @@ }; async function loadMap() { - mapContainer.innerHTML = '
加载地图中...
'; try { - const [res, stationsData] = await Promise.all([ - fetch('/api/public/fares/map/light?t=' + Date.now()), + const res = await fetch('/api/public/fares/map/light'); + const svg = await res.text(); + mapContainer.innerHTML = svg; + + const svgEl = mapContainer.querySelector('svg'); + if(!svgEl) return; + + renderLineMap(); + } catch(e) { + mapContainer.innerHTML = '
加载地图失败
'; + } + } + + async function renderLineMap() { + try { + const [lines, stations] = await Promise.all([ + fetch('/api/public/lines').then(r=>r.json()), fetch('/api/public/stations').then(r=>r.json()) ]); + let html = ''; + lines.forEach(line => { + const stList = line.站序 || []; + + }); + } catch(e) {} + } + + + + async function renderSystemMap() { + mapContainer.innerHTML = '
加载线路数据...
'; + try { + const [linesData, stationsData] = await Promise.all([ + fetch('/api/public/lines').then(r=>r.json()), + fetch('/api/public/stations').then(r=>r.json()) + ]); + window.cachedStationsData = stationsData; + stationTransfer = new Set(); + stationCanonicalByCode = {}; + stationCodesByCanonical = {}; stationNameByCode = {}; stationEnByCode = {}; for (const s of stationsData) { @@ -73,22 +108,182 @@ stationEnByCode[code] = String(s.en_name || s.英文名 || ''); } - const svg = await res.text(); - mapContainer.innerHTML = svg; + const transferGroups = buildTransferGroups(stationsData); + stationCanonicalByCode = transferGroups.canonicalByCode; + stationCodesByCanonical = transferGroups.codesByCanonical; + for (const codes of Object.values(stationCodesByCanonical)) { + if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code)); + } + + const lineStops = []; + const lineColors = []; + const linesByStation = new Map(); + for (let i = 0; i < linesData.length; i++) { + const line = linesData[i] || {}; + const color = line.color || line.颜色 || '#93a2b7'; + const stopsRaw = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []); + const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean); + if (stops.length === 0) continue; + lineStops.push({ idx: lineStops.length, name: line.name || line.线路名称 || '', color, stops }); + lineColors.push(color); + for (const c of stops) { + const arr = linesByStation.get(c) || []; + arr.push(lineStops.length - 1); + linesByStation.set(c, arr); + } + } + + if (lineStops.length === 0) { + mapContainer.innerHTML = '

暂无可显示的线路数据。

'; + return; + } + + for (const [c, arr] of linesByStation.entries()) { + const uniq = Array.from(new Set(arr)); + if (uniq.length >= 2) stationTransfer.add(c); + } - const svgEl = mapContainer.querySelector('svg'); - if(!svgEl) return; + // Build SVG + const legendW = 210; + const legendX = 16; + const legendSwatchW = 22; + const legendTextX = legendX + legendSwatchW + 10; + const startX = legendW + 90; + const baseY = 44; + const lineGapY = 92; + const minGapX = 78; + + const occurrences = {}; + for (const li of lineStops) { + for (let i = 0; i < li.stops.length; i++) { + const code = li.stops[i]; + const canonical = stationCanonicalByCode[code] || code; + if (!occurrences[canonical]) occurrences[canonical] = []; + occurrences[canonical].push(i * minGapX); + } + } + stationXByCanonical = {}; + for (const canonical of Object.keys(occurrences)) { + const arr = occurrences[canonical]; + const avg = arr.reduce((a,b)=>a+b,0) / Math.max(1, arr.length); + stationXByCanonical[canonical] = startX + Math.round(avg); + } + + for (let pass = 0; pass < 3; pass++) { + for (const li of lineStops) { + let prevX = startX - minGapX; + for (const code of li.stops) { + const canonical = stationCanonicalByCode[code] || code; + const x = stationXByCanonical[canonical] ?? (prevX + minGapX); + const nx = Math.max(x, prevX + minGapX); + if (stationXByCanonical[canonical] == null || nx > stationXByCanonical[canonical]) stationXByCanonical[canonical] = nx; + prevX = stationXByCanonical[canonical]; + } + } + } + + const primaryLineByStation = {}; + for (const [c, arr] of linesByStation.entries()) { + const uniq = Array.from(new Set(arr)).sort((a,b)=>a-b); + if (uniq.length === 0) continue; + primaryLineByStation[c] = uniq[0]; + } + + stationYByCanonical = {}; + for (const c of Object.keys(primaryLineByStation)) { + const li = primaryLineByStation[c]; + if (li == null) continue; + stationYByCanonical[c] = baseY + li * lineGapY; + } + + const allStations = Object.keys(primaryLineByStation); + if (allStations.length === 0) { + mapContainer.innerHTML = '

线路数据为空,请稍后再试。

'; + return; + } + const labelShownForCanonical = new Set(); + const transferColorsByStation = {}; + const primaryColorByStation = {}; + for (const c of allStations) { + const lineIdxs = Array.from(new Set(linesByStation.get(c) || [])).sort((a,b)=>a-b); + const colors = lineIdxs.map(i => lineStops[i]?.color).filter(Boolean); + transferColorsByStation[c] = Array.from(new Set(colors)); + primaryColorByStation[c] = colors[0] || '#93a2b7'; + } + + let svgContent = ''; + + for (const li of lineStops) { + const yLine = baseY + li.idx * lineGapY; + svgContent += ``; + svgContent += `${li.name}`; + + if (li.stops.length >= 2) { + const firstX = stationXByCanonical[stationCanonicalByCode[li.stops[0]] || li.stops[0]]; + let d = `M ${firstX} ${yLine}`; + for (let i = 1; i < li.stops.length; i++) { + const x = stationXByCanonical[stationCanonicalByCode[li.stops[i]] || li.stops[i]]; + d += ` L ${x} ${yLine}`; + } + svgContent += ``; + } + } + + for (const canonical of Object.keys(stationCodesByCanonical)) { + const codes = (stationCodesByCanonical[canonical] || []).filter(code => Number.isFinite(stationYByCanonical[code])); + if (codes.length < 2) continue; + const ys = codes.map(code => stationYByCanonical[code]).sort((a, b) => a - b); + const x = stationXByCanonical[canonical]; + const labelY = ys[ys.length - 1] + 34; + svgContent += ``; + svgContent += ``; + svgContent += ``; + svgContent += `${getStationDisplayName(canonical)}`; + labelShownForCanonical.add(canonical); + } + + for (const c of allStations) { + const canonical = stationCanonicalByCode[c] || c; + const x = stationXByCanonical[canonical]; + const yNode = stationYByCanonical[c] ?? baseY; + const name = stationNameByCode[c] || c; + const isTransfer = stationTransfer.has(c); + const cls = isTransfer ? 'map-station transfer' : 'map-station'; + const ringSvg = ''; + const primaryColor = primaryColorByStation[c] || '#93a2b7'; + const outer = isTransfer + ? `` + : ``; + const transferFill = isTransfer ? `` : ''; + const coreFill = isTransfer ? '#ffffff' : '#ffffff'; + const showLabel = !isTransfer || !labelShownForCanonical.has(canonical); + const textSvg = showLabel + ? `${name}` + : ''; + svgContent += ` + + ${ringSvg} + ${outer} + ${transferFill} + + ${textSvg} + + `; + } + + const width = Math.max(560, Math.max(...allStations.map(c => stationXByCanonical[stationCanonicalByCode[c] || c] || 0)) + 120); + const height = Math.max(260, baseY + lineStops.length * lineGapY + 60); + mapContainer.innerHTML = `${svgContent}`; + + // Bind click events (standard onclick attribute in SVG string might not work in some contexts, but usually fine in innerHTML) + // Better to add event listeners via delegation - svgEl.querySelectorAll('.map-station').forEach(el => { - el.addEventListener('click', () => { - window.handleStationClick(el.getAttribute('data-code')); - }); - }); } catch(e) { - mapContainer.innerHTML = '
加载地图失败
'; + console.error(e); + mapContainer.innerHTML = '
地图加载失败
'; } } - + window.handleStationClick = (code) => { code = String(code || '').trim(); // Toggle if already selected @@ -311,7 +506,7 @@ } // Load Map on Start - loadMap(); + renderSystemMap(); async function previewPrice(force = false){ const seq = ++previewSeq;