diff --git a/pretty_gpx/drawing/drawing_figure.py b/pretty_gpx/drawing/drawing_figure.py index 29bbe93..084ffb1 100644 --- a/pretty_gpx/drawing/drawing_figure.py +++ b/pretty_gpx/drawing/drawing_figure.py @@ -76,8 +76,9 @@ class DrawingFigure(BaseDrawingFigure): track_data: list[BaseDrawingData] peak_data: list[BaseDrawingData] - title: TextData - stats: TextData + title: TextData + stats: TextData + duration: TextData def draw(self, fig: Figure, @@ -85,7 +86,8 @@ def draw(self, img: np.ndarray, theme_colors: ThemeColors, title_txt: str, - stats_txt: str) -> None: + stats_txt: str, + duration_txt: str) -> None: """Plot the background image and the annotations on top of it.""" self.imshow(fig, ax, img) self.adjust_display_width(fig, self.w_display_pix) @@ -101,3 +103,7 @@ def draw(self, self.title.plot(ax, theme_colors.peak_color, self.ref_img_shape, img.shape) self.stats.plot(ax, theme_colors.background_color, self.ref_img_shape, img.shape) + + if duration_txt is not None and duration_txt != "": + self.duration.s = duration_txt + self.duration.plot(ax, theme_colors.background_color, self.ref_img_shape, img.shape) \ No newline at end of file diff --git a/pretty_gpx/drawing/poster_image_cache.py b/pretty_gpx/drawing/poster_image_cache.py index bae1eac..0aa4d43 100644 --- a/pretty_gpx/drawing/poster_image_cache.py +++ b/pretty_gpx/drawing/poster_image_cache.py @@ -60,6 +60,18 @@ def from_gpx_data(gpx_data: AugmentedGpxData, low_res = high_res.change_dpi(WORKING_DPI) return PosterImageCaches(low_res=low_res, high_res=high_res, gpx_data=gpx_data) + @staticmethod + def update_duration_str(gpx_data: AugmentedGpxData, + paper_size: PaperSize, + override_duration_str:str) -> 'PosterImageCaches': + """Create a PosterImageCaches from a GPX file.""" + high_res = PosterImageCache.from_gpx_data(gpx_data, + dpi=HIGH_RES_DPI, + paper=paper_size, + override_duration=override_duration_str) + low_res = high_res.change_dpi(WORKING_DPI) + return PosterImageCaches(low_res=low_res, high_res=high_res, gpx_data=gpx_data) + @dataclass class PosterDrawingData: @@ -68,6 +80,7 @@ class PosterDrawingData: theme_colors: ThemeColors title_txt: str stats_text: str + duration_txt: str @dataclass @@ -79,6 +92,7 @@ class PosterImageCache: stats_dist_km: float stats_uphill_m: float + stat_duration_str: str plotter: DrawingFigure @@ -88,7 +102,8 @@ class PosterImageCache: def from_gpx_data(gpx_data: AugmentedGpxData, paper: PaperSize, layout: VerticalLayout = VerticalLayout(), - dpi: float = HIGH_RES_DPI) -> 'PosterImageCache': + dpi: float = HIGH_RES_DPI, + override_duration: str=None) -> 'PosterImageCache': """Create a PosterImageCache from a GPX file.""" # Download the elevation map at the correct layout bounds, latlon_aspect_ratio = get_bounds(gpx_data.track, layout, paper) @@ -169,18 +184,28 @@ def from_gpx_data(gpx_data: AugmentedGpxData, fontproperties=drawing_style_params.classic_font) # Draw the elevation profile + if override_duration is not None and override_duration != "": + duration_to_draw = override_duration + else: + duration_to_draw = gpx_data.total_duration + + if duration_to_draw is not None and duration_to_draw != "": + draw_duration = True + else: + draw_duration = False draw_start = gpx_data.start_name is not None draw_end = draw_start if gpx_data.is_closed else gpx_data.end_name is not None - ele_scatter, ele_fill_poly, stats = get_elevation_drawings(layout=layout, - h_pix=h, w_pix=w, - list_ele=gpx_data.track.list_ele, - list_cum_d=gpx_data.track.list_cumul_d, - passes_ids=gpx_data.passes_ids, - huts_ids=gpx_data.hut_ids, - draw_start=draw_start, - draw_end=draw_end, - drawing_style_params=drawing_style_params, - drawing_size_params=drawing_size_params) + ele_scatter, ele_fill_poly, stats, duration_stat = get_elevation_drawings(layout=layout, + h_pix=h, w_pix=w, + list_ele=gpx_data.track.list_ele, + list_cum_d=gpx_data.track.list_cumul_d, + passes_ids=gpx_data.passes_ids, + huts_ids=gpx_data.hut_ids, + draw_start=draw_start, + draw_end=draw_end, + draw_duration=draw_duration, + drawing_style_params=drawing_style_params, + drawing_size_params=drawing_size_params) # Prepare the plot data augmented_hut_ids = [0] + gpx_data.hut_ids + [None] @@ -241,13 +266,15 @@ def from_gpx_data(gpx_data: AugmentedGpxData, track_data=track_data, peak_data=peak_data, title=title, - stats=stats) + stats=stats, + duration=duration_stat) print("Successful GPX Processing") return PosterImageCache(elevation_map=elevation, elevation_shading=CachedHillShading(elevation), stats_dist_km=gpx_data.dist_km, stats_uphill_m=gpx_data.uphill_m, + stat_duration_str=duration_to_draw, plotter=plotter, dpi=dpi) @@ -258,6 +285,7 @@ def change_dpi(self, dpi: float) -> 'PosterImageCache': elevation_shading=CachedHillShading(new_elevation_map), stats_dist_km=self.stats_dist_km, stats_uphill_m=self.stats_uphill_m, + stat_duration_str=self.stat_duration_str, plotter=self.plotter, dpi=dpi) @@ -266,7 +294,8 @@ def update_drawing_data(self, theme_colors: ThemeColors, title_txt: str, uphill_m: str, - dist_km: str) -> PosterDrawingData: + dist_km: str, + duration: str) -> PosterDrawingData: """Update the drawing data (can run in a separate thread).""" grey_hillshade = self.elevation_shading.render_grey(azimuth)[..., None] background_color_rgb = hex_to_rgb(theme_colors.background_color) @@ -280,7 +309,7 @@ def update_drawing_data(self, uphill_m_int = int(uphill_m if uphill_m != '' else self.stats_uphill_m) stats_text = f"{dist_km_int} km - {uphill_m_int} m D+" - return PosterDrawingData(img, theme_colors, title_txt=title_txt, stats_text=stats_text) + return PosterDrawingData(img, theme_colors, title_txt=title_txt, stats_text=stats_text, duration_txt=duration) def draw(self, fig: Figure, ax: Axes, poster_drawing_data: PosterDrawingData) -> None: """Draw the updated drawing data (Must run in the main thread because of matplotlib backend).""" @@ -288,7 +317,8 @@ def draw(self, fig: Figure, ax: Axes, poster_drawing_data: PosterDrawingData) -> poster_drawing_data.img, poster_drawing_data.theme_colors, poster_drawing_data.title_txt, - poster_drawing_data.stats_text) + poster_drawing_data.stats_text, + poster_drawing_data.duration_txt) print(f"Drawing updated (Elevation Map {poster_drawing_data.img.shape[1]}x{poster_drawing_data.img.shape[0]})") @@ -300,12 +330,15 @@ def get_elevation_drawings(layout: VerticalLayout, huts_ids: list[int], draw_start: bool, draw_end: bool, + draw_duration: bool, drawing_style_params: DrawingStyleParams, drawing_size_params: DrawingSizeParams) -> tuple[list[ScatterData], PolyFillData, TextData]: """Create the plot elements for the elevation profile.""" # Elevation Profile h_up_pix = h_pix * (layout.title_relative_h + layout.map_relative_h) h_bot_pix = h_pix * (layout.title_relative_h + layout.map_relative_h + layout.elevation_relative_h) + h_stat_text = h_pix *(layout.title_relative_h + layout.map_relative_h + layout.elevation_relative_h + 1.0)/2.0 + h_duration_text = h_pix * 0.99 elevation_poly_x = np.array(list_cum_d)/list_cum_d[-1]*w_pix @@ -339,11 +372,20 @@ def get_elevation_drawings(layout: VerticalLayout, elevation_poly_y = np.hstack((h_pix, h_bot_pix, elevation_poly_y, h_bot_pix, h_pix)).tolist() elevation_data = PolyFillData(x=elevation_poly_x, y=elevation_poly_y) - stats = TextData(x=0.5 * w_pix, y=0.5 * (h_bot_pix+h_pix), - s="", fontsize=drawing_size_params.stats_fontsize, - fontproperties=drawing_style_params.pretty_font, ha="center") - - return scatter_data, elevation_data, stats + if draw_duration: + duration = TextData(x=0.01 * w_pix, y=h_duration_text, + s="", fontsize=drawing_size_params.stats_fontsize*0.5, + fontproperties=drawing_style_params.pretty_font, ha="left") + stats = TextData(x=0.5 * w_pix, y=h_stat_text, + s="", fontsize=drawing_size_params.stats_fontsize*0.8, + fontproperties=drawing_style_params.pretty_font, ha="center") + else: + duration = None + stats = TextData(x=0.5 * w_pix, y=h_stat_text, + s="", fontsize=drawing_size_params.stats_fontsize*0.8, + fontproperties=drawing_style_params.pretty_font, ha="center") + + return scatter_data, elevation_data, stats, duration def rescale_elevation_to_dpi(elevation_map: np.ndarray, paper: PaperSize, target_dpi: float) -> np.ndarray: diff --git a/pretty_gpx/gpx/augmented_gpx_data.py b/pretty_gpx/gpx/augmented_gpx_data.py index d04e27d..203de17 100644 --- a/pretty_gpx/gpx/augmented_gpx_data.py +++ b/pretty_gpx/gpx/augmented_gpx_data.py @@ -11,6 +11,7 @@ from pretty_gpx.gpx.gpx_bounds import GpxBounds from pretty_gpx.gpx.gpx_track import GpxTrack from pretty_gpx.gpx.gpx_track import local_m_to_deg +from pretty_gpx.utils.utils import format_timedelta DEBUG_OVERPASS_QUERY = False @@ -58,6 +59,8 @@ def uphill_m(self) -> float: """Total climb in m.""" return self.track.list_cumul_ele[-1] + total_duration: str | None + @staticmethod def from_path(list_gpx_path: str | bytes | list[str] | list[bytes], strict_ths_m: float = 50, @@ -68,7 +71,15 @@ def from_path(list_gpx_path: str | bytes | list[str] | list[bytes], if isinstance(list_gpx_path[0], str): list_gpx_path = natsorted(list_gpx_path) - gpx_track, huts_ids, huts_names = find_huts_between_daily_tracks(list_gpx_path) + gpx_track, duration_l, huts_ids, huts_names = find_huts_between_daily_tracks(list_gpx_path) + + if None in duration_l: + print("WARNING: cannot get the duration, some GPX files have not the time saved") + total_duration_str = None + else: + total_duration = sum(duration_l) + total_duration_str = format_timedelta(total_duration) + print("TOTAL DURATION = ",total_duration,total_duration_str) is_closed = gpx_track.is_closed(loose_ths_m) passes_ids, mountain_passes = get_close_mountain_passes(gpx_track, strict_ths_m) @@ -98,7 +109,8 @@ def from_path(list_gpx_path: str | bytes | list[str] | list[bytes], mountain_passes=mountain_passes, passes_ids=passes_ids, huts=huts_names, - hut_ids=huts_ids) + hut_ids=huts_ids, + total_duration=total_duration_str) def overpass_query(query_elements: list[str], gpx_track: GpxTrack) -> overpy.Result: @@ -219,7 +231,8 @@ def find_huts_between_daily_tracks(list_gpx_path: list[str] | list[bytes], list[MountainHut]]: """Merge ordered GPX tracks into a single one and find huts between them.""" # Load GPX tracks - list_gpx_track = [GpxTrack.load(path) for path in list_gpx_path] + list_gpx_track, list_duration = zip(*[GpxTrack.load(path) for path in list_gpx_path]) + if len(list_gpx_track) == 1: return list_gpx_track[0], [], [] @@ -284,4 +297,4 @@ def find_huts_between_daily_tracks(list_gpx_path: list[str] | list[bytes], huts.append(MountainHut(name=None, lat=hut_lat, lon=hut_lon)) print(f"Huts: {', '.join([h.name if h.name is not None else '?' for h in huts if h.name])}") - return full_gpx_track, huts_ids, huts + return full_gpx_track, list_duration, huts_ids, huts diff --git a/pretty_gpx/gpx/gpx_track.py b/pretty_gpx/gpx/gpx_track.py index 8c289c7..052b926 100644 --- a/pretty_gpx/gpx/gpx_track.py +++ b/pretty_gpx/gpx/gpx_track.py @@ -95,7 +95,7 @@ def load(gpx_path: str | bytes) -> tuple['GpxTrack', float, float]: # possible to reduce the threshold assert abs(all_segment_cumul_ele - gpx.get_uphill_downhill().uphill)/all_segment_cumul_ele < 0.2,f"Total climb is not coherent between point to point calculation ({all_segment_cumul_ele:.0f}) and total sum {gpx.get_uphill_downhill().uphill:.0f}" - return gpx_track + return gpx_track, gpx.get_duration() def is_closed(self, dist_m: float) -> bool: """Estimate if the track is closed.""" diff --git a/pretty_gpx/main.py b/pretty_gpx/main.py index 7bb8991..dcad834 100644 --- a/pretty_gpx/main.py +++ b/pretty_gpx/main.py @@ -59,6 +59,12 @@ def change_paper_size(gpx_data: AugmentedGpxData, new_paper_size: PaperSize) -> return PosterImageCaches.from_gpx_data(gpx_data, new_paper_size) +def change_duration_str(gpx_data: AugmentedGpxData, paper_size: PaperSize, new_duration_str: str) -> PosterImageCaches: + return PosterImageCaches.update_duration_str(gpx_data, + paper_size, + new_duration_str) + + async def on_click_load_example() -> None: contents = [os.path.join(HIKING_DIR, "vanoise1.gpx"), os.path.join(HIKING_DIR, "vanoise2.gpx"), @@ -74,6 +80,20 @@ async def on_paper_size_change() -> None: cache = await run.cpu_bound(change_paper_size, copy.deepcopy(cache.gpx_data), PAPER_SIZES[new_paper_size_name]) await on_click_update()() +async def on_duration_change() -> None: + duration_value = "" + if duration_switch.value: + duration_value = override_duration.value + print("DURATION VALUE ",duration_value) + new_paper_size_name = safe(paper_size_mode_toggle.value) + with UiModal(f"Adding/deleting duration"): + global cache + cache = await run.cpu_bound(change_duration_str, + copy.deepcopy(cache.gpx_data), + PAPER_SIZES[new_paper_size_name], + duration_value) + await on_click_update()() + with ui.row(): with ui.card().classes(f'w-[{W_DISPLAY_PIX}px]').style('box-shadow: 0 0 20px 10px rgba(0, 0, 0, 0.2);'): with ui.pyplot(close=False) as plot: @@ -112,7 +132,8 @@ def _update(c: PosterImageCache) -> PosterDrawingData: theme_colors=color_themes[safe(theme_toggle.value)], title_txt=title_button.value, uphill_m=uphill_button.value, - dist_km=dist_km_button.value) + dist_km=dist_km_button.value, + duration=override_duration.value) def _update_done_callback(c: PosterImageCache, poster_drawing_data: PosterDrawingData) -> None: with plot: @@ -143,6 +164,15 @@ def on_click_update() -> Callable: with ui.input(label='Distance (km)', value="").on('keydown.enter', on_click_update()) as dist_km_button: ui.tooltip("Press Enter to override distance from GPX") + duration_switch = ui.switch(text='Duration', value=False) + duration_switch.tooltip("Toggle to show the duration") + + override_duration = ui.input(label='Duration', value="") + override_duration.tooltip("Press the update button to override the duration from GPX") + + button = ui.button('Update duration', on_click=on_duration_change) + + azimuth_toggle = ui.toggle(list(AZIMUTHS.keys()), value=list(AZIMUTHS.keys())[0], on_change=on_click_update()) diff --git a/pretty_gpx/utils/utils.py b/pretty_gpx/utils/utils.py index 66a87ea..43cbc4b 100644 --- a/pretty_gpx/utils/utils.py +++ b/pretty_gpx/utils/utils.py @@ -3,6 +3,7 @@ import os from typing import TypeVar +import datetime T = TypeVar('T') @@ -35,3 +36,31 @@ def suffix_filename(filepath: str, suffix: str) -> str: """ base, ext = os.path.splitext(filepath) return f"{base}{suffix}{ext}" + +def format_timedelta(total_seconds: float | int) -> str: + """Format the timedelta to a string""" + + # Extract days, hours, minutes, and seconds + days, remainder = divmod(total_seconds, 86400) # 86400 seconds in a day + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + + parts = [] + if days > 0: + parts.append(f"{days}d-") + if hours > 0 or days > 0: # Show hours if days are shown or hours are non-zero + if days > 0: + parts.append(f"{hours:2.0f}h") + else: + parts.append(f"{hours}h") + if minutes > 0 or hours > 0 or days > 0: # Show minutes if hours or days are shown or minutes are non-zero + if days > 0 or hours > 0: + parts.append(f"{minutes:2.0f}") + else: + parts.append(f"{minutes}min") + if seconds > 0 or minutes > 0 or hours > 0 or days > 0: # Show seconds if any higher units are shown or seconds are non-zero + if not(days > 0 or hours > 0): + parts.append(f"{seconds:2.0f}") + + # Join the parts with commas + return ''.join(parts) if parts else '0s'