Skip to content

Commit

Permalink
Encode additional coordinates in url (#7328)
Browse files Browse the repository at this point in the history
* encode additional coordinates in url

* fix hash parsing bug

* add spec for additional coordinate parsing

* update changelog

* use sth like pos-t in url for additional coordinates
  • Loading branch information
philippotto committed Oct 12, 2023
1 parent 7fe2849 commit 74347e6
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 65 deletions.
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) {
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

0 comments on commit 74347e6

Please sign in to comment.