diff --git a/applications/visualizer/frontend/src/components/viewers/EM/EMStackTilesViewer.tsx b/applications/visualizer/frontend/src/components/viewers/EM/EMStackTilesViewer.tsx index 65ffd06e..0d4f0dc4 100644 --- a/applications/visualizer/frontend/src/components/viewers/EM/EMStackTilesViewer.tsx +++ b/applications/visualizer/frontend/src/components/viewers/EM/EMStackTilesViewer.tsx @@ -3,6 +3,7 @@ import "ol/ol.css"; import { type Feature, Map as OLMap, View } from "ol"; import type { FeatureLike } from "ol/Feature"; import ScaleLine from "ol/control/ScaleLine"; +import type { Coordinate } from "ol/coordinate"; import { shiftKeyOnly } from "ol/events/condition"; import { getCenter } from "ol/extent"; import GeoJSON from "ol/format/GeoJSON"; @@ -12,59 +13,15 @@ import VectorLayer from "ol/layer/Vector"; import { Projection } from "ol/proj"; import { XYZ } from "ol/source"; import VectorSource from "ol/source/Vector"; -import Fill from "ol/style/Fill"; -import Stroke from "ol/style/Stroke"; -import Style from "ol/style/Style"; -import Text from "ol/style/Text"; import { TileGrid } from "ol/tilegrid"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useGlobalContext } from "../../../contexts/GlobalContext.tsx"; import { SlidingRing } from "../../../helpers/slidingRing"; -import { getEMDataURL, getSegmentationURL } from "../../../models/models.ts"; +import { ViewerType, getEMDataURL, getSegmentationURL } from "../../../models/models.ts"; +import type { Workspace } from "../../../models/workspace.ts"; import type { Dataset } from "../../../rest/index.ts"; import SceneControls from "./SceneControls.tsx"; - -const getFeatureStyle = (feature: FeatureLike) => { - const opacity = 0.2; - const [r, g, b] = feature.get("color"); - const rgbaColor = `rgba(${r}, ${g}, ${b}, ${opacity})`; - - return new Style({ - stroke: new Stroke({ - color: [r, g, b], - width: 2, - }), - fill: new Fill({ - color: rgbaColor, - }), - }); -}; - -const resetStyle = (feature: Feature) => { - feature.setStyle(getFeatureStyle(feature)); -}; - -const setHighlightStyle = (feature: Feature) => { - const opacity = 0.5; - const [r, g, b] = feature.get("color"); - const rgbaColor = `rgba(${r}, ${g}, ${b}, ${opacity})`; - - const style = new Style({ - stroke: new Stroke({ - color: [r, g, b], - width: 4, - }), - fill: new Fill({ - color: rgbaColor, - }), - text: new Text({ - text: feature.get("name"), - scale: 2, - }), - }); - - feature.setStyle(style); -}; +import { activeNeuronStyle, neuronFeatureName, selectedNeuronStyle } from "./neuronsMapFeature.ts"; const newEMLayer = (dataset: Dataset, slice: number, tilegrid: TileGrid, projection: Projection): TileLayer => { return new TileLayer({ @@ -84,11 +41,72 @@ const newSegLayer = (dataset: Dataset, slice: number) => { url: getSegmentationURL(dataset, slice), format: new GeoJSON(), }), - style: getFeatureStyle, zIndex: 1, }); }; +function isNeuronActive(neuronId: string, workspace: Workspace): boolean { + const emViewerVisibleNeurons = workspace.getVisibleNeuronsInEM(); + return emViewerVisibleNeurons.includes(neuronId) || emViewerVisibleNeurons.includes(workspace.getNeuronClass(neuronId)); +} + +function isNeuronSelected(neuronId: string, workspace: Workspace): boolean { + return workspace.getSelection(ViewerType.EM).includes(neuronId); +} + +function isNeuronVisible(neuronId: string, workspace: Workspace): boolean { + return isNeuronActive(neuronId, workspace) || isNeuronSelected(neuronId, workspace); +} + +function neuronColor(neuronId, workspace: Workspace): string { + const neuronVisibilities = workspace.visibilities[neuronId] || workspace.visibilities[workspace.getNeuronClass(neuronId)]; + return neuronVisibilities?.[ViewerType.EM].color; +} + +function neuronsStyle(feature: FeatureLike, workspace: Workspace) { + const neuronName = neuronFeatureName(feature); + + const color = neuronColor(neuronName, workspace); + + if (isNeuronSelected(neuronName, workspace)) { + return selectedNeuronStyle(feature, color); + } + + if (isNeuronActive(neuronName, workspace)) { + return activeNeuronStyle(feature, color); + } + + return null; +} + +function onNeuronSelect(position: Coordinate, source: VectorSource | undefined, workspace: Workspace) { + const features = source?.getFeaturesAtCoordinate(position); + if (!features || features.length === 0) { + return; + } + + const feature = features[0]; + const neuronName = neuronFeatureName(feature); + + if (isNeuronSelected(neuronName, workspace)) { + workspace.removeSelection(neuronName, ViewerType.EM); + // Is there a neuron in the selection that comes from the same class. If not, we can remove the class from the selection + const removeClass = !workspace + .getSelection(ViewerType.ThreeD) + .some((e) => workspace.getNeuronClass(e) !== e && workspace.getNeuronClass(e) === workspace.getNeuronClass(neuronName)); + if (removeClass) { + workspace.removeSelection(workspace.getNeuronClass(neuronName), ViewerType.ThreeD); + } + return; + } + + if (!isNeuronVisible(neuronName, workspace)) { + return; + } + + workspace.addSelection(neuronName, ViewerType.EM); +} + const scale = new ScaleLine({ units: "metric", }); @@ -101,7 +119,6 @@ const interactions = defaultInteractions({ }), ]); -// const EMStackViewer = ({ dataset }: EMStackViewerParameters) => { const EMStackViewer = () => { const currentWorkspace = useGlobalContext().getCurrentWorkspace(); @@ -109,11 +126,11 @@ const EMStackViewer = () => { const firstActiveDataset = Object.values(currentWorkspace.activeDatasets)?.[0]; const [minSlice, maxSlice] = firstActiveDataset.emData.sliceRange; const startSlice = Math.floor((maxSlice + minSlice) / 2); + const [segSlice, segSetSlice] = useState(startSlice); const ringSize = 11; const mapRef = useRef(null); const currSegLayer = useRef | null>(null); - const clickedFeature = useRef(null); const ringEM = useRef>>(); const ringSeg = useRef>>(); @@ -153,6 +170,19 @@ const EMStackViewer = () => { // }), // }); + const neuronsStyleRef = useRef((feature) => neuronsStyle(feature, currentWorkspace)); + const onNeuronSelectRef = useRef((position) => onNeuronSelect(position, currSegLayer.current?.getSource(), currentWorkspace)); + + useEffect(() => { + if (!currSegLayer.current?.getSource()) { + return; + } + + neuronsStyleRef.current = (feature: Feature) => neuronsStyle(feature, currentWorkspace); + onNeuronSelectRef.current = (position) => onNeuronSelect(position, currSegLayer.current.getSource(), currentWorkspace); + currSegLayer.current.getSource().changed(); + }, [currentWorkspace.getVisibleNeuronsInEM(), currentWorkspace.visibilities, currentWorkspace.getSelection(ViewerType.EM), segSlice]); + useEffect(() => { if (mapRef.current) { return; @@ -199,12 +229,14 @@ const EMStackViewer = () => { onPush: (slice) => { const layer = newSegLayer(firstActiveDataset, slice); layer.setOpacity(0); + layer.setStyle((feature) => neuronsStyleRef.current(feature)); map.addLayer(layer); return layer; }, - onSelected: (_, layer) => { + onSelected: (slice, layer) => { layer.setOpacity(1); currSegLayer.current = layer; + segSetSlice(slice); }, onUnselected: (_, layer) => { layer.setOpacity(0); @@ -214,30 +246,9 @@ const EMStackViewer = () => { }, }); - map.on("click", (evt) => { - if (!currSegLayer.current) return; - - const features = currSegLayer.current.getSource().getFeaturesAtCoordinate(evt.coordinate); - if (features.length === 0) return; - - const feature = features[0]; - if (clickedFeature.current) { - resetStyle(clickedFeature.current); - } - - if (feature) { - setHighlightStyle(feature as Feature); - clickedFeature.current = feature as Feature; - console.log("Feature", feature.get("name"), feature); - } - }); - - map.getTargetElement().addEventListener("wheel", (e) => { - if (e.shiftKey) { - return; - } + map.on("click", (e) => onNeuronSelectRef.current(e.coordinate)); - e.preventDefault(); + function handleSliceScroll(e: WheelEvent) { const scrollUp = e.deltaY < 0; if (scrollUp) { @@ -247,6 +258,28 @@ const EMStackViewer = () => { ringEM.current.prev(); ringSeg.current.prev(); } + } + + function handleZoomScroll(e: WheelEvent) { + const scrollUp = e.deltaY < 0; + + const view = map.getView(); + const zoom = view.getZoom(); + + if (scrollUp) { + view.setZoom(view.getConstrainedZoom(zoom + 1, 1)); + } else { + view.setZoom(view.getConstrainedZoom(zoom - 1, -1)); + } + } + + map.getTargetElement().addEventListener("wheel", (e) => { + e.preventDefault(); + if (e.shiftKey) { + handleZoomScroll(e); + return; + } + handleSliceScroll(e); }); // set map zoom to the minimum zoom possible @@ -305,7 +338,7 @@ const EMStackViewer = () => { export default EMStackViewer; -function printEMView(map: OLMap) { +export function printEMView(map: OLMap) { const mapCanvas = document.createElement("canvas"); const size = map.getSize(); diff --git a/applications/visualizer/frontend/src/components/viewers/EM/neuronsMapFeature.ts b/applications/visualizer/frontend/src/components/viewers/EM/neuronsMapFeature.ts new file mode 100644 index 00000000..333a4c9c --- /dev/null +++ b/applications/visualizer/frontend/src/components/viewers/EM/neuronsMapFeature.ts @@ -0,0 +1,60 @@ +import type { FeatureLike } from "ol/Feature"; +import Fill from "ol/style/Fill"; +import Stroke from "ol/style/Stroke"; +import Style from "ol/style/Style"; +import Text from "ol/style/Text"; + +export function hexToRGBArray(hex: string): [number, number, number] { + hex = hex.replace("#", ""); + const r = Number.parseInt(hex.slice(0, 2), 16); + const g = Number.parseInt(hex.slice(2, 4), 16); + const b = Number.parseInt(hex.slice(4, 6), 16); + + return [r, g, b]; +} + +export function activeNeuronStyle(feature: FeatureLike, color?: string): Style { + const opacity = 0.2; + const [r, g, b] = color ? hexToRGBArray(color) : feature.get("color"); + const rgbaColor = `rgba(${r}, ${g}, ${b}, ${opacity})`; + + return new Style({ + stroke: new Stroke({ + color: [r, g, b], + width: 2, + }), + fill: new Fill({ + color: rgbaColor, + }), + }); +} + +export function selectedNeuronStyle(feature: FeatureLike, color?: string): Style { + const opacity = 0.5; + const [r, g, b] = color ? hexToRGBArray(color) : feature.get("color"); + const rgbaColor = `rgba(${r}, ${g}, ${b}, ${opacity})`; + + return new Style({ + stroke: new Stroke({ + color: [r, g, b], + width: 4, + }), + fill: new Fill({ + color: rgbaColor, + }), + text: new Text({ + text: feature.get("name"), + scale: 2, + }), + }); +} + +export function neuronFeatureName(feature: FeatureLike): string { + const neuronName = feature.getProperties()?.name; + + if (typeof neuronName !== "string") { + throw Error("neuron segment doesn't have a valid name property"); + } + + return neuronName; +} diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/STLMesh.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/STLMesh.tsx index e7093cdb..293db96d 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/STLMesh.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/STLMesh.tsx @@ -1,6 +1,6 @@ import { Outlines } from "@react-three/drei"; import type { ThreeEvent } from "@react-three/fiber"; -import type { FC } from "react"; +import { type FC, useCallback, useMemo } from "react"; import { useSelector } from "react-redux"; import { type BufferGeometry, DoubleSide, NormalBlending } from "three"; import { useGlobalContext } from "../../../contexts/GlobalContext"; @@ -23,20 +23,36 @@ const STLMesh: FC = ({ id, color, opacity, renderOrder, isWireframe, stl const { workspaces } = useGlobalContext(); const workspaceId = useSelector((state: RootState) => state.workspaceId); const workspace: Workspace = workspaces[workspaceId]; - const selectedNeurons = workspace.getViewerSelectedNeurons(ViewerType.Graph); - const isSelected = selectedNeurons.includes(id); - const onClick = (event: ThreeEvent) => { - const clicked = getFurthestIntersectedObject(event); - const { id } = clicked.userData; - if (clicked) { - if (isSelected) { - console.log(`Neurons selected: ${id}`); - } else { - console.log(`Neurons un selected: ${id}`); + const isSelected = useMemo(() => { + const selectedNeurons = workspace.getSelection(ViewerType.ThreeD); + return selectedNeurons.includes(id); + }, [workspace.getSelection(ViewerType.ThreeD)]); + + const onClick = useCallback( + (event: ThreeEvent) => { + const clicked = getFurthestIntersectedObject(event); + if (!clicked) { + return; + } + const { id } = clicked.userData; + if (clicked) { + if (isSelected) { + workspace.removeSelection(id, ViewerType.ThreeD); + // Is there a neuron in the selection that comes from the same class. If not, we can remove the class from the selection + const removeClass = !workspace + .getSelection(ViewerType.ThreeD) + .some((e) => workspace.getNeuronClass(e) !== e && workspace.getNeuronClass(e) === workspace.getNeuronClass(id)); + if (removeClass) { + workspace.removeSelection(workspace.getNeuronClass(id), ViewerType.ThreeD); + } + } else { + workspace.addSelection(id, ViewerType.ThreeD); + } } - } - }; + }, + [workspace], + ); return ( diff --git a/applications/visualizer/frontend/src/components/viewers/TwoD/ContextMenu.tsx b/applications/visualizer/frontend/src/components/viewers/TwoD/ContextMenu.tsx index a9481033..e31e4458 100644 --- a/applications/visualizer/frontend/src/components/viewers/TwoD/ContextMenu.tsx +++ b/applications/visualizer/frontend/src/components/viewers/TwoD/ContextMenu.tsx @@ -38,7 +38,7 @@ interface ContextMenuProps { const ContextMenu: React.FC = ({ open, onClose, position, setSplitJoinState, openGroups, setOpenGroups, cy }) => { const workspace = useSelectedWorkspace(); const [submenuAnchorEl, setSubmenuAnchorEl] = useState(null); - const selectedNeurons = workspace.getViewerSelectedNeurons(ViewerType.Graph); + const selectedNeurons = workspace.getSelection(ViewerType.Graph); const submenuOpen = Boolean(submenuAnchorEl); diff --git a/applications/visualizer/frontend/src/components/viewers/TwoD/TwoDViewer.tsx b/applications/visualizer/frontend/src/components/viewers/TwoD/TwoDViewer.tsx index 63a1afcd..17d690bf 100644 --- a/applications/visualizer/frontend/src/components/viewers/TwoD/TwoDViewer.tsx +++ b/applications/visualizer/frontend/src/components/viewers/TwoD/TwoDViewer.tsx @@ -70,7 +70,7 @@ const TwoDViewer = () => { unreportedNeurons: new Set(), }); - const selectedNeurons = workspace.getViewerSelectedNeurons(ViewerType.Graph); + const selectedNeurons = workspace.getSelection(ViewerType.Graph); const visibleActiveNeurons = useMemo(() => { return getVisibleActiveNeuronsIn2D(workspace); @@ -236,6 +236,19 @@ const TwoDViewer = () => { }; }, []); + useEffect(() => { + for (const node of cyRef.current.nodes()) { + const neuronId = node.id(); + const isSelected = selectedNeurons.includes(neuronId) || selectedNeurons.some((e) => workspace.getNeuronCellsByClass(neuronId).includes(e)); + + if (isSelected) { + node.addClass(SELECTED_CLASS); + } else { + node.removeClass(SELECTED_CLASS); + } + } + }, [selectedNeurons]); + // Add event listener for node clicks to toggle neuron selection and right-click context menu useEffect(() => { if (!cyRef.current) return; @@ -243,16 +256,28 @@ const TwoDViewer = () => { const cy = cyRef.current; const handleNodeClick = (event) => { - const neuronId = event.target.id(); + const node = event.target; + const neuronId = node.id(); const selectedNeurons = workspace.getSelection(ViewerType.Graph); - const isSelected = selectedNeurons.includes(neuronId); + const isSelected = selectedNeurons.includes(neuronId) || selectedNeurons.some((e) => workspace.getNeuronCellsByClass(neuronId).includes(e)); if (isSelected) { workspace.removeSelection(neuronId, ViewerType.Graph); - event.target.removeClass(SELECTED_CLASS); + + if (workspace.getNeuronClass(neuronId) === neuronId) { + const relatedNeurons = workspace.getNeuronCellsByClass(neuronId); + for (const neuron of relatedNeurons) { + workspace.locallyRemoveSelection(neuron, ViewerType.EM); + workspace.locallyRemoveSelection(neuron, ViewerType.ThreeD); + } + } } else { workspace.addSelection(neuronId, ViewerType.Graph); - event.target.addClass(SELECTED_CLASS); + const relatedNeurons = workspace.getNeuronCellsByClass(neuronId); + for (const neuron of relatedNeurons) { + workspace.locallyInjectSelection(neuron, ViewerType.EM); + workspace.locallyInjectSelection(neuron, ViewerType.ThreeD); + } } }; @@ -270,7 +295,7 @@ const TwoDViewer = () => { const cyEvent = event as any; // Cast to any to access originalEvent const originalEvent = cyEvent.originalEvent as MouseEvent; - const selectedNeurons = workspace.getViewerSelectedNeurons(ViewerType.Graph); + const selectedNeurons = workspace.getSelection(ViewerType.Graph); if (selectedNeurons.length > 0) { setMousePosition({ mouseX: originalEvent.clientX, diff --git a/applications/visualizer/frontend/src/helpers/twoD/graphRendering.ts b/applications/visualizer/frontend/src/helpers/twoD/graphRendering.ts index d80a2031..401ec3c6 100644 --- a/applications/visualizer/frontend/src/helpers/twoD/graphRendering.ts +++ b/applications/visualizer/frontend/src/helpers/twoD/graphRendering.ts @@ -27,7 +27,7 @@ export const computeGraphDifferences = ( includePostEmbryonic: boolean, ) => { const visibleActiveNeurons = getVisibleActiveNeuronsIn2D(workspace); - const selectedNeurons = workspace.getViewerSelectedNeurons(ViewerType.Graph); + const selectedNeurons = workspace.getSelection(ViewerType.Graph); // Current nodes and edges in the Cytoscape instance const currentNodes = new Set(cy.nodes().map((node) => node.id())); diff --git a/applications/visualizer/frontend/src/helpers/twoD/splitJoinHelper.ts b/applications/visualizer/frontend/src/helpers/twoD/splitJoinHelper.ts index aa7f4709..70d0d7a3 100644 --- a/applications/visualizer/frontend/src/helpers/twoD/splitJoinHelper.ts +++ b/applications/visualizer/frontend/src/helpers/twoD/splitJoinHelper.ts @@ -10,7 +10,7 @@ interface SplitJoinState { export const processNeuronSplit = (workspace: Workspace, splitJoinState: SplitJoinState): SplitJoinState => { const newSplit = new Set(splitJoinState.split); const newJoin = new Set(splitJoinState.join); - const selectedNeurons = workspace.getViewerSelectedNeurons(ViewerType.Graph); + const selectedNeurons = workspace.getSelection(ViewerType.Graph); const newSelectedNeurons = new Set(selectedNeurons); const graphViewDataUpdates: Record> = {}; @@ -83,7 +83,7 @@ export const processNeuronSplit = (workspace: Workspace, splitJoinState: SplitJo export const processNeuronJoin = (workspace: Workspace, splitJoinState: SplitJoinState): SplitJoinState => { const newJoin = new Set(splitJoinState.join); const newSplit = new Set(splitJoinState.split); - const selectedNeurons = workspace.getViewerSelectedNeurons(ViewerType.Graph); + const selectedNeurons = workspace.getSelection(ViewerType.Graph); const newSelectedNeurons = new Set(selectedNeurons); const graphViewDataUpdates: Record> = {}; diff --git a/applications/visualizer/frontend/src/models/models.ts b/applications/visualizer/frontend/src/models/models.ts index c3acf6e0..454e1298 100644 --- a/applications/visualizer/frontend/src/models/models.ts +++ b/applications/visualizer/frontend/src/models/models.ts @@ -36,12 +36,19 @@ export interface ThreeDViewerData { color: string; } -function getRandomColor(): string { - return `#${Math.floor(Math.random() * 16777215) +export interface EMViewerData { + visibility: Visibility; + color: string; +} + +function randomColor(): string { + return `#${Math.floor(Math.random() * 0xffffff) .toString(16) .padStart(6, "0")}`; } + export function getDefaultViewerData(visibility?: Visibility): ViewerData { + const color = randomColor(); return { [ViewerType.Graph]: { defaultPosition: null, @@ -49,7 +56,11 @@ export function getDefaultViewerData(visibility?: Visibility): ViewerData { }, [ViewerType.ThreeD]: { visibility: visibility ?? Visibility.Hidden, - color: getRandomColor(), + color, + }, + [ViewerType.EM]: { + visibility: visibility ?? Visibility.Hidden, + color, }, }; } @@ -57,7 +68,7 @@ export function getDefaultViewerData(visibility?: Visibility): ViewerData { export interface ViewerData { [ViewerType.Graph]?: GraphViewerData; [ViewerType.ThreeD]?: ThreeDViewerData; - [ViewerType.EM]?: any; // Define specific data for EM viewer if needed + [ViewerType.EM]?: EMViewerData; [ViewerType.InstanceDetails]?: any; // Define specific data for Instance Details viewer if needed } diff --git a/applications/visualizer/frontend/src/models/synchronizer.ts b/applications/visualizer/frontend/src/models/synchronizer.ts index dae62a8b..51b0e34d 100644 --- a/applications/visualizer/frontend/src/models/synchronizer.ts +++ b/applications/visualizer/frontend/src/models/synchronizer.ts @@ -28,30 +28,37 @@ class Synchronizer { return new Synchronizer(active, pair); } - private canHandle(viewer: ViewerType) { + public canHandle(viewer: ViewerType) { return this.viewers.includes(viewer); } - sync(selection: Array, initiator: ViewerType, contexts: Record) { - if (!this.canHandle(initiator)) { - return; + public firstViewer(): ViewerType { + return this.viewers[0]; + } + + public secondViewer(): ViewerType { + return this.viewers[1]; + } + + public otherViewer(viewer: ViewerType): ViewerType { + if (this.viewers[0] === viewer) { + return this.viewers[1]; } + return this.viewers[0]; + } + sync(selection: Array, initiator: ViewerType, contexts: Record): ViewerType { if (!this.active) { - contexts[initiator] = selection.map((n) => n); + contexts[initiator] = [...selection]; return; } for (const viewer of this.viewers) { - contexts[viewer] = selection.map((n) => n); + contexts[viewer] = [...selection]; } } - select(selection: string, initiator: ViewerType, contexts: Record) { - if (!this.canHandle(initiator)) { - return; - } - + select(selection: string, initiator: ViewerType, contexts: Record): ViewerType { if (!this.active) { contexts[initiator] = [...new Set([...contexts[initiator], selection])]; return; @@ -61,11 +68,8 @@ class Synchronizer { contexts[viewer] = [...new Set([...contexts[viewer], selection])]; } } - unSelect(selection: string, initiator: ViewerType, contexts: Record) { - if (!this.canHandle(initiator)) { - return; - } + unSelect(selection: string, initiator: ViewerType, contexts: Record): ViewerType { if (!this.active) { const storedNodes = [...contexts[initiator]]; contexts[initiator] = storedNodes.filter((n) => n !== selection); @@ -79,10 +83,6 @@ class Synchronizer { } clear(initiator: ViewerType, contexts: Record) { - if (!this.canHandle(initiator)) { - return; - } - if (!this.active) { contexts[initiator] = []; return; @@ -128,26 +128,68 @@ export class SynchronizerOrchestrator { return new SynchronizerOrchestrator(synchronizers, contexts); } - public select(selection: Array, initiator: ViewerType) { + private getConnectedViewers(initiator: ViewerType): Set { + const synchros = new Set(); + for (const synchronizer of this.synchronizers) { + if (synchronizer.canHandle(initiator)) { + synchros.add(synchronizer); + const otherViewer = synchronizer.otherViewer(initiator); + for (const connectedSynchronizer of this.synchronizers) { + if (connectedSynchronizer === synchronizer) { + continue; + } + if (connectedSynchronizer.canHandle(otherViewer) && connectedSynchronizer.active) { + synchros.add(connectedSynchronizer); + } + } + } + } + return synchros; + } + + public select(selection: Array, initiator: ViewerType) { + const synchronizers = this.getConnectedViewers(initiator); + for (const synchronizer of synchronizers) { synchronizer.sync(selection, initiator, this.contexts); } } + public locallyInjectSelection(selection: string, target: ViewerType) { + const synchronizers = this.getConnectedViewers(target); + if ([...synchronizers].some((sync) => sync.canHandle(target) && sync.active)) { + this.contexts[target].push(selection); + } + } + + public locallyRemoveSelection(selection: string, target: ViewerType) { + const synchronizers = this.getConnectedViewers(target); + if ([...synchronizers].some((sync) => sync.canHandle(target) && sync.active)) { + const selected = this.contexts[target]; + const index = selected.indexOf(selection); + if (index > -1) { + selected.splice(index, 1); + } + } + } + public selectNeuron(selection: string, initiator: ViewerType) { - for (const synchronizer of this.synchronizers) { + const synchronizers = this.getConnectedViewers(initiator); + for (const synchronizer of synchronizers) { synchronizer.select(selection, initiator, this.contexts); } } public unSelectNeuron(selection: string, initiator: ViewerType) { - for (const synchronizer of this.synchronizers) { + const synchronizers = this.getConnectedViewers(initiator); + for (const synchronizer of synchronizers) { synchronizer.unSelect(selection, initiator, this.contexts); } } public clearSelection(initiator: ViewerType) { - for (const synchronizer of this.synchronizers) { + const synchronizers = this.getConnectedViewers(initiator); + for (const synchronizer of synchronizers) { synchronizer.clear(initiator, this.contexts); } } @@ -156,7 +198,19 @@ export class SynchronizerOrchestrator { return this.contexts[viewerType]; } public setActive(synchronizer: ViewerSynchronizationPair, isActive: boolean) { - this.synchronizers[synchronizer].setActive(isActive); + const synchros = this.synchronizers[synchronizer]; + synchros.setActive(isActive); + + if (isActive) { + // When we merge the selections + const selection = new Set( + this.synchronizers + .filter((s) => s.canHandle(synchros.firstViewer()) || s.canHandle(synchros.secondViewer())) + .flatMap((s) => [...this.getSelection(s.firstViewer()), ...this.getSelection(s.secondViewer())]), + ); + + this.select([...selection], synchros.firstViewer()); + } } public isActive(synchronizer: ViewerSynchronizationPair) { @@ -164,7 +218,7 @@ export class SynchronizerOrchestrator { } public switchSynchronizer(syncPair: ViewerSynchronizationPair) { - const synchronizer = this.synchronizers[syncPair]; - synchronizer.setActive(!synchronizer.active); + const active = this.synchronizers[syncPair].active; + this.setActive(syncPair, !active); } } diff --git a/applications/visualizer/frontend/src/models/workspace.ts b/applications/visualizer/frontend/src/models/workspace.ts index ad493c6d..3d9f1b91 100644 --- a/applications/visualizer/frontend/src/models/workspace.ts +++ b/applications/visualizer/frontend/src/models/workspace.ts @@ -116,6 +116,7 @@ export class Workspace { // todo: add actions for other viewers this.visibilities[neuronId][ViewerType.Graph].visibility = Visibility.Hidden; this.visibilities[neuronId][ViewerType.ThreeD].visibility = Visibility.Hidden; + this.visibilities[neuronId][ViewerType.EM].visibility = Visibility.Hidden; } @triggerUpdate @@ -126,6 +127,7 @@ export class Workspace { // todo: add actions for other viewers this.visibilities[neuronId][ViewerType.Graph].visibility = Visibility.Visible; this.visibilities[neuronId][ViewerType.ThreeD].visibility = Visibility.Visible; + this.visibilities[neuronId][ViewerType.EM].visibility = Visibility.Visible; } @triggerUpdate @@ -218,6 +220,16 @@ export class Workspace { this.updateContext(updated); } + @triggerUpdate + locallyInjectSelection(selection: string, target: ViewerType) { + this.syncOrchestrator.locallyInjectSelection(selection, target); + } + + @triggerUpdate + locallyRemoveSelection(selection: string, target: ViewerType) { + this.syncOrchestrator.locallyRemoveSelection(selection, target); + } + @triggerUpdate setSelection(selection: Array, initiator: ViewerType) { this.syncOrchestrator.select(selection, initiator); @@ -243,10 +255,6 @@ export class Workspace { return this.syncOrchestrator.getSelection(viewerType); } - getViewerSelectedNeurons(viewerType: ViewerType): string[] { - return this.syncOrchestrator.getSelection(viewerType); - } - getNeuronCellsByClass(neuronClassId: string): string[] { return Object.values(this.availableNeurons) .filter((neuron) => neuron.nclass === neuronClassId && neuron.nclass !== neuron.name) @@ -255,13 +263,17 @@ export class Workspace { getNeuronClass(neuronId: string): string { const neuron = this.availableNeurons[neuronId]; - return neuron.nclass; + return neuron?.nclass; } getVisibleNeuronsInThreeD(): string[] { return Array.from(this.activeNeurons).filter((neuronId) => this.visibilities[neuronId]?.[ViewerType.ThreeD]?.visibility === Visibility.Visible); } + getVisibleNeuronsInEM(): string[] { + return Array.from(this.activeNeurons).filter((neuronId) => this.visibilities[neuronId]?.[ViewerType.EM]?.visibility === Visibility.Visible); + } + changeNeuronColorForViewers(neuronId: string, color: string): void { const viewers: ViewerType[] = [ViewerType.ThreeD, ViewerType.EM]; diff --git a/applications/visualizer/frontend/src/settings/threeDSettings.ts b/applications/visualizer/frontend/src/settings/threeDSettings.ts index a9986af2..8beeb396 100644 --- a/applications/visualizer/frontend/src/settings/threeDSettings.ts +++ b/applications/visualizer/frontend/src/settings/threeDSettings.ts @@ -5,12 +5,12 @@ export const CAMERA_POSITION = new Vector3(0, 0, 50); export const CAMERA_NEAR = 0.1; export const CAMERA_FAR = 2000; -export const LIGHT_1_COLOR = "0x404040"; -export const LIGHT_2_COLOR = "0xccccff"; +export const LIGHT_1_COLOR = 0xffffff; +export const LIGHT_2_COLOR = 0xccccff; export const LIGHT_2_POSITION = new Vector3(-1, 0.75, -0.5); export const LIGHT_SCENE_BACKGROUND = "#f6f5f4"; export const DARK_SCENE_BACKGROUND = "#21201c"; -export const OUTLINE_THICKNESS = 0.05; +export const OUTLINE_THICKNESS = 5; export const OUTLINE_COLOR = "hotpink";