DataFrameVisualizer.kt
src/jvmMain/kotlin/afield/ui/visualization/DataFrameVisualizer.kt · 190 lines · cited by DataFrames in a Box
package afield.ui.visualization
import afield.app.diagnostics.named
import afield.data.table.div
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.document.graph.box
import afield.document.graph.nested
import afield.runtime.execution.plus
import afield.runtime.lifecycle.frames
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.ui.notation.with
import afield.app.protocol.put
import afield.audio.synth.*
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.map
import afield.ui.output.column
import org.jetbrains.kotlinx.dataframe.DataColumn
import org.jetbrains.kotlinx.dataframe.DataFrame
import org.jetbrains.kotlinx.dataframe.DataRow
import org.jetbrains.kotlinx.dataframe.api.GroupBy
Makes Kotlin DataFrame values render as Field values (.h, .h2, display and box return
values) without DataFrame having to know Field exists.
DataFrame is a third-party interface, so it cannot implement ToHTML the way Table and Img
do. Both registries exist for exactly this: ValuePrinterRegistry for the compact .h form, and
H2ValueRegistry for .h2. Everything here is a thin adapter onto primitives that already exist,
so a frame gets the same styling, the same expander and the same paging as every other value.
The compact .h form
table already renders a column-oriented table in Field's output style, so a frame's compact
form is that table over the first HEAD_ROWS rows, plus a line giving the shape. Only the head is
touched: each column is indexed for its first HEAD_ROWS cells rather than the frame being
materialised, so printing a million-row frame costs about what printing ten rows costs.
The .h2 scroller
H2ValueSnapshot adapts one value into another for the lazy renderer rather than producing HTML.
So .h2 on a frame adapts it into a private list-of-rows view that reads a row only when asked,
and the h2 explorer (h2explorer.kt, reached as an iframe onto /h2-explorer?root=<token> and fed
over the /ws __h2_explorer__ channel) streams windows of it by index. The server holds the
frame rather than a rendering of it behind an .h2 token, and each scroll materialises only the
window being looked at, which is what makes a large frame a scroller instead of a large HTML dump.
ValueVisualizer's /lazy-value-fragment renderer is a separate path, for expanding a value in
place rather than scrolling one.
Going through the registries rather than a bespoke widget also means a frame nested inside another value, a list of frames or a data class with a frame field, expands with the same machinery.
Where the adapters get registered
install is idempotent and runs during server start-up. A bare jvm-main harness does not run
that, so tests call it explicitly; DataFrameVisualizerSmokeTest is the worked example.
object DataFrameVisualizer
The registration point, and the four renderers behind it: a frame, a single column, a single row and a grouped frame, each as one HTML fragment.
object DataFrameVisualizer {
const val HEAD_ROWS: Int = 10
Rows shown in the compact .h form. .h2 streams the whole frame instead.
@Volatile
private var installed = false
fun install()
Register every DataFrame renderer. Idempotent, and safe to call from any thread.
@Synchronized
fun install() {
if (installed) return
installed = true
ValuePrinterRegistry.register(DataFrame::class.java) { frame -> frameHead(frame) }
ValuePrinterRegistry.register(DataColumn::class.java) { column -> columnHead(column) }
ValuePrinterRegistry.register(DataRow::class.java) { row -> rowFacts(row) }
ValuePrinterRegistry.register(GroupBy::class.java) { grouped -> groupBySummary(grouped) }
// `.h2` on a frame streams every row; the other three are small enough that their compact
// form is already the whole story, so they fall through to the default lazy rendering.
H2ValueRegistry.register(DataFrame::class.java) { frame ->
H2ValueSnapshot(
value = LazyRows(frame),
summary = shape(frame),
title = "DataFrame"
)
}
// …and say that out loud, because since `H2Reflect` the sentence above stopped being true on
// its own. The reflective default claims any type it can read two properties from, and
// `DataColumn`/`GroupBy` are neither platform types nor adapter-served, so `.h2` on a column
// showed three reflected properties instead of `columnHead`. Measured on
// `ValueColumnImpl`/`GroupByImpl`: both `isExplorableObject` before this call and not after.
// `DataRow` needs no entry — reflection finds zero readable properties on `DataRowImpl`, so
// it fails the two-property floor by itself — but it is named here anyway, because relying on
// a third-party implementation detail to enforce our own decision is how this broke once.
listOf(DataColumn::class.java, DataRow::class.java, GroupBy::class.java)
.forEach { H2Reflect.refuseReflection(it) }
}
12 rows × 3 columns, the line worth having above any table.
private fun shape(frame: DataFrame<*>): String =
"${frame.rowsCount()} rows × ${frame.columnsCount()} columns"
The head of a frame as a Table, plus its shape.
take(HEAD_ROWS) per column rather than materialising the frame: this is the
form that shows up in a box output on every execution, so it has to stay cheap for a frame far
larger than the screen.
private fun frameHead(frame: DataFrame<*>): String {
val columns = frame.columns()
if (columns.isEmpty()) return "<span style='color: $TABLE_MUTED;'>empty DataFrame</span>"
val head: List<Pair<String, List<Any?>>> =
columns.map { column -> column.name() to headCells(column) }
val rendered = table(*head.toTypedArray()).toHTML()
val hidden = frame.rowsCount() - HEAD_ROWS
val note = if (hidden > 0) " — first $HEAD_ROWS; use <code>.h2</code> to scroll all of them" else ""
return rendered + "<div style='color: $TABLE_MUTED; font-size: 90%;'>${shape(frame)}$note</div>"
}
The first HEAD_ROWS cells of a column, by index.
Indexing rather than take: DataFrame declares its own take on a column and Kotlin declares
one on Iterable, so the call is ambiguous — and indexing is what actually guarantees only the
head is touched, which is the point for a frame far larger than the screen.
private fun headCells(column: DataColumn<*>): List<Any?> {
val n = minOf(HEAD_ROWS, column.size())
return (0 until n).map { column[it] }
}
A single column: its name and element type, then its head.
private fun columnHead(column: DataColumn<*>): String {
val cells: Pair<String, List<Any?>> = column.name() to headCells(column)
val rendered = table(cells).toHTML()
val hidden = column.size() - HEAD_ROWS
val note = if (hidden > 0) " — first $HEAD_ROWS of ${column.size()}" else ""
return rendered +
"<div style='color: $TABLE_MUTED; font-size: 90%;'>${column.type()}$note</div>"
}
A row reads as label/value pairs, which is what facts is for.
private fun rowFacts(row: DataRow<*>): String =
facts(row.df().columns().map { column -> column.name() to row[column.name()] }).toHTML()
A grouped frame: how many groups, keyed by what.
private fun groupBySummary(grouped: GroupBy<*, *>): String {
val keys = grouped.keys
return facts(
"groups" to keys.rowsCount(),
"keyed by" to keys.columnNames().joinToString(", "),
).toHTML() + keys.let { frameHead(it) }
}
A frame seen as its rows, materialised one at a time.
This is what turns .h2 into a scroller rather than a dump. The lazy visualizer pages a list
by index, so holding the frame and answering get on demand means only the rows actually being
looked at are ever built — the whole point when the frame is larger than memory would like.
private class LazyRows(private val frame: DataFrame<*>) : AbstractList<Map<String, Any?>>() {
private val names = frame.columns().map { it.name() }
override val size: Int get() = frame.rowsCount()
override fun get(index: Int): Map<String, Any?> {
val row = frame[index]
return LinkedHashMap<String, Any?>(names.size).apply {
names.forEach { name -> put(name, row[name]) }
}
}
}
}