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 {