From ef44f4c22c26b77ffa6c58fa72e462bfa11cee47 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Thu, 5 Mar 2026 19:54:10 +0100 Subject: [PATCH 1/7] feat: android charts --- .../glance/renderers/ChartBitmapRenderer.kt | 560 ++++++++++++++++++ .../voltra/glance/renderers/ChartRenderers.kt | 137 +++++ .../voltra/glance/renderers/RenderCommon.kt | 7 +- .../models/parameters/ChartParameters.kt | 35 ++ .../java/voltra/payload/ComponentTypeID.kt | 7 +- data/components.json | 3 +- example/app.json | 10 + example/app/android-widgets/charts.tsx | 5 + .../screens/android/AndroidChartScreen.tsx | 165 ++++++ example/screens/android/AndroidScreen.tsx | 7 + example/widgets/AndroidChartWidget.tsx | 122 ++++ .../widgets/android-chart-widget-initial.tsx | 10 + src/android/jsx/AreaMark.tsx | 15 + src/android/jsx/BarMark.tsx | 17 + src/android/jsx/Chart.tsx | 119 ++++ src/android/jsx/LineMark.tsx | 15 + src/android/jsx/PointMark.tsx | 15 + src/android/jsx/RuleMark.tsx | 14 + src/android/jsx/SectorMark.tsx | 15 + src/android/jsx/chart-types.ts | 1 + src/android/jsx/primitives.ts | 8 + src/android/payload/component-ids.ts | 2 + 22 files changed, 1284 insertions(+), 5 deletions(-) create mode 100644 android/src/main/java/voltra/glance/renderers/ChartBitmapRenderer.kt create mode 100644 android/src/main/java/voltra/glance/renderers/ChartRenderers.kt create mode 100644 android/src/main/java/voltra/models/parameters/ChartParameters.kt create mode 100644 example/app/android-widgets/charts.tsx create mode 100644 example/screens/android/AndroidChartScreen.tsx create mode 100644 example/widgets/AndroidChartWidget.tsx create mode 100644 example/widgets/android-chart-widget-initial.tsx create mode 100644 src/android/jsx/AreaMark.tsx create mode 100644 src/android/jsx/BarMark.tsx create mode 100644 src/android/jsx/Chart.tsx create mode 100644 src/android/jsx/LineMark.tsx create mode 100644 src/android/jsx/PointMark.tsx create mode 100644 src/android/jsx/RuleMark.tsx create mode 100644 src/android/jsx/SectorMark.tsx create mode 100644 src/android/jsx/chart-types.ts 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..43fa7105 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/ChartBitmapRenderer.kt @@ -0,0 +1,560 @@ +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 voltra.styling.JSColorParser +import androidx.compose.ui.graphics.toArgb + +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, + dpScale: Float = 1f, +): Bitmap { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(0x00000000) + + val paddingLeft = if (yAxisVisible) 48f else 16f + val paddingBottom = if (xAxisVisible) 40f else 16f + val paddingTop = 16f + val paddingRight = 16f + + 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 { + return 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 { + return chartBottom - ((y.toFloat() - yMin.toFloat()) / (yMax.toFloat() - yMin.toFloat()) * chartHeight) + } + + val gridPaint = Paint().apply { + color = 0x20808080 + style = Paint.Style.STROKE + strokeWidth = 1f + pathEffect = DashPathEffect(floatArrayOf(4f, 4f), 0f) + } + val gridSteps = 4 + 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 + } + 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 = 10f * (width / 400f).coerceIn(0.8f, 1.5f) + 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, 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, 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 { + return 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) + } + } + } +} + +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 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 path = Path() + path.moveTo(mapX(sorted[0]), mapY(sorted[0].y)) + for (i in 1 until sorted.size) { + path.lineTo(mapX(sorted[i]), mapY(sorted[i].y)) + } + 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 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 fillPaint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + color = (baseColor and 0x00FFFFFF) or 0x40000000 + } + + val fillPath = Path() + fillPath.moveTo(mapX(sorted[0]), baseline) + for (pt in sorted) { + fillPath.lineTo(mapX(pt), mapY(pt.y)) + } + fillPath.lineTo(mapX(sorted.last()), baseline) + fillPath.close() + canvas.drawPath(fillPath, fillPaint) + + val strokePaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = 2f + isAntiAlias = true + color = baseColor + } + val strokePath = Path() + strokePath.moveTo(mapX(sorted[0]), mapY(sorted[0].y)) + for (i in 1 until sorted.size) { + strokePath.lineTo(mapX(sorted[i]), mapY(sorted[i].y)) + } + canvas.drawPath(strokePath, 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..2a8fabf3 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/ChartRenderers.kt @@ -0,0 +1,137 @@ +package voltra.glance.renderers + +import android.graphics.drawable.Icon +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.toArgb +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +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 +private const val MAX_BITMAP_PIXELS = 600 + +@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 (_, 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 chartWidthDp = when (styleWidth) { + is SizeValue.Fixed -> styleWidth.value.value.toInt() + else -> DEFAULT_CHART_WIDTH_DP + } + val chartHeightDp = when (styleHeight) { + is SizeValue.Fixed -> styleHeight.value.value.toInt() + else -> DEFAULT_CHART_HEIGHT_DP + } + + val scale = (MAX_BITMAP_PIXELS.toFloat() / maxOf(chartWidthDp, chartHeightDp).coerceAtLeast(1)) + .coerceAtMost(1.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, + 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/ChartParameters.kt b/android/src/main/java/voltra/models/parameters/ChartParameters.kt new file mode 100644 index 00000000..bbfefb12 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/ChartParameters.kt @@ -0,0 +1,35 @@ +// +// ChartParameters.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 Chart component + * Charts component for data visualization + */ +@Serializable +data class ChartParameters( + /** 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..2e6b3603 100644 --- a/android/src/main/java/voltra/payload/ComponentTypeID.kt +++ b/android/src/main/java/voltra/payload/ComponentTypeID.kt @@ -32,12 +32,13 @@ 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 */ - fun getComponentName(id: Int): String? = - when (id) { + fun getComponentName(id: Int): String? { + return when (id) { 0 -> "AndroidFilledButton" 1 -> "AndroidImage" 2 -> "AndroidSwitch" @@ -58,6 +59,8 @@ object ComponentTypeID { 17 -> "AndroidSquareIconButton" 18 -> "AndroidText" 19 -> "AndroidTitleBar" + 20 -> "Chart" else -> null } + } } diff --git a/data/components.json b/data/components.json index 403ac77c..9d1e3c78 100644 --- a/data/components.json +++ b/data/components.json @@ -1247,8 +1247,9 @@ }, { "name": "Chart", - "description": "SwiftUI Charts component for data visualization", + "description": "Charts component for data visualization", "swiftAvailability": "iOS 16.0, macOS 13.0", + "androidAvailability": "Android 12+", "hasChildren": true, "parameters": { "marks": { diff --git a/example/app.json b/example/app.json index 0fa6653c..1fa2cc72 100644 --- a/example/app.json +++ b/example/app.json @@ -90,6 +90,16 @@ "resizeMode": "horizontal|vertical", "widgetCategory": "home_screen", "initialStatePath": "./widgets/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" } ] }, 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/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) => ( +