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(``, { 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 = `
+
+ `;
+
+ 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 = `[](${window.location.origin})`;
+
+ return (
+
+
e.stopPropagation()}>
+
+
GitHub Profile Decoration
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
{error}
+ ) : (
+
+
+

+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+
const RecordsView = ({ trips, railwayData, setTrips, onEdit, onDelete, onAdd, segmentGeometries }) => (
{trips.length === 0 ? (
@@ -1088,7 +1228,7 @@ const RecordsView = ({ trips, railwayData, setTrips, onEdit, onDelete, onAdd, se
);
-const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeometries }) => {
+const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeometries, onOpenCard }) => {
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;
@@ -1120,7 +1260,7 @@ const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeom
return (
{user && (
-
+
{userProfile?.bindings?.github?.avatar_url ? (
@@ -1140,6 +1280,11 @@ const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeom
+ {userProfile?.bindings?.github && (
+
+ )}
)}
@@ -1184,6 +1329,7 @@ export default function RailRoundApp() {
const [isRouteSearching, setIsRouteSearching] = useState(false);
const [mapZoom, setMapZoom] = useState(10);
const [isExportingKML, setIsExportingKML] = useState(false);
+ const [cardModalUser, setCardModalUser] = useState(null);
const mapRef = useRef(null);
const mapInstance = useRef(null);
@@ -2010,7 +2156,8 @@ export default function RailRoundApp() {
// Sync to Cloud
if (user) {
- api.saveData(user.token, finalTrips, pins).catch(e => alert('云端保存失败: ' + e.message));
+ const latest5 = calculateLatestStats(finalTrips, segmentGeometries, railwayData, geoData);
+ api.saveData(user.token, finalTrips, pins, latest5).catch(e => alert('云端保存失败: ' + e.message));
}
setIsTripEditing(false); setEditingTripId(null);
@@ -2027,7 +2174,8 @@ export default function RailRoundApp() {
const newTrips = trips.filter(t => t.id !== id);
setTrips(newTrips);
if (user) {
- api.saveData(user.token, newTrips, pins).catch(e => alert('云端同步失败'));
+ const latest5 = calculateLatestStats(newTrips, segmentGeometries, railwayData, geoData);
+ api.saveData(user.token, newTrips, pins, latest5).catch(e => alert('云端同步失败'));
}
}
};
@@ -2270,7 +2418,7 @@ export default function RailRoundApp() {
{activeTab === 'records' &&
{ setTripForm({ date: new Date().toISOString().split('T')[0], memo: '', segments: [{ id: Date.now().toString(), lineKey: '', fromId: '', toId: '' }] }); setIsTripEditing(true); }} />}
- {activeTab === 'stats' && }
+ {activeTab === 'stats' && }
@@ -2281,6 +2429,7 @@ export default function RailRoundApp() {
setIsLoginOpen(false)} onLoginSuccess={handleLoginSuccess} />
setIsGithubRegisterOpen(false)} regToken={githubRegToken} onLoginSuccess={handleLoginSuccess} />
+ setCardModalUser(null)} />
{/* Line Selector */}
{}} onSelect={() => {}} railwayData={railwayData} />
diff --git a/src/services/api.js b/src/services/api.js
index 509704d..628bbed 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -36,14 +36,14 @@ export const api = {
return data;
},
- async saveData(token, trips, pins) {
+ async saveData(token, trips, pins, latest_5) {
const res = await fetch(`${API_BASE}/user/data`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
- body: JSON.stringify({ trips, pins })
+ body: JSON.stringify({ trips, pins, latest_5 })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save data');
@@ -67,5 +67,18 @@ export const api = {
url += `&session_token=${encodeURIComponent(sessionToken)}`;
}
window.location.href = url;
+ },
+
+ async getOrCreateCardKey(token) {
+ const res = await fetch(`${API_BASE}/user/key`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ }
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Failed to get card key');
+ return data.key;
}
};