Compare commits

...
2 Commits
Author SHA1 Message Date
Henry_Du d32d72a3da feat(web): 优化区间选择界面线路图布局
1. 实现正交布线与圆角拐角连接,减少斜线交叉

2. 动态调整线路间距与节点位置,提高整体紧凑性

3. 优化线路名称与换乘站标签的避让逻辑,避免遮挡

4. 删除不再使用的全局预览页(map-preview.html)
2026-07-16 00:26:18 +08:00
Henry_Du 86f3d478d6 feat(ticket-order): 重写线路地图可视化功能,替换原有线路UI
替换原有的标签式线路站点UI,新增加载渲染官方线路SVG地图、线路分段展示、换乘站标记、站点中英文标注、路线规划高亮预览等功能,优化响应式布局与交互逻辑。
2026-07-15 20:41:11 +08:00
3 changed files with 554 additions and 294 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
1. 完成功能制作后自行编辑git commit并推送
2.
2. 提交时请添加功能描述
-63
View File
@@ -1,63 +0,0 @@
<!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>
+553 -230
View File
@@ -1,3 +1,11 @@
/*
* @Author: HenryDu8133 813367384@qq.com
* @Date: 2025-12-06 23:59:11
* @LastEditors: HenryDu8133 813367384@qq.com
* @LastEditTime: 2026-07-15 23:25:36
* @FilePath: \TicketMachine\web\ticket-order.js
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
(() => {
const $ = (sel) => document.querySelector(sel);
const fromEl = $('#from');
@@ -32,10 +40,159 @@
let stationYByCanonical = {};
let stationTransfer = new Set();
// TVM UI State
let activeLineId = null;
let allLinesData = [];
let allStationsData = [];
const 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 large = (a1 - a0) > Math.PI ? 1 : 0;
return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} Z`;
};
const pieSvg = (cx, cy, r, colors) => {
const cols = (Array.isArray(colors) ? colors.filter(Boolean) : []).slice(0, 4);
if (cols.length === 0) return '';
if (cols.length === 1) return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${cols[0]}" />`;
const step = (Math.PI * 2) / cols.length;
let out = '';
for (let i = 0; i < cols.length; i++) {
const a0 = -Math.PI / 2 + i * step;
const a1 = a0 + step;
out += `<path d="${piePath(cx, cy, r, a0, a1)}" fill="${cols[i]}" />`;
}
return out;
};
const buildTransitPath = (points, radius = 12) => {
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 fmt = (n) => {
const v = Number(n);
if (!Number.isFinite(v)) return '0';
return String(Math.round(v * 10) / 10);
};
const eq = (a, b) => Math.abs(a.x - b.x) < 0.01 && Math.abs(a.y - b.y) < 0.01;
const push = (arr, p) => {
const x = Number(p?.x);
const y = Number(p?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
const item = { x, y, code: p?.code ? String(p.code).trim() : '' };
if (arr.length && eq(arr[arr.length - 1], item)) return;
arr.push(item);
};
const isTransfer = (p) => {
const code = String(p?.code || '').trim();
return !!code && stationTransfer && stationTransfer.has(code);
};
const simplify = (arr) => {
const out = [];
for (const p of arr) push(out, p);
let changed = true;
while (changed) {
changed = false;
for (let i = 1; i < out.length - 1; i++) {
const a = out[i - 1];
const b = out[i];
const c = out[i + 1];
if ((a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y)) {
out.splice(i, 1);
changed = true;
break;
}
}
}
return out;
};
const orth = [];
push(orth, src[0]);
for (let i = 1; i < src.length; i++) {
const prev = orth[orth.length - 1];
const next = src[i];
const x2 = Number(next?.x);
const y2 = Number(next?.y);
if (!Number.isFinite(x2) || !Number.isFinite(y2)) continue;
if (prev.x === x2 || prev.y === y2) {
push(orth, next);
continue;
}
const aT = isTransfer(prev);
const bT = isTransfer(next);
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 });
} else {
push(orth, { x: x2, y: prev.y, code: next?.code });
}
push(orth, next);
}
const pts = simplify(orth);
if (pts.length === 0) return '';
if (pts.length === 1) return `M ${fmt(pts[0].x)} ${fmt(pts[0].y)}`;
if (radius <= 0 || pts.length === 2) {
let d = `M ${fmt(pts[0].x)} ${fmt(pts[0].y)}`;
for (let i = 1; i < pts.length; i++) d += ` L ${fmt(pts[i].x)} ${fmt(pts[i].y)}`;
return d;
}
const rBase = Math.max(0, Number(radius) || 0);
let d = `M ${fmt(pts[0].x)} ${fmt(pts[0].y)}`;
for (let i = 1; i < pts.length - 1; i++) {
const a = pts[i - 1];
const b = pts[i];
const c = pts[i + 1];
const v1x = b.x - a.x;
const v1y = b.y - a.y;
const v2x = c.x - b.x;
const v2y = c.y - b.y;
const len1 = Math.hypot(v1x, v1y);
const len2 = Math.hypot(v2x, v2y);
if (!len1 || !len2) continue;
const r = Math.min(rBase, len1 / 2, len2 / 2);
const p1 = { x: b.x - (v1x / len1) * r, y: b.y - (v1y / len1) * r };
const p2 = { x: b.x + (v2x / len2) * r, y: b.y + (v2y / len2) * r };
d += ` L ${fmt(p1.x)} ${fmt(p1.y)} Q ${fmt(b.x)} ${fmt(b.y)} ${fmt(p2.x)} ${fmt(p2.y)}`;
}
d += ` L ${fmt(pts[pts.length - 1].x)} ${fmt(pts[pts.length - 1].y)}`;
return d;
};
async function loadMap() {
try {
const res = await fetch('/api/public/fares/map/light');
const svg = await res.text();
mapContainer.innerHTML = svg;
const svgEl = mapContainer.querySelector('svg');
if(!svgEl) return;
renderLineMap();
} catch(e) {
mapContainer.innerHTML = '<div class="error">加载地图失败</div>';
}
}
async function renderLineMap() {
try {
const [lines, stations] = await Promise.all([
fetch('/api/public/lines').then(r=>r.json()),
fetch('/api/public/stations').then(r=>r.json())
]);
let html = '';
lines.forEach(line => {
const stList = line.站序 || [];
});
} catch(e) {}
}
async function renderSystemMap() {
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
@@ -46,15 +203,11 @@
]);
window.cachedStationsData = stationsData;
allLinesData = linesData;
allStationsData = stationsData;
stationTransfer = new Set();
stationCanonicalByCode = {};
stationCodesByCanonical = {};
stationNameByCode = {};
stationEnByCode = {};
for (const s of stationsData) {
const code = String(s.code || s.编号 || '').trim();
if (!code) continue;
@@ -69,245 +222,297 @@
if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code));
}
const lineStops = [];
const lineColors = [];
const linesByStation = new Map();
const lineIdxsByCanonical = {};
// NEW logic for breaking lines with '-'
let currentLineIdx = 0;
for (let i = 0; i < linesData.length; i++) {
const line = linesData[i] || {};
const color = line.color || line.颜色 || '#93a2b7';
const stopsRaw = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []);
const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean);
if (stops.length === 0) continue;
let currentSegment = [];
let addedAny = false;
for (const c of stops) {
if (c === '-') continue;
const arr = linesByStation.get(c) || [];
arr.push(line);
linesByStation.set(c, arr);
if (c === '-') {
if (currentSegment.length > 0) {
lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops: currentSegment });
lineColors.push(color);
for (const stopCode of currentSegment) {
const arr = linesByStation.get(stopCode) || [];
arr.push(currentLineIdx);
linesByStation.set(stopCode, arr);
const canonical = stationCanonicalByCode[stopCode] || stopCode;
if (!lineIdxsByCanonical[canonical]) lineIdxsByCanonical[canonical] = new Set();
lineIdxsByCanonical[canonical].add(currentLineIdx);
}
currentSegment = [];
addedAny = true;
}
} else {
currentSegment.push(c);
}
}
if (currentSegment.length > 0) {
lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops: currentSegment });
lineColors.push(color);
for (const stopCode of currentSegment) {
const arr = linesByStation.get(stopCode) || [];
arr.push(currentLineIdx);
linesByStation.set(stopCode, arr);
const canonical = stationCanonicalByCode[stopCode] || stopCode;
if (!lineIdxsByCanonical[canonical]) lineIdxsByCanonical[canonical] = new Set();
lineIdxsByCanonical[canonical].add(currentLineIdx);
}
addedAny = true;
}
if (addedAny) {
currentLineIdx++;
}
}
if (lineStops.length === 0) {
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂无可显示的线路数据。</p></div>';
return;
}
for (const [c, arr] of linesByStation.entries()) {
const uniq = Array.from(new Set(arr.map(l => l.id)));
const uniq = Array.from(new Set(arr));
if (uniq.length >= 2) stationTransfer.add(c);
}
// Select first line by default if none selected
if (!activeLineId && allLinesData.length > 0) {
activeLineId = allLinesData[0].id;
// Build SVG
const legendW = 10;
const startX = 140;
const baseY = 80;
const totalLines = currentLineIdx;
const maxStops = Math.max(2, ...lineStops.map(li => Array.isArray(li.stops) ? li.stops.length : 2));
let lineGapY = 80;
if (totalLines >= 14) lineGapY = 66;
else if (totalLines >= 10) lineGapY = 72;
lineGapY = Math.max(60, Math.min(84, lineGapY));
let minGapX = 80;
if (maxStops >= 24) minGapX = 64;
else if (maxStops >= 18) minGapX = 72;
minGapX = Math.max(56, Math.min(96, minGapX));
const labelStackGap = Math.max(30, Math.round(lineGapY * 0.55));
const occurrences = {};
for (const li of lineStops) {
for (let i = 0; i < li.stops.length; i++) {
const code = li.stops[i];
if (code === '-') continue;
const canonical = stationCanonicalByCode[code] || code;
if (!occurrences[canonical]) occurrences[canonical] = [];
occurrences[canonical].push(i * minGapX);
}
}
renderTvmUI();
stationXByCanonical = {};
for (const canonical of Object.keys(occurrences)) {
const arr = occurrences[canonical];
const avg = arr.reduce((a,b)=>a+b,0) / Math.max(1, arr.length);
stationXByCanonical[canonical] = startX + Math.round(avg);
}
for (let pass = 0; pass < 3; pass++) {
for (const li of lineStops) {
let prevX = startX - minGapX;
for (const code of li.stops) {
if (code === '-') continue;
const canonical = stationCanonicalByCode[code] || code;
const x = stationXByCanonical[canonical] ?? (prevX + minGapX);
const nx = Math.max(x, prevX + minGapX);
if (stationXByCanonical[canonical] == null || nx > stationXByCanonical[canonical]) {
stationXByCanonical[canonical] = nx;
}
prevX = stationXByCanonical[canonical];
}
}
}
// 3. Assign Grid Y to canonical stations (Average of their lines)
const hubYByCanonical = {};
for (const canonical of Object.keys(lineIdxsByCanonical)) {
const idxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b);
if (!idxs.length) continue;
const ys = idxs.map(idx => baseY + idx * lineGapY);
hubYByCanonical[canonical] = ys.reduce((sum, y) => sum + y, 0) / ys.length;
}
stationYByCanonical = {};
for (const li of lineStops) {
const yNode = baseY + li.idx * lineGapY;
for (const c of li.stops) {
const canonical = stationCanonicalByCode[c] || c;
stationYByCanonical[c] = hubYByCanonical[canonical] ?? yNode;
}
}
const getContrastColor = (hexcolor) => {
if (!hexcolor) return '#ffffff';
let hex = hexcolor.replace('#', '');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
const r = parseInt(hex.substr(0,2),16) || 0;
const g = parseInt(hex.substr(2,2),16) || 0;
const b = parseInt(hex.substr(4,2),16) || 0;
const yiq = ((r*299)+(g*587)+(b*114))/1000;
return (yiq >= 140) ? '#0f172a' : '#ffffff';
};
const labelPlacements = {};
const placeLabel = (x, y, isStart, name, enName, color) => {
const key = `${x},${y},${isStart ? 'left' : 'right'}`;
const count = labelPlacements[key] || 0;
labelPlacements[key] = count + 1;
const textColor = getContrastColor(color);
const subTextColor = textColor === '#ffffff' ? 'rgba(255,255,255,0.9)' : 'rgba(15,23,42,0.8)';
const hasEn = !!enName;
const nameLen = Math.max(name.length * 13 + 16, hasEn ? enName.length * 6.5 + 16 : 0);
const h = hasEn ? 36 : 24;
const yOffset = count * labelStackGap - (h / 2);
let lx, textX;
if (isStart) {
lx = x - 54 - nameLen; // Pushed further out to avoid line overlap
textX = lx + nameLen / 2;
} else {
lx = x + 54; // Pushed further out
textX = lx + nameLen / 2;
}
let svg = `<rect x="${lx}" y="${y + yOffset}" width="${nameLen}" height="${h}" rx="6" fill="${color}" />`;
if (hasEn) {
svg += `<text x="${textX}" y="${y + yOffset + 15}" text-anchor="middle" fill="${textColor}" font-size="12" font-weight="bold">${name}</text>`;
svg += `<text x="${textX}" y="${y + yOffset + 28}" text-anchor="middle" fill="${subTextColor}" font-size="9" font-weight="600">${enName}</text>`;
} else {
svg += `<text x="${textX}" y="${y + yOffset + 16}" text-anchor="middle" fill="${textColor}" font-size="12" font-weight="bold">${name}</text>`;
}
return svg;
};
let svgContent = '';
for (const li of lineStops) {
if (li.stops.length >= 2) {
const pts = li.stops.map(code => {
const canonical = stationCanonicalByCode[code] || code;
return {
x: stationXByCanonical[canonical] || 0,
y: stationYByCanonical[code] ?? (baseY + li.idx * lineGapY),
code
};
});
const d = buildTransitPath(pts, 16);
svgContent += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
// Add line name labels at ends
const pStart = pts[0];
const pEnd = pts[pts.length - 1];
const yLabel = baseY + li.idx * lineGapY;
svgContent += placeLabel(pStart.x, yLabel, true, li.name, li.enName, li.color);
svgContent += placeLabel(pEnd.x, yLabel, false, li.name, li.enName, li.color);
}
}
const sortedStations = Object.keys(stationXByCanonical).sort((a, b) => stationXByCanonical[a] - stationXByCanonical[b]);
for (let i = 0; i < sortedStations.length; i++) {
const canonical = sortedStations[i];
const x = stationXByCanonical[canonical];
const codes = stationCodesByCanonical[canonical] || [canonical];
const lineIdxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b);
const colors = lineIdxs.map(idx => lineColors[idx]).filter(Boolean);
const uniqueColors = Array.from(new Set(colors));
const primaryCode = codes[0];
const y = hubYByCanonical[canonical] ?? stationYByCanonical[primaryCode] ?? baseY;
const isTransfer = uniqueColors.length >= 2 || codes.length >= 2;
const name = getStationDisplayName(primaryCode);
const enName = stationEnByCode[primaryCode] || '';
const showEn = !!enName;
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] || '#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" />`;
}
const preferTop = isTransfer || (i % 2 === 0 && y >= baseY + 20);
let labelSvg = '';
let textContent = '';
const codeFontSize = 9;
const nameFontSize = 13;
const enFontSize = 8.5;
const codeW = primaryCode.length * 6 + 10;
if (preferTop) {
const bottomY = y - 22;
const enY = bottomY;
const nameY = showEn ? bottomY - 12 : bottomY;
const codeY = nameY - 18;
textContent += `<rect x="${x - codeW / 2}" y="${codeY - 8}" width="${codeW}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
textContent += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${primaryCode}</text>`;
// Text with white halo for readability
textContent += `<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 (showEn) {
textContent += `<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 + 34;
const codeY = topY;
const nameY = topY + 18;
const enY = nameY + 12;
textContent += `<rect x="${x - codeW / 2}" y="${codeY - 8}" width="${codeW}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
textContent += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${primaryCode}</text>`;
textContent += `<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 (showEn) {
textContent += `<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>`;
}
}
labelSvg += textContent;
svgContent += `
<g class="map-station ${isTransfer ? 'transfer' : ''}" data-code="${primaryCode}" data-canonical="${canonical}" onclick="handleStationClick('${primaryCode}')" style="cursor:pointer">
<rect x="${x - 20}" y="${y - 20}" width="40" height="40" fill="transparent" />
${markerSvg}
${labelSvg}
</g>
`;
}
const allCanonicals = Object.keys(stationXByCanonical);
const minX = Math.min(...allCanonicals.map(c => stationXByCanonical[c] || 0));
const maxX = Math.max(...allCanonicals.map(c => stationXByCanonical[c] || 0));
const width = Math.max(560, maxX + 200);
const height = Math.max(260, baseY + currentLineIdx * lineGapY + 120);
mapContainer.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
} catch(e) {
console.error(e);
mapContainer.innerHTML = '<div class="error">数据加载失败</div>';
mapContainer.innerHTML = '<div class="error">地图加载失败</div>';
}
}
function renderTvmUI() {
if (!allLinesData || allLinesData.length === 0) {
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂无可显示的线路数据。</p></div>';
return;
}
const nodeSlotWidth = window.innerWidth <= 768 ? 58 : 70;
const trackPadding = Math.round(nodeSlotWidth / 2);
// 1. Render Line Tabs
let tabsHtml = `<div class="tvm-lines-nav" style="display: flex; gap: 8px; overflow-x: auto; padding: 12px; background: var(--surface-color, #f8fafc); border-bottom: 1px solid var(--border-color, #e2e8f0); border-radius: 12px 12px 0 0; -webkit-overflow-scrolling: touch; scrollbar-width: none;">`;
allLinesData.forEach(line => {
const color = line.color || '#94a3b8';
const isActive = String(line.id) === String(activeLineId);
const bg = isActive ? color : 'var(--bg-color, #ffffff)';
const fg = isActive ? '#ffffff' : 'var(--text-color, #334155)';
const border = isActive ? color : 'var(--border-color, #cbd5e1)';
tabsHtml += `
<button class="tvm-line-tab" onclick="window.switchTvmLine('${line.id}')" style="
flex-shrink: 0;
padding: 8px 16px;
border-radius: 20px;
border: 2px solid ${border};
background: ${bg};
color: ${fg};
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 6px;
box-shadow: ${isActive ? '0 2px 4px rgba(0,0,0,0.1)' : 'none'};
white-space: nowrap;
">
<span style="width: 10px; height: 10px; border-radius: 50%; background: ${isActive ? 'var(--bg-color, #fff)' : color}; display: inline-block;"></span>
${line.name || line.线路名称 || '未知线路'}
</button>
`;
});
tabsHtml += `</div>`;
// 2. Render Stations for Active Line
const activeLine = allLinesData.find(l => String(l.id) === String(activeLineId)) || allLinesData[0];
const stopsRaw = Array.isArray(activeLine.stops) ? activeLine.stops : (Array.isArray(activeLine.站点列表) ? activeLine.站点列表 : []);
const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean);
const visibleStops = stops.filter(code => code !== '-');
let stationsHtml = `<div class="tvm-stations-line-container" style="overflow-x: auto; padding: 40px 20px 20px 20px; background: var(--bg-color, #ffffff); border-radius: 0 0 12px 12px; -webkit-overflow-scrolling: touch; scrollbar-width: thin;">`;
stationsHtml += `<div style="display: flex; position: relative; min-width: max-content; align-items: flex-start;">`;
stationsHtml += `<div style="position: absolute; top: 14px; left: ${trackPadding}px; right: ${trackPadding}px; height: 8px; background-color: ${activeLine.color || '#94a3b8'}; border-radius: 4px; z-index: 1;"></div>`;
const getVisibleStopIndex = (targetCode) => {
const targetCanonical = stationCanonicalByCode[String(targetCode || '').trim()] || String(targetCode || '').trim();
return visibleStops.findIndex(code => (stationCanonicalByCode[code] || code) === targetCanonical);
};
const fromIndex = getVisibleStopIndex(selection[0]);
const toIndex = getVisibleStopIndex(selection[1]);
const sameLineSelection = fromIndex >= 0
&& toIndex >= 0
&& selection[0]
&& selection[1]
&& (!Array.isArray(currentRouteTransfers) || currentRouteTransfers.length === 0);
if (sameLineSelection) {
const startIndex = Math.min(fromIndex, toIndex);
const endIndex = Math.max(fromIndex, toIndex);
const left = (startIndex * nodeSlotWidth) + trackPadding;
const width = Math.max((endIndex - startIndex) * nodeSlotWidth, 0);
const arrowCount = Math.max(1, Math.floor(width / 40));
let arrowsHtml = '';
for (let i = 0; i < arrowCount; i++) {
arrowsHtml += `<span style="font-size: 16px; line-height: 1; color: #854d0e; font-weight: 800;">›</span>`;
}
stationsHtml += `
<div style="
position: absolute;
top: 14px;
left: ${left}px;
width: ${width}px;
height: 8px;
background: linear-gradient(90deg, rgba(250,204,21,0.92) 0%, #facc15 100%);
border-radius: 999px;
z-index: 2;
box-shadow: 0 0 0 2px rgba(255,255,255,0.85), 0 4px 12px rgba(250, 204, 21, 0.35);
overflow: visible;
">
<div style="
position: absolute;
inset: -7px 8px auto 8px;
display: flex;
align-items: center;
justify-content: space-around;
pointer-events: none;
">${arrowsHtml}</div>
</div>
`;
}
let hasStations = false;
stops.forEach(code => {
if (code === '-') return;
hasStations = true;
const name = stationNameByCode[code] || code;
const enName = stationEnByCode[code] || '';
const isSelectedFrom = selection[0] === code;
const isSelectedTo = selection[1] === code;
const inRoute = Array.isArray(currentRoute) && currentRoute.includes(code);
// Check transfers
const canonical = stationCanonicalByCode[code] || code;
const codesInGroup = stationCodesByCanonical[canonical] || [canonical];
const linesForThisStation = new Set();
codesInGroup.forEach(c => {
allLinesData.forEach(l => {
const lStops = Array.isArray(l.stops) ? l.stops : (Array.isArray(l.站点列表) ? l.站点列表 : []);
if (lStops.includes(c)) linesForThisStation.add(l);
});
});
const transferLines = Array.from(linesForThisStation).filter(l => String(l.id) !== String(activeLineId));
const isTransfer = transferLines.length > 0;
let nodeColor = activeLine.color || '#94a3b8';
let nodeSize = isTransfer ? '20px' : '14px';
let nodeBorder = isTransfer ? '4px' : '3px';
let badgeHtml = '';
if (isSelectedFrom) {
nodeColor = 'var(--success, #16a34a)';
nodeSize = '24px';
badgeHtml = `<div style="position: absolute; top: -32px; background: var(--success, #16a34a); color: white; font-size: 11px; padding: 2px 6px; border-radius: 4px; font-weight: bold; white-space: nowrap; box-shadow: 0 2px 4px rgba(0,0,0,0.2); z-index: 10;">起点</div>`;
} else if (isSelectedTo) {
nodeColor = 'var(--danger, #dc2626)';
nodeSize = '24px';
badgeHtml = `<div style="position: absolute; top: -32px; background: var(--danger, #dc2626); color: white; font-size: 11px; padding: 2px 6px; border-radius: 4px; font-weight: bold; white-space: nowrap; box-shadow: 0 2px 4px rgba(0,0,0,0.2); z-index: 10;">终点</div>`;
} else if (inRoute) {
nodeColor = 'var(--primary, #facc15)';
if (!isTransfer) nodeSize = '18px';
else nodeSize = '24px';
}
let transferDotsHtml = '';
if (isTransfer) {
transferDotsHtml = `<div style="display: flex; gap: 3px; position: absolute; top: -14px;">`;
transferLines.slice(0, 3).forEach(l => {
transferDotsHtml += `<div title="可换乘 ${l.name}" style="width: 8px; height: 8px; border-radius: 50%; background: ${l.color || '#999'}; border: 1px solid #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.2);"></div>`;
});
transferDotsHtml += `</div>`;
}
stationsHtml += `
<div class="tvm-station-node" onclick="window.handleStationClick('${code}')" style="
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
width: ${nodeSlotWidth}px;
flex-shrink: 0;
cursor: pointer;
">
<!-- Fixed height container for vertical alignment of the track nodes -->
<div style="height: 36px; display: flex; align-items: center; justify-content: center; position: relative; width: 100%;">
${badgeHtml}
${transferDotsHtml}
<div style="
width: ${nodeSize};
height: ${nodeSize};
border-radius: 50%;
background: #fff;
border: ${nodeBorder} solid ${nodeColor};
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 2;
transition: all 0.2s;
"></div>
</div>
<!-- Labels -->
<div style="text-align: center; margin-top: 8px; width: 100%; display: flex; flex-direction: column; align-items: center;">
<div style="font-size: 10px; color: var(--text-muted, #64748b); font-weight: 700; margin-bottom: 2px;">${code}</div>
<div style="font-size: ${window.innerWidth <= 768 ? '13px' : '14px'}; font-weight: 800; color: var(--text-color, #0f172a); line-height: 1.2; word-break: break-all; max-width: ${Math.max(nodeSlotWidth - 10, 46)}px;">${name}</div>
${enName ? `<div style="font-size: 9px; color: var(--text-muted, #94a3b8); font-weight: 600; margin-top: 2px; transform: scale(0.9);">${enName}</div>` : ''}
</div>
</div>
`;
});
if (!hasStations) {
stationsHtml += `<div style="width: 100%; text-align: center; padding: 40px; color: var(--text-muted, #64748b);">该线路暂无站点</div>`;
}
stationsHtml += `</div></div>`;
mapContainer.innerHTML = tabsHtml + stationsHtml;
}
window.switchTvmLine = (lineId) => {
activeLineId = lineId;
renderTvmUI();
};
window.handleStationClick = (code) => {
code = String(code || '').trim();
// Toggle if already selected
@@ -432,6 +637,104 @@
return merged;
}
function getStationPoint(code) {
const normalized = String(code || '').trim();
if (!normalized) return null;
const canonical = stationCanonicalByCode[normalized] || normalized;
const x = stationXByCanonical[canonical];
const y = stationYByCanonical[normalized];
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y, code: normalized };
}
function getMapStationEl(code) {
const normalized = String(code || '').trim();
if (!normalized) return null;
const canonical = stationCanonicalByCode[normalized] || normalized;
return document.querySelector(`.map-station[data-canonical="${canonical}"]`);
}
function renderRouteOverlay() {
const svgEl = mapContainer.querySelector('svg');
if (!svgEl) return;
const existing = svgEl.querySelector('.route-overlay-group');
if (existing) existing.remove();
if (!Array.isArray(currentRoute) || currentRoute.length < 2) return;
const points = currentRoute.map(getStationPoint).filter(Boolean);
if (points.length < 2) return;
const ns = 'http://www.w3.org/2000/svg';
const group = document.createElementNS(ns, 'g');
group.setAttribute('class', 'route-overlay-group');
const pathData = buildTransitPath(points, 16);
if (!pathData) return;
const glow = document.createElementNS(ns, 'path');
glow.setAttribute('d', pathData);
glow.setAttribute('fill', 'none');
glow.setAttribute('stroke', 'rgba(250, 204, 21, 0.38)');
glow.setAttribute('stroke-width', '18');
glow.setAttribute('stroke-linecap', 'round');
glow.setAttribute('stroke-linejoin', 'round');
const main = document.createElementNS(ns, 'path');
main.setAttribute('d', pathData);
main.setAttribute('fill', 'none');
main.setAttribute('stroke', '#facc15');
main.setAttribute('stroke-width', '8');
main.setAttribute('stroke-linecap', 'round');
main.setAttribute('stroke-linejoin', 'round');
group.appendChild(glow);
group.appendChild(main);
// Dynamic arrows pointing to the destination
let totalDist = 0;
for (let i = 0; i < points.length - 1; i++) {
const dx = points[i+1].x - points[i].x;
const dy = points[i+1].y - points[i].y;
totalDist += Math.sqrt(dx * dx + dy * dy);
}
const speed = 150; // pixels per second
const duration = Math.max(1, totalDist / speed);
const numArrows = Math.max(1, Math.floor(duration / 0.8));
const interval = duration / numArrows;
for (let i = 0; i < numArrows; i++) {
const arrowG = document.createElementNS(ns, 'g');
const arrowPath = document.createElementNS(ns, 'path');
// Chevron shape
arrowPath.setAttribute('d', 'M -6 -5 L 4 0 L -6 5');
arrowPath.setAttribute('fill', 'none');
arrowPath.setAttribute('stroke', '#854d0e'); // Dark brown/orange to contrast with yellow
arrowPath.setAttribute('stroke-width', '2.5');
arrowPath.setAttribute('stroke-linecap', 'round');
arrowPath.setAttribute('stroke-linejoin', 'round');
const animateMotion = document.createElementNS(ns, 'animateMotion');
animateMotion.setAttribute('dur', `${duration}s`);
animateMotion.setAttribute('repeatCount', 'indefinite');
animateMotion.setAttribute('path', pathData);
animateMotion.setAttribute('rotate', 'auto');
animateMotion.setAttribute('begin', `-${i * interval}s`);
// Use keyPoints/keyTimes to ensure constant speed along the path
animateMotion.setAttribute('calcMode', 'linear');
arrowG.appendChild(arrowPath);
arrowG.appendChild(animateMotion);
group.appendChild(arrowG);
}
const firstStation = svgEl.querySelector('.map-station');
if (firstStation) svgEl.insertBefore(group, firstStation);
else svgEl.appendChild(group);
}
function updateSelectionUI(skipPreview = false) {
if (!(selection[0] && selection[1])) {
currentRoute = [];
@@ -450,8 +753,29 @@
$('#fromDisplay').textContent = selection[0] ? getName(selection[0]) : '请在上方地图选择';
$('#toDisplay').textContent = selection[1] ? getName(selection[1]) : '请在上方地图选择';
// Refresh TVM UI to show selected/route states
renderTvmUI();
// Update Map Styles
document.querySelectorAll('.map-station').forEach(el => {
const code = el.getAttribute('data-code');
const canonical = el.getAttribute('data-canonical');
el.classList.remove('start', 'end', 'selected', 'route', 'route-transfer');
if (selection[0] && canonical === (stationCanonicalByCode[selection[0]] || selection[0])) el.classList.add('start');
if (selection[1] && canonical === (stationCanonicalByCode[selection[1]] || selection[1])) el.classList.add('end');
});
if (Array.isArray(currentRoute) && currentRoute.length > 0) {
for (const c of currentRoute) {
const el = getMapStationEl(c);
if (el) el.classList.add('route');
}
}
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.length > 0) {
for (const c of currentRouteTransfers) {
const el = getMapStationEl(c);
if (el) el.classList.add('route-transfer');
}
}
renderRouteOverlay();
// Auto preview if both selected
if(!skipPreview && selection[0] && selection[1]) previewPrice();
@@ -461,8 +785,7 @@
renderSystemMap();
window.addEventListener('resize', () => {
if (!allLinesData || allLinesData.length === 0) return;
renderTvmUI();
if (currentRoute.length > 0) renderRouteOverlay();
});
async function previewPrice(force = false){