diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43f2b79f9..ecd735a4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,6 +138,8 @@ If there is an example you would like to add to the editor example library, plea ## Bumping Gosling.js +The version should follow the [semver](https://semver.org/) convention. This includes release candidates (e.g., alpha). + GitHub Action handles bumping the version of Gosling.js. The pattern looks like the following: ``` @@ -145,6 +147,26 @@ pnpm version patch # or minor or major git push origin main --tags ``` +### Alpha or Beta Versions + +After updating the `version` in `package.json`: + +``` +# Add a pre-release tag to the commit +git tag v2.0.0-alpha.1 [commit hash] + +# Push changes with the tag +git push origin tag v2.0.0-alpha.1 +``` + +You need to properly tag the `latest` and next versions in NPM. You can do this by: + +``` +npm dist-tag add gosling.js@2.0.0-alpha.1 alpha +``` + +This will add the `alpha` tag to the `2.0.0-alpha.1` version of Gosling.js. + # Internal Explanations ## How does a Gosling spec get turned into a HiGlass spec? A Gosling schema goes through the following steps: @@ -170,4 +192,4 @@ tracks: [ { ..., id: '4' }, // ← This track is included in a HiGlass view '2' ]} ] -``` \ No newline at end of file +``` diff --git a/demo/gosling-component.tsx b/demo/gosling-component.tsx index cb4d31eb5..0ee2cd00f 100644 --- a/demo/gosling-component.tsx +++ b/demo/gosling-component.tsx @@ -25,7 +25,16 @@ interface GoslingComponentProps { } export function GoslingComponent(props: GoslingComponentProps) { - const { spec, id, className, padding, urlToFetchOptions, theme = 'light', ref, visualized = () => { } } = props; + const { + spec, + id = 'gosling-component', + className = 'gosling-component', + padding, + urlToFetchOptions, + theme = 'light', + ref, + visualized = () => { } + } = props; const [compiledResults, setCompiledResults] = useState>(); @@ -44,29 +53,38 @@ export function GoslingComponent(props: GoslingComponentProps) { useEffect(() => { if (!spec) return; - const plotElement = document.getElementById('plot') as HTMLDivElement; + const plotElement = document.getElementById(id) as HTMLDivElement; // If the pixiManager doesn't exist, create a new one if (!pixiManager) { - const canvasWidth = 1000, - canvasHeight = 1000; // These initial sizes don't matter because the size will be updated + // These initial sizes don't matter because the size will be updated + const canvasWidth = 1000; + const canvasHeight = 1000; const pixiManager = new PixiManager(canvasWidth, canvasHeight, plotElement, () => { }, { padding }); - const compileResult = renderGosling(spec, plotElement, pixiManager, theme, urlToFetchOptions); + const compileResult = renderGosling( + spec, + plotElement, + pixiManager, + theme, + compiledResults?.plots, + urlToFetchOptions + ); setCompiledResults(compileResult); setPixiManager(pixiManager); } else { - pixiManager.clearAll(); - const compileResult = renderGosling(spec, plotElement, pixiManager, theme, urlToFetchOptions); + // pixiManager.clearAll(); + const compileResult = renderGosling( + spec, + plotElement, + pixiManager, + theme, + compiledResults?.plots, + urlToFetchOptions + ); setCompiledResults(compileResult); } }, [spec]); - return ( -
- ); + return
; } /** * This is the main function. It takes a Gosling spec and renders it using the PixiManager @@ -76,6 +94,7 @@ export function renderGosling( container: HTMLDivElement, pixiManager: PixiManager, theme: Theme, + prevPlots: Record = {}, urlToFetchOptions?: UrlToFetchOptions ) { const themeDeep = getTheme(theme); @@ -108,7 +127,7 @@ export function renderGosling( ); // 4. Render the tracks const trackDefs = createTrackDefs(rescaledTracks, themeDeep); - plots = renderTrackDefs(trackDefs, linkedEncodings, pixiManager, urlToFetchOptions); + plots = renderTrackDefs(trackDefs, linkedEncodings, pixiManager, prevPlots, urlToFetchOptions); // Resize the canvas to make sure it fits the tracks const { width, height } = calculateWidthHeight(rescaledTracks); pixiManager.resize(width, height); @@ -119,7 +138,8 @@ export function renderGosling( // 4. If the spec is not responsive, we can just render the tracks const trackDefs = createTrackDefs(trackInfos, themeDeep); - plots = renderTrackDefs(trackDefs, linkedEncodings, pixiManager, urlToFetchOptions); + plots = renderTrackDefs(trackDefs, linkedEncodings, pixiManager, prevPlots, urlToFetchOptions); + // Resize the canvas to make sure it fits the tracks const { width, height } = calculateWidthHeight(trackInfos); pixiManager.resize(width, height); diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 17baf851d..df07aa79b 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -4,7 +4,16 @@ import { BrushLinearTrack, type BrushLinearTrackOptions } from '@gosling-lang/br import { signal, Signal } from '@preact/signals-core'; import { TextTrack, type TextTrackOptions } from '@gosling-lang/text-track'; -import { cursor, cursor2D, panZoom, panZoomHeatmap } from '@gosling-lang/interactors'; +import { + cursor, + cursor2D, + panZoom, + panZoomHeatmap, + updatePanZoom, + updatePanZoomHeatmap +} from '@gosling-lang/interactors'; +import { cursorCircular, updateCursorCircular } from '../../src/interactors/cursor-circular'; +import { panZoomCircular } from '../../src/interactors/pan-zoom-circular'; import { type TrackDefs, TrackType } from '../track-def/main'; import { getDataFetcher } from './dataFetcher'; import type { LinkedEncoding } from '../linking/linkedEncoding'; @@ -26,118 +35,259 @@ export function renderTrackDefs( trackDefs: TrackDefs[], linkedEncodings: LinkedEncoding[], pixiManager: PixiManager, + cachedPlots: Record, urlToFetchOptions?: UrlToFetchOptions ) { const plotDict: Record = {}; - const cursorPosX = signal(0); - const cursorPosY = signal(0); + const cursorPosX = signal(Number.NEGATIVE_INFINITY); + const cursorPosY = signal(Number.NEGATIVE_INFINITY); - trackDefs.forEach(trackDef => { - const { boundingBox, type, options, trackId } = trackDef; - - if (type === TrackType.Text) { - const textOptions = options as TextTrackOptions; - const plot = new TextTrack(textOptions, pixiManager.makeContainer(boundingBox)); - plotDict[trackId] = plot; + // Remove existing plots except for the ones that need to be reused + Object.keys(cachedPlots).forEach(cacheId => { + if (!trackDefs.find(def => def.cacheId === cacheId)) { + pixiManager.clear(cacheId); + delete cachedPlots[cacheId]; } - if (type === TrackType.Gosling) { - const gosOptions = options as GoslingTrackOptions; - const { spec } = gosOptions; - const xDomain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); - const yDomain = getEncodingSignal(trackDef.trackId, 'y', linkedEncodings); - if (!xDomain) return; - - const datafetcher = getDataFetcher(spec, urlToFetchOptions); - if (!datafetcher) return; - const gosPlot = new GoslingTrack( - gosOptions, - datafetcher as DataFetcher, - pixiManager.makeContainer(boundingBox), - xDomain, - yDomain, - gosOptions.spec.orientation - ); - const isOverlayedOnPrevious = 'overlayOnPreviousTrack' in spec && spec.overlayOnPreviousTrack; - if (!spec.static && !isOverlayedOnPrevious) { - gosPlot.addInteractor(plot => panZoom(plot, xDomain, yDomain)); + }); + + // Reuse cached plots first + trackDefs + .filter(d => cachedPlots[d.cacheId]) + .forEach(trackDef => { + const { boundingBox, type, options, trackId, cacheId } = trackDef; + + // It is certain that cached plot exists + const cachedPlot = cachedPlots[cacheId]!; + + if (type === TrackType.Text) { + const txtPlot = cachedPlot as TextTrack; + pixiManager.updateContainer(boundingBox, cacheId); + txtPlot.setDimensions([boundingBox.width, boundingBox.height]); + txtPlot.rerender(options as TextTrackOptions, true); + plotDict[cacheId] = cachedPlot as TextTrack; + } else if (type === TrackType.Gosling) { + const gosOptions = options as GoslingTrackOptions; + const { spec } = gosOptions; + + const xDomain = getEncodingSignal(trackId, 'x', linkedEncodings); + if (!xDomain) return; + + const datafetcher = getDataFetcher(spec, urlToFetchOptions); + if (!datafetcher) return; + + const gosPlot = cachedPlots[cacheId] as GoslingTrack; + pixiManager.updateContainer(boundingBox, cacheId); + gosPlot.onOptionsChange(gosOptions, boundingBox); + const isOverlayedOnPrevious = 'overlayOnPreviousTrack' in spec && spec.overlayOnPreviousTrack; + if (!spec.static && !isOverlayedOnPrevious) { + // Update the zoom behavior to use the new dimensions + if (spec.layout === 'circular') { + // + } else { + updatePanZoom(gosPlot); + } + } + if (spec.layout === 'circular') { + updateCursorCircular(gosPlot, cursorPosX); + } + plotDict[cacheId] = gosPlot; + } else if (type === TrackType.Heatmap) { + // TODO: This is not fully implemented yet + const hmOptions = options as HeatmapTrackOptions; + const xDomain = getEncodingSignal(trackId, 'x', linkedEncodings); + const yDomain = getEncodingSignal(trackId, 'y', linkedEncodings); + if (!xDomain || !yDomain) return; + + // TODO: the new signal needs to be passed to the existing plot + const heatmapPlot = cachedPlots[cacheId] as HeatmapTrack; + pixiManager.updateContainer(boundingBox, cacheId); + heatmapPlot.setDimensions([boundingBox.width, boundingBox.height]); + heatmapPlot.rerender(hmOptions); + // Update the zoom behavior to use the new dimensions + updatePanZoomHeatmap(heatmapPlot); + plotDict[cacheId] = heatmapPlot; + } else if (type === TrackType.Axis) { + const axisOptions = options as AxisTrackOptions; + const domain = getEncodingSignal(trackId, axisOptions.encoding, linkedEncodings); + if (!domain) { + console.warn(`No domain found for axis ${trackId}. Skipping...`); + return; + } + const axis = cachedPlot as AxisTrack; + pixiManager.updateContainer(boundingBox, cacheId); + axis.options = axisOptions; + axis.setDimensions([boundingBox.width, boundingBox.height]); + axis.rerender(options as TextTrackOptions, true); + if (!axisOptions.static) { + // Update the zoom behavior to use the new dimensions + updatePanZoom(axis); + } + plotDict[cacheId] = cachedPlot; + } else if (type === TrackType.BrushLinear) { + const brushOptions = options as BrushLinearTrackOptions; + const domain = getEncodingSignal(trackId, 'x', linkedEncodings); + const brushDomain = getEncodingSignal(trackId, 'brush', linkedEncodings); + if (!domain || !brushDomain || !hasLinkedTracks(trackId, linkedEncodings)) return; + // We only want to add the brush track if it is linked to another track + const brush = new BrushLinearTrack( + brushOptions, + brushDomain, + pixiManager.makeContainer(boundingBox, cacheId).overlayDiv, + domain + ); + if (!brushOptions.static) brush.addInteractor(plot => panZoom(plot, domain)); + plotDict[cacheId] = brush; + } else if (type === TrackType.BrushCircular) { + const brushOptions = options as BrushCircularTrackOptions; + const domain = getEncodingSignal(trackId, 'x', linkedEncodings); + const brushDomain = getEncodingSignal(trackId, 'brush', linkedEncodings); + if (!domain || !brushDomain || !hasLinkedTracks(trackId, linkedEncodings)) return; + // We only want to add the brush track if it is linked to another track + const brush = new BrushCircularTrack( + brushOptions, + brushDomain, + pixiManager.makeContainer(boundingBox, cacheId).overlayDiv, + domain + ); + if (!brushOptions.static) { + brush.addInteractor(plot => panZoom(plot, domain)); + } + plotDict[cacheId] = brush; + } else if (type === TrackType.Dummy) { + const dummyOptions = options as DummyTrackOptions; + const dummyPlot = new DummyTrack( + dummyOptions, + pixiManager.makeContainer(boundingBox, cacheId).overlayDiv + ); + plotDict[cacheId] = dummyPlot; } - gosPlot.addInteractor(plot => cursor(plot, cursorPosX)); - plotDict[trackId] = gosPlot; - } - if (type === TrackType.Heatmap) { - const hmOptions = options as HeatmapTrackOptions; - const { spec } = hmOptions; - const xDomain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); - const yDomain = getEncodingSignal(trackDef.trackId, 'y', linkedEncodings); - if (!xDomain || !yDomain) return; - - const datafetcher = getDataFetcher(spec as SingleTrack | OverlaidTrack, urlToFetchOptions); - const heatmapPlot = new HeatmapTrack( - hmOptions, - datafetcher as DataFetcher, - pixiManager.makeContainer(boundingBox) - ) - .addInteractor(plot => panZoomHeatmap(plot, xDomain, yDomain)) - .addInteractor(plot => cursor2D(plot, cursorPosX, cursorPosY)); - plotDict[trackId] = heatmapPlot; - } - if (type === TrackType.Axis) { - const axisOptions = options as AxisTrackOptions; - const domain = getEncodingSignal(trackDef.trackId, axisOptions.encoding, linkedEncodings); - if (!domain) { - console.warn(`No domain found for axis ${trackDef.trackId}. Skipping...`); - return; + }); + + // Create new plots for the ones that are not cached + trackDefs + .filter(d => !cachedPlots[d.cacheId]) + .forEach(trackDef => { + const { boundingBox, type, options, trackId, cacheId } = trackDef; + + if (type === TrackType.Text) { + const textOptions = options as TextTrackOptions; + plotDict[cacheId] = new TextTrack(textOptions, pixiManager.makeContainer(boundingBox, cacheId)); + } + if (type === TrackType.Gosling) { + const gosOptions = options as GoslingTrackOptions; + const { spec } = gosOptions; + + const xDomain = getEncodingSignal(trackId, 'x', linkedEncodings); + const yDomain = getEncodingSignal(trackId, 'y', linkedEncodings); + if (!xDomain) return; + + const datafetcher = getDataFetcher(spec, urlToFetchOptions); + if (!datafetcher) return; + + const gosPlot = new GoslingTrack( + gosOptions, + datafetcher as DataFetcher, + pixiManager.makeContainer(boundingBox, cacheId), + xDomain, + yDomain, + gosOptions.spec.orientation + ); + const isOverlayedOnPrevious = 'overlayOnPreviousTrack' in spec && spec.overlayOnPreviousTrack; + + // TODO: Is this check sufficient? + if (!spec.static && !(spec.layout === 'linear' && isOverlayedOnPrevious)) { + if (spec.layout === 'circular') { + gosPlot.addInteractor(plot => panZoomCircular(plot, cursorPosX, xDomain)); + } else { + gosPlot.addInteractor(plot => panZoom(plot, xDomain, yDomain)); + } + } + if (spec.layout === 'circular') { + gosPlot.addInteractor(plot => cursorCircular(plot, cursorPosX)); + } else { + gosPlot.addInteractor(plot => cursor(plot, cursorPosX)); + } + plotDict[trackId] = gosPlot; } - const axisTrack = new AxisTrack( - axisOptions, - domain, - pixiManager.makeContainer(boundingBox), - axisOptions.orientation - ); - if (!axisOptions.static) { - axisTrack.addInteractor(plot => panZoom(plot, domain)); + if (type === TrackType.Heatmap) { + const hmOptions = options as HeatmapTrackOptions; + const { spec } = hmOptions; + const xDomain = getEncodingSignal(trackId, 'x', linkedEncodings); + const yDomain = getEncodingSignal(trackId, 'y', linkedEncodings); + if (!xDomain || !yDomain) return; + + const datafetcher = getDataFetcher(spec as SingleTrack | OverlaidTrack, urlToFetchOptions); + const heatmapPlot = new HeatmapTrack( + hmOptions, + datafetcher as DataFetcher, + pixiManager.makeContainer(boundingBox, cacheId) + ) + .addInteractor(plot => panZoomHeatmap(plot, xDomain, yDomain)) + .addInteractor(plot => cursor2D(plot, cursorPosX, cursorPosY)); + plotDict[cacheId] = heatmapPlot; } - plotDict[trackId] = axisTrack; - } - if (type === TrackType.BrushLinear) { - const brushOptions = options as BrushLinearTrackOptions; - const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); - const brushDomain = getEncodingSignal(trackDef.trackId, 'brush', linkedEncodings); - if (!domain || !brushDomain || !hasLinkedTracks(trackDef.trackId, linkedEncodings)) return; - // We only want to add the brush track if it is linked to another track - const brush = new BrushLinearTrack( - brushOptions, - brushDomain, - pixiManager.makeContainer(boundingBox).overlayDiv, - domain - ); - if (!brushOptions.static) brush.addInteractor(plot => panZoom(plot, domain)); - plotDict[trackId] = brush; - } - if (type === TrackType.BrushCircular) { - const brushOptions = options as BrushCircularTrackOptions; - const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); - const brushDomain = getEncodingSignal(trackDef.trackId, 'brush', linkedEncodings); - if (!domain || !brushDomain || !hasLinkedTracks(trackDef.trackId, linkedEncodings)) return; - // We only want to add the brush track if it is linked to another track - const brush = new BrushCircularTrack( - brushOptions, - brushDomain, - pixiManager.makeContainer(boundingBox).overlayDiv, - domain - ); - if (!brushOptions.static) { - brush.addInteractor(plot => panZoom(plot, domain)); + if (type === TrackType.Axis) { + console.warn('axis'); + const axisOptions = options as AxisTrackOptions; + const domain = getEncodingSignal(trackId, axisOptions.encoding, linkedEncodings); + if (!domain) { + console.warn(`No domain found for axis ${trackId}. Skipping...`); + return; + } + + const axis = new AxisTrack( + axisOptions, + domain, + pixiManager.makeContainer(boundingBox, cacheId), + axisOptions.orientation + ); + if (!axisOptions.static) { + axis.addInteractor(plot => panZoom(plot, domain)); + } + plotDict[cacheId] = axis; } - plotDict[trackId] = brush; - } - if (type === TrackType.Dummy) { - const dummyOptions = options as DummyTrackOptions; - const dummyPlot = new DummyTrack(dummyOptions, pixiManager.makeContainer(boundingBox).overlayDiv); - plotDict[trackId] = dummyPlot; - } - }); + if (type === TrackType.BrushLinear) { + const brushOptions = options as BrushLinearTrackOptions; + const domain = getEncodingSignal(trackId, 'x', linkedEncodings); + const brushDomain = getEncodingSignal(trackId, 'brush', linkedEncodings); + if (!domain || !brushDomain || !hasLinkedTracks(trackId, linkedEncodings)) return; + // We only want to add the brush track if it is linked to another track + const brush = new BrushLinearTrack( + brushOptions, + brushDomain, + pixiManager.makeContainer(boundingBox, cacheId).overlayDiv, + domain + ); + if (!brushOptions.static) brush.addInteractor(plot => panZoom(plot, domain)); + plotDict[cacheId] = brush; + } + if (type === TrackType.BrushCircular) { + const brushOptions = options as BrushCircularTrackOptions; + const domain = getEncodingSignal(trackId, 'x', linkedEncodings); + const brushDomain = getEncodingSignal(trackId, 'brush', linkedEncodings); + if (!domain || !brushDomain || !hasLinkedTracks(trackId, linkedEncodings)) return; + // We only want to add the brush track if it is linked to another track + const brush = new BrushCircularTrack( + brushOptions, + brushDomain, + pixiManager.makeContainer(boundingBox, cacheId).overlayDiv, + domain + ); + if (!brushOptions.static) { + brush.addInteractor(plot => panZoom(plot, domain)); + } + plotDict[cacheId] = brush; + } + if (type === TrackType.Dummy) { + const dummyOptions = options as DummyTrackOptions; + const dummyPlot = new DummyTrack( + dummyOptions, + pixiManager.makeContainer(boundingBox, cacheId).overlayDiv + ); + plotDict[cacheId] = dummyPlot; + } + }); return plotDict; } diff --git a/demo/track-def/axis.ts b/demo/track-def/axis.ts index ba46a2927..0777ccd31 100644 --- a/demo/track-def/axis.ts +++ b/demo/track-def/axis.ts @@ -32,6 +32,7 @@ export function getAxisTrackDef( trackDefs.push({ type: TrackType.Axis, trackId: track.id, + cacheId: `axis-circular-${track.id}`, boundingBox: boundingBox, options: getAxisTrackCircularOptions(track as ProcessedCircularTrack, boundingBox, xAxisPosition, theme) }); @@ -53,6 +54,7 @@ export function getAxisTrackDef( trackDefs.push({ type: TrackType.Axis, trackId: track.id, + cacheId: `axis-linear-x-${track.id}`, boundingBox: axisBbox, options: getAxisTrackLinearOptions('x', track, axisBbox, xAxisPosition, theme) }); @@ -78,6 +80,7 @@ export function getAxisTrackDef( trackDefs.push({ type: TrackType.Axis, trackId: track.id, + cacheId: `axis-linear-y-${track.id}`, boundingBox: axisBbox, options: getAxisTrackLinearOptions('y', track, axisBbox, yAxisPosition, theme) }); diff --git a/demo/track-def/dummy.ts b/demo/track-def/dummy.ts index 3355ea487..fa39419da 100644 --- a/demo/track-def/dummy.ts +++ b/demo/track-def/dummy.ts @@ -9,6 +9,7 @@ export function processDummyTrack( const trackDef: TrackDef = { type: TrackType.Dummy, trackId: track.id, + cacheId: track.id, boundingBox, options: { width: boundingBox.width, diff --git a/demo/track-def/gosling.ts b/demo/track-def/gosling.ts index 7bd03b9f4..177ccec84 100644 --- a/demo/track-def/gosling.ts +++ b/demo/track-def/gosling.ts @@ -38,6 +38,7 @@ export function processGoslingTrack( trackDefs.push({ type: TrackType.Gosling, trackId: track.id, + cacheId: track.id, boundingBox: { ...boundingBox }, options: goslingTrackOptions }); @@ -54,7 +55,7 @@ export function processGoslingTrack( function getGoslingTrackOptions(spec: ProcessedTrack, theme: Required): GoslingTrackOptions { return { // @ts-expect-error At this point, the spec is processed - spec: spec, + spec, id: spec.id, siblingIds: [], showMousePosition: true, @@ -71,6 +72,6 @@ function getGoslingTrackOptions(spec: ProcessedTrack, theme: Required { type: TrackType; trackId: string; + cacheId: string; boundingBox: { x: number; y: number; width: number; height: number }; options: T; } @@ -85,8 +86,8 @@ export function createTrackDefs(trackInfos: TrackInfo[], theme: Required
{ - // if (!gosRef.current) return; - // // ! Be aware that the first view is for the title/subtitle track. So navigation API does not work. - // const id = gosRef.current.api.getViewIds()?.[1]; //'view-1'; - // if(id) { - // gosRef.current.api.zoomToExtent(id); - // } - // - // // Static visualization rendered in canvas - // const { canvas } = gosRef.current.api.getCanvas({ - // resolution: 1, - // transparentBackground: true, - // }); - // const testDiv = document.getElementById('preview-container'); - // if(canvas && testDiv) { - // testDiv.appendChild(canvas); - // } - // }} + onClick={() => { + gosRef.current?.api.zoomTo('track-1', 'chr1:1-10000', 0, 1000); + }} + // To test APIs, uncomment the following code. + // onClick={() => { + // if (!gosRef.current) return; + // // ! Be aware that the first view is for the title/subtitle track. So navigation API does not work. + // const id = gosRef.current.api.getViewIds()?.[1]; //'view-1'; + // if(id) { + // gosRef.current.api.zoomToExtent(id); + // } + // + // // Static visualization rendered in canvas + // const { canvas } = gosRef.current.api.getCanvas({ + // resolution: 1, + // transparentBackground: true, + // }); + // const testDiv = document.getElementById('preview-container'); + // if(canvas && testDiv) { + // testDiv.appendChild(canvas); + // } + // }} >