Skip to content

Commit

Permalink
Fix #1037 - fix polygon-based geoms in polar coordinate system (#1069)
Browse files Browse the repository at this point in the history
  • Loading branch information
IKupriyanov-HORIS authored and RYangazov committed Mar 29, 2024
1 parent 018ef8d commit dc19f6e
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 75 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import org.jetbrains.letsPlot.commons.geometry.DoubleVector
import org.jetbrains.letsPlot.commons.intern.math.distance2
import org.jetbrains.letsPlot.commons.intern.math.distance2ToLine


// Note that resampled points may contain duplicates, i.e. rings detection may fail.
class AdaptiveResampler<T> private constructor(
private val transform: (T) -> T?,
precision: Double,
Expand All @@ -18,7 +18,7 @@ class AdaptiveResampler<T> private constructor(
private val precisionSqr: Double = precision * precision

companion object {
const val PIXEL_RESAMPLING_PRECISION = 0.95
const val PIXEL_PRECISION = 0.95
private const val MAX_DEPTH_LIMIT = 9 // 1_025 points maximum (2^(LIMIT + 1) + 1)

private val DOUBLE_VECTOR_ADAPTER = object : DataAdapter<DoubleVector> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,18 @@ fun <T> splitRings(points: List<T>, eq: (T, T) -> Boolean): List<List<T>> {
val rings = findRingIntervals(points, eq).map(points::sublist).toMutableList()

if (rings.isNotEmpty()) {
if (!rings.last().isClosed()) {
if (!rings.last().isClosed(eq)) {
rings[rings.lastIndex] = makeClosed(rings.last())
}
}

//require(rings.sumOf { it.size } == points.size) { "Split rings error: ${rings.sumOf { it.size }} != ${points.size}" }
return rings
}

private fun <T> makeClosed(path: List<T>) = path.toMutableList() + path.first()

fun <T> List<T>.isClosed() = first() == last()
fun <T> List<T>.isClosed(eq: (T, T) -> Boolean = { p1, p2 -> p1 == p2 }) = eq(first(), last())

private fun <T> findRingIntervals(path: List<T>, eq: (T, T) -> Boolean): List<IntSpan> {
val intervals = ArrayList<IntSpan>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import org.jetbrains.letsPlot.commons.geometry.DoubleRectangle
import org.jetbrains.letsPlot.commons.geometry.DoubleRectangles.boundingBox
import org.jetbrains.letsPlot.commons.geometry.DoubleVector
import org.jetbrains.letsPlot.commons.intern.spatial.projections.Projection
import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.AdaptiveResampler.Companion.PIXEL_RESAMPLING_PRECISION
import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.AdaptiveResampler.Companion.PIXEL_PRECISION
import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.AdaptiveResampler.Companion.resample
import org.jetbrains.letsPlot.core.plot.base.ScaleMapper
import org.jetbrains.letsPlot.core.plot.base.scale.Mappers
Expand Down Expand Up @@ -140,7 +140,7 @@ fun projectDomain(

val hLines = points(domain.top, domain.bottom).map { DoubleVector(domain.left, it) to DoubleVector(domain.right, it) }
val vLines = points(domain.left, domain.right).map { DoubleVector(it, domain.top) to DoubleVector(it, domain.bottom) }
val grid = (hLines + vLines).map { (p1, p2) -> resample(p1, p2, PIXEL_RESAMPLING_PRECISION, projection::project) }
val grid = (hLines + vLines).map { (p1, p2) -> resample(p1, p2, PIXEL_PRECISION, projection::project) }

val projectedDomain = boundingBox(grid.flatten()) ?: error("Can't calculate bounding box for projected domain")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ class PieGeom : GeomBase(), WithWidth, WithHeight {

val segmentLength = startPoint.subtract(endPoint).length()

return resample(startPoint, endPoint, AdaptiveResampler.PIXEL_RESAMPLING_PRECISION) { p: DoubleVector ->
return resample(startPoint, endPoint, AdaptiveResampler.PIXEL_PRECISION) { p: DoubleVector ->
val ratio = p.subtract(startPoint).length() / segmentLength
if (ratio.isFinite()) {
arcPoint(sector.startAngle + sector.angle * ratio)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ open class PolygonGeom : GeomBase() {
) {
val dataPoints = dataPoints(aesthetics)
val linesHelper = LinesHelper(pos, coord, ctx)
linesHelper.setResamplingEnabled(coord.isPolar)

val targetCollectorHelper = TargetCollectorHelper(GeomKind.POLYGON, ctx)

val pathData = linesHelper.createPathDataByGroup(dataPoints, GeomUtil.TO_LOCATION_X_Y)
targetCollectorHelper.addPolygons(pathData)
val svgPath = linesHelper.renderPaths(pathData.values, filled = true)
root.appendNodes(svgPath)
linesHelper.createPolygon(dataPoints, GeomUtil.TO_LOCATION_X_Y).forEach { (svg, polygonData) ->
targetCollectorHelper.addPolygons(polygonData)
root.add(svg)
}
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ open class GeomHelper(
private var myArrowSpec: ArrowSpec? = null
private var myStrokeAlphaEnabled = false
private var myResamplingEnabled = false
private var myResamplingPrecision = AdaptiveResampler.PIXEL_RESAMPLING_PRECISION
private var myResamplingPrecision = AdaptiveResampler.PIXEL_PRECISION
private var mySpacer: Double = 0.0
private var myDebugRendering = false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ package org.jetbrains.letsPlot.core.plot.base.geom.util

import org.jetbrains.letsPlot.commons.geometry.DoubleVector
import org.jetbrains.letsPlot.commons.intern.splitBy
import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.AdaptiveResampler
import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.AdaptiveResampler.Companion.PIXEL_PRECISION
import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.AdaptiveResampler.Companion.resample
import org.jetbrains.letsPlot.commons.intern.typedGeometry.algorithms.splitRings
import org.jetbrains.letsPlot.commons.values.Colors.withOpacity
import org.jetbrains.letsPlot.core.commons.geometry.PolylineSimplifier
import org.jetbrains.letsPlot.core.commons.geometry.PolylineSimplifier.Companion.DOUGLAS_PEUCKER_PIXEL_THRESHOLD
import org.jetbrains.letsPlot.core.commons.geometry.PolylineSimplifier.Companion.douglasPeucker
import org.jetbrains.letsPlot.core.plot.base.*
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.geom.util.GeomUtil.createPathGroups
import org.jetbrains.letsPlot.core.plot.base.render.svg.LinePath
import org.jetbrains.letsPlot.datamodel.svg.dom.SvgNode

open class LinesHelper(
pos: PositionAdjustment,
Expand Down Expand Up @@ -49,32 +51,78 @@ open class LinesHelper(
return renderPaths(pathDataByGroup.values, filled = false)
}

// TODO: filled parameter is always false
fun renderPaths(paths: Map<Int, List<PathData>>, filled: Boolean): List<LinePath> {
return renderPaths(paths.values.flatten(), filled)
}

fun renderPaths(paths: Collection<PathData>, filled: Boolean): List<LinePath> {
return paths.map { path -> renderPaths(path.aes, path.coordinates, filled) }
// TODO: filled parameter is always false
private fun renderPaths(paths: Collection<PathData>, filled: Boolean): List<LinePath> {
return paths.map { path ->
val visualPath = douglasPeucker(path.coordinates, DOUGLAS_PEUCKER_PIXEL_THRESHOLD)
val element = when (filled) {
true -> LinePath.polygon(visualPath)
false -> LinePath.line(visualPath)
}

decorate(element, path.aes, filled)
element
}
}

fun createPathData(
dataPoints: Iterable<DataPointAesthetics>,
locationTransform: (DataPointAesthetics) -> DoubleVector? = GeomUtil.TO_LOCATION_X_Y,
closePath: Boolean = false,
): Map<Int, List<PathData>> {
val domainInterpolatedData = preparePathData(dataPoints, locationTransform, closePath)
return toClient(domainInterpolatedData)
val domainData = preparePathData(dataPoints, locationTransform, closePath)
return toClient(domainData)
}

fun createPolygon(
dataPoints: Iterable<DataPointAesthetics>,
locationTransform: (DataPointAesthetics) -> DoubleVector? = GeomUtil.TO_LOCATION_X_Y,
): List<Pair<SvgNode, PolygonData>> {
val domainPathData = createPathGroups(dataPoints, locationTransform, sorted = true, closePath = true).values

// split in domain space! after resampling coordinates may repeat and splitRings will return wrong results
val domainPolygonData = domainPathData
.map { splitRings(it.points) { p1, p2 -> p1.coord == p2.coord } }
.map { PolygonData(it) }

val clientPolygonData = domainPolygonData.map { polygon ->
polygon.rings
.map { resample(it) + it.last().let { (aes, coord) -> PathPoint(aes, toClient(coord, aes)!!) } }
.let { PolygonData(it) }
}

val svg = clientPolygonData.map { polygon ->
val element = polygon.coordinates
.map { douglasPeucker(it, DOUGLAS_PEUCKER_PIXEL_THRESHOLD) }
.let(::insertPathSeparators)
.let { LinePath.polygon(it) }

decorate(element, polygon.aes, filled = true)
element.rootGroup
}

return svg.zip(clientPolygonData)
}

private fun resample(linestring: List<PathPoint>): List<PathPoint> {
return linestring.windowed(size = 2)
.map { (p1, p2) -> p1.aes to resample(p1.coord, p2.coord, PIXEL_PRECISION) { p -> toClient(p, p1.aes) } }
.flatMap { (aes, coords) -> coords.map { PathPoint(aes, it) } }
}

// TODO: return list of PathData for consistency
fun createPathDataByGroup(
dataPoints: Iterable<DataPointAesthetics>,
toLocation: (DataPointAesthetics) -> DoubleVector?
): Map<Int, PathData> {
return GeomUtil.createPathGroups(dataPoints, toClientLocation(toLocation), sorted = true, closePath = false)
return createPathGroups(dataPoints, toClientLocation(toLocation), sorted = true, closePath = false)
}


fun createSteps(paths: Map<Int, PathData>, horizontalThenVertical: Boolean): List<LinePath> {
val linePaths = ArrayList<LinePath>()

Expand Down Expand Up @@ -145,7 +193,12 @@ open class LinesHelper(
val points = pathData.coordinates

if (points.isNotEmpty()) {
val path = LinePath.polygon(if (simplifyBorders) simplify(points) else points)
val path = LinePath.polygon(
when {
simplifyBorders -> douglasPeucker(points, DOUGLAS_PEUCKER_PIXEL_THRESHOLD)
else -> points
}
)
decorateFillingPart(path, pathData.aes)
path
} else {
Expand All @@ -154,11 +207,6 @@ open class LinesHelper(
}
}

private fun simplify(points: List<DoubleVector>): List<DoubleVector> {
val weightLimit = 0.25 // in px for Douglas–Peucker algorithm
return PolylineSimplifier.douglasPeucker(points).setWeightLimit(weightLimit).points
}

fun decorate(
path: LinePath,
p: DataPointAesthetics,
Expand Down Expand Up @@ -191,40 +239,24 @@ open class LinesHelper(
path.fill().set(withOpacity(fill, fillAlpha))
}

private fun renderPaths(
aes: DataPointAesthetics,
points: List<DoubleVector>,
filled: Boolean
): LinePath {
val element = when (filled) {
true -> LinePath
.polygon(
splitRings(points)
.map { PolylineSimplifier.douglasPeucker(it, DOUGLAS_PEUCKER_PIXEL_THRESHOLD) }
.let(::insertPathSeparators)
)
false -> LinePath.line(PolylineSimplifier.douglasPeucker(points, DOUGLAS_PEUCKER_PIXEL_THRESHOLD))
}
decorate(element, aes, filled)
return element
}

private fun preparePathData(
dataPoints: Iterable<DataPointAesthetics>,
locationTransform: (DataPointAesthetics) -> DoubleVector?,
closePath: Boolean
): Map<Int, List<PathData>> {
val domainPathData = GeomUtil.createPathGroups(dataPoints, locationTransform, sorted = true, closePath = closePath)
val domainPathData = createPathGroups(dataPoints, locationTransform, sorted = true, closePath = closePath)
return domainPathData.mapValues { (_, pathData) -> listOf(pathData) }
}

private fun toClient(domainPathData: Map<Int, List<PathData>>): Map<Int, List<PathData>> {
return when (myResamplingEnabled) {
true -> {
val domainVariadicPathData = domainPathData.mapValues { (_, groupPath) -> groupPath.flatMap(::splitByStyle) }
val domainInterpolatedPathData = interpolatePathData(domainVariadicPathData)
resamplePathData(domainInterpolatedPathData)
domainPathData
.mapValues { (_, groupPath) -> groupPath.flatMap(::splitByStyle) }
.let { interpolatePathData(it) }
.mapValues { (_, paths) -> paths.map { PathData(resample(it.points)) } }
}

false -> {
val clientPathData = domainPathData.mapValues { (_, groupPath) ->
groupPath.map { segment ->
Expand All @@ -237,25 +269,13 @@ open class LinesHelper(
}
}

val clientVariadicPathData = clientPathData.mapValues { (_, pathData) -> pathData.flatMap(::splitByStyle) }
val clientVariadicPathData =
clientPathData.mapValues { (_, pathData) -> pathData.flatMap(::splitByStyle) }
interpolatePathData(clientVariadicPathData)
}
}
}

// TODO: refactor - inconsistent and implicit usage of the toClient method in a whole LinesHelper class
private fun resamplePathData(pathData: Map<Int, List<PathData>>): Map<Int, List<PathData>> {
return pathData.mapValues { (_, path) ->
path.map { segment ->
val smoothed = segment.points
.windowed(size = 2)
.map { (p1, p2) -> p1.aes to resample(p1.coord, p2.coord, AdaptiveResampler.PIXEL_RESAMPLING_PRECISION) { toClient(it, p1.aes) } }
.flatMap { (aes, points) -> points.map { PathPoint(aes, it) } }
PathData(smoothed)
}
}
}

companion object {
private fun insertPathSeparators(rings: Iterable<List<DoubleVector>>): List<DoubleVector?> {
val result = ArrayList<DoubleVector?>()
Expand Down Expand Up @@ -339,7 +359,37 @@ data class PathData(

val aes: DataPointAesthetics by lazy(points.first()::aes) // decoration aes (only for color, fill, size, stroke)
val aesthetics by lazy { points.map(PathPoint::aes) }
val coordinates by lazy { points.map(PathPoint::coord) }
val coordinates by lazy { points.map(PathPoint::coord) } // may contain duplicates, don't work well for polygon
}

data class PolygonData(
val rings: List<List<PathPoint>>
) {
init {
require(rings.isNotEmpty()) { "PolygonData should contain at least one ring" }
require(rings.all { it.size >= 3 }) { "PolygonData ring should contain at least 3 points" }
}

val aes: DataPointAesthetics by lazy( rings.first().first()::aes ) // decoration aes (only for color, fill, size, stroke)
val aesthetics by lazy { rings.map { ring -> ring.map { it.aes } } }
val coordinates by lazy { rings.map { ring -> ring.map { it.coord } } }
val flattenCoordinates by lazy { // guaranteed to have no duplicates on ends caused by resampling
val output = mutableListOf<DoubleVector>()
rings.forEach { ring ->
val firstPoint = ring.first().coord
val lastPoint = ring.last().coord

// trim duplications at start and end to make the ring detection work

output.add(firstPoint)
output.addAll(ring.asSequence().dropWhile { it.coord == firstPoint }.map { it.coord })
while (output.last() == lastPoint) {
output.removeLast()
}
output.add(lastPoint)
}
output
}
}

data class PathPoint(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class RectanglesHelper(
myAesthetics.dataPoints().forEach { p ->
geometryFactory(p)?.let { rect ->
val polyRect = resample(
precision = AdaptiveResampler.PIXEL_RESAMPLING_PRECISION,
precision = AdaptiveResampler.PIXEL_PRECISION,
points = listOf(
DoubleVector(rect.left, rect.top),
DoubleVector(rect.right, rect.top),
Expand Down Expand Up @@ -95,7 +95,7 @@ class RectanglesHelper(
inner class SvgRectHelper {
private var onGeometry: (DataPointAesthetics, DoubleRectangle?, List<DoubleVector>?) -> Unit = { _, _, _ -> }
private var myResamplingEnabled = false
private var myResamplingPrecision = AdaptiveResampler.PIXEL_RESAMPLING_PRECISION
private var myResamplingPrecision = AdaptiveResampler.PIXEL_PRECISION

fun setResamplingEnabled(b: Boolean) {
myResamplingEnabled = b
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,13 @@ class TargetCollectorHelper(
}
}

fun addPolygons(pathDataList: Map<Int, PathData>) {
pathDataList.values.forEach { pathData ->
targetCollector.addPolygon(
pathData.coordinates,
pathData.aes.index(),
TooltipParams(markerColors = colorMarkerMapper(pathData.aes)),
TipLayoutHint.Kind.CURSOR_TOOLTIP
)
}
fun addPolygons(polygonData: PolygonData) {
targetCollector.addPolygon(
polygonData.flattenCoordinates,
polygonData.aes.index(),
TooltipParams(markerColors = colorMarkerMapper(polygonData.aes)),
TipLayoutHint.Kind.CURSOR_TOOLTIP
)
}

private fun addPath(path: PathData, tooltipParams: TooltipParams) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ object PolarAxisUtil {
DoubleVector(gridDomain.xRange().lowerEnd, it),
DoubleVector(gridDomain.xRange().upperEnd, it)
)
}.map { line -> AdaptiveResampler.resample(line, AdaptiveResampler.PIXEL_RESAMPLING_PRECISION) { toClient(it) } }
}.map { line -> AdaptiveResampler.resample(line, AdaptiveResampler.PIXEL_PRECISION, ::toClient) }
}

private fun buildAxis(): List<DoubleVector> {
Expand Down

0 comments on commit dc19f6e

Please sign in to comment.