diff --git a/client/src/lib/progress/progressChart.ts b/client/src/lib/progress/progressChart.ts new file mode 100644 index 0000000..6b56eee --- /dev/null +++ b/client/src/lib/progress/progressChart.ts @@ -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 = {} // 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 + 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 } \ No newline at end of file diff --git a/client/src/lib/progress/progressChartConfig.ts b/client/src/lib/progress/progressChartConfig.ts new file mode 100644 index 0000000..637c0a5 --- /dev/null +++ b/client/src/lib/progress/progressChartConfig.ts @@ -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 = { + 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 } \ No newline at end of file diff --git a/client/src/lib/progress/progressChartTypes.ts b/client/src/lib/progress/progressChartTypes.ts new file mode 100644 index 0000000..a8bbc6f --- /dev/null +++ b/client/src/lib/progress/progressChartTypes.ts @@ -0,0 +1,25 @@ +import { DateTime } from "luxon"; +import { Track } from "@/lib/Track"; + +type TracksByYearDict = Record; + +type PChartTType = 'line' +interface PChartDataPoint { x: DateTime; y: number; } +type PChartTLabel = DateTime + + +interface ExtendedPChartDataPoint extends PChartDataPoint { + name: string, + step: number, + originalDate: DateTime +} + +interface DSet { + label: string, + data: ExtendedPChartDataPoint[], + pointRadius: number +} + +type PChartSortType = 'newest_year' | 'highest_progress' + +export type { TracksByYearDict, ExtendedPChartDataPoint, DSet, PChartTType, PChartDataPoint, PChartTLabel, PChartSortType } \ No newline at end of file diff --git a/client/src/views/ProgressChart.vue b/client/src/views/ProgressChart.vue index 4bf7e79..90e8874 100644 --- a/client/src/views/ProgressChart.vue +++ b/client/src/views/ProgressChart.vue @@ -1,19 +1,25 @@ + + diff --git a/client/src/views/TrackOverviewPage.vue b/client/src/views/TrackOverviewPage.vue index c28e6ce..fed0880 100644 --- a/client/src/views/TrackOverviewPage.vue +++ b/client/src/views/TrackOverviewPage.vue @@ -1,14 +1,12 @@