Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Encode additional coordinates in url #7328

Merged
merged 8 commits into from
Oct 12, 2023
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
104 changes: 71 additions & 33 deletions frontend/javascripts/oxalis/controller/url_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,10 +96,8 @@ export type UrlManagerState = {
export type PartialUrlManagerState = Partial<UrlManagerState>;

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;
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 29 additions & 9 deletions frontend/javascripts/oxalis/model/helpers/nml_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,16 +340,36 @@ function serializeNodes(nodes: NodeMap): Array<string> {
});
}

function getAdditionalCoordinateLabel(useConciseStyle: boolean) {
normanrz marked this conversation as resolved.
Show resolved Hide resolved
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)),
);
}

Expand Down Expand Up @@ -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,
Expand Down
74 changes: 51 additions & 23 deletions frontend/javascripts/test/controller/url_manager.spec.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand All @@ -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<UrlManagerState>);
// rome-ignore lint/correctness/noUnusedVariables: underscore prefix does not work with object destructuring
const { activeNode, ...stateWithoutActiveNode } = state;
location.hash = `#${[
Expand All @@ -91,26 +94,51 @@ 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: {
viewMode: {
$set: mode,
},
},
flycam: {
additionalCoordinates: { $set: [{ name: "t", value: 123 }] },
},
});

const hash = UrlManager.buildUrlHashCsv(initialState);
location.hash = `#${hash}`;
t.deepEqual(UrlManager.parseUrlHash(), urlState);
});

test("UrlManager should parse url hash with comment links", (t) => {
const state = {
position: [555, 278, 482],
position: [555, 278, 482] as Vector3,
activeNode: 2,
};

Expand All @@ -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))}`;
Expand All @@ -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
Expand All @@ -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: {
Expand All @@ -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<UrlManagerState>);
});

test("UrlManager should build default url in csv format", (t) => {
Expand Down