Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions client/src/lib/progress/progressChart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { DateTime } from 'luxon';
import _ from 'lodash'

import { Track } from '@/lib/Track'
import type { TracksByYearDict, ExtendedPChartDataPoint, DSet, PChartSortType } from '@/lib/progress/progressChartTypes'


/**
* Converts yearly track data into Chart.js line datasets, calculating
* cumulative distance for each year with dates normalized to 2024 for comparison.
* @param tracksByYear Dictionary mapping years to arrays of Track objects
* @param maxSortType Determines whether to prioritize newest years or highest progress when limiting datasets
* @param maxLines Maximum number of datasets (years) to include in the output. Set to 0 for no limit.
* @returns Array of Chart.js datasets with cumulative distance data, larger point radius for the most recent year
*/
function generateChartDataSets(tracksByYear: TracksByYearDict, maxSortType: PChartSortType = 'newest_year', maxLines = 0): DSet[] {

const yearList = _.keys(tracksByYear).map((ys) => Number.parseInt(ys))
yearList.sort().reverse()

const maxYear = Math.max(...yearList)

const yearListToUse: number[] = []

// Cut down list of years to use based on sorting preference and max lines
if (maxSortType === 'newest_year') {
yearListToUse.push(...yearList.slice(0, maxLines === 0 ? yearList.length : maxLines))

} else if (maxSortType === 'highest_progress') {
yearListToUse.push(...yearList) // filtering is later
}

const progressYearPairs: { year: number, progress: number }[] = [] // for ranking by progress if needed
const dataSetByYear: Record<number, DSet> = {} // for easy lookup when sorting by progress

for (const year of yearListToUse) {

if ((tracksByYear[year] === undefined) || tracksByYear[year].length === 0) continue

// Clean and sort tracks for the year
const sortedValidTracks: Track[] = []
for (const track of tracksByYear[year]) {

const tTime = track.getTime()

if (tTime?.isValid) {
sortedValidTracks.push(track)
}
sortedValidTracks.sort((a, b) => (a.getTime()!.toSeconds() - b.getTime()!.toSeconds()))
}

// map tracks to dataset structure with cumulative distance, normalizing dates to 2024 for comparison across years
let sum = 0
const chartData: ExtendedPChartDataPoint[] = sortedValidTracks.map(
(t: Track) => {
const step = t.distance() / 1000
sum += step

const trackDate = t.getTime() as DateTime<true>
const normDate = trackDate.set({ year: 2024 })
return {
x: normDate, y: sum, step, name: t.getNameOrSrc(), originalDate: trackDate
}
}
)

const totalProgress = chartData.at(-1)?.y ?? 0 // can not happen
progressYearPairs.push({ year, progress: totalProgress })

// Label below is for whole dataset, not individual points
const dataset: DSet = {
label: year.toString(),
data: chartData,
pointRadius: (year === maxYear) ? 5 : 3
}

dataSetByYear[year] = dataset
//returnDataSetList.push(dataset)

}

const returnDataSetList: DSet[] = []
if (maxSortType === 'newest_year') {
// Return datasets for the newest years
for (const year of yearListToUse) {
returnDataSetList.push(dataSetByYear[year]!)
}
} else if (maxSortType === 'highest_progress') {
// Sort years by progress and return datasets for the top years
progressYearPairs.sort((a, b) => b.progress - a.progress)
const topYears = progressYearPairs.slice(0, maxLines).map(p => p.year)

// check if min 1 year, then check if current year is in top years, if not add it to the list (for visual comparison)
if (topYears.length > 0 && !topYears.includes(maxYear)) {
topYears.push(maxYear)
}

// sort them again by year for better visual comparison
topYears.sort().reverse()
for (const year of topYears) {
returnDataSetList.push(dataSetByYear[year]!)
}
}

return returnDataSetList
}

/**
* State of both buttons, for now only one can be active at a time, but maybe in the future we want to allow both to be active at the same time.
* Will also print classes for active/inactive state of buttons, so that they can be styled accordingly.
* */
class FilterButtonState {
newestActive: boolean
bestActive: boolean

constructor() {
this.newestActive = false
this.bestActive = false
}

toggleNewest() {
this.newestActive = !this.newestActive
if (this.newestActive) {
this.bestActive = false
}
}

toggleBest() {
this.bestActive = !this.bestActive
if (this.bestActive) {
this.newestActive = false
}
}

filterActive(): boolean {
return this.newestActive || this.bestActive
}

newestButtonClass() {
return FilterButtonState.activeClass(this.newestActive)
}

bestButtonClass() {
return FilterButtonState.activeClass(this.bestActive)
}

static activeClass(active: boolean) {
return active ? "btn-secondary" : "btn-outline-secondary"
}
}

export { generateChartDataSets, FilterButtonState }
74 changes: 74 additions & 0 deletions client/src/lib/progress/progressChartConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ChartConfiguration } from "chart.js"
import { DateTime } from "luxon";
import type { ExtendedPChartDataPoint, PChartTLabel, PChartTType } from '@/lib/progress/progressChartTypes'


const chartConfig: ChartConfiguration<PChartTType, ExtendedPChartDataPoint[], PChartTLabel> = {
type: 'line',
data: {
datasets: [],
},
options: {
maintainAspectRatio: false,
scales: {
x: {
type: "time",
time: {
displayFormats: {
month: 'MMM',
year: ''
},
unit: 'month'
},
min: '2024-01-01',
max: '2025-01-02',
title: {
display: true,
text: "Day in year"
}
},
y: {
title: {
display: true,
text: 'Mileage'
},
ticks: {
callback: (value) => `${Math.round(value as number)} km`
}
}
},
plugins: {
tooltip: {
boxPadding: 10,
callbacks: {
title: function (dataset) {
return (dataset[0]?.raw as ExtendedPChartDataPoint).originalDate.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)
},
label: function (item) {
return Math.round((item.raw as ExtendedPChartDataPoint).step) + " km"
},
afterLabel: function (item) {
return (item.raw as ExtendedPChartDataPoint).name
}
}
},
zoom: {
zoom: {
wheel: {
enabled: true,
},
pinch: {
enabled: true
},
mode: 'xy',
},
pan: {
enabled: true,

}
}
}
}
}

export { chartConfig }
25 changes: 25 additions & 0 deletions client/src/lib/progress/progressChartTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { DateTime } from "luxon";
import { Track } from "@/lib/Track";

type TracksByYearDict = Record<number, Track[]>;

type PChartTType = 'line'
interface PChartDataPoint { x: DateTime<true>; y: number; }
type PChartTLabel = DateTime


interface ExtendedPChartDataPoint extends PChartDataPoint {
name: string,
step: number,
originalDate: DateTime<true>
}

interface DSet {
label: string,
data: ExtendedPChartDataPoint[],
pointRadius: number
}

type PChartSortType = 'newest_year' | 'highest_progress'

export type { TracksByYearDict, ExtendedPChartDataPoint, DSet, PChartTType, PChartDataPoint, PChartTLabel, PChartSortType }
Loading