Merged Tooltips

When several data points are hit at the same cursor location, Lets-Plot shows a general tooltip for each target. Two theme() parameters control how these are displayed:

  • tooltipMerge — if true, the per-target tooltips are combined into a single merged tooltip.
  • tooltipMaxCount — a guard against clutter: if the number of targets at a location exceeds it (default 10), only the closest target is shown. Set to 0 to disable the limit.

These effects are interactive: hover the cursor over the lines near a shared x to see the tooltips.

In [1]:
%useLatestDescriptors
%use lets-plot
In [2]:
LetsPlot.getInfo()
Out[2]:
Lets-Plot Kotlin API v.4.15.0. Frontend: Notebook with dynamically loaded JS. Lets-Plot JS v.4.11.0.
Outputs: Web (HTML+JS), Kotlin Notebook (Swing), Static SVG (hidden)
In [3]:
import java.util.Random

val nSeries = 12
val months = (1..12).toList()

val rnd = Random(42)
val monthCol = mutableListOf<Int>()
val valueCol = mutableListOf<Double>()
val seriesCol = mutableListOf<String>()
for (s in 0 until nSeries) {
    val base = 10 + rnd.nextDouble() * 30          // uniform(10, 40)
    val trend = -1.5 + rnd.nextDouble() * 3.0      // uniform(-1.5, 1.5)
    for (m in months) {
        val value = base + trend * m + rnd.nextGaussian() * 1.5
        monthCol.add(m)
        valueCol.add(Math.round(value * 10.0) / 10.0)
        seriesCol.add("series " + (s + 1).toString().padStart(2, '0'))
    }
}
val df = mapOf("month" to monthCol, "value" to valueCol, "series" to seriesCol)
In [4]:
val basePlot = letsPlot(df) { x = "month"; y = "value"; color = "series" } +
    geomLine(size = 1, tooltips = layerTooltips().line("@series: @value")) +
    ggsize(700, 400)

Default Behavior

There are 12 series, so hovering near an x hits more targets than the default tooltipMaxCount (10). When the limit is exceeded, Lets-Plot keeps the clutter down by showing only the closest target.

In [5]:
basePlot
Out[5]:
2 4 6 8 10 12 10 15 20 25 30 35 40 45 value month series series 01 series 02 series 03 series 04 series 05 series 06 series 07 series 08 series 09 series 10 series 11 series 12

Merging: tooltipMerge = true

tooltipMerge = true combines the per-target tooltips into a single box — one row per series, each with its color marker — a much cleaner way to show them all at once.

All 12 series appear even though that exceeds the default tooltipMaxCount of 10: merging takes priority over the limit, so tooltipMaxCount is ignored and every target is collected into the merged tooltip.

In [6]:
basePlot + theme(tooltipMerge = true)
Out[6]:
2 4 6 8 10 12 10 15 20 25 30 35 40 45 value month series series 01 series 02 series 03 series 04 series 05 series 06 series 07 series 08 series 09 series 10 series 11 series 12

Stacked Bars

Merging is just as useful for stacked bars: hovering over a bar hits every segment in the stack, so a merged tooltip summarizes the whole stack in a single box.

In [7]:
val rndBars = Random(1)
val quarters = listOf("Q1", "Q2", "Q3", "Q4")
val products = (1..5).map { "product $it" }
val qCol = mutableListOf<String>()
val pCol = mutableListOf<String>()
val salesCol = mutableListOf<Int>()
for (q in quarters) {
    for (p in products) {
        qCol.add(q)
        pCol.add(p)
        salesCol.add(5 + rndBars.nextInt(25))   // randint(5, 30)
    }
}
val barsDf = mapOf("quarter" to qCol, "product" to pCol, "sales" to salesCol)

letsPlot(barsDf) { x = "quarter"; y = "sales"; fill = "product" } +
    geomBar(stat = Stat.identity, tooltips = layerTooltips().line("@product: @sales")) +
    ggsize(700, 400) +
    theme(tooltipMerge = true)
Out[7]:
Q1 Q2 Q3 Q4 0 20 40 60 80 100 sales quarter product product 1 product 2 product 3 product 4 product 5

Side Tooltips: geomErrorBar with disableSplitting()

By default, geoms like geomErrorBar split their tooltip: the general tooltip is broken into a general tooltip plus a set of side tooltips (here, the interval bounds) placed next to the data. layerTooltips().disableSplitting() turns splitting off, rebuilding a single general tooltip — which merging can then combine across the group at a category into one box.

In [8]:
val rndErr = Random(2)
val cats = listOf("A", "B", "C", "D")
val groups = listOf("group 1", "group 2", "group 3")
val cCol = mutableListOf<String>()
val gCol = mutableListOf<String>()
val meanCol = mutableListOf<Double>()
val yminCol = mutableListOf<Double>()
val ymaxCol = mutableListOf<Double>()
for (c in cats) {
    for (g in groups) {
        val m = 8 + rndErr.nextDouble() * 10       // uniform(8, 18)
        val e = 1.5 + rndErr.nextDouble() * 2.0    // uniform(1.5, 3.5)
        cCol.add(c)
        gCol.add(g)
        meanCol.add(Math.round(m * 10.0) / 10.0)
        yminCol.add(Math.round((m - e) * 10.0) / 10.0)
        ymaxCol.add(Math.round((m + e) * 10.0) / 10.0)
    }
}
val errDf = mapOf("category" to cCol, "group" to gCol, "mean" to meanCol, "ymin" to yminCol, "ymax" to ymaxCol)

letsPlot(errDf) { x = "category"; y = "mean"; color = "group" } +
    geomErrorBar(size = 1, tooltips = layerTooltips().title("@group").disableSplitting()) { ymin = "ymin"; ymax = "ymax" } +
    ggsize(700, 400) +
    theme(tooltipMerge = true)
Out[8]:
A B C D 6 8 10 12 14 16 18 20 y category group group 1 group 2 group 3