UNPKG

@jbrowse/core

Version:

JBrowse 2 core libraries used by plugins

877 lines (876 loc) 26.9 kB
import { useEffect, useRef, useState } from 'react'; import { unzip } from '@gmod/bgzf-filehandle'; import useMeasure from '@jbrowse/core/util/useMeasure'; import { getEnv as getEnvMST, getParent, getSnapshot, hasParent, isAlive, isStateTreeNode, } from '@jbrowse/mobx-state-tree'; import { flushSync } from 'react-dom'; import { createRoot } from 'react-dom/client'; import { coarseStripHTML } from "./coarseStripHTML.js"; import { colord } from "./colord.js"; import { parseLocString } from "./locString.js"; import { checkStopToken } from "./stopToken.js"; import { isDisplayModel, isSessionModel, isTrackModel, isUriLocation, isViewModel, } from "./types/index.js"; export * from "./types/index.js"; export * from "./when.js"; export * from "./range.js"; export * from "./dedupe.js"; export * from "./coarseStripHTML.js"; export * from "./offscreenCanvasPonyfill.js"; export * from "./offscreenCanvasUtils.js"; export * from "./rpc.js"; export * from "./crypto.js"; const containingDisplayCache = new WeakMap(); const containingTrackCache = new WeakMap(); const containingViewCache = new WeakMap(); const sessionCache = new WeakMap(); export function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handle = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handle); }; }, [value, delay]); return debouncedValue; } export function useWidthSetter(view, padding) { const [ref, { width }] = useMeasure(); useEffect(() => { let token; if (width && isAlive(view)) { token = requestAnimationFrame(() => { view.setWidth(width); }); } return () => { if (token) { cancelAnimationFrame(token); } }; }, [padding, view, width]); return ref; } export function useDebouncedCallback(callback, wait = 400) { const argsRef = useRef(null); const timeout = useRef(null); useEffect(() => { if (timeout.current) { clearTimeout(timeout.current); } }, []); return function debouncedCallback(...args) { argsRef.current = args; if (timeout.current) { clearTimeout(timeout.current); } timeout.current = setTimeout(() => { if (argsRef.current) { callback(...argsRef.current); } }, wait); }; } export function findParentThat(node, predicate) { if (!hasParent(node)) { throw new Error('node does not have parent'); } let currentNode = getParent(node); while (currentNode && isAlive(currentNode)) { if (predicate(currentNode)) { return currentNode; } if (hasParent(currentNode)) { currentNode = getParent(currentNode); } else { break; } } throw new Error('no matching node found'); } export function springAnimate(fromValue, toValue, setValue, onFinish = () => { }, precision = 0, tension = 400, friction = 20, clamp = true) { const mass = 1; if (!precision) { precision = Math.abs(toValue - fromValue) / 1000; } let animationFrameId; function update(animation) { const time = performance.now(); let position = animation.lastPosition; let lastTime = animation.lastTime || time; let velocity = animation.lastVelocity || 0; if (time > lastTime + 64) { lastTime = time; } const numSteps = Math.floor(time - lastTime); for (let i = 0; i < numSteps; ++i) { const force = -tension * (position - toValue); const damping = -friction * velocity; const acceleration = (force + damping) / mass; velocity += (acceleration * 1) / 1000; position += (velocity * 1) / 1000; } const isVelocity = Math.abs(velocity) <= precision; const isDisplacement = tension !== 0 ? Math.abs(toValue - position) <= precision : true; const isOvershooting = clamp && tension !== 0 ? fromValue < toValue ? position > toValue : position < toValue : false; const endOfAnimation = isOvershooting || (isVelocity && isDisplacement); if (endOfAnimation) { setValue(toValue); onFinish(); } else { setValue(position); animationFrameId = requestAnimationFrame(() => { update({ lastPosition: position, lastTime: time, lastVelocity: velocity, }); }); } } return [ () => { update({ lastPosition: fromValue }); }, () => { cancelAnimationFrame(animationFrameId); }, ]; } export function findParentThatIs(node, predicate) { return findParentThat(node, predicate); } export function getSession(node) { const cached = sessionCache.get(node); if (cached && isAlive(cached)) { return cached; } try { const result = findParentThatIs(node, isSessionModel); sessionCache.set(node, result); return result; } catch (e) { throw new Error('no session model found!'); } } export function getContainingView(node) { const cached = containingViewCache.get(node); if (cached && isAlive(cached)) { return cached; } try { const result = findParentThatIs(node, isViewModel); containingViewCache.set(node, result); return result; } catch (e) { throw new Error('no containing view found'); } } export function getContainingTrack(node) { const cached = containingTrackCache.get(node); if (cached && isAlive(cached)) { return cached; } try { const result = findParentThatIs(node, isTrackModel); containingTrackCache.set(node, result); return result; } catch (e) { throw new Error('no containing track found'); } } export function getContainingDisplay(node) { const cached = containingDisplayCache.get(node); if (cached && isAlive(cached)) { return cached; } try { const result = findParentThatIs(node, isDisplayModel); containingDisplayCache.set(node, result); return result; } catch (e) { throw new Error('no containing display found'); } } export function assembleLocString(region) { return assembleLocStringFast(region, toLocale); } export function assembleLocStringFast(region, cb = (n) => n) { const { assemblyName, refName, start, end, reversed } = region; const assemblyNameString = assemblyName ? `{${assemblyName}}` : ''; let startString; if (start !== undefined) { startString = `:${cb(start + 1)}`; } else if (end !== undefined) { startString = ':1'; } else { startString = ''; } let endString; if (end !== undefined) { endString = start !== undefined && start + 1 === end ? '' : `..${cb(end)}`; } else { endString = start !== undefined ? '..' : ''; } let rev = ''; if (reversed) { rev = '[rev]'; } return `${assemblyNameString}${refName}${startString}${endString}${rev}`; } export function compareLocs(locA, locB) { const assemblyComp = locA.assemblyName || locB.assemblyName ? (locA.assemblyName || '').localeCompare(locB.assemblyName || '') : 0; if (assemblyComp) { return assemblyComp; } const refComp = locA.refName || locB.refName ? (locA.refName || '').localeCompare(locB.refName || '') : 0; if (refComp) { return refComp; } if (locA.start !== undefined && locB.start !== undefined) { const startComp = locA.start - locB.start; if (startComp) { return startComp; } } if (locA.end !== undefined && locB.end !== undefined) { const endComp = locA.end - locB.end; if (endComp) { return endComp; } } return 0; } export function compareLocStrings(a, b, isValidRefName) { const locA = parseLocString(a, isValidRefName); const locB = parseLocString(b, isValidRefName); return compareLocs(locA, locB); } export function clamp(num, min, max) { if (num < min) { return min; } if (num > max) { return max; } return num; } function roundToNearestPointOne(num) { return Math.round(num * 10) / 10; } export function bpToPx(bp, { reversed, end = 0, start = 0, }, bpPerPx) { return roundToNearestPointOne((reversed ? end - bp : bp - start) / bpPerPx); } const oneEightyOverPi = 180 / Math.PI; const piOverOneEighty = Math.PI / 180; export function radToDeg(radians) { return (radians * oneEightyOverPi) % 360; } export function degToRad(degrees) { return (degrees * piOverOneEighty) % (2 * Math.PI); } export function polarToCartesian(rho, theta) { return [rho * Math.cos(theta), rho * Math.sin(theta)]; } export function cartesianToPolar(x, y) { const rho = Math.sqrt(x * x + y * y); const theta = Math.atan(y / x); return [rho, theta]; } export function featureSpanPx(feature, region, bpPerPx) { return bpSpanPx(feature.get('start'), feature.get('end'), region, bpPerPx); } export function bpSpanPx(leftBp, rightBp, region, bpPerPx) { const start = bpToPx(leftBp, region, bpPerPx); const end = bpToPx(rightBp, region, bpPerPx); return region.reversed ? [end, start] : [start, end]; } export function calculateLayoutBounds(featureStart, featureEnd, layoutWidthBp, reversed) { const featureWidthBp = featureEnd - featureStart; const labelOverhangBp = Math.max(0, layoutWidthBp - featureWidthBp); return reversed ? [featureStart - labelOverhangBp, featureEnd] : [featureStart, featureStart + layoutWidthBp]; } export function iterMap(iter, func, sizeHint) { const results = Array.from({ length: sizeHint || 0 }); let counter = 0; for (const item of iter) { results[counter] = func(item); counter += 1; } return results; } export function findLastIndex(array, predicate) { let l = array.length; while (l--) { if (predicate(array[l], l, array)) { return l; } } return -1; } export function findLast(array, predicate) { let l = array.length; while (l--) { if (predicate(array[l], l, array)) { return array[l]; } } return undefined; } export function renameRegionIfNeeded(refNameMap, region, getSeqAdapterRefName) { if (isStateTreeNode(region) && !isAlive(region)) { return region; } const newRef = refNameMap?.[region.refName]; if (newRef) { return { ...(isStateTreeNode(region) ? getSnapshot(region) : region), refName: newRef, originalRefName: getSeqAdapterRefName?.(region.refName) ?? region.refName, }; } return region; } export async function renameRegionsIfNeeded(assemblyManager, args) { const { regions = [], adapterConfig } = args; if (!args.sessionId) { throw new Error('sessionId is required'); } const assemblyNames = regions.map(r => r.assemblyName); const uniqueAssemblyNames = [...new Set(assemblyNames)]; const assemblyData = Object.fromEntries(await Promise.all(uniqueAssemblyNames.map(async (name) => [ name, { refNameMap: await assemblyManager.getRefNameMapForAdapter(adapterConfig, name, args), assembly: assemblyManager.get(name), }, ]))); return { ...args, regions: regions.map((region, i) => { const { refNameMap, assembly } = assemblyData[assemblyNames[i]]; return renameRegionIfNeeded(refNameMap, region, assembly ? r => assembly.getSeqAdapterRefName(r) : undefined); }), }; } export function minmax(a, b) { return [Math.min(a, b), Math.max(a, b)]; } export function shorten(name, max = 70, short = 30) { return name.length > max ? `${name.slice(0, short)}...${name.slice(-short)}` : name; } export function shorten2(name, max = 70) { return name.length > max ? `${name.slice(0, max)}...` : name; } export function stringify({ refName, coord, assemblyName, oob, }, useAssemblyName) { return [ assemblyName && useAssemblyName ? `{${assemblyName}}` : '', refName ? `${shorten(refName)}:${toLocale(coord)}${oob ? ' (out of bounds)' : ''}` : '', ].join(''); } export const isElectron = /electron/i.test(typeof navigator !== 'undefined' ? navigator.userAgent : ''); export const complementTable = { S: 'S', w: 'w', T: 'A', r: 'y', a: 't', N: 'N', K: 'M', x: 'x', d: 'h', Y: 'R', V: 'B', y: 'r', M: 'K', h: 'd', k: 'm', C: 'G', g: 'c', t: 'a', A: 'T', n: 'n', W: 'W', X: 'X', m: 'k', v: 'b', B: 'V', s: 's', H: 'D', c: 'g', D: 'H', b: 'v', R: 'Y', G: 'C', }; export function revcom(str) { const revcomped = []; for (let i = str.length - 1; i >= 0; i--) { revcomped.push(complementTable[str[i]] ?? str[i]); } return revcomped.join(''); } export function reverse(str) { const reversed = []; for (let i = str.length - 1; i >= 0; i--) { reversed.push(str[i]); } return reversed.join(''); } export function complement(str) { const comp = []; for (let i = 0, l = str.length; i < l; i++) { comp.push(complementTable[str[i]] ?? str[i]); } return comp.join(''); } export const rIC = typeof jest === 'undefined' ? typeof window !== 'undefined' && window.requestIdleCallback ? window.requestIdleCallback : (cb) => setTimeout(() => { cb(); }, 1) : (cb) => { cb(); }; const widths = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.2796875, 0.2765625, 0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625, 0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125, 0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875, 1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625, 0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625, 0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625, 0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375, 0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625, 0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5, 0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875, 0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875, 0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625]; const avgWidth = 0.5279276315789471; export function measureText(str, fontSize = 10) { const s = String(str); let total = 0; for (let i = 0, l = s.length; i < l; i++) { total += widths[s.charCodeAt(i)] ?? avgWidth; } return total * fontSize; } export function getFrame(start, end, strand, phase) { return strand === 1 ? (((start + phase) % 3) + 1) : (-1 * ((end - phase) % 3) - 1); } export const defaultStarts = ['ATG']; export const defaultStops = ['TAA', 'TAG', 'TGA']; export const defaultCodonTable = { TCA: 'S', TCC: 'S', TCG: 'S', TCT: 'S', TTC: 'F', TTT: 'F', TTA: 'L', TTG: 'L', TAC: 'Y', TAT: 'Y', TAA: '*', TAG: '*', TGC: 'C', TGT: 'C', TGA: '*', TGG: 'W', CTA: 'L', CTC: 'L', CTG: 'L', CTT: 'L', CCA: 'P', CCC: 'P', CCG: 'P', CCT: 'P', CAC: 'H', CAT: 'H', CAA: 'Q', CAG: 'Q', CGA: 'R', CGC: 'R', CGG: 'R', CGT: 'R', ATA: 'I', ATC: 'I', ATT: 'I', ATG: 'M', ACA: 'T', ACC: 'T', ACG: 'T', ACT: 'T', AAC: 'N', AAT: 'N', AAA: 'K', AAG: 'K', AGC: 'S', AGT: 'S', AGA: 'R', AGG: 'R', GTA: 'V', GTC: 'V', GTG: 'V', GTT: 'V', GCA: 'A', GCC: 'A', GCG: 'A', GCT: 'A', GAC: 'D', GAT: 'D', GAA: 'E', GAG: 'E', GGA: 'G', GGC: 'G', GGG: 'G', GGT: 'G', }; export function generateCodonTable(table) { const tempCodonTable = {}; for (const codon of Object.keys(table)) { const aa = table[codon]; const nucs = []; for (let i = 0; i < 3; i++) { const nuc = codon.charAt(i); nucs[i] = []; nucs[i][0] = nuc.toUpperCase(); nucs[i][1] = nuc.toLowerCase(); } for (let i = 0; i < 2; i++) { const n0 = nucs[0][i]; for (let j = 0; j < 2; j++) { const n1 = nucs[1][j]; for (let k = 0; k < 2; k++) { const n2 = nucs[2][k]; const triplet = n0 + n1 + n2; tempCodonTable[triplet] = aa; } } } } return tempCodonTable; } export async function updateStatus(msg, cb, fn) { cb?.(msg); const res = await fn(); cb?.(''); return res; } export async function updateStatus2(msg, cb, stopToken, fn) { cb(msg); const res = await fn(); checkStopToken(stopToken); cb(''); return res; } export function hashCode(str) { let hash = 0; if (str.length === 0) { return hash; } for (let i = 0; i < str.length; i++) { const chr = str.charCodeAt(i); hash = (hash << 5) - hash + chr; hash |= 0; } return hash; } export function objectHash(obj) { return `${hashCode(JSON.stringify(obj))}`; } export async function bytesForRegions(regions, index) { const blockResults = await Promise.all(regions.map(r => index.blocksForRange(r.refName, r.start, r.end))); return sum(blockResults .flat() .map(block => block.maxv.blockPosition + 65535 - block.minv.blockPosition)); } export function isSupportedIndexingAdapter(type = '') { return [ 'Gff3TabixAdapter', 'VcfTabixAdapter', 'Gff3Adapter', 'VcfAdapter', ].includes(type); } export function getBpDisplayStr(total) { if (Math.floor(total / 1_000_000) > 0) { return `${reducePrecision(total / 1_000_000)}Mbp`; } else if (Math.floor(total / 1_000) > 0) { return `${reducePrecision(total / 1_000)}Kbp`; } else { return `${Math.floor(total)}bp`; } } export function reducePrecision(s, n = 3) { return toLocale(Number.parseFloat(s.toPrecision(n))); } export function getProgressDisplayStr(current, total) { if (Math.floor(total / 1_000_000) > 0) { return `${reducePrecision(current / 1_000_000)}/${reducePrecision(total / 1_000_000)}Mb`; } else if (Math.floor(total / 1_000) > 0) { return `${reducePrecision(current / 1_000)}/${reducePrecision(total / 1_000)}Kb`; } else { return `${reducePrecision(current)}/${reducePrecision(total)} bytes`; } } export function toLocale(n) { if (n < 1000) { return String(n); } const str = String(n); const len = str.length; let result = ''; for (let i = 0; i < len; i++) { if (i > 0 && (len - i) % 3 === 0) { result += ','; } result += str[i]; } return result; } export function getTickDisplayStr(totalBp, bpPerPx) { return Math.floor(bpPerPx / 1_000) > 0 ? `${toLocale(Number.parseFloat((totalBp / 1_000_000).toFixed(2)))}M` : toLocale(Math.floor(totalBp)); } export function getLayoutId({ sessionId, trackInstanceId, }) { return `${sessionId}-${trackInstanceId}`; } export function getStatsId({ sessionId, trackInstanceId, }) { return `${sessionId}-${trackInstanceId}`; } export function useLocalStorage(key, initialValue, enabled = true) { const [storedValue, setStoredValue] = useState(() => { if (typeof window === 'undefined' || !enabled) { return initialValue; } try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch (error) { console.error(error); return initialValue; } }); const setValue = (value) => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); if (typeof window !== 'undefined' && enabled) { window.localStorage.setItem(key, JSON.stringify(valueToStore)); } } catch (error) { console.error(error); } }; return [storedValue, setValue]; } export function getUriLink(value) { const { uri, baseUri = '' } = value; let href; try { href = new URL(uri, baseUri).href; } catch (e) { href = uri; } return href; } export function getStr(obj) { return isObject(obj) ? isUriLocation(obj) ? getUriLink(obj) : JSON.stringify(obj) : String(obj); } export function linkify(s) { const pattern = /(^|[\s\n]|<[A-Za-z]*\/?>)((?:https?|ftp):\/\/[-A-Z0-9+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|])/gi; return s.replaceAll(pattern, '$1<a href=\'$2\' target="_blank">$2</a>'); } export function measureGridWidth(elements, args) { const { padding = 30, minWidth = 80, fontSize = 12, maxWidth = 1000, stripHTML = false, } = args || {}; return max(elements .map(element => getStr(element)) .map(str => (stripHTML ? coarseStripHTML(str) : str)) .map(str => measureText(str, fontSize)) .map(n => Math.min(Math.max(n + padding, minWidth), maxWidth))); } export function getEnv(obj) { return getEnvMST(obj); } export function localStorageGetItem(item) { return typeof localStorage !== 'undefined' ? localStorage.getItem(item) : undefined; } export function localStorageSetItem(str, item) { if (typeof localStorage !== 'undefined') { localStorage.setItem(str, item); } } export function max(arr, init = Number.NEGATIVE_INFINITY) { let max = init; for (const entry of arr) { max = Math.max(entry, max); } return max; } export function min(arr, init = Number.POSITIVE_INFINITY) { let min = init; for (const entry of arr) { min = Math.min(entry, min); } return min; } export function sum(arr) { let sum = 0; for (const entry of arr) { sum += entry; } return sum; } export function avg(arr) { return sum(arr) / arr.length; } export function groupBy(array, predicate) { const result = {}; for (const value of array) { const t = predicate(value); if (!result[t]) { result[t] = []; } result[t].push(value); } return result; } export function notEmpty(value) { return value !== null && value !== undefined; } export function mergeIntervals(intervals, w = 5000) { if (intervals.length <= 1) { return intervals; } const stack = []; let top = null; intervals = intervals.sort((a, b) => a.start - b.start); stack.push(intervals[0]); for (let i = 1; i < intervals.length; i++) { top = stack.at(-1); if (top.end + w < intervals[i].start - w) { stack.push(intervals[i]); } else if (top.end < intervals[i].end) { top.end = Math.max(top.end, intervals[i].end); stack.pop(); stack.push(top); } } return stack; } export function gatherOverlaps(regions, w = 5000) { const memo = {}; for (const x of regions) { if (!memo[x.refName]) { memo[x.refName] = []; } memo[x.refName].push(x); } return Object.values(memo).flatMap(group => mergeIntervals(group.sort((a, b) => a.start - b.start), w)); } export function stripAlpha(str) { return colord(str).alpha(1).toHex(); } export function getStrokeProps(str) { if (str) { const c = colord(str); return { strokeOpacity: c.alpha(), stroke: c.alpha(1).toHex(), }; } else { return {}; } } export function getFillProps(str) { if (str) { const c = colord(str); return { fillOpacity: c.alpha(), fill: c.alpha(1).toHex(), }; } else { return {}; } } export function renderToStaticMarkup(node) { const div = document.createElement('div'); flushSync(() => { createRoot(div).render(node); }); return div.innerHTML.replaceAll(/\brgba\((.+?),[^,]+?\)/g, 'rgb($1)'); } export function isGzip(buf) { return buf[0] === 31 && buf[1] === 139 && buf[2] === 8; } export async function fetchAndMaybeUnzip(loc, opts = {}) { const { statusCallback = () => { } } = opts; const buf = await updateStatus('Downloading file', statusCallback, () => loc.readFile(opts)); return isGzip(buf) ? await updateStatus('Unzipping', statusCallback, () => unzip(buf)) : buf; } export async function fetchAndMaybeUnzipText(loc, opts) { const buffer = await fetchAndMaybeUnzip(loc, opts); if (buffer.length > 536_870_888) { throw new Error('Data exceeds maximum string length (512MB)'); } return new TextDecoder('utf8', { fatal: true }).decode(buffer); } export function isObject(x) { return typeof x === 'object' && x !== null; } export function localStorageGetNumber(key, defaultVal) { return +(localStorageGetItem(key) ?? defaultVal); } export function localStorageGetBoolean(key, defaultVal) { return Boolean(JSON.parse(localStorageGetItem(key) || JSON.stringify(defaultVal))); } export function localStorageSetBoolean(key, value) { localStorageSetItem(key, JSON.stringify(value)); } export function testAdapter(fileName, regex, adapterHint, expected) { return (regex.test(fileName) && !adapterHint) || adapterHint === expected; } export { default as SimpleFeature, isFeature, } from "./simpleFeature.js"; export { blobToDataURL } from "./blobToDataURL.js"; export { makeAbortableReaction } from "./makeAbortableReaction.js"; export * from "./aborting.js"; export * from "./linkify.js"; export * from "./locString.js"; export * from "./stopToken.js"; export * from "./tracks.js"; export * from "./fileHandleStore.js"; export { IntervalTree } from "./IntervalTree.js";