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

Add context menu for nodes #4950

Merged
merged 22 commits into from
Jan 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
82a2dfe
first context menu version
MichaelBuessemeyer Nov 20, 2020
94581f6
Merge branch 'master' of github.com:scalableminds/webknossos into add…
MichaelBuessemeyer Nov 23, 2020
cad6981
add more functionally to node context menu
MichaelBuessemeyer Nov 23, 2020
bf806c7
added measure length between nodes using the dijkstra algorithm
MichaelBuessemeyer Nov 23, 2020
cf8a967
add popover with shortcut hints and some more options
MichaelBuessemeyer Nov 30, 2020
41b487b
add option to hide a tree
MichaelBuessemeyer Nov 30, 2020
10f04b6
add context menu shown when there is no selected node
MichaelBuessemeyer Nov 30, 2020
9774288
Merge branch 'master' into add-context-menu-for-nodes
MichaelBuessemeyer Nov 30, 2020
8f05458
apply feedback
MichaelBuessemeyer Dec 4, 2020
ebbaa7a
apply some feedback
MichaelBuessemeyer Dec 8, 2020
8edf3cd
remove isUsingFirefox method
MichaelBuessemeyer Dec 11, 2020
70eb945
Merge branch 'master' of github.com:scalableminds/webknossos into add…
MichaelBuessemeyer Jan 8, 2021
6bfc47f
remove possible interactions hint
MichaelBuessemeyer Jan 8, 2021
63ce6c7
added doc entry
MichaelBuessemeyer Jan 8, 2021
36a9487
revert using short assignment
MichaelBuessemeyer Jan 8, 2021
223411a
remove unused css
MichaelBuessemeyer Jan 8, 2021
34dd8f0
add changelog entry
MichaelBuessemeyer Jan 8, 2021
acff406
Merge branch 'master' of github.com:scalableminds/webknossos into add…
MichaelBuessemeyer Jan 11, 2021
4c3b2ef
revert using compound assignment
MichaelBuessemeyer Jan 11, 2021
4e8392d
Merge branch 'master' into add-context-menu-for-nodes
MichaelBuessemeyer Jan 14, 2021
827b446
fix linting errors by disabing eslint rule
MichaelBuessemeyer Jan 15, 2021
84c6588
Merge branch 'add-context-menu-for-nodes' of github.com:scalableminds…
MichaelBuessemeyer Jan 15, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
[Commits](https://github.com/scalableminds/webknossos/compare/21.01.0...HEAD)

### Added
- Added a context menu via *Shift & Right Click* that provides easy access to skeleton functionalities and additional information. [#4950](https://github.com/scalableminds/webknossos/pull/4950)
- Added the possibility to generate skeletons from an HDF5 agglomerate file mapping on-the-fly. With an activated agglomerate file mapping, use `Shift + Middle Mouse Click` to import a skeleton of the cell into the annotation. Alternatively, use the button in the segmentation tab to import a skeleton of the centered cell into the annotation. [#4958](https://github.com/scalableminds/webknossos/pull/4958)
- Added a cleanup procedure for erroneous uploads, so failed uploads can be retried without changing the dataset name. [#4999](https://github.com/scalableminds/webknossos/pull/4999)

Expand Down
Binary file added docs/images/context_menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion docs/skeleton_annotation.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ Skeleton annotations consist of connected nodes forming a graph.
Nodes are connected through edges and are organized in trees.

Nodes can be placed by right-clicking (*Right Click*) in orthogonal mode or automatically when moving in flight or oblique mode.
webKnossos uses the concept of always having an active node and an active tree.
All (global) operations are executed on the current active node, e.g. adding a comment or node deletion.
Most keyboard shortcuts take the active node into context.
Operations on whole trees, e.g. splitting or merging trees, follow the same pattern.
Expand Down Expand Up @@ -130,6 +129,14 @@ There are also keyboard shortcuts to quickly toggle the visibility:

![Trees can be hidden for a better overview over the data. Toggle the visibility of individual tree using the checkbox in front of the tree's name or use the button to toggle all (inactive) trees at once.](images/tracing_ui_tree_visibility.png)

#### The Context Menu for Easy Access to Functionalities
webKnossos also has a context menu which can be opened via *Shift + Right Click*. This context menu takes the currently active node into context and offers functionalities plus information to the user.
![Example of the context menu](./images/context_menu.png)
The context menu has two modes.

- The first mode is active if the mouse was over a node when the user opened the context menu (like in the image above). In this case, the context menu offers additional information about the selected node and offers interactions with the active node. An example of possible interactions is to measure the path length of active node to the selected node. (This option is only available if both nodes are in the same tree).
- The other mode is active if the user did not select a node with the context menu. In this mode there are no options available that require a selected node. Instead it offers the user to create a new tree or a new node at the selected position.


### Importing & Exporting NML Files
webKnossos makes it easy to import or export skeleton annotations as [NML files](./data_formats.md#nml).
Expand Down
6 changes: 6 additions & 0 deletions frontend/javascripts/libs/mjs.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable operator-assignment */
// @flow
// See
// https://github.com/imbcmdth/mjs/blob/master/index.js
Expand Down Expand Up @@ -217,6 +218,11 @@ V3.scaledSquaredDist = function squaredDist(a, b, scale) {
return V3.lengthSquared(_tmpVec);
};

V3.scaledDist = function scaledDist(a, b, scale) {
const squaredDist = V3.scaledSquaredDist(a, b, scale);
return Math.sqrt(squaredDist);
};

V3.toArray = function(vec) {
return [vec[0], vec[1], vec[2]];
};
Expand Down
2 changes: 2 additions & 0 deletions frontend/javascripts/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ instead. Only enable this option if you understand its effect. All layers will n
"tracing.out_of_task_bounds": "The current position is outside of the task's bounding box.",
"tracing.copy_position": "Copy position to clipboard.",
"tracing.copy_rotation": "Copy rotation to clipboard.",
"tracing.tree_length_notification": (treeName: string, length: string) =>
`The tree ${treeName} has a total path length of ${length}.`,
"tracing.sharing_modal_basic_information": (sharingActiveNode?: boolean) =>
`This link includes the ${
sharingActiveNode ? "active tree node," : ""
Expand Down
58 changes: 49 additions & 9 deletions frontend/javascripts/oxalis/api/api_latest.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import {
findTreeByNodeId,
getNodeAndTree,
getNodeAndTreeOrNull,
getActiveNode,
getActiveTree,
getTree,
Expand Down Expand Up @@ -104,6 +105,7 @@ import Store, {
type VolumeTracing,
} from "oxalis/store";
import Toast, { type ToastStyle } from "libs/toast";
import PriorityQueue from "js-priority-queue";
import UrlManager from "oxalis/controller/url_manager";
import Request from "libs/request";
import * as Utils from "libs/utils";
Expand Down Expand Up @@ -605,20 +607,12 @@ class TracingApi {
const datasetScale = Store.getState().dataset.dataSource.scale;

// Pre-allocate vectors
const currentScaledPositionA = new Float32Array([0, 0, 0]);
const currentScaledPositionB = new Float32Array([0, 0, 0]);
const diffVector = new Float32Array([0, 0, 0]);

let lengthAcc = 0;
for (const edge of tree.edges.all()) {
const sourceNode = tree.nodes.get(edge.source);
const targetNode = tree.nodes.get(edge.target);

V3.scale3(sourceNode.position, datasetScale, currentScaledPositionA);
V3.scale3(targetNode.position, datasetScale, currentScaledPositionB);
V3.sub(currentScaledPositionA, currentScaledPositionB, diffVector);

lengthAcc += V3.length(diffVector);
lengthAcc += V3.scaledDist(sourceNode.position, targetNode.position, datasetScale);
}

return lengthAcc;
Expand All @@ -635,6 +629,52 @@ class TracingApi {
return totalLength;
}

measurePathLengthBetweenNodes(sourceNodeId: number, targetNodeId: number): number {
const skeletonTracing = assertSkeleton(Store.getState().tracing);
const { node: sourceNode, tree: sourceTree } = getNodeAndTreeOrNull(
skeletonTracing,
sourceNodeId,
);
const { node: targetNode, tree: targetTree } = getNodeAndTreeOrNull(
skeletonTracing,
targetNodeId,
);
if (sourceNode == null || targetNode == null) {
throw new Error(`The node with id ${sourceNodeId} or ${targetNodeId} does not exist.`);
}
if (sourceTree == null || sourceTree !== targetTree) {
throw new Error("The nodes are not within the same tree.");
}
const datasetScale = Store.getState().dataset.dataSource.scale;
// We use the Dijkstra algorithm to get the shortest path between the nodes.
const distanceMap = {};
const getDistance = nodeId =>
distanceMap[nodeId] != null ? distanceMap[nodeId] : Number.POSITIVE_INFINITY;
distanceMap[sourceNode.id] = 0;
// The priority queue saves node id and distance tuples.
const priorityQueue = new PriorityQueue<[number, number]>({
comparator: ([_first, firstDistance], [_second, secondDistance]) =>
firstDistance <= secondDistance ? -1 : 1,
});
priorityQueue.queue([sourceNodeId, 0]);
while (priorityQueue.length > 0) {
const [nextNodeId, distance] = priorityQueue.dequeue();
const nextNodePosition = sourceTree.nodes.get(nextNodeId).position;
// Calculate the distance to all neighbours and update the distances.
for (const { source, target } of sourceTree.edges.getEdgesForNode(nextNodeId)) {
const neighbourNodeId = source === nextNodeId ? target : source;
const neightbourPosition = sourceTree.nodes.get(neighbourNodeId).position;
const neighbourDistance =
distance + V3.scaledDist(nextNodePosition, neightbourPosition, datasetScale);
if (neighbourDistance < getDistance(neighbourNodeId)) {
distanceMap[neighbourNodeId] = neighbourDistance;
priorityQueue.queue([neighbourNodeId, neighbourDistance]);
}
}
}
return distanceMap[targetNodeId];
}

/**
* Starts an animation to center the given position.
*
Expand Down
10 changes: 8 additions & 2 deletions frontend/javascripts/oxalis/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ import * as Utils from "libs/utils";
import type { APIUser } from "types/api_flow_types";

import app from "app";
import constants, { ControlModeEnum, type ViewMode } from "oxalis/constants";
import constants, {
ControlModeEnum,
type ViewMode,
type Vector3,
type OrthoView,
} from "oxalis/constants";
import messages from "messages";
import window, { document } from "libs/window";

Expand All @@ -49,6 +54,7 @@ type OwnProps = {|
initialCommandType: TraceOrViewCommand,
controllerStatus: ControllerStatus,
setControllerStatus: ControllerStatus => void,
showNodeContextMenuAt: (number, number, ?number, Vector3, OrthoView) => void,
|};
type StateProps = {|
viewMode: ViewMode,
Expand Down Expand Up @@ -331,7 +337,7 @@ class Controller extends React.PureComponent<PropsWithRouter, State> {
if (isArbitrary) {
return <ArbitraryController viewMode={viewMode} />;
} else if (isPlane) {
return <PlaneController />;
return <PlaneController showNodeContextMenuAt={this.props.showNodeContextMenuAt} />;
} else {
// At the moment, all possible view modes consist of the union of MODES_ARBITRARY and MODES_PLANE
// In case we add new viewmodes, the following error will be thrown.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,14 @@ function simulateTracing(nodesPerTree: number = -1, nodesAlreadySet: number = 0)
}

const [x, y, z] = getPosition(Store.getState().flycam);
setWaypoint([x + 1, y + 1, z], false);
setWaypoint([x + 1, y + 1, z], OrthoViews.PLANE_XY, false);
_.defer(() => simulateTracing(nodesPerTree, nodesAlreadySet + 1));
}

export function getPlaneMouseControls(planeView: PlaneView) {
export function getPlaneMouseControls(
planeView: PlaneView,
showNodeContextMenuAt: (number, number, ?number, Vector3, OrthoView) => void,
) {
return {
leftDownMove: (delta: Point2, pos: Point2, _id: ?string, event: MouseEvent) => {
const { tracing } = Store.getState();
Expand All @@ -88,26 +91,18 @@ export function getPlaneMouseControls(planeView: PlaneView) {
},
leftClick: (pos: Point2, plane: OrthoView, event: MouseEvent, isTouch: boolean) =>
onClick(planeView, pos, event.shiftKey, event.altKey, event.ctrlKey, plane, isTouch, event),
rightClick: (pos: Point2, plane: OrthoView, event: MouseEvent) => {
const state = Store.getState();
if (isMagRestrictionViolated(state)) {
// The current zoom value violates the specified magnification-restriction in the
// task type. Therefore, we abort the action here.
// Actually, one would need to handle more skeleton actions (e.g., deleting a node),
// but not all (e.g., deleting a tree from the tree tab should be allowed). Therefore,
// this solution is a bit of a shortcut. However, it should cover 90% of the use case
// for restricting the rendered magnification.
// See https://github.com/scalableminds/webknossos/pull/4891 for context and
// https://github.com/scalableminds/webknossos/issues/4838 for the follow-up issue.
return;
}

const { volume } = state.tracing;
if (!volume || volume.activeTool === VolumeToolEnum.MOVE) {
// We avoid creating nodes when in brushing mode.
setWaypoint(calculateGlobalPos(pos), event.ctrlKey);
}
},
rightClick: (pos: Point2, plane: OrthoView, event: MouseEvent, isTouch: boolean) =>
onRightClick(
planeView,
pos,
event.shiftKey,
event.altKey,
event.ctrlKey,
plane,
isTouch,
event,
showNodeContextMenuAt,
),
middleClick: (pos: Point2, plane: OrthoView, event: MouseEvent) => {
if (event.shiftKey) {
agglomerateSkeletonMiddleClick(pos);
Expand Down Expand Up @@ -287,20 +282,12 @@ export function getLoopedKeyboardControls() {
};
}

function onClick(
function maybeGetNodeIdFromPosition(
planeView: PlaneView,
position: Point2,
shiftPressed: boolean,
altPressed: boolean,
ctrlPressed: boolean,
plane: OrthoView,
isTouch: boolean,
event?: MouseEvent,
): void {
if (!shiftPressed && !isTouch && !(ctrlPressed && event != null)) {
// do nothing
return;
}
): ?number {
const SceneController = getSceneController();

// render the clicked viewport with picking enabled
Expand Down Expand Up @@ -328,10 +315,28 @@ function onClick(

// prevent flickering sometimes caused by picking
planeView.renderFunction(true);
return nodeId > 0 ? nodeId : null;
}

function onClick(
planeView: PlaneView,
position: Point2,
shiftPressed: boolean,
altPressed: boolean,
ctrlPressed: boolean,
plane: OrthoView,
isTouch: boolean,
event?: MouseEvent,
): void {
if (!shiftPressed && !isTouch && !(ctrlPressed && event != null)) {
// do nothing
return;
}
const nodeId = maybeGetNodeIdFromPosition(planeView, position, plane, isTouch);

const skeletonTracing = enforceSkeletonTracing(Store.getState().tracing);
// otherwise we have hit the background and do nothing
if (nodeId > 0) {
if (nodeId != null && nodeId > 0) {
if (altPressed) {
getActiveNode(skeletonTracing).map(activeNode =>
Store.dispatch(mergeTreesAction(activeNode.id, nodeId)),
Expand All @@ -348,13 +353,57 @@ function onClick(
}
}

function setWaypoint(position: Vector3, ctrlPressed: boolean): void {
function onRightClick(
planeView: PlaneView,
position: Point2,
shiftPressed: boolean,
altPressed: boolean,
ctrlPressed: boolean,
plane: OrthoView,
isTouch: boolean,
event: MouseEvent,
showNodeContextMenuAt: (number, number, ?number, Vector3, OrthoView) => void,
) {
const state = Store.getState();
if (isMagRestrictionViolated(state)) {
// The current zoom value violates the specified magnification-restriction in the
// task type. Therefore, we abort the action here.
// Actually, one would need to handle more skeleton actions (e.g., deleting a node),
// but not all (e.g., deleting a tree from the tree tab should be allowed). Therefore,
// this solution is a bit of a shortcut. However, it should cover 90% of the use case
// for restricting the rendered magnification.
// See https://github.com/scalableminds/webknossos/pull/4891 for context and
// https://github.com/scalableminds/webknossos/issues/4838 for the follow-up issue.
return;
}
const { activeViewport } = Store.getState().viewModeData.plane;
if (activeViewport === OrthoViews.TDView) {
return;
}

const { volume } = state.tracing;
if (!volume || volume.activeTool === VolumeToolEnum.MOVE) {
// We avoid creating nodes when in brushing mode.
const nodeId = event.shiftKey
? maybeGetNodeIdFromPosition(planeView, position, plane, isTouch)
: null;
const globalPosition = calculateGlobalPos(position);
if (event.shiftKey) {
showNodeContextMenuAt(event.pageX, event.pageY, nodeId, globalPosition, activeViewport);
} else {
setWaypoint(globalPosition, activeViewport, ctrlPressed);
}
}
}

export function setWaypoint(
position: Vector3,
activeViewport: OrthoView,
ctrlPressed: boolean,
): void {
const skeletonTracing = enforceSkeletonTracing(Store.getState().tracing);
const activeNodeMaybe = getActiveNode(skeletonTracing);
const rotation = getRotationOrtho(activeViewport);

// set the new trace direction
activeNodeMaybe.map(activeNode =>
Expand All @@ -367,7 +416,6 @@ function setWaypoint(position: Vector3, ctrlPressed: boolean): void {
),
);

const rotation = getRotationOrtho(activeViewport);
addNode(position, rotation, !ctrlPressed);

// Ctrl + right click to set new not active branchpoint
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,17 @@ function ensureNonConflictingHandlers(skeletonControls: Object, volumeControls:
}
}

type OwnProps = {| showNodeContextMenuAt: (number, number, ?number, Vector3, OrthoView) => void |};

type StateProps = {|
tracing: Tracing,
is2d: boolean,
|};
type Props = {| ...StateProps |};

type Props = {|
...StateProps,
...OwnProps,
|};

export const movePlane = (v: Vector3, increaseSpeedWithZoom: boolean = true) => {
const { activeViewport } = Store.getState().viewModeData.plane;
Expand Down Expand Up @@ -173,7 +179,7 @@ class PlaneController extends React.PureComponent<Props> {
...skeletonControls
} =
this.props.tracing.skeleton != null
? skeletonController.getPlaneMouseControls(this.planeView)
? skeletonController.getPlaneMouseControls(this.planeView, this.props.showNodeContextMenuAt)
: emptyDefaultHandler;

const {
Expand Down Expand Up @@ -625,4 +631,4 @@ export function mapStateToProps(state: OxalisState): StateProps {
}

export { PlaneController as PlaneControllerClass };
export default connect<Props, {||}, _, _, _, _>(mapStateToProps)(PlaneController);
export default connect<Props, OwnProps, _, _, _, _>(mapStateToProps)(PlaneController);
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ export function mergeTrees(
sourceNodeId: number,
targetNodeId: number,
restrictions: RestrictionsAndSettings,
): Maybe<[Tree, number, number]> {
): Maybe<[TreeMap, number, number]> {
const { allowUpdate } = restrictions;
const { trees } = skeletonTracing;
const sourceTree = findTreeByNodeId(trees, sourceNodeId).get();
Expand Down
Loading