From ab13d7bbaacf1b39ba23a2df1f4db43e16920736 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Thu, 21 Aug 2025 12:54:59 -0500 Subject: [PATCH 1/8] WIP, lines containers decorator --- lib/matplotlib/_containers.py | 37 +++++++++++++++++++++++++++++++++++ lib/matplotlib/lines.py | 5 ++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 lib/matplotlib/_containers.py diff --git a/lib/matplotlib/_containers.py b/lib/matplotlib/_containers.py new file mode 100644 index 000000000000..e8a6ec16a203 --- /dev/null +++ b/lib/matplotlib/_containers.py @@ -0,0 +1,37 @@ +from mpl_data_containers.description import Desc, desc_like +from mpl_data_containers.graph import Graph +from mpl_data_containers.conversion_edge import TransformEdge + + +def containerize_draw(draw_func): + def draw(self, renderer, *, graph=None): + if graph is None: + graph = Graph([]) + + ax = self.axes + if ax is None: + implicit_graph = Graph([]) + else: + desc: Desc = Desc(("N",), coordinates="data") + xy: dict[str, Desc] = {"x": desc, "y": desc} + implicit_graph = Graph( + [ + TransformEdge( + "data", + xy, + desc_like(xy, coordinates="axes"), + transform=ax.transData - ax.transAxes, + ), + TransformEdge( + "axes", + desc_like(xy, coordinates="axes"), + desc_like(xy, coordinates="display"), + transform=ax.transAxes, + ), + ], + aliases=(("parent", "axes"),), + ) + + return draw_func(self, renderer, graph=graph+implicit_graph) + + return draw diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 83750721b38d..923c7a4c732b 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -18,6 +18,7 @@ from .path import Path from .transforms import Bbox, BboxTransformTo, TransformedPath from ._enums import JoinStyle, CapStyle +from ._containers import containerize_draw # Imported here for backward compatibility, even though they don't # really belong. @@ -446,6 +447,7 @@ def __init__(self, xdata, ydata, *, self._transformed_path = None self._subslice = False self._x_filled = None # used in subslicing; only x is needed + self._container = None self.set_data(xdata, ydata) @@ -776,7 +778,8 @@ def set_transform(self, t): super().set_transform(t) @allow_rasterization - def draw(self, renderer): + @containerize_draw + def draw(self, renderer, *, graph=None): # docstring inherited if not self.get_visible(): From 0982207ec2b5fa2dfef9a988e7fa0010a8a5c368 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Thu, 4 Dec 2025 12:56:59 -0600 Subject: [PATCH 2/8] Add DPI transform to auto generated graph --- lib/matplotlib/_containers.py | 49 +++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/lib/matplotlib/_containers.py b/lib/matplotlib/_containers.py index e8a6ec16a203..4a0a2511e234 100644 --- a/lib/matplotlib/_containers.py +++ b/lib/matplotlib/_containers.py @@ -14,24 +14,39 @@ def draw(self, renderer, *, graph=None): else: desc: Desc = Desc(("N",), coordinates="data") xy: dict[str, Desc] = {"x": desc, "y": desc} - implicit_graph = Graph( - [ - TransformEdge( - "data", - xy, - desc_like(xy, coordinates="axes"), - transform=ax.transData - ax.transAxes, - ), - TransformEdge( - "axes", - desc_like(xy, coordinates="axes"), - desc_like(xy, coordinates="display"), - transform=ax.transAxes, - ), - ], - aliases=(("parent", "axes"),), - ) + implicit_graph = _get_graph(ax) return draw_func(self, renderer, graph=graph+implicit_graph) return draw + + +def _get_graph(ax): + if ax is None: + return Graph([]) + desc: Desc = Desc(("N",), coordinates="data") + xy: dict[str, Desc] = {"x": desc, "y": desc} + implicit_graph = Graph( + [ + TransformEdge( + "data", + xy, + desc_like(xy, coordinates="axes"), + transform=ax.transData - ax.transAxes, + ), + TransformEdge( + "axes", + desc_like(xy, coordinates="axes"), + desc_like(xy, coordinates="display"), + transform=ax.transAxes, + ), + TransformEdge( + "dpi", + desc_like(xy, coordinates="display_inches"), + desc_like(xy, coordinates="display"), + transform=ax.figure.dpi_scale_trans, + ), + ], + aliases=(("parent", "axes"),), + ) + return implicit_graph From 640d0931e8d84cecfd938ff76b20d381ce2f90e9 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Thu, 11 Dec 2025 13:55:30 -0600 Subject: [PATCH 3/8] Vendor relevant portions of data containers --- ci/codespell-ignore-words.txt | 1 + lib/matplotlib/_data_containers/__init__.py | 0 .../_helpers.py} | 5 +- lib/matplotlib/_data_containers/containers.py | 416 ++++++++++++++++++ .../_data_containers/conversion_edge.py | 401 +++++++++++++++++ .../_data_containers/description.py | 154 +++++++ lib/matplotlib/_data_containers/meson.build | 13 + lib/matplotlib/image.py | 1 + lib/matplotlib/lines.py | 2 +- lib/matplotlib/meson.build | 1 + 10 files changed, 990 insertions(+), 4 deletions(-) create mode 100644 lib/matplotlib/_data_containers/__init__.py rename lib/matplotlib/{_containers.py => _data_containers/_helpers.py} (89%) create mode 100644 lib/matplotlib/_data_containers/containers.py create mode 100644 lib/matplotlib/_data_containers/conversion_edge.py create mode 100644 lib/matplotlib/_data_containers/description.py create mode 100644 lib/matplotlib/_data_containers/meson.build diff --git a/ci/codespell-ignore-words.txt b/ci/codespell-ignore-words.txt index e138f26e216a..8e5163842c51 100644 --- a/ci/codespell-ignore-words.txt +++ b/ci/codespell-ignore-words.txt @@ -1,5 +1,6 @@ aas ABD +aother axises coo curvelinear diff --git a/lib/matplotlib/_data_containers/__init__.py b/lib/matplotlib/_data_containers/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lib/matplotlib/_containers.py b/lib/matplotlib/_data_containers/_helpers.py similarity index 89% rename from lib/matplotlib/_containers.py rename to lib/matplotlib/_data_containers/_helpers.py index 4a0a2511e234..b48c5743941f 100644 --- a/lib/matplotlib/_containers.py +++ b/lib/matplotlib/_data_containers/_helpers.py @@ -1,6 +1,5 @@ -from mpl_data_containers.description import Desc, desc_like -from mpl_data_containers.graph import Graph -from mpl_data_containers.conversion_edge import TransformEdge +from .description import Desc, desc_like +from .conversion_edge import Graph, TransformEdge def containerize_draw(draw_func): diff --git a/lib/matplotlib/_data_containers/containers.py b/lib/matplotlib/_data_containers/containers.py new file mode 100644 index 000000000000..cd487a4b2c59 --- /dev/null +++ b/lib/matplotlib/_data_containers/containers.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +from typing import ( + Protocol, + Optional, + Any, + Union, +) +from collections.abc import Callable, MutableMapping +import uuid + +from cachetools import LFUCache # type: ignore[import-untyped] + +import numpy as np +import pandas as pd + +from .description import Desc, desc_like + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .conversion_edge import Graph + + +class _MatplotlibTransform(Protocol): + def transform(self, verts): ... + + def __sub__(self, other) -> "_MatplotlibTransform": ... + + +class DataContainer(Protocol): + def query( + self, + graph: Graph, + parent_coordinates: str = "axes", + /, + ) -> tuple[dict[str, Any], Union[str, int]]: + """ + Query the data container for data. + + We are given the data limits and the screen size so that we have an + estimate of how finely (or not) we need to sample the data we wrapping. + + Parameters + ---------- + coord_transform : matplotlib.transform.Transform + Must go from axes fraction space -> data space + + size : 2 integers + xpixels, ypixels + + The size in screen / render units that we have to fill. + + Returns + ------- + data : dict[str, Any] + The values are really array-likes + + cache_key : str + This is a key that clients can use to cache down-stream + computations on this data. + """ + ... + + def describe(self) -> dict[str, Desc]: + """ + Describe the data a query will return + + Returns + ------- + dict[str, Desc] + """ + ... + + +class NoNewKeys(ValueError): ... + + +class ArrayContainer: + def __init__(self, coordinates: dict[str, str] | None = None, /, **data): + coordinates = coordinates or {} + self._data = data + self._cache_key = str(uuid.uuid4()) + self._desc = { + k: ( + Desc(v.shape, coordinates.get(k, "auto")) + if hasattr(v, "shape") + else Desc((), coordinates.get(k, "auto")) + ) + for k, v in data.items() + } + + def query( + self, + graph: Graph, + parent_coordinates: str = "axes", + ) -> tuple[dict[str, Any], Union[str, int]]: + return dict(self._data), self._cache_key + + def describe(self) -> dict[str, Desc]: + return dict(self._desc) + + def update(self, **data): + # TODO check that this is still consistent with desc! + if not all(k in self._data for k in data): + raise NoNewKeys( + f"The keys that currently exist are {set(self._data)}. You " + f"tried to add {set(data) - set(self._data)!r}." + ) + self._data.update(data) + self._cache_key = str(uuid.uuid4()) + + +class RandomContainer: + def __init__(self, **shapes): + self._desc = {k: Desc(s) for k, s in shapes.items()} + + def query( + self, + graph: Graph, + parent_coordinates: str = "axes", + ) -> tuple[dict[str, Any], Union[str, int]]: + return {k: np.random.randn(*d.shape) for k, d in self._desc.items()}, str( + uuid.uuid4() + ) + + def describe(self) -> dict[str, Desc]: + return dict(self._desc) + + +class FuncContainer: + def __init__( + self, + # TODO: is this really the best spelling?! + xfuncs: Optional[ + dict[str, tuple[tuple[Union[str, int], ...], Callable[[Any], Any]]] + ] = None, + yfuncs: Optional[ + dict[str, tuple[tuple[Union[str, int], ...], Callable[[Any], Any]]] + ] = None, + xyfuncs: Optional[ + dict[str, tuple[tuple[Union[str, int], ...], Callable[[Any, Any], Any]]] + ] = None, + ): + """ + A container that wraps several functions. They are split into 3 categories: + + - functions that are offered x-like values as input + - functions that are offered y-like values as input + - functions that are offered both x and y like values as two inputs + + In addition to the callable, the user needs to provide a spelling of + what the (relative) shapes will be in relation to each other. For now this + is a list of integers and strings, where the strings are "generic" values. + + For example if two functions report shapes: ``{'bins':[N], 'edges': [N + 1]`` + then when called, *edges* will always have one more entry than bins. + + Parameters + ---------- + xfuncs, yfuncs, xyfuncs : dict[str, tuple[shape, func]] + + """ + # TODO validate no collisions + self._desc: dict[str, Desc] = {} + + def _split(input_dict): + out = {} + for k, (shape, func) in input_dict.items(): + self._desc[k] = Desc(shape) + out[k] = func + return out + + self._xfuncs = _split(xfuncs) if xfuncs is not None else {} + self._yfuncs = _split(yfuncs) if yfuncs is not None else {} + self._xyfuncs = _split(xyfuncs) if xyfuncs is not None else {} + self._cache: MutableMapping[Union[str, int], Any] = LFUCache(64) + + def _query_hash(self, coord_transform, size): + # TODO find a better way to compute the hash key, this is not sentative to + # scale changes, only limit changes + data_bounds = tuple(coord_transform.transform([[0, 0], [1, 1]]).flatten()) + hash_key = hash((data_bounds, size)) + return hash_key + + def query( + self, + graph: Graph, + parent_coordinates: str = "axes", + ) -> tuple[dict[str, Any], Union[str, int]]: + # hash_key = self._query_hash(coord_transform, size) + # if hash_key in self._cache: + # return self._cache[hash_key], hash_key + + desc = Desc(("N",)) + xy = {"x": desc, "y": desc} + data_lim = graph.evaluator( + desc_like(xy, coordinates="data"), + desc_like(xy, coordinates=parent_coordinates), + ).inverse + + screen_size = graph.evaluator( + desc_like(xy, coordinates=parent_coordinates), + desc_like(xy, coordinates="display"), + ) + + screen_dims = screen_size.evaluate({"x": [0, 1], "y": [0, 1]}) + xpix, ypix = np.ceil(np.abs(np.diff(screen_dims["x"]))), np.ceil( + np.abs(np.diff(screen_dims["y"])) + ) + + x_data = data_lim.evaluate( + { + "x": np.linspace(0, 1, int(xpix) * 2), + "y": np.zeros(int(xpix) * 2), + } + )["x"] + y_data = data_lim.evaluate( + { + "x": np.zeros(int(ypix) * 2), + "y": np.linspace(0, 1, int(ypix) * 2), + } + )["y"] + + hash_key = str(uuid.uuid4()) + ret = self._cache[hash_key] = dict( + **{k: f(x_data) for k, f in self._xfuncs.items()}, + **{k: f(y_data) for k, f in self._yfuncs.items()}, + **{k: f(x_data, y_data) for k, f in self._xyfuncs.items()}, + ) + return ret, hash_key + + def describe(self) -> dict[str, Desc]: + return dict(self._desc) + + +class HistContainer: + def __init__(self, raw_data, num_bins: int): + self._raw_data = raw_data + self._num_bins = num_bins + self._desc = { + "edges": Desc((num_bins + 1 + 2,)), + "density": Desc((num_bins + 2,)), + } + self._full_range = (raw_data.min(), raw_data.max()) + self._cache: MutableMapping[Union[str, int], Any] = LFUCache(64) + + def query( + self, + graph: Graph, + parent_coordinates: str = "axes", + ) -> tuple[dict[str, Any], Union[str, int]]: + dmin, dmax = self._full_range + + desc = Desc(("N",)) + xy = {"x": desc, "y": desc} + data_lim = graph.evaluator( + desc_like(xy, coordinates="data"), + desc_like(xy, coordinates=parent_coordinates), + ).inverse + + pts = data_lim.evaluate({"x": (0, 1), "y": (0, 1)}) + xmin, xmax = pts["x"] + ymin, ymax = pts["y"] + + xmin, xmax = np.clip([xmin, xmax], dmin, dmax) + hash_key = hash((xmin, xmax)) + if hash_key in self._cache: + return self._cache[hash_key], hash_key + # TODO this gives an artifact with high lw + edges_in = [] + if dmin < xmin: + edges_in.append(np.array([dmin])) + edges_in.append(np.linspace(xmin, xmax, self._num_bins)) + if xmax < dmax: + edges_in.append(np.array([dmax])) + + density, edges = np.histogram( + self._raw_data, + bins=np.concatenate(edges_in), + density=True, + ) + ret = self._cache[hash_key] = {"edges": edges, "density": density} + return ret, hash_key + + def describe(self) -> dict[str, Desc]: + return dict(self._desc) + + +class SeriesContainer: + _data: pd.Series + _index_name: str + _hash_key: str + + def __init__(self, series: pd.Series, *, index_name: str, col_name: str): + # TODO make a copy? + self._data = series + self._index_name = index_name + self._col_name = col_name + self._desc = { + index_name: Desc((len(series),)), + col_name: Desc((len(series),)), + } + self._hash_key = str(uuid.uuid4()) + + def query( + self, + graph: Graph, + parent_coordinates: str = "axes", + ) -> tuple[dict[str, Any], Union[str, int]]: + return { + self._index_name: self._data.index.values, + self._col_name: self._data.values, + }, self._hash_key + + def describe(self) -> dict[str, Desc]: + return dict(self._desc) + + +class DataFrameContainer: + _data: pd.DataFrame + + def __init__( + self, + df: pd.DataFrame, + *, + col_names: Union[Callable[[str], str], dict[str, str]], + index_name: Optional[str] = None, + ): + # TODO make a copy? + self._data = df + self._index_name = index_name + + if callable(col_names): + # TODO cache the function so we can replace the dataframe later? + self._col_name_dict = {k: col_names(k) for k in df.columns} + else: + self._col_name_dict = dict(col_names) + + self._desc: dict[str, Desc] = {} + if self._index_name is not None: + self._desc[self._index_name] = Desc((len(df),)) + for col, out in self._col_name_dict.items(): + self._desc[out] = Desc((len(df),)) + + self._hash_key = str(uuid.uuid4()) + + def query( + self, + graph: Graph, + parent_coordinates: str = "axes", + ) -> tuple[dict[str, Any], Union[str, int]]: + ret: dict[str, Any] = {} + if self._index_name is not None: + ret[self._index_name] = self._data.index.values + for col, out in self._col_name_dict.items(): + ret[out] = self._data[col].values + + return ret, self._hash_key + + def describe(self) -> dict[str, Desc]: + return dict(self._desc) + + +class ReNamer: + def __init__(self, data: DataContainer, mapping: dict[str, str]): + # TODO: check all the asked for key exist + self._data = data + self._mapping = mapping + + def query( + self, + graph: Graph, + parent_coordinates: str = "axes", + ) -> tuple[dict[str, Any], Union[str, int]]: + base, cache_key = self._data.query(graph, parent_coordinates) + return {v: base[k] for k, v in self._mapping.items()}, cache_key + + def describe(self): + base = self._data.describe() + return {v: base[k] for k, v in self._mapping.items()} + + +class DataUnion: + def __init__(self, *data: DataContainer): + # TODO check no collisions + self._datas = data + + def query( + self, + graph: Graph, + parent_coordinates: str = "axes", + ) -> tuple[dict[str, Any], Union[str, int]]: + cache_keys = [] + ret = {} + for data in self._datas: + base, cache_key = data.query(graph, parent_coordinates) + ret.update(base) + cache_keys.append(cache_key) + return ret, hash(tuple(cache_keys)) + + def describe(self): + return {k: v for d in self._datas for k, v in d.describe().items()} + + +class WebServiceContainer: + def query( + self, + graph: Graph, + parent_coordinates: str = "axes", + ) -> tuple[dict[str, Any], Union[str, int]]: + def hit_some_database(): + return {}, "1" + + data, etag = hit_some_database() + return data, etag diff --git a/lib/matplotlib/_data_containers/conversion_edge.py b/lib/matplotlib/_data_containers/conversion_edge.py new file mode 100644 index 000000000000..8002d9879b9a --- /dev/null +++ b/lib/matplotlib/_data_containers/conversion_edge.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +from collections.abc import Sequence +from collections.abc import Callable +from dataclasses import dataclass +from queue import PriorityQueue +from typing import Any +import numpy as np + +from .description import Desc, desc_like, ShapeSpec + +from matplotlib.transforms import Transform + + +@dataclass +class Edge: + name: str + input: dict[str, Desc] + output: dict[str, Desc] + weight: float = 1 + invertable: bool = True + + def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: + return input + + @property + def inverse(self) -> "Edge": + return Edge(self.name + "_r", self.output, self.input, self.weight) + + +@dataclass +class SequenceEdge(Edge): + edges: Sequence[Edge] = () + + @classmethod + def from_edges( + cls, + name: str, + edges: Sequence[Edge], + output: dict[str, Desc], + weight: float | None = None, + ): + input: dict[str, Desc] = {} + intermediates: dict[str, Desc] = {} + invertable = True + edge_sum: float = 0 + for edge in edges: + edge_sum += edge.weight + input |= {k: v for k, v in edge.input.items() if k not in intermediates} + intermediates |= edge.output + if not edge.invertable: + invertable = False + + if weight is None: + weight = edge_sum + + return cls(name, input, output, weight, invertable, edges) + + def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: + for edge in self.edges: + input |= edge.evaluate({k: input[k] for k in edge.input}) + return {k: input[k] for k in self.output} + + @property + def inverse(self) -> "SequenceEdge": + return SequenceEdge.from_edges( + self.name + "_r", + [e.inverse for e in self.edges[::-1]], + self.input, + self.weight, + ) + + +@dataclass +class CoordinateEdge(Edge): + """Change coordinates without changing values""" + + @classmethod + def from_coords( + cls, name: str, input: dict[str, Desc | str], output: str, weight: float = 1 + ): + # dtype/shape is reductive here, but I like the idea of being able to just + # supply only the input/output coordinates for many things + # could also see lowering default weight for this edge, but just defaulting + # everything to 1 for now + inp = { + k: v if isinstance(v, Desc) else Desc(("N",), v) for k, v in input.items() + } + outp = {k: desc_like(v, coordinates=output) for k, v in inp.items()} + + return cls(name, inp, outp, weight) + + @property + def inverse(self) -> Edge: + return Edge(f"{self.name}_r", self.output, self.input, self.weight) + + +@dataclass +class DefaultEdge(Edge): + """Provide default values with a high weight""" + + weight = 1e6 + value: Any = None + + @classmethod + def from_default_value( + cls, + name: str, + key: str, + output: Desc, + value: Any, + weight=1e6, + ) -> "DefaultEdge": + return cls(name, {}, {key: output}, weight, invertable=False, value=value) + + @classmethod + def from_rc( + cls, rc_name: str, key: str | None = None, coordinates: str = "display" + ): + from matplotlib import rcParams + + if key is None: + key = rc_name.split(".")[-1] + scalar = Desc((), coordinates) + return cls.from_default_value(f"{rc_name}_rc", key, scalar, rcParams[rc_name]) + + def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: + return {k: self.value for k in self.output} + + +@dataclass +class FuncEdge(Edge): + # TODO: more explicit callable boundaries? + func: Callable = lambda: {} + inverse_func: Callable | None = None + + @classmethod + def from_func( + cls, + name: str, + func: Callable, + input: str | dict[str, Desc], + output: str | dict[str, Desc], + weight: float = 1, + inverse: Callable | None = None, + ): + # dtype/shape is reductive here, but I like the idea of being able to just + # supply a function and the input/output coordinates for many things + if isinstance(input, str): + import inspect + + input_vars = inspect.signature(func).parameters.keys() + input = {k: Desc(("N",), input) for k in input_vars} + if isinstance(output, str): + output = {k: Desc(("N",), output) for k in input.keys()} + + return cls(name, input, output, weight, inverse is not None, func, inverse) + + def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: + res = self.func(**{k: input[k] for k in self.input}) + + if isinstance(res, dict): + # TODO: more sanity checks here? + # How forgiving do we _really_ wish to be? + return res + elif isinstance(res, tuple): + if len(res) != len(self.output): + if len(self.output) == 1: + return {k: res for k in self.output} + raise RuntimeError( + f"Expected {len(self.output)} return values," + f"got {len(res)} in {self.name}" + ) + return {k: v for k, v in zip(self.output, res)} + elif len(self.output) == 1: + return {k: res for k in self.output} + raise RuntimeError("Output of function does not match expected output") + + @property + def inverse(self) -> "FuncEdge": + if self.inverse_func is None: + raise RuntimeError("Trying to invert a non-invertable edge") + + return FuncEdge.from_func( + self.name + "_r", + self.inverse_func, + self.output, + self.input, + self.weight, + self.func, + ) + + +@dataclass +class TransformEdge(Edge): + transform: Transform | Callable[[], Transform] | None = None + + # TODO: helper for common cases/validation? + + def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: + # TODO: ensure ordering? + # Stacking and unstacking at every step seems inefficient, + # especially if initially given as stacked + if self.transform is None: + return input + elif isinstance(self.transform, Callable): # type: ignore[arg-type] + trf = self.transform() # type: ignore[operator] + else: + trf = self.transform + inp = np.stack([input[k] for k in self.input], axis=-1) + outp = trf.transform(inp) + return {k: v for k, v in zip(self.output, outp.T)} + + @property + def inverse(self) -> "TransformEdge": + if self.transform is None: + raise RuntimeError("Trying to invert a non-invertable edge") + + if isinstance(self.transform, Callable): # type: ignore[arg-type] + return TransformEdge( + self.name + "_r", + self.output, + self.input, + self.weight, + True, + lambda: self.transform().inverted(), # type: ignore[misc,operator] + ) + + return TransformEdge( + self.name + "_r", + self.output, + self.input, + self.weight, + True, + self.transform.inverted(), # type: ignore[union-attr] + ) + + +class Graph: + def __init__( + self, edges: Sequence[Edge], aliases: tuple[tuple[str, str], ...] = () + ): + self._edges = tuple(edges) + self._aliases = aliases + + self._subgraphs: list[tuple[set[str], list[Edge]]] = [] + for edge in self._edges: + keys = set(edge.input) | set(edge.output) + + overlapping = [] + + for n, (sub_keys, sub_edges) in enumerate(self._subgraphs): + if keys & sub_keys: + overlapping.append(n) + + if not overlapping: + self._subgraphs.append((keys, [edge])) + elif len(overlapping) == 1: + s = self._subgraphs[overlapping[0]][0] + s |= keys + self._subgraphs[overlapping[0]][1].append(edge) + else: + edges_combined = [edge] + for n in overlapping: + keys |= self._subgraphs[n][0] + edges_combined.extend(self._subgraphs[n][1]) + for n in overlapping[::-1]: + self._subgraphs.pop(n) + self._subgraphs.append((keys, edges_combined)) + + def _resolve_alias(self, coord: str) -> str: + while True: + for coa, cob in self._aliases: + if coord == coa: + coord = cob + break + else: + break + return coord + + def evaluator(self, input: dict[str, Desc], output: dict[str, Desc]) -> Edge: + out_edges = [] + + for sub_keys, sub_edges in self._subgraphs: + if not (sub_keys & set(output) or sub_keys & set(input)): + continue + + output_subset = {k: v for k, v in output.items() if k in sub_keys} + sub_edges = sorted(sub_edges, key=lambda x: x.weight) + + @dataclass + class Node: + weight: float + desc: dict[str, Desc] + prev_node: Node | None = None + edge: Edge | None = None + + def __le__(self, other): + return self.weight <= other.weight + + def __lt__(self, other): + return self.weight < other.weight + + def __ge__(self, other): + return self.weight >= other.weight + + def __gt__(self, other): + return self.weight > other.weight + + @property + def edges(self): + if self.prev_node is None: + return [self.edge] + return self.prev_node.edges + [self.edge] + + q: PriorityQueue[Node] = PriorityQueue() + q.put(Node(0, input)) + + best: Node = Node(np.inf, {}) + while not q.empty(): + n = q.get() + if n.weight > best.weight: + continue + if Desc.compatible(n.desc, output_subset, aliases=self._aliases): + if n.weight < best.weight: + best = n + continue + for e in sub_edges: + if e in n.edges: + continue + if Desc.compatible(n.desc, e.input, aliases=self._aliases): + d = n.desc | e.output + w = n.weight + e.weight + + q.put(Node(w, d, n, e)) + if np.isinf(best.weight): + raise NotImplementedError( + "This may be possible, but is not a simple case already considered" + ) + + edges: list[Edge] = [] + n = best + while n.prev_node is not None: + if n.edge is not None: + edges.insert(0, n.edge) + n = n.prev_node + if len(edges) == 0: + continue + elif len(edges) == 1: + out_edges.append(edges[0]) + else: + out_edges.append(SequenceEdge.from_edges("eval", edges, output_subset)) + + found_outputs = set(input) + for out in out_edges: + found_outputs |= set(out.output) + if missing := set(output) - found_outputs: + raise RuntimeError(f"Could not find path to resolve all outputs: {missing}") + + if len(out_edges) == 0: + return Edge("noop", input, output) + if len(out_edges) == 1: + return out_edges[0] + return SequenceEdge.from_edges("eval", out_edges, output) + + def __add__(self, other: Graph) -> Graph: + aself = {k: v for k, v in self._aliases} + aother = {k: v for k, v in other._aliases} + aliases = tuple((aself | aother).items()) + return Graph(self._edges + other._edges, aliases) + + def cache_key(self): + """A cache key representing the graph. + + Current implementation is a new UUID, that is to say uncachable. + """ + import uuid + + return str(uuid.uuid4()) + + +def coord_and_default( + key: str, + shape: ShapeSpec = (), + coordinates: str = "display", + default_value: Any = None, + default_rc: str | None = None, +): + if default_rc is not None: + if default_value is not None: + raise ValueError( + "Only one of 'default_value' and 'default_rc' may be specified" + ) + def_edge = DefaultEdge.from_rc(default_rc, key, coordinates) + else: + scalar = Desc((), coordinates) + def_edge = DefaultEdge.from_default_value( + f"{key}_def", key, scalar, default_value + ) + coord_edge = CoordinateEdge.from_coords(key, {key: Desc(shape)}, coordinates) + return coord_edge, def_edge diff --git a/lib/matplotlib/_data_containers/description.py b/lib/matplotlib/_data_containers/description.py new file mode 100644 index 000000000000..d84fbf9a11f0 --- /dev/null +++ b/lib/matplotlib/_data_containers/description.py @@ -0,0 +1,154 @@ +from dataclasses import dataclass +from typing import TypeAlias, Union, overload + + +ShapeSpec: TypeAlias = tuple[Union[str, int], ...] + + +@dataclass(frozen=True) +class Desc: + # TODO: sort out how to actually spell this. We need to know: + # - what the number of dimensions is (1d vs 2d vs ...) + # - is this a fixed size dimension (e.g. 2 for xextent) + # - is this a variable size depending on the query (e.g. N) + # - what is the relative size to the other variable values (N vs N+1) + # We are probably going to have to implement a DSL for this (😞) + shape: ShapeSpec + coordinates: str = "auto" + + @staticmethod + def validate_shapes( + specification: dict[str, ShapeSpec | "Desc"], + actual: dict[str, ShapeSpec | "Desc"], + *, + broadcast: bool = False, + ) -> None: + """Validate specified shape relationships against a provided set of shapes. + + Shapes provided are tuples of int | str. If a specification calls for an int, + the exact size is expected. + If it is a str, it must be a single capital letter optionally followed by ``+`` + or ``-`` an integer value. + The same letter used in the specification must represent the same value in all + appearances. The value may, however, be a variable (with an offset) in the + actual shapes (which does not need to have the same letter). + + Shapes may be provided as raw tuples or as ``Desc`` objects. + + Parameters + ---------- + specification: dict[str, ShapeSpec | "Desc"] + The desired shape relationships + actual: dict[str, ShapeSpec | "Desc"] + The shapes to test for compliance + + Keyword Parameters + ------------------ + broadcast: bool + Whether to allow broadcasted shapes to pass (i.e. actual shapes with a ``1`` + will not cause exceptions regardless of what the specified shape value is) + + Raises + ------ + KeyError: + If a required field from the specification is missing in the provided actual + values. + ValueError: + If shapes are incompatible in any other way + """ + specvars: dict[str, int | tuple[str, int]] = {} + for fieldname in specification: + spec = specification[fieldname] + if fieldname not in actual: + raise KeyError( + f"Actual is missing {fieldname!r}, required by specification." + ) + desc = actual[fieldname] + if isinstance(spec, Desc): + spec = spec.shape + if isinstance(desc, Desc): + desc = desc.shape + if not broadcast: + if len(spec) != len(desc): + raise ValueError( + f"{fieldname!r} shape {desc} incompatible with specification " + f"{spec}." + ) + elif len(desc) > len(spec): + raise ValueError( + f"{fieldname!r} shape {desc} incompatible with specification " + f"{spec}." + ) + for speccomp, desccomp in zip(spec[::-1], desc[::-1]): + if broadcast and desccomp == 1: + continue + if isinstance(speccomp, str): + specv, specoff = speccomp[0], int(speccomp[1:] or 0) + entry: tuple[str, int] | int + + if isinstance(desccomp, str): + descv, descoff = desccomp[0], int(desccomp[1:] or 0) + entry = (descv, descoff - specoff) + else: + entry = desccomp - specoff + + if specv in specvars and entry != specvars[specv]: + raise ValueError(f"Found two incompatible values for {specv!r}") + + specvars[specv] = entry + elif speccomp != desccomp: + raise ValueError( + f"{fieldname!r} shape {desc} incompatible with specification " + f"{spec}" + ) + return None + + @staticmethod + def compatible( + a: dict[str, "Desc"], + b: dict[str, "Desc"], + aliases: tuple[tuple[str, str], ...] = (), + ) -> bool: + """Determine if ``a`` is a valid input for ``b``. + + Note: ``a`` _may_ have additional keys. + """ + + def resolve_aliases(coord): + while True: + for coa, cob in aliases: + if coord == coa: + coord = cob + break + else: + break + return coord + + try: + Desc.validate_shapes(b, a) # type: ignore[arg-type] + except (KeyError, ValueError): + return False + for k, v in b.items(): + if resolve_aliases(a[k].coordinates) != resolve_aliases(v.coordinates): + return False + return True + + +@overload +def desc_like(desc: Desc, shape=None, coordinates=None) -> Desc: ... + + +@overload +def desc_like( + desc: dict[str, Desc], shape=None, coordinates=None +) -> dict[str, Desc]: ... + + +def desc_like(desc, shape=None, coordinates=None): + if isinstance(desc, dict): + return {k: desc_like(v, shape, coordinates) for k, v in desc.items()} + if shape is None: + shape = desc.shape + if coordinates is None: + coordinates = desc.coordinates + return Desc(shape, coordinates) diff --git a/lib/matplotlib/_data_containers/meson.build b/lib/matplotlib/_data_containers/meson.build new file mode 100644 index 000000000000..9607203ae74f --- /dev/null +++ b/lib/matplotlib/_data_containers/meson.build @@ -0,0 +1,13 @@ +python_sources = [ + '__init__.py', + 'containers.py', + 'conversion_edge.py', + 'description.py', + '_helpers.py', +] + +typing_sources = [ +] + +py3.install_sources(python_sources, typing_sources, + subdir: 'matplotlib/_data_containers') diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py index 84ef3df48fc2..88558fc5d0c8 100644 --- a/lib/matplotlib/image.py +++ b/lib/matplotlib/image.py @@ -12,6 +12,7 @@ import PIL.Image import PIL.PngImagePlugin + import matplotlib as mpl from matplotlib import _api, cbook # For clarity, names from _image are given explicitly in this module diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 923c7a4c732b..a2a492f85570 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -18,7 +18,7 @@ from .path import Path from .transforms import Bbox, BboxTransformTo, TransformedPath from ._enums import JoinStyle, CapStyle -from ._containers import containerize_draw +from ._data_containers._helpers import containerize_draw # Imported here for backward compatibility, even though they don't # really belong. diff --git a/lib/matplotlib/meson.build b/lib/matplotlib/meson.build index c0bfdb227e2e..e799b4338ef8 100644 --- a/lib/matplotlib/meson.build +++ b/lib/matplotlib/meson.build @@ -168,3 +168,4 @@ subdir('style') subdir('testing') subdir('tests') subdir('tri') +subdir('_data_containers') From 637b39c0d9c5dc48c1f3bcc4b054d7b6098c05f0 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Mon, 15 Dec 2025 17:29:40 -0600 Subject: [PATCH 4/8] Rework Line2D --- lib/matplotlib/lines.py | 92 +++++++++++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index a2a492f85570..3399db32f610 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -4,6 +4,7 @@ import copy +from dataclasses import dataclass from numbers import Integral, Number, Real import logging @@ -18,7 +19,8 @@ from .path import Path from .transforms import Bbox, BboxTransformTo, TransformedPath from ._enums import JoinStyle, CapStyle -from ._data_containers._helpers import containerize_draw +from ._data_containers._helpers import containerize_draw, _get_graph, check_container +from ._data_containers.description import Desc # Imported here for backward compatibility, even though they don't # really belong. @@ -250,6 +252,26 @@ def _slice_or_none(in_v, slc): raise ValueError(f"markevery={markevery!r} is not a recognized value") +@dataclass +class LineContainer: + x: np.ndarray + y: np.ndarray + + def describe(self): + + return { + "x": Desc(("N",), "data"), + "y": Desc(("N",), "data"), + } + + def query(self, graph, parent_coordinates="axes"): + return { + "x": self.x, + "y": self.y, + }, "" + # TODO hash + + @_docstring.interpd @_api.define_aliases({ "antialiased": ["aa"], @@ -358,6 +380,9 @@ def __init__(self, xdata, ydata, *, """ super().__init__() + self._container = self._init_container() + self.__query = None + # Convert sequences to NumPy arrays. if not np.iterable(xdata): raise RuntimeError('xdata must be a sequence') @@ -436,21 +461,60 @@ def __init__(self, xdata, ydata, *, not isinstance(self._picker, bool)): self._pickradius = self._picker - self._xorig = np.asarray([]) - self._yorig = np.asarray([]) self._invalidx = True self._invalidy = True - self._x = None - self._y = None - self._xy = None self._path = None self._transformed_path = None self._subslice = False self._x_filled = None # used in subslicing; only x is needed - self._container = None self.set_data(xdata, ydata) + def set_container(self, container): + self._container = container + self.stale = True + + def get_container(self): + return self._container + + def _init_container(self): + return LineContainer( + x=np.array([]), + y=np.array([]), + ) + + @property + def _xorig(self): + return self._query["x"] + + @property + def _x(self): + xconv = self.convert_xunits(self._xorig) + return _to_unmasked_float_array(xconv).ravel() + + @property + def _yorig(self): + return self._query["y"] + + @property + def _y(self): + yconv = self.convert_yunits(self._yorig) + return _to_unmasked_float_array(yconv).ravel() + + @property + def _xy(self): + x, y = self._x, self._y + return np.column_stack(np.broadcast_arrays(x, y)).astype(float) + + @property + def _query(self): + if self.__query is not None: + return self.__query + return self._container.query(_get_graph(self.axes))[0] + + def _cache_query(self): + self.__query = self._container.query(_get_graph(self.axes))[0] + def contains(self, mouseevent): """ Test whether *mouseevent* occurred on the line. @@ -709,10 +773,6 @@ def recache(self, always=False): else: y = self._y - self._xy = np.column_stack(np.broadcast_arrays(x, y)).astype(float) - self._x = self._xy[:, 0] # views of the x and y data - self._y = self._xy[:, 1] - self._subslice = False if (self.axes and len(x) > self._subslice_optim_min_size @@ -785,6 +845,8 @@ def draw(self, renderer, *, graph=None): if not self.get_visible(): return + self._cache_query() + if self._invalidy or self._invalidx: self.recache() self.ind_offset = 0 # Needed for contains() method. @@ -1338,9 +1400,11 @@ def set_xdata(self, x): set_data set_ydata """ + check_container(self, LineContainer, "'set_xdata'") if not np.iterable(x): raise RuntimeError('x must be a sequence') - self._xorig = copy.copy(x) + self._container.x = copy.copy(x) + self.__query = None self._invalidx = True self.stale = True @@ -1357,9 +1421,11 @@ def set_ydata(self, y): set_data set_xdata """ + check_container(self, LineContainer, "'set_ydata'") if not np.iterable(y): raise RuntimeError('y must be a sequence') - self._yorig = copy.copy(y) + self._container.y = copy.copy(y) + self.__query = None self._invalidy = True self.stale = True From 520039560e1c9c4553ec3054dcc30dd2eff85658 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Mon, 15 Dec 2025 18:22:15 -0600 Subject: [PATCH 5/8] Add monkey-patch for compatibility with mpl_data_containers --- lib/matplotlib/_data_containers/description.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/matplotlib/_data_containers/description.py b/lib/matplotlib/_data_containers/description.py index d84fbf9a11f0..095faab7f6da 100644 --- a/lib/matplotlib/_data_containers/description.py +++ b/lib/matplotlib/_data_containers/description.py @@ -152,3 +152,11 @@ def desc_like(desc, shape=None, coordinates=None): if coordinates is None: coordinates = desc.coordinates return Desc(shape, coordinates) + + +# Monkey patch mpl_data_containers for Desc isinstance checks +try: + from mpl_data_containers import description + description.Desc = Desc +except ImportError: + pass From 4495d704d320e3195a0586043e316d6fd1001f03 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Thu, 10 Sep 2026 13:16:17 -0500 Subject: [PATCH 6/8] Remove unused container classes --- lib/matplotlib/_data_containers/containers.py | 200 ------------------ 1 file changed, 200 deletions(-) diff --git a/lib/matplotlib/_data_containers/containers.py b/lib/matplotlib/_data_containers/containers.py index cd487a4b2c59..df7337b07604 100644 --- a/lib/matplotlib/_data_containers/containers.py +++ b/lib/matplotlib/_data_containers/containers.py @@ -12,7 +12,6 @@ from cachetools import LFUCache # type: ignore[import-untyped] import numpy as np -import pandas as pd from .description import Desc, desc_like @@ -111,23 +110,6 @@ def update(self, **data): self._cache_key = str(uuid.uuid4()) -class RandomContainer: - def __init__(self, **shapes): - self._desc = {k: Desc(s) for k, s in shapes.items()} - - def query( - self, - graph: Graph, - parent_coordinates: str = "axes", - ) -> tuple[dict[str, Any], Union[str, int]]: - return {k: np.random.randn(*d.shape) for k, d in self._desc.items()}, str( - uuid.uuid4() - ) - - def describe(self) -> dict[str, Desc]: - return dict(self._desc) - - class FuncContainer: def __init__( self, @@ -232,185 +214,3 @@ def query( def describe(self) -> dict[str, Desc]: return dict(self._desc) - - -class HistContainer: - def __init__(self, raw_data, num_bins: int): - self._raw_data = raw_data - self._num_bins = num_bins - self._desc = { - "edges": Desc((num_bins + 1 + 2,)), - "density": Desc((num_bins + 2,)), - } - self._full_range = (raw_data.min(), raw_data.max()) - self._cache: MutableMapping[Union[str, int], Any] = LFUCache(64) - - def query( - self, - graph: Graph, - parent_coordinates: str = "axes", - ) -> tuple[dict[str, Any], Union[str, int]]: - dmin, dmax = self._full_range - - desc = Desc(("N",)) - xy = {"x": desc, "y": desc} - data_lim = graph.evaluator( - desc_like(xy, coordinates="data"), - desc_like(xy, coordinates=parent_coordinates), - ).inverse - - pts = data_lim.evaluate({"x": (0, 1), "y": (0, 1)}) - xmin, xmax = pts["x"] - ymin, ymax = pts["y"] - - xmin, xmax = np.clip([xmin, xmax], dmin, dmax) - hash_key = hash((xmin, xmax)) - if hash_key in self._cache: - return self._cache[hash_key], hash_key - # TODO this gives an artifact with high lw - edges_in = [] - if dmin < xmin: - edges_in.append(np.array([dmin])) - edges_in.append(np.linspace(xmin, xmax, self._num_bins)) - if xmax < dmax: - edges_in.append(np.array([dmax])) - - density, edges = np.histogram( - self._raw_data, - bins=np.concatenate(edges_in), - density=True, - ) - ret = self._cache[hash_key] = {"edges": edges, "density": density} - return ret, hash_key - - def describe(self) -> dict[str, Desc]: - return dict(self._desc) - - -class SeriesContainer: - _data: pd.Series - _index_name: str - _hash_key: str - - def __init__(self, series: pd.Series, *, index_name: str, col_name: str): - # TODO make a copy? - self._data = series - self._index_name = index_name - self._col_name = col_name - self._desc = { - index_name: Desc((len(series),)), - col_name: Desc((len(series),)), - } - self._hash_key = str(uuid.uuid4()) - - def query( - self, - graph: Graph, - parent_coordinates: str = "axes", - ) -> tuple[dict[str, Any], Union[str, int]]: - return { - self._index_name: self._data.index.values, - self._col_name: self._data.values, - }, self._hash_key - - def describe(self) -> dict[str, Desc]: - return dict(self._desc) - - -class DataFrameContainer: - _data: pd.DataFrame - - def __init__( - self, - df: pd.DataFrame, - *, - col_names: Union[Callable[[str], str], dict[str, str]], - index_name: Optional[str] = None, - ): - # TODO make a copy? - self._data = df - self._index_name = index_name - - if callable(col_names): - # TODO cache the function so we can replace the dataframe later? - self._col_name_dict = {k: col_names(k) for k in df.columns} - else: - self._col_name_dict = dict(col_names) - - self._desc: dict[str, Desc] = {} - if self._index_name is not None: - self._desc[self._index_name] = Desc((len(df),)) - for col, out in self._col_name_dict.items(): - self._desc[out] = Desc((len(df),)) - - self._hash_key = str(uuid.uuid4()) - - def query( - self, - graph: Graph, - parent_coordinates: str = "axes", - ) -> tuple[dict[str, Any], Union[str, int]]: - ret: dict[str, Any] = {} - if self._index_name is not None: - ret[self._index_name] = self._data.index.values - for col, out in self._col_name_dict.items(): - ret[out] = self._data[col].values - - return ret, self._hash_key - - def describe(self) -> dict[str, Desc]: - return dict(self._desc) - - -class ReNamer: - def __init__(self, data: DataContainer, mapping: dict[str, str]): - # TODO: check all the asked for key exist - self._data = data - self._mapping = mapping - - def query( - self, - graph: Graph, - parent_coordinates: str = "axes", - ) -> tuple[dict[str, Any], Union[str, int]]: - base, cache_key = self._data.query(graph, parent_coordinates) - return {v: base[k] for k, v in self._mapping.items()}, cache_key - - def describe(self): - base = self._data.describe() - return {v: base[k] for k, v in self._mapping.items()} - - -class DataUnion: - def __init__(self, *data: DataContainer): - # TODO check no collisions - self._datas = data - - def query( - self, - graph: Graph, - parent_coordinates: str = "axes", - ) -> tuple[dict[str, Any], Union[str, int]]: - cache_keys = [] - ret = {} - for data in self._datas: - base, cache_key = data.query(graph, parent_coordinates) - ret.update(base) - cache_keys.append(cache_key) - return ret, hash(tuple(cache_keys)) - - def describe(self): - return {k: v for d in self._datas for k, v in d.describe().items()} - - -class WebServiceContainer: - def query( - self, - graph: Graph, - parent_coordinates: str = "axes", - ) -> tuple[dict[str, Any], Union[str, int]]: - def hit_some_database(): - return {}, "1" - - data, etag = hit_some_database() - return data, etag From 4c3f2057c3c23928cd8cf9c4c3621bd69714773e Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Thu, 10 Sep 2026 13:55:01 -0500 Subject: [PATCH 7/8] Fix caching on FuncContainer --- lib/matplotlib/_data_containers/containers.py | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/matplotlib/_data_containers/containers.py b/lib/matplotlib/_data_containers/containers.py index df7337b07604..fa3c650a1bc2 100644 --- a/lib/matplotlib/_data_containers/containers.py +++ b/lib/matplotlib/_data_containers/containers.py @@ -143,7 +143,6 @@ def __init__( xfuncs, yfuncs, xyfuncs : dict[str, tuple[shape, func]] """ - # TODO validate no collisions self._desc: dict[str, Desc] = {} def _split(input_dict): @@ -158,10 +157,9 @@ def _split(input_dict): self._xyfuncs = _split(xyfuncs) if xyfuncs is not None else {} self._cache: MutableMapping[Union[str, int], Any] = LFUCache(64) - def _query_hash(self, coord_transform, size): - # TODO find a better way to compute the hash key, this is not sentative to - # scale changes, only limit changes - data_bounds = tuple(coord_transform.transform([[0, 0], [1, 1]]).flatten()) + def _query_hash(self, data_lim, size): + xlims, ylims = data_lim.evaluate({"x": [0, 1], "y": [0, 1]}).values() + data_bounds = (*(float(x) for x in xlims), *(float(y) for y in ylims)) hash_key = hash((data_bounds, size)) return hash_key @@ -170,10 +168,6 @@ def query( graph: Graph, parent_coordinates: str = "axes", ) -> tuple[dict[str, Any], Union[str, int]]: - # hash_key = self._query_hash(coord_transform, size) - # if hash_key in self._cache: - # return self._cache[hash_key], hash_key - desc = Desc(("N",)) xy = {"x": desc, "y": desc} data_lim = graph.evaluator( @@ -190,21 +184,26 @@ def query( xpix, ypix = np.ceil(np.abs(np.diff(screen_dims["x"]))), np.ceil( np.abs(np.diff(screen_dims["y"])) ) + xpix = int(xpix) + ypix = int(ypix) + + hash_key = self._query_hash(data_lim, (xpix, ypix)) + if hash_key in self._cache: + return self._cache[hash_key], hash_key x_data = data_lim.evaluate( { - "x": np.linspace(0, 1, int(xpix) * 2), - "y": np.zeros(int(xpix) * 2), + "x": np.linspace(0, 1, xpix * 2), + "y": np.zeros(xpix * 2), } )["x"] y_data = data_lim.evaluate( { - "x": np.zeros(int(ypix) * 2), - "y": np.linspace(0, 1, int(ypix) * 2), + "x": np.zeros(ypix * 2), + "y": np.linspace(0, 1, ypix * 2), } )["y"] - hash_key = str(uuid.uuid4()) ret = self._cache[hash_key] = dict( **{k: f(x_data) for k, f in self._xfuncs.items()}, **{k: f(y_data) for k, f in self._yfuncs.items()}, From 17ded9b7facf34ac681de2ec7508dd30b70efda9 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Thu, 10 Sep 2026 14:03:02 -0500 Subject: [PATCH 8/8] Update caching logic on lines --- lib/matplotlib/_data_containers/_helpers.py | 5 +++++ lib/matplotlib/lines.py | 15 ++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/_data_containers/_helpers.py b/lib/matplotlib/_data_containers/_helpers.py index b48c5743941f..a747cd54ac78 100644 --- a/lib/matplotlib/_data_containers/_helpers.py +++ b/lib/matplotlib/_data_containers/_helpers.py @@ -49,3 +49,8 @@ def _get_graph(ax): aliases=(("parent", "axes"),), ) return implicit_graph + + +def check_container(artist, container_cls, operation="This operation"): + if not isinstance(artist._container, container_cls): + raise TypeError(f"{operation} is not available with a custom container class") diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 3399db32f610..e01b89a329cf 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -19,7 +19,7 @@ from .path import Path from .transforms import Bbox, BboxTransformTo, TransformedPath from ._enums import JoinStyle, CapStyle -from ._data_containers._helpers import containerize_draw, _get_graph, check_container +from ._data_containers._helpers import _get_graph, check_container from ._data_containers.description import Desc # Imported here for backward compatibility, even though they don't @@ -382,6 +382,7 @@ def __init__(self, xdata, ydata, *, self._container = self._init_container() self.__query = None + self.__query_hash = None # Convert sequences to NumPy arrays. if not np.iterable(xdata): @@ -513,7 +514,9 @@ def _query(self): return self._container.query(_get_graph(self.axes))[0] def _cache_query(self): - self.__query = self._container.query(_get_graph(self.axes))[0] + self.__query, query_hash = self._container.query(_get_graph(self.axes)) + self._invalidx = self._invalidy = (query_hash != self.__query_hash) + self.__query_hash = query_hash def contains(self, mouseevent): """ @@ -838,8 +841,7 @@ def set_transform(self, t): super().set_transform(t) @allow_rasterization - @containerize_draw - def draw(self, renderer, *, graph=None): + def draw(self, renderer): # docstring inherited if not self.get_visible(): @@ -847,8 +849,9 @@ def draw(self, renderer, *, graph=None): self._cache_query() - if self._invalidy or self._invalidx: + if self._invalidx or self._invalidy: self.recache() + self.ind_offset = 0 # Needed for contains() method. if self._subslice and self.axes: x0, x1 = self.axes.get_xbound() @@ -1405,6 +1408,7 @@ def set_xdata(self, x): raise RuntimeError('x must be a sequence') self._container.x = copy.copy(x) self.__query = None + self.__query_hash = None self._invalidx = True self.stale = True @@ -1426,6 +1430,7 @@ def set_ydata(self, y): raise RuntimeError('y must be a sequence') self._container.y = copy.copy(y) self.__query = None + self.__query_hash = None self._invalidy = True self.stale = True