From 90fce68e19293dfd0f7763bc12ec098f78c050b7 Mon Sep 17 00:00:00 2001 From: Minjoon Kim Date: Fri, 24 Apr 2026 10:11:51 +0100 Subject: [PATCH 1/5] initial canvas port --- .storybook/main.js | 4 + dev/benchmarks/variation-canvas.html | 324 ++++++++++++++++++ nightingale-variation-canvas-spec.md | 263 ++++++++++++++ .../nightingale-variation-canvas/README.md | 40 +++ .../nightingale-variation-canvas/package.json | 41 +++ .../nightingale-variation-canvas/src/index.ts | 4 + .../src/nightingale-variation-canvas.ts | 323 +++++++++++++++++ .../nightingale-variation-canvas.test.ts | 62 ++++ .../tsconfig.json | 7 + .../NightingaleVariationCanvas.stories.mdx | 13 + .../NightingaleVariationCanvas.stories.ts | 144 ++++++++ 11 files changed, 1225 insertions(+) create mode 100644 dev/benchmarks/variation-canvas.html create mode 100644 nightingale-variation-canvas-spec.md create mode 100644 packages/nightingale-variation-canvas/README.md create mode 100644 packages/nightingale-variation-canvas/package.json create mode 100644 packages/nightingale-variation-canvas/src/index.ts create mode 100644 packages/nightingale-variation-canvas/src/nightingale-variation-canvas.ts create mode 100644 packages/nightingale-variation-canvas/tests/nightingale-variation-canvas.test.ts create mode 100644 packages/nightingale-variation-canvas/tsconfig.json create mode 100644 stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.mdx create mode 100644 stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.ts diff --git a/.storybook/main.js b/.storybook/main.js index b323f2452..571e1aa95 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 000000000..da66ff613 --- /dev/null +++ b/dev/benchmarks/variation-canvas.html @@ -0,0 +1,324 @@ + + + + + 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. +

+

+ Prerequisite: run yarn build at the repo + root before opening this page — the benchmark imports from + packages/*/dist/. +

+ + + +

Results

+
+ +
+ + + + diff --git a/nightingale-variation-canvas-spec.md b/nightingale-variation-canvas-spec.md new file mode 100644 index 000000000..f54939ac4 --- /dev/null +++ b/nightingale-variation-canvas-spec.md @@ -0,0 +1,263 @@ +# Spec: `nightingale-variation-canvas` + +## Purpose +Create a canvas-backed variant of `nightingale-variation` that preserves the existing public API and behavior, following the pattern established by `nightingale-track-canvas`. Benchmark both implementations to quantify the improvement. + +## Non-Goals +- No changes to `nightingale-variation` (or any other package). +- No changes to data models or API responses. +- No new public attributes, events, or methods beyond what `nightingale-variation` already exposes. +- No new visual features — canvas is a pure rendering-backend swap. +- No promotion of shared utilities to `nightingale-new-core` as part of this work (tracked separately as future cleanup). + +## Context: Why a second package and not an option on the existing one? +Same rationale `nightingale-track-canvas` used: shipping a sibling package keeps the SVG path untouched (zero risk to existing consumers), keeps the canvas-specific code paths isolated, and lets downstream users opt in by swapping the tag name. + +--- + +## Package Structure +Mirror `nightingale-track-canvas` exactly: + +``` +packages/nightingale-variation-canvas/ +├── README.md +├── package.json # depends on nightingale-new-core + nightingale-variation + d3 +├── tsconfig.json +├── src/ +│ ├── index.ts # re-exports from nightingale-variation + default-exports the canvas class +│ ├── nightingale-variation-canvas.ts +│ └── utils/ +│ └── hit-test.ts # position-indexed lookup + pixel-radius circle test +└── tests/ + └── nightingale-variation-canvas.test.ts +``` + +`package.json` fields to copy from `nightingale-track-canvas/package.json` and adapt: +- `name`: `@nightingale-elements/nightingale-variation-canvas` +- `description`: "Variation track type of the viewer, implemented via HTML canvas." +- `version`: match the monorepo's current version +- `dependencies`: `@nightingale-elements/nightingale-new-core`, `@nightingale-elements/nightingale-variation`, `d3` + +--- + +## Class Design + +```ts +@customElementOnce("nightingale-variation-canvas") +export default class NightingaleVariationCanvas extends withCanvas(NightingaleVariation) { ... } +``` + +### render() +Return a container with a `` underlay and an `` overlay. The SVG keeps the axes, the `withSVGHighlight` overlay, and serves as the mouse-event capture surface. + +```html +
+
+ + +
+
+``` + +### createFeatures() — override +Mirror the existing method but skip the circle-drawing bit: +- Still build the `series` selection, left/right axes, and `chartArea` scaffolding (axes and highlight stay in SVG). +- Do **not** append any circles in SVG. +- Build a spatial index for hit-testing (see below). +- Bind click / mousemove / mouseout on the SVG (or container) for event dispatch. + +### zoomRefreshed() — override +- Call `updateScale()` (as today, for axes). +- Call `this.updateHighlight()`. +- Call `this.requestDraw()` instead of `series.call(variationPlot.drawVariationPlot, this)`. + +### refresh() — override +- Call `super.refresh()` then `this.requestDraw()`. + +### firstUpdated() — override +Same as parent but end with a `requestDraw()`. + +### onCanvasScaleChange() — override +Call `super.onCanvasScaleChange()` then `this.refresh()` (matches track-canvas). + +### Draw pipeline +```ts +private readonly _drawStamp = new Stamp(() => ({ + data: 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, +})); + +private requestDraw = () => this._drawer.requestRefresh(); +private readonly _drawer = Refresher(() => this._draw()); +private _draw() { + if (!this._drawStamp.update().changed) return; + this.adjustCanvasCtxLogicalSize(); + this.drawCanvasContent(); +} +``` + +### drawCanvasContent() +```text +clear canvas +compute visible seq range from display-start/display-end (or via getSeqPositionFromX of canvas edges) +for each position in processedData.mutationArray where position is in the visible range AND has variants: + for each variant at that position: + cx = scale * (getXFromSeqPosition(variant.start) + halfBaseWidth) + cy = scale * yScale(variant.variant.charAt(0)) + r = scale * (variant.size ?? 5) + ctx.fillStyle = variant.color ?? colorConfig(variant) + ctx.globalAlpha = 0.6 // matches existing CSS baseline; hover affordance dropped (D1) + ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2*PI); ctx.fill() +``` + +### Hit-testing +Variation data is point-like (one position per variant), so no range-overlap index is needed. Build a `Map` keyed by `pos`, rebuilt once per data change. On `mousemove`/`click`: +1. Convert `event.offsetX` → sequence position via `getSeqPositionFromX`. +2. `O(1)` lookup in the position map. +3. Convert `event.offsetY` → candidate amino-acid row (invert `yScale`). +4. Circle-test the 1–2 variants at that (pos, aa) cell against `(event.offsetX, event.offsetY)` with radius `datum.size ?? 5`. +5. Dispatch via `createEvent(...)` to match `bindEvents`' existing event shape — so downstream consumers see the same `change` custom events with the same `detail` structure. + +--- + +## API Surface to Preserve +Inherited via `NightingaleVariation`, so these come for free as long as we don't break the base class: + +- Attributes: `protein-api`, `condensed-view`, `row-height`, plus everything from `withManager` (`width`, `height`, `length`, `display-start`, `display-end`, `highlight`, `margin-*`, `highlight-event`, etc.). +- Property setter: `data` +- Public: `processData()`, `updateData()`, `colorConfig` +- Events: same `change` CustomEvent shape as today (`click`, `mouseover`, `mouseout`, `reset`). + +--- + +## Resolved Decisions + +**D1 — Hover opacity affordance: DROPPED.** +Existing CSS applies `opacity: 0.6` to all circles and `opacity: 1` on `:hover`. Canvas draws all circles at `globalAlpha = 0.6` unconditionally. The `:hover` affordance is not replicated. Document this in the README as a known parity gap. + +**D2 — `internalId`: NOT SET in canvas path.** +`d.internalId` is written as a side effect inside the existing `drawVariationPlot`. Nothing in the repo reads it. Canvas draw path does not set it. Document this in the README as a known parity gap. The field remains declared on `VariationDatum` (unchanged) and consumers that set it manually still see it preserved. + +**D3 — Benchmark methodology: two curves.** +- X axis: number of annotations (variants). +- Y axis A: initial load time (ms) — measured from `data =` setter call to first `_draw()` completion. +- Y axis B: refresh time (ms) — measured as the time for a single `refresh()` → canvas redraw cycle after `display-start`/`display-end` change. +- Sweep range: 100, 500, 1k, 2k, 5k, 10k, 20k variants (adjust after a first pass if curves are uninformative). +- One harness page under `dev/benchmarks/` that instantiates both components, runs the sweep, and outputs a table (page + console). +- Use a seeded RNG so runs are reproducible. +- FPS, memory, and interaction latency intentionally out of scope for this round. + +**D4 — Shared utilities: DUPLICATE locally, no `new-core` changes.** +Variation's point-like data means no `RangeCollection`-style spatial index is needed. Hit-testing uses a local `Map`. If any small helper from `nightingale-track-canvas/src/utils/utils.ts` proves useful (e.g. `last()`), duplicate it under `nightingale-variation-canvas/src/utils/`. Promotion to `new-core` is tracked as a future cleanup, out of scope here. + +--- + +## Acceptance Criteria + +### Functional parity +- [ ] `nightingale-variation-canvas` registers as custom element `nightingale-variation-canvas`. +- [ ] All attributes listed under "API Surface" behave identically to `nightingale-variation`. +- [ ] `click`, `mouseover`, `mouseout` events fire with the same `detail` payload shape and at the same logical trigger points. +- [ ] `highlight-event="onclick"` and `highlight-event="onmouseover"` both work. +- [ ] Highlight overlay (`withSVGHighlight`) renders correctly over canvas content. +- [ ] Axes render identically to SVG version (same labels, same placement). +- [ ] `condensed-view` hides empty amino-acid rows; `row-height` resizes rows. +- [ ] Component responds to `display-start` / `display-end` / `length` changes and `zoomRefreshed()`. + +### Rendering fidelity +- [ ] Circles positioned at the same (seq, aa) coordinates as the SVG version. +- [ ] Circle radius matches `datum.size ?? 5`. +- [ ] Circle color matches `datum.color ?? colorConfig(datum)`. +- [ ] Visual output is sharp on HiDPI displays (relies on `withCanvas`'s `devicePixelRatio` handling). +- [ ] No visible stale content after rapid `display-start`/`display-end` changes. + +### Performance +- [ ] Canvas version is not meaningfully slower than SVG version at 100 variants. +- [ ] Canvas version is **measurably faster** at ≥2k variants on both initial load time and refresh time. +- [ ] Canvas version **does not freeze the main thread** at 20k variants where SVG does. +- [ ] Both curves (initial load vs N, refresh vs N) are documented in the benchmark output. + +Exact thresholds TBD once we run the benchmark once and see what numbers look like. + +--- + +## Tests + +### Unit +- Any new utility in `src/utils/` (spatial index, hit-test math) gets a test file modeled on `nightingale-track-canvas/tests/nightingale-track-canvas.test.ts`. + +### Integration / rendering +- Render the component with a fixture (`tests/fixtures/P42336.variation.json` already exists in `nightingale-variation/tests/`) and assert: + - Canvas element exists in the shadow DOM. + - After `data` is set, `canvasCtx` has been written to (non-empty pixel buffer; or simpler: spy on the draw method and assert it was called). + - `click` dispatches a `change` event with correct `detail.feature`. + - `highlight` attribute wires through. + +### Visual regression (optional, stretch) +- Canvas rendered output captured via `toBlob()` and snapshotted. Only if the repo already has a visual-snapshot setup — skip otherwise. + +### Benchmark (reproducible) +- `dev/benchmarks/variation-canvas.html` (or similar) that produces a deterministic two-curve results table given a fixed RNG seed: annotations × initial-load-time and annotations × refresh-time. + +--- + +## Storybook Story +Add a matching story under `stories/` that mirrors the existing variation story and the track-canvas story: + +- Location: `stories/XX.NightingaleVariationCanvas/` (next available number). +- Files: `NightingaleVariationCanvas.stories.ts` and `NightingaleVariationCanvas.stories.mdx`. +- Content: mirror `stories/16.Variation/NightingaleVariation.stories.ts` exactly, swapping the tag name. Reuse the same fixtures so the story is a direct visual comparison. +- Purpose: lets reviewers eyeball parity during PR review and gives docs consumers a page to reference. + +## README +Mirror the `nightingale-track-canvas/README.md` structure. Required sections: + +- **Overview** — one-line summary and the tag name (`nightingale-variation-canvas`). +- **Usage** — minimal example snippet, effectively identical to `nightingale-variation`'s usage with the tag swapped. +- **API** — point readers at `nightingale-variation`'s README rather than duplicating the full attribute table; note that canvas version preserves the same API. +- **Parity gaps** — a short, explicit list: + - Hover opacity affordance (`circle:hover { opacity: 1 }`) is not replicated. All circles render at `globalAlpha = 0.6`. + - `VariationDatum.internalId` is not set as a side effect during rendering. The field remains on the type for consumers who set it themselves. +- **Performance** — link to the benchmark page; include a short summary of the two-curve results once we have numbers. + +## Release / Versioning +This repo uses Lerna in **fixed-version mode** (single `version` in `lerna.json` applies to all packages). No per-package version bumps or changelog entries required for this PR: + +- Set the new package's `version` in `package.json` to match `nightingale-variation`'s current version (currently `5.6.0`). +- Do **not** modify `lerna.json`. +- No `CHANGELOG.md` to update — this repo doesn't maintain one; git tags are the release record. +- PR title / description should explain the addition (standard repo convention based on recent history). + +--- + +## Implementation Order (suggested) +1. Scaffold `packages/nightingale-variation-canvas/` with empty class, `render()`, and package.json. +2. Get it to mount with a data set and draw **any** circle on canvas. +3. Wire `createFeatures()` / `zoomRefreshed()` / `refresh()` correctly. +4. Implement `drawCanvasContent()` full fidelity. +5. Implement hit-testing + event dispatch. +6. Port/adapt the `Refresher`+`Stamp` debounce. +7. Handle `condensed-view`, `row-height`, `highlight`. +8. Benchmark harness. +9. Tests. +10. README. + +--- + +## Risks & Mitigations +- **Hit-testing drift from SVG behavior.** Mitigation: event dispatch uses the same `createEvent()` helper, and hit-test geometry mirrors the draw geometry exactly. +- **HiDPI blurriness.** Mitigation: `withCanvas` already handles DPR; track-canvas is the reference implementation. +- **Event dispatch order / timing differences.** Canvas events go through `mousemove` sampling rather than per-circle SVG events — rapid mouse movement could skip entries. Mitigation: dispatch on every `mousemove`, and dispatch `mouseout` when the hit-test returns nothing. +- **a11y regression.** SVG circles are in the DOM; canvas circles are not. Variation already doesn't set ARIA attributes on circles, so parity is preserved. Out of scope to add. diff --git a/packages/nightingale-variation-canvas/README.md b/packages/nightingale-variation-canvas/README.md new file mode 100644 index 000000000..0da47476d --- /dev/null +++ b/packages/nightingale-variation-canvas/README.md @@ -0,0 +1,40 @@ +# 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 + +Two intentional differences versus `nightingale-variation`: + +1. **Hover opacity affordance is not replicated.** In the SVG version, `circle { opacity: 0.6 }` is bumped to `1` on `:hover`. Canvas circles render at a constant `globalAlpha = 0.6` and do not brighten on hover. +2. **`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. + +## Performance + +See `dev/benchmarks/variation-canvas.html` for a reproducible benchmark comparing SVG vs canvas across variant counts. diff --git a/packages/nightingale-variation-canvas/package.json b/packages/nightingale-variation-canvas/package.json new file mode 100644 index 000000000..b7bf99d6c --- /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 000000000..a4ea51c07 --- /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 000000000..ad494d9ca --- /dev/null +++ b/packages/nightingale-variation-canvas/src/nightingale-variation-canvas.ts @@ -0,0 +1,323 @@ +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, and the +// spec forbids modifying that package. 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. */ +const MAX_HIT_RADIUS = 10; + +/** Opacity at which variant circles are drawn. Matches `circle { opacity: 0.6 }` in SVG variation CSS. */ +const VARIANT_ALPHA = 0.6; + +@customElementOnce("nightingale-variation-canvas") +export default class NightingaleVariationCanvas extends withCanvas( + NightingaleVariation, +) { + /** Variants indexed by sequence position for fast hit-testing. */ + private variantIndex?: Map; + + 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(); + this.requestDraw(); + } + + override onCanvasScaleChange() { + super.onCanvasScaleChange(); + this.refresh(); + } + + private readonly _drawStamp = 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()); + /** Do not call directly — call `requestDraw` instead to avoid blocking the main thread. */ + private _draw(): void { + if (!this._drawStamp.update().changed) return; + this.adjustCanvasCtxLogicalSize(); + this.drawCanvasContent(); + } + + 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 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 as unknown as Parameters["1"], + withHighlight, + true, + variant.start, + undefined, + 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); + if (!variant) { + this.handleMouseout(event); + return; + } + const withHighlight = + this.getAttribute("highlight-event") === "onmouseover"; + const customEvent = createEvent( + "mouseover", + variant as unknown as Parameters["1"], + withHighlight, + false, + variant.start, + undefined, + event.target instanceof HTMLElement ? event.target : undefined, + event, + this, + ); + this.dispatchEvent(customEvent); + } + + private handleMouseout(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); + } +} + +function buildVariantIndex( + mutationArray: ProcessedVariationData[] | undefined, +): Map { + const index = new Map(); + if (!mutationArray) return index; + for (const entry of mutationArray) { + if (entry.variants.length === 0) continue; + index.set(entry.pos, entry.variants); + } + 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 000000000..e690e5a08 --- /dev/null +++ b/packages/nightingale-variation-canvas/tests/nightingale-variation-canvas.test.ts @@ -0,0 +1,62 @@ +import NightingaleVariationCanvas from "../src/nightingale-variation-canvas"; +import type { VariationData } 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); + }); +}); diff --git a/packages/nightingale-variation-canvas/tsconfig.json b/packages/nightingale-variation-canvas/tsconfig.json new file mode 100644 index 000000000..6a62dbc48 --- /dev/null +++ b/packages/nightingale-variation-canvas/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.mdx b/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.mdx new file mode 100644 index 000000000..3b608cee4 --- /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 000000000..ff837d0b9 --- /dev/null +++ b/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.ts @@ -0,0 +1,144 @@ +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 SvgVsCanvasSideBySide = () => html` + + +
+ +
+
+ +
+
+
SVG
+ +
+
+
Canvas
+ +
+
+`; +SvgVsCanvasSideBySide.play = async () => { + await customElements.whenDefined("nightingale-variation"); + await customElements.whenDefined("nightingale-variation-canvas"); + const svg = document.getElementById("variation-svg"); + if (svg) (svg as any).data = data.P99999; + const canvas = document.getElementById("variation-canvas-compare"); + if (canvas) (canvas as any).data = data.P99999; +}; From 49a561cc2feb0a33cef7e417b084331f69a915e8 Mon Sep 17 00:00:00 2001 From: Minjoon Kim Date: Fri, 24 Apr 2026 11:49:28 +0100 Subject: [PATCH 2/5] remove spec from directory --- nightingale-variation-canvas-spec.md | 263 --------------------------- 1 file changed, 263 deletions(-) delete mode 100644 nightingale-variation-canvas-spec.md diff --git a/nightingale-variation-canvas-spec.md b/nightingale-variation-canvas-spec.md deleted file mode 100644 index f54939ac4..000000000 --- a/nightingale-variation-canvas-spec.md +++ /dev/null @@ -1,263 +0,0 @@ -# Spec: `nightingale-variation-canvas` - -## Purpose -Create a canvas-backed variant of `nightingale-variation` that preserves the existing public API and behavior, following the pattern established by `nightingale-track-canvas`. Benchmark both implementations to quantify the improvement. - -## Non-Goals -- No changes to `nightingale-variation` (or any other package). -- No changes to data models or API responses. -- No new public attributes, events, or methods beyond what `nightingale-variation` already exposes. -- No new visual features — canvas is a pure rendering-backend swap. -- No promotion of shared utilities to `nightingale-new-core` as part of this work (tracked separately as future cleanup). - -## Context: Why a second package and not an option on the existing one? -Same rationale `nightingale-track-canvas` used: shipping a sibling package keeps the SVG path untouched (zero risk to existing consumers), keeps the canvas-specific code paths isolated, and lets downstream users opt in by swapping the tag name. - ---- - -## Package Structure -Mirror `nightingale-track-canvas` exactly: - -``` -packages/nightingale-variation-canvas/ -├── README.md -├── package.json # depends on nightingale-new-core + nightingale-variation + d3 -├── tsconfig.json -├── src/ -│ ├── index.ts # re-exports from nightingale-variation + default-exports the canvas class -│ ├── nightingale-variation-canvas.ts -│ └── utils/ -│ └── hit-test.ts # position-indexed lookup + pixel-radius circle test -└── tests/ - └── nightingale-variation-canvas.test.ts -``` - -`package.json` fields to copy from `nightingale-track-canvas/package.json` and adapt: -- `name`: `@nightingale-elements/nightingale-variation-canvas` -- `description`: "Variation track type of the viewer, implemented via HTML canvas." -- `version`: match the monorepo's current version -- `dependencies`: `@nightingale-elements/nightingale-new-core`, `@nightingale-elements/nightingale-variation`, `d3` - ---- - -## Class Design - -```ts -@customElementOnce("nightingale-variation-canvas") -export default class NightingaleVariationCanvas extends withCanvas(NightingaleVariation) { ... } -``` - -### render() -Return a container with a `` underlay and an `` overlay. The SVG keeps the axes, the `withSVGHighlight` overlay, and serves as the mouse-event capture surface. - -```html -
-
- - -
-
-``` - -### createFeatures() — override -Mirror the existing method but skip the circle-drawing bit: -- Still build the `series` selection, left/right axes, and `chartArea` scaffolding (axes and highlight stay in SVG). -- Do **not** append any circles in SVG. -- Build a spatial index for hit-testing (see below). -- Bind click / mousemove / mouseout on the SVG (or container) for event dispatch. - -### zoomRefreshed() — override -- Call `updateScale()` (as today, for axes). -- Call `this.updateHighlight()`. -- Call `this.requestDraw()` instead of `series.call(variationPlot.drawVariationPlot, this)`. - -### refresh() — override -- Call `super.refresh()` then `this.requestDraw()`. - -### firstUpdated() — override -Same as parent but end with a `requestDraw()`. - -### onCanvasScaleChange() — override -Call `super.onCanvasScaleChange()` then `this.refresh()` (matches track-canvas). - -### Draw pipeline -```ts -private readonly _drawStamp = new Stamp(() => ({ - data: 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, -})); - -private requestDraw = () => this._drawer.requestRefresh(); -private readonly _drawer = Refresher(() => this._draw()); -private _draw() { - if (!this._drawStamp.update().changed) return; - this.adjustCanvasCtxLogicalSize(); - this.drawCanvasContent(); -} -``` - -### drawCanvasContent() -```text -clear canvas -compute visible seq range from display-start/display-end (or via getSeqPositionFromX of canvas edges) -for each position in processedData.mutationArray where position is in the visible range AND has variants: - for each variant at that position: - cx = scale * (getXFromSeqPosition(variant.start) + halfBaseWidth) - cy = scale * yScale(variant.variant.charAt(0)) - r = scale * (variant.size ?? 5) - ctx.fillStyle = variant.color ?? colorConfig(variant) - ctx.globalAlpha = 0.6 // matches existing CSS baseline; hover affordance dropped (D1) - ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2*PI); ctx.fill() -``` - -### Hit-testing -Variation data is point-like (one position per variant), so no range-overlap index is needed. Build a `Map` keyed by `pos`, rebuilt once per data change. On `mousemove`/`click`: -1. Convert `event.offsetX` → sequence position via `getSeqPositionFromX`. -2. `O(1)` lookup in the position map. -3. Convert `event.offsetY` → candidate amino-acid row (invert `yScale`). -4. Circle-test the 1–2 variants at that (pos, aa) cell against `(event.offsetX, event.offsetY)` with radius `datum.size ?? 5`. -5. Dispatch via `createEvent(...)` to match `bindEvents`' existing event shape — so downstream consumers see the same `change` custom events with the same `detail` structure. - ---- - -## API Surface to Preserve -Inherited via `NightingaleVariation`, so these come for free as long as we don't break the base class: - -- Attributes: `protein-api`, `condensed-view`, `row-height`, plus everything from `withManager` (`width`, `height`, `length`, `display-start`, `display-end`, `highlight`, `margin-*`, `highlight-event`, etc.). -- Property setter: `data` -- Public: `processData()`, `updateData()`, `colorConfig` -- Events: same `change` CustomEvent shape as today (`click`, `mouseover`, `mouseout`, `reset`). - ---- - -## Resolved Decisions - -**D1 — Hover opacity affordance: DROPPED.** -Existing CSS applies `opacity: 0.6` to all circles and `opacity: 1` on `:hover`. Canvas draws all circles at `globalAlpha = 0.6` unconditionally. The `:hover` affordance is not replicated. Document this in the README as a known parity gap. - -**D2 — `internalId`: NOT SET in canvas path.** -`d.internalId` is written as a side effect inside the existing `drawVariationPlot`. Nothing in the repo reads it. Canvas draw path does not set it. Document this in the README as a known parity gap. The field remains declared on `VariationDatum` (unchanged) and consumers that set it manually still see it preserved. - -**D3 — Benchmark methodology: two curves.** -- X axis: number of annotations (variants). -- Y axis A: initial load time (ms) — measured from `data =` setter call to first `_draw()` completion. -- Y axis B: refresh time (ms) — measured as the time for a single `refresh()` → canvas redraw cycle after `display-start`/`display-end` change. -- Sweep range: 100, 500, 1k, 2k, 5k, 10k, 20k variants (adjust after a first pass if curves are uninformative). -- One harness page under `dev/benchmarks/` that instantiates both components, runs the sweep, and outputs a table (page + console). -- Use a seeded RNG so runs are reproducible. -- FPS, memory, and interaction latency intentionally out of scope for this round. - -**D4 — Shared utilities: DUPLICATE locally, no `new-core` changes.** -Variation's point-like data means no `RangeCollection`-style spatial index is needed. Hit-testing uses a local `Map`. If any small helper from `nightingale-track-canvas/src/utils/utils.ts` proves useful (e.g. `last()`), duplicate it under `nightingale-variation-canvas/src/utils/`. Promotion to `new-core` is tracked as a future cleanup, out of scope here. - ---- - -## Acceptance Criteria - -### Functional parity -- [ ] `nightingale-variation-canvas` registers as custom element `nightingale-variation-canvas`. -- [ ] All attributes listed under "API Surface" behave identically to `nightingale-variation`. -- [ ] `click`, `mouseover`, `mouseout` events fire with the same `detail` payload shape and at the same logical trigger points. -- [ ] `highlight-event="onclick"` and `highlight-event="onmouseover"` both work. -- [ ] Highlight overlay (`withSVGHighlight`) renders correctly over canvas content. -- [ ] Axes render identically to SVG version (same labels, same placement). -- [ ] `condensed-view` hides empty amino-acid rows; `row-height` resizes rows. -- [ ] Component responds to `display-start` / `display-end` / `length` changes and `zoomRefreshed()`. - -### Rendering fidelity -- [ ] Circles positioned at the same (seq, aa) coordinates as the SVG version. -- [ ] Circle radius matches `datum.size ?? 5`. -- [ ] Circle color matches `datum.color ?? colorConfig(datum)`. -- [ ] Visual output is sharp on HiDPI displays (relies on `withCanvas`'s `devicePixelRatio` handling). -- [ ] No visible stale content after rapid `display-start`/`display-end` changes. - -### Performance -- [ ] Canvas version is not meaningfully slower than SVG version at 100 variants. -- [ ] Canvas version is **measurably faster** at ≥2k variants on both initial load time and refresh time. -- [ ] Canvas version **does not freeze the main thread** at 20k variants where SVG does. -- [ ] Both curves (initial load vs N, refresh vs N) are documented in the benchmark output. - -Exact thresholds TBD once we run the benchmark once and see what numbers look like. - ---- - -## Tests - -### Unit -- Any new utility in `src/utils/` (spatial index, hit-test math) gets a test file modeled on `nightingale-track-canvas/tests/nightingale-track-canvas.test.ts`. - -### Integration / rendering -- Render the component with a fixture (`tests/fixtures/P42336.variation.json` already exists in `nightingale-variation/tests/`) and assert: - - Canvas element exists in the shadow DOM. - - After `data` is set, `canvasCtx` has been written to (non-empty pixel buffer; or simpler: spy on the draw method and assert it was called). - - `click` dispatches a `change` event with correct `detail.feature`. - - `highlight` attribute wires through. - -### Visual regression (optional, stretch) -- Canvas rendered output captured via `toBlob()` and snapshotted. Only if the repo already has a visual-snapshot setup — skip otherwise. - -### Benchmark (reproducible) -- `dev/benchmarks/variation-canvas.html` (or similar) that produces a deterministic two-curve results table given a fixed RNG seed: annotations × initial-load-time and annotations × refresh-time. - ---- - -## Storybook Story -Add a matching story under `stories/` that mirrors the existing variation story and the track-canvas story: - -- Location: `stories/XX.NightingaleVariationCanvas/` (next available number). -- Files: `NightingaleVariationCanvas.stories.ts` and `NightingaleVariationCanvas.stories.mdx`. -- Content: mirror `stories/16.Variation/NightingaleVariation.stories.ts` exactly, swapping the tag name. Reuse the same fixtures so the story is a direct visual comparison. -- Purpose: lets reviewers eyeball parity during PR review and gives docs consumers a page to reference. - -## README -Mirror the `nightingale-track-canvas/README.md` structure. Required sections: - -- **Overview** — one-line summary and the tag name (`nightingale-variation-canvas`). -- **Usage** — minimal example snippet, effectively identical to `nightingale-variation`'s usage with the tag swapped. -- **API** — point readers at `nightingale-variation`'s README rather than duplicating the full attribute table; note that canvas version preserves the same API. -- **Parity gaps** — a short, explicit list: - - Hover opacity affordance (`circle:hover { opacity: 1 }`) is not replicated. All circles render at `globalAlpha = 0.6`. - - `VariationDatum.internalId` is not set as a side effect during rendering. The field remains on the type for consumers who set it themselves. -- **Performance** — link to the benchmark page; include a short summary of the two-curve results once we have numbers. - -## Release / Versioning -This repo uses Lerna in **fixed-version mode** (single `version` in `lerna.json` applies to all packages). No per-package version bumps or changelog entries required for this PR: - -- Set the new package's `version` in `package.json` to match `nightingale-variation`'s current version (currently `5.6.0`). -- Do **not** modify `lerna.json`. -- No `CHANGELOG.md` to update — this repo doesn't maintain one; git tags are the release record. -- PR title / description should explain the addition (standard repo convention based on recent history). - ---- - -## Implementation Order (suggested) -1. Scaffold `packages/nightingale-variation-canvas/` with empty class, `render()`, and package.json. -2. Get it to mount with a data set and draw **any** circle on canvas. -3. Wire `createFeatures()` / `zoomRefreshed()` / `refresh()` correctly. -4. Implement `drawCanvasContent()` full fidelity. -5. Implement hit-testing + event dispatch. -6. Port/adapt the `Refresher`+`Stamp` debounce. -7. Handle `condensed-view`, `row-height`, `highlight`. -8. Benchmark harness. -9. Tests. -10. README. - ---- - -## Risks & Mitigations -- **Hit-testing drift from SVG behavior.** Mitigation: event dispatch uses the same `createEvent()` helper, and hit-test geometry mirrors the draw geometry exactly. -- **HiDPI blurriness.** Mitigation: `withCanvas` already handles DPR; track-canvas is the reference implementation. -- **Event dispatch order / timing differences.** Canvas events go through `mousemove` sampling rather than per-circle SVG events — rapid mouse movement could skip entries. Mitigation: dispatch on every `mousemove`, and dispatch `mouseout` when the hit-test returns nothing. -- **a11y regression.** SVG circles are in the DOM; canvas circles are not. Variation already doesn't set ARIA attributes on circles, so parity is preserved. Out of scope to add. From bb9f54dd9b274428a780652320841019da6d9abd Mon Sep 17 00:00:00 2001 From: Minjoon Kim Date: Fri, 24 Apr 2026 12:23:04 +0100 Subject: [PATCH 3/5] add opacity/hover on annotations --- .../nightingale-variation-canvas/README.md | 5 ++--- .../src/nightingale-variation-canvas.ts | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/nightingale-variation-canvas/README.md b/packages/nightingale-variation-canvas/README.md index 0da47476d..852a9b2e9 100644 --- a/packages/nightingale-variation-canvas/README.md +++ b/packages/nightingale-variation-canvas/README.md @@ -30,10 +30,9 @@ This component inherits from `nightingale-variation` and exposes the same attrib ## Parity gaps -Two intentional differences versus `nightingale-variation`: +One intentional difference versus `nightingale-variation`: -1. **Hover opacity affordance is not replicated.** In the SVG version, `circle { opacity: 0.6 }` is bumped to `1` on `:hover`. Canvas circles render at a constant `globalAlpha = 0.6` and do not brighten on hover. -2. **`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. +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. ## Performance diff --git a/packages/nightingale-variation-canvas/src/nightingale-variation-canvas.ts b/packages/nightingale-variation-canvas/src/nightingale-variation-canvas.ts index ad494d9ca..205ccc464 100644 --- a/packages/nightingale-variation-canvas/src/nightingale-variation-canvas.ts +++ b/packages/nightingale-variation-canvas/src/nightingale-variation-canvas.ts @@ -28,7 +28,7 @@ const DEFAULT_RADIUS = 5; /** Upper bound used when determining hit-test candidate positions; real `datum.size` values are typically ≤ 10. */ const MAX_HIT_RADIUS = 10; -/** Opacity at which variant circles are drawn. Matches `circle { opacity: 0.6 }` in SVG variation CSS. */ +/** Base opacity for variant circles. Matches `circle { opacity: 0.6 }` in SVG variation CSS. */ const VARIANT_ALPHA = 0.6; @customElementOnce("nightingale-variation-canvas") @@ -38,6 +38,10 @@ export default class NightingaleVariationCanvas extends withCanvas( /** 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; + override render() { return html`
- + +
`; @@ -103,6 +116,14 @@ export default class NightingaleVariationCanvas extends withCanvas( 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(); } @@ -111,7 +132,7 @@ export default class NightingaleVariationCanvas extends withCanvas( this.refresh(); } - private readonly _drawStamp = new Stamp(() => ({ + private readonly _backgroundStamp = new Stamp(() => ({ "processedData": this["processedData"], "canvasCtx": this["canvasCtx"], "width": this["width"], @@ -127,7 +148,6 @@ export default class NightingaleVariationCanvas extends withCanvas( "condensedView": this["condensedView"], "rowHeight": this["rowHeight"], "colorConfig": this["colorConfig"], - "hoveredVariant": this["hoveredVariant"], })); /** Request canvas redraw (debounced). */ @@ -135,9 +155,51 @@ export default class NightingaleVariationCanvas extends withCanvas( private readonly _drawer = Refresher(() => this._draw()); /** Do not call directly — call `requestDraw` instead to avoid blocking the main thread. */ private _draw(): void { - if (!this._drawStamp.update().changed) return; - this.adjustCanvasCtxLogicalSize(); - this.drawCanvasContent(); + 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(); + } + + 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() { @@ -158,7 +220,7 @@ export default class NightingaleVariationCanvas extends withCanvas( this.getSeqPositionFromX(canvasWidth / scale + MAX_HIT_RADIUS) ?? Infinity; const marginTop = this["margin-top"]; - const hovered = this.hoveredVariant; + ctx.globalAlpha = VARIANT_ALPHA; for (const entry of mutations) { if (entry.pos < leftEdgeSeq || entry.pos > rightEdgeSeq) continue; @@ -174,7 +236,6 @@ export default class NightingaleVariationCanvas extends withCanvas( const cy = scale * (marginTop + yRow); const r = scale * (variant.size ?? DEFAULT_RADIUS); - ctx.globalAlpha = variant === hovered ? 1 : VARIANT_ALPHA; ctx.fillStyle = variant.color ?? this.colorConfig(variant); ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2 * Math.PI); @@ -272,7 +333,7 @@ export default class NightingaleVariationCanvas extends withCanvas( withHighlight, true, variant.start, - undefined, + getVariantEnd(variant), event.target instanceof HTMLElement ? event.target : undefined, event, this, @@ -297,7 +358,7 @@ export default class NightingaleVariationCanvas extends withCanvas( withHighlight, false, variant.start, - undefined, + getVariantEnd(variant), event.target instanceof HTMLElement ? event.target : undefined, event, this, @@ -324,6 +385,17 @@ export default class NightingaleVariationCanvas extends withCanvas( } } +/** 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 { diff --git a/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.ts b/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.ts index ff837d0b9..a1f042071 100644 --- a/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.ts +++ b/stories/21.NightingaleVariationCanvas/NightingaleVariationCanvas.stories.ts @@ -76,16 +76,11 @@ BasicVariationCanvas.play = async () => { } }; -export const SvgVsCanvasSideBySide = () => html` +export const CompleteView = () => html`
@@ -107,38 +102,20 @@ export const SvgVsCanvasSideBySide = () => html` >
-
SVG
- -
-
-
Canvas
`; -SvgVsCanvasSideBySide.play = async () => { - await customElements.whenDefined("nightingale-variation"); +CompleteView.play = async () => { await customElements.whenDefined("nightingale-variation-canvas"); - const svg = document.getElementById("variation-svg"); - if (svg) (svg as any).data = data.P99999; - const canvas = document.getElementById("variation-canvas-compare"); + const canvas = document.getElementById("variation-canvas-complete"); if (canvas) (canvas as any).data = data.P99999; }; From 43995687272d1cf83536506c41746918f23a76b8 Mon Sep 17 00:00:00 2001 From: Daniel Rice Date: Mon, 27 Apr 2026 07:31:15 +0100 Subject: [PATCH 5/5] Tweak comments, benchmarking, types, and code. --- dev/benchmarks/variation-canvas.html | 52 +++++--- package.json | 1 + .../nightingale-variation-canvas/README.md | 12 +- .../src/nightingale-variation-canvas.ts | 124 +++++++++++++----- .../nightingale-variation-canvas.test.ts | 113 +++++++++++++++- packages/nightingale-variation/src/index.ts | 13 +- web-dev-server.config.mjs | 65 +++++++++ 7 files changed, 321 insertions(+), 59 deletions(-) create mode 100644 web-dev-server.config.mjs diff --git a/dev/benchmarks/variation-canvas.html b/dev/benchmarks/variation-canvas.html index da66ff613..ca04f93df 100644 --- a/dev/benchmarks/variation-canvas.html +++ b/dev/benchmarks/variation-canvas.html @@ -5,14 +5,15 @@ nightingale-variation-canvas benchmark - - + +