Skip to content
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

LP-1040 and LP-1041 #1052

Merged
merged 9 commits into from
Mar 21, 2024
Prev Previous commit
Next Next commit
Add adaptive arrow head and tail lengths
  • Loading branch information
IKupriyanov-HORIS committed Mar 20, 2024
commit 12780b04922157167940b941951d203e9bef40ad
2 changes: 2 additions & 0 deletions future_changes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## [4.3.1] - 2024-mm-dd

### Added
- Parameter `min_tail_length` for `arrow()` function [[#1040](https://github.com/JetBrains/lets-plot/issues/1040)].

### Changed

Expand All @@ -9,3 +10,4 @@
- livemap: when release the mouse button from outside the map, it gets stuck in panning mode [[#1044](https://github.com/JetBrains/lets-plot/issues/1044)].
- Incorrect 'plot_background' area (with empty space capture) [[#918](https://github.com/JetBrains/lets-plot/issues/918)].
- Support arrow() in geom_spoke() [[#986](https://github.com/JetBrains/lets-plot/issues/986)].
- arrow on curve sometimes looks weird [[#1041](https://github.com/JetBrains/lets-plot/issues/1041)].
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import org.jetbrains.letsPlot.core.plot.base.DataPointAesthetics
import org.jetbrains.letsPlot.core.plot.base.aes.AesScaling
import org.jetbrains.letsPlot.core.plot.base.render.linetype.NamedLineType
import kotlin.math.atan2
import kotlin.math.min
import kotlin.math.sin

/**
Expand All @@ -25,7 +24,8 @@ class ArrowSpec(
val angle: Double,
val length: Double,
val end: End,
val type: Type
val type: Type,
val minTailLength: Double
) {

val isOnFirstEnd: Boolean
Expand Down Expand Up @@ -79,20 +79,16 @@ class ArrowSpec(
): List<DoubleVector> {
if (geometry.size < 2) return emptyList()

// Shorten the arrow head if the segment is shorter than the arrow head
val headLength = when (geometry.size) {
0, 1 -> error("Invalid geometry")
2 -> min(arrowSpec.length, distance(geometry[0], geometry[1]))
else -> arrowSpec.length // yet not implemented for multi-segment lines (e.g. curves), so use full length
}
val lineLength = geometry.windowed(2).sumOf { (a, b) -> distance(a, b) }
val headLength = adjustArrowHeadLength(lineLength, arrowSpec)

// basePoint affects direction of the arrow head. Important for curves.
val basePoint = when (geometry.size) {
0, 1 -> error("Invalid geometry")
2 -> geometry.first()
else -> geometry[pointIndexAtDistance(geometry, distanceFromEnd = headLength)]
}

//val basePoint = geometry[geometry.lastIndex - 1]
val tipPoint = geometry.last()

val abscissa = tipPoint.x - basePoint.x
Expand All @@ -114,6 +110,17 @@ class ArrowSpec(
}
}

fun adjustArrowHeadLength(lineLength: Double, arrowSpec: ArrowSpec): Double {
val headsCount = listOf(arrowSpec.isOnFirstEnd, arrowSpec.isOnLastEnd).count { it }
val headsLength = arrowSpec.length * headsCount
val tailLength = lineLength - headsLength

return when (tailLength < arrowSpec.minTailLength) {
true -> maxOf((lineLength - arrowSpec.minTailLength) / headsCount, 5.0) // 5.0 so the arrow head never disappears
false -> arrowSpec.length
}
}

internal fun ArrowSpec.toArrowAes(p: DataPointAesthetics): DataPointAesthetics {
return object : DataPointAestheticsDelegate(p) {
private val filled = (type == Type.CLOSED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package org.jetbrains.letsPlot.core.plot.livemap

import org.jetbrains.letsPlot.commons.geometry.DoubleVector
import org.jetbrains.letsPlot.commons.intern.math.distance
import org.jetbrains.letsPlot.commons.intern.spatial.LonLat
import org.jetbrains.letsPlot.commons.intern.typedGeometry.Vec
import org.jetbrains.letsPlot.commons.intern.typedGeometry.explicitVec
Expand Down Expand Up @@ -106,8 +107,23 @@ internal class DataPointsConverter(
this.geodesic = myGeodesic
this.spacer = mySpacer
this.isCurve = myIsCurve
setArrowSpec(myArrowSpec)
setAnimation(myAnimation)

val adjustedArrowSpec = myArrowSpec?.let {
val angle = it.angle
val ends = it.end
val type = it.type

val geometryLength = when (points.size) {
0, 1 -> 0.0
else -> points.windowed(2).sumOf { (a, b) -> distance(a.x, a.y, b.x, b.y) }
}

val length = ArrowSpec.adjustArrowHeadLength(geometryLength, it)
val minTailLength = 0.0 // we already adjusted arrow length, no need to store original minTailLength
ArrowSpec(angle, length, ends, type, minTailLength)
}
setArrowSpec(adjustedArrowSpec)
}

fun setArrowSpec(arrowSpec: ArrowSpec?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,7 @@ object Option {
const val LENGTH = "length"
const val ENDS = "ends"
const val TYPE = "type"
const val MIN_TAIL_LENGTH = "min_tail_length"
}

internal object Sampling {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,43 +13,36 @@ internal class ArrowSpecConfig private constructor(options: Map<String, Any>) :

fun createArrowSpec(): ArrowSpec {
// See R function arrow(): https://www.rdocumentation.org/packages/grid/versions/3.4.1/topics/arrow
var angle = DEF_ANGLE
var length = DEF_LENGTH
var end = DEF_END
var type = DEF_TYPE

if (has(Option.Arrow.ANGLE)) {
angle = getDouble(Option.Arrow.ANGLE)!!
}
if (has(Option.Arrow.LENGTH)) {
length = getDouble(Option.Arrow.LENGTH)!!
}
if (has(Option.Arrow.ENDS)) {
val s = getString(Option.Arrow.ENDS)
when (s) {
"last" -> end = ArrowSpec.End.LAST
"first" -> end = ArrowSpec.End.FIRST
"both" -> end = ArrowSpec.End.BOTH
val angle = getDouble(Option.Arrow.ANGLE) ?: DEF_ANGLE
val length = getDouble(Option.Arrow.LENGTH) ?: DEF_LENGTH
val minTailLength = getDouble(Option.Arrow.MIN_TAIL_LENGTH) ?: DEF_MIN_TAIL_LENGTH

val end = getString(Option.Arrow.ENDS)?.let {
when (it) {
"last" -> ArrowSpec.End.LAST
"first" -> ArrowSpec.End.FIRST
"both" -> ArrowSpec.End.BOTH
else -> throw IllegalArgumentException("Expected: first|last|both")
}
}
if (has(Option.Arrow.TYPE)) {
val s = getString(Option.Arrow.TYPE)
when (s) {
"open" -> type = ArrowSpec.Type.OPEN
"closed" -> type = ArrowSpec.Type.CLOSED
} ?: DEF_END

val type = getString(Option.Arrow.TYPE)?.let {
when (it) {
"open" -> ArrowSpec.Type.OPEN
"closed" -> ArrowSpec.Type.CLOSED
else -> throw IllegalArgumentException("Expected: open|closed")
}
}
} ?: DEF_TYPE

return ArrowSpec(toRadians(angle), length, end, type)
return ArrowSpec(toRadians(angle), length, end, type, minTailLength)
}

companion object {
private const val DEF_ANGLE = 30.0
private const val DEF_LENGTH = 10.0
private val DEF_END = ArrowSpec.End.LAST
private val DEF_TYPE = ArrowSpec.Type.OPEN
private const val DEF_MIN_TAIL_LENGTH = 5.0

fun create(options: Any): ArrowSpecConfig {
if (options is Map<*, *>) {
Expand Down
8 changes: 6 additions & 2 deletions python-package/lets_plot/plot/geom_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
#
# See R doc: https://www.rdocumentation.org/packages/grid/versions/3.4.1/topics/arrow
#
def arrow(angle=None, length=None, ends=None, type=None):
def arrow(angle=None, length=None, ends=None, type=None, min_tail_length=None):
"""
Describe arrows to add to a line.

Expand All @@ -24,7 +24,11 @@ def arrow(angle=None, length=None, ends=None, type=None):
Indicating which ends of the line to draw arrow heads.
type : {'open', 'closed'}
Indicating whether the arrow head should be a closed triangle.

min_tail_length : int
The minimum length of the tail (line between arrow heads) in pixels. If the tail is shorter than this,
length of heads is reduced to fit. Yet there is a minimum length of 5 pixels for the tail, so the heads will
not disappear completely.

Returns
-------
`FeatureSpec`
Expand Down