Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The chart kit — line, column, bar, scatter and candle, with no JavaScript

Chart(title: "Revenue against target", labels: months) { Column(label: "Revenue", values: revenue); Axis(side: AxisSide.Left); }

Line, area, column, bar, scatter and OHLC candle marks over one shared value scale, plus a Pie — an optional KIT you depend on with one line. Marks are written as CHILDREN, so the call site reads as the picture being described. It ships no bundle at all: a charting library is the surface people are most certain needs JavaScript, and this one does not.

stable2 examples compiled by CIuichartskitvisualisation

Source on GitHub: Osysharp.Charts — github.com/osysharp/charts ↗

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#

marksline, area, column, horizontal bar (GROUPED for more than one series, or stacked: true), scatter, OHLC candles
axesa value axis with round-number ticks, a category axis, optional gridlines, a title, an explicit min/max
signed valuesa negative grows from the zero line — down on a column, left on a bar — and the axis opens below zero to make room
hovera crosshair and a readout naming every series' value in the hovered category
legendautomatic for two or more series; position: on any edge or None, interactive: true to toggle a series off
paletteled by the app's accent, then validated hues in fixed order — any too close to the accent skipped ([[ui-palette#series]]) — never cycled
themecard, 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
annotationsa reference Rule, a shaded Band, a labelled Note — none of them a series
tableTable() below the chart, or Table(position: Instead) in place of it — a real role: Grid
bare modechrome: 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.

controlit iswhat it takes
Chart(title, subtitle, labels, …)the container for every category chartthe category labels, then marks as children
Pie(title, subtitle, slices, …)the container for a part-of-whole chartSlice children, or a slices: array
Line(label, values)a mark — a series as a lineone double[], one per category
Area(label, values)a mark — a filled lineas Line; stacked: true on the chart to stack them
Column(label, values)a mark — vertical barsas Line
Bar(label, values)a mark — horizontal barsas Line
Scatter(label, values)a mark — pointsas Line
Candle(label, values, opens, highs, …)a mark — OHLC candlesfour arrays, one per category
Slice(label, value)a mark — one wedge of a Piea single number
Axis(side, grid, ticks, …)the value or category axisAxisSide, and whether to draw gridlines
Legend(position, interactive)the series keyLegendPos, and whether clicking toggles a series
Rule(value, label)an ANNOTATION — a reference linethe value to sit at
Band(from, to, label)an ANNOTATION — a shaded rangethe two bounds
Note(category, value, label)an ANNOTATION — a label at one pointwhere to put it
Marker(category, label)an ANNOTATION — an event: a line across the plot at one categorywhich category
Table(position)the same data as a real role: Gridbelow 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 if with no else adds 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 (Column to the series, Rule to 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#

Related

control — foreign UI controls (charts, grids, maps)

A `control` block declares the contract of a foreign UI widget — a chart, a data grid, a map — that a small JavaScript…

The markdown editor kit — a rich editor you opt into

A full rich-text markdown editor — sections, partial saves, a block menu, tables, find and replace, maths and diagrams…

Osysharp.Ui (the UI kit)

The bundled UI kit — ready-made styled controls like `Button`, the shared design-system vocabularies (`Tone`, `Size`)…

theme tokens

A `theme` block names your app's design tokens — colors, spacing, radii, and more — as reusable values. A token can…

layout primitives

The built-in layout primitives and how they arrange children. `Stack` stacks children in a column, `Row` lays them in a…

use

Declares a capability your app depends on, written inside the `app { }` manifest block. It provisions the capability…