diff --git a/CHANGELOG.md b/CHANGELOG.md index 5feb392..ee51919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ## [Unreleased] +### Changed +- Line/Bar/Donut/Pie/Radar 차트 렌더링 성능 개선 — Canvas draw 중 선택 상태 변경과 콜백 호출을 제거하고, 데이터/크기 기반 파생값을 캐시해 반복 할당을 줄임 +- 선택 콜백 호출 안정화 — 동일한 터치 선택이 redraw/recomposition 되는 동안 `onSelectionChanged` 및 레거시 선택 콜백이 반복 호출되지 않도록 변경 + ## [1.3.0] - 2026-04-10 ### Added diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md index 58242a4..e7465f9 100644 --- a/CODE_QUALITY.md +++ b/CODE_QUALITY.md @@ -335,6 +335,13 @@ internal fun resolveGridLineColor(color: Color, isDark: Boolean): Color = - 싱글톤(`object`), 캐싱, 지연 초기화(`lazy`)를 적절히 활용한다. - 성능이 중요한 계산에서는 기본 자료형 배열(`FloatArray`, `IntArray`)을 사용한다. +### Canvas 렌더링 최적화 + +- `Canvas`/draw 블록 안에서는 상태 변경, 외부 콜백 호출, 선택 이벤트 방출을 하지 않는다. +- 터치 hit-test 결과와 접근성 선택 설명은 draw 밖에서 파생 상태로 계산하고, 콜백은 선택값 변경 시점에만 호출한다. +- 데이터/크기/style에만 의존하는 좌표, `Path`, 라벨 목록, 누적값은 `remember`, `lazy`, `drawWithCache` 등으로 재사용한다. +- draw 중 반복 생성되는 `Paint`, `PathEffect`, 임시 컬렉션은 호출 단위 캐시나 더 직접적인 draw API로 줄인다. + ### 컬렉션 처리 최적화 - 여러 단계의 컬렉션 처리는 `Sequence`를 고려한다 (지연 처리, 최소 연산). diff --git a/README.md b/README.md index 76aa80c..e35e7b3 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,12 @@ GaugeChart( 기존의 `onPointSelected`/`onBarSelected`/`onSliceSelected`/`onAxisSelected` 콜백은 소스 호환을 위해 유지되지만 v2.0에서 제거될 예정입니다. +### 성능 팁 + +- 차트 `data`와 `style`은 가능하면 `remember` 또는 상위 상태로 안정적으로 유지하세요. 같은 값을 매 recomposition마다 새 객체로 만들면 차트가 다시 계산될 수 있습니다. +- 데이터 포인트가 많다면 `showDots`, slice/radar label, 긴 애니메이션을 필요한 화면에서만 켜는 것이 좋습니다. +- `Modifier.chartCaptureModifier()`는 offscreen 기록 비용이 있으므로 실제 이미지 내보내기가 필요한 차트에만 적용하세요. + ### 범례 ```kotlin diff --git a/compose-chart/src/androidTest/java/com/inseong/composechart/SelectionCallbackTest.kt b/compose-chart/src/androidTest/java/com/inseong/composechart/SelectionCallbackTest.kt index 93334dc..65d5c7d 100644 --- a/compose-chart/src/androidTest/java/com/inseong/composechart/SelectionCallbackTest.kt +++ b/compose-chart/src/androidTest/java/com/inseong/composechart/SelectionCallbackTest.kt @@ -3,6 +3,9 @@ package com.inseong.composechart import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.junit4.createComposeRule @@ -21,6 +24,7 @@ import com.inseong.composechart.gauge.GaugeChart import com.inseong.composechart.line.LineChart import com.inseong.composechart.radar.RadarChart import com.inseong.composechart.style.DonutChartStyle +import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Rule import org.junit.Test @@ -65,6 +69,39 @@ class SelectionCallbackTest { composeTestRule.mainClock.advanceTimeBy(500) } + @Test + fun lineChart_onSelectionChanged_sameTouchRecomposition_doesNotRepeat() { + var callbackCount = 0 + var recompositionTick by mutableIntStateOf(0) + + composeTestRule.setContent { + LineChart( + data = LineChartData.fromValues(values = listOf(10f, 25f, 18f)), + modifier = wideModifier, + accessibilityLabel = "선 차트 $recompositionTick", + onSelectionChanged = { callbackCount++ }, + ) + } + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(1000) + composeTestRule + .onNodeWithContentDescription("선 차트 0, 1개 시리즈, 3개 데이터 포인트") + .performTouchInput { + down(Offset(x = 1f, y = centerY)) + } + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.runOnIdle { + assertEquals(1, callbackCount) + recompositionTick = 1 + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + assertEquals("Selection callback should not repeat for the same touch selection", 1, callbackCount) + } + composeTestRule.onRoot().performTouchInput { up() } + composeTestRule.mainClock.advanceTimeBy(500) + } + @Test fun barChart_onSelectionChanged_emitsBarSelection() { var selection: ChartSelection.Bar? = null diff --git a/compose-chart/src/main/java/com/inseong/composechart/bar/BarChart.kt b/compose-chart/src/main/java/com/inseong/composechart/bar/BarChart.kt index 0984895..82e70b5 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/bar/BarChart.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/bar/BarChart.kt @@ -4,10 +4,11 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius @@ -21,6 +22,9 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick @@ -108,11 +112,11 @@ fun BarChart( val resolvedAxisStyle = style.axis.copy( labelColor = ChartDefaults.resolveAxisLabelColor(style.axis.labelColor, isDark), ) + val density = LocalDensity.current + var chartSize by remember { mutableStateOf(IntSize.Zero) } val progress by rememberChartAnimation(style.animationDurationMs, animationKey = data) var touchOffset by remember { mutableStateOf(null) } - var selectedGroupIndex by remember { mutableIntStateOf(-1) } - var selectedEntryIndex by remember { mutableIntStateOf(-1) } // Filter valid groups (only groups with entries) val validGroups = remember(data) { @@ -121,22 +125,124 @@ fun BarChart( if (validGroups.isEmpty()) return // Calculate data range (safeValues guard against NaN/negative) - val maxValue = validGroups.maxOf { group -> - group.entries.maxOfOrNull { entry -> - entry.safeValues.sum() - } ?: 0f + val maxValue = remember(validGroups) { + validGroups.maxOf { group -> + group.entries.maxOfOrNull { entry -> + entry.safeTotal + } ?: 0f + } + } + val adjustedMax = remember(maxValue, style.axis.yAxisMax) { + style.axis.yAxisMax?.coerceAtLeast(maxValue) ?: BarMath.calculateAdjustedMax(maxValue) } - val adjustedMax = style.axis.yAxisMax?.coerceAtLeast(maxValue) - ?: BarMath.calculateAdjustedMax(maxValue) val chartPaddingPx = style.chart.chartPadding + val chartArea = remember( + chartSize, + chartPaddingPx, + resolvedAxisStyle.showYAxis, + resolvedAxisStyle.showXAxis, + density, + ) { + with(density) { + val paddingPx = chartPaddingPx.toPx() + val yAxisWidth = if (resolvedAxisStyle.showYAxis) 40.dp.toPx() else 0f + val xAxisHeight = if (resolvedAxisStyle.showXAxis) 20.dp.toPx() else 0f + Rect( + left = paddingPx + yAxisWidth, + top = paddingPx, + right = chartSize.width.toFloat() - paddingPx, + bottom = chartSize.height.toFloat() - paddingPx - xAxisHeight, + ) + } + } + + LaunchedEffect( + zoomState, + chartArea.width, + chartArea.height, + zoomState?.scale, + zoomState?.offsetX, + zoomState?.offsetY, + ) { + if (chartArea.width > 0f && chartArea.height > 0f) { + zoomState?.clampOffset(chartArea.width, chartArea.height) + } + } + + val zScale = zoomState?.scale ?: 1f + val zOffsetX = zoomState?.offsetX ?: 0f + val zOffsetY = zoomState?.offsetY ?: 0f + val groupCount = validGroups.size + val groupSpacingPx = with(density) { style.groupSpacing.toPx() } + val barSpacingPx = with(density) { style.barSpacing.toPx() } + val groupWidth = remember(chartArea.width, groupCount, groupSpacingPx) { + BarMath.calculateGroupWidth(chartArea.width, groupCount, groupSpacingPx) + } + val groupLabels = remember(validGroups) { validGroups.map { it.label } } + + val currentTouch = remember(touchOffset, zoomState, zScale, zOffsetX, zOffsetY) { + touchOffset?.let { touch -> + if (zoomState != null && zoomState.isZoomed) { + Offset( + (touch.x - zOffsetX) / zScale, + (touch.y - zOffsetY) / zScale, + ) + } else { + touch + } + } + } + + val selectedBarSelection = remember( + currentTouch, + validGroups, + chartArea, + groupWidth, + groupSpacingPx, + barSpacingPx, + ) { + if (currentTouch == null || chartArea.width <= 0f || chartArea.height <= 0f) { + null + } else { + val groupIndex = BarMath.findTouchedGroupIndex( + touchX = currentTouch.x, + chartLeft = chartArea.left, + groupWidth = groupWidth, + groupSpacingPx = groupSpacingPx, + groupCount = groupCount, + ) + val group = validGroups.getOrNull(groupIndex) + val entryCount = group?.entries?.size ?: 0 + if (group == null || entryCount == 0) { + null + } else { + val groupLeft = chartArea.left + groupIndex * (groupWidth + groupSpacingPx) + val barWidth = BarMath.calculateBarWidth(groupWidth, entryCount, barSpacingPx) + val entryIndex = if (entryCount > 1) { + BarMath.findTouchedEntryIndex( + touchX = currentTouch.x, + groupLeft = groupLeft, + barWidth = barWidth, + barSpacingPx = barSpacingPx, + entryCount = entryCount, + ) + } else { + 0 + } + ChartSelection.Bar(groupIndex, entryIndex, 0) + } + } + } + val selectedGroupIndex = selectedBarSelection?.groupIndex ?: -1 + val selectedEntryIndex = selectedBarSelection?.entryIndex ?: -1 val accessibilityDescription = "$accessibilityLabel, ${validGroups.size}개 그룹" val selectionDescription = if (selectedGroupIndex >= 0 && selectedGroupIndex < validGroups.size) { val group = validGroups[selectedGroupIndex] val entry = group.entries.getOrNull(selectedEntryIndex.coerceAtLeast(0)) val label = group.label.takeIf { it.isNotEmpty() } ?: "${selectedGroupIndex + 1}번 그룹" - val value = entry?.safeValues?.sum() + val value = entry?.safeTotal if (value != null) { "선택된 그룹: $label, 값 ${ChartDefaults.formatSemanticsValue(value)}" } else { @@ -148,10 +254,6 @@ fun BarChart( val touchCallback: (Offset?) -> Unit = { offset -> touchOffset = offset - if (offset == null) { - selectedGroupIndex = -1 - selectedEntryIndex = -1 - } } val touchModifier = if (zoomState != null) { Modifier.chartTouchHandlerWithZoom(zoomState = zoomState, onTouch = touchCallback) @@ -159,6 +261,15 @@ fun BarChart( Modifier.chartTouchHandler(onTouch = touchCallback) } + val currentOnBarSelected by rememberUpdatedState(onBarSelected) + val currentOnSelectionChanged by rememberUpdatedState(onSelectionChanged) + LaunchedEffect(selectedBarSelection) { + selectedBarSelection?.let { selection -> + currentOnBarSelected?.invoke(selection.groupIndex, selection.entryIndex, selection.stackIndex) + currentOnSelectionChanged?.invoke(selection) + } + } + Canvas( modifier = modifier .semantics { @@ -167,18 +278,10 @@ fun BarChart( onClickLabel?.let { label -> onClick(label = label, action = null) } } .fillMaxWidth() + .onSizeChanged { chartSize = it } .then(touchModifier), ) { - val paddingPx = chartPaddingPx.toPx() - val yAxisWidth = if (resolvedAxisStyle.showYAxis) 40.dp.toPx() else 0f - val xAxisHeight = if (resolvedAxisStyle.showXAxis) 20.dp.toPx() else 0f - - val chartArea = Rect( - left = paddingPx + yAxisWidth, - top = paddingPx, - right = size.width - paddingPx, - bottom = size.height - paddingPx - xAxisHeight, - ) + if (chartArea.width <= 0f || chartArea.height <= 0f) return@Canvas // Draw grid and axes drawGrid(resolvedGridStyle, chartArea, resolvedAxisStyle.yLabelCount) @@ -187,49 +290,13 @@ fun BarChart( drawYAxisLabels(0f, adjustedMax, resolvedAxisStyle, chartArea) } - // Bar layout calculation - if (chartArea.width <= 0f || chartArea.height <= 0f) return@Canvas - - // Clamp zoom offsets - zoomState?.clampOffset(chartArea.width, chartArea.height) - val zScale = zoomState?.scale ?: 1f - val zOffsetX = zoomState?.offsetX ?: 0f - val zOffsetY = zoomState?.offsetY ?: 0f - - val groupCount = validGroups.size - val groupSpacingPx = style.groupSpacing.toPx() - val barSpacingPx = style.barSpacing.toPx() - val groupWidth = BarMath.calculateGroupWidth(chartArea.width, groupCount, groupSpacingPx) - // X-axis labels (centered under each bar group) if (resolvedAxisStyle.showXAxis) { - val labels = validGroups.map { it.label } - if (labels.any { it.isNotEmpty() }) { - drawXAxisLabels(labels, resolvedAxisStyle, chartArea, groupWidth, groupSpacingPx) + if (groupLabels.any { it.isNotEmpty() }) { + drawXAxisLabels(groupLabels, resolvedAxisStyle, chartArea, groupWidth, groupSpacingPx) } } - // Inverse-transform touch coordinates for accurate hit testing under zoom - val currentTouch = touchOffset?.let { touch -> - if (zoomState != null && zoomState.isZoomed) { - Offset( - (touch.x - zOffsetX) / zScale, - (touch.y - zOffsetY) / zScale, - ) - } else { - touch - } - } - - if (currentTouch != null) { - selectedGroupIndex = BarMath.findTouchedGroupIndex( - touchX = currentTouch.x, chartLeft = chartArea.left, - groupWidth = groupWidth, groupSpacingPx = groupSpacingPx, groupCount = groupCount, - ) - } else { - selectedEntryIndex = -1 - } - // Clip to chart area and apply zoom transform clipRect( left = chartArea.left, @@ -242,6 +309,7 @@ fun BarChart( scale(zScale, zScale, Offset(chartArea.left, chartArea.top)) }) { // Draw bars and tooltips + val cornerRadiusPx = style.cornerRadius.toPx() validGroups.forEachIndexed { groupIndex, group -> val groupLeft = chartArea.left + groupIndex * (groupWidth + groupSpacingPx) val entryCount = group.entries.size @@ -266,7 +334,7 @@ fun BarChart( maxValue = adjustedMax, progress = progress, alpha = alpha, - cornerRadius = style.cornerRadius.toPx(), + cornerRadius = cornerRadiusPx, ) } else { drawVerticalStackedBar( @@ -278,26 +346,15 @@ fun BarChart( maxValue = adjustedMax, progress = progress, alpha = alpha, - cornerRadius = style.cornerRadius.toPx(), + cornerRadius = cornerRadiusPx, ) } } // Show tooltip for selected group - if (selectedGroupIndex == groupIndex && group.entries.isNotEmpty()) { - // Determine which entry was touched within the group - val touchedEntryIndex = if (currentTouch != null && entryCount > 1) { - BarMath.findTouchedEntryIndex( - touchX = currentTouch.x, groupLeft = groupLeft, - barWidth = barWidth, barSpacingPx = barSpacingPx, entryCount = entryCount, - ) - } else { - 0 - } - selectedEntryIndex = touchedEntryIndex - - val entry = group.entries[touchedEntryIndex] - val totalValue = entry.safeValues.sum() + if (selectedGroupIndex == groupIndex && selectedEntryIndex in group.entries.indices) { + val entry = group.entries[selectedEntryIndex] + val totalValue = entry.safeTotal val formattedValue = ChartMath.formatValue(totalValue) val tooltipText = if (group.label.isNotEmpty()) { "${group.label}: $formattedValue" @@ -305,7 +362,7 @@ fun BarChart( formattedValue } - val barLeft = groupLeft + touchedEntryIndex * (barWidth + barSpacingPx) + val barLeft = groupLeft + selectedEntryIndex * (barWidth + barSpacingPx) val barHeight = (totalValue / adjustedMax) * chartArea.height * progress val tooltipX = barLeft + barWidth / 2 val tooltipY = chartArea.bottom - barHeight @@ -314,14 +371,9 @@ fun BarChart( position = Offset(tooltipX, tooltipY), text = tooltipText, style = style.tooltip, - lineColor = colors[touchedEntryIndex % colors.size], + lineColor = colors[selectedEntryIndex % colors.size], canvasSize = size, ) - - onBarSelected?.invoke(groupIndex, touchedEntryIndex, 0) - onSelectionChanged?.invoke( - ChartSelection.Bar(groupIndex, touchedEntryIndex, 0), - ) } } } diff --git a/compose-chart/src/main/java/com/inseong/composechart/data/BarChartData.kt b/compose-chart/src/main/java/com/inseong/composechart/data/BarChartData.kt index eaefb06..6ee60da 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/data/BarChartData.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/data/BarChartData.kt @@ -21,6 +21,11 @@ data class BarEntry( internal val safeValues: List by lazy { values.map { if (it.isFinite()) it.coerceAtLeast(0f) else 0f } } + + /** Cached total of [safeValues], used by layout and accessibility calculations. */ + internal val safeTotal: Float by lazy { + safeValues.sum() + } } /** diff --git a/compose-chart/src/main/java/com/inseong/composechart/donut/DonutChart.kt b/compose-chart/src/main/java/com/inseong/composechart/donut/DonutChart.kt index 7ec40cb..ec8fdab 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/donut/DonutChart.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/donut/DonutChart.kt @@ -6,10 +6,11 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -18,10 +19,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntSize import com.inseong.composechart.ChartDefaults import com.inseong.composechart.ChartSelection import com.inseong.composechart.data.DonutChartData @@ -32,6 +36,13 @@ import com.inseong.composechart.internal.touch.chartTouchHandler import com.inseong.composechart.style.DonutChartStyle import kotlin.math.min +private data class DonutGeometry( + val centerX: Float, + val centerY: Float, + val radius: Float, + val holeRadius: Float, +) + /** * Donut chart Composable. * @@ -83,17 +94,60 @@ fun DonutChart( onSelectionChanged: ((ChartSelection.Donut) -> Unit)? = null, onSliceSelected: ((index: Int, slice: DonutSlice) -> Unit)? = null, ) { + val density = LocalDensity.current + var chartSize by remember { mutableStateOf(IntSize.Zero) } val progress by rememberChartAnimation(style.animationDurationMs, animationKey = data) - var selectedIndex by remember { mutableIntStateOf(-1) } var touchOffset by remember { mutableStateOf(null) } // Filter valid slices (value > 0) val validSlices = remember(data) { data.slices.filter { it.value > 0f } } if (validSlices.isEmpty()) return - val total = validSlices.sumOf { it.value.toDouble() }.toFloat() + val sliceValues = remember(validSlices) { validSlices.map { it.value } } + val total = remember(validSlices) { validSlices.sumOf { it.value.toDouble() }.toFloat() } if (total == 0f) return + val geometry = remember(chartSize, style.chart.chartPadding, style.holeRadius, density) { + with(density) { + val paddingPx = style.chart.chartPadding.toPx() + val radius = min(chartSize.width.toFloat(), chartSize.height.toFloat()) / 2 - paddingPx + DonutGeometry( + centerX = chartSize.width / 2f, + centerY = chartSize.height / 2f, + radius = radius, + holeRadius = radius * style.holeRadius.coerceIn(0f, 0.95f), + ) + } + } + + val selectedIndex = remember(touchOffset, geometry, sliceValues, total, style.startAngle) { + val currentTouch = touchOffset + if (currentTouch == null || geometry.radius <= 0f) { + -1 + } else { + DonutMath.findTouchedSliceIndex( + touchX = currentTouch.x, + touchY = currentTouch.y, + centerX = geometry.centerX, + centerY = geometry.centerY, + outerRadius = geometry.radius, + holeRadius = geometry.holeRadius, + sliceValues = sliceValues, + total = total, + startAngle = style.startAngle, + ) + } + } + + val currentOnSliceSelected by rememberUpdatedState(onSliceSelected) + val currentOnSelectionChanged by rememberUpdatedState(onSelectionChanged) + LaunchedEffect(selectedIndex, validSlices) { + validSlices.getOrNull(selectedIndex)?.let { slice -> + currentOnSliceSelected?.invoke(selectedIndex, slice) + currentOnSelectionChanged?.invoke(ChartSelection.Donut(selectedIndex, slice)) + } + } + // Scale animation for selected slice val selectedScales = validSlices.indices.map { index -> animateFloatAsState( @@ -119,16 +173,15 @@ fun DonutChart( } .chartTouchHandler { offset -> touchOffset = offset - if (offset == null) selectedIndex = -1 - }, + } + .onSizeChanged { chartSize = it }, ) { - val paddingPx = style.chart.chartPadding.toPx() - val centerX = size.width / 2 - val centerY = size.height / 2 - val radius = min(size.width, size.height) / 2 - paddingPx + val centerX = geometry.centerX + val centerY = geometry.centerY + val radius = geometry.radius if (radius <= 0f) return@Canvas - val holeRadiusPx = radius * style.holeRadius.coerceIn(0f, 0.95f) + val holeRadiusPx = geometry.holeRadius // Spacing angle between slices val spacingAngle = DonutMath.calculateSpacingAngle( @@ -137,26 +190,17 @@ fun DonutChart( radius = radius, ) - // Touch detection: determine slice by angle and distance - val currentTouch = touchOffset - if (currentTouch != null) { - val touchedIndex = DonutMath.findTouchedSliceIndex( - touchX = currentTouch.x, touchY = currentTouch.y, - centerX = centerX, centerY = centerY, - outerRadius = radius, holeRadius = holeRadiusPx, - sliceValues = validSlices.map { it.value }, - total = total, startAngle = style.startAngle, - ) - if (touchedIndex >= 0) { - if (selectedIndex != touchedIndex) { - selectedIndex = touchedIndex - val slice = validSlices[touchedIndex] - onSliceSelected?.invoke(touchedIndex, slice) - onSelectionChanged?.invoke(ChartSelection.Donut(touchedIndex, slice)) - } - } else { - selectedIndex = -1 + val labelPaint = if (style.showLabels && progress > 0.8f) { + val scaledTextSize = (radius * 0.12f).coerceIn(8f * this.density, 14f * this.density) + Paint().apply { + color = android.graphics.Color.WHITE + textSize = scaledTextSize + textAlign = Paint.Align.CENTER + isAntiAlias = true + typeface = Typeface.DEFAULT_BOLD } + } else { + null } // Draw slices @@ -211,7 +255,7 @@ fun DonutChart( // Draw slice label // Skip labels when chart is too small or slice is too narrow (< 15 degrees) - val minLabelRadius = 40f * density + val minLabelRadius = 40f * this.density if (style.showLabels && slice.label.isNotEmpty() && progress > 0.8f && rawSweep >= 15f && radius >= minLabelRadius) { // Donut: midpoint between hole and outer edge, Filled: 65% of radius val labelRadius = if (style.holeRadius > 0f) { @@ -226,9 +270,9 @@ fun DonutChart( centerX = arcCenterX, centerY = arcCenterY, labelRadius = labelRadius, - chartRadius = radius, canvasWidth = size.width, canvasHeight = size.height, + paint = labelPaint ?: return@forEachIndexed, ) } @@ -249,9 +293,9 @@ private fun DrawScope.drawSliceLabel( centerX: Float, centerY: Float, labelRadius: Float, - chartRadius: Float, canvasWidth: Float, canvasHeight: Float, + paint: Paint, ) { val (labelX, labelY) = DonutMath.calculateLabelAnchor( midAngleDegrees = midAngle, @@ -260,20 +304,9 @@ private fun DrawScope.drawSliceLabel( labelRadius = labelRadius, ) - // Text size proportional to chart size (min 8dp, max ~14dp) - val scaledTextSize = (chartRadius * 0.12f).coerceIn(8f * density, 14f * density) - - val paint = Paint().apply { - color = android.graphics.Color.WHITE - textSize = scaledTextSize - textAlign = Paint.Align.CENTER - isAntiAlias = true - typeface = Typeface.DEFAULT_BOLD - } - // Measure text size val textWidth = paint.measureText(label) - val textHeight = scaledTextSize + val textHeight = paint.textSize // Do not draw if text extends beyond canvas bounds if (!DonutMath.isLabelWithinBounds( diff --git a/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/GridDrawer.kt b/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/GridDrawer.kt index 4a63a46..115b930 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/GridDrawer.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/GridDrawer.kt @@ -19,10 +19,10 @@ internal fun DrawScope.drawGrid( style: GridStyle, chartArea: Rect, horizontalCount: Int, -) { - val strokeWidthPx = style.strokeWidth.toPx() - val pathEffect = style.dashPattern?.let { - PathEffect.dashPathEffect(it.toFloatArray(), 0f) + ) { + val strokeWidthPx = style.strokeWidth.toPx() + val pathEffect = style.dashPatternArray?.let { + PathEffect.dashPathEffect(it, 0f) } // Horizontal grid lines diff --git a/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/TooltipDrawer.kt b/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/TooltipDrawer.kt index 940f945..0cced74 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/TooltipDrawer.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/TooltipDrawer.kt @@ -4,13 +4,9 @@ import android.graphics.Paint import android.graphics.Typeface import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Fill -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas import com.inseong.composechart.internal.canvas.toTypefaceStyle import com.inseong.composechart.style.TooltipStyle @@ -76,18 +72,12 @@ internal fun DrawScope.drawTooltip( } // Draw tooltip background - val bubblePath = Path().apply { - addRoundRect( - RoundRect( - left = bubbleLeft, - top = bubbleTop, - right = bubbleLeft + bubbleWidth, - bottom = bubbleTop + bubbleHeight, - cornerRadius = CornerRadius(cornerRadius), - ) - ) - } - drawPath(path = bubblePath, color = style.backgroundColor, style = Fill) + drawRoundRect( + color = style.backgroundColor, + topLeft = Offset(bubbleLeft, bubbleTop), + size = Size(bubbleWidth, bubbleHeight), + cornerRadius = CornerRadius(cornerRadius), + ) // Draw tooltip text drawContext.canvas.nativeCanvas.drawText( diff --git a/compose-chart/src/main/java/com/inseong/composechart/line/LineChart.kt b/compose-chart/src/main/java/com/inseong/composechart/line/LineChart.kt index d41749e..d8def93 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/line/LineChart.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/line/LineChart.kt @@ -5,17 +5,23 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.inseong.composechart.ChartDefaults import com.inseong.composechart.ChartSelection @@ -41,6 +47,14 @@ import com.inseong.composechart.internal.touch.chartTouchHandlerWithZoom import com.inseong.composechart.internal.touch.findNearestPointIndex import com.inseong.composechart.style.LineChartStyle +private data class LineSeriesLayout( + val seriesIndex: Int, + val color: Color, + val points: List, + val path: Path, + val xPositions: List, +) + /** * Line chart Composable. * @@ -107,28 +121,142 @@ fun LineChart( val resolvedAxisStyle = style.axis.copy( labelColor = ChartDefaults.resolveAxisLabelColor(style.axis.labelColor, isDark), ) + val density = LocalDensity.current + var chartSize by remember { mutableStateOf(IntSize.Zero) } // Animation progress (0 -> 1) val progress by rememberChartAnimation(style.animationDurationMs, animationKey = data) // Touch state var touchOffset by remember { mutableStateOf(null) } - var selectedPointSummary by remember { mutableStateOf(null) } // Filter valid series only (series with points) val validSeries = remember(data) { data.series.filter { it.points.isNotEmpty() } } if (validSeries.isEmpty()) return // Calculate data range (safeX/safeY guard against NaN/Infinity) - val allPoints = validSeries.flatMap { it.points } - val xyRange = ChartMath.calculateXYRange( - xValues = allPoints.map { it.safeX }, - yValues = allPoints.map { it.safeY }, - yAxisMin = style.axis.yAxisMin, - yAxisMax = style.axis.yAxisMax, - ) + val allPoints = remember(validSeries) { validSeries.flatMap { it.points } } + val xyRange = remember(allPoints, style.axis.yAxisMin, style.axis.yAxisMax) { + ChartMath.calculateXYRange( + xValues = allPoints.map { it.safeX }, + yValues = allPoints.map { it.safeY }, + yAxisMin = style.axis.yAxisMin, + yAxisMax = style.axis.yAxisMax, + ) + } val chartPaddingPx = style.chart.chartPadding + val chartArea = remember( + chartSize, + chartPaddingPx, + resolvedAxisStyle.showYAxis, + resolvedAxisStyle.showXAxis, + density, + ) { + with(density) { + val paddingPx = chartPaddingPx.toPx() + val yAxisWidth = if (resolvedAxisStyle.showYAxis) 40.dp.toPx() else 0f + val xAxisHeight = if (resolvedAxisStyle.showXAxis) 20.dp.toPx() else 0f + Rect( + left = paddingPx + yAxisWidth, + top = paddingPx, + right = chartSize.width.toFloat() - paddingPx, + bottom = chartSize.height.toFloat() - paddingPx - xAxisHeight, + ) + } + } + + LaunchedEffect( + zoomState, + chartArea.width, + chartArea.height, + zoomState?.scale, + zoomState?.offsetX, + zoomState?.offsetY, + ) { + if (chartArea.width > 0f && chartArea.height > 0f) { + zoomState?.clampOffset(chartArea.width, chartArea.height) + } + } + + val zScale = zoomState?.scale ?: 1f + val zOffsetX = zoomState?.offsetX ?: 0f + val zOffsetY = zoomState?.offsetY ?: 0f + + val lineLayouts = remember(validSeries, xyRange, chartArea, style.curved, colors) { + if (chartArea.width <= 0f || chartArea.height <= 0f) { + emptyList() + } else { + validSeries.mapIndexed { seriesIndex, series -> + val seriesColor = if (series.color == Color.Unspecified) { + colors[seriesIndex % colors.size] + } else { + series.color + } + val mappedPoints = series.points.map { point -> + val (cx, cy) = ChartMath.mapToCanvas( + dataX = point.safeX, + dataY = point.safeY, + range = xyRange, + chartLeft = chartArea.left, + chartBottom = chartArea.bottom, + chartWidth = chartArea.width, + chartHeight = chartArea.height, + ) + Offset(cx, cy) + } + LineSeriesLayout( + seriesIndex = seriesIndex, + color = seriesColor, + points = mappedPoints, + path = if (style.curved) mappedPoints.toBezierPath() else mappedPoints.toLinearPath(), + xPositions = mappedPoints.map { it.x }, + ) + } + } + } + + val currentTouch = remember(touchOffset, zoomState, zScale, zOffsetX, zOffsetY) { + touchOffset?.let { touch -> + if (zoomState != null && zoomState.isZoomed) { + Offset( + (touch.x - zOffsetX) / zScale, + (touch.y - zOffsetY) / zScale, + ) + } else { + touch + } + } + } + + val selectedLineSelections = remember(currentTouch, lineLayouts, validSeries, style.showTooltipOnTouch) { + if (currentTouch == null || !style.showTooltipOnTouch) { + emptyList() + } else { + lineLayouts.mapNotNull { layout -> + val nearestIndex = findNearestPointIndex(currentTouch.x, layout.xPositions) + val dataPoint = validSeries[layout.seriesIndex].points.getOrNull(nearestIndex) + dataPoint?.let { ChartSelection.Line(layout.seriesIndex, nearestIndex, it) } + } + } + } + + val selectedPointSummary = remember(selectedLineSelections, data.xLabels) { + selectedLineSelections.firstOrNull { it.seriesIndex == 0 }?.let { selection -> + val xLabel = data.xLabels.getOrNull(selection.pointIndex).orEmpty() + val labelPrefix = if (xLabel.isNotEmpty()) "$xLabel, " else "" + "선택된 포인트: ${labelPrefix}값 ${ChartDefaults.formatSemanticsValue(selection.point.y)}" + } + } + + val currentOnPointSelected by rememberUpdatedState(onPointSelected) + val currentOnSelectionChanged by rememberUpdatedState(onSelectionChanged) + LaunchedEffect(selectedLineSelections) { + selectedLineSelections.forEach { selection -> + currentOnPointSelected?.invoke(selection.seriesIndex, selection.pointIndex, selection.point) + currentOnSelectionChanged?.invoke(selection) + } + } val accessibilityDescription = buildString { append("$accessibilityLabel, ${validSeries.size}개 시리즈, ${allPoints.size}개 데이터 포인트") @@ -151,22 +279,10 @@ fun LineChart( onClickLabel?.let { label -> onClick(label = label, action = null) } } .fillMaxWidth() + .onSizeChanged { chartSize = it } .then(touchModifier), ) { - val paddingPx = chartPaddingPx.toPx() - - // Y-axis label area width - val yAxisWidth = if (resolvedAxisStyle.showYAxis) 40.dp.toPx() else 0f - // X-axis label area height - val xAxisHeight = if (resolvedAxisStyle.showXAxis) 20.dp.toPx() else 0f - - // Chart data area (excluding axis labels and padding) - val chartArea = Rect( - left = paddingPx + yAxisWidth, - top = paddingPx, - right = size.width - paddingPx, - bottom = size.height - paddingPx - xAxisHeight, - ) + if (chartArea.width <= 0f || chartArea.height <= 0f) return@Canvas // Draw grid drawGrid(resolvedGridStyle, chartArea, resolvedAxisStyle.yLabelCount) @@ -181,27 +297,6 @@ fun LineChart( drawXAxisLabels(data.xLabels, resolvedAxisStyle, chartArea) } - if (chartArea.width <= 0f || chartArea.height <= 0f) return@Canvas - - // Clamp zoom offsets - zoomState?.clampOffset(chartArea.width, chartArea.height) - val zScale = zoomState?.scale ?: 1f - val zOffsetX = zoomState?.offsetX ?: 0f - val zOffsetY = zoomState?.offsetY ?: 0f - - // Inverse-transform touch coordinates for accurate hit testing under zoom - val currentTouch = touchOffset?.let { touch -> - if (zoomState != null && zoomState.isZoomed) { - Offset( - (touch.x - zOffsetX) / zScale, - (touch.y - zOffsetY) / zScale, - ) - } else { - touch - } - } - if (currentTouch == null) selectedPointSummary = null - // Clip to chart area and apply zoom transform clipRect( left = chartArea.left, @@ -214,36 +309,10 @@ fun LineChart( scale(zScale, zScale, Offset(chartArea.left, chartArea.top)) }) { // Draw each series - validSeries.forEachIndexed { seriesIndex, series -> - - // Determine series color - val seriesColor = if (series.color == Color.Unspecified) { - colors[seriesIndex % colors.size] - } else { - series.color - } - - // Map data points to canvas coordinates (using safeX/safeY) - val mappedPoints = series.points.map { point -> - val (cx, cy) = ChartMath.mapToCanvas( - dataX = point.safeX, dataY = point.safeY, - range = xyRange, - chartLeft = chartArea.left, chartBottom = chartArea.bottom, - chartWidth = chartArea.width, chartHeight = chartArea.height, - ) - Offset(cx, cy) - } - - // Create line path (curved or straight) - val linePath = if (style.curved) { - mappedPoints.toBezierPath() - } else { - mappedPoints.toLinearPath() - } - - // Animation: clip left-to-right based on progress - val clipRight = chartArea.left + chartArea.width * progress - + val clipRight = chartArea.left + chartArea.width * progress + val lineWidthPx = style.lineWidth.toPx() + val dotRadiusPx = style.dotRadius.toPx() + lineLayouts.forEach { layout -> clipRect( left = chartArea.left, top = chartArea.top, @@ -251,81 +320,60 @@ fun LineChart( bottom = chartArea.bottom, ) { // Gradient area fill - if (style.gradientFill && mappedPoints.size >= 2) { + if (style.gradientFill && layout.points.size >= 2) { drawGradientFill( - linePath = linePath, - color = seriesColor, + linePath = layout.path, + color = layout.color, alpha = style.gradientAlpha, bottomY = chartArea.bottom, - startX = mappedPoints.first().x, - endX = mappedPoints.last().x, + startX = layout.points.first().x, + endX = layout.points.last().x, ) } // Draw line drawPath( - path = linePath, - color = seriesColor, - style = Stroke(width = style.lineWidth.toPx()), + path = layout.path, + color = layout.color, + style = Stroke(width = lineWidthPx), ) // Draw data point dots if (style.showDots) { - mappedPoints.forEach { point -> + layout.points.forEach { point -> drawCircle( - color = seriesColor, - radius = style.dotRadius.toPx(), + color = layout.color, + radius = dotRadiusPx, center = point, ) } } } + } - // Touch interaction handling - if (currentTouch != null && style.showTooltipOnTouch) { - val pointXPositions = mappedPoints.map { it.x } - val nearestIndex = findNearestPointIndex(currentTouch.x, pointXPositions) - - if (nearestIndex >= 0 && nearestIndex < mappedPoints.size) { - val nearestPoint = mappedPoints[nearestIndex] - val dataPoint = series.points[nearestIndex] - if (seriesIndex == 0) { - val xLabel = data.xLabels.getOrNull(nearestIndex).orEmpty() - val labelPrefix = if (xLabel.isNotEmpty()) "$xLabel, " else "" - selectedPointSummary = - "선택된 포인트: ${labelPrefix}값 ${ChartDefaults.formatSemanticsValue(dataPoint.y)}" - } - - // Draw vertical indicator line only for the first series (avoid duplicates) - if (seriesIndex == 0) { - drawVerticalIndicatorLine( - x = nearestPoint.x, - topY = chartArea.top, - bottomY = chartArea.bottom, - ) - } - - // Determine tooltip text - val tooltipText = dataPoint.label.ifEmpty { - ChartMath.formatValue(dataPoint.y) - } - - // Draw tooltip - drawTooltip( - position = nearestPoint, - text = tooltipText, - style = style.tooltip, - lineColor = seriesColor, - canvasSize = size, - ) + selectedLineSelections.firstOrNull { it.seriesIndex == 0 }?.let { selection -> + lineLayouts.getOrNull(selection.seriesIndex)?.points?.getOrNull(selection.pointIndex)?.let { point -> + drawVerticalIndicatorLine( + x = point.x, + topY = chartArea.top, + bottomY = chartArea.bottom, + ) + } + } - // Invoke callbacks (deprecated + new sealed-type variant) - onPointSelected?.invoke(seriesIndex, nearestIndex, dataPoint) - onSelectionChanged?.invoke( - ChartSelection.Line(seriesIndex, nearestIndex, dataPoint), - ) - } + selectedLineSelections.forEach { selection -> + val layout = lineLayouts.getOrNull(selection.seriesIndex) ?: return@forEach + val nearestPoint = layout.points.getOrNull(selection.pointIndex) ?: return@forEach + val tooltipText = selection.point.label.ifEmpty { + ChartMath.formatValue(selection.point.y) } + drawTooltip( + position = nearestPoint, + text = tooltipText, + style = style.tooltip, + lineColor = layout.color, + canvasSize = size, + ) } } } diff --git a/compose-chart/src/main/java/com/inseong/composechart/pie/PieChart.kt b/compose-chart/src/main/java/com/inseong/composechart/pie/PieChart.kt index b6d7fea..5a39837 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/pie/PieChart.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/pie/PieChart.kt @@ -1,6 +1,7 @@ package com.inseong.composechart.pie import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.inseong.composechart.ChartDefaults @@ -56,10 +57,8 @@ fun PieChart( onSelectionChanged: ((ChartSelection.Donut) -> Unit)? = null, onSliceSelected: ((index: Int, slice: DonutSlice) -> Unit)? = null, ) { - DonutChart( - data = data, - modifier = modifier, - style = DonutChartStyle( + val donutStyle = remember(style) { + DonutChartStyle( holeRadius = 0f, sliceSpacing = style.sliceSpacing, selectedScale = style.selectedScale, @@ -67,7 +66,13 @@ fun PieChart( animationDurationMs = style.animationDurationMs, startAngle = style.startAngle, chart = style.chart, - ), + ) + } + + DonutChart( + data = data, + modifier = modifier, + style = donutStyle, colors = colors, accessibilityLabel = accessibilityLabel, onClickLabel = onClickLabel, diff --git a/compose-chart/src/main/java/com/inseong/composechart/radar/RadarChart.kt b/compose-chart/src/main/java/com/inseong/composechart/radar/RadarChart.kt index 1da8f15..2a86a17 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/radar/RadarChart.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/radar/RadarChart.kt @@ -5,9 +5,11 @@ import android.graphics.Typeface import androidx.compose.foundation.Canvas import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -17,11 +19,14 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntSize import com.inseong.composechart.ChartDefaults import com.inseong.composechart.ChartSelection import com.inseong.composechart.data.RadarChartData @@ -32,6 +37,20 @@ import com.inseong.composechart.internal.touch.chartTouchHandler import com.inseong.composechart.style.RadarChartStyle import kotlin.math.min +private data class RadarGeometry( + val centerX: Float, + val centerY: Float, + val radius: Float, + val axisVertices: List, + val axisLabelPositions: List, + val webPaths: List, +) + +private data class RadarEntryLayout( + val color: Color, + val normalizedValues: List, +) + /** * Radar chart Composable. * @@ -84,11 +103,12 @@ fun RadarChart( val isDark = isSystemInDarkTheme() val resolvedWebColor = ChartDefaults.resolveRadarWebColor(style.webLineColor, isDark) val resolvedLabelColor = ChartDefaults.resolveAxisLabelColor(style.labelColor, isDark) + val density = LocalDensity.current + var chartSize by remember { mutableStateOf(IntSize.Zero) } val progress by rememberChartAnimation(style.animationDurationMs, animationKey = data) var touchOffset by remember { mutableStateOf(null) } - var selectedAxisIndex by remember { mutableStateOf(null) } val axisCount = data.axisLabels.size if (axisCount < 3) return @@ -98,10 +118,116 @@ fun RadarChart( } if (validEntries.isEmpty()) return - val resolvedMaxValue = if (data.maxValue > 0f) { - data.maxValue - } else { - validEntries.flatMap { it.safeValues }.maxOrNull() ?: 1f + val resolvedMaxValue = remember(data.maxValue, validEntries) { + if (data.maxValue > 0f) { + data.maxValue + } else { + (validEntries.asSequence().flatMap { it.safeValues.asSequence() }.maxOrNull() ?: 1f) + .coerceAtLeast(1f) + } + } + + val geometry = remember( + chartSize, + style.chart.chartPadding, + style.labelSize, + style.webLevels, + axisCount, + density, + ) { + with(density) { + val centerX = chartSize.width / 2f + val centerY = chartSize.height / 2f + val radius = min(chartSize.width.toFloat(), chartSize.height.toFloat()) / 2 - + style.chart.chartPadding.toPx() - + style.labelSize.toPx() * 1.5f + val startAngle = -90f + val angleStep = 360f / axisCount + val axisVertices = if (radius > 0f) { + (0 until axisCount).map { i -> + val (x, y) = RadarMath.polarToCartesian(centerX, centerY, startAngle + angleStep * i, radius) + Offset(x, y) + } + } else { + emptyList() + } + val labelOffset = style.labelSize.toPx() * 1.2f + val axisLabelPositions = if (radius > 0f) { + (0 until axisCount).map { i -> + val (x, y) = RadarMath.calculateAxisLabelPosition( + axisIndex = i, + axisCount = axisCount, + centerX = centerX, + centerY = centerY, + radius = radius, + startAngleDegrees = startAngle, + labelOffset = labelOffset, + labelBaselineOffset = style.labelSize.toPx() / 3, + ) + Offset(x, y) + } + } else { + emptyList() + } + val webPaths = if (radius > 0f && style.webLevels > 0) { + (1..style.webLevels).map { level -> + val levelRadius = radius * level / style.webLevels + Path().apply { + for (i in 0 until axisCount) { + val (x, y) = RadarMath.polarToCartesian( + centerX, + centerY, + startAngle + angleStep * i, + levelRadius, + ) + if (i == 0) moveTo(x, y) else lineTo(x, y) + } + close() + } + } + } else { + emptyList() + } + RadarGeometry( + centerX = centerX, + centerY = centerY, + radius = radius, + axisVertices = axisVertices, + axisLabelPositions = axisLabelPositions, + webPaths = webPaths, + ) + } + } + + val entryLayouts = remember(validEntries, resolvedMaxValue, axisCount, colors) { + validEntries.mapIndexed { entryIndex, entry -> + val entryColor = if (entry.color == Color.Unspecified) { + colors[entryIndex % colors.size] + } else { + entry.color + } + RadarEntryLayout( + color = entryColor, + normalizedValues = List(axisCount) { i -> + entry.safeValues[i].coerceIn(0f, resolvedMaxValue) / resolvedMaxValue + }, + ) + } + } + + val selectedAxisIndex = remember(touchOffset, geometry.axisVertices) { + touchOffset?.let { touch -> + findNearestAxisIndex(touch, geometry.axisVertices).takeIf { it >= 0 } + } + } + + val currentOnAxisSelected by rememberUpdatedState(onAxisSelected) + val currentOnSelectionChanged by rememberUpdatedState(onSelectionChanged) + LaunchedEffect(selectedAxisIndex) { + selectedAxisIndex?.let { axisIndex -> + currentOnAxisSelected?.invoke(axisIndex) + currentOnSelectionChanged?.invoke(ChartSelection.Radar(axisIndex)) + } } val accessibilityDescription = "$accessibilityLabel, ${axisCount}개 축, ${validEntries.size}개 시리즈" @@ -119,83 +245,61 @@ fun RadarChart( } .chartTouchHandler { offset -> touchOffset = offset - if (offset == null) selectedAxisIndex = null - }, + } + .onSizeChanged { chartSize = it }, ) { - val paddingPx = style.chart.chartPadding.toPx() - val centerX = size.width / 2 - val centerY = size.height / 2 - val radius = min(size.width, size.height) / 2 - paddingPx - style.labelSize.toPx() * 1.5f + val centerX = geometry.centerX + val centerY = geometry.centerY + val radius = geometry.radius if (radius <= 0f) return@Canvas - val angleStep = 360f / axisCount - val startAngle = -90f + val webLineWidthPx = style.webLineWidth.toPx() + val dotRadiusPx = style.dotRadius.toPx() // Draw concentric web levels - for (level in 1..style.webLevels) { - val levelRadius = radius * level / style.webLevels - val webPath = Path() - for (i in 0 until axisCount) { - val (x, y) = RadarMath.polarToCartesian(centerX, centerY, startAngle + angleStep * i, levelRadius) - if (i == 0) webPath.moveTo(x, y) else webPath.lineTo(x, y) - } - webPath.close() + geometry.webPaths.forEach { webPath -> drawPath( path = webPath, color = resolvedWebColor, - style = Stroke(width = style.webLineWidth.toPx()), + style = Stroke(width = webLineWidthPx), ) } // Draw axis lines from center to each vertex - for (i in 0 until axisCount) { - val (x, y) = RadarMath.polarToCartesian(centerX, centerY, startAngle + angleStep * i, radius) + geometry.axisVertices.forEach { vertex -> drawLine( color = resolvedWebColor, start = Offset(centerX, centerY), - end = Offset(x, y), - strokeWidth = style.webLineWidth.toPx(), + end = vertex, + strokeWidth = webLineWidthPx, ) } // Draw axis labels drawAxisLabels( axisLabels = data.axisLabels, - centerX = centerX, - centerY = centerY, - radius = radius, - startAngle = startAngle, + positions = geometry.axisLabelPositions, labelColor = resolvedLabelColor, labelSize = style.labelSize.toPx(), fontWeight = style.labelFontWeight, ) // Draw data polygons - validEntries.forEachIndexed { entryIndex, entry -> - val entryColor = if (entry.color == Color.Unspecified) { - colors[entryIndex % colors.size] - } else { - entry.color - } - - val safeValues = entry.safeValues - val vertices = RadarMath.calculateDataPolygonVertices( - values = safeValues, maxValue = resolvedMaxValue, - axisCount = axisCount, centerX = centerX, centerY = centerY, - radius = radius, startAngle = startAngle, progress = progress, - ) + entryLayouts.forEach { layout -> val dataPath = Path() - val dotPositions = mutableListOf() - vertices.forEachIndexed { i, (x, y) -> + for (i in 0 until axisCount) { + val axisVertex = geometry.axisVertices[i] + val ratio = layout.normalizedValues[i] * progress + val x = centerX + (axisVertex.x - centerX) * ratio + val y = centerY + (axisVertex.y - centerY) * ratio if (i == 0) dataPath.moveTo(x, y) else dataPath.lineTo(x, y) - dotPositions.add(Offset(x, y)) } dataPath.close() // Fill polygon drawPath( path = dataPath, - color = entryColor, + color = layout.color, alpha = style.fillAlpha, style = Fill, ) @@ -203,46 +307,52 @@ fun RadarChart( // Draw polygon outline drawPath( path = dataPath, - color = entryColor, + color = layout.color, style = Stroke(width = 2f), ) // Draw dots at vertices if (style.showDots) { - dotPositions.forEach { pos -> + for (i in 0 until axisCount) { + val axisVertex = geometry.axisVertices[i] + val ratio = layout.normalizedValues[i] * progress drawCircle( - color = entryColor, - radius = style.dotRadius.toPx(), - center = pos, + color = layout.color, + radius = dotRadiusPx, + center = Offset( + x = centerX + (axisVertex.x - centerX) * ratio, + y = centerY + (axisVertex.y - centerY) * ratio, + ), ) } } } + } +} - // Touch interaction: find nearest axis - val currentTouch = touchOffset - if (currentTouch != null) { - val nearestAxis = RadarMath.findNearestAxisIndex( - touchX = currentTouch.x, touchY = currentTouch.y, - axisCount = axisCount, centerX = centerX, centerY = centerY, - radius = radius, startAngle = startAngle, - ) +private fun findNearestAxisIndex( + touch: Offset, + axisVertices: List, +): Int { + if (axisVertices.isEmpty()) return -1 - if (nearestAxis >= 0) { - selectedAxisIndex = nearestAxis - onAxisSelected?.invoke(nearestAxis) - onSelectionChanged?.invoke(ChartSelection.Radar(nearestAxis)) - } + var nearestAxis = -1 + var minDistance = Float.MAX_VALUE + axisVertices.forEachIndexed { index, vertex -> + val dx = touch.x - vertex.x + val dy = touch.y - vertex.y + val distance = dx * dx + dy * dy + if (distance < minDistance) { + minDistance = distance + nearestAxis = index } } + return nearestAxis } private fun DrawScope.drawAxisLabels( axisLabels: List, - centerX: Float, - centerY: Float, - radius: Float, - startAngle: Float, + positions: List, labelColor: Color, labelSize: Float, fontWeight: FontWeight = FontWeight.Normal, @@ -262,20 +372,9 @@ private fun DrawScope.drawAxisLabels( typeface = Typeface.create(Typeface.DEFAULT, fontWeight.toTypefaceStyle()) } - val labelOffset = labelSize * 1.2f - axisLabels.forEachIndexed { index, label -> - val (x, y) = RadarMath.calculateAxisLabelPosition( - axisIndex = index, - axisCount = axisLabels.size, - centerX = centerX, - centerY = centerY, - radius = radius, - startAngleDegrees = startAngle, - labelOffset = labelOffset, - labelBaselineOffset = labelSize / 3, - ) + val position = positions.getOrNull(index) ?: return@forEachIndexed - drawContext.canvas.nativeCanvas.drawText(label, x, y, paint) + drawContext.canvas.nativeCanvas.drawText(label, position.x, position.y, paint) } } diff --git a/compose-chart/src/main/java/com/inseong/composechart/style/GridStyle.kt b/compose-chart/src/main/java/com/inseong/composechart/style/GridStyle.kt index 712d7b3..d2a935f 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/style/GridStyle.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/style/GridStyle.kt @@ -21,4 +21,8 @@ data class GridStyle( val lineColor: Color = Color.Unspecified, val strokeWidth: Dp = 0.5.dp, val dashPattern: List? = null, -) +) { + internal val dashPatternArray: FloatArray? by lazy { + dashPattern?.toFloatArray() + } +} diff --git a/compose-chart/src/test/java/com/inseong/composechart/data/FactoryMethodsTest.kt b/compose-chart/src/test/java/com/inseong/composechart/data/FactoryMethodsTest.kt index d911fe5..3323562 100644 --- a/compose-chart/src/test/java/com/inseong/composechart/data/FactoryMethodsTest.kt +++ b/compose-chart/src/test/java/com/inseong/composechart/data/FactoryMethodsTest.kt @@ -38,6 +38,14 @@ class FactoryMethodsTest { assertEquals("Feb", data.groups[1].label) } + @Test + fun barEntry_safeTotal_invalidValues_sumsClampedValues() { + val entry = BarEntry(values = listOf(10f, Float.NaN, -5f, Float.POSITIVE_INFINITY, 15f)) + + assertEquals(listOf(10f, 0f, 0f, 0f, 15f), entry.safeValues) + assertEquals(25f, entry.safeTotal, 0.001f) + } + @Test fun lineChartData_fromMap_createsMultiSeries() { val data = LineChartData.fromMap( diff --git a/docs/project-architecture.md b/docs/project-architecture.md index 49ebc49..b7ec1b5 100644 --- a/docs/project-architecture.md +++ b/docs/project-architecture.md @@ -75,6 +75,7 @@ All six charts expose a consistent set of accessibility parameters (`accessibili - Prefer safe input handling over crashing on malformed data. - Keep calculations testable by extracting non-UI logic into pure internal helpers. - Expose simple public APIs with sensible defaults and convenience factories. +- Keep Canvas draw blocks side-effect free: hit testing, selection callbacks, and state changes happen outside draw, while size/data-derived geometry is cached and reused. ## Testing Strategy