790667d0f7ee6965244ed31318aa9459e82fda73
braney
Mon Aug 3 16:50:48 2026 -0700
Docent: add hideKids, and make a named mouseover wait for that item's own tooltip.
hideKids is a container "visibility" meaning hide everything under it, so a child
named alongside it is left the only one drawn. A superTrack needs it: unlike a
composite, its own mode does not reach its children, so each comes up at its own
trackDb visibility and {varsInPubs: show} draws all eight of its members however
much an earlier "hide: all" hid. The expansion skips any child the step names
itself and is sent in a round of its own AFTER the rest, since a subtrack hide
travelling in the same request as its container can be dropped by the cart
(#37953). It works on a composite or view too, where it deselects (_sel=0).
The mouseover fix: tooltips are mouseenter-driven with a 500ms show delay and a
500ms hide grace (hg/js/utils.js addMouseover), so while the cursor glides in it
crosses other items and one of THEIR tooltips is often still on screen when it
arrives. Waiting for "some tooltip is visible" therefore recorded a neighbour's
text -- an Alignment Differences mismatch pinned as the adjacent aligned block's
"identical". That only bit pin:, because the dwell which follows let the right
tooltip replace the wrong one before any shot: -- so a scenario's figures and its
mp4 disagreed with each other, and FAST (a single cursor jump, never entering the
neighbour) read correctly while the full run did not. A named mouseover now waits
for the item's own text, taken from its map box and rendered the way the tooltip
renders it (innerHTML then textContent: the attribute holds markup and undecoded
entities such as ★). Comparison is whitespace-insensitive on a distinctive
prefix. A positional hover has no expected text, so it settles instead.
DOCENT_ROWS=1 now also reports mouseovers -- which map boxes matched the name,
where the cursor went, the tooltip expected from the box and the one that came
up -- and warns when the item's own text never appears. Verified over all ten
named-item mouseovers in the Current Protocols quickLift scenarios: no warnings,
every tooltip correct.
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 a2fd5ba8914..f4402c92bc3 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -516,47 +516,63 @@
if (!img || !row) throw new Error(`track "${t}" not shown (need #img_data_${key} + #tr_${key})`);
return { key, img, row };
}
// Resolve a NAMED item to a point {x,y} + its map-box HREF (the item's hgc link). We
// pick the map <area> whose href(&i=<name>)/title carries the name AND whose box sits in
// this track's own ROW band (so stacked items on other rows don't win); fall back to the
// JSON mouseOver spans (wig/dense tracks, no per-item href).
async function itemXY(t, want, titleOnly) {
const { key, img, row } = await trackBox(t);
const band = { top: row.y, bot: row.y + row.height };
const area = await page.evaluate(({ want, titleOnly, band, imgBox }) => {
const areas = [...document.querySelectorAll('map[name^="map_"] area')];
const cands = [];
for (const a of areas) {
const href = a.getAttribute('href') || '';
- const title = a.getAttribute('title') || a.getAttribute('data-tooltip') || '';
+ const title = a.getAttribute('title') || a.getAttribute('data-tooltip')
+ || a.getAttribute('mouseoverText') || '';
const hay = titleOnly ? title : (href + ' ' + title);
if (!hay.includes(want)) continue;
const c = (a.getAttribute('coords') || '').split(',').map(Number);
if (c.length < 4) continue;
// origin = the image this map is attached to (fall back to the data image box)
const m = a.closest('map'), nm = m && m.getAttribute('name');
const im = nm && document.querySelector(`img[usemap="#${nm}"]`);
const r = im ? im.getBoundingClientRect() : null;
const ox = r ? r.left : imgBox.x, oy = r ? r.top : imgBox.y;
const cx = ox + (c[0] + c[2]) / 2, cy = oy + (c[1] + c[3]) / 2;
- cands.push({ cx, cy, href, inBand: cy >= band.top - 1 && cy <= band.bot + 1 });
+ // The tooltip's own text, so the hover can wait for THIS item's tooltip rather than
+ // for any tooltip at all (see mouseover()). Rendered exactly the way the tooltip
+ // renders it -- innerHTML then textContent -- because the attribute holds markup AND
+ // undecoded entities (`<b>`, `★`) that getAttribute hands back literally.
+ const tmp = document.createElement('div');
+ tmp.innerHTML = title;
+ const tip = (tmp.textContent || '').replace(/\s+/g, ' ').trim();
+ cands.push({ cx, cy, href, tip, inBand: cy >= band.top - 1 && cy <= band.bot + 1 });
}
const inBand = cands.filter(h => h.inBand);
- return (inBand[0] || cands[0]) || null;
+ const pick = (inBand[0] || cands[0]) || null;
+ return pick && { ...pick, n: cands.length, nBand: inBand.length,
+ all: cands.map(c => Math.round(c.cx)) };
}, { want: String(want), titleOnly: !!titleOnly, band, imgBox: img });
- if (area) return { x: area.cx, y: area.cy, href: area.href };
+ if (area) {
+ if (process.env.DOCENT_ROWS)
+ console.log(` item "${want}" in ${t}: ${area.n} map box(es) match (${area.nBand} in row), `
+ + `centers x=[${area.all}] -> hovering (${Math.round(area.cx)},${Math.round(area.cy)})`
+ + (area.tip ? `, expecting tip "${area.tip.slice(0, 40)}"` : ''));
+ return { x: area.cx, y: area.cy, href: area.href, tip: area.tip };
+ }
const span = await page.evaluate(({ keys, want }) => {
const md = window.mapData; if (!md || !md.spans) return null;
for (const k of keys) {
const arr = md.spans[k]; if (!arr) continue;
const s = arr.find(r => String(r.value || '').includes(want));
if (s) return { x1: s.x1, x2: s.x2 };
}
return null;
}, { keys: [key, t], want: String(want) });
if (!span) throw new Error(`item "${want}" not found in track "${t}" (searched map-box areas + mouseOver spans)`);
return { x: img.x + (span.x1 + span.x2) / 2, y: row.y + row.height / 2, href: null };
}
// POSITIONAL point: at:/frac:/x: -> x, y forced to the track row's middle. The grey
// side-label strip (insideX) is baked into the image's left, so a fraction/coord maps
// across [img.x+insideX, img.x+img.width], not the whole image width.
@@ -574,64 +590,116 @@
}, o.at)
: 0.5;
x = img.x + insideX + frac * (img.width - insideX);
}
return { x, y: row.y + row.height / 2 };
}
// Hover an item to raise its mouseover tooltip (real mousemove -> the browser's own
// tooltip). Two ways to place the cursor:
// IDENTITY `item:` / `title:` / `value:` -> name the item (lands on the right ROW).
// POSITION `at:` (genomic coord) / `frac:` (0..1) / `x:` (raw px) -> a point.
async function mouseover(o) {
if (typeof o === 'string') o = { track: o };
o = o || {};
const t = o.track; if (!t) throw new Error('mouseover: needs a track');
const want = o.item ?? o.title ?? o.value; // identity mode if any is set
- const { x, y } = (want != null)
+ const spot = (want != null)
? await itemXY(t, want, o.title != null && o.item == null && o.value == null)
: await posXY(t, o);
- // Raise a FRESH tooltip for THIS item. Two waits, both on the tooltip's actual state
- // rather than on a duration: park over the grey side-label strip (no items there) until
- // the previous tooltip is GONE, then hover the item until a tooltip is up whose content
- // differs from the one we just dismissed. Sleeping instead only looks right -- with the
- // dwells trimmed (FAST) a back-to-back pinned mouseover captured the PREVIOUS item's
- // text, and even at full pace that was a race waiting to be lost.
+ const { x, y } = spot;
+ // Raise a FRESH tooltip for THIS item, and be sure it IS this item's. The browser shows
+ // a tooltip on MOUSEENTER after a 500ms delay and hides it 500ms after mouseleave
+ // (hg/js/utils.js addMouseover), so while the cursor glides in it crosses other items
+ // and any of THEIR tooltips can still be on screen when we arrive -- an Alignment
+ // Differences item recorded as "identical" (the neighbouring aligned block, lingering in
+ // its grace period) when the item under the cursor reads "mismatch C->T". Waiting for
+ // "some tooltip is visible" is therefore not enough: when we know the item's own text
+ // (from its map box) we wait for exactly that.
const tipHtml = () => page.evaluate(() => {
const c = document.getElementById('mouseoverContainer');
if (!c || !c.offsetWidth) return null;
const st = getComputedStyle(c);
return (st.display === 'none' || st.visibility === 'hidden') ? null : c.innerHTML;
});
const prevTip = await tipHtml();
await page.mouse.move(2, y); cur.x = 2; cur.y = y;
if (prevTip) await page.waitForFunction(() => {
const c = document.getElementById('mouseoverContainer');
if (!c || !c.offsetWidth) return true;
const st = getComputedStyle(c);
return st.display === 'none' || st.visibility === 'hidden';
}, null, { timeout: 2000 }).catch(() => {});
await sleep(60);
await glide(x, y);
// small jiggle so the mousemove handler definitely fires and positions the tooltip
await page.mouse.move(x + 1, y); await sleep(60); await page.mouse.move(x, y);
+ const shown = () => page.evaluate(() => {
+ const c = document.getElementById('mouseoverContainer');
+ if (!c || !c.offsetWidth) return null;
+ const st = getComputedStyle(c);
+ if (st.display === 'none' || st.visibility === 'hidden') return null;
+ return (c.textContent || '').replace(/\s+/g, ' ').trim();
+ });
+ const wantTip = spot.tip || null;
+ if (wantTip) {
+ // The item's own tooltip, or nothing. A neighbour's tooltip lingering from the glide
+ // fails this test, so we keep waiting until the 500ms show timer fires for OUR item.
+ // Compared with ALL whitespace removed: the title's markup (`<b>rsID</b>: ...`, `<br>`)
+ // leaves no whitespace at all in textContent, so any tag-to-space normalisation would
+ // never match and the wait would just burn its timeout on a tooltip that was right.
+ await page.waitForFunction(w => {
+ const c = document.getElementById('mouseoverContainer');
+ if (!c || !c.offsetWidth) return false;
+ const st = getComputedStyle(c);
+ if (st.display === 'none' || st.visibility === 'hidden') return false;
+ const flat = z => z.replace(/\s+/g, '');
+ // A distinctive PREFIX, not the whole string: the head of a mouseOver carries the
+ // item's identity (its name/HGVS), while the tail can render differently from the
+ // title it came from (entities, stars, a max-width span). Short tips match whole.
+ return flat(c.textContent || '').includes(flat(w).slice(0, 60));
+ }, wantTip, { timeout: 4000 }).catch(() => {});
+ const flat = z => (z || '').replace(/\s+/g, '');
+ if (process.env.DOCENT_ROWS && !flat(await shown()).includes(flat(wantTip).slice(0, 60)))
+ console.warn(` WARNING: mouseover ${t} "${want}": tooltip never showed its own text\n`
+ + ` want: ${JSON.stringify(flat(wantTip).slice(0, 70))}\n`
+ + ` got : ${JSON.stringify(flat(await shown()).slice(0, 70))}`);
+ } else {
await page.waitForFunction(prev => {
const c = document.getElementById('mouseoverContainer');
if (!c || !c.offsetWidth) return false;
const st = getComputedStyle(c);
if (st.display === 'none' || st.visibility === 'hidden') return false;
return prev == null || c.innerHTML !== prev;
}, prevTip, { timeout: 3000 }).catch(() => {});
+ }
+ // A POSITIONAL hover has no expected text to wait for, so the best it can do is let the
+ // tooltip settle: the content stops changing once the cursor is parked, so sample until
+ // two reads agree. (A pinned positional hover therefore still records whatever is under
+ // the point -- `frac: 0.5` can land between two features and report the block they sit
+ // in. Name the item when the figure depends on which tooltip it is.)
+ if (!wantTip) {
+ let settled = await tipHtml();
+ for (let i = 0; i < 15; i++) {
+ await sleep(80);
+ const now = await tipHtml();
+ if (now && now === settled) break;
+ settled = now;
+ }
+ }
+ if (process.env.DOCENT_ROWS)
+ console.log(` tip at (${Math.round(x)},${Math.round(y)}): `
+ + JSON.stringify(((await shown()) || '').slice(0, 90)));
// 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).
@@ -995,60 +1063,81 @@
const navDone = page.waitForNavigation({ waitUntil: 'load', timeout: 30000 }).catch(() => {});
await clickGlide((await page.locator('#goButton').count()) ? '#goButton' : '.jwGoButtonContainer');
await navDone;
}
// A unique hit lands on the track image; a term with no suggestion and several
// matches lands on the search-results page instead, which has no #imgTbl -- that's
// legal, the script can `click` a result from there.
await page.waitForSelector('#imgTbl', { timeout: 15000 }).catch(() => {});
await captureState();
await page.mouse.move(cur.x, cur.y); // re-show the cursor overlay
if (o.shot) { await shot(o.shot); return; }
break;
}
case 'hide': if (arg === 'all' || arg === true) { await clickGlide('#hgt\\.hideAll'); await page.waitForSelector('#imgTbl'); } break;
case 'track': {
- const entries = Object.entries(arg);
- const named = new Set(entries.map(([n]) => n)); // what the author spelled out
+ let entries = Object.entries(arg);
+ const isKidHide = ([, mode]) => String(mode).toLowerCase() === 'hidekids';
+ // What the author spelled out AS A VISIBILITY. A `hideKids` container is deliberately
+ // NOT in here: it names no mode of its own, so it must not suppress the container
+ // variable derived from a child below it (`pubtator: pack` is what turns varsInPubs on).
+ const named = new Set(entries.filter(e => !isKidHide(e)).map(([n]) => n));
const idx = await tdbIndex(state.db);
+ // `hideKids` is not a visibility -- it is "hide everything under this container", so
+ // that a child named alongside it is left the only one drawn. A superTrack needs it:
+ // unlike a composite, its own mode does NOT reach its children, so every child comes
+ // up at its own trackDb visibility and an earlier `hide: all` does not stick
+ // (`{varsInPubs: show}` alone draws all eight of its members). The expansion skips any
+ // child the step names itself, and runs in a round of its own AFTER the rest, because
+ // a subtrack hide travelling in the same request as its container can be dropped by
+ // the cart (#37953) -- so the container goes on first and the hides follow.
+ const kidHides = [];
+ for (const e of entries.filter(isKidHide)) {
+ const leaves = (await tdbLeaves(e[0])).filter(k => !named.has(k));
+ if (!leaves.length)
+ console.warn(`track ${e[0]}: hideKids -- trackDb gives it no children to hide`);
+ for (const k of leaves) kidHides.push([k, 'hide']);
+ }
+ entries = entries.filter(e => !isKidHide(e));
// Visible gesture first: drive the real track-controls dropdowns so the mouse is
// seen turning the tracks on. State is still applied by the nav()s below (which
// carry the container/checkbox vars too), so these opens are non-committing.
if (doc.trackAnim !== false)
for (const [name, mode] of entries) {
const csel = await ctrlSelect(name);
if (csel) await openSelectVisible(csel, mode, 6, false);
}
// hgTracks RESHAPES a composite when its container visibility changes, and that wipes
// per-subtrack overrides arriving in the same request (`clinvar=pack&clinvarCnv=hide`
// leaves clinvarCnv_sel=1 and the CNV row still drawn). So a step that names both a
// composite and something under it is applied in rounds -- container first, then the
// deviations -- which is exactly what writing them as two steps does. superTracks
// don't reshape, so they don't force a round.
const rounds = new Map();
for (const e of entries) {
let d = 0;
for (let k = e[0]; ;) {
const n = idx && idx.get(k);
if (!n || !n.parent) break;
const p = idx.get(n.parent);
if (named.has(n.parent) && !(p && p.superTrack)) d++;
k = n.parent;
}
if (!rounds.has(d)) rounds.set(d, []);
rounds.get(d).push(e);
}
+ if (kidHides.length) rounds.set(Number.MAX_SAFE_INTEGER, kidHides);
for (const d of [...rounds.keys()].sort((a, b) => a - b)) {
const vars = new Map();
for (const [name, mode] of rounds.get(d))
// A derived variable never overrides one the step names itself, whatever the
// order: `{clinvar: pack, clinvarCnv: hide}` keeps clinvar=pack.
for (const [k, v] of await visVars(name, mode))
if (k === name || !named.has(k)) vars.set(k, v);
const parts = [...vars].map(([k, v]) => `${k}=${v}`);
console.log('track:', parts.join(' ')); // what trackDb turned the step into
await nav(`/cgi-bin/hgTracks?db=${state.db}&position=${enc(state.position)}&${parts.join('&')}&pix=${PIX}`);
}
break;
}
case 'convert': await convert(arg); break;
case 'hub': {