Files
FSE-Ticket.sys/server/services/svg-generator.js
T
Henry_Du 7812d47f0c refactor & feat & fix: 重写SVG生成器,优化前端线路绘制与修复样式
重写服务器端SVG生成逻辑,将原铁路线路图生成功能替换为站点间票价矩阵生成。
优化前端换乘路径绘制:新增线路偏移与弯道处理,避免多条线路路径重叠。
修复站点标记的外层圆圈填充颜色,使其正确使用对应线路的配色。
2026-07-16 03:49:45 +08:00

224 lines
8.1 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 escapeHTML = (s) => String(s || '')
.replace(/&/g, '&')
.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 {};
}
})();
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 = {};
(stations || []).forEach((s) => {
const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim();
if (!k) return;
if (!groupsMap[k]) groupsMap[k] = [];
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'];
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);
};
const validStationCodes = new Set((stations || []).map((s) => String(s.code || '')));
(fares || []).forEach((f) => {
const a = String(f.from || '');
const 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);
});
if (Array.isArray(transfers)) {
transfers.forEach((p) => {
const u = String(p[0] || '');
const v = String(p[1] || '');
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
addEdge(u, v, 0);
}
});
}
(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">`;
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)) {
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 routeColors = colorsForRoute(code, codes[j]);
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;
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"/>`;
}
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>`;
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 };