refactor(ui): 6채널 파형을 공용 컴포넌트로

`ClinicalLiveView` 안에 private 으로 있던 `SixChannelGrid`/`ChannelChart` 를
`ui/components/ChannelWaveform.kt` 로 옮겼다. 임상 화면 여러 곳에서 같은 파형을
보여줘야 하는데, 복붙하면 두 벌이 따로 흘러가 한쪽만 고치는 일이 생긴다.

  · cellHeight · compact 로 크기만 다르게 쓴다 (실시간 화면 320dp, 곁들이는 곳 140dp)
  · walls 는 선택 — 검출을 돌리지 않는 화면은 비워 두면 된다
  · y 축은 0~4095 고정. 자동 스케일이면 채널마다 축이 달라져 서로 비교할 수 없다
  · 벽 마커를 파형보다 먼저 그린다 — 나중에 그리면 파형을 가린다

옮기면서 안 쓰게 된 import 9개를 정리했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 15:48:09 +09:00
parent d97b02bbee
commit 49975b7792
2 changed files with 177 additions and 135 deletions
@@ -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<Color> = 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<PiezoChannelData>,
modifier: Modifier = Modifier,
walls: List<Pair<Int, Int>?> = 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<UShort>,
color: Color,
walls: Pair<Int, Int>? = 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))
}
}
}
}
@@ -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<PiezoChannelData>,
walls: List<Pair<Int, Int>?>,
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<UShort>,
color: Color,
walls: Pair<Int, Int>?,
) {
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))
}
}
}
}