diff --git a/cypress/snapshots/app.cy.ts/auxspectrum.snap.png b/cypress/snapshots/app.cy.ts/auxspectrum.snap.png index 87581d5a0..1c57ea0f9 100644 Binary files a/cypress/snapshots/app.cy.ts/auxspectrum.snap.png and b/cypress/snapshots/app.cy.ts/auxspectrum.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/nxspectrum.snap.png b/cypress/snapshots/app.cy.ts/nxspectrum.snap.png index 9a2c266d3..f34de5b44 100644 Binary files a/cypress/snapshots/app.cy.ts/nxspectrum.snap.png and b/cypress/snapshots/app.cy.ts/nxspectrum.snap.png differ diff --git a/packages/lib/src/vis/line/DataCurve.tsx b/packages/lib/src/vis/line/DataCurve.tsx index 66f75fb7b..12b80b692 100644 --- a/packages/lib/src/vis/line/DataCurve.tsx +++ b/packages/lib/src/vis/line/DataCurve.tsx @@ -1,14 +1,21 @@ import type { NumArray } from '@h5web/shared'; -import { extend, useThree } from '@react-three/fiber'; import type { Object3DNode, ThreeEvent } from '@react-three/fiber'; -import { useCallback, useLayoutEffect, useState } from 'react'; +import { extend, useThree } from '@react-three/fiber'; +import { useCallback, useLayoutEffect, useMemo } from 'react'; import { BufferGeometry, Line } from 'three'; +import { useVisCanvasContext } from '../shared/VisCanvasProvider'; +import { createBufferAttr } from '../utils'; import ErrorBars from './ErrorBars'; import GlyphMaterial from './GlyphMaterial'; -import { useCanvasPoints } from './hooks'; +import { useValueToErrorPositions, useValueToPosition } from './hooks'; import { CurveType, GlyphType } from './models'; +/* Render points with NaN/Infinity coordinates (i.e. values <= 0 in log) + * at origin to avoid Three warning, and outside of camera's field of view + * to hide them and any segments connecting them. */ +const CAMERA_FAR = 1000; // R3F's default + extend({ Line_: Line }); // https://github.com/pmndrs/react-three-fiber/issues/1152 @@ -54,15 +61,106 @@ function DataCurve(props: Props) { ignoreValue, } = props; - const [dataGeometry] = useState(() => new BufferGeometry()); - const points = useCanvasPoints(abscissas, ordinates, errors, ignoreValue); + const { length } = ordinates; + const hasErrors = !!errors; + const { abscissaScale, ordinateScale } = useVisCanvasContext(); const invalidate = useThree((state) => state.invalidate); + const valueToPosition = useValueToPosition( + abscissas, + abscissaScale, + ordinateScale, + ignoreValue + ); + + const valueToErrorPositions = useValueToErrorPositions(errors, ordinateScale); + + const dataGeometry = useMemo(() => { + const geometry = new BufferGeometry(); + geometry.setAttribute('position', createBufferAttr(length, 3)); + return geometry; + }, [length]); + + const errorGeometries = useMemo(() => { + if (!hasErrors) { + return undefined; + } + + const geometries = { + caps: new BufferGeometry(), + bars: new BufferGeometry(), + }; + + geometries.caps.setAttribute('position', createBufferAttr(length * 2, 3)); + geometries.bars.setAttribute('position', createBufferAttr(length * 2, 3)); + + return geometries; + }, [hasErrors, length]); + + // eslint-disable-next-line sonarjs/cognitive-complexity useLayoutEffect(() => { - dataGeometry.setFromPoints(points.data); + const { position: dataPosition } = dataGeometry.attributes; + const errorPositions = errorGeometries && { + caps: errorGeometries.caps.attributes.position, + bars: errorGeometries.bars.attributes.position, + }; + + ordinates.forEach((value, index) => { + const pos = valueToPosition(value, index); + + if (pos) { + dataPosition.setXYZ(index, pos[0], pos[1], 0); + } else { + dataPosition.setXYZ(index, 0, 0, CAMERA_FAR); + } + + if (!errorPositions) { + return; + } + + const { topCap, bottomCap, bar } = valueToErrorPositions( + value, + index, + pos + ); + + if (topCap) { + errorPositions.caps.setXYZ(index * 2 + 1, topCap[0], topCap[1], 0); + } else { + errorPositions.caps.setXYZ(index * 2 + 1, 0, 0, CAMERA_FAR); + } + + if (bottomCap) { + errorPositions.caps.setXYZ(index * 2, bottomCap[0], bottomCap[1], 0); + } else { + errorPositions.caps.setXYZ(index * 2, 0, 0, CAMERA_FAR); + } + + if (bar) { + errorPositions.bars.setXYZ(index * 2, bar[0], bar[1], 0); + errorPositions.bars.setXYZ(index * 2 + 1, bar[2], bar[3], 0); + } else { + errorPositions.bars.setXYZ(index * 2, 0, 0, CAMERA_FAR); + errorPositions.bars.setXYZ(index * 2 + 1, 0, 0, CAMERA_FAR); + } + }); + dataGeometry.computeBoundingSphere(); + dataPosition.needsUpdate = true; + if (errorPositions) { + errorPositions.caps.needsUpdate = true; + errorPositions.bars.needsUpdate = true; + } + invalidate(); - }, [dataGeometry, invalidate, points.data]); + }, [ + ordinates, + dataGeometry, + errorGeometries, + valueToErrorPositions, + valueToPosition, + invalidate, + ]); const handleClick = useCallback( (evt: ThreeEvent) => { @@ -114,12 +212,11 @@ function DataCurve(props: Props) { > - {showErrors && errors && ( + {errorGeometries && ( )} diff --git a/packages/lib/src/vis/line/ErrorBars.tsx b/packages/lib/src/vis/line/ErrorBars.tsx index 42e758843..b27d4e77d 100644 --- a/packages/lib/src/vis/line/ErrorBars.tsx +++ b/packages/lib/src/vis/line/ErrorBars.tsx @@ -1,42 +1,29 @@ -import { useThree } from '@react-three/fiber'; -import { useLayoutEffect, useRef } from 'react'; -import type { BufferGeometry, Vector3 } from 'three'; +import type { BufferGeometry } from 'three'; import GlyphMaterial from './GlyphMaterial'; import { GlyphType } from './models'; +export interface ErrorGeometries { + bars: BufferGeometry; + caps: BufferGeometry; +} + interface Props { - capsPoints: Vector3[]; - barsSegments: Vector3[]; + geometries: ErrorGeometries; color: string; visible?: boolean; } function ErrorBars(props: Props) { - const { barsSegments, capsPoints, color, visible } = props; - const invalidate = useThree((state) => state.invalidate); - - const barsGeometry = useRef(null); - useLayoutEffect(() => { - barsGeometry.current?.setFromPoints(barsSegments); - invalidate(); - }, [barsGeometry, barsSegments, invalidate]); - - const capsGeometry = useRef(null); - useLayoutEffect(() => { - capsGeometry.current?.setFromPoints(capsPoints); - invalidate(); - }, [capsGeometry, capsPoints, invalidate]); + const { geometries, color, visible } = props; return ( <> - - - + + - + - ); diff --git a/packages/lib/src/vis/line/hooks.ts b/packages/lib/src/vis/line/hooks.ts index d4184efde..fab114c67 100644 --- a/packages/lib/src/vis/line/hooks.ts +++ b/packages/lib/src/vis/line/hooks.ts @@ -1,60 +1,6 @@ -import type { NumArray } from '@h5web/shared'; -import { useMemo } from 'react'; -import { Vector3 } from 'three'; +import { createMemo } from '@h5web/shared'; -import { useVisCanvasContext } from '../shared/VisCanvasProvider'; +import { getValueToErrorPositions, getValueToPosition } from './utils'; -const CAMERA_FAR = 1000; // R3F's default - -export function useCanvasPoints( - abscissas: NumArray, - ordinates: NumArray, - errors?: NumArray, - ignoreValue?: (val: number) => boolean -) { - const { abscissaScale, ordinateScale } = useVisCanvasContext(); - - return useMemo(() => { - const dataPoints: Vector3[] = []; - const errorBarSegments: Vector3[] = []; - const errorCapPoints: Vector3[] = []; - - ordinates.forEach((val, index) => { - const x = abscissaScale(abscissas[index]); - const y = ordinateScale(val); - - const hasFiniteCoords = - !ignoreValue?.(val) && Number.isFinite(x) && Number.isFinite(y); - const dataVector = hasFiniteCoords - ? new Vector3(x, y, 0) - : /* Render points with NaN/Infinity coordinates (i.e. values <= 0 in log) - * at origin to avoid Three warning, and outside of camera's field of view - * to hide them and any segments connecting them. */ - new Vector3(0, 0, CAMERA_FAR); - - dataPoints.push(dataVector); - - const error = errors?.[index]; - if (!error || !hasFiniteCoords) { - return; - } - - const yErrBottom = ordinateScale(val - error); - const yErrTop = ordinateScale(val + error); - - if (Number.isFinite(yErrBottom)) { - const errBottomVector = new Vector3(x, yErrBottom, 0); - errorBarSegments.push(errBottomVector, dataVector); - errorCapPoints.push(errBottomVector); - } - - if (Number.isFinite(yErrTop)) { - const errTopVector = new Vector3(x, yErrTop, 0); - errorBarSegments.push(dataVector, errTopVector); - errorCapPoints.push(errTopVector); - } - }); - - return { data: dataPoints, bars: errorBarSegments, caps: errorCapPoints }; - }, [abscissaScale, abscissas, errors, ordinateScale, ordinates, ignoreValue]); -} +export const useValueToPosition = createMemo(getValueToPosition); +export const useValueToErrorPositions = createMemo(getValueToErrorPositions); diff --git a/packages/lib/src/vis/line/utils.ts b/packages/lib/src/vis/line/utils.ts new file mode 100644 index 000000000..f14354d65 --- /dev/null +++ b/packages/lib/src/vis/line/utils.ts @@ -0,0 +1,72 @@ +import type { NumArray } from '@h5web/shared'; + +import type { AxisScale } from '../models'; + +const NO_ERROR_POSITIONS = { + topCap: undefined, + bottomCap: undefined, + bar: undefined, +}; + +export function getValueToPosition( + abscissas: NumArray, + abscissaScale: AxisScale, + ordinateScale: AxisScale, + ignoreValue?: (val: number) => boolean +): (value: number, index: number) => [number, number] | undefined { + return (value: number, index: number) => { + const x = abscissaScale(abscissas[index]); + const y = ordinateScale(value); + + const isIgnored = ignoreValue ? ignoreValue(value) : false; + const hasFiniteCoords = Number.isFinite(x) && Number.isFinite(y); + + return !isIgnored && hasFiniteCoords ? [x, y] : undefined; + }; +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +export function getValueToErrorPositions( + errors: NumArray | undefined, + ordinateScale: AxisScale +): ( + value: number, + index: number, + position: [number, number] | undefined +) => { + topCap: [number, number] | undefined; + bottomCap: [number, number] | undefined; + bar: [number, number, number, number] | undefined; +} { + if (!errors) { + return () => NO_ERROR_POSITIONS; + } + + return (value, index, position) => { + if (!position) { + return NO_ERROR_POSITIONS; + } + + const [x, y] = position; + const error = errors[index]; + if (error < 0) { + return NO_ERROR_POSITIONS; + } + + const yCapTop = ordinateScale(value + error); + const yCapBottom = ordinateScale(value - error); + + const showTopCap = Number.isFinite(yCapTop); + const showBottomCap = Number.isFinite(yCapBottom); + + const yBarTop = showTopCap ? yCapTop : y; + const yBarBottom = showBottomCap ? yCapBottom : y; + const showBar = showTopCap || showBottomCap; + + return { + topCap: showTopCap ? [x, yCapTop] : undefined, + bottomCap: showBottomCap ? [x, yCapBottom] : undefined, + bar: showBar ? [x, yBarTop, x, yBarBottom] : undefined, + }; + }; +}