Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion js/src/30_ticks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,35 @@ function fmtTime(ms, step) {
return `${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${pad(d.getUTCMilliseconds(), 3)}`;
}

// Mantissa digits so exponential tick labels stay distinct at `step`: the
// digits between the value's magnitude and the step's last significant digit
// (1.25e6 at step 2.5e5 -> (6 - 5) + 1 = 2 -> "1.25e6"). One fixed decimal
// labelled a 50,000-step axis "1.0e6, 1.1e6, 1.1e6, 1.2e6, …". Mirrors
// `_exp_digits` in python/xy/_svg.py exactly.
// Cap: 16 fractional digits are 17 significant, the most any two adjacent f64
// values need to print distinctly (1e6 and its next float at a one-ulp step);
// beyond that only representation noise would print.
const EXP_DIGITS_MAX = 16;

function expDigits(av, step) {
if (!step || !Number.isFinite(step) || av === 0) return 1;
step = Math.abs(step);
const eStep = Math.floor(Math.log10(step));
// 10 ** eStep underflows to 0 below 1e-308 and 10 ** -eStep overflows above
// 1e308, so a subnormal step is scaled in two stages instead of clamped —
// clamping threw the step's real exponent away and collapsed labels on a
// (legal) subnormal axis.
const mantissa = eStep < -300 ? (step * 1e300) * 10 ** (-eStep - 300) : step / 10 ** eStep;
let k = 0;
while (k < 8 && Math.abs(Number(mantissa.toFixed(k)) - mantissa) > mantissa / 1000) k++;
return Math.max(1, Math.min(EXP_DIGITS_MAX, Math.floor(Math.log10(av)) - eStep + k));
}

export function fmtLinear(v, step) {
const av = Math.abs(v);
if (av >= 1e6 || (av !== 0 && av < 1e-4)) return v.toExponential(1).replace("e+", "e");
if (av >= 1e6 || (av !== 0 && av < 1e-4)) {
return v.toExponential(expDigits(av, step)).replace("e+", "e");
Comment thread
Alek99 marked this conversation as resolved.
}
let dec = step ? Math.max(0, Math.ceil(-Math.log10(Math.abs(step)))) : 0;
while (dec < 8 && Math.abs(Number(step.toFixed(dec)) - step) > Math.abs(step) / 1000) dec++;
return v.toFixed(Math.min(dec, 8));
Expand Down
13 changes: 13 additions & 0 deletions news/507.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Five silent wrong-output defects are fixed. `facet_chart` now rejects a `by=`
array whose length differs from the data's row count instead of drawing the
whole dataset in every panel. An object-dtype column whose values are all real
numbers (a list holding a `None`, Decimals, a pandas object Series with NaN)
is ingested as numeric with NaN holes rather than becoming a categorical axis
with a `(missing)` label — the rule the color channel already applied.
Out-of-order positioned colormap stops raise instead of being clamped into a
near-solid ramp. `x_axis(type_="time"|"log"|"symlog")` on an axis the marks
made categorical is a build-time error instead of turning the labels into 1970
epoch ticks. And automatic tick labels past 1e6 (or below 1e-4) keep enough
mantissa digits to stay distinct at the tick step — "1.00e6, 1.05e6, 1.10e6"
instead of "1.0e6, 1.1e6, 1.1e6" — identically in the browser and every static
export.
28 changes: 27 additions & 1 deletion python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,8 @@ def _real_float_array(arr: np.ndarray, label: str) -> np.ndarray:
raise ValueError(f"{label} must be real numeric, not boolean")
if np.issubdtype(arr.dtype, np.complexfloating):
raise ValueError(f"{label} must be real numeric")
if arr.dtype == object:
arr = columns.object_missing_to_nan(arr)
try:
return arr.astype(np.float64, copy=False)
except (TypeError, ValueError) as e:
Expand Down Expand Up @@ -1230,7 +1232,14 @@ def _is_category_like(values: Any) -> bool:
# O(n) copy of the column.
values = values[:0].to_numpy(zero_copy_only=False)
arr = np.asarray(values)
return arr.dtype.kind in ("U", "S", "O", "b")
if arr.dtype.kind == "O":
# An object column whose non-missing values are all real numbers
# (a list holding a None, a CSV column read as object, Decimals)
# is numeric data with holes, not a set of categories — the same
# rule the color channel applies (`channels._object_array_is_real_
# numeric`). Strings, bytes, bools and mixed values stay categorical.
return not channels._object_array_is_real_numeric(arr.reshape(-1))
return arr.dtype.kind in ("U", "S", "b")

@staticmethod
def _category_axis_labels(values: Any, axis: str) -> list[str]:
Expand Down Expand Up @@ -1457,6 +1466,8 @@ def y_range(self) -> tuple[float, float]:
return self._range("y")

def _range(self, axis_id: str, *, use_domain: bool = True) -> tuple[float, float]:
# Before the log branch below can complain about positive values.
self._check_forced_scale(axis_id)
opts = self.axis_options.get(axis_id, {})
fixed = opts.get("domain")
if use_domain and fixed is not None:
Expand Down Expand Up @@ -1647,8 +1658,23 @@ def _axis_coord(self, axis_id: str, values: Any) -> np.ndarray:
return np.sign(v) * np.log1p(np.abs(v) / constant)
return v

def _check_forced_scale(self, axis_id: str) -> None:
"""A category axis is linear by construction (positions are the label
indices): forcing time turned the labels into 1970 epoch ticks and
forcing log put category 0 off the axis. G3: a scale conflict is a
build-time error, not a coercion. Membership, not truthiness — an empty
categorical mark registers the axis with no labels yet."""
forced = self.axis_options.get(axis_id, {}).get("type")
categories = self._axis_categories.get(axis_id)
if categories is not None and forced in ("time", "log", "symlog"):
raise ValueError(
f"{axis_id} axis is categorical ({len(categories)} categories from the "
f"marks) and cannot be a {forced} axis; drop type_= or pass numeric positions"
)

def _axis_kind(self, axis_id: str) -> str:
axis = self._axis_dim(axis_id)
self._check_forced_scale(axis_id)
forced = self.axis_options.get(axis_id, {}).get("type")
if forced == "time":
return "time"
Expand Down
52 changes: 51 additions & 1 deletion python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import re
from collections.abc import Callable, Sequence
from datetime import UTC, datetime
from decimal import ROUND_HALF_UP, Decimal
from itertools import pairwise
from os import PathLike
from typing import Any, NamedTuple, Optional, cast
Expand Down Expand Up @@ -672,10 +673,59 @@ def _fmt_time(ms: float, step: float) -> str:
return f"{d.minute:02d}:{d.second:02d}.{d.microsecond // 1000:03d}"


# Mantissa digits an exponential label may carry: 16 fractional digits are 17
# significant, the most any two adjacent f64 values need to print distinctly
# (1e6 and its next float at a one-ulp step); beyond that only representation
# noise would print.
_EXP_DIGITS_MAX = 16


def _exp_digits(av: float, step: float) -> int:
"""Mantissa digits so exponential tick labels stay distinct at `step`.

One fixed decimal labelled 1,000,000 … 1,300,000 at a 50,000 step as
"1.0e6, 1.1e6, 1.1e6, 1.2e6, 1.2e6, 1.3e6, 1.3e6". The mantissa needs the
digits between the value's magnitude and the step's last significant
digit: for 1.25e6 at step 2.5e5 that is (6 - 5) + 1 = 2 -> "1.25e6".
Mirrors `expDigits` in js/src/30_ticks.ts exactly.
"""
if not step or not np.isfinite(step) or av == 0:
return 1
step = abs(step)
e_step = int(np.floor(np.log10(step)))
# 10**e_step underflows to 0 below 1e-308 and 10**-e_step overflows above
# 1e308, so a subnormal step is scaled in two stages instead of clamped —
# clamping threw the step's real exponent away and collapsed labels on a
# (legal) subnormal axis.
mantissa = (step * 1e300) * 10.0 ** (-e_step - 300) if e_step < -300 else step / 10.0**e_step
k = 0
while k < 8 and abs(round(mantissa, k) - mantissa) > mantissa / 1000.0:
k += 1
return max(1, min(_EXP_DIGITS_MAX, int(np.floor(np.log10(av))) - e_step + k))


def _fmt_exponential(v: float, digits: int) -> str:
"""`v` as `d.ddde±N` with JavaScript's `toExponential` rounding.

Exact ties round half-up on the magnitude ("pick the larger n" in the
ECMAScript spec); Python's own `:e` rounds them half-even, so 1.25e6 at
one digit would read "1.3e6" in the browser and "1.2e6" in the PNG.
`Decimal(float)` is the exact binary value, so both sides see the same tie.
"""
exact = Decimal(abs(v))
exponent = exact.adjusted()
quantum = Decimal(1).scaleb(-digits)
mantissa = exact.scaleb(-exponent).quantize(quantum, rounding=ROUND_HALF_UP)
if mantissa >= 10: # 9.99 -> 10.0 carries into the exponent
mantissa = mantissa.scaleb(-1).quantize(quantum, rounding=ROUND_HALF_UP)
exponent += 1
return f"{'-' if v < 0 else ''}{mantissa}e{exponent}"


def _fmt_linear(v: float, step: float) -> str:
av = abs(v)
if av >= 1e6 or (av != 0 and av < 1e-4):
return f"{v:.1e}".replace("e+0", "e").replace("e-0", "e-").replace("e+", "e")
return _fmt_exponential(v, _exp_digits(av, step))
dec = max(0, int(np.ceil(-np.log10(abs(step))))) if step else 0
# A non-nice step (pi/2, 0.3333…) needs enough decimals to keep adjacent
# ticks distinct; widen until the step itself round-trips at that precision.
Expand Down
14 changes: 12 additions & 2 deletions python/xy/_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,9 +765,19 @@ def colormap_stops(value: Any, label: str) -> list[list[int]]:
anchors.setdefault(0, 0.0)
anchors.setdefault(count - 1, 1.0)
keys = sorted(anchors)
previous = 0.0
previous = anchors[keys[0]]
for i in keys:
previous = anchors[i] = max(anchors[i], previous)
# CSS clamps an out-of-order gradient stop to its predecessor; for
# a colormap that quietly turned `[(1, red), (0, blue)]` into 255
# red texels and one blue one. A value→color map has no spatial
# reading to fall back on, so a decreasing position is an error.
if anchors[i] < previous:
raise ValueError(
f"{label} stop positions must be non-decreasing: {label}[{i}] at "
f"{anchors[i]:g} follows a stop at {previous:g}; reverse the stop order "
"instead"
)
previous = anchors[i]
positions = [0.0] * count
for i0, i1 in itertools.pairwise(keys):
v0, v1 = anchors[i0], anchors[i1]
Expand Down
22 changes: 22 additions & 0 deletions python/xy/columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,11 @@ def _canonicalize(data: Any) -> tuple[npt.NDArray[np.float64], str, int]:
raise ValueError("columns must be real numeric or datetime-like")
if arr.dtype == object and any(isinstance(value, (bool, np.bool_)) for value in arr):
raise ValueError("columns must be real numeric or datetime-like, not boolean")
if arr.dtype == object:
cleaned = object_missing_to_nan(arr)
if cleaned is not arr:
copies += 1 # the hole-filling pass is a real copy (§29)
arr = cleaned
try:
arr, copies = _astype_counted(arr, np.float64, copies)
except (TypeError, ValueError) as e:
Expand Down Expand Up @@ -625,6 +630,23 @@ def _is_datetime_object_array(arr: npt.NDArray[Any]) -> bool:
return False


def object_missing_to_nan(arr: npt.NDArray[Any]) -> npt.NDArray[Any]:
"""Object array with None / pandas NA / NaT / NaN entries replaced by NaN.

NumPy turns `None` into NaN on `astype(float)` but refuses `pd.NA`; a
numeric object column with either kind of hole must ingest as numeric with
NaN (§19: nulls are NaN, never a category).
"""
if arr.dtype != object:
return arr
missing = np.fromiter((_is_object_missing(v) for v in arr.flat), dtype=bool, count=arr.size)
if not missing.any():
return arr
out = arr.astype(object, copy=True)
out.reshape(-1)[missing] = np.nan
return out


def _is_object_missing(value: Any) -> bool:
if value is None:
return True
Expand Down
48 changes: 46 additions & 2 deletions python/xy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -5325,10 +5325,32 @@ def _strict_bool(value: Any, label: str) -> bool:
_FACET_CHANNEL_PROPS = ("color", "size", "upper", "yerr", "xerr", "base", "z", "x", "group")


def _facet_check_mark_channels(mark: Mark, n: int) -> None:
def _facet_check_mark_channels(mark: Mark, n: int, data: Any = None) -> None:
from .facets import _FACET_ROWS_MISMATCH

items = [("x", mark.x), ("y", mark.y)]
items.extend((key, mark.props.get(key)) for key in _FACET_CHANNEL_PROPS)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
plugin = plugins.get_mark_plugin(mark.kind)
if plugin is not None:
# Plugin marks resolve their declared columns from data= too.
items.extend((key, mark.props.get(key)) for key in plugin.columns)
table = mark.data if mark.data is not None else data
for channel, value in items:
if channel == "color" and isinstance(value, str) and _is_css_color_literal(value):
# `color="red"` is paint on every mark kind, even when the mapping
# happens to hold a "red" key; never a row column to length-check.
continue
if isinstance(value, str) and isinstance(table, Mapping) and value in table:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# A mapping table keeps short config values alongside row columns
# (`facets._subset_data`), so a column is only checked once a mark
# channel names it as row data — then its length must be the
# by= length, or every panel would draw the whole column.
rows = _facet_row_count(table[value])
if rows is not None and rows != n:
raise ValueError(
_FACET_ROWS_MISMATCH.format(n=n, rows=rows, what=f"column {value!r}")
)
continue
if value is None or isinstance(value, (str, bytes)) or np.isscalar(value):
continue
try:
Expand All @@ -5342,6 +5364,28 @@ def _facet_check_mark_channels(mark: Mark, n: int) -> None:
)


def _is_css_color_literal(value: str) -> bool:
try:
_validate.css_color(value, "color")
except (TypeError, ValueError):
return False
return True


def _facet_row_count(column: Any) -> Optional[int]:
"""Row count of a 1-D column value, None for scalars and matrices."""
if hasattr(column, "to_numpy"):
column = column.to_numpy()
elif isinstance(column, (list, tuple)):
try:
column = np.asarray(column)
except ValueError:
return None
if isinstance(column, np.ndarray) and column.ndim == 1:
return len(column)
return None


def _facet_mark(mark: Mark, mask: np.ndarray, n: int) -> Mark:
"""Panel copy of a mark: mark-level data= tables subset with the panel's
row mask (when row-aligned) so panels do not repeat the full dataset."""
Expand Down Expand Up @@ -5432,7 +5476,7 @@ def figure(self) -> Any:
base_title = self.props.get("title")
for child in self.children:
if isinstance(child, Mark):
_facet_check_mark_channels(child, n)
_facet_check_mark_channels(child, n, data)
masks = [codes == code for code in range(len(unique_labels))]

def build_panels(preseed: dict[str, list[str]]) -> list[Figure]:
Expand Down
19 changes: 15 additions & 4 deletions python/xy/facets.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,28 @@
from ._png import png_truecolor
from ._raster import render_raster

_FACET_ROWS_MISMATCH = (
"facet_chart by= has {n} values but {what} has {rows} rows; "
"by= must name a column of the data or supply one value per row"
)


def _subset_data(data: Any, mask: np.ndarray, n: int) -> Any:
"""Row-subset a table for one facet panel.

Only 1-D columns of exactly `n` rows are masked; scalars and short config
values pass through untouched. Multi-dimensional columns whose first axis
happens to equal `n` are ambiguous (row-masking would corrupt e.g. a
heatmap z matrix), so they raise instead of silently guessing.
values in a mapping pass through untouched. A DataFrame is rows and nothing
else, so one whose row count differs from the `n` facet values cannot be
split by them at all — passing it through unsplit handed every panel the
whole dataset under its own label, so it raises. Multi-dimensional columns
whose first axis happens to equal `n` are ambiguous (row-masking would
corrupt e.g. a heatmap z matrix), so they raise instead of silently
guessing.
"""
if hasattr(data, "iloc"):
return data.iloc[mask] if len(data) == n else data
if len(data) != n:
Comment thread
Alek99 marked this conversation as resolved.
raise ValueError(_FACET_ROWS_MISMATCH.format(n=n, rows=len(data), what="data"))
return data.iloc[mask]
if isinstance(data, Mapping):
out: dict[Any, Any] = {}
for key, value in data.items():
Expand Down
6 changes: 5 additions & 1 deletion spec/api/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,11 @@ from the public API and exists for hand-authored specs.
The gradient form shares `mark_fill`'s CSS stop-position grammar and therefore
its 2–8 stop bound; the sequence forms take up to 256. A direction keyword
(`to top`) is refused rather than ignored — a colormap maps values to colors and
has no spatial axis, so reverse the stop order instead.
has no spatial axis, so reverse the stop order instead. Positioned stops in the
sequence form must be non-decreasing: CSS clamps a stop placed before its
predecessor, which for a colormap silently turned `[(1, "red"), (0, "blue")]`
into 255 red texels and one blue, so a decreasing position is a `ValueError`
(reverse the stop order).

Every form normalizes to **evenly spaced 8-bit RGB stops** — the shape the
built-in tables already use — so the WebGL client, the SVG writer, and the
Expand Down
Binary file added spec/assets/tick-labels-1e6-before-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 13 additions & 1 deletion spec/design/chart-grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,15 @@ not break.
auto-inferred from marks (time columns → time; bar categories → category)
but overridable on the axis node. Mixing marks whose natural scales
conflict (bar-category + scatter-linear x) is a build-time error with a
fix-it message, not a coercion.
fix-it message, not a coercion. So is forcing `type_="time"|"log"|"symlog"`
onto an axis whose marks made it categorical (`Figure._axis_kind`): category
positions are label indices, and coercing them produced 1970 epoch ticks or
put category 0 off a log axis. Category detection is by value, not by
container: an object-dtype column whose non-missing values are all real
numbers (a list holding a `None`, a CSV column read as `object`, Decimals) is
numeric with NaN holes — the rule the color channel already applied
(`channels._object_array_is_real_numeric`) — while strings, bytes, bools and
mixed values become categories.
- **G4 — Chrome reads, never owns.** Legend derives entries from mark
channel modes (already true); axes derive from scales; tooltips derive
from the hovered mark's readout row. Adding a mark kind never edits chrome
Expand Down Expand Up @@ -267,6 +275,10 @@ integer, and `gap` a non-negative one.
`max(120, (width - (cols - 1) * gap) // cols)` pixels wide
(`FacetGrid.rows`, `FacetGrid.panel_width`, `facets.py:146-154`). Each
panel's chart title is its facet label.
- **`by=` must cover every row.** A `by=` array whose length differs from the
data's row count (chart-level or mark-level `data=`) is a `ValueError`
(`facets._subset_data`); it used to pass the table through unsplit, so every
panel drew the whole dataset under its own label.
- **`share_x` / `share_y` are global, not per-panel.** For each shared axis
id the grid takes every panel's `_range(axis_id)` and applies the merged
`(min, max)` to all panels (`components.py:3657-3666`). Categorical axes
Expand Down
Loading