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.
- pherotrails-0.1.0a1/PKG-INFO +180 -0
- pherotrails-0.1.0a1/README.md +162 -0
- pherotrails-0.1.0a1/pyproject.toml +32 -0
- pherotrails-0.1.0a1/src/pherotrails/__init__.py +0 -0
- pherotrails-0.1.0a1/src/pherotrails/color_codes.py +217 -0
- pherotrails-0.1.0a1/src/pherotrails/declutter.py +118 -0
- pherotrails-0.1.0a1/src/pherotrails/figure.py +85 -0
- pherotrails-0.1.0a1/src/pherotrails/get_mapbox.py +51 -0
- pherotrails-0.1.0a1/src/pherotrails/main.py +231 -0
- pherotrails-0.1.0a1/src/pherotrails/pil_figure.py +140 -0
- pherotrails-0.1.0a1/src/pherotrails/pipeline.py +115 -0
- pherotrails-0.1.0a1/src/pherotrails/pipeline_model.py +117 -0
- pherotrails-0.1.0a1/src/pherotrails/pipeline_steps.py +471 -0
- pherotrails-0.1.0a1/src/pherotrails/projection.py +217 -0
- pherotrails-0.1.0a1/src/pherotrails/tracks.py +335 -0
- pherotrails-0.1.0a1/src/pherotrails/video.py +71 -0
|
@@ -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
|
+
)
|