Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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> {
Copy link
Member

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.

/**
* 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(
Copy link
Member

Choose a reason for hiding this comment

The 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 = { _ -> },
Copy link
Member

Choose a reason for hiding this comment

The 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,
Copy link
Member

Choose a reason for hiding this comment

The 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(
Copy link
Member

Choose a reason for hiding this comment

The 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>(
Copy link
Member

Choose a reason for hiding this comment

The 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) {
Copy link
Member

Choose a reason for hiding this comment

The 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.
Copy link
Member

Choose a reason for hiding this comment

The 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,
Copy link
Member

Choose a reason for hiding this comment

The 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
Expand Up @@ -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 ->
Expand Down Expand Up @@ -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 // 추가
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please only use English for comments (and code).

}
}
}
Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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()
Expand Down Expand Up @@ -184,4 +192,8 @@ private class HoverableElementAreaScopeImpl(private val sender: Sender) :
}
}
}

override fun getCurrentPointer(): Offset {
Copy link
Member

Choose a reason for hiding this comment

The 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 getCurrentPosition to update whatever it wants to do with an updated mouse position?

return currentPosition
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ public interface AxisModel<T> {
*/
public fun computeOffset(point: T): Float

/**
* Converts an offset in the 0..1 range to a data type [T].
* If the axis is linear, the conversion follows: min + offset * (max - min).
* For a logarithmic axis, an appropriate inverse transformation should be implemented.
*/
public fun computeValue(offset: Float): T

/**
* Asks the AxisState to compute new ranges and tick values after zooming, if the axis supports
* zooming.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The 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>
}
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,23 @@ public class DoubleLinearAxisModel(
}
}

override fun computeValue(offset: Float): Double {
require(offset in 0f..1f) {
"Offset ($offset) must be within [0, 1]"
}

val cr = currentRange.value
return if (!inverted) {
// offsetComputer(point) = (point - start) / (end - start)
// → point = start + offset * (end - start)
cr.start + offset * (cr.endInclusive - cr.start)
} else {
// offsetComputer(point) = (end - point) / (end - start)
// → point = end - offset * (end - start)
cr.endInclusive - offset * (cr.endInclusive - cr.start)
}
}

private fun computeMajorTickSpacing(minTickSpacing: Float): Double {
require(minTickSpacing > 0 && minTickSpacing <= 1) {
"Minimum tick spacing must be greater than 0 and less than or equal to 1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,24 @@ public class FloatLinearAxisModel(
}
}

override fun computeValue(offset: Float): Float {
require(offset in 0f..1f) {
"Offset ($offset) must be in [0, 1]"
}
val start = currentRange.value.start
val end = currentRange.value.endInclusive

return if (!inverted) {
// offset = (point - start) / (end - start)
// point = start + offset * (end - start)
start + offset * (end - start)
} else {
// offset = (end - point) / (end - start)
// point = end - offset * (end - start)
end - offset * (end - start)
}
}

private fun computeMajorTickSpacing(minTickSpacing: Float): Float {
require(minTickSpacing > 0 && minTickSpacing <= 1) {
"Minimum tick spacing must be greater than 0 and less than or equal to 1"
Expand Down
Loading