feat: 重构选择购票区间地图为 TVM 瀑布流样式
This commit is contained in:
+240
-502
@@ -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(); };
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user