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) <noreply@anthropic.com>

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 <img> 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 = '<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>';
     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';