pherotrails 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: pherotrails
3
+ Version: 0.1.0a1
4
+ Summary: Hive-like sports activity visualizer
5
+ Author: Nicola De Mitri
6
+ Author-email: Nicola De Mitri <nicola.dmt@gmail.com>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Requires-Dist: cartopy>=0.25.0
13
+ Requires-Dist: gpxpy>=1.6.2
14
+ Requires-Dist: palettable>=3.3.3
15
+ Requires-Dist: pillow>=12.1.1
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+
19
+ # pherotrails
20
+
21
+ Your activities, as ant trails 🐜
22
+
23
+ ## Disclaimer
24
+
25
+ **This is a pre-alpha. ⚠️ It comes with important limitations.**
26
+
27
+ - It only supports tracks that were recorded directly via the Strava App (`.gpx` files), not those that
28
+ were imported from other sources such as a Garmin device (`.fit` or other formats).
29
+
30
+ - Its activity-type filter is limited to just "running" activities.
31
+
32
+ - **IMPORTANT FOR PRIVACY. IT DOES NOT HIDE** tracks or parts of a track (e.g. the first 200 m) that
33
+ were marked as "hidden" in Strava.
34
+
35
+ - Does not honor pause/resume events in Strava. The animation will still show the track as stationary
36
+ during that time.
37
+
38
+ ## Setup
39
+
40
+ (Tested on Linux only - but should work on Windows and MacOS too.)
41
+
42
+ - Please install either [`uv`](https://docs.astral.sh/uv/getting-started/installation/)
43
+ (recommended) or [`pipx`](https://pipx.pypa.io/stable/how-to/install-pipx/).
44
+
45
+ - Install [`ffmpeg`](https://www.ffmpeg.org/) and ensure it is in your `PATH`.
46
+
47
+ ### Optional: Mapbox
48
+
49
+ To use a map as background, you need to have a Mapbox account and generate a token from
50
+ [here](https://console.mapbox.com/account/access-tokens/).
51
+
52
+ It's free to sign up, and the free tier allows for 50,000 monthly requests.
53
+
54
+ Store your token in an environment variable called `MAPBOX_TOKEN`:
55
+
56
+ ```bash
57
+ export MAPBOX_TOKEN="<your token here>"
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ### Obtain your data
63
+
64
+ - Go to ["delete your account"](https://www.strava.com/athlete/delete_your_account) on Strava.
65
+ **I know, it sounds scary, but you don't have to actually delete your account.** This is just the page
66
+ where you can request a copy of your data.
67
+
68
+ - Click "Request your archive" and wait for the email from Strava.
69
+
70
+ - Once you receive the email, download the zip file and extract it somewhere. You'll need the
71
+ location of the `activities` folder.
72
+
73
+ - Define your bounding box. To do so, on Google Maps, click on the bottom-left point of the area you want
74
+ to visualize and take a note of the latitude and longitude (with sign and with 4 decimal places).
75
+ Then click on the top-right point of the area and take a note of its latitude and longitude.
76
+
77
+ Your notes should contain something like: `"44.4562,11.2635,44.5430,11.3676"`
78
+
79
+ NOTE: some very narrow bounding boxes may not work. Look out for a warning message from Mapbox.
80
+
81
+ ### Run the program
82
+
83
+ Invoke using
84
+
85
+ ```bash
86
+ uvx pherotrails [ARGS]
87
+
88
+ # or, with pipx:
89
+ pipx run --pip-args="--pre" pherotrails [ARGS]
90
+ ```
91
+
92
+ A typical run looks like:
93
+
94
+ ```bash
95
+ uvx pherotrails \
96
+ --bbox "44.4562,11.2635,44.5430,11.3676" \
97
+ --data-folder ~/my_strava_data/activities \
98
+ --activity running \
99
+ --highlight-circle "17561487369,Bologna Half Marathon 2026" \
100
+ --output pherotrails_nic_bologna.mp4
101
+ ```
102
+
103
+ ### `--help`
104
+
105
+ The `[ARGS]` are as follows (you can also run `uvx pherotrails --help` to see them):
106
+
107
+ ```
108
+ [-h] --bbox S,W,N,E --data-folder DATA_FOLDER --activity ACTIVITY
109
+ [--highlight-circle ID,LABEL] [--highlight-square ID,LABEL] [--width PX]
110
+ [--background-color R,G,B] [--brush-multiplier VAL] [--time-alignment {start,end}]
111
+ [--color-palette {agSunset_7,Fall_6,TealRose_6,Temps_6}]
112
+ [--color-code-class {Recency,TotalDuration}] [--color-code-limits MIN,MAX]
113
+ [--show-color-legend | --no-show-color-legend]
114
+ [--seconds-per-frame SECONDS_PER_FRAME] [--fps FPS] [--extend EXTEND]
115
+ [--output OUTPUT] [--use-map-background | --no-use-map-background]
116
+ [--unit-system {metric,imperial}]
117
+
118
+ Render GPS activity tracks as an animated heatmap video.
119
+
120
+ options:
121
+ -h, --help show this help message and exit
122
+ --bbox S,W,N,E Bounding box as four comma-separated decimal lat/lon values:
123
+ south,west,north,east
124
+ --data-folder DATA_FOLDER
125
+ Path to a folder containing GPX files.
126
+ --activity ACTIVITY Activity type to filter for, e.g. "running".
127
+ --highlight-circle ID,LABEL
128
+ Highlight a track with a circle marker. Format: "id,label" (label may contain
129
+ commas).
130
+ --highlight-square ID,LABEL
131
+ Highlight a track with a square marker. Format: "id,label" (label may contain
132
+ commas).
133
+ --width PX Width of the output image in pixels. Height is computed to preserve aspect
134
+ ratio. NOTE: to edit feature sizes, use --brush-multiplier instead of changing
135
+ the width, as the brush size scales with the width. (default: 1000)
136
+ --background-color R,G,B
137
+ Background color as comma-separated RGB values. (default: 255,255,255)
138
+ --brush-multiplier VAL
139
+ Size multiplier for the brush. Note that there is already a multiplier based
140
+ on the total size of the image. The two are compounded. (default: 1.0)
141
+ --time-alignment {start,end}
142
+ How to align tracks in time. (default: TimeAlignment.start)
143
+ --color-palette {agSunset_7,Fall_6,TealRose_6,Temps_6}
144
+ Color palette to use. (default: ColorPalette.Temps_6)
145
+ --color-code-class {Recency,TotalDuration}
146
+ What property to color-code tracks by. (default: ColorCodeClass.TotalDuration)
147
+ --color-code-limits MIN,MAX
148
+ Comma-separated min,max for color coding gradients (in seconds). (default:
149
+ 1680,3120)
150
+ --show-color-legend, --no-show-color-legend
151
+ Draw an on-screen legend for the color coding used. (default: True)
152
+ --seconds-per-frame SECONDS_PER_FRAME
153
+ How many real-world seconds pass per animation frame. (default: 12.0)
154
+ --fps FPS Target frames per second for the output animation. (default: 30.0)
155
+ --extend EXTEND Number of frames to add past the last movement. (default: 10)
156
+ --output OUTPUT Path for the output video file. (default: pherotrails_output.mp4)
157
+ --use-map-background, --no-use-map-background
158
+ Fetch a map tile to use as background. (default: True)
159
+ --unit-system {metric,imperial}
160
+ Units to display. (default: UnitSystem.metric)
161
+ ```
162
+
163
+ ## Developers
164
+
165
+ ### Editable run
166
+
167
+ From the repository root, run `uv run pherotrails:
168
+
169
+ ```console
170
+ uv run pherotrails [ARGS]
171
+ ```
172
+
173
+ ### Formatting and linting
174
+
175
+ Currently manual only, therefore mandatory 😇
176
+
177
+ ```console
178
+ uvx ruff format . && uvx ruff check --extend-select I --fix .
179
+ uvx mypy --ignore-missing-imports .
180
+ ```
@@ -0,0 +1,162 @@
1
+ # pherotrails
2
+
3
+ Your activities, as ant trails 🐜
4
+
5
+ ## Disclaimer
6
+
7
+ **This is a pre-alpha. ⚠️ It comes with important limitations.**
8
+
9
+ - It only supports tracks that were recorded directly via the Strava App (`.gpx` files), not those that
10
+ were imported from other sources such as a Garmin device (`.fit` or other formats).
11
+
12
+ - Its activity-type filter is limited to just "running" activities.
13
+
14
+ - **IMPORTANT FOR PRIVACY. IT DOES NOT HIDE** tracks or parts of a track (e.g. the first 200 m) that
15
+ were marked as "hidden" in Strava.
16
+
17
+ - Does not honor pause/resume events in Strava. The animation will still show the track as stationary
18
+ during that time.
19
+
20
+ ## Setup
21
+
22
+ (Tested on Linux only - but should work on Windows and MacOS too.)
23
+
24
+ - Please install either [`uv`](https://docs.astral.sh/uv/getting-started/installation/)
25
+ (recommended) or [`pipx`](https://pipx.pypa.io/stable/how-to/install-pipx/).
26
+
27
+ - Install [`ffmpeg`](https://www.ffmpeg.org/) and ensure it is in your `PATH`.
28
+
29
+ ### Optional: Mapbox
30
+
31
+ To use a map as background, you need to have a Mapbox account and generate a token from
32
+ [here](https://console.mapbox.com/account/access-tokens/).
33
+
34
+ It's free to sign up, and the free tier allows for 50,000 monthly requests.
35
+
36
+ Store your token in an environment variable called `MAPBOX_TOKEN`:
37
+
38
+ ```bash
39
+ export MAPBOX_TOKEN="<your token here>"
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ### Obtain your data
45
+
46
+ - Go to ["delete your account"](https://www.strava.com/athlete/delete_your_account) on Strava.
47
+ **I know, it sounds scary, but you don't have to actually delete your account.** This is just the page
48
+ where you can request a copy of your data.
49
+
50
+ - Click "Request your archive" and wait for the email from Strava.
51
+
52
+ - Once you receive the email, download the zip file and extract it somewhere. You'll need the
53
+ location of the `activities` folder.
54
+
55
+ - Define your bounding box. To do so, on Google Maps, click on the bottom-left point of the area you want
56
+ to visualize and take a note of the latitude and longitude (with sign and with 4 decimal places).
57
+ Then click on the top-right point of the area and take a note of its latitude and longitude.
58
+
59
+ Your notes should contain something like: `"44.4562,11.2635,44.5430,11.3676"`
60
+
61
+ NOTE: some very narrow bounding boxes may not work. Look out for a warning message from Mapbox.
62
+
63
+ ### Run the program
64
+
65
+ Invoke using
66
+
67
+ ```bash
68
+ uvx pherotrails [ARGS]
69
+
70
+ # or, with pipx:
71
+ pipx run --pip-args="--pre" pherotrails [ARGS]
72
+ ```
73
+
74
+ A typical run looks like:
75
+
76
+ ```bash
77
+ uvx pherotrails \
78
+ --bbox "44.4562,11.2635,44.5430,11.3676" \
79
+ --data-folder ~/my_strava_data/activities \
80
+ --activity running \
81
+ --highlight-circle "17561487369,Bologna Half Marathon 2026" \
82
+ --output pherotrails_nic_bologna.mp4
83
+ ```
84
+
85
+ ### `--help`
86
+
87
+ The `[ARGS]` are as follows (you can also run `uvx pherotrails --help` to see them):
88
+
89
+ ```
90
+ [-h] --bbox S,W,N,E --data-folder DATA_FOLDER --activity ACTIVITY
91
+ [--highlight-circle ID,LABEL] [--highlight-square ID,LABEL] [--width PX]
92
+ [--background-color R,G,B] [--brush-multiplier VAL] [--time-alignment {start,end}]
93
+ [--color-palette {agSunset_7,Fall_6,TealRose_6,Temps_6}]
94
+ [--color-code-class {Recency,TotalDuration}] [--color-code-limits MIN,MAX]
95
+ [--show-color-legend | --no-show-color-legend]
96
+ [--seconds-per-frame SECONDS_PER_FRAME] [--fps FPS] [--extend EXTEND]
97
+ [--output OUTPUT] [--use-map-background | --no-use-map-background]
98
+ [--unit-system {metric,imperial}]
99
+
100
+ Render GPS activity tracks as an animated heatmap video.
101
+
102
+ options:
103
+ -h, --help show this help message and exit
104
+ --bbox S,W,N,E Bounding box as four comma-separated decimal lat/lon values:
105
+ south,west,north,east
106
+ --data-folder DATA_FOLDER
107
+ Path to a folder containing GPX files.
108
+ --activity ACTIVITY Activity type to filter for, e.g. "running".
109
+ --highlight-circle ID,LABEL
110
+ Highlight a track with a circle marker. Format: "id,label" (label may contain
111
+ commas).
112
+ --highlight-square ID,LABEL
113
+ Highlight a track with a square marker. Format: "id,label" (label may contain
114
+ commas).
115
+ --width PX Width of the output image in pixels. Height is computed to preserve aspect
116
+ ratio. NOTE: to edit feature sizes, use --brush-multiplier instead of changing
117
+ the width, as the brush size scales with the width. (default: 1000)
118
+ --background-color R,G,B
119
+ Background color as comma-separated RGB values. (default: 255,255,255)
120
+ --brush-multiplier VAL
121
+ Size multiplier for the brush. Note that there is already a multiplier based
122
+ on the total size of the image. The two are compounded. (default: 1.0)
123
+ --time-alignment {start,end}
124
+ How to align tracks in time. (default: TimeAlignment.start)
125
+ --color-palette {agSunset_7,Fall_6,TealRose_6,Temps_6}
126
+ Color palette to use. (default: ColorPalette.Temps_6)
127
+ --color-code-class {Recency,TotalDuration}
128
+ What property to color-code tracks by. (default: ColorCodeClass.TotalDuration)
129
+ --color-code-limits MIN,MAX
130
+ Comma-separated min,max for color coding gradients (in seconds). (default:
131
+ 1680,3120)
132
+ --show-color-legend, --no-show-color-legend
133
+ Draw an on-screen legend for the color coding used. (default: True)
134
+ --seconds-per-frame SECONDS_PER_FRAME
135
+ How many real-world seconds pass per animation frame. (default: 12.0)
136
+ --fps FPS Target frames per second for the output animation. (default: 30.0)
137
+ --extend EXTEND Number of frames to add past the last movement. (default: 10)
138
+ --output OUTPUT Path for the output video file. (default: pherotrails_output.mp4)
139
+ --use-map-background, --no-use-map-background
140
+ Fetch a map tile to use as background. (default: True)
141
+ --unit-system {metric,imperial}
142
+ Units to display. (default: UnitSystem.metric)
143
+ ```
144
+
145
+ ## Developers
146
+
147
+ ### Editable run
148
+
149
+ From the repository root, run `uv run pherotrails:
150
+
151
+ ```console
152
+ uv run pherotrails [ARGS]
153
+ ```
154
+
155
+ ### Formatting and linting
156
+
157
+ Currently manual only, therefore mandatory 😇
158
+
159
+ ```console
160
+ uvx ruff format . && uvx ruff check --extend-select I --fix .
161
+ uvx mypy --ignore-missing-imports .
162
+ ```
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "pherotrails"
3
+ version = "0.1.0a1"
4
+ description = "Hive-like sports activity visualizer"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Nicola De Mitri", email = "nicola.dmt@gmail.com" }
8
+ ]
9
+ license = "MIT"
10
+
11
+ requires-python = ">=3.12"
12
+
13
+ dependencies = [
14
+ "cartopy>=0.25.0",
15
+ "gpxpy>=1.6.2",
16
+ "palettable>=3.3.3",
17
+ "pillow>=12.1.1",
18
+ ]
19
+
20
+ classifiers = [
21
+ "Development Status :: 2 - Pre-Alpha",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.12",
24
+ "License :: OSI Approved :: MIT License",
25
+ ]
26
+
27
+ [project.scripts]
28
+ pherotrails = "pherotrails.main:main"
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.10.6,<0.12.0"]
32
+ build-backend = "uv_build"
File without changes
@@ -0,0 +1,217 @@
1
+ import enum
2
+ import math
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ import palettable
7
+ from palettable.cartocolors.diverging import Fall_6, TealRose_6, Temps_6
8
+ from palettable.cartocolors.sequential import agSunset_7
9
+
10
+ from pherotrails.tracks import TrackDeck
11
+
12
+ ColorPalette = enum.Enum(
13
+ "ColorPalette",
14
+ [
15
+ ("agSunset_7", agSunset_7),
16
+ ("Fall_6", Fall_6),
17
+ ("TealRose_6", TealRose_6),
18
+ ("Temps_6", Temps_6),
19
+ ],
20
+ )
21
+
22
+ #: "Round" step sizes (in seconds) considered when picking spacing for
23
+ #: duration-legend ticks.
24
+ NICE_DURATION_STEPS = sorted(
25
+ set(
26
+ multiplier * unit
27
+ for unit in (1, 60, 3600, 86400)
28
+ for multiplier in (1, 2, 3, 5, 10, 15, 20, 30)
29
+ )
30
+ )
31
+
32
+
33
+ def _format_duration(seconds: float) -> str:
34
+ """Format a duration in seconds as a short human-readable label.
35
+
36
+ Drops any zero-valued trailing components, e.g. 90 -> "1m 30s" but
37
+ 3600 -> "1h".
38
+ """
39
+ sec = int(round(seconds))
40
+ if sec < 60:
41
+ return f'{sec}"'
42
+ elif sec < 3600:
43
+ m, s = divmod(sec, 60)
44
+ return f"{m}'" + (f' {s}"' if s > 0 else "")
45
+ else:
46
+ h, rem = divmod(sec, 3600)
47
+ m, s = divmod(rem, 60)
48
+ return (
49
+ f"{h}h" + (f" {m}'" if m > 0 or s > 0 else "") + (f' {s}"' if s > 0 else "")
50
+ )
51
+
52
+
53
+ class ColorCode(ABC):
54
+ #: The track deck being colored. Set by subclasses in __init__.
55
+ track_deck: TrackDeck
56
+ #: The palette used to turn a 0-1 fraction into an RGB color.
57
+ palette: palettable.palette.Palette
58
+ #: The domain range (in whatever unit the subclass colors by) that maps
59
+ #: onto the full extent of the palette.
60
+ limits: tuple[Any, Any]
61
+
62
+ @abstractmethod
63
+ def __init__(
64
+ self,
65
+ track_deck: TrackDeck,
66
+ palette: palettable.palette.Palette,
67
+ limits: tuple[Any, Any] | None,
68
+ ):
69
+ pass
70
+
71
+ @abstractmethod
72
+ def get_base_rgb_for_track(self, track_idx: int) -> tuple[int, int, int]:
73
+ pass
74
+
75
+ @abstractmethod
76
+ def legend_title(self) -> str:
77
+ """Give the label to print above the on-screen color legend."""
78
+
79
+ @abstractmethod
80
+ def legend_stops(self) -> list[tuple[Any, str]]:
81
+ """Give the (value, label) ticks to print along the color legend.
82
+
83
+ Values are expected ascending, and to span at least the full range
84
+ the legend gradient is drawn over (callers may extend the gradient
85
+ past `limits` to cover the deck's actual extremes; any such
86
+ extension is expected to be reflected as the first/last stop here
87
+ too).
88
+ """
89
+
90
+ @abstractmethod
91
+ def rgb_at_value(self, value: Any) -> tuple[int, int, int]:
92
+ """Give the RGB color for an arbitrary value on this code's domain."""
93
+
94
+ def _rgb_at_fraction(self, fraction_0_to_1: float) -> tuple[int, int, int]:
95
+ """Give the RGB color at a position along `palette`'s continuous colormap.
96
+
97
+ `fraction_0_to_1` is a position within this color code's own `limits`
98
+ (0.0 == `limits[0]`, 1.0 == `limits[1]`), not within the deck's actual
99
+ range of values, which may be wider (values outside `limits` should
100
+ already be clamped to [0, 1] by the caller before reaching here.
101
+ """
102
+ fraction = max(0.0, min(1.0, fraction_0_to_1))
103
+ r, g, b, _ = self.palette.mpl_colormap(fraction)
104
+ return (int(r * 255), int(g * 255), int(b * 255))
105
+
106
+
107
+ class RecencyColorCode(ColorCode):
108
+ def __init__(
109
+ self,
110
+ track_deck: TrackDeck,
111
+ palette: palettable.palette.Palette,
112
+ limits: tuple[Any, Any] | None,
113
+ ):
114
+ self.track_deck = track_deck
115
+ self.palette = palette
116
+ if limits is None:
117
+ self.limits = (
118
+ track_deck.tracks[0].start_time,
119
+ track_deck.tracks[-1].start_time,
120
+ )
121
+ else:
122
+ self.limits = limits
123
+
124
+ def get_base_rgb_for_track(self, track_idx: int) -> tuple[int, int, int]:
125
+ track = self.track_deck.tracks[track_idx]
126
+ recency_0_to_1 = (track.start_time - self.limits[0]) / (
127
+ self.limits[1] - self.limits[0]
128
+ )
129
+ recency_0_to_1 = max(0.0, min(1.0, recency_0_to_1))
130
+ r, g, b, a = self.palette.mpl_colormap(recency_0_to_1)
131
+ return (int(r * 255), int(g * 255), int(b * 255))
132
+
133
+ def legend_title(self) -> str:
134
+ raise NotImplementedError(
135
+ "No color legend is implemented for RecencyColorCode."
136
+ )
137
+
138
+ def legend_stops(self) -> list[tuple[Any, str]]:
139
+ raise NotImplementedError(
140
+ "No color legend is implemented for RecencyColorCode."
141
+ )
142
+
143
+ def rgb_at_value(self, value: Any) -> tuple[int, int, int]:
144
+ raise NotImplementedError(
145
+ "No color legend is implemented for RecencyColorCode."
146
+ )
147
+
148
+
149
+ class TotalDurationColorCode(ColorCode):
150
+ def __init__(
151
+ self,
152
+ track_deck: TrackDeck,
153
+ palette: palettable.palette.Palette,
154
+ limits: tuple[Any, Any] | None,
155
+ ):
156
+ self.track_deck = track_deck
157
+ self.palette = palette
158
+ self.limits = (
159
+ limits if limits is not None else (0, track_deck.max_individual_duration)
160
+ )
161
+
162
+ def get_base_rgb_for_track(self, track_idx: int) -> tuple[int, int, int]:
163
+ track = self.track_deck.tracks[track_idx]
164
+ return self.rgb_at_value(track.duration_seconds)
165
+
166
+ def rgb_at_value(self, value: Any) -> tuple[int, int, int]:
167
+ lo, hi = self.limits
168
+ fraction = (value - lo) / (hi - lo)
169
+ return self._rgb_at_fraction(fraction)
170
+
171
+ def legend_title(self) -> str:
172
+ return "Track duration"
173
+
174
+ def legend_stops(self) -> list[tuple[Any, str]]:
175
+ """Give (value, label) ticks spanning `limits`, extended to the deck's actual extremes.
176
+
177
+ Picks the coarsest "nice" step (from NICE_DURATION_STEPS) that
178
+ yields 2 to 4 ticks within `limits`, falling back to just the two
179
+ limit values if none does. If the deck's shortest or longest track
180
+ falls more than one such step outside `limits`, an extra tick is
181
+ added for it.
182
+ """
183
+ lo, hi = self.limits
184
+ span = hi - lo
185
+ if span <= 0:
186
+ return []
187
+
188
+ for step in NICE_DURATION_STEPS:
189
+ first = math.ceil(lo / step) * step
190
+ last = math.floor(hi / step) * step
191
+ if first > last:
192
+ continue
193
+ count = int((last - first) // step) + 1
194
+ if 2 <= count <= 4:
195
+ stops = [first + i * step for i in range(count)]
196
+ break
197
+ else:
198
+ stops = [lo, hi]
199
+ step = span
200
+
201
+ actual_lo = min(lo, self.track_deck.min_individual_duration)
202
+ actual_hi = max(hi, self.track_deck.max_individual_duration)
203
+ if actual_lo < stops[0] - step:
204
+ stops.insert(0, actual_lo)
205
+ if actual_hi > stops[-1] + step:
206
+ stops.append(actual_hi)
207
+
208
+ return [(value, _format_duration(value)) for value in stops]
209
+
210
+
211
+ ColorCodeClass = enum.Enum(
212
+ "ColorCodeClass",
213
+ [
214
+ ("Recency", RecencyColorCode),
215
+ ("TotalDuration", TotalDurationColorCode),
216
+ ],
217
+ )