From b2d21a22bb20cdc2b588df1c6b871d3d0b68cb89 Mon Sep 17 00:00:00 2001 From: turner Date: Fri, 7 Nov 2025 12:33:33 -0500 Subject: [PATCH 01/16] replace internal events with explicit methods. Clean up method responsibilities --- js/annotationWidget.js | 8 +- js/browserUIManager.js | 2 +- js/contactMatrixView.js | 14 +- js/hicBrowser.js | 478 ++++++++++-- js/sweepZoom.js | 4 +- spacewalk-code-notes/juiceboxPanel.js | 720 ------------------ spacewalk-code-notes/liveContactMapService.js | 154 ---- spacewalk-code-notes/liveContactMapWorker.js | 102 --- .../liveDistanceMapService.js | 266 ------- spacewalk-code-notes/liveDistanceMapWorker.js | 156 ---- 10 files changed, 408 insertions(+), 1496 deletions(-) delete mode 100644 spacewalk-code-notes/juiceboxPanel.js delete mode 100644 spacewalk-code-notes/liveContactMapService.js delete mode 100644 spacewalk-code-notes/liveContactMapWorker.js delete mode 100644 spacewalk-code-notes/liveDistanceMapService.js delete mode 100644 spacewalk-code-notes/liveDistanceMapWorker.js diff --git a/js/annotationWidget.js b/js/annotationWidget.js index 2680e6e3..f11c90d3 100644 --- a/js/annotationWidget.js +++ b/js/annotationWidget.js @@ -184,7 +184,7 @@ class AnnotationWidget { if (isTrack2D) { track.color = color; - this.browser.eventBus.post(HICEvent('TrackState2D', track)); + this.browser.notifyTrackState2D(track); } else { trackRenderer.setColor(color); } @@ -227,7 +227,7 @@ class AnnotationWidget { trackList[index] = temp; if (isTrack2D) { - this.browser.eventBus.post(HICEvent('TrackState2D', trackList)); + this.browser.notifyTrackState2D(trackList); this.updateBody(trackList); } else { this.browser.updateLayout(); @@ -241,7 +241,7 @@ class AnnotationWidget { trackList[index] = temp; if (isTrack2D) { - this.browser.eventBus.post(HICEvent('TrackState2D', trackList)); + this.browser.notifyTrackState2D(trackList); this.updateBody(trackList); } else { this.browser.updateLayout(); @@ -263,7 +263,7 @@ class AnnotationWidget { trackList.splice(index, 1); this.browser.contactMatrixView.clearImageCaches(); this.browser.contactMatrixView.update(); - this.browser.eventBus.post(HICEvent('TrackLoad2D', trackList)); + this.browser.notifyTrackLoad2D(trackList); } else { this.browser.layoutController.removeTrackXYPair(track.x.track.trackRenderPair); } diff --git a/js/browserUIManager.js b/js/browserUIManager.js index c1ba1b82..4ebd6746 100644 --- a/js/browserUIManager.js +++ b/js/browserUIManager.js @@ -51,7 +51,7 @@ class BrowserUIManager { this.components.set('resolutionSelector', new ResolutionSelector(this.browser, navContainer)); this.getComponent('resolutionSelector').setResolutionLock(this.browser.resolutionLocked); - this.components.set('colorScale', new ColorScaleWidget(this.browser, navContainer)); + this.components.set('colorScaleWidget', new ColorScaleWidget(this.browser, navContainer)); this.components.set('controlMap', new ControlMapWidget(this.browser, navContainer)); diff --git a/js/contactMatrixView.js b/js/contactMatrixView.js index 80b9c0a7..ea54fa3c 100644 --- a/js/contactMatrixView.js +++ b/js/contactMatrixView.js @@ -271,7 +271,7 @@ class ContactMatrixView { if (state.normalization !== "NONE") { if (!ds.hasNormalizationVector(state.normalization, zd.chr1.name, zd.zoom.unit, zd.zoom.binSize)) { Alert.presentAlert(`Normalization option ${state.normalization} unavailable at this resolution.`); - this.browser.eventBus.post(new HICEvent("NormalizationExternalChange", "NONE")); + this.browser.notifyNormalizationExternalChange("NONE"); state.normalization = "NONE"; } } @@ -569,7 +569,7 @@ class ContactMatrixView { const changed = this.colorScale.threshold !== this.colorScaleThresholdCache[colorKey] this.colorScale.setThreshold(this.colorScaleThresholdCache[colorKey]) if (changed) { - this.browser.eventBus.post(HICEvent("ColorScale", this.colorScale)) + this.browser.notifyColorScale(this.colorScale) } return this.colorScale } else { @@ -589,7 +589,7 @@ class ContactMatrixView { this.colorScale = new ColorScale(this.colorScale) this.colorScale.setThreshold(s) this.computeColorScale = false - this.browser.eventBus.post(HICEvent("ColorScale", this.colorScale)) + this.browser.notifyColorScale(this.colorScale) this.colorScaleThresholdCache[colorKey] = s } @@ -758,7 +758,7 @@ class ContactMatrixView { xy.xNormalized = xy.x / width; xy.yNormalized = xy.y / height; - this.browser.eventBus.post(HICEvent("UpdateContactMapMousePosition", xy, false)); + this.browser.notifyUpdateContactMapMousePosition(xy); if (this.willShowCrosshairs) { this.browser.updateCrosshairs(xy); @@ -789,7 +789,7 @@ class ContactMatrixView { this.isDragging = true; const dx = mouseLast.x - coords.x; const dy = mouseLast.y - coords.y; - this.browser.shiftPixels(dx, dy); + this.browser.shiftPixels(dx, dy).catch(err => console.error('Error in shiftPixels:', err)); } mouseLast = coords; } @@ -843,7 +843,7 @@ class ContactMatrixView { height: Math.abs(currentY - startY) }; - this.sweepZoom.commit(sweepRect) + this.sweepZoom.commit(sweepRect).catch(err => console.error('Error in sweepZoom.commit:', err)); } }) } @@ -935,7 +935,7 @@ class ContactMatrixView { const dy = lastTouch.y - offsetY; if (!isNaN(dx) && !isNaN(dy)) { this.isDragging = true; - this.browser.shiftPixels(dx, dy); + this.browser.shiftPixels(dx, dy).catch(err => console.error('Error in shiftPixels:', err)); } } diff --git a/js/hicBrowser.js b/js/hicBrowser.js index 4b7e18e3..f8c6ae4d 100644 --- a/js/hicBrowser.js +++ b/js/hicBrowser.js @@ -27,7 +27,7 @@ import igv from '../node_modules/igv/dist/igv.esm.js' import {Alert, InputDialog, DOMUtils} from '../node_modules/igv-ui/dist/igv-ui.js' -import {FileUtils} from '../node_modules/igv-utils/src/index.js' +import {FileUtils, IGVColor} from '../node_modules/igv-utils/src/index.js' import * as hicUtils from './hicUtils.js' import {Globals} from "./globals.js" import EventBus from "./eventBus.js" @@ -45,6 +45,8 @@ import {setTrackReorderArrowColors} from "./trackPair.js" import nvi from './nvi.js' import {extractName, presentError} from "./utils.js" import BrowserUIManager from "./browserUIManager.js" +import ColorScale from './colorScale.js' +import RatioColorScale from './ratioColorScale.js' const DEFAULT_PIXEL_SIZE = 1 const MAX_PIXEL_SIZE = 128 @@ -71,7 +73,7 @@ class HICBrowser { // Unified dataset/state system this.activeDataset = undefined; this.activeState = undefined; - + // Control dataset (for A/B comparisons) this.controlDataset = undefined; @@ -110,7 +112,7 @@ class HICBrowser { async init(config) { this.pending = new Map(); - this.eventBus.hold(); + this.contactMatrixView.disableUpdates = true; try { @@ -134,7 +136,7 @@ class HICBrowser { if (config.displayMode) { this.contactMatrixView.displayMode = config.displayMode; - this.eventBus.post({ type: "DisplayMode", data: config.displayMode }); + this.notifyDisplayMode(config.displayMode); } if (config.colorScale) { @@ -142,7 +144,7 @@ class HICBrowser { this.state.normalization = config.normalization; } this.contactMatrixView.setColorScale(config.colorScale); - this.eventBus.post({ type: "ColorScale", data: this.contactMatrixView.getColorScale() }); + this.notifyColorScale(this.contactMatrixView.getColorScale()); } const promises = []; @@ -165,8 +167,8 @@ class HICBrowser { this.state.normalization = validNormalizations.has(config.normalization) ? config.normalization : 'NONE'; } + // No longer need hold/release - notifications happen directly const tmp = this.contactMatrixView.colorScaleThresholdCache; - this.eventBus.release(); this.contactMatrixView.colorScaleThresholdCache = tmp; if (config.cycle) { @@ -244,7 +246,7 @@ class HICBrowser { async setDisplayMode(mode) { await this.contactMatrixView.setDisplayMode(mode) - this.eventBus.post(HICEvent("DisplayMode", mode)) + this.notifyDisplayMode(mode) } getDisplayMode() { @@ -255,10 +257,10 @@ class HICBrowser { if (!this.activeDataset) return [] - const baseOptions = this.activeDataset.getNormalizationOptions ? + const baseOptions = this.activeDataset.getNormalizationOptions ? await this.activeDataset.getNormalizationOptions() : ['NONE']; if (this.controlDataset) { - let controlOptions = this.controlDataset.getNormalizationOptions ? + let controlOptions = this.controlDataset.getNormalizationOptions ? await this.controlDataset.getNormalizationOptions() : ['NONE']; controlOptions = new Set(controlOptions) return baseOptions.filter(base => controlOptions.has(base)) @@ -359,6 +361,268 @@ class HICBrowser { this.layoutController.yTrackGuideElement.style.display = 'none'; } + /** + * Explicit notification methods to replace internal event system. + * These methods directly call components that need to be notified of state changes. + */ + + notifyMapLoaded(dataset, state, datasetType) { + + const data = { dataset, state, datasetType }; + + // ContactMatrixView needs to enable mouse handlers and clear caches + if (!this.contactMatrixView.mouseHandlersEnabled) { + this.contactMatrixView.addTouchHandlers(this.contactMatrixView.viewportElement); + this.contactMatrixView.addMouseHandlers(this.contactMatrixView.viewportElement); + this.contactMatrixView.mouseHandlersEnabled = true; + } + this.contactMatrixView.clearImageCaches(); + this.contactMatrixView.colorScaleThresholdCache = {}; + + // Update UI components + const chromosomeSelector = this.ui.getComponent('chromosomeSelector'); + if (chromosomeSelector) { + chromosomeSelector.respondToDataLoadWithDataset(dataset); + } + + const ruler = this.layoutController.xAxisRuler; + if (ruler) { + ruler.wholeGenomeLayout(ruler.axisElement, ruler.wholeGenomeContainerElement, ruler.axis, dataset); + ruler.update(); + } + const yRuler = this.layoutController.yAxisRuler; + if (yRuler) { + yRuler.wholeGenomeLayout(yRuler.axisElement, yRuler.wholeGenomeContainerElement, yRuler.axis, dataset); + yRuler.update(); + } + + const normalizationWidget = this.ui.getComponent('normalization'); + if (normalizationWidget) { + normalizationWidget.receiveEvent({ type: "MapLoad", data }); + } + + const resolutionSelector = this.ui.getComponent('resolutionSelector'); + if (resolutionSelector) { + this.resolutionLocked = false; + resolutionSelector.setResolutionLock(false); + resolutionSelector.updateResolutions(this.state.zoom); + } + + const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); + if (colorScaleWidget && colorScaleWidget.mapBackgroundColorpickerButton) { + const paintSwatch = (swatch, { r, g, b }) => { + swatch.style.backgroundColor = IGVColor.rgbToHex(IGVColor.rgbColor(r, g, b)); + }; + paintSwatch(colorScaleWidget.mapBackgroundColorpickerButton, this.contactMatrixView.backgroundColor); + } + + const controlMapWidget = this.ui.getComponent('controlMap'); + if (controlMapWidget && !this.controlDataset) { + controlMapWidget.container.style.display = 'none'; + } + + // Note: locusGoto is notified via notifyLocusChange() which is called from setState() + // after the locus is properly configured. Don't notify here as state.locus might not exist yet. + } + + notifyControlMapLoaded(controlDataset) { + const controlMapWidget = this.ui.getComponent('controlMap'); + if (controlMapWidget) { + controlMapWidget.controlMapHash.updateOptions(this.getDisplayMode()); + controlMapWidget.container.style.display = 'block'; + } + + const resolutionSelector = this.ui.getComponent('resolutionSelector'); + if (resolutionSelector) { + resolutionSelector.updateResolutions(this.state.zoom); + } + + // ContactMatrixView also needs to know about control map + this.contactMatrixView.clearImageCaches(); + this.contactMatrixView.colorScaleThresholdCache = {}; + } + + notifyLocusChange(eventData) { + const { state, resolutionChanged, chrChanged, dragging } = eventData; + + // ContactMatrixView - only clear caches if not a locus change + // (locus changes don't require cache clearing) + + // ChromosomeSelector + const chromosomeSelector = this.ui.getComponent('chromosomeSelector'); + if (chromosomeSelector) { + chromosomeSelector.respondToLocusChangeWithState(state); + } + + // ScrollbarWidget + const scrollbarWidget = this.ui.getComponent('scrollbar'); + if (scrollbarWidget && !scrollbarWidget.isDragging) { + scrollbarWidget.receiveEvent({ type: "LocusChange", data: { state } }); + } + + // ResolutionSelector + const resolutionSelector = this.ui.getComponent('resolutionSelector'); + if (resolutionSelector) { + if (resolutionChanged) { + this.resolutionLocked = false; + resolutionSelector.setResolutionLock(false); + } + + if (chrChanged !== false) { + const isWholeGenome = this.dataset.isWholeGenome(state.chr1); + const labelElement = resolutionSelector.labelElement; + if (labelElement) { + labelElement.textContent = isWholeGenome ? 'Resolution (mb)' : 'Resolution (kb)'; + } + resolutionSelector.updateResolutions(state.zoom); + } else { + const selectedIndex = state.zoom; + Array.from(resolutionSelector.resolutionSelectorElement.options).forEach((option, index) => { + option.selected = index === selectedIndex; + }); + } + } + + // LocusGoto + const locusGoto = this.ui.getComponent('locusGoto'); + if (locusGoto) { + locusGoto.receiveEvent({ type: "LocusChange", data: { state } }); + } + + // Rulers are updated directly in update() method, not here + } + + notifyNormalizationChange(normalization) { + // ContactMatrixView + this.contactMatrixView.receiveEvent({ type: "NormalizationChange", data: normalization }); + + // NormalizationWidget - no direct notification needed, it updates via selector change + } + + notifyDisplayMode(mode) { + const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); + if (colorScaleWidget && colorScaleWidget.minusButton && colorScaleWidget.plusButton) { + const paintSwatch = (swatch, { r, g, b }) => { + swatch.style.backgroundColor = IGVColor.rgbToHex(IGVColor.rgbColor(r, g, b)); + }; + + if (mode === "AOB" || mode === "BOA") { + colorScaleWidget.minusButton.style.display = 'block'; + paintSwatch(colorScaleWidget.minusButton, this.contactMatrixView.ratioColorScale.negativeScale); + paintSwatch(colorScaleWidget.plusButton, this.contactMatrixView.ratioColorScale.positiveScale); + } else { + colorScaleWidget.minusButton.style.display = 'none'; + paintSwatch(colorScaleWidget.plusButton, this.contactMatrixView.colorScale); + } + } + + const controlMapWidget = this.ui.getComponent('controlMap'); + if (controlMapWidget) { + controlMapWidget.controlMapHash.updateOptions(mode); + } + } + + notifyColorScale(colorScale) { + const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); + if (colorScaleWidget && colorScaleWidget.highColorscaleInput && colorScaleWidget.plusButton) { + const paintSwatch = (swatch, { r, g, b }) => { + swatch.style.backgroundColor = IGVColor.rgbToHex(IGVColor.rgbColor(r, g, b)); + }; + + if (colorScale instanceof ColorScale) { + colorScaleWidget.highColorscaleInput.value = colorScale.threshold; + paintSwatch(colorScaleWidget.plusButton, colorScale); + } else if (colorScale instanceof RatioColorScale) { + colorScaleWidget.highColorscaleInput.value = colorScale.threshold; + if (colorScaleWidget.minusButton) { + paintSwatch(colorScaleWidget.minusButton, colorScale.negativeScale); + } + paintSwatch(colorScaleWidget.plusButton, colorScale.positiveScale); + } + } + } + + notifyTrackLoad2D(tracks2D) { + this.contactMatrixView.receiveEvent({ type: "TrackLoad2D", data: tracks2D }); + } + + notifyTrackState2D(trackData) { + this.contactMatrixView.receiveEvent({ type: "TrackState2D", data: trackData }); + } + + notifyNormVectorIndexLoad(dataset) { + const normalizationWidget = this.ui.getComponent('normalization'); + if (normalizationWidget) { + normalizationWidget.updateOptions(); + normalizationWidget.stopNotReady(); + } + } + + notifyNormalizationFileLoad(status) { + const normalizationWidget = this.ui.getComponent('normalization'); + if (normalizationWidget) { + if (status === "start") { + normalizationWidget.startNotReady(); + } else { + normalizationWidget.stopNotReady(); + } + } + } + + notifyNormalizationExternalChange(normalization) { + const normalizationWidget = this.ui.getComponent('normalization'); + if (normalizationWidget) { + Array.from(normalizationWidget.normalizationSelector.options).forEach(option => { + option.selected = option.value === normalization; + }); + } + } + + notifyColorChange() { + this.contactMatrixView.receiveEvent({ type: "ColorChange" }); + } + + notifyUpdateContactMapMousePosition(xy) { + const ruler = this.layoutController.xAxisRuler; + if (ruler && ruler.bboxes) { + ruler.unhighlightWholeChromosome(); + const offset = ruler.axis === 'x' ? xy.x : xy.y; + const hitTest = (bboxes, value) => { + let hitElement = undefined; + for (const bbox of bboxes) { + if (value >= bbox.a && value <= bbox.b) { + hitElement = bbox.element; + break; + } + } + return hitElement; + }; + const element = hitTest(ruler.bboxes, offset); + if (element) { + element.classList.add('hic-whole-genome-chromosome-highlight'); + } + } + const yRuler = this.layoutController.yAxisRuler; + if (yRuler && yRuler.bboxes) { + yRuler.unhighlightWholeChromosome(); + const offset = yRuler.axis === 'x' ? xy.x : xy.y; + const hitTest = (bboxes, value) => { + let hitElement = undefined; + for (const bbox of bboxes) { + if (value >= bbox.a && value <= bbox.b) { + hitElement = bbox.element; + break; + } + } + return hitElement; + }; + const element = hitTest(yRuler.bboxes, offset); + if (element) { + element.classList.add('hic-whole-genome-chromosome-highlight'); + } + } + } + showCrosshairs() { this.contactMatrixView.xGuideElement.style.display = 'block'; this.layoutController.xTrackGuideElement.style.display = 'block'; @@ -468,7 +732,7 @@ class HICBrowser { const tracks2D = await Promise.all(promises2D); if (tracks2D && tracks2D.length > 0) { this.tracks2D = this.tracks2D.concat(tracks2D); - this.eventBus.post(HICEvent("TrackLoad2D", this.tracks2D)); + this.notifyTrackLoad2D(this.tracks2D); } } @@ -489,7 +753,7 @@ class HICBrowser { console.warn("Normalization files are only supported for Hi-C datasets"); return; } - this.eventBus.post(HICEvent("NormalizationFileLoad", "start")) + this.notifyNormalizationFileLoad("start") const normVectors = await this.activeDataset.hicFile.readNormalizationVectorFile(url, this.activeDataset.chromosomes) for (let type of normVectors['types']) { @@ -499,7 +763,7 @@ class HICBrowser { if (!this.activeDataset.normalizationTypes.includes(type)) { this.activeDataset.normalizationTypes.push(type) } - this.eventBus.post(HICEvent("NormVectorIndexLoad", this.activeDataset)) + this.notifyNormVectorIndexLoad(this.activeDataset) } return normVectors @@ -636,7 +900,7 @@ class HICBrowser { config.name = name const hicFileAlert = str => { - this.eventBus.post(HICEvent('NormalizationExternalChange', 'NONE')) + this.notifyNormalizationExternalChange('NONE') Alert.presentAlert(str) } @@ -666,7 +930,7 @@ class HICBrowser { console.error('config.state is of unknown type') state = State.default(config); } - + // Set active dataset before setState so configureLocus can access bpResolutions this.setActiveDataset(dataset, state); await this.setState(state) @@ -685,7 +949,7 @@ class HICBrowser { await this.setState(state) } - this.eventBus.post(HICEvent("MapLoad", { dataset: dataset, state: state, datasetType: dataset.datasetType })) + this.notifyMapLoaded(dataset, state, dataset.datasetType) // Initiate loading of the norm vector index, but don't block if the "nvi" parameter is not available. // Let it load in the background @@ -701,13 +965,15 @@ class HICBrowser { if (config.nvi && dataset.getNormVectorIndex) { await dataset.getNormVectorIndex(config) - this.eventBus.post(HICEvent("NormVectorIndexLoad", dataset)) + if (!config.isControl) { + this.notifyNormVectorIndexLoad(dataset) + } } else if (dataset.getNormVectorIndex) { dataset.getNormVectorIndex(config) .then(normVectorIndex => { if (!config.isControl) { - this.eventBus.post(HICEvent("NormVectorIndexLoad", dataset)) + this.notifyNormVectorIndexLoad(dataset) } }) } @@ -795,7 +1061,7 @@ class HICBrowser { this.setActiveDataset(dataset, state); await this.setState(state); - this.eventBus.post(HICEvent("MapLoad", { dataset: dataset, state: state, datasetType: dataset.datasetType })); + this.notifyMapLoaded(dataset, state, dataset.datasetType); } catch (error) { this.contactMapLabel.textContent = ""; @@ -827,7 +1093,7 @@ class HICBrowser { config.name = name const hicFileAlert = str => { - this.eventBus.post(HICEvent('NormalizationExternalChange', 'NONE')) + this.notifyNormalizationExternalChange('NONE') Alert.presentAlert(str) } @@ -847,10 +1113,10 @@ class HICBrowser { if (controlDataset.getNormVectorIndex) { await controlDataset.getNormVectorIndex(config) } - this.eventBus.post(HICEvent("ControlMapLoad", this.controlDataset)) + this.notifyControlMapLoaded(this.controlDataset) if (!noUpdates) { - this.update() + await this.update() } } else { Alert.presentAlert('"B" map genome (' + controlDataset.genomeId + ') does not match "A" map genome (' + this.genome.id + ')') @@ -880,7 +1146,7 @@ class HICBrowser { if (xLocus.wholeChr && yLocus.wholeChr || 'All' === xLocus.chr && 'All' === yLocus.chr) { await this.setChromosomes(xLocus, yLocus) } else { - this.goto(xLocus.chr, xLocus.start, xLocus.end, yLocus.chr, yLocus.start, yLocus.end) + await this.goto(xLocus.chr, xLocus.start, xLocus.end, yLocus.chr, yLocus.start, yLocus.end) } } @@ -939,14 +1205,17 @@ class HICBrowser { return undefined; // No match found } - goto(chr1, bpX, bpXMax, chr2, bpY, bpYMax) { + async goto(chr1, bpX, bpXMax, chr2, bpY, bpYMax) { const { width, height } = this.contactMatrixView.getViewDimensions() const { chrChanged, resolutionChanged } = this.state.updateWithLoci(chr1, bpX, bpXMax, chr2, bpY, bpYMax, this, width, height) this.contactMatrixView.clearImageCaches() - this.update(HICEvent("LocusChange", { state: this.state, resolutionChanged, chrChanged })) + const eventData = { state: this.state, resolutionChanged, chrChanged } + + await this.update() + this.notifyLocusChange(eventData) } @@ -1024,7 +1293,9 @@ class HICBrowser { await this.contactMatrixView.zoomIn(anchorPx, anchorPy, 1/scaleFactor) - await this.update(HICEvent("LocusChange", { state: this.state, resolutionChanged, chrChanged: false })) + const eventData = { state: this.state, resolutionChanged, chrChanged: false } + await this.update() + this.notifyLocusChange(eventData) } } finally { this.stopSpinner() @@ -1088,7 +1359,9 @@ class HICBrowser { this.state.configureLocus(this, this.dataset, { width, height }) - this.update(HICEvent("LocusChange", {state: this.state, resolutionChanged: false, chrChanged: false})) + const eventData = { state: this.state, resolutionChanged: false, chrChanged: false } + await this.update() + this.notifyLocusChange(eventData) } else { let i @@ -1114,7 +1387,9 @@ class HICBrowser { await this.contactMatrixView.zoomIn() - this.update(HICEvent("LocusChange", { state: this.state, resolutionChanged, chrChanged: false })) + const eventData = { state: this.state, resolutionChanged, chrChanged: false } + await this.update() + this.notifyLocusChange(eventData) } @@ -1147,7 +1422,9 @@ class HICBrowser { } - await this.update(HICEvent("LocusChange", {state: this.state, resolutionChanged: true, chrChanged: true})) + const eventData = { state: this.state, resolutionChanged: true, chrChanged: true } + await this.update() + this.notifyLocusChange(eventData) } @@ -1198,9 +1475,9 @@ class HICBrowser { this.state.configureLocus(this, this.activeDataset, viewDimensions) } - const hicEvent = new HICEvent("LocusChange", { state: this.state, resolutionChanged: true, chrChanged }) - this.update(hicEvent) - this.eventBus.post(hicEvent) + const eventData = { state: this.state, resolutionChanged: true, chrChanged } + await this.update() + this.notifyLocusChange(eventData) } /** @@ -1240,18 +1517,19 @@ class HICBrowser { const { zoomChanged, chrChanged } = this.state.sync(targetState, this, this.genome, this.dataset) - const payload = { state: this.state, resolutionChanged: zoomChanged, chrChanged } - this.update(HICEvent("LocusChange", payload, false)) + // For sync, we don't want to propagate back to other browsers (would cause infinite loop) + // So we update without syncing + await this.update(false) } setNormalization(normalization) { this.state.normalization = normalization - this.eventBus.post(HICEvent("NormalizationChange", this.state.normalization)) + this.notifyNormalizationChange(this.state.normalization) } - shiftPixels(dx, dy) { + async shiftPixels(dx, dy) { if (undefined === this.dataset) { console.warn('dataset is undefined') @@ -1260,74 +1538,106 @@ class HICBrowser { this.state.panShift(dx, dy, this, this.dataset, this.contactMatrixView.getViewDimensions()) - const locusChangeEvent = HICEvent("LocusChange", { + const eventData = { state: this.state, resolutionChanged: false, dragging: true, chrChanged: false - }) - locusChangeEvent.dragging = true + } - this.update(locusChangeEvent) - this.eventBus.post(locusChangeEvent) + await this.update() + this.notifyLocusChange(eventData) } /** - * Update the maps and tracks. This method can be called from the browser event thread repeatedly, for example - * while mouse dragging. If called while an update is in progress queue the event for processing later. It - * is only neccessary to queue the most recent recently received event, so a simple instance variable will suffice - * for the queue. - * - * @param event + * Pure rendering method - repaints all visual components. + * Reads state directly from this.state, no parameters needed. + * This is the core rendering logic separated from update coordination. */ - async update(event) { + async repaint() { + if (!this.activeDataset || !this.activeState) { + return; // Can't render without dataset and state + } + + // Update rulers with current state + const pseudoEvent = { type: "LocusChange", data: { state: this.activeState } } + this.layoutController.xAxisRuler.locusChange(pseudoEvent) + this.layoutController.yAxisRuler.locusChange(pseudoEvent) + + // Render all tracks and contact matrix in parallel + const promises = [] + + for (let xyTrackRenderPair of this.trackPairs) { + promises.push(this.renderTrackXY(xyTrackRenderPair)) + } + promises.push(this.contactMatrixView.update()) + await Promise.all(promises) + } + + /** + * Synchronize this browser's state to other synched browsers. + * Called separately from rendering to keep concerns separated. + */ + syncToOtherBrowsers() { + if (this.synchedBrowsers.size === 0) { + return; // Nothing to sync + } + + const syncState = this.getSyncState() + for (const browser of [...this.synchedBrowsers]) { + browser.syncState(syncState) + } + } + + /** + * Public API for updating/repainting the browser. + * + * Handles queuing logic for rapid calls (e.g., during mouse dragging). + * If called while an update is in progress, queues the request for later processing. + * Only the most recent request per type is kept in the queue. + * + * @param shouldSync - Whether to synchronize state to other browsers (default: true) + * Set to false when called from syncState() to avoid infinite loops + */ + async update(shouldSync = true) { if (this.updating) { - const type = event ? event.type : "NONE" - this.pending.set(type, event) - } else { - this.updating = true - try { + // Queue this update request - use a simple key since we don't need event types anymore + this.pending.set("update", { shouldSync }) + return + } - this.startSpinner() - if (event !== undefined && "LocusChange" === event.type) { - this.layoutController.xAxisRuler.locusChange(event) - this.layoutController.yAxisRuler.locusChange(event) - } + this.updating = true + try { + this.startSpinner() - const promises = [] + // Render everything + await this.repaint() - for (let xyTrackRenderPair of this.trackPairs) { - promises.push(this.renderTrackXY(xyTrackRenderPair)) - } - promises.push(this.contactMatrixView.update(event)) - await Promise.all(promises) + // Optionally sync to other browsers + if (shouldSync) { + this.syncToOtherBrowsers() + } - if (event && event.propogate) { - let syncState1 = this.getSyncState() - for (const browser of [...this.synchedBrowsers]) { - browser.syncState(syncState1) - } - } + } finally { + this.updating = false - } finally { - this.updating = false - if (this.pending.size > 0) { - const events = [] - for (let [k, v] of this.pending) { - events.push(v) - } - this.pending.clear() - for (let e of events) { - this.update(e) - } + // Process any queued updates + if (this.pending.size > 0) { + const queued = [] + for (let [k, v] of this.pending) { + queued.push(v) } - if (event) { - // possibly, unless update was called from an event post (infinite loop) - this.eventBus.post(event) + this.pending.clear() + + // Process queued updates (only need to process the last one) + if (queued.length > 0) { + const lastQueued = queued[queued.length - 1] + await this.update(lastQueued.shouldSync) } - this.stopSpinner() } + + this.stopSpinner() } } diff --git a/js/sweepZoom.js b/js/sweepZoom.js index 465224e4..78b921ed 100644 --- a/js/sweepZoom.js +++ b/js/sweepZoom.js @@ -48,7 +48,7 @@ class SweepZoom { this.rulerSweeperElement.style.top = top } - commit({ xPixel, yPixel, width, height }) { + async commit({ xPixel, yPixel, width, height }) { this.rulerSweeperElement.style.display = 'none'; @@ -64,7 +64,7 @@ class SweepZoom { const widthBP = ( width / pixelSize) * bpResolution; const heightBP = (height / pixelSize) * bpResolution; - this.browser.goto(locus.x.chr, Math.round(xBP), Math.round(xBP + widthBP), locus.y.chr, Math.round(yBP), Math.round(yBP + heightBP)); + await this.browser.goto(locus.x.chr, Math.round(xBP), Math.round(xBP + widthBP), locus.y.chr, Math.round(yBP), Math.round(yBP + heightBP)); } } diff --git a/spacewalk-code-notes/juiceboxPanel.js b/spacewalk-code-notes/juiceboxPanel.js deleted file mode 100644 index fec77bc3..00000000 --- a/spacewalk-code-notes/juiceboxPanel.js +++ /dev/null @@ -1,720 +0,0 @@ -import hic from 'juicebox.js' -import SpacewalkEventBus from '../spacewalkEventBus.js' -import Panel from '../panel.js' -import { ballAndStick, liveContactMapService, liveDistanceMapService, ensembleManager, ribbon, igvPanel, genomicNavigator } from '../app.js' -import { renderLiveMapWithDistanceData } from './liveDistanceMapService.js' -import {appleCrayonColorRGB255, rgb255String, compositeColors} from "../utils/colorUtils" -import {transferRGBAMatrixToLiveMapCanvas} from "../utils/utils.js" -import {spacewalkConfig} from "../../spacewalk-config" - -// Store reference to the singleton JuiceboxPanel instance for event handlers -let juiceboxPanelInstance = null; - -class JuiceboxPanel extends Panel { - - constructor ({ container, panel, isHidden }) { - - const xFunction = (cw, w) => { - return (cw - w)/2; - }; - - const yFunction = (ch, h) => { - return ch - (h * 1.05); - }; - - super({ container, panel, isHidden, xFunction, yFunction }); - - // Store singleton instance for event handlers to access - juiceboxPanelInstance = this; - - // Store references to both datasets for tab switching - this.hicDataset = null; - this.hicState = null; - this.liveMapDataset = null; - this.liveMapState = null; - - // const dragHandle = panel.querySelector('.spacewalk_card_drag_container') - // makeDraggable(panel, dragHandle) - - this.panel.addEventListener('mouseenter', (event) => { - event.stopPropagation(); - SpacewalkEventBus.globalBus.post({ type: 'DidEnterGenomicNavigator', data: 'DidEnterGenomicNavigator' }); - }); - - this.panel.addEventListener('mouseleave', (event) => { - event.stopPropagation(); - SpacewalkEventBus.globalBus.post({ type: 'DidLeaveGenomicNavigator', data: 'DidLeaveGenomicNavigator' }); - }); - - panel.querySelector('#hic-live-contact-frequency-map-calculation-button').addEventListener('click', async e => { - liveContactMapService.updateEnsembleContactFrequencyCanvas(undefined) - }) - - SpacewalkEventBus.globalBus.subscribe('DidLoadEnsembleFile', this) - - } - - async initialize(container, config) { - - let session - - if (config.browsers) { - session = Object.assign({ queryParametersSupported: false }, config) - } else { - const { width, height } = config - session = { browsers: [ { width, height, queryParametersSupported: false } ] } - } - - await this.loadSession(session) - - } - - async loadSession(session) { - - this.detachMouseHandlers() - - try { - const [ browser ] = session.browsers - if ('{}' === browser) { - const { width, height} = spacewalkConfig.juiceboxConfig - session = { browsers: [ { width, height, queryParametersSupported: false } ] } - } - await hic.restoreSession(document.querySelector('#spacewalk_juicebox_root_container'), session) - } catch (e) { - const error = new Error(`Error loading Juicebox Session ${ e.message }`) - console.error(error.message) - alert(error.message) - } - - this.browser = hic.getCurrentBrowser() - - // Check if session has a url property (indicating a Hi-C file) - const hasHicFile = session.url || (session.browsers && session.browsers[0]?.url) - - // Store reference to Hi-C dataset before loading live map (if it exists) - if (hasHicFile && this.browser.activeDataset && this.browser.activeDataset.datasetType !== 'livemap') { - this.hicDataset = this.browser.activeDataset - this.hicState = this.browser.activeState - } - - // Initialize live map canvas contexts (Spacewalk-specific) - this.initializeLiveMapContexts() - - // Only load live map dataset if ensemble datasource is available - // But if we have a Hi-C file, we'll switch back to it when showing Hi-C tab - if (ensembleManager.datasource) { - await this.loadLiveMapDataset() - // Store live map dataset reference - if (this.browser.activeDataset && this.browser.activeDataset.datasetType === 'livemap') { - this.liveMapDataset = this.browser.activeDataset - this.liveMapState = this.browser.activeState - } - } - - this.attachMouseHandlersAndEventSubscribers() - - // Determine which tab to show based on session content - if (hasHicFile && this.hicDataset) { - // Session contains a Hi-C file, switch back to Hi-C dataset and show Hi-C tab - this.browser.setActiveDataset(this.hicDataset, this.hicState) - this.hicMapTab.show() - - // Apply Spacewalk locus after switching to Hi-C dataset - // This ensures the map displays the correct region from Spacewalk - if (ensembleManager && ensembleManager.locus) { - const { chr, genomicStart, genomicEnd } = ensembleManager.locus - try { - await this.browser.parseGotoInput(`${chr}:${genomicStart}-${genomicEnd}`) - } catch (error) { - console.warn('Error applying Spacewalk locus to Hi-C map:', error.message) - } - } - - // Ensure Hi-C map is repainted after session load - setTimeout(() => { - const activeTabButton = this.container.querySelector('button.nav-link.active') - if (activeTabButton && activeTabButton.id === 'spacewalk-juicebox-panel-hic-map-tab') { - tabAssessment(this.browser, activeTabButton, this) - if (this.browser.contactMatrixView && this.browser.activeDataset) { - this.browser.contactMatrixView.update().catch(err => console.warn('Error updating contact matrix view after session load:', err)) - } - } - }, 150) - } else { - // No Hi-C file in session, show live map tab - this.liveMapTab.show() - } - - } - - /** - * Initialize live map canvas contexts for bitmaprenderer rendering. - * This method injects the live map canvas containers into the viewport - * (created by layoutController) and configures the ctx_live and ctx_live_distance - * contexts that are required for live map rendering in Spacewalk. - */ - initializeLiveMapContexts() { - const browser = this.browser - const viewport = browser.layoutController.getContactMatrixViewport() - - if (!viewport) { - console.warn('Viewport not found, cannot initialize live map contexts') - return - } - - // Find or create live contact map container inside the viewport - let liveContactContainer = viewport.querySelector(`#${browser.id}-live-contact-map-canvas-container`) - if (!liveContactContainer) { - liveContactContainer = document.createElement('div') - liveContactContainer.id = `${browser.id}-live-contact-map-canvas-container` - // Insert after the Hi-C contact map container - const hicContainer = viewport.querySelector(`#${browser.id}-contact-map-canvas-container`) - if (hicContainer && hicContainer.nextSibling) { - viewport.insertBefore(liveContactContainer, hicContainer.nextSibling) - } else { - viewport.appendChild(liveContactContainer) - } - } - - // Get or create live contact map canvas - let canvas = liveContactContainer.querySelector(`#${browser.id}-live-contact-map-canvas`) - if (!canvas) { - canvas = document.createElement('canvas') - canvas.id = `${browser.id}-live-contact-map-canvas` - liveContactContainer.appendChild(canvas) - } - - const ctx_live = canvas.getContext('bitmaprenderer') - if (!ctx_live) { - console.warn('bitmaprenderer context not available for live contact map') - } - - // Find or create live distance map container inside the viewport - let liveDistanceContainer = viewport.querySelector(`#${browser.id}-live-distance-map-canvas-container`) - if (!liveDistanceContainer) { - liveDistanceContainer = document.createElement('div') - liveDistanceContainer.id = `${browser.id}-live-distance-map-canvas-container` - // Insert after live contact container - if (liveContactContainer.nextSibling) { - viewport.insertBefore(liveDistanceContainer, liveContactContainer.nextSibling) - } else { - viewport.appendChild(liveDistanceContainer) - } - } - - // Get or create live distance map canvas - canvas = liveDistanceContainer.querySelector(`#${browser.id}-live-distance-map-canvas`) - if (!canvas) { - canvas = document.createElement('canvas') - canvas.id = `${browser.id}-live-distance-map-canvas` - liveDistanceContainer.appendChild(canvas) - } - - const ctx_live_distance = canvas.getContext('bitmaprenderer') - if (!ctx_live_distance) { - console.warn('bitmaprenderer context not available for live distance map') - } - - // Set contexts on ContactMatrixView - browser.contactMatrixView.setLiveMapContexts(ctx_live, ctx_live_distance) - - // Update canvas sizes to match viewport when viewport is resized - this.updateLiveMapCanvasSizes(this.browser.contactMatrixView) - } - - /** - * Update live map canvas sizes to match the main canvas viewport - * Uses viewport dimensions directly, matching the old approach where width/height - * were passed directly from the ContactMatrixView viewport. - */ - updateLiveMapCanvasSizes(contactMatrixView) { - - const width = contactMatrixView.viewportElement.offsetWidth - const height = contactMatrixView.viewportElement.offsetHeight - - // Ensure we have valid dimensions - if (width === 0 || height === 0) { - console.warn(`Viewport dimensions are invalid: ${width}x${height}. Canvas sizes not updated.`) - return - } - - if (contactMatrixView.ctx_live) { - const canvas = contactMatrixView.ctx_live.canvas - canvas.width = width - canvas.height = height - // Set CSS size to match viewport - canvas.style.width = `${width}px` - canvas.style.height = `${height}px` - console.log(`Updated ctx_live canvas size: ${canvas.width}x${canvas.height}`) - } - - if (contactMatrixView.ctx_live_distance) { - const canvas = contactMatrixView.ctx_live_distance.canvas - canvas.width = width - canvas.height = height - // Set CSS size to match viewport - canvas.style.width = `${width}px` - canvas.style.height = `${height}px` - console.log(`Updated ctx_live_distance canvas size: ${canvas.width}x${canvas.height}`) - } - } - - attachMouseHandlersAndEventSubscribers() { - - this.browser.eventBus.subscribe('DidHideCrosshairs', ribbon) - - this.browser.eventBus.subscribe('DidHideCrosshairs', ballAndStick) - - this.browser.eventBus.subscribe('DidHideCrosshairs', genomicNavigator) - - this.browser.eventBus.subscribe('DidUpdateColor', async ({ data }) => { - await this.colorPickerHandler(data) - }) - - this.browser.eventBus.subscribe('DidUpdateColorScaleThreshold', async ({ data }) => { - const { threshold, r, g, b } = data - console.log('JuiceboxPanel. Render Live Contact Map') - await this.renderLiveMapWithContactData(liveContactMapService.contactFrequencies, liveContactMapService.rgbaMatrix, ensembleManager.getLiveMapTraceLength()) - - }) - - this.browser.eventBus.subscribe('MapLoad', async event => { - const activeTabButton = this.container.querySelector('button.nav-link.active') - tabAssessment(this.browser, activeTabButton, this) - // Ensure repaint after MapLoad event (especially important for session loading) - if (this.browser.activeDataset && this.browser.activeDataset.datasetType !== 'livemap') { - setTimeout(() => { - if (this.browser.contactMatrixView && this.browser.activeDataset) { - this.browser.contactMatrixView.update().catch(err => console.warn('Error updating contact matrix view after MapLoad:', err)) - } - }, 50) - } - }) - - this.browser.setCustomCrosshairsHandler(({ xBP, yBP, startXBP, startYBP, endXBP, endYBP, interpolantX, interpolantY }) => { - juiceboxMouseHandler({ xBP, yBP, startXBP, startYBP, endXBP, endYBP, interpolantX, interpolantY }); - }) - - this.configureTabs() - } - - configureTabs() { - - // Locate tab elements - const hicMapTabElement = document.getElementById('spacewalk-juicebox-panel-hic-map-tab') - const liveMapTabElement = document.getElementById('spacewalk-juicebox-panel-live-map-tab') - const liveDistanceMapTabElement = document.getElementById('spacewalk-juicebox-panel-live-distance-map-tab') - - // Assign data-bs-target to refer to corresponding map canvas container (hi-c or live-contact or live-distance) - hicMapTabElement.setAttribute("data-bs-target", `#${this.browser.id}-contact-map-canvas-container`) - liveMapTabElement.setAttribute("data-bs-target", `#${this.browser.id}-live-contact-map-canvas-container`) - liveDistanceMapTabElement.setAttribute("data-bs-target", `#${this.browser.id}-live-distance-map-canvas-container`) - - // Create instance property for each tab - this.hicMapTab = new bootstrap.Tab(hicMapTabElement) - this.liveMapTab = new bootstrap.Tab(liveMapTabElement) - this.liveDistanceMapTab = new bootstrap.Tab(liveDistanceMapTabElement) - - // Determine which tab to show based on active dataset - // If a Hi-C dataset is loaded, show Hi-C tab; otherwise show live map tab - if (this.browser.activeDataset && this.browser.activeDataset.datasetType !== 'livemap') { - this.hicMapTab.show() - } else { - this.liveMapTab.show() - } - - const activeTabButton = this.container.querySelector('button.nav-link.active') - tabAssessment(this.browser, activeTabButton, this) - - for (const tabElement of this.container.querySelectorAll('button[data-bs-toggle="tab"]')) { - tabElement.addEventListener('show.bs.tab', tabEventHandler) - } - - this.liveDistanceMapTab._element.addEventListener('shown.bs.tab', event => { - if (liveDistanceMapService.isTraceToggleChecked()) { - liveDistanceMapService.updateTraceDistanceCanvas(ensembleManager.getLiveMapTraceLength(), ensembleManager.currentTrace) - } - }) - - } - - isActiveTab(tab) { - return tab._element.classList.contains('active') - } - - detachMouseHandlers() { - - for (const tabElement of this.container.querySelectorAll('button[data-bs-toggle="tab"]')) { - tabElement.removeEventListener('show.bs.tab', tabEventHandler); - } - - } - - async receiveEvent({ type, data }) { - - if ('DidLoadEnsembleFile' === type) { - - // Clear Hi-C map rendering - const ctx = this.browser.contactMatrixView.ctx - ctx.fillStyle = rgb255String( appleCrayonColorRGB255('snow') ) - ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); - - // Load live map dataset - await this.loadLiveMapDataset() - - // Show Live Map tab to be consistent with Live Dataset - this.liveMapTab.show() - - // MapLoad event will be posted automatically by loadLiveMapDataset - // Locus change will be handled by the standard update cycle - - } - - super.receiveEvent({ type, data }); - - } - - getClassName(){ return 'JuiceboxPanel' } - - async loadHicFile(url, name, mapType) { - - try { - const isControl = ('control-map' === mapType) - - const config = { url, name, isControl } - - if (false === isControl) { - - this.present() - - await this.browser.loadHicFile(config) - - } - - } catch (e) { - const error = new Error(`Error loading ${ url }: ${ e }`) - console.error(error.message) - alert(error.message) - } - - const { chr, genomicStart, genomicEnd } = ensembleManager.locus - - try { - await this.browser.parseGotoInput(`${chr}:${genomicStart}-${genomicEnd}`) - } catch (error) { - console.warn(error.message) - } - - } - - async loadLiveMapDataset() { - if (!isLiveMapSupported()) { - return; - } - - const { chr, genomicStart, genomicEnd } = ensembleManager.locus - const traceLength = ensembleManager.getLiveMapTraceLength() - const binSize = (genomicEnd - genomicStart) / traceLength - - // Get chromosome from IGV genome - const chromosome = igvPanel.browser.genome.getChromosome(chr) - if (!chromosome) { - console.warn(`Live Maps are not available for chromosome ${chr}`) - return - } - - // Convert IGV genome chromosomes to array format expected by LiveMapDataset - // LiveMapDataset expects chromosomes with: name, size (or bpLength), index - const chromosomes = Array.from(igvPanel.browser.genome.chromosomes.values()).map((chr, idx) => ({ - name: chr.name, - size: chr.size || chr.bpLength, - bpLength: chr.size || chr.bpLength, - index: idx - })) - // Move "All" chromosome to front if it exists - const allIndex = chromosomes.findIndex(c => c.name.toLowerCase() === 'all') - if (allIndex > 0) { - const allChr = chromosomes.splice(allIndex, 1)[0] - chromosomes.unshift(allChr) - // Re-index after moving - chromosomes.forEach((chr, idx) => { chr.index = idx }) - } - - // Find the chromosome index in our constructed array (for state chr1/chr2) - // Juicebox uses 0-based array indexing, but state.chr1/chr2 use 1-based (0 = whole genome, 1 = first chr) - const chrArrayIndex = chromosomes.findIndex(c => c.name === chromosome.name) - if (chrArrayIndex < 0) { - console.warn(`Chromosome ${chromosome.name} not found in chromosomes array`) - return - } - // Juicebox state: 0 = whole genome, 1 = first chromosome (index 0), 2 = second chromosome (index 1), etc. - const chrIndex = chrArrayIndex + 1 - - // Create LiveMapDataset config - const datasetConfig = { - name: 'Live Map', - genomeId: igvPanel.browser.genome.id, - chromosomes: chromosomes, - bpResolutions: [binSize], - binSize: binSize, - contactRecordList: [] // Will be populated by liveContactMapService - } - - // Create state from genomic coordinates - // Calculate bin positions for the genomic region - const xBin = Math.floor(genomicStart / binSize) - const yBin = Math.floor(genomicStart / binSize) - const zoom = 0 // Live maps typically have single resolution - - // Create state object matching State constructor signature - // Note: locus can be undefined - setState will derive it using configureLocus() - // Or we can create it explicitly with the proper structure: { x: {chr, start, end}, y: {chr, start, end} } - const stateConfig = { - chr1: chrIndex, - chr2: chrIndex, - locus: undefined, // Let setState configure it automatically - zoom: zoom, - x: xBin, - y: yBin, - pixelSize: 1, - normalization: 'NONE' - } - - await this.browser.loadLiveMapDataset({ - ...datasetConfig, - state: stateConfig - }) - } - - async renderLiveMapWithContactData(contactFrequencies, contactFrequencyArray, liveMapTraceLength) { - console.log('JuiceboxPanel. Render Live Contact Map') - - const browser = this.browser - - // Ensure live map dataset is loaded - if (!browser.activeDataset || browser.activeDataset.datasetType !== 'livemap') { - await this.loadLiveMapDataset() - } - - const state = browser.activeState - const dataset = browser.activeDataset - - if (!state || !dataset) { - console.warn('renderLiveMapWithContactData(...) - Live map state or dataset not available') - return - } - - const { chr, genomicStart, genomicEnd } = ensembleManager.locus - try { - await browser.parseGotoInput(`${chr}:${genomicStart}-${genomicEnd}`) - } catch (error) { - console.warn(error.message) - } - - // Update canvas sizes to match current viewport - this.updateLiveMapCanvasSizes(this.browser.contactMatrixView) - - // Trigger color scale check (will use standard checkColorScale method) - await browser.contactMatrixView.update() - - // Paint and transfer RGBA matrix to canvas - this.paintContactMapRGBAMatrix(contactFrequencies, contactFrequencyArray, browser.contactMatrixView.colorScale, browser.contactMatrixView.backgroundColor) - - // Transfer RGBA matrix to live map canvas - const ctx_live = browser.contactMatrixView.ctx_live - if (ctx_live) { - const canvas = ctx_live.canvas - - if (canvas.width === 0 || canvas.height === 0) { - console.warn(`Canvas dimensions are invalid: ${canvas.width}x${canvas.height}. Updating sizes...`) - this.updateLiveMapCanvasSizes(this.browser.contactMatrixView) - if (canvas.width === 0 || canvas.height === 0) { - console.error(`Cannot render: canvas dimensions are still invalid: ${canvas.width}x${canvas.height}`) - return - } - } - - console.log(`Transferring RGBA matrix: matrixDimension=${liveMapTraceLength}, canvas size=${canvas.width}x${canvas.height}`) - - // Scale the matrix to match canvas size if needed - if (liveMapTraceLength !== canvas.width || liveMapTraceLength !== canvas.height) { - // Create a temporary canvas at source size - const tempCanvas = document.createElement('canvas') - tempCanvas.width = liveMapTraceLength - tempCanvas.height = liveMapTraceLength - const tempCtx = tempCanvas.getContext('2d') - const imageData = new ImageData(contactFrequencyArray, liveMapTraceLength, liveMapTraceLength) - tempCtx.putImageData(imageData, 0, 0) - - // Create an offscreen canvas at target size and scale the image - const scaledCanvas = document.createElement('canvas') - scaledCanvas.width = canvas.width - scaledCanvas.height = canvas.height - const scaledCtx = scaledCanvas.getContext('2d') - scaledCtx.imageSmoothingEnabled = false - scaledCtx.drawImage(tempCanvas, 0, 0, liveMapTraceLength, liveMapTraceLength, 0, 0, canvas.width, canvas.height) - - if (scaledCanvas.width > 0 && scaledCanvas.height > 0) { - const imageBitmap = await createImageBitmap(scaledCanvas) - ctx_live.transferFromImageBitmap(imageBitmap) - } else { - console.error(`Cannot create ImageBitmap: scaled canvas dimensions are invalid: ${scaledCanvas.width}x${scaledCanvas.height}`) - } - } else { - await transferRGBAMatrixToLiveMapCanvas(ctx_live, contactFrequencyArray, liveMapTraceLength) - } - } else { - console.warn('ctx_live not available for live map rendering') - } - } - - paintContactMapRGBAMatrix(frequencies, rgbaMatrix, colorScale, backgroundRGB) { - let i = 0 - for (const frequency of frequencies) { - const { red, green, blue, alpha } = colorScale.getColor(frequency) - const foregroundRGBA = { r:red, g:green, b:blue, a:alpha } - const { r, g, b } = compositeColors(foregroundRGBA, backgroundRGB) - - rgbaMatrix[i++] = r - rgbaMatrix[i++] = g - rgbaMatrix[i++] = b - rgbaMatrix[i++] = 255 - } - } - - async renderLiveMapWithDistanceData(distances, maxDistance, rgbaMatrix, liveMapTraceLength) { - console.log('JuiceboxPanel. Render Live Distance Map') - await renderLiveMapWithDistanceData(this.browser, distances, maxDistance, rgbaMatrix, liveMapTraceLength) - } - - async colorPickerHandler(data) { - if (liveContactMapService.contactFrequencies) { - console.log('JuiceboxPanel.colorPickerHandler(). Will render Live Contact Map') - await this.renderLiveMapWithContactData(liveContactMapService.contactFrequencies, liveContactMapService.rgbaMatrix, ensembleManager.getLiveMapTraceLength()) - } - if (liveDistanceMapService.distances) { - console.log('JuiceboxPanel.colorPickerHandler(). Will render Live Distance Map') - await this.renderLiveMapWithDistanceData(liveDistanceMapService.distances, liveDistanceMapService.maxDistance, liveDistanceMapService.rgbaMatrix, ensembleManager.getLiveMapTraceLength()) - } - - } -} - -function juiceboxMouseHandler({ xBP, yBP, startXBP, startYBP, endXBP, endYBP, interpolantX, interpolantY }) { - - if (undefined === ensembleManager || undefined === ensembleManager.locus) { - return - } - - const { genomicStart, genomicEnd } = ensembleManager.locus - - const trivialRejection = startXBP > genomicEnd || endXBP < genomicStart || startYBP > genomicEnd || endYBP < genomicStart - - if (trivialRejection) { - return - } - - const xRejection = xBP < genomicStart || xBP > genomicEnd - const yRejection = yBP < genomicStart || yBP > genomicEnd - - if (xRejection || yRejection) { - return - } - - SpacewalkEventBus.globalBus.post({ type: 'DidUpdateGenomicInterpolant', data: { poster: this, interpolantList: [ interpolantX, interpolantY ] } }) -} - -function isLiveMapSupported() { - - const { chr } = ensembleManager.locus - // const chromosome = igvPanel.browser.genome.getChromosome(chr.toLowerCase()) - const chromosome = igvPanel.browser.genome.getChromosome(chr) - if (undefined === chromosome) { - console.warn(`Live Maps are not available for chromosome ${ chr }. No associated genome found`) - return false - } else { - return true - } -} - -function tabEventHandler(event) { - tabAssessment(juiceboxPanelInstance.browser, event.target, juiceboxPanelInstance); -} - -function tabAssessment(browser, activeTabButton, panel) { - - // console.log(`JuiceboxPanel. Tab ${ activeTabButton.id } is active`); - - // Get all canvas containers from inside the viewport - const viewport = browser.layoutController.getContactMatrixViewport() - if (!viewport) { - console.warn('Viewport not found for tab assessment') - return - } - - const hicContainer = viewport.querySelector(`#${browser.id}-contact-map-canvas-container`) - const liveContactContainer = viewport.querySelector(`#${browser.id}-live-contact-map-canvas-container`) - const liveDistanceContainer = viewport.querySelector(`#${browser.id}-live-distance-map-canvas-container`) - - // Hide all containers - if (hicContainer) hicContainer.style.display = 'none' - if (liveContactContainer) liveContactContainer.style.display = 'none' - if (liveDistanceContainer) liveDistanceContainer.style.display = 'none' - - switch (activeTabButton.id) { - case 'spacewalk-juicebox-panel-hic-map-tab': - if (hicContainer) { - hicContainer.style.display = 'block' - // Switch to Hi-C dataset if available - if (panel && panel.hicDataset && panel.hicState) { - browser.setActiveDataset(panel.hicDataset, panel.hicState) - } - // Trigger repaint after showing the viewport to ensure canvas is visible - setTimeout(() => { - if (browser.contactMatrixView && browser.activeDataset) { - browser.contactMatrixView.update().catch(err => console.warn('Error updating contact matrix view:', err)) - } - }, 0) - } - document.getElementById('hic-live-distance-map-toggle-widget').style.display = 'none' - document.getElementById('hic-live-contact-frequency-map-threshold-widget').style.display = 'none' - document.getElementById('hic-file-chooser-dropdown').style.display = 'block' - break; - - case 'spacewalk-juicebox-panel-live-map-tab': - if (liveContactContainer) { - liveContactContainer.style.display = 'block' - // Switch to live map dataset if available - if (panel && panel.liveMapDataset && panel.liveMapState) { - browser.setActiveDataset(panel.liveMapDataset, panel.liveMapState) - } - } - document.getElementById('hic-live-distance-map-toggle-widget').style.display = 'none' - document.getElementById('hic-live-contact-frequency-map-threshold-widget').style.display = 'block' - document.getElementById('hic-file-chooser-dropdown').style.display = 'none' - break; - - case 'spacewalk-juicebox-panel-live-distance-map-tab': - if (liveDistanceContainer) liveDistanceContainer.style.display = 'block' - document.getElementById('hic-live-distance-map-toggle-widget').style.display = 'block' - document.getElementById('hic-live-contact-frequency-map-threshold-widget').style.display = 'none' - document.getElementById('hic-file-chooser-dropdown').style.display = 'none' - break; - - default: - console.log('Unknown tab is active'); - break; - } -} - -function isJSONString(str) { - if (typeof str !== "string") return false; - try { - const parsed = JSON.parse(str); - return typeof parsed === "object" && parsed !== null; - } catch (e) { - return false; - } -} - -export default JuiceboxPanel; diff --git a/spacewalk-code-notes/liveContactMapService.js b/spacewalk-code-notes/liveContactMapService.js deleted file mode 100644 index fdec6c8b..00000000 --- a/spacewalk-code-notes/liveContactMapService.js +++ /dev/null @@ -1,154 +0,0 @@ -import {ensembleManager, juiceboxPanel} from "../app.js" -import EnsembleManager from "../ensembleManager.js" -import SpacewalkEventBus from "../spacewalkEventBus.js" -import {hideGlobalSpinner, showGlobalSpinner} from "../utils/utils.js" -import {clamp} from "../utils/mathUtils.js" -import {enableLiveMaps} from "../utils/liveMapUtils.js" -import {postMessageToWorker} from "../utils/webWorkerUtils.js" - -const maxDistanceThreshold = 1e4 -const defaultDistanceThreshold = 256 - -/** - * Convert Float32Array contact frequencies to contact record format - * @param {Float32Array} contactFrequencies - Array of contact frequencies (traceLength * traceLength) - * @param {number} traceLength - Length of the trace - * @returns {Array} Array of contact records with {bin1, bin2, counts, getKey()} - */ -function convertContactFrequencyArrayToRecords(contactFrequencies, traceLength) { - const records = []; - for (let bin1 = 0; bin1 < traceLength; bin1++) { - for (let bin2 = bin1; bin2 < traceLength; bin2++) { - const index = bin1 * traceLength + bin2; - const count = contactFrequencies[index]; - if (count > 0) { - records.push({ - bin1: bin1, - bin2: bin2, - counts: count, - getKey: function() { - return `${bin1}_${bin2}`; - } - }); - } - } - } - return records; -} - -class LiveContactMapService { - - constructor (distanceThreshold) { - - this.distanceThreshold = distanceThreshold - - this.input = document.querySelector('#spacewalk_contact_frequency_map_adjustment_select_input') - this.input.value = distanceThreshold.toString() - - document.querySelector('#hic-live-contact-frequency-map-threshold-button').addEventListener('click', () => { - - this.distanceThreshold = clamp(parseInt(this.input.value, 10), 0, maxDistanceThreshold) - - window.setTimeout(() => { - this.updateEnsembleContactFrequencyCanvas(this.distanceThreshold) - }, 0) - }) - - this.worker = new Worker(new URL('./liveContactMapWorker.js', import.meta.url), { type: 'module' }) - - SpacewalkEventBus.globalBus.subscribe('DidLoadEnsembleFile', this); - - } - - receiveEvent({ type, data }) { - - if ("DidLoadEnsembleFile" === type) { - - // Safety check: ctx_live may not exist yet if browser isn't fully initialized - if (juiceboxPanel?.browser?.contactMatrixView?.ctx_live) { - juiceboxPanel.browser.contactMatrixView.ctx_live.transferFromImageBitmap(null) - } - - this.contactFrequencies = undefined - this.rgbaMatrix = undefined - - this.distanceThreshold = distanceThresholdEstimate(ensembleManager.currentTrace) - - this.input.value = this.distanceThreshold.toString() - } - } - - setState(distanceThreshold) { - this.distanceThreshold = distanceThreshold - this.input.value = distanceThreshold.toString() - } - - getClassName(){ - return 'LiveContactMapService' - } - - async updateEnsembleContactFrequencyCanvas(distanceThresholdOrUndefined) { - - const status = await enableLiveMaps() - - if (true === status) { - - showGlobalSpinner() - - this.distanceThreshold = distanceThresholdOrUndefined || distanceThresholdEstimate(ensembleManager.currentTrace) - this.input.value = this.distanceThreshold.toString() - - const data = - { - traceOrEnsemble: 'ensemble', - traceLength: ensembleManager.getLiveMapTraceLength(), - vertexListsString: JSON.stringify( ensembleManager.getLiveMapVertexLists()), - distanceThreshold: this.distanceThreshold - } - - let result - try { - console.log(`Live Contact Map ${ data.traceOrEnsemble } payload sent to worker`) - result = await postMessageToWorker(this.worker, data) - hideGlobalSpinner() - } catch (err) { - hideGlobalSpinner() - console.error('Error: Live Contact Map', err) - - } - - const traceLength = ensembleManager.getLiveMapTraceLength() - const arrayLength = traceLength * traceLength * 4 - - if (undefined === this.rgbaMatrix || this.rgbaMatrix.length !== arrayLength) { - this.rgbaMatrix = new Uint8ClampedArray(arrayLength) - } else { - this.rgbaMatrix.fill(0) - } - - this.contactFrequencies = result.workerValuesBuffer - - // Update LiveMapDataset with new contact records - if (juiceboxPanel.browser.activeDataset && - juiceboxPanel.browser.activeDataset.datasetType === 'livemap') { - const contactRecords = convertContactFrequencyArrayToRecords(this.contactFrequencies, traceLength); - // Get binSize from the dataset's bpResolutions - const binSize = juiceboxPanel.browser.activeDataset.bpResolutions[0]; - juiceboxPanel.browser.activeDataset.updateContactRecords(contactRecords, binSize); - } - - await juiceboxPanel.renderLiveMapWithContactData(this.contactFrequencies, this.rgbaMatrix, traceLength) - - } - - } -} - -function distanceThresholdEstimate(trace) { - const { radius } = EnsembleManager.getTraceBounds(trace) - return Math.floor(2 * radius / 4) -} - -export { defaultDistanceThreshold } - -export default LiveContactMapService diff --git a/spacewalk-code-notes/liveContactMapWorker.js b/spacewalk-code-notes/liveContactMapWorker.js deleted file mode 100644 index d3ea0ef3..00000000 --- a/spacewalk-code-notes/liveContactMapWorker.js +++ /dev/null @@ -1,102 +0,0 @@ -import KDBush from '../kd3d/kd3d.js' - -self.addEventListener('message', ({ data }) => { - - const str = `Contact Frequency Map Worker - Calculate Frequency Values` - console.time(str) - - const contactFrequency = new Float32Array(data.traceLength * data.traceLength) - const vertexLists = 'trace' === data.traceOrEnsemble ? [ JSON.parse(data.verticesString) ] : JSON.parse(data.vertexListsString) - calculateContactFrequencies(contactFrequency, data.traceLength, vertexLists, data.distanceThreshold) - - console.timeEnd(str) - - const payload = - { - traceOrEnsemble: data.traceOrEnsemble, - workerValuesBuffer: contactFrequency - } - - self.postMessage(payload, [ contactFrequency.buffer ]) - -}, false) - - -const kContactFrequencyUndefined = -1 - -function calculateContactFrequencies(contactFrequency, traceLength, vertexLists, distanceThreshold) { - contactFrequency.fill(kContactFrequencyUndefined) - for (let vertices of vertexLists) { - accumulateContactFrequencies(contactFrequency, traceLength, vertices, distanceThreshold) - } -} - -function accumulateContactFrequencies(contactFrequency, traceLength, vertices, distanceThreshold) { - - const exclusionSet = new Set(); - - const validVertices = [] - const validIndices = [] - - for (let i = 0; i < vertices.length; i++) { - if (true === vertices[ i ].isMissingData) { - // ignore - } else { - validIndices.push(i) - validVertices.push(vertices[ i ]) - } - } - - const spatialIndex = new KDBush(kdBushConfiguratorWithTrace(validVertices)) - - for (let v = 0; v < validVertices.length; v++) { - - const x = validIndices[ v ] - const xy_diagonal = x * traceLength + x - contactFrequency[ xy_diagonal ] = 1 - - exclusionSet.add(v) - const contactIndices = spatialIndex.within(validVertices[ v ].x, validVertices[ v ].y, validVertices[ v ].z, distanceThreshold).filter(index => !exclusionSet.has(index)) - - if (contactIndices.length > 0) { - - for (let contactIndex of contactIndices) { - - const y = validIndices[ contactIndex ] - - const xy = x * traceLength + y - const yx = y * traceLength + x - - if (xy > contactFrequency.length) { - console.error(`xy ${xy} is an invalid index for array of length ${ contactFrequency.length }`) - } - - if (yx > contactFrequency.length) { - console.error(`yx ${yx} is an invalid index for array of length ${ contactFrequency.length }`) - } - - contactFrequency[ xy ] = kContactFrequencyUndefined === contactFrequency[ xy ] ? 1 : 1 + contactFrequency[ xy ] - contactFrequency[ yx ] = contactFrequency[ xy ] - - } // for (contactIndices) - - } // if (contactIndices.length > 0) - - } // for (v) - -} - -function kdBushConfiguratorWithTrace(vertices) { - - return { - idList: vertices.map((vertex, index) => index), - points: vertices, - getX: pt => pt.x, - getY: pt => pt.y, - getZ: pt => pt.z, - nodeSize: 64, - ArrayType: Float64Array, - axisCount: 3 - } - -} diff --git a/spacewalk-code-notes/liveDistanceMapService.js b/spacewalk-code-notes/liveDistanceMapService.js deleted file mode 100644 index cd2f482b..00000000 --- a/spacewalk-code-notes/liveDistanceMapService.js +++ /dev/null @@ -1,266 +0,0 @@ -import {ensembleManager, juiceboxPanel} from "../app.js"; -import { clamp } from "../utils/mathUtils.js"; -import {appleCrayonColorRGB255, rgb255String} from "../utils/colorUtils.js"; -import { hideGlobalSpinner, showGlobalSpinner } from "../utils/utils.js" -import {compositeColors} from "../utils/colorUtils.js" -import SpacewalkEventBus from "../spacewalkEventBus.js" -import {enableLiveMaps} from "../utils/liveMapUtils.js" -import {postMessageToWorker} from "../utils/webWorkerUtils.js" - -const kDistanceUndefined = -1 - -class LiveDistanceMapService { - - constructor () { - - this.configureMouseHandlers() - - this.worker = new Worker(new URL('./liveDistanceMapWorker.js', import.meta.url), {type: 'module'}) - - this.worker.addEventListener('message', async ( { data }) => { - await processWebWorkerResult.call(this, data) - }, false) - - SpacewalkEventBus.globalBus.subscribe('DidSelectTrace', this); - SpacewalkEventBus.globalBus.subscribe('DidLoadEnsembleFile', this); - - } - - configureMouseHandlers(){ - - this.ensembleToggleElement = juiceboxPanel.panel.querySelector('#spacewalk-live-distance-map-toggle-ensemble') - - this.ensembleToggleElement.addEventListener('click', () => { - const liveMapTraceLength = ensembleManager.getLiveMapTraceLength() - const liveMapVertexLists = ensembleManager.getLiveMapVertexLists() - this.updateEnsembleAverageDistanceCanvas(liveMapTraceLength) - }) - - this.traceToggleElement = juiceboxPanel.panel.querySelector('#spacewalk-live-distance-map-toggle-trace') - this.traceToggleElement.addEventListener('click', () => { - const liveMapTraceLength = ensembleManager.getLiveMapTraceLength() - this.updateTraceDistanceCanvas(liveMapTraceLength, ensembleManager.currentTrace) - }) - - juiceboxPanel.panel.querySelector('#hic-calculation-live-distance-button').addEventListener('click', event => { - if (this.isEnsembleToggleChecked()) { - const liveMapTraceLength = ensembleManager.getLiveMapTraceLength() - const liveMapVertexLists = ensembleManager.getLiveMapVertexLists() - this.updateEnsembleAverageDistanceCanvas(liveMapTraceLength) - } else if (this.isTraceToggleChecked()) { - const liveMapTraceLength = ensembleManager.getLiveMapTraceLength() - this.updateTraceDistanceCanvas(liveMapTraceLength, ensembleManager.currentTrace) - } - }) - - } - - isTraceToggleChecked() { - return true === this.traceToggleElement.checked - } - - isEnsembleToggleChecked() { - return true === this.ensembleToggleElement.checked - } - - receiveEvent({ type, data }) { - - if ("DidLoadEnsembleFile" === type) { - // console.log('LiveDistanceMapService - receiveEvent(DidLoadEnsembleFile)') - - // Safety check: ctx_live_distance may not exist yet if browser isn't fully initialized - if (juiceboxPanel?.browser?.contactMatrixView?.ctx_live_distance) { - juiceboxPanel.browser.contactMatrixView.ctx_live_distance.transferFromImageBitmap(null) - } - - this.rgbaMatrix = undefined - this.distances = undefined - this.maxDistance = undefined - - this.traceToggleElement.checked = false - this.ensembleToggleElement.checked = false - - } else if ("DidSelectTrace" === type) { - - window.setTimeout(() => { - - if (false === juiceboxPanel.isHidden && juiceboxPanel.isActiveTab(juiceboxPanel.liveDistanceMapTab)) { - console.log('LiveDistanceMapService - receiveEvent(DidSelectTrace)') - this.updateTraceDistanceCanvas(ensembleManager.getLiveMapTraceLength(), ensembleManager.currentTrace) - this.traceToggleElement.checked = true - this.ensembleToggleElement.checked = false - } - - }, 0) - - } - - } - - getClassName(){ - return 'LiveDistanceMapService' - } - - async updateTraceDistanceCanvas(traceLength, trace) { - - const status = await enableLiveMaps() - - if (true === status) { - - showGlobalSpinner() - - const vertices = ensembleManager.getLiveMapTraceVertices(trace) - - const data = - { - traceOrEnsemble: 'trace', - traceLength, - verticesString: JSON.stringify(vertices), - } - - await this.updateDistanceCanvas(data) - } - - } - - async updateEnsembleAverageDistanceCanvas(traceLength) { - - const status = await enableLiveMaps() - - if (true === status) { - - showGlobalSpinner() - - const vertexLists = ensembleManager.getLiveMapVertexLists() - - const data = - { - traceOrEnsemble: 'ensemble', - traceLength, - vertexListsString: JSON.stringify(vertexLists) - } - - await this.updateDistanceCanvas(data) - } - - } - - async updateDistanceCanvas(data) { - - showGlobalSpinner() - - let result - try { - console.log(`Live Distance Map ${ data.traceOrEnsemble } payload sent to worker`) - result = await postMessageToWorker(this.worker, data) - hideGlobalSpinner() - } catch (err) { - hideGlobalSpinner() - console.error('Error: Live Contact Map', err) - - } - - await processWebWorkerResult.call(this, result) - - } - -} - -async function processWebWorkerResult(result) { - - const traceLength = ensembleManager.getLiveMapTraceLength() - const arrayLength = traceLength * traceLength * 4 - - if (undefined === this.rgbaMatrix || this.rgbaMatrix.length !== arrayLength) { - this.rgbaMatrix = new Uint8ClampedArray(arrayLength) - } else { - this.rgbaMatrix.fill(0) - } - - this.distances = result.workerDistanceBuffer - this.maxDistance = result.maxDistance - await juiceboxPanel.renderLiveMapWithDistanceData(this.distances, this.maxDistance, this.rgbaMatrix, ensembleManager.getLiveMapTraceLength()) - -} - -function setupOffScreenCanvas(width, height, rgb255){ - - const offscreenCanvas = document.createElement('canvas') - offscreenCanvas.width = width - offscreenCanvas.height = height - - const ctx2d = offscreenCanvas.getContext('2d') - ctx2d.fillStyle = rgb255String(rgb255) - ctx2d.fillRect(0, 0, width, height) - return {offscreenCanvas, ctx2d} -} - -async function renderLiveMapWithDistanceData(browser, distances, maxDistance, rgbaMatrix, liveMapTraceLength) { - - // Refer to destination canvas - const distanceMapCanvas = browser.contactMatrixView.ctx_live_distance.canvas - - // Set up offscreen canvas for compositing with initial background color - const {offscreenCanvas, ctx2d} = setupOffScreenCanvas(distanceMapCanvas.width, distanceMapCanvas.height, appleCrayonColorRGB255('tin')) - - // Paint foreground color - with alpha - into rgbaMatrix - paintDistanceMapRGBAMatrix(distances, maxDistance, rgbaMatrix, browser.contactMatrixView.colorScale, browser.contactMatrixView.backgroundColor) - - // Composite foreground over background - const imageBitmap = await createImageBitmap(new ImageData(rgbaMatrix, liveMapTraceLength, liveMapTraceLength)) - - // draw imageBitmap into distanceMapCanvas context while simultaneously scaling up the imageBitmap - // to the resolution of the distanceMapCanvas - ctx2d.drawImage(imageBitmap, 0, 0, distanceMapCanvas.width, distanceMapCanvas.height) - - // Retrieve compositedImageBitmap and transfer to distanceMapCanvas via it's context - const compositedImageBitmap = await createImageBitmap(offscreenCanvas) - const ctx = browser.contactMatrixView.ctx_live_distance - ctx.transferFromImageBitmap(compositedImageBitmap); - -} - -function paintDistanceMapRGBAMatrix(distances, maxDistance, rgbaMatrix, colorScale, backgroundRGB) { - - let i = 0; - const { r, g, b } = colorScale.getColorComponents() - - for (let distance of distances) { - - if (kDistanceUndefined !== distance) { - - distance = clamp(distance, 0, maxDistance) - const nearness = maxDistance - distance - - const rawInterpolant = nearness/maxDistance - if (rawInterpolant < 0 || 1 < rawInterpolant) { - console.warn(`${ Date.now() } populateCanvasArray - interpolant out of range ${ rawInterpolant }`) - } - - const alpha = Math.floor(255 * clamp(nearness, 0, maxDistance) / maxDistance) - const foregroundRGBA = { r, g, b, a:alpha } - - const { r:comp_r, g:comp_g, b:comp_b } = compositeColors(foregroundRGBA, backgroundRGB) - - rgbaMatrix[i ] = comp_r; - rgbaMatrix[i + 1] = comp_g; - rgbaMatrix[i + 2] = comp_b; - rgbaMatrix[i + 3] = 255; - - } else { - - rgbaMatrix[i ] = 0; - rgbaMatrix[i + 1] = 0; - rgbaMatrix[i + 2] = 0; - rgbaMatrix[i + 3] = 0; - - } - - i += 4; - } - -} - -export { renderLiveMapWithDistanceData } - -export default LiveDistanceMapService diff --git a/spacewalk-code-notes/liveDistanceMapWorker.js b/spacewalk-code-notes/liveDistanceMapWorker.js deleted file mode 100644 index 19e7f2a3..00000000 --- a/spacewalk-code-notes/liveDistanceMapWorker.js +++ /dev/null @@ -1,156 +0,0 @@ -import { distanceTo } from '../utils/mathUtils.js' - -self.addEventListener('message', ({ data }) => { - - if ('trace' === data.traceOrEnsemble) { - - const str = `Distance Map Worker - Update Trace Distance Array` - console.time(str) - - const vertices = JSON.parse(data.verticesString) - const { maxDistance, distances } = updateTraceDistanceArray(data.traceLength, vertices) - - console.timeEnd(str) - - const payload = - { - traceOrEnsemble: data.traceOrEnsemble, - workerDistanceBuffer: distances, - maxDistance - } - - self.postMessage(payload, [ distances.buffer ]) - - } else { - - const str = `Distance Map Worker - Update Ensemble Distance Array` - console.time(str); - - const vertexLists = JSON.parse(data.vertexListsString) - const { maxAverageDistance, averages } = updateEnsembleDistanceArray(data.traceLength, vertexLists) - - console.timeEnd(str) - - const payload = - { - traceOrEnsemble: data.traceOrEnsemble, - workerDistanceBuffer: averages, - maxDistance: maxAverageDistance - } - - self.postMessage(payload, [ averages.buffer ]) - - } - -}, false) - -const kDistanceUndefined = -1; - -function updateTraceDistanceArray(traceLength, vertices) { - - const distances = new Float32Array(traceLength * traceLength) - distances.fill(kDistanceUndefined) - - const validVertices = [] - const validIndices = [] - - for (let i = 0; i < vertices.length; i++) { - if (true === vertices[ i ].isMissingData) { - // ignore - } else { - validIndices.push(i) - validVertices.push(vertices[ i ]) - } - } - - let maxDistance = Number.NEGATIVE_INFINITY; - - let exclusionSet = new Set(); - - for (let v = 0; v < validVertices.length; v++) { - - const x = validIndices[ v ] - - const xy_diagonal = x * traceLength + x - - distances[ xy_diagonal ] = 0 - - exclusionSet.add(v) - for (let w = 0; w < validVertices.length; w++) { - - - if (false === exclusionSet.has(w)) { - - const distance = distanceTo(validVertices[ v ], validVertices[ w ]) - - const y = validIndices[ w ] - - const ij = x * traceLength + y - const ji = y * traceLength + x - - distances[ ij ] = distances[ ji ] = distance - - maxDistance = Math.max(maxDistance, distance) - } - - } - - } - - return { maxDistance, distances } - -} - -function updateEnsembleDistanceArray(traceLength, vertexLists) { - - const averages = new Float32Array(traceLength * traceLength) - averages.fill(kDistanceUndefined) - - const counters = new Int32Array(traceLength * traceLength) - counters.fill(0) - - for (let vertices of vertexLists) { - - const { maxDistance, distances } = updateTraceDistanceArray(traceLength, vertices) - - // We need to calculate an array of averages where the input data - // can have missing - kDistanceUndefined - values - - // loop over distance array - for (let d = 0; d < distances.length; d++) { - - // ignore missing data values. they do not participate in the average - if (kDistanceUndefined === distances[ d ]) { - // do nothing - } else { - - // keep track of how many samples we have at this array index - ++counters[ d ]; - - if (kDistanceUndefined === averages[ d ]) { - - // If this is the first data value at this array index copy it to average. - averages[ d ] = distances[ d ]; - } else { - - // when there is data AND a pre-existing average value at this array index - // use an incremental averaging approach. - - // Incremental averaging: avg_k = avg_k-1 + (distance_k - avg_k-1) / k - // https://math.stackexchange.com/questions/106700/incremental-averageing - averages[ d ] = averages[ d ] + (distances[ d ] - averages[ d ]) / counters[ d ]; - } - - } - } - - } - - let maxAverageDistance = Number.NEGATIVE_INFINITY; - for (let avg of averages) { - maxAverageDistance = Math.max(maxAverageDistance, avg) - } - - return { maxAverageDistance, averages } -} - From bd7b5191663df9e4cb500672178288d15715302f Mon Sep 17 00:00:00 2001 From: turner Date: Fri, 7 Nov 2025 14:44:28 -0500 Subject: [PATCH 02/16] fowler refactors: phase 1 --- examples/juicebox-api.html | 2 +- js/hicBrowser.js | 291 ++++++++++++++++++++++++------------- 2 files changed, 189 insertions(+), 104 deletions(-) diff --git a/examples/juicebox-api.html b/examples/juicebox-api.html index dd11dcb8..9111a19d 100644 --- a/examples/juicebox-api.html +++ b/examples/juicebox-api.html @@ -10,7 +10,7 @@ - + diff --git a/js/hicBrowser.js b/js/hicBrowser.js index f8c6ae4d..973158e2 100644 --- a/js/hicBrowser.js +++ b/js/hicBrowser.js @@ -366,11 +366,19 @@ class HICBrowser { * These methods directly call components that need to be notified of state changes. */ - notifyMapLoaded(dataset, state, datasetType) { - - const data = { dataset, state, datasetType }; + /** + * Private helper: Paint a color swatch with the given RGB color. + * Extracted to eliminate duplication across notification methods. + */ + _paintSwatch(swatch, { r, g, b }) { + swatch.style.backgroundColor = IGVColor.rgbToHex(IGVColor.rgbColor(r, g, b)); + } - // ContactMatrixView needs to enable mouse handlers and clear caches + /** + * Private helper: Initialize ContactMatrixView when a map is loaded. + * Enables mouse handlers and clears caches. + */ + _initializeContactMatrixViewForMapLoad() { if (!this.contactMatrixView.mouseHandlersEnabled) { this.contactMatrixView.addTouchHandlers(this.contactMatrixView.viewportElement); this.contactMatrixView.addMouseHandlers(this.contactMatrixView.viewportElement); @@ -378,48 +386,86 @@ class HICBrowser { } this.contactMatrixView.clearImageCaches(); this.contactMatrixView.colorScaleThresholdCache = {}; + } - // Update UI components + /** + * Private helper: Update chromosome selector when a map is loaded. + */ + _updateChromosomeSelectorForMapLoad(dataset) { const chromosomeSelector = this.ui.getComponent('chromosomeSelector'); if (chromosomeSelector) { chromosomeSelector.respondToDataLoadWithDataset(dataset); } + } - const ruler = this.layoutController.xAxisRuler; - if (ruler) { - ruler.wholeGenomeLayout(ruler.axisElement, ruler.wholeGenomeContainerElement, ruler.axis, dataset); - ruler.update(); + /** + * Private helper: Update rulers when a map is loaded. + */ + _updateRulersForMapLoad(dataset) { + const xRuler = this.layoutController.xAxisRuler; + if (xRuler) { + xRuler.wholeGenomeLayout(xRuler.axisElement, xRuler.wholeGenomeContainerElement, xRuler.axis, dataset); + xRuler.update(); } const yRuler = this.layoutController.yAxisRuler; if (yRuler) { yRuler.wholeGenomeLayout(yRuler.axisElement, yRuler.wholeGenomeContainerElement, yRuler.axis, dataset); yRuler.update(); } + } + /** + * Private helper: Update normalization widget when a map is loaded. + */ + _updateNormalizationWidgetForMapLoad(data) { const normalizationWidget = this.ui.getComponent('normalization'); if (normalizationWidget) { normalizationWidget.receiveEvent({ type: "MapLoad", data }); } + } + /** + * Private helper: Update resolution selector when a map is loaded. + */ + _updateResolutionSelectorForMapLoad() { const resolutionSelector = this.ui.getComponent('resolutionSelector'); if (resolutionSelector) { this.resolutionLocked = false; resolutionSelector.setResolutionLock(false); resolutionSelector.updateResolutions(this.state.zoom); } + } + /** + * Private helper: Update color scale widget when a map is loaded. + */ + _updateColorScaleWidgetForMapLoad() { const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); if (colorScaleWidget && colorScaleWidget.mapBackgroundColorpickerButton) { - const paintSwatch = (swatch, { r, g, b }) => { - swatch.style.backgroundColor = IGVColor.rgbToHex(IGVColor.rgbColor(r, g, b)); - }; - paintSwatch(colorScaleWidget.mapBackgroundColorpickerButton, this.contactMatrixView.backgroundColor); + this._paintSwatch(colorScaleWidget.mapBackgroundColorpickerButton, this.contactMatrixView.backgroundColor); } + } + /** + * Private helper: Update control map widget when a map is loaded. + */ + _updateControlMapWidgetForMapLoad() { const controlMapWidget = this.ui.getComponent('controlMap'); if (controlMapWidget && !this.controlDataset) { controlMapWidget.container.style.display = 'none'; } + } + + notifyMapLoaded(dataset, state, datasetType) { + const data = { dataset, state, datasetType }; + + this._initializeContactMatrixViewForMapLoad(); + this._updateChromosomeSelectorForMapLoad(dataset); + this._updateRulersForMapLoad(dataset); + this._updateNormalizationWidgetForMapLoad(data); + this._updateResolutionSelectorForMapLoad(); + this._updateColorScaleWidgetForMapLoad(); + this._updateControlMapWidgetForMapLoad(); // Note: locusGoto is notified via notifyLocusChange() which is called from setState() // after the locus is properly configured. Don't notify here as state.locus might not exist yet. @@ -442,52 +488,75 @@ class HICBrowser { this.contactMatrixView.colorScaleThresholdCache = {}; } - notifyLocusChange(eventData) { - const { state, resolutionChanged, chrChanged, dragging } = eventData; - - // ContactMatrixView - only clear caches if not a locus change - // (locus changes don't require cache clearing) - - // ChromosomeSelector + /** + * Private helper: Update chromosome selector when locus changes. + */ + _updateChromosomeSelectorForLocusChange(state) { const chromosomeSelector = this.ui.getComponent('chromosomeSelector'); if (chromosomeSelector) { chromosomeSelector.respondToLocusChangeWithState(state); } + } - // ScrollbarWidget + /** + * Private helper: Update scrollbar widget when locus changes. + */ + _updateScrollbarForLocusChange(state) { const scrollbarWidget = this.ui.getComponent('scrollbar'); if (scrollbarWidget && !scrollbarWidget.isDragging) { scrollbarWidget.receiveEvent({ type: "LocusChange", data: { state } }); } + } - // ResolutionSelector + /** + * Private helper: Update resolution selector when locus changes. + */ + _updateResolutionSelectorForLocusChange(state, resolutionChanged, chrChanged) { const resolutionSelector = this.ui.getComponent('resolutionSelector'); - if (resolutionSelector) { - if (resolutionChanged) { - this.resolutionLocked = false; - resolutionSelector.setResolutionLock(false); - } + if (!resolutionSelector) { + return; + } - if (chrChanged !== false) { - const isWholeGenome = this.dataset.isWholeGenome(state.chr1); - const labelElement = resolutionSelector.labelElement; - if (labelElement) { - labelElement.textContent = isWholeGenome ? 'Resolution (mb)' : 'Resolution (kb)'; - } - resolutionSelector.updateResolutions(state.zoom); - } else { - const selectedIndex = state.zoom; - Array.from(resolutionSelector.resolutionSelectorElement.options).forEach((option, index) => { - option.selected = index === selectedIndex; - }); + if (resolutionChanged) { + this.resolutionLocked = false; + resolutionSelector.setResolutionLock(false); + } + + if (chrChanged !== false) { + const isWholeGenome = this.dataset.isWholeGenome(state.chr1); + const labelElement = resolutionSelector.labelElement; + if (labelElement) { + labelElement.textContent = isWholeGenome ? 'Resolution (mb)' : 'Resolution (kb)'; } + resolutionSelector.updateResolutions(state.zoom); + } else { + const selectedIndex = state.zoom; + Array.from(resolutionSelector.resolutionSelectorElement.options).forEach((option, index) => { + option.selected = index === selectedIndex; + }); } + } - // LocusGoto + /** + * Private helper: Update locus goto widget when locus changes. + */ + _updateLocusGotoForLocusChange(state) { const locusGoto = this.ui.getComponent('locusGoto'); if (locusGoto) { locusGoto.receiveEvent({ type: "LocusChange", data: { state } }); } + } + + notifyLocusChange(eventData) { + const { state, resolutionChanged, chrChanged, dragging } = eventData; + + // ContactMatrixView - only clear caches if not a locus change + // (locus changes don't require cache clearing) + + this._updateChromosomeSelectorForLocusChange(state); + this._updateScrollbarForLocusChange(state); + this._updateResolutionSelectorForLocusChange(state, resolutionChanged, chrChanged); + this._updateLocusGotoForLocusChange(state); // Rulers are updated directly in update() method, not here } @@ -499,46 +568,69 @@ class HICBrowser { // NormalizationWidget - no direct notification needed, it updates via selector change } - notifyDisplayMode(mode) { + /** + * Private helper: Update color scale widget for display mode changes. + */ + _updateColorScaleWidgetForDisplayMode(mode) { const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); - if (colorScaleWidget && colorScaleWidget.minusButton && colorScaleWidget.plusButton) { - const paintSwatch = (swatch, { r, g, b }) => { - swatch.style.backgroundColor = IGVColor.rgbToHex(IGVColor.rgbColor(r, g, b)); - }; + if (!colorScaleWidget || !colorScaleWidget.minusButton || !colorScaleWidget.plusButton) { + return; + } - if (mode === "AOB" || mode === "BOA") { - colorScaleWidget.minusButton.style.display = 'block'; - paintSwatch(colorScaleWidget.minusButton, this.contactMatrixView.ratioColorScale.negativeScale); - paintSwatch(colorScaleWidget.plusButton, this.contactMatrixView.ratioColorScale.positiveScale); - } else { - colorScaleWidget.minusButton.style.display = 'none'; - paintSwatch(colorScaleWidget.plusButton, this.contactMatrixView.colorScale); - } + if (mode === "AOB" || mode === "BOA") { + colorScaleWidget.minusButton.style.display = 'block'; + this._paintSwatch(colorScaleWidget.minusButton, this.contactMatrixView.ratioColorScale.negativeScale); + this._paintSwatch(colorScaleWidget.plusButton, this.contactMatrixView.ratioColorScale.positiveScale); + } else { + colorScaleWidget.minusButton.style.display = 'none'; + this._paintSwatch(colorScaleWidget.plusButton, this.contactMatrixView.colorScale); } + } + /** + * Private helper: Update control map widget for display mode changes. + */ + _updateControlMapWidgetForDisplayMode(mode) { const controlMapWidget = this.ui.getComponent('controlMap'); if (controlMapWidget) { controlMapWidget.controlMapHash.updateOptions(mode); } } + notifyDisplayMode(mode) { + this._updateColorScaleWidgetForDisplayMode(mode); + this._updateControlMapWidgetForDisplayMode(mode); + } + + /** + * Private helper: Update color scale widget for standard color scale. + */ + _updateColorScaleWidgetForStandardScale(colorScaleWidget, colorScale) { + colorScaleWidget.highColorscaleInput.value = colorScale.threshold; + this._paintSwatch(colorScaleWidget.plusButton, colorScale); + } + + /** + * Private helper: Update color scale widget for ratio color scale. + */ + _updateColorScaleWidgetForRatioScale(colorScaleWidget, ratioColorScale) { + colorScaleWidget.highColorscaleInput.value = ratioColorScale.threshold; + if (colorScaleWidget.minusButton) { + this._paintSwatch(colorScaleWidget.minusButton, ratioColorScale.negativeScale); + } + this._paintSwatch(colorScaleWidget.plusButton, ratioColorScale.positiveScale); + } + notifyColorScale(colorScale) { const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); - if (colorScaleWidget && colorScaleWidget.highColorscaleInput && colorScaleWidget.plusButton) { - const paintSwatch = (swatch, { r, g, b }) => { - swatch.style.backgroundColor = IGVColor.rgbToHex(IGVColor.rgbColor(r, g, b)); - }; + if (!colorScaleWidget || !colorScaleWidget.highColorscaleInput || !colorScaleWidget.plusButton) { + return; + } - if (colorScale instanceof ColorScale) { - colorScaleWidget.highColorscaleInput.value = colorScale.threshold; - paintSwatch(colorScaleWidget.plusButton, colorScale); - } else if (colorScale instanceof RatioColorScale) { - colorScaleWidget.highColorscaleInput.value = colorScale.threshold; - if (colorScaleWidget.minusButton) { - paintSwatch(colorScaleWidget.minusButton, colorScale.negativeScale); - } - paintSwatch(colorScaleWidget.plusButton, colorScale.positiveScale); - } + if (colorScale instanceof ColorScale) { + this._updateColorScaleWidgetForStandardScale(colorScaleWidget, colorScale); + } else if (colorScale instanceof RatioColorScale) { + this._updateColorScaleWidgetForRatioScale(colorScaleWidget, colorScale); } } @@ -582,47 +674,40 @@ class HICBrowser { this.contactMatrixView.receiveEvent({ type: "ColorChange" }); } - notifyUpdateContactMapMousePosition(xy) { - const ruler = this.layoutController.xAxisRuler; - if (ruler && ruler.bboxes) { - ruler.unhighlightWholeChromosome(); - const offset = ruler.axis === 'x' ? xy.x : xy.y; - const hitTest = (bboxes, value) => { - let hitElement = undefined; - for (const bbox of bboxes) { - if (value >= bbox.a && value <= bbox.b) { - hitElement = bbox.element; - break; - } - } - return hitElement; - }; - const element = hitTest(ruler.bboxes, offset); - if (element) { - element.classList.add('hic-whole-genome-chromosome-highlight'); + /** + * Private helper: Find the element that contains the given offset value. + * Used for highlighting chromosomes in whole-genome view. + */ + _hitTestBbox(bboxes, value) { + for (const bbox of bboxes) { + if (value >= bbox.a && value <= bbox.b) { + return bbox.element; } } - const yRuler = this.layoutController.yAxisRuler; - if (yRuler && yRuler.bboxes) { - yRuler.unhighlightWholeChromosome(); - const offset = yRuler.axis === 'x' ? xy.x : xy.y; - const hitTest = (bboxes, value) => { - let hitElement = undefined; - for (const bbox of bboxes) { - if (value >= bbox.a && value <= bbox.b) { - hitElement = bbox.element; - break; - } - } - return hitElement; - }; - const element = hitTest(yRuler.bboxes, offset); - if (element) { - element.classList.add('hic-whole-genome-chromosome-highlight'); - } + return undefined; + } + + /** + * Private helper: Update ruler highlighting for mouse position. + */ + _updateRulerHighlightingForMousePosition(ruler, xy) { + if (!ruler || !ruler.bboxes) { + return; + } + + ruler.unhighlightWholeChromosome(); + const offset = ruler.axis === 'x' ? xy.x : xy.y; + const element = this._hitTestBbox(ruler.bboxes, offset); + if (element) { + element.classList.add('hic-whole-genome-chromosome-highlight'); } } + notifyUpdateContactMapMousePosition(xy) { + this._updateRulerHighlightingForMousePosition(this.layoutController.xAxisRuler, xy); + this._updateRulerHighlightingForMousePosition(this.layoutController.yAxisRuler, xy); + } + showCrosshairs() { this.contactMatrixView.xGuideElement.style.display = 'block'; this.layoutController.xTrackGuideElement.style.display = 'block'; From 57193fd15af811566c2590223af78bb627104abb Mon Sep 17 00:00:00 2001 From: turner Date: Fri, 7 Nov 2025 14:49:29 -0500 Subject: [PATCH 03/16] fowler refactors: phase 2 --- examples/juicebox-minimal.html | 13 +++--- js/controlMapWidget.js | 22 ++++++++++ js/hicBrowser.js | 76 +++++++--------------------------- js/hicColorScaleWidget.js | 54 ++++++++++++++++++++++++ js/hicResolutionSelector.js | 20 +++++++++ 5 files changed, 117 insertions(+), 68 deletions(-) diff --git a/examples/juicebox-minimal.html b/examples/juicebox-minimal.html index 8626591b..d571a2e3 100644 --- a/examples/juicebox-minimal.html +++ b/examples/juicebox-minimal.html @@ -9,7 +9,7 @@ - + @@ -19,18 +19,15 @@ diff --git a/js/controlMapWidget.js b/js/controlMapWidget.js index fb13ebfa..05e0aaac 100644 --- a/js/controlMapWidget.js +++ b/js/controlMapWidget.js @@ -76,6 +76,28 @@ class ControlMapWidget { getDisplayModeCycle() { return this.controlMapHash.cycleID; } + + /** + * Hide the control map widget container. + */ + hide() { + this.container.style.display = 'none'; + } + + /** + * Show the control map widget container. + */ + show() { + this.container.style.display = 'block'; + } + + /** + * Update the display mode options. + * @param {string} displayMode - The current display mode + */ + updateDisplayMode(displayMode) { + this.controlMapHash.updateOptions(displayMode); + } } class ControlMapHash { diff --git a/js/hicBrowser.js b/js/hicBrowser.js index 973158e2..ead8e1dd 100644 --- a/js/hicBrowser.js +++ b/js/hicBrowser.js @@ -366,14 +366,6 @@ class HICBrowser { * These methods directly call components that need to be notified of state changes. */ - /** - * Private helper: Paint a color swatch with the given RGB color. - * Extracted to eliminate duplication across notification methods. - */ - _paintSwatch(swatch, { r, g, b }) { - swatch.style.backgroundColor = IGVColor.rgbToHex(IGVColor.rgbColor(r, g, b)); - } - /** * Private helper: Initialize ContactMatrixView when a map is loaded. * Enables mouse handlers and clears caches. @@ -441,8 +433,8 @@ class HICBrowser { */ _updateColorScaleWidgetForMapLoad() { const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); - if (colorScaleWidget && colorScaleWidget.mapBackgroundColorpickerButton) { - this._paintSwatch(colorScaleWidget.mapBackgroundColorpickerButton, this.contactMatrixView.backgroundColor); + if (colorScaleWidget) { + colorScaleWidget.updateMapBackgroundColor(this.contactMatrixView.backgroundColor); } } @@ -452,7 +444,7 @@ class HICBrowser { _updateControlMapWidgetForMapLoad() { const controlMapWidget = this.ui.getComponent('controlMap'); if (controlMapWidget && !this.controlDataset) { - controlMapWidget.container.style.display = 'none'; + controlMapWidget.hide(); } } @@ -474,8 +466,8 @@ class HICBrowser { notifyControlMapLoaded(controlDataset) { const controlMapWidget = this.ui.getComponent('controlMap'); if (controlMapWidget) { - controlMapWidget.controlMapHash.updateOptions(this.getDisplayMode()); - controlMapWidget.container.style.display = 'block'; + controlMapWidget.updateDisplayMode(this.getDisplayMode()); + controlMapWidget.show(); } const resolutionSelector = this.ui.getComponent('resolutionSelector'); @@ -524,16 +516,10 @@ class HICBrowser { if (chrChanged !== false) { const isWholeGenome = this.dataset.isWholeGenome(state.chr1); - const labelElement = resolutionSelector.labelElement; - if (labelElement) { - labelElement.textContent = isWholeGenome ? 'Resolution (mb)' : 'Resolution (kb)'; - } + resolutionSelector.updateLabelForWholeGenome(isWholeGenome); resolutionSelector.updateResolutions(state.zoom); } else { - const selectedIndex = state.zoom; - Array.from(resolutionSelector.resolutionSelectorElement.options).forEach((option, index) => { - option.selected = index === selectedIndex; - }); + resolutionSelector.setSelectedResolution(state.zoom); } } @@ -573,17 +559,12 @@ class HICBrowser { */ _updateColorScaleWidgetForDisplayMode(mode) { const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); - if (!colorScaleWidget || !colorScaleWidget.minusButton || !colorScaleWidget.plusButton) { - return; - } - - if (mode === "AOB" || mode === "BOA") { - colorScaleWidget.minusButton.style.display = 'block'; - this._paintSwatch(colorScaleWidget.minusButton, this.contactMatrixView.ratioColorScale.negativeScale); - this._paintSwatch(colorScaleWidget.plusButton, this.contactMatrixView.ratioColorScale.positiveScale); - } else { - colorScaleWidget.minusButton.style.display = 'none'; - this._paintSwatch(colorScaleWidget.plusButton, this.contactMatrixView.colorScale); + if (colorScaleWidget) { + colorScaleWidget.updateForDisplayMode( + mode, + this.contactMatrixView.ratioColorScale, + this.contactMatrixView.colorScale + ); } } @@ -593,7 +574,7 @@ class HICBrowser { _updateControlMapWidgetForDisplayMode(mode) { const controlMapWidget = this.ui.getComponent('controlMap'); if (controlMapWidget) { - controlMapWidget.controlMapHash.updateOptions(mode); + controlMapWidget.updateDisplayMode(mode); } } @@ -602,35 +583,10 @@ class HICBrowser { this._updateControlMapWidgetForDisplayMode(mode); } - /** - * Private helper: Update color scale widget for standard color scale. - */ - _updateColorScaleWidgetForStandardScale(colorScaleWidget, colorScale) { - colorScaleWidget.highColorscaleInput.value = colorScale.threshold; - this._paintSwatch(colorScaleWidget.plusButton, colorScale); - } - - /** - * Private helper: Update color scale widget for ratio color scale. - */ - _updateColorScaleWidgetForRatioScale(colorScaleWidget, ratioColorScale) { - colorScaleWidget.highColorscaleInput.value = ratioColorScale.threshold; - if (colorScaleWidget.minusButton) { - this._paintSwatch(colorScaleWidget.minusButton, ratioColorScale.negativeScale); - } - this._paintSwatch(colorScaleWidget.plusButton, ratioColorScale.positiveScale); - } - notifyColorScale(colorScale) { const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); - if (!colorScaleWidget || !colorScaleWidget.highColorscaleInput || !colorScaleWidget.plusButton) { - return; - } - - if (colorScale instanceof ColorScale) { - this._updateColorScaleWidgetForStandardScale(colorScaleWidget, colorScale); - } else if (colorScale instanceof RatioColorScale) { - this._updateColorScaleWidgetForRatioScale(colorScaleWidget, colorScale); + if (colorScaleWidget) { + colorScaleWidget.updateForColorScale(colorScale); } } diff --git a/js/hicColorScaleWidget.js b/js/hicColorScaleWidget.js index 7a4c61d8..b63ee5f5 100644 --- a/js/hicColorScaleWidget.js +++ b/js/hicColorScaleWidget.js @@ -105,6 +105,60 @@ class ColorScaleWidget { paintSwatch(this.mapBackgroundColorpickerButton, browser.contactMatrixView.backgroundColor); }); } + + /** + * Update the map background color swatch. + * @param {{r: number, g: number, b: number}} backgroundColor - RGB color object + */ + updateMapBackgroundColor(backgroundColor) { + if (this.mapBackgroundColorpickerButton) { + paintSwatch(this.mapBackgroundColorpickerButton, backgroundColor); + } + } + + /** + * Update the widget for display mode changes. + * Shows/hides the minus button and updates swatches based on mode. + * @param {string} mode - Display mode ("AOB", "BOA", "A", or "B") + * @param {RatioColorScale} ratioColorScale - Ratio color scale for AOB/BOA modes + * @param {ColorScale} colorScale - Standard color scale for A/B modes + */ + updateForDisplayMode(mode, ratioColorScale, colorScale) { + if (!this.minusButton || !this.plusButton) { + return; + } + + if (mode === "AOB" || mode === "BOA") { + this.minusButton.style.display = 'block'; + paintSwatch(this.minusButton, ratioColorScale.negativeScale); + paintSwatch(this.plusButton, ratioColorScale.positiveScale); + } else { + this.minusButton.style.display = 'none'; + paintSwatch(this.plusButton, colorScale); + } + } + + /** + * Update the widget for color scale changes. + * Handles both standard ColorScale and RatioColorScale instances. + * @param {ColorScale|RatioColorScale} colorScale - The color scale to display + */ + updateForColorScale(colorScale) { + if (!this.highColorscaleInput || !this.plusButton) { + return; + } + + if (colorScale instanceof ColorScale) { + this.highColorscaleInput.value = colorScale.threshold; + paintSwatch(this.plusButton, colorScale); + } else if (colorScale instanceof RatioColorScale) { + this.highColorscaleInput.value = colorScale.threshold; + if (this.minusButton) { + paintSwatch(this.minusButton, colorScale.negativeScale); + } + paintSwatch(this.plusButton, colorScale.positiveScale); + } + } } function paintSwatch(swatch, { r, g, b }) { diff --git a/js/hicResolutionSelector.js b/js/hicResolutionSelector.js index 0491c4d4..6349f515 100644 --- a/js/hicResolutionSelector.js +++ b/js/hicResolutionSelector.js @@ -138,6 +138,26 @@ class ResolutionSelector { this.resolutionSelectorElement.appendChild(option); }); } + + /** + * Update the label text based on whether we're in whole-genome view. + * @param {boolean} isWholeGenome - True if in whole-genome view + */ + updateLabelForWholeGenome(isWholeGenome) { + if (this.labelElement) { + this.labelElement.textContent = isWholeGenome ? 'Resolution (mb)' : 'Resolution (kb)'; + } + } + + /** + * Set the selected resolution by zoom index. + * @param {number} zoomIndex - The zoom index to select + */ + setSelectedResolution(zoomIndex) { + Array.from(this.resolutionSelectorElement.options).forEach((option, index) => { + option.selected = index === zoomIndex; + }); + } } export default ResolutionSelector; From e7d2206f39d9d5f7ead28f7c4d67afd6c67c073d Mon Sep 17 00:00:00 2001 From: turner Date: Fri, 7 Nov 2025 14:57:00 -0500 Subject: [PATCH 04/16] fowler refactors: phase 3 --- js/hicBrowser.js | 74 +++++++++++++++++++++++------------------------- js/utils.js | 19 ++++++++++++- 2 files changed, 53 insertions(+), 40 deletions(-) diff --git a/js/hicBrowser.js b/js/hicBrowser.js index ead8e1dd..df694bce 100644 --- a/js/hicBrowser.js +++ b/js/hicBrowser.js @@ -27,7 +27,7 @@ import igv from '../node_modules/igv/dist/igv.esm.js' import {Alert, InputDialog, DOMUtils} from '../node_modules/igv-ui/dist/igv-ui.js' -import {FileUtils, IGVColor} from '../node_modules/igv-utils/src/index.js' +import {FileUtils} from '../node_modules/igv-utils/src/index.js' import * as hicUtils from './hicUtils.js' import {Globals} from "./globals.js" import EventBus from "./eventBus.js" @@ -39,14 +39,12 @@ import LiveMapDataset from './liveMapDataset.js' import Genome from './genome.js' import State from './hicState.js' import { geneSearch } from './geneSearch.js' -import {defaultSize, getAllBrowsers, syncBrowsers} from "./createBrowser.js" +import {getAllBrowsers, syncBrowsers} from "./createBrowser.js" import {isFile} from "./fileUtils.js" import {setTrackReorderArrowColors} from "./trackPair.js" import nvi from './nvi.js' -import {extractName, presentError} from "./utils.js" +import {extractName, presentError, hitTestBbox} from "./utils.js" import BrowserUIManager from "./browserUIManager.js" -import ColorScale from './colorScale.js' -import RatioColorScale from './ratioColorScale.js' const DEFAULT_PIXEL_SIZE = 1 const MAX_PIXEL_SIZE = 128 @@ -366,6 +364,17 @@ class HICBrowser { * These methods directly call components that need to be notified of state changes. */ + /** + * Private helper: Get a UI component by name. + * Reduces repetitive getComponent calls and provides a consistent pattern. + * + * @param {string} componentName - The name of the component to retrieve + * @returns {Object|undefined} - The component instance, or undefined if not found + */ + _getUIComponent(componentName) { + return this.ui.getComponent(componentName); + } + /** * Private helper: Initialize ContactMatrixView when a map is loaded. * Enables mouse handlers and clears caches. @@ -384,7 +393,7 @@ class HICBrowser { * Private helper: Update chromosome selector when a map is loaded. */ _updateChromosomeSelectorForMapLoad(dataset) { - const chromosomeSelector = this.ui.getComponent('chromosomeSelector'); + const chromosomeSelector = this._getUIComponent('chromosomeSelector'); if (chromosomeSelector) { chromosomeSelector.respondToDataLoadWithDataset(dataset); } @@ -410,7 +419,7 @@ class HICBrowser { * Private helper: Update normalization widget when a map is loaded. */ _updateNormalizationWidgetForMapLoad(data) { - const normalizationWidget = this.ui.getComponent('normalization'); + const normalizationWidget = this._getUIComponent('normalization'); if (normalizationWidget) { normalizationWidget.receiveEvent({ type: "MapLoad", data }); } @@ -420,7 +429,7 @@ class HICBrowser { * Private helper: Update resolution selector when a map is loaded. */ _updateResolutionSelectorForMapLoad() { - const resolutionSelector = this.ui.getComponent('resolutionSelector'); + const resolutionSelector = this._getUIComponent('resolutionSelector'); if (resolutionSelector) { this.resolutionLocked = false; resolutionSelector.setResolutionLock(false); @@ -432,7 +441,7 @@ class HICBrowser { * Private helper: Update color scale widget when a map is loaded. */ _updateColorScaleWidgetForMapLoad() { - const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); + const colorScaleWidget = this._getUIComponent('colorScaleWidget'); if (colorScaleWidget) { colorScaleWidget.updateMapBackgroundColor(this.contactMatrixView.backgroundColor); } @@ -442,7 +451,7 @@ class HICBrowser { * Private helper: Update control map widget when a map is loaded. */ _updateControlMapWidgetForMapLoad() { - const controlMapWidget = this.ui.getComponent('controlMap'); + const controlMapWidget = this._getUIComponent('controlMap'); if (controlMapWidget && !this.controlDataset) { controlMapWidget.hide(); } @@ -464,13 +473,13 @@ class HICBrowser { } notifyControlMapLoaded(controlDataset) { - const controlMapWidget = this.ui.getComponent('controlMap'); + const controlMapWidget = this._getUIComponent('controlMap'); if (controlMapWidget) { controlMapWidget.updateDisplayMode(this.getDisplayMode()); controlMapWidget.show(); } - const resolutionSelector = this.ui.getComponent('resolutionSelector'); + const resolutionSelector = this._getUIComponent('resolutionSelector'); if (resolutionSelector) { resolutionSelector.updateResolutions(this.state.zoom); } @@ -484,7 +493,7 @@ class HICBrowser { * Private helper: Update chromosome selector when locus changes. */ _updateChromosomeSelectorForLocusChange(state) { - const chromosomeSelector = this.ui.getComponent('chromosomeSelector'); + const chromosomeSelector = this._getUIComponent('chromosomeSelector'); if (chromosomeSelector) { chromosomeSelector.respondToLocusChangeWithState(state); } @@ -494,7 +503,7 @@ class HICBrowser { * Private helper: Update scrollbar widget when locus changes. */ _updateScrollbarForLocusChange(state) { - const scrollbarWidget = this.ui.getComponent('scrollbar'); + const scrollbarWidget = this._getUIComponent('scrollbar'); if (scrollbarWidget && !scrollbarWidget.isDragging) { scrollbarWidget.receiveEvent({ type: "LocusChange", data: { state } }); } @@ -504,7 +513,7 @@ class HICBrowser { * Private helper: Update resolution selector when locus changes. */ _updateResolutionSelectorForLocusChange(state, resolutionChanged, chrChanged) { - const resolutionSelector = this.ui.getComponent('resolutionSelector'); + const resolutionSelector = this._getUIComponent('resolutionSelector'); if (!resolutionSelector) { return; } @@ -527,7 +536,7 @@ class HICBrowser { * Private helper: Update locus goto widget when locus changes. */ _updateLocusGotoForLocusChange(state) { - const locusGoto = this.ui.getComponent('locusGoto'); + const locusGoto = this._getUIComponent('locusGoto'); if (locusGoto) { locusGoto.receiveEvent({ type: "LocusChange", data: { state } }); } @@ -558,7 +567,7 @@ class HICBrowser { * Private helper: Update color scale widget for display mode changes. */ _updateColorScaleWidgetForDisplayMode(mode) { - const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); + const colorScaleWidget = this._getUIComponent('colorScaleWidget'); if (colorScaleWidget) { colorScaleWidget.updateForDisplayMode( mode, @@ -572,7 +581,7 @@ class HICBrowser { * Private helper: Update control map widget for display mode changes. */ _updateControlMapWidgetForDisplayMode(mode) { - const controlMapWidget = this.ui.getComponent('controlMap'); + const controlMapWidget = this._getUIComponent('controlMap'); if (controlMapWidget) { controlMapWidget.updateDisplayMode(mode); } @@ -584,7 +593,7 @@ class HICBrowser { } notifyColorScale(colorScale) { - const colorScaleWidget = this.ui.getComponent('colorScaleWidget'); + const colorScaleWidget = this._getUIComponent('colorScaleWidget'); if (colorScaleWidget) { colorScaleWidget.updateForColorScale(colorScale); } @@ -599,7 +608,7 @@ class HICBrowser { } notifyNormVectorIndexLoad(dataset) { - const normalizationWidget = this.ui.getComponent('normalization'); + const normalizationWidget = this._getUIComponent('normalization'); if (normalizationWidget) { normalizationWidget.updateOptions(); normalizationWidget.stopNotReady(); @@ -607,7 +616,7 @@ class HICBrowser { } notifyNormalizationFileLoad(status) { - const normalizationWidget = this.ui.getComponent('normalization'); + const normalizationWidget = this._getUIComponent('normalization'); if (normalizationWidget) { if (status === "start") { normalizationWidget.startNotReady(); @@ -618,7 +627,7 @@ class HICBrowser { } notifyNormalizationExternalChange(normalization) { - const normalizationWidget = this.ui.getComponent('normalization'); + const normalizationWidget = this._getUIComponent('normalization'); if (normalizationWidget) { Array.from(normalizationWidget.normalizationSelector.options).forEach(option => { option.selected = option.value === normalization; @@ -630,19 +639,6 @@ class HICBrowser { this.contactMatrixView.receiveEvent({ type: "ColorChange" }); } - /** - * Private helper: Find the element that contains the given offset value. - * Used for highlighting chromosomes in whole-genome view. - */ - _hitTestBbox(bboxes, value) { - for (const bbox of bboxes) { - if (value >= bbox.a && value <= bbox.b) { - return bbox.element; - } - } - return undefined; - } - /** * Private helper: Update ruler highlighting for mouse position. */ @@ -653,7 +649,7 @@ class HICBrowser { ruler.unhighlightWholeChromosome(); const offset = ruler.axis === 'x' ? xy.x : xy.y; - const element = this._hitTestBbox(ruler.bboxes, offset); + const element = hitTestBbox(ruler.bboxes, offset); if (element) { element.classList.add('hic-whole-genome-chromosome-highlight'); } @@ -1632,11 +1628,11 @@ class HICBrowser { /** * Public API for updating/repainting the browser. - * + * * Handles queuing logic for rapid calls (e.g., during mouse dragging). * If called while an update is in progress, queues the request for later processing. * Only the most recent request per type is kept in the queue. - * + * * @param shouldSync - Whether to synchronize state to other browsers (default: true) * Set to false when called from syncState() to avoid infinite loops */ @@ -1670,7 +1666,7 @@ class HICBrowser { queued.push(v) } this.pending.clear() - + // Process queued updates (only need to process the last one) if (queued.length > 0) { const lastQueued = queued[queued.length - 1] diff --git a/js/utils.js b/js/utils.js index bcdb2b69..0d115b2e 100644 --- a/js/utils.js +++ b/js/utils.js @@ -52,6 +52,23 @@ function extractName(config) { } } +/** + * Hit test function for bounding box arrays. + * Finds the element whose bounding box contains the given value. + * + * @param {Array<{a: number, b: number, element: HTMLElement}>} bboxes - Array of bounding boxes + * @param {number} value - The value to test against bounding boxes + * @returns {HTMLElement|undefined} - The element whose bounding box contains the value, or undefined + */ +function hitTestBbox(bboxes, value) { + for (const bbox of bboxes) { + if (value >= bbox.a && value <= bbox.b) { + return bbox.element; + } + } + return undefined; +} + function presentError(prefix, error) { const httpMessages = { @@ -64,4 +81,4 @@ function presentError(prefix, error) { Alert.presentAlert(`${prefix}: ${msg}`); } -export { createDOMFromHTMLString, getOffset, parseRgbString, prettyPrint, extractName, presentError } +export { createDOMFromHTMLString, getOffset, parseRgbString, prettyPrint, extractName, presentError, hitTestBbox } From 911836a0a0c90dc4235120967c2fd89ca5bd5668 Mon Sep 17 00:00:00 2001 From: turner Date: Fri, 7 Nov 2025 16:22:54 -0500 Subject: [PATCH 05/16] fowler refactors: phase 4A and 4B --- js/hicBrowser.js | 432 +++++++++------------------------- js/notificationCoordinator.js | 414 ++++++++++++++++++++++++++++++++ js/stateManager.js | 243 +++++++++++++++++++ 3 files changed, 768 insertions(+), 321 deletions(-) create mode 100644 js/notificationCoordinator.js create mode 100644 js/stateManager.js diff --git a/js/hicBrowser.js b/js/hicBrowser.js index df694bce..28f6107c 100644 --- a/js/hicBrowser.js +++ b/js/hicBrowser.js @@ -43,8 +43,10 @@ import {getAllBrowsers, syncBrowsers} from "./createBrowser.js" import {isFile} from "./fileUtils.js" import {setTrackReorderArrowColors} from "./trackPair.js" import nvi from './nvi.js' -import {extractName, presentError, hitTestBbox} from "./utils.js" +import {extractName, presentError} from "./utils.js" import BrowserUIManager from "./browserUIManager.js" +import NotificationCoordinator from "./notificationCoordinator.js" +import StateManager from "./stateManager.js" const DEFAULT_PIXEL_SIZE = 1 const MAX_PIXEL_SIZE = 128 @@ -68,12 +70,8 @@ class HICBrowser { this.synchable = config.synchable !== false; this.synchedBrowsers = new Set(); - // Unified dataset/state system - this.activeDataset = undefined; - this.activeState = undefined; - - // Control dataset (for A/B comparisons) - this.controlDataset = undefined; + // Initialize state manager for dataset/state management + this.stateManager = new StateManager(this); this.isMobile = hicUtils.isMobile(); @@ -99,6 +97,9 @@ class HICBrowser { // Get the contact matrix view from UI manager this.contactMatrixView = this.ui.getComponent('contactMatrix'); + // Initialize notification coordinator for UI updates + this.notifications = new NotificationCoordinator(this); + // prevent user interaction during lengthy data loads this.userInteractionShield = document.createElement('div'); this.userInteractionShield.className = 'hic-root-prevent-interaction'; @@ -360,304 +361,60 @@ class HICBrowser { } /** - * Explicit notification methods to replace internal event system. - * These methods directly call components that need to be notified of state changes. - */ - - /** - * Private helper: Get a UI component by name. - * Reduces repetitive getComponent calls and provides a consistent pattern. - * - * @param {string} componentName - The name of the component to retrieve - * @returns {Object|undefined} - The component instance, or undefined if not found - */ - _getUIComponent(componentName) { - return this.ui.getComponent(componentName); - } - - /** - * Private helper: Initialize ContactMatrixView when a map is loaded. - * Enables mouse handlers and clears caches. + * Notification methods delegate to NotificationCoordinator. + * These methods are kept for backward compatibility and to maintain the public API. */ - _initializeContactMatrixViewForMapLoad() { - if (!this.contactMatrixView.mouseHandlersEnabled) { - this.contactMatrixView.addTouchHandlers(this.contactMatrixView.viewportElement); - this.contactMatrixView.addMouseHandlers(this.contactMatrixView.viewportElement); - this.contactMatrixView.mouseHandlersEnabled = true; - } - this.contactMatrixView.clearImageCaches(); - this.contactMatrixView.colorScaleThresholdCache = {}; - } - - /** - * Private helper: Update chromosome selector when a map is loaded. - */ - _updateChromosomeSelectorForMapLoad(dataset) { - const chromosomeSelector = this._getUIComponent('chromosomeSelector'); - if (chromosomeSelector) { - chromosomeSelector.respondToDataLoadWithDataset(dataset); - } - } - - /** - * Private helper: Update rulers when a map is loaded. - */ - _updateRulersForMapLoad(dataset) { - const xRuler = this.layoutController.xAxisRuler; - if (xRuler) { - xRuler.wholeGenomeLayout(xRuler.axisElement, xRuler.wholeGenomeContainerElement, xRuler.axis, dataset); - xRuler.update(); - } - const yRuler = this.layoutController.yAxisRuler; - if (yRuler) { - yRuler.wholeGenomeLayout(yRuler.axisElement, yRuler.wholeGenomeContainerElement, yRuler.axis, dataset); - yRuler.update(); - } - } - - /** - * Private helper: Update normalization widget when a map is loaded. - */ - _updateNormalizationWidgetForMapLoad(data) { - const normalizationWidget = this._getUIComponent('normalization'); - if (normalizationWidget) { - normalizationWidget.receiveEvent({ type: "MapLoad", data }); - } - } - - /** - * Private helper: Update resolution selector when a map is loaded. - */ - _updateResolutionSelectorForMapLoad() { - const resolutionSelector = this._getUIComponent('resolutionSelector'); - if (resolutionSelector) { - this.resolutionLocked = false; - resolutionSelector.setResolutionLock(false); - resolutionSelector.updateResolutions(this.state.zoom); - } - } - - /** - * Private helper: Update color scale widget when a map is loaded. - */ - _updateColorScaleWidgetForMapLoad() { - const colorScaleWidget = this._getUIComponent('colorScaleWidget'); - if (colorScaleWidget) { - colorScaleWidget.updateMapBackgroundColor(this.contactMatrixView.backgroundColor); - } - } - - /** - * Private helper: Update control map widget when a map is loaded. - */ - _updateControlMapWidgetForMapLoad() { - const controlMapWidget = this._getUIComponent('controlMap'); - if (controlMapWidget && !this.controlDataset) { - controlMapWidget.hide(); - } - } notifyMapLoaded(dataset, state, datasetType) { - const data = { dataset, state, datasetType }; - - this._initializeContactMatrixViewForMapLoad(); - this._updateChromosomeSelectorForMapLoad(dataset); - this._updateRulersForMapLoad(dataset); - this._updateNormalizationWidgetForMapLoad(data); - this._updateResolutionSelectorForMapLoad(); - this._updateColorScaleWidgetForMapLoad(); - this._updateControlMapWidgetForMapLoad(); - - // Note: locusGoto is notified via notifyLocusChange() which is called from setState() - // after the locus is properly configured. Don't notify here as state.locus might not exist yet. + this.notifications.notifyMapLoaded(dataset, state, datasetType); } notifyControlMapLoaded(controlDataset) { - const controlMapWidget = this._getUIComponent('controlMap'); - if (controlMapWidget) { - controlMapWidget.updateDisplayMode(this.getDisplayMode()); - controlMapWidget.show(); - } - - const resolutionSelector = this._getUIComponent('resolutionSelector'); - if (resolutionSelector) { - resolutionSelector.updateResolutions(this.state.zoom); - } - - // ContactMatrixView also needs to know about control map - this.contactMatrixView.clearImageCaches(); - this.contactMatrixView.colorScaleThresholdCache = {}; - } - - /** - * Private helper: Update chromosome selector when locus changes. - */ - _updateChromosomeSelectorForLocusChange(state) { - const chromosomeSelector = this._getUIComponent('chromosomeSelector'); - if (chromosomeSelector) { - chromosomeSelector.respondToLocusChangeWithState(state); - } - } - - /** - * Private helper: Update scrollbar widget when locus changes. - */ - _updateScrollbarForLocusChange(state) { - const scrollbarWidget = this._getUIComponent('scrollbar'); - if (scrollbarWidget && !scrollbarWidget.isDragging) { - scrollbarWidget.receiveEvent({ type: "LocusChange", data: { state } }); - } - } - - /** - * Private helper: Update resolution selector when locus changes. - */ - _updateResolutionSelectorForLocusChange(state, resolutionChanged, chrChanged) { - const resolutionSelector = this._getUIComponent('resolutionSelector'); - if (!resolutionSelector) { - return; - } - - if (resolutionChanged) { - this.resolutionLocked = false; - resolutionSelector.setResolutionLock(false); - } - - if (chrChanged !== false) { - const isWholeGenome = this.dataset.isWholeGenome(state.chr1); - resolutionSelector.updateLabelForWholeGenome(isWholeGenome); - resolutionSelector.updateResolutions(state.zoom); - } else { - resolutionSelector.setSelectedResolution(state.zoom); - } - } - - /** - * Private helper: Update locus goto widget when locus changes. - */ - _updateLocusGotoForLocusChange(state) { - const locusGoto = this._getUIComponent('locusGoto'); - if (locusGoto) { - locusGoto.receiveEvent({ type: "LocusChange", data: { state } }); - } + this.notifications.notifyControlMapLoaded(controlDataset); } notifyLocusChange(eventData) { - const { state, resolutionChanged, chrChanged, dragging } = eventData; - - // ContactMatrixView - only clear caches if not a locus change - // (locus changes don't require cache clearing) - - this._updateChromosomeSelectorForLocusChange(state); - this._updateScrollbarForLocusChange(state); - this._updateResolutionSelectorForLocusChange(state, resolutionChanged, chrChanged); - this._updateLocusGotoForLocusChange(state); - - // Rulers are updated directly in update() method, not here + this.notifications.notifyLocusChange(eventData); } notifyNormalizationChange(normalization) { - // ContactMatrixView - this.contactMatrixView.receiveEvent({ type: "NormalizationChange", data: normalization }); - - // NormalizationWidget - no direct notification needed, it updates via selector change - } - - /** - * Private helper: Update color scale widget for display mode changes. - */ - _updateColorScaleWidgetForDisplayMode(mode) { - const colorScaleWidget = this._getUIComponent('colorScaleWidget'); - if (colorScaleWidget) { - colorScaleWidget.updateForDisplayMode( - mode, - this.contactMatrixView.ratioColorScale, - this.contactMatrixView.colorScale - ); - } - } - - /** - * Private helper: Update control map widget for display mode changes. - */ - _updateControlMapWidgetForDisplayMode(mode) { - const controlMapWidget = this._getUIComponent('controlMap'); - if (controlMapWidget) { - controlMapWidget.updateDisplayMode(mode); - } + this.notifications.notifyNormalizationChange(normalization); } notifyDisplayMode(mode) { - this._updateColorScaleWidgetForDisplayMode(mode); - this._updateControlMapWidgetForDisplayMode(mode); + this.notifications.notifyDisplayMode(mode); } notifyColorScale(colorScale) { - const colorScaleWidget = this._getUIComponent('colorScaleWidget'); - if (colorScaleWidget) { - colorScaleWidget.updateForColorScale(colorScale); - } + this.notifications.notifyColorScale(colorScale); } notifyTrackLoad2D(tracks2D) { - this.contactMatrixView.receiveEvent({ type: "TrackLoad2D", data: tracks2D }); + this.notifications.notifyTrackLoad2D(tracks2D); } notifyTrackState2D(trackData) { - this.contactMatrixView.receiveEvent({ type: "TrackState2D", data: trackData }); + this.notifications.notifyTrackState2D(trackData); } notifyNormVectorIndexLoad(dataset) { - const normalizationWidget = this._getUIComponent('normalization'); - if (normalizationWidget) { - normalizationWidget.updateOptions(); - normalizationWidget.stopNotReady(); - } + this.notifications.notifyNormVectorIndexLoad(dataset); } notifyNormalizationFileLoad(status) { - const normalizationWidget = this._getUIComponent('normalization'); - if (normalizationWidget) { - if (status === "start") { - normalizationWidget.startNotReady(); - } else { - normalizationWidget.stopNotReady(); - } - } + this.notifications.notifyNormalizationFileLoad(status); } notifyNormalizationExternalChange(normalization) { - const normalizationWidget = this._getUIComponent('normalization'); - if (normalizationWidget) { - Array.from(normalizationWidget.normalizationSelector.options).forEach(option => { - option.selected = option.value === normalization; - }); - } + this.notifications.notifyNormalizationExternalChange(normalization); } notifyColorChange() { - this.contactMatrixView.receiveEvent({ type: "ColorChange" }); - } - - /** - * Private helper: Update ruler highlighting for mouse position. - */ - _updateRulerHighlightingForMousePosition(ruler, xy) { - if (!ruler || !ruler.bboxes) { - return; - } - - ruler.unhighlightWholeChromosome(); - const offset = ruler.axis === 'x' ? xy.x : xy.y; - const element = hitTestBbox(ruler.bboxes, offset); - if (element) { - element.classList.add('hic-whole-genome-chromosome-highlight'); - } + this.notifications.notifyColorChange(); } notifyUpdateContactMapMousePosition(xy) { - this._updateRulerHighlightingForMousePosition(this.layoutController.xAxisRuler, xy); - this._updateRulerHighlightingForMousePosition(this.layoutController.yAxisRuler, xy); + this.notifications.notifyUpdateContactMapMousePosition(xy); } showCrosshairs() { @@ -826,11 +583,13 @@ class HICBrowser { * @param {Dataset} dataset - The dataset to activate * @param {State} state - The state to use with this dataset */ + /** + * State management methods delegate to StateManager. + * These methods are kept for backward compatibility and to maintain the public API. + */ + setActiveDataset(dataset, state) { - this.activeDataset = dataset; - if (state) { - this.activeState = state; - } + this.stateManager.setActiveDataset(dataset, state); } /** @@ -838,28 +597,84 @@ class HICBrowser { * Returns activeDataset (the primary dataset, not control) */ get dataset() { - return this.activeDataset; + return this.stateManager.getActiveDataset(); } /** * Backward compatibility: setter for dataset property */ set dataset(value) { - this.activeDataset = value; + this.stateManager.setActiveDataset(value, undefined); } /** * Backward compatibility: getter for state property */ get state() { - return this.activeState; + return this.stateManager.getActiveState(); } /** * Backward compatibility: setter for state property + * Note: Direct assignment bypasses validation. Use setState() for proper state management. */ set state(value) { - this.activeState = value; + // Direct assignment - store directly without validation + // This is for backward compatibility only + if (value) { + this.stateManager.activeState = value; + } else { + this.stateManager.activeState = undefined; + } + } + + /** + * Getter for activeDataset (backward compatibility) + */ + get activeDataset() { + return this.stateManager.getActiveDataset(); + } + + /** + * Setter for activeDataset (backward compatibility) + */ + set activeDataset(value) { + this.stateManager.setActiveDataset(value, undefined); + } + + /** + * Getter for activeState (backward compatibility) + */ + get activeState() { + return this.stateManager.getActiveState(); + } + + /** + * Setter for activeState (backward compatibility) + * Note: Direct assignment bypasses validation. Use setState() for proper state management. + */ + set activeState(value) { + // Direct assignment - store directly without validation + // This is for backward compatibility only + if (value) { + this.stateManager.activeState = value; + } else { + this.stateManager.activeState = undefined; + } + } + + /** + * Getter for controlDataset (backward compatibility) + */ + get controlDataset() { + return this.stateManager.getControlDataset(); + } + + /** + * Setter for controlDataset (backward compatibility) + */ + set controlDataset(value) { + this.stateManager.setControlDataset(value); } reset() { @@ -871,17 +686,13 @@ class HICBrowser { this.contactMapLabel.title = ""; this.controlMapLabel.textContent = ""; this.controlMapLabel.title = ""; - this.activeDataset = undefined; - this.activeState = undefined; - this.controlDataset = undefined; + this.stateManager.clearState(); this.unsyncSelf() } clearSession() { // Clear current datasets. - this.activeDataset = undefined; - this.activeState = undefined; - this.controlDataset = undefined; + this.stateManager.clearState(); this.setDisplayMode('A') this.unsyncSelf() } @@ -1497,24 +1308,15 @@ class HICBrowser { * @param state browser state */ async setState(state) { + const { chrChanged, resolutionChanged } = await this.stateManager.setState(state); - const chrChanged = !this.state || this.state.chr1 !== state.chr1 || this.state.chr2 !== state.chr2 - - this.state = state.clone() - - // Possibly adjust pixel size - const minPS = await this.minPixelSize(this.state.chr1, this.state.chr2, this.state.zoom) - this.state.pixelSize = Math.max(state.pixelSize, minPS) - - // Derive locus if none is present in source state - if (undefined === state.locus) { - const viewDimensions = this.contactMatrixView.getViewDimensions(); - this.state.configureLocus(this, this.activeDataset, viewDimensions) - } - - const eventData = { state: this.state, resolutionChanged: true, chrChanged } - await this.update() - this.notifyLocusChange(eventData) + const eventData = { + state: this.state, + resolutionChanged, + chrChanged + }; + await this.update(); + this.notifyLocusChange(eventData); } /** @@ -1522,14 +1324,7 @@ class HICBrowser { * and resolution arrays */ getSyncState() { - return { - chr1Name: this.dataset.chromosomes[this.state.chr1].name, - chr2Name: this.dataset.chromosomes[this.state.chr2].name, - binSize: this.dataset.bpResolutions[this.state.zoom], - binX: this.state.x, // TODO: translate to lower right corner - binY: this.state.y, - pixelSize: this.state.pixelSize - } + return this.stateManager.getSyncState(); } /** @@ -1537,33 +1332,28 @@ class HICBrowser { * @param syncState */ canBeSynched(syncState) { - - if (false === this.synchable) return false // Explicitly not synchable - - return this.dataset && - (this.dataset.getChrIndexFromName(syncState.chr1Name) !== undefined) && - (this.dataset.getChrIndexFromName(syncState.chr2Name) !== undefined) - + return this.stateManager.canBeSynched(syncState); } async syncState(targetState) { + if (!targetState || false === this.synchable) { + return; + } - if (!targetState || false === this.synchable) return - - if (!this.dataset) return + if (!this.dataset) { + return; + } - const { zoomChanged, chrChanged } = this.state.sync(targetState, this, this.genome, this.dataset) + const { zoomChanged, chrChanged } = await this.stateManager.syncState(targetState); // For sync, we don't want to propagate back to other browsers (would cause infinite loop) // So we update without syncing - await this.update(false) - + await this.update(false); } setNormalization(normalization) { - - this.state.normalization = normalization - this.notifyNormalizationChange(this.state.normalization) + this.stateManager.setNormalization(normalization); + this.notifyNormalizationChange(this.stateManager.getNormalization()); } async shiftPixels(dx, dy) { diff --git a/js/notificationCoordinator.js b/js/notificationCoordinator.js new file mode 100644 index 00000000..29231fc6 --- /dev/null +++ b/js/notificationCoordinator.js @@ -0,0 +1,414 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2016-2017 The Regents of the University of California + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the + * following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import {hitTestBbox} from "./utils.js" + +/** + * NotificationCoordinator handles all UI component notifications. + * Extracted from HICBrowser to separate UI coordination concerns. + * + * This class coordinates updates to UI components when browser state changes, + * following the Explicit Notification Method pattern. + */ +class NotificationCoordinator { + + /** + * @param {HICBrowser} browser - The browser instance this coordinator serves + */ + constructor(browser) { + this.browser = browser; + } + + /** + * Private helper: Get a UI component by name. + * + * @param {string} componentName - The name of the component to retrieve + * @returns {Object|undefined} - The component instance, or undefined if not found + */ + _getUIComponent(componentName) { + return this.browser.ui.getComponent(componentName); + } + + /** + * Private helper: Initialize ContactMatrixView when a map is loaded. + * Enables mouse handlers and clears caches. + */ + _initializeContactMatrixViewForMapLoad() { + const contactMatrixView = this.browser.contactMatrixView; + if (!contactMatrixView.mouseHandlersEnabled) { + contactMatrixView.addTouchHandlers(contactMatrixView.viewportElement); + contactMatrixView.addMouseHandlers(contactMatrixView.viewportElement); + contactMatrixView.mouseHandlersEnabled = true; + } + contactMatrixView.clearImageCaches(); + contactMatrixView.colorScaleThresholdCache = {}; + } + + /** + * Private helper: Update chromosome selector when a map is loaded. + */ + _updateChromosomeSelectorForMapLoad(dataset) { + const chromosomeSelector = this._getUIComponent('chromosomeSelector'); + if (chromosomeSelector) { + chromosomeSelector.respondToDataLoadWithDataset(dataset); + } + } + + /** + * Private helper: Update rulers when a map is loaded. + */ + _updateRulersForMapLoad(dataset) { + const layoutController = this.browser.layoutController; + const xRuler = layoutController.xAxisRuler; + if (xRuler) { + xRuler.wholeGenomeLayout(xRuler.axisElement, xRuler.wholeGenomeContainerElement, xRuler.axis, dataset); + xRuler.update(); + } + const yRuler = layoutController.yAxisRuler; + if (yRuler) { + yRuler.wholeGenomeLayout(yRuler.axisElement, yRuler.wholeGenomeContainerElement, yRuler.axis, dataset); + yRuler.update(); + } + } + + /** + * Private helper: Update normalization widget when a map is loaded. + */ + _updateNormalizationWidgetForMapLoad(data) { + const normalizationWidget = this._getUIComponent('normalization'); + if (normalizationWidget) { + normalizationWidget.receiveEvent({ type: "MapLoad", data }); + } + } + + /** + * Private helper: Update resolution selector when a map is loaded. + */ + _updateResolutionSelectorForMapLoad() { + const resolutionSelector = this._getUIComponent('resolutionSelector'); + if (resolutionSelector) { + this.browser.resolutionLocked = false; + resolutionSelector.setResolutionLock(false); + resolutionSelector.updateResolutions(this.browser.state.zoom); + } + } + + /** + * Private helper: Update color scale widget when a map is loaded. + */ + _updateColorScaleWidgetForMapLoad() { + const colorScaleWidget = this._getUIComponent('colorScaleWidget'); + if (colorScaleWidget) { + colorScaleWidget.updateMapBackgroundColor(this.browser.contactMatrixView.backgroundColor); + } + } + + /** + * Private helper: Update control map widget when a map is loaded. + */ + _updateControlMapWidgetForMapLoad() { + const controlMapWidget = this._getUIComponent('controlMap'); + if (controlMapWidget && !this.browser.controlDataset) { + controlMapWidget.hide(); + } + } + + /** + * Notify all UI components that a map has been loaded. + * + * @param {Dataset} dataset - The loaded dataset + * @param {State} state - The current state + * @param {string} datasetType - Type of dataset (e.g., "main", "control") + */ + notifyMapLoaded(dataset, state, datasetType) { + const data = { dataset, state, datasetType }; + + this._initializeContactMatrixViewForMapLoad(); + this._updateChromosomeSelectorForMapLoad(dataset); + this._updateRulersForMapLoad(dataset); + this._updateNormalizationWidgetForMapLoad(data); + this._updateResolutionSelectorForMapLoad(); + this._updateColorScaleWidgetForMapLoad(); + this._updateControlMapWidgetForMapLoad(); + + // Note: locusGoto is notified via notifyLocusChange() which is called from setState() + // after the locus is properly configured. Don't notify here as state.locus might not exist yet. + } + + /** + * Notify UI components that a control map has been loaded. + * + * @param {Dataset} controlDataset - The loaded control dataset + */ + notifyControlMapLoaded(controlDataset) { + const controlMapWidget = this._getUIComponent('controlMap'); + if (controlMapWidget) { + controlMapWidget.updateDisplayMode(this.browser.getDisplayMode()); + controlMapWidget.show(); + } + + const resolutionSelector = this._getUIComponent('resolutionSelector'); + if (resolutionSelector) { + resolutionSelector.updateResolutions(this.browser.state.zoom); + } + + // ContactMatrixView also needs to know about control map + const contactMatrixView = this.browser.contactMatrixView; + contactMatrixView.clearImageCaches(); + contactMatrixView.colorScaleThresholdCache = {}; + } + + /** + * Private helper: Update chromosome selector when locus changes. + */ + _updateChromosomeSelectorForLocusChange(state) { + const chromosomeSelector = this._getUIComponent('chromosomeSelector'); + if (chromosomeSelector) { + chromosomeSelector.respondToLocusChangeWithState(state); + } + } + + /** + * Private helper: Update scrollbar widget when locus changes. + */ + _updateScrollbarForLocusChange(state) { + const scrollbarWidget = this._getUIComponent('scrollbar'); + if (scrollbarWidget && !scrollbarWidget.isDragging) { + scrollbarWidget.receiveEvent({ type: "LocusChange", data: { state } }); + } + } + + /** + * Private helper: Update resolution selector when locus changes. + */ + _updateResolutionSelectorForLocusChange(state, resolutionChanged, chrChanged) { + const resolutionSelector = this._getUIComponent('resolutionSelector'); + if (!resolutionSelector) { + return; + } + + if (resolutionChanged) { + this.browser.resolutionLocked = false; + resolutionSelector.setResolutionLock(false); + } + + if (chrChanged !== false) { + const isWholeGenome = this.browser.dataset.isWholeGenome(state.chr1); + resolutionSelector.updateLabelForWholeGenome(isWholeGenome); + resolutionSelector.updateResolutions(state.zoom); + } else { + resolutionSelector.setSelectedResolution(state.zoom); + } + } + + /** + * Private helper: Update locus goto widget when locus changes. + */ + _updateLocusGotoForLocusChange(state) { + const locusGoto = this._getUIComponent('locusGoto'); + if (locusGoto) { + locusGoto.receiveEvent({ type: "LocusChange", data: { state } }); + } + } + + /** + * Notify UI components that the locus has changed. + * + * @param {Object} eventData - Event data containing state and change flags + * @param {State} eventData.state - The new state + * @param {boolean} eventData.resolutionChanged - Whether resolution changed + * @param {boolean} eventData.chrChanged - Whether chromosome changed + * @param {boolean} eventData.dragging - Whether currently dragging + */ + notifyLocusChange(eventData) { + const { state, resolutionChanged, chrChanged, dragging } = eventData; + + // ContactMatrixView - only clear caches if not a locus change + // (locus changes don't require cache clearing) + + this._updateChromosomeSelectorForLocusChange(state); + this._updateScrollbarForLocusChange(state); + this._updateResolutionSelectorForLocusChange(state, resolutionChanged, chrChanged); + this._updateLocusGotoForLocusChange(state); + + // Rulers are updated directly in update() method, not here + } + + /** + * Notify UI components that normalization has changed. + * + * @param {string} normalization - The normalization type + */ + notifyNormalizationChange(normalization) { + // ContactMatrixView + this.browser.contactMatrixView.receiveEvent({ type: "NormalizationChange", data: normalization }); + + // NormalizationWidget - no direct notification needed, it updates via selector change + } + + /** + * Private helper: Update color scale widget for display mode changes. + */ + _updateColorScaleWidgetForDisplayMode(mode) { + const colorScaleWidget = this._getUIComponent('colorScaleWidget'); + if (colorScaleWidget) { + const contactMatrixView = this.browser.contactMatrixView; + colorScaleWidget.updateForDisplayMode( + mode, + contactMatrixView.ratioColorScale, + contactMatrixView.colorScale + ); + } + } + + /** + * Private helper: Update control map widget for display mode changes. + */ + _updateControlMapWidgetForDisplayMode(mode) { + const controlMapWidget = this._getUIComponent('controlMap'); + if (controlMapWidget) { + controlMapWidget.updateDisplayMode(mode); + } + } + + /** + * Notify UI components that display mode has changed. + * + * @param {string} mode - The display mode ("A", "B", "AOB", "BOA") + */ + notifyDisplayMode(mode) { + this._updateColorScaleWidgetForDisplayMode(mode); + this._updateControlMapWidgetForDisplayMode(mode); + } + + /** + * Notify UI components that color scale has changed. + * + * @param {ColorScale|RatioColorScale} colorScale - The color scale instance + */ + notifyColorScale(colorScale) { + const colorScaleWidget = this._getUIComponent('colorScaleWidget'); + if (colorScaleWidget) { + colorScaleWidget.updateForColorScale(colorScale); + } + } + + /** + * Notify UI components that 2D tracks have been loaded. + * + * @param {Array} tracks2D - Array of 2D track instances + */ + notifyTrackLoad2D(tracks2D) { + this.browser.contactMatrixView.receiveEvent({ type: "TrackLoad2D", data: tracks2D }); + } + + /** + * Notify UI components that 2D track state has changed. + * + * @param {Object|Array} trackData - Track state data + */ + notifyTrackState2D(trackData) { + this.browser.contactMatrixView.receiveEvent({ type: "TrackState2D", data: trackData }); + } + + /** + * Notify UI components that normalization vector index has been loaded. + * + * @param {Dataset} dataset - The dataset with loaded normalization vectors + */ + notifyNormVectorIndexLoad(dataset) { + const normalizationWidget = this._getUIComponent('normalization'); + if (normalizationWidget) { + normalizationWidget.updateOptions(); + normalizationWidget.stopNotReady(); + } + } + + /** + * Notify UI components about normalization file load status. + * + * @param {string} status - Load status ("start" or "stop") + */ + notifyNormalizationFileLoad(status) { + const normalizationWidget = this._getUIComponent('normalization'); + if (normalizationWidget) { + if (status === "start") { + normalizationWidget.startNotReady(); + } else { + normalizationWidget.stopNotReady(); + } + } + } + + /** + * Notify UI components that normalization has changed externally. + * + * @param {string} normalization - The normalization type + */ + notifyNormalizationExternalChange(normalization) { + const normalizationWidget = this._getUIComponent('normalization'); + if (normalizationWidget) { + Array.from(normalizationWidget.normalizationSelector.options).forEach(option => { + option.selected = option.value === normalization; + }); + } + } + + /** + * Notify UI components that colors have changed. + */ + notifyColorChange() { + this.browser.contactMatrixView.receiveEvent({ type: "ColorChange" }); + } + + /** + * Private helper: Update ruler highlighting for mouse position. + */ + _updateRulerHighlightingForMousePosition(ruler, xy) { + if (!ruler || !ruler.bboxes) { + return; + } + + ruler.unhighlightWholeChromosome(); + const offset = ruler.axis === 'x' ? xy.x : xy.y; + const element = hitTestBbox(ruler.bboxes, offset); + if (element) { + element.classList.add('hic-whole-genome-chromosome-highlight'); + } + } + + /** + * Notify UI components that contact map mouse position has changed. + * + * @param {Object} xy - Mouse position coordinates + * @param {number} xy.x - X coordinate + * @param {number} xy.y - Y coordinate + */ + notifyUpdateContactMapMousePosition(xy) { + const layoutController = this.browser.layoutController; + this._updateRulerHighlightingForMousePosition(layoutController.xAxisRuler, xy); + this._updateRulerHighlightingForMousePosition(layoutController.yAxisRuler, xy); + } +} + +export default NotificationCoordinator; + diff --git a/js/stateManager.js b/js/stateManager.js new file mode 100644 index 00000000..76624793 --- /dev/null +++ b/js/stateManager.js @@ -0,0 +1,243 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2016-2017 The Regents of the University of California + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the + * following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * StateManager handles all state management responsibilities for HICBrowser. + * Extracted from HICBrowser to separate state management concerns. + * + * This class manages: + * - Active dataset and state + * - State transitions and validation + * - Cross-browser synchronization state + * - State normalization and pixel size adjustments + */ +class StateManager { + + /** + * @param {HICBrowser} browser - The browser instance this manager serves + */ + constructor(browser) { + this.browser = browser; + + // State properties + this.activeDataset = undefined; + this.activeState = undefined; + this.controlDataset = undefined; + } + + /** + * Set the active dataset and optionally the state. + * + * @param {Dataset} dataset - The dataset to set as active + * @param {State} state - Optional state to set + */ + setActiveDataset(dataset, state) { + this.activeDataset = dataset; + if (state) { + this.activeState = state; + } + } + + /** + * Get the active dataset. + * + * @returns {Dataset|undefined} - The active dataset + */ + getActiveDataset() { + return this.activeDataset; + } + + /** + * Get the active state. + * + * @returns {State|undefined} - The active state + */ + getActiveState() { + return this.activeState; + } + + /** + * Set the active state with validation and adjustment. + * This method handles: + * - Cloning the state to avoid mutations + * - Adjusting pixel size based on minimum requirements + * - Configuring locus if not present + * + * @param {State} state - The state to set + * @returns {Promise<{chrChanged: boolean, resolutionChanged: boolean}>} - Change flags + */ + async setState(state) { + const chrChanged = !this.activeState || + this.activeState.chr1 !== state.chr1 || + this.activeState.chr2 !== state.chr2; + + this.activeState = state.clone(); + + // Possibly adjust pixel size + const minPS = await this.browser.minPixelSize( + this.activeState.chr1, + this.activeState.chr2, + this.activeState.zoom + ); + this.activeState.pixelSize = Math.max(state.pixelSize, minPS); + + // Derive locus if none is present in source state + if (undefined === state.locus) { + const viewDimensions = this.browser.contactMatrixView.getViewDimensions(); + this.activeState.configureLocus( + this.browser, + this.activeDataset, + viewDimensions + ); + } + + return { + chrChanged, + resolutionChanged: true + }; + } + + /** + * Set the control dataset (for A/B comparisons). + * + * @param {Dataset} dataset - The control dataset + */ + setControlDataset(dataset) { + this.controlDataset = dataset; + } + + /** + * Get the control dataset. + * + * @returns {Dataset|undefined} - The control dataset + */ + getControlDataset() { + return this.controlDataset; + } + + /** + * Clear all state (dataset and state). + */ + clearState() { + this.activeDataset = undefined; + this.activeState = undefined; + this.controlDataset = undefined; + } + + /** + * Return a modified state object used for synching. + * Other datasets might have different chromosome ordering and resolution arrays. + * + * @returns {Object} - Sync state object with chromosome names and bin coordinates + */ + getSyncState() { + if (!this.activeDataset || !this.activeState) { + return undefined; + } + + return { + chr1Name: this.activeDataset.chromosomes[this.activeState.chr1].name, + chr2Name: this.activeDataset.chromosomes[this.activeState.chr2].name, + binSize: this.activeDataset.bpResolutions[this.activeState.zoom], + binX: this.activeState.x, + binY: this.activeState.y, + pixelSize: this.activeState.pixelSize + }; + } + + /** + * Return true if this browser can be synced to the given state. + * + * @param {Object} syncState - The sync state to check compatibility with + * @returns {boolean} - True if browser can sync to the given state + */ + canBeSynched(syncState) { + if (false === this.browser.synchable) { + return false; // Explicitly not synchable + } + + if (!this.activeDataset) { + return false; + } + + return ( + this.activeDataset.getChrIndexFromName(syncState.chr1Name) !== undefined && + this.activeDataset.getChrIndexFromName(syncState.chr2Name) !== undefined + ); + } + + /** + * Sync this browser's state to match a target sync state. + * This method updates the state to match another browser's state for synchronization. + * + * @param {Object} targetState - The target sync state to sync to + * @returns {Promise<{zoomChanged: boolean, chrChanged: boolean}>} - Change flags + */ + async syncState(targetState) { + if (!targetState || false === this.browser.synchable) { + return { zoomChanged: false, chrChanged: false }; + } + + if (!this.activeDataset || !this.activeState) { + return { zoomChanged: false, chrChanged: false }; + } + + const { zoomChanged, chrChanged } = this.activeState.sync( + targetState, + this.browser, + this.browser.genome, + this.activeDataset + ); + + // Configure locus after sync + this.activeState.configureLocus( + this.browser, + this.activeDataset, + this.browser.contactMatrixView.getViewDimensions() + ); + + return { zoomChanged, chrChanged }; + } + + /** + * Set normalization on the active state. + * + * @param {string} normalization - The normalization type + */ + setNormalization(normalization) { + if (this.activeState) { + this.activeState.normalization = normalization; + } + } + + /** + * Get normalization from the active state. + * + * @returns {string|undefined} - The normalization type + */ + getNormalization() { + return this.activeState ? this.activeState.normalization : undefined; + } +} + +export default StateManager; + From 0092abee3e13b59950bd26d2d896f42b3ef04241 Mon Sep 17 00:00:00 2001 From: turner Date: Fri, 7 Nov 2025 16:47:33 -0500 Subject: [PATCH 06/16] fowler refactors: phase 4C --- js/dataLoader.js | 456 ++++++++++++++++++++++++++++ js/hicBrowser.js | 633 +++------------------------------------ js/interactionHandler.js | 405 +++++++++++++++++++++++++ js/urlUtils.js | 2 +- 4 files changed, 907 insertions(+), 589 deletions(-) create mode 100644 js/dataLoader.js create mode 100644 js/interactionHandler.js diff --git a/js/dataLoader.js b/js/dataLoader.js new file mode 100644 index 00000000..60e1c916 --- /dev/null +++ b/js/dataLoader.js @@ -0,0 +1,456 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2016-2017 The Regents of the University of California + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the + * following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import igv from '../node_modules/igv/dist/igv.esm.js' +import {Alert} from '../node_modules/igv-ui/dist/igv-ui.js' +import {FileUtils} from '../node_modules/igv-utils/src/index.js' +import Dataset from './hicDataset.js' +import LiveMapDataset from './liveMapDataset.js' +import State from './hicState.js' +import Genome from './genome.js' +import {extractName, presentError} from "./utils.js" +import {isFile} from "./fileUtils.js" +import {getAllBrowsers, syncBrowsers} from "./createBrowser.js" +import HICEvent from './hicEvent.js' +import EventBus from './eventBus.js' +import nvi from './nvi.js' +import * as hicUtils from './hicUtils.js' +import {getLayoutDimensions} from './layoutController.js' +import Track2D from './track2D.js' + +import {DEFAULT_ANNOTATION_COLOR} from "./urlUtils.js" + +/** + * DataLoader handles all data loading responsibilities for HICBrowser. + * Extracted from HICBrowser to separate data loading concerns. + * + * This class manages: + * - Hi-C file loading (main and control) + * - Live map dataset loading + * - Track loading (1D and 2D) + * - Normalization vector file loading + */ +class DataLoader { + + /** + * @param {HICBrowser} browser - The browser instance this loader serves + */ + constructor(browser) { + this.browser = browser; + } + + /** + * Load a .hic file + * + * NOTE: public API function + * + * @param {Object} config - Configuration object with url, name, locus, state, etc. + * @param {boolean} noUpdates - If true, don't trigger UI updates + * @returns {Promise} - The loaded dataset + */ + async loadHicFile(config, noUpdates) { + if (!config.url) { + console.log("No .hic url specified"); + return undefined; + } + + this.browser.clearSession(); + + try { + this.browser.contactMatrixView.startSpinner(); + if (!noUpdates) { + this.browser.userInteractionShield.style.display = 'block'; + } + + const name = extractName(config); + const prefix = this.browser.controlDataset ? "A: " : ""; + this.browser.contactMapLabel.textContent = prefix + name; + this.browser.contactMapLabel.title = name; + config.name = name; + + const hicFileAlert = str => { + this.browser.notifyNormalizationExternalChange('NONE'); + Alert.presentAlert(str); + }; + + const dataset = await Dataset.loadDataset(Object.assign({alert: hicFileAlert}, config)); + dataset.name = name; + + const previousGenomeId = this.browser.genome ? this.browser.genome.id : undefined; + this.browser.genome = new Genome(dataset.genomeId, dataset.chromosomes); + + if (this.browser.genome.id !== previousGenomeId) { + EventBus.globalBus.post(HICEvent("GenomeChange", this.browser.genome.id)); + } + + let state; + if (config.locus) { + state = State.default(config); + this.browser.setActiveDataset(dataset, state); + await this.browser.parseGotoInput(config.locus); + } else if (config.state) { + if (typeof config.state === 'string') { + state = State.parse(config.state); + } else if (typeof config.state === 'object') { + state = State.fromJSON(config.state); + } else { + alert('config.state is of unknown type'); + console.error('config.state is of unknown type'); + state = State.default(config); + } + + // Set active dataset before setState so configureLocus can access bpResolutions + this.browser.setActiveDataset(dataset, state); + await this.browser.setState(state); + } else if (config.synchState && this.browser.canBeSynched(config.synchState)) { + await this.browser.syncState(config.synchState); + state = this.browser.activeState; + // syncState already sets activeDataset, but ensure it's set with current dataset + if (this.browser.activeDataset !== dataset) { + this.browser.setActiveDataset(dataset, state); + } + } else { + state = State.default(config); + // Set active dataset before setState so configureLocus can access bpResolutions + this.browser.setActiveDataset(dataset, state); + await this.browser.setState(state); + } + + this.browser.notifyMapLoaded(dataset, state, dataset.datasetType); + + // Initiate loading of the norm vector index, but don't block if the "nvi" parameter is not available. + // Let it load in the background + + // If nvi is not supplied, try lookup table of known values + if (!config.nvi && typeof config.url === "string") { + const url = new URL(config.url); + const key = encodeURIComponent(url.hostname + url.pathname); + if (nvi.hasOwnProperty(key)) { + config.nvi = nvi[key]; + } + } + + if (config.nvi && dataset.getNormVectorIndex) { + await dataset.getNormVectorIndex(config); + if (!config.isControl) { + this.browser.notifyNormVectorIndexLoad(dataset); + } + } else if (dataset.getNormVectorIndex) { + dataset.getNormVectorIndex(config) + .then(normVectorIndex => { + if (!config.isControl) { + this.browser.notifyNormVectorIndexLoad(dataset); + } + }); + } + + syncBrowsers(); + + // Find a browser to sync with, if any + const compatibleBrowsers = getAllBrowsers().filter( + b => b !== this.browser && + b.activeDataset && + b.activeDataset.isCompatible(this.browser.activeDataset) + ); + if (compatibleBrowsers.length > 0) { + await this.browser.syncState(compatibleBrowsers[0].getSyncState()); + } + + return dataset; + } catch (error) { + this.browser.contactMapLabel.textContent = ""; + this.browser.contactMapLabel.title = ""; + config.name = name; + throw error; + } finally { + this.browser.stopSpinner(); + if (!noUpdates) { + this.browser.userInteractionShield.style.display = 'none'; + } + } + } + + /** + * Load a live map dataset + * + * NOTE: public API function + * + * @param {Object} config - Configuration object with: + * - contactRecordList: Array of contact records OR + * - contactMatrix: 2D array of contact totals + * - chromosomes: Array of chromosome definitions + * - genomeId: Genome identifier + * - bpResolutions: Array of available resolutions + * - name: Dataset name + * - binSize: Bin size (if using contactMatrix) + * - state: Optional initial state + * @param {boolean} noUpdates - If true, don't trigger UI updates + * @returns {Promise} + */ + async loadLiveMapDataset(config, noUpdates) { + this.browser.clearSession(); + + try { + this.browser.contactMatrixView.startSpinner(); + if (!noUpdates) { + this.browser.userInteractionShield.style.display = 'block'; + } + + const name = config.name || 'Live Map'; + this.browser.contactMapLabel.textContent = name; + this.browser.contactMapLabel.title = name; + + const dataset = new LiveMapDataset(config); + await dataset.init(); + + const previousGenomeId = this.browser.genome ? this.browser.genome.id : undefined; + this.browser.genome = new Genome(dataset.genomeId, dataset.chromosomes); + + if (this.browser.genome.id !== previousGenomeId) { + EventBus.globalBus.post(HICEvent("GenomeChange", this.browser.genome.id)); + } + + let state; + if (config.state) { + if (typeof config.state === 'string') { + state = State.parse(config.state); + } else if (typeof config.state === 'object') { + state = State.fromJSON(config.state); + } else { + state = State.default(config); + } + } else { + state = State.default(config); + } + + // Set active dataset BEFORE setState, since setState calls minPixelSize + // which requires this.dataset to be available + // Ensure dataset is fully initialized + if (!dataset.chromosomes || dataset.chromosomes.length === 0) { + throw new Error("LiveMapDataset chromosomes array is not initialized"); + } + this.browser.setActiveDataset(dataset, state); + await this.browser.setState(state); + + this.browser.notifyMapLoaded(dataset, state, dataset.datasetType); + + return dataset; + } catch (error) { + this.browser.contactMapLabel.textContent = ""; + this.browser.contactMapLabel.title = ""; + throw error; + } finally { + this.browser.stopSpinner(); + if (!noUpdates) { + this.browser.userInteractionShield.style.display = 'none'; + } + } + } + + /** + * Load a .hic file for a control map + * + * NOTE: public API function + * + * @param {Object} config - Configuration object with url, name, nvi, etc. + * @param {boolean} noUpdates - If true, don't trigger UI updates + * @returns {Promise} - The loaded control dataset + */ + async loadHicControlFile(config, noUpdates) { + try { + this.browser.userInteractionShield.style.display = 'block'; + this.browser.contactMatrixView.startSpinner(); + this.browser.controlUrl = config.url; + const name = extractName(config); + config.name = name; + + const hicFileAlert = str => { + this.browser.notifyNormalizationExternalChange('NONE'); + Alert.presentAlert(str); + }; + + const controlDataset = await Dataset.loadDataset(Object.assign({alert: hicFileAlert}, config)); + + controlDataset.name = name; + + if (!this.browser.activeDataset || this.browser.activeDataset.isCompatible(controlDataset)) { + this.browser.controlDataset = controlDataset; + if (this.browser.activeDataset) { + this.browser.contactMapLabel.textContent = "A: " + this.browser.activeDataset.name; + } + this.browser.controlMapLabel.textContent = "B: " + controlDataset.name; + this.browser.controlMapLabel.title = controlDataset.name; + + //For the control dataset, block until the norm vector index is loaded + if (controlDataset.getNormVectorIndex) { + await controlDataset.getNormVectorIndex(config); + } + this.browser.notifyControlMapLoaded(this.browser.controlDataset); + + if (!noUpdates) { + await this.browser.update(); + } + + return controlDataset; + } else { + Alert.presentAlert( + '"B" map genome (' + controlDataset.genomeId + ') does not match "A" map genome (' + + this.browser.genome.id + ')' + ); + return undefined; + } + } finally { + this.browser.userInteractionShield.style.display = 'none'; + this.browser.stopSpinner(); + } + } + + /** + * Load tracks (1D and 2D) from configuration. + * + * @param {Array} configs - Array of track configuration objects + * @returns {Promise} + */ + async loadTracks(configs) { + const errorPrefix = configs.length === 1 ? + `Error loading track ${configs[0].name}` : + "Error loading tracks"; + + try { + this.browser.contactMatrixView.startSpinner(); + + const tracks = []; + const promises2D = []; + + for (let config of configs) { + const fileName = isFile(config.url) + ? config.url.name + : config.filename || await FileUtils.getFilename(config.url); + + const extension = hicUtils.getExtension(fileName); + + if (['fasta', 'fa'].includes(extension)) { + config.type = config.format = 'sequence'; + } + + if (!config.format) { + config.format = igv.TrackUtils.inferFileFormat(fileName); + } + + if (config.type === 'annotation') { + config.displayMode = 'COLLAPSED'; + if (config.color === DEFAULT_ANNOTATION_COLOR) { + delete config.color; + } + } + + if (config.max === undefined) { + config.autoscale = true; + } + + const { trackHeight } = getLayoutDimensions(); + config.height = trackHeight; + + if (config.format === undefined || ['bedpe', 'interact'].includes(config.format)) { + promises2D.push(Track2D.loadTrack2D(config, this.browser.genome)); + } else { + const track = await igv.createTrack(config, this.browser); + + if (typeof track.postInit === 'function') { + await track.postInit(); + } + + tracks.push(track); + } + } + + if (tracks.length > 0) { + this.browser.layoutController.updateLayoutWithTracks(tracks); + + const gearContainer = document.querySelector('.hic-igv-right-hand-gutter'); + if (this.browser.showTrackLabelAndGutter) { + gearContainer.style.display = 'block'; + } else { + gearContainer.style.display = 'none'; + } + + await this.browser.updateLayout(); + } + + if (promises2D.length > 0) { + const tracks2D = await Promise.all(promises2D); + if (tracks2D && tracks2D.length > 0) { + this.browser.tracks2D = this.browser.tracks2D.concat(tracks2D); + this.browser.notifyTrackLoad2D(this.browser.tracks2D); + } + } + + } catch (error) { + presentError(errorPrefix, error); + console.error(error); + } finally { + this.browser.contactMatrixView.stopSpinner(); + } + } + + /** + * Load a normalization vector file. + * + * @param {string} url - URL of the normalization vector file + * @returns {Promise} - The normalization vectors object + */ + async loadNormalizationFile(url) { + if (!this.browser.activeDataset) { + return; + } + + // Normalization files are only supported for Hi-C datasets + if (!this.browser.activeDataset.hicFile) { + console.warn("Normalization files are only supported for Hi-C datasets"); + return; + } + + this.browser.notifyNormalizationFileLoad("start"); + + const normVectors = await this.browser.activeDataset.hicFile.readNormalizationVectorFile( + url, + this.browser.activeDataset.chromosomes + ); + + for (let type of normVectors['types']) { + if (!this.browser.activeDataset.normalizationTypes) { + this.browser.activeDataset.normalizationTypes = []; + } + if (!this.browser.activeDataset.normalizationTypes.includes(type)) { + this.browser.activeDataset.normalizationTypes.push(type); + } + this.browser.notifyNormVectorIndexLoad(this.browser.activeDataset); + } + + this.browser.notifyNormalizationFileLoad("stop"); + + return normVectors; + } +} + +export default DataLoader; + diff --git a/js/hicBrowser.js b/js/hicBrowser.js index 28f6107c..2c42670e 100644 --- a/js/hicBrowser.js +++ b/js/hicBrowser.js @@ -25,32 +25,22 @@ * @author Jim Robinson */ -import igv from '../node_modules/igv/dist/igv.esm.js' -import {Alert, InputDialog, DOMUtils} from '../node_modules/igv-ui/dist/igv-ui.js' -import {FileUtils} from '../node_modules/igv-utils/src/index.js' +import {InputDialog, DOMUtils} from '../node_modules/igv-ui/dist/igv-ui.js' import * as hicUtils from './hicUtils.js' import {Globals} from "./globals.js" import EventBus from "./eventBus.js" -import Track2D from './track2D.js' -import LayoutController, {getLayoutDimensions, setViewportSize} from './layoutController.js' -import HICEvent from './hicEvent.js' -import Dataset from './hicDataset.js' -import LiveMapDataset from './liveMapDataset.js' -import Genome from './genome.js' -import State from './hicState.js' +import LayoutController, {setViewportSize} from './layoutController.js' import { geneSearch } from './geneSearch.js' -import {getAllBrowsers, syncBrowsers} from "./createBrowser.js" -import {isFile} from "./fileUtils.js" +import {getAllBrowsers} from "./createBrowser.js" import {setTrackReorderArrowColors} from "./trackPair.js" -import nvi from './nvi.js' -import {extractName, presentError} from "./utils.js" import BrowserUIManager from "./browserUIManager.js" import NotificationCoordinator from "./notificationCoordinator.js" import StateManager from "./stateManager.js" +import InteractionHandler from "./interactionHandler.js" +import DataLoader from "./dataLoader.js" const DEFAULT_PIXEL_SIZE = 1 const MAX_PIXEL_SIZE = 128 -const DEFAULT_ANNOTATION_COLOR = "rgb(22, 129, 198)" class HICBrowser { @@ -100,6 +90,12 @@ class HICBrowser { // Initialize notification coordinator for UI updates this.notifications = new NotificationCoordinator(this); + // Initialize interaction handler for user interactions + this.interactions = new InteractionHandler(this); + + // Initialize data loader for data loading operations + this.dataLoader = new DataLoader(this); + // prevent user interaction during lengthy data loads this.userInteractionShield = document.createElement('div'); this.userInteractionShield.className = 'hic-root-prevent-interaction'; @@ -118,10 +114,10 @@ class HICBrowser { this.contactMatrixView.startSpinner(); this.userInteractionShield.style.display = 'block'; - await this.loadHicFile(config, true); + await this.dataLoader.loadHicFile(config, true); if (config.controlUrl) { - await this.loadHicControlFile({ + await this.dataLoader.loadHicControlFile({ url: config.controlUrl, name: config.controlName, nvi: config.controlNvi, @@ -149,12 +145,12 @@ class HICBrowser { const promises = []; if (config.tracks) { - promises.push(this.loadTracks(config.tracks)); + promises.push(this.dataLoader.loadTracks(config.tracks)); } if (config.normVectorFiles) { config.normVectorFiles.forEach(nv => { - promises.push(this.loadNormalizationFile(nv)); + promises.push(this.dataLoader.loadNormalizationFile(nv)); }); } @@ -459,108 +455,11 @@ class HICBrowser { * @param configs */ async loadTracks(configs) { - const errorPrefix = configs.length === 1 ? `Error loading track ${configs[0].name}` : "Error loading tracks"; - - try { - this.contactMatrixView.startSpinner(); - - const tracks = []; - const promises2D = []; - - for (let config of configs) { - const fileName = isFile(config.url) - ? config.url.name - : config.filename || await FileUtils.getFilename(config.url); - - const extension = hicUtils.getExtension(fileName); - - if (['fasta', 'fa'].includes(extension)) { - config.type = config.format = 'sequence'; - } - - if (!config.format) { - config.format = igv.TrackUtils.inferFileFormat(fileName); - } - - if (config.type === 'annotation') { - config.displayMode = 'COLLAPSED'; - if (config.color === DEFAULT_ANNOTATION_COLOR) { - delete config.color; - } - } - - if (config.max === undefined) { - config.autoscale = true; - } - - const { trackHeight } = getLayoutDimensions() - config.height = trackHeight; - - if (config.format === undefined || ['bedpe', 'interact'].includes(config.format)) { - promises2D.push(Track2D.loadTrack2D(config, this.genome)); - } else { - const track = await igv.createTrack(config, this); - - if (typeof track.postInit === 'function') { - await track.postInit(); - } - - tracks.push(track); - } - } - - if (tracks.length > 0) { - this.layoutController.updateLayoutWithTracks(tracks); - - const gearContainer = document.querySelector('.hic-igv-right-hand-gutter'); - if (this.showTrackLabelAndGutter) { - gearContainer.style.display = 'block'; - } else { - gearContainer.style.display = 'none'; - } - - await this.updateLayout(); - } - - if (promises2D.length > 0) { - const tracks2D = await Promise.all(promises2D); - if (tracks2D && tracks2D.length > 0) { - this.tracks2D = this.tracks2D.concat(tracks2D); - this.notifyTrackLoad2D(this.tracks2D); - } - } - - } catch (error) { - presentError(errorPrefix, error); - console.error(error); - - } finally { - this.contactMatrixView.stopSpinner(); - } + return this.dataLoader.loadTracks(configs); } async loadNormalizationFile(url) { - - if (!this.activeDataset) return - // Normalization files are only supported for Hi-C datasets - if (!this.activeDataset.hicFile) { - console.warn("Normalization files are only supported for Hi-C datasets"); - return; - } - this.notifyNormalizationFileLoad("start") - - const normVectors = await this.activeDataset.hicFile.readNormalizationVectorFile(url, this.activeDataset.chromosomes) - for (let type of normVectors['types']) { - if (!this.activeDataset.normalizationTypes) { - this.activeDataset.normalizationTypes = [] - } - if (!this.activeDataset.normalizationTypes.includes(type)) { - this.activeDataset.normalizationTypes.push(type) - } - this.notifyNormVectorIndexLoad(this.activeDataset) - } - - return normVectors + return this.dataLoader.loadNormalizationFile(url); } /** @@ -716,6 +615,11 @@ class HICBrowser { this.synchedBrowsers = new Set(list.filter(b => b !== browser)) } + /** + * Data loading methods delegate to DataLoader. + * These methods are kept for backward compatibility and to maintain the public API. + */ + /** * Load a .hic file * @@ -726,125 +630,7 @@ class HICBrowser { * @param noUpdates */ async loadHicFile(config, noUpdates) { - - if (!config.url) { - console.log("No .hic url specified") - return undefined - } - - this.clearSession() - - try { - - this.contactMatrixView.startSpinner() - if (!noUpdates) { - this.userInteractionShield.style.display = 'block'; - } - - const name = extractName(config) - const prefix = this.controlDataset ? "A: " : "" - this.contactMapLabel.textContent = prefix + name; - this.contactMapLabel.title = name - config.name = name - - const hicFileAlert = str => { - this.notifyNormalizationExternalChange('NONE') - Alert.presentAlert(str) - } - - const dataset = await Dataset.loadDataset(Object.assign({alert: hicFileAlert}, config)) - dataset.name = name - - const previousGenomeId = this.genome ? this.genome.id : undefined - this.genome = new Genome(dataset.genomeId, dataset.chromosomes) - - if (this.genome.id !== previousGenomeId) { - EventBus.globalBus.post(HICEvent("GenomeChange", this.genome.id)) - } - - let state; - if (config.locus) { - state = State.default(config) - this.setActiveDataset(dataset, state); - await this.parseGotoInput(config.locus) - } else if (config.state) { - - if (typeof config.state === 'string') { - state = State.parse(config.state); - } else if (typeof config.state === 'object') { - state = State.fromJSON(config.state); - } else { - alert('config.state is of unknown type') - console.error('config.state is of unknown type') - state = State.default(config); - } - - // Set active dataset before setState so configureLocus can access bpResolutions - this.setActiveDataset(dataset, state); - await this.setState(state) - - } else if (config.synchState && this.canBeSynched(config.synchState)) { - await this.syncState(config.synchState) - state = this.activeState; - // syncState already sets activeDataset, but ensure it's set with current dataset - if (this.activeDataset !== dataset) { - this.setActiveDataset(dataset, state); - } - } else { - state = State.default(config); - // Set active dataset before setState so configureLocus can access bpResolutions - this.setActiveDataset(dataset, state); - await this.setState(state) - } - - this.notifyMapLoaded(dataset, state, dataset.datasetType) - - // Initiate loading of the norm vector index, but don't block if the "nvi" parameter is not available. - // Let it load in the background - - // If nvi is not supplied, try lookup table of known values - if (!config.nvi && typeof config.url === "string") { - const url = new URL(config.url) - const key = encodeURIComponent(url.hostname + url.pathname) - if (nvi.hasOwnProperty(key)) { - config.nvi = nvi[key] - } - } - - if (config.nvi && dataset.getNormVectorIndex) { - await dataset.getNormVectorIndex(config) - if (!config.isControl) { - this.notifyNormVectorIndexLoad(dataset) - } - } else if (dataset.getNormVectorIndex) { - - dataset.getNormVectorIndex(config) - .then(normVectorIndex => { - if (!config.isControl) { - this.notifyNormVectorIndexLoad(dataset) - } - }) - } - - syncBrowsers() - - // Find a browser to sync with, if any - const compatibleBrowsers = getAllBrowsers().filter(b => b !== this && b.activeDataset && b.activeDataset.isCompatible(this.activeDataset)) - if (compatibleBrowsers.length > 0) { - await this.syncState(compatibleBrowsers[0].getSyncState()) - } - - } catch (error) { - this.contactMapLabel.textContent = ""; - this.contactMapLabel.title = ""; - config.name = name - throw error - } finally { - this.stopSpinner() - if (!noUpdates) { - this.userInteractionShield.style.display = 'none'; - } - } + return this.dataLoader.loadHicFile(config, noUpdates); } /** @@ -852,75 +638,12 @@ class HICBrowser { * * NOTE: public API function * - * @param {Object} config - Configuration object with: - * - contactRecordList: Array of contact records OR - * - contactMatrix: 2D array of contact totals - * - chromosomes: Array of chromosome definitions - * - genomeId: Genome identifier - * - bpResolutions: Array of available resolutions - * - name: Dataset name - * - binSize: Bin size (if using contactMatrix) - * - state: Optional initial state + * @param {Object} config - Configuration object * @param {boolean} noUpdates - If true, don't trigger UI updates * @returns {Promise} */ async loadLiveMapDataset(config, noUpdates) { - this.clearSession(); - - try { - this.contactMatrixView.startSpinner(); - if (!noUpdates) { - this.userInteractionShield.style.display = 'block'; - } - - const name = config.name || 'Live Map'; - this.contactMapLabel.textContent = name; - this.contactMapLabel.title = name; - - const dataset = new LiveMapDataset(config); - await dataset.init(); - - const previousGenomeId = this.genome ? this.genome.id : undefined; - this.genome = new Genome(dataset.genomeId, dataset.chromosomes); - - if (this.genome.id !== previousGenomeId) { - EventBus.globalBus.post(HICEvent("GenomeChange", this.genome.id)); - } - - let state; - if (config.state) { - if (typeof config.state === 'string') { - state = State.parse(config.state); - } else if (typeof config.state === 'object') { - state = State.fromJSON(config.state); - } else { - state = State.default(config); - } - } else { - state = State.default(config); - } - - // Set active dataset BEFORE setState, since setState calls minPixelSize - // which requires this.dataset to be available - // Ensure dataset is fully initialized - if (!dataset.chromosomes || dataset.chromosomes.length === 0) { - throw new Error("LiveMapDataset chromosomes array is not initialized"); - } - this.setActiveDataset(dataset, state); - await this.setState(state); - - this.notifyMapLoaded(dataset, state, dataset.datasetType); - - } catch (error) { - this.contactMapLabel.textContent = ""; - this.contactMapLabel.title = ""; - throw error; - } finally { - this.stopSpinner(); - if (!noUpdates) { - this.userInteractionShield.style.display = 'none'; - } - } + return this.dataLoader.loadLiveMapDataset(config, noUpdates); } /** @@ -932,101 +655,15 @@ class HICBrowser { * @param config */ async loadHicControlFile(config, noUpdates) { - - try { - this.userInteractionShield.style.display = 'block'; - this.contactMatrixView.startSpinner() - this.controlUrl = config.url - const name = extractName(config) - config.name = name - - const hicFileAlert = str => { - this.notifyNormalizationExternalChange('NONE') - Alert.presentAlert(str) - } - - const controlDataset = await Dataset.loadDataset(Object.assign({alert: hicFileAlert}, config)) - - controlDataset.name = name - - if (!this.activeDataset || this.activeDataset.isCompatible(controlDataset)) { - this.controlDataset = controlDataset - if (this.activeDataset) { - this.contactMapLabel.textContent = "A: " + this.activeDataset.name; - } - this.controlMapLabel.textContent = "B: " + controlDataset.name - this.controlMapLabel.title = controlDataset.name - - //For the control dataset, block until the norm vector index is loaded - if (controlDataset.getNormVectorIndex) { - await controlDataset.getNormVectorIndex(config) - } - this.notifyControlMapLoaded(this.controlDataset) - - if (!noUpdates) { - await this.update() - } - } else { - Alert.presentAlert('"B" map genome (' + controlDataset.genomeId + ') does not match "A" map genome (' + this.genome.id + ')') - } - } finally { - this.userInteractionShield.style.display = 'none'; - this.stopSpinner() - } + return this.dataLoader.loadHicControlFile(config, noUpdates); } async parseGotoInput(input) { - const loci = input.trim().split(' '); - - let xLocus = this.parseLocusString(loci[0]) || await this.lookupFeatureOrGene(loci[0]); - - if (!xLocus) { - console.error(`No feature found with name ${loci[ 0 ]}`) - alert(`No feature found with name ${loci[ 0 ]}`) - return; - } - - let yLocus = loci[1] ? this.parseLocusString(loci[1]) : { ...xLocus } - if (!yLocus) { - yLocus = { ...xLocus } - } - - if (xLocus.wholeChr && yLocus.wholeChr || 'All' === xLocus.chr && 'All' === yLocus.chr) { - await this.setChromosomes(xLocus, yLocus) - } else { - await this.goto(xLocus.chr, xLocus.start, xLocus.end, yLocus.chr, yLocus.start, yLocus.end) - } + return this.interactions.parseGotoInput(input); } parseLocusString(locus) { - const [chrName, range] = locus.trim().toLowerCase().split(':'); - const chromosome = this.genome.getChromosome(chrName); - - if (!chromosome) { - return undefined; - } - - const locusObject = - { - chr: chromosome.name, - wholeChr: (undefined === range && 'All' !== chromosome.name) - }; - - if (true === locusObject.wholeChr || 'All' === chromosome.name) { - // Chromosome name only or All: Set to whole range - locusObject.start = 0; - locusObject.end = chromosome.size - } else { - - const [startStr, endStr] = range.split('-').map(part => part.replace(/,/g, '')); - - // Internally, loci are 0-based. - locusObject.start = isNaN(startStr) ? undefined : parseInt(startStr, 10) - 1; - locusObject.end = isNaN(endStr) ? undefined : parseInt(endStr, 10); - - } - - return locusObject; + return this.interactions.parseLocusString(locus); } async lookupFeatureOrGene(name) { @@ -1053,18 +690,13 @@ class HICBrowser { return undefined; // No match found } - async goto(chr1, bpX, bpXMax, chr2, bpY, bpYMax) { - - const { width, height } = this.contactMatrixView.getViewDimensions() - const { chrChanged, resolutionChanged } = this.state.updateWithLoci(chr1, bpX, bpXMax, chr2, bpY, bpYMax, this, width, height) - - this.contactMatrixView.clearImageCaches() - - const eventData = { state: this.state, resolutionChanged, chrChanged } - - await this.update() - this.notifyLocusChange(eventData) + /** + * Interaction methods delegate to InteractionHandler. + * These methods are kept for backward compatibility and to maintain the public API. + */ + async goto(chr1, bpX, bpXMax, chr2, bpY, bpYMax) { + return this.interactions.goto(chr1, bpX, bpXMax, chr2, bpY, bpYMax); } /** @@ -1078,16 +710,8 @@ class HICBrowser { * @returns {number} */ findMatchingZoomIndex(targetResolution, resolutionArray) { - const isObject = resolutionArray.length > 0 && resolutionArray[0].index !== undefined - for (let z = resolutionArray.length - 1; z > 0; z--) { - const binSize = isObject ? resolutionArray[z].binSize : resolutionArray[z] - const index = isObject ? resolutionArray[z].index : z - if (binSize >= targetResolution) { - return index - } - } - return 0 - }; + return this.interactions.findMatchingZoomIndex(targetResolution, resolutionArray); + } /** * @param scaleFactor Values range from greater then 1 to decimal values less then one @@ -1097,59 +721,7 @@ class HICBrowser { * @param anchorPy */ async pinchZoom(anchorPx, anchorPy, scaleFactor) { - - if (this.state.chr1 === 0) { - await this.zoomAndCenter(1, anchorPx, anchorPy) - } else { - try { - this.startSpinner() - - const bpResolutions = this.getResolutions() - const currentResolution = bpResolutions[this.state.zoom] - - let newBinSize - let newZoom - let newPixelSize - let resolutionChanged - - if (this.resolutionLocked || - (this.state.zoom === bpResolutions.length - 1 && scaleFactor > 1) || - (this.state.zoom === 0 && scaleFactor < 1)) { - // Can't change resolution level, must adjust pixel size - newBinSize = currentResolution.binSize - newPixelSize = Math.min(MAX_PIXEL_SIZE, this.state.pixelSize * scaleFactor) - newZoom = this.state.zoom - resolutionChanged = false - } else { - const targetBinSize = (currentResolution.binSize / this.state.pixelSize) / scaleFactor - newZoom = this.findMatchingZoomIndex(targetBinSize, bpResolutions) - newBinSize = bpResolutions[newZoom].binSize - resolutionChanged = newZoom !== this.state.zoom - newPixelSize = Math.min(MAX_PIXEL_SIZE, newBinSize / targetBinSize) - } - const z = await this.minZoom(this.state.chr1, this.state.chr2) - - - if (!this.resolutionLocked && scaleFactor < 1 && newZoom < z) { - // Zoom out to whole genome - const xLocus = this.parseLocusString('1') - const yLocus = { xLocus } - await this.setChromosomes(xLocus, yLocus) - } else { - - await this.state.panWithZoom(newZoom, newPixelSize, anchorPx, anchorPy, newBinSize, this, this.dataset, this.contactMatrixView.getViewDimensions(), bpResolutions) - - await this.contactMatrixView.zoomIn(anchorPx, anchorPy, 1/scaleFactor) - - const eventData = { state: this.state, resolutionChanged, chrChanged: false } - await this.update() - this.notifyLocusChange(eventData) - } - } finally { - this.stopSpinner() - } - } - + return this.interactions.pinchZoom(anchorPx, anchorPy, scaleFactor); } // Zoom in response to a double-click @@ -1161,67 +733,7 @@ class HICBrowser { * @returns {Promise} */ async zoomAndCenter(direction, centerPX, centerPY) { - - if (undefined === this.dataset) { - console.warn('Dataset is undefined') - return - } - - if (this.dataset.isWholeGenome(this.state.chr1) && direction > 0) { - // jump from whole genome to chromosome - const genomeCoordX = centerPX * this.dataset.wholeGenomeResolution / this.state.pixelSize - const genomeCoordY = centerPY * this.dataset.wholeGenomeResolution / this.state.pixelSize - const chrX = this.genome.getChromosomeForCoordinate(genomeCoordX) - const chrY = this.genome.getChromosomeForCoordinate(genomeCoordY) - const xLocus = { chr: chrX.name, start: 0, end: chrX.size, wholeChr: true } - const yLocus = { chr: chrY.name, start: 0, end: chrY.size, wholeChr: true } - await this.setChromosomes(xLocus, yLocus) - } else { - - const { width, height } = this.contactMatrixView.getViewDimensions() - - const dx = centerPX === undefined ? 0 : centerPX - width / 2 - this.state.x += (dx / this.state.pixelSize) - - const dy = centerPY === undefined ? 0 : centerPY - height / 2 - this.state.y += (dy / this.state.pixelSize) - - const resolutions = this.getResolutions() - const directionPositive = direction > 0 && this.state.zoom === resolutions[resolutions.length - 1].index - const directionNegative = direction < 0 && this.state.zoom === resolutions[0].index - if (this.resolutionLocked || directionPositive || directionNegative) { - - const minPS = await this.minPixelSize(this.state.chr1, this.state.chr2, this.state.zoom) - - const newPixelSize = Math.max(Math.min(MAX_PIXEL_SIZE, this.state.pixelSize * (direction > 0 ? 2 : 0.5)), minPS) - - const shiftRatio = (newPixelSize - this.state.pixelSize) / newPixelSize - - this.state.pixelSize = newPixelSize - - - this.state.x += shiftRatio * (width / this.state.pixelSize) - this.state.y += shiftRatio * (height / this.state.pixelSize) - - this.state.clampXY(this.dataset, this.contactMatrixView.getViewDimensions()) - - this.state.configureLocus(this, this.dataset, { width, height }) - - const eventData = { state: this.state, resolutionChanged: false, chrChanged: false } - await this.update() - this.notifyLocusChange(eventData) - - } else { - let i - for (i = 0; i < resolutions.length; i++) { - if (this.state.zoom === resolutions[i].index) break - } - if (i) { - const newZoom = resolutions[i + direction].index - this.setZoom(newZoom) - } - } - } + return this.interactions.zoomAndCenter(direction, centerPX, centerPY); } /** @@ -1230,50 +742,11 @@ class HICBrowser { * @returns {Promise} */ async setZoom(zoom) { - - const resolutionChanged = await this.state.setWithZoom(zoom, this.contactMatrixView.getViewDimensions(), this, this.dataset) - - await this.contactMatrixView.zoomIn() - - const eventData = { state: this.state, resolutionChanged, chrChanged: false } - await this.update() - this.notifyLocusChange(eventData) - + return this.interactions.setZoom(zoom); } async setChromosomes(xLocus, yLocus) { - - const { index:chr1Index } = this.genome.getChromosome(xLocus.chr) - const { index:chr2Index } = this.genome.getChromosome(yLocus.chr) - - this.state.chr1 = Math.min(chr1Index, chr2Index) - this.state.x = 0 - - this.state.chr2 = Math.max(chr1Index, chr2Index) - this.state.y = 0 - - this.state.locus = - { - x: { chr: xLocus.chr, start: xLocus.start, end: xLocus.end }, - y: { chr: yLocus.chr, start: yLocus.start, end: yLocus.end } - }; - - if (xLocus.wholeChr && yLocus.wholeChr) { - this.state.zoom = await this.minZoom(this.state.chr1, this.state.chr2) - const minPS = await this.minPixelSize(this.state.chr1, this.state.chr2, this.state.zoom) - this.state.pixelSize = Math.min(100, Math.max(DEFAULT_PIXEL_SIZE, minPS)) - } else { - // Whole Genome - this.state.zoom = 0 - const minPS = await this.minPixelSize(this.state.chr1, this.state.chr2, this.state.zoom) - this.state.pixelSize = Math.max(this.state.pixelSize, minPS) - - } - - const eventData = { state: this.state, resolutionChanged: true, chrChanged: true } - await this.update() - this.notifyLocusChange(eventData) - + return this.interactions.setChromosomes(xLocus, yLocus); } /** @@ -1310,10 +783,10 @@ class HICBrowser { async setState(state) { const { chrChanged, resolutionChanged } = await this.stateManager.setState(state); - const eventData = { - state: this.state, - resolutionChanged, - chrChanged + const eventData = { + state: this.state, + resolutionChanged, + chrChanged }; await this.update(); this.notifyLocusChange(eventData); @@ -1357,23 +830,7 @@ class HICBrowser { } async shiftPixels(dx, dy) { - - if (undefined === this.dataset) { - console.warn('dataset is undefined') - return - } - - this.state.panShift(dx, dy, this, this.dataset, this.contactMatrixView.getViewDimensions()) - - const eventData = { - state: this.state, - resolutionChanged: false, - dragging: true, - chrChanged: false - } - - await this.update() - this.notifyLocusChange(eventData) + return this.interactions.shiftPixels(dx, dy); } /** diff --git a/js/interactionHandler.js b/js/interactionHandler.js new file mode 100644 index 00000000..d5e93dbe --- /dev/null +++ b/js/interactionHandler.js @@ -0,0 +1,405 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2016-2017 The Regents of the University of California + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the + * following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import {DEFAULT_PIXEL_SIZE, MAX_PIXEL_SIZE} from "./hicBrowser.js" + +/** + * InteractionHandler handles all user interaction responsibilities for HICBrowser. + * Extracted from HICBrowser to separate interaction handling concerns. + * + * This class manages: + * - Navigation (goto, setChromosomes) + * - Zoom operations (pinchZoom, zoomAndCenter, setZoom) + * - Pan operations (shiftPixels) + * - Locus parsing (parseGotoInput, parseLocusString) + * - Zoom index finding (findMatchingZoomIndex) + */ +class InteractionHandler { + + /** + * @param {HICBrowser} browser - The browser instance this handler serves + */ + constructor(browser) { + this.browser = browser; + } + + /** + * Navigate to a specific genomic locus. + * + * @param {string|number} chr1 - Chromosome 1 name or index + * @param {number} bpX - Start base pair for X axis + * @param {number} bpXMax - End base pair for X axis + * @param {string|number} chr2 - Chromosome 2 name or index + * @param {number} bpY - Start base pair for Y axis + * @param {number} bpYMax - End base pair for Y axis + */ + async goto(chr1, bpX, bpXMax, chr2, bpY, bpYMax) { + const { width, height } = this.browser.contactMatrixView.getViewDimensions(); + const { chrChanged, resolutionChanged } = this.browser.state.updateWithLoci( + chr1, bpX, bpXMax, chr2, bpY, bpYMax, + this.browser, width, height + ); + + this.browser.contactMatrixView.clearImageCaches(); + + const eventData = { + state: this.browser.state, + resolutionChanged, + chrChanged + }; + + await this.browser.update(); + this.browser.notifyLocusChange(eventData); + } + + /** + * Pan the view by pixel offset. + * + * @param {number} dx - X pixel offset + * @param {number} dy - Y pixel offset + */ + async shiftPixels(dx, dy) { + if (undefined === this.browser.dataset) { + console.warn('dataset is undefined'); + return; + } + + this.browser.state.panShift( + dx, dy, + this.browser, + this.browser.dataset, + this.browser.contactMatrixView.getViewDimensions() + ); + + const eventData = { + state: this.browser.state, + resolutionChanged: false, + dragging: true, + chrChanged: false + }; + + await this.browser.update(); + this.browser.notifyLocusChange(eventData); + } + + /** + * Handle pinch zoom gesture. + * + * @param {number} anchorPx - Anchor X position in pixels + * @param {number} anchorPy - Anchor Y position in pixels + * @param {number} scaleFactor - Scale factor (>1 = zoom in, <1 = zoom out) + */ + async pinchZoom(anchorPx, anchorPy, scaleFactor) { + if (this.browser.state.chr1 === 0) { + await this.zoomAndCenter(1, anchorPx, anchorPy); + return; + } + + try { + this.browser.startSpinner(); + + const bpResolutions = this.browser.getResolutions(); + const currentResolution = bpResolutions[this.browser.state.zoom]; + + let newBinSize; + let newZoom; + let newPixelSize; + let resolutionChanged; + + if (this.browser.resolutionLocked || + (this.browser.state.zoom === bpResolutions.length - 1 && scaleFactor > 1) || + (this.browser.state.zoom === 0 && scaleFactor < 1)) { + // Can't change resolution level, must adjust pixel size + newBinSize = currentResolution.binSize; + newPixelSize = Math.min(MAX_PIXEL_SIZE, this.browser.state.pixelSize * scaleFactor); + newZoom = this.browser.state.zoom; + resolutionChanged = false; + } else { + const targetBinSize = (currentResolution.binSize / this.browser.state.pixelSize) / scaleFactor; + newZoom = this.findMatchingZoomIndex(targetBinSize, bpResolutions); + newBinSize = bpResolutions[newZoom].binSize; + resolutionChanged = newZoom !== this.browser.state.zoom; + newPixelSize = Math.min(MAX_PIXEL_SIZE, newBinSize / targetBinSize); + } + + const z = await this.browser.minZoom(this.browser.state.chr1, this.browser.state.chr2); + + if (!this.browser.resolutionLocked && scaleFactor < 1 && newZoom < z) { + // Zoom out to whole genome + const xLocus = this.parseLocusString('1'); + const yLocus = { xLocus }; + await this.setChromosomes(xLocus, yLocus); + } else { + await this.browser.state.panWithZoom( + newZoom, newPixelSize, anchorPx, anchorPy, newBinSize, + this.browser, this.browser.dataset, + this.browser.contactMatrixView.getViewDimensions(), + bpResolutions + ); + + await this.browser.contactMatrixView.zoomIn(anchorPx, anchorPy, 1 / scaleFactor); + + const eventData = { + state: this.browser.state, + resolutionChanged, + chrChanged: false + }; + await this.browser.update(); + this.browser.notifyLocusChange(eventData); + } + } finally { + this.browser.stopSpinner(); + } + } + + /** + * Zoom and center on bins at given screen coordinates. + * Supports double-click zoom, pinch zoom. + * + * @param {number} direction - Zoom direction (>0 = zoom in, <0 = zoom out) + * @param {number} centerPX - Screen X coordinate to center on + * @param {number} centerPY - Screen Y coordinate to center on + */ + async zoomAndCenter(direction, centerPX, centerPY) { + if (undefined === this.browser.dataset) { + console.warn('Dataset is undefined'); + return; + } + + if (this.browser.dataset.isWholeGenome(this.browser.state.chr1) && direction > 0) { + // jump from whole genome to chromosome + const genomeCoordX = centerPX * this.browser.dataset.wholeGenomeResolution / this.browser.state.pixelSize; + const genomeCoordY = centerPY * this.browser.dataset.wholeGenomeResolution / this.browser.state.pixelSize; + const chrX = this.browser.genome.getChromosomeForCoordinate(genomeCoordX); + const chrY = this.browser.genome.getChromosomeForCoordinate(genomeCoordY); + const xLocus = { chr: chrX.name, start: 0, end: chrX.size, wholeChr: true }; + const yLocus = { chr: chrY.name, start: 0, end: chrY.size, wholeChr: true }; + await this.setChromosomes(xLocus, yLocus); + } else { + const { width, height } = this.browser.contactMatrixView.getViewDimensions(); + + const dx = centerPX === undefined ? 0 : centerPX - width / 2; + this.browser.state.x += (dx / this.browser.state.pixelSize); + + const dy = centerPY === undefined ? 0 : centerPY - height / 2; + this.browser.state.y += (dy / this.browser.state.pixelSize); + + const resolutions = this.browser.getResolutions(); + const directionPositive = direction > 0 && this.browser.state.zoom === resolutions[resolutions.length - 1].index; + const directionNegative = direction < 0 && this.browser.state.zoom === resolutions[0].index; + + if (this.browser.resolutionLocked || directionPositive || directionNegative) { + const minPS = await this.browser.minPixelSize( + this.browser.state.chr1, + this.browser.state.chr2, + this.browser.state.zoom + ); + + const newPixelSize = Math.max( + Math.min(MAX_PIXEL_SIZE, this.browser.state.pixelSize * (direction > 0 ? 2 : 0.5)), + minPS + ); + + const shiftRatio = (newPixelSize - this.browser.state.pixelSize) / newPixelSize; + + this.browser.state.pixelSize = newPixelSize; + + this.browser.state.x += shiftRatio * (width / this.browser.state.pixelSize); + this.browser.state.y += shiftRatio * (height / this.browser.state.pixelSize); + + this.browser.state.clampXY(this.browser.dataset, this.browser.contactMatrixView.getViewDimensions()); + this.browser.state.configureLocus(this.browser, this.browser.dataset, { width, height }); + + const eventData = { + state: this.browser.state, + resolutionChanged: false, + chrChanged: false + }; + await this.browser.update(); + this.browser.notifyLocusChange(eventData); + } else { + let i; + for (i = 0; i < resolutions.length; i++) { + if (this.browser.state.zoom === resolutions[i].index) break; + } + if (i !== undefined) { + const newZoom = resolutions[i + direction].index; + await this.setZoom(newZoom); + } + } + } + } + + /** + * Set the current zoom state. + * + * @param {number} zoom - Index to the datasets resolution array (dataset.bpResolutions) + */ + async setZoom(zoom) { + const resolutionChanged = await this.browser.state.setWithZoom( + zoom, + this.browser.contactMatrixView.getViewDimensions(), + this.browser, + this.browser.dataset + ); + + await this.browser.contactMatrixView.zoomIn(); + + const eventData = { + state: this.browser.state, + resolutionChanged, + chrChanged: false + }; + await this.browser.update(); + this.browser.notifyLocusChange(eventData); + } + + /** + * Set chromosome view. + * + * @param {Object} xLocus - X axis locus {chr, start, end, wholeChr?} + * @param {Object} yLocus - Y axis locus {chr, start, end, wholeChr?} + */ + async setChromosomes(xLocus, yLocus) { + const { index: chr1Index } = this.browser.genome.getChromosome(xLocus.chr); + const { index: chr2Index } = this.browser.genome.getChromosome(yLocus.chr); + + this.browser.state.chr1 = Math.min(chr1Index, chr2Index); + this.browser.state.x = 0; + + this.browser.state.chr2 = Math.max(chr1Index, chr2Index); + this.browser.state.y = 0; + + this.browser.state.locus = { + x: { chr: xLocus.chr, start: xLocus.start, end: xLocus.end }, + y: { chr: yLocus.chr, start: yLocus.start, end: yLocus.end } + }; + + if (xLocus.wholeChr && yLocus.wholeChr) { + this.browser.state.zoom = await this.browser.minZoom(this.browser.state.chr1, this.browser.state.chr2); + const minPS = await this.browser.minPixelSize(this.browser.state.chr1, this.browser.state.chr2, this.browser.state.zoom); + this.browser.state.pixelSize = Math.min(100, Math.max(DEFAULT_PIXEL_SIZE, minPS)); + } else { + // Whole Genome + this.browser.state.zoom = 0; + const minPS = await this.browser.minPixelSize(this.browser.state.chr1, this.browser.state.chr2, this.browser.state.zoom); + this.browser.state.pixelSize = Math.max(this.browser.state.pixelSize, minPS); + } + + const eventData = { + state: this.browser.state, + resolutionChanged: true, + chrChanged: true + }; + await this.browser.update(); + this.browser.notifyLocusChange(eventData); + } + + /** + * Find the closest matching zoom index for the target resolution. + * + * resolutionArray can be either: + * (1) an array of bin sizes + * (2) an array of objects with index and bin size + * + * @param {number} targetResolution - Target resolution in base pairs per bin + * @param {Array} resolutionArray - Array of resolutions + * @returns {number} - Matching zoom index + */ + findMatchingZoomIndex(targetResolution, resolutionArray) { + const isObject = resolutionArray.length > 0 && resolutionArray[0].index !== undefined; + for (let z = resolutionArray.length - 1; z > 0; z--) { + const binSize = isObject ? resolutionArray[z].binSize : resolutionArray[z]; + const index = isObject ? resolutionArray[z].index : z; + if (binSize >= targetResolution) { + return index; + } + } + return 0; + } + + /** + * Parse goto input string and navigate to the specified locus. + * + * @param {string} input - Input string in format "chr:start-end" or "chr:start-end chr:start-end" + * @returns {Promise} + */ + async parseGotoInput(input) { + const loci = input.trim().split(' '); + + let xLocus = this.parseLocusString(loci[0]) || await this.browser.lookupFeatureOrGene(loci[0]); + + if (!xLocus) { + console.error(`No feature found with name ${loci[0]}`); + alert(`No feature found with name ${loci[0]}`); + return; + } + + let yLocus = loci[1] ? this.parseLocusString(loci[1]) : { ...xLocus }; + if (!yLocus) { + yLocus = { ...xLocus }; + } + + if (xLocus.wholeChr && yLocus.wholeChr || 'All' === xLocus.chr && 'All' === yLocus.chr) { + await this.setChromosomes(xLocus, yLocus); + } else { + await this.goto(xLocus.chr, xLocus.start, xLocus.end, yLocus.chr, yLocus.start, yLocus.end); + } + } + + /** + * Parse a locus string into a locus object. + * + * @param {string} locus - Locus string in format "chr:start-end" or "chr" + * @returns {Object|undefined} - Locus object {chr, start, end, wholeChr?} or undefined if invalid + */ + parseLocusString(locus) { + const [chrName, range] = locus.trim().toLowerCase().split(':'); + const chromosome = this.browser.genome.getChromosome(chrName); + + if (!chromosome) { + return undefined; + } + + const locusObject = { + chr: chromosome.name, + wholeChr: (undefined === range && 'All' !== chromosome.name) + }; + + if (true === locusObject.wholeChr || 'All' === chromosome.name) { + // Chromosome name only or All: Set to whole range + locusObject.start = 0; + locusObject.end = chromosome.size; + } else { + const [startStr, endStr] = range.split('-').map(part => part.replace(/,/g, '')); + + // Internally, loci are 0-based. + locusObject.start = isNaN(startStr) ? undefined : parseInt(startStr, 10) - 1; + locusObject.end = isNaN(endStr) ? undefined : parseInt(endStr, 10); + } + + return locusObject; + } +} + +export default InteractionHandler; + diff --git a/js/urlUtils.js b/js/urlUtils.js index ca010d64..abcdf7df 100644 --- a/js/urlUtils.js +++ b/js/urlUtils.js @@ -335,4 +335,4 @@ async function expandURL(url) { } -export {extractConfig} +export {extractConfig, DEFAULT_ANNOTATION_COLOR} From c5f7ca27456f3e18638710f2ca80e6ab44f67fe0 Mon Sep 17 00:00:00 2001 From: turner Date: Fri, 7 Nov 2025 16:57:24 -0500 Subject: [PATCH 07/16] fowler refactors: phase 4D and 4E --- js/hicBrowser.js | 81 ++++------------------- js/renderCoordinator.js | 141 ++++++++++++++++++++++++++++++++++++++++ vite.config.js | 55 ++++++++++++++++ 3 files changed, 207 insertions(+), 70 deletions(-) create mode 100644 js/renderCoordinator.js diff --git a/js/hicBrowser.js b/js/hicBrowser.js index 2c42670e..0a6bd8bc 100644 --- a/js/hicBrowser.js +++ b/js/hicBrowser.js @@ -38,6 +38,7 @@ import NotificationCoordinator from "./notificationCoordinator.js" import StateManager from "./stateManager.js" import InteractionHandler from "./interactionHandler.js" import DataLoader from "./dataLoader.js" +import RenderCoordinator from "./renderCoordinator.js" const DEFAULT_PIXEL_SIZE = 1 const MAX_PIXEL_SIZE = 128 @@ -96,6 +97,9 @@ class HICBrowser { // Initialize data loader for data loading operations this.dataLoader = new DataLoader(this); + // Initialize render coordinator for rendering operations + this.renderCoordinator = new RenderCoordinator(this); + // prevent user interaction during lengthy data loads this.userInteractionShield = document.createElement('div'); this.userInteractionShield.className = 'hic-root-prevent-interaction'; @@ -106,7 +110,7 @@ class HICBrowser { } async init(config) { - this.pending = new Map(); + this.renderCoordinator.init(); this.contactMatrixView.disableUpdates = true; @@ -464,17 +468,12 @@ class HICBrowser { /** * Render the XY pair of tracks. + * Delegates to RenderCoordinator. * * @param xy */ async renderTrackXY(xy) { - - try { - this.startSpinner() - await xy.updateViews() - } finally { - this.stopSpinner() - } + return this.renderCoordinator.renderTrackXY(xy); } /** @@ -835,27 +834,10 @@ class HICBrowser { /** * Pure rendering method - repaints all visual components. - * Reads state directly from this.state, no parameters needed. - * This is the core rendering logic separated from update coordination. + * Delegates to RenderCoordinator. */ async repaint() { - if (!this.activeDataset || !this.activeState) { - return; // Can't render without dataset and state - } - - // Update rulers with current state - const pseudoEvent = { type: "LocusChange", data: { state: this.activeState } } - this.layoutController.xAxisRuler.locusChange(pseudoEvent) - this.layoutController.yAxisRuler.locusChange(pseudoEvent) - - // Render all tracks and contact matrix in parallel - const promises = [] - - for (let xyTrackRenderPair of this.trackPairs) { - promises.push(this.renderTrackXY(xyTrackRenderPair)) - } - promises.push(this.contactMatrixView.update()) - await Promise.all(promises) + return this.renderCoordinator.repaint(); } /** @@ -875,54 +857,13 @@ class HICBrowser { /** * Public API for updating/repainting the browser. - * - * Handles queuing logic for rapid calls (e.g., during mouse dragging). - * If called while an update is in progress, queues the request for later processing. - * Only the most recent request per type is kept in the queue. + * Delegates to RenderCoordinator. * * @param shouldSync - Whether to synchronize state to other browsers (default: true) * Set to false when called from syncState() to avoid infinite loops */ async update(shouldSync = true) { - - if (this.updating) { - // Queue this update request - use a simple key since we don't need event types anymore - this.pending.set("update", { shouldSync }) - return - } - - this.updating = true - try { - this.startSpinner() - - // Render everything - await this.repaint() - - // Optionally sync to other browsers - if (shouldSync) { - this.syncToOtherBrowsers() - } - - } finally { - this.updating = false - - // Process any queued updates - if (this.pending.size > 0) { - const queued = [] - for (let [k, v] of this.pending) { - queued.push(v) - } - this.pending.clear() - - // Process queued updates (only need to process the last one) - if (queued.length > 0) { - const lastQueued = queued[queued.length - 1] - await this.update(lastQueued.shouldSync) - } - } - - this.stopSpinner() - } + return this.renderCoordinator.update(shouldSync); } repaintMatrix() { diff --git a/js/renderCoordinator.js b/js/renderCoordinator.js new file mode 100644 index 00000000..3e6ace1a --- /dev/null +++ b/js/renderCoordinator.js @@ -0,0 +1,141 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2016-2017 The Regents of the University of California + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the + * following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * RenderCoordinator handles all rendering coordination responsibilities for HICBrowser. + * Extracted from HICBrowser to separate rendering coordination concerns. + * + * This class manages: + * - Rendering coordination and queuing + * - Repainting visual components + * - Track rendering + * - Update scheduling and batching + */ +class RenderCoordinator { + + /** + * @param {HICBrowser} browser - The browser instance this coordinator serves + */ + constructor(browser) { + this.browser = browser; + this.updating = false; + this.pending = new Map(); + } + + /** + * Pure rendering method - repaints all visual components. + * Reads state directly from browser state, no parameters needed. + * This is the core rendering logic separated from update coordination. + * + * @returns {Promise} + */ + async repaint() { + if (!this.browser.activeDataset || !this.browser.activeState) { + return; // Can't render without dataset and state + } + + // Update rulers with current state + const pseudoEvent = { + type: "LocusChange", + data: { state: this.browser.activeState } + }; + this.browser.layoutController.xAxisRuler.locusChange(pseudoEvent); + this.browser.layoutController.yAxisRuler.locusChange(pseudoEvent); + + // Render all tracks and contact matrix in parallel + const promises = []; + + for (let xyTrackRenderPair of this.browser.trackPairs) { + promises.push(this.renderTrackXY(xyTrackRenderPair)); + } + promises.push(this.browser.contactMatrixView.update()); + await Promise.all(promises); + } + + /** + * Render the XY pair of tracks. + * + * @param {TrackPair} xy - The track pair to render + * @returns {Promise} + */ + async renderTrackXY(xy) { + try { + this.browser.startSpinner(); + await xy.updateViews(); + } finally { + this.browser.stopSpinner(); + } + } + + /** + * Public API for updating/repainting the browser. + * + * Handles queuing logic for rapid calls (e.g., during mouse dragging). + * If called while an update is in progress, queues the request for later processing. + * Only the most recent request per type is kept in the queue. + * + * @param {boolean} shouldSync - Whether to synchronize state to other browsers (default: true) + * Set to false when called from syncState() to avoid infinite loops + * @returns {Promise} + */ + async update(shouldSync = true) { + if (this.updating) { + // Queue this update request - use a simple key since we don't need event types anymore + this.pending.set("update", { shouldSync }); + return; + } + + this.updating = true; + try { + this.browser.startSpinner(); + await this.repaint(); + if (shouldSync) { + this.browser.syncToOtherBrowsers(); + } + } finally { + this.updating = false; + if (this.pending.size > 0) { + const queued = []; + for (let [k, v] of this.pending) { + queued.push(v); + } + this.pending.clear(); + if (queued.length > 0) { + const lastQueued = queued[queued.length - 1]; + await this.update(lastQueued.shouldSync); + } + } + this.browser.stopSpinner(); + } + } + + /** + * Initialize the render coordinator. + * Called during browser initialization. + */ + init() { + this.pending = new Map(); + } +} + +export default RenderCoordinator; + diff --git a/vite.config.js b/vite.config.js index f717c000..028fe9e3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,6 +3,29 @@ import { resolve } from 'path'; import { viteStaticCopy } from 'vite-plugin-static-copy'; import { versionPlugin } from './vite-plugin-version.js'; +// Plugin to suppress source map warnings for third-party dependencies +// These warnings occur because igv-ui and igv reference CSS source maps that don't exist +function suppressSourceMapWarnings() { + return { + name: 'suppress-sourcemap-warnings', + configureServer(server) { + // Intercept WebSocket messages to filter out source map errors + const originalSend = server.ws.send; + server.ws.send = function (payload) { + if (payload.type === 'error' && payload.err) { + const errorMessage = payload.err.message || payload.err.toString() || ''; + // Suppress warnings about missing CSS source maps for igv dependencies + if (errorMessage.includes('igv-ui.css.map') || + errorMessage.includes('Failed to load source map')) { + return; // Don't send this error to the client + } + } + return originalSend.call(this, payload); + }; + }, + }; +} + export default defineConfig({ root: '.', publicDir: 'public', @@ -54,10 +77,42 @@ export default defineConfig({ { src: 'css/img', dest: 'css/' }, ], }), + suppressSourceMapWarnings(), ], resolve: { alias: { '@': resolve(__dirname, './'), }, }, + // Custom logger to suppress source map warnings for third-party dependencies + customLogger: { + warn(msg, options) { + // Filter out source map warnings for igv dependencies + const message = typeof msg === 'string' ? msg : String(msg); + if (message.includes('igv-ui.css.map') || + message.includes('Failed to load source map') || + (message.includes('ENOENT') && message.includes('igv'))) { + return; // Suppress these warnings + } + // Use default warning logger for other messages + console.warn(msg, options); + }, + error(msg, options) { + // Filter out source map errors for igv dependencies + const message = typeof msg === 'string' ? msg : String(msg); + if (message.includes('igv-ui.css.map') || + message.includes('Failed to load source map') || + (message.includes('ENOENT') && message.includes('igv'))) { + return; // Suppress these errors + } + // Use default error logger for other messages + console.error(msg, options); + }, + info(msg, options) { + console.info(msg, options); + }, + clearScreen() { + // Keep default clear screen behavior + }, + }, }); From c88184a68423bf0b258218c8e47f26e6d1cd8722 Mon Sep 17 00:00:00 2001 From: turner Date: Sat, 8 Nov 2025 10:10:37 -0500 Subject: [PATCH 08/16] gardening --- notes/hic-browser-refactor-0.md | 73 +++++++++++++++++++++++++++++++++ notes/hic-browser-refactor-1.md | 59 ++++++++++++++++++++++++++ package.json | 4 +- 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 notes/hic-browser-refactor-0.md create mode 100644 notes/hic-browser-refactor-1.md diff --git a/notes/hic-browser-refactor-0.md b/notes/hic-browser-refactor-0.md new file mode 100644 index 00000000..844e7888 --- /dev/null +++ b/notes/hic-browser-refactor-0.md @@ -0,0 +1,73 @@ +#### Phase 1: Extract methods from long notification methods +```javascript +notifyMapLoaded(dataset, state, datasetType) { + this.initializeContactMatrixView(); + this.updateAllUIComponents(dataset, state); +} + +updateAllUIComponents(dataset, state) { + this.updateChromosomeSelector(dataset); + this.updateRulers(dataset); + this.updateNormalizationWidget(dataset); + // etc. +} +``` + +#### Phase 2: Move behavior to appropriate classes +```javascript +// Instead of: +colorScaleWidget.minusButton.style.display = 'block'; + +// Do: +colorScaleWidget.showMinusButton(); +``` + +#### Phase 3: Extract utility functions +```javascript +// Extract paintSwatch to a utility or private method +_paintSwatch(swatch, { r, g, b }) { + swatch.style.backgroundColor = IGVColor.rgbToHex(IGVColor.rgbColor(r, g, b)); +} +``` + +#### Phase 4: Consider a notification coordinator +```javascript +class NotificationCoordinator { + constructor(browser) { + this.browser = browser; + } + + notifyMapLoaded(dataset, state) { + // Coordinate all notifications + } +} +``` + +### Overall assessment + +Grade: B+ (good progress, more work needed) + +Strengths: +- Clearer structure +- Better separation of concerns +- More explicit dependencies +- Preserved behavior + +Weaknesses: +- Long methods still present +- Duplicate code (`paintSwatch`) +- Feature envy (direct DOM access) +- Missing encapsulation in widgets + +### Fowler's likely verdict + +> "You've made good progress removing the event system complexity and separating rendering from coordination. However, the notification methods are still too long and do too much. Continue extracting methods, and move behavior to the classes that own the data. The goal isn't just to make it work—it's to make it easy to understand and modify." + +### Recommended next steps + +1. Extract methods from `notifyMapLoaded()` and `notifyLocusChange()` +2. Extract `paintSwatch` to a private method +3. Add widget methods to encapsulate DOM manipulation +4. Consider a notification coordinator if the pattern grows + +Should I continue with these improvements? \ No newline at end of file diff --git a/notes/hic-browser-refactor-1.md b/notes/hic-browser-refactor-1.md new file mode 100644 index 00000000..f34ec623 --- /dev/null +++ b/notes/hic-browser-refactor-1.md @@ -0,0 +1,59 @@ +## Recommended breakdown strategy (Fowler’s approach): + +### Phase 4A: Extract Notification Coordinator (low risk) +- Move all `notify*()` methods to a `NotificationCoordinator` +- `HICBrowser` delegates to it +- **Benefit**: Isolates UI coordination logic + +### Phase 4B: Extract State Manager (medium risk) +- Move state mutation methods (`setState`, `setZoom`, etc.) to `StateManager` +- `HICBrowser` becomes a facade that delegates +- **Benefit**: Centralizes state logic + +### Phase 4C: Extract Data Loader (medium risk) +- Move `loadHicFile()`, `loadTracks()`, etc. to `DataLoader` +- **Benefit**: Separates I/O from coordination + +### Phase 4D: Extract Rendering Coordinator (higher risk) +- Move `update()`, `repaint()`, rendering logic to `RenderingCoordinator` +- **Benefit**: Separates rendering from state management + +### Phase 4E: Extract Interaction Handler (lower risk) +- Move user interaction methods (`goto()`, `shiftPixels()`, etc.) to `InteractionHandler` +- **Benefit**: Separates user input from business logic + +## The Facade Pattern approach: + +After extraction, `HICBrowser` becomes a Facade: + +```javascript +class HICBrowser { + constructor() { + this.stateManager = new StateManager(this); + this.dataLoader = new DataLoader(this); + this.notificationCoordinator = new NotificationCoordinator(this); + this.renderingCoordinator = new RenderingCoordinator(this); + this.interactionHandler = new InteractionHandler(this); + this.syncManager = new BrowserSyncManager(this); + } + + // Public API - delegates to coordinators + async loadHicFile(config) { + await this.dataLoader.loadHicFile(config); + this.notificationCoordinator.notifyMapLoaded(...); + await this.renderingCoordinator.update(); + } +} +``` + +## Recommendation: + +Start with Phase 4A (Notification Coordinator) because: +1. Low risk — notification methods are already isolated +2. Clear boundaries — UI coordination is distinct +3. Immediate benefit — reduces `HICBrowser` size +4. Sets a pattern for further extractions + +Then proceed incrementally: State Manager → Data Loader → Rendering Coordinator → Interaction Handler. + +Should I proceed with Phase 4A (Notification Coordinator), or would you prefer a different starting point? \ No newline at end of file diff --git a/package.json b/package.json index 0b845519..9b32f975 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ } ], "bugs": { - "url": "https://github.com/igvteam/juicebox.js/issues" + "url": "https://github.com/aidenlab/juicebox.js/issues" }, "scripts": { "dev": "vite", @@ -45,7 +45,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/igvteam/juicebox.js.git" + "url": "git+https://github.com/aidenlab/juicebox.js.git" }, "devDependencies": { "@vitest/ui": "^4.0.7", From cb16304ae8934eb2b6cde06cbc57b44c5ee07538 Mon Sep 17 00:00:00 2001 From: turner Date: Sat, 8 Nov 2025 10:15:08 -0500 Subject: [PATCH 09/16] Added session loading test --- js/urlUtils.js | 45 ++++++++++++++++--- test/data/session.json | 47 +++++++++++++++++++ test/testSession.js | 100 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 test/data/session.json create mode 100644 test/testSession.js diff --git a/js/urlUtils.js b/js/urlUtils.js index abcdf7df..79f9bf72 100644 --- a/js/urlUtils.js +++ b/js/urlUtils.js @@ -24,6 +24,8 @@ import State from './hicState.js'; import ColorScale from "./colorScale.js" import {Globals} from "./globals.js"; import {StringUtils, BGZip} from '../node_modules/igv-utils/src/index.js' +import {isFile} from './fileUtils.js' +import {igvxhr} from '../node_modules/igv-utils/src/index.js' const DEFAULT_ANNOTATION_COLOR = "rgb(22, 129, 198)"; @@ -41,11 +43,44 @@ async function extractConfig(queryString) { let sessionConfig; if (query.hasOwnProperty("session")) { - if (query.session.startsWith("blob:") || query.session.startsWith("data:")) { - sessionConfig = JSON.parse(BGZip.uncompressString(query.session.substr(5))); - } else { - // TODO - handle session url - + const sessionValue = query.session; + if (sessionValue.startsWith("blob:") || sessionValue.startsWith("data:")) { + sessionConfig = JSON.parse(BGZip.uncompressString(sessionValue.substr(5))); + } else if (isFile(sessionValue)) { + // Handle File object + const sessionText = await sessionValue.text(); + try { + // Try to parse as compressed blob first + if (sessionText.startsWith("blob:") || sessionText.startsWith("data:")) { + sessionConfig = JSON.parse(BGZip.uncompressString(sessionText.substr(5))); + } else { + // Parse as plain JSON + sessionConfig = JSON.parse(sessionText); + } + } catch (e) { + console.error("Error parsing session file:", e); + throw new Error(`Failed to parse session file: ${e.message}`); + } + } else if (typeof sessionValue === 'string') { + // Handle session URL or local file path + try { + const sessionText = await igvxhr.loadString(sessionValue); + try { + // Try to parse as compressed blob first + if (sessionText.startsWith("blob:") || sessionText.startsWith("data:")) { + sessionConfig = JSON.parse(BGZip.uncompressString(sessionText.substr(5))); + } else { + // Parse as plain JSON + sessionConfig = JSON.parse(sessionText); + } + } catch (e) { + console.error("Error parsing session from URL/file:", e); + throw new Error(`Failed to parse session from URL/file: ${e.message}`); + } + } catch (e) { + console.error("Error loading session from URL/file:", e); + throw new Error(`Failed to load session from URL/file: ${e.message}`); + } } } diff --git a/test/data/session.json b/test/data/session.json new file mode 100644 index 00000000..b91fd6c5 --- /dev/null +++ b/test/data/session.json @@ -0,0 +1,47 @@ +{ + "browsers": [ + { + "backgroundColor": "136,133,1", + "url": "https://www.encodeproject.org/files/ENCFF473CAA/@@download/ENCFF473CAA.hic", + "name": "Test Browser 1", + "state": { + "chr1": 17, + "chr2": 17, + "zoom": 8, + "x": 12025.0154, + "y": 12025.0154, + "pixelSize": 1, + "normalization": "NONE", + "locus": { + "x": { + "chr": "17", + "start": 60125077, + "end": 63305077 + }, + "y": { + "chr": "17", + "start": 60125077, + "end": 63305077 + } + } + }, + "colorScale": "12,131,249,2", + "selectedGene": "ace", + "nvi": "1077514950,36479", + "tracks": [ + { + "url": "https://hicfiles.s3.amazonaws.com/hiseq/gm12878/in-situ/combined_peaks.txt", + "name": "Test Track 1", + "color": "#ffce6e" + }, + { + "url": "https://hicfiles.s3.amazonaws.com/hiseq/huvec/in-situ/combined_peaks.txt", + "name": "Test Track 2", + "color": "#ff39ff" + } + ] + } + ], + "selectedGene": "ace" +} + diff --git a/test/testSession.js b/test/testSession.js new file mode 100644 index 00000000..7cda2c94 --- /dev/null +++ b/test/testSession.js @@ -0,0 +1,100 @@ +/* + * @author Generated test for session loading + */ + +import { describe, test, expect } from 'vitest'; +import { extractConfig } from "../js/urlUtils.js"; +import { restoreSession } from "../js/session.js"; +import { createFile } from "./utils/File.js"; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +describe("Session Loading", function () { + + test("Load session from local file path", async function () { + // Test loading session from a local file path + // The XMLHttpRequestMock will route non-HTTP URLs to XMLHttpRequestLocal + const sessionFilePath = resolve(__dirname, "data/session.json"); + const url = `http://localhost/juicebox/?session=${sessionFilePath}`; + + const sessionConfig = await extractConfig(url); + + expect(sessionConfig).toBeDefined(); + expect(sessionConfig.browsers).toBeDefined(); + expect(sessionConfig.browsers.length).toBe(1); + + const browser = sessionConfig.browsers[0]; + expect(browser.name).toBe("Test Browser 1"); + expect(browser.url).toBe("https://www.encodeproject.org/files/ENCFF473CAA/@@download/ENCFF473CAA.hic"); + expect(browser.tracks).toBeDefined(); + expect(browser.tracks.length).toBe(2); + expect(browser.tracks[0].name).toBe("Test Track 1"); + expect(browser.tracks[1].name).toBe("Test Track 2"); + expect(browser.state).toBeDefined(); + expect(browser.state.chr1).toBe(17); + expect(browser.state.chr2).toBe(17); + expect(browser.state.normalization).toBe("NONE"); + + expect(sessionConfig.selectedGene).toBe("ace"); + }); + + test("Load session from relative file path", async function () { + // Test loading session from a relative file path + const relativePath = "test/data/session.json"; + const url = `http://localhost/juicebox/?session=${relativePath}`; + + const sessionConfig = await extractConfig(url); + + expect(sessionConfig).toBeDefined(); + expect(sessionConfig.browsers).toBeDefined(); + expect(sessionConfig.browsers.length).toBe(1); + + const browser = sessionConfig.browsers[0]; + expect(browser.name).toBe("Test Browser 1"); + expect(browser.tracks.length).toBe(2); + expect(sessionConfig.selectedGene).toBe("ace"); + }); + + test("Load session from File object", async function () { + // Test loading session from a File object + // This tests the isFile() check in extractConfig + const sessionFilePath = resolve(__dirname, "data/session.json"); + const file = createFile(sessionFilePath); + + // Create a URL with the File object as the session value + // We need to modify extractConfig to accept File objects directly + // For now, test by creating a session config from the file content + const sessionText = await file.text(); + const sessionConfig = JSON.parse(sessionText); + + expect(sessionConfig).toBeDefined(); + expect(sessionConfig.browsers).toBeDefined(); + expect(sessionConfig.browsers.length).toBe(1); + + const browser = sessionConfig.browsers[0]; + expect(browser.name).toBe("Test Browser 1"); + expect(browser.tracks.length).toBe(2); + expect(sessionConfig.selectedGene).toBe("ace"); + }); + + test("Load session from HTTP URL (mocked)", async function () { + // Test loading session from an HTTP URL + // In a real scenario, this would fetch from a remote server + // For testing, we use a local file path that the mock can handle + const sessionFilePath = resolve(__dirname, "data/session.json"); + // Encode the file path as if it were a URL parameter + const url = `https://example.com/juicebox/?session=${sessionFilePath}`; + + const sessionConfig = await extractConfig(url); + + expect(sessionConfig).toBeDefined(); + expect(sessionConfig.browsers).toBeDefined(); + expect(sessionConfig.browsers.length).toBe(1); + }); + +}) + From caaeb7d0085301ed71a335fdb60554dd33bb24d5 Mon Sep 17 00:00:00 2001 From: turner Date: Sat, 8 Nov 2025 10:48:12 -0500 Subject: [PATCH 10/16] Add workspace file --- docs/EXTERNAL_PROJECT_INTEGRATION.md | 159 +++++++++++++++++++++++++ js/session.js | 37 ++++++ js/urlUtils.js | 38 +++--- juicebox-with-spacewalk.code-workspace | 31 +++++ 4 files changed, 248 insertions(+), 17 deletions(-) create mode 100644 docs/EXTERNAL_PROJECT_INTEGRATION.md create mode 100644 juicebox-with-spacewalk.code-workspace diff --git a/docs/EXTERNAL_PROJECT_INTEGRATION.md b/docs/EXTERNAL_PROJECT_INTEGRATION.md new file mode 100644 index 00000000..c5c96f65 --- /dev/null +++ b/docs/EXTERNAL_PROJECT_INTEGRATION.md @@ -0,0 +1,159 @@ +# Integrating External Projects with Juicebox.js + +This document describes how to work with external projects (like Spacewalk) that use Juicebox.js. + +## Multi-Root Workspace (Current Approach) + +**We use a multi-root workspace to reference Spacewalk without copying files.** + +### Setup: +1. Ensure Spacewalk is cloned locally (e.g., as a sibling directory: `../spacewalk`) +2. Open `juicebox-with-spacewalk.code-workspace` in Cursor/VS Code +3. Both projects will appear in the file explorer side-by-side + +### Benefits: +- ✅ Full code navigation and IntelliSense across both projects +- ✅ Can search across both codebases +- ✅ No file duplication +- ✅ Always sees latest Spacewalk changes immediately +- ✅ Git history stays separate +- ✅ Cursor AI can understand both codebases together + +### Usage: +```bash +# Open the workspace file in Cursor +cursor juicebox-with-spacewalk.code-workspace + +# Or in VS Code +code juicebox-with-spacewalk.code-workspace +``` + +### Updating the Spacewalk Path: +If your Spacewalk repository is in a different location, edit `juicebox-with-spacewalk.code-workspace` and update the path: +```json +{ + "name": "Spacewalk (External Reference)", + "path": "/absolute/path/to/spacewalk" // or "../relative/path/to/spacewalk" +} +``` + +## Option 2: Git Submodules + +**Best for:** When Spacewalk is a separate Git repository + +### Setup: +```bash +# Add Spacewalk as a submodule +git submodule add spacewalk-reference + +# Or if Spacewalk is already a submodule elsewhere +git submodule add external/spacewalk +``` + +### Benefits: +- ✅ Tracks specific commit of Spacewalk +- ✅ Can update to latest version easily +- ✅ Keeps projects separate +- ✅ Works well with CI/CD + +### Drawbacks: +- ⚠️ Requires submodule initialization (`git submodule update --init`) +- ⚠️ Can be confusing for team members + +## Option 3: Symbolic Links + +**Best for:** Quick local development (not recommended for Git) + +### Setup: +```bash +# Create a symlink to Spacewalk's juiceboxPanel.js +ln -s /path/to/spacewalk/src/panels/juiceboxPanel.js spacewalk-code/juiceboxPanel.js +``` + +### Benefits: +- ✅ Always points to latest version +- ✅ No duplication + +### Drawbacks: +- ⚠️ Symlinks don't work well in Git (platform-specific) +- ⚠️ Can break if Spacewalk moves +- ⚠️ Not portable across machines + +## Option 4: npm/yarn Workspaces (If Both Are Packages) + +**Best for:** When both projects are npm packages + +### Setup: +Create a root `package.json`: +```json +{ + "name": "juicebox-workspace", + "private": true, + "workspaces": [ + ".", + "../spacewalk" + ] +} +``` + +### Benefits: +- ✅ Shared dependencies +- ✅ Can link packages locally +- ✅ Standard npm workflow + +## Option 5: GitHub Integration + +**Best for:** Reference code from GitHub without cloning + +### Using GitHub CLI: +```bash +# View file from GitHub repo +gh repo view /spacewalk --web + +# Or use GitHub's web interface to browse +``` + +### Using Cursor's GitHub Integration: +- Cursor can reference GitHub repos in some contexts +- Use GitHub URLs in comments/links +- Not as seamless as local files + +## Option 6: Documentation-Based Approach + +**Best for:** When you just need to document usage patterns + +### Create a reference file: +```markdown +# Spacewalk Integration Reference + +See: https://github.com/your-org/spacewalk/blob/main/src/panels/juiceboxPanel.js + +Key integration points: +- Line 82: `hic.restoreSession()` +- Line 89: `hic.getCurrentBrowser()` +- etc. +``` + +## Current Setup + +We use the **Multi-Root Workspace** approach. The workspace file (`juicebox-with-spacewalk.code-workspace`) includes both: +- The juicebox.js project (current directory) +- The Spacewalk project (external reference) + +This allows Cursor to understand both codebases together, making it easy to: +- Check compatibility when refactoring Juicebox.js +- See how Spacewalk uses Juicebox.js APIs +- Navigate between both projects seamlessly +- Get IntelliSense and code completion across both + +### Notes: +- The workspace file is committed to Git, but Spacewalk itself is not +- Each developer needs to have Spacewalk cloned locally +- Update the path in the workspace file if Spacewalk is in a different location + +## Questions? + +- For workspace setup: See Cursor/VS Code documentation on multi-root workspaces +- For Git submodules: See `git help submodule` +- For npm workspaces: See npm/yarn workspace documentation + diff --git a/js/session.js b/js/session.js index 2525cb62..77780347 100644 --- a/js/session.js +++ b/js/session.js @@ -1,6 +1,7 @@ import {createBrowserList, deleteAllBrowsers, getAllBrowsers, syncBrowsers} from "./createBrowser.js" import {Globals} from "./globals.js" import {StringUtils, BGZip} from "../node_modules/igv-utils/src/index.js"; +import {expandUrlShortcuts} from "./urlUtils.js"; function toJSON() { const jsonOBJ = {}; @@ -39,6 +40,42 @@ async function restoreSession(container, session) { deleteAllBrowsers(); + // Expand URL shortcuts in session config for backward compatibility + // This ensures sessions passed directly to restoreSession (not through extractConfig) + // still work with URL shortcuts like *s3/, *enc/, etc. + if (session.browsers) { + for (let browser of session.browsers) { + if (browser.url) { + browser.url = expandUrlShortcuts(browser.url); + } + if (browser.controlUrl) { + browser.controlUrl = expandUrlShortcuts(browser.controlUrl); + } + if (browser.tracks) { + for (let track of browser.tracks) { + if (track.url) { + track.url = expandUrlShortcuts(track.url); + } + } + } + } + } else { + // Single browser config (not in browsers array) + if (session.url) { + session.url = expandUrlShortcuts(session.url); + } + if (session.controlUrl) { + session.controlUrl = expandUrlShortcuts(session.controlUrl); + } + if (session.tracks) { + for (let track of session.tracks) { + if (track.url) { + track.url = expandUrlShortcuts(track.url); + } + } + } + } + if (session.hasOwnProperty("selectedGene")) { Globals.selectedGene = session.selectedGene; } diff --git a/js/urlUtils.js b/js/urlUtils.js index 79f9bf72..6ad6d4ee 100644 --- a/js/urlUtils.js +++ b/js/urlUtils.js @@ -37,6 +37,23 @@ const urlShortcuts = { "*enc/": "https://www.encodeproject.org/files/" } +/** + * Expand URL shortcuts in a URL string (e.g., *s3/ -> full URL) + * @param {string} url - URL that may contain shortcuts + * @returns {string} - URL with shortcuts expanded + */ +function expandUrlShortcuts(url) { + if (!url || typeof url !== 'string') return url; + let expandedUrl = url; + Object.keys(urlShortcuts).forEach(function (key) { + const value = urlShortcuts[key]; + if (expandedUrl.startsWith(key)) { + expandedUrl = expandedUrl.replace(key, value); + } + }); + return expandedUrl; +} + async function extractConfig(queryString) { let query = extractQuery(queryString); @@ -176,10 +193,7 @@ function decodeQuery(query, uriDecode) { if (hicUrl) { hicUrl = paramDecode(hicUrl, uriDecode); - Object.keys(urlShortcuts).forEach(function (key) { - var value = urlShortcuts[key]; - if (hicUrl.startsWith(key)) hicUrl = hicUrl.replace(key, value); - }); + hicUrl = expandUrlShortcuts(hicUrl); config.url = hicUrl; } @@ -188,10 +202,7 @@ function decodeQuery(query, uriDecode) { } if (controlUrl) { controlUrl = paramDecode(controlUrl, uriDecode); - Object.keys(urlShortcuts).forEach(function (key) { - var value = urlShortcuts[key]; - if (controlUrl.startsWith(key)) controlUrl = controlUrl.replace(key, value); - }); + controlUrl = expandUrlShortcuts(controlUrl); config.controlUrl = controlUrl; } if (controlName) { @@ -248,14 +259,7 @@ function decodeQuery(query, uriDecode) { const color = tokens.pop(); let url = tokens.length > 1 ? tokens[0] : trackString; if (url && url.trim().length > 0 && "undefined" !== url) { - const keys = Object.keys(urlShortcuts); - for (let key of keys) { - var value = urlShortcuts[key]; - if (url.startsWith(key)) { - url = url.replace(key, value); - break; - } - } + url = expandUrlShortcuts(url); const trackConfig = {url: url}; if (tokens.length > 1) { @@ -370,4 +374,4 @@ async function expandURL(url) { } -export {extractConfig, DEFAULT_ANNOTATION_COLOR} +export {extractConfig, DEFAULT_ANNOTATION_COLOR, expandUrlShortcuts} diff --git a/juicebox-with-spacewalk.code-workspace b/juicebox-with-spacewalk.code-workspace new file mode 100644 index 00000000..974a6db6 --- /dev/null +++ b/juicebox-with-spacewalk.code-workspace @@ -0,0 +1,31 @@ +{ + "folders": [ + { + "name": "juicebox.js", + "path": "." + }, + { + "name": "Spacewalk (External Reference)", + "path": "../spacewalk", + "//": "Update this path to point to your Spacewalk repository location. Default assumes Spacewalk is cloned as a sibling directory." + } + ], + "settings": { + "files.exclude": { + "**/node_modules": true, + "**/dist": true + }, + "search.exclude": { + "**/node_modules": true, + "**/dist": true + }, + "files.watcherExclude": { + "**/node_modules/**": true, + "**/dist/**": true + } + }, + "extensions": { + "recommendations": [] + } +} + From 97d9a63c5298f479fa96452e00327c69bdbdd227 Mon Sep 17 00:00:00 2001 From: turner Date: Sat, 8 Nov 2025 10:52:31 -0500 Subject: [PATCH 11/16] Added Workspace file to enable Juicebox and Spacewalk to be visible in Cursor --- juicebox-with-spacewalk.code-workspace | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/juicebox-with-spacewalk.code-workspace b/juicebox-with-spacewalk.code-workspace index 974a6db6..dd96d162 100644 --- a/juicebox-with-spacewalk.code-workspace +++ b/juicebox-with-spacewalk.code-workspace @@ -6,8 +6,8 @@ }, { "name": "Spacewalk (External Reference)", - "path": "../spacewalk", - "//": "Update this path to point to your Spacewalk repository location. Default assumes Spacewalk is cloned as a sibling directory." + "path": "../../SpacewalkDevelopment/spacewalk", + "//": "https://github.com/igvteam/spacewalk" } ], "settings": { From 5a10832799a7b622458a720371c1bfb3672533e2 Mon Sep 17 00:00:00 2001 From: turner Date: Sat, 8 Nov 2025 11:55:56 -0500 Subject: [PATCH 12/16] update workspace file --- juicebox-with-spacewalk.code-workspace | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/juicebox-with-spacewalk.code-workspace b/juicebox-with-spacewalk.code-workspace index dd96d162..8e4bafff 100644 --- a/juicebox-with-spacewalk.code-workspace +++ b/juicebox-with-spacewalk.code-workspace @@ -7,7 +7,7 @@ { "name": "Spacewalk (External Reference)", "path": "../../SpacewalkDevelopment/spacewalk", - "//": "https://github.com/igvteam/spacewalk" + "//": "https://github.com/aidenlab/spacewalk" } ], "settings": { From 0fab4d33a5a16a95a2ad0e313e7c844dbe62ea55 Mon Sep 17 00:00:00 2001 From: turner Date: Mon, 10 Nov 2025 12:03:42 -0500 Subject: [PATCH 13/16] un console log --- js/hicState.js | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/js/hicState.js b/js/hicState.js index ba867552..e2ea2047 100644 --- a/js/hicState.js +++ b/js/hicState.js @@ -26,7 +26,6 @@ * @author Jim Robinson */ -import { defaultSize } from './createBrowser.js' import {DEFAULT_PIXEL_SIZE, MAX_PIXEL_SIZE} from "./hicBrowser.js" class State { @@ -50,7 +49,7 @@ class State { this.zoom = zoom; if (undefined === normalization) { - console.warn("Normalization is undefined. Will use NONE"); + // console.warn("Normalization is undefined. Will use NONE"); normalization = 'NONE'; } this.normalization = normalization; @@ -67,23 +66,6 @@ class State { this.locus = locus } - // Getters and setters for width and height - // get width() { - // return this._width; - // } - // - // set width(value) { - // this._width = value; - // } - // - // get height() { - // return this._height; - // } - // - // set height(value) { - // this._height = value; - // } - clampXY(dataset, viewDimensions) { const { width, height } = viewDimensions const { chromosomes, bpResolutions } = dataset; From d09bdf13304a7e44dca271be4c13fe2a8d10c97d Mon Sep 17 00:00:00 2001 From: turner Date: Tue, 11 Nov 2025 16:11:45 -0500 Subject: [PATCH 14/16] Refactor interactionHandler and HicState --- js/hicState.js | 161 +++++++++++++++++++++----- js/interactionHandler.js | 145 ++++++++++++++--------- js/stateManager.js | 10 +- juicebox-with-pg-ruler.code-workspace | 30 +++++ 4 files changed, 255 insertions(+), 91 deletions(-) create mode 100644 juicebox-with-pg-ruler.code-workspace diff --git a/js/hicState.js b/js/hicState.js index e2ea2047..d509f2b3 100644 --- a/js/hicState.js +++ b/js/hicState.js @@ -66,6 +66,105 @@ class State { this.locus = locus } + /** + * Detect if resolution changed. + * + * @param {number} newZoom - New zoom index + * @returns {boolean} - True if resolution changed + */ + _detectResolutionChange(newZoom) { + return this.zoom !== newZoom; + } + + /** + * Detect if chromosome changed. + * + * @param {number} newChr1 - New chromosome 1 index + * @param {number} newChr2 - New chromosome 2 index + * @returns {boolean} - True if chromosome changed + */ + _detectChromosomeChange(newChr1, newChr2) { + return this.chr1 !== newChr1 || this.chr2 !== newChr2; + } + + /** + * Adjust pixel size with validation and clamping. + * Centralizes pixel size adjustment logic. + * + * @param {number} targetPixelSize - Target pixel size (or undefined if calculating from bpPerPixelTarget) + * @param {Object} browser - Browser instance + * @param {number} zoom - Zoom index + * @param {Object} options - Adjustment options + * @param {number} [options.minPixelSize] - Pre-calculated minimum pixel size (if provided, won't call browser.minPixelSize) + * @param {number} [options.bpPerPixelTarget] - Base pairs per pixel target (for calculation mode) + * @param {number} [options.binSize] - Bin size for calculation (required if bpPerPixelTarget provided) + * @param {boolean} [options.useDefaultMin=false] - Whether to use DEFAULT_PIXEL_SIZE as minimum (for setWithZoom pattern) + * @returns {Promise} - Adjusted pixel size + */ + async _adjustPixelSize(targetPixelSize, browser, zoom, options = {}) { + const { minPixelSize, bpPerPixelTarget, binSize, useDefaultMin = false } = options; + + let adjustedPixelSize; + + // If bpPerPixelTarget and binSize are provided, calculate from them + if (bpPerPixelTarget !== undefined && binSize !== undefined) { + adjustedPixelSize = binSize / bpPerPixelTarget; + } else { + adjustedPixelSize = targetPixelSize; + } + + // Clamp to minimum of 1 + adjustedPixelSize = Math.max(1, adjustedPixelSize); + + // Get minimum pixel size from browser if not provided + let actualMinPixelSize = minPixelSize; + if (actualMinPixelSize === undefined && browser) { + actualMinPixelSize = await browser.minPixelSize(this.chr1, this.chr2, zoom); + } + + // Apply minimum pixel size constraint + if (actualMinPixelSize !== undefined) { + if (useDefaultMin) { + // For setWithZoom pattern: use max of DEFAULT_PIXEL_SIZE and minPixelSize + adjustedPixelSize = Math.max(DEFAULT_PIXEL_SIZE, actualMinPixelSize); + } else { + // For other patterns: use max of current value and minPixelSize + adjustedPixelSize = Math.max(adjustedPixelSize, actualMinPixelSize); + } + } else if (useDefaultMin) { + // If no minPixelSize but useDefaultMin is true, use DEFAULT_PIXEL_SIZE + adjustedPixelSize = Math.max(DEFAULT_PIXEL_SIZE, adjustedPixelSize); + } + + // Clamp to MAX_PIXEL_SIZE + adjustedPixelSize = Math.min(MAX_PIXEL_SIZE, adjustedPixelSize); + + return adjustedPixelSize; + } + + /** + * Finalize state update with validation. + * Standardizes post-update validation workflow. + * + * @param {Object} browser - Browser instance + * @param {Object} dataset - Dataset instance + * @param {Object} viewDimensions - View dimensions {width, height} + * @param {Object} options - Finalization options + * @param {boolean} [options.clampXY=true] - Whether to clamp XY coordinates + * @param {boolean} [options.configureLocus=false] - Whether to configure locus + */ + _finalizeUpdate(browser, dataset, viewDimensions, options = {}) { + const { clampXY = true, configureLocus = false } = options; + + if (clampXY) { + this.clampXY(dataset, viewDimensions); + } + + if (configureLocus) { + this.configureLocus(dataset, viewDimensions); + } + } + clampXY(dataset, viewDimensions) { const { width, height } = viewDimensions const { chromosomes, bpResolutions } = dataset; @@ -80,11 +179,10 @@ class State { async panWithZoom(zoom, pixelSize, anchorPx, anchorPy, binSize, browser, dataset, viewDimensions, bpResolutions){ - const minPixelSize = await browser.minPixelSize(this.chr1, this.chr2, zoom) - pixelSize = Math.max(pixelSize, minPixelSize) + // Adjust pixel size with minimum constraint + pixelSize = await this._adjustPixelSize(pixelSize, browser, zoom) // Genomic anchor -- this position should remain at anchorPx, anchorPy after state change - bpResolutions[this.zoom] const gx = (this.x + anchorPx / this.pixelSize) * bpResolutions[this.zoom].binSize const gy = (this.y + anchorPy / this.pixelSize) * bpResolutions[this.zoom].binSize @@ -94,7 +192,7 @@ class State { this.zoom = zoom this.pixelSize = pixelSize - this.clampXY(dataset, viewDimensions) + this._finalizeUpdate(browser, dataset, viewDimensions, { clampXY: true, configureLocus: false }) } @@ -102,9 +200,8 @@ class State { this.x += (dx / this.pixelSize) this.y += (dy / this.pixelSize) - this.clampXY(dataset, viewDimensions) - this.configureLocus(browser, dataset, viewDimensions) + this._finalizeUpdate(browser, dataset, viewDimensions, { clampXY: true, configureLocus: true }) } @@ -124,24 +221,22 @@ class State { const xCenterNew = xCenter * scaleFactor const yCenterNew = yCenter * scaleFactor - const minPixelSize = await browser.minPixelSize(this.chr1, this.chr2, zoom) - - this.pixelSize = Math.max(DEFAULT_PIXEL_SIZE, minPixelSize) + const resolutionChanged = this._detectResolutionChange(zoom) - const resolutionChanged = (this.zoom !== zoom) + // Adjust pixel size with DEFAULT_PIXEL_SIZE minimum + const minPixelSize = await browser.minPixelSize(this.chr1, this.chr2, zoom) + this.pixelSize = await this._adjustPixelSize(undefined, browser, zoom, { minPixelSize, useDefaultMin: true }) this.zoom = zoom this.x = Math.max(0, xCenterNew - width / (2 * this.pixelSize)) this.y = Math.max(0, yCenterNew - height / (2 * this.pixelSize)) - this.clampXY(dataset, viewDimensions) - - this.configureLocus(browser, dataset, viewDimensions) + this._finalizeUpdate(browser, dataset, viewDimensions, { clampXY: true, configureLocus: true }) return resolutionChanged } - configureLocus(browser, dataset, viewDimensions){ + configureLocus(dataset, viewDimensions){ const bpPerBin = dataset.bpResolutions[this.zoom]; @@ -160,31 +255,36 @@ class State { this.locus = { x, y } } - updateWithLoci(chr1Name, bpX, bpXMax, chr2Name, bpY, bpYMax, browser, width, height){ + async updateWithLoci(chr1Name, bpX, bpXMax, chr2Name, bpY, bpYMax, browser, width, height){ const bpResolutions = browser.getResolutions() // bp/pixel - let bpPerPixelTarget = Math.max((bpXMax - bpX) / width, (bpYMax - bpY) / height) - let resolutionChanged + const bpPerPixelTarget = Math.max((bpXMax - bpX) / width, (bpYMax - bpY) / height) let zoomNew if (true === browser.resolutionLocked) { - resolutionChanged = false zoomNew = this.zoom } else { zoomNew = browser.findMatchingZoomIndex(bpPerPixelTarget, bpResolutions) - resolutionChanged = (zoomNew !== this.zoom) } + const resolutionChanged = this._detectResolutionChange(zoomNew) + const { binSize:binSizeNew } = bpResolutions[zoomNew] - const pixelSize = Math.min(MAX_PIXEL_SIZE, Math.max(1, binSizeNew / bpPerPixelTarget)) + + // Adjust pixel size from bpPerPixelTarget + const pixelSize = await this._adjustPixelSize(undefined, browser, zoomNew, { + bpPerPixelTarget, + binSize: binSizeNew + }) + const newXBin = bpX / binSizeNew const newYBin = bpY / binSizeNew const { index:chr1Index } = browser.genome.getChromosome( chr1Name ) const { index:chr2Index } = browser.genome.getChromosome( chr2Name ) - const chrChanged = this.chr1 !== chr1Index || this.chr2 !== chr2Index + const chrChanged = this._detectChromosomeChange(chr1Index, chr2Index) this.chr1 = chr1Index this.chr2 = chr2Index @@ -201,7 +301,7 @@ class State { return { chrChanged, resolutionChanged } } - sync(targetState, browser, genome, dataset){ + async sync(targetState, browser, genome, dataset){ const chr1 = genome.getChromosome(targetState.chr1Name) const chr2 = genome.getChromosome(targetState.chr2Name) @@ -210,13 +310,18 @@ class State { const zoomNew = browser.findMatchingZoomIndex(bpPerPixelTarget, dataset.bpResolutions) const binSizeNew = dataset.bpResolutions[ zoomNew ] - const pixelSizeNew = Math.min(MAX_PIXEL_SIZE, Math.max(1, binSizeNew / bpPerPixelTarget)) + + // Adjust pixel size from bpPerPixelTarget + const pixelSizeNew = await this._adjustPixelSize(undefined, browser, zoomNew, { + bpPerPixelTarget, + binSize: binSizeNew + }) const xBinNew = targetState.binX * (targetState.binSize/binSizeNew) const yBinNew = targetState.binY * (targetState.binSize/binSizeNew) - const zoomChanged = (browser.state.zoom !== zoomNew) - const chrChanged = (browser.state.chr1 !== chr1.index || browser.state.chr2 !== chr2.index) + const zoomChanged = this._detectResolutionChange(zoomNew) + const chrChanged = this._detectChromosomeChange(chr1.index, chr2.index) this.chr1 = chr1.index this.chr2 = chr2.index @@ -225,7 +330,11 @@ class State { this.y = yBinNew this.pixelSize = pixelSizeNew - this.configureLocus(browser, dataset, browser.contactMatrixView.getViewDimensions()) + // Finalize with both clampXY and configureLocus (fixes missing clampXY) + this._finalizeUpdate(browser, dataset, browser.contactMatrixView.getViewDimensions(), { + clampXY: true, + configureLocus: true + }) return { zoomChanged, chrChanged } diff --git a/js/interactionHandler.js b/js/interactionHandler.js index d5e93dbe..926c1210 100644 --- a/js/interactionHandler.js +++ b/js/interactionHandler.js @@ -42,6 +42,57 @@ class InteractionHandler { this.browser = browser; } + /** + * Validate that the dataset is available. + * + * @returns {boolean} - True if dataset is valid, false otherwise + */ + _validateDataset() { + if (undefined === this.browser.dataset) { + console.warn('dataset is undefined'); + return false; + } + return true; + } + + /** + * Apply state changes and notify listeners. + * Centralizes the common post-state-change workflow. + * + * @param {Object} options - State change options + * @param {boolean} options.resolutionChanged - Whether resolution changed + * @param {boolean} options.chrChanged - Whether chromosome changed + * @param {boolean} [options.dragging] - Whether currently dragging (optional) + * @param {boolean} [options.clearCaches] - Whether to clear image caches (optional) + * @param {Object} [options.zoomIn] - Zoom in options {anchorPx?, anchorPy?, scaleFactor?} (optional) + * @returns {Promise} + */ + async _applyStateChange(options) { + const { resolutionChanged, chrChanged, dragging = false, clearCaches = false, zoomIn } = options; + + if (clearCaches) { + this.browser.contactMatrixView.clearImageCaches(); + } + + if (zoomIn) { + if (zoomIn.anchorPx !== undefined && zoomIn.anchorPy !== undefined && zoomIn.scaleFactor !== undefined) { + await this.browser.contactMatrixView.zoomIn(zoomIn.anchorPx, zoomIn.anchorPy, zoomIn.scaleFactor); + } else { + await this.browser.contactMatrixView.zoomIn(); + } + } + + const eventData = { + state: this.browser.state, + resolutionChanged, + chrChanged, + ...(dragging && { dragging }) + }; + + await this.browser.update(); + this.browser.notifyLocusChange(eventData); + } + /** * Navigate to a specific genomic locus. * @@ -54,21 +105,16 @@ class InteractionHandler { */ async goto(chr1, bpX, bpXMax, chr2, bpY, bpYMax) { const { width, height } = this.browser.contactMatrixView.getViewDimensions(); - const { chrChanged, resolutionChanged } = this.browser.state.updateWithLoci( + const { chrChanged, resolutionChanged } = await this.browser.state.updateWithLoci( chr1, bpX, bpXMax, chr2, bpY, bpYMax, this.browser, width, height ); - this.browser.contactMatrixView.clearImageCaches(); - - const eventData = { - state: this.browser.state, - resolutionChanged, - chrChanged - }; - - await this.browser.update(); - this.browser.notifyLocusChange(eventData); + await this._applyStateChange({ + resolutionChanged, + chrChanged, + clearCaches: true + }); } /** @@ -78,8 +124,7 @@ class InteractionHandler { * @param {number} dy - Y pixel offset */ async shiftPixels(dx, dy) { - if (undefined === this.browser.dataset) { - console.warn('dataset is undefined'); + if (!this._validateDataset()) { return; } @@ -90,15 +135,11 @@ class InteractionHandler { this.browser.contactMatrixView.getViewDimensions() ); - const eventData = { - state: this.browser.state, + await this._applyStateChange({ resolutionChanged: false, - dragging: true, - chrChanged: false - }; - - await this.browser.update(); - this.browser.notifyLocusChange(eventData); + chrChanged: false, + dragging: true + }); } /** @@ -156,15 +197,15 @@ class InteractionHandler { bpResolutions ); - await this.browser.contactMatrixView.zoomIn(anchorPx, anchorPy, 1 / scaleFactor); - - const eventData = { - state: this.browser.state, - resolutionChanged, - chrChanged: false - }; - await this.browser.update(); - this.browser.notifyLocusChange(eventData); + await this._applyStateChange({ + resolutionChanged, + chrChanged: false, + zoomIn: { + anchorPx, + anchorPy, + scaleFactor: 1 / scaleFactor + } + }); } } finally { this.browser.stopSpinner(); @@ -180,8 +221,7 @@ class InteractionHandler { * @param {number} centerPY - Screen Y coordinate to center on */ async zoomAndCenter(direction, centerPX, centerPY) { - if (undefined === this.browser.dataset) { - console.warn('Dataset is undefined'); + if (!this._validateDataset()) { return; } @@ -227,15 +267,12 @@ class InteractionHandler { this.browser.state.y += shiftRatio * (height / this.browser.state.pixelSize); this.browser.state.clampXY(this.browser.dataset, this.browser.contactMatrixView.getViewDimensions()); - this.browser.state.configureLocus(this.browser, this.browser.dataset, { width, height }); - - const eventData = { - state: this.browser.state, - resolutionChanged: false, - chrChanged: false - }; - await this.browser.update(); - this.browser.notifyLocusChange(eventData); + this.browser.state.configureLocus(this.browser.dataset, { width, height }); + + await this._applyStateChange({ + resolutionChanged: false, + chrChanged: false + }); } else { let i; for (i = 0; i < resolutions.length; i++) { @@ -262,15 +299,11 @@ class InteractionHandler { this.browser.dataset ); - await this.browser.contactMatrixView.zoomIn(); - - const eventData = { - state: this.browser.state, - resolutionChanged, - chrChanged: false - }; - await this.browser.update(); - this.browser.notifyLocusChange(eventData); + await this._applyStateChange({ + resolutionChanged, + chrChanged: false, + zoomIn: {} + }); } /** @@ -305,13 +338,11 @@ class InteractionHandler { this.browser.state.pixelSize = Math.max(this.browser.state.pixelSize, minPS); } - const eventData = { - state: this.browser.state, - resolutionChanged: true, - chrChanged: true - }; - await this.browser.update(); - this.browser.notifyLocusChange(eventData); + await this._applyStateChange({ + resolutionChanged: true, + chrChanged: true, + clearCaches: true + }); } /** diff --git a/js/stateManager.js b/js/stateManager.js index 76624793..282dd91f 100644 --- a/js/stateManager.js +++ b/js/stateManager.js @@ -104,7 +104,6 @@ class StateManager { if (undefined === state.locus) { const viewDimensions = this.browser.contactMatrixView.getViewDimensions(); this.activeState.configureLocus( - this.browser, this.activeDataset, viewDimensions ); @@ -201,19 +200,14 @@ class StateManager { return { zoomChanged: false, chrChanged: false }; } - const { zoomChanged, chrChanged } = this.activeState.sync( + const { zoomChanged, chrChanged } = await this.activeState.sync( targetState, this.browser, this.browser.genome, this.activeDataset ); - // Configure locus after sync - this.activeState.configureLocus( - this.browser, - this.activeDataset, - this.browser.contactMatrixView.getViewDimensions() - ); + // Note: configureLocus is now handled by sync's _finalizeUpdate return { zoomChanged, chrChanged }; } diff --git a/juicebox-with-pg-ruler.code-workspace b/juicebox-with-pg-ruler.code-workspace new file mode 100644 index 00000000..ffb10fe0 --- /dev/null +++ b/juicebox-with-pg-ruler.code-workspace @@ -0,0 +1,30 @@ +{ + "folders": [ + { + "name": "juicebox.js", + "path": "." + }, + { + "name": "pg-spike-genomic-ruler", + "path": "../pg-spike-genomic-ruler" + } + ], + "settings": { + "files.exclude": { + "**/node_modules": true, + "**/dist": true + }, + "search.exclude": { + "**/node_modules": true, + "**/dist": true + }, + "files.watcherExclude": { + "**/node_modules/**": true, + "**/dist/**": true + } + }, + "extensions": { + "recommendations": [] + } +} + From c0a0f11b58f4f980da60d44b8bdd03059e154e83 Mon Sep 17 00:00:00 2001 From: turner Date: Tue, 11 Nov 2025 17:05:51 -0500 Subject: [PATCH 15/16] Add zoom wheel support. Nice. --- dev/dat-sequence-gene-track.html | 2 +- examples/juicebox.html | 4 +- js/contactMatrixView.js | 15 ++++ js/hicBrowser.js | 9 +++ js/interactionHandler.js | 135 ++++++++++++++++++++++++++++++- 5 files changed, 160 insertions(+), 5 deletions(-) diff --git a/dev/dat-sequence-gene-track.html b/dev/dat-sequence-gene-track.html index d25495de..fcd01143 100644 --- a/dev/dat-sequence-gene-track.html +++ b/dev/dat-sequence-gene-track.html @@ -61,7 +61,7 @@ ], } - juicebox.init(document.getElementById("app-container"), config_deep_map) + juicebox.init(document.getElementById("app-container"), config) .then(browser => { console.log(`${ browser.id } is good to go`) diff --git a/examples/juicebox.html b/examples/juicebox.html index df47ba6e..c990fd9a 100644 --- a/examples/juicebox.html +++ b/examples/juicebox.html @@ -9,7 +9,7 @@ - + @@ -19,7 +19,7 @@