refactor(ticket-order): 清理废弃的SVG渲染代码并优化TVM界面高亮反馈
This commit is contained in:
@@ -158,7 +158,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=11"></script>
|
||||||
<script src="/ticket-order.js?v=30"></script>
|
<script src="/ticket-order.js?v=31"></script>
|
||||||
<script src="/public-status.js?v=14"></script>
|
<script src="/public-status.js?v=14"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
+151
-468
@@ -32,71 +32,10 @@
|
|||||||
let stationYByCanonical = {};
|
let stationYByCanonical = {};
|
||||||
let stationTransfer = new Set();
|
let stationTransfer = new Set();
|
||||||
|
|
||||||
const piePath = (cx, cy, r, a0, a1) => {
|
// TVM UI State
|
||||||
const x0 = cx + r * Math.cos(a0);
|
let activeLineId = null;
|
||||||
const y0 = cy + r * Math.sin(a0);
|
let allLinesData = [];
|
||||||
const x1 = cx + r * Math.cos(a1);
|
let allStationsData = [];
|
||||||
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) => {
|
|
||||||
if (!points || points.length === 0) return '';
|
|
||||||
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
|
|
||||||
|
|
||||||
let d = `M ${points[0].x} ${points[0].y}`;
|
|
||||||
for (let i = 1; i < points.length; i++) {
|
|
||||||
d += ` L ${points[i].x} ${points[i].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() {
|
async function renderSystemMap() {
|
||||||
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
|
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
|
||||||
@@ -107,11 +46,15 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
window.cachedStationsData = stationsData;
|
window.cachedStationsData = stationsData;
|
||||||
|
allLinesData = linesData;
|
||||||
|
allStationsData = stationsData;
|
||||||
|
|
||||||
stationTransfer = new Set();
|
stationTransfer = new Set();
|
||||||
stationCanonicalByCode = {};
|
stationCanonicalByCode = {};
|
||||||
stationCodesByCanonical = {};
|
stationCodesByCanonical = {};
|
||||||
stationNameByCode = {};
|
stationNameByCode = {};
|
||||||
stationEnByCode = {};
|
stationEnByCode = {};
|
||||||
|
|
||||||
for (const s of stationsData) {
|
for (const s of stationsData) {
|
||||||
const code = String(s.code || s.编号 || '').trim();
|
const code = String(s.code || s.编号 || '').trim();
|
||||||
if (!code) continue;
|
if (!code) continue;
|
||||||
@@ -126,312 +69,175 @@
|
|||||||
if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code));
|
if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineStops = [];
|
|
||||||
const lineColors = [];
|
|
||||||
const linesByStation = new Map();
|
const linesByStation = new Map();
|
||||||
const lineIdxsByCanonical = {};
|
|
||||||
|
|
||||||
// NEW logic for breaking lines with '-'
|
|
||||||
let currentLineIdx = 0;
|
|
||||||
for (let i = 0; i < linesData.length; i++) {
|
for (let i = 0; i < linesData.length; i++) {
|
||||||
const line = linesData[i] || {};
|
const line = linesData[i] || {};
|
||||||
const color = line.color || line.颜色 || '#93a2b7';
|
|
||||||
const stopsRaw = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []);
|
const stopsRaw = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []);
|
||||||
const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean);
|
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) {
|
for (const c of stops) {
|
||||||
if (c === '-') {
|
if (c === '-') continue;
|
||||||
if (currentSegment.length > 0) {
|
const arr = linesByStation.get(c) || [];
|
||||||
lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops: currentSegment });
|
arr.push(line);
|
||||||
lineColors.push(color);
|
linesByStation.set(c, arr);
|
||||||
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) {
|
for (const [c, arr] of linesByStation.entries()) {
|
||||||
|
const uniq = Array.from(new Set(arr.map(l => l.id)));
|
||||||
|
if (uniq.length >= 2) stationTransfer.add(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select first line by default if none selected
|
||||||
|
if (!activeLineId && allLinesData.length > 0) {
|
||||||
|
activeLineId = allLinesData[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTvmUI();
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
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>';
|
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂无可显示的线路数据。</p></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [c, arr] of linesByStation.entries()) {
|
// 1. Render Line Tabs
|
||||||
const uniq = Array.from(new Set(arr));
|
let tabsHtml = `<div class="tvm-lines-nav" style="display: flex; gap: 8px; overflow-x: auto; padding: 12px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; border-radius: 12px 12px 0 0;">`;
|
||||||
if (uniq.length >= 2) stationTransfer.add(c);
|
allLinesData.forEach(line => {
|
||||||
}
|
const color = line.color || '#94a3b8';
|
||||||
|
const isActive = line.id === activeLineId;
|
||||||
|
const bg = isActive ? color : '#ffffff';
|
||||||
|
const fg = isActive ? '#ffffff' : '#334155';
|
||||||
|
const border = isActive ? 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;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: ${isActive ? '0 2px 4px rgba(0,0,0,0.1)' : 'none'};
|
||||||
|
">
|
||||||
|
<span style="width: 10px; height: 10px; border-radius: 50%; background: ${isActive ? '#fff' : color};"></span>
|
||||||
|
${line.name || line.线路名称 || '未知线路'}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
tabsHtml += `</div>`;
|
||||||
|
|
||||||
// Build SVG
|
// 2. Render Stations for Active Line
|
||||||
const legendW = 10;
|
const activeLine = allLinesData.find(l => l.id === activeLineId) || allLinesData[0];
|
||||||
const startX = 140;
|
const stopsRaw = Array.isArray(activeLine.stops) ? activeLine.stops : (Array.isArray(activeLine.站点列表) ? activeLine.站点列表 : []);
|
||||||
const baseY = 80;
|
const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean);
|
||||||
const lineGapY = 80;
|
|
||||||
const minGapX = 80;
|
|
||||||
|
|
||||||
const occurrences = {};
|
let stationsHtml = `<div class="tvm-stations-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; padding: 20px; background: #ffffff; border-radius: 0 0 12px 12px; max-height: 400px; overflow-y: auto;">`;
|
||||||
for (const li of lineStops) {
|
|
||||||
for (let i = 0; i < li.stops.length; i++) {
|
let hasStations = false;
|
||||||
const code = li.stops[i];
|
stops.forEach(code => {
|
||||||
if (code === '-') continue;
|
if (code === '-') return; // Skip breakpoints in this view
|
||||||
|
hasStations = true;
|
||||||
|
|
||||||
|
const name = stationNameByCode[code] || code;
|
||||||
|
const enName = stationEnByCode[code] || '';
|
||||||
|
|
||||||
|
const isSelectedFrom = selection[0] === code;
|
||||||
|
const isSelectedTo = selection[1] === code;
|
||||||
|
const isSelected = isSelectedFrom || isSelectedTo;
|
||||||
|
|
||||||
|
// Check transfers
|
||||||
const canonical = stationCanonicalByCode[code] || code;
|
const canonical = stationCanonicalByCode[code] || code;
|
||||||
if (!occurrences[canonical]) occurrences[canonical] = [];
|
const codesInGroup = stationCodesByCanonical[canonical] || [canonical];
|
||||||
occurrences[canonical].push(i * minGapX);
|
const linesForThisStation = new Set();
|
||||||
}
|
codesInGroup.forEach(c => {
|
||||||
}
|
allLinesData.forEach(l => {
|
||||||
|
const lStops = Array.isArray(l.stops) ? l.stops : (Array.isArray(l.站点列表) ? l.站点列表 : []);
|
||||||
stationXByCanonical = {};
|
if (lStops.includes(c)) linesForThisStation.add(l);
|
||||||
stationYByCanonical = {};
|
});
|
||||||
const customXByCanonical = {};
|
|
||||||
const customYByCanonical = {};
|
|
||||||
|
|
||||||
for (const s of stationsData) {
|
|
||||||
const code = String(s.code || s.编号 || '').trim();
|
|
||||||
if (!code) continue;
|
|
||||||
const canonical = stationCanonicalByCode[code] || code;
|
|
||||||
if (s.svg_x != null && s.svg_y != null) {
|
|
||||||
customXByCanonical[canonical] = Number(s.svg_x);
|
|
||||||
customYByCanonical[canonical] = Number(s.svg_y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const canonical of Object.keys(occurrences)) {
|
|
||||||
if (customXByCanonical[canonical] != null) {
|
|
||||||
stationXByCanonical[canonical] = customXByCanonical[canonical];
|
|
||||||
} else {
|
|
||||||
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;
|
|
||||||
if (customXByCanonical[canonical] != null) {
|
|
||||||
prevX = customXByCanonical[canonical];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
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)) {
|
|
||||||
if (customYByCanonical[canonical] != null) {
|
|
||||||
hubYByCanonical[canonical] = customYByCanonical[canonical];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const li of lineStops) {
|
|
||||||
const yNode = baseY + li.idx * lineGapY;
|
|
||||||
for (const c of li.stops) {
|
|
||||||
const canonical = stationCanonicalByCode[c] || c;
|
|
||||||
stationYByCanonical[c] = customYByCanonical[canonical] ?? 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 * 42 - (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)
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const d = buildTransitPath(pts, 16);
|
const transferLines = Array.from(linesForThisStation).filter(l => l.id !== activeLineId);
|
||||||
svgContent += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
|
|
||||||
|
|
||||||
// Add line name labels at ends
|
let transferIcons = '';
|
||||||
const pStart = pts[0];
|
if (transferLines.length > 0) {
|
||||||
const pEnd = pts[pts.length - 1];
|
transferIcons = `<div style="display: flex; gap: 4px; margin-top: 6px;">`;
|
||||||
svgContent += placeLabel(pStart.x, pStart.y, true, li.name, li.enName, li.color);
|
transferLines.slice(0, 3).forEach(l => {
|
||||||
svgContent += placeLabel(pEnd.x, pEnd.y, false, li.name, li.enName, li.color);
|
transferIcons += `<div title="可换乘 ${l.name}" style="width: 14px; height: 14px; border-radius: 3px; background: ${l.color || '#999'};"></div>`;
|
||||||
|
});
|
||||||
|
transferIcons += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let badgeHtml = '';
|
||||||
|
let borderColor = '#e2e8f0';
|
||||||
|
let bgColor = '#f8fafc';
|
||||||
|
let shadow = 'none';
|
||||||
|
|
||||||
|
if (isSelectedFrom) {
|
||||||
|
badgeHtml = `<div style="position: absolute; top: -8px; right: -8px; background: #16a34a; color: white; font-size: 10px; padding: 2px 6px; border-radius: 10px; font-weight: bold; border: 2px solid #fff; z-index: 10;">起点</div>`;
|
||||||
|
borderColor = '#16a34a';
|
||||||
|
bgColor = '#f0fdf4';
|
||||||
|
shadow = '0 4px 6px rgba(0,0,0,0.1)';
|
||||||
|
} else if (isSelectedTo) {
|
||||||
|
badgeHtml = `<div style="position: absolute; top: -8px; right: -8px; background: #dc2626; color: white; font-size: 10px; padding: 2px 6px; border-radius: 10px; font-weight: bold; border: 2px solid #fff; z-index: 10;">终点</div>`;
|
||||||
|
borderColor = '#dc2626';
|
||||||
|
bgColor = '#fef2f2';
|
||||||
|
shadow = '0 4px 6px rgba(0,0,0,0.1)';
|
||||||
|
} else if (Array.isArray(currentRoute) && currentRoute.includes(code)) {
|
||||||
|
borderColor = '#facc15';
|
||||||
|
bgColor = '#fefce8';
|
||||||
|
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.includes(code)) {
|
||||||
|
badgeHtml = `<div style="position: absolute; top: -8px; right: -8px; background: #ca8a04; color: white; font-size: 10px; padding: 2px 6px; border-radius: 10px; font-weight: bold; border: 2px solid #fff; z-index: 10;">换乘</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedStations = Object.keys(stationXByCanonical).sort((a, b) => stationXByCanonical[a] - stationXByCanonical[b]);
|
stationsHtml += `
|
||||||
for (let i = 0; i < sortedStations.length; i++) {
|
<div class="tvm-station-card" onclick="window.handleStationClick('${code}')" style="
|
||||||
const canonical = sortedStations[i];
|
position: relative;
|
||||||
const x = stationXByCanonical[canonical];
|
padding: 12px;
|
||||||
const codes = stationCodesByCanonical[canonical] || [canonical];
|
border-radius: 8px;
|
||||||
const lineIdxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b);
|
border: 2px solid ${borderColor};
|
||||||
const colors = lineIdxs.map(idx => lineColors[idx]).filter(Boolean);
|
background: ${bgColor};
|
||||||
const uniqueColors = Array.from(new Set(colors));
|
cursor: pointer;
|
||||||
const primaryCode = codes[0];
|
transition: all 0.2s;
|
||||||
const y = hubYByCanonical[canonical] ?? stationYByCanonical[primaryCode] ?? baseY;
|
display: flex;
|
||||||
const isTransfer = uniqueColors.length >= 2 || codes.length >= 2;
|
flex-direction: column;
|
||||||
const name = getStationDisplayName(primaryCode);
|
box-shadow: ${shadow};
|
||||||
|
">
|
||||||
const enName = stationEnByCode[primaryCode] || '';
|
${badgeHtml}
|
||||||
const showEn = !!enName;
|
<div style="font-size: 11px; color: #64748b; font-weight: 700; margin-bottom: 4px;">${code}</div>
|
||||||
|
<div style="font-size: 16px; font-weight: 800; color: #0f172a;">${name}</div>
|
||||||
let markerSvg = '';
|
${enName ? `<div style="font-size: 10px; color: #64748b; font-weight: 600; margin-top: 2px;">${enName}</div>` : ''}
|
||||||
if (isTransfer) {
|
${transferIcons}
|
||||||
markerSvg += `<circle cx="${x}" cy="${y}" r="15" fill="#ffffff" stroke="#1f2937" stroke-width="2.5" />`;
|
</div>
|
||||||
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 - 14;
|
|
||||||
const enY = bottomY;
|
|
||||||
const nameY = showEn ? bottomY - 12 : bottomY;
|
|
||||||
const codeY = nameY - 16;
|
|
||||||
|
|
||||||
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 + 24;
|
|
||||||
const codeY = topY;
|
|
||||||
const nameY = topY + 16;
|
|
||||||
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>
|
|
||||||
`;
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasStations) {
|
||||||
|
stationsHtml += `<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: #64748b;">该线路暂无站点</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allCanonicals = Object.keys(stationXByCanonical);
|
stationsHtml += `</div>`;
|
||||||
const minX = Math.min(...allCanonicals.map(c => stationXByCanonical[c] || 0));
|
|
||||||
const maxX = Math.max(...allCanonicals.map(c => stationXByCanonical[c] || 0));
|
|
||||||
const maxY = Math.max(...allCanonicals.map(c => stationYByCanonical[c] || 0));
|
|
||||||
const width = Math.max(560, maxX + 200);
|
|
||||||
const height = Math.max(260, maxY + 120, baseY + lineStops.length * lineGapY + 120);
|
|
||||||
mapContainer.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
|
|
||||||
|
|
||||||
} catch(e) {
|
mapContainer.innerHTML = tabsHtml + stationsHtml;
|
||||||
console.error(e);
|
|
||||||
mapContainer.innerHTML = '<div class="error">地图加载失败</div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.switchTvmLine = (lineId) => {
|
||||||
|
activeLineId = lineId;
|
||||||
|
renderTvmUI();
|
||||||
|
};
|
||||||
|
|
||||||
window.handleStationClick = (code) => {
|
window.handleStationClick = (code) => {
|
||||||
code = String(code || '').trim();
|
code = String(code || '').trim();
|
||||||
// Toggle if already selected
|
// Toggle if already selected
|
||||||
@@ -556,104 +362,6 @@
|
|||||||
return merged;
|
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
function updateSelectionUI(skipPreview = false) {
|
||||||
if (!(selection[0] && selection[1])) {
|
if (!(selection[0] && selection[1])) {
|
||||||
currentRoute = [];
|
currentRoute = [];
|
||||||
@@ -672,29 +380,8 @@
|
|||||||
$('#fromDisplay').textContent = selection[0] ? getName(selection[0]) : '请在上方地图选择';
|
$('#fromDisplay').textContent = selection[0] ? getName(selection[0]) : '请在上方地图选择';
|
||||||
$('#toDisplay').textContent = selection[1] ? getName(selection[1]) : '请在上方地图选择';
|
$('#toDisplay').textContent = selection[1] ? getName(selection[1]) : '请在上方地图选择';
|
||||||
|
|
||||||
// Update Map Styles
|
// Refresh TVM UI to show selected/route states
|
||||||
document.querySelectorAll('.map-station').forEach(el => {
|
renderTvmUI();
|
||||||
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
|
// Auto preview if both selected
|
||||||
if(!skipPreview && selection[0] && selection[1]) previewPrice();
|
if(!skipPreview && selection[0] && selection[1]) previewPrice();
|
||||||
@@ -703,10 +390,6 @@
|
|||||||
// Load Map on Start
|
// Load Map on Start
|
||||||
renderSystemMap();
|
renderSystemMap();
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (currentRoute.length > 0) renderRouteOverlay();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function previewPrice(force = false){
|
async function previewPrice(force = false){
|
||||||
const seq = ++previewSeq;
|
const seq = ++previewSeq;
|
||||||
const type = document.querySelector('input[name="trainType"]:checked').value;
|
const type = document.querySelector('input[name="trainType"]:checked').value;
|
||||||
|
|||||||
Reference in New Issue
Block a user