1. 重构并统一票务、IC卡的事件日志格式化展示逻辑,替换原有零散的格式化函数,提升事件展示的一致性与可读性 2. 为AI助手面板新增折叠按钮与折叠逻辑,支持收起/展开面板,优化界面空间利用率 3. 移除多个页面的冗余介绍、提醒区块,精简页面结构 4. 修复IC卡购卡页面的充值选项按钮布局问题,将button元素改为div适配样式规范 5. 新增移动端卡片布局的横向滚动适配,优化小屏设备上的卡片展示与浏览体验 6. 更新后台管理页面的导航样式、文案与统计数据展示,新增车票、凭证的统计卡片 7. 统一全站品牌文案与页面格式,修复部分页面的DOCTYPE多余空格问题 8. 简化车票订单页面的地图加载逻辑,修复原有的冗余代码与加载异常问题
192 lines
6.7 KiB
JavaScript
192 lines
6.7 KiB
JavaScript
const fs = require('fs');
|
|
|
|
const escapeHTML = (s) => String(s || '').replace(/&/g, '&').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 || ''; });
|
|
|
|
// 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); };
|
|
|
|
stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); });
|
|
|
|
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)) {
|
|
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. 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 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 = `<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) {
|
|
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" />`;
|
|
}
|
|
|
|
if (w > 0) {
|
|
const cx = (nu.x + nv.x) / 2;
|
|
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="${cx}" y="${cy + 4}" font-size="12" font-weight="bold" fill="#ca8a04" text-anchor="middle">${w}</text>`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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>`;
|
|
return svg;
|
|
}
|
|
|
|
module.exports = { generate };
|