diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/geometry/Curve.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/geometry/Curve.kt new file mode 100644 index 00000000000..e0a2a6969f6 --- /dev/null +++ b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/geometry/Curve.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2024. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package org.jetbrains.letsPlot.commons.geometry + +import org.jetbrains.letsPlot.commons.intern.math.lineSlope +import org.jetbrains.letsPlot.commons.intern.math.toRadians +import kotlin.math.* + + +// https://svn.r-project.org/R/trunk/src/library/grid/R/curve.R +fun curve( + start: DoubleVector, + end: DoubleVector, + curvature: Double, + angle: Double, + ncp: Int +): List { + val controlPoints = calcControlPoints(start, end, curvature, angle, ncp) + return listOf(start) + controlPoints + listOf(end) +} + +private fun calcControlPoints( + start: DoubleVector, + end: DoubleVector, + curvature: Double, + angle: Double, + ncp: Int +): List { + // straight line + if (curvature == 0.0 || abs(angle) !in 1.0..179.0) { + return emptyList() + } + + val mid = start.add(end).mul(0.5) + val d = end.subtract(start) + + val rAngle = toRadians(angle) + val corner = mid.add( + start.subtract(mid).rotate(rAngle) + ) + + // Calculate angle to rotate region by to align it with x/y axes + val beta = -atan(lineSlope(start, corner)) + + // Rotate end point about start point to align region with x/y axes + val new = start.add( + d.rotate(beta) + ) + + // Calculate x-scale factor to make region "square" + val scaleX = lineSlope(start, new) + + // Calculate the origin in the "square" region + // (for rotating start point to produce control points) + // (depends on 'curvature') + // 'origin' calculated from 'curvature' + val ratio = 2 * (sin(atan(curvature)).pow(2)) + val origin = curvature - curvature / ratio + + val ps = DoubleVector(start.x * scaleX, start.y) + val oxy = calcOrigin( + ps = ps, + pe = DoubleVector(new.x * scaleX, new.y), + origin + ) + + // Direction of rotation + val dir = sign(curvature) + + // Angle of rotation depends on location of origin + val maxTheta = PI + sign(origin * dir) * 2 * atan(abs(origin)) + + val theta = (0 until (ncp + 2)) + .map { it * dir * maxTheta / (ncp + 1) } + .drop(1) + .dropLast(1) + + // May have BOTH multiple end points AND multiple + // control points to generate (per set of end points) + // Generate consecutive sets of control points by performing + // matrix multiplication + + val indices = List(theta.size) { index -> index } + + val p = ps.subtract(oxy) + val cp = indices.map { index -> + oxy.add( + p.rotate(theta[index]) + ) + } + // Reverse transformations (scaling and rotation) to + // produce control points in the original space + .map { + DoubleVector( + it.x / scaleX, + it.y + ) + } + + return indices.map { index -> + start.add( + cp[index].subtract(start).rotate(-beta) + ) + } +} + +private fun calcOrigin( + ps: DoubleVector, + pe: DoubleVector, + origin: Double +): DoubleVector { + + val mid = ps.add(pe).mul(0.5) + val d = pe.subtract(ps) + val slope = lineSlope(ps, pe) + + val oSlope = -1 / slope + + // The origin is a point somewhere along the line between + // the end points, rotated by 90 (or -90) degrees + // Two special cases: + // If slope is non-finite then the end points lie on a vertical line, so + // the origin lies along a horizontal line (oSlope = 0) + // If oSlope is non-finite then the end points lie on a horizontal line, + // so the origin lies along a vertical line (oSlope = Inf) + val tmpOX = when { + !slope.isFinite() -> 0.0 + !oSlope.isFinite() -> origin * d.x / 2 + else -> origin * d.x / 2 + } + + val tmpOY = when { + !slope.isFinite() -> origin * d.y / 2 + !oSlope.isFinite() -> 0.0 + else -> origin * d.y / 2 + } + + return DoubleVector(mid.x + tmpOY, mid.y - tmpOX) +} diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/geometry/Padding.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/geometry/Padding.kt new file mode 100644 index 00000000000..725018f270c --- /dev/null +++ b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/geometry/Padding.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2024. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package org.jetbrains.letsPlot.commons.geometry + +import org.jetbrains.letsPlot.commons.intern.math.distance +import org.jetbrains.letsPlot.commons.intern.math.distance2 +import org.jetbrains.letsPlot.commons.intern.math.pointOnLine + +private fun pad(lineString: List, padding: Double): Pair? { + if (lineString.size < 2) { + return null + } + + val padding2 = padding * padding + val indexOutsidePadding = lineString.indexOfFirst { distance2(lineString.first(), it) >= padding2 } + if (indexOutsidePadding < 1) { // not found or first points already satisfy the padding + return null + } + + val adjustedStartPoint = run { + val insidePadding = lineString[indexOutsidePadding - 1] + val outsidePadding = lineString[indexOutsidePadding] + val overPadding = distance(lineString.first(), outsidePadding) - padding + + pointOnLine(outsidePadding, insidePadding, overPadding) + } + + return indexOutsidePadding to adjustedStartPoint +} + +private fun padStart(lineString: List, padding: Double): List { + val (index, adjustedStartPoint) = pad(lineString, padding) ?: return lineString + return listOf(adjustedStartPoint) + lineString.subList(index, lineString.size) +} + +private fun padEnd(lineString: List, padding: Double): List { + val (index, adjustedEndPoint) = pad(lineString.asReversed(), padding) ?: return lineString + return lineString.subList(0, lineString.size - index) + adjustedEndPoint +} + +fun padLineString( + lineString: List, + startPadding: Double, + endPadding: Double +): List { + val startPadded = padStart(lineString, startPadding) + return padEnd(startPadded, endPadding) +} diff --git a/future_changes.md b/future_changes.md index 2efaddc2d66..978bcd888da 100644 --- a/future_changes.md +++ b/future_changes.md @@ -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 @@ -9,4 +10,5 @@ - 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)]. - Livemap: `vjust` implemented incorrectly [[#1051](https://github.com/JetBrains/lets-plot/issues/1051)]. \ No newline at end of file diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AesScaling.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AesScaling.kt index c41adcf76c8..9abc4e6011d 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AesScaling.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AesScaling.kt @@ -57,4 +57,20 @@ object AesScaling { return p.size()!! * 2 } + private fun targetSize(p: DataPointAesthetics, atStart: Boolean): Double { + // px -> aes Units + val sizeAes = if (atStart) DataPointAesthetics::sizeStart else DataPointAesthetics::sizeEnd + val strokeAes = if (atStart) DataPointAesthetics::strokeStart else DataPointAesthetics::strokeEnd + return circleDiameter(p, sizeAes) / 2 + pointStrokeWidth(p, strokeAes) + } + + fun targetStartSize(p: DataPointAesthetics): Double { + // px -> aes Units + return targetSize(p, true) + } + + fun targetEndSize(p: DataPointAesthetics): Double { + // px -> aes Units + return targetSize(p, false) + } } \ No newline at end of file diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/ABLineGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/ABLineGeom.kt index 9aef30808b2..dae55c7d628 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/ABLineGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/ABLineGeom.kt @@ -58,10 +58,8 @@ class ABLineGeom : GeomBase() { if (lineEnds.size == 2) { val it = lineEnds.iterator() - val line = helper.createLine(it.next(), it.next(), p) - if (line != null) { - lines.add(line) - } + val (svg) = helper.createLine(it.next(), it.next(), p) ?: continue + lines.add(svg) } } } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/BoxplotGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/BoxplotGeom.kt index 9409f2c4ed0..2f3baaa1ec1 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/BoxplotGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/BoxplotGeom.kt @@ -75,7 +75,7 @@ class BoxplotGeom : GeomBase() { DoubleVector(x, hinge), DoubleVector(x, fence), p - )!! + )!!.first ) // fence line lines.add( @@ -83,7 +83,7 @@ class BoxplotGeom : GeomBase() { DoubleVector(x - halfFenceWidth, fence), DoubleVector(x + halfFenceWidth, fence), p - )!! + )!!.first ) } @@ -97,7 +97,7 @@ class BoxplotGeom : GeomBase() { DoubleVector(x, hinge), DoubleVector(x, fence), p - )!! + )!!.first ) // fence line lines.add( @@ -105,7 +105,7 @@ class BoxplotGeom : GeomBase() { DoubleVector(x - halfFenceWidth, fence), DoubleVector(x + halfFenceWidth, fence), p - )!! + )!!.first ) lines.forEach { root.add(it) } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/CrossBarGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/CrossBarGeom.kt index 2814c0d8d59..144261fdcc4 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/CrossBarGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/CrossBarGeom.kt @@ -133,11 +133,11 @@ class CrossBarGeom( val middle = p[yAes]!! val width = p[sizeAes]!! * ctx.getResolution(xAes) - val line = elementHelper.createLine( + val (line) = elementHelper.createLine( afterRotation(DoubleVector(x - width / 2, middle)), afterRotation(DoubleVector(x + width / 2, middle)), p - )!! + ) ?: continue // TODO: use strokeScale in createLine() function // adjust thickness diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/CurveGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/CurveGeom.kt index dbc288dca1c..02def98a928 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/CurveGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/CurveGeom.kt @@ -5,18 +5,13 @@ package org.jetbrains.letsPlot.core.plot.base.geom -import org.jetbrains.letsPlot.commons.geometry.DoubleVector -import org.jetbrains.letsPlot.commons.intern.math.lineSlope -import org.jetbrains.letsPlot.commons.intern.math.toRadians -import org.jetbrains.letsPlot.core.commons.data.SeriesUtil.finiteOrNull import org.jetbrains.letsPlot.core.plot.base.* import org.jetbrains.letsPlot.core.plot.base.geom.util.ArrowSpec import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomHelper +import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomUtil.toLocation import org.jetbrains.letsPlot.core.plot.base.render.LegendKeyElementFactory import org.jetbrains.letsPlot.core.plot.base.render.SvgRoot import org.jetbrains.letsPlot.datamodel.svg.dom.SvgPathDataBuilder -import org.jetbrains.letsPlot.datamodel.svg.dom.SvgPathElement -import kotlin.math.* class CurveGeom : GeomBase() { @@ -42,57 +37,24 @@ class CurveGeom : GeomBase() { ctx: GeomContext ) { val geomHelper = GeomHelper(pos, coord, ctx) + val svgElementHelper = geomHelper + .createSvgElementHelper() + .setInterpolation(SvgPathDataBuilder.Interpolation.BSPLINE) + .setArrowSpec(arrowSpec) + .setSpacer(spacer) for (p in aesthetics.dataPoints()) { - val x = finiteOrNull(p.x()) ?: continue - val y = finiteOrNull(p.y()) ?: continue - val xend = finiteOrNull(p.xend()) ?: continue - val yend = finiteOrNull(p.yend()) ?: continue - - val clientStart = geomHelper.toClient(DoubleVector(x, y), p) ?: continue - val clientEnd = geomHelper.toClient(DoubleVector(xend, yend), p) ?: continue + val start = p.toLocation(Aes.X, Aes.Y) ?: continue + val end = p.toLocation(Aes.XEND, Aes.YEND) ?: continue // Create curve geometry - val adjustedGeometry = createGeometry( - clientStart, clientEnd, - curvature, - -angle, // inverse because of using client coordinates - ncp - ).let { geometry -> - if (geometry.isEmpty()) { - // degenerated - null - } else { - // Apply padding to curve geometry based on the target size, spacer and arrow spec - val startPadding = SegmentGeom.padding(p, arrowSpec, spacer, atStart = true) - val endPadding = SegmentGeom.padding(p, arrowSpec, spacer, atStart = false) - SegmentGeom.padLineString(geometry, startPadding, endPadding) - } - } ?: continue - - // Draw curve - SvgPathElement().apply { - d().set( - SvgPathDataBuilder().apply { - moveTo(adjustedGeometry.first()) - interpolatePoints( - adjustedGeometry, - SvgPathDataBuilder.Interpolation.BSPLINE - ) - }.build() - ) - GeomHelper.decorate(this, p, applyAlphaToAll = true, filled = false) - } - .also(root::add) + // inverse angle because of using client coordinates + val (svg) = svgElementHelper.createCurve(start, end, curvature, -angle, ncp, p) ?: continue - // arrows - arrowSpec - ?.let { ArrowSpec.createArrows(p, adjustedGeometry, it) } - ?.forEach(root::add) + root.add(svg) } } - companion object { const val HANDLES_GROUPS = false @@ -100,151 +62,6 @@ class CurveGeom : GeomBase() { const val DEF_CURVATURE = 0.5 const val DEF_NCP = 5 const val DEF_SPACER = 0.0 - - /* - Calculates a set of control points based on: - 'curvature', ' angle', and 'ncp' and the start and end point locations. - */ - fun createGeometry( - start: DoubleVector, - end: DoubleVector, - curvature: Double, - angle: Double, - ncp: Int - ): List { - if (start == end) { - return emptyList() - } - val controlPoints = calcControlPoints( - start, - end, - curvature, - angle, - ncp - ) - return listOf(start) + controlPoints + listOf(end) - } - - // https://svn.r-project.org/R/trunk/src/library/grid/R/curve.R - - private fun calcControlPoints( - start: DoubleVector, - end: DoubleVector, - curvature: Double, - angle: Double, - ncp: Int - ): List { - // straight line - if (curvature == 0.0 || abs(angle) !in 1.0..179.0) { - return emptyList() - } - - val mid = start.add(end).mul(0.5) - val d = end.subtract(start) - - val rAngle = toRadians(angle) - val corner = mid.add( - start.subtract(mid).rotate(rAngle) - ) - - // Calculate angle to rotate region by to align it with x/y axes - val beta = -atan(lineSlope(start, corner)) - - // Rotate end point about start point to align region with x/y axes - val new = start.add( - d.rotate(beta) - ) - - // Calculate x-scale factor to make region "square" - val scaleX = lineSlope(start, new) - - // Calculate the origin in the "square" region - // (for rotating start point to produce control points) - // (depends on 'curvature') - // 'origin' calculated from 'curvature' - val ratio = 2 * (sin(atan(curvature)).pow(2)) - val origin = curvature - curvature / ratio - - val ps = DoubleVector(start.x * scaleX, start.y) - val oxy = calcOrigin( - ps = ps, - pe = DoubleVector(new.x * scaleX, new.y), - origin - ) - - // Direction of rotation - val dir = sign(curvature) - - // Angle of rotation depends on location of origin - val maxTheta = PI + sign(origin * dir) * 2 * atan(abs(origin)) - - val theta = (0 until (ncp + 2)) - .map { it * dir * maxTheta / (ncp + 1) } - .drop(1) - .dropLast(1) - - // May have BOTH multiple end points AND multiple - // control points to generate (per set of end points) - // Generate consecutive sets of control points by performing - // matrix multiplication - - val indices = List(theta.size) { index -> index } - - val p = ps.subtract(oxy) - val cp = indices.map { index -> - oxy.add( - p.rotate(theta[index]) - ) - } - // Reverse transformations (scaling and rotation) to - // produce control points in the original space - .map { - DoubleVector( - it.x / scaleX, - it.y - ) - } - - return indices.map { index -> - start.add( - cp[index].subtract(start).rotate(-beta) - ) - } - } - - private fun calcOrigin( - ps: DoubleVector, - pe: DoubleVector, - origin: Double - ): DoubleVector { - - val mid = ps.add(pe).mul(0.5) - val d = pe.subtract(ps) - val slope = lineSlope(ps, pe) - - val oSlope = -1 / slope - - // The origin is a point somewhere along the line between - // the end points, rotated by 90 (or -90) degrees - // Two special cases: - // If slope is non-finite then the end points lie on a vertical line, so - // the origin lies along a horizontal line (oSlope = 0) - // If oSlope is non-finite then the end points lie on a horizontal line, - // so the origin lies along a vertical line (oSlope = Inf) - val tmpOX = when { - !slope.isFinite() -> 0.0 - !oSlope.isFinite() -> origin * d.x / 2 - else -> origin * d.x / 2 - } - - val tmpOY = when { - !slope.isFinite() -> origin * d.y / 2 - !oSlope.isFinite() -> 0.0 - else -> origin * d.y / 2 - } - - return DoubleVector(mid.x + tmpOY, mid.y - tmpOX) - } } } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/ErrorBarGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/ErrorBarGeom.kt index a46335f7663..ed380cbb6a2 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/ErrorBarGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/ErrorBarGeom.kt @@ -155,7 +155,7 @@ class ErrorBarGeom(private val isVertical: Boolean) : GeomBase() { elementHelper.setStrokeAlphaEnabled(true) segments.forEach { segment -> g.children().add( - elementHelper.createLine(segment.start, segment.end, p)!! + elementHelper.createLine(segment.start, segment.end, p)!!.first ) } return g diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/HLineGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/HLineGeom.kt index 17282bd6671..61b935e96a4 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/HLineGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/HLineGeom.kt @@ -28,9 +28,8 @@ class HLineGeom : GeomBase() { val tooltipHelper = TargetCollectorHelper(GeomKind.H_LINE, ctx) val geomHelper = GeomHelper(pos, coord, ctx) val helper = geomHelper.createSvgElementHelper() - helper.setStrokeAlphaEnabled(true) - helper.setResamplingEnabled(!coord.isLinear) - helper.setGeometryHandler { aes, lineString -> tooltipHelper.addLine(lineString, aes) } + .setStrokeAlphaEnabled(true) + .setResamplingEnabled(!coord.isLinear) val viewPort = overallAesBounds(ctx) @@ -42,7 +41,9 @@ class HLineGeom : GeomBase() { val start = DoubleVector(viewPort.left, intercept) val end = DoubleVector(viewPort.right, intercept) - val svg = helper.createLine(start, end, p) ?: continue + val (svg, linestring) = helper.createLine(start, end, p) ?: continue + + tooltipHelper.addLine(linestring, p) root.add(svg) } } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/LineRangeGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/LineRangeGeom.kt index d24e93ccb81..63900dea4ae 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/LineRangeGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/LineRangeGeom.kt @@ -60,7 +60,7 @@ class LineRangeGeom(private val isVertical: Boolean) : GeomBase() { // line val start = afterRotation(DoubleVector(x, ymin)) val end = afterRotation(DoubleVector(x, ymax)) - helper.createLine(start, end, p)?.let { root.add(it) } + helper.createLine(start, end, p)?.let { (svgElement, _) -> root.add(svgElement) } } // tooltip flipHelper.buildHints( diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/PointRangeGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/PointRangeGeom.kt index 82b0f126d09..e2b7d3bed2c 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/PointRangeGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/PointRangeGeom.kt @@ -70,7 +70,7 @@ class PointRangeGeom(private val isVertical: Boolean) : GeomBase() { // vertical line val start = afterRotation(DoubleVector(x, ymin)) val end = afterRotation(DoubleVector(x, ymax)) - helper.createLine(start, end, p, strokeScaler = AesScaling::lineWidth)?.let { root.add(it) } + helper.createLine(start, end, p, strokeScaler = AesScaling::lineWidth)?.let { (svgElement, _) -> root.add(svgElement) } // mid-point val location = geomHelper.toClient(afterRotation(DoubleVector(x, y)), p)!! diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SegmentGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SegmentGeom.kt index 74961630a5a..f767ecb72e1 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SegmentGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SegmentGeom.kt @@ -5,23 +5,13 @@ package org.jetbrains.letsPlot.core.plot.base.geom -import org.jetbrains.letsPlot.commons.geometry.DoubleVector -import org.jetbrains.letsPlot.commons.intern.math.distance -import org.jetbrains.letsPlot.commons.intern.math.distance2 -import org.jetbrains.letsPlot.commons.intern.math.pointOnLine -import org.jetbrains.letsPlot.core.commons.data.SeriesUtil.finiteOrNull import org.jetbrains.letsPlot.core.plot.base.* -import org.jetbrains.letsPlot.core.plot.base.aes.AesScaling import org.jetbrains.letsPlot.core.plot.base.geom.util.ArrowSpec import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomHelper +import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomUtil.toLocation import org.jetbrains.letsPlot.core.plot.base.geom.util.TargetCollectorHelper import org.jetbrains.letsPlot.core.plot.base.render.LegendKeyElementFactory import org.jetbrains.letsPlot.core.plot.base.render.SvgRoot -import org.jetbrains.letsPlot.core.plot.base.render.svg.lineString -import org.jetbrains.letsPlot.datamodel.svg.dom.SvgPathDataBuilder -import org.jetbrains.letsPlot.datamodel.svg.dom.SvgPathElement -import kotlin.math.sign -import kotlin.math.sin class SegmentGeom : GeomBase() { @@ -46,112 +36,22 @@ class SegmentGeom : GeomBase() { val geomHelper = GeomHelper(pos, coord, ctx) val svgHelper = geomHelper .createSvgElementHelper() + .setSpacer(spacer) .setResamplingEnabled(!coord.isLinear && !flat) + .setArrowSpec(arrowSpec) - for (p in aesthetics.dataPoints()) { - val segmentGeometry = createSegmentGeometry(p, svgHelper) ?: continue - - // Apply padding to segment geometry based on the target size, spacer and arrow spec - val startPadding = padding(p, arrowSpec, spacer, atStart = true) - val endPadding = padding(p, arrowSpec, spacer, atStart = false) - - val adjustedSegmentGeometry = padLineString(segmentGeometry, startPadding, endPadding) - - val svgSegmentElement = SvgPathElement(SvgPathDataBuilder().lineString(adjustedSegmentGeometry).build()) - GeomHelper.decorate(svgSegmentElement, p, applyAlphaToAll = true, filled = false) - root.add(svgSegmentElement) - arrowSpec - ?.let { ArrowSpec.createArrows(p, adjustedSegmentGeometry, it) } - ?.forEach(root::add) + for (p in aesthetics.dataPoints()) { + val start = p.toLocation(Aes.X, Aes.Y) ?: continue + val end = p.toLocation(Aes.XEND, Aes.YEND) ?: continue + val (svg, geometry) = svgHelper.createLine(start, end, p) ?: continue - tooltipHelper.addLine(adjustedSegmentGeometry, p) + tooltipHelper.addLine(geometry, p) + root.add(svg) } } - private fun createSegmentGeometry(p: DataPointAesthetics, geomHelper: GeomHelper.SvgElementHelper): List? { - val x = finiteOrNull(p.x()) ?: return null - val y = finiteOrNull(p.y()) ?: return null - val xend = finiteOrNull(p.xend()) ?: return null - val yend = finiteOrNull(p.yend()) ?: return null - - val start = DoubleVector(x, y) - val end = DoubleVector(xend, yend) - - return geomHelper.createLineGeometry(start, end, p) - } - companion object { const val HANDLES_GROUPS = false - - private fun pad(lineString: List, padding: Double): Pair? { - if (lineString.size < 2) { - return null - } - - val padding2 = padding * padding - val indexOutsidePadding = lineString.indexOfFirst { distance2(lineString.first(), it) >= padding2 } - if (indexOutsidePadding < 1) { // not found or first points already satisfy the padding - return null - } - - val adjustedStartPoint = run { - val insidePadding = lineString[indexOutsidePadding - 1] - val outsidePadding = lineString[indexOutsidePadding] - val overPadding = distance(lineString.first(), outsidePadding) - padding - - pointOnLine(outsidePadding, insidePadding, overPadding) - } - - return indexOutsidePadding to adjustedStartPoint - } - - private fun padStart(lineString: List, padding: Double): List { - val (index, adjustedStartPoint) = pad(lineString, padding) ?: return lineString - return listOf(adjustedStartPoint) + lineString.subList(index, lineString.size) - } - - private fun padEnd(lineString: List, padding: Double): List { - val (index, adjustedEndPoint) = pad(lineString.asReversed(), padding) ?: return lineString - return lineString.subList(0, lineString.size - index) + adjustedEndPoint - } - - fun padLineString( - lineString: List, - startPadding: Double, - endPadding: Double - ): List { - val startPadded = padStart(lineString, startPadding) - return padEnd(startPadded, endPadding) - } - - private fun targetSize(p: DataPointAesthetics, atStart: Boolean): Double { - val sizeAes = if (atStart) DataPointAesthetics::sizeStart else DataPointAesthetics::sizeEnd - val strokeAes = if (atStart) DataPointAesthetics::strokeStart else DataPointAesthetics::strokeEnd - return AesScaling.circleDiameter(p, sizeAes) / 2 + AesScaling.pointStrokeWidth(p, strokeAes) - } - - fun padding( - p: DataPointAesthetics, - arrowSpec: ArrowSpec?, - spacer: Double, - atStart: Boolean - ): Double { - val targetSize = targetSize(p, atStart) - - val miterOffset = arrowSpec?.let { - val hasArrow = if (atStart) arrowSpec.isOnFirstEnd else arrowSpec.isOnLastEnd - if (hasArrow) { - val strokeWidth = AesScaling.strokeWidth(p) - val miterLength = ArrowSpec.miterLength(arrowSpec.angle * 2, strokeWidth) - val miterSign = sign(sin(arrowSpec.angle * 2)) - miterLength * miterSign / 2 - } else { - 0.0 - } - } ?: 0.0 - - return targetSize + spacer + miterOffset - } } } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SpokeGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SpokeGeom.kt index 6b5e8c8df68..1fc047c1f98 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SpokeGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/SpokeGeom.kt @@ -5,18 +5,16 @@ package org.jetbrains.letsPlot.core.plot.base.geom -import org.jetbrains.letsPlot.commons.geometry.DoubleVector import org.jetbrains.letsPlot.commons.interval.DoubleSpan import org.jetbrains.letsPlot.core.plot.base.* import org.jetbrains.letsPlot.core.plot.base.geom.legend.HLineLegendKeyElementFactory import org.jetbrains.letsPlot.core.plot.base.geom.util.ArrowSpec import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomHelper -import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomUtil +import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomHelper.SvgElementHelper +import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomUtil.toLocation import org.jetbrains.letsPlot.core.plot.base.geom.util.TargetCollectorHelper import org.jetbrains.letsPlot.core.plot.base.render.LegendKeyElementFactory import org.jetbrains.letsPlot.core.plot.base.render.SvgRoot -import kotlin.math.cos -import kotlin.math.sin class SpokeGeom : GeomBase(), WithWidth, WithHeight { var arrowSpec: ArrowSpec? = null @@ -35,27 +33,17 @@ class SpokeGeom : GeomBase(), WithWidth, WithHeight { val tooltipHelper = TargetCollectorHelper(GeomKind.SPOKE, ctx) val geomHelper = GeomHelper(pos, coord, ctx) val svgElementHelper = geomHelper.createSvgElementHelper() - svgElementHelper.setStrokeAlphaEnabled(true) - - svgElementHelper.setGeometryHandler { aes, lineString -> - tooltipHelper.addLine(lineString, aes) - - arrowSpec?.let { - val arrow = ArrowSpec.createArrows(aes, lineString, it) - arrow.forEach(root::add) - } - } + .setStrokeAlphaEnabled(true) + .setArrowSpec(arrowSpec) for (p in aesthetics.dataPoints()) { - val x = p.finiteOrNull(Aes.X) ?: continue - val y = p.finiteOrNull(Aes.Y) ?: continue - val spoke = toSpoke(p) ?: continue - val base = DoubleVector(x, y) - val start = getStart(base, spoke, pivot) - val end = getEnd(base, spoke, pivot) - val line = svgElementHelper.createLine(start, end, p) ?: continue + val start = p.toLocation(Aes.X, Aes.Y) ?: continue + val angle = p.finiteOrNull(Aes.ANGLE) ?: continue + val radius = p.finiteOrNull(Aes.RADIUS) ?: continue + val (svg, geometry) = svgElementHelper.createSpoke(start, angle, radius, pivot.factor, p) ?: continue - root.add(line) + tooltipHelper.addLine(geometry, p) + root.add(svg) } } @@ -82,11 +70,15 @@ class SpokeGeom : GeomBase(), WithWidth, WithHeight { coordAes: Aes, spanAxisAes: Aes ): DoubleSpan? { - val loc = GeomUtil.TO_LOCATION_X_Y(p) ?: return null - val base = loc.flipIf(coordAes != spanAxisAes) - val spoke = toSpoke(p) ?: return null - val start = getStart(base, spoke, pivot) - val end = getEnd(base, spoke, pivot) + val base = p.toLocation(Aes.X, Aes.Y)?.flipIf(coordAes != spanAxisAes) ?: return null + val angle = p.finiteOrNull(Aes.ANGLE) ?: return null + val radius = p.finiteOrNull(Aes.RADIUS) ?: return null + val elementHelper = SvgElementHelper() + val (_, geometry) = elementHelper.createSpoke(base, angle, radius, pivot.factor, p) ?: return null + + require(geometry.size == 2) + val (start, end) = geometry + return if (spanAxisAes == Aes.X) { DoubleSpan(start.x, end.x) } else { @@ -94,52 +86,14 @@ class SpokeGeom : GeomBase(), WithWidth, WithHeight { } } - private fun toSpoke(p: DataPointAesthetics): DoubleVector? { - val angle = p.finiteOrNull(Aes.ANGLE) ?: return null - val radius = p.finiteOrNull(Aes.RADIUS) ?: return null - - return getSpoke(angle, radius) - } - - enum class Pivot { - TAIL, MIDDLE, TIP + enum class Pivot( + val factor: Double + ) { + TAIL(0.0), MIDDLE(0.5), TIP(1.0) } companion object { val DEF_PIVOT = Pivot.TAIL - - fun createGeometry( - x: Double, - y: Double, - angle: Double, - radius: Double, - pivot: Pivot - ): List { - val base = DoubleVector(x, y) - val spoke = getSpoke(angle, radius) - return listOf(getStart(base, spoke, pivot), getEnd(base, spoke, pivot)) - } - - private fun getStart(base: DoubleVector, spoke: DoubleVector, pivot: Pivot): DoubleVector { - return when (pivot) { - Pivot.TAIL -> base - Pivot.MIDDLE -> base.subtract(spoke.mul(0.5)) - Pivot.TIP -> base.subtract(spoke) - } - } - - private fun getEnd(base: DoubleVector, spoke: DoubleVector, pivot: Pivot): DoubleVector { - return when (pivot) { - Pivot.TAIL -> base.add(spoke) - Pivot.MIDDLE -> base.add(spoke.mul(0.5)) - Pivot.TIP -> base - } - } - - private fun getSpoke(angle: Double, radius: Double): DoubleVector { - return DoubleVector(radius * cos(angle), radius * sin(angle)) - } - const val HANDLES_GROUPS = false } } \ No newline at end of file diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/VLineGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/VLineGeom.kt index 64a20e612ff..dc5e4e6794b 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/VLineGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/VLineGeom.kt @@ -30,7 +30,6 @@ class VLineGeom : GeomBase() { val helper = geomHelper.createSvgElementHelper() helper.setStrokeAlphaEnabled(true) helper.setResamplingEnabled(!coord.isLinear) - helper.setGeometryHandler { aes, lineString -> tooltipHelper.addLine(lineString, aes) } val viewPort = overallAesBounds(ctx) @@ -42,7 +41,9 @@ class VLineGeom : GeomBase() { val start = DoubleVector(intercept, viewPort.top) val end = DoubleVector(intercept, viewPort.bottom) - val svg = helper.createLine(start, end, p) ?: continue + val (svg, geometry) = helper.createLine(start, end, p) ?: continue + + tooltipHelper.addLine(geometry, p) root.add(svg) } } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/ArrowSpec.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/ArrowSpec.kt index 70483308f79..72d61f8505d 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/ArrowSpec.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/ArrowSpec.kt @@ -6,16 +6,13 @@ package org.jetbrains.letsPlot.core.plot.base.geom.util import org.jetbrains.letsPlot.commons.geometry.DoubleVector +import org.jetbrains.letsPlot.commons.intern.math.distance import org.jetbrains.letsPlot.commons.values.Color import org.jetbrains.letsPlot.core.plot.base.Aes 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 org.jetbrains.letsPlot.datamodel.svg.dom.SvgPathDataBuilder -import org.jetbrains.letsPlot.datamodel.svg.dom.SvgPathElement -import kotlin.math.abs import kotlin.math.atan2 -import kotlin.math.cos import kotlin.math.sin /** @@ -23,7 +20,13 @@ import kotlin.math.sin * Essentially describes the width of the arrow head. * @param length The length of the arrow head (px). */ -class ArrowSpec(val angle: Double, val length: Double, val end: End, val type: Type) { +class ArrowSpec( + val angle: Double, + val length: Double, + val end: End, + val type: Type, + val minTailLength: Double +) { val isOnFirstEnd: Boolean get() = end == End.FIRST || end == End.BOTH @@ -40,71 +43,85 @@ class ArrowSpec(val angle: Double, val length: Double, val end: End, val type: T } companion object { - fun createArrows( - p: DataPointAesthetics, + + fun createArrowHeads( geometry: List, arrowSpec: ArrowSpec - ): List { - val arrows = mutableListOf() - if (arrowSpec.isOnFirstEnd) { - val (start, end) = geometry.take(2).reversed() - arrows += createArrowAtEnd(p, start, end, arrowSpec) + ): Pair, List> { + val startHead = when (arrowSpec.isOnFirstEnd) { + true -> createArrowHeadGeometry(arrowSpec, geometry.asReversed()) + false -> emptyList() } - if (arrowSpec.isOnLastEnd) { - val (start, end) = geometry.takeLast(2) - arrows += createArrowAtEnd(p, start, end, arrowSpec) + + val endHead = when (arrowSpec.isOnLastEnd) { + true -> createArrowHeadGeometry(arrowSpec, geometry) + false -> emptyList() } - return arrows.filterNotNull() + + return startHead to endHead } - private fun createArrowAtEnd( - p: DataPointAesthetics, - start: DoubleVector, - end: DoubleVector, - arrowSpec: ArrowSpec - ): SvgPathElement? { + private fun pointIndexAtDistance(curve: List, distanceFromEnd: Double): Int { + var length = 0.0 + var i = curve.lastIndex - val abscissa = end.x - start.x - val ordinate = end.y - start.y - if (abscissa == 0.0 && ordinate == 0.0) return null + while (i > 0 && length < distanceFromEnd) { + val cur = curve[i] + val prev = curve[--i] + length += distance(cur, prev) + } + return i + } + + private fun createArrowHeadGeometry( + arrowSpec: ArrowSpec, + geometry: List + ): List { + if (geometry.size < 2) return emptyList() + + 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 tipPoint = geometry.last() + + val abscissa = tipPoint.x - basePoint.x + val ordinate = tipPoint.y - basePoint.y + if (abscissa == 0.0 && ordinate == 0.0) return emptyList() // Compute the angle that the vector defined by this segment makes with the // X-axis (radians) val polarAngle = atan2(ordinate, abscissa) - val arrowAes = arrowSpec.toArrowAes(p) - - val arrow = createElement(polarAngle, end.x, end.y, arrowSpec) - val strokeScaler = AesScaling::strokeWidth - GeomHelper.decorate(arrow, arrowAes, applyAlphaToAll = true, strokeScaler, filled = arrowSpec.type == Type.CLOSED) - // Use 'stroke-miterlimit' attribute to avoid the bevelled corner - val miterLimit = miterLength(arrowSpec.angle * 2, strokeScaler(p)) - arrow.strokeMiterLimit().set(abs(miterLimit)) - return arrow - } - - /** - * @param polarAngle Angle between X-axis and the arrowed vector. - */ - private fun createElement(polarAngle: Double, x: Double, y: Double, arrowSpec: ArrowSpec): SvgPathElement { - val xs = with(arrowSpec) { doubleArrayOf(x - length * cos(polarAngle - angle), x, x - length * cos(polarAngle + angle)) } - val ys = with(arrowSpec) { doubleArrayOf(y - length * sin(polarAngle - angle), y, y - length * sin(polarAngle + angle)) } + val length = tipPoint.subtract(DoubleVector(headLength, 0)) - val b = SvgPathDataBuilder(true) - .moveTo(xs[0], ys[0]) + val leftSide = length.rotateAround(tipPoint, polarAngle - arrowSpec.angle) + val rightSide = length.rotateAround(tipPoint, polarAngle + arrowSpec.angle) - for (i in 1..2) { - b.lineTo(xs[i], ys[i], true) + return when (arrowSpec.type) { + Type.CLOSED -> listOf(leftSide, tipPoint, rightSide, leftSide) + Type.OPEN -> listOf(leftSide, tipPoint, rightSide) } + } - if (arrowSpec.type == Type.CLOSED) { - b.closePath() - } + 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 SvgPathElement(b.build()) + return when (tailLength < arrowSpec.minTailLength) { + true -> maxOf((lineLength - arrowSpec.minTailLength) / headsCount, 5.0) // 5.0 so the arrow head never disappears + false -> arrowSpec.length + } } - private fun ArrowSpec.toArrowAes(p: DataPointAesthetics): DataPointAesthetics { + internal fun ArrowSpec.toArrowAes(p: DataPointAesthetics): DataPointAesthetics { return object : DataPointAestheticsDelegate(p) { private val filled = (type == Type.CLOSED) @@ -120,8 +137,12 @@ class ArrowSpec(val angle: Double, val length: Double, val end: End, val type: T } } - fun miterLength(headAngle: Double, strokeWidth: Double): Double { - return strokeWidth / sin(headAngle / 2) + fun miterLength( + arrowSpec: ArrowSpec, + p: DataPointAesthetics, + strokeScaler: (DataPointAesthetics) -> Double = AesScaling::strokeWidth + ): Double { + return strokeScaler(p) / sin(arrowSpec.angle) } } } diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/BoxHelper.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/BoxHelper.kt index 7bf8a6b69bc..615ef343a64 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/BoxHelper.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/BoxHelper.kt @@ -50,11 +50,11 @@ object BoxHelper { val middle = p[middleAesthetic]!! val width = p.width()!! * ctx.getResolution(Aes.X) - val line = elementHelper.createLine( + val (line) = elementHelper.createLine( DoubleVector(x - width / 2, middle), DoubleVector(x + width / 2, middle), p - )!! + ) ?: continue // TODO: use strokeScale in createLine() function // adjust thickness diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomHelper.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomHelper.kt index 385b4ed519c..694e94f6a6d 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomHelper.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomHelper.kt @@ -7,7 +7,10 @@ package org.jetbrains.letsPlot.core.plot.base.geom.util import org.jetbrains.letsPlot.commons.geometry.DoubleRectangle import org.jetbrains.letsPlot.commons.geometry.DoubleVector +import org.jetbrains.letsPlot.commons.geometry.curve +import org.jetbrains.letsPlot.commons.geometry.padLineString import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.AdaptiveResampler.Companion.resample +import org.jetbrains.letsPlot.commons.values.Color import org.jetbrains.letsPlot.core.plot.base.CoordinateSystem import org.jetbrains.letsPlot.core.plot.base.DataPointAesthetics import org.jetbrains.letsPlot.core.plot.base.GeomContext @@ -15,10 +18,16 @@ import org.jetbrains.letsPlot.core.plot.base.PositionAdjustment import org.jetbrains.letsPlot.core.plot.base.aes.AesScaling import org.jetbrains.letsPlot.core.plot.base.aes.AestheticsUtil import org.jetbrains.letsPlot.core.plot.base.aes.AestheticsUtil.ALPHA_CONTROLS_BOTH +import org.jetbrains.letsPlot.core.plot.base.geom.util.ArrowSpec.Companion.toArrowAes +import org.jetbrains.letsPlot.core.plot.base.geom.util.ArrowSpec.Type.CLOSED import org.jetbrains.letsPlot.core.plot.base.render.svg.StrokeDashArraySupport import org.jetbrains.letsPlot.core.plot.base.render.svg.lineString import org.jetbrains.letsPlot.datamodel.svg.dom.* +import org.jetbrains.letsPlot.datamodel.svg.dom.SvgPathDataBuilder.Interpolation import org.jetbrains.letsPlot.datamodel.svg.dom.slim.SvgSlimShape +import kotlin.math.cos +import kotlin.math.sign +import kotlin.math.sin open class GeomHelper( protected val pos: PositionAdjustment, @@ -92,77 +101,210 @@ open class GeomHelper( } fun createSvgElementHelper(): SvgElementHelper { - return SvgElementHelper() + return SvgElementHelper(::toClient) } - inner class SvgElementHelper { - private var geometryHandler: (DataPointAesthetics, List) -> Unit = { _, _ -> } + class SvgElementHelper( + private val toClient: (DoubleVector, DataPointAesthetics) -> DoubleVector? = { v, _ -> v } + ) { + private var myNoSvg: Boolean = false + private var myInterpolation: Interpolation? = null + private var myArrowSpec: ArrowSpec? = null private var myStrokeAlphaEnabled = false private var myResamplingEnabled = false private var myResamplingPrecision = 0.5 - - fun setStrokeAlphaEnabled(b: Boolean) { - myStrokeAlphaEnabled = b - } - - fun setResamplingEnabled(b: Boolean): SvgElementHelper { - myResamplingEnabled = b - return this - } - - fun setResamplingPrecision(precision: Double) { - myResamplingPrecision = precision - } - - fun setGeometryHandler(handler: (DataPointAesthetics, List) -> Unit) { - geometryHandler = handler - } - - fun createLineGeometry( + private var mySpacer: Double = 0.0 + private var myDebugRendering = false + + fun setStrokeAlphaEnabled(b: Boolean) = apply { myStrokeAlphaEnabled = b } + fun setResamplingEnabled(b: Boolean) = apply { myResamplingEnabled = b } + fun setArrowSpec(arrowSpec: ArrowSpec?) = apply { myArrowSpec = arrowSpec } + fun setSpacer(spacer: Double) = apply { mySpacer = spacer } + fun setInterpolation(interpolation: Interpolation) = apply { myInterpolation = interpolation } + fun setResamplingPrecision(precision: Double) = apply { myResamplingPrecision = precision } + fun noSvg() = apply { myNoSvg = true } + fun debugRendering(value: Boolean) = apply { myDebugRendering = value } + + private fun createLineGeometry( start: DoubleVector, end: DoubleVector, - p: DataPointAesthetics, + aes: DataPointAesthetics, ): List? { if (myResamplingEnabled) { - return resample(listOf(start, end), myResamplingPrecision) { toClient(it, p) } + return resample(listOf(start, end), myResamplingPrecision) { toClient(it, aes) } } else { - val from = toClient(start, p) ?: return null - val to = toClient(end, p) ?: return null + val from = toClient(start, aes) ?: return null + val to = toClient(end, aes) ?: return null return listOf(from, to) } } + fun createCurve( + start: DoubleVector, + end: DoubleVector, + curvature: Double, + angle: Double, + ncp: Int, + aes: DataPointAesthetics, + strokeScaler: (DataPointAesthetics) -> Double = AesScaling::strokeWidth + ): Pair>? { + if (start == end) { + return null + } + @Suppress("NAME_SHADOWING") + val start = toClient(start, aes) ?: return null + + @Suppress("NAME_SHADOWING") + val end = toClient(end, aes) ?: return null + + val lineString = curve(start, end, curvature, angle, ncp) + + if (myNoSvg) return SvgGElement() to lineString + + val svgElement = renderSvgElement(aes, lineString, strokeScaler) ?: return null + + return svgElement to lineString + } + fun createLine( start: DoubleVector, end: DoubleVector, p: DataPointAesthetics, strokeScaler: (DataPointAesthetics) -> Double = AesScaling::strokeWidth - ): SvgNode? { - if (myResamplingEnabled) { - val lineString = resample(listOf(start, end), myResamplingPrecision) { toClient(it, p) } + ): Pair>? { + val lineString = createLineGeometry(start, end, p) ?: return null + val svgElement = renderSvgElement(p, lineString, strokeScaler) ?: return null + + return svgElement to lineString + } - geometryHandler(p, lineString) + fun createSpoke( + base: DoubleVector, + angle: Double, + radius: Double, + pivot: Double, + p: DataPointAesthetics, + strokeScaler: (DataPointAesthetics) -> Double = AesScaling::strokeWidth + ): Pair>? { + val spoke = DoubleVector(radius * cos(angle), radius * sin(angle)) + val start = base.subtract(spoke.mul(pivot)) + val end = base.add(spoke.mul(1 - pivot)) + return createLine(start, end, p, strokeScaler) + } - val svgPathElement = SvgPathElement() - decorate(svgPathElement, p, myStrokeAlphaEnabled, strokeScaler, filled = false) - svgPathElement.d().set(SvgPathDataBuilder().lineString(lineString).build()) - return svgPathElement + private fun renderSvgElement( + p: DataPointAesthetics, + lineString: List, + strokeScaler: (DataPointAesthetics) -> Double + ): SvgNode? { + val lineStringAfterPadding = padLineString(lineString, p) + if (lineStringAfterPadding.isEmpty() || lineStringAfterPadding.size == 1) return null + + val lineElement = if (lineStringAfterPadding.size == 2) { + // Simple SvgLineElement is enough for a straight line without arrow + SvgLineElement().apply { + x1().set(lineStringAfterPadding.first().x) + y1().set(lineStringAfterPadding.first().y) + x2().set(lineStringAfterPadding.last().x) + y2().set(lineStringAfterPadding.last().y) + } + } else { + SvgPathElement().apply { + d().set( + if (myInterpolation != null) { + SvgPathDataBuilder() + .moveTo(lineStringAfterPadding.first()) + .interpolatePoints(lineStringAfterPadding, myInterpolation!!) + .build() + } else { + SvgPathDataBuilder().lineString(lineStringAfterPadding).build() + } + ) + } + } + decorate(lineElement, p, myStrokeAlphaEnabled, strokeScaler, filled = false) + + val arrowElements = myArrowSpec?.let { arrowSpec -> + val (startHead, endHead) = ArrowSpec.createArrowHeads(lineStringAfterPadding, arrowSpec) + val startHeadSvg = renderArrowHead(startHead, p, strokeScaler) + val endHeadSvg = renderArrowHead(endHead, p, strokeScaler) + listOfNotNull(startHeadSvg, endHeadSvg) + } ?: emptyList() + + val debugPoints = if (myDebugRendering) { + lineStringAfterPadding.map { + SvgCircleElement(it.x, it.y, 1.0).apply { + fillColor().set(Color.LIGHT_GREEN) + strokeColor().set(Color.GREEN) + } + } } else { - val from = toClient(start, p) ?: return null - val to = toClient(end, p) ?: return null + emptyList() + } - geometryHandler(p, listOf(from, to)) + return if (arrowElements.isEmpty() && debugPoints.isEmpty()) { + lineElement + } else { + SvgGElement().apply { + children().add(lineElement) + children().addAll(arrowElements) + children().addAll(debugPoints) + } + } + } - val svgLineElement = SvgLineElement(from.x, from.y, to.x, to.y) - decorate(svgLineElement, p, myStrokeAlphaEnabled, strokeScaler, filled = false) - return svgLineElement + private fun renderArrowHead( + points: List, + p: DataPointAesthetics, + strokeScaler: (DataPointAesthetics) -> Double + ): SvgNode? { + if (points.size < 2) return null + val arrowSpec = myArrowSpec ?: return null + + val arrowSvg = SvgPathElement().apply { + d().set(SvgPathDataBuilder() + .lineString(points) + .also { if (arrowSpec.type == CLOSED) it.closePath() } + .build() + ) } + + decorate( + arrowSvg, + arrowSpec.toArrowAes(p), + myStrokeAlphaEnabled, + strokeScaler, + filled = arrowSpec.type == CLOSED + ) + + return arrowSvg + } + + + private fun padLineString(lineString: List, p: DataPointAesthetics): List { + val startPadding = arrowPadding(p, atStart = true) + mySpacer + AesScaling.targetStartSize(p) + val endPadding = arrowPadding(p, atStart = false) + mySpacer + AesScaling.targetEndSize(p) + + return padLineString(lineString, startPadding, endPadding) + } + + private fun arrowPadding( + aes: DataPointAesthetics, + atStart: Boolean + ): Double { + val arrowSpec = myArrowSpec ?: return 0.0 + + val hasArrow = if (atStart) arrowSpec.isOnFirstEnd else arrowSpec.isOnLastEnd + if (!hasArrow) return 0.0 + + val miterLength = ArrowSpec.miterLength(arrowSpec, aes) + val miterSign = sign(sin(arrowSpec.angle * 2)) + return miterLength * miterSign / 2 } } companion object { - fun decorate( node: SvgNode, p: DataPointAesthetics, @@ -171,13 +313,7 @@ open class GeomHelper( filled: Boolean = true ) { if (node is SvgShape) { - decorateShape( - node as SvgShape, - p, - applyAlphaToAll, - strokeScaler, - filled - ) + decorateShape(node as SvgShape, p, applyAlphaToAll, strokeScaler, filled) } if (node is SvgElement) { diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomUtil.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomUtil.kt index e2ba0fab832..8f77451504d 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomUtil.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/GeomUtil.kt @@ -15,6 +15,10 @@ import org.jetbrains.letsPlot.core.plot.base.DataPointAesthetics object GeomUtil { + fun DataPointAesthetics.toLocation(xAes: Aes, yAes: Aes): DoubleVector? { + return toLocationOrNull(get(xAes), get(yAes)) + } + val TO_LOCATION_X_Y = { p: DataPointAesthetics -> toLocationOrNull( p.x(), diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/QuantilesHelper.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/QuantilesHelper.kt index f0189fc6c02..97a814ef84f 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/QuantilesHelper.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/util/QuantilesHelper.kt @@ -124,6 +124,6 @@ open class QuantilesHelper( val svgElementHelper = GeomHelper(pos, coord, ctx).createSvgElementHelper() val start = toLocationBoundStart(dataPoint) val end = toLocationBoundEnd(dataPoint) - return svgElementHelper.createLine(start, end, dataPoint)!! + return svgElementHelper.createLine(start, end, dataPoint)!!.first } } \ No newline at end of file diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/PathUtil.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/PathUtil.kt index 161e71cc543..27f45098e8f 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/PathUtil.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/render/svg/PathUtil.kt @@ -9,6 +9,8 @@ import org.jetbrains.letsPlot.commons.geometry.DoubleVector import org.jetbrains.letsPlot.datamodel.svg.dom.SvgPathDataBuilder fun SvgPathDataBuilder.lineString(points: List): SvgPathDataBuilder { + if (points.isEmpty()) return this + moveTo(points.first()) points.asSequence().drop(1).forEach(::lineTo) return this diff --git a/plot-livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/livemap/DataPointsConverter.kt b/plot-livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/livemap/DataPointsConverter.kt index e57a8a6bcc0..c0a62d1b5c5 100644 --- a/plot-livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/livemap/DataPointsConverter.kt +++ b/plot-livemap/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/livemap/DataPointsConverter.kt @@ -6,12 +6,12 @@ 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 import org.jetbrains.letsPlot.commons.values.Color import org.jetbrains.letsPlot.core.commons.data.SeriesUtil -import org.jetbrains.letsPlot.core.commons.data.SeriesUtil.finiteOrNull import org.jetbrains.letsPlot.core.plot.base.Aes import org.jetbrains.letsPlot.core.plot.base.Aesthetics import org.jetbrains.letsPlot.core.plot.base.DataPointAesthetics @@ -21,6 +21,7 @@ import org.jetbrains.letsPlot.core.plot.base.geom.util.* import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomUtil.TO_LOCATION_X_Y import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomUtil.TO_RECTANGLE import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomUtil.createPathGroups +import org.jetbrains.letsPlot.core.plot.base.geom.util.GeomUtil.toLocation import org.jetbrains.letsPlot.core.plot.builder.scale.DefaultNaValue import kotlin.math.abs import kotlin.math.min @@ -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?) { @@ -218,28 +234,33 @@ internal class DataPointsConverter( setFlat(true) return process(isClosed = false) { - if (SeriesUtil.allFinite(it.x(), it.y(), it.xend(), it.yend())) { - CurveGeom.createGeometry( - start = DoubleVector(it.x()!!, it.y()!!), - end = DoubleVector(it.xend()!!, it.yend()!!), - curvature = geom.curvature, - angle = geom.angle, - ncp = geom.ncp - ) - } else { - emptyList() - } + val start = it.toLocation(Aes.X, Aes.Y) ?: return@process emptyList() + val end = it.toLocation(Aes.XEND, Aes.YEND) ?: return@process emptyList() + + // not set arrowSpec - livemap handles it via setArrowSpec() call + val elementHelper = GeomHelper.SvgElementHelper() + .setSpacer(geom.spacer) + .noSvg() + + val (_, geometry) = elementHelper.createCurve(start, end, geom.curvature, geom.angle, geom.ncp, it) ?: return@process emptyList() + geometry } } fun spoke(geom: SpokeGeom): List { + setArrowSpec(geom.arrowSpec) + setFlat(true) + return process(isClosed = false) { - val x = finiteOrNull(it.x()) ?: return@process emptyList() - val y = finiteOrNull(it.y()) ?: return@process emptyList() - val angle = finiteOrNull(it.angle()) ?: return@process emptyList() - val radius = finiteOrNull(it.radius()) ?: return@process emptyList() + val base = it.toLocation(Aes.X, Aes.Y) ?: return@process emptyList() + val angle = it.finiteOrNull(Aes.ANGLE) ?: return@process emptyList() + val radius = it.finiteOrNull(Aes.RADIUS) ?: return@process emptyList() + + val elementHelper = GeomHelper.SvgElementHelper() + .noSvg() - SpokeGeom.createGeometry(x, y, angle, radius, geom.pivot) + val (_, geometry) = elementHelper.createSpoke(base, angle, radius, geom.pivot.factor, it) ?: return@process emptyList() + geometry } } diff --git a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt index 8373ab6a18c..2e858137d95 100644 --- a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt +++ b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt @@ -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 { diff --git a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/config/ArrowSpecConfig.kt b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/config/ArrowSpecConfig.kt index a1006ce886f..1db9301375d 100644 --- a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/config/ArrowSpecConfig.kt +++ b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/config/ArrowSpecConfig.kt @@ -13,36 +13,28 @@ internal class ArrowSpecConfig private constructor(options: Map) : 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 { @@ -50,6 +42,7 @@ internal class ArrowSpecConfig private constructor(options: Map) : 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<*, *>) { diff --git a/python-package/lets_plot/plot/geom_extras.py b/python-package/lets_plot/plot/geom_extras.py index aae0c48e18e..52e21332af0 100644 --- a/python-package/lets_plot/plot/geom_extras.py +++ b/python-package/lets_plot/plot/geom_extras.py @@ -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. @@ -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`