Skip to content

Commit

Permalink
Merge pull request #307 from midlik/nightingale-track-canvas
Browse files Browse the repository at this point in the history
NightingaleTrackCanvas
  • Loading branch information
dlrice authored Dec 20, 2024
2 parents 26fa77b + adaa941 commit 87b8b45
Show file tree
Hide file tree
Showing 13 changed files with 1,356 additions and 20 deletions.
2 changes: 1 addition & 1 deletion packages/nightingale-new-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export { default as withManager } from "./mixins/withManager/index";
export { default as withHighlight } from "./mixins/withHighlight/index";
export { default as withSVGHighlight } from "./mixins/withHighlight/SVG/index";
export { default as withZoom } from "./mixins/withZoom/index";
export { default as bindEvents } from "./utils/bindEvents";
export { default as bindEvents, createEvent } from "./utils/bindEvents";
export { contrastingColor, getColor } from "./utils/colors";
export { default as Region } from "./utils/Region";
export { default as customElementOnce } from "./decorators/customElementOnce";
Expand Down
36 changes: 17 additions & 19 deletions packages/nightingale-new-core/src/mixins/withZoom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,16 @@ import withPosition, { withPositionInterface } from "../withPosition";
import withMargin, { withMarginInterface } from "../withMargin";
import withResizable, { WithResizableInterface } from "../withResizable";


type SVGSelection = Selection<SVGSVGElement, unknown, HTMLElement | SVGElement | null, unknown>;

export interface WithZoomInterface
extends WithDimensionsInterface,
withPositionInterface,
withMarginInterface,
WithResizableInterface {
withPositionInterface,
withMarginInterface,
WithResizableInterface {
xScale?: ScaleLinear<number, number>;
svg?: Selection<
SVGSVGElement,
unknown,
HTMLElement | SVGElement | null,
unknown
>;
svg?: SVGSelection;
updateScaleDomain(): void;
getSingleBaseWidth(): number;
getXFromSeqPosition(position: number): number;
Expand All @@ -41,7 +39,7 @@ const withZoom = <T extends Constructor<NightingaleBaseElement>>(
) => {
class WithZoom extends withMargin(
withPosition(withResizable(withDimensions(superClass))),
) {
) implements WithZoomInterface {
_applyZoomTranslation: () => void;
/**
* Base scale without any transformations, only updated in `updateScaleDomain`
Expand All @@ -55,8 +53,8 @@ const withZoom = <T extends Constructor<NightingaleBaseElement>>(
* Current scale, the one used to calculate any positions. Calculated based on `display-start` and `display-end`.
*/
xScale?: ScaleLinear<number, number>;
_zoom?: ZoomBehavior<HTMLElement, unknown>;
_svg?: Selection<HTMLElement, unknown, HTMLElement, unknown>;
_zoom?: ZoomBehavior<SVGSVGElement, unknown>;
_svg?: SVGSelection;
dontDispatch?: boolean;

@property({ type: Boolean })
Expand Down Expand Up @@ -105,32 +103,32 @@ const withZoom = <T extends Constructor<NightingaleBaseElement>>(
return this._zoom;
}

set svg(svg) {
set svg(svg: SVGSelection) {
if (!svg || !this._zoom) return;
this._svg = svg;
svg.call(this._zoom).on("dblclick.zoom", null);
this.applyZoomTranslation();
}

get svg() {
get svg(): SVGSelection | undefined {
return this._svg;
}

updateScaleDomain() {
this.xScale = scaleLinear()
this.originXScale = scaleLinear()
// The max width should match the start of the n+1 base
.domain([1, (this.length || 0) + 1])
.range([0, this.getWidthWithMargins()]);
this.originXScale = this.xScale?.copy();
this.tmpXScale = this.xScale?.copy();
this.tmpXScale = this.originXScale.copy();
this.xScale ??= this.originXScale.copy(); // Do not force set `xScale`, will be updated in `zoomed`
this.zoom?.translateExtent([
[0, 0],
[this.getWidthWithMargins(), 0],
]);
}

_initZoom() {
this._zoom = d3zoom<HTMLElement, unknown>()
this._zoom = d3zoom<SVGSVGElement, unknown>()
.scaleExtent([1, Infinity])
.translateExtent([
[0, 0],
Expand Down Expand Up @@ -208,7 +206,7 @@ const withZoom = <T extends Constructor<NightingaleBaseElement>>(
1,
// +1 because the displayend base should be included
(this.length || 0) /
(1 + (this["display-end"] || 0) - (this["display-start"] || 0)),
(1 + (this["display-end"] || 0) - (this["display-start"] || 0)),
);
// The deltaX gets calculated using the position of the first base to display in original scale
const dx = -this.originXScale(this["display-start"] || 0);
Expand Down
52 changes: 52 additions & 0 deletions packages/nightingale-saver/src/nightingale-saver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class NightingaleSaver extends NightingaleElement {
zoom: scaleFactor,
})
.then(() => {
copyCanvases(element, canvas, scaleFactor);
const image = canvas
.toDataURL(`image/${this.fileFormat}`, 1.0)
.replace(`image/${this.fileFormat}`, "image/octet-stream");
Expand Down Expand Up @@ -145,3 +146,54 @@ const wrapHTML = (html: string) =>
<body>${html}</body>
</html>
`;


/** Render contents of all HTML canvas elements within `srcElement` into `destCanvas`. */
function copyCanvases(srcElement: HTMLElement, destCanvas: HTMLCanvasElement, destScale: number = 1) {
const destCtx = destCanvas.getContext("2d");
if (!destCtx) {
console.error("Failed to write to destination canvas.");
return;
}

const parentBox = srcElement.getBoundingClientRect();
const srcCanvases = srcElement.querySelectorAll('canvas');
for (const srcCanvas of srcCanvases) {
if (srcCanvas === destCanvas) continue;
const box = srcCanvas.getBoundingClientRect();
const destX = destScale * (box.x - parentBox.x);
const destY = destScale * (box.y - parentBox.y);
const destWidth = destScale * box.width;
const destHeight = destScale * box.height;
// Try render high-resolution image, if `srcCanvas` belongs to an element supporting `getImageData` (e.g. `NightingaleTrackCanvas`):
type GetImageDataFunc = (options?: { scale?: number }) => ImageData | undefined;
const nightingaleElement = findAncestor(srcCanvas, elem => 'getImageData' in elem && typeof elem.getImageData === 'function') as { getImageData: GetImageDataFunc } | undefined;
const image = nightingaleElement?.getImageData({ scale: destScale });
if (image) {
// Copy rendered image with required resolution
const offscreen = new OffscreenCanvas(image.width, image.height);
offscreen.getContext('2d')?.putImageData(image, 0, 0);
destCtx.drawImage(offscreen,
0, 0, offscreen.width, offscreen.height,
destX, destY, destWidth, destHeight,
);
} else {
// Copy canvas content as is (will be blurred if destination size is larger)
destCtx.drawImage(srcCanvas,
0, 0, srcCanvas.width, srcCanvas.height,
destX, destY, destWidth, destHeight,
);
}
}
}

/** Return nearest DOM ancestor which fulfills `predicate`, if any. */
function findAncestor(element: HTMLElement | null, predicate: (elem: HTMLElement) => Boolean): HTMLElement | undefined {
while (element) {
if (predicate(element)) {
return element;
}
element = element.parentElement;
}
return undefined;
}
33 changes: 33 additions & 0 deletions packages/nightingale-track-canvas/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# nightingale-track-canvas

[![Published on NPM](https://img.shields.io/npm/v/@nightingale-elements/nightingale-track-canvas.svg)](https://www.npmjs.com/package/@nightingale-elements/nightingale-track-canvas)

Alternative to `nightingale-track`, using HTML canvas for rendering instead of SVG graphics.

Canvas-based rendering can provide better performance, especially with large datasets (many features within a track or many parallel tracks). Some non-critical parts are still implemented via SVG (e.g. highlights).

Application interface for `nightingale-track-canvas` is the same as for `nightingale-track`. Some shapes might look slightly different. In case there are overlapping features, their order (z-index) might not be preserved.

## Usage

```html
<nightingale-track-canvas
id="my-track-id"
length="223"
height="100"
display-start="1"
display-end="50"
layout="non-overlapping"
></nightingale-track-canvas>
```

#### Setting the data through property

```javascript
const track = document.querySelector("#my-track-id");
track.data = myDataObject;
```

## API Reference

This component inherits from `nigthingale-track` and has the same API.
40 changes: 40 additions & 0 deletions packages/nightingale-track-canvas/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@nightingale-elements/nightingale-track-canvas",
"version": "5.2.0",
"description": "Basic 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"
],
"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/",
"author": "Adam Midlik <midlik@gmail.com>",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@nightingale-elements/nightingale-new-core": "^5.2.0",
"@nightingale-elements/nightingale-track": "^5.2.0",
"d3": "7.9.0"
}
}
4 changes: 4 additions & 0 deletions packages/nightingale-track-canvas/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "@nightingale-elements/nightingale-track";

import NightingaleTrackCanvas from "./nightingale-track-canvas";
export default NightingaleTrackCanvas;
Loading

0 comments on commit 87b8b45

Please sign in to comment.