const fs = require('fs'); const path = require('path'); const DEFAULT_VIEWPORT = { width: 1440, height: 920 }; const DEFAULT_LINE_COLOR = '#93a2b7'; const REFERENCE_SVG_PATH = path.resolve(__dirname, '..', '..', 'FSE铁路线路图.svg'); const escapeHTML = (s) => String(s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const round = (value, step = 2) => Math.round(Number(value || 0) / step) * step; const toCode = (value) => String(value || '').trim(); const normalizeName = (value) => String(value || '').trim().toLowerCase().replace(/\s+/g, ''); const parseNumber = (value) => { const num = Number(value); return Number.isFinite(num) ? num : null; }; const attrRegex = /([\w:-]+)="([^"]*)"/g; function parseAttributes(raw) { const attrs = {}; if (!raw) return attrs; for (const match of raw.matchAll(attrRegex)) { attrs[match[1]] = match[2]; } return attrs; } function parseReferenceSvgMeta() { try { if (!fs.existsSync(REFERENCE_SVG_PATH)) return { ...DEFAULT_VIEWPORT, anchorPoints: [] }; const raw = fs.readFileSync(REFERENCE_SVG_PATH, 'utf8'); const viewBoxMatch = raw.match(/viewBox="([\d.\s-]+)"/i); const widthMatch = raw.match(/\bwidth="([\d.]+)"/i); const heightMatch = raw.match(/\bheight="([\d.]+)"/i); let width = DEFAULT_VIEWPORT.width; let height = DEFAULT_VIEWPORT.height; if (viewBoxMatch) { const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number); if (parts.length === 4 && Number.isFinite(parts[2]) && Number.isFinite(parts[3])) { width = parts[2]; height = parts[3]; } } else { width = parseNumber(widthMatch?.[1]) || width; height = parseNumber(heightMatch?.[1]) || height; } const circles = []; for (const match of raw.matchAll(/]*)\/?>/gi)) { const attrs = parseAttributes(match[1]); const cx = parseNumber(attrs.cx); const cy = parseNumber(attrs.cy); const r = parseNumber(attrs.r); if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(r)) continue; circles.push({ cx, cy, r, fill: attrs.fill || '', stroke: attrs.stroke || '', strokeWidth: parseNumber(attrs['stroke-width']) || 0 }); } const anchorCandidates = circles.filter((circle) => { if (circle.r < 4) return false; if (!circle.stroke || circle.stroke.toLowerCase() === 'none') return false; return true; }); const clusters = []; for (const circle of anchorCandidates) { let target = null; for (const cluster of clusters) { const dx = cluster.x - circle.cx; const dy = cluster.y - circle.cy; const distance = Math.sqrt((dx * dx) + (dy * dy)); if (distance <= 18) { target = cluster; break; } } if (!target) { target = { x: circle.cx, y: circle.cy, count: 0 }; clusters.push(target); } target.count += 1; target.x += (circle.cx - target.x) / target.count; target.y += (circle.cy - target.y) / target.count; } const anchorPoints = clusters .sort((a, b) => (a.y - b.y) || (a.x - b.x)) .map((point) => ({ x: point.x, y: point.y })); return { width, height, anchorPoints }; } catch (_) { return { ...DEFAULT_VIEWPORT, anchorPoints: [] }; } } function createUnionFind() { const parent = new Map(); const find = (value) => { if (!parent.has(value)) parent.set(value, value); const root = parent.get(value); if (root !== value) { const collapsed = find(root); parent.set(value, collapsed); return collapsed; } return root; }; const union = (a, b) => { if (!a || !b) return; const ra = find(a); const rb = find(b); if (ra !== rb) parent.set(rb, ra); }; return { parent, find, union }; } function buildTransferGroups(stations, transfers) { const uf = createUnionFind(); const codesByName = new Map(); for (const station of (stations || [])) { const code = toCode(station?.code); if (!code) continue; uf.find(code); const nameKey = `${normalizeName(station?.name || station?.cn_name || station?.station_name)}|${normalizeName(station?.en_name || station?.enName || station?.en)}`; if (nameKey !== '|') { if (!codesByName.has(nameKey)) codesByName.set(nameKey, []); codesByName.get(nameKey).push(code); } } for (const station of (stations || [])) { const from = toCode(station?.code); if (!from) continue; const transferTo = Array.isArray(station?.transfer_to) ? station.transfer_to : []; for (const item of transferTo) { const to = toCode((typeof item === 'string') ? item : (item?.code || item?.station || item?.id || item?.[0])); if (to) uf.union(from, to); } } for (const pair of (transfers || [])) { const from = toCode(pair?.[0]); const to = toCode(pair?.[1]); if (from && to) uf.union(from, to); } for (const group of codesByName.values()) { if (!Array.isArray(group) || group.length < 2) continue; for (let index = 1; index < group.length; index += 1) { uf.union(group[0], group[index]); } } const codesByCanonical = {}; const canonicalByCode = {}; for (const station of (stations || [])) { const code = toCode(station?.code); if (!code) continue; const canonical = uf.find(code); if (!codesByCanonical[canonical]) codesByCanonical[canonical] = []; codesByCanonical[canonical].push(code); } for (const root of Object.keys(codesByCanonical)) { const sortedCodes = Array.from(new Set(codesByCanonical[root])).sort((a, b) => a.localeCompare(b)); const canonical = sortedCodes[0]; delete codesByCanonical[root]; codesByCanonical[canonical] = sortedCodes; for (const code of sortedCodes) canonicalByCode[code] = canonical; } return { canonicalByCode, codesByCanonical }; } function buildNodes(stations, nameByCode, transferGroups) { const nodes = new Map(); for (const station of (stations || [])) { const code = toCode(station?.code); if (!code) continue; const canonical = transferGroups.canonicalByCode[code] || code; const name = String( station?.name || station?.cn_name || station?.station_name || nameByCode?.[code] || code ); const enName = String(station?.en_name || station?.enName || station?.en || ''); const svgX = parseNumber(station?.svg_x); const svgY = parseNumber(station?.svg_y); if (!nodes.has(canonical)) { nodes.set(canonical, { canonical, codes: [], primaryCode: code, name, enName, lines: new Set(), explicitPositions: [], explicitLineIndices: [], isTransfer: false }); } const node = nodes.get(canonical); node.codes.push(code); if (!node.name && name) node.name = name; if (!node.enName && enName) node.enName = enName; if (Number.isFinite(svgX) && Number.isFinite(svgY)) { node.explicitPositions.push({ x: svgX, y: svgY }); } } return nodes; } function buildLineSegments(lines, transferGroups, nodeMap) { const segments = []; const uniqueEdgeSet = new Set(); (lines || []).forEach((line, lineIndex) => { const color = line?.color || DEFAULT_LINE_COLOR; const lineId = String(line?.id || `line-${lineIndex + 1}`); const name = String(line?.name || line?.cn_name || line?.线路名称 || lineId); const enName = String(line?.en_name || line?.英文名称 || line?.英文名 || ''); const stopsRaw = Array.isArray(line?.stations) ? line.stations : (Array.isArray(line?.stops) ? line.stops : (Array.isArray(line?.站点列表) ? line.站点列表 : [])); let current = []; const commitSegment = () => { const canonicalStops = []; for (const stop of current) { const code = toCode(stop); if (!code || code === '-') continue; const canonical = transferGroups.canonicalByCode[code] || code; if (canonicalStops[canonicalStops.length - 1] !== canonical) canonicalStops.push(canonical); const node = nodeMap.get(canonical); if (node) { node.lines.add(lineId); node.explicitLineIndices.push(lineIndex); } } if (canonicalStops.length < 2) { current = []; return; } segments.push({ id: lineId, name, enName, color, lineIndex, stops: canonicalStops }); for (let index = 0; index < canonicalStops.length - 1; index += 1) { const a = canonicalStops[index]; const b = canonicalStops[index + 1]; uniqueEdgeSet.add([a, b].sort().join('|')); } current = []; }; for (const stop of stopsRaw) { if (toCode(stop) === '-') { commitSegment(); } else { current.push(stop); } } commitSegment(); }); return { segments, edges: Array.from(uniqueEdgeSet).map((key) => { const [from, to] = key.split('|'); return { from, to }; }) }; } function buildSeedLayout(nodeMap, lineSegments, referenceMeta) { const nodes = Array.from(nodeMap.values()); const baseX = 140; const baseY = 120; const gapX = 96; const gapY = 92; const seed = {}; const hasExplicitPositions = nodes.some((node) => node.explicitPositions.length > 0); if (hasExplicitPositions) { for (const node of nodes) { if (node.explicitPositions.length > 0) { const x = node.explicitPositions.reduce((sum, pos) => sum + pos.x, 0) / node.explicitPositions.length; const y = node.explicitPositions.reduce((sum, pos) => sum + pos.y, 0) / node.explicitPositions.length; seed[node.canonical] = { x, y, locked: true }; } } } const occurrenceMap = {}; for (const segment of lineSegments.segments) { segment.stops.forEach((canonical, stopIndex) => { if (!occurrenceMap[canonical]) occurrenceMap[canonical] = []; occurrenceMap[canonical].push({ x: baseX + (stopIndex * gapX), y: baseY + (segment.lineIndex * gapY) }); }); } let fallbackIndex = 0; const fallbackCols = Math.max(4, Math.ceil(Math.sqrt(Math.max(nodes.length, 1)))); for (const node of nodes) { if (!seed[node.canonical] && occurrenceMap[node.canonical]?.length) { const samples = occurrenceMap[node.canonical]; seed[node.canonical] = { x: samples.reduce((sum, item) => sum + item.x, 0) / samples.length, y: samples.reduce((sum, item) => sum + item.y, 0) / samples.length, locked: false }; } if (!seed[node.canonical]) { seed[node.canonical] = { x: baseX + ((fallbackIndex % fallbackCols) * gapX), y: baseY + (Math.floor(fallbackIndex / fallbackCols) * gapY), locked: false }; fallbackIndex += 1; } } const anchorPoints = Array.isArray(referenceMeta?.anchorPoints) ? referenceMeta.anchorPoints : []; if (!hasExplicitPositions && anchorPoints.length >= nodes.length * 0.6) { const nodeOrder = nodes .map((node) => ({ node, seed: seed[node.canonical] })) .sort((a, b) => (a.seed.y - b.seed.y) || (a.seed.x - b.seed.x)); const anchors = anchorPoints .slice() .sort((a, b) => (a.y - b.y) || (a.x - b.x)); const limit = Math.min(nodeOrder.length, anchors.length); for (let index = 0; index < limit; index += 1) { const { node } = nodeOrder[index]; const anchor = anchors[index]; seed[node.canonical] = { x: (seed[node.canonical].x * 0.45) + (anchor.x * 0.55), y: (seed[node.canonical].y * 0.45) + (anchor.y * 0.55), locked: false }; } } return seed; } function optimizeLayout(nodeMap, lineSegments, referenceMeta) { const nodes = Array.from(nodeMap.values()); const seed = buildSeedLayout(nodeMap, lineSegments, referenceMeta); const positions = {}; const velocities = {}; const neighbors = new Map(); for (const node of nodes) { positions[node.canonical] = { x: seed[node.canonical]?.x || 0, y: seed[node.canonical]?.y || 0 }; velocities[node.canonical] = { x: 0, y: 0 }; neighbors.set(node.canonical, new Set()); } for (const edge of lineSegments.edges) { neighbors.get(edge.from)?.add(edge.to); neighbors.get(edge.to)?.add(edge.from); } const iterations = 220; const repulsionBase = 18000; const springLength = 92; const springStrength = 0.012; const bendStrength = 0.018; const anchorStrength = 0.018; const damping = 0.82; for (let iteration = 0; iteration < iterations; iteration += 1) { const alpha = 1 - (iteration / iterations); const forces = {}; for (const node of nodes) forces[node.canonical] = { x: 0, y: 0 }; for (let i = 0; i < nodes.length; i += 1) { const a = nodes[i]; const pa = positions[a.canonical]; for (let j = i + 1; j < nodes.length; j += 1) { const b = nodes[j]; const pb = positions[b.canonical]; let dx = pa.x - pb.x; let dy = pa.y - pb.y; let distanceSq = (dx * dx) + (dy * dy); if (distanceSq < 1) { dx = 0.5 - Math.random(); dy = 0.5 - Math.random(); distanceSq = (dx * dx) + (dy * dy); } const distance = Math.sqrt(distanceSq); const scale = (repulsionBase * alpha) / distanceSq; const fx = (dx / distance) * scale; const fy = (dy / distance) * scale; forces[a.canonical].x += fx; forces[a.canonical].y += fy; forces[b.canonical].x -= fx; forces[b.canonical].y -= fy; } } for (const segment of lineSegments.segments) { for (let index = 0; index < segment.stops.length - 1; index += 1) { const from = segment.stops[index]; const to = segment.stops[index + 1]; const pa = positions[from]; const pb = positions[to]; let dx = pb.x - pa.x; let dy = pb.y - pa.y; let distance = Math.sqrt((dx * dx) + (dy * dy)); if (distance < 1) distance = 1; const delta = distance - springLength; const force = delta * springStrength; const fx = (dx / distance) * force; const fy = (dy / distance) * force; forces[from].x += fx; forces[from].y += fy; forces[to].x -= fx; forces[to].y -= fy; } for (let index = 1; index < segment.stops.length - 1; index += 1) { const prev = positions[segment.stops[index - 1]]; const current = positions[segment.stops[index]]; const next = positions[segment.stops[index + 1]]; const targetX = (prev.x + next.x) / 2; const targetY = (prev.y + next.y) / 2; forces[segment.stops[index]].x += (targetX - current.x) * bendStrength; forces[segment.stops[index]].y += (targetY - current.y) * bendStrength; } } for (const node of nodes) { const anchor = seed[node.canonical]; if (!anchor) continue; const strength = anchor.locked ? 0.14 : anchorStrength; forces[node.canonical].x += (anchor.x - positions[node.canonical].x) * strength; forces[node.canonical].y += (anchor.y - positions[node.canonical].y) * strength; } for (const node of nodes) { const velocity = velocities[node.canonical]; const force = forces[node.canonical]; velocity.x = (velocity.x + (force.x * 0.6)) * damping; velocity.y = (velocity.y + (force.y * 0.6)) * damping; positions[node.canonical].x += velocity.x; positions[node.canonical].y += velocity.y; } } const xs = nodes.map((node) => positions[node.canonical].x); const ys = nodes.map((node) => positions[node.canonical].y); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); const sourceWidth = Math.max(maxX - minX, 1); const sourceHeight = Math.max(maxY - minY, 1); const targetWidth = Math.max((referenceMeta?.width || DEFAULT_VIEWPORT.width) - 260, 640); const targetHeight = Math.max((referenceMeta?.height || DEFAULT_VIEWPORT.height) - 220, 420); const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight); const offsetX = 130; const offsetY = 110; for (const node of nodes) { const point = positions[node.canonical]; point.x = round(((point.x - minX) * scale) + offsetX, 2); point.y = round(((point.y - minY) * scale) + offsetY, 2); } return positions; } function buildLinePath(points) { if (!Array.isArray(points) || points.length === 0) return ''; let d = `M ${points[0].x} ${points[0].y}`; for (let index = 1; index < points.length; index += 1) { d += ` L ${points[index].x} ${points[index].y}`; } return d; } function getContrastColor(hex) { if (!hex) return '#ffffff'; let value = String(hex).replace('#', ''); if (value.length === 3) value = value.split('').map((item) => item + item).join(''); const r = parseInt(value.slice(0, 2), 16) || 0; const g = parseInt(value.slice(2, 4), 16) || 0; const b = parseInt(value.slice(4, 6), 16) || 0; const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; return yiq >= 140 ? '#0f172a' : '#ffffff'; } function piePath(cx, cy, r, a0, a1) { const x0 = cx + (r * Math.cos(a0)); const y0 = cy + (r * Math.sin(a0)); const x1 = cx + (r * Math.cos(a1)); const y1 = cy + (r * Math.sin(a1)); const largeArc = (a1 - a0) > Math.PI ? 1 : 0; return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1} Z`; } function pieSvg(cx, cy, r, colors) { const palette = Array.from(new Set((Array.isArray(colors) ? colors : []).filter(Boolean))).slice(0, 4); if (palette.length === 0) return ''; if (palette.length === 1) return ``; const step = (Math.PI * 2) / palette.length; let output = ''; for (let index = 0; index < palette.length; index += 1) { const a0 = (-Math.PI / 2) + (index * step); const a1 = a0 + step; output += ``; } return output; } function renderStation(node, point, lineColorMap) { const colors = Array.from(node.lines) .map((lineId) => lineColorMap.get(lineId) || DEFAULT_LINE_COLOR) .filter(Boolean); const uniqueColors = Array.from(new Set(colors)); const isTransfer = node.isTransfer || uniqueColors.length >= 2 || node.codes.length >= 2; const code = escapeHTML(node.primaryCode); const name = escapeHTML(node.name || node.primaryCode); const enName = escapeHTML(node.enName || ''); const x = point.x; const y = point.y; const codeFontSize = 9; const nameFontSize = 13; const enFontSize = 8.5; const codeWidth = Math.max((node.primaryCode || '').length * 6 + 10, 28); const preferTop = isTransfer; let markerSvg = ''; if (isTransfer) { markerSvg += ``; markerSvg += pieSvg(x, y, 11.5, uniqueColors); markerSvg += ``; } else { const color = uniqueColors[0] || DEFAULT_LINE_COLOR; markerSvg += ``; markerSvg += ``; } let textSvg = ''; if (preferTop) { const baseY = y - 14; const enY = baseY; const nameY = enName ? (baseY - 12) : baseY; const codeY = nameY - 16; textSvg += ``; textSvg += `${code}`; textSvg += `${name}`; if (enName) { textSvg += `${enName}`; } } else { const topY = y + 24; const codeY = topY; const nameY = topY + 16; const enY = nameY + 12; textSvg += ``; textSvg += `${code}`; textSvg += `${name}`; if (enName) { textSvg += `${enName}`; } } return ` ${markerSvg} ${textSvg} `; } function renderLineLabels(segment, points) { if (!Array.isArray(points) || points.length < 2) return ''; const color = segment.color || DEFAULT_LINE_COLOR; const textColor = getContrastColor(color); const subTextColor = (textColor === '#ffffff') ? 'rgba(255,255,255,0.9)' : 'rgba(15,23,42,0.8)'; const labelWidth = Math.max(String(segment.name || '').length * 14 + 20, String(segment.enName || '').length * 7 + 20, 80); const height = segment.enName ? 36 : 24; const build = (point, alignLeft) => { const x = alignLeft ? point.x - 54 - labelWidth : point.x + 54; const y = point.y - (height / 2); const textX = x + (labelWidth / 2); let output = ``; if (segment.enName) { output += `${escapeHTML(segment.name)}`; output += `${escapeHTML(segment.enName)}`; } else { output += `${escapeHTML(segment.name)}`; } return output; }; return build(points[0], true) + build(points[points.length - 1], false); } function calculateBounds(positions) { const items = Object.values(positions || {}); if (!items.length) return { minX: 0, minY: 0, maxX: DEFAULT_VIEWPORT.width, maxY: DEFAULT_VIEWPORT.height }; return { minX: Math.min(...items.map((item) => item.x)), minY: Math.min(...items.map((item) => item.y)), maxX: Math.max(...items.map((item) => item.x)), maxY: Math.max(...items.map((item) => item.y)) }; } function generate(stations, lines, fares, nameByCode, transfers) { const referenceMeta = parseReferenceSvgMeta(); const transferGroups = buildTransferGroups(stations, transfers); const nodeMap = buildNodes(stations, nameByCode || {}, transferGroups); const lineSegments = buildLineSegments(lines, transferGroups, nodeMap); for (const node of nodeMap.values()) { node.codes = Array.from(new Set(node.codes)).sort((a, b) => a.localeCompare(b)); node.primaryCode = node.codes[0] || node.primaryCode; node.isTransfer = node.codes.length >= 2 || node.lines.size >= 2; } if (!nodeMap.size) { return `暂无可显示的站点数据`; } const positions = optimizeLayout(nodeMap, lineSegments, referenceMeta); const bounds = calculateBounds(positions); const width = Math.max(referenceMeta.width || DEFAULT_VIEWPORT.width, Math.ceil(bounds.maxX + 180)); const height = Math.max(referenceMeta.height || DEFAULT_VIEWPORT.height, Math.ceil(bounds.maxY + 160)); const lineColorMap = new Map(); (lineSegments.segments || []).forEach((segment) => lineColorMap.set(segment.id, segment.color || DEFAULT_LINE_COLOR)); let svg = ``; svg += `generated-by=svg-generator; mode=network-map; reference-svg=${escapeHTML(path.basename(REFERENCE_SVG_PATH))}; stations=${nodeMap.size}; lineSegments=${lineSegments.segments.length}; fares=${Array.isArray(fares) ? fares.length : 0}`; svg += ``; for (const segment of lineSegments.segments) { const points = segment.stops.map((canonical) => positions[canonical]).filter(Boolean); if (points.length < 2) continue; const d = buildLinePath(points); svg += ``; svg += renderLineLabels(segment, points); } const orderedNodes = Array.from(nodeMap.values()).sort((a, b) => { const pa = positions[a.canonical]; const pb = positions[b.canonical]; return (pa.x - pb.x) || (pa.y - pb.y); }); for (const node of orderedNodes) { const point = positions[node.canonical]; if (!point) continue; svg += renderStation(node, point, lineColorMap); } svg += ``; return svg; } module.exports = { generate };