diff --git a/.storybook/main.js b/.storybook/main.js index b323f245..571e1aa9 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -25,6 +25,10 @@ module.exports = { __dirname, "../packages/nightingale-new-core/src/index.ts" ), + "@nightingale-elements/nightingale-variation": path.resolve( + __dirname, + "../packages/nightingale-variation/src/index.ts" + ), }; config.optimization = { ...config.optimization, diff --git a/dev/benchmarks/variation-canvas.html b/dev/benchmarks/variation-canvas.html new file mode 100644 index 00000000..ca04f93d --- /dev/null +++ b/dev/benchmarks/variation-canvas.html @@ -0,0 +1,336 @@ + + + + + nightingale-variation-canvas benchmark + + + + + + + +

nightingale-variation vs nightingale-variation-canvas

+

+ Two-curve benchmark: initial load time (data setter → first draw) and + refresh time (zoom-pan redraw) across variant counts. Uses a seeded RNG + (seed = 42) so runs are reproducible. Components are rendered + offscreen in #stage. +

+

+ How to run: from the repo root, run + yarn benchmark:variation-canvas. That starts the dev server + and opens this page in your browser; click Run benchmark + below to start the sweep. No build step is required — the dev server + compiles TypeScript on the fly and serves directly from + packages/*/src. +

+ + + +

Results

+
+ +
+ + + + diff --git a/package.json b/package.json index 3fd81155..c90afcf6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "analyze:watch": "cem analyze --litelement --globs \"src/**/*.ts\" --watch", "serve": "wds --node-resolve --watch", "serve:prod": "MODE=prod yarn serve", + "benchmark:variation-canvas": "wds --node-resolve --watch --open /dev/benchmarks/variation-canvas.html", "checksize": "rollup -c ; cat my-element.bundled.js | gzip -9 | wc -c ; rm my-element.bundled.js", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook" diff --git a/packages/nightingale-variation-canvas/README.md b/packages/nightingale-variation-canvas/README.md new file mode 100644 index 00000000..111620fd --- /dev/null +++ b/packages/nightingale-variation-canvas/README.md @@ -0,0 +1,47 @@ +# nightingale-variation-canvas + +[![Published on NPM](https://img.shields.io/npm/v/@nightingale-elements/nightingale-variation-canvas.svg)](https://www.npmjs.com/package/@nightingale-elements/nightingale-variation-canvas) + +Alternative to `nightingale-variation`, using HTML canvas for rendering instead of SVG graphics. + +Canvas-based rendering scales much better with large variant datasets. The axes and the highlight overlay are still drawn in SVG. + +## Usage + +```html + +``` + +#### Setting the data through property + +```javascript +const track = document.querySelector("#variationId"); +track.data = myDataObject; +``` + +## API Reference + +This component inherits from `nightingale-variation` and exposes the same attributes, properties, and events. See the [nightingale-variation README](../nightingale-variation/README.md) for the full API. + +## Parity gaps + +Intentional differences versus `nightingale-variation`: + +1. **`VariationDatum.internalId` is no longer set as a render side effect.** The SVG render path writes `d.internalId = "var_${wildType}${start}${mutation}"` while drawing. The canvas draw path does not. The field remains on the `VariationDatum` type for consumers that set it themselves. + +2. **Click / mouseover dispatch falls back to `start` when `end` is missing.** The SVG version passes `datum.end` straight through to the highlight-event payload, which can be `undefined`/`NaN` for raw `VariationDatum` objects. The canvas version uses `start` as the fallback when `end` is missing or non-numeric, so highlight events always carry a usable range. + +## Performance + +A reproducible benchmark comparing SVG vs canvas across variant counts lives at `dev/benchmarks/variation-canvas.html`. Run it with: + +``` +yarn benchmark:variation-canvas +``` + +That starts the dev server and opens the page in your browser. Click **Run benchmark** to start the sweep. No build step is required — the dev server compiles TypeScript on the fly and serves directly from `packages/*/src`. diff --git a/packages/nightingale-variation-canvas/package.json b/packages/nightingale-variation-canvas/package.json new file mode 100644 index 00000000..b7bf99d6 --- /dev/null +++ b/packages/nightingale-variation-canvas/package.json @@ -0,0 +1,41 @@ +{ + "name": "@nightingale-elements/nightingale-variation-canvas", + "version": "5.6.0", + "description": "Variation track type of the viewer, implemented via HTML canvas.", + "files": [ + "dist", + "src" + ], + "main": "dist/index.js", + "module": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "scripts": { + "build": "rollup --config ../../rollup.config.mjs", + "test": "../../node_modules/.bin/jest --config ../../jest.config.js ./tests/*" + }, + "keywords": [ + "nightingale", + "webcomponents", + "customelements", + "variation" + ], + "repository": { + "type": "git", + "url": "https://github.com/ebi-webcomponents/nightingale.git" + }, + "bugs": { + "url": "https://github.com/ebi-webcomponents/nightingale/issues" + }, + "homepage": "https://ebi-webcomponents.github.io/nightingale/", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "dependencies": { + "@nightingale-elements/nightingale-new-core": "^5.6.0", + "@nightingale-elements/nightingale-variation": "^5.6.0", + "d3": "7.9.0" + } +} diff --git a/packages/nightingale-variation-canvas/src/index.ts b/packages/nightingale-variation-canvas/src/index.ts new file mode 100644 index 00000000..a4ea51c0 --- /dev/null +++ b/packages/nightingale-variation-canvas/src/index.ts @@ -0,0 +1,4 @@ +export * from "@nightingale-elements/nightingale-variation"; + +import NightingaleVariationCanvas from "./nightingale-variation-canvas"; +export default NightingaleVariationCanvas; diff --git a/packages/nightingale-variation-canvas/src/nightingale-variation-canvas.ts b/packages/nightingale-variation-canvas/src/nightingale-variation-canvas.ts new file mode 100644 index 00000000..237ed8f9 --- /dev/null +++ b/packages/nightingale-variation-canvas/src/nightingale-variation-canvas.ts @@ -0,0 +1,471 @@ +import { + createEvent, + customElementOnce, + Refresher, + Stamp, + withCanvas, +} from "@nightingale-elements/nightingale-new-core"; +import NightingaleVariation, { + VariationDatum, +} from "@nightingale-elements/nightingale-variation"; +import { BaseType, Selection } from "d3"; +import { html } from "lit"; + +// Re-declared locally because `ProcessedVariationData` is not exported from +// `@nightingale-elements/nightingale-variation`'s public entry point. Keep +// this shape in sync with `packages/nightingale-variation/src/nightingale-variation.ts`. +type ProcessedVariationData = { + type: string; + normal: string; + pos: number; + variants: VariationDatum[]; +}; + +/** Default circle radius if `VariationDatum.size` is not provided. Mirrors SVG default in `variationPlot.ts`. */ +const DEFAULT_RADIUS = 5; + +/** Upper bound used when determining hit-test candidate positions; real `datum.size` values are typically ≤ 10. + * Variants with `size > MAX_HIT_RADIUS` still render, but their outer ring won't be hit-testable — + * `buildVariantIndex` warns once on the console when this is detected. */ +const MAX_HIT_RADIUS = 10; + +/** Base opacity for variant circles. Matches `circle { opacity: 0.6 }` in SVG variation CSS. */ +const VARIANT_ALPHA = 0.6; + +// TODO(deprecation): when `nightingale-variation` is removed, lift the shared +// data-side logic (types, processVariants, proteinAPI, AA list, axis/y-scale +// setup) into a base class or utility module rather than absorbing it +// directly here. The current `extends withCanvas(NightingaleVariation)` is a +// shortcut for the co-existence period; once the SVG version is gone, the +// inheritance has to be undone. +@customElementOnce("nightingale-variation-canvas") +export default class NightingaleVariationCanvas extends withCanvas( + NightingaleVariation, +) { + /** Variants indexed by sequence position for fast hit-testing. */ + private variantIndex?: Map; + + /** Variant currently under the mouse. Drawn at full opacity to mirror the + * SVG `circle:hover { opacity: 1 }` affordance. */ + private hoveredVariant: VariationDatum | null = null; + + /** Variant most recently dispatched on a `mouseover` event. Tracked separately + * from `hoveredVariant` so we only fire `mouseover` / `mouseout` on actual + * boundary crossings, not on every `mousemove` over the same circle (which + * would spam consumers — particularly anything bound via `highlight-event="onmouseover"`). */ + private lastDispatchedVariant: VariationDatum | null = null; + + /** Foreground canvas layered above the SVG (via `pointer-events: none`), used + * to redraw the hovered variant at full opacity on top of the SVG highlight + * band so the band doesn't tint the circle. */ + private foregroundCanvasCtx?: CanvasRenderingContext2D; + + override render() { + return html` + +
+
+ + + + + +
+
+ `; + } + + override createFeatures() { + super.createFeatures(); + // updateScale (inside super.createFeatures) can mutate `this.height`. + // Re-run onDimensionsChange so the canvas CSS size tracks it; otherwise + // the oversized pixel buffer gets squashed into a stale CSS height. + this.onDimensionsChange(); + this.variantIndex = buildVariantIndex(this.processedData?.mutationArray); + if (this.svg) { + this.unbindEvents(this.svg); + this.bindEvents(this.svg); + } + this.requestDraw(); + } + + override zoomRefreshed() { + if (this.series) { + this.updateScale(); + this.onDimensionsChange(); + this.updateHighlight(); + } + this.requestDraw(); + } + + refresh() { + this.requestDraw(); + } + + override firstUpdated() { + super.firstUpdated(); + const foreground = + this.renderRoot.querySelector("canvas.foreground"); + this.foregroundCanvasCtx = foreground?.getContext("2d") ?? undefined; + if (foreground) { + foreground.style.width = `${this.width}px`; + foreground.style.height = `${this.height}px`; + } + this.requestDraw(); + } + + override onCanvasScaleChange() { + super.onCanvasScaleChange(); + this.refresh(); + } + + private readonly _backgroundStamp = new Stamp(() => ({ + processedData: this["processedData"], + canvasCtx: this["canvasCtx"], + width: this["width"], + height: this["height"], + canvasScale: this["canvasScale"], + length: this["length"], + "display-start": this["display-start"], + "display-end": this["display-end"], + "margin-left": this["margin-left"], + "margin-right": this["margin-right"], + "margin-top": this["margin-top"], + "margin-bottom": this["margin-bottom"], + condensedView: this["condensedView"], + rowHeight: this["rowHeight"], + colorConfig: this["colorConfig"], + })); + + /** Request canvas redraw (debounced). */ + private requestDraw = () => this._drawer.requestRefresh(); + private readonly _drawer = Refresher(() => this._draw()); + + /** Resolvers for `whenDrawn()` promises waiting on the next `_draw` to finish. */ + private _drawCompleteResolvers: Array<() => void> = []; + + /** Returns a promise that resolves after the next `_draw` finishes. Useful + * for tests and benchmarks that need to await actual draw completion rather + * than guessing with `requestAnimationFrame`. The caller is expected to have + * just triggered a redraw (e.g. via `data =` or a `display-start`/`display-end` + * change); if no redraw is pending, the promise will not resolve until the + * next one happens. */ + whenDrawn(): Promise { + return new Promise((resolve) => { + this._drawCompleteResolvers.push(resolve); + }); + } + + /** Do not call directly — call `requestDraw` instead to avoid blocking the main thread. */ + private _draw(): void { + const backgroundChanged = this._backgroundStamp.update().changed; + if (backgroundChanged) { + this.adjustCanvasCtxLogicalSize(); + this.drawCanvasContent(); + } + // Foreground is a single circle — cheap to redraw every tick, which covers + // hover changes without forcing a full background redraw. + this.adjustForegroundCtxLogicalSize(); + this.drawForegroundContent(); + + if (this._drawCompleteResolvers.length > 0) { + const resolvers = this._drawCompleteResolvers; + this._drawCompleteResolvers = []; + for (const resolve of resolvers) resolve(); + } + } + + private adjustForegroundCtxLogicalSize() { + const fg = this.foregroundCanvasCtx; + if (!fg) return; + const newWidth = Math.floor(this.width * this.canvasScale); + const newHeight = Math.floor(this.height * this.canvasScale); + if (fg.canvas.width !== newWidth) fg.canvas.width = newWidth; + if (fg.canvas.height !== newHeight) fg.canvas.height = newHeight; + fg.canvas.style.width = `${this.width}px`; + fg.canvas.style.height = `${this.height}px`; + } + + private drawForegroundContent(): void { + const ctx = this.foregroundCanvasCtx; + if (!ctx) return; + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + const hovered = this.hoveredVariant; + if (!hovered || !this.yScale) return; + const aa = hovered.variant?.charAt(0); + if (!aa) return; + const yRow = this.yScale(aa); + if (yRow === undefined) return; + + const scale = this.canvasScale; + const halfBaseWidth = 0.5 * this.getSingleBaseWidth(); + const cx = + scale * (this.getXFromSeqPosition(hovered.start) + halfBaseWidth); + const cy = scale * (this["margin-top"] + yRow); + const r = scale * (hovered.size ?? DEFAULT_RADIUS); + + ctx.globalAlpha = 1; + ctx.fillStyle = hovered.color ?? this.colorConfig(hovered); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, 2 * Math.PI); + ctx.fill(); + } + + private drawCanvasContent() { + const ctx = this.canvasCtx; + if (!ctx) return; + const canvasWidth = ctx.canvas.width; + const canvasHeight = ctx.canvas.height; + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + const mutations = this.processedData?.mutationArray; + if (!mutations || !this.yScale) return; + + const scale = this.canvasScale; + const halfBaseWidth = 0.5 * this.getSingleBaseWidth(); + const leftEdgeSeq = this.getSeqPositionFromX(-MAX_HIT_RADIUS) ?? -Infinity; + const rightEdgeSeq = + this.getSeqPositionFromX(canvasWidth / scale + MAX_HIT_RADIUS) ?? + Infinity; + const marginTop = this["margin-top"]; + + ctx.globalAlpha = VARIANT_ALPHA; + + for (const entry of mutations) { + if (entry.pos < leftEdgeSeq || entry.pos > rightEdgeSeq) continue; + if (entry.variants.length === 0) continue; + + const cx = scale * (this.getXFromSeqPosition(entry.pos) + halfBaseWidth); + + for (const variant of entry.variants) { + const aa = variant.variant?.charAt(0); + if (!aa) continue; + const yRow = this.yScale(aa); + if (yRow === undefined) continue; + + const cy = scale * (marginTop + yRow); + const r = scale * (variant.size ?? DEFAULT_RADIUS); + ctx.fillStyle = variant.color ?? this.colorConfig(variant); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, 2 * Math.PI); + ctx.fill(); + } + } + + ctx.globalAlpha = 1; + } + + private setHoveredVariant(variant: VariationDatum | null): void { + if (this.hoveredVariant === variant) return; + this.hoveredVariant = variant; + this.requestDraw(); + } + + private getVariantAt( + offsetX: number, + offsetY: number, + ): VariationDatum | undefined { + if (!this.variantIndex || !this.yScale) return undefined; + const baseWidth = this.getSingleBaseWidth(); + if (!(baseWidth > 0)) return undefined; + + const halfBaseWidth = 0.5 * baseWidth; + const seqMin = this.getSeqPositionFromX(offsetX - MAX_HIT_RADIUS); + const seqMax = this.getSeqPositionFromX(offsetX + MAX_HIT_RADIUS); + if (seqMin === undefined || seqMax === undefined) return undefined; + + const posMin = Math.max(1, Math.floor(seqMin)); + const posMax = Math.ceil(seqMax); + + const marginTop = this["margin-top"]; + let hit: VariationDatum | undefined; + for (let pos = posMin; pos <= posMax; pos++) { + const variants = this.variantIndex.get(pos); + if (!variants) continue; + const cx = this.getXFromSeqPosition(pos) + halfBaseWidth; + for (const variant of variants) { + const aa = variant.variant?.charAt(0); + if (!aa) continue; + const yRow = this.yScale(aa); + if (yRow === undefined) continue; + const cy = marginTop + yRow; + const r = variant.size ?? DEFAULT_RADIUS; + const dx = offsetX - cx; + const dy = offsetY - cy; + if (dx * dx + dy * dy <= r * r) { + // Later-drawn variants appear on top; keep the last match. + hit = variant; + } + } + } + return hit; + } + + private bindEvents( + target: Selection, + ): void { + target.on("click.NightingaleVariationCanvas", (event: MouseEvent) => + this.handleClick(event), + ); + target.on("mousemove.NightingaleVariationCanvas", (event: MouseEvent) => + this.handleMousemove(event), + ); + target.on("mouseout.NightingaleVariationCanvas", (event: MouseEvent) => + this.handleMouseout(event), + ); + } + + private unbindEvents( + target: Selection, + ): void { + target.on("click.NightingaleVariationCanvas", null); + target.on("mousemove.NightingaleVariationCanvas", null); + target.on("mouseout.NightingaleVariationCanvas", null); + } + + private getLocalCoords(event: MouseEvent): { x: number; y: number } | null { + const svgNode = this.svg?.node(); + if (!svgNode) return null; + const rect = svgNode.getBoundingClientRect(); + return { x: event.clientX - rect.left, y: event.clientY - rect.top }; + } + + private handleClick(event: MouseEvent): void { + const coords = this.getLocalCoords(event); + if (!coords) return; + const variant = this.getVariantAt(coords.x, coords.y); + if (!variant) return; + const withHighlight = this.getAttribute("highlight-event") === "onclick"; + const customEvent = createEvent( + "click", + variant, + withHighlight, + true, + variant.start, + getVariantEnd(variant), + event.target instanceof HTMLElement ? event.target : undefined, + event, + this, + ); + this.dispatchEvent(customEvent); + } + + private handleMousemove(event: MouseEvent): void { + const coords = this.getLocalCoords(event); + if (!coords) return; + const variant = this.getVariantAt(coords.x, coords.y) ?? null; + this.setHoveredVariant(variant); + + // Only dispatch on transitions: empty→variant, variant→variant', variant→empty. + if (variant === this.lastDispatchedVariant) return; + if (this.lastDispatchedVariant) { + this.dispatchMouseout(event); + } + if (variant) { + this.dispatchMouseover(event, variant); + } + this.lastDispatchedVariant = variant; + } + + private handleMouseout(event: MouseEvent): void { + this.setHoveredVariant(null); + if (!this.lastDispatchedVariant) return; + this.dispatchMouseout(event); + this.lastDispatchedVariant = null; + } + + private dispatchMouseover(event: MouseEvent, variant: VariationDatum): void { + const withHighlight = + this.getAttribute("highlight-event") === "onmouseover"; + const customEvent = createEvent( + "mouseover", + variant, + withHighlight, + false, + variant.start, + getVariantEnd(variant), + event.target instanceof HTMLElement ? event.target : undefined, + event, + this, + ); + this.dispatchEvent(customEvent); + } + + private dispatchMouseout(event: MouseEvent): void { + const withHighlight = + this.getAttribute("highlight-event") === "onmouseover"; + const customEvent = createEvent( + "mouseout", + null, + withHighlight, + undefined, + undefined, + undefined, + event.target instanceof HTMLElement ? event.target : undefined, + event, + this, + ); + this.dispatchEvent(customEvent); + } +} + +/** Resolve a variant's end position for highlight-event dispatch. Protein-API + * variants carry `end` as a string (spread from the raw Variant type); plain + * VariationDatum objects have no `end` field — fall back to `start` so the + * column highlight still fires. */ +function getVariantEnd(variant: VariationDatum): number { + const end = (variant as unknown as { end?: number | string }).end; + if (end === undefined || end === null || end === "") return variant.start; + const n = Number(end); + return Number.isFinite(n) ? n : variant.start; +} + +function buildVariantIndex( + mutationArray: ProcessedVariationData[] | undefined, +): Map { + const index = new Map(); + if (!mutationArray) return index; + let warnedOversized = false; + for (const entry of mutationArray) { + if (entry.variants.length === 0) continue; + index.set(entry.pos, entry.variants); + if (!warnedOversized) { + for (const v of entry.variants) { + if ((v.size ?? 0) > MAX_HIT_RADIUS) { + // eslint-disable-next-line no-console + console.warn( + `nightingale-variation-canvas: variant size ${v.size} exceeds MAX_HIT_RADIUS=${MAX_HIT_RADIUS}; the outer ring will render but may not be hit-testable.`, + ); + warnedOversized = true; + break; + } + } + } + } + return index; +} diff --git a/packages/nightingale-variation-canvas/tests/nightingale-variation-canvas.test.ts b/packages/nightingale-variation-canvas/tests/nightingale-variation-canvas.test.ts new file mode 100644 index 00000000..744fbf85 --- /dev/null +++ b/packages/nightingale-variation-canvas/tests/nightingale-variation-canvas.test.ts @@ -0,0 +1,173 @@ +import NightingaleVariationCanvas from "../src/nightingale-variation-canvas"; +import type { + VariationData, + VariationDatum, +} from "@nightingale-elements/nightingale-variation"; + +const minimalData: VariationData = { + sequence: "MEEP", + variants: [ + { + accession: "v1", + variant: "A", + start: 2, + xrefNames: [], + hasPredictions: false, + consequenceType: "missense", + }, + { + accession: "v2", + variant: "G", + start: 3, + xrefNames: [], + hasPredictions: false, + consequenceType: "missense", + }, + ], +}; + +describe("nightingale-variation-canvas", () => { + let element: NightingaleVariationCanvas; + + beforeEach(() => { + element = new NightingaleVariationCanvas(); + element.setAttribute("length", "4"); + element.setAttribute("display-start", "1"); + element.setAttribute("display-end", "4"); + element.setAttribute("height", "200"); + document.body.appendChild(element); + }); + + afterEach(() => { + document.body.removeChild(element); + }); + + test("registers as a custom element", () => { + expect(customElements.get("nightingale-variation-canvas")).toBeDefined(); + }); + + test("processData builds the expected mutation array", () => { + element.processData(minimalData); + expect(element.processedData).toBeTruthy(); + expect(element.processedData?.mutationArray).toHaveLength(4); + expect(element.processedData?.mutationArray[1].variants).toHaveLength(1); + expect(element.processedData?.mutationArray[2].variants).toHaveLength(1); + expect(element.processedData?.aaPresence.A).toBe(true); + expect(element.processedData?.aaPresence.G).toBe(true); + }); + + test("setting data runs processData without throwing", () => { + expect(() => { + element.data = minimalData; + }).not.toThrow(); + expect(element.data).toBe(minimalData); + }); +}); + +/** + * Event-dispatch tests: the canvas component reimplements pointer interaction + * on top of `mousemove`, so we explicitly assert that mouseover/mouseout fire + * once per actual enter/leave (not on every mousemove). This is the regression + * guard for the spam bug fixed in this package's first review pass. + * + * The hit-test logic itself depends on layout primitives (yScale, + * getXFromSeqPosition, getBoundingClientRect) that aren't reliable in jsdom, + * so we stub `getVariantAt` and `getLocalCoords` directly and verify the + * transition-tracking layer on top. + */ +describe("nightingale-variation-canvas event dispatch", () => { + let element: NightingaleVariationCanvas; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let internals: any; + let dispatched: Array<{ type: string; eventType: string }>; + + const variantA: VariationDatum = { + accession: "vA", + variant: "A", + start: 2, + xrefNames: [], + hasPredictions: false, + consequenceType: "missense", + }; + const variantB: VariationDatum = { + accession: "vB", + variant: "G", + start: 3, + xrefNames: [], + hasPredictions: false, + consequenceType: "missense", + }; + + beforeEach(() => { + element = new NightingaleVariationCanvas(); + element.setAttribute("length", "4"); + element.setAttribute("display-start", "1"); + element.setAttribute("display-end", "4"); + element.setAttribute("height", "200"); + document.body.appendChild(element); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + internals = element as any; + internals.getLocalCoords = () => ({ x: 0, y: 0 }); + + dispatched = []; + element.addEventListener("change", (e: Event) => { + const ce = e as CustomEvent<{ eventType: string }>; + dispatched.push({ type: e.type, eventType: ce.detail?.eventType }); + }); + }); + + afterEach(() => { + document.body.removeChild(element); + }); + + function move(over: VariationDatum | null) { + internals.getVariantAt = () => over ?? undefined; + internals.handleMousemove(new MouseEvent("mousemove")); + } + + test("fires mouseover exactly once when entering a variant", () => { + move(variantA); + move(variantA); // same variant — should not re-dispatch + move(variantA); + const overs = dispatched.filter((d) => d.eventType === "mouseover"); + expect(overs).toHaveLength(1); + }); + + test("does not fire mouseout while moving over empty space", () => { + move(null); + move(null); + move(null); + const outs = dispatched.filter((d) => d.eventType === "mouseout"); + expect(outs).toHaveLength(0); + }); + + test("fires mouseout once on leaving a variant for empty space", () => { + move(variantA); + dispatched.length = 0; + move(null); + move(null); + expect(dispatched.map((d) => d.eventType)).toEqual(["mouseout"]); + }); + + test("fires mouseout+mouseover when moving directly between variants", () => { + move(variantA); + dispatched.length = 0; + move(variantB); + expect(dispatched.map((d) => d.eventType)).toEqual([ + "mouseout", + "mouseover", + ]); + }); + + test("native mouseout (leaving the SVG) clears the dispatch state", () => { + move(variantA); + dispatched.length = 0; + internals.handleMouseout(new MouseEvent("mouseout")); + expect(dispatched.map((d) => d.eventType)).toEqual(["mouseout"]); + // A subsequent mouseout should not double-fire. + internals.handleMouseout(new MouseEvent("mouseout")); + expect( + dispatched.filter((d) => d.eventType === "mouseout"), + ).toHaveLength(1); + }); +}); diff --git a/packages/nightingale-variation-canvas/tsconfig.json b/packages/nightingale-variation-canvas/tsconfig.json new file mode 100644 index 00000000..6a62dbc4 --- /dev/null +++ b/packages/nightingale-variation-canvas/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/nightingale-variation/src/index.ts b/packages/nightingale-variation/src/index.ts index 9dedc389..952372c6 100644 --- a/packages/nightingale-variation/src/index.ts +++ b/packages/nightingale-variation/src/index.ts @@ -1,6 +1,9 @@ -import NightingaleVariation, { - VariationDatum, - VariationData, -} from "./nightingale-variation"; -export { NightingaleVariation as default, VariationDatum, VariationData }; +import NightingaleVariation from "./nightingale-variation"; +export { NightingaleVariation as default }; +// Marked as `export type` so per-file transpilers (e.g. the dev server's +// `ts.transpileModule` path) drop these from the JS output entirely. A plain +// `export { … }` re-export forces TS to keep them as runtime bindings, which +// then fails at module-link time in the browser because the source uses +// `export type` for the underlying declarations. +export type { VariationDatum, VariationData } from "./nightingale-variation"; export * from "./proteinAPI"; diff --git a/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.mdx b/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.mdx new file mode 100644 index 00000000..3b608cee --- /dev/null +++ b/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.mdx @@ -0,0 +1,13 @@ +import { Meta, Description } from "@storybook/addon-docs"; + +import Readme from "../../packages/nightingale-variation-canvas/README.md"; + + + +{Readme} + +
+ +

Parent Class

+ +See more details of the parent class: [nightingale-variation](?path=/story/components-tracks-variation-readme--page) diff --git a/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.ts b/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.ts new file mode 100644 index 00000000..a1f04207 --- /dev/null +++ b/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.ts @@ -0,0 +1,121 @@ +import { Meta, Story } from "@storybook/web-components"; +import { html } from "lit-html"; +import "../../packages/nightingale-variation-canvas/src/index"; +import "../../packages/nightingale-navigation/src/index.ts"; +import "../../packages/nightingale-sequence/src/index.ts"; +import "../../packages/nightingale-manager/src/index.ts"; + +import variationP99999 from "../../packages/nightingale-variation/tests/P99999.variation.json"; +import variationP42336 from "../../packages/nightingale-variation/tests/P42336.variation.json"; +import { ProteinsAPIVariation } from "../../packages/nightingale-variation/src/proteinAPI.js"; + +const data: Record = { + P99999: variationP99999 as unknown as ProteinsAPIVariation, + P42336: variationP42336 as unknown as ProteinsAPIVariation, +}; + +export default { + title: "Components/Tracks/NightingaleVariation-Canvas", +} as Meta; + +const Template: Story<{ + rowHeight: number; + width: number; + displayStart: number; + displayEnd: number; + marginLeft: number; + protein: "P99999" | "P42336"; + condensedView: boolean; +}> = (args) => { + setTimeout(async () => { + await customElements.whenDefined("nightingale-variation-canvas"); + const variationTrack = document.getElementById("variation-canvas"); + if (variationTrack) { + (variationTrack as any).data = data[args.protein]; + } + }, 500); + return html` + + `; +}; + +export const BasicVariationCanvas = Template.bind({}); +BasicVariationCanvas.args = { + rowHeight: 15, + width: 800, + displayStart: 1, + displayEnd: 50, + marginLeft: 20, + protein: "P99999", + condensedView: false, +}; +BasicVariationCanvas.argTypes = { + protein: { + options: ["P99999", "P42336"], + control: { type: "radio" }, + }, +}; + +BasicVariationCanvas.play = async () => { + await customElements.whenDefined("nightingale-variation-canvas"); + const variationTrack = document.getElementById("variation-canvas"); + if (variationTrack) { + (variationTrack as any).colorConfig = (v: any) => { + if (v.hasPredictions) return "green"; + return "#DD2121"; + }; + } +}; + +export const CompleteView = () => html` + + +
+ +
+
+ +
+
+ +
+
+`; +CompleteView.play = async () => { + await customElements.whenDefined("nightingale-variation-canvas"); + const canvas = document.getElementById("variation-canvas-complete"); + if (canvas) (canvas as any).data = data.P99999; +}; diff --git a/web-dev-server.config.mjs b/web-dev-server.config.mjs new file mode 100644 index 00000000..01f5a04e --- /dev/null +++ b/web-dev-server.config.mjs @@ -0,0 +1,65 @@ +import { createRequire } from "module"; + +// Load `typescript` via createRequire so we get the package's CJS module +// object (it isn't ESM-friendly via a default import in some Node versions). +const require = createRequire(import.meta.url); +const ts = require("typescript"); + +const PACKAGE_PREFIX = "@nightingale-elements/"; + +/* + * Resolves bare imports of `@nightingale-elements/` to that package's + * source entry point in this monorepo, so `dev/` and `dev/benchmarks/` pages + * can run without a `yarn build` step. The dev server compiles TypeScript on + * the fly and serves source directly from each package's `src/index.ts`. + */ +const nightingaleSrcResolver = { + name: "nightingale-src-resolver", + resolveImport({ source }) { + if (!source.startsWith(PACKAGE_PREFIX)) return; + const name = source.slice(PACKAGE_PREFIX.length); + return `/packages/${name}/src/index.ts`; + }, +}; + +/** + * Per-request TypeScript → JavaScript transpile using `ts.transpileModule`. + * Single-file transpile (no cross-file type checking) is exactly what the + * dev server needs — fast, no rollup dependency, no native binaries. + */ +const typescriptTranspilePlugin = { + name: "typescript-transpile", + transform(context) { + if (!context.path.endsWith(".ts") && !context.path.endsWith(".tsx")) return; + const source = + typeof context.body === "string" + ? context.body + : context.body?.toString("utf8") ?? ""; + const result = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ES2020, + experimentalDecorators: true, + useDefineForClassFields: false, + esModuleInterop: false, + allowSyntheticDefaultImports: true, + importHelpers: false, + inlineSources: true, + inlineSourceMap: true, + jsx: context.path.endsWith(".tsx") ? ts.JsxEmit.Preserve : undefined, + }, + fileName: context.path, + }); + return { body: result.outputText }; + }, +}; + +export default { + nodeResolve: true, + watch: true, + mimeTypes: { + "**/*.ts": "js", + "**/*.tsx": "js", + }, + plugins: [nightingaleSrcResolver, typescriptTranspilePlugin], +};