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) 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 whose href(&i=)/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 (``, `★`) 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 (`rsID: ...`, `
`) + // 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': {