Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

## 체크리스트

- [ ] PR을 Draft가 아닌 ready for review 상태로 생성했습니다. (명시적으로 Draft 요청을 받은 경우 제외)
- [ ] `compose-chart/` 라이브러리 모듈에 Material3 의존성을 추가하지 않았습니다.
- [ ] 계산/변환 로직은 가능한 한 순수 함수로 분리했습니다.
- [ ] 동작 변경에는 테스트를 추가하거나 갱신했습니다.
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
- 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 중 반복 계산과 할당을 축소
- PR 운영 문서 정리 — 사용자가 명시적으로 요청하지 않는 한 PR은 Draft가 아닌 ready for review 상태로 생성하도록 contributor/agent 문서와 PR 템플릿을 갱신

## [1.3.0] - 2026-04-10

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Publish to Maven Central:
### 4. 작업 완료 시 커밋
- 모든 작업이 완료되면 반드시 git commit을 수행한다.
- 커밋 메시지는 한국어로, conventional commit 형식을 따른다 (예: `feat:`, `fix:`, `refactor:`, `docs:`).
- PR을 생성할 때는 사용자가 명시적으로 Draft를 요청한 경우를 제외하고 ready for review 상태로 올린다.

## CI/CD

Expand Down
6 changes: 6 additions & 0 deletions CODE_QUALITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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에 필요한 계산만 남긴다.

### 컬렉션 처리 최적화

Expand Down Expand Up @@ -489,6 +490,11 @@ test: BarMath 경계값 테스트 추가
- 본문은 한국어
- 하나의 커밋은 하나의 논리적 변경

### PR 제출
- PR은 기본적으로 ready for review 상태로 생성한다.
- Draft PR은 사용자가 명시적으로 요청한 경우에만 사용한다.
- PR 본문에는 변경 요약, 검증 결과, 실행하지 못한 검증의 이유를 기록한다.

---

## 빌드 및 검증
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ GaugeChart(
### 성능 팁

- 차트 `data`와 `style`은 가능하면 `remember` 또는 상위 상태로 안정적으로 유지하세요. 같은 값을 매 recomposition마다 새 객체로 만들면 차트가 다시 계산될 수 있습니다.
- 데이터 포인트가 많다면 `showDots`, slice/radar label, 긴 애니메이션을 필요한 화면에서만 켜는 것이 좋습니다.
- 데이터 포인트가 많다면 `showDots`, X축/slice/radar label, 긴 애니메이션을 필요한 화면에서만 켜는 것이 좋습니다.
- 대량의 `LineChart` 데이터는 시간순/인덱스순처럼 x 값이 오름차순이 되도록 전달하면 터치 선택 탐색이 더 빠르게 동작합니다. 정렬되지 않은 데이터도 계속 지원됩니다.
- `Modifier.chartCaptureModifier()`는 offscreen 기록 비용이 있으므로 실제 이미지 내보내기가 필요한 차트에만 적용하세요.

Expand Down
189 changes: 122 additions & 67 deletions compose-chart/src/main/java/com/inseong/composechart/bar/BarChart.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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

/**
Expand All @@ -26,29 +23,22 @@ internal fun DrawScope.drawXAxisLabels(
labels: List<String>,
style: AxisStyle,
chartArea: Rect,
labelPaint: Paint,
groupWidth: Float = 0f,
groupSpacing: Float = 0f,
) {
if (labels.isEmpty()) return
// 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
}
Expand All @@ -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)
}
}
}
Expand All @@ -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
Expand All @@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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

/**
Expand All @@ -29,29 +27,23 @@ 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)
val bubbleHeight = (textHeight + paddingV * 2).coerceAtMost(canvasSize.height)
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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.inseong.composechart.internal.math

import com.inseong.composechart.data.LineSeries

/**
* XY range calculation result for axis-based charts.
*/
Expand Down Expand Up @@ -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<LineSeries>,
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
Expand Down
Loading