route-surface-breakdown (Phase 1): Planner-path surface/waytype bars
Synchronous path + rendering for the surface/waytype breakdown: - map-core `computeSurfaceBreakdown(coords, surfaces, highways)` → distance- weighted metres per surface + waytype category (unit-tested); - `SurfaceBreakdownSchema` in @trails-cool/api; - nullable `surfaceBreakdown` jsonb on routes + activities; - Planner `SaveToJournalButton` computes it from the BRouter waytags already in routeData and sends it; `api.save-to-journal` forwards it; the journal route callback validates + persists (journal is the authoritative validator); - `SurfaceBreakdown` component (stacked bars per dimension, map-core palettes, legend category · % · km largest-first, unknown → "other", hidden when empty) on route + activity detail; journal gains a @trails-cool/map-core dep; - i18n journal.surface.* (en + de). Phase 2 (async Overpass backfill + SSE for imports/uploads, and the e2e that seeds a breakdown) follows in a separate PR. Tests: computeSurfaceBreakdown unit (map-core 36); SurfaceBreakdown component (jsdom, journal 326). typecheck + lint green; verified in the browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e1e3dc5c81
commit
3a1c34317d
21 changed files with 388 additions and 18 deletions
40
apps/journal/app/components/SurfaceBreakdown.test.tsx
Normal file
40
apps/journal/app/components/SurfaceBreakdown.test.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { SurfaceBreakdown } from "./SurfaceBreakdown.tsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("SurfaceBreakdown", () => {
|
||||
it("renders nothing without a breakdown", () => {
|
||||
const { container } = render(<SurfaceBreakdown breakdown={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when every bucket is zero", () => {
|
||||
const { container } = render(<SurfaceBreakdown breakdown={{ surface: { asphalt: 0 }, highway: {} }} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a legend item per non-zero category with its percentage", () => {
|
||||
const { container, getByText } = render(
|
||||
<SurfaceBreakdown breakdown={{ surface: { asphalt: 6000, gravel: 4000 }, highway: { residential: 10000 } }} />,
|
||||
);
|
||||
// 2 surface + 1 waytype = 3 legend entries
|
||||
expect(container.querySelectorAll("li")).toHaveLength(3);
|
||||
expect(getByText(/60% · 6\.0 km/)).toBeTruthy();
|
||||
expect(getByText(/40% · 4\.0 km/)).toBeTruthy();
|
||||
expect(getByText(/100% · 10\.0 km/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sorts segments largest-first within a dimension", () => {
|
||||
const { container } = render(
|
||||
<SurfaceBreakdown breakdown={{ surface: { gravel: 3000, asphalt: 7000 }, highway: {} }} />,
|
||||
);
|
||||
// first bar's first segment is the larger (asphalt 70%)
|
||||
const firstBar = container.querySelector("div.flex.h-3");
|
||||
const widths = [...firstBar!.querySelectorAll("div")].map((d) => (d as HTMLElement).style.width);
|
||||
expect(widths[0]).toBe("70%");
|
||||
expect(widths[1]).toBe("30%");
|
||||
});
|
||||
});
|
||||
99
apps/journal/app/components/SurfaceBreakdown.tsx
Normal file
99
apps/journal/app/components/SurfaceBreakdown.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
SURFACE_COLORS,
|
||||
DEFAULT_SURFACE_COLOR,
|
||||
HIGHWAY_COLORS,
|
||||
DEFAULT_HIGHWAY_COLOR,
|
||||
} from "@trails-cool/map-core";
|
||||
import { formatDistanceKm } from "~/lib/stats";
|
||||
|
||||
export interface SurfaceBreakdownData {
|
||||
surface: Record<string, number>;
|
||||
highway: Record<string, number>;
|
||||
}
|
||||
|
||||
function Bar({
|
||||
title,
|
||||
data,
|
||||
colorFor,
|
||||
}: {
|
||||
title: string;
|
||||
data: Record<string, number>;
|
||||
colorFor: (category: string) => string;
|
||||
}) {
|
||||
const { t } = useTranslation("journal");
|
||||
const entries = Object.entries(data)
|
||||
.filter(([, m]) => m > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
const total = entries.reduce((s, [, m]) => s + m, 0);
|
||||
if (total <= 0) return null;
|
||||
|
||||
const label = (cat: string) =>
|
||||
cat === "unknown"
|
||||
? t("surface.other")
|
||||
: t(`surface.cat.${cat}`, { defaultValue: cat.replace(/_/g, " ") });
|
||||
|
||||
return (
|
||||
<div className="mt-3 first:mt-0">
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{title}</p>
|
||||
<div className="flex h-3 w-full overflow-hidden rounded">
|
||||
{entries.map(([cat, m]) => (
|
||||
<div
|
||||
key={cat}
|
||||
style={{ width: `${(m / total) * 100}%`, backgroundColor: colorFor(cat) }}
|
||||
title={`${label(cat)} · ${formatDistanceKm(m)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
{entries.map(([cat, m]) => (
|
||||
<li key={cat} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: colorFor(cat) }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{label(cat)}</span>
|
||||
<span className="tabular-nums text-gray-400">
|
||||
{Math.round((m / total) * 100)}% · {formatDistanceKm(m)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface + waytype proportion bars (route-surface-breakdown). Renders nothing
|
||||
* when there's no breakdown data. Colours come from the shared map-core
|
||||
* palettes; unknown tags collapse into "other".
|
||||
*/
|
||||
export function SurfaceBreakdown({
|
||||
breakdown,
|
||||
className,
|
||||
}: {
|
||||
breakdown: SurfaceBreakdownData | null | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("journal");
|
||||
if (!breakdown) return null;
|
||||
const hasSurface = Object.values(breakdown.surface).some((m) => m > 0);
|
||||
const hasHighway = Object.values(breakdown.highway).some((m) => m > 0);
|
||||
if (!hasSurface && !hasHighway) return null;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Bar
|
||||
title={t("surface.surface")}
|
||||
data={breakdown.surface}
|
||||
colorFor={(c) => SURFACE_COLORS[c] ?? DEFAULT_SURFACE_COLOR}
|
||||
/>
|
||||
<Bar
|
||||
title={t("surface.waytype")}
|
||||
data={breakdown.highway}
|
||||
colorFor={(c) => HIGHWAY_COLORS[c] ?? DEFAULT_HIGHWAY_COLOR}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||
import { eq, desc, and, inArray, isNotNull } from "drizzle-orm";
|
||||
import { getDb } from "./db.ts";
|
||||
import { routes, routeVersions } from "@trails-cool/db/schema/journal";
|
||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
||||
import type { Visibility, SurfaceBreakdown } from "@trails-cool/db/schema/journal";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { processGpx, writeGeom } from "./gpx-save.server.ts";
|
||||
import type { ProcessedGpx } from "./gpx-save.server.ts";
|
||||
|
|
@ -20,6 +20,7 @@ export interface RouteInput {
|
|||
elevationLoss?: number | null;
|
||||
dayBreaks?: number[];
|
||||
synthetic?: boolean;
|
||||
surfaceBreakdown?: SurfaceBreakdown;
|
||||
}
|
||||
|
||||
export async function createRoute(ownerId: string, input: RouteInput) {
|
||||
|
|
@ -139,6 +140,7 @@ export async function updateRoute(route: OwnedRef, input: Partial<RouteInput>) {
|
|||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.description !== undefined) updateData.description = input.description;
|
||||
if (input.visibility !== undefined) updateData.visibility = input.visibility;
|
||||
if (input.surfaceBreakdown !== undefined) updateData.surfaceBreakdown = input.surfaceBreakdown;
|
||||
|
||||
if (input.gpx && processed) {
|
||||
const { stats } = processed;
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export async function loadActivityDetail(request: Request, id: string | undefine
|
|||
duration: activity.duration,
|
||||
movingTimeSec,
|
||||
elevation,
|
||||
surfaceBreakdown: activity.surfaceBreakdown ?? null,
|
||||
routeId: activity.routeId,
|
||||
hasGpx: !!activity.gpx,
|
||||
geojson: activity.geojson ?? null,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ClientMap } from "~/components/ClientMap";
|
|||
import { SportBadge } from "~/components/SportBadge";
|
||||
import { StatRow } from "~/components/StatRow";
|
||||
import { ElevationProfile } from "~/components/ElevationProfile";
|
||||
import { SurfaceBreakdown } from "~/components/SurfaceBreakdown";
|
||||
import { activityStatItems } from "~/lib/stats";
|
||||
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
|
||||
import {
|
||||
|
|
@ -142,6 +143,8 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
|
|||
/>
|
||||
)}
|
||||
|
||||
<SurfaceBreakdown breakdown={activity.surfaceBreakdown} className="mt-6" />
|
||||
|
||||
{activity.routeId && (
|
||||
<div className="mt-6">
|
||||
<a href={`/routes/${activity.routeId}`} className="text-blue-600 hover:underline">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { data } from "react-router";
|
||||
import { SurfaceBreakdownSchema } from "@trails-cool/api";
|
||||
import type { Route } from "./+types/api.routes.$id.callback";
|
||||
import { verifyRouteToken } from "~/lib/jwt.server";
|
||||
import { updateRoute, getRoute } from "~/lib/routes.server";
|
||||
|
|
@ -56,16 +57,23 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||
|
||||
// Parse GPX from request body
|
||||
const body = await request.json();
|
||||
const { gpx } = body as { gpx: string };
|
||||
const { gpx, surfaceBreakdown } = body as { gpx: string; surfaceBreakdown?: unknown };
|
||||
|
||||
if (!gpx) {
|
||||
return data({ error: "Missing GPX data" }, { status: 400, headers: corsHeaders() });
|
||||
}
|
||||
|
||||
// Optional, best-effort surface/waytype breakdown from the Planner's BRouter
|
||||
// waytags (route-surface-breakdown, Path 1) — stored if well-formed, ignored otherwise.
|
||||
const sb = SurfaceBreakdownSchema.safeParse(surfaceBreakdown);
|
||||
|
||||
// Update route with new GPX (creates new version). Authorization
|
||||
// here comes from the verified single-use route token, not a
|
||||
// session — vouchOwnership marks that explicitly.
|
||||
await updateRoute(vouchOwnership(route), { gpx });
|
||||
await updateRoute(vouchOwnership(route), {
|
||||
gpx,
|
||||
...(sb.success ? { surfaceBreakdown: sb.data } : {}),
|
||||
});
|
||||
|
||||
return data({ success: true, routeId: params.id }, { headers: corsHeaders() });
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ export async function loadRouteDetail(request: Request, id: string | undefined)
|
|||
hasGpx: !!route.gpx,
|
||||
dayBreaks: route.dayBreaks ?? [],
|
||||
elevation,
|
||||
surfaceBreakdown: route.surfaceBreakdown ?? null,
|
||||
geojson: routeWithGeojson?.geojson ?? null,
|
||||
visibility: route.visibility,
|
||||
createdAt: route.createdAt.toISOString(),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ClientDate } from "~/components/ClientDate";
|
|||
import { ClientMap } from "~/components/ClientMap";
|
||||
import { StatRow } from "~/components/StatRow";
|
||||
import { ElevationProfile } from "~/components/ElevationProfile";
|
||||
import { SurfaceBreakdown } from "~/components/SurfaceBreakdown";
|
||||
import { activityStatItems } from "~/lib/stats";
|
||||
import { loadRouteDetail, routeDetailAction } from "./routes.$id.server";
|
||||
|
||||
|
|
@ -332,6 +333,8 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<SurfaceBreakdown breakdown={route.surfaceBreakdown} className="mt-6" />
|
||||
|
||||
{isEmpty && (
|
||||
<div className="mt-6 flex flex-col items-center rounded-lg border border-dashed border-gray-300 bg-gray-50 px-6 py-12 text-center">
|
||||
<div className="text-4xl" aria-hidden="true">🗺️</div>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
"@trails-cool/jobs": "workspace:*",
|
||||
"@trails-cool/map-core": "workspace:*",
|
||||
"@trails-cool/sentry-config": "workspace:*",
|
||||
"@trails-cool/types": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue