When you plot clustered data — scRNA-seq clusters, cell subtypes, any
labelled embedding — the default categorical palette treats every
cluster as unrelated. But clusters usually are related: several
T-cell subtypes belong to one compartment, several myeloid subtypes to
another. palettome assigns colors that encode that
structure, while keeping every individual color overridable by hand.
The workflow is four steps:
as_cluster_data() — normalize a data frame (or a Seurat
object) into a common structure.detect_families() — group clusters into families,
automatically or from a column you supply.generate_palette() — produce the colors.cluster_colors() / plot_*() /
export_*() — use them.Everything below runs with no UI packages installed; the interactive
Shiny app (launch_palettome_ui()) is entirely optional.
The vignette uses a small synthetic embedding so it builds anywhere: three compartments, each with a few subclusters scattered around it.
set.seed(42)
compartments <- list(
T = list(center = c(-5, 4), subclusters = c("T_naive", "T_mem", "T_reg", "T_cyto")),
Mono = list(center = c( 6, 2), subclusters = c("Mono_cl", "Mono_nc", "Mac")),
B = list(center = c( 0, -6), subclusters = c("B_naive", "B_mem", "Plasma"))
)
cells <- do.call(rbind, lapply(names(compartments), function(comp) {
info <- compartments[[comp]]
do.call(rbind, lapply(info$subclusters, function(cl) {
offset <- stats::rnorm(2, sd = 1.6)
n <- sample(150:400, 1)
data.frame(
UMAP_1 = stats::rnorm(n, info$center[1] + offset[1], 0.7),
UMAP_2 = stats::rnorm(n, info$center[2] + offset[2], 0.7),
cluster = cl,
compartment = comp,
stringsAsFactors = FALSE
)
}))
}))
str(cells)
#> 'data.frame': 2968 obs. of 4 variables:
#> $ UMAP_1 : num -2.77 -3.58 -2.43 -2.4 -3.27 ...
#> $ UMAP_2 : num 2.91 3.38 2.74 3.41 3.81 ...
#> $ cluster : chr "T_naive" "T_naive" "T_naive" "T_naive" ...
#> $ compartment: chr "T" "T" "T" "T" ...palettome_data objectas_cluster_data() is the single entry point. Give it the
coordinate columns and the cluster column:
pdata <- as_cluster_data(
cells,
coord_cols = c("UMAP_1", "UMAP_2"),
cluster_col = "cluster"
)
pdata$coord_cols
#> [1] "UMAP_1" "UMAP_2"
pdata$has_family
#> [1] FALSEcompute_centroids() gives the per-cluster summary that
the rest of the package works from — one row per cluster rather than one
per cell, which is what keeps recoloring cheap on large datasets:
compute_centroids(pdata)
#> cluster UMAP_1_centroid UMAP_2_centroid n_cells
#> 1 B_mem 0.3671124 -7.7595480 361
#> 2 B_naive -2.0660316 -6.8384714 344
#> 3 Mac 7.9705575 1.4004343 297
#> 4 Mono_cl 6.1834749 0.9824060 399
#> 5 Mono_nc 6.3139941 -0.2668122 304
#> 6 Plasma -1.6893401 -6.5228760 292
#> 7 T_cyto -8.8665748 3.6599710 324
#> 8 T_mem -7.3990703 4.7970331 264
#> 9 T_naive -2.8410995 3.0575040 223
#> 10 T_reg -10.3788372 5.3223582 160as_cluster_data() has a Seurat method, so
you do not have to pull out embeddings and metadata yourself. Step 1 is
the only step that differs — everything downstream sees the same
palettome_data structure either way.
The chunk below is not run when the vignette builds (it needs a real Seurat object), but it is the whole substitution:
# --- replaces the as_cluster_data() call above ---------------------------
# 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: metadata column with an
# # explicit compartment/family label
# )
#
# --- from here on, identical to the data-frame path ----------------------
# fam <- detect_families(pdata, k = 3)
# session <- generate_palette(fam$assignment, mode = "harmonious",
# neighbors = fam$neighbors, seed = 1)
#
# --- and hand the colors straight back to Seurat's own plotting ----------
# Seurat::DimPlot(seurat_obj, reduction = "umap",
# group.by = "seurat_clusters",
# cols = cluster_colors(session))With no family column present, families are found by hierarchical
clustering of the cluster centroids. k sets how many
families to cut the tree into:
fam <- detect_families(pdata, k = 3)
fam$method
#> [1] "hclust"
fam$assignment
#> cluster family_id
#> 1 B_mem F1
#> 2 B_naive F1
#> 3 Mac F2
#> 4 Mono_cl F2
#> 5 Mono_nc F2
#> 6 Plasma F1
#> 7 T_cyto F3
#> 8 T_mem F3
#> 9 T_naive F3
#> 10 T_reg F3The dendrogram is kept on the result, so you can preview the tree and
re-cut it at a different k without recomputing
distances:
cut_families(fam$dendro, k = 5)
#> cluster family_id
#> 1 B_mem F1
#> 2 B_naive F1
#> 3 Mac F2
#> 4 Mono_cl F2
#> 5 Mono_nc F2
#> 6 Plasma F1
#> 7 T_cyto F3
#> 8 T_mem F3
#> 9 T_naive F4
#> 10 T_reg F5If you already know the compartments, pass the column instead and no
clustering happens at all — method becomes
"explicit":
pdata_explicit <- as_cluster_data(
cells,
coord_cols = c("UMAP_1", "UMAP_2"),
cluster_col = "cluster",
family_col = "compartment"
)
fam_explicit <- detect_families(pdata_explicit)
fam_explicit$method
#> [1] "explicit"
head(fam_explicit$assignment)
#> cluster family_id
#> 1 B_mem B
#> 2 B_naive B
#> 3 Mac Mono
#> 4 Mono_cl Mono
#> 5 Mono_nc Mono
#> 6 Plasma BEither way, fam$assignment is a plain
cluster/family_id data frame you are free to
edit by hand before generating colors.
The default. Families are laid along a single analogous hue arc with a shared lightness ramp and muted chroma, so the whole palette reads as one coordinated sweep rather than a bag of unrelated hues.
Passing fam$neighbors (the centroids, or a connectivity
matrix if you supplied one) is what makes the placement spatially aware:
families adjacent in the embedding are separated, and within a family,
adjacent clusters get far-apart shades.
session <- generate_palette(
fam$assignment,
mode = "harmonious",
neighbors = fam$neighbors,
seed = 1
)
cluster_colors(session)
#> B_mem B_naive Mac Mono_cl Mono_nc Plasma T_cyto T_mem
#> "#EAB6DB" "#BFA0D1" "#A697C8" "#898FBD" "#6A87AF" "#D6AAD7" "#0E605D" "#497F9D"
#> T_naive T_reg
#> "#126C72" "#20778A"Every cluster instead gets a maximally distinguishable color, chosen
by greedy max-min selection in Lab space. Family membership is still
available, carried by the family’s own accent color in
session$families:
session_contrast <- generate_palette(
fam$assignment,
mode = "contrast",
neighbors = fam$neighbors,
seed = 1
)
family_colors(session_contrast)
#> F1 F2 F3
#> "#909F44" "#D37CAC" "#23A5BE"Different seeds give genuinely different sweeps — a different hue arc altogether, not a jitter around one look:
for (s in c(3, 11)) {
plot_swatches(generate_palette(fam$assignment, mode = "harmonious",
neighbors = fam$neighbors, seed = s))
}If you want a specific gradient — brand colors, or a reference ramp —
give the anchors explicitly with sweep_anchors and the
sweep interpolates through them in HCL space:
session_anchored <- generate_palette(
fam$assignment,
mode = "harmonious",
sweep_anchors = c("#0B3D91", "#FF5733", "#FFD166"),
neighbors = fam$neighbors
)
plot_swatches(session_anchored)harmonious_style = "per_family" switches to the older
look: one hue per family, members as graded shades of it.
plot_swatches(generate_palette(fam$assignment, mode = "harmonious",
harmonious_style = "per_family",
neighbors = fam$neighbors, seed = 1))Any cluster or family color can be replaced by hand. The override is
marked with manual_color = TRUE and survives regeneration —
a new mode, a new seed, or an edited family assignment — until you reset
it explicitly.
session <- set_manual_color(session, cluster = "Plasma", color = "#2E5A87")
session$clusters[session$clusters$manual_color, c("cluster", "color", "manual_color")]
#> cluster color manual_color
#> 6 Plasma #2E5A87 TRUESeveral clusters can be set in one call, with one color each or a single color for all of them. Ids that are not in the session are skipped with a warning rather than an error, so a color map written for a related dataset can be reused as is:
multi <- set_manual_color(session, cluster = c("B_naive", "B_mem", "Not_here"),
color = c("#6E6E6E", "#9A9A9A", "#000000"))
#> Warning: Ignoring cluster id(s) not present in the session: Not_here
cluster_colors(multi)[c("B_naive", "B_mem")]
#> B_naive B_mem
#> "#6E6E6E" "#9A9A9A"Regenerating session now keeps the Plasma
pick fixed and, because lightness_range and
chroma_range are "auto", refits the remaining
colors into the lightness/chroma envelope your pick implies:
session2 <- generate_palette(
fam$assignment,
session = session,
mode = "contrast",
neighbors = fam$neighbors
)
cluster_colors(session2)[["Plasma"]]
#> [1] "#2E5A87"Families can be overridden the same way.
set_manual_color() itself only records the pick;
regenerating then reshades that family’s non-manual member clusters
around the new hue:
session2 <- set_manual_color(session2, family = "F1", color = "#7A4E9E")
session2 <- generate_palette(fam$assignment, session = session2,
mode = "harmonious", neighbors = fam$neighbors)
family_colors(session2)
#> F1 F2 F3
#> "#7A4E9E" "#515B9C" "#0A5156"
cluster_colors(session2, format = "data.frame")
#> cluster color family_id family_color
#> 1 B_mem #8C68AD F1 #7A4E9E
#> 2 B_naive #815BA3 F1 #7A4E9E
#> 3 Mac #7158A0 F2 #515B9C
#> 4 Mono_cl #515B9C F2 #515B9C
#> 5 Mono_nc #1A5D93 F2 #515B9C
#> 6 Plasma #2E5A87 F1 #7A4E9E
#> 7 T_cyto #084B49 F3 #0A5156
#> 8 T_mem #0F5A78 F3 #0A5156
#> 9 T_naive #0A5156 F3 #0A5156
#> 10 T_reg #0C5665 F3 #0A5156optimize_color() snaps an arbitrary hand-picked hex to
the nearest in-gamut, aesthetically tuned lightness/chroma for its hue —
useful when a raw pick is too neon or too muddy for the surrounding
palette:
reset_overrides() clears them, either all at once or by
name:
color_distance() returns the pairwise perceptual
distance matrix (CIE2000 by default) — a quick way to find the two
clusters most likely to be confused:
d <- color_distance(cluster_colors(session))
round(d[1:4, 1:4], 1)
#> #EAB6DB #BFA0D1 #A697C8 #898FBD
#> #EAB6DB 0.0 9.8 15.2 22.2
#> #BFA0D1 9.8 0.0 5.7 13.1
#> #A697C8 15.2 5.7 0.0 7.4
#> #898FBD 22.2 13.1 7.4 0.0simulate_cvd() maps colors through a
color-vision-deficiency simulation, and both plotting functions take a
cvd argument so you can preview the palette as a viewer
with that deficiency would see it:
simulate_cvd(cluster_colors(session)[1:4], type = "deutan")
#> B_mem B_naive Mac Mono_cl
#> "#C3C8D9" "#9FACCF" "#8F9EC7" "#7F90BC"
plot_swatches(session, cvd = "deutan")cluster_colors() returns exactly what other plotting
code wants — a named vector keyed by cluster id:
Seurat::DimPlot(seurat_obj, cols = cluster_colors(session))
ggplot2::ggplot(cells, ggplot2::aes(UMAP_1, UMAP_2, color = cluster)) +
ggplot2::geom_point() +
ggplot2::scale_color_manual(values = cluster_colors(session))Or, if you prefer a copy-paste-ready named vector:
The "data.frame" format also merges in each cluster’s
family id and the family’s own color, so faceting or legend code needs
no extra join:
Sessions round-trip through JSON, which is the stable contract documented in the README — so a palette edited today can be reloaded (or read by a non-R frontend) later:
path <- tempfile(fileext = ".json")
export_palette_json(session, path)
session_back <- import_palette_json(path)
identical(cluster_colors(session_back), cluster_colors(session))
#> [1] TRUEexport_palette_csv() writes the same tables as flat CSV
when that is easier to hand off.
Interactive editing does not need every cell drawn.
downsample_stratified() takes a cluster-stratified
subsample — with a floor per cluster so rare clusters stay visible — for
previews; keep the full object for the final export.
preview <- downsample_stratified(pdata, max_n = 500, min_per_cluster = 20)
nrow(preview$cells)
#> [1] 500
table(preview$cells$cluster)
#>
#> B_mem B_naive Mac Mono_cl Mono_nc Plasma T_cyto T_mem T_naive T_reg
#> 61 58 50 67 51 49 55 44 38 27Note that the palette itself does not change: colors come from the cluster and family structure, not from the number of cells plotted.
Everything above is core, and depends only on
colorspace, farver, jsonlite, and
base R. The Shiny app — drag-to-regroup families, HSL sliders, live HCL
readout, per-cluster color picking — is one optional function:
When the app closes – via its Done button, or by
interrupting R with Esc / Ctrl+C – launch_palettome_ui()
returns the edited session, so the assignment above keeps every change
made in the UI. The app also shows the current cluster and family colors
as named-vector R code with a copy button, for pasting straight into a
script.
It requires shiny, plotly,
colourpicker, and shinyjs, all
Suggests-only. If they are missing, it stops with a message
naming them and nothing else in the package is affected.