Skip to content

feat(styling): add a hatch encoding to Contour, with a hatch legend helper - #362

Merged
MAfarrag merged 12 commits into
mainfrom
feat/contour-hatches
Sep 13, 2026
Merged

MAfarrag merged 12 commits into
mainfrom
feat/contour-hatches

Conversation

@MAfarrag

Copy link
Copy Markdown
Member

Description

Adds a hatch encoding to the contour options, plus a hatch_legend helper — the two things #354 asks for.
cleopatra could fill a field and draw isolines over it, but could not draw a pattern over it: no way to mark a
region without spending the colour channel the data already carries. This lets a caller express the significance /
uncertainty overlay — a filled field plus a second, unfilled contour set drawn as hatching wherever a mask is true,
with one legend entry explaining the pattern.

  • Contour gains three fieldshatches (a pattern per band), fill (False → unfilled overlay), and
    hatch_color (per-set stroke colour). to_options() emits only the fields that were set, so passing a Contour
    never clobbers a glyph default.
  • ArrayGlyph contourf path — applies hatches; fill=False renders the bands unfilled (colors="none", so
    only the hatch marks draw). An unfilled set is not colour-mapped, so its vmin/vmax/norm and its colorbar are
    dropped; hatch_color recolours the strokes for that set only (via set_edgecolor, avoiding the process-global
    hatch.color rcParam).
  • hatch_legend — a Patch-proxy legend next to disjoint_legend, the pattern counterpart to it.
  • Wiring — the three keys are registered in ARRAY_DEFAULT_OPTIONS and _GROUPED_KWARG_HINTS, so a loose
    hatches= / fill= / hatch_color= raises the same guided contour=Contour(...) error levels= does; the new
    keys ride the existing snapshot/rollback. Scoped to ArrayGlyph; inert on glyphs that do not model the keys.

Caller-facing shape (a second glyph onto the same axes, the composition path already supported):

fig, ax = ArrayGlyph(difference).plot(cmap="RdBu_r", title="2050 - 1990")
ArrayGlyph(significant.astype(float), ax=ax).plot(
    kind="contourf",
    contour=Contour(levels=[0.5, 1.5], hatches=["///"], fill=False, hatch_color="0.2"),
    colorbar=False,
)
hatch_legend(ax, ["///"], ["p < 0.05"], loc="lower left")

No new dependency (matplotlib.patches.Patch is already imported for disjoint_legend).

Issues

Type of change

Check relevant points.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update
  • Dev changes (CI/pyproject.toml/docs/examples/testing)

How Has This Been Tested?

  • Contour.to_options emits the hatch keys only when set (test_scaling.py).
  • A kind="contourf" render carries im.hatches, and an unfilled (fill=False) set draws no colorbar;
    a loose hatches= is rejected with the contour=Contour(...) hint (test_array_glyph.py).
  • hatch_legend labels, per-patch hatches, and the length-mismatch ValueError (test_styles.py); its
    docstring doctests run in test_styles_doctests.py.
  • A failed ArrayGlyph plot rolls the co-passed hatch keys back to None (test_group_rollback.py).
  • pytest --doctest-modules src/cleopatra/styling/params.py src/cleopatra/styling/styles.py — doctests pass.
  • Full non-e2e suite: pytest -m "not e2e"3325 passed; ruff check (pinned 0.15.22) clean.

Checklist:

  • updated version number in pyproject.toml
  • added changes to History.rst
  • updated the latest version in README file
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

…elper

cleopatra could fill a field and draw isolines over it, but not draw a
pattern over it -- no way to mark a region without spending the colour
channel the data already carries. Add a hatch encoding to the Contour
group plus a hatch_legend helper, so a significance / uncertainty overlay
(a second, unfilled contour set hatched wherever a mask is true, with a
legend entry) can be expressed.

- Contour gains hatches / fill / hatch_color; to_options emits only the
  fields set, so passing a Contour never clobbers a glyph default
- the ArrayGlyph contourf path applies hatches, and fill=False renders
  the bands unfilled (colors="none") so only the hatch marks draw; an
  unfilled set is not colour-mapped, so its vmin/vmax/norm and colorbar
  are dropped, and hatch_color recolours the strokes per set (no global
  hatch.color rcParam)
- hatch_legend builds Patch proxies -- the pattern counterpart to
  disjoint_legend
- the three keys are registered in ARRAY_DEFAULT_OPTIONS and the
  grouped-kwarg hints, so a loose hatches=/fill=/hatch_color= raises the
  same guided error levels= does
- scoped to ArrayGlyph; inert on glyphs that do not model the keys

Closes #354
- hatch_legend: default transparent-fill/black-edge, custom
  facecolor/edgecolor, legend-kwarg forwarding, and the equal-empty
  boundary of the length guard
- contourf: hatch_color on a filled set recolours the strokes while
  keeping its colorbar (previously exercised only via the unfilled
  overlay)
The hatch_legend directive was inserted between disjoint_legend and its
options: block, orphaning the block onto hatch_legend and leaving
disjoint_legend rendering with mkdocstrings defaults. Give each its own
block so both mirror the other legend builders.
The generic name 'fill' sat in the shared _GROUPED_KWARG_HINTS map, which
gates every glyph's construction via the base __init__. A caller of another
glyph (e.g. filled polygons) passing a loose fill= would be misdirected to
contour=Contour(fill=False). Mirror the existing 'alpha' carve-out: drop
'fill' from the global map and reject a loose fill= locally in ArrayGlyph
(construction and plot) via _reject_loose_fill, with the array-specific
hint. 'hatches'/'hatch_color' stay global (unlikely to collide).
im.set_edgecolor(hatch_color) only recoloured the rendered hatch strokes
when rcParams["hatch.color"] == "edge" -- the default only from matplotlib
3.11. On an older matplotlib, or whenever a user customised hatch.color, the
hatches rendered black regardless of hatch_color, silently, and the band
edges were recoloured as a side effect.

Use QuadContourSet.set_hatchcolor (matplotlib >= 3.11), which recolours the
hatch strokes independently of the rcParam and leaves the edges alone, and
raise the matplotlib floor to >=3.11 accordingly. The test now asserts the
rendered get_hatchcolor() under hatch.color="black" (so it fails if the
recolour regresses to the edge) and that the band edges are not reddened.
hatches/fill/hatch_color are documented as contourf-only, but the block ran
for kind='contour' too: set_hatchcolor recoloured the isolines and hatches
was silently accepted-then-ignored. Gate all three on kind=='contourf' and
warn when they are supplied with kind='contour' instead of mutating unrelated
line-contour output.
- fill=False with no hatches renders an invisible contour set (colors='none'
  and nothing drawn) with a suppressed colorbar -- warn and point at hatches=.
- hatch_color with no hatches has no effect -- warn.
- an unfilled overlay drops the colorbar; warn when the caller explicitly
  requested one (colorbar= arg or add_colorbar in the explicit options)
  rather than silently ignoring the request.
The loose-reject suite only checked hatches=; add fill= (now rejected
locally in ArrayGlyph) and hatch_color= (rejected via the global map),
each asserting the guided contour=Contour(...) hint.
The floor bump in pyproject.toml (>=3.9 -> >=3.11) was not propagated to
uv.lock, whose requires-dist still recorded matplotlib>=3.9. The resolved
version (3.11.1) satisfies both, so runtime and plain 'uv run' CI are
unaffected, but uv sync --locked / uv lock --check would fail on the
mismatch. Regenerate: the only change is the matplotlib specifier line.
…acklevel

- The contourf-only warning fired only inside the contour/contourf branch,
  so it caught kind='contour' but silently dropped hatch fields on the default
  kind='auto', imshow, pcolormesh, and in animate (imshow-only). Move it above
  the kind dispatch (kind is already the resolved effective kind) so every
  non-contourf kind reports the ignored fields, animate included.
- The hatch warnings emitted from the _plot_im_get_cbar_kw helper used
  stacklevel=2, which points at cleopatra's own plot() frame; bump to
  stacklevel=3 so they blame the caller's line.
to_options() aliased the caller's hatches list into default_options and on
into matplotlib. Return a defensive list() copy (as hatch_legend already
does for its inputs) so the frozen dataclass does not share mutable list
contents with the render options.
- S9088/S5778: hoist the Contour(...) and DataStyle(...) constructors out of
  the pytest.warns / pytest.raises blocks so each block wraps a single call
  that can emit the warning/exception.
- S9083: drop the empty parentheses from the TestHatchLegend ax fixture
  decorator (@pytest.fixture).
No behaviour change; the assertions are unchanged.
@sonarqubecloud

Copy link
Copy Markdown

@MAfarrag
MAfarrag merged commit 6365a4e into main Sep 13, 2026
10 checks passed
@MAfarrag
MAfarrag deleted the feat/contour-hatches branch September 13, 2026 20:41
MAfarrag added a commit that referenced this pull request Sep 13, 2026
After merging #362 (hatch encoding), classify and Contour(fill=False) both live
in the contourf path. fill=False renders colors="none" (an unfilled hatch-only
overlay), so a co-passed classify draws no class colours even though it still
sets the band edges and a stepped colorbar. Warn that the class colours are not
drawn in that case, consistent with the color_scale / data_style conflict
warnings. Test covers the combination.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a hatch encoding to the contour options, with a hatch legend helper

1 participant