@jbrowse/capture 5.0.0-beta.11 → 5.0.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/capture.js CHANGED
@@ -1,23 +1,19 @@
1
1
  import { mkdirSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
+ import { assertValidAnnotations } from './annotationSpec.js';
4
+ import { drawAnnotations } from './annotations.js';
3
5
  import { isBrowserConsoleNoise, launchBrowser } from './browser.js';
4
- import { resolveAgainstConfig } from './catalog.js';
6
+ import { canonicalSessionAssembly, resolveAgainstConfig } from './catalog.js';
5
7
  import { assertImagePath } from './imagePath.js';
6
8
  import { assertSupportedInstance } from './instanceVersion.js';
7
9
  import { DEFAULT_TIMEOUT } from './poll.js';
8
10
  import { waitForFrame, waitForJBrowseReady } from './ready.js';
9
11
  import { assemblyFromSession, savedSnapshot, trackIdsFromSession, } from './session.js';
10
12
  import { sessionOverflowInPage } from './sessionOverflow.js';
11
- import { PUBLIC_INSTANCE, assertSessionStandsAlone, jbrowseUrl } from './url.js';
12
- /**
13
- * Launch a browser, open a JBrowse session, and wait until it has rendered.
14
- * The caller owns the returned browser and must close it. The assembly and
15
- * tracks asked for become the session gate's expectations unless `trackIds`
16
- * names others.
17
- */
18
- export async function openJBrowse(options = {}) {
19
- const { width = 1400, height = 900, deviceScaleFactor = 2, headless, executablePath, args, onConsole, timeout = DEFAULT_TIMEOUT, trackIds, ...given } = options;
20
- assertSessionStandsAlone(given);
13
+ import { PUBLIC_INSTANCE, assertCoherentOptions, jbrowseUrl } from './url.js';
14
+ async function prepareOpen(options) {
15
+ const { width = 1400, height = 900, dpr = 2, headless, executablePath, args, onConsole, timeout = DEFAULT_TIMEOUT, trackIds, ...given } = options;
16
+ assertCoherentOptions(given);
21
17
  const [urlOptions] = await Promise.all([
22
18
  resolveAgainstConfig(given),
23
19
  assertSupportedInstance(given.instance ?? PUBLIC_INSTANCE),
@@ -27,10 +23,31 @@ export async function openJBrowse(options = {}) {
27
23
  ...urlOptions,
28
24
  sessionName: urlOptions.sessionName ?? 'Screenshot',
29
25
  });
30
- const browser = await launchBrowser({ headless, executablePath, args });
26
+ const { spec, session, assembly, hub, tracks } = urlOptions;
27
+ const opens = spec ?? (session && savedSnapshot(session));
28
+ const sessionAssembly = opens && assemblyFromSession(opens);
29
+ const expectedAssembly = opens
30
+ ? sessionAssembly &&
31
+ (await canonicalSessionAssembly(urlOptions, sessionAssembly))
32
+ : (assembly ?? hub);
33
+ return {
34
+ url,
35
+ viewport: { width, height, deviceScaleFactor: dpr },
36
+ onConsole,
37
+ timeout,
38
+ ready: {
39
+ ...options,
40
+ assembly: expectedAssembly,
41
+ trackIds: trackIds ?? (opens ? trackIdsFromSession(opens) : tracks),
42
+ },
43
+ };
44
+ }
45
+ async function openPage(browser, { url, viewport, onConsole, timeout, ready, }) {
46
+ // a window each: a tab behind another stops painting, and a batch has
47
+ // several pages open
48
+ const page = await browser.newPage({ type: 'window' });
31
49
  try {
32
- const page = await browser.newPage();
33
- await page.setViewport({ width, height, deviceScaleFactor });
50
+ await page.setViewport(viewport);
34
51
  if (onConsole) {
35
52
  page.on('console', msg => {
36
53
  const text = msg.text();
@@ -41,50 +58,155 @@ export async function openJBrowse(options = {}) {
41
58
  page.on('pageerror', error => {
42
59
  onConsole(`uncaught ${error instanceof Error ? error.stack : error}`);
43
60
  });
61
+ page.on('requestfailed', request => {
62
+ const reason = request.failure()?.errorText;
63
+ if (reason !== 'net::ERR_ABORTED') {
64
+ onConsole(`request failed: ${request.url()} (${reason})`);
65
+ }
66
+ });
67
+ page.on('response', response => {
68
+ if (response.status() >= 400) {
69
+ onConsole(`HTTP ${response.status()}: ${response.url()}`);
70
+ }
71
+ });
44
72
  }
45
73
  // an app streaming track data may never reach networkidle; the session
46
74
  // gate is the signal that it is up
47
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
48
- const { spec, session, assembly, hub, tracks } = urlOptions;
49
- const opens = spec ?? (session && savedSnapshot(session));
50
- const report = await waitForJBrowseReady(page, {
51
- ...options,
52
- assembly: opens ? assemblyFromSession(opens) : (assembly ?? hub),
53
- trackIds: trackIds ?? (opens ? trackIdsFromSession(opens) : tracks),
75
+ const response = await page.goto(url, {
76
+ waitUntil: 'domcontentloaded',
77
+ timeout,
54
78
  });
55
- return { browser, page, url, ...report };
79
+ // 304: a page after the first in a batch comes from the shared cache
80
+ if (response && !response.ok() && response.status() !== 304) {
81
+ throw new Error(`${url} answered HTTP ${response.status()}. Check --instance.`);
82
+ }
83
+ const report = await waitForJBrowseReady(page, ready);
84
+ return { page, url, ...report };
56
85
  }
57
86
  catch (error) {
58
- await browser.close().catch(() => { });
87
+ await page.close().catch(() => { });
59
88
  throw error;
60
89
  }
61
90
  }
62
91
  /**
63
- * Open a JBrowse session, wait for it to render, screenshot it, and close the
64
- * browser.
92
+ * Launch a browser, open a JBrowse session, and wait until it has rendered.
93
+ * The caller owns the returned browser and must close it. The assembly and
94
+ * tracks asked for become the session gate's expectations unless `trackIds`
95
+ * names others.
65
96
  */
66
- export async function captureJBrowse(options = {}) {
67
- const { out, fullPage = false, ...openOptions } = options;
97
+ export async function openJBrowse(options = {}) {
98
+ const prepared = await prepareOpen(options);
99
+ const browser = await launchBrowser(options);
100
+ try {
101
+ return { browser, ...(await openPage(browser, prepared)) };
102
+ }
103
+ catch (error) {
104
+ await browser.close().catch(() => { });
105
+ throw error;
106
+ }
107
+ }
108
+ function assertCapturable({ out, annotations }) {
109
+ if (annotations) {
110
+ assertValidAnnotations(annotations);
111
+ }
68
112
  if (out) {
69
113
  assertImagePath(out);
70
114
  mkdirSync(dirname(out), { recursive: true });
71
115
  }
72
- const { browser, page, url, ...opened } = await openJBrowse(openOptions);
116
+ }
117
+ async function shoot(page, opened, { out, fullPage = false, annotations, ...openOptions }) {
118
+ let report = opened;
119
+ const overflow = fullPage ? await page.evaluate(sessionOverflowInPage) : 0;
120
+ const viewport = page.viewport();
121
+ if (viewport && overflow > 0) {
122
+ await page.setViewport({
123
+ ...viewport,
124
+ height: viewport.height + overflow,
125
+ });
126
+ report = await waitForFrame(page, openOptions);
127
+ }
128
+ if (annotations?.length) {
129
+ await drawAnnotations(page, annotations);
130
+ }
131
+ const image = await page.screenshot({ path: out });
132
+ return { image, ...report };
133
+ }
134
+ /**
135
+ * Open a JBrowse session, wait for it to render, screenshot it, and close the
136
+ * browser.
137
+ */
138
+ export async function captureJBrowse(options = {}) {
139
+ assertCapturable(options);
140
+ const { browser, page, url, ...opened } = await openJBrowse(options);
73
141
  try {
74
- let report = opened;
75
- const overflow = fullPage ? await page.evaluate(sessionOverflowInPage) : 0;
76
- const viewport = page.viewport();
77
- if (viewport && overflow > 0) {
78
- await page.setViewport({
79
- ...viewport,
80
- height: viewport.height + overflow,
81
- });
82
- report = await waitForFrame(page, openOptions);
142
+ return { url, ...(await shoot(page, opened, options)) };
143
+ }
144
+ finally {
145
+ await browser.close().catch(() => { });
146
+ }
147
+ }
148
+ /**
149
+ * Screenshot many sessions from one browser, each on a page of its own that is
150
+ * closed after its screenshot. A failed capture is reported in its result and
151
+ * the rest carry on; a browser that died is relaunched for the captures left.
152
+ * Results come back in the order of `captures`.
153
+ */
154
+ export async function captureBatch(captures, { concurrency = 4, onResult, ...launch } = {}) {
155
+ for (const capture of captures) {
156
+ assertCapturable(capture);
157
+ }
158
+ const results = [];
159
+ let browser;
160
+ // chained, so workers that find no live browser share one launch
161
+ const liveBrowser = () => {
162
+ const launched = () => launchBrowser(launch);
163
+ browser = browser
164
+ ? browser.then(b => (b.connected ? b : launched()), launched)
165
+ : launched();
166
+ return browser;
167
+ };
168
+ let next = 0;
169
+ const worker = async () => {
170
+ while (next < captures.length) {
171
+ const index = next++;
172
+ const capture = captures[index];
173
+ const start = performance.now();
174
+ let result;
175
+ let page;
176
+ try {
177
+ const prepared = await prepareOpen(capture);
178
+ const opened = await openPage(await liveBrowser(), prepared);
179
+ page = opened.page;
180
+ const { url } = opened;
181
+ result = {
182
+ ok: true,
183
+ out: capture.out,
184
+ url,
185
+ ...(await shoot(page, opened, capture)),
186
+ ms: performance.now() - start,
187
+ };
188
+ }
189
+ catch (error) {
190
+ result = {
191
+ ok: false,
192
+ out: capture.out,
193
+ error: error instanceof Error ? error : new Error(String(error)),
194
+ ms: performance.now() - start,
195
+ };
196
+ }
197
+ finally {
198
+ await page?.close().catch(() => { });
199
+ }
200
+ results[index] = result;
201
+ onResult?.(result, index);
83
202
  }
84
- const image = await page.screenshot({ path: out });
85
- return { url, image, ...report };
203
+ };
204
+ try {
205
+ await Promise.all(Array.from({ length: Math.min(concurrency, captures.length) }, worker));
86
206
  }
87
207
  finally {
88
- await browser.close();
208
+ const launched = await browser?.catch(() => undefined);
209
+ await launched?.close().catch(() => { });
89
210
  }
211
+ return results;
90
212
  }
package/esm/catalog.d.ts CHANGED
@@ -10,10 +10,6 @@ export interface Catalog {
10
10
  assembly?: CatalogAssembly;
11
11
  tracks?: Track[];
12
12
  }
13
- /**
14
- * The canonical name of the assembly `input` names by name or alias, ignoring
15
- * case: the name the app's census publishes.
16
- */
17
13
  export declare function resolveAssemblyName(catalog: Catalog, input: string): string;
18
14
  /**
19
15
  * The trackId `input` names: an exact trackId, then the id with the assembly
@@ -25,8 +21,17 @@ export declare function resolveTrackId(tracks: Track[], input: string, assemblyN
25
21
  /**
26
22
  * The options with the assembly and tracks checked against the config and
27
23
  * spelled the way it spells them, so a typo fails before a browser launches.
28
- * A config this cannot fetch passes through for the app to judge, and so does
29
- * a spec or saved session, which may declare tracks no config lists.
24
+ * A `--config` this cannot fetch passes through for the app to judge, and so
25
+ * does a spec or saved session, which may declare tracks no config lists. A
26
+ * hosted `hub` that fails to fetch throws, since genomes.jbrowse.org either has
27
+ * the assembly or the name is wrong.
30
28
  */
31
29
  export declare function resolveAgainstConfig(options: JBrowseUrlOptions): Promise<JBrowseUrlOptions>;
30
+ /**
31
+ * The assembly a spec or saved session names, spelled the way the config does,
32
+ * since the app's census publishes the config's name and a spec may use an
33
+ * alias. A name the config does not list stays as given: a spec can declare
34
+ * its own assemblies.
35
+ */
36
+ export declare function canonicalSessionAssembly(options: JBrowseUrlOptions, name: string): Promise<string>;
32
37
  export {};
package/esm/catalog.js CHANGED
@@ -5,12 +5,16 @@ const assembliesOf = (catalog) => catalog.assemblies ?? (catalog.assembly ? [cat
5
5
  * The canonical name of the assembly `input` names by name or alias, ignoring
6
6
  * case: the name the app's census publishes.
7
7
  */
8
- export function resolveAssemblyName(catalog, input) {
9
- const assemblies = assembliesOf(catalog);
8
+ const findAssembly = (catalog, input) => {
10
9
  const target = input.toLowerCase();
11
- const found = assemblies.find(a => [a.name, ...(a.aliases ?? [])].some(n => n.toLowerCase() === target));
10
+ return assembliesOf(catalog).find(a => [a.name, ...(a.aliases ?? [])].some(n => n.toLowerCase() === target));
11
+ };
12
+ export function resolveAssemblyName(catalog, input) {
13
+ const found = findAssembly(catalog, input);
12
14
  if (!found) {
13
- const names = assemblies.map(a => a.name).join(', ');
15
+ const names = assembliesOf(catalog)
16
+ .map(a => a.name)
17
+ .join(', ');
14
18
  throw new Error(`assembly "${input}" is not in the config, which has ${names || 'none'}`);
15
19
  }
16
20
  return found.name;
@@ -52,24 +56,33 @@ export function resolveTrackId(tracks, input, assemblyName) {
52
56
  : '';
53
57
  throw new Error(`--track "${input}" is not in the config${hint}`);
54
58
  }
59
+ async function fetchCatalog({ hub, config, instance, }) {
60
+ const url = config
61
+ ? new URL(config, instanceUrl(instance)).href
62
+ : hub
63
+ ? hubUrl(hub)
64
+ : undefined;
65
+ if (!url) {
66
+ return undefined;
67
+ }
68
+ const fetched = fetchJson(url, config ? 'config' : `hub "${hub}"`);
69
+ return (await (config ? fetched.catch(() => undefined) : fetched));
70
+ }
55
71
  /**
56
72
  * The options with the assembly and tracks checked against the config and
57
73
  * spelled the way it spells them, so a typo fails before a browser launches.
58
- * A config this cannot fetch passes through for the app to judge, and so does
59
- * a spec or saved session, which may declare tracks no config lists.
74
+ * A `--config` this cannot fetch passes through for the app to judge, and so
75
+ * does a spec or saved session, which may declare tracks no config lists. A
76
+ * hosted `hub` that fails to fetch throws, since genomes.jbrowse.org either has
77
+ * the assembly or the name is wrong.
60
78
  */
61
79
  export async function resolveAgainstConfig(options) {
62
- const { hub, config, assembly, tracks, spec, session, instance } = options;
80
+ const { hub, assembly, tracks, spec, session } = options;
63
81
  const wanted = assembly ?? hub;
64
- const url = config
65
- ? new URL(config, instanceUrl(instance)).href
66
- : hub
67
- ? hubUrl(hub)
68
- : undefined;
69
- if (spec || session || !url || (!wanted && !tracks?.length)) {
82
+ if (spec || session || (!wanted && !tracks?.length)) {
70
83
  return options;
71
84
  }
72
- const catalog = (await fetchJson(url, 'config').catch(() => undefined));
85
+ const catalog = await fetchCatalog(options);
73
86
  if (!catalog) {
74
87
  return options;
75
88
  }
@@ -82,3 +95,13 @@ export async function resolveAgainstConfig(options) {
82
95
  tracks: tracks?.map(t => resolveTrackId(catalog.tracks ?? [], t, assemblyName ?? '')),
83
96
  };
84
97
  }
98
+ /**
99
+ * The assembly a spec or saved session names, spelled the way the config does,
100
+ * since the app's census publishes the config's name and a spec may use an
101
+ * alias. A name the config does not list stays as given: a spec can declare
102
+ * its own assemblies.
103
+ */
104
+ export async function canonicalSessionAssembly(options, name) {
105
+ const catalog = await fetchCatalog(options);
106
+ return (catalog && findAssembly(catalog, name)?.name) ?? name;
107
+ }
@@ -0,0 +1,4 @@
1
+ import type { AnnotationAnchor } from './annotationOverlay.ts';
2
+ import type { ViewportRect } from './graphAnchor.ts';
3
+ import type { Page } from 'puppeteer';
4
+ export declare function dotplotAnchorRect(page: Page, anchor: AnnotationAnchor): Promise<ViewportRect | undefined>;
@@ -0,0 +1,104 @@
1
+ // Where a DotplotView drew a pair of loci, in viewport CSS px.
2
+ //
3
+ // The LGV sibling of this is `getHighlightCoords`, which the overlay calls from
4
+ // page context (`annotationOverlay.ts`'s modelRect). A dotplot cannot be reached
5
+ // that way for the same reason a graph cannot: the plot is one canvas with no
6
+ // element per feature, so a callout on a block would be a raw viewport
7
+ // coordinate — correct only for the width, the assembly order and the zoom it
8
+ // was measured against, with nothing to say when one of those moved. This is the
9
+ // `graphAnchor.ts` treatment: resolve it out here, against the live model.
10
+ //
11
+ // Two axes, so an anchor names up to two loci. `hLoc` is the horizontal
12
+ // (`hview`) axis and `vLoc` the vertical one; either may be omitted, and the
13
+ // omitted axis spans the whole plot — which is what a callout on "this
14
+ // chromosome, whatever it pairs with" wants. Naming neither is not a dotplot
15
+ // anchor at all, and `annotations.ts` never routes one here.
16
+ //
17
+ // The vertical axis is INVERTED against the layout: a dotplot's origin is the
18
+ // bottom-left, so a base's screen y is `viewHeight - alongPx`, the same flip the
19
+ // model's own `pxToBp` round trip makes (`DotplotView/model.ts`, the rubberband
20
+ // region). Reading the layout offset straight through as y puts every callout on
21
+ // the wrong half of the plot, mirrored about the middle — which looks like a
22
+ // plausible position rather than like a bug.
23
+ //
24
+ // Returns undefined when the view, an axis, the canvas or a refName isn't there,
25
+ // so the caller fails the spec by name rather than parking a callout at (0,0).
26
+ export async function dotplotAnchorRect(page, anchor) {
27
+ const path = Array.isArray(anchor.view) ? anchor.view : [anchor.view ?? 0];
28
+ return page.evaluate((viewPath, h, v) => {
29
+ let view = window
30
+ .JBrowseSession;
31
+ for (const i of viewPath) {
32
+ view = view?.views?.[i];
33
+ }
34
+ const hview = view?.hview;
35
+ const vview = view?.vview;
36
+ if (!view || !hview || !vview) {
37
+ return undefined;
38
+ }
39
+ // The canvas IS the plot area — it is styled to viewWidth/viewHeight, i.e.
40
+ // the frame less the two axis borders — so its own rect is the box the
41
+ // layout offsets below are measured in, and the borders need no arithmetic
42
+ // here. The testid carries a `_done` suffix once the view settles, hence
43
+ // the prefix match. Scoped to the view container when there is one, so a
44
+ // page with two dotplots anchors against the one the spec named.
45
+ const canvas = document.querySelector(`[data-testid="view-container-${CSS.escape(view.id)}"] [data-testid^="dotplot_webgl_canvas"]`) ?? document.querySelector('[data-testid^="dotplot_webgl_canvas"]');
46
+ if (!canvas) {
47
+ return undefined;
48
+ }
49
+ const r = canvas.getBoundingClientRect();
50
+ // A locus as screen px along one axis, low first. A bare refName takes the
51
+ // whole displayed region of that name; a reversed axis puts `end` left of
52
+ // `start`, so neither this nor its caller can assume an order.
53
+ function span(axis, locus) {
54
+ const whole = axis.displayedRegions.find(reg => reg.refName === locus.refName);
55
+ const start = locus.start ?? whole?.start;
56
+ const end = locus.end ?? whole?.end;
57
+ if (start === undefined || end === undefined) {
58
+ return undefined;
59
+ }
60
+ const a = axis.bpToPx({ refName: locus.refName, coord: start });
61
+ const b = axis.bpToPx({ refName: locus.refName, coord: end });
62
+ return a === undefined || b === undefined
63
+ ? undefined
64
+ : {
65
+ lo: Math.min(a, b) - axis.offsetPx,
66
+ hi: Math.max(a, b) - axis.offsetPx,
67
+ };
68
+ }
69
+ const hs = h ? span(hview, h) : { lo: 0, hi: r.width };
70
+ const vs = v ? span(vview, v) : { lo: 0, hi: r.height };
71
+ if (!hs || !vs) {
72
+ return undefined;
73
+ }
74
+ return {
75
+ left: r.left + hs.lo,
76
+ width: hs.hi - hs.lo,
77
+ // the flip: an axis's HIGH coordinate is the plot's TOP edge
78
+ top: r.top + r.height - vs.hi,
79
+ height: vs.hi - vs.lo,
80
+ };
81
+ }, path, anchor.hLoc ? parseAxisLocus(anchor.hLoc) : null, anchor.vLoc ? parseAxisLocus(anchor.vLoc) : null);
82
+ }
83
+ // `4A`, `4A:1-5,000,000` or `4A:2,500,000`. Deliberately not
84
+ // `parseAnnotationLocus`, which requires the coordinates: on a dotplot the
85
+ // common case is a whole chromosome, and spelling that as `4A:1-830000000`
86
+ // would put an assembly's chromosome lengths into a spec, where they would be
87
+ // wrong the next time the assembly moved.
88
+ function parseAxisLocus(locus) {
89
+ const cleaned = locus.replaceAll(/[\s,]/g, '');
90
+ const idx = cleaned.lastIndexOf(':');
91
+ if (idx === -1) {
92
+ return { refName: cleaned };
93
+ }
94
+ const match = /^(\d+)(?:\.\.|-)?(\d+)?$/.exec(cleaned.slice(idx + 1));
95
+ if (!match) {
96
+ throw new Error(`dotplot anchor loc "${locus}" is not <refName>[:<start>[-<end>]]`);
97
+ }
98
+ const start = Number(match[1]) - 1;
99
+ return {
100
+ refName: cleaned.slice(0, idx),
101
+ start,
102
+ end: match[2] ? Number(match[2]) : start,
103
+ };
104
+ }
@@ -0,0 +1,47 @@
1
+ import type { AnnotationAnchor } from './annotationOverlay.ts';
2
+ import type { JSHandle, Page } from 'puppeteer';
3
+ export interface ViewportRect {
4
+ left: number;
5
+ top: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface GraphNodeGeometry extends ViewportRect {
10
+ midX: number;
11
+ midY: number;
12
+ }
13
+ export interface GraphPane {
14
+ scale?: number;
15
+ scaleX?: number;
16
+ scaleY?: number;
17
+ translateX?: number;
18
+ translateY?: number;
19
+ nodePositions?: Record<string, {
20
+ x: number;
21
+ y: number;
22
+ }[]>;
23
+ graph?: {
24
+ nodes: {
25
+ id: string;
26
+ length?: number;
27
+ stable?: {
28
+ rank?: number;
29
+ refName?: string;
30
+ start?: number;
31
+ end?: number;
32
+ };
33
+ }[];
34
+ };
35
+ }
36
+ export declare function locateGraphPaneInPage(viewPath: number[], trackId: string | undefined): {
37
+ pane: GraphPane;
38
+ canvas: Element;
39
+ } | undefined;
40
+ export type LocatedGraphPane = NonNullable<ReturnType<typeof locateGraphPaneInPage>>;
41
+ export declare function nodeGeometryInPage(located: LocatedGraphPane | undefined, nodeId: string): GraphNodeGeometry | undefined;
42
+ export declare function locateGraphPane(page: Page, anchor: Pick<AnnotationAnchor, 'view' | 'trackId'>): Promise<JSHandle<LocatedGraphPane | undefined>>;
43
+ export declare function graphNodePoint(page: Page, anchor: AnnotationAnchor): Promise<{
44
+ x: number;
45
+ y: number;
46
+ } | undefined>;
47
+ export declare function graphNodeRect(page: Page, anchor: AnnotationAnchor, wantBounds: boolean): Promise<ViewportRect | undefined>;
@@ -0,0 +1,120 @@
1
+ // The graph pane an anchor addresses, and the canvas it draws on. The graph is
2
+ // one canvas with no element per node, so the pane is asked where it put one
3
+ // instead, the way a locus anchor asks the LGV. The pane is the addressed view
4
+ // itself when that is a GraphGenomeView, and otherwise a LinearGraphDisplay
5
+ // among the view's tracks, the one `trackId` names or the first: the display
6
+ // itself from plugin 4.0.7, its nested `pane` before that.
7
+ //
8
+ // Puppeteer serializes this into the page, so it closes over nothing.
9
+ export function locateGraphPaneInPage(viewPath, trackId) {
10
+ let view = window.JBrowseSession;
11
+ for (const i of viewPath) {
12
+ view = view?.views?.[i];
13
+ }
14
+ if (!view) {
15
+ return undefined;
16
+ }
17
+ const container = `[data-testid="view-container-${CSS.escape(view.id)}"]`;
18
+ let pane = view;
19
+ let scope = container;
20
+ if (view.tracks) {
21
+ const paneOf = (d) => d.pane ?? (d.type === 'LinearGraphDisplay' ? d : undefined);
22
+ const track = view.tracks.find(t => t.displays?.some(paneOf) &&
23
+ (trackId === undefined || t.configuration?.trackId === trackId));
24
+ const display = track?.displays?.find(paneOf);
25
+ pane = display ? paneOf(display) : undefined;
26
+ const id = track?.configuration?.trackId ?? '';
27
+ scope = `${container} [data-testid="trackRenderingContainer-${CSS.escape(view.id)}-${CSS.escape(id)}"] [data-testid="linear-graph-display"]`;
28
+ }
29
+ const canvas = document.querySelector(`${scope} [data-testid="graph-genome-canvas"]`);
30
+ return pane && canvas ? { pane, canvas } : undefined;
31
+ }
32
+ // Where the located pane drew one GFA segment, in viewport CSS px: its
33
+ // `nodePositions` in graph world units through the transform the renderer
34
+ // draws with. Undefined when the node isn't there, so the caller can fail the
35
+ // spec by name rather than acting on (0,0). Serialized into the page too.
36
+ export function nodeGeometryInPage(located, nodeId) {
37
+ if (!located) {
38
+ return undefined;
39
+ }
40
+ const { pane, canvas } = located;
41
+ // nodePositions is keyed by the node's drawn id, which carries the
42
+ // orientation the cut walked the segment in ('s2037+'), so a spec may name
43
+ // either that or the bare GFA segment id
44
+ const positions = pane.nodePositions;
45
+ const pts = positions?.[nodeId] ??
46
+ positions?.[`${nodeId}+`] ??
47
+ positions?.[`${nodeId}-`];
48
+ if (!pts?.length) {
49
+ return undefined;
50
+ }
51
+ const r = canvas.getBoundingClientRect();
52
+ const scaleX = pane.scaleX ?? pane.scale ?? 1;
53
+ const scaleY = pane.scaleY ?? pane.scale ?? 1;
54
+ const tx = pane.translateX ?? 0;
55
+ const ty = pane.translateY ?? 0;
56
+ const screen = pts.map(p => ({ x: p.x * scaleX + tx, y: p.y * scaleY + ty }));
57
+ const xs = screen.map(p => p.x);
58
+ const ys = screen.map(p => p.y);
59
+ const left = Math.min(...xs);
60
+ const top = Math.min(...ys);
61
+ // Halfway along the drawn line rather than its middle vertex: an anchored
62
+ // node is a two-point line, whose "middle" vertex is its left end, where it
63
+ // meets its neighbour, and the app's hit test there answers the neighbour.
64
+ const legs = screen
65
+ .slice(1)
66
+ .map((p, i) => Math.hypot(p.x - screen[i].x, p.y - screen[i].y));
67
+ let rest = legs.reduce((sum, leg) => sum + leg, 0) / 2;
68
+ let mid = screen[0];
69
+ for (const [i, leg] of legs.entries()) {
70
+ const from = screen[i];
71
+ const to = screen[i + 1];
72
+ if (rest <= leg) {
73
+ const t = leg > 0 ? rest / leg : 0;
74
+ mid = { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
75
+ break;
76
+ }
77
+ rest -= leg;
78
+ mid = to;
79
+ }
80
+ return {
81
+ left: r.left + left,
82
+ top: r.top + top,
83
+ width: Math.max(...xs) - left,
84
+ height: Math.max(...ys) - top,
85
+ midX: r.left + mid.x,
86
+ midY: r.top + mid.y,
87
+ };
88
+ }
89
+ export function locateGraphPane(page, anchor) {
90
+ const path = Array.isArray(anchor.view) ? anchor.view : [anchor.view ?? 0];
91
+ return page.evaluateHandle(locateGraphPaneInPage, path, anchor.trackId);
92
+ }
93
+ async function graphNodeGeometry(page, anchor) {
94
+ const located = await locateGraphPane(page, anchor);
95
+ try {
96
+ return await located.evaluate(nodeGeometryInPage, anchor.graphNode ?? '');
97
+ }
98
+ finally {
99
+ await located.dispose();
100
+ }
101
+ }
102
+ // What a click/hover/ring acts on: a point on the node itself.
103
+ export async function graphNodePoint(page, anchor) {
104
+ const geom = await graphNodeGeometry(page, anchor);
105
+ return geom
106
+ ? { x: geom.midX + (anchor.dx ?? 0), y: geom.midY + (anchor.dy ?? 0) }
107
+ : undefined;
108
+ }
109
+ // What an annotation resolves to: the drawn bounds for a box, a zero-size rect
110
+ // on the node for anything else (so its centre lands on the node, not in the
111
+ // space a bent node encloses).
112
+ export async function graphNodeRect(page, anchor, wantBounds) {
113
+ const geom = await graphNodeGeometry(page, anchor);
114
+ if (!geom) {
115
+ return undefined;
116
+ }
117
+ return wantBounds
118
+ ? { left: geom.left, top: geom.top, width: geom.width, height: geom.height }
119
+ : { left: geom.midX, top: geom.midY, width: 0, height: 0 };
120
+ }
package/esm/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { captureJBrowse, openJBrowse } from './capture.ts';
1
+ export { captureBatch, captureJBrowse, openJBrowse } from './capture.ts';
2
+ export { clearAnnotations, drawAnnotations } from './annotations.ts';
2
3
  export { delay } from './poll.ts';
3
4
  export { waitForFrame, waitForJBrowseReady } from './ready.ts';
4
5
  export { describeDisplays, displayCensusInPage, waitForSession, } from './sessionGate.ts';
@@ -6,8 +7,10 @@ export { jbrowseUrl } from './url.ts';
6
7
  export { assemblyFromSession, encodeSessionSpec, sessionSpecQuery, trackIdsFromSession, } from './session.ts';
7
8
  export { PENDING_DISPLAYS, displayPainted, displaySettled, waitForAppSettled, waitForSelectorAttributed, } from './waits.ts';
8
9
  export { BASE_CHROME_ARGS, findChromeExecutable, isBrowserConsoleNoise, launchBrowser, } from './browser.ts';
10
+ export type { Annotation, AnnotationAnchor } from './annotationOverlay.ts';
9
11
  export type { LaunchOptions } from './browser.ts';
10
- export type { CaptureOptions, CaptureResult, OpenOptions, OpenResult, } from './capture.ts';
12
+ export type { BatchOptions, BatchResult, CaptureOptions, CaptureResult, OpenOptions, OpenResult, } from './capture.ts';
13
+ export type { PageOrFrame } from './poll.ts';
11
14
  export type { ReadyOptions, ReadyReport } from './ready.ts';
12
15
  export type { DisplayState, SessionExpectations } from './sessionGate.ts';
13
16
  export type { JBrowseUrlOptions } from './url.ts';
package/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
- export { captureJBrowse, openJBrowse } from './capture.js';
1
+ export { captureBatch, captureJBrowse, openJBrowse } from './capture.js';
2
+ export { clearAnnotations, drawAnnotations } from './annotations.js';
2
3
  export { delay } from './poll.js';
3
4
  export { waitForFrame, waitForJBrowseReady } from './ready.js';
4
5
  export { describeDisplays, displayCensusInPage, waitForSession, } from './sessionGate.js';