Last active
February 23, 2025 14:23
-
-
Save kimji1/7117ac9037890a62311c7fb5e1761dd4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Composable | |
fun PointLineChart( | |
modifier: Modifier = Modifier, | |
state: PointLineChartState, | |
focusUpdateState: OuterFocusUpdateState = rememberOuterFocusUpdateState(), | |
) { | |
val textMeasurer = rememberTextMeasurer() | |
val mutablePoints: SnapshotStateList<LineChartPoint> = remember(state.points) { | |
state.points.toMutableStateList() | |
} | |
val topDataAreaHeight = state.topDataAreaHeight.toPx() | |
val chartHeight = state.topDataAreaHeight + state.contentAreaHeight | |
var focusedPoint by remember(state.points) { mutableStateOf<LineChartPoint?>(null) } | |
val isOffsetCalculated by remember(state.points) { derivedStateOf { mutablePoints.all { it.isOffsetCalculated } } } | |
fun changeFocused(item: LineChartPoint?, shouldRequestUpdate: Boolean = true) { | |
focusedPoint = if (item != null && item.focused.not()) { | |
val index = mutablePoints.indexOf(item) | |
mutablePoints.replaceAll { it.copy(focused = false) } | |
mutablePoints[index] = mutablePoints[index].copy(focused = true) | |
mutablePoints[index] | |
} else { | |
mutablePoints.replaceAll { it.copy(focused = false) } | |
null | |
} | |
if (shouldRequestUpdate) { | |
focusUpdateState.requestUpdateFocusedPoint(focusedPoint) | |
} | |
} | |
LaunchedEffect(isOffsetCalculated, focusUpdateState.shouldUpdateFocusedPoint) { | |
if (focusUpdateState.shouldUpdateFocusedPoint.not()) { | |
return@LaunchedEffect | |
} | |
val outerFocusedPoint = mutablePoints.filter { it.value != null } | |
.firstOrNull { focusUpdateState.isFocusedPoint(it) } | |
changeFocused(item = outerFocusedPoint, shouldRequestUpdate = false) | |
focusUpdateState.onUpdatedFocusedPoint() | |
} | |
val defaultColor = state.pointConfig.defaultColor | |
val pointColor = state.pointConfig.pointColor | |
var chartSize by remember { mutableStateOf<Size?>(null) } | |
val brush by remember(chartSize, state.pointConfig) { | |
val defaultBrush = mutableStateOf<Brush?>(SolidColor(defaultColor)) | |
val config = state.chartBackgroundConfig ?: return@remember defaultBrush | |
val highlightingAreaTopYPos = config.highlightingAreaYPos - topDataAreaHeight | |
val highlightingAreaBottomYPos = highlightingAreaTopYPos + config.highlightingAreaHeight | |
mutableStateOf<Brush?>( | |
chartSize?.let { | |
createShaderBrush( | |
size = it, | |
areas = listOf( | |
Area(0f, highlightingAreaTopYPos, defaultColor), | |
Area(highlightingAreaTopYPos, highlightingAreaBottomYPos, pointColor), | |
Area(highlightingAreaBottomYPos, it.height, defaultColor) | |
) | |
) | |
} | |
) | |
} | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.wrapContentSize() | |
.then(modifier) | |
) { | |
Column(modifier = Modifier.wrapContentSize()) { | |
state.title?.let { | |
ChartTitleText( | |
text = it, | |
modifier = Modifier.padding(bottom = 24.dp) | |
) | |
} | |
Box(modifier = Modifier | |
.height(chartHeight) | |
.onSizeChanged { chartSize = it.toSize() }) { | |
if (state.chartBackgroundConfig != null) { | |
ChartBackground( | |
chartHeight = chartHeight.toPx(), | |
config = state.chartBackgroundConfig | |
) | |
} | |
LineChart( | |
textMeasurer = textMeasurer, | |
pointConfig = state.pointConfig, | |
mutablePoints = mutablePoints, | |
contentAreaHeight = state.contentAreaHeight, | |
topDataAreaHeight = state.topDataAreaHeight, | |
defaultColor = defaultColor, | |
pointColor = pointColor, | |
brush = brush, | |
changeFocused = ::changeFocused, | |
) | |
Row(modifier = Modifier.matchParentSize()) { | |
repeat(state.points.size) { | |
Canvas( | |
modifier = Modifier | |
.weight(1f) | |
.fillMaxHeight() | |
) { | |
drawVerticalDash( | |
lineColor = state.dividerConfig.color, | |
xOffset = 0f, | |
yTopOffset = 0f, | |
yBottomOffset = size.height, | |
strokeWidth = state.dividerConfig.width | |
) | |
} | |
} | |
} | |
focusedPoint?.let { | |
PointDetailPopup( | |
textMeasurer = textMeasurer, | |
parentSize = chartSize, | |
point = it, | |
pointConfig = state.pointConfig, | |
changeFocused = ::changeFocused | |
) | |
} | |
} | |
HorizontalDivider( | |
thickness = 1.dp, | |
color = VCTheme.colors.secondaryLine | |
) | |
NameTag( | |
items = state.points.map { it.name to if (it.isColoredName) VCTheme.colors.error else VCTheme.colors.onSurface0 }, | |
groupingUnit = state.groupingUnit, | |
dividerConfig = state.dividerConfig | |
) | |
if (state.footNoteData != null) { | |
FootNote(footNoteData = state.footNoteData) | |
} | |
} | |
} | |
} | |
@NonRestartableComposable | |
@Composable | |
fun ChartTitleText( | |
text: String, | |
modifier: Modifier = Modifier | |
) = Text( | |
text = text, | |
style = VCTheme.typo.bodyB, | |
color = VCTheme.colors.onSurface0, | |
modifier = modifier | |
) | |
@Composable | |
fun PointDetailPopup( | |
textMeasurer: TextMeasurer, | |
parentSize: Size?, | |
point: LineChartPoint, | |
pointConfig: PointConfig, | |
changeFocused: (LineChartPoint) -> Unit | |
) { | |
parentSize ?: return | |
val strokeWidth = 1.dp.toPx() | |
val color = VCTheme.colors.onSurface0 | |
val valueTextStyle = VCTheme.typo.bodyB | |
val nameTextStyle = VCTheme.typo.caption1B | |
val valueWidth = textMeasurer.measure(point.valueDetailDisplay, valueTextStyle).size.width | |
val secondaryValueWidth = | |
point.secondaryDetailDisplay?.let { textMeasurer.measure(it, valueTextStyle).size.width } | |
?: 0 | |
val nameWidth = textMeasurer.measure(point.detailName, nameTextStyle).size.width | |
val halfWidth = maxOf(valueWidth, secondaryValueWidth, nameWidth) / 2 | |
val circleRadius = pointConfig.focusedPointSize.toPx() / 2 | |
val (xOffset, textAlign) = (point.offset.x - halfWidth - circleRadius).let { xOffset -> | |
if (xOffset < 0f) { | |
0f to Alignment.Start | |
} else if (xOffset + (halfWidth * 2) > parentSize.width) { | |
(parentSize.width - halfWidth * 2f) to Alignment.End | |
} else xOffset to Alignment.CenterHorizontally | |
} | |
Canvas( | |
modifier = Modifier | |
.width(pointConfig.focusedPointSize) | |
.fillMaxHeight() | |
.offset(x = point.offset.x.toDp()) | |
.noRippleClickable { changeFocused(point) }, | |
) { | |
drawLine( | |
color = color, | |
start = Offset.Zero, | |
end = Offset(x = 0f, y = size.height), | |
strokeWidth = strokeWidth | |
) | |
drawCircle( | |
color = color, | |
radius = pointConfig.focusedPointSize.toPx() / 2, | |
center = point.offset.copy(x = 0f) | |
) | |
drawCircle( | |
color = pointConfig.backgroundColor, | |
radius = pointConfig.strokeWidth.toPx(), | |
center = point.offset.copy(x = 0f) | |
) | |
} | |
Column( | |
modifier = Modifier | |
.wrapContentSize() | |
.padding(horizontal = 4.dp) | |
.offset(x = xOffset.toDp()) | |
.background(color = pointConfig.backgroundColor) | |
.noRippleClickable { changeFocused(point) }, | |
horizontalAlignment = textAlign | |
) { | |
Text( | |
text = point.valueDetailDisplay, | |
style = valueTextStyle.copy(color = point.focusedColor), | |
) | |
point.secondaryDetailDisplay?.let { | |
Text( | |
text = it, | |
style = valueTextStyle.copy(color = color) | |
) | |
} | |
Text( | |
text = point.detailName, | |
style = nameTextStyle.copy(color = color) | |
) | |
} | |
} | |
@Composable | |
private fun BoxScope.LineChart( | |
textMeasurer: TextMeasurer, | |
mutablePoints: MutableList<LineChartPoint>, | |
pointConfig: PointConfig, | |
contentAreaHeight: Dp, | |
topDataAreaHeight: Dp, | |
defaultColor: Color, | |
pointColor: Color, | |
brush: Brush?, | |
changeFocused: (LineChartPoint) -> Unit, | |
) { | |
val baseLine = (pointConfig.valueRange.max - pointConfig.valueRange.min) | |
val unit = contentAreaHeight.toPx() / baseLine | |
val dotRadius = pointConfig.dotSize.toPx() / 2 | |
fun calculateYOffset(value: Float): Float { | |
return ((pointConfig.valueRange.max - value) * unit) | |
} | |
ChartLine( | |
contentAreaHeight = contentAreaHeight, | |
mutablePoints = mutablePoints, | |
calculateYOffset = ::calculateYOffset, | |
pointConfig = pointConfig, | |
brush = brush | |
) | |
ChartPoint( | |
topDataAreaHeight = topDataAreaHeight, | |
contentAreaHeight = contentAreaHeight, | |
mutablePoints = mutablePoints, | |
dotRadius = dotRadius, | |
pointConfig = pointConfig, | |
pointColor = pointColor, | |
defaultColor = defaultColor, | |
calculateYOffset = ::calculateYOffset, | |
changeFocused = changeFocused, | |
) | |
ChartPointValue( | |
textMeasurer = textMeasurer, | |
contentAreaHeight = contentAreaHeight, | |
topDataAreaHeight = topDataAreaHeight, | |
mutablePoints = mutablePoints, | |
pointConfig = pointConfig, | |
pointColor = pointColor, | |
defaultColor = defaultColor, | |
calculateYOffset = ::calculateYOffset | |
) | |
} | |
@Composable | |
fun BoxScope.ChartLine( | |
contentAreaHeight: Dp, | |
mutablePoints: MutableList<LineChartPoint>, | |
calculateYOffset: (Float) -> Float, | |
pointConfig: PointConfig, | |
brush: Brush? | |
) { | |
val pathValues by remember(mutablePoints) { | |
mutableStateOf(mutablePoints | |
.mapIndexed { index, lineChartPoint -> | |
lineChartPoint.value?.let { | |
val value = it.roundRange(pointConfig.valueRange) | |
index to value | |
} | |
} | |
.filterNotNull() | |
) | |
} | |
val bottom = contentAreaHeight.toPx() - pointConfig.dotSize.value * 1.5f | |
Canvas( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(contentAreaHeight) | |
.align(Alignment.BottomCenter) | |
) { | |
val width = size.width / mutablePoints.size | |
fun calculateXOffset(index: Int) = (width * index) + width / 2 | |
val path = Path() | |
pathValues.forEachIndexed { index, value -> | |
val point = Offset( | |
x = calculateXOffset(index + (value.first - index)), | |
y = min(bottom, calculateYOffset(value.second)) | |
) | |
if (index == 0) { | |
path.moveTo(point.x, point.y) | |
} | |
val nextPoint = if (index == pathValues.lastIndex) null else { | |
val nextIndex = index + 1 | |
val nextValue = pathValues.subList(nextIndex, pathValues.size) | |
.firstOrNull() ?: return@forEachIndexed | |
Offset( | |
x = calculateXOffset(nextIndex + nextValue.first - nextIndex), | |
y = min(bottom, calculateYOffset(nextValue.second)) | |
) | |
} ?: return@forEachIndexed | |
path.cubicTo( | |
point.x, | |
point.y, | |
nextPoint.x, | |
nextPoint.y, | |
nextPoint.x, | |
nextPoint.y | |
) | |
} | |
if (brush != null) { | |
drawPath( | |
path = path, | |
brush = brush, | |
style = Stroke(width = pointConfig.strokeWidth.toPx()) | |
) | |
} | |
} | |
} | |
@Composable | |
fun BoxScope.ChartPoint( | |
topDataAreaHeight: Dp, | |
contentAreaHeight: Dp, | |
mutablePoints: MutableList<LineChartPoint>, | |
dotRadius: Float, | |
pointConfig: PointConfig, | |
pointColor: Color, | |
defaultColor: Color, | |
calculateYOffset: (Float) -> Float, | |
changeFocused: (LineChartPoint) -> Unit, | |
) { | |
val bottom = calculateYOffset(pointConfig.valueRange.min) - dotRadius | |
fun updatePointOffset(index: Int, offset: Offset, color: Color) { | |
mutablePoints[index] = (mutablePoints[index].copy( | |
offset = offset, | |
focusedColor = color, | |
isOffsetCalculated = true | |
)) | |
} | |
Row( | |
modifier = Modifier | |
.height(contentAreaHeight) | |
.align(Alignment.BottomCenter) | |
) { | |
mutablePoints.forEachIndexed { index, item -> | |
if (item.value == null) { | |
Spacer(modifier = Modifier.weight(1f)) | |
return@forEachIndexed | |
} | |
var positionInParent by remember { mutableStateOf(Offset.Zero) } | |
var halfWidth by remember { mutableFloatStateOf(0f) } | |
val hasGuideline = pointConfig.guideline != null | |
val shouldPointing = hasGuideline && pointConfig.guideline?.let { | |
pointConfig.pointComparisonRule.check(item.value, it.max) && | |
ComparisonRule.More.check(item.value, it.min) | |
} ?: false | |
val itemColor = if (shouldPointing) pointColor else defaultColor | |
val value = item.value.roundRange(pointConfig.valueRange) | |
val yPos = min(bottom, calculateYOffset(value)) | |
LaunchedEffect(positionInParent, halfWidth) { | |
if (positionInParent == Offset.Zero && halfWidth == 0f) { | |
return@LaunchedEffect | |
} | |
val itemOffset = Offset(x = halfWidth, y = yPos + topDataAreaHeight.toPx()) | |
updatePointOffset( | |
index = index, | |
offset = itemOffset + positionInParent, | |
color = itemColor | |
) | |
} | |
Box( | |
modifier = Modifier | |
.weight(1f) | |
.fillMaxHeight() | |
.onGloballyPositioned { | |
positionInParent = Offset(x = it.positionInParent().x, y = 0f) | |
} | |
) { | |
Canvas(modifier = Modifier | |
.matchParentSize() | |
.conditional(item.valueDetailDisplay.isNotEmpty()) { | |
noRippleClickable { | |
changeFocused(mutablePoints[index]) | |
} | |
} | |
) { | |
halfWidth = size.width / 2 | |
drawCircle( | |
color = itemColor, | |
radius = dotRadius, | |
center = Offset(halfWidth, yPos) | |
) | |
drawCircle( | |
color = pointConfig.backgroundColor, | |
radius = pointConfig.strokeWidth.toPx(), | |
center = Offset(halfWidth, yPos) | |
) | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun BoxScope.ChartPointValue( | |
textMeasurer: TextMeasurer, | |
contentAreaHeight: Dp, | |
topDataAreaHeight: Dp, | |
mutablePoints: MutableList<LineChartPoint>, | |
pointConfig: PointConfig, | |
pointColor: Color, | |
defaultColor: Color, | |
calculateYOffset: (Float) -> Float | |
) { | |
val dotRadius = pointConfig.dotSize.value / 2 | |
val bottom = calculateYOffset(0f) - dotRadius | |
Row( | |
modifier = Modifier | |
.height(contentAreaHeight) | |
.align(Alignment.BottomCenter) | |
) { | |
mutablePoints.forEach { item -> | |
if (item.value == null) { | |
Spacer(modifier = Modifier.weight(1f)) | |
return@forEach | |
} | |
val yPos = min(bottom, calculateYOffset(item.value.roundRange(pointConfig.valueRange))) | |
val hasGuideline = pointConfig.guideline != null | |
val shouldPointing = hasGuideline && pointConfig.guideline?.let { | |
pointConfig.pointComparisonRule.check(item.value, it.max) && | |
ComparisonRule.More.check(item.value, it.min) | |
} ?: false | |
val itemColor = if (shouldPointing) pointColor else defaultColor | |
Box( | |
modifier = Modifier | |
.weight(1f) | |
.fillMaxHeight() | |
.wrapContentWidth(unbounded = true) | |
) { | |
if (pointConfig.hideValue.not() && item.focused.not() && item.valueDisplay.isNotBlank()) { | |
OutlineText( | |
textMeasurer = textMeasurer, | |
text = item.valueDisplay, | |
textColor = itemColor, | |
textStyle = VCTheme.typo.caption1B, | |
topDataAreaHeight = topDataAreaHeight, | |
yPos = yPos, | |
pointConfig = pointConfig | |
) | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
private fun BoxScope.OutlineText( | |
textMeasurer: TextMeasurer, | |
text: String, | |
textColor: Color, | |
textStyle: TextStyle, | |
topDataAreaHeight: Dp, | |
yPos: Float, | |
pointConfig: PointConfig | |
) { | |
val textMeasuredSize = textMeasurer.measure( | |
text = text, | |
style = textStyle | |
).size | |
val yOffset = ((yPos - textMeasuredSize.height - 12.dp.toPx()).toDp()) | |
.let { yOffset -> if (yOffset < -topDataAreaHeight) -topDataAreaHeight else yOffset } | |
Text( | |
modifier = Modifier | |
.offset(x = 0.dp, y = yOffset) | |
.align(Alignment.TopCenter), | |
text = text, | |
style = textStyle.copy( | |
color = pointConfig.backgroundColor, | |
drawStyle = Stroke( | |
width = 4.dp.toPx(), | |
join = StrokeJoin.Round | |
) | |
) | |
) | |
Text( | |
modifier = Modifier | |
.offset(x = 0.dp, y = yOffset) | |
.align(Alignment.TopCenter), | |
text = text, | |
style = textStyle.copy(color = textColor) | |
) | |
} | |
private fun Float.roundRange(range: Range) = if (this < range.min) { | |
range.min | |
} else if (this > range.max) { | |
range.max | |
} else this |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
data class PointLineChartState( | |
val title: String?, | |
val points: List<LineChartPoint>, | |
val pointConfig: PointConfig, | |
val topDataAreaHeight: Dp, | |
val contentAreaHeight: Dp, | |
val groupingUnit: Int, | |
val dividerConfig: DividerConfig, | |
val chartBackgroundConfig: ChartBackgroundConfig?, | |
val footNoteData: FootNoteData? | |
) | |
@Composable | |
fun rememberLineChartState( | |
points: List<LineChartPoint>, | |
groupingUnit: Int, | |
pointConfig: PointConfig, | |
useBackgroundHighlighting: Boolean = true, | |
title: String? = null, | |
topDataAreaHeight: Dp = 28.dp, | |
contentAreaHeight: Dp = 200.dp, | |
dividerConfig: DividerConfig = DividerConfig( | |
color = VCTheme.colors.secondaryLine, | |
width = 1.dp.toPx() | |
), | |
footNoteData: FootNoteData? = null, | |
): PointLineChartState { | |
fun calculateBackgroundConfig(): Pair<Float, Float> { | |
val guidelineRangeGap = if (pointConfig.guideline != null) { | |
pointConfig.guideline.max - pointConfig.guideline.min | |
} else 0f | |
val valueRangeGap = (pointConfig.valueRange.max - pointConfig.valueRange.min) | |
val highlightingAreaRatio = guidelineRangeGap / valueRangeGap | |
val highlightingAreaHeight = contentAreaHeight.toPx() * highlightingAreaRatio | |
val unit = contentAreaHeight.toPx() / valueRangeGap | |
val highlightingAreaYPos = if (pointConfig.guideline != null) { | |
topDataAreaHeight.toPx() + (unit * (pointConfig.valueRange.max - pointConfig.guideline.max)) | |
} else 0f | |
return highlightingAreaHeight to highlightingAreaYPos | |
} | |
val chartBackgroundConfig = if (useBackgroundHighlighting) { | |
val (highlightingAreaHeight, highlightingAreaYPos) = calculateBackgroundConfig() | |
createChartBackgroundConfig( | |
highlightingAreaHeight = highlightingAreaHeight, | |
highlightingAreaYPos = highlightingAreaYPos, | |
) | |
} else null | |
return remember(points) { | |
PointLineChartState( | |
title = title, | |
points = points, | |
pointConfig = pointConfig, | |
groupingUnit = groupingUnit, | |
topDataAreaHeight = topDataAreaHeight, | |
contentAreaHeight = contentAreaHeight, | |
dividerConfig = dividerConfig, | |
chartBackgroundConfig = chartBackgroundConfig, | |
footNoteData = footNoteData | |
) | |
} | |
} | |
enum class ComparisonRule { | |
Below, // 이하 | |
Under, // 미만 | |
More, // 초과 | |
Over; // 초과 | |
fun check(target: Float, base: Float) = when (this) { | |
Below -> target <= base | |
Under -> target < base | |
More -> target >= base | |
Over -> target > base | |
} | |
} | |
data class LineChartPoint( | |
val value: Float?, | |
val valueDisplay: String, | |
val valueDetailDisplay: String = "", | |
val secondaryDetailDisplay: String? = null, | |
val name: String, | |
val detailName: String, | |
val isColoredName: Boolean = false, | |
val focused: Boolean = false, | |
val isOffsetCalculated: Boolean = false, | |
val offset: Offset = Offset.Zero, | |
val focusedColor: Color = Color.Black | |
) | |
data class PointConfig( | |
val guideline: Range?, | |
val valueRange: Range, | |
val dotSize: Dp, | |
val focusedPointSize: Dp, | |
val pointComparisonRule: ComparisonRule, | |
val strokeWidth: Dp, | |
val defaultColor: Color, | |
val pointColor: Color, | |
val backgroundColor: Color, | |
val hideValue: Boolean | |
) | |
data class Range(val min: Float, val max: Float) | |
@Composable | |
fun createPointConfig( | |
valueRange: Range, | |
guideline: Range? = null, | |
hideValue: Boolean = false, | |
pointComparisonRule: ComparisonRule = ComparisonRule.Below, | |
pointSize: Dp = 8.dp, | |
focusedPointSize: Dp = 10.dp, | |
strokeWidth: Dp = 2.dp, | |
defaultColor: Color = VCTheme.colors.error, | |
pointColor: Color = VCTheme.colors.graphSafety40, | |
backgroundColor: Color = VCTheme.colors.background, | |
) = PointConfig( | |
guideline = guideline, | |
valueRange = valueRange, | |
hideValue = hideValue, | |
dotSize = pointSize, | |
focusedPointSize = focusedPointSize, | |
pointComparisonRule = pointComparisonRule, | |
strokeWidth = strokeWidth, | |
defaultColor = defaultColor, | |
pointColor = pointColor, | |
backgroundColor = backgroundColor | |
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
data class ChartBackgroundConfig( | |
val showBaseLine: Boolean, | |
val baseLineColor: Color, | |
val baseLineWidth: Dp, | |
val highlightingAreaYPos: Float, | |
val highlightingAreaHeight: Float, | |
val highlightingAreaColor: Color, | |
val highlightingLineColor: Color, | |
val highlightingLineWidth: Dp, | |
val highlightingLineAlignment: Alignment.Vertical | |
) | |
@Composable | |
fun createChartBackgroundConfig( | |
highlightingAreaHeight: Float, | |
showBaseLine: Boolean = false, | |
baseLineColor: Color = VCTheme.colors.graphLow, | |
baseLineWidth: Dp = 1.dp, | |
highlightingAreaYPos: Float = 0f, | |
highlightingAreaColor: Color = VCTheme.colors.graphSafety99, | |
highlightingLineColor: Color = VCTheme.colors.graphSafety10, | |
highlightingLineWidth: Dp = 1.dp, | |
highlightingLineAlignment: Alignment.Vertical = Alignment.Top | |
) = ChartBackgroundConfig( | |
showBaseLine = showBaseLine, | |
baseLineColor = baseLineColor, | |
baseLineWidth = baseLineWidth, | |
highlightingAreaYPos = highlightingAreaYPos, | |
highlightingAreaHeight = highlightingAreaHeight, | |
highlightingAreaColor = highlightingAreaColor, | |
highlightingLineColor = highlightingLineColor, | |
highlightingLineWidth = highlightingLineWidth, | |
highlightingLineAlignment = highlightingLineAlignment | |
) | |
@Composable | |
fun BoxScope.ChartBackground( | |
chartHeight: Float, | |
baselineYOffset: Float = 0f, | |
config: ChartBackgroundConfig, | |
chart: @Composable () -> Unit = {} | |
) { | |
if (config.highlightingAreaHeight != 0f) { | |
Canvas( | |
modifier = Modifier | |
.matchParentSize() | |
.offset(y = config.highlightingAreaYPos.toDp()) | |
) { | |
drawRect( | |
color = config.highlightingAreaColor, | |
size = size.copy(height = config.highlightingAreaHeight) | |
) | |
} | |
} | |
chart() | |
if (config.highlightingAreaHeight != 0f) { | |
val highlightingLineYPos = | |
config.highlightingAreaYPos + if (config.highlightingLineAlignment == Alignment.Bottom) config.highlightingAreaHeight else 0f | |
Canvas( | |
modifier = Modifier | |
.matchParentSize() | |
.offset(y = highlightingLineYPos.toDp()) | |
) { | |
drawHorizontalDash( | |
lineColor = config.highlightingLineColor, | |
xStartOffset = 0f, | |
xEndOffset = size.width, | |
yOffset = 0f, | |
strokeWidth = config.highlightingLineWidth.toPx(), | |
) | |
} | |
} | |
if (config.showBaseLine) { | |
Canvas(modifier = Modifier.matchParentSize()) { | |
drawHorizontalDash( | |
lineColor = config.baseLineColor, | |
xStartOffset = 0f, | |
xEndOffset = size.width, | |
yOffset = chartHeight + baselineYOffset, | |
strokeWidth = config.baseLineWidth.toPx() | |
) | |
} | |
} | |
} | |
data class DividerConfig( | |
val color: Color, | |
val width: Float | |
) | |
@Composable | |
fun NameTag( | |
items: List<Pair<String, Color>>, | |
groupingUnit: Int, | |
dividerConfig: DividerConfig | |
) { | |
val shouldGrouping = groupingUnit != 1 | |
val step = if (shouldGrouping) items.size / groupingUnit else groupingUnit | |
val groupingIndices = (0..items.size step step) | |
Row( | |
modifier = Modifier.fillMaxWidth(), | |
verticalAlignment = Alignment.Bottom | |
) { | |
items.forEachIndexed { index, item -> | |
if (groupingIndices.contains(index)) { | |
Text( | |
modifier = Modifier | |
.weight(1f) | |
.drawBehind { | |
drawVerticalDash( | |
lineColor = dividerConfig.color, | |
xOffset = 0f, | |
yTopOffset = 0f, | |
yBottomOffset = size.height, | |
strokeWidth = dividerConfig.width | |
) | |
} | |
.padding(top = 12.dp) | |
.wrapContentHeight(Alignment.Bottom) | |
.padding(horizontal = 4.dp), | |
text = item.first, | |
style = VCTheme.typo.caption1.copy(item.second), | |
textAlign = if (shouldGrouping) TextAlign.Start else TextAlign.Center, | |
maxLines = 2 | |
) | |
} | |
} | |
} | |
} | |
data class FootNoteData( | |
val name: String, | |
val color: Color | |
) | |
@Composable | |
fun createFootNoteData( | |
name: String, | |
color: Color = VCTheme.colors.graphSafety10 | |
) = FootNoteData(name = name, color = color) | |
@Composable | |
fun FootNote(footNoteData: FootNoteData) { | |
Spacer(modifier = Modifier.height(16.dp)) | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.wrapContentHeight(), | |
horizontalArrangement = Arrangement.Center, | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
DashedHorizontalDivider( | |
modifier = Modifier | |
.width(16.dp) | |
.height(1.dp), | |
color = footNoteData.color | |
) | |
Spacer(modifier = Modifier.width(4.dp)) | |
Text( | |
text = footNoteData.name, | |
style = VCTheme.typo.caption1.copy(footNoteData.color) | |
) | |
} | |
} | |
fun DrawScope.drawHorizontalDash( | |
lineColor: Color, | |
xStartOffset: Float, | |
xEndOffset: Float, | |
yOffset: Float, | |
strokeWidth: Float | |
) { | |
drawLine( | |
color = lineColor, | |
start = Offset(xStartOffset, yOffset), | |
end = Offset(xEndOffset, yOffset), | |
strokeWidth = strokeWidth, | |
pathEffect = PathEffect.dashPathEffect(floatArrayOf(2.dp.toPx(), 2.dp.toPx(), 0f)) | |
) | |
} | |
fun DrawScope.drawVerticalDash( | |
lineColor: Color, | |
xOffset: Float, | |
yTopOffset: Float, | |
yBottomOffset: Float, | |
strokeWidth: Float | |
) { | |
drawLine( | |
color = lineColor, | |
start = Offset(xOffset, yTopOffset), | |
end = Offset(xOffset, yBottomOffset), | |
strokeWidth = strokeWidth, | |
pathEffect = PathEffect.dashPathEffect(floatArrayOf(2.dp.toPx(), 2.dp.toPx(), 0f)) | |
) | |
} | |
data class Area(val min: Float, val max: Float, val color: Color) | |
fun createShaderBrush( | |
size: Size, | |
areas: List<Area>, | |
): ShaderBrush { | |
val bitmapCanvas = BitmapCanvas(width = size.width.toInt(), height = size.height.toInt()) | |
val canvas = android.graphics.Canvas(bitmapCanvas.bitmap) | |
areas.forEach { area -> | |
val paint = Paint().apply { | |
color = area.color.toArgb() | |
} | |
canvas.drawRect(RectF(0f, area.max, size.width, area.min), paint) | |
} | |
return ShaderBrush(ImageShader(bitmapCanvas.imageBitmap)) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Preview | |
@Composable | |
private fun LineChart_Preview_CV() { | |
VirtualCareTheme(darkTheme = true) { | |
val stringFormat = "%s%%" | |
PointLineChart( | |
state = rememberLineChartState( | |
points = listOf( | |
LineChartPoint( | |
value = 10.7f, | |
valueDisplay = (10.7f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(10.7f.toIntegerableString()), | |
name = "수", | |
detailName = "10.13" | |
), | |
LineChartPoint( | |
value = 5.8f, | |
valueDisplay = (5.8f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(5.8f.toIntegerableString()), | |
name = "목", | |
detailName = "10.14" | |
), | |
LineChartPoint( | |
value = 120f, | |
valueDisplay = "높음", | |
valueDetailDisplay = stringFormat.format(120f.toIntegerableString()), | |
name = "금", | |
detailName = "10.15" | |
), | |
LineChartPoint( | |
value = 33f, | |
valueDisplay = (33f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(33f.toIntegerableString()), | |
name = "토", | |
detailName = "10.16" | |
), | |
LineChartPoint( | |
value = -10f, | |
valueDisplay = "낮음", | |
valueDetailDisplay = stringFormat.format((-10f).toIntegerableString()), | |
name = "일", | |
detailName = "10.17" | |
), | |
LineChartPoint( | |
value = 33.1f, | |
valueDisplay = (33.1f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(33.1f.toIntegerableString()), | |
name = "월", | |
detailName = "10.18" | |
), | |
LineChartPoint( | |
value = 36f, | |
valueDisplay = (36f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(36f.toIntegerableString()), | |
name = "화", | |
detailName = "화" | |
), | |
), | |
pointConfig = createPointConfig( | |
guideline = Range(min = 0f, max = 36f), | |
valueRange = Range(min = 0f, max = 100f), | |
pointComparisonRule = ComparisonRule.Under | |
), | |
groupingUnit = 1, | |
) | |
) | |
} | |
} | |
@Preview | |
@Composable | |
private fun PointLineChart_Preview_CV2() { | |
VirtualCareTheme(darkTheme = true) { | |
val stringFormat = "%3.1f%%" | |
PointLineChart( | |
state = rememberPointLineChartState( | |
points = listOf( | |
LineChartPoint( | |
value = null, | |
valueDisplay = "", | |
valueDetailDisplay = "", | |
name = "18", | |
detailName = "10.18" | |
), | |
LineChartPoint( | |
value = null, | |
valueDisplay = "", | |
valueDetailDisplay = "", | |
name = "19", | |
detailName = "10.19" | |
), | |
LineChartPoint( | |
value = null, | |
valueDisplay = "", | |
valueDetailDisplay = "", | |
name = "20", | |
detailName = "10.20" | |
), | |
LineChartPoint( | |
value = 27f, | |
valueDisplay = (27f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(27f), | |
name = "21", | |
detailName = "10.21" | |
), | |
LineChartPoint( | |
value = 34f, | |
valueDisplay = (34f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(34f), | |
name = "22", | |
detailName = "10.22" | |
), | |
LineChartPoint( | |
value = 30f, | |
valueDisplay = (30f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(30f), | |
name = "23", | |
detailName = "10.23" | |
), | |
LineChartPoint( | |
value = 15f, | |
valueDisplay = (15f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(15f), | |
name = "24", | |
detailName = "10.24" | |
), | |
LineChartPoint( | |
value = 34f, | |
valueDisplay = (34f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(34f), | |
name = "25", | |
detailName = "10.25" | |
), | |
LineChartPoint( | |
value = 37f, | |
valueDisplay = (37f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(37f), | |
name = "26", | |
detailName = "10.26" | |
), | |
LineChartPoint( | |
value = null, | |
valueDisplay = "", | |
valueDetailDisplay = "", | |
name = "27", | |
detailName = "10.27" | |
), | |
LineChartPoint( | |
value = null, | |
valueDisplay = "", | |
valueDetailDisplay = "", | |
name = "28", | |
detailName = "10.28" | |
), | |
LineChartPoint( | |
value = null, | |
valueDisplay = "", | |
valueDetailDisplay = "", | |
name = "29", | |
detailName = "10.29" | |
), | |
LineChartPoint( | |
value = 30f, | |
valueDisplay = (30f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(30f), | |
name = "30", | |
detailName = "10.30" | |
), | |
LineChartPoint( | |
value = 13f, | |
valueDisplay = (13f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(13f), | |
name = "31", | |
detailName = "10.31" | |
), | |
LineChartPoint( | |
value = 37f, | |
valueDisplay = (37f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(37f), | |
name = "1", | |
detailName = "11.1" | |
), | |
LineChartPoint( | |
value = 39f, | |
valueDisplay = (39f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(39f), | |
name = "2", | |
detailName = "11.1" | |
), | |
LineChartPoint( | |
value = 37f, | |
valueDisplay = (37f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(37f), | |
name = "3", | |
detailName = "11.3" | |
), | |
LineChartPoint( | |
value = 24f, | |
valueDisplay = (24f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(24f), | |
name = "4", | |
detailName = "11.4" | |
), | |
LineChartPoint( | |
value = 30f, | |
valueDisplay = (30f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(30f), | |
name = "5", | |
detailName = "11.5" | |
), | |
LineChartPoint( | |
value = 48f, | |
valueDisplay = (48f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(48f), | |
name = "6", | |
detailName = "11.6" | |
), | |
LineChartPoint( | |
value = 8f, | |
valueDisplay = (8f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(8f), | |
name = "7", | |
detailName = "11.7" | |
), | |
LineChartPoint( | |
value = 34f, | |
valueDisplay = (34f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(34f), | |
name = "8", | |
detailName = "11.8" | |
), | |
LineChartPoint( | |
value = 37f, | |
valueDisplay = (37f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(37f), | |
name = "9", | |
detailName = "11.9" | |
), | |
LineChartPoint( | |
value = 34f, | |
valueDisplay = (34f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(34f), | |
name = "10", | |
detailName = "11.10" | |
), | |
LineChartPoint( | |
value = 39f, | |
valueDisplay = (39f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(39f), | |
name = "11", | |
detailName = "11.11" | |
), | |
LineChartPoint( | |
value = 52f, | |
valueDisplay = (52f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(52f), | |
name = "12", | |
detailName = "11.12" | |
), | |
LineChartPoint( | |
value = 23f, | |
valueDisplay = (23f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(23f), | |
name = "13", | |
detailName = "11.13" | |
), | |
LineChartPoint( | |
value = 30f, | |
valueDisplay = (30f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(30f), | |
name = "14", | |
detailName = "11.14" | |
), | |
LineChartPoint( | |
value = 29f, | |
valueDisplay = (29f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(29f), | |
name = "15", | |
detailName = "11.15" | |
), | |
LineChartPoint( | |
value = 30f, | |
valueDisplay = (30f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(30f), | |
name = "16", | |
detailName = "11.16" | |
), | |
), | |
pointConfig = createPointConfig( | |
guideline = Range(min = 0f, max = 36f), | |
valueRange = Range(min = 0f, max = 100f), | |
pointComparisonRule = ComparisonRule.Under, | |
hideValue = true, | |
), | |
groupingUnit = 5, | |
) | |
) | |
} | |
} | |
@Preview | |
@Composable | |
private fun LineChart_Preview_MEAN() { | |
VirtualCareTheme(darkTheme = true) { | |
val stringFormat = "%3.1f%%" | |
PointLineChart( | |
state = rememberLineChartState( | |
points = listOf( | |
LineChartPoint( | |
value = 102f, | |
valueDisplay = (102f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(102f), | |
name = "수", | |
detailName = "10.13" | |
), | |
LineChartPoint( | |
value = 117f, | |
valueDisplay = (117f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(117f), | |
name = "목", | |
detailName = "10.14" | |
), | |
LineChartPoint( | |
value = 164f, | |
valueDisplay = (164f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(164f), | |
name = "금", | |
detailName = "10.15" | |
), | |
LineChartPoint( | |
value = 126f, | |
valueDisplay = (126f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(126f), | |
name = "토", | |
detailName = "10.16" | |
), | |
LineChartPoint( | |
value = 145f, | |
valueDisplay = (145f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(145f), | |
name = "일", | |
detailName = "10.17" | |
), | |
LineChartPoint( | |
value = 110f, | |
valueDisplay = (110f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(110f), | |
name = "월", | |
detailName = "10.18" | |
), | |
LineChartPoint( | |
value = 70f, | |
valueDisplay = (70f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(70f), | |
name = "화", | |
detailName = "화" | |
), | |
), | |
pointConfig = createPointConfig( | |
guideline = Range(min = 70f, max = 117f), | |
valueRange = Range(70f, 164f), | |
pointComparisonRule = ComparisonRule.Below | |
), | |
groupingUnit = 1, | |
) | |
) | |
} | |
} | |
@Preview | |
@Composable | |
private fun LineChart_Preview_MEAN2() { | |
VirtualCareTheme(darkTheme = true) { | |
val stringFormat = "%3f%%" | |
PointLineChart( | |
state = rememberLineChartState( | |
points = listOf( | |
LineChartPoint( | |
value = 239f, | |
valueDisplay = (239f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(239f), | |
name = "수", | |
detailName = "10.13" | |
), | |
LineChartPoint( | |
value = 300f, | |
valueDisplay = (300f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(300f), | |
name = "목", | |
detailName = "10.14" | |
), | |
LineChartPoint( | |
value = 330f, | |
valueDisplay = (330f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(330f), | |
name = "금", | |
detailName = "10.15" | |
), | |
LineChartPoint( | |
value = 69f, | |
valueDisplay = (69f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(69f), | |
name = "토", | |
detailName = "10.16" | |
), | |
LineChartPoint( | |
value = 169f, | |
valueDisplay = (169f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(169f), | |
name = "일", | |
detailName = "10.17" | |
), | |
LineChartPoint( | |
value = 141f, | |
valueDisplay = (141f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(141f), | |
name = "월", | |
detailName = "10.18" | |
), | |
LineChartPoint( | |
value = 40f, | |
valueDisplay = (40f).toIntegerableString(), | |
valueDetailDisplay = stringFormat.format(40f), | |
name = "화", | |
detailName = "화" | |
), | |
), | |
pointConfig = createPointConfig( | |
guideline = Range(min = 70f, max = 154f), | |
valueRange = Range(min = 40f, max = 300f), | |
pointComparisonRule = ComparisonRule.Below | |
), | |
groupingUnit = 1, | |
title = "평균 혈당" | |
) | |
) | |
} | |
} | |
@Preview | |
@Composable | |
private fun LineChart_Preview_GMI() { | |
VirtualCareTheme(darkTheme = true) { | |
PointLineChart( | |
state = rememberLineChartState( | |
points = listOf( | |
LineChartPoint( | |
value = 7f, | |
valueDisplay = 7f.toIntegerableString() + "%", | |
name = "1차\n10.13", | |
detailName = "10.13" | |
) | |
), | |
groupingUnit = 1, | |
pointConfig = createPointConfig( | |
valueRange = Range(min = 0f, max = 14f), | |
defaultColor = VCTheme.colors.onSurface0 | |
) | |
) | |
) | |
} | |
} | |
@Preview | |
@Composable | |
private fun LineChart_Preview_GMI2() { | |
VirtualCareTheme(darkTheme = true) { | |
PointLineChart( | |
state = rememberLineChartState( | |
points = listOf( | |
LineChartPoint( | |
value = 7f, | |
valueDisplay = 7f.toIntegerableString() + "%", | |
name = "1차\n10.13", | |
detailName = "10.13" | |
), LineChartPoint( | |
value = 7f, | |
valueDisplay = 7f.toIntegerableString() + "%", | |
name = "1차\n10.13", | |
detailName = "10.13" | |
), LineChartPoint( | |
value = 7f, | |
valueDisplay = 7f.toIntegerableString() + "%", | |
name = "1차\n10.13", | |
detailName = "10.13" | |
), LineChartPoint( | |
value = 7f, | |
valueDisplay = 7f.toIntegerableString() + "%", | |
name = "1차\n10.13", | |
detailName = "10.13" | |
) | |
), | |
groupingUnit = 1, | |
pointConfig = createPointConfig( | |
valueRange = Range(min = 0f, max = 14f), | |
defaultColor = VCTheme.colors.onSurface0 | |
) | |
) | |
) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment