diff --git a/packages/regulation/epigenome-activity/epigenome-activity.ts b/packages/regulation/epigenome-activity/epigenome-activity.ts index 3d84cad..7b07f34 100644 --- a/packages/regulation/epigenome-activity/epigenome-activity.ts +++ b/packages/regulation/epigenome-activity/epigenome-activity.ts @@ -2,7 +2,10 @@ import { html, css, svg, LitElement, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { scaleLinear, type ScaleLinear } from 'd3'; -import { prepareActivityDataForDisplay } from './prepare-data'; +import { + prepareActivityDataForDisplay, + type TrackDataForDisplay +} from './prepare-data'; import { toZeroBased } from '../helpers/toZeroBased'; import { renderOpenChromatinSignals, @@ -53,6 +56,9 @@ export class EpigenomeActivity extends LitElement { @state() bedScale: ScaleLinear | null = null; + @state() + preparedTracksData: TrackDataForDisplay[] = []; + trackIds: string[][] = []; connectedCallback(): void { @@ -61,13 +67,22 @@ export class EpigenomeActivity extends LitElement { } willUpdate(changedProperties: PropertyValues) { - if ( + const shouldUpdateScale = changedProperties.has('start') || changedProperties.has('end') || - changedProperties.has('imageWidth') - ) { + changedProperties.has('imageWidth'); + + if (shouldUpdateScale) { this.#updateScale(); } + + if ( + shouldUpdateScale || + changedProperties.has('tracks') || + changedProperties.has('trackMetadata') + ) { + this.#updatePreparedTracksData(); + } } updated() { @@ -78,7 +93,7 @@ export class EpigenomeActivity extends LitElement { const resizeObserver = new ResizeObserver((entries) => { const [hostElementEntry] = entries; const { width: hostWidth } = hostElementEntry.contentRect; - this.imageWidth = hostWidth; + this.imageWidth = Math.round(hostWidth); }); resizeObserver.observe(this); @@ -88,12 +103,27 @@ export class EpigenomeActivity extends LitElement { this.bedScale = scaleLinear().domain([ toZeroBased(this.start), this.end - ]).rangeRound([ + ]).range([ 0, this.imageWidth ]); } + #updatePreparedTracksData() { + const bedScale = this.bedScale; + if (!bedScale || !this.trackMetadata || !this.tracks.length) { + this.preparedTracksData = []; + return; + } + + this.preparedTracksData = prepareActivityDataForDisplay({ + location: { start: this.start, end: this.end }, + scale: bedScale, + trackMetadata: this.trackMetadata, + tracks: this.tracks + }); + } + #reportTrackPositions() { // Check if the list of track ids has changed since previous render, // and report to outside if it did @@ -117,24 +147,18 @@ export class EpigenomeActivity extends LitElement { } render() { - if(!this.bedScale || !this.trackMetadata || !this.tracks.length) { + if (!this.bedScale || !this.preparedTracksData.length) { return null; } - const preparedTracksData = prepareActivityDataForDisplay({ - location: { start: this.start, end: this.end }, - scale: this.bedScale, - trackMetadata: this.trackMetadata, - tracks: this.tracks - }); - const imageHeight = TRACK_HEIGHT * preparedTracksData.length; + const imageHeight = TRACK_HEIGHT * this.preparedTracksData.length; return html` - ${this.#renderTracks({ tracks: preparedTracksData })} + ${this.#renderTracks({ tracks: this.preparedTracksData })} ${this.#renderVerticalRules({ imageHeight })} ` @@ -143,30 +167,34 @@ export class EpigenomeActivity extends LitElement { #renderTracks({ tracks }: { - tracks: ReturnType + tracks: TrackDataForDisplay[] }) { - return tracks.map((track, index) => { - return [ - renderOpenChromatinSignals({ + const renderedTracks: ReturnType[] = []; + + for (const [index, track] of tracks.entries()) { + renderedTracks.push( + ...renderOpenChromatinSignals({ trackData: track, offsetTop: index * TRACK_HEIGHT, colors: this.colors }), - renderOpenChromatinPeaks({ + ...renderOpenChromatinPeaks({ trackData: track, offsetTop: index * TRACK_HEIGHT, colors: this.colors }), - renderHistoneNarrowPeaks({ + ...renderHistoneNarrowPeaks({ trackData: track, offsetTop: index * TRACK_HEIGHT }), - renderHistoneGappedPeaks({ + ...renderHistoneGappedPeaks({ trackData: track, offsetTop: index * TRACK_HEIGHT }) - ]; - }); + ); + } + + return renderedTracks; } #renderVerticalRules({ @@ -211,4 +239,4 @@ declare global { interface HTMLElementTagNameMap { 'ens-reg-epigenome-activity': EpigenomeActivity; } -} \ No newline at end of file +} diff --git a/packages/regulation/epigenome-activity/prepare-data.ts b/packages/regulation/epigenome-activity/prepare-data.ts index 56858bb..7d26c17 100644 --- a/packages/regulation/epigenome-activity/prepare-data.ts +++ b/packages/regulation/epigenome-activity/prepare-data.ts @@ -126,24 +126,11 @@ const prepareOpenChromatinSignalsForTrack = ({ const signalX = scale(toZeroBased(signalStart)); const signalWidth = scale(signalEnd) - signalX; - const previousPreparedSignal = result.at(-1); - - const distanceFromPreviousSignal = previousPreparedSignal - ? signalX - previousPreparedSignal.x - : 1; - - if (distanceFromPreviousSignal >= 1) { - const signal = { - x: signalX, - width: Math.max(signalWidth, 1), - value: signalValue - }; - result.push(signal); - } else { - // combine current signal with the previous signal - const newValue = Math.round((signalValue + previousPreparedSignal!.value) / 2); - previousPreparedSignal!.value = newValue; - } + result.push({ + x: signalX, + width: signalWidth, + value: signalValue + }); } return result; diff --git a/packages/regulation/epigenome-activity/render-track.ts b/packages/regulation/epigenome-activity/render-track.ts index e0d8dd1..10d7772 100644 --- a/packages/regulation/epigenome-activity/render-track.ts +++ b/packages/regulation/epigenome-activity/render-track.ts @@ -17,6 +17,8 @@ import { import type { TrackDataForDisplay } from './prepare-data'; +type PathResult = ReturnType; + export const renderOpenChromatinSignals = ({ trackData, offsetTop, @@ -25,7 +27,7 @@ export const renderOpenChromatinSignals = ({ trackData: TrackDataForDisplay; offsetTop: number; colors: Partial | null; -}) => { +}): PathResult[] => { const colorFrom = colors?.openChromatinLow ?? COLORS.openChromatinLow; const colorTo = colors?.openChromatinHigh ?? COLORS.openChromatinHigh; const colorScale = createSignalColorScale({ @@ -33,17 +35,30 @@ export const renderOpenChromatinSignals = ({ colorTo }); - return trackData.openChromatin.signals.map((signal) => { - return svg` - - `; - }); + const pathsByValue = new Map(); + for (const signal of trackData.openChromatin.signals) { + const currentPaths = pathsByValue.get(signal.value) ?? []; + currentPaths.push( + rectToPath({ + x: signal.x, + y: offsetTop + OPEN_CHROMATIN_SIGNAL_OFFSET_TOP, + width: signal.width, + height: OPEN_CHROMATIN_SIGNAL_HEIGHT + }) + ); + pathsByValue.set(signal.value, currentPaths); + } + + return [...pathsByValue.entries()].map(([value, paths]) => { + return svg` + + `; + }); }; export const renderOpenChromatinPeaks = ({ @@ -54,22 +69,31 @@ export const renderOpenChromatinPeaks = ({ trackData: TrackDataForDisplay; offsetTop: number; colors: Partial | null; -}) => { +}): PathResult[] => { const strokeColor = colors?.openChromatinPeak ?? COLORS.openChromatinPeak; + const paths = trackData.openChromatin.peaks.map(peak => + rectToPath({ + x: peak.x, + y: offsetTop + OPEN_CHROMATIN_PEAK_OFFSET_TOP, + width: peak.width, + height: OPEN_CHROMATIN_PEAK_HEIGHT + }) + ); - return trackData.openChromatin.peaks.map(peak => { - return svg` - - `; - }); + ` + ]; }; export const renderHistoneNarrowPeaks = ({ @@ -78,26 +102,34 @@ export const renderHistoneNarrowPeaks = ({ }: { trackData: TrackDataForDisplay; offsetTop: number; -}) => { - return trackData.histones.narrowPeaks.map(peak => { - const order = peak.order; - const trackOffsetTop = offsetTop; +}): PathResult[] => { + const pathsByColor = new Map(); + + for (const peak of trackData.histones.narrowPeaks) { const peakOffsetTop = - trackOffsetTop + + offsetTop + OPEN_CHROMATIN_PEAK_HEIGHT + HISTONE_NARROW_PEAK_OFFSET_TOP + - order * (HISTONE_NARROW_PEAK_HEIGHT + HISTONE_NARROW_PEAK_OFFSET_TOP); + peak.order * (HISTONE_NARROW_PEAK_HEIGHT + HISTONE_NARROW_PEAK_OFFSET_TOP); + const currentPaths = pathsByColor.get(peak.color) ?? []; + currentPaths.push( + rectToPath({ + x: peak.x, + y: peakOffsetTop, + width: peak.width, + height: HISTONE_NARROW_PEAK_HEIGHT + }) + ); + pathsByColor.set(peak.color, currentPaths); + } + + return [...pathsByColor.entries()].map(([color, paths]) => { return svg` - `; }); @@ -109,13 +141,16 @@ export const renderHistoneGappedPeaks = ({ }: { trackData: TrackDataForDisplay; offsetTop: number; -}) => { +}): PathResult[] => { // calculate the additional distance from the top based on how many narrow peaks have been rendered const narrowPeakTracksCount = trackData.histones.narrowPeaks.reduce((acc, peak) => { return Math.max(acc, peak.order); }, 0); - return trackData.histones.gappedPeaks.map(peak => { + const blocksByColor = new Map(); + const connectorsByColor = new Map(); + + for (const peak of trackData.histones.gappedPeaks) { const offsetTop = trackOffsetTop + OPEN_CHROMATIN_PEAK_HEIGHT + @@ -123,38 +158,84 @@ export const renderHistoneGappedPeaks = ({ narrowPeakTracksCount * (HISTONE_NARROW_PEAK_HEIGHT + HISTONE_NARROW_PEAK_OFFSET_TOP) + HISTONE_GAPPED_PEAK_OFFSET_TOP + peak.order * (HISTONE_GAPPED_PEAK_BLOCK_HEIGHT + HISTONE_GAPPED_PEAK_OFFSET_TOP); - + const connectorOffsetTop = offsetTop + HISTONE_GAPPED_PEAK_BLOCK_HEIGHT / 2; - const blocks = peak.blocks.map((block) => { + const blockPaths = blocksByColor.get(peak.color) ?? []; + for (const block of peak.blocks) { + blockPaths.push( + rectToPath({ + x: block.x, + y: offsetTop, + width: block.width, + height: HISTONE_GAPPED_PEAK_BLOCK_HEIGHT + }) + ); + } + blocksByColor.set(peak.color, blockPaths); + + const connectorPaths = connectorsByColor.get(peak.color) ?? []; + for (const connector of peak.connectors) { + connectorPaths.push( + lineToPath({ + x1: connector.x, + x2: connector.x + connector.width, + y: connectorOffsetTop + }) + ); + } + connectorsByColor.set(peak.color, connectorPaths); + } + + return [ + ...[...blocksByColor.entries()].map(([color, paths]) => { return svg` - + /> `; - }); - - const connectors = peak.connectors.map((connector) => { - return svg ` - + }), + ...[...connectorsByColor.entries()].map(([color, paths]) => { + return svg` + `; - }); + }) + ]; +}; - return [blocks, connectors]; - }); +const rectToPath = ({ + x, + y, + width, + height +}: { + x: number; + y: number; + width: number; + height: number; +}) => { + return `M ${x} ${y} h ${width} v ${height} h ${-width} Z`; +}; + +const lineToPath = ({ + x1, + x2, + y +}: { + x1: number; + x2: number; + y: number; +}) => { + return `M ${x1} ${y} H ${x2}`; }; const createSignalColorScale = ({ @@ -168,4 +249,4 @@ const createSignalColorScale = ({ .domain([1, 9]) .range([colorFrom, colorTo]) .interpolate(interpolateHcl); -}; \ No newline at end of file +};