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:",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { computeSurfaceBreakdown } from "@trails-cool/map-core";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { buildPlanGpx } from "~/lib/gpx-export";
|
||||
import { readComputedRoute } from "~/lib/route-data";
|
||||
|
||||
interface SaveToJournalButtonProps {
|
||||
yjs: YjsState;
|
||||
|
|
@ -24,6 +26,19 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal
|
|||
// route round-trips correctly through the journal.
|
||||
const gpx = buildPlanGpx(yjs);
|
||||
|
||||
// Distance-weighted surface/waytype breakdown from the BRouter waytags
|
||||
// already in routeData — sent alongside the GPX so the journal can show
|
||||
// it without re-deriving (route-surface-breakdown, Path 1).
|
||||
const route = readComputedRoute(yjs.routeData);
|
||||
const breakdown =
|
||||
route.coordinates && route.coordinates.length > 1
|
||||
? computeSurfaceBreakdown(route.coordinates, route.surfaces, route.highways)
|
||||
: null;
|
||||
const surfaceBreakdown =
|
||||
breakdown && (Object.keys(breakdown.surface).length > 0 || Object.keys(breakdown.highway).length > 0)
|
||||
? breakdown
|
||||
: undefined;
|
||||
|
||||
// POST to the planner's server-side proxy. The proxy attaches the
|
||||
// journal Bearer token (stored on the session row) and forwards
|
||||
// the GPX. Token never leaves the planner server — see
|
||||
|
|
@ -31,7 +46,7 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal
|
|||
const response = await fetch("/api/save-to-journal", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId, gpx }),
|
||||
body: JSON.stringify({ sessionId, gpx, ...(surfaceBreakdown ? { surfaceBreakdown } : {}) }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { validateFetchUrl, getCallbackAllowedHosts } from "~/lib/url-validation.
|
|||
interface SaveRequestBody {
|
||||
sessionId?: unknown;
|
||||
gpx?: unknown;
|
||||
surfaceBreakdown?: unknown;
|
||||
}
|
||||
|
||||
const MAX_GPX_BYTES = 5 * 1024 * 1024; // 5 MB — same ceiling as the Yjs doc cap
|
||||
|
|
@ -39,6 +40,12 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
|
||||
const sessionId = typeof body.sessionId === "string" ? body.sessionId : "";
|
||||
const gpx = typeof body.gpx === "string" ? body.gpx : "";
|
||||
// Optional, best-effort decoration — forwarded as-is; the journal callback is
|
||||
// the authoritative validator (planner doesn't depend on @trails-cool/api).
|
||||
const surfaceBreakdown =
|
||||
body.surfaceBreakdown && typeof body.surfaceBreakdown === "object"
|
||||
? body.surfaceBreakdown
|
||||
: undefined;
|
||||
|
||||
if (!sessionId) return data({ error: "sessionId required" }, { status: 400 });
|
||||
if (!gpx) return data({ error: "gpx required" }, { status: 400 });
|
||||
|
|
@ -69,7 +76,7 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session.callbackToken}`,
|
||||
},
|
||||
body: JSON.stringify({ gpx }),
|
||||
body: JSON.stringify({ gpx, ...(surfaceBreakdown ? { surfaceBreakdown } : {}) }),
|
||||
});
|
||||
} catch {
|
||||
return data({ error: "journal unreachable" }, { status: 502 });
|
||||
|
|
|
|||
|
|
@ -1,24 +1,27 @@
|
|||
<!-- PHASE 1 (this PR): synchronous Planner path + bars. PHASE 2 (follow-up):
|
||||
async Overpass backfill + SSE (sections 5–6, e2e in 7.3). -->
|
||||
|
||||
## 1. Shared derivation + shape
|
||||
|
||||
- [ ] 1.1 Pure `surfaceBreakdown(coordinates, surfaces, highways)` helper: haversine-weight each segment into per-surface + per-waytype buckets → `{ surface: Record<string, number>, highway: Record<string, number> }` (metres). Unit-test.
|
||||
- [ ] 1.2 `SurfaceBreakdownSchema` (Zod) in `@trails-cool/api` (record of category → metres ≥ 0, surface + highway).
|
||||
- [x] 1.1 `computeSurfaceBreakdown(coordinates, surfaces, highways)` in `@trails-cool/map-core` → `{ surface, highway }` metres; unit-tested.
|
||||
- [x] 1.2 `SurfaceBreakdownSchema` (Zod) in `@trails-cool/api`.
|
||||
|
||||
## 2. Schema
|
||||
|
||||
- [ ] 2.1 Nullable `surfaceBreakdown` jsonb on `journal.routes` **and** `journal.activities`; `pnpm db:push`.
|
||||
- [x] 2.1 Nullable `surfaceBreakdown` jsonb on `journal.routes` **and** `journal.activities`; pushed to dev + e2e DBs.
|
||||
|
||||
## 3. Path 1 — synchronous (Planner routes)
|
||||
|
||||
- [ ] 3.1 `apps/planner/.../api.save-to-journal`: compute the breakdown from the session `EnrichedRoute` (coords + surfaces + highways) and add `surfaceBreakdown` to the POST body.
|
||||
- [ ] 3.2 Journal route callback validates the optional field and persists it (overwrite on re-save).
|
||||
- [x] 3.1 `SaveToJournalButton` computes the breakdown from `readComputedRoute` and sends it; `api.save-to-journal` forwards it (journal is the authoritative validator — planner has no `@trails-cool/api` dep).
|
||||
- [x] 3.2 Journal route callback validates the optional field with `SurfaceBreakdownSchema` and persists via `updateRoute` (overwrite on re-save).
|
||||
|
||||
## 4. Render
|
||||
|
||||
- [ ] 4.1 Route + activity detail loaders expose `surfaceBreakdown`.
|
||||
- [ ] 4.2 `SurfaceBreakdown` component: stacked bar per dimension (surface, waytype), `map-core` `SURFACE_COLORS`/`HIGHWAY_COLORS` (DEFAULT → "other"), legend category · km · % (largest first), hidden when empty. Mount on `routes.$id.tsx` and `activities.$id.tsx`.
|
||||
- [ ] 4.3 i18n `journal.surface.*` (label + category names + "other") en + de.
|
||||
- [x] 4.1 Route + activity detail loaders expose `surfaceBreakdown`.
|
||||
- [x] 4.2 `SurfaceBreakdown` component (stacked bar per dimension, `map-core` palettes, legend category · % · km largest-first, unknown → "other", hidden when empty); mounted on `routes.$id.tsx` and `activities.$id.tsx`.
|
||||
- [x] 4.3 i18n `journal.surface.*` (surface/waytype labels, common categories, "other") en + de.
|
||||
|
||||
## 5. Path 2 — async backfill + SSE
|
||||
## 5. Path 2 — async backfill + SSE (PHASE 2 — follow-up PR)
|
||||
|
||||
- [ ] 5.1 Journal-side Overpass client: `way[highway]` in bbox + parse (mirror the planner's `buildQuery`/`parseResponse` + rate-limit handling; route via the `/api/overpass` proxy).
|
||||
- [ ] 5.2 Map-match helper: nearest OSM way per route segment → surface/highway; unmatched → unknown. Unit-test the matcher on a small fixture.
|
||||
|
|
@ -32,7 +35,7 @@
|
|||
|
||||
## 7. Tests & checks
|
||||
|
||||
- [ ] 7.1 Unit: `surfaceBreakdown` weighting; map-matcher nearest-way + unknown bucket.
|
||||
- [ ] 7.2 Component (jsdom): bars/proportions, "other" bucket, hidden when empty.
|
||||
- [ ] 7.3 E2E: a route/activity with a stored breakdown shows the bars (seed the breakdown to avoid a live Overpass call in CI).
|
||||
- [ ] 7.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.
|
||||
- [x] 7.1 Unit: `computeSurfaceBreakdown` weighting + unknown bucket (map-matcher unit is Phase 2).
|
||||
- [x] 7.2 Component (jsdom): bars/proportions, largest-first, hidden when empty/null.
|
||||
- [ ] 7.3 E2E: a route/activity with a stored breakdown shows the bars — **Phase 2** (needs a seed path; deferred to land with the backfill that creates the data). Phase 1 render verified in the browser + component test.
|
||||
- [x] 7.4 Phase 1: typecheck + lint + unit (map-core 36, journal 326) green; verified in the browser.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ export {
|
|||
type ComputeRouteRequest,
|
||||
} from "./routes.ts";
|
||||
|
||||
// Surface / waytype breakdown
|
||||
export { SurfaceBreakdownSchema, type SurfaceBreakdown } from "./surface-breakdown.ts";
|
||||
|
||||
// Activities
|
||||
export {
|
||||
ActivitySummarySchema, ActivityDetailSchema,
|
||||
|
|
|
|||
15
packages/api/src/surface-breakdown.ts
Normal file
15
packages/api/src/surface-breakdown.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Distance-weighted surface/waytype breakdown (metres per category). Shared
|
||||
* wire shape for the planner→journal handoff and the route/activity read APIs.
|
||||
* Mirrors `SurfaceBreakdown` in `@trails-cool/map-core`.
|
||||
*/
|
||||
const MetresByCategory = z.record(z.string(), z.number().min(0));
|
||||
|
||||
export const SurfaceBreakdownSchema = z.object({
|
||||
surface: MetresByCategory,
|
||||
highway: MetresByCategory,
|
||||
});
|
||||
|
||||
export type SurfaceBreakdown = z.infer<typeof SurfaceBreakdownSchema>;
|
||||
|
|
@ -91,6 +91,15 @@ export const magicTokens = journalSchema.table("magic_tokens", {
|
|||
*/
|
||||
export type Visibility = "private" | "unlisted" | "public";
|
||||
|
||||
/**
|
||||
* Distance-weighted surface/waytype breakdown (metres per category), derived
|
||||
* from BRouter waytags at Planner save or backfilled via Overpass.
|
||||
*/
|
||||
export type SurfaceBreakdown = {
|
||||
surface: Record<string, number>;
|
||||
highway: Record<string, number>;
|
||||
};
|
||||
|
||||
export const routes = journalSchema.table("routes", {
|
||||
id: text("id").primaryKey(),
|
||||
ownerId: text("owner_id")
|
||||
|
|
@ -106,6 +115,7 @@ export const routes = journalSchema.table("routes", {
|
|||
elevationLoss: real("elevation_loss"),
|
||||
dayBreaks: jsonb("day_breaks").$type<number[]>(),
|
||||
tags: jsonb("tags").$type<string[]>(),
|
||||
surfaceBreakdown: jsonb("surface_breakdown").$type<SurfaceBreakdown>(),
|
||||
plannerState: bytea("planner_state"),
|
||||
visibility: text("visibility").$type<Visibility>().notNull().default("private"),
|
||||
/**
|
||||
|
|
@ -173,6 +183,9 @@ export const activities = journalSchema.table("activities", {
|
|||
description: text("description").default(""),
|
||||
// Sport / activity type (NULL = unspecified). See SPORT_TYPES above.
|
||||
sportType: text("sport_type").$type<SportType>(),
|
||||
// Distance-weighted surface/waytype breakdown (backfilled via Overpass for
|
||||
// imported/uploaded activities). NULL until derived.
|
||||
surfaceBreakdown: jsonb("surface_breakdown").$type<SurfaceBreakdown>(),
|
||||
gpx: text("gpx"),
|
||||
geom: lineString("geom"),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
|
|
|
|||
|
|
@ -418,6 +418,38 @@ export default {
|
|||
weeklyDistance: "Wochendistanz (letzte 12 Wochen)",
|
||||
weekOf: "Woche vom {{date}}",
|
||||
},
|
||||
surface: {
|
||||
surface: "Oberfläche",
|
||||
waytype: "Wegtyp",
|
||||
other: "Sonstige",
|
||||
cat: {
|
||||
asphalt: "Asphalt",
|
||||
paved: "Befestigt",
|
||||
concrete: "Beton",
|
||||
paving_stones: "Pflastersteine",
|
||||
cobblestone: "Kopfsteinpflaster",
|
||||
gravel: "Schotter",
|
||||
fine_gravel: "Feinschotter",
|
||||
compacted: "Verdichtet",
|
||||
ground: "Naturboden",
|
||||
dirt: "Erde",
|
||||
grass: "Gras",
|
||||
sand: "Sand",
|
||||
unpaved: "Unbefestigt",
|
||||
path: "Pfad",
|
||||
track: "Wirtschaftsweg",
|
||||
residential: "Wohnstraße",
|
||||
cycleway: "Radweg",
|
||||
footway: "Fußweg",
|
||||
primary: "Hauptstraße",
|
||||
secondary: "Landstraße",
|
||||
tertiary: "Nebenstraße",
|
||||
service: "Erschließungsweg",
|
||||
unclassified: "Kleine Straße",
|
||||
bridleway: "Reitweg",
|
||||
steps: "Treppe",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Einstellungen",
|
||||
nav: {
|
||||
|
|
|
|||
|
|
@ -418,6 +418,38 @@ export default {
|
|||
weeklyDistance: "Weekly distance (last 12 weeks)",
|
||||
weekOf: "Week of {{date}}",
|
||||
},
|
||||
surface: {
|
||||
surface: "Surface",
|
||||
waytype: "Way type",
|
||||
other: "other",
|
||||
cat: {
|
||||
asphalt: "Asphalt",
|
||||
paved: "Paved",
|
||||
concrete: "Concrete",
|
||||
paving_stones: "Paving stones",
|
||||
cobblestone: "Cobblestone",
|
||||
gravel: "Gravel",
|
||||
fine_gravel: "Fine gravel",
|
||||
compacted: "Compacted",
|
||||
ground: "Ground",
|
||||
dirt: "Dirt",
|
||||
grass: "Grass",
|
||||
sand: "Sand",
|
||||
unpaved: "Unpaved",
|
||||
path: "Path",
|
||||
track: "Track",
|
||||
residential: "Residential road",
|
||||
cycleway: "Cycleway",
|
||||
footway: "Footway",
|
||||
primary: "Primary road",
|
||||
secondary: "Secondary road",
|
||||
tertiary: "Tertiary road",
|
||||
service: "Service road",
|
||||
unclassified: "Minor road",
|
||||
bridleway: "Bridleway",
|
||||
steps: "Steps",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
nav: {
|
||||
|
|
|
|||
|
|
@ -21,3 +21,6 @@ export {
|
|||
} from "./z-index.ts";
|
||||
|
||||
export { SNAP_DISTANCE_METERS } from "./snap.ts";
|
||||
|
||||
export { computeSurfaceBreakdown } from "./surface-breakdown.ts";
|
||||
export type { SurfaceBreakdown } from "./surface-breakdown.ts";
|
||||
|
|
|
|||
34
packages/map-core/src/surface-breakdown.test.ts
Normal file
34
packages/map-core/src/surface-breakdown.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { computeSurfaceBreakdown } from "./surface-breakdown.ts";
|
||||
|
||||
// ~0.001° lon at the equator ≈ 111 m; exact value doesn't matter, only ratios.
|
||||
const coords: [number, number][] = [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
[0.002, 0],
|
||||
[0.003, 0],
|
||||
];
|
||||
|
||||
describe("computeSurfaceBreakdown", () => {
|
||||
it("weights segments by distance into surface and waytype buckets", () => {
|
||||
const { surface, highway } = computeSurfaceBreakdown(
|
||||
coords,
|
||||
["asphalt", "asphalt", "gravel"],
|
||||
["residential", "residential", "track"],
|
||||
);
|
||||
// segments 0,1 asphalt; segment 2 gravel — equal lengths → 2:1
|
||||
expect(surface.asphalt! / surface.gravel!).toBeCloseTo(2, 5);
|
||||
expect(highway.residential! / highway.track!).toBeCloseTo(2, 5);
|
||||
expect(Object.keys(surface)).toEqual(["asphalt", "gravel"]);
|
||||
});
|
||||
|
||||
it("buckets missing/empty values as unknown", () => {
|
||||
const { surface } = computeSurfaceBreakdown(coords, ["asphalt", "", "asphalt"], ["", "", ""]);
|
||||
expect(surface.asphalt).toBeGreaterThan(0);
|
||||
expect(surface.unknown).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty buckets for a degenerate track", () => {
|
||||
expect(computeSurfaceBreakdown([[0, 0]], [], [])).toEqual({ surface: {}, highway: {} });
|
||||
});
|
||||
});
|
||||
52
packages/map-core/src/surface-breakdown.ts
Normal file
52
packages/map-core/src/surface-breakdown.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Distance-weighted surface / waytype breakdown for a route. Both derivation
|
||||
* paths (BRouter waytags at Planner save, and the async Overpass backfill) feed
|
||||
* their per-segment `surfaces`/`highways` arrays through this to produce the
|
||||
* same compact summary that gets stored and rendered.
|
||||
*/
|
||||
export interface SurfaceBreakdown {
|
||||
/** metres per surface category (e.g. asphalt, gravel, path) */
|
||||
surface: Record<string, number>;
|
||||
/** metres per waytype/highway category */
|
||||
highway: Record<string, number>;
|
||||
}
|
||||
|
||||
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000;
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum each coordinate segment's length into its surface and waytype buckets.
|
||||
* `coordinates` are `[lon, lat, ...]` (GeoJSON axis order); `surfaces[i]` /
|
||||
* `highways[i]` describe the segment from point `i` to `i+1`. Missing/empty
|
||||
* values bucket as `"unknown"`. Returns metres per category.
|
||||
*/
|
||||
export function computeSurfaceBreakdown(
|
||||
coordinates: ReadonlyArray<readonly number[]>,
|
||||
surfaces: readonly string[],
|
||||
highways: readonly string[],
|
||||
): SurfaceBreakdown {
|
||||
const surface: Record<string, number> = {};
|
||||
const highway: Record<string, number> = {};
|
||||
|
||||
for (let i = 0; i < coordinates.length - 1; i++) {
|
||||
const a = coordinates[i];
|
||||
const b = coordinates[i + 1];
|
||||
if (!a || !b || a.length < 2 || b.length < 2) continue;
|
||||
const d = haversineMeters(a[1]!, a[0]!, b[1]!, b[0]!);
|
||||
if (!(d > 0)) continue;
|
||||
const s = surfaces[i] || "unknown";
|
||||
const h = highways[i] || "unknown";
|
||||
surface[s] = (surface[s] ?? 0) + d;
|
||||
highway[h] = (highway[h] ?? 0) + d;
|
||||
}
|
||||
|
||||
return { surface, highway };
|
||||
}
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -241,6 +241,9 @@ importers:
|
|||
'@trails-cool/jobs':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/jobs
|
||||
'@trails-cool/map-core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/map-core
|
||||
'@trails-cool/sentry-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/sentry-config
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue