diff --git a/projects/ngx-reactome-cytoscape-style/package.json b/projects/ngx-reactome-cytoscape-style/package.json index 01fb81a..3e85d94 100644 --- a/projects/ngx-reactome-cytoscape-style/package.json +++ b/projects/ngx-reactome-cytoscape-style/package.json @@ -13,15 +13,13 @@ "peerDependencies": { "cytoscape": "^3.30.0", "cytoscape-layers": "^3.0.0", - "chroma-js": "^3.1.2", - "lodash": "^4.17.21" + "chroma-js": "^3.1.2" }, "dependencies": { "tslib": "^2.3.0" }, "devDependencies": { - "@types/chroma-js": "^3.1.1", - "@types/lodash": "^4.17.0" + "@types/chroma-js": "^3.1.1" }, "sideEffects": false } diff --git a/projects/ngx-reactome-cytoscape-style/src/lib/drawer/image-builder.ts b/projects/ngx-reactome-cytoscape-style/src/lib/drawer/image-builder.ts index fcf7007..f1b1552 100644 --- a/projects/ngx-reactome-cytoscape-style/src/lib/drawer/image-builder.ts +++ b/projects/ngx-reactome-cytoscape-style/src/lib/drawer/image-builder.ts @@ -1,6 +1,6 @@ import cytoscape from 'cytoscape'; import { gene } from './shape/gene-shape'; -import { isArray, isNumber, memoize } from 'lodash'; +import { memoize } from '../util/memoize'; import { molecule } from './shape/molecule-shape'; import { protein } from './shape/protein-shape'; import { rna } from './shape/rna-shape'; @@ -105,8 +105,10 @@ export const imageBuilder = (properties: Properties, style: Style) => ...l, 'background-image': svgStr( l['background-image'] as string, - isNumber(l['background-width']) ? l['background-width'] : drawerParams.width, - isNumber(l['background-height']) ? l['background-height'] : drawerParams.height + typeof l['background-width'] === 'number' ? l['background-width'] : drawerParams.width, + typeof l['background-height'] === 'number' + ? l['background-height'] + : drawerParams.height ), })); @@ -167,20 +169,23 @@ function _expToGradient( exp: number | undefined; width: number; }[] = []; - const size = exps.reduce((l: number, e) => (e !== undefined && isArray(e) ? l + e[1] : l + 1), 0); + const size = exps.reduce( + (l: number, e) => (e !== undefined && Array.isArray(e) ? l + e[1] : l + 1), + 0 + ); // Nothing to divide the width between: no gradient, not NaN widths. if (!(size > 0)) return undefined; const delta = 1 / size; exps.forEach((exp, _i) => { const p = stops.length - 1; - const realExp = isArray(exp) ? exp[0]! : exp; + const realExp = Array.isArray(exp) ? exp[0]! : exp; if (stops.length !== 0 && stops[p].exp === realExp) { // A [value, count] pair is count shares of the width, merged or not. - const share = isArray(exp) ? delta * exp[1] : delta; + const share = Array.isArray(exp) ? delta * exp[1] : delta; stops[p].stop += share; stops[p].width += share; } else { - if (isArray(exp)) { + if (Array.isArray(exp)) { stops.push({ start: stops[p]?.stop || 0, stop: (stops[p]?.stop || 0) + delta * exp[1], @@ -316,7 +321,7 @@ export function clearDrawersCache() { for (const value of classToDrawers.values()) { value.cache.clear(); } - OMMITED_ICON.cache.clear!(); + OMMITED_ICON.cache.clear(); } function aggregate( diff --git a/projects/ngx-reactome-cytoscape-style/src/lib/interactivity.ts b/projects/ngx-reactome-cytoscape-style/src/lib/interactivity.ts index 3e3e129..26401d8 100644 --- a/projects/ngx-reactome-cytoscape-style/src/lib/interactivity.ts +++ b/projects/ngx-reactome-cytoscape-style/src/lib/interactivity.ts @@ -3,11 +3,11 @@ import { extract } from './properties-utils'; import { Properties } from './properties'; import { ReactomeEvent, ReactomeEventTypes } from './model/reactome-event.model'; import Layers, { IHTMLLayer, layers, LayersPlugin } from 'cytoscape-layers'; -import * as _ from 'lodash'; +import { throttle } from './util/throttle'; cytoscape.use(Layers); type RenderableHTMLElement = HTMLElement & { - render: _.DebouncedFunc<() => void>; + render: () => void; }; /** @@ -334,7 +334,7 @@ export class Interactivity { elem.style.display = 'flex'; const video = elem.children[0] as HTMLVideoElement; - elem.render = _.throttle(() => { + elem.render = throttle(() => { if (isElementInViewport(elem)) { // console.log('rendering', name) if ( diff --git a/projects/ngx-reactome-cytoscape-style/src/lib/style.ts b/projects/ngx-reactome-cytoscape-style/src/lib/style.ts index dc77409..1d7d4d2 100644 --- a/projects/ngx-reactome-cytoscape-style/src/lib/style.ts +++ b/projects/ngx-reactome-cytoscape-style/src/lib/style.ts @@ -910,7 +910,7 @@ export class Style { } clearCache() { - this.imageBuilder.cache.clear!(); + this.imageBuilder.cache.clear(); clearDrawersCache(); } diff --git a/projects/ngx-reactome-cytoscape-style/src/lib/util/memoize.spec.ts b/projects/ngx-reactome-cytoscape-style/src/lib/util/memoize.spec.ts new file mode 100644 index 0000000..a5007a5 --- /dev/null +++ b/projects/ngx-reactome-cytoscape-style/src/lib/util/memoize.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { memoize as lodashMemoize } from 'lodash'; +import { memoize } from './memoize'; + +// Replaces lodash's memoize, so it is held to lodash's behaviour. +/** What both offer, so the same checks run against each. */ +type Impl = unknown>( + fn: F, + resolver?: (...args: Parameters) => unknown +) => F & { cache: { clear(): void } }; + +describe('memoize', () => { + for (const [name, impl] of [ + ['lodash', lodashMemoize as unknown as Impl], + ['ours', memoize as Impl], + ] as const) { + it(`${name}: keys on the first argument by default`, () => { + const fn = vi.fn((_a: object, b: number) => b); + const m = impl(fn); + const key = {}; + expect(m(key, 1)).toBe(1); + expect(m(key, 2)).toBe(1); + expect(m({}, 3)).toBe(3); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it(`${name}: keys on what the resolver returns`, () => { + const fn = vi.fn((_p: object, size: { w: number }) => size.w * 2); + const m = impl(fn, (_p: object, size: { w: number }) => `${size.w}`); + expect(m({}, { w: 2 })).toBe(4); + expect(m({}, { w: 2 })).toBe(4); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it(`${name}: computes again once its cache is cleared`, () => { + const fn = vi.fn((a: string) => a.length); + const m = impl(fn); + m('abc'); + m.cache.clear(); + m('abc'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it(`${name}: remembers an undefined result`, () => { + const fn = vi.fn((_a: string) => undefined); + const m = impl(fn); + m('x'); + m('x'); + expect(fn).toHaveBeenCalledTimes(1); + }); + } +}); diff --git a/projects/ngx-reactome-cytoscape-style/src/lib/util/memoize.ts b/projects/ngx-reactome-cytoscape-style/src/lib/util/memoize.ts new file mode 100644 index 0000000..44237db --- /dev/null +++ b/projects/ngx-reactome-cytoscape-style/src/lib/util/memoize.ts @@ -0,0 +1,27 @@ +/** + * A function that remembers its results, keyed on its first argument or on what + * `resolver` returns for its arguments -- lodash's memoize, which this replaces. + * + * lodash is CommonJS, so a single named import of it carried the whole library + * (about 73 KB) into every bundle that used this one, the embeddable diagram's + * included. Nothing else here needed it. + */ +export type Memoized unknown> = F & { + /** Every remembered result; clear it to have them computed again. */ + readonly cache: Map>; +}; + +export function memoize unknown>( + fn: F, + resolver?: (...args: Parameters) => unknown +): Memoized { + const cache = new Map>(); + const memoized = (...args: Parameters): ReturnType => { + const key = resolver ? resolver(...args) : args[0]; + if (cache.has(key)) return cache.get(key) as ReturnType; + const result = fn(...args) as ReturnType; + cache.set(key, result); + return result; + }; + return Object.assign(memoized, { cache }) as unknown as Memoized; +} diff --git a/projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.spec.ts b/projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.spec.ts new file mode 100644 index 0000000..c17d180 --- /dev/null +++ b/projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.spec.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { throttle as lodashThrottle } from 'lodash'; +import { throttle } from './throttle'; + +// Replaces lodash's throttle(fn, wait), so it is held to lodash's behaviour: the +// first call runs at once, calls inside the window run once at its end, and a +// quiet window runs nothing. +describe('throttle', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + /** When `fn` ran, for calls made at the given times. */ + function runs(impl: (fn: () => void, wait: number) => () => void, callsAt: number[]) { + const at: number[] = []; + const start = Date.now(); + const t = impl(() => at.push(Date.now() - start), 500); + let now = 0; + for (const time of callsAt) { + vi.advanceTimersByTime(time - now); + now = time; + t(); + } + vi.advanceTimersByTime(5000); + return at; + } + + const sequences: [string, number[]][] = [ + ['one call', [0]], + ['a burst', [0, 10, 20, 30]], + ['steady calls', [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100]], + ['two bursts apart', [0, 50, 2000, 2050]], + ['a call just after a window', [0, 499, 501, 1200]], + ]; + + // And some irregular ones, the way render events arrive: fixed seeds, so a + // failure names a sequence that can be replayed. + for (const seed of [1, 7, 42, 1234]) { + let x = seed; + const next = () => (x = (x * 1103515245 + 12345) % 2 ** 31) / 2 ** 31; + const calls: number[] = []; + let time = 0; + for (let i = 0; i < 40; i++) calls.push((time += Math.floor(next() * 400))); + sequences.push([`irregular, seed ${seed}`, calls]); + } + + for (const [name, calls] of sequences) { + it(`runs when lodash's does: ${name}`, () => { + expect(runs(throttle, calls)).toEqual(runs(lodashThrottle, calls)); + }); + } + + /** When `fn` ran when the clock is set back mid-sequence, for each implementation. */ + function runsWithClockBack(impl: (fn: () => void, wait: number) => () => void) { + vi.setSystemTime(100_000); + const at: number[] = []; + const t = impl(() => at.push(Date.now()), 500); + t(); + vi.advanceTimersByTime(100); + t(); + // The clock goes back, as a system clock can: lodash treats that as a fresh start. + vi.setSystemTime(50_000); + t(); + vi.advanceTimersByTime(5000); + return at; + } + + it("runs when lodash's does when the clock goes backwards", () => { + expect(runsWithClockBack(throttle)).toEqual(runsWithClockBack(lodashThrottle)); + }); + + /** When `fn` ran when `fn` itself calls the throttled function, as a render can. */ + function runsReentrant(impl: (fn: () => void, wait: number) => () => void) { + const start = Date.now(); + const at: number[] = []; + let depth = 0; + const t: () => void = impl(() => { + at.push(Date.now() - start); + if (depth++ < 3) t(); + }, 500); + t(); + vi.advanceTimersByTime(5000); + return at; + } + + it("runs when lodash's does when fn calls it again", () => { + expect(runsReentrant(throttle)).toEqual(runsReentrant(lodashThrottle)); + }); +}); diff --git a/projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.ts b/projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.ts new file mode 100644 index 0000000..90bebcf --- /dev/null +++ b/projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.ts @@ -0,0 +1,61 @@ +/** + * `fn`, run at most once every `wait` milliseconds: lodash's throttle(fn, wait), + * which this replaces (see memoize.ts for why). + * + * A port of lodash's algorithm -- its throttle is its debounce with `leading`, + * `trailing` and `maxWait = wait` -- rather than a simpler timer. The first call + * runs at once; calls inside a window run `fn` once more when it ends; and a + * window's end follows the last call, as lodash's does. throttle.spec.ts holds + * the two to the same timings. + */ +export function throttle(fn: () => void, wait: number): () => void { + let lastCallTime: number | undefined; + let lastInvokeTime = 0; + let timer: ReturnType | undefined; + let pending = false; + + const invoke = (time: number) => { + pending = false; + lastInvokeTime = time; + fn(); + }; + + const shouldInvoke = (time: number) => { + if (lastCallTime === undefined) return true; + const sinceCall = time - lastCallTime; + return sinceCall >= wait || sinceCall < 0 || time - lastInvokeTime >= wait; + }; + + const remainingWait = (time: number) => + Math.min(wait - (time - (lastCallTime ?? time)), wait - (time - lastInvokeTime)); + + const timerExpired = () => { + const time = Date.now(); + if (shouldInvoke(time)) { + timer = undefined; + if (pending) invoke(time); + return; + } + timer = setTimeout(timerExpired, remainingWait(time)); + }; + + return () => { + const time = Date.now(); + const invoking = shouldInvoke(time); + pending = true; + lastCallTime = time; + if (invoking) { + if (timer === undefined) { + lastInvokeTime = time; + timer = setTimeout(timerExpired, wait); + invoke(time); + return; + } + clearTimeout(timer); + timer = setTimeout(timerExpired, wait); + invoke(time); + return; + } + timer ??= setTimeout(timerExpired, wait); + }; +}