diff --git a/image.png b/image.png new file mode 100644 index 0000000..c4b09d4 Binary files /dev/null and b/image.png differ diff --git a/public/edge-functions/api/auth/complete_github_register.js b/public/edge-functions/api/auth/complete_github_register.js deleted file mode 100644 index ab59013..0000000 --- a/public/edge-functions/api/auth/complete_github_register.js +++ /dev/null @@ -1,92 +0,0 @@ -// SHA-256 Hashing helper -async function sha256(message) { - const encoder = new TextEncoder(); - const data = encoder.encode(message); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); -} - -export async function onRequest(event) { - const headers = { - "Content-Type": "application/json; charset=utf-8", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type" - }; - - if (event.request.method === "OPTIONS") { - return new Response(null, { headers }); - } - - if (event.request.method !== "POST") { - return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers }); - } - - const DB = globalThis.RAILROUND_KV; - if (!DB) return new Response(JSON.stringify({ error: "KV Missing" }), { status: 500, headers }); - - try { - const body = await event.request.json(); - const { username, password, reg_token } = body; - - if (!username || !password || !reg_token) { - return new Response(JSON.stringify({ error: "Missing required fields" }), { status: 400, headers }); - } - - // 1. Verify Registration Token - const tempKey = `temp_reg:${reg_token}`; - const tempRaw = await DB.get(tempKey); - - if (!tempRaw) { - return new Response(JSON.stringify({ error: "Registration session expired or invalid" }), { status: 400, headers }); - } - - const githubUser = JSON.parse(tempRaw); - - // 2. Check Username Availability - const userKey = `user:${username}`; - const existing = await DB.get(userKey); - if (existing) { - return new Response(JSON.stringify({ error: "Username already exists" }), { status: 409, headers }); - } - - // 3. Create Account - const hashedPassword = await sha256(password); - - const userData = { - username, - password: hashedPassword, - created_at: new Date().toISOString(), - bindings: { - github: githubUser // Bind the GitHub info - }, - trips: [], - pins: [] - }; - - // Store User - await DB.put(userKey, JSON.stringify(userData)); - - // Store Binding - const bindingKey = `binding:github:${githubUser.id}`; - await DB.put(bindingKey, username); - - // 4. Cleanup Temp Token - await DB.delete(tempKey); - - // 5. Create Session (Auto Login) - const token = crypto.randomUUID(); - const sessionKey = `session:${token}`; - await DB.put(sessionKey, username, { expirationTtl: 86400 * 30 }); - - return new Response(JSON.stringify({ - success: true, - token, - username - }), { headers }); - - } catch (e) { - return new Response(JSON.stringify({ error: e.message }), { status: 500, headers }); - } -} diff --git a/public/edge-functions/api/auth/oauth.js b/public/edge-functions/api/auth/oauth.js deleted file mode 100644 index 525173c..0000000 --- a/public/edge-functions/api/auth/oauth.js +++ /dev/null @@ -1,174 +0,0 @@ -export async function onRequest(event) { - const headers = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - }; - - if (event.request.method === "OPTIONS") { - return new Response(null, { headers }); - } - - try { - const url = new URL(event.request.url); - const code = url.searchParams.get("code"); - const error = url.searchParams.get("error"); - const provider = url.searchParams.get("provider"); - const DB = globalThis.RAILROUND_KV; - - // 1. Handle Callback (if 'code' is present) - if (code) { - if (error) { - return new Response(JSON.stringify({ error }), { status: 400, headers: { "Content-Type": "application/json" } }); - } - - if (!DB) throw new Error("KV Missing"); - - const CLIENT_ID = env.CLIENT_ID; - const CLIENT_SECRET = env.CLIENT_SECRET; - - if (!CLIENT_ID || !CLIENT_SECRET) { - return new Response(JSON.stringify({ error: "Server Configuration Error" }), { status: 500, headers: { "Content-Type": "application/json" } }); - } - - // Parse State to check for Session Token (Binding Mode) - const stateParam = url.searchParams.get("state"); - let sessionToken = null; - if (stateParam) { - try { - const parsed = JSON.parse(decodeURIComponent(stateParam)); - if (parsed && parsed.t) sessionToken = parsed.t; - } catch (e) { - console.warn("Failed to parse state", e); - } - } - - // Exchange code for access token - const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code: code - }) - }); - - const tokenData = await tokenResponse.json(); - if (tokenData.error) throw new Error(tokenData.error_description || "Failed to get token"); - const accessToken = tokenData.access_token; - - // Fetch User Info - const userResponse = await fetch('https://api.github.com/user', { - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'User-Agent': 'RailRound-EdgeFunction' - } - }); - - if (!userResponse.ok) throw new Error("Failed to fetch GitHub user"); - const githubUser = await userResponse.json(); - - const bindingKey = `binding:github:${githubUser.id}`; - - // --- Scenario A: Binding to Existing Account --- - if (sessionToken) { - // Verify Session - const sessionKey = `session:${sessionToken}`; - const currentUsername = await DB.get(sessionKey); - - if (!currentUsername) { - return Response.redirect(`${url.origin}/?error=session_expired`, 302); - } - - // Check if this GitHub ID is already bound - const existingBoundUser = await DB.get(bindingKey); - if (existingBoundUser && existingBoundUser !== currentUsername) { - return Response.redirect(`${url.origin}/?error=github_bound_to_other`, 302); - } - - // Perform Binding - const userKey = `user:${currentUsername}`; - const userDataRaw = await DB.get(userKey); - if (!userDataRaw) throw new Error("User record missing"); - - const userData = JSON.parse(userDataRaw); - userData.bindings = userData.bindings || {}; - userData.bindings.github = { - id: githubUser.id, - login: githubUser.login, - avatar_url: githubUser.avatar_url, - name: githubUser.name - }; - - await DB.put(userKey, JSON.stringify(userData)); - await DB.put(bindingKey, currentUsername); - - // Redirect home with status - return Response.redirect(`${url.origin}/?status=bound_success`, 302); - } - - // --- Scenario B: Login or Register --- - let username = await DB.get(bindingKey); - - if (username) { - // Login Flow - const token = crypto.randomUUID(); - const sessionKey = `session:${token}`; - await DB.put(sessionKey, username, { expirationTtl: 86400 * 30 }); - return Response.redirect(`${url.origin}/?token=${token}&username=${username}`, 302); - } else { - // Register Flow (Pending) - // Store GitHub info temporarily - const regToken = crypto.randomUUID(); - const tempKey = `temp_reg:${regToken}`; - const tempPayload = { - id: githubUser.id, - login: githubUser.login, - avatar_url: githubUser.avatar_url, - name: githubUser.name - }; - // Expires in 1 hour - await DB.put(tempKey, JSON.stringify(tempPayload), { expirationTtl: 3600 }); - - // Redirect to frontend completion flow - return Response.redirect(`${url.origin}/?reg_token=${regToken}`, 302); - } - } - - // 2. Handle Initiation (if 'provider' is present) - if (provider === 'github') { - const clientId = env.CLIENT_ID; - if (!clientId) { - return new Response(JSON.stringify({ error: "Server Configuration Error: Missing CLIENT_ID" }), { status: 500, headers: { "Content-Type": "application/json" } }); - } - - // Parse session_token if provided (for binding) - const sessionToken = url.searchParams.get("session_token"); - const stateObj = { - nonce: crypto.randomUUID(), - t: sessionToken || undefined - }; - // Simple JSON encoding for state - const state = encodeURIComponent(JSON.stringify(stateObj)); - - // Point back to THIS file for the callback - const redirectUri = `${url.origin}/api/auth/oauth`; - const targetUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=read:user&state=${state}`; - - return Response.redirect(targetUrl, 302); - } - - // Google Placeholder - if (provider === 'google') { - return new Response(JSON.stringify({ error: "Google Auth not implemented yet" }), { status: 501, headers: { "Content-Type": "application/json" } }); - } - - return new Response(JSON.stringify({ error: "Invalid Request" }), { status: 400, headers: { "Content-Type": "application/json" } }); - - } catch (e) { - return new Response(JSON.stringify({ error: "Auth Failed", details: e.message }), { status: 500, headers: { "Content-Type": "application/json" } }); - } -} diff --git a/public/functions/api/card.js b/public/functions/api/card.js new file mode 100644 index 0000000..96d9922 --- /dev/null +++ b/public/functions/api/card.js @@ -0,0 +1,178 @@ +export async function onRequest(event) { + const url = new URL(event.request.url); + const key = url.searchParams.get("key"); + + // SVG Headers + const headers = { + "Content-Type": "image/svg+xml", + "Cache-Control": "max-age=300, s-maxage=300", // Cache for 5 mins + "Access-Control-Allow-Origin": "*" + }; + + const errorSvg = (msg) => new Response(`${msg}`, { headers }); + + if (!key) { + return errorSvg("Key missing"); + } + + try { + const DB = globalThis.RAILROUND_KV; + if (!DB) return errorSvg("KV Error"); + + const username = await DB.get(`card_key:${key}`); + if (!username) return errorSvg("Invalid Key"); + + const userKey = `user:${username}`; + const dataRaw = await DB.get(userKey); + + if (!dataRaw) { + return errorSvg("User data not found"); + } + + const data = JSON.parse(dataRaw); + const stats = data.latest_5 || { count: 0, dist: 0, lines: 0, latest: [] }; + + const esc = (str) => { + if (!str) return ""; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }; + + // --- SVG Generation (New Split Layout) --- + const cardWidth = 600; + const cardHeight = 340; + + // Colors + const glassBg = "rgba(15, 23, 42, 0.95)"; // Darker background + const glassBorder = "rgba(255, 255, 255, 0.1)"; + const textColor = "#ffffff"; + const labelColor = "#94a3b8"; + const accentColor = "#2dd4bf"; // Teal-400 + const secondaryColor = "#818cf8"; // Indigo-400 + + const styles = ` + .bg { fill: ${glassBg}; stroke: ${glassBorder}; stroke-width: 1px; } + .text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; fill: ${textColor}; } + .label { font-size: 10px; fill: ${labelColor}; font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; } + .value { font-size: 18px; font-weight: 800; fill: ${textColor}; } + .trip-title { font-size: 11px; font-weight: 600; fill: ${textColor}; } + .trip-date { font-size: 9px; fill: ${labelColor}; } + .trip-dist { font-size: 9px; fill: ${labelColor}; font-family: monospace; } + .line-path { fill: none; stroke: ${accentColor}; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } + .separator { stroke: ${glassBorder}; stroke-width: 1; } + .icon-lg { stroke: ${accentColor}; stroke-width: 1.5; fill: none; opacity: 0.8; } + `; + + // Left Panel (Visual) - Large Train Icon centered + // Panel Width: 200px + // Icon center: x=100, y=170 + // Scale: 4x (approx 96px size) + const leftPanelVisual = ` + + + + + + + + + + + `; + + let svgContent = ` + + + + + + + + + + + ${leftPanelVisual} + + + + + + + + + Trips + ${stats.count} + + + + Lines + ${stats.lines} + + + + Total Distance + ${Math.round(stats.dist)}km + + + + + + + + Latest Activity + + + `; + + // Render List + const displayLimit = 5; + const rowH = 45; // Height per row + + stats.latest.slice(0, displayLimit).forEach((trip, idx) => { + const y = idx * rowH; + let points = trip.svg_points || ""; + points = points.replace(/[^0-9, .\-ML]/g, ''); + + svgContent += ` + + + + + + ${esc(trip.title)} + ${esc(trip.date)} + + + + + + + + + + ${Math.round(trip.dist)} km + + `; + }); + + if (stats.latest.length === 0) { + svgContent += `No trips recorded yet.`; + } + + svgContent += ` + + + + + `; + + return new Response(svgContent, { headers }); + + } catch (e) { + return errorSvg(e.message); + } +} diff --git a/public/functions/api/user/data.js b/public/functions/api/user/data.js index d3921e9..5f64deb 100644 --- a/public/functions/api/user/data.js +++ b/public/functions/api/user/data.js @@ -1,66 +1,63 @@ export async function onRequest(event) { const headers = { - "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization" + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Content-Type": "application/json" }; if (event.request.method === "OPTIONS") { return new Response(null, { headers }); } - const DB = globalThis.RAILROUND_KV; - if (!DB) return new Response(JSON.stringify({ error: "KV Missing" }), { status: 500, headers }); - - // Auth check - const authHeader = event.request.headers.get("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers }); - } + try { + const DB = globalThis.RAILROUND_KV; + if (!DB) throw new Error("KV Missing"); - const token = authHeader.split(" ")[1]; - const sessionKey = `session:${token}`; + const authHeader = event.request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers }); + } + const token = authHeader.split(" ")[1]; - try { - const username = await DB.get(sessionKey); + // Verify Session + const username = await DB.get(`session:${token}`); if (!username) { - return new Response(JSON.stringify({ error: "Invalid or expired token" }), { status: 401, headers }); + return new Response(JSON.stringify({ error: "Invalid Token" }), { status: 401, headers }); } const userKey = `user:${username}`; - let rawUserData = await DB.get(userKey); - if (!rawUserData) { - return new Response(JSON.stringify({ error: "User data corrupted" }), { status: 404, headers }); - } + if (event.request.method === "GET") { + const dataRaw = await DB.get(userKey); + const data = dataRaw ? JSON.parse(dataRaw) : { trips: [], pins: [] }; - let userData = typeof rawUserData === 'string' ? JSON.parse(rawUserData) : rawUserData; + // Security: Remove sensitive fields + const { password, ...safeData } = data; - if (event.request.method === "GET") { - // Return only data, remove sensitive info like password - const { password, ...safeData } = userData; - return new Response(JSON.stringify(safeData), { headers }); + return new Response(JSON.stringify(safeData), { status: 200, headers }); } - else if (event.request.method === "POST") { - const body = await event.request.json(); - // Update trips and pins - // We do a merge strategy or overwrite? - // Plan said "Merge/Overwrite". Let's assume the client sends the full new state for simplicity and consistency with the current frontend app logic which holds full state. - - if (body.trips) userData.trips = body.trips; - if (body.pins) userData.pins = body.pins; + if (event.request.method === "POST") { + const body = await event.request.json(); + const { trips, pins, latest_5 } = body; - // Update timestamp - userData.updated_at = new Date().toISOString(); + // Fetch existing to preserve other fields (like password, bindings) + const existingRaw = await DB.get(userKey); + const existing = existingRaw ? JSON.parse(existingRaw) : {}; - await DB.put(userKey, JSON.stringify(userData)); + const newData = { + ...existing, + trips: trips || existing.trips || [], + pins: pins || existing.pins || [], + latest_5: latest_5 || existing.latest_5 || null // Store the pre-calculated card data + }; - return new Response(JSON.stringify({ success: true }), { headers }); + await DB.put(userKey, JSON.stringify(newData)); + return new Response(JSON.stringify({ success: true }), { status: 200, headers }); } - return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers }); + return new Response(JSON.stringify({ error: "Method Not Allowed" }), { status: 405, headers }); } catch (e) { return new Response(JSON.stringify({ error: e.message }), { status: 500, headers }); diff --git a/public/functions/api/user/key.js b/public/functions/api/user/key.js new file mode 100644 index 0000000..6685464 --- /dev/null +++ b/public/functions/api/user/key.js @@ -0,0 +1,57 @@ +export async function onRequest(event) { + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Content-Type": "application/json" + }; + + if (event.request.method === "OPTIONS") { + return new Response(null, { headers }); + } + + if (event.request.method !== "POST") { + return new Response(JSON.stringify({ error: "Method Not Allowed" }), { status: 405, headers }); + } + + try { + const DB = globalThis.RAILROUND_KV; + if (!DB) throw new Error("KV Missing"); + + const authHeader = event.request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers }); + } + const token = authHeader.split(" ")[1]; + + // Verify Session + const username = await DB.get(`session:${token}`); + if (!username) { + return new Response(JSON.stringify({ error: "Invalid Token" }), { status: 401, headers }); + } + + const userKey = `user:${username}`; + const dataRaw = await DB.get(userKey); + const data = dataRaw ? JSON.parse(dataRaw) : {}; + + // Check if key exists + if (data.card_key) { + return new Response(JSON.stringify({ key: data.card_key }), { status: 200, headers }); + } + + // Generate new key + const newKey = crypto.randomUUID(); + + // Update User Data + data.card_key = newKey; + await DB.put(userKey, JSON.stringify(data)); + + // Create Reverse Index + await DB.put(`card_key:${newKey}`, username); + + return new Response(JSON.stringify({ key: newKey }), { status: 200, headers }); + + } catch (e) { + return new Response(JSON.stringify({ error: e.message }), { status: 500, headers }); + } +} diff --git a/src/RailRound.jsx b/src/RailRound.jsx index d4860c6..7016591 100644 --- a/src/RailRound.jsx +++ b/src/RailRound.jsx @@ -917,109 +917,161 @@ const FabButton = ({ activeTab, pinMode, togglePinMode }) => (
{activeTab === 'map' && ()}
); -const RouteSlice = ({ segments, segmentGeometries }) => { - const allCoords = []; - if (!segmentGeometries) return null; - - let totalDist = 0; - - segments.forEach(seg => { - const key = `${seg.lineKey}_${seg.fromId}_${seg.toId}`; - const geom = segmentGeometries.get(key); - if (geom && geom.coords) { - if (geom.isMulti) { - geom.coords.forEach(c => { - allCoords.push({ coords: c, color: geom.color }); - if (turf) totalDist += turf.length(turf.lineString(c.map(p => [p[1], p[0]]))); - }); - } else { - allCoords.push({ coords: geom.coords, color: geom.color }); - if (turf) totalDist += turf.length(turf.lineString(geom.coords.map(p => [p[1], p[0]]))); - } - } - }); +// --- Shared Helper: Calculate Visualization Data --- +const getRouteVisualData = (segments, segmentGeometries, railwayData, geoData) => { + let totalDist = 0; + const allCoords = []; - if (allCoords.length === 0) return
无预览
; + // Helper to get or calc geometry on-the-fly + const getGeometry = (seg) => { + const key = `${seg.lineKey}_${seg.fromId}_${seg.toId}`; + let geom = segmentGeometries ? segmentGeometries.get(key) : null; - // Flattening Logic using PCA (Principal Component Analysis) approximation - // 1. Calculate Centroid - let sumLat = 0, sumLng = 0, count = 0; - allCoords.forEach(item => { - item.coords.forEach(pt => { - sumLat += pt[0]; - sumLng += pt[1]; - count++; - }); - }); - if (count === 0) return null; - const cenLat = sumLat / count; - const cenLng = sumLng / count; - - // 2. Calculate Covariance Matrix terms - let u20 = 0, u02 = 0, u11 = 0; - allCoords.forEach(item => { - item.coords.forEach(pt => { - const x = pt[1] - cenLng; // lng is x - const y = pt[0] - cenLat; // lat is y - u20 += x * x; - u02 += y * y; - u11 += x * y; - }); - }); + // Fallback: If not in cache but we have geoData, try to slice it now + if ((!geom || !geom.coords) && geoData && railwayData) { + const line = railwayData[seg.lineKey]; + if (line) { + const s1 = line.stations.find(st => st.id === seg.fromId); + const s2 = line.stations.find(st => st.id === seg.toId); + if (s1 && s2) { + const parts = seg.lineKey.split(':'); + const company = parts[0]; + const lineName = parts.slice(1).join(':'); + const feature = geoData.features.find(f => + f.properties.type === 'line' && + f.properties.name === lineName && + f.properties.company === company + ); + if (feature) { + const coords = sliceGeoJsonPath(feature, s1.lat, s1.lng, s2.lat, s2.lng); + if (coords) { + const isMulti = Array.isArray(coords[0]) && Array.isArray(coords[0][0]); + geom = { coords, isMulti }; + } + } + } + } + } + return geom; + }; - // 3. Principal Axis Angle - // theta is the angle of the main axis relative to X-axis - const theta = 0.5 * Math.atan2(2 * u11, u20 - u02); - - // We want to rotate by -theta to align with X axis - const cosT = Math.cos(-theta); - const sinT = Math.sin(-theta); - - // 4. Rotate and BBox - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - const rotatedCoords = allCoords.map(item => ({ - ...item, - coords: item.coords.map(pt => { - const x = pt[1] - cenLng; - const y = pt[0] - cenLat; - const rx = x * cosT - y * sinT; - const ry = x * sinT + y * cosT; - if (rx < minX) minX = rx; - if (rx > maxX) maxX = rx; - if (ry < minY) minY = ry; - if (ry > maxY) maxY = ry; - return [ry, rx]; // Keep as [y, x] format for consistency if needed, but projected is purely Cartesian - }) - })); - - const w = maxX - minX || 0.001; - const h = maxY - minY || 0.001; - - // Padding - const padX = w * 0.1; - const padY = h * 0.1; - - const vMinX = minX - padX; - const vMinY = minY - padY; - const vW = w + padX * 2; - const vH = h + padY * 2; - - // Determine Aspect Ratio strategy - // 1. Normalize data to fill 100x50 (2:1) coordinate space - // 2. Calculate target visual ratio = max(2, actualRatio) - // 3. Size container to target ratio - // 4. Use preserveAspectRatio="none" to stretch the normalized 2:1 coords to the target ratio - - const contentRatio = vW / vH; - const visualRatio = Math.min(8, Math.max(2, contentRatio)); // Min 2:1, Max 8:1 - const heightPx = 40; - const widthPx = heightPx * visualRatio; - - const project = (lat, lng) => { - const px = ((lng - vMinX) / vW) * 100; // Map to 0-100 - const py = 50 - ((lat - vMinY) / vH) * 50; // Map to 0-50 - return `${px},${py}`; - }; + segments.forEach(seg => { + const geom = getGeometry(seg); + if (geom && geom.coords) { + if (geom.isMulti) { + geom.coords.forEach(c => { + allCoords.push({ coords: c, color: geom.color || '#94a3b8' }); + if(turf) totalDist += turf.length(turf.lineString(c.map(p => [p[1], p[0]]))); + }); + } else { + allCoords.push({ coords: geom.coords, color: geom.color || '#94a3b8' }); + if(turf) totalDist += turf.length(turf.lineString(geom.coords.map(p => [p[1], p[0]]))); + } + } else { + // Fallback Distance Approx + const line = railwayData ? railwayData[seg.lineKey] : null; + if (line) { + const s1 = line.stations.find(st => st.id === seg.fromId); + const s2 = line.stations.find(st => st.id === seg.toId); + if (s1 && s2) totalDist += calcDist(s1.lat, s1.lng, s2.lat, s2.lng); + } + } + }); + + if (allCoords.length === 0) return { totalDist, visualPaths: [] }; + + // PCA & Projection Logic + let sumLat = 0, sumLng = 0, count = 0; + allCoords.forEach(item => { + item.coords.forEach(pt => { + sumLat += pt[0]; + sumLng += pt[1]; + count++; + }); + }); + + if (count === 0) return { totalDist, visualPaths: [] }; + + const cenLat = sumLat / count; + const cenLng = sumLng / count; + + let u20 = 0, u02 = 0, u11 = 0; + allCoords.forEach(item => { + item.coords.forEach(pt => { + const x = pt[1] - cenLng; + const y = pt[0] - cenLat; + u20 += x * x; + u02 += y * y; + u11 += x * y; + }); + }); + + const theta = 0.5 * Math.atan2(2 * u11, u20 - u02); + const cosT = Math.cos(-theta); + const sinT = Math.sin(-theta); + + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + + // Helper to rotate a point + const rotate = (lat, lng) => { + const x = lng - cenLng; + const y = lat - cenLat; + const rx = x * cosT - y * sinT; + const ry = x * sinT + y * cosT; + return { rx, ry }; + }; + + // 1. Calc BBox + allCoords.forEach(item => { + item.coords.forEach(pt => { + const { rx, ry } = rotate(pt[0], pt[1]); + if (rx < minX) minX = rx; + if (rx > maxX) maxX = rx; + if (ry < minY) minY = ry; + if (ry > maxY) maxY = ry; + }); + }); + + const w = maxX - minX || 0.001; + const h = maxY - minY || 0.001; + const padX = w * 0.1; + const padY = h * 0.1; + const vMinX = minX - padX; + const vMinY = minY - padY; + const vW = w + padX * 2; + const vH = h + padY * 2; + + const contentRatio = vW / vH; + const visualRatio = Math.min(8, Math.max(2, contentRatio)); + const heightPx = 40; + const widthPx = heightPx * visualRatio; + + // 2. Generate Paths + const visualPaths = allCoords.map(item => { + const pointsStr = item.coords.map(pt => { + const { rx, ry } = rotate(pt[0], pt[1]); + const px = ((rx - vMinX) / vW) * 100; + const py = 50 - ((ry - vMinY) / vH) * 50; + return `${px.toFixed(1)},${py.toFixed(1)}`; + }).join(' '); + + return { + path: `M ${pointsStr.replace(/ /g, ' L ')}`, // SVG Path Command + polyline: pointsStr, // Legacy Polyline points + color: item.color + }; + }); + + return { totalDist, visualPaths, widthPx, heightPx }; +}; + +const RouteSlice = ({ segments, segmentGeometries, railwayData, geoData }) => { + const { visualPaths, totalDist, widthPx, heightPx } = useMemo( + () => getRouteVisualData(segments, segmentGeometries, railwayData, geoData), + [segments, segmentGeometries, railwayData, geoData] + ); + + if (visualPaths.length === 0) return
无预览
; return (
@@ -1029,10 +1081,10 @@ const RouteSlice = ({ segments, segmentGeometries }) => { preserveAspectRatio="none" className="w-full h-full opacity-80" > - {rotatedCoords.map((item, idx) => ( - ( + project(pt[0], pt[1])).join(' ')} + d={item.path} fill="none" stroke={item.color || '#94a3b8'} strokeWidth="4" @@ -1048,6 +1100,94 @@ const RouteSlice = ({ segments, segmentGeometries }) => { ); }; +// --- Updated: Stats Calculation using Shared Helper --- +const calculateLatestStats = (trips, segmentGeometries, railwayData, geoData) => { + // 1. Basic Stats + const totalTrips = trips.length; + const allSegments = trips.flatMap(t => t.segments || [{ lineKey: t.lineKey, fromId: t.fromId, toId: t.toId }]); + const uniqueLines = new Set(allSegments.map(s => s.lineKey)).size; + + // Calc total distance using helper (aggregating cached or on-the-fly) + const { totalDist: grandTotalDist } = getRouteVisualData(allSegments, segmentGeometries, railwayData, geoData); + + // 2. Latest 5 + const latest = trips.slice(0, 5).map(t => { + const segs = t.segments || [{ lineKey: t.lineKey, fromId: t.fromId, toId: t.toId }]; + const lineNames = segs.map(s => s.lineKey.split(':').pop()).join(' → '); // Simplified Title + + const { totalDist, visualPaths } = getRouteVisualData(segs, segmentGeometries, railwayData, geoData); + + // Combine all paths into one 'd' string for the card + const svgPoints = visualPaths.map(vp => vp.path).join(" "); + + return { + id: t.id, + date: t.date, + title: lineNames, + dist: totalDist, + svg_points: svgPoints + }; + }); + + return { + count: totalTrips, + lines: uniqueLines, + dist: grandTotalDist, + latest: latest + }; +}; + +const GithubCardModal = ({ isOpen, onClose, user }) => { + const [cardKey, setCardKey] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen && user && !cardKey) { + setLoading(true); + api.getOrCreateCardKey(user.token) + .then(setCardKey) + .catch(err => setError(err.message)) + .finally(() => setLoading(false)); + } + }, [isOpen, user]); + + if (!isOpen || !user) return null; + + const url = cardKey ? `${window.location.origin}/api/card?key=${cardKey}` : ''; + const md = `[![RailRound Stats](${url})](${window.location.origin})`; + + return ( +
+
e.stopPropagation()}> +
+

GitHub Profile Decoration

+ +
+ + {loading ? ( +
+ ) : error ? ( +
{error}
+ ) : ( +
+
+ Preview +
+
+ +
+