Last active
February 24, 2025 15:39
-
-
Save kimji1/ffdf7edc46b7323f0fd19d9ea6ae33b8 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 VerticalStackedBarChart(modifier: Modifier = Modifier, state: VerticalStackedBarChartState) { | |
var parentWidth by remember { mutableFloatStateOf(0f) } | |
val mutableItems: MutableList<VerticalStackedBar> = remember(state.items) { | |
state.items.toMutableStateList() | |
} | |
val barHeight = state.barHeight.toPx() | |
val chartUnit = barHeight / 100 | |
val gap = state.gap.toPx() | |
val barCornerRadius = state.barCornerRadius.toPx() | |
val underHeight = mutableItems.maxOfOrNull { bar -> | |
bar.elements.filter { it.isLower }.mapNotNull { it.value }.sum() * chartUnit | |
} ?: 0f | |
val chartContentHeight = barHeight.toDp() + underHeight.toDp() | |
val showBaseLine = mutableItems.flatMap { bar -> | |
bar.elements.filter { it.isLower && it.value != null && it.value > 0f } | |
}.isNotEmpty() | |
var chartSize by remember { mutableStateOf(Size(0f, 0f)) } | |
val barItemWidthState = remember { mutableFloatStateOf(0f) } | |
var focusedItem: VerticalStackedBar? by remember(state.items) { mutableStateOf(null) } | |
var popupSize by remember { mutableStateOf(Size(0f, 0f)) } | |
var popupXOffset by remember { mutableFloatStateOf(0f) } | |
var popupYOffset by remember { mutableFloatStateOf(0f) } | |
val popupMargin = 4.dp.toPx() | |
var showDetailPopup by remember { mutableStateOf(false) } | |
LaunchedEffect(focusedItem, popupSize, chartSize) { | |
if (focusedItem == null || popupSize.height == 0f || chartSize.height == 0f || chartSize.width == 0f) return@LaunchedEffect | |
if (focusedItem?.focused == false) { | |
showDetailPopup = false | |
return@LaunchedEffect | |
} | |
val index = mutableItems.indexOf(focusedItem) | |
val isLeftSideBar = index <= (mutableItems.lastIndex / 2f) | |
val itemWidth = chartSize.width / mutableItems.size | |
val barWidth = itemWidth / 3f | |
popupXOffset = if (isLeftSideBar) { | |
val xPos = (itemWidth * index) + (barWidth * 2) + popupMargin | |
val screenOffWidth = xPos + popupSize.width - parentWidth | |
if ((xPos + popupSize.width > parentWidth)) xPos - screenOffWidth else xPos | |
} else { | |
val xPos = (itemWidth * index) + barWidth - popupSize.width - popupMargin | |
if (xPos < 0f) 0f else xPos | |
} | |
popupYOffset = (chartSize.height - popupSize.height) / 2 | |
showDetailPopup = focusedItem?.focused == true | |
} | |
fun changeFocused(item: VerticalStackedBar) { | |
val index = mutableItems.indexOf(item) | |
mutableItems.replaceAll { it.copy(focused = false) } | |
mutableItems[index] = mutableItems[index].copy(focused = !item.focused) | |
focusedItem = if (mutableItems[index].focused) mutableItems[index] else null | |
popupXOffset = Float.MAX_VALUE | |
} | |
Box(modifier = modifier.onSizeChanged { parentWidth = it.width.toFloat() }) { | |
Column(modifier = Modifier.align(Alignment.TopCenter)) { | |
Box { | |
ChartBackground( | |
chartHeight = barHeight, | |
baselineYOffset = gap, | |
config = state.chartBackgroundConfig.copy(showBaseLine = showBaseLine) | |
) { | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(chartContentHeight) | |
.onSizeChanged { chartSize = it.toSize() } | |
) { | |
mutableItems.forEach { item -> | |
VerticalStackedBar( | |
item = item, | |
barHeight = barHeight, | |
barWidthState = barItemWidthState, | |
chartUnit = chartUnit, | |
barCornerRadius = barCornerRadius, | |
gap = gap, | |
dividerConfig = state.dividerConfig, | |
changeFocused = ::changeFocused | |
) | |
} | |
} | |
} | |
} | |
HorizontalDivider( | |
modifier = Modifier.padding(top = if (underHeight != 0f) 8.dp else 0.dp), | |
thickness = 1.dp, | |
color = VCTheme.colors.secondaryLine | |
) | |
NameTag( | |
items = mutableItems.map { it.name to it.nameColor }, | |
groupingUnit = state.groupingUnit, | |
dividerConfig = state.dividerConfig | |
) | |
if (state.footNoteData != null) { | |
FootNote(footNoteData = state.footNoteData) | |
} | |
} | |
if (focusedItem != null) { | |
val item = focusedItem ?: return@Box | |
StackedBarDetailPopup( | |
modifier = Modifier | |
.onSizeChanged { | |
popupSize = Size(it.width.toFloat(), it.height.toFloat()) | |
} | |
.offset(x = popupXOffset.toDp(), y = popupYOffset.toDp()) | |
.clip(shape = RoundedCornerShape(12.dp)) | |
.noRippleClickable { changeFocused(item) } | |
.widthIn(max = parentWidth.toDp()) | |
.alpha(if (showDetailPopup && popupXOffset != Float.MAX_VALUE) 1f else 0f), | |
focusedItem = item | |
) | |
} | |
} | |
} | |
@Composable | |
fun StackedBarDetailPopup( | |
modifier: Modifier = Modifier, | |
focusedItem: VerticalStackedBar | |
) { | |
if (focusedItem.elements.mapNotNull { it.value }.isEmpty()) { | |
return | |
} | |
Box( | |
modifier = modifier | |
.wrapContentHeight(unbounded = true) | |
.wrapContentWidth() | |
.border( | |
width = 1.dp, | |
color = VCTheme.colors.onSurface70, | |
shape = RoundedCornerShape(12.dp) | |
) | |
.background(VCTheme.colors.surface99, shape = RoundedCornerShape(12.dp)) | |
.padding(horizontal = 12.dp, vertical = 8.dp) | |
) { | |
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { | |
var height by remember { mutableFloatStateOf(0f) } | |
Row( | |
modifier = Modifier | |
.wrapContentWidth() | |
.onSizeChanged { height = it.height.toFloat() }, | |
horizontalArrangement = Arrangement.spacedBy(12.dp) | |
) { | |
Column( | |
modifier = Modifier.heightIn(min = height.toDp()), | |
verticalArrangement = Arrangement.SpaceBetween | |
) { | |
focusedItem.elements.forEach { element -> | |
val color = if (element.value != null && element.value > 0f) { | |
element.nameColor | |
} else VCTheme.colors.onSurface70 | |
Text( | |
text = element.name, | |
style = VCTheme.typo.subhead.copy(color = color) | |
) | |
} | |
} | |
Column( | |
modifier = Modifier.heightIn(min = height.toDp()), | |
verticalArrangement = Arrangement.SpaceBetween | |
) { | |
focusedItem.elements.forEach { element -> | |
val color = if ((element.value ?: 0f) > 0f) element.nameColor else VCTheme.colors.onSurface70 | |
Text( | |
modifier = Modifier.weight(1f, false), | |
text = element.valueStr, | |
style = VCTheme.typo.subhead.copy(color = color) | |
) | |
} | |
} | |
} | |
Text( | |
text = focusedItem.detailName, | |
style = VCTheme.typo.caption1B.copy(color = VCTheme.colors.onSurface0) | |
) | |
} | |
} | |
} | |
@Composable | |
fun RowScope.VerticalStackedBar( | |
modifier: Modifier = Modifier, | |
item: VerticalStackedBar?, | |
barHeight: Float, | |
chartUnit: Float, | |
barCornerRadius: Float, | |
gap: Float, | |
dividerConfig: DividerConfig, | |
changeFocused: (selectedItem: VerticalStackedBar) -> Unit, | |
barWidthState: MutableFloatState | |
) { | |
Canvas( | |
modifier = modifier | |
.weight(1f) | |
.fillMaxHeight() | |
.conditional(item != null) { | |
noRippleClickable { | |
item?.let { changeFocused(it) } | |
} | |
} | |
) { | |
barWidthState.floatValue = size.width | |
if (item != null) { | |
val height = drawVerticalStackedBar( | |
barHeight = barHeight, | |
chartUnit = chartUnit, | |
radius = barCornerRadius, | |
gap = gap, | |
item = item, | |
) | |
drawVerticalDash( | |
lineColor = dividerConfig.color, | |
xOffset = 0f, | |
yTopOffset = 0f, | |
yBottomOffset = height, | |
strokeWidth = dividerConfig.width | |
) | |
} | |
} | |
} | |
fun DrawScope.drawVerticalStackedBar( | |
barHeight: Float, | |
chartUnit: Float, | |
radius: Float, | |
gap: Float, | |
item: VerticalStackedBar, | |
): Float { | |
if (item.elements.mapNotNull { it.value }.isEmpty()) { | |
return 0f | |
} | |
val cornerRadius = CornerRadius(radius, radius) | |
val zeroRadius = CornerRadius(0f, 0f) | |
val firstIndex = item.elements.indexOfFirst { it.value != null && it.value > 0f } | |
val lastIndex = item.elements.indexOfLast { it.value != null && it.value > 0f } | |
val defaultBarWidth = size.width / 3f | |
val upperItemHeight = | |
(item.elements.filter { it.isLower.not() }.mapNotNull { it.value }.sum() * chartUnit) | |
val topOffset = barHeight - upperItemHeight | |
var stackedHeight = 0f | |
var totalRatio = 100f | |
item.elements.forEachIndexed { index, element -> | |
if (element.value == null || element.value == 0f) return@forEachIndexed | |
val containedNextGap = | |
element.isLower && index != lastIndex && item.elements.getOrNull(index + 1)?.value == 1f | |
val unit = (barHeight - stackedHeight) / totalRatio | |
val height = if (element.value == 1f) { // 1% 인 경우 비율에 따른 높이가 아닌 1dp 고정값 | |
1.dp.toPx() | |
} else if (element.value > 0f && element.isUpper || element.isLower) { | |
(unit * element.value) - if (containedNextGap) gap * 2 else gap | |
} else if (element.value > 0f) { | |
unit * element.value | |
} else 0f | |
val barWidth = | |
if (element.value == 1f && (index == firstIndex || index == lastIndex)) defaultBarWidth * 0.9f else defaultBarWidth | |
val path = Path().apply { | |
addRoundRect( | |
RoundRect( | |
rect = Rect( | |
offset = Offset( | |
x = defaultBarWidth + (defaultBarWidth - barWidth) / 2, | |
y = topOffset + stackedHeight + if (element.isLower && element.value > 0f) gap else 0f | |
), | |
size = Size(width = barWidth, height = height), | |
), | |
topLeft = if (index == firstIndex) cornerRadius else zeroRadius, | |
topRight = if (index == firstIndex) cornerRadius else zeroRadius, | |
bottomLeft = if (index == lastIndex && (element.isUpper || element.isLower)) cornerRadius else zeroRadius, | |
bottomRight = if (index == lastIndex && (element.isUpper || element.isLower)) cornerRadius else zeroRadius | |
) | |
) | |
} | |
drawPath(path, color = if (item.focused) element.focusedColor else element.color) | |
stackedHeight += if (element.value > 0f && (element.isUpper || element.isLower)) { | |
height + gap | |
} else if (element.value > 0f) { | |
height | |
} else 0f | |
totalRatio -= element.value | |
} | |
return (topOffset + stackedHeight) | |
} | |
@Preview | |
@Composable | |
fun VerticalStackedBarChartPreview_1() { | |
VirtualCareTheme(darkTheme = true) { | |
val firstElement = VerticalStackedBarElement( | |
name = "매우 높음", | |
value = 0f, | |
valueStr = "", | |
color = VCTheme.colors.graphHigh80, | |
focusedColor = VCTheme.colors.graphHigh40, | |
nameColor = VCTheme.colors.graphHigh40, | |
isUpper = true | |
) | |
val secondElement = VerticalStackedBarElement( | |
name = "높음", | |
value = 0f, | |
valueStr = "", | |
color = VCTheme.colors.graphHigh90, | |
focusedColor = VCTheme.colors.graphHigh, | |
nameColor = VCTheme.colors.graphHigh, | |
isUpper = true | |
) | |
val thirdElement = VerticalStackedBarElement( | |
name = "목표 범위", | |
value = 0f, | |
valueStr = "", | |
color = VCTheme.colors.graphSafety40, | |
focusedColor = VCTheme.colors.graphSafety40, | |
nameColor = VCTheme.colors.graphSafety10, | |
) | |
val forthElement = VerticalStackedBarElement( | |
name = "낮음", | |
value = 0f, | |
valueStr = "", | |
color = VCTheme.colors.graphLow80, | |
focusedColor = VCTheme.colors.graphLow, | |
nameColor = VCTheme.colors.graphLow, | |
isLower = true | |
) | |
val fifthElement = VerticalStackedBarElement( | |
name = "매우 낮음", | |
value = 0f, | |
valueStr = "", | |
color = VCTheme.colors.graphLow90, | |
focusedColor = VCTheme.colors.graphLow40, | |
nameColor = VCTheme.colors.graphLow40, | |
isLower = true, | |
) | |
VerticalStackedBarChart( | |
state = rememberVerticalStackedBarChartState( | |
footNoteData = FootNoteData("70% 이상 권장", VCTheme.colors.graphSafety10), | |
items = listOf( | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 1f, valueStr = "1%"), | |
secondElement.copy(value = 1f, valueStr = "1%"), | |
thirdElement.copy(value = 96f, valueStr = "96%"), | |
forthElement.copy(value = 1f, valueStr = "1%"), | |
fifthElement.copy(value = 1f, valueStr = "1%") | |
), | |
name = "화", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 0f, valueStr = "0%"), | |
secondElement.copy(value = 0f, valueStr = "0%"), | |
thirdElement.copy(value = 20f, valueStr = "20%"), | |
forthElement.copy(value = 3f, valueStr = "3%"), | |
fifthElement.copy(value = 77f, valueStr = "77%") | |
), | |
name = "월", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 0f, valueStr = "0%"), | |
secondElement.copy(value = 0f, valueStr = "0%"), | |
thirdElement.copy(value = 0f, valueStr = "0%"), | |
forthElement.copy(value = 48f, valueStr = "48%"), | |
fifthElement.copy(value = 52f, valueStr = "52%") | |
), | |
name = "일", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.error | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 0f, valueStr = "0%"), | |
secondElement.copy(value = 0f, valueStr = "0%"), | |
thirdElement.copy(value = 0f, valueStr = "0%"), | |
forthElement.copy(value = 0f, valueStr = "0%"), | |
fifthElement.copy(value = 100f, valueStr = "100%") | |
), | |
name = "토", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 0f, valueStr = "0%"), | |
secondElement.copy(value = 1f, valueStr = "1%"), | |
thirdElement.copy(value = 24f, valueStr = "24%"), | |
forthElement.copy(value = 46.5f, valueStr = "46%"), | |
fifthElement.copy(value = 28.5f, valueStr = "29%") | |
), | |
name = "금", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 1f, valueStr = "1%"), | |
secondElement.copy(value = 29f, valueStr = "29%"), | |
thirdElement.copy(value = 50f, valueStr = "50%"), | |
forthElement.copy(value = 19f, valueStr = "19%"), | |
fifthElement.copy(value = 1f, valueStr = "1%") | |
), | |
name = "목", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 14f, valueStr = "14%"), | |
secondElement.copy(value = 28f, valueStr = "28%"), | |
thirdElement.copy(value = 7f, valueStr = "7%"), | |
forthElement.copy(value = 19f, valueStr = "19%"), | |
fifthElement.copy(value = 32f, valueStr = "32%") | |
), | |
name = "수", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0, | |
focused = true | |
) | |
) | |
) | |
) | |
} | |
} | |
@Preview | |
@Composable | |
private fun VerticalStackedBarChartPreview_2() { | |
VirtualCareTheme(darkTheme = true) { | |
val firstElement = VerticalStackedBarElement( | |
name = "매우 높음", | |
value = 0f, | |
valueStr = "0%", | |
color = VCTheme.colors.graphHigh80, | |
focusedColor = VCTheme.colors.graphHigh40, | |
nameColor = VCTheme.colors.graphHigh40, | |
isUpper = true | |
) | |
val secondElement = VerticalStackedBarElement( | |
name = "높음", | |
value = 0f, | |
valueStr = "0%", | |
color = VCTheme.colors.graphHigh90, | |
focusedColor = VCTheme.colors.graphHigh, | |
nameColor = VCTheme.colors.graphHigh, | |
isUpper = true | |
) | |
val thirdElement = VerticalStackedBarElement( | |
name = "목표 범위", | |
value = 100f, | |
valueStr = "100%", | |
color = VCTheme.colors.graphSafety40, | |
focusedColor = VCTheme.colors.graphSafety40, | |
nameColor = VCTheme.colors.graphSafety10, | |
) | |
val forthElement = VerticalStackedBarElement( | |
name = "낮음", | |
value = 0f, | |
valueStr = "0%", | |
color = VCTheme.colors.graphLow80, | |
focusedColor = VCTheme.colors.graphLow, | |
nameColor = VCTheme.colors.graphLow, | |
isLower = true | |
) | |
val fifthElement = VerticalStackedBarElement( | |
name = "매우 낮음", | |
value = 0f, | |
valueStr = "0%", | |
color = VCTheme.colors.graphLow90, | |
focusedColor = VCTheme.colors.graphLow40, | |
nameColor = VCTheme.colors.graphLow40, | |
isLower = true, | |
) | |
VerticalStackedBarChart( | |
state = rememberVerticalStackedBarChartState( | |
footNoteData = FootNoteData("70% 이상 권장", VCTheme.colors.graphSafety10), | |
items = listOf( | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement, | |
secondElement, | |
thirdElement, | |
forthElement, | |
fifthElement | |
), | |
name = "화", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement, | |
secondElement, | |
thirdElement, | |
forthElement, | |
fifthElement | |
), | |
name = "월", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement, | |
secondElement, | |
thirdElement, | |
forthElement, | |
fifthElement | |
), | |
name = "일", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.error | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement, | |
secondElement, | |
thirdElement, | |
forthElement, | |
fifthElement | |
), | |
name = "토", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0, | |
focused = true | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 3f, valueStr = "3%"), | |
secondElement.copy(value = 23f, valueStr = "23%"), | |
thirdElement.copy(value = 74f, valueStr = "74%"), | |
forthElement.copy(value = 0f, valueStr = "0%"), | |
fifthElement.copy(value = 0f, valueStr = "0%") | |
), | |
name = "금", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 0f, valueStr = "0%"), | |
secondElement.copy(value = 0f, valueStr = "0%"), | |
thirdElement.copy(value = 20f, valueStr = "20%"), | |
forthElement.copy(value = 3f, valueStr = "3%"), | |
fifthElement.copy(value = 77f, valueStr = "77%") | |
), | |
name = "목", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement, | |
secondElement, | |
thirdElement, | |
forthElement, | |
fifthElement | |
), | |
name = "수", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0, | |
) | |
) | |
) | |
) | |
} | |
} | |
@Preview | |
@Composable | |
private fun VerticalStackedBarChartPreview_3() { | |
VirtualCareTheme(darkTheme = true) { | |
val firstElement = VerticalStackedBarElement( | |
name = "매우 높음", | |
value = 0f, | |
valueStr = "0%", | |
color = VCTheme.colors.graphHigh80, | |
focusedColor = VCTheme.colors.graphHigh40, | |
nameColor = VCTheme.colors.graphHigh40, | |
isUpper = true | |
) | |
val secondElement = VerticalStackedBarElement( | |
name = "높음", | |
value = 0f, | |
valueStr = "0%", | |
color = VCTheme.colors.graphHigh90, | |
focusedColor = VCTheme.colors.graphHigh, | |
nameColor = VCTheme.colors.graphHigh, | |
isUpper = true | |
) | |
val thirdElement = VerticalStackedBarElement( | |
name = "목표 범위", | |
value = 0f, | |
valueStr = "0%", | |
color = VCTheme.colors.graphSafety40, | |
focusedColor = VCTheme.colors.graphSafety40, | |
nameColor = VCTheme.colors.graphSafety10, | |
) | |
val forthElement = VerticalStackedBarElement( | |
name = "낮음", | |
value = 0f, | |
valueStr = "0%", | |
color = VCTheme.colors.graphLow80, | |
focusedColor = VCTheme.colors.graphLow, | |
nameColor = VCTheme.colors.graphLow, | |
isLower = true | |
) | |
val fifthElement = VerticalStackedBarElement( | |
name = "매우 낮음", | |
value = 0f, | |
valueStr = "0%", | |
color = VCTheme.colors.graphLow90, | |
focusedColor = VCTheme.colors.graphLow40, | |
nameColor = VCTheme.colors.graphLow40, | |
isLower = true, | |
) | |
val items = listOf( | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 0f, valueStr = "0%"), | |
secondElement.copy(value = 1f, valueStr = "1%"), | |
thirdElement.copy(value = 24f, valueStr = "24%"), | |
forthElement.copy(value = 46.5f, valueStr = "46%"), | |
fifthElement.copy(value = 28.5f, valueStr = "29%") | |
), | |
name = "화", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 1f, valueStr = "1%"), | |
secondElement.copy(value = 29f, valueStr = "29%"), | |
thirdElement.copy(value = 50f, valueStr = "50%"), | |
forthElement.copy(value = 19f, valueStr = "19%"), | |
fifthElement.copy(value = 1f, valueStr = "1%") | |
), | |
name = "월", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 0f, valueStr = "0%"), | |
secondElement.copy(value = 30f, valueStr = "30%"), | |
thirdElement.copy(value = 50f, valueStr = "50%"), | |
forthElement.copy(value = 20f, valueStr = "20%"), | |
fifthElement.copy(value = 0f, valueStr = "0%") | |
), | |
name = "일", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.error | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 1f, valueStr = "1%"), | |
secondElement.copy(value = 29f, valueStr = "29%"), | |
thirdElement.copy(value = 50f, valueStr = "50%"), | |
forthElement.copy(value = 19f, valueStr = "19%"), | |
fifthElement.copy(value = 1f, valueStr = "1%") | |
), | |
name = "토", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 0f, valueStr = "0%"), | |
secondElement.copy(value = 1f, valueStr = "1%"), | |
thirdElement.copy(value = 24f, valueStr = "24%"), | |
forthElement.copy(value = 46.5f, valueStr = "46%"), | |
fifthElement.copy(value = 28.5f, valueStr = "29%") | |
), | |
name = "금", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 1f, valueStr = "1%"), | |
secondElement.copy(value = 29f, valueStr = "29%"), | |
thirdElement.copy(value = 50f, valueStr = "50%"), | |
forthElement.copy(value = 19f, valueStr = "19%"), | |
fifthElement.copy(value = 1f, valueStr = "1%") | |
), | |
name = "목", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0 | |
), | |
VerticalStackedBar( | |
elements = listOf( | |
firstElement.copy(value = 14f, valueStr = "14%"), | |
secondElement.copy(value = 28f, valueStr = "28%"), | |
thirdElement.copy(value = 7f, valueStr = "7%"), | |
forthElement.copy(value = 19f, valueStr = "19%"), | |
fifthElement.copy(value = 32f, valueStr = "32%") | |
), | |
name = "수", | |
detailName = "10.17", | |
nameColor = VCTheme.colors.onSurface0, | |
focused = false | |
) | |
) | |
VerticalStackedBarChart( | |
state = rememberVerticalStackedBarChartState( | |
footNoteData = FootNoteData("70% 이상 권장", VCTheme.colors.graphSafety10), | |
items = (items + items + items + items + items).take(30), | |
groupingUnit = 5 | |
) | |
) | |
} | |
} |
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 VerticalStackedBarChartState( | |
val items: List<VerticalStackedBar>, | |
val groupingUnit: Int, | |
val barHeight: Dp, | |
val barWidth: Dp, | |
val barCornerRadius: Dp, | |
val gap: Dp, | |
val dividerConfig: DividerConfig, | |
val chartBackgroundConfig: ChartBackgroundConfig, | |
val footNoteData: FootNoteData? | |
) | |
data class VerticalStackedBar( | |
val elements: List<VerticalStackedBarElement>, | |
val name: String, | |
val detailName: String, | |
val nameColor: Color, | |
var focused: Boolean = false | |
) | |
data class VerticalStackedBarElement( | |
val name: String, | |
val value: Float?, | |
val valueStr: String, | |
val color: Color, | |
val focusedColor: Color, | |
val nameColor: Color, | |
val isUpper: Boolean = false, | |
val isLower: Boolean = false | |
) | |
@Composable | |
fun rememberVerticalStackedBarChartState( | |
items: List<VerticalStackedBar>, | |
barHeight: Dp = 200.dp, | |
barWidth: Dp = 16.dp, | |
gap: Dp = 2.dp, | |
barCornerRadius: Dp = 4.dp, | |
groupingUnit: Int = 1, | |
dividerConfig: DividerConfig = DividerConfig( | |
color = VCTheme.colors.secondaryLine, | |
width = 1.dp.toPx() | |
), | |
highlightingAreaRatio: Float = 0.3f, | |
chartBackgroundConfig: ChartBackgroundConfig = createChartBackgroundConfig( | |
highlightingAreaHeight = barHeight.toPx() * highlightingAreaRatio, | |
highlightingLineAlignment = Alignment.Bottom | |
), | |
footNoteData: FootNoteData? = null | |
) = remember(items) { | |
VerticalStackedBarChartState( | |
items = items, | |
barHeight = barHeight, | |
barWidth = barWidth, | |
gap = gap, | |
barCornerRadius = barCornerRadius, | |
groupingUnit = groupingUnit, | |
dividerConfig = dividerConfig, | |
chartBackgroundConfig = chartBackgroundConfig, | |
footNoteData = footNoteData, | |
) | |
} |
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)) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment