Skip to content

Commit 745db79

Browse files
committed
refactor Bounds and InsetPad into utilities, add handling of label rotation
1 parent a67801f commit 745db79

3 files changed

Lines changed: 244 additions & 116 deletions

File tree

compose-charts/src/commonMain/kotlin/ir/ehsannarmani/compose_charts/LineChart.kt

Lines changed: 153 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
3737
import androidx.compose.ui.graphics.drawscope.Stroke
3838
import androidx.compose.ui.graphics.drawscope.clipRect
3939
import androidx.compose.ui.graphics.drawscope.inset
40+
import androidx.compose.ui.graphics.drawscope.rotate
4041
import androidx.compose.ui.graphics.isSpecified
4142
import androidx.compose.ui.graphics.takeOrElse
4243
import androidx.compose.ui.input.pointer.PointerInputScope
@@ -73,6 +74,8 @@ import ir.ehsannarmani.compose_charts.models.LabelProperties
7374
import ir.ehsannarmani.compose_charts.models.Line
7475
import ir.ehsannarmani.compose_charts.models.PopupProperties
7576
import ir.ehsannarmani.compose_charts.models.ZeroLineProperties
77+
import ir.ehsannarmani.compose_charts.utils.InsetPad
78+
import ir.ehsannarmani.compose_charts.utils.bounds
7679
import ir.ehsannarmani.compose_charts.utils.calculateOffset
7780
import kotlinx.coroutines.CoroutineScope
7881
import kotlinx.coroutines.Job
@@ -250,7 +253,7 @@ fun LineChart(
250253
if (!properties.enabled) return@forEachIndexed
251254

252255
val bounds = insetPad.toBounds(size)
253-
val positionX = position.x.coerceIn(bounds.xMin, bounds.xMax)
256+
val positionX = position.x.coerceIn(bounds.left, bounds.right)
254257
val pathData = linesPathData[dataIndex]
255258

256259
val isSingleValue = line.values.count() == 1
@@ -775,44 +778,6 @@ data class DotInfo(
775778
val value: Float,
776779
)
777780

778-
data class InsetBounds(
779-
val xMin: Float,
780-
val xMax: Float,
781-
val yMin: Float,
782-
val yMax: Float,
783-
) {
784-
fun xWithin(x: Float): Boolean {
785-
return x in xMin..xMax
786-
}
787-
788-
fun yWithin(y: Float): Boolean {
789-
return y in yMin..yMax
790-
}
791-
792-
fun pointWithin(x: Float, y: Float): Boolean {
793-
return xWithin(x) && yWithin(y)
794-
}
795-
}
796-
797-
data class InsetPad(
798-
var left: Float,
799-
var right: Float,
800-
var top: Float,
801-
var bottom: Float,
802-
) {
803-
fun width(size: IntSize): Float {
804-
return size.width - left - right
805-
}
806-
807-
fun height(size: IntSize): Float {
808-
return size.height - top - bottom
809-
}
810-
811-
fun toBounds(size: IntSize) = InsetBounds(
812-
left, size.width - right, top, size.height - bottom
813-
)
814-
}
815-
816781
fun DrawScope.inset(insetPad: InsetPad, block: DrawScope.() -> Unit) {
817782
val mostLeft = size.width / 2f
818783
val mostTop = size.height / 2f
@@ -826,6 +791,43 @@ fun DrawScope.inset(insetPad: InsetPad, block: DrawScope.() -> Unit) {
826791
}
827792
}
828793

794+
data class MinLabelPadding(
795+
val minLeft: Float,
796+
val minRight: Float,
797+
val minBottom: Float,
798+
val shouldRotate: Boolean
799+
)
800+
801+
fun getLabelMinPadding(
802+
labels: List<String>,
803+
labelProperties: LabelProperties,
804+
measureLabel: (String) -> TextLayoutResult
805+
): MinLabelPadding {
806+
if (labels.isEmpty()) return MinLabelPadding(0f, 0f, 0f, false)
807+
val measuredLabels = labels.map { measureLabel(it) }
808+
val labelPad = measureLabel("M").size.width
809+
val maxLabelWidth = measuredLabels.maxOf { it.size.width }
810+
val minLabelWidth = measuredLabels.minOf { it.size.width }
811+
val shouldRotate = labelProperties.rotation.mode == LabelProperties.Rotation.Mode.Force ||
812+
((maxLabelWidth / minLabelWidth.toDouble()) >= 1.5 && labelProperties.rotation.degree != 0f)
813+
if (shouldRotate) {
814+
val rotatedBounds =
815+
measuredLabels.map { it.bounds.rotate(-labelProperties.rotation.degree) }
816+
return MinLabelPadding(
817+
rotatedBounds[0].width / 2f,
818+
rotatedBounds.last().width / 2f,
819+
rotatedBounds.maxOf { it.height } + labelPad,
820+
true
821+
)
822+
}
823+
return MinLabelPadding(
824+
measuredLabels[0].size.width / 2f,
825+
measuredLabels.last().size.width / 2f,
826+
measuredLabels.maxOf { it.size.height }.toFloat() + labelPad,
827+
false
828+
)
829+
}
830+
829831
fun getInsetPad(
830832
measureLabel: (String) -> TextLayoutResult,
831833
measureIndicator: (String) -> TextLayoutResult,
@@ -834,38 +836,28 @@ fun getInsetPad(
834836
labelProperties: LabelProperties
835837
): InsetPad {
836838
val labels = labelProperties.labels
837-
val insetPad = InsetPad(0f, 0f, 0f, 0f)
839+
val (minLeft, minRight, minBottom, shouldRotate) =
840+
getLabelMinPadding(labels, labelProperties, measureLabel)
841+
val insetPad = InsetPad(0f, 0f, 0f, 0f, shouldRotate)
838842
if (indicators.isNotEmpty() && indicatorProperties.enabled) {
839843
val indicatorPad = measureIndicator("M").size.width
840844
val textIndicators = indicators.map(indicatorProperties.contentBuilder)
841845
val maxIndicatorWidth = textIndicators.maxOf { measureIndicator(it).size.width }
842846
val maxIndicatorHeight = textIndicators.maxOf { measureIndicator(it).size.height }
843847
insetPad.top = maxIndicatorHeight / 2f
844-
if (labels.isNotEmpty()) {
845-
val labelPad = measureLabel("M").size.width
846-
insetPad.bottom = labels.maxOf { measureLabel(it).size.height }.toFloat() + labelPad
847-
if (indicatorProperties.position == IndicatorPosition.Horizontal.Start) {
848-
insetPad.left =
849-
max(measureLabel(labels[0]).size.width / 2f, maxIndicatorWidth.toFloat()) +
850-
indicatorPad.toFloat()
851-
insetPad.right = measureLabel(labels.last()).size.width / 2f
852-
} else {
853-
insetPad.left = measureLabel(labels[0]).size.width / 2f
854-
insetPad.right =
855-
max(measureLabel(labels.last()).size.width / 2f, maxIndicatorWidth.toFloat()) +
856-
indicatorPad.toFloat()
857-
}
848+
insetPad.bottom = max(maxIndicatorHeight / 2f, minBottom)
849+
val indicatorSidePad = max(minLeft, maxIndicatorWidth.toFloat()) + indicatorPad.toFloat()
850+
if (indicatorProperties.position == IndicatorPosition.Horizontal.Start) {
851+
insetPad.left = indicatorSidePad
852+
insetPad.right = minRight
858853
} else {
859-
insetPad.bottom = (maxIndicatorHeight) / 2f
860-
if (indicatorProperties.position == IndicatorPosition.Horizontal.Start) {
861-
insetPad.left = (maxIndicatorWidth + indicatorPad).toFloat()
862-
} else {
863-
insetPad.right = (maxIndicatorWidth + indicatorPad).toFloat()
864-
}
854+
insetPad.left = minLeft
855+
insetPad.right = indicatorSidePad
865856
}
866857
} else if (labels.isNotEmpty()) {
867-
insetPad.left = measureLabel(labels[0]).size.width / 2f
868-
insetPad.right = measureLabel(labels.last()).size.width / 2f
858+
insetPad.left = minLeft
859+
insetPad.right = minRight
860+
insetPad.bottom = minBottom
869861
}
870862
return insetPad
871863
}
@@ -976,59 +968,109 @@ private fun RowScope.LineChartCanvas(
976968
onPressJobChange = onPressJobChange
977969
)
978970
Canvas(modifier = modifier) {
979-
val yTicks = if (indicators.isNotEmpty() && indicatorProperties.enabled) {
980-
val sortedIndicators = indicators.sortedBy { -it }.filter { it in minValue..maxValue }
981-
val maxIndicatorWidth = sortedIndicators.maxOf {
982-
measureIndicator(indicatorProperties.contentBuilder(it)).size.width
983-
}
984-
val drawingHeight = size.height - insetPad.top - insetPad.bottom
985-
val getOffset = { value: Double ->
986-
drawingHeight + insetPad.top - calculateOffset(
987-
maxValue,
988-
minValue,
989-
drawingHeight,
990-
value.toFloat()
971+
val yTicks = getTicksAndDrawIndicators(
972+
indicators,
973+
indicatorProperties,
974+
minValue,
975+
maxValue,
976+
measureIndicator,
977+
insetPad,
978+
textMeasurer,
979+
)
980+
val xTicks = getTicksAndDrawLabels(labelProperties, measureLabel, insetPad, textMeasurer)
981+
inset(insetPad) {
982+
insetDrawScope(xTicks + listOf(0f, size.width), yTicks + listOf(0f, size.height))
983+
}
984+
}
985+
}
986+
987+
fun DrawScope.getTicksAndDrawIndicators(
988+
indicators: List<Double>,
989+
indicatorProperties: HorizontalIndicatorProperties,
990+
minValue: Double,
991+
maxValue: Double,
992+
measureIndicator: (String) -> TextLayoutResult,
993+
insetPad: InsetPad,
994+
textMeasurer: TextMeasurer
995+
): List<Float> {
996+
return if (indicators.isNotEmpty() && indicatorProperties.enabled) {
997+
val sortedIndicators = indicators.sortedBy { -it }.filter { it in minValue..maxValue }
998+
val maxIndicatorWidth = sortedIndicators.maxOf {
999+
measureIndicator(indicatorProperties.contentBuilder(it)).size.width
1000+
}
1001+
val drawingHeight = size.height - insetPad.top - insetPad.bottom
1002+
val getTick = { value: Double ->
1003+
drawingHeight - calculateOffset(
1004+
maxValue,
1005+
minValue,
1006+
drawingHeight,
1007+
value.toFloat()
1008+
)
1009+
}
1010+
sortedIndicators.map {
1011+
val tick = getTick(it)
1012+
val offset = tick + insetPad.top
1013+
val text = indicatorProperties.contentBuilder(it)
1014+
val textSize = measureIndicator(text)
1015+
if (indicatorProperties.position == IndicatorPosition.Horizontal.Start) {
1016+
drawText(
1017+
textMeasurer = textMeasurer,
1018+
text = text,
1019+
style = indicatorProperties.textStyle,
1020+
topLeft = Offset(
1021+
(maxIndicatorWidth - textSize.size.width).toFloat(),
1022+
offset.toFloat() - textSize.size.height / 2f
1023+
)
9911024
)
992-
}
993-
sortedIndicators.map {
994-
val offset = getOffset(it)
995-
val text = indicatorProperties.contentBuilder(it)
996-
val textSize = measureIndicator(text)
997-
if (indicatorProperties.position == IndicatorPosition.Horizontal.Start) {
998-
drawText(
999-
textMeasurer = textMeasurer,
1000-
text = text,
1001-
style = indicatorProperties.textStyle,
1002-
topLeft = Offset(
1003-
(maxIndicatorWidth - textSize.size.width).toFloat(),
1004-
offset.toFloat() - textSize.size.height / 2f
1005-
)
1025+
} else {
1026+
drawText(
1027+
textMeasurer = textMeasurer,
1028+
text = text,
1029+
style = indicatorProperties.textStyle,
1030+
topLeft = Offset(
1031+
(size.width - maxIndicatorWidth),
1032+
offset.toFloat() - textSize.size.height / 2f
10061033
)
1007-
} else {
1034+
)
1035+
}
1036+
tick.toFloat()
1037+
}
1038+
} else emptyList()
1039+
}
1040+
1041+
fun DrawScope.getTicksAndDrawLabels(
1042+
labelProperties: LabelProperties,
1043+
measureLabel: (String) -> TextLayoutResult,
1044+
insetPad: InsetPad,
1045+
textMeasurer: TextMeasurer
1046+
): List<Float> {
1047+
return if (labelProperties.labels.isNotEmpty() && labelProperties.enabled) {
1048+
val labels = labelProperties.labels
1049+
val maxLabelHeight = labels.maxOf {
1050+
measureLabel(it).size.height
1051+
}
1052+
val insetBounds = insetPad.toBounds(size)
1053+
val bottomStart = insetBounds.bottom + measureLabel("M").size.width.let {
1054+
if (insetPad.shouldRotate) it*2f/3 else it.toFloat()
1055+
}
1056+
labels.mapIndexed { index, label ->
1057+
val tick = insetBounds.width * index / (labels.size - 1)
1058+
val offset = insetBounds.left + tick
1059+
val textSize = measureLabel(label)
1060+
if (insetPad.shouldRotate) {
1061+
val degrees = labelProperties.rotation.degree
1062+
val bounds = textSize.bounds
1063+
val rotatedBounds = bounds.rotate(-degrees)
1064+
val center = Offset(offset, bottomStart + rotatedBounds.height / 2f)
1065+
rotate(degrees, pivot = center) {
10081066
drawText(
10091067
textMeasurer = textMeasurer,
1010-
text = text,
1011-
style = indicatorProperties.textStyle,
1012-
topLeft = Offset(
1013-
(size.width - maxIndicatorWidth),
1014-
offset.toFloat() - textSize.size.height / 2f
1015-
)
1068+
text = label,
1069+
style = labelProperties.textStyle,
1070+
topLeft = center - Offset(textSize.size.width/2f, textSize.size.height/2f)
10161071
)
10171072
}
1018-
(offset - insetPad.top).toFloat()
1019-
}
1020-
} else emptyList()
1021-
val xTicks = if (labelProperties.labels.isNotEmpty() && labelProperties.enabled) {
1022-
val labels = labelProperties.labels
1023-
val maxLabelHeight = labels.maxOf {
1024-
measureLabel(it).size.height
1025-
}
1026-
val start = insetPad.left
1027-
val end = size.width - insetPad.right
1028-
val range = end - start
1029-
labels.mapIndexed { index, label ->
1030-
val offset = start + range * index / (labels.size - 1)
1031-
val textSize = measureLabel(label)
1073+
} else {
10321074
drawText(
10331075
textMeasurer = textMeasurer,
10341076
text = label,
@@ -1038,13 +1080,8 @@ private fun RowScope.LineChartCanvas(
10381080
size.height - maxLabelHeight
10391081
)
10401082
)
1041-
offset - start
10421083
}
1043-
} else emptyList()
1044-
inset(insetPad) {
1045-
insetDrawScope(xTicks + listOf(0f, size.width), yTicks + listOf(0f, size.height))
1084+
tick
10461085
}
1047-
}
1086+
} else emptyList()
10481087
}
1049-
1050-

0 commit comments

Comments
 (0)