Skip to content
Closed
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
17 changes: 14 additions & 3 deletions js/src/20_theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,26 @@ export function cssColor([r, g, b, a]: any) {
// convention every host we target uses (Reflex/next-themes, Radix Themes,
// Tailwind). These remain zero-specificity :where() rules, so public
// --chart-badge-* / --chart-modebar-* tokens or utility classes override them.
//
// Every chrome text slot below either declares `font-weight:400` or declares
// no weight at all and inherits 400. Matplotlib's `axes.titleweight`,
// `axes.labelweight` and `font.weight` all default to "normal", and its legend
// title and colorbar label are normal too, so 400 is the parity default for
// title, axis titles, annotations, legend titles and colorbar titles alike.
// The SVG (`python/xy/_svg.py`) and native raster (`python/xy/_raster.py`)
// exporters carry the same 400 default — the three renderers must not
// disagree; tests/test_text_weight_defaults.py guards all three. A heavier
// weight is opt-in, via `styles[slot]`, a mark/axis text style, or the pyplot
// `axes.titleweight`/`axes.labelweight` rcParams.
export const XY_CHROME_CSS = `
@layer base{
:where(.xy [data-xy-slot="title"]){text-align:center;font-size:14px;font-weight:600;color:var(--chart-text,inherit)}
:where(.xy [data-xy-slot="title"]){text-align:center;font-size:14px;font-weight:400;color:var(--chart-text,inherit)}
:where(.xy [data-xy-slot="tooltip"]){max-width:calc(100% - 8px);max-height:calc(100% - 8px);box-sizing:border-box;white-space:normal;overflow-wrap:anywhere;overflow:auto;background:var(--chart-tooltip-bg,rgba(20,24,33,.92));color:var(--chart-tooltip-text,#fff);padding:5px 8px;border-radius:4px;font-size:11px;line-height:1.35;box-shadow:0 2px 8px rgba(0,0,0,.3)}
:where(.xy [data-xy-slot="legend"]){left:var(--xy-legend-left,auto);right:var(--xy-legend-right,auto);top:var(--xy-legend-top,auto);bottom:var(--xy-legend-bottom,auto);transform:var(--xy-legend-transform,none);max-width:var(--xy-legend-max-width);max-height:var(--xy-legend-max-height);gap:2px;font-size:11px;background:var(--chart-legend-bg,rgba(128,128,128,.08));border-radius:4px;padding:4px 8px;color:var(--chart-text,inherit)}
:where(.xy [data-xy-slot="legend_swatch"]){width:12px;height:10px;border-radius:2px;margin-right:5px}
:where(.xy [data-xy-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px}
:where(.xy [data-xy-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box}
:where(.xy [data-xy-slot="colorbar_title"]){font-weight:500}
:where(.xy [data-xy-slot="colorbar_title"]){font-weight:400}
:where(.xy [data-xy-slot="badge"]){gap:3px;font-size:11px;line-height:1.2}
:where(.xy [data-xy-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,var(--xy-badge-text));background:var(--chart-badge-bg,var(--xy-badge-bg));box-shadow:var(--xy-badge-shadow)}
:where(.xy){--xy-badge-text:#0f172a;--xy-badge-bg:rgba(255,255,255,.82);--xy-badge-shadow:0 1px 4px rgba(15,23,42,.14);--xy-modebar-bg:#fff;--xy-modebar-menu-bg:#fff;--xy-modebar-hover:#edf1f6;--xy-modebar-text:#5c6573;--xy-modebar-text-strong:#1b212a;--xy-modebar-text-soft:#798495;--xy-modebar-text-subtle:#9aa4b2;--xy-modebar-border:rgba(27,33,42,.12);--xy-modebar-separator:rgba(27,33,42,.08);--xy-modebar-active:#edf1f6;--xy-modebar-shadow:0 8px 24px rgba(28,32,36,.1),0 2px 6px rgba(28,32,36,.06);--xy-modebar-menu-shadow:0 8px 24px rgba(28,32,36,.12);--xy-modebar-button-shadow:0 1px 2px rgba(28,32,36,.06)}
Expand Down Expand Up @@ -141,7 +152,7 @@ export const XY_CHROME_CSS = `
:where(.xy [data-xy-slot="crosshair_x"],.xy [data-xy-slot="crosshair_y"]){background:var(--chart-crosshair,rgba(15,23,42,.42))}
:where(.xy [data-xy-slot="tick_label"]){color:var(--chart-text,inherit)}
:where(.xy [data-xy-slot="axis_title"]){color:var(--chart-text,inherit);font-size:12px}
:where(.xy [data-xy-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:500;color:var(--chart-annotation-text,var(--chart-text,inherit))}
:where(.xy [data-xy-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:400;color:var(--chart-annotation-text,var(--chart-text,inherit))}
:where(.xy [data-xy-slot="canvas"]){cursor:var(--chart-cursor,crosshair)}
:where(.xy [data-xy-slot="canvas"][data-xy-dragmode="pan"]){cursor:var(--chart-cursor-pan,grab)}
:where(.xy [data-xy-slot="canvas"][data-xy-dragmode="none"]){cursor:default}
Expand Down
23 changes: 13 additions & 10 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1769,7 +1769,10 @@ export class ChartView {
if (options.title) {
const title = document.createElement("div");
title.textContent = String(options.title);
title.style.fontWeight = "600";
// Matplotlib renders a legend title at normal weight, and the native
// raster exporter never emphasized it, so 400 is what both the SVG
// exporter and this path emit.
title.style.fontWeight = "400";
title.style.gridColumn = `1 / span ${horizontal ? ncols : 1}`;
lg.appendChild(title);
}
Expand Down Expand Up @@ -4173,7 +4176,7 @@ export class ChartView {
const hasAngle = axis && Number.isFinite(Number(axis.label_angle));
if (!hasPosition && !hasOffset && !hasAngle) return { css: fallbackCss, style: null };
if (rawPosition && typeof rawPosition === "object" && !Array.isArray(rawPosition)) {
return { css: "font-weight:500;white-space:nowrap;", style: rawPosition };
return { css: "font-weight:400;white-space:nowrap;", style: rawPosition };
}

const p = this.plot;
Expand All @@ -4196,7 +4199,7 @@ export class ChartView {
css:
`left:${x}px;top:${y}px;` +
`transform:translateX(${translateX}%) rotate(${angle}deg);` +
"transform-origin:center;font-weight:500;white-space:nowrap;",
"transform-origin:center;font-weight:400;white-space:nowrap;",
style: null,
};
}
Expand All @@ -4211,7 +4214,7 @@ export class ChartView {
css:
`left:${x}px;top:${y}px;` +
`transform:translate(-50%,-50%) rotate(${angle}deg);` +
"transform-origin:center;font-weight:500;white-space:nowrap;",
"transform-origin:center;font-weight:400;white-space:nowrap;",
style: null,
};
}
Expand Down Expand Up @@ -4536,7 +4539,7 @@ export class ChartView {
if (axis.label && this._axisTickLabelStrategy(axis) !== "none") {
const top = axis.side === "top" ? p.y - 34 : p.y + p.h + 24;
const fallbackCss =
`left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:500;`;
`left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:400;`;
const placement = this._axisLabelCss(axis, "x", fallbackCss);
label(axis.label, placement.css, axis, "label", placement.style);
}
Expand Down Expand Up @@ -4586,22 +4589,22 @@ export class ChartView {
}
if (axis.label && this._axisTickLabelStrategy(axis) !== "none") {
const fallbackCss = axis.side === "left"
? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;`
: `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;`;
? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:400;`
: `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:400;`;
const placement = this._axisLabelCss(axis, "y", fallbackCss);
label(axis.label, placement.css, axis, "label", placement.style);
}
}
if (s.x_axis.label && !hideX) {
const top = xAxis.side === "top" ? p.y - 34 : p.y + p.h + 24;
const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:500;`;
const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:400;`;
const placement = this._axisLabelCss(xAxis, "x", fallbackCss);
label(s.x_axis.label, placement.css, xAxis, "label", placement.style);
}
if (s.y_axis.label && !hideY) {
const fallbackCss = yAxis.side === "right"
? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;`
: `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;`;
? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:400;`
: `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:400;`;
const placement = this._axisLabelCss(yAxis, "y", fallbackCss);
label(s.y_axis.label, placement.css, yAxis, "label", placement.style);
}
Expand Down
Binary file added pr-assets/text-weight/core-chart-text-weight.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 5 additions & 2 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,10 @@ def emit_tick_labels(
title_italic, title_bold = _native_font_emphasis(
{
"font_style": title_style.get("font-style"),
"font_weight": title_style.get("font-weight", 600),
# 400 = Matplotlib's `axes.titleweight: normal`; the baked
# atlas only has a bold face, so anything >= 600 rounds up to
# it. Mirrors the SVG/browser title default.
"font_weight": title_style.get("font-weight", 400),
}
)
cmd.text(
Expand All @@ -1024,7 +1027,7 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
italic, bold = _native_font_emphasis(
{
"font_style": axis_style.get("label_font_style"),
"font_weight": axis_style.get("label_font_weight", 500),
"font_weight": axis_style.get("label_font_weight", 400),
}
)
args = (
Expand Down
10 changes: 7 additions & 3 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1625,7 +1625,11 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
if spec.get("title"):
title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {}
title_size = _px_size(title_style.get("font-size"), 14.0)
title_weight = title_style.get("font-weight", 600)
# Matplotlib's `axes.titleweight`/`axes.labelweight` both default to
# "normal", so chrome text stays at 400 unless a style or rcParam asks
# for more. Keep this in step with the `title`/`axis_title` slot rules
# in js/src/20_theme.ts and the raster defaults in _raster.py.
title_weight = title_style.get("font-weight", 400)
title_family = title_style.get("font-family")
title_font_style = title_style.get("font-style")
title_font_attrs = (
Expand Down Expand Up @@ -1658,7 +1662,7 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
chrome.append(
f'<text x="{_num(x)}" y="{_num(y)}" text-anchor="{geometry["anchor"]}" '
f'font-size="{_num(float(geometry["font_size"]))}" '
f'font-weight="{escape(str(axis_style.get("label_font_weight", 500)))}"{font_attrs} '
f'font-weight="{escape(str(axis_style.get("label_font_weight", 400)))}"{font_attrs} '
f'fill="{escape(_css(axis_style.get("label_color"), default_text))}"{transform}>'
f"{escape(str(axis['label']))}</text>"
)
Expand Down Expand Up @@ -2906,7 +2910,7 @@ def _legend(
if title:
rows.append(
f'<text x="{_num(x + pad)}" y="{_num(y + pad / 2 + 11)}" '
f'font-weight="600" fill="{escape(text_color)}">{escape(str(title))}</text>'
f'font-weight="400" fill="{escape(text_color)}">{escape(str(title))}</text>'
)
for i, t in enumerate(named[: legend["visible_count"]]):
style = t.get("style") or {}
Expand Down
19 changes: 19 additions & 0 deletions python/xy/pyplot/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,14 @@ def _rc_chrome_snapshot(dpi: float) -> dict[str, Any]:
f"{_font_size(rcParams['axes.titlesize'], rcParams['font.size'], dpi):g}px"
),
"color": theme_tokens["text_color"],
**_rc_font_weight(rcParams["axes.titleweight"]),
},
"axis_title": {
"font-size": (
f"{_font_size(rcParams['axes.labelsize'], rcParams['font.size'], dpi):g}px"
),
"color": resolve_color(rcParams["axes.labelcolor"]),
**_rc_font_weight(rcParams["axes.labelweight"]),
},
"tick_label": {
"font-size": (
Expand Down Expand Up @@ -5081,6 +5083,19 @@ def _font_size(value: Any, base: Any, dpi: float = 96.0) -> float:
return _font_size_points(value, base) * float(dpi) / 72.0


def _rc_font_weight(value: Any, key: str = "font-weight") -> dict[str, Any]:
"""Carry a weight rcParam only when it asks for something other than normal.

Matplotlib's ``axes.titleweight``/``axes.labelweight`` default to
``"normal"``, which is also every xy renderer's own default, so the
default case needs nothing on the wire — the same rule the neighbouring
``axes.labelcolor`` check uses. An explicit weight (``"bold"``, ``"light"``,
a numeric string) travels through to the browser, SVG, and raster paths.
"""
weight = str(value)
return {} if weight == "normal" else {key: weight}


def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]:
prefix = "xtick" if axis == "x" else "ytick"
point_scale = float(dpi) / 72.0
Expand All @@ -5105,6 +5120,10 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]:
if rcParams["axes.labelcolor"] != "black":
result["label_color"] = resolve_color(rcParams["axes.labelcolor"])
result["label_size"] = _font_size(rcParams["axes.labelsize"], rcParams["font.size"], dpi)
# The browser reads the weight off `chrome_styles["axis_title"]`; the SVG
# and native raster exporters read it off the axis style, so `axes.labelweight`
# has to be published in both places to reach all three renderers.
result.update(_rc_font_weight(rcParams["axes.labelweight"], "label_font_weight"))
return result


Expand Down
2 changes: 2 additions & 0 deletions python/xy/pyplot/_rc.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ def by_key(self) -> dict[str, list[str]]:
"axes.edgecolor": "black",
"axes.labelcolor": "black",
"axes.labelsize": "medium",
"axes.labelweight": "normal",
"axes.titlesize": "large",
"axes.titlecolor": "auto",
"axes.titleweight": "normal",
"axes.linewidth": 0.8,
"axes.xmargin": 0.05,
"axes.ymargin": 0.05,
Expand Down
46 changes: 46 additions & 0 deletions spec/api/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ or a CSS `px` value such as `"3px"`.
| `grid_opacity` | Number from `0` to `1` |
| `tick_length` | Non-negative pixel length |
| `tick_size` / `tick_label_size`, `label_size` | Positive pixel font size |
| `label_font_weight`, `label_font_family`, `label_font_style` | Axis-label font overrides, passed through to the browser, SVG, and native PNG paths. `label_font_weight` defaults to `400` — see [Chrome text weight](#chrome-text-weight). |
| `tick_direction` | `"in"`, `"out"`, or `"inout"` |
| `tick_label_anchor` | `"start"`, `"center"`, or `"end"` (mpl `ha` aliases `"left"`/`"right"`/`"middle"` normalize) — which label edge pins to the tick; rotated labels pivot about the pinned edge. Also a first-class `x_axis`/`y_axis` option. X defaults to `"center"`; y defaults to the tick-side edge (`"end"` left of the plot, `"start"` right of it). Honored by static SVG/PNG exports. |

Expand Down Expand Up @@ -237,6 +238,51 @@ raises before it reaches the client.
<div class="[&_[data-xy-slot=legend]]:bg-transparent"> … </div>
```

### Chrome text weight

**Every chrome text element defaults to `font-weight: 400`** — chart title, axis
titles, tick labels, legend entries, legend titles, colorbar titles, and text/
label/callout annotations alike. This is Matplotlib's default and it is
deliberate: `axes.titleweight`, `axes.labelweight`, and `font.weight` are all
`normal` in Matplotlib 3.11, and its legend titles and colorbar labels are
normal too, so a chart exported from `xy.pyplot` carries the same text weight as
the same script run under Matplotlib.

The default is a **cross-renderer contract**, not a per-renderer choice. All
three renderers must agree:

| Renderer | Where the default lives |
| --- | --- |
| Browser render client | `font-weight:400` on the text slot rules in `js/src/20_theme.ts`, plus the inline axis-label/legend-title weights in `js/src/50_chartview.ts` (inline styles beat the slot stylesheet, so both have to say 400) |
| SVG export | `python/xy/_svg.py` — the `font-weight` attribute on the title, axis-title, and legend-title `<text>` elements |
| Native PNG export | `python/xy/_raster.py` — `_native_font_emphasis` maps a weight `>= 600` onto the baked atlas's bold face, so 400 emits a plain, unemphasized text record |

A renderer that drifts heavier is a bug; `tests/test_text_weight_defaults.py`
asserts the emitted weight per element in the SVG output and in the native
raster command stream, and holds a source-level guard on the TypeScript
defaults (the client bundles are a generated, git-ignored artifact, so a
bundle-reading test could not run from a fresh checkout).

Heavier text is always opt-in, never a default:

```python
xy.chart(..., styles={"title": {"font_weight": 600}}) # per-slot
xy.x_axis(label="time", style={"label_font_weight": "bold"}) # per-axis
```

Under the pyplot shim, Matplotlib's own knobs work too —
`rcParams["axes.titleweight"]`, `rcParams["axes.labelweight"]`, and the explicit
`ax.set_title(..., fontweight=)` / `ax.set_xlabel(..., fontweight=)` arguments.
Because `normal` is already every renderer's default, the shim only puts a
weight on the wire when it differs from `normal`.

The native PNG exporter's font atlas is bounded and carries one regular and one
bold face, so it approximates: any weight `>= 600` (or a name in
`bold`/`semibold`/`demibold`/`heavy`/`black`) renders with the bold face, and
everything lighter renders regular. Intermediate weights are therefore not
distinguishable in native PNG output, while the browser and SVG paths pass the
requested weight through verbatim.

## Why your styles always win

The client injects one stylesheet of *visual* defaults (background, color,
Expand Down
Loading
Loading