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

Restrict the number of tooltips for a bar plot #903

Merged
merged 4 commits into from
Oct 17, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -140,12 +142,32 @@ class LocatedTargetsPicker(
return lft.geomKind === rgt.geomKind && STACKABLE_GEOMS.contains(rgt.geomKind)
}

private fun LookupResult.withTargets(newTargets: List<GeomTarget>) = 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
}

Expand All @@ -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)
Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<TargetPrototype>): 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<GeomTarget> {
return LocatedTargetsPicker(flippedAxis = false, cursor)
.apply { locator.search(cursor)?.let(::addLookupResult) }
.picked
.singleOrNull()
?.targets
?: emptyList()
}

private fun assertTargets(targets: List<GeomTarget>, vararg expected: Int) {
assertEquals(expected.size, targets.size)
val actual = targets.map(GeomTarget::hitIndex)
assertEquals(expected.toList(), actual)
}
}