From 462a27a57d2785fc7ff17f4a70a322297b8dd93a Mon Sep 17 00:00:00 2001 From: oh in seong Date: Thu, 11 Jun 2026 14:14:48 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EC=B0=A8=ED=8A=B8=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + CODE_QUALITY.md | 1 + README.md | 2 +- .../com/inseong/composechart/bar/BarChart.kt | 189 +++++++++++------- .../internal/canvas/AxisDrawer.kt | 33 +-- .../internal/canvas/TextPaintCache.kt | 63 ++++++ .../internal/canvas/TooltipDrawer.kt | 14 +- .../composechart/internal/math/ChartMath.kt | 63 ++++++ .../inseong/composechart/line/LineChart.kt | 31 ++- .../inseong/composechart/radar/RadarChart.kt | 44 ++-- .../internal/math/ChartMathTest.kt | 86 ++++++++ docs/project-architecture.md | 1 + 12 files changed, 388 insertions(+), 140 deletions(-) create mode 100644 compose-chart/src/main/java/com/inseong/composechart/internal/canvas/TextPaintCache.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d50f5c3..6f52701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Line/Bar/Donut/Pie/Radar 차트 렌더링 성능 개선 — Canvas draw 중 선택 상태 변경과 콜백 호출을 제거하고, 데이터/크기 기반 파생값을 캐시해 반복 할당을 줄임 - 선택 콜백 호출 안정화 — 동일한 터치 선택이 redraw/recomposition 되는 동안 `onSelectionChanged` 및 레거시 선택 콜백이 반복 호출되지 않도록 변경 - LineChart 터치 탐색 최적화 — 정렬된 x 값은 primitive 배열과 binary search로 가장 가까운 포인트를 찾고, 정렬되지 않은 데이터는 기존 linear fallback 동작을 유지 +- Line/Bar/Radar 렌더링 hot path 추가 최적화 — LineChart range 계산을 single-pass로 줄이고, Bar/Radar layout 및 공통 text paint 재사용으로 draw 중 반복 계산과 할당을 축소 ## [1.3.0] - 2026-04-10 diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md index 6d65d68..c94d76d 100644 --- a/CODE_QUALITY.md +++ b/CODE_QUALITY.md @@ -342,6 +342,7 @@ internal fun resolveGridLineColor(color: Color, isDark: Boolean): Color = - 데이터/크기/style에만 의존하는 좌표, `Path`, 라벨 목록, 누적값은 `remember`, `lazy`, `drawWithCache` 등으로 재사용한다. - draw 중 반복 생성되는 `Paint`, `PathEffect`, 임시 컬렉션은 호출 단위 캐시나 더 직접적인 draw API로 줄인다. - 정렬된 좌표 탐색은 `FloatArray` 같은 primitive 배열과 binary search를 우선하고, 정렬되지 않은 사용자 데이터에는 기존 순서 기반 fallback을 보존한다. +- range/layout 계산은 가능하면 composition 단계에서 single-pass로 끝내고, draw 단계에는 animation progress에 필요한 계산만 남긴다. ### 컬렉션 처리 최적화 diff --git a/README.md b/README.md index 00375a5..741e8bb 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,7 @@ GaugeChart( ### 성능 팁 - 차트 `data`와 `style`은 가능하면 `remember` 또는 상위 상태로 안정적으로 유지하세요. 같은 값을 매 recomposition마다 새 객체로 만들면 차트가 다시 계산될 수 있습니다. -- 데이터 포인트가 많다면 `showDots`, slice/radar label, 긴 애니메이션을 필요한 화면에서만 켜는 것이 좋습니다. +- 데이터 포인트가 많다면 `showDots`, X축/slice/radar label, 긴 애니메이션을 필요한 화면에서만 켜는 것이 좋습니다. - 대량의 `LineChart` 데이터는 시간순/인덱스순처럼 x 값이 오름차순이 되도록 전달하면 터치 선택 탐색이 더 빠르게 동작합니다. 정렬되지 않은 데이터도 계속 지원됩니다. - `Modifier.chartCaptureModifier()`는 offscreen 기록 비용이 있으므로 실제 이미지 내보내기가 필요한 차트에만 적용하세요. 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 82e70b5..44d1d4d 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 @@ -1,5 +1,6 @@ package com.inseong.composechart.bar +import android.graphics.Paint import androidx.compose.foundation.Canvas import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxWidth @@ -33,6 +34,7 @@ import androidx.compose.ui.semantics.semantics import com.inseong.composechart.ChartDefaults import com.inseong.composechart.ChartSelection import com.inseong.composechart.ChartZoomState +import com.inseong.composechart.data.BarEntry import com.inseong.composechart.data.BarChartData import com.inseong.composechart.internal.animation.rememberChartAnimation import com.inseong.composechart.internal.math.BarMath @@ -41,10 +43,29 @@ import com.inseong.composechart.internal.canvas.drawGrid import com.inseong.composechart.internal.canvas.drawTooltip import com.inseong.composechart.internal.canvas.drawXAxisLabels import com.inseong.composechart.internal.canvas.drawYAxisLabels +import com.inseong.composechart.internal.canvas.rememberAxisLabelPaint +import com.inseong.composechart.internal.canvas.rememberTooltipTextPaint import com.inseong.composechart.internal.touch.chartTouchHandler import com.inseong.composechart.internal.touch.chartTouchHandlerWithZoom import com.inseong.composechart.style.BarChartStyle +private class BarGroupLayout( + val groupIndex: Int, + val label: String, + val groupLeft: Float, + val barWidth: Float, + val entries: List, +) + +private class BarEntryLayout( + val entryIndex: Int, + val entry: BarEntry, + val barLeft: Float, + val segmentColors: List, + val totalValue: Float, + val tooltipLineColor: Color, +) + /** * Bar chart Composable. * @@ -112,6 +133,9 @@ fun BarChart( val resolvedAxisStyle = style.axis.copy( labelColor = ChartDefaults.resolveAxisLabelColor(style.axis.labelColor, isDark), ) + val xAxisLabelPaint = rememberAxisLabelPaint(resolvedAxisStyle, Paint.Align.CENTER) + val yAxisLabelPaint = rememberAxisLabelPaint(resolvedAxisStyle, Paint.Align.RIGHT) + val tooltipTextPaint = rememberTooltipTextPaint(style.tooltip) val density = LocalDensity.current var chartSize by remember { mutableStateOf(IntSize.Zero) } @@ -180,6 +204,33 @@ fun BarChart( BarMath.calculateGroupWidth(chartArea.width, groupCount, groupSpacingPx) } val groupLabels = remember(validGroups) { validGroups.map { it.label } } + val barLayouts = remember(validGroups, chartArea, groupWidth, groupSpacingPx, barSpacingPx, colors) { + if (chartArea.width <= 0f || chartArea.height <= 0f) { + emptyList() + } else { + validGroups.mapIndexed { groupIndex, group -> + val groupLeft = chartArea.left + groupIndex * (groupWidth + groupSpacingPx) + val entryCount = group.entries.size + val barWidth = BarMath.calculateBarWidth(groupWidth, entryCount, barSpacingPx) + BarGroupLayout( + groupIndex = groupIndex, + label = group.label, + groupLeft = groupLeft, + barWidth = barWidth, + entries = group.entries.mapIndexed { entryIndex, entry -> + BarEntryLayout( + entryIndex = entryIndex, + entry = entry, + barLeft = groupLeft + entryIndex * (barWidth + barSpacingPx), + segmentColors = entry.colors.ifEmpty { colors }, + totalValue = entry.safeTotal, + tooltipLineColor = colors[entryIndex % colors.size], + ) + }, + ) + } + } + } val currentTouch = remember(touchOffset, zoomState, zScale, zOffsetX, zOffsetY) { touchOffset?.let { touch -> @@ -212,18 +263,16 @@ fun BarChart( groupSpacingPx = groupSpacingPx, groupCount = groupCount, ) - val group = validGroups.getOrNull(groupIndex) - val entryCount = group?.entries?.size ?: 0 - if (group == null || entryCount == 0) { + val groupLayout = barLayouts.getOrNull(groupIndex) + val entryCount = groupLayout?.entries?.size ?: 0 + if (groupLayout == 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, + groupLeft = groupLayout.groupLeft, + barWidth = groupLayout.barWidth, barSpacingPx = barSpacingPx, entryCount = entryCount, ) @@ -238,19 +287,15 @@ fun BarChart( val selectedEntryIndex = selectedBarSelection?.entryIndex ?: -1 val accessibilityDescription = "$accessibilityLabel, ${validGroups.size}개 그룹" - val selectionDescription = if (selectedGroupIndex >= 0 && selectedGroupIndex < validGroups.size) { - val group = validGroups[selectedGroupIndex] + val selectionDescription = barLayouts.getOrNull(selectedGroupIndex)?.let { group -> val entry = group.entries.getOrNull(selectedEntryIndex.coerceAtLeast(0)) val label = group.label.takeIf { it.isNotEmpty() } ?: "${selectedGroupIndex + 1}번 그룹" - val value = entry?.safeTotal - if (value != null) { - "선택된 그룹: $label, 값 ${ChartDefaults.formatSemanticsValue(value)}" + if (entry != null) { + "선택된 그룹: $label, 값 ${ChartDefaults.formatSemanticsValue(entry.totalValue)}" } else { "선택된 그룹: $label" } - } else { - "선택된 그룹 없음" - } + } ?: "선택된 그룹 없음" val touchCallback: (Offset?) -> Unit = { offset -> touchOffset = offset @@ -287,13 +332,26 @@ fun BarChart( drawGrid(resolvedGridStyle, chartArea, resolvedAxisStyle.yLabelCount) if (resolvedAxisStyle.showYAxis) { - drawYAxisLabels(0f, adjustedMax, resolvedAxisStyle, chartArea) + drawYAxisLabels( + minValue = 0f, + maxValue = adjustedMax, + style = resolvedAxisStyle, + chartArea = chartArea, + labelPaint = yAxisLabelPaint, + ) } // X-axis labels (centered under each bar group) if (resolvedAxisStyle.showXAxis) { if (groupLabels.any { it.isNotEmpty() }) { - drawXAxisLabels(groupLabels, resolvedAxisStyle, chartArea, groupWidth, groupSpacingPx) + drawXAxisLabels( + labels = groupLabels, + style = resolvedAxisStyle, + chartArea = chartArea, + labelPaint = xAxisLabelPaint, + groupWidth = groupWidth, + groupSpacing = groupSpacingPx, + ) } } @@ -310,51 +368,48 @@ fun BarChart( }) { // 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 - val barWidth = BarMath.calculateBarWidth(groupWidth, entryCount, barSpacingPx) - - group.entries.forEachIndexed { entryIndex, entry -> - val barLeft = groupLeft + entryIndex * (barWidth + barSpacingPx) - + val roundedSegmentPath = Path() + barLayouts.forEach { group -> + group.entries.forEach { entryLayout -> // Highlight: apply transparency to unselected bars val isHighlighted = !style.highlightOnTouch || selectedGroupIndex == -1 || - selectedGroupIndex == groupIndex + selectedGroupIndex == group.groupIndex val alpha = if (isHighlighted) 1f else style.highlightAlpha if (style.horizontal) { drawHorizontalStackedBar( - values = entry.safeValues, - colors = entry.colors.ifEmpty { colors }, - barTop = barLeft, - barHeight = barWidth, + values = entryLayout.entry.safeValues, + colors = entryLayout.segmentColors, + barTop = entryLayout.barLeft, + barHeight = group.barWidth, chartArea = chartArea, maxValue = adjustedMax, progress = progress, alpha = alpha, cornerRadius = cornerRadiusPx, + roundedPath = roundedSegmentPath, ) } else { drawVerticalStackedBar( - values = entry.safeValues, - colors = entry.colors.ifEmpty { colors }, - barLeft = barLeft, - barWidth = barWidth, + values = entryLayout.entry.safeValues, + colors = entryLayout.segmentColors, + barLeft = entryLayout.barLeft, + barWidth = group.barWidth, chartArea = chartArea, maxValue = adjustedMax, progress = progress, alpha = alpha, cornerRadius = cornerRadiusPx, + roundedPath = roundedSegmentPath, ) } } // Show tooltip for selected group - if (selectedGroupIndex == groupIndex && selectedEntryIndex in group.entries.indices) { + if (selectedGroupIndex == group.groupIndex && selectedEntryIndex in group.entries.indices) { val entry = group.entries[selectedEntryIndex] - val totalValue = entry.safeTotal + val totalValue = entry.totalValue val formattedValue = ChartMath.formatValue(totalValue) val tooltipText = if (group.label.isNotEmpty()) { "${group.label}: $formattedValue" @@ -362,17 +417,17 @@ fun BarChart( formattedValue } - val barLeft = groupLeft + selectedEntryIndex * (barWidth + barSpacingPx) val barHeight = (totalValue / adjustedMax) * chartArea.height * progress - val tooltipX = barLeft + barWidth / 2 + val tooltipX = entry.barLeft + group.barWidth / 2 val tooltipY = chartArea.bottom - barHeight drawTooltip( position = Offset(tooltipX, tooltipY), text = tooltipText, style = style.tooltip, - lineColor = colors[selectedEntryIndex % colors.size], + lineColor = entry.tooltipLineColor, canvasSize = size, + textPaint = tooltipTextPaint, ) } } @@ -395,6 +450,7 @@ private fun DrawScope.drawVerticalStackedBar( progress: Float, alpha: Float, cornerRadius: Float, + roundedPath: Path, ) { var currentBottom = chartArea.bottom val totalSegments = values.size @@ -408,21 +464,20 @@ private fun DrawScope.drawVerticalStackedBar( if (isTopSegment && cornerRadius > 0f) { // Top segment: rounded top corners only - val path = Path().apply { - addRoundRect( - RoundRect( - left = barLeft, - top = segmentTop, - right = barLeft + barWidth, - bottom = currentBottom, - topLeftCornerRadius = CornerRadius(cornerRadius), - topRightCornerRadius = CornerRadius(cornerRadius), - bottomLeftCornerRadius = CornerRadius.Zero, - bottomRightCornerRadius = CornerRadius.Zero, - ) + roundedPath.reset() + roundedPath.addRoundRect( + RoundRect( + left = barLeft, + top = segmentTop, + right = barLeft + barWidth, + bottom = currentBottom, + topLeftCornerRadius = CornerRadius(cornerRadius), + topRightCornerRadius = CornerRadius(cornerRadius), + bottomLeftCornerRadius = CornerRadius.Zero, + bottomRightCornerRadius = CornerRadius.Zero, ) - } - drawPath(path = path, color = color, style = Fill) + ) + drawPath(path = roundedPath, color = color, style = Fill) } else { // Bottom segments: square corners drawRect( @@ -450,6 +505,7 @@ private fun DrawScope.drawHorizontalStackedBar( progress: Float, alpha: Float, cornerRadius: Float, + roundedPath: Path, ) { var currentLeft = chartArea.left val totalSegments = values.size @@ -462,21 +518,20 @@ private fun DrawScope.drawHorizontalStackedBar( if (isRightSegment && cornerRadius > 0f) { // Rightmost segment: rounded right corners only - val path = Path().apply { - addRoundRect( - RoundRect( - left = currentLeft, - top = barTop, - right = currentLeft + segmentWidth, - bottom = barTop + barHeight, - topLeftCornerRadius = CornerRadius.Zero, - topRightCornerRadius = CornerRadius(cornerRadius), - bottomLeftCornerRadius = CornerRadius.Zero, - bottomRightCornerRadius = CornerRadius(cornerRadius), - ) + roundedPath.reset() + roundedPath.addRoundRect( + RoundRect( + left = currentLeft, + top = barTop, + right = currentLeft + segmentWidth, + bottom = barTop + barHeight, + topLeftCornerRadius = CornerRadius.Zero, + topRightCornerRadius = CornerRadius(cornerRadius), + bottomLeftCornerRadius = CornerRadius.Zero, + bottomRightCornerRadius = CornerRadius(cornerRadius), ) - } - drawPath(path = path, color = color, style = Fill) + ) + drawPath(path = roundedPath, color = color, style = Fill) } else { drawRect( color = color, diff --git a/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/AxisDrawer.kt b/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/AxisDrawer.kt index e3493f7..bf04e5a 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/AxisDrawer.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/AxisDrawer.kt @@ -1,12 +1,9 @@ package com.inseong.composechart.internal.canvas import android.graphics.Paint -import android.graphics.Typeface import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb -import com.inseong.composechart.internal.canvas.toTypefaceStyle import com.inseong.composechart.style.AxisStyle /** @@ -26,6 +23,7 @@ internal fun DrawScope.drawXAxisLabels( labels: List, style: AxisStyle, chartArea: Rect, + labelPaint: Paint, groupWidth: Float = 0f, groupSpacing: Float = 0f, ) { @@ -33,22 +31,14 @@ internal fun DrawScope.drawXAxisLabels( // Hide labels when chart area is too narrow if (chartArea.width < 80f * density) return - val paint = Paint().apply { - color = style.labelColor.toArgb() - textSize = style.labelSize.toPx() - textAlign = Paint.Align.CENTER - isAntiAlias = true - typeface = Typeface.create(Typeface.DEFAULT, style.fontWeight.toTypefaceStyle()) - } - - val y = chartArea.bottom + style.labelSize.toPx() + 8f + val y = chartArea.bottom + labelPaint.textSize + 8f if (labels.size == 1) { drawContext.canvas.nativeCanvas.drawText( labels[0], chartArea.center.x, y, - paint, + labelPaint, ) return } @@ -57,14 +47,14 @@ internal fun DrawScope.drawXAxisLabels( // Center each label under its bar group labels.forEachIndexed { index, label -> val x = chartArea.left + index * (groupWidth + groupSpacing) + groupWidth / 2 - drawContext.canvas.nativeCanvas.drawText(label, x, y, paint) + drawContext.canvas.nativeCanvas.drawText(label, x, y, labelPaint) } } else { // Edge-to-edge distribution (for line charts) val step = chartArea.width / (labels.size - 1) labels.forEachIndexed { index, label -> val x = chartArea.left + step * index - drawContext.canvas.nativeCanvas.drawText(label, x, y, paint) + drawContext.canvas.nativeCanvas.drawText(label, x, y, labelPaint) } } } @@ -84,19 +74,12 @@ internal fun DrawScope.drawYAxisLabels( maxValue: Float, style: AxisStyle, chartArea: Rect, + labelPaint: Paint, ) { if (style.yLabelCount <= 0) return // Hide labels when chart area is too short if (chartArea.height < 80f * density) return - val paint = Paint().apply { - color = style.labelColor.toArgb() - textSize = style.labelSize.toPx() - textAlign = Paint.Align.RIGHT - isAntiAlias = true - typeface = Typeface.create(Typeface.DEFAULT, style.fontWeight.toTypefaceStyle()) - } - val range = maxValue - minValue val step = chartArea.height / style.yLabelCount val valueStep = range / style.yLabelCount @@ -115,8 +98,8 @@ internal fun DrawScope.drawYAxisLabels( drawContext.canvas.nativeCanvas.drawText( text, chartArea.left - 8f, - yPos + style.labelSize.toPx() / 3, // Vertical centering adjustment - paint, + yPos + labelPaint.textSize / 3, // Vertical centering adjustment + labelPaint, ) } } diff --git a/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/TextPaintCache.kt b/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/TextPaintCache.kt new file mode 100644 index 0000000..0c66f7b --- /dev/null +++ b/compose-chart/src/main/java/com/inseong/composechart/internal/canvas/TextPaintCache.kt @@ -0,0 +1,63 @@ +package com.inseong.composechart.internal.canvas + +import android.graphics.Paint +import android.graphics.Typeface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import com.inseong.composechart.style.AxisStyle +import com.inseong.composechart.style.TooltipStyle + +@Composable +internal fun rememberAxisLabelPaint( + style: AxisStyle, + textAlign: Paint.Align, +): Paint = rememberChartTextPaint( + color = style.labelColor, + textSize = style.labelSize, + fontWeight = style.fontWeight, + textAlign = textAlign, +) + +@Composable +internal fun rememberTooltipTextPaint(style: TooltipStyle): Paint { + val density = LocalDensity.current + val textSizePx = with(density) { style.textSize.toPx() } + val typefaceStyle = style.fontWeight.toTypefaceStyle() + + return remember(textSizePx, typefaceStyle) { + Paint().apply { + color = android.graphics.Color.WHITE + textSize = textSizePx + isAntiAlias = true + typeface = Typeface.create(Typeface.DEFAULT, typefaceStyle) + } + } +} + +@Composable +internal fun rememberChartTextPaint( + color: Color, + textSize: TextUnit, + fontWeight: FontWeight, + textAlign: Paint.Align, +): Paint { + val density = LocalDensity.current + val colorArgb = color.toArgb() + val textSizePx = with(density) { textSize.toPx() } + val typefaceStyle = fontWeight.toTypefaceStyle() + + return remember(colorArgb, textSizePx, typefaceStyle, textAlign) { + Paint().apply { + this.color = colorArgb + this.textSize = textSizePx + this.textAlign = textAlign + isAntiAlias = true + typeface = Typeface.create(Typeface.DEFAULT, typefaceStyle) + } + } +} 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 0cced74..86e3f0c 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 @@ -1,14 +1,12 @@ package com.inseong.composechart.internal.canvas 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.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.nativeCanvas -import com.inseong.composechart.internal.canvas.toTypefaceStyle import com.inseong.composechart.style.TooltipStyle /** @@ -29,21 +27,15 @@ internal fun DrawScope.drawTooltip( style: TooltipStyle, lineColor: Color, canvasSize: Size, + textPaint: Paint, ) { val paddingH = style.paddingHorizontal.toPx() val paddingV = style.paddingVertical.toPx() val cornerRadius = style.cornerRadius.toPx() - val textSizePx = style.textSize.toPx() // Measure text size - val textPaint = Paint().apply { - color = android.graphics.Color.WHITE - textSize = textSizePx - isAntiAlias = true - typeface = Typeface.create(Typeface.DEFAULT, style.fontWeight.toTypefaceStyle()) - } val textWidth = textPaint.measureText(text) - val textHeight = textSizePx + val textHeight = textPaint.textSize // Clamp bubble size to not exceed canvas val bubbleWidth = (textWidth + paddingH * 2).coerceAtMost(canvasSize.width) @@ -51,7 +43,7 @@ internal fun DrawScope.drawTooltip( val arrowHeight = 6f // Skip tooltip if canvas is too small - if (canvasSize.width < textSizePx || canvasSize.height < textSizePx) return + if (canvasSize.width < textPaint.textSize || canvasSize.height < textPaint.textSize) return // Default position: above the point var bubbleLeft = position.x - bubbleWidth / 2 diff --git a/compose-chart/src/main/java/com/inseong/composechart/internal/math/ChartMath.kt b/compose-chart/src/main/java/com/inseong/composechart/internal/math/ChartMath.kt index 3807726..6abc830 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/internal/math/ChartMath.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/internal/math/ChartMath.kt @@ -1,5 +1,7 @@ package com.inseong.composechart.internal.math +import com.inseong.composechart.data.LineSeries + /** * XY range calculation result for axis-based charts. */ @@ -41,6 +43,67 @@ internal object ChartMath { val minY = yValues.minOrNull() ?: 0f val maxY = yValues.maxOrNull() ?: 0f + return buildXYRange( + minX = minX, + maxX = maxX, + minY = minY, + maxY = maxY, + yAxisMin = yAxisMin, + yAxisMax = yAxisMax, + ) + } + + /** + * Calculates X/Y data range for line series without building intermediate point/value lists. + */ + fun calculateXYRange( + series: List, + yAxisMin: Float? = null, + yAxisMax: Float? = null, + ): XYRange { + var hasPoint = false + var minX = 0f + var maxX = 0f + var minY = 0f + var maxY = 0f + + series.forEach { lineSeries -> + lineSeries.points.forEach { point -> + val safeX = point.safeX + val safeY = point.safeY + if (!hasPoint) { + minX = safeX + maxX = safeX + minY = safeY + maxY = safeY + hasPoint = true + } else { + if (safeX < minX) minX = safeX + if (safeX > maxX) maxX = safeX + if (safeY < minY) minY = safeY + if (safeY > maxY) maxY = safeY + } + } + } + + return buildXYRange( + minX = minX, + maxX = maxX, + minY = minY, + maxY = maxY, + yAxisMin = yAxisMin, + yAxisMax = yAxisMax, + ) + } + + private fun buildXYRange( + minX: Float, + maxX: Float, + minY: Float, + maxY: Float, + yAxisMin: Float?, + yAxisMax: Float?, + ): XYRange { val yRange = (maxY - minY).let { if (it == 0f) 1f else it } val yPadding = yRange * 0.1f val autoMinY = minY - yPadding 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 86ac698..89ba2a0 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 @@ -1,5 +1,6 @@ package com.inseong.composechart.line +import android.graphics.Paint import androidx.compose.foundation.Canvas import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxWidth @@ -40,6 +41,8 @@ import com.inseong.composechart.internal.canvas.drawTooltip import com.inseong.composechart.internal.canvas.drawVerticalIndicatorLine import com.inseong.composechart.internal.canvas.drawXAxisLabels import com.inseong.composechart.internal.canvas.drawYAxisLabels +import com.inseong.composechart.internal.canvas.rememberAxisLabelPaint +import com.inseong.composechart.internal.canvas.rememberTooltipTextPaint import com.inseong.composechart.internal.canvas.toBezierPath import com.inseong.composechart.internal.canvas.toLinearPath import com.inseong.composechart.internal.touch.chartTouchHandler @@ -122,6 +125,9 @@ fun LineChart( val resolvedAxisStyle = style.axis.copy( labelColor = ChartDefaults.resolveAxisLabelColor(style.axis.labelColor, isDark), ) + val xAxisLabelPaint = rememberAxisLabelPaint(resolvedAxisStyle, Paint.Align.CENTER) + val yAxisLabelPaint = rememberAxisLabelPaint(resolvedAxisStyle, Paint.Align.RIGHT) + val tooltipTextPaint = rememberTooltipTextPaint(style.tooltip) val density = LocalDensity.current var chartSize by remember { mutableStateOf(IntSize.Zero) } @@ -136,11 +142,10 @@ fun LineChart( if (validSeries.isEmpty()) return // Calculate data range (safeX/safeY guard against NaN/Infinity) - val allPoints = remember(validSeries) { validSeries.flatMap { it.points } } - val xyRange = remember(allPoints, style.axis.yAxisMin, style.axis.yAxisMax) { + val dataPointCount = remember(validSeries) { validSeries.sumOf { it.points.size } } + val xyRange = remember(validSeries, style.axis.yAxisMin, style.axis.yAxisMax) { ChartMath.calculateXYRange( - xValues = allPoints.map { it.safeX }, - yValues = allPoints.map { it.safeY }, + series = validSeries, yAxisMin = style.axis.yAxisMin, yAxisMax = style.axis.yAxisMax, ) @@ -265,7 +270,7 @@ fun LineChart( } val accessibilityDescription = buildString { - append("$accessibilityLabel, ${validSeries.size}개 시리즈, ${allPoints.size}개 데이터 포인트") + append("$accessibilityLabel, ${validSeries.size}개 시리즈, ${dataPointCount}개 데이터 포인트") if (data.xLabels.isNotEmpty()) { append(", ${data.xLabels.size}개 X축 라벨") } @@ -295,12 +300,23 @@ fun LineChart( // Draw Y-axis labels if (resolvedAxisStyle.showYAxis) { - drawYAxisLabels(xyRange.adjustedMinY, xyRange.adjustedMaxY, resolvedAxisStyle, chartArea) + drawYAxisLabels( + minValue = xyRange.adjustedMinY, + maxValue = xyRange.adjustedMaxY, + style = resolvedAxisStyle, + chartArea = chartArea, + labelPaint = yAxisLabelPaint, + ) } // Draw X-axis labels if (resolvedAxisStyle.showXAxis && data.xLabels.isNotEmpty()) { - drawXAxisLabels(data.xLabels, resolvedAxisStyle, chartArea) + drawXAxisLabels( + labels = data.xLabels, + style = resolvedAxisStyle, + chartArea = chartArea, + labelPaint = xAxisLabelPaint, + ) } // Clip to chart area and apply zoom transform @@ -379,6 +395,7 @@ fun LineChart( style = style.tooltip, lineColor = layout.color, canvasSize = size, + textPaint = tooltipTextPaint, ) } } 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 2a86a17..86188ad 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 @@ -1,7 +1,6 @@ package com.inseong.composechart.radar import android.graphics.Paint -import android.graphics.Typeface import androidx.compose.foundation.Canvas import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable @@ -23,7 +22,6 @@ 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 @@ -31,7 +29,7 @@ import com.inseong.composechart.ChartDefaults import com.inseong.composechart.ChartSelection import com.inseong.composechart.data.RadarChartData import com.inseong.composechart.internal.animation.rememberChartAnimation -import com.inseong.composechart.internal.canvas.toTypefaceStyle +import com.inseong.composechart.internal.canvas.rememberChartTextPaint import com.inseong.composechart.internal.math.RadarMath import com.inseong.composechart.internal.touch.chartTouchHandler import com.inseong.composechart.style.RadarChartStyle @@ -46,9 +44,9 @@ private data class RadarGeometry( val webPaths: List, ) -private data class RadarEntryLayout( +private class RadarEntryLayout( val color: Color, - val normalizedValues: List, + val normalizedValues: FloatArray, ) /** @@ -103,6 +101,12 @@ fun RadarChart( val isDark = isSystemInDarkTheme() val resolvedWebColor = ChartDefaults.resolveRadarWebColor(style.webLineColor, isDark) val resolvedLabelColor = ChartDefaults.resolveAxisLabelColor(style.labelColor, isDark) + val axisLabelPaint = rememberChartTextPaint( + color = resolvedLabelColor, + textSize = style.labelSize, + fontWeight = style.labelFontWeight, + textAlign = Paint.Align.CENTER, + ) val density = LocalDensity.current var chartSize by remember { mutableStateOf(IntSize.Zero) } @@ -208,7 +212,7 @@ fun RadarChart( } RadarEntryLayout( color = entryColor, - normalizedValues = List(axisCount) { i -> + normalizedValues = FloatArray(axisCount) { i -> entry.safeValues[i].coerceIn(0f, resolvedMaxValue) / resolvedMaxValue }, ) @@ -279,14 +283,13 @@ fun RadarChart( drawAxisLabels( axisLabels = data.axisLabels, positions = geometry.axisLabelPositions, - labelColor = resolvedLabelColor, - labelSize = style.labelSize.toPx(), - fontWeight = style.labelFontWeight, + labelPaint = axisLabelPaint, ) // Draw data polygons + val dataPath = Path() entryLayouts.forEach { layout -> - val dataPath = Path() + dataPath.reset() for (i in 0 until axisCount) { val axisVertex = geometry.axisVertices[i] val ratio = layout.normalizedValues[i] * progress @@ -353,28 +356,11 @@ private fun findNearestAxisIndex( private fun DrawScope.drawAxisLabels( axisLabels: List, positions: List, - labelColor: Color, - labelSize: Float, - fontWeight: FontWeight = FontWeight.Normal, + labelPaint: Paint, ) { - val paint = Paint().apply { - color = labelColor.let { - android.graphics.Color.argb( - (it.alpha * 255).toInt(), - (it.red * 255).toInt(), - (it.green * 255).toInt(), - (it.blue * 255).toInt(), - ) - } - textSize = labelSize - textAlign = Paint.Align.CENTER - isAntiAlias = true - typeface = Typeface.create(Typeface.DEFAULT, fontWeight.toTypefaceStyle()) - } - axisLabels.forEachIndexed { index, label -> val position = positions.getOrNull(index) ?: return@forEachIndexed - drawContext.canvas.nativeCanvas.drawText(label, position.x, position.y, paint) + drawContext.canvas.nativeCanvas.drawText(label, position.x, position.y, labelPaint) } } diff --git a/compose-chart/src/test/java/com/inseong/composechart/internal/math/ChartMathTest.kt b/compose-chart/src/test/java/com/inseong/composechart/internal/math/ChartMathTest.kt index fa30b49..d0472f5 100644 --- a/compose-chart/src/test/java/com/inseong/composechart/internal/math/ChartMathTest.kt +++ b/compose-chart/src/test/java/com/inseong/composechart/internal/math/ChartMathTest.kt @@ -1,5 +1,7 @@ package com.inseong.composechart.internal.math +import com.inseong.composechart.data.ChartPoint +import com.inseong.composechart.data.LineSeries import org.junit.Assert.assertEquals import org.junit.Test @@ -160,4 +162,88 @@ class ChartMathTest { // Should fallback to auto range since min >= max assertEquals(true, range.adjustedMinY < range.adjustedMaxY) } + + @Test + fun calculateXYRange_lineSeriesNormalData_matchesListRange() { + val series = listOf( + LineSeries( + points = listOf( + ChartPoint(0f, 10f), + ChartPoint(1f, 20f), + ChartPoint(2f, 30f), + ChartPoint(3f, 40f), + ), + ), + ) + + val range = ChartMath.calculateXYRange(series = series) + + assertEquals(0f, range.minX, 0.001f) + assertEquals(3f, range.maxX, 0.001f) + assertEquals(3f, range.xRange, 0.001f) + assertEquals(7f, range.adjustedMinY, 0.001f) + assertEquals(43f, range.adjustedMaxY, 0.001f) + } + + @Test + fun calculateXYRange_lineSeriesSinglePoint_rangeIsOne() { + val range = ChartMath.calculateXYRange( + series = listOf(LineSeries(points = listOf(ChartPoint(5f, 10f)))), + ) + + assertEquals(5f, range.minX, 0.001f) + assertEquals(5f, range.maxX, 0.001f) + assertEquals(1f, range.xRange, 0.001f) + assertEquals(9.9f, range.adjustedMinY, 0.001f) + assertEquals(10.1f, range.adjustedMaxY, 0.001f) + } + + @Test + fun calculateXYRange_lineSeriesNegativeValues_handlesCorrectly() { + val range = ChartMath.calculateXYRange( + series = listOf( + LineSeries( + points = listOf( + ChartPoint(-5f, -10f), + ChartPoint(5f, 10f), + ), + ), + ), + ) + + assertEquals(-5f, range.minX, 0.001f) + assertEquals(5f, range.maxX, 0.001f) + assertEquals(10f, range.xRange, 0.001f) + assertEquals(-12f, range.adjustedMinY, 0.001f) + assertEquals(12f, range.adjustedMaxY, 0.001f) + } + + @Test + fun calculateXYRange_lineSeriesEmpty_returnsZeroDefaults() { + val range = ChartMath.calculateXYRange(series = emptyList()) + + assertEquals(0f, range.minX, 0.001f) + assertEquals(0f, range.maxX, 0.001f) + assertEquals(1f, range.xRange, 0.001f) + } + + @Test + fun calculateXYRange_lineSeriesManualYAxisOverrides() { + val range = ChartMath.calculateXYRange( + series = listOf( + LineSeries( + points = listOf( + ChartPoint(0f, 10f), + ChartPoint(1f, 20f), + ChartPoint(2f, 30f), + ), + ), + ), + yAxisMin = 0f, + yAxisMax = 50f, + ) + + assertEquals(0f, range.adjustedMinY, 0.001f) + assertEquals(50f, range.adjustedMaxY, 0.001f) + } } diff --git a/docs/project-architecture.md b/docs/project-architecture.md index 6f2ea51..3293dbb 100644 --- a/docs/project-architecture.md +++ b/docs/project-architecture.md @@ -77,6 +77,7 @@ All six charts expose a consistent set of accessibility parameters (`accessibili - 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. - Prefer cached primitive geometry for interaction hot paths. For example, `LineChart` stores x positions in a `FloatArray` and uses binary search for ascending data, while preserving a linear fallback for unordered input. +- Keep high-volume chart calculations single-pass where possible, cache Bar/Radar layout outside draw, and reuse common text drawing resources instead of rebuilding them every frame. ## Testing Strategy From 142b4c0cc8502123c698bc995135e3644b252eed Mon Sep 17 00:00:00 2001 From: oh in seong Date: Thu, 11 Jun 2026 14:24:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20PR=20=EC=A0=9C=EC=B6=9C=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 1 + AGENTS.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 1 + CODE_QUALITY.md | 5 +++++ docs/project-architecture.md | 1 + 6 files changed, 10 insertions(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 073aa31..76395e0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,6 +17,7 @@ ## 체크리스트 +- [ ] PR을 Draft가 아닌 ready for review 상태로 생성했습니다. (명시적으로 Draft 요청을 받은 경우 제외) - [ ] `compose-chart/` 라이브러리 모듈에 Material3 의존성을 추가하지 않았습니다. - [ ] 계산/변환 로직은 가능한 한 순수 함수로 분리했습니다. - [ ] 동작 변경에는 테스트를 추가하거나 갱신했습니다. diff --git a/AGENTS.md b/AGENTS.md index 3d04b89..04a4ea8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,7 @@ Before making changes, read documents in this order: - Run the narrowest useful verification for the change. - Review the resulting diff before commit. - Commit completed work with a Korean conventional commit message. +- When opening a PR, create it as ready for review, not draft, unless the user explicitly asks for a draft PR. ## Build Commands diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f52701..d830229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - 선택 콜백 호출 안정화 — 동일한 터치 선택이 redraw/recomposition 되는 동안 `onSelectionChanged` 및 레거시 선택 콜백이 반복 호출되지 않도록 변경 - LineChart 터치 탐색 최적화 — 정렬된 x 값은 primitive 배열과 binary search로 가장 가까운 포인트를 찾고, 정렬되지 않은 데이터는 기존 linear fallback 동작을 유지 - Line/Bar/Radar 렌더링 hot path 추가 최적화 — LineChart range 계산을 single-pass로 줄이고, Bar/Radar layout 및 공통 text paint 재사용으로 draw 중 반복 계산과 할당을 축소 +- PR 운영 문서 정리 — 사용자가 명시적으로 요청하지 않는 한 PR은 Draft가 아닌 ready for review 상태로 생성하도록 contributor/agent 문서와 PR 템플릿을 갱신 ## [1.3.0] - 2026-04-10 diff --git a/CLAUDE.md b/CLAUDE.md index 0996d90..13d8731 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,7 @@ Publish to Maven Central: ### 4. 작업 완료 시 커밋 - 모든 작업이 완료되면 반드시 git commit을 수행한다. - 커밋 메시지는 한국어로, conventional commit 형식을 따른다 (예: `feat:`, `fix:`, `refactor:`, `docs:`). +- PR을 생성할 때는 사용자가 명시적으로 Draft를 요청한 경우를 제외하고 ready for review 상태로 올린다. ## CI/CD diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md index c94d76d..524f17b 100644 --- a/CODE_QUALITY.md +++ b/CODE_QUALITY.md @@ -490,6 +490,11 @@ test: BarMath 경계값 테스트 추가 - 본문은 한국어 - 하나의 커밋은 하나의 논리적 변경 +### PR 제출 +- PR은 기본적으로 ready for review 상태로 생성한다. +- Draft PR은 사용자가 명시적으로 요청한 경우에만 사용한다. +- PR 본문에는 변경 요약, 검증 결과, 실행하지 못한 검증의 이유를 기록한다. + --- ## 빌드 및 검증 diff --git a/docs/project-architecture.md b/docs/project-architecture.md index 3293dbb..b5c9bf9 100644 --- a/docs/project-architecture.md +++ b/docs/project-architecture.md @@ -129,5 +129,6 @@ GitHub Actions: - Keep internal contributor context in `docs/` instead of expanding agent instruction files. - When behavior changes, update tests and user-facing docs together when appropriate. - Follow `CODE_QUALITY.md` for implementation details such as immutability, pure functions, visibility, and named arguments. +- Open pull requests as ready for review by default. Use draft PRs only when explicitly requested. - Keep README examples mirrored by compile-safe tests so doc snippets stay aligned with the current API. - Update `CHANGELOG.md` whenever public behavior, documentation, accessibility output, or release-visible workflows change.