refactor(ticket-map): 重写车票地图渲染与SVG生成逻辑
- 前端拆分并重写地图渲染函数,实现完整动态线路地图,包含图例、换乘站标记与站点点击交互 - 后端完全重写SVG生成器,改用网格票价矩阵布局,严格使用数据库传入的数据并移除自动站点分组逻辑 - 优化加载与错误提示状态,新增空数据兜底显示
This commit is contained in:
+174
-164
@@ -3,187 +3,197 @@ const fs = require('fs');
|
|||||||
const escapeHTML = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
const escapeHTML = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
|
||||||
function generate(stations, lines, fares, nameByCode, transfers) {
|
function generate(stations, lines, fares, nameByCode, transfers) {
|
||||||
const enNameByCode = {};
|
const enNameByCode = (() => {
|
||||||
stations.forEach(s => { enNameByCode[String(s.code || '')] = s.en_name || s.enName || ''; });
|
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 groupsMap = {};
|
||||||
const validStationCodes = new Set(stations.map(s => String(s.code || '')));
|
// Optional: We can still use groups for coloring logic, but NOT for graph edges if we want strict mode.
|
||||||
const adj = new Map();
|
// But let's keep groupsMap for color logic (lines 30-46) as it helps determine if lines connect visually.
|
||||||
const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); };
|
stations.forEach(s => {
|
||||||
const addEdge = (u, v, w) => { addNode(u); addNode(v); adj.get(u).set(v, w); adj.get(v).set(u, w); };
|
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();
|
// 1. Edges from explicit FARES only
|
||||||
(fares || []).forEach(f => {
|
(fares || []).forEach(f => {
|
||||||
const a = String(f.from || ''), b = String(f.to || '');
|
const a = String(f.from || ''), b = String(f.to || '');
|
||||||
if (a && b && validStationCodes.has(a) && validStationCodes.has(b)) {
|
if (!a || !b) return;
|
||||||
|
if (!validStationCodes.has(a) || !validStationCodes.has(b)) return;
|
||||||
|
|
||||||
const w = Number(f.cost_regular ?? f.cost) || 0;
|
const w = Number(f.cost_regular ?? f.cost) || 0;
|
||||||
addEdge(a, b, w);
|
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 margin = 8;
|
||||||
const nodes = Array.from(validStationCodes).map(code => ({
|
const cellW = 56;
|
||||||
code,
|
const cellH = 40;
|
||||||
x: Math.random() * 800,
|
const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10));
|
||||||
y: Math.random() * 600,
|
const maxCnW = Math.max(1, ...codes.map(c => estimateWidth(nameByCode[c] || c, 14)));
|
||||||
vx: 0, vy: 0,
|
const maxEnW = Math.max(1, ...codes.map(c => estimateWidth(enNameByCode[c] || '', 10)));
|
||||||
name: nameByCode[code] || code
|
const maxCodeW = Math.max(1, ...codes.map(c => estimateWidth(c, 10)));
|
||||||
}));
|
const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW));
|
||||||
const nodeMap = new Map(nodes.map(n => [n.code, n]));
|
const topH = 64;
|
||||||
|
const n = codes.length;
|
||||||
// Add line connections to edges for layout purposes
|
const width = margin + leftW + n * cellW + margin;
|
||||||
const layoutEdges = [];
|
const height = margin + topH + n * cellH + margin;
|
||||||
(lines || []).forEach(li => {
|
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif">`;
|
||||||
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x));
|
// Column headers
|
||||||
for (let i = 0; i < arr.length - 1; i++) {
|
for (let j = 0; j < n; j++) {
|
||||||
if (nodeMap.has(arr[i]) && nodeMap.has(arr[i+1])) {
|
const code = codes[j]; const name = nameByCode[code] || code; const en = enNameByCode[code] || '';
|
||||||
layoutEdges.push({ source: nodeMap.get(arr[i]), target: nodeMap.get(arr[i+1]), type: 'line' });
|
const x = margin + leftW + j * cellW + cellW / 2; const y = margin + 20;
|
||||||
}
|
svg += `<text x="${x}" y="${y}" font-size="14" font-weight="900" fill="#000000" text-anchor="middle">${escapeHTML(name)}</text>`;
|
||||||
}
|
if (en) svg += `<text x="${x}" y="${y + 14}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(en)}</text>`;
|
||||||
});
|
svg += `<text x="${x}" y="${y + 26}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(code)}</text>`;
|
||||||
|
|
||||||
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 iterations = 300;
|
for (let i = 0; i < n; i++) {
|
||||||
const k = Math.sqrt((1200 * 800) / nodes.length) || 100;
|
const code = codes[i]; const name = nameByCode[code] || code; const en = enNameByCode[code] || '';
|
||||||
for (let i = 0; i < iterations; i++) {
|
const xText = margin + 6; const baseY = margin + topH + i * cellH + 22;
|
||||||
const t = 1 - i / iterations;
|
svg += `<text x="${xText}" y="${baseY - 10}" font-size="14" font-weight="900" fill="#000000">${escapeHTML(name)}</text>`;
|
||||||
|
if (en) svg += `<text x="${xText}" y="${baseY + 4}" font-size="10" fill="#000000">${escapeHTML(en)}</text>`;
|
||||||
// Repulsion
|
svg += `<text x="${xText}" y="${baseY + 16}" font-size="10" fill="#000000">${escapeHTML(code)}</text>`;
|
||||||
for (let a = 0; a < nodes.length; a++) {
|
for (let j = 0; j < n; j++) {
|
||||||
for (let b = a + 1; b < nodes.length; b++) {
|
const x0 = margin + leftW + j * cellW; const y0 = margin + topH + i * cellH;
|
||||||
const dx = nodes[a].x - nodes[b].x;
|
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#ffffff" stroke="#e5e7eb"/>`;
|
||||||
const dy = nodes[a].y - nodes[b].y;
|
if (i === j) {
|
||||||
let dist = Math.sqrt(dx * dx + dy * dy);
|
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" fill="#6b7280" text-anchor="middle">-</text>`;
|
||||||
if (dist === 0) { dist = 0.01; }
|
} else {
|
||||||
if (dist < k * 2) {
|
const val = Math.round(Number(distAll[code][codes[j]] || 0));
|
||||||
const force = (k * k) / dist;
|
const intensity = maxFare > 0 ? Math.min(1, val / maxFare) : 0;
|
||||||
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, sans-serif">`;
|
|
||||||
|
|
||||||
// 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 += `<path d="${d}" fill="none" stroke="${escapeHTML(color)}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" opacity="0.7"/>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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}`);
|
|
||||||
|
|
||||||
if (isTransfer) {
|
// Get route colors
|
||||||
svg += `<line x1="${nu.x}" y1="${nu.y}" x2="${nv.x}" y2="${nv.y}" stroke="#94a3b8" stroke-width="6" stroke-dasharray="4 4" stroke-linecap="round" />`;
|
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 += `<rect x="${stripX}" y="${y0}" width="${stripW}" height="${cellH}" fill="${escapeHTML(c)}" fill-opacity="0.15" stroke="none"/>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#000000" fill-opacity="0.03" stroke="none"/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (w > 0) {
|
// Add cell border
|
||||||
const cx = (nu.x + nv.x) / 2;
|
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>`;
|
||||||
const cy = (nu.y + nv.y) / 2;
|
|
||||||
svg += `<rect x="${cx - 12}" y="${cy - 10}" width="24" height="20" rx="4" fill="#fef08a" stroke="#ca8a04" stroke-width="1"/>`;
|
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" font-weight="800" fill="#000000" text-anchor="middle">${escapeHTML(String(val))}</text>`;
|
||||||
svg += `<text x="${cx}" y="${cy + 4}" font-size="12" font-weight="bold" fill="#ca8a04" text-anchor="middle">${w}</text>`;
|
|
||||||
|
// 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 += `<circle cx="${cx}" cy="${cy}" r="3.5" fill="${escapeHTML(dotColor)}" stroke="white" stroke-width="1"/>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw nodes
|
|
||||||
nodes.forEach(n => {
|
|
||||||
svg += `<g class="map-station" data-code="${escapeHTML(n.code)}" style="cursor:pointer">`;
|
|
||||||
svg += `<circle cx="${n.x}" cy="${n.y}" r="8" fill="#ffffff" stroke="#1e293b" stroke-width="3"/>`;
|
|
||||||
svg += `<text x="${n.x}" y="${n.y - 15}" font-size="14" font-weight="bold" fill="#0f172a" text-anchor="middle" style="pointer-events:none">${escapeHTML(n.name)}</text>`;
|
|
||||||
svg += `<text x="${n.x}" y="${n.y + 20}" font-size="10" fill="#64748b" text-anchor="middle" style="pointer-events:none">${escapeHTML(n.code)}</text>`;
|
|
||||||
svg += `</g>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
svg += `</svg>`;
|
svg += `</svg>`;
|
||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|||||||
+210
-15
@@ -56,14 +56,49 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function loadMap() {
|
async function loadMap() {
|
||||||
mapContainer.innerHTML = '<div class="loading">加载地图中...</div>';
|
|
||||||
try {
|
try {
|
||||||
const [res, stationsData] = await Promise.all([
|
const res = await fetch('/api/public/fares/map/light');
|
||||||
fetch('/api/public/fares/map/light?t=' + Date.now()),
|
const svg = await res.text();
|
||||||
|
mapContainer.innerHTML = svg;
|
||||||
|
|
||||||
|
const svgEl = mapContainer.querySelector('svg');
|
||||||
|
if(!svgEl) return;
|
||||||
|
|
||||||
|
renderLineMap();
|
||||||
|
} catch(e) {
|
||||||
|
mapContainer.innerHTML = '<div class="error">加载地图失败</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
fetch('/api/public/stations').then(r=>r.json())
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
lines.forEach(line => {
|
||||||
|
const stList = line.站序 || [];
|
||||||
|
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function renderSystemMap() {
|
||||||
|
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
|
||||||
|
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;
|
window.cachedStationsData = stationsData;
|
||||||
|
stationTransfer = new Set();
|
||||||
|
stationCanonicalByCode = {};
|
||||||
|
stationCodesByCanonical = {};
|
||||||
stationNameByCode = {};
|
stationNameByCode = {};
|
||||||
stationEnByCode = {};
|
stationEnByCode = {};
|
||||||
for (const s of stationsData) {
|
for (const s of stationsData) {
|
||||||
@@ -73,22 +108,182 @@
|
|||||||
stationEnByCode[code] = String(s.en_name || s.英文名 || '');
|
stationEnByCode[code] = String(s.en_name || s.英文名 || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const svg = await res.text();
|
const transferGroups = buildTransferGroups(stationsData);
|
||||||
mapContainer.innerHTML = svg;
|
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 = '<div class="empty-state jr-empty-state"><p>暂无可显示的线路数据。</p></div>';
|
||||||
|
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');
|
// Build SVG
|
||||||
if(!svgEl) return;
|
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 = '<div class="empty-state jr-empty-state"><p>线路数据为空,请稍后再试。</p></div>';
|
||||||
|
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 += `<rect x="${legendX}" y="${yLine-10}" width="${legendSwatchW}" height="10" rx="5" fill="${li.color}" />`;
|
||||||
|
svgContent += `<text x="${legendTextX}" y="${yLine-1}" fill="#e5e7eb" font-size="16" font-weight="800">${li.name}</text>`;
|
||||||
|
|
||||||
|
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 += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 += `<line x1="${x}" y1="${ys[0]}" x2="${x}" y2="${ys[ys.length - 1]}" stroke="#cbd5e1" stroke-width="12" stroke-linecap="round" opacity="0.98" />`;
|
||||||
|
svgContent += `<circle cx="${x}" cy="${(ys[0] + ys[ys.length - 1]) / 2}" r="7" fill="#ffffff" stroke="#111827" stroke-width="3" />`;
|
||||||
|
svgContent += `<rect x="${x - 42}" y="${labelY - 16}" width="84" height="24" rx="12" fill="rgba(241,245,249,0.92)" stroke="#cbd5e1" stroke-width="1.5" />`;
|
||||||
|
svgContent += `<text x="${x}" y="${labelY}" text-anchor="middle" fill="#334155" font-size="13" font-weight="800">${getStationDisplayName(canonical)}</text>`;
|
||||||
|
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
|
||||||
|
? `<circle cx="${x}" cy="${yNode}" r="13" fill="#ffffff" stroke="${primaryColor}" stroke-width="5" />`
|
||||||
|
: `<circle cx="${x}" cy="${yNode}" r="12" fill="#ffffff" stroke="${primaryColor}" stroke-width="5" />`;
|
||||||
|
const transferFill = isTransfer ? `<circle cx="${x}" cy="${yNode}" r="7" fill="#ffffff" stroke="#111827" stroke-width="3" />` : '';
|
||||||
|
const coreFill = isTransfer ? '#ffffff' : '#ffffff';
|
||||||
|
const showLabel = !isTransfer || !labelShownForCanonical.has(canonical);
|
||||||
|
const textSvg = showLabel
|
||||||
|
? `<text x="${x}" y="${yNode+28}" text-anchor="middle" fill="#e5e7eb" font-size="14" font-weight="700">${name}</text>`
|
||||||
|
: '';
|
||||||
|
svgContent += `
|
||||||
|
<g class="${cls}" data-code="${c}" onclick="handleStationClick('${c}')" style="cursor:pointer">
|
||||||
|
${ringSvg}
|
||||||
|
${outer}
|
||||||
|
${transferFill}
|
||||||
|
<circle class="node-core" cx="${x}" cy="${yNode}" r="8" fill="${coreFill}" stroke="#111827" stroke-width="3" />
|
||||||
|
${textSvg}
|
||||||
|
</g>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
|
||||||
|
|
||||||
|
// 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) {
|
} catch(e) {
|
||||||
mapContainer.innerHTML = '<div class="error">加载地图失败</div>';
|
console.error(e);
|
||||||
|
mapContainer.innerHTML = '<div class="error">地图加载失败</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.handleStationClick = (code) => {
|
window.handleStationClick = (code) => {
|
||||||
code = String(code || '').trim();
|
code = String(code || '').trim();
|
||||||
// Toggle if already selected
|
// Toggle if already selected
|
||||||
@@ -311,7 +506,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load Map on Start
|
// Load Map on Start
|
||||||
loadMap();
|
renderSystemMap();
|
||||||
|
|
||||||
async function previewPrice(force = false){
|
async function previewPrice(force = false){
|
||||||
const seq = ++previewSeq;
|
const seq = ++previewSeq;
|
||||||
|
|||||||
Reference in New Issue
Block a user