diff --git a/client/__tests__/util/dataframe/dataframe.test.js b/client/__tests__/util/dataframe/dataframe.test.js index 149c663d..f489f132 100644 --- a/client/__tests__/util/dataframe/dataframe.test.js +++ b/client/__tests__/util/dataframe/dataframe.test.js @@ -321,7 +321,10 @@ describe("dataframe factories", () => { test("KeyIndex", () => { const df = new Dataframe.Dataframe( [2, 2], - [["red", "blue"], [true, false]], + [ + ["red", "blue"], + [true, false] + ], null, new Dataframe.KeyIndex(["colors", "bools"]) ); @@ -341,7 +344,10 @@ describe("dataframe factories", () => { test("DenseInt32Index", () => { const df = new Dataframe.Dataframe( [2, 2], - [["red", "blue"], [true, false]], + [ + ["red", "blue"], + [true, false] + ], null, new Dataframe.DenseInt32Index([74, 75]) ); @@ -363,7 +369,10 @@ describe("dataframe factories", () => { test("DenseInt32Index promote", () => { const df = new Dataframe.Dataframe( [2, 2], - [["red", "blue"], [true, false]], + [ + ["red", "blue"], + [true, false] + ], null, new Dataframe.DenseInt32Index([74, 75]) ); @@ -385,7 +394,10 @@ describe("dataframe factories", () => { test("IdentityInt32Index with last", () => { const df = new Dataframe.Dataframe( [2, 2], - [["red", "blue"], [true, false]], + [ + ["red", "blue"], + [true, false] + ], null, null ); @@ -407,7 +419,10 @@ describe("dataframe factories", () => { test("IdentityInt32Index promote", () => { const df = new Dataframe.Dataframe( [2, 2], - [["red", "blue"], [true, false]], + [ + ["red", "blue"], + [true, false] + ], null, null ); @@ -461,7 +476,11 @@ describe("dataframe factories", () => { */ const dfA = new Dataframe.Dataframe( [2, 3], - [["red", "blue"], [true, false], [1, 0]], + [ + ["red", "blue"], + [true, false], + [1, 0] + ], null, new Dataframe.KeyIndex(["colors", "bools", "numbers"]) ); @@ -520,13 +539,87 @@ describe("dataframe factories", () => { expect(dfC.col("colors").asArray()).toEqual(["red", "blue"]); expect(dfC.col("bools").asArray()).toEqual([true, false]); }); + + test("column picking", () => { + const dfEmpty = Dataframe.Dataframe.empty(); + const dfA = new Dataframe.Dataframe( + [2, 1], + [["red", "blue"]], + null, + new Dataframe.KeyIndex(["colors"]) + ); + const dfB = new Dataframe.Dataframe( + [2, 3], + [ + ["red", "blue"], + [true, false], + [1, 0] + ], + null, + new Dataframe.KeyIndex(["colors", "bools", "numbers"]) + ); + + const dfX = dfEmpty.withColsFrom(dfB, ["colors", "bools"]); + expect(dfX).toBeDefined(); + expect(dfX.dims).toEqual([2, 2]); + expect(dfX.colIndex.keys()).toEqual(["colors", "bools"]); + expect(dfX.rowIndex).toEqual(dfB.rowIndex); + expect(dfX.icol(0).asArray()).toEqual(dfB.icol(0).asArray()); + + const dfY = dfA.withColsFrom(dfB, ["numbers"]); + expect(dfY).toBeDefined(); + expect(dfY.dims).toEqual([2, 2]); + expect(dfY.colIndex.keys()).toEqual(["colors", "numbers"]); + expect(dfY.rowIndex).toEqual(dfA.rowIndex); + expect(dfY.icol(0).asArray()).toEqual(dfA.icol(0).asArray()); + + const dfZ = dfA.withColsFrom(dfEmpty, []); + expect(dfZ).toBeDefined(); + expect(dfZ.dims).toEqual(dfA.dims); + expect(dfZ.colIndex.keys()).toEqual(dfA.colIndex.keys()); + expect(dfZ.rowIndex).toEqual(dfA.rowIndex); + expect(dfZ.icol(0).asArray()).toEqual(dfA.icol(0).asArray()); + + expect(() => dfA.withColsFrom(dfB, ["bools", "colors"])).toThrow(); + }); + + test("column aliasing", () => { + const dfA = new Dataframe.Dataframe( + [2, 1], + [["red", "blue"]], + null, + new Dataframe.KeyIndex(["colors"]) + ); + const dfB = new Dataframe.Dataframe( + [2, 3], + [ + ["red", "blue"], + [true, false], + [1, 0] + ], + null, + new Dataframe.KeyIndex(["colors", "bools", "numbers"]) + ); + + const dfX = dfA.withColsFrom(dfB, { colors: "_colors", bools: "_bools" }); + expect(dfX).toBeDefined(); + expect(dfX.dims).toEqual([2, 3]); + expect(dfX.colIndex.keys()).toEqual(["colors", "_colors", "_bools"]); + expect(dfX.rowIndex).toEqual(dfA.rowIndex); + expect(dfX.icol(0).asArray()).toEqual(dfA.icol(0).asArray()); + expect(dfX.col("_colors").asArray()).toBe(dfB.col("colors").asArray()); + }); }); describe("dropCol", () => { test("KeyIndex", () => { const df = new Dataframe.Dataframe( [2, 3], - [["red", "blue"], [true, false], [1, 0]], + [ + ["red", "blue"], + [true, false], + [1, 0] + ], null, new Dataframe.KeyIndex(["colors", "bools", "numbers"]) ); @@ -545,7 +638,11 @@ describe("dataframe factories", () => { test("IdentityInt32Index drop first", () => { const df = new Dataframe.Dataframe( [2, 3], - [["red", "blue"], [true, false], [1, 0]], + [ + ["red", "blue"], + [true, false], + [1, 0] + ], null, null ); @@ -565,7 +662,11 @@ describe("dataframe factories", () => { test("IdentityInt32Index drop last", () => { const df = new Dataframe.Dataframe( [2, 3], - [["red", "blue"], [true, false], [1, 0]], + [ + ["red", "blue"], + [true, false], + [1, 0] + ], null, null ); @@ -585,7 +686,11 @@ describe("dataframe factories", () => { test("DenseInt32Index", () => { const df = new Dataframe.Dataframe( [2, 3], - [["red", "blue"], [true, false], [1, 0]], + [ + ["red", "blue"], + [true, false], + [1, 0] + ], null, new Dataframe.DenseInt32Index([102, 101, 100]) ); @@ -653,7 +758,10 @@ describe("dataframe factories", () => { test("renameCol", () => { const dfA = new Dataframe.Dataframe( [2, 2], - [[true, false], [1, 0]], + [ + [true, false], + [1, 0] + ], null, new Dataframe.KeyIndex(["A", "B"]) ); @@ -671,7 +779,10 @@ describe("dataframe col", () => { beforeEach(() => { df = new Dataframe.Dataframe( [2, 2], - [[true, false], [1, 0]], + [ + [true, false], + [1, 0] + ], null, new Dataframe.KeyIndex(["A", "B"]) ); diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 3cab18d8..f199e616 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -7,6 +7,7 @@ import { doBinaryRequest, dispatchNetworkErrorMessageToUser } from "../util/actionHelpers"; +import { requestReembed, reembedResetWorldToUniverse } from "./reembed"; /* return promise to fetch the OBS annotations we need to load. Omit anything @@ -387,6 +388,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async ( const resetWorldToUniverse = () => (dispatch, getState) => { const { universe } = getState(); + reembedResetWorldToUniverse(dispatch, getState); dispatch({ type: "reset World to eq Universe", universe @@ -451,7 +453,8 @@ export default { requestDifferentialExpression, requestSingleGeneExpressionCountsForColoringPOST, requestUserDefinedGene, + requestReembed, resetWorldToUniverse, saveObsAnnotations, - setWorldToSelection, + setWorldToSelection }; diff --git a/client/src/actions/reembed.js b/client/src/actions/reembed.js new file mode 100644 index 00000000..8e340816 --- /dev/null +++ b/client/src/actions/reembed.js @@ -0,0 +1,113 @@ +import { API } from "../globals"; +import { Universe } from "../util/stateManager"; +import { + postNetworkErrorToast, + postAsyncSuccessToast, + postAsyncFailureToast +} from "../components/framework/toasters"; + +function abortableFetch(request, opts, timeout = 0) { + const controller = new AbortController(); + const { signal } = controller; + + return { + abort: () => controller.abort(), + isAborted: () => signal.aborted, + ready: () => { + if (timeout) { + setTimeout(() => controller.abort(), timeout); + } + return fetch(request, { ...opts, signal }); + } + }; +} + +async function doReembedFetch(dispatch, getState) { + const state = getState(); + let cells = state.world.obsAnnotations.rowIndex.keys(); + + // These lines ensure that we convert any TypedArray to an Array. + // This is necessary because JSON.stringify() does some very strange + // things with TypedArrays (they are marshalled to JSON objects, rather + // than being marshalled as a JSON array). + cells = Array.isArray(cells) ? cells : Array.from(cells); + + const af = abortableFetch( + `${API.prefix}${API.version}layout/obs`, + { + method: "PUT", + headers: new Headers({ + Accept: "application/octet-stream", + "Content-Type": "application/json" + }), + body: JSON.stringify({ + method: "umap", + filter: { obs: { index: cells } } + }), + credentials: "include" + }, + 60000 // 1 minute timeout + ); + dispatch({ + type: "reembed: request start", + abortableFetch: af + }); + const res = await af.ready(); + + if ( + res.ok && + res.headers.get("Content-Type").includes("application/octet-stream") + ) { + return res; + } + + // else an error + let msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`; + const body = await res.text(); + if (body && body.length > 0) { + msg = `${msg} -- ${body}`; + } + postNetworkErrorToast(msg); + throw new Error(msg); +} + +/* +functions below are dispatch-able +*/ +export function requestReembed() { + return async (dispatch, getState) => { + try { + const res = await doReembedFetch(dispatch, getState); + const schema = JSON.parse(res.headers.get("CxG-Schema")); + const buffer = await res.arrayBuffer(); + const df = Universe.matrixFBSToDataframe(buffer); + dispatch({ + type: "reembed: request completed" + }); + dispatch({ + type: "reembed: add reembedding", + embedding: df, + schema + }); + postAsyncSuccessToast("Re-embedding has completed."); + } catch (error) { + dispatch({ + type: "reembed: request aborted" + }); + if (error.name === "AbortError") { + postAsyncFailureToast("Re-embedding calculation was aborted."); + } else { + postNetworkErrorToast(`Re-embedding: ${error.message}`); + } + console.log("Reembed exception:", error, error.name, error.message); + } + }; +} + +export function reembedResetWorldToUniverse(dispatch, getState) { + const { reembedController } = getState(); + if (reembedController.pendingFetch) reembedController.pendingFetch.abort(); + dispatch({ + type: "reembed: clear all reembeddings" + }); +} diff --git a/client/src/components/framework/toasters.js b/client/src/components/framework/toasters.js index 46c9d4e8..52964d29 100644 --- a/client/src/components/framework/toasters.js +++ b/client/src/components/framework/toasters.js @@ -2,7 +2,7 @@ import { Position, Toaster, Intent } from "@blueprintjs/core"; /** Singleton toaster instance. Create separate instances for different options. */ -const ErrorToastTopCenter = Toaster.create({ +const ToastTopCenter = Toaster.create({ className: "recipe-toaster", position: Position.TOP }); @@ -11,21 +11,38 @@ const ErrorToastTopCenter = Toaster.create({ A "user" error - eg, bad input */ export const postUserErrorToast = message => - ErrorToastTopCenter.show({ message, intent: Intent.WARNING }); + ToastTopCenter.show({ message, intent: Intent.WARNING }); /* A toast the user must dismiss manually, because they need to act on its information, ie., 8 bulk add genes out of 40 were bad. Manually see which ones and fix. */ export const keepAroundErrorToast = message => - ErrorToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING }); + ToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING }); /* a hard network error */ export const postNetworkErrorToast = message => - ErrorToastTopCenter.show({ + ToastTopCenter.show({ message, timeout: 30000, intent: Intent.DANGER }); + +/* +Async message to user +*/ +export const postAsyncSuccessToast = message => + ToastTopCenter.show({ + message, + timeout: 10000, + intent: Intent.SUCCESS + }); + +export const postAsyncFailureToast = message => + ToastTopCenter.show({ + message, + timeout: 10000, + intent: Intent.WARNING + }); diff --git a/client/src/components/menubar/embedding.js b/client/src/components/menubar/embedding.js new file mode 100644 index 00000000..e1fa9963 --- /dev/null +++ b/client/src/components/menubar/embedding.js @@ -0,0 +1,122 @@ +import React from "react"; +import { + AnchorButton, + ButtonGroup, + Popover, + Button, + Radio, + RadioGroup, + Tooltip, + Position +} from "@blueprintjs/core"; +import { connect } from "react-redux"; +import * as globals from "../../globals"; +import { World } from "../../util/stateManager"; +import actions from "../../actions"; + +@connect(state => ({ + universe: state.universe, + world: state.world, + layoutChoice: state.layoutChoice, + reembedController: state.reembedController, + enableReembedding: state.config?.parameters?.["enable-reembedding"] ?? false +})) +class Embedding extends React.PureComponent { + handleLayoutChoiceChange = e => { + const { dispatch } = this.props; + dispatch({ + type: "set layout choice", + layoutChoice: e.currentTarget.value + }); + }; + + renderReembedding() { + const { + enableReembedding, + world, + universe, + dispatch, + reembedController + } = this.props; + + if (!enableReembedding) return null; + + const loading = !!reembedController?.pendingFetch; + const disabled = World.worldEqUniverse(world, universe); + const tipContent = disabled + ? "Subset cells first, then click to recompute UMAP embedding." + : "Click to recompute UMAP embedding on the current cell subset."; + + return ( + + dispatch(actions.requestReembed())} + loading={loading} + /> + + ); + } + + render() { + const { layoutChoice } = this.props; + + return ( + + +