@jbrowse/capture 5.0.0-beta.11 → 5.0.0-beta.13
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 +52 -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 +2 -1
- package/esm/args.js +17 -5
- package/esm/bin.js +10 -21
- package/esm/capture.d.ts +9 -2
- package/esm/capture.js +41 -11
- 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 +2 -0
- package/esm/index.js +1 -0
- package/esm/jsonArgs.d.ts +4 -0
- package/esm/jsonArgs.js +34 -0
- package/esm/poll.d.ts +7 -0
- package/esm/poll.js +20 -0
- package/esm/ready.d.ts +1 -1
- package/esm/ready.js +6 -2
- package/esm/sessionGate.d.ts +8 -2
- 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.js +3 -5
- package/package.json +5 -3
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Annotation } from './annotationOverlay.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Throw for each callout the overlay would draw nothing for, or draw at the
|
|
4
|
+
* page origin, so a spec fails before a browser launches rather than shipping a
|
|
5
|
+
* figure with the callout missing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function assertValidAnnotations(annotations: Annotation[]): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ANNOTATION_TYPES } from './annotationOverlay.js';
|
|
2
|
+
const ANCHOR_KEYS = new Set(Object.keys({
|
|
3
|
+
selector: 0,
|
|
4
|
+
text: 0,
|
|
5
|
+
graphNode: 0,
|
|
6
|
+
hLoc: 0,
|
|
7
|
+
vLoc: 0,
|
|
8
|
+
view: 0,
|
|
9
|
+
trackId: 0,
|
|
10
|
+
loc: 0,
|
|
11
|
+
fracY: 0,
|
|
12
|
+
alignX: 0,
|
|
13
|
+
alignY: 0,
|
|
14
|
+
dx: 0,
|
|
15
|
+
dy: 0,
|
|
16
|
+
fracX: 0,
|
|
17
|
+
}));
|
|
18
|
+
function unknownAnchorKeys(a) {
|
|
19
|
+
return [a.anchor, a.fromAnchor].flatMap(anchor => Object.keys(anchor ?? {}).filter(k => !ANCHOR_KEYS.has(k)));
|
|
20
|
+
}
|
|
21
|
+
const isNumber = (n) => typeof n === 'number' && Number.isFinite(n);
|
|
22
|
+
function missing(a) {
|
|
23
|
+
const placed = !!a.anchor || (isNumber(a.x) && isNumber(a.y));
|
|
24
|
+
switch (a.type) {
|
|
25
|
+
case 'arrow': {
|
|
26
|
+
const head = !!a.anchor || isNumber(a.to?.x);
|
|
27
|
+
const tail = !!a.fromAnchor || isNumber(a.from?.x);
|
|
28
|
+
return head && tail ? undefined : 'an arrow needs a head and a tail';
|
|
29
|
+
}
|
|
30
|
+
case 'box':
|
|
31
|
+
return !!a.anchor || (placed && isNumber(a.width) && isNumber(a.height))
|
|
32
|
+
? undefined
|
|
33
|
+
: 'a box needs an anchor or x, y, width and height';
|
|
34
|
+
case 'circle':
|
|
35
|
+
return placed ? undefined : 'a circle needs an anchor or x and y';
|
|
36
|
+
case 'text':
|
|
37
|
+
return a.text && placed
|
|
38
|
+
? undefined
|
|
39
|
+
: 'a text needs text and an anchor or x and y';
|
|
40
|
+
case 'legend':
|
|
41
|
+
return a.entries?.length && placed
|
|
42
|
+
? undefined
|
|
43
|
+
: 'a legend needs entries and an anchor or x and y';
|
|
44
|
+
case 'trapezoid':
|
|
45
|
+
return a.anchor && a.fromAnchor
|
|
46
|
+
? undefined
|
|
47
|
+
: 'a trapezoid needs both anchor and fromAnchor';
|
|
48
|
+
default:
|
|
49
|
+
return `type "${a.type}" is not one of ${ANNOTATION_TYPES.join(', ')}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Throw for each callout the overlay would draw nothing for, or draw at the
|
|
54
|
+
* page origin, so a spec fails before a browser launches rather than shipping a
|
|
55
|
+
* figure with the callout missing.
|
|
56
|
+
*/
|
|
57
|
+
export function assertValidAnnotations(annotations) {
|
|
58
|
+
const problems = annotations.flatMap((a, i) => {
|
|
59
|
+
const unknown = a ? unknownAnchorKeys(a) : [];
|
|
60
|
+
const why = unknown.length > 0
|
|
61
|
+
? `anchor key(s) ${unknown.join(', ')} not recognized; the keys are ${[...ANCHOR_KEYS].join(', ')}`
|
|
62
|
+
: a
|
|
63
|
+
? missing(a)
|
|
64
|
+
: 'a callout must be an object';
|
|
65
|
+
return why ? [`annotation ${i}: ${why}`] : [];
|
|
66
|
+
});
|
|
67
|
+
if (problems.length > 0) {
|
|
68
|
+
throw new Error(`invalid annotations: ${problems.join('; ')}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Annotation } from './annotationOverlay.ts';
|
|
2
|
+
import type { Page } from 'puppeteer';
|
|
3
|
+
/** Remove the callout overlay a previous `drawAnnotations` left on the page. */
|
|
4
|
+
export declare function clearAnnotations(page: Page): Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Draw callouts (arrows, boxes, labels, badges) over the page as one fixed SVG
|
|
7
|
+
* overlay, so the next screenshot includes them. Each anchor resolves against
|
|
8
|
+
* the live session — a locus through the view model, a graph node or dotplot
|
|
9
|
+
* cell through its view's layout, page chrome by selector or text — so nothing
|
|
10
|
+
* is a measured pixel. Throws when an anchor resolves to nothing or a callout
|
|
11
|
+
* lands outside the viewport, rather than writing a figure with a callout
|
|
12
|
+
* parked in the corner or missing.
|
|
13
|
+
*/
|
|
14
|
+
export declare function drawAnnotations(page: Page, annotations: Annotation[]): Promise<void>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ANNOTATION_OVERLAY_ID, drawAnnotationOverlay, parseAnnotationLocus, } from './annotationOverlay.js';
|
|
2
|
+
import { assertValidAnnotations } from './annotationSpec.js';
|
|
3
|
+
import { dotplotAnchorRect } from './dotplotAnchor.js';
|
|
4
|
+
import { graphNodeRect } from './graphAnchor.js';
|
|
5
|
+
// Attach the parsed region so page context never parses strings, plus the
|
|
6
|
+
// viewport rect of the two anchor kinds that resolve out here rather than in
|
|
7
|
+
// the overlay: a graph node, and a dotplot's pair of axes. Both draw into a
|
|
8
|
+
// single canvas, so there is no element in the page for the overlay to measure
|
|
9
|
+
// and the view's own layout is the only thing that knows where they went.
|
|
10
|
+
//
|
|
11
|
+
// `wantBounds` is the same question for both kinds of anchor: does this callout
|
|
12
|
+
// WRAP the thing it names, or point at it? A force-directed graph node bends, so
|
|
13
|
+
// the centre of its bounding box can sit off the node and everything but a box
|
|
14
|
+
// wants a point ON it (a dotplot block's cell is a rectangle either way). A
|
|
15
|
+
// one-base locus is the same shape of question — the base's own column for a
|
|
16
|
+
// box, the zero-width position between two bases for a pill or an arrow head —
|
|
17
|
+
// which is what parseAnnotationLocus's `wrap` decides.
|
|
18
|
+
async function withRegion(page, anchor, wantBounds) {
|
|
19
|
+
if (!anchor) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const isDotplot = anchor.hLoc !== undefined || anchor.vLoc !== undefined;
|
|
23
|
+
return {
|
|
24
|
+
...anchor,
|
|
25
|
+
region: anchor.loc
|
|
26
|
+
? parseAnnotationLocus(anchor.loc, wantBounds)
|
|
27
|
+
: undefined,
|
|
28
|
+
rect: anchor.graphNode
|
|
29
|
+
? await graphNodeRect(page, anchor, wantBounds)
|
|
30
|
+
: isDotplot
|
|
31
|
+
? await dotplotAnchorRect(page, anchor)
|
|
32
|
+
: undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Remove the callout overlay a previous `drawAnnotations` left on the page. */
|
|
36
|
+
export async function clearAnnotations(page) {
|
|
37
|
+
await page.evaluate(id => {
|
|
38
|
+
document.getElementById(id)?.remove();
|
|
39
|
+
}, ANNOTATION_OVERLAY_ID);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Draw callouts (arrows, boxes, labels, badges) over the page as one fixed SVG
|
|
43
|
+
* overlay, so the next screenshot includes them. Each anchor resolves against
|
|
44
|
+
* the live session — a locus through the view model, a graph node or dotplot
|
|
45
|
+
* cell through its view's layout, page chrome by selector or text — so nothing
|
|
46
|
+
* is a measured pixel. Throws when an anchor resolves to nothing or a callout
|
|
47
|
+
* lands outside the viewport, rather than writing a figure with a callout
|
|
48
|
+
* parked in the corner or missing.
|
|
49
|
+
*/
|
|
50
|
+
export async function drawAnnotations(page, annotations) {
|
|
51
|
+
assertValidAnnotations(annotations);
|
|
52
|
+
await clearAnnotations(page);
|
|
53
|
+
const items = await Promise.all(annotations.map(async (a) => ({
|
|
54
|
+
...a,
|
|
55
|
+
// only a box wants the drawn bounds — of a graph node, or of the base a
|
|
56
|
+
// one-coordinate locus names; a ring/arrow/label wants a point on it, and
|
|
57
|
+
// an arrow's tail always does
|
|
58
|
+
anchor: await withRegion(page, a.anchor, a.type === 'box'),
|
|
59
|
+
fromAnchor: await withRegion(page, a.fromAnchor, false),
|
|
60
|
+
})));
|
|
61
|
+
const { unresolved, offFrame } = await page.evaluate(drawAnnotationOverlay, items, ANNOTATION_OVERLAY_ID);
|
|
62
|
+
if (unresolved.length > 0) {
|
|
63
|
+
throw new Error(`annotation anchors resolved to nothing: ${unresolved.join(', ')}`);
|
|
64
|
+
}
|
|
65
|
+
if (offFrame.length > 0) {
|
|
66
|
+
throw new Error(`annotations drew outside the capture: ${offFrame.join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/esm/args.d.ts
CHANGED
package/esm/args.js
CHANGED
|
@@ -13,9 +13,10 @@ const OPTIONS = {
|
|
|
13
13
|
track: { type: 'string', multiple: true, default: [] },
|
|
14
14
|
width: { type: 'string' },
|
|
15
15
|
height: { type: 'string' },
|
|
16
|
-
|
|
16
|
+
dpr: { type: 'string' },
|
|
17
17
|
timeout: { type: 'string' },
|
|
18
18
|
fullPage: { type: 'boolean', default: false },
|
|
19
|
+
annotations: { type: 'string' },
|
|
19
20
|
headed: { type: 'boolean', default: false },
|
|
20
21
|
verbose: { type: 'boolean', default: false },
|
|
21
22
|
help: { type: 'boolean', short: 'h', default: false },
|
|
@@ -47,6 +48,13 @@ function finite(name, raw) {
|
|
|
47
48
|
}
|
|
48
49
|
return n;
|
|
49
50
|
}
|
|
51
|
+
function positiveInt(name, raw) {
|
|
52
|
+
const n = positive(name, raw);
|
|
53
|
+
if (n !== undefined && !Number.isInteger(n)) {
|
|
54
|
+
throw new Error(`--${name} needs a whole number, got "${raw}"`);
|
|
55
|
+
}
|
|
56
|
+
return n;
|
|
57
|
+
}
|
|
50
58
|
function positive(name, raw) {
|
|
51
59
|
const n = finite(name, raw);
|
|
52
60
|
if (n !== undefined && n <= 0) {
|
|
@@ -76,15 +84,19 @@ export function parseArgs(argv) {
|
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
}
|
|
79
|
-
const
|
|
87
|
+
const fromStdin = ['spec', 'session', 'annotations'].filter(flag => values[flag] === '-');
|
|
88
|
+
if (fromStdin.length > 1) {
|
|
89
|
+
throw new Error(`${fromStdin.map(f => `--${f}`).join(' and ')} cannot both read stdin (-)`);
|
|
90
|
+
}
|
|
91
|
+
const { track, width, height, dpr, timeout, ...rest } = values;
|
|
80
92
|
return {
|
|
81
93
|
...rest,
|
|
82
94
|
command,
|
|
83
95
|
// copied, since node hands back the option table's own default array
|
|
84
96
|
tracks: [...track],
|
|
85
|
-
width:
|
|
86
|
-
height:
|
|
87
|
-
|
|
97
|
+
width: positiveInt('width', width),
|
|
98
|
+
height: positiveInt('height', height),
|
|
99
|
+
dpr: positive('dpr', dpr),
|
|
88
100
|
timeout: positive('timeout', timeout),
|
|
89
101
|
positionals,
|
|
90
102
|
};
|
package/esm/bin.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
2
|
import { parseArgs } from './args.js';
|
|
4
3
|
import { captureJBrowse } from './capture.js';
|
|
5
4
|
import { resolveAgainstConfig } from './catalog.js';
|
|
6
5
|
import { listHubAssemblies, listHubTracks, trackName } from './hub.js';
|
|
6
|
+
import { readAnnotations, readJson, readSpec } from './jsonArgs.js';
|
|
7
7
|
import { PUBLIC_INSTANCE, jbrowseUrl } from './url.js';
|
|
8
8
|
import { version } from './version.js';
|
|
9
9
|
const HELP = `jb2capture — screenshot a live JBrowse 2 view, once it has finished drawing
|
|
@@ -31,9 +31,13 @@ THE IMAGE
|
|
|
31
31
|
subcommand)
|
|
32
32
|
--width <px> viewport width (default 1400)
|
|
33
33
|
--height <px> viewport height (default 900)
|
|
34
|
-
--
|
|
34
|
+
--dpr <n> device pixel ratio (default 2)
|
|
35
35
|
--fullPage grow the image until every view fits, not just the
|
|
36
36
|
--height of the viewport
|
|
37
|
+
--annotations <json|path|->
|
|
38
|
+
callouts to draw over the view: a JSON array of arrows,
|
|
39
|
+
boxes and labels, each anchored to a locus, a graph
|
|
40
|
+
node, a dotplot cell or an element
|
|
37
41
|
|
|
38
42
|
WAITING
|
|
39
43
|
--timeout <ms> budget per wait stage (default 60000)
|
|
@@ -61,24 +65,6 @@ EXAMPLES
|
|
|
61
65
|
jb2capture --config https://example.org/config.json --assembly mydata \\
|
|
62
66
|
--loc "chr3:25,325,000-25,361,000 chr10:58,716,500-58,718,500" -o two.png
|
|
63
67
|
`;
|
|
64
|
-
// inline JSON, `-` for stdin, or a path: the forms `jb2export --spec` reads
|
|
65
|
-
function readJson(flag, value) {
|
|
66
|
-
const text = value === '-'
|
|
67
|
-
? readFileSync(0, 'utf8')
|
|
68
|
-
: value.trimStart().startsWith('{')
|
|
69
|
-
? value
|
|
70
|
-
: readFileSync(value, 'utf8');
|
|
71
|
-
const parsed = JSON.parse(text);
|
|
72
|
-
if (typeof parsed !== 'object' || parsed === null) {
|
|
73
|
-
throw new Error(`--${flag} must be a JSON object`);
|
|
74
|
-
}
|
|
75
|
-
return parsed;
|
|
76
|
-
}
|
|
77
|
-
// a bare view object is the one-view spec it would be wrapped in
|
|
78
|
-
function readSpec(value) {
|
|
79
|
-
const spec = readJson('spec', value);
|
|
80
|
-
return 'type' in spec && !('views' in spec) ? { views: [spec] } : spec;
|
|
81
|
-
}
|
|
82
68
|
function urlOptions(args) {
|
|
83
69
|
return {
|
|
84
70
|
hub: args.hub,
|
|
@@ -143,8 +129,11 @@ async function main() {
|
|
|
143
129
|
out: args.out,
|
|
144
130
|
width: args.width,
|
|
145
131
|
height: args.height,
|
|
146
|
-
|
|
132
|
+
dpr: args.dpr,
|
|
147
133
|
fullPage: args.fullPage,
|
|
134
|
+
annotations: args.annotations
|
|
135
|
+
? readAnnotations(args.annotations)
|
|
136
|
+
: undefined,
|
|
148
137
|
headless: !args.headed,
|
|
149
138
|
timeout: args.timeout,
|
|
150
139
|
allowUnsettled: args.allowUnsettled,
|
package/esm/capture.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Annotation } from './annotationOverlay.ts';
|
|
1
2
|
import type { LaunchOptions } from './browser.ts';
|
|
2
3
|
import type { ReadyOptions, ReadyReport } from './ready.ts';
|
|
3
4
|
import type { JBrowseUrlOptions } from './url.ts';
|
|
@@ -6,8 +7,8 @@ export interface OpenOptions extends JBrowseUrlOptions, ReadyOptions, LaunchOpti
|
|
|
6
7
|
width?: number;
|
|
7
8
|
height?: number;
|
|
8
9
|
/** Device pixel ratio. Default 2, the density a figure usually wants. */
|
|
9
|
-
|
|
10
|
-
/** Called with each page console message that is not known GPU noise,
|
|
10
|
+
dpr?: number;
|
|
11
|
+
/** Called with each page console message that is not known GPU noise, each uncaught page error, and each failed or HTTP 4xx/5xx request. */
|
|
11
12
|
onConsole?: (text: string) => void;
|
|
12
13
|
}
|
|
13
14
|
export interface OpenResult extends ReadyReport {
|
|
@@ -33,6 +34,12 @@ export interface CaptureOptions extends OpenOptions {
|
|
|
33
34
|
* far the session runs past it, and the frame is waited for again.
|
|
34
35
|
*/
|
|
35
36
|
fullPage?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Callouts drawn over the settled view before the screenshot, in the shape
|
|
39
|
+
* the JBrowse docs figures use: arrows, boxes, labels and badges, each
|
|
40
|
+
* anchored to a locus, a graph node, a dotplot cell or an element.
|
|
41
|
+
*/
|
|
42
|
+
annotations?: Annotation[];
|
|
36
43
|
}
|
|
37
44
|
export interface CaptureResult extends ReadyReport {
|
|
38
45
|
url: string;
|
package/esm/capture.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
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,
|
|
13
|
+
import { PUBLIC_INSTANCE, assertCoherentOptions, jbrowseUrl } from './url.js';
|
|
12
14
|
/**
|
|
13
15
|
* Launch a browser, open a JBrowse session, and wait until it has rendered.
|
|
14
16
|
* The caller owns the returned browser and must close it. The assembly and
|
|
@@ -16,8 +18,8 @@ import { PUBLIC_INSTANCE, assertSessionStandsAlone, jbrowseUrl } from './url.js'
|
|
|
16
18
|
* names others.
|
|
17
19
|
*/
|
|
18
20
|
export async function openJBrowse(options = {}) {
|
|
19
|
-
const { width = 1400, height = 900,
|
|
20
|
-
|
|
21
|
+
const { width = 1400, height = 900, dpr = 2, headless, executablePath, args, onConsole, timeout = DEFAULT_TIMEOUT, trackIds, ...given } = options;
|
|
22
|
+
assertCoherentOptions(given);
|
|
21
23
|
const [urlOptions] = await Promise.all([
|
|
22
24
|
resolveAgainstConfig(given),
|
|
23
25
|
assertSupportedInstance(given.instance ?? PUBLIC_INSTANCE),
|
|
@@ -27,10 +29,17 @@ export async function openJBrowse(options = {}) {
|
|
|
27
29
|
...urlOptions,
|
|
28
30
|
sessionName: urlOptions.sessionName ?? 'Screenshot',
|
|
29
31
|
});
|
|
32
|
+
const { spec, session, assembly, hub, tracks } = urlOptions;
|
|
33
|
+
const opens = spec ?? (session && savedSnapshot(session));
|
|
34
|
+
const sessionAssembly = opens && assemblyFromSession(opens);
|
|
35
|
+
const expectedAssembly = opens
|
|
36
|
+
? sessionAssembly &&
|
|
37
|
+
(await canonicalSessionAssembly(urlOptions, sessionAssembly))
|
|
38
|
+
: (assembly ?? hub);
|
|
30
39
|
const browser = await launchBrowser({ headless, executablePath, args });
|
|
31
40
|
try {
|
|
32
41
|
const page = await browser.newPage();
|
|
33
|
-
await page.setViewport({ width, height, deviceScaleFactor });
|
|
42
|
+
await page.setViewport({ width, height, deviceScaleFactor: dpr });
|
|
34
43
|
if (onConsole) {
|
|
35
44
|
page.on('console', msg => {
|
|
36
45
|
const text = msg.text();
|
|
@@ -41,15 +50,30 @@ export async function openJBrowse(options = {}) {
|
|
|
41
50
|
page.on('pageerror', error => {
|
|
42
51
|
onConsole(`uncaught ${error instanceof Error ? error.stack : error}`);
|
|
43
52
|
});
|
|
53
|
+
page.on('requestfailed', request => {
|
|
54
|
+
const reason = request.failure()?.errorText;
|
|
55
|
+
if (reason !== 'net::ERR_ABORTED') {
|
|
56
|
+
onConsole(`request failed: ${request.url()} (${reason})`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
page.on('response', response => {
|
|
60
|
+
if (response.status() >= 400) {
|
|
61
|
+
onConsole(`HTTP ${response.status()}: ${response.url()}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
44
64
|
}
|
|
45
65
|
// an app streaming track data may never reach networkidle; the session
|
|
46
66
|
// gate is the signal that it is up
|
|
47
|
-
await page.goto(url, {
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
const response = await page.goto(url, {
|
|
68
|
+
waitUntil: 'domcontentloaded',
|
|
69
|
+
timeout,
|
|
70
|
+
});
|
|
71
|
+
if (response && !response.ok()) {
|
|
72
|
+
throw new Error(`${url} answered HTTP ${response.status()}. Check --instance.`);
|
|
73
|
+
}
|
|
50
74
|
const report = await waitForJBrowseReady(page, {
|
|
51
75
|
...options,
|
|
52
|
-
assembly:
|
|
76
|
+
assembly: expectedAssembly,
|
|
53
77
|
trackIds: trackIds ?? (opens ? trackIdsFromSession(opens) : tracks),
|
|
54
78
|
});
|
|
55
79
|
return { browser, page, url, ...report };
|
|
@@ -64,7 +88,10 @@ export async function openJBrowse(options = {}) {
|
|
|
64
88
|
* browser.
|
|
65
89
|
*/
|
|
66
90
|
export async function captureJBrowse(options = {}) {
|
|
67
|
-
const { out, fullPage = false, ...openOptions } = options;
|
|
91
|
+
const { out, fullPage = false, annotations, ...openOptions } = options;
|
|
92
|
+
if (annotations) {
|
|
93
|
+
assertValidAnnotations(annotations);
|
|
94
|
+
}
|
|
68
95
|
if (out) {
|
|
69
96
|
assertImagePath(out);
|
|
70
97
|
mkdirSync(dirname(out), { recursive: true });
|
|
@@ -81,10 +108,13 @@ export async function captureJBrowse(options = {}) {
|
|
|
81
108
|
});
|
|
82
109
|
report = await waitForFrame(page, openOptions);
|
|
83
110
|
}
|
|
111
|
+
if (annotations?.length) {
|
|
112
|
+
await drawAnnotations(page, annotations);
|
|
113
|
+
}
|
|
84
114
|
const image = await page.screenshot({ path: out });
|
|
85
115
|
return { url, image, ...report };
|
|
86
116
|
}
|
|
87
117
|
finally {
|
|
88
|
-
await browser.close();
|
|
118
|
+
await browser.close().catch(() => { });
|
|
89
119
|
}
|
|
90
120
|
}
|
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
|
+
}
|