Skip to content

Commit 5c820bc

Browse files
committed
chore(mcap): update algorithm for downsampling and interpolation to match iOS
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent cae8cc2 commit 5c820bc

4 files changed

Lines changed: 167 additions & 46 deletions

File tree

apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapSection.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ internal fun MarketCapSection(
147147
end = contentPadding.calculateEndPadding(),
148148
),
149149
data = data,
150+
currentValue = marketCap.decimalValue,
150151
trendType = TrendType.FirstVsLast,
151152
selectedPeriod = selectedPeriod,
152153
onPointHighlighted = { highlightedCapPoint = it },

apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/marketcap/MarketCapChart.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import kotlin.time.Duration
6969
@Composable
7070
internal fun MarketCapChart(
7171
data: List<MarketCapPoint>,
72+
currentValue: Double,
7273
trendType: TrendType,
7374
selectedPeriod: Period,
7475
modifier: Modifier = Modifier,
@@ -92,7 +93,7 @@ internal fun MarketCapChart(
9293

9394
val windowedData by remember(historicalData, selectedPeriod) {
9495
derivedStateOf {
95-
historicalData.collapse(selectedPeriod)
96+
historicalData.collapse(selectedPeriod, currentValue = currentValue)
9697
}
9798
}
9899

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,161 @@
11
package com.flipcash.app.tokens.data
22

3+
import kotlin.math.abs
4+
35
sealed interface AggregationType {
4-
fun aggregate(points: List<MarketCapPoint>): Double?
6+
sealed interface Downsample : AggregationType {
7+
fun downsample(
8+
startTime: Long,
9+
now: Long,
10+
points: List<MarketCapPoint>,
11+
targetPoints: Int,
12+
currentValue: Double,
13+
): List<MarketCapPoint>
14+
}
15+
16+
sealed interface Bucketed : AggregationType {
17+
fun aggregate(points: List<MarketCapPoint>): Double?
18+
}
519

6-
data object Last : AggregationType {
20+
data object Last : Bucketed {
721
override fun aggregate(points: List<MarketCapPoint>): Double? {
822
return points.maxByOrNull { it.x }?.y
923
}
1024
}
11-
data object First : AggregationType {
25+
26+
data object First : Bucketed {
1227
override fun aggregate(points: List<MarketCapPoint>): Double? {
1328
return points.minByOrNull { it.x }?.y
1429
}
1530
}
16-
data object Average : AggregationType {
31+
32+
data object Average : Bucketed {
1733
override fun aggregate(points: List<MarketCapPoint>): Double? {
1834
return points.map { it.y }.average()
1935
}
2036
}
21-
data object Max : AggregationType {
37+
38+
data object Max : Bucketed {
2239
override fun aggregate(points: List<MarketCapPoint>): Double? {
2340
return points.maxOfOrNull { it.y }
2441
}
2542
}
26-
data object Min : AggregationType {
43+
44+
data object Min : Bucketed {
2745
override fun aggregate(points: List<MarketCapPoint>): Double? {
2846
return points.minOfOrNull { it.y }
2947
}
3048
}
49+
50+
data object LargestTriangleThreeBuckets : Downsample {
51+
override fun downsample(
52+
startTime: Long,
53+
now: Long,
54+
points: List<MarketCapPoint>,
55+
targetPoints: Int,
56+
currentValue: Double,
57+
): List<MarketCapPoint> {
58+
val withCurrent = points + MarketCapPoint(x = now, y = currentValue)
59+
val sorted = withCurrent.sortedBy { it.x }
60+
61+
// Select visually significant points using LTTB
62+
val selected = when {
63+
sorted.size <= targetPoints -> sorted
64+
targetPoints < 3 -> listOfNotNull(
65+
sorted.firstOrNull(),
66+
sorted.lastOrNull()
67+
).distinct()
68+
69+
else -> selectPoints(sorted, targetPoints)
70+
}
71+
72+
// Interpolate to evenly-spaced timestamps
73+
val duration = now - startTime
74+
val intervalMs = (duration / targetPoints).coerceAtLeast(1)
75+
76+
return (0 until targetPoints).map { bucket ->
77+
val timestamp = startTime + (bucket * intervalMs) + (intervalMs / 2)
78+
val y = if (bucket == targetPoints - 1) {
79+
currentValue
80+
} else {
81+
interpolateAt(selected, timestamp)
82+
}
83+
MarketCapPoint(x = timestamp, y = y)
84+
}
85+
}
86+
87+
private fun selectPoints(sorted: List<MarketCapPoint>, targetPoints: Int): List<MarketCapPoint> {
88+
val result = mutableListOf<MarketCapPoint>()
89+
result.add(sorted.first())
90+
91+
val bucketSize = (sorted.size - 2).toDouble() / (targetPoints - 2)
92+
var prevSelectedIndex = 0
93+
94+
for (i in 0 until targetPoints - 2) {
95+
val bucketStart = ((i + 1) * bucketSize).toInt() + 1
96+
val bucketEnd = (((i + 2) * bucketSize).toInt() + 1).coerceAtMost(sorted.size - 1)
97+
98+
val nextBucketEnd = (((i + 3) * bucketSize).toInt() + 1).coerceAtMost(sorted.size)
99+
100+
val (avgX, avgY) = if (bucketEnd < nextBucketEnd) {
101+
val nextBucket = sorted.subList(bucketEnd, nextBucketEnd)
102+
nextBucket.map { it.x }.average() to nextBucket.map { it.y }.average()
103+
} else {
104+
sorted.last().x.toDouble() to sorted.last().y
105+
}
106+
107+
val prev = sorted[prevSelectedIndex]
108+
var maxArea = -1.0
109+
var selectedIndex = bucketStart
110+
111+
for (j in bucketStart until bucketEnd) {
112+
val current = sorted[j]
113+
val area = triangleArea(
114+
prev.x.toDouble(), prev.y,
115+
current.x.toDouble(), current.y,
116+
avgX, avgY
117+
)
118+
if (area > maxArea) {
119+
maxArea = area
120+
selectedIndex = j
121+
}
122+
}
123+
124+
result.add(sorted[selectedIndex])
125+
prevSelectedIndex = selectedIndex
126+
}
127+
128+
result.add(sorted.last())
129+
return result
130+
}
131+
132+
private fun interpolateAt(points: List<MarketCapPoint>, timestamp: Long): Double {
133+
if (points.isEmpty()) return 0.0
134+
if (timestamp <= points.first().x) return points.first().y
135+
if (timestamp >= points.last().x) return points.last().y
136+
137+
// Binary search for surrounding points
138+
val insertionPoint = points.binarySearchBy(timestamp) { it.x }
139+
140+
return if (insertionPoint >= 0) {
141+
points[insertionPoint].y
142+
} else {
143+
val rightIndex = -(insertionPoint + 1)
144+
val leftIndex = rightIndex - 1
145+
146+
val left = points[leftIndex]
147+
val right = points[rightIndex]
148+
149+
// Linear interpolation
150+
val t = (timestamp - left.x).toDouble() / (right.x - left.x)
151+
left.y + t * (right.y - left.y)
152+
}
153+
}
154+
155+
private fun triangleArea(
156+
x1: Double, y1: Double,
157+
x2: Double, y2: Double,
158+
x3: Double, y3: Double
159+
): Double = abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2.0)
160+
}
31161
}

apps/flipcash/shared/tokens/src/main/kotlin/data/MarketCapPoint.kt

Lines changed: 28 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ typealias MarketCapPoint = ChartPoint<Long, Double>
99
fun List<MarketCapPoint>.collapse(
1010
period: Period,
1111
targetPoints: Int = 100,
12-
aggregation: AggregationType = AggregationType.Last,
12+
currentValue: Double,
13+
aggregation: AggregationType = AggregationType.LargestTriangleThreeBuckets,
1314
): List<MarketCapPoint> {
1415
if (isEmpty()) return emptyList()
15-
1616
val now = Clock.System.now().toEpochMilliseconds()
1717

1818
val startTime = when (period) {
@@ -29,45 +29,34 @@ fun List<MarketCapPoint>.collapse(
2929
val duration = now - startTime
3030
val intervalMs = (duration / targetPoints).coerceAtLeast(1)
3131

32-
val bucketedData = filtered
33-
.groupBy { (it.x - startTime) / intervalMs }
34-
.mapNotNull { (bucket, points) ->
35-
val value = aggregation.aggregate(points) ?: return@mapNotNull null
36-
bucket to value
32+
return when (aggregation) {
33+
is AggregationType.Bucketed -> {
34+
val bucketedData = filtered
35+
.groupBy { (it.x - startTime) / intervalMs }
36+
.mapNotNull { (bucket, points) ->
37+
val value = aggregation.aggregate(points) ?: return@mapNotNull null
38+
bucket to value
39+
}
40+
.toMap()
41+
42+
var lastValue = 0.0
43+
(0 until targetPoints).mapIndexed { index, bucket ->
44+
val timestamp = startTime + (bucket * intervalMs) + (intervalMs / 2)
45+
val value = bucketedData[bucket.toLong()]
46+
if (value != null) lastValue = value
47+
val y = if (index == targetPoints - 1) currentValue else lastValue
48+
MarketCapPoint(x = timestamp, y = y)
49+
}
3750
}
38-
.toMap()
39-
40-
var lastValue = 0.0
41-
return (0 until targetPoints).map { bucket ->
42-
val timestamp = startTime + (bucket * intervalMs) + (intervalMs / 2)
43-
val value = bucketedData[bucket.toLong()]
44-
if (value != null) lastValue = value
45-
MarketCapPoint(x = timestamp, y = lastValue)
46-
}
47-
}
48-
49-
private fun List<MarketCapPoint>.interpolateToSize(targetSize: Int): List<MarketCapPoint> {
50-
if (size == targetSize || isEmpty()) return this
51-
52-
val result = mutableListOf<MarketCapPoint>()
53-
val step = (size - 1).toFloat() / (targetSize - 1).coerceAtLeast(1)
5451

55-
repeat(targetSize) { i ->
56-
val position = i * step
57-
val lowerIndex = position.toInt().coerceIn(0, size - 1)
58-
val upperIndex = (lowerIndex + 1).coerceAtMost(size - 1)
59-
val fraction = position - lowerIndex
60-
61-
val lower = this[lowerIndex]
62-
val upper = this[upperIndex]
63-
64-
result.add(
65-
MarketCapPoint(
66-
x = (lower.x + (upper.x - lower.x) * fraction).toLong(),
67-
y = (lower.y + (upper.y - lower.y) * fraction),
52+
is AggregationType.Downsample -> {
53+
aggregation.downsample(
54+
startTime = startTime,
55+
now = now,
56+
points = filtered,
57+
targetPoints = targetPoints,
58+
currentValue = currentValue
6859
)
69-
)
60+
}
7061
}
71-
72-
return result
7362
}

0 commit comments

Comments
 (0)