diff --git a/image.png b/image.png
new file mode 100644
index 0000000..bc0cb1f
Binary files /dev/null and b/image.png differ
diff --git a/public/functions/api/card.js b/public/functions/api/card.js
index 6626bf7..a912ad0 100644
--- a/public/functions/api/card.js
+++ b/public/functions/api/card.js
@@ -1,6 +1,7 @@
export async function onRequest(event) {
const url = new URL(event.request.url);
const key = url.searchParams.get("key");
+ const hash = url.searchParams.get("hash");
// SVG Headers
const headers = {
@@ -11,26 +12,66 @@ export async function onRequest(event) {
const errorSvg = (msg) => new Response(``, { headers });
- if (!key) {
- return errorSvg("Key missing");
+ if (!key && !hash) {
+ return errorSvg("Key or Hash 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);
+ let stats = null;
+ let username = "Traveller";
+ let isGlobalEnabled = true;
+
+ if (hash) {
+ // Folder Badge Mode
+ const badgeDataRaw = await DB.get(`badge:${hash}`);
+ if (!badgeDataRaw) return errorSvg("Invalid Hash");
+
+ const badgeData = JSON.parse(badgeDataRaw);
+ username = badgeData.username || "Traveller";
+ stats = badgeData.stats;
+
+ // Check global switch via User KV
+ if (badgeData.username) {
+ const userKey = `user:${badgeData.username}`;
+ const userDataRaw = await DB.get(userKey);
+ if (userDataRaw) {
+ const u = JSON.parse(userDataRaw);
+ if (u.badge_settings?.enabled === false) {
+ isGlobalEnabled = false;
+ }
+ }
+ }
+
+ } else if (key) {
+ // Global Badge Mode
+ 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);
+
+ // Check Master Switch
+ if (data.badge_settings?.enabled === false) {
+ isGlobalEnabled = false;
+ }
+
+ stats = data.latest_5 || { count: 0, dist: 0, lines: 0, latest: [] };
+ }
- if (!dataRaw) {
- return errorSvg("User data not found");
+ if (!isGlobalEnabled) {
+ return errorSvg("Badge Disabled by User");
}
- const data = JSON.parse(dataRaw);
- const stats = data.latest_5 || { count: 0, dist: 0, lines: 0, latest: [] };
+ if (!stats) stats = { count: 0, dist: 0, lines: 0, latest: [] };
const esc = (str) => {
if (!str) return "";
@@ -92,11 +133,6 @@ export async function onRequest(event) {
-
diff --git a/public/functions/api/user/data.js b/public/functions/api/user/data.js
index 279e2ea..ca9e0d5 100644
--- a/public/functions/api/user/data.js
+++ b/public/functions/api/user/data.js
@@ -40,17 +40,55 @@ export async function onRequest(event) {
if (event.request.method === "POST") {
const body = await event.request.json();
- const { trips, pins, latest_5, version } = body;
+ const { trips, pins, latest_5, version, folders, badge_settings } = body;
// Fetch existing to preserve other fields (like password, bindings)
const existingRaw = await DB.get(userKey);
const existing = existingRaw ? JSON.parse(existingRaw) : {};
+ // --- Folder Badge Sync Logic ---
+ if (folders && Array.isArray(folders)) {
+ const oldFolders = existing.folders || [];
+
+ // 1. Identify hashes to delete (existed before, but now removed or made private)
+ // Map current public hashes
+ const newPublicHashes = new Set(folders.filter(f => f.is_public && f.hash).map(f => f.hash));
+
+ const promises = [];
+
+ oldFolders.forEach(f => {
+ if (f.hash && !newPublicHashes.has(f.hash)) {
+ promises.push(DB.delete(`badge:${f.hash}`));
+ }
+ });
+
+ // 2. Identify/Update hashes to save
+ folders.forEach(f => {
+ if (f.is_public && f.hash && f.stats) {
+ // Store minimal data needed for the card
+ const badgeData = {
+ username: username,
+ stats: f.stats,
+ type: 'folder',
+ updated_at: new Date().toISOString()
+ };
+ promises.push(DB.put(`badge:${f.hash}`, JSON.stringify(badgeData)));
+ }
+ });
+
+ if (promises.length > 0) {
+ await Promise.allSettled(promises);
+ }
+ }
+ // -------------------------------
+
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
+ folders: folders || existing.folders || [],
+ badge_settings: badge_settings || existing.badge_settings || { enabled: true },
version: version || existing.version || null
};
diff --git a/src/RailRound.jsx b/src/RailRound.jsx
index 2d5fd1b..576bbbd 100644
--- a/src/RailRound.jsx
+++ b/src/RailRound.jsx
@@ -10,11 +10,12 @@ try { console.log('[iconfixed] module loaded'); } catch {}
import {
Train, Calendar, Navigation, Map as MapIcon, Layers, Upload, Plus, Edit2, Trash2,
PieChart, TrendingUp, MapPin, Save, X, Camera, MessageSquare, Move, Magnet, CheckCircle2, FilePlus, ArrowDown, Search, Building2, AlertTriangle, Loader2, Download, ListFilter,
- LogOut, User, Github
+ LogOut, User, Github, Star, Folder, Globe, Lock, Eye, EyeOff
} from 'lucide-react';
import { LoginModal } from './components/LoginModal';
import { api } from './services/api';
import { db } from './utils/db';
+import { calcDist, sliceGeoJsonPath, getRouteVisualData, calculateLatestStats, stitchRoutes } from './utils/stats';
const CURRENT_VERSION = 0.30;
const MIN_SUPPORTED_VERSION = 0.0;
@@ -131,17 +132,6 @@ const LEAFLET_CSS = `
const COLOR_PALETTE = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#ec4899', '#64748b'];
// --- 2. 核心算法 (Integrated) ---
-// 辅助:计算两点间直线距离 (Haversine Formula)
-const calcDist = (lat1, lon1, lat2, lon2) => {
- if (!lat1 || !lon1 || !lat2 || !lon2) return 0;
- const R = 6371; // 地球半径 km
- const dLat = (lat2 - lat1) * Math.PI / 180;
- const dLon = (lon2 - lon1) * Math.PI / 180;
- const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
- Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
- const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
- return R * c;
-};
const distSq = (x1, y1, x2, y2) => (x1-x2)**2 + (y1-y2)**2;
@@ -170,150 +160,6 @@ const getCoordinates = (geometry) => {
return [];
};
-// [New] 路径缝合算法: 将乱序的 MultiLineString 缝合成连续的 LineString
-const stitchRoutes = (turf, multiCoords, startPt) => {
- let pool = multiCoords.map((coords, i) => {
- if (!coords || coords.length < 2) return null;
- return {
- id: i,
- coords: coords,
- head: turf.point(coords[0]),
- tail: turf.point(coords[coords.length - 1])
- };
- }).filter(Boolean);
-
- if (pool.length === 0) return [];
- if (pool.length === 1) return pool[0].coords;
-
- let seedIdx = -1;
- let minSeedDist = Infinity;
-
- pool.forEach((seg, i) => {
- const line = turf.lineString(seg.coords);
- const dist = turf.pointToLineDistance(startPt, line);
- if (dist < minSeedDist) { minSeedDist = dist; seedIdx = i; }
- });
-
- if (seedIdx === -1) seedIdx = 0;
-
- let pathSegments = [pool[seedIdx]];
- pool.splice(seedIdx, 1);
-
- while (pool.length > 0) {
- const currentHeadCoords = pathSegments[0].coords;
- const currentTailCoords = pathSegments[pathSegments.length - 1].coords;
-
- const pathHeadPt = turf.point(currentHeadCoords[0]);
- const pathTailPt = turf.point(currentTailCoords[currentTailCoords.length - 1]);
-
- let bestMatchIdx = -1;
- let minDist = Infinity;
- let matchType = '';
-
- for (let i = 0; i < pool.length; i++) {
- const seg = pool[i];
- const d_Tail_Start = turf.distance(pathTailPt, seg.head);
- const d_Tail_End = turf.distance(pathTailPt, seg.tail);
- const d_Head_End = turf.distance(pathHeadPt, seg.tail);
- const d_Head_Start = turf.distance(pathHeadPt, seg.head);
-
- if (d_Tail_Start < minDist) { minDist = d_Tail_Start; bestMatchIdx = i; matchType = 'tail-start'; }
- if (d_Tail_End < minDist) { minDist = d_Tail_End; bestMatchIdx = i; matchType = 'tail-end'; }
- if (d_Head_End < minDist) { minDist = d_Head_End; bestMatchIdx = i; matchType = 'head-end'; }
- if (d_Head_Start < minDist) { minDist = d_Head_Start; bestMatchIdx = i; matchType = 'head-start'; }
- }
-
- if (bestMatchIdx !== -1) {
- const seg = pool[bestMatchIdx];
- if (matchType === 'tail-start') {
- pathSegments.push(seg);
- } else if (matchType === 'tail-end') {
- seg.coords.reverse();
- const temp = seg.head; seg.head = seg.tail; seg.tail = temp;
- pathSegments.push(seg);
- } else if (matchType === 'head-end') {
- pathSegments.unshift(seg);
- } else if (matchType === 'head-start') {
- seg.coords.reverse();
- const temp = seg.head; seg.head = seg.tail; seg.tail = temp;
- pathSegments.unshift(seg);
- }
- pool.splice(bestMatchIdx, 1);
- } else {
- break;
- }
- }
-
- let flatCoords = [];
- pathSegments.forEach(seg => {
- flatCoords.push(...seg.coords);
- });
- return flatCoords;
-};
-
-// [Turf.js] 轨迹切分算法
-const sliceGeoJsonPath = (feature, startLat, startLng, endLat, endLng) => {
- if (!turf || !feature || !feature.geometry) return null;
-
- try {
- let line = feature;
- const startPt = turf.point([startLng, startLat]);
- const endPt = turf.point([endLng, endLat]);
-
- // If MultiLineString, attempt to stitch segments into a sensible continuous path
- if (feature.geometry.type === 'MultiLineString') {
- const multiCoords = feature.geometry.coordinates;
- const stitchedCoords = stitchRoutes(turf, multiCoords, startPt);
- if (stitchedCoords && stitchedCoords.length > 0) {
- line = turf.lineString(stitchedCoords);
- } else {
- const flatCoords = feature.geometry.coordinates.flat();
- line = turf.lineString(flatCoords);
- }
- }
-
- // 1. 吸附 (Snap)
- const snappedStart = turf.nearestPointOnLine(line, startPt);
- const snappedEnd = turf.nearestPointOnLine(line, endPt);
-
- const startIdx = snappedStart.properties.index;
- const endIdx = snappedEnd.properties.index;
-
- // 2. 环线检测
- const coords = line.geometry.coordinates;
- const firstPt = coords[0];
- const lastPt = coords[coords.length - 1];
- const isLoop = turf.distance(turf.point(firstPt), turf.point(lastPt)) < 0.5;
-
- // 3. 切分
- let resultCoords = [];
-
- if (!isLoop) {
- const sliced = turf.lineSlice(snappedStart, snappedEnd, line);
- resultCoords = sliced.geometry.coordinates;
- } else {
- const sliceDirect = turf.lineSlice(snappedStart, snappedEnd, line);
- const lenDirect = turf.length(sliceDirect);
-
- const sliceToTail = turf.lineSlice(snappedStart, turf.point(lastPt), line);
- const sliceFromHead = turf.lineSlice(turf.point(firstPt), snappedEnd, line);
- const lenWrap = turf.length(sliceToTail) + turf.length(sliceFromHead);
-
- if (lenDirect <= lenWrap) {
- resultCoords = sliceDirect.geometry.coordinates;
- } else {
- const c1 = sliceToTail.geometry.coordinates.map(p => [p[1], p[0]]);
- const c2 = sliceFromHead.geometry.coordinates.map(p => [p[1], p[0]]);
- return [c1, c2]; // MultiPolyline
- }
- }
- return resultCoords.map(p => [p[1], p[0]]); // Leaflet [lat, lng]
- } catch (e) {
- console.warn("Turf slice failed:", e);
- return null;
- }
-};
-
// [Custom] 高精度吸附算法 (无需 Turf, 纯几何计算投影)
const findNearestPointOnLine = (railwayData, targetLat, targetLng) => {
let minDistSq = Infinity;
@@ -921,152 +767,6 @@ const FabButton = ({ activeTab, pinMode, togglePinMode }) => (
);
// --- Shared Helper: Calculate Visualization Data ---
-const getRouteVisualData = (segments, segmentGeometries, railwayData, geoData) => {
- let totalDist = 0;
- const allCoords = [];
-
- // 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;
-
- // 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;
- };
-
- 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(
@@ -1104,46 +804,12 @@ const RouteSlice = ({ segments, segmentGeometries, railwayData, geoData }) => {
};
// --- 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 GithubCardModal = ({ isOpen, onClose, user, folders, badgeSettings, onUpdateSettings }) => {
const [cardKey, setCardKey] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ const [source, setSource] = useState('global'); // 'global' or folder_id
useEffect(() => {
if (isOpen && user && !cardKey) {
@@ -1157,8 +823,18 @@ const GithubCardModal = ({ isOpen, onClose, user }) => {
if (!isOpen || !user) return null;
- const url = cardKey ? `${window.location.origin}/api/card?key=${cardKey}` : '';
- const md = `[](${window.location.origin})`;
+ let url = "";
+ if (source === 'global' && cardKey) {
+ url = `${window.location.origin}/api/card?key=${cardKey}`;
+ } else if (source !== 'global') {
+ const f = folders.find(fo => fo.id === source);
+ if (f && f.hash) {
+ url = `${window.location.origin}/api/card?hash=${f.hash}`;
+ }
+ }
+
+ const md = url ? `[](${window.location.origin})` : "Please select a valid source";
+ const publicFolders = folders.filter(f => f.is_public && f.hash);
return (
@@ -1168,21 +844,55 @@ const GithubCardModal = ({ isOpen, onClose, user }) => {
+
+
+ {badgeSettings.enabled ? : }
+ Public Badge Access
+
+
+
+
{loading ? (
) : error ? (
{error}
) : (
-
-

-
-
-
-
+
+
+
+
+
+ {badgeSettings.enabled ? (
+ url ?

:
No public URL available
+ ) : (
+
Badges are disabled
+ )}
+
+ {badgeSettings.enabled && url && (
+
+
+
+
+
+ )}
)}
@@ -1191,7 +901,138 @@ const GithubCardModal = ({ isOpen, onClose, user }) => {
);
};
-const RecordsView = ({ trips, railwayData, setTrips, onEdit, onDelete, onAdd, segmentGeometries }) => (
+const FolderManagerModal = ({ isOpen, onClose, folders, onUpdateFolders }) => {
+ const [newFolderName, setNewFolderName] = useState("");
+
+ if (!isOpen) return null;
+
+ const handleCreate = () => {
+ if (!newFolderName.trim()) return;
+ const newFolder = {
+ id: crypto.randomUUID(),
+ name: newFolderName.trim(),
+ is_public: false,
+ trip_ids: [],
+ stats: null,
+ hash: null
+ };
+ onUpdateFolders([...folders, newFolder]);
+ setNewFolderName("");
+ };
+
+ const handleDelete = (id) => {
+ if (confirm("Delete this folder?")) {
+ onUpdateFolders(folders.filter(f => f.id !== id));
+ }
+ };
+
+ const togglePublic = (id) => {
+ const updated = folders.map(f => {
+ if (f.id === id) {
+ const willBePublic = !f.is_public;
+ return {
+ ...f,
+ is_public: willBePublic,
+ hash: willBePublic ? (f.hash || crypto.randomUUID()) : null
+ };
+ }
+ return f;
+ });
+ onUpdateFolders(updated);
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
Star Folders
+
+
+
+
+ setNewFolderName(e.target.value)}
+ />
+
+
+
+
+ {folders.length === 0 &&
No folders yet.
}
+ {folders.map(f => (
+
+
+
{f.name}
+
{f.trip_ids?.length || 0} trips
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+};
+
+const AddToFolderModal = ({ isOpen, onClose, trip, folders, onUpdateFolders }) => {
+ if (!isOpen || !trip) return null;
+
+ const toggleFolder = (folderId) => {
+ const updatedFolders = folders.map(f => {
+ if (f.id === folderId) {
+ const currentIds = new Set(f.trip_ids || []);
+ if (currentIds.has(trip.id)) {
+ currentIds.delete(trip.id);
+ } else {
+ currentIds.add(trip.id);
+ }
+ return { ...f, trip_ids: Array.from(currentIds) };
+ }
+ return f;
+ });
+ onUpdateFolders(updatedFolders);
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
Add to Folder
+
+
+
+ {folders.length === 0 &&
No folders created. Go to Stats page to create one.
}
+ {folders.map(f => {
+ const isSelected = f.trip_ids?.includes(trip.id);
+ return (
+
+ );
+ })}
+
+
+
+ );
+};
+
+const RecordsView = ({ trips, railwayData, setTrips, onEdit, onDelete, onAdd, segmentGeometries, onAddToFolder }) => (
{trips.length === 0 ? (
@@ -1208,6 +1049,7 @@ const RecordsView = ({ trips, railwayData, setTrips, onEdit, onDelete, onAdd, se
{t.date}
{t.cost > 0 && ¥{t.cost}}
+
@@ -1231,7 +1073,7 @@ const RecordsView = ({ trips, railwayData, setTrips, onEdit, onDelete, onAdd, se
);
-const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeometries, onOpenCard }) => {
+const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeometries, onOpenCard, onOpenFolders }) => {
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;
@@ -1291,7 +1133,26 @@ const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeom
)}
-
+
+
+ {user && (
+
+ )}
+
里程统计
总距离
{Math.round(totalDist)} km
@@ -1333,6 +1194,9 @@ export default function RailLOOPApp() {
const [mapZoom, setMapZoom] = useState(10);
const [isExportingKML, setIsExportingKML] = useState(false);
const [cardModalUser, setCardModalUser] = useState(null);
+ const [folderManagerOpen, setFolderManagerOpen] = useState(false);
+ const [addToFolderModalOpen, setAddToFolderModalOpen] = useState(false);
+ const [currentTripForFolder, setCurrentTripForFolder] = useState(null);
const mapRef = useRef(null);
const mapInstance = useRef(null);
@@ -1436,6 +1300,9 @@ export default function RailLOOPApp() {
}
}, []);
+ const [folders, setFolders] = useState([]);
+ const [badgeSettings, setBadgeSettings] = useState({ enabled: true });
+
const loadUserData = async (token, isInteractive = false) => {
try {
const cloudData = await api.getData(token);
@@ -1443,6 +1310,8 @@ export default function RailLOOPApp() {
let newTrips = cloudData.trips || [];
let newPins = cloudData.pins || [];
+ let newFolders = cloudData.folders || [];
+ let newBadgeSettings = cloudData.badge_settings || { enabled: true };
if (isInteractive && (trips.length > 0 || pins.length > 0)) {
// Merge Strategy
@@ -1461,13 +1330,15 @@ export default function RailLOOPApp() {
// Sync back the merged result to cloud immediately
if (token) {
- api.saveData(token, newTrips, newPins, null, CURRENT_VERSION).catch(e => console.error("Merge sync failed", e));
+ saveDataFull(token, newTrips, newPins, newFolders, newBadgeSettings);
}
}
}
setTrips(newTrips.sort((a,b) => b.date.localeCompare(a.date)));
setPins(newPins);
+ setFolders(newFolders);
+ setBadgeSettings(newBadgeSettings);
console.log('User data loaded');
} catch (e) {
console.error('Failed to load user data:', e);
@@ -1477,6 +1348,18 @@ export default function RailLOOPApp() {
}
};
+ const saveDataFull = async (token, currentTrips, currentPins, currentFolders, currentBadgeSettings) => {
+ const latest5 = calculateLatestStats(currentTrips, segmentGeometries, railwayData, geoData);
+ await api.saveData(token, currentTrips, currentPins, latest5, CURRENT_VERSION, currentFolders, currentBadgeSettings);
+ };
+
+ const saveUserFolders = (newFolders) => {
+ setFolders(newFolders);
+ if (user) {
+ saveDataFull(user.token, trips, pins, newFolders, badgeSettings).catch(e => alert("保存失败: " + e.message));
+ }
+ };
+
const handleLoginSuccess = (data) => {
setUser({ token: data.token, username: data.username });
localStorage.setItem('rail_token', data.token);
@@ -2159,8 +2042,7 @@ export default function RailLOOPApp() {
// Sync to Cloud
if (user) {
- const latest5 = calculateLatestStats(finalTrips, segmentGeometries, railwayData, geoData);
- api.saveData(user.token, finalTrips, pins, latest5, CURRENT_VERSION).catch(e => alert('云端保存失败: ' + e.message));
+ saveDataFull(user.token, finalTrips, pins, folders, badgeSettings).catch(e => alert('云端保存失败: ' + e.message));
}
setIsTripEditing(false); setEditingTripId(null);
@@ -2177,8 +2059,7 @@ export default function RailLOOPApp() {
const newTrips = trips.filter(t => t.id !== id);
setTrips(newTrips);
if (user) {
- const latest5 = calculateLatestStats(newTrips, segmentGeometries, railwayData, geoData);
- api.saveData(user.token, newTrips, pins, latest5, CURRENT_VERSION).catch(e => alert('云端同步失败'));
+ saveDataFull(user.token, newTrips, pins, folders, badgeSettings).catch(e => alert('云端同步失败'));
}
}
};
@@ -2213,7 +2094,7 @@ export default function RailLOOPApp() {
setPins(newPins);
if (user) {
- api.saveData(user.token, trips, newPins, null, CURRENT_VERSION).catch(e => console.error('Pin sync failed', e));
+ saveDataFull(user.token, trips, newPins, folders, badgeSettings).catch(e => console.error('Pin sync failed', e));
}
setEditingPin(null);
@@ -2226,7 +2107,7 @@ export default function RailLOOPApp() {
if (editingPin?.id === id) setEditingPin(null);
if (user) {
- api.saveData(user.token, trips, newPins, null, CURRENT_VERSION).catch(e => console.error('Pin sync failed', e));
+ saveDataFull(user.token, trips, newPins, folders, badgeSettings).catch(e => console.error('Pin sync failed', e));
}
}
};
@@ -2420,8 +2301,8 @@ export default function RailLOOPApp() {
- {activeTab === 'records' &&
{ setTripForm({ date: new Date().toISOString().split('T')[0], memo: '', segments: [{ id: Date.now().toString(), lineKey: '', fromId: '', toId: '' }] }); setIsTripEditing(true); }} />}
- {activeTab === 'stats' && }
+ {activeTab === 'records' && { setCurrentTripForFolder(t); setAddToFolderModalOpen(true); }} onAdd={() => { setTripForm({ date: new Date().toISOString().split('T')[0], memo: '', segments: [{ id: Date.now().toString(), lineKey: '', fromId: '', toId: '' }] }); setIsTripEditing(true); }} />}
+ {activeTab === 'stats' && setFolderManagerOpen(true)} />}
@@ -2432,7 +2313,52 @@ export default function RailLOOPApp() {
setIsLoginOpen(false)} onLoginSuccess={handleLoginSuccess} />
setIsGithubRegisterOpen(false)} regToken={githubRegToken} onLoginSuccess={handleLoginSuccess} />
- setCardModalUser(null)} />
+
+ setCardModalUser(null)}
+ folders={folders}
+ badgeSettings={badgeSettings}
+ onUpdateSettings={(s) => {
+ setBadgeSettings(s);
+ if (user) saveDataFull(user.token, trips, pins, folders, s).catch(e => alert("Failed to save settings: " + e.message));
+ }}
+ />
+
+ setFolderManagerOpen(false)}
+ folders={folders}
+ onUpdateFolders={saveUserFolders}
+ />
+
+ setAddToFolderModalOpen(false)}
+ trip={currentTripForFolder}
+ folders={folders}
+ onUpdateFolders={(updatedFolders) => {
+ // Recalculate stats for modified folders
+ const foldersWithStats = updatedFolders.map(f => {
+ // Optimization: only recalc if we touched this folder?
+ // Since we don't easily know, let's just recalc for safety or check trip_ids length change?
+ // For simplicity, we just recalc all or specifically if trip_ids is different from old state (but we only have new state here).
+ // Actually, the `onUpdateFolders` logic in `AddToFolderModal` returns the full new array.
+ // We should re-run calculateLatestStats for any folder in the array to ensure its `stats` cache is fresh.
+ if (f.trip_ids && f.trip_ids.length > 0) {
+ const folderTrips = trips.filter(t => f.trip_ids.includes(t.id));
+ // Sort by date desc
+ folderTrips.sort((a,b) => b.date.localeCompare(a.date));
+ const stats = calculateLatestStats(folderTrips, segmentGeometries, railwayData, geoData);
+ return { ...f, stats };
+ } else {
+ return { ...f, stats: null };
+ }
+ });
+ saveUserFolders(foldersWithStats);
+ }}
+ />
{/* Line Selector */}
{}} onSelect={() => {}} railwayData={railwayData} />
diff --git a/src/services/api.js b/src/services/api.js
index 6cf4200..9c63fc2 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -1,6 +1,9 @@
const API_BASE = '/api'; // Relative path for Edge Functions
export const api = {
+ // Export API_BASE for direct usage if needed
+ API_BASE,
+
async register(username, password) {
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
@@ -36,14 +39,14 @@ export const api = {
return data;
},
- async saveData(token, trips, pins, latest_5, version = null) {
+ async saveData(token, trips, pins, latest_5, version = null, folders = null, badge_settings = null) {
const res = await fetch(`${API_BASE}/user/data`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
- body: JSON.stringify({ trips, pins, latest_5, version })
+ body: JSON.stringify({ trips, pins, latest_5, version, folders, badge_settings })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save data');
diff --git a/src/utils/stats.js b/src/utils/stats.js
new file mode 100644
index 0000000..889c9d4
--- /dev/null
+++ b/src/utils/stats.js
@@ -0,0 +1,342 @@
+import * as turf from '@turf/turf';
+
+// 辅助:计算两点间直线距离 (Haversine Formula)
+export const calcDist = (lat1, lon1, lat2, lon2) => {
+ if (!lat1 || !lon1 || !lat2 || !lon2) return 0;
+ const R = 6371; // 地球半径 km
+ const dLat = (lat2 - lat1) * Math.PI / 180;
+ const dLon = (lon2 - lon1) * Math.PI / 180;
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return R * c;
+};
+
+// [New] 路径缝合算法: 将乱序的 MultiLineString 缝合成连续的 LineString
+export const stitchRoutes = (turf, multiCoords, startPt) => {
+ let pool = multiCoords.map((coords, i) => {
+ if (!coords || coords.length < 2) return null;
+ return {
+ id: i,
+ coords: coords,
+ head: turf.point(coords[0]),
+ tail: turf.point(coords[coords.length - 1])
+ };
+ }).filter(Boolean);
+
+ if (pool.length === 0) return [];
+ if (pool.length === 1) return pool[0].coords;
+
+ let seedIdx = -1;
+ let minSeedDist = Infinity;
+
+ pool.forEach((seg, i) => {
+ const line = turf.lineString(seg.coords);
+ const dist = turf.pointToLineDistance(startPt, line);
+ if (dist < minSeedDist) { minSeedDist = dist; seedIdx = i; }
+ });
+
+ if (seedIdx === -1) seedIdx = 0;
+
+ let pathSegments = [pool[seedIdx]];
+ pool.splice(seedIdx, 1);
+
+ while (pool.length > 0) {
+ const currentHeadCoords = pathSegments[0].coords;
+ const currentTailCoords = pathSegments[pathSegments.length - 1].coords;
+
+ const pathHeadPt = turf.point(currentHeadCoords[0]);
+ const pathTailPt = turf.point(currentTailCoords[currentTailCoords.length - 1]);
+
+ let bestMatchIdx = -1;
+ let minDist = Infinity;
+ let matchType = '';
+
+ for (let i = 0; i < pool.length; i++) {
+ const seg = pool[i];
+ const d_Tail_Start = turf.distance(pathTailPt, seg.head);
+ const d_Tail_End = turf.distance(pathTailPt, seg.tail);
+ const d_Head_End = turf.distance(pathHeadPt, seg.tail);
+ const d_Head_Start = turf.distance(pathHeadPt, seg.head);
+
+ if (d_Tail_Start < minDist) { minDist = d_Tail_Start; bestMatchIdx = i; matchType = 'tail-start'; }
+ if (d_Tail_End < minDist) { minDist = d_Tail_End; bestMatchIdx = i; matchType = 'tail-end'; }
+ if (d_Head_End < minDist) { minDist = d_Head_End; bestMatchIdx = i; matchType = 'head-end'; }
+ if (d_Head_Start < minDist) { minDist = d_Head_Start; bestMatchIdx = i; matchType = 'head-start'; }
+ }
+
+ if (bestMatchIdx !== -1) {
+ const seg = pool[bestMatchIdx];
+ if (matchType === 'tail-start') {
+ pathSegments.push(seg);
+ } else if (matchType === 'tail-end') {
+ seg.coords.reverse();
+ const temp = seg.head; seg.head = seg.tail; seg.tail = temp;
+ pathSegments.push(seg);
+ } else if (matchType === 'head-end') {
+ pathSegments.unshift(seg);
+ } else if (matchType === 'head-start') {
+ seg.coords.reverse();
+ const temp = seg.head; seg.head = seg.tail; seg.tail = temp;
+ pathSegments.unshift(seg);
+ }
+ pool.splice(bestMatchIdx, 1);
+ } else {
+ break;
+ }
+ }
+
+ let flatCoords = [];
+ pathSegments.forEach(seg => {
+ flatCoords.push(...seg.coords);
+ });
+ return flatCoords;
+};
+
+// [Turf.js] 轨迹切分算法
+export const sliceGeoJsonPath = (feature, startLat, startLng, endLat, endLng) => {
+ if (!turf || !feature || !feature.geometry) return null;
+
+ try {
+ let line = feature;
+ const startPt = turf.point([startLng, startLat]);
+ const endPt = turf.point([endLng, endLat]);
+
+ // If MultiLineString, attempt to stitch segments into a sensible continuous path
+ if (feature.geometry.type === 'MultiLineString') {
+ const multiCoords = feature.geometry.coordinates;
+ const stitchedCoords = stitchRoutes(turf, multiCoords, startPt);
+ if (stitchedCoords && stitchedCoords.length > 0) {
+ line = turf.lineString(stitchedCoords);
+ } else {
+ const flatCoords = feature.geometry.coordinates.flat();
+ line = turf.lineString(flatCoords);
+ }
+ }
+
+ // 1. 吸附 (Snap)
+ const snappedStart = turf.nearestPointOnLine(line, startPt);
+ const snappedEnd = turf.nearestPointOnLine(line, endPt);
+
+ const startIdx = snappedStart.properties.index;
+ const endIdx = snappedEnd.properties.index;
+
+ // 2. 环线检测
+ const coords = line.geometry.coordinates;
+ const firstPt = coords[0];
+ const lastPt = coords[coords.length - 1];
+ const isLoop = turf.distance(turf.point(firstPt), turf.point(lastPt)) < 0.5;
+
+ // 3. 切分
+ let resultCoords = [];
+
+ if (!isLoop) {
+ const sliced = turf.lineSlice(snappedStart, snappedEnd, line);
+ resultCoords = sliced.geometry.coordinates;
+ } else {
+ const sliceDirect = turf.lineSlice(snappedStart, snappedEnd, line);
+ const lenDirect = turf.length(sliceDirect);
+
+ const sliceToTail = turf.lineSlice(snappedStart, turf.point(lastPt), line);
+ const sliceFromHead = turf.lineSlice(turf.point(firstPt), snappedEnd, line);
+ const lenWrap = turf.length(sliceToTail) + turf.length(sliceFromHead);
+
+ if (lenDirect <= lenWrap) {
+ resultCoords = sliceDirect.geometry.coordinates;
+ } else {
+ const c1 = sliceToTail.geometry.coordinates.map(p => [p[1], p[0]]);
+ const c2 = sliceFromHead.geometry.coordinates.map(p => [p[1], p[0]]);
+ return [c1, c2]; // MultiPolyline
+ }
+ }
+ return resultCoords.map(p => [p[1], p[0]]); // Leaflet [lat, lng]
+ } catch (e) {
+ console.warn("Turf slice failed:", e);
+ return null;
+ }
+};
+
+// --- Shared Helper: Calculate Visualization Data ---
+export const getRouteVisualData = (segments, segmentGeometries, railwayData, geoData) => {
+ let totalDist = 0;
+ const allCoords = [];
+
+ // 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;
+
+ // 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;
+ };
+
+ 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 };
+};
+
+// --- Updated: Stats Calculation using Shared Helper ---
+export 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
+ };
+};