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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue