import type { AnalyticsPoint } from "./types.js"; export const niceStep = (range: number, target: number): number => { if (range <= 0) return 1; const ideal = range / target; const magnitude = 10 ** Math.floor(Math.log10(ideal)); let best = magnitude; let bestDistance = Number.POSITIVE_INFINITY; for (const factor of [1, 2, 5, 10]) { const candidate = factor * magnitude; const distance = Math.abs(candidate - ideal); if (distance < bestDistance) { bestDistance = distance; best = candidate; } } return best; }; export const ticks = (min: number, max: number, target = 5): number[] => { const step = niceStep(max - min, target); const values: number[] = []; for (let value = Math.floor(min / step) * step; value <= max + 1e-9; value += step) { if (value >= min - 1e-9) values.push(roundStep(value)); } return values; }; const roundStep = (value: number): number => { if (!Number.isFinite(value) || value === 0) return 0; const decimals = Math.max(0, -Math.floor(Math.log10(value) + 1e-9)); return Number(value.toFixed(decimals)); }; export const formatSpeed = (speed: number): string => `${speed.toFixed(1)}`; export const formatCotacol = (cotacol: number): string => (cotacol >= 100 ? `${Math.round(cotacol)}` : `${cotacol.toFixed(1)}`); export const formatHours = (hours: number): string => { if (hours < 1) return `${Math.round(hours * 60)}m`; const h = Math.floor(hours); const m = Math.round((hours - h) * 60); if (m === 0) return `${h}h`; return `${h}h${String(m).padStart(2, "0")}`; }; export type ColorBy = "none" | "year" | "rideType" | "bikeType"; export interface RideGroup { label: string; count: number; colorIndex: number; } export interface GroupedPoint { point: AnalyticsPoint; colorIndex: number; } const UNKNOWN_LABEL = "Unknown"; const rideTypeLabels: Record = { "": UNKNOWN_LABEL, training: "Training", indoor: "Indoor", race: "Race", transport: "Transport", touring: "Touring", }; const bikeTypeLabels: Record = { "": UNKNOWN_LABEL, road: "Road", mtb: "MTB", }; const rideTypeOrder = ["training", "indoor", "race", "transport", "touring"]; const bikeTypeOrder = ["road", "mtb"]; export const analyticsRideTypeLabel = (value: string): string => rideTypeLabels[value] ?? value; export const analyticsBikeTypeLabel = (value: string): string => bikeTypeLabels[value] ?? value; const rideTypeColorIndex = (value: string): number => { const index = rideTypeOrder.indexOf(value); return index < 0 ? rideTypeOrder.length : index; }; const bikeTypeColorIndex = (value: string): number => { const index = bikeTypeOrder.indexOf(value); return index < 0 ? bikeTypeOrder.length : index; }; const yearColorIndex = (value: number, years: number[]): number => years.indexOf(value); export const groupRidePoints = (points: AnalyticsPoint[], colorBy: ColorBy): RideGroup[] => { if (colorBy === "none") return []; const counts = new Map(); for (const point of points) { const key = groupKey(colorBy, point); counts.set(key, (counts.get(key) ?? 0) + 1); } return groupKeys(colorBy, counts).map((key, index) => ({ label: groupLabel(colorBy, key), count: counts.get(key) ?? 0, colorIndex: colorIndexForGroupKey(colorBy, key, index), })); }; const colorIndexForGroupKey = (colorBy: ColorBy, key: string, compactIndex: number): number => { if (colorBy === "rideType") return rideTypeColorIndex(key); if (colorBy === "bikeType") return bikeTypeColorIndex(key); return compactIndex; }; const groupKey = (colorBy: ColorBy, point: AnalyticsPoint): string => { if (colorBy === "year") return String(point.year); if (colorBy === "rideType") return point.rideType; if (colorBy === "bikeType") return point.bikeType; return ""; }; const groupLabel = (colorBy: ColorBy, key: string): string => { if (colorBy === "year") return key; if (colorBy === "rideType") return analyticsRideTypeLabel(key); if (colorBy === "bikeType") return analyticsBikeTypeLabel(key); return key; }; const groupKeys = (colorBy: ColorBy, counts: Map): string[] => { const keys = [...counts.keys()]; if (colorBy === "rideType") { return [...rideTypeOrder.filter((key) => counts.has(key)), ...(counts.has("") ? [""] : [])]; } if (colorBy === "bikeType") { return [...bikeTypeOrder.filter((key) => counts.has(key)), ...(counts.has("") ? [""] : [])]; } if (colorBy === "year") { return keys.sort((a, b) => Number(a) - Number(b)); } return keys; }; export const colorIndexForPoint = (point: AnalyticsPoint, colorBy: ColorBy): number => { if (colorBy === "year") return 0; if (colorBy === "rideType") return rideTypeColorIndex(point.rideType); if (colorBy === "bikeType") return bikeTypeColorIndex(point.bikeType); return 0; }; export const groupedPoints = (points: AnalyticsPoint[], colorBy: ColorBy): GroupedPoint[] => { if (colorBy === "year") { const years = [...new Set(points.map((point) => point.year))].sort((a, b) => a - b); return points.map((point) => ({ point, colorIndex: yearColorIndex(point.year, years) })); } return points.map((point) => ({ point, colorIndex: colorIndexForPoint(point, colorBy) })); };