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:
@@ -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
|
package com.medithings.vesiscan.ui.views.clinical
|
||||||
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material.icons.filled.Save
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.Color
|
||||||
import androidx.compose.ui.graphics.Path
|
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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 androidx.compose.ui.unit.sp
|
||||||
|
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
|
||||||
import com.medithings.vesiscan.AppScreen
|
import com.medithings.vesiscan.AppScreen
|
||||||
import com.medithings.vesiscan.AppState
|
import com.medithings.vesiscan.AppState
|
||||||
import com.medithings.vesiscan.ble.BleManager
|
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.AdcCsvLogger
|
||||||
import com.medithings.vesiscan.services.ClinicalSessionStore
|
import com.medithings.vesiscan.services.ClinicalSessionStore
|
||||||
import com.medithings.vesiscan.services.ImuCsvLogger
|
import com.medithings.vesiscan.services.ImuCsvLogger
|
||||||
import com.medithings.vesiscan.services.MeasurementLogService
|
|
||||||
import com.medithings.vesiscan.ui.theme.*
|
import com.medithings.vesiscan.ui.theme.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlin.math.max
|
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clinical Live 6채널 시각화 화면.
|
* Clinical Live 6채널 시각화 화면.
|
||||||
@@ -214,10 +206,10 @@ fun ClinicalLiveView(appState: AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 6ch grid (2 rows × 3 cols) ──
|
// ── 6ch grid (2 rows × 3 cols) ──
|
||||||
SixChannelGrid(
|
SixChannelWaveformGrid(
|
||||||
channels = lastChannels,
|
channels = lastChannels,
|
||||||
walls = channelWalls,
|
walls = channelWalls,
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -390,127 +382,3 @@ fun ClinicalLiveView(appState: AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 2×3 grid 6채널 라이브 차트 */
|
/** 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user