diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml
index ecd82e2ab..b98adfa3d 100644
--- a/.github/workflows/test_and_build.yml
+++ b/.github/workflows/test_and_build.yml
@@ -215,9 +215,10 @@ jobs:
echo "NUMBA_THREADING_LAYER=workqueue" >> "$GITHUB_ENV"
fi
- # Decide whether to install numba and sparse.
+ # Decide whether to install numba and the packages that depend on it.
# Random skip: pypy always, 20% on Windows, 40% on other OSes (unless notebooks).
awkward=awkward${akver}
+ datashade=""
if [[ ${{ contains(steps.pyver.outputs.selected, 'pypy') }} == true ||
( ${{ matrix.slowtask != 'notebooks'}} == true && (
( ${{ matrix.os == 'windows-latest' }} == true && $(python -c 'import random ; print(random.random() < .2)') == True ) ||
@@ -246,6 +247,10 @@ jobs:
sparsever=NA
else
numba=numba${numbaver}
+ # graphblas.viz.datashade's dependencies, which the notebooks job installs so its
+ # tests run and count for coverage. datashader depends on numba, so they come
+ # only where numba does.
+ datashade="datashader holoviews hvplot bokeh"
if [[ $sparsever == "NA" ]] ; then
sparse=""
else
@@ -280,7 +285,7 @@ jobs:
pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \
networkx${nxver} ${numba} ${psg} \
${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \
- ${{ matrix.slowtask == 'notebooks' && '"matplotlib${mplver}" nbconvert jupyter "ipython>=7" drawsvg' || '' }} \
+ ${{ matrix.slowtask == 'notebooks' && '"matplotlib${mplver}" nbconvert jupyter "ipython>=7" drawsvg ${datashade}' || '' }} \
${{ steps.sourcetype.outputs.selected == 'upstream' && 'cython' || '' }} \
${graphblas} ${blas:+"$blas"} \
${interpreter} \
diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py
new file mode 100644
index 000000000..faeee2670
--- /dev/null
+++ b/graphblas/tests/test_viz.py
@@ -0,0 +1,345 @@
+"""Smoke tests for graphblas.viz.
+
+The viz module is optional-dependency heavy (matplotlib, networkx, scipy for
+``spy``/``draw``; datashader + holoviews + hvplot + bokeh + pandas for
+``datashade``). These tests only check that each public function runs end to end
+under the headless Agg backend and populates a figure/returns an object; they do
+not assert on pixel output. Anything missing is skipped, not failed, so a
+minimal-dependency CI run sees clean skips.
+"""
+
+import importlib
+import inspect
+import math
+import warnings
+
+import pytest
+
+from graphblas import Matrix, Vector, backend, dtypes, viz
+from graphblas.core import _supports_udfs as supports_udfs
+
+
+def _importorskip(modname):
+ """Skip when an optional dependency is missing *or* installed but unusable.
+
+ From pytest 9.1 on, ``pytest.importorskip`` counts only ``ModuleNotFoundError``
+ as "missing". A dependency that is installed but cannot run raises a plain
+ ``ImportError`` instead (matplotlib does exactly that when numpy is older than
+ it supports), which escapes ``importorskip`` and aborts collection for the
+ whole session. pytest's ``exc_type`` argument covers that, but it only exists
+ in pytest >=8.2 and this project supports pytest >=6.2, so do the import here
+ and hand the already-imported module to pytest.
+ """
+ try:
+ with warnings.catch_warnings():
+ # ``importorskip`` ignores warnings while importing; match it, or
+ # ``filterwarnings = error`` would fail on whatever an optional
+ # dependency happens to emit at import time.
+ warnings.simplefilter("ignore")
+ importlib.import_module(modname)
+ except ImportError as exc:
+ pytest.skip(f"could not import {modname!r}: {exc}", allow_module_level=True)
+ return pytest.importorskip(modname)
+
+
+# Skip the whole module if matplotlib is absent (draw and spy both need it).
+# Set the backend to Agg before pyplot is imported so no display is required.
+mpl = _importorskip("matplotlib")
+mpl.use("Agg")
+plt = _importorskip("matplotlib.pyplot")
+
+
+@pytest.fixture(autouse=True)
+def _close_figures():
+ # Close every figure after each test to avoid matplotlib's
+ # "More than 20 figures have been opened" warning (which the project's
+ # ``filterwarnings = error`` config would turn into a failure).
+ yield
+ plt.close("all")
+
+
+def square_matrix():
+ # Small square adjacency matrix with distinct weights.
+ return Matrix.from_coo([0, 0, 1, 2], [1, 2, 2, 0], [1.0, 2.0, 3.0, 4.0], nrows=3, ncols=3)
+
+
+def test_spy_default():
+ _importorskip("scipy.sparse")
+ A = square_matrix()
+ fig = viz.spy(A, show=False)
+ assert isinstance(fig, mpl.figure.Figure)
+ assert fig.axes, "spy should populate at least one Axes"
+ # matplotlib's Axes.spy draws the pattern as a single markered Line2D.
+ assert fig.axes[0].lines, "spy should plot the sparsity markers"
+
+
+def test_spy_centered():
+ # centered=True skips the tick-offset fixup branch.
+ _importorskip("scipy.sparse")
+ A = square_matrix()
+ fig = viz.spy(A, show=False, centered=True)
+ assert isinstance(fig, mpl.figure.Figure)
+ assert fig.axes[0].lines
+
+
+def test_spy_with_axes():
+ # Passing an explicit Axes exercises the ``axes is not None`` branch,
+ # including the auto-markersize path (which once raised NameError here).
+ _importorskip("scipy.sparse")
+ A = square_matrix()
+ fig = mpl.figure.Figure()
+ axes = fig.subplots()
+ result = viz.spy(A, show=False, axes=axes)
+ assert result is fig
+ assert axes.lines
+
+
+def test_spy_with_figure():
+ # Passing an explicit Figure (no Axes) once raised NameError; spy should
+ # create the Axes on the given figure and return that same figure.
+ _importorskip("scipy.sparse")
+ A = square_matrix()
+ fig = mpl.figure.Figure()
+ result = viz.spy(A, show=False, figure=fig)
+ assert result is fig
+ assert fig.axes
+ assert fig.axes[0].lines
+
+
+@pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive")
+def test_draw():
+ # draw() renders onto the current pyplot Axes via networkx and calls
+ # plt.show(); on Agg that show() emits the non-interactive UserWarning,
+ # which we ignore here.
+ _importorskip("networkx")
+ _importorskip("scipy.sparse")
+ A = square_matrix()
+ viz.draw(A)
+ axes = plt.gcf().get_axes()
+ assert axes, "draw should populate the current figure"
+ ax = axes[0]
+ # Nodes render as patches/collections and labels as texts.
+ assert ax.collections or ax.patches
+ assert ax.texts, "draw should render node/edge labels"
+
+
+def test_draw_rejects_non_matrix():
+ _importorskip("networkx")
+ v = Vector.from_coo([0, 1, 2], [1.0, 2.0, 3.0])
+ with pytest.raises(TypeError, match="Can only draw a Matrix"):
+ viz.draw(v)
+
+
+@pytest.mark.parametrize(("nrows", "ncols"), [(2, 5), (5, 2)])
+def test_draw_rejects_non_square(nrows, ncols):
+ # A rectangular Matrix used to draw as a graph of its rows plus whichever
+ # columns had entries.
+ _importorskip("networkx")
+ M = Matrix.from_coo([0, 1], [1, 0], [1.0, 2.0], nrows=nrows, ncols=ncols)
+ with pytest.raises(ValueError, match=rf"square Matrix, not shape \({nrows}, {ncols}\)"):
+ viz.draw(M)
+
+
+@pytest.mark.skipif("not dtypes._supports_complex")
+def test_draw_rejects_complex():
+ # networkx's layout used to raise "float() argument must be ... not 'complex'"
+ _importorskip("networkx")
+ M = Matrix.from_coo([0, 1], [1, 0], [1j, 2], nrows=2, ncols=2)
+ with pytest.raises(TypeError, match="booleans or real numbers, not FC64"):
+ viz.draw(M)
+
+
+@pytest.mark.skipif(
+ not supports_udfs and backend == "suitesparse-vanilla",
+ reason="building a UDT Matrix needs binary.any on it, which vanilla has only through Numba",
+)
+def test_draw_rejects_udt():
+ # networkx's layout used to raise "shape mismatch: value array of shape (2,2) ..."
+ _importorskip("networkx")
+ import numpy as np
+
+ udt = dtypes.register_anonymous(np.dtype([("viz_draw_a", np.int32), ("viz_draw_b", np.int8)]))
+ M = Matrix.from_coo([0, 1], [1, 0], [(1, 2), (3, 4)], nrows=2, ncols=2, dtype=udt)
+ with pytest.raises(TypeError, match="booleans or real numbers"):
+ viz.draw(M)
+
+
+@pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive")
+def test_draw_reciprocal_edges_both_labels_visible():
+ # Regression for gh-474: reciprocal directed edges (0->1 and 1->0) used to be
+ # drawn as coincident straight lines, so one weight hid the other. draw() now
+ # curves reciprocal pairs; both weights must appear at distinct positions.
+ nx = _importorskip("networkx")
+ _importorskip("scipy.sparse")
+ # draw() only curves reciprocal pairs when networkx can place edge labels along
+ # the curve; without that it deliberately draws every edge straight, and the two
+ # labels then coincide (which test_draw_without_networkx_curved_label_support
+ # covers). Ask the same question draw() asks, so the two cannot drift apart.
+ if "connectionstyle" not in inspect.signature(nx.draw_networkx_edge_labels).parameters:
+ pytest.skip("networkx <3.3: draw_networkx_edge_labels has no connectionstyle")
+ M = Matrix.from_coo([0, 1], [1, 0], [10, 20], nrows=2, ncols=2)
+ viz.draw(M)
+ ax = plt.gcf().get_axes()[0]
+
+ weight_labels = [t for t in ax.texts if t.get_text() in {"10", "20"}]
+ assert {t.get_text() for t in weight_labels} == {"10", "20"}, "both weights must be drawn"
+ assert len(weight_labels) == 2
+
+ # Old behavior placed both labels on the shared straight-line midpoint.
+ # networkx returns two anchors there that differ only by floating-point
+ # noise (order 1e-6), so an exact ``!=`` comparison passes even when the
+ # labels sit on top of each other. Require a separation that is a real
+ # fraction of the distance between the two nodes instead.
+ node_positions = [t.get_position() for t in ax.texts if t.get_text() in {"0", "1"}]
+ assert len(node_positions) == 2
+ edge_length = math.dist(*node_positions)
+ separation = math.dist(*(t.get_position() for t in weight_labels))
+ assert separation > 0.001 * edge_length, "reciprocal edge labels still overlap"
+
+
+@pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive")
+def test_draw_without_networkx_curved_label_support(monkeypatch):
+ # draw_networkx_edge_labels gained connectionstyle in networkx 3.3, and the
+ # project supports >=2.8. Standing in a pre-3.3 signature must not raise; the
+ # gh-474 curving is skipped and every edge renders straight, as it did before.
+ nx = _importorskip("networkx")
+ _importorskip("scipy.sparse")
+ real = nx.draw_networkx_edge_labels
+
+ def pre_33_draw_networkx_edge_labels(g, pos, edge_labels=None, **kwargs):
+ if "connectionstyle" in kwargs: # pragma: no cover (only on failure)
+ raise TypeError(
+ "draw_networkx_edge_labels() got an unexpected keyword argument "
+ "'connectionstyle'"
+ )
+ return real(g, pos, edge_labels=edge_labels, **kwargs)
+
+ monkeypatch.setattr(nx, "draw_networkx_edge_labels", pre_33_draw_networkx_edge_labels)
+ M = Matrix.from_coo([0, 1], [1, 0], [10, 20], nrows=2, ncols=2)
+ viz.draw(M)
+ ax = plt.gcf().get_axes()[0]
+ assert {t.get_text() for t in ax.texts if t.get_text() in {"10", "20"}} == {"10", "20"}
+
+
+def _import_datashade_deps():
+ for name in ("numpy", "pandas", "datashader", "holoviews", "hvplot", "bokeh"):
+ _importorskip(name)
+
+
+def test_datashade_single():
+ _import_datashade_deps()
+ import holoviews as hv
+
+ A = square_matrix()
+ obj = viz.datashade(A)
+ assert obj is not None
+ assert isinstance(obj, hv.core.dimension.Dimensioned)
+
+
+def test_datashade_agg_list():
+ # A flat list of aggregators produces one row of linked plots.
+ _import_datashade_deps()
+ import holoviews as hv
+
+ A = square_matrix()
+ layout = viz.datashade(A, agg=["count", "sum"])
+ assert isinstance(layout, hv.Layout)
+
+
+def test_datashade_agg_grid():
+ # A list-of-lists produces a 2d grid of linked plots.
+ _import_datashade_deps()
+ import holoviews as hv
+
+ A = square_matrix()
+ layout = viz.datashade(A, agg=[["count", "sum"], ["min", "max"]])
+ assert isinstance(layout, hv.Layout)
+
+
+def test_datashade_empty_agg():
+ # An empty aggregator list is a no-op that returns None.
+ _import_datashade_deps()
+ A = square_matrix()
+ assert viz.datashade(A, agg=[]) is None
+
+
+def test_datashade_positions_match_spy():
+ # Regression for gh-473: element (row=r, col=c) must render centered on the
+ # integer tick pair (col, row), the same convention ``spy`` uses. We check
+ # the datashader aggregation directly (no display) over the limits the
+ # interactive path uses, at one pixel per matrix cell.
+ _import_datashade_deps()
+ import datashader as ds
+ import numpy as np
+
+ # Non-square (3x4) with distinct row/col so a row<->col swap would show.
+ M = Matrix.from_coo([0, 0, 2], [1, 3, 3], [1.0, 1.0, 1.0], nrows=3, ncols=4)
+ df = viz._matrix_to_dataframe(M)
+ xlim, ylim = viz._cell_centered_limits(M)
+ assert xlim == (-0.5, M.ncols - 0.5)
+ assert ylim == (-0.5, M.nrows - 0.5)
+
+ canvas = ds.Canvas(plot_width=M.ncols, plot_height=M.nrows, x_range=xlim, y_range=ylim)
+ agg = canvas.points(df, "col", "row", ds.count())
+
+ # Pixel centers land on integers, so ticks label the cells they sit on.
+ assert agg.coords["col"].values.tolist() == [0.0, 1.0, 2.0, 3.0]
+ assert agg.coords["row"].values.tolist() == [0.0, 1.0, 2.0]
+
+ # Counts are nonzero exactly at the (row, col) indices of the elements.
+ xs = agg.coords["col"].values
+ ys = agg.coords["row"].values
+ nonzero = {(round(float(ys[i])), round(float(xs[j]))) for i, j in np.argwhere(agg.values > 0)}
+ assert nonzero == {(0, 1), (0, 3), (2, 3)}
+
+
+def test_datashade_bool():
+ # datashader aggregates only numbers. A boolean Matrix used to plot blank, with
+ # holoviews logging "input must be numeric" when it rendered; now its values
+ # plot as 0 and 1.
+ _import_datashade_deps()
+ import numpy as np
+
+ M = Matrix.from_coo([0, 2], [3, 1], [True, False], nrows=3, ncols=4)
+ nan = np.nan
+ for agg, expected in [
+ ("sum", [[nan, nan, nan, 1], [nan, nan, nan, nan], [nan, 0, nan, nan]]),
+ ("count", [[0, 0, 0, 1], [0, 0, 0, 0], [0, 1, 0, 0]]),
+ ]:
+ # Indexing the lazy DynamicMap computes the image, as rendering would
+ image = viz.datashade(M, agg=agg)[()]
+ values = image.data[image.vdims[0].name]
+ assert values.dims == ("row", "col")
+ np.testing.assert_array_equal(values.values, expected)
+
+
+@pytest.mark.skipif("not dtypes._supports_complex")
+def test_datashade_rejects_complex():
+ # Rendering used to fail later with a numba TypingError that holoviews only
+ # logged, leaving a blank plot.
+ _import_datashade_deps()
+ M = Matrix.from_coo([0, 2], [3, 1], [1j, 2], nrows=3, ncols=4)
+ with pytest.raises(TypeError, match="booleans or real numbers, not FC64"):
+ viz.datashade(M)
+
+
+def test_datashade_rejects_udt():
+ # Rendering used to fail later with "NumPy datatype ... not supported"
+ _import_datashade_deps()
+ import numpy as np
+
+ udt = dtypes.register_anonymous(np.dtype([("viz_ds_a", np.int32), ("viz_ds_b", np.float64)]))
+ M = Matrix.from_coo([0, 2], [3, 1], [(1, 2.0), (3, 4.0)], nrows=3, ncols=4, dtype=udt)
+ with pytest.raises(TypeError, match="booleans or real numbers"):
+ viz.datashade(M)
+ with pytest.raises(TypeError, match="booleans or real numbers"):
+ viz.datashade(M, agg=["count", "sum"])
+
+
+@pytest.mark.parametrize(("nrows", "ncols"), [(0, 0), (3, 0), (0, 4)])
+def test_datashade_rejects_no_rows_or_columns(nrows, ncols):
+ # Rendering used to fail later with ZeroDivisionError
+ _import_datashade_deps()
+ M = Matrix(float, nrows, ncols)
+ with pytest.raises(ValueError, match="with rows and columns"):
+ viz.datashade(M)
diff --git a/graphblas/viz.py b/graphblas/viz.py
index 8e2a53228..67da128dc 100644
--- a/graphblas/viz.py
+++ b/graphblas/viz.py
@@ -42,12 +42,17 @@ def _get_imports(names, within):
return rv
-def draw(m): # pragma: no cover
+def draw(m):
"""Draw a square adjacency Matrix as a graph.
Requires `networkx `_ and
`matplotlib `_ to be installed.
+ Reciprocal directed edges (``u -> v`` and ``v -> u``) are drawn as curves so
+ both arrows and both edge weights stay visible; all other edges are straight.
+ Curving them needs networkx 3.3 or newer; with older versions every edge is
+ drawn straight.
+
Example output:
.. image:: /_static/img/draw-example.png
@@ -56,12 +61,53 @@ def draw(m): # pragma: no cover
typ = _output_type(m)
if typ is not _Matrix and typ is not _TransposedMatrix:
raise TypeError(f"Can only draw a Matrix, not {type(m)}")
+ if m.nrows != m.ncols:
+ # Row i and column i are both node i, so only a square Matrix is an adjacency matrix
+ raise ValueError(f"Can only draw a square Matrix, not shape {m.shape}")
+ if m.dtype._is_udt or m.dtype.np_type.kind == "c":
+ # The layout reads the values as edge weights, which networkx needs as floats
+ raise TypeError(f"Can only draw a Matrix of booleans or real numbers, not {m.dtype}")
g = to_networkx(m)
pos = nx.spring_layout(g)
- edge_labels = {(i, j): d["weight"] for i, j, d in g.edges(data=True)}
- nx.draw_networkx(g, pos, node_color="red", node_size=500)
- nx.draw_networkx_edge_labels(g, pos, edge_labels=edge_labels)
+ node_size = 500
+ nx.draw_networkx_nodes(g, pos, node_color="red", node_size=node_size)
+ nx.draw_networkx_labels(g, pos)
+
+ # A reciprocal pair (u -> v and v -> u) drawn as two straight lines coincides,
+ # hiding one edge's weight (python-graphblas #474). Curving both edges makes
+ # each bend toward its own side, so both arrows and both labels stay visible
+ # and attributable. Self-loops (u == v) are not reciprocal; leave them straight.
+ #
+ # networkx only learned to place edge labels along a curve in 3.3, and we
+ # support >=2.8, so fall back to the previous straight rendering without it.
+ # Curving the edges but not the labels would be worse than not curving at all:
+ # the labels would sit back on the shared chord midpoint, which is the overlap
+ # #474 is about, and they would no longer track their arrows. Check the
+ # parameter rather than pin a version.
+ import inspect
+
+ if "connectionstyle" in inspect.signature(nx.draw_networkx_edge_labels).parameters:
+ curved = {(u, v) for u, v in g.edges if u != v and g.has_edge(v, u)}
+ else:
+ curved = set()
+ straight = [e for e in g.edges if e not in curved]
+ connectionstyle = "arc3,rad=0.1"
+
+ def _edge_labels(edges):
+ return {(u, v): g[u][v]["weight"] for u, v in edges}
+
+ if straight:
+ nx.draw_networkx_edges(g, pos, edgelist=straight, node_size=node_size)
+ nx.draw_networkx_edge_labels(g, pos, edge_labels=_edge_labels(straight))
+ if curved:
+ curved = list(curved)
+ nx.draw_networkx_edges(
+ g, pos, edgelist=curved, node_size=node_size, connectionstyle=connectionstyle
+ )
+ nx.draw_networkx_edge_labels(
+ g, pos, edge_labels=_edge_labels(curved), connectionstyle=connectionstyle
+ )
plt.show()
@@ -88,12 +134,12 @@ def spy(M, *, centered=False, show=True, figure=None, axes=None, figsize=None, *
plt.show()
if axes is None:
if figure is None:
- fig = mpl.figure.Figure(figsize=figsize)
- axes = fig.subplots()
+ figure = mpl.figure.Figure(figsize=figsize)
+ axes = figure.subplots()
if kwargs.get("markersize") is None:
# Make the square markers "fill" their space
markersize = min(axes.bbox.width / A.shape[1], axes.bbox.height / A.shape[0])
- kwargs["markersize"] = max(0.002, markersize * 72 / fig.dpi)
+ kwargs["markersize"] = max(0.002, markersize * 72 / axes.figure.dpi)
axes.spy(A, **kwargs)
# Fix offsets
if not centered:
@@ -103,6 +149,35 @@ def spy(M, *, centered=False, show=True, figure=None, axes=None, figsize=None, *
return axes.figure
+def _matrix_to_dataframe(M):
+ """Build the ``(row, col, val)`` DataFrame that ``datashade`` rasterizes.
+
+ Factored out of ``datashade`` so the coordinate convention can be checked
+ without rendering an interactive plot (see ``_cell_centered_limits``).
+ """
+ np, pd = _get_imports(["np", "pd"], "datashade")
+ rows, cols, vals = M.to_coo()
+ # GraphBLAS dimensions are at most 2**60, so int64 holds every uint64 index
+ rows = rows.astype(np.int64)
+ cols = cols.astype(np.int64)
+ if vals.dtype == np.bool_:
+ # datashader aggregates only numbers, so a boolean Matrix plots as 0 and 1
+ vals = vals.astype(np.uint8)
+ return pd.DataFrame({"row": rows, "col": cols, "val": vals})
+
+
+def _cell_centered_limits(M):
+ """Axis limits that center each element on its integer index, like ``spy``.
+
+ datashader bins points into pixels by ``x_range``/``y_range``. With limits
+ ``(0, N)`` the pixel for index ``k`` spans ``[k, k+1)``, so an element lands
+ half a cell to the lower-right of the tick labeled ``k``. Offsetting the
+ limits by half a cell makes the pixel for index ``k`` span ``[k-0.5, k+0.5)``,
+ centered on tick ``k`` and matching what ``spy`` draws (python-graphblas #473).
+ """
+ return (-0.5, M.ncols - 0.5), (-0.5, M.nrows - 0.5)
+
+
def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kwargs):
"""Interactive plot of the sparsity pattern of a Matrix using hvplot and datashader.
@@ -132,19 +207,15 @@ def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kw
spy
"""
- np, pd, bk, hv, _hp, _ds = _get_imports(["np", "pd", "bk", "hv", "hp", "ds"], "datashade")
+ bk, hv, _hp, _ds = _get_imports(["bk", "hv", "hp", "ds"], "datashade")
+ # The plot renders lazily, and holoviews only logs an error raised then and
+ # shows a blank plot, so refuse up front what datashader cannot draw.
+ if M.dtype._is_udt or M.dtype.np_type.kind == "c":
+ raise TypeError(f"Can only datashade a Matrix of booleans or real numbers, not {M.dtype}")
+ if M.nrows == 0 or M.ncols == 0:
+ raise ValueError(f"Can only datashade a Matrix with rows and columns, not shape {M.shape}")
if "df" not in kwargs:
- rows, cols, vals = M.to_coo()
- max_int = np.iinfo(np.int64).max
- if M.nrows > max_int and rows.max() > max_int:
- rows = rows.astype(np.float64)
- else:
- rows = rows.astype(np.int64)
- if M.ncols > max_int and cols.max() > max_int:
- cols = cols.astype(np.float64)
- else:
- cols = cols.astype(np.int64)
- df = pd.DataFrame({"row": rows, "col": cols, "val": vals})
+ df = _matrix_to_dataframe(M)
else:
df = kwargs.pop("df")
@@ -183,6 +254,7 @@ def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kw
images.extend(image_row)
return hv.Layout(images).cols(ncols)
+ xlim, ylim = _cell_centered_limits(M)
kwds = {
"x": "col",
"y": "row",
@@ -192,8 +264,8 @@ def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kw
"frame_height": height,
"cmap": "fire",
"cnorm": "eq_hist",
- "xlim": (0, M.ncols),
- "ylim": (0, M.nrows),
+ "xlim": xlim,
+ "ylim": ylim,
"rasterize": True,
"flip_yaxis": True,
"hover": True,
diff --git a/pyproject.toml b/pyproject.toml
index e6372b708..b599bf27d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -219,9 +219,6 @@ filterwarnings = [
[tool.coverage.run]
branch = true
source = ["graphblas"]
-omit = [
- "graphblas/viz.py", # TODO: test and get coverage for viz.py
-]
[tool.coverage.report]
ignore_errors = false
diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh
index e99cb7ca6..a9bcb6d0f 100755
--- a/scripts/check_versions.sh
+++ b/scripts/check_versions.sh
@@ -25,5 +25,11 @@ conda search 'pyyaml[channel=conda-forge]>=6.0'
# matplotlib is installed unpinned for the notebooks job; ci_pick_versions.py caps it
# below 3.11 when the numpy pick predates matplotlib's numpy >=1.25 runtime floor.
conda search 'matplotlib-base[channel=conda-forge]>=3.11'
+# The notebooks job also installs these, unpinned, wherever it installs numba, which
+# datashader depends on. They are what graphblas.viz.datashade needs.
+conda search 'datashader[channel=conda-forge]>=0.19'
+conda search 'holoviews[channel=conda-forge]>=1.23'
+conda search 'hvplot[channel=conda-forge]>=0.12'
+conda search 'bokeh[channel=conda-forge]>=3.10'
conda search 'python-suitesparse-graphblas[channel=conda-forge]>=10.5.2'
# conda search 'python[channel=conda-forge]>=3.11 *pypy*'