Skip to content

Add Bar3DCollection for 3d bar graphs. #26075

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
23 changes: 23 additions & 0 deletions doc/users/next_whats_new/bar3d_plots.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
New and improved 3D bar plots
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

We fixed a long standing issue with incorrect z-sorting in 3d bar graphs.
It is now possible to produce 3D bar charts that render correctly for all
viewing angles by using `.Axes3D.bar3d_grid`. In addition, bar charts with
hexagonal cross section can now be created with `.Axes3Dx.hexbar3d`. This
supports visualisation of density maps on hexagonal tessellations of the data
space. Two new artist collections are introduced to support this functionality:
`.Bar3DCollection` and `.HexBar3DCollection`.


.. plot::
:include-source: true
:alt: Example of creating hexagonal 3D bars

import matplotlib.pyplot as plt
import numpy as np

fig, (ax1, ax2) = plt.subplots(1, 2, subplot_kw={'projection': '3d'})
bars3d = ax1.bar3d_grid([0, 1], [0, 1], [1, 2], '0.8', facecolors=('m', 'y'))
hexbars3d = ax2.hexbar3d([0, 1], [0, 1], [1, 2], '0.8', facecolors=('m', 'y'))
plt.show()
37 changes: 37 additions & 0 deletions galleries/examples/mplot3d/hexbin3d.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
========================================
3D Histogram with hexagonal bins
========================================

Demonstrates visualising a 3D density map of data using hexagonal tessellation.
"""

import matplotlib.pyplot as plt
import numpy as np

from matplotlib.cbook import hexbin

# Fixing random state for reproducibility
np.random.seed(42)

# Generate samples from mltivariate Gaussian
# Parameters
mu = (0, 0)
sigma = ([0.8, 0.3],
[0.3, 0.5])
n = 10_000
gridsize = 15
# draw samples
xy = np.random.multivariate_normal(mu, sigma, n)
# histogram samples with hexbin
xyz, (xmin, xmax), (ymin, ymax), (nx, ny) = hexbin(*xy.T, gridsize=gridsize,
mincnt=3)
# compute bar cross section size
dxy = np.array([(xmax - xmin) / nx, (ymax - ymin) / ny / np.sqrt(3)]) * 0.95

# plot
fig, ax = plt.subplots(subplot_kw={'projection': '3d'})
ax.hexbar3d(*xyz, dxy, cmap='plasma')
ax.set(xlabel='x', ylabel='y', zlabel='z')

plt.show()
105 changes: 6 additions & 99 deletions lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5507,112 +5507,19 @@ def reduce_C_function(C: array) -> float

x, y, C = cbook.delete_masked_points(x, y, C)

# Set the size of the hexagon grid
if np.iterable(gridsize):
nx, ny = gridsize
else:
nx = gridsize
ny = int(nx / math.sqrt(3))
# Count the number of data in each hexagon
x = np.asarray(x, float)
y = np.asarray(y, float)

# Will be log()'d if necessary, and then rescaled.
tx = x
ty = y

if xscale == 'log':
if np.any(x <= 0.0):
raise ValueError(
"x contains non-positive values, so cannot be log-scaled")
tx = np.log10(tx)
if yscale == 'log':
if np.any(y <= 0.0):
raise ValueError(
"y contains non-positive values, so cannot be log-scaled")
ty = np.log10(ty)
if extent is not None:
xmin, xmax, ymin, ymax = extent
if xmin > xmax:
raise ValueError("In extent, xmax must be greater than xmin")
if ymin > ymax:
raise ValueError("In extent, ymax must be greater than ymin")
else:
xmin, xmax = (tx.min(), tx.max()) if len(x) else (0, 1)
ymin, ymax = (ty.min(), ty.max()) if len(y) else (0, 1)

# to avoid issues with singular data, expand the min/max pairs
xmin, xmax = mtransforms.nonsingular(xmin, xmax, expander=0.1)
ymin, ymax = mtransforms.nonsingular(ymin, ymax, expander=0.1)

nx1 = nx + 1
ny1 = ny + 1
nx2 = nx
ny2 = ny
n = nx1 * ny1 + nx2 * ny2

# In the x-direction, the hexagons exactly cover the region from
# xmin to xmax. Need some padding to avoid roundoff errors.
padding = 1.e-9 * (xmax - xmin)
xmin -= padding
xmax += padding
(*offsets, accum), (xmin, xmax), (ymin, ymax), (nx, ny) = cbook.hexbin(
x, y, C, gridsize, xscale, yscale, extent, reduce_C_function, mincnt
)
offsets = np.transpose(offsets)
sx = (xmax - xmin) / nx
sy = (ymax - ymin) / ny
# Positions in hexagon index coordinates.
ix = (tx - xmin) / sx
iy = (ty - ymin) / sy
ix1 = np.round(ix).astype(int)
iy1 = np.round(iy).astype(int)
ix2 = np.floor(ix).astype(int)
iy2 = np.floor(iy).astype(int)
# flat indices, plus one so that out-of-range points go to position 0.
i1 = np.where((0 <= ix1) & (ix1 < nx1) & (0 <= iy1) & (iy1 < ny1),
ix1 * ny1 + iy1 + 1, 0)
i2 = np.where((0 <= ix2) & (ix2 < nx2) & (0 <= iy2) & (iy2 < ny2),
ix2 * ny2 + iy2 + 1, 0)

d1 = (ix - ix1) ** 2 + 3.0 * (iy - iy1) ** 2
d2 = (ix - ix2 - 0.5) ** 2 + 3.0 * (iy - iy2 - 0.5) ** 2
bdist = (d1 < d2)

if C is None: # [1:] drops out-of-range points.
counts1 = np.bincount(i1[bdist], minlength=1 + nx1 * ny1)[1:]
counts2 = np.bincount(i2[~bdist], minlength=1 + nx2 * ny2)[1:]
accum = np.concatenate([counts1, counts2]).astype(float)
if mincnt is not None:
accum[accum < mincnt] = np.nan

if C is None:
C = np.ones(len(x))
else:
# store the C values in a list per hexagon index
Cs_at_i1 = [[] for _ in range(1 + nx1 * ny1)]
Cs_at_i2 = [[] for _ in range(1 + nx2 * ny2)]
for i in range(len(x)):
if bdist[i]:
Cs_at_i1[i1[i]].append(C[i])
else:
Cs_at_i2[i2[i]].append(C[i])
if mincnt is None:
mincnt = 1
accum = np.array(
[reduce_C_function(acc) if len(acc) >= mincnt else np.nan
for Cs_at_i in [Cs_at_i1, Cs_at_i2]
for acc in Cs_at_i[1:]], # [1:] drops out-of-range points.
float)

good_idxs = ~np.isnan(accum)

offsets = np.zeros((n, 2), float)
offsets[:nx1 * ny1, 0] = np.repeat(np.arange(nx1), ny1)
offsets[:nx1 * ny1, 1] = np.tile(np.arange(ny1), nx1)
offsets[nx1 * ny1:, 0] = np.repeat(np.arange(nx2) + 0.5, ny2)
offsets[nx1 * ny1:, 1] = np.tile(np.arange(ny2), nx2) + 0.5
offsets[:, 0] *= sx
offsets[:, 1] *= sy
offsets[:, 0] += xmin
offsets[:, 1] += ymin
# remove accumulation bins with no data
offsets = offsets[good_idxs, :]
accum = accum[good_idxs]

polygon = [sx, sy / 3] * np.array(
[[.5, -.5], [.5, .5], [0., 1.], [-.5, .5], [-.5, -.5], [0., -1.]])
Expand Down
141 changes: 140 additions & 1 deletion lib/matplotlib/cbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,32 @@ def is_scalar_or_string(val):
return isinstance(val, str) or not np.iterable(val)


def get_sample_data(fname, asfileobj=True):
def duplicate_if_scalar(obj, n=2, raises=True):
"""Ensure object size or duplicate into a list if necessary."""

if is_scalar_or_string(obj):
return [obj] * n

size = len(obj)
if size == 0:
if raises:
raise ValueError(f'Cannot duplicate empty {type(obj)}.')
return [obj] * n

if size == 1:
return list(obj) * n

if (size != n) and raises:
raise ValueError(
f'Input object of type {type(obj)} has incorrect size. Expected '
f'either a scalar type object, or a Container with length in {{1, '
f'{n}}}.'
)

return obj


def get_sample_data(fname, asfileobj=True, *, np_load=True):
"""
Return a sample data file. *fname* is a path relative to the
:file:`mpl-data/sample_data` directory. If *asfileobj* is `True`
Expand Down Expand Up @@ -1430,6 +1455,120 @@ def _reshape_2D(X, name):
return result


def hexbin(x, y, C=None, gridsize=100,
xscale='linear', yscale='linear', extent=None,
reduce_C_function=np.mean, mincnt=None):

# local import to avoid circular import
import matplotlib.transforms as mtransforms

# Set the size of the hexagon grid
if np.iterable(gridsize):
nx, ny = gridsize
else:
nx = gridsize
ny = int(nx / math.sqrt(3))

# Will be log()'d if necessary, and then rescaled.
tx = x
ty = y

if xscale == 'log':
if np.any(x <= 0.0):
raise ValueError(
"x contains non-positive values, so cannot be log-scaled")
tx = np.log10(tx)
if yscale == 'log':
if np.any(y <= 0.0):
raise ValueError(
"y contains non-positive values, so cannot be log-scaled")
ty = np.log10(ty)
if extent is not None:
xmin, xmax, ymin, ymax = extent
if xmin > xmax:
raise ValueError("In extent, xmax must be greater than xmin")
if ymin > ymax:
raise ValueError("In extent, ymax must be greater than ymin")
else:
xmin, xmax = (tx.min(), tx.max()) if len(x) else (0, 1)
ymin, ymax = (ty.min(), ty.max()) if len(y) else (0, 1)

# to avoid issues with singular data, expand the min/max pairs
xmin, xmax = mtransforms.nonsingular(xmin, xmax, expander=0.1)
ymin, ymax = mtransforms.nonsingular(ymin, ymax, expander=0.1)

nx1 = nx + 1
ny1 = ny + 1
nx2 = nx
ny2 = ny
n = nx1 * ny1 + nx2 * ny2

# In the x-direction, the hexagons exactly cover the region from
# xmin to xmax. Need some padding to avoid roundoff errors.
padding = 1.e-9 * (xmax - xmin)
xmin -= padding
xmax += padding
sx = (xmax - xmin) / nx
sy = (ymax - ymin) / ny
# Positions in hexagon index coordinates.
ix = (tx - xmin) / sx
iy = (ty - ymin) / sy
ix1 = np.round(ix).astype(int)
iy1 = np.round(iy).astype(int)
ix2 = np.floor(ix).astype(int)
iy2 = np.floor(iy).astype(int)
# flat indices, plus one so that out-of-range points go to position 0.
i1 = np.where((0 <= ix1) & (ix1 < nx1) & (0 <= iy1) & (iy1 < ny1),
ix1 * ny1 + iy1 + 1, 0)
i2 = np.where((0 <= ix2) & (ix2 < nx2) & (0 <= iy2) & (iy2 < ny2),
ix2 * ny2 + iy2 + 1, 0)

d1 = (ix - ix1) ** 2 + 3.0 * (iy - iy1) ** 2
d2 = (ix - ix2 - 0.5) ** 2 + 3.0 * (iy - iy2 - 0.5) ** 2
bdist = (d1 < d2)

if C is None: # [1:] drops out-of-range points.
counts1 = np.bincount(i1[bdist], minlength=1 + nx1 * ny1)[1:]
counts2 = np.bincount(i2[~bdist], minlength=1 + nx2 * ny2)[1:]
accum = np.concatenate([counts1, counts2]).astype(float)
if mincnt is not None:
accum[accum < mincnt] = np.nan

else:
# store the C values in a list per hexagon index
Cs_at_i1 = [[] for _ in range(1 + nx1 * ny1)]
Cs_at_i2 = [[] for _ in range(1 + nx2 * ny2)]
for i in range(len(x)):
if bdist[i]:
Cs_at_i1[i1[i]].append(C[i])
else:
Cs_at_i2[i2[i]].append(C[i])
if mincnt is None:
mincnt = 1
accum = np.array(
[reduce_C_function(acc) if len(acc) >= mincnt else np.nan
for Cs_at_i in [Cs_at_i1, Cs_at_i2]
for acc in Cs_at_i[1:]], # [1:] drops out-of-range points.
float)

good_idxs = ~np.isnan(accum)

offsets = np.zeros((n, 2), float)
offsets[:nx1 * ny1, 0] = np.repeat(np.arange(nx1), ny1)
offsets[:nx1 * ny1, 1] = np.tile(np.arange(ny1), nx1)
offsets[nx1 * ny1:, 0] = np.repeat(np.arange(nx2) + 0.5, ny2)
offsets[nx1 * ny1:, 1] = np.tile(np.arange(ny2), nx2) + 0.5
offsets[:, 0] *= sx
offsets[:, 1] *= sy
offsets[:, 0] += xmin
offsets[:, 1] += ymin
# remove accumulation bins with no data
offsets = offsets[good_idxs, :]
accum = accum[good_idxs]

return (*offsets.T, accum), (xmin, xmax), (ymin, ymax), (nx, ny)


def violin_stats(X, method, points=100, quantiles=None):
"""
Return a list of dictionaries of data which can be used to draw a series
Expand Down
13 changes: 13 additions & 0 deletions lib/matplotlib/cbook.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
encoding: str | None = ...,
) -> contextlib.AbstractContextManager[IO]: ...
def is_scalar_or_string(val: Any) -> bool: ...
def duplicate_if_scalar(obj: Any, n: int = 2, raises: bool = True) -> list: ...
@overload
def get_sample_data(
fname: str | os.PathLike, asfileobj: Literal[True] = ...
Expand All @@ -83,6 +84,7 @@
def flatten(
seq: Iterable[Any], scalarp: Callable[[Any], bool] = ...
) -> Generator[Any, None, None]: ...
def pairwise(iterable: Iterable[Any]) -> Iterable[Any]: ...

Check failure on line 87 in lib/matplotlib/cbook.pyi

View workflow job for this annotation

GitHub Actions / mypy-stubtest

[mypy-stubtest] reported by reviewdog 🐶 matplotlib.cbook.pairwise is not present at runtime def (iterable: typing.Iterable[Any]) -> typing.Iterable[Any] Runtime: MISSING Raw Output: error: matplotlib.cbook.pairwise is not present at runtime Stub: in file /home/runner/work/matplotlib/matplotlib/lib/matplotlib/cbook.pyi:87 def (iterable: typing.Iterable[Any]) -> typing.Iterable[Any] Runtime: MISSING

class _Stack(Generic[_T]):
def __init__(self) -> None: ...
Expand Down Expand Up @@ -132,6 +134,17 @@

def contiguous_regions(mask: ArrayLike) -> list[np.ndarray]: ...
def is_math_text(s: str) -> bool: ...
def hexbin(
x: ArrayLike,
y: ArrayLike,
C: ArrayLike | None = None,
gridsize: int | tuple[int, int] = 100,
xscale: str = 'linear',
yscale: str = 'linear',
extent: ArrayLike | None = None,
reduce_C_function: Callable = np.mean,
mincnt: int | None = None
) -> tuple[tuple]: ...
def violin_stats(
X: ArrayLike, method: Callable, points: int = ..., quantiles: ArrayLike | None = ...
) -> list[dict[str, Any]]: ...
Expand Down
Loading
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy