From 44525a7afc1fc0e4c65fab7ddffcad776786eedf Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Tue, 15 Oct 2024 17:46:22 +0100 Subject: [PATCH 1/6] Added NightingaleTrackCanvas --- packages/nightingale-new-core/src/index.ts | 2 +- .../src/mixins/withZoom/index.ts | 36 +- .../src/nightingale-saver.ts | 52 +++ packages/nightingale-track-canvas/README.md | 33 ++ .../nightingale-track-canvas/package.json | 40 ++ .../nightingale-track-canvas/src/index.ts | 4 + .../src/nightingale-track-canvas.ts | 314 +++++++++++++++ .../src/utils/draw-shapes.ts | 363 ++++++++++++++++++ .../src/utils/utils.ts | 208 ++++++++++ .../tests/nightingale-track-canvas.test.ts | 58 +++ .../nightingale-track-canvas/tsconfig.json | 7 + .../src/nightingale-track.ts | 8 +- .../NightingaleTrackCanvas.stories.mdx | 13 + .../NightingaleTrackCanvas.stories.ts | 244 ++++++++++++ 14 files changed, 1358 insertions(+), 24 deletions(-) create mode 100644 packages/nightingale-track-canvas/README.md create mode 100644 packages/nightingale-track-canvas/package.json create mode 100644 packages/nightingale-track-canvas/src/index.ts create mode 100644 packages/nightingale-track-canvas/src/nightingale-track-canvas.ts create mode 100644 packages/nightingale-track-canvas/src/utils/draw-shapes.ts create mode 100644 packages/nightingale-track-canvas/src/utils/utils.ts create mode 100644 packages/nightingale-track-canvas/tests/nightingale-track-canvas.test.ts create mode 100644 packages/nightingale-track-canvas/tsconfig.json create mode 100644 stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.mdx create mode 100644 stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.ts diff --git a/packages/nightingale-new-core/src/index.ts b/packages/nightingale-new-core/src/index.ts index 9deec2af5..569d955fe 100644 --- a/packages/nightingale-new-core/src/index.ts +++ b/packages/nightingale-new-core/src/index.ts @@ -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"; diff --git a/packages/nightingale-new-core/src/mixins/withZoom/index.ts b/packages/nightingale-new-core/src/mixins/withZoom/index.ts index d1851afbe..1399f4d9c 100644 --- a/packages/nightingale-new-core/src/mixins/withZoom/index.ts +++ b/packages/nightingale-new-core/src/mixins/withZoom/index.ts @@ -17,18 +17,16 @@ import withPosition, { withPositionInterface } from "../withPosition"; import withMargin, { withMarginInterface } from "../withMargin"; import withResizable, { WithResizableInterface } from "../withResizable"; + +type SVGSelection = Selection; + export interface WithZoomInterface extends WithDimensionsInterface, - withPositionInterface, - withMarginInterface, - WithResizableInterface { + withPositionInterface, + withMarginInterface, + WithResizableInterface { xScale?: ScaleLinear; - svg?: Selection< - SVGSVGElement, - unknown, - HTMLElement | SVGElement | null, - unknown - >; + svg?: SVGSelection; updateScaleDomain(): void; getSingleBaseWidth(): number; getXFromSeqPosition(position: number): number; @@ -41,7 +39,7 @@ const withZoom = >( ) => { class WithZoom extends withMargin( withPosition(withResizable(withDimensions(superClass))), - ) { + ) implements WithZoomInterface { _applyZoomTranslation: () => void; /** * Base scale without any transformations, only updated in `updateScaleDomain` @@ -55,8 +53,8 @@ const withZoom = >( * Current scale, the one used to calculate any positions. Calculated based on `display-start` and `display-end`. */ xScale?: ScaleLinear; - _zoom?: ZoomBehavior; - _svg?: Selection; + _zoom?: ZoomBehavior; + _svg?: SVGSelection; dontDispatch?: boolean; @property({ type: Boolean }) @@ -105,24 +103,24 @@ const withZoom = >( 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], @@ -130,7 +128,7 @@ const withZoom = >( } _initZoom() { - this._zoom = d3zoom() + this._zoom = d3zoom() .scaleExtent([1, Infinity]) .translateExtent([ [0, 0], @@ -208,7 +206,7 @@ const withZoom = >( 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); diff --git a/packages/nightingale-saver/src/nightingale-saver.ts b/packages/nightingale-saver/src/nightingale-saver.ts index 9998376fe..863460283 100644 --- a/packages/nightingale-saver/src/nightingale-saver.ts +++ b/packages/nightingale-saver/src/nightingale-saver.ts @@ -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"); @@ -145,3 +146,54 @@ const wrapHTML = (html: string) => ${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; +} diff --git a/packages/nightingale-track-canvas/README.md b/packages/nightingale-track-canvas/README.md new file mode 100644 index 000000000..0e8397efa --- /dev/null +++ b/packages/nightingale-track-canvas/README.md @@ -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 + +``` + +#### 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. diff --git a/packages/nightingale-track-canvas/package.json b/packages/nightingale-track-canvas/package.json new file mode 100644 index 000000000..e82ac2d4e --- /dev/null +++ b/packages/nightingale-track-canvas/package.json @@ -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": "Gustavo Salazar ", + "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" + } +} diff --git a/packages/nightingale-track-canvas/src/index.ts b/packages/nightingale-track-canvas/src/index.ts new file mode 100644 index 000000000..680b3276d --- /dev/null +++ b/packages/nightingale-track-canvas/src/index.ts @@ -0,0 +1,4 @@ +export * from "@nightingale-elements/nightingale-track"; + +import NightingaleTrackCanvas from "./nightingale-track-canvas"; +export default NightingaleTrackCanvas; diff --git a/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts b/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts new file mode 100644 index 000000000..f59c93d72 --- /dev/null +++ b/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts @@ -0,0 +1,314 @@ +import { createEvent, customElementOnce } from "@nightingale-elements/nightingale-new-core"; +import NightingaleTrack, { Feature, FeatureLocation, Shapes } from "@nightingale-elements/nightingale-track"; +import { BaseType, select, Selection } from "d3"; +import { html } from "lit"; +import { drawRange, drawSymbol, drawUnknown, shapeCategory } from "./utils/draw-shapes"; +import { last, RangeCollection, Refresher } from "./utils/utils"; + + +type Fragment = FeatureLocation["fragments"][number] +type ExtendedFragment = Fragment & { featureIndex: number, location: FeatureLocation }; + + +@customElementOnce("nightingale-track-canvas") +export default class NightingaleTrackCanvas extends NightingaleTrack { + private canvas?: Selection; + private canvasCtx?: CanvasRenderingContext2D; + /** Ratio of canvas logical size versus canvas display size */ + private canvasScale: number = 1; + /** Feature fragments, stored in a data structure for fast range queries */ + private fragmentCollection?: RangeCollection; + + + override connectedCallback(): void { + super.connectedCallback(); + // Correctly adjust canvasScale on resize: + select(window).on(`resize.NightingaleTrackCanvas-${this.id}`, () => { + const devicePixelRatio = getDevicePixelRatio(); + if (devicePixelRatio !== this.canvasScale) { + this.canvasScale = devicePixelRatio; + this.refresh(); + } + }); + } + + override disconnectedCallback(): void { + select(window).on(`resize.NightingaleTrackCanvas-${this.id}`, null); + super.disconnectedCallback(); + } + + override onDimensionsChange(): void { + super.onDimensionsChange(); + if (this.canvas && !this.canvas.empty()) { + this.canvas.style("width", `${this.width}px`); + this.canvas.style("height", `${this.height}px`); + this.canvasScale = getDevicePixelRatio(); + } + } + + protected override createTrack() { + if (this.svg) { + this.svg.selectAll("g").remove(); + this.unbindEvents(this.svg); + } + if (!this.data) return; + this.layoutObj?.init(this.data); + this.svg = select(this).selectAll("svg"); + this.canvas = select(this).selectAll("canvas"); + this.canvasCtx = this.canvas.node()?.getContext("2d") ?? undefined; + this.onDimensionsChange(); + this.fragmentCollection = getFragmentCollection(this.data); + if (this.svg) { // this check is necessary because `svg` setter does not always set + this.bindEvents(this.svg); + this.highlighted = this.svg.append("g").attr("class", "highlighted"); + } + } + + override refresh() { + super.refresh(); + this.requestDraw(); + } + + override render() { + return html` +
+
+ + +
+
+ `; + } + + + private _drawStamp: { data?: Feature[], canvas?: CanvasRenderingContext2D, extent?: string } = {}; + /** If `_drawStamp` has become outdated since the last call to this function, update `_drawStamp` and return true. + * Otherwise return false. */ + private needsRedraw(): boolean { + const stamp = { + data: this.data, + canvas: this.canvasCtx, + extent: `${this.width}x${this.height}@${this.canvasScale}/${this.getSeqPositionFromX(0)}:${this.getSeqPositionFromX(this.width)}`, + }; + if (stamp.data === this._drawStamp.data && stamp.canvas === this._drawStamp.canvas && stamp.extent === this._drawStamp.extent) { + return false; + } else { + this._drawStamp = stamp; + return true; + } + } + + /** Request canvas redraw. */ + private requestDraw = () => this._drawer.requestRefresh(); + private readonly _drawer = Refresher(() => this._draw()); + /** Do not call directly! Call `requestDraw` instead to avoid browser freezing. */ + private _draw(): void { + if (!this.needsRedraw()) return; + this.adjustCanvasLogicalSize(); + this.drawCanvasContent(); + } + + private adjustCanvasLogicalSize() { + if (!this.canvasCtx) return; + const newWidth = Math.floor(this.width * this.canvasScale); + const newHeight = Math.floor(this.height * this.canvasScale); + if (this.canvasCtx.canvas.width !== newWidth) { + this.canvasCtx.canvas.width = newWidth; + } + if (this.canvasCtx.canvas.height !== newHeight) { + this.canvasCtx.canvas.height = newHeight; + } + } + + 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); + if (!this.fragmentCollection) return; + + const scale = this.canvasScale; + ctx.lineWidth = scale * 1; + const baseWidth = scale * this.getSingleBaseWidth(); + const height = scale * Math.max(1, this.layoutObj?.getFeatureHeight() ?? 0); // Yes, sometimes `getFeatureHeight` returns negative numbers ¯\_(ツ)_/¯ + const optXPadding = Math.min(scale * 1.5, 0.25 * baseWidth); // To avoid overlap/touch for certain shapes (line, bridge, helix, strand) + const leftEdgeSeq = this.getSeqPositionFromX(0 - SYMBOL_RADIUS) ?? -Infinity; + const rightEdgeSeq = this.getSeqPositionFromX(canvasWidth / scale + SYMBOL_RADIUS) ?? Infinity; + // This is better than this["display-start"], this["display-end"]+1, because it considers margins and symbol size + + // Draw features + const fragments = this.fragmentCollection.overlappingItems(leftEdgeSeq, rightEdgeSeq); + for (const fragment of fragments) { + const iFeature = fragment.featureIndex; + const fragmentLength = (fragment.end ?? fragment.start) + 1 - fragment.start; + const x = scale * this.getXFromSeqPosition(fragment.start); + const width = fragmentLength * baseWidth; + const y = scale * (this.layoutObj?.getFeatureYPos(this.data[iFeature]) ?? 0); + const shape = this.getShape(this.data[iFeature]); + ctx.fillStyle = this.getFeatureFillColor(this.data[iFeature]); + ctx.strokeStyle = this.getFeatureColor(this.data[iFeature]); + ctx.globalAlpha = (this.data[iFeature].opacity ?? 0.9); + + const rangeDrawn = drawRange(ctx, shape, x, y, width, height, optXPadding, fragmentLength); + if (!rangeDrawn) { + const cx = x + 0.5 * width; + const cy = y + 0.5 * height; + const r = scale * SYMBOL_RADIUS; + const symbolDrawn = drawSymbol(ctx, shape, cx, cy, r); + if (!symbolDrawn) { + this.printUnknownShapeWarning(shape); + drawUnknown(ctx, cx, cy, r); + } + if (fragmentLength > 1) { + drawRange(ctx, "line", x, y, width, height, optXPadding, fragmentLength); + } + } + } + + // Draw margins + ctx.globalAlpha = 1; + ctx.fillStyle = this["margin-color"]; + const marginLeft = this["margin-left"] * scale; + const marginRight = this["margin-right"] * scale; + const marginTop = this["margin-top"] * scale; + const marginBottom = this["margin-bottom"] * scale; + ctx.fillRect(0, 0, marginLeft, canvasHeight); + ctx.fillRect(canvasWidth - marginRight, 0, marginRight, canvasHeight); + ctx.fillRect(marginLeft, 0, canvasWidth - marginLeft - marginRight, marginTop); + ctx.fillRect(marginLeft, canvasHeight - marginBottom, canvasWidth - marginLeft - marginRight, marginBottom); + } + + private _unknownShapeWarningPrinted = new Set(); + private printUnknownShapeWarning(shape: Shapes): void { + if (!this._unknownShapeWarningPrinted.has(shape)) { + console.warn(`NightingaleTrackCanvas: Drawing shape "${shape}" is not implemented. Will draw question marks instead ¯\\_(ツ)_/¯`); + this._unknownShapeWarningPrinted.add(shape); + } + } + + /** Inverse of `this.getXFromSeqPosition`. */ + getSeqPositionFromX(x: number): number | undefined { + return this.xScale?.invert(x - this["margin-left"]); + } + + private getFragmentAt(svgX: number, svgY: number): ExtendedFragment | undefined { + if (!this.fragmentCollection) return undefined; + const seqStart = this.getSeqPositionFromX(svgX - SYMBOL_RADIUS); + const seqEnd = this.getSeqPositionFromX(svgX + SYMBOL_RADIUS); + if (seqStart === undefined || seqEnd === undefined) return undefined; + + const fragments = this.fragmentCollection.overlappingItems(seqStart, seqEnd); + const baseWidth = this.getSingleBaseWidth(); + const featureHeight = this.layoutObj?.getFeatureHeight() ?? 0; + + const isPointed = (fragment: ExtendedFragment) => { + const feature = this.data[fragment.featureIndex]; + const y = this.layoutObj?.getFeatureYPos(feature) ?? 0; + const yOK = y <= svgY && svgY <= y + featureHeight; + if (!yOK) return false; + const fragmentLength = (fragment.end ?? fragment.start) + 1 - fragment.start; + const xStart = this.getXFromSeqPosition(fragment.start); + const xEnd = xStart + fragmentLength * baseWidth; + if (xStart <= svgX && svgX <= xEnd) return true; // pointing at range (for symbol and range shapes) + if (shapeCategory(this.getShape(feature)) !== "range") { + // Symbol shapes + const xMid = xStart + 0.5 * fragmentLength * baseWidth; + if (xMid - SYMBOL_RADIUS <= svgX && svgX <= xMid + SYMBOL_RADIUS) return true; // pointing at symbol (for symbol shapes only) + } + return false; + }; + + return last(fragments, isPointed); + } + + private bindEvents(target: Selection): void { + target.on("click.NightingaleTrackCanvas", (event: MouseEvent) => this.handleClick(event)); + target.on("mousemove.NightingaleTrackCanvas", (event: MouseEvent) => this.handleMousemove(event)); + target.on("mouseout.NightingaleTrackCanvas", () => this.handleMouseout()); + } + + private unbindEvents(target: Selection): void { + target.on("click.NightingaleTrackCanvas", null); + target.on("mousemove.NightingaleTrackCanvas", null); + target.on("mouseout.NightingaleTrackCanvas", null); + } + + private handleClick(event: MouseEvent): void { + const fragment = this.getFragmentAt(event.offsetX, event.offsetY); + if (!fragment) { + return; // This is not optimal, but trying to mimic NightingaleTrack behavior + } + const feature = this.data[fragment.featureIndex]; + const withHighlight = this.getAttribute("highlight-event") === "onclick"; + const customEvent = createEvent( + "click", + feature as unknown as Parameters<(typeof createEvent)>["1"], + withHighlight, + true, + fragment.start, + fragment.end ?? fragment.start, + event.target instanceof HTMLElement ? event.target : undefined, + event, + this, + ); + this.dispatchEvent(customEvent); + } + + private handleMousemove(event: MouseEvent): void { + const fragment = this.getFragmentAt(event.offsetX, event.offsetY); + if (!fragment) { + return this.handleMouseout(); + } + const feature = this.data[fragment.featureIndex]; + const withHighlight = this.getAttribute("highlight-event") === "onmouseover"; + const customEvent = createEvent( + "mouseover", + feature as unknown as Parameters<(typeof createEvent)>["1"], + withHighlight, + false, + fragment.start, + fragment.end ?? fragment.start, + event.target instanceof HTMLElement ? event.target : undefined, + event, + this, + ); + this.dispatchEvent(customEvent); + } + + private handleMouseout(): void { + const withHighlight = this.getAttribute("highlight-event") === "onmouseover"; + const customEvent = createEvent("mouseout", null, withHighlight); + this.dispatchEvent(customEvent); + } +} + + +function getDevicePixelRatio(): number { + return window?.devicePixelRatio ?? 1; +} + +function getAllFragments(data: Feature[]): ExtendedFragment[] { + const out: ExtendedFragment[] = []; + const nFeatures = data.length; + for (let i = 0; i < nFeatures; i++) { + const feature = data[i]; + if (!feature.locations) continue; + for (const location of feature.locations) { + for (const fragment of location.fragments) { + out.push({ ...fragment, featureIndex: i, location }); + } + } + } + return out; +} + +function getFragmentCollection(data: Feature[]): RangeCollection { + const fragments = getAllFragments(data); + return new RangeCollection(fragments, { start: f => f.start, stop: f => (f.end ?? f.start) + 1 }); +} + + +// Magic number from packages/nightingale-track/src/FeatureShape.ts: +const SYMBOL_SIZE = 10; +const SYMBOL_RADIUS = 0.5 * SYMBOL_SIZE; diff --git a/packages/nightingale-track-canvas/src/utils/draw-shapes.ts b/packages/nightingale-track-canvas/src/utils/draw-shapes.ts new file mode 100644 index 000000000..5765decd3 --- /dev/null +++ b/packages/nightingale-track-canvas/src/utils/draw-shapes.ts @@ -0,0 +1,363 @@ +import { type Shapes } from "@nightingale-elements/nightingale-track"; + + +/** Draw an "unknown shape" symbol (a question mark). */ +export function drawUnknown(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + ctx.beginPath(); + ctx.arc(cx, cy - 0.5 * r, 0.2 * r, 0.25 * Math.PI, 1 * Math.PI, true); + ctx.arc(cx - 0.35 * r, cy - 0.5 * r, 0.15 * r, 0, 1 * Math.PI, false); + ctx.arc(cx, cy - 0.5 * r, 0.5 * r, 1 * Math.PI, 0.25 * Math.PI, false); + ctx.arc(cx + 0.25 * r, cy + 0.3 * r, 0.2 * r, 1.25 * Math.PI, 1 * Math.PI, true); + ctx.arc(cx + 0.25 * r - 0.35 * r, cy + 0.3 * r, 0.15 * r, 0, 1 * Math.PI, false); + ctx.arc(cx + 0.25 * r, cy + 0.3 * r, 0.5 * r, 1 * Math.PI, 1.25 * Math.PI, false); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(cx + 0.25 * r - 0.35 * r, cy + 0.85 * r, 0.15 * r, 0, 2 * Math.PI, true); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); +} + +/** Try to draw a symbol and return true. + * Draw nothing and return false if `shape` is not supported. + * This only draws "symbols", i.e. shapes that do not stretch when zoomed in. */ +export function drawSymbol(ctx: CanvasRenderingContext2D, shape: Shapes, cx: number, cy: number, r: number): boolean { + const drawer = SymbolDrawers[shape]; + if (drawer) { + drawer(ctx, cx, cy, r); + return true; + } else { + return false; + } +} + +/** Try to draw a range and return true. + * Draw nothing and return false if `shape` is not supported. + * This only draws "ranges", i.e. shapes that stretch when zoomed in. */ +export function drawRange(ctx: CanvasRenderingContext2D, shape: Shapes, x: number, y: number, width: number, height: number, optXPadding: number, fragmentLength: number): boolean { + const drawer = RangeDrawers[shape]; + if (drawer) { + drawer(ctx, x, y, width, height, optXPadding, fragmentLength); + return true; + } else { + return false; + } +} + +/** Return shape category this shape belongs to. + * "range" are shapes that stretch when zoomed in; + * "symbol" are shapes that do not stretch when zoomed in + * (but they are rendered with a stretching line, when applied to more than one residue); + * "unknown" are shapes that are not implemented (drawn as a question mark, thus they behave as "symbol"). */ +export function shapeCategory(shape: Shapes): "range" | "symbol" | "unknown" { + if (shape in SymbolDrawers) return "symbol"; + if (shape in RangeDrawers) return "range"; + return "unknown"; +} + + +type SymbolDrawer = (ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) => void; + +const SymbolDrawers: Partial> = { + circle(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, 2 * Math.PI); + ctx.fill(); + ctx.stroke(); + }, + + /** This is not an equilateral triangle, therefore not using `drawPolygon` */ + triangle(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy + r); + ctx.lineTo(cx - r, cy + r); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, + + diamond(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, + + pentagon(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + return drawPolygon(ctx, 5, cx, cy, r); + }, + + hexagon(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + return drawPolygon(ctx, 6, cx, cy, r); + }, + + chevron(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + r, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.lineTo(cx - r, cy - r); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, + + catFace(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + const r02 = 0.2 * r; + const r04 = 0.4 * r; + const r10 = r; + ctx.beginPath(); + ctx.moveTo(cx + r04, cy - r02); + ctx.lineTo(cx + r10, cy - r10); + ctx.lineTo(cx + r10, cy + r02); + ctx.lineTo(cx + r04, cy + r10); + ctx.lineTo(cx - r04, cy + r10); + ctx.lineTo(cx - r10, cy + r02); + ctx.lineTo(cx - r10, cy - r10); + ctx.lineTo(cx - r04, cy - r02); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, + + /** This does not look like an arrow. It is something similar to a kite. */ + arrow(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + const r01 = 0.1 * r; + const r04 = 0.4 * r; + const r08 = 0.8 * r; + const r12 = 1.2 * r; + ctx.beginPath(); + ctx.moveTo(cx - r01, cy - r12); + ctx.lineTo(cx - r08, cy - r04); + ctx.lineTo(cx - r01, cy + r12); + ctx.lineTo(cx + r01, cy + r12); + ctx.lineTo(cx + r08, cy - r04); + ctx.lineTo(cx + r01, cy - r12); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, + + wave(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + const r05 = 0.5 * r; + ctx.beginPath(); + ctx.ellipse(cx - r05, cy, r05, r, 0, Math.PI, 0, false); + ctx.ellipse(cx + r05, cy, r05, r, 0, Math.PI, 0, true); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, + + /** This does not look like a double bar. It is a rhomboid. */ + doubleBar(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy - r); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy + r); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, +}; + + +type RangeDrawer = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, optXPadding: number, fragmentLength: number) => void; + +const RangeDrawers: Partial> = { + rectangle(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number): void { + ctx.fillRect(x, y, width, height); + ctx.strokeRect(x, y, width, height); + }, + + roundRectangle(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number): void { + const ry = 0.5 * height; // In NightingaleTrack, this is harcoded 6px + const rx = Math.min(ry, 0.5 * width); + ctx.beginPath(); + ctx.ellipse(x + rx, y + ry, rx, ry, 0, Math.PI, 1.5 * Math.PI, false); + ctx.ellipse(x + width - rx, y + ry, rx, ry, 0, 1.5 * Math.PI, 0, false); + ctx.ellipse(x + width - rx, y + height - ry, rx, ry, 0, 0, 0.5 * Math.PI, false); + ctx.ellipse(x + rx, y + height - ry, rx, ry, 0, 0.5 * Math.PI, Math.PI, false); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, + + line(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, xPadding: number): void { + const cy = y + 0.5 * height; + drawLine(ctx, x + xPadding, cy, x + width - xPadding, cy); + }, + + bridge(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, xPadding: number, fragmentLength: number): void { + x += xPadding; + width -= 2 * xPadding; + + if (fragmentLength === 1) { + // This does not look like a bridge + ctx.beginPath(); + ctx.moveTo(x, y + 0.5 * height); + ctx.lineTo(x + 0.5 * width, y + 0.5 * height); + ctx.lineTo(x + 0.5 * width, y); + ctx.lineTo(x + 0.5 * width, y + 0.5 * height); + ctx.lineTo(x + width, y + 0.5 * height); + ctx.lineTo(x + width, y + height); + ctx.lineTo(x, y + height); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } else { + // This looks like a bridge + const beam = 0.2 * height; // In NightingaleTrack, this is harcoded 2px + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + width, y); + ctx.lineTo(x + width, y + height); + ctx.lineTo(x + width, y + beam); + ctx.lineTo(x, y + beam); + ctx.lineTo(x, y + height); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + }, + + discontinuosStart(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number): void { + const qy = 0.2 * height; + const qx = Math.min(qy, 0.5 * width); + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + qx, y + qy); + ctx.lineTo(x, y + 2 * qy); + ctx.lineTo(x + qx, y + 3 * qy); + ctx.lineTo(x, y + 4 * qy); + ctx.lineTo(x + qx, y + height); + ctx.lineTo(x + width, y + height); + ctx.lineTo(x + width, y); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, + + discontinuosEnd(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number): void { + const qy = 0.2 * height; + const qx = Math.min(qy, 0.5 * width); + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x, y + height); + ctx.lineTo(x + width, y + height); + ctx.lineTo(x + width - qx, y + 4 * qy); + ctx.lineTo(x + width, y + 3 * qy); + ctx.lineTo(x + width - qx, y + 2 * qy); + ctx.lineTo(x + width, y + 1 * qy); + ctx.lineTo(x + width - qx, y); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, + + discontinuos(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number): void { + const qy = 0.2 * height; + const qx = Math.min(qy, 0.5 * width); + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + qx, y + qy); + ctx.lineTo(x, y + 2 * qy); + ctx.lineTo(x + qx, y + 3 * qy); + ctx.lineTo(x, y + 4 * qy); + ctx.lineTo(x + qx, y + height); + ctx.lineTo(x + width, y + height); + ctx.lineTo(x + width - qx, y + 4 * qy); + ctx.lineTo(x + width, y + 3 * qy); + ctx.lineTo(x + width - qx, y + 2 * qy); + ctx.lineTo(x + width, y + 1 * qy); + ctx.lineTo(x + width - qx, y); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, + + helix(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, xPadding: number): void { + x += xPadding; + width -= 2 * xPadding; + + const w = Math.min(0.4 * height, width); + const n = Math.floor(width / w); + const top = y + 0.05 * height; + const bottom = y + 0.95 * height; + ctx.beginPath(); + ctx.moveTo(x, bottom); + for (let i = 0; i < n; i++) { + const x_ = x + i * w; + ctx.bezierCurveTo( + x_ + 0.75 * w, bottom, + x_ + 1.25 * w, top, + x_ + 0.5 * w, top); + ctx.bezierCurveTo( + x_, top, + x_ + 0.5 * w, bottom, + x_ + w, bottom); + } + ctx.lineTo(x + width, bottom); + ctx.fill(); + ctx.stroke(); + }, + + strand(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, xPadding: number): void { + x += xPadding; + width -= 2 * xPadding + + const q = 0.25 * height; + const head = Math.min(0.5 * height, 0.75 * width); + const delta = 0.01 * head; // to avoid stroke of a too sharp angle protruding out of the track + ctx.beginPath(); + ctx.moveTo(x, y + q); + ctx.lineTo(x + width - head, y + q); + ctx.lineTo(x + width - head, y); + ctx.lineTo(x + width - head + delta, y); + ctx.lineTo(x + width, y + 0.5 * height); + ctx.lineTo(x + width - head + delta, y + height); + ctx.lineTo(x + width - head, y + height); + ctx.lineTo(x + width - head, y + height - q); + ctx.lineTo(x, y + height - q); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }, +}; + +// Future-proofing for fixing typos (discontinUOS -> discontinUOUS) +(RangeDrawers as Partial>).discontinuousStart = RangeDrawers.discontinuosStart; +(RangeDrawers as Partial>).discontinuousEnd = RangeDrawers.discontinuosEnd; +(RangeDrawers as Partial>).discontinuous = RangeDrawers.discontinuos; + + +function drawLine(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number): void { + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); +} + +function drawPolygon(ctx: CanvasRenderingContext2D, n: number, cx: number, cy: number, r: number): void { + ctx.beginPath(); + ctx.moveTo(cx + r, cy); + for (let i = 1; i < n; i++) { + const phase = 2 * Math.PI * i / n; + const x = cx + r * Math.cos(phase); + const y = cy + r * Math.sin(phase); + ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); +} diff --git a/packages/nightingale-track-canvas/src/utils/utils.ts b/packages/nightingale-track-canvas/src/utils/utils.ts new file mode 100644 index 000000000..e49101506 --- /dev/null +++ b/packages/nightingale-track-canvas/src/utils/utils.ts @@ -0,0 +1,208 @@ + +/** Helper for running potentially time-consuming "refresh" actions (e.g. canvas draw) in a non-blocking way. + * If the caller calls `requestRefresh()`, this call returns immediately but it is guaranteed + * that `refresh` will be run asynchronously in the future. + * If the caller calls `requestRefresh()` multiple times, it is NOT guaranteed + * that `refresh` will be run the same number of times, only that it will be run + * at least once after the last call to `requestRefresh()`. */ +export interface Refresher { + requestRefresh: () => void, +} +export function Refresher(refresh: () => void): Refresher { + let requested = false; + let running = false; + function requestRefresh(): void { + requested = true; + if (!running) { + handleRequests(); // do not await + } + } + async function handleRequests(): Promise { + while (requested) { + requested = false; + running = true; + await sleep(0); // let other things happen (this pushes the rest of the function to the end of the queue) + try { + refresh(); + } catch (err) { + console.error(err); + } + running = false; + } + } + return { + requestRefresh, + }; +} + + +/** Sleep for `ms` milliseconds. */ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(() => { + resolve(); + }, ms)); +} + + +/** Return the last element of `array`, or `undefined` if there are no elements. + * If `predicate` is provided, return the last element where `predicate` returns true, + * or `undefined` if there is no such element. */ +export function last(array: T[], predicate?: (value: T, index: number, obj: T[]) => boolean): T | undefined { + if (!predicate) return array.length > 0 ? array[array.length - 1] : undefined; + for (let i = array.length - 1; i >= 0; i--) { + const element = array[i]; + if (predicate(element, i, array)) return element; + } + return undefined; +} + + +/** Return index of the first element of `sortedArray` for which `key(element) >= query`. + * Return length of `sortedArray` if `key(element) < query` for all elements. + * (aka Return the first index where `query` could be inserted while keeping the array sorted.) */ +export function firstGteqIndex(sortedArray: ArrayLike, query: number, key: (element: T) => number) { + return firstGteqIndexInRange(sortedArray, query, 0, sortedArray.length, key); +} + +/** Return index of the first element within range [start, end) for which `key(element) >= query`. + * Return `end` if for all elements in the range `key(element) < query`. */ +function firstGteqIndexInRange(sortedArray: ArrayLike, query: number, start: number, end: number, key: (element: T) => number) { + if (start === end) return start; + // Invariants: + // key(sortedArray[i]) < query for each i < start + // key(sortedArray[i]) >= query for each i >= end + while (end - start > 4) { + const mid = (start + end) >> 1; + if (key(sortedArray[mid]) >= query) { + end = mid; + } else { + start = mid + 1; + } + } + // Linear search remaining 4 or fewer elements: + for (let i = start; i < end; i++) { + if (key(sortedArray[i]) >= query) { + return i; + } + } + return end; +} + + +/** Data structure for storing integer intervals (ranges) and efficiently retrieving a subset of ranges which overlap with another (query) interval. + * Not suited for storing float intervals, but query interval can be float. */ +export class RangeCollection { + protected readonly items: T[]; + protected readonly starts: number[]; + protected readonly stops: number[]; + + /** Keys to `this.bins`, sorted in ascending order */ + protected readonly binSpans: number[]; + /** `this.bins[span]` contains indices of all items whose length is `<= span` but `> span/Q`, in ascending order */ + protected readonly bins: Record; + /** Ratio of spans of neighboring bins */ + protected readonly Q = 2; + /** Reusable arrays (one for each bin), to avoid repeated array allocation */ + private readonly _tmpArrays: Record = {}; + + /** Create a new collection of ranges. `start` must return range start (inclusive), `stop` must return range end (exclusive) */ + constructor(items: T[], accessors: { start: (item: T) => number, stop: (item: T) => number }) { + const { start, stop } = accessors; + this.items = items; + this.starts = items.map(start); + this.stops = items.map(stop); + + this.bins = {}; + for (let i = 0; i < items.length; i++) { + const length = this.stops[i] - this.starts[i]; + let binSpans = 1; + while (binSpans < length) binSpans *= this.Q; + (this.bins[binSpans] ??= []).push(i); + } + this.binSpans = sortNumeric(Object.keys(this.bins).map(Number)); + for (const binSpan of this.binSpans) { + this.bins[binSpan].sort(this.compareFn); + } + } + + /** Return number of items. */ + size(): number { + return this.items.length; + } + + /** Get all ranges that overlap with interval [start, stop). + * Does not preserve original order of the ranges! + * Instead sorts the ranges by their start (ranges with the same start are sorted by decreasing length). */ + overlappingItems(start: number, stop: number): T[] { + return this.overlappingItemIndices(start, stop).map(i => this.items[i]); + } + + /** Get indices of all ranges that overlap with interval [start, stop). + * Does not preserve original order of the ranges! + * Instead sorts the ranges by their start (ranges with the same start are sorted by decreasing length). */ + overlappingItemIndices(start: number, stop: number): number[] { + const partialOuts = this.binSpans.map(binSpan => this.overlappingItemIndicesInBin(binSpan, start, stop, this._tmpArrays[binSpan] ??= [])); + return mergeSortedArrays(partialOuts, this.compareFn); + } + + private overlappingItemIndicesInBin(binSpan: number, start: number, stop: number, out: number[]): number[] { + out.length = 0; + const bin = this.bins[binSpan]; + const from = firstGteqIndex(bin, Math.floor(start) - binSpan + 1, i => this.starts[i]); + const to = firstGteqIndex(bin, Math.ceil(stop), i => this.starts[i]); + for (let j = from; j < to; j++) { + const i = bin[j]; + if (this.stops[i] > start) { + out.push(i); + } + } + return out; + } + + /** Console.log info about this RangeCollection */ + print(): void { + for (const binSpan of this.binSpans) { + console.log(`Bin ${binSpan}:`, this.bins[binSpan].map(r => `${this.starts[r]}-${this.stops[r]}(${this.stops[r] - this.starts[r]})`).join(" ")); + } + } + + /** Compare function used to sort ranges. Sorts by start, if start equal longer range goes first. */ + private readonly compareFn = (i: number, j: number) => this.starts[i] - this.starts[j] || this.stops[j] - this.stops[i]; +} + +function mergeSortedArrays(arrays: T[][], compareFn: (a: T, b: T) => number): T[] { + const queue = arrays.map((arr, i) => i).filter(i => arrays[i].length > 0); + queue.sort((a, b) => compareFn(arrays[a][0], arrays[b][0])); + const heads = arrays.map(() => 0); + const out: T[] = []; + while (queue.length > 0) { + const iHeadArr = queue[0]; + const headArr = arrays[iHeadArr]; + // Take one element from head array + out.push(headArr[heads[iHeadArr]++]); + // Restore queue ordering + if (heads[iHeadArr] === headArr.length) { + // Discard depleted head array + queue.shift(); + } else { + // Insert head array to correct position + const headValue = headArr[heads[iHeadArr]]; + let i = 1; + for (; i < queue.length; i++) { + const iOtherArr = queue[i]; + const otherHeadValue = arrays[iOtherArr][heads[iOtherArr]]; + if (compareFn(otherHeadValue, headValue) < 0) { + queue[i - 1] = iOtherArr; + } else { + break; + } + } + queue[i - 1] = iHeadArr; + } + } + return out; +} + +function sortNumeric(array: number[]): number[] { + return array.sort((a, b) => a - b); +} diff --git a/packages/nightingale-track-canvas/tests/nightingale-track-canvas.test.ts b/packages/nightingale-track-canvas/tests/nightingale-track-canvas.test.ts new file mode 100644 index 000000000..73ff38ec7 --- /dev/null +++ b/packages/nightingale-track-canvas/tests/nightingale-track-canvas.test.ts @@ -0,0 +1,58 @@ +import { firstGteqIndex, last, RangeCollection } from "../src/utils/utils"; + + +const mockSortedArray = [0, 0, 2, 3, 3, 5, 7, 9, 10, 11, 11, 13, 16, 19, 23, 23, 24, 24, 24, 27, 30, 31, 31, 32, 33, 34, 34, 35, 36, 38, 39, 39, 41, 41, 41, 42, 46, 46, 47, 48, 48, 50, 52, 53, 55, 56, 58, 58, 58, 59, 61, 61, 63, 64, 65, 65, 67, 67, 70, 71, 72, 74, 74, 74, 75, 77, 78, 79, 80, 82, 82, 83, 84, 84, 85, 86, 87, 88, 88, 88, 89, 92, 92, 92, 93, 93, 93, 94, 95, 95, 96, 96, 96, 99, 99, 99, 102, 102, 103, 104, 105, 107, 107, 109, 110, 110, 111, 113, 114, 114, 114, 115, 115, 117, 117, 117, 119, 120, 121, 122, 122, 123, 126, 126, 128, 128, 129, 129, 130, 130, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 146, 146, 147, 148, 148, 149, 151, 151, 151, 153, 153, 154, 156, 156, 157, 157, 157, 158, 158, 159, 159, 159, 162, 162, 164, 166, 167, 168, 169, 169, 169, 171, 172, 173, 174, 175, 175, 175, 178, 178, 178, 181, 181, 182, 183, 183, 184, 185, 185, 186, 186, 186, 187, 187, 188, 188, 190, 192, 192, 192, 193, 193, 193, 194, 196, 197, 198, 199, 200, 201, 202, 203, 205, 205, 206, 206, 207, 207, 210, 210, 211, 211, 211, 211, 211, 211, 215, 217, 219, 219, 221, 222, 222, 223, 223, 224, 227, 228, 228, 229, 229, 233, 233, 233, 233, 234, 235, 236, 237, 238, 238, 238, 240, 241, 243, 245, 246, 247, 249, 250, 250, 250, 252, 252, 256, 256, 257, 258, 259, 260, 260, 261, 265, 268, 270, 270, 270, 270, 273, 273, 274, 274, 275, 278, 279, 279, 280, 281, 282, 282, 282, 283, 283, 284, 284, 284, 285, 287, 290, 292, 295, 295, 298, 298, 301, 302, 303, 304, 305, 306, 308, 309, 309, 310, 311, 311, 315, 315, 315, 317, 317, 317, 318, 319, 322, 322, 325, 327, 327, 327, 328, 328, 328, 329, 330, 330, 331, 332, 332, 333, 334, 334, 334, 335, 337, 340, 343, 345, 347, 348, 348, 349, 351, 351, 352, 354, 355, 355, 355, 356, 357, 358, 358, 359, 360, 360, 360, 361, 362, 363, 364, 365, 367, 369, 369, 369, 370, 372, 374, 375, 376, 377, 378, 379, 380, 381, 381, 387, 388, 388, 388, 391, 393, 394, 395, 395, 395, 396, 396, 398, 398, 399, 400, 400, 402, 402, 403, 403, 404, 405, 406, 408, 408, 410, 410, 412, 414, 415, 418, 418, 418, 420, 421, 422, 422, 424, 424, 425, 425, 425, 426, 428, 428, 429, 429, 430, 430, 431, 431, 432, 434, 435, 436, 437, 439, 439, 439, 444, 444, 445, 447, 450, 453, 453, 453, 454, 458, 458, 459, 463, 463, 464, 467, 467, 467, 469, 471, 471, 471, 473, 476, 478, 480, 481, 483, 483, 484, 484, 486, 486, 487, 487, 489, 489, 491, 491, 491, 492, 493, 494, 495, 495, 496, 496, 499, 500, 500, 500, 502, 504, 505, 506, 506, 507, 509, 509, 510, 511, 512, 512, 513, 513, 515, 517, 517, 519, 520, 521, 521, 522, 522, 524, 525, 525, 525, 527, 527, 528, 529, 533, 533, 535, 536, 539, 539, 540, 540, 541, 541, 545, 545, 545, 549, 549, 551, 551, 553, 554, 554, 555, 557, 557, 558, 558, 558, 559, 560, 561, 562, 562, 563, 563, 564, 565, 566, 571, 572, 573, 573, 573, 575, 576, 577, 578, 578, 578, 581, 581, 582, 582, 585, 585, 586, 587, 587, 588, 588, 589, 590, 592, 593, 594, 595, 595, 597, 599, 599, 600, 600, 601, 603, 603, 607, 608, 609, 609, 611, 612, 615, 617, 620, 621, 623, 625, 626, 626, 627, 628, 629, 630, 631, 633, 633, 634, 635, 635, 636, 636, 636, 638, 640, 640, 641, 641, 641, 643, 644, 644, 647, 648, 648, 649, 650, 651, 652, 652, 653, 654, 654, 655, 656, 656, 658, 659, 660, 660, 661, 661, 662, 663, 665, 668, 668, 672, 674, 674, 675, 675, 678, 679, 680, 682, 683, 683, 683, 684, 685, 685, 686, 686, 687, 695, 699, 699, 700, 701, 702, 703, 705, 705, 706, 706, 706, 706, 706, 706, 707, 707, 708, 711, 711, 714, 714, 714, 714, 715, 716, 717, 720, 722, 724, 724, 726, 726, 727, 728, 728, 729, 731, 731, 731, 732, 733, 734, 735, 737, 738, 738, 739, 739, 741, 741, 743, 743, 746, 747, 747, 748, 749, 750, 751, 751, 753, 754, 754, 756, 756, 757, 758, 758, 759, 760, 760, 760, 762, 763, 763, 764, 764, 765, 765, 767, 768, 769, 769, 770, 772, 774, 774, 774, 774, 775, 775, 778, 778, 778, 778, 778, 779, 780, 780, 781, 781, 782, 782, 783, 783, 783, 783, 784, 784, 786, 789, 790, 791, 791, 792, 792, 793, 793, 794, 794, 797, 799, 799, 799, 799, 799, 800, 801, 802, 802, 803, 804, 809, 810, 810, 811, 812, 812, 812, 813, 814, 816, 817, 819, 819, 819, 820, 821, 821, 823, 823, 824, 825, 826, 826, 829, 830, 830, 831, 831, 832, 832, 834, 836, 836, 837, 838, 839, 840, 840, 843, 843, 844, 844, 846, 847, 850, 851, 852, 853, 854, 854, 855, 857, 857, 859, 860, 861, 861, 862, 863, 864, 865, 866, 869, 870, 871, 871, 871, 874, 874, 875, 875, 875, 877, 880, 880, 881, 881, 883, 883, 885, 887, 888, 888, 889, 890, 890, 891, 893, 895, 895, 896, 897, 899, 899, 900, 900, 900, 902, 902, 903, 904, 905, 906, 907, 908, 908, 909, 909, 910, 911, 911, 911, 913, 914, 914, 915, 916, 916, 920, 922, 923, 924, 924, 927, 927, 928, 928, 929, 930, 931, 933, 936, 937, 938, 941, 945, 945, 946, 947, 948, 948, 949, 949, 951, 954, 956, 956, 957, 959, 959, 961, 961, 966, 966, 966, 968, 969, 970, 970, 973, 973, 973, 973, 976, 979, 981, 982, 982, 983, 984, 986, 986, 988, 989, 989, 990, 991, 991, 991, 991, 991, 992, 993, 994, 998, 999]; +const queries = [-10, -1, 0, 1, 2, 4, 5, 42, 500, 501, 502, 997, 998, 999, 1000, 1001, 2000]; + +const mockRanges: [number, number][] = [[31, 99], [5, 40], [8, 77], [35, 65], [10, 81], [7, 31], [16, 43], [36, 90], [40, 80], [14, 71], [7, 47], [5, 16], [53, 73], [27, 46], [16, 50], [15, 26], [63, 97], [52, 57], [26, 38], [43, 71], [26, 100], [75, 88], [19, 63], [2, 19], [17, 78], [8, 49], [6, 10], [15, 90], [1, 50], [7, 65], [16, 20], [72, 95], [23, 75], [32, 59], [47, 62], [2, 33], [13, 48], [27, 53], [16, 48], [17, 95], [15, 43], [33, 93], [66, 75], [33, 76], [2, 52], [55, 72], [33, 90], [63, 76], [67, 84], [89, 96], [39, 91], [30, 34], [83, 89], [41, 62], [82, 83], [30, 64], [56, 83], [65, 86], [8, 12], [6, 94], [55, 77], [24, 40], [11, 30], [9, 45], [38, 71], [14, 24], [50, 58], [21, 74], [75, 89], [65, 81], [11, 76], [13, 17], [27, 56], [19, 63], [72, 85], [44, 86], [80, 88], [18, 25], [60, 65], [18, 86], [69, 92], [35, 97], [6, 54], [3, 63], [24, 83], [7, 75], [47, 83], [31, 88], [9, 15], [63, 95], [32, 66], [26, 77], [39, 94], [35, 79], [51, 55], [35, 92], [21, 93], [30, 84], [36, 66], [17, 37], [8, 82], [24, 31], [35, 49], [10, 36], [64, 67], [16, 76], [22, 43], [63, 78], [12, 13], [3, 56], [29, 97], [29, 99], [23, 57], [12, 78], [35, 51], [24, 78], [22, 85], [25, 70], [17, 19], [6, 91], [2, 19], [21, 63], [79, 86], [79, 80], [27, 53], [41, 91], [25, 55], [64, 71], [40, 62], [24, 69], [31, 33], [14, 52], [34, 80], [1, 14], [65, 89], [17, 67], [72, 77], [84, 95], [66, 99], [20, 59], [33, 54], [32, 75], [26, 53], [33, 81], [21, 74], [17, 21], [16, 50], [56, 61], [72, 96], [27, 91], [11, 63], [21, 90], [18, 39], [31, 97], [31, 37], [8, 87], [18, 49], [34, 84], [10, 70], [60, 82], [55, 77], [6, 34], [71, 100], [30, 93], [55, 99], [43, 76], [15, 87], [10, 61], [66, 74], [14, 21], [39, 91], [74, 86], [18, 69], [13, 60], [64, 96], [3, 32], [23, 35], [44, 82], [21, 39], [14, 47], [69, 71], [16, 55], [23, 96], [56, 60], [46, 77], [93, 95], [32, 98], [47, 86], [31, 67], [39, 83], [29, 80], [17, 28], [7, 8], [9, 44], [2, 15], [61, 74], [5, 55], [12, 19], [51, 75], [0, 1], [18, 67], [46, 53], [37, 83], [43, 57], [19, 24], [45, 76], [12, 85], [3, 43], [31, 44], [50, 51], [58, 63], [22, 24], [23, 25], [0, 47], [28, 48], [11, 59], [37, 43], [12, 20], [13, 37], [42, 72], [76, 78], [65, 73], [62, 72], [0, 29], [66, 83], [24, 33], [12, 45], [73, 100], [0, 52], [58, 67], [31, 95], [82, 85], [30, 32], [18, 77], [24, 80], [0, 61], [21, 85], [6, 29], [17, 97], [46, 86], [15, 72], [30, 49], [65, 74], [0, 40], [52, 81], [16, 50], [31, 33], [21, 84], [27, 98], [3, 54], [30, 86], [54, 58], [35, 84], [3, 53], [22, 29], [54, 64], [50, 95], [56, 57], [9, 34], [2, 71], [30, 98], [3, 70], [62, 78], [36, 86], [1, 96], [16, 36], [23, 62], [55, 99], [23, 48], [25, 77], [11, 92], [31, 97], [69, 86], [23, 93], [19, 76], [21, 26], [47, 68], [76, 97], [41, 57], [11, 85], [0, 93], [27, 93], [38, 38], [1, 66], [7, 44], [25, 59], [4, 34], [68, 82], [77, 89], [44, 70], [18, 95], [59, 86], [29, 66], [7, 85], [57, 81], [16, 25], [1, 5], [5, 28], [53, 56], [89, 96], [47, 81], [6, 21], [58, 98], [42, 55], [46, 51], [66, 98], [42, 59], [22, 63], [4, 27], [14, 95], [71, 74], [25, 90], [47, 99], [31, 94], [56, 96], [22, 38], [16, 43], [40, 54], [66, 69], [49, 80], [4, 58], [12, 41], [82, 84], [75, 77], [1, 37], [5, 62], [59, 61], [66, 91], [15, 78], [62, 70], [22, 34], [33, 40], [28, 36], [38, 65], [44, 81], [51, 55], [0, 55], [11, 52], [29, 43], [7, 94], [53, 74], [24, 51], [29, 57], [12, 64], [1, 70], [28, 97], [57, 98], [9, 82], [12, 89], [7, 35], [61, 97], [73, 93], [77, 80], [20, 97], [47, 67], [84, 96], [8, 9], [36, 68], [71, 85], [11, 32], [28, 62], [27, 64], [14, 18], [28, 92], [5, 40], [15, 29], [10, 12], [50, 93], [76, 92], [36, 55], [54, 61], [1, 90], [41, 49], [76, 94], [16, 80], [45, 66], [76, 98], [65, 99], [38, 86], [2, 29], [11, 60], [48, 79], [44, 94], [86, 98], [72, 98], [36, 73], [58, 59], [77, 80], [74, 85], [13, 71], [6, 87], [15, 50], [46, 70], [0, 29], [34, 45], [3, 40], [20, 67], [1, 32], [6, 87], [4, 44], [22, 71], [49, 77], [8, 51], [68, 73], [35, 94], [48, 67], [41, 92], [34, 90], [59, 62], [60, 66], [14, 43], [14, 92], [62, 71], [57, 91], [48, 93], [37, 62], [93, 99], [28, 82], [31, 34], [36, 72], [1, 34], [38, 43], [15, 54], [90, 97], [7, 55], [93, 98], [21, 56], [61, 83], [32, 60], [18, 37], [10, 57], [14, 74], [54, 72], [23, 72], [82, 84], [2, 46], [26, 97], [12, 58], [2, 75], [31, 86], [27, 94], [79, 96], [35, 70], [11, 28], [8, 25], [51, 69], [53, 60], [16, 75], [40, 49], [61, 70], [10, 61], [3, 44], [1, 16], [56, 80], [0, 48], [52, 62], [31, 64], [42, 43], [39, 73], [14, 21], [65, 73], [33, 50], [18, 33], [55, 91], [25, 29], [41, 52], [60, 62], [78, 100], [12, 19], [42, 53], [2, 60], [78, 92], [56, 100], [39, 57], [48, 85], [59, 99], [63, 78], [87, 96], [38, 100], [33, 93], [78, 78], [37, 45], [33, 79], [7, 100], [61, 82], [11, 50], [48, 88], [43, 91], [10, 13], [8, 30], [35, 62], [30, 93], [34, 39], [16, 96], [66, 76], [28, 37], [61, 67], [16, 92], [30, 99], [51, 52]]; +const rangeQueries = mockRanges.slice(20); + + +describe("nightingale-track-canvas tests", () => { + test("firstGteqIndex", () => { + for (const query of queries) { + const truth = firstGteqIndex_reference(mockSortedArray, query, v => v); + const found = firstGteqIndex(mockSortedArray, query, v => v); + expect(found).toEqual(truth); + } + }); + + test("firstGteqIndex with non-trivial key", () => { + const descArray = Array.from(mockSortedArray).reverse(); + for (const query of queries) { + const truth = firstGteqIndex_reference(descArray, query, v => -v); + const found = firstGteqIndex(descArray, query, v => -v); + expect(found).toEqual(truth); + } + }); + + test("last", () => { + expect(last([])).toEqual(undefined); + expect(last(mockSortedArray)).toEqual(999); + expect(last([], v => v % 10 === 0)).toEqual(undefined); + expect(last(mockSortedArray, v => v % 10 === 0)).toEqual(990); + }); + + test("RangeCollection", () => { + const collection = new RangeCollection(mockRanges, { start: r => r[0], stop: r => r[1] }); + expect(collection.size()).toEqual(mockRanges.length); + + for (const query of rangeQueries) { + const truth = overlappingItems_reference(mockRanges, ...query); + const found = collection.overlappingItems(...query); + expect(found).toEqual(truth); + } + }); +}); + + +function firstGteqIndex_reference(sortedArray: T[], query: number, key: (element: T) => number): number { + const found = sortedArray.findIndex(v => key(v) >= query); + if (found >= 0) return found; + else return sortedArray.length; +} + +function overlappingItems_reference(ranges: [number, number][], start: number, stop: number) { + const compareFn = (p: [number, number], q: [number, number]) => p[0] - q[0] || q[1] - p[1]; + return ranges.filter(r => r[0] < stop && r[1] > start).sort(compareFn); +} diff --git a/packages/nightingale-track-canvas/tsconfig.json b/packages/nightingale-track-canvas/tsconfig.json new file mode 100644 index 000000000..6a62dbc48 --- /dev/null +++ b/packages/nightingale-track-canvas/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/nightingale-track/src/nightingale-track.ts b/packages/nightingale-track/src/nightingale-track.ts index a830f4f53..1ded5c638 100644 --- a/packages/nightingale-track/src/nightingale-track.ts +++ b/packages/nightingale-track/src/nightingale-track.ts @@ -103,7 +103,7 @@ class NightingaleTrack extends withManager( HTMLElement | SVGElement | null, unknown >; - #highlighted?: Selection< + protected highlighted?: Selection< SVGGElement, unknown, HTMLElement | SVGElement | null, @@ -284,7 +284,7 @@ class NightingaleTrack extends withManager( if (!this.svg) return; this.seqG = this.svg.append("g").attr("class", "sequence-features"); this.createFeatures(); - this.#highlighted = this.svg.append("g").attr("class", "highlighted"); + this.highlighted = this.svg.append("g").attr("class", "highlighted"); this.margins = this.svg.append("g").attr("class", "margin"); } @@ -532,8 +532,8 @@ class NightingaleTrack extends withManager( } protected updateHighlight() { - if (!this.#highlighted) return; - const highlights = this.#highlighted + if (!this.highlighted) return; + const highlights = this.highlighted .selectAll< SVGRectElement, { diff --git a/stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.mdx b/stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.mdx new file mode 100644 index 000000000..d9be1ce9d --- /dev/null +++ b/stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.mdx @@ -0,0 +1,13 @@ +import { Meta, Description } from "@storybook/addon-docs"; + +import Readme from "../../packages/nightingale-track-canvas/README.md"; + + + +{Readme} + +
+ +

Parent Class

+ +See more details of the parent class: [nightingale-track](?path=/story/components-tracks-nightingaletrack-readme--page) diff --git a/stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.ts b/stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.ts new file mode 100644 index 000000000..3c372d9e6 --- /dev/null +++ b/stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.ts @@ -0,0 +1,244 @@ +import { Meta, Story } from "@storybook/web-components"; +import { range, rgb } from "d3"; +import { html } from "lit-html"; +import "../../packages/nightingale-track-canvas/src/index"; + + +export default { title: "Components/Tracks/NightingaleTrack-Canvas" } as Meta; + + +const DefaultArgs = { + "min-width": 400, + "height": 24, + "highlight-event": "onmouseover", // "onmouseover"|"onclick" + "highlight-color": "#EB3BFF22", + "margin-color": "#ffffffdd", // "transparent" + "layout": "non-overlapping", // "default"|"non-overlapping" +}; +type Args = typeof DefaultArgs; + + +const sampleSequence = "iubcbcIUENACBPAOUBCASFUBRUABBRWOAUVBISVBAISBVDOASV"; + +/** Create a sequence of given `length` */ +function makeSequence(length: number) { + const n = Math.ceil(length / sampleSequence.length); + return range(n).map(() => sampleSequence).join("").slice(0, length); +} + + +const Colors = ["#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#66a61e", "#e6ab02", "#a6761d"]; +const Shapes = [ + "rectangle", "roundRectangle", "line", "bridge", + "discontinuosEnd", "discontinuos", "discontinuosStart", + "helix", "strand", + "circle", "triangle", "diamond", "pentagon", "hexagon", + "chevron", "catFace", "arrow", "wave", "doubleBar", +]; + +const defaultData = [ + { + accession: "feature1", + start: 1, + end: 2, + color: "pink", + }, + { + accession: "feature1", + start: 49, + end: 50, + color: "red", + }, + { + accession: "feature1", + start: 10, + end: 20, + color: "#342ea2", + }, + { + accession: "feature2", + locations: [{ fragments: [{ start: 30, end: 45 }] }], + color: "#A42ea2", + }, + { + accession: "feature3", + locations: [ + { + fragments: [{ start: 15, end: 15 }], + }, + { fragments: [{ start: 18, end: 18 }] }, + ], + color: "#A4Aea2", + }, + { + accession: "feature4", + locations: [ + { + fragments: [ + { start: 20, end: 23 }, + { start: 26, end: 32 }, + ], + }, + ], + }, +]; + +/** Create dummy data with one feature per residue */ +function makeResidueData(start: number, end: number) { + return makeSpanData(start, end, 1, 0); +} + +/** Create dummy data with one feature per a span of residues (e.g. 1-10, 11-20, 21-30...) */ +function makeSpanData(start: number, end: number, spanLength: number = 10, gapLength: number = 0) { + return range(start, end + 1, spanLength + gapLength).map((start_, i) => ({ + accession: `feature${i}`, + tooltipContent: `feature${i}`, + start: start_, + end: start_ + spanLength - 1, + color: rgb(Colors[i % Colors.length]).darker().toString(), + fill: Colors[i % Colors.length], + shape: Shapes[i % Shapes.length], + opacity: 0.9, + })); +} + + +function nightingaleNavigation(args: Args & { length: number }) { + return html` +
+
+ + +
`; +} + +function nightingaleSequence(args: Args & { length: number }) { + const sequence = makeSequence(args["length"]); + return html` +
+
+ + +
`; +} + +function nightingaleTrack(args: Args & { length: number, id: number }) { + return html` +
+
SVG
+ + +
`; +} + +function nightingaleTrackCanvas(args: Args & { length: number, id: number }) { + return html` +
+
Canvas
+ + +
`; +} + + +function makeStory(options: { nTracks: number, showNightingaleTrack: boolean, showNightingaleTrackCanvas: boolean, length: number, data: any[] }): Story { + const template: Story = (args: Args) => { + const tracks = range(options.nTracks).map(i => html` + ${options.showNightingaleTrack ? nightingaleTrack({ ...args, length: options.length, id: i }) : undefined} + ${options.showNightingaleTrackCanvas ? nightingaleTrackCanvas({ ...args, length: options.length, id: i }) : undefined} + `); + return html` + + + Use Ctrl+scroll to zoom. +
+ +
+ ${nightingaleNavigation({ ...args, length: options.length })} + ${nightingaleSequence({ ...args, length: options.length })} + ${tracks} +
+
+
`; + } + + const story: Story = template.bind({}); + story.args = { ...DefaultArgs }; + story.play = async () => { + await customElements.whenDefined("nightingale-track"); + for (const track of document.getElementsByTagName("nightingale-track")) { + (track as any).data = options.data; + } + await customElements.whenDefined("nightingale-track-canvas"); + for (const track of document.getElementsByTagName("nightingale-track-canvas")) { + (track as any).data = options.data; + } + }; + return story; +} + + +export const Track = makeStory({ + nTracks: 1, + showNightingaleTrack: true, + showNightingaleTrackCanvas: true, + length: 60, + data: defaultData, +}); + +export const AllShapes = makeStory({ + nTracks: 1, + showNightingaleTrack: true, + showNightingaleTrackCanvas: true, + length: 400, + data: [...makeResidueData(1, 100), ...makeSpanData(121, 400)], +}); + +export const BigData = makeStory({ + nTracks: 50, + showNightingaleTrack: false, + showNightingaleTrackCanvas: true, + length: 5000, + data: makeResidueData(1, 5000), +}); From 6619222e91904a5c660623d91aae83a7a65187ba Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Tue, 22 Oct 2024 15:58:23 +0100 Subject: [PATCH 2/6] NightingaleTrackCanvas: remove min feature height 1 --- .../nightingale-track-canvas/src/nightingale-track-canvas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts b/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts index f59c93d72..56f943e61 100644 --- a/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts +++ b/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts @@ -131,7 +131,7 @@ export default class NightingaleTrackCanvas extends NightingaleTrack { const scale = this.canvasScale; ctx.lineWidth = scale * 1; const baseWidth = scale * this.getSingleBaseWidth(); - const height = scale * Math.max(1, this.layoutObj?.getFeatureHeight() ?? 0); // Yes, sometimes `getFeatureHeight` returns negative numbers ¯\_(ツ)_/¯ + const height = scale * Math.max(0, this.layoutObj?.getFeatureHeight() ?? 0); // Yes, sometimes `getFeatureHeight` returns negative numbers ¯\_(ツ)_/¯ const optXPadding = Math.min(scale * 1.5, 0.25 * baseWidth); // To avoid overlap/touch for certain shapes (line, bridge, helix, strand) const leftEdgeSeq = this.getSeqPositionFromX(0 - SYMBOL_RADIUS) ?? -Infinity; const rightEdgeSeq = this.getSeqPositionFromX(canvasWidth / scale + SYMBOL_RADIUS) ?? Infinity; From e3f649ece599a68b324fce3c58d951d6faae53d7 Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Tue, 22 Oct 2024 16:17:59 +0100 Subject: [PATCH 3/6] NightingaleTrackCanvas: consider stroke width in pointing --- .../src/nightingale-track-canvas.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts b/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts index 56f943e61..399dbafb7 100644 --- a/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts +++ b/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts @@ -129,12 +129,12 @@ export default class NightingaleTrackCanvas extends NightingaleTrack { if (!this.fragmentCollection) return; const scale = this.canvasScale; - ctx.lineWidth = scale * 1; + ctx.lineWidth = scale * LINE_WIDTH; const baseWidth = scale * this.getSingleBaseWidth(); const height = scale * Math.max(0, this.layoutObj?.getFeatureHeight() ?? 0); // Yes, sometimes `getFeatureHeight` returns negative numbers ¯\_(ツ)_/¯ const optXPadding = Math.min(scale * 1.5, 0.25 * baseWidth); // To avoid overlap/touch for certain shapes (line, bridge, helix, strand) - const leftEdgeSeq = this.getSeqPositionFromX(0 - SYMBOL_RADIUS) ?? -Infinity; - const rightEdgeSeq = this.getSeqPositionFromX(canvasWidth / scale + SYMBOL_RADIUS) ?? Infinity; + const leftEdgeSeq = this.getSeqPositionFromX(0 - SYMBOL_RADIUS - 0.5 * LINE_WIDTH) ?? -Infinity; + const rightEdgeSeq = this.getSeqPositionFromX(canvasWidth / scale + SYMBOL_RADIUS + 0.5 * LINE_WIDTH) ?? Infinity; // This is better than this["display-start"], this["display-end"]+1, because it considers margins and symbol size // Draw features @@ -194,8 +194,9 @@ export default class NightingaleTrackCanvas extends NightingaleTrack { private getFragmentAt(svgX: number, svgY: number): ExtendedFragment | undefined { if (!this.fragmentCollection) return undefined; - const seqStart = this.getSeqPositionFromX(svgX - SYMBOL_RADIUS); - const seqEnd = this.getSeqPositionFromX(svgX + SYMBOL_RADIUS); + const halfLineWidth = 0.5 * LINE_WIDTH; + const seqStart = this.getSeqPositionFromX(svgX - SYMBOL_RADIUS - halfLineWidth); + const seqEnd = this.getSeqPositionFromX(svgX + SYMBOL_RADIUS + halfLineWidth); if (seqStart === undefined || seqEnd === undefined) return undefined; const fragments = this.fragmentCollection.overlappingItems(seqStart, seqEnd); @@ -205,16 +206,16 @@ export default class NightingaleTrackCanvas extends NightingaleTrack { const isPointed = (fragment: ExtendedFragment) => { const feature = this.data[fragment.featureIndex]; const y = this.layoutObj?.getFeatureYPos(feature) ?? 0; - const yOK = y <= svgY && svgY <= y + featureHeight; + const yOK = (y - halfLineWidth <= svgY) && (svgY <= y + featureHeight + halfLineWidth); if (!yOK) return false; const fragmentLength = (fragment.end ?? fragment.start) + 1 - fragment.start; const xStart = this.getXFromSeqPosition(fragment.start); const xEnd = xStart + fragmentLength * baseWidth; - if (xStart <= svgX && svgX <= xEnd) return true; // pointing at range (for symbol and range shapes) + if ((xStart - halfLineWidth <= svgX) && (svgX <= xEnd + halfLineWidth)) return true; // pointing at range (for symbol and range shapes) if (shapeCategory(this.getShape(feature)) !== "range") { // Symbol shapes const xMid = xStart + 0.5 * fragmentLength * baseWidth; - if (xMid - SYMBOL_RADIUS <= svgX && svgX <= xMid + SYMBOL_RADIUS) return true; // pointing at symbol (for symbol shapes only) + if ((xMid - SYMBOL_RADIUS - halfLineWidth <= svgX) && (svgX <= xMid + SYMBOL_RADIUS + halfLineWidth)) return true; // pointing at symbol (for symbol shapes only) } return false; }; @@ -312,3 +313,4 @@ function getFragmentCollection(data: Feature[]): RangeCollection Date: Tue, 22 Oct 2024 16:33:53 +0100 Subject: [PATCH 4/6] NightingaleTrackCanvas: Update package author --- packages/nightingale-track-canvas/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nightingale-track-canvas/package.json b/packages/nightingale-track-canvas/package.json index e82ac2d4e..a8a820bc3 100644 --- a/packages/nightingale-track-canvas/package.json +++ b/packages/nightingale-track-canvas/package.json @@ -27,7 +27,7 @@ "url": "https://github.com/ebi-webcomponents/nightingale/issues" }, "homepage": "https://ebi-webcomponents.github.io/nightingale/", - "author": "Gustavo Salazar ", + "author": "Adam Midlik ", "license": "MIT", "publishConfig": { "access": "public" From 7126d38ca67595a389bdb2d39f802c16408f91ab Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Fri, 29 Nov 2024 10:12:34 +0000 Subject: [PATCH 5/6] NightingaleTrackCanvas: Addressed PR feedback --- packages/nightingale-track-canvas/src/utils/utils.ts | 12 ++++++------ .../NightingaleTrackCanvas.stories.ts | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/nightingale-track-canvas/src/utils/utils.ts b/packages/nightingale-track-canvas/src/utils/utils.ts index e49101506..6873f3052 100644 --- a/packages/nightingale-track-canvas/src/utils/utils.ts +++ b/packages/nightingale-track-canvas/src/utils/utils.ts @@ -72,7 +72,7 @@ function firstGteqIndexInRange(sortedArray: ArrayLike, query: number, star // key(sortedArray[i]) < query for each i < start // key(sortedArray[i]) >= query for each i >= end while (end - start > 4) { - const mid = (start + end) >> 1; + const mid = (start + end) >> 1; // Floored mean of start and end if (key(sortedArray[mid]) >= query) { end = mid; } else { @@ -115,9 +115,9 @@ export class RangeCollection { this.bins = {}; for (let i = 0; i < items.length; i++) { const length = this.stops[i] - this.starts[i]; - let binSpans = 1; - while (binSpans < length) binSpans *= this.Q; - (this.bins[binSpans] ??= []).push(i); + let binSpan = 1; + while (binSpan < length) binSpan *= this.Q; + (this.bins[binSpan] ??= []).push(i); } this.binSpans = sortNumeric(Object.keys(this.bins).map(Number)); for (const binSpan of this.binSpans) { @@ -148,8 +148,8 @@ export class RangeCollection { private overlappingItemIndicesInBin(binSpan: number, start: number, stop: number, out: number[]): number[] { out.length = 0; const bin = this.bins[binSpan]; - const from = firstGteqIndex(bin, Math.floor(start) - binSpan + 1, i => this.starts[i]); - const to = firstGteqIndex(bin, Math.ceil(stop), i => this.starts[i]); + const from = firstGteqIndex(bin, start - binSpan, i => this.starts[i]); + const to = firstGteqIndex(bin, stop, i => this.starts[i]); for (let j = from; j < to; j++) { const i = bin[j]; if (this.stops[i] > start) { diff --git a/stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.ts b/stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.ts index 3c372d9e6..5953aba17 100644 --- a/stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.ts +++ b/stories/18.NightingaleTrackCanvas/NightingaleTrackCanvas.stories.ts @@ -186,13 +186,13 @@ function makeStory(options: { nTracks: number, showNightingaleTrack: boolean, sh ${options.showNightingaleTrackCanvas ? nightingaleTrackCanvas({ ...args, length: options.length, id: i }) : undefined} `); return html` - Use Ctrl+scroll to zoom.
+
${nightingaleNavigation({ ...args, length: options.length })} From adaa941cfd3b593cfdfe6a285599640678e87730 Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Fri, 29 Nov 2024 10:18:54 +0000 Subject: [PATCH 6/6] NightingaleTrackCanvas: Addressed PR feedback 2 --- .../nightingale-track-canvas/src/nightingale-track-canvas.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts b/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts index 399dbafb7..b70fa8f90 100644 --- a/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts +++ b/packages/nightingale-track-canvas/src/nightingale-track-canvas.ts @@ -7,7 +7,7 @@ import { last, RangeCollection, Refresher } from "./utils/utils"; type Fragment = FeatureLocation["fragments"][number] -type ExtendedFragment = Fragment & { featureIndex: number, location: FeatureLocation }; +type ExtendedFragment = Fragment & { featureIndex: number }; @customElementOnce("nightingale-track-canvas") @@ -297,7 +297,7 @@ function getAllFragments(data: Feature[]): ExtendedFragment[] { if (!feature.locations) continue; for (const location of feature.locations) { for (const fragment of location.fragments) { - out.push({ ...fragment, featureIndex: i, location }); + out.push({ ...fragment, featureIndex: i }); } } }