trails/apps/journal/app/components/SurfaceBreakdown.tsx
Ullrich Schäfer 3a1c34317d
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>
2026-06-14 18:46:49 +02:00

99 lines
2.9 KiB
TypeScript

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>
);
}