feat(route-map): 新增线路预览页面并优化八向布线布局
- 新增全网线路图预览前端页面 `/web/map-preview.html` - 调整布局物理参数与迭代次数优化排布效果 - 新增正交八向布线强制对齐逻辑 - 实现节点网格对齐与优化线路路径转角处理 - 调整锚点固定力度提升布局稳定性
This commit is contained in:
@@ -380,12 +380,12 @@ function optimizeLayout(nodeMap, lineSegments, referenceMeta) {
|
||||
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 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) {
|
||||
@@ -393,6 +393,7 @@ function optimizeLayout(nodeMap, lineSegments, referenceMeta) {
|
||||
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];
|
||||
@@ -418,6 +419,7 @@ function optimizeLayout(nodeMap, lineSegments, referenceMeta) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
@@ -448,14 +450,46 @@ function optimizeLayout(nodeMap, lineSegments, referenceMeta) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.14 : anchorStrength;
|
||||
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];
|
||||
@@ -466,6 +500,7 @@ function optimizeLayout(nodeMap, lineSegments, referenceMeta) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -480,10 +515,16 @@ function optimizeLayout(nodeMap, lineSegments, referenceMeta) {
|
||||
const offsetX = 130;
|
||||
const offsetY = 110;
|
||||
|
||||
const GRID_SIZE = 24;
|
||||
|
||||
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);
|
||||
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;
|
||||
@@ -492,8 +533,37 @@ function optimizeLayout(nodeMap, lineSegments, referenceMeta) {
|
||||
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}`;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>全网线路图预览 - FSE铁路运输票务系统</title>
|
||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; background: #f8fafc; display: flex; flex-direction: column; height: 100vh; font-family: system-ui, -apple-system, sans-serif; }
|
||||
header { background: white; padding: 16px 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; justify-content: space-between; align-items: center; z-index: 10; }
|
||||
h1 { margin: 0; font-size: 18px; color: #0f172a; font-weight: 700; }
|
||||
.btn { background: #3b82f6; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; text-decoration: none; font-size: 14px; font-weight: 500; transition: background 0.2s; }
|
||||
.btn:hover { background: #2563eb; }
|
||||
.btn-secondary { background: #e2e8f0; color: #475569; }
|
||||
.btn-secondary:hover { background: #cbd5e1; }
|
||||
.controls { display: flex; gap: 12px; }
|
||||
#map-container { flex: 1; overflow: auto; display: flex; justify-content: center; align-items: center; padding: 32px; position: relative; }
|
||||
#map-container svg { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); border-radius: 12px; background: white; max-width: none; transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); transform-origin: center; }
|
||||
.loading-hint { color: #64748b; font-size: 15px; position: absolute; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>全网线路图预览 (正交布线)</h1>
|
||||
<div class="controls">
|
||||
<a href="/index.html" class="btn btn-secondary">返回后台</a>
|
||||
<button class="btn" onclick="loadMap()">重新生成</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="map-container">
|
||||
<div class="loading-hint">正在计算布局,请稍候...</div>
|
||||
</div>
|
||||
<script>
|
||||
let scale = 1;
|
||||
async function loadMap() {
|
||||
const container = document.getElementById('map-container');
|
||||
container.innerHTML = '<div class="loading-hint">正在计算正交布局,请稍候...</div>';
|
||||
try {
|
||||
const res = await fetch('/api/public/fares/map/light?t=' + Date.now());
|
||||
const svg = await res.text();
|
||||
container.innerHTML = svg;
|
||||
scale = 1;
|
||||
updateZoom();
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="color: #ef4444;">加载失败</div>';
|
||||
}
|
||||
}
|
||||
function updateZoom() {
|
||||
const svg = document.querySelector('#map-container svg');
|
||||
if(svg) svg.style.transform = `scale(${scale})`;
|
||||
}
|
||||
document.getElementById('map-container').addEventListener('wheel', (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
scale += e.deltaY * -0.002;
|
||||
scale = Math.min(Math.max(0.125, scale), 4);
|
||||
updateZoom();
|
||||
}
|
||||
}, { passive: false });
|
||||
loadMap();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user