fffiloni commited on
Commit
9e6ae60
·
verified ·
1 Parent(s): ef90405

core: refactor map layers with optional forests, waterways and coastline

Browse files
Files changed (1) hide show
  1. create_map_poster.py +354 -55
create_map_poster.py CHANGED
@@ -29,6 +29,184 @@ OVERFETCH = 1.20
29
  # Buildings are only practical at small scale
30
  BUILDINGS_MAX_DIST = 5000 # Town threshold
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def finalize_map_view(ax_map, target_ratio: float, *, pad: float):
33
  """
34
  Vue finale sans distorsion et sans letterboxing (bandes blanches).
@@ -760,8 +938,7 @@ def drop_nodes_missing_xy(G, label="graph"):
760
  print(f"⚠ {label}: dropping {len(bad)} nodes missing x/y (pre-plot safety)")
761
  G.remove_nodes_from(bad)
762
  return G
763
-
764
- from shapely.geometry import LineString, MultiLineString
765
 
766
  def plot_lines_dashed(ax, gdf, color, lw, alpha, zorder, dash=(0, (3, 3))):
767
  if gdf is None or getattr(gdf, "empty", True):
@@ -836,6 +1013,9 @@ def create_poster(
836
  enable_buildings=False,
837
  enable_railroads=False,
838
  enable_margins=True,
 
 
 
839
  ):
840
  print(f"\nGenerating map for {city}, {country}...")
841
 
@@ -845,7 +1025,19 @@ def create_poster(
845
  print(f"⚠ Buildings layer disabled (distance {dist}m > {BUILDINGS_MAX_DIST}m).")
846
  enable_buildings = False
847
 
848
- steps = 4 + (1 if enable_buildings else 0) + (1 if enable_railroads else 0)
 
 
 
 
 
 
 
 
 
 
 
 
849
 
850
  with tqdm(
851
  total=steps,
@@ -854,19 +1046,24 @@ def create_poster(
854
  bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}",
855
  ) as pbar:
856
 
 
857
  pbar.set_description("Downloading street network")
858
  G = ox.graph_from_point(point, dist=dist_fetch, dist_type="bbox", network_type="all")
859
  pbar.update(1)
860
- time.sleep(0.5)
861
-
862
- pbar.set_description("Downloading coastline")
863
- try:
864
- coast = ox.features_from_point(point, tags={"natural": "coastline"}, dist=dist_fetch)
865
- except Exception:
866
- coast = None
867
- pbar.update(1)
868
  time.sleep(0.2)
869
 
 
 
 
 
 
 
 
 
 
 
 
 
870
  pbar.set_description("Downloading water features")
871
  try:
872
  water = ox.features_from_point(
@@ -875,18 +1072,59 @@ def create_poster(
875
  except Exception:
876
  water = None
877
  pbar.update(1)
878
- time.sleep(0.3)
879
 
880
- pbar.set_description("Downloading parks/green spaces")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
881
  try:
882
- parks = ox.features_from_point(
883
- point, tags={"leisure": "park", "landuse": "grass"}, dist=dist_fetch
 
 
 
 
 
884
  )
885
  except Exception:
886
- parks = None
887
  pbar.update(1)
888
- time.sleep(0.3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889
 
 
890
  buildings = None
891
  if enable_buildings:
892
  pbar.set_description("Downloading buildings")
@@ -895,7 +1133,9 @@ def create_poster(
895
  except Exception:
896
  buildings = None
897
  pbar.update(1)
 
898
 
 
899
  rails = None
900
  if enable_railroads:
901
  pbar.set_description("Downloading railroads")
@@ -904,17 +1144,17 @@ def create_poster(
904
  except Exception:
905
  rails = None
906
  pbar.update(1)
907
- time.sleep(0.2)
908
 
909
  print("✓ All data downloaded successfully!")
910
 
911
- # ---- Project to metric CRS (stabilizes aspect + enables correct cropping) ----
912
  G = ox.project_graph(G)
913
  G = ensure_graph_xy(G)
914
  G = drop_nodes_missing_xy(G, label="projected G")
915
  crs = G.graph.get("crs", None)
916
 
917
- # Build a bbox polygon in projected CRS from the graph nodes
918
  bbox_poly = None
919
  try:
920
  xs = [data["x"] for _, data in G.nodes(data=True)]
@@ -924,6 +1164,7 @@ def create_poster(
924
  except Exception:
925
  bbox_poly = None
926
 
 
927
  if crs is not None:
928
  try:
929
  if water is not None and hasattr(water, "to_crs") and not water.empty:
@@ -931,8 +1172,18 @@ def create_poster(
931
  except Exception:
932
  pass
933
  try:
934
- if parks is not None and hasattr(parks, "to_crs") and not parks.empty:
935
- parks = parks.to_crs(crs)
 
 
 
 
 
 
 
 
 
 
936
  except Exception:
937
  pass
938
  try:
@@ -945,11 +1196,20 @@ def create_poster(
945
  rails = rails.to_crs(crs)
946
  except Exception:
947
  pass
 
 
 
 
 
948
 
949
- # Remove point artifacts (GeoPandas default blue markers)
950
  buildings = _clean_polygons(buildings)
951
  water = _clean_polygons(water)
952
- parks = _clean_polygons(parks)
 
 
 
 
953
 
954
  if buildings is not None and not buildings.empty:
955
  try:
@@ -957,8 +1217,6 @@ def create_poster(
957
  except Exception:
958
  pass
959
 
960
- rails = _clean_lines(rails)
961
-
962
  # Optional rail noise filters
963
  if rails is not None and not getattr(rails, "empty", True):
964
  if "service" in rails.columns:
@@ -992,11 +1250,11 @@ def create_poster(
992
  ax_map = fig.add_axes([0, 0, 1, 1])
993
  ax_map.set_axis_off()
994
 
995
- # ---- Coastline-aware ocean fill ----
996
  land_poly = None
997
  ocean_poly = None
998
 
999
- if bbox_poly is not None:
1000
  land_poly = coastline_land_polygon(coast, point, crs, bbox_poly)
1001
  if land_poly is not None:
1002
  ocean_poly = bbox_poly.difference(land_poly)
@@ -1015,29 +1273,50 @@ def create_poster(
1015
  ocean_poly = None
1016
  land_poly = None
1017
 
 
 
1018
  # Fallback background
1019
- ax_map.set_facecolor(THEME["bg"] if ocean_poly is None else THEME["water"])
1020
 
1021
  ax_page = fig.add_axes([0, 0, 1, 1], facecolor="none")
1022
  ax_page.set_axis_off()
1023
 
1024
- # ---- Layer 1: polygons ----
1025
  if water is not None and not getattr(water, "empty", True):
1026
- water.plot(ax=ax_map, facecolor=THEME["water"], edgecolor="none", zorder=1)
1027
- if parks is not None and not getattr(parks, "empty", True):
1028
- parks.plot(ax=ax_map, facecolor=THEME["parks"], edgecolor="none", zorder=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1029
 
1030
- # ---- Layer 1.5: buildings ----
1031
  if enable_buildings and buildings is not None and not getattr(buildings, "empty", True):
1032
  face, alpha = buildings_style_from_text(THEME)
1033
- buildings.plot(ax=ax_map, facecolor=face, edgecolor="none", alpha=alpha, zorder=3)
1034
 
1035
- # ---- Layer 2: roads ----
1036
  print("Applying road hierarchy colors...")
1037
  edge_colors = get_edge_colors_by_type(G)
1038
  edge_widths = get_edge_widths_by_type(G)
1039
 
1040
- bgcolor_roads = THEME["water"] if ocean_poly is not None else THEME["bg"]
1041
 
1042
  ox.plot_graph(
1043
  G,
@@ -1054,7 +1333,13 @@ def create_poster(
1054
  close=False,
1055
  )
1056
 
1057
- # ---- Layer 2.5: railroads ----
 
 
 
 
 
 
1058
  if enable_railroads and rails is not None and not getattr(rails, "empty", True):
1059
  rail_color = THEME.get("rail_color", THEME.get("text", "#000000"))
1060
  rail_width = float(THEME.get("rail_width", 0.6))
@@ -1069,21 +1354,18 @@ def create_poster(
1069
  dash=(0, (3, 3)),
1070
  )
1071
 
1072
- # ---- Hard clamp view to bbox (kills OSMnx/GeoPandas autoscale padding) ----
1073
  if bbox_poly is not None:
1074
  minx, miny, maxx, maxy = bbox_poly.bounds
1075
  ax_map.set_xlim(minx, maxx)
1076
  ax_map.set_ylim(miny, maxy)
1077
  ax_map.margins(0)
1078
-
1079
- # ---- Cropping + edge treatment (OPTIONAL) ----
1080
- # Règle: on garde toujours un rendu sans distorsion.
1081
- # - margins ON: inland => pad + fades ; coastal => pad=0 + frame
1082
- # - margins OFF: pas de fades, pas de frame ; pad=0
1083
-
1084
  if enable_margins:
1085
- if ocean_poly is None:
1086
- # inland: pad + fades
1087
  finalize_map_view(ax_map, target_ratio, pad=MAP_PAD)
1088
 
1089
  if orientation == "portrait":
@@ -1092,16 +1374,12 @@ def create_poster(
1092
  elif orientation == "landscape":
1093
  create_gradient_fade(ax_map, THEME["gradient_color"], "left", thickness=fade_lr)
1094
  create_gradient_fade(ax_map, THEME["gradient_color"], "right", thickness=fade_lr)
1095
-
1096
  else:
1097
- # coastal: pas de pad + frame uniforme
1098
  finalize_map_view(ax_map, target_ratio, pad=0.0)
1099
 
1100
  frame_t = margin_thickness_from_layout(THEME)
1101
  add_margin_frame(ax_page, color=THEME["bg"], thickness=frame_t, zorder=10)
1102
-
1103
  else:
1104
- # no-margins: pas de fades, pas de frame, mais ratio OK et pas de squizz
1105
  finalize_map_view(ax_map, target_ratio, pad=0.0)
1106
 
1107
  if THEME.get("_text_fade", False):
@@ -1114,16 +1392,16 @@ def create_poster(
1114
  zorder=10,
1115
  )
1116
 
 
1117
  city_label = city or "Custom"
1118
-
1119
  lat, lon = point
1120
  coords = f"{lat:.4f}° N / {lon:.4f}° E" if lat >= 0 else f"{abs(lat):.4f}° S / {lon:.4f}° E"
1121
  if lon < 0:
1122
  coords = coords.replace("E", "W")
1123
 
1124
  text_offset = 0.0
1125
- if enable_margins and (ocean_poly is not None):
1126
- text_offset = frame_t # même valeur que la marge du bas
1127
 
1128
  layout_bottom_text(
1129
  ax_page=ax_page,
@@ -1133,7 +1411,7 @@ def create_poster(
1133
  theme=THEME,
1134
  fonts=FONTS,
1135
  zorder=11,
1136
- bottom_offset=text_offset, # <-- NEW
1137
  )
1138
 
1139
  dpi = int(THEME.get("_dpi", DEFAULT_DPI))
@@ -1260,6 +1538,24 @@ Examples:
1260
  help='Enable railroads layer (OSM railway=rail).'
1261
  )
1262
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1263
  args = parser.parse_args()
1264
 
1265
  if len(os.sys.argv) == 1:
@@ -1316,6 +1612,9 @@ Examples:
1316
  enable_buildings=bool(args.buildings),
1317
  enable_railroads=bool(args.railroads),
1318
  enable_margins=(not bool(args.no_margins)),
 
 
 
1319
  )
1320
 
1321
  print("\n" + "=" * 50)
 
29
  # Buildings are only practical at small scale
30
  BUILDINGS_MAX_DIST = 5000 # Town threshold
31
 
32
+ import re
33
+ from shapely.geometry import LineString, MultiLineString
34
+
35
+
36
+ def first_tag_value(v):
37
+ """OSM tags can be scalars or lists. Always return a single scalar."""
38
+ if isinstance(v, list):
39
+ return v[0] if v else None
40
+ return v
41
+
42
+
43
+ def _safe_float(x):
44
+ try:
45
+ if x is None:
46
+ return None
47
+ if isinstance(x, (int, float)):
48
+ return float(x)
49
+ # Handle strings like "3", "3.5", "3 m", "3,5"
50
+ s = str(x).strip().lower().replace(",", ".")
51
+ m = re.search(r"[-+]?\d*\.?\d+", s)
52
+ return float(m.group(0)) if m else None
53
+ except Exception:
54
+ return None
55
+
56
+
57
+ def get_waterway_widths_by_type(waterways_gdf, theme=None):
58
+ """
59
+ Return a list of linewidths (floats) aligned with waterways_gdf rows.
60
+ If a numeric 'width' tag exists in OSM data, it is used as a hint.
61
+ Otherwise uses a simple hierarchy by waterway type.
62
+
63
+ Notes:
64
+ - OSM 'width' is in meters (often missing / inconsistent). We map it to a
65
+ plotting linewidth with a gentle clamp.
66
+ - 'riverbank' is a polygon, so it won't normally be in waterways_gdf (lines).
67
+ """
68
+ if waterways_gdf is None or getattr(waterways_gdf, "empty", True):
69
+ return []
70
+
71
+ # Base widths in "plot linewidth units"
72
+ base = {
73
+ "river": 1.00,
74
+ "canal": 0.85,
75
+ "stream": 0.55,
76
+ "brook": 0.45,
77
+ "tidal_channel": 0.65,
78
+ "ditch": 0.40,
79
+ "drain": 0.38,
80
+ }
81
+ default_w = 0.45
82
+
83
+ # Allow theme overrides if you want later
84
+ # e.g. theme["waterway_river_width"] etc.
85
+ theme = theme or {}
86
+ default_w = float(theme.get("waterway_default_width", default_w))
87
+ base["river"] = float(theme.get("waterway_river_width", base["river"]))
88
+ base["canal"] = float(theme.get("waterway_canal_width", base["canal"]))
89
+ base["stream"] = float(theme.get("waterway_stream_width", base["stream"]))
90
+ base["brook"] = float(theme.get("waterway_brook_width", base["brook"]))
91
+ base["ditch"] = float(theme.get("waterway_ditch_width", base["ditch"]))
92
+ base["drain"] = float(theme.get("waterway_drain_width", base["drain"]))
93
+ base["tidal_channel"] = float(theme.get("waterway_tidal_width", base["tidal_channel"]))
94
+
95
+ widths = []
96
+
97
+ # Pre-check columns
98
+ has_type = "waterway" in waterways_gdf.columns
99
+ has_width = "width" in waterways_gdf.columns
100
+
101
+ for _, row in waterways_gdf.iterrows():
102
+ wtype = first_tag_value(row.get("waterway")) if has_type else None
103
+ lw = base.get(wtype, default_w)
104
+
105
+ # Optional: use OSM "width" tag as a hint when present
106
+ if has_width:
107
+ osm_w = _safe_float(first_tag_value(row.get("width")))
108
+ if osm_w is not None:
109
+ # Map meters -> linewidth. Keep it subtle.
110
+ # 0..30m -> 0.4..2.2 (clamped)
111
+ mapped = 0.4 + (min(max(osm_w, 0.0), 30.0) / 30.0) * 1.8
112
+ # Blend with hierarchy so width doesn't explode on bad tags
113
+ lw = 0.6 * lw + 0.4 * mapped
114
+
115
+ # Safety clamp (avoids ugly giant strokes)
116
+ lw = float(np.clip(lw, 0.25, 2.4))
117
+ widths.append(lw)
118
+
119
+ return widths
120
+
121
+
122
+ def get_waterway_alphas_by_type(waterways_gdf, theme=None):
123
+ """
124
+ Optional: alpha hierarchy (bigger waterways slightly more opaque).
125
+ """
126
+ if waterways_gdf is None or getattr(waterways_gdf, "empty", True):
127
+ return []
128
+
129
+ theme = theme or {}
130
+
131
+ base = {
132
+ "river": 0.75,
133
+ "canal": 0.70,
134
+ "stream": 0.60,
135
+ "brook": 0.55,
136
+ "tidal_channel": 0.65,
137
+ "ditch": 0.50,
138
+ "drain": 0.50,
139
+ }
140
+ default_a = float(theme.get("waterway_default_alpha", 0.60))
141
+
142
+ alphas = []
143
+ has_type = "waterway" in waterways_gdf.columns
144
+
145
+ for _, row in waterways_gdf.iterrows():
146
+ wtype = first_tag_value(row.get("waterway")) if has_type else None
147
+ a = float(base.get(wtype, default_a))
148
+ a = float(np.clip(a, 0.25, 0.90))
149
+ alphas.append(a)
150
+
151
+ return alphas
152
+
153
+
154
+ def plot_waterways_by_hierarchy(
155
+ ax,
156
+ waterways_gdf,
157
+ *,
158
+ color,
159
+ theme=None,
160
+ zorder=2.2,
161
+ dash=None,
162
+ ):
163
+ """
164
+ Plot waterways with per-feature linewidth/alpha (like road hierarchy).
165
+
166
+ - dash=None => solid line
167
+ - dash=(0,(3,3)) => dashed etc.
168
+ """
169
+ if waterways_gdf is None or getattr(waterways_gdf, "empty", True):
170
+ return
171
+
172
+ theme = theme or {}
173
+ widths = get_waterway_widths_by_type(waterways_gdf, theme=theme)
174
+ alphas = get_waterway_alphas_by_type(waterways_gdf, theme=theme)
175
+
176
+ # Iterate geometries and plot with their style
177
+ i = 0
178
+ for geom in waterways_gdf.geometry:
179
+ if geom is None or geom.is_empty:
180
+ i += 1
181
+ continue
182
+
183
+ lw = widths[i] if i < len(widths) else 0.45
184
+ alpha = alphas[i] if i < len(alphas) else 0.60
185
+ i += 1
186
+
187
+ if isinstance(geom, LineString):
188
+ xs, ys = geom.xy
189
+ ax.plot(
190
+ xs, ys,
191
+ color=color,
192
+ linewidth=lw,
193
+ alpha=alpha,
194
+ zorder=zorder,
195
+ linestyle=dash if dash is not None else "solid",
196
+ )
197
+
198
+ elif isinstance(geom, MultiLineString):
199
+ for part in geom.geoms:
200
+ xs, ys = part.xy
201
+ ax.plot(
202
+ xs, ys,
203
+ color=color,
204
+ linewidth=lw,
205
+ alpha=alpha,
206
+ zorder=zorder,
207
+ linestyle=dash if dash is not None else "solid",
208
+ )
209
+
210
  def finalize_map_view(ax_map, target_ratio: float, *, pad: float):
211
  """
212
  Vue finale sans distorsion et sans letterboxing (bandes blanches).
 
938
  print(f"⚠ {label}: dropping {len(bad)} nodes missing x/y (pre-plot safety)")
939
  G.remove_nodes_from(bad)
940
  return G
941
+
 
942
 
943
  def plot_lines_dashed(ax, gdf, color, lw, alpha, zorder, dash=(0, (3, 3))):
944
  if gdf is None or getattr(gdf, "empty", True):
 
1013
  enable_buildings=False,
1014
  enable_railroads=False,
1015
  enable_margins=True,
1016
+ enable_waterways=True,
1017
+ enable_forests=True,
1018
+ enable_coastline=True,
1019
  ):
1020
  print(f"\nGenerating map for {city}, {country}...")
1021
 
 
1025
  print(f"⚠ Buildings layer disabled (distance {dist}m > {BUILDINGS_MAX_DIST}m).")
1026
  enable_buildings = False
1027
 
1028
+ # ---- Build dynamic progress steps (avoid downloading unused layers) ----
1029
+ # Always: streets + water polygons + green (parks/grass)
1030
+ steps = 3
1031
+ if enable_coastline:
1032
+ steps += 1
1033
+ if enable_waterways:
1034
+ steps += 1
1035
+ if enable_forests:
1036
+ steps += 1
1037
+ if enable_buildings:
1038
+ steps += 1
1039
+ if enable_railroads:
1040
+ steps += 1
1041
 
1042
  with tqdm(
1043
  total=steps,
 
1046
  bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}",
1047
  ) as pbar:
1048
 
1049
+ # ---- Streets ----
1050
  pbar.set_description("Downloading street network")
1051
  G = ox.graph_from_point(point, dist=dist_fetch, dist_type="bbox", network_type="all")
1052
  pbar.update(1)
 
 
 
 
 
 
 
 
1053
  time.sleep(0.2)
1054
 
1055
+ # ---- Coastline (optional) ----
1056
+ coast = None
1057
+ if enable_coastline:
1058
+ pbar.set_description("Downloading coastline")
1059
+ try:
1060
+ coast = ox.features_from_point(point, tags={"natural": "coastline"}, dist=dist_fetch)
1061
+ except Exception:
1062
+ coast = None
1063
+ pbar.update(1)
1064
+ time.sleep(0.1)
1065
+
1066
+ # ---- Water polygons (always) ----
1067
  pbar.set_description("Downloading water features")
1068
  try:
1069
  water = ox.features_from_point(
 
1072
  except Exception:
1073
  water = None
1074
  pbar.update(1)
1075
+ time.sleep(0.15)
1076
 
1077
+ # ---- Waterways lines (optional) ----
1078
+ waterways = None
1079
+ if enable_waterways:
1080
+ pbar.set_description("Downloading waterways")
1081
+ try:
1082
+ waterways = ox.features_from_point(
1083
+ point,
1084
+ tags={"waterway": ["river", "stream", "brook", "canal", "ditch", "drain", "tidal_channel"]},
1085
+ dist=dist_fetch,
1086
+ )
1087
+ except Exception:
1088
+ waterways = None
1089
+ pbar.update(1)
1090
+ time.sleep(0.1)
1091
+
1092
+ # ---- Green (parks + grass) (always) ----
1093
+ green = None
1094
+ pbar.set_description("Downloading parks / grass")
1095
  try:
1096
+ green = ox.features_from_point(
1097
+ point,
1098
+ tags={
1099
+ "leisure": "park",
1100
+ "landuse": ["grass"],
1101
+ },
1102
+ dist=dist_fetch,
1103
  )
1104
  except Exception:
1105
+ green = None
1106
  pbar.update(1)
1107
+ time.sleep(0.1)
1108
+
1109
+ # ---- Forests (optional, separated) ----
1110
+ forests = None
1111
+ if enable_forests:
1112
+ pbar.set_description("Downloading forests")
1113
+ try:
1114
+ forests = ox.features_from_point(
1115
+ point,
1116
+ tags={
1117
+ "landuse": "forest",
1118
+ "natural": "wood",
1119
+ },
1120
+ dist=dist_fetch,
1121
+ )
1122
+ except Exception:
1123
+ forests = None
1124
+ pbar.update(1)
1125
+ time.sleep(0.1)
1126
 
1127
+ # ---- Buildings (optional) ----
1128
  buildings = None
1129
  if enable_buildings:
1130
  pbar.set_description("Downloading buildings")
 
1133
  except Exception:
1134
  buildings = None
1135
  pbar.update(1)
1136
+ time.sleep(0.1)
1137
 
1138
+ # ---- Railroads (optional) ----
1139
  rails = None
1140
  if enable_railroads:
1141
  pbar.set_description("Downloading railroads")
 
1144
  except Exception:
1145
  rails = None
1146
  pbar.update(1)
1147
+ time.sleep(0.1)
1148
 
1149
  print("✓ All data downloaded successfully!")
1150
 
1151
+ # ---- Project to metric CRS ----
1152
  G = ox.project_graph(G)
1153
  G = ensure_graph_xy(G)
1154
  G = drop_nodes_missing_xy(G, label="projected G")
1155
  crs = G.graph.get("crs", None)
1156
 
1157
+ # Build bbox polygon in projected CRS from graph nodes
1158
  bbox_poly = None
1159
  try:
1160
  xs = [data["x"] for _, data in G.nodes(data=True)]
 
1164
  except Exception:
1165
  bbox_poly = None
1166
 
1167
+ # Reproject layers to graph CRS
1168
  if crs is not None:
1169
  try:
1170
  if water is not None and hasattr(water, "to_crs") and not water.empty:
 
1172
  except Exception:
1173
  pass
1174
  try:
1175
+ if waterways is not None and hasattr(waterways, "to_crs") and not waterways.empty:
1176
+ waterways = waterways.to_crs(crs)
1177
+ except Exception:
1178
+ pass
1179
+ try:
1180
+ if green is not None and hasattr(green, "to_crs") and not green.empty:
1181
+ green = green.to_crs(crs)
1182
+ except Exception:
1183
+ pass
1184
+ try:
1185
+ if forests is not None and hasattr(forests, "to_crs") and not forests.empty:
1186
+ forests = forests.to_crs(crs)
1187
  except Exception:
1188
  pass
1189
  try:
 
1196
  rails = rails.to_crs(crs)
1197
  except Exception:
1198
  pass
1199
+ try:
1200
+ if coast is not None and hasattr(coast, "to_crs") and crs is not None and not coast.empty:
1201
+ coast = coast.to_crs(crs)
1202
+ except Exception:
1203
+ pass
1204
 
1205
+ # Clean geometries
1206
  buildings = _clean_polygons(buildings)
1207
  water = _clean_polygons(water)
1208
+ green = _clean_polygons(green)
1209
+ forests = _clean_polygons(forests)
1210
+ waterways = _clean_lines(waterways)
1211
+ rails = _clean_lines(rails)
1212
+ coast = _clean_lines(coast)
1213
 
1214
  if buildings is not None and not buildings.empty:
1215
  try:
 
1217
  except Exception:
1218
  pass
1219
 
 
 
1220
  # Optional rail noise filters
1221
  if rails is not None and not getattr(rails, "empty", True):
1222
  if "service" in rails.columns:
 
1250
  ax_map = fig.add_axes([0, 0, 1, 1])
1251
  ax_map.set_axis_off()
1252
 
1253
+ # ---- Coastline-aware ocean fill (optional) ----
1254
  land_poly = None
1255
  ocean_poly = None
1256
 
1257
+ if enable_coastline and bbox_poly is not None and coast is not None and not getattr(coast, "empty", True):
1258
  land_poly = coastline_land_polygon(coast, point, crs, bbox_poly)
1259
  if land_poly is not None:
1260
  ocean_poly = bbox_poly.difference(land_poly)
 
1273
  ocean_poly = None
1274
  land_poly = None
1275
 
1276
+ is_coastal = (ocean_poly is not None)
1277
+
1278
  # Fallback background
1279
+ ax_map.set_facecolor(THEME["bg"] if not is_coastal else THEME["water"])
1280
 
1281
  ax_page = fig.add_axes([0, 0, 1, 1], facecolor="none")
1282
  ax_page.set_axis_off()
1283
 
1284
+ # ---- Layer 1: polygons (bottom) ----
1285
  if water is not None and not getattr(water, "empty", True):
1286
+ water.plot(ax=ax_map, facecolor=THEME["water"], edgecolor="none", zorder=0.6)
1287
+
1288
+ if green is not None and not getattr(green, "empty", True):
1289
+ green.plot(ax=ax_map, facecolor=THEME["parks"], edgecolor="none", zorder=0.7)
1290
+
1291
+ # Forests (separate optional layer)
1292
+ if enable_forests and forests is not None and not getattr(forests, "empty", True):
1293
+ forest_color = THEME.get("forests", THEME.get("parks", "#F0F0F0"))
1294
+ forest_alpha = float(THEME.get("forests_alpha", 1.0))
1295
+ forest_alpha = max(0.05, min(1.0, forest_alpha))
1296
+ forests.plot(ax=ax_map, facecolor=forest_color, edgecolor="none", alpha=forest_alpha, zorder=0.75)
1297
+
1298
+ # ---- Waterways (optional) ----
1299
+ if enable_waterways and waterways is not None and not getattr(waterways, "empty", True):
1300
+ plot_waterways_by_hierarchy(
1301
+ ax_map,
1302
+ waterways,
1303
+ color=THEME["water"],
1304
+ theme=THEME,
1305
+ zorder=2.2,
1306
+ dash=None,
1307
+ )
1308
 
1309
+ # ---- Buildings ----
1310
  if enable_buildings and buildings is not None and not getattr(buildings, "empty", True):
1311
  face, alpha = buildings_style_from_text(THEME)
1312
+ buildings.plot(ax=ax_map, facecolor=face, edgecolor="none", alpha=alpha, zorder=3.0)
1313
 
1314
+ # ---- Roads ----
1315
  print("Applying road hierarchy colors...")
1316
  edge_colors = get_edge_colors_by_type(G)
1317
  edge_widths = get_edge_widths_by_type(G)
1318
 
1319
+ bgcolor_roads = THEME["water"] if is_coastal else THEME["bg"]
1320
 
1321
  ox.plot_graph(
1322
  G,
 
1333
  close=False,
1334
  )
1335
 
1336
+ # Ensure plotted roads sit above polygons/waterways
1337
+ from matplotlib.collections import LineCollection
1338
+ for coll in ax_map.collections:
1339
+ if isinstance(coll, LineCollection):
1340
+ coll.set_zorder(2.6)
1341
+
1342
+ # ---- Railroads ----
1343
  if enable_railroads and rails is not None and not getattr(rails, "empty", True):
1344
  rail_color = THEME.get("rail_color", THEME.get("text", "#000000"))
1345
  rail_width = float(THEME.get("rail_width", 0.6))
 
1354
  dash=(0, (3, 3)),
1355
  )
1356
 
1357
+ # ---- Hard clamp view to bbox ----
1358
  if bbox_poly is not None:
1359
  minx, miny, maxx, maxy = bbox_poly.bounds
1360
  ax_map.set_xlim(minx, maxx)
1361
  ax_map.set_ylim(miny, maxy)
1362
  ax_map.margins(0)
1363
+
1364
+ # ---- Cropping + edge treatment ----
1365
+ frame_t = 0.0
1366
+
 
 
1367
  if enable_margins:
1368
+ if not is_coastal:
 
1369
  finalize_map_view(ax_map, target_ratio, pad=MAP_PAD)
1370
 
1371
  if orientation == "portrait":
 
1374
  elif orientation == "landscape":
1375
  create_gradient_fade(ax_map, THEME["gradient_color"], "left", thickness=fade_lr)
1376
  create_gradient_fade(ax_map, THEME["gradient_color"], "right", thickness=fade_lr)
 
1377
  else:
 
1378
  finalize_map_view(ax_map, target_ratio, pad=0.0)
1379
 
1380
  frame_t = margin_thickness_from_layout(THEME)
1381
  add_margin_frame(ax_page, color=THEME["bg"], thickness=frame_t, zorder=10)
 
1382
  else:
 
1383
  finalize_map_view(ax_map, target_ratio, pad=0.0)
1384
 
1385
  if THEME.get("_text_fade", False):
 
1392
  zorder=10,
1393
  )
1394
 
1395
+ # ---- Bottom text ----
1396
  city_label = city or "Custom"
 
1397
  lat, lon = point
1398
  coords = f"{lat:.4f}° N / {lon:.4f}° E" if lat >= 0 else f"{abs(lat):.4f}° S / {lon:.4f}° E"
1399
  if lon < 0:
1400
  coords = coords.replace("E", "W")
1401
 
1402
  text_offset = 0.0
1403
+ if enable_margins and is_coastal:
1404
+ text_offset = frame_t
1405
 
1406
  layout_bottom_text(
1407
  ax_page=ax_page,
 
1411
  theme=THEME,
1412
  fonts=FONTS,
1413
  zorder=11,
1414
+ bottom_offset=text_offset,
1415
  )
1416
 
1417
  dpi = int(THEME.get("_dpi", DEFAULT_DPI))
 
1538
  help='Enable railroads layer (OSM railway=rail).'
1539
  )
1540
 
1541
+ parser.add_argument(
1542
+ '--waterways',
1543
+ action='store_true',
1544
+ help='Enable waterways layer (rivers, streams, canals, etc.)'
1545
+ )
1546
+
1547
+ parser.add_argument(
1548
+ '--forests',
1549
+ action='store_true',
1550
+ help='Enable forests layer (wooded areas)'
1551
+ )
1552
+
1553
+ parser.add_argument(
1554
+ '--coastline',
1555
+ action='store_true',
1556
+ help='Enable coastline detection and ocean fill'
1557
+ )
1558
+
1559
  args = parser.parse_args()
1560
 
1561
  if len(os.sys.argv) == 1:
 
1612
  enable_buildings=bool(args.buildings),
1613
  enable_railroads=bool(args.railroads),
1614
  enable_margins=(not bool(args.no_margins)),
1615
+ enable_waterways=bool(args.waterways),
1616
+ enable_forests=bool(args.forests),
1617
+ enable_coastline=bool(args.coastline),
1618
  )
1619
 
1620
  print("\n" + "=" * 50)