550a0e2666bcce27181a0cb0eab32ed261e444f6
mspeir
  Thu Aug 27 14:56:42 2026 -0700
singleCellSignalsPeaks: color the Cell class facet checkboxes, refs #37820, refs #37914

The faceted UI already draws a color swatch beside each checkbox of any facet
named in a colorSettingsUrl JSON, and the track already colors its subtracks by
broad cell class from celltype-palette.tsv. Publish that palette so the selector
shows the same colors: copySingleCellSignalsPeaksFiles.py now writes
<bed>/singleCellSignalsPeaks_colors.json alongside the facet metadata, and the
composite header names it. Rendered from the one shared palette, so a class is
the same color in the checkbox list, in the drawn tracks, and on both assemblies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git src/hg/makeDb/scripts/singleCellSignalsPeaks/copySingleCellSignalsPeaksFiles.py src/hg/makeDb/scripts/singleCellSignalsPeaks/copySingleCellSignalsPeaksFiles.py
index 289f4a40d85..cbf63d44b87 100644
--- src/hg/makeDb/scripts/singleCellSignalsPeaks/copySingleCellSignalsPeaksFiles.py
+++ src/hg/makeDb/scripts/singleCellSignalsPeaks/copySingleCellSignalsPeaksFiles.py
@@ -1,77 +1,132 @@
 #!/usr/bin/env python3
 """
 Copy the source data files for the native singleCellSignalsPeaks track into the
 genome's bed directory, mirroring each file's served relative path (Redmine
 #37820 for hg38, #37914 for mm10).
 
 For the given assembly it reads the hub build's main signal-&-peaks composite
 (cellBrowser<Asm>) stanzas, and for every subtrack copies the source file
 (resolved from the hub manifest by served relative path) to
   /hive/data/genomes/<asm>/bed/singleCellSignalsPeaks/<served-relpath>
 The served subpath is preserved on purpose: some peak-file basenames repeat
 across datasets, so a flat directory would clobber them, and keeping the subpath
 lets the /gbdb/<asm>/bbi/singleCellSignalsPeaks symlink resolve every bigDataUrl.
 
 It also copies the composite's facet metadata to
-  <bed>/singleCellSignalsPeaks_metadata.tsv   (the track's metaDataUrl target).
+  <bed>/singleCellSignalsPeaks_metadata.tsv   (the track's metaDataUrl target),
+and writes the cell-class facet colors to
+  <bed>/singleCellSignalsPeaks_colors.json    (the track's colorSettingsUrl target).
 
 Usage:
   copySingleCellSignalsPeaksFiles.py [--assembly hg38|mm10] [--stanzas STANZAS]
                                      [--manifest MANIFEST] [--dry-run]
 """
-import re, os, shutil, argparse
+import re, os, json, shutil, argparse
 from urllib.parse import urlparse
 
 # Where the hub build writes manifest.tsv -- its OUTPUT dir, not its code. The build
 # itself lives in the cellBrowser repo (ucsc/allTracksHub):
 #   https://github.com/ucscGenomeBrowser/cellBrowser/tree/develop/ucsc/allTracksHub
 # Its output dir is set there by CBHUB_OUT; keep this default in step with it (or pass
 # --manifest).
 HUB_BUILD = os.environ.get(
     "HUB_BUILD", "/hive/data/inside/cells/all-tracks-hub-build")
 TRACK = "singleCellSignalsPeaks"
 
 def copy_atomic(src, dst):
     """Copy src to dst without ever leaving a half-written dst in place.
 
     The bed dir is served live -- /gbdb/<asm>/bbi/singleCellSignalsPeaks is a symlink
     straight to it -- so copying onto a file the browser may be reading hands out a
     truncated bigBed for as long as the copy takes. Write a temp file beside the
     destination and rename it, which is atomic within one filesystem.
     """
     tmp = "%s.tmp%d" % (dst, os.getpid())
     try:
         shutil.copy2(src, tmp)
         os.replace(tmp, dst)
     except BaseException:
         if os.path.exists(tmp):
             os.remove(tmp)
         raise
 
+def write_text_atomic(text, dst):
+    """Write text to dst without ever leaving a half-written dst in place.
+
+    Same reason as copy_atomic: the bed dir is served live, and the faceted UI fetches
+    this file on every hgTrackUi page load, so a partial write hands out unparseable JSON
+    for as long as the write takes.
+    """
+    tmp = "%s.tmp%d" % (dst, os.getpid())
+    try:
+        with open(tmp, "w") as fh:
+            fh.write(text)
+        os.replace(tmp, dst)
+    except BaseException:
+        if os.path.exists(tmp):
+            os.remove(tmp)
+        raise
+
 def up_to_date(src, dst):
     """True if dst already holds this copy of src, so it can be skipped.
 
     Size alone is not enough: a rebuilt file often lands on the same size, and
     skipping it then serves last month's data forever. shutil.copy2 carries the
     mtime across, so a dst older than its source means the source moved on.
     """
     if not os.path.exists(dst):
         return False
     return (os.path.getsize(dst) == os.path.getsize(src)
             and os.path.getmtime(dst) >= os.path.getmtime(src))
 
+def palette_file(build):
+    """Path to the cell class -> color palette TSV (class<TAB>R,G,B, one per line).
+
+    Same resolution order as makeSingleCellSignalsPeaksRa.py: prefer the copy of record
+    archived alongside these scripts (written by build_celltype_crosswalks.py), fall back
+    to the hub build dir. The two scripts must agree on the palette, or the swatches in
+    the faceted selector would not match the colors the subtracks are drawn in.
+    """
+    pal = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                       "celltype-crosswalks", "celltype-palette.tsv")
+    if not os.path.isfile(pal):
+        pal = os.path.join(build, "celltype-crosswalks", "celltype-palette.tsv")
+    return pal
+
+def colors_json(pal):
+    """Render the palette as the faceted UI's colorSettingsUrl JSON.
+
+    facetedComposite.js wants {facetName: {facetValue: cssColor}} and draws a swatch
+    beside each checkbox of any facet named in it. The facet name must be the metadata
+    column ("Cell_class") and each key must be the column value verbatim -- the lookup
+    is an exact string match, so a case or spacing difference silently drops the swatch.
+    The whole palette is emitted, not just the classes this assembly uses: extra keys are
+    ignored by the JS, and it keeps hg38 and mm10 sharing one class->color mapping.
+    """
+    colors = {}
+    for line in open(pal):
+        f = line.rstrip("\n").split("\t")
+        if len(f) < 2 or not f[0].strip():
+            continue
+        rgb = [int(x) for x in f[1].split(",")]
+        colors[f[0]] = "#%02X%02X%02X" % tuple(rgb)
+    if not colors:
+        raise SystemExit("ERROR: no class/color rows read from %s, so the facet color "
+                         "swatches cannot be written." % pal)
+    return json.dumps({"Cell_class": colors}, indent=4, sort_keys=True) + "\n"
+
 def load_relpath_to_abs(manifest, asm):
     m = {}
     with open(manifest) as fh:
         hdr = fh.readline().rstrip("\n").split("\t")
         ai, ui, asmi = hdr.index("abs_path"), hdr.index("track_url"), hdr.index("assembly")
         for line in fh:
             f = line.rstrip("\n").split("\t")
             if len(f) <= max(ai, ui, asmi) or f[asmi] != asm:
                 continue
             m[urlparse(f[ui]).path.lstrip("/")] = f[ai]
     return m
 
 def main():
     ap = argparse.ArgumentParser()
     ap.add_argument("--assembly", default="hg38", choices=["hg38", "mm10"])
@@ -145,25 +200,40 @@
                          "manifest come from the same build?"
                          % (total, hub_composite, stanzas, manifest))
 
     # Missing metadata is fatal, the same way it is in makeSingleCellSignalsPeaksRa.py.
     # Skipping it quietly leaves the bed dir advertising the previous build's facets
     # against this build's data files, which is the mismatch nobody would go looking for.
     meta_src = os.path.join(build, "meta", "%s.metadata.tsv" % asm)
     meta_dst = os.path.join(beddir, "%s_metadata.tsv" % TRACK)
     if not os.path.isfile(meta_src):
         raise SystemExit("ERROR: no facet metadata at %s, so %s cannot be refreshed. The "
                          "track's metaDataUrl would keep pointing at the previous build's "
                          "facets." % (meta_src, meta_dst))
     if not args.dry_run:
         os.makedirs(beddir, exist_ok=True)
         copy_atomic(meta_src, meta_dst)
+
+    # Facet color swatches: the track's colorSettingsUrl target. Derived from the same
+    # class->color palette the subtrack "color" lines come from, so a class has one color
+    # in the selector and in the track display. A missing palette is fatal for the same
+    # reason a missing metadata file is: the .ra names this file, and quietly leaving the
+    # previous build's copy in place would show swatches that no longer match the tracks.
+    pal = palette_file(build)
+    if not os.path.isfile(pal):
+        raise SystemExit("ERROR: no cell class palette at %s, so the facet color swatches "
+                         "cannot be written. The track's colorSettingsUrl would keep "
+                         "pointing at the previous build's colors." % pal)
+    colors_dst = os.path.join(beddir, "%s_colors.json" % TRACK)
+    if not args.dry_run:
+        write_text_atomic(colors_json(pal), colors_dst)
+
     print("assembly=%s composite=%s: subtracks=%d copied=%d missing=%d  ~%.1f GB%s"
           % (asm, hub_composite, total, copied, missing, nbytes / 1e9,
              "  (dry-run)" if args.dry_run else "  -> " + beddir))
     if misses:
         print("MISSING %d source files:" % len(misses))
         for r in misses[:25]:
             print("  " + r)
 
 if __name__ == "__main__":
     main()