feat: 重构选择购票区间地图为 TVM 瀑布流样式
This commit is contained in:
+246
-508
@@ -27,7 +27,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch and Render Map
|
||||
const mapContainer = $('#stationMap');
|
||||
let selection = [null, null];
|
||||
let currentRoute = [];
|
||||
@@ -36,10 +35,14 @@
|
||||
let stationEnByCode = {};
|
||||
let stationCanonicalByCode = {};
|
||||
let stationCodesByCanonical = {};
|
||||
let stationXByCanonical = {};
|
||||
let stationYByCanonical = {};
|
||||
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 x0 = cx + r * Math.cos(a0);
|
||||
const y0 = cy + r * Math.sin(a0);
|
||||
@@ -63,159 +66,232 @@
|
||||
return out;
|
||||
};
|
||||
|
||||
const buildTransitPath = (points, radius = 12, options = {}) => {
|
||||
const src = Array.isArray(points) ? points.filter(Boolean) : [];
|
||||
if (src.length === 0) return '';
|
||||
if (src.length === 1) return `M ${src[0].x} ${src[0].y}`;
|
||||
const laneOffset = Number(options?.laneOffset) || 0;
|
||||
|
||||
const fmt = (n) => {
|
||||
const v = Number(n);
|
||||
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 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;
|
||||
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';
|
||||
};
|
||||
|
||||
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>';
|
||||
function renderStationNode(x, y, code, i, forceColor = null) {
|
||||
const canonical = stationCanonicalByCode[code] || code;
|
||||
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 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 = 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() {
|
||||
try {
|
||||
const [lines, stations] = await Promise.all([
|
||||
fetch('/api/public/lines').then(r=>r.json()),
|
||||
fetch('/api/public/stations').then(r=>r.json())
|
||||
]);
|
||||
function renderTvmLines() {
|
||||
const container = document.getElementById('tvmLines');
|
||||
if (!container) return;
|
||||
let html = '';
|
||||
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;
|
||||
}
|
||||
|
||||
window.selectTvmLine = (idx) => {
|
||||
activeLineIdx = idx;
|
||||
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 html = '';
|
||||
lines.forEach(line => {
|
||||
const stList = line.站序 || [];
|
||||
|
||||
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() {
|
||||
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
|
||||
try {
|
||||
@@ -244,12 +320,11 @@
|
||||
if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code));
|
||||
}
|
||||
|
||||
const lineStops = [];
|
||||
const lineColors = [];
|
||||
const linesByStation = new Map();
|
||||
const lineIdxsByCanonical = {};
|
||||
lineStops = [];
|
||||
lineColors = [];
|
||||
linesByStation = new Map();
|
||||
lineIdxsByCanonical = {};
|
||||
|
||||
// NEW logic for breaking lines with '-'
|
||||
let currentLineIdx = 0;
|
||||
for (let i = 0; i < linesData.length; i++) {
|
||||
const line = linesData[i] || {};
|
||||
@@ -308,252 +383,36 @@
|
||||
if (uniq.length >= 2) stationTransfer.add(c);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
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>`;
|
||||
mapContainer.innerHTML = `
|
||||
<div class="tvm-layout" style="display: flex; flex-direction: column; gap: 20px; width: 100%;">
|
||||
<div class="tvm-lines" id="tvmLines" style="display: flex; flex-wrap: wrap; gap: 8px;"></div>
|
||||
<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>
|
||||
<div class="tvm-route-title" style="font-weight: bold; margin-bottom: -10px; color: #475569; display: none; font-size: 16px;" id="tvmRouteTitle">已选路径</div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderTvmLines();
|
||||
renderTvmSingleLine();
|
||||
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
mapContainer.innerHTML = '<div class="error">地图加载失败</div>';
|
||||
mapContainer.innerHTML = '<div class="error">数据加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
window.handleStationClick = (code) => {
|
||||
code = String(code || '').trim();
|
||||
// Toggle if already selected
|
||||
if (selection[0] === code) {
|
||||
selection[0] = null;
|
||||
} else if (selection[1] === code) {
|
||||
selection[1] = null;
|
||||
} else {
|
||||
// Add new selection
|
||||
if (!selection[0]) {
|
||||
selection[0] = code;
|
||||
} else if (!selection[1]) {
|
||||
selection[1] = code;
|
||||
} else {
|
||||
// When both ends are already selected, start a new selection flow.
|
||||
selection[0] = code;
|
||||
selection[1] = null;
|
||||
currentRoute = [];
|
||||
@@ -561,7 +420,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure no gaps (if start removed, move end to start)
|
||||
if (!selection[0] && selection[1]) {
|
||||
selection[0] = selection[1];
|
||||
selection[1] = null;
|
||||
@@ -569,7 +427,7 @@
|
||||
|
||||
updateSelectionUI();
|
||||
};
|
||||
|
||||
|
||||
function normalizeTrips() {
|
||||
const raw = Number(tripsEl?.value || 1);
|
||||
const trips = Number.isFinite(raw) ? Math.max(1, Math.floor(raw)) : 1;
|
||||
@@ -663,115 +521,15 @@
|
||||
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 = [];
|
||||
currentRouteTransfers = [];
|
||||
}
|
||||
|
||||
// Update Inputs
|
||||
fromEl.value = selection[0] || '';
|
||||
toEl.value = selection[1] || '';
|
||||
|
||||
// Update Displays with Chinese names
|
||||
const getName = (code) => {
|
||||
return stationNameByCode[String(code || '').trim()] || code;
|
||||
};
|
||||
@@ -779,41 +537,24 @@
|
||||
$('#fromDisplay').textContent = selection[0] ? getName(selection[0]) : '请在上方地图选择';
|
||||
$('#toDisplay').textContent = selection[1] ? getName(selection[1]) : '请在上方地图选择';
|
||||
|
||||
// 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.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) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
renderTvmRoute();
|
||||
|
||||
renderRouteOverlay();
|
||||
|
||||
// Auto preview if both selected
|
||||
if(!skipPreview && selection[0] && selection[1]) previewPrice();
|
||||
}
|
||||
|
||||
// Load Map on Start
|
||||
renderSystemMap();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (currentRoute.length > 0) renderRouteOverlay();
|
||||
});
|
||||
|
||||
async function previewPrice(force = false){
|
||||
const seq = ++previewSeq;
|
||||
const type = document.querySelector('input[name="trainType"]:checked').value;
|
||||
@@ -894,7 +635,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Create Order with auto-preview
|
||||
async function createOrder(){
|
||||
const type = document.querySelector('input[name="trainType"]:checked').value;
|
||||
const trips = normalizeTrips();
|
||||
@@ -905,7 +645,6 @@
|
||||
const to = (toEl.value||'').trim();
|
||||
if(!from || !to){ alert('请完整填写信息'); return; }
|
||||
|
||||
// Auto-fetch price before creating
|
||||
await previewPrice(true);
|
||||
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 r = await res.json();
|
||||
if(r && r.ok){
|
||||
// Link to external subdomain or local page
|
||||
const buildTokenLink = (code) => {
|
||||
if (location.hostname.includes('fse-media.group')) {
|
||||
return `https://ticket.fse-media.group/token?code=${encodeURIComponent(code)}`;
|
||||
@@ -942,7 +680,7 @@
|
||||
|
||||
const btnCreate = $('#createOrder');
|
||||
if(btnCreate) btnCreate.onclick = createOrder;
|
||||
// Listen for Type change to auto-update price
|
||||
|
||||
document.querySelectorAll('input[name="trainType"]').forEach(el => {
|
||||
el.onchange = () => { if(fromEl.value && toEl.value) previewPrice(); };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user