From e53adeec0f1d86b016c791c8f5f999ef3ed9cbaf Mon Sep 17 00:00:00 2001 From: CodeVoyager AI Date: Fri, 17 Jul 2026 18:08:20 +0000 Subject: [PATCH] [codevoyager] fix: persist legend track visibility state across configuration changes The issue was that unchecked tracks in the Legend would reset to checked whenever the configuration changed (reference, region, etc.). This happened because the hidden state was stored only in module-level variables in tubemap.js that were lost when the visualization was recreated. Changes: - Added hiddenTrackIds and visibilityChangedCallback to tubemap.js config - Added setHiddenTrackIds/getHiddenTrackIds/setVisibilityChangedCallback exports - Modified create() to apply persisted hidden tracks and set track types - Modified changeTrackVisibility/changeAllTracksVisibility to update hidden state - Modified drawLegend() to use inputTracks and reflect actual hidden state - Added hiddenTracks to visOptions in App.js for React-level persistence - Added onTrackVisibilityChanged callback chain: App -> TubeMapContainer -> TubeMap - TubeMap component sets the visibility callback and hidden track IDs on the tubemap Closes #464 --- src/App.js | 13 +++++++ src/components/TubeMap.js | 12 ++++++- src/components/TubeMapContainer.js | 2 ++ src/util/tubemap.js | 55 ++++++++++++++++++++++------ src/util/tubemap.test.js | 57 +++++++++++++++++++++++++++++- 5 files changed, 127 insertions(+), 12 deletions(-) diff --git a/src/App.js b/src/App.js index 24d25228..beea0316 100644 --- a/src/App.js +++ b/src/App.js @@ -68,6 +68,7 @@ class App extends Component { alphaReadsByMappingQuality: false, colorSchemes: getColorSchemesFromTracks(this.defaultViewTarget.tracks), mappingQualityCutoff: 0, + hiddenTracks: [], }, APIInterface: new ServerAPI(props.apiUrl) }; @@ -95,6 +96,7 @@ class App extends Component { visOptions: { ...state.visOptions, colorSchemes: [], + hiddenTracks: [], }, }; } else if (mode === "server") { @@ -107,6 +109,7 @@ class App extends Component { visOptions: { ...state.visOptions, colorSchemes: getColorSchemesFromTracks(this.defaultViewTarget.tracks), + hiddenTracks: [], }, }; } else { @@ -199,6 +202,15 @@ class App extends Component { })); }; + handleTrackVisibilityChanged = (hiddenTrackIds) => { + this.setState((state) => ({ + visOptions: { + ...state.visOptions, + hiddenTracks: hiddenTrackIds, + }, + })); + }; + // Set a color scheme setting for a particular track. // // key is the name of the setting to set, and may be "mainPalette", "auxPalette", or "colorByMappingQuality". @@ -251,6 +263,7 @@ class App extends Component { dataOrigin={this.state.dataOrigin} visOptions={this.state.visOptions} APIInterface={this.state.APIInterface} + onTrackVisibilityChanged={this.handleTrackVisibilityChanged} /> { tubeMap.create({ svgID: "#svg", @@ -50,6 +58,7 @@ class TubeMap extends Component { tubeMap.setShowReadsFlag(visOptions.showReads); tubeMap.setSoftClipsFlag(visOptions.showSoftClips); tubeMap.setColoredNodes(visOptions.coloredNodes); + tubeMap.setHiddenTrackIds(visOptions.hiddenTracks || []); for (let key of Object.keys(visOptions.colorSchemes)) { // Apply color-by-mapping-quality parameter to all the schemes. @@ -76,7 +85,8 @@ TubeMap.propTypes = { reads: PropTypes.array.isRequired, region: PropTypes.array.isRequired, visOptions: PropTypes.object.isRequired, - nodeSequences: PropTypes.bool + nodeSequences: PropTypes.bool, + onTrackVisibilityChanged: PropTypes.func, }; TubeMap.defaultProps = { diff --git a/src/components/TubeMapContainer.js b/src/components/TubeMapContainer.js index f3478f64..530b44db 100644 --- a/src/components/TubeMapContainer.js +++ b/src/components/TubeMapContainer.js @@ -126,6 +126,7 @@ class TubeMapContainer extends Component { ...this.props.visOptions, }} nodeSequences={!this.props.viewTarget.removeSequences} + onTrackVisibilityChanged={this.props.onTrackVisibilityChanged} /> @@ -326,6 +327,7 @@ TubeMapContainer.propTypes = { viewTarget: PropTypes.object.isRequired, visOptions: PropTypes.object.isRequired, APIInterface: PropTypes.object.isRequired, + onTrackVisibilityChanged: PropTypes.func, }; export default TubeMapContainer; diff --git a/src/util/tubemap.js b/src/util/tubemap.js index 062938d8..5efb45dc 100644 --- a/src/util/tubemap.js +++ b/src/util/tubemap.js @@ -159,6 +159,8 @@ const config = { showInfoCallback: function (info) { alert(info); }, + hiddenTrackIds: new Set(), + visibilityChangedCallback: null, }; // variables for storing info which can be directly translated into drawing instructions @@ -191,6 +193,13 @@ export function create(params) { // And then leave a hole in the array at 0 which we won't iterate over. delete inputNodes[0]; inputTracks = deepCopy(params.tracks); // deep copy + inputTracks.forEach((t) => { + if (!t.type) t.type = "haplotype"; + }); + config.hiddenTrackIds.forEach((id) => { + const track = inputTracks.find((t) => t.id === id); + if (track) track.hidden = true; + }); inputReads = params.reads || null; inputRegion = params.region; bed = params.bed || null; @@ -311,6 +320,14 @@ export function changeTrackVisibility(trackID) { } else { inputTracks[i].hidden = true; } + if (inputTracks[i].hidden) { + config.hiddenTrackIds.add(trackID); + } else { + config.hiddenTrackIds.delete(trackID); + } + if (config.visibilityChangedCallback) { + config.visibilityChangedCallback(Array.from(config.hiddenTrackIds)); + } } createTubeMap(); } @@ -318,12 +335,19 @@ export function changeTrackVisibility(trackID) { // to select/deselect all export function changeAllTracksVisibility(value) { let i = 0; + config.hiddenTrackIds.clear(); while (i < inputTracks.length) { inputTracks[i].hidden = !value; + if (!value) { + config.hiddenTrackIds.add(inputTracks[i].id); + } var checkbox = document.getElementById(`showTrack${inputTracks[i].id}`); checkbox.checked = value; i += 1; } + if (config.visibilityChangedCallback) { + config.visibilityChangedCallback(Array.from(config.hiddenTrackIds)); + } createTubeMap(); } @@ -418,6 +442,18 @@ export function setMappingQualityCutoff(value) { } } +export function setHiddenTrackIds(ids) { + config.hiddenTrackIds = new Set(ids || []); +} + +export function setVisibilityChangedCallback(callback) { + config.visibilityChangedCallback = callback; +} + +export function getHiddenTrackIds() { + return Array.from(config.hiddenTrackIds); +} + // main function createTubeMap() { console.log("Recreating tube map in", svgID); @@ -4224,24 +4260,23 @@ function drawLegend() { content += ''; const listeners = []; - // This is in terms of tracks, but when we change visibility we need to touch inputTracks, so we need to set up listeners by track ID. - for (let i = 0; i < tracks.length; i += 1) { - if (tracks[i].type === "haplotype") { + for (let i = 0; i < inputTracks.length; i += 1) { + if (inputTracks[i].type === "haplotype") { content += ``; - if (tracks[i].hasOwnProperty("name")) { - content += ``; + if (inputTracks[i].hasOwnProperty("name")) { + content += ``; } else { - content += ``; + content += ``; } - content += ``; - listeners.push(tracks[i].id); + const isChecked = !config.hiddenTrackIds.has(inputTracks[i].id); + content += ``; + listeners.push(inputTracks[i].id); } } content += " { document diff --git a/src/util/tubemap.test.js b/src/util/tubemap.test.js index 80d10c20..cb97a30c 100644 --- a/src/util/tubemap.test.js +++ b/src/util/tubemap.test.js @@ -1,4 +1,11 @@ -import { cigar_string, coverage, axisIntervals } from "./tubemap"; +import { + cigar_string, + coverage, + axisIntervals, + setHiddenTrackIds, + getHiddenTrackIds, + setVisibilityChangedCallback, +} from "./tubemap"; // cigar string test describe("cigar_string", () => { @@ -865,6 +872,54 @@ describe("coverage", () => { }); +describe("hiddenTrackIds", () => { + it("starts empty", async () => { + expect(getHiddenTrackIds()).toStrictEqual([]); + }); + + it("can set and get hidden track IDs", async () => { + setHiddenTrackIds([1, 3, 5]); + expect(getHiddenTrackIds()).toStrictEqual([1, 3, 5]); + }); + + it("can set empty list", async () => { + setHiddenTrackIds([]); + expect(getHiddenTrackIds()).toStrictEqual([]); + }); + + it("can set null/undefined to empty", async () => { + setHiddenTrackIds(null); + expect(getHiddenTrackIds()).toStrictEqual([]); + setHiddenTrackIds(undefined); + expect(getHiddenTrackIds()).toStrictEqual([]); + }); + + it("deduplicates IDs via Set", async () => { + setHiddenTrackIds([1, 1, 2, 2, 3]); + const result = getHiddenTrackIds(); + expect(result.sort((a, b) => a - b)).toStrictEqual([1, 2, 3]); + }); + + it("replaces previous values on set", async () => { + setHiddenTrackIds([1, 2]); + setHiddenTrackIds([3, 4]); + expect(getHiddenTrackIds()).toStrictEqual([3, 4]); + }); +}); + +describe("visibilityChangedCallback", () => { + it("can set callback", async () => { + const cb = () => {}; + setVisibilityChangedCallback(cb); + // No error thrown + }); + + it("can clear callback with null", async () => { + setVisibilityChangedCallback(null); + // No error thrown + }); +}); + describe("axisIntervals", () => { // TEST 1 it("can handle an empty array", async () => {
ColorTracknameShow Track
${tracks[i].name}${inputTracks[i].name}${tracks[i].id}${inputTracks[i].id}