Analyse des traces GPX issus d'un GPS
Dossier travail: $HOME/media/dplus/cartographie/analyse-gpx/
Analyse
Traitement des fichiers GPX issus d’un GPS Garmin depuis le dossier gpxdata et conversion en fichiers JSON/JS.
Le script analysegpx.py parcourt un dossier contenant des fichiers .gpx (export GPS Garmin), calcule des infos de chaque trace, fait un géocodage inverse pour convertir le point de départ (latitude/longitude) en lieu (adresse/commune/CP), puis génère deux fichiers de sortie :
tracestableau.json: tableau d’objets JSON (1 entrée par fichier GPX)tracesdataset.js: tableau JSvar addressPoints = [...](1 entrée par fichier GPX)
Déroulement principal
- Arguments :
gpx_folder(dossier source) etoutput_folder(dossier destination), plus--pause(temporisation entre fichiers). - Recherche : liste tous les fichiers
*.gpxdansgpx_folder. - Prépare les fichiers de sortie :
tracestableau.jsondémarre par[\ntracesdataset.jsdémarre parvar addressPoints = [\nPuis, si les fichiers existent déjà, le script essaie de gérer la “virgule/finale” avecstrip_trailing_closer(...).
- Charge un cache de géocodage depuis
geo_cache.jsondansoutput_folder(pour éviter de refaire des requêtes pour des coordonnées déjà vues). - Pour chaque fichier GPX :
- Parse le GPX avec
gpxpy. - Prend le premier point de la première piste/segment (latitude/longitude).
- Fait un reverse geocode (Nominatim via geopy) pour obtenir une adresse, avec jusqu’à 3 tentatives et un backoff.
- Extrait depuis l’adresse découpée en morceaux :
lieu(élément proche de la fin),commune,cp(code postal, supposé être l’avant-dernier “morceau” de la découpe). (Ces indices supposent un format d’adresse stable.)
- Calcule des métriques GPX :
length_2d→distance- temps/mouvement →
vitessemoyenne get_uphill_downhill()→niveau(montée totale)- date de début →
jour(formatYYYY-MM-DD)
- Ajoute une entrée dans les deux fichiers :
- JSON : objet
{ id, latitude, longitude, lieu, commune, cp, distance, vitesse, niveau, jour, gpx } - JS : tableau
[lat, lon, lieu, commune, cp, distance, vitesse, niveau, jour, fichier, dataIndex]
- JSON : objet
- Si le géocodage réussit (
ok=True), le GPX est déplacé versoutput_folder. S’il échoue, le fichier reste dans le dossier source et est listé danserreurs. - Attend
--pausesecondes.
- Parse le GPX avec
- Finalise les fichiers :
tracestableau.jsontermine par]\ntracesdataset.jstermine par];\n
- Sauvegarde le cache géocodage dans
geo_cache.json. - Affiche un résumé : nombre traité, nombre en erreur.
Points importants / effets concrets
- Le script ne géocode qu’une fois par GPX : sur le point de départ (pas sur tout le parcours).
- La réussite dépend surtout du reverse geocode : si ça échoue, le fichier GPX n’est pas “accepté” (même si les stats GPX existent).
- Le cache
_geo_cacheclé surround(lat,6),round(lon,6)réduit fortement les requêtes répétées. - Il modifie/ajoute dans des fichiers “tableaux” en gérant la virgule finale via
strip_trailing_closer.
Script analysegpx.py
Le script analysegpx.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
#!/usr/bin/python
# -*- coding: utf-8 -*-
"""
tracesgpxnew.py
"""
import argparse
import calendar
import datetime
import fnmatch
import json
import logging as mod_logging
import math as mod_math
import os
import shutil
import sys
import time
import gpxpy as mod_gpxpy
import geopy.geocoders
from geopy.geocoders import Nominatim
def format_time(time_s):
if not time_s:
return 'n/a'
minutes = mod_math.floor(time_s / 60.)
hours = mod_math.floor(minutes / 60.)
return '%s:%s:%s' % (str(int(hours)).zfill(2), str(int(minutes % 60)).zfill(2), str(int(time_s % 60)).zfill(2))
def format_date(date_s):
if not date_s:
return 'n/a'
return '%s' % (date_s.strftime("%d %b %Y"))
def format_heure(heure_s):
if not heure_s:
return 'n/a'
return '%s' % (heure_s.strftime("%H:%M:%S"))
def format_long_length(length):
return '{:.3f}km'.format(length / 1000.)
def format_long_length_num(length):
return '{:.3f}'.format(length / 1000.)
def format_short_length(length):
return '{:.2f}m'.format(length)
def format_short_length_num(length):
return '{:.2f}'.format(length)
def format_speed(speed):
if not speed:
speed = 0
return '{:.2f}km/h'.format(speed * 3600. / 1000.)
def format_speed_num(speed):
if not speed:
speed = 0
return '{:.2f}'.format(speed * 3600. / 1000.)
def quelJour(date):
jour = datetime.datetime.strptime(date, '%d %m %Y').weekday()
return calendar.day_name[jour]
def nblignes(nf):
return len(open(nf).readlines())
_geo_cache = {}
def _load_geo_cache(path=None):
global _geo_cache
if path and os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as fh:
_geo_cache = json.load(fh)
except Exception:
_geo_cache = {}
def _save_geo_cache(path=None):
global _geo_cache
if path:
try:
with open(path, "w", encoding="utf-8") as fh:
json.dump(_geo_cache, fh, ensure_ascii=False, indent=2)
except Exception:
pass
def strip_trailing_closer(path, closers=("]", "];")):
with open(path, "rb+") as fh:
fh.seek(0, os.SEEK_END)
pos = fh.tell()
if pos == 0:
return
while pos > 0:
pos -= 1
fh.seek(pos)
c = fh.read(1)
if c not in b" \t\r\n":
break
for closer in closers:
bcloser = closer.encode("utf-8")
start = pos - len(bcloser) + 1
if start >= 0:
fh.seek(start)
if fh.read(len(bcloser)) == bcloser:
fh.seek(start)
fh.truncate()
return
def print_gpx_part_info(gpx_part, jsTraces, dataTraces, fichier, dataIndex, json_first):
"""
Retourne:
- json_first
- ok (True si traitement OK, False si erreur géocodage)
"""
ok = True
try:
points = gpx_part.tracks[0].segments[0].points
if not points:
raise ValueError("No points in gpx part")
lati = points[0].latitude
longi = points[0].longitude
except Exception as e:
print(f"ERROR: cannot read points for {fichier}: {e}")
sys.stdout.flush()
return json_first, False
cache_key = f"{round(lati,6)},{round(longi,6)}"
address_parts = []
if cache_key in _geo_cache:
address = _geo_cache[cache_key]
if address:
address_parts = address.split(",")
else:
print(f"--> geocode reverse for fichier={fichier} lat={lati} lon={longi}")
sys.stdout.flush()
geopy.geocoders.options.default_user_agent = 'gxinforep-tracesgpx'
geopy.geocoders.options.default_timeout = 30
geolocator = Nominatim()
location = None
geo_error = None
for attempt in range(1, 4):
try:
location = geolocator.reverse(f"{lati},{longi}")
geo_error = None
break
except Exception as e:
geo_error = str(e)
print(f"WARNING: geocode attempt {attempt} failed for {fichier}: {e}")
sys.stdout.flush()
if attempt < 3:
wait = 2 * (2 ** (attempt - 1))
time.sleep(wait)
if location and getattr(location, "address", None):
address = location.address
address_parts = address.split(",")
_geo_cache[cache_key] = address
else:
_geo_cache[cache_key] = ""
ok = False
print(f"ERROR: geocode failed for {fichier}: {geo_error}")
z = len(address_parts)
def part(idx, default=""):
try:
return address_parts[idx].strip()
except Exception:
return default
lieu = part(z - 8)
commune = part(z - 7)
cposte = part(z - 2)
try:
length_2d = gpx_part.length_2d() or 0.0
except Exception:
length_2d = 0.0
try:
moving_time, stopped_time, moving_distance, stopped_distance, max_speed = gpx_part.get_moving_data()
except Exception:
moving_time = stopped_time = moving_distance = stopped_distance = max_speed = 0.0
try:
uphill, downhill = gpx_part.get_uphill_downhill()
except Exception:
uphill = downhill = 0.0
try:
start_time, end_time = gpx_part.get_time_bounds()
except Exception:
start_time = end_time = None
vmoy_num = (format_speed_num(moving_distance / moving_time) if moving_time and moving_time > 0 else "0.00")
niveau_str = format_short_length_num(uphill)
distance_str = format_long_length_num(length_2d)
jour_str = start_time.strftime("%Y-%m-%d") if start_time else "n/a"
def esc(s):
return str(s).replace('"', '\\"')
json_entry = (
'{\n'
' "id": ' + str(dataIndex) + ','
' "latitude": ' + str(lati) + ','
' "longitude": ' + str(longi) + ','
' "lieu": "' + esc(lieu) + '",'
' "commune": "' + esc(commune) + '",'
' "cp": "' + esc(cposte) + '",'
' "distance": "' + distance_str + '",'
' "vitesse": "' + vmoy_num + '",'
' "niveau": "' + niveau_str + '",'
' "jour": "' + jour_str + '",'
' "gpx": "' + esc(fichier) + '"\n'
'}'
)
if json_first:
jsTraces.write(json_entry)
json_first = False
else:
jsTraces.write(',\n' + json_entry)
js_entry = (
'[' + str(lati) + ',' + str(longi) + ',"' + esc(lieu) + '","' + esc(commune) +
'","' + esc(cposte) + '","' + distance_str + '","' + vmoy_num + '","' + niveau_str +
'","' + jour_str + '","' + esc(fichier) + '",' + str(dataIndex) + '],\n'
)
dataTraces.write(js_entry)
return json_first, ok
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Traite les fichiers GPX depuis un dossier 'garmin' et génère des fichiers JSON/JS.")
parser.add_argument('gpx_folder', help="Dossier contenant les fichiers GPX")
parser.add_argument('output_folder', help="Dossier de sortie")
parser.add_argument('--pause', type=float, default=2.0, help="Pause entre deux fichiers GPX")
args = parser.parse_args()
garmin = os.path.join(args.gpx_folder, '')
dossier = os.path.join(args.output_folder, '')
if not os.path.isdir(garmin):
print("Dossier garmin inexistant:", garmin)
sys.exit(1)
if not os.path.isdir(dossier):
print("Dossier gpx inexistant:", dossier)
sys.exit(1)
print("Dossier contenant les fichiers GPX à traiter:", garmin)
print("Dossier contenant les fichiers GPX après traitement:", dossier)
NomSansExt = "tracestableau"
jsonFile = os.path.join(dossier, NomSansExt + ".json")
dataFile = os.path.join(dossier, "tracesdataset.js")
pattern = "*.gpx"
listOfFiles = sorted(os.listdir(garmin))
matches = [f for f in listOfFiles if fnmatch.fnmatch(f, pattern)]
if not matches:
print("pas de fichier à traiter")
sys.exit(0)
print("Nombre de fichiers:", len(matches))
def ensure_file_start(path, start_text):
if not os.path.exists(path):
with open(path, "w", encoding="utf-8") as fh:
fh.write(start_text)
return True
return False
json_new = ensure_file_start(jsonFile, "[\n")
data_new = ensure_file_start(dataFile, "var addressPoints = [\n")
def chop_last_line(path):
with open(path, "rb+") as fh:
fh.seek(0, os.SEEK_END)
if fh.tell() == 0:
return
pos = fh.tell() - 1
while pos > 0:
fh.seek(pos)
if fh.read(1) == b"\n":
fh.truncate(pos)
return
pos -= 1
fh.truncate(0)
if not json_new:
#chop_last_line(jsonFile)
strip_trailing_closer(jsonFile, ("]",))
if not data_new:
#chop_last_line(dataFile)
strip_trailing_closer(dataFile, ("];",))
jsTraces = open(jsonFile, "a", encoding="utf-8")
dataTraces = open(dataFile, "a", encoding="utf-8")
json_first = json_new
if data_new:
dataIndex = 0
else:
with open(dataFile, "r", encoding="utf-8") as fh:
content = fh.read()
dataIndex = content.count("],\n")
_load_geo_cache(os.path.join(dossier, "geo_cache.json"))
erreurs = []
traites = 0
for fichier in matches:
try:
print("--> processing", fichier)
sys.stdout.flush()
with open(os.path.join(garmin, fichier), "r", encoding="utf-8") as gf:
gpx = mod_gpxpy.parse(gf)
json_first, ok = print_gpx_part_info(gpx, jsTraces, dataTraces, fichier, dataIndex, json_first)
if ok:
shutil.move(os.path.join(garmin, fichier), os.path.join(dossier, fichier))
traites += 1
dataIndex += 1
else:
erreurs.append(fichier)
print("Fichier conservé dans le dossier source:", fichier)
time.sleep(args.pause)
except Exception as e:
mod_logging.exception(e)
erreurs.append(fichier)
print("Error processing", fichier, ":", e)
print("Fichier conservé dans le dossier source:", fichier)
time.sleep(args.pause)
jsTraces.write('\n]\n')
jsTraces.close()
dataTraces.write('\n];\n')
dataTraces.close()
_save_geo_cache(os.path.join(dossier, "geo_cache.json"))
print("Traitement terminé — écrit :", jsonFile, dataFile)
print("Fichiers déplacés:", traites)
print("Fichiers restés en erreur:", len(erreurs))
if erreurs:
for f in erreurs:
print(" -", f)
Tableau
Le script tableau.py lance une petite application Tkinter qui charge un fichier JSON contenant des traces GPX, puis affiche ces données dans une table (Treeview) avec :
- recherche (filtre texte sur toutes les colonnes)
- tri par colonne (avec tri spécial sur
jouren date) - affichage/masquage de colonnes “cachables” (
id/latitude/longitudeetgpx) - thème clair/sombre
- copie des GPX sélectionnés vers un dossier temporaire
- sauvegarde des préférences d’UI (colonnes visibles, tri, thème) dans un fichier de config JSON.
Fonctionnement concret
- Au démarrage, il lit un fichier
config.json(ou celui passé via--config) et en extrait :json_path(le JSON à afficher),gpx_dir(dossier contenant les.gpx),- éventuellement
temp_dir,visible_columns,default_sort,window,theme.
- Il charge
tracestableau.jsonen mémoire (self.data) puis affiche toutes les lignes. - Chaque ligne correspond à un élément avec les colonnes :
id, latitude, longitude, lieu, commune, cp, distance, vitesse, niveau, jour, gpx. - La barre “Recherche” filtre la table en gardant les lignes où le texte recherché apparaît dans n’importe quelle colonne.
- Cliquer sur un en-tête trie la table, et il met une flèche
↓/↑sur l’en-tête de la colonne active. - Les boutons “Toggle id/lat/lon” et “Toggle gpx” masquent/affichent ces colonnes et mettent à jour la vue + la config.
- “Copier GPX sélection” :
- récupère
gpxpour les lignes sélectionnées, - copie les fichiers correspondants depuis
GPX_DIRversTEMP_DIR(avecshutil.copy2), - affiche la liste copiée dans la console.
- récupère
- Le bouton “Afficher sélection (id)” affiche juste les
iddes lignes sélectionnées dans la console. geo_cache/géocodage n’est pas concerné ici : il travaille uniquement sur le JSON déjà produit.
Fichier config.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"default_sort": {
"column": "jour",
"descending": true
},
"json_path": "/home/yann/media/dplus/cartographie/analyse-gpx/traces/tracestableau.json",
"gpx_dir": "/home/yann/media/dplus/cartographie/analyse-gpx/traces",
"temp_dir": "/tmp/gpx-studio-drop",
"visible_columns": [
"lieu",
"commune",
"cp",
"distance",
"vitesse",
"niveau",
"jour"
],
"window": {
"width": 1200,
"height": 700,
"icon_path": "/home/yann/media/dplus/cartographie/analyse-gpx/assets/icon.ico"
},
"theme": {
"mode": "light"
}
}
Script tableau.py
script python tableau.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
import json
import os
import shutil
import tkinter as tk
from tkinter import ttk
from pathlib import Path
from datetime import datetime
import argparse
import copy
# --- config ---
JSON_PATH = "/home/yann/media/dplus/cartographie/analyse-gpx/traces/tracestableau.json"
GPX_DIR = Path("/home/yann/media/dplus/cartographie/analyse-gpx/traces")
TEMP_DIR = "/tmp/gpx-studio-drop"
COLUMNS = ["id", "latitude", "longitude", "lieu", "commune", "cp",
"distance", "vitesse", "niveau", "jour", "gpx"]
HIDEABLE_COLS = ["id", "latitude", "longitude", "gpx"]
DEFAULT_VISIBLE = {"lieu", "commune", "cp", "distance", "vitesse", "niveau", "jour", "gpx"}
def load_config(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def is_number(s):
try:
float(s)
return True
except Exception:
return False
def load_data_from_file(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
class App(tk.Tk):
def __init__(self, json_path, visible_columns, default_sort, config_path=None, window_cfg=None, theme_cfg=None):
super().__init__()
self.title("Table JSON - tri + recherche")
window_cfg = window_cfg or {}
theme_cfg = theme_cfg or {}
w = int(window_cfg.get("width", 1200))
h = int(window_cfg.get("height", 700))
self.geometry(f"{w}x{h}")
icon_path = window_cfg.get("icon_path")
if icon_path:
try:
self.iconbitmap(icon_path)
except Exception:
pass
self.data = load_data_from_file(json_path)
self.filtered = list(self.data)
# état colonnes visibles
self.visible_cols = set(visible_columns)
# tri par défaut: jour décroissant
self.sort_col = default_sort["column"]
self.sort_reverse = bool(default_sort.get("descending", True))
# ---------- UI ----------
top = tk.Frame(self)
top.pack(fill="x", padx=10, pady=8)
tk.Label(top, text="Recherche :").pack(side="left")
self.search_var = tk.StringVar()
self.search_var.trace_add("write", lambda *_: self.apply_filter())
tk.Entry(top, textvariable=self.search_var).pack(side="left", fill="x", expand=True, padx=8)
self.config_path = config_path
#self.visible_cols = set(visible_columns)
self.tree = ttk.Treeview(self, columns=COLUMNS, show="headings", selectmode="extended")
self.tree.pack(fill="both", expand=True, padx=10, pady=8)
# config initial colonnes
for c in COLUMNS:
self._apply_column_style(c)
bottom = tk.Frame(self)
bottom.pack(fill="x", padx=10, pady=4)
self.btn_selection = tk.Button(bottom, text="Afficher sélection (id)", command=self.show_selection)
self.btn_selection.pack(side="left", padx=6)
self.theme_mode = (theme_cfg or {}).get("mode", "light")
self.btn_theme = tk.Button(bottom, text="Thème: clair/sombre", command=self.toggle_theme)
self.btn_theme.pack(side="left", padx=6)
tk.Button(
bottom,
text="Toggle id/lat/lon",
command=lambda: self.toggle_hidden(["id", "latitude", "longitude"])
).pack(side="left", padx=6)
tk.Button(
bottom,
text="Toggle gpx",
command=lambda: self.toggle_hidden(["gpx"])
).pack(side="left", padx=6)
tk.Button(
bottom,
text="Copier GPX sélection",
command=self.copy_selected_gpx_to_temp
).pack(side="left", padx=6)
# affichage initial (tri déjà configuré)
self.refresh_table()
# applique le tri depuis default_sort sans toggler le sens
self.sort_col = default_sort["column"]
self.sort_reverse = bool(default_sort.get("descending", True))
self.sort_by(self.sort_col, from_header=False)
self.apply_theme()
def save_ui_state(self):
if not self.config_path:
return
cfg = load_config(self.config_path)
# colonnes visibles
cfg["visible_columns"] = [c for c in COLUMNS if c in self.visible_cols]
# tri
cfg["default_sort"] = {"column": self.sort_col, "descending": bool(self.sort_reverse)}
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _sort_symbol_for(self, col):
if col != self.sort_col:
return ""
return "↓" if self.sort_reverse else "↑"
def _apply_column_style(self, c):
if c in HIDEABLE_COLS and c not in self.visible_cols:
self.tree.column(c, width=0, minwidth=0, stretch=False)
self.tree.heading(c, text="")
return
w = 80
if c in ("lieu", "gpx"):
w = 220
elif c == "commune":
w = 170
elif c == "jour":
w = 95
elif c in ("id", "latitude", "longitude"):
w = 90
symbol = self._sort_symbol_for(c)
self.tree.heading(c, text=f"{c} {symbol}".rstrip(), command=lambda col=c: self.sort_by(col))
self.tree.column(c, width=w, anchor="w", stretch=True)
def toggle_hidden(self, which):
any_visible = any(c in self.visible_cols for c in which)
if any_visible:
for c in which:
self.visible_cols.discard(c)
else:
for c in which:
self.visible_cols.add(c)
# ré-appliquer styles
for c in COLUMNS:
self._apply_column_style(c)
self.refresh_table()
self.save_ui_state()
# ré-appliquer headings pour refléter éventuellement le tri courant
for c in COLUMNS:
self._apply_column_style(c)
self.save_visible_columns()
def refresh_table(self):
self.tree.delete(*self.tree.get_children())
for idx, row in enumerate(self.filtered):
values = [row.get(c, "") for c in COLUMNS]
iid = f"row-{idx}"
self.tree.insert("", "end", iid=iid, values=values)
def apply_filter(self):
q = (self.search_var.get() or "").strip().lower()
if not q:
self.filtered = list(self.data)
else:
def row_matches(r):
for c in COLUMNS:
v = r.get(c, "")
if q in str(v).lower():
return True
return False
self.filtered = [r for r in self.data if row_matches(r)]
self.refresh_table()
# garder le tri courant
if self.sort_col is not None:
self.sort_by(self.sort_col, from_header=False)
def sort_by(self, col, from_header=True):
if from_header:
if self.sort_col == col:
self.sort_reverse = not self.sort_reverse
else:
self.sort_col = col
self.sort_reverse = False
def key_func(r):
v = r.get(col, "")
if col == "jour" and v:
try:
return datetime.strptime(v, "%Y-%m-%d").date()
except Exception:
return datetime.min.date()
if is_number(v):
return float(v)
return str(v).lower()
self.filtered.sort(key=key_func, reverse=self.sort_reverse)
self.refresh_table()
# maj des entêtes (↓/↑) + persistance
for c in COLUMNS:
self._apply_column_style(c)
self.save_ui_state()
def show_selection(self):
selected = []
gpx_col_idx = COLUMNS.index("gpx")
for iid in self.tree.selection():
vals = self.tree.item(iid, "values")
try:
selected.append(int(vals[0])) # id
except Exception:
pass
print("Sélection (id) :", selected)
# utile si tu veux voir aussi les gpx
# paths = []
# for iid in self.tree.selection():
# vals = self.tree.item(iid, "values")
# if vals[gpx_col_idx]:
# paths.append(vals[gpx_col_idx])
# print("GPX:", paths)
def _get_selected_gpx_names(self):
gpx_col_idx = COLUMNS.index("gpx")
names = []
for iid in self.tree.selection():
vals = self.tree.item(iid, "values")
name = vals[gpx_col_idx]
if name:
names.append(name)
return names
def save_visible_columns(self):
if not self.config_path:
return
cfg = load_config(self.config_path)
# ne sauvegarder que les colonnes "toggle" (hideables) + celles visibles par défaut
cfg["visible_columns"] = [c for c in COLUMNS if c in self.visible_cols]
# garder le reste tel quel
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def apply_theme(self):
mode = self.theme_mode
is_dark = (mode == "dark")
bg = "#1e1e1e" if is_dark else "#f5f5f5"
fg = "#eaeaea" if is_dark else "#000000"
tree_bg = "#2b2b2b" if is_dark else "#ffffff"
tree_fg = "#eaeaea" if is_dark else "#000000"
head_bg = "#303030" if is_dark else "#eaeaea"
# fenêtre + frames + labels
self.configure(bg=bg)
for child in self.winfo_children():
try:
child.configure(bg=bg)
except Exception:
pass
# treeview (couleurs)
#style = ttk.Style(self)
#style.theme_use("clam")
style = ttk.Style(self)
# clam marche souvent bien pour le dark via Treeview
try:
style.theme_use("clam")
except Exception:
pass
style.configure("Treeview",
background=tree_bg,
foreground=tree_fg,
fieldbackground=tree_bg)
style.configure("Treeview.Heading",
background=head_bg,
foreground=tree_fg)
# boutons / entry / label (tk)
# (simple: on change juste le root et on force bg/fg si possible)
# note: ttk.Entry/ttk widgets ici: ton Entry est tk.Entry, donc gérable via configure
# si tu veux un rendu parfait, je peux adapter selon ton code exact.
self.btn_theme.configure(text=f"Thème: {'sombre' if is_dark else 'clair'}")
def toggle_theme(self):
self.theme_mode = "light" if self.theme_mode == "dark" else "dark"
self.apply_theme()
self.save_theme_mode()
def save_theme_mode(self):
if not self.config_path:
return
cfg = load_config(self.config_path)
cfg["theme"] = cfg.get("theme", {})
cfg["theme"]["mode"] = self.theme_mode
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def copy_selected_gpx_to_temp(self, temp_dir=TEMP_DIR):
os.makedirs(temp_dir, exist_ok=True)
copied = []
for name in self._get_selected_gpx_names():
src = GPX_DIR / name
if src.exists() and src.is_file():
dst = Path(temp_dir) / src.name
shutil.copy2(src, dst)
copied.append(str(dst))
print("GPX copiés :", copied)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--config", default="config.json", help="chemin du fichier config")
args = parser.parse_args()
cfg = load_config(args.config)
JSON_PATH = cfg["json_path"]
GPX_DIR = Path(cfg["gpx_dir"])
TEMP_DIR = cfg.get("temp_dir", "/tmp/gpx-studio-drop")
visible_columns = cfg.get("visible_columns", list(DEFAULT_VISIBLE))
default_sort = cfg.get("default_sort", {"column": "jour", "descending": True})
window_cfg = cfg.get("window", {})
theme_cfg = cfg.get("theme", {})
App(JSON_PATH, visible_columns, default_sort, config_path=args.config, window_cfg=window_cfg, theme_cfg=theme_cfg).mainloop()
Installer tableau
Option 1 - paquet “simple” avec pyinstaller
1) Installe PyInstaller
1
python -m pip install --user pyinstaller
2) Crée un binaire
1
pyinstaller --onefile --name tableau tableau.py
3) Dans dist/, tu auras tableau (exécutable).
4) Procédure d’installation (scripts)
- Copie le binaire dans un chemin du PATH, ex.
~/.local/bin1
install -m 0755 dist/tableau ~/.local/bin/
- Copie le
config.jsonquelque part (ex. dans~/.config/ton_app/config.json) et lance avec--config1 2 3
mkdir -p ~/.config/tableau cp config.json ~/.config/tableau/config.json ~/.local/bin/tableau --config ~/.config/tableau/config.json
5) Lancer depuis KDE (menu)
Crée un .desktop :
1
2
3
4
5
6
7
8
9
10
11
mkdir -p ~/.local/share/applications
cat > ~/.local/share/applications/tableau.desktop <<'EOF'
[Desktop Entry]
Type=Application
Name=Tableau GPX
Exec=~/.local/bin/tableau --config ~/.config/tableau/config.json
Icon=utilities-terminal
Terminal=true
Categories=Utility;
EOF
update-desktop-database ~/.local/share/applications 2>/dev/null || true
Option 2 - installer via un “wrapper” + pyinstaller
Pour une installation plus “propre” (dépendances packagées), le plus robuste sous Arch/KDE est de faire un PKGBUILD (AUR-style) ou un paquet local.
Structure
1
2
3
4
5
6
7
$HOME/media/dplus/cartographie/analyse-gpx/
.
├── config.json
├── icon.png
├── PKGBUILD
├── tableau-gpx.desktop
└── tableau.py
PKGBUILD
PKGBUILD adapté à l’arborescence (avec icon.png), en supposant --onefile --name tableau (binaire installé sous /usr/bin/tableau).
Création PKGBUILD :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
cat > PKGBUILD <<'EOF'
pkgname=tableau-gpx
pkgver=1.0.0
pkgrel=1
arch=('x86_64')
license=('GPL3')
depends=('glibc')
makedepends=('python' 'pyinstaller')
options=('strip')
source=(
"config.json"
"tableau-gpx.desktop"
"icon.png"
)
sha256sums=('SKIP' 'SKIP' 'SKIP')
build() {
cd "$srcdir"
rm -rf build dist
pyinstaller --onefile --clean --name tableau tableau.py
}
package() {
cd "$srcdir"
install -Dm0755 dist/tableau "$pkgdir/usr/bin/tableau"
install -Dm0755 -d "$pkgdir/etc/tableau"
install -Dm0644 "$srcdir/config.json" "$pkgdir/etc/tableau/config.json"
install -Dm0644 "$srcdir/tableau-gpx.desktop" "$pkgdir/usr/share/applications/tableau-gpx.desktop"
# icône (hicolor). Choix simple en 256x256.
install -Dm0644 "$srcdir/icon.png" \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/tableau-gpx.png"
}
post_install() {
update-desktop-database -q /usr/share/applications 2>/dev/null || true
}
EOF
Fichier desktop
Création tableau-gpx.desktop :
1
2
3
4
5
6
7
8
9
cat > tableau-gpx.desktop <<'EOF'
[Desktop Entry]
Type=Application
Name=Tableau GPX
Exec=/usr/bin/tableau --config /etc/tableau/config.json
Icon=tableau-gpx
Terminal=false
Categories=Utility;
EOF
Icône (optionnel)
- Mettre une image
icon.pngdans le même dossier.
Build & install
Le PKGBUILD se construit avec makepkg et produit un paquet .pkg.tar.* que tu installes ensuite via pacman ou yay.
Procédure :
1) Dans le dossier tableau-gpx/ :
1
makepkg -s
2) L’installation avec pacman :
1
sudo pacman -U ./*.pkg.tar.*
3) L’installation avec yay :
1
yay -U ./*.pkg.tar.*
- Si AUR on poste le PKGBUILD sur AUR, mais pour “local” le plus simple est
makepkgpuispacman -U.
Le binaire final sera tableau-gpx via le .desktop, qui exécute /usr/bin/tableau --config /etc/tableau/config.json.
OK : variante “sans pyinstaller” = on installe le script Python et on dépend de python-tkinter/tk selon ton système. Voici un PKGBUILD qui installe tableau.py + un wrapper /usr/bin/tableau pour lancer proprement.
Option 3 - PKGBUILD (sans pyinstaller)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
cat > PKGBUILD <<'EOF'
pkgname=tableau-gpx
pkgver=1.0.0
pkgrel=1
arch=('x86_64')
license=('GPL3')
depends=('python' 'tk')
makedepends=()
options=('strip')
source=(
"config.json"
"tableau-gpx.desktop"
"icon.png"
"tableau"
"tableau.py"
)
sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP')
build() { :; }
package() {
cd "$srcdir"
install -Dm0755 "tableau" "$pkgdir/usr/bin/tableau"
install -Dm0644 "tableau.py" "$pkgdir/usr/share/tableau/tableau.py"
install -Dm0644 "config.json" "$pkgdir/etc/tableau/config.json"
install -Dm0644 "tableau-gpx.desktop" "$pkgdir/usr/share/applications/tableau-gpx.desktop"
install -Dm0644 "icon.png" \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/tableau-gpx.png"
}
post_install() {
update-desktop-database -q /usr/share/applications 2>/dev/null || true
}
EOF
Wrapper /bin/tableau
Crée un fichier nommé tableau (sans extension) dans le même dossier que le PKGBUILD :
1
2
3
4
5
cat > tableau <<'EOF'
#!/bin/sh
exec /usr/bin/python3 /usr/share/tableau/tableau.py --config /etc/tableau/config.json "$@"
EOF
chmod +x tableau
Vérifier que la ligne Icon/Exec de ton .desktop est cohérente
Le tableau-gpx.desktop actuel est bien car il pointe vers :
/usr/bin/tableau --config /etc/tableau/config.json
Build & install
1
2
makepkg -s
sudo pacman -U ./*.pkg.tar.*
Option 4 - Install type utilisateur
Variante “utilisateur” : pas de paquet, juste un script d’installation qui copie tout dans ~/.config/tableau et un wrapper dans ~/.local/bin, + un .desktop dans ~/.local/share/applications.
Création wrapper ‘~/.local/bin/tableau’
1
2
3
4
5
6
cat > tableau <<'EOF'
#!/bin/sh
exec /usr/bin/python3 ~/.config/tableau/tableau.py --config ~/.config/tableau/config.json "$@"
EOF
chmod +x tableau
sudo cp tableau /usr/local/bin/
Installer fichiers dans ‘~/.config/tableau’
Depuis le dossier ~/media/dplus/cartographie/analyse-gpx/ :
1
2
3
mkdir -p ~/.config/tableau
cp tableau.py config.json ~/.config/tableau/
cp icon.png ~/.config/tableau/tableau-gpx.png
Installer .desktop utilisateur
1
2
3
mkdir -p ~/.local/share/applications
# Relever le $HOME
pwd # dans mon cas /home/yann
Création ~/.local/share/applications/tableau-gpx.desktop :
1
2
3
4
5
6
7
[Desktop Entry]
Type=Application
Name=Tableau GPX
Exec=/usr/local/bin/tableau --config /home/yann/.config/tableau/config.json
Icon=/home/yann/.config/tableau/tableau-gpx.png
Terminal=false
Categories=Utility;
Remplacer le
/home/yansi nécessaire
Mise à jour
1
update-desktop-database ~/.local/share/applications 2>/dev/null || true
Utilisation
Le binaire final sera tableau-gpx via le .desktop, qui exécute /usr/local/bin/tableau --config /home/yann/.config/tableau/config.json
Le fichier config /etc/tableau/config.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"default_sort": {
"column": "jour",
"descending": true
},
"json_path": "/home/yann/media/dplus/cartographie/analyse-gpx/traces/tracestableau.json",
"gpx_dir": "/home/yann/media/dplus/cartographie/analyse-gpx/traces",
"temp_dir": "/tmp/gpx-studio-drop",
"visible_columns": [
"lieu",
"commune",
"cp",
"distance",
"vitesse",
"niveau",
"jour"
],
"window": {
"width": 1200,
"height": 700,
"icon_path": "/home/yann/media/dplus/cartographie/analyse-gpx/icon.png"
},
"theme": {
"mode": "dark"
}
}
Structure
$HOME/media/dplus/cartographie/analyse-gpx/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
analyse-gpx/
.
├── analysegpx.py
├── analyse-gpx.wpr
├── config.json
├── gpxdata
├── icon.png
├── pkg
├── PKGBUILD
├── src
├── tableau
├── tableau-gpx-1.0.0-1-x86_64.pkg.tar.zst
├── tableau-gpx.desktop
├── tableau.py
└── traces
Lancement
Sur le PC EndeavourOS, manuellement
1
/usr/local/bin/tableau --config /home/yann/.config/tableau/config.json

