Changelog#
0.38.0 (2026-09-13)#
- feat!: add classified rasters and scale-bar / north-arrow furniture (#363)
-
- ArrayGlyph.plot / facet / animate accept classify=Classify(scheme=..., k=...), binning the 2-D field into discrete colour classes with a stepped colorbar (numpy-only, reusing the existing classification engine); facet and animate resolve the classes once over the whole stack so panels and frames share them.
- New cleopatra.styling.furniture module adds add_scale_bar and add_north_arrow -- frameless-inset artists modelled on stamp_mark, with GeoMixin sugar. They own no geodesy: length is in axis data units and rotation is caller-supplied.
- Group the parameter-heavy signatures into typed specs: add_scale_bar takes ScaleBar, add_north_arrow takes NorthArrow, and ArrayGlyph.animate takes playback=Animation for its playback options.
- Warn on conflicting combinations (a scheme with color_scale / levels, a data_style preset, or an unfilled contour overlay) and roll a failed classified render back so no half-applied option sticks.
- Update the reference docs, migration guide, and the animation notebooks for the new signatures.
- BREAKING CHANGE: add_scale_bar and add_north_arrow now take a ScaleBar / NorthArrow spec instead of loose presentation keywords, and ArrayGlyph.animate takes playback=Animation(...) instead of the loose interval / frame_label / cell_value_text_colors / data_getter keywords.
- Closes #351, #352
- feat(styling): add a hatch encoding to Contour and a hatch legend (#362)
-
Contour gains hatches / fill / hatch_color, applied on the contourf
render path. fill=False draws the bands unfilled so only the hatch
marks show -- the significance/uncertainty overlay form -- and
hatch_color recolours just the hatch strokes via
QuadContourSet.set_hatchcolor (matplotlib >= 3.11), leaving band
edges untouched. -
add hatch_legend(): a Patch-proxy legend keyed by pattern rather
than colour, the counterpart to disjoint_legend - hatch fields are contourf-only: they warn and are ignored on other
kinds and in animate; loose hatches=/fill=/hatch_color= are rejected
with a contour=Contour(...) hint - warn on degenerate combinations: fill=False or hatch_color with no
hatches, and a colorbar explicitly requested on an unfilled overlay - raise the matplotlib floor to >=3.11 (required by set_hatchcolor)
Closes #354
- feat(glyphs): add HexbinGlyph for hexagonally-binned point density (#361)
- Add cleopatra.glyphs.stats.hexbin_glyph.HexbinGlyph, the discrete
counterpart of KDEGlyph: it bins an (x, y) point cloud onto a hexagonal
lattice via Axes.hexbin and colours each cell by a per-bin aggregate --
a count by default, or the reduce (mean/sum/min/max/std or a callable)
of a per-point values array. The aggregate routes through the shared
Glyph._prepare_scalar_mapping pipeline, so vmin/vmax, color_scale,
levels, the ColorBar spec and classify=Classify(...) behave as for the
other colour-mapped glyphs; a categorical scheme is rejected since a
per-bin aggregate is continuous.
- - Constructor x, y, values=None with keyword-only ax/fig/**kwargs
and shape validation, mirroring ScatterGlyph/KDEGlyph.
- evaluate() returns bin centres and the aggregate without rendering
(on a throwaway figure, leaking no global state) and matches the drawn
PolyCollection.
- Options: gridsize (int or pair), reduce, min_count, extent,
edge_color, line_width; geometry- and CRS-agnostic.
- Raise a clear error when the binning leaves no cells to draw (an
extent excluding the data or a min_count above the densest cell).
- Document the counts-vs-reduce empty-bin behaviour and the log-scale
footgun.
- Tests in tests/test_hexbin_glyph.py (100% line + branch coverage);
HexbinGlyph folded into the shared scheme-scope and colorbar cross-glyph
suites; reference page, mkdocs nav, README/index catalog, architecture
diagram, and GeoMixin list updated.
- Closes #353
- feat(basemap): serve WMS and WMTS basemaps through the tile fetch-and-stitch path (#360)
- basemap.tiles could only talk to an XYZ slippy-tile template, so a service
published as OGC WMS or WMTS -- which is how most national and institutional
imagery is served -- could not be drawn at all.
- Almost nothing in the pipeline needed changing. tiles reaches a provider
through exactly one call, build_url(x=, y=, z=) returning an http(s) URL;
everything after it is service-agnostic, since the fetch returns opaque bytes
and the mosaic is stitched from dict[Tile, bytes]. The gap was entirely in
URL construction.
- - add WMTSProvider and WMSProvider in basemap/ogc.py: frozen dataclasses
satisfying that one method, so add_tiles(ax, WMSProvider(...)) works
through the unchanged public entry point
- map WMTS directly, since its (TileMatrix, TileRow, TileCol) triple is
(z, y, x) on the GoogleMapsCompatible grid; support both the KVP and the
RESTful-template encodings, the latter substituted in one pass with
percent-encoded values and case-insensitive placeholder names
- fit WMS by requesting one GetMap per tile, converting the tile to its Web
Mercator bounds with _tile_xy_bounds. Pinning every request to EPSG:3857
sidesteps the 1.3.0 axis-order trap: it is EPSG:4326 that flips to
latitude-first, and that is never asked for
- render BBOX coordinates through Decimal, which is exponent-free and
lossless; tiles adjacent to the projection origin have bounds around 1e-10,
and plain formatting sent those in scientific notation
- validate the service description at construction rather than per tile, so a
bad endpoint, an unknown version or a template that cannot address a tile
names the field it came from instead of arriving as an unreadable tile
- carry credentials in extra_params, which also closes the keyed-provider gap
that existed for XYZ, and keep them out of the provider's repr() and out of
the tile fetcher's debug log
- fetch_single_tile now logs a redacted URL on a failed attempt, masking
credential-shaped query values and userinfo while keeping the OGC and XYZ
parameter names that say which tile failed. This changes the DEBUG log line for
existing XYZ callers; nothing else in the render path moved.
No new runtime dependency -- urllib.parse.urlencode and decimal.Decimal are
stdlib -- and importing the module touches neither xyzservices nor pyproj.
Only the GoogleMapsCompatible tile-matrix set is supported, and world_texture
stays XYZ-only; both limits are stated in the docstrings and on the new
docs/reference/ogc.md page.
Closes #355
- feat(basemap): add day/night terminator and Tissot artists for 2-D axes (#358)
- Add cleopatra.basemap.solar: a CRS-free, matplotlib-only layer that
draws a day/night terminator and a Tissot indicatrix on a plain lon/lat
axes, the flat-map counterpart to TexturedGlobeGlyph's 3-D lighting.
- subsolar_point / terminator / night_polygon: NOAA/Meeus solar position,
the terminator small circle, and the filled night region as lon/lat
rings — split at the antimeridian (or closed along the dark pole) so a
flat map is never smeared with a whole-world band - tissot_circles: geodesic circles of a fixed ground radius, in lon/lat
- add_nightshade / add_tissot: draw the geometry as a PolyCollection in
data coordinates, preserving axis limits; map via a transform callable
or the optional pyproj crs= shortcut (mutually exclusive), dropping
non-finite vertices a non-global projection produces - share the small-circle geometry between terminator and tissot_circles
- docs/reference/solar.md with a runnable example and autodoc, wired
into the nav and index and cross-linked from the globe glyph page - tests/test_solar.py: 100% line and branch coverage, plus doctests
No new required dependency; pyproj stays behind the [tiles] extra.
Closes #356
- feat(glyphs)!: let ArrayGlyph.facet draw into caller-supplied axes via FacetLayout (#359)
- Add an axes= target on FacetLayout so facet can render its panels
into a caller's existing Figure/SubFigure, a GridSpec/SubplotSpec
region, or a 2-D/flat/nested block of Axes, instead of always building
its own figure via plt.subplots. This makes the shared stack-wide
colour scale reachable for callers assembling their own layout.
- - Group the layout parameters (col, row, col_wrap, labels, figure_size,
axes, extents) into a new frozen FacetLayout passed as facet's first
argument, leaving facet's signature focused on per-panel render options
(kind, colorbar, color, contour, cells, data_style, compose).
- Preserve the shared vmin/vmax on every path, return the root Figure,
and keep FacetGrid.axes shaped (nrows, ncols) (a supplied block must
reproduce the grid, so col_wrap is honoured).
- When axes are supplied, do not own the figure: skip tight_layout/close,
hide only the empty slots inside the block, and on a mid-render failure
remove only the subplots created on a host while leaving pre-existing
caller axes untouched.
- Forward compose= to each panel's plot; reject a malformed axes= block
with a clear error instead of recursing.
- BREAKING CHANGE: ArrayGlyph.facet no longer accepts col/row/col_wrap/labels/figure_size/axes/extents as loose keywords; pass a FacetLayout as the first argument, e.g. facet(FacetLayout(col="time", col_wrap=3), color=...).
Closes #357
- feat(styling): add an equalising colour scale + caller norm passthrough (#349)
- Add ColorScaling.equalize(), a continuous rank-equalising scale backed
by a matplotlib FuncNorm over the data's empirical CDF, so every
quantile of a skewed field gets an equal share of the colour ramp while
the surface stays continuous (unlike the discrete boundary scale).
Callers can also pass a pre-built matplotlib norm directly, so any norm
is reachable without a dedicated scale variant.
- new ColorScale.EQUALIZE and equalize(samples=512) factory; build_norm
gains a values= argument (a CDF needs the data itself, not just the
tick range), supplied lazily by a _scale_values() hook that ArrayGlyph
overrides to expose its finite cells - equalize ranks within the resolved display window, so vmin/vmax and
robust clip the field before ranking; the bar gets quantile-placed,
plainly formatted ticks; and plateau / constant / all-non-finite
fields are handled or raise a clear, actionable error - plot(color=my_norm) and plot(norm=my_norm) accept a matplotlib
Normalize; applying a ColorScaling clears a sticky caller norm, a
caller norm's ticks sit in its own space, and passing both a scale and
a raw norm in one call warns (the norm wins) - honoured by the colormap glyphs (ArrayGlyph, MeshGlyph) that build
a norm
Closes #343
- fix(glyphs): default mp4 export to full colour range (#348)
- save_animation's FFmpeg export (mp4/mov/avi) inherited ffmpeg's
limited/broadcast colour range (16-235), which visibly washed out the
full-range (0-255) figures matplotlib produces. Encode full colour
range by default instead so exported video keeps the figure's contrast.
- Use the full-range yuvj* pixel format (yuv420p -> yuvj420p); the range
is carried by the format, so the RGB->YUV conversion maps to 0-255 on
every ffmpeg build, including the static binary imageio-ffmpeg bundles
(which honours a bare -color_range tag only as a label, not a remap) - Keep the escape hatch: a caller-supplied -color_range in extra_args
leaves the pixel format as-is, so extra_args=["-color_range", "tv"]
restores the old limited/broadcast-range output - Extract extra_args parsing into a helper to keep cognitive complexity
within bounds - Add a real-encode regression that decodes the Y-plane with an identity
range map to read the stored luma faithfully across ffmpeg builds - Document the new default and the opt-out in the animation reference
This changes the encoded bytes/metadata of the default FFmpeg export
(no Python API change); callers relying on limited range can opt out as
above.
Closes #344
- feat(glyphs): apply the advertised axis options and allow composition (#350)
- xlabel, ylabel, their font sizes, the tick label sizes and grid_alpha
were advertised by the option validator on every glyph and applied by
none of them. A shared helper now applies them, and only the ones the
caller actually passed -- applying the declared defaults would have
restyled every figure in the package.
- - add compose= to ArrayGlyph.plot/animate and VectorGlyph.plot, so a
glyph can draw over an existing axes instead of replacing it. Off by
default, where a render still replaces every glyph's artists on the
axes (issue #210); a composed render leaves the host's layers,
colorbar, title, ticks, canvas colour and projection frame alone and
draws no colorbar of its own unless asked
- track render-artist ownership per glyph, keyed by identity, with a
finalizer that drops a collected glyph's entry so a later glyph
handed the same address is not mistaken for it
- pad a multi-line title clear of top-spine tick labels: matplotlib
raises the title but anchors its first line, so every later line
hangs down into the labels
- add thin=n to VectorGlyph for quiver/barbs, which draw one arrow per
grid point -- unreadable and slow on a real grid
- document compose= and thin= in docs/reference/render-options.md
Closes #347, #346, #345
0.37.0 (2026-09-07)#
- feat(glyphs): floor a log colour scale's vmin past near-zero outliers (#340)
-
A LogNorm has no linear band, so a single near-zero pixel dragged the
whole log scale down: the colour bar spanned decades far below the
data's bulk and the map's real values collapsed into the top colours
(issue #339). The norm code only sees vmin/vmax, so the fix runs at
ArrayGlyph render time, where the data is in hand. -
When vmin is unset on a log scale, floor it at the smallest positive
value that is not an extreme low outlier -- more than
LOG_OUTLIER_DECADES decades below the 2nd percentile of the positives.
A stray near-zero pixel is dropped; clean and genuinely-low data keep
their true minimum, and the true vmax is untouched. - Apply it per-render on both plot() and animate() without mutating the
glyph's persistent vmin, so reusing the glyph for a later linear or
sym_log render still auto-ranges from the true minimum. - Genuine negative data yields no floor, so the strictly-positive
guardrail still raises and steers to sym_log(); a lone zero is rescued. - An explicit vmin (constructor or call, remembered across sticky calls)
and an explicit ticks_spacing still win. - Scope: ArrayGlyph + ColorScaling.log() only; the floor targets a small
fraction of stray low pixels (documented on _log_safe_vmin).
Closes #339 - feat(styling): auto-derive sym_log linthresh and linscale from the data range (#338) - ColorScaling.sym_log() defaulted linthresh to a fixed 0.0001, so on wide-ranging data almost everything fell in the log region and the colour bar filled with near-zero sub-scale decade ticks (a [-24, 744] terrain bar came back with 14 ticks, 8 below the data's magnitude). - - Derive linthresh from the range (1% of the peak magnitude) when the caller passes no threshold, so the log decades track the data's own scale; the same value drives the norm and the bar ticks. - Pair a sensible linscale (matplotlib's 1.0) with the widened linear band when no scale is given, so the near-zero band stays legible and the in-band -1/0/1 labels no longer overprint. - Flip the threshold/scale defaults 0.0001/0.001 -> None across the dataclass field, _SCALE_DEFAULTS, and the flat DEFAULT_OPTIONS, so the glyph path benefits too; an explicit threshold/scale still wins. - Extract build_norm's SYM_LOGNORM and LOGNORM branches into the _sym_log_norm and _log_norm helpers to keep the dispatcher simple. - Add norm-level tests (auto-derivation, explicit override, flat path, negative-only, O(1) straddle, in-band tick legibility). - The default rendered image for sym_log without explicit knobs changes (the norm, not just the labels); callers passing threshold/scale are unaffected. The data-style norm='symlog' path and the plain-log scale are unchanged. - Closes #337 - fix(styling): give sym_log and log colour bars scale-aware, labelled ticks (#336) - Glyph.get_ticks() builds a linear tick ladder that never consults the colour scale, and the sym_log/log bars used matplotlib's LogFormatter, which blanks non-decade positions and drops the sign of negatives -- so a non-linear colour bar came back ~1 of 11 ticks labelled (#335). - get_ticks() must stay linear (it supplies vmin/vmax via ticks[0]/[-1]), so fix it in ColorScaling.build_norm(): - - the sym_log/log branches place decade ticks with SymmetricalLogLocator / LogLocator over [vmin, vmax] (shared via _decades_in_range, falling back to the linear ladder when fewer than two decades land in range). - they format with a plain, sign-correct FuncFormatter (f"{v + 0.0:g}", -0.0 -> "0") instead of LogFormatter, so a later cbar.set_ticks([...]) is labelled without a paired set_ticklabels(). - linear, power, midpoint and boundary bars are unchanged. - Also add a stripped demo notebook (examples/colorbar_ticks_335.ipynb). A follow-up (#337) tracks auto-deriving the sym_log linear threshold. - Closes #335
0.36.1 (2026-09-07)#
- fix(styling): give sym_log and log colour bars scale-aware, labelled ticks (#336)
- Glyph.get_ticks() builds a linear tick ladder that never consults the colour scale, and the sym_log/log bars used matplotlib's LogFormatter, which blanks non-decade positions and drops the sign of negatives -- so a non-linear colour bar came back ~1 of 11 ticks labelled (#335).
- get_ticks() must stay linear (it supplies vmin/vmax via ticks[0]/[-1]), so fix it in ColorScaling.build_norm():
-
- the sym_log/log branches place decade ticks with SymmetricalLogLocator / LogLocator over [vmin, vmax] (shared via _decades_in_range, falling back to the linear ladder when fewer than two decades land in range).
- they format with a plain, sign-correct FuncFormatter (f"{v + 0.0:g}", -0.0 -> "0") instead of LogFormatter, so a later cbar.set_ticks([...]) is labelled without a paired set_ticklabels().
- linear, power, midpoint and boundary bars are unchanged.
- Also add a stripped demo notebook (examples/colorbar_ticks_335.ipynb). A follow-up (#337) tracks auto-deriving the sym_log linear threshold.
- Closes #335
0.36.0 (2026-09-06)#
- feat(compositing): add reusable alpha "over" array primitive (#333)
-
Add
cleopatra.glyphs.base.compositing.alpha_over, a NumPy-only
Porter-Duff "over" operator, so the leaf render package owns the
alpha-compositing formula that higher layers otherwise re-derive
privately -- with the two fiddly edge cases handled in one place:
un-premultiplying the RGBA blend, and guarding the divide-by-zero
where the output alpha is zero. -
channel-last (H, W, C): an RGB background gives a 3-band result and
an RGBA background a 4-band one; this matches cleopatra's
matplotlib/PIL image layout rather than the issue's band-first
(C, H, W) sketch, and lets the watermark halo delegate directly
(band-first callers transpose at their own boundary) - floating-point inputs keep their own width (a float32 pair yields a
float32 result, never upcast to float64); integer and boolean inputs
are promoted to float64 - replace the private watermark _alpha_over copy with the shared
primitive -- verified bit-for-bit identical, so the halo renders
unchanged -- and drop its forward-referencing TODO - validate array shapes, naming the offending array in the error
Closes #306
- feat(globe): add area sampling and a face_colors accessor (#332)
- TexturedGlobeGlyph point-sampled one texture cell per mesh-face centre,
so a feature narrower than one face fell between the sample points and
vanished. Add an opt-in reduction and a way to inspect the result,
keeping the sample-once/rotate-per-frame contract (the reduction runs
once in _prepare).
- sampling="point"|"area": "point" (default) is unchanged, the cheap
per-centre lookup; "area" reduces the whole texture block each face
covers, alpha-aware -- a face's colour is the mean of the
non-transparent (alpha > 0) cells it spans (transparent if none), so
a small feature stays visible. RGB is an un-premultiplied mean, and
faces finer than one texture cell fall back to point sampling so a
coarse texture never gains gaps. - face_colors: a read-only property returning the base per-face RGBA
the mesh is filled with (before per-frame lighting), so a caller can
check whether its data survived the sampling without a draw(). - expose the modes as SAMPLING_POINT / SAMPLING_AREA / SAMPLING_MODES.
Closes #325
- fix(glyphs): draw into a figure supplied without an axes (#331)
- Constructing a glyph with a figure but no axes left self.ax = None,
so the first plot()/animate() render crashed in
_clear_projection_frame ('NoneType' has no attribute
'_cleo_projection_frame'). ax-alone and fig+ax already worked.
- - ArrayGlyph.plot()/animate() now resolve the axes from a bound
figure (its first axes, or a fresh add_subplot(111) if it has
none) instead of leaving self.ax None; a later plot(ax=) override
still wins and leaves no stray axes.
- Extend the same fig-only resolution to MeshGlyph.plot()/animate().
- Guard _clear_projection_frame(None) to a no-op backstop.
- Reset the figure-ownership flags in animate()'s branch so teardown
never tightens or repaints a caller-owned figure, matching plot().
- ax-alone / fig+ax / neither behaviour is unchanged; no new
dependencies.
- Closes #326
- feat(styling): add ColorScaling.log for a plain-logarithmic colour scale (#330)
- ColorScaling could express linear/power/sym-lognorm/boundary-norm/
midpoint but not a plain matplotlib LogNorm, even though the string-keyed
data-style path already built one. So a caller using the typed grouped
colour object could not ask for a log scale, and sym_log is not a
substitute (it is linear within +/-linthresh, so it renders
strictly-positive data's low end differently).
- - Add a LOGNORM member to ColorScale and a ColorScaling.log() variant
constructor plus its build_norm branch, producing a LogNorm over the
positive tick range.
- Extract a shared build_log_norm(vmin, vmax, *, context, remedy) helper
and route both the data-style norm='log' path and ColorScaling.log()
through it, so the two agree on the requirement -- a strictly-positive,
ascending range -- and each error names its own remedy (norm='symlog'
or ColorScaling.sym_log()).
- Widen a degenerate constant-positive range to [v, v+1] (matching the
data-style path) so a uniform field renders; a non-positive range
raises with its real bound.
- List the new scale in the in-source docstrings, the mesh/index/
array-glyph docs, and the norm-dispatch diagram; add unit, end-to-end
render, and round-trip tests.
- Closes #329
- fix(glyph): accept data_style at construction so the grouped options are reachable (#328)
- A loose style= / hillshade= keyword is rejected with "pass
data_style=DataStyle(...) instead", but no constructor accepted
data_style, so the message named a remedy that did not exist. This also
left MeshGlyph._construct_hillshade permanently False and its restore in
plot() inert.
- - accept data_style on Glyph.init, merged after the loose kwargs so
the group wins on a collision, as in plot()
- refuse a group whose every key is unmodelled, naming the glyph and the
options it lacks; an empty DataStyle() stays a no-op
- annotate the parameter DataStyle and raise TypeError for anything else
- document it on ArrayGlyph, MeshGlyph and KDEGlyph
- Also raise statement coverage from 98.9% to 99.96% and cut partial branches
from 42 to 4. Every new test is mutation-checked: the code it covers was
broken deliberately and the test confirmed to fail.
- Closes #327
0.35.0 (2026-08-29)#
- feat(glyphs): expose TexturedGlobeGlyph's tilt transform for callers (#323)
- Expose the transform the glyph applies to place its sphere, so a caller can align their own scene geometry with the rendered globe instead of reimplementing the tilt.
-
- Add rotation_matrix(spin=0.0) returning the (3, 3) body-to-world matrix R_tilt(x) @ R_z(spin) -- spin about the polar axis, then the fixed axial tilt about world x -- computable without a texture or a draw.
- Add transform(points, spin=0.0) applying that matrix to a (3,) point or an (N, 3) array of body-frame points (unit sphere, +z pole, equatorial plane z=0); non-1-D or scalar input raises ValueError.
- Route _spun_mesh through rotation_matrix and drop the redundant cached _tilt_matrix, so the exposed transform provably lands where the glyph's own mesh does.
- Keep the X-axis tilt default unchanged for every existing caller (the mesh still equals R_x(tilt) @ R_z(spin) @ base within floating-point rounding).
- Add tests (transform-equals-mesh, matrix orthogonality/freshness, shape and validation, output independence, pre-draw use) and a docs example.
- Closes #322
0.34.0 (2026-08-25)#
- feat(glyphs): add directional lighting to TexturedGlobeGlyph (#320)
- Add a sun unit vector and an ambient floor so the globe can be lit from a direction, shading a lambertian day/night terminator instead of reading as evenly illuminated. sun=None (the default) renders byte-identical to 0.33.0.
-
- Accept sun/ambient on init and override per call on draw/animate (mirroring spin, via an inherit sentinel so sun=None can disable lighting for one call); sun is world-space (+z up/north, +x toward the viewer at spin=0), auto-normalized to unit length; ambient must be in [0, 1].
- Apply lighting per frame from the already-rotated vertices (one dot product over the mesh, whose unit-sphere positions are the surface normals), scaling a copy of the cached facecolors by ambient + (1 - ambient) * clip(dot(normal, sun), 0, 1). The facecolors cache is never mutated and the texture is never re-sampled, so the sample-once/rotate-per-frame contract holds and a fixed sun sweeps the terminator as the globe spins; alpha is preserved.
- Validate sun/ambient eagerly on both draw and animate; reject non-1-D or zero sun vectors and out-of-range ambient.
- Add lighting tests (byte-identical sun=None, terminator + ambient floor, lit fraction tracks spin, cache untouched, world-space sun under tilt, validation) and a reference-doc example.
- Closes #319
0.33.0 (2026-08-25)#
- feat(animation): derive a GIF from an existing video and fix the clip palette (#317)
-
The shared GIF palette was chosen by pixel population, so on a clip with a
large textured area the background claimed nearly the whole table and small
saturated marks collapsed to the nearest muddy neighbour. It is now chosen
for colour coverage over the set of colours the clip contains, collected at
full 8-bit precision, so a mark survives however few pixels it covers. -
Add gif_from_video, deriving a GIF from a video already on disk so the
frames are rendered once and every other format read back off that file - Build the palette from a colour census rather than a spatial downsample,
which blended one-pixel marks away before the quantiser could see them - Share build_clip_palette and quantize_to_palette between the rendered and
derived paths so both quantise identically - Stream the video in two passes instead of buffering it, keeping the
decoded RGB frames from ever being resident together - Warn when the source is chroma-subsampled, since that loss precedes the
palette and no quantiser can undo it - Add quantize_method for clips better served by a population-weighted split
- Validate writer inputs, close the decoder, resolve ffmpeg the same way for
reading as for writing, and classify pixel formats by family - Document the real memory cost: Pillow accumulates the quantised frames, so
peak stays proportional to the clip's length - Record in SCOPE.md why re-encoding cleopatra's own animation output is in
scope, and what still is not
Closes #315, #308
- feat(styling): add stamp_mark figure watermark / brand-mark helper (#314)
- Stamp a logo/watermark image onto a matplotlib Figure with one call,
sized as a fraction of the figure so it stays proportional across the
dpis a figure is exported at (MP4 master, web copy, GIF).
- - Draw on a frameless inset axes in figure-fraction coordinates (the
dpi-independent counterpart of Figure.figimage). frac sizes the
mark's longer side, so it is never distorted and always fits, in any
of the four corners; margin is a scalar or an (x, y) pair.
- Optionally composite a centred, gaussian-blurred halo behind the mark
(alpha-over into a single axes) so it separates from a busy or dark
canvas; blur is a fraction of the mark's unpadded width, and the
axes is grown so the mark's own painted extent still equals frac.
- Accept a file path (via Pillow) or an in-memory RGB/RGBA array (uint8
0-255 or float 0-1); reject out-of-contract inputs -- bad shape, a
non-uint8 non-float dtype, a float outside [0, 1] or with NaN/inf, a
zero-size image, or a margin that pushes the mark off-canvas -- with
clear ValueErrors.
- No new dependency (Pillow is already a base dependency; no SciPy).
- Add a docs reference page and a SCOPE.md note that reading a
presentation asset (a logo, not user data) is an allowed exception.
- Closes #312
- feat(glyphs): add TexturedGlobeGlyph for 3-D textured globes (#316)
- Add cleopatra's first 3-D glyph: wrap an equirectangular (lon/lat)
RGB(A) texture onto a tilted, spinnable sphere on a matplotlib Axes3D.
- Take an (H, W, 3)/(H, W, 4) equirectangular array (the north-up
layout of basemap.reference.relief()) and return (fig, Axes3D); a
standalone class like HistogramGlyph, not a Glyph subclass. - draw(spin=...) rotates the globe about its fixed tilted polar axis by
rotating only the once-sampled mesh; animate() returns a FuncAnimation
of a full rotation. - Normalize textures by dtype: integer by dtype max, float by their own
peak only when a channel exceeds 1 (RGB only, so alpha is preserved);
NaN and negative cells render black; unknown render options raise
ValueError. - Add no dependency (mpl_toolkits.mplot3d ships with matplotlib);
default mesh 180x90, with the quadratic render cost documented. - Add a full test suite (100% line + branch) and a reference doc page.
Closes #311 - feat(basemap): add world_texture() and mercator_to_equirectangular (#313) - Port the two generic basemap helpers from the earthlens satellite showcase notebook's inline basemap_texture into cleopatra.basemap.tiles. - - mercator_to_equirectangular(mosaic, bounds, n_lon, n_lat): a pure-NumPy area-averaging resample of a Web Mercator (EPSG:3857) tile mosaic onto an equirectangular lon/lat grid via np.add.reduceat. It reads the mosaic's own 3857 bounds and sizes each cell's divisor from the actual reduceat block widths so the poles do not seam. Returns float32 in the input value scale; the output always spans the globe, clamping out-of-coverage edges. No network, Pillow, or pyproj needed. - world_texture(provider, , zoom, n_lon, n_lat, cache, ...): the XYZ analogue of reference.relief -- fetches the whole 2*zoom world tile grid (zoom capped at 6), stitches, reprojects, and returns an (n_lat, n_lon, 3) float32 texture in [0, 1]. Accepts a provider name or a resolved TileProvider. Caches the texture under Config.get_cache_dir() with a guarded read that rebuilds a corrupt file and an atomic mkstemp write. Requires the [tiles] extra. - The earthlens-specific two-tone / ocean-land recolour stays in the notebook. - Closes #309, #310
0.32.0 (2026-08-17)#
- perf(array_glyph): count domain cells without materialising a per-cell index list (#305)
- Replace
len(get_indices2(frame, [np.nan])), which built one Python tuple per cell, with a pure-numpy, mask-aware reduction extracted intoArrayGlyph._count_domain_cells. -
- fixes the MemoryError when building a 4-D
(n, h, w, 3)RGB animation stack: the oldlen(shape) == 3frame pick passed the whole stack toget_indices2(~15 GB tuple list); the reduction is O(1) in Python objects and ~3000x faster on large frames
- fixes the MemoryError when building a 4-D
- count a stack on frame 0 and a single frame (2-D, or an
(h, w, 3)RGB image fromrgb_bands) whole, usingself.rgbto disambiguate the two 3-D shapes and fix a lone RGB image counting only its first row - keep the mask term so
exclude_value-masked cells stay excluded, byte-equivalent to the oldget_indices2(a plain~np.isnanwould over-count masked cells) - add regression tests (4-D, 3-D multi-frame, zero-domain, masked 2-D and
masked stack, single RGB image, integer dtype) and clarify the
num_domain_cellsdocstring - Closes #304
- fix(styling): drop pandas & numpy null sentinels in categorize (#303)
-
categorize's null filter recognised only None and np.nan, so pandas'
pd.NA / pd.NaT (and, on some numpy builds, datetime64('NaT')) survived
and became their own colour category -- making categorisation depend on
the column dtype. Treat every null flavour uniformly so the category set
depends only on the distinct real values (the in-glyph categorical path
delegates to categorize and inherits the fix). -
Drop pd.NA / pd.NaT via pandas' scalar pd.isna, and datetime64('NaT')
via np.isnan or np.isnat. - Keep pandas an undeclared soft dependency: import pd.isna lazily and
once (not per element), degrading to the numpy-only path (np.isnat for
datetime NaT) when pandas is absent -- no new runtime dependency. - Short-circuit str/bytes and guard np.isnan / pd.isna against array-like
object-array elements (e.g. range) so odd contents are carried through
as non-null rather than crashing, matching base behaviour. - Extract the check to a module-level _categorical_is_null helper to keep
categorize's cognitive complexity within bounds. - Add tests (pd.NA/pd.NaT, no-pandas fallback, all-null error, mixed null
kinds, numpy datetime NaT incl. the forced np.isnat path, genuine
pandas nullable dtypes, array-like element) and refresh the categorize
/ _is_null docstrings.
Closes #302
0.31.0 (2026-08-12)#
- chore: repo housekeeping and docs refresh for the grouped-parameter API (#298)
-
- untrack the maintainer-only tools/build_.py scripts (kept on disk) and broaden the ignore rule to tools/.py
- remove nbqa's leaked *_nbqa_ipynb.py temp files and ignore the pattern
- refresh the docs and README for the grouped render-parameter API: fix examples that used removed loose keywords, add a Render-options reference page, complete the migration guide, and fix a broken link
- rephrase historical change-log entries to drop vendored-source and library names
- Closes #299, #300
- refactor(glyphs)!: move render/prep logic onto the grouped parameter objects (#291)
- Cohere the grouped rendering/prep objects with the logic that consumes their fields (the ColorScaling.build_norm / to_options model): the object owns the transform, the glyph just calls it. Also groups ArrayGlyph's loose RGB band-prep keywords into a new RgbBands object.
-
- ColorBar owns to_options(), resolve(), reset_options(), and specifies_placement(); _resolve_colorbar just delegates
- PointOverlay.draw(), FrameLabel.resolve_location()/draw(), and PanelLabels.label_for()/panel_title()/validate() replace the inline plot/animate/facet logic; base Glyph._plot_point_values is removed
- add DataStyle.for_apply_style() and unify the hillshade "unset" sentinel, deleting three per-glyph duplicates
- add Glyph._snapshot_group_options() for the shared pre-merge option-snapshot (ArrayGlyph.plot and KDEGlyph.plot)
- group ArrayGlyph's rgb / surface_reflectance / cutoff / percentile constructor keywords into an RgbBands object owning validate() / prepare(); init drops from 10 to 7 explicit params
- fix the RGB surface-reflectance cutoff to clip each band's data, not the integer band index
- add unit tests + doctest examples for the new methods; migrate the examples, the array_glyph notebook, and add a migration-guide entry
- BREAKING CHANGE: ArrayGlyph no longer accepts the loose rgb / surface_reflectance / cutoff / percentile constructor keywords; pass rgb_bands=RgbBands([r, g, b], surface_reflectance=..., cutoff=..., percentile=...) instead. prepare_array() and scale_percentile() keep their loose keyword signatures.
- Closes #292, #293, #294, #295, #296, #297
0.30.0 (2026-08-11)#
- feat(styling)!: grouped render parameters and a large vendored preset-library expansion (#275)
-
Complete the migration of ArrayGlyph/MeshGlyph plot/animate/facet from
flat keyword arguments to typed group objects, and substantially expand
the vendored preset library. -
Finish the grouped-parameter render API: data_style=DataStyle(...),
color=ColorScaling(...), contour=Contour(...), cells=CellValues(...),
classify=Classify(...); remove the legacy-keyword shims so a moved
keyword now raises with a pointer to its group object. - Widen per-call styled-preset overrides (bands/alpha/alpha_range) onto
DataStyle: sticky across calls on a reused glyph, with correct opacity
mode-switch and clearing. - Vendor ~200 presets across seven libraries: 39 perceptually-uniform
scientific colour maps, 11 radar/satellite tables, 3 hypsometric
terrain ramps, and the weather library grown to 112 (+28 CAMS
atmospheric-composition and operational fields). - Add an optional [science-colors] extra for namespaced colour maps
(cmocean:thermal, ...) via the numpy-only cmap aggregator; no new
runtime dependency. - Add gallery notebooks for the new preset sets, committed output-free.
- Treat vendored colour data as derived under neutral-source naming; keep
the maintainer download scripts local-only.
BREAKING CHANGE: the legacy flat render keywords on ArrayGlyph/MeshGlyph
plot/animate/facet are removed. Pass them through the group objects
(DataStyle, ColorScaling, Contour, CellValues, Classify) instead; a
removed keyword now raises with a pointer to its group.
- Closes #288, #289
- refactor(glyphs)!: group plot/animate parameters into typed objects (#287)
- Replace the loose **kwargs surface on every glyph's plot() / animate()
with a small set of discoverable, typed parameter objects, and remove the
backward-compatibility shims that kept the old loose keywords working.
- bundle the loose plot() / animate() keywords into grouped objects:
color=ColorScaling, contour=Contour, cells=CellValues,
classify=Classify, data_style=DataStyle, points=PointOverlay,
frame_label=FrameLabel, colorbar=ColorBar - group facet's per-panel coordinate labels into PanelLabels and rename
facet's figsize to figure_size - remove the legacy loose-kwarg shims; a removed keyword now raises with a
pointer to its group object - rename text_colors to cell_value_text_colors and no_elem to
num_domain_cells
BREAKING CHANGE: the loose plot / animate styling keywords (point_color /
point_size / point_label_color / point_label_size / pid_color / pid_size,
label_location / label_color / text_loc, text_colors, col_coords /
row_coords, and facet's figsize) are removed and now raise; pass the
matching group object instead (PointOverlay, FrameLabel,
cell_value_text_colors, labels=PanelLabels, figure_size). ArrayGlyph.no_elem
is renamed to num_domain_cells, and a bare array for points must be wrapped
in a PointOverlay.
- Closes #281, #283, #285
- refactor(glyphs)!: group plot/animate parameters into typed objects (#274)
- Replace the flat ~30 keyword surface on every glyph's plot()/animate()
with a small set of discoverable, typed parameter objects, and remove
the backward-compatible shims that kept the old keywords working.
- - add ColorScaling owning norm/colorbar construction, with variants
linear/power/sym_log/boundary/midpoint
- add Contour, CellValues, DataStyle, and Classify group objects in
styling/params.py, each emitting only set fields via to_options()
- add PointOverlay and FrameLabel for point overlays and animation
frame labels
- group facet parameters into PanelLabels; rename facet figsize to
figure_size
- add base group-merge infrastructure (merge_group_params,
_reject_grouped_kwargs, _rollback_options_on_error): loose grouped
kwargs raise with a pointer, failed styled plots roll back
- remove all deprecation shims; rename text_colors to
cell_value_text_colors and no_elem to num_domain_cells
- reject conflicting style and data_style in templates.publication_map
- add a migration guide and wire it into the mkdocs nav
- BREAKING CHANGE: the flat styling keywords (color_scale, gamma, bounds,
midpoint, line_threshold, line_scale, levels, labels, display_cell_value,
num_size, background_color_threshold, style, hillshade, scheme, k,
point_color, point_size, point_label_color, point_label_size,
label_location, label_color, text_loc) are removed; pass the matching
group object instead. text_colors is renamed to cell_value_text_colors,
no_elem to num_domain_cells, and facet's figsize to figure_size. Passing
a bare array as points no longer works; wrap it in a PointOverlay.
- Closes #277, #278, #279, #280, #281, #282, #283, #284, #285
- feat(geo): add a relief backdrop to the dark reference map (#273)
- add_reference_map now draws a dimmed hypsometric relief backdrop beneath
the data when the resolved preset carries a "relief" entry -- present on
the dark map, absent on the chrome-only light map. The relief config accepts a
resolution string, an add_relief kwargs dict, or True (mirroring the
sibling _draw_basemap), and defaults crs to self.crs via _basemap_kwargs
so it warps to match non-EPSG:4326 data.
- The backdrop is skipped when the axes are not georeferenced, and an
environmental relief failure -- missing Pillow (the [tiles] extra), an
offline/uncached fetch, or a corrupt cache -- degrades with a warning
while the coastline/border chrome still draws, so the chrome never
hard-depends on the relief. A bad relief resolution in a custom preset
still raises loudly. The per-call resolution= knob affects only the
features, not the relief.
- Closes #216
- feat(config): add Config.get_cache_dir for the basemap cache directory (#272)
- Surface CLEOPATRA_CACHE_DIR — previously a bare os.environ read buried in
basemap/reference._cache_dir — as a single, discoverable Config method,
so it lives alongside set_matplotlib_backend where users look for
configuration.
- - resolve in order: a non-empty explicit path argument, then the
CLEOPATRA_CACHE_DIR environment variable, then the default
~/.cleopatra/naturalearth; a leading ~ is expanded
- treat any value that is None, empty, or whitespace-only (for both the
argument and the env var) as not provided, so get_cache_dir("") and
get_cache_dir(" ") behave like get_cache_dir(); Path("") is Path(".")
under pathlib and resolves to the current directory, as documented
- keep it a pure getter that only resolves the path; reference._cache_dir
delegates to it and retains the create-on-use mkdir, so config stays the
leaf owner of the setting and reference remains its sole consumer
- expand ~ in the env var, fixing a latent bug where CLEOPATRA_CACHE_DIR=
~/foo created a literal ./~/foo directory
- add unit tests (14 get_cache_dir scenarios, including the Path("") and
whitespace edges), a reference delegation/creation test, and a hermetic
doctest runner; document get_cache_dir on the config reference page
- Closes #253
- feat(array_glyph): accept colorbar=ColorBar on ArrayGlyph.facet (#271)
- Give ArrayGlyph.facet a colorbar: bool | ColorBar | None parameter
mirroring plot / animate, so the typed spec no longer falls into
kwargs and raises a ValueError. The colorbar is resolved per panel
through sub.plot(colorbar=...), so the shared apply_kwargs_and_colorbar
logic runs: the resolved spec wins over any loose cbar kwargs and sets
style_wants_colorbar, letting a placement-bearing colour bar override a
preset swatch on the faceted path just as on plot / animate. facet also
calls _warn_deprecated_cbar_kwargs so loose cbar deprecate uniformly.
colorbar=None (default) preserves the prior per-panel behaviour.
- - feat: facet(colorbar=bool | ColorBar | None), routed via sub.plot
- fix: warn on loose cbar* only after the col/row and structural
validation, so a malformed call raises without a spurious warning
- refactor: collapse redundant multiple returns in is_notebook and the
Colors hex/rgb validators into single boolean expressions
- docs: document the facet colorbar param, True's sticky-cbar reset, and
that None keeps each panel's default colour legend (bar or swatch)
- test: TestFacetColorbar matrix (None/True/False/ColorBar/invalid,
per-panel application, orientation, precedence, vmin/vmax sharing,
preset-swatch override, single-emission deprecation, 4-D facets) plus
is_notebook branch coverage
- Closes #256
- refactor!: reorganize the package into glyphs/styling/basemap subpackages (#254)
- Split the flat src/cleopatra/ module layout into three concern-based
subpackages so the ~25 modules are navigable, and rename the histogram
glyph for accuracy. Behavior is unchanged throughout -- only import
paths (and one class name) move; every function, method, argument, and
return value keeps its name and semantics. Verified behavior-preserving
via byte-identical ASTs and the full test suite (2157 passed).
- glyphs/ -- chart-type building blocks, grouped by data model:
base/ (Glyph, animation, hillshade), gridded/ (array, mesh, vector),
primitives/ (scatter, line, polygon, flow), stats/ (histogram, kde) - styling/ -- colour/legend/presentation layer (styles, colors,
colorbar, palettes, perceptual) plus the data/ preset assets - basemap/ -- networked basemap/CRS helpers (geo, tiles, reference,
projection) - config and templates stay at the top level
- rename statistical_glyph -> glyphs/stats/histogram_glyph and the class
StatisticalGlyph -> HistogramGlyph (it only draws histogram-family
plots) - rewrite every internal import and all docs, notebooks, and tools to
the new paths; the package root still re-exports nothing - strip ~1300 redundant/rationale comment lines and simplify glyphs/base
(single-return helpers, hoisted Pillow import); promote pillow to a
core dependency - add docs/migration.md with the full old->new import map and an
automated-migration script for downstream packages
BREAKING CHANGE: all submodule import paths change, e.g.
from cleopatra.array_glyph import ArrayGlyph is now
from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph, and
cleopatra.statistical_glyph.StatisticalGlyph is now
cleopatra.glyphs.stats.histogram_glyph.HistogramGlyph. See
docs/migration.md for the complete mapping and a migration script.
Closes #268, #269, #270
- feat(styles): unified preset schema, science colormaps, glow, projection, rendering polish (#246)
- Land the outstanding style & preset workstreams, the preset schema
restructure, and the rendering polish surfaced in review.
- Unify all 111 presets onto one canonical on-disk schema with a single
loader; the in-memory DATA_STYLES is byte-for-byte unchanged. - Add a namespaced scientific-colormap resolver (cmocean: and other collections)
behind the optional [science-colors] extra. - Add a native line-glow primitive and glow= on LineGlyph/FlowGlyph.
- Add projection= (globe/flat) on ArrayGlyph and MeshGlyph, styled-globe
composition, scientific terrain and NCL/MeteoSwiss colour tables, and fix
the topography hinge mis-registration. - Add a units layer (convert_units), a shortName->style lookup, a NEXRAD
radar preset, THIRD_PARTY_NOTICES, and the publication_map composer. - Tighten auto-sized figures to their content, add a preset background
canvas with value-linked opacity, and create+seed the axes on demand
so basemap layers work before plot/animate. - Rewrite the preset example notebooks to the glyph builder flow.
Closes #247, #248, #249, #250, #251, #252, #257, #258, #259, #260, #261, #262, #263, #264, #265, #266, #267
0.29.0 (2026-08-03)#
- feat(glyphs): accept colorbar=ColorBar on every glyph, plus label_location validation and colorbar=True reset (#244)
-
Follow-ups to the ColorBar spec (#234/#235) that make it work package-wide
and harden two edges. -
Move ColorBar, _resolve_colorbar, _swatch_text_default,
_warn_deprecated_cbar_kwargs, and _DEPRECATED_CBAR_KWARGS into a lean,
glyph-independent cleopatra.colorbar module, re-exported from array_glyph
for back-compat. - Wire a colorbar: bool | ColorBar | None parameter into MeshGlyph (plot and
animate), FlowGlyph, KDEGlyph, ScatterGlyph, PolygonGlyph, and VectorGlyph;
each merges the resolved spec into its options and deprecates the loose
cbar_* kwargs. Non-breaking: existing add_colorbar params and MeshGlyph's
colorbar bool toggle keep working, and the merged spec is sticky like
ArrayGlyph (colorbar=True resets, False suppresses). - Reject an orientation-incompatible label_location up front (and at render,
against the resolved orientation, for the unpinned case) instead of crashing
in matplotlib; drop the invalid baseline/center_baseline label positions. - Make colorbar=True reset the whole caption/sizing cbar_* family to defaults
on a reused glyph, and honour a ColorBar(ticks_spacing=...) spec on
MeshGlyph rather than auto-overwriting it.
Closes #239, #241, #242
0.28.0 (2026-08-02)#
- feat(array_glyph): add ColorBar.orientation and deprecate the loose cbar_orientation kwarg (#240)
- Give ColorBar a typed orientation field so the colorbar orientation can be set through colorbar=ColorBar(...) instead of the loose cbar_orientation kwarg, and resolve the silent-override footgun behind #235.
-
- Map ColorBar.orientation onto cbar_orientation only when set; deprecate the loose cbar_orientation kwarg (it still works but warns).
- Warn at construction when an explicit orientation disagrees with a set location (location wins at render); validate orientation up front and at render so a typo raises an actionable error, not an opaque matplotlib one.
- Fix a crash: a horizontal inset colorbar with no location now derives its inset edge from the resolved orientation instead of the vertical layout.
- Reset a sticky orientation on colorbar=True, and draw a real colorbar for an orientation-only spec over a style preset instead of dropping it.
- Migrate the docstrings, numpydoc blocks, a plot example, and the tutorial notebook to the typed form, and extract the shared plot/animate kwargs+colorbar setup to remove duplication.
- Closes #235
- feat(array_glyph): complete the ColorBar spec with caption and sizing fields (#237)
- Add six caption/sizing fields to ColorBar (label, length, label_size, label_rotation, label_location, ticks_spacing) and map them through resolve_colorbar onto the internal cbar keys, but only when set, so a loose cbar_ value survives during the transition.
-
- Wire cbar_label_rotation into the colorbar label and honour ColorBar.length for inside (inset) colorbars; both were silent no-ops.
- Stop the auto-computed tick spacing from clobbering a ColorBar(ticks_spacing=...) value in plot() and animate().
- Deprecate the loose cbar_* and ticks_spacing kwargs in favour of colorbar=ColorBar(...) via a DeprecationWarning; they still take effect during the deprecation window.
- Migrate the plot/animate docstrings, the ArrayGlyph tutorial notebooks, and the tests to the typed spec; add render-level tests.
- Use numpy.random.Generator in the migrated notebook example (SonarCloud S6711).
- Closes #234
- refactor(tiles): replace the mercantile dependency with built-in tile math (#236)
-
- Add a Tile NamedTuple plus _tiles_for_bbox()/_tile_xy_bounds()/
_lonlat_to_tile_xy(), a direct port of mercantile's tile(),
tiles(), and xy_bounds() (the only three things cleopatra.tiles
used from it), verified formula-for-formula against mercantile's
own installed source.
- Add a Tile NamedTuple plus _tiles_for_bbox()/_tile_xy_bounds()/
- Drop mercantile from the [tiles] extra, uv.lock, and docs/README;
update tests/test_tiles.py to mock the new internal function
instead of patching the mercantile module. - Clamp both latitude bounds symmetrically (north was capped but
south only floored, and vice versa) and nudge sin(lat) away from
the exact +/-90 singularity, closing a crash on any bbox whose
north or south sits within ~1e-7 degrees of a pole. - Split an antimeridian-crossing bbox (west > east) into its two
dateline-side sub-boxes instead of raising, matching
mercantile.tiles()'s own behavior -- needed because reprojecting a
near-global Web Mercator extent to EPSG:4326 wraps longitude at
the +/-180 seam, so this is a real, reachable input through
add_tiles(), not just a hand-crafted edge case. - Add 30+ direct unit tests for the tile-math functions and Tile's
NamedTuple behavior, with expected values cross-checked against
the real mercantile package, plus an end-to-end add_tiles() test
exercising the reprojection-driven antimeridian wraparound. - Fix a tautological-looking test assertion (same expression on
both sides of ==) flagged by SonarCloud.
No public API changes; add_tiles/fetch_tiles/stitch_tiles/get_provider
keep their existing signatures and behavior.
Closes #238
- ci(docs): resolve release tag for workflow_run-triggered mkdocs deploys (#233)
- deploy-release hardcoded trigger: 'release' but never supplied a
release-tag, so mkdocs-deploy could only resolve the version from
github.event.release.tag_name -- populated only on a real release
event. This job normally runs via workflow_run instead, so that
payload is absent and the deploy fails outright, as it did for the
0.27.0 release.
- - Check out the branch the release actually ran on
(workflow_run.head_branch) instead of the default trigger ref
- Extract the version from the just-bumped pyproject.toml as a
fallback source for the release tag
- Pass release-tag with a fallback: release event payload first,
extracted version otherwise
- Guard the workflow_run branch of the job condition against forks
- Mirrors the existing working implementation in pyramids.
- ci(docs): resolve the release tag for workflow_run-triggered deploys
- deploy-release hardcoded trigger: 'release' but never supplied a
release-tag, so mkdocs-deploy could only resolve the version from
github.event.release.tag_name -- which is only populated on a real
release event. Since this job is normally reached via workflow_run
(github-release completing), that payload is absent and the job
fails outright, as it did for the 0.27.0 release.
- Mirror the fix already used in pyramids: check out the branch the
release actually ran on instead of defaulting to the trigger ref,
extract the version straight from the just-bumped pyproject.toml,
and fall back to it whenever the release event payload isn't
available. Also guard the workflow_run branch of the job condition
against forks, matching pyramids.
0.27.0 (2026-08-01)#
- ci: pin workflow action versions and tighten CI concurrency (#231)
- Harden the GitHub Actions workflows by resolving every action reference to an explicit, verified version instead of a moving target, and stop wasted CI time on superseded pull request pushes.
-
- Bump actions/checkout (v5 -> v7.0.1) and codecov/codecov-action (v5 -> v7.0.0), both two majors behind
- Pin every serapeum-org/github-actions composite action to a specific released version instead of the floating v1 major tag
- Replace every tag reference with the full commit SHA it resolves to, keeping the version as a trailing comment
- Fix mkdocs-deploy using an unpinned @main ref in two of its three jobs while the third used a pinned tag
- Pin the uv version installed in tests.yml and github-release.yml to 0.12.1 to match the local dev environment
- Group tests.yml by PR number with cancel-in-progress so a new push cancels its own stale run, while push events to main are grouped by commit SHA so they always run to completion
- feat!: add a perceptual palette system and richer plotting controls (#220)
-
- perceptual: a numpy-only sRGB<->CIELAB toolkit -- interp_perceptual / perceptual_colormap (exact endpoints preserved), make_diverging (two lightness-balanced Lab arms), make_categorical (the glasbey max-min method) and a perceptual_uniformity diagnostic
- palettes: one Palette record + PaletteKind registry with a kind-driven default_norm and preview_palettes(); the haze / CAMS-AOD / flame families now build in CIELAB and register at import
- colors: cmocean ocean & weather data-style presets, continuous ramps re-interpolated in CIELAB, and a defaults < preset < explicit style precedence
- array_glyph/glyph: a ColorBar spec (location / inside / box / label_color / tick_color) replacing the separate cbar_* kwargs, a FrameLabel with its own size, and a full_bleed chrome-free layout
- geo: a basemap= parameter with typed Basemap / Feature specs, CRS-aware relief warping, and an opt-in mis-georeference check
- animation: one shared GIF palette across frames with pure black and white reserved, so date labels and colorbar ticks stay crisp
- tooling: a ruff / mypy / bandit lint stack, http(s)-only urlopen and a path-traversal guard in the asset builders, and NumPy-only example notebooks
- Closes #218, #219, #225, #226, #227, #228, #229, #230 Refs #216 BREAKING CHANGE: the weather/ocean data-style preset keys were renamed from GRIB shortNames to descriptive names; update any code that looks up presets by the old keys.
- refactor(data)!: restructure the weather/ocean preset system and rename its keys (#217)
-
- Merge the Magics and reference preset libraries into one weather_presets.json with a single loader, and rename every weather preset key from a raw GRIB shortName to a descriptive slug (e.g. 2t -> temperature_2m).
- Drop dead per-vendor metadata (_meta, source_style, continuous_colormap) never read by any loader, and fix a silent bug where colors.py pointed at the pre-rename ocean presets file.
- Migrate lint/type tooling to Ruff, add a mypy hook, and fix the 194 pre-existing type errors it surfaced.
- Close bandit and SonarCloud security findings (restrict urlopen to http(s), guard build-script output paths against path traversal, parse URL schemes properly) without suppressing any of them.
- Serialize the mkdocs deploy-pr/deploy-main jobs to stop them racing gh-pages pushes.
- BREAKING CHANGE: DATA_STYLES weather presets are keyed by descriptive names now, not GRIB shortNames (e.g. "temperature_2m" instead of "2t"). Closes #221, #222, #223, #224
- feat(reference): make add_relief honor the axis CRS (#214)
- add_relief now respects the CRS of the data on the axis, so the relief lines up under the plot instead of being stretched or misprojected.
-
- Crop a lon/lat extent that lies within the global bounds out of the relief array and draw it at that box, instead of stretching the whole globe into it (the issue #177 footgun). extent=None and out-of-bounds extents keep the previous whole-image placement.
- Add a keyword-only crs= parameter. A None or EPSG:4326 axis runs the lon/lat path unchanged, with no pyproj import; any other CRS warps the global relief into the axis CRS -- an output grid over the axis view is inverse-transformed to lon/lat via pyproj, sampled per pixel, and cells outside the CRS domain are left transparent.
- GeoMixin.add_relief defaults crs to self.crs (via _basemap_kwargs), matching add_features and add_tiles; an explicit crs= still wins and an unset self.crs preserves the prior behaviour.
- No new dependencies (pyproj already ships in the [tiles] extra) and no GDAL.
- Closes #177
0.26.1 (2026-07-18)#
- fix(glyphs): remove orphaned render artists on repeated plot()/animate() calls (#211)
-
- Track each Axes' prior render artists via a shared marker so a second
plot()/animate() call — same glyph instance, or a different glyph
sharing the Axes viaax=/fig=— removes them instead of leaving
them attached and undriven.
- Track each Axes' prior render artists via a shared marker so a second
- Extend cleanup to ArrayGlyph, MeshGlyph, StatisticalGlyph, and
VectorGlyph: colorbars, frame-label text, point/cell-value overlays,
and streamplot arrowheads (never actually attached via the returned
collection, so removed by diffing ax.patches instead). - Defer cleanup until after each call's own input validation succeeds,
so a failed call (e.g. an invalid color_scale, or a mismatched color
list) no longer destroys a valid prior render before propagating its
exception. - Tolerate an artist already partially removed by ax.clear() or a
prior apply_style() call instead of crashing on the second removal
attempt. - Add direct unit tests for the shared cleanup helpers and regression
tests covering same-instance repeats, cross-glyph shared axes, and
validation-failure paths across all four glyph classes.
Closes #210
0.26.0 (2026-07-16)#
- refactor(array_glyph)!: clean up plot()/animate()'s kwarg API (#207)
-
- Rename pid_color/pid_size to point_label_color/point_label_size,
animate()'s text_colors to cell_value_text_colors, and text_loc to
label_location
- Rename pid_color/pid_size to point_label_color/point_label_size,
- Bundle the five point-overlay parameters into a new PointOverlay
class and animate()'s two frame-label parameters into a new
FrameLabel class - Type the remaining **kwargs on both methods via TypedDict + Unpack
(PEP 692), inert at runtime - Keep every removed name/shape working via **kwargs behind a
DeprecationWarning, resolved before the strict kwargs validation - Fix bugs found during two rounds of adversarial review: a silent
positional-arg drop, wrong warning stacklevels, a both-given
conflict false-negative, a missing precision field, and a crash on
deprecated point kwargs passed without points - Update the example notebooks to the new PointOverlay/FrameLabel API
BREAKING CHANGE: point_color, point_size, pid_color, pid_size, and
animate()'s label_color are no longer explicit parameters. Keyword
calls still work via a deprecated alias with a warning; positional
calls to these slots now bind to the wrong parameter or raise
TypeError.
Closes #208
- feat(styles,glyphs): distinct-value categorical colouring for PolygonGlyph/ScatterGlyph (#206)
- Add styles.categorize(values, cmap="tab10") -> (categories, colors), the
distinct-value counterpart to classify(): one colour per unique value,
sorted when sortable, cycling past the cmap's size, nulls dropped.
Wire a "categorical" scheme into the shared Glyph scalar-mapping
pipeline: _prepare_categorical_mapping builds a ListedColormap +
BoundaryNorm over per-element integer class codes, and
create_categorical_legend draws a disjoint_legend in place of a
colorbar. PolygonGlyph and ScatterGlyph opt in via
_SUPPORTS_CATEGORICAL_SCHEME; VectorGlyph/FlowGlyph still accept scheme
for continuous classification but reject "categorical" with a clear
error.
- Validate the full values shape (not just its first dimension) in
PolygonGlyph, closing a silent colour-array mis-sizing bug - Reset cbar/category_legend unconditionally on every plot() call so a
scheme-switching re-plot never leaves a stale reference - Fall back to a qualitative cmap when the caller left cmap at the
glyph's own continuous default, matched by resolved name so a
Colormap object is caught the same as the equivalent string - Re-attach the categorical legend via ax.add_artist() before drawing
ScatterGlyph's size legend, since Axes.legend() is single-slot per
axes and would otherwise silently evict it - Add category_legend_kwargs (mirroring size_legend_kwargs) to
reposition/restyle the disjoint legend - categorize() raises the documented TypeError for non-hashable
entries and documents the int/bool/float dedup collision
Closes #204
0.25.0 (2026-07-12)#
- feat(glyphs): public apply_style() to (re)apply a data-style preset on an existing glyph (#203)
-
Add a discoverable way to (re)apply a DATA_STYLES preset on a glyph
instance that already exists, so a caller holding a rendered glyph can
restyle it by name without rebuilding it. -
Add apply_style(name, **kwargs) and a style read-back property to
ArrayGlyph, MeshGlyph, and KDEGlyph. apply_style re-renders the glyph
in place on its own axes (or a fresh figure if never plotted or the
figure was closed) and forwards extra kwargs (hillshade, ...) to plot. - Unify style persistence across the three glyphs: an applied style is
sticky (survives a plain plot()) and clearable (plot(style=None)).
MeshGlyph tracks the current style and restores it after its options
reset; KDEGlyph uses a typed _Unset sentinel so style=None can clear. - Validate a preset name before persisting or clearing, and roll back on
a bad/categorical name, so a typo can't wipe the render or brick the
glyph for later plain plots. - Add a shared Glyph._reset_axes_for_restyle helper (root-figure aware,
reuses an existing axes) and copy MeshGlyph's cached data (preserving a
masked array's mask). Honour a construction-time MeshGlyph style too. - Document the option and add an apply_style example to the ArrayGlyph,
MeshGlyph, and KDEGlyph notebooks.
Closes #202
0.24.0 (2026-07-12)#
- feat(glyphs): add a data-style preset option across the surface glyphs (#200)
-
Add a
styleoption that renders a named cleopatra DATA_STYLES preset
(colormap, norm, transparent nodata, value-linked opacity, and a
categorical legend) so a field can be visualised by name rather than by
hand-built colour settings. -
ArrayGlyph: plot() delegates to apply_data_style; animate() reproduces
the preset per frame for both continuous and categorical presets. It
supports curvilinear coords, composes with hillshade (via the new
hillshade.shade_rgb), and presents a swatch / discrete legend
consistently across plot and animate. Unknown or multi-layer names,
RGB arrays, and overlay kwargs are guarded with clear errors or
warnings; integer-masked rasters are handled. - MeshGlyph: continuous presets override the tripcolor/tricontour
cmap+norm (composing with node-elevation hillshade); categorical
presets draw a discrete legend and mask out-of-range codes. - KDEGlyph: continuous presets colour the density; a categorical preset
raises a clear error. - Add hillshade.shade_rgb (light an already-coloured image) and promote
colors.resolve_style_norm, alpha_rgba, category_boundaries, and
resolve_single_layer_style to public API (private aliases kept). - Document the option in the glyph docstrings and add a style section to
the ArrayGlyph example notebook.
Closes #199
- feat(colors,glyphs): add reusable hillshade, style-preset libraries, and flow-raster palettes (#195)
- - Add a reusable cleopatra.hillshade module (shade_grid, shade_faces,
resolve_hillshade) and wire relief shading into ArrayGlyph, MeshGlyph,
and KDEGlyph via a hillshade option at construction and plot() time.
- Extend the DATA_STYLES preset system with vendored Magics (69)
and cmocean (15) libraries, CAMS AOD colormaps, and log, symlog,
diverging, and categorical norm handling in apply_data_style.
- Add flow_direction_d8 and flow_accumulation presets and a
draw_order="width" stream-order mode for FlowGlyph.
- Bundle the vendored preset JSON assets with upstream NOTICE/LICENSE
text, regenerated by tools/build_magics_presets.py and
tools/build_ocean_presets.py.
- Give swatch_legend a norm parameter so log/symlog legends sample the
gradient honestly.
- Cover the new behaviour with tests (incl. tests/test_hillshade.py) and
document it in the example notebooks and the mkdocs nav.
Closes #198
- fix(polygon_glyph): render visible outlines in outline_only mode (#196)
- PolygonGlyph.plot(outline_only=True) drew nothing under default
options: the "none" borderless-fill edgecolor was passed straight to
the outline PolyCollection, leaving an unfilled polygon with a
transparent edge. Substitute a new OUTLINE_EDGECOLOR ("black") when
edgecolor is left at its "none" default; an explicit edgecolor is
still honoured and the filled choropleth branch keeps its borderless
default. Add regression tests for the fallback, the no-values path, an
explicit colour, and the still-borderless filled branch.
Also refresh the documentation to the 0.23.0 API and fix stale or
incorrect content found in a full docs audit:
- document the geo/GeoMixin basemap methods, the reference module, the
haze data-styles in colors, and the orthographic globe presets in
projection across the README, docs/index.md, and reference pages - add swatch_legend and apply_blank_canvas (styles), and WebP / to_mp4
/ quality kwargs / bundled ffmpeg (animation), correcting the
projection "single stateless helper" and "no PROJ dependency" claims - correct the bar() docs (bar takes a single 1D series, not one series
per column) in the reference page and the LineGlyph docstring - note the [tiles] extra also powers reference and the projection
presets; fix the ArrayGlyph render kinds and netCDF4 note in the mesh
guide; add per-glyph example figures to the README - clean up mkdocs.yml (duplicate features/extensions, stray theme keys,
unused table-reader/tags plugins) and un-orphan the glyph-architecture
diagram, wiring it into the nav - fix the CODE_OF_CONDUCT project name and empty contact fields, and
align CONTRIBUTING with the commitizen workflow
Closes #197
0.23.0 (2026-07-09)#
- feat(colors,projection): add composable haze-style data and globe projection presets (#192)
-
- Add HAZE_COLORMAPS, DATA_STYLES["haze"], and apply_data_style() for
value-modulated-alpha colour layers with decoupled alpha/colour
ranges (a glowing "flame" rim effect) plus swatch legends.
- Add HAZE_COLORMAPS, DATA_STYLES["haze"], and apply_data_style() for
- Add alpha_scaled_image()/alpha_scaled_mesh() low-level rendering
primitives that apply_data_style is built on. - Add PROJECTION_STYLES and apply_projection_style() to reproject
gridded data onto an orthographic globe or leave it flat behind one
call, backed by orthographic_grid(), orthographic_grid_edges(),
orthographic_points(), orthographic_boundary(), and
orthographic_graticule(). - Add apply_blank_canvas() and swatch_legend() to cleopatra.styles.
- Add add_point_labels() to cleopatra.geo for plain dot-and-text
point/city labels, reprojectable onto a globe via
orthographic_points(). - Add a tutorial notebook demonstrating every primitive independently
and composed, including an animated multi-day globe view. - Cover every new primitive with tests, guarding the pyproj-backed
globe helpers with pytest.importorskip so the suite still passes
without the [tiles] extra installed.
Closes #193 - fix: stop animate() label clipping and execute docs notebooks in CI (#190) - - Wire notebook execution into all three mkdocs-deploy jobs (deploy-pr/main/release) so docs/notebooks/ render fresh, real output during the build instead of whatever was last committed by hand. - Add an nbstripout pre-commit hook so notebooks never carry baked-in outputs in git history again. - Fix ArrayGlyph.animate()'s default text_loc: it clipped the frame label under the array's inverted Y-axis for any shape. The default now anchors via axes-fraction coordinates with top alignment; an explicit text_loc keeps the prior data-coordinate behavior unchanged. - Add a keyword-only label_color parameter to animate() for the frame label's color. - Retry transient network failures in reference._download with exponential backoff, so the two live-data example notebooks no longer hard-fail the docs build on a flaky fetch. - Reformat most of src/cleopatra/.py and tests/.py (line-wrap and isort); no behavior change. - Closes #191
0.22.0 (2026-07-07)#
- feat(animation): add save_animation quality controls, WebP, and bundled ffmpeg (#188)
-
Rework save_animation (and the Glyph.save_animation wrapper) from a
minimal two-line writer into a configurable, robust exporter, resolving
the rough edges surfaced in issue #185. -
Replace the declared-but-unused ffmpeg-python dependency with
imageio-ffmpeg, and fall back to its bundled static binary when no
system ffmpeg is on PATH, so MP4/MOV/AVI export works out of the box. - Auto-pad odd frame dimensions (-vf pad) so libx264 no longer crashes
on odd-sized figures, and set pix_fmt=yuv420p for universal playback. - Add keyword-only crf, bitrate, codec, preset, pix_fmt, dpi, optimize,
loop, and extra_args controls, with crf and bitrate mutually
exclusive and a caller -vf/-pix_fmt merged into the built arguments. - Drop the hardcoded bitrate=1800 default in favour of libx264's
constant-quality default; existing 3-arg calls stay valid but encode
smaller, better files. - Add animated WebP output and optimise/loop-configurable GIF output
via an _OptimizedPillowWriter subclass. - Add to_bytes and to_mp4 in-memory render helpers alongside the
existing to_gif/embed_gif; to_gif now delegates to to_bytes. - Forward the new controls through Glyph.save_animation so ArrayGlyph
and MeshGlyph inherit them.
Closes #185
- feat(geo): add a reference-map style preset for georeferenced glyphs (#187)
- Add GeoMixin.add_reference_map(style=...) so the ~15-line weather-centre
map recipe (grey Natural Earth coastline + borders, a dashed lon/lat
graticule, degree labels, a subtle frame) is a single call on top of a
plotted, georeferenced glyph.
- Light-background and dark-field reference-map presets (lighter greys so
coastlines stay visible over a dark field), plus style="auto" that
picks between them from the displayed luminance (im.to_rgba through the
colormap/norm; only opaque cells count, so light no-data fields are not
misread as dark; the target axes' image is preferred over self.im). - extent=[xmin, ymin, xmax, ymax] (the ArrayGlyph order) georeferences
the image and axis limits, handling the pixel-coordinate RGB/animate
case; a warning fires when the glyph has no geographic extent. - Degree formatters label the +/-180 antimeridian as "180"; _nice_step
covers sub-degree to 90-degree spacing; graticule_step is validated as
positive and finite; extent length is validated with a clear message. - available_map_styles() and the REFERENCE_MAP_STYLES table expose and
allow copying the presets; the geographic knowledge (deriving extent
from a dataset geotransform) stays upstream.
Add a runnable docs notebook (docs/notebooks/array_glyph/reference_map)
wired into the mkdocs nav, and TestAddReferenceMap plus a non-mocked
integration test (geo.py coverage 98%).
Closes #184 - fix(tiles): align web-tile basemap for non-EPSG:3857 data (#186) - For a non-EPSG:3857 axis, add_tiles stretched the stitched Mercator mosaic onto the data bounds, discarding the mosaic's tile-snapped (larger) coverage and offsetting the basemap by up to hundreds of km at coarse zoom (e.g. a curvilinear ROMS field over the Gulf of Mexico). - - Place the mosaic at its own geographic footprint: reproject its Web-Mercator bounds (extent_3857) into the target CRS with edge densification and use that as the imshow extent, keeping set_xlim/ylim at the data bounds. If a coarse mosaic overflows a limited-domain CRS (e.g. a whole-world mosaic into a UTM zone or a singular projection), the reprojection is caught and the basemap falls back to the data bounds with a warning, so a figure is still produced. - Add an auto_zoom floor via a new min_tiles_across parameter (default 2, also exposed and validated on add_tiles): pick the smallest zoom at which the larger extent spans at least that many tiles, so a mid-range region is no longer rendered from one or two coarse tiles (global z0->z1, Berlin z10->z11, Gulf z6->z7). min_tiles_across=1 restores the old heuristic and MAX_TILES still caps the tile count. - Update the module and reference-doc notes; add tests for the reprojected/enveloping extent, the overflow fallback, and the auto_zoom floor and its validation. - The auto_zoom floor applies to every zoom="auto" call (all CRS, including EPSG:3857), so the default basemap fetches a slightly higher zoom and a few more tiles (bounded by MAX_TILES); a residual Mercator-vs-linear-axis nonlinearity remains for very large extents. - Closes #176
0.21.0 (2026-07-06)#
- fix(animation): accept os.PathLike in save_animation and create_from_image (#181)
-
save_animation derived the output format with str.rsplit, so passing a
pathlib.Path (idiomatic from pyramids' Path-everywhere Dataset API)
raised AttributeError: 'WindowsPath' object has no attribute 'rsplit'. -
Normalise paths with os.fspath and derive the extension via
os.path.splitext, so both str and os.PathLike work. - Widen the type hints to str | os.PathLike on animation.save_animation,
ArrayGlyph.save_animation, and Colors.create_from_image (the only two
public path-taking APIs; reference/tiles handle internal paths only). - Give a clear error when the path has no file extension, and lock the
tightened dotfile / extension-less rejection (.gif, dir/.gif, bare
gif) with a regression test. - Cover PathLike on the happy and error branches of every widened API.
Closes #180
0.20.0 (2026-06-26)#
- feat(geo)!: glyph axis CRS with defaulting and assignment-time validation (#178)
-
Let the geographic glyphs carry the CRS of their plotted data so reference
layers default to it, and make bad values fail fast. -
GeoMixingains acrsproperty (defaultNone).add_featuresand
add_tilesdefault theircrs=toself.crswhen omitted, so a caller
that records the axis CRS once (glyph.crs = 4326) gets correctly placed
layers without restating it; an explicitcrs=still wins and
self.crs is Noneis a pure pass-through. crslives onGeoMixin, not the baseGlyph, so non-geographic glyphs
are unaffected.add_reliefis excluded -- it has nocrsparameter
(relief is a fixed EPSG:4326 raster placed byextent).- The
crssetter validates on assignment:TypeErrorfor non
int/str/None (bool rejected),ValueErrorfor a non-positive EPSG code,
an empty string, or -- whenpyprojis installed -- an unresolvable CRS.
Strings are stripped and a bare numeric string ("4326") is normalised
to the int4326. Settingcrsnever requires the[tiles]extra; the
deep check is skipped (deferred to draw time) withoutpyproj. - Make
add_tilesoptions keyword-only aftersource(matching
add_features), socrscan no longer be passed positionally and the
default injection is unconditionally safe. - Hoist the
cleopatra.tiles/cleopatra.referenceimports to module top
and call via the module, removing the inline imports.
BREAKING CHANGE: cleopatra.tiles.add_tiles now accepts crs, zoom,
alpha, attribution, zorder, interpolation, timeout, retries,
user_agent and max_tiles as keyword-only arguments (everything after
source). Callers that passed any of these positionally must switch to
keyword arguments; ax and source remain positional.
Closes #177
0.19.0 (2026-06-22)#
- feat(geo): glyph convenience methods for basemaps (GeoMixin) (#172)
- Add cleopatra.geo.GeoMixin so the glyphs that plot geographic data can drop a basemap straight from the glyph, plus the supporting reference docs.
-
- GeoMixin exposes add_tiles / add_features / add_relief; each draws on the glyph's own axes (or an explicit ax=) and forwards all arguments to the standalone cleopatra.tiles / cleopatra.reference functions, which stay the single source of truth.
- Basemap modules are imported lazily, so importing a glyph never pulls in the optional [tiles] extra.
- ArrayGlyph, MeshGlyph, VectorGlyph, FlowGlyph, PolygonGlyph and ScatterGlyph inherit the mixin; the chart/statistical glyphs (LineGlyph, StatisticalGlyph, KDEGlyph) deliberately do not.
- docs: add a reference example notebook (network/[tiles] cells tagged nbval-skip), a geo.md page, embedded equal-aspect screenshots, and a discovery-helper note; correct the polygon note to PathCollection.
- Add tests covering inheritance gating, delegation, the ax= override, and the no-axes error.
-
- Closes #173
- Closes #174
0.18.0 (2026-06-14)#
- fix(changelog): generate on bump and backfill 0.8.0-0.17.0
- The release flow runs cz bump, which only writes the changelog when update_changelog_on_bump is set. That key was missing from the [tool.commitizen] config, so docs/change-log.md froze at 0.7.1 while 0.8.0-0.17.0 shipped (their notes only reached the GitHub Releases page).
-
- Add update_changelog_on_bump = true so cz bump regenerates and commits the changelog on every future release.
- Backfill the missing 0.8.0-0.17.0 sections (regenerated with commitizen from the release commits on main; the 0.1.0-0.7.1 history is unchanged).
- feat(array_glyph): animate RGB / true-colour stacks (#169)
- ArrayGlyph.animate previously handled only 2-D single-band frames, so producing an RGB time-lapse meant abandoning cleopatra and hand-rolling matplotlib's FuncAnimation. It now also accepts a 4-D (time, rows, cols, 3|4) RGB/RGBA stack:
-
- 4-D stacks render each frame through imshow as true colour, with no norm/colormap/colorbar (self.cbar stays None), mirroring plot()'s RGB branch.
- The lazy data_getter path accepts RGB frames whose spatial dims (first two axes) match the template's last two axes.
- display_cell_value and background_color_threshold are skipped for RGB frames (per-cell annotation needs a scalar field).
- The 3-D single-band path is unchanged; the no-time-axis error now names both the 3-D and 4-D accepted shapes.
- Add TestAnimateRGB covering 4-D RGB/RGBA stacks, GIF rendering, lazy RGB frames, bad-shape errors, display_cell_value being ignored, and the unchanged single-band path. Update the animate docstring/doctest and add a docs/notebooks/array_glyph/rgb_animation.ipynb example wired into the mkdocs nav.
- Closes #168
0.17.0 (2026-06-07)#
- feat(reference): add Natural Earth and relief basemap helpers (#166)
- Add
cleopatra.reference, a matplotlib map-decoration layer that draws fixed public reference data under a plot — the cartopyax.coastlines()/GeoAxes.stock_img()niche and the vector/raster sibling ofcleopatra.tiles. -
add_features(Natural Earth coastline/borders/land/ocean/rivers/ lakes across 110m/50m/10m) andadd_relief(global hypsometric backdrop, low/medium) axes helpers, plus rawnatural_earth/reliefaccessors and discovery helpers.
- Assets are fixed public datasets re-hosted on a cleopatra-owned
basemap-data-v1release (gzipped GeoJSON + PNG); no GDAL/geopandas and no dependency on pyramids. Features in EPSG:4326 need only numpy+matplotlib; relief decode andcrs=reprojection use the existing[tiles]extra. - Downloads are http(s)-only, streamed atomically, cached under
~/.cleopatra/naturalearth(overrideCLEOPATRA_CACHE_DIR), and self-heal a corrupt/poisoned cache. - Polygon layers render hole-aware via compound paths (nonzero fill), so
oceancontinent cut-outs are correct; reprojection drops non-finite vertices at projection singularities; invalidcrsraises a clearValueError. - Add
tools/build_basemap_assets.py, the offline maintainer script that builds the artifacts from upstream Natural Earth shapefiles and relief GeoTIFFs. - Document usage and migration from
pyramids.basemap.natural_earth/relief; amendSCOPE.mdto record thetiles/referencebasemap helpers as the deliberate networked exception. - Tests reach 100% line and branch coverage of
cleopatra.reference(50 tests) with 47 passing doctests. - ref: #165
0.16.0 (2026-06-06)#
- feat(glyphs ): add classification scheme, value-to-size, KDEGlyph, FlowGlyph (#162)
- Consolidates the geoplot/Digital-Earth upstream tasks (#154–#157) into the shared glyph subsystem, all pure numpy + matplotlib with no new dependency.
-
- Categorical colour
scheme(quantiles, equal_interval, percentiles, std_mean, explicit edges, and a native Fisher-Jenks natural-breaks optimisation) viastyles.classifywired into the sharedGlyph._prepare_scalar_mapping, so every norm-driven glyph (Scatter/Polygon/Vector/Flow) gains discrete classes and a stepped colorbar. Array/Mesh/KDE rejectschemerather than ignore it.
- Categorical colour
ScatterGlyphvalue-to-size scaling with an optional size legend, factored into the reusablestyles.resolve_sizeshelper.FlowGlyph: magnitude-coloured, width-scaledLineCollectionfor flow/Sankey maps, reusingresolve_sizesfor line width.KDEGlyph: numpy-only 2-D Gaussian KDE drawn as filled/line contours with an optional clip path and memory-chunked evaluation.- Fisher-Jenks runs natively (exact O(k·n^2) DP, mean-centred for numeric
stability) and falls back to a quantile sample above
MAX_JENKS_N. - ref: #154, #155, #156, #157
0.15.0 (2026-06-02)#
- feat(mesh_glyph): inline labels for line tricontours via labels argument (#152)
- Add
labels(bool) andlabel_kw(dict) options toMeshGlyph.plotso node line tricontours can carry inline numeric labels throughax.clabel, the unstructured mirror ofArrayGlyph's contour labels (#148/#149). TheTriContourSetmappable was already returned, but every caller had to re-roll the sameax.clabelglue. -
- labels=True (location="node", filled=False) draws inline labels (defaults inline=True, fontsize=8, fmt="%g") and stores the Text artists on self.contour_labels
- label_kw is merged over those defaults and forwarded to ax.clabel, so user keys win on collision
- documented no-op for tripcolor (face data) and tricontourf (filled=True); a labelled line set with no isolines yields an empty list
- contour_labels resets to None on every plot() and animate() render, so re-plotting without labels (or switching to filled/animation) clears stale label artists
- complete the node_x/node_y/n_faces/n_nodes/n_edges property docstrings and add TestContourLabels plus coverage for the render/animate option branches (mesh_glyph coverage 96% -> 98%)
- Closes #151
0.14.0 (2026-06-02)#
- feat(array_glyph): inline contour labels via plot(kind="contour", labels=True) (#149)
- feat(array_glyph): inline contour labels via plot(kind="contour", labels=True)
Add labels (bool) and label_kw (dict) options to ArrayGlyph.plot so
line contours can carry inline numeric labels through ax.clabel, the way
Magics / cartopy and similar tools label isolines. Previously the
QuadContourSet was returned as the mappable but every caller had to
re-roll the same ax.clabel glue.
- - labels=True draws inline labels (defaults inline=True, fontsize=8,
fmt="%g") and keeps the Text artists on self.contour_labels.
- label_kw is merged over those defaults and forwarded to ax.clabel,
so user keys win on collision.
- labels is a documented no-op for contourf and every non-contour kind;
a labelled contour with no isolines yields an empty list.
- self.contour_labels resets to None on each render, so re-plotting
without labels (or switching kind) clears stale label artists.
- Docstring section + two doctests on plot(); TestContourLabels adds 8
cases covering draw/expose, default no-op, contourf no-op, both reset
paths, the degenerate empty-list case, and label_kw forwarding and
precedence.
- ref: #148.
0.13.0 (2026-06-01)#
- feat(animation): glyph-independent save/embed helpers for any FuncAnimation (#145)
- Expose cleopatra's animation save/embed machinery as glyph-independent
free functions in a new
cleopatra.animationmodule, so downstream packages and notebooks can reuse the writer/format handling on any matplotlibFuncAnimationinstead of re-rolling temp-file + writer +IPython.displayglue. -
- add
save_animation(anim, path, fps=2),to_gif(anim, fps=2), andembed_gif(anim, fps=2); the writer is chosen from the file extension (gif via PillowWriter, mov/avi/mp4 via FFMpegWriter)
- add
Glyph.save_animationnow delegates to the free function, removing the duplicated writer logic;SUPPORTED_VIDEO_FORMATmoves to the new module and is re-exported fromcleopatra.glyphfor back-compat- match the file extension case-insensitively (
out.GIFworks) - raise actionable errors: a missing FFmpeg points at ffmpeg.org, and a
missing IPython points at
pip install ipython(andto_gif), while a missing IPython sub-dependency is surfaced unchanged - import IPython lazily so importing cleopatra never requires it
- add an API reference page and register the
animationsubmodule in the package surface - cover the module with unit tests (100% line + branch) and executable doctests
- ref: #144
0.12.0 (2026-05-29)#
- feat(projection): add apply_projection_frame for static projected map frames (#142)
- Add a stateless, PROJ-free helper that turns a plain matplotlib Axes into a static projected ("globe") frame: it sets equal aspect and projected limits, draws the projection boundary as a PathPatch, draws graticule polylines, and optionally clips existing data layers to the boundary.
- All geometry (boundary vertices, graticule polylines, limits) is supplied as plain (N, 2) arrays, so the module has no PROJ/CRS dependency -- the upstream engine owns reprojection, cleopatra owns matplotlib.
-
- add cleopatra/projection.py with apply_projection_frame and the _as_xy input-coercion helper, validating axes, limits, and array shapes
- register the projection submodule in the package docstring and the package-surface allowlist test
- add a full test suite (100% line + branch coverage) plus an in-band doctest runner so the module examples are exercised by the default run
- add the projection reference page and wire it into the mkdocs nav
- Closes #141
0.11.0 (2026-05-28)#
- feat(glyph): colorbar toggles, mesh mappable, per-band RGB stretch, flat-data guard (#136)
- Close the remaining cleopatra composition gaps used by the Digital-Earth port.
-
- Add an
add_colorbaroption (default True) to ScatterGlyph/PolygonGlyph/ VectorGlyph and a plot-time override, so a shared-axes host can own one aggregated colorbar instead of one per layer. (MeshGlyph already exposes acolorbar=toggle; LineGlyph draws no colorbar.)
- Add an
- Expose the tripcolor/tricontour(f) artist as
MeshGlyph.im(cleared byplot_outline), mirroringArrayGlyph.imand the other glyphs. - Add a per-band percentile stretch to
ArrayGlyph.scale_to_rgb(per_band=True, default cut(2, 98)); the default global-max path is unchanged, guards an all-zero array, and maps NaN/flat bands to a flat zero band without warning. - Fix constant-value arrays raising
ZeroDivisionError:Glyph.get_ticksreturns a single tick for a degenerate range,ArrayGlyphfalls back to a unitticks_spacing, and a constant-field linecontourskips its empty colorbar with a warning. - ref: #61, #137, #138, #139
0.10.1 (2026-05-27)#
- perf(mesh): vectorize fan triangulation and edge derivation (#134)
- Replace the Python loops in MeshGlyph._fan_triangles and MeshGlyph._build_edge_segments with vectorized numpy index manipulation, removing the performance bottleneck on large mixed-element meshes (>100k faces).
-
- _fan_triangles: compact valid nodes in face order and build fan triangles with np.repeat plus fancy indexing, via a new _grouped_arange helper (handles zero-size groups); no per-face Python loop.
- _build_edge_segments: derive polygon edges with wrap-around indexing and deduplicate undirected edges via an int64 key encoding plus sort/diff instead of a Python set.
- Fix a latent bug where a pure-triangle mesh stored in a wider padded connectivity array leaked fill values into the triangulation; the fast path now only applies to a clean (n, 3) array.
- Add Google-style docstring examples for the vectorized helpers and expand the test suite (randomized equivalence vs the original loops, pentagon/pure-quad fans, shared-edge dedup, grouped-arange edge cases, and a non-flaky performance guard).
- A 100k mixed-element mesh now triangulates and derives edges in well under 100ms; focus methods reach 100% line and branch coverage.
- ref: #102
0.10.0 (2026-05-27)#
- feat(glyph)!: standardize axes/figure API and expand glyph controls (#132)
-
Unify how the cleopatra glyphs bind to matplotlib axes/figures and round out ArrayGlyph's rendering surface, with a small pre-construction introspection API shared across all glyphs.
-
ArrayGlyph: store the colour-mapped artist on
self.imfor every kind (imshow/pcolormesh/contour/contourf/RGB) and add anadd_colorbaroption (default True) honoured by bothplotandanimate. - ArrayGlyph.plot: accept
axandtitle; resolve axes asplot(ax=)> constructor ax > fresh figure.figstays a construction-time binding (derived from the axes, never a plot arg). - Glyph: keep a constructor
axgiven withoutfig(derive the figure from it) and warn on a mismatchedfig/axpair; resolve the top-level figure across matplotlib versions (SubFigure-safe). - Glyph: add
option_keys()/filter_kwargs()classmethods plus a per-glyphDEFAULT_OPTIONSclass attribute so accepted option keys can be inspected and filtered before construction; StatisticalGlyph gains matching helpers. - Rename the array/statistical option dicts to
ARRAY_DEFAULT_OPTIONS/STATISTICAL_DEFAULT_OPTIONS(aligned with the other glyphs) with a backwards-compatibleDEFAULT_OPTIONSalias. - BREAKING CHANGE: StatisticalGlyph.boxplot/multiboxplot/stripes no longer
accept a
figargument; bind the figure at construction instead, e.g.StatisticalGlyph(values, fig=fig).boxplot(ax=ax). - Closes #128, #129, #130, #131
0.9.0 (2026-05-26)#
- feat: add matplotlib glyph primitives for new plot types (#117)
- Add generic matplotlib building blocks for point, vector, polygon, line, and statistical plots, each with tests and Google-style docstrings.
-
- glyph: add shared Glyph._prepare_scalar_mapping (+ _resolve_limits) so every colour-by-value glyph reuses one resolve-limits -> ticks_spacing -> norm/colorbar pipeline instead of re-deriving it; ArrayGlyph output is unchanged
- scatter_glyph: new ScatterGlyph for coloured/uncoloured point clouds
- vector_glyph: new VectorGlyph for quiver/barbs/streamplot coloured by magnitude, plus add_key (quiverkey)
- polygon_glyph: new PolygonGlyph for filled choropleths / outlines, geometry-agnostic (plain vertex arrays, no geopandas)
- line_glyph: new LineGlyph for line/bar/fill_between, and extend StatisticalGlyph with boxplot, multiboxplot, and stripes
- mesh_glyph: add filled=False to render node data as line tricontour
- styles: add disjoint_legend, colorbar_legend, and histogram_legend to complete the three reusable legend styles
- build: raise the matplotlib floor to >=3.9 to match the APIs used
- ref: #118, #119, #120, #121, #122, #123, #124, #125
- feat(statistical_glyph)!: compose histograms into caller fig/ax; drop implicit plt.show() (#111)
-
- add optional
fig/axparameters toStatisticalGlyph;histogram()resolves three composition modes: draw into a suppliedax(inferring its figure), add an axes onto an empty suppliedfig(raisingValueErrorif that figure already has axes), or create a new figure/axes when neither is given
- add optional
- switch histogram styling from pyplot (
plt.grid/xlabel/ylabel/xticks/yticks) to axes-level (ax.grid/set_xlabel/set_ylabel/tick_params) so labels land on the intended axes - remove internal
plt.show()fromStatisticalGlyph.histogram(),ArrayGlyph.plot(), andArrayGlyph.animate(); these now return theirFigure/Axes/FuncAnimationfor the caller to display or save - document the composition modes and no-show behavior in docstrings; fix stale
Statisticnaming and the module usage example - add tests for fig/ax injection, the populated-figure
ValueError, the no-show contract, a self-checking doctest runner, and validation guards (statistical_glyph at 100% coverage) - BREAKING CHANGE:
StatisticalGlyph.histogram(),ArrayGlyph.plot(), andArrayGlyph.animate()no longer callplt.show(). Code relying on the implicit display must callplt.show()itself (or save the returned figure/animation). - Closes #116
0.8.0 (2026-05-11)#
- feat!: Expand
ArrayGlyphplotting and add thecleopatra.tilesmodule (#112) - xarray-aligned plotting features, a new web-tile basemap module, plus bug fixes and packaging cleanups from PR review.
-
- ArrayGlyph: plot(kind=imshow/pcolormesh/contour/contourf), colour kwargs (robust/center/levels/extend/cbar_kwargs), coords=(x, y) curvilinear grids, facet(col=, row=, col_wrap=, extents=) -> FacetGrid, animate(data_getter=) lazy frames.
- New cleopatra.tiles module (add_tiles + helpers) behind the cleopatra[tiles] extra (mercantile, pillow, pyproj, xyzservices); cleopatra.styles.ColorScale enum.
- Fixes: facet mask preservation, animate(display_cell_value=True) IndexError, import-time matplotlib backend no longer touched, all-NaN colour-limit guard, broader tile image-signature acceptance, color_scale validation.
- Internal: single-backtick docstrings, CLAUDE.md untracked/gitignored, tests expanded.
- Refs: #113, #114
- BREAKING CHANGE: ArrayGlyph.no_elem -> num_domain_cells (deprecated alias kept); cleopatra.add_tiles / cleopatra.Config top-level re-exports removed (use the submodules); import cleopatra no longer sets the matplotlib backend; ArrayGlyph(all_nan_arr) and bad color_scale now raise ValueError.
0.7.1 (2026-04-09)#
fix#
- glyph: set
use_gridspec=Falsefor colorbar on subplot figures so colorbars allocate space locally instead of stealing from sibling axes (#109)
chore#
- remove dead boilerplate from
__init__.py(unused author metadata, empty hard-dependency checker, redundant module docstring) - replace deprecated
typing.Listwith builtinlistinarray_glyph.pyparameter annotations and docstrings
test#
- add subplot colorbar tests for single-axes, 1x2, 1x3, and 2x2
grid layouts in
test_glyph.py - add MeshGlyph subplot rendering tests in
test_mesh_glyph.py
docs#
- update README
0.7.0 (2026-04-08)#
feat#
- mesh: add MeshGlyph class for UGRID unstructured mesh visualization with tripcolor/tricontourf rendering, wireframe outlines via LineCollection, and mixed-element fan triangulation (#99, #100)
- mesh: add face-centered and node-centered data plotting with all 5 color scale types (linear, power, sym-lognorm, boundary-norm, midpoint) and full colorbar customization
- mesh: add
animate()for time-varying mesh data with frame-by-frame rendering and gif/mp4/mov/avi export - mesh: add
plot_outline()for wireframe rendering with optional explicit edge connectivity - extract
Glyphbase class fromArrayGlyphwith shared infrastructure: fig/ax lifecycle, color scale normalization, colorbar creation, tick management, point overlays, and animation saving (#101) - add input validation for constructor arrays, data length, all-NaN data, constant data, and animation frame consistency
fix#
- ci: correct PyPI release workflow (#94)
- fix ticks_spacing not propagated to
default_options, causing wrong colorbar ticks for non-default data ranges - fix colorbar accumulation on repeated
plot()calls - fix
default_optionsmutation leaking acrossplot()calls on the same instance - fix
save_animationsilently swallowingFileNotFoundErrorwhen FFmpeg is missing - fix
animproperty returningNoneinstead of raising when_animis not set - remove
plt.show()fromadjust_ticks()andanimate()to prevent blocking in interactive backends - fix broken image paths in reference documentation
build#
- packaging: migrate from setuptools/pip to uv and hatchling (#96, #97)
- replace setuptools build backend with hatchling
- convert
[project.optional-dependencies]to PEP 735[dependency-groups]for dev and docs groups - migrate all GitHub Actions workflows to composite actions with uv
- generate
uv.lockfor reproducible dependency resolution - update classifiers to Python 3.11/3.12/3.13, drop 3.10
docs#
- convert all package docstrings from NumPy to Google style (#103)
- add API reference pages for
GlyphandMeshGlyph - add unstructured mesh visualization guide
- add MeshGlyph example Jupyter notebook
- update
mkdocs.ymltodocstring_style: google - add missing type annotations to fix griffe/mkdocstrings warnings
test#
- add 105 new tests (45 Glyph, 60 MeshGlyph) with 100% line and branch coverage (#104)
- vectorize
_build_edge_segmentsand_map_face_to_triangle_valueswith numpy; add fast path for pure-triangle meshes - skip FFmpeg-dependent tests when FFmpeg is unavailable
- replace deprecated
typing.List/Tuple/Union/Dictwith Python 3.11+ builtins
chore#
- rename organisation from Serapieum-of-alex to serapeum-org (#98)
0.6.0 (2025-06-25)#
Dev#
- replace the setup.py with pyproject.toml
- convert the documentation to use mkdocs instead of sphinx.
- remove the CI test workflow based on conda.
- test the jupyter notebook in ci.
config#
- add a config file to the package to handle the configuration of the matplotlib backend.
- in the init.py file, load the config file and set the matplotlib backend to
Agg.
ArrayGlyph#
- rename the statistics module to statistical_glyph.
- move creating the ax, and fig from the constructor to the
plot/animatemethods . - create
arrproperty to access the array data. - create
apply_colormapmethod to apply a colormap to the array. - create
to_imagemethod to convert the array to an RGB image. - create
scale_to_rgbmethod to scale the array to RGB values. - create
adjust_ticksmethod to adjust the plot ticks.
colors#
- add
get_color_mapfunction to create a color map from a list of colors. - make the
_is_valid_rgb_norm, and_is_valid_rgb_255protected and the public method is onlyis_valid_rgb. - make the
_is_valid_hex_iprotected and the public method is onlyis_valid_hexto process single value and lists. - create a
create_from_imagefunction to create a color map from an image.
0.5.1 (2024-07-24)#
ArrayGlyph#
- the ArrayGlyph constructor uses a masked array instead of a numpy array.
0.5.0 (2024-07-22)#
ArrayGlyph#
- rename the
Arrayclass toArrayGlyph. - add
scale_percentilemethod to theArrayclass to scale the array using the percentile values. - the
statistic.histogramcan plot multiple column array. - change the
color_scalevalues to be string (linear, "power", ...) - the
kwargscan be provided to the constructor or theplotmethod to plot the array.
Colors#
- rename the
get_rgbtoto_rgb - add
get_typeto get the type of the color. - add
to_hexto convert the color to hex. - add
to_rgbto convert the color to rgb.
0.4.3 (2024-07-13)#
- Add extent to the array plot when plotting an rgb array.
- Add
ax, andfigparameters to theArrayconstructor method to take an Axes and plot the array on it. - Add
__str__to theArrayclass.
0.4.2 (2024-06-30)#
- Update dependencies
0.4.1 (2024-1-11)#
- add extent to the array plot.
0.4.0 (2023-9-24)#
- Add a colors module to handle issues related to
- Converting colors from one format to another
- Creating colormaps
0.3.5 (2023-8-31)#
- Update dependencies
0.3.4 (2023-04-26)#
- pass the plot kwargs to the init of the array to scale the color bar using the vmin and vmax.
0.3.3 (2023-04-25)#
- change the default value for the color bar label.
0.3.2 (2023-04-23)#
- bump up hpc version
0.3.1 (2023-04-17)#
- plot RGB plots
0.3.0 (2023-04-11)#
- change API to work completly with numpy array inputs
- chenge to conda config
- add hpc-utils to filter and access arrays
- restructure the whole modules to array, statistics, and styles modules.
- all modules has classes.
- save animation function using ffmpeg.
0.2.7 (2023-01-31)#
- bump up numpy to version 1.24.1
0.2.6 (2023-01-31)#
- bump up versions
- add serapeum_utils as a dependency
0.2.5 (2022-12-26)#
- plot array with discrete bounds takes the bounds as a parameter
0.2.4 (2022-12-26)#
- bump up numpy versions to 1.23.5, add pandas
0.1.0 (2022-05-24)#
- First release on PyPI.