diff --git a/.changeset/blue-schools-exist.md b/.changeset/blue-schools-exist.md new file mode 100644 index 00000000..65b0e45f --- /dev/null +++ b/.changeset/blue-schools-exist.md @@ -0,0 +1,5 @@ +--- +'voltra': minor +--- + +Add chart components for iOS and Android widgets and Live Activities, including bar, line, area, point, rule, and pie/donut charts. diff --git a/android/src/main/java/voltra/VoltraRN.kt b/android/src/main/java/voltra/VoltraRN.kt index 2423802d..2d00f2e8 100644 --- a/android/src/main/java/voltra/VoltraRN.kt +++ b/android/src/main/java/voltra/VoltraRN.kt @@ -124,9 +124,13 @@ class VoltraRN( withContext(Dispatchers.Main) { try { - // Try to reapply to the existing view first to avoid flickering/replacing + // Try to reapply to the existing view first to avoid flickering/replacing. + // IMPORTANT: Only use reapply for dimension-only changes (same payload). + // When the payload changes, always do a fresh apply to prevent stale + // style bleed (e.g. padding from a previous widget persisting because + // the new widget's RemoteViews doesn't explicitly reset it to zero). var applied = false - if (frameLayout.childCount > 0) { + if (frameLayout.childCount > 0 && payloadStr == lastRenderedPayload) { try { val existingView = frameLayout.getChildAt(0) remoteViews.reapply(context, existingView) diff --git a/android/src/main/java/voltra/generated/ShortNames.kt b/android/src/main/java/voltra/generated/ShortNames.kt index e883a51e..a0a2d72c 100644 --- a/android/src/main/java/voltra/generated/ShortNames.kt +++ b/android/src/main/java/voltra/generated/ShortNames.kt @@ -67,6 +67,7 @@ object ShortNames { "fvar" to "fontVariant", "fw" to "fontWeight", "fgs" to "foregroundStyle", + "fss" to "foregroundStyleScale", "f" to "frame", "g" to "gap", "gs" to "gaugeStyle", @@ -83,6 +84,7 @@ object ShortNames { "ly" to "layout", "lp" to "layoutPriority", "l" to "left", + "lgv" to "legendVisibility", "ls" to "letterSpacing", "lh" to "lineHeight", "ll" to "lineLimit", @@ -95,6 +97,7 @@ object ShortNames { "mr" to "marginRight", "mt" to "marginTop", "mv" to "marginVertical", + "mrk" to "marks", "me" to "maskElement", "maxh" to "maxHeight", "max" to "maximumValue", @@ -163,6 +166,10 @@ object ShortNames { "valig" to "verticalAlignment", "wt" to "weight", "w" to "width", + "xgs" to "xAxisGridStyle", + "xav" to "xAxisVisibility", + "ygs" to "yAxisGridStyle", + "yav" to "yAxisVisibility", "zi" to "zIndex", ) diff --git a/android/src/main/java/voltra/glance/renderers/ChartBitmapRenderer.kt b/android/src/main/java/voltra/glance/renderers/ChartBitmapRenderer.kt new file mode 100644 index 00000000..814baede --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/ChartBitmapRenderer.kt @@ -0,0 +1,827 @@ +package voltra.glance.renderers + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.util.Log +import androidx.compose.ui.graphics.toArgb +import voltra.styling.JSColorParser + +private const val TAG = "ChartBitmapRenderer" + +private val DEFAULT_PALETTE = + intArrayOf( + 0xFF4E79A7.toInt(), // blue + 0xFFF28E2B.toInt(), // orange + 0xFFE15759.toInt(), // red + 0xFF76B7B2.toInt(), // teal + 0xFF59A14F.toInt(), // green + 0xFFEDC948.toInt(), // yellow + 0xFFB07AA1.toInt(), // purple + 0xFFFF9DA7.toInt(), // pink + 0xFF9C755F.toInt(), // brown + 0xFFBAB0AC.toInt(), // grey + ) + +data class WireMark( + val type: String, + val data: List>?, + val props: Map, +) + +data class ChartPoint( + val xStr: String?, + val xNum: Double?, + val y: Double, + val series: String?, +) + +data class SectorPoint( + val value: Double, + val category: String, +) + +fun parseMarksJson(marksJson: String): List { + return try { + val gson = com.google.gson.Gson() + val type = object : com.google.gson.reflect.TypeToken>>() {}.type + val outer: List> = gson.fromJson(marksJson, type) + outer.mapNotNull { row -> + if (row.size < 3) return@mapNotNull null + val markType = row[0] as? String ?: return@mapNotNull null + + @Suppress("UNCHECKED_CAST") + val data = row[1] as? List> + + @Suppress("UNCHECKED_CAST") + val props = (row[2] as? Map) ?: emptyMap() + WireMark(markType, data, props) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse marks JSON", e) + emptyList() + } +} + +private fun extractChartPoints(data: List>?): List { + if (data == null) return emptyList() + return data.map { pt -> + val y = (pt.getOrNull(1) as? Number)?.toDouble() ?: 0.0 + val series = pt.getOrNull(2) as? String + val xRaw = pt.getOrNull(0) + when (xRaw) { + is String -> ChartPoint(xStr = xRaw, xNum = null, y = y, series = series) + is Number -> ChartPoint(xStr = null, xNum = xRaw.toDouble(), y = y, series = series) + else -> ChartPoint(xStr = null, xNum = null, y = y, series = series) + } + } +} + +private fun extractSectorPoints(data: List>?): List { + if (data == null) return emptyList() + return data.mapNotNull { pt -> + val value = (pt.getOrNull(0) as? Number)?.toDouble() ?: return@mapNotNull null + val category = pt.getOrNull(1) as? String ?: return@mapNotNull null + SectorPoint(value, category) + } +} + +private fun wireColor(props: Map): Int? { + val colorStr = props["c"] as? String ?: return null + return try { + JSColorParser.parse(colorStr)?.toArgb() + } catch (_: Exception) { + null + } +} + +private fun seriesColorMap( + points: List, + foregroundStyleScale: Map?, +): Map { + val map = mutableMapOf() + var idx = 0 + for (pt in points) { + if (pt.series != null && pt.series !in map) { + val scaleColor = foregroundStyleScale?.get(pt.series) + map[pt.series] = scaleColor ?: DEFAULT_PALETTE[idx % DEFAULT_PALETTE.size] + idx++ + } + } + return map +} + +fun renderChartBitmap( + marks: List, + width: Int, + height: Int, + foregroundStyleScale: Map? = null, + xAxisVisible: Boolean = true, + yAxisVisible: Boolean = true, + xAxisGridVisible: Boolean = true, + yAxisGridVisible: Boolean = true, + dpScale: Float = 1f, +): Bitmap { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(0x00000000) + + // Stroke extends half its width beyond the path center — use that as edge safety margin + val maxStrokeWidth = + marks + .filter { it.type == "line" || it.type == "area" } + .maxOfOrNull { (it.props["lw"] as? Number)?.toFloat() ?: 2f } + ?: 2f + val strokeSafety = maxStrokeWidth / 2f + val paddingLeft = if (yAxisVisible) 36f * dpScale else strokeSafety + val paddingBottom = if (xAxisVisible) 24f * dpScale else strokeSafety + val paddingTop = if (yAxisVisible) 12f * dpScale else strokeSafety + val paddingRight = if (xAxisVisible) 12f * dpScale else strokeSafety + + val chartLeft = paddingLeft + val chartTop = paddingTop + val chartRight = width - paddingRight + val chartBottom = height - paddingBottom + val chartWidth = chartRight - chartLeft + val chartHeight = chartBottom - chartTop + + if (chartWidth <= 0 || chartHeight <= 0) return bitmap + + val allPoints = marks.flatMap { extractChartPoints(it.data) } + val hasSectors = marks.any { it.type == "sector" } + + if (hasSectors) { + for (m in marks) { + if (m.type == "sector") { + drawSector(canvas, m, width, height, foregroundStyleScale, dpScale) + } + } + return bitmap + } + + if (allPoints.isEmpty() && marks.none { it.type == "rule" }) return bitmap + + val hasStringX = allPoints.any { it.xStr != null } + val categories: List = + if (hasStringX) { + allPoints.mapNotNull { it.xStr }.distinct() + } else { + emptyList() + } + + val xMin: Double + val xMax: Double + if (hasStringX) { + xMin = 0.0 + xMax = (categories.size - 1).toDouble().coerceAtLeast(1.0) + } else { + val nums = allPoints.mapNotNull { it.xNum } + xMin = nums.minOrNull() ?: 0.0 + xMax = (nums.maxOrNull() ?: 1.0).let { if (it == xMin) it + 1.0 else it } + } + + val yValues = allPoints.map { it.y } + val yMin = (yValues.minOrNull() ?: 0.0).coerceAtMost(0.0) + val yMax = (yValues.maxOrNull() ?: 1.0).let { if (it == yMin) it + 1.0 else it } + + fun mapX(pt: ChartPoint): Float = + if (hasStringX) { + val idx = categories.indexOf(pt.xStr ?: "") + chartLeft + (idx.toFloat() / (categories.size - 1).coerceAtLeast(1).toFloat()) * chartWidth + } else { + chartLeft + ((pt.xNum ?: 0.0).toFloat() - xMin.toFloat()) / (xMax.toFloat() - xMin.toFloat()) * chartWidth + } + + fun mapY(y: Double): Float = + chartBottom - ((y.toFloat() - yMin.toFloat()) / (yMax.toFloat() - yMin.toFloat()) * chartHeight) + + val gridPaint = + Paint().apply { + color = 0x20808080 + style = Paint.Style.STROKE + strokeWidth = 1f * dpScale + pathEffect = DashPathEffect(floatArrayOf(4f * dpScale, 4f * dpScale), 0f) + } + val gridSteps = 4 + if (yAxisGridVisible) { + for (i in 0..gridSteps) { + val y = chartTop + (chartHeight * i / gridSteps) + canvas.drawLine(chartLeft, y, chartRight, y, gridPaint) + } + } + + val axisPaint = + Paint().apply { + color = 0xFF888888.toInt() + style = Paint.Style.STROKE + strokeWidth = 1.5f * dpScale + } + if (yAxisVisible) { + canvas.drawLine(chartLeft, chartTop, chartLeft, chartBottom, axisPaint) + } + if (xAxisVisible) { + canvas.drawLine(chartLeft, chartBottom, chartRight, chartBottom, axisPaint) + } + + val labelPaint = + Paint().apply { + color = 0xFF888888.toInt() + textSize = 12f * dpScale + isAntiAlias = true + } + + if (yAxisVisible) { + for (i in 0..gridSteps) { + val yVal = yMin + (yMax - yMin) * (gridSteps - i) / gridSteps + val y = chartTop + (chartHeight * i / gridSteps) + val label = + if (yVal == yVal.toLong().toDouble()) { + yVal.toLong().toString() + } else { + String.format("%.1f", yVal) + } + canvas.drawText(label, 4f * dpScale, y + labelPaint.textSize / 3, labelPaint) + } + } + + if (xAxisVisible && hasStringX) { + labelPaint.textAlign = Paint.Align.CENTER + for ((idx, cat) in categories.withIndex()) { + val x = chartLeft + (idx.toFloat() / (categories.size - 1).coerceAtLeast(1).toFloat()) * chartWidth + canvas.drawText(cat, x, chartBottom + labelPaint.textSize + 4f * dpScale, labelPaint) + } + } + + for (m in marks) { + val points = extractChartPoints(m.data) + val color = wireColor(m.props) + + when (m.type) { + "bar" -> { + drawBars( + canvas, + points, + m.props, + color, + foregroundStyleScale, + hasStringX, + categories, + chartLeft, + chartBottom, + chartWidth, + chartHeight, + xMin, + xMax, + yMin, + yMax, + ) + } + + "line" -> { + drawLine( + canvas, + points, + m.props, + color, + foregroundStyleScale, + ::mapX, + ::mapY, + ) + } + + "area" -> { + drawArea( + canvas, + points, + m.props, + color, + foregroundStyleScale, + ::mapX, + ::mapY, + chartBottom, + ) + } + + "point" -> { + drawPoints( + canvas, + points, + m.props, + color, + foregroundStyleScale, + ::mapX, + ::mapY, + ) + } + + "rule" -> { + drawRule( + canvas, + m.props, + chartLeft, + chartRight, + chartTop, + chartBottom, + chartWidth, + chartHeight, + xMin, + xMax, + yMin, + yMax, + hasStringX, + categories, + ) + } + } + } + + return bitmap +} + +private fun drawBars( + canvas: Canvas, + points: List, + props: Map, + staticColor: Int?, + foregroundStyleScale: Map?, + hasStringX: Boolean, + categories: List, + chartLeft: Float, + chartBottom: Float, + chartWidth: Float, + chartHeight: Float, + xMin: Double, + xMax: Double, + yMin: Double, + yMax: Double, +) { + if (points.isEmpty()) return + + val cornerRadius = (props["cr"] as? Number)?.toFloat() ?: 0f + val grouped = (props["stk"] as? String) == "grouped" + val seriesColors = seriesColorMap(points, foregroundStyleScale) + val seriesList = seriesColors.keys.filterNotNull() + val seriesCount = seriesList.size.coerceAtLeast(1) + + val barWidthRatio = 0.6f + val categoryCount = if (hasStringX) categories.size.coerceAtLeast(1) else points.size.coerceAtLeast(1) + val totalBarSlot = chartWidth / categoryCount + val barWidth = (props["w"] as? Number)?.toFloat() ?: (totalBarSlot * barWidthRatio) + + val paint = + Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + } + + fun yToCanvas(y: Double): Float = + chartBottom - ((y.toFloat() - yMin.toFloat()) / (yMax.toFloat() - yMin.toFloat()) * chartHeight) + + val zeroY = yToCanvas(0.0.coerceIn(yMin, yMax)) + + for ((i, pt) in points.withIndex()) { + val catIdx = if (hasStringX) categories.indexOf(pt.xStr ?: "") else i + val cx = chartLeft + (catIdx + 0.5f) * totalBarSlot + + val barX: Float + if (grouped && pt.series != null) { + val seriesIdx = seriesList.indexOf(pt.series) + val groupWidth = barWidth + val singleWidth = groupWidth / seriesCount + barX = cx - groupWidth / 2f + seriesIdx * singleWidth + paint.color = seriesColors[pt.series] ?: staticColor ?: DEFAULT_PALETTE[0] + val left = barX + val right = barX + singleWidth + val top = yToCanvas(pt.y) + val rect = RectF(left, top.coerceAtMost(zeroY), right, top.coerceAtLeast(zeroY)) + if (cornerRadius > 0) { + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + } else { + canvas.drawRect(rect, paint) + } + } else { + paint.color = + if (pt.series != null) { + seriesColors[pt.series] ?: staticColor ?: DEFAULT_PALETTE[0] + } else { + staticColor ?: DEFAULT_PALETTE[0] + } + val left = cx - barWidth / 2f + val right = cx + barWidth / 2f + val top = yToCanvas(pt.y) + val rect = RectF(left, top.coerceAtMost(zeroY), right, top.coerceAtLeast(zeroY)) + if (cornerRadius > 0) { + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + } else { + canvas.drawRect(rect, paint) + } + } + } +} + +// MARK: - Interpolation helpers + +private enum class Interpolation { + LINEAR, + MONOTONE, + STEP_START, + STEP_END, + STEP_CENTER, + CARDINAL, + CATMULL_ROM, +} + +private fun parseInterpolation(props: Map): Interpolation = + when (props["itp"] as? String) { + "monotone" -> Interpolation.MONOTONE + "stepStart" -> Interpolation.STEP_START + "stepEnd" -> Interpolation.STEP_END + "stepCenter" -> Interpolation.STEP_CENTER + "cardinal" -> Interpolation.CARDINAL + "catmullRom" -> Interpolation.CATMULL_ROM + else -> Interpolation.LINEAR + } + +/** + * Build an interpolated [Path] through the given screen-space points. + * Supports the same methods as iOS SwiftUI Charts: linear, monotone, step*, cardinal, catmullRom. + */ +private fun buildInterpolatedPath( + xs: FloatArray, + ys: FloatArray, + interpolation: Interpolation, +): Path { + val n = xs.size + val path = Path() + if (n == 0) return path + path.moveTo(xs[0], ys[0]) + if (n == 1) return path + + when (interpolation) { + Interpolation.LINEAR -> { + for (i in 1 until n) path.lineTo(xs[i], ys[i]) + } + + Interpolation.MONOTONE -> { + val dx = FloatArray(n - 1) { xs[it + 1] - xs[it] } + val dy = FloatArray(n - 1) { ys[it + 1] - ys[it] } + val m = FloatArray(n - 1) { if (dx[it] != 0f) dy[it] / dx[it] else 0f } + val tangent = FloatArray(n) + tangent[0] = m[0] + tangent[n - 1] = m[n - 2] + for (i in 1 until n - 1) { + if (m[i - 1] * m[i] <= 0f) { + tangent[i] = 0f + } else { + tangent[i] = (m[i - 1] + m[i]) / 2f + } + } + for (i in 0 until n - 1) { + if (m[i] == 0f) { + tangent[i] = 0f + tangent[i + 1] = 0f + } else { + val alpha = tangent[i] / m[i] + val beta = tangent[i + 1] / m[i] + val s = alpha * alpha + beta * beta + if (s > 9f) { + val tau = 3f / kotlin.math.sqrt(s) + tangent[i] = tau * alpha * m[i] + tangent[i + 1] = tau * beta * m[i] + } + } + } + for (i in 0 until n - 1) { + val d = dx[i] / 3f + path.cubicTo( + xs[i] + d, + ys[i] + tangent[i] * d, + xs[i + 1] - d, + ys[i + 1] - tangent[i + 1] * d, + xs[i + 1], + ys[i + 1], + ) + } + } + + Interpolation.STEP_START -> { + for (i in 1 until n) { + path.lineTo(xs[i], ys[i - 1]) + path.lineTo(xs[i], ys[i]) + } + } + + Interpolation.STEP_END -> { + for (i in 1 until n) { + path.lineTo(xs[i - 1], ys[i]) + path.lineTo(xs[i], ys[i]) + } + } + + Interpolation.STEP_CENTER -> { + for (i in 1 until n) { + val midX = (xs[i - 1] + xs[i]) / 2f + path.lineTo(midX, ys[i - 1]) + path.lineTo(midX, ys[i]) + path.lineTo(xs[i], ys[i]) + } + } + + Interpolation.CARDINAL -> { + val tension = 0f + val s = (1f - tension) / 2f + for (i in 0 until n - 1) { + val p0x = if (i > 0) xs[i - 1] else xs[0] + val p0y = if (i > 0) ys[i - 1] else ys[0] + val p1x = xs[i] + val p1y = ys[i] + val p2x = xs[i + 1] + val p2y = ys[i + 1] + val p3x = if (i + 2 < n) xs[i + 2] else xs[n - 1] + val p3y = if (i + 2 < n) ys[i + 2] else ys[n - 1] + + val cp1x = p1x + s * (p2x - p0x) / 3f + val cp1y = p1y + s * (p2y - p0y) / 3f + val cp2x = p2x - s * (p3x - p1x) / 3f + val cp2y = p2y - s * (p3y - p1y) / 3f + path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2x, p2y) + } + } + + Interpolation.CATMULL_ROM -> { + val alpha = 0.5f + for (i in 0 until n - 1) { + val p0x = if (i > 0) xs[i - 1] else xs[0] + val p0y = if (i > 0) ys[i - 1] else ys[0] + val p1x = xs[i] + val p1y = ys[i] + val p2x = xs[i + 1] + val p2y = ys[i + 1] + val p3x = if (i + 2 < n) xs[i + 2] else xs[n - 1] + val p3y = if (i + 2 < n) ys[i + 2] else ys[n - 1] + + fun dist( + ax: Float, + ay: Float, + bx: Float, + by: Float, + ): Float { + val ddx = bx - ax + val ddy = by - ay + return kotlin.math.sqrt(ddx * ddx + ddy * ddy).coerceAtLeast(1e-6f) + } + + val d01 = Math.pow(dist(p0x, p0y, p1x, p1y).toDouble(), alpha.toDouble()).toFloat() + val d12 = Math.pow(dist(p1x, p1y, p2x, p2y).toDouble(), alpha.toDouble()).toFloat() + val d23 = Math.pow(dist(p2x, p2y, p3x, p3y).toDouble(), alpha.toDouble()).toFloat() + + val cp1x = p1x + (p2x - p0x) / (3f * d01 + 3f * d12) * d12 + val cp1y = p1y + (p2y - p0y) / (3f * d01 + 3f * d12) * d12 + val cp2x = p2x - (p3x - p1x) / (3f * d12 + 3f * d23) * d12 + val cp2y = p2y - (p3y - p1y) / (3f * d12 + 3f * d23) * d12 + + path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2x, p2y) + } + } + } + return path +} + +private fun drawLine( + canvas: Canvas, + points: List, + props: Map, + staticColor: Int?, + foregroundStyleScale: Map?, + mapX: (ChartPoint) -> Float, + mapY: (Double) -> Float, +) { + if (points.isEmpty()) return + + val lineWidth = (props["lw"] as? Number)?.toFloat() ?: 2f + val itp = parseInterpolation(props) + val seriesColors = seriesColorMap(points, foregroundStyleScale) + + val groups = + if (seriesColors.isNotEmpty()) { + points.groupBy { it.series } + } else { + mapOf(null as String? to points) + } + + for ((series, pts) in groups) { + val sorted = pts.sortedBy { it.xNum ?: 0.0 } + if (sorted.size < 2) continue + + val paint = + Paint().apply { + style = Paint.Style.STROKE + this.strokeWidth = lineWidth + isAntiAlias = true + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + color = seriesColors[series] ?: staticColor ?: DEFAULT_PALETTE[0] + } + + val xs = FloatArray(sorted.size) { mapX(sorted[it]) } + val ys = FloatArray(sorted.size) { mapY(sorted[it].y) } + val path = buildInterpolatedPath(xs, ys, itp) + canvas.drawPath(path, paint) + } +} + +private fun drawArea( + canvas: Canvas, + points: List, + props: Map, + staticColor: Int?, + foregroundStyleScale: Map?, + mapX: (ChartPoint) -> Float, + mapY: (Double) -> Float, + baseline: Float, +) { + if (points.isEmpty()) return + + val itp = parseInterpolation(props) + val seriesColors = seriesColorMap(points, foregroundStyleScale) + + val groups = + if (seriesColors.isNotEmpty()) { + points.groupBy { it.series } + } else { + mapOf(null as String? to points) + } + + for ((series, pts) in groups) { + val sorted = pts.sortedBy { it.xNum ?: 0.0 } + if (sorted.isEmpty()) continue + + val baseColor = seriesColors[series] ?: staticColor ?: DEFAULT_PALETTE[0] + + val xs = FloatArray(sorted.size) { mapX(sorted[it]) } + val ys = FloatArray(sorted.size) { mapY(sorted[it].y) } + + val topPath = buildInterpolatedPath(xs, ys, itp) + + // Build fill: start at baseline under first point, trace the top edge, + // then line back down to baseline under last point and close. + val fillPath = Path(topPath) + fillPath.lineTo(xs.last(), baseline) + fillPath.lineTo(xs.first(), baseline) + fillPath.close() + + val fillPaint = + Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + color = (baseColor and 0x00FFFFFF) or 0x40000000 + } + canvas.drawPath(fillPath, fillPaint) + + val strokePaint = + Paint().apply { + style = Paint.Style.STROKE + strokeWidth = 2f + isAntiAlias = true + color = baseColor + } + canvas.drawPath(topPath, strokePaint) + } +} + +private fun drawPoints( + canvas: Canvas, + points: List, + props: Map, + staticColor: Int?, + foregroundStyleScale: Map?, + mapX: (ChartPoint) -> Float, + mapY: (Double) -> Float, +) { + if (points.isEmpty()) return + + val symbolSize = (props["syms"] as? Number)?.toFloat() ?: 24f + val radius = kotlin.math.sqrt(symbolSize) * 1.2f + val seriesColors = seriesColorMap(points, foregroundStyleScale) + + val paint = + Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + } + + for (pt in points) { + paint.color = seriesColors[pt.series] ?: staticColor ?: DEFAULT_PALETTE[0] + canvas.drawCircle(mapX(pt), mapY(pt.y), radius, paint) + } +} + +private fun drawRule( + canvas: Canvas, + props: Map, + chartLeft: Float, + chartRight: Float, + chartTop: Float, + chartBottom: Float, + chartWidth: Float, + chartHeight: Float, + xMin: Double, + xMax: Double, + yMin: Double, + yMax: Double, + hasStringX: Boolean, + categories: List, +) { + val lineWidth = (props["lw"] as? Number)?.toFloat() ?: 1.5f + val color = wireColor(props) ?: 0xFF888888.toInt() + + val paint = + Paint().apply { + style = Paint.Style.STROKE + strokeWidth = lineWidth + this.color = color + isAntiAlias = true + } + + val yv = (props["yv"] as? Number)?.toDouble() + val xvStr = props["xv"] as? String + val xvNum = (props["xv"] as? Number)?.toDouble() + + if (yv != null) { + val y = chartBottom - ((yv.toFloat() - yMin.toFloat()) / (yMax.toFloat() - yMin.toFloat()) * chartHeight) + canvas.drawLine(chartLeft, y, chartRight, y, paint) + } else if (xvStr != null && hasStringX) { + val idx = categories.indexOf(xvStr) + if (idx >= 0) { + val x = chartLeft + (idx.toFloat() / (categories.size - 1).coerceAtLeast(1).toFloat()) * chartWidth + canvas.drawLine(x, chartTop, x, chartBottom, paint) + } + } else if (xvNum != null) { + val x = chartLeft + ((xvNum.toFloat() - xMin.toFloat()) / (xMax.toFloat() - xMin.toFloat()) * chartWidth) + canvas.drawLine(x, chartTop, x, chartBottom, paint) + } +} + +private fun drawSector( + canvas: Canvas, + mark: WireMark, + width: Int, + height: Int, + foregroundStyleScale: Map?, + dpScale: Float = 1f, +) { + val sectors = extractSectorPoints(mark.data) + if (sectors.isEmpty()) return + + val total = sectors.sumOf { it.value } + if (total <= 0) return + + val innerRaw = (mark.props["ir"] as? Number)?.toFloat() ?: 0f + val outerRaw = (mark.props["or"] as? Number)?.toFloat() ?: 1f + val angularInset = (mark.props["agin"] as? Number)?.toFloat() ?: 1f + + val cx = width / 2f + val cy = height / 2f + val maxRadius = minOf(cx, cy) - 8f + + val outerR = if (outerRaw > 1f) (outerRaw * dpScale).coerceAtMost(maxRadius) else maxRadius * outerRaw + val innerR = + if (innerRaw > + 1f + ) { + (innerRaw * dpScale).coerceAtMost(outerR - 1f).coerceAtLeast(0f) + } else { + maxRadius * innerRaw + } + + val outerRect = RectF(cx - outerR, cy - outerR, cx + outerR, cy + outerR) + + val paint = + Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + } + + var startAngle = -90f + for ((i, sector) in sectors.withIndex()) { + val sweep = (sector.value / total * 360.0).toFloat() + paint.color = foregroundStyleScale?.get(sector.category) + ?: DEFAULT_PALETTE[i % DEFAULT_PALETTE.size] + + if (innerR > 0) { + val path = Path() + path.arcTo(outerRect, startAngle + angularInset / 2f, sweep - angularInset) + val innerRect = RectF(cx - innerR, cy - innerR, cx + innerR, cy + innerR) + path.arcTo(innerRect, startAngle + sweep - angularInset / 2f, -(sweep - angularInset)) + path.close() + canvas.drawPath(path, paint) + } else { + canvas.drawArc(outerRect, startAngle + angularInset / 2f, sweep - angularInset, true, paint) + } + + startAngle += sweep + } +} diff --git a/android/src/main/java/voltra/glance/renderers/ChartRenderers.kt b/android/src/main/java/voltra/glance/renderers/ChartRenderers.kt new file mode 100644 index 00000000..c4f097f4 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/ChartRenderers.kt @@ -0,0 +1,155 @@ +package voltra.glance.renderers + +import android.graphics.drawable.Icon +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.layout.ContentScale +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.width +import voltra.glance.LocalVoltraRenderContext +import voltra.glance.applyClickableIfNeeded +import voltra.glance.resolveAndApplyStyle +import voltra.models.VoltraElement +import voltra.styling.JSColorParser +import voltra.styling.SizeValue + +private const val TAG = "ChartRenderers" + +private const val DEFAULT_CHART_WIDTH_DP = 300 +private const val DEFAULT_CHART_HEIGHT_DP = 200 + +@Composable +fun RenderChart( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val renderContext = LocalVoltraRenderContext.current + val (baseModifier, _) = resolveAndApplyStyle(element.p, renderContext.sharedStyles) + val finalModifier = + applyClickableIfNeeded( + modifier ?: baseModifier, + element.p, + element.i, + renderContext.widgetId, + element.t, + element.hashCode(), + ) + + val marksJson = element.p?.get("marks") as? String + if (marksJson.isNullOrEmpty()) { + Log.w(TAG, "Chart element has no marks data") + return + } + + val marks = parseMarksJson(marksJson) + if (marks.isEmpty()) { + Log.w(TAG, "Chart element has no valid marks after parsing") + return + } + + val foregroundStyleScale = parseForegroundStyleScale(element.p?.get("foregroundStyleScale") as? String) + + val xAxisVisible = (element.p?.get("xAxisVisibility") as? String) != "hidden" + val yAxisVisible = (element.p?.get("yAxisVisibility") as? String) != "hidden" + val xAxisGridVisible = (element.p?.get("xAxisGridVisible") as? Boolean) ?: true + val yAxisGridVisible = (element.p?.get("yAxisGridVisible") as? Boolean) ?: true + + val (_, compositeStyle) = resolveAndApplyStyle(element.p, renderContext.sharedStyles) + val styleWidth = compositeStyle?.layout?.width + val styleHeight = compositeStyle?.layout?.height + + val widthIsFill = styleWidth is SizeValue.Fill + val heightIsFill = styleHeight is SizeValue.Fill + val hasSectors = marks.any { it.type == "sector" } + val defaultWidth = + if (hasSectors && heightIsFill && + widthIsFill + ) { + DEFAULT_CHART_HEIGHT_DP + } else { + DEFAULT_CHART_WIDTH_DP + } + val chartWidthDp = + when (styleWidth) { + is SizeValue.Fixed -> styleWidth.value.value.toInt() + else -> defaultWidth + } + val chartHeightDp = + when (styleHeight) { + is SizeValue.Fixed -> styleHeight.value.value.toInt() + else -> DEFAULT_CHART_HEIGHT_DP + } + + val density = LocalContext.current.resources.displayMetrics.density + val scale = density.coerceIn(1f, 3.5f) + val bitmapWidth = (chartWidthDp * scale).toInt().coerceAtLeast(1) + val bitmapHeight = (chartHeightDp * scale).toInt().coerceAtLeast(1) + + val bitmap = + renderChartBitmap( + marks = marks, + width = bitmapWidth, + height = bitmapHeight, + foregroundStyleScale = foregroundStyleScale, + xAxisVisible = xAxisVisible, + yAxisVisible = yAxisVisible, + xAxisGridVisible = xAxisGridVisible, + yAxisGridVisible = yAxisGridVisible, + dpScale = scale, + ) + + var sizeModifier = finalModifier + sizeModifier = + sizeModifier.then( + when { + widthIsFill -> GlanceModifier.fillMaxWidth() + else -> GlanceModifier.width(chartWidthDp.dp) + }, + ) + sizeModifier = + sizeModifier.then( + when { + heightIsFill -> GlanceModifier.fillMaxHeight() + else -> GlanceModifier.height(chartHeightDp.dp) + }, + ) + + val icon = Icon.createWithBitmap(bitmap) + + Image( + provider = ImageProvider(icon), + contentDescription = "Chart", + contentScale = ContentScale.Fit, + modifier = sizeModifier, + ) +} + +private fun parseForegroundStyleScale(json: String?): Map? { + if (json.isNullOrEmpty()) return null + return try { + val gson = com.google.gson.Gson() + val type = object : com.google.gson.reflect.TypeToken>>() {}.type + val pairs: List> = gson.fromJson(json, type) + val map = mutableMapOf() + for (pair in pairs) { + if (pair.size >= 2) { + val color = JSColorParser.parse(pair[1]) + if (color != null) { + map[pair[0]] = color.toArgb() + } + } + } + if (map.isEmpty()) null else map + } catch (e: Exception) { + Log.w(TAG, "Failed to parse foregroundStyleScale", e) + null + } +} diff --git a/android/src/main/java/voltra/glance/renderers/RenderCommon.kt b/android/src/main/java/voltra/glance/renderers/RenderCommon.kt index 419e8409..b33fe765 100644 --- a/android/src/main/java/voltra/glance/renderers/RenderCommon.kt +++ b/android/src/main/java/voltra/glance/renderers/RenderCommon.kt @@ -3,6 +3,7 @@ package voltra.glance.renderers import android.content.Context import android.content.Intent import android.graphics.BitmapFactory +import android.graphics.drawable.Icon import android.net.Uri import android.util.Log import androidx.compose.runtime.Composable @@ -103,7 +104,7 @@ fun extractImageProvider(sourceProp: Any?): ImageProvider? { val uri = Uri.parse(uriString) context.contentResolver.openInputStream(uri)?.use { stream -> val bitmap = BitmapFactory.decodeStream(stream) - return ImageProvider(bitmap) + return ImageProvider(Icon.createWithBitmap(bitmap)) } } catch (e: Exception) { Log.e(TAG, "Failed to decode preloaded image: $assetName", e) @@ -116,7 +117,7 @@ fun extractImageProvider(sourceProp: Any?): ImageProvider? { val decodedString = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) val bitmap = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size) if (bitmap != null) { - return ImageProvider(bitmap) + return ImageProvider(Icon.createWithBitmap(bitmap)) } } catch (e: Exception) { Log.e(TAG, "Failed to decode base64 image", e) @@ -183,6 +184,7 @@ private fun RenderElement(element: VoltraElement) { ComponentTypeID.SCAFFOLD -> RenderScaffold(element) ComponentTypeID.LAZY_COLUMN -> RenderLazyColumn(element) ComponentTypeID.LAZY_VERTICAL_GRID -> RenderLazyVerticalGrid(element) + ComponentTypeID.CHART -> RenderChart(element) } } @@ -217,5 +219,6 @@ fun RenderElementWithModifier( ComponentTypeID.SCAFFOLD -> RenderScaffold(element, modifier) ComponentTypeID.LAZY_COLUMN -> RenderLazyColumn(element, modifier) ComponentTypeID.LAZY_VERTICAL_GRID -> RenderLazyVerticalGrid(element, modifier) + ComponentTypeID.CHART -> RenderChart(element, modifier) } } diff --git a/android/src/main/java/voltra/models/parameters/AndroidChartParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidChartParameters.kt new file mode 100644 index 00000000..5fe55de3 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidChartParameters.kt @@ -0,0 +1,30 @@ +// +// AndroidChartParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidChart component + * Android charts component for data visualization + */ +@Serializable +data class AndroidChartParameters( + /** Compact mark data encoded from children by toJSON */ + val marks: String? = null, + /** Show or hide the x-axis */ + val xAxisVisibility: String? = null, + /** Show or hide the y-axis */ + val yAxisVisibility: String? = null, + /** Show or hide the chart legend */ + val legendVisibility: String? = null, + /** Map of series name to color string */ + val foregroundStyleScale: String? = null, + /** Enable scrolling on the given axis */ + val chartScrollableAxes: String? = null, +) diff --git a/android/src/main/java/voltra/payload/ComponentTypeID.kt b/android/src/main/java/voltra/payload/ComponentTypeID.kt index 16238e6f..2582c9cc 100644 --- a/android/src/main/java/voltra/payload/ComponentTypeID.kt +++ b/android/src/main/java/voltra/payload/ComponentTypeID.kt @@ -32,6 +32,7 @@ object ComponentTypeID { const val SQUARE_ICON_BUTTON = 17 const val TEXT = 18 const val TITLE_BAR = 19 + const val CHART = 20 /** * Get component name from numeric ID @@ -58,6 +59,7 @@ object ComponentTypeID { 17 -> "AndroidSquareIconButton" 18 -> "AndroidText" 19 -> "AndroidTitleBar" + 20 -> "AndroidChart" else -> null } } diff --git a/data/components.json b/data/components.json index 23f27238..b4d96390 100644 --- a/data/components.json +++ b/data/components.json @@ -149,7 +149,14 @@ "scaleEffect": "sce", "rotationEffect": "re", "border": "bd", - "clipped": "clip" + "clipped": "clip", + "foregroundStyleScale": "fss", + "legendVisibility": "lgv", + "marks": "mrk", + "xAxisGridStyle": "xgs", + "xAxisVisibility": "xav", + "yAxisGridStyle": "ygs", + "yAxisVisibility": "yav" }, "styleProperties": [ "padding", @@ -1238,6 +1245,101 @@ "swiftAvailability": "iOS 16.0, macOS 13.0", "hasChildren": true, "parameters": {} + }, + { + "name": "Chart", + "description": "Charts component for data visualization", + "swiftAvailability": "iOS 16.0, macOS 13.0", + "hasChildren": true, + "parameters": { + "marks": { + "type": "array", + "optional": true, + "jsonEncoded": true, + "description": "Compact mark data encoded from children by toJSON" + }, + "xAxisVisibility": { + "type": "string", + "optional": true, + "enum": ["automatic", "visible", "hidden"], + "description": "Show or hide the x-axis" + }, + "xAxisGridStyle": { + "type": "object", + "optional": true, + "jsonEncoded": true, + "description": "Configure x-axis grid line style" + }, + "yAxisVisibility": { + "type": "string", + "optional": true, + "enum": ["automatic", "visible", "hidden"], + "description": "Show or hide the y-axis" + }, + "yAxisGridStyle": { + "type": "object", + "optional": true, + "jsonEncoded": true, + "description": "Configure y-axis grid line style" + }, + "legendVisibility": { + "type": "string", + "optional": true, + "enum": ["automatic", "visible", "hidden"], + "description": "Show or hide the chart legend" + }, + "foregroundStyleScale": { + "type": "object", + "optional": true, + "jsonEncoded": true, + "description": "Map of series name to color string" + } + } + }, + { + "name": "AndroidChart", + "description": "Android charts component for data visualization", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", + "hasChildren": true, + "parameters": { + "marks": { + "type": "array", + "optional": true, + "jsonEncoded": true, + "description": "Compact mark data encoded from children by toJSON" + }, + "xAxisVisibility": { + "type": "string", + "optional": true, + "enum": ["automatic", "visible", "hidden"], + "description": "Show or hide the x-axis" + }, + "yAxisVisibility": { + "type": "string", + "optional": true, + "enum": ["automatic", "visible", "hidden"], + "description": "Show or hide the y-axis" + }, + "legendVisibility": { + "type": "string", + "optional": true, + "enum": ["automatic", "visible", "hidden"], + "description": "Show or hide the chart legend" + }, + "foregroundStyleScale": { + "type": "object", + "optional": true, + "jsonEncoded": true, + "description": "Map of series name to color string" + }, + "chartScrollableAxes": { + "type": "string", + "optional": true, + "enum": ["horizontal", "vertical"], + "description": "Enable scrolling on the given axis" + } + } } ] } diff --git a/example/app.json b/example/app.json index 9d9689e6..7cdd7ab8 100644 --- a/example/app.json +++ b/example/app.json @@ -103,6 +103,16 @@ "widgetCategory": "home_screen", "initialStatePath": "./widgets/android/android-image-fallback-initial.tsx" }, + { + "id": "chart_widget", + "displayName": "Chart Widget", + "description": "Test Chart component", + "targetCellWidth": 3, + "targetCellHeight": 3, + "resizeMode": "horizontal|vertical", + "widgetCategory": "home_screen", + "initialStatePath": "./widgets/android-chart-widget-initial.tsx" + }, { "id": "dynamic_weather", "displayName": "Dynamic Weather Widget", diff --git a/example/app/android-widgets/charts.tsx b/example/app/android-widgets/charts.tsx new file mode 100644 index 00000000..cddda454 --- /dev/null +++ b/example/app/android-widgets/charts.tsx @@ -0,0 +1,5 @@ +import AndroidChartScreen from '~/screens/android/AndroidChartScreen' + +export default function AndroidChartIndex() { + return +} diff --git a/example/app/testing-grounds/chart-playground.tsx b/example/app/testing-grounds/chart-playground.tsx new file mode 100644 index 00000000..03d633af --- /dev/null +++ b/example/app/testing-grounds/chart-playground.tsx @@ -0,0 +1,5 @@ +import ChartPlaygroundScreen from '~/screens/testing-grounds/chart-playground/ChartPlaygroundScreen' + +export default function ChartPlaygroundIndex() { + return +} diff --git a/example/package.json b/example/package.json index 8f38d9e7..6e7884f5 100644 --- a/example/package.json +++ b/example/package.json @@ -5,7 +5,8 @@ "scripts": { "clean": "rm -rf .expo ios", "start": "expo start --dev-client --clear", - "prebuild": "expo prebuild --clean", + "prebuild": "expo prebuild", + "prebuild:clean": "expo prebuild --clean", "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", diff --git a/example/screens/android/AndroidChartScreen.tsx b/example/screens/android/AndroidChartScreen.tsx new file mode 100644 index 00000000..f1d06a04 --- /dev/null +++ b/example/screens/android/AndroidChartScreen.tsx @@ -0,0 +1,165 @@ +import { useRouter } from 'expo-router' +import React, { useState } from 'react' +import { ScrollView, StyleSheet, Text, View } from 'react-native' +import { AndroidWidgetFamily, VoltraWidgetPreview } from 'voltra/android/client' + +import { Button } from '~/components/Button' +import { Card } from '~/components/Card' +import { AreaChartWidget, BarChartWidget, LineChartWidget, PieChartWidget } from '~/widgets/AndroidChartWidget' + +type ChartType = 'bar' | 'line' | 'area' | 'pie' + +const CHART_TYPES: { id: ChartType; title: string; description: string }[] = [ + { id: 'bar', title: 'Bar Chart', description: 'Weekly activity with bars and a reference rule line' }, + { id: 'line', title: 'Line Chart', description: 'Multi-series line chart with data points' }, + { id: 'area', title: 'Area Chart', description: 'Stacked area chart showing traffic by platform' }, + { id: 'pie', title: 'Pie / Donut', description: 'Donut chart showing framework usage breakdown' }, +] + +const WIDGET_SIZES: { id: AndroidWidgetFamily; title: string }[] = [ + { id: 'mediumWide', title: 'Medium Wide' }, + { id: 'mediumSquare', title: 'Medium Square' }, + { id: 'large', title: 'Large' }, + { id: 'extraLarge', title: 'Extra Large' }, +] + +function ChartPreview({ chartType }: { chartType: ChartType }) { + switch (chartType) { + case 'bar': + return + case 'line': + return + case 'area': + return + case 'pie': + return + } +} + +export default function AndroidChartScreen() { + const router = useRouter() + const [selectedChart, setSelectedChart] = useState('bar') + const [selectedSize, setSelectedSize] = useState('large') + + return ( + + + Chart Widgets + + Preview Android chart widgets rendered via Canvas bitmap. Charts are drawn natively using + android.graphics.Canvas and displayed as a Glance Image. + + + + Chart Type + {CHART_TYPES.find((c) => c.id === selectedChart)?.description} + + + {CHART_TYPES.map((chart) => ( +