ffccd9cceb44efbd4d07dab8eda90e95ccd72d77
braney
  Thu Aug 6 13:45:12 2026 -0700
Docent: pinned-mouseover cursors, and a montage verb for multi-panel figures, refs #37892

A pinShot puts several tooltips on one still, but nothing in the picture says
which feature each was raised from -- the reader infers it from an 8px anchor
offset, and on a dense row that is a guess.  recordTip now keeps the hover
point alongside the tooltip's own offset and pinShot draws a static pointer
there.  The glyph and its box move out of CURSOR_INIT into constants the two
share: a pinned pointer that did not match the animated one would read as a
different cursor rather than as the same tour paused, and that is the kind of
drift nobody notices until the figure is in a proof.  pinShot takes a map
form, {name: x, cursors: false}, for a figure that does not want them, and the
pointers join the crop's bounding box so one on the bottom row cannot fall off.

The other half is composition.  A journal wants parts (A), (B) as a single
file, so the panels had to be assembled by hand after the run.  That puts the
figure's layout outside the script: rename a shot and the montage quietly
drops a panel instead of failing, which is the sort of thing found at
submission.  montage: {name: x, shots: [a, b]} does it in the tour, so the
composite is a product of the same run as its parts.  Panels stack in order
and letter themselves; labels:, direction:, gap: and labelSize: override.
Composition happens in a browser page at deviceScaleFactor 1 with every panel
at its natural pixel size, so the result is pixel-for-pixel its inputs -- a
make hires montage is print resolution because the panels were, not because
anything was upscaled.  Panels narrower than the widest are left-aligned and
padded rather than stretched, and a named shot that was never taken warns and
is skipped.

diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js
index e3b1bb7d803..838534b0b57 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -279,37 +279,44 @@
 // a recorded tooltip re-injected by pinShot() (which strips the id, keeps the class).
 const SCALE_INIT = ({ k, tipPx }) => {
   const add = () => {
     if (document.getElementById('__scale')) return;
     const s = document.createElement('style');
     s.id = '__scale';
     s.textContent = `#imgTbl, #chromIdeoImg, img[src*="hgtIdeo"] { zoom: ${1 / k} !important; }`
       + `\n#mouseoverContainer, #mouseOverText, .tooltip, .__pinnedTip { font-size: ${tipPx}px !important; }`;
     (document.head || document.documentElement).appendChild(s);
   };
   if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add);
   else add();
 };
 
 // ---------- animated cursor (same technique as the walkthrough-video skill's record.js) ----------
-const CURSOR_INIT = () => {
+// The glyph and its box are shared with pinShot(), which draws a STATIC copy at every
+// pinned mouseover so a combined figure shows where each tooltip was raised from. Keep
+// them one definition: a pinned cursor that did not match the animated one would read as
+// a different pointer rather than as the same tour paused.
+const CURSOR_BOX = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;'
+  + 'width:24px;height:24px;margin-left:-3px;margin-top:-2px;filter:drop-shadow(0 1px 1px rgba(0,0,0,.4));';
+const CURSOR_SVG = '<svg width="24" height="24" viewBox="0 0 24 24"><path d="M3 2 L3 19 L7.5 14.5 L10.5 21.5 L13.5 20.2 L10.6 13.5 L17 13.5 Z" fill="#111" stroke="#fff" stroke-width="1.3"/></svg>';
+const CURSOR_INIT = ({ box, svg }) => {
   const add = () => {
     if (document.getElementById('__cur')) return;
     const c = document.createElement('div');
     c.id = '__cur';
-    c.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;width:24px;height:24px;margin-left:-3px;margin-top:-2px;filter:drop-shadow(0 1px 1px rgba(0,0,0,.4));';
-    c.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24"><path d="M3 2 L3 19 L7.5 14.5 L10.5 21.5 L13.5 20.2 L10.6 13.5 L17 13.5 Z" fill="#111" stroke="#fff" stroke-width="1.3"/></svg>';
+    c.style.cssText = box;
+    c.innerHTML = svg;
     document.documentElement.appendChild(c);
     const place = (x, y) => { c.style.transform = `translate(${x}px,${y}px)`; };
     place(120, 120);
     document.addEventListener('mousemove', e => place(e.clientX, e.clientY), true);
     document.addEventListener('mousedown', e => {
       const r = document.createElement('div');
       r.className = '__ripple';
       r.style.cssText = `position:fixed;left:${e.clientX}px;top:${e.clientY}px;z-index:2147483645;pointer-events:none;width:6px;height:6px;margin:-3px 0 0 -3px;border:3px solid rgba(225,30,30,.95);border-radius:50%;`;
       document.documentElement.appendChild(r);
       r.animate([{ transform: 'scale(1)', opacity: 1 }, { transform: 'scale(6)', opacity: 0 }], { duration: 520, easing: 'ease-out' });
       setTimeout(() => r.remove(), 540);
     }, true);
   };
   if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add);
   else add();
@@ -319,31 +326,31 @@
   if (/^https?:/.test(u)) return u;
   if (u.startsWith('/cgi-bin/')) return SERVER.replace(/\/cgi-bin$/, '') + u;
   if (u.startsWith('/')) return SERVER.replace(/\/cgi-bin$/, '') + u;
   return SERVER + '/' + u;
 }
 
 const T_START = Date.now();
 (async () => {
   fs.mkdirSync(STILLDIR, { recursive: true });
   const browser = await chromium.launch({ headless: true, args: ['--force-color-profile=srgb'] });
   const ctx = await browser.newContext({
     viewport: { width: VW, height: VH }, deviceScaleFactor: SCALE,
     ...(FAST ? {} : { recordVideo: { dir: path.join(HERE, '.vid_' + base), size: { width: VW, height: VH } } }),
   });
   if (SCALE > 1) await ctx.addInitScript(SCALE_INIT, SCALE_ARGS);
-  await ctx.addInitScript(CURSOR_INIT);
+  await ctx.addInitScript(CURSOR_INIT, { box: CURSOR_BOX, svg: CURSOR_SVG });
   await ctx.addInitScript(() => { try { localStorage.setItem('hgTracks_hideTutorial', '1'); } catch (e) {} });
   const page = await ctx.newPage();
   const cur = { x: 120, y: 120 };
   const pinnedTips = [];   // recorded mouseover tooltips for the next pinShot (per view)
 
   async function captureState() {
     try {
       const u = new URL(page.url());
       const h = u.searchParams.get('hgsid'); if (h) state.hgsid = h;
       const db = u.searchParams.get('db'); if (db) state.db = db;
       const p = u.searchParams.get('position'); if (p) state.position = p;
     } catch (e) {}
     // An interactive zoom / drag-select reload stores the new window in the CART, not the
     // URL, so read the live position straight from hgTracks when we're on a tracks page --
     // otherwise a later position-based nav (e.g. turning on a track) reverts the zoom.
@@ -842,83 +849,169 @@
     // Optionally RECORD this tooltip so a later `pinShot:` can show several mouseovers
     // open together in one figure. We only record (position + the tooltip's own HTML)
     // here -- nothing is injected into the recorded page, so the mp4 still shows just the
     // transient native tooltip. `pin:` on the step overrides the document-level
     // `pinMouseovers:` default. Records accumulate within a view and are cleared on nav.
     const pin = (o.pin != null) ? o.pin : (doc.pinMouseovers === true);
     if (pin) await recordTip(x, y);
     if (!FAST) await sleep(o.hold != null ? Number(o.hold) * 1000 : SHOTHOLD);
     if (o.shot) await shot(o.shot);
   }
   // Grab the live mouseover tooltip's HTML and anchor it at the ITEM's coordinate (x,y
   // that mouseover just hovered), expressed RELATIVE TO the track image (#imgTbl). The
   // browser parks its own tooltip at a near-fixed spot, so two tips would stack; anchoring
   // to the item keeps each pinned tooltip on its own feature (and robust to the throwaway
   // page's image sitting at a different offset).
+  // cx/cy is the HOVER POINT itself (also image-relative), kept alongside the tooltip's
+  // own offset so pinShot() can draw a cursor exactly where the tip was raised from.
   async function recordTip(x, y) {
     const t = await page.evaluate(({ x, y }) => {
       const c = document.getElementById('mouseoverContainer');
       if (!c || !c.offsetWidth) return null;
       const im = document.getElementById('imgTbl');
       const ir = im ? im.getBoundingClientRect() : { left: 0, top: 0 };
-      return { dx: x - ir.left + 8, dy: y - ir.top + 8, html: c.outerHTML };
+      return { cx: x - ir.left, cy: y - ir.top,
+               dx: x - ir.left + 8, dy: y - ir.top + 8, html: c.outerHTML };
     }, { x, y });
     if (t) pinnedTips.push(t);
   }
   // Render every recorded tooltip open at once in a still, WITHOUT touching the recorded
   // page (so the mp4 is unaffected): reload the current view on a throwaway page that
   // shares the session cookie (cart), inject the recorded tooltips, screenshot, discard.
-  async function pinShot(name) {
+  // Bare string is the still's name; the map form adds `cursors:` (default true) to draw
+  // a static pointer at every pinned hover point, so a combined figure says which feature
+  // each tooltip came off rather than leaving the reader to infer it from the anchor.
+  async function pinShot(arg) {
+    const o = (arg && typeof arg === 'object') ? arg : { name: arg };
+    const name = o.name ?? o.shot;
+    const cursors = (o.cursors != null) ? o.cursors !== false : true;
     if (!pinnedTips.length) { console.warn(`pinShot ${name}: no pinned mouseovers recorded (set pin: true / pinMouseovers: true)`); return; }
     const url = page.url();
     const ctx2 = await browser.newContext({ viewport: { width: VW, height: VH }, deviceScaleFactor: SCALE });
     if (SCALE > 1) await ctx2.addInitScript(SCALE_INIT, SCALE_ARGS);
     await ctx2.addCookies(await ctx.cookies());
     const pg2 = await ctx2.newPage();
     await pg2.goto(url, { waitUntil: 'load' });
     await pg2.waitForSelector('#imgTbl', { timeout: 8000 }).catch(() => {});
-    await pg2.evaluate((tips) => {
+    await pg2.evaluate(({ tips, cursors, box, svg }) => {
       window.scrollTo(0, 0);
       const im = document.getElementById('imgTbl');
       const ir = im ? im.getBoundingClientRect() : { left: 0, top: 0 };
       for (const t of tips) {
         const wrap = document.createElement('div');
         wrap.innerHTML = t.html;
         const el = wrap.firstElementChild; if (!el) continue;
         el.removeAttribute('id');
         el.classList.add('__pinnedTip');
         el.style.position = 'fixed';
         el.style.left = (ir.left + t.dx) + 'px'; el.style.top = (ir.top + t.dy) + 'px';
         el.style.opacity = '1'; el.style.visibility = 'visible';
         el.style.display = 'inline-block'; el.style.pointerEvents = 'none';
         document.documentElement.appendChild(el);
+        // A pointer at the hover point, drawn the same way the live overlay draws it.
+        // It lands on the tooltip's top-left corner, which is exactly where a real
+        // screenshot of that hover would put it.
+        if (cursors && t.cx != null) {
+          const c = document.createElement('div');
+          c.className = '__pinnedCursor';
+          c.style.cssText = box;
+          c.innerHTML = svg;
+          c.style.transform = `translate(${ir.left + t.cx}px,${ir.top + t.cy}px)`;
+          document.documentElement.appendChild(c);
+        }
       }
-    }, pinnedTips);
+    }, { tips: pinnedTips, cursors, box: CURSOR_BOX, svg: CURSOR_SVG });
     const p = path.join(STILLDIR, name + '.png');
     const clip = await pg2.evaluate(() => {
       const im = document.getElementById('imgTbl'); if (!im) return null;
-      const els = [im, ...document.querySelectorAll('.__pinnedTip')];
+      const els = [im, ...document.querySelectorAll('.__pinnedTip, .__pinnedCursor')];
       let x = Infinity, y = Infinity, x2 = -Infinity, y2 = -Infinity;
       for (const o of els) { const r = o.getBoundingClientRect(); x = Math.min(x, r.left); y = Math.min(y, r.top); x2 = Math.max(x2, r.right); y2 = Math.max(y2, r.bottom); }
       return { x: Math.max(0, x - 4), y: Math.max(0, y - 4), width: (x2 - x) + 8, height: (y2 - y) + 8 };
     });
     if (clip) await pg2.screenshot({ path: p, clip });
     else await pg2.locator('#imgTbl').screenshot({ path: p });
     await ctx2.close();
     console.log('SHOT', p, `(pinned: ${pinnedTips.length})`);
     pinnedTips.length = 0;   // consume the set
   }
+  // Compose stills already written this run into ONE multi-panel PNG, which is what a
+  // journal wants for a figure with parts (A), (B), ... Doing it here rather than in a
+  // project script keeps the composite a product of the same tour: rename a shot and the
+  // montage follows, instead of silently dropping a panel at submission time.
+  //
+  //   montage: {name: figure1, shots: [source_hg38, lifted_hs1]}
+  //   montage: {name: fig2, shots: [a, b], direction: horizontal, labels: [Before, After]}
+  //
+  // Composed in a browser page at deviceScaleFactor 1 with every panel at its NATURAL
+  // pixel size, so the composite is pixel-for-pixel the panels -- a `make hires` montage
+  // is print resolution because its inputs were, not because anything was upscaled.
+  // Panels narrower than the widest are left-aligned and padded, never stretched.
+  async function montage(arg) {
+    const o = (arg && typeof arg === 'object') ? arg : { name: arg };
+    const name = o.name ?? o.shot;
+    const shots = o.shots || o.panels || [];
+    if (!name || !shots.length) { console.warn(`montage: needs {name:, shots: [...]}`); return; }
+    const dir = (o.direction || 'vertical').startsWith('h') ? 'row' : 'column';
+    const gap = o.gap != null ? Number(o.gap) : 14;
+    const auto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+    const panels = [];
+    for (let i = 0; i < shots.length; i++) {
+      const src = path.join(STILLDIR, shots[i] + '.png');
+      if (!fs.existsSync(src)) { console.warn(`montage ${name}: no still "${shots[i]}.png" -- panel skipped`); continue; }
+      panels.push({ data: 'data:image/png;base64,' + fs.readFileSync(src).toString('base64'),
+                    label: (o.labels && o.labels[i] != null) ? String(o.labels[i])
+                         : (o.labels === false ? '' : auto[i] || String(i + 1)) });
+    }
+    if (!panels.length) { console.warn(`montage ${name}: no panels to compose`); return; }
+    const ctx3 = await browser.newContext({ viewport: { width: 1200, height: 900 }, deviceScaleFactor: 1 });
+    const pg3 = await ctx3.newPage();
+    await pg3.setContent('<!doctype html><body style="margin:0;background:#fff"><div id="__fig"></div></body>');
+    const labelSize = await pg3.evaluate(({ panels, dir, gap, labelSize }) => {
+      const fig = document.getElementById('__fig');
+      fig.style.cssText = `display:inline-flex;flex-direction:${dir};align-items:flex-start;`
+        + `gap:${gap}px;background:#fff;padding:${gap}px;font-family:Helvetica,Arial,sans-serif;`;
+      const rows = panels.map(p => {
+        const row = document.createElement('div');
+        row.style.cssText = 'display:flex;align-items:flex-start;';
+        const lab = document.createElement('div');
+        lab.className = '__figLabel';
+        lab.textContent = p.label;
+        const img = document.createElement('img');
+        img.src = p.data; img.style.display = 'block';
+        row.appendChild(lab); row.appendChild(img);
+        fig.appendChild(row);
+        return { lab, img };
+      });
+      return Promise.all(rows.map(r => r.img.decode().catch(() => {}))).then(() => {
+        // Label size follows the panels' own resolution, so a 3x montage gets 3x lettering
+        // rather than a caption that shrinks to nothing next to a 2500px panel.
+        const maxW = Math.max(...rows.map(r => r.img.naturalWidth));
+        const fs = labelSize != null ? labelSize : Math.max(11, Math.round(maxW / 55));
+        for (const r of rows) {
+          r.lab.style.cssText = `flex:0 0 ${Math.round(fs * 1.5)}px;font-weight:bold;`
+            + `font-size:${fs}px;line-height:1;color:#111;`;
+          r.img.style.width = r.img.naturalWidth + 'px';   // natural size, never stretched
+        }
+        return fs;
+      });
+    }, { panels, dir, gap, labelSize: o.labelSize != null ? Number(o.labelSize) : null });
+    const p = path.join(STILLDIR, name + '.png');
+    await pg3.locator('#__fig').screenshot({ path: p });
+    await ctx3.close();
+    console.log('SHOT', p, `(montage: ${panels.length} panels, label ${labelSize}px)`);
+  }
   // Shift+drag across the track image to open the browser's own drag-select dialog
   // ("Zoom In / Single Highlight / ..."), then act on it. The usual form gives one
   // genomic region and zooms:  drag: chr7:155,806,100-155,806,557
   // Any other action needs the map form, which is also how you pass shot:/track:
   //   drag: {range: "chr7:155,806,100-155,806,557", then: highlight}
   // Endpoints that are not genomic coords use a fraction
   // across the view (fromFrac:/toFrac:) or a raw pixel (fromX:/toX:) instead.
   // Optional `track:` picks the row the drag runs over (y); default is the middle of
   // the image. `shot:` captures the open dialog (e.g. the Figure 1A drag-select box).
   // `then:` = zoom (default, clicks Zoom In) | highlight (Single Highlight) | cancel
   // (Escape, leaves the view unchanged).
   async function drag(o) {
     // A bare string is the region; `range:` is the same thing with room for other
     // keys. Both expand to the from:/to: endpoints the rest of this function uses.
     if (typeof o === 'string') o = { range: o };
@@ -1419,30 +1512,31 @@
         await clickGlide(btn);
         await page.waitForSelector('#imgTbl');
         // The zoom buttons redraw the image in place (ajax), so #imgTbl never went away and
         // waiting for it proves nothing: the next step can read the OLD view's map boxes and
         // report an item "not found" that simply is not in view yet. Wait for the window to
         // change instead. Until FAST there was always a dwell here hiding this.
         if (was) await page.waitForFunction(
             w => { try { return `${hgTracks.winStart}-${hgTracks.winEnd}` !== w; } catch (_) { return false; } },
             was, { timeout: 15000 })
           .catch(() => console.warn(`zoom ${arg}: window still ${was} after 15s`));
         await captureState();
         break;
       }
       case 'shot': await shot(arg); return;                       // shot supplies its own dwell
       case 'pinShot': await pinShot(arg); break;                  // combined figure, off the mp4 timeline
+      case 'montage': await montage(arg); break;                  // stills -> one multi-panel PNG
       case 'mouseover': await mouseover(arg); return;             // supplies its own dwell (o.hold)
       // escape hatches
       case 'goto': await nav(arg); break;
       case 'click':
         if (arg && typeof arg === 'object' && arg.track) {
           // Click a NAMED track item -> follow its map-box link (e.g. the hgc detail page).
           const it = await itemXY(arg.track, arg.item ?? arg.title ?? arg.value,
                                   arg.title != null && arg.item == null && arg.value == null);
           await glide(it.x, it.y); await sleep(200);
           // A raw click on the data area is swallowed by hgTracks' drag-select handler, so
           // follow the item's own map-box link (the hgc detail page) directly.
           if (it.href) await nav(it.href);
           else { await page.mouse.click(it.x, it.y); await page.waitForLoadState('load').catch(() => {}); await captureState(); }
           if (arg.shot) { await shot(arg.shot); return; }
         } else {