diff --git a/app/src/main/java/com/medithings/vesiscan/ui/components/ChannelWaveform.kt b/app/src/main/java/com/medithings/vesiscan/ui/components/ChannelWaveform.kt new file mode 100644 index 0000000..b825253 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/components/ChannelWaveform.kt @@ -0,0 +1,174 @@ +/* + * ChannelWaveform — 6채널 raw ADC 파형 그리드. 임상 화면들이 공유한다. + */ +package com.medithings.vesiscan.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +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.Spacer +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.medithings.vesiscan.ble.PiezoChannelData +import com.medithings.vesiscan.ui.theme.MlSecondaryText + +/** 채널별 고정 색. 화면이 달라도 같은 채널은 같은 색이어야 눈이 따라간다. */ +val CHANNEL_COLORS: List = listOf( + Color(0xFF1976D2), Color(0xFF388E3C), Color(0xFFE64A19), + Color(0xFF7B1FA2), Color(0xFF00838F), Color(0xFFD81B60), +) + +/** 12-bit ADC. y 축은 채널·화면에 관계없이 이 범위로 고정한다 — 안 그러면 크기 비교가 안 된다. */ +private const val ADC_MAX = 4095f + +/** + * 6채널 파형 그리드 (2열 × 3행). + * + * [cellHeight] 로 높이를 조절한다. 화면 전체를 파형에 쓰는 실시간 화면은 크게(320dp), + * 측정 진행 화면처럼 아래쪽에 곁들이는 곳은 작게(120dp 안팎) 쓴다. + * + * [walls] 는 채널별 (전벽, 후벽) 인덱스. 검출을 돌리지 않는 화면은 비워 두면 된다. + */ +@Composable +fun SixChannelWaveformGrid( + channels: List, + modifier: Modifier = Modifier, + walls: List?> = emptyList(), + cellHeight: Dp = 320.dp, + compact: Boolean = false, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp), + ) { + for (row in 0 until 3) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp), + ) { + for (col in 0 until 2) { + val ch = row * 2 + col + Box(modifier = Modifier.weight(1f)) { + ChannelWaveform( + channelIdx = ch, + buffer = channels.find { it.channel == ch }?.buffer ?: emptyList(), + color = CHANNEL_COLORS[ch], + walls = walls.getOrNull(ch), + height = cellHeight, + compact = compact, + ) + } + } + } + } + } +} + +/** 한 채널 파형 + (있으면) 전벽·후벽 마커. */ +@Composable +fun ChannelWaveform( + channelIdx: Int, + buffer: List, + color: Color, + walls: Pair? = null, + height: Dp = 320.dp, + compact: Boolean = false, +) { + val antColor = Color(0xFF4CAF50) // 전벽 + val postColor = Color(0xFFFF9800) // 후벽 + val pad = if (compact) 4.dp else 8.dp + val label = if (compact) 11.sp else 16.sp + + Card( + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + modifier = Modifier.fillMaxWidth().height(height), + ) { + Column(modifier = Modifier.fillMaxSize().padding(pad)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(if (compact) 8.dp else 12.dp) + .clip(RoundedCornerShape(3.dp)) + .background(color), + ) + Spacer(modifier = Modifier.width(if (compact) 4.dp else 6.dp)) + Text("CH$channelIdx", fontSize = label, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.weight(1f)) + if (walls != null) { + Text("a${walls.first}", fontSize = label, color = antColor, + fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.width(if (compact) 4.dp else 6.dp)) + Text("p${walls.second}", fontSize = label, color = postColor, + fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.width(if (compact) 6.dp else 8.dp)) + } + // 피크값. 파형이 작을 때는 이것만 보고도 신호 세기를 가늠할 수 있어야 한다. + if (buffer.isNotEmpty()) { + Text("${buffer.maxOrNull()?.toInt() ?: 0}", fontSize = label, + color = MlSecondaryText, fontWeight = FontWeight.SemiBold) + } + } + Spacer(modifier = Modifier.height(if (compact) 2.dp else 4.dp)) + Canvas(modifier = Modifier.fillMaxSize()) { + if (buffer.size < 2) return@Canvas + val w = size.width + val h = size.height + val n = buffer.size + // y 는 0~4095 고정. 자동 스케일을 쓰면 채널마다 축이 달라져 서로 비교할 수 없다. + val yScale = h / ADC_MAX + val xScale = w / (n - 1).toFloat() + + for (i in 1..3) { + val y = h * i / 4f + drawLine(Color.LightGray.copy(alpha = 0.3f), + Offset(0f, y), Offset(w, y), strokeWidth = 0.5f) + } + + // 벽 마커를 파형보다 **먼저** 그린다 — 나중에 그리면 파형을 가린다. + if (walls != null) { + val (antIdx, postIdx) = walls + if (antIdx in 0 until n) { + drawLine(antColor.copy(alpha = 0.75f), + Offset(antIdx * xScale, 0f), Offset(antIdx * xScale, h), strokeWidth = 2.5f) + } + if (postIdx in 0 until n) { + drawLine(postColor.copy(alpha = 0.75f), + Offset(postIdx * xScale, 0f), Offset(postIdx * xScale, h), strokeWidth = 2.5f) + } + } + + val path = Path() + for (i in buffer.indices) { + val x = i * xScale + val y = h - buffer[i].toInt() * yScale + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath(path, color, style = Stroke(width = if (compact) 1.5f else 2.5f)) + } + } + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt index 64694e6..0b4ea4f 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt @@ -1,7 +1,6 @@ package com.medithings.vesiscan.ui.views.clinical import android.widget.Toast -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -11,20 +10,16 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Stop -import androidx.compose.material.icons.filled.Save import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppState import com.medithings.vesiscan.ble.BleManager @@ -34,11 +29,8 @@ import com.medithings.vesiscan.managers.ImuAnalysis import com.medithings.vesiscan.services.AdcCsvLogger import com.medithings.vesiscan.services.ClinicalSessionStore import com.medithings.vesiscan.services.ImuCsvLogger -import com.medithings.vesiscan.services.MeasurementLogService import com.medithings.vesiscan.ui.theme.* import kotlinx.coroutines.delay -import kotlin.math.max -import kotlin.math.min /** * Clinical Live 6채널 시각화 화면. @@ -214,10 +206,10 @@ fun ClinicalLiveView(appState: AppState) { } // ── 6ch grid (2 rows × 3 cols) ── - SixChannelGrid( + SixChannelWaveformGrid( channels = lastChannels, walls = channelWalls, - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp) + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), ) Spacer(modifier = Modifier.height(8.dp)) @@ -390,127 +382,3 @@ fun ClinicalLiveView(appState: AppState) { } /** 2×3 grid 6채널 라이브 차트 */ -@Composable -private fun SixChannelGrid( - channels: List, - walls: List?>, - modifier: Modifier = Modifier -) { - val channelColors = listOf( - Color(0xFF1976D2), Color(0xFF388E3C), Color(0xFFE64A19), - Color(0xFF7B1FA2), Color(0xFF00838F), Color(0xFFD81B60), - ) - // 회의 #1: 2열 3행 grid로 복귀 + 셀 height 더 키움 → y축 길이 확보로 기울기 보임. - // 화면 전체의 2/3 정도 차지 (3행 × 320dp ≈ 960dp). - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - for (row in 0 until 3) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - for (col in 0 until 2) { - val ch = row * 2 + col - val data = channels.find { it.channel == ch } - Box(modifier = Modifier.weight(1f)) { - ChannelChart( - channelIdx = ch, - buffer = data?.buffer ?: emptyList(), - color = channelColors[ch], - walls = walls.getOrNull(ch), - ) - } - } - } - } - } -} - -/** 한 채널 그래프 (raw ADC waveform + V4.1 ant/post 마커) */ -@Composable -private fun ChannelChart( - channelIdx: Int, - buffer: List, - color: Color, - walls: Pair?, -) { - val antColor = Color(0xFF4CAF50) // green = anterior wall - val postColor = Color(0xFFFF9800) // orange = posterior wall - // 회의 #1: 2x3 grid에서 y축 더 늘림 → 기울기 잘 보임. 한 셀 height 240→320dp. - Card( - shape = RoundedCornerShape(8.dp), - colors = CardDefaults.cardColors(containerColor = Color.White), - modifier = Modifier.fillMaxWidth().height(320.dp) - ) { - Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(12.dp) - .clip(RoundedCornerShape(3.dp)) - .background(color) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text("CH$channelIdx", fontSize = 16.sp, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.weight(1f)) - if (walls != null) { - Text("a${walls.first}", fontSize = 14.sp, color = antColor, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.width(6.dp)) - Text("p${walls.second}", fontSize = 14.sp, color = postColor, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.width(8.dp)) - } - if (buffer.isNotEmpty()) { - val peak = buffer.maxOrNull()?.toInt() ?: 0 - Text("$peak", fontSize = 14.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold) - } - } - Spacer(modifier = Modifier.height(4.dp)) - Canvas(modifier = Modifier.fillMaxSize()) { - if (buffer.isEmpty()) return@Canvas - val w = size.width - val h = size.height - val n = buffer.size - if (n < 2) return@Canvas - // y 범위: 0~4095 (12-bit ADC), 일부 여백 - val yMin = 0f - val yMax = 4095f - val yScale = h / (yMax - yMin) - val xScale = w / (n - 1).toFloat() - - // 가로 grid (4분할) - for (i in 1..3) { - val y = h * i / 4f - drawLine( - Color.LightGray.copy(alpha = 0.3f), - Offset(0f, y), Offset(w, y), - strokeWidth = 0.5f - ) - } - - // V4.1 ant/post 마커 (waveform 뒤에 그려서 가려지지 않게 alpha 조정) - if (walls != null) { - val (antIdx, postIdx) = walls - if (antIdx in 0 until n) { - val x = antIdx * xScale - drawLine(antColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 2.5f) - } - if (postIdx in 0 until n) { - val x = postIdx * xScale - drawLine(postColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 2.5f) - } - } - - // waveform — 굵게 (1.5 → 2.5) - val path = Path() - for (i in buffer.indices) { - val x = i * xScale - val y = h - (buffer[i].toInt() - yMin) * yScale - if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) - } - drawPath(path, color, style = Stroke(width = 2.5f)) - } - } - } -}