Source code for glyphx.contour

"""
GlyphX ContourSeries — 2D filled contour plot (companion to Surface3D).

Renders isocontour bands on a regular grid, equivalent to
Matplotlib's ``ax.contourf()`` and ``ax.contour()``.  Works inside a
regular :class:`~glyphx.Figure` (not Figure3D).
"""
from __future__ import annotations

import math
import numpy as np

from .series    import BaseSeries
from .colormaps import apply_colormap, colormap_colors
from .utils     import svg_escape, _format_tick


[docs] class ContourSeries(BaseSeries): """ 2D filled contour plot — iso-lines and/or filled bands. Args: x: 1-D X grid values (length N). y: 1-D Y grid values (length M). z: 2-D Z matrix, shape (M, N). levels: Number of contour levels, or an explicit list of Z values. cmap: Colormap for fill bands. filled: Draw filled colour bands between levels. lines: Draw contour lines at each level. line_color: Color for contour lines when not filled. line_width: Contour line stroke width. alpha: Fill opacity. label: Legend label. """ def __init__( self, x, y, z, levels: int | list[float] = 10, cmap: str = "viridis", filled: bool = True, lines: bool = True, line_color: str = "#ffffff88", line_width: float = 0.8, alpha: float = 0.85, label: str | None = None, ) -> None: self.x_1d = list(x) self.y_1d = list(y) self.z_mat = np.asarray(z, dtype=float) self.cmap = cmap self.filled = filled self.lines = lines self.line_color = line_color self.line_width = float(line_width) self.alpha = float(alpha) z_min, z_max = float(self.z_mat.min()), float(self.z_mat.max()) if isinstance(levels, int): self._levels = [z_min + i * (z_max - z_min) / levels for i in range(levels + 1)] else: self._levels = sorted(levels) # BaseSeries domain — X is x_1d, Y is y_1d for Axes scaling all_x = list(x) + list(x) all_y = list(y) + list(y) super().__init__(x=list(x), y=list(y), color="#000", label=label)
[docs] def to_svg(self, ax: object, use_y2: bool = False) -> str: """Render filled contour bands using linear interpolation.""" scale_x = ax.scale_x # type: ignore scale_y = ax.scale_y # type: ignore elements: list[str] = [] nx = len(self.x_1d) ny = len(self.y_1d) # For each band between level[k] and level[k+1], shade cells n_bands = len(self._levels) - 1 band_colors = colormap_colors(self.cmap, max(n_bands, 2)) for band_idx in range(n_bands): lo = self._levels[band_idx] hi = self._levels[band_idx + 1] col = band_colors[band_idx % len(band_colors)] # Find grid cells that intersect this band for j in range(ny - 1): for i in range(nx - 1): zs = [ float(self.z_mat[j, i]), float(self.z_mat[j, i+1]), float(self.z_mat[j+1, i+1]), float(self.z_mat[j+1, i]), ] if max(zs) < lo or min(zs) > hi: continue # Compute pixel corners of this cell xs = [scale_x(self.x_1d[i]), scale_x(self.x_1d[i+1]), scale_x(self.x_1d[i+1]), scale_x(self.x_1d[i])] ys = [scale_y(self.y_1d[j]), scale_y(self.y_1d[j]), scale_y(self.y_1d[j+1]), scale_y(self.y_1d[j+1])] if self.filled: pts = " ".join(f"{px:.1f},{py:.1f}" for px,py in zip(xs,ys)) elements.append( f'<polygon points="{pts}" fill="{col}" ' f'fill-opacity="{self.alpha}" stroke="none"/>' ) # Draw contour lines at each level using marching squares (simplified) if self.lines: for lv in self._levels[1:-1]: segs = self._marching_squares(lv, scale_x, scale_y) for x0, y0, x1, y1 in segs: elements.append( f'<line x1="{x0:.1f}" y1="{y0:.1f}" ' f'x2="{x1:.1f}" y2="{y1:.1f}" ' f'stroke="{self.line_color}" ' f'stroke-width="{self.line_width}"/>' ) # Colorbar if self.filled: elements.append(self._colorbar_svg(ax, band_colors)) return "\n".join(elements)
def _marching_squares(self, level: float, sx, sy) -> list[tuple]: """Simplified marching squares: return line segments at 'level'.""" nx, ny = len(self.x_1d), len(self.y_1d) segs = [] for j in range(ny - 1): for i in range(nx - 1): # Cell corners: 0=BL, 1=BR, 2=TR, 3=TL z00 = float(self.z_mat[j, i]) z10 = float(self.z_mat[j, i+1]) z11 = float(self.z_mat[j+1, i+1]) z01 = float(self.z_mat[j+1, i]) px = [sx(self.x_1d[i]), sx(self.x_1d[i+1]), sx(self.x_1d[i+1]), sx(self.x_1d[i])] py = [sy(self.y_1d[j]), sy(self.y_1d[j]), sy(self.y_1d[j+1]), sy(self.y_1d[j+1])] def interp(za, zb, pa, pb): if zb == za: return ((pa[0]+pb[0])/2, (pa[1]+pb[1])/2) t = (level - za) / (zb - za) return (pa[0] + t*(pb[0]-pa[0]), pa[1] + t*(pb[1]-pa[1])) corners = list(zip( [z00, z10, z11, z01], [(px[0],py[0]),(px[1],py[1]),(px[2],py[2]),(px[3],py[3])] )) above = [z > level for z, _ in corners] code = sum(1<<k for k, v in enumerate(above) if v) edges = { 0:[], 15:[], 1:[0,3], 14:[0,3], 2:[0,1], 13:[0,1], 3:[1,3], 12:[1,3], 4:[1,2], 11:[1,2], 5:[0,1,2,3], 10:[0,1,2,3], 6:[0,2], 9:[0,2], 7:[2,3], 8:[2,3], } edge_pairs = edges.get(code, []) edge_verts = [ (0,1), (1,2), (2,3), (3,0) ] pts = [] for ei in edge_pairs: a, b = edge_verts[ei] za, pa = corners[a] zb, pb = corners[b] if (za > level) != (zb > level): pts.append(interp(za, zb, pa, pb)) if len(pts) == 2: segs.append((pts[0][0], pts[0][1], pts[1][0], pts[1][1])) return segs def _colorbar_svg(self, ax, colors: list[str]) -> str: """Vertical colorbar strip on the right side.""" from .utils import _format_tick bx = ax.width - 20 # type: ignore by = ax.padding # type: ignore bh = ax.height - 2 * ax.padding # type: ignore bw = 12 steps = len(colors) step_h = bh / steps items = [] for k, col in enumerate(reversed(colors)): ry = by + k * step_h items.append( f'<rect x="{bx}" y="{ry:.1f}" width="{bw}" ' f'height="{step_h + 0.5:.1f}" fill="{col}"/>' ) items.append( f'<text x="{bx + bw + 3}" y="{by + 8}" font-size="10" ' f'fill="{ax.theme.get("text_color","#000")}">' # type: ignore f'{_format_tick(self._levels[-1])}</text>' ) items.append( f'<text x="{bx + bw + 3}" y="{by + bh}" font-size="10" ' f'fill="{ax.theme.get("text_color","#000")}">' # type: ignore f'{_format_tick(self._levels[0])}</text>' ) return "\n".join(items)