Last active
February 24, 2025 15:37
-
-
Save kimji1/1a93a65afb273ac7c27b18b7306773e5 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 HorizontalGaugeBarChart( | |
modifier: Modifier = Modifier, | |
state: HorizontalGaugeBarChartState, | |
) { | |
var unit by remember { mutableFloatStateOf(0f) } | |
val cornerRadius = remember { state.barHeight / 2 } | |
Column(modifier = modifier.wrapContentSize()) { | |
Canvas( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(state.barHeight.toDp()) | |
.clip(RoundedCornerShape(cornerRadius.toDp())) | |
) { | |
val base = state.gauge.base | |
unit = size.width / if (base > 0f) base else 1f | |
if (unit > 0f) { | |
drawHorizontalStackedBar( | |
currentIdx = 0, | |
lastIndex = 0, | |
barHeight = state.barHeight, | |
cornerRadius = cornerRadius, | |
stackedWidth = 0f, | |
elementWidth = size.width, | |
topPadding = 0f, | |
color = state.backgroundBarColor | |
) | |
val gaugeWidth = state.gauge.value * unit | |
drawHorizontalStackedBar( | |
currentIdx = 0, | |
lastIndex = 0, | |
barHeight = state.barHeight, | |
cornerRadius = cornerRadius, | |
stackedWidth = 0f, | |
elementWidth = gaugeWidth, | |
topPadding = 0f, | |
color = state.gauge.barColor | |
) | |
state.highlightRange?.let { highlightRange -> | |
drawHighlightRange( | |
highlightRange = highlightRange, | |
unit = unit, | |
barHeight = state.barHeight, | |
highlightColor = state.highlightColor, | |
highlightLineColor = state.highlightLineColor | |
) | |
} | |
} | |
} | |
state.highlightRange?.let { range -> | |
HighlightRange(range, unit) | |
} | |
} | |
} | |
@Composable | |
fun HighlightRange( | |
range: Range, | |
unit: Float, | |
) { | |
val textMeasurer = rememberTextMeasurer() | |
val textStyle = VCTheme.typo.caption1.copy(color = VCTheme.colors.graphSafety10) | |
val minTextSize = textMeasurer.measure( | |
text = range.min.toIntegerableString(), | |
style = textStyle | |
).size | |
val height = minTextSize.height.toFloat().toDp() | |
val minTextWidth = minTextSize.width | |
val maxTextWidth = textMeasurer.measure( | |
text = range.max.toIntegerableString(), | |
style = textStyle | |
).size.width | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(top = 2.dp) | |
.height(height) | |
) { | |
val minOffset = (range.min ?: 0f) * unit | |
val maxOffset = (range.max ?: 0f) * unit | |
val minTextXOffset = minOffset - (minTextWidth / 2) | |
val maxTextXOffset = maxOffset - minTextWidth - (maxTextWidth / 2) | |
val minimumGapConst = 4.dp.toPx() | |
val overlappingWidth = maxTextXOffset - minTextXOffset | |
val minGap = if (overlappingWidth < minimumGapConst) { | |
(overlappingWidth.absoluteValue + minimumGapConst) / 2 | |
} else 0f | |
if (range.min != null && unit > 0f) { | |
Text( | |
modifier = Modifier.offset(x = (minTextXOffset - minGap).toDp()), | |
text = range.min.toIntegerableString(), | |
style = textStyle, | |
) | |
} | |
if (range.max != null && unit > 0f) { | |
Text( | |
modifier = Modifier.offset(x = (maxTextXOffset + minGap).toDp()), | |
text = range.max.toIntegerableString(), | |
style = textStyle, | |
) | |
} | |
} | |
} | |
fun DrawScope.drawHighlightRange( | |
highlightRange: Range, | |
unit: Float, | |
barHeight: Float, | |
highlightColor: Color, | |
highlightLineColor: Color | |
) { | |
val width = ((highlightRange.max ?: 0f) - (highlightRange.min ?: 0f)) * unit | |
val minOffset = (highlightRange.min ?: 0f) * unit | |
val maxOffset = (highlightRange.max ?: 0f) * unit | |
val strokeWidth = 1.dp.toPx() | |
drawRect( | |
color = highlightColor, | |
topLeft = Offset(x = minOffset, y = 0f), | |
size = size.copy(width = width, height = barHeight), | |
) | |
drawLine( | |
color = highlightLineColor, | |
start = Offset(x = minOffset, y = 0f), | |
end = Offset(x = minOffset, y = barHeight), | |
strokeWidth = strokeWidth, | |
cap = StrokeCap.Round, | |
pathEffect = PathEffect.dashPathEffect( | |
intervals = floatArrayOf(strokeWidth, strokeWidth * 3f), | |
phase = strokeWidth * 0.5f, | |
), | |
) | |
drawLine( | |
color = highlightLineColor, | |
start = Offset(x = maxOffset, y = 0f), | |
end = Offset(x = maxOffset, y = barHeight), | |
strokeWidth = strokeWidth, | |
cap = StrokeCap.Round, | |
pathEffect = PathEffect.dashPathEffect( | |
intervals = floatArrayOf(strokeWidth, strokeWidth * 3f), | |
phase = strokeWidth * 0.5f, | |
), | |
) | |
} | |
data class HorizontalGaugeBarChartState( | |
val gauge: Gauge, | |
val barHeight: Float, | |
val backgroundBarColor: Color, | |
val highlightRange: Range?, | |
val highlightColor: Color, | |
val highlightLineColor: Color, | |
) | |
data class Gauge( | |
val value: Float, | |
val base: Float, | |
val barColor: Color, | |
) | |
data class Range( | |
val min: Float?, | |
val max: Float?, | |
) | |
@Composable | |
fun rememberHorizontalGaugeBarChartState( | |
gauge: Gauge, | |
highlightRange: Range?, | |
barHeight: Float = 8.dp.toPx(), | |
backgroundBarColor: Color = VCTheme.colors.line, | |
highlightColor: Color = VCTheme.colors.graphSafety99.copy(alpha = 0.45f), | |
highlightLine: Color = VCTheme.colors.graphSafety10, | |
) = remember(gauge) { | |
HorizontalGaugeBarChartState( | |
gauge = gauge, | |
barHeight = barHeight, | |
backgroundBarColor = backgroundBarColor, | |
highlightRange = highlightRange, | |
highlightColor = highlightColor, | |
highlightLineColor = highlightLine, | |
) | |
} |
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
fun Float?.toIntegerableString( | |
nullStr: String = "", | |
requireRound: Boolean = false, | |
requireTwoDecimalPlaces: Boolean = false | |
): String = if (this == null) { | |
nullStr | |
} else if (requireRound || (this % 1).compareTo(0f) == 0) { | |
DecimalFormat("##,###").format(roundHalfUp(0).toInt()) | |
} else if (requireTwoDecimalPlaces && (this % 0.1).compareTo(0f) != 0) { | |
DecimalFormat("##,###.##").format(this) | |
} else { | |
DecimalFormat("##,###.#").format(this) | |
} | |
fun Dp.toPx(): Float { | |
return this.value * getDensity() | |
} | |
fun Float.toDp(): Dp { | |
return (this / getDensity()).dp | |
} | |
// cf. HorizontalStackedBarChart.kt | |
fun DrawScope.drawHorizontalStackedBar( | |
currentIdx: Int, | |
lastIndex: Int, | |
barHeight: Float, | |
cornerRadius: Float, | |
stackedWidth: Float, | |
elementWidth: Float, | |
topPadding: Float, | |
color: Color | |
) { | |
val path = Path().apply { | |
val xPos = if (stackedWidth < cornerRadius) 0f else stackedWidth | |
val width = if (stackedWidth < cornerRadius) elementWidth + stackedWidth else elementWidth | |
val radius = CornerRadius(cornerRadius, cornerRadius) | |
addRoundRect( | |
RoundRect( | |
rect = Rect( | |
offset = Offset(x = xPos, y = topPadding), | |
size = size.copy(width = width, height = barHeight), | |
), | |
topLeft = if (currentIdx == 0) radius else CornerRadius.Zero, | |
topRight = if (currentIdx == lastIndex) radius else CornerRadius.Zero, | |
bottomLeft = if (currentIdx == 0) radius else CornerRadius.Zero, | |
bottomRight = if (currentIdx == lastIndex) radius else CornerRadius.Zero | |
) | |
) | |
} | |
drawPath(path, color = color) | |
} |
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(widthDp = 320, heightDp = 40) | |
@Composable | |
fun GaugeChartPreview() { | |
VirtualCareTheme(darkTheme = true) { | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(VCTheme.colors.background) | |
.padding(horizontal = 16.dp), | |
verticalArrangement = Arrangement.Center | |
) { | |
HorizontalGaugeBarChart( | |
state = rememberHorizontalGaugeBarChartState( | |
gauge = Gauge( | |
value = 30f, | |
base = 100f, | |
barColor = VCTheme.colors.onSurface40 | |
), | |
highlightRange = null, | |
) | |
) | |
} | |
} | |
} | |
@Preview(widthDp = 320, heightDp = 40) | |
@Composable | |
fun GaugeChartPreview_Clip() { | |
VirtualCareTheme(darkTheme = true) { | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(VCTheme.colors.background) | |
.padding(horizontal = 16.dp), | |
verticalArrangement = Arrangement.Center | |
) { | |
HorizontalGaugeBarChart( | |
state = rememberHorizontalGaugeBarChartState( | |
gauge = Gauge( | |
value = 2f, | |
base = 100f, | |
barColor = VCTheme.colors.onSurface40 | |
), | |
highlightRange = null, | |
) | |
) | |
} | |
} | |
} | |
@Preview(widthDp = 320) | |
@Composable | |
fun GaugeChartPreview_RangeTextGapTest1() { | |
VirtualCareTheme(darkTheme = true) { | |
Column( | |
modifier = Modifier | |
.background(VCTheme.colors.background) | |
.padding(all = 16.dp), | |
) { | |
HorizontalGaugeBarChart( | |
state = rememberHorizontalGaugeBarChartState( | |
gauge = Gauge( | |
value = 50f, | |
base = 75f, | |
barColor = VCTheme.colors.onSurface40 | |
), | |
highlightRange = Range(30f, 40f), | |
) | |
) | |
} | |
} | |
} | |
@Preview(widthDp = 320) | |
@Composable | |
fun GaugeChartPreview_RangeTextGapTest2() { | |
VirtualCareTheme(darkTheme = true) { | |
Column( | |
modifier = Modifier | |
.background(VCTheme.colors.background) | |
.padding(all = 16.dp), | |
) { | |
HorizontalGaugeBarChart( | |
state = rememberHorizontalGaugeBarChartState( | |
gauge = Gauge( | |
value = 50f, | |
base = 75f, | |
barColor = VCTheme.colors.onSurface40 | |
), | |
highlightRange = Range(20f, 22f), | |
) | |
) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment