diff --git a/packages/nightingale-track/src/DefaultLayout.ts b/packages/nightingale-track/src/DefaultLayout.ts index af47250b4..0136504ae 100644 --- a/packages/nightingale-track/src/DefaultLayout.ts +++ b/packages/nightingale-track/src/DefaultLayout.ts @@ -1,10 +1,22 @@ +import { clamp } from "lodash-es"; import { Feature } from "./nightingale-track"; + export type LayoutOptions = { + /** Height of the whole track */ layoutHeight: number; + /** Empty space at the top/bottom/left/right of the track */ margin?: Margin; + /** Minimum feature height (excluding gap) */ minHeight?: number; + /** Maximum feature height (excluding gap) */ + maxHeight?: number; + /** Gap between two rows in non-overlapping layout. + * Rectangle stroke protrudes into this gap, so the default value 2 means 1px of white space between rows. + * When there are to many rows, gap will be reduced. */ + gap?: number; }; + type Margin = { top: number; bottom: number; @@ -15,6 +27,8 @@ type Margin = { export default class DefaultLayout { protected margin: Margin; protected minHeight: number; + protected maxHeight: number; + protected gap: number; protected layoutHeight: number; features: Feature[]; @@ -27,10 +41,14 @@ export default class DefaultLayout { left: 0, right: 0, }, - minHeight = 17, + minHeight = 0.1, // Using a non-zero value to keep SVG shapes targetable with cursor + maxHeight = Infinity, + gap = 2, }: LayoutOptions) { this.margin = margin; this.minHeight = minHeight; + this.maxHeight = maxHeight; + this.gap = gap; this.layoutHeight = layoutHeight; this.features = []; } @@ -42,18 +60,11 @@ export default class DefaultLayout { // eslint-disable-next-line @typescript-eslint/no-unused-vars getFeatureYPos(feature?: Feature | string) { // Position right in the middle of the space between the margins - const featureHeight = this.getFeatureHeight(); - return ( - this.margin.top + - (this.layoutHeight - this.margin.top - this.margin.bottom) / 2 - - featureHeight / 2 - ); + const center = 0.5 * (this.margin.top + this.layoutHeight - this.margin.bottom); + return center - 0.5 * this.getFeatureHeight(); } getFeatureHeight(..._args: unknown[]) { - return Math.max( - this.minHeight, - this.layoutHeight - this.margin.top - this.margin.bottom, - ); + return clamp(this.layoutHeight - this.margin.top - this.margin.bottom - this.gap, this.minHeight, this.maxHeight); } } diff --git a/packages/nightingale-track/src/NonOverlappingLayout.ts b/packages/nightingale-track/src/NonOverlappingLayout.ts index 5067f0442..eb1892d0d 100644 --- a/packages/nightingale-track/src/NonOverlappingLayout.ts +++ b/packages/nightingale-track/src/NonOverlappingLayout.ts @@ -1,66 +1,96 @@ +import { clamp } from "lodash-es"; import DefaultLayout, { LayoutOptions } from "./DefaultLayout"; import { Feature } from "./nightingale-track"; -const featuresOverlap = (feature1: Feature, feature2: Feature) => - !( - Number(feature2.start) > Number(feature1.end) || - Number(feature2.end) < Number(feature1.start) - ); - -const featureOvelapsInRow = (feature: Feature, row: Feature[]) => - row.some((rowFeature) => featuresOverlap(feature, rowFeature)); export default class NonOverlappingLayout extends DefaultLayout { - protected rowHeight: number; - featuresMap = new Map(); - #rows: Feature[][]; + /** Height of a row, including gap between rows */ + protected rowHeight: number = 0; + /** Height of a feature, excluding gap between rows */ + protected featureHeight: number = 0; + /** Y coordinate of the top of the first row */ + protected topOffset: number = 0; - constructor(options: LayoutOptions) { - super(options); - this.rowHeight = 0; - this.minHeight = 15; - this.#rows = []; - } + /** Mapping of feature to row index */ + featuresMap = new Map(); - init(features: Feature[]) { - features.forEach((feature) => { - const rowIndex = this.#rows.findIndex( - (row) => !featureOvelapsInRow(feature, row), - ); - if (rowIndex >= 0) { - this.#rows[rowIndex].push(feature); - this.featuresMap.set(feature, rowIndex); - } else { - this.#rows.push([feature]); - this.featuresMap.set(feature, this.#rows.length - 1); - } + constructor(options: LayoutOptions) { + super({ + ...options, + maxHeight: options.maxHeight ?? 15, }); - this.rowHeight = Math.min( - this.layoutHeight / this.#rows.length, - this.minHeight, - ); } - getOffset() { - // this offset is required for centering if the number of rows doesn't - // fill the layout - if (this.#rows.length * this.rowHeight < this.layoutHeight) { - return this.layoutHeight / 2 - (this.#rows.length * this.rowHeight) / 2; - } - return 0; + init(features: Feature[]) { + const { featuresMap, rows } = placeFeaturesIntoRows(features); + this.featuresMap = featuresMap; + const usableHeight = clamp(this.layoutHeight - this.margin.top - this.margin.bottom, 0, rows.length * (this.maxHeight + this.gap)); + this.rowHeight = usableHeight / Math.max(rows.length, 1); + this.featureHeight = clamp(this.rowHeight - this.gap, this.minHeight, this.maxHeight); + const center = 0.5 * (this.margin.top + this.layoutHeight - this.margin.bottom); + this.topOffset = center - 0.5 * usableHeight; } getFeatureYPos(feature: Feature) { - const rowNumber = this.featuresMap.get(feature); - return ( - this.getOffset() + - this.rowHeight * rowNumber + - this.margin.top + - this.margin.bottom - ); + const rowIndex = this.featuresMap.get(feature) ?? 0; + const center = this.topOffset + (rowIndex + 0.5) * this.rowHeight; + return center - 0.5 * this.featureHeight; } getFeatureHeight() { - return this.rowHeight - this.margin.top - this.margin.bottom; + return this.featureHeight; + } +} + + +type Range = { start: number, end: number }; + +function overlap(feature1: Partial, feature2: Partial): boolean { + return !( + Number(feature2.start) > Number(feature1.end) || + Number(feature2.end) < Number(feature1.start) + ); +} + +function overlapInRow(feature: Partial, row: Partial[]): boolean { + // Search in reverse order for better performance + return reverseSome(row, rowFeature => overlap(feature, rowFeature)); +} + +/** Equivalent to `array.some(predicate)` but tests elements in reverse order. */ +function reverseSome(array: T[], predicate: (value: T, index: number, array: T[]) => boolean): boolean { + for (let i = array.length - 1; i >= 0; i--) { + if (predicate(array[i], i, array)) return true; + } + return false; +} + +function placeFeaturesIntoRows(features: Feature[]) { + const featuresMap = new Map(); + const rows: Feature[][] = []; + const rowRanges: Record = {}; // For better performance, would also work without this + for (const feature of features) { + const rowIndex = rows.findIndex( + (row, i) => !overlap(feature, rowRanges[i]) || !overlapInRow(feature, row), + ); + if (rowIndex >= 0) { + // Add to existing row + rows[rowIndex].push(feature); + featuresMap.set(feature, rowIndex); + const rowRange = rowRanges[rowIndex]; + rowRanges[rowIndex] = { + start: Math.min(rowRange.start, feature.start ?? -Infinity), + end: Math.max(rowRange.end, feature.end ?? Infinity), + }; + } else { + // Create new row + rows.push([feature]); + featuresMap.set(feature, rows.length - 1); + rowRanges[rows.length - 1] = { + start: feature.start ?? -Infinity, + end: feature.end ?? Infinity, + }; + } } + return { rows, featuresMap }; } diff --git a/packages/nightingale-track/src/nightingale-track.ts b/packages/nightingale-track/src/nightingale-track.ts index a830f4f53..27afed7a8 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, @@ -120,6 +120,12 @@ class NightingaleTrack extends withManager( if (String(this.layout).toLowerCase() === "non-overlapping") return new NonOverlappingLayout({ layoutHeight: this.height, + margin: { + top: this["margin-top"], + bottom: this["margin-bottom"], + left: this["margin-left"], + right: this["margin-right"], + }, }); return new DefaultLayout({ layoutHeight: this.height, @@ -146,22 +152,18 @@ class NightingaleTrack extends withManager( } static normalizeLocations(data: Feature[]) { - return data.map((obj) => { - const { locations, start, end } = obj; - return locations - ? obj - : Object.assign(obj, { - locations: [ - { - fragments: [ - { - start, - end, - }, - ], - }, - ], - }); + return data.map(feature => { + if (feature.locations) { + // Add missing `start`/`end` based on `locations` + feature.start ??= getStartFromLocations(feature.locations); + feature.end ??= getEndFromLocations(feature.locations); + } else { + // Add missing `locations` based on `start`/`end` + feature.start ??= 1; + feature.end ??= feature.start; + feature.locations = [{ fragments: [{ start: feature.start, end: feature.end }] }]; + } + return feature; }); } @@ -284,7 +286,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 +534,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, { @@ -575,3 +577,29 @@ export default NightingaleTrack; export { DefaultLayout }; export { getColorByType }; + + +/** Return leftmost start of fragment */ +function getStartFromLocations(locations: FeatureLocation[]): number | undefined { + let start: number | undefined = undefined; + for (const location of locations) { + for (const fragment of location.fragments) { + if (start === undefined || fragment.start < start) { + start = fragment.start; + } + } + } + return start; +} +/** Return rightmost end of fragment */ +function getEndFromLocations(locations: FeatureLocation[]): number | undefined { + let end: number | undefined = undefined; + for (const location of locations) { + for (const fragment of location.fragments) { + if (end === undefined || fragment.end > end) { + end = fragment.end; + } + } + } + return end; +} diff --git a/packages/nightingale-track/tests/NonOverlappingLayout.test.js b/packages/nightingale-track/tests/NonOverlappingLayout.test.js index ec15599c4..c4d7f92a5 100644 --- a/packages/nightingale-track/tests/NonOverlappingLayout.test.js +++ b/packages/nightingale-track/tests/NonOverlappingLayout.test.js @@ -38,7 +38,7 @@ describe("Layout bumping", () => { }); test("should be on second row", () => { - expect(layout.getFeatureYPos(features[1])).toEqual(8); + expect(layout.getFeatureYPos(features[1])).toBeCloseTo(7.3333, 3); }); test("should be back on first row", () => { @@ -46,10 +46,10 @@ describe("Layout bumping", () => { }); test("should be back on third row", () => { - expect(layout.getFeatureYPos(features[3])).toEqual(14); + expect(layout.getFeatureYPos(features[3])).toBeCloseTo(12.6666, 3); }); - test("should be on second row", () => { - expect(layout.getFeatureYPos(features[4])).toEqual(8); + test("should be on second row again", () => { + expect(layout.getFeatureYPos(features[4])).toBeCloseTo(7.3333, 3); }); }); diff --git a/stories/05.Track/NightingaleTrack.stories.ts b/stories/05.Track/NightingaleTrack.stories.ts index e67a08a58..5a6307c4b 100644 --- a/stories/05.Track/NightingaleTrack.stories.ts +++ b/stories/05.Track/NightingaleTrack.stories.ts @@ -1,5 +1,6 @@ import { Meta, Story } from "@storybook/web-components"; import { html } from "lit-html"; +import { range } from "lodash-es"; import "../../packages/nightingale-track/src/index.ts"; import mockData from "../../packages/nightingale-track/tests/mockData/data.json"; @@ -121,3 +122,82 @@ TrackNoControls.play = async () => { const track = document.getElementById("track"); if (track) (track as any).data = defaultData; }; + + +const LayoutsTemplate: Story<{ + "width": number; + "height": number; + "margin-top": number; + "margin-bottom": number; + "margin-left": number; + "margin-right": number; + "margin-color": string; + "length": number; + "display-start": number; + "display-end": number; +}> = (args) => { + function makeTrack(id: string, layout: "non-overlapping" | "default") { + return html`
+ +
`; + } + return html` +

DefaultLayout

+ ${makeTrack("track-default-1", "default")} + ${makeTrack("track-default-2", "default")} + ${makeTrack("track-default-3", "default")} +

NonOverlappingLayout

+ ${makeTrack("track-non-overlapping-1", "non-overlapping")} + ${makeTrack("track-non-overlapping-2", "non-overlapping")} + ${makeTrack("track-non-overlapping-3", "non-overlapping")} + `; +}; + +export const Layouts = LayoutsTemplate.bind({}); +Layouts.args = { + "width": 500, + "height": 50, + "margin-top": 0, + "margin-bottom": 0, + "margin-left": 10, + "margin-right": 10, + "margin-color": "transparent", + "length": 50, + "display-start": 1, + "display-end": 50, +}; +Layouts.play = async () => { + await customElements.whenDefined("nightingale-track"); + const data1 = defaultData.map(d => ({ ...d, fill: d.color ?? "gray", color: "black" })); + const data2 = [{ accession: `feature0`, start: 1, end: 50, fill: "#7570b3", color: "#524E7D" }]; + const data3 = range(10).map((_, i) => ({ + accession: `feature${i}`, + start: 1 + (i % 10), + end: 41 + (i % 10), + fill: i % 2 ? "#1B9E77" : "#d95f02", + color: i % 2 ? "#136F53" : "#984301", + })); + function setData(trackId: string, data: any) { + const track = document.getElementById(trackId); + if (track) (track as any).data = data; + } + setData("track-default-1", data1); + setData("track-default-2", data2); + setData("track-default-3", data3); + setData("track-non-overlapping-1", data1); + setData("track-non-overlapping-2", data2); + setData("track-non-overlapping-3", data3); +};