Add route detail day segment highlighting and per-day GPX export
- Hovering a day row dims other segments (opacity 0.3) and thickens the hovered day's segment (weight 5) for clear visual focus - Each day row has a GPX download button that exports just that day's track segment via /api/routes/:id/gpx?day=N - GPX endpoint uses computeDays to extract the correct track slice Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e2fc682a95
commit
7029c85dd0
3 changed files with 101 additions and 14 deletions
|
|
@ -26,9 +26,11 @@ interface RouteMapProps {
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
dayBreaks?: number[];
|
dayBreaks?: number[];
|
||||||
|
/** 1-based day number to highlight, or null for no highlight */
|
||||||
|
highlightedDay?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks }: RouteMapProps) {
|
export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay }: RouteMapProps) {
|
||||||
const data: GeoJsonObject = JSON.parse(geojson);
|
const data: GeoJsonObject = JSON.parse(geojson);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -48,7 +50,7 @@ export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks }
|
||||||
attribution={interactive ? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' : undefined}
|
attribution={interactive ? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' : undefined}
|
||||||
/>
|
/>
|
||||||
{dayBreaks && dayBreaks.length > 0 ? (
|
{dayBreaks && dayBreaks.length > 0 ? (
|
||||||
<DayColoredRoute data={data} dayBreaks={dayBreaks} />
|
<DayColoredRoute data={data} dayBreaks={dayBreaks} highlightedDay={highlightedDay} />
|
||||||
) : (
|
) : (
|
||||||
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
|
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -57,7 +59,7 @@ export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DayColoredRoute({ data, dayBreaks }: { data: GeoJsonObject; dayBreaks: number[] }) {
|
function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObject; dayBreaks: number[]; highlightedDay?: number | null }) {
|
||||||
// Extract coordinates from the GeoJSON LineString
|
// Extract coordinates from the GeoJSON LineString
|
||||||
const geometry = (data as { type: string; coordinates?: number[][] }).coordinates
|
const geometry = (data as { type: string; coordinates?: number[][] }).coordinates
|
||||||
?? ((data as { features?: Array<{ geometry: { coordinates: number[][] } }> }).features?.[0]?.geometry?.coordinates);
|
?? ((data as { features?: Array<{ geometry: { coordinates: number[][] } }> }).features?.[0]?.geometry?.coordinates);
|
||||||
|
|
@ -84,9 +86,13 @@ function DayColoredRoute({ data, dayBreaks }: { data: GeoJsonObject; dayBreaks:
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isHighlighting = highlightedDay != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{segments.map((seg, i) => {
|
{segments.map((seg, i) => {
|
||||||
|
const dayNum = i + 1;
|
||||||
|
const isActive = highlightedDay === dayNum;
|
||||||
const segData: GeoJsonObject = {
|
const segData: GeoJsonObject = {
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
geometry: { type: "LineString", coordinates: seg.coords },
|
geometry: { type: "LineString", coordinates: seg.coords },
|
||||||
|
|
@ -94,9 +100,13 @@ function DayColoredRoute({ data, dayBreaks }: { data: GeoJsonObject; dayBreaks:
|
||||||
} as unknown as GeoJsonObject;
|
} as unknown as GeoJsonObject;
|
||||||
return (
|
return (
|
||||||
<GeoJSON
|
<GeoJSON
|
||||||
key={i}
|
key={`${i}-${highlightedDay}`}
|
||||||
data={segData}
|
data={segData}
|
||||||
style={{ color: seg.color, weight: 3, opacity: 0.8 }}
|
style={{
|
||||||
|
color: seg.color,
|
||||||
|
weight: isHighlighting ? (isActive ? 5 : 2) : 3,
|
||||||
|
opacity: isHighlighting ? (isActive ? 1 : 0.3) : 0.8,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,77 @@
|
||||||
import type { Route } from "./+types/api.routes.$id.gpx";
|
import type { Route } from "./+types/api.routes.$id.gpx";
|
||||||
import { getRouteWithVersions } from "~/lib/routes.server";
|
import { getRouteWithVersions } from "~/lib/routes.server";
|
||||||
|
import { parseGpxAsync, computeDays, generateGpx } from "@trails-cool/gpx";
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const route = await getRouteWithVersions(params.id);
|
const route = await getRouteWithVersions(params.id);
|
||||||
if (!route?.gpx) {
|
if (!route?.gpx) {
|
||||||
return new Response("No GPX data", { status: 404 });
|
return new Response("No GPX data", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(route.gpx, {
|
const url = new URL(request.url);
|
||||||
headers: {
|
const dayParam = url.searchParams.get("day");
|
||||||
"Content-Type": "application/gpx+xml",
|
|
||||||
"Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`,
|
if (!dayParam) {
|
||||||
},
|
return new Response(route.gpx, {
|
||||||
});
|
headers: {
|
||||||
|
"Content-Type": "application/gpx+xml",
|
||||||
|
"Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a single day's segment
|
||||||
|
const dayNumber = parseInt(dayParam, 10);
|
||||||
|
if (isNaN(dayNumber) || dayNumber < 1) {
|
||||||
|
return new Response("Invalid day number", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gpxData = await parseGpxAsync(route.gpx);
|
||||||
|
const days = computeDays(gpxData.waypoints, gpxData.tracks);
|
||||||
|
const day = days.find((d) => d.dayNumber === dayNumber);
|
||||||
|
if (!day) {
|
||||||
|
return new Response("Day not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract track points for this day by matching waypoint positions to track
|
||||||
|
const allPoints = gpxData.tracks.flat();
|
||||||
|
const startWp = gpxData.waypoints[day.startWaypointIndex]!;
|
||||||
|
const endWp = gpxData.waypoints[day.endWaypointIndex]!;
|
||||||
|
|
||||||
|
const findClosest = (wp: { lat: number; lon: number }) => {
|
||||||
|
let bestIdx = 0;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (let i = 0; i < allPoints.length; i++) {
|
||||||
|
const dx = allPoints[i]!.lat - wp.lat;
|
||||||
|
const dy = allPoints[i]!.lon - wp.lon;
|
||||||
|
const d = dx * dx + dy * dy;
|
||||||
|
if (d < bestDist) { bestDist = d; bestIdx = i; }
|
||||||
|
}
|
||||||
|
return bestIdx;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startIdx = findClosest(startWp);
|
||||||
|
const endIdx = findClosest(endWp);
|
||||||
|
const dayPoints = allPoints.slice(startIdx, endIdx + 1);
|
||||||
|
|
||||||
|
const dayName = day.startName && day.endName
|
||||||
|
? `Day ${dayNumber}: ${day.startName} - ${day.endName}`
|
||||||
|
: `Day ${dayNumber}`;
|
||||||
|
|
||||||
|
const dayGpx = generateGpx({
|
||||||
|
name: dayName,
|
||||||
|
tracks: [dayPoints],
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `${route.name.replace(/[^a-z0-9]/gi, "_")}_day${dayNumber}.gpx`;
|
||||||
|
return new Response(dayGpx, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/gpx+xml",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response("Failed to extract day segment", { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { route, dayStats, versions, isOwner } = loaderData;
|
const { route, dayStats, versions, isOwner } = loaderData;
|
||||||
const { t } = useTranslation("journal");
|
const { t } = useTranslation("journal");
|
||||||
const [editLoading, setEditLoading] = useState(false);
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
const [highlightedDay, setHighlightedDay] = useState<number | null>(null);
|
||||||
|
|
||||||
const handleEditInPlanner = useCallback(async () => {
|
const handleEditInPlanner = useCallback(async () => {
|
||||||
setEditLoading(true);
|
setEditLoading(true);
|
||||||
|
|
@ -176,7 +177,12 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t("routes.dayBreakdown")}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t("routes.dayBreakdown")}</h2>
|
||||||
<div className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
|
<div className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
|
||||||
{dayStats.map((day) => (
|
{dayStats.map((day) => (
|
||||||
<div key={day.dayNumber} className="flex items-center gap-4 px-4 py-3">
|
<div
|
||||||
|
key={day.dayNumber}
|
||||||
|
className="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||||
|
onMouseEnter={() => setHighlightedDay(day.dayNumber)}
|
||||||
|
onMouseLeave={() => setHighlightedDay(null)}
|
||||||
|
>
|
||||||
<span className="text-sm font-medium text-gray-700">
|
<span className="text-sm font-medium text-gray-700">
|
||||||
{t("routes.dayLabel", { n: day.dayNumber })}
|
{t("routes.dayLabel", { n: day.dayNumber })}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -194,6 +200,16 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
↓{day.descent} m
|
↓{day.descent} m
|
||||||
</span>
|
</span>
|
||||||
|
{route.hasGpx && (
|
||||||
|
<a
|
||||||
|
href={`/api/routes/${route.id}/gpx?day=${day.dayNumber}`}
|
||||||
|
download
|
||||||
|
className="shrink-0 rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
title={t("routes.exportGpx")}
|
||||||
|
>
|
||||||
|
GPX
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -202,7 +218,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
|
||||||
{route.geojson && (
|
{route.geojson && (
|
||||||
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
|
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
|
||||||
<ClientMap geojson={route.geojson} interactive className="h-full w-full" dayBreaks={route.dayBreaks.length > 0 ? route.dayBreaks : undefined} />
|
<ClientMap geojson={route.geojson} interactive className="h-full w-full" dayBreaks={route.dayBreaks.length > 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue