Liste des traces GPX (tableau)
Dossier travail: $HOME/media/dplus/cartographie/analyse-gpx/
Tableau
Description
Le script tableauqt.py lance une petite application Qt qui charge un fichier JSON contenant des traces GPX, puis affiche ces données dans une table 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.
- Drag n drop
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.
Paramètres config.json
Fichier ~/.config/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
27
{
"default_sort": {
"column": "jour",
"descending": true
},
"json_path": "/sharenfs/traces-gpx/tracestableau.json",
"gpx_dir": "/sharenfs/traces-gpx",
"temp_dir": "/tmp/gpx-studio-drop",
"visible_columns": [
"lieu",
"commune",
"cp",
"distance",
"vitesse",
"niveau",
"jour",
"gpx"
],
"window": {
"width": 1200,
"height": 700,
"icon_path": "/home/yann/.config/tableau/tableau-gpx.png"
},
"theme": {
"mode": "dark"
}
}
Script tableauqt.py
Qt utilise le protocole DnD natif (Wayland/XDG ou X11), donc le drag sortant de fichiers GPX vers Dolphin, GPX Studio ou autre fonctionne directement.
~/media/dplus/cartographie/analyse-gpx/tableauqt.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
430
431
432
433
434
435
436
437
438
439
440
441
#!/usr/bin/env python3
# tableauqt.py - port PySide6 de tableau.py (tkinter -> Qt)
#
# Installation :
# pip install PySide6
#
# Lancement :
# python tableauqt.py --config config.json
#
# Le drag & drop sortant (fichiers GPX vers une autre appli) utilise
# QDrag + QMimeData avec des URLs de fichiers : c'est la méthode native
# Qt, bien plus fiable que TkinterDnD2 sous KDE Plasma / Wayland.
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import (
QAbstractTableModel, QMimeData, QModelIndex, QSortFilterProxyModel,
QUrl, Qt,
)
from PySide6.QtGui import QAction, QDrag, QIcon
from PySide6.QtWidgets import (
QApplication, QFileDialog, QHBoxLayout, QHeaderView, QLineEdit,
QMainWindow, QMessageBox, QPushButton, QStatusBar, QTableView,
)
# --- config par défaut (écrasées par le fichier config.json) ---
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"}
COL_WIDTHS = {"lieu": 220, "gpx": 220, "commune": 170, "jour": 95,
"id": 90, "latitude": 90, "longitude": 90}
def load_config(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_config(path, cfg):
with open(path, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
# --- modèle ---
class TraceTableModel(QAbstractTableModel):
"""Modèle plat sur la liste de dicts du JSON."""
def __init__(self, rows, parent=None):
super().__init__(parent)
self.rows = rows
# Qt API ------------------------------------------------------------
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.rows)
def columnCount(self, parent=QModelIndex()):
return len(COLUMNS)
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
row = self.rows[index.row()]
col = COLUMNS[index.column()]
if role in (Qt.DisplayRole, Qt.EditRole):
v = row.get(col, "")
return "" if v is None else str(v)
if role == Qt.UserRole:
return row
return None
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role == Qt.DisplayRole and orientation == Qt.Horizontal:
return COLUMNS[section]
return None
def flags(self, index):
return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled
def mimeTypes(self):
return ["text/uri-list"]
def mimeData(self, indexes):
"""Drag sortant : les fichiers GPX des lignes sélectionnées."""
mime = QMimeData()
urls, seen = [], set()
for idx in indexes:
row = self.rows[idx.row()]
name = row.get("gpx", "")
if not name:
continue
p = GPX_DIR / str(name)
if p.is_file() and p not in seen:
seen.add(p)
urls.append(QUrl.fromLocalFile(str(p)))
if not urls:
return None
mime.setUrls(urls)
return mime
# helpers ------------------------------------------------------------
def row_at(self, proxy_index, proxy_model):
src_index = proxy_model.mapToSource(proxy_index)
return self.rows[src_index.row()]
# --- fenêtre principale ---
class MainWindow(QMainWindow):
def __init__(self, json_path, visible_columns, default_sort,
config_path=None, window_cfg=None, theme_cfg=None):
super().__init__()
self.config_path = config_path
self.window_cfg = window_cfg or {}
self.theme_mode = (theme_cfg or {}).get("mode", "light")
self.show_only_selection = False
self.selected_ids = set()
self.sort_col = default_sort["column"]
self.sort_desc = bool(default_sort.get("descending", True))
self.visible_cols = set(visible_columns)
with open(json_path, "r", encoding="utf-8") as f:
self.data = json.load(f)
self.filtered = list(self.data)
self.model = TraceTableModel(self.filtered)
self.proxy = QSortFilterProxyModel(self)
self.proxy.setSourceModel(self.model)
self.proxy.setFilterCaseSensitivity(Qt.CaseInsensitive)
self.proxy.setFilterKeyColumn(-1) # toutes les colonnes
self.setWindowTitle("Table JSON - tri + recherche (Qt)")
w = int(self.window_cfg.get("width", 1200))
h = int(self.window_cfg.get("height", 700))
self.resize(w, h)
self._apply_window_icon()
# --- UI ---
top = self._top_bar()
self.table = QTableView()
self.table.setModel(self.proxy)
self.table.setSelectionMode(QTableView.ExtendedSelection)
self.table.setSelectionBehavior(QTableView.SelectRows)
self.table.setDragEnabled(True)
self.table.setDragDropMode(QTableView.DragOnly)
self.table.setSortingEnabled(True)
self.table.verticalHeader().setVisible(False)
self.table.setAlternatingRowColors(True)
self.table.setShowGrid(False)
header = self.table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Interactive)
header.sortIndicatorChanged.connect(self._on_sort_changed)
self._apply_column_layout()
bottom = self._bottom_bar()
central = self._central_widget(top, self.table, bottom)
self.setCentralWidget(central)
self.setStatusBar(QStatusBar())
# tri initial
col = COLUMNS.index(self.sort_col) if self.sort_col in COLUMNS else 0
self.table.sortByColumn(
col, Qt.DescendingOrder if self.sort_desc else Qt.AscendingOrder)
self.apply_theme()
# --- construction UI ---
def _central_widget(self, top, table, bottom):
from PySide6.QtWidgets import QWidget, QVBoxLayout
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(10, 8, 10, 8)
lay.addWidget(top)
lay.addWidget(table, 1)
lay.addWidget(bottom)
return w
def _top_bar(self):
from PySide6.QtWidgets import QWidget, QLabel
w = QWidget()
lay = QHBoxLayout(w)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(QLabel("Recherche :"))
self.search_edit = QLineEdit()
self.search_edit.setClearButtonEnabled(True)
self.search_edit.textChanged.connect(self.apply_filter)
lay.addWidget(self.search_edit, 1)
return w
def _bottom_bar(self):
from PySide6.QtWidgets import QWidget
w = QWidget()
lay = QHBoxLayout(w)
lay.setContentsMargins(0, 0, 0, 0)
self.btn_selection = QPushButton("Afficher sélection (id)")
self.btn_selection.clicked.connect(self.toggle_selection_view)
lay.addWidget(self.btn_selection)
self.btn_theme = QPushButton("Thème: clair/sombre")
self.btn_theme.clicked.connect(self.toggle_theme)
lay.addWidget(self.btn_theme)
btn_idlatlon = QPushButton("Toggle id/lat/lon")
btn_idlatlon.clicked.connect(
lambda: self.toggle_hidden(["id", "latitude", "longitude"]))
lay.addWidget(btn_idlatlon)
btn_gpx = QPushButton("Toggle gpx")
btn_gpx.clicked.connect(lambda: self.toggle_hidden(["gpx"]))
lay.addWidget(btn_gpx)
btn_copy = QPushButton("Copier GPX sélection")
btn_copy.clicked.connect(self.copy_selected_gpx_to_temp)
lay.addWidget(btn_copy)
lay.addStretch(1)
return w
def _apply_window_icon(self):
icon_file = self.window_cfg.get("icon_path")
if not icon_file:
return
p = Path(icon_file).expanduser().resolve()
if p.exists():
self.setWindowIcon(QIcon(str(p)))
# --- colonnes ---
def _apply_column_layout(self):
for i, c in enumerate(COLUMNS):
if c in HIDEABLE_COLS and c not in self.visible_cols:
self.table.hideColumn(i)
else:
self.table.showColumn(i)
self.table.setColumnWidth(i, COL_WIDTHS.get(c, 80))
def toggle_hidden(self, which):
any_visible = any(c in self.visible_cols for c in which)
if any_visible:
self.visible_cols -= set(which)
else:
self.visible_cols |= set(which)
self._apply_column_layout()
self.save_ui_state()
# --- tri / filtre ---
def _on_sort_changed(self, column, order):
self.sort_col = COLUMNS[column]
self.sort_desc = (order == Qt.DescendingOrder)
self.save_ui_state()
def apply_filter(self):
q = self.search_edit.text().strip()
if self.show_only_selection:
# repasse en vue complète puis réapplique la sélection
self.toggle_selection_view()
self.proxy.setFilterFixedString(q)
# --- sélection ---
def _selected_ids(self):
ids = set()
for idx in self.table.selectionModel().selectedRows(0):
src = self.proxy.mapToSource(idx)
row = self.model.rows[src.row()]
try:
ids.add(int(row.get("id")))
except (TypeError, ValueError):
pass
return ids
def toggle_selection_view(self):
if not self.show_only_selection:
self.selected_ids = self._selected_ids()
if not self.selected_ids:
return
self.show_only_selection = True
self.filtered = [r for r in self.data
if _to_int(r.get("id")) in self.selected_ids]
self.btn_selection.setText("Afficher tout")
else:
self.show_only_selection = False
self.filtered = list(self.data)
self.btn_selection.setText("Afficher sélection (id)")
# réapplique le filtre de recherche et le tri courants
self.model.beginResetModel()
self.model.rows = self.filtered
self.model.endResetModel()
self.proxy.setFilterFixedString(self.search_edit.text().strip())
# --- GPX ---
def _selected_gpx_names(self):
names = []
gpx_col = COLUMNS.index("gpx")
for idx in self.table.selectionModel().selectedRows():
src = self.proxy.mapToSource(idx)
name = self.model.rows[src.row()].get("gpx", "")
if name:
names.append(str(name))
return names
def copy_selected_gpx_to_temp(self):
temp_dir = TEMP_DIR
names = self._selected_gpx_names()
if not names:
QMessageBox.information(self, "Copier GPX",
"Aucune ligne sélectionnée (ou colonne gpx vide).")
return
Path(temp_dir).mkdir(parents=True, exist_ok=True)
copied, missing = [], []
for name in names:
src = GPX_DIR / name
if src.is_file():
shutil.copy2(src, Path(temp_dir) / src.name)
copied.append(src.name)
else:
missing.append(str(src))
msg = f"{len(copied)} fichier(s) copié(s) dans :\n{temp_dir}"
if missing:
msg += "\n\nIntrouvables :\n" + "\n".join(missing)
QMessageBox.information(self, "Copier GPX", msg)
# --- thème ---
def apply_theme(self):
is_dark = self.theme_mode == "dark"
app = QApplication.instance()
if is_dark:
app.setStyleSheet(DARK_QSS)
else:
app.setStyleSheet("")
self.btn_theme.setText(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()
# --- persistance ---
def save_ui_state(self):
if not self.config_path:
return
cfg = load_config(self.config_path)
cfg["visible_columns"] = [c for c in COLUMNS if c in self.visible_cols]
cfg["default_sort"] = {"column": self.sort_col,
"descending": bool(self.sort_desc)}
cfg["window"] = {"width": self.width(), "height": self.height()}
save_config(self.config_path, cfg)
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
save_config(self.config_path, cfg)
def closeEvent(self, event):
self.save_ui_state()
super().closeEvent(event)
def _to_int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
DARK_QSS = """
QWidget { background-color: #1e1e1e; color: #eaeaea; }
QTableView {
background-color: #2b2b2b; color: #eaeaea;
gridline-color: #3c3c3c; alternate-background-color: #303030;
selection-background-color: #094771; selection-color: #ffffff;
}
QHeaderView::section {
background-color: #303030; color: #eaeaea;
border: 1px solid #3c3c3c; padding: 4px;
}
QLineEdit, QPushButton {
background-color: #2b2b2b; border: 1px solid #3c3c3c;
border-radius: 4px; padding: 4px 8px;
}
QPushButton:hover { background-color: #3a3a3a; }
QStatusBar { background-color: #1e1e1e; }
"""
def 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)
global JSON_PATH, GPX_DIR, TEMP_DIR
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 = QApplication(sys.argv)
win = MainWindow(JSON_PATH, visible_columns, default_sort,
config_path=args.config, window_cfg=window_cfg,
theme_cfg=theme_cfg)
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Ce que fait le port :
QTableView+QAbstractTableModel: le drag sortant est implémenté viamimeData()du modèle, qui retourne les fichiers GPX des lignes sélectionnées entext/uri-list(le standard que toutes les applications comprennent).- Tri par clic d’en-tête via
QSortFilterProxyModel(avec l’indicateur ▲/▼ intégré), recherche instantanée sur toutes les colonnes, tout comme votre version. - Les mêmes fonctionnalités : affichage sélection uniquement, toggle des colonnes id/lat/lon/gpx, copie des GPX vers
/tmp/gpx-studio-drop, thème clair/sombre (via QSS), icône et dimensions de fenêtre. - La persistance dans
config.jsonest conservée (colonnes visibles, tri, thème, taille de fenêtre), avec le même format de clés : votreconfig.jsonactuel fonctionne tel quel.
Test
Dans le dossier de tableauqt.py :
1
2
3
4
5
6
7
8
9
10
cd ~/media/dplus/cartographie/analyse-gpx # adapter : là où se trouve tableau.py
# Environnement et Pyside
uv venv --python /usr/bin/python # Python système, donc tkinter garanti
uv pip install PySide6
# lancement
uv run --no-project python tableauqt.py --config config.json
# ou directement :
.venv/bin/python tableauqt.py --config config.json
Une différence notable : sous Qt, le tri en mode “Afficher sélection” est géré par le proxy, donc trier en vue filtrée ne réordonne plus vos données sources, ce qui corrige un effet de bord présent dans la version tkinter.
Installer tableau
Créer le script ~/.local/bin/tableau :
1
2
3
#!/bin/sh
cd ~/media/dplus/cartographie/analyse-gpx || exit 1
exec .venv/bin/python tableauqt.py --config config.json "$@"
Le rendre exécutable
1
chmod +x ~/.local/bin/tableau
Lanceur KDE desktop ~/.local/share/applications/tableau-gpx.desktop
1
2
3
4
5
6
7
8
9
10
11
[Desktop Entry]
Categories=Utility;
Exec=/home/yann/.local/bin/tableau --config /home/yann/.config/tableau/config.json
Icon=/home/yann/.config/tableau/tableau-gpx.png
Name=Tableau GPX
NoDisplay=false
PrefersNonDefaultGPU=false
StartupNotify=true
Terminal=false
Type=Application
X-KDE-SubstituteUID=false
Remplacer
/home/yannsi autre utilisateur
Structure finale
~/.config/tableau
1
2
3
4
.config/tableau/
├── config.json
├── tableau-gpx.ico
└── tableau-gpx.png
Application tableau: ~/.local/bin/tableau
Lanceur KDE desktop ~/.local/share/applications/tableau-gpx.desktop