1. 实现正交布线与圆角拐角连接,减少斜线交叉 2. 动态调整线路间距与节点位置,提高整体紧凑性 3. 优化线路名称与换乘站标签的避让逻辑,避免遮挡 4. 删除不再使用的全局预览页(map-preview.html)
928 lines
40 KiB
JavaScript
928 lines
40 KiB
JavaScript
/*
|
|
* @Author: HenryDu8133 813367384@qq.com
|
|
* @Date: 2025-12-06 23:59:11
|
|
* @LastEditors: HenryDu8133 813367384@qq.com
|
|
* @LastEditTime: 2026-07-15 23:25:36
|
|
* @FilePath: \TicketMachine\web\ticket-order.js
|
|
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
|
*/
|
|
(() => {
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const fromEl = $('#from');
|
|
const toEl = $('#to');
|
|
const tripsEl = $('#trips');
|
|
const priceBox = $('#priceBox');
|
|
const voucherBox = $('#voucherBox');
|
|
let previewSeq = 0;
|
|
let lastPreviewKey = '';
|
|
|
|
const api = {
|
|
fareQuery: async (from, to) => {
|
|
const r = await fetch(`/api/public/fares/query?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
|
return r.json();
|
|
},
|
|
createOrder: async (payload) => {
|
|
const r = await fetch('/api/public/orders', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(payload) });
|
|
return r.json();
|
|
}
|
|
};
|
|
|
|
// Fetch and Render Map
|
|
const mapContainer = $('#stationMap');
|
|
let selection = [null, null];
|
|
let currentRoute = [];
|
|
let currentRouteTransfers = [];
|
|
let stationNameByCode = {};
|
|
let stationEnByCode = {};
|
|
let stationCanonicalByCode = {};
|
|
let stationCodesByCanonical = {};
|
|
let stationXByCanonical = {};
|
|
let stationYByCanonical = {};
|
|
let stationTransfer = new Set();
|
|
|
|
const piePath = (cx, cy, r, a0, a1) => {
|
|
const x0 = cx + r * Math.cos(a0);
|
|
const y0 = cy + r * Math.sin(a0);
|
|
const x1 = cx + r * Math.cos(a1);
|
|
const y1 = cy + r * Math.sin(a1);
|
|
const large = (a1 - a0) > Math.PI ? 1 : 0;
|
|
return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} Z`;
|
|
};
|
|
|
|
const pieSvg = (cx, cy, r, colors) => {
|
|
const cols = (Array.isArray(colors) ? colors.filter(Boolean) : []).slice(0, 4);
|
|
if (cols.length === 0) return '';
|
|
if (cols.length === 1) return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${cols[0]}" />`;
|
|
const step = (Math.PI * 2) / cols.length;
|
|
let out = '';
|
|
for (let i = 0; i < cols.length; i++) {
|
|
const a0 = -Math.PI / 2 + i * step;
|
|
const a1 = a0 + step;
|
|
out += `<path d="${piePath(cx, cy, r, a0, a1)}" fill="${cols[i]}" />`;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const buildTransitPath = (points, radius = 12) => {
|
|
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 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 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);
|
|
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() {
|
|
try {
|
|
const res = await fetch('/api/public/fares/map/light');
|
|
const svg = await res.text();
|
|
mapContainer.innerHTML = svg;
|
|
|
|
const svgEl = mapContainer.querySelector('svg');
|
|
if(!svgEl) return;
|
|
|
|
renderLineMap();
|
|
} catch(e) {
|
|
mapContainer.innerHTML = '<div class="error">加载地图失败</div>';
|
|
}
|
|
}
|
|
|
|
async function renderLineMap() {
|
|
try {
|
|
const [lines, stations] = await Promise.all([
|
|
fetch('/api/public/lines').then(r=>r.json()),
|
|
fetch('/api/public/stations').then(r=>r.json())
|
|
]);
|
|
|
|
let html = '';
|
|
lines.forEach(line => {
|
|
const stList = line.站序 || [];
|
|
|
|
});
|
|
} catch(e) {}
|
|
}
|
|
|
|
|
|
|
|
async function renderSystemMap() {
|
|
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
|
|
try {
|
|
const [linesData, stationsData] = await Promise.all([
|
|
fetch('/api/public/lines').then(r=>r.json()),
|
|
fetch('/api/public/stations').then(r=>r.json())
|
|
]);
|
|
|
|
window.cachedStationsData = stationsData;
|
|
stationTransfer = new Set();
|
|
stationCanonicalByCode = {};
|
|
stationCodesByCanonical = {};
|
|
stationNameByCode = {};
|
|
stationEnByCode = {};
|
|
for (const s of stationsData) {
|
|
const code = String(s.code || s.编号 || '').trim();
|
|
if (!code) continue;
|
|
stationNameByCode[code] = String(s.name || s.名称 || code);
|
|
stationEnByCode[code] = String(s.en_name || s.英文名 || '');
|
|
}
|
|
|
|
const transferGroups = buildTransferGroups(stationsData);
|
|
stationCanonicalByCode = transferGroups.canonicalByCode;
|
|
stationCodesByCanonical = transferGroups.codesByCanonical;
|
|
for (const codes of Object.values(stationCodesByCanonical)) {
|
|
if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code));
|
|
}
|
|
|
|
const lineStops = [];
|
|
const lineColors = [];
|
|
const linesByStation = new Map();
|
|
const lineIdxsByCanonical = {};
|
|
|
|
// NEW logic for breaking lines with '-'
|
|
let currentLineIdx = 0;
|
|
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;
|
|
|
|
let currentSegment = [];
|
|
let addedAny = false;
|
|
for (const c of stops) {
|
|
if (c === '-') {
|
|
if (currentSegment.length > 0) {
|
|
lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops: currentSegment });
|
|
lineColors.push(color);
|
|
for (const stopCode of currentSegment) {
|
|
const arr = linesByStation.get(stopCode) || [];
|
|
arr.push(currentLineIdx);
|
|
linesByStation.set(stopCode, arr);
|
|
const canonical = stationCanonicalByCode[stopCode] || stopCode;
|
|
if (!lineIdxsByCanonical[canonical]) lineIdxsByCanonical[canonical] = new Set();
|
|
lineIdxsByCanonical[canonical].add(currentLineIdx);
|
|
}
|
|
currentSegment = [];
|
|
addedAny = true;
|
|
}
|
|
} else {
|
|
currentSegment.push(c);
|
|
}
|
|
}
|
|
if (currentSegment.length > 0) {
|
|
lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops: currentSegment });
|
|
lineColors.push(color);
|
|
for (const stopCode of currentSegment) {
|
|
const arr = linesByStation.get(stopCode) || [];
|
|
arr.push(currentLineIdx);
|
|
linesByStation.set(stopCode, arr);
|
|
const canonical = stationCanonicalByCode[stopCode] || stopCode;
|
|
if (!lineIdxsByCanonical[canonical]) lineIdxsByCanonical[canonical] = new Set();
|
|
lineIdxsByCanonical[canonical].add(currentLineIdx);
|
|
}
|
|
addedAny = true;
|
|
}
|
|
if (addedAny) {
|
|
currentLineIdx++;
|
|
}
|
|
}
|
|
|
|
if (lineStops.length === 0) {
|
|
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 = 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 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);
|
|
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="#ffffff" stroke="${color}" stroke-width="5" />`;
|
|
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
|
|
}
|
|
|
|
const preferTop = isTransfer || (i % 2 === 0 && y >= baseY + 20);
|
|
|
|
let labelSvg = '';
|
|
let textContent = '';
|
|
|
|
const codeFontSize = 9;
|
|
const nameFontSize = 13;
|
|
const enFontSize = 8.5;
|
|
|
|
const codeW = primaryCode.length * 6 + 10;
|
|
|
|
if (preferTop) {
|
|
const bottomY = y - 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) {
|
|
console.error(e);
|
|
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 = [];
|
|
currentRouteTransfers = [];
|
|
}
|
|
}
|
|
|
|
// Ensure no gaps (if start removed, move end to start)
|
|
if (!selection[0] && selection[1]) {
|
|
selection[0] = selection[1];
|
|
selection[1] = null;
|
|
}
|
|
|
|
updateSelectionUI();
|
|
};
|
|
|
|
function normalizeTrips() {
|
|
const raw = Number(tripsEl?.value || 1);
|
|
const trips = Number.isFinite(raw) ? Math.max(1, Math.floor(raw)) : 1;
|
|
if (tripsEl) tripsEl.value = String(trips);
|
|
return trips;
|
|
}
|
|
|
|
function getStationDisplayName(code) {
|
|
return stationNameByCode[String(code || '').trim()] || String(code || '').trim();
|
|
}
|
|
|
|
function getStationNameKey(code) {
|
|
const cn = getStationDisplayName(code).replace(/\s+/g, '');
|
|
const en = String(stationEnByCode[String(code || '').trim()] || '').toLowerCase().replace(/\s+/g, '');
|
|
return `${cn}|${en}`;
|
|
}
|
|
|
|
function buildTransferGroups(stationsData) {
|
|
const parent = {};
|
|
const codesByName = {};
|
|
const find = (code) => {
|
|
if (!parent[code]) parent[code] = code;
|
|
if (parent[code] !== code) parent[code] = find(parent[code]);
|
|
return parent[code];
|
|
};
|
|
const union = (a, b) => {
|
|
if (!a || !b) return;
|
|
const ra = find(a);
|
|
const rb = find(b);
|
|
if (ra !== rb) parent[rb] = ra;
|
|
};
|
|
|
|
for (const s of stationsData) {
|
|
const code = String(s?.code || s?.编号 || '').trim();
|
|
if (!code) continue;
|
|
parent[code] = code;
|
|
const cn = String(s?.name || s?.名称 || '').replace(/\s+/g, '');
|
|
const en = String(s?.en_name || s?.英文名 || '').toLowerCase().replace(/\s+/g, '');
|
|
const nameKey = `${cn}|${en}`;
|
|
if (cn || en) {
|
|
if (!codesByName[nameKey]) codesByName[nameKey] = [];
|
|
codesByName[nameKey].push(code);
|
|
}
|
|
}
|
|
|
|
for (const s of stationsData) {
|
|
const code = String(s?.code || s?.编号 || '').trim();
|
|
if (!code) continue;
|
|
const list = Array.isArray(s?.transfer_to) ? s.transfer_to : [];
|
|
for (const item of list) {
|
|
const to = String((typeof item === 'string') ? item : (item?.code || item?.station || item?.id || item?.[0] || '')).trim();
|
|
if (to) union(code, to);
|
|
}
|
|
}
|
|
|
|
for (const codes of Object.values(codesByName)) {
|
|
if (!Array.isArray(codes) || codes.length < 2) continue;
|
|
for (let i = 1; i < codes.length; i++) {
|
|
union(codes[0], codes[i]);
|
|
}
|
|
}
|
|
|
|
const byRoot = {};
|
|
for (const code of Object.keys(parent)) {
|
|
const root = find(code);
|
|
if (!byRoot[root]) byRoot[root] = [];
|
|
byRoot[root].push(code);
|
|
}
|
|
|
|
const canonicalByCode = {};
|
|
const codesByCanonical = {};
|
|
for (const codes of Object.values(byRoot)) {
|
|
codes.sort((a, b) => a.localeCompare(b));
|
|
const canonical = codes[0];
|
|
codesByCanonical[canonical] = codes;
|
|
for (const code of codes) canonicalByCode[code] = canonical;
|
|
}
|
|
return { canonicalByCode, codesByCanonical };
|
|
}
|
|
|
|
function buildDisplayRouteCodes(route, from, to) {
|
|
const middle = Array.isArray(route) ? route.filter(c => c && c !== from && c !== to) : [];
|
|
const merged = [];
|
|
for (const code of [from, ...middle, to].filter(Boolean)) {
|
|
const prev = merged[merged.length - 1];
|
|
const prevName = prev ? getStationDisplayName(prev).replace(/\s+/g, '') : '';
|
|
const nextName = getStationDisplayName(code).replace(/\s+/g, '');
|
|
if (prev && prevName && prevName === nextName) continue;
|
|
merged.push(code);
|
|
}
|
|
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;
|
|
};
|
|
|
|
$('#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.length > 0) {
|
|
for (const c of currentRoute) {
|
|
const el = getMapStationEl(c);
|
|
if (el) el.classList.add('route');
|
|
}
|
|
}
|
|
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.length > 0) {
|
|
for (const c of currentRouteTransfers) {
|
|
const el = getMapStationEl(c);
|
|
if (el) el.classList.add('route-transfer');
|
|
}
|
|
}
|
|
|
|
renderRouteOverlay();
|
|
|
|
// Auto preview if both selected
|
|
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;
|
|
const trips = normalizeTrips();
|
|
|
|
const from = (fromEl.value||'').trim();
|
|
const to = (toEl.value||'').trim();
|
|
if(!from || !to){ priceBox.innerHTML = '<div class="empty-state jr-empty-state"><p>请选择起点与终点</p></div>'; return; }
|
|
const previewKey = `${from}|${to}|${type}|${trips}`;
|
|
if (!force && previewKey === lastPreviewKey) return;
|
|
lastPreviewKey = previewKey;
|
|
|
|
try{
|
|
const fare = await api.fareQuery(from, to);
|
|
if (seq !== previewSeq) return;
|
|
if(fare && (fare.error || fare['错误'])){ priceBox.innerHTML = '<div class="list-item jr-result-row"><span class="k">提示</span><span class="v">未找到对应票价</span></div>'; return; }
|
|
const resolvedFrom = String(fare?.from_code || '').trim();
|
|
const resolvedTo = String(fare?.to_code || '').trim();
|
|
if ((resolvedFrom && resolvedFrom !== from) || (resolvedTo && resolvedTo !== to)) {
|
|
lastPreviewKey = '';
|
|
priceBox.innerHTML = `
|
|
<div class="empty-state jr-empty-state">
|
|
<p>站点解析异常,当前后端返回的站码与页面选择不一致。</p>
|
|
<p>已选:${getStationDisplayName(from)}(${from}) -> ${getStationDisplayName(to)}(${to})</p>
|
|
<p>返回:${getStationDisplayName(resolvedFrom)}(${resolvedFrom || '-'}) -> ${getStationDisplayName(resolvedTo)}(${resolvedTo || '-'})</p>
|
|
<p>请先部署最新后端并清理 CDN 缓存后再试。</p>
|
|
</div>
|
|
`;
|
|
currentRoute = [];
|
|
currentRouteTransfers = [];
|
|
updateSelectionUI(true);
|
|
return;
|
|
}
|
|
const discRaw = Number(fare?.discount ?? fare?.['折扣'] ?? 1);
|
|
const disc = Number.isFinite(discRaw) && discRaw > 0 ? discRaw : 1;
|
|
const base = (type==='Express')
|
|
? Number(fare?.express_fare ?? fare?.['特快票价'] ?? 0)
|
|
: Number(fare?.regular_fare ?? fare?.['常规票价'] ?? 0);
|
|
const discountedRaw = (type==='Express')
|
|
? (fare?.discounted_express_fare ?? fare?.['优惠后特快票价'])
|
|
: (fare?.discounted_regular_fare ?? fare?.['优惠后常规票价']);
|
|
const discountedSingle = Number(discountedRaw ?? Math.round(base * disc) ?? 0);
|
|
const price = discountedSingle * trips;
|
|
|
|
const routeKey = (type === 'Express') ? 'express_path' : 'regular_path';
|
|
const transferKey = (type === 'Express') ? 'express_transfers' : 'regular_transfers';
|
|
currentRoute = Array.isArray(fare?.[routeKey]) ? fare[routeKey] : [];
|
|
currentRouteTransfers = Array.isArray(fare?.[transferKey]) ? fare[transferKey] : [];
|
|
const displayRoute = buildDisplayRouteCodes(currentRoute, from, to);
|
|
const routeText = displayRoute.length > 0 ? displayRoute.map(getStationDisplayName).join(' → ') : '';
|
|
const transferStops = [];
|
|
const seenTransferNames = new Set();
|
|
for (const code of currentRouteTransfers) {
|
|
if (code === from || code === to) continue;
|
|
const nameKey = getStationDisplayName(code).replace(/\s+/g, '');
|
|
if (!nameKey || seenTransferNames.has(nameKey)) continue;
|
|
seenTransferNames.add(nameKey);
|
|
transferStops.push(code);
|
|
}
|
|
const transferHtml = transferStops.length
|
|
? `<div class="jr-transfer-chips">${transferStops.map(c => `<span class="badge badge-secondary">${getStationDisplayName(c)}</span>`).join('')}</div>`
|
|
: `<span class="text-muted">无</span>`;
|
|
|
|
priceBox.innerHTML = `
|
|
<div class="list-item jr-result-row"><span class="k">原始票价</span><span class="v">${base}</span></div>
|
|
<div class="list-item jr-result-row"><span class="k">优惠后票价</span><span class="v">${discountedSingle}</span></div>
|
|
<div class="list-item jr-result-row"><span class="k">折扣</span><span class="v">-${Math.round((1-disc)*100)}%</span></div>
|
|
${routeText ? `<div class="list-item jr-result-row jr-result-multiline"><span class="k">路径</span><span class="v jr-route-text">${routeText}</span></div>` : ``}
|
|
<div class="list-item jr-result-row jr-result-multiline"><span class="k">换乘</span><span class="v jr-route-text">${transferHtml}</span></div>
|
|
<div class="list-item jr-result-row jr-total-row">
|
|
<span class="k">总价</span><span class="v jr-total-amount">${price}</span>
|
|
</div>
|
|
`;
|
|
updateSelectionUI(true);
|
|
}catch(e){
|
|
lastPreviewKey = '';
|
|
priceBox.innerHTML = '<div class="empty-state jr-empty-state"><p>票价预估失败,请稍后再试。</p></div>';
|
|
}
|
|
}
|
|
|
|
// Create Order with auto-preview
|
|
async function createOrder(){
|
|
const type = document.querySelector('input[name="trainType"]:checked').value;
|
|
const trips = normalizeTrips();
|
|
const ride_date = new Date().toISOString().slice(0, 10);
|
|
|
|
let payload = null;
|
|
const from = (fromEl.value||'').trim();
|
|
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 };
|
|
|
|
try{
|
|
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)}`;
|
|
}
|
|
return `/token.html?code=${encodeURIComponent(code)}`;
|
|
};
|
|
|
|
voucherBox.innerHTML = `
|
|
<div class="voucher-container jr-voucher-panel">
|
|
<div class="jr-voucher-meta">凭证码</div>
|
|
<div class="voucher-code">${r.code}</div>
|
|
<div class="voucher-hint">请在游戏内任意售票机选择线上订票并输入该凭证码兑票。</div>
|
|
<div class="toolbar jr-voucher-actions">
|
|
<a class="btn jr-detail-btn" href="${buildTokenLink(r.code)}" target="_self"><i class="fas fa-eye"></i> 详情</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}else{
|
|
alert('创建失败: ' + (r.error || '未知错误'));
|
|
}
|
|
}catch(e){ alert('创建失败'); }
|
|
}
|
|
|
|
const btnPreview = $('#previewPrice');
|
|
if(btnPreview) btnPreview.onclick = previewPrice;
|
|
|
|
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(); };
|
|
});
|
|
if (tripsEl) {
|
|
tripsEl.oninput = () => { if(fromEl.value && toEl.value) previewPrice(); };
|
|
tripsEl.onchange = () => { normalizeTrips(); if(fromEl.value && toEl.value) previewPrice(); };
|
|
}
|
|
})();
|