@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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  export interface ParsedArgs {
2
- command: 'capture' | 'url' | 'list';
2
+ command: 'capture' | 'batch' | 'url' | 'list';
3
3
  hub?: string;
4
4
  config?: string;
5
5
  assembly?: string;
@@ -11,15 +11,17 @@ export interface ParsedArgs {
11
11
  out?: string;
12
12
  width?: number;
13
13
  height?: number;
14
- scale?: number;
14
+ dpr?: number;
15
15
  timeout?: number;
16
+ concurrency?: number;
16
17
  fullPage: boolean;
18
+ annotations?: string;
17
19
  headed: boolean;
18
20
  verbose: boolean;
19
21
  help: boolean;
20
22
  version: boolean;
21
23
  allowUnsettled: boolean;
22
- /** `list`'s hub and filter. Empty for the other commands. */
24
+ /** `list`'s hub and filter, or `batch`'s manifest. Empty for the other commands. */
23
25
  positionals: string[];
24
26
  }
25
27
  /**
package/esm/args.js CHANGED
@@ -13,9 +13,11 @@ const OPTIONS = {
13
13
  track: { type: 'string', multiple: true, default: [] },
14
14
  width: { type: 'string' },
15
15
  height: { type: 'string' },
16
- scale: { type: 'string' },
16
+ dpr: { type: 'string' },
17
17
  timeout: { type: 'string' },
18
+ concurrency: { type: 'string' },
18
19
  fullPage: { type: 'boolean', default: false },
20
+ annotations: { type: 'string' },
19
21
  headed: { type: 'boolean', default: false },
20
22
  verbose: { type: 'boolean', default: false },
21
23
  help: { type: 'boolean', short: 'h', default: false },
@@ -24,6 +26,8 @@ const OPTIONS = {
24
26
  };
25
27
  const EVERY_COMMAND = ['help', 'version'];
26
28
  const SUBCOMMAND_FLAGS = {
29
+ capture: new Set(Object.keys(OPTIONS).filter(f => f !== 'concurrency')),
30
+ batch: new Set(Object.keys(OPTIONS).filter(f => f !== 'out')),
27
31
  url: new Set([
28
32
  'hub',
29
33
  'config',
@@ -47,6 +51,13 @@ function finite(name, raw) {
47
51
  }
48
52
  return n;
49
53
  }
54
+ function positiveInt(name, raw) {
55
+ const n = positive(name, raw);
56
+ if (n !== undefined && !Number.isInteger(n)) {
57
+ throw new Error(`--${name} needs a whole number, got "${raw}"`);
58
+ }
59
+ return n;
60
+ }
50
61
  function positive(name, raw) {
51
62
  const n = finite(name, raw);
52
63
  if (n !== undefined && n <= 0) {
@@ -61,31 +72,35 @@ function positive(name, raw) {
61
72
  */
62
73
  export function parseArgs(argv) {
63
74
  const [first, ...afterCommand] = argv;
64
- const command = first === 'list' || first === 'url' ? first : 'capture';
75
+ const command = first === 'list' || first === 'url' || first === 'batch' ? first : 'capture';
65
76
  const { values, positionals, tokens } = parseNodeArgs({
66
77
  args: command === 'capture' ? argv : afterCommand,
67
78
  options: OPTIONS,
68
- allowPositionals: command === 'list',
79
+ allowPositionals: command === 'list' || command === 'batch',
69
80
  tokens: true,
70
81
  });
71
- if (command !== 'capture') {
72
- for (const token of tokens) {
73
- if (token.kind === 'option' &&
74
- !SUBCOMMAND_FLAGS[command].has(token.name)) {
75
- throw new Error(`${token.rawName} does not apply to \`jb2capture ${command}\``);
76
- }
82
+ for (const token of tokens) {
83
+ if (token.kind === 'option' && !SUBCOMMAND_FLAGS[command].has(token.name)) {
84
+ throw new Error(command === 'capture'
85
+ ? `${token.rawName} applies only to \`jb2capture batch\``
86
+ : `${token.rawName} does not apply to \`jb2capture ${command}\``);
77
87
  }
78
88
  }
79
- const { track, width, height, scale, timeout, ...rest } = values;
89
+ const fromStdin = ['spec', 'session', 'annotations'].filter(flag => values[flag] === '-');
90
+ if (fromStdin.length > 1) {
91
+ throw new Error(`${fromStdin.map(f => `--${f}`).join(' and ')} cannot both read stdin (-)`);
92
+ }
93
+ const { track, width, height, dpr, timeout, concurrency, ...rest } = values;
80
94
  return {
81
95
  ...rest,
82
96
  command,
83
97
  // copied, since node hands back the option table's own default array
84
98
  tracks: [...track],
85
- width: positive('width', width),
86
- height: positive('height', height),
87
- scale: positive('scale', scale),
99
+ width: positiveInt('width', width),
100
+ height: positiveInt('height', height),
101
+ dpr: positive('dpr', dpr),
88
102
  timeout: positive('timeout', timeout),
103
+ concurrency: positiveInt('concurrency', concurrency),
89
104
  positionals,
90
105
  };
91
106
  }
package/esm/bin.js CHANGED
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
3
2
  import { parseArgs } from './args.js';
4
- import { captureJBrowse } from './capture.js';
3
+ import { captureBatch, captureJBrowse } from './capture.js';
5
4
  import { resolveAgainstConfig } from './catalog.js';
6
5
  import { listHubAssemblies, listHubTracks, trackName } from './hub.js';
6
+ import { readAnnotations, readBatch, 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
10
10
 
11
11
  USAGE
12
12
  jb2capture [flags] --out <file.png> open a view, wait for it, screenshot it
13
+ jb2capture batch <json|path|-> [flags] screenshot many views from one browser
13
14
  jb2capture url [flags] print the URL instead of launching a browser
14
15
  jb2capture list [hub] [filter] list hosted assemblies, or one's trackIds
15
16
 
@@ -31,9 +32,13 @@ THE IMAGE
31
32
  subcommand)
32
33
  --width <px> viewport width (default 1400)
33
34
  --height <px> viewport height (default 900)
34
- --scale <n> device pixel ratio (default 2)
35
+ --dpr <n> device pixel ratio (default 2)
35
36
  --fullPage grow the image until every view fits, not just the
36
37
  --height of the viewport
38
+ --annotations <json|path|->
39
+ callouts to draw over the view: a JSON array of arrows,
40
+ boxes and labels, each anchored to a locus, a graph
41
+ node, a dotplot cell or an element
37
42
 
38
43
  WAITING
39
44
  --timeout <ms> budget per wait stage (default 60000)
@@ -42,6 +47,16 @@ WAITING
42
47
 
43
48
  Either fails the run rather than writing a frame that is not the picture.
44
49
 
50
+ BATCH
51
+ The manifest is a JSON array with one object per image. Each names its "out"
52
+ and may set hub, config, assembly, loc, tracks, spec, session, width, height,
53
+ dpr, fullPage and annotations; the flags above are the defaults for what an
54
+ object leaves out. Every image gets a page of its own in one shared browser.
55
+ A failed image is reported and the rest carry on; the exit code is 1 if any
56
+ failed.
57
+
58
+ --concurrency <n> pages open at once (default 4)
59
+
45
60
  OTHER
46
61
  --headed run with a visible browser window
47
62
  --verbose print browser console output and uncaught errors
@@ -57,28 +72,14 @@ EXAMPLES
57
72
  ## find the trackIds first
58
73
  jb2capture list hg38 conservation
59
74
 
75
+ ## one image per locus, from one browser
76
+ jb2capture batch '[{"loc":"BRCA1","out":"brca1.png"},{"loc":"TP53","out":"tp53.png"}]' \\
77
+ --hub hg38 --track hg38-ncbiRefSeqCurated
78
+
60
79
  ## your own config, two loci side by side
61
80
  jb2capture --config https://example.org/config.json --assembly mydata \\
62
81
  --loc "chr3:25,325,000-25,361,000 chr10:58,716,500-58,718,500" -o two.png
63
82
  `;
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
83
  function urlOptions(args) {
83
84
  return {
84
85
  hub: args.hub,
@@ -91,6 +92,71 @@ function urlOptions(args) {
91
92
  instance: args.instance,
92
93
  };
93
94
  }
95
+ async function runBatch(args) {
96
+ const [manifest, ...extra] = args.positionals;
97
+ if (!manifest || extra.length) {
98
+ throw new Error('`jb2capture batch` takes one manifest: JSON, a path or -');
99
+ }
100
+ const defaults = {
101
+ ...urlOptions(args),
102
+ width: args.width,
103
+ height: args.height,
104
+ dpr: args.dpr,
105
+ fullPage: args.fullPage,
106
+ annotations: args.annotations
107
+ ? readAnnotations(args.annotations)
108
+ : undefined,
109
+ timeout: args.timeout,
110
+ allowUnsettled: args.allowUnsettled,
111
+ onConsole: args.verbose
112
+ ? (text) => {
113
+ console.error(` [page] ${text}`);
114
+ }
115
+ : undefined,
116
+ };
117
+ const captures = readBatch(manifest).map(({ spec, session, annotations, ...entry }) => ({
118
+ ...defaults,
119
+ // an entry that says what to open replaces the default session whole
120
+ ...(spec || session ? { spec: undefined, session: undefined } : {}),
121
+ ...entry,
122
+ ...(spec
123
+ ? { spec: typeof spec === 'string' ? readSpec(spec) : spec }
124
+ : {}),
125
+ ...(session
126
+ ? {
127
+ session: typeof session === 'string'
128
+ ? readJson('session', session)
129
+ : session,
130
+ }
131
+ : {}),
132
+ ...(annotations
133
+ ? {
134
+ annotations: typeof annotations === 'string'
135
+ ? readAnnotations(annotations)
136
+ : annotations,
137
+ }
138
+ : {}),
139
+ }));
140
+ const start = performance.now();
141
+ const results = await captureBatch(captures, {
142
+ headless: !args.headed,
143
+ concurrency: args.concurrency,
144
+ onResult: (result, index) => {
145
+ const label = `[${index + 1}/${captures.length}] ${result.out} ${(result.ms / 1000).toFixed(1)}s`;
146
+ if (result.ok) {
147
+ console.log(`wrote ${label}`);
148
+ }
149
+ else {
150
+ console.error(`FAILED ${label}: ${result.error.message}`);
151
+ }
152
+ },
153
+ });
154
+ const failed = results.filter(r => !r.ok).length;
155
+ console.log(`wrote ${results.length - failed}/${results.length} images in ${((performance.now() - start) / 1000).toFixed(1)}s`);
156
+ if (failed) {
157
+ process.exitCode = 1;
158
+ }
159
+ }
94
160
  async function runList(positionals) {
95
161
  const [hub, filter] = positionals;
96
162
  if (!hub) {
@@ -131,6 +197,10 @@ async function main() {
131
197
  await runList(args.positionals);
132
198
  return;
133
199
  }
200
+ if (args.command === 'batch') {
201
+ await runBatch(args);
202
+ return;
203
+ }
134
204
  if (args.command === 'url') {
135
205
  console.log(jbrowseUrl(await resolveAgainstConfig(urlOptions(args))));
136
206
  return;
@@ -143,8 +213,11 @@ async function main() {
143
213
  out: args.out,
144
214
  width: args.width,
145
215
  height: args.height,
146
- deviceScaleFactor: args.scale,
216
+ dpr: args.dpr,
147
217
  fullPage: args.fullPage,
218
+ annotations: args.annotations
219
+ ? readAnnotations(args.annotations)
220
+ : undefined,
148
221
  headless: !args.headed,
149
222
  timeout: args.timeout,
150
223
  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
- deviceScaleFactor?: number;
10
- /** Called with each page console message that is not known GPU noise, and each uncaught page error. */
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;
@@ -43,3 +50,25 @@ export interface CaptureResult extends ReadyReport {
43
50
  * browser.
44
51
  */
45
52
  export declare function captureJBrowse(options?: CaptureOptions): Promise<CaptureResult>;
53
+ export interface BatchOptions extends LaunchOptions {
54
+ /** Pages open at once. Default 4. */
55
+ concurrency?: number;
56
+ /** Called as each capture finishes, in completion order. */
57
+ onResult?: (result: BatchResult, index: number) => void;
58
+ }
59
+ export type BatchResult = {
60
+ out?: string;
61
+ ms: number;
62
+ } & (({
63
+ ok: true;
64
+ } & CaptureResult) | {
65
+ ok: false;
66
+ error: Error;
67
+ });
68
+ /**
69
+ * Screenshot many sessions from one browser, each on a page of its own that is
70
+ * closed after its screenshot. A failed capture is reported in its result and
71
+ * the rest carry on; a browser that died is relaunched for the captures left.
72
+ * Results come back in the order of `captures`.
73
+ */
74
+ export declare function captureBatch(captures: CaptureOptions[], { concurrency, onResult, ...launch }?: BatchOptions): Promise<BatchResult[]>;