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
16 changes: 8 additions & 8 deletions docs/styling/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,15 @@ token bag or in mark and axis `style=`, which every renderer reads.
### Notes

- **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export.
- **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`legend`** (via `styles={'legend': ...} / xy.legend(style=...) / --chart-legend-bg`) — The frame box. Both spellings and the theme token now converge on one merged declaration block before the writers see it, so what agrees in the browser agrees in a PNG. `background`, `boxShadow`, `borderRadius`, `--xy-legend-frame-alpha`, and `padding`/`rowGap` in `em` are honored; an explicit background paints opaque, as it does in the browser.
- **`legend_title`** (via `styles={'legend_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`legend_label`** (via `styles={'legend_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`colorbar`** (via `styles={'colorbar': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`colorbar_tick`** (via `styles={'colorbar_tick': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`colorbar_title`** (via `styles={'colorbar_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`tick_label`** (via `styles={'tick_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`axis_title`** (via `styles={'axis_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`legend_title`** (via `styles={'legend_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`legend_label`** (via `styles={'legend_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`colorbar`** (via `styles={'colorbar': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`colorbar_tick`** (via `styles={'colorbar_tick': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`colorbar_title`** (via `styles={'colorbar_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`tick_label`** (via `styles={'tick_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
- **`axis_title`** (via `styles={'axis_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.

## Extension points

Expand Down
47 changes: 42 additions & 5 deletions js/src/30_ticks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,39 @@ export function linearTicks(lo, hi, target = 6) {
return { ticks: out, step };
}

// Mirrored by `_log_ticks` in python/xy/_svg.py — the static exporters must
// tick a log axis exactly where the browser does (renderer-architecture §6.1).
export function logTicks(lo, hi, target = 6) {
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { ticks: [], step: 1 };
const a = Math.min(lo, hi);
const b = Math.max(lo, hi);
if (a <= 0 || b <= 0) return { ticks: [], step: 1 };
const e0 = Math.floor(Math.log10(a));
const e1 = Math.ceil(Math.log10(b));
// The decade ladder is multiplicative: inside one decade it has at most the
// 2 and 5 mantissas to offer, and a window like 0.3..0.35 or 100..110 holds
// none of them — every zoom past ~3x on a log axis lost all ticks, labels
// and grid. With fewer than two decades in view the ladder cannot describe
// the window, so tick it linearly across the span (matplotlib/Plotly do the
// same). `step` is the nice linear step so fmtLinear shares one decimal
// count along the axis (§6.2); every tick is labelled.
let decadesInView = 0;
for (let e = e0; e <= e1; e++) {
const v = Math.pow(10, e);
if (v >= a * (1 - 1e-12) && v <= b * (1 + 1e-12)) decadesInView++;
}
if (decadesInView < 2) {
const linear = linearTicks(a, b, target);
// A positive subnormal window (1e-323..1.5e-323) is a few ulps wide:
// `(b - a) / target` underflows to 0, niceStep answers 1, and the first
// multiple of that lies past `b` — no ticks again. The window's own
// endpoints are always representable, so they stand in as the ticks
// (the formatter is exponential there regardless of step). A degenerate
// lo === hi keeps linearTicks' single tick.
const ticks = linear.ticks.length < 2 && a < b ? [a, b] : linear.ticks;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: For subnormal windows whose endpoints fall in the same one-decimal exponential bucket, this fallback adds two ticks but formats both as the same label, such as 1.0e-320. Preserve the endpoint ticks while formatting the pair with enough exponential precision to keep their labels distinct.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At js/src/30_ticks.ts, line 58:

<comment>For subnormal windows whose endpoints fall in the same one-decimal exponential bucket, this fallback adds two ticks but formats both as the same label, such as `1.0e-320`. Preserve the endpoint ticks while formatting the pair with enough exponential precision to keep their labels distinct.</comment>

<file context>
@@ -49,7 +49,15 @@ export function logTicks(lo, hi, target = 6) {
+    // endpoints are always representable, so they stand in as the ticks
+    // (the formatter is exponential there regardless of step). A degenerate
+    // lo === hi keeps linearTicks' single tick.
+    const ticks = linear.ticks.length < 2 && a < b ? [a, b] : linear.ticks;
+    const step = ticks === linear.ticks ? linear.step : b - a;
+    return { ticks, labels: ticks, step, log: true };
</file context>

const step = ticks === linear.ticks ? linear.step : b - a;
return { ticks, labels: ticks, step, log: true };
}
const span = Math.max(1, e1 - e0);
const mults = span <= Math.max(2, target) ? [1, 2, 5] : [1];
const out = [];
Expand Down Expand Up @@ -258,13 +284,18 @@ function fmtTimeSpec(ms, format) {

// Decade ticks are multiplicative, so the linear formatter's step-derived
// precision rounds every decade under 1.0 to a bare "0" — 0.001 and 0.01 read
// as two identical, wrong labels. Label these from their own magnitude.
// as two identical, wrong labels. Label these from their own value: the fewest
// decimals (up to 8) that reproduce it. For the 1/2/5 ladder that is exactly
// the old magnitude rule (0.002 -> 3 decimals, 20 -> none); it differs only
// for the within-decade linear ticks a log colorbar now carries (§6.1), which
// the magnitude rule collapsed to "2 2 2 3 3 3" for a 2..3 range.
// Mirrored by `_fmt_log` in python/xy/_svg.py.
export function fmtLog(v) {
const av = Math.abs(v);
if (av >= 1e6 || (av !== 0 && av < 1e-4)) return v.toExponential(1).replace("e+", "e");
const dec = av && av < 1 ? Math.max(0, Math.ceil(-Math.log10(av))) : 0;
return v.toFixed(Math.min(dec, 8));
let dec = 0;
while (dec < 8 && Math.abs(Number(av.toFixed(dec)) - av) > av * 1e-9) dec++;
return v.toFixed(dec);
}

// Whether a formatted label has lost the value it was meant to show. Tests the
Expand Down Expand Up @@ -300,8 +331,14 @@ export function fmtAxis(axis, v, tickStep) {
const formatted = fmtNumberSpec(v, axis && axis.format);
if (axis && axis.scale === "log" && Number(v) > 0 && Number(v) < 1) {
// A fixed-decimal spec collapses sub-unit decades; so does the linear
// fallback. Either way the magnitude-derived label is the useful one.
if (collapsedToZero(formatted)) return fmtLog(v);
// fallback under the ladder's step of 1. Either way the value-derived
// label is the useful one — except on the within-decade linear tick set
// (§6.1), whose sub-unit step gives fmtLinear the one shared decimal
// count an axis reads by ("0.30, 0.31, ...") where the per-value fmtLog
// would go ragged ("0.3, 0.31, ..."). Mirrored by `_fmt_axis`.
if (collapsedToZero(formatted)) {
return tickStep > 0 && tickStep < 1 ? fmtLinear(v, tickStep) : fmtLog(v);
}
}
return formatted || fmtLinear(v, tickStep);
}
Expand Down
46 changes: 44 additions & 2 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8722,6 +8722,41 @@ export class ChartView {
];
}

// Whether a legend category toggle removed shipped row `i` from the drawn
// set. A hidden series is out of every pipeline (interaction spec §10), and
// the CPU hover fallbacks below scan the UNFILTERED retained columns, so
// they must ask. `_visInv` is the shipped-row -> drawn-vertex map that
// `_filterScatterRows` maintains beside `_visMap` (-1 = hidden); reading it,
// rather than re-deriving the hidden set from the category codes, keeps the
// fallback in lockstep with what the GPU pick pass can see by construction.
_cpuRowHidden(g, i) {
const inv = g._visInv;
return !!inv && inv[i] < 0;
}

// How many retained rows a CPU hover scan covers. `g.n` is the DRAWN count,
// which a category filter shrinks below the retained columns: capping the
// scan at it left every visible row shipped after the cut unreachable while
// hidden rows before it still answered. A filtered trace is therefore scanned
// over exactly the rows `_visInv` carries visibility for (its length is the
// pre-filter count `_filterScatterRows` built it from) — deliberately that
// array and not a separately stored count, so the scan range and the
// hidden-row test can never disagree. An unfiltered trace keeps the `g.n`
// cap, which is load-bearing there (a smooth-resampled line's `n` exceeds
// its source columns; mismatched x/y lengths undershoot them).
//
// Growing the retained columns under a live filter would leave both stale,
// but cannot happen: `_filterScatterRows` runs only on a categorical color
// channel, and `append_data` (python/xy/interaction.py) rejects exactly
// those ("append does not support categorical color channels yet"), so a
// trace is appendable or category-filterable, never both. Whoever lifts that
// restriction must re-filter on append — `tests/test_legend_hidden_hover.py`
// fails here when they do.
_cpuScanLimit(g, ...lengths) {
const n = g._visInv ? g._visInv.length : g.n;
return Math.min(...lengths, n || lengths[0]);
}

_nearestCpuIndex(g, dataX) {
const cpu = g && g._cpu;
if (!cpu || !cpu.x || !cpu.x.length) return -1;
Expand All @@ -8730,8 +8765,9 @@ export class ChartView {
const target = this._axisCoord(axis, dataX);
let best = -1;
let bestDist = Infinity;
const limit = Math.min(cpu.x.length, g.n || cpu.x.length);
const limit = this._cpuScanLimit(g, cpu.x.length);
for (let i = 0; i < limit; i++) {
if (this._cpuRowHidden(g, i)) continue;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const starts = g._transitionPrevXValues;
const progress = g._transitionPositionProgress;
const xEncoded = starts && Number.isFinite(progress)
Expand All @@ -8753,11 +8789,12 @@ export class ChartView {
const xMeta = cpu.xMeta || g.xMeta;
const yMeta = cpu.yMeta || g.yMeta;
const progress = g._transitionPositionProgress;
const limit = Math.min(cpu.x.length, cpu.y.length, g.n || cpu.x.length);
const limit = this._cpuScanLimit(g, cpu.x.length, cpu.y.length);
const geom = this._polarGeometry();
let best = -1;
let bestDist = Infinity;
for (let i = 0; i < limit; i++) {
if (this._cpuRowHidden(g, i)) continue;
const xEncoded = g._transitionPrevXValues && Number.isFinite(progress)
? g._transitionPrevXValues[i] + (cpu.x[i] - g._transitionPrevXValues[i]) * progress
: cpu.x[i];
Expand Down Expand Up @@ -8893,6 +8930,11 @@ export class ChartView {
const cpu = g._cpu;
const horizontal = g.orientation === 1;
const geom = this._polarGeometry();
// No hidden-row test here: a bar trace cannot carry a categorical color
// channel (only funnels and point series build one), so it never has
// category legend rows to toggle and never reaches `_filterScatterRows`.
// Bar legends are whole-trace rows, which `_hoverAt` skips via
// `_legendHidden` before it gets here. Recorded in interaction spec §10.
const limit = Math.min(cpu.x.length, cpu.y.length, g.n || cpu.x.length);
for (let i = 0; i < limit; i++) {
const x = this._decodeValue(cpu.x, cpu.xMeta, i);
Expand Down
22 changes: 22 additions & 0 deletions news/508.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Twenty-one P1 defects from the 0.0.7 audit are fixed. Kernel: a non-string
message `type`, a pick past a histogram's bin count, and a subnormal-span
`density_view` window no longer raise inside the dispatcher, and `view_change`
records only finite, ordered, non-empty ranges. API: a log axis warns when it
drops non-positive points, non-finite continuous color/size values are not
drawn instead of painting as the colormap floor, `sankey_chart(xy.sankey(...))`
works, a hand-built `xy.Mark` fills in factory defaults instead of a KeyError,
bare pyarrow string arrays become categorical axes, and a missing facet column
is a ValueError. Client: a log axis zoomed inside one decade shows linear
fallback ticks instead of none (exports agree), and legend-hidden category
points no longer hover or show a tooltip. Exports: PDF honors `font-style`,
`font-family` (mapped to the base-14 faces), `letter-spacing`, and `opacity`
on text, and XML-illegal control characters are dropped so SVG and PDF never
emit unparsable documents. pyplot: `ticklabel_format` configures the
formatter instead of breaking export; spans and rules autoscale and take
`label=`; `series.plot(ax=)` works with pandas' locators; datetime limits,
ticks, and `fill_between` coordinates are accepted; `legend(handles=,
labels=)`, integer and tuple `loc`, `Line2D`/`Patch`/`Rectangle` legend proxies,
`plt.subplot(n, m, i)` creating only the requested cell, callable `plt.cm.*`
colormaps, `BarPatch` geometry getters, and a long list of common kwargs
(`zorder`, `clip_on`, `facecolors='none'`, `tick_label=`, `log=True`, ...)
are accepted.
Loading
Loading