feat(app): portfolio showcase app — 7 scenes demoing all capabilities
Some checks failed
CI / build (push) Has been cancelled
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:
parent
07197ed8df
commit
512ca35d8b
12 changed files with 1163 additions and 45 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
app/src/main/kotlin/dev/dtrentin/chart/demo/EcgMonitorScreen.kt
Normal file
119
app/src/main/kotlin/dev/dtrentin/chart/demo/EcgMonitorScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
88
app/src/main/kotlin/dev/dtrentin/chart/demo/ShowcaseApp.kt
Normal file
88
app/src/main/kotlin/dev/dtrentin/chart/demo/ShowcaseApp.kt
Normal 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", "🌗"), // 🌗
|
||||
}
|
||||
173
app/src/main/kotlin/dev/dtrentin/chart/demo/StressTestScreen.kt
Normal file
173
app/src/main/kotlin/dev/dtrentin/chart/demo/StressTestScreen.kt
Normal 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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue