rsplot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ """Combined raster + station overlay plotting.
2
+
3
+ Satellite raster as the background colored field, 国控 station observations
4
+ on top as colored dots. Each layer keeps its own colorbar so the two
5
+ quantity scales stay independent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ import cartopy.crs as ccrs
13
+ import cartopy.feature as cfeature
14
+ import matplotlib.pyplot as plt
15
+ import matplotlib.ticker as mticker
16
+ import numpy as np
17
+ from cartopy.feature import ShapelyFeature
18
+ from cartopy.mpl.gridliner import LATITUDE_FORMATTER, LONGITUDE_FORMATTER
19
+
20
+ from rsplot.plotting.colormaps import (
21
+ STATION_CONCENTRATION_CMAP,
22
+ register_rsplot_colormaps,
23
+ )
24
+ from rsplot.plotting.raster import _add_sub_labels, _figsize_for_level
25
+ from rsplot.plotting.station import _build_aqi_cmap, _station_marker_size
26
+ from rsplot.tiles.tianditu import TianDiTuTiles
27
+
28
+ if TYPE_CHECKING:
29
+ from rsplot.geo.boundaries import RegionInfo
30
+ from rsplot.readers.guokong import StationData
31
+
32
+
33
+ def plot_overlay(
34
+ LON: np.ndarray,
35
+ LAT: np.ndarray,
36
+ grid: np.ndarray,
37
+ data: StationData,
38
+ region: RegionInfo,
39
+ *,
40
+ # raster layer
41
+ raster_cmap: str = "YlOrRd",
42
+ raster_vmin: float = 0.0,
43
+ raster_vmax: float = 20.0,
44
+ raster_label: str = "",
45
+ # station layer
46
+ station_threshold: float | None = None,
47
+ station_label: str = "",
48
+ # shared
49
+ dpi: int = 300,
50
+ basemap: bool | None = None,
51
+ tianditu_key: str | None = None,
52
+ title: str | None = None,
53
+ output: str | None = None,
54
+ ) -> None:
55
+ """Draw satellite raster + station scatter on the same axes.
56
+
57
+ The raster uses a reduced alpha so station markers stay legible; when
58
+ a basemap is requested the raster alpha is reduced further. The two
59
+ colorbars share the right side — raster on top, stations below —
60
+ so each quantity keeps its own scale.
61
+ """
62
+ params = region.params
63
+ use_basemap = basemap if basemap is not None else params.basemap
64
+ register_rsplot_colormaps()
65
+
66
+ # --- Figure setup ---
67
+ if use_basemap and tianditu_key:
68
+ tiles = TianDiTuTiles(tk=tianditu_key, layer="img")
69
+ projection = tiles.crs
70
+ else:
71
+ tiles = None
72
+ projection = ccrs.PlateCarree()
73
+
74
+ fig, ax = plt.subplots(
75
+ figsize=_figsize_for_level(region.level),
76
+ dpi=dpi,
77
+ subplot_kw={"projection": projection},
78
+ )
79
+
80
+ # --- Raster layer (beneath everything else) ---
81
+ raster_alpha = 0.45 if use_basemap else 0.7
82
+ im = ax.pcolormesh(
83
+ LON,
84
+ LAT,
85
+ grid,
86
+ transform=ccrs.PlateCarree(),
87
+ cmap=raster_cmap,
88
+ vmin=raster_vmin,
89
+ vmax=raster_vmax,
90
+ shading="auto",
91
+ alpha=raster_alpha,
92
+ zorder=1,
93
+ )
94
+
95
+ # --- Basemap tiles ---
96
+ if tiles is not None:
97
+ ax.add_image(tiles, params.zoom)
98
+
99
+ # --- Detail boundaries (dotted) ---
100
+ if (
101
+ region.detail_boundary_gdf is not None
102
+ and len(region.detail_boundary_gdf) > 0
103
+ ):
104
+ ax.add_feature(
105
+ ShapelyFeature(
106
+ region.detail_boundary_gdf.geometry,
107
+ ccrs.PlateCarree(),
108
+ edgecolor="#aaa",
109
+ facecolor="none",
110
+ linewidth=0.25,
111
+ linestyle=":",
112
+ )
113
+ )
114
+
115
+ # --- Sub-boundaries ---
116
+ if (
117
+ region.sub_boundary_gdf is not None
118
+ and len(region.sub_boundary_gdf) > 0
119
+ ):
120
+ is_county_sub = region.level == "city"
121
+ ax.add_feature(
122
+ ShapelyFeature(
123
+ region.sub_boundary_gdf.geometry,
124
+ ccrs.PlateCarree(),
125
+ edgecolor="#888" if not is_county_sub else "#666",
126
+ facecolor="none",
127
+ linewidth=0.4 if not is_county_sub else 0.6,
128
+ linestyle="-" if not is_county_sub else "--",
129
+ )
130
+ )
131
+ _add_sub_labels(ax, region)
132
+
133
+ # --- Main boundary (red) ---
134
+ if region.main_boundary_gdf is not None:
135
+ ax.add_feature(
136
+ ShapelyFeature(
137
+ region.main_boundary_gdf.geometry,
138
+ ccrs.PlateCarree(),
139
+ edgecolor="red",
140
+ facecolor="none",
141
+ linewidth=params.boundary_linewidth,
142
+ )
143
+ )
144
+
145
+ # --- Station overlay ---
146
+ valid = np.isfinite(data.values)
147
+ lon = data.lon[valid]
148
+ lat = data.lat[valid]
149
+ vals = data.values[valid]
150
+
151
+ sc = None
152
+ if len(vals) > 0:
153
+ marker_size = _station_marker_size(region.level)
154
+
155
+ is_aqi = data.var_name == "AQI"
156
+ if is_aqi:
157
+ cmap, norm = _build_aqi_cmap()
158
+ sc = ax.scatter(
159
+ lon, lat, c=vals, cmap=cmap, norm=norm,
160
+ s=marker_size, edgecolors="#111", linewidths=0.4,
161
+ transform=ccrs.PlateCarree(), zorder=5,
162
+ )
163
+ else:
164
+ p5 = float(np.nanpercentile(vals, 5))
165
+ p95 = float(np.nanpercentile(vals, 95))
166
+ sc = ax.scatter(
167
+ lon, lat, c=vals, cmap=STATION_CONCENTRATION_CMAP,
168
+ vmin=p5, vmax=p95,
169
+ s=marker_size, edgecolors="#111", linewidths=0.4,
170
+ transform=ccrs.PlateCarree(), zorder=5,
171
+ )
172
+
173
+ # Red circle for exceedance
174
+ if station_threshold is not None:
175
+ exceed_mask = vals > station_threshold
176
+ if exceed_mask.any():
177
+ ax.scatter(
178
+ lon[exceed_mask],
179
+ lat[exceed_mask],
180
+ s=marker_size * 3,
181
+ facecolors="none",
182
+ edgecolors="red",
183
+ linewidths=1.5,
184
+ transform=ccrs.PlateCarree(),
185
+ zorder=6,
186
+ )
187
+
188
+ # --- Map decorations ---
189
+ if not use_basemap:
190
+ ax.coastlines(linewidth=0.5, color="#333")
191
+ ax.add_feature(cfeature.OCEAN, facecolor="#e6f2ff", zorder=0)
192
+
193
+ lon_min, lon_max, lat_min, lat_max = region.extent
194
+ pad = params.pad
195
+ ax.set_extent(
196
+ [lon_min - pad, lon_max + pad, lat_min - pad, lat_max + pad],
197
+ crs=ccrs.PlateCarree(),
198
+ )
199
+
200
+ gl = ax.gridlines(
201
+ draw_labels=True, linewidth=0.2, linestyle="--", alpha=0.5
202
+ )
203
+ gl.top_labels = gl.right_labels = False
204
+ gl.xformatter = LONGITUDE_FORMATTER
205
+ gl.yformatter = LATITUDE_FORMATTER
206
+ gl.xlocator = mticker.MultipleLocator(params.grid_step)
207
+ gl.ylocator = mticker.MultipleLocator(params.grid_step)
208
+
209
+ # --- Twin colorbars, hugging the right edge of the map ---
210
+ # Using ax.inset_axes keeps the bars next to the plot instead of
211
+ # letting them float out to the figure corners. Top half = raster,
212
+ # bottom half = station, with a small gap between them.
213
+ # Coords are in axes-fraction: [x0, y0, width, height].
214
+ cax_raster = ax.inset_axes([1.03, 0.54, 0.025, 0.42])
215
+ cb_raster = fig.colorbar(im, cax=cax_raster)
216
+ if raster_label:
217
+ cb_raster.set_label(raster_label, fontsize=9)
218
+ cb_raster.ax.tick_params(labelsize=8)
219
+
220
+ if sc is not None:
221
+ cax_station = ax.inset_axes([1.03, 0.04, 0.025, 0.42])
222
+ cb_station = fig.colorbar(sc, cax=cax_station)
223
+ if station_label:
224
+ cb_station.set_label(station_label, fontsize=9)
225
+ cb_station.ax.tick_params(labelsize=8)
226
+
227
+ # Title
228
+ if title is None:
229
+ title = f"{region.name} · raster + stations"
230
+ ax.set_title(title, fontsize=14)
231
+
232
+ # Reserve room on the right for the two inset colorbars — plain
233
+ # tight_layout() ignores inset_axes and would crop them off.
234
+ fig.tight_layout(rect=[0, 0, 0.9, 1])
235
+
236
+ # --- Output ---
237
+ if output:
238
+ fig.savefig(output, dpi=dpi, bbox_inches="tight")
239
+ plt.close(fig)
240
+ else:
241
+ plt.show()
@@ -0,0 +1,222 @@
1
+ """Core raster plotting pipeline.
2
+
3
+ Unified entry point for all administrative levels. Takes gridded data
4
+ and RegionInfo, produces a publication-quality map.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ import cartopy.crs as ccrs
12
+ import cartopy.feature as cfeature
13
+ import matplotlib.patheffects as pe
14
+ import matplotlib.pyplot as plt
15
+ import matplotlib.ticker as mticker
16
+ import numpy as np
17
+ from cartopy.feature import ShapelyFeature
18
+ from cartopy.mpl.gridliner import LATITUDE_FORMATTER, LONGITUDE_FORMATTER
19
+
20
+ from rsplot.plotting.colormaps import register_rsplot_colormaps
21
+ from rsplot.tiles.tianditu import TianDiTuTiles
22
+
23
+ if TYPE_CHECKING:
24
+ from rsplot.geo.boundaries import RegionInfo
25
+
26
+
27
+ def plot_raster(
28
+ LON: np.ndarray,
29
+ LAT: np.ndarray,
30
+ grid: np.ndarray,
31
+ region: RegionInfo,
32
+ *,
33
+ # overridable params
34
+ dpi: int = 200,
35
+ cmap: str = "turbo",
36
+ vmin: float = 0.0,
37
+ vmax: float = 20.0,
38
+ basemap: bool | None = None,
39
+ tianditu_key: str | None = None,
40
+ title: str | None = None,
41
+ output: str | None = None,
42
+ colorbar_label: str = "NO2 VCD (x10^15 molec/cm2)",
43
+ ) -> None:
44
+ """Draw a raster map for the given region.
45
+
46
+ Parameters
47
+ ----------
48
+ LON, LAT, grid : 2-D arrays from gridding pipeline.
49
+ region : RegionInfo from resolve_region.
50
+ basemap : override region.params.basemap if not None.
51
+ output : save to file path. If None, plt.show().
52
+ """
53
+ params = region.params
54
+ use_basemap = basemap if basemap is not None else params.basemap
55
+ register_rsplot_colormaps()
56
+
57
+ # --- Figure setup ---
58
+ if use_basemap and tianditu_key:
59
+ tiles = TianDiTuTiles(tk=tianditu_key, layer="img")
60
+ projection = tiles.crs
61
+ else:
62
+ tiles = None
63
+ projection = ccrs.PlateCarree()
64
+
65
+ fig, ax = plt.subplots(
66
+ figsize=_figsize_for_level(region.level),
67
+ dpi=dpi,
68
+ subplot_kw={"projection": projection},
69
+ )
70
+
71
+ # --- Raster layer ---
72
+ alpha = 0.55 if use_basemap else 1.0
73
+ im = ax.pcolormesh(
74
+ LON,
75
+ LAT,
76
+ grid,
77
+ transform=ccrs.PlateCarree(),
78
+ cmap=cmap,
79
+ vmin=vmin,
80
+ vmax=vmax,
81
+ shading="auto",
82
+ alpha=alpha,
83
+ )
84
+
85
+ # --- Basemap tiles (beneath boundaries, above raster) ---
86
+ if tiles is not None:
87
+ ax.add_image(tiles, params.zoom)
88
+
89
+ # --- Detail boundaries (counties in province view, dotted) ---
90
+ if (
91
+ region.detail_boundary_gdf is not None
92
+ and len(region.detail_boundary_gdf) > 0
93
+ ):
94
+ ax.add_feature(
95
+ ShapelyFeature(
96
+ region.detail_boundary_gdf.geometry,
97
+ ccrs.PlateCarree(),
98
+ edgecolor="#aaa",
99
+ facecolor="none",
100
+ linewidth=0.25,
101
+ linestyle=":",
102
+ )
103
+ )
104
+
105
+ # --- Sub-boundaries (grey) ---
106
+ if (
107
+ region.sub_boundary_gdf is not None
108
+ and len(region.sub_boundary_gdf) > 0
109
+ ):
110
+ is_county_sub = region.level == "city"
111
+ ax.add_feature(
112
+ ShapelyFeature(
113
+ region.sub_boundary_gdf.geometry,
114
+ ccrs.PlateCarree(),
115
+ edgecolor="#888" if not is_county_sub else "#666",
116
+ facecolor="none",
117
+ linewidth=0.4 if not is_county_sub else 0.6,
118
+ linestyle="-" if not is_county_sub else "--",
119
+ )
120
+ )
121
+ # Sub-boundary labels
122
+ _add_sub_labels(ax, region)
123
+
124
+ # --- Main boundary (red) ---
125
+ if region.main_boundary_gdf is not None:
126
+ ax.add_feature(
127
+ ShapelyFeature(
128
+ region.main_boundary_gdf.geometry,
129
+ ccrs.PlateCarree(),
130
+ edgecolor="red",
131
+ facecolor="none",
132
+ linewidth=params.boundary_linewidth,
133
+ )
134
+ )
135
+
136
+ # --- Map decorations ---
137
+ if not use_basemap:
138
+ ax.coastlines(linewidth=0.5, color="#333")
139
+ ax.add_feature(cfeature.OCEAN, facecolor="#e6f2ff", zorder=0)
140
+
141
+ # Set extent with padding
142
+ lon_min, lon_max, lat_min, lat_max = region.extent
143
+ pad = params.pad
144
+ ax.set_extent(
145
+ [lon_min - pad, lon_max + pad, lat_min - pad, lat_max + pad],
146
+ crs=ccrs.PlateCarree(),
147
+ )
148
+
149
+ # Gridlines
150
+ gl = ax.gridlines(
151
+ draw_labels=True, linewidth=0.2, linestyle="--", alpha=0.5
152
+ )
153
+ gl.top_labels = gl.right_labels = False
154
+ gl.xformatter = LONGITUDE_FORMATTER
155
+ gl.yformatter = LATITUDE_FORMATTER
156
+ gl.xlocator = mticker.MultipleLocator(params.grid_step)
157
+ gl.ylocator = mticker.MultipleLocator(params.grid_step)
158
+
159
+ # Colorbar
160
+ shrink = 0.6 if region.level in ("key_region", "province") else 0.7
161
+ plt.colorbar(im, ax=ax, shrink=shrink, pad=0.05).set_label(colorbar_label)
162
+
163
+ # Title
164
+ if title is None:
165
+ title = f"TROPOMI - {region.name}"
166
+ ax.set_title(title, fontsize=14)
167
+
168
+ plt.tight_layout()
169
+
170
+ # --- Output ---
171
+ if output:
172
+ fig.savefig(output, dpi=dpi, bbox_inches="tight")
173
+ plt.close(fig)
174
+ else:
175
+ plt.show()
176
+
177
+
178
+ def _figsize_for_level(level: str) -> tuple[float, float]:
179
+ """Choose figure size based on admin level."""
180
+ return {
181
+ "country": (14, 12),
182
+ "key_region": (12, 10),
183
+ "province": (10, 10),
184
+ "city": (10, 8),
185
+ "county": (8, 7),
186
+ }.get(level, (10, 8))
187
+
188
+
189
+ def _add_sub_labels(ax, region: RegionInfo) -> None:
190
+ """Add name labels for sub-boundary features."""
191
+ gdf = region.sub_boundary_gdf
192
+ if gdf is None or len(gdf) == 0:
193
+ return
194
+
195
+ name_field = region.sub_name_field
196
+ fontsize = region.params.label_fontsize
197
+
198
+ for _, row in gdf.iterrows():
199
+ pt = row.geometry.representative_point()
200
+ # For key_region/province level: only label cities inside the region
201
+ if region.level in (
202
+ "key_region",
203
+ "province",
204
+ ) and not region.geometry.contains(pt):
205
+ continue
206
+
207
+ label = row[name_field]
208
+ # Strip "市" suffix for city-level labels in province/key_region view
209
+ if region.level in ("key_region", "province"):
210
+ label = label.replace("市", "")
211
+
212
+ ax.text(
213
+ pt.x,
214
+ pt.y,
215
+ label,
216
+ transform=ccrs.PlateCarree(),
217
+ fontsize=fontsize,
218
+ ha="center",
219
+ va="center",
220
+ color="#222",
221
+ path_effects=[pe.withStroke(linewidth=2, foreground="white")],
222
+ )
@@ -0,0 +1,225 @@
1
+ """Station scatter plot pipeline.
2
+
3
+ Plots ground monitoring station data as colored dots on a map,
4
+ with red circles for stations exceeding air quality limits.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ import cartopy.crs as ccrs
12
+ import cartopy.feature as cfeature
13
+ import matplotlib.colors as mcolors
14
+ import matplotlib.pyplot as plt
15
+ import matplotlib.ticker as mticker
16
+ import numpy as np
17
+ from cartopy.feature import ShapelyFeature
18
+ from cartopy.mpl.gridliner import LATITUDE_FORMATTER, LONGITUDE_FORMATTER
19
+
20
+ from rsplot.config import AQI_COLORS
21
+ from rsplot.plotting.raster import _add_sub_labels, _figsize_for_level
22
+ from rsplot.tiles.tianditu import TianDiTuTiles
23
+
24
+ if TYPE_CHECKING:
25
+ from rsplot.geo.boundaries import RegionInfo
26
+ from rsplot.readers.guokong import StationData
27
+
28
+
29
+ def _build_aqi_cmap() -> tuple[mcolors.ListedColormap, mcolors.BoundaryNorm]:
30
+ """Build a discrete colormap matching official AQI color bands."""
31
+ bounds = [0] + [b for b, _ in AQI_COLORS]
32
+ colors = [c for _, c in AQI_COLORS]
33
+ cmap = mcolors.ListedColormap(colors)
34
+ norm = mcolors.BoundaryNorm(bounds, cmap.N)
35
+ return cmap, norm
36
+
37
+
38
+ def _station_marker_size(level: str) -> int:
39
+ """Marker area by region level, in points squared."""
40
+ return {
41
+ "country": 18,
42
+ "key_region": 32,
43
+ "province": 46,
44
+ "city": 76,
45
+ "county": 110,
46
+ }.get(level, 46)
47
+
48
+
49
+ def plot_station(
50
+ data: StationData,
51
+ region: RegionInfo,
52
+ *,
53
+ threshold: float | None = None,
54
+ dpi: int = 300,
55
+ basemap: bool | None = None,
56
+ tianditu_key: str | None = None,
57
+ title: str | None = None,
58
+ output: str | None = None,
59
+ colorbar_label: str = "",
60
+ ) -> None:
61
+ """Draw a station scatter map for the given region.
62
+
63
+ Parameters
64
+ ----------
65
+ data : StationData from guokong reader.
66
+ region : RegionInfo from resolve_region.
67
+ threshold : value above which a red exceedance circle is drawn.
68
+ """
69
+ params = region.params
70
+ use_basemap = basemap if basemap is not None else params.basemap
71
+
72
+ # --- Figure setup ---
73
+ if use_basemap and tianditu_key:
74
+ tiles = TianDiTuTiles(tk=tianditu_key, layer="img")
75
+ projection = tiles.crs
76
+ else:
77
+ tiles = None
78
+ projection = ccrs.PlateCarree()
79
+
80
+ fig, ax = plt.subplots(
81
+ figsize=_figsize_for_level(region.level),
82
+ dpi=dpi,
83
+ subplot_kw={"projection": projection},
84
+ )
85
+
86
+ # --- Basemap tiles ---
87
+ if tiles is not None:
88
+ ax.add_image(tiles, params.zoom)
89
+
90
+ # --- Detail boundaries (counties in province view, dotted) ---
91
+ if (
92
+ region.detail_boundary_gdf is not None
93
+ and len(region.detail_boundary_gdf) > 0
94
+ ):
95
+ ax.add_feature(
96
+ ShapelyFeature(
97
+ region.detail_boundary_gdf.geometry,
98
+ ccrs.PlateCarree(),
99
+ edgecolor="#aaa",
100
+ facecolor="none",
101
+ linewidth=0.25,
102
+ linestyle=":",
103
+ )
104
+ )
105
+
106
+ # --- Sub-boundaries (grey) ---
107
+ if (
108
+ region.sub_boundary_gdf is not None
109
+ and len(region.sub_boundary_gdf) > 0
110
+ ):
111
+ is_county_sub = region.level == "city"
112
+ ax.add_feature(
113
+ ShapelyFeature(
114
+ region.sub_boundary_gdf.geometry,
115
+ ccrs.PlateCarree(),
116
+ edgecolor="#888" if not is_county_sub else "#666",
117
+ facecolor="none",
118
+ linewidth=0.4 if not is_county_sub else 0.6,
119
+ linestyle="-" if not is_county_sub else "--",
120
+ )
121
+ )
122
+ _add_sub_labels(ax, region)
123
+
124
+ # --- Main boundary (red) ---
125
+ if region.main_boundary_gdf is not None:
126
+ ax.add_feature(
127
+ ShapelyFeature(
128
+ region.main_boundary_gdf.geometry,
129
+ ccrs.PlateCarree(),
130
+ edgecolor="red",
131
+ facecolor="none",
132
+ linewidth=params.boundary_linewidth,
133
+ )
134
+ )
135
+
136
+ # --- Filter to valid data ---
137
+ valid = np.isfinite(data.values)
138
+ lon = data.lon[valid]
139
+ lat = data.lat[valid]
140
+ vals = data.values[valid]
141
+
142
+ if len(vals) == 0:
143
+ ax.text(
144
+ 0.5, 0.5, "No valid data",
145
+ transform=ax.transAxes, ha="center", fontsize=14,
146
+ )
147
+ else:
148
+ # --- Marker size by level ---
149
+ marker_size = _station_marker_size(region.level)
150
+
151
+ # --- Colormap / norm ---
152
+ is_aqi = data.var_name == "AQI"
153
+ if is_aqi:
154
+ cmap, norm = _build_aqi_cmap()
155
+ sc = ax.scatter(
156
+ lon, lat, c=vals, cmap=cmap, norm=norm,
157
+ s=marker_size, edgecolors="#333", linewidths=0.3,
158
+ transform=ccrs.PlateCarree(), zorder=5,
159
+ )
160
+ else:
161
+ p5 = float(np.nanpercentile(vals, 5))
162
+ p95 = float(np.nanpercentile(vals, 95))
163
+ sc = ax.scatter(
164
+ lon, lat, c=vals, cmap="YlOrRd",
165
+ vmin=p5, vmax=p95,
166
+ s=marker_size, edgecolors="#333", linewidths=0.3,
167
+ transform=ccrs.PlateCarree(), zorder=5,
168
+ )
169
+
170
+ # --- Red circles for exceedance ---
171
+ if threshold is not None:
172
+ exceed_mask = vals > threshold
173
+ if exceed_mask.any():
174
+ ax.scatter(
175
+ lon[exceed_mask],
176
+ lat[exceed_mask],
177
+ s=marker_size * 3,
178
+ facecolors="none",
179
+ edgecolors="red",
180
+ linewidths=1.5,
181
+ transform=ccrs.PlateCarree(),
182
+ zorder=6,
183
+ )
184
+
185
+ # --- Colorbar ---
186
+ shrink = 0.6 if region.level in ("country", "key_region", "province") else 0.7
187
+ cb = plt.colorbar(sc, ax=ax, shrink=shrink, pad=0.05)
188
+ if colorbar_label:
189
+ cb.set_label(colorbar_label)
190
+
191
+ # --- Map decorations ---
192
+ if not use_basemap:
193
+ ax.coastlines(linewidth=0.5, color="#333")
194
+ ax.add_feature(cfeature.OCEAN, facecolor="#e6f2ff", zorder=0)
195
+
196
+ lon_min, lon_max, lat_min, lat_max = region.extent
197
+ pad = params.pad
198
+ ax.set_extent(
199
+ [lon_min - pad, lon_max + pad, lat_min - pad, lat_max + pad],
200
+ crs=ccrs.PlateCarree(),
201
+ )
202
+
203
+ # Gridlines
204
+ gl = ax.gridlines(
205
+ draw_labels=True, linewidth=0.2, linestyle="--", alpha=0.5
206
+ )
207
+ gl.top_labels = gl.right_labels = False
208
+ gl.xformatter = LONGITUDE_FORMATTER
209
+ gl.yformatter = LATITUDE_FORMATTER
210
+ gl.xlocator = mticker.MultipleLocator(params.grid_step)
211
+ gl.ylocator = mticker.MultipleLocator(params.grid_step)
212
+
213
+ # Title
214
+ if title is None:
215
+ title = f"{data.var_name} - {region.name}"
216
+ ax.set_title(title, fontsize=14)
217
+
218
+ plt.tight_layout()
219
+
220
+ # --- Output ---
221
+ if output:
222
+ fig.savefig(output, dpi=dpi, bbox_inches="tight")
223
+ plt.close(fig)
224
+ else:
225
+ plt.show()