From 512ca35d8b34807618ea0e72282d3396377ca32c Mon Sep 17 00:00:00 2001 From: Trentin Davide Date: Fri, 22 May 2026 11:02:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(app):=20portfolio=20showcase=20app=20?= =?UTF-8?q?=E2=80=94=207=20scenes=20demoing=20all=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../chart/demo/CustomRendererScreen.kt | 128 +++++++++++++ .../dtrentin/chart/demo/EcgMonitorScreen.kt | 119 ++++++++++++ .../chart/demo/FormattersShowcaseScreen.kt | 160 ++++++++++++++++ .../chart/demo/HistoryScrubberScreen.kt | 158 ++++++++++++++++ .../{DemoScreen.kt => LiveSensorScreen.kt} | 61 ++---- .../dev/dtrentin/chart/demo/MainActivity.kt | 20 +- .../dev/dtrentin/chart/demo/ShowcaseApp.kt | 88 +++++++++ .../dtrentin/chart/demo/StressTestScreen.kt | 173 ++++++++++++++++++ .../dtrentin/chart/demo/ThemeGalleryScreen.kt | 171 +++++++++++++++++ .../chart/demo/generators/EcgGenerator.kt | 27 +++ .../demo/renderers/AreaSignalRenderer.kt | 59 ++++++ .../demo/renderers/ScatterSignalRenderer.kt | 44 +++++ 12 files changed, 1163 insertions(+), 45 deletions(-) create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/CustomRendererScreen.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/EcgMonitorScreen.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/FormattersShowcaseScreen.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/HistoryScrubberScreen.kt rename app/src/main/kotlin/dev/dtrentin/chart/demo/{DemoScreen.kt => LiveSensorScreen.kt} (75%) create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/ShowcaseApp.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/StressTestScreen.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/ThemeGalleryScreen.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/generators/EcgGenerator.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/renderers/AreaSignalRenderer.kt create mode 100644 app/src/main/kotlin/dev/dtrentin/chart/demo/renderers/ScatterSignalRenderer.kt diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/CustomRendererScreen.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/CustomRendererScreen.kt new file mode 100644 index 0000000..894e7dd --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/CustomRendererScreen.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/EcgMonitorScreen.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/EcgMonitorScreen.kt new file mode 100644 index 0000000..89a2d90 --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/EcgMonitorScreen.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/FormattersShowcaseScreen.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/FormattersShowcaseScreen.kt new file mode 100644 index 0000000..fcb006c --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/FormattersShowcaseScreen.kt @@ -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) +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/HistoryScrubberScreen.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/HistoryScrubberScreen.kt new file mode 100644 index 0000000..6d92563 --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/HistoryScrubberScreen.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/DemoScreen.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/LiveSensorScreen.kt similarity index 75% rename from app/src/main/kotlin/dev/dtrentin/chart/demo/DemoScreen.kt rename to app/src/main/kotlin/dev/dtrentin/chart/demo/LiveSensorScreen.kt index b9963bf..a411694 100644 --- a/app/src/main/kotlin/dev/dtrentin/chart/demo/DemoScreen.kt +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/LiveSensorScreen.kt @@ -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( - config = config, - onConfigChange = { config = it }, - ) - } + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + 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() } - // 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 = (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 = 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), ) diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt index 753ac9e..6e2a927 100644 --- a/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/MainActivity.kt @@ -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 }) + } + } } } diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/ShowcaseApp.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/ShowcaseApp.kt new file mode 100644 index 0000000..f09f64a --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/ShowcaseApp.kt @@ -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", "🌗"), // 🌗 +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/StressTestScreen.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/StressTestScreen.kt new file mode 100644 index 0000000..2f31b20 --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/StressTestScreen.kt @@ -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" +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/ThemeGalleryScreen.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/ThemeGalleryScreen.kt new file mode 100644 index 0000000..a45507a --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/ThemeGalleryScreen.kt @@ -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, +) diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/generators/EcgGenerator.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/generators/EcgGenerator.kt new file mode 100644 index 0000000..01e86cf --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/generators/EcgGenerator.kt @@ -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 +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/renderers/AreaSignalRenderer.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/renderers/AreaSignalRenderer.kt new file mode 100644 index 0000000..21e0844 --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/renderers/AreaSignalRenderer.kt @@ -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)) + } + } +} diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/renderers/ScatterSignalRenderer.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/renderers/ScatterSignalRenderer.kt new file mode 100644 index 0000000..3436def --- /dev/null +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/renderers/ScatterSignalRenderer.kt @@ -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)) + } + } + } +}