đ„Ÿđ 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/.config/tableau/tableau-gpx.png"
},
"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
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
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")
icon_file = window_cfg.get("icon_path") if window_cfg else None
if icon_file:
print(icon_file)
p = Path(icon_file).expanduser().resolve()
if p.exists():
try:
img = tk.PhotoImage(file=str(p))
self.iconphoto(False, img)
self._icon_ref = img
except tk.TclError:
pass
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)
self.full_filtered = list(self.filtered)
self.show_only_selection = False
self.selected_ids = set()
# é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.toggle_selection_view)
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())
self._id_to_row = {} # id(int) -> row(dict)
for idx, row in enumerate(self.filtered):
row_id = row.get("id")
try:
row_id = int(row_id)
except Exception:
row_id = None
if row_id is None:
continue
self._id_to_row[row_id] = row
values = [row.get(c, "") for c in COLUMNS]
iid = f"id-{row_id}-{idx}" # unique
self.tree.insert("", "end", iid=iid, values=values)
def get_selected_ids(self):
ids = []
for iid in self.tree.selection():
# iid = "id-<id>-<idx>"
parts = iid.split("-")
# ["id", "<id>", "<idx>"]
if len(parts) >= 3 and parts[0] == "id":
try:
ids.append(int(parts[1]))
except Exception:
pass
return ids
def toggle_selection_view(self):
# met Ă jour selected_ids seulement au moment oĂč on passe en mode sĂ©lection
if not self.show_only_selection:
self.selected_ids = set(self.get_selected_ids())
if not self.selected_ids:
return
self.show_only_selection = True
self.filtered = [r for r in self.full_filtered if int(r.get("id")) in self.selected_ids]
self.refresh_table()
self.btn_selection.configure(text="Afficher tout")
else:
self.show_only_selection = False
self.filtered = list(self.full_filtered)
if self.sort_col is not None:
self.sort_by(self.sort_col, from_header=False)
else:
self.refresh_table()
self.btn_selection.configure(text="Afficher sélection (id)")
def apply_filter(self):
q = (self.search_var.get() or "").strip().lower()
if not q:
base = 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
base = [r for r in self.data if row_matches(r)]
# mettre à jour la vue pleine (avant sélection)
self.full_filtered = list(base)
# si on est en mode sélection, ne pas casser la sélection actuelle
if self.show_only_selection:
self.toggle_selection_view() # refiltre selon la sélection courante
else:
self.filtered = list(self.full_filtered)
if self.sort_col is not None:
self.sort_by(self.sort_col, from_header=False)
else:
self.refresh_table()
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 _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 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
Option 2 - 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 3 - 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 4 - 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.*
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

