From 46b7530959376587a6e8dbd29fcd761166c47768 Mon Sep 17 00:00:00 2001
From: GISWLH <93301293+GISWLH@users.noreply.github.com>
Date: Sat, 10 Oct 2026 10:45:57 -0400
Subject: [PATCH] Journal-grade figure toolkit, redesigned brand and bilingual
README (v0.4.0)
- add plot_function.journal: journal_style/figsize, discrete_cmap with extend
triangles, add_colorbar, geo_ticks, add_land, stippling/hatching helpers,
add_inset_bars (hatched low agreement), add_inset_histogram (stacked, log,
cumulative twin axis), projection-aligned latitude/longitude profiles,
panel labels, size legends, save_figure
- plot_map/layers: white publication theme, slim colourbars, degree ticks,
profiles aligned to map latitudes on any projection, cleaner insets
- new gallery (examples/journal_figures.py, docs/gallery) and quick start
- minimalist line-drawing logo and banner (docs/brand, SVG + PNG)
- bilingual README with badges, gallery grid, links, citation, star history;
CITATION.cff, package metadata, tests for the journal module
---
.github/workflows/tests.yml | 1 +
.gitignore | 3 +
CHANGELOG.md | 22 +
CITATION.cff | 20 +
MANIFEST.in | 3 +-
README.ja.md | 4 +-
README.md | 334 +++---
README.zh-CN.md | 17 +-
...ies.shp.DESKTOP-1H76CSN.10272.3740.sr.lock | 0
docs/API.md | 48 +-
docs/assets/README.md | 23 +-
docs/assets/east-asia.png | Bin 210700 -> 438313 bytes
docs/assets/global-temperature.png | Bin 238978 -> 368409 bytes
docs/assets/header.png | Bin 1310844 -> 0 bytes
docs/assets/journal-global.png | Bin 378834 -> 411231 bytes
docs/assets/journal-regional.png | Bin 289268 -> 332043 bytes
docs/assets/journal-significance.png | Bin 367473 -> 275441 bytes
docs/assets/logo.png | Bin 1123859 -> 0 bytes
docs/assets/seasonal-contrast.png | Bin 253092 -> 381495 bytes
docs/assets/seasons.png | Bin 320157 -> 327353 bytes
docs/brand/banner.png | Bin 0 -> 107331 bytes
docs/brand/banner.svg | 56 +
docs/brand/logo.png | Bin 0 -> 26091 bytes
docs/brand/logo.svg | 43 +
docs/gallery/fig1_regimes.png | Bin 0 -> 428100 bytes
docs/gallery/fig2_sites.png | Bin 0 -> 500441 bytes
docs/gallery/fig3_era5_profile.png | Bin 0 -> 430777 bytes
docs/gallery/fig4_regional_significance.png | Bin 0 -> 294662 bytes
examples/journal_figures.py | 389 +++++++
examples/journal_gallery.py | 122 +--
examples/journal_quickstart.py | 48 +
plot_function/__init__.py | 4 +-
plot_function/journal.py | 988 ++++++++++++++++++
plot_function/layers.py | 202 ++--
plot_function/maps.py | 138 ++-
plot_function/options.py | 16 +-
pyproject.toml | 20 +-
tests/test_journal.py | 116 ++
38 files changed, 2196 insertions(+), 421 deletions(-)
create mode 100644 CITATION.cff
delete mode 100644 data/countries.shp.DESKTOP-1H76CSN.10272.3740.sr.lock
delete mode 100644 docs/assets/header.png
delete mode 100644 docs/assets/logo.png
create mode 100644 docs/brand/banner.png
create mode 100644 docs/brand/banner.svg
create mode 100644 docs/brand/logo.png
create mode 100644 docs/brand/logo.svg
create mode 100644 docs/gallery/fig1_regimes.png
create mode 100644 docs/gallery/fig2_sites.png
create mode 100644 docs/gallery/fig3_era5_profile.png
create mode 100644 docs/gallery/fig4_regional_significance.png
create mode 100644 examples/journal_figures.py
create mode 100644 examples/journal_quickstart.py
create mode 100644 plot_function/journal.py
create mode 100644 tests/test_journal.py
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 7f1e871..2686976 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -21,4 +21,5 @@ jobs:
- run: ruff check plot_function utils tests examples
- run: pytest
- run: python examples/quickstart.py
+ - run: python examples/journal_quickstart.py
- run: python -m build
diff --git a/.gitignore b/.gitignore
index c6b911b..f39aa5b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -152,3 +152,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
+
+# ArcGIS schema locks
+*.sr.lock
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c5327a..9e638f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,27 @@
# Changelog
+## 0.4.0 — Unreleased
+
+- New `plot_function.journal` module: Nature/Science-style typography
+ (`journal_style`, Arial/Helvetica fallback chain, 7–9 pt, editable PDF/SVG text),
+ column-width `figsize`, discrete colormaps with extend triangles
+ (`discrete_cmap`), slim titled colourbars (`add_colorbar`), clean degree ticks and
+ dashed graticules (`geo_ticks`), cream land and borders (`add_land`),
+ stippling/hatching helpers with legend handles, `add_inset_bars` (stacked
+ low-agreement hatching), `add_inset_histogram` (stacked groups, total outline,
+ log bins, cumulative curve on an offset twin axis), projection-aware marginal
+ profiles (`add_latitude_profile`, `add_longitude_profile`), panel labels, size
+ legends and 600-dpi `save_figure`.
+- Marginal profiles are now aligned with the map latitudes on every projection
+ (Robinson, Equal Earth, …), not only Plate Carrée, and follow `set_extent`.
+- Restyled `plot_map`: white publication theme, thinner lines, slim colourbars,
+ degree tick labels on regional maps, Nature-style panel letters, 300-dpi export.
+- Restyled distribution insets (white translucent backdrop, open spines, mean
+ marker) and profile panels (labels below the axis, dashed latitude guides).
+- New gallery (`examples/journal_figures.py`, `docs/gallery/`) and redesigned
+ minimalist line-drawing logo and banner (`docs/brand/*.svg`).
+- Bilingual README, `CITATION.cff`, richer package metadata.
+
## 0.3.0 — Unreleased
- Add right/top profiles with line, spread-band, and bar styles.
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000..a4f216f
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,20 @@
+cff-version: 1.2.0
+message: "If plot-function helped your figures, please cite it as below and give the repository a star."
+title: "plot-function: journal-grade maps from NetCDF with xarray, Cartopy and Matplotlib"
+type: software
+authors:
+ - family-names: Wang
+ given-names: Longhao
+ orcid: "https://orcid.org/0000-0002-4642-4701"
+repository-code: "https://github.com/GISWLH/plot-function"
+url: "https://github.com/GISWLH/plot-function"
+license: MIT
+version: 0.4.0
+keywords:
+ - netcdf
+ - cartopy
+ - xarray
+ - matplotlib
+ - climate
+ - hydrology
+ - scientific visualization
diff --git a/MANIFEST.in b/MANIFEST.in
index c0bbc9b..564acf3 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,5 @@
include README*.md CHANGELOG.md CONTRIBUTING.md LICENSE
-recursive-include docs *.md *.png
+recursive-include docs *.md *.png *.svg
recursive-include examples *.py
recursive-include tests *.py
+include CITATION.cff
diff --git a/README.ja.md b/README.ja.md
index 6b6867c..a5b2fb3 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -1,6 +1,6 @@
-

+
-
+> **0.4 の新機能:** Nature / Science 風の図版ツールキット `plot_function.journal`(離散カラースケール、点描・ハッチング、左下の統計インセット、地図の緯度に揃えた右側プロファイル、600 dpi 出力)。[新ギャラリー](README.md#gallery) と [API](docs/API.md#plot_functionjournal) を参照してください。
English · 简体中文 · 日本語
diff --git a/README.md b/README.md
index 454ac0c..430b599 100644
--- a/README.md
+++ b/README.md
@@ -1,267 +1,187 @@
-
+
-
-
-
- English · 简体中文 · 日本語
-
-
-
+
+
+
+
+
+
-**Make a map from a NetCDF file in a few lines.** `plot-function` brings coordinate handling, geographic projections, colorbars, and export into a small Python API and command-line tool. It keeps the original **xarray → Cartopy → Matplotlib** approach, with full access to the resulting figure.
+
+ English · 中文简介 · 简体中文完整文档 · 日本語
+
+ Gallery · Quick start · Journal toolkit · API · Research-figure guide · Cite
+
-No shapefile, GeoTIFF, or Salem installation is needed for the new workflow. Optional coastlines come from Cartopy's Natural Earth cache; set `coastlines=False` for completely offline plotting.
+**plot-function** turns NetCDF / xarray fields into figures that look like they came out of *Nature*, *Science* or *Nature Geoscience*:
+discrete diverging colour scales with triangle ends, stippling for low ensemble agreement, a lower-left statistics inset,
+a right-hand zonal-mean profile that is **aligned with the map latitudes on any projection**, clean `40°N` ticks, Arial 7–9 pt typography and 600-dpi export — in a few lines of Python, with every Matplotlib object still yours to edit.
-[Quick start](#quick-start) · [Gallery](#gallery) · [Your own data](#your-own-data) · [CLI](#command-line) · [API reference](docs/API.md)
+> ⭐ If it saves you an afternoon of fiddling with Cartopy, please **star the repo** — it helps other geoscientists find it.
-## Quick start
+## Gallery
-Requires Python **3.10+**. Install from this repository; the commands below do not assume a PyPI release.
+| | |
+| :---: | :---: |
+| 
Regime map · two BrBG-style scales · stippling · hatched inset bars · ensemble zonal profile | 
Site map · cream land · sized markers · log histogram + cumulative curve · legends below |
+| 
Robinson + aligned profile · real ERA5 1978 · inter-month IQR and range bands | 
Regional panels · one shared scale · stippling vs hatching · per-panel profiles |
-```bash
-git clone https://github.com/GISWLH/plot-function.git
-cd plot-function
-python -m venv .venv
-source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1
-python -m pip install -e .
-```
+
+
+
+
-Use the bundled NetCDF file to plot the first monthly temperature field:
+Figures 1, 2, 4 and the significance panel use synthetic example data (seeded, labelled in each figure); the others use the bundled ERA5 1978 monthly 2 m temperature file. Reproduce everything with python examples/journal_figures.py and python examples/journal_gallery.py.
-```python
-from plot_function import plot_map
+## Features
-result = plot_map(
- "data/ERA5temp_1978_monthly.nc",
- variable="t2m",
- isel={"time": 0},
- offset=-273.15, units="°C", # This file stores temperature in Kelvin.
- cmap="RdYlBu_r",
- title="January 1978 · 2 m air temperature",
- output="january.png",
-)
-```
+- **One-call maps from NetCDF** — `plot_map("file.nc", "t2m", isel={"time": 0})` handles variable/dimension selection, coordinate normalisation, projection, colourbar and export.
+- **Journal typography** — `journal_style()` applies Arial/Helvetica (with Liberation/Nimbus fallbacks), 7–9 pt text, 0.5 pt lines, editable text in PDF/SVG; `figsize("single" | "double")` gives 89/183 mm column widths.
+- **Discrete colour scales** — `discrete_cmap()` returns a `(cmap, norm)` pair whose extend triangles take the darkest colours, exactly like printed atlases.
+- **Significance & agreement** — `add_stippling()` (staggered dot lattice) and `add_hatching()` from a mask, or `Significance(p_values, correction="fdr_bh")` in `plot_map`.
+- **Lower-left insets** — `add_inset_bars()` with hatched low-agreement fractions; `add_inset_histogram()` with stacked groups, a total outline, log bins and a cumulative curve on an offset twin axis. Labels get a white halo so they stay legible over the map.
+- **Aligned marginal profiles** — `add_latitude_profile()` / `add_longitude_profile()` push latitudes through the map projection, so 40°N on the profile is level with 40°N on Robinson or Equal Earth maps; IQR/ensemble bands, min–max envelopes or individual members.
+- **Cartographic polish** — `geo_ticks()` (degree labels, dashed light graticule), `add_land()` (cream land, thin grey borders), `add_size_legend()`, `add_panel_label()`, `save_figure(dpi=600)`.
+- **Batteries included** — CLI (`plot-function plot …`), offline tests, CI, and the original notebook helpers (`from utils import plot`) kept for backwards compatibility.
-First use of coastlines may download Natural Earth data. Add `coastlines=False` to avoid that download. In a notebook, display `result.figure`; in a Python GUI session, call `matplotlib.pyplot.show()`.
-
-Want a tiny example without external downloads or the bundled climate dataset?
+## Quick start
```bash
-python examples/quickstart.py
+git clone https://github.com/GISWLH/plot-function.git
+cd plot-function
+python -m pip install -e . # Python 3.10+, not yet on PyPI
+python examples/journal_quickstart.py
```
-This creates a clearly labeled **synthetic** NetCDF field and a PNG in `examples/output/`, using only the core dependencies.
-
-## Research figures: statistics, context, and significance
-
-Build a complete composition with reusable option objects. The new layers work with the same NetCDF inputs and editable Matplotlib results.
+**A complete map from the bundled NetCDF file:**
```python
-from plot_function import plot_map, Profile, Distribution, MapFeatures
+import numpy as np
+from plot_function import plot_map, Profile, Distribution
result = plot_map(
"data/ERA5temp_1978_monthly.nc", "t2m",
- isel={"time": 6}, offset=-273.15, units="°C",
- projection="platecarree", extent=[92, 142, 8, 53], cmap="RdYlBu_r",
- profiles=[Profile(position="right", style="band"),
- Profile(position="top", style="line", weights="coslat")],
+ reduce="time", offset=-273.15, units="°C",
+ cmap="RdYlBu_r", levels=np.arange(-40, 41, 5),
+ title="Annual-mean 2 m air temperature",
+ profiles=Profile(style="band", reference=0, label="Zonal mean ± 1 s.d."),
distribution=Distribution(style="bars", weights="coslat", color="map"),
- features=MapFeatures(rivers=True, lakes=True, borders=True, resolution="50m"),
- title="East Asia · July 1978", figsize=(12, 9),
- output="research-figure.png",
+ panel_label="a",
+ output="annual_mean.png", dpi=600,
)
```
-| Layer | Styles and controls |
-| :--- | :--- |
-| Marginal profiles | Right/top; `line`, `band`, `bars`; mean or median; spatial SD or IQR; reference lines |
-| Distribution inset | Bottom-left by default; `bars`, `step`, `line`, `ecdf`; custom bins and placement; mean marker |
-| Weighting | Equal cells, cosine latitude, or explicitly aligned cell weights |
-| Significance | `stipple`, `hatch`, `contour`; supplied p-values or boolean masks; optional BH FDR correction |
-| Geographic detail | Rivers, lakes, borders, land/ocean backgrounds; 110m, 50m, or 10m Natural Earth layers |
-| Composition | Panel labels, shared color scales, editable axes, PNG/PDF/SVG export |
-
-
-
-
+## Journal toolkit
-**Significance is an explicit statistical layer.** Supply p-values from a test appropriate to your data; the plotting library does not infer significance from map colors or sample size.
+`plot_function.journal` works with *any* Cartopy axes, so you can build fully custom multi-panel figures:
```python
-from plot_function import Significance
-
-# p_values must be a 2D DataArray on the same normalized grid as the map.
-# Or pass a NetCDF path with variable=, sel= and/or isel=.
-layer = result.add_significance(
- Significance(p_values, style="stipple", alpha=0.05, correction="fdr_bh")
-)
-print(layer.statistics.attrs) # Tested cells, significant cells, and critical p-value.
-```
-
-The comparison below uses a **seeded synthetic Gaussian experiment**, not the ERA5 observations. Each panel uses exactly the same p-values and FDR decision; only the display style changes.
-
-
-
-```bash
-python examples/journal_gallery.py
-# Entirely offline: omits coastlines and all downloaded geographic features.
-python examples/journal_gallery.py --offline --output examples/output/journal
-
-plot-function plot data/ERA5temp_1978_monthly.nc --variable t2m \
- --isel '{"time": 6}' --profile right --profile-style band \
- --distribution line --coslat --no-coastlines --output summary.png
-```
-
-Statistics are computed over finite grid-cell centers inside `extent`, or the full field when no extent is supplied. SD/IQR bands describe **spatial variability, not confidence intervals**. Cosine latitude approximates cell-area weights only on regular geographic grids; provide actual cell areas for irregular grids. Profiles have geographic coordinate scales; use Plate Carrée for direct alignment with map axes, since curved projections have different geometry. A frequency `line` is a histogram polygon, not a kernel density estimate. Features may require an initial download even when `coastlines=False`.
-
-The [research-figure guide](docs/RESEARCH_FIGURES.md) documents all options, FDR assumptions, inset placement, returned statistics, and custom layouts.
-
-## Gallery
-
-All four figures below are generated by [`examples/gallery.py`](examples/gallery.py) from the repository's **1978 ERA5 monthly 2 m air temperature NetCDF file**. No external shapefile or raster input is needed.
-
-### 01 · A global overview
-
-Robinson projection, restrained gridlines, discrete temperature intervals, and a readable horizontal colorbar.
-
-
-
-### 02 · Two seasons, one scale
-
-January and July share the same color limits, making a side-by-side comparison meaningful. A dark theme provides an alternative for presentations.
-
-
-
-### 03 · The seasonal difference
-
-An Equal Earth view with a symmetric, zero-centered color scale for **July minus January**. This is a seasonal difference within one year, not a climate trend or a climatological anomaly.
-
-
-
-### 04 · Regional detail
-
-A Lambert conformal view of East Asia with labeled isotherms. The same NetCDF field supports both global and regional figures.
-
-
-
-Rebuild the gallery:
-
-```bash
-python examples/gallery.py
-# Offline alternative; preserves the checked-in gallery:
-python examples/gallery.py --no-coastlines --output examples/output/gallery
+import numpy as np, matplotlib.pyplot as plt, cartopy.crs as ccrs
+import plot_function.journal as pj
+
+lon, lat = np.arange(-179.5, 180, 1.0), np.arange(-59.5, 90, 1.0)
+field = (8 * np.sin(np.deg2rad(2 * lon))[None, :] * np.cos(np.deg2rad(lat))[:, None]
+ + 4 * np.sin(np.deg2rad(3 * lat))[:, None]) # example data
+low_agreement = np.abs(field) < 1.5
+
+with pj.journal_style():
+ fig = plt.figure(figsize=pj.figsize("double", 0.45))
+ ax = fig.add_axes([0.06, 0.24, 0.78, 0.72], projection=ccrs.PlateCarree())
+ ax.set_extent([-180, 180, -60, 90], crs=ccrs.PlateCarree())
+ cmap, norm = pj.discrete_cmap("BrBG", np.arange(-10, 10.1, 2.5), extend="both")
+ mesh = ax.pcolormesh(lon, lat, field, cmap=cmap, norm=norm, transform=ccrs.PlateCarree())
+ ax.coastlines(lw=0.3)
+ pj.geo_ticks(ax, xticks=range(-180, 181, 60), yticks=range(-40, 81, 20))
+ pj.add_stippling(ax, lon, lat, low_agreement, stride=3)
+ pj.add_inset_bars(ax, ["Drier", "Wetter"], [42, 58], hatched=[9, 12],
+ colors=["#a6611a", "#018571"], ylabel="Area [%]")
+ pj.add_latitude_profile(ax, lat, field.mean(1), lower=np.percentile(field, 25, 1),
+ upper=np.percentile(field, 75, 1), xlabel="Zonal mean")
+ pj.add_colorbar(mesh, ax, title="Example regime", label="Δ [units]")
+ pj.add_panel_label(ax, "a")
+ pj.save_figure(fig, "my_figure.png", dpi=600) # or formats=("png", "pdf")
```
-**How the demo is computed:** Kelvin is converted to Celsius by subtracting 273.15. The annual view is an equally weighted mean of 12 monthly means, not a day-weighted annual mean. Every fourth latitude/longitude point is used for 1° display spacing; the original data are unchanged. Coastlines are Natural Earth 110m. See [data and artwork notes](docs/assets/README.md).
+| Helper | What it draws |
+| :--- | :--- |
+| `journal_style()`, `figsize()` | Temporary rcParams for 7–9 pt Arial-like figures; 89 / 120 / 183 mm widths |
+| `discrete_cmap(cmap, levels, extend)` | One colour per interval, darkest colours on the triangles |
+| `add_colorbar(mappable, ax, title=, label=)` | Slim bar, triangle ends, bold title above and label below |
+| `geo_ticks(ax, xticks=, yticks=)` | `60°E` / `40°N` ticks, light dashed graticule |
+| `add_stippling`, `add_hatching`, `hatch_patch` | Low-agreement / significance overlays and legend handles |
+| `add_inset_bars(..., hatched=)` | Category bars with hatched uncertain fractions |
+| `add_inset_histogram(groups, log=, cumulative=)` | Stacked histogram, total outline, cumulative twin axis |
+| `add_latitude_profile`, `add_longitude_profile` | Projection-aligned marginal profiles with bands |
+| `add_land`, `add_size_legend`, `add_panel_label`, `save_figure` | Cream land & borders, marker-size legend, **(a)** labels, 600 dpi |
-## Your own data
+More: the [API reference](docs/API.md), the [research-figure guide](docs/RESEARCH_FIGURES.md) (profiles, distributions, FDR significance in `plot_map`) and the [migration notes](docs/MIGRATION.md).
-Start by inspecting variable names, dimensions, and units:
+## Your own data & the CLI
```bash
-plot-function inspect your-data.nc
+plot-function inspect your-data.nc # list variables, dims, units
+plot-function plot data/ERA5temp_1978_monthly.nc --variable t2m --isel '{"time": 6}' \
+ --offset -273.15 --units '°C' --profile right --distribution bars --output july.png
```
```python
from plot_function import open_field, plot_map
-
-# Explicitly choose a time and a vertical level when present.
-# Adapt these dimension names and values to your file.
-field = open_field(
- "your-data.nc", variable="temperature",
- isel={"time": 0}, sel={"level": 850},
-)
-result = plot_map(field, title="Temperature at 850 hPa", output="map.png")
+field = open_field("your-data.nc", variable="temperature", isel={"time": 0}, sel={"level": 850})
+plot_map(field, projection="platecarree", extent=[90, 145, 5, 55], output="map.pdf")
```
-For the bundled dataset, a temporal mean needs just one extra argument:
-
-```python
-result = plot_map(
- "data/ERA5temp_1978_monthly.nc", variable="t2m",
- reduce="time", statistic="mean",
- offset=-273.15, units="°C",
- projection="platecarree", extent=[90, 145, 5, 55],
- cmap="RdYlBu_r", title="East Asia · 1978 monthly-mean average",
-)
-result.save("figures/east-asia.pdf")
-```
+`plot_map` returns a `MapResult` exposing `.figure`, `.axes`, `.artist`, `.colorbar`, `.data`, `.profiles` and `.distribution` for further editing. Rectilinear lat/lon grids are supported; see the [data contract](docs/API.md#data-contract) and [API reference](docs/API.md) for details. Existing notebooks keep working via `from utils import plot`.
-| Need | Option |
-| :--- | :--- |
-| Select by position / coordinate label | `isel={"time": 0}` / `sel={"level": 850}` |
-| Aggregate an extra dimension | `reduce="time"`, `statistic="mean"` |
-| Aggregate several dimensions | `reduce=["time", "member"]` |
-| Use custom geographic coordinate names | `latitude="nav_lat", longitude="nav_lon"` |
-| Convert values explicitly | `scale=1, offset=-273.15, units="°C"` |
-| Focus on a region | `extent=[west, east, south, north]` |
-| Compare panels consistently | Set the same `levels` or `vmin` / `vmax` |
-| Change appearance | `theme="dark"`, `cmap="viridis"`, `plotfunc="contourf"` |
-| Use your existing layout | Pass a Cartopy `ax=`; its projection takes precedence |
-| Export | `output="map.png"` or `result.save("map.svg")` |
-
-Supported projection names are `robinson`, `platecarree`, `equalearth`, and `mollweide`. A Cartopy projection object also works. The returned `MapResult` exposes `.figure`, `.axes`, `.artist`, `.colorbar`, and `.data` for annotations, shared colorbars, and further analysis. Call `plt.close(result.figure)` in batch scripts.
-
-### Data contract
-
-- **Rectilinear geographic grids:** latitude and longitude must each be one-dimensional, finite, and contain at least two distinct values. CF `standard_name` / geographic `units` and common `lat`/`lon` or `latitude`/`longitude` names are recognized.
-- **Explicit scientific choices:** multiple fields require `variable=`; nonspatial dimensions with more than one value require selection or reduction. Reductions are unweighted and skip missing values. Supported statistics: `mean`, `median`, `min`, `max`, `sum`, `std`.
-- **Coordinate normalization:** latitude is sorted, longitude is wrapped to `[-180, 180)` and sorted; a redundant global 0°/360° endpoint is removed. Other duplicates are rejected.
-- **Missing values:** xarray decodes NetCDF fill values; nonfinite values are masked. An entirely missing field produces an actionable error.
-- **Bounded scope:** curvilinear grids, unstructured meshes, projected x/y grids in metres, and antimeridian-crossing regional grids/extents need preprocessing. The API does not reproject source grids or infer units, vertical levels, time weights, or area weights.
-- **Memory:** the selected/reduced 2D field is loaded before the file is closed. For large workflows, preprocess with xarray and pass a prepared `DataArray`.
-
-## Command line
+## Development
```bash
-plot-function plot data/ERA5temp_1978_monthly.nc \
- --variable t2m --isel '{"time": 0}' \
- --offset -273.15 --units '°C' --cmap RdYlBu_r \
- --title 'January 1978' --output january.png
-
-# Headless, offline, regional monthly-mean average:
-plot-function plot data/ERA5temp_1978_monthly.nc \
- --variable t2m --reduce time --projection platecarree \
- --extent 90 145 5 55 --no-coastlines --output regional.png
+python -m pip install -e '.[dev]'
+pytest && ruff check plot_function utils tests examples
+python examples/journal_figures.py --dpi 600 --pdf # rebuild the gallery
```
-`python -m plot_function` is equivalent to `plot-function`. Use `plot-function plot --help` for all options. CLI rendering uses Matplotlib's headless Agg backend. Selection arguments are JSON objects; quote them for your shell.
+## Links
-## Existing notebooks still work
+- 👤 Author: **Longhao Wang** — [homepage](https://giswlh.github.io/) · [GitHub @GISWLH](https://github.com/GISWLH) · [Google Scholar](https://scholar.google.com/citations?user=ei3oenUAAAAJ) · [ORCID](https://orcid.org/0000-0002-4642-4701) · [Hugging Face](https://huggingface.co/LonghaoWang)
+- 🌏 Related: [IPCC](https://github.com/GISWLH/IPCC) (IPCC-style climate visualisation) · [CAS-Canglong](https://github.com/GISWLH/CAS-Canglong) · [WeatherAI](https://github.com/GISWLH/WeatherAI) (weather AI model zoo) · [GeoAreaWeight](https://github.com/GISWLH/GeoAreaWeight) (area-weighted means) · [cartopy-robinson-lat-clip](https://github.com/GISWLH/cartopy-robinson-lat-clip)
+- 🐛 [Issues & feature requests](https://github.com/GISWLH/plot-function/issues) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)
-```python
-from utils import plot # Original import remains available.
-from plot_function import legacy # The same helpers in the new package.
-```
-
-The low-level map, hatching, regional, and warming-panel helpers are retained. Bugs in coastline keyword forwarding, hatch inversion and return values, regional extents, legends, colorbar overrides, and profile labeling have been corrected. See [migration notes](docs/MIGRATION.md).
+## Citation
-Optional tools for the historical notebook are separate from the core install:
+If plot-function helped your paper, please cite it (GitHub's **“Cite this repository”** button reads [`CITATION.cff`](CITATION.cff)) and ⭐ star the project:
-```bash
-python -m pip install -e '.[notebook,legacy]'
-jupyter lab
+```bibtex
+@software{wang_plot_function,
+ author = {Wang, Longhao},
+ title = {plot-function: journal-grade maps from NetCDF with xarray, Cartopy and Matplotlib},
+ url = {https://github.com/GISWLH/plot-function},
+ version = {0.4.0},
+ license = {MIT}
+}
```
-[`plotbook.ipynb`](plotbook.ipynb) is the historical reference, including raster/shapefile examples. Its China-temperature section references the unbundled `data/tp/tmp_2022.nc`, so it cannot run end-to-end as distributed. Legacy China boundary helpers need the repository's `data/` files (or explicit shapefile paths); those large datasets are not included in the Python wheel. New users should start with the NetCDF examples above.
+## Star history
-## Development
+
+
+
-```bash
-python -m pip install -e '.[dev]'
-pytest
-ruff check plot_function utils tests examples
-python examples/quickstart.py
-python -m build
-```
+## 中文简介
+
+**plot-function** 让 NetCDF / xarray 数据几行代码就画出顶刊风格的地学地图:带三角端点的离散发散色标、低一致性打点(stippling)、左下角统计小图(分类柱状图 / 对数直方图 + 累积曲线)、右侧与地图纬度**严格对齐**的纬向平均剖面(任意投影,含 Robinson)、`40°N` 式坐标、Arial 7–9 pt 字体以及 600 dpi 导出。所有 Matplotlib 对象都可以继续修改。
+
+- **一行出图**:`plot_map("file.nc", "t2m", isel={"time": 0})`,自动处理变量、维度、坐标、投影、色标与导出。
+- **顶刊工具箱** `plot_function.journal`:`journal_style()` 字体规范、`discrete_cmap()` 离散色标、`add_stippling()` / `add_hatching()` 显著性、`add_inset_bars()` / `add_inset_histogram()` 左下角小图、`add_latitude_profile()` 右侧纬度剖面、`geo_ticks()` 经纬度刻度、`add_colorbar()` 色标、`save_figure()` 高分辨率导出。
+- **快速开始**:`python -m pip install -e .` 后运行 `python examples/journal_quickstart.py`;完整画廊见 `python examples/journal_figures.py`。
+- 示例图 1、2、4 与显著性示例使用**合成示例数据**(图中已注明),其余使用仓库自带的 ERA5 1978 月平均气温。
-Tests cover NetCDF selection, coordinate and unit handling, rendering without network downloads, the CLI, and legacy regressions. GitHub Actions runs tests on Python 3.10 and 3.12. Contributions are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
+完整中文文档见 [README.zh-CN.md](README.zh-CN.md)。觉得有用的话欢迎点个 ⭐ Star,也欢迎在论文中引用!
-## Credits & license
+## License
-Created by **Longhao Wang**. Built on [xarray](https://docs.xarray.dev/), [Cartopy](https://scitools.org.uk/cartopy/docs/latest/), [Matplotlib](https://matplotlib.org/), and [mplotutils](https://github.com/mathause/mplotutils). Software is licensed under [MIT](LICENSE). Dataset provenance, Natural Earth attribution, and the distinction between decorative artwork and computed plots are documented [here](docs/assets/README.md).
+[MIT](LICENSE) © Longhao Wang. Dataset provenance and Natural Earth attribution: [docs/assets/README.md](docs/assets/README.md). Logo and banner are hand-authored SVG line drawings in [`docs/brand/`](docs/brand/).
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 5d7dcd2..a34eda6 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,6 +1,4 @@
-
-
-
+
English · 简体中文 · 日本語
@@ -13,7 +11,18 @@
新工作流无需准备 Shapefile 或 GeoTIFF,也不依赖 Salem。可选海岸线由 Cartopy 的 Natural Earth 缓存提供;设置 `coastlines=False` 即可完全离线绘图。
-[快速开始](#快速开始) · [示例画廊](#示例画廊) · [使用自己的数据](#使用自己的数据) · [命令行](#命令行) · [API 参考](docs/API.md)
+[顶刊图库](#顶刊图库04-新增) · [快速开始](#快速开始) · [示例画廊](#示例画廊) · [使用自己的数据](#使用自己的数据) · [命令行](#命令行) · [API 参考](docs/API.md)
+
+## 顶刊图库(0.4 新增)
+
+新增 `plot_function.journal` 模块,参照 Nature / Science 版式:Arial 7–9 pt 字体、带三角端点的离散色标、低一致性打点与纹理、左下角统计小图(分类柱状图带低一致性纹理 / 对数直方图 + 累积曲线双轴)、右侧**与地图纬度严格对齐**的纬向剖面(Robinson 等任意投影均对齐,含 IQR / 集合范围阴影)、`40°N` 式刻度与浅色虚线经纬网、600 dpi 导出。
+
+| | |
+| :---: | :---: |
+|
|
|
+|
|
|
+
+图 1、2、4 使用**合成示例数据**(图中已注明),图 3 使用仓库自带的 ERA5 1978 年数据。运行 `python examples/journal_figures.py` 可全部复现;最小示例见 `python examples/journal_quickstart.py`,用法见英文 [README](README.md#journal-toolkit) 与 [API 参考](docs/API.md#plot_functionjournal)。
## 快速开始
diff --git a/data/countries.shp.DESKTOP-1H76CSN.10272.3740.sr.lock b/data/countries.shp.DESKTOP-1H76CSN.10272.3740.sr.lock
deleted file mode 100644
index e69de29..0000000
diff --git a/docs/API.md b/docs/API.md
index acc93a2..5374629 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -73,9 +73,9 @@ Accepts all `open_field` options, plus:
| `colorbar` | `True` | Add a horizontal colorbar. Disable for shared custom colorbars. |
| `plotfunc` | `"pcolormesh"` | `pcolormesh` or `contourf`. |
| `ax` | `None` | An existing Cartopy GeoAxes; overrides `projection`. Its figure background remains caller-controlled. |
-| `figsize` | `(10, 5.8)` | Inches, used only when creating a new figure. |
+| `figsize` | `(7.2, 4.2)` | Inches (≈183 mm double column), used only when creating a new figure. |
| `output` | `None` | Optional path to save immediately. Parent directories are created. |
-| `dpi` | `180` | Resolution for immediate export. |
+| `dpi` | `300` | Resolution for immediate export; use 600 for print. |
Regional extents control the viewport; they do not crop, mask, or resample the
returned data. For very large arrays, select/crop the data before plotting.
@@ -89,7 +89,7 @@ returned data. For very large arrays, select/crop the data before plotting.
| `.artist` | The plotted mappable, usable for a shared colorbar. |
| `.colorbar` | Colorbar or `None`. |
| `.data` | The normalized 2D field, including explicitly converted values. |
-| `.save(path, dpi=180, **kwargs)` | Save with a tight bounding box and the figure's background. Additional options go to `Figure.savefig`. Returns a `Path`. |
+| `.save(path, dpi=300, **kwargs)` | Save with a tight bounding box and the figure's background. Additional options go to `Figure.savefig`. Returns a `Path`. |
```python
result.axes.text(0.02, 0.02, "ERA5 · 1978", transform=result.axes.transAxes)
@@ -137,3 +137,45 @@ preserving the original API's behavior.
`add_profile`, `add_distribution`, `add_significance`, and `add_features` accept
an option object or its keyword arguments. See [Research figures](RESEARCH_FIGURES.md)
for styles, weights, scope, algorithms, defaults, and examples.
+
+## Data contract
+
+- **Rectilinear geographic grids:** latitude and longitude must each be one-dimensional, finite, and contain at least two distinct values. CF `standard_name` / geographic `units` and common `lat`/`lon` or `latitude`/`longitude` names are recognized.
+- **Explicit scientific choices:** multiple fields require `variable=`; nonspatial dimensions with more than one value require selection or reduction. Reductions are unweighted and skip missing values. Supported statistics: `mean`, `median`, `min`, `max`, `sum`, `std`.
+- **Coordinate normalization:** latitude is sorted, longitude is wrapped to `[-180, 180)` and sorted; a redundant global 0°/360° endpoint is removed. Other duplicates are rejected.
+- **Missing values:** xarray decodes NetCDF fill values; nonfinite values are masked. An entirely missing field produces an actionable error.
+- **Bounded scope:** curvilinear grids, unstructured meshes, projected x/y grids in metres, and antimeridian-crossing regional grids/extents need preprocessing. The API does not reproject source grids or infer units, vertical levels, time weights, or area weights.
+- **Memory:** the selected/reduced 2D field is loaded before the file is closed. For large workflows, preprocess with xarray and pass a prepared `DataArray`.
+
+## `plot_function.journal`
+
+Publication helpers that decorate *any* Matplotlib/Cartopy axes. They never
+modify global `rcParams`; wrap figure code in `journal_style()`. Gallery:
+[`examples/journal_figures.py`](../examples/journal_figures.py); minimal example:
+[`examples/journal_quickstart.py`](../examples/journal_quickstart.py).
+
+| Function | Key arguments | Returns |
+| :--- | :--- | :--- |
+| `journal_style(**overrides)` | any rcParams | context manager (Arial → Helvetica → Liberation Sans → Nimbus Sans → DejaVu Sans, 7 pt base, 0.5 pt lines, `pdf.fonttype=42`) |
+| `figsize(width="double", ratio=0.55)` | `"single"` (89 mm), `"onehalf"` (120 mm), `"double"` (183 mm) or mm | `(w, h)` inches |
+| `discrete_cmap(cmap, levels, extend="both")` | colormap name/object or explicit colour list (`len(levels)-1` + number of extend ends) | `(ListedColormap, BoundaryNorm)` |
+| `add_colorbar(mappable, ax, label=, title=, orientation=, bounds=, tick_every=)` | `bounds=[x, y, w, h]` in figure fraction, or placement relative to `ax` | `Colorbar` |
+| `geo_ticks(ax, xticks=, yticks=, lat_labels=, lon_labels=, gridlines=)` | degree tick positions | gridliner list |
+| `add_land(ax, color=LAND, borders=True, resolution="110m")` | Natural Earth (downloads once) | feature artists |
+| `add_stippling(ax, lon, lat, mask, stride=2, size=1.2, offset=True)` | 1-D or 2-D lon/lat, boolean mask | `PathCollection` or `None` |
+| `add_hatching(ax, lon, lat, mask, hatch="....")` | | contour set or `None` |
+| `hatch_patch(hatch)` | | legend handle |
+| `add_inset_bars(ax, labels, values, hatched=, colors=, bounds=, frame="box")` | `hatched` = uncertain part of each bar | inset axes |
+| `add_inset_histogram(ax, groups, bins=, log=, cumulative=, bounds=, frame="open")` | array or `{label: values}`; `cumulative=True` or weights | `(hist_axes, twin_axes or None)` |
+| `add_latitude_profile(ax, lat, center, lower=, upper=, outer=, members=, width=, pad=)` | values per latitude | profile axes, y axis locked to the map |
+| `add_longitude_profile(ax, lon, center, lower=, upper=)` | values per longitude | profile axes above the map |
+| `add_panel_label(ax, "a", style="({})")` | | `Text` |
+| `add_size_legend(ax, values, labels, scale=)` | `scale(value) -> marker area (pt²)` | `Legend` |
+| `save_figure(fig, path, dpi=600, formats=None)` | e.g. `formats=("png", "pdf")` | path(s) |
+
+**Profile alignment.** Profile coordinates are transformed with
+`ax.projection.transform_points` at the central longitude (or the centre of
+the extent), and the profile's limits are synchronised with the map's at draw
+time. On pseudo-cylindrical projections (Robinson, Equal Earth, Mollweide)
+latitude maps to a single y value, so alignment is exact; on conic projections
+it is exact only along the reference meridian.
diff --git a/docs/assets/README.md b/docs/assets/README.md
index 2d271ca..8de7de2 100644
--- a/docs/assets/README.md
+++ b/docs/assets/README.md
@@ -28,12 +28,25 @@ plots, not image-generated depictions of data.
The code's MIT license does not replace the applicable terms of source datasets.
-## Decorative branding
+## Branding
-`header.png` and `logo.png` are AI-generated decorative artwork created for this
-project. They are separate assets, both designed on white backgrounds; the header
-does not embed the standalone logo. The abstract contour shapes are not observations, model outputs, or
-geographic boundaries. They are deliberately separate from the computed gallery.
+The logo and banner live in [`docs/brand/`](../brand/). They are hand-authored
+SVG line drawings (white background) of the library's signature layout — a map
+with isolines and stippling, a lower-left histogram inset, a right-hand latitude
+profile and a discrete colourbar. They are decorative and contain no data.
+PNG exports (`logo.png`, `banner.png`) were rendered from the SVGs with CairoSVG;
+the banner's text uses Inter and Noto Sans CJK SC when rendered.
+
+## Journal gallery (0.4)
+
+`docs/gallery/*.png` are generated by `examples/journal_figures.py`:
+
+- `fig1_regimes.png`, `fig2_sites.png`, `fig4_regional_significance.png`:
+ **synthetic example data** (seeded random fields, pseudo-ensemble members,
+ random sites and z-test p-values). They demonstrate layout only and carry no
+ scientific meaning; each figure says so in its footnote.
+- `fig3_era5_profile.png`: the bundled ERA5 1978 file, July minus the
+ 12-month mean, with inter-month IQR/range bands on the zonal profile.
## Reproduction
diff --git a/docs/assets/east-asia.png b/docs/assets/east-asia.png
index 41abdf0386d57d735c9f77eda7bf5e894e506f02..10ff3066b3cce8a981eb90acc749da11c5a7f278 100644
GIT binary patch
literal 438313
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