diff --git a/js/src/30_ticks.ts b/js/src/30_ticks.ts index b1809b8e..b3bce92d 100644 --- a/js/src/30_ticks.ts +++ b/js/src/30_ticks.ts @@ -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"); + } 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)); diff --git a/news/507.bugfix.md b/news/507.bugfix.md new file mode 100644 index 00000000..02c121b8 --- /dev/null +++ b/news/507.bugfix.md @@ -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. diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 3f94bb5e..20dc886d 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -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: @@ -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]: @@ -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: @@ -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" diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 7d4301ae..e6d4e0b8 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -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 @@ -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. diff --git a/python/xy/_validate.py b/python/xy/_validate.py index 93ce3f0c..5a7d9fbd 100644 --- a/python/xy/_validate.py +++ b/python/xy/_validate.py @@ -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] diff --git a/python/xy/columns.py b/python/xy/columns.py index 7d37a745..a6211ff7 100644 --- a/python/xy/columns.py +++ b/python/xy/columns.py @@ -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: @@ -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 diff --git a/python/xy/components.py b/python/xy/components.py index f365432d..3433ebc9 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -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) + 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: + # 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: @@ -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.""" @@ -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]: diff --git a/python/xy/facets.py b/python/xy/facets.py index 69d2c5a6..9317ca10 100644 --- a/python/xy/facets.py +++ b/python/xy/facets.py @@ -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: + 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(): diff --git a/spec/api/styling.md b/spec/api/styling.md index 2bbf64c8..6fdae2d7 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -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 diff --git a/spec/assets/tick-labels-1e6-before-after.png b/spec/assets/tick-labels-1e6-before-after.png new file mode 100644 index 00000000..09213a64 Binary files /dev/null and b/spec/assets/tick-labels-1e6-before-after.png differ diff --git a/spec/design/chart-grammar.md b/spec/design/chart-grammar.md index 03b6c08a..6632ea51 100644 --- a/spec/design/chart-grammar.md +++ b/spec/design/chart-grammar.md @@ -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 @@ -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 diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 79336f1d..5337d102 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -368,8 +368,19 @@ so a pathological domain cannot produce an unbounded loop or DOM label count. With no `format=` on the axis, labels come from the step: -- `fmtLinear` switches to one-decimal exponential (with `e+` normalized to `e`) - when `|v| ≥ 1e6` or `0 < |v| < 1e-4`. Otherwise it derives the decimal count +- `fmtLinear` switches to exponential (with `e+` normalized to `e`) when + `|v| ≥ 1e6` or `0 < |v| < 1e-4`. The mantissa carries as many digits as sit + between the value's magnitude and the step's last significant digit + (`expDigits`; `1.25e6` at step `2.5e5` → `(6 − 5) + 1 = 2` → `1.25e6`), at + least one and at most sixteen (17 significant digits, what two adjacent f64 + values need to stay distinct) — a fixed single decimal labelled a + 50,000-step axis `1.0e6, 1.1e6, 1.1e6, 1.2e6, …`. `python/xy/_svg.py:: + _exp_digits` is the same function, and `_fmt_exponential` reproduces + `toExponential`'s half-up tie rounding on the exact binary value (Python's + `:e` is half-even: `1.25e6` at one digit would otherwise read `1.3e6` live + and `1.2e6` in the PNG), so the two agree label for label + (`spec/assets/tick-labels-1e6-before-after.png`: a 50,000-step axis before + and after). Otherwise it derives the decimal count from the tick step — `ceil(−log10(step))`, then increments while the step is not representable at that precision to within a thousandth of itself — and caps at 8 decimals. Ticks on one axis therefore share a decimal count. diff --git a/tests/test_axis_type_conflicts.py b/tests/test_axis_type_conflicts.py new file mode 100644 index 00000000..1551ca71 --- /dev/null +++ b/tests/test_axis_type_conflicts.py @@ -0,0 +1,56 @@ +"""Forcing a time/log/symlog type onto a categorical axis is a build error (G3). + +`xy.x_axis(type_="time")` on a bar chart with string categories used to ship +`{"kind": "time", "range": [-0.45, 1.45]}` — categories gone, ticks in 1970 — +and `type_="log"` left category 0 off the axis. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +import xy # noqa: E402 + + +@pytest.mark.parametrize("type_", ["time", "log", "symlog"]) +def test_forced_scale_on_category_axis_is_an_error(type_: str) -> None: + chart = xy.bar_chart(xy.bar(x=["a", "b"], y=[1, 2]), xy.x_axis(type_=type_)) + with pytest.raises(ValueError, match=f"x axis is categorical .*cannot be a {type_} axis"): + chart.figure().build_payload() + + +def test_forced_scale_on_numeric_axis_and_linear_on_category_axis_still_work() -> None: + spec, _ = ( + xy.bar_chart(xy.bar(x=["a", "b"], y=[1, 2]), xy.x_axis(type_="linear")) + .figure() + .build_payload() + ) + assert spec["axes"]["x"]["kind"] == "category" and spec["axes"]["x"]["categories"] == ["a", "b"] + spec, _ = ( + xy.scatter_chart(xy.scatter(x=[1, 10, 100], y=[1, 2, 3]), xy.x_axis(type_="log")) + .figure() + .build_payload() + ) + assert spec["axes"]["x"].get("scale") == "log" + # The y axis is untouched by an x-axis conflict. + spec, _ = ( + xy.bar_chart(xy.bar(x=["a", "b"], y=[1, 10]), xy.y_axis(type_="log")) + .figure() + .build_payload() + ) + assert spec["axes"]["y"].get("scale") == "log" and spec["axes"]["x"]["kind"] == "category" + + +@pytest.mark.parametrize("type_", ["time", "log", "symlog"]) +def test_empty_category_axis_still_rejects_forced_scale(type_: str) -> None: + # An empty object column registers the axis as categorical with no labels. + chart = xy.bar_chart(xy.bar(x=np.array([], dtype=object), y=[]), xy.x_axis(type_=type_)) + with pytest.raises(ValueError, match=f"x axis is categorical .*cannot be a {type_} axis"): + chart.figure().build_payload() diff --git a/tests/test_custom_ramps_and_palette.py b/tests/test_custom_ramps_and_palette.py index 84931dac..585e341a 100644 --- a/tests/test_custom_ramps_and_palette.py +++ b/tests/test_custom_ramps_and_palette.py @@ -609,3 +609,18 @@ def test_text_switch_does_not_collide_with_tick_labels(): axis = xy.x_axis(tick_values=(0.0, 1.0), tick_labels=("lo", "hi"), text=False) assert axis.tick_labels == ["lo", "hi"] assert axis.style["tick_label_color"] == "#00000000" + + +def test_colormap_positions_must_be_non_decreasing() -> None: + """Out-of-order stops were clamped CSS-style, so [(1, red), (0, blue)] + silently became 255 red texels and one blue.""" + from xy import channels + + with pytest.raises(ValueError, match=r"non-decreasing.*reverse the stop order"): + channels.resolve_colormap([(1.0, "red"), (0.0, "blue")]) + with pytest.raises(ValueError, match=r"colormap\[2\] at 0.2 follows a stop at 0.5"): + channels.resolve_colormap([(0.5, "red"), "white", (0.2, "blue")]) + # Ordered stops resolve as before, and equal positions (a hard stop) stay legal. + lut = channels.resolve_colormap([(0.0, "blue"), (1.0, "red")]) + assert lut[0] == [0, 0, 255] and lut[-1] == [255, 0, 0] + channels.resolve_colormap([(0.0, "blue"), (0.5, "red"), (0.5, "white"), (1.0, "black")]) diff --git a/tests/test_facets.py b/tests/test_facets.py index 14964575..0788fced 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -380,3 +380,54 @@ def test_stairs_tooltip_channels_are_not_mislabeled() -> None: assert tooltip["aliases"] == {"v": "y", "e": "x"} assert tooltip["sources"]["v"] == [{"trace": 0, "channel": "y"}] assert tooltip["sources"]["e"] == [{"trace": 0, "channel": "x"}] + + +def test_facet_by_array_must_match_row_count() -> None: + """A by= array of the wrong length used to hand every panel the full + table (two panels, each drawing all three rows) instead of erroring.""" + pd = pytest.importorskip("pandas") + df = pd.DataFrame({"x": [1.0, 2.0, 3.0], "y": [10.0, 20.0, 30.0]}) + with pytest.raises(ValueError, match="by= has 2 values but data has 3 rows"): + xy.facet_chart(xy.scatter(x="x", y="y"), by=["a", "b"], data=df).figure() + with pytest.raises(ValueError, match="by= has 4 values but data has 3 rows"): + xy.facet_chart(xy.scatter(x="x", y="y"), by=["a", "b", "c", "d"], data=df).figure() + # Mapping data keeps its short-config pass-through, so a mapping column is + # checked where a mark channel names it as row data. + with pytest.raises(ValueError, match="column 'x' has 3 rows"): + xy.facet_chart( + xy.scatter(x="x", y="y"), + by=["a", "b"], + data={"x": [1.0, 2.0, 3.0], "y": [1.0, 2.0, 3.0], "config": [1, 2]}, + ).figure() + # Mark-level data= tables are split by the same by= and must match too. + with pytest.raises(ValueError, match="by= has 2 values but data has 3 rows"): + xy.facet_chart(xy.scatter(x="x", y="y", data=df), by=["a", "b"]).figure() + # The right length still works and actually subsets. + grid = xy.facet_chart(xy.scatter(x="x", y="y"), by=["a", "b", "a"], data=df).figure() + assert [t.n_points for fig in grid.figures for t in fig.traces] == [2, 1] + + +def test_facet_row_check_covers_plugin_columns_and_skips_color_literals() -> None: + """Plugin marks resolve their declared columns from data= too, so those + are length-checked; a CSS color literal is paint even when the mapping + happens to hold a key of the same name.""" + + def build(ctx): + return [xy.scatter(x=ctx.columns["t"], y=ctx.columns["v"], name=ctx.name)] + + xy.register_mark(xy.MarkPlugin(name="facetprobe", columns=("t", "v"), build=build)) + try: + with pytest.raises(ValueError, match="column 't' has 3 rows"): + xy.facet_chart( + xy.mark("facetprobe", t="t", v="v"), + by=["a", "b"], + data={"t": [0.0, 1.0, 2.0], "v": [1.0, 2.0, 3.0]}, + ).figure() + finally: + xy.unregister_mark("facetprobe") + grid = xy.facet_chart( + xy.line(x="x", y="y", color="red"), + by=["a", "b", "a"], + data={"x": [0.0, 1.0, 2.0], "y": [1.0, 2.0, 3.0], "red": [9.0]}, + ).figure() + assert len(grid.figures) == 2 diff --git a/tests/test_object_numeric_columns.py b/tests/test_object_numeric_columns.py new file mode 100644 index 00000000..9ead1f8b --- /dev/null +++ b/tests/test_object_numeric_columns.py @@ -0,0 +1,86 @@ +"""Object-dtype columns holding real numbers are numeric, not categorical. + +A list with a `None`, an object ndarray, a CSV column read as `object`, or a +column of Decimals used to turn into a category axis with labels +'1', '(missing)', '3' at positions 0, 1, 2 — silently — while the color +channel classified the very same input as continuous. Both now apply +`channels._object_array_is_real_numeric`; missing entries become NaN (§19). +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +import xy # noqa: E402 +from xy._figure import Figure # noqa: E402 + + +def _x_axis(chart): + spec, _ = chart.figure().build_payload() + return spec["axes"]["x"] + + +def test_object_numeric_column_is_a_numeric_axis() -> None: + for values in ( + [1, None, 3], + np.array([1, 2, 3], dtype=object), + [Decimal("1.5"), Decimal("2"), Decimal("3")], + [1.0, float("nan"), 3.0], + ): + axis = _x_axis(xy.scatter_chart(xy.scatter(x=values, y=[1, 2, 3]))) + assert axis["kind"] == "linear", (values, axis) + assert "categories" not in axis, (values, axis) + + +def test_object_numeric_missing_values_are_nan_not_categories() -> None: + pd = pytest.importorskip("pandas") + fig = Figure() + fig.scatter(np.array([1, None, 3], dtype=object), [1.0, 2.0, 3.0]) + col = fig.traces[0].x + assert col.kind == "float" + assert np.isnan(col.values[1]) and col.values[0] == 1.0 and col.values[2] == 3.0 + # §29: the hole-filling pass is a copy and is reported as one, on top of + # the object->f64 cast every object column pays. + clean = Figure() + clean.scatter(np.array([1, 2, 3], dtype=object), [1.0, 2.0, 3.0]) + assert col.ingest_copies == clean.traces[0].x.ingest_copies + 1 + # pandas' NA scalar is a hole too, not a category or a TypeError. + fig2 = Figure() + fig2.scatter(np.array([1, pd.NA, 3], dtype=object), [1.0, 2.0, 3.0]) + assert np.isnan(fig2.traces[0].x.values[1]) + # A pandas object Series of numbers with a NaN hole (the common + # `df["x"].astype(object)` / mixed-source idiom) is numeric too. + series = pd.Series([1, np.nan, 3], dtype=object) + axis = _x_axis(xy.scatter_chart(xy.scatter(x=series, y=[1, 2, 3]))) + assert axis["kind"] == "linear" and "categories" not in axis + + +def test_strings_bools_and_mixed_object_columns_stay_categorical() -> None: + # Bools are excluded from "real number" on purpose, so a bool-bearing + # object column is categories too (as it was before). + for values in ( + ["1", "2", "3"], + ["a", None, "c"], + [1, "a", 3], + [b"a", b"b", b"c"], + np.array([True, 1, 2], dtype=object), + ): + axis = _x_axis(xy.bar_chart(xy.bar(x=values, y=[1, 2, 3]))) + assert axis["kind"] == "category", (values, axis) + + +def test_axis_and_color_channel_agree_on_object_numeric_input() -> None: + values = [1, None, 3] + spec, _ = ( + xy.scatter_chart(xy.scatter(x=values, y=[1, 2, 3], color=values)).figure().build_payload() + ) + assert spec["axes"]["x"]["kind"] == "linear" + assert spec["traces"][0]["color"]["mode"] == "continuous" diff --git a/tests/test_tick_label_precision.py b/tests/test_tick_label_precision.py new file mode 100644 index 00000000..8241f981 --- /dev/null +++ b/tests/test_tick_label_precision.py @@ -0,0 +1,132 @@ +"""Exponential tick labels stay distinct past 1e6, identically in Python and JS. + +`_fmt_linear` / `fmtLinear` formatted every |v| >= 1e6 (or < 1e-4) tick with +one mantissa decimal regardless of the step, so a 50,000-step axis read +"1.0e6, 1.1e6, 1.1e6, 1.2e6, 1.2e6, 1.3e6, 1.3e6" and 1,250,000 was labelled +"1.2e6". The mantissa now carries the digits between the value's magnitude and +the step's last significant digit; the two implementations are asserted equal +so a PNG never disagrees with the browser. +""" + +from __future__ import annotations + +import base64 +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +from xy._svg import _fmt_linear # noqa: E402 + +CASES: list[tuple[list[float], float]] = [ + ([1e6, 1.05e6, 1.1e6, 1.15e6, 1.2e6, 1.25e6, 1.3e6], 5e4), + ([1e6, 1e6 + 20, 1e6 + 40, 1e6 + 60, 1e6 + 80, 1e6 + 100], 20.0), + ([0.0, 5e5, 1e6, 1.5e6, 2e6], 5e5), + ([1e6, 1.25e6, 1.5e6, 1.75e6, 2e6], 2.5e5), + ([1e-5, 1.02e-5, 1.04e-5], 2e-7), + ([-1e-12, -5e-13, 0.0, 5e-13, 1e-12], 5e-13), + ([1e300, 2e300, 3e300], 1e300), + ([1e6, 2e6, 3e6], 1e6), + ([12345678.0, 12345679.0, 12345680.0], 1.0), + # A 1e-3 step at 1e6 magnitude needs nine mantissa digits (cap was 8). + ([1000000.001, 1000000.002, 1000000.003], 1e-3), + # Exact binary ties: JS toExponential rounds half-up, Python :e half-even. + ([1.25e6, 1.75e6, 2.25e6], 5e5), + ([9.95e6, 9.85e6], 1e5), + # Adjacent f64 values at a one-ulp step need all 17 significant digits. + ( + [1e6, np.nextafter(1e6, np.inf), np.nextafter(np.nextafter(1e6, np.inf), np.inf)], + np.spacing(1e6), + ), + # Subnormal steps: 10**e_step must not underflow to zero, and the step's + # real exponent still sets the digit count (1.00e-310 vs 1.05e-310). + ([1e-310, 2e-310, 3e-310], 1e-310), + ([1.00e-310, 1.05e-310, 1.10e-310], 5e-312), + ([5e-320, 1e-319], 5e-320), +] + + +def test_exponential_labels_are_distinct_at_the_tick_step() -> None: + for ticks, step in CASES: + labels = [_fmt_linear(v, step) for v in ticks] + assert len(set(labels)) == len(labels), (ticks, step, labels) + assert [_fmt_linear(v, 5e4) for v in (1e6, 1.05e6, 1.3e6)] == ["1.00e6", "1.05e6", "1.30e6"] + assert _fmt_linear(1.25e6, 2.5e5) == "1.25e6" + assert _fmt_linear(1e6 + 20, 20.0) == "1.00002e6" + assert _fmt_linear(1000000.002, 1e-3) == "1.000000002e6" + # Ties round half-up like JavaScript, not half-even like Python's :e. + assert _fmt_linear(1.25e6, 5e5) == "1.3e6" + assert _fmt_linear(-1.25e6, 5e5) == "-1.3e6" + assert _fmt_linear(9.95e6, 1e6) == "1.0e7" + assert _fmt_linear(2e-310, 1e-310) == "2.0e-310" + assert _fmt_linear(1.05e-310, 5e-312) == "1.05e-310" + # Below the exponential threshold nothing changed. + assert [_fmt_linear(v, 0.25) for v in (0.0, 0.25, 0.5)] == ["0.00", "0.25", "0.50"] + assert _fmt_linear(5e-13, 5e-13) == "5.0e-13" + # Degenerate steps fall back to one decimal instead of failing. + assert _fmt_linear(2e6, 0.0) == "2.0e6" + assert _fmt_linear(2e6, float("nan")) == "2.0e6" + + +def _node_type_stripping(node: str) -> list[str]: + """Flags that let this node import a .ts file, or None if it cannot. + + Type stripping shipped behind --experimental-strip-types in 22.6 and is on + by default from 23.6; older versions fail at the import. The flag is still + accepted where stripping is already on, so every version below 23.6 gets it + (22.x included, whether or not it is a 22.18+ default-on release). + """ + version = subprocess.run([node, "--version"], capture_output=True, text=True, check=True).stdout + major, minor = (int(part) for part in version.strip().lstrip("v").split(".")[:2]) + if (major, minor) < (22, 6): + return None + return ["--experimental-strip-types"] if (major, minor) < (23, 6) else [] + + +def test_python_and_client_formatters_agree() -> None: + node = shutil.which("node") + if node is None: + pytest.skip("node not available for the fmtLinear parity check") + strip_types = _node_type_stripping(node) + if strip_types is None: + pytest.skip("node >= 22.6 needed to import the TypeScript source directly") + ticks_ts = (ROOT / "js" / "src" / "30_ticks.ts").resolve().as_uri() + payload = base64.b64encode(json.dumps(CASES).encode()).decode("ascii") + script = ( + f'const m = await import("{ticks_ts}");' + f'const cases = JSON.parse(Buffer.from("{payload}", "base64").toString());' + "console.log(JSON.stringify(cases.map(([ticks, step]) => ticks.map((v) => m.fmtLinear(v, step)))));" + ) + completed = subprocess.run( + [node, "--no-warnings", *strip_types, "--input-type=module", "--eval", script], + cwd=ROOT, + capture_output=True, + text=True, + timeout=60, + check=True, + ) + js_labels = json.loads(completed.stdout) + py_labels = [[_fmt_linear(v, step) for v in ticks] for ticks, step in CASES] + assert js_labels == py_labels + + +def test_exported_axis_labels_are_distinct_past_a_million() -> None: + import xml.etree.ElementTree as ET + + import xy + + chart = xy.line_chart( + xy.line([0.0, 1.0, 2.0], [1e6, 1e6 + 1.0, 1e6 + 2.0]), width=400, height=300 + ) + root = ET.fromstring(chart.to_svg()) + texts = [el.text for el in root.iter("{http://www.w3.org/2000/svg}text") if el.text] + y_labels = [t for t in texts if t.endswith("e6")] + assert len(y_labels) >= 3 and len(set(y_labels)) == len(y_labels), texts + assert np.all(np.diff([float(t.replace("e6", "e+6")) for t in y_labels]) != 0)