From 86f3d478d66ad6708dfdba589285ca36d24a1c0f Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Wed, 15 Jul 2026 20:41:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(ticket-order):=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E7=BA=BF=E8=B7=AF=E5=9C=B0=E5=9B=BE=E5=8F=AF=E8=A7=86=E5=8C=96?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=9B=BF=E6=8D=A2=E5=8E=9F=E6=9C=89?= =?UTF-8?q?=E7=BA=BF=E8=B7=AFUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 替换原有的标签式线路站点UI,新增加载渲染官方线路SVG地图、线路分段展示、换乘站标记、站点中英文标注、路线规划高亮预览等功能,优化响应式布局与交互逻辑。 --- web/ticket-order.js | 676 +++++++++++++++++++++++++++++--------------- 1 file changed, 446 insertions(+), 230 deletions(-) diff --git a/web/ticket-order.js b/web/ticket-order.js index ff7087f..90ab193 100644 --- a/web/ticket-order.js +++ b/web/ticket-order.js @@ -32,10 +32,71 @@ let stationYByCanonical = {}; let stationTransfer = new Set(); - // TVM UI State - let activeLineId = null; - let allLinesData = []; - let allStationsData = []; + 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 ``; + 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 += ``; + } + return out; + }; + + const buildTransitPath = (points, radius = 12) => { + if (!points || points.length === 0) return ''; + if (points.length === 1) return `M ${points[0].x} ${points[0].y}`; + + let d = `M ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i++) { + d += ` L ${points[i].x} ${points[i].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 = '
加载地图失败
'; + } + } + + 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 = '
加载线路数据...
'; @@ -46,15 +107,11 @@ ]); window.cachedStationsData = stationsData; - allLinesData = linesData; - allStationsData = stationsData; - stationTransfer = new Set(); stationCanonicalByCode = {}; stationCodesByCanonical = {}; stationNameByCode = {}; stationEnByCode = {}; - for (const s of stationsData) { const code = String(s.code || s.编号 || '').trim(); if (!code) continue; @@ -69,245 +126,286 @@ 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 === '-') continue; - const arr = linesByStation.get(c) || []; - arr.push(line); - linesByStation.set(c, arr); + 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 = '

暂无可显示的线路数据。

'; + return; + } + for (const [c, arr] of linesByStation.entries()) { - const uniq = Array.from(new Set(arr.map(l => l.id))); + const uniq = Array.from(new Set(arr)); if (uniq.length >= 2) stationTransfer.add(c); } - // Select first line by default if none selected - if (!activeLineId && allLinesData.length > 0) { - activeLineId = allLinesData[0].id; + // Build SVG + const legendW = 10; + const startX = 140; + const baseY = 80; + const lineGapY = 80; + const minGapX = 80; + + 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); + } } - renderTvmUI(); + 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 * 42 - (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) + }; + }); + + const d = buildTransitPath(pts, 16); + svgContent += ``; + + // Add line name labels at ends + const pStart = pts[0]; + const pEnd = pts[pts.length - 1]; + svgContent += placeLabel(pStart.x, pStart.y, true, li.name, li.enName, li.color); + svgContent += placeLabel(pEnd.x, pEnd.y, 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 - 14; + const enY = bottomY; + const nameY = showEn ? bottomY - 12 : bottomY; + const codeY = nameY - 16; + + textContent += ``; + textContent += `${primaryCode}`; + + // Text with white halo for readability + textContent += `${name}`; + if (showEn) { + textContent += `${enName}`; + } + } else { + const topY = y + 24; + const codeY = topY; + const nameY = topY + 16; + 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}`; } catch(e) { console.error(e); - mapContainer.innerHTML = '
数据加载失败
'; + mapContainer.innerHTML = '
地图加载失败
'; } } - function renderTvmUI() { - if (!allLinesData || allLinesData.length === 0) { - mapContainer.innerHTML = '

暂无可显示的线路数据。

'; - return; - } - - const nodeSlotWidth = window.innerWidth <= 768 ? 58 : 70; - const trackPadding = Math.round(nodeSlotWidth / 2); - - // 1. Render Line Tabs - let tabsHtml = `
`; - allLinesData.forEach(line => { - const color = line.color || '#94a3b8'; - const isActive = String(line.id) === String(activeLineId); - const bg = isActive ? color : 'var(--bg-color, #ffffff)'; - const fg = isActive ? '#ffffff' : 'var(--text-color, #334155)'; - const border = isActive ? color : 'var(--border-color, #cbd5e1)'; - tabsHtml += ` - - `; - }); - tabsHtml += `
`; - - // 2. Render Stations for Active Line - const activeLine = allLinesData.find(l => String(l.id) === String(activeLineId)) || allLinesData[0]; - const stopsRaw = Array.isArray(activeLine.stops) ? activeLine.stops : (Array.isArray(activeLine.站点列表) ? activeLine.站点列表 : []); - const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean); - const visibleStops = stops.filter(code => code !== '-'); - - let stationsHtml = `
`; - - stationsHtml += `
`; - - stationsHtml += `
`; - - const getVisibleStopIndex = (targetCode) => { - const targetCanonical = stationCanonicalByCode[String(targetCode || '').trim()] || String(targetCode || '').trim(); - return visibleStops.findIndex(code => (stationCanonicalByCode[code] || code) === targetCanonical); - }; - - const fromIndex = getVisibleStopIndex(selection[0]); - const toIndex = getVisibleStopIndex(selection[1]); - const sameLineSelection = fromIndex >= 0 - && toIndex >= 0 - && selection[0] - && selection[1] - && (!Array.isArray(currentRouteTransfers) || currentRouteTransfers.length === 0); - - if (sameLineSelection) { - const startIndex = Math.min(fromIndex, toIndex); - const endIndex = Math.max(fromIndex, toIndex); - const left = (startIndex * nodeSlotWidth) + trackPadding; - const width = Math.max((endIndex - startIndex) * nodeSlotWidth, 0); - const arrowCount = Math.max(1, Math.floor(width / 40)); - let arrowsHtml = ''; - for (let i = 0; i < arrowCount; i++) { - arrowsHtml += `›`; - } - stationsHtml += ` -
-
${arrowsHtml}
-
- `; - } - - let hasStations = false; - stops.forEach(code => { - if (code === '-') return; - hasStations = true; - - const name = stationNameByCode[code] || code; - const enName = stationEnByCode[code] || ''; - - const isSelectedFrom = selection[0] === code; - const isSelectedTo = selection[1] === code; - const inRoute = Array.isArray(currentRoute) && currentRoute.includes(code); - - // Check transfers - const canonical = stationCanonicalByCode[code] || code; - const codesInGroup = stationCodesByCanonical[canonical] || [canonical]; - const linesForThisStation = new Set(); - codesInGroup.forEach(c => { - allLinesData.forEach(l => { - const lStops = Array.isArray(l.stops) ? l.stops : (Array.isArray(l.站点列表) ? l.站点列表 : []); - if (lStops.includes(c)) linesForThisStation.add(l); - }); - }); - - const transferLines = Array.from(linesForThisStation).filter(l => String(l.id) !== String(activeLineId)); - const isTransfer = transferLines.length > 0; - - let nodeColor = activeLine.color || '#94a3b8'; - let nodeSize = isTransfer ? '20px' : '14px'; - let nodeBorder = isTransfer ? '4px' : '3px'; - let badgeHtml = ''; - - if (isSelectedFrom) { - nodeColor = 'var(--success, #16a34a)'; - nodeSize = '24px'; - badgeHtml = `
起点
`; - } else if (isSelectedTo) { - nodeColor = 'var(--danger, #dc2626)'; - nodeSize = '24px'; - badgeHtml = `
终点
`; - } else if (inRoute) { - nodeColor = 'var(--primary, #facc15)'; - if (!isTransfer) nodeSize = '18px'; - else nodeSize = '24px'; - } - - let transferDotsHtml = ''; - if (isTransfer) { - transferDotsHtml = `
`; - transferLines.slice(0, 3).forEach(l => { - transferDotsHtml += `
`; - }); - transferDotsHtml += `
`; - } - - stationsHtml += ` -
- - -
- ${badgeHtml} - ${transferDotsHtml} -
-
- - -
-
${code}
-
${name}
- ${enName ? `
${enName}
` : ''} -
-
- `; - }); - - if (!hasStations) { - stationsHtml += `
该线路暂无站点
`; - } - - stationsHtml += `
`; - - mapContainer.innerHTML = tabsHtml + stationsHtml; - } - - window.switchTvmLine = (lineId) => { - activeLineId = lineId; - renderTvmUI(); - }; - window.handleStationClick = (code) => { code = String(code || '').trim(); // Toggle if already selected @@ -432,6 +530,104 @@ 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 }; + } + + 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 = []; @@ -450,8 +646,29 @@ $('#fromDisplay').textContent = selection[0] ? getName(selection[0]) : '请在上方地图选择'; $('#toDisplay').textContent = selection[1] ? getName(selection[1]) : '请在上方地图选择'; - // Refresh TVM UI to show selected/route states - renderTvmUI(); + // 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(); @@ -461,8 +678,7 @@ renderSystemMap(); window.addEventListener('resize', () => { - if (!allLinesData || allLinesData.length === 0) return; - renderTvmUI(); + if (currentRoute.length > 0) renderRouteOverlay(); }); async function previewPrice(force = false){