@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/README.md +83 -2
- package/esm/annotationOverlay.d.ts +77 -0
- package/esm/annotationOverlay.js +766 -0
- package/esm/annotationSpec.d.ts +7 -0
- package/esm/annotationSpec.js +70 -0
- package/esm/annotations.d.ts +14 -0
- package/esm/annotations.js +68 -0
- package/esm/args.d.ts +5 -3
- package/esm/args.js +28 -13
- package/esm/bin.js +95 -22
- package/esm/capture.d.ts +31 -2
- package/esm/capture.js +162 -40
- package/esm/catalog.d.ts +11 -6
- package/esm/catalog.js +37 -14
- package/esm/dotplotAnchor.d.ts +4 -0
- package/esm/dotplotAnchor.js +104 -0
- package/esm/graphAnchor.d.ts +47 -0
- package/esm/graphAnchor.js +120 -0
- package/esm/index.d.ts +5 -2
- package/esm/index.js +2 -1
- package/esm/jsonArgs.d.ts +23 -0
- package/esm/jsonArgs.js +46 -0
- package/esm/poll.d.ts +9 -0
- package/esm/poll.js +23 -0
- package/esm/ready.d.ts +4 -4
- package/esm/ready.js +6 -2
- package/esm/sessionGate.d.ts +9 -3
- package/esm/sessionGate.js +15 -5
- package/esm/url.d.ts +6 -5
- package/esm/url.js +20 -5
- package/esm/version.d.ts +1 -1
- package/esm/version.js +1 -1
- package/esm/waits.d.ts +5 -4
- package/esm/waits.js +3 -5
- package/package.json +6 -4
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,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
87
|
+
await page.close().catch(() => { });
|
|
59
88
|
throw error;
|
|
60
89
|
}
|
|
61
90
|
}
|
|
62
91
|
/**
|
|
63
|
-
*
|
|
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
|
|
67
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
203
|
+
};
|
|
204
|
+
try {
|
|
205
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, captures.length) }, worker));
|
|
86
206
|
}
|
|
87
207
|
finally {
|
|
88
|
-
await browser
|
|
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
|
|
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
|
-
|
|
9
|
-
const assemblies = assembliesOf(catalog);
|
|
8
|
+
const findAssembly = (catalog, input) => {
|
|
10
9
|
const target = input.toLowerCase();
|
|
11
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
80
|
+
const { hub, assembly, tracks, spec, session } = options;
|
|
63
81
|
const wanted = assembly ?? hub;
|
|
64
|
-
|
|
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 =
|
|
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';
|