Post

đŸ„ŸđŸŽ’ Analyse des traces GPX issus d'un GPS

đŸ„ŸđŸŽ’ 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 JS var addressPoints = [...] (1 entrĂ©e par fichier GPX)

Déroulement principal

  1. Arguments :
    gpx_folder (dossier source) et output_folder (dossier destination), plus --pause (temporisation entre fichiers).
  2. Recherche : liste tous les fichiers *.gpx dans gpx_folder.
  3. Prépare les fichiers de sortie :
    • tracestableau.json dĂ©marre par [\n
    • tracesdataset.js dĂ©marre par var addressPoints = [\n Puis, si les fichiers existent dĂ©jĂ , le script essaie de gĂ©rer la “virgule/finale” avec strip_trailing_closer(...).
  4. Charge un cache de gĂ©ocodage depuis geo_cache.json dans output_folder (pour Ă©viter de refaire des requĂȘtes pour des coordonnĂ©es dĂ©jĂ  vues).
  5. 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 → vitesse moyenne
      • get_uphill_downhill() → niveau (montĂ©e totale)
      • date de dĂ©but → jour (format YYYY-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]
    • Si le gĂ©ocodage rĂ©ussit (ok=True), le GPX est dĂ©placĂ© vers output_folder. S’il Ă©choue, le fichier reste dans le dossier source et est listĂ© dans erreurs.
    • Attend --pause secondes.
  6. Finalise les fichiers :
    • tracestableau.json termine par ]\n
    • tracesdataset.js termine par ];\n
  7. Sauvegarde le cache géocodage dans geo_cache.json.
  8. 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_cache clĂ© sur round(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 jour en date)
  • affichage/masquage de colonnes “cachables” (id/latitude/longitude et gpx)
  • 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

  1. 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.
  2. Il charge tracestableau.json en mémoire (self.data) puis affiche toutes les lignes.
  3. Chaque ligne correspond à un élément avec les colonnes : id, latitude, longitude, lieu, commune, cp, distance, vitesse, niveau, jour, gpx.
  4. La barre “Recherche” filtre la table en gardant les lignes oĂč le texte recherchĂ© apparaĂźt dans n’importe quelle colonne.
  5. Cliquer sur un en-tĂȘte trie la table, et il met une flĂšche ↓/↑ sur l’en-tĂȘte de la colonne active.
  6. Les boutons “Toggle id/lat/lon” et “Toggle gpx” masquent/affichent ces colonnes et mettent à jour la vue + la config.
  7. “Copier GPX sĂ©lection” :
    • rĂ©cupĂšre gpx pour les lignes sĂ©lectionnĂ©es,
    • copie les fichiers correspondants depuis GPX_DIR vers TEMP_DIR (avec shutil.copy2),
    • affiche la liste copiĂ©e dans la console.
  8. Le bouton “Afficher sĂ©lection (id)” affiche juste les id des lignes sĂ©lectionnĂ©es dans la console.
  9. 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/yan si 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/bin
    1
    
    install -m 0755 dist/tableau ~/.local/bin/
    
  • Copie le config.json quelque part (ex. dans ~/.config/ton_app/config.json) et lance avec --config
    1
    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.png dans 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 makepkg puis pacman -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

Avec icĂŽne tableau

tableau

Cet article est sous licence CC BY 4.0 par l'auteur.