loom-browser 0.0.4 → 0.0.6

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.
@@ -1,4 +1,4 @@
1
- import { jsxs, jsx } from 'react/jsx-runtime';
1
+ import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import { createContext, forwardRef, useRef, useState, useEffect, useImperativeHandle, useMemo, useContext, useCallback } from 'react';
3
3
  import { BigWig } from '@gmod/bbi';
4
4
  import { RemoteFile } from 'generic-filehandle2';
@@ -1337,6 +1337,35 @@ function parseLocus(str, cumOffsets) {
1337
1337
  return null;
1338
1338
  return { chr, start, end };
1339
1339
  }
1340
+ /**
1341
+ * Format a Locus object as a human-readable string with thousands separators.
1342
+ * e.g., { chr: 'chr17', start: 7668000, end: 7688000 } → "chr17:7,668,000-7,688,000"
1343
+ */
1344
+ function formatLocus(locus) {
1345
+ if (isWholeGenomeView(locus))
1346
+ return 'All Chromosomes';
1347
+ return `${locus.chr}:${Math.round(locus.start).toLocaleString()}-${Math.round(locus.end).toLocaleString()}`;
1348
+ }
1349
+ /**
1350
+ * Format a base pair length as a human-readable string.
1351
+ * e.g., 20000 → "20 kb", 2500000 → "2.5 Mb", 500 → "500 bp"
1352
+ */
1353
+ function formatBpLength(bp) {
1354
+ bp = Math.abs(bp);
1355
+ if (bp >= 1e9) {
1356
+ const val = bp / 1e9;
1357
+ return `${val % 1 === 0 ? val.toFixed(0) : val.toFixed(1)} Gb`;
1358
+ }
1359
+ if (bp >= 1e6) {
1360
+ const val = bp / 1e6;
1361
+ return `${val % 1 === 0 ? val.toFixed(0) : val.toFixed(1)} Mb`;
1362
+ }
1363
+ if (bp >= 1e3) {
1364
+ const val = bp / 1e3;
1365
+ return `${val % 1 === 0 ? val.toFixed(0) : val.toFixed(1)} kb`;
1366
+ }
1367
+ return `${Math.round(bp)} bp`;
1368
+ }
1340
1369
  /**
1341
1370
  * Clamp a locus to valid chromosome bounds.
1342
1371
  *
@@ -8202,6 +8231,72 @@ class SequenceDataSource {
8202
8231
  }
8203
8232
  }
8204
8233
 
8234
+ /**
8235
+ * DataSource backed by an in-memory feature array.
8236
+ *
8237
+ * Wraps a pre-loaded feature array in a FeatureCache for spatial queries,
8238
+ * enabling inline features to flow through the same data lifecycle as
8239
+ * URL-based data sources.
8240
+ *
8241
+ * Layer 1 (Data + Layout): no DOM, no canvas.
8242
+ */
8243
+ /**
8244
+ * DataSource that serves features from an in-memory array.
8245
+ *
8246
+ * Features are indexed in a FeatureCache on construction for efficient
8247
+ * spatial queries. Calling `setFeatures()` replaces the cache entirely.
8248
+ */
8249
+ class MemoryDataSource {
8250
+ constructor(features) {
8251
+ this.cache = new FeatureCache(features);
8252
+ }
8253
+ /** Replace the in-memory features and rebuild the spatial index. */
8254
+ setFeatures(features) {
8255
+ this.cache = new FeatureCache(features);
8256
+ }
8257
+ /** Set a chromosome name resolver for alias resolution. */
8258
+ setChromNameResolver(resolver) {
8259
+ this._resolveChromName = resolver;
8260
+ }
8261
+ /** Set cumulative offsets for whole genome view coordinate transformation. */
8262
+ setCumulativeOffsets(offsets) {
8263
+ this._cumulativeOffsets = offsets;
8264
+ }
8265
+ async fetch(locus, _bpPerPixel, _signal) {
8266
+ if (isWholeGenomeView(locus) && this._cumulativeOffsets) {
8267
+ return this.fetchWG();
8268
+ }
8269
+ const chr = this._resolveChromName
8270
+ ? this._resolveChromName(locus.chr)
8271
+ : locus.chr;
8272
+ return this.cache.queryFeatures(chr, locus.start, locus.end);
8273
+ }
8274
+ fetchWG() {
8275
+ const offsets = this._cumulativeOffsets;
8276
+ const chrNames = mainChromosomeNames(Object.fromEntries(offsets.chromosomeNames.map(name => { var _a; return [name, (_a = offsets.offsets[name]) !== null && _a !== void 0 ? _a : 0]; })));
8277
+ const wgFeatures = [];
8278
+ const allByChrom = this.cache.getAllFeatures();
8279
+ for (const chr of chrNames) {
8280
+ const features = allByChrom[chr];
8281
+ if (!features)
8282
+ continue;
8283
+ const offset = offsets.offsets[chr];
8284
+ if (offset === undefined)
8285
+ continue;
8286
+ for (const f of features) {
8287
+ wgFeatures.push({
8288
+ ...f,
8289
+ chr: 'all',
8290
+ start: offset + f.start,
8291
+ end: offset + f.end,
8292
+ });
8293
+ }
8294
+ }
8295
+ wgFeatures.sort((a, b) => a.start - b.start);
8296
+ return wgFeatures;
8297
+ }
8298
+ }
8299
+
8205
8300
  /**
8206
8301
  * Stateless renderer for interaction (arc/BEDPE) tracks.
8207
8302
  *
@@ -8772,6 +8867,11 @@ function createDataSource(config, workerProvider) {
8772
8867
  indexed: config.indexed,
8773
8868
  workerProvider,
8774
8869
  });
8870
+ case 'memory':
8871
+ // Memory data sources are created directly with features by the caller.
8872
+ // This path is only hit during session restore, where in-memory features
8873
+ // are not available — return an empty MemoryDataSource as a placeholder.
8874
+ return new MemoryDataSource([]);
8775
8875
  }
8776
8876
  }
8777
8877
  // ─── Built-in track creators ─────────────────────────────────────────────────
@@ -8950,6 +9050,9 @@ function dataSourceCacheKey(config) {
8950
9050
  return `ucsc:${(_b = config.genome) !== null && _b !== void 0 ? _b : ''}:${(_c = config.track) !== null && _c !== void 0 ? _c : ''}`;
8951
9051
  case 'text':
8952
9052
  return `text:${config.url}:${(_d = config.format) !== null && _d !== void 0 ? _d : ''}:${(_e = config.indexURL) !== null && _e !== void 0 ? _e : ''}`;
9053
+ case 'memory':
9054
+ // Each memory data source is unique — no deduplication.
9055
+ return `memory:${Math.random()}`;
8953
9056
  }
8954
9057
  }
8955
9058
 
@@ -10060,6 +10163,60 @@ class HeadlessGenomeBrowser {
10060
10163
  this.managedTracks[this.managedTracks.length - 1].metadata = options.metadata;
10061
10164
  return track;
10062
10165
  }
10166
+ /** Add a BigWig-style signal track backed by in-memory features (no URL required). */
10167
+ addWigTrackWithFeatures(features, options) {
10168
+ const { canvas } = this.canvasProvider.createCanvas(0, 0);
10169
+ const track = new WigTrackCanvas(canvas, {
10170
+ locus: this._locus,
10171
+ features: [],
10172
+ config: options === null || options === void 0 ? void 0 : options.config,
10173
+ height: options === null || options === void 0 ? void 0 : options.height,
10174
+ background: options === null || options === void 0 ? void 0 : options.background,
10175
+ theme: this.theme,
10176
+ canvasProvider: this.canvasProvider,
10177
+ name: options === null || options === void 0 ? void 0 : options.name,
10178
+ sequenceProvider: this.sequenceProvider,
10179
+ });
10180
+ const dataSource = new MemoryDataSource(features);
10181
+ if (this.cumulativeOffsets) {
10182
+ dataSource.setCumulativeOffsets(this.cumulativeOffsets);
10183
+ }
10184
+ if (this.genome) {
10185
+ dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10186
+ }
10187
+ const dataSourceConfig = { type: 'memory' };
10188
+ this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
10189
+ if (options === null || options === void 0 ? void 0 : options.metadata)
10190
+ this.managedTracks[this.managedTracks.length - 1].metadata = options.metadata;
10191
+ return track;
10192
+ }
10193
+ /** Add a BED/annotation track backed by in-memory features (no URL required). Features must include `chr`. */
10194
+ addBedTrackWithFeatures(features, options) {
10195
+ const { canvas } = this.canvasProvider.createCanvas(0, 0);
10196
+ const track = new AnnotationTrackCanvas(canvas, {
10197
+ locus: this._locus,
10198
+ features: [],
10199
+ config: options === null || options === void 0 ? void 0 : options.config,
10200
+ height: options === null || options === void 0 ? void 0 : options.height,
10201
+ background: options === null || options === void 0 ? void 0 : options.background,
10202
+ theme: this.theme,
10203
+ canvasProvider: this.canvasProvider,
10204
+ workerProvider: this.workerProvider,
10205
+ name: options === null || options === void 0 ? void 0 : options.name,
10206
+ });
10207
+ const dataSource = new MemoryDataSource(features);
10208
+ if (this.cumulativeOffsets) {
10209
+ dataSource.setCumulativeOffsets(this.cumulativeOffsets);
10210
+ }
10211
+ if (this.genome) {
10212
+ dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10213
+ }
10214
+ const dataSourceConfig = { type: 'memory' };
10215
+ this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
10216
+ if (options === null || options === void 0 ? void 0 : options.metadata)
10217
+ this.managedTracks[this.managedTracks.length - 1].metadata = options.metadata;
10218
+ return track;
10219
+ }
10063
10220
  /** Add a DNA/RNA sequence track. Data fetching is handled automatically via the genome's sequence provider. */
10064
10221
  addSequenceTrack(options) {
10065
10222
  const trackConfig = { type: 'sequence', config: options === null || options === void 0 ? void 0 : options.config };
@@ -11210,6 +11367,13 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11210
11367
  this.events.on(BrowserEvent.DataLoaded, ({ track }) => {
11211
11368
  this.updateAxisContent(track);
11212
11369
  });
11370
+ // Repaint axis canvases when theme changes (track canvases update via
11371
+ // track.setTheme(), but axis sidebar is managed here in GenomeBrowser).
11372
+ this.events.on(BrowserEvent.ThemeChanged, () => {
11373
+ for (const mt of this.managedTracks) {
11374
+ this.updateAxisContent(mt.track);
11375
+ }
11376
+ });
11213
11377
  // Re-render ROI overlays when ROIs change or viewport moves
11214
11378
  this.events.on(BrowserEvent.ROIAdded, () => this.renderROIOverlays());
11215
11379
  this.events.on(BrowserEvent.ROIRemoved, () => this.renderROIOverlays());
@@ -11412,9 +11576,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11412
11576
  const { axisDiv } = entry;
11413
11577
  const rawInfo = (_a = track.getAxisInfo) === null || _a === void 0 ? void 0 : _a.call(track);
11414
11578
  if (!rawInfo) {
11415
- // No axis info — clear content and hide border (e.g., ruler tracks)
11579
+ // No axis info — keep the column for alignment but clear content
11416
11580
  axisDiv.innerHTML = "";
11417
11581
  axisDiv.style.borderRight = "none";
11582
+ axisDiv.style.backgroundColor = this.theme.palette.background;
11418
11583
  entry.axisCanvas = null;
11419
11584
  return;
11420
11585
  }
@@ -12435,6 +12600,23 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
12435
12600
 
12436
12601
  const GenomeBrowserContext = createContext(null);
12437
12602
 
12603
+ /** Infer the shell theme from a RenderTheme's palette. */
12604
+ function inferShellTheme(theme) {
12605
+ var _a;
12606
+ const bg = (_a = theme === null || theme === void 0 ? void 0 : theme.palette) === null || _a === void 0 ? void 0 : _a.background;
12607
+ if (!bg)
12608
+ return 'modern';
12609
+ // Simple luminance check: dark backgrounds → dark shell theme
12610
+ const el = document.createElement('canvas');
12611
+ const ctx = el.getContext('2d');
12612
+ ctx.fillStyle = bg;
12613
+ const hex = ctx.fillStyle; // normalized to #rrggbb
12614
+ const r = parseInt(hex.slice(1, 3), 16);
12615
+ const g = parseInt(hex.slice(3, 5), 16);
12616
+ const b = parseInt(hex.slice(5, 7), 16);
12617
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
12618
+ return luminance < 0.5 ? 'dark' : 'modern';
12619
+ }
12438
12620
  const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
12439
12621
  const { defaultLocus, locus, onLocusChange, theme, genome, interactive, wheelZoom, workerProvider, popupProvider, contextMenuProvider, ruler, genes, sequence, session, tracks: trackConfigs, remoteSocket, className, style, children, } = props;
12440
12622
  const containerRef = useRef(null);
@@ -12577,8 +12759,9 @@ const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
12577
12759
  attachRemote(socket) { var _a; (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.attachRemote(socket); },
12578
12760
  detachRemote() { var _a; (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.detachRemote(); },
12579
12761
  }), [browser]);
12580
- const ctxValue = useMemo(() => ({ browser }), [browser]);
12581
- return (jsxs(GenomeBrowserContext.Provider, { value: ctxValue, children: [jsx("div", { ref: containerRef, className: className, style: style }), browser && children] }));
12762
+ const shellTheme = useMemo(() => inferShellTheme(theme), [theme]);
12763
+ const ctxValue = useMemo(() => ({ browser, shellTheme }), [browser, shellTheme]);
12764
+ return (jsx(GenomeBrowserContext.Provider, { value: ctxValue, children: jsxs("div", { className: className, style: style, children: [browser && children, jsx("div", { ref: containerRef })] }) }));
12582
12765
  });
12583
12766
 
12584
12767
  /**
@@ -12686,8 +12869,15 @@ function RulerTrack({ config, maxTrackHeight }) {
12686
12869
  return null;
12687
12870
  }
12688
12871
 
12689
- function WigTrack({ url, config, height, background, windowFunction, maxTrackHeight, name, metadata }) {
12690
- useTrackManager((browser) => browser.addWigTrack(url, { config, height, background, windowFunction, maxTrackHeight, name, metadata }), [url, windowFunction], (track) => { if (config)
12872
+ function WigTrack({ url, features, config, height, background, windowFunction, maxTrackHeight, name, metadata }) {
12873
+ useTrackManager((browser) => {
12874
+ if (features) {
12875
+ return browser.addWigTrackWithFeatures(features, { config, height, background, maxTrackHeight, name, metadata });
12876
+ }
12877
+ if (!url)
12878
+ throw new Error('WigTrack requires either a `url` or `features` prop');
12879
+ return browser.addWigTrack(url, { config, height, background, windowFunction, maxTrackHeight, name, metadata });
12880
+ }, [url, features, windowFunction], (track) => { if (config)
12691
12881
  track.setConfig(config); }, [config, height, background, name]);
12692
12882
  return null;
12693
12883
  }
@@ -12698,8 +12888,15 @@ function GeneTrack({ config, height, background, genome, track, maxTrackHeight,
12698
12888
  return null;
12699
12889
  }
12700
12890
 
12701
- function BedTrack({ url, config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata }) {
12702
- useTrackManager((browser) => browser.addBedTrack(url, { config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata }), [url, format, indexURL, indexed], (track) => { if (config)
12891
+ function BedTrack({ url, features, config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata }) {
12892
+ useTrackManager((browser) => {
12893
+ if (features) {
12894
+ return browser.addBedTrackWithFeatures(features, { config, height, background, maxTrackHeight, name, metadata });
12895
+ }
12896
+ if (!url)
12897
+ throw new Error('BedTrack requires either a `url` or `features` prop');
12898
+ return browser.addBedTrack(url, { config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata });
12899
+ }, [url, features, format, indexURL, indexed], (track) => { if (config)
12703
12900
  track.setConfig(config); }, [config, height, background, name]);
12704
12901
  return null;
12705
12902
  }
@@ -12722,6 +12919,757 @@ function GtxTrack({ url, experimentId, config, height, background, windowFunctio
12722
12919
  return null;
12723
12920
  }
12724
12921
 
12922
+ /**
12923
+ * <loom-chromosome-select> — Chromosome dropdown for quick navigation.
12924
+ *
12925
+ * Lists main chromosomes (chr1-22, X, Y, M) plus "All" for whole genome view.
12926
+ * Updates automatically when the browser locus changes.
12927
+ */
12928
+ const template$5 = document.createElement('template');
12929
+ template$5.innerHTML = /* html */ `
12930
+ <style>
12931
+ :host {
12932
+ display: inline-flex;
12933
+ align-items: center;
12934
+ }
12935
+ select {
12936
+ font: var(--loom-font, 12px Arial, sans-serif);
12937
+ color: var(--loom-text-color, #333);
12938
+ background: var(--loom-input-bg, white);
12939
+ border: var(--loom-input-border, 1px solid #b0b0b0);
12940
+ border-radius: var(--loom-border-radius, 4px);
12941
+ height: var(--loom-input-height, 22px);
12942
+ padding: 0 4px;
12943
+ box-sizing: border-box;
12944
+ outline: none;
12945
+ cursor: pointer;
12946
+ transition: border-color 0.15s;
12947
+ }
12948
+ select:focus {
12949
+ border: var(--loom-input-focus-border, 1px solid #4A90D9);
12950
+ }
12951
+ </style>
12952
+ <select></select>
12953
+ `;
12954
+ class LoomChromosomeSelect extends HTMLElement {
12955
+ constructor() {
12956
+ super();
12957
+ this.unsubscribe = null;
12958
+ this._browser = null;
12959
+ this.attachShadow({ mode: 'open' });
12960
+ this.shadowRoot.appendChild(template$5.content.cloneNode(true));
12961
+ this.select = this.shadowRoot.querySelector('select');
12962
+ this.select.addEventListener('change', () => {
12963
+ if (!this._browser)
12964
+ return;
12965
+ const value = this.select.value;
12966
+ if (value === 'all') {
12967
+ this._browser.search('all');
12968
+ }
12969
+ else {
12970
+ const chromSizes = this._browser.chromSizes;
12971
+ if (chromSizes && chromSizes[value] != null) {
12972
+ this._browser.setLocus({ chr: value, start: 0, end: chromSizes[value] });
12973
+ }
12974
+ }
12975
+ });
12976
+ }
12977
+ set browser(b) {
12978
+ if (this.unsubscribe) {
12979
+ this.unsubscribe();
12980
+ this.unsubscribe = null;
12981
+ }
12982
+ this._browser = b;
12983
+ if (b) {
12984
+ this.populate(b);
12985
+ this.updateSelection(b.locus.chr);
12986
+ this.unsubscribe = b.on(BrowserEvent.LocusChange, ({ locus }) => {
12987
+ this.updateSelection(locus.chr);
12988
+ });
12989
+ }
12990
+ }
12991
+ get browser() {
12992
+ return this._browser;
12993
+ }
12994
+ populate(browser) {
12995
+ this.select.innerHTML = '';
12996
+ const chromSizes = browser.chromSizes;
12997
+ if (!chromSizes)
12998
+ return;
12999
+ // "All" option for whole genome view
13000
+ if (browser.cumulativeOffsets) {
13001
+ const opt = document.createElement('option');
13002
+ opt.value = 'all';
13003
+ opt.textContent = 'All';
13004
+ this.select.appendChild(opt);
13005
+ }
13006
+ // Main chromosomes in order
13007
+ const names = mainChromosomeNames(chromSizes);
13008
+ for (const name of names) {
13009
+ const opt = document.createElement('option');
13010
+ opt.value = name;
13011
+ opt.textContent = name;
13012
+ this.select.appendChild(opt);
13013
+ }
13014
+ }
13015
+ updateSelection(chr) {
13016
+ if (isWholeGenomeView({ chr, start: 0, end: 0 })) {
13017
+ this.select.value = 'all';
13018
+ }
13019
+ else {
13020
+ this.select.value = chr;
13021
+ }
13022
+ }
13023
+ disconnectedCallback() {
13024
+ if (this.unsubscribe) {
13025
+ this.unsubscribe();
13026
+ this.unsubscribe = null;
13027
+ }
13028
+ }
13029
+ }
13030
+
13031
+ /**
13032
+ * <loom-locus-input> — Locus search/display input.
13033
+ *
13034
+ * Shows the current locus as a formatted string. User can type a new locus
13035
+ * and press Enter to navigate. Updates automatically on locuschange events.
13036
+ */
13037
+ const template$4 = document.createElement('template');
13038
+ template$4.innerHTML = /* html */ `
13039
+ <style>
13040
+ :host {
13041
+ display: inline-flex;
13042
+ align-items: center;
13043
+ }
13044
+ .container {
13045
+ display: flex;
13046
+ align-items: center;
13047
+ gap: 4px;
13048
+ }
13049
+ input {
13050
+ font: var(--loom-font, 12px Arial, sans-serif);
13051
+ color: var(--loom-text-color, #333);
13052
+ background: var(--loom-input-bg, white);
13053
+ border: var(--loom-input-border, 1px solid #b0b0b0);
13054
+ border-radius: var(--loom-border-radius, 4px);
13055
+ width: var(--loom-input-width, 220px);
13056
+ height: var(--loom-input-height, 22px);
13057
+ padding: 0 8px;
13058
+ box-sizing: border-box;
13059
+ outline: none;
13060
+ transition: border-color 0.15s;
13061
+ }
13062
+ input:focus {
13063
+ border: var(--loom-input-focus-border, 1px solid #4A90D9);
13064
+ }
13065
+ input::placeholder {
13066
+ color: var(--loom-text-muted, #737373);
13067
+ }
13068
+ .search-btn {
13069
+ display: flex;
13070
+ align-items: center;
13071
+ justify-content: center;
13072
+ width: var(--loom-button-size, 24px);
13073
+ height: var(--loom-button-size, 24px);
13074
+ background: var(--loom-button-bg, white);
13075
+ border: var(--loom-button-border, 1px solid #b0b0b0);
13076
+ border-radius: var(--loom-border-radius, 4px);
13077
+ cursor: pointer;
13078
+ color: var(--loom-icon-color, #555);
13079
+ transition: background 0.15s;
13080
+ padding: 0;
13081
+ line-height: 1;
13082
+ }
13083
+ .search-btn:hover {
13084
+ background: var(--loom-button-hover, #e8e8e8);
13085
+ }
13086
+ .search-btn svg {
13087
+ width: var(--loom-icon-size, 14px);
13088
+ height: var(--loom-icon-size, 14px);
13089
+ fill: none;
13090
+ stroke: currentColor;
13091
+ stroke-width: 2;
13092
+ stroke-linecap: round;
13093
+ stroke-linejoin: round;
13094
+ }
13095
+ </style>
13096
+ <div class="container">
13097
+ <input type="text" placeholder="Locus Search" spellcheck="false" />
13098
+ <button class="search-btn" title="Go to locus">
13099
+ <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>
13100
+ </button>
13101
+ </div>
13102
+ `;
13103
+ class LoomLocusInput extends HTMLElement {
13104
+ constructor() {
13105
+ super();
13106
+ this.unsubscribe = null;
13107
+ this._browser = null;
13108
+ this.attachShadow({ mode: 'open' });
13109
+ this.shadowRoot.appendChild(template$4.content.cloneNode(true));
13110
+ this.input = this.shadowRoot.querySelector('input');
13111
+ this.input.addEventListener('keydown', (e) => {
13112
+ if (e.key === 'Enter') {
13113
+ this.doSearch();
13114
+ }
13115
+ });
13116
+ this.shadowRoot.querySelector('.search-btn').addEventListener('click', () => {
13117
+ this.doSearch();
13118
+ });
13119
+ }
13120
+ set browser(b) {
13121
+ if (this.unsubscribe) {
13122
+ this.unsubscribe();
13123
+ this.unsubscribe = null;
13124
+ }
13125
+ this._browser = b;
13126
+ if (b) {
13127
+ this.updateDisplay(b.locus);
13128
+ this.unsubscribe = b.on(BrowserEvent.LocusChange, ({ locus }) => {
13129
+ this.updateDisplay(locus);
13130
+ });
13131
+ }
13132
+ }
13133
+ get browser() {
13134
+ return this._browser;
13135
+ }
13136
+ updateDisplay(locus) {
13137
+ if (document.activeElement !== this.input && this.shadowRoot.activeElement !== this.input) {
13138
+ this.input.value = formatLocus(locus);
13139
+ }
13140
+ }
13141
+ doSearch() {
13142
+ if (!this._browser)
13143
+ return;
13144
+ const value = this.input.value.trim();
13145
+ if (value) {
13146
+ this._browser.search(value);
13147
+ }
13148
+ this.input.blur();
13149
+ }
13150
+ disconnectedCallback() {
13151
+ if (this.unsubscribe) {
13152
+ this.unsubscribe();
13153
+ this.unsubscribe = null;
13154
+ }
13155
+ }
13156
+ }
13157
+
13158
+ /**
13159
+ * <loom-zoom-controls> — Zoom in/out buttons.
13160
+ */
13161
+ const template$3 = document.createElement('template');
13162
+ template$3.innerHTML = /* html */ `
13163
+ <style>
13164
+ :host {
13165
+ display: inline-flex;
13166
+ align-items: center;
13167
+ }
13168
+ .container {
13169
+ display: flex;
13170
+ align-items: center;
13171
+ gap: 2px;
13172
+ }
13173
+ button {
13174
+ display: flex;
13175
+ align-items: center;
13176
+ justify-content: center;
13177
+ width: var(--loom-button-size, 24px);
13178
+ height: var(--loom-button-size, 24px);
13179
+ background: var(--loom-button-bg, white);
13180
+ border: var(--loom-button-border, 1px solid #b0b0b0);
13181
+ border-radius: var(--loom-border-radius, 4px);
13182
+ cursor: pointer;
13183
+ color: var(--loom-icon-color, #555);
13184
+ transition: background 0.15s;
13185
+ padding: 0;
13186
+ line-height: 1;
13187
+ }
13188
+ button:hover {
13189
+ background: var(--loom-button-hover, #e8e8e8);
13190
+ }
13191
+ button:active {
13192
+ transform: scale(0.95);
13193
+ }
13194
+ button svg {
13195
+ width: var(--loom-icon-size, 14px);
13196
+ height: var(--loom-icon-size, 14px);
13197
+ fill: none;
13198
+ stroke: currentColor;
13199
+ stroke-width: 2;
13200
+ stroke-linecap: round;
13201
+ }
13202
+ </style>
13203
+ <div class="container">
13204
+ <button class="zoom-out" title="Zoom out">
13205
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
13206
+ </button>
13207
+ <button class="zoom-in" title="Zoom in">
13208
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
13209
+ </button>
13210
+ </div>
13211
+ `;
13212
+ class LoomZoomControls extends HTMLElement {
13213
+ constructor() {
13214
+ super();
13215
+ this._browser = null;
13216
+ this.attachShadow({ mode: 'open' });
13217
+ this.shadowRoot.appendChild(template$3.content.cloneNode(true));
13218
+ this.shadowRoot.querySelector('.zoom-out').addEventListener('click', () => {
13219
+ var _a;
13220
+ (_a = this._browser) === null || _a === void 0 ? void 0 : _a.zoomOut();
13221
+ });
13222
+ this.shadowRoot.querySelector('.zoom-in').addEventListener('click', () => {
13223
+ var _a;
13224
+ (_a = this._browser) === null || _a === void 0 ? void 0 : _a.zoomIn();
13225
+ });
13226
+ }
13227
+ set browser(b) {
13228
+ this._browser = b;
13229
+ }
13230
+ get browser() {
13231
+ return this._browser;
13232
+ }
13233
+ }
13234
+
13235
+ /**
13236
+ * <loom-window-size> — Read-only display of current viewport bp range.
13237
+ */
13238
+ const template$2 = document.createElement('template');
13239
+ template$2.innerHTML = /* html */ `
13240
+ <style>
13241
+ :host {
13242
+ display: inline-flex;
13243
+ align-items: center;
13244
+ }
13245
+ .label {
13246
+ font: var(--loom-font-small, 11px Arial, sans-serif);
13247
+ color: var(--loom-text-muted, #737373);
13248
+ white-space: nowrap;
13249
+ user-select: none;
13250
+ }
13251
+ </style>
13252
+ <span class="label"></span>
13253
+ `;
13254
+ class LoomWindowSize extends HTMLElement {
13255
+ constructor() {
13256
+ super();
13257
+ this.unsubscribe = null;
13258
+ this._browser = null;
13259
+ this.attachShadow({ mode: 'open' });
13260
+ this.shadowRoot.appendChild(template$2.content.cloneNode(true));
13261
+ this.label = this.shadowRoot.querySelector('.label');
13262
+ }
13263
+ set browser(b) {
13264
+ if (this.unsubscribe) {
13265
+ this.unsubscribe();
13266
+ this.unsubscribe = null;
13267
+ }
13268
+ this._browser = b;
13269
+ if (b) {
13270
+ this.update(b.locus);
13271
+ this.unsubscribe = b.on(BrowserEvent.LocusChange, ({ locus }) => {
13272
+ this.update(locus);
13273
+ });
13274
+ }
13275
+ }
13276
+ get browser() {
13277
+ return this._browser;
13278
+ }
13279
+ update(locus) {
13280
+ this.label.textContent = formatBpLength(locus.end - locus.start);
13281
+ }
13282
+ disconnectedCallback() {
13283
+ if (this.unsubscribe) {
13284
+ this.unsubscribe();
13285
+ this.unsubscribe = null;
13286
+ }
13287
+ }
13288
+ }
13289
+
13290
+ /**
13291
+ * <loom-export-controls> — SVG/PNG export buttons.
13292
+ *
13293
+ * Adds "Save SVG" and "Save PNG" buttons to the shell navbar.
13294
+ * Calls GenomeBrowser.saveSVGtoFile() and savePNGtoFile() respectively.
13295
+ */
13296
+ const template$1 = document.createElement('template');
13297
+ template$1.innerHTML = /* html */ `
13298
+ <style>
13299
+ :host {
13300
+ display: inline-flex;
13301
+ align-items: center;
13302
+ }
13303
+ .container {
13304
+ display: flex;
13305
+ align-items: center;
13306
+ gap: 2px;
13307
+ }
13308
+ button {
13309
+ display: flex;
13310
+ align-items: center;
13311
+ justify-content: center;
13312
+ width: var(--loom-button-size, 24px);
13313
+ height: var(--loom-button-size, 24px);
13314
+ background: var(--loom-button-bg, white);
13315
+ border: var(--loom-button-border, 1px solid #b0b0b0);
13316
+ border-radius: var(--loom-border-radius, 4px);
13317
+ cursor: pointer;
13318
+ color: var(--loom-icon-color, #555);
13319
+ transition: background 0.15s;
13320
+ padding: 0;
13321
+ line-height: 1;
13322
+ }
13323
+ button:hover {
13324
+ background: var(--loom-button-hover, #e8e8e8);
13325
+ }
13326
+ button:active {
13327
+ transform: scale(0.95);
13328
+ }
13329
+ button svg {
13330
+ width: var(--loom-icon-size, 14px);
13331
+ height: var(--loom-icon-size, 14px);
13332
+ fill: none;
13333
+ stroke: currentColor;
13334
+ stroke-width: 2;
13335
+ stroke-linecap: round;
13336
+ stroke-linejoin: round;
13337
+ }
13338
+ </style>
13339
+ <div class="container">
13340
+ <button class="save-svg" title="Save as SVG">
13341
+ <svg viewBox="0 0 24 24">
13342
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
13343
+ <polyline points="7 10 12 15 17 10"/>
13344
+ <line x1="12" y1="15" x2="12" y2="3"/>
13345
+ </svg>
13346
+ </button>
13347
+ <button class="save-png" title="Save as PNG">
13348
+ <svg viewBox="0 0 24 24">
13349
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
13350
+ <circle cx="8.5" cy="8.5" r="1.5"/>
13351
+ <polyline points="21 15 16 10 5 21"/>
13352
+ </svg>
13353
+ </button>
13354
+ </div>
13355
+ `;
13356
+ class LoomExportControls extends HTMLElement {
13357
+ constructor() {
13358
+ super();
13359
+ this._browser = null;
13360
+ this.attachShadow({ mode: 'open' });
13361
+ this.shadowRoot.appendChild(template$1.content.cloneNode(true));
13362
+ this.shadowRoot.querySelector('.save-svg').addEventListener('click', () => {
13363
+ var _a;
13364
+ (_a = this._browser) === null || _a === void 0 ? void 0 : _a.saveSVGtoFile();
13365
+ });
13366
+ this.shadowRoot.querySelector('.save-png').addEventListener('click', () => {
13367
+ var _a;
13368
+ void ((_a = this._browser) === null || _a === void 0 ? void 0 : _a.savePNGtoFile());
13369
+ });
13370
+ }
13371
+ set browser(b) {
13372
+ this._browser = b;
13373
+ }
13374
+ get browser() {
13375
+ return this._browser;
13376
+ }
13377
+ }
13378
+
13379
+ /**
13380
+ * <loom-navbar> — Horizontal navigation bar composing locus input, window size, and zoom controls.
13381
+ */
13382
+ // Ensure child custom elements are registered
13383
+ if (!customElements.get('loom-chromosome-select')) {
13384
+ customElements.define('loom-chromosome-select', LoomChromosomeSelect);
13385
+ }
13386
+ if (!customElements.get('loom-locus-input')) {
13387
+ customElements.define('loom-locus-input', LoomLocusInput);
13388
+ }
13389
+ if (!customElements.get('loom-zoom-controls')) {
13390
+ customElements.define('loom-zoom-controls', LoomZoomControls);
13391
+ }
13392
+ if (!customElements.get('loom-window-size')) {
13393
+ customElements.define('loom-window-size', LoomWindowSize);
13394
+ }
13395
+ if (!customElements.get('loom-export-controls')) {
13396
+ customElements.define('loom-export-controls', LoomExportControls);
13397
+ }
13398
+ const template = document.createElement('template');
13399
+ template.innerHTML = /* html */ `
13400
+ <style>
13401
+ :host {
13402
+ display: block;
13403
+ }
13404
+ .navbar {
13405
+ display: flex;
13406
+ align-items: center;
13407
+ justify-content: space-between;
13408
+ height: var(--loom-navbar-height, 32px);
13409
+ padding: var(--loom-navbar-padding, 0 8px);
13410
+ background: var(--loom-navbar-bg, #f3f3f3);
13411
+ border-bottom: var(--loom-border, 1px solid #ccc);
13412
+ box-sizing: border-box;
13413
+ gap: var(--loom-gap, 8px);
13414
+ }
13415
+ .left {
13416
+ display: flex;
13417
+ align-items: center;
13418
+ gap: var(--loom-gap, 8px);
13419
+ flex: 1;
13420
+ min-width: 0;
13421
+ }
13422
+ .right {
13423
+ display: flex;
13424
+ align-items: center;
13425
+ gap: var(--loom-gap, 8px);
13426
+ flex-shrink: 0;
13427
+ }
13428
+ </style>
13429
+ <div class="navbar">
13430
+ <div class="left">
13431
+ <loom-chromosome-select></loom-chromosome-select>
13432
+ <loom-locus-input></loom-locus-input>
13433
+ <loom-window-size></loom-window-size>
13434
+ </div>
13435
+ <div class="right">
13436
+ <loom-export-controls></loom-export-controls>
13437
+ <loom-zoom-controls></loom-zoom-controls>
13438
+ </div>
13439
+ </div>
13440
+ `;
13441
+ class LoomNavbar extends HTMLElement {
13442
+ constructor() {
13443
+ super();
13444
+ this._browser = null;
13445
+ this.attachShadow({ mode: 'open' });
13446
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
13447
+ this.chromSelect = this.shadowRoot.querySelector('loom-chromosome-select');
13448
+ this.locusInput = this.shadowRoot.querySelector('loom-locus-input');
13449
+ this.zoomControls = this.shadowRoot.querySelector('loom-zoom-controls');
13450
+ this.windowSize = this.shadowRoot.querySelector('loom-window-size');
13451
+ this.exportControls = this.shadowRoot.querySelector('loom-export-controls');
13452
+ }
13453
+ set browser(b) {
13454
+ this._browser = b;
13455
+ this.chromSelect.browser = b;
13456
+ this.locusInput.browser = b;
13457
+ this.zoomControls.browser = b;
13458
+ this.windowSize.browser = b;
13459
+ this.exportControls.browser = b;
13460
+ }
13461
+ get browser() {
13462
+ return this._browser;
13463
+ }
13464
+ }
13465
+
13466
+ /**
13467
+ * Ensures all Loom UI web components are registered as custom elements.
13468
+ * Safe to call multiple times — each element is only registered once.
13469
+ */
13470
+ let registered = false;
13471
+ function ensureRegistered() {
13472
+ if (registered || typeof customElements === 'undefined')
13473
+ return;
13474
+ registered = true;
13475
+ const elements = [
13476
+ ['loom-chromosome-select', LoomChromosomeSelect],
13477
+ ['loom-locus-input', LoomLocusInput],
13478
+ ['loom-zoom-controls', LoomZoomControls],
13479
+ ['loom-window-size', LoomWindowSize],
13480
+ ['loom-export-controls', LoomExportControls],
13481
+ ['loom-navbar', LoomNavbar],
13482
+ ];
13483
+ for (const [name, ctor] of elements) {
13484
+ if (!customElements.get(name)) {
13485
+ customElements.define(name, ctor);
13486
+ }
13487
+ }
13488
+ }
13489
+
13490
+ function ChromosomeSelect({ browser: browserProp }) {
13491
+ const contextBrowser = useGenomeBrowser();
13492
+ const browser = browserProp !== null && browserProp !== void 0 ? browserProp : contextBrowser;
13493
+ const ref = useRef(null);
13494
+ ensureRegistered();
13495
+ useEffect(() => {
13496
+ if (ref.current)
13497
+ ref.current.browser = browser;
13498
+ }, [browser]);
13499
+ return jsx("loom-chromosome-select", { ref: ref });
13500
+ }
13501
+
13502
+ function LocusInput({ browser: browserProp }) {
13503
+ const contextBrowser = useGenomeBrowser();
13504
+ const browser = browserProp !== null && browserProp !== void 0 ? browserProp : contextBrowser;
13505
+ const ref = useRef(null);
13506
+ ensureRegistered();
13507
+ useEffect(() => {
13508
+ if (ref.current)
13509
+ ref.current.browser = browser;
13510
+ }, [browser]);
13511
+ return jsx("loom-locus-input", { ref: ref });
13512
+ }
13513
+
13514
+ function ZoomControls({ browser: browserProp }) {
13515
+ const contextBrowser = useGenomeBrowser();
13516
+ const browser = browserProp !== null && browserProp !== void 0 ? browserProp : contextBrowser;
13517
+ const ref = useRef(null);
13518
+ ensureRegistered();
13519
+ useEffect(() => {
13520
+ if (ref.current)
13521
+ ref.current.browser = browser;
13522
+ }, [browser]);
13523
+ return jsx("loom-zoom-controls", { ref: ref });
13524
+ }
13525
+
13526
+ function WindowSize({ browser: browserProp }) {
13527
+ const contextBrowser = useGenomeBrowser();
13528
+ const browser = browserProp !== null && browserProp !== void 0 ? browserProp : contextBrowser;
13529
+ const ref = useRef(null);
13530
+ ensureRegistered();
13531
+ useEffect(() => {
13532
+ if (ref.current)
13533
+ ref.current.browser = browser;
13534
+ }, [browser]);
13535
+ return jsx("loom-window-size", { ref: ref });
13536
+ }
13537
+
13538
+ function ExportControls({ browser: browserProp }) {
13539
+ const contextBrowser = useGenomeBrowser();
13540
+ const browser = browserProp !== null && browserProp !== void 0 ? browserProp : contextBrowser;
13541
+ const ref = useRef(null);
13542
+ ensureRegistered();
13543
+ useEffect(() => {
13544
+ if (ref.current)
13545
+ ref.current.browser = browser;
13546
+ }, [browser]);
13547
+ return jsx("loom-export-controls", { ref: ref });
13548
+ }
13549
+
13550
+ /**
13551
+ * CSS custom property themes for UI shell web components.
13552
+ *
13553
+ * Themes are injected as <style> blocks into Shadow DOM roots.
13554
+ * Components reference CSS custom properties (--loom-*) for all visual styling.
13555
+ */
13556
+ /** Classic theme — matches igv.js navbar look. */
13557
+ const classicThemeCSS = /* css */ `
13558
+ :host {
13559
+ --loom-navbar-bg: #f3f3f3;
13560
+ --loom-navbar-height: 32px;
13561
+ --loom-navbar-padding: 0 8px;
13562
+ --loom-font: 12px Arial, sans-serif;
13563
+ --loom-font-small: 11px Arial, sans-serif;
13564
+ --loom-text-color: #333;
13565
+ --loom-text-muted: #737373;
13566
+ --loom-border: 1px solid #ccc;
13567
+ --loom-border-radius: 4px;
13568
+ --loom-button-bg: white;
13569
+ --loom-button-hover: #e8e8e8;
13570
+ --loom-button-border: 1px solid #b0b0b0;
13571
+ --loom-button-size: 24px;
13572
+ --loom-input-bg: white;
13573
+ --loom-input-border: 1px solid #b0b0b0;
13574
+ --loom-input-focus-border: 1px solid #4A90D9;
13575
+ --loom-input-width: 220px;
13576
+ --loom-input-height: 22px;
13577
+ --loom-accent: #4A90D9;
13578
+ --loom-icon-color: #555;
13579
+ --loom-icon-size: 14px;
13580
+ --loom-gap: 8px;
13581
+ }
13582
+ `;
13583
+ /** Modern theme — softer colors, rounded corners, taller navbar. */
13584
+ const modernThemeCSS = /* css */ `
13585
+ :host {
13586
+ --loom-navbar-bg: #fafbfc;
13587
+ --loom-navbar-height: 40px;
13588
+ --loom-navbar-padding: 0 12px;
13589
+ --loom-font: 13px Inter, system-ui, -apple-system, sans-serif;
13590
+ --loom-font-small: 11px Inter, system-ui, -apple-system, sans-serif;
13591
+ --loom-text-color: #1a1a1a;
13592
+ --loom-text-muted: #6b7280;
13593
+ --loom-border: 1px solid rgba(0, 0, 0, 0.08);
13594
+ --loom-border-radius: 8px;
13595
+ --loom-button-bg: white;
13596
+ --loom-button-hover: #f0f4ff;
13597
+ --loom-button-border: 1px solid rgba(0, 0, 0, 0.1);
13598
+ --loom-button-size: 28px;
13599
+ --loom-input-bg: white;
13600
+ --loom-input-border: 1px solid rgba(0, 0, 0, 0.12);
13601
+ --loom-input-focus-border: 1px solid #4A90D9;
13602
+ --loom-input-width: 260px;
13603
+ --loom-input-height: 28px;
13604
+ --loom-accent: #4A90D9;
13605
+ --loom-icon-color: #6b7280;
13606
+ --loom-icon-size: 16px;
13607
+ --loom-gap: 10px;
13608
+ }
13609
+ `;
13610
+ /** Dark theme — dark backgrounds, light text, high contrast. */
13611
+ const darkThemeCSS = /* css */ `
13612
+ :host {
13613
+ --loom-shell-bg: #16162a;
13614
+ --loom-navbar-bg: #1a1a2e;
13615
+ --loom-navbar-height: 36px;
13616
+ --loom-navbar-padding: 0 10px;
13617
+ --loom-font: 12px Arial, sans-serif;
13618
+ --loom-font-small: 11px Arial, sans-serif;
13619
+ --loom-text-color: #e0e0e0;
13620
+ --loom-text-muted: #888;
13621
+ --loom-border: 1px solid #333;
13622
+ --loom-border-radius: 4px;
13623
+ --loom-button-bg: #2a2a3e;
13624
+ --loom-button-hover: #3a3a50;
13625
+ --loom-button-border: 1px solid #444;
13626
+ --loom-button-size: 24px;
13627
+ --loom-input-bg: #2a2a3e;
13628
+ --loom-input-border: 1px solid #444;
13629
+ --loom-input-focus-border: 1px solid #6a9fd9;
13630
+ --loom-input-width: 220px;
13631
+ --loom-input-height: 22px;
13632
+ --loom-accent: #6a9fd9;
13633
+ --loom-icon-color: #b0b0b0;
13634
+ --loom-icon-size: 14px;
13635
+ --loom-gap: 8px;
13636
+ }
13637
+ `;
13638
+ function getThemeCSS(theme) {
13639
+ if (theme === 'modern')
13640
+ return modernThemeCSS;
13641
+ if (theme === 'dark')
13642
+ return darkThemeCSS;
13643
+ return classicThemeCSS;
13644
+ }
13645
+
13646
+ function Navbar({ browser: browserProp, theme: themeProp }) {
13647
+ var _a;
13648
+ const ctx = useContext(GenomeBrowserContext);
13649
+ const contextBrowser = useGenomeBrowser();
13650
+ const browser = browserProp !== null && browserProp !== void 0 ? browserProp : contextBrowser;
13651
+ const theme = (_a = themeProp !== null && themeProp !== void 0 ? themeProp : ctx === null || ctx === void 0 ? void 0 : ctx.shellTheme) !== null && _a !== void 0 ? _a : 'modern';
13652
+ const ref = useRef(null);
13653
+ const styleRef = useRef(null);
13654
+ ensureRegistered();
13655
+ useEffect(() => {
13656
+ if (ref.current)
13657
+ ref.current.browser = browser;
13658
+ }, [browser]);
13659
+ // Inject shell theme CSS into the navbar's shadow root
13660
+ useEffect(() => {
13661
+ const el = ref.current;
13662
+ if (!(el === null || el === void 0 ? void 0 : el.shadowRoot))
13663
+ return;
13664
+ if (!styleRef.current) {
13665
+ styleRef.current = document.createElement('style');
13666
+ el.shadowRoot.prepend(styleRef.current);
13667
+ }
13668
+ styleRef.current.textContent = getThemeCSS(theme);
13669
+ }, [theme]);
13670
+ return jsx("loom-navbar", { ref: ref });
13671
+ }
13672
+
12725
13673
  /**
12726
13674
  * <loom-context-menu> — Right-click context menu Web Component.
12727
13675
  *
@@ -13287,4 +14235,4 @@ var LoomInputDialog$1 = /*#__PURE__*/Object.freeze({
13287
14235
  LoomInputDialog: LoomInputDialog
13288
14236
  });
13289
14237
 
13290
- export { BedTrack, GeneTrack, GenomeBrowserContext, GtxTrack, InteractionTrack, LoomBrowser, RulerTrack, SequenceTrack, WigTrack, useBrowserEvent, useGenomeBrowser, useLocus };
14238
+ export { BedTrack, ChromosomeSelect, ExportControls, GeneTrack, GenomeBrowserContext, GtxTrack, InteractionTrack, LocusInput, LoomBrowser, Navbar, RulerTrack, SequenceTrack, WigTrack, WindowSize, ZoomControls, useBrowserEvent, useGenomeBrowser, useLocus };