refactor & feat & fix: 重写SVG生成器,优化前端线路绘制与修复样式
重写服务器端SVG生成逻辑,将原铁路线路图生成功能替换为站点间票价矩阵生成。 优化前端换乘路径绘制:新增线路偏移与弯道处理,避免多条线路路径重叠。 修复站点标记的外层圆圈填充颜色,使其正确使用对应线路的配色。
This commit is contained in:
+197
-727
@@ -1,10 +1,3 @@
|
||||
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, '<')
|
||||
@@ -12,738 +5,215 @@ const escapeHTML = (s) => String(s || '')
|
||||
.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(/<circle\b([^>]*)\/?>/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 = 450;
|
||||
const repulsionBase = 45000;
|
||||
const springLength = 120;
|
||||
const springStrength = 0.015;
|
||||
const bendStrength = 0.025;
|
||||
const anchorStrength = 0.02;
|
||||
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 };
|
||||
|
||||
// 1. Repulsion to untangle graph
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Spring forces for edges & bend smoothing
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Orthogonal snapping force (Octilinear bias)
|
||||
const orthoStrength = 0.03 * alpha;
|
||||
for (const edge of lineSegments.edges) {
|
||||
const pa = positions[edge.from];
|
||||
const pb = positions[edge.to];
|
||||
const dx = pb.x - pa.x;
|
||||
const dy = pb.y - pa.y;
|
||||
const adx = Math.abs(dx);
|
||||
const ady = Math.abs(dy);
|
||||
|
||||
if (adx < ady && adx < ady * 0.4) {
|
||||
// pull towards vertical
|
||||
forces[edge.from].x += dx * orthoStrength;
|
||||
forces[edge.to].x -= dx * orthoStrength;
|
||||
} else if (ady < adx && ady < adx * 0.4) {
|
||||
// pull towards horizontal
|
||||
forces[edge.from].y += dy * orthoStrength;
|
||||
forces[edge.to].y -= dy * orthoStrength;
|
||||
} else {
|
||||
// pull towards diagonal (45 degrees)
|
||||
const avg = (adx + ady) / 2;
|
||||
const targetDx = avg * Math.sign(dx || 1);
|
||||
const targetDy = avg * Math.sign(dy || 1);
|
||||
forces[edge.from].x += (dx - targetDx) * orthoStrength;
|
||||
forces[edge.to].x -= (dx - targetDx) * orthoStrength;
|
||||
forces[edge.from].y += (dy - targetDy) * orthoStrength;
|
||||
forces[edge.to].y -= (dy - targetDy) * orthoStrength;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Anchor forces
|
||||
for (const node of nodes) {
|
||||
const anchor = seed[node.canonical];
|
||||
if (!anchor) continue;
|
||||
const strength = anchor.locked ? 0.2 : anchorStrength;
|
||||
forces[node.canonical].x += (anchor.x - positions[node.canonical].x) * strength;
|
||||
forces[node.canonical].y += (anchor.y - positions[node.canonical].y) * strength;
|
||||
}
|
||||
|
||||
// 5. Update velocities and positions
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Final alignment to Grid to ensure perfect octilinear rendering
|
||||
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;
|
||||
|
||||
const GRID_SIZE = 24;
|
||||
|
||||
for (const node of nodes) {
|
||||
const point = positions[node.canonical];
|
||||
let rawX = ((point.x - minX) * scale) + offsetX;
|
||||
let rawY = ((point.y - minY) * scale) + offsetY;
|
||||
|
||||
// Snap to grid
|
||||
point.x = Math.round(rawX / GRID_SIZE) * GRID_SIZE;
|
||||
point.y = Math.round(rawY / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
|
||||
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) {
|
||||
const p1 = points[index - 1];
|
||||
const p2 = points[index];
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const adx = Math.abs(dx);
|
||||
const ady = Math.abs(dy);
|
||||
|
||||
if (adx === 0 || ady === 0 || Math.abs(adx - ady) < 2) {
|
||||
// Already orthogonal or 45-degree diagonal
|
||||
d += ` L ${p2.x} ${p2.y}`;
|
||||
} else {
|
||||
// Need an octilinear chamfer (a single bend)
|
||||
const signX = Math.sign(dx);
|
||||
const signY = Math.sign(dy);
|
||||
|
||||
if (adx > ady) {
|
||||
// Horizontal is longer: straight horizontal, then 45-degree
|
||||
const hDist = adx - ady;
|
||||
const midX = p1.x + hDist * signX;
|
||||
const midY = p1.y;
|
||||
d += ` L ${midX} ${midY} L ${p2.x} ${p2.y}`;
|
||||
} else {
|
||||
// Vertical is longer: straight vertical, then 45-degree
|
||||
const vDist = ady - adx;
|
||||
const midX = p1.x;
|
||||
const midY = p1.y + vDist * signY;
|
||||
d += ` L ${midX} ${midY} L ${p2.x} ${p2.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 `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${escapeHTML(palette[0])}" />`;
|
||||
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 += `<path d="${piePath(cx, cy, r, a0, a1)}" fill="${escapeHTML(palette[index])}" />`;
|
||||
}
|
||||
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 += `<circle cx="${x}" cy="${y}" r="15" fill="#ffffff" stroke="#1f2937" stroke-width="2.5" />`;
|
||||
markerSvg += pieSvg(x, y, 11.5, uniqueColors);
|
||||
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="6.5" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
|
||||
} else {
|
||||
const color = uniqueColors[0] || DEFAULT_LINE_COLOR;
|
||||
markerSvg += `<circle cx="${x}" cy="${y}" r="12.5" fill="#ffffff" stroke="${escapeHTML(color)}" stroke-width="5" />`;
|
||||
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
|
||||
}
|
||||
|
||||
let textSvg = '';
|
||||
if (preferTop) {
|
||||
const baseY = y - 14;
|
||||
const enY = baseY;
|
||||
const nameY = enName ? (baseY - 12) : baseY;
|
||||
const codeY = nameY - 16;
|
||||
textSvg += `<rect x="${x - (codeWidth / 2)}" y="${codeY - 8}" width="${codeWidth}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
|
||||
textSvg += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${code}</text>`;
|
||||
textSvg += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="4" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
|
||||
if (enName) {
|
||||
textSvg += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
|
||||
}
|
||||
} else {
|
||||
const topY = y + 24;
|
||||
const codeY = topY;
|
||||
const nameY = topY + 16;
|
||||
const enY = nameY + 12;
|
||||
textSvg += `<rect x="${x - (codeWidth / 2)}" y="${codeY - 8}" width="${codeWidth}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
|
||||
textSvg += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${code}</text>`;
|
||||
textSvg += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="4" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
|
||||
if (enName) {
|
||||
textSvg += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<g class="map-station ${isTransfer ? 'transfer' : ''}" data-code="${code}" data-canonical="${escapeHTML(node.canonical)}" onclick="handleStationClick('${code}')" style="cursor:pointer">
|
||||
<rect x="${x - 22}" y="${y - 22}" width="44" height="44" fill="transparent" />
|
||||
${markerSvg}
|
||||
${textSvg}
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `<rect x="${x}" y="${y}" width="${labelWidth}" height="${height}" rx="6" fill="${escapeHTML(color)}" />`;
|
||||
if (segment.enName) {
|
||||
output += `<text x="${textX}" y="${y + 15}" text-anchor="middle" fill="${textColor}" font-size="12" font-weight="bold">${escapeHTML(segment.name)}</text>`;
|
||||
output += `<text x="${textX}" y="${y + 28}" text-anchor="middle" fill="${subTextColor}" font-size="9" font-weight="600">${escapeHTML(segment.enName)}</text>`;
|
||||
} else {
|
||||
output += `<text x="${textX}" y="${y + 16}" text-anchor="middle" fill="${textColor}" font-size="12" font-weight="bold">${escapeHTML(segment.name)}</text>`;
|
||||
}
|
||||
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);
|
||||
const enNameByCode = (() => {
|
||||
try {
|
||||
const map = {};
|
||||
for (const s of (stations || [])) {
|
||||
map[String(s.code || '')] = s.en_name || s.enName || '';
|
||||
}
|
||||
return map;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!nodeMap.size) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${DEFAULT_VIEWPORT.width}" height="${DEFAULT_VIEWPORT.height}" viewBox="0 0 ${DEFAULT_VIEWPORT.width} ${DEFAULT_VIEWPORT.height}" style="background:#ffffff"><text x="80" y="120" fill="#64748b" font-size="18" font-family="Inter, system-ui, sans-serif">暂无可显示的站点数据</text></svg>`;
|
||||
(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;
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
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">`;
|
||||
svg += `<metadata>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}</metadata>`;
|
||||
svg += `<rect width="${width}" height="${height}" fill="#ffffff" />`;
|
||||
|
||||
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 += `<path d="${d}" fill="none" stroke="${escapeHTML(segment.color || DEFAULT_LINE_COLOR)}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
|
||||
svg += renderLineLabels(segment, points);
|
||||
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>`;
|
||||
}
|
||||
|
||||
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 (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 (const node of orderedNodes) {
|
||||
const point = positions[node.canonical];
|
||||
if (!point) continue;
|
||||
svg += renderStation(node, point, lineColorMap);
|
||||
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>`;
|
||||
|
||||
+31
-5
@@ -63,10 +63,11 @@
|
||||
return out;
|
||||
};
|
||||
|
||||
const buildTransitPath = (points, radius = 12) => {
|
||||
const buildTransitPath = (points, radius = 12, options = {}) => {
|
||||
const src = Array.isArray(points) ? points.filter(Boolean) : [];
|
||||
if (src.length === 0) return '';
|
||||
if (src.length === 1) return `M ${src[0].x} ${src[0].y}`;
|
||||
const laneOffset = Number(options?.laneOffset) || 0;
|
||||
|
||||
const fmt = (n) => {
|
||||
const v = Number(n);
|
||||
@@ -86,6 +87,23 @@
|
||||
const code = String(p?.code || '').trim();
|
||||
return !!code && stationTransfer && stationTransfer.has(code);
|
||||
};
|
||||
const resolveLaneBendX = (from, to) => {
|
||||
if (!laneOffset) return null;
|
||||
const fromTransfer = isTransfer(from);
|
||||
const toTransfer = isTransfer(to);
|
||||
if (!fromTransfer && !toTransfer) return null;
|
||||
|
||||
const minX = Math.min(from.x, to.x);
|
||||
const maxX = Math.max(from.x, to.x);
|
||||
if ((maxX - minX) < 28) return null;
|
||||
|
||||
const anchorX = toTransfer && !fromTransfer
|
||||
? to.x
|
||||
: (fromTransfer && !toTransfer ? from.x : ((from.x + to.x) / 2));
|
||||
const candidate = Math.max(minX + 12, Math.min(maxX - 12, anchorX + laneOffset));
|
||||
if (Math.abs(candidate - from.x) < 4 || Math.abs(candidate - to.x) < 4) return null;
|
||||
return candidate;
|
||||
};
|
||||
const simplify = (arr) => {
|
||||
const out = [];
|
||||
for (const p of arr) push(out, p);
|
||||
@@ -121,7 +139,11 @@
|
||||
|
||||
const aT = isTransfer(prev);
|
||||
const bT = isTransfer(next);
|
||||
if (bT && !aT) {
|
||||
const bendX = resolveLaneBendX(prev, next);
|
||||
if (Number.isFinite(bendX)) {
|
||||
push(orth, { x: bendX, y: prev.y, code: prev?.code || next?.code });
|
||||
push(orth, { x: bendX, y: y2, code: next?.code || prev?.code });
|
||||
} else if (bT && !aT) {
|
||||
push(orth, { x: x2, y: prev.y, code: next?.code });
|
||||
} else if (aT && !bT) {
|
||||
push(orth, { x: prev.x, y: y2, code: prev?.code });
|
||||
@@ -366,6 +388,10 @@
|
||||
};
|
||||
|
||||
const labelPlacements = {};
|
||||
const getLaneOffset = (lineIdx) => {
|
||||
const center = (Math.max(totalLines, 1) - 1) / 2;
|
||||
return Math.max(-18, Math.min(18, Math.round((lineIdx - center) * 4)));
|
||||
};
|
||||
const placeLabel = (x, y, isStart, name, enName, color) => {
|
||||
const key = `${x},${y},${isStart ? 'left' : 'right'}`;
|
||||
const count = labelPlacements[key] || 0;
|
||||
@@ -411,7 +437,7 @@
|
||||
};
|
||||
});
|
||||
|
||||
const d = buildTransitPath(pts, 16);
|
||||
const d = buildTransitPath(pts, 16, { laneOffset: getLaneOffset(li.idx) });
|
||||
svgContent += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
|
||||
|
||||
// Add line name labels at ends
|
||||
@@ -446,8 +472,8 @@
|
||||
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="6.5" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
|
||||
} else {
|
||||
const color = uniqueColors[0] || '#93a2b7';
|
||||
markerSvg += `<circle cx="${x}" cy="${y}" r="12.5" fill="#ffffff" stroke="${color}" stroke-width="5" />`;
|
||||
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
|
||||
markerSvg += `<circle cx="${x}" cy="${y}" r="12.5" fill="${color}" stroke="${color}" stroke-width="4" />`;
|
||||
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="${color}" stroke-width="2.25" />`;
|
||||
}
|
||||
|
||||
const preferTop = isTransfer || (i % 2 === 0 && y >= baseY + 20);
|
||||
|
||||
Reference in New Issue
Block a user