plotly.kt
src/jvmMain/kotlin/afield/ui/visualization/plotly.kt · 635 lines
package and imports — 51 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Visualization")
package afield.ui.visualization
import afield.app.diagnostics.named
import afield.app.protocol.payload
import afield.app.server.flag
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.runtime.completion.CompletionCategory
import afield.runtime.execution.plus
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.ui.notation.double
import afield.ui.notation.long
import afield.ui.notation.string
import afield.ui.output.histogram
import afield.ui.output.logs
import afield.ui.output.md
import afield.audio.synth.*
import afield.data.table.div
import afield.document.graph.Box
import afield.document.graph.box
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.map
import afield.ui.notation.with
import afield.ui.output.templates.filter
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import kotlinx.html.*
import kotlinx.html.stream.createHTML
import space.kscience.plotly.*
import space.kscience.plotly.models.Dash
import space.kscience.plotly.models.ScatterMode
import space.kscience.plotly.models.ViolinPoints
import space.kscience.plotly.models.SpanMode
import space.kscience.plotly.models.XAnchor
import space.kscience.plotly.models.YAnchor
import space.kscience.plotly.models.ShapeLayer
import space.kscience.plotly.models.ShapeType
import space.kscience.dataforge.meta.asValue
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
This is the DEFAULT chart path in Field. charts.kt's FLine Chart exists only for figures that
must compose with other FLine geometry, be pickable through Field's interactive SVG, or draw an
expectation band; anything else belongs here. Both capture headlessly through the docs runner —
public/docs/dough_dsp_sound.md has a dozen diffstatic_runner fences over showSignals.
Plotly helpers — the short way to get a chart out of a box, wrapping space.kscience.plotly.
linePlot, scatter, scatterSized and violin cover the common shapes (lists of numbers,
Vec2, Vec3) and hand back a Plot already run through darkMode, the repo dark theme with
data-first margins. showSignals goes one step further: pass "label" to values pairs and it
traces them against a shared x with the signalPalette, optionally min-max normalized and with
dashed reference marks, then displays the plot into a channel of a live Box — it is already on
screen when it returns, so do not display it again.
toHtml renders a Plot into an HTML fragment via PlotlyIntegration. That fragment calls
Plotly.react in the browser, so the page has to have the Plotly library loaded already;
without it the fragment logs to the console and draws nothing.
fun linePlot(vararg data: List<Number>): Plot
A dark-mode line plot of one or more lists of numbers against their own sample index — the fastest way to look at a signal. Each list becomes its own hairline trace, so lists of different lengths are fine: they simply run out at different x.
The Plot is returned, not displayed — hand it to display yourself, or use showSignals,
which labels the traces and puts them on screen in one call. darkMode has already run, and it
sized the top margin for an untitled plot, so a title set after this returns gets a slim band;
see darkMode.
@JvmName("linePlot_scalar")
fun linePlot(vararg data: List<Number>): Plot {
return Plotly.plot {
data.forEach {
trace {
x.set(it.mapIndexed { index, _ -> index })
y.set(it)
line.width = 1.0
}
}
}.darkMode()
}
fun linePlot(vararg data: List<Vec2>): Plot
A dark-mode line plot of one or more Vec2 polylines, using each point's own x and y — the variant
to reach for when x is a real abscissa (time, frequency, arc length) rather than a sample index.
Points are joined in list order, so this draws a path and will happily double back on itself.
Same conventions as the scalar overload: one hairline trace per list, darkMode already applied,
and the Plot returned rather than displayed.
@JvmName("linePlot_vec2")
fun linePlot(vararg data: List<Vec2>): Plot {
return Plotly.plot {
data.forEach {
trace {
x.set(it.map { it.x })
y.set(it.map { it.y })
line.width = 1.0
}
}
}.darkMode()
}
fun scatter(vararg data: List<Vec2>): Plot
A dark-mode markers-only scatter of one or more Vec2 lists, at a fixed 3px marker — for point
clouds where the order of the points means nothing and a connecting line would lie about it.
Reach for scatterSized instead when a third number per point should drive the marker size.
darkMode is already applied and the Plot is returned, not displayed.
fun scatter(vararg data: List<Vec2>): Plot {
return Plotly.plot {
data.forEach {
scatter {
x.set(it.map { it.x })
y.set(it.map { it.y })
mode = ScatterMode.markers
marker {
size = 3.0
}
}
}
}.darkMode()
}
fun scatterSized(vararg data: List<Vec3>): Plot
A dark-mode scatter of Vec3 lists in which x and y place the marker and z is the marker size —
a bubble chart, for carrying a third quantity per point.
The z values go to Plotly as marker sizes with no normalisation and no sizeref, so they are read
as diameters in pixels: a z of 0.5 is invisible and a z of 400 swallows the chart. Scale z yourself.
darkMode is applied and the Plot is returned, not displayed.
fun scatterSized(vararg data: List<Vec3>): Plot {
return Plotly.plot {
data.forEach {
scatter {
x.set(it.map { it.x })
y.set(it.map { it.y })
mode = ScatterMode.markers
marker {
sizesList = it.map { it.z }
}
}
}
}.darkMode()
}
fun violin(vararg data: List<Number>): Plot
A dark-mode violin plot, one violin per list of numbers, for comparing whole distributions rather than sequences — order within a list is irrelevant here, only the spread of values.
Each violin carries its inner box and its mean line, and draws individual points only for outliers.
The traces are left unnamed, so name them yourself when you plot more than one or the
legend will be useless. darkMode is applied and the Plot is returned, not displayed.
fun violin(vararg data: List<Number>): Plot {
return Plotly.plot {
data.forEach {
violin {
y.set(it)
name = ""
box.visible = true
meanline.visible = true
points = ViolinPoints.outliers
}
}
}.darkMode()
}
fun violin
Named violins side by side, each with an optional reference value drawn as a dashed line across its own violin — for "what was asked for against what came out" over several groups: a target radius against the measured radii, a set point against the readings.
Pass "label" to values pairs; each pair becomes one violin at category label, coloured in
signalPalette order, with its inner box and mean line as in the unnamed overload.
spanQuantiles clips each violin's density outline to that quantile range of its own group
(Plotly spanmode: "manual"), so a handful of stragglers does not stretch a thin tail across the
whole axis; null keeps Plotly's soft span over the full data. Nothing is dropped: values outside
the box whiskers are drawn as small faint points at outlierOpacity. references,
when given, holds one number per group in the same order (a NaN skips that group) and is drawn in
referenceColor as a dashed horizontal segment spanning that violin's slot only, so four groups
with four different targets read as four comparisons rather than one shared threshold. yRange
pins the value axis; yTitle names it.
Every value is serialised into the page, so thin a very large sample (tens of thousands of points
per group) before plotting; the shape survives decimation long before Plotly's payload does.
darkMode is applied and the Plot is returned, not displayed.
body — 64 lines, from line 205
@JvmName("violin_named")
fun violin(
vararg groups: Pair<String, List<Number>>,
references: List<Number> = emptyList(),
referenceColor: String = "#ffe066",
yRange: ClosedFloatingPointRange<Double>? = null,
yTitle: String? = null,
spanQuantiles: ClosedFloatingPointRange<Double>? = 0.01..0.99,
outlierOpacity: Double = 0.25,
): Plot {
require(references.isEmpty() || references.size == groups.size) {
"violin got ${groups.size} groups and ${references.size} references; give one per group or none"
}
return Plotly.plot {
groups.forEachIndexed { i, (label, values) ->
violin {
x.set(List(values.size) { label })
y.set(values)
name = label
box.visible = true
meanline.visible = true
points = ViolinPoints.outliers
marker {
size = 2.0
opacity = outlierOpacity
}
spanQuantiles?.let { q ->
val sorted = values.map { it.toDouble() }.filter { it.isFinite() }.sorted()
if (sorted.isNotEmpty()) {
fun at(f: Double) = sorted[((sorted.size - 1) * f.coerceIn(0.0, 1.0)).toInt()]
spanmode = SpanMode.manual
span(listOf(at(q.start), at(q.endInclusive)))
}
}
line.color(signalPalette[i % signalPalette.size])
}
}
layout {
showlegend = false
yaxis {
yTitle?.let { title = it }
yRange?.let { range(it) }
}
references.forEachIndexed { i, ref ->
val v = ref.toDouble()
if (v.isNaN()) return@forEachIndexed
figure {
type = ShapeType.line
xref = "x"
yref = "y"
x0 = (i - 0.45).asValue()
x1 = (i + 0.45).asValue()
y0 = v.asValue()
y1 = v.asValue()
line {
color(referenceColor)
width = 1.5
dash = Dash.dash
}
}
}
}
}.darkMode()
}
data class SignalRegion(val label: String, val from: Number, val to: Number, val color: String = "#ffe066")
A labelled x-span for showSignals' regions: a translucent band from from to to.
data class SignalRegion(val label: String, val from: Number, val to: Number, val color: String = "#ffe066")
val signalPalette: List<String>
Cyan/orange-led dark-mode palette for signal traces (leads cyan, then orange).
val signalPalette: List<String> = listOf("#7ad9ff", "#ff9e4a", "#8fff9a", "#ffe066", "#c58cff", "#ff7a9e")
fun Box.showSignals
Put one or more named signals on the screen as a dark-mode Plotly line plot, and return the Plot.
The terse "these signals, on screen, now" helper: pass "label" to values pairs and it traces
them against a shared x (sample index by default, or x), applies the signalPalette and the
repo dark theme, and displays into channel. This collapses the usual
Plotly.plot { trace {...}; trace {...} }.darkMode(); display(plot) boilerplate to one call.
x: shared x values for every series (e.g. frequency in Hz); defaults to the sample index.marks: vertical dashed reference lines,"label" to xPosition(e.g. a spectral centroid or rolloff, or a regime boundary), spanning the full y-range, drawn inmarkColor.normalize: min-max each series into[0,1]— use when the signals live on very different scales (e.g. a centroid in Hz alongside an RMS in[0,1]) and you only care about their shape.regions: translucent bands over x-spans, each labelled at the top — where a detector fired, where a motif occurs. Drawn under the traces.width/height: the figure size in CSS pixels; the default is Plotly's 700 × 450.floatingLegend: put the legend inside the plot's upper right on a translucent dark panel instead of outside its right edge, so a wide figure is all data area. It can cover data there, which is why it is not the default; use it when the upper right is quiet.
The plot is already displayed on return; the Plot is returned only for further tweaking (do not
display it again).
body — 97 lines, from line 299
fun Box.showSignals(
vararg series: Pair<String, List<Number>>,
x: List<Number>? = null,
channel: String = "output",
title: String? = null,
xTitle: String? = null,
yTitle: String? = null,
marks: List<Pair<String, Number>> = emptyList(),
markColor: String = "#ffe066",
normalize: Boolean = false,
regions: List<SignalRegion> = emptyList(),
width: Int? = null,
height: Int? = null,
floatingLegend: Boolean = false,
): Plot {
fun shape(values: List<Number>): List<Number> {
if (!normalize) return values
val ds = values.map { it.toDouble() }
// NaN marks a gap; it must not decide the range, or every point normalizes to NaN.
val lo = ds.filter { !it.isNaN() }.minOrNull() ?: 0.0
val hi = ds.filter { !it.isNaN() }.maxOrNull() ?: 1.0
val span = (hi - lo).takeIf { it > 1e-12 } ?: 1.0
return ds.map { (it - lo) / span }
}
val plot = Plotly.plot {
series.forEachIndexed { i, (label, values) ->
trace {
name = label
this.x.set(x ?: values.indices.toList())
this.y.set(shape(values))
line.width = 1.4
line.color(signalPalette[i % signalPalette.size])
}
}
if (marks.isNotEmpty()) {
val allY = series.flatMap { shape(it.second).map { n -> n.toDouble() } }.filter { !it.isNaN() }
val ymin = allY.minOrNull() ?: 0.0
val ymax = allY.maxOrNull() ?: 1.0
marks.forEach { (label, mx) ->
trace {
name = label
this.x.set(listOf(mx, mx))
this.y.set(listOf(ymin, ymax))
line.width = 1.2
line.color(markColor)
line.dash = Dash.dash
}
}
}
layout {
title?.let { this.title = it }
xaxis { xTitle?.let { this.title = it } }
yaxis { yTitle?.let { this.title = it } }
width?.let { this.width = it }
height?.let { this.height = it }
for (r in regions) {
figure {
type = ShapeType.rect
layer = ShapeLayer.below
xref = "x"
yref = "paper"
x0 = r.from.asValue()
x1 = r.to.asValue()
y0 = 0.asValue()
y1 = 1.asValue()
fillcolor(r.color)
opacity = 0.28
line { this.width = 0 }
}
annotation {
text = r.label
xref = "x"
yref = "paper"
this.x = ((r.from.toDouble() + r.to.toDouble()) / 2).asValue()
y = 1.0.asValue()
yanchor = YAnchor.bottom
showarrow = false
font { color(r.color); size = 12 }
}
}
}
}.darkMode()
if (floatingLegend) plot.layout {
legend {
xanchor = XAnchor.right
yanchor = YAnchor.top
this.x = 0.99
y = 0.99
bgcolor("rgba(17,17,17,0.92)")
bordercolor("rgba(170,170,170,0.35)")
borderwidth = 1
}
margin { r = 16 }
}
display(channel, plot)
return plot
}
fun Box.showSpectrogram
Put a spectrogram of audio on the screen as a Plotly heatmap with time and frequency axes, and return the Plot.
Frequencies run from 0 to maxHz, so a low maxHz zooms in on bass. fftSize sets the
frequency resolution, sampleRate / fftSize Hz per bin (8192 at 48 kHz is 5.9 Hz, fine enough
to separate a bass note from the tone an octave under it), and hopSize the time step. Colour is
level in dB relative to the loudest bin, over a rangeDb range.
fun Box.showSpectrogram(
audio: FloatArray,
sampleRate: Int = 48_000,
maxHz: Double = 600.0,
fftSize: Int = 8192,
hopSize: Int = 1024,
rangeDb: Double = 70.0,
channel: String = "output",
title: String? = null,
): Plot {
val spec = afield.audio.analysis.stft(audio, fftSize = fftSize, hopSize = hopSize, sampleRate = sampleRate.toDouble())
val bins = (0 until spec.numBins).filter { spec.binFrequency(it) <= maxHz }
val mags = spec.magnitudes()
val peak = mags.maxOf { m -> bins.maxOf { m[it] } }.coerceAtLeast(1e-12)
val db = bins.map { b -> mags.map { m -> (20.0 * kotlin.math.log10((m[b] / peak).coerceAtLeast(1e-9))).coerceAtLeast(-rangeDb) } }
val plot = Plotly.plot {
heatmap {
x.set((0 until spec.numFrames).map { spec.frameTime(it) })
y.set(bins.map { spec.binFrequency(it) })
z.set(db)
zmin = -rangeDb
zmax = 0.0
colorscale = space.kscience.dataforge.meta.Value.of("Viridis")
}
layout {
if (title != null) this.title = title
xaxis { this.title = "seconds" }
yaxis { this.title = "Hz" }
}
}.darkMode()
display(channel, plot)
return plot
}
fun Plot.darkMode(): Plot
Restyle a Plot into the repo's dark theme and hand back the same instance — near-black paper and
plot backgrounds, muted grid and tick colours, tick labels held horizontal, a vertical legend just
outside the plot's right edge, and data-first margins that reclaim most of the ~100px Plotly
reserves up top for a title.
It mutates the receiver rather than copying, so the Plot you get back is the one you passed in.
The top margin is decided at the moment you call it, from whether a title is already set: 46px if
so, 22px if not. Set the title first, or a title added afterwards is squeezed into the slim band.
Axis automargin stays on, so tick labels and axis titles still get the room they need. Everything
else in this file already calls this for you — you only need it on a Plot you built by hand.
The legend sits outside the plot area, against its right edge, so it cannot cover data. Until 2026-07-30 it was pinned inside at the lower right, where it hid the tail of every series that descended to the right — 11 published panels across 6 pages were losing data to it. Plotly's autoMargin reserves the width, so a plot with long trace labels gets a narrower data area rather than a clipped legend; keep the labels short if the data area matters more.
body — 95 lines, from line 458
fun Plot.darkMode(): Plot {
val hasTitle = !this.layout.title.isNullOrEmpty()
layout {
title {
x = 0.05
font {
color("#f2f5fa")
size = 15
}
}
// The legend sits OUTSIDE the plot area, to its right. Until 2026-07-30 it was pinned inside
// at the lower right (yanchor top, y 0.2), where it silently covered the tail of any series
// descending to the right — a decaying error curve, or the right tail of a null histogram,
// which is exactly the shape you most want to read. The upper right is no better, only
// differently wrong: rising traces end there. No position inside the frame is safe for a
// shared helper.
//
// Above the frame does not work either, because these legends are *vertical*. `orientation`
// is deliberately not set here: this plotly-kt serialises the Kotlin enum by name, emitting
// `orientation: "horizontal"` where Plotly.js accepts only "h" or "v", so it is rejected and
// the default vertical stack is drawn. Every committed figure on the site shows that stack,
// including ones with 11 entries — far too tall for a top band. Setting the property at all
// only puts an invalid attribute in the payload, so it is gone.
//
// x = 1.02 in paper coordinates is just past the plot area's right edge; Plotly's autoMargin
// then reserves the width the legend needs on top of the `margin.r` below, which is why that
// number does not have to know how long the labels are.
legend {
yanchor = YAnchor.top
xanchor = XAnchor.left
y = 1.0
x = 1.02
}
paper_bgcolor("rgb(17,17,17)")
plot_bgcolor("rgb(17,17,17)")
// Both axes carried `tickangle = 20.0` from `30461ba4f` onwards. **That is a real trade, not
// an oversight**: a positive tick angle improves the chances that large dense labels — long
// numbers, many ticks, a narrow figure — do not collide with each other.
//
// It is set to 0 here because the cost lands on the labels a reader needs most. Iosevka's `1`
// has a prominent flag and no base serif, so on a 20 degree tilt it reads as a `7`, and its
// `0` is slashed, so a tilted `0` stops reading as a disambiguated zero. On a normalised
// axis those two glyphs are exactly the endpoints: a plot whose own y title said "of 1"
// appeared to top out at 7.
//
// What it gives up turns out to be nothing this helper can reach, and that was measured
// rather than assumed. On a NUMERIC axis Plotly already defends against collision twice
// before an angle would matter: it picks the tick *count* from the axis width, and it
// *abbreviates* large magnitudes with an SI suffix. Asked for the densest axis this helper
// can be given — 96 000 six-digit sample indices in a 1460 px panel — it emitted
// `0 20k 40k 60k 80k`, five short labels using about a quarter of their slot each. Across
// the four densest committed figures (`streaming_features`, `filter_chains`,
// `one_spectrum`, `resonator_bank`) the worst case was five labels at roughly 40 px in a
// 160 px slot. There is no published plot where the 20 degrees was saving anything.
//
// So the angle only earns its keep on labels Plotly can neither thin nor abbreviate — long
// categorical strings, dates — and `showSignals` draws neither: its x is a sample index or
// a caller's numeric list. If a caller ever does hit collisions, the escalations in order
// are `dtick`/`nticks` for that plot, then `tickangle` on that plot's `xaxis` ONLY (a y tick
// is a short number stacked vertically, where rotation buys nothing and costs legibility),
// then Plotly's `"auto"`, which rotates only when it judges the labels crowded.
xaxis {
gridcolor("#283442")
linecolor("#000000")
zerolinecolor("#283442")
color("#aaaaaa")
tickangle = 0.0
zerolinewidth = 3
automargin = true
}
yaxis {
gridcolor("#283442")
linecolor("#000000")
zerolinecolor("#283442")
color("#aaaaaa")
tickangle = 0.0
zerolinewidth = 3
automargin = true
}
// Data-first margins: Plotly reserves ~100px up top for a title by default.
// Give the title only a slim band (or almost none when untitled) so the plot
// area gets the space. Axis `automargin` above still grows the left/bottom
// gutters as needed, so tick labels and axis titles never clip.
// `r` stays slim because the legend is not competing for it: sitting outside the plot area
// it goes through Plotly's autoMargin, which expands the right gutter past this figure by
// however much the labels actually need (`margin.autoexpand` defaults on).
margin {
t = if (hasTitle) 46 else 22
b = 34
l = 44
r = 24
}
}
return this
}
fun Plot.toHtml(): String
Render a Plot to an inline HTML fragment: a plain wrapper div around the plot's own div, which
carries a fresh random id and a script that calls Plotly.react on it. Not an iframe and not a whole document — this is exactly what a box
output does with a Plot, so the result drops straight into the editor's output pane.
The host page must already have the Plotly library loaded. If it does not, the script logs "Plotly not loaded" to the browser console and you are left with an empty div: no exception, no placeholder. The traces and layout are serialised into that script as JavaScript literals, so a non-finite value survives as a bare NaN or Infinity token — legal JavaScript, so Plotly receives a real NaN and leaves a gap in the line there, but not legal JSON, so never route this payload through a JSON parser. The id is a new UUID on every call, so two calls give two independent charts.
@OptIn(ExperimentalUuidApi::class)
fun Plot.toHtml(): String {
val p = PlotlyIntegration()
var plot = this
with(p) {
var frag: String = createHTML().div {
renderPlot(plot, Uuid.random().toString(), PlotlyConfig() {
responsive = true
})
}
return frag
}
}
public class PlotlyIntegration : PlotlyRenderer
The PlotlyRenderer behind toHtml: it emits a bare div plus a Plotly.react call against it,
rather than the self-contained page plotly-kt's own renderers produce. That is what makes the output
embeddable in a box output or a docs page that already loads Plotly.
Rarely worth using directly — toHtml is the front door, and renderFragment is here for a
PlotlyFragment holding several plots at once.
public class PlotlyIntegration : PlotlyRenderer {
override fun FlowContent.renderPlot(plot: Plot, plotId: String, config: PlotlyConfig): Plot
Emit plot into the surrounding kotlinx.html flow as a div with id plotId, followed by an
inline script handing the trace data, layout and config to Plotly.react. Returns the plot
unchanged.
The script is guarded on Plotly being defined, so a page without the library logs to the
console rather than throwing. Everything is serialised as JavaScript literals, which is what
lets NaN through intact — see toHtml.
override fun FlowContent.renderPlot(plot: Plot, plotId: String, config: PlotlyConfig): Plot {
div {
id = plotId
script {
unsafe {
//language=JavaScript
+"""
if(typeof Plotly !== "undefined"){
Plotly.react(
'$plotId',
${plot.data.toJsonString()},
${plot.layout.toJsonString()},
${config.toJsonString()}
);
} else {
console.error("Plotly not loaded")
}
""".trimIndent()
}
}
}
return plot
}
fun renderFragment(fragment: PlotlyFragment): String
Render a whole PlotlyFragment — a piece of kotlinx.html that may hold several plots and
ordinary markup between them — to an HTML string, with every plot inside it going through this
renderer. The result is wrapped in a single enclosing div; the same "Plotly must already be
loaded" caveat applies as for toHtml.
fun renderFragment(fragment: PlotlyFragment): String = createHTML().div {
with(fragment) {
render(this@PlotlyIntegration)
}
}
}