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 = ''; +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 = ''; + 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('
'); + 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 {