140e29ae6894cc8d55bb231e73e7a16f7e51a146 braney Wed Aug 5 14:20:32 2026 -0700 Docent: keep a print render's tooltips and track heights in proportion Two things stayed 1x in a 3x still. hgTracks takes the tooltip's font-size from the browser text size, which a scaled run has already multiplied by k for the image, and then the device pixel ratio scales that same text a second time -- so the popups came out k times too big, swamping the figure, and the last tooltip pinned fell off the crop. Pin the tooltip font-size back to the 1x value. The other is height. Neither pix nor textSize reaches a track whose height is a fixed pixel count, so a 128px bigLolly row that was 15% of an 850px image was 5% of a 2550px one -- ClinVar's lollipop row came out a sliver with unreadable y-axis labels next to a bed track that had grown with the font. After each view change, ask for k times the height of every row the page actually drew. Going by the drawn names is what reaches a lifted view, whose tracks are hub tracks under names trackDb never saw. Each track's own maxHeightPixels still clamps the request, so a track that should stay short does. refs #37892 Co-Authored-By: Claude Opus 5 (1M context) diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js index 9150f8fb1ff..2c83e02ae75 100755 --- src/hg/utils/docent/docent.js +++ src/hg/utils/docent/docent.js @@ -95,30 +95,43 @@ // back the 1x amount of layout space. One image pixel then falls on exactly one device // pixel: native resolution, no resampling anywhere in the path. // // So the still comes out k times the 1x still in each dimension, showing the same figure -- // not a variation of it rendered in a bigger window. const SCALE = Math.max(1, Number(process.env.DOCENT_SCALE || doc.scale || 1)); const [VW, VH] = doc.size || [1000, 760]; const PIX = Math.round((doc.pix || 850) * SCALE); // hgTracks offers a fixed ladder of track font sizes (hgTracks/config.c); step to the one // closest to scaling its 8px default, so rows and labels grow with the image instead of // staying 8px tall in a 3x-wide picture. const TEXTSIZE = [6, 8, 10, 12, 14, 18, 24, 34].reduce((a, b) => Math.abs(b - 8 * SCALE) < Math.abs(a - 8 * SCALE) ? b : a); // What every hgTracks nav carries: the image width, plus the font to draw it with at scale. const IMGVARS = `pix=${PIX}` + (SCALE > 1 ? `&textSize=${TEXTSIZE}` : ''); +// The tooltip's font-size, forced back to what a 1x run gives it (see SCALE_INIT). hgTracks +// takes it from the browser text size, which is TEXTSIZE on a scaled run, and then the device +// pixel ratio scales it a second time. +const SCALE_ARGS = { k: SCALE, tipPx: Math.round(TEXTSIZE / SCALE) }; +// `pix` makes the image k times WIDER; nothing makes a fixed-height track taller. A bigLolly +// or wiggle row is a pixel count (`DEFAULT_HEIGHT_PER` = 128 in hg/inc/wiggle.h), read from +// trackDb/the cart and untouched by `pix` or `textSize` -- so a 128px row that was 15% of an +// 850px image is 5% of a 2550px one, which is how the ClinVar lollipop row came out a sliver +// with unreadable y-axis labels. A print run therefore asks for k times the height of every +// track a `track:` step turns on. It is harmless where it means nothing (a bigBed never reads +// heightPer) and each track's own `maxHeightPixels` still clamps it, so a track that should +// stay short does -- raise that ceiling in trackDb for one that should not. +const HEIGHTPER = SCALE > 1 ? Math.round(128 * SCALE) : 0; // FAST: iterate on the FIGURES. Everything that exists only for the video is dropped -- // the dwells, the cursor animation, the dropdown theatrics, the screen recording and the // mp4 transcode. The stills are byte-for-byte what a full run produces, and a run costs // roughly a third as long. `fast: true` in the script, DOCENT_FAST=1, or `make FAST=1 BP1`. // A scaled run is a figure run: at 3x the video would be a 3000px-wide recording of a tour // nobody watches at that size, so the mp4 is skipped and only the stills are produced. Build // the video from an unscaled run of the same script. const FAST = !!(doc.fast || process.env.DOCENT_FAST || SCALE > 1); const PACE = FAST ? 0 : Math.round((doc.pace ?? 1.2) * 1000); // dwell after each step const SHOTHOLD = FAST ? 0 : Math.round((doc.shotHold ?? 2.2) * 1000); // extra pause at a shot // ---------- trackDb ---------- // Docent carries NO table of per-track cart variables. Such a table encodes one snapshot // of trackDb and then quietly lies when trackDb changes (this file used to pin // `clinvarMain=dense` for every `clinvar:` step, for instance). Instead ask the server for @@ -237,36 +250,46 @@ else await pg.type(sel, String(text), { delay: 45 }); } const enc = s => encodeURIComponent(String(s)); const state = { db: doc.db || 'hg38', position: doc.position || '', hgsid: '' }; // ---------- SCALE: give the k-times-wider browser image the 1x amount of layout space ---------- // hgTracks sizes the image table in the HTML it writes, so shrinking the elements alone // would leave the table 3x wider than its own contents. Zoom the table: the zoom reaches the // images inside it, and its box goes back to the width the 1x page gives it -- 854 CSS px for // a pix=850 run, whatever k is -- so the rest of the page is laid out exactly as at 1x while // the image keeps all k times its pixels (one per device pixel at deviceScaleFactor: k). // // Installed for the whole run rather than at the shutter: the tour's own geometry -- a drag // across the image, a mouseover on a feature -- then works in the same coordinates a 1x run // works in, and needs no scale arithmetic of its own. -const SCALE_INIT = (k) => { +// +// The tooltip needs the opposite correction. It is a DOM element, so deviceScaleFactor +// already draws it k times bigger -- and hgTracks sets its font-size from the BROWSER TEXT +// SIZE (`window.browserTextSize` -> hg/js/utils.js addMouseover, hgTracks.js #mouseOverText), +// which a print run has just multiplied by k for the image. Both scalings land on the same +// text, so a 3x still gets a tooltip 3x too big -- the popups swamp the figure and the last +// one pinned falls off the crop. Pin the font-size back to the 1x value (TEXTSIZE / k); it +// is written as an inline style, so the rule has to be !important to win. `.__pinnedTip` is +// 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; }`; + 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 = () => { 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 = ''; document.documentElement.appendChild(c); @@ -289,31 +312,31 @@ function absurl(u) { 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); + if (SCALE > 1) await ctx.addInitScript(SCALE_INIT, SCALE_ARGS); await ctx.addInitScript(CURSOR_INIT); 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 -- @@ -321,30 +344,55 @@ try { const pos = await page.evaluate(() => { try { if (typeof hgTracks !== 'undefined' && hgTracks.chromName) return hgTracks.chromName + ':' + (hgTracks.winStart + 1) + '-' + hgTracks.winEnd; } catch (_) {} return null; }); if (pos) state.position = pos; } catch (e) {} // Authoring aid: DOCENT_ROWS=1 logs the rows hgTracks actually drew, so "why is that // subtrack still there / why is my mouseover track not shown" is one run, not a guess. if (process.env.DOCENT_ROWS) { const rows = await page.evaluate(() => [...document.querySelectorAll('[id^="img_data_"]')].map(e => e.id.replace('img_data_', ''))).catch(() => null); if (rows) console.log(' rows:', rows.join(', ') || '(none)'); } + await scaleHeights(); // print runs only; see below + } + // A print run makes the image k times wider, and a track with a FIXED PIXEL height does not + // follow: a bigLolly or wiggle row is a pixel count read from trackDb/the cart, untouched by + // `pix` and `textSize`, so a 128px row that was 15% of an 850px image is 5% of a 2550px one. + // That is how the ClinVar lollipop row came out a sliver with unreadable y-axis labels next to + // a bed track that DID grow with the font. So after each view change, ask for k times the + // height of every row hgTracks just drew. It is asked of the rows the page actually has -- + // which is the only way to reach the LIFTED view, whose tracks are hub tracks under names + // trackDb never saw. Harmless where it means nothing (a bigBed never reads heightPer), and + // each track's own `maxHeightPixels` still clamps it, so a track that should stay short does; + // raise that ceiling in trackDb for one that should not (clinvarSubLolly does this). + const heightsSent = new Set(); + let inHeightNav = false; + async function scaleHeights() { + if (!HEIGHTPER || inHeightNav) return; + const drawn = await page.evaluate(() => + [...document.querySelectorAll('#imgTbl [id^="img_data_"]')].map(e => e.id.replace('img_data_', '')) + ).catch(() => []); + const fresh = (drawn || []).filter(n => n && !heightsSent.has(n)); + if (!fresh.length) return; // same rows as last time: no second load + fresh.forEach(n => heightsSent.add(n)); + inHeightNav = true; // the nav below must not recurse + try { await nav(`/cgi-bin/hgTracks?${fresh.map(n => `${n}.heightPer=${HEIGHTPER}`).join('&')}&${IMGVARS}`); } + finally { inHeightNav = false; } } async function nav(u) { pinnedTips.length = 0; await page.goto(absurl(u), { waitUntil: 'load' }); await captureState(); await page.mouse.move(cur.x, cur.y); } async function glide(x, y) { if (FAST) { await page.mouse.move(x, y); cur.x = x; cur.y = y; return; } const steps = Math.max(10, Math.round(Math.hypot(x - cur.x, y - cur.y) / 9)); for (let i = 1; i <= steps; i++) { await page.mouse.move(cur.x + (x - cur.x) * i / steps, cur.y + (y - cur.y) * i / steps); await sleep(15); } cur.x = x; cur.y = y; } async function glideTo(sel) { const b = await page.locator(sel).first().boundingBox({ timeout: 8000 }).catch(() => null); if (b) await glide(b.x + b.width / 2, b.y + b.height / 2); } async function clickGlide(sel) { await glideTo(sel); await sleep(160); await page.click(sel); } async function checkGlide(sel, want) { await glideTo(sel); await sleep(140); @@ -805,31 +853,31 @@ 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 }; }, { 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) { 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); + 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) => { 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';