{}} ref={graphElement} />
+
+ );
+};
+
+export default VisualizerInstance;
diff --git a/client/src/features/visualizer-threejs/wrapper/ColorPanel.tsx b/client/src/features/visualizer-vivagraph/components/ColorPanel.tsx
similarity index 100%
rename from client/src/features/visualizer-threejs/wrapper/ColorPanel.tsx
rename to client/src/features/visualizer-vivagraph/components/ColorPanel.tsx
diff --git a/client/src/features/visualizer-vivagraph/components/KeyPanel.scss b/client/src/features/visualizer-vivagraph/components/KeyPanel.scss
new file mode 100644
index 000000000..1c5a8c479
--- /dev/null
+++ b/client/src/features/visualizer-vivagraph/components/KeyPanel.scss
@@ -0,0 +1,120 @@
+@import "../../../scss/fonts";
+@import "../../../scss/mixins";
+@import "../../../scss/media-queries";
+@import "../../../scss/variables";
+@import "../../../scss/themes";
+
+.info-container {
+ display: flex;
+ position: absolute;
+ z-index: 1;
+ right: 30px;
+ bottom: 10px;
+ left: 30px;
+ justify-content: center;
+ pointer-events: none;
+ gap: 20px;
+
+ .card {
+ background: var(--body-background);
+ padding: 16px 32px;
+ }
+
+ .key-panel-list {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 32px;
+
+ .key-panel-item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ @include desktop-down {
+ width: 110px;
+ margin: 0;
+ }
+
+ .key-marker {
+ width: 12px;
+ height: 12px;
+ margin-right: 10px;
+ 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);
+
+ color: var(--body-color);
+ font-family: $metropolis;
+ font-weight: 500;
+ }
+ }
+ }
+
+ .stats-panel-container {
+ display: flex;
+ z-index: 1;
+ align-items: center;
+ pointer-events: none;
+
+ .card--label {
+ justify-content: flex-start;
+ }
+ .card--content {
+ padding: 0;
+ }
+ .stats-panel__info {
+ justify-content: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ @include tablet-down {
+ top: 60px;
+ left: 20px;
+ bottom: auto;
+ justify-content: left;
+
+ .stats-panel {
+ .card--value,
+ .card--label {
+ text-align: left;
+ }
+ .card--label {
+ justify-content: flex-start;
+ }
+ .card--content {
+ padding: 0;
+ }
+ .stats-panel__info {
+ padding: 0 10px;
+ display: inline-block;
+ }
+ }
+ }
+
+ @include phone-down {
+ left: 10px;
+ .stats-panel {
+ .card--value,
+ .card--label {
+ font-size: 12px;
+ }
+ .stats-panel__info:last-of-type {
+ display: block;
+ }
+ }
+ }
+ }
+}
diff --git a/client/src/features/visualizer-vivagraph/components/KeyPanel.tsx b/client/src/features/visualizer-vivagraph/components/KeyPanel.tsx
new file mode 100644
index 000000000..346217c1b
--- /dev/null
+++ b/client/src/features/visualizer-vivagraph/components/KeyPanel.tsx
@@ -0,0 +1,64 @@
+import React, { memo } from "react";
+import { BlockState } from "@iota/sdk-wasm-nova/web";
+import StatsPanel from "~features/visualizer-threejs/wrapper/StatsPanel";
+import { SEARCH_RESULT_COLOR, THEME_BLOCK_COLORS } from "../definitions/constants";
+import { ThemeMode } from "../definitions/enums";
+import ColorPanel from "./ColorPanel";
+import "./KeyPanel.scss";
+
+export const KeyPanel = ({ network, themeMode }: { network: string; themeMode: ThemeMode }) => {
+ const statuses: {
+ label: string;
+ state: BlockState | "searchResult";
+ }[] = [
+ {
+ label: "Pending",
+ state: "pending",
+ },
+ {
+ label: "Accepted",
+ state: "accepted",
+ },
+ {
+ label: "Confirmed",
+ state: "confirmed",
+ },
+ {
+ label: "Finalized",
+ state: "finalized",
+ },
+ {
+ label: "Dropped",
+ state: "dropped",
+ },
+ {
+ label: "Orphaned",
+ state: "orphaned",
+ },
+ {
+ label: "Search result",
+ state: "searchResult",
+ },
+ ];
+
+ return (
+
+
+ {statuses.map(({ label, state }) => {
+ if (state === "searchResult") {
+ return ;
+ } else {
+ const targetColor = THEME_BLOCK_COLORS[themeMode][state];
+ if (Array.isArray(targetColor)) {
+ return color)} />;
+ }
+ return ;
+ }
+ })}
+
+
+
+ );
+};
+
+export default memo(KeyPanel);
diff --git a/client/src/features/visualizer-vivagraph/components/SelectedFeedInfo.tsx b/client/src/features/visualizer-vivagraph/components/SelectedFeedInfo.tsx
new file mode 100644
index 000000000..6281414c8
--- /dev/null
+++ b/client/src/features/visualizer-vivagraph/components/SelectedFeedInfo.tsx
@@ -0,0 +1,53 @@
+import React, { useCallback } from "react";
+import { Link } from "react-router-dom";
+import BlockTangleState from "~/app/components/nova/block/BlockTangleState";
+import TruncatedId from "~/app/components/stardust/TruncatedId";
+import CloseIcon from "~assets/close.svg?react";
+import { INetwork } from "~models/config/INetwork";
+import { useBlockMetadata } from "~/helpers/nova/hooks/useBlockMetadata";
+import { IFeedBlockData } from "~/models/api/nova/feed/IFeedBlockData";
+import "./KeyPanel.scss";
+
+export const SelectedFeedInfo = ({
+ network,
+ selectedFeedItem,
+ networkConfig,
+}: {
+ readonly networkConfig: INetwork;
+ readonly network: string;
+ readonly selectedFeedItem: IFeedBlockData;
+}) => {
+ const [selectedBlockMetadata, isMetadataLoading] = useBlockMetadata(network, selectedFeedItem.blockId);
+
+ const onClose = useCallback(() => {}, []);
+
+ return (
+
+
+
+
+
+
+
+ Block
+
+ {selectedBlockMetadata.metadata && !isMetadataLoading && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/features/visualizer-vivagraph/components/Wrapper.tsx b/client/src/features/visualizer-vivagraph/components/Wrapper.tsx
new file mode 100644
index 000000000..1ee628e0e
--- /dev/null
+++ b/client/src/features/visualizer-vivagraph/components/Wrapper.tsx
@@ -0,0 +1,70 @@
+import React, { useCallback } from "react";
+import Modal from "~/app/components/Modal";
+import { TSelectFeedItemNova } from "~/app/types/visualizer.types";
+import { INetwork } from "~/models/config/INetwork";
+import KeyPanel from "./KeyPanel";
+import mainHeader from "~assets/modals/visualizer/main-header.json";
+import { SelectedFeedInfo } from "./SelectedFeedInfo";
+import { ThemeMode } from "../definitions/enums";
+
+export const Wrapper = ({
+ children,
+ network,
+ networkConfig,
+ themeMode,
+
+ isPlaying,
+
+ selectedFeedItem,
+}: {
+ readonly children: React.ReactNode;
+ readonly network: string;
+ readonly networkConfig: INetwork;
+ readonly themeMode: ThemeMode;
+ readonly isPlaying: boolean;
+ readonly selectedFeedItem: TSelectFeedItemNova;
+}) => {
+ const onToggle = useCallback(() => {}, []);
+
+ return (
+ <>
+
+
+
+
Visualizer
+
+
+
+
+
Search
+
{}} maxLength={2000} />
+
+
+
+
+ {children}
+
+
+
+
+
+
+ {selectedFeedItem && (
+
+ )}
+
+
+ >
+ );
+};
+
+Wrapper.defaultProps = {
+ isEdgeRenderingEnabled: undefined,
+ setEdgeRenderingEnabled: undefined,
+};
diff --git a/client/src/features/visualizer-vivagraph/definitions/constants.ts b/client/src/features/visualizer-vivagraph/definitions/constants.ts
new file mode 100644
index 000000000..ccdf9eba8
--- /dev/null
+++ b/client/src/features/visualizer-vivagraph/definitions/constants.ts
@@ -0,0 +1,45 @@
+import { BlockState } from "@iota/sdk-wasm-nova/web";
+import { ThemeMode } from "./enums";
+
+export const MAX_VISIBLE_BLOCKS = 2500;
+
+// colors
+export const ACCEPTED_BLOCK_COLORS: string[] = ["#0101FF", "#0000DB", "#0101AB"];
+export const CONFIRMED_BLOCK_COLOR = "#3CE5E1";
+export const ORPHANED_BLOCK_COLOR = "#C026D3";
+export const DROPPED_BLOCK_COLOR = ORPHANED_BLOCK_COLOR;
+export const SEARCH_RESULT_COLOR = "#1EC15A";
+export const HOVERED_BLOCK_COLOR = SEARCH_RESULT_COLOR;
+
+// colors by theme
+export const PENDING_BLOCK_COLOR_LIGHTMODE = "#A6C3FC";
+export const PENDING_BLOCK_COLOR_DARKMODE = "#5C84FA";
+export const FINALIZED_BLOCK_COLOR_LIGHTMODE = "#5C84FA";
+export const FINALIZED_BLOCK_COLOR_DARKMODE = "#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,
+ dropped: DROPPED_BLOCK_COLOR,
+ orphaned: ORPHANED_BLOCK_COLOR,
+ },
+ [ThemeMode.Light]: {
+ accepted: ACCEPTED_BLOCK_COLORS,
+ pending: PENDING_BLOCK_COLOR_LIGHTMODE,
+ confirmed: CONFIRMED_BLOCK_COLOR,
+ finalized: FINALIZED_BLOCK_COLOR_LIGHTMODE,
+ dropped: DROPPED_BLOCK_COLOR,
+ orphaned: ORPHANED_BLOCK_COLOR,
+ },
+};
+
+// time
+export const MILLISECONDS_PER_SECOND = 1000;
+
+export const VISUALIZER_BACKGROUND: Record = {
+ [ThemeMode.Dark]: "#000000",
+ [ThemeMode.Light]: "#FFFFFF",
+};
diff --git a/client/src/features/visualizer-vivagraph/definitions/enums.ts b/client/src/features/visualizer-vivagraph/definitions/enums.ts
new file mode 100644
index 000000000..2c765b865
--- /dev/null
+++ b/client/src/features/visualizer-vivagraph/definitions/enums.ts
@@ -0,0 +1,4 @@
+export enum ThemeMode {
+ Light = "light",
+ Dark = "dark",
+}
diff --git a/client/src/features/visualizer-vivagraph/hooks/useFeed.ts b/client/src/features/visualizer-vivagraph/hooks/useFeed.ts
new file mode 100644
index 000000000..963f9785a
--- /dev/null
+++ b/client/src/features/visualizer-vivagraph/hooks/useFeed.ts
@@ -0,0 +1,153 @@
+import { useEffect, useRef, useState } from "react";
+import { type BlockMetadataResponse } from "@iota/sdk-wasm-nova/web";
+import { ServiceFactory } from "~factories/serviceFactory";
+import { IFeedBlockData } from "~/models/api/nova/feed/IFeedBlockData";
+import Viva from "vivagraphjs";
+import { INodeData } from "~models/graph/stardust/INodeData";
+import { NovaFeedClient } from "~services/nova/novaFeedClient";
+import { buildNodeShader } from "../lib/buildNodeShader";
+import { useTangleStore } from "~features/visualizer-vivagraph/store/tangle";
+import { getBlockParents, hexToDecimalColor } from "~features/visualizer-vivagraph/lib/helpers";
+import { MAX_VISIBLE_BLOCKS } from "~features/visualizer-vivagraph/definitions/constants";
+import { getBlockColorByState } from "../lib/helpers";
+import { useGetThemeMode } from "~helpers/hooks/useGetThemeMode";
+
+export const useFeed = (network: string) => {
+ const [feedService] = useState(ServiceFactory.get(`feed-${network}`));
+
+ const graphElement = useRef(null);
+ const graph = useRef | null>(null);
+ const resetCounter = useRef(0);
+ const lastUpdateTime = useRef(0);
+ const graphics = useRef | null>(null);
+ const renderer = useRef(null);
+ const getBlockIdToMetadata = useTangleStore((state) => state.getBlockIdToMetadata);
+ const getExistingBlockIds = useTangleStore((state) => state.getExistingBlockIds);
+ const createBlockIdToMetadata = useTangleStore((state) => state.createBlockIdToMetadata);
+ const getVisibleBlocks = useTangleStore((state) => state.getVisibleBlocks);
+ const setVisibleBlocks = useTangleStore((state) => state.setVisibleBlocks);
+ const deleteBlockIdToMetadata = useTangleStore((state) => state.deleteBlockIdToMetadata);
+ const themeMode = useGetThemeMode();
+
+ useEffect(() => {
+ // eslint-disable-next-line no-void
+ void (() => {
+ if (!feedService) {
+ return;
+ }
+ setupGraph();
+ feedSubscriptionStart();
+ })();
+ }, [feedService, graph.current, graphElement.current]);
+
+ const updateBlockColor = (blockId: string, color: string) => {
+ const nodeUI = graphics?.current?.getNodeUI(blockId);
+ if (nodeUI) {
+ nodeUI.color = hexToDecimalColor(color);
+ }
+ };
+
+ const createBlock = (blockId: string, newBlock: IFeedBlockData, addedTime: number) => {
+ createBlockIdToMetadata(blockId, newBlock);
+
+ graph.current?.addNode(blockId, {
+ feedItem: newBlock,
+ added: addedTime,
+ });
+ const visibleBlocks = getVisibleBlocks();
+ const updatedVisibleBlocks = [...visibleBlocks, blockId];
+
+ if (updatedVisibleBlocks.length >= MAX_VISIBLE_BLOCKS) {
+ const firstBlockId = updatedVisibleBlocks[0];
+ updatedVisibleBlocks.shift();
+ deleteBlockIdToMetadata(firstBlockId);
+ graph.current?.removeNode(firstBlockId);
+ // graph.current?.removeLink(); // TODO investigate if we need to remove it manually
+ }
+
+ setVisibleBlocks(updatedVisibleBlocks);
+ };
+
+ const onNewBlock = (newBlock: IFeedBlockData) => {
+ if (graph.current) {
+ const now = Date.now();
+ lastUpdateTime.current = now;
+
+ const blockId = newBlock.blockId;
+ const blockMetadata = getBlockIdToMetadata(blockId);
+
+ if (!blockMetadata) {
+ createBlock(blockId, newBlock, now);
+
+ const parentIds = getBlockParents(newBlock);
+ const existingBlockIds = getExistingBlockIds();
+
+ for (const parentId of parentIds) {
+ if (existingBlockIds.includes(parentId)) {
+ graph.current.addLink(parentId, blockId);
+ }
+ }
+ }
+ }
+ };
+
+ function onBlockMetadataUpdate(metadataUpdate: BlockMetadataResponse): void {
+ if (metadataUpdate?.blockState) {
+ const selectedColor = getBlockColorByState(themeMode, metadataUpdate.blockState);
+ updateBlockColor(metadataUpdate.blockId, selectedColor);
+ }
+ }
+
+ const feedSubscriptionStart = () => {
+ if (!feedService) {
+ return;
+ }
+
+ feedService.subscribeBlocks(onNewBlock, onBlockMetadataUpdate, () => {});
+ };
+
+ function setupGraph(): void {
+ if (graphElement.current && !graph.current) {
+ graph.current = Viva.Graph.graph();
+ graphics.current = Viva.Graph.View.webglGraphics();
+
+ const layout = Viva.Graph.Layout.forceDirected(graph.current, {
+ springLength: 10,
+ springCoeff: 0.0001,
+ stableThreshold: 0.15,
+ gravity: -2,
+ dragCoeff: 0.02,
+ timeStep: 20,
+ theta: 0.8,
+ });
+
+ graphics.current.setNodeProgram(buildNodeShader());
+
+ const events = Viva.Graph.webglInputEvents(graphics.current, graph.current);
+
+ events.dblClick((node) => {
+ window.open(`${window.location.origin}/${network}/block/${node.id}`, "_blank");
+ });
+
+ renderer.current = Viva.Graph.View.renderer(graph.current, {
+ container: graphElement.current,
+ graphics: graphics.current,
+ layout,
+ renderLinks: true,
+ });
+
+ renderer.current.run();
+
+ graphics.current.scale(1, { x: graphElement.current.clientWidth / 2, y: graphElement.current.clientHeight / 2 });
+
+ for (let i = 0; i < 12; i++) {
+ renderer.current.zoomOut();
+ }
+ }
+ }
+
+ return {
+ graphElement,
+ resetCounter,
+ };
+};
diff --git a/client/src/features/visualizer-vivagraph/lib/buildNodeShader.ts b/client/src/features/visualizer-vivagraph/lib/buildNodeShader.ts
new file mode 100644
index 000000000..46ea0afe0
--- /dev/null
+++ b/client/src/features/visualizer-vivagraph/lib/buildNodeShader.ts
@@ -0,0 +1,166 @@
+/* eslint-disable max-len */
+
+import Viva from "vivagraphjs";
+
+/**
+ * Generate a WebGL node shader.
+ * @returns The program for the shader.
+ */
+export function buildNodeShader(): WebGLProgram {
+ // For each primitive we need 4 attributes: x, y, color and size.
+ const ATTRIBUTES_PER_PRIMITIVE = 4;
+ const nodesFS = [
+ "precision mediump float;",
+ "varying vec4 color;",
+ "void main(void) {",
+ " vec2 center = vec2(0.5);",
+ " float radius = 0.5;",
+ " if (length(gl_PointCoord - center) < radius) {",
+ " gl_FragColor = color;",
+ " } else {",
+ " gl_FragColor = vec4(0);",
+ " }",
+ "}",
+ ].join("\n");
+ const nodesVS = [
+ "attribute vec2 a_vertexPos;",
+ // Pack color and size into vector. First elemnt is color, second - size.
+ // Since it's floating point we can only use 24 bit to pack colors...
+ // thus alpha channel is dropped, and is always assumed to be 1.
+ "attribute vec2 a_customAttributes;",
+ "uniform vec2 u_screenSize;",
+ "uniform mat4 u_transform;",
+ "varying vec4 color;",
+ "void main(void) {",
+ " gl_Position = u_transform * vec4(a_vertexPos/u_screenSize, 0, 1);",
+ " gl_PointSize = a_customAttributes[1] * u_transform[0][0];",
+ " float c = a_customAttributes[0];",
+ " color.b = mod(c, 256.0); c = floor(c/256.0);",
+ " color.g = mod(c, 256.0); c = floor(c/256.0);",
+ " color.r = mod(c, 256.0); c = floor(c/256.0); color /= 255.0;",
+ " color.a = 1.0;",
+ "}",
+ ].join("\n");
+ let program: WebGLProgram;
+ let gl: WebGLRenderingContext;
+ let buffer: WebGLBuffer | null;
+ let locations: Viva.Graph.ILocation;
+ let webglUtils: Viva.Graph.IWebGL;
+ let nodes = new Float32Array(64);
+ let nodesCount: number = 0;
+ let canvasWidth: number;
+ let canvasHeight: number;
+ let transform: Float32List;
+ let isCanvasDirty: boolean;
+ return {
+ /**
+ * Called by webgl renderer to load the shader into gl context.
+ * @param glContext the webgl context
+ */
+ load: (glContext: WebGLRenderingContext) => {
+ gl = glContext;
+ webglUtils = Viva.Graph.webgl(glContext);
+ program = webglUtils.createProgram(nodesVS, nodesFS);
+ gl.useProgram(program);
+ locations = webglUtils.getLocations(program, ["a_vertexPos", "a_customAttributes", "u_screenSize", "u_transform"]);
+ gl.enableVertexAttribArray(locations.vertexPos);
+ gl.enableVertexAttribArray(locations.customAttributes);
+ buffer = gl.createBuffer();
+ },
+ /**
+ * Called by webgl renderer to update node position in the buffer array
+ * @param nodeUI - data model for the rendered node (WebGLCircle in this case)
+ * @param nodeUI.color Color
+ * @param nodeUI.size Size
+ * @param nodeUI.id Id
+ * @param pos - {x, y} coordinates of the node.
+ * @param pos.x X
+ * @param pos.y Y
+ */
+ position: (nodeUI: { color: number; size: number; id: number }, pos: { x: number; y: number }) => {
+ const idx = nodeUI.id;
+ nodes[idx * ATTRIBUTES_PER_PRIMITIVE] = pos.x;
+ nodes[idx * ATTRIBUTES_PER_PRIMITIVE + 1] = -pos.y;
+ nodes[idx * ATTRIBUTES_PER_PRIMITIVE + 2] = nodeUI.color;
+ nodes[idx * ATTRIBUTES_PER_PRIMITIVE + 3] = nodeUI.size;
+ },
+ /**
+ * Request from webgl renderer to actually draw our stuff into the
+ * gl context. This is the core of our shader.
+ */
+ render: () => {
+ gl.useProgram(program);
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, nodes, gl.DYNAMIC_DRAW);
+ if (isCanvasDirty) {
+ isCanvasDirty = false;
+ gl.uniformMatrix4fv(locations.transform, false, transform);
+ gl.uniform2f(locations.screenSize, canvasWidth, canvasHeight);
+ }
+ gl.vertexAttribPointer(locations.vertexPos, 2, gl.FLOAT, false, ATTRIBUTES_PER_PRIMITIVE * Float32Array.BYTES_PER_ELEMENT, 0);
+ gl.vertexAttribPointer(
+ locations.customAttributes,
+ 2,
+ gl.FLOAT,
+ false,
+ ATTRIBUTES_PER_PRIMITIVE * Float32Array.BYTES_PER_ELEMENT,
+ 2 * 4,
+ );
+ gl.drawArrays(gl.POINTS, 0, nodesCount);
+ },
+
+ /**
+ * Called by webgl renderer when user scales/pans the canvas with nodes.
+ * @param newTransform The new transform
+ */
+ updateTransform: (newTransform: Float32List) => {
+ transform = newTransform;
+ isCanvasDirty = true;
+ },
+ /**
+ * Called by webgl renderer when user resizes the canvas with nodes.
+ * @param newCanvasWidth New Width
+ * @param newCanvasHeight New height
+ */
+ updateSize: (newCanvasWidth: number, newCanvasHeight: number) => {
+ canvasWidth = newCanvasWidth;
+ canvasHeight = newCanvasHeight;
+ isCanvasDirty = true;
+ },
+ /**
+ * Called by webgl renderer to notify us that the new node was created in the graph
+ */
+ createNode: () => {
+ nodes = webglUtils.extendArray(nodes, nodesCount, ATTRIBUTES_PER_PRIMITIVE);
+ nodesCount += 1;
+ },
+ /**
+ * Called by webgl renderer to notify us that the node was removed from the graph
+ * @param node Node object
+ * @param node.id Id of the node
+ */
+ removeNode: (node: { id: number }) => {
+ if (nodesCount > 0) {
+ nodesCount -= 1;
+ }
+ if (node.id < nodesCount && nodesCount > 0) {
+ // we do not really delete anything from the buffer.
+ // Instead we swap deleted node with the "last" node in the
+ // buffer and decrease marker of the "last" node. Gives nice O(1)
+ // performance, but make code slightly harder than it could be:
+ webglUtils.copyArrayPart(
+ nodes,
+ node.id * ATTRIBUTES_PER_PRIMITIVE,
+ nodesCount * ATTRIBUTES_PER_PRIMITIVE,
+ ATTRIBUTES_PER_PRIMITIVE,
+ );
+ }
+ },
+ /**
+ * This method is called by webgl renderer when it changes parts of its
+ * buffers. We don't use it here, but it's needed by API (see the comment
+ * in the removeNode() method)
+ */
+ replaceProperties() {},
+ };
+}
diff --git a/client/src/features/visualizer-vivagraph/lib/helpers.ts b/client/src/features/visualizer-vivagraph/lib/helpers.ts
new file mode 100644
index 000000000..d35befe58
--- /dev/null
+++ b/client/src/features/visualizer-vivagraph/lib/helpers.ts
@@ -0,0 +1,56 @@
+import { BasicBlockBody, Parents, BlockState } from "@iota/sdk-wasm-nova/web";
+import { IFeedBlockData } from "~models/api/nova/feed/IFeedBlockData";
+import { ThemeMode } from "../definitions/enums";
+import { THEME_BLOCK_COLORS } from "../definitions/constants";
+
+export const getBlockParents = (blockData: IFeedBlockData): string[] => {
+ const parents: Parents = [];
+ const blockStrongParents = (blockData?.block?.body as BasicBlockBody).strongParents ?? [];
+ const blockWeakParents = (blockData?.block?.body as BasicBlockBody).weakParents ?? [];
+ parents.push(...blockStrongParents, ...blockWeakParents);
+
+ if (parents && parents.length) {
+ return parents;
+ }
+
+ // TODO confusing, because in interface method isBasic() exists, but in the implementation it does not
+ // if (block.block?.body?.isBasic()) {
+ // return block.block?.body?.asBasic().strongParents;
+ // }
+
+ // if (block.block?.body?.isValidation()) {
+ // return block.block?.body?.asValidation().strongParents;
+ // }
+
+ return [];
+};
+
+export function hexToDecimalColor(hex: string) {
+ if (hex.startsWith("#")) {
+ hex = hex.slice(1);
+ }
+
+ const r = parseInt(hex.slice(0, 2), 16);
+ const g = parseInt(hex.slice(2, 4), 16);
+ const b = parseInt(hex.slice(4, 6), 16);
+
+ return String(r * 65536 + g * 256 + b);
+}
+
+export const randomIntFromInterval = (min: number, max: number) => {
+ const randomFraction = Math.random();
+ const range = max - min + 1;
+ const randomInRange = Math.floor(randomFraction * range);
+ return randomInRange + min;
+};
+
+export function getBlockColorByState(theme: ThemeMode, blockState: BlockState): string {
+ 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-vivagraph/store/tangle.ts b/client/src/features/visualizer-vivagraph/store/tangle.ts
new file mode 100644
index 000000000..9b5bf0a36
--- /dev/null
+++ b/client/src/features/visualizer-vivagraph/store/tangle.ts
@@ -0,0 +1,59 @@
+import { create } from "zustand";
+import { devtools } from "zustand/middleware";
+import { IFeedBlockData } from "~models/api/nova/feed/IFeedBlockData";
+
+interface TangleState {
+ blockIdToMetadata: Map;
+ createBlockIdToMetadata: (blockId: string, metadata: IFeedBlockData) => void;
+ getBlockIdToMetadata: (blockId: string) => IFeedBlockData | undefined;
+ updateBlockIdToMetadata: (blockId: string, metadata: Partial) => void;
+ deleteBlockIdToMetadata: (blockId: string) => void;
+ getExistingBlockIds: () => string[];
+
+ visibleBlocks: string[];
+ setVisibleBlocks: (blockIds: string[]) => void;
+ getVisibleBlocks: () => string[];
+}
+
+const INITIAL_STATE = {
+ blockIdToMetadata: new Map(),
+ visibleBlocks: [],
+};
+
+export const useTangleStore = create()(
+ devtools((set, get) => ({
+ ...INITIAL_STATE,
+
+ setVisibleBlocks: (blockIds) => {
+ set(() => {
+ return {
+ visibleBlocks: blockIds,
+ };
+ });
+ },
+ getVisibleBlocks: () => {
+ return get().visibleBlocks;
+ },
+ createBlockIdToMetadata: (blockId, metadata) => {
+ get().blockIdToMetadata.set(blockId, metadata);
+ },
+ getBlockIdToMetadata: (blockId) => {
+ return get().blockIdToMetadata.get(blockId);
+ },
+ updateBlockIdToMetadata: (blockId, metadata) => {
+ const blockMetadata = get().blockIdToMetadata;
+ const currentMetadata = blockMetadata.get(blockId);
+ if (currentMetadata) {
+ const newMetadata = { ...currentMetadata, ...metadata };
+ blockMetadata.set(blockId, newMetadata);
+ }
+ },
+ deleteBlockIdToMetadata: (blockId) => {
+ const blockMetadata = get().blockIdToMetadata;
+ blockMetadata.delete(blockId);
+ },
+ getExistingBlockIds: () => {
+ return Array.from(get().blockIdToMetadata.keys());
+ },
+ })),
+);