diff --git a/server/services/svg-generator.js b/server/services/svg-generator.js index 36be81c..fa14bd3 100644 --- a/server/services/svg-generator.js +++ b/server/services/svg-generator.js @@ -1,10 +1,3 @@ -const fs = require('fs'); -const path = require('path'); - -const DEFAULT_VIEWPORT = { width: 1440, height: 920 }; -const DEFAULT_LINE_COLOR = '#93a2b7'; -const REFERENCE_SVG_PATH = path.resolve(__dirname, '..', '..', 'FSE铁路线路图.svg'); - const escapeHTML = (s) => String(s || '') .replace(/&/g, '&') .replace(/ String(s || '') .replace(/"/g, '"') .replace(/'/g, '''); -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]; +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 || ''; } - } else { - width = parseNumber(widthMatch?.[1]) || width; - height = parseNumber(heightMatch?.[1]) || height; + return map; + } catch (_) { + return {}; } + })(); - 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 - }); + 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 anchorCandidates = circles.filter((circle) => { - if (circle.r < 4) return false; - if (!circle.stroke || circle.stroke.toLowerCase() === 'none') return false; - return true; - }); - - 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 }; - }) - }; -} + const groupsMap = {}; + (stations || []).forEach((s) => { + const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim(); + if (!k) return; + if (!groupsMap[k]) groupsMap[k] = []; + groupsMap[k].push(String(s.code || '')); + }); -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 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']; - 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 }; + 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]; } } - } + return []; + }; - 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 + 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); }; - 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 validStationCodes = new Set((stations || []).map((s) => String(s.code || ''))); - const iterations = 450; - const repulsionBase = 45000; - const springLength = 120; - const springStrength = 0.015; - const bendStrength = 0.025; - const anchorStrength = 0.02; - const damping = 0.82; + (fares || []).forEach((f) => { + const a = String(f.from || ''); + const b = String(f.to || ''); + if (!a || !b) return; + if (!validStationCodes.has(a) || !validStationCodes.has(b)) return; - 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 }; + const w = Number(f.cost_regular ?? f.cost) || 0; + addEdge(a, b, w); + }); - // 1. Repulsion to untangle graph - 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); + if (Array.isArray(transfers)) { + transfers.forEach((p) => { + const u = String(p[0] || ''); + const v = String(p[1] || ''); + if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) { + addEdge(u, v, 0); } - 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; - } + }); } - // 2. Spring forces for edges & bend smoothing - 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; - } - } - - // 3. Orthogonal snapping force (Octilinear bias) - const orthoStrength = 0.03 * alpha; - for (const edge of lineSegments.edges) { - const pa = positions[edge.from]; - const pb = positions[edge.to]; - const dx = pb.x - pa.x; - const dy = pb.y - pa.y; - const adx = Math.abs(dx); - const ady = Math.abs(dy); - - if (adx < ady && adx < ady * 0.4) { - // pull towards vertical - forces[edge.from].x += dx * orthoStrength; - forces[edge.to].x -= dx * orthoStrength; - } else if (ady < adx && ady < adx * 0.4) { - // pull towards horizontal - forces[edge.from].y += dy * orthoStrength; - forces[edge.to].y -= dy * orthoStrength; - } else { - // pull towards diagonal (45 degrees) - const avg = (adx + ady) / 2; - const targetDx = avg * Math.sign(dx || 1); - const targetDy = avg * Math.sign(dy || 1); - forces[edge.from].x += (dx - targetDx) * orthoStrength; - forces[edge.to].x -= (dx - targetDx) * orthoStrength; - forces[edge.from].y += (dy - targetDy) * orthoStrength; - forces[edge.to].y -= (dy - targetDy) * orthoStrength; - } - } - - // 4. Anchor forces - for (const node of nodes) { - const anchor = seed[node.canonical]; - if (!anchor) continue; - const strength = anchor.locked ? 0.2 : anchorStrength; - forces[node.canonical].x += (anchor.x - positions[node.canonical].x) * strength; - forces[node.canonical].y += (anchor.y - positions[node.canonical].y) * strength; - } - - // 5. Update velocities and positions - 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; - } - } - - // Final alignment to Grid to ensure perfect octilinear rendering - 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; - - const GRID_SIZE = 24; - - for (const node of nodes) { - const point = positions[node.canonical]; - let rawX = ((point.x - minX) * scale) + offsetX; - let rawY = ((point.y - minY) * scale) + offsetY; - - // Snap to grid - point.x = Math.round(rawX / GRID_SIZE) * GRID_SIZE; - point.y = Math.round(rawY / GRID_SIZE) * GRID_SIZE; - } - - 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) { - const p1 = points[index - 1]; - const p2 = points[index]; - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const adx = Math.abs(dx); - const ady = Math.abs(dy); - - if (adx === 0 || ady === 0 || Math.abs(adx - ady) < 2) { - // Already orthogonal or 45-degree diagonal - d += ` L ${p2.x} ${p2.y}`; - } else { - // Need an octilinear chamfer (a single bend) - const signX = Math.sign(dx); - const signY = Math.sign(dy); - - if (adx > ady) { - // Horizontal is longer: straight horizontal, then 45-degree - const hDist = adx - ady; - const midX = p1.x + hDist * signX; - const midY = p1.y; - d += ` L ${midX} ${midY} L ${p2.x} ${p2.y}`; - } else { - // Vertical is longer: straight vertical, then 45-degree - const vDist = ady - adx; - const midX = p1.x; - const midY = p1.y + vDist * signY; - d += ` L ${midX} ${midY} L ${p2.x} ${p2.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; + (stations || []).forEach((s) => { + const c = String(s.code || ''); + if (c) addNode(c); + }); + return adj; }; - return build(points[0], true) + build(points[points.length - 1], false); -} + const adj = buildGraph(); + const codes = Array.from(adj.keys()).sort((a, b) => a.localeCompare(b)); + const neighborsOf = (u) => adj.get(u) || new Map(); -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)) + 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; }; -} -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); + const distAll = {}; + codes.forEach((c) => { + distAll[c] = dijkstra(c); + }); - 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; + 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; + } } - 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)); + 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 = ``; - 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); + 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)}`; } - 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 (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 (const node of orderedNodes) { - const point = positions[node.canonical]; - if (!point) continue; - svg += renderStation(node, point, lineColorMap); + 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)) { + svg += `×`; + } else { + const val = Math.round(Number(distVal || 0)); + const routeColors = colorsForRoute(code, codes[j]); + + 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; + svg += ``; + } + } else { + svg += ``; + } + + svg += ``; + svg += `${escapeHTML(String(val))}`; + + 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 += ``; + } + } + } + } } svg += ``; diff --git a/web/ticket-order.js b/web/ticket-order.js index 7ce8333..4d68825 100644 --- a/web/ticket-order.js +++ b/web/ticket-order.js @@ -63,10 +63,11 @@ return out; }; - const buildTransitPath = (points, radius = 12) => { + 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); @@ -86,6 +87,23 @@ 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); @@ -121,7 +139,11 @@ const aT = isTransfer(prev); const bT = isTransfer(next); - if (bT && !aT) { + 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 }); @@ -366,6 +388,10 @@ }; 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; @@ -411,7 +437,7 @@ }; }); - const d = buildTransitPath(pts, 16); + const d = buildTransitPath(pts, 16, { laneOffset: getLaneOffset(li.idx) }); svgContent += ``; // Add line name labels at ends @@ -446,8 +472,8 @@ markerSvg += ``; } else { const color = uniqueColors[0] || '#93a2b7'; - markerSvg += ``; - markerSvg += ``; + markerSvg += ``; + markerSvg += ``; } const preferTop = isTransfer || (i % 2 === 0 && y >= baseY + 20);