geo-activity-playground 0.42.0__py3-none-any.whl → 0.43.1__py3-none-any.whl
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.
- geo_activity_playground/alembic/script.py.mako +0 -6
- geo_activity_playground/alembic/versions/da2cba03b71d_add_photos.py +40 -0
- geo_activity_playground/alembic/versions/script.py.mako +6 -0
- geo_activity_playground/core/activities.py +3 -1
- geo_activity_playground/core/datamodel.py +48 -22
- geo_activity_playground/core/enrichment.py +4 -2
- geo_activity_playground/core/meta_search.py +78 -34
- geo_activity_playground/core/missing_values.py +4 -3
- geo_activity_playground/core/paths.py +2 -0
- geo_activity_playground/core/test_missing_values.py +5 -0
- geo_activity_playground/webui/app.py +38 -13
- geo_activity_playground/webui/blueprints/activity_blueprint.py +25 -15
- geo_activity_playground/webui/blueprints/entry_views.py +4 -1
- geo_activity_playground/webui/blueprints/photo_blueprint.py +198 -0
- geo_activity_playground/webui/blueprints/upload_blueprint.py +11 -0
- geo_activity_playground/webui/search_util.py +23 -7
- geo_activity_playground/webui/templates/activity/show.html.j2 +46 -11
- geo_activity_playground/webui/templates/eddington/distance.html.j2 +1 -2
- geo_activity_playground/webui/templates/eddington/elevation_gain.html.j2 +1 -2
- geo_activity_playground/webui/templates/elevation_eddington/index.html.j2 +18 -15
- geo_activity_playground/webui/templates/heatmap/index.html.j2 +1 -2
- geo_activity_playground/webui/templates/page.html.j2 +8 -0
- geo_activity_playground/webui/templates/photo/map.html.j2 +45 -0
- geo_activity_playground/webui/templates/photo/new.html.j2 +13 -0
- geo_activity_playground/webui/templates/search/index.html.j2 +6 -3
- geo_activity_playground/webui/templates/search_form.html.j2 +47 -22
- geo_activity_playground/webui/templates/summary/index.html.j2 +12 -10
- {geo_activity_playground-0.42.0.dist-info → geo_activity_playground-0.43.1.dist-info}/METADATA +2 -1
- {geo_activity_playground-0.42.0.dist-info → geo_activity_playground-0.43.1.dist-info}/RECORD +32 -28
- {geo_activity_playground-0.42.0.dist-info → geo_activity_playground-0.43.1.dist-info}/LICENSE +0 -0
- {geo_activity_playground-0.42.0.dist-info → geo_activity_playground-0.43.1.dist-info}/WHEEL +0 -0
- {geo_activity_playground-0.42.0.dist-info → geo_activity_playground-0.43.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,198 @@
|
|
1
|
+
import datetime
|
2
|
+
import pathlib
|
3
|
+
import uuid
|
4
|
+
|
5
|
+
import dateutil.parser
|
6
|
+
import exifread
|
7
|
+
import geojson
|
8
|
+
import sqlalchemy
|
9
|
+
from flask import Blueprint
|
10
|
+
from flask import redirect
|
11
|
+
from flask import render_template
|
12
|
+
from flask import request
|
13
|
+
from flask import Response
|
14
|
+
from flask import url_for
|
15
|
+
from PIL import Image
|
16
|
+
from PIL import ImageOps
|
17
|
+
|
18
|
+
from ...core.config import ConfigAccessor
|
19
|
+
from ...core.datamodel import Activity
|
20
|
+
from ...core.datamodel import DB
|
21
|
+
from ...core.datamodel import Photo
|
22
|
+
from ...core.paths import PHOTOS_DIR
|
23
|
+
from ..authenticator import Authenticator
|
24
|
+
from ..authenticator import needs_authentication
|
25
|
+
from ..flasher import Flasher
|
26
|
+
from ..flasher import FlashTypes
|
27
|
+
|
28
|
+
|
29
|
+
def ratio_to_decimal(numbers: list[exifread.utils.Ratio]) -> float:
|
30
|
+
deg, min, sec = numbers.values
|
31
|
+
return deg.decimal() + min.decimal() / 60 + sec.decimal() / 3600
|
32
|
+
|
33
|
+
|
34
|
+
def get_metadata_from_image(path: pathlib.Path) -> dict:
|
35
|
+
with open(path, "rb") as f:
|
36
|
+
tags = exifread.process_file(f)
|
37
|
+
metadata = {}
|
38
|
+
try:
|
39
|
+
metadata["latitude"] = ratio_to_decimal(tags["GPS GPSLatitude"])
|
40
|
+
metadata["longitude"] = ratio_to_decimal(tags["GPS GPSLongitude"])
|
41
|
+
except KeyError:
|
42
|
+
pass
|
43
|
+
try:
|
44
|
+
metadata["time"] = datetime.datetime.strptime(
|
45
|
+
str(tags["EXIF DateTimeOriginal"]), "%Y:%m:%d %H:%M:%S"
|
46
|
+
)
|
47
|
+
except KeyError:
|
48
|
+
pass
|
49
|
+
|
50
|
+
return metadata
|
51
|
+
|
52
|
+
|
53
|
+
def make_photo_blueprint(
|
54
|
+
config_accessor: ConfigAccessor, authenticator: Authenticator, flasher: Flasher
|
55
|
+
) -> Blueprint:
|
56
|
+
blueprint = Blueprint("photo", __name__, template_folder="templates")
|
57
|
+
|
58
|
+
@blueprint.route("/get/<int:id>/<int:size>.webp")
|
59
|
+
def get(id: int, size: int) -> Response:
|
60
|
+
assert size < 5000
|
61
|
+
photo = DB.session.get_one(Photo, id)
|
62
|
+
|
63
|
+
original_path = PHOTOS_DIR() / "original" / photo.path
|
64
|
+
small_path = PHOTOS_DIR() / f"size-{size}" / photo.path.with_suffix(".webp")
|
65
|
+
|
66
|
+
if not small_path.exists():
|
67
|
+
with Image.open(original_path) as im:
|
68
|
+
target_size = (size, size)
|
69
|
+
im = ImageOps.contain(im, target_size)
|
70
|
+
small_path.parent.mkdir(exist_ok=True)
|
71
|
+
im.save(small_path)
|
72
|
+
|
73
|
+
with open(small_path, "rb") as f:
|
74
|
+
return Response(f.read(), mimetype="image/webp")
|
75
|
+
|
76
|
+
@blueprint.route("/map")
|
77
|
+
def map() -> str:
|
78
|
+
return render_template("photo/map.html.j2")
|
79
|
+
|
80
|
+
@blueprint.route("/map-for-all/photos.geojson")
|
81
|
+
def map_for_all() -> Response:
|
82
|
+
photos = DB.session.scalars(sqlalchemy.select(Photo)).all()
|
83
|
+
fc = geojson.FeatureCollection(
|
84
|
+
features=[
|
85
|
+
geojson.Feature(
|
86
|
+
geometry=geojson.Point((photo.longitude, photo.latitude)),
|
87
|
+
properties={
|
88
|
+
"photo_id": photo.id,
|
89
|
+
"url_marker": url_for(".get", id=photo.id, size=128),
|
90
|
+
"url_popup": url_for(".get", id=photo.id, size=512),
|
91
|
+
"url_full": url_for(".get", id=photo.id, size=4096),
|
92
|
+
},
|
93
|
+
)
|
94
|
+
for photo in photos
|
95
|
+
]
|
96
|
+
)
|
97
|
+
return Response(
|
98
|
+
geojson.dumps(fc, sort_keys=True, indent=2, ensure_ascii=False),
|
99
|
+
mimetype="application/json",
|
100
|
+
)
|
101
|
+
|
102
|
+
@blueprint.route("/map-for-activity/<int:activity_id>/photos.geojson")
|
103
|
+
def map_for_activity(activity_id: int) -> Response:
|
104
|
+
activity = DB.session.get_one(Activity, activity_id)
|
105
|
+
fc = geojson.FeatureCollection(
|
106
|
+
features=[
|
107
|
+
geojson.Feature(
|
108
|
+
geometry=geojson.Point((photo.longitude, photo.latitude)),
|
109
|
+
properties={
|
110
|
+
"photo_id": photo.id,
|
111
|
+
"url_marker": url_for(".get", id=photo.id, size=128),
|
112
|
+
"url_popup": url_for(".get", id=photo.id, size=512),
|
113
|
+
"url_full": url_for(".get", id=photo.id, size=4096),
|
114
|
+
},
|
115
|
+
)
|
116
|
+
for photo in activity.photos
|
117
|
+
]
|
118
|
+
)
|
119
|
+
return Response(
|
120
|
+
geojson.dumps(fc, sort_keys=True, indent=2, ensure_ascii=False),
|
121
|
+
mimetype="application/json",
|
122
|
+
)
|
123
|
+
|
124
|
+
@blueprint.route("/new", methods=["GET", "POST"])
|
125
|
+
@needs_authentication(authenticator)
|
126
|
+
def new() -> Response:
|
127
|
+
if request.method == "POST":
|
128
|
+
# check if the post request has the file part
|
129
|
+
if "file" not in request.files:
|
130
|
+
flasher.flash_message(
|
131
|
+
"No file could be found. Did you select a file?", FlashTypes.WARNING
|
132
|
+
)
|
133
|
+
return redirect(url_for(".new"))
|
134
|
+
|
135
|
+
file = request.files["file"]
|
136
|
+
# If the user does not select a file, the browser submits an
|
137
|
+
# empty file without a filename.
|
138
|
+
if file.filename == "":
|
139
|
+
flasher.flash_message("No selected file.", FlashTypes.WARNING)
|
140
|
+
return redirect(url_for(".new"))
|
141
|
+
if not file:
|
142
|
+
flasher.flash_message("Empty file uploaded.", FlashTypes.WARNING)
|
143
|
+
return redirect(url_for(".new"))
|
144
|
+
|
145
|
+
filename = str(uuid.uuid4()) + pathlib.Path(file.filename).suffix
|
146
|
+
path = PHOTOS_DIR() / "original" / filename
|
147
|
+
path.parent.mkdir(exist_ok=True)
|
148
|
+
file.save(path)
|
149
|
+
metadata = get_metadata_from_image(path)
|
150
|
+
|
151
|
+
if "time" not in metadata:
|
152
|
+
flasher.flash_message(
|
153
|
+
"Your image doesn't have the EXIF attribute 'EXIF DateTimeOriginal' and hence cannot be dated.",
|
154
|
+
FlashTypes.DANGER,
|
155
|
+
)
|
156
|
+
return redirect(url_for(".new"))
|
157
|
+
time: datetime.datetime = metadata["time"]
|
158
|
+
|
159
|
+
activity = DB.session.scalar(
|
160
|
+
sqlalchemy.select(Activity)
|
161
|
+
.where(
|
162
|
+
Activity.start.is_not(None),
|
163
|
+
Activity.elapsed_time.is_not(None),
|
164
|
+
Activity.start <= time,
|
165
|
+
)
|
166
|
+
.order_by(Activity.start.desc())
|
167
|
+
.limit(1)
|
168
|
+
)
|
169
|
+
if activity is None or activity.start + activity.elapsed_time < time:
|
170
|
+
flasher.flash_message(
|
171
|
+
f"Your image is from {time} but no activity could be found. Please first upload an activity or fix the time in the photo",
|
172
|
+
FlashTypes.DANGER,
|
173
|
+
)
|
174
|
+
print(activity)
|
175
|
+
|
176
|
+
if "latitude" not in metadata:
|
177
|
+
time_series = activity.time_series
|
178
|
+
print(time_series)
|
179
|
+
row = time_series.loc[time_series["time"] >= time].iloc[0]
|
180
|
+
metadata["latitude"] = row["latitude"]
|
181
|
+
metadata["longitude"] = row["longitude"]
|
182
|
+
|
183
|
+
photo = Photo(
|
184
|
+
filename=filename,
|
185
|
+
time=time,
|
186
|
+
latitude=metadata["latitude"],
|
187
|
+
longitude=metadata["longitude"],
|
188
|
+
activity=activity,
|
189
|
+
)
|
190
|
+
|
191
|
+
DB.session.add(photo)
|
192
|
+
DB.session.commit()
|
193
|
+
|
194
|
+
return redirect(f"/activity/{activity.id}")
|
195
|
+
else:
|
196
|
+
return render_template("photo/new.html.j2")
|
197
|
+
|
198
|
+
return blueprint
|
@@ -1,6 +1,7 @@
|
|
1
1
|
import os
|
2
2
|
import pathlib
|
3
3
|
|
4
|
+
import sqlalchemy
|
4
5
|
from flask import Blueprint
|
5
6
|
from flask import flash
|
6
7
|
from flask import redirect
|
@@ -10,7 +11,10 @@ from flask import url_for
|
|
10
11
|
|
11
12
|
from ...core.activities import ActivityRepository
|
12
13
|
from ...core.config import Config
|
14
|
+
from ...core.datamodel import DB
|
15
|
+
from ...core.datamodel import Kind
|
13
16
|
from ...core.enrichment import populate_database_from_extracted
|
17
|
+
from ...core.tasks import work_tracker_path
|
14
18
|
from ...explorer.tile_visits import compute_tile_evolution
|
15
19
|
from ...explorer.tile_visits import compute_tile_visits_new
|
16
20
|
from ...explorer.tile_visits import TileVisitAccessor
|
@@ -114,6 +118,13 @@ def scan_for_activities(
|
|
114
118
|
populate_database_from_extracted(config)
|
115
119
|
|
116
120
|
if len(repository) > 0:
|
121
|
+
kinds = DB.session.scalars(sqlalchemy.select(Kind)).all()
|
122
|
+
if all(kind.consider_for_achievements == False for kind in kinds):
|
123
|
+
for kind in kinds:
|
124
|
+
kind.consider_for_achievements = True
|
125
|
+
DB.session.commit()
|
126
|
+
tile_visit_accessor.reset()
|
127
|
+
work_tracker_path("tile-state").unlink()
|
117
128
|
compute_tile_visits_new(repository, tile_visit_accessor)
|
118
129
|
compute_tile_evolution(tile_visit_accessor.tile_state, config)
|
119
130
|
tile_visit_accessor.save()
|
@@ -1,3 +1,5 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
1
3
|
from werkzeug.datastructures import MultiDict
|
2
4
|
|
3
5
|
from ..core.config import ConfigAccessor
|
@@ -7,13 +9,20 @@ from .authenticator import Authenticator
|
|
7
9
|
|
8
10
|
|
9
11
|
def search_query_from_form(args: MultiDict) -> SearchQuery:
|
10
|
-
query = SearchQuery(
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
12
|
+
query = SearchQuery.from_primitives(
|
13
|
+
{
|
14
|
+
"equipment": map(int, args.getlist("equipment")),
|
15
|
+
"kind": map(int, args.getlist("kind")),
|
16
|
+
"tag": map(int, args.getlist("tag")),
|
17
|
+
"name": args.get("name", None),
|
18
|
+
"name_case_sensitive": _parse_bool(
|
19
|
+
args.get("name_case_sensitive", "false")
|
20
|
+
),
|
21
|
+
"start_begin": args.get("start_begin", None),
|
22
|
+
"start_end": args.get("start_end", None),
|
23
|
+
"distance_km_min": _optional_float(args.get("distance_km_min", None)),
|
24
|
+
"distance_km_max": _optional_float(args.get("distance_km_max", None)),
|
25
|
+
}
|
17
26
|
)
|
18
27
|
|
19
28
|
return query
|
@@ -23,6 +32,13 @@ def _parse_bool(s: str) -> bool:
|
|
23
32
|
return s == "true"
|
24
33
|
|
25
34
|
|
35
|
+
def _optional_float(s: str) -> Optional[float]:
|
36
|
+
if s:
|
37
|
+
return float(s)
|
38
|
+
else:
|
39
|
+
return None
|
40
|
+
|
41
|
+
|
26
42
|
class SearchQueryHistory:
|
27
43
|
def __init__(
|
28
44
|
self, config_accessor: ConfigAccessor, authenticator: Authenticator
|
@@ -10,14 +10,16 @@
|
|
10
10
|
<div class="row mb-3">
|
11
11
|
<div class="col-sm-12 col-md-4">
|
12
12
|
<dl>
|
13
|
-
<dt>Name</dt>
|
14
|
-
<dd>{{ activity.name }}</dd>
|
15
|
-
|
16
13
|
{% if activity.kind %}
|
17
14
|
<dt>Kind</dt>
|
18
15
|
<dd>{{ activity.kind.name }}</dd>
|
19
16
|
{% endif %}
|
20
17
|
|
18
|
+
{% if activity.equipment %}
|
19
|
+
<dt>Equipment</dt>
|
20
|
+
<dd>{{ activity.equipment.name }}</dd>
|
21
|
+
{% endif %}
|
22
|
+
|
21
23
|
{% if activity.tags %}
|
22
24
|
<dt>Tags</dt>
|
23
25
|
<dd>
|
@@ -61,11 +63,6 @@
|
|
61
63
|
<dd>{{ activity.elevation_gain|round(0)|int }} m</dd>
|
62
64
|
{% endif %}
|
63
65
|
|
64
|
-
{% if activity.equipment %}
|
65
|
-
<dt>Equipment</dt>
|
66
|
-
<dd>{{ activity.equipment.name }}</dd>
|
67
|
-
{% endif %}
|
68
|
-
|
69
66
|
{% if new_tiles[14] %}
|
70
67
|
<dt>New Explorer Tiles</dt>
|
71
68
|
<dd>{{ new_tiles[14] }}</dd>
|
@@ -97,9 +94,33 @@
|
|
97
94
|
}).addTo(map);
|
98
95
|
|
99
96
|
let geojson = L.geoJSON({{ color_line_geojson| safe }}, {
|
100
|
-
style: function (feature) { return { color: feature.properties.color } }
|
97
|
+
style: function (feature) { return { color: feature.properties.color ? feature.properties.color : 'red' } }
|
101
98
|
}).addTo(map)
|
102
99
|
map.fitBounds(geojson.getBounds());
|
100
|
+
|
101
|
+
|
102
|
+
|
103
|
+
fetch("{{ url_for('photo.map_for_activity', activity_id = activity.id) }}")
|
104
|
+
.then(function (response) {
|
105
|
+
return response.json();
|
106
|
+
})
|
107
|
+
.then(function (data) {
|
108
|
+
L.geoJSON(data, {
|
109
|
+
pointToLayer: function (feature, latlng) {
|
110
|
+
return L.marker(latlng, {
|
111
|
+
icon: new L.Icon({
|
112
|
+
iconSize: [32, 32],
|
113
|
+
iconAnchor: [16, 16],
|
114
|
+
popupAnchor: [16, 0],
|
115
|
+
iconUrl: feature.properties.url_marker,
|
116
|
+
})
|
117
|
+
});
|
118
|
+
},
|
119
|
+
onEachFeature: function (feature, layer) {
|
120
|
+
layer.bindPopup(`<a href="${feature.properties.url_full}" target="_blank"><img src="${feature.properties.url_popup}" /></a>`)
|
121
|
+
}
|
122
|
+
}).addTo(map);
|
123
|
+
});
|
103
124
|
</script>
|
104
125
|
|
105
126
|
|
@@ -113,6 +134,7 @@
|
|
113
134
|
}
|
114
135
|
</style>
|
115
136
|
|
137
|
+
{% if line_color_bar %}
|
116
138
|
<div>
|
117
139
|
{% for value, color in line_color_bar.colors %}
|
118
140
|
<span class="colorbar" style="width: 15px; background-color: {{ color }}">{{ value }}</span>
|
@@ -132,11 +154,23 @@
|
|
132
154
|
</select>
|
133
155
|
</form>
|
134
156
|
</div>
|
157
|
+
{% endif %}
|
135
158
|
</div>
|
136
159
|
</div>
|
137
160
|
|
138
|
-
|
161
|
+
{% if activity.photos %}
|
162
|
+
<h2 class="mb-3">Photos</h2>
|
139
163
|
<div class="row mb-3">
|
164
|
+
{% for photo in activity.photos %}
|
165
|
+
<div class="col-md-3">
|
166
|
+
<img src="{{ url_for('photo.get', id=photo.id, size=512) }}" width="100%" />
|
167
|
+
</div>
|
168
|
+
{% endfor %}
|
169
|
+
</div>
|
170
|
+
{% endif %}
|
171
|
+
|
172
|
+
|
173
|
+
<div class=" row mb-3">
|
140
174
|
<div class="col">
|
141
175
|
<h2>Distance & speed</h2>
|
142
176
|
</div>
|
@@ -212,7 +246,8 @@
|
|
212
246
|
|
213
247
|
{% if new_tiles_geojson %}
|
214
248
|
<h2>New explorer tiles</h2>
|
215
|
-
<p>With this activity you have explored new explorer tiles. The following maps show the new tiles on the respective
|
249
|
+
<p>With this activity you have explored new explorer tiles. The following maps show the new tiles on the respective
|
250
|
+
zoom
|
216
251
|
levels.</p>
|
217
252
|
<script>
|
218
253
|
function add_map(id, geojson) {
|
@@ -1,11 +1,10 @@
|
|
1
1
|
{% extends "page.html.j2" %}
|
2
|
-
{% from "search_form.html.j2" import search_form %}
|
3
2
|
|
4
3
|
{% block container %}
|
5
4
|
<h1 class="mb-3">Eddington Number</h1>
|
6
5
|
|
7
6
|
<div class="mb-3">
|
8
|
-
{
|
7
|
+
{% include "search_form.html.j2" %}
|
9
8
|
</div>
|
10
9
|
|
11
10
|
<div class="row mb-3">
|
@@ -1,11 +1,10 @@
|
|
1
1
|
{% extends "page.html.j2" %}
|
2
|
-
{% from "search_form.html.j2" import search_form %}
|
3
2
|
|
4
3
|
{% block container %}
|
5
4
|
<h1 class="mb-3">Eddington Number for Elevation Gain</h1>
|
6
5
|
|
7
6
|
<div class="mb-3">
|
8
|
-
{
|
7
|
+
{% include "search_form.html.j2" %}
|
9
8
|
</div>
|
10
9
|
|
11
10
|
<div class="row mb-3">
|
@@ -1,11 +1,10 @@
|
|
1
1
|
{% extends "page.html.j2" %}
|
2
|
-
{% from "search_form.html.j2" import search_form %}
|
3
2
|
|
4
3
|
{% block container %}
|
5
4
|
<h1 class="mb-3">Eddington Number for Elevation Gain</h1>
|
6
5
|
|
7
6
|
<div class="mb-3">
|
8
|
-
{
|
7
|
+
{% include "search_form.html.j2" %}
|
9
8
|
</div>
|
10
9
|
|
11
10
|
<div class="row mb-3">
|
@@ -13,20 +12,21 @@
|
|
13
12
|
<form method="GET">
|
14
13
|
<div class="mb-3">
|
15
14
|
<label class="form-label">Divisor</label>
|
16
|
-
<select class="form-select" aria-label="Divisor" name="elevation_eddington_divisor"
|
15
|
+
<select class="form-select" aria-label="Divisor" name="elevation_eddington_divisor"
|
16
|
+
onchange="this.form.submit()">
|
17
17
|
{% for dv in divisor_values_avail %}
|
18
18
|
<option {% if dv==elevation_eddington_divisor %} selected {% endif %}>{{ dv }}</option>
|
19
19
|
{% endfor %}
|
20
20
|
{% for key, value in query.items() %}
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
21
|
+
{% if value and key != "active" %}
|
22
|
+
{% if value is sequence %}
|
23
|
+
{% for v in value %}
|
24
|
+
<input type="hidden" name="{{ key }}" value="{{ v }}" />
|
25
|
+
{% endfor %}
|
26
|
+
{% else %}
|
27
|
+
<input type="hidden" name="{{ key }}" value="{{ value }}" />
|
28
|
+
{% endif %}
|
29
|
+
{% endif %}
|
30
30
|
{% endfor %}
|
31
31
|
</select>
|
32
32
|
</div>
|
@@ -37,9 +37,11 @@
|
|
37
37
|
<p>Your Eddington number with divisor {{ elevation_eddington_divisor }} is <b>{{ eddington_number }}</b>.</p>
|
38
38
|
|
39
39
|
<p>That means that on {{ (eddington_number / elevation_eddington_divisor) | int }} separate days you
|
40
|
-
have recorded activities with an elevation gain of more than {{ eddington_number }} m. Going high is one
|
40
|
+
have recorded activities with an elevation gain of more than {{ eddington_number }} m. Going high is one
|
41
|
+
thing, going often is
|
41
42
|
another. But going high often is hard. Also if you increment the Eddington number, all days with less
|
42
|
-
elevation gain will not count any more. It becomes increasingly hard to increment the Eddington number
|
43
|
+
elevation gain will not count any more. It becomes increasingly hard to increment the Eddington number
|
44
|
+
because you
|
43
45
|
don't only need to achieve a higher count, but all flatter activities don't count towards the bigger number.
|
44
46
|
</p>
|
45
47
|
</div>
|
@@ -74,7 +76,8 @@
|
|
74
76
|
<div class="col">
|
75
77
|
<h2>Plot</h2>
|
76
78
|
|
77
|
-
<p>In a graphical representation, the Eddington number is the elevation gain where the red line intersects with
|
79
|
+
<p>In a graphical representation, the Eddington number is the elevation gain where the red line intersects with
|
80
|
+
the
|
78
81
|
blue area.</p>
|
79
82
|
|
80
83
|
{{ vega_direct("logarithmic_plot", logarithmic_plot) }}
|
@@ -1,11 +1,10 @@
|
|
1
1
|
{% extends "page.html.j2" %}
|
2
|
-
{% from "search_form.html.j2" import search_form %}
|
3
2
|
|
4
3
|
{% block container %}
|
5
4
|
<h1 class="mb-3">Heatmap</h1>
|
6
5
|
|
7
6
|
<div class="mb-3">
|
8
|
-
{
|
7
|
+
{% include "search_form.html.j2" %}
|
9
8
|
</div>
|
10
9
|
|
11
10
|
<div class="row mb-3">
|
@@ -123,6 +123,12 @@
|
|
123
123
|
</li>
|
124
124
|
{% endif %}
|
125
125
|
|
126
|
+
{% if photo_count > 0 %}
|
127
|
+
<li class="nav-item">
|
128
|
+
<a class="nav-link" aria-current="page" href="{{ url_for('photo.map') }}">Photo Map</a>
|
129
|
+
</li>
|
130
|
+
{% endif %}
|
131
|
+
|
126
132
|
<li class="nav-item dropdown">
|
127
133
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
|
128
134
|
aria-expanded="false">
|
@@ -131,6 +137,8 @@
|
|
131
137
|
<ul class="dropdown-menu">
|
132
138
|
<li><a class="dropdown-item" href="{{ url_for('upload.index') }}">Upload Activities</a>
|
133
139
|
</li>
|
140
|
+
<li><a class="dropdown-item" href="{{ url_for('photo.new') }}">Upload Photos</a>
|
141
|
+
</li>
|
134
142
|
<li><a class="dropdown-item" href="{{ url_for('upload.reload') }}">Scan New
|
135
143
|
Activities</a>
|
136
144
|
</li>
|
@@ -0,0 +1,45 @@
|
|
1
|
+
{% extends "page.html.j2" %}
|
2
|
+
|
3
|
+
{% block container %}
|
4
|
+
<h1>Photo Map</h1>
|
5
|
+
|
6
|
+
<div id="photo-map" style="height: 800px;" class="mb-3"></div>
|
7
|
+
<script>
|
8
|
+
var map = L.map('photo-map', {
|
9
|
+
fullscreenControl: true
|
10
|
+
});
|
11
|
+
L.tileLayer('/tile/color/{z}/{x}/{y}.png', {
|
12
|
+
maxZoom: 19,
|
13
|
+
attribution: '{{ map_tile_attribution|safe }}'
|
14
|
+
}).addTo(map);
|
15
|
+
|
16
|
+
fetch("{{ url_for('photo.map_for_all') }}")
|
17
|
+
.then(function (response) {
|
18
|
+
return response.json();
|
19
|
+
})
|
20
|
+
.then(function (data) {
|
21
|
+
let layer = L.geoJSON(data, {
|
22
|
+
pointToLayer: function (feature, latlng) {
|
23
|
+
return L.marker(latlng, {
|
24
|
+
icon: new L.Icon({
|
25
|
+
iconSize: [32, 32],
|
26
|
+
iconAnchor: [16, 16],
|
27
|
+
popupAnchor: [16, 0],
|
28
|
+
iconUrl: feature.properties.url_marker,
|
29
|
+
})
|
30
|
+
});
|
31
|
+
},
|
32
|
+
onEachFeature: function (feature, layer) {
|
33
|
+
layer.bindPopup(`<a href="${feature.properties.url_full}" target="_blank"><img src="${feature.properties.url_popup}" /></a>`)
|
34
|
+
}
|
35
|
+
})
|
36
|
+
|
37
|
+
let group = L.markerClusterGroup()
|
38
|
+
group.addLayer(layer)
|
39
|
+
|
40
|
+
group.addTo(map);
|
41
|
+
map.fitBounds(group.getBounds());
|
42
|
+
});
|
43
|
+
</script>
|
44
|
+
|
45
|
+
{% endblock %}
|
@@ -0,0 +1,13 @@
|
|
1
|
+
{% extends "page.html.j2" %}
|
2
|
+
|
3
|
+
{% block container %}
|
4
|
+
<h1>Upload Photo</h1>
|
5
|
+
|
6
|
+
<form method="POST" enctype="multipart/form-data">
|
7
|
+
<div class="mb-3">
|
8
|
+
<label for="file" class="form-label">Photo file</label>
|
9
|
+
<input type="file" name="file" id="file" class="form-control">
|
10
|
+
</div>
|
11
|
+
<button type="submit" class="btn btn-primary">Upload</button>
|
12
|
+
</form>
|
13
|
+
{% endblock %}
|
@@ -1,12 +1,11 @@
|
|
1
1
|
{% extends "page.html.j2" %}
|
2
|
-
{% from "search_form.html.j2" import search_form %}
|
3
2
|
|
4
3
|
{% block container %}
|
5
4
|
|
6
5
|
<h1 class="row mb-3">Activities Overview & Search</h1>
|
7
6
|
|
8
7
|
<div class="mb-3">
|
9
|
-
{
|
8
|
+
{% include "search_form.html.j2" %}
|
10
9
|
</div>
|
11
10
|
|
12
11
|
<table class="table table-sort table-arrows">
|
@@ -32,7 +31,11 @@
|
|
32
31
|
</td>
|
33
32
|
<td>{{ '%.1f' % activity["distance_km"] }} km</td>
|
34
33
|
<td>{{ activity.elapsed_time|td }}</td>
|
35
|
-
<td>
|
34
|
+
<td>
|
35
|
+
{% if not activity.average_speed_moving_kmh|isna %}
|
36
|
+
{{ activity.average_speed_moving_kmh|round(1) }}
|
37
|
+
{% endif %}
|
38
|
+
</td>
|
36
39
|
<td>{{ activity["equipment"] }}</td>
|
37
40
|
<td>{{ activity['kind'] }}</td>
|
38
41
|
</tr>
|