Summary#
Charts are a KIT, not a platform feature — you depend on it in one line and nothing is copied into your project.
It draws line, area, column, horizontal bar, scatter and OHLC candle marks over one shared value scale, with a
Pie/donut sibling beside it.
```osy title="one line to depend on it, one using to write it" syntax
app Dashboard {
use Osysharp.Charts@1;
model "model/**/*.osy";
}
> **It is a PACKAGE**, fetched from `github.com/osysharp/charts` — the first of the platform's own kits to move out of
> the binary, which is why the `use` carries a version. `osy lock` pins it by content address; see [package.osy](/reference/project/package/).
⭐ **It ships NO BUNDLE.** `Osysharp.MarkdownEditor` carries a JavaScript shim because it wraps a third-party editor nobody
should rewrite; this kit is `.osy` files and nothing else. A charting library is the surface people are most certain
needs JavaScript — hit-testing a hover against pixel coordinates on a `<canvas>` is most of one — and the hover here
is an ancestor-conditioned style variant that lowers to a descendant CSS rule. No pointer handler, no coordinates,
no measurement.
## Signature {#signature}
```osy title="the shape: a chart, its categories, and its marks as children" syntax
Chart(title: string, subtitle: string, labels: string[], height: int, chrome: ChartChrome) {
Column(label: string, values: double[]); // …or Line, Area, Bar, Dots, Candles
Axis(side: AxisSide, ticks: int, title: string, min: double, max: double);
}The marks are CHILDREN, and that is the point. They are config records, so the call site reads as the picture
being described rather than as an array being assembled. Giving the chart labels is what asks for a category axis
— you rarely declare Axis(side: Bottom) yourself.
An enum member is written QUALIFIED — side: AxisSide.Left — exactly as in C#. A bare Left is refused, with a
message that says so: an enum member, a theme token and a style keyword are spelled alike, so the language always
qualifies them.
A mark may equally be written inside a foreach or an if — see [[#from-data]]. That is how a chart whose series
are data is written.
Description#
What it draws#
| marks | line, area, column, horizontal bar (GROUPED for more than one series, or stacked: true), scatter, OHLC candles |
| axes | a value axis with round-number ticks, a category axis, optional gridlines, a title, an explicit min/max |
| signed values | a negative grows from the zero line — down on a column, left on a bar — and the axis opens below zero to make room |
| hover | a crosshair and a readout naming every series' value in the hovered category |
| legend | automatic for two or more series; position: on any edge or None, interactive: true to toggle a series off |
| palette | led by the app's accent, then validated hues in fixed order — any too close to the accent skipped ([[ui-palette#series]]) — never cycled |
| theme | card, ink, gridlines, readout and type are the app's theme tokens (Colors.Surface, Colors.CardLine, Colors.Muted, …), so a chart follows any theme or look with no line written for it |
| annotations | a reference Rule, a shaded Band, a labelled Note — none of them a series |
| table | Table() below the chart, or Table(position: Instead) in place of it — a real role: Grid |
| bare mode | chrome: Bare — the sparkline: no card, axes, gridlines, legend, heading or hover |
The whole vocabulary, in one place#
Fifteen controls. Chart and Pie are the containers; everything else goes inside one of them.
| control | it is | what it takes |
|---|---|---|
Chart(title, subtitle, labels, …) | the container for every category chart | the category labels, then marks as children |
Pie(title, subtitle, slices, …) | the container for a part-of-whole chart | Slice children, or a slices: array |
Line(label, values) | a mark — a series as a line | one double[], one per category |
Area(label, values) | a mark — a filled line | as Line; stacked: true on the chart to stack them |
Column(label, values) | a mark — vertical bars | as Line |
Bar(label, values) | a mark — horizontal bars | as Line |
Scatter(label, values) | a mark — points | as Line |
Candle(label, values, opens, highs, …) | a mark — OHLC candles | four arrays, one per category |
Slice(label, value) | a mark — one wedge of a Pie | a single number |
Axis(side, grid, ticks, …) | the value or category axis | AxisSide, and whether to draw gridlines |
Legend(position, interactive) | the series key | LegendPos, and whether clicking toggles a series |
Rule(value, label) | an ANNOTATION — a reference line | the value to sit at |
Band(from, to, label) | an ANNOTATION — a shaded range | the two bounds |
Note(category, value, label) | an ANNOTATION — a label at one point | where to put it |
Marker(category, label) | an ANNOTATION — an event: a line across the plot at one category | which category |
Table(position) | the same data as a real role: Grid | below the chart, or Instead of it |
⚠ An annotation is not a series — Rule, Band, Note and Marker carry no data, take no palette colour and never
appear in the legend. See [[#annotations]], which is the half people get wrong.
osy kit <name> prints any of them in full, and osy kit --for "<what you want>" finds one by what it does.
Marks from data — foreach and if#
When the series are DATA — one per region, one per row a query returned — write the loop where the marks go. A mark
inside a foreach or an if reaches the chart exactly as a mark written directly does, and marks are drawn in the
order they are written: an axis before the loop, then every iteration's marks in iteration order, then whatever
follows the loop.
Chart(title: "Revenue by region", labels: months) {
Axis(side: AxisSide.Left);
foreach (var r in regions) {
Column(label: r.Name, values: r.Revenue); // one series per region, reading the loop variable
}
if (showTarget) { Rule(value: 60000.0, label: "Target"); }
}- An
ifwith noelseadds its marks only while the condition holds — and when the condition is state that changes, the chart follows it. - A loop over an empty list draws a chart with no marks in it. It is not an error and draws no default series.
- Loops and
ifs nest either way, and one loop body may write several marks, or a mark and an annotation together; each goes to its own place (Columnto the series,Ruleto the annotations).
⚠ Two things cannot sit in a block that writes marks, because that block is collected as one expression: a var
binding (write the value inline in the mark's arguments instead) and a NAMED pattern, if (x is T t) (test x is T
and cast where you read it). Both are refused with a message that names them.
Building the array yourself and passing it — marks: regions.Select(r => new Mark { … }).ToArray() — still works and is
the same value; the loop is simply the spelling that keeps the kit's own vocabulary (Column(label:, values:)).
One crosshair rule for every mark kind#
An invisible band of full-height cells sits over the plot, one per category. Hovering a cell reveals a crosshair and a readout of every series at that category — the shared-crosshair behaviour a line chart wants, which happens to be right for columns and dots too. What you learn on a bar chart holds on a line chart.
The readout stays inside the plot: a readout centred on the first or last category would hang past the edge and be
clipped, losing exactly the label you hovered to read. It measures with Layout.TextWidth and shifts only the
cells that would overflow, so a middle category is still centred on its crosshair — a flip, not a re-anchor.
⚑ Measured from the TEXT, not from the box. Asking Layout.Width for the readout's own width from inside the
readout answered 109.0 where the painted box was 114.6: a self-measurement lags its own content by construction,
because the number describes one layout pass and the content may be from another. Layout.TextWidth is a pure
function of the string and the font, so there is nothing to lag.
An annotation is not a series#
Things that are true about the chart but are not in the data: a target, an acceptable range, the day something happened.
Chart(title: "Revenue against target", labels: months) {
Column(label: "Revenue", values: revenue);
Band(from: 55000.0, to: 65000.0, label: "Acceptable");
Rule(value: 60000.0, label: "Target");
Note(category: "May", value: 73000.0, label: "v2 launch");
Axis(side: AxisSide.Left);
}⛔ An annotation gets no legend entry, no palette slot, cannot be toggled off, and DOES NOT MOVE THE SCALE —
because none of those things is true of "the target is 60k". Modelling a target as a flat one-value Line is the
usual shortcut and it costs exactly those four: the legend grows an entry nobody clicks, the palette shifts under
the real series, and a 500k target stretches the axis until every real bar is a stub in the bottom eighth.
An out-of-range annotation is clamped and says so both ways — ↑ on the face of the chart, and "(above the top
of this chart)" in the accessible name. A clamped line that looked exactly like a met target would be its own kind
of lie.
⚠ A Note names its category by NAME. An index would be a lie waiting to happen: insert a month at the front
and every note silently shifts one slot. A name that is not in labels is said out loud.
A Marker is an EVENT, where a Rule is a VALUE — "we released on the 9th", against "the target is 60k". It
draws a dashed line straight across the plot at one category, named at the top, so a change in the marks can be
read against what happened that day:
Chart(title: "Downtime per day", labels: days) {
Column(label: "Minutes down", values: downMinutes);
foreach (var r in releases) { Marker(category: r.Day, label: r.Version); }
Axis(side: AxisSide.Left);
}It crosses the CATEGORY axis, so it is vertical on a column or line chart and horizontal on a Bar chart — the
opposite of a Rule. Like a Note it names its category by name, and a name the chart does not have is said on the
chart. ⚑ Two events on one category are ONE marker whose label names both: two lines drawn on the same spot are one
line with two labels printed over each other. Neighbouring markers' labels alternate between two rows so they do not
collide.
A negative grows from the zero line#
A signed quantity — profit and loss, variance, net flow — is drawn from the zero line, not from the bottom of the plot: positives grow up (right, on a bar chart) and negatives grow down (left), the axis opens below zero to fit the data's minimum, and the zero line falls on a gridline like any other tick. A stack with values on both sides splits at that line, and its value label stays the NET total, because that is what the stack says and what the axis measures.
Chart(title: "Net flow", labels: months, showValues: true) {
Column(label: "Net flow", values: netFlow); // e.g. [12000, -8000, 24000, -3000]
Axis(side: AxisSide.Left, format: "N0", prefix: "£");
}⚠ The sign leads the prefix: a negative on a prefix: "£" axis reads -£8,000, as C# writes it — not £-8,000.
One formatter feeds the tick labels, the hover readout, the table cells, the on-mark values and every mark's
accessible name, so they cannot disagree about it.
⛔ A bar's baseline stays zero. Axis(min:) can extend the axis below zero but never lift it above, because a bar
is read by its length from zero and cropping that length changes what the bar says. Zooming is honest for a line,
scatter or candle — a price between 118 and 137 drawn from zero is a band in the top eighth — and those honour a
declared min in full.
Why one value scale#
Two y-axes let any two series be made to cross wherever you like. One shared scale is the honest picture, and it is why a target belongs in an annotation rather than in a second axis.
Examples#
The commonest dashboard chart there is — magnitude by category, with a target over it:
app ChartExample {
use Osysharp.Charts;
model "model/**/*.osy";
}
using Osysharp.Charts;
[Route("/")]
[Render(CSR)]
[AllowAnonymous]
[Title("Revenue")]
component Dashboard() {
string[] months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
double[] revenue = [42000.0, 55000.0, 48000.0, 61000.0, 73000.0, 69000.0];
double[] target = [40000.0, 50000.0, 55000.0, 60000.0, 65000.0, 70000.0];
render {
Chart(title: "Revenue against target", subtitle: "First half", labels: months, height: 280) {
Column(label: "Revenue", values: revenue);
Line(label: "Target", values: target);
Rule(value: 60000.0, label: "Target");
Axis(side: AxisSide.Left, ticks: 4);
}
}
}The series come from a list, so the loop writes one mark per region — and the target is an if:
app ChartFromData {
use Osysharp.Charts;
model "model/**/*.osy";
}
using Osysharp.Charts;
class Region { public string Name; public double[] Revenue; }
[Route("/")]
[Render(CSR)]
[AllowAnonymous]
component Regions() {
string[] months = ["Jan", "Feb", "Mar"];
var regions = new List<Region> {
new Region { Name = "North", Revenue = [42000.0, 55000.0, 48000.0] },
new Region { Name = "South", Revenue = [38000.0, 41000.0, 52000.0] }
};
bool showTarget = true;
render {
Chart(title: "Revenue by region", labels: months) {
Axis(side: AxisSide.Left);
foreach (var r in regions) {
Column(label: r.Name, values: r.Revenue);
}
if (showTarget) { Rule(value: 50000.0, label: "Target"); }
}
}
}demo/chart-demo is the worked gallery: every mark kind at a realistic size (/), a live query feeding a chart
(/live), the palette's eight slots and what happens at nine series (/palette), and candles over sessions
(/time).
See also#
- The markdown editor kit — a rich editor you opt into — the other shipped kit, and the one that DOES carry a bundle
- Osysharp.Ui (the UI kit) — the 46 bundled controls, which need no
useat all - theme tokens — the tokens a chart's surface, ink and grid read