6b2ef5505a6121a2343661bea48ec63d4bdb7477
braney
  Wed Sep 9 09:24:32 2026 -0700
docent: itemXY picked another track's item when this track had none

`mouseover: {track: X, item: "n"}` and `click: {track: X, item: "n"}` both resolve
the name through itemXY, which gathered every map box on the PAGE carrying that
name and then did:

const inBand = cands.filter(h => h.inBand);
const pick = (inBand[0] || cands[0]) || null;

so when track X had no box of its own, `cands[0]` handed back a box belonging to
some other track. Nothing warned. The step passed, the tooltip that came up was
real, and it was the wrong row.

This is not hypothetical. A hub track that declares more bigBed fields than its
file has draws no items at all (#38310). A probe hub with eight tracks over one
file, four of them drawing nothing, reported all eight as drawing uc.1, because
hg38 and the three tracks that do work use that name too. rm35920 had been
reading hg38's native `ultras` rather than its own fixture hub for as long as it
existed, and looked green the whole time. An answer that is wrong but reads as a
pass is worse than a failure.

Candidates are now scoped by MAP NAME, which is how areaXY and itemXY's own error
message already picked a row: hgTracks names each map after the track it draws
(map_data_<key>, map_center_<key>), so the test is exact. The y-band survives only
as the tie-break between several boxes OF THIS TRACK that share a name -- geometry
was never a safe primary test, since a packed row stacks items above and below its
middle and a quickLift target puts them outside the band altogether.

The one case that still gets to decide by geometry is a page where NO map can be
attributed to this key at all, i.e. hgTracks named it something we do not
recognise. `anyMine` keeps the old behavior there rather than turning an
unrecognised name into a hard failure.

When the lookup now fails, the error says where the name actually was, which is
the sentence that would have saved the most time here:

item "uc.1" not found in track "pT5" ... 1 map box(es) in that row, addressable
as: title: "Click to alter the display density of pT5". That name IS on this
page, in hub_195310_pT1, hub_195310_pT2, hub_195310_pT8 -- another track's box
is never used for this one

Checked against that probe hub: the three tracks that really draw uc.1 still pass,
the ones that draw nothing now fail with the message above. Both suites are green,
tests/ (15) and tests/regress/.

refs #38252, refs #38310

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 b5037a27a19..cc0bedd6c42 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -688,75 +688,110 @@
     if (!key) throw new Error(`track "${t}" not shown (no #img_data_ for ${cands.join(', ')})`);
     const img = await page.locator(`#img_data_${key}`).first().boundingBox({ timeout: 8000 }).catch(() => null);
     const row = await page.locator(`#imgTbl tr#tr_${key}`).first().boundingBox({ timeout: 8000 }).catch(() => null);
     if (!img || !row) throw new Error(`track "${t}" not shown (need #img_data_${key} + #tr_${key})`);
     // Everything hgTracks reports about the image -- map-box coords, mouseOver spans,
     // insideX -- is in the pixels of the image the SERVER drew, which is not the size the
     // page shows it at when the image is scaled (SCALE). imgPx is that ratio, so those
     // numbers can be turned into page coordinates: 1 normally, 1/SCALE for a print render.
     const imgPx = await page.evaluate(k => {
       const im = document.getElementById('img_data_' + k);
       return im && im.naturalWidth ? im.getBoundingClientRect().width / im.naturalWidth : 1;
     }, key);
     return { key, img, row, imgPx: imgPx || 1 };
   }
   // 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).
+  // pick the map <area> whose href(&i=<name>)/title carries the name AND that belongs to
+  // THIS track's own map; fall back to the JSON mouseOver spans (wig/dense tracks, no
+  // per-item href).
+  //
+  // Belonging is decided by MAP NAME, not by geometry, the same way areaXY and the error
+  // message below decide it. hgTracks names each map after the track it draws
+  // (map_data_<key>, map_center_<key>), so the test is exact. A y-band test is not: a
+  // packed row stacks items above and below its middle, and on a quickLift target the band
+  // drops boxes that really are in the row. The band is kept only to choose between
+  // several boxes OF THIS TRACK that carry the same name.
+  //
+  // A box belonging to another track is never picked, however well it matches. That
+  // fallback used to be here -- `inBand[0] || cands[0]` -- and it answered with a
+  // neighbouring track's item, silently, whenever this track had none of its own. A hub
+  // track that declares more bigBed fields than its file has draws no items at all
+  // (#38310), and a probe for one reported the item of a native track that happens to use
+  // the same item names, so three tracks that draw nothing were recorded as drawing.
+  // rm35920 read hg38's native `ultras` for years the same way. An answer that is wrong
+  // but reads as a pass is worse than a failure, so this now returns nothing and lets the
+  // error below say where the name really was.
   async function itemXY(t, want, titleOnly) {
     const { key, img, row, imgPx } = await trackBox(t);
     const band = { top: row.y, bot: row.y + row.height };
-    const area = await page.evaluate(({ want, titleOnly, band, imgBox }) => {
+    const area = await page.evaluate(({ want, titleOnly, band, imgBox, key }) => {
+      // Does this map hold the pixels of the track we were asked about?
+      const isMine = m => {
+        const nm = (m && m.getAttribute('name')) || '';
+        return nm === `map_${key}` || nm.endsWith(`_${key}`);
+      };
+      // When NOTHING on the page can be attributed to this key, hgTracks named the map
+      // something we do not recognise. Only then does geometry get to decide, which is
+      // what this did for every track before.
+      const anyMine = [...document.querySelectorAll('map[name^="map_"]')].some(isMine);
       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')
                    || 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;
         // coords are in the drawn image's own pixels; s converts them to page pixels
         const s = (r && im.naturalWidth) ? r.width / im.naturalWidth : 1;
         const cx = ox + s * (c[0] + c[2]) / 2, cy = oy + s * (c[1] + c[3]) / 2;
         // 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>`, `&#9733;`) 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);
-      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) {
+        cands.push({ cx, cy, href, tip, map: nm || '', mine: isMine(m),
+                     inBand: cy >= band.top - 1 && cy <= band.bot + 1 });
+      }
+      // This track's boxes only, unless the page names no map after this track at all.
+      const pool = anyMine ? cands.filter(h => h.mine) : cands.filter(h => h.inBand);
+      const pick = pool.find(h => h.inBand) || pool[0] || null;
+      // The tracks a matching box was found in that are NOT this one, so a failure can say
+      // where the name actually is instead of leaving the reader to guess.
+      const elsewhere = [...new Set(cands.filter(h => !h.mine)
+                                         .map(h => h.map.replace(/^map_(data_|center_)?/, ''))
+                                         .filter(Boolean))];
+      return { pick, n: cands.length, nPool: pool.length, anyMine, elsewhere,
+               all: pool.map(c => Math.round(c.cx)) };
+    }, { want: String(want), titleOnly: !!titleOnly, band, imgBox: img, key });
+    if (area && area.pick) {
+      const p = area.pick;
       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 };
+        console.log(`  item "${want}" in ${t}: ${area.n} map box(es) match, ${area.nPool} of them `
+                  + `${area.anyMine ? "this track's" : 'in the row (no map names this track)'}, `
+                  + `centers x=[${area.all}] -> hovering (${Math.round(p.cx)},${Math.round(p.cy)})`
+                  + (p.tip ? `, expecting tip "${p.tip.slice(0, 40)}"` : ''));
+      return { x: p.cx, y: p.cy, href: p.href, tip: p.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) {
       // Say WHAT is there instead. An item name that has gone missing is usually a track
       // whose items depend on the pixel width -- a print render (SCALE) draws a wider image,
       // so features hgTracks merged into one box at screen width come apart into several
       // with names of their own -- and the fix is to pick from the names that do exist.
@@ -788,39 +823,49 @@
             const tmp = document.createElement('div');
             tmp.innerHTML = a.getAttribute('title') || a.getAttribute('data-tooltip') || '';
             const tip = (tmp.textContent || '').replace(/\s+/g, ' ').trim();
             if (tip) out.push(`title: ${JSON.stringify(tip)}`);
           }
         }
         const boxes = maps.reduce((k, m) => k + m.querySelectorAll('area').length, 0);
         return { names: [...new Set(out)], boxes };
       }, { key }).catch(() => ({ names: [], boxes: 0 }));
       const win = await page.evaluate(() => {
         try { return `${hgTracks.chromName}:${hgTracks.winStart}-${hgTracks.winEnd}`; }
         catch (_) { return '?'; }
       }).catch(() => '?');
       const names = near.names;
       const show = process.env.DOCENT_ROWS ? names : names.slice(0, 12);
+      // Where the name DID turn up. Without this the reader sees only "not found here" and
+      // has no way to tell an item that is missing from one that is sitting in the track
+      // next door -- which is the case that used to be answered silently and wrongly, so
+      // it is the one worth naming. See the comment on itemXY.
+      const other = (area && area.elsewhere && area.elsewhere.length) ? area.elsewhere : [];
+      const alsoIn = other.length
+        ? `. That name IS on this page, in ${other.slice(0, 6).join(', ')}`
+          + `${other.length > 6 ? `, ... (${other.length} tracks)` : ''}`
+          + ` -- another track's box is never used for this one`
+        : '';
       // The BOX count matters as much as the names. A type-bigBed-3 row has plenty of
       // items and no way to name any of them -- 26 boxes collapsing to two distinct
       // titles -- and the next thing to try there is a positional `click: {frac: ...}`,
       // not a different name. Saying only "in that row: two titles" hides that.
       throw new Error(`item "${want}" not found in track "${t}" (searched map-box areas + `
         + `mouseOver spans). Window ${win}. ${near.boxes} map box(es) in that row`
         + `${names.length ? `, addressable as: ${show.join(', ')}`
           + `${show.length < names.length ? `, ... (${names.length} distinct; DOCENT_ROWS=1 for all)` : ''}`
-          : ' and none of them carries a name or a title'}`);
+          : ' and none of them carries a name or a title'}${alsoIn}`);
     }
     return { x: img.x + imgPx * (span.x1 + span.x2) / 2, y: row.y + row.height / 2, href: null };
   }
   // A POSITIONAL point plus the hgc link of the map box nearest it. `click:` needs this
   // because some tracks have no item that can be named at all: every subtrack of GIAB
   // Problematic Regions is `type bigBed 3`, so its hgc hrefs carry an EMPTY `i=` and every
   // box's title is "Start of Exon (1/1)" -- neither `item:` nor `title:` can pick one, and
   // a raw mouse click on the data area is swallowed by hgTracks' drag-select handler. So
   // place the point the way posXY does and follow the box under (or nearest) it.
   //
   // Nearest rather than strictly containing, because y is the row's middle and a packed
   // row stacks its items above and below that line.
   async function areaXY(t, o) {
     const { key } = await trackBox(t);
     const { x, y } = await posXY(t, o);