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_2ddistance
      • 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/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/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 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.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 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/yan si 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

Avec icône tableau

tableau

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