diff --git a/pretty_gpx/common/drawing/components/annotated_scatter.py b/pretty_gpx/common/drawing/components/annotated_scatter.py index 004055b..e014851 100644 --- a/pretty_gpx/common/drawing/components/annotated_scatter.py +++ b/pretty_gpx/common/drawing/components/annotated_scatter.py @@ -19,7 +19,6 @@ from pretty_gpx.common.utils.asserts import assert_eq from pretty_gpx.common.utils.asserts import assert_in from pretty_gpx.common.utils.asserts import assert_same_len -from pretty_gpx.common.utils.utils import safe @dataclass @@ -166,13 +165,14 @@ def finalize_text_allocation(paper_size: PaperSize, annot = None if scatter.name is not None: text, line = output.texts_xy[alloc_idx], output.lines_xy[alloc_idx] - text_x, text_y = safe(text) - line_x, line_y = safe(line) - annot = AnnotationArrow(text_s=input.list_text_s[alloc_idx], - text_lat=text_y, - text_lon=text_x, - arrow_begin_lat=line_y[0], - arrow_begin_lon=line_x[0]) + if text is not None and line is not None: + text_x, text_y = text + line_x, line_y = line + annot = AnnotationArrow(text_s=input.list_text_s[alloc_idx], + text_lat=text_y, + text_lon=text_x, + arrow_begin_lat=line_y[0], + arrow_begin_lon=line_x[0]) alloc_idx += 1 res[scatter.category].list_lat.append(scatter.lat) diff --git a/pretty_gpx/common/drawing/utils/text_allocation.py b/pretty_gpx/common/drawing/utils/text_allocation.py index 204ba89..a705715 100644 --- a/pretty_gpx/common/drawing/utils/text_allocation.py +++ b/pretty_gpx/common/drawing/utils/text_allocation.py @@ -40,9 +40,13 @@ class TextAllocationInput: @dataclass(kw_only=True) class TextAllocationOutput: - """Output of textalloc.""" - texts_xy: list[tuple[float, float]] = field(default_factory=list) - lines_xy: list[tuple[tuple[float, float], tuple[float, float]]] = field(default_factory=list) + """Output of textalloc. + + A `None` entry means textalloc couldn't find a non-overlapping spot for that text (e.g. too many + labels crammed in a small area) -- the corresponding label is dropped, its marker is still drawn. + """ + texts_xy: list[tuple[float, float] | None] = field(default_factory=list) + lines_xy: list[tuple[tuple[float, float], tuple[float, float]] | None] = field(default_factory=list) @profile @@ -90,12 +94,19 @@ def allocate_text(input: TextAllocationInput, fontproperties=input.annot_fontproperties) output = TextAllocationOutput() assert_same_len((texts_xyz, lines_xyz, input.list_text_s)) - for text, line in zip(texts_xyz, lines_xyz): - assert text is not None, "Failed to allocate text" + nbr_dropped = 0 + for text, line, text_s in zip(texts_xyz, lines_xyz, input.list_text_s): + if text is None or line is None: + # Too many labels too close together for textalloc to find a non-overlapping spot. + # Drop this one label rather than failing the whole poster (its marker is still drawn). + nbr_dropped += 1 + output.texts_xy.append(None) + output.lines_xy.append(None) + continue + assert_len(text, 3) text_x, text_y, _ = text - assert line is not None assert_len(line, 3) line_x, line_y, _ = line assert_len(line_x, 2) @@ -104,6 +115,10 @@ def allocate_text(input: TextAllocationInput, output.texts_xy.append((text_x, text_y)) output.lines_xy.append((line_x, line_y)) + if nbr_dropped > 0: + logger.warning(f"Could not allocate {nbr_dropped}/{len(input.list_text_s)} text labels " + "(too many labels too close together): dropping them, keeping their markers.") + if DEBUG: __debug_after(paper_size, background_bounds, mid_bounds, input, output) diff --git a/pretty_gpx/common/gpx/gpx_track.py b/pretty_gpx/common/gpx/gpx_track.py index 6645fe7..f14d218 100644 --- a/pretty_gpx/common/gpx/gpx_track.py +++ b/pretty_gpx/common/gpx/gpx_track.py @@ -146,7 +146,10 @@ def append_track_to_gpx_track(gpx_track: GpxTrack, track_points: list[GPXTrackPo gpx_track.list_ele_m.append(point.elevation) if prev_point is not None: - prev_cumul_dist_km += safe(prev_point.distance_3d(point)) * 1e-3 + # Match gpxpy's own `length()` call order (point.distance_3d(previous)): its flat-earth + # approximation uses cos(latitude) of the `self` point only, so swapping the operands + # yields a (tiny but non-zero) different distance and breaks coherence with gpx.length_3d(). + prev_cumul_dist_km += safe(point.distance_3d(prev_point)) * 1e-3 gpx_track.list_cumul_dist_km.append(prev_cumul_dist_km) diff --git a/pretty_gpx/common/utils/profile.py b/pretty_gpx/common/utils/profile.py index f896ee6..2f0b995 100644 --- a/pretty_gpx/common/utils/profile.py +++ b/pretty_gpx/common/utils/profile.py @@ -188,9 +188,13 @@ def profile_parallel(func: Callable[P, R]) -> Callable[P, tuple[R, list[Profilin @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> tuple[R, list[ProfilingEvent]]: Profiling.set_bypass_queue("Parallel") - with Profiling.Scope(get_function_name(func)): - retval = func(*args, **kwargs) - events = Profiling.pop_bypass_queue() + try: + with Profiling.Scope(get_function_name(func)): + retval = func(*args, **kwargs) + finally: + # Always pop the bypass queue, even if `func` raised, so a failed call doesn't + # permanently block subsequent calls reusing the same worker process. + events = Profiling.pop_bypass_queue() return retval, events return wrapper diff --git a/pretty_gpx/rendering_modes/multi_mountain/drawing/multi_mountain_drawer.py b/pretty_gpx/rendering_modes/multi_mountain/drawing/multi_mountain_drawer.py index bf6c794..78b5978 100644 --- a/pretty_gpx/rendering_modes/multi_mountain/drawing/multi_mountain_drawer.py +++ b/pretty_gpx/rendering_modes/multi_mountain/drawing/multi_mountain_drawer.py @@ -18,6 +18,8 @@ from pretty_gpx.common.layout.vertical_layout import VerticalLayoutUnion from pretty_gpx.common.request.overpass_request import OverpassQuery from pretty_gpx.common.utils.profile import profile +from pretty_gpx.rendering_modes.mountain.data.mountain_passes import prepare_download_mountain_passes +from pretty_gpx.rendering_modes.mountain.data.mountain_passes import process_mountain_passes from pretty_gpx.rendering_modes.mountain.drawing.mountain_background import MountainBackground from pretty_gpx.rendering_modes.multi_mountain.data.mountain_huts import prepare_download_mountain_huts from pretty_gpx.rendering_modes.multi_mountain.data.mountain_huts import process_mountain_huts @@ -52,6 +54,7 @@ class MultiMountainDrawer(DrawerMultiTrack): def change_gpx(self, gpx_paths: list[str] | list[bytes], paper: PaperSize) -> None: """Load several GPX file to create a Multi Mountain Poster.""" gpx_track = MultiGpxTrack.load(gpx_paths) + merged_track = gpx_track.merge() ele_ratio = 0.45 bot_ratio, ele_ratio = handle_flat_elevation_profile(gpx_track, self.bot_ratio, ele_ratio) @@ -62,15 +65,17 @@ def change_gpx(self, gpx_paths: list[str] | list[bytes], paper: PaperSize) -> No total_query = OverpassQuery() prepare_download_mountain_huts(total_query, gpx_track) + prepare_download_mountain_passes(total_query, merged_track) total_query.launch_queries() scatter_points = get_start_end_named_points(gpx_track) scatter_points += process_mountain_huts(total_query, gpx_track) + scatter_points += process_mountain_passes(total_query, merged_track) background = MountainBackground.from_union_bounds(layouts.union_bounds) layout = layouts.layouts[paper] background.change_papersize(paper, layout.background_bounds) - ele_profile = ElevationProfile.from_track(layout.bot_bounds, gpx_track.merge(), scatter_points, + ele_profile = ElevationProfile.from_track(layout.bot_bounds, merged_track, scatter_points, ele_ratio=ele_ratio) title = CenteredTitle(bounds=layout.top_bounds) scatter_all = AnnotatedScatterAll.from_scatter(paper, layout.background_bounds, layout.mid_bounds, diff --git a/pretty_gpx/rendering_modes/multi_mountain/drawing/multi_mountain_params.py b/pretty_gpx/rendering_modes/multi_mountain/drawing/multi_mountain_params.py index 57d5521..d74c8ff 100644 --- a/pretty_gpx/rendering_modes/multi_mountain/drawing/multi_mountain_params.py +++ b/pretty_gpx/rendering_modes/multi_mountain/drawing/multi_mountain_params.py @@ -56,6 +56,9 @@ def default() -> "MultiMountainParams": ScatterPointCategory.MOUNTAIN_HUT: ScatterParams(markersize=A4Float(mm=3.5), marker=MarkerType.HOUSE, color="m"), + ScatterPointCategory.MOUNTAIN_PASS: ScatterParams(markersize=A4Float(mm=3.5), + marker=MarkerType.TRIANGLE, + color="m"), ScatterPointCategory.START: ScatterParams(markersize=A4Float(mm=3.5), marker=MarkerType.DISK, color="m"), @@ -66,6 +69,8 @@ def default() -> "MultiMountainParams": annot_params={ ScatterPointCategory.MOUNTAIN_HUT: AnnotatedScatterParams(arrow_linewidth=A4Float(mm=0.5), fontsize=A4Float(mm=3.0)), + ScatterPointCategory.MOUNTAIN_PASS: AnnotatedScatterParams(arrow_linewidth=A4Float(mm=0.5), + fontsize=A4Float(mm=3.0)), ScatterPointCategory.START: AnnotatedScatterParams(arrow_linewidth=A4Float(mm=0.5), fontsize=A4Float(mm=3.0)), ScatterPointCategory.END: AnnotatedScatterParams(arrow_linewidth=A4Float(mm=0.5), @@ -81,6 +86,9 @@ def default() -> "MultiMountainParams": ScatterPointCategory.MOUNTAIN_HUT: ScatterParams(markersize=A4Float(mm=3.5), marker=MarkerType.HOUSE, color="m"), + ScatterPointCategory.MOUNTAIN_PASS: ScatterParams(markersize=A4Float(mm=3.5), + marker=MarkerType.TRIANGLE, + color="m"), ScatterPointCategory.START: ScatterParams(markersize=A4Float(mm=3.5), marker=MarkerType.DISK, color="m"),