Skip to content

Charts as FLines

A Chart is a plot made of FLines. Put one in the same drawing as your other FLines, click its marks, or draw an expected range and see which data falls outside it. For a general-purpose interactive chart, take a look at Plotly too.

New to Field's ways of displaying data? Looking at Data starts with values and plots in a box. If your data is already a table, DataFrames in a Box shows how to work with its columns.

You build a Chart up a call at a time and hand it to showChart. showChart(c), display(channel, c), c.h, and a box whose last expression is a Chart all render the same figure. Here is a residual curve with an expected bound:

Chart(title = "residual per iteration", xLabel = "iteration", yLabel = "|r|")
    .line("residual", iterations, residual)
    .points("residual", iterations, residual)
    .expect(max = 1e-6, because = "converged below 1e-6")

Reach for Plotly first

Field already has Plotly: linePlot, scatter, violin, showSignals, each described in the reference under plotly.kt. The shortest of them puts named signals on screen in one call:

showSignals("residual" to residual, "step" to step, title = "per iteration")

The others return a Plot for you to display; DoughDSP: hear the primitives uses showSignals throughout. Plotly has real text, a legend with line swatches, axes that autorange, and it reflows to whatever shape you give it. A Chart is fixed geometry fitted and centred in its panel, so a panel of a different shape leaves empty margin, and its text scales with the figure.

Use a Chart instead when you need one of these:

  • the plot drawn under the same transform as other FLine geometry, which Plotly cannot join;
  • clicking a mark and getting back the datum it stands for;
  • expect(), which draws an expected range on the plot.

The marks

points, line, area, bars, histogram, plus rule, vrule and band for annotation. Marks over the same numbers share one frame and one legend.

val xs = (0..120).map { it / 120.0 * 6.28318 }
val decay = xs.map { exp(-it * 0.25) * sin(it * 2.0) }
val envelope = xs.map { exp(-it * 0.25) }

Chart(title = "line, area and points share one frame", xLabel = "t (radians)", yLabel = "amplitude")
    .area("envelope", xs, envelope)
    .line("damped sine", xs, decay)
    .points(
        "samples",
        xs.filterIndexed { i, _ -> i % 8 == 0 },
        decay.filterIndexed { i, _ -> i % 8 == 0 },
    )

Three marks in one frame: a filled envelope area, a damped sine line through it, and every eighth sample as a green dot

bars takes categories and puts them on the axis, so you do not index them yourself:

Chart(title = "bars put their categories on the axis", yLabel = "gates")
    .bars("cost", ["xor", "maj3", "adder", "mux", "parity"], [6, 11, 27, 4, 19])

Five bars labelled xor, maj3, adder, mux and parity, with their categories written under them on the axis

histogram bins for you by Sturges' rule:

A histogram of 600 samples from two overlapping Gaussians, twelve bins wide, counts on the y axis

Scales and ticks

Scale(lo, hi, pixelLo, pixelHi, kind) maps data to pixels with toPixel/toData. A y scale is built inverted, because y grows downwards in Field's 2D outputs.

The tick functions are public, so you can use them for an axis of your own: niceTicks(lo, hi, target), logTicks, niceNumber, tickLabel and tickLabels.

Values are snapped to their own step, so an axis never reads 0.30000000000000004, and tickLabels picks one notation for a whole axis rather than per label.

Set xKind/yKind to ScaleKind.LOG for a log axis. A log scale refuses a non-positive domain, and a non-positive datum is left out of the plot.

val n = (1..6).map { 10.0.pow(it) }
val time = n.map { it * 1.4e-6 + 3e-4 }

Chart(title = "a log axis, and decade ticks", xLabel = "n", yLabel = "seconds")
    .also {
        it.xKind = ScaleKind.LOG
        it.yKind = ScaleKind.LOG
    }
    .line("measured", n, time)
    .points("measured", n, time)

A log-log plot over six decades, ticks at 1.0e+01 through 1.0e+06 on x and 0.001 through 1 on y

expect(): drawing what the data should do

expect(min, max, series, because) draws the expectation: the accepted range becomes a band across the plot, the bounds it names are ruled, data outside recolours, and the footnote counts the violations. An unbounded side draws no edge, which is the visual difference between "at most 1e-6" and "between 0 and 1e-6".

Chart(title = "residual per iteration", xLabel = "iteration", yLabel = "|r|", note = "Adam, lr 0.05")
    .line("residual", iterations, residual)
    .points("residual", iterations, residual)
    .expect(max = 1e-6, because = "converged below 1e-6")

A residual curve that converges but spikes twice above the expectation band; the bound is ruled at 1e-6, four points are red, and the footnote reads 4 points outside expectation

  • Chart.deviations returns the points outside the expectation, so a test can assert on the same list the footnote counts. There is also a predicate form, expect(series, because) { x, y -> … }, with no shape to draw.
  • Each datum is counted once. A line for the trend plus points to make it clickable is two marks over one datum, and the footnote counts that datum once.

Picking marks

Box.lines switches to the interactive SVG renderer the moment any line carries onClick or hoverColor. Point and bar marks set both, plus a label naming the datum, so you can hover over a bar or click a point with no extra setup:

val c = Chart(yLabel = "gates").bars("cost", "xor" to 6, "adder" to 27)
c.onPick = { pick ->
    print("${pick.series}/${pick.category} = ${pick.y}")
}
showChart(c)

Set pickable = false to turn this off. A series with more data than pickLimit (400 by default) stops responding point by point.

From a DataFrame

DataFrame<*>.chart() picks the axes so you do not have to name them for a quick look: the first numeric column for x if the frame has two or more, otherwise the first column as categories, otherwise the row index; then every remaining numeric column as a series.

import org.jetbrains.kotlinx.dataframe.api.*

val people = dataFrameOf("name", "age")("Alice", 15, "Bob", 30, "Cara", 45)

people.chart() // bars: age per name
people.chart(x = "name", y = ["age"]) // choose both axes explicitly
showChart(people) // straight from a frame to the screen
import org.jetbrains.kotlinx.dataframe.api.*

val people = dataFrameOf("name", "age")("Alice", 15, "Bob", 30, "Cara", 45)

showChart(people) // bars: age per name, chosen by chart()

Three light blue bars, age per name: Alice 15, Bob 30, Cara 45, with name along x and age up y, and a caption reading 3 rows by 2 columns

Series are capped for auto-selection, because a legend stops being readable past a handful of entries, and the per-datum dots are dropped automatically for a frame with more rows than pickLimit. See DataFrames in a Box.

Keeping a figure inside its frame

The panel a Chart lands in is fitted to the geometry it actually draws, so anything drawn outside width × height would make the whole figure smaller. clip, on by default, prevents that in two ways:

  • mark geometry is cut to plotRect: lines and area outlines by Liang–Barsky, an area's fill by Sutherland–Hodgman so it stays closed, a point outside dropped whole, a bar clamped on both axes and dropped when no part of it is in the window, a rule or a band that falls off the axis not drawn at all. A clipped series stays one FLine with several subpaths, so it is still one series, one legend entry, and one thing to hover;
  • title and note are wrapped to the figure width, capped at two and four lines with an ellipsis past that.

Clipping only matters when you set an explicit xDomain or yDomain, because a derived domain always covers its own data.

The same marks in an explicit window, with clip off and then on:

// The same marks in an explicit window, with clip off and then on.
fun figure(clipped: Boolean) =
    Chart(title = if (clipped) "clip = true" else "clip = false", xLabel = "x", yLabel = "y")
        .area("area", (0..40).toList(), (0..40).map { it * 0.6 - 6.0 })
        .line("line", (0..40).toList(), (0..40).map { 22.0 - it * 0.9 })
        .points("points", (0..40).toList(), (0..40).map { 4.0 + it * 0.4 })
        .apply {
            xDomain = 8.0 to 32.0
            yDomain = 0.0 to 20.0
            clip = clipped
        }

showChart(figure(false), channel = "unclipped")
showChart(figure(true), channel = "clipped")

Two charts of an area, a line and points over x 8 to 32 and y 0 to 20. With clip = false the marks run far past the axes and the whole figure shrinks to fit them, the plot a small rectangle in the middle. With clip = true every mark stops at the plot rectangle and the figure fills its frame

Set clip = false when you are composing a chart with other FLine geometry under a shared transform and want the overflow.

Down to geometry

toFLines() lowers the whole figure to List<FLine> in pixel space and plotRect() reports the rectangle the marks are clipped to, so the chart can go into the same drawing as the rest of your geometry, under one transform. toHTML() goes through afield.ui.visualization.linesToHTML, the same markup Box.lines builds.

Labels are glyph outlines. On a machine with no fonts, the chart is drawn without labels.

Text of your own goes in as TextLayout.toFLines() (one line per glyph) or toFLine() (one for the whole string). Move it with FLine.transform, which carries each curve's control points with its end points.

Agents: where the pictures come from

The figures on this page are committed captures from the visualization.chart-gallery FIELD_TEST: Chart.toFLines() → Skia's outputTo on a raster surface → PNG, with no AWT, no GPU and no Field window. Regenerate them with:

CHART_GALLERY_DIR=public/docs/assets/chart_gallery python3 tools/run_field_tests.py --id visualization.chart-gallery

ChartTest fails while the committed figures differ from what the chart code draws. What the gallery asserts is in dev_docs/chart_primitive.md; the clipping before/after pairs are in dev_docs/chart_clip_and_note.md. Dots are twelve-sided polygons rather than pointed nodes because Skia exports each pointed node to SVG as several kilobytes of curves.

The arithmetic is tested separately from the pixels. afield.ui.visualization.ChartTest owns ticks, mapping, log refusals and expectations:

./gradlew jvmTest --no-daemon --tests 'afield.ui.visualization.ChartTest'

Reference

The generated API listing is under Visualization. See also DataFrames in a Box and FLine examples.