Spaces:
Sleeping
Sleeping
core: refactor map layers with optional forests, waterways and coastline
Browse files- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 879 |
|
| 880 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 881 |
try:
|
| 882 |
-
|
| 883 |
-
point,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 884 |
)
|
| 885 |
except Exception:
|
| 886 |
-
|
| 887 |
pbar.update(1)
|
| 888 |
-
time.sleep(0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 908 |
|
| 909 |
print("✓ All data downloaded successfully!")
|
| 910 |
|
| 911 |
-
# ---- Project to metric CRS
|
| 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
|
| 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
|
| 935 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 950 |
buildings = _clean_polygons(buildings)
|
| 951 |
water = _clean_polygons(water)
|
| 952 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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=
|
| 1027 |
-
|
| 1028 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
|
| 1030 |
-
# ----
|
| 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 |
-
# ----
|
| 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
|
| 1041 |
|
| 1042 |
ox.plot_graph(
|
| 1043 |
G,
|
|
@@ -1054,7 +1333,13 @@ def create_poster(
|
|
| 1054 |
close=False,
|
| 1055 |
)
|
| 1056 |
|
| 1057 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
# - margins OFF: pas de fades, pas de frame ; pad=0
|
| 1083 |
-
|
| 1084 |
if enable_margins:
|
| 1085 |
-
if
|
| 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
|
| 1126 |
-
text_offset = frame_t
|
| 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,
|
| 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)
|