Skip to content

Commit

Permalink
Merge pull request #99 from pakka-papad/feat/radar-chart
Browse files Browse the repository at this point in the history
Added: Radar Chart
  • Loading branch information
hi-manshu committed Apr 6, 2024
2 parents bb94dad + 07eedd9 commit f5b7967
Show file tree
Hide file tree
Showing 6 changed files with 353 additions and 0 deletions.
18 changes: 18 additions & 0 deletions app/src/main/java/com/himanshoe/chartysample/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import com.himanshoe.charty.pie.PieChart
import com.himanshoe.charty.pie.config.PieChartDefaults
import com.himanshoe.charty.pie.model.PieData
import com.himanshoe.charty.point.PointChart
import com.himanshoe.charty.radar.RadarChart
import com.himanshoe.charty.radar.model.RadarData
import com.himanshoe.charty.stacked.StackedBarChart
import com.himanshoe.charty.stacked.config.StackBarData
import kotlin.random.Random
Expand Down Expand Up @@ -309,6 +311,12 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.wrapContentSize()
)
}
item {
RadarChart(
dataCollection = ChartDataCollection(generateMockRadarDataList()),
modifier = Modifier.size(400.dp),
)
}
item {
CurveLineChart(
dataCollection = ChartDataCollection(generateMockLineDataList()),
Expand Down Expand Up @@ -348,4 +356,14 @@ class MainActivity : ComponentActivity() {
LineData(11F, "Nov"),
)
}

private fun generateMockRadarDataList(): List<RadarData> {
return listOf(
RadarData(0F, "Jan"),
RadarData(10F, "Feb"),
RadarData(05F, "Mar"),
RadarData(50F, "Apr"),
RadarData(55F, "May"),
)
}
}
243 changes: 243 additions & 0 deletions charty/src/main/java/com/himanshoe/charty/radar/RadarChart.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package com.himanshoe.charty.radar

import android.graphics.Paint
import android.graphics.PointF
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import com.himanshoe.charty.common.ChartDataCollection
import com.himanshoe.charty.common.config.AxisConfig
import com.himanshoe.charty.common.config.ChartDefaults
import com.himanshoe.charty.common.maxYValue
import com.himanshoe.charty.common.minYValue
import com.himanshoe.charty.radar.config.RadarChartColors
import com.himanshoe.charty.radar.config.RadarChartDefaults
import com.himanshoe.charty.radar.config.RadarConfig
import com.himanshoe.charty.radar.model.RadarData
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin

/**
* A composable function that displays a radar chart.
*
* @param dataCollection The collection of chart data points.
* @param modifier The modifier for the chart.
* @param axisConfig The configuration for the chart's axes.
* @param radarConfig The configuration for the polygon in the chart.
* @param chartColors The colors used in the chart.
* @param radiusScale The scale factor for the radius of the data points.
*/
@Composable
fun RadarChart(
dataCollection: ChartDataCollection,
modifier: Modifier = Modifier,
axisConfig: AxisConfig = ChartDefaults.axisConfigDefaults(),
radarConfig: RadarConfig = RadarChartDefaults.defaultConfig(),
chartColors: RadarChartColors = RadarChartDefaults.defaultColor(),
radiusScale: Float = 0.02f,
) {
val maxYValue by remember { mutableStateOf(dataCollection.maxYValue()) }
val minYValue by remember { mutableStateOf(dataCollection.minYValue()) }
val diff = abs(maxYValue * 0.1F)
val dataRange = (minYValue - diff)..(maxYValue + diff)
val partitionAngle = (2 * Math.PI / dataCollection.data.size).toFloat()

Canvas(
modifier = modifier
.drawBehind {
drawRadarAxis(
dataCollection = dataCollection,
dataRange = dataRange,
radius = size.minDimension / 3,
center = PointF(size.width / 2, size.height / 2),
axisConfig = axisConfig,
)
}
) {
val radius = size.minDimension / 3
val centerX = size.width / 2
val centerY = size.height / 2

val scaleFactor = radius / (dataRange.endInclusive - dataRange.start)

val lineColor = Brush.linearGradient(chartColors.lineColor)
val dotColor = Brush.linearGradient(chartColors.dotColor)

val radarPolygonVertices = mutableListOf<PointF>()

Path().apply {
dataCollection.data.fastForEachIndexed { index, chartData ->
val angle = index * partitionAngle
val x = centerX + scaleFactor * (chartData.yValue - dataRange.start) * cos(angle)
val y = centerY + scaleFactor * (chartData.yValue - dataRange.start) * sin(angle)
if (index == 0) {
moveTo(x, y)
} else {
lineTo(x, y)
}

radarPolygonVertices.add(PointF(x, y))
}
close()

drawPath(
path = this,
brush = lineColor,
style = Stroke(width = radarConfig.strokeSize)
)
if (radarConfig.fillPolygon) {
drawPath(
path = this,
brush = Brush.linearGradient(chartColors.fillColor),
)
}
}

if (radarConfig.hasDotMarker) {
radarPolygonVertices.fastForEach {
drawCircle(
brush = dotColor,
radius = radiusScale * size.width,
center = Offset(it.x, it.y)
)
}
}
}
}

private fun DrawScope.drawRadarAxis(
dataCollection: ChartDataCollection,
dataRange: ClosedFloatingPointRange<Float>,
radius: Float,
center: PointF,
axisConfig: AxisConfig,
) {
val partitionAngle = (2 * Math.PI / dataCollection.data.size).toFloat()
val axisPolygons = if (axisConfig.showGridLines) {
listOf(Path(), Path(), Path(), Path())
} else {
emptyList()
}

for (spokeIndex in 0 until dataCollection.data.size) {
val angle = spokeIndex * partitionAngle

if (axisConfig.showAxes) {
drawLine(
start = Offset(center.x, center.y),
end = Offset(center.x + radius * cos(angle), center.y + radius * sin(angle)),
color = axisConfig.axisColor,
strokeWidth = axisConfig.axisStroke
)
}

drawContext.canvas.nativeCanvas.drawText(
dataCollection.data[spokeIndex].xValue.toString(),
center.x + (radius + 30F) * cos(angle),
center.y + (radius + 30F) * sin(angle),
Paint().apply {
this.color = axisConfig.axisColor.toArgb()
this.textSize = size.width / 30
this.textAlign = if (angle > Math.PI / 2 && angle < 3 * Math.PI / 2) {
Paint.Align.RIGHT
} else {
Paint.Align.LEFT
}
}
)

axisPolygons.fastForEachIndexed { i, path ->
val scale = 1F - 0.25F * i
if (spokeIndex == 0) {
path.moveTo(center.x + scale * radius * cos(angle), center.y + scale * radius * sin(angle))
} else {
path.lineTo(center.x + scale * radius * cos(angle), center.y + scale * radius * sin(angle))
}
}
}

if (!axisConfig.showGridLines) return

axisPolygons.fastForEach {
it.close()
drawPath(
path = it,
color = axisConfig.gridColor,
style = Stroke(width = axisConfig.axisStroke)
)
}

if (!axisConfig.showGridLabel) return

drawGridLabels(
radius = radius,
center = center,
dataRange = dataRange,
angle = (partitionAngle / 2),
labelCount = axisPolygons.size,
labelColor = axisConfig.gridColor
)
}

private fun DrawScope.drawGridLabels(
radius: Float,
center: PointF,
dataRange: ClosedFloatingPointRange<Float>,
angle: Float,
labelCount: Int,
labelColor: Color
) {
val reduceBy = 1F / labelCount
for (i in 0 until labelCount) {
val scale = 1F - reduceBy * i
drawContext.canvas.nativeCanvas.drawText(
(dataRange.start + (dataRange.endInclusive - dataRange.start) * scale).toString(),
center.x + scale * radius * cos(angle),
center.y + scale * radius * sin(angle),
Paint().apply {
this.color = labelColor.toArgb()
this.textSize = size.width / 30
this.textAlign = Paint.Align.CENTER
}
)
}
}

@Preview(showBackground = true)
@Composable
private fun RadarChartPreview() {
RadarChart(
dataCollection = ChartDataCollection(
listOf(
RadarData(30f, "AAAAAA"),
RadarData(25f, "BBBBBB"),
RadarData(20f, "CCCCCC"),
RadarData(15f, "DDDDDD"),
RadarData(10f, "EEEEEE"),
)
),
modifier = Modifier.size(350.dp),
axisConfig = ChartDefaults.axisConfigDefaults(),
radarConfig = RadarChartDefaults.defaultConfig().copy(
fillPolygon = true
)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.himanshoe.charty.radar.config

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color

/**
* Represents the colors used in a radar chart.
*
* @property lineColor The colors of the lines in the chart.
* @property dotColor The colors of the dots in the chart.
* @property fillColor The fill color of the polygon in the chart.
*/
@Immutable
data class RadarChartColors(
val lineColor: List<Color>,
val dotColor: List<Color>,
val fillColor: List<Color> = lineColor.map { it.copy(alpha = 0.5F) }
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.himanshoe.charty.radar.config

import androidx.compose.ui.graphics.Color

/**
* Default configurations and colors for a radar chart.
*/
object RadarChartDefaults {

/**
* Returns the default configuration for a radar chart.
*
* @return The default radar chart configuration.
*/
fun defaultConfig(): RadarConfig =
RadarConfig(
hasDotMarker = true,
strokeSize = 5F,
fillPolygon = true,
)

/**
* Returns the default colors for a radar chart.
*
* @return The default radar chart colors.
*/
fun defaultColor(): RadarChartColors =
RadarChartColors(
lineColor = listOf(
Color(0xffed625d),
Color(0xfff79f88)
),
dotColor = listOf(
Color(0xff50c0a8),
Color(0xff7a57e3),
),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.himanshoe.charty.radar.config

import androidx.compose.runtime.Immutable

/**
* Configuration options for a data polygon in a radar chart.
*
* @property hasDotMarker Whether the polygon should have a dot marker at each data point.
* @property strokeSize The size of the line stroke.
* @property fillPolygon Whether the polygon should be filled with a color.
*/
@Immutable
data class RadarConfig(
val hasDotMarker: Boolean,
val strokeSize: Float,
val fillPolygon: Boolean,
)
19 changes: 19 additions & 0 deletions charty/src/main/java/com/himanshoe/charty/radar/model/RadarData.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.himanshoe.charty.radar.model

import androidx.compose.runtime.Immutable
import com.himanshoe.charty.common.ChartData

/**
* Represents a data point for a radar chart.
*
* @property yValue The value of the data point.
* @property xValue The label or identifier of the data point.
*/
@Immutable
data class RadarData(
override val yValue: Float,
override val xValue: Any,
) : ChartData {
override val chartString: String
get() = "Radar Chart"
}

0 comments on commit f5b7967

Please sign in to comment.