diff --git a/tkinter_tag.py b/tkinter_tag.py index d9598c5..56f4804 100644 --- a/tkinter_tag.py +++ b/tkinter_tag.py @@ -1,165 +1,826 @@ import tkinter as tk -from tkinter import ttk, scrolledtext, messagebox + +from tkinter import ttk, messagebox +import json import requests from datetime import datetime, timedelta import threading import webbrowser +import os +import subprocess +import sys +import tempfile + + +# ============================================================ +# CACHE DES ARRÊTS +# ============================================================ -# Cache des arrêts stops_dict = {} + +BUS_SCRIPT_TEMPLATE = r'''import requests +from datetime import datetime +import time +import os +import msvcrt +from collections import defaultdict + +################################################################################## +###################### ###################### +###################### MRESO Next Buses Schedule ###################### +###################### BY Antqu (@antlevrai) ft ChatGPT ###################### +###################### ###################### +################################################################################## + +########################################################################################################################################################### +###################### CLUSTER Code est le Code referenciel de la station, envoie moi un MP pour que je te donne le code que tu veux ###################### +########################################################################################################################################################### +CLUSTER_CODE = __CLUSTER_CODE__ +URL = f"https://data.mobilites-m.fr/api/routers/default/index/clusters/{CLUSTER_CODE}/stoptimes" + +headers = { + "origin": "mon_appli" +} + +#################################################################### +###################### Ici tu mets tes lignes ###################### +#################################################################### +LINES_WANTED = __LINES_WANTED__ + +FORCE_TEST_ZERO = False +buffer = "" + +WIDTH = 65 + + +def get_bus_times(): + response = requests.get(URL, headers=headers) + + if response.status_code != 200: + return [] + + data = response.json() + now = datetime.now() + buses = [] + + for cluster in data: + line = cluster["pattern"]["id"] + + if line not in LINES_WANTED: + continue + + direction = cluster["pattern"]["lastStopName"] + + for stoptime in cluster["times"]: + realtime = stoptime.get("realtimeArrival") or stoptime.get("scheduledArrival") + service_day = stoptime["serviceDay"] + + timestamp = service_day + realtime + departure = datetime.fromtimestamp(timestamp) + + minutes = int((departure - now).total_seconds() / 60) + + if minutes >= 0: + buses.append({"line": line, "direction": direction, "minutes": minutes, "departure": departure}) + + buses.sort(key=lambda x: x["minutes"]) + + grouped = defaultdict(list) + for bus in buses: + key = (bus["line"], bus["direction"]) + grouped[key].append(bus) + + result = [] + + for (line, direction), times in grouped.items(): + times.sort(key=lambda x: x["minutes"]) + + first = times[0] if len(times) > 0 else None + second = times[1] if len(times) > 1 else None + + result.append({ + "line": line, + "direction": direction, + "first": first, + "second": second + }) + + global FORCE_TEST_ZERO + if FORCE_TEST_ZERO and result: + result[0]["first"]["minutes"] = 0 + + return result + + +def format_passage(bus, blink): + if not bus: + return "" + + if bus["minutes"] <= 0: + return "0" if blink else " " + + if bus["minutes"] > 30: + return bus["departure"].strftime("%H:%M") + else: + return f"{bus['minutes']} min" + + +def draw_static_frame(row_count): + os.system("cls" if os.name == "nt" else "clear") + + now_local = datetime.now() + current_time = now_local.strftime("%H:%M") + + title = "GRENOBLE - GARES" + space = WIDTH - len(title) - len(current_time) - 2 + + print("╔" + "═" * WIDTH + "╗") + print(f"║ {title}{' ' * space}{current_time} ║") + print("╠" + "═" * WIDTH + "╣") + + print(f"║ {'Ligne':<5}│ {'Destination':<28}│ {'1er passage':^12}│ {'2eme passage':^12} ║") + print("╠" + "═" * WIDTH + "╣") + + print("\033[s", end="") + + for _ in range(row_count): + print("║" + " " * WIDTH + "║") + + print("╚" + "═" * WIDTH + "╝") + + +def update_lines(grouped_buses, blink): + print("\033[u", end="") + + for row in grouped_buses: + line = row["line"] + direction = row["direction"] + + if len(direction) > 28: + direction = direction[:25] + "..." + + t1 = format_passage(row["first"], blink) + t2 = format_passage(row["second"], blink) + + print("\033[K", end="") + print(f"║ {line:<5}│ {direction:<28}│ {t1:^12}│ {t2:^12} ║") + + +def check_input(): + global FORCE_TEST_ZERO, buffer + + while msvcrt.kbhit(): + char = msvcrt.getwch() + + if char == "\r": + cmd = buffer.strip() + + if cmd == "test0": + FORCE_TEST_ZERO = True + elif cmd == "reset": + FORCE_TEST_ZERO = False + + buffer = "" + elif char == "\b": + buffer = buffer[:-1] + else: + buffer += char + + +def main(): + blink = True + buses = [] + last_fetch = 0 + + draw_static_frame(0) + + while True: + now = time.time() + + if now - last_fetch > 30: + buses = get_bus_times() + last_fetch = now + draw_static_frame(len(buses)) + + check_input() + + has_zero = any( + (row["first"] and row["first"]["minutes"] <= 0) or + (row["second"] and row["second"]["minutes"] <= 0) + for row in buses + ) + + update_lines(buses, blink) + + if has_zero: + blink = not blink + time.sleep(1) + else: + blink = True + time.sleep(30) + +if __name__ == "__main__": + main() +''' + + def load_stops_for_line(line): if line in stops_dict: return + try: - r = requests.get(f"https://data.mobilites-m.fr/api/routers/default/index/routes/SEM:{line}/clusters") + r = requests.get( + f"https://data.mobilites-m.fr/api/routers/default/index/routes/SEM:{line}/clusters", + timeout=8 + ) + stops = r.json() + for s in stops: - name = s['name'] - full = f"{s['id']}::{s['lat']},{s['lon']}" - stops_dict[name.lower()] = (full, name) - except: + name = s.get("name", "").strip() + + if not name: + continue + + full = f"{s.get('id', '')}::{s.get('lat', '')},{s.get('lon', '')}" + + stops_dict[name.lower()] = ( + full, + name + ) + + except Exception: pass -# Charger les arrêts des lignes communes + +# ============================================================ +# CHARGEMENT DES LIGNES +# ============================================================ + all_lines = [] + try: - r = requests.get("https://data.mobilites-m.fr/api/routers/default/index/routes") + r = requests.get( + "https://data.mobilites-m.fr/api/routers/default/index/routes", + timeout=8 + ) + routes = r.json() + for route in routes: - if route.get('id', '').startswith('SEM:'): - line = route['id'][4:] # remove SEM: - all_lines.append(line) -except: - all_lines = ['E', 'A', 'C1', 'C2', 'B'] # fallback + route_id = route.get("id", "") + + if route_id.startswith("SEM:"): + all_lines.append(route_id[4:]) + +except Exception: + all_lines = [ + "E", + "A", + "C1", + "C2", + "B" + ] + for line in all_lines: load_stops_for_line(line) -# Ajouter des arrêts connus manuellement -stops_dict["pont de vence"] = ("SEM:GENPTVENCE::45.23009,5.6823", "Pont de Vence") -stops_dict["alsace lorraine"] = ("SEM:GENALSACELO::45.18911,5.7193", "Alsace Lorraine") -stops_dict["neron"] = ("SEM:NERON::45.21782,5.69334", "Néron") -stops_dict["néron"] = ("SEM:NERON::45.21782,5.69334", "Néron") -# Mapping des lignes courtes vers les noms complets des routes +# ============================================================ +# ARRÊTS CONNUS MANUELLEMENT +# ============================================================ + +stops_dict["pont de vence"] = ( + "SEM:GENPTVENCE::45.23009,5.6823", + "Pont de Vence" +) + +stops_dict["alsace lorraine"] = ( + "SEM:GENALSACELO::45.18911,5.7193", + "Alsace Lorraine" +) + +stops_dict["neron"] = ( + "SEM:NERON::45.21782,5.69334", + "Néron" +) + +stops_dict["néron"] = ( + "SEM:NERON::45.21782,5.69334", + "Néron" +) + + +# ============================================================ +# NOMS COMPLETS DES LIGNES +# ============================================================ + line_names = { "E": "Fontanil-Cornillon Palluel / Grenoble Louise Michel", - # Ajouter d'autres lignes si besoin } + +# ============================================================ +# RECHERCHE D'UN ARRÊT +# ============================================================ + def search_stop(query): query = query.strip().lower() + if not query: return None, None - # Recherche dans le cache if query in stops_dict: return stops_dict[query] - # Recherche floue : si le nom contient la query ou vice versa for name_lower, (full, name) in stops_dict.items(): + if query in name_lower or name_lower in query: return full, name - # Fallback : texte brut return query, query + +# ============================================================ +# APPLICATION PRINCIPALE +# ============================================================ + class TAGApp: + def __init__(self, root): + self.root = root - self.root.title("TAG Express - Calculateur d'Itinéraires") - self.root.geometry("760x510") - - self.github_url = "https://github.com/Palmine38" - - top_frame = ttk.Frame(root) - top_frame.grid(row=0, column=2, padx=4, pady=4, sticky='ne') - self.github_btn = ttk.Button(top_frame, text="Made by Palmine", command=self.open_github) - self.github_btn.pack() + self.root.title( + "TAG Express - Calculateur d'Itinéraires" + ) + + self.root.geometry( + "850x560" + ) + + self.root.minsize( + 700, + 500 + ) + + self.github_url = ( + "https://github.com/Palmine38" + ) + + # ---------------------------------------------------- + # VARIABLES + # ---------------------------------------------------- - # Variables self.fromPlace = "" self.toPlace = "" self.ligne = "" + self.time_offset = 0 + + self.search_base_time = None + self.results = [] - # Widgets - ttk.Label(root, text="Départ:").grid(row=0, column=0, sticky="w", padx=6, pady=2) - self.dep_entry = ttk.Entry(root, width=28) - self.dep_entry.grid(row=0, column=1, padx=6, pady=2) + # Liste des itinéraires effectivement affichés. + # Cela permet de retrouver le bon trajet lors du + # double-clic. + self.displayed_results = [] + + self.active_entry = None + + # ---------------------------------------------------- + # CONFIGURATION DE LA GRILLE + # ---------------------------------------------------- + + root.rowconfigure( + 4, + weight=1 + ) - ttk.Label(root, text="Arrivée:").grid(row=1, column=0, sticky="w", padx=6, pady=2) - self.arr_entry = ttk.Entry(root, width=28) - self.arr_entry.grid(row=1, column=1, padx=6, pady=2) + root.columnconfigure( + 1, + weight=1 + ) - # auto-complétion de nom d'arrêt - self.suggestion_box = tk.Listbox(root, height=5) - self.suggestion_box.bind("", self._fill_from_suggestion) + # ---------------------------------------------------- + # BOUTON GITHUB + # ---------------------------------------------------- - for entry in [self.dep_entry, self.arr_entry]: - entry.bind("", self.on_entry_keyrelease) - entry.bind("", lambda e: self.search()) - entry.bind("", self.on_entry_tab) + top_frame = ttk.Frame(root) + + top_frame.grid( + row=0, + column=2, + padx=6, + pady=5, + sticky="ne" + ) + + self.github_btn = ttk.Button( + top_frame, + text="?", + width=3, + command=self.open_about + ) + + self.github_btn.pack() - ttk.Label(root, text="Ligne (optionnel):").grid(row=2, column=0, sticky="w", padx=6, pady=2) - self.line_entry = ttk.Entry(root, width=28) - self.line_entry.grid(row=2, column=1, padx=6, pady=2) - self.line_entry.bind("", lambda e: self.search()) + # ---------------------------------------------------- + # DÉPART + # ---------------------------------------------------- + + ttk.Label( + root, + text="Départ:" + ).grid( + row=0, + column=0, + sticky="w", + padx=6, + pady=3 + ) + + self.dep_entry = ttk.Entry( + root, + width=28 + ) + + self.dep_entry.grid( + row=0, + column=1, + padx=6, + pady=3, + sticky="ew" + ) + + # ---------------------------------------------------- + # ARRIVÉE + # ---------------------------------------------------- + + ttk.Label( + root, + text="Arrivée:" + ).grid( + row=1, + column=0, + sticky="w", + padx=6, + pady=3 + ) + + self.arr_entry = ttk.Entry( + root, + width=28 + ) + + self.arr_entry.grid( + row=1, + column=1, + padx=6, + pady=3, + sticky="ew" + ) + + # ---------------------------------------------------- + # AUTOCOMPLÉTION + # ---------------------------------------------------- + + self.suggestion_box = tk.Listbox( + root, + height=5 + ) + + self.suggestion_box.bind( + "", + self._fill_from_suggestion + ) + + for entry in ( + self.dep_entry, + self.arr_entry + ): + + entry.bind( + "", + self.on_entry_keyrelease + ) + + entry.bind( + "", + lambda e: self.search() + ) + + entry.bind( + "", + self.on_entry_tab + ) + + # ---------------------------------------------------- + # LIGNE + # ---------------------------------------------------- + + ttk.Label( + root, + text="Ligne (optionnel):" + ).grid( + row=2, + column=0, + sticky="w", + padx=6, + pady=3 + ) + + self.line_entry = ttk.Entry( + root, + width=28 + ) + + self.line_entry.grid( + row=2, + column=1, + padx=6, + pady=3, + sticky="ew" + ) + + self.line_entry.bind( + "", + lambda e: self.search() + ) + + # ---------------------------------------------------- + # BOUTONS + # ---------------------------------------------------- - # compact buttons button_frame = ttk.Frame(root) - button_frame.grid(row=3, column=0, columnspan=2, pady=5, sticky='ew') - - self.search_btn = ttk.Button(button_frame, text="Rechercher", command=self.search) - self.load_more_btn = ttk.Button(button_frame, text="+1h", command=self.load_more, state="disabled") - self.list_stops_btn = ttk.Button(button_frame, text="Lister arrêts", command=self.list_stops) - self.new_search_btn = ttk.Button(button_frame, text="Réinitialiser", command=self.new_search) - - self.search_btn.grid(row=0, column=0, padx=2) - self.load_more_btn.grid(row=0, column=1, padx=2) - self.list_stops_btn.grid(row=0, column=2, padx=2) - self.new_search_btn.grid(row=0, column=3, padx=2) - self.load_more_btn.grid_remove() # Masqué jusqu'à la première recherche réussie - - self.results_tree = ttk.Treeview(root, columns=("ligne", "direction", "dep", "arr", "dur", "type"), show="headings", height=9) - self.results_tree.heading("ligne", text="LIGNE") - self.results_tree.heading("direction", text="Direction") - self.results_tree.heading("dep", text="Départ") - self.results_tree.heading("arr", text="Arrivée") - self.results_tree.heading("dur", text="Durée") - self.results_tree.heading("type", text="Type") - self.results_tree.column("ligne", width=60, anchor="center") - self.results_tree.column("direction", width=280, anchor="w") - self.results_tree.column("dep", width=90, anchor="center") - self.results_tree.column("arr", width=90, anchor="center") - self.results_tree.column("dur", width=70, anchor="center") - self.results_tree.column("type", width=110, anchor="center") - self.results_tree.grid(row=4, column=0, columnspan=2, padx=4, pady=2, sticky="nsew") - - # Scrollbar collée au tableau droit - scrollbar = ttk.Scrollbar(root, orient="vertical", command=self.results_tree.yview) - self.results_tree.configure(yscrollcommand=scrollbar.set) - scrollbar.grid(row=4, column=2, sticky="ns", pady=2) - - # Make resizable - root.rowconfigure(4, weight=1) - root.columnconfigure(0, weight=0) - root.columnconfigure(1, weight=1) - - def _update_load_more_button_text(self): - if not hasattr(self, 'search_base_time') or self.search_base_time is None: - return - next_time = self.search_base_time + timedelta(hours=self.time_offset + 1) - self.load_more_btn.config(text=f"Rechercher pour après {next_time.strftime('%H:%M')} (+{self.time_offset+1}h)") - self.load_more_btn.grid() + + button_frame.grid( + row=3, + column=0, + columnspan=3, + pady=5, + sticky="w" + ) + + self.search_btn = ttk.Button( + button_frame, + text="Rechercher", + command=self.search + ) + + self.previous_btn = ttk.Button( + button_frame, + text="<", + width=4, + command=self.load_previous, + state="disabled" + ) + + self.next_btn = ttk.Button( + button_frame, + text=">", + width=4, + command=self.load_next, + state="disabled" + ) + + self.list_stops_btn = ttk.Button( + button_frame, + text="Lister arrêts", + command=self.list_stops + ) + + self.new_search_btn = ttk.Button( + button_frame, + text="Réinitialiser", + command=self.new_search + ) + + self.search_btn.grid( + row=0, + column=0, + padx=3 + ) + + self.previous_btn.grid( + row=0, + column=1, + padx=3 + ) + + self.next_btn.grid( + row=0, + column=2, + padx=3 + ) + + self.list_stops_btn.grid( + row=0, + column=3, + padx=3 + ) + + self.new_search_btn.grid( + row=0, + column=4, + padx=3 + ) + + # ---------------------------------------------------- + # LABEL HEURE + # ---------------------------------------------------- + + self.time_label = ttk.Label( + root, + text="Aucune recherche effectuée", + font=("TkDefaultFont", 10, "bold") + ) + + self.time_label.grid( + row=3, + column=3, + columnspan=2, + padx=10, + sticky="e" + ) + + # ---------------------------------------------------- + # TABLEAU DES RÉSULTATS + # ---------------------------------------------------- + + self.results_tree = ttk.Treeview( + root, + columns=( + "ligne", + "direction", + "dep", + "arr", + "dur", + "type" + ), + show="headings", + height=12 + ) + + self.results_tree.heading( + "ligne", + text="LIGNE" + ) + + self.results_tree.heading( + "direction", + text="Direction" + ) + + self.results_tree.heading( + "dep", + text="Départ" + ) + + self.results_tree.heading( + "arr", + text="Arrivée" + ) + + self.results_tree.heading( + "dur", + text="Durée" + ) + + self.results_tree.heading( + "type", + text="Type" + ) + + self.results_tree.column( + "ligne", + width=70, + anchor="center" + ) + + self.results_tree.column( + "direction", + width=300, + anchor="w" + ) + + self.results_tree.column( + "dep", + width=90, + anchor="center" + ) + + self.results_tree.column( + "arr", + width=90, + anchor="center" + ) + + self.results_tree.column( + "dur", + width=80, + anchor="center" + ) + + self.results_tree.column( + "type", + width=130, + anchor="center" + ) + + self.results_tree.grid( + row=4, + column=0, + columnspan=2, + padx=4, + pady=2, + sticky="nsew" + ) + + # ---------------------------------------------------- + # DOUBLE-CLIC + # ---------------------------------------------------- + + self.results_tree.bind( + "", + self.on_result_double_click + ) + + # ---------------------------------------------------- + # SCROLLBAR + # ---------------------------------------------------- + + scrollbar = ttk.Scrollbar( + root, + orient="vertical", + command=self.results_tree.yview + ) + + self.results_tree.configure( + yscrollcommand=scrollbar.set + ) + + scrollbar.grid( + row=4, + column=2, + sticky="ns", + pady=2 + ) + + # ======================================================== + # GITHUB + # ======================================================== def open_github(self): - webbrowser.open(self.github_url) + + webbrowser.open( + self.github_url + ) + + def open_about(self): + + window = tk.Toplevel(self.root) + window.title("À propos") + window.geometry("320x190") + window.resizable(False, False) + + ttk.Label( + window, + text="Copyright Mars 2026", + font=("TkDefaultFont", 12, "bold") + ).pack(pady=(18, 12)) + + ttk.Button( + window, + text="Made by Palmine", + command=lambda: webbrowser.open( + "https://github.com/Palmine38" + ) + ).pack(fill="x", padx=45, pady=4) + + ttk.Button( + window, + text="Remasterised by Antquu", + command=lambda: webbrowser.open( + "https://github.com/antquu" + ) + ).pack(fill="x", padx=45, pady=4) + + ttk.Button( + window, + text="Fermer", + command=window.destroy + ).pack(pady=(8, 0)) + + # ======================================================== + # RECHERCHE + # ======================================================== def search(self): + dep = self.dep_entry.get().strip() arr = self.arr_entry.get().strip() ligne = self.line_entry.get().strip().upper() @@ -167,200 +828,227 @@ def search(self): fromPlace, dep_name = search_stop(dep) toPlace, arr_name = search_stop(arr) - if "::" not in fromPlace: - messagebox.showerror("Erreur", f"Arrêt de départ '{dep}' non trouvé.") + if ( + not fromPlace + or "::" not in fromPlace + ): + + messagebox.showerror( + "Erreur", + f"Arrêt de départ '{dep}' non trouvé." + ) + return - if "::" not in toPlace: - messagebox.showerror("Erreur", f"Arrêt d'arrivée '{arr}' non trouvé.") + + if ( + not toPlace + or "::" not in toPlace + ): + + messagebox.showerror( + "Erreur", + f"Arrêt d'arrivée '{arr}' non trouvé." + ) + return self.fromPlace = fromPlace self.toPlace = toPlace self.ligne = ligne + + self.search_base_time = ( + datetime.now() + .replace( + second=0, + microsecond=0 + ) + ) + self.time_offset = 0 - self.search_base_time = datetime.now() - self.search_btn.config(state="disabled") + self.update_time_label() - threading.Thread(target=self.do_search).start() + self.search_btn.config( + state="disabled" + ) - def list_stops(self): - win = tk.Toplevel(self.root) - win.title("Lister arrêts par ligne") - win.geometry("560x450") - - ttk.Label(win, text="Ligne (E, A, C1...) :").pack(anchor="w", padx=10, pady=(10, 0)) - line_entry = ttk.Entry(win, width=18) - line_entry.pack(anchor="w", padx=10, pady=(0, 10)) - - columns = ("name", "cluster", "lat", "lon") - stop_tree = ttk.Treeview(win, columns=columns, show='headings', height=16) - stop_tree.heading("name", text="Nom") - stop_tree.heading("cluster", text="Cluster ID") - stop_tree.heading("lat", text="Lat") - stop_tree.heading("lon", text="Lon") - stop_tree.column("name", width=210, anchor="w") - stop_tree.column("cluster", width=150, anchor="center") - stop_tree.column("lat", width=80, anchor="center") - stop_tree.column("lon", width=80, anchor="center") - stop_tree.pack(fill="both", expand=True, padx=10, pady=5) - - scrollbar = ttk.Scrollbar(win, orient="vertical", command=stop_tree.yview) - stop_tree.configure(yscrollcommand=scrollbar.set) - scrollbar.pack(side="right", fill="y") + self.previous_btn.config( + state="disabled" + ) - def show_stops(event=None): - line = line_entry.get().strip().upper() - if not line: - messagebox.showerror("Erreur", "Entrez une ligne valide.") - return + self.next_btn.config( + state="disabled" + ) - try: - r = requests.get(f"https://data.mobilites-m.fr/api/routers/default/index/routes/SEM:{line}/clusters", timeout=8) - stops = r.json() - if not stops: - messagebox.showinfo("Info", f"Aucun arrêt trouvé pour la ligne {line}.") - for item in stop_tree.get_children(): - stop_tree.delete(item) - return - except Exception as e: - messagebox.showerror("Erreur", f"Impossible de lister la ligne {line} : {e}") - return + threading.Thread( + target=self.do_search, + daemon=True + ).start() - for item in stop_tree.get_children(): - stop_tree.delete(item) + # ======================================================== + # RECHERCHE HORAIRE PRECEDENT + # ======================================================== - for s in stops: - stop_tree.insert("", "end", values=(s.get('name'), s.get('id'), s.get('lat'), s.get('lon'))) + def load_previous(self): - line_entry.bind('', show_stops) - line_entry.focus() + if self.search_base_time is None: + return - stop_tree.bind('', lambda event: self._fill_from_list_table(event, stop_tree)) + self.time_offset -= 1 - ttk.Button(win, text="Fermer", command=win.destroy).pack(pady=6) + self.start_navigation_search() - def _fill_from_list_table(self, event, tree): - item = tree.selection() - if not item: - return - values = tree.item(item[0], 'values') - if not values: + # ======================================================== + # RECHERCHE HORAIRE SUIVANT + # ======================================================== + + def load_next(self): + + if self.search_base_time is None: return - name = values[0] - choice = tk.Toplevel(self.root) - choice.title("Choisir champ") - choice.geometry("250x100") - ttk.Label(choice, text=f"Utiliser '{name}' comme :").pack(pady=6) - def choose_depart(): - self.dep_entry.delete(0, tk.END) - self.dep_entry.insert(0, name) - choice.destroy() + self.time_offset += 1 - def choose_arrivee(): - self.arr_entry.delete(0, tk.END) - self.arr_entry.insert(0, name) - choice.destroy() + self.start_navigation_search() - btn_frame = ttk.Frame(choice) - btn_frame.pack(pady=4) - ttk.Button(btn_frame, text="Départ", command=choose_depart).grid(row=0, column=0, padx=6) - ttk.Button(btn_frame, text="Arrivée", command=choose_arrivee).grid(row=0, column=1, padx=6) + # ======================================================== + # LANCEMENT RECHERCHE NAVIGATION + # ======================================================== + def start_navigation_search(self): + self.update_time_label() - def _fill_from_list(self, event, listbox): - sel = listbox.curselection() - if not sel: - return - text = listbox.get(sel[0]) - name = text.split(' | ')[0] + self.search_btn.config( + state="disabled" + ) - # Choix dépôt vs arrivée dans une mini popup - choice = tk.Toplevel(self.root) - choice.title("Choisir champ") - choice.geometry("240x100") - ttk.Label(choice, text=f"Sélection : {name}").pack(pady=5) + self.previous_btn.config( + state="disabled" + ) - def choose_depart(): - self.dep_entry.delete(0, tk.END) - self.dep_entry.insert(0, name) - choice.destroy() + self.next_btn.config( + state="disabled" + ) - def choose_arrivee(): - self.arr_entry.delete(0, tk.END) - self.arr_entry.insert(0, name) - choice.destroy() + threading.Thread( + target=self.do_search, + daemon=True + ).start() - ttk.Button(choice, text="Départ", command=choose_depart).pack(side="left", padx=10, pady=5) - ttk.Button(choice, text="Arrivée", command=choose_arrivee).pack(side="right", padx=10, pady=5) + # ======================================================== + # BOUTONS NAVIGATION + # ======================================================== - def on_entry_keyrelease(self, event): - widget = event.widget - query = widget.get().strip().lower() + def update_navigation_buttons(self): - # si correspond exactement à un arrêt, on masque la suggestion - if query in stops_dict: - self.suggestion_box.place_forget() - return + if self.search_base_time is None: - if not query: - self.suggestion_box.place_forget() - return + self.previous_btn.config( + state="disabled" + ) + + self.next_btn.config( + state="disabled" + ) - matches = [name for name in stops_dict.keys() if query in name] - if not matches: - self.suggestion_box.place_forget() return - self.suggestion_box.delete(0, tk.END) - for name in matches[:10]: - self.suggestion_box.insert(tk.END, stops_dict[name][1]) + # Limite de sécurité : 12 heures avant/après + if self.time_offset <= -12: - x = widget.winfo_rootx() - self.root.winfo_rootx() - y = widget.winfo_rooty() - self.root.winfo_rooty() + widget.winfo_height() - self.suggestion_box.place(x=x, y=y, width=widget.winfo_width()) - self.suggestion_box.lift() + self.previous_btn.config( + state="disabled" + ) - self.active_entry = widget + else: + + self.previous_btn.config( + state="normal" + ) + + if self.time_offset >= 12: + + self.next_btn.config( + state="disabled" + ) + + else: + + self.next_btn.config( + state="normal" + ) + + # ======================================================== + # AFFICHAGE DE L'HEURE + # ======================================================== + + def update_time_label(self): + + if self.search_base_time is None: + + self.time_label.config( + text="Aucune recherche effectuée" + ) - def _fill_from_suggestion(self, event): - if not hasattr(self, 'active_entry') or not self.active_entry: - return - sel = self.suggestion_box.curselection() - if not sel: return - text = self.suggestion_box.get(sel[0]) - self.active_entry.delete(0, tk.END) - self.active_entry.insert(0, text) - self.suggestion_box.place_forget() - def on_entry_tab(self, event): - if self.suggestion_box.winfo_ismapped() and self.suggestion_box.size() > 0: - text = self.suggestion_box.get(0) - self.active_entry.delete(0, tk.END) - self.active_entry.insert(0, text) - self.suggestion_box.place_forget() - return 'break' - return None + current_time = ( + self.search_base_time + + + timedelta( + hours=self.time_offset + ) + ) + + self.time_label.config( + text=( + "Horaires recherchés : " + + + current_time.strftime("%H:%M") + ) + ) + + # ======================================================== + # REQUÊTE API + # ======================================================== def do_search(self): - # Clear tree - for item in self.results_tree.get_children(): - self.results_tree.delete(item) - loading_text = f"Chargement des départs après +{self.time_offset}h..." if self.time_offset > 0 else "Calcul en cours..." - self.results_tree.insert("", "end", values=("", loading_text, "", "", "", "")) - base_time = getattr(self, 'search_base_time', None) + self.root.after( + 0, + self.show_loading + ) + + base_time = self.search_base_time + if base_time is None: - base_time = datetime.now() + + base_time = ( + datetime.now() + .replace( + second=0, + microsecond=0 + ) + ) + self.search_base_time = base_time - search_time = base_time + timedelta(hours=self.time_offset) - heure = search_time.strftime("%H:%M") - date = base_time.strftime("%Y-%m-%d") - self._update_load_more_button_text() + search_time = ( + base_time + + + timedelta( + hours=self.time_offset + ) + ) + + heure = search_time.strftime( + "%H:%M" + ) + + date = search_time.strftime( + "%Y-%m-%d" + ) params = { "fromPlace": self.fromPlace, @@ -376,110 +1064,2255 @@ def do_search(self): } try: - r = requests.get("https://data.mobilites-m.fr/api/routers/default/plan", params=params, timeout=12) + + r = requests.get( + "https://data.mobilites-m.fr/api/routers/default/plan", + params=params, + timeout=12 + ) + + r.raise_for_status() + data = r.json() - its = data.get("plan", {}).get("itineraries", []) - self.results = its - self.display_results() + itineraries = ( + data + .get("plan", {}) + .get("itineraries", []) + ) + + self.results = itineraries + + self.root.after( + 0, + self.display_results + ) + except Exception as e: - for item in self.results_tree.get_children(): - self.results_tree.delete(item) - self.results_tree.insert("", "end", values=("", f"Erreur: {e}", "", "", "", "")) - self.load_more_btn.config(state="disabled") - self.load_more_btn.grid_remove() - finally: - self.search_btn.config(state="normal") + + self.root.after( + 0, + lambda error=e: self.show_error(error) + ) + + # ======================================================== + # CHARGEMENT + # ======================================================== + + def show_loading(self): + + for item in self.results_tree.get_children(): + self.results_tree.delete(item) + + self.results_tree.insert( + "", + "end", + values=( + "", + "Calcul en cours...", + "", + "", + "", + "" + ) + ) + + self.update_time_label() + + # ======================================================== + # ERREUR + # ======================================================== + + def show_error(self, error): + + for item in self.results_tree.get_children(): + self.results_tree.delete(item) + + self.results_tree.insert( + "", + "end", + values=( + "", + f"Erreur : {error}", + "", + "", + "", + "" + ) + ) + + self.search_btn.config( + state="normal" + ) + + self.update_navigation_buttons() + + # ======================================================== + # AFFICHAGE DES RÉSULTATS + # ======================================================== def display_results(self): + for item in self.results_tree.get_children(): self.results_tree.delete(item) + + self.displayed_results = [] + shown = 0 - for it in self.results: - dur = round(it["duration"] / 60) - if dur > 35: continue + + for itinerary in self.results: + + try: + + duration = round( + itinerary["duration"] / 60 + ) + + except Exception: + + continue + + # On garde le filtre de 35 minutes + if duration > 35: + continue + + # ------------------------------------------------ + # FILTRE PAR LIGNE + # ------------------------------------------------ if self.ligne: + target = self.ligne.upper() - target_pattern = line_names.get(target, target).upper() + + target_pattern = ( + line_names + .get( + target, + target + ) + .upper() + ) + leg_routes = [] - for leg in it["legs"]: - if leg["mode"] == "WALK": + + for leg in itinerary.get( + "legs", + [] + ): + + if ( + leg.get("mode") + == "WALK" + ): continue - route_short = (leg.get("routeShortName") or "").replace("SEM:", "").upper() - route_id = (leg.get("routeId") or "").replace("SEM:", "").upper() - route = (leg.get("route") or "").replace("SEM:", "").upper() - for r in [route_short, route_id, route]: - if r: - leg_routes.append(r) - # déduire de la route shortest name ou identifiant - if not any(rt == target or rt == target_pattern or rt.startswith(target) for rt in leg_routes): + + route_short = ( + leg.get( + "routeShortName" + ) + or "" + ) + + route_id = ( + leg.get( + "routeId" + ) + or "" + ) + + route = ( + leg.get( + "route" + ) + or "" + ) + + route_short = ( + route_short + .replace( + "SEM:", + "" + ) + .upper() + ) + + route_id = ( + route_id + .replace( + "SEM:", + "" + ) + .upper() + ) + + route = ( + route + .replace( + "SEM:", + "" + ) + .upper() + ) + + for value in ( + route_short, + route_id, + route + ): + + if value: + leg_routes.append( + value + ) + + if not any( + value == target + or value == target_pattern + or value.startswith(target) + for value in leg_routes + ): + continue - if "startTime" not in it or "endTime" not in it: + # ------------------------------------------------ + # HORAIRES + # ------------------------------------------------ + + if ( + "startTime" not in itinerary + or + "endTime" not in itinerary + ): + continue + try: - dep_time = datetime.fromtimestamp(it["startTime"] / 1000).strftime("%H:%M") - arr_time = datetime.fromtimestamp(it["endTime"] / 1000).strftime("%H:%M") + + dep_time = ( + datetime.fromtimestamp( + itinerary["startTime"] / 1000 + ) + .strftime("%H:%M") + ) + + arr_time = ( + datetime.fromtimestamp( + itinerary["endTime"] / 1000 + ) + .strftime("%H:%M") + ) + except Exception: + continue - shown += 1 - legs = [leg for leg in it["legs"] if leg["mode"] != "WALK"] - direct = len(legs) <= 1 - - # direction = terminus dans le sens du trajet (headsign, ou seconde partie du nom de route) - if legs: - transit_leg = legs[0] - headsign = (transit_leg.get('headsign') or '').strip() - route_text = transit_leg.get('route') or '' + # ------------------------------------------------ + # LEGS DE TRANSPORT + # ------------------------------------------------ + + transit_legs = [ + leg + for leg in itinerary.get( + "legs", + [] + ) + if leg.get("mode") != "WALK" + ] + + direct = ( + len(transit_legs) <= 1 + ) + + # ------------------------------------------------ + # DIRECTION / LIGNE + # ------------------------------------------------ + + if transit_legs: + + first_leg = transit_legs[0] + + headsign = ( + first_leg.get( + "headsign" + ) + or "" + ).strip() + + route_text = ( + first_leg.get( + "route" + ) + or "" + ) + if headsign: + direction = headsign - elif '/' in route_text: - direction = route_text.split('/', 1)[1].strip() + + elif "/" in route_text: + + direction = ( + route_text + .split( + "/", + 1 + )[1] + .strip() + ) + else: - direction = legs[-1]['to'].get('name', '?') - # extraire ligne la plus fiable: routeShortName en priorité - raw_route = transit_leg.get('routeShortName') or transit_leg.get('routeId') or route_text or '' - if raw_route.startswith('SEM:'): - raw_route = raw_route.replace('SEM:', '') - default_ligne = raw_route.strip() or '?' + direction = ( + first_leg + .get( + "to", + {} + ) + .get( + "name", + "?" + ) + ) + + route = ( + first_leg.get( + "routeShortName" + ) + or + first_leg.get( + "routeId" + ) + or + route_text + or + "?" + ) + + route = ( + route + .replace( + "SEM:", + "" + ) + .strip() + ) + + else: + + direction = "Marche" + route = "Marche" + + if self.ligne: + + ligne_display = ( + self.ligne.upper() + ) + else: - direction = "?" - default_ligne = "?" - ligne_display = (self.ligne.upper() if self.ligne.strip() else default_ligne) + ligne_display = route + + # ------------------------------------------------ + # AJOUT AU TABLEAU + # ------------------------------------------------ + + self.results_tree.insert( + "", + "end", + values=( + ligne_display, + direction, + dep_time, + arr_time, + f"{duration} min", + ( + "DIRECT" + if direct + else + "CORRESPONDANCE" + ) + ) + ) + + # Très important : + # on sauvegarde exactement le même + # itinéraire que celui affiché. + self.displayed_results.append( + itinerary + ) + + shown += 1 - self.results_tree.insert("", "end", values=(ligne_display, direction, dep_time, arr_time, f"{dur} min", "DIRECT" if direct else "CORRESPONDANCE")) + # ------------------------------------------------ + # AUCUN RÉSULTAT + # ------------------------------------------------ if shown == 0: - self.results_tree.insert("", "end", values=("", "⚠️ Aucun itinéraire trouvé.", "", "", "", "")) - self.load_more_btn.config(state="disabled") - self.load_more_btn.grid_remove() - else: - self.load_more_btn.config(state="normal") - self.load_more_btn.grid() - self._update_load_more_button_text() - def load_more(self): - self.time_offset += 1 - self._update_load_more_button_text() - self.load_more_btn.config(state="disabled") - threading.Thread(target=self.do_search).start() + self.results_tree.insert( + "", + "end", + values=( + "", + "Aucun itinéraire trouvé.", + "", + "", + "", + "" + ) + ) + + self.search_btn.config( + state="normal" + ) + + self.update_time_label() + + self.update_navigation_buttons() + + # ======================================================== + # DOUBLE CLIC SUR UN TRAJET + # ======================================================== + + def on_result_double_click(self, event): + + item = self.results_tree.identify_row( + event.y + ) + + if not item: + return + + children = ( + self.results_tree.get_children() + ) + + try: + + index = children.index( + item + ) + + except ValueError: + + return + + # Ligne de chargement ou ligne "aucun résultat" + if ( + index < 0 + or + index >= len( + self.displayed_results + ) + ): + + return + + itinerary = ( + self.displayed_results[index] + ) + + self.open_timeline_window( + itinerary + ) + + def get_stop_code(self, stop): + + if not isinstance(stop, dict): + return None + + for key in ( + "id", + "clusterId", + "stopId", + "code", + "gtfsId" + ): + + value = stop.get(key) + + if value: + return str(value).strip().split("::", 1)[0] + + for key in ( + "cluster", + "stop", + "place", + "stopData", + "vertex" + ): + + code = self.get_stop_code( + stop.get(key) + ) + + if code: + return code + + name = str(stop.get("name", "")).strip() + + known_stop = stops_dict.get(name.lower()) + + if known_stop: + return known_stop[0].split("::", 1)[0] + + return None + + def get_stop_display_name(self, stop, fallback="Arrêt"): + + if not isinstance(stop, dict): + return fallback + + name = str(stop.get("name", "")).strip() + + if name and not name.upper().startswith("SEM:"): + return name + + code = self.get_stop_code(stop) + + for full_code, known_name in stops_dict.values(): + + if full_code.split("::", 1)[0] == code: + return known_name + + return fallback + + def bind_timeline_stop( + self, + canvas, + tag, + stop_name, + stop_code, + line=None + ): + + canvas.tag_bind( + tag, + "", + lambda event: self.on_timeline_stop_click( + canvas, + tag + ) + ) + + canvas.tag_bind( + tag, + "", + lambda event: self.on_timeline_stop_double_click( + stop_name, + stop_code, + line + ) + ) + + def on_timeline_stop_click( + self, + canvas, + selected_tag + ): + + canvas.itemconfigure( + selected_tag, + fill=( + "#1976d2" + if selected_tag == "timeline_stop_departure" + else "#2a8f6b" + ) + ) + + def on_timeline_stop_double_click( + self, + stop_name, + stop_code, + line=None + ): + + if not stop_code: + + messagebox.showerror( + "Arrêt sans code", + f"Le code de l'arrêt '{stop_name}' est introuvable." + ) + + return + + script = BUS_SCRIPT_TEMPLATE.replace( + "__CLUSTER_CODE__", + json.dumps(stop_code) + ).replace( + "__LINES_WANTED__", + json.dumps( + [ + line + if line.upper().startswith("SEM:") + else f"SEM:{line}" + ] + if line + else [] + ) + ) + + try: + + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + suffix="_tag_next_buses.py", + delete=False + ) as script_file: + + script_file.write(script) + script_path = script_file.name + + if os.name == "nt": + + command = ( + f'cmd.exe /d /k ""{sys.executable}" ' + f'"{script_path}""' + ) + + subprocess.Popen( + command, + creationflags=subprocess.CREATE_NEW_CONSOLE + ) + + else: + + subprocess.Popen( + [ + "x-terminal-emulator", + "-e", + sys.executable, + script_path + ] + ) + + except Exception as error: + + messagebox.showerror( + "Lancement impossible", + f"Impossible d'ouvrir le terminal : {error}" + ) + + # ======================================================== + # DESSIN D'UN ARRÊT PRINCIPAL + # ======================================================== + + def draw_big_stop( + self, + canvas, + x_time, + x_line, + x_content, + y, + stop_time, + stop_name, + label, + stop_code=None, + stop_tag=None + ): + + tag = stop_tag or f"timeline_stop_{canvas.find_all().__len__()}" + + canvas.create_oval( + x_line - 11, + y - 11, + x_line + 11, + y + 11, + fill=( + "#1976d2" + if label == "Départ" + else "#333333" + ), + outline=( + "#1976d2" + if label == "Départ" + else "#333333" + ), + tags=("timeline_stop", tag) + ) + + if stop_time: + + canvas.create_text( + x_time, + y, + text=stop_time.strftime("%H:%M"), + anchor="e", + font=("TkDefaultFont", 10, "bold"), + fill=( + "#1976d2" + if label == "Départ" + else "#333333" + ), + tags=("timeline_stop", tag) + ) + + canvas.create_text( + x_content, + y - 4, + text=f"{label} : {stop_name}", + anchor="w", + font=("TkDefaultFont", 10, "bold"), + fill=( + "#1976d2" + if label == "Départ" + else "#222222" + ), + tags=("timeline_stop", tag) + ) + + self.bind_timeline_stop( + canvas, + tag, + stop_name, + stop_code, + line + ) + + # ======================================================== + # FENÊTRE TIMELINE + # ======================================================== + + def open_timeline_window( + self, + itinerary + ): + + window = tk.Toplevel( + self.root + ) + + window.title( + "Détails du trajet" + ) + + window.geometry( + "900x720" + ) + + window.minsize( + 700, + 500 + ) + + # ---------------------------------------------------- + # INFORMATIONS GÉNÉRALES + # ---------------------------------------------------- + + header = ttk.Frame( + window + ) + + header.pack( + fill="x", + padx=20, + pady=15 + ) + + try: + + start_dt = ( + datetime.fromtimestamp( + itinerary["startTime"] / 1000 + ) + ) + + end_dt = ( + datetime.fromtimestamp( + itinerary["endTime"] / 1000 + ) + ) + + total_duration = round( + itinerary["duration"] / 60 + ) + + header_text = ( + f"{start_dt.strftime('%H:%M')}" + " → " + f"{end_dt.strftime('%H:%M')}" + f" {total_duration} min" + ) + + except Exception: + + header_text = "" + + ttk.Label( + header, + text="Détails du trajet", + font=( + "TkDefaultFont", + 17, + "bold" + ) + ).pack( + side="left" + ) + + ttk.Label( + header, + text=header_text, + font=( + "TkDefaultFont", + 11 + ) + ).pack( + side="right" + ) + + # ---------------------------------------------------- + # ZONE SCROLLABLE + # ---------------------------------------------------- + + outer_frame = ttk.Frame( + window + ) + + outer_frame.pack( + fill="both", + expand=True, + padx=10, + pady=5 + ) + + canvas = tk.Canvas( + outer_frame, + background="#f7f7f7", + highlightthickness=0 + ) + + scrollbar = ttk.Scrollbar( + outer_frame, + orient="vertical", + command=canvas.yview + ) + + canvas.configure( + yscrollcommand=scrollbar.set + ) + + scrollbar.pack( + side="right", + fill="y" + ) + + canvas.pack( + side="left", + fill="both", + expand=True + ) + + # ---------------------------------------------------- + # DESSIN + # ---------------------------------------------------- + + self.draw_timeline( + canvas, + itinerary + ) + + # ---------------------------------------------------- + # FERMER + # ---------------------------------------------------- + + ttk.Button( + window, + text="Fermer", + command=window.destroy + ).pack( + pady=10 + ) + + # ---------------------------------------------------- + # MOLETTE + # ---------------------------------------------------- + + def mousewheel(event): + + canvas.yview_scroll( + int( + -1 * + (event.delta / 120) + ), + "units" + ) + + canvas.bind( + "", + mousewheel + ) + + # Linux + canvas.bind( + "", + lambda event: canvas.yview_scroll( + -3, + "units" + ) + ) + + canvas.bind( + "", + lambda event: canvas.yview_scroll( + 3, + "units" + ) + ) + + # ======================================================== + # DESSIN DE LA TIMELINE + # ======================================================== + + def draw_timeline( + self, + canvas, + itinerary + ): + + legs = itinerary.get( + "legs", + [] + ) + + if not legs: + + canvas.create_text( + 450, + 100, + text="Aucun détail disponible.", + font=( + "TkDefaultFont", + 12 + ) + ) + + canvas.configure( + scrollregion=( + 0, + 0, + 900, + 250 + ) + ) + + return + + # ---------------------------------------------------- + # DIMENSIONS + # ---------------------------------------------------- + + width = 880 + + x_time = 90 + x_line = 180 + x_content = 230 + + y = 50 + + # ---------------------------------------------------- + # CALCUL DE LA HAUTEUR + # ---------------------------------------------------- + + total_height = 180 + + for leg in legs: + + if ( + leg.get("mode", "") + .upper() + == "WALK" + ): + + total_height += 100 + + else: + + total_height += 150 + + intermediate = ( + leg.get( + "intermediateStops" + ) + or [] + ) + + total_height += ( + len(intermediate) + * 48 + ) + + total_height += 100 + + canvas.configure( + width=width, + height=min( + total_height, + 600 + ), + scrollregion=( + 0, + 0, + width, + total_height + ) + ) + + # ---------------------------------------------------- + # LIGNE VERTICALE + # ---------------------------------------------------- + + canvas.create_line( + x_line, + 50, + x_line, + total_height - 50, + fill="#777777", + width=5 + ) + + # ---------------------------------------------------- + # PREMIER ARRÊT + # ---------------------------------------------------- + + first_leg = legs[0] + + first_time = self.timestamp_to_datetime( + first_leg.get( + "startTime" + ) + ) + + first_stop = first_leg.get( + "from", + {} + ) + + first_from = self.get_stop_display_name( + first_stop, + "Départ" + ) + + self.draw_big_stop( + canvas, + x_time, + x_line, + x_content, + y, + first_time, + first_from, + "Départ", + self.get_stop_code(first_stop), + "timeline_stop_departure" + ) + + y += 90 + + previous_route = None + + # ==================================================== + # CHAQUE ÉTAPE + # ==================================================== + + for leg_index, leg in enumerate(legs): + + mode = ( + leg.get( + "mode", + "" + ) + .upper() + ) + + # ================================================= + # MARCHE + # ================================================= + + if mode == "WALK": + + start_time = ( + self.timestamp_to_datetime( + leg.get( + "startTime" + ) + ) + ) + + end_time = ( + self.timestamp_to_datetime( + leg.get( + "endTime" + ) + ) + ) + + to_name = ( + self.get_stop_display_name( + leg.get("to", {}), + "Correspondance" + ) + ) + + duration = self.duration_minutes( + start_time, + end_time + ) + + canvas.create_line( + x_line, + y, + x_line, + y + 60, + fill="#999999", + width=4, + dash=(6, 5) + ) + + canvas.create_oval( + x_line - 7, + y + 53, + x_line + 7, + y + 67, + fill="#f7f7f7", + outline="#666666", + width=2 + ) + + if start_time: + + canvas.create_text( + x_time, + y + 20, + text=start_time.strftime( + "%H:%M" + ), + anchor="e", + font=( + "TkDefaultFont", + 9, + "bold" + ), + fill="#555555" + ) + + canvas.create_text( + x_content, + y + 15, + text="Marche", + anchor="w", + font=( + "TkDefaultFont", + 10, + "bold" + ), + fill="#555555" + ) + + walk_text = ( + f"Vers {to_name}" + ) + + if duration is not None: + walk_text += ( + f" {duration} min" + ) + + canvas.create_text( + x_content, + y + 38, + text=walk_text, + anchor="w", + font=( + "TkDefaultFont", + 9 + ), + fill="#777777" + ) + + y += 100 + + previous_route = None + + continue + + # ================================================= + # TRANSPORT + # ================================================= + + route = ( + leg.get( + "routeShortName" + ) + or + leg.get( + "routeId" + ) + or + leg.get( + "route" + ) + or + "?" + ) + + route = ( + str(route) + .replace( + "SEM:", + "" + ) + .strip() + ) + + headsign = ( + leg.get( + "headsign" + ) + or + "" + ).strip() + + start_time = ( + self.timestamp_to_datetime( + leg.get( + "startTime" + ) + ) + ) + + end_time = ( + self.timestamp_to_datetime( + leg.get( + "endTime" + ) + ) + ) + + from_name = self.get_stop_display_name( + leg.get("from", {}), + "?" + ) + + to_name = self.get_stop_display_name( + leg.get("to", {}), + "?" + ) + + # ================================================= + # CORRESPONDANCE + # ================================================= + + if ( + previous_route + and + previous_route != route + ): + + canvas.create_rectangle( + x_line + 25, + y - 18, + x_line + 205, + y + 18, + fill="#ffffff", + outline="#bbbbbb" + ) + + canvas.create_text( + x_line + 115, + y, + text="Correspondance", + font=( + "TkDefaultFont", + 9, + "bold" + ), + fill="#555555" + ) + + y += 55 + + # ================================================= + # CARTE DE LA LIGNE + # ================================================= + + card_x1 = x_content + card_x2 = width - 35 + + card_y1 = y + card_y2 = y + 95 + boarding_tag = f"timeline_boarding_{leg_index}" + + # Carte + canvas.create_rectangle( + card_x1, + card_y1, + card_x2, + card_y2, + fill="#ffffff", + outline="#cccccc", + width=1, + tags=(boarding_tag,) + ) + + # Bloc ligne + line_x2 = card_x1 + 85 + + canvas.create_rectangle( + card_x1, + card_y1, + line_x2, + card_y2, + fill="#333333", + outline="#333333", + tags=(boarding_tag,) + ) + + canvas.create_text( + card_x1 + 42, + card_y1 + 39, + text=route, + fill="white", + font=( + "TkDefaultFont", + 16, + "bold" + ) + ) + + # Direction + canvas.create_text( + line_x2 + 20, + card_y1 + 17, + text=f"Départ : {from_name}", + anchor="w", + font=( + "TkDefaultFont", + 9, + "bold" + ), + fill="#1976d2", + tags=(boarding_tag,) + ) + + canvas.create_text( + line_x2 + 20, + card_y1 + 43, + text="Direction", + anchor="w", + font=( + "TkDefaultFont", + 8 + ), + fill="#888888" + ) + + direction = ( + headsign + if headsign + else "Direction inconnue" + ) + + canvas.create_text( + line_x2 + 20, + card_y1 + 70, + text=direction, + anchor="w", + font=( + "TkDefaultFont", + 10, + "bold" + ), + fill="#333333" + ) + + canvas.tag_bind( + boarding_tag, + "", + lambda event, name=from_name, code=self.get_stop_code( + leg.get("from", {}) + ): self.on_timeline_stop_double_click( + name, + code, + route + ) + ) + + # ================================================= + # POINT D'EMBARQUEMENT + # ================================================= + + canvas.create_oval( + x_line - 9, + y - 9, + x_line + 9, + y + 9, + fill="#333333", + outline="#333333" + ) + + if start_time: + + canvas.create_text( + x_time, + y, + text=start_time.strftime( + "%H:%M" + ), + anchor="e", + font=( + "TkDefaultFont", + 10, + "bold" + ), + fill="#333333" + ) + + y += 120 + + # ================================================= + # ARRÊTS INTERMÉDIAIRES + # ================================================= + + intermediate = ( + leg.get( + "intermediateStops" + ) + or [] + ) + + for stop_index, stop in enumerate(intermediate): + + stop_name = self.get_stop_display_name( + stop, + "Arrêt" + ) + + # Certains retours API utilisent + # arrival/departure en millisecondes, + # d'autres peuvent être en secondes. + stop_timestamp = ( + stop.get( + "arrival" + ) + or + stop.get( + "departure" + ) + ) + + stop_time = ( + self.timestamp_to_datetime( + stop_timestamp + ) + ) + + stop_tag = ( + f"timeline_stop_{leg_index}_{stop_index}" + ) + + # Petit point + canvas.create_oval( + x_line - 5, + y - 5, + x_line + 5, + y + 5, + fill="#f7f7f7", + outline="#666666", + width=2, + tags=("timeline_stop", stop_tag) + ) + + if stop_time: + + canvas.create_text( + x_time, + y, + text=stop_time.strftime( + "%H:%M" + ), + anchor="e", + font=( + "TkDefaultFont", + 9 + ), + fill="#666666", + tags=("timeline_stop", stop_tag) + ) + + canvas.create_text( + x_content, + y, + text=stop_name, + anchor="w", + font=( + "TkDefaultFont", + 9 + ), + fill="#555555", + tags=("timeline_stop", stop_tag) + ) + + self.bind_timeline_stop( + canvas, + stop_tag, + stop_name, + self.get_stop_code(stop), + route + ) + + if end_time: + + canvas.create_text( + x_time, + y, + text=end_time.strftime( + "%H:%M" + ), + anchor="e", + font=( + "TkDefaultFont", + 10, + "bold" + ), + fill="#333333", + tags=( + "timeline_stop", + f"timeline_stop_arrival_{leg_index}" + ) + ) + + canvas.create_text( + x_content, + y, + text=to_name, + anchor="w", + font=( + "TkDefaultFont", + 10, + "bold" + ), + fill="#333333", + tags=( + "timeline_stop", + f"timeline_stop_arrival_{leg_index}" + ) + ) + + self.bind_timeline_stop( + canvas, + f"timeline_stop_arrival_{leg_index}", + to_name, + self.get_stop_code( + leg.get("to", {}) + ), + route + ) + + y += 80 + + previous_route = route + + # ==================================================== + # ARRIVÉE FINALE + # ==================================================== + + try: + + final_time = datetime.fromtimestamp( + itinerary["endTime"] / 1000 + ) + + except Exception: + + final_time = None + + final_name = "Destination" + + if legs: + + final_name = self.get_stop_display_name( + legs[-1].get("to", {}), + "Destination" + ) + + # Ligne jusqu'à l'arrivée + canvas.create_line( + x_line, + y - 70, + x_line, + y, + fill="#777777", + width=5 + ) + + # Gros point final + canvas.create_oval( + x_line - 11, + y - 11, + x_line + 11, + y + 11, + fill="#333333", + outline="#333333", + tags=("timeline_stop", "timeline_stop_final") + ) + + if final_time: + + canvas.create_text( + x_time, + y, + text=final_time.strftime( + "%H:%M" + ), + anchor="e", + font=( + "TkDefaultFont", + 10, + "bold" + ), + fill="#333333", + tags=("timeline_stop", "timeline_stop_final") + ) + + canvas.create_text( + x_content, + y, + text=final_name, + anchor="w", + font=( + "TkDefaultFont", + 11, + "bold" + ), + fill="#222222", + tags=("timeline_stop", "timeline_stop_final") + ) + + self.bind_timeline_stop( + canvas, + "timeline_stop_final", + final_name, + self.get_stop_code( + legs[-1].get("to", {}) + ) + ) + + # ---------------------------------------------------- + # RÉGION DE SCROLL + # ---------------------------------------------------- + + canvas.configure( + scrollregion=( + 0, + 0, + width, + y + 70 + ) + ) + + # ======================================================== + # GESTION TIMESTAMP + # ======================================================== + + def timestamp_to_datetime( + self, + timestamp + ): + + if timestamp is None: + return None + + try: + + timestamp = float( + timestamp + ) + + # Millisecondes + if timestamp > 100000000000: + + timestamp /= 1000 + + return datetime.fromtimestamp( + timestamp + ) + + except Exception: + + return None + + # ======================================================== + # DURÉE + # ======================================================== + + def duration_minutes( + self, + start, + end + ): + + if not start or not end: + return None + + try: + + return round( + ( + end - start + ).total_seconds() + / 60 + ) + + except Exception: + + return None + + # ======================================================== + # LISTE DES ARRÊTS + # ======================================================== + + def list_stops(self): + + win = tk.Toplevel( + self.root + ) + + win.title( + "Lister arrêts par ligne" + ) + + win.geometry( + "560x450" + ) + + ttk.Label( + win, + text="Ligne (E, A, C1...) :" + ).pack( + anchor="w", + padx=10, + pady=(10, 0) + ) + + line_entry = ttk.Entry( + win, + width=18 + ) + + line_entry.pack( + anchor="w", + padx=10, + pady=(0, 10) + ) + + columns = ( + "name", + "cluster", + "lat", + "lon" + ) + + stop_tree = ttk.Treeview( + win, + columns=columns, + show="headings", + height=16 + ) + + stop_tree.heading( + "name", + text="Nom" + ) + + stop_tree.heading( + "cluster", + text="Cluster ID" + ) + + stop_tree.heading( + "lat", + text="Lat" + ) + + stop_tree.heading( + "lon", + text="Lon" + ) + + stop_tree.column( + "name", + width=210, + anchor="w" + ) + + stop_tree.column( + "cluster", + width=150, + anchor="center" + ) + + stop_tree.column( + "lat", + width=80, + anchor="center" + ) + + stop_tree.column( + "lon", + width=80, + anchor="center" + ) + + stop_tree.pack( + fill="both", + expand=True, + padx=10, + pady=5 + ) + + scrollbar = ttk.Scrollbar( + win, + orient="vertical", + command=stop_tree.yview + ) + + stop_tree.configure( + yscrollcommand=scrollbar.set + ) + + scrollbar.pack( + side="right", + fill="y" + ) + + def show_stops(event=None): + + line = ( + line_entry + .get() + .strip() + .upper() + ) + + if not line: + + messagebox.showerror( + "Erreur", + "Entrez une ligne valide." + ) + + return + + try: + + r = requests.get( + f"https://data.mobilites-m.fr/api/routers/default/index/routes/SEM:{line}/clusters", + timeout=8 + ) + + stops = r.json() + + if not stops: + + messagebox.showinfo( + "Info", + f"Aucun arrêt trouvé pour la ligne {line}." + ) + + for item in stop_tree.get_children(): + stop_tree.delete(item) + + return + + except Exception as e: + + messagebox.showerror( + "Erreur", + f"Impossible de lister la ligne {line} : {e}" + ) + + return + + for item in stop_tree.get_children(): + + stop_tree.delete( + item + ) + + for stop in stops: + + stop_tree.insert( + "", + "end", + values=( + stop.get( + "name" + ), + stop.get( + "id" + ), + stop.get( + "lat" + ), + stop.get( + "lon" + ) + ) + ) + + line_entry.bind( + "", + show_stops + ) + + line_entry.focus() + + stop_tree.bind( + "", + lambda event: self._fill_from_list_table( + event, + stop_tree + ) + ) + + ttk.Button( + win, + text="Fermer", + command=win.destroy + ).pack( + pady=6 + ) + + # ======================================================== + # REMPLIR DEPUIS LA LISTE DES ARRÊTS + # ======================================================== + + def _fill_from_list_table( + self, + event, + tree + ): + + selection = tree.selection() + + if not selection: + return + + values = tree.item( + selection[0], + "values" + ) + + if not values: + return + + name = values[0] + + choice = tk.Toplevel( + self.root + ) + + choice.title( + "Choisir champ" + ) + + choice.geometry( + "250x100" + ) + + ttk.Label( + choice, + text=f"Utiliser '{name}' comme :" + ).pack( + pady=6 + ) + + def choose_depart(): + + self.dep_entry.delete( + 0, + tk.END + ) + + self.dep_entry.insert( + 0, + name + ) + + choice.destroy() + + def choose_arrivee(): + + self.arr_entry.delete( + 0, + tk.END + ) + + self.arr_entry.insert( + 0, + name + ) + + choice.destroy() + + frame = ttk.Frame( + choice + ) + + frame.pack( + pady=4 + ) + + ttk.Button( + frame, + text="Départ", + command=choose_depart + ).grid( + row=0, + column=0, + padx=6 + ) + + ttk.Button( + frame, + text="Arrivée", + command=choose_arrivee + ).grid( + row=0, + column=1, + padx=6 + ) + + # ======================================================== + # AUTOCOMPLÉTION + # ======================================================== + + def on_entry_keyrelease( + self, + event + ): + + widget = event.widget + + query = ( + widget + .get() + .strip() + .lower() + ) + + if query in stops_dict: + + self.suggestion_box.place_forget() + + return + + if not query: + + self.suggestion_box.place_forget() + + return + + matches = [ + name + for name in stops_dict + if query in name + ] + + if not matches: + + self.suggestion_box.place_forget() + + return + + self.suggestion_box.delete( + 0, + tk.END + ) + + for name in matches[:10]: + + self.suggestion_box.insert( + tk.END, + stops_dict[name][1] + ) + + x = ( + widget.winfo_rootx() + - + self.root.winfo_rootx() + ) + + y = ( + widget.winfo_rooty() + - + self.root.winfo_rooty() + + + widget.winfo_height() + ) + + self.suggestion_box.place( + x=x, + y=y, + width=widget.winfo_width() + ) + + self.suggestion_box.lift() + + self.active_entry = widget + + # ======================================================== + # SÉLECTION SUGGESTION + # ======================================================== + + def _fill_from_suggestion( + self, + event + ): + + if ( + not hasattr( + self, + "active_entry" + ) + or + not self.active_entry + ): + + return + + selection = ( + self.suggestion_box + .curselection() + ) + + if not selection: + return + + text = ( + self.suggestion_box + .get( + selection[0] + ) + ) + + self.active_entry.delete( + 0, + tk.END + ) + + self.active_entry.insert( + 0, + text + ) + + self.suggestion_box.place_forget() + + # ======================================================== + # TAB AUTOCOMPLÉTION + # ======================================================== + + def on_entry_tab( + self, + event + ): + + if ( + self.suggestion_box.winfo_ismapped() + and + self.suggestion_box.size() > 0 + ): + + text = ( + self.suggestion_box + .get(0) + ) + + self.active_entry.delete( + 0, + tk.END + ) + + self.active_entry.insert( + 0, + text + ) + + self.suggestion_box.place_forget() + + return "break" + + return None + + # ======================================================== + # RÉINITIALISATION + # ======================================================== def new_search(self): - self.dep_entry.delete(0, tk.END) - self.arr_entry.delete(0, tk.END) - self.line_entry.delete(0, tk.END) + + self.dep_entry.delete( + 0, + tk.END + ) + + self.arr_entry.delete( + 0, + tk.END + ) + + self.line_entry.delete( + 0, + tk.END + ) + for item in self.results_tree.get_children(): - self.results_tree.delete(item) - self.load_more_btn.config(state="disabled") - self.load_more_btn.grid_remove() + + self.results_tree.delete( + item + ) + + self.results = [] + + self.displayed_results = [] + + self.fromPlace = "" + self.toPlace = "" + self.ligne = "" + self.time_offset = 0 + self.search_base_time = None + self.search_btn.config( + state="normal" + ) + + self.previous_btn.config( + state="disabled" + ) + + self.next_btn.config( + state="disabled" + ) + + self.time_label.config( + text="Aucune recherche effectuée" + ) + + self.suggestion_box.place_forget() + + +# ============================================================ +# LANCEMENT +# ============================================================ + if __name__ == "__main__": + root = tk.Tk() - app = TAGApp(root) - root.mainloop() \ No newline at end of file + + app = TAGApp( + root + ) + + root.mainloop()