diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index 188bf534c..427985107 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -46,7 +46,7 @@ import StardustStatisticsPage from "./routes/stardust/statistics/StatisticsPage" import NovaStatisticsPage from "./routes/nova/statistics/StatisticsPage"; import StardustTransactionPage from "./routes/stardust/TransactionPage"; import { Visualizer as StardustVisualizer } from "./routes/stardust/Visualizer"; -import NovaVisualizer from "../features/visualizer-threejs/NovaVisualizer"; +import Visualizer from "./routes/nova/Visualizer"; import StreamsV0 from "./routes/StreamsV0"; import { StreamsV0RouteProps } from "./routes/StreamsV0RouteProps"; import { VisualizerRouteProps } from "./routes/VisualizerRouteProps"; @@ -180,7 +180,7 @@ const buildAppRoutes = (protocolVersion: string, withNetworkContext: (wrappedCom const novaRoutes = [ , , - , + , , , , diff --git a/client/src/app/routes/nova/Visualizer.tsx b/client/src/app/routes/nova/Visualizer.tsx new file mode 100644 index 000000000..d753954e4 --- /dev/null +++ b/client/src/app/routes/nova/Visualizer.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { RouteComponentProps } from "react-router-dom"; +import { useGetThemeMode } from "~/helpers/hooks/useGetThemeMode"; +import VisualizerInstance from "~features/visualizer-vivagraph/VisualizerInstance"; +import { VisualizerRouteProps } from "~app/routes/VisualizerRouteProps"; + +export default function Visualizer(props: RouteComponentProps): React.JSX.Element { + const theme = useGetThemeMode(); + + return ; +} diff --git a/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx b/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx index 9b565b36c..3a1cdd65d 100644 --- a/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx +++ b/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx @@ -3,7 +3,7 @@ import { BlockState } from "@iota/sdk-wasm-nova/web"; 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 ColorPanel from "~features/visualizer-vivagraph/components/ColorPanel"; import "./KeyPanel.scss"; export const KeyPanel = ({ network, themeMode }: { network: string; themeMode: ThemeMode }) => { diff --git a/client/src/features/visualizer-vivagraph/Visualizer.scss b/client/src/features/visualizer-vivagraph/Visualizer.scss new file mode 100644 index 000000000..59c4e2d5f --- /dev/null +++ b/client/src/features/visualizer-vivagraph/Visualizer.scss @@ -0,0 +1,180 @@ +@import "../../scss/fonts"; +@import "../../scss/mixins"; +@import "../../scss/media-queries"; +@import "../../scss/variables"; +@import "../../scss/themes"; + +.visualizer-nova { + position: relative; + margin: 16px 40px; + + .heading { + min-width: 230px; + } + + @include phone-down { + margin: 20px; + } + + .search-filter { + flex: 1; + margin-bottom: 16px; + + @include tablet-down { + display: none; + } + + .card--content { + padding: 8px 16px; + } + + button { + white-space: nowrap; + } + } + + .graph-border { + display: flex; + position: relative; + flex: 1; + align-items: stretch; + justify-content: stretch; + height: 80vh; + overflow: hidden; + border: 1px solid var(--input-border-color); + border-radius: 6px; + + .viva { + position: relative; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--stardust-visualizer-bg); + } + + .selected-node { + @include font-size(12px); + + position: absolute; + z-index: 1; + margin: 5px; + background-color: $white; + color: var(--link-highlight); + font-family: $metropolis; + word-break: break-all; + } + + .action-panel-container { + display: flex; + position: absolute; + z-index: 2; + top: 20px; + right: 20px; + + .pause-button { + padding: 6px; + color: var(--header-icon-color); + + &:hover { + color: var(--link-highlight); + } + } + } + } + + .info-panel { + background: var(--body-background); + display: flex; + position: absolute; + z-index: 2; + top: 150px; + right: 20px; + width: 320px; + overflow: visible; + + @include tablet-down { + top: 140px; + width: 46%; + } + + @include phone-down { + top: 200px; + left: 10px; + width: 60%; + } + + button { + position: absolute; + top: 8px; + right: 8px; + } + + .card--content { + padding: 20px 30px; + + .card--value, + .card--label { + align-items: center; + + @include phone-down { + .block-tangle-state__confirmed { + text-indent: -9999px; + padding: 0 5px; + + &::before { + content: "\2713"; + text-indent: 0px; + color: var(--mint-green-bg); + font-size: 16px; + font-weight: bold; + } + } + + .blocks-tangle-state .block-tangle-state { + margin-right: 0; + padding: 0 5px; + .tooltip .children { + &::before { + content: "\2717"; + padding-top: 4px; + color: $error; + font-size: 16px; + font-weight: bold; + } + span { + display: none; + } + } + } + } + } + } + + .tooltip { + .wrap { + width: 180px; + } + } + + .info-panel__dropdown { + .card--content__input--dropdown { + display: none; + + @include phone-down { + display: block; + } + } + } + + .info-panel__reattachments { + @include phone-down { + display: none; + + &.info-panel__reattachments--opened { + display: block; + } + } + } + } +} diff --git a/client/src/features/visualizer-vivagraph/VisualizerInstance.tsx b/client/src/features/visualizer-vivagraph/VisualizerInstance.tsx new file mode 100644 index 000000000..f715f8265 --- /dev/null +++ b/client/src/features/visualizer-vivagraph/VisualizerInstance.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { RouteComponentProps } from "react-router-dom"; +import { VisualizerRouteProps } from "~app/routes/VisualizerRouteProps"; +import { useGetThemeMode } from "~/helpers/hooks/useGetThemeMode"; +import { useNetworkConfig } from "~helpers/hooks/useNetworkConfig"; +import { Wrapper } from "./components/Wrapper"; +import "./Visualizer.scss"; +import { useFeed } from "~features/visualizer-vivagraph/hooks/useFeed"; + +const VisualizerInstance: React.FC> = ({ + match: { + params: { network }, + }, +}) => { + const [networkConfig] = useNetworkConfig(network); + const themeMode = useGetThemeMode(); + const { graphElement } = useFeed(network); + + return ( + +
{}} 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()); + }, + })), +);