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>
99 lines
2.9 KiB
TypeScript
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>
|
|
);
|
|
}
|