From c23cb42a133ca345df111380df97d52f4f3275a7 Mon Sep 17 00:00:00 2001 From: LudvvigB Date: Fri, 11 Apr 2025 23:09:34 +0300 Subject: [PATCH] POI Types Editor tool has been added. Updates and fixes. Project structure has been changed. README updated. --- README.md | 47 ++++++- config/poi_types_list.txt | 23 ++++ main.py | 8 ++ scripts/gpx2cnx.py | 198 ----------------------------- src/__init__.py | 0 src/gpx2cnx.py | 256 ++++++++++++++++++++++++++++++++++++++ src/poi_types_editor.py | 204 ++++++++++++++++++++++++++++++ 7 files changed, 532 insertions(+), 204 deletions(-) create mode 100644 config/poi_types_list.txt create mode 100644 main.py delete mode 100644 scripts/gpx2cnx.py create mode 100644 src/__init__.py create mode 100644 src/gpx2cnx.py create mode 100644 src/poi_types_editor.py diff --git a/README.md b/README.md index 14e8810..880dd46 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # GPXtoCNXConverter -This is a Python script for converting .GPX files to .CNX files. It allows you to export your tracks and waypoints (POIs). The script supports bulk conversion of multiple .GPX files. +This is a Python app for converting .GPX files to .CNX files. It allows you to export your tracks and waypoints (POI's). It also supports bulk conversion of multiple .GPX files. The app includes a tool for editing POI types, allowing appropriate icons to be used for POI's on the bike computer. * CNX is a GIS data file format used by iGPSPORT GPS sport devices. * GPX is an XML based format for GPS tracks. ## Data -The script exports the following GPX data to the CNX file: +The GPX2CNX tool in the app exports the following GPX data to the CNX file: Track name from GPX tags. @@ -16,26 +16,61 @@ Trackpoints: * Longitude * Elevation -Waypoints (POIs): +Waypoints (POI's): * Latitude * Longitude * Name -The script also calculates the following data based on the coordinates (features of the format and devices where it is used): - +The app also calculates the following data based on the coordinates (features of the format and devices where it is used): * Distance * Ascent * Descent * Trackpoint count * Waypoint count +The POI TYPES EDITOR tool in the app allows you to assign POI's to 23 supported icon types: +1. Waypoint +2. Sprint Point +3. HC Climb +4. Level 1 Climb +5. Level 2 Climb +6. Level 3 Climb +7. Level 4 Climb +8. Supply Point +9. Garbage recycle area +10. Restroom +11. Service Point +12. Medical Aid Station +13. Equipment Area +14. Shop +15. Meeting Point +16. Viewing Platform +17. Instagram-Worthy Location +18. Tunnel +19. Valley +10. Dangerous Road +21. Sharp Turn +22. Steep Slope +23. Intersection + ## Run & Troubleshoot This uses built-in Python libraries. To run on Python for Linux and older versions of Python for Windows, you may need to install tkinter module for Windows and python3-tk for Linux. +Nuitka is used to build the .exe file in the releases. Python applications packaged to .exe with various tools often trigger antivirus alerts. + ## Usage -Run the gnx2cnx.py script or gnx2cnx.exe (from the release) to start the GUI. Browse and open your files. The converted files will be placed in the same directory as the original files, in a subfolder called 'cnx_routes'. +Run the main.py script or gnx2cnx.exe (from the release) to start the GUI. Select the tool in the app for converting or changing POI types. + +#### GPX2CNX Tool + +Browse and open your files. The converted files will be placed in the same directory as the original files, in a subfolder called 'cnx_routes'. + +#### POI TYPES EDITOR Tool + +Select the desired CNX file. All POI's from the track will be loaded with names and types. For each point, you can select the desired type from the dropdown menu. When you're done, click "Save All". + ## Features diff --git a/config/poi_types_list.txt b/config/poi_types_list.txt new file mode 100644 index 0000000..856a708 --- /dev/null +++ b/config/poi_types_list.txt @@ -0,0 +1,23 @@ +0, Waypoint +1, Sprint Point +2, HC Climb +3, Level 1 Climb +4, Level 2 Climb +5, Level 3 Climb +6, Level 4 Climb +7, Supply Point +8, Garbage recycle area +9, Restroom +10, Service Point +11, Medical Aid Station +12, Equipment Area +13, Shop +14, Meeting Point +15, Viewing Platform +16, Instagram-Worthy Location +17, Tunnel +18, Valley +19, Dangerous Road +20, Sharp Turn +21, Steep Slope +22, Intersection \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..46e06b9 --- /dev/null +++ b/main.py @@ -0,0 +1,8 @@ +# main.py +import tkinter as tk +import src.gpx2cnx as G2C + +# Run App +root = tk.Tk() +G2C.GPX2CNX(root) +root.mainloop() diff --git a/scripts/gpx2cnx.py b/scripts/gpx2cnx.py deleted file mode 100644 index 8e5fc6d..0000000 --- a/scripts/gpx2cnx.py +++ /dev/null @@ -1,198 +0,0 @@ -import os -import math -import codecs -import xml.etree.ElementTree as ET -from xml.dom import minidom -import tkinter as tk -from tkinter import filedialog, scrolledtext -import decimal -from decimal import Decimal, getcontext - - -getcontext().prec = 28 - -# Select files and start conversion -def select_files(): - file_paths = filedialog.askopenfilenames(filetypes=[("GPX files", "*.gpx")]) - if file_paths: - info_area.delete(1.0, tk.END) - results = convert_gpx2cnx(list(file_paths)) - for result in results: - info_area.insert(tk.END, result + "\n") - - -def prettify(elem): - rough_string = ET.tostring(elem, 'utf-8') - reparsed = minidom.parseString(rough_string) - return reparsed.toprettyxml(indent=" ") - - -# Calculate 3D distance between coordinates -def calc_distance(lat1, lon1, ele1, lat2, lon2, ele2): - R = 6371 * 1000 # Earth radius in meters - dlat = Decimal(str(math.radians(lat2 - lat1))) - dlon = Decimal(str(math.radians(lon2 - lon1))) - dele = Decimal(str(ele2 - ele1)) - a = Decimal(str((math.sin(dlat/2) * math.sin(dlat/2)) + \ - (math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * \ - math.sin(dlon/2) * math.sin(dlon/2)))) - b = Decimal(str(2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)))) - h_distance = R * b - distance = (h_distance**2 + dele**2).sqrt() # 3D distance - return distance - - -# Converts a list of GPX files to CNX xml format -def convert_gpx2cnx(gpx_files): - output_dir= os.path.join(os.path.normpath(os.path.split(gpx_files[0])[0]),'cnx_routes') - if not os.path.exists(output_dir): - os.makedirs(output_dir) - - results = [] - for gpx_file in gpx_files: - try: - tree = ET.parse(gpx_file) - root = tree.getroot() - - # Extracting waypoints - waypoints = [] - for wpt in root.findall('{http://www.topografix.com/GPX/1/1}wpt'): - lat = Decimal(wpt.get('lat')) - lon = Decimal(wpt.get('lon')) - name = wpt.find('{http://www.topografix.com/GPX/1/1}name').text \ - if wpt.find('{http://www.topografix.com/GPX/1/1}name') is not None else "" - - waypoints.append({'lat': lat, 'lon': lon, 'name': name}) - - # Extracting track points - track_points = [] - track = root.find('{http://www.topografix.com/GPX/1/1}trk') - if track is not None: - track_name = track.find('{http://www.topografix.com/GPX/1/1}name').text \ - if track.find('{http://www.topografix.com/GPX/1/1}name') is not None else "Unknown" - trkseg = track.find('{http://www.topografix.com/GPX/1/1}trkseg') - if trkseg is not None: - for trkpt in trkseg.findall('{http://www.topografix.com/GPX/1/1}trkpt'): - lat = Decimal(trkpt.get('lat')) - lon = Decimal(trkpt.get('lon')) - ele = Decimal(trkpt.find('{http://www.topografix.com/GPX/1/1}ele').text) \ - if trkpt.find('{http://www.topografix.com/GPX/1/1}ele') is not None else Decimal('0.0') - - track_points.append({'lat': lat, 'lon': lon, 'ele': ele}) - - # Calc Distance, Ascent, Descent - distance = Decimal('0.0') - ascent = Decimal('0.0') - descent = Decimal('0.0') - if len(track_points) > 1: - for i in range(1, len(track_points)): - lat1 = track_points[i - 1]['lat'] - lon1 = track_points[i - 1]['lon'] - ele1 = track_points[i - 1]['ele'] - lat2 = track_points[i]['lat'] - lon2 = track_points[i]['lon'] - ele2 = track_points[i]['ele'] - - distance += calc_distance(lat1, lon1, ele1, lat2, lon2, ele2) - ele_diff = ele2 - ele1 - - if ele_diff > 0: - ascent += ele_diff - else: - descent += ele_diff - - distance = distance.quantize(Decimal('1.00'), decimal.ROUND_HALF_UP) - ascent = ascent.quantize(Decimal('1.00'), decimal.ROUND_HALF_UP) - descent = descent.quantize(Decimal('1.00'), decimal.ROUND_HALF_UP) - - # Calc Track - Relative Coordinates - if track_points: - first_lat = track_points[0]['lat'] - first_lon = track_points[0]['lon'] - first_ele = (track_points[0]['ele'] * 100).quantize(Decimal('1'), decimal.ROUND_HALF_UP) - - relative_points = [f"{first_lat},{first_lon},{first_ele}"] # First Point - Absolute Coordinates - - first_diffs = [] - for i in range(1, len(track_points)): # Calc first diffs - lat1 = track_points[i-1]['lat'] - lon1 = track_points[i-1]['lon'] - lat2 = track_points[i]['lat'] - lon2 = track_points[i]['lon'] - - lat_diff = (lat2 - lat1) * 10000000 - lon_diff = (lon2 - lon1) * 10000000 - ele_diff = track_points[i]['ele'] * 100 - track_points[i-1]['ele'] * 100 - - first_diffs.append((lat_diff, lon_diff, ele_diff)) - - if i == 1: # Second Point - lat_diff = lat_diff.quantize(Decimal('1'), decimal.ROUND_HALF_UP) - lon_diff = lon_diff.quantize(Decimal('1'), decimal.ROUND_HALF_UP) - ele_diff = ele_diff.quantize(Decimal('1'), decimal.ROUND_HALF_UP) - - relative_points.append(f"{lat_diff},{lon_diff},{ele_diff}") - - for i in range(1, len(first_diffs)): # Calc second diffs - lat_diff = (first_diffs[i][0] - first_diffs[i-1][0]).quantize(Decimal('1'), decimal.ROUND_HALF_UP) - lon_diff = (first_diffs[i][1] - first_diffs[i-1][1]).quantize(Decimal('1'), decimal.ROUND_HALF_UP) - ele_diff = (first_diffs[i][2]).quantize(Decimal('1'), decimal.ROUND_HALF_UP) - - relative_points.append(f"{lat_diff},{lon_diff},{ele_diff}") - - # Create XML - route = ET.Element('Route') - ET.SubElement(route, 'Id').text = track_name # Use track name as Id - ET.SubElement(route, 'Distance').text = f'{distance}' - - duration = ET.SubElement(route, 'Duration') - duration.text = '\n ' - - ET.SubElement(route, 'Ascent').text = f'{ascent}' - ET.SubElement(route, 'Descent').text = f'{descent}' - ET.SubElement(route, 'Encode').text = '2' - ET.SubElement(route, 'Lang').text = '0' - ET.SubElement(route, 'TracksCount').text = str(len(track_points)) - - if relative_points: - tracks_str = ';'.join(relative_points) - ET.SubElement(route, 'Tracks').text = tracks_str - else: - ET.SubElement(route, 'Tracks').text = "" - - ET.SubElement(route, 'Navs') - ET.SubElement(route, 'PointsCount').text = str(len(waypoints)) - - points = ET.SubElement(route, 'Points') - for wpt in waypoints: - point = ET.SubElement(points, 'Point') - ET.SubElement(point, 'Lat').text = str(wpt['lat']) - ET.SubElement(point, 'Lng').text = str(wpt['lon']) - ET.SubElement(point, 'Type').text = '0' # Type 0 - point marker without differentiation by purpose - ET.SubElement(point, 'Descr').text = wpt['name'] - - filename = os.path.splitext(os.path.basename(gpx_file))[0] - trim_filename = filename[:18] - output_filename = os.path.join(output_dir, f"route_{trim_filename}.cnx") - xml_string = prettify(route) - with codecs.open(output_filename, "w", encoding='utf-8-sig') as f: - f.write('\n') - f.write(xml_string[xml_string.find(' SUCCESS") - except Exception as e: - results.append(f"ERROR - file {gpx_file}: {e}") - return results - - -# Create GUI -root = tk.Tk() -root.title("GPXtoCNX Converter") - -button = tk.Button(root, text="Browse files", command=select_files) -button.pack(pady=10) - -info_area = scrolledtext.ScrolledText(root, width=80, height=20) -info_area.pack(pady=10) - -root.mainloop() \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gpx2cnx.py b/src/gpx2cnx.py new file mode 100644 index 0000000..e9048db --- /dev/null +++ b/src/gpx2cnx.py @@ -0,0 +1,256 @@ +# gpx2cnx.py +import os +import math +import codecs +import xml.etree.ElementTree as ET +from xml.dom import minidom +import tkinter as tk +from tkinter import filedialog, scrolledtext +import decimal +from decimal import Decimal, getcontext +import src.poi_types_editor as PE + + +getcontext().prec = 28 + + +class GPX2CNX: + # Creating GUI and calling button functions + def __init__(self, root): + self.root = root + root.title("GPXtoCNX Converter") + + self.status_label = tk.Label(root, text="", width=30) + self.status_label.grid(row=4, column=1, columnspan=3, sticky="ew", padx=25, pady=5) + + self.button1 = tk.Button(root, text="GPX -> CNX", command=lambda:[self.status_label.config(text=""), self.select_files()]) + self.button1.grid(row=1, column=1, columnspan=1, sticky="ew", padx=25, pady=5) + + self.button2 = tk.Button(root, text="POI TYPES EDITOR", command=self.poi_editor) + self.button2.grid(row=2, column=1, columnspan=1, sticky="ew", padx=25, pady=5) + + self.button3 = tk.Button(root, text="Log", command=self.log_window) + self.button3.grid(row=3, column=1, columnspan=1, sticky="ew", padx=25, pady=5) + + root.grid_columnconfigure(1, weight=1) + + def logging(self): + log = "gpx2cnx.py log" + for result in self.results: + log = log + "\n" + result + try: + with open("log.txt", "w", encoding="utf8") as logtxt: + logtxt.write(log + "\n") + except Exception as e: + self.status_label.config(text="* Error write log *") + if self.rstat == 1: + self.status_label.config(text="* DONE *") + else: + self.status_label.config(text="* ERROR *") + + # Run POI Types Editor + def poi_editor(self): + window = tk.Toplevel() + PE.POIEditor(window) + window.grab_set() + + # log_window + def log_window(self): + textinfo = "" + flogout = 0 + try: + with open("log.txt", "r") as logtxt: + textinfo = logtxt.read() + flogout = 1 + except FileNotFoundError: + self.status_label.config(text="* File not found *") + except Exception as e: + self.status_label.config(text="* Error read log *") + + if flogout == 1: + loginfo = tk.Toplevel() + loginfo.title("Log") + info_area = scrolledtext.ScrolledText(loginfo, width=80, height=20) + info_area.pack(pady=10) + loginfo.grab_set() + + info_area.insert(tk.END, textinfo) + + # Select files and start conversion + def select_files(self): + file_paths = filedialog.askopenfilenames(filetypes=[("GPX files", "*.gpx")]) + if file_paths: + self.convert_gpx2cnx(list(file_paths)) + + def prettify(self, elem): + rough_string = ET.tostring(elem, 'utf-8') + reparsed = minidom.parseString(rough_string) + return reparsed.toprettyxml(indent=" ") + + # Calculate 3D distance between coordinates + def calc_distance(self): + R = 6371 * 1000 # Earth radius in meters + dlat = Decimal(str(math.radians(self.lat2 - self.lat1))) + dlon = Decimal(str(math.radians(self.lon2 - self.lon1))) + dele = Decimal(str(self.ele2 - self.ele1)) + a = Decimal(str((math.sin(dlat/2) * math.sin(dlat/2)) + + (math.cos(math.radians(self.lat1)) * math.cos(math.radians(self.lat2)) * + math.sin(dlon/2) * math.sin(dlon/2)))) + b = Decimal(str(2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)))) + h_distance = R * b + distance = (h_distance**2 + dele**2).sqrt() # 3D distance + return distance + + # Converts a list of GPX files to CNX xml format + def convert_gpx2cnx(self, gpx_files): + output_dir= os.path.join(os.path.normpath(os.path.split(gpx_files[0])[0]),'cnx_routes') + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + log = "gpx2cnx.py log" + self.results = [] + for gpx_file in gpx_files: + try: + tree = ET.parse(gpx_file) + root = tree.getroot() + + # Extracting waypoints + waypoints = [] + for wpt in root.findall('{http://www.topografix.com/GPX/1/1}wpt'): + lat = Decimal(wpt.get('lat')) + lon = Decimal(wpt.get('lon')) + name = wpt.find('{http://www.topografix.com/GPX/1/1}name').text \ + if wpt.find('{http://www.topografix.com/GPX/1/1}name') is not None else "" + + waypoints.append({'lat': lat, 'lon': lon, 'name': name}) + + # Extracting track points + track_points = [] + track = root.find('{http://www.topografix.com/GPX/1/1}trk') + if track is not None: + track_name = track.find('{http://www.topografix.com/GPX/1/1}name').text \ + if track.find('{http://www.topografix.com/GPX/1/1}name') is not None else "Unknown" + trkseg = track.find('{http://www.topografix.com/GPX/1/1}trkseg') + if trkseg is not None: + for trkpt in trkseg.findall('{http://www.topografix.com/GPX/1/1}trkpt'): + lat = Decimal(trkpt.get('lat')) + lon = Decimal(trkpt.get('lon')) + ele = Decimal(trkpt.find('{http://www.topografix.com/GPX/1/1}ele').text) \ + if trkpt.find('{http://www.topografix.com/GPX/1/1}ele') is not None else Decimal('0.0') + + track_points.append({'lat': lat, 'lon': lon, 'ele': ele}) + + # Calc Distance, Ascent, Descent + distance = Decimal('0.0') + ascent = Decimal('0.0') + descent = Decimal('0.0') + if len(track_points) > 1: + for i in range(1, len(track_points)): + self.lat1 = track_points[i - 1]['lat'] + self.lon1 = track_points[i - 1]['lon'] + self.ele1 = track_points[i - 1]['ele'] + self.lat2 = track_points[i]['lat'] + self.lon2 = track_points[i]['lon'] + self.ele2 = track_points[i]['ele'] + + distance += self.calc_distance() + ele_diff = self.ele2 - self.ele1 + + if ele_diff > 0: + ascent += ele_diff + else: + descent += ele_diff + + distance = distance.quantize(Decimal('1.00'), decimal.ROUND_HALF_UP) + ascent = ascent.quantize(Decimal('1.00'), decimal.ROUND_HALF_UP) + descent = descent.quantize(Decimal('1.00'), decimal.ROUND_HALF_UP) + + # Calc Track - Relative Coordinates + if track_points: + first_lat = track_points[0]['lat'] + first_lon = track_points[0]['lon'] + first_ele = (track_points[0]['ele'] * 100).quantize(Decimal('1'), decimal.ROUND_HALF_UP) + + relative_points = [f"{first_lat},{first_lon},{first_ele}"] # First Point - Absolute Coordinates + + first_diffs = [] + for i in range(1, len(track_points)): # Calc first diffs + lat1 = track_points[i-1]['lat'] + lon1 = track_points[i-1]['lon'] + lat2 = track_points[i]['lat'] + lon2 = track_points[i]['lon'] + + lat_diff = (lat2 - lat1) * 10000000 + lon_diff = (lon2 - lon1) * 10000000 + ele_diff = track_points[i]['ele'] * 100 - track_points[i-1]['ele'] * 100 + + first_diffs.append((lat_diff, lon_diff, ele_diff)) + + if i == 1: # Second Point + lat_diff = lat_diff.quantize(Decimal('1'), decimal.ROUND_HALF_UP) + lon_diff = lon_diff.quantize(Decimal('1'), decimal.ROUND_HALF_UP) + ele_diff = ele_diff.quantize(Decimal('1'), decimal.ROUND_HALF_UP) + + relative_points.append(f"{lat_diff},{lon_diff},{ele_diff}") + + for i in range(1, len(first_diffs)): # Calc second diffs + lat_diff = (first_diffs[i][0] - first_diffs[i-1][0]).quantize(Decimal('1'), decimal.ROUND_HALF_UP) + lon_diff = (first_diffs[i][1] - first_diffs[i-1][1]).quantize(Decimal('1'), decimal.ROUND_HALF_UP) + ele_diff = (first_diffs[i][2]).quantize(Decimal('1'), decimal.ROUND_HALF_UP) + + relative_points.append(f"{lat_diff},{lon_diff},{ele_diff}") + + # Create XML + route = ET.Element('Route') + ET.SubElement(route, 'Id').text = track_name # Use track name as Id + ET.SubElement(route, 'Distance').text = f'{distance}' + + duration = ET.SubElement(route, 'Duration') + duration.text = '\n ' + + ET.SubElement(route, 'Ascent').text = f'{ascent}' + ET.SubElement(route, 'Descent').text = f'{descent}' + ET.SubElement(route, 'Encode').text = '2' + ET.SubElement(route, 'Lang').text = '0' + ET.SubElement(route, 'TracksCount').text = str(len(track_points)) + + if relative_points: + tracks_str = ';'.join(relative_points) + ET.SubElement(route, 'Tracks').text = tracks_str + else: + ET.SubElement(route, 'Tracks').text = "" + + ET.SubElement(route, 'Navs') + ET.SubElement(route, 'PointsCount').text = str(len(waypoints)) + + points = ET.SubElement(route, 'Points') + for wpt in waypoints: + point = ET.SubElement(points, 'Point') + ET.SubElement(point, 'Lat').text = str(wpt['lat']) + ET.SubElement(point, 'Lng').text = str(wpt['lon']) + ET.SubElement(point, 'Type').text = '0' # Type 0 - point marker without differentiation by purpose + ET.SubElement(point, 'Descr').text = wpt['name'] + + filename = os.path.splitext(os.path.basename(gpx_file))[0] + trim_filename = filename[:18] + output_filename = os.path.join(output_dir, f"route_{trim_filename}.cnx") + xml_string = self.prettify(route) + with codecs.open(output_filename, "w", encoding='utf-8-sig') as f: + f.write('\n') + f.write(xml_string[xml_string.find(' SUCCESS") + self.rstat = 1 + except Exception as e: + self.results.append(f"ERROR - file {gpx_file}: {e}") + self.rstat = 0 + self.logging() + self.logging() + return self.results + + +# Run app +if __name__ == "__main__": + root = tk.Tk() + app = GPX2CNX(root) + root.mainloop() diff --git a/src/poi_types_editor.py b/src/poi_types_editor.py new file mode 100644 index 0000000..7e1b866 --- /dev/null +++ b/src/poi_types_editor.py @@ -0,0 +1,204 @@ +# poi_types_editor.py +from pathlib import Path +import tkinter as tk +from tkinter import ttk +from tkinter import filedialog +from xml.etree import ElementTree as ET +import codecs + + +ROOT_DIR = Path(__file__).resolve().parents[1] +CONFIG_PATH = ROOT_DIR / 'config' +TYPES_LIST_FILE = 'poi_types_list.txt' +LIST_FILEPATH = CONFIG_PATH / TYPES_LIST_FILE + + +# Loads POI types from a file as a dictionary {code: name} +def load_poi_types(filepath=LIST_FILEPATH): + poi_types = {} + try: + with open(filepath, "r", encoding="utf-8") as file: + for line in file: + line = line.strip() + if line: + parts = line.split(',') + if len(parts) == 2: + type_code = parts[0].strip() + type_name = parts[1].strip() + poi_types[type_code] = type_name + except FileNotFoundError: + poi_types = {"0": "Waypoint"} # default value + return poi_types + + +class POIEditor: + # Creating GUI and calling button functions + def __init__(self, root): + self.root = root + root.title("POI TYPES EDITOR") + + self.poi_types = load_poi_types() + self.type_list_for_combo = list(self.poi_types.values()) + self.points = [] + self.current_index = 0 + self.xml_filepath = None + self.xml_tree = None + self.xml_root = None + + self.name_label = tk.Label(root, text="POI Name:") + self.name_label.grid(row=0, column=0, sticky="w", padx=5, pady=5) + + self.name_text = tk.StringVar() + self.name_entry = ttk.Entry(root, textvariable=self.name_text, state="readonly", width=50) + self.name_entry.grid(row=0, column=1, columnspan=2, sticky="ew", padx=5, pady=5) + + self.type_label = ttk.Label(root, text="POI Type:") + self.type_label.grid(row=1, column=0, sticky="w", padx=5, pady=5) + + self.type_combo = ttk.Combobox(root, values=self.type_list_for_combo, state="readonly") + self.type_combo.grid(row=1, column=1,columnspan=3, sticky="ew", padx=5, pady=5) + self.type_combo.bind("<>", self.update_current_poi_type) + + self.prev_button = ttk.Button(root, text="Prev", command=self.show_previous_poi, state="disabled") + self.prev_button.grid(row=2, column=0, sticky="ew", padx=5, pady=10) + + self.next_button = ttk.Button(root, text="Next", command=self.show_next_poi, state="disabled") + self.next_button.grid(row=2, column=1, sticky="ew", padx=5, pady=10) + + self.counter_label = ttk.Label(root, text="0/0") + self.counter_label.grid(row=2, column=2, sticky="ew", padx=5, pady=10) + + self.save_button = ttk.Button(root, text="Save All", command=self.save_all_changes, state="disabled") + self.save_button.grid(row=3, column=0, columnspan=3, sticky="ew", padx=5, pady=10) + + self.load_button = ttk.Button(root, text="Load CNX", command=self.load_cnx) + self.load_button.grid(row=4, column=0, columnspan=3, sticky="ew", padx=5, pady=10) + + self.status_label = ttk.Label(root, text="") + self.status_label.grid(row=5, column=0, columnspan=3, sticky="e", padx=5, pady=5) + + root.grid_columnconfigure(1, weight=1) + + # Loads file and parses the data + def load_cnx(self): + filepath = filedialog.askopenfilename( + defaultextension=".cnx", + filetypes=[("CNX files", "*.cnx")], + title="Select CNX file" + ) + if filepath: + try: + self.xml_tree = ET.parse(filepath) + self.xml_root = self.xml_tree.getroot() + points_element = self.xml_root.find("Points") + if points_element is not None: + self.points = [] + for point_element in points_element.findall("Point"): + lat = point_element.findtext("Lat") + lng = point_element.findtext("Lng") + type_val = point_element.findtext("Type") + descr = point_element.findtext("Descr") + self.points.append({"lat": lat, "lng": lng, "type": type_val, "descr": descr, "element": point_element}) + if self.points: + self.current_index = 0 + self.show_current_poi() + self.prev_button["state"] = "disabled" + self.next_button["state"] = "normal" if len(self.points) > 1 else "disabled" + self.save_button["state"] = "normal" + self.status_label.config(text=f"* Loaded {len(self.points)} POI's *") + self.xml_filepath = filepath + else: + self.status_label.config(text="* No POI's found in the file *") + self.clear_ui() + self.xml_filepath = None + else: + self.status_label.config(text="* The element not found in the file *") + self.clear_ui() + xml_filepath = None + except ET.ParseError: + self.status_label.config(text="* CNX file parsing error *") + self.clear_ui() + self.xml_filepath = None + except FileNotFoundError: + self.status_label.config(text="* File not found *") + self.clear_ui() + self.xml_filepath = None + + # Displays the current POI in the interface + def show_current_poi(self): + if self.points: + current_poi = self.points[self.current_index] + self.name_text.set(current_poi["descr"]) + poi_type = self.poi_types.get(current_poi["type"], f"Unknown type ({current_poi['type']})") + self.type_combo.set(poi_type) + self.counter_label.config(text=f"{self.current_index + 1}/{len(self.points)}") + + # Next POI + def show_next_poi(self): + if self.current_index < len(self.points) - 1: + self.current_index += 1 + self.show_current_poi() + self.prev_button["state"] = "normal" + if self.current_index == len(self.points) - 1: + self.next_button["state"] = "disabled" + + # Prev POI + def show_previous_poi(self): + if self.current_index > 0: + self.current_index -= 1 + self.show_current_poi() + self.next_button["state"] = "normal" + if self.current_index == 0: + self.prev_button["state"] = "disabled" + + # Returns the type code by type name + def get_type_code(self, type_name): + for code, name in self.poi_types.items(): + if name == type_name: + return code + return "0" # default value + + # Updates the type of the current POI in the self.points list when a value is selected in the Combobox + def update_current_poi_type(self, event): + if self.points and 0 <= self.current_index < len(self.points): + selected_type_name = self.type_combo.get() + selected_type_code = self.get_type_code(selected_type_name) + self.points[self.current_index]["type"] = selected_type_code + + # Saves all changes to the original file + def save_all_changes(self): + if self.points and self.xml_filepath and self.xml_tree: + points_element = self.xml_root.find("Points") + if points_element is not None: + for i, poi in enumerate(self.points): + point_element = points_element[i] + point_element.find("Type").text = poi["type"] + try: + xml_string = ET.tostring(self.xml_root, encoding='unicode') + with codecs.open(self.xml_filepath, "w", encoding='utf-8-sig') as f: + f.write('\n') + f.write(xml_string[xml_string.find('