Source code for bokeh.plotting.contour

#-----------------------------------------------------------------------------
# Copyright (c) 2012 - 2024, Anaconda, Inc., and Bokeh Contributors.
# All rights reserved.
#
# The full license is in the file LICENSE.txt, distributed with this software.
#-----------------------------------------------------------------------------

#-----------------------------------------------------------------------------
# Boilerplate
#-----------------------------------------------------------------------------
from __future__ import annotations

import logging # isort:skip
log = logging.getLogger(__name__)

#-----------------------------------------------------------------------------
# Imports
#-----------------------------------------------------------------------------

# Standard library imports
from typing import TYPE_CHECKING, Sequence, Union

# External imports
import numpy as np

# Bokeh imports
from ..core.property_mixins import FillProps, HatchProps, LineProps
from ..models.glyphs import MultiLine, MultiPolygons
from ..models.renderers import ContourRenderer, GlyphRenderer
from ..models.sources import ColumnDataSource
from ..palettes import interp_palette
from ..plotting._renderer import _process_sequence_literals
from ..util.dataclasses import dataclass, entries

if TYPE_CHECKING:
    from numpy.typing import ArrayLike, NDArray
    from typing_extensions import TypeAlias

    from ..palettes import Palette, PaletteCollection
    from ..transform import ColorLike

    ContourColor: TypeAlias = Union[ColorLike, Sequence[ColorLike]]
    ContourColorOrPalette: TypeAlias = Union[ContourColor, Palette, PaletteCollection, ContourColor]

#-----------------------------------------------------------------------------
# Globals and constants
#-----------------------------------------------------------------------------

__all__ = (
    'contour_data',
    'from_contour',
)

#-----------------------------------------------------------------------------
# General API
#-----------------------------------------------------------------------------

@dataclass(frozen=True)
class FillCoords:
    ''' Coordinates for all filled polygons over a whole sequence of contour levels.
    '''
    xs: list[list[list[np.ndarray]]]
    ys: list[list[list[np.ndarray]]]

@dataclass(frozen=True)
class LineCoords:
    ''' Coordinates for all contour lines over a whole sequence of contour levels.
    '''
    xs: list[np.ndarray]
    ys: list[np.ndarray]

@dataclass(frozen=True)
class ContourCoords:
    ''' Combined filled and line contours over a whole sequence of contour levels.
    '''
    fill_coords: FillCoords | None
    line_coords: LineCoords | None

@dataclass(frozen=True)
class FillData(FillCoords):
    ''' Complete geometry data for filled polygons over a whole sequence of contour levels.
    '''
    lower_levels: ArrayLike
    upper_levels: ArrayLike

    def asdict(self):
        # Convert to dict using shallow copy.  dataclasses.asdict uses deep copy.
        return dict(entries(self))

@dataclass(frozen=True)
class LineData(LineCoords):
    ''' Complete geometry data for contour lines over a whole sequence of contour levels.
    '''
    levels: ArrayLike

    def asdict(self):
        # Convert to dict using shallow copy.  dataclasses.asdict uses deep copy.
        return dict(entries(self))

@dataclass(frozen=True)
class ContourData:
    ''' Complete geometry data for filled polygons and/or contour lines over a
    whole sequence of contour levels.

    :func:`~bokeh.plotting.contour.contour_data` returns an object of
    this class that can then be passed to :func:`bokeh.models.ContourRenderer.set_data`.
    '''
    fill_data: FillData | None
    line_data: LineData | None

[docs] def contour_data( x: ArrayLike | None = None, y: ArrayLike | None = None, z: ArrayLike | np.ma.MaskedArray | None = None, levels: ArrayLike | None = None, *, want_fill: bool = True, want_line: bool = True, ) -> ContourData: ''' Return the contour data of filled and/or line contours that can be passed to :func:`bokeh.models.ContourRenderer.set_data` ''' levels = _validate_levels(levels) if len(levels) < 2: want_fill = False if not want_fill and not want_line: raise ValueError("Neither fill nor line requested in contour_data") coords = _contour_coords(x, y, z, levels, want_fill, want_line) fill_data = None if coords.fill_coords: fill_coords = coords.fill_coords fill_data = FillData(xs=fill_coords.xs, ys=fill_coords.ys, lower_levels=levels[:-1], upper_levels=levels[1:]) line_data = None if coords.line_coords: line_coords = coords.line_coords line_data = LineData(xs=line_coords.xs, ys=line_coords.ys, levels=levels) return ContourData(fill_data, line_data)
[docs] def from_contour( x: ArrayLike | None = None, y: ArrayLike | None = None, z: ArrayLike | np.ma.MaskedArray | None = None, levels: ArrayLike | None = None, **visuals, # This is union of LineProps, FillProps and HatchProps ) -> ContourRenderer: ''' Creates a :class:`bokeh.models.ContourRenderer` containing filled polygons and/or contour lines. Usually it is preferable to call :func:`~bokeh.plotting.figure.contour` instead of this function. Filled contour polygons are calculated if ``fill_color`` is set, contour lines if ``line_color`` is set. Args: x (array-like[float] of shape (ny, nx) or (nx,), optional) : The x-coordinates of the ``z`` values. May be 2D with the same shape as ``z.shape``, or 1D with length ``nx = z.shape[1]``. If not specified are assumed to be ``np.arange(nx)``. Must be ordered monotonically. y (array-like[float] of shape (ny, nx) or (ny,), optional) : The y-coordinates of the ``z`` values. May be 2D with the same shape as ``z.shape``, or 1D with length ``ny = z.shape[0]``. If not specified are assumed to be ``np.arange(ny)``. Must be ordered monotonically. z (array-like[float] of shape (ny, nx)) : A 2D NumPy array of gridded values to calculate the contours of. May be a masked array, and any invalid values (``np.inf`` or ``np.nan``) will also be masked out. levels (array-like[float]) : The z-levels to calculate the contours at, must be increasing. Contour lines are calculated at each level and filled contours are calculated between each adjacent pair of levels so the number of sets of contour lines is ``len(levels)`` and the number of sets of filled contour polygons is ``len(levels)-1``. **visuals: |fill properties|, |hatch properties| and |line properties| Fill and hatch properties are used for filled contours, line properties for line contours. If using vectorized properties then the correct number must be used, ``len(levels)`` for line properties and ``len(levels)-1`` for fill and hatch properties. ``fill_color`` and ``line_color`` are more flexible in that they will accept longer sequences and interpolate them to the required number using :func:`~bokeh.palettes.linear_palette`, and also accept palette collections (dictionaries mapping from integer length to color sequence) such as `bokeh.palettes.Cividis`. ''' levels = _validate_levels(levels) if len(levels) < 2: want_fill = False nlevels = len(levels) want_line = visuals.get("line_color", None) is not None if want_line: # Handle possible callback or interpolation for line_color. visuals["line_color"] = _color(visuals["line_color"], nlevels) line_cds = ColumnDataSource() _process_sequence_literals(MultiLine, visuals, line_cds, False) # Remove line visuals identified from visuals dict. line_visuals = {} for name in LineProps.properties(): prop = visuals.pop(name, None) if prop is not None: line_visuals[name] = prop else: visuals.pop("line_color", None) want_fill = visuals.get("fill_color", None) is not None if want_fill: # Handle possible callback or interpolation for fill_color and hatch_color. visuals["fill_color"] = _color(visuals["fill_color"], nlevels-1) if "hatch_color" in visuals: visuals["hatch_color"] = _color(visuals["hatch_color"], nlevels-1) fill_cds = ColumnDataSource() _process_sequence_literals(MultiPolygons, visuals, fill_cds, False) else: visuals.pop("fill_color", None) # Check for extra unknown kwargs. unknown = visuals.keys() - FillProps.properties() - HatchProps.properties() if unknown: raise ValueError(f"Unknown keyword arguments in 'from_contour': {', '.join(unknown)}") new_contour_data = contour_data(x=x, y=y, z=z, levels=levels, want_fill=want_fill, want_line=want_line) # Will be other possibilities here like logarithmic.... contour_renderer = ContourRenderer( fill_renderer=GlyphRenderer(glyph=MultiPolygons(), data_source=ColumnDataSource()), line_renderer=GlyphRenderer(glyph=MultiLine(), data_source=ColumnDataSource()), levels=list(levels)) contour_renderer.set_data(new_contour_data) if new_contour_data.fill_data: glyph = contour_renderer.fill_renderer.glyph for name, value in visuals.items(): setattr(glyph, name, value) cds = contour_renderer.fill_renderer.data_source for name, value in fill_cds.data.items(): cds.add(value, name) glyph.line_alpha = 0 # Don't display lines around fill. glyph.line_width = 0 if new_contour_data.line_data: glyph = contour_renderer.line_renderer.glyph for name, value in line_visuals.items(): setattr(glyph, name, value) cds = contour_renderer.line_renderer.data_source for name, value in line_cds.data.items(): cds.add(value, name) return contour_renderer
#----------------------------------------------------------------------------- # Private API #----------------------------------------------------------------------------- @dataclass(frozen=True) class SingleFillCoords: ''' Coordinates for filled contour polygons between a lower and upper level. The first list contains a list for each polygon. The second list contains a separate NumPy array for each boundary of that polygon; the first array is always the outer boundary, subsequent arrays are holes. ''' xs: list[list[np.ndarray]] ys: list[list[np.ndarray]] @dataclass(frozen=True) class SingleLineCoords: ''' Coordinates for contour lines at a single contour level. The x and y coordinates are stored in a single NumPy array each, with a np.nan separating each line. ''' xs: np.ndarray ys: np.ndarray def _color(color: ContourColorOrPalette, n: int) -> ContourColor: # Dict to sequence of colors such as palettes.cividis if isinstance(color, dict): return _palette_from_collection(color, n) if isinstance(color, Sequence) and not isinstance(color, (bytes, str)) and len(color) != n: return interp_palette(color, n) return color def _contour_coords( x: ArrayLike | None, y: ArrayLike | None, z: ArrayLike | np.ma.MaskedArray | None, levels: ArrayLike, want_fill: bool, want_line: bool, ) -> ContourCoords: ''' Return the (xs, ys) coords of filled and/or line contours. ''' if not want_fill and not want_line: raise RuntimeError("Neither fill nor line requested in _contour_coords") from contourpy import FillType, LineType, contour_generator cont_gen = contour_generator(x, y, z, line_type=LineType.ChunkCombinedOffset, fill_type=FillType.OuterOffset) fill_coords = None if want_fill: all_xs = [] all_ys = [] for i in range(len(levels)-1): filled = cont_gen.filled(levels[i], levels[i+1]) coords = _filled_to_coords(filled) all_xs.append(coords.xs) all_ys.append(coords.ys) fill_coords = FillCoords(all_xs, all_ys) line_coords = None if want_line: all_xs = [] all_ys = [] for level in levels: lines = cont_gen.lines(level) coords = _lines_to_coords(lines) all_xs.append(coords.xs) all_ys.append(coords.ys) line_coords = LineCoords(all_xs, all_ys) return ContourCoords(fill_coords, line_coords) def _filled_to_coords(filled) -> SingleFillCoords: # Processes polygon data returned from a single call to # contourpy.ContourGenerator.filled(lower_level, upper_level) # ContourPy filled data format is FillType.OuterOffset. # 'filled' type awaits type annotations in contourpy. xs = [] ys = [] for points, offsets in zip(*filled): # Polygon with outer boundary and zero or more holes. n = len(offsets) - 1 xs.append([points[offsets[i]:offsets[i+1], 0] for i in range(n)]) ys.append([points[offsets[i]:offsets[i+1], 1] for i in range(n)]) return SingleFillCoords(xs, ys) def _lines_to_coords(lines) -> SingleLineCoords: # Processes line data returned from a single call to # contourpy.ContourGenerator.lines(level). # ContourPy line data format is LineType.ChunkCombinedOffset. # 'lines' type awaits type annotations in contourpy. points = lines[0][0] if points is None: empty = np.empty(0) return SingleLineCoords(empty, empty) offsets = lines[1][0] npoints = len(points) nlines = len(offsets) - 1 xs = np.empty(npoints + nlines-1) ys = np.empty(npoints + nlines-1) for i in range(nlines): start = offsets[i] end = offsets[i+1] if i > 0: xs[start+i-1] = np.nan ys[start+i-1] = np.nan xs[start+i:end+i] = points[start:end, 0] ys[start+i:end+i] = points[start:end, 1] return SingleLineCoords(xs, ys) def _palette_from_collection(collection: PaletteCollection, n: int) -> Palette: # Return palette of length n from the specified palette collection, which # is a dict[int, Palette]. If the required length palette is in the # collection then return that. If the required length is bigger than the # longest palette then interpolate that. If the required length is smaller # than the shortest palette then interpolate that. if len(collection) < 1: raise ValueError("PaletteCollection is empty") palette = collection.get(n, None) if palette is not None: return palette max_key = max(collection.keys()) if isinstance(max_key, int) and n > max_key: return interp_palette(collection[max_key], n) min_key = min(collection.keys()) if isinstance(min_key, int) and n < min_key: return interp_palette(collection[min_key], n) raise ValueError(f"Unable to extract or interpolate palette of length {n} from PaletteCollection") def _validate_levels(levels: ArrayLike | None) -> NDArray[float]: levels = np.asarray(levels) if levels.ndim == 0 or len(levels) == 0: raise ValueError("No contour levels specified") if len(levels) > 1 and np.diff(levels).min() <= 0.0: raise ValueError("Contour levels must be increasing") return levels