"""GlyphX Bar3DSeries — 3D bar chart."""
from __future__ import annotations
import numpy as np
from .projection3d import Camera3D, normalize, _format_3d_tick
from .colormaps import apply_colormap, colormap_colors
from .utils import svg_escape
[docs]
class Bar3DSeries:
"""
3D bar chart — one rectangular bar per (x, y) grid cell, height = z.
Args:
x: 1-D X positions (bar centres).
y: 1-D Y positions (bar centres).
z: 2-D Z matrix (heights), shape (len(y), len(x)), or 1-D if
x and y are already paired point arrays.
dx: Bar width along X (default: auto from grid spacing).
dy: Bar depth along Y (default: auto from grid spacing).
cmap: Colormap name for bar colours (mapped by height).
alpha: Bar opacity.
label: Legend label.
"""
def __init__(
self,
x, y, z,
dx: float | None = None,
dy: float | None = None,
cmap: str = "viridis",
alpha: float = 0.85,
label: str | None = None,
) -> None:
self.x_1d = list(x)
self.y_1d = list(y)
self.cmap = cmap
self.alpha = float(alpha)
self.label = label
z_arr = np.asarray(z, dtype=float)
if z_arr.ndim == 1:
# Paired points (len(x) == len(y) == len(z))
self.paired = True
self.z_vals = z_arr.tolist()
else:
# Grid: z[j][i] = height at (x[i], y[j])
self.paired = False
self.z_mat = z_arr.tolist()
self.z_vals = z_arr.flatten().tolist()
z_flat = np.asarray(self.z_vals)
self._z_min = float(z_flat.min())
self._z_max = float(z_flat.max())
self._z_span = self._z_max - self._z_min or 1.0
# Auto bar width from spacing
self._dx = dx if dx else (self.x_1d[1] - self.x_1d[0]) * 0.7 if len(self.x_1d) > 1 else 0.5
self._dy = dy if dy else (self.y_1d[1] - self.y_1d[0]) * 0.7 if len(self.y_1d) > 1 else 0.5
def _bar_color(self, z_val: float) -> str:
norm = (z_val - self._z_min) / self._z_span
return apply_colormap(norm, self.cmap)
[docs]
def to_svg(self, cam: Camera3D, x_range, y_range, z_range) -> str:
xn, xlo, xhi = normalize(self.x_1d)
yn, ylo, yhi = normalize(self.y_1d)
# Scale bar dims proportionally
x_span = xhi - xlo or 1
y_span = yhi - ylo or 1
dx_n = self._dx / x_span
dy_n = self._dy / y_span
all_z = self.z_vals
z_max = max(all_z)
zn_scale = lambda z: z / (self._z_max or 1) * 1.8 - 0.9
bars = []
if self.paired:
for i, (xi, yi, zi) in enumerate(zip(xn, yn, self.z_vals)):
bars.append((xi, yi, zi, self._bar_color(zi)))
else:
nx, ny = len(self.x_1d), len(self.y_1d)
for j in range(ny):
for i in range(nx):
zi = self.z_mat[j][i]
bars.append((xn[i], yn[j], zi, self._bar_color(zi)))
# Sort back-to-front
def _bar_depth(b):
return cam.project(b[0], b[1], 0).depth
bars.sort(key=_bar_depth)
elements: list[str] = []
for bx, by, bz, col in bars:
bz_n = zn_scale(bz)
hw = dx_n / 2
hd = dy_n / 2
# 8 corners of the bar box
corners_3d = [
(bx-hw, by-hd, -0.9), (bx+hw, by-hd, -0.9),
(bx+hw, by+hd, -0.9), (bx-hw, by+hd, -0.9),
(bx-hw, by-hd, bz_n), (bx+hw, by-hd, bz_n),
(bx+hw, by+hd, bz_n), (bx-hw, by+hd, bz_n),
]
c = [cam.project(*pt) for pt in corners_3d]
def face(indices, shade=1.0):
pts = " ".join(f"{c[k].px:.1f},{c[k].py:.1f}" for k in indices)
r, g, b_ = int(col[1:3],16), int(col[3:5],16), int(col[5:7],16)
sr = min(255, int(r * shade))
sg = min(255, int(g * shade))
sb = min(255, int(b_ * shade))
shaded = f"#{sr:02x}{sg:02x}{sb:02x}"
return (f'<polygon points="{pts}" fill="{shaded}" '
f'fill-opacity="{self.alpha}" stroke="#fff" stroke-width="0.3"/>')
elements.append(face([4,5,6,7], 1.0)) # top
elements.append(face([0,1,5,4], 0.80)) # front
elements.append(face([1,2,6,5], 0.65)) # right
return "\n".join(elements)
[docs]
def to_threejs_data(self) -> dict:
bars = []
if self.paired:
for xi, yi, zi in zip(self.x_1d, self.y_1d, self.z_vals):
bars.append({"x": xi, "y": yi, "z": zi,
"color": self._bar_color(zi),
"dx": self._dx, "dy": self._dy})
else:
nx, ny = len(self.x_1d), len(self.y_1d)
for j in range(ny):
for i in range(nx):
zi = self.z_mat[j][i]
bars.append({"x": self.x_1d[i], "y": self.y_1d[j],
"z": zi, "color": self._bar_color(zi),
"dx": self._dx, "dy": self._dy})
return {"type": "bar3d", "bars": bars, "alpha": self.alpha,
"label": self.label or ""}