Files
FSE-Ticket.sys/server/services/svg-generator.js
T
Henry_Du 06a89c678a feat(web,server): 修复模板语法错误并新增多项体验优化
修复多处JS文件中的模板字符串转义错误,将错误的\\`替换为标准反引号
为IC卡搜索页面新增中文事件类型匹配,支持充值、进站、出站场景
重构票务订单页面的线路绘制逻辑,添加站点交替波浪偏移并使用贝塞尔曲线优化线路显示
优化SVG票价表格渲染,新增不可达站点的红色叉号提示,重构代码提升可读性
2026-07-05 15:34:31 +08:00

208 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs');
const escapeHTML = (s) => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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 = `<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">`;
// 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 += `<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 (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 += `<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>`;
svg += `<text x="${xText}" y="${baseY + 16}" font-size="10" fill="#000000">${escapeHTML(code)}</text>`;
for (let j = 0; j < n; j++) {
const x0 = margin + leftW + j * cellW; const y0 = margin + topH + i * cellH;
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#ffffff" stroke="#e5e7eb"/>`;
if (i === j) {
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" fill="#6b7280" text-anchor="middle">-</text>`;
} else {
const distVal = distAll[code][codes[j]];
if (distVal === Infinity || !Number.isFinite(distVal)) {
// Unreachable station
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="14" font-weight="800" fill="#ef4444" text-anchor="middle">×</text>`;
} else {
const val = Math.round(Number(distVal || 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 += `<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"/>`;
}
// Add cell border
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>`;
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" font-weight="800" fill="#000000" text-anchor="middle">${escapeHTML(String(val))}</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"/>`;
}
}
}
}
}
svg += `</svg>`;
return svg;
}
module.exports = { generate };