feat(web): 订票地图升级为静态SVG并叠加动态坐标交互;完善断点支持
This commit is contained in:
+62
-175
@@ -1,4 +1,4 @@
|
||||
(() => {
|
||||
(() => {
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const fromEl = $('#from');
|
||||
const toEl = $('#to');
|
||||
@@ -90,9 +90,10 @@
|
||||
async function renderSystemMap() {
|
||||
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
|
||||
try {
|
||||
const [linesData, stationsData] = await Promise.all([
|
||||
const [linesData, stationsData, manifestData] = await Promise.all([
|
||||
fetch('/api/public/lines').then(r=>r.json()),
|
||||
fetch('/api/public/stations').then(r=>r.json())
|
||||
fetch('/api/public/stations').then(r=>r.json()),
|
||||
fetch('/api/assets/manifest').then(r=>r.json()).catch(() => ({ ok: true }))
|
||||
]);
|
||||
|
||||
window.cachedStationsData = stationsData;
|
||||
@@ -116,186 +117,66 @@
|
||||
}
|
||||
|
||||
const lineStops = [];
|
||||
const lineColors = [];
|
||||
const linesByStation = new Map();
|
||||
for (let i = 0; i < linesData.length; i++) {
|
||||
const line = linesData[i] || {};
|
||||
const color = line.color || line.颜色 || '#93a2b7';
|
||||
const stopsRaw = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []);
|
||||
const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean);
|
||||
if (stops.length === 0) continue;
|
||||
lineStops.push({ idx: lineStops.length, name: line.name || line.线路名称 || '', color, stops });
|
||||
lineColors.push(color);
|
||||
lineStops.push({ idx: lineStops.length, stops });
|
||||
for (const c of stops) {
|
||||
if (c === '-') continue;
|
||||
const arr = linesByStation.get(c) || [];
|
||||
arr.push(lineStops.length - 1);
|
||||
linesByStation.set(c, arr);
|
||||
}
|
||||
}
|
||||
|
||||
if (lineStops.length === 0) {
|
||||
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂无可显示的线路数据。</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [c, arr] of linesByStation.entries()) {
|
||||
const uniq = Array.from(new Set(arr));
|
||||
if (uniq.length >= 2) stationTransfer.add(c);
|
||||
}
|
||||
|
||||
// Build SVG
|
||||
const legendW = 210;
|
||||
const legendX = 16;
|
||||
const legendSwatchW = 22;
|
||||
const legendTextX = legendX + legendSwatchW + 10;
|
||||
const startX = legendW + 90;
|
||||
const baseY = 44;
|
||||
const lineGapY = 92;
|
||||
const minGapX = 78;
|
||||
|
||||
const occurrences = {};
|
||||
for (const li of lineStops) {
|
||||
for (let i = 0; i < li.stops.length; i++) {
|
||||
const code = li.stops[i];
|
||||
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) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const primaryLineByStation = {};
|
||||
for (const [c, arr] of linesByStation.entries()) {
|
||||
const uniq = Array.from(new Set(arr)).sort((a,b)=>a-b);
|
||||
if (uniq.length === 0) continue;
|
||||
primaryLineByStation[c] = uniq[0];
|
||||
}
|
||||
|
||||
stationYByCanonical = {};
|
||||
const ysByCanonical = {};
|
||||
for (const c of Object.keys(primaryLineByStation)) {
|
||||
const li = primaryLineByStation[c];
|
||||
if (li == null) continue;
|
||||
const canonical = stationCanonicalByCode[c] || c;
|
||||
if (!ysByCanonical[canonical]) ysByCanonical[canonical] = [];
|
||||
ysByCanonical[canonical].push(baseY + li * lineGapY);
|
||||
}
|
||||
|
||||
const canonicalY = {};
|
||||
for (const canonical of Object.keys(ysByCanonical)) {
|
||||
const ys = ysByCanonical[canonical];
|
||||
canonicalY[canonical] = ys.reduce((a,b) => a+b, 0) / ys.length;
|
||||
}
|
||||
|
||||
for (const c of Object.keys(primaryLineByStation)) {
|
||||
const canonical = stationCanonicalByCode[c] || c;
|
||||
stationYByCanonical[c] = canonicalY[canonical];
|
||||
}
|
||||
|
||||
const allStations = Object.keys(primaryLineByStation);
|
||||
if (allStations.length === 0) {
|
||||
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>线路数据为空,请稍后再试。</p></div>';
|
||||
// Build Interactive Map Over Static SVG
|
||||
if (!manifestData || !manifestData.routeMap) {
|
||||
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂未上传静态线路图,请在控制台进行配置。</p></div>';
|
||||
return;
|
||||
}
|
||||
const labelShownForCanonical = new Set();
|
||||
const transferColorsByStation = {};
|
||||
const primaryColorByStation = {};
|
||||
for (const c of allStations) {
|
||||
const lineIdxs = Array.from(new Set(linesByStation.get(c) || [])).sort((a,b)=>a-b);
|
||||
const colors = lineIdxs.map(i => lineStops[i]?.color).filter(Boolean);
|
||||
transferColorsByStation[c] = Array.from(new Set(colors));
|
||||
primaryColorByStation[c] = colors[0] || '#93a2b7';
|
||||
}
|
||||
|
||||
const routeMapUrl = `/assets/${manifestData.routeMap}?v=${manifestData.updatedAt || Date.now()}`;
|
||||
|
||||
let svgContent = '';
|
||||
|
||||
for (const li of lineStops) {
|
||||
const yLine = baseY + li.idx * lineGapY;
|
||||
svgContent += `<rect x="${legendX}" y="${yLine-10}" width="${legendSwatchW}" height="10" rx="5" fill="${li.color}" />`;
|
||||
svgContent += `<text x="${legendTextX}" y="${yLine-1}" fill="#e5e7eb" font-size="16" font-weight="800">${li.name}</text>`;
|
||||
|
||||
if (li.stops.length >= 2) {
|
||||
const pts = li.stops.map(code => {
|
||||
const canonical = stationCanonicalByCode[code] || code;
|
||||
return {
|
||||
x: stationXByCanonical[canonical] || 0,
|
||||
y: stationYByCanonical[code] ?? yLine
|
||||
};
|
||||
});
|
||||
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
d += ` L ${pts[i].x} ${pts[i].y}`;
|
||||
}
|
||||
svgContent += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const canonical of Object.keys(stationCodesByCanonical)) {
|
||||
const codes = (stationCodesByCanonical[canonical] || []).filter(code => Number.isFinite(stationYByCanonical[code]));
|
||||
if (codes.length < 2) continue;
|
||||
const x = stationXByCanonical[canonical];
|
||||
const y = stationYByCanonical[codes[0]];
|
||||
const labelY = y + 34;
|
||||
svgContent += `<circle cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="#111827" stroke-width="3" />`;
|
||||
svgContent += `<rect x="${x - 42}" y="${labelY - 16}" width="84" height="24" rx="12" fill="rgba(241,245,249,0.92)" stroke="#cbd5e1" stroke-width="1.5" />`;
|
||||
svgContent += `<text x="${x}" y="${labelY}" text-anchor="middle" fill="#334155" font-size="13" font-weight="800">${getStationDisplayName(canonical)}</text>`;
|
||||
labelShownForCanonical.add(canonical);
|
||||
}
|
||||
|
||||
for (const c of allStations) {
|
||||
const canonical = stationCanonicalByCode[c] || c;
|
||||
const x = stationXByCanonical[canonical];
|
||||
const yNode = stationYByCanonical[c] ?? baseY;
|
||||
const name = stationNameByCode[c] || c;
|
||||
const isTransfer = stationTransfer.has(c);
|
||||
const cls = isTransfer ? 'map-station transfer' : 'map-station';
|
||||
const ringSvg = '';
|
||||
const primaryColor = primaryColorByStation[c] || '#93a2b7';
|
||||
const outer = isTransfer
|
||||
? `<circle cx="${x}" cy="${yNode}" r="13" fill="#ffffff" stroke="${primaryColor}" stroke-width="5" />`
|
||||
: `<circle cx="${x}" cy="${yNode}" r="12" fill="#ffffff" stroke="${primaryColor}" stroke-width="5" />`;
|
||||
const transferFill = isTransfer ? `<circle cx="${x}" cy="${yNode}" r="7" fill="#ffffff" stroke="#111827" stroke-width="3" />` : '';
|
||||
const coreFill = isTransfer ? '#ffffff' : '#ffffff';
|
||||
const showLabel = !isTransfer || !labelShownForCanonical.has(canonical);
|
||||
const textSvg = showLabel
|
||||
? `<text x="${x}" y="${yNode+28}" text-anchor="middle" fill="#e5e7eb" font-size="14" font-weight="700">${name}</text>`
|
||||
: '';
|
||||
|
||||
for (const s of stationsData) {
|
||||
const code = String(s.code || s.编号 || '').trim();
|
||||
if (!code || s.svg_x == null || s.svg_y == null) continue;
|
||||
|
||||
const x = s.svg_x;
|
||||
const y = s.svg_y;
|
||||
const name = stationNameByCode[code] || code;
|
||||
const isTransfer = stationTransfer.has(code);
|
||||
|
||||
// We render interactive dots using percentage coordinates over the SVG
|
||||
svgContent += `
|
||||
<g class="${cls}" data-code="${c}" onclick="handleStationClick('${c}')" style="cursor:pointer">
|
||||
${ringSvg}
|
||||
${outer}
|
||||
${transferFill}
|
||||
<circle class="node-core" cx="${x}" cy="${yNode}" r="8" fill="${coreFill}" stroke="#111827" stroke-width="3" />
|
||||
${textSvg}
|
||||
</g>
|
||||
<div class="map-station-overlay map-station" data-code="${code}"
|
||||
onclick="handleStationClick('${code}')"
|
||||
style="position: absolute; left: ${x}%; top: ${y}%; transform: translate(-50%, -50%); cursor: pointer;">
|
||||
<div class="station-hotspot" title="${name} (${code})"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const width = Math.max(560, Math.max(...allStations.map(c => stationXByCanonical[stationCanonicalByCode[c] || c] || 0)) + 120);
|
||||
const height = Math.max(260, baseY + lineStops.length * lineGapY + 60);
|
||||
mapContainer.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
|
||||
|
||||
// Bind click events (standard onclick attribute in SVG string might not work in some contexts, but usually fine in innerHTML)
|
||||
// Better to add event listeners via delegation
|
||||
mapContainer.innerHTML = `
|
||||
<div class="static-map-scroll-container" style="width: 100%; max-width: 100%; overflow: auto;">
|
||||
<div class="static-map-wrapper" style="position: relative; width: 100%; min-width: 800px;">
|
||||
<img src="${routeMapUrl}" alt="线路图" style="display: block; width: 100%; height: auto;" />
|
||||
<svg class="route-svg-overlay" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"></svg>
|
||||
<div class="static-map-overlays" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||
${svgContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
@@ -430,28 +311,32 @@
|
||||
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 };
|
||||
const el = document.querySelector(`.map-station-overlay[data-code="${normalized}"]`);
|
||||
if (!el) return null;
|
||||
return { x: el.offsetLeft, y: el.offsetTop };
|
||||
}
|
||||
|
||||
function renderRouteOverlay() {
|
||||
const svgEl = mapContainer.querySelector('svg');
|
||||
if (!svgEl) return;
|
||||
const svgEl = mapContainer.querySelector('.route-svg-overlay');
|
||||
if (!svgEl) return;
|
||||
|
||||
const existing = svgEl.querySelector('.route-overlay-group');
|
||||
if (existing) existing.remove();
|
||||
const existing = svgEl.querySelector('.route-overlay-group');
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (!Array.isArray(currentRoute) || currentRoute.length < 2) return;
|
||||
if (!Array.isArray(currentRoute) || currentRoute.length < 2) return;
|
||||
|
||||
const points = currentRoute.map(getStationPoint).filter(Boolean);
|
||||
if (points.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');
|
||||
// Update SVG dimensions to match the image
|
||||
const wrapper = mapContainer.querySelector('.static-map-wrapper');
|
||||
if (wrapper) {
|
||||
svgEl.setAttribute('viewBox', `0 0 ${wrapper.offsetWidth} ${wrapper.offsetHeight}`);
|
||||
}
|
||||
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const group = document.createElementNS(ns, 'g');
|
||||
group.setAttribute('class', 'route-overlay-group');
|
||||
|
||||
const pathData = points.map((pt, idx) => `${idx === 0 ? 'M' : 'L'} ${pt.x} ${pt.y}`).join(' ');
|
||||
|
||||
@@ -510,9 +395,7 @@
|
||||
group.appendChild(arrowG);
|
||||
}
|
||||
|
||||
const firstStation = svgEl.querySelector('.map-station');
|
||||
if (firstStation) svgEl.insertBefore(group, firstStation);
|
||||
else svgEl.appendChild(group);
|
||||
svgEl.appendChild(group);
|
||||
}
|
||||
|
||||
function updateSelectionUI(skipPreview = false) {
|
||||
@@ -563,6 +446,10 @@
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user