Bring the chart's chrome onto the design system (spec task 5.1): - Plain mode: sage accent line + soft accent→transparent gradient fill (was off-palette blue). - Hover: calm near-black crosshair + accent dot with a light ring, mono label (was red). - Axis + day-divider labels: token colors, Geist Mono; dividers use the border-md token. - Drag-select: accent tint (was blue). Per-mode data-viz palettes (grade, surface, …) are unchanged. Darwin visual-regression baselines regenerated; linux baselines to follow via the update-snapshots workflow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import {
|
|
elevationColor,
|
|
maxspeedColor,
|
|
SURFACE_COLORS,
|
|
DEFAULT_SURFACE_COLOR,
|
|
HIGHWAY_COLORS,
|
|
DEFAULT_HIGHWAY_COLOR,
|
|
SMOOTHNESS_COLORS,
|
|
DEFAULT_SMOOTHNESS_COLOR,
|
|
TRACKTYPE_COLORS,
|
|
DEFAULT_TRACKTYPE_COLOR,
|
|
CYCLEWAY_COLORS,
|
|
DEFAULT_CYCLEWAY_COLOR,
|
|
BIKEROUTE_COLORS,
|
|
DEFAULT_BIKEROUTE_COLOR,
|
|
} from "@trails-cool/map-core";
|
|
import type { ColorMode } from "~/components/ColoredRoute";
|
|
import type { DayStage } from "@trails-cool/gpx";
|
|
|
|
export interface ElevationPoint {
|
|
distance: number;
|
|
elevation: number;
|
|
lat: number;
|
|
lon: number;
|
|
}
|
|
|
|
function gradeColor(grade: number): string {
|
|
const absGrade = Math.abs(grade);
|
|
if (absGrade < 3) return "#22c55e";
|
|
if (absGrade < 6) return "#eab308";
|
|
if (absGrade < 10) return "#f97316";
|
|
if (absGrade < 15) return "#ef4444";
|
|
return "#991b1b";
|
|
}
|
|
|
|
export const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
|
|
|
|
// Chart chrome palette — mirrors the @trails-cool/ui theme tokens (keep in
|
|
// sync with packages/ui/src/theme.css). Canvas can't read CSS vars cheaply,
|
|
// so the stable brand values live here. Per-mode data-viz palettes (grade,
|
|
// surface, …) stay in map-core.
|
|
const CHROME = {
|
|
accent: "#4a6b40", // --color-accent
|
|
accentFill: "rgba(74, 107, 64, 0.18)", // soft area under the line
|
|
axis: "#9a9484", // --color-text-lo
|
|
divider: "#cac6bc", // --color-border-md
|
|
hoverLine: "rgba(26, 25, 22, 0.35)", // --color-text-hi @ 35%
|
|
hoverText: "#1a1916", // --color-text-hi
|
|
ring: "#faf8f4", // --color-bg-raised (dot ring)
|
|
selectFill: "rgba(74, 107, 64, 0.12)",
|
|
selectStroke: "rgba(74, 107, 64, 0.5)",
|
|
};
|
|
const MONO = '600 10px "Geist Mono Variable", ui-monospace, monospace';
|
|
const MONO_SM = '9px "Geist Mono Variable", ui-monospace, monospace';
|
|
|
|
export interface DrawChartParams {
|
|
points: ElevationPoint[];
|
|
colorMode: ColorMode;
|
|
surfaces: string[];
|
|
highways: string[];
|
|
maxspeeds: string[];
|
|
smoothnesses: string[];
|
|
tracktypes: string[];
|
|
cycleways: string[];
|
|
bikeroutes: string[];
|
|
hoverIdx: number | null;
|
|
isDragging: boolean;
|
|
dragStartX: number | null;
|
|
dragCurrentX: number | null;
|
|
days?: DayStage[];
|
|
}
|
|
|
|
function drawSegments(
|
|
ctx: CanvasRenderingContext2D,
|
|
points: ElevationPoint[],
|
|
chartH: number,
|
|
getColor: (i: number) => string,
|
|
toX: (d: number) => number,
|
|
toY: (e: number) => number,
|
|
) {
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
const p0 = points[i]!;
|
|
const p1 = points[i + 1]!;
|
|
const color = getColor(i);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
|
|
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
|
|
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
|
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
|
|
ctx.closePath();
|
|
ctx.fillStyle = color + "40";
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
|
|
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
export function drawElevationChart(
|
|
ctx: CanvasRenderingContext2D,
|
|
width: number,
|
|
height: number,
|
|
params: DrawChartParams,
|
|
): void {
|
|
const {
|
|
points,
|
|
colorMode,
|
|
surfaces,
|
|
highways,
|
|
maxspeeds,
|
|
smoothnesses,
|
|
tracktypes,
|
|
cycleways,
|
|
bikeroutes,
|
|
hoverIdx,
|
|
isDragging,
|
|
dragStartX,
|
|
dragCurrentX,
|
|
days,
|
|
} = params;
|
|
|
|
if (points.length < 2) return;
|
|
|
|
const w = width;
|
|
const h = height;
|
|
const chartW = w - PADDING.left - PADDING.right;
|
|
const chartH = h - PADDING.top - PADDING.bottom;
|
|
|
|
const maxDist = points[points.length - 1]!.distance;
|
|
const elevations = points.map((p) => p.elevation);
|
|
const minEle = Math.min(...elevations);
|
|
const maxEle = Math.max(...elevations);
|
|
const eleRange = maxEle - minEle || 1;
|
|
|
|
const toX = (d: number) => PADDING.left + (d / maxDist) * chartW;
|
|
const toY = (e: number) => PADDING.top + chartH - ((e - minEle) / eleRange) * chartH;
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
if (colorMode === "grade") {
|
|
drawSegments(ctx, points, chartH, (i) => {
|
|
const p0 = points[i]!;
|
|
const p1 = points[i + 1]!;
|
|
const dDist = p1.distance - p0.distance;
|
|
const grade = dDist > 0 ? ((p1.elevation - p0.elevation) / dDist) * 100 : 0;
|
|
return gradeColor(grade);
|
|
}, toX, toY);
|
|
} else if (colorMode === "elevation") {
|
|
// elevationColor returns rgb(...), not hex, so we can't use hex alpha suffix
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
const p0 = points[i]!;
|
|
const p1 = points[i + 1]!;
|
|
const t = (p0.elevation - minEle) / eleRange;
|
|
const color = elevationColor(t);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
|
|
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
|
|
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
|
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
|
|
ctx.closePath();
|
|
ctx.fillStyle = color.replace("rgb", "rgba").replace(")", ", 0.2)");
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
|
|
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
}
|
|
} else if (colorMode === "surface" && surfaces.length >= points.length) {
|
|
drawSegments(ctx, points, chartH, (i) => SURFACE_COLORS[surfaces[i] ?? "unknown"] ?? DEFAULT_SURFACE_COLOR, toX, toY);
|
|
} else if (colorMode === "highway" && highways.length >= points.length) {
|
|
drawSegments(ctx, points, chartH, (i) => HIGHWAY_COLORS[highways[i] ?? "unknown"] ?? DEFAULT_HIGHWAY_COLOR, toX, toY);
|
|
} else if (colorMode === "maxspeed" && maxspeeds.length >= points.length) {
|
|
drawSegments(ctx, points, chartH, (i) => maxspeedColor(maxspeeds[i] ?? "unknown"), toX, toY);
|
|
} else if (colorMode === "smoothness" && smoothnesses.length >= points.length) {
|
|
drawSegments(ctx, points, chartH, (i) => SMOOTHNESS_COLORS[smoothnesses[i] ?? "unknown"] ?? DEFAULT_SMOOTHNESS_COLOR, toX, toY);
|
|
} else if (colorMode === "tracktype" && tracktypes.length >= points.length) {
|
|
drawSegments(ctx, points, chartH, (i) => TRACKTYPE_COLORS[tracktypes[i] ?? "unknown"] ?? DEFAULT_TRACKTYPE_COLOR, toX, toY);
|
|
} else if (colorMode === "cycleway" && cycleways.length >= points.length) {
|
|
drawSegments(ctx, points, chartH, (i) => CYCLEWAY_COLORS[cycleways[i] ?? "unknown"] ?? DEFAULT_CYCLEWAY_COLOR, toX, toY);
|
|
} else if (colorMode === "bikeroute" && bikeroutes.length >= points.length) {
|
|
drawSegments(ctx, points, chartH, (i) => BIKEROUTE_COLORS[bikeroutes[i] ?? "none"] ?? DEFAULT_BIKEROUTE_COLOR, toX, toY);
|
|
} else {
|
|
// Plain fill and line
|
|
ctx.beginPath();
|
|
ctx.moveTo(PADDING.left, PADDING.top + chartH);
|
|
for (const p of points) {
|
|
ctx.lineTo(toX(p.distance), toY(p.elevation));
|
|
}
|
|
ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH);
|
|
ctx.closePath();
|
|
const grad = ctx.createLinearGradient(0, PADDING.top, 0, PADDING.top + chartH);
|
|
grad.addColorStop(0, CHROME.accentFill);
|
|
grad.addColorStop(1, "rgba(74, 107, 64, 0.02)");
|
|
ctx.fillStyle = grad;
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
for (let i = 0; i < points.length; i++) {
|
|
const p = points[i]!;
|
|
if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation));
|
|
else ctx.lineTo(toX(p.distance), toY(p.elevation));
|
|
}
|
|
ctx.strokeStyle = CHROME.accent;
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Axis labels
|
|
ctx.fillStyle = CHROME.axis;
|
|
ctx.font = MONO;
|
|
ctx.textAlign = "right";
|
|
ctx.fillText(`${Math.round(maxEle)}m`, PADDING.left - 4, PADDING.top + 10);
|
|
ctx.fillText(`${Math.round(minEle)}m`, PADDING.left - 4, PADDING.top + chartH);
|
|
ctx.textAlign = "center";
|
|
ctx.fillText("0 km", PADDING.left, h - 4);
|
|
ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4);
|
|
|
|
// Day dividers
|
|
if (days && days.length > 1) {
|
|
for (let d = 0; d < days.length - 1; d++) {
|
|
const boundaryDist = days.slice(0, d + 1).reduce((sum, s) => sum + s.distance, 0);
|
|
const bx = toX(boundaryDist);
|
|
|
|
ctx.beginPath();
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.moveTo(bx, PADDING.top);
|
|
ctx.lineTo(bx, PADDING.top + chartH);
|
|
ctx.strokeStyle = CHROME.divider;
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
ctx.fillStyle = CHROME.axis;
|
|
ctx.font = MONO_SM;
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(`${(boundaryDist / 1000).toFixed(0)} km`, bx, h - 4);
|
|
}
|
|
}
|
|
|
|
// Hover crosshair + info
|
|
if (hoverIdx !== null && hoverIdx >= 0 && hoverIdx < points.length) {
|
|
const p = points[hoverIdx]!;
|
|
const hx = toX(p.distance);
|
|
const hy = toY(p.elevation);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(hx, PADDING.top);
|
|
ctx.lineTo(hx, PADDING.top + chartH);
|
|
ctx.strokeStyle = CHROME.hoverLine;
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(hx, hy, 4, 0, Math.PI * 2);
|
|
ctx.fillStyle = CHROME.accent;
|
|
ctx.fill();
|
|
ctx.strokeStyle = CHROME.ring;
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = CHROME.hoverText;
|
|
ctx.font = MONO;
|
|
ctx.textAlign = "left";
|
|
let label = `${Math.round(p.elevation)}m · ${(p.distance / 1000).toFixed(1)}km`;
|
|
if (colorMode === "grade" && hoverIdx > 0) {
|
|
const prev = points[hoverIdx - 1]!;
|
|
const dDist = p.distance - prev.distance;
|
|
const grade = dDist > 0 ? ((p.elevation - prev.elevation) / dDist) * 100 : 0;
|
|
label += ` · ${grade > 0 ? "+" : ""}${grade.toFixed(1)}%`;
|
|
}
|
|
if (colorMode === "surface" && surfaces[hoverIdx]) label += ` · ${surfaces[hoverIdx]}`;
|
|
if (colorMode === "highway" && highways[hoverIdx]) label += ` · ${highways[hoverIdx]}`;
|
|
if (colorMode === "maxspeed" && maxspeeds[hoverIdx]) {
|
|
const s = maxspeeds[hoverIdx]!;
|
|
label += ` · ${s === "unknown" ? s : `${s} km/h`}`;
|
|
}
|
|
if (colorMode === "smoothness" && smoothnesses[hoverIdx]) label += ` · ${smoothnesses[hoverIdx]}`;
|
|
if (colorMode === "tracktype" && tracktypes[hoverIdx]) label += ` · ${tracktypes[hoverIdx]}`;
|
|
if (colorMode === "cycleway" && cycleways[hoverIdx]) label += ` · ${cycleways[hoverIdx]}`;
|
|
if (colorMode === "bikeroute" && bikeroutes[hoverIdx]) {
|
|
const names: Record<string, string> = { icn: "International", ncn: "National", rcn: "Regional", lcn: "Local", none: "None" };
|
|
label += ` · ${names[bikeroutes[hoverIdx]!] ?? bikeroutes[hoverIdx]}`;
|
|
}
|
|
const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8;
|
|
ctx.textAlign = hx + 8 > w - 80 ? "right" : "left";
|
|
ctx.fillText(label, labelX, PADDING.top + 10);
|
|
}
|
|
|
|
// Drag selection overlay
|
|
if (isDragging && dragStartX != null && dragCurrentX != null) {
|
|
const x1 = Math.max(PADDING.left, Math.min(dragStartX, PADDING.left + chartW));
|
|
const x2 = Math.max(PADDING.left, Math.min(dragCurrentX, PADDING.left + chartW));
|
|
const selLeft = Math.min(x1, x2);
|
|
const selRight = Math.max(x1, x2);
|
|
ctx.fillStyle = CHROME.selectFill;
|
|
ctx.fillRect(selLeft, PADDING.top, selRight - selLeft, chartH);
|
|
ctx.strokeStyle = CHROME.selectStroke;
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(selLeft, PADDING.top, selRight - selLeft, chartH);
|
|
}
|
|
}
|