feat: 重构选择购票区间地图为 TVM 瀑布流样式

This commit is contained in:
2026-07-16 06:05:33 +08:00
parent 7812d47f0c
commit cfaa622dcd
+240 -502
View File
@@ -27,7 +27,6 @@
} }
}; };
// Fetch and Render Map
const mapContainer = $('#stationMap'); const mapContainer = $('#stationMap');
let selection = [null, null]; let selection = [null, null];
let currentRoute = []; let currentRoute = [];
@@ -36,10 +35,14 @@
let stationEnByCode = {}; let stationEnByCode = {};
let stationCanonicalByCode = {}; let stationCanonicalByCode = {};
let stationCodesByCanonical = {}; let stationCodesByCanonical = {};
let stationXByCanonical = {};
let stationYByCanonical = {};
let stationTransfer = new Set(); let stationTransfer = new Set();
let lineStops = [];
let lineColors = [];
let linesByStation = new Map();
let lineIdxsByCanonical = {};
let activeLineIdx = 0;
const piePath = (cx, cy, r, a0, a1) => { const piePath = (cx, cy, r, a0, a1) => {
const x0 = cx + r * Math.cos(a0); const x0 = cx + r * Math.cos(a0);
const y0 = cy + r * Math.sin(a0); const y0 = cy + r * Math.sin(a0);
@@ -63,159 +66,232 @@
return out; return out;
}; };
const buildTransitPath = (points, radius = 12, options = {}) => { const getContrastColor = (hexcolor) => {
const src = Array.isArray(points) ? points.filter(Boolean) : []; if (!hexcolor) return '#ffffff';
if (src.length === 0) return ''; let hex = hexcolor.replace('#', '');
if (src.length === 1) return `M ${src[0].x} ${src[0].y}`; if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
const laneOffset = Number(options?.laneOffset) || 0; const r = parseInt(hex.substr(0,2),16) || 0;
const g = parseInt(hex.substr(2,2),16) || 0;
const fmt = (n) => { const b = parseInt(hex.substr(4,2),16) || 0;
const v = Number(n); const yiq = ((r*299)+(g*587)+(b*114))/1000;
if (!Number.isFinite(v)) return '0'; return (yiq >= 140) ? '#0f172a' : '#ffffff';
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 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);
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);
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 });
} 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() { function renderStationNode(x, y, code, i, forceColor = null) {
try { const canonical = stationCanonicalByCode[code] || code;
const res = await fetch('/api/public/fares/map/light'); const codes = stationCodesByCanonical[canonical] || [canonical];
const svg = await res.text(); const lineIdxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b);
mapContainer.innerHTML = svg; const colors = lineIdxs.map(idx => lineColors[idx]).filter(Boolean);
const uniqueColors = Array.from(new Set(colors));
const primaryCode = codes[0];
const svgEl = mapContainer.querySelector('svg'); const isTransfer = uniqueColors.length >= 2 || codes.length >= 2;
if(!svgEl) return; const name = getStationDisplayName(primaryCode);
const enName = stationEnByCode[primaryCode] || '';
const showEn = !!enName;
renderLineMap(); let markerSvg = '';
} catch(e) { if (isTransfer) {
mapContainer.innerHTML = '<div class="error">加载地图失败</div>'; 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 = forceColor || uniqueColors[0] || '#93a2b7';
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 = i % 2 === 0;
let labelSvg = '';
const codeFontSize = 9;
const nameFontSize = 13;
const enFontSize = 8.5;
const codeW = primaryCode.length * 6 + 10;
let textContent = '';
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>`;
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;
let classList = 'map-station';
if (isTransfer) classList += ' transfer';
if (selection[0] && canonical === (stationCanonicalByCode[selection[0]] || selection[0])) classList += ' start';
if (selection[1] && canonical === (stationCanonicalByCode[selection[1]] || selection[1])) classList += ' end';
if (Array.isArray(currentRoute) && currentRoute.includes(primaryCode)) classList += ' route';
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.includes(primaryCode)) classList += ' route-transfer';
return `
<g class="${classList}" 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>`;
} }
async function renderLineMap() { function renderTvmLines() {
try { const container = document.getElementById('tvmLines');
const [lines, stations] = await Promise.all([ if (!container) return;
fetch('/api/public/lines').then(r=>r.json()), let html = '';
fetch('/api/public/stations').then(r=>r.json()) for (let i = 0; i < lineStops.length; i++) {
]); const li = lineStops[i];
const isActive = (i === activeLineIdx);
const bg = isActive ? li.color : '#f8fafc';
const fg = isActive ? getContrastColor(li.color) : '#334155';
const border = isActive ? li.color : '#cbd5e1';
html += `<button class="tvm-line-btn" style="padding: 8px 16px; border: 2px solid ${border}; background: ${bg}; color: ${fg}; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 14px; transition: all 0.2s;" onclick="selectTvmLine(${i})">${li.name}</button>`;
}
container.innerHTML = html;
}
let html = ''; window.selectTvmLine = (idx) => {
lines.forEach(line => { activeLineIdx = idx;
const stList = line.站序 || []; renderTvmLines();
renderTvmSingleLine();
};
function renderTvmSingleLine() {
const container = document.getElementById('tvmSingleLine');
if (!container) return;
const li = lineStops[activeLineIdx];
if (!li) {
container.innerHTML = '';
return;
}
const startX = 80;
const gapX = 90;
const y = 80;
const pts = [];
for (let i = 0; i < li.stops.length; i++) {
const code = li.stops[i];
if (code === '-') continue;
const x = startX + pts.length * gapX;
pts.push({ x, y, code });
}
if (pts.length === 0) {
container.innerHTML = '<div style="color:#94a3b8; padding:20px;">该线路暂无站点</div>';
return;
}
let svgContent = '';
if (pts.length >= 2) {
let d = `M ${pts[0].x} ${y}`;
for(let i=1; i<pts.length; i++) d += ` L ${pts[i].x} ${y}`;
svgContent += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
}
for (let i = 0; i < pts.length; i++) {
svgContent += renderStationNode(pts[i].x, pts[i].y, pts[i].code, i, li.color);
}
const width = startX + pts.length * gapX + 80;
const height = 160;
container.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="display:block; margin:auto;">${svgContent}</svg>`;
}
function renderTvmRoute() {
const container = document.getElementById('tvmRoute');
const title = document.getElementById('tvmRouteTitle');
if (!container) return;
if (!selection[0] || !selection[1] || !currentRoute || currentRoute.length < 2) {
container.style.display = 'none';
if (title) title.style.display = 'none';
container.innerHTML = '';
return;
}
const routeCodes = buildDisplayRouteCodes(currentRoute, selection[0], selection[1]);
if (routeCodes.length < 2) {
container.style.display = 'none';
if (title) title.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = 'flex';
if (title) title.style.display = 'block';
const startX = 80;
const gapX = 90;
const y = 80;
let svgContent = '';
const segments = [];
for (let i = 0; i < routeCodes.length - 1; i++) {
const c1 = routeCodes[i];
const c2 = routeCodes[i+1];
let color = '#93a2b7';
const can1 = stationCanonicalByCode[c1] || c1;
const can2 = stationCanonicalByCode[c2] || c2;
const canLines1 = Array.from(lineIdxsByCanonical[can1] || []);
const canLines2 = Array.from(lineIdxsByCanonical[can2] || []);
const canCommon = canLines1.filter(l => canLines2.includes(l));
if (canCommon.length > 0) {
color = lineColors[canCommon[0]];
}
segments.push({
x1: startX + i * gapX,
x2: startX + (i + 1) * gapX,
color
}); });
} catch(e) {} }
for (const seg of segments) {
svgContent += `<line x1="${seg.x1}" y1="${y}" x2="${seg.x2}" y2="${y}" stroke="${seg.color}" stroke-width="8" stroke-linecap="round" />`;
}
const pathD = `M ${startX} ${y} L ${startX + (routeCodes.length - 1) * gapX} ${y}`;
const totalDist = (routeCodes.length - 1) * gapX;
const duration = Math.max(1, totalDist / 150);
const numArrows = Math.max(1, Math.floor(duration / 0.8));
const interval = duration / numArrows;
for (let i = 0; i < numArrows; i++) {
svgContent += `
<g>
<path d="M -6 -5 L 4 0 L -6 5" fill="none" stroke="#854d0e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
<animateMotion dur="${duration}s" repeatCount="indefinite" path="${pathD}" rotate="auto" begin="-${i * interval}s" calcMode="linear" />
</g>
`;
}
for (let i = 0; i < routeCodes.length; i++) {
svgContent += renderStationNode(startX + i * gapX, y, routeCodes[i], i);
}
const width = startX + routeCodes.length * gapX + 80;
const height = 160;
container.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="display:block; margin:auto;">${svgContent}</svg>`;
} }
async function renderSystemMap() { async function renderSystemMap() {
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>'; mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
try { try {
@@ -244,12 +320,11 @@
if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code)); if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code));
} }
const lineStops = []; lineStops = [];
const lineColors = []; lineColors = [];
const linesByStation = new Map(); linesByStation = new Map();
const lineIdxsByCanonical = {}; lineIdxsByCanonical = {};
// NEW logic for breaking lines with '-'
let currentLineIdx = 0; 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] || {};
@@ -308,252 +383,36 @@
if (uniq.length >= 2) stationTransfer.add(c); if (uniq.length >= 2) stationTransfer.add(c);
} }
// Build SVG mapContainer.innerHTML = `
const legendW = 10; <div class="tvm-layout" style="display: flex; flex-direction: column; gap: 20px; width: 100%;">
const startX = 140; <div class="tvm-lines" id="tvmLines" style="display: flex; flex-wrap: wrap; gap: 8px;"></div>
const baseY = 80; <div class="tvm-single-line" id="tvmSingleLine" style="overflow-x: auto; padding: 10px 0; min-height: 160px; display: flex; align-items: center; justify-content: flex-start;"></div>
const totalLines = currentLineIdx; <div class="tvm-route-title" style="font-weight: bold; margin-bottom: -10px; color: #475569; display: none; font-size: 16px;" id="tvmRouteTitle">已选路径</div>
const maxStops = Math.max(2, ...lineStops.map(li => Array.isArray(li.stops) ? li.stops.length : 2)); <div class="tvm-route" id="tvmRoute" style="overflow-x: auto; padding: 10px 0; border-top: 2px dashed #cbd5e1; min-height: 160px; display: none; align-items: center; justify-content: flex-start;"></div>
let lineGapY = 80; </div>
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 = {}; renderTvmLines();
for (const li of lineStops) { renderTvmSingleLine();
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);
}
}
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 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;
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, { 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
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="${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);
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) { } catch(e) {
console.error(e); console.error(e);
mapContainer.innerHTML = '<div class="error">地图加载失败</div>'; mapContainer.innerHTML = '<div class="error">数据加载失败</div>';
} }
} }
window.handleStationClick = (code) => { window.handleStationClick = (code) => {
code = String(code || '').trim(); code = String(code || '').trim();
// Toggle if already selected
if (selection[0] === code) { if (selection[0] === code) {
selection[0] = null; selection[0] = null;
} else if (selection[1] === code) { } else if (selection[1] === code) {
selection[1] = null; selection[1] = null;
} else { } else {
// Add new selection
if (!selection[0]) { if (!selection[0]) {
selection[0] = code; selection[0] = code;
} else if (!selection[1]) { } else if (!selection[1]) {
selection[1] = code; selection[1] = code;
} else { } else {
// When both ends are already selected, start a new selection flow.
selection[0] = code; selection[0] = code;
selection[1] = null; selection[1] = null;
currentRoute = []; currentRoute = [];
@@ -561,7 +420,6 @@
} }
} }
// Ensure no gaps (if start removed, move end to start)
if (!selection[0] && selection[1]) { if (!selection[0] && selection[1]) {
selection[0] = selection[1]; selection[0] = selection[1];
selection[1] = null; selection[1] = null;
@@ -663,115 +521,15 @@
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, 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) { function updateSelectionUI(skipPreview = false) {
if (!(selection[0] && selection[1])) { if (!(selection[0] && selection[1])) {
currentRoute = []; currentRoute = [];
currentRouteTransfers = []; currentRouteTransfers = [];
} }
// Update Inputs
fromEl.value = selection[0] || ''; fromEl.value = selection[0] || '';
toEl.value = selection[1] || ''; toEl.value = selection[1] || '';
// Update Displays with Chinese names
const getName = (code) => { const getName = (code) => {
return stationNameByCode[String(code || '').trim()] || code; return stationNameByCode[String(code || '').trim()] || code;
}; };
@@ -779,41 +537,24 @@
$('#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
document.querySelectorAll('.map-station').forEach(el => { document.querySelectorAll('.map-station').forEach(el => {
const code = el.getAttribute('data-code'); const code = el.getAttribute('data-code');
const canonical = el.getAttribute('data-canonical'); const canonical = el.getAttribute('data-canonical');
el.classList.remove('start', 'end', 'selected', 'route', 'route-transfer'); el.classList.remove('start', 'end', 'selected', 'route', 'route-transfer');
if (selection[0] && canonical === (stationCanonicalByCode[selection[0]] || selection[0])) el.classList.add('start'); 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 (selection[1] && canonical === (stationCanonicalByCode[selection[1]] || selection[1])) el.classList.add('end');
if (Array.isArray(currentRoute) && currentRoute.includes(code)) el.classList.add('route');
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.includes(code)) el.classList.add('route-transfer');
}); });
if (Array.isArray(currentRoute) && currentRoute.length > 0) { renderTvmRoute();
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(); if(!skipPreview && selection[0] && selection[1]) previewPrice();
} }
// 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;
@@ -894,7 +635,6 @@
} }
} }
// Create Order with auto-preview
async function createOrder(){ async function createOrder(){
const type = document.querySelector('input[name="trainType"]:checked').value; const type = document.querySelector('input[name="trainType"]:checked').value;
const trips = normalizeTrips(); const trips = normalizeTrips();
@@ -905,7 +645,6 @@
const to = (toEl.value||'').trim(); const to = (toEl.value||'').trim();
if(!from || !to){ alert('请完整填写信息'); return; } if(!from || !to){ alert('请完整填写信息'); return; }
// Auto-fetch price before creating
await previewPrice(true); await previewPrice(true);
payload = { start: from, terminal: to, train_type: type, trips, ride_date }; payload = { start: from, terminal: to, train_type: type, trips, ride_date };
@@ -913,7 +652,6 @@
const res = await fetch('/api/public/orders', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(payload) }); const res = await fetch('/api/public/orders', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(payload) });
const r = await res.json(); const r = await res.json();
if(r && r.ok){ if(r && r.ok){
// Link to external subdomain or local page
const buildTokenLink = (code) => { const buildTokenLink = (code) => {
if (location.hostname.includes('fse-media.group')) { if (location.hostname.includes('fse-media.group')) {
return `https://ticket.fse-media.group/token?code=${encodeURIComponent(code)}`; return `https://ticket.fse-media.group/token?code=${encodeURIComponent(code)}`;
@@ -942,7 +680,7 @@
const btnCreate = $('#createOrder'); const btnCreate = $('#createOrder');
if(btnCreate) btnCreate.onclick = createOrder; if(btnCreate) btnCreate.onclick = createOrder;
// Listen for Type change to auto-update price
document.querySelectorAll('input[name="trainType"]').forEach(el => { document.querySelectorAll('input[name="trainType"]').forEach(el => {
el.onchange = () => { if(fromEl.value && toEl.value) previewPrice(); }; el.onchange = () => { if(fromEl.value && toEl.value) previewPrice(); };
}); });