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).
%useLatestDescriptors
%use dataframe
%use lets-plot
LetsPlot.getInfo()
Basic usage: text visible on any background¶
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
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)
Text labels with and without halo¶
The halo helps preserve the label color while separating the text from nearby points.
// 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()
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)
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.
gggrid(listOf(withHalo + flavorHighContrastDark() + ggtitle("Dark flavor")))
Inverting Theme Colors¶
You can invert the color pairing by using color = "paper" for the text and haloColor = "pen" for the outline.
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)
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.
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
corrPlots + flavorDarcula()
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.
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")
))
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.
@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")
%use lets-plot-gt
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")
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)