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
+
+[](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}
+
+
+
+