
Configurable, data-source-agnostic reporting: ingest rows, define dimensions/measures, date-bucket and filter, aggregate, render themeable Koala Plot charts with metric cards, extensible visuals and export formats.
Configurable, data-source-agnostic reporting for Kotlin Multiplatform. Feed it rows, define your dimensions and measures, and render themeable Koala Plot charts — on Android, iOS, desktop, and the web from a single codebase.
RowSource. FHIR, SQL, REST, or in-memory: the charts
never see your backend.The name —
womblot= wombat + plot: a small tribute to Koala Plot, which draws every chart here. The wombat is the koala's closest relative, so the nod stays in the family. 🐨🐻
The demo module running in the browser on WasmJS — the same Compose code that ships to Android, iOS, and
desktop.
| Light | Dark |
|---|---|
![]() |
![]() |
| Target | womblot-core |
womblot-compose |
|---|---|---|
| Android | ✅ | ✅ |
iOS (iosArm64, iosSimulatorArm64) |
✅ | ✅ |
| JVM / desktop | ✅ | ✅ |
| JS (browser, Node) | ✅ | ✅ |
| WasmJS | ✅ | ✅ |
| Module | Contents |
|---|---|
womblot-core |
The RowSource seam, the aggregation engine (dimensions × measures, date bucketing, filters), and CSV/JSON export. Pure Kotlin, no UI. |
womblot-compose |
Compose Multiplatform visuals (metric cards + charts) drawn with Koala Plot, the ReportVisualRegistry, ReportTheme, and the ReportResult → ChartData adapter. |
Artifacts are published to Maven Central under io.github.ellykits.womblot.
repositories {
mavenCentral()
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.ellykits.womblot:womblot-core:1.0.0-alpha02")
implementation("io.github.ellykits.womblot:womblot-compose:1.0.0-alpha02")
}
}
}womblot-compose exposes womblot-core as an api dependency, so the ReportResult types the chart adapters
take come along with it — depend on womblot-core directly only if you use the engine without any UI.
womblot-core supports all targets and may live in commonMain. womblot-compose targets Android, iOS, desktop,
JS, and WasmJS.
This walkthrough builds the exact report the demo module
ships with — count of service visits per month, split by programme — so you can follow along against real, runnable
code. The three moving parts are always the same: rows in → a definition → a result → a visual.
A Row is just a Map<String, ReportValue> — one flat record. womblot never parses your domain objects; you
hand it rows and it aggregates them. For tests and prototypes, InMemoryRowSource groups named lists of rows into
"sources" a report can point at.
import io.ellykits.womblot.model.ReportValue
import io.ellykits.womblot.source.InMemoryRowSource
import io.ellykits.womblot.source.Row
fun visit(date: String, program: String, patient: String): Row =
mapOf(
"date" to ReportValue.Text(date),
"program" to ReportValue.Text(program),
"patient" to ReportValue.Text(patient),
)
val visits =
listOf(
visit("2026-01-05", "ANC", "p1"),
visit("2026-01-20", "ANC", "p2"),
visit("2026-01-15", "Immunization", "p3"),
visit("2026-02-10", "ANC", "p4"),
visit("2026-02-18", "Immunization", "p6"),
visit("2026-03-04", "ANC", "p7"),
visit("2026-03-22", "Immunization", "p9"),
)
val source = InMemoryRowSource(mapOf("visits" to visits))For anything real, implement the one-method seam instead — the engine only ever calls rows(query):
class SqlRowSource(private val db: Database) : RowSource {
override suspend fun rows(query: SourceQuery): List<Row> =
db.select(query.source).map { record -> record.toReportRow() }
}A ReportDefinition is the whole query, declared as data:
source — which named row set to read ("visits" above).dimensions — how to group. Each Dimension(name, column, bucket?) reads one column; an optional
DateBucket (DAY, WEEK, MONTH, QUARTER, YEAR) collapses a date column into periods. Here date →
month, and program is grouped verbatim.measures — what to compute per group. Each Measure(name, aggregation, column?) applies an Aggregation
(COUNT, SUM, AVG, MIN, MAX, DISTINCT). COUNT needs no column.filters (optional) — Filter(column, op, value) drops rows before aggregation (EQ, NE, GT, LT, …).import io.ellykits.womblot.model.Aggregation
import io.ellykits.womblot.model.DateBucket
import io.ellykits.womblot.model.Dimension
import io.ellykits.womblot.model.Measure
import io.ellykits.womblot.model.ReportDefinition
val servicesByMonth =
ReportDefinition(
name = "servicesByMonth",
source = "visits",
dimensions =
listOf(
Dimension("month", column = "date", bucket = DateBucket.MONTH),
Dimension("program", column = "program"),
),
measures = listOf(Measure("count", Aggregation.COUNT)),
)ReportEngine reads the rows from the source and returns a ReportResult — a small cube of cells keyed by each
dimension combination, holding the computed measures. It is pure data, UI-free, and identical on every platform.
import io.ellykits.womblot.engine.ReportEngine
val result = ReportEngine(source).run(servicesByMonth)Wrap content in a ReportThemeProvider, pivot the result into ChartData with toChartData(...), and drop in any
visual. category picks the axis dimension, measure the value, and the optional seriesBy splits into a series
per value of another dimension (here, one bar colour per programme).
import io.ellykits.womblot.compose.model.toChartData
import io.ellykits.womblot.compose.theme.ReportPalettes
import io.ellykits.womblot.compose.theme.ReportTheme
import io.ellykits.womblot.compose.theme.ReportThemeProvider
import io.ellykits.womblot.compose.ui.Metric
import io.ellykits.womblot.compose.ui.MetricRow
import io.ellykits.womblot.compose.ui.chart.BarChart
ReportThemeProvider(theme = ReportTheme.light(ReportPalettes.Cool)) {
Column {
MetricRow(
listOf(
Metric("Households visited", "248"),
Metric("ANC visits", "86"),
),
)
BarChart(
data = result.toChartData(category = "month", measure = "count", seriesBy = "program"),
title = "Services by month",
)
}
}val csv: ByteArray = ExportRegistry.export("csv", result)
val json: ByteArray = ExportRegistry.export("json", result)Every visual takes caller-supplied ChartData and a ChartStyle, renders inside a themed card, and is drawn by
Koala Plot underneath.
| Composable | Registry type | Notes |
|---|---|---|
MetricCard, MetricRow
|
— | Headline value with a ↑/↓ delta; MetricRow fills on wide screens and swipes on narrow ones. |
BarChart |
bar, barStacked
|
Grouped or stacked vertical bars. |
HorizontalBarChart |
hbar |
Ranked horizontal bars. |
LineChart |
line, area
|
One or more series; per-series solid/dashed; optional area fill. |
DonutChart |
donut, pie
|
Slices with a centre total and legend. |
TargetBarChart |
targetBar |
Progress bars against a target marker. |
BulletChart |
bullet |
Measure vs. target with qualitative bands. |
RadarChart |
radar |
Multi-axis comparison across series. |
DataTable |
— | Columns with threshold-coloured cells and status chips. |
ReportTheme controls the palette, semantic colours, card, axis, and grid. Use ReportTheme.light(palette) /
ReportTheme.dark(palette) with a preset from ReportPalettes (Default, Cool, Warm, Pastel, Vibrant,
ColorblindSafe), or build your own. Any ChartStyle.palette overrides the theme for a single chart.
ReportThemeProvider(theme = ReportTheme.dark(ReportPalettes.ColorblindSafe)) { /* … */ }Register a new visual type — configuration and ReportVisualRegistry.Render pick it up like any built-in.
registerBuiltinVisuals()
ReportVisualRegistry.register("gauge") { data, style, title -> MyGauge(data, style, title) }ExportRegistry.register("xlsx", exporter) adds export formats the same way.
The demo shows every visual over the sample data above, with a theme switch and a palette picker.
./gradlew :demo:run # desktop
./gradlew :demo:wasmJsBrowserDevelopmentRun # browser, WasmJSCharts are rendered with Koala Plot, a Compose Multiplatform
plotting library, which womblot is named in tribute to. womblot is a thin, opinionated reporting layer on top
of it: the aggregation engine, theming, and config surface are ours; the drawing is theirs. If you need lower-level
plotting control, use Koala Plot directly.
Apache License 2.0.
Configurable, data-source-agnostic reporting for Kotlin Multiplatform. Feed it rows, define your dimensions and measures, and render themeable Koala Plot charts — on Android, iOS, desktop, and the web from a single codebase.
RowSource. FHIR, SQL, REST, or in-memory: the charts
never see your backend.The name —
womblot= wombat + plot: a small tribute to Koala Plot, which draws every chart here. The wombat is the koala's closest relative, so the nod stays in the family. 🐨🐻
The demo module running in the browser on WasmJS — the same Compose code that ships to Android, iOS, and
desktop.
| Light | Dark |
|---|---|
![]() |
![]() |
| Target | womblot-core |
womblot-compose |
|---|---|---|
| Android | ✅ | ✅ |
iOS (iosArm64, iosSimulatorArm64) |
✅ | ✅ |
| JVM / desktop | ✅ | ✅ |
| JS (browser, Node) | ✅ | ✅ |
| WasmJS | ✅ | ✅ |
| Module | Contents |
|---|---|
womblot-core |
The RowSource seam, the aggregation engine (dimensions × measures, date bucketing, filters), and CSV/JSON export. Pure Kotlin, no UI. |
womblot-compose |
Compose Multiplatform visuals (metric cards + charts) drawn with Koala Plot, the ReportVisualRegistry, ReportTheme, and the ReportResult → ChartData adapter. |
Artifacts are published to Maven Central under io.github.ellykits.womblot.
repositories {
mavenCentral()
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.ellykits.womblot:womblot-core:1.0.0-alpha02")
implementation("io.github.ellykits.womblot:womblot-compose:1.0.0-alpha02")
}
}
}womblot-compose exposes womblot-core as an api dependency, so the ReportResult types the chart adapters
take come along with it — depend on womblot-core directly only if you use the engine without any UI.
womblot-core supports all targets and may live in commonMain. womblot-compose targets Android, iOS, desktop,
JS, and WasmJS.
This walkthrough builds the exact report the demo module
ships with — count of service visits per month, split by programme — so you can follow along against real, runnable
code. The three moving parts are always the same: rows in → a definition → a result → a visual.
A Row is just a Map<String, ReportValue> — one flat record. womblot never parses your domain objects; you
hand it rows and it aggregates them. For tests and prototypes, InMemoryRowSource groups named lists of rows into
"sources" a report can point at.
import io.ellykits.womblot.model.ReportValue
import io.ellykits.womblot.source.InMemoryRowSource
import io.ellykits.womblot.source.Row
fun visit(date: String, program: String, patient: String): Row =
mapOf(
"date" to ReportValue.Text(date),
"program" to ReportValue.Text(program),
"patient" to ReportValue.Text(patient),
)
val visits =
listOf(
visit("2026-01-05", "ANC", "p1"),
visit("2026-01-20", "ANC", "p2"),
visit("2026-01-15", "Immunization", "p3"),
visit("2026-02-10", "ANC", "p4"),
visit("2026-02-18", "Immunization", "p6"),
visit("2026-03-04", "ANC", "p7"),
visit("2026-03-22", "Immunization", "p9"),
)
val source = InMemoryRowSource(mapOf("visits" to visits))For anything real, implement the one-method seam instead — the engine only ever calls rows(query):
class SqlRowSource(private val db: Database) : RowSource {
override suspend fun rows(query: SourceQuery): List<Row> =
db.select(query.source).map { record -> record.toReportRow() }
}A ReportDefinition is the whole query, declared as data:
source — which named row set to read ("visits" above).dimensions — how to group. Each Dimension(name, column, bucket?) reads one column; an optional
DateBucket (DAY, WEEK, MONTH, QUARTER, YEAR) collapses a date column into periods. Here date →
month, and program is grouped verbatim.measures — what to compute per group. Each Measure(name, aggregation, column?) applies an Aggregation
(COUNT, SUM, AVG, MIN, MAX, DISTINCT). COUNT needs no column.filters (optional) — Filter(column, op, value) drops rows before aggregation (EQ, NE, GT, LT, …).import io.ellykits.womblot.model.Aggregation
import io.ellykits.womblot.model.DateBucket
import io.ellykits.womblot.model.Dimension
import io.ellykits.womblot.model.Measure
import io.ellykits.womblot.model.ReportDefinition
val servicesByMonth =
ReportDefinition(
name = "servicesByMonth",
source = "visits",
dimensions =
listOf(
Dimension("month", column = "date", bucket = DateBucket.MONTH),
Dimension("program", column = "program"),
),
measures = listOf(Measure("count", Aggregation.COUNT)),
)ReportEngine reads the rows from the source and returns a ReportResult — a small cube of cells keyed by each
dimension combination, holding the computed measures. It is pure data, UI-free, and identical on every platform.
import io.ellykits.womblot.engine.ReportEngine
val result = ReportEngine(source).run(servicesByMonth)Wrap content in a ReportThemeProvider, pivot the result into ChartData with toChartData(...), and drop in any
visual. category picks the axis dimension, measure the value, and the optional seriesBy splits into a series
per value of another dimension (here, one bar colour per programme).
import io.ellykits.womblot.compose.model.toChartData
import io.ellykits.womblot.compose.theme.ReportPalettes
import io.ellykits.womblot.compose.theme.ReportTheme
import io.ellykits.womblot.compose.theme.ReportThemeProvider
import io.ellykits.womblot.compose.ui.Metric
import io.ellykits.womblot.compose.ui.MetricRow
import io.ellykits.womblot.compose.ui.chart.BarChart
ReportThemeProvider(theme = ReportTheme.light(ReportPalettes.Cool)) {
Column {
MetricRow(
listOf(
Metric("Households visited", "248"),
Metric("ANC visits", "86"),
),
)
BarChart(
data = result.toChartData(category = "month", measure = "count", seriesBy = "program"),
title = "Services by month",
)
}
}val csv: ByteArray = ExportRegistry.export("csv", result)
val json: ByteArray = ExportRegistry.export("json", result)Every visual takes caller-supplied ChartData and a ChartStyle, renders inside a themed card, and is drawn by
Koala Plot underneath.
| Composable | Registry type | Notes |
|---|---|---|
MetricCard, MetricRow
|
— | Headline value with a ↑/↓ delta; MetricRow fills on wide screens and swipes on narrow ones. |
BarChart |
bar, barStacked
|
Grouped or stacked vertical bars. |
HorizontalBarChart |
hbar |
Ranked horizontal bars. |
LineChart |
line, area
|
One or more series; per-series solid/dashed; optional area fill. |
DonutChart |
donut, pie
|
Slices with a centre total and legend. |
TargetBarChart |
targetBar |
Progress bars against a target marker. |
BulletChart |
bullet |
Measure vs. target with qualitative bands. |
RadarChart |
radar |
Multi-axis comparison across series. |
DataTable |
— | Columns with threshold-coloured cells and status chips. |
ReportTheme controls the palette, semantic colours, card, axis, and grid. Use ReportTheme.light(palette) /
ReportTheme.dark(palette) with a preset from ReportPalettes (Default, Cool, Warm, Pastel, Vibrant,
ColorblindSafe), or build your own. Any ChartStyle.palette overrides the theme for a single chart.
ReportThemeProvider(theme = ReportTheme.dark(ReportPalettes.ColorblindSafe)) { /* … */ }Register a new visual type — configuration and ReportVisualRegistry.Render pick it up like any built-in.
registerBuiltinVisuals()
ReportVisualRegistry.register("gauge") { data, style, title -> MyGauge(data, style, title) }ExportRegistry.register("xlsx", exporter) adds export formats the same way.
The demo shows every visual over the sample data above, with a theme switch and a palette picker.
./gradlew :demo:run # desktop
./gradlew :demo:wasmJsBrowserDevelopmentRun # browser, WasmJSCharts are rendered with Koala Plot, a Compose Multiplatform
plotting library, which womblot is named in tribute to. womblot is a thin, opinionated reporting layer on top
of it: the aggregation engine, theming, and config surface are ours; the drawing is theirs. If you need lower-level
plotting control, use Koala Plot directly.
Apache License 2.0.