diff --git a/client/src/features/visualizer-threejs/CameraControls.tsx b/client/src/features/visualizer-threejs/CameraControls.tsx index a8353cac9..7a65a5382 100644 --- a/client/src/features/visualizer-threejs/CameraControls.tsx +++ b/client/src/features/visualizer-threejs/CameraControls.tsx @@ -9,14 +9,28 @@ import { VISUALIZER_PADDINGS } from "./constants"; const CAMERA_ANGLES = getCameraAngles(); const CameraControls = () => { + const { camera } = useThree(); const controls = React.useRef(null); const [shouldLockZoom, setShouldLockZoom] = useState(false); const scene = useThree((state) => state.scene); const zoom = useTangleStore((state) => state.zoom); + const forcedZoom = useTangleStore((state) => state.forcedZoom); const mesh = scene.getObjectByName(CanvasElement.TangleWrapperMesh) as THREE.Mesh | undefined; const canvasDimensions = useConfigStore((state) => state.dimensions); + useEffect(() => { + if (!forcedZoom) return; + + (async () => { + if (camera && controls.current) { + controls.current.minZoom = forcedZoom; + controls.current.minZoom = forcedZoom; + await controls.current.zoomTo(forcedZoom, true); + } + })(); + }, [forcedZoom]); + /** * Fits the camera to the TangleMesh. */ diff --git a/client/src/features/visualizer-threejs/ConfigControls.scss b/client/src/features/visualizer-threejs/ConfigControls.scss new file mode 100644 index 000000000..07da9d94d --- /dev/null +++ b/client/src/features/visualizer-threejs/ConfigControls.scss @@ -0,0 +1,47 @@ +.controls-container { + font-family: + "Metropolis Regular", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Helvetica, + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol"; + background: var(--body-background); + border: 1px solid var(--border-color); + padding: 8px 16px; + border-radius: 8px; + .controls__list { + color: var(--type-color); + display: flex; + gap: 8px; + flex-wrap: wrap; + } + .controls__item { + width: 20%; + display: flex; + flex-direction: column; + + input { + width: 100%; + } + } + .controls__error { + font-size: 12px; + margin-top: 4px; + } + .controls__actions { + margin-top: 16px; + display: flex; + gap: 8px; + } + + input { + background: var(--body-background); + padding: 8px 16px; + } +} diff --git a/client/src/features/visualizer-threejs/ConfigControls.tsx b/client/src/features/visualizer-threejs/ConfigControls.tsx new file mode 100644 index 000000000..59784fa44 --- /dev/null +++ b/client/src/features/visualizer-threejs/ConfigControls.tsx @@ -0,0 +1,261 @@ +import React, { useState } from "react"; +import { + MIN_SINUSOID_PERIOD, + MAX_SINUSOID_PERIOD, + MIN_SINUSOID_AMPLITUDE, + MAX_SINUSOID_AMPLITUDE, + MIN_TILT_FACTOR_DEGREES, + MAX_TILT_FACTOR_DEGREES, + TILT_DURATION_SECONDS, + EMITTER_SPEED_MULTIPLIER, + features, +} from "./constants"; +import { useTangleStore } from "~features/visualizer-threejs/store"; +import "./ConfigControls.scss"; + +enum VisualizerConfig { + MinSinusoidPeriod = "minSinusoidPeriod", + MaxSinusoidPeriod = "maxSinusoidPeriod", + MinSinusoidAmplitude = "minSinusoidAmplitude", + MaxSinusoidAmplitude = "maxSinusoidAmplitude", + MinTiltDegrees = "minTiltDegrees", + MaxTiltDegrees = "maxTiltDegrees", + TiltDurationSeconds = "tiltDurationSeconds", + EmitterSpeedMultiplier = "emitterSpeedMultiplier", +} + +const VISUALIZER_CONFIG_LOCAL_STORAGE_KEY = "visualizerConfigs"; + +const DEFAULT_VISUALIZER_CONFIG_VALUES: Record = { + [VisualizerConfig.MinSinusoidPeriod]: MIN_SINUSOID_PERIOD, + [VisualizerConfig.MaxSinusoidPeriod]: MAX_SINUSOID_PERIOD, + [VisualizerConfig.MinSinusoidAmplitude]: MIN_SINUSOID_AMPLITUDE, + [VisualizerConfig.MaxSinusoidAmplitude]: MAX_SINUSOID_AMPLITUDE, + [VisualizerConfig.MinTiltDegrees]: MIN_TILT_FACTOR_DEGREES, + [VisualizerConfig.MaxTiltDegrees]: MAX_TILT_FACTOR_DEGREES, + [VisualizerConfig.TiltDurationSeconds]: TILT_DURATION_SECONDS, + [VisualizerConfig.EmitterSpeedMultiplier]: EMITTER_SPEED_MULTIPLIER, +}; + +/** + * Retrieves a value from localStorage and parses it as JSON. + */ +export const getVisualizerConfigValues = (): Record => { + if (features.controlsVisualiserEnabled) { + const item = localStorage.getItem(VISUALIZER_CONFIG_LOCAL_STORAGE_KEY); + return item ? JSON.parse(item) : DEFAULT_VISUALIZER_CONFIG_VALUES; + } else { + localStorage.removeItem(VISUALIZER_CONFIG_LOCAL_STORAGE_KEY); + return DEFAULT_VISUALIZER_CONFIG_VALUES; + } +}; + +/** + * Saves a value to localStorage as a JSON string. + */ +function setToLocalStorage(value: Record) { + localStorage.setItem(VISUALIZER_CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(value)); +} + +/** + * Checks if config for visualizer inputs exists in localStorage. + */ +function controlsExistInLocalStorage(): boolean { + return !!localStorage.getItem(VISUALIZER_CONFIG_LOCAL_STORAGE_KEY); +} + +export const ConfigControls = () => { + const forcedZoom = useTangleStore((state) => state.forcedZoom); + const setForcedZoom = useTangleStore((state) => state.setForcedZoom); + const forcedZoomInit = forcedZoom !== undefined ? String(forcedZoom) : forcedZoom; + const [localZoom, setLocalZoom] = useState(forcedZoomInit); + + const [visualizerConfigValues, setVisualizerConfigValues] = useState>(() => { + return getVisualizerConfigValues() || DEFAULT_VISUALIZER_CONFIG_VALUES; // Use getFromLocalStorage to retrieve the state + }); + const [showResetButton, setShowResetButton] = useState(() => { + return controlsExistInLocalStorage(); + }); + + const [errors, setErrors] = useState<{ + [k: string]: string; + }>({}); + + const inputs: { + key: VisualizerConfig; + label: string; + min: number; + max: number; + }[] = [ + { + key: VisualizerConfig.MinSinusoidPeriod, + label: "Min sinusoid period", + min: 1, + max: 7, + }, + { + key: VisualizerConfig.MaxSinusoidPeriod, + label: "Max sinusoid period", + min: 8, + max: 15, + }, + { + key: VisualizerConfig.MinSinusoidAmplitude, + label: "Min sinusoid amplitude", + min: 50, + max: 199, + }, + { + key: VisualizerConfig.MaxSinusoidAmplitude, + label: "Max sinusoid amplitude", + min: 200, + max: 500, + }, + { + key: VisualizerConfig.MinTiltDegrees, + label: "Min tilt factor degrees", + min: 0, + max: 90, + }, + { + key: VisualizerConfig.MaxTiltDegrees, + label: "Max tilt factor degrees", + min: 0, + max: 90, + }, + { + key: VisualizerConfig.TiltDurationSeconds, + label: "Tilt duration (seconds)", + min: 1, + max: 100, + }, + { + key: VisualizerConfig.EmitterSpeedMultiplier, + label: "Emitter Speed Multiplier", + min: 0, + max: 1000, + }, + ]; + + const handleApply = () => { + if (Object.keys(errors).some((key) => errors[key])) { + // Handle the error case, e.g., display a message + console.error("There are errors in the form."); + return; + } + + setToLocalStorage(visualizerConfigValues); + location.reload(); + }; + + const handleChange = (key: VisualizerConfig, val: string) => { + const input = inputs.find((input) => input.key === key); + if (!input) return; + + if (!val) { + setErrors((prevErrors) => ({ ...prevErrors, [key]: "Value is required" })); + setVisualizerConfigValues((prevState) => ({ ...prevState, [key]: "" })); + return; + } + + const numericValue = Number(val); + if (numericValue < input.min || numericValue > input.max) { + setErrors((prevErrors) => ({ ...prevErrors, [key]: `Value must be between ${input.min} and ${input.max}` })); + } else { + setErrors((prevErrors) => ({ ...prevErrors, [key]: "" })); + } + + setVisualizerConfigValues((prevState) => ({ ...prevState, [key]: numericValue })); + }; + + if (!features.controlsVisualiserEnabled) { + return null; + } + + return ( +
+
+ {inputs.map((i) => { + return ( +
+ + handleChange(i.key, e.target.value)} + /> + {!!errors[i.key] &&
{errors[i.key]}
} +
+ ); + })} +
+ +
+ + {showResetButton && ( + + )} +
+ +
+
+ + { + const input = e.target.value; + setErrors((prevErrors) => ({ ...prevErrors, zoom: "" })); + + if (!input) { + setLocalZoom(undefined); + return; + } + + const numberRegExp = /^-?\d+(\.|\.\d*|\d*)?$/; + if (numberRegExp.test(input)) { + if (input.endsWith(".")) { + setLocalZoom(input); + } else { + const value = parseFloat(input); + if (value > 2) { + setErrors((prevErrors) => ({ ...prevErrors, zoom: "Value must be between 0 and 2" })); + + setLocalZoom(String(2)); + return; + } + setLocalZoom(input); + } + } + }} + /> + {!!errors["zoom"] &&
{errors["zoom"]}
} +
+ +
+
+
+
+ ); +}; + +export default React.memo(ConfigControls); diff --git a/client/src/features/visualizer-threejs/VisualizerInstance.tsx b/client/src/features/visualizer-threejs/VisualizerInstance.tsx index 2d3c685c0..4ff59e4b3 100644 --- a/client/src/features/visualizer-threejs/VisualizerInstance.tsx +++ b/client/src/features/visualizer-threejs/VisualizerInstance.tsx @@ -7,6 +7,7 @@ import { RouteComponentProps } from "react-router-dom"; import * as THREE from "three"; import { FAR_PLANE, + features, NEAR_PLANE, DIRECTIONAL_LIGHT_INTENSITY, PENDING_BLOCK_COLOR, @@ -34,11 +35,6 @@ import useSearchStore from "~features/visualizer-threejs/store/search"; import { useSearch } from "~features/visualizer-threejs/hooks/useSearch"; import "./Visualizer.scss"; -const features = { - statsEnabled: false, - cameraControls: true, -}; - const VisualizerInstance: React.FC> = ({ match: { params: { network }, diff --git a/client/src/features/visualizer-threejs/constants.ts b/client/src/features/visualizer-threejs/constants.ts index 4b54a5151..9207e93e8 100644 --- a/client/src/features/visualizer-threejs/constants.ts +++ b/client/src/features/visualizer-threejs/constants.ts @@ -100,3 +100,9 @@ export const NUMBER_OF_RANDOM_TILTINGS = 100; export const TILT_DURATION_SECONDS = 4; export const MAX_TILT_FACTOR_DEGREES = 16; export const MIN_TILT_FACTOR_DEGREES = 1; + +export const features = { + statsEnabled: false, + cameraControls: true, + controlsVisualiserEnabled: true, +}; diff --git a/client/src/features/visualizer-threejs/store/tangle.ts b/client/src/features/visualizer-threejs/store/tangle.ts index 250f13ce7..db8159c78 100644 --- a/client/src/features/visualizer-threejs/store/tangle.ts +++ b/client/src/features/visualizer-threejs/store/tangle.ts @@ -1,10 +1,11 @@ import { Color } from "three"; import { create } from "zustand"; import { devtools } from "zustand/middleware"; -import { ZOOM_DEFAULT, EMITTER_SPEED_MULTIPLIER, SPRAY_DISTANCE } from "../constants"; +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 { getVisualizerConfigValues } from "~features/visualizer-threejs/ConfigControls"; export interface IBlockAnimationPosition { initPosition: IThreeDimensionalPosition; @@ -61,6 +62,9 @@ interface TangleState { zoom: number; setZoom: (zoom: number) => void; + forcedZoom: number | undefined; + setForcedZoom: (zoom: number | undefined) => void; + bps: number; setBps: (bps: number) => void; @@ -89,6 +93,7 @@ const INITIAL_STATE = { blockIdToAnimationPosition: new Map(), indexToBlockId: [], zoom: ZOOM_DEFAULT, + forcedZoom: undefined, bps: 0, clickedInstanceId: null, confirmedBlocksBySlot: new Map(), @@ -104,8 +109,11 @@ export const useTangleStore = create()( state.blockIdToAnimationPosition.set(key, value); }); + const { emitterSpeedMultiplier } = getVisualizerConfigValues(); + for (const [key, value] of state.blockIdToAnimationPosition) { - const animationTime = SPRAY_DISTANCE / EMITTER_SPEED_MULTIPLIER; + // const animationTime = SPRAY_DISTANCE / emitterSpeedMultiplier; + const animationTime = SPRAY_DISTANCE / emitterSpeedMultiplier; if (value.elapsedTime > animationTime) { state.blockIdToAnimationPosition.delete(key); } @@ -214,6 +222,12 @@ export const useTangleStore = create()( zoom, })); }, + setForcedZoom: (forcedZoom) => { + set((state) => ({ + ...state, + forcedZoom, + })); + }, setBps: (bps) => { set((state) => ({ ...state, diff --git a/client/src/features/visualizer-threejs/useRenderTangle.tsx b/client/src/features/visualizer-threejs/useRenderTangle.tsx index 4da8ff476..a9d5c107e 100644 --- a/client/src/features/visualizer-threejs/useRenderTangle.tsx +++ b/client/src/features/visualizer-threejs/useRenderTangle.tsx @@ -1,12 +1,13 @@ import { useThree } from "@react-three/fiber"; import { useEffect, useRef, useState } from "react"; import * as THREE from "three"; -import { SPRAY_ANIMATION_DURATION, MAX_BLOCK_INSTANCES, NODE_SIZE_DEFAULT } from "./constants"; +import { MAX_BLOCK_INSTANCES, NODE_SIZE_DEFAULT, SPRAY_DISTANCE } from "./constants"; import { useMouseMove } from "./hooks/useMouseMove"; import { IBlockState, IBlockAnimationPosition, useConfigStore, useTangleStore } from "./store"; import { useRenderEdges } from "./useRenderEdges"; import useVisualizerTimer from "~/helpers/nova/hooks/useVisualizerTimer"; import { positionToVector } from "./utils"; +import { getVisualizerConfigValues } from "~features/visualizer-threejs/ConfigControls"; const SPHERE_GEOMETRY = new THREE.SphereGeometry(NODE_SIZE_DEFAULT, 32, 16); const SPHERE_MATERIAL = new THREE.MeshPhongMaterial(); @@ -155,10 +156,12 @@ export const useRenderTangle = () => { const SPRAY_FRAMES_PER_SECOND = 24; const interval = setInterval(() => { + const { emitterSpeedMultiplier } = getVisualizerConfigValues(); blockIdToAnimationPosition.forEach((properties, blockId) => { const { initPosition, targetPosition, blockAddedTimestamp } = properties; const currentAnimationTime = getVisualizerTimeDiff(); const elapsedTime = currentAnimationTime - blockAddedTimestamp; + const SPRAY_ANIMATION_DURATION = SPRAY_DISTANCE / emitterSpeedMultiplier; const animationAlpha = Math.min(elapsedTime / SPRAY_ANIMATION_DURATION, 1); const targetPositionVector = new THREE.Vector3(); diff --git a/client/src/features/visualizer-threejs/utils.ts b/client/src/features/visualizer-threejs/utils.ts index a77a28edd..e687a51e5 100644 --- a/client/src/features/visualizer-threejs/utils.ts +++ b/client/src/features/visualizer-threejs/utils.ts @@ -5,27 +5,21 @@ import { MIN_TANGLE_RADIUS, MAX_TANGLE_RADIUS, MAX_BLOCK_INSTANCES, - EMITTER_SPEED_MULTIPLIER, CAMERA_X_AXIS_MOVEMENT, CAMERA_Y_AXIS_MOVEMENT, CAMERA_X_OFFSET, CAMERA_Y_OFFSET, NUMBER_OF_RANDOM_PERIODS, - MIN_SINUSOID_PERIOD, - MAX_SINUSOID_PERIOD, NUMBER_OF_RANDOM_AMPLITUDES, - MIN_SINUSOID_AMPLITUDE, - MAX_SINUSOID_AMPLITUDE, NUMBER_OF_RANDOM_TILTINGS, TILT_DURATION_SECONDS, SPRAY_DISTANCE, MAX_PREV_POINTS, MAX_POINT_RETRIES, MIN_BLOCK_NEAR_RADIUS, - MIN_TILT_FACTOR_DEGREES, - MAX_TILT_FACTOR_DEGREES, } from "./constants"; import type { ICameraAngles, ISinusoidalPositionParams, IThreeDimensionalPosition, ITwoDimensionalPosition } from "./interfaces"; +import { getVisualizerConfigValues } from "~features/visualizer-threejs/ConfigControls"; /** * Generates a random number within a specified range. @@ -182,15 +176,17 @@ export function getTangleDistances(): { xTangleDistance: number; yTangleDistance: number; } { + const { maxSinusoidAmplitude, emitterSpeedMultiplier } = getVisualizerConfigValues(); + /* We assume MAX BPS to get the max possible Y */ const MAX_TANGLE_DISTANCE_SECONDS = MAX_BLOCK_INSTANCES / MIN_BLOCKS_PER_SECOND; - const MAX_BLOCK_DISTANCE = EMITTER_SPEED_MULTIPLIER * MAX_TANGLE_DISTANCE_SECONDS; + const MAX_BLOCK_DISTANCE = emitterSpeedMultiplier * MAX_TANGLE_DISTANCE_SECONDS; const maxXDistance = MAX_BLOCK_DISTANCE; /* Max Y Distance will be multiplied by 2 to position blocks in the negative and positive Y axis */ - const maxYDistance = MAX_TANGLE_RADIUS * 2 + MAX_SINUSOID_AMPLITUDE * 2; + const maxYDistance = MAX_TANGLE_RADIUS * 2 + maxSinusoidAmplitude * 2; /* TODO: add sinusoidal distances */ @@ -257,7 +253,8 @@ export function calculateSinusoidalAmplitude({ * @returns the emitter position */ export function calculateEmitterPositionX(currentAnimationTime: number): number { - return currentAnimationTime * EMITTER_SPEED_MULTIPLIER; + const { emitterSpeedMultiplier } = getVisualizerConfigValues(); + return currentAnimationTime * emitterSpeedMultiplier; } /** @@ -287,7 +284,8 @@ export function positionToVector(position: IThreeDimensionalPosition) { export function generateRandomPeriods(): { periods: number[]; sum: number } { let sum = 0; const periods = Array.from({ length: NUMBER_OF_RANDOM_PERIODS }, () => { - const period = Number(randomNumberFromInterval(MIN_SINUSOID_PERIOD, MAX_SINUSOID_PERIOD).toFixed(4)); + const { minSinusoidPeriod, maxSinusoidPeriod } = getVisualizerConfigValues(); + const period = Number(randomNumberFromInterval(minSinusoidPeriod, maxSinusoidPeriod).toFixed(4)); sum += period; return period; }); @@ -317,18 +315,19 @@ function getCurrentPeriodValues(animationTime: number, periods: number[], totalS } function getNextAmplitudeWithVariation(currentAmplitude: number = 0): number { - const variation = (2 * MIN_SINUSOID_AMPLITUDE) / 3; + const { minSinusoidAmplitude, maxSinusoidAmplitude } = getVisualizerConfigValues(); + const variation = (2 * minSinusoidAmplitude) / 3; const randomAmplitudeVariation = randomNumberFromInterval(-variation, variation); let newAmplitude = currentAmplitude + randomAmplitudeVariation; - if (newAmplitude > MAX_SINUSOID_AMPLITUDE) { + if (newAmplitude > maxSinusoidAmplitude) { newAmplitude = currentAmplitude - Math.abs(randomAmplitudeVariation); - } else if (newAmplitude < MIN_SINUSOID_AMPLITUDE) { + } else if (newAmplitude < minSinusoidAmplitude) { newAmplitude = currentAmplitude + Math.abs(randomAmplitudeVariation); } - newAmplitude = Math.max(MIN_SINUSOID_AMPLITUDE, Math.min(newAmplitude, MAX_SINUSOID_AMPLITUDE)); + newAmplitude = Math.max(minSinusoidAmplitude, Math.min(newAmplitude, maxSinusoidAmplitude)); return newAmplitude; } @@ -346,9 +345,10 @@ export function generateRandomAmplitudes(): number[] { export function generateRandomTiltings(): number[] { let previousValue: number; + const { minTiltDegrees, maxTiltDegrees } = getVisualizerConfigValues(); const tilts: number[] = Array.from({ length: NUMBER_OF_RANDOM_TILTINGS }, () => { - let randomTilt = randomIntFromInterval(MIN_TILT_FACTOR_DEGREES, MAX_TILT_FACTOR_DEGREES); + let randomTilt = randomIntFromInterval(minTiltDegrees, maxTiltDegrees); if ((previousValue < 0 && randomTilt < 0) || (previousValue > 0 && randomTilt > 0)) { randomTilt *= -1; diff --git a/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx b/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx index ba7340a13..cdb10c622 100644 --- a/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx +++ b/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx @@ -5,6 +5,7 @@ import { INetwork } from "~/models/config/INetwork"; import KeyPanel from "./KeyPanel"; import mainHeader from "~assets/modals/visualizer/main-header.json"; import { SelectedFeedInfo } from "./SelectedFeedInfo"; +import ConfigControls from "../ConfigControls"; import useSearchStore from "~features/visualizer-threejs/store/search"; import { useTangleStore } from "~features/visualizer-threejs/store/tangle"; import { SEARCH_RESULT_COLOR } from "~features/visualizer-threejs/constants"; @@ -70,52 +71,62 @@ export const Wrapper = ({ }, [searchQuery]); return ( -
-
-
-

Visualizer

- -
-
-
-
Search
- { - setSearchQuery(e.target.value); - }} - maxLength={2000} - /> + <> +
+
+
+

Visualizer

+
-
-
-
- {children} -
-
- -
- {isEdgeRenderingEnabled !== undefined && setEdgeRenderingEnabled !== undefined && ( -
-

Show edges:

+
+
+
Search
setEdgeRenderingEnabled(checked)} + className="input form-input-long" + type="text" + value={searchQuery} + onChange={(e) => { + setSearchQuery(e.target.value); + }} + maxLength={2000} />
- )} +
+
+
+ {children} +
+
+ +
+ {isEdgeRenderingEnabled !== undefined && setEdgeRenderingEnabled !== undefined && ( +
+

Show edges:

+ setEdgeRenderingEnabled(checked)} + /> +
+ )} +
+ {selectedFeedItem && ( + + )} +
- - {selectedFeedItem && } - -
+
+ +
+ ); };