ba3e92274483227d4969e7f9d89e6cd507e2bfb3
braney
  Fri Jul 31 14:46:55 2026 -0700
Docent: drag takes a genomic range instead of from/to endpoints, refs #37892

The usual form is now one region and it zooms:

drag: chr7:155,806,100-155,806,557

For any other action, or to pass shot:/track: as well, the region goes
under range: in the map form:

drag: {range: "chr7:155,806,100-155,806,557", shot: dragselect, then: highlight}

A malformed range is reported at the drag step rather than failing later
in the sweep.  from:/to: still parse, so existing scripts keep working,
and the fraction (fromFrac:/toFrac:) and pixel (fromX:/toX:) endpoints are
untouched since neither has a genomic range to express.

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 5c27819655b..a2fd5ba8914 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -676,38 +676,50 @@
     const p = path.join(STILLDIR, name + '.png');
     const clip = await pg2.evaluate(() => {
       const im = document.getElementById('imgTbl'); if (!im) return null;
       const els = [im, ...document.querySelectorAll('.__pinnedTip')];
       let x = Infinity, y = Infinity, x2 = -Infinity, y2 = -Infinity;
       for (const o of els) { const r = o.getBoundingClientRect(); x = Math.min(x, r.left); y = Math.min(y, r.top); x2 = Math.max(x2, r.right); y2 = Math.max(y2, r.bottom); }
       return { x: Math.max(0, x - 4), y: Math.max(0, y - 4), width: (x2 - x) + 8, height: (y2 - y) + 8 };
     });
     if (clip) await pg2.screenshot({ path: p, clip });
     else await pg2.locator('#imgTbl').screenshot({ path: p });
     await ctx2.close();
     console.log('SHOT', p, `(pinned: ${pinnedTips.length})`);
     pinnedTips.length = 0;   // consume the set
   }
   // Shift+drag across the track image to open the browser's own drag-select dialog
-  // ("Zoom In / Single Highlight / ..."), then act on it. Endpoints are given as
-  // genomic coords (from:/to:), a fraction across the view (fromFrac:/toFrac:) or a
-  // raw pixel (fromX:/toX:). Optional `track:` picks the row the drag runs over (y);
-  // default is the middle of the image. `shot:` captures the open dialog (e.g. the
-  // Figure 1A drag-select box). `then:` = zoom (default, clicks Zoom In) | highlight
-  // (Single Highlight) | cancel (Escape, leaves the view unchanged).
+  // ("Zoom In / Single Highlight / ..."), then act on it. The usual form gives one
+  // genomic region and zooms:  drag: chr7:155,806,100-155,806,557
+  // Any other action needs the map form, which is also how you pass shot:/track:
+  //   drag: {range: "chr7:155,806,100-155,806,557", then: highlight}
+  // Endpoints that are not genomic coords use a fraction
+  // across the view (fromFrac:/toFrac:) or a raw pixel (fromX:/toX:) instead.
+  // Optional `track:` picks the row the drag runs over (y); default is the middle of
+  // the image. `shot:` captures the open dialog (e.g. the Figure 1A drag-select box).
+  // `then:` = zoom (default, clicks Zoom In) | highlight (Single Highlight) | cancel
+  // (Escape, leaves the view unchanged).
   async function drag(o) {
+    // A bare string is the region; `range:` is the same thing with room for other
+    // keys. Both expand to the from:/to: endpoints the rest of this function uses.
+    if (typeof o === 'string') o = { range: o };
     o = o || {};
+    if (o.range != null) {
+      const m = String(o.range).match(/^\s*(.+):([\d,]+)\s*-\s*([\d,]+)\s*$/);
+      if (!m) throw new Error(`drag: range "${o.range}" is not chrom:start-end`);
+      o = Object.assign({}, o, { from: `${m[1]}:${m[2]}`, to: `${m[1]}:${m[3]}` });
+    }
     const img = await page.locator('img[id^="img_data_"]').first().boundingBox({ timeout: 8000 }).catch(() => null);
     const tbl = await page.locator('#imgTbl').first().boundingBox({ timeout: 8000 }).catch(() => null);
     if (!img || !tbl) throw new Error('drag: track image not shown (need #imgTbl)');
     const coordFrac = at => page.evaluate(a => {
       try { const s = hgTracks.winStart, e = hgTracks.winEnd;
         const c = +String(a).replace(/.*:/, '').replace(/,/g, '');
         return Math.max(0, Math.min(1, (c - s) / (e - s))); } catch (_) { return null; }
     }, at);
     // The grey side-label strip is baked into the LEFT of every full-width track
     // image, so the genomic data area starts insideX px in — fractions/coords map
     // across [img.x+insideX, img.x+img.width], not the whole image width.
     const insideX = await page.evaluate(() => { try { return hgTracks.insideX || 0; } catch (_) { return 0; } });
     const dataLeft = img.x + insideX, dataW = Math.max(1, img.width - insideX);
     const endX = async (px, fr, coord) => {
       if (px != null) return img.x + Number(px);