Skip to content

Commit

Permalink
Merge pull request #305 from midlik/nonoverlapping-perf
Browse files Browse the repository at this point in the history
NightingaleTrack: Improve `NonOverlappingLayout` performance
  • Loading branch information
dlrice authored Dec 20, 2024
2 parents 93879ed + 04ec485 commit ecf2b18
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 83 deletions.
33 changes: 22 additions & 11 deletions packages/nightingale-track/src/DefaultLayout.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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[];
Expand All @@ -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 = [];
}
Expand All @@ -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);
}
}
126 changes: 78 additions & 48 deletions packages/nightingale-track/src/NonOverlappingLayout.ts
Original file line number Diff line number Diff line change
@@ -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<Feature, number>();

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<Range>, feature2: Partial<Range>): boolean {
return !(
Number(feature2.start) > Number(feature1.end) ||
Number(feature2.end) < Number(feature1.start)
);
}

function overlapInRow(feature: Partial<Range>, row: Partial<Range>[]): 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<T>(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<Feature, number>();
const rows: Feature[][] = [];
const rowRanges: Record<number, Range> = {}; // 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 };
}
68 changes: 48 additions & 20 deletions packages/nightingale-track/src/nightingale-track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class NightingaleTrack extends withManager(
HTMLElement | SVGElement | null,
unknown
>;
#highlighted?: Selection<
protected highlighted?: Selection<
SVGGElement,
unknown,
HTMLElement | SVGElement | null,
Expand All @@ -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,
Expand All @@ -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;
});
}

Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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;
}
8 changes: 4 additions & 4 deletions packages/nightingale-track/tests/NonOverlappingLayout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,18 @@ 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", () => {
expect(layout.getFeatureYPos(features[2])).toEqual(2);
});

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);
});
});
Loading

0 comments on commit ecf2b18

Please sign in to comment.