import numpy as np
import matplotlib.pyplot as plt
from matplotlib import colors
from mpl_toolkits.basemap import Basemap
import random
import matplotlib.colors as mcolors
#########################################################
#these function below set a nice tick for color bar
def _nice_num(x, round_to_nearest):
# Returns a "nice" number approximately equal to x.
# If round_to_nearest is True, round; else ceil.
if x == 0 or not np.isfinite(x):
return 0.0
expv = np.floor(np.log10(abs(x)))
f = abs(x) / (10**expv) # fraction in [1,10)
if round_to_nearest:
if f < 1.5:
nf = 1.0
elif f < 3.0:
nf = 2.0
elif f < 7.0:
nf = 5.0
else:
nf = 10.0
else:
if f <= 1.0:
nf = 1.0
elif f <= 2.0:
nf = 2.0
elif f <= 5.0:
nf = 5.0
else:
nf = 10.0
return np.sign(x) * nf * (10**expv)
def _pretty_ticks(dmin, dmax, prefer_counts=(5,6), fallback=(4,7)):
"""
Build "nice" ticks covering [dmin, dmax] with about 5–12 ticks.
"""
if not np.isfinite(dmin) or not np.isfinite(dmax):
# Fallback if data are all NaN
return np.array([0, 1])
if dmin == dmax:
# Expand a degenerate range
if dmin == 0:
dmin, dmax = -1, 1
else:
pad = abs(dmin) * 0.1 if dmin != 0 else 1.0
dmin, dmax = dmin - pad, dmax + pad
rng = dmax - dmin
# Try preferred counts first
for n in prefer_counts:
d = _nice_num(rng / (n - 1), round_to_nearest=True)
gmin = np.floor(dmin / d) * d
gmax = np.ceil(dmax / d) * d
ticks = np.arange(gmin, gmax + 0.5 * d, d)
if 4 <= len(ticks) <= 7:
return ticks
# Then try fallbacks
for n in fallback:
d = _nice_num(rng / (n - 1), round_to_nearest=True)
gmin = np.floor(dmin / d) * d
gmax = np.ceil(dmax / d) * d
ticks = np.arange(gmin, gmax + 0.5 * d, d)
if 4 <= len(ticks) <= 7:
return ticks
# Absolute fallback
d = _nice_num(rng / 7, round_to_nearest=True)
gmin = np.floor(dmin / d) * d
gmax = np.ceil(dmax / d) * d
return np.arange(gmin, gmax + 0.5 * d, d)
def _pad_10pct(minv, maxv):
# Interpret “0.9 of min to 1.1 of max”, but handle negatives gracefully:
lo = minv * 0.99 if minv >= 0 else minv * 1.01
hi = maxv * 1.01 if maxv >= 0 else maxv * 0.99
# If min == 0 or max == 0, still get some padding
if minv == 0:
hi_abs = abs(maxv) if maxv != 0 else 1.0
lo = -0.1 * hi_abs
if maxv == 0:
lo_abs = abs(minv) if minv != 0 else 1.0
hi = 0.1 * lo_abs
if not np.isfinite(lo) or not np.isfinite(hi) or lo == hi:
lo, hi = -1.0, 1.0
return min(lo, hi), max(lo, hi)
#########################################################
# these function below set a nice tick for
def _nice_ticks_1d(dmin, dmax, max_ticks=5):
"""
Return a list of 'nice' tick values between dmin and dmax.
The number of ticks will be <= max_ticks (default=5).
"""
if not np.isfinite(dmin) or not np.isfinite(dmax):
return [0]
if dmin == dmax:
# Degenerate case: expand slightly
if dmin == 0:
dmin, dmax = -1, 1
else:
pad = abs(dmin) * 0.1
dmin, dmax = dmin - pad, dmax + pad
rng = dmax - dmin
if rng == 0:
rng = abs(dmin) if dmin != 0 else 1.0
# Initial step size
raw_step = rng / (max_ticks - 1)
# Get a "nice" step
expv = np.floor(np.log10(raw_step))
frac = raw_step / (10**expv)
if frac <= 1:
nice_frac = 1
elif frac <= 2:
nice_frac = 2
elif frac <= 5:
nice_frac = 5
else:
nice_frac = 10
step = nice_frac * 10**expv
# Round range to multiples of step
start = np.floor(dmin / step) * step
end = np.ceil(dmax / step) * step
ticks = np.arange(start, end + 0.5 * step, step)
# If too many ticks, thin them out
if len(ticks) > max_ticks:
stride = int(np.ceil(len(ticks) / max_ticks))
ticks = ticks[::stride]
return ticks.tolist()
#########################################################
[docs]
def map_draw(lon_min, lon_max, lat_min, lat_max, title, lon_data, lat_data, data_draw, path_save, name_save,
data_min=None, data_max=None, custom_coastline = None, layer_name = None, ):
"""
Draw a 2D geospatial field on a Mercator map using ``Basemap`` and save it as a PNG image.
The function plots a rectangular region bounded by the given longitude and latitude
limits, with grid lines and coastlines automatically drawn. The data range is scaled
between the 5th and 95th percentiles (unless manually specified) and padded by 10%
for better visual contrast.
Parameters
----------
lon_min, lon_max : float
Minimum and maximum longitude boundaries of the map.
lat_min, lat_max : float
Minimum and maximum latitude boundaries of the map.
title : str
Title of the map.
lon_data : np.ndarray
2D array of longitudes (same shape as ``data_draw``).
lat_data : np.ndarray
2D array of latitudes (same shape as ``data_draw``).
data_draw : np.ndarray
2D array of scalar values to plot (e.g., temperature, precipitation).
path_save : str
Directory path where the output PNG will be saved.
name_save : str
Base filename (without extension) for the saved image.
data_min, data_max : float, optional
User-specified minimum and maximum color limits.
If ``None``, they are derived from the 5th and 95th percentiles of ``data_draw``.
custom_coastline : str, optional
User-specified path of the custom coastline
If ``None``, they are derived from the default map of Basemap library
layer_name : str, optional
Name of the layer of the custom shapefile to draw
Returns
-------
None
Examples
--------
See :doc:`/examples/example_1`
"""
dlon = lon_max - lon_min
dlat = lat_max - lat_min
dy = np.around(dlat/dlon, 1)
if dy >= 2:
dy = 1.5
elif dy < 0.5:
dy = 0.8
fig = plt.figure(figsize=(7,7*dy))
ax = fig.add_subplot(1,1,1)
ax.set_title('%s' % (title))
map2 = Basemap(projection='merc', llcrnrlon=lon_min, llcrnrlat=lat_min,
urcrnrlon=lon_max, urcrnrlat=lat_max, resolution='i', epsg=4326)
parallels = _nice_ticks_1d(np.nanmin(lat_data), np.nanmax(lat_data)) #horizontal line
meridians = _nice_ticks_1d(np.nanmin(lon_data), np.nanmax(lon_data)) #vertical line
map2.drawparallels(parallels, linewidth=0.5, dashes=[2,8], labels=[1,0,0,0], fontsize=15, zorder=12)
map2.drawmeridians(meridians, linewidth=0.5, dashes=[2,8], labels=[0,0,0,1], fontsize=15, zorder=12)
if custom_coastline is None:
map2.drawcoastlines(zorder=10)
else:
map2.readshapefile(custom_coastline, layer_name, linewidth=1, color='k', zorder = 20)
# -------- Auto colorbar limits and nice ticks --------
finite_vals = np.asarray(data_draw)[np.isfinite(data_draw)]
if finite_vals.size == 0:
if data_min is None:
data_min = 0.0
if data_max is None:
data_max = 1.0
else:
if data_min is None:
data_min = float(np.nanpercentile(finite_vals, 5))
if data_max is None:
data_max = float(np.nanpercentile(finite_vals, 95))
vmin_pad, vmax_pad = _pad_10pct(data_min, data_max)
# Overwrite in case provided value as input ! Oct 14
if data_min is not None:
vmin_pad = data_min
if data_max is not None:
vmax_pad = data_max
ticks = _pretty_ticks(vmin_pad, vmax_pad)
# Colormap and normalization
color_map = plt.get_cmap('jet')
color_map.set_bad(color='white')
norm = colors.Normalize(vmin=ticks[0], vmax=ticks[-1])
# Grid shift for cell corners (as you had)
dlon_cell = (lon_data[0,1] - lon_data[0,0]) / 2.0
dlat_cell = (lat_data[1,0] - lat_data[0,0]) / 2.0
cm = plt.pcolormesh(lon_data - dlon_cell, lat_data - dlat_cell, data_draw,
norm=norm, cmap='jet')
# Colorbar with nice ticks
cbar_ax = fig.add_axes([0.15, 0.06, 0.7, 0.02])
cb = fig.colorbar(cm, cax=cbar_ax, ticks=ticks, orientation='horizontal')
cb.ax.tick_params(labelsize=20)
# Layout and save
fig.subplots_adjust(bottom=0.15, top=0.9, left=0.15, right=0.90, wspace=0.2, hspace=0.3)
session_id = random.randint(10000, 99999)
plt.savefig('%s/%s_%s.png' % (path_save, name_save, session_id), dpi=250)
plt.close()
#########################################################
[docs]
def map_draw_point(lon_min, lon_max, lat_min, lat_max, title, lon_data, lat_data, data_draw, lat_point, lon_point, path_save=None, name_save=None, show=False):
"""
Draw a 2D geospatial field with annotated point markers on a Mercator map.
Parameters
----------
lon_min, lon_max : float
Minimum and maximum longitude boundaries of the map.
lat_min, lat_max : float
Minimum and maximum latitude boundaries of the map.
title : str
Title of the map.
lon_data : np.ndarray
2D array of longitudes (same shape as ``data_draw``).
lat_data : np.ndarray
2D array of latitudes (same shape as ``data_draw``).
data_draw : np.ndarray
2D array of scalar values to plot.
lat_point : list or np.ndarray
List of latitudes for point markers to display.
lon_point : list or np.ndarray
List of longitudes corresponding to ``lat_point``.
path_save : str, optional
Directory path where the output image will be saved. If omitted, no file is saved.
name_save : str, optional
Base name (without extension) for the output image file. Required when saving.
show : bool, optional
If True, display the figure in a Matplotlib window.
Returns
-------
None
Examples
--------
See :doc:`/examples/example_2`
"""
dlon = lon_max - lon_min
dlat = lat_max - lat_min
dy = np.around(dlat/dlon, 1)
if dy >= 2:
dy = 1.5
elif dy < 0.5:
dy = 0.8
fig = plt.figure(figsize=(7,7*dy))
ax = fig.add_subplot(1,1,1)
ax.set_title('%s' % (title))
map2 = Basemap(projection='merc', llcrnrlon=lon_min, llcrnrlat=lat_min,
urcrnrlon=lon_max, urcrnrlat=lat_max, resolution='i', epsg=4326)
parallels = _nice_ticks_1d(np.nanmin(lat_data), np.nanmax(lat_data)) #horizontal line
meridians = _nice_ticks_1d(np.nanmin(lon_data), np.nanmax(lon_data)) #vertical line
map2.drawparallels(parallels, linewidth=0.5, dashes=[2,8], labels=[1,0,0,0], fontsize=15, zorder=12)
map2.drawmeridians(meridians, linewidth=0.5, dashes=[2,8], labels=[0,0,0,1], fontsize=15, zorder=12)
map2.drawcoastlines(zorder=10)
# -------- Auto colorbar limits and nice ticks --------
finite_vals = np.asarray(data_draw)[np.isfinite(data_draw)]
if finite_vals.size == 0:
data_min, data_max = 0.0, 1.0
else:
data_min, data_max = float(np.nanpercentile(finite_vals, 5)), float(np.nanpercentile(finite_vals, 95))
vmin_pad, vmax_pad = _pad_10pct(data_min, data_max)
ticks = _pretty_ticks(vmin_pad, vmax_pad)
# Colormap and normalization
color_map = plt.get_cmap('jet')
color_map.set_bad(color='white')
norm = colors.Normalize(vmin=ticks[0], vmax=ticks[-1])
# Grid shift for cell corners (as you had)
dlon_cell = (lon_data[0,1] - lon_data[0,0]) / 2.0
dlat_cell = (lat_data[1,0] - lat_data[0,0]) / 2.0
cm = plt.pcolormesh(lon_data - dlon_cell, lat_data - dlat_cell, data_draw,
norm=norm, cmap='jet')
if len(lat_point) >= 2 and len(lon_point) >= 2:
plt.plot(lon_point, lat_point, color="white", linewidth=2, zorder=11)
plt.plot(lon_point, lat_point, color="red", linewidth=1.5, zorder=12)
# plot the point
labels = ["A", "B"]
for i in range(0, len(lat_point)):
plt.scatter(lon_point[i], lat_point[i], s=20, zorder=13, marker="o", edgecolor="white", facecolor='red')
label = labels[i] if i < len(labels) else str(i + 1)
plt.annotate(label, (lon_point[i] + 0.05, lat_point[i]),
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", edgecolor="None", alpha=0.7,), zorder=14)
# Colorbar with nice ticks
cbar_ax = fig.add_axes([0.15, 0.06, 0.7, 0.02])
cb = fig.colorbar(cm, cax=cbar_ax, ticks=ticks, orientation='horizontal')
cb.ax.tick_params(labelsize=20)
# Layout and output
fig.subplots_adjust(bottom=0.15, top=0.9, left=0.15, right=0.90, wspace=0.2, hspace=0.3)
saved = False
if path_save is not None and name_save is not None:
session_id = random.randint(10000, 99999)
plt.savefig('%s/%s_%s.png' % (path_save, name_save, session_id), dpi=250)
saved = True
if show:
plt.show(block=False)
else:
plt.close()
#########################################################
[docs]
def map_draw_box(lon_min, lon_max, lat_min, lat_max, title, lon_data, lat_data, data_draw,
lon_min_box, lon_max_box, lat_min_box, lat_max_box, label, path_save, name_save):
"""
Draw a 2D geospatial field on a Mercator map with labeled rectangular boxes.
Parameters
----------
lon_min, lon_max : float
Minimum and maximum longitude boundaries of the map.
lat_min, lat_max : float
Minimum and maximum latitude boundaries of the map.
title : str
Title displayed at the top of the figure.
lon_data : np.ndarray
2D array of longitudes (same shape as ``data_draw``).
lat_data : np.ndarray
2D array of latitudes (same shape as ``data_draw``).
data_draw : np.ndarray
2D array of scalar values to visualize (e.g., rainfall, temperature).
lon_min_box, lon_max_box : list or np.ndarray
Lists of minimum and maximum longitudes for each box.
lat_min_box, lat_max_box : list or np.ndarray
Lists of minimum and maximum latitudes for each box.
label : list of str
Text labels to annotate each box. Length must match the number of boxes.
path_save : str
Directory path where the output PNG file will be saved.
name_save : str
Base filename (without extension) for the saved image.
Returns
-------
None
"""
dlon = lon_max - lon_min
dlat = lat_max - lat_min
dy = np.around(dlat/dlon, 1)
if dy >= 2:
dy = 1.5
elif dy < 0.5:
dy = 0.8
fig = plt.figure(figsize=(7,7*dy))
ax = fig.add_subplot(1,1,1)
ax.set_title('%s' % (title))
map2 = Basemap(projection='merc', llcrnrlon=lon_min, llcrnrlat=lat_min,
urcrnrlon=lon_max, urcrnrlat=lat_max, resolution='i', epsg=4326)
parallels = _nice_ticks_1d(np.nanmin(lat_data), np.nanmax(lat_data)) #horizontal line
meridians = _nice_ticks_1d(np.nanmin(lon_data), np.nanmax(lon_data)) #vertical line
map2.drawparallels(parallels, linewidth=0.5, dashes=[2,8], labels=[1,0,0,0], fontsize=15, zorder=12)
map2.drawmeridians(meridians, linewidth=0.5, dashes=[2,8], labels=[0,0,0,1], fontsize=15, zorder=12)
map2.drawcoastlines(zorder=10)
# -------- Auto colorbar limits and nice ticks --------
finite_vals = np.asarray(data_draw)[np.isfinite(data_draw)]
if finite_vals.size == 0:
data_min, data_max = 0.0, 1.0
else:
data_min, data_max = float(np.nanpercentile(finite_vals, 5)), float(np.nanpercentile(finite_vals, 95))
vmin_pad, vmax_pad = _pad_10pct(data_min, data_max)
ticks = _pretty_ticks(vmin_pad, vmax_pad)
# Colormap and normalization
color_map = plt.get_cmap('jet')
color_map.set_bad(color='white')
norm = colors.Normalize(vmin=ticks[0], vmax=ticks[-1])
# Grid shift for cell corners (as you had)
dlon_cell = (lon_data[0,1] - lon_data[0,0]) / 2.0
dlat_cell = (lat_data[1,0] - lat_data[0,0]) / 2.0
cm = plt.pcolormesh(lon_data - dlon_cell, lat_data - dlat_cell, data_draw,
norm=norm, cmap='jet')
for i in range(0, len(lon_min_box)):
#Plot the box:
map2.plot([lon_min_box[i], lon_max_box[i], lon_max_box[i], lon_min_box[i], lon_min_box[i]],
[lat_min_box[i], lat_min_box[i], lat_max_box[i], lat_max_box[i], lat_min_box[i]],
color='white', linewidth=3, zorder = 20)
#Plot the box:
map2.plot([lon_min_box[i], lon_max_box[i], lon_max_box[i], lon_min_box[i], lon_min_box[i]],
[lat_min_box[i], lat_min_box[i], lat_max_box[i], lat_max_box[i], lat_min_box[i]],
color='red', linewidth=1.5, zorder = 21)
# Compute box center
lon_center = (lon_min_box[i] + lon_max_box[i]) / 2
lat_center = (lat_min_box[i] + lat_max_box[i]) / 2
# Add text label at center
ax.text(
lon_center, lat_center, label[i],
ha='center', va='center', fontsize=9,
color='black', fontweight='bold',
bbox=dict(facecolor='white', edgecolor='none', alpha=0.8, boxstyle='round,pad=0.3'),
zorder=22
)
# Colorbar with nice ticks
cbar_ax = fig.add_axes([0.15, 0.06, 0.7, 0.02])
cb = fig.colorbar(cm, cax=cbar_ax, ticks=ticks, orientation='horizontal')
cb.ax.tick_params(labelsize=20)
# Layout and save
fig.subplots_adjust(bottom=0.15, top=0.9, left=0.15, right=0.90, wspace=0.2, hspace=0.3)
session_id = random.randint(10000, 99999)
plt.savefig('%s/%s_%s.png' % (path_save, name_save, session_id), dpi=250)
plt.close()
#########################################################
def _truncate_colormap(cmap, minval=0.0, maxval=1.0, n=256):
if isinstance(cmap, str):
cmap = plt.get_cmap(cmap)
new_cmap = mcolors.LinearSegmentedColormap.from_list(
f"trunc({cmap.name},{minval:.2f},{maxval:.2f})",
cmap(np.linspace(minval, maxval, n))
)
return new_cmap
[docs]
def map_draw_uv(
lon_min, lon_max, lat_min, lat_max, title,
lon_data, lat_data, data_u, data_v,
mask_ocean,
path_save, name_save,
quiver_max_n=10, # ~max arrows per axis (auto step so arrows <= quiver_max_n x quiver_max_n)
quiver_scale=None, # None lets Matplotlib choose; or set e.g. 50, 100 for different scaling
data_min=None, data_max=None,
):
"""
Draw a 2D vector field (U–V components) on a Mercator map with colored speed shading.
Parameters
----------
lon_min, lon_max : float
Minimum and maximum longitude boundaries of the map.
lat_min, lat_max : float
Minimum and maximum latitude boundaries of the map.
title : str
Title displayed at the top of the figure.
lon_data : np.ndarray
2D array of longitudes (same shape as ``data_u`` and ``data_v``).
lat_data : np.ndarray
2D array of latitudes (same shape as ``data_u`` and ``data_v``).
data_u : np.ndarray
2D array of the U-component (zonal) of the vector field.
data_v : np.ndarray
2D array of the V-component (meridional) of the vector field.
mask_ocean : np.ndarray
2D mask array (same shape as data) with ``1`` indicating valid (e.g., ocean) grid cells.
path_save : str
Directory path where the output PNG file will be saved.
name_save : str
Base filename (without extension) for the saved image.
quiver_max_n : int, optional
Maximum number of quiver arrows per axis.
The grid is automatically downsampled to at most ``quiver_max_n × quiver_max_n`` arrows.
Default is 10.
quiver_scale : float, optional
Scaling factor for quiver arrow lengths.
If ``None``, Matplotlib chooses automatically. Examples: ``50``, ``100``.
data_min, data_max : float, optional
User-specified minimum and maximum color limits.
If ``None``, they are derived from the 5th and 95th percentiles of ``data_draw``.
Returns
-------
None
The plot is saved as a PNG image in ``path_save`` with a random 5-digit suffix.
"""
# --- Figure geometry like your original ---
dlon = lon_max - lon_min
dlat = lat_max - lat_min
dy = np.around(dlat/dlon, 1)
if dy >= 2:
dy = 1.5
elif dy < 0.5:
dy = 0.8
fig = plt.figure(figsize=(7, 7*dy))
ax = fig.add_subplot(1,1,1)
ax.set_title(f"{title}")
map2 = Basemap(
projection='merc',
llcrnrlon=lon_min, llcrnrlat=lat_min,
urcrnrlon=lon_max, urcrnrlat=lat_max,
resolution='i', epsg=4326
)
# Grid lines and coast
parallels = _nice_ticks_1d(np.nanmin(lat_data), np.nanmax(lat_data))
meridians = _nice_ticks_1d(np.nanmin(lon_data), np.nanmax(lon_data))
map2.drawparallels(parallels, linewidth=0.5, dashes=[2,8], labels=[1,0,0,0], fontsize=15, zorder=12)
map2.drawmeridians(meridians, linewidth=0.5, dashes=[2,8], labels=[0,0,0,1], fontsize=15, zorder=12)
map2.drawcoastlines(zorder=10)
# --- Speed for color ---
speed = np.hypot(data_u, data_v)
finite_vals = np.asarray(speed)[np.isfinite(speed)]
if finite_vals.size == 0:
if data_min is None:
data_min = 0.0
if data_max is None:
data_max = 1.0
else:
if data_min is None:
data_min = float(np.nanpercentile(finite_vals, 5))
if data_max is None:
data_max = float(np.nanpercentile(finite_vals, 95))
vmin_pad, vmax_pad = _pad_10pct(data_min, data_max)
# Overwrite in case provided value as input ! Nov 30
if data_min is not None:
vmin_pad = data_min
if data_max is not None:
vmax_pad = data_max
ticks = _pretty_ticks(vmin_pad, vmax_pad)
# Colormap
cmap = _truncate_colormap("YlOrBr", 0.0, 0.6)
cmap.set_bad(color='white')
norm = colors.Normalize(vmin=ticks[0], vmax=ticks[-1])
# --- Cell corner shift for pcolormesh ---
# Assumes lon_data, lat_data are 2D center coords on a rectilinear grid
dlon_cell = (lon_data[0,1] - lon_data[0,0]) / 2.0
dlat_cell = (lat_data[1,0] - lat_data[0,0]) / 2.0
lon_c = lon_data - dlon_cell
lat_c = lat_data - dlat_cell
# Draw colored speed field
cm = map2.pcolormesh(lon_c, lat_c, speed, latlon=True, cmap=cmap, norm=norm)
# --- Quiver arrows for direction ---
lon_small_1d = np.linspace(np.nanmin(lon_data), np.nanmax(lon_data), quiver_max_n)
lat_small_1d = np.linspace(np.nanmin(lat_data), np.nanmax(lat_data), quiver_max_n)
lon_small, lat_small = np.meshgrid(lon_small_1d, lat_small_1d)
# --- Obtain nearest value from data_u, data_v ---
u_q = np.full_like(lon_small, np.nan, dtype=float)
v_q = np.full_like(lon_small, np.nan, dtype=float)
dlon = np.nanmax(np.abs(np.diff(lon_data, axis=1)))
dlat = np.nanmax(np.abs(np.diff(lat_data, axis=0)))
max_dist = (max(dlon, dlat)) **2
for j in range(lat_small.shape[0]):
for i in range(lon_small.shape[1]):
# tính khoảng cách (theo độ) tới toàn bộ grid gốc
dist2 = (lon_data - lon_small[j, i])**2 + (lat_data - lat_small[j, i])**2
idx = np.unravel_index(np.nanargmin(dist2), dist2.shape)
if ((mask_ocean[idx] ==1) and (dist2[idx] < max_dist)):
u_q[j, i] = data_u[idx]
v_q[j, i] = data_v[idx]
lon_small[j,i] = lon_data[idx]
lat_small[j,i] = lat_data[idx]
# Draw quiver in geographic coords
map2.quiver(
lon_small, lat_small, u_q, v_q,
latlon=True, zorder=11,
scale=quiver_scale,
width=0.004, # nhỏ hơn, nằm trong lớp trắng
headwidth=3, headlength=4, headaxislength=3.5,
color="black"
)
# --- Colorbar ---
cbar_ax = fig.add_axes([0.15, 0.06, 0.7, 0.02])
cb = fig.colorbar(cm, cax=cbar_ax, ticks=ticks, orientation='horizontal')
cb.ax.tick_params(labelsize=20)
cb.set_label("Speed", fontsize=12)
# --- Save ---
fig.subplots_adjust(bottom=0.15, top=0.9, left=0.15, right=0.90, wspace=0.2, hspace=0.3)
session_id = random.randint(10000, 99999)
plt.savefig(f"{path_save}/{name_save}_{session_id}.png", dpi=250)
plt.close()