@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.
@@ -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
@@ -11,9 +11,10 @@ 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
16
  fullPage: boolean;
17
+ annotations?: string;
17
18
  headed: boolean;
18
19
  verbose: boolean;
19
20
  help: boolean;
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
- scale: { type: 'string' },
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 { track, width, height, scale, timeout, ...rest } = values;
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: positive('width', width),
86
- height: positive('height', height),
87
- scale: positive('scale', scale),
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
- --scale <n> device pixel ratio (default 2)
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
- deviceScaleFactor: args.scale,
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
- 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;
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, assertSessionStandsAlone, jbrowseUrl } from './url.js';
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, deviceScaleFactor = 2, headless, executablePath, args, onConsole, timeout = DEFAULT_TIMEOUT, trackIds, ...given } = options;
20
- assertSessionStandsAlone(given);
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, { waitUntil: 'domcontentloaded', timeout });
48
- const { spec, session, assembly, hub, tracks } = urlOptions;
49
- const opens = spec ?? (session && savedSnapshot(session));
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: opens ? assemblyFromSession(opens) : (assembly ?? hub),
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 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
+ }