From cfaa622dcdfce3da7c5d60805b303d012bad1c39 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Thu, 16 Jul 2026 06:05:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E8=B4=AD=E7=A5=A8=E5=8C=BA=E9=97=B4=E5=9C=B0=E5=9B=BE=E4=B8=BA?= =?UTF-8?q?=20TVM=20=E7=80=91=E5=B8=83=E6=B5=81=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/ticket-order.js | 754 +++++++++++++++----------------------------- 1 file changed, 246 insertions(+), 508 deletions(-) diff --git a/web/ticket-order.js b/web/ticket-order.js index 4d68825..e2d740a 100644 --- a/web/ticket-order.js +++ b/web/ticket-order.js @@ -27,7 +27,6 @@ } }; - // Fetch and Render Map const mapContainer = $('#stationMap'); let selection = [null, null]; let currentRoute = []; @@ -36,10 +35,14 @@ let stationEnByCode = {}; let stationCanonicalByCode = {}; let stationCodesByCanonical = {}; - let stationXByCanonical = {}; - let stationYByCanonical = {}; let stationTransfer = new Set(); + let lineStops = []; + let lineColors = []; + let linesByStation = new Map(); + let lineIdxsByCanonical = {}; + let activeLineIdx = 0; + const piePath = (cx, cy, r, a0, a1) => { const x0 = cx + r * Math.cos(a0); const y0 = cy + r * Math.sin(a0); @@ -63,159 +66,232 @@ return out; }; - const buildTransitPath = (points, radius = 12, options = {}) => { - const src = Array.isArray(points) ? points.filter(Boolean) : []; - if (src.length === 0) return ''; - if (src.length === 1) return `M ${src[0].x} ${src[0].y}`; - const laneOffset = Number(options?.laneOffset) || 0; - - const fmt = (n) => { - const v = Number(n); - if (!Number.isFinite(v)) return '0'; - return String(Math.round(v * 10) / 10); - }; - const eq = (a, b) => Math.abs(a.x - b.x) < 0.01 && Math.abs(a.y - b.y) < 0.01; - const push = (arr, p) => { - const x = Number(p?.x); - const y = Number(p?.y); - if (!Number.isFinite(x) || !Number.isFinite(y)) return; - const item = { x, y, code: p?.code ? String(p.code).trim() : '' }; - if (arr.length && eq(arr[arr.length - 1], item)) return; - arr.push(item); - }; - const isTransfer = (p) => { - const code = String(p?.code || '').trim(); - return !!code && stationTransfer && stationTransfer.has(code); - }; - const resolveLaneBendX = (from, to) => { - if (!laneOffset) return null; - const fromTransfer = isTransfer(from); - const toTransfer = isTransfer(to); - if (!fromTransfer && !toTransfer) return null; - - const minX = Math.min(from.x, to.x); - const maxX = Math.max(from.x, to.x); - if ((maxX - minX) < 28) return null; - - const anchorX = toTransfer && !fromTransfer - ? to.x - : (fromTransfer && !toTransfer ? from.x : ((from.x + to.x) / 2)); - const candidate = Math.max(minX + 12, Math.min(maxX - 12, anchorX + laneOffset)); - if (Math.abs(candidate - from.x) < 4 || Math.abs(candidate - to.x) < 4) return null; - return candidate; - }; - const simplify = (arr) => { - const out = []; - for (const p of arr) push(out, p); - let changed = true; - while (changed) { - changed = false; - for (let i = 1; i < out.length - 1; i++) { - const a = out[i - 1]; - const b = out[i]; - const c = out[i + 1]; - if ((a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y)) { - out.splice(i, 1); - changed = true; - break; - } - } - } - return out; - }; - const orth = []; - push(orth, src[0]); - for (let i = 1; i < src.length; i++) { - const prev = orth[orth.length - 1]; - const next = src[i]; - const x2 = Number(next?.x); - const y2 = Number(next?.y); - if (!Number.isFinite(x2) || !Number.isFinite(y2)) continue; - - if (prev.x === x2 || prev.y === y2) { - push(orth, next); - continue; - } - - const aT = isTransfer(prev); - const bT = isTransfer(next); - const bendX = resolveLaneBendX(prev, next); - if (Number.isFinite(bendX)) { - push(orth, { x: bendX, y: prev.y, code: prev?.code || next?.code }); - push(orth, { x: bendX, y: y2, code: next?.code || prev?.code }); - } else if (bT && !aT) { - push(orth, { x: x2, y: prev.y, code: next?.code }); - } else if (aT && !bT) { - push(orth, { x: prev.x, y: y2, code: prev?.code }); - } else { - push(orth, { x: x2, y: prev.y, code: next?.code }); - } - push(orth, next); - } - - const pts = simplify(orth); - if (pts.length === 0) return ''; - if (pts.length === 1) return `M ${fmt(pts[0].x)} ${fmt(pts[0].y)}`; - if (radius <= 0 || pts.length === 2) { - let d = `M ${fmt(pts[0].x)} ${fmt(pts[0].y)}`; - for (let i = 1; i < pts.length; i++) d += ` L ${fmt(pts[i].x)} ${fmt(pts[i].y)}`; - return d; - } - - const rBase = Math.max(0, Number(radius) || 0); - let d = `M ${fmt(pts[0].x)} ${fmt(pts[0].y)}`; - for (let i = 1; i < pts.length - 1; i++) { - const a = pts[i - 1]; - const b = pts[i]; - const c = pts[i + 1]; - const v1x = b.x - a.x; - const v1y = b.y - a.y; - const v2x = c.x - b.x; - const v2y = c.y - b.y; - const len1 = Math.hypot(v1x, v1y); - const len2 = Math.hypot(v2x, v2y); - if (!len1 || !len2) continue; - const r = Math.min(rBase, len1 / 2, len2 / 2); - const p1 = { x: b.x - (v1x / len1) * r, y: b.y - (v1y / len1) * r }; - const p2 = { x: b.x + (v2x / len2) * r, y: b.y + (v2y / len2) * r }; - d += ` L ${fmt(p1.x)} ${fmt(p1.y)} Q ${fmt(b.x)} ${fmt(b.y)} ${fmt(p2.x)} ${fmt(p2.y)}`; - } - d += ` L ${fmt(pts[pts.length - 1].x)} ${fmt(pts[pts.length - 1].y)}`; - return d; + const getContrastColor = (hexcolor) => { + if (!hexcolor) return '#ffffff'; + let hex = hexcolor.replace('#', ''); + if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; + const r = parseInt(hex.substr(0,2),16) || 0; + const g = parseInt(hex.substr(2,2),16) || 0; + const b = parseInt(hex.substr(4,2),16) || 0; + const yiq = ((r*299)+(g*587)+(b*114))/1000; + return (yiq >= 140) ? '#0f172a' : '#ffffff'; }; - async function loadMap() { - try { - const res = await fetch('/api/public/fares/map/light'); - const svg = await res.text(); - mapContainer.innerHTML = svg; - - const svgEl = mapContainer.querySelector('svg'); - if(!svgEl) return; - - renderLineMap(); - } catch(e) { - mapContainer.innerHTML = '
加载地图失败
'; + function renderStationNode(x, y, code, i, forceColor = null) { + const canonical = stationCanonicalByCode[code] || code; + const codes = stationCodesByCanonical[canonical] || [canonical]; + const lineIdxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b); + const colors = lineIdxs.map(idx => lineColors[idx]).filter(Boolean); + const uniqueColors = Array.from(new Set(colors)); + const primaryCode = codes[0]; + + const isTransfer = uniqueColors.length >= 2 || codes.length >= 2; + const name = getStationDisplayName(primaryCode); + const enName = stationEnByCode[primaryCode] || ''; + const showEn = !!enName; + + let markerSvg = ''; + if (isTransfer) { + markerSvg += ``; + markerSvg += pieSvg(x, y, 11.5, uniqueColors); + markerSvg += ``; + } else { + const color = forceColor || uniqueColors[0] || '#93a2b7'; + markerSvg += ``; + markerSvg += ``; } + + 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 += ``; + textContent += `${primaryCode}`; + textContent += `${name}`; + if (showEn) { + textContent += `${enName}`; + } + } else { + const topY = y + 34; + const codeY = topY; + const nameY = topY + 18; + const enY = nameY + 12; + textContent += ``; + textContent += `${primaryCode}`; + textContent += `${name}`; + if (showEn) { + textContent += `${enName}`; + } + } + 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 ` + + + ${markerSvg} + ${labelSvg} + `; } - async function renderLineMap() { - try { - const [lines, stations] = await Promise.all([ - fetch('/api/public/lines').then(r=>r.json()), - fetch('/api/public/stations').then(r=>r.json()) - ]); + function renderTvmLines() { + const container = document.getElementById('tvmLines'); + if (!container) return; + let html = ''; + for (let i = 0; i < lineStops.length; i++) { + const li = lineStops[i]; + const isActive = (i === activeLineIdx); + const bg = isActive ? li.color : '#f8fafc'; + const fg = isActive ? getContrastColor(li.color) : '#334155'; + const border = isActive ? li.color : '#cbd5e1'; + html += ``; + } + container.innerHTML = html; + } + + window.selectTvmLine = (idx) => { + activeLineIdx = idx; + renderTvmLines(); + renderTvmSingleLine(); + }; + + function renderTvmSingleLine() { + const container = document.getElementById('tvmSingleLine'); + if (!container) return; + const li = lineStops[activeLineIdx]; + if (!li) { + container.innerHTML = ''; + return; + } + + const startX = 80; + const gapX = 90; + const y = 80; + const pts = []; + + for (let i = 0; i < li.stops.length; i++) { + const code = li.stops[i]; + if (code === '-') continue; + const x = startX + pts.length * gapX; + pts.push({ x, y, code }); + } + + if (pts.length === 0) { + container.innerHTML = '
该线路暂无站点
'; + return; + } + + let svgContent = ''; + + if (pts.length >= 2) { + let d = `M ${pts[0].x} ${y}`; + for(let i=1; i`; + } + + 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 = `${svgContent}`; + } + + function renderTvmRoute() { + const container = document.getElementById('tvmRoute'); + const title = document.getElementById('tvmRouteTitle'); + if (!container) return; + + if (!selection[0] || !selection[1] || !currentRoute || currentRoute.length < 2) { + container.style.display = 'none'; + if (title) title.style.display = 'none'; + container.innerHTML = ''; + return; + } + + const routeCodes = buildDisplayRouteCodes(currentRoute, selection[0], selection[1]); + if (routeCodes.length < 2) { + container.style.display = 'none'; + if (title) title.style.display = 'none'; + container.innerHTML = ''; + return; + } + + container.style.display = 'flex'; + if (title) title.style.display = 'block'; + + const startX = 80; + const gapX = 90; + const y = 80; + + let svgContent = ''; + + const segments = []; + for (let i = 0; i < routeCodes.length - 1; i++) { + const c1 = routeCodes[i]; + const c2 = routeCodes[i+1]; - let html = ''; - lines.forEach(line => { - const stList = line.站序 || []; - + let color = '#93a2b7'; + const can1 = stationCanonicalByCode[c1] || c1; + const can2 = stationCanonicalByCode[c2] || c2; + const canLines1 = Array.from(lineIdxsByCanonical[can1] || []); + const canLines2 = Array.from(lineIdxsByCanonical[can2] || []); + const canCommon = canLines1.filter(l => canLines2.includes(l)); + if (canCommon.length > 0) { + color = lineColors[canCommon[0]]; + } + + segments.push({ + x1: startX + i * gapX, + x2: startX + (i + 1) * gapX, + color }); - } catch(e) {} + } + + for (const seg of segments) { + svgContent += ``; + } + + 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 += ` + + + + + `; + } + + 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 = `${svgContent}`; } - - async function renderSystemMap() { mapContainer.innerHTML = '
加载线路数据...
'; try { @@ -244,12 +320,11 @@ if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code)); } - const lineStops = []; - const lineColors = []; - const linesByStation = new Map(); - const lineIdxsByCanonical = {}; + lineStops = []; + lineColors = []; + linesByStation = new Map(); + lineIdxsByCanonical = {}; - // NEW logic for breaking lines with '-' let currentLineIdx = 0; for (let i = 0; i < linesData.length; i++) { const line = linesData[i] || {}; @@ -308,252 +383,36 @@ if (uniq.length >= 2) stationTransfer.add(c); } - // Build SVG - const legendW = 10; - const startX = 140; - const baseY = 80; - const totalLines = currentLineIdx; - const maxStops = Math.max(2, ...lineStops.map(li => Array.isArray(li.stops) ? li.stops.length : 2)); - let lineGapY = 80; - if (totalLines >= 14) lineGapY = 66; - else if (totalLines >= 10) lineGapY = 72; - lineGapY = Math.max(60, Math.min(84, lineGapY)); - let minGapX = 80; - if (maxStops >= 24) minGapX = 64; - else if (maxStops >= 18) minGapX = 72; - minGapX = Math.max(56, Math.min(96, minGapX)); - const labelStackGap = Math.max(30, Math.round(lineGapY * 0.55)); - - const occurrences = {}; - for (const li of lineStops) { - for (let i = 0; i < li.stops.length; i++) { - const code = li.stops[i]; - if (code === '-') continue; - const canonical = stationCanonicalByCode[code] || code; - if (!occurrences[canonical]) occurrences[canonical] = []; - occurrences[canonical].push(i * minGapX); - } - } - - stationXByCanonical = {}; - for (const canonical of Object.keys(occurrences)) { - const arr = occurrences[canonical]; - const avg = arr.reduce((a,b)=>a+b,0) / Math.max(1, arr.length); - stationXByCanonical[canonical] = startX + Math.round(avg); - } - - for (let pass = 0; pass < 3; pass++) { - for (const li of lineStops) { - let prevX = startX - minGapX; - for (const code of li.stops) { - if (code === '-') continue; - const canonical = stationCanonicalByCode[code] || code; - const x = stationXByCanonical[canonical] ?? (prevX + minGapX); - const nx = Math.max(x, prevX + minGapX); - if (stationXByCanonical[canonical] == null || nx > stationXByCanonical[canonical]) { - stationXByCanonical[canonical] = nx; - } - prevX = stationXByCanonical[canonical]; - } - } - } - - // 3. Assign Grid Y to canonical stations (Average of their lines) - const hubYByCanonical = {}; - for (const canonical of Object.keys(lineIdxsByCanonical)) { - const idxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b); - if (!idxs.length) continue; - const ys = idxs.map(idx => baseY + idx * lineGapY); - hubYByCanonical[canonical] = ys.reduce((sum, y) => sum + y, 0) / ys.length; - } - - stationYByCanonical = {}; - for (const li of lineStops) { - const yNode = baseY + li.idx * lineGapY; - for (const c of li.stops) { - const canonical = stationCanonicalByCode[c] || c; - stationYByCanonical[c] = hubYByCanonical[canonical] ?? yNode; - } - } - - const getContrastColor = (hexcolor) => { - if (!hexcolor) return '#ffffff'; - let hex = hexcolor.replace('#', ''); - if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; - const r = parseInt(hex.substr(0,2),16) || 0; - const g = parseInt(hex.substr(2,2),16) || 0; - const b = parseInt(hex.substr(4,2),16) || 0; - const yiq = ((r*299)+(g*587)+(b*114))/1000; - return (yiq >= 140) ? '#0f172a' : '#ffffff'; - }; - - const labelPlacements = {}; - const getLaneOffset = (lineIdx) => { - const center = (Math.max(totalLines, 1) - 1) / 2; - return Math.max(-18, Math.min(18, Math.round((lineIdx - center) * 4))); - }; - const placeLabel = (x, y, isStart, name, enName, color) => { - const key = `${x},${y},${isStart ? 'left' : 'right'}`; - const count = labelPlacements[key] || 0; - labelPlacements[key] = count + 1; - - const textColor = getContrastColor(color); - const subTextColor = textColor === '#ffffff' ? 'rgba(255,255,255,0.9)' : 'rgba(15,23,42,0.8)'; - - const hasEn = !!enName; - const nameLen = Math.max(name.length * 13 + 16, hasEn ? enName.length * 6.5 + 16 : 0); - const h = hasEn ? 36 : 24; - const yOffset = count * labelStackGap - (h / 2); - - let lx, textX; - if (isStart) { - lx = x - 54 - nameLen; // Pushed further out to avoid line overlap - textX = lx + nameLen / 2; - } else { - lx = x + 54; // Pushed further out - textX = lx + nameLen / 2; - } - - let svg = ``; - if (hasEn) { - svg += `${name}`; - svg += `${enName}`; - } else { - svg += `${name}`; - } - 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 += ``; - - // 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 += ``; - markerSvg += pieSvg(x, y, 11.5, uniqueColors); - markerSvg += ``; - } else { - const color = uniqueColors[0] || '#93a2b7'; - markerSvg += ``; - markerSvg += ``; - } - - 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 += ``; - textContent += `${primaryCode}`; - - // Text with white halo for readability - textContent += `${name}`; - if (showEn) { - textContent += `${enName}`; - } - } else { - const topY = y + 34; - const codeY = topY; - const nameY = topY + 18; - const enY = nameY + 12; - - textContent += ``; - textContent += `${primaryCode}`; - - textContent += `${name}`; - if (showEn) { - textContent += `${enName}`; - } - } - labelSvg += textContent; - - svgContent += ` - - - ${markerSvg} - ${labelSvg} - - `; - } - - 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 = `${svgContent}`; + mapContainer.innerHTML = ` +
+
+
+ + +
+ `; + + renderTvmLines(); + renderTvmSingleLine(); } catch(e) { console.error(e); - mapContainer.innerHTML = '
地图加载失败
'; + mapContainer.innerHTML = '
数据加载失败
'; } } - + window.handleStationClick = (code) => { code = String(code || '').trim(); - // Toggle if already selected if (selection[0] === code) { selection[0] = null; } else if (selection[1] === code) { selection[1] = null; } else { - // Add new selection if (!selection[0]) { selection[0] = code; } else if (!selection[1]) { selection[1] = code; } else { - // When both ends are already selected, start a new selection flow. selection[0] = code; selection[1] = null; currentRoute = []; @@ -561,7 +420,6 @@ } } - // Ensure no gaps (if start removed, move end to start) if (!selection[0] && selection[1]) { selection[0] = selection[1]; selection[1] = null; @@ -569,7 +427,7 @@ updateSelectionUI(); }; - + function normalizeTrips() { const raw = Number(tripsEl?.value || 1); const trips = Number.isFinite(raw) ? Math.max(1, Math.floor(raw)) : 1; @@ -663,115 +521,15 @@ return merged; } - function getStationPoint(code) { - const normalized = String(code || '').trim(); - if (!normalized) return null; - const canonical = stationCanonicalByCode[normalized] || normalized; - const x = stationXByCanonical[canonical]; - const y = stationYByCanonical[normalized]; - if (!Number.isFinite(x) || !Number.isFinite(y)) return null; - return { x, y, code: normalized }; - } - - function getMapStationEl(code) { - const normalized = String(code || '').trim(); - if (!normalized) return null; - const canonical = stationCanonicalByCode[normalized] || normalized; - return document.querySelector(`.map-station[data-canonical="${canonical}"]`); - } - - function renderRouteOverlay() { - const svgEl = mapContainer.querySelector('svg'); - if (!svgEl) return; - - const existing = svgEl.querySelector('.route-overlay-group'); - if (existing) existing.remove(); - - if (!Array.isArray(currentRoute) || currentRoute.length < 2) return; - - const points = currentRoute.map(getStationPoint).filter(Boolean); - if (points.length < 2) return; - - const ns = 'http://www.w3.org/2000/svg'; - const group = document.createElementNS(ns, 'g'); - group.setAttribute('class', 'route-overlay-group'); - - const pathData = buildTransitPath(points, 16); - if (!pathData) return; - - const glow = document.createElementNS(ns, 'path'); - glow.setAttribute('d', pathData); - glow.setAttribute('fill', 'none'); - glow.setAttribute('stroke', 'rgba(250, 204, 21, 0.38)'); - glow.setAttribute('stroke-width', '18'); - glow.setAttribute('stroke-linecap', 'round'); - glow.setAttribute('stroke-linejoin', 'round'); - - const main = document.createElementNS(ns, 'path'); - main.setAttribute('d', pathData); - main.setAttribute('fill', 'none'); - main.setAttribute('stroke', '#facc15'); - main.setAttribute('stroke-width', '8'); - main.setAttribute('stroke-linecap', 'round'); - main.setAttribute('stroke-linejoin', 'round'); - - group.appendChild(glow); - group.appendChild(main); - - // Dynamic arrows pointing to the destination - let totalDist = 0; - for (let i = 0; i < points.length - 1; i++) { - const dx = points[i+1].x - points[i].x; - const dy = points[i+1].y - points[i].y; - totalDist += Math.sqrt(dx * dx + dy * dy); - } - - const speed = 150; // pixels per second - const duration = Math.max(1, totalDist / speed); - const numArrows = Math.max(1, Math.floor(duration / 0.8)); - const interval = duration / numArrows; - - for (let i = 0; i < numArrows; i++) { - const arrowG = document.createElementNS(ns, 'g'); - const arrowPath = document.createElementNS(ns, 'path'); - // Chevron shape - arrowPath.setAttribute('d', 'M -6 -5 L 4 0 L -6 5'); - arrowPath.setAttribute('fill', 'none'); - arrowPath.setAttribute('stroke', '#854d0e'); // Dark brown/orange to contrast with yellow - arrowPath.setAttribute('stroke-width', '2.5'); - arrowPath.setAttribute('stroke-linecap', 'round'); - arrowPath.setAttribute('stroke-linejoin', 'round'); - - const animateMotion = document.createElementNS(ns, 'animateMotion'); - animateMotion.setAttribute('dur', `${duration}s`); - animateMotion.setAttribute('repeatCount', 'indefinite'); - animateMotion.setAttribute('path', pathData); - animateMotion.setAttribute('rotate', 'auto'); - animateMotion.setAttribute('begin', `-${i * interval}s`); - // Use keyPoints/keyTimes to ensure constant speed along the path - animateMotion.setAttribute('calcMode', 'linear'); - - arrowG.appendChild(arrowPath); - arrowG.appendChild(animateMotion); - group.appendChild(arrowG); - } - - const firstStation = svgEl.querySelector('.map-station'); - if (firstStation) svgEl.insertBefore(group, firstStation); - else svgEl.appendChild(group); -} - function updateSelectionUI(skipPreview = false) { if (!(selection[0] && selection[1])) { currentRoute = []; currentRouteTransfers = []; } - // Update Inputs fromEl.value = selection[0] || ''; toEl.value = selection[1] || ''; - // Update Displays with Chinese names const getName = (code) => { return stationNameByCode[String(code || '').trim()] || code; }; @@ -779,41 +537,24 @@ $('#fromDisplay').textContent = selection[0] ? getName(selection[0]) : '请在上方地图选择'; $('#toDisplay').textContent = selection[1] ? getName(selection[1]) : '请在上方地图选择'; - // Update Map Styles document.querySelectorAll('.map-station').forEach(el => { const code = el.getAttribute('data-code'); const canonical = el.getAttribute('data-canonical'); el.classList.remove('start', 'end', 'selected', 'route', 'route-transfer'); if (selection[0] && canonical === (stationCanonicalByCode[selection[0]] || selection[0])) el.classList.add('start'); if (selection[1] && canonical === (stationCanonicalByCode[selection[1]] || selection[1])) el.classList.add('end'); + + if (Array.isArray(currentRoute) && currentRoute.includes(code)) el.classList.add('route'); + if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.includes(code)) el.classList.add('route-transfer'); }); - if (Array.isArray(currentRoute) && currentRoute.length > 0) { - for (const c of currentRoute) { - const el = getMapStationEl(c); - if (el) el.classList.add('route'); - } - } - if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.length > 0) { - for (const c of currentRouteTransfers) { - const el = getMapStationEl(c); - if (el) el.classList.add('route-transfer'); - } - } + renderTvmRoute(); - renderRouteOverlay(); - - // Auto preview if both selected if(!skipPreview && selection[0] && selection[1]) previewPrice(); } - // Load Map on Start renderSystemMap(); - window.addEventListener('resize', () => { - if (currentRoute.length > 0) renderRouteOverlay(); - }); - async function previewPrice(force = false){ const seq = ++previewSeq; const type = document.querySelector('input[name="trainType"]:checked').value; @@ -894,7 +635,6 @@ } } - // Create Order with auto-preview async function createOrder(){ const type = document.querySelector('input[name="trainType"]:checked').value; const trips = normalizeTrips(); @@ -905,7 +645,6 @@ const to = (toEl.value||'').trim(); if(!from || !to){ alert('请完整填写信息'); return; } - // Auto-fetch price before creating await previewPrice(true); payload = { start: from, terminal: to, train_type: type, trips, ride_date }; @@ -913,7 +652,6 @@ const res = await fetch('/api/public/orders', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(payload) }); const r = await res.json(); if(r && r.ok){ - // Link to external subdomain or local page const buildTokenLink = (code) => { if (location.hostname.includes('fse-media.group')) { return `https://ticket.fse-media.group/token?code=${encodeURIComponent(code)}`; @@ -942,7 +680,7 @@ const btnCreate = $('#createOrder'); if(btnCreate) btnCreate.onclick = createOrder; - // Listen for Type change to auto-update price + document.querySelectorAll('input[name="trainType"]').forEach(el => { el.onchange = () => { if(fromEl.value && toEl.value) previewPrice(); }; });