diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index e2d75b10845..a62d32f580b 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -14,6 +14,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Added a new tool that allows either measuring the distance of a path or a non-self-crossing area. [#7258](https://github.com/scalableminds/webknossos/pull/7258) - Added social media link previews for links to datasets and annotations (only if they are public or if the links contain sharing tokens). [#7331](https://github.com/scalableminds/webknossos/pull/7331) - Loading sharded zarr3 datasets is now significantly faster. [#7363](https://github.com/scalableminds/webknossos/pull/7363) +- Higher-dimension coordinates (e.g., for the t axis) are now encoded in the URL, too, so that reloading the page will keep you at your current position. Only relevant for 4D datasets. [#7328](https://github.com/scalableminds/webknossos/pull/7328) ### Changed - Updated backend code to Scala 2.13, with upgraded Dependencies for optimized performance. [#7327](https://github.com/scalableminds/webknossos/pull/7327) diff --git a/frontend/javascripts/oxalis/controller/url_manager.ts b/frontend/javascripts/oxalis/controller/url_manager.ts index cf293f6198f..d5ef48492ea 100644 --- a/frontend/javascripts/oxalis/controller/url_manager.ts +++ b/frontend/javascripts/oxalis/controller/url_manager.ts @@ -20,8 +20,13 @@ import { validateUrlStateJSON } from "types/validation"; import { APIAnnotationType, APICompoundTypeEnum } from "types/api_flow_types"; import { coalesce } from "libs/utils"; import { type AdditionalCoordinate } from "types/api_flow_types"; +import { + additionalCoordinateToKeyValue, + parseAdditionalCoordinateKey, +} from "oxalis/model/helpers/nml_helpers"; const MAX_UPDATE_INTERVAL = 1000; +const MINIMUM_VALID_CSV_LENGTH = 5; type BaseMeshUrlDescriptor = { readonly segmentId: number; @@ -91,10 +96,8 @@ export type UrlManagerState = { export type PartialUrlManagerState = Partial; class UrlManager { - // @ts-expect-error ts-migrate(2564) FIXME: Property 'baseUrl' has no initializer and is not d... Remove this comment to see the full error message - baseUrl: string; - // @ts-expect-error ts-migrate(2564) FIXME: Property 'initialState' has no initializer and is ... Remove this comment to see the full error message - initialState: PartialUrlManagerState; + baseUrl: string = ""; + initialState: PartialUrlManagerState = {}; initialize() { this.baseUrl = location.pathname + location.search; @@ -133,11 +136,13 @@ class UrlManager { if (urlHash.includes("{")) { // The hash is in json format return this.parseUrlHashJson(urlHash); - } else if (urlHash.includes("=")) { + } else if (urlHash.split(",")[0].includes("=")) { // The hash was changed by a comment link return this.parseUrlHashCommentLink(urlHash); } else { - // The hash is in csv format + // The hash is in csv format (it can also contain + // key=value pairs, but only after the first mandatory + // CSV values). return this.parseUrlHashCsv(urlHash); } } @@ -167,40 +172,63 @@ class UrlManager { parseUrlHashCsv(urlHash: string): PartialUrlManagerState { // State string format: - // x,y,z,mode,zoomStep[,rotX,rotY,rotZ][,activeNode] + // x,y,z,mode,zoomStep[,rotX,rotY,rotZ][,activeNode][,key=value]* const state: PartialUrlManagerState = {}; - if (urlHash) { - const stateArray = urlHash.split(",").map(Number); - const validStateArray = stateArray.map((value) => (!isNaN(value) ? value : 0)); - - if (validStateArray.length >= 5) { - const positionValues = validStateArray.slice(0, 3); - state.position = Utils.numberArrayToVector3(positionValues); - const modeString = ViewModeValues[validStateArray[3]]; + if (!urlHash) { + return state; + } - if (modeString) { - state.mode = modeString; - } else { - // Let's default to MODE_PLANE_TRACING - state.mode = constants.MODE_PLANE_TRACING; - } + const commaSeparatedValues = urlHash.split(","); + const [baseValues, keyValuePairStrings] = _.partition( + commaSeparatedValues, + (value) => !value.includes("="), + ); + const stateArray = baseValues.map(Number); + const validStateArray = stateArray.map((value) => (!isNaN(value) ? value : 0)); + + if (validStateArray.length >= MINIMUM_VALID_CSV_LENGTH) { + const positionValues = validStateArray.slice(0, 3); + state.position = Utils.numberArrayToVector3(positionValues); + const modeString = ViewModeValues[validStateArray[3]]; + + if (modeString) { + state.mode = modeString; + } else { + // Let's default to MODE_PLANE_TRACING + state.mode = constants.MODE_PLANE_TRACING; + } - // default to zoom step 1 - state.zoomStep = validStateArray[4] !== 0 ? validStateArray[4] : 1; + // default to zoom step 1 + state.zoomStep = validStateArray[4] !== 0 ? validStateArray[4] : 1; - if (validStateArray.length >= 8) { - state.rotation = Utils.numberArrayToVector3(validStateArray.slice(5, 8)); + if (validStateArray.length >= 8) { + state.rotation = Utils.numberArrayToVector3(validStateArray.slice(5, 8)); - if (validStateArray[8] != null) { - state.activeNode = validStateArray[8]; - } - } else if (validStateArray[5] != null) { - state.activeNode = validStateArray[5]; + if (validStateArray[8] != null) { + state.activeNode = validStateArray[8]; } + } else if (validStateArray[5] != null) { + state.activeNode = validStateArray[5]; } } + const additionalCoordinates = []; + const keyValuePairs = keyValuePairStrings.map((keyValueStr) => keyValueStr.split("=", 2)); + for (const [key, value] of keyValuePairs) { + const coordinateName = parseAdditionalCoordinateKey(key, true); + if (coordinateName != null) { + additionalCoordinates.push({ + name: coordinateName, + value: parseFloat(value), + }); + } + } + + if (additionalCoordinates.length > 0) { + state.additionalCoordinates = additionalCoordinates; + } + return state; } @@ -210,7 +238,7 @@ class UrlManager { window.onhashchange = () => this.onHashChange(); } - getUrlState(state: OxalisState): UrlManagerState { + getUrlState(state: OxalisState): UrlManagerState & { mode: ViewMode } { const position: Vector3 = V3.floor(getPosition(state.flycam)); const { viewMode: mode } = state.temporaryConfiguration; const zoomStep = Utils.roundTo(state.flycam.zoomStep, 3); @@ -305,10 +333,20 @@ class UrlManager { buildUrlHashCsv(state: OxalisState): string { const { position = [], mode, zoomStep, rotation = [], activeNode } = this.getUrlState(state); - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | undefined' is not assig... Remove this comment to see the full error message const viewModeIndex = ViewModeValues.indexOf(mode); const activeNodeArray = activeNode != null ? [activeNode] : []; - return [...position, viewModeIndex, zoomStep, ...rotation, ...activeNodeArray].join(","); + const keyValuePairs = (state.flycam.additionalCoordinates || []).map((coord) => + additionalCoordinateToKeyValue(coord, true), + ); + + return [ + ...position, + viewModeIndex, + zoomStep, + ...rotation, + ...activeNodeArray, + ...keyValuePairs.map(([key, value]) => `${key}=${value}`), + ].join(","); } buildUrlHashJson(state: OxalisState): string { diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index f8d00e677d7..874068a06b0 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -340,16 +340,36 @@ function serializeNodes(nodes: NodeMap): Array { }); } +function getAdditionalCoordinateLabel(useConciseStyle: boolean) { + return useConciseStyle ? "pos" : "additionalCoordinate"; +} + +export function additionalCoordinateToKeyValue( + coord: AdditionalCoordinate, + useConciseStyle: boolean = false, +): [string, number] { + const label = getAdditionalCoordinateLabel(useConciseStyle); + return [ + // Export additional coordinates like this: + // additionalCoordinate-t="10" + // Don't capitalize coord.name, because it it's not reversible for + // names that are already capitalized. + `${label}-${coord.name}`, + coord.value, + ]; +} + +export function parseAdditionalCoordinateKey( + key: string, + expectConciseStyle: boolean = false, +): string { + const label = getAdditionalCoordinateLabel(expectConciseStyle); + return key.split(`${label}-`)[1]; +} + function additionalCoordinatesToObject(additionalCoordinates: AdditionalCoordinate[]) { return Object.fromEntries( - additionalCoordinates.map((coord) => [ - // Export additional coordinates like this: - // additionalCoordinate-t="10" - // Don't capitalize coord.name, because it it's not reversible for - // names that are already capitalized. - `additionalCoordinate-${coord.name}`, - coord.value, - ]), + additionalCoordinates.map((coord) => additionalCoordinateToKeyValue(coord)), ); } @@ -742,7 +762,7 @@ export function parseNml(nmlString: string): Promise<{ ] as Vector3, // Parse additional coordinates, like additionalCoordinate-t="10" additionalCoordinates: Object.keys(attr) - .map((key) => [key, key.split("additionalCoordinate-")[1]]) + .map((key) => [key, parseAdditionalCoordinateKey(key)]) .filter(([_key, name]) => name != null) .map(([key, name]) => ({ name, diff --git a/frontend/javascripts/test/controller/url_manager.spec.ts b/frontend/javascripts/test/controller/url_manager.spec.ts index fa43226dd66..556892ebb5d 100644 --- a/frontend/javascripts/test/controller/url_manager.spec.ts +++ b/frontend/javascripts/test/controller/url_manager.spec.ts @@ -1,9 +1,12 @@ -// @ts-nocheck import "test/mocks/lz4"; import test from "ava"; -import UrlManager, { updateTypeAndId, encodeUrlHash } from "oxalis/controller/url_manager"; +import UrlManager, { + updateTypeAndId, + encodeUrlHash, + UrlManagerState, +} from "oxalis/controller/url_manager"; import { location } from "libs/window"; -import Constants, { ViewModeValues } from "oxalis/constants"; +import Constants, { Vector3, ViewModeValues } from "oxalis/constants"; import defaultState from "oxalis/default_state"; import update from "immutability-helper"; @@ -39,12 +42,12 @@ test("UrlManager should replace tracing in url", (t) => { test("UrlManager should parse full csv url hash", (t) => { const state = { - position: [555, 278, 482], - mode: "flight", + position: [555, 278, 482] as Vector3, + mode: "flight" as const, zoomStep: 2.0, - rotation: [40.45, 13.65, 0.8], + rotation: [40.45, 13.65, 0.8] as Vector3, activeNode: 2, - }; + } as const; location.hash = `#${[ ...state.position, ViewModeValues.indexOf(state.mode), @@ -57,10 +60,10 @@ test("UrlManager should parse full csv url hash", (t) => { test("UrlManager should parse csv url hash without optional values", (t) => { const state = { - position: [555, 278, 482], - mode: "flight", + position: [555, 278, 482] as Vector3, + mode: "flight" as const, zoomStep: 2.0, - rotation: [40.45, 13.65, 0.8], + rotation: [40.45, 13.65, 0.8] as Vector3, activeNode: 2, }; // rome-ignore lint/correctness/noUnusedVariables: underscore prefix does not work with object destructuring @@ -71,7 +74,7 @@ test("UrlManager should parse csv url hash without optional values", (t) => { state.zoomStep, state.activeNode, ].join(",")}`; - t.deepEqual(UrlManager.parseUrlHash(), stateWithoutRotation); + t.deepEqual(UrlManager.parseUrlHash(), stateWithoutRotation as Partial); // rome-ignore lint/correctness/noUnusedVariables: underscore prefix does not work with object destructuring const { activeNode, ...stateWithoutActiveNode } = state; location.hash = `#${[ @@ -91,10 +94,31 @@ test("UrlManager should parse csv url hash without optional values", (t) => { test("UrlManager should build csv url hash and parse it again", (t) => { const mode = Constants.MODE_ARBITRARY; const urlState = { - position: [0, 0, 0], + position: [0, 0, 0] as Vector3, + mode, + zoomStep: 1.3, + rotation: [0, 0, 180] as Vector3, + }; + const initialState = update(defaultState, { + temporaryConfiguration: { + viewMode: { + $set: mode, + }, + }, + }); + const hash = UrlManager.buildUrlHashCsv(initialState); + location.hash = `#${hash}`; + t.deepEqual(UrlManager.parseUrlHash(), urlState); +}); + +test.only("UrlManager should build csv url hash with additional coordinates and parse it again", (t) => { + const mode = Constants.MODE_ARBITRARY; + const urlState = { + position: [0, 0, 0] as Vector3, mode, zoomStep: 1.3, - rotation: [0, 0, 180], + rotation: [0, 0, 180] as Vector3, + additionalCoordinates: [{ name: "t", value: 123 }], }; const initialState = update(defaultState, { temporaryConfiguration: { @@ -102,7 +126,11 @@ test("UrlManager should build csv url hash and parse it again", (t) => { $set: mode, }, }, + flycam: { + additionalCoordinates: { $set: [{ name: "t", value: 123 }] }, + }, }); + const hash = UrlManager.buildUrlHashCsv(initialState); location.hash = `#${hash}`; t.deepEqual(UrlManager.parseUrlHash(), urlState); @@ -110,7 +138,7 @@ test("UrlManager should build csv url hash and parse it again", (t) => { test("UrlManager should parse url hash with comment links", (t) => { const state = { - position: [555, 278, 482], + position: [555, 278, 482] as Vector3, activeNode: 2, }; @@ -124,11 +152,11 @@ test("UrlManager should parse url hash with comment links", (t) => { test("UrlManager should parse json url hash", (t) => { const state = { - position: [555, 278, 482], + position: [555, 278, 482] as Vector3, additionalCoordinates: [], - mode: "flight", + mode: "flight" as const, zoomStep: 2.0, - rotation: [40.45, 13.65, 0.8], + rotation: [40.45, 13.65, 0.8] as Vector3, activeNode: 2, }; location.hash = `#${encodeUrlHash(JSON.stringify(state))}`; @@ -137,11 +165,11 @@ test("UrlManager should parse json url hash", (t) => { test("UrlManager should parse incomplete json url hash", (t) => { const state = { - position: [555, 278, 482], + position: [555, 278, 482] as Vector3, additionalCoordinates: [], - mode: "flight", + mode: "flight" as const, zoomStep: 2.0, - rotation: [40.45, 13.65, 0.8], + rotation: [40.45, 13.65, 0.8] as Vector3, activeNode: 2, }; // rome-ignore lint/correctness/noUnusedVariables: underscore prefix does not work with object destructuring @@ -157,11 +185,11 @@ test("UrlManager should parse incomplete json url hash", (t) => { test("UrlManager should build json url hash and parse it again", (t) => { const mode = Constants.MODE_ARBITRARY; const urlState = { - position: [0, 0, 0], + position: [0, 0, 0] as Vector3, additionalCoordinates: [], mode, zoomStep: 1.3, - rotation: [0, 0, 180], + rotation: [0, 0, 180] as Vector3 as Vector3, }; const initialState = update(defaultState, { temporaryConfiguration: { @@ -172,7 +200,7 @@ test("UrlManager should build json url hash and parse it again", (t) => { }); const hash = UrlManager.buildUrlHashJson(initialState); location.hash = `#${hash}`; - t.deepEqual(UrlManager.parseUrlHash(), urlState); + t.deepEqual(UrlManager.parseUrlHash(), urlState as Partial); }); test("UrlManager should build default url in csv format", (t) => {