Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-02 11:24:49 +03:00
parent 45db7c879f
commit e20f932081
52 changed files with 1676 additions and 813 deletions

View file

@ -22,4 +22,5 @@ dependencies {
implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
implementation(deps.kotlin.immutable.collections)
}

View file

@ -3,7 +3,6 @@ package com.tangem.common.ui.charts
import android.content.res.Configuration
import androidx.annotation.FloatRange
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -12,10 +11,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontSynthesis
import androidx.compose.ui.text.font.FontWeight
@ -23,26 +23,32 @@ import androidx.compose.ui.text.font.resolveAsTypeface
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.*
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottomAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberCustomStartAxis
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import com.patrykandpatrick.vico.compose.common.of
import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout
import com.patrykandpatrick.vico.core.cartesian.Zoom
import com.patrykandpatrick.vico.core.cartesian.axis.*
import com.patrykandpatrick.vico.core.cartesian.axis.AxisPosition
import com.patrykandpatrick.vico.core.cartesian.axis.BaseAxis
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener
import com.patrykandpatrick.vico.core.cartesian.marker.LineCartesianLayerMarkerTarget
import com.patrykandpatrick.vico.core.common.Dimensions
import com.patrykandpatrick.vico.core.common.component.LineComponent
import com.patrykandpatrick.vico.core.common.shape.Shape
import com.tangem.common.ui.charts.layer.TimeItemPlacer
import com.tangem.common.ui.charts.layer.rememberMarketChartLayer
import com.tangem.common.ui.charts.marker.rememberTangemChartMarker
import com.tangem.common.ui.charts.layer.rememberTangemChartMarker
import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider
import com.tangem.common.ui.charts.state.*
import com.tangem.core.ui.components.SpacerH16
@ -50,6 +56,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toTimeFormat
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
@ -73,70 +80,72 @@ fun MarketChart(
splitChartSegmentColor: Color = TangemTheme.colors.icon.inactive,
@FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float = 0.24f,
@FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float = 0.24f,
noChartContent: @Composable BoxScope.() -> Unit,
) {
var canvasWidth by remember { mutableIntStateOf(0) }
var canvasHeight by remember { mutableIntStateOf(0) }
var chartHeight by remember { mutableIntStateOf(0) }
val layer = rememberLayerFromState(
state = state,
splitChartSegmentColor = splitChartSegmentColor,
backgroundColorAlpha = backgroundColorAlpha,
backgroundSplitChartSegmentColorAlpha = backgroundSplitChartSegmentColorAlpha,
canvasHeight = canvasHeight,
val layer = rememberMarketChartLayer(
lineColor = state.chartColor,
backgroundLineColor = state.chartColor.copy(alpha = backgroundColorAlpha),
secondLineColor = splitChartSegmentColor,
backgroundSecondLineColor = splitChartSegmentColor.copy(alpha = backgroundSplitChartSegmentColorAlpha),
secondColorOnTheRightSide = state.markerHighlightRightSide.not(),
markerFraction = state.markerFraction,
axisValueOverrider = AxisValueOverrider.fixed(),
canvasHeight = chartHeight,
)
val marker = rememberTangemChartMarker(color = state.chartColor)
val chart = rememberCartesianChart(
layer,
startAxis = rememberMarketChartStartAxis(
yValueFormatter = state.yValueFormatter,
),
bottomAxis = rememberMarketChartBottomAxis(
xValueFormatter = state.xValueFormatter,
),
startAxis = rememberMarketChartStartAxis(state.yValueFormatter),
bottomAxis = rememberMarketChartBottomAxis(state.xValueFormatter),
horizontalLayout = HorizontalLayout.FullWidth(),
markerVisibilityListener = rememberMarketVisibilityListener(canvasWidth, state),
marker = marker,
)
val marker = rememberTangemChartMarker(
color = state.chartColor,
innerCircleColor = Color.White,
)
val density = LocalDensity.current
// we need to calculate what the overall height should be in order to get the correct height of the graph
val bottomAxisHeight = with(LocalDensity.current) {
TangemTheme.typography.caption2.fontSize.toPx().toInt() + TangemTheme.dimens.spacing26.toPx().toInt()
}
CartesianChartHost(
modifier = modifier.onGloballyPositioned {
with(density) {
modifier = modifier
.onGloballyPositioned {
canvasWidth = it.size.width
canvasHeight = if (it.size.height != 0) {
// FIXME get height bounded to min max chart points
it.size.height - 20.dp.toPx().toInt() - 27.dp.toPx().toInt()
chartHeight = if (it.size.height != 0) {
it.size.height - bottomAxisHeight
} else {
0
}
}
},
},
chart = chart,
modelProducer = state.modelProducer,
scrollState = rememberVicoScrollState(scrollEnabled = false),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
markerVisibilityListener = state.rememberMarketVisibilityListener(canvasWidth = canvasWidth),
diffAnimationSpec = null,
marker = marker,
placeholder = noChartContent,
animationSpec = null,
)
}
@Composable
private fun MarketChartState.rememberMarketVisibilityListener(canvasWidth: Int): CartesianMarkerVisibilityListener {
val state = this
return remember(state.markerVisibilityListener, canvasWidth) {
private fun rememberMarketVisibilityListener(
canvasWidth: Int,
state: MarketChartState,
): CartesianMarkerVisibilityListener {
val haptic = LocalHapticFeedback.current
return remember(state, canvasWidth) {
val maxCanvasXFloat = canvasWidth.toFloat().takeIf { it != 0f }
object : CartesianMarkerVisibilityListener {
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
state.stopDrawingAnimation()
val xCanvas = (targets[0] as LineCartesianLayerMarkerTarget).canvasX
state.markerFraction = maxCanvasXFloat?.let { xCanvas / it }
state.markerVisibilityListener.onShown(marker, targets)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
override fun onHidden(marker: CartesianMarker) {
@ -149,38 +158,29 @@ private fun MarketChartState.rememberMarketVisibilityListener(canvasWidth: Int):
state.markerFraction = maxCanvasXFloat?.let { xCanvas / it }
state.markerVisibilityListener.onUpdated(marker, targets)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
}
}
@Composable
private fun rememberLayerFromState(
state: MarketChartState,
splitChartSegmentColor: Color,
@FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float,
@FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float,
canvasHeight: Int,
): LineCartesianLayer {
return rememberMarketChartLayer(
lineColor = state.chartColor,
backgroundLineColor = state.chartColor.copy(alpha = backgroundColorAlpha),
secondLineColor = splitChartSegmentColor,
backgroundSecondLineColor = splitChartSegmentColor.copy(alpha = backgroundSplitChartSegmentColorAlpha),
secondColorOnTheRightSide = state.markerHighlightRightSide.not(),
startDrawingAnimation = state.startDrawingAnimationState,
markerFraction = state.markerFraction,
axisValueOverrider = AxisValueOverrider.adaptiveYValues(yFraction = 1.2f, round = true), // FIXME ?
canvasHeight = canvasHeight,
)
}
@Composable
private fun rememberMarketChartStartAxis(
yValueFormatter: CartesianValueFormatter,
): VerticalAxis<AxisPosition.Vertical.Start> {
val textStyle = TangemTheme.typography.caption2
val resolver = LocalFontFamilyResolver.current
val typeface by remember(resolver, textStyle) {
resolver.resolveAsTypeface(
fontFamily = textStyle.fontFamily,
fontWeight = textStyle.fontWeight ?: FontWeight.Normal,
fontStyle = textStyle.fontStyle ?: FontStyle.Normal,
fontSynthesis = textStyle.fontSynthesis ?: FontSynthesis.All,
)
}
return rememberCustomStartAxis(
axis = null,
line = null,
tick = null,
guideline = null,
labelGuideline = rememberChartAxisGuidelineComponent(
@ -194,38 +194,44 @@ private fun rememberMarketChartStartAxis(
end = TangemTheme.dimens.spacing4,
),
textSize = TangemTheme.typography.caption2.fontSize,
typeface = TangemTheme.typography.caption2.toGraphicsTypeFace(),
typeface = typeface,
),
horizontalLabelPosition = VerticalAxis.HorizontalLabelPosition.Inside,
verticalLabelPosition = VerticalAxis.VerticalLabelPosition.Center,
itemPlacer = AxisItemPlacer.Vertical.count({ GUIDELINES_COUNT }, false),
itemPlacer = VerticalAxis.ItemPlacer.count({ GUIDELINES_COUNT }, false),
valueFormatter = yValueFormatter,
)
}
@Composable
fun rememberMarketChartBottomAxis(
private fun rememberMarketChartBottomAxis(
xValueFormatter: CartesianValueFormatter,
): HorizontalAxis<AxisPosition.Horizontal.Bottom> {
val textStyle = TangemTheme.typography.caption2
val resolver = LocalFontFamilyResolver.current
val typeface by remember(resolver, textStyle) {
resolver.resolveAsTypeface(
fontFamily = textStyle.fontFamily,
fontWeight = textStyle.fontWeight ?: FontWeight.Normal,
fontStyle = textStyle.fontStyle ?: FontStyle.Normal,
fontSynthesis = textStyle.fontSynthesis ?: FontSynthesis.All,
)
}
return rememberBottomAxis(
label = rememberAxisLabelComponent(
color = TangemTheme.colors.text.tertiary,
textSize = TangemTheme.typography.caption2.fontSize,
padding = Dimensions.of(top = TangemTheme.dimens.spacing20),
typeface = TangemTheme.typography.caption2.toGraphicsTypeFace(),
padding = Dimensions.of(top = TangemTheme.dimens.spacing26),
typeface = typeface,
),
tick = null,
axis = null,
line = null,
guideline = null,
sizeConstraint = BaseAxis.SizeConstraint.Exact(sizeDp = 37f), // FIXME ?
itemPlacer = remember {
AxisItemPlacer.Horizontal.default(
spacing = 25, // FIXME ?
offset = 60, // FIXME ?
shiftExtremeTicks = false,
addExtremeLabelPadding = false,
)
},
sizeConstraint = BaseAxis.SizeConstraint.Auto(),
itemPlacer = remember { TimeItemPlacer() },
valueFormatter = xValueFormatter,
)
}
@ -245,19 +251,6 @@ private fun rememberChartAxisGuidelineComponent(color: Color): LineComponent {
)
}
@Composable
internal fun TextStyle.toGraphicsTypeFace(): android.graphics.Typeface {
val resolver = LocalFontFamilyResolver.current
return remember(resolver, this) {
resolver.resolveAsTypeface(
fontFamily = this.fontFamily,
fontWeight = this.fontWeight ?: FontWeight.Normal,
fontStyle = this.fontStyle ?: FontStyle.Normal,
fontSynthesis = this.fontSynthesis ?: FontSynthesis.All,
)
}.value
}
// region Preview
@Suppress("LongMethod")
@ -275,7 +268,6 @@ private fun MarketChartPreview(
chartLook = MarketChartLook(
type = MarketChartLook.Type.Growing,
markerHighlightRightSide = true,
animationOnDataChange = true,
)
}
}
@ -283,8 +275,8 @@ private fun MarketChartPreview(
LaunchedEffect(key1 = Unit) {
dataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
x = x,
y = y,
x = x.toImmutableList(),
y = y.toImmutableList(),
)
updateLook {
it.copy(
@ -338,13 +330,9 @@ private fun MarketChartPreview(
splitChartSegmentColor = TangemTheme.colors.icon.inactive,
backgroundSplitChartSegmentColorAlpha = 0.24f,
backgroundColorAlpha = 0.24f,
noChartContent = { },
)
SpacerH16()
Button(onClick = { chartState.startDrawingAnimation() }) {
Text("Start drawing animation")
}
Button(
onClick = {
dataProducer.runTransaction {
@ -365,7 +353,7 @@ private fun MarketChartPreview(
updateData {
MarketChartData.Data(
x = it.x,
y = it.y.reversed(),
y = it.y.reversed().toImmutableList(),
)
}
}
@ -374,15 +362,6 @@ private fun MarketChartPreview(
) {
Text("Change Data")
}
Button(
onClick = {
dataProducer.runTransaction {
updateLook { it.copy(animationOnDataChange = it.animationOnDataChange.not()) }
}
},
) {
Text("Change animationOnDataChange = ${look.animationOnDataChange}")
}
Button(
onClick = {

View file

@ -12,19 +12,21 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.*
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec
import com.patrykandpatrick.vico.compose.common.shader.BrushShader
import com.patrykandpatrick.vico.compose.common.shader.toDynamicShader
import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout
import com.patrykandpatrick.vico.core.cartesian.Zoom
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.core.common.shader.ColorShader
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.toImmutableList
import kotlin.random.Random
@Composable
@ -44,18 +46,19 @@ fun MarketChartMini(
MarketChartLook.Type.Falling -> fallingColor
}
val lineSpec = rememberLineSpec(
val lineSpec = rememberLine(
shader = ColorShader(lineColor.toArgb()),
thickness = 1.dp,
backgroundShader = BrushShader(
brush = Brush.verticalGradient(
colors = listOf(lineColor.copy(alpha = 0.22f), Color.Transparent),
),
),
backgroundShader = Brush.verticalGradient(
colors = listOf(lineColor.copy(alpha = 0.22f), Color.Transparent),
).toDynamicShader(),
)
val layer = rememberLineCartesianLayer(listOf(lineSpec))
val chart = rememberCartesianChart(layer)
val layer = rememberLineCartesianLayer(LineCartesianLayer.LineProvider.series(lineSpec))
val chart = rememberCartesianChart(
layer,
horizontalLayout = HorizontalLayout.fullWidth(),
)
CartesianChartHost(
modifier = modifier,
@ -63,7 +66,6 @@ fun MarketChartMini(
model = model,
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
scrollState = rememberVicoScrollState(scrollEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
)
}
@ -74,8 +76,8 @@ fun MarketChartMini(
@Composable
private fun Preview() {
val data = MarketChartRawData(
x = List(20) { Random.nextFloat() },
y = List(20) { Random.nextFloat() },
x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(),
y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(),
)
TangemThemePreview {
@ -92,8 +94,8 @@ private fun Preview() {
@Composable
private fun PreviewColumn() {
val data = MarketChartRawData(
x = List(20) { Random.nextFloat() },
y = List(20) { Random.nextFloat() },
x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(),
y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(),
)
TangemThemePreview {

View file

@ -0,0 +1,246 @@
package com.tangem.common.ui.charts.downsample
import kotlin.math.max
/**
* =========================================================
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* =========================================================
*
* Downsamples the given data points to the desired number of buckets (points + 2).
*
[REDACTED_AUTHOR]
*/
object LTThreeBuckets {
fun downsample(x: List<Double>, y: List<Double>, desiredBuckets: Int): Result {
require(x.size == y.size) { "X and Y must have the same size" }
require(desiredBuckets > 0) { "Desired buckets must be greater than 0" }
val points = x.zip(y).mapIndexed { index, (x, y) -> Point(index, x, y) }
val results = mutableListOf<Point>()
points.onPassBucketize(desiredBuckets)
.sliding(size = 3, step = 1)
.map { buckets -> Triangle.of(buckets) }
.fastForEach { triangle ->
if (results.isEmpty()) {
results.add(triangle.getFirst())
}
results.add(triangle.getResult())
if (results.size == desiredBuckets + 1) {
results.add(triangle.getLast())
}
}
val xRes = ArrayList<Double>(points.size)
val yRes = ArrayList<Double>(points.size)
val indexesRes = ArrayList<Int>(points.size)
results.fastForEach {
xRes.add(it.x)
yRes.add(it.y)
indexesRes.add(it.originalIndex!!)
}
return Result(
originalIndexes = indexesRes,
x = xRes,
y = yRes,
)
}
data class Result(
val originalIndexes: List<Int>,
val x: List<Double>,
val y: List<Double>,
)
}
private fun List<Point>.onPassBucketize(desiredBucketsCount: Int): List<Bucket> {
val middleSize = size - 2
val bucketSize = middleSize / desiredBucketsCount
val remainingElements = middleSize % desiredBucketsCount
require(bucketSize != 0) {
"Can't produce $desiredBucketsCount buckets from an input series of ${middleSize + 2} elements"
}
val buckets = mutableListOf<Bucket>()
// Add first point as the only point in the first bucket
buckets.add(Bucket.of(this[0]))
var rest = this.subList(1, this.lastIndex)
// Add middle buckets.
// When inputSize is not a multiple of desiredBuckets,
// remaining elements are equally distributed on the first buckets.
while (buckets.size < desiredBucketsCount + 1) {
val size = if (buckets.size <= remainingElements) bucketSize + 1 else bucketSize
buckets.add(Bucket.of(rest.subList(0, size)))
rest = rest.subList(size, rest.size)
}
// Add last point as the only point in the last bucket
buckets.add(Bucket.of(this.last()))
return buckets
}
private fun List<Bucket>.sliding(size: Int, step: Int): List<List<Bucket>> {
val window = max(size, step)
val buffer = ArrayDeque<Bucket>()
var totalIn = 0
val lists = mutableListOf<List<Bucket>>()
fastForEach { p ->
buffer.add(p)
++totalIn
if (buffer.size == window) {
val batch = buffer.take(size)
lists.add(batch)
repeat(step) {
buffer.removeFirst()
}
}
}
if (buffer.isNotEmpty()) {
val totalOut = max(0, (totalIn + step - size - 1) / step) + 1
if (totalOut > lists.size) {
val batch = buffer.take(size)
lists.add(batch)
}
}
return lists
}
private data class Point(
val originalIndex: Int? = null,
val x: Double,
val y: Double,
)
private data class Bucket(
val data: List<Point>,
val center: Point,
val result: Point,
val first: Point,
val last: Point,
) {
companion object {
private fun centerBetweenPoints(a: Point, b: Point): Point {
val vector = Point(
x = b.x - a.x,
y = b.y - a.y,
)
val halfVector = Point(
x = vector.x / 2,
y = vector.y / 2,
)
return Point(
x = a.x + halfVector.x,
y = a.y + halfVector.y,
)
}
fun of(points: List<Point>): Bucket {
val first = points.first()
val last = points.last()
return Bucket(
data = points,
center = centerBetweenPoints(first, last),
result = first,
first = first,
last = last,
)
}
fun of(point: Point): Bucket {
return Bucket(
data = listOf(point),
center = point,
result = point,
first = point,
last = point,
)
}
}
}
private data class Triangle(
val left: Bucket,
val center: Bucket,
val right: Bucket,
) {
fun getResult(): Point {
return center.data.map { Area.ofTriangle(left.result, it, right.center) }
.maxByOrNull { it.value }
?.generator
?: error("Can't obtain max area triangle")
}
fun getFirst(): Point {
return left.first
}
fun getLast(): Point {
return right.last
}
companion object {
fun of(buckets: List<Bucket>): Triangle {
return Triangle(
left = buckets[0],
center = buckets[1],
right = buckets[2],
)
}
}
}
private data class Area(
val generator: Point,
val value: Double,
) {
companion object {
fun ofTriangle(a: Point, b: Point, c: Point): Area {
val addends = listOf(
a.x * (b.y - c.y),
b.x * (c.y - a.y),
c.x * (a.y - b.y),
)
val sum = addends.sum()
val value = kotlin.math.abs(sum / 2)
return Area(b, value)
}
}
}
inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
for (index in indices) {
val item = get(index)
action(item)
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.common.ui.charts.layer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.common.component.rememberUnboundedLineComponent
import com.patrykandpatrick.vico.compose.common.component.shapeComponent
import com.patrykandpatrick.vico.compose.common.of
import com.patrykandpatrick.vico.compose.common.shape.dashed
import com.patrykandpatrick.vico.core.cartesian.*
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerValueFormatter
import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker
import com.patrykandpatrick.vico.core.common.Dimensions
import com.patrykandpatrick.vico.core.common.LayeredComponent
import com.patrykandpatrick.vico.core.common.component.Component
import com.patrykandpatrick.vico.core.common.component.TextComponent
import com.patrykandpatrick.vico.core.common.shape.Shape
import com.tangem.core.ui.res.TangemTheme
/**
* @param color The color of the indicator and guideline.
* @param innerCircleColor The color of the inner circle of the indicator.
*
* @return A [CartesianMarker] that consists of a dashed guideline and a layered indicator with a shadow effect.
*/
@Composable
internal fun rememberTangemChartMarker(color: Color): CartesianMarker {
val guideline = rememberUnboundedLineComponent(
color = color,
verticalAddDrawSpace = TangemTheme.dimens.spacing24,
shape = remember { Shape.dashed(Shape.Rectangle, 4.dp, 4.dp) },
)
return remember(guideline) {
val outColor = guideline.color
object : DefaultCartesianMarker(
label = TextComponent(textSizeSp = 0f),
indicator = ::indicator,
indicatorSizeDp = INDICATOR_SIZE_DP,
guideline = guideline,
valueFormatter = object : CartesianMarkerValueFormatter {
override fun format(
context: CartesianDrawContext,
targets: List<CartesianMarker.Target>,
): CharSequence = ""
},
) {
override fun updateInsets(
context: CartesianMeasureContext,
horizontalDimensions: HorizontalDimensions,
model: CartesianChartModel,
insets: Insets,
) {
with(context) {
super.updateInsets(context, horizontalDimensions, model, insets)
val baseShadowInsetDp =
CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER * LABEL_BACKGROUND_SHADOW_RADIUS_DP
val topInset = (baseShadowInsetDp - LABEL_BACKGROUND_SHADOW_DY_DP).pixels
val bottomInset = (baseShadowInsetDp + LABEL_BACKGROUND_SHADOW_DY_DP).pixels
insets.ensureValuesAtLeast(top = topInset, bottom = bottomInset)
}
}
override fun CartesianDrawContext.drawIndicator(x: Float, y: Float, color: Int, halfIndicatorSize: Float) {
val indicator = indicator ?: return
cacheStore
.getOrSet(keyNamespace, indicator, outColor) { indicator.invoke(outColor) }
.draw(
this,
x - halfIndicatorSize,
y - halfIndicatorSize,
x + halfIndicatorSize,
y + halfIndicatorSize,
)
}
}
}
}
private fun indicator(color: Int): Component {
val composeColor = Color(color)
return LayeredComponent(
rear = shapeComponent(
color = composeColor.copy(alpha = INDICATOR_REAR_COLOR_ALPHA),
shape = Shape.Pill,
),
front = LayeredComponent(
rear = shapeComponent(
color = composeColor,
shape = Shape.Pill,
),
front = shapeComponent(
color = Color.White,
shape = Shape.Pill,
),
padding = indicatorPadding,
),
padding = indicatorPadding,
)
}
private val indicatorPadding = Dimensions.of(3.dp)
private const val LABEL_BACKGROUND_SHADOW_RADIUS_DP = 4f
private const val LABEL_BACKGROUND_SHADOW_DY_DP = 2f
private const val CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER = 1.4f
private const val INDICATOR_SIZE_DP = 16f
private const val INDICATOR_REAR_COLOR_ALPHA = .24f

View file

@ -2,9 +2,6 @@ package com.tangem.common.ui.charts.layer
import android.content.res.Configuration
import androidx.annotation.FloatRange
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -20,22 +17,22 @@ import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.fullWidth
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberSplitLineSpec
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberSplitLine
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import com.patrykandpatrick.vico.compose.common.shader.BrushShader
import com.patrykandpatrick.vico.compose.common.shader.toDynamicShader
import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout
import com.patrykandpatrick.vico.core.cartesian.Zoom
import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.core.common.shader.ColorShader
import com.patrykandpatrick.vico.core.common.shader.DynamicShader
import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
/**
@ -59,117 +56,50 @@ internal fun rememberMarketChartLayer(
backgroundLineColor: Color,
secondLineColor: Color,
backgroundSecondLineColor: Color,
startDrawingAnimation: MutableState<Boolean>,
axisValueOverrider: AxisValueOverrider,
secondColorOnTheRightSide: Boolean,
@FloatRange(from = 0.0, to = 1.0) markerFraction: Float?,
canvasHeight: Int,
): LineCartesianLayer {
var animationFraction: Float? by remember { mutableStateOf(null) }
val backgroundColorLineGradient = persistentListOf(backgroundLineColor, Color.Transparent)
val backgroundSecondLineColorGradient = persistentListOf(backgroundSecondLineColor, Color.Transparent)
LaunchedEffect(startDrawingAnimation.value) {
animationFraction = null
if (startDrawingAnimation.value) {
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = tween(easing = LinearEasing, durationMillis = 1000),
) { start, _ ->
if (start == 1f) {
animationFraction = null
startDrawingAnimation.value = false
} else {
animationFraction = start
}
}
}
}
val markerSet = markerFraction != null
return rememberRawMarketChartLayer(
lineColor = lineColor,
backgroundLineColor = backgroundLineColor,
secondLineColor = secondLineColor,
backgroundSecondLineColor = backgroundSecondLineColor,
return rememberLayer(
fractionValue = markerFraction ?: 0f,
axisValueOverrider = axisValueOverrider,
secondColorOnTheRightSide = secondColorOnTheRightSide,
markerFraction = markerFraction,
animationFraction = animationFraction,
canvasHeight = canvasHeight,
lineColor = if (markerFraction != null) {
secondLineColor
} else {
lineColor
},
backLineColor = if (markerSet && !secondColorOnTheRightSide) {
backgroundSecondLineColorGradient
} else {
backgroundColorLineGradient
},
lineColorRight = when {
markerSet && secondColorOnTheRightSide -> secondLineColor
else -> lineColor
},
backLineColorRight = when {
markerSet && secondColorOnTheRightSide -> backgroundSecondLineColorGradient
else -> backgroundColorLineGradient
},
)
}
@Suppress("LongParameterList")
@Composable
private fun rememberRawMarketChartLayer(
lineColor: Color,
backgroundLineColor: Color,
secondLineColor: Color,
backgroundSecondLineColor: Color,
axisValueOverrider: AxisValueOverrider,
canvasHeight: Int,
secondColorOnTheRightSide: Boolean = false,
@FloatRange(from = 0.0, to = 1.0) markerFraction: Float? = null,
@FloatRange(from = 0.0, to = 1.0) animationFraction: Float? = null,
): LineCartesianLayer {
val backgroundColorLineGradient = listOf(backgroundLineColor, Color.Transparent)
val backgroundSecondLineColorGradient = listOf(backgroundSecondLineColor, Color.Transparent)
val markerSet = markerFraction != null
val animationRunning = animationFraction != null && animationFraction != 1f
val layerColors = when {
!animationRunning && markerSet && secondColorOnTheRightSide -> {
LayerColors(
lineColor = lineColor,
backLineColor = backgroundColorLineGradient,
lineColorRight = secondLineColor,
backLineColorRight = backgroundSecondLineColorGradient,
)
}
!animationRunning && markerSet && !secondColorOnTheRightSide -> {
LayerColors(
lineColor = secondLineColor,
backLineColor = backgroundSecondLineColorGradient,
lineColorRight = lineColor,
backLineColorRight = backgroundColorLineGradient,
)
}
animationRunning -> {
LayerColors(
lineColor = lineColor,
backLineColor = backgroundColorLineGradient,
lineColorRight = Color.Transparent,
backLineColorRight = listOf(Color.Transparent, Color.Transparent),
)
}
else -> {
LayerColors(
lineColor = lineColor,
backLineColor = backgroundColorLineGradient,
)
}
}
return rememberLayer(
fractionValue = animationFraction ?: markerFraction,
axisValueOverrider = axisValueOverrider,
layerColors = layerColors,
canvasHeight = canvasHeight,
)
}
private data class LayerColors(
val lineColor: Color,
val backLineColor: List<Color>,
val lineColorRight: Color? = null,
val backLineColorRight: List<Color>? = null,
)
@Composable
private fun rememberLayer(
fractionValue: Float?,
fractionValue: Float,
axisValueOverrider: AxisValueOverrider,
layerColors: LayerColors,
lineColor: Color,
backLineColor: ImmutableList<Color>,
lineColorRight: Color,
backLineColorRight: ImmutableList<Color>,
canvasHeight: Int,
): LineCartesianLayer {
val endGradientColorPosition = if (canvasHeight != 0) {
@ -178,47 +108,27 @@ private fun rememberLayer(
Float.POSITIVE_INFINITY
}
val alineColor = remember(lineColor) { lineColor.toArgb() }
val alineColorRight = remember(lineColorRight) { lineColorRight.toArgb() }
return rememberLineCartesianLayer(
listOf(
if (layerColors.lineColorRight == null || layerColors.backLineColorRight == null || fractionValue == null) {
rememberLineSpec(
shader = remember(layerColors.lineColor) { ColorShader(color = layerColors.lineColor.toArgb()) },
backgroundShader = remember(layerColors.backLineColor, endGradientColorPosition) {
BrushShader(
brush = Brush.verticalGradient(
colors = layerColors.backLineColor,
endY = endGradientColorPosition,
),
)
},
)
} else {
rememberSplitLineSpec(
shader = remember(layerColors.lineColor, layerColors.lineColorRight, fractionValue) {
DynamicShader.Companion.horizontalGradient(
colors = intArrayOf(layerColors.lineColor.toArgb(), layerColors.lineColorRight.toArgb()),
positions = floatArrayOf(fractionValue, fractionValue),
)
},
backgroundShaderFirst = remember(layerColors.backLineColor, endGradientColorPosition) {
BrushShader(
brush = Brush.verticalGradient(
colors = layerColors.backLineColor,
endY = endGradientColorPosition,
),
)
},
backgroundShaderSecond = remember(layerColors.backLineColorRight, endGradientColorPosition) {
BrushShader(
brush = Brush.verticalGradient(
colors = layerColors.backLineColorRight,
endY = endGradientColorPosition,
),
)
},
xSplitFraction = fractionValue,
)
},
LineCartesianLayer.LineProvider.series(
rememberSplitLine(
shader = DynamicShader.Companion.horizontalGradient(
colors = intArrayOf(alineColor, alineColorRight),
positions = floatArrayOf(fractionValue, fractionValue),
),
backgroundShaderFirst = Brush.verticalGradient(
colors = backLineColor,
endY = endGradientColorPosition,
).toDynamicShader(),
backgroundShaderSecond = Brush.verticalGradient(
colors = backLineColorRight,
endY = endGradientColorPosition,
).toDynamicShader(),
xSplitFraction = fractionValue,
thickness = 1.dp,
),
),
axisValueOverrider = axisValueOverrider,
)
@ -250,25 +160,26 @@ private fun LayerChartPreview(
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberRawMarketChartLayer(
rememberMarketChartLayer(
lineColor = lineColor,
backgroundLineColor = lineColor.copy(alpha = 0.24f),
secondLineColor = Color.Gray,
backgroundSecondLineColor = Color.Gray.copy(alpha = 0.24f),
secondColorOnTheRightSide = true,
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
markerFraction = 0.35f,
canvasHeight = 495,
),
horizontalLayout = HorizontalLayout.fullWidth(),
),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
model = model,
)
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberRawMarketChartLayer(
rememberMarketChartLayer(
lineColor = lineColor,
backgroundLineColor = lineColor.copy(alpha = 0.24f),
secondLineColor = Color.Gray,
@ -278,16 +189,16 @@ private fun LayerChartPreview(
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
canvasHeight = 495,
),
horizontalLayout = HorizontalLayout.fullWidth(),
),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
model = model,
)
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberRawMarketChartLayer(
rememberMarketChartLayer(
lineColor = lineColor,
backgroundLineColor = lineColor.copy(alpha = 0.24f),
secondLineColor = Color.Gray,
@ -297,29 +208,9 @@ private fun LayerChartPreview(
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
canvasHeight = 495,
),
horizontalLayout = HorizontalLayout.fullWidth(),
),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
model = model,
)
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberRawMarketChartLayer(
lineColor = lineColor,
backgroundLineColor = lineColor.copy(alpha = 0.24f),
secondLineColor = lineColor,
backgroundSecondLineColor = lineColor.copy(alpha = 0.24f),
markerFraction = 0.35f,
secondColorOnTheRightSide = true,
animationFraction = 0.7f,
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
canvasHeight = 495,
),
),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
model = model,
)
}

View file

@ -0,0 +1,56 @@
package com.tangem.common.ui.charts.layer
import com.patrykandpatrick.vico.core.cartesian.CartesianDrawContext
import com.patrykandpatrick.vico.core.cartesian.CartesianMeasureContext
import com.patrykandpatrick.vico.core.cartesian.HorizontalDimensions
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.core.cartesian.data.ChartValues
@Suppress("MagicNumber")
class TimeItemPlacer : HorizontalAxis.ItemPlacer {
private val ChartValues.measuredLabelValues
get() = buildList {
// produce exactly 6 values distributed evenly
val xLength = maxX - minX
val xStep = xLength / 7
repeat(times = 6) {
add(minX + xStep * (it + 1))
}
}
override fun getEndHorizontalAxisInset(
context: CartesianMeasureContext,
horizontalDimensions: HorizontalDimensions,
tickThickness: Float,
maxLabelWidth: Float,
): Float = 0f
override fun getStartHorizontalAxisInset(
context: CartesianMeasureContext,
horizontalDimensions: HorizontalDimensions,
tickThickness: Float,
maxLabelWidth: Float,
): Float = 0f
override fun getHeightMeasurementLabelValues(
context: CartesianMeasureContext,
horizontalDimensions: HorizontalDimensions,
fullXRange: ClosedFloatingPointRange<Double>,
maxLabelWidth: Float,
): List<Double> = context.chartValues.measuredLabelValues
override fun getLabelValues(
context: CartesianDrawContext,
visibleXRange: ClosedFloatingPointRange<Double>,
fullXRange: ClosedFloatingPointRange<Double>,
maxLabelWidth: Float,
): List<Double> = context.chartValues.measuredLabelValues
override fun getWidthMeasurementLabelValues(
context: CartesianMeasureContext,
horizontalDimensions: HorizontalDimensions,
fullXRange: ClosedFloatingPointRange<Double>,
): List<Double> = context.chartValues.measuredLabelValues
}

View file

@ -1,146 +0,0 @@
package com.tangem.common.ui.charts.marker
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.fullWidth
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import com.patrykandpatrick.vico.compose.common.component.rememberLayeredComponent
import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent
import com.patrykandpatrick.vico.compose.common.component.rememberUnboundedLineComponent
import com.patrykandpatrick.vico.compose.common.of
import com.patrykandpatrick.vico.compose.common.shader.color
import com.patrykandpatrick.vico.compose.common.shape.dashed
import com.patrykandpatrick.vico.core.cartesian.*
import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker
import com.patrykandpatrick.vico.core.common.Dimensions
import com.patrykandpatrick.vico.core.common.component.TextComponent
import com.patrykandpatrick.vico.core.common.shader.DynamicShader
import com.patrykandpatrick.vico.core.common.shape.Shape
import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import java.math.BigDecimal
/**
* @param color The color of the indicator and guideline.
* @param innerCircleColor The color of the inner circle of the indicator.
*
* @return A [CartesianMarker] that consists of a dashed guideline and a layered indicator with a shadow effect.
*/
@Composable
internal fun rememberTangemChartMarker(color: Color, innerCircleColor: Color): CartesianMarker {
val indicatorFrontComponent = rememberShapeComponent(
shape = Shape.Pill,
color = innerCircleColor,
)
val indicatorCenterComponent = rememberShapeComponent(
shape = Shape.Pill,
color = color,
)
val indicatorRearComponent = rememberShapeComponent(
shape = Shape.Pill,
color = if (color == Color.Transparent) {
Color.Transparent
} else {
color.copy(alpha = INDICATOR_REAR_COLOR_ALPHA)
},
)
val indicator = rememberLayeredComponent(
rear = indicatorRearComponent,
front = rememberLayeredComponent(
rear = indicatorCenterComponent,
front = indicatorFrontComponent,
padding = indicatorPadding,
),
padding = indicatorPadding,
)
val guideline = rememberUnboundedLineComponent(
color = color,
verticalAddDrawSpace = TangemTheme.dimens.spacing24,
shape = remember { Shape.dashed(Shape.Rectangle, 4.dp, 4.dp) },
)
return remember(indicator, guideline) {
object : DefaultCartesianMarker(
label = TextComponent.build { textSizeSp = 0f },
indicator = indicator,
indicatorSizeDp = INDICATOR_SIZE_DP,
guideline = guideline,
) {
override fun getInsets(
context: CartesianMeasureContext,
outInsets: Insets,
horizontalDimensions: HorizontalDimensions,
) {
with(context) {
super.getInsets(context, outInsets, horizontalDimensions)
val baseShadowInsetDp =
CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER * LABEL_BACKGROUND_SHADOW_RADIUS_DP
outInsets.top += (baseShadowInsetDp - LABEL_BACKGROUND_SHADOW_DY_DP).pixels
outInsets.bottom += (baseShadowInsetDp + LABEL_BACKGROUND_SHADOW_DY_DP).pixels
}
}
}
}
}
private val indicatorPadding = Dimensions.of(3.dp)
private const val LABEL_BACKGROUND_SHADOW_RADIUS_DP = 4f
private const val LABEL_BACKGROUND_SHADOW_DY_DP = 2f
private const val CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER = 1.4f
private const val INDICATOR_SIZE_DP = 16f
private const val INDICATOR_REAR_COLOR_ALPHA = .24f
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemChartMarkerPreview(
@PreviewParameter(MarketChartPreviewDataProvider::class) previewData: Pair<List<BigDecimal>, List<BigDecimal>>,
) {
val marker = rememberTangemChartMarker(Color.Red, Color.White)
val y = previewData.second.map { it.toFloat() }
val x = List(y.size) { it.toFloat() }
val model = CartesianChartModel(LineCartesianLayerModel.build { series(x, y) })
val centerAprx = (model.models[0].minX + model.models[0].maxX) / 2f
val center = model.models[0].getXDeltaGcd().let { centerAprx - centerAprx % it }
TangemThemePreview {
Box(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberLineCartesianLayer(
listOf(rememberLineSpec(shader = DynamicShader.color(Color.Blue))),
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
),
persistentMarkers = mapOf(center to marker),
),
model = model,
marker = marker,
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
)
}
}
}
// endregion Preview

View file

@ -1,6 +1,8 @@
package com.tangem.common.ui.charts.state
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Immutable
@ -30,7 +32,7 @@ sealed interface MarketChartData {
*/
@Immutable
data class Data(
val x: List<BigDecimal> = listOf(),
val y: List<BigDecimal> = listOf(),
val x: ImmutableList<BigDecimal> = persistentListOf(),
val y: ImmutableList<BigDecimal> = persistentListOf(),
) : MarketChartData
}

View file

@ -3,13 +3,13 @@ package com.tangem.common.ui.charts.state
import androidx.compose.runtime.Stable
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel
import com.patrykandpatrick.vico.core.common.data.ExtraStore
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import com.tangem.common.ui.charts.state.converter.PointValuesConverter
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.formatter.FormatterWrapWithCache
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* This class represents a transaction for updating the state and look of a Market Chart.
@ -25,7 +25,12 @@ class Transaction(
var chartData: MarketChartData.NoData? = null
fun updateLook(block: (prev: MarketChartLook) -> MarketChartLook) {
chartLook = block(currentLook)
val newLook = block(currentLook)
chartLook = newLook.copy(
xAxisFormatter = FormatterWrapWithCache(newLook.xAxisFormatter),
yAxisFormatter = FormatterWrapWithCache(newLook.yAxisFormatter),
)
}
fun updateState(block: (prev: MarketChartData) -> MarketChartData.NoData) {
@ -56,7 +61,12 @@ class TransactionSuspend(
}
fun updateLook(block: (prev: MarketChartLook) -> MarketChartLook) {
chartLook = block(currentLook)
val newLook = block(currentLook)
chartLook = newLook.copy(
xAxisFormatter = FormatterWrapWithCache(newLook.xAxisFormatter),
yAxisFormatter = FormatterWrapWithCache(newLook.yAxisFormatter),
)
}
internal fun updateState(block: (prev: MarketChartData) -> MarketChartData) {
@ -75,21 +85,24 @@ class TransactionSuspend(
class MarketChartDataProducer private constructor(
initialData: MarketChartData,
initialLook: MarketChartLook,
val pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter,
val pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true),
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
internal val startDrawingAnimation = MutableSharedFlow<Unit>()
internal val dataState = MutableStateFlow(initialData)
internal val lookState = MutableStateFlow(initialLook)
internal val entries = MutableStateFlow<List<LineCartesianLayerModel.Entry>>(emptyList())
internal val modelProducer = CartesianChartModelProducer.build(dispatcher = dispatcher)
internal val modelProducer = CartesianChartModelProducer(dispatcher = dispatcher)
internal val rawData = MutableStateFlow<MarketChartRawData?>(null)
private val mutex = Mutex()
/**
* This function runs a suspending transaction block to update the state and look of the Market Chart.
*/
suspend fun runTransactionSuspend(block: TransactionSuspend.() -> Unit) =
handleTransactionSuspend(transaction = TransactionSuspend(dataState.value, lookState.value).apply(block))
suspend fun runTransactionSuspend(block: TransactionSuspend.() -> Unit) = withContext(dispatcher) {
mutex.withLock {
handleTransactionSuspend(transaction = TransactionSuspend(dataState.value, lookState.value).apply(block))
}
}
/**
* This function runs a non-suspending transaction block to update the state and look of the Market Chart.
@ -102,32 +115,30 @@ class MarketChartDataProducer private constructor(
val chartData = transaction.chartData
val oldData = dataState.value
if (chartData != null) {
dataState.value = chartData
}
if (chartData is MarketChartData.Data && (oldData !is MarketChartData.Data || oldData != chartData)) {
if (lookState.value.animationOnDataChange) {
startDrawingAnimation.emit(Unit)
}
withContext(dispatcher) {
val rawData = pointsValuesConverter.convert(chartData)
(lookState.value.xAxisFormatter as? FormatterWrapWithCache)?.clearCache()
(lookState.value.yAxisFormatter as? FormatterWrapWithCache)?.clearCache()
val entriesLocal =
rawData.x.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, rawData.y[index]) }
val rawData = pointsValuesConverter.convert(chartData)
entries.value = entriesLocal
val entriesLocal =
rawData.x.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, rawData.y[index]) }
currentCoroutineContext().ensureActive()
runCatching {
modelProducer.runTransaction {
add(LineCartesianLayerModel.Partial(series = listOf(entriesLocal)))
updateExtras {
it[entriesKey] = entriesLocal
it[xKey] = chartData.x
it[yKey] = chartData.y
}
}.await()
}
}
entries.value = entriesLocal
dataState.value = chartData
this.rawData.value = rawData
delay(timeMillis = 200)
} else if (chartData != null) {
dataState.value = chartData
}
nonSuspendTransaction?.let { handleTransaction(it) }
@ -143,10 +154,6 @@ class MarketChartDataProducer private constructor(
}
companion object {
internal val entriesKey = ExtraStore.Key<List<LineCartesianLayerModel.Entry>>()
internal val xKey = ExtraStore.Key<List<BigDecimal>>()
internal val yKey = ExtraStore.Key<List<BigDecimal>>()
private val initialData: MarketChartData = MarketChartData.NoData.Empty
private val initialLook: MarketChartLook = MarketChartLook()
@ -159,7 +166,7 @@ class MarketChartDataProducer private constructor(
* @return A MarketChartDataProducer.
*/
suspend fun buildSuspend(
pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter,
pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true),
dispatcher: CoroutineDispatcher = Dispatchers.Default,
block: TransactionSuspend.() -> Unit,
): MarketChartDataProducer {
@ -184,7 +191,7 @@ class MarketChartDataProducer private constructor(
* @return A MarketChartDataProducer.
*/
fun build(
pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter,
pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true),
dispatcher: CoroutineDispatcher = Dispatchers.Default,
block: Transaction.() -> Unit,
): MarketChartDataProducer {

View file

@ -1,5 +1,8 @@
package com.tangem.common.ui.charts.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.formatter.AxisLabelFormatter
/**
* This class represents the look and feel of a Market Chart.
* It includes properties for type, marker highlight, animation on data change, animate data appearance,
@ -7,16 +10,13 @@ package com.tangem.common.ui.charts.state
*
* @property type The type of the chart, can be either Growing or Falling.
* @property markerHighlightRightSide A boolean indicating whether the marker highlights the right side of the chart.
* @property animationOnDataChange A boolean indicating whether to animate on data change.
* @property animateDataAppearance A boolean indicating whether to animate data appearance.
* @property xAxisFormatter A formatter for the x-axis labels.
* @property yAxisFormatter A formatter for the y-axis labels.
*/
@Immutable
data class MarketChartLook(
val type: Type = Type.Growing,
val markerHighlightRightSide: Boolean = true,
val animationOnDataChange: Boolean = false,
val animateDataAppearance: Boolean = false,
val xAxisFormatter: AxisLabelFormatter = AxisLabelFormatter { it.toString() },
val yAxisFormatter: AxisLabelFormatter = AxisLabelFormatter { it.toString() },
) {

View file

@ -1,9 +1,20 @@
package com.tangem.common.ui.charts.state
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* This class represents raw data for a Market Chart. Used for drawing the chart.
*
* @property originalIndexes If the source data has the original representation (due to reduced sampling),
* this list contains the original indexes of the data points.
* @property y The list of y-values.
* @property x The list of x-values.
*/
@Immutable
data class MarketChartRawData(
val y: List<Float>,
val x: List<Float> = List(y.size) { 1f },
val originalIndexes: ImmutableList<Int>? = null,
val y: ImmutableList<Double>,
val x: ImmutableList<Double> = List(y.size) { 1.0 }.toImmutableList(),
)

View file

@ -2,7 +2,6 @@ package com.tangem.common.ui.charts.state
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener
@ -20,26 +19,22 @@ import java.math.BigDecimal
@Composable
fun rememberMarketChartState(
dataProducer: MarketChartDataProducer = remember { MarketChartDataProducer.build {} },
colorMapper: (MarketChartLook.Type) -> Color = {
when (it) {
MarketChartLook.Type.Growing -> Color.Green
MarketChartLook.Type.Falling -> Color.Red
colorMapper: (MarketChartLook.Type) -> Color = remember {
{
when (it) {
MarketChartLook.Type.Growing -> Color.Green
MarketChartLook.Type.Falling -> Color.Red
}
}
},
onMarkerShown: (x: BigDecimal?, y: BigDecimal?) -> Unit = { _, _ -> },
): MarketChartState {
val lookState = dataProducer.lookState.collectAsStateWithLifecycle()
val lookState = dataProducer.lookState.collectAsState()
val state = remember(dataProducer, lookState, colorMapper, onMarkerShown) {
MarketChartState(dataProducer, lookState, colorMapper, onMarkerShown)
}
LaunchedEffect(Unit) {
dataProducer.startDrawingAnimation.collect {
state.startDrawingAnimation()
}
}
return state
}
@ -59,7 +54,6 @@ class MarketChartState internal constructor(
private val colorMapper: (MarketChartLook.Type) -> Color,
private val markerCallback: (x: BigDecimal?, y: BigDecimal?) -> Unit,
) {
internal val startDrawingAnimationState = mutableStateOf(false)
internal val modelProducer = dataProducer.modelProducer
internal val chartColor by derivedStateOf {
@ -70,29 +64,29 @@ class MarketChartState internal constructor(
lookState.value.markerHighlightRightSide
}
internal val xValueFormatter by derivedStateOf {
CartesianValueFormatter { value, _, _ ->
val state = dataProducer.dataState.value as? MarketChartData.Data
?: return@CartesianValueFormatter value.toString()
internal val xValueFormatter = CartesianValueFormatter { value, _, _ ->
val formatter = dataProducer.lookState.value.xAxisFormatter
lookState.value.xAxisFormatter.format(
value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state),
)
}
val state = dataProducer.dataState.value as? MarketChartData.Data
?: return@CartesianValueFormatter value.toString()
formatter.format(
value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state),
)
}
internal val yValueFormatter by derivedStateOf {
CartesianValueFormatter { value, _, _ ->
val state = dataProducer.dataState.value as? MarketChartData.Data
?: return@CartesianValueFormatter value.toString()
internal val yValueFormatter = CartesianValueFormatter { value, _, _ ->
val formatter = dataProducer.lookState.value.yAxisFormatter
lookState.value.yAxisFormatter.format(
value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state),
)
}
val state = dataProducer.dataState.value as? MarketChartData.Data
?: return@CartesianValueFormatter value.toString()
formatter.format(
value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state),
)
}
internal var markerFraction: Float? by mutableStateOf(null)
internal var markerFraction by mutableStateOf<Float?>(null)
internal val markerVisibilityListener = object : CartesianMarkerVisibilityListener {
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
@ -116,24 +110,17 @@ class MarketChartState internal constructor(
}
}
val isDrawingAnimationInProgress: Boolean by derivedStateOf {
startDrawingAnimationState.value
}
private fun getPoint(targets: List<CartesianMarker.Target>): Pair<BigDecimal, BigDecimal>? {
val entry = (targets[0] as LineCartesianLayerMarkerTarget).points[0].entry
val entryIndex = dataProducer.entries.value.indexOf(entry).takeIf { it != -1 } ?: return null
val state = dataProducer.dataState.value as? MarketChartData.Data ?: return null
val x = state.x.getOrNull(entryIndex) ?: return null
val y = state.y.getOrNull(entryIndex) ?: return null
val rawData = dataProducer.rawData.value ?: return null
val originalIndex = rawData.originalIndexes?.getOrNull(entryIndex)
val index = originalIndex ?: entryIndex
val x = state.x.getOrNull(index) ?: return null
val y = state.y.getOrNull(index) ?: return null
return x to y
}
fun startDrawingAnimation() {
startDrawingAnimationState.value = true
}
fun stopDrawingAnimation() {
startDrawingAnimationState.value = false
}
}

View file

@ -1,69 +0,0 @@
package com.tangem.common.ui.charts.state
import java.math.BigDecimal
/**
* Interface to convert chart data values to Floats and backwards.
*
* We need to convert the values on the graph to floating point values in order to display them correctly on the canvas.
* We also need to determine exactly which floating point value on the graph corresponds to the decimal point,
* so that we can format the actual value and display on the x/y axis.
*/
interface PointValuesConverter {
fun convert(data: MarketChartData.Data): MarketChartRawData
fun prepareRawXForFormat(rawX: Float, data: MarketChartData.Data): BigDecimal
fun prepareRawYForFormat(rawY: Float, data: MarketChartData.Data): BigDecimal
}
object DefaultPointValuesConverter : PointValuesConverter {
override fun convert(data: MarketChartData.Data): MarketChartRawData {
val minX = data.x.min()
val minY = data.y.min()
val normY = data.y.map { normalize(it, minY) }
val normX = data.x.map { normalize(it, minX) }
return MarketChartRawData(
x = normX,
y = normY,
)
}
override fun prepareRawXForFormat(rawX: Float, data: MarketChartData.Data): BigDecimal {
val dataMin = data.x.min()
val scale = dataMin.scale()
val bVal = if (scale > 2) {
rawX.toBigDecimal().movePointLeft(scale - 2) + dataMin
} else {
rawX.toBigDecimal() + dataMin
}
return bVal
}
override fun prepareRawYForFormat(rawY: Float, data: MarketChartData.Data): BigDecimal {
val dataMin = data.y.min()
val scale = dataMin.scale()
val bVal = if (scale > 2) {
rawY.toBigDecimal().movePointLeft(scale - 2) + dataMin
} else {
rawY.toBigDecimal() + dataMin
}
return bVal
}
// TODO enhance algorithm for values with big difference between min and max, which cannot fit in Float
private fun normalize(value: BigDecimal, min: BigDecimal, scale: Int = min.scale()): Float {
val n = value - min
return if (scale > 2) {
n.movePointRight(scale - 2).toFloat()
} else {
n.toFloat()
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.common.ui.charts.state.converter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import java.math.BigDecimal
/**
* Interface to convert chart data values to Floats and backwards.
*
* We need to convert the values on the graph to floating point values in order to display them correctly on the canvas.
* We also need to determine exactly which floating point value on the graph corresponds to the decimal point,
* so that we can format the actual value and display on the x/y axis.
*
* **[prepareRawXForFormat] and [prepareRawYForFormat] must be very fast because they are called in the onDraw method**
*/
interface PointValuesConverter {
fun convert(data: MarketChartData.Data): MarketChartRawData
fun prepareRawXForFormat(rawX: Double, data: MarketChartData.Data): BigDecimal
fun prepareRawYForFormat(rawY: Double, data: MarketChartData.Data): BigDecimal
}

View file

@ -0,0 +1,108 @@
package com.tangem.common.ui.charts.state.converter
import com.tangem.common.ui.charts.downsample.LTThreeBuckets
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
@Suppress("MagicNumber")
class PriceAndTimePointValuesConverter(
private val needToFormatAxis: Boolean,
) : PointValuesConverter {
private data class MinMaxCache(
val minX: BigDecimal,
val maxX: BigDecimal,
val minY: BigDecimal,
val maxY: BigDecimal,
)
private var minMaxCache = MinMaxCache(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO)
private val formatYValuesCache = mutableMapOf<Double, BigDecimal>()
private val formatXValuesCache = mutableMapOf<Double, BigDecimal>()
override fun convert(data: MarketChartData.Data): MarketChartRawData {
formatYValuesCache.clear()
formatXValuesCache.clear()
val cache = MinMaxCache(
minY = data.y.minOrNull() ?: BigDecimal.ZERO,
maxY = data.y.maxOrNull() ?: BigDecimal.ZERO,
minX = data.x.minOrNull() ?: BigDecimal.ZERO,
maxX = data.x.maxOrNull() ?: BigDecimal.ZERO,
)
minMaxCache = cache
val normY = data.y.normalizeToDouble(min = cache.minY, max = cache.maxY)
val normX = data.x.normalizeTime(min = cache.minX, max = cache.maxX)
return if (normX.size > MAX_POINTS) {
LTThreeBuckets
.downsample(normX, normY, MAX_POINTS - 2)
.let {
MarketChartRawData(
originalIndexes = it.originalIndexes.toImmutableList(),
x = it.x.toImmutableList(),
y = it.y.toImmutableList(),
)
}
} else {
MarketChartRawData(
x = normX.toImmutableList(),
y = normY.toImmutableList(),
)
}
}
override fun prepareRawXForFormat(rawX: Double, data: MarketChartData.Data): BigDecimal {
if (!needToFormatAxis) return BigDecimal.ZERO
if (formatXValuesCache.containsKey(rawX)) return formatXValuesCache[rawX]!!
val result = (rawX * MINUTE).toBigDecimal()
formatXValuesCache[rawX] = result
return result
}
override fun prepareRawYForFormat(rawY: Double, data: MarketChartData.Data): BigDecimal {
if (!needToFormatAxis) return BigDecimal.ZERO
if (formatYValuesCache.containsKey(rawY)) return formatYValuesCache[rawY]!!
val min = minMaxCache.minY
val max = minMaxCache.maxY
val length = max - min
val result = when {
rawY < 0.01f -> min
rawY < 0.55f && rawY > 0.45f -> min + length / 2.toBigDecimal()
rawY > 0.97f && rawY < 1.01f -> max
else -> length * rawY.toBigDecimal() + min
}
formatYValuesCache[rawY] = result
return result
}
private fun List<BigDecimal>.normalizeToDouble(min: BigDecimal, max: BigDecimal): List<Double> {
if (min == max) {
return List(size) { 0.5 }
}
return map { ((it - min) / (max - min)).toDouble() }
}
private fun List<BigDecimal>.normalizeTime(min: BigDecimal, max: BigDecimal): List<Double> {
if (min == max) {
return List(size) { 0.5 }
}
return map {
(it / MINUTE_BIG).toDouble()
}
}
private companion object {
private const val MAX_POINTS = 502
private const val MINUTE = 60000L
private val MINUTE_BIG = 60000L.toBigDecimal()
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.common.ui.charts.state
package com.tangem.common.ui.charts.state.formatter
import androidx.compose.runtime.Stable
import java.math.BigDecimal
@ -7,6 +7,8 @@ import java.math.BigDecimal
* Used for formatting the axis labels in a chart.
* It takes a BigDecimal value and returns a CharSequence that represents the formatted label.
*
* [format] has to be very fast because it is called in the onDraw method.
*
* @param value The value to be formatted.
* @return The formatted label as a CharSequence.
*/

View file

@ -0,0 +1,15 @@
package com.tangem.common.ui.charts.state.formatter
import java.math.BigDecimal
internal class FormatterWrapWithCache(private val formatter: AxisLabelFormatter) : AxisLabelFormatter {
private val cache = mutableMapOf<BigDecimal, CharSequence>()
override fun format(value: BigDecimal): CharSequence {
return cache.getOrPut(value) { formatter.format(value) }
}
fun clearCache() {
cache.clear()
}
}