Three composable capabilities for turning a plain chart into a decision-ready one:threshold-based style rules that recolor or re-texture marks by value,declarative hatch fills that read identically on canvas and in exported SVG, and annotation label backgrounds (a stroke halo or a semitransparent box) that keep reference-line labels legible over dense marks. They were built to reproduce dashboards like the capacity chart below — but each works on its own.
Putting it together: a capacity chart
A stacked bar with a solid Fast scaling base and a yellow-hatchedFixed-rate burst on top. Wherever a bar breaks the Max · 15 ceiling, the portion above the line is split into its own segment and hatched red — the overage reads as a distinct, alarming texture. Plus two reference lines: the redMax · 15 ceiling with the default halo label, and a greenFast-scaling · 10 line whose label sits in a semitransparent box so it stays readable where it crosses the bars.
hatch fill + over-max highlight + threshold lines
import { StackedBarChart } from "semiotic" const frameProps = { /* --- Data --- */ data: usageData, /* --- Size --- */ height: 360, /* --- Layout --- */ barPadding: 24, stackBy: "tier", /* --- Process --- */ valueAccessor: "value", categoryAccessor: "t", /* --- Customize --- */ colorScheme: { Fast scaling: "#3fa34d", Fixed-rate: "#f0b429", Over max: "#d7263d" }, showLegend: true, legendPosition: "bottom", /* --- Annotate --- */ annotations: [{ type: "y-threshold", value: 15, label: "Max · 15", color: "#d7263d", strokeDasharray: "6,4" }, { type: "y-threshold", value: 10, label: "Fast-scaling · 10", color: "#2f7d32", strokeDasharray: "5,4", labelPosition: "left", labelBackground: { type: "box", fill: "var(--semiotic-bg)", opacity: 0.9 } }], /* --- Realtime --- */ valueExtent: [0,20], /* --- Other --- */ styleRules: [{ when: (d16, ctx) => ctx.category === "Fixed-rate", style: { fill: { type: "hatch", background: "#f7d774", stroke: "#e0a92a", spacing: 6 } } }, { when: (d16, ctx) => ctx.category === "Over max", style: { fill: { type: "hatch", background: "#f8b4b4", stroke: "#d7263d", spacing: 6 } } }] } export default () => { return <StackedBarChart {...frameProps} /> }
A stacked-bar rule styles a whole segment, so "the part over the ceiling" has to be its own segment — the sample data splits each fixed-rate burst at 15 into aFixed-rate piece (up to the line) and an Over max piece (above it), and two rules hatch them yellow and red respectively.
Threshold style rules
styleRules on BarChart, StackedBarChart, andGroupedBarChart is an ordered list of { when, style } pairs. Every rule whose when condition matches a bar contributes itsstyle, and the styles are merged in list order — so for any single property,the last applicable rule wins. It is the CSS-cascade model: send as many rules as you like; the last one that applies to a property is the one you see. Bars that match no rule keep the chart's resolved base color.
Bars recolored by value band — last applicable rule wins
import { BarChart } from "semiotic" const frameProps = { /* --- Data --- */ data: bandData, /* --- Size --- */ height: 320, /* --- Process --- */ valueAccessor: "value", categoryAccessor: "region", /* --- Customize --- */ color: "#3fa34d", /* --- Other --- */ styleRules: [{ when: { gte: 10 }, style: { fill: "#f0b429" } }, { when: { gte: 15 }, style: { fill: "#d7263d" } }] } export default () => { return <BarChart {...frameProps} /> }
A bar of value 12 matches the first rule (≥ 10) → amber. A bar of value 17 matches both rules; the second (≥ 15) wins → red. Bars below 10 match nothing and keep the basecolor. Because merging is per-property, an early rule can setstroke and a later rule can override just the fill without touching the stroke.
The when condition
when accepts three forms. Omit it (or pass true) for an unconditional base layer; pass false to disable a rule without deleting it.
| Form | Example | Use for |
|---|
| Declarative threshold | { gte: 10, lt: 15 } | Numeric/categorical matching with no code. Compares the bar's value unlessfield is set. |
| Predicate function | (d, ctx) => ctx.category === 'burst' | Full control. ctx is { value, category, index };d is the raw datum. |
| Boolean | true / false | Always / never — an unconditional layer, or a toggled-off rule. |
Threshold operators (all present operators are AND-ed together):
TS
interface StyleRuleThreshold { field?: string // datum field to read; defaults to the bar value gt?: number // value > n gte?: number // value >= n lt?: number // value < n lte?: number // value <= n eq?: number | string // value === n (categorical or numeric) ne?: number | string // value !== n within?: [number, number] // min <= value <= max outside?: [number, number] // value < min OR value > max in?: Array<number | string> // value is one of these }
Declarative hatch fills
A rule's (or any pieceStyle's) fill can be a HatchFill descriptor — a plain object that resolves to aCanvasPattern when drawn to canvas in the browser and to an SVG<pattern> when serialized on the server. One declaration, both backends, so a hatched bar looks the same live and in an exported PNG/SVG.
TS
interface HatchFill { type: "hatch" background?: string // tile background (default "transparent") stroke?: string // diagonal line color (default "#000") lineWidth?: number // line width in px (default 1.5) spacing?: number // gap between lines in px (default 6) angle?: number // line angle in degrees (default 45) lineOpacity?: number // SVG line opacity (default 1) }
Projected bars textured with a hatch fill
import { BarChart } from "semiotic" const frameProps = { /* --- Data --- */ data: quarterlyData, /* --- Size --- */ height: 300, /* --- Process --- */ valueAccessor: "revenue", categoryAccessor: "q", /* --- Customize --- */ color: "#4e79a7", /* --- Other --- */ styleRules: [{ when: (d16) => d16.projected === true, style: { fill: { type: "hatch", background: "#cdddef", stroke: "#4e79a7" } } }] } export default () => { return <BarChart {...frameProps} /> }
The same descriptor works as a region fill on band andx-band annotations (fill: { type: 'hatch', … }), so an uncertainty or "projected" region can be hatched rather than flat-shaded. For a hand-builtCanvasPattern (canvas only), createHatchPattern() is still available — see Styling.
Annotation label backgrounds
Region-bounding annotations — y-threshold, x-threshold,band, x-band, enclose, rect-enclose, andcategory-highlight — accept a labelBackground that puts a legibility backdrop behind the label text. It works on every frame (XY, ordinal, network, geo) and in server SVG, because all of them share one label renderer.
labelBackground | Result |
|---|
"halo" / true | Stroke halo (a thick outline in the plot background color) — the default for threshold and band labels. |
"box" | A filled, semitransparent rounded panel behind the text. |
"none" / false | Plain text, no backdrop. |
{ type, fill, opacity, padding, radius, stroke, haloWidth } | Fine-grained control over either treatment. |
A latency SLO line with a boxed threshold label
import { LineChart } from "semiotic" const frameProps = { /* --- Data --- */ data: latencyData, /* --- Size --- */ height: 300, /* --- Process --- */ xAccessor: "t", yAccessor: "ms", /* --- Customize --- */ color: "#4e79a7", /* --- Annotate --- */ annotations: [{ type: "y-threshold", value: 200, label: "SLO · 200ms", color: "#d7263d", labelBackground: { type: "box", fill: "var(--semiotic-bg)", opacity: 0.9, radius: 4 } }, { type: "x-band", x0: 27, x1: 39, label: "incident window", fill: { type: "hatch", background: "#fdecec", stroke: "#d7263d", spacing: 7 }, fillOpacity: 0.5, color: "#d7263d" }] } export default () => { return <LineChart {...frameProps} /> }
Across every chart family
styleRules uses the same API on every family — only thectx channels differ. Bars expose value; XY marks exposevalue (= y), x, and y (target either axis with{ axis: "x" }); network nodes, geo features, and physics particles exposevalue plus category (the colorBy group).
XY — per-point, either axis
Scatterplot rules see the raw datum, so an x and y threshold both resolve. Here points in the top-right quadrant are flagged and the far outliers hatched.
Scatter points styled by x/y thresholds
import { Scatterplot } from "semiotic" const frameProps = { /* --- Data --- */ data: scatterData, /* --- Size --- */ height: 320, /* --- Process --- */ xAccessor: "x", yAccessor: "y", /* --- Customize --- */ pointRadius: 6, color: "#4e79a7", /* --- Other --- */ styleRules: [{ when: { axis: "x", gte: 60 }, style: { fill: "#f0b429" } }, { when: (d16) => d16.x >= 60 && d16.y >= 60, style: { fill: "#d7263d" } }, ... ] } export default () => { return <Scatterplot {...frameProps} /> }
Network nodes — interactive
Rules see the raw node; ctx.value is the node's weight here. Drag the threshold, switch solid/hatch, and toggle a second rule to watchlast-applicable-rule-wins repaint the overlapping nodes. The layout re-settles on data change but a rule change only restyles — a good way to confirm nothing breaks.
Live styleRules config
[ { "when": { "field": "weight", "gte": 6 }, "style": { "fill": { "type": "hatch", "background": "rgba(215, 38, 61, 0.18)", "stroke": "#d7263d", "spacing": 6 } } } ]Geo features — interactive
The same controls over a ChoroplethMap (each country is seeded with a 0–100 value). Rules layer over the sequential base fill, so unmatched countries keep their scale color while matched ones flip to your rule's solid color or hatch.
Loading world map…
Physics particles
Physics particles use the identical API — ctx.value is the body's value. (Shown as code to keep the semiotic/physics kernel out of this page's bundle.)
TSX
// Physics — recolor particles over a threshold (ctx.value = the body's value) <GaltonBoardChart data={samples} valueAccessor="value" styleRules={[{ when: { gte: 15 }, style: { fill: "var(--semiotic-danger)" } }]} />
Coverage notes: line/area rules resolve per-series (against the series' first point), not per-vertex. Network hierarchy charts (Tree, Treemap, CirclePack, Orbit) are not wired — their colorByDepth owns the fill. MultiAxisLineChart, MinimapChart, and CandlestickChart are also not wired.
For agents & server rendering
styleRules, hatch fills, and labelBackground all work throughrenderChart() and the MCP renderChart tool — the props are plain JSON, so an agent can emit them directly. The capacity chart above, rendered server-side:
TS
import { renderChart } from "semiotic/server" const svg = renderChart("StackedBarChart", { data: usageData, // Fast scaling / Fixed-rate / Over max (split at 15) categoryAccessor: "t", stackBy: "tier", valueAccessor: "value", colorScheme: { "Fast scaling": "#3fa34d", "Fixed-rate": "#f0b429", "Over max": "#d7263d" }, valueExtent: [0, 20], // Declarative { field, eq } thresholds are JSON-serializable — an agent can // emit these directly (a predicate function can't cross a JSON boundary). styleRules: [ { when: { field: "tier", eq: "Fixed-rate" }, style: { fill: { type: "hatch", background: "#f7d774", stroke: "#e0a92a" } } }, { when: { field: "tier", eq: "Over max" }, style: { fill: { type: "hatch", background: "#f8b4b4", stroke: "#d7263d" } } }, ], annotations: [ { type: "y-threshold", value: 15, label: "Max · 15", color: "#d7263d" }, { type: "y-threshold", value: 10, label: "Fast-scaling · 10", color: "#2f7d32", labelBackground: "box" }, ], })
Note: on the server path, prefer the declarative threshold form ({ field, eq }) over a predicate function when the config must be serialized as JSON — predicate functions can't cross a JSON boundary.
Precedence
For bar marks, fill resolution runs base → rules → user override, with primitives last:
TEXT
top-level stroke / strokeWidth / opacity (always win) > frameProps.pieceStyle (imperative escape hatch) > styleRules (declarative, last-applicable rule per property) > colorBy / color / colorScheme / theme (resolved base)
- Bar Chart — the base chart and its other styling props.
- Styling — primitive styling, gradients, and the canvas
createHatchPattern() helper. - Annotations — the full annotation type reference.