diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index 62ac887e8..236d2916d 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -43,7 +43,7 @@ import StardustSearch from "./routes/stardust/Search"; import StardustStatisticsPage from "./routes/stardust/statistics/StatisticsPage"; import StardustTransactionPage from "./routes/stardust/TransactionPage"; import { Visualizer as StardustVisualizer } from "./routes/stardust/Visualizer"; -import NovaVisualizer from "../features/visualizer-threejs/VisualizerInstance"; +import NovaVisualizer from "../features/visualizer-threejs/NovaVisualizer"; import StreamsV0 from "./routes/StreamsV0"; import { StreamsV0RouteProps } from "./routes/StreamsV0RouteProps"; import { VisualizerRouteProps } from "./routes/VisualizerRouteProps"; diff --git a/client/src/features/visualizer-threejs/NovaVisualizer.tsx b/client/src/features/visualizer-threejs/NovaVisualizer.tsx new file mode 100644 index 000000000..c3c0815e3 --- /dev/null +++ b/client/src/features/visualizer-threejs/NovaVisualizer.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { useGetThemeMode } from "~/helpers/hooks/useGetThemeMode.js"; +import { VisualizerRouteProps } from "~/app/routes/VisualizerRouteProps.js"; +import { RouteComponentProps } from "react-router-dom"; +import VisualizerInstance from "./VisualizerInstance"; + +export default function NovaVisualizer(props: RouteComponentProps): React.JSX.Element { + const theme = useGetThemeMode(); + + return ; +} diff --git a/client/src/features/visualizer-threejs/VisualizerInstance.tsx b/client/src/features/visualizer-threejs/VisualizerInstance.tsx index 4ff59e4b3..b61688bee 100644 --- a/client/src/features/visualizer-threejs/VisualizerInstance.tsx +++ b/client/src/features/visualizer-threejs/VisualizerInstance.tsx @@ -5,15 +5,7 @@ import { Perf } from "r3f-perf"; import React, { useEffect, useRef } from "react"; import { RouteComponentProps } from "react-router-dom"; import * as THREE from "three"; -import { - FAR_PLANE, - features, - NEAR_PLANE, - DIRECTIONAL_LIGHT_INTENSITY, - PENDING_BLOCK_COLOR, - VISUALIZER_BACKGROUND, - BLOCK_STATE_TO_COLOR, -} from "./constants"; +import { FAR_PLANE, features, NEAR_PLANE, DIRECTIONAL_LIGHT_INTENSITY, VISUALIZER_BACKGROUND } from "./constants"; import Emitter from "./Emitter"; import { useTangleStore, useConfigStore } from "./store"; import { BPSCounter } from "./BPSCounter"; @@ -30,7 +22,7 @@ import { IFeedBlockData } from "~/models/api/nova/feed/IFeedBlockData"; import CameraControls from "./CameraControls"; import useVisualizerTimer from "~/helpers/nova/hooks/useVisualizerTimer"; import { getBlockInitPosition, getBlockTargetPosition } from "./blockPositions"; -import { getCurrentTiltValue } from "./utils"; +import { getBlockColorByState, getCurrentTiltValue } from "./utils"; import useSearchStore from "~features/visualizer-threejs/store/search"; import { useSearch } from "~features/visualizer-threejs/hooks/useSearch"; import "./Visualizer.scss"; @@ -69,6 +61,9 @@ const VisualizerInstance: React.FC> = const clickedInstanceId = useTangleStore((s) => s.clickedInstanceId); const matchingBlockIds = useSearchStore((state) => state.matchingBlockIds); + const blockIdToState = useTangleStore((s) => s.blockIdToState); + const setBlockIdToBlockState = useTangleStore((s) => s.setBlockIdToBlockState); + // Confirmed or accepted blocks by slot const confirmedBlocksBySlot = useTangleStore((s) => s.confirmedBlocksBySlot); const addToConfirmedBlocksSlot = useTangleStore((s) => s.addToConfirmedBlocksBySlot); @@ -219,7 +214,8 @@ const VisualizerInstance: React.FC> = bpsCounter.start(); } - blockMetadata.set(blockData.blockId, { ...blockData, treeColor: PENDING_BLOCK_COLOR }); + const blockColor = getBlockColorByState(themeMode, "pending"); + blockMetadata.set(blockData.blockId, { ...blockData, treeColor: blockColor }); // edges const blockStrongParents = (blockData.block.body as BasicBlockBody).strongParents ?? []; @@ -235,9 +231,11 @@ const VisualizerInstance: React.FC> = highlightSearchBlock(blockData.blockId); } + setBlockIdToBlockState(blockData.blockId, "pending"); + addBlock({ id: blockData.blockId, - color: PENDING_BLOCK_COLOR, + color: blockColor, blockAddedTimestamp: currentAnimationTime, targetPosition, initPosition, @@ -247,22 +245,29 @@ const VisualizerInstance: React.FC> = function onBlockMetadataUpdate(metadataUpdate: IBlockMetadata): void { if (metadataUpdate?.blockState) { - const selectedColor = BLOCK_STATE_TO_COLOR.get(metadataUpdate.blockState); + const selectedColor = getBlockColorByState(themeMode, metadataUpdate.blockState); if (selectedColor) { const currentBlockMetadata = blockMetadata.get(metadataUpdate.blockId); if (currentBlockMetadata) { currentBlockMetadata.treeColor = selectedColor; } - if (!matchingBlockIds.includes(metadataUpdate.blockId)) { - addToColorQueue(metadataUpdate.blockId, selectedColor); + + const previousBlockState = blockIdToState.get(metadataUpdate.blockId); + const wasConfirmedBeforeAccepted = previousBlockState === "accepted" && metadataUpdate.blockState === "confirmed"; + + if (!wasConfirmedBeforeAccepted) { + setBlockIdToBlockState(metadataUpdate.blockId, metadataUpdate.blockState); + if (!matchingBlockIds.includes(metadataUpdate.blockId)) { + addToColorQueue(metadataUpdate.blockId, selectedColor); + } } - } - const acceptedStates: BlockState[] = ["confirmed", "accepted"]; + const acceptedStates: BlockState[] = ["confirmed", "accepted"]; - if (acceptedStates.includes(metadataUpdate.blockState)) { - const slot = Utils.computeSlotIndex(metadataUpdate.blockId); - addToConfirmedBlocksSlot(metadataUpdate.blockId, slot); + if (acceptedStates.includes(metadataUpdate.blockState)) { + const slot = Utils.computeSlotIndex(metadataUpdate.blockId); + addToConfirmedBlocksSlot(metadataUpdate.blockId, slot); + } } } } @@ -272,9 +277,10 @@ const VisualizerInstance: React.FC> = if (blocks?.length) { blocks.forEach((blockId) => { - const selectedColor = BLOCK_STATE_TO_COLOR.get("finalized"); + const selectedColor = getBlockColorByState(themeMode, "finalized"); if (selectedColor) { addToColorQueue(blockId, selectedColor); + setBlockIdToBlockState(blockId, "finalized"); } }); } @@ -294,6 +300,7 @@ const VisualizerInstance: React.FC> = setIsPlaying={setIsPlaying} isEdgeRenderingEnabled={isEdgeRenderingEnabled} setEdgeRenderingEnabled={(checked) => setEdgeRenderingEnabled(checked)} + themeMode={themeMode} > ([ - ["pending", PENDING_BLOCK_COLOR], - ["accepted", ACCEPTED_BLOCK_COLOR], - ["confirmed", CONFIRMED_BLOCK_COLOR], - ["finalized", FINALIZED_BLOCK_COLOR], -]); +// colors by theme +export const PENDING_BLOCK_COLOR_LIGHTMODE = new Color("#A6C3FC"); +export const PENDING_BLOCK_COLOR_DARKMODE = new Color("#5C84FA"); +export const FINALIZED_BLOCK_COLOR_LIGHTMODE = new Color("#5C84FA"); +export const FINALIZED_BLOCK_COLOR_DARKMODE = new Color("#000081"); + +export const THEME_BLOCK_COLORS: Record> = { + [ThemeMode.Dark]: { + accepted: ACCEPTED_BLOCK_COLORS, + pending: PENDING_BLOCK_COLOR_DARKMODE, + confirmed: CONFIRMED_BLOCK_COLOR, + finalized: FINALIZED_BLOCK_COLOR_DARKMODE, + rejected: REJECTED_BLOCK_COLOR, + failed: FAILED_BLOCK_COLOR, + }, + [ThemeMode.Light]: { + accepted: ACCEPTED_BLOCK_COLORS, + pending: PENDING_BLOCK_COLOR_LIGHTMODE, + confirmed: CONFIRMED_BLOCK_COLOR, + finalized: FINALIZED_BLOCK_COLOR_LIGHTMODE, + rejected: REJECTED_BLOCK_COLOR, + failed: FAILED_BLOCK_COLOR, + }, +}; // emitter export const EMITTER_SPEED_MULTIPLIER = 150; @@ -64,7 +82,7 @@ export const DIRECTIONAL_LIGHT_INTENSITY = 0.45; export const VISUALIZER_BACKGROUND: Record = { [ThemeMode.Dark]: "#000000", - [ThemeMode.Light]: "#f2f2f2", + [ThemeMode.Light]: "#FFFFFF", }; // emitter diff --git a/client/src/features/visualizer-threejs/store/tangle.ts b/client/src/features/visualizer-threejs/store/tangle.ts index db8159c78..d8b2f3435 100644 --- a/client/src/features/visualizer-threejs/store/tangle.ts +++ b/client/src/features/visualizer-threejs/store/tangle.ts @@ -4,7 +4,7 @@ import { devtools } from "zustand/middleware"; import { ZOOM_DEFAULT, SPRAY_DISTANCE } from "../constants"; import { IFeedBlockData } from "~models/api/nova/feed/IFeedBlockData"; import { IThreeDimensionalPosition } from "../interfaces"; -import { BlockId, SlotIndex } from "@iota/sdk-wasm-nova/web"; +import { BlockId, BlockState, SlotIndex } from "@iota/sdk-wasm-nova/web"; import { getVisualizerConfigValues } from "~features/visualizer-threejs/ConfigControls"; export interface IBlockAnimationPosition { @@ -80,6 +80,9 @@ interface TangleState { confirmedBlocksBySlot: Map; addToConfirmedBlocksBySlot: (blockId: BlockId, slot: SlotIndex) => void; removeConfirmedBlocksSlot: (slot: SlotIndex) => void; + + blockIdToState: Map; + setBlockIdToBlockState: (blockId: BlockId, blockState: BlockState) => void; } const INITIAL_STATE = { @@ -97,12 +100,32 @@ const INITIAL_STATE = { bps: 0, clickedInstanceId: null, confirmedBlocksBySlot: new Map(), + blockIdToState: new Map(), }; export const useTangleStore = create()( devtools((set) => ({ ...INITIAL_STATE, - resetConfigState: () => set(INITIAL_STATE), + resetConfigState: () => + // hard cleanup of the store + set((state) => { + state.blockQueue = []; + state.edgeQueue = []; + state.colorQueue = []; + state.blockIdToEdges = new Map(); + state.blockIdToIndex = new Map(); + state.blockIdToPosition = new Map(); + state.blockMetadata = new Map(); + state.blockIdToAnimationPosition = new Map(); + state.indexToBlockId = []; + state.zoom = ZOOM_DEFAULT; + state.forcedZoom = undefined; + state.bps = 0; + state.clickedInstanceId = null; + state.confirmedBlocksBySlot = new Map(); + state.blockIdToState = new Map(); + return state; + }), updateBlockIdToAnimationPosition: (updatedPositions) => { set((state) => { updatedPositions.forEach((value, key) => { @@ -196,15 +219,18 @@ export const useTangleStore = create()( updateBlockIdToIndex: (blockId: string, index: number) => { set((state) => { state.blockIdToIndex.set(blockId, index); - if (state.indexToBlockId[index]) { + const previousBlockId = state.indexToBlockId[index]; + if (previousBlockId) { // Clean up map from old blockIds - state.blockIdToIndex.delete(state.indexToBlockId[index]); + state.blockIdToIndex.delete(previousBlockId); // Clean up old block edges - state.blockIdToEdges.delete(state.indexToBlockId[index]); + state.blockIdToEdges.delete(previousBlockId); // Clean up old block position - state.blockIdToPosition.delete(state.indexToBlockId[index]); + state.blockIdToPosition.delete(previousBlockId); // Clean up old block metadata - state.blockMetadata.delete(state.indexToBlockId[index]); + state.blockMetadata.delete(previousBlockId); + // Cleanup old block state + state.blockIdToState.delete(previousBlockId); } const nextIndexToBlockId = [...state.indexToBlockId]; @@ -268,5 +294,14 @@ export const useTangleStore = create()( }; }); }, + setBlockIdToBlockState(blockId, blockState) { + set((state) => { + state.blockIdToState.set(blockId, blockState); + return { + ...state, + blockIdToState: state.blockIdToState, + }; + }); + }, })), ); diff --git a/client/src/features/visualizer-threejs/useRenderTangle.tsx b/client/src/features/visualizer-threejs/useRenderTangle.tsx index a9d5c107e..4c941ac1f 100644 --- a/client/src/features/visualizer-threejs/useRenderTangle.tsx +++ b/client/src/features/visualizer-threejs/useRenderTangle.tsx @@ -32,6 +32,8 @@ export const useRenderTangle = () => { const blockIdToAnimationPosition = useTangleStore((s) => s.blockIdToAnimationPosition); const updateBlockIdToAnimationPosition = useTangleStore((s) => s.updateBlockIdToAnimationPosition); + const resetTangleStore = useTangleStore((s) => s.resetConfigState); + const getVisualizerTimeDiff = useVisualizerTimer(); const assignBlockToMesh = (block: IBlockState) => { @@ -197,4 +199,14 @@ export const useRenderTangle = () => { setUpdateAnimationPositionQueue(updateAnimationPositionQueue); } }, [isPlaying, updateAnimationPositionQueue]); + + /** + * Cleanup scene + */ + useEffect(() => { + return () => { + scene.remove(tangleMeshRef.current); + resetTangleStore(); + }; + }, [scene, tangleMeshRef]); }; diff --git a/client/src/features/visualizer-threejs/utils.ts b/client/src/features/visualizer-threejs/utils.ts index e687a51e5..30528b929 100644 --- a/client/src/features/visualizer-threejs/utils.ts +++ b/client/src/features/visualizer-threejs/utils.ts @@ -17,9 +17,12 @@ import { MAX_PREV_POINTS, MAX_POINT_RETRIES, MIN_BLOCK_NEAR_RADIUS, + THEME_BLOCK_COLORS, } from "./constants"; import type { ICameraAngles, ISinusoidalPositionParams, IThreeDimensionalPosition, ITwoDimensionalPosition } from "./interfaces"; import { getVisualizerConfigValues } from "~features/visualizer-threejs/ConfigControls"; +import { BlockState } from "@iota/sdk-wasm-nova/web"; +import { ThemeMode } from "./enums"; /** * Generates a random number within a specified range. @@ -384,3 +387,14 @@ export function getCurrentTiltValue(animationTime: number, tilts: number[]): num return currentTilt; } + +export function getBlockColorByState(theme: ThemeMode, blockState: BlockState): THREE.Color { + const targetColor = THEME_BLOCK_COLORS[theme][blockState]; + + if (Array.isArray(targetColor)) { + const index = randomIntFromInterval(0, targetColor.length - 1); + return targetColor[index]; + } + + return targetColor; +} diff --git a/client/src/features/visualizer-threejs/wrapper/ColorPanel.tsx b/client/src/features/visualizer-threejs/wrapper/ColorPanel.tsx new file mode 100644 index 000000000..0d288fb9c --- /dev/null +++ b/client/src/features/visualizer-threejs/wrapper/ColorPanel.tsx @@ -0,0 +1,29 @@ +import React, { memo } from "react"; + +const ColorPanel = ({ label, color }: { label: string; color: string | string[] }): React.JSX.Element => ( +
+ {Array.isArray(color) ? ( +
+ {color.map((c, index) => ( +
+ ))} +
+ ) : ( +
+ )} +
{label}
+
+); + +export default memo(ColorPanel); diff --git a/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss b/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss index 5e4521164..1c5a8c479 100644 --- a/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss +++ b/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss @@ -43,6 +43,14 @@ border-radius: 50%; } + .key-panel-item-multi-color { + display: flex; + flex-direction: row; + .key-marker:not(:last-of-type) { + margin-right: 4px; + } + } + .key-label { @include font-size(14px); diff --git a/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx b/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx index 4aaa63e5a..a95863ef4 100644 --- a/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx +++ b/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx @@ -1,67 +1,59 @@ import React, { memo } from "react"; import { BlockState } from "@iota/sdk-wasm-nova/web"; -import { ACCEPTED_BLOCK_COLOR, CONFIRMED_BLOCK_COLOR, FINALIZED_BLOCK_COLOR, PENDING_BLOCK_COLOR, SEARCH_RESULT_COLOR } from "../constants"; -import "./KeyPanel.scss"; +import { SEARCH_RESULT_COLOR, THEME_BLOCK_COLORS } from "../constants"; import StatsPanel from "~features/visualizer-threejs/wrapper/StatsPanel"; +import { ThemeMode } from "../enums"; +import ColorPanel from "./ColorPanel"; +import "./KeyPanel.scss"; -export const KeyPanel = ({ network }: { network: string }) => { +export const KeyPanel = ({ network, themeMode }: { network: string; themeMode: ThemeMode }) => { const statuses: { label: string; state: BlockState | "searchResult"; - color: string; }[] = [ { label: "Pending", state: "pending", - color: PENDING_BLOCK_COLOR.getStyle(), }, { label: "Accepted", state: "accepted", - color: ACCEPTED_BLOCK_COLOR.getStyle(), }, { label: "Confirmed", state: "confirmed", - color: CONFIRMED_BLOCK_COLOR.getStyle(), }, { label: "Finalized", state: "finalized", - color: FINALIZED_BLOCK_COLOR.getStyle(), }, { label: "Rejected", state: "rejected", - color: "#252525", }, { label: "Failed", state: "failed", - color: "#ff1d38", }, { label: "Search result", state: "searchResult", - color: SEARCH_RESULT_COLOR.getStyle(), }, ]; return (
- {statuses.map((s) => { - return ( -
-
-
{s.label}
-
- ); + {statuses.map(({ label, state }) => { + if (state === "searchResult") { + return ; + } else { + const targetColor = THEME_BLOCK_COLORS[themeMode][state]; + if (Array.isArray(targetColor)) { + return color.getStyle())} />; + } + return ; + } })}
diff --git a/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx b/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx index 5ab8ef71e..41f81e203 100644 --- a/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx +++ b/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx @@ -10,6 +10,7 @@ import useSearchStore from "~features/visualizer-threejs/store/search"; import { useTangleStore } from "~features/visualizer-threejs/store/tangle"; import { SEARCH_RESULT_COLOR, features } from "~features/visualizer-threejs/constants"; import { isSearchMatch } from "~features/visualizer-threejs/hooks/useSearch"; +import { ThemeMode } from "../enums"; export const Wrapper = ({ blocksCount, @@ -21,6 +22,7 @@ export const Wrapper = ({ setIsPlaying, isEdgeRenderingEnabled, setEdgeRenderingEnabled, + themeMode, }: { readonly blocksCount: number; readonly children: React.ReactNode; @@ -32,6 +34,7 @@ export const Wrapper = ({ readonly setIsPlaying: (isPlaying: boolean) => void; readonly isEdgeRenderingEnabled?: boolean; readonly setEdgeRenderingEnabled?: (isEnabled: boolean) => void; + readonly themeMode: ThemeMode; }) => { const searchQuery = useSearchStore((state) => state.searchQuery); const setSearchQuery = useSearchStore((state) => state.setSearchQuery); @@ -123,7 +126,7 @@ export const Wrapper = ({ {selectedFeedItem && ( )} - +