Palettome

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

The same UMAP colored three ways: ggplot2’s default palette, palettome harmonious mode, and palettome contrast mode

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:

Palettes for the same four cell families across seeds, a custom sweep_anchors gradient, per_family style, and contrast mode

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:

A contrast-mode palette shown as generated and under simulated deutan, protan, and tritan 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 vs. UI

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)

Quick start (core only)

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

Working with a Seurat object

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.

Interactive UI

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 palettome interactive UI: embedding scatter plot, family dendrogram, and drag-and-drop compartment panel

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:

The JSON schema (stable contract)

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

CSV export (export_palette_csv()) writes the clusters table (and optionally the families table) as plain CSV with the same column names.

Data input contract

as_cluster_data() accommodates either input into one palettome_data object so every other function only has to know one shape:

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.

Performance for large datasets

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.

Tests

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