From 620289e836fe3f313e786df33f73d6017eaa6bee Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Wed, 15 Jul 2026 00:50:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=BA=BF=E8=B7=AF=E5=9B=BE?= =?UTF-8?q?=E7=BB=98=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/services/svg-generator.js | 852 ++++++++++++++++++++++++------- web/style.css | 7 - web/ticket-order.html | 2 +- 3 files changed, 665 insertions(+), 196 deletions(-) diff --git a/server/services/svg-generator.js b/server/services/svg-generator.js index 71f9a8b..49a4454 100644 --- a/server/services/svg-generator.js +++ b/server/services/svg-generator.js @@ -1,205 +1,681 @@ const fs = require('fs'); +const path = require('path'); -const escapeHTML = (s) => String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +const DEFAULT_VIEWPORT = { width: 1440, height: 920 }; +const DEFAULT_LINE_COLOR = '#93a2b7'; +const REFERENCE_SVG_PATH = path.resolve(__dirname, '..', '..', 'FSE铁路线路图.svg'); -function generate(stations, lines, fares, nameByCode, transfers) { - const enNameByCode = (() => { - try { - const map = {}; for (const s of stations) { map[String(s.code || '')] = s.en_name || s.enName || ''; } - return map; - } catch (_) { return {}; } - })(); - - // Build linesForStation for coloring (still needed for visualization) - const linesForStation = {}; - (lines || []).forEach(li => { - const id = li.id || ''; - const name = li.name || li.cn_name || ''; - const color = li.color || '#93a2b7'; - const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x)); - for (const code of arr) { - if (!linesForStation[code]) linesForStation[code] = []; - linesForStation[code].push({ id, name, color, stations: arr }); - } - }); +const escapeHTML = (s) => String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); - const groupsMap = {}; - // Optional: We can still use groups for coloring logic, but NOT for graph edges if we want strict mode. - // But let's keep groupsMap for color logic (lines 30-46) as it helps determine if lines connect visually. - stations.forEach(s => { - const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim(); - if (!k) return; (groupsMap[k] ||= []).push(String(s.code || '')); - }); - - const groupsForColors = Object.values(groupsMap).filter(arr => arr.length > 1); - const colorsForRoute = (a, b) => { - const la = linesForStation[a] || []; - const lb = linesForStation[b] || []; - const same = la.find(x => lb.some(y => y.id === x.id)); - if (same) return [same.color || '#93a2b7']; - // Logic to find connecting colors via transfer groups - for (const g of groupsForColors) { - const startLine = la.find(li => (li.stations || []).some(code => g.includes(code))); - const endLine = lb.find(li => (li.stations || []).some(code => g.includes(code))); - if (startLine && endLine) { - const c1 = startLine.color || '#93a2b7'; - const c2 = endLine.color || '#93a2b7'; - return (startLine.id === endLine.id) ? [c1] : [c1, c2]; +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +const round = (value, step = 2) => Math.round(Number(value || 0) / step) * step; +const toCode = (value) => String(value || '').trim(); +const normalizeName = (value) => String(value || '').trim().toLowerCase().replace(/\s+/g, ''); +const parseNumber = (value) => { + const num = Number(value); + return Number.isFinite(num) ? num : null; +}; + +const attrRegex = /([\w:-]+)="([^"]*)"/g; + +function parseAttributes(raw) { + const attrs = {}; + if (!raw) return attrs; + for (const match of raw.matchAll(attrRegex)) { + attrs[match[1]] = match[2]; + } + return attrs; +} + +function parseReferenceSvgMeta() { + try { + if (!fs.existsSync(REFERENCE_SVG_PATH)) return { ...DEFAULT_VIEWPORT, anchorPoints: [] }; + const raw = fs.readFileSync(REFERENCE_SVG_PATH, 'utf8'); + const viewBoxMatch = raw.match(/viewBox="([\d.\s-]+)"/i); + const widthMatch = raw.match(/\bwidth="([\d.]+)"/i); + const heightMatch = raw.match(/\bheight="([\d.]+)"/i); + let width = DEFAULT_VIEWPORT.width; + let height = DEFAULT_VIEWPORT.height; + if (viewBoxMatch) { + const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number); + if (parts.length === 4 && Number.isFinite(parts[2]) && Number.isFinite(parts[3])) { + width = parts[2]; + height = parts[3]; } + } else { + width = parseNumber(widthMatch?.[1]) || width; + height = parseNumber(heightMatch?.[1]) || height; } - return []; - }; - const buildGraph = () => { - const adj = new Map(); - const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); }; - const addEdge = (u, v, w) => { addNode(u); addNode(v); adj.get(u).set(v, w); adj.get(v).set(u, w); }; - - // Create a set of valid station codes for fast lookup - const validStationCodes = new Set(stations.map(s => String(s.code || ''))); + const circles = []; + for (const match of raw.matchAll(/]*)\/?>/gi)) { + const attrs = parseAttributes(match[1]); + const cx = parseNumber(attrs.cx); + const cy = parseNumber(attrs.cy); + const r = parseNumber(attrs.r); + if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(r)) continue; + circles.push({ + cx, + cy, + r, + fill: attrs.fill || '', + stroke: attrs.stroke || '', + strokeWidth: parseNumber(attrs['stroke-width']) || 0 + }); + } - // 1. Edges from explicit FARES only - (fares || []).forEach(f => { - const a = String(f.from || ''), b = String(f.to || ''); - if (!a || !b) return; - if (!validStationCodes.has(a) || !validStationCodes.has(b)) return; - - const w = Number(f.cost_regular ?? f.cost) || 0; - addEdge(a, b, w); + const anchorCandidates = circles.filter((circle) => { + if (circle.r < 4) return false; + if (!circle.stroke || circle.stroke.toLowerCase() === 'none') return false; + return true; }); - // 2. Edges from TRANSFERS (0 cost) - if (Array.isArray(transfers)) { - transfers.forEach(p => { - const a = String(p[1] || ''), b = String(p[2] || ''); // startup.lua used index 1,2? No, lua is 1-based. JS is 0-based. - // Wait, config transfers is [[a,b], [c,d]]. - // Let's check how it's stored. usually JSON array of arrays. - // In startup.lua: p[1], p[2]. - // In JS: p[0], p[1]. - // But let's check standard. - // If I look at startup.lua: `local a, b = tostring(p[1] or ''), tostring(p[2] or '')` (Lua 1-based index) - // So in JSON it is `["A", "B"]`. - // In JS `p[0]` is "A", `p[1]` is "B". - // Let's be safe and check both or iterate. - - const u = String(p[0] || ''); - const v = String(p[1] || ''); - - if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) { - addEdge(u, v, 0); - } - }); - } - - // REMOVED: Edges from Lines (implicit 0 cost) - // REMOVED: Edges from Groups (implicit 0 cost) - unless we want to keep it as fallback? - // User said "Strictly use database data". If transfers are in DB, we use them. - // If user relies on name grouping but didn't put it in DB transfers, they might be unhappy. - // But "Strictly use database data" implies we shouldn't automagically connect things. - // I will remove the auto-grouping for graph edges to be consistent with startup.lua changes. - - stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); }); - return adj; - }; - const adj = buildGraph(); - const codes = Array.from(adj.keys()).sort((a, b) => a.localeCompare(b)); - - const neighborsOf = (u) => adj.get(u) || new Map(); - const dijkstra = (src) => { - const dist = {}; const visited = new Set(); - for (const n of adj.keys()) dist[n] = Infinity; dist[src] = 0; - while (true) { - let u = null; let best = Infinity; - for (const n of adj.keys()) { if (!visited.has(n) && dist[n] < best) { best = dist[n]; u = n; } } - if (u === null) break; visited.add(u); - for (const [v, w] of neighborsOf(u)) { - const nd = dist[u] + Number(w || 0); - if (nd < dist[v]) dist[v] = nd; - } - } - return dist; - }; - const distAll = {}; codes.forEach(c => { distAll[c] = dijkstra(c); }); - - let maxFare = 1; - for (const r of codes) { - for (const c of codes) { if (r === c) continue; const v = Number(distAll[r][c] || 0); if (v > maxFare) maxFare = v; } - } - - const margin = 8; - const cellW = 56; - const cellH = 40; - const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10)); - const maxCnW = Math.max(1, ...codes.map(c => estimateWidth(nameByCode[c] || c, 14))); - const maxEnW = Math.max(1, ...codes.map(c => estimateWidth(enNameByCode[c] || '', 10))); - const maxCodeW = Math.max(1, ...codes.map(c => estimateWidth(c, 10))); - const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW)); - const topH = 64; - const n = codes.length; - const width = margin + leftW + n * cellW + margin; - const height = margin + topH + n * cellH + margin; - let svg = ``; - // Column headers - for (let j = 0; j < n; j++) { - const code = codes[j]; const name = nameByCode[code] || code; const en = enNameByCode[code] || ''; - const x = margin + leftW + j * cellW + cellW / 2; const y = margin + 20; - svg += `${escapeHTML(name)}`; - if (en) svg += `${escapeHTML(en)}`; - svg += `${escapeHTML(code)}`; - } - - for (let i = 0; i < n; i++) { - const code = codes[i]; const name = nameByCode[code] || code; const en = enNameByCode[code] || ''; - const xText = margin + 6; const baseY = margin + topH + i * cellH + 22; - svg += `${escapeHTML(name)}`; - if (en) svg += `${escapeHTML(en)}`; - svg += `${escapeHTML(code)}`; - for (let j = 0; j < n; j++) { - const x0 = margin + leftW + j * cellW; const y0 = margin + topH + i * cellH; - svg += ``; - if (i === j) { - svg += `-`; - } else { - const distVal = distAll[code][codes[j]]; - if (distVal === Infinity || !Number.isFinite(distVal)) { - // Unreachable station - svg += `×`; - } else { - const val = Math.round(Number(distVal || 0)); - const intensity = maxFare > 0 ? Math.min(1, val / maxFare) : 0; - - // Get route colors - const routeColors = colorsForRoute(code, codes[j]); - - // Draw background strips for multiple lines - if (routeColors.length > 0) { - const stripW = cellW / routeColors.length; - for (let k = 0; k < routeColors.length; k++) { - const c = routeColors[k] || '#93a2b7'; - const stripX = x0 + k * stripW; - // Use fill-opacity for robustness against any color format - svg += ``; - } - } else { - svg += ``; - } - - // Add cell border - svg += ``; - - svg += `${escapeHTML(String(val))}`; - - // Show small color indicators at top left - const dots = routeColors.slice(0, 4); - for (let k = 0; k < dots.length; k++) { - const cx = x0 + 8 + k * 10; const cy = y0 + 10; - const dotColor = dots[k] || '#93a2b7'; - svg += ``; - } + const clusters = []; + for (const circle of anchorCandidates) { + let target = null; + for (const cluster of clusters) { + const dx = cluster.x - circle.cx; + const dy = cluster.y - circle.cy; + const distance = Math.sqrt((dx * dx) + (dy * dy)); + if (distance <= 18) { + target = cluster; + break; } } + if (!target) { + target = { x: circle.cx, y: circle.cy, count: 0 }; + clusters.push(target); + } + target.count += 1; + target.x += (circle.cx - target.x) / target.count; + target.y += (circle.cy - target.y) / target.count; + } + + const anchorPoints = clusters + .sort((a, b) => (a.y - b.y) || (a.x - b.x)) + .map((point) => ({ x: point.x, y: point.y })); + + return { width, height, anchorPoints }; + } catch (_) { + return { ...DEFAULT_VIEWPORT, anchorPoints: [] }; + } +} + +function createUnionFind() { + const parent = new Map(); + const find = (value) => { + if (!parent.has(value)) parent.set(value, value); + const root = parent.get(value); + if (root !== value) { + const collapsed = find(root); + parent.set(value, collapsed); + return collapsed; + } + return root; + }; + const union = (a, b) => { + if (!a || !b) return; + const ra = find(a); + const rb = find(b); + if (ra !== rb) parent.set(rb, ra); + }; + return { parent, find, union }; +} + +function buildTransferGroups(stations, transfers) { + const uf = createUnionFind(); + const codesByName = new Map(); + + for (const station of (stations || [])) { + const code = toCode(station?.code); + if (!code) continue; + uf.find(code); + const nameKey = `${normalizeName(station?.name || station?.cn_name || station?.station_name)}|${normalizeName(station?.en_name || station?.enName || station?.en)}`; + if (nameKey !== '|') { + if (!codesByName.has(nameKey)) codesByName.set(nameKey, []); + codesByName.get(nameKey).push(code); } } + + for (const station of (stations || [])) { + const from = toCode(station?.code); + if (!from) continue; + const transferTo = Array.isArray(station?.transfer_to) ? station.transfer_to : []; + for (const item of transferTo) { + const to = toCode((typeof item === 'string') ? item : (item?.code || item?.station || item?.id || item?.[0])); + if (to) uf.union(from, to); + } + } + + for (const pair of (transfers || [])) { + const from = toCode(pair?.[0]); + const to = toCode(pair?.[1]); + if (from && to) uf.union(from, to); + } + + for (const group of codesByName.values()) { + if (!Array.isArray(group) || group.length < 2) continue; + for (let index = 1; index < group.length; index += 1) { + uf.union(group[0], group[index]); + } + } + + const codesByCanonical = {}; + const canonicalByCode = {}; + for (const station of (stations || [])) { + const code = toCode(station?.code); + if (!code) continue; + const canonical = uf.find(code); + if (!codesByCanonical[canonical]) codesByCanonical[canonical] = []; + codesByCanonical[canonical].push(code); + } + + for (const root of Object.keys(codesByCanonical)) { + const sortedCodes = Array.from(new Set(codesByCanonical[root])).sort((a, b) => a.localeCompare(b)); + const canonical = sortedCodes[0]; + delete codesByCanonical[root]; + codesByCanonical[canonical] = sortedCodes; + for (const code of sortedCodes) canonicalByCode[code] = canonical; + } + + return { canonicalByCode, codesByCanonical }; +} + +function buildNodes(stations, nameByCode, transferGroups) { + const nodes = new Map(); + for (const station of (stations || [])) { + const code = toCode(station?.code); + if (!code) continue; + const canonical = transferGroups.canonicalByCode[code] || code; + const name = String( + station?.name + || station?.cn_name + || station?.station_name + || nameByCode?.[code] + || code + ); + const enName = String(station?.en_name || station?.enName || station?.en || ''); + const svgX = parseNumber(station?.svg_x); + const svgY = parseNumber(station?.svg_y); + if (!nodes.has(canonical)) { + nodes.set(canonical, { + canonical, + codes: [], + primaryCode: code, + name, + enName, + lines: new Set(), + explicitPositions: [], + explicitLineIndices: [], + isTransfer: false + }); + } + const node = nodes.get(canonical); + node.codes.push(code); + if (!node.name && name) node.name = name; + if (!node.enName && enName) node.enName = enName; + if (Number.isFinite(svgX) && Number.isFinite(svgY)) { + node.explicitPositions.push({ x: svgX, y: svgY }); + } + } + return nodes; +} + +function buildLineSegments(lines, transferGroups, nodeMap) { + const segments = []; + const uniqueEdgeSet = new Set(); + + (lines || []).forEach((line, lineIndex) => { + const color = line?.color || DEFAULT_LINE_COLOR; + const lineId = String(line?.id || `line-${lineIndex + 1}`); + const name = String(line?.name || line?.cn_name || line?.线路名称 || lineId); + const enName = String(line?.en_name || line?.英文名称 || line?.英文名 || ''); + const stopsRaw = Array.isArray(line?.stations) + ? line.stations + : (Array.isArray(line?.stops) ? line.stops : (Array.isArray(line?.站点列表) ? line.站点列表 : [])); + let current = []; + + const commitSegment = () => { + const canonicalStops = []; + for (const stop of current) { + const code = toCode(stop); + if (!code || code === '-') continue; + const canonical = transferGroups.canonicalByCode[code] || code; + if (canonicalStops[canonicalStops.length - 1] !== canonical) canonicalStops.push(canonical); + const node = nodeMap.get(canonical); + if (node) { + node.lines.add(lineId); + node.explicitLineIndices.push(lineIndex); + } + } + if (canonicalStops.length < 2) { + current = []; + return; + } + segments.push({ id: lineId, name, enName, color, lineIndex, stops: canonicalStops }); + for (let index = 0; index < canonicalStops.length - 1; index += 1) { + const a = canonicalStops[index]; + const b = canonicalStops[index + 1]; + uniqueEdgeSet.add([a, b].sort().join('|')); + } + current = []; + }; + + for (const stop of stopsRaw) { + if (toCode(stop) === '-') { + commitSegment(); + } else { + current.push(stop); + } + } + commitSegment(); + }); + + return { + segments, + edges: Array.from(uniqueEdgeSet).map((key) => { + const [from, to] = key.split('|'); + return { from, to }; + }) + }; +} + +function buildSeedLayout(nodeMap, lineSegments, referenceMeta) { + const nodes = Array.from(nodeMap.values()); + const baseX = 140; + const baseY = 120; + const gapX = 96; + const gapY = 92; + const seed = {}; + + const hasExplicitPositions = nodes.some((node) => node.explicitPositions.length > 0); + if (hasExplicitPositions) { + for (const node of nodes) { + if (node.explicitPositions.length > 0) { + const x = node.explicitPositions.reduce((sum, pos) => sum + pos.x, 0) / node.explicitPositions.length; + const y = node.explicitPositions.reduce((sum, pos) => sum + pos.y, 0) / node.explicitPositions.length; + seed[node.canonical] = { x, y, locked: true }; + } + } + } + + const occurrenceMap = {}; + for (const segment of lineSegments.segments) { + segment.stops.forEach((canonical, stopIndex) => { + if (!occurrenceMap[canonical]) occurrenceMap[canonical] = []; + occurrenceMap[canonical].push({ + x: baseX + (stopIndex * gapX), + y: baseY + (segment.lineIndex * gapY) + }); + }); + } + + let fallbackIndex = 0; + const fallbackCols = Math.max(4, Math.ceil(Math.sqrt(Math.max(nodes.length, 1)))); + for (const node of nodes) { + if (!seed[node.canonical] && occurrenceMap[node.canonical]?.length) { + const samples = occurrenceMap[node.canonical]; + seed[node.canonical] = { + x: samples.reduce((sum, item) => sum + item.x, 0) / samples.length, + y: samples.reduce((sum, item) => sum + item.y, 0) / samples.length, + locked: false + }; + } + if (!seed[node.canonical]) { + seed[node.canonical] = { + x: baseX + ((fallbackIndex % fallbackCols) * gapX), + y: baseY + (Math.floor(fallbackIndex / fallbackCols) * gapY), + locked: false + }; + fallbackIndex += 1; + } + } + + const anchorPoints = Array.isArray(referenceMeta?.anchorPoints) ? referenceMeta.anchorPoints : []; + if (!hasExplicitPositions && anchorPoints.length >= nodes.length * 0.6) { + const nodeOrder = nodes + .map((node) => ({ node, seed: seed[node.canonical] })) + .sort((a, b) => (a.seed.y - b.seed.y) || (a.seed.x - b.seed.x)); + const anchors = anchorPoints + .slice() + .sort((a, b) => (a.y - b.y) || (a.x - b.x)); + const limit = Math.min(nodeOrder.length, anchors.length); + for (let index = 0; index < limit; index += 1) { + const { node } = nodeOrder[index]; + const anchor = anchors[index]; + seed[node.canonical] = { + x: (seed[node.canonical].x * 0.45) + (anchor.x * 0.55), + y: (seed[node.canonical].y * 0.45) + (anchor.y * 0.55), + locked: false + }; + } + } + + return seed; +} + +function optimizeLayout(nodeMap, lineSegments, referenceMeta) { + const nodes = Array.from(nodeMap.values()); + const seed = buildSeedLayout(nodeMap, lineSegments, referenceMeta); + const positions = {}; + const velocities = {}; + const neighbors = new Map(); + + for (const node of nodes) { + positions[node.canonical] = { + x: seed[node.canonical]?.x || 0, + y: seed[node.canonical]?.y || 0 + }; + velocities[node.canonical] = { x: 0, y: 0 }; + neighbors.set(node.canonical, new Set()); + } + + for (const edge of lineSegments.edges) { + neighbors.get(edge.from)?.add(edge.to); + neighbors.get(edge.to)?.add(edge.from); + } + + const iterations = 220; + const repulsionBase = 18000; + const springLength = 92; + const springStrength = 0.012; + const bendStrength = 0.018; + const anchorStrength = 0.018; + const damping = 0.82; + + for (let iteration = 0; iteration < iterations; iteration += 1) { + const alpha = 1 - (iteration / iterations); + const forces = {}; + for (const node of nodes) forces[node.canonical] = { x: 0, y: 0 }; + + for (let i = 0; i < nodes.length; i += 1) { + const a = nodes[i]; + const pa = positions[a.canonical]; + for (let j = i + 1; j < nodes.length; j += 1) { + const b = nodes[j]; + const pb = positions[b.canonical]; + let dx = pa.x - pb.x; + let dy = pa.y - pb.y; + let distanceSq = (dx * dx) + (dy * dy); + if (distanceSq < 1) { + dx = 0.5 - Math.random(); + dy = 0.5 - Math.random(); + distanceSq = (dx * dx) + (dy * dy); + } + const distance = Math.sqrt(distanceSq); + const scale = (repulsionBase * alpha) / distanceSq; + const fx = (dx / distance) * scale; + const fy = (dy / distance) * scale; + forces[a.canonical].x += fx; + forces[a.canonical].y += fy; + forces[b.canonical].x -= fx; + forces[b.canonical].y -= fy; + } + } + + for (const segment of lineSegments.segments) { + for (let index = 0; index < segment.stops.length - 1; index += 1) { + const from = segment.stops[index]; + const to = segment.stops[index + 1]; + const pa = positions[from]; + const pb = positions[to]; + let dx = pb.x - pa.x; + let dy = pb.y - pa.y; + let distance = Math.sqrt((dx * dx) + (dy * dy)); + if (distance < 1) distance = 1; + const delta = distance - springLength; + const force = delta * springStrength; + const fx = (dx / distance) * force; + const fy = (dy / distance) * force; + forces[from].x += fx; + forces[from].y += fy; + forces[to].x -= fx; + forces[to].y -= fy; + } + for (let index = 1; index < segment.stops.length - 1; index += 1) { + const prev = positions[segment.stops[index - 1]]; + const current = positions[segment.stops[index]]; + const next = positions[segment.stops[index + 1]]; + const targetX = (prev.x + next.x) / 2; + const targetY = (prev.y + next.y) / 2; + forces[segment.stops[index]].x += (targetX - current.x) * bendStrength; + forces[segment.stops[index]].y += (targetY - current.y) * bendStrength; + } + } + + for (const node of nodes) { + const anchor = seed[node.canonical]; + if (!anchor) continue; + const strength = anchor.locked ? 0.14 : anchorStrength; + forces[node.canonical].x += (anchor.x - positions[node.canonical].x) * strength; + forces[node.canonical].y += (anchor.y - positions[node.canonical].y) * strength; + } + + for (const node of nodes) { + const velocity = velocities[node.canonical]; + const force = forces[node.canonical]; + velocity.x = (velocity.x + (force.x * 0.6)) * damping; + velocity.y = (velocity.y + (force.y * 0.6)) * damping; + positions[node.canonical].x += velocity.x; + positions[node.canonical].y += velocity.y; + } + } + + const xs = nodes.map((node) => positions[node.canonical].x); + const ys = nodes.map((node) => positions[node.canonical].y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const sourceWidth = Math.max(maxX - minX, 1); + const sourceHeight = Math.max(maxY - minY, 1); + const targetWidth = Math.max((referenceMeta?.width || DEFAULT_VIEWPORT.width) - 260, 640); + const targetHeight = Math.max((referenceMeta?.height || DEFAULT_VIEWPORT.height) - 220, 420); + const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight); + const offsetX = 130; + const offsetY = 110; + + for (const node of nodes) { + const point = positions[node.canonical]; + point.x = round(((point.x - minX) * scale) + offsetX, 2); + point.y = round(((point.y - minY) * scale) + offsetY, 2); + } + + return positions; +} + +function buildLinePath(points) { + if (!Array.isArray(points) || points.length === 0) return ''; + let d = `M ${points[0].x} ${points[0].y}`; + for (let index = 1; index < points.length; index += 1) { + d += ` L ${points[index].x} ${points[index].y}`; + } + return d; +} + +function getContrastColor(hex) { + if (!hex) return '#ffffff'; + let value = String(hex).replace('#', ''); + if (value.length === 3) value = value.split('').map((item) => item + item).join(''); + const r = parseInt(value.slice(0, 2), 16) || 0; + const g = parseInt(value.slice(2, 4), 16) || 0; + const b = parseInt(value.slice(4, 6), 16) || 0; + const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; + return yiq >= 140 ? '#0f172a' : '#ffffff'; +} + +function 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 largeArc = (a1 - a0) > Math.PI ? 1 : 0; + return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1} Z`; +} + +function pieSvg(cx, cy, r, colors) { + const palette = Array.from(new Set((Array.isArray(colors) ? colors : []).filter(Boolean))).slice(0, 4); + if (palette.length === 0) return ''; + if (palette.length === 1) return ``; + const step = (Math.PI * 2) / palette.length; + let output = ''; + for (let index = 0; index < palette.length; index += 1) { + const a0 = (-Math.PI / 2) + (index * step); + const a1 = a0 + step; + output += ``; + } + return output; +} + +function renderStation(node, point, lineColorMap) { + const colors = Array.from(node.lines) + .map((lineId) => lineColorMap.get(lineId) || DEFAULT_LINE_COLOR) + .filter(Boolean); + const uniqueColors = Array.from(new Set(colors)); + const isTransfer = node.isTransfer || uniqueColors.length >= 2 || node.codes.length >= 2; + const code = escapeHTML(node.primaryCode); + const name = escapeHTML(node.name || node.primaryCode); + const enName = escapeHTML(node.enName || ''); + const x = point.x; + const y = point.y; + const codeFontSize = 9; + const nameFontSize = 13; + const enFontSize = 8.5; + const codeWidth = Math.max((node.primaryCode || '').length * 6 + 10, 28); + const preferTop = isTransfer; + + let markerSvg = ''; + if (isTransfer) { + markerSvg += ``; + markerSvg += pieSvg(x, y, 11.5, uniqueColors); + markerSvg += ``; + } else { + const color = uniqueColors[0] || DEFAULT_LINE_COLOR; + markerSvg += ``; + markerSvg += ``; + } + + let textSvg = ''; + if (preferTop) { + const baseY = y - 14; + const enY = baseY; + const nameY = enName ? (baseY - 12) : baseY; + const codeY = nameY - 16; + textSvg += ``; + textSvg += `${code}`; + textSvg += `${name}`; + if (enName) { + textSvg += `${enName}`; + } + } else { + const topY = y + 24; + const codeY = topY; + const nameY = topY + 16; + const enY = nameY + 12; + textSvg += ``; + textSvg += `${code}`; + textSvg += `${name}`; + if (enName) { + textSvg += `${enName}`; + } + } + + return ` + + + ${markerSvg} + ${textSvg} + + `; +} + +function renderLineLabels(segment, points) { + if (!Array.isArray(points) || points.length < 2) return ''; + const color = segment.color || DEFAULT_LINE_COLOR; + const textColor = getContrastColor(color); + const subTextColor = (textColor === '#ffffff') ? 'rgba(255,255,255,0.9)' : 'rgba(15,23,42,0.8)'; + const labelWidth = Math.max(String(segment.name || '').length * 14 + 20, String(segment.enName || '').length * 7 + 20, 80); + const height = segment.enName ? 36 : 24; + const build = (point, alignLeft) => { + const x = alignLeft ? point.x - 54 - labelWidth : point.x + 54; + const y = point.y - (height / 2); + const textX = x + (labelWidth / 2); + let output = ``; + if (segment.enName) { + output += `${escapeHTML(segment.name)}`; + output += `${escapeHTML(segment.enName)}`; + } else { + output += `${escapeHTML(segment.name)}`; + } + return output; + }; + + return build(points[0], true) + build(points[points.length - 1], false); +} + +function calculateBounds(positions) { + const items = Object.values(positions || {}); + if (!items.length) return { minX: 0, minY: 0, maxX: DEFAULT_VIEWPORT.width, maxY: DEFAULT_VIEWPORT.height }; + return { + minX: Math.min(...items.map((item) => item.x)), + minY: Math.min(...items.map((item) => item.y)), + maxX: Math.max(...items.map((item) => item.x)), + maxY: Math.max(...items.map((item) => item.y)) + }; +} + +function generate(stations, lines, fares, nameByCode, transfers) { + const referenceMeta = parseReferenceSvgMeta(); + const transferGroups = buildTransferGroups(stations, transfers); + const nodeMap = buildNodes(stations, nameByCode || {}, transferGroups); + const lineSegments = buildLineSegments(lines, transferGroups, nodeMap); + + for (const node of nodeMap.values()) { + node.codes = Array.from(new Set(node.codes)).sort((a, b) => a.localeCompare(b)); + node.primaryCode = node.codes[0] || node.primaryCode; + node.isTransfer = node.codes.length >= 2 || node.lines.size >= 2; + } + + if (!nodeMap.size) { + return `暂无可显示的站点数据`; + } + + const positions = optimizeLayout(nodeMap, lineSegments, referenceMeta); + const bounds = calculateBounds(positions); + const width = Math.max(referenceMeta.width || DEFAULT_VIEWPORT.width, Math.ceil(bounds.maxX + 180)); + const height = Math.max(referenceMeta.height || DEFAULT_VIEWPORT.height, Math.ceil(bounds.maxY + 160)); + const lineColorMap = new Map(); + (lineSegments.segments || []).forEach((segment) => lineColorMap.set(segment.id, segment.color || DEFAULT_LINE_COLOR)); + + let svg = ``; + svg += `generated-by=svg-generator; mode=network-map; reference-svg=${escapeHTML(path.basename(REFERENCE_SVG_PATH))}; stations=${nodeMap.size}; lineSegments=${lineSegments.segments.length}; fares=${Array.isArray(fares) ? fares.length : 0}`; + svg += ``; + + for (const segment of lineSegments.segments) { + const points = segment.stops.map((canonical) => positions[canonical]).filter(Boolean); + if (points.length < 2) continue; + const d = buildLinePath(points); + svg += ``; + svg += renderLineLabels(segment, points); + } + + const orderedNodes = Array.from(nodeMap.values()).sort((a, b) => { + const pa = positions[a.canonical]; + const pb = positions[b.canonical]; + return (pa.x - pb.x) || (pa.y - pb.y); + }); + + for (const node of orderedNodes) { + const point = positions[node.canonical]; + if (!point) continue; + svg += renderStation(node, point, lineColorMap); + } + svg += ``; return svg; } diff --git a/web/style.css b/web/style.css index 357de10..a4ecf38 100644 --- a/web/style.css +++ b/web/style.css @@ -3699,14 +3699,7 @@ body.jr-ticket-board-page .jr-board-card:last-child { .jr-panel-headline { flex-direction: column; align-items: flex-start; - gap: 12px; } -} - -@keyframes tvmArrowMove { - 0% { transform: translateX(-10px); } - 100% { transform: translateX(10px); } -} .jr-query-stat, .jr-ticket-preview, diff --git a/web/ticket-order.html b/web/ticket-order.html index bb21941..318de7c 100644 --- a/web/ticket-order.html +++ b/web/ticket-order.html @@ -158,7 +158,7 @@ - +