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

Fix #1037 - fix polygon-based geoms in polar coordinate system #1069

Merged
merged 1 commit into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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