feat(app): portfolio showcase app — 7 scenes demoing all capabilities
Some checks failed
CI / build (push) Has been cancelled

Multi-scene Compose app demonstrating chart-realtime v0.5.0 features
end-to-end. Single Activity, sealed Screen enum + mutableStateOf for
nav (no nav library dep).

Scenes:
- LiveSensor — synthetic waveform sandbox with full control panel
  (relocated from DemoScreen.kt; behavior unchanged)
- Ecg — single-signal 250Hz ECG with P/QRS/T complex synthesis, dark
  theme, UnitAxisFormatter("mV"), MinMaxLttbLodStrategy
- StressTest — 8 signals @ 200Hz (1600 samples/sec total), MinMaxLod
  for peak preservation, live push-rate readout
- HistoryScrubber — preloads 300k samples in coroutine, then pauses
  production; demonstrates tiered buffer (Tier0 60s full-rate +
  Tier1/Tier2 LoD-decimated history) and interaction History mode
- CustomRenderer — ships AreaSignalRenderer + ScatterSignalRenderer
  in app/demo/renderers/ to demo SignalRenderer extensibility (~30
  LoC each, no library code change)
- Formatters — 4 stacked mini-charts, same data, 4 different
  AxisFormatter impls (Time / Decimal / DateTime / Unit("Hz"))
- ThemeGallery — 4 ChartTheme presets side-by-side (Light, Dark,
  Cyberpunk, Newspaper)

Material3 NavigationBar + TopAppBar. Unicode emoji as nav icons
(skips material-icons-extended dep). enableEdgeToEdge() with Scaffold
inset handling. Dark mode toggle in TopAppBar actions.

Files:
- app/demo/ShowcaseApp.kt — root composable, Screen enum, nav
- app/demo/{LiveSensor,Ecg,StressTest,HistoryScrubber,
  CustomRenderer,FormattersShowcase,ThemeGallery}Screen.kt
- app/demo/renderers/{AreaSignalRenderer,ScatterSignalRenderer}.kt
- app/demo/generators/EcgGenerator.kt
- app/demo/MainActivity.kt — hosts ShowcaseApp + dark-mode state
- app/demo/DemoScreen.kt — deleted (content moved to
  LiveSensorScreen.kt)

Preserved (used by LiveSensorScreen):
- ControlPanel.kt, DemoConfig.kt, SignalGenerator.kt,
  WaveformType.kt, PushCounter.kt

Build: assembleDebug green, APK 11.1 MB. No new Maven deps. No
library code changes. iosSimulatorArm64Test still 207/207 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trentin Davide 2026-05-22 11:02:19 +02:00
parent 07197ed8df
commit 512ca35d8b
12 changed files with 1163 additions and 45 deletions

View file

@ -0,0 +1,128 @@
package dev.dtrentin.chart.demo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.dtrentin.chart.RealtimeChart
import dev.dtrentin.chart.RealtimeChartState
import dev.dtrentin.chart.demo.renderers.AreaSignalRenderer
import dev.dtrentin.chart.demo.renderers.ScatterSignalRenderer
import dev.dtrentin.chart.interaction.InteractionConfig
import dev.dtrentin.chart.interaction.rememberChartInteractionState
import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy
import dev.dtrentin.chart.model.AxisConfig
import dev.dtrentin.chart.model.AxisLabelMode
import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.DataConfig
import dev.dtrentin.chart.model.RenderConfig
import dev.dtrentin.chart.model.SignalConfig
import dev.dtrentin.chart.model.YRange
import dev.dtrentin.chart.model.rememberMaterialChartTheme
import dev.dtrentin.chart.render.DecimalAxisFormatter
import dev.dtrentin.chart.render.TimeAxisFormatter
import kotlin.math.PI
import kotlin.math.sin
import kotlin.random.Random
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
/**
* Demonstrates the [dev.dtrentin.chart.render.SignalRenderer] extensibility surface.
* Two signals on the same chart:
* - "area": filled curve via [AreaSignalRenderer]
* - "scatter": dot scatter via [ScatterSignalRenderer]
*
* Both renderers are ~30 LoC each (see `demo/renderers/`).
*/
private const val CUSTOM_SAMPLE_HZ = 100
@Composable
fun CustomRendererScreen() {
val chartTheme = rememberMaterialChartTheme()
val primary = MaterialTheme.colorScheme.primary
val secondary = MaterialTheme.colorScheme.tertiary
val state = remember(chartTheme) {
RealtimeChartState(
config = ChartConfig(
data = DataConfig(
xWindowSeconds = 10f,
yRange = YRange.Auto(),
),
axis = AxisConfig(
xLabelMode = AxisLabelMode.BESIDE,
yLabelMode = AxisLabelMode.BESIDE,
yLabelDecimals = 2,
showGrid = true,
xFormatter = TimeAxisFormatter,
yFormatter = DecimalAxisFormatter(decimals = 2),
),
render = RenderConfig(
theme = chartTheme,
lodStrategy = MinMaxLttbLodStrategy(),
),
),
)
}
val interaction = rememberChartInteractionState(InteractionConfig())
LaunchedEffect(state, primary, secondary) {
state.addSignal(
"area",
SignalConfig(color = primary, strokeWidth = 2f, renderer = AreaSignalRenderer),
)
state.addSignal(
"scatter",
SignalConfig(color = secondary, strokeWidth = 2f, renderer = ScatterSignalRenderer),
)
val tickIntervalMs = (1_000L / CUSTOM_SAMPLE_HZ).coerceAtLeast(1L)
val startMs = System.currentTimeMillis()
val twoPi = 2f * PI.toFloat()
var n = 0L
while (isActive) {
val ts = startMs + (n * 1000L) / CUSTOM_SAMPLE_HZ
val t = n.toFloat() / CUSTOM_SAMPLE_HZ
val areaV = sin(twoPi * 0.3f * t)
val scatterV = (Random.nextFloat() * 2f - 1f) * 0.7f
state.push("area", ts, areaV)
state.push("scatter", ts, scatterV)
n++
delay(tickIntervalMs)
}
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp)) {
Text(
"Custom SignalRenderer impls",
style = MaterialTheme.typography.titleSmall,
)
Text(
"Same SignalRenderer interface as LineSignalRenderer. ~30 LoC each.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
RealtimeChart(
state = state,
modifier = Modifier.fillMaxSize(),
xWindowSeconds = 10f,
theme = chartTheme,
interaction = interaction,
)
}
}
}
}

View file

@ -0,0 +1,119 @@
package dev.dtrentin.chart.demo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.dtrentin.chart.RealtimeChart
import dev.dtrentin.chart.RealtimeChartState
import dev.dtrentin.chart.demo.generators.ecgSample
import dev.dtrentin.chart.interaction.InteractionConfig
import dev.dtrentin.chart.interaction.rememberChartInteractionState
import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy
import dev.dtrentin.chart.model.AxisConfig
import dev.dtrentin.chart.model.AxisLabelMode
import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.ChartTheme
import dev.dtrentin.chart.model.DataConfig
import dev.dtrentin.chart.model.RenderConfig
import dev.dtrentin.chart.model.SignalConfig
import dev.dtrentin.chart.model.YRange
import dev.dtrentin.chart.render.TimeAxisFormatter
import dev.dtrentin.chart.render.UnitAxisFormatter
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
/**
* High-fidelity synthetic ECG. Dark theme, 250 Hz, medical-green trace, mV axis,
* MinMaxLTTB LoD. Fixed Y range so the trace shape is stable. 4 s X window.
*/
private const val ECG_SAMPLE_HZ = 250
private const val ECG_HR_BPM = 72f
@Composable
fun EcgMonitorScreen() {
val theme = remember {
ChartTheme(
backgroundColor = Color(0xFF0A0A0A),
gridColor = Color(0x22FFFFFF),
axisColor = Color(0xFF888888),
labelColor = Color(0xFF888888),
strokeWidth = 1f,
)
}
val state = remember {
RealtimeChartState(
config = ChartConfig(
data = DataConfig(
xWindowSeconds = 4f,
yRange = YRange.Fixed(-0.5f, 1.2f),
),
axis = AxisConfig(
xLabelMode = AxisLabelMode.BESIDE,
yLabelMode = AxisLabelMode.BESIDE,
yLabelDecimals = 2,
showGrid = true,
xFormatter = TimeAxisFormatter,
yFormatter = UnitAxisFormatter("mV", decimals = 2),
),
render = RenderConfig(
theme = theme,
lodStrategy = MinMaxLttbLodStrategy(),
),
),
)
}
val interaction = rememberChartInteractionState(InteractionConfig())
LaunchedEffect(state) {
state.addSignal(
"ECG",
SignalConfig(color = Color(0xFF00E676), strokeWidth = 1.5f),
)
val tickIntervalMs = (1_000L / ECG_SAMPLE_HZ).coerceAtLeast(1L)
val startMs = System.currentTimeMillis()
var i = 0L
while (isActive) {
val ts = startMs + (i * 1000L) / ECG_SAMPLE_HZ
state.push("ECG", ts, ecgSample(ts, ECG_HR_BPM))
i++
delay(tickIntervalMs)
}
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text("${ECG_HR_BPM.toInt()} BPM", style = MaterialTheme.typography.titleMedium)
Text(
"$ECG_SAMPLE_HZ Hz - Real-time ECG simulation - MinMaxLTTB decimation",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
RealtimeChart(
state = state,
modifier = Modifier.fillMaxSize(),
xWindowSeconds = 4f,
theme = theme,
interaction = interaction,
)
}
}
}
}

View file

@ -0,0 +1,160 @@
package dev.dtrentin.chart.demo
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.dtrentin.chart.RealtimeChart
import dev.dtrentin.chart.RealtimeChartState
import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy
import dev.dtrentin.chart.model.AxisConfig
import dev.dtrentin.chart.model.AxisLabelMode
import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.ChartTheme
import dev.dtrentin.chart.model.DataConfig
import dev.dtrentin.chart.model.RenderConfig
import dev.dtrentin.chart.model.SignalConfig
import dev.dtrentin.chart.model.YRange
import dev.dtrentin.chart.model.rememberMaterialChartTheme
import dev.dtrentin.chart.render.AxisFormatter
import dev.dtrentin.chart.render.DateTimeAxisFormatter
import dev.dtrentin.chart.render.DecimalAxisFormatter
import dev.dtrentin.chart.render.TimeAxisFormatter
import dev.dtrentin.chart.render.UnitAxisFormatter
import kotlin.math.PI
import kotlin.math.sin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
/**
* Stack of 4 mini-charts, each driven by the same sine source but with a different
* [AxisFormatter] on the Y axis. X axis fixed to [TimeAxisFormatter] for reference.
*/
private const val FMT_SAMPLE_HZ = 50
@Composable
fun FormattersShowcaseScreen() {
val theme = rememberMaterialChartTheme()
val charts = remember(theme) {
listOf(
FormatterChart(
title = "TimeAxisFormatter (Y)",
subtitle = "Seconds with optional H:MM:SS rollover",
yFormatter = TimeAxisFormatter,
state = makeState(theme, TimeAxisFormatter),
),
FormatterChart(
title = "DecimalAxisFormatter(2) (Y)",
subtitle = "Fixed decimals, scientific for extremes",
yFormatter = DecimalAxisFormatter(decimals = 2),
state = makeState(theme, DecimalAxisFormatter(decimals = 2)),
),
FormatterChart(
title = "DateTimeAxisFormatter (Y)",
subtitle = "Treats Y as epoch seconds -> HH:mm:ss",
yFormatter = DateTimeAxisFormatter(showSeconds = true),
state = makeState(theme, DateTimeAxisFormatter(showSeconds = true)),
),
FormatterChart(
title = "UnitAxisFormatter(\"Hz\", 1) (Y)",
subtitle = "Value + unit suffix",
yFormatter = UnitAxisFormatter("Hz", decimals = 1),
state = makeState(theme, UnitAxisFormatter("Hz", decimals = 1)),
),
)
}
LaunchedEffect(charts) {
for (c in charts) {
c.state.addSignal("sig", SignalConfig(color = MaterialColors.signal, strokeWidth = 1.5f))
}
val tickIntervalMs = (1_000L / FMT_SAMPLE_HZ).coerceAtLeast(1L)
val startMs = System.currentTimeMillis()
val twoPi = 2f * PI.toFloat()
var n = 0L
while (isActive) {
val ts = startMs + (n * 1000L) / FMT_SAMPLE_HZ
val t = n.toFloat() / FMT_SAMPLE_HZ
val v = sin(twoPi * 0.3f * t)
for (c in charts) c.state.push("sig", ts, v)
n++
delay(tickIntervalMs)
}
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
) {
for ((i, c) in charts.withIndex()) {
Text(c.title, style = MaterialTheme.typography.titleSmall)
Text(
c.subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
RealtimeChart(
state = c.state,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.padding(vertical = 4.dp),
xWindowSeconds = 10f,
theme = theme,
)
if (i < charts.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
}
}
}
}
}
private data class FormatterChart(
val title: String,
val subtitle: String,
val yFormatter: AxisFormatter,
val state: RealtimeChartState,
)
private fun makeState(theme: ChartTheme, yFmt: AxisFormatter): RealtimeChartState =
RealtimeChartState(
config = ChartConfig(
data = DataConfig(
xWindowSeconds = 10f,
yRange = YRange.Auto(),
),
axis = AxisConfig(
xLabelMode = AxisLabelMode.BESIDE,
yLabelMode = AxisLabelMode.BESIDE,
yLabelDecimals = 2,
showGrid = true,
xFormatter = TimeAxisFormatter,
yFormatter = yFmt,
),
render = RenderConfig(
theme = theme,
lodStrategy = MinMaxLttbLodStrategy(),
),
),
)
private object MaterialColors {
val signal: androidx.compose.ui.graphics.Color =
androidx.compose.ui.graphics.Color(0xFF6750A4)
}

View file

@ -0,0 +1,158 @@
package dev.dtrentin.chart.demo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.dtrentin.chart.RealtimeChart
import dev.dtrentin.chart.RealtimeChartState
import dev.dtrentin.chart.interaction.InteractionConfig
import dev.dtrentin.chart.interaction.ViewportMode
import dev.dtrentin.chart.interaction.rememberChartInteractionState
import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy
import dev.dtrentin.chart.model.AxisConfig
import dev.dtrentin.chart.model.AxisLabelMode
import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.DataConfig
import dev.dtrentin.chart.model.RenderConfig
import dev.dtrentin.chart.model.SignalConfig
import dev.dtrentin.chart.model.YRange
import dev.dtrentin.chart.model.rememberMaterialChartTheme
import dev.dtrentin.chart.render.DecimalAxisFormatter
import dev.dtrentin.chart.render.TimeAxisFormatter
import kotlin.math.PI
import kotlin.math.sin
import kotlinx.coroutines.delay
/**
* History scrubber: preloads 5 minutes of synthetic data at 1 kHz, then stops production.
* User can pan back through history via drag the chart's tiered ring buffer keeps a
* decimated view of older data even past Tier0's 5-min full-rate window.
*/
private const val HISTORY_PRELOAD_SAMPLES = 300_000
private const val HISTORY_PRELOAD_HZ = 1000
@Composable
fun HistoryScrubberScreen() {
val chartTheme = rememberMaterialChartTheme()
val state = remember(chartTheme) {
RealtimeChartState(
config = ChartConfig(
data = DataConfig(
xWindowSeconds = 30f,
yRange = YRange.Auto(),
),
axis = AxisConfig(
xLabelMode = AxisLabelMode.BESIDE,
yLabelMode = AxisLabelMode.BESIDE,
yLabelDecimals = 2,
showGrid = true,
xFormatter = TimeAxisFormatter,
yFormatter = DecimalAxisFormatter(decimals = 2),
),
render = RenderConfig(
theme = chartTheme,
lodStrategy = MinMaxLttbLodStrategy(),
),
),
)
}
val interaction = rememberChartInteractionState(
InteractionConfig(maxXWindowSeconds = 3600f, minXWindowSeconds = 1f),
)
var preloadProgress by remember { mutableFloatStateOf(0f) }
var preloadDone by remember { mutableStateOf(false) }
LaunchedEffect(state) {
state.addSignal(
"history",
SignalConfig(color = Color(0xFF0061A4), strokeWidth = 1.5f),
)
// Backdate so latest sample is "now". Span the full 5 minutes ending now.
val nowMs = System.currentTimeMillis()
val durationMs = 5L * 60_000L
val startMs = nowMs - durationMs
val twoPi = 2f * PI.toFloat()
var pushed = 0
while (pushed < HISTORY_PRELOAD_SAMPLES) {
val ts = startMs + (pushed * 1000L) / HISTORY_PRELOAD_HZ
val t = pushed.toFloat() / HISTORY_PRELOAD_HZ
// Slowly drifting sinusoid: base 0.5 Hz, modulated by 0.05 Hz envelope.
val drift = 0.5f + 0.3f * sin(twoPi * 0.01f * t)
val v = sin(twoPi * drift * t)
state.push("history", ts, v)
pushed++
if (pushed % 5000 == 0) {
preloadProgress = pushed.toFloat() / HISTORY_PRELOAD_SAMPLES.toFloat()
delay(1L)
}
}
preloadProgress = 1f
preloadDone = true
}
val modeLabel = when (val m = interaction.mode) {
ViewportMode.Following -> "Following (live edge)"
ViewportMode.Frozen -> "Frozen"
is ViewportMode.History -> {
val ageSec = (-interaction.viewportOffsetMs / 1000L).coerceAtLeast(0L)
"History - ${ageSec}s ago (anchor ${m.anchorMs})"
}
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp)) {
if (!preloadDone) {
Text(
"Loading 5min @ ${HISTORY_PRELOAD_HZ}Hz - ${(preloadProgress * 100).toInt()}%",
style = MaterialTheme.typography.titleSmall,
)
LinearProgressIndicator(
progress = { preloadProgress },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
} else {
Text(
"Loaded ${HISTORY_PRELOAD_SAMPLES / 1000}k samples - pan to scrub history, pinch to zoom",
style = MaterialTheme.typography.titleSmall,
)
Text(
"Mode: $modeLabel",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center,
) {
RealtimeChart(
state = state,
modifier = Modifier.fillMaxSize(),
xWindowSeconds = 30f,
theme = chartTheme,
interaction = interaction,
)
}
}
}
}

View file

@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@ -47,39 +45,27 @@ import dev.dtrentin.chart.render.UnitAxisFormatter
import kotlinx.coroutines.delay
/**
* Top-level demo composable. Hosts the Material3 theme, the configuration panel, and
* the live chart. All settings flow through a single [DemoConfig] held in `remember`.
*
* Architecture:
* - `chartConfigKey` (a sub-data-class of [DemoConfig]) drives [ChartConfig] / state
* rebuilds. Changes to fields outside this key (e.g. sample rate, waveforms) do NOT
* rebuild state they only re-launch the producer coroutine.
* - Signal registration is managed by a `LaunchedEffect(state, signalCount)` block
* that diffs the desired set against the current one, calling `addSignal` /
* `removeSignal` as needed.
* - FPS metric: derived from `dataVersion` deltas sampled every 1s.
* Live sensor showcase scene. Hosts the legacy [ControlPanel] + [RealtimeChart] combo:
* configurable sample rate, signal count, waveform per signal, window, LoD strategy,
* Y label mode + formatter, interaction toggle. Uses the ambient Material3 theme; the
* top-level dark-mode toggle (in [ShowcaseApp]) drives palette.
*/
@Composable
fun DemoApp() {
fun LiveSensorScreen() {
var config by remember { mutableStateOf(DemoConfig.DEFAULT) }
val colorScheme = if (config.darkTheme) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = colorScheme) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
DemoScreen(
LiveSensorBody(
config = config,
onConfigChange = { config = it },
)
}
}
}
@Composable
private fun DemoScreen(
private fun LiveSensorBody(
config: DemoConfig,
onConfigChange: (DemoConfig) -> Unit,
) {
// Chart-affecting subset of config. State rebuilds when any field here changes.
val chartTheme: ChartTheme = rememberMaterialChartTheme()
val chartConfigKey = ChartConfigKey(
windowSeconds = config.windowSeconds,
@ -89,30 +75,22 @@ private fun DemoScreen(
themeKey = chartTheme,
)
// External counter — library doesn't expose dataVersion publicly. We track every
// push from the producer side, which equals total samples emitted.
val pushCounter = remember { PushCounter() }
val state = remember(chartConfigKey) {
// Reset counter on state rebuild (old samples gone).
pushCounter.reset()
RealtimeChartState(config = buildChartConfig(chartConfigKey))
}
// Mirror of registered signal names. Library API doesn't expose a names accessor,
// so we keep our own. Cleared on state rebuild via `remember(state)`.
val registeredSignals = remember(state) { mutableSetOf<String>() }
// Reconcile signal registrations whenever signal count changes (or state rebuilds).
LaunchedEffect(state, config.signalCount) {
val desired = signalNames(config.signalCount).toSet()
// Remove stale.
val toRemove = registeredSignals - desired
for (name in toRemove) {
state.removeSignal(name)
registeredSignals.remove(name)
}
// Add new (in order, with color per index).
for ((i, name) in signalNames(config.signalCount).withIndex()) {
if (name !in registeredSignals) {
state.addSignal(name, SignalConfig(color = SIGNAL_COLORS[i], strokeWidth = 2f))
@ -121,10 +99,8 @@ private fun DemoScreen(
}
}
// Producer coroutine. Restarts on any input that changes the sample stream.
val waveformsKey = config.waveforms.take(config.signalCount)
LaunchedEffect(state, config.signalCount, config.sampleRateHz, waveformsKey) {
// Clear when waveforms or count change so old samples don't visually mix.
state.clear()
pushCounter.reset()
runSignalGenerator(
@ -136,7 +112,6 @@ private fun DemoScreen(
)
}
// FPS sampler: counts push deltas per second.
var statusFps by remember { mutableStateOf(0) }
var statusTotalSamples by remember { mutableLongStateOf(0L) }
LaunchedEffect(state) {
@ -237,12 +212,12 @@ private fun buildChartConfig(key: ChartConfigKey): ChartConfig {
private fun signalNames(count: Int): List<String> = (0 until count).map { "S${it + 1}" }
/** Distinct hues for up to 6 signals. Sourced from Material3 baseline + 2 custom hues. */
/** Distinct hues for up to 6 signals. */
val SIGNAL_COLORS: List<Color> = listOf(
Color(0xFF6750A4), // primary
Color(0xFF625B71), // secondary
Color(0xFF7D5260), // tertiary
Color(0xFFB3261E), // error
Color(0xFF006A6A), // teal custom
Color(0xFFE08F00), // amber custom
Color(0xFF6750A4),
Color(0xFF625B71),
Color(0xFF7D5260),
Color(0xFFB3261E),
Color(0xFF006A6A),
Color(0xFFE08F00),
)

View file

@ -3,13 +3,29 @@ package dev.dtrentin.chart.demo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
/**
* Host activity for the chart-realtime demo. Composition entry point is [DemoApp].
* Host activity. Owns the dark-mode toggle which feeds [MaterialTheme] and is
* propagated into [ShowcaseApp]. Edge-to-edge enabled; insets handled by the
* top-level [androidx.compose.material3.Scaffold] inside [ShowcaseApp].
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { DemoApp() }
enableEdgeToEdge()
setContent {
var dark by remember { mutableStateOf(true) }
MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) {
ShowcaseApp(isDark = dark, onToggleDark = { dark = it })
}
}
}
}

View file

@ -0,0 +1,88 @@
package dev.dtrentin.chart.demo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
/**
* Top-level showcase host. Bottom-navigation-driven; one screen at a time. No nav
* library the current [Screen] is held in `mutableStateOf` and each screen owns
* its own producer coroutine via `LaunchedEffect`. Leaving a screen disposes the
* composable, cancelling its effects (no producers leak).
*
* The dark-mode toggle in the top app bar drives the MaterialTheme upstream
* (see `MainActivity`); each screen uses ambient `MaterialTheme.colorScheme`.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShowcaseApp(
isDark: Boolean,
onToggleDark: (Boolean) -> Unit,
) {
var current by remember { mutableStateOf(Screen.LiveSensor) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("chart-realtime - ${current.label}") },
actions = {
IconButton(onClick = { onToggleDark(!isDark) }) {
Text(if (isDark) "Light" else "Dark", style = MaterialTheme.typography.labelMedium)
}
},
)
},
bottomBar = {
NavigationBar {
for (s in Screen.entries) {
NavigationBarItem(
selected = current == s,
onClick = { current = s },
icon = { Text(s.glyph) },
label = { Text(s.label, style = MaterialTheme.typography.labelSmall) },
alwaysShowLabel = true,
)
}
}
},
) { padding ->
Box(modifier = Modifier.padding(padding)) {
when (current) {
Screen.LiveSensor -> LiveSensorScreen()
Screen.Ecg -> EcgMonitorScreen()
Screen.StressTest -> StressTestScreen()
Screen.History -> HistoryScrubberScreen()
Screen.CustomRenderer -> CustomRendererScreen()
Screen.Formatters -> FormattersShowcaseScreen()
Screen.Themes -> ThemeGalleryScreen()
}
}
}
}
/**
* Showcase scenes. [glyph] is a single unicode emoji used as the icon slot text in
* [NavigationBarItem] avoids the `androidx.compose.material.icons.*` dep.
*/
enum class Screen(val label: String, val glyph: String) {
LiveSensor("Sensor", "📊"), // 📊
Ecg("ECG", "❤️"), // ❤
StressTest("Stress", ""), // ⚡
History("History", "🕒"), // 🕒
CustomRenderer("Renderer", "🎨"),// 🎨
Formatters("Axes", "📐"), // 📐
Themes("Themes", "🌗"), // 🌗
}

View file

@ -0,0 +1,173 @@
package dev.dtrentin.chart.demo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.dtrentin.chart.RealtimeChart
import dev.dtrentin.chart.RealtimeChartState
import dev.dtrentin.chart.interaction.InteractionConfig
import dev.dtrentin.chart.interaction.rememberChartInteractionState
import dev.dtrentin.chart.lod.MinMaxLodStrategy
import dev.dtrentin.chart.model.AxisConfig
import dev.dtrentin.chart.model.AxisLabelMode
import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.DataConfig
import dev.dtrentin.chart.model.RenderConfig
import dev.dtrentin.chart.model.SignalConfig
import dev.dtrentin.chart.model.YRange
import dev.dtrentin.chart.model.rememberMaterialChartTheme
import dev.dtrentin.chart.render.DecimalAxisFormatter
import dev.dtrentin.chart.render.TimeAxisFormatter
import kotlin.math.PI
import kotlin.math.sin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
private const val STRESS_SIGNAL_COUNT = 8
private const val STRESS_SAMPLE_HZ = 200
private val STRESS_COLORS = listOf(
Color(0xFF6750A4),
Color(0xFF625B71),
Color(0xFF7D5260),
Color(0xFFB3261E),
Color(0xFF006A6A),
Color(0xFFE08F00),
Color(0xFF0061A4),
Color(0xFF3F8841),
)
/**
* Stress test: 8 simultaneous signals @ 200 Hz each (1600 pushes/sec total). Uses
* MinMax LoD (preserves peaks under heavy throughput). 10 s window 16k samples
* visible at once.
*/
@Composable
fun StressTestScreen() {
val chartTheme = rememberMaterialChartTheme()
val state = remember(chartTheme) {
RealtimeChartState(
config = ChartConfig(
data = DataConfig(
xWindowSeconds = 10f,
yRange = YRange.Auto(),
),
axis = AxisConfig(
xLabelMode = AxisLabelMode.INSIDE,
yLabelMode = AxisLabelMode.BESIDE,
yLabelDecimals = 2,
showGrid = true,
xFormatter = TimeAxisFormatter,
yFormatter = DecimalAxisFormatter(decimals = 2),
),
render = RenderConfig(
theme = chartTheme,
lodStrategy = MinMaxLodStrategy(),
),
),
)
}
val interaction = rememberChartInteractionState(InteractionConfig())
val pushCounter = remember { PushCounter() }
LaunchedEffect(state) {
for (i in 0 until STRESS_SIGNAL_COUNT) {
state.addSignal(
"S${i + 1}",
SignalConfig(color = STRESS_COLORS[i], strokeWidth = 1.5f),
)
}
val tickIntervalMs = (1_000L / STRESS_SAMPLE_HZ).coerceAtLeast(1L)
val startMs = System.currentTimeMillis()
var n = 0L
val twoPi = 2f * PI.toFloat()
while (isActive) {
val ts = startMs + (n * 1000L) / STRESS_SAMPLE_HZ
val t = n.toFloat() / STRESS_SAMPLE_HZ
for (i in 0 until STRESS_SIGNAL_COUNT) {
val freq = 0.3f + i * 0.2f
val phase = i * 0.4f
val amp = 1f + (i % 3) * 0.3f
val v = amp * sin(twoPi * freq * (t + phase))
state.push("S${i + 1}", ts, v)
pushCounter.increment()
}
n++
delay(tickIntervalMs)
}
}
var pushPerSec by remember { mutableIntStateOf(0) }
var totalPushes by remember { mutableLongStateOf(0L) }
LaunchedEffect(state) {
var last = 0L
while (true) {
delay(1_000L)
val now = pushCounter.get()
pushPerSec = (now - last).coerceAtLeast(0L).toInt()
last = now
totalPushes = now
}
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
StressChip("Signals", "$STRESS_SIGNAL_COUNT")
StressChip("Rate/sig", "${STRESS_SAMPLE_HZ}Hz")
StressChip("Push/s", "$pushPerSec")
StressChip("Total", formatLargeCount(totalPushes))
}
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
RealtimeChart(
state = state,
modifier = Modifier.fillMaxSize(),
xWindowSeconds = 10f,
theme = chartTheme,
interaction = interaction,
)
}
}
}
}
@Composable
private fun StressChip(key: String, value: String) {
AssistChip(
onClick = {},
enabled = false,
label = { Text("$key: $value", style = MaterialTheme.typography.labelSmall) },
colors = AssistChipDefaults.assistChipColors(
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
private fun formatLargeCount(n: Long): String = when {
n < 1_000 -> "$n"
n < 1_000_000 -> "${n / 1_000}k"
else -> "${"%.2f".format(n / 1_000_000.0)}M"
}

View file

@ -0,0 +1,171 @@
package dev.dtrentin.chart.demo
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.dtrentin.chart.RealtimeChart
import dev.dtrentin.chart.RealtimeChartState
import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy
import dev.dtrentin.chart.model.AxisConfig
import dev.dtrentin.chart.model.AxisLabelMode
import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.ChartTheme
import dev.dtrentin.chart.model.DataConfig
import dev.dtrentin.chart.model.RenderConfig
import dev.dtrentin.chart.model.SignalConfig
import dev.dtrentin.chart.model.YRange
import dev.dtrentin.chart.render.DecimalAxisFormatter
import dev.dtrentin.chart.render.TimeAxisFormatter
import kotlin.math.PI
import kotlin.math.sin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
/**
* Same sine source rendered with 4 preset [ChartTheme]s stacked vertically.
*/
private const val THEME_SAMPLE_HZ = 50
@Composable
fun ThemeGalleryScreen() {
val themes = remember {
listOf(
ThemePreset(
"Default Light",
"Soft surfaces, low-contrast grid",
ChartTheme(
backgroundColor = Color(0xFFFFFFFF),
gridColor = Color(0x11000000),
axisColor = Color(0xFF555555),
labelColor = Color(0xFF333333),
strokeWidth = 1.5f,
),
Color(0xFF6750A4),
),
ThemePreset(
"Default Dark",
"High-contrast for low-light",
ChartTheme(
backgroundColor = Color(0xFF1A1A2E),
gridColor = Color(0x66FFFFFF),
axisColor = Color(0xFFBBBBBB),
labelColor = Color(0xFFBBBBBB),
strokeWidth = 1.5f,
),
Color(0xFFFFAB91),
),
ThemePreset(
"Cyberpunk",
"Neon-on-deep-purple, ultra-thin trace",
ChartTheme(
backgroundColor = Color(0xFF0A0028),
gridColor = Color(0x44FF00FF),
axisColor = Color(0xFF00FFFF),
labelColor = Color(0xFFFF00FF),
strokeWidth = 1f,
),
Color(0xFF00FFD1),
),
ThemePreset(
"Newspaper",
"Print-style cream + heavy ink",
ChartTheme(
backgroundColor = Color(0xFFF5F1E8),
gridColor = Color(0x22000000),
axisColor = Color(0xFF222222),
labelColor = Color(0xFF444444),
strokeWidth = 1.5f,
),
Color(0xFF1A1A1A),
),
)
}
val states = remember(themes) {
themes.map { preset ->
preset to RealtimeChartState(
config = ChartConfig(
data = DataConfig(xWindowSeconds = 10f, yRange = YRange.Auto()),
axis = AxisConfig(
xLabelMode = AxisLabelMode.BESIDE,
yLabelMode = AxisLabelMode.BESIDE,
yLabelDecimals = 2,
showGrid = true,
xFormatter = TimeAxisFormatter,
yFormatter = DecimalAxisFormatter(decimals = 2),
),
render = RenderConfig(theme = preset.theme, lodStrategy = MinMaxLttbLodStrategy()),
),
)
}
}
LaunchedEffect(states) {
for ((preset, state) in states) {
state.addSignal("sig", SignalConfig(color = preset.signalColor, strokeWidth = preset.theme.strokeWidth + 0.5f))
}
val tickIntervalMs = (1_000L / THEME_SAMPLE_HZ).coerceAtLeast(1L)
val startMs = System.currentTimeMillis()
val twoPi = 2f * PI.toFloat()
var n = 0L
while (isActive) {
val ts = startMs + (n * 1000L) / THEME_SAMPLE_HZ
val t = n.toFloat() / THEME_SAMPLE_HZ
val v = sin(twoPi * 0.3f * t)
for ((_, state) in states) state.push("sig", ts, v)
n++
delay(tickIntervalMs)
}
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
) {
for ((i, pair) in states.withIndex()) {
val (preset, state) = pair
Text(preset.name, style = MaterialTheme.typography.titleSmall)
Text(
preset.description,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
RealtimeChart(
state = state,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.padding(vertical = 4.dp),
xWindowSeconds = 10f,
theme = preset.theme,
)
if (i < states.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
}
}
}
}
}
private data class ThemePreset(
val name: String,
val description: String,
val theme: ChartTheme,
val signalColor: Color,
)

View file

@ -0,0 +1,27 @@
package dev.dtrentin.chart.demo.generators
import kotlin.math.exp
import kotlin.math.pow
import kotlin.random.Random
/**
* Synthetic ECG waveform sampled at arbitrary time `tMs`. Approximates a single-lead
* ECG cycle (P wave, QRS complex, T wave) via summed gaussian bumps, with a tiny
* noise floor for realism.
*
* @param tMs absolute time in ms (modulo HR cycle length).
* @param hrBpm heart rate in beats per minute. Default 72.
* @return synthetic voltage in mV (range ~[-0.5, 1.2]).
*/
fun ecgSample(tMs: Long, hrBpm: Float = 72f): Float {
val cycleMs = (60_000f / hrBpm).toLong().coerceAtLeast(1L)
val phaseMs = ((tMs % cycleMs) + cycleMs) % cycleMs
val frac = phaseMs.toFloat() / cycleMs.toFloat()
val p = 0.15f * exp(-((frac - 0.10f) / 0.025f).pow(2f))
val q = -0.15f * exp(-((frac - 0.18f) / 0.008f).pow(2f))
val r = 1.0f * exp(-((frac - 0.20f) / 0.010f).pow(2f))
val s = -0.30f * exp(-((frac - 0.22f) / 0.012f).pow(2f))
val t = 0.30f * exp(-((frac - 0.40f) / 0.040f).pow(2f))
val noise = (Random.nextFloat() - 0.5f) * 0.02f
return p + q + r + s + t + noise
}

View file

@ -0,0 +1,59 @@
package dev.dtrentin.chart.demo.renderers
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import dev.dtrentin.chart.render.SignalRenderer
/**
* Custom [SignalRenderer]: filled area between the signal curve and the chart's
* bottom baseline, plus an outline stroke at the configured color/width.
*
* Demonstrates the [SignalRenderer] extension surface same API as the library's
* `LineSignalRenderer`, drop-in via `SignalConfig(renderer = AreaSignalRenderer)`.
*/
object AreaSignalRenderer : SignalRenderer {
override fun DrawScope.drawSignal(
color: Color,
strokeWidth: Float,
visible: Boolean,
lodX: FloatArray,
lodY: FloatArray,
count: Int,
path: Path,
chartLeft: Float,
chartRight: Float,
chartBottom: Float,
yMin: Float,
yMax: Float,
) {
if (!visible || count == 0) return
if (chartBottom <= 0f || chartRight <= chartLeft) return
val yRange = (yMax - yMin).coerceAtLeast(1e-6f)
val invY = 1f / yRange
val baseline = chartBottom
path.reset()
path.moveTo(chartLeft + lodX[0], baseline)
path.lineTo(chartLeft + lodX[0], chartBottom - ((lodY[0] - yMin) * invY) * chartBottom)
for (i in 1 until count) {
path.lineTo(chartLeft + lodX[i], chartBottom - ((lodY[i] - yMin) * invY) * chartBottom)
}
path.lineTo(chartLeft + lodX[count - 1], baseline)
path.close()
clipRect(left = chartLeft, top = 0f, right = chartRight, bottom = chartBottom) {
drawPath(path = path, color = color.copy(alpha = 0.4f))
// Outline: re-trace the curve only (no baseline) and stroke.
path.reset()
path.moveTo(chartLeft + lodX[0], chartBottom - ((lodY[0] - yMin) * invY) * chartBottom)
for (i in 1 until count) {
path.lineTo(chartLeft + lodX[i], chartBottom - ((lodY[i] - yMin) * invY) * chartBottom)
}
drawPath(path = path, color = color, style = Stroke(width = strokeWidth))
}
}
}

View file

@ -0,0 +1,44 @@
package dev.dtrentin.chart.demo.renderers
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipRect
import dev.dtrentin.chart.render.SignalRenderer
/**
* Custom [SignalRenderer]: dot scatter one filled circle per decimated sample.
*
* Radius derived from `strokeWidth + 1`, capped at 4 px. Demonstrates a non-path
* renderer; the supplied [path] arg is unused.
*/
object ScatterSignalRenderer : SignalRenderer {
override fun DrawScope.drawSignal(
color: Color,
strokeWidth: Float,
visible: Boolean,
lodX: FloatArray,
lodY: FloatArray,
count: Int,
path: Path,
chartLeft: Float,
chartRight: Float,
chartBottom: Float,
yMin: Float,
yMax: Float,
) {
if (!visible || count == 0) return
if (chartBottom <= 0f || chartRight <= chartLeft) return
val yRange = (yMax - yMin).coerceAtLeast(1e-6f)
val invY = 1f / yRange
val radius = (strokeWidth + 1f).coerceAtMost(4f)
clipRect(left = chartLeft, top = 0f, right = chartRight, bottom = chartBottom) {
for (i in 0 until count) {
val x = chartLeft + lodX[i]
val y = chartBottom - ((lodY[i] - yMin) * invY) * chartBottom
drawCircle(color = color, radius = radius, center = Offset(x, y))
}
}
}
}