Hierarchy-aware, interactively editable color palettes for clustered data — built for scRNA-seq clusters/cell subtypes (Seurat, UMAP) but not hard-dependent on Seurat.

LLM was used in this project to speed up the development.
Clusters that belong to the same family / compartment (e.g. subtypes of T cells, subtypes of myeloid cells) can be colored either:
harmonious_style = "sweep" (default) lays the families along one
analogous hue arc with a shared monotone lightness ramp and muted chroma
(e.g. a smooth blue → purple → rose → tan progression is just one example
— a different seed gives a genuinely different arc: green, teal, red,
gold, ..., not a small jitter around one fixed look — and sweep_anchors
lets you specify the exact gradient to sweep through instead of an
automatic one); harmonious_style = "per_family" gives each family its
own hue and colors its members as graded shades of it.
Color choice is color-theory aware even for random seeds: chroma is kept
inside the displayable gamut and away from the neon and muddy yellow-green
zones, hues are spread with a low-discrepancy sequence, and each hue gets
its natural lightness. When neighbor information is supplied, families that
are adjacent in the embedding get contrasting hues by default (or
analogous, by choice — neighbor_hues), and within a family
spatially-adjacent clusters are given far-apart shades for local contrast.
simulate_cvd() and the cvd argument of the plotting functions preview a
palette as it appears with color-vision deficiency:

Every generated color can be overridden by hand, and manual overrides survive re-running the generator (new mode, new seed, edited families) until explicitly reset; while manual overrides are in place the remaining colors auto-fit to the lightness/chroma envelope of your picks.
Core is fully independent of the UI. as_cluster_data(),
detect_families(), generate_palette(), plot_palette_static(),
plot_swatches(), export_palette_json()/export_palette_csv(),
import_palette_json(), downsample_stratified(), color_distance(), and
simulate_cvd() depend only on colorspace, farver, stats, graphics,
methods, utils, and jsonlite — all listed in Imports, so a plain
install.packages("palettome") (or devtools::load_all()) gives you a
fully working package with zero UI packages installed.
The interactive UI is exactly one function, launch_palettome_ui(). It
requires shiny, plotly, colourpicker, and shinyjs, all listed under
Suggests only. If they aren't installed, launch_palettome_ui() fails
with a message telling you which packages to install and reminding you that
every other function still works without them — it never blocks or breaks
the rest of the package.
| Function | Core or UI |
|---|---|
as_cluster_data(), compute_centroids()
|
Core |
detect_families(), compute_family_dendrogram(), cut_families(), plot_dendrogram()
|
Core |
generate_palette(), set_manual_color(), reset_overrides(), optimize_color()
|
Core |
cluster_colors(), family_colors()
|
Core |
color_distance(), simulate_cvd()
|
Core |
downsample_stratified() |
Core |
plot_palette_static(), plot_swatches()
|
Core (ggplot2 used opportunistically if installed; base-R fallback otherwise) |
export_palette_json(), export_palette_csv(), import_palette_json()
|
Core |
launch_palettome_ui() |
UI (Suggests-only: shiny, plotly, colourpicker, shinyjs) |
devtools::load_all() # or library(palettome) once installed
pdata <- as_cluster_data(
my_df,
coord_cols = c("UMAP_1", "UMAP_2"),
cluster_col = "cluster"
)
fam <- detect_families(pdata, k = 4) # auto-detect via hclust
# fam$neighbors carries centroids (or a connectivity matrix) so families and
# clusters can be placed in space — pass it through for neighbor-aware color
session <- generate_palette(
fam$assignment,
mode = "harmonious", harmonious_style = "sweep",
neighbors = fam$neighbors, seed = 1
)
plot_palette_static(pdata, session) # ggplot2 if installed, base R otherwise
plot_swatches(session) # family-grouped swatch legend
export_palette_json(session, "palette.json")
Key generate_palette() arguments:
| argument | meaning |
|---|---|
mode |
"harmonious" or "contrast"
|
harmonious_style |
"sweep" (one analogous multi-hue sweep) or "per_family" (one hue per family, shaded) |
sweep_anchors |
(sweep only) 2+ hex colors to interpolate the sweep through, instead of the automatic gradient |
neighbor_hues |
"contrast" / "coherent", or NULL to follow the mode (contrast→contrast, harmonious→coherent) |
neighbors |
centroids data frame or connectivity matrix; NULL disables neighbor-aware placement |
lightness_range, chroma_range
|
length-2 numeric, or "auto" (default): derived from manual picks if any, else from mode/style presets |
seed, hue_range, cvd
|
random seed; hue window in degrees; "none"/"deutan"/"protan"/"tritan"
|
To resume editing later, or hand off manual overrides:
session <- set_manual_color(session, cluster = "3", color = "#2E5A87")
# several at once: one color each (or a single color for all); ids not in
# the session are skipped with a warning
session <- set_manual_color(session, cluster = c("4", "5"), color = c("#6E6E6E", "#C9A227"))
session2 <- generate_palette(fam$assignment, session = session, mode = "contrast")
# clusters/families with manual_color == TRUE keep their color unchanged;
# with lightness_range/chroma_range = "auto" the rest fit to the manual picks
optimize_color("#7B00FF", session = session2) # snap a hand-picked hex into the palette envelope
Different seeds give genuinely different sweep gradients (not a small jitter around one look), or specify the exact gradient yourself:
generate_palette(fam$assignment, mode = "harmonious", seed = 7) # try a few seeds
generate_palette(fam$assignment, mode = "harmonious",
sweep_anchors = c("#0B3D91", "#FF5733", "#FFD166")) # your own gradient
session$clusters/session$families are already plain data frames (see
the JSON schema below), but for the common case of just needing the colors
themselves -- to hand to Seurat::DimPlot(cols = ...),
ggplot2::scale_color_manual(values = ...), etc. -- use:
cluster_colors(session) # named vector: c(c1 = "#4E5716", c2 = "#899925", ...)
cluster_colors(session, format = "data.frame") # data frame: cluster, color
family_colors(session) # same, one row per family
as_cluster_data() has a Seurat method, so you don't need to hand-extract
embeddings/metadata yourself:
pdata <- as_cluster_data(
seurat_obj,
reduction = "umap", # any reduction name (Seurat::Embeddings())
dims = 1:2, # or 1:3 for a 3D embedding
cluster_col = "seurat_clusters", # any column of seurat_obj[[]]
family_col = NULL # optional: a metadata column with an explicit parent/family label
)
# from here on it's identical to the data-frame path:
fam <- detect_families(pdata, k = 4)
session <- generate_palette(fam$assignment, mode = "harmonious", seed = 1)
# hand the result back to Seurat's own plotting:
Seurat::DimPlot(seurat_obj, reduction = "umap", group.by = "seurat_clusters",
cols = cluster_colors(session))
This method requires the Seurat package (Suggests-only, checked with a
friendly error if missing) but nothing else in the package does -- everything
downstream of as_cluster_data() only ever sees the plain palettome_data
structure either input path produces.
install.packages(c("shiny", "plotly", "colourpicker", "shinyjs"))
session <- launch_palettome_ui(pdata) # auto-detects families
session <- launch_palettome_ui(pdata, session = session) # resume a saved session
cluster_colors(session) # your edited colors
launch_palettome_ui() returns the edited session when the app closes --
either via the Done -- return session to R button or by interrupting R
(Esc / Ctrl+C) -- so assigning its result keeps every change made in the UI.

The app shows a WebGL (plotly scattergl) scatter plot of the embedding
(fixed 1:1 x/y aspect ratio, so distances aren't visually distorted), a
family dendrogram, and a drag-and-drop compartment panel: drag a cluster
chip into a different family bin to regroup it, hit + Add compartment to
make a new (empty, dashed) bin to drag clusters into, and empty compartments
are removed automatically. Mode, harmonious style, neighbor-hue rule, seed,
and a colorblind simulation preview are all live. Hit 🎲 Randomize next
to the seed for a fresh sweep gradient at a click, or type 2+ comma-
separated hex colors into Custom gradient anchors to sweep through your
own colors instead of an automatic gradient (a live swatch strip previews
what you typed). When the auto light/chroma toggle is switched off, the
Lightness- and Chroma-range sliders each get a swatch strip underneath
rendered in the current palette's own hue, dimmed outside the selected
sub-range -- so the numeric range reads as actual colors, not just two
numbers. An R code panel shows the current cluster and family colors as
named-vector R code (cluster_colors <- c("3" = "#2E5A87", ...)) with a
copy button, ready to paste into a script. Sessions export as JSON/CSV, and
the final plot exports as a full-resolution PNG.
Click anywhere on a chip or a family's whole color bar (not just its label) to open the recolor dialog:
optimize_color()).export_palette_json() writes, and import_palette_json() reads, this
structure. It is deliberately plain and language-agnostic — a future
Python/Scanpy implementation can read and write the exact same file without
any R-specific types.
{
"schema_version": "1.0",
"clusters": [
{ "cluster": "0", "family_id": "F1", "color": "#3E7CB1", "manual_color": false, "n_cells": 1523 },
{ "cluster": "1", "family_id": "F1", "color": "#7FB1DA", "manual_color": true, "n_cells": 842 }
],
"families": [
{ "family_id": "F1", "color": "#2C5F8A", "manual_color": false }
],
"params": {
"mode": "harmonious",
"harmonious_style": "sweep",
"sweep_anchors": null,
"neighbor_hues": "coherent",
"seed": 1,
"lightness_range": [34, 82],
"chroma_range": [24, 46],
"hue_range": [0, 360],
"cvd": "none",
"auto_lightness": true,
"auto_chroma": true
}
}
clusters[].color is the per-cluster fill color actually used for plotting.families[].color is the family's representative/accent color: in
harmonious mode it's a point on the family's part of the sweep (or the
family's base hue for per_family); in contrast mode it's the secondary
channel that shows family membership even though cluster colors themselves
are maximally spread apart.manual_color: true marks an entry a person set by hand; generate_palette()
never overwrites these unless reset_overrides() is called first.n_cells is informational (legend sizing, etc.) and optional/nullable.params.lightness_range / chroma_range are always the resolved
numeric ranges; auto_lightness / auto_chroma record whether they were
derived automatically (so a reload can keep auto-fitting).CSV export (export_palette_csv()) writes the clusters table (and
optionally the families table) as plain CSV with the same column names.
as_cluster_data() accommodates either input into one palettome_data object
so every other function only has to know one shape:
as_cluster_data(df, coord_cols = c("UMAP_1","UMAP_2"[,"UMAP_3"]), cluster_col = "cluster", family_col = NULL, cell_id_col = NULL)
as_cluster_data(seurat_obj, reduction = "umap", dims = 1:2, cluster_col = "seurat_clusters", family_col = NULL) (requires the Seurat package, Suggests-only)If family_col/an explicit family is supplied, detect_families() uses it
directly; otherwise it hierarchically clusters cluster centroids (or a
supplied cluster-cluster connectivity/distance matrix) and lets you pick a
cut (k or h), with plot_dendrogram() for a preview. Either way, the
resulting cluster -> family_id assignment is just a plain data frame you
(or the UI's drag-and-drop panel) can freely rewrite afterward — detection
is a starting point, not a final answer.
See inst/benchmark/ for the full write-up. In short:
the browser never receives one point per cell for interactive editing.
downsample_stratified() precomputes a per-cluster-stratified subsample
(default cap 50,000 points) server-side for live dragging/recoloring, and
plotly's WebGL scattergl trace renders that subsample smoothly; a full,
un-subsampled render is only produced on final PNG export via
plot_palette_static(). Cluster centroids, hierarchy, and per-cluster cell
counts are precomputed once (compute_centroids()) so hierarchy edits and
recoloring never touch the raw per-cell table at all.
devtools::test()
Covers: perceptual-distance thresholds and gamut/muddy-zone constraints for
contrast-mode selection; the harmonious sweep being a monotone-lightness
analogous progression; neighbor_hues switching adjacent families between
analogous and contrasting; spatially-adjacent subclusters getting far-apart
shades; lightness_range = "auto" fitting to manual picks; optimize_color()
preserving hue; and manual overrides surviving generate_palette() re-runs
(including across a family reassignment). Plus unit tests for the
color-theory primitives (R/color_theory.R) and adjacency helpers
(R/adjacency.R).