diff --git a/active_statistics/gui/gui.py b/active_statistics/gui/gui.py
index d050357..9a0c657 100644
--- a/active_statistics/gui/gui.py
+++ b/active_statistics/gui/gui.py
@@ -5,6 +5,7 @@
from active_statistics.statistics.plots import (
average_heartrate_by_average_speed,
cumulative_distance_travelled,
+ cumulative_gear_distance,
cumulative_gear_time,
cumulative_time_spent,
github_style_activities,
@@ -93,10 +94,16 @@
),
PlotTab(
name="Cumulative Gear Time",
- description="A cumulative plot of the total distance you've travelled from the activities that you've logged on Strava.",
+ description="A cumulative plot of the time logged with Strava using different gear.",
plot_function=cumulative_gear_time.plot,
detailed=True,
),
+ PlotTab(
+ name="Cumulative Gear Distance",
+ description="A cumuative plot of the distance travelled while using different gear that you've logged on Strava.",
+ plot_function=cumulative_gear_distance.plot,
+ detailed=True,
+ ),
TriviaTab(
name="Detailed Trivia",
detailed=True,
diff --git a/active_statistics/statistics/plots/cumulative_gear_distance.py b/active_statistics/statistics/plots/cumulative_gear_distance.py
new file mode 100644
index 0000000..8f7f63a
--- /dev/null
+++ b/active_statistics/statistics/plots/cumulative_gear_distance.py
@@ -0,0 +1,198 @@
+import dataclasses
+import datetime as dt
+import itertools
+from collections import defaultdict
+from typing import Any, Iterator, Optional
+
+import plotly.graph_objects as go
+from stravalib import unithelper as uh
+from stravalib.model import Activity, ActivityType
+
+ALL_ACTIVITIES = "All"
+
+
+@dataclasses.dataclass
+class CompactActivity:
+ type: ActivityType
+ start_date_local: dt.datetime
+ distance: uh.Quantity
+ gear_id: str
+ gear_name: str
+
+
+def plot(activity_iterator: Iterator[Activity]) -> go.Figure:
+ compact_activities: list[CompactActivity] = get_compact_activities(
+ activity_iterator
+ )
+
+ # Sort activities into chronological order
+ compact_activities.sort(key=lambda activity: activity.start_date_local)
+
+ gear_id_to_name_mapping = {
+ activity.gear_id: activity.gear_name for activity in compact_activities
+ }
+
+ # Get set of all the different types of activities this person has logged.
+ activity_types = set(activity.type for activity in compact_activities)
+
+ all_plots: dict[ActivityType, dict[str, go.Scatter]] = {}
+
+ # Make plots for all activities
+ all_plots[ALL_ACTIVITIES] = plot_graph(compact_activities)
+
+ # Make plots for specific activities
+ for activity_type in activity_types:
+ all_plots[activity_type] = plot_graph(
+ list(
+ filter(
+ lambda activity: activity.type == activity_type, compact_activities
+ )
+ ),
+ )
+
+ data = get_figure_data_from_all_activity_data(all_plots, gear_id_to_name_mapping)
+ layout = get_layout_from_all_activity_data(all_plots)
+
+ fig = go.Figure(data=data, layout=layout)
+ return fig
+
+
+def get_compact_activities(
+ activity_iterator: Iterator[Activity],
+) -> list[CompactActivity]:
+ return [
+ CompactActivity(
+ type=activity.type,
+ start_date_local=activity.start_date_local,
+ distance=activity.distance,
+ gear_id=activity.gear_id,
+ gear_name=activity.gear.name,
+ )
+ for activity in activity_iterator
+ if activity.start_date_local is not None
+ and activity.distance is not None
+ and activity.gear_id is not None
+ and activity.gear.name is not None
+ ]
+
+
+def plot_graph(
+ activities: list[CompactActivity],
+) -> dict[str, go.Scatter]:
+ start_time_arrays: dict[str, list[dt.datetime]] = defaultdict(list)
+ distance_arrays: dict[str, list[float]] = defaultdict(list)
+ for activity in activities:
+ start_time_arrays[activity.gear_id].append(activity.start_date_local)
+ distance_arrays[activity.gear_id].append(activity.distance)
+
+ for gear_id, times in distance_arrays.items():
+ distance_arrays[gear_id] = list(itertools.accumulate(times))
+
+ for gear_id, times in distance_arrays.items():
+ # Convert distance from meters to km.
+ distance_arrays[gear_id] = [x / 1000 for x in times]
+
+ dd = {}
+
+ # We want to iterate through the data by year, in reverse chronological order.
+ # This ensures the scatter plots are generated in order.
+ for gear_id, date in sorted(
+ start_time_arrays.items(), key=lambda t: t[1][0], reverse=True
+ ):
+ dd[gear_id] = go.Scatter(
+ x=date,
+ y=distance_arrays[gear_id],
+ hovertemplate="Kilometers: %{y:.0f}
",
+ mode="lines",
+ )
+
+ return dd
+
+
+def get_title_for_activity_type(activity_type: Optional[str]) -> str:
+ if activity_type:
+ return f"Cumulative Time Spent on {activity_type} Activities"
+ else:
+ return "Time Logged on Strava by Year"
+
+
+def get_figure_data_from_all_activity_data(
+ all_data: dict[ActivityType, dict[str, go.Scatter]],
+ gear_id_to_gear_name_mapping: dict[str, str],
+) -> list[go.Scatter]:
+ figure_data: list[go.Scatter] = []
+
+ for activity_type, scatters in all_data.items():
+ for gear_id, scatter in scatters.items():
+ if activity_type != ALL_ACTIVITIES:
+ scatter.visible = False
+ scatter.name = gear_id_to_gear_name_mapping[gear_id]
+ figure_data.append(scatter)
+ return figure_data
+
+
+def get_layout_from_all_activity_data(
+ all_data: dict[ActivityType, dict[str, go.Scatter]]
+) -> dict[str, Any]:
+ updatemenus = list(
+ [
+ dict(
+ direction="left",
+ pad={"r": 10, "t": 10},
+ showactive=True,
+ x=0.5,
+ xanchor="center",
+ y=1.02,
+ yanchor="bottom",
+ type="buttons",
+ active=-1,
+ buttons=list(
+ [
+ dict(
+ label=f"{activity_type}",
+ method="update",
+ args=[
+ {"visible": get_visible_array(all_data, activity_type)},
+ {
+ "title": f"Cumulative Time Spent Using Certain Gear on {activity_type} Activities"
+ },
+ ],
+ )
+ for activity_type, _ in all_data.items()
+ ]
+ ),
+ )
+ ]
+ )
+
+ layout = dict(
+ title=f"Cumulative Distance for Different Gear",
+ title_x=0.5,
+ updatemenus=updatemenus,
+ xaxis_title="Date",
+ yaxis_title="Distance",
+ )
+
+ return layout
+
+
+def get_visible_array(
+ all_data: dict[ActivityType, dict[str, go.Scatter]],
+ activity_type_button: ActivityType,
+) -> list[bool]:
+ visibility_list = []
+
+ for activity_type, scatters in all_data.items():
+ for year, scatter in scatters.items():
+ visibility_list.append(activity_type == activity_type_button)
+
+ return visibility_list
+
+
+# For testing
+if __name__ == "__main__":
+ from active_statistics.utils import local_storage
+
+ activity_iterator = local_storage.get_activity_iterator(94896104)
+ f = plot(activity_iterator)
+ f.show()
diff --git a/tests/test_visualisations/test_cumulative_gear_distance.py b/tests/test_visualisations/test_cumulative_gear_distance.py
new file mode 100644
index 0000000..4501aae
--- /dev/null
+++ b/tests/test_visualisations/test_cumulative_gear_distance.py
@@ -0,0 +1,9 @@
+from active_statistics.statistics.plots.cumulative_gear_distance import plot
+
+
+def test_cumulative_gear_distance(some_basic_runs_and_rides) -> None:
+ plot(some_basic_runs_and_rides)
+
+
+def test_no_data(no_activities_at_all) -> None:
+ plot(no_activities_at_all)