Getting started with palettome

library(palettome)

What this package is for

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:

  1. as_cluster_data() — normalize a data frame (or a Seurat object) into a common structure.
  2. detect_families() — group clusters into families, automatically or from a column you supply.
  3. generate_palette() — produce the colors.
  4. cluster_colors() / plot_*() / export_*() — use them.

Everything below runs with no UI packages installed; the interactive Shiny app (launch_palettome_ui()) is entirely optional.

Example data

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" ...

Step 1: build a palettome_data object

as_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] FALSE

compute_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     160

The same step with a Seurat object

as_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))

Step 2: detect families

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        F3

The dendrogram is kept on the result, so you can preview the tree and re-cut it at a different k without recomputing distances:

plot_dendrogram(fam$dendro, k = 3)

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        F5

If 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         B

Either way, fam$assignment is a plain cluster/family_id data frame you are free to edit by hand before generating colors.

Step 3: generate a palette

Harmonious mode

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"
plot_palette_static(pdata, session)

plot_swatches(session)

Contrast mode

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"
plot_swatches(session_contrast)

Steering the look

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))

Step 4: manual overrides

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         TRUE

Several 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      #0A5156

optimize_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:

optimize_color("#7B00FF", session = session2)
#> [1] "#6B40C1"

reset_overrides() clears them, either all at once or by name:

session3 <- reset_overrides(session2, cluster = "Plasma", family = FALSE)
any(session3$clusters$manual_color)
#> [1] FALSE

Checking the palette

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.0

simulate_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")

Using the colors elsewhere

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:

cluster_colors(session) |> dput()

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:

head(cluster_colors(session, format = "data.frame"))
#>   cluster   color family_id family_color
#> 1   B_mem #EAB6DB        F1      #D6AAD7
#> 2 B_naive #BFA0D1        F1      #D6AAD7
#> 3     Mac #A697C8        F2      #898FBD
#> 4 Mono_cl #898FBD        F2      #898FBD
#> 5 Mono_nc #6A87AF        F2      #898FBD
#> 6  Plasma #2E5A87        F1      #D6AAD7

Saving and resuming

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] TRUE

export_palette_csv() writes the same tables as flat CSV when that is easier to hand off.

export_palette_csv(session, clusters_file = tempfile(fileext = ".csv"))

Large datasets

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      27

Note that the palette itself does not change: colors come from the cluster and family structure, not from the number of cells plotted.

The interactive UI

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:

session <- launch_palettome_ui(pdata, session = session)

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.