From 3b0470fcf3dd4a99ccc87a775e9f345dedc38775 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 13 Oct 2026 04:40:41 +0000 Subject: [PATCH 1/2] cytoscape-style: drop lodash for small local memoize and throttle lodash is CommonJS, so the library's named imports of it carried the whole of lodash (about 73 KB) into every app bundle that used the library -- including WebsiteAngular's embeddable diagram, which is over its size target (reactome/WebsiteAngular#356). Four functions were used: - isArray, isNumber: Array.isArray and typeof === 'number' (what lodash's do). - memoize: util/memoize.ts, keyed on the first argument or a resolver, with a Map as `cache` (callers clear it). memoize.spec.ts runs the same checks against lodash's and ours. - throttle: util/throttle.ts, a port of lodash's algorithm (debounce with leading, trailing, maxWait = wait). throttle.spec.ts compares the two call for call over fixed and seeded irregular timings; a simpler fixed-window version failed it ("a call just after a window": lodash runs at 1001 and 1200, it ran at 1000 and 1500), which is why it is a port. lodash is no longer a peer dependency of ngx-reactome-cytoscape-style. ngx-reactome-diagram still uses it and keeps its own dependency. Co-Authored-By: Claude Opus 5.5 --- .../ngx-reactome-cytoscape-style/package.json | 6 +- .../src/lib/drawer/image-builder.ts | 21 ++++--- .../src/lib/interactivity.ts | 6 +- .../src/lib/style.ts | 2 +- .../src/lib/util/memoize.spec.ts | 52 ++++++++++++++++ .../src/lib/util/memoize.ts | 27 ++++++++ .../src/lib/util/throttle.spec.ts | 51 ++++++++++++++++ .../src/lib/util/throttle.ts | 61 +++++++++++++++++++ 8 files changed, 210 insertions(+), 16 deletions(-) create mode 100644 projects/ngx-reactome-cytoscape-style/src/lib/util/memoize.spec.ts create mode 100644 projects/ngx-reactome-cytoscape-style/src/lib/util/memoize.ts create mode 100644 projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.spec.ts create mode 100644 projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.ts 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..99237e1 --- /dev/null +++ b/projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.spec.ts @@ -0,0 +1,51 @@ +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)); + }); + } +}); 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); + }; +} From 5ed25367d759f259cfe9709c9695826a67bb71a1 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 13 Oct 2026 04:46:49 +0000 Subject: [PATCH 2/2] throttle spec: the clock going backwards, and fn calling it again Co-Authored-By: Claude Opus 5.5 --- .../src/lib/util/throttle.spec.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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 index 99237e1..c17d180 100644 --- a/projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.spec.ts +++ b/projects/ngx-reactome-cytoscape-style/src/lib/util/throttle.spec.ts @@ -48,4 +48,41 @@ describe('throttle', () => { 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)); + }); });