Define the mapping from the full annotation
labels_full <- c("B", "T", "NK", "Mono")
map <- sc_color_map(labels_full, palette = "chromatic")
as_named_colors(map)## B Mono NK T
## "#475D8F" "#E3B54E" "#00DADF" "#765A11"
Subsets select names from the same object; they never rerun positional palette assignment.
labels_subset <- c("T", "NK")
subset_colors <- as_named_colors(map)[labels_subset]
stopifnot(identical(
subset_colors,
as_named_colors(map)[c("T", "NK")]
))Factor reordering also leaves the contract intact.
reordered <- factor(labels_full, levels = rev(labels_full))
as_named_colors(map)[levels(reordered)]## Mono NK T B
## "#E3B54E" "#00DADF" "#765A11" "#475D8F"
Align colors for AnnData
AnnData stores categorical colors positionally in
.uns['<category>_colors']; that vector must use the
same order as the corresponding categorical levels. Keep the mapping
named in R, then select it in factor-level order immediately before
writing it to AnnData.
cell_type <- factor(labels_full, levels = c("T", "NK", "B", "Mono"))
ann_data_colors <- as_named_colors(map)[levels(cell_type)]
ann_data_colors## T NK B Mono
## "#765A11" "#00DADF" "#475D8F" "#E3B54E"
unname(ann_data_colors) can be written to
.uns['cell_type_colors'] with zellkonverter, reticulate, or
another AnnData interface. If levels are reordered, repeat the indexed
as_named_colors(map)[levels(cell_type)] lookup rather than
reusing a positional vector. This keeps the AnnData colors aligned
without adding an AnnData dependency to scChromatic.
CELLXGENE also uses the cyclic d3_rainbow scheme as a
categorical fallback, but those assignments change with the category
count and order. For that reason, d3_rainbow is a
compatibility palette and is intentionally unavailable to
sc_color_map(); use a fixed qualitative palette for
persistent mappings.
Add a new annotation
updated <- update_sc_color_map(map, c(labels_full, "DC"))
stopifnot(identical(
as_named_colors(updated)[names(as_named_colors(map))],
as_named_colors(map)
))Hierarchy maps use the same append-only rule when they are extended
through sc_hierarchy_map(existing = ...). New parents and
children receive colors, while established child colors and parent
anchors remain unchanged.
hierarchy <- sc_hierarchy_map(
parent = c("Lymphoid", "Lymphoid", "Myeloid"),
child = c("B", "T", "Mono")
)
hierarchy_updated <- sc_hierarchy_map(
parent = c("Lymphoid", "Myeloid"),
child = c("NK", "DC"),
existing = hierarchy
)## Warning: Some hierarchy colors have weak CIE2000 separation (2.4).
stopifnot(identical(
as_named_colors(hierarchy_updated)[names(as_named_colors(hierarchy))],
as_named_colors(hierarchy)
))Derive context from coordinate neighborhoods
sc_relationship_map() accepts a symmetric affinity
matrix in [0, 1]. Affinities near one ask related labels to
use perceptually closer—but still distinct—colors; affinities near zero
ask unrelated labels to be farther apart. The optimizer fits
normal-vision CIE2000 distance to the supplied or derived affinity
target. A separate penalty uses the worst distance across normal,
deutan, protan, and tritan simulations by default, penalizing shortfalls
below that target in any requested view.
sc_relationship_from_knn() can derive the matrix from
coordinates. It builds an exact Euclidean k-nearest-neighbor graph
within each sample, symmetrizes label-to-label neighbor proportions, and
combines sample matrices with equal weight. A sample contributes to a
pair only when both labels are present; this pairwise-complete rule
prevents missing labels from being treated as evidence of separation. A
pair never observed together is an error unless
unobserved = "zero" explicitly makes that assumption. Use
aggregate = "median" instead of the default mean when a
median across contributing samples is more appropriate.
data(sc_example)
affinity <- sc_relationship_from_knn(
as.matrix(sc_example[c("UMAP1", "UMAP2")]),
labels = sc_example$cell_type,
sample = sc_example$sample,
k = 15,
aggregate = "mean"
)
canonical <- sc_color_map(sc_example$cell_type)
relationship_map <- sc_relationship_map(
affinity,
canonical = canonical,
locked = "B cell",
stability_budget = 1,
seed = 2026
)
as_named_colors(relationship_map)## B cell CD4 T CD8 T Dendritic Monocyte NK
## "#475D8F" "#E3B54E" "#00DADF" "#9C8CFB" "#C15E58" "#765A11"
The bundled data expose UMAP-like coordinates, so they are used here
only to demonstrate the interface. UMAP does not preserve all
quantitative distances. For scientific neighborhood context, prefer
appropriately scaled PCA, a model-derived latent space, or spatial
coordinates, and report the preprocessing and k. Equal
sample weights prevent a larger sample from dominating, but do not by
themselves correct batch effects. Neighborhood affinities also depend on
label prevalence, local density, coordinate scaling, and k;
they should not be interpreted as direct evidence of biological
similarity. Stored provenance retains fingerprints and aggregate sample
summaries, not raw coordinates or sample identifiers.
Hard-locked labels never change. The stability budget is a separate
maximum on how many other canonical assignments may change; new labels
do not consume it. The output is an ordinary derived
sc_color_map, so the same scales and export functions
apply. Its context, history, and
metadata retain the method version, seed, requested CVD
modes, objective, normalized effective upper-triangle affinities,
canonical before/after colors, and changes. These records describe
construction and remain immutable if the map is subset. To add labels or
recalculate metrics for a subset, rerun
sc_relationship_map() with the corresponding matrix;
generic map extension is rejected because it would leave the stored
relationship input stale.
Add shape or pattern redundancy
Color need not carry the full annotation alone.
sc_redundant_encoding() finds pairs below a chosen
worst-vision CIE2000 distance across normal and requested CVD
simulations, then assigns different auxiliary encodings to those pairs.
The original colors are never changed.
encoding <- sc_redundant_encoding(
relationship_map,
channel = "shape",
min_cie2000 = 8
)
shape_values <- setNames(encoding$shape, encoding$label)
ggplot(sc_example, aes(UMAP1, UMAP2, color = cell_type, shape = cell_type)) +
geom_point(size = 1) +
scale_color_sc_map(relationship_map) +
scale_shape_manual(values = shape_values)
For geometries that can render texture, use
channel = "pattern" or channel = "both". The
returned pattern names are suitable for compatible pattern
layers, while texture_group is a generic group index for
texture systems such as scatterHatch. These integrations remain
optional; scChromatic does not require a pattern-rendering package. The
allocation is one-shot: create it for the full label universe and reuse
or subset the same table across figures. Rerunning after labels are
added or removed can change DSATUR priorities. RDS preserves the table’s
method and conflict attributes; ordinary CSV export does not.
Export and restore
JSON follows the installed versioned color-map schema. CSV keeps readable label/color rows and embeds the same complete JSON payload in one metadata row. Both formats preserve provenance and hierarchy, focus, alias, lock, history, context, and seed metadata. Legacy unversioned JSON and two-column CSV files are migrated when read; unknown future schema versions are rejected.
path <- tempfile(fileext = ".json")
write_sc_color_map(updated, path)
restored <- read_sc_color_map(path)
stopifnot(identical(as_named_colors(restored), as_named_colors(updated)))Plot and framework interoperability
dat <- data.frame(
x = seq_along(labels_full),
y = c(1, 3, 2, 4),
cell_type = labels_full
)
ggplot(dat, aes(x, y, color = cell_type)) +
geom_point(size = 4) +
scale_color_sc_map(map)
as_named_colors(map) is a plain named character vector.
It can be passed to Seurat manual scales, SingleCellExperiment plotting
code, ArchR color arguments, or
ComplexHeatmap::HeatmapAnnotation() without loading any of
those frameworks in scChromatic.