Text Halo

A text halo draws an outline around text, improving readability when labels are placed on top of colorful, textured, or busy plot areas.

Use haloWidth to set the halo width and haloColor to set the halo color. A halo is rendered only when haloWidth > 0; setting haloColor on its own has no visible effect. When haloColor is omitted, the panel background color is used (falling back to the plot background color when the panel draws no rectangle).

In [1]:
%useLatestDescriptors
%use dataframe
%use lets-plot
kotlin-logging: initializing... active logger factory: Slf4jLoggerFactory
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)

Basic usage: text visible on any background

In [3]:
val bg = mapOf(
    "xmin" to listOf(0.0, 0.7, 2.3),
    "xmax" to listOf(0.7, 2.3, 3.0),
    "ymin" to listOf(0.0, 0.0, 0.0),
    "ymax" to listOf(1.0, 1.0, 1.0),
    "fill" to listOf("white", "black", "white")
)

var p = letsPlot() +
    geomRect(data = bg, size = 0) { xmin = "xmin"; xmax = "xmax"; ymin = "ymin"; ymax = "ymax"; fill = "fill" } +
    scaleFillIdentity() +
    xlim(0 to 3) + ylim(0 to 1) + ggsize(560, 240) + themeVoid()

p += geomText(
    x = 1.5, y = 0.7, label = "black text with white halo",
    color = "black",
    haloColor = "white",         // <-- adding halo
    haloWidth = 2.2,             // <-
    size = 22,
    fontface = "bold"
)

p += geomText(
    x = 1.5, y = 0.3, label = "white text with black halo",
    color = "white",
    haloColor = "black",         // <-- adding halo
    haloWidth = 2.2,             // <-
    size = 22,
    fontface = "bold"
)
p
Out[3]:
black text with white halo black text with white halo white text with black halo white text with black halo
In [4]:
val mpg = DataFrame.readCsv("https://raw.githubusercontent.com/JetBrains/lets-plot-docs/master/data/mpg.csv")

// numeric columns, as in pandas' select_dtypes(include=number)
val numCols = listOf("displ", "year", "cyl", "cty", "hwy")
val mpgNum = numCols.associateWith { mpg[it].toList() }
mpg.head(3)
Out[4]:

DataFrame: rowsCount = 3, columnsCount = 12

untitledmanufacturermodeldisplyearcyltransdrvctyhwyflclass
1audia41.80000019994auto(l5)f1829pcompact
2audia41.80000019994manual(m5)f2129pcompact
3audia42.00000020084manual(m6)f2031pcompact

Text labels with and without halo

The halo helps preserve the label color while separating the text from nearby points.

In [5]:
// Keep one row per manufacturer: the one with the highest 'hwy' (pandas: sort_values + groupby.head(1))
val manufacturer = mpg["manufacturer"].toList().map { it as String }
val hwy = mpg["hwy"].toList().map { (it as Number).toInt() }
val displ = mpg["displ"].toList().map { (it as Number).toDouble() }

val bestIdxByMan = LinkedHashMap<String, Int>()
for (i in manufacturer.indices) {
    val m = manufacturer[i]
    val cur = bestIdxByMan[m]
    if (cur == null || hwy[i] > hwy[cur]) bestIdxByMan[m] = i
}
val sample = mapOf(
    "manufacturer" to bestIdxByMan.values.map { manufacturer[it] },
    "displ" to bestIdxByMan.values.map { displ[it] },
    "hwy" to bestIdxByMan.values.map { hwy[it] }
)

val scatterBase = letsPlot(mpg.toMap()) { x = "displ"; y = "hwy" } +
    geomPoint(size = 4, alpha = .75) { color = "class" } +
    ggsize(520, 360)

val basePlot = scatterBase + themeMinimal()
In [6]:
val noHalo = basePlot +
    geomText(data = sample, size = 7, checkOverlap = true) { label = "manufacturer" } +
    ggtitle("Labels without halo")

val withHalo = basePlot +
    geomText(data = sample, size = 7, checkOverlap = true, haloWidth = 1.5) { label = "manufacturer" } +   // adding a halo
    ggtitle("Labels with halo")

gggrid(listOf(noHalo, withHalo), ncol = 1)
Out[6]:
audi dodge ford honda jeep land rover lincoln subaru volkswagen 2 3 4 5 6 7 15 20 25 30 35 40 45 Labels without halo hwy displ class compact midsize suv 2seater minivan pickup subcompact audi audi dodge dodge ford ford honda honda jeep jeep land rover land rover lincoln lincoln subaru subaru volkswagen volkswagen 2 3 4 5 6 7 15 20 25 30 35 40 45 Labels with halo hwy displ class compact midsize suv 2seater minivan pickup subcompact

Because haloColor defaults to the theme's background color, the same labeled plot stays readable when the flavor changes: the pen text and its halo both follow the theme, with no manual color tuning.

In [7]:
gggrid(listOf(withHalo + flavorHighContrastDark() + ggtitle("Dark flavor")))
Out[7]:
audi audi dodge dodge ford ford honda honda jeep jeep land rover land rover lincoln lincoln pontiac pontiac subaru subaru volkswagen volkswagen 2 3 4 5 6 7 15 20 25 30 35 40 45 Dark flavor hwy displ class compact midsize suv 2seater minivan pickup subcompact

Inverting Theme Colors

You can invert the color pairing by using color = "paper" for the text and haloColor = "pen" for the outline.

In [8]:
val invertedLabels = basePlot +
    geomText(
        data = sample, size = 7, checkOverlap = true,
        color = "paper",
        haloWidth = 1.7,
        haloColor = "pen"
    ) { label = "manufacturer" } +
    ggtitle("Text in paper, halo in pen")

gggrid(listOf(invertedLabels + themeGrey(), invertedLabels + flavorHighContrastDark()), ncol = 1)
Out[8]:
audi audi dodge dodge ford ford honda honda jeep jeep land rover land rover lincoln lincoln subaru subaru volkswagen volkswagen 2 3 4 5 6 7 20 30 40 Text in paper, halo in pen hwy displ class compact midsize suv 2seater minivan pickup subcompact audi audi dodge dodge ford ford honda honda jeep jeep land rover land rover lincoln lincoln subaru subaru volkswagen volkswagen 2 3 4 5 6 7 15 20 25 30 35 40 45 Text in paper, halo in pen hwy displ class compact midsize suv 2seater minivan pickup subcompact

Correlation Plot Labels

In a correlation plot the labels sit on top of the colored tiles, not on the plot background, so the default halo does not blend into the surface behind the text the way it does over an empty panel. Instead, it acts as a contrasting outline around each label.

Pairing color = "pen" with the default halo is convenient here: the text takes the theme's foreground color while the halo takes the theme's paper color, so the two always contrast and the labels stay readable in both light and dark flavors. The first two examples demonstrate this.

In [9]:
val corrPlots = gggrid(listOf(
    CorrPlot(mpgNum)
        .tiles()
        .labels(color = "pen")
        .build() + ggtitle("No halo"),

    CorrPlot(mpgNum)
        .tiles()
        .labels(color = "pen", haloWidth = 1.3)
        .build() + ggtitle("With halo")
))
corrPlots
Out[9]:
1.00 0.15 0.93 -0.80 -0.77 0.15 1.00 0.12 -0.04 0.00 0.93 0.12 1.00 -0.81 -0.76 -0.80 -0.04 -0.81 1.00 0.96 -0.77 0.00 -0.76 0.96 1.00 displ year cyl cty hwy hwy cty cyl year displ No halo -1 -0.5 0 0.5 1 1.00 1.00 0.15 0.15 0.93 0.93 -0.80 -0.80 -0.77 -0.77 0.15 0.15 1.00 1.00 0.12 0.12 -0.04 -0.04 0.00 0.00 0.93 0.93 0.12 0.12 1.00 1.00 -0.81 -0.81 -0.76 -0.76 -0.80 -0.80 -0.04 -0.04 -0.81 -0.81 1.00 1.00 0.96 0.96 -0.77 -0.77 0.00 0.00 -0.76 -0.76 0.96 0.96 1.00 1.00 displ year cyl cty hwy hwy cty cyl year displ With halo -1 -0.5 0 0.5 1
In [10]:
corrPlots + flavorDarcula()
Out[10]:
1.00 0.15 0.93 -0.80 -0.77 0.15 1.00 0.12 -0.04 0.00 0.93 0.12 1.00 -0.81 -0.76 -0.80 -0.04 -0.81 1.00 0.96 -0.77 0.00 -0.76 0.96 1.00 displ year cyl cty hwy hwy cty cyl year displ No halo -1 -0.5 0 0.5 1 1.00 1.00 0.15 0.15 0.93 0.93 -0.80 -0.80 -0.77 -0.77 0.15 0.15 1.00 1.00 0.12 0.12 -0.04 -0.04 0.00 0.00 0.93 0.93 0.12 0.12 1.00 1.00 -0.81 -0.81 -0.76 -0.76 -0.80 -0.80 -0.04 -0.04 -0.81 -0.81 1.00 1.00 0.96 0.96 -0.77 -0.77 0.00 0.00 -0.76 -0.76 0.96 0.96 1.00 1.00 displ year cyl cty hwy hwy cty cyl year displ With halo -1 -0.5 0 0.5 1

If you choose a fixed text color instead, you may also need to set haloColor explicitly so the halo contrasts with both the labels and the tiles.

In [11]:
gggrid(listOf(
    CorrPlot(mpgNum)
        .tiles()
        .labels(color = "white")
        .build() + ggtitle("White text"),

    CorrPlot(mpgNum)
        .tiles()
        .labels(color = "white", haloWidth = 1.6)             // haloColor is missing
        .build() + ggtitle("Default haloColor"),

    CorrPlot(mpgNum)
        .tiles()
        .labels(color = "white", haloWidth = 1.6, haloColor = "gray40")
        .build() + ggtitle("Adjusted haloColor")
))
Out[11]:
1.00 0.15 0.93 -0.80 -0.77 0.15 1.00 0.12 -0.04 0.00 0.93 0.12 1.00 -0.81 -0.76 -0.80 -0.04 -0.81 1.00 0.96 -0.77 0.00 -0.76 0.96 1.00 displ year cyl cty hwy hwy cty cyl year displ White text -1 -0.5 0 0.5 1 1.00 1.00 0.15 0.15 0.93 0.93 -0.80 -0.80 -0.77 -0.77 0.15 0.15 1.00 1.00 0.12 0.12 -0.04 -0.04 0.00 0.00 0.93 0.93 0.12 0.12 1.00 1.00 -0.81 -0.81 -0.76 -0.76 -0.80 -0.80 -0.04 -0.04 -0.81 -0.81 1.00 1.00 0.96 0.96 -0.77 -0.77 0.00 0.00 -0.76 -0.76 0.96 0.96 1.00 1.00 displ year cyl cty hwy hwy cty cyl year displ Default haloColor -1 -0.5 0 0.5 1 1.00 1.00 0.15 0.15 0.93 0.93 -0.80 -0.80 -0.77 -0.77 0.15 0.15 1.00 1.00 0.12 0.12 -0.04 -0.04 0.00 0.00 0.93 0.93 0.12 0.12 1.00 1.00 -0.81 -0.81 -0.76 -0.76 -0.80 -0.80 -0.04 -0.04 -0.81 -0.81 1.00 1.00 0.96 0.96 -0.77 -0.77 0.00 0.00 -0.76 -0.76 0.96 0.96 1.00 1.00 displ year cyl cty hwy hwy cty cyl year displ Adjusted haloColor -1 -0.5 0 0.5 1

Repelled Labels on a Map

For geographic labels, a light halo separates place names from map outlines and neighboring markers while keeping the map styling simple.

In [12]:
@file:Repository("https://repo.osgeo.org/repository/release/")
@file:DependsOn("org.geotools:gt-shapefile:33.2")
@file:DependsOn("org.geotools:gt-epsg-hsql:33.2")
In [13]:
%use lets-plot-gt
In [14]:
import org.geotools.data.shapefile.ShapefileDataStoreFactory
import java.net.URL

val factory = ShapefileDataStoreFactory()
val baseUrl = "https://raw.githubusercontent.com/JetBrains/lets-plot-kotlin/master/docs/examples/shp/"

fun loadSds(name: String) =
    factory.createDataStore(URL("$baseUrl$name/$name.shp")).featureSource.features.toSpatialDataset()

val worldSds = loadSds("naturalearth_lowres")
val citiesSds = loadSds("naturalearth_cities")
In [15]:
letsPlot() +
    geomMap(map = worldSds, fill = "light_green/0.3") +
    geomPoint(map = citiesSds, color = "dark_slate_gray", size = 3) +
    geomTextRepel(
        map = citiesSds,
        color = "dark_slate_gray",
        seed = 42,
        maxIter = 200,
        haloWidth = 2.0,
        haloColor = "white"
    ) { label = "name" } +
    coordMap(xlim = -10.5 to 44.0, ylim = 37.0 to 60.5) +
    theme(axis = "blank", panelGrid = "blank") +
    ggsize(800, 600)
Out[15]:
Vatican City Vatican City Rome Rome San Marino San Marino Vaduz Vaduz Luxembourg Luxembourg Ankara Ankara Budapest Budapest Paris Paris Monaco Monaco Bucharest Bucharest Vilnius Vilnius Andorra Andorra Riga Riga Lisbon Lisbon Oslo Oslo Warsaw Warsaw Ljubljana Ljubljana Bratislava Bratislava Dublin Dublin Podgorica Podgorica Bern Bern Pristina Pristina Prague Prague Zagreb Zagreb Tallinn Tallinn Skopje Skopje Helsinki Helsinki København København Brussels Brussels Kiev Kiev Belgrade Belgrade Madrid Madrid Stockholm Stockholm Tirana Tirana Amsterdam Amsterdam Berlin Berlin Sofia Sofia Athens Athens Minsk Minsk Sarajevo Sarajevo Vienna Vienna London London Moscow Moscow Chisinau Chisinau