From 1543a556d0a16cc24d4f1b226da76de410638b96 Mon Sep 17 00:00:00 2001 From: Olga Larionova <46743085+OLarionova-HORIS@users.noreply.github.com> Date: Tue, 17 Oct 2023 18:33:42 +0300 Subject: [PATCH] Restrict the number of tooltips for a bar plot (#903) * Add LocatedTargetsPicker test: primary filtering of results. * Add restriction on tooltip count for geom_bar. Add test. * Improve tests. --- .../tooltip/loc/LocatedTargetsPicker.kt | 39 +++-- .../LocatedTargetsPickerFilterTargetsTest.kt | 135 ++++++++++++++++++ 2 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 plot-builder/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/builder/tooltip/loc/LocatedTargetsPickerFilterTargetsTest.kt diff --git a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/tooltip/loc/LocatedTargetsPicker.kt b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/tooltip/loc/LocatedTargetsPicker.kt index 3d231526e71..06fcb38fa1e 100644 --- a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/tooltip/loc/LocatedTargetsPicker.kt +++ b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/tooltip/loc/LocatedTargetsPicker.kt @@ -101,6 +101,9 @@ class LocatedTargetsPicker( internal const val CUTOFF_DISTANCE = 30.0 internal const val FAKE_DISTANCE = 15.0 + private const val BAR_TOOLTIPS_MAX_COUNT = 5 // allowed number of visible tooltips + private val BAR_GEOMS = setOf(BAR, HISTOGRAM) + // Consider layers with the same geom as a single layer to join their tooltips private val STACKABLE_GEOMS = setOf( DENSITY, @@ -127,8 +130,7 @@ class LocatedTargetsPicker( // use XY distance for tooltips with crosshair to avoid giving them priority locatedTargetList.targets .filter { it.tipLayoutHint.coord != null } - .map { target -> MathUtil.distance(coord, target.tipLayoutHint.coord!!) } - .minOrNull() + .minOfOrNull { target -> MathUtil.distance(coord, target.tipLayoutHint.coord!!) } ?: FAKE_DISTANCE } } else { @@ -140,12 +142,32 @@ class LocatedTargetsPicker( return lft.geomKind === rgt.geomKind && STACKABLE_GEOMS.contains(rgt.geomKind) } + private fun LookupResult.withTargets(newTargets: List) = LookupResult( + targets = newTargets, + distance = distance, + geomKind = geomKind, + contextualMapping = contextualMapping, + isCrosshairEnabled = isCrosshairEnabled + ) + private fun filterResults( lookupResult: LookupResult, coord: DoubleVector?, flippedAxis: Boolean ): LookupResult { - if (coord == null || lookupResult.geomKind !in setOf(DENSITY, HISTOGRAM, FREQPOLY, LINE, AREA, SEGMENT, RIBBON)) { + if (coord == null) return lookupResult + + val geomTargets = lookupResult.targets.filter { it.tipLayoutHint.coord != null } + + // for bar - if the number of targets exceeds the restriction value => use the closest one + if (lookupResult.geomKind in BAR_GEOMS && geomTargets.size > BAR_TOOLTIPS_MAX_COUNT) { + val closestTarget = geomTargets.minBy { target -> + MathUtil.distance(coord, target.tipLayoutHint.coord!!) + } + return lookupResult.withTargets(listOf(closestTarget)) + } + + if (lookupResult.geomKind !in setOf(DENSITY, HISTOGRAM, FREQPOLY, LINE, AREA, SEGMENT, RIBBON)) { return lookupResult } @@ -157,8 +179,7 @@ class LocatedTargetsPicker( } } - // Get closest targets and remove duplicates - val geomTargets = lookupResult.targets.filter { it.tipLayoutHint.coord != null } + // Get the closest targets and remove duplicates val minXDistanceToTarget = geomTargets .map(::xDistanceToCoord) @@ -168,13 +189,7 @@ class LocatedTargetsPicker( .filter { target -> xDistanceToCoord(target) == minXDistanceToTarget } .distinctBy(GeomTarget::hitIndex) - return LookupResult( - targets = newTargets, - distance = lookupResult.distance, - geomKind = lookupResult.geomKind, - contextualMapping = lookupResult.contextualMapping, - isCrosshairEnabled = lookupResult.isCrosshairEnabled - ) + return lookupResult.withTargets(newTargets) } } } diff --git a/plot-builder/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/builder/tooltip/loc/LocatedTargetsPickerFilterTargetsTest.kt b/plot-builder/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/builder/tooltip/loc/LocatedTargetsPickerFilterTargetsTest.kt new file mode 100644 index 00000000000..3777bfd6ec9 --- /dev/null +++ b/plot-builder/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/builder/tooltip/loc/LocatedTargetsPickerFilterTargetsTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2023. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package jetbrains.datalore.org.jetbrains.letsPlot.core.plot.builder.tooltip.loc + +import org.jetbrains.letsPlot.commons.geometry.DoubleRectangle +import org.jetbrains.letsPlot.commons.geometry.DoubleVector +import org.jetbrains.letsPlot.core.plot.base.Aes +import org.jetbrains.letsPlot.core.plot.base.DataFrame +import org.jetbrains.letsPlot.core.plot.base.GeomKind +import org.jetbrains.letsPlot.core.plot.base.tooltip.GeomTarget +import org.jetbrains.letsPlot.core.plot.base.tooltip.GeomTargetLocator +import org.jetbrains.letsPlot.core.plot.base.tooltip.GeomTargetLocator.* +import org.jetbrains.letsPlot.core.plot.builder.tooltip.MappedDataAccessMock +import org.jetbrains.letsPlot.core.plot.builder.tooltip.TestUtil +import org.jetbrains.letsPlot.core.plot.builder.tooltip.TestUtil.createLocator +import org.jetbrains.letsPlot.core.plot.builder.tooltip.conf.GeomInteractionBuilder +import org.jetbrains.letsPlot.core.plot.builder.tooltip.loc.LocatedTargetsPicker +import org.jetbrains.letsPlot.core.plot.builder.tooltip.loc.TargetPrototype +import kotlin.test.Test +import kotlin.test.assertEquals + +class LocatedTargetsPickerFilterTargetsTest { + + @Test + fun `line plot - choose targets closest to cursor by x`() { + val pathKey1 = 1 + val pathKey2 = 2 + val pathKey3 = 3 + + val targetPrototypes = listOf( + TestUtil.pathTarget(listOf(DoubleVector(0.0, 0.0), DoubleVector(3.0, 0.0)), indexMapper = { pathKey1 }), + TestUtil.pathTarget(listOf(DoubleVector(1.0, 1.0), DoubleVector(3.0, 1.0)), indexMapper = { pathKey2 }), + TestUtil.pathTarget(listOf(DoubleVector(0.0, 2.0), DoubleVector(2.0, 2.0)), indexMapper = { pathKey3 }), + ) + val locator = createLocator(GeomKind.LINE, targetPrototypes) + + assertTargets( + findTargets(locator, cursor = DoubleVector(-0.5, 0.0)) + // no targets + ) + assertTargets( + findTargets(locator, cursor = DoubleVector(0.5, 0.0)), + pathKey1, pathKey3 + ) + assertTargets( + findTargets(locator, cursor = DoubleVector(1.5, 0.0)), + pathKey2 + ) + assertTargets( + findTargets(locator, cursor = DoubleVector(2.0, 0.0)), + pathKey3 + ) + assertTargets( + findTargets(locator, cursor = DoubleVector(2.5, 0.0)), + pathKey1, pathKey2 + ) + } + + @Test + fun `bar plot - check restriction on visible tooltips`() { + + val targetPrototypes = run { + val startTargetRect = DoubleRectangle(DoubleVector.ZERO, DoubleVector(1.0, 10.0)) + (0..10) + .toList() + .map { startTargetRect.add(DoubleVector(0.0, it.toDouble())) } + .mapIndexed { index, rect -> TestUtil.rectTarget(index, rect) } + } + + // restriction for bar tooltips = 5: + // - if more - choose the closest one + // - else - get all targets + + run { + val locator = createLocator(GeomKind.BAR, targetPrototypes) + assertTargets( + findTargets(locator, cursor = DoubleVector(0.0, 0.0)), + 0 + ) + assertTargets( + findTargets(locator, cursor = DoubleVector(0.0, 6.0)), + 6 + ) + assertTargets( + findTargets(locator, cursor = DoubleVector(0.0, 10.0)), + 10 + ) + } + run { + // targets no more than the restriction value => use all targets + val locator = createLocator(GeomKind.BAR, targetPrototypes.take(5)) + assertTargets( + findTargets(locator, cursor = DoubleVector(0.0, 10.0)), + 0, 1, 2, 3, 4 + ) + } + } + + private fun createLocator(geomKind: GeomKind, targetPrototypes: List): GeomTargetLocator { + val contextualMapping = GeomInteractionBuilder.DemoAndTest(supportedAes = Aes.values()) + .xUnivariateFunction(LookupStrategy.HOVER) + .build() + .createContextualMapping( + MappedDataAccessMock().mappedDataAccess, + DataFrame.Builder().build() + ) + return createLocator( + lookupSpec = LookupSpec(LookupSpace.X, LookupStrategy.HOVER), + contextualMapping = contextualMapping, + targetPrototypes = targetPrototypes, + geomKind = geomKind + ) + } + + private fun findTargets( + locator: GeomTargetLocator, + cursor: DoubleVector + ): List { + return LocatedTargetsPicker(flippedAxis = false, cursor) + .apply { locator.search(cursor)?.let(::addLookupResult) } + .picked + .singleOrNull() + ?.targets + ?: emptyList() + } + + private fun assertTargets(targets: List, vararg expected: Int) { + assertEquals(expected.size, targets.size) + val actual = targets.map(GeomTarget::hitIndex) + assertEquals(expected.toList(), actual) + } +} \ No newline at end of file