-
Notifications
You must be signed in to change notification settings - Fork 28
Add CandleStick Chart & Mouse Tracking #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d3bb510
5edccd0
90a6b3a
513a114
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,221 @@ | ||
package io.github.koalaplot.core.bar | ||
|
||
import androidx.compose.animation.core.AnimationSpec | ||
import androidx.compose.foundation.BorderStroke | ||
import androidx.compose.foundation.background | ||
import androidx.compose.foundation.border | ||
import androidx.compose.foundation.layout.Box | ||
import androidx.compose.foundation.layout.fillMaxSize | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.remember | ||
import androidx.compose.ui.Modifier | ||
import androidx.compose.ui.draw.clip | ||
import androidx.compose.ui.graphics.* | ||
import io.github.koalaplot.core.style.KoalaPlotTheme | ||
import io.github.koalaplot.core.util.ExperimentalKoalaPlotApi | ||
import io.github.koalaplot.core.xygraph.XYGraphScope | ||
|
||
/** | ||
* Represents a set of data for a single candle in a [CandleStickPlot]. | ||
* | ||
* @param X The type of the x-axis values | ||
* @param Y The type of the y-axis values | ||
*/ | ||
public interface CandleStickPlotEntry<X, Y> { | ||
/** | ||
* The x-axis value of this [CandleStickPlotEntry]. | ||
*/ | ||
public val x: X | ||
|
||
/** | ||
* The opening price of the candle. | ||
*/ | ||
public val open: Y | ||
|
||
/** | ||
* The closing price of the candle. | ||
*/ | ||
public val close: Y | ||
|
||
/** | ||
* The highest price of the candle. | ||
*/ | ||
public val high: Y | ||
|
||
/** | ||
* The lowest price of the candle. | ||
*/ | ||
public val low: Y | ||
} | ||
|
||
/** | ||
* Returns an instance of a [CandleStickPlotEntry] using the provided data. | ||
*/ | ||
public fun <X, Y> candleStickPlotEntry(x: X, open: Y, close: Y, high: Y, low: Y): CandleStickPlotEntry<X, Y> = | ||
object : CandleStickPlotEntry<X, Y> { | ||
override val x = x | ||
override val open = open | ||
override val close = close | ||
override val high = high | ||
override val low = low | ||
} | ||
|
||
public const val WICKWIDTH: Float = 0.02f | ||
|
||
@ExperimentalKoalaPlotApi | ||
@Composable | ||
public fun <X, Y : Comparable<Y>> XYGraphScope<X, Y>.CandleStickPlot( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add comments documenting all parameters. |
||
defaultCandle: @Composable BarScope.(entry: CandleStickPlotEntry<X, Y>) -> Unit = { _ -> }, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is a @composable for the hover element. In that case, this variable name is not clear as it looks like its for the candle shape itself. |
||
modifier: Modifier = Modifier, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. modifier should be the 1st optional parameter to the function |
||
candleWidth: Float = 0.5f, | ||
wickWidth: Float = WICKWIDTH, | ||
animationSpec: AnimationSpec<Float> = KoalaPlotTheme.animationSpec, | ||
colorForIncrease: Color = Color.Green, | ||
colorForDecrease: Color = Color.Red, | ||
content: CandleStickPlotScope<X, Y>.() -> Unit | ||
) { | ||
val scope = remember(content, defaultCandle) { CandleStickPlotScopeImpl<X, Y>(defaultCandle) } | ||
val data = remember(scope) { | ||
scope.content() | ||
scope.data | ||
} | ||
|
||
val candleBodyEntries = data.map { entry -> | ||
verticalBarPlotEntry(entry.x, entry.open, entry.close) | ||
} | ||
|
||
val candleWickEntries = data.map { entry -> | ||
verticalBarPlotEntry(entry.x, entry.low, entry.high) | ||
} | ||
|
||
VerticalBarPlot( | ||
data = candleBodyEntries, | ||
modifier = modifier, | ||
barWidth = candleWidth, | ||
animationSpec = animationSpec, | ||
bar = { index -> | ||
val entry = data[index] | ||
val candleColor = if (entry.close >= entry.open) colorForIncrease else colorForDecrease | ||
DefaultCandleBody( | ||
color = candleColor, | ||
hoverElement = { | ||
scope.candleContent.invoke(this, entry) | ||
} | ||
) | ||
} | ||
) | ||
|
||
VerticalBarPlot( | ||
data = candleWickEntries, | ||
modifier = modifier, | ||
barWidth = wickWidth, | ||
animationSpec = animationSpec, | ||
bar = { index -> | ||
val entry = data[index] | ||
val wickColor = if (entry.close >= entry.open) colorForIncrease else colorForDecrease | ||
DefaultCandleWick( | ||
color = wickColor, | ||
width = wickWidth, | ||
hoverElement = { | ||
scope.candleContent.invoke(this, entry) | ||
} | ||
) | ||
} | ||
) | ||
} | ||
|
||
/** | ||
* Scope item to allow adding items to a [CandleStickPlot]. | ||
*/ | ||
public interface CandleStickPlotScope<X, Y> { | ||
|
||
public fun item( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function needs documentation. |
||
entry: CandleStickPlotEntry<X, Y>, | ||
candleContent: (@Composable BarScope.(entry: CandleStickPlotEntry<X, Y>) -> Unit)? = null | ||
) | ||
} | ||
|
||
internal class CandleStickPlotScopeImpl<X, Y>( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this class should implement equals() and hashCode() |
||
private val defaultCandle: @Composable BarScope.(entry: CandleStickPlotEntry<X, Y>) -> Unit | ||
) : | ||
CandleStickPlotScope<X, Y> { | ||
val data: MutableList<CandleStickPlotEntry<X, Y>> = mutableListOf() | ||
var candleContent: @Composable BarScope.(entry: CandleStickPlotEntry<X, Y>) -> Unit = defaultCandle | ||
|
||
override fun item( | ||
entry: CandleStickPlotEntry<X, Y>, | ||
candleContent: ( | ||
@Composable BarScope.(entry: CandleStickPlotEntry<X, Y>) -> Unit | ||
)? | ||
) { | ||
data.add(entry) | ||
if (candleContent != null) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The semantics of candleContent is not obvious here. If item is called more than once with a different candleContent, the subsequent values are going to override the previous values. Either this should allow overriding the default value individually for each data point, or this should be removed and only the value provided to CandlestickPlot should be used. |
||
this.candleContent = candleContent | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* A default implementation of a candle body for candle stick charts. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The user should be able to override what candle body implementation is used for the candle stick, or more parameters should be provided to customize it from the candlestick plot. For example, what if I want to use a non-solid fill, or use rounded corners? |
||
* @param brush The brush to paint the candle body with | ||
* @param shape An optional shape for the candle body. | ||
* @param border An optional border for the candle body. | ||
* @param hoverElement An optional Composable to be displayed over the candle body when hovered over by the | ||
* mouse or pointer. | ||
*/ | ||
@Composable | ||
public fun BarScope.DefaultCandleBody( | ||
brush: Brush, | ||
modifier: Modifier = Modifier, | ||
shape: Shape = RectangleShape, | ||
border: BorderStroke? = null, | ||
hoverElement: @Composable () -> Unit = {} | ||
) { | ||
Box( | ||
modifier = modifier.fillMaxSize() | ||
.then(if (border != null) Modifier.border(border, shape) else Modifier) | ||
.background(brush = brush, shape = shape) | ||
.clip(shape) | ||
.hoverableElement(hoverElement) | ||
) | ||
} | ||
|
||
/** | ||
* A simplified DefaultCandleBody that uses a Solid Color [color] and default [RectangleShape]. | ||
*/ | ||
@Composable | ||
public fun BarScope.DefaultCandleBody( | ||
color: Color, | ||
shape: Shape = RectangleShape, | ||
border: BorderStroke? = null, | ||
hoverElement: @Composable () -> Unit = {} | ||
) { | ||
DefaultCandleBody(SolidColor(color), shape = shape, border = border, hoverElement = hoverElement) | ||
} | ||
|
||
/** | ||
* A default implementation of a candle wick for candle stick charts. | ||
* @param color The color to paint the candle wick with | ||
* @param width The width of the candle wick | ||
* @param shape An optional shape for the candle wick. | ||
* @param border An optional border for the candle wick. | ||
* @param hoverElement An optional Composable to be displayed over the candle wick when hovered over by the | ||
* mouse or pointer. | ||
*/ | ||
@Composable | ||
public fun BarScope.DefaultCandleWick( | ||
color: Color, | ||
width: Float, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Where is this used? |
||
modifier: Modifier = Modifier, | ||
shape: Shape = RectangleShape, | ||
border: BorderStroke? = null, | ||
hoverElement: @Composable () -> Unit = {} | ||
) { | ||
Box( | ||
modifier = modifier.fillMaxSize() | ||
.then(if (border != null) Modifier.border(border, shape) else Modifier) | ||
.background(color = color, shape = shape) | ||
.clip(shape) | ||
.hoverableElement(hoverElement) | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -62,7 +62,7 @@ public fun HoverableElementArea( | |
} | ||
|
||
Layout( | ||
modifier = modifier.pointerPosition(Unit) { position = it }, | ||
modifier = modifier.pointerPosition(Unit, { position = it }, scope), | ||
// localProvider exhibiting inconsistent behavior between platforms and package location | ||
// .modifierLocalProvider(ModifierLocalSender) { | ||
// Sender { composable, display -> | ||
|
@@ -106,13 +106,18 @@ public fun HoverableElementArea( | |
} | ||
} | ||
|
||
private fun Modifier.pointerPosition(key1: Any?, update: (Offset) -> Unit): Modifier = composed { | ||
private fun Modifier.pointerPosition( | ||
key1: Any?, | ||
update: (Offset) -> Unit, | ||
scope: HoverableElementAreaScopeImpl | ||
): Modifier = composed { | ||
Modifier.pointerInput(key1) { | ||
val currentContext = currentCoroutineContext() | ||
awaitPointerEventScope { | ||
while (currentContext.isActive) { | ||
val event = awaitPointerEvent() | ||
update(event.changes.last().position) | ||
scope.currentPosition = event.changes.last().position // 추가 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please only use English for comments (and code). |
||
} | ||
} | ||
} | ||
|
@@ -124,15 +129,18 @@ private fun Modifier.pointerPosition(key1: Any?, update: (Offset) -> Unit): Modi | |
*/ | ||
public interface HoverableElementAreaScope { | ||
public fun Modifier.hoverableElement(element: @Composable () -> Unit): Modifier | ||
public fun getCurrentPointer(): Offset | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please document. |
||
} | ||
|
||
private class HoverableElementAreaScopeImpl(private val sender: Sender) : | ||
HoverableElementAreaScope { | ||
var currentPosition: Offset by mutableStateOf(Offset.Zero) | ||
override fun Modifier.hoverableElement(element: @Composable () -> Unit): Modifier = composed { | ||
val interactionSource = remember { MutableInteractionSource() } | ||
var hoverInteraction by remember { mutableStateOf<HoverInteraction.Enter?>(null) } | ||
// var sender by remember { mutableStateOf<Sender?>(null) } | ||
|
||
|
||
fun emitEnter() { | ||
if (hoverInteraction == null) { | ||
val interaction = HoverInteraction.Enter() | ||
|
@@ -184,4 +192,8 @@ private class HoverableElementAreaScopeImpl(private val sender: Sender) : | |
} | ||
} | ||
} | ||
|
||
override fun getCurrentPointer(): Offset { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the mouse is moved the currentPosition is updated, but how will client code know it needs to call |
||
return currentPosition | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -35,6 +35,28 @@ public class CategoryAxisModel<T>( | |
return TickValues(categories, listOf()) | ||
} | ||
|
||
override fun computeValue(offset: Float): T { | ||
// The offset is expected to be in the 0~1 range. Handle exceptions if out of bounds. | ||
require(offset in 0f..1f) { "Offset ($offset) must be in [0,1]" } | ||
|
||
// When firstCategoryIsZero == true: | ||
// computeOffset(point) is index / categories.size | ||
// -> index = offset * categories.size | ||
// When firstCategoryIsZero == false: | ||
// computeOffset(point) is (index + 1) / (categories.size + 1) | ||
// -> index = offset * (categories.size + 1) - 1 | ||
val rawIndex = if (firstCategoryIsZero) { | ||
(offset * categories.size) | ||
} else { | ||
(offset * (categories.size + 1) - 1) | ||
} | ||
|
||
// Adjust safely to prevent negative values or exceeding (categories.size - 1) | ||
val index = rawIndex.toInt().coerceIn(0, categories.size - 1) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. rawIndex.toInt() is going to do a floor() operation, but I think it would be preferred to round to the nearest integer here instead. |
||
|
||
return categories[index] | ||
} | ||
|
||
private data class TickValues<T>(override val majorTickValues: List<T>, override val minorTickValues: List<T>) : | ||
io.github.koalaplot.core.xygraph.TickValues<T> | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Candlestick is 1 word, so the "S" should not be capitalized.