diff --git a/apps/storybook/src/DataCurve.stories.tsx b/apps/storybook/src/DataCurve.stories.tsx index 5c4aa9744..0f204539a 100644 --- a/apps/storybook/src/DataCurve.stories.tsx +++ b/apps/storybook/src/DataCurve.stories.tsx @@ -4,6 +4,7 @@ import { DataCurve, DefaultInteractions, GlyphType as GlyphTypeEnum, + Interpolation, mockValues, ScaleType, useDomain, @@ -84,6 +85,28 @@ export const Color = { }, } satisfies Story; +export const Width = { + ...Default, + args: { + width: 3, + }, +} satisfies Story; + +export const ConstantInterpolation = { + ...Default, + args: { + interpolation: Interpolation.Constant, + }, +} satisfies Story; + +export const ConstantWithWidth = { + ...Default, + args: { + width: 3, + interpolation: Interpolation.Constant, + }, +} satisfies Story; + export const Glyphs = { ...Default, args: { diff --git a/apps/storybook/src/Line.stories.tsx b/apps/storybook/src/Line.stories.tsx index 5b0a815e0..afc9ff48e 100644 --- a/apps/storybook/src/Line.stories.tsx +++ b/apps/storybook/src/Line.stories.tsx @@ -1,5 +1,6 @@ import { DefaultInteractions, + Interpolation, Line, mockValues, useDomain, @@ -72,6 +73,28 @@ export const Color = { }, } satisfies Story; +export const Width = { + ...Default, + args: { + width: 3, + }, +} satisfies Story; + +export const ConstantInterpolation = { + ...Default, + args: { + interpolation: Interpolation.Constant, + }, +} satisfies Story; + +export const ConstantWithWidth = { + ...Default, + args: { + width: 3, + interpolation: Interpolation.Constant, + }, +} satisfies Story; + export const IgnoreValue = { ...Default, args: { diff --git a/cypress/snapshots/app.cy.ts/auxspectrum.snap.png b/cypress/snapshots/app.cy.ts/auxspectrum.snap.png index b37fb5acc..d5c8db56b 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/fillvalue_1D.snap.png b/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png index 0f12d06be..20602d4d0 100644 Binary files a/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png and b/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/line_1D.snap.png b/cypress/snapshots/app.cy.ts/line_1D.snap.png index 31dc13795..6a240ff78 100644 Binary files a/cypress/snapshots/app.cy.ts/line_1D.snap.png and b/cypress/snapshots/app.cy.ts/line_1D.snap.png differ diff --git a/packages/lib/src/vis/line/DataCurve.tsx b/packages/lib/src/vis/line/DataCurve.tsx index 7eb7f7e9a..0c7b97ce4 100644 --- a/packages/lib/src/vis/line/DataCurve.tsx +++ b/packages/lib/src/vis/line/DataCurve.tsx @@ -1,8 +1,6 @@ import { type IgnoreValue, type NumArray } from '@h5web/shared/vis-models'; -import { - type LineBasicMaterialProps, - type ThreeEvent, -} from '@react-three/fiber'; +import { type ThreeEvent } from '@react-three/fiber'; +import { type LineMaterialParameters } from 'three/addons/lines/LineMaterial.js'; import ErrorBars from './ErrorBars'; import Glyphs from './Glyphs'; @@ -17,9 +15,11 @@ interface Props { showErrors?: boolean; color: string; curveType?: CurveType; + width?: number; + interpolation?: Interpolation; glyphType?: GlyphType; glyphSize?: number; - materialProps?: LineBasicMaterialProps; + materialProps?: LineMaterialParameters; visible?: boolean; onLineClick?: (index: number, event: ThreeEvent) => void; onLineEnter?: (index: number, event: ThreeEvent) => void; @@ -28,7 +28,6 @@ interface Props { onDataPointEnter?: (index: number, evt: ThreeEvent) => void; onDataPointLeave?: (index: number, evt: ThreeEvent) => void; ignoreValue?: IgnoreValue; - interpolation?: Interpolation; } function DataCurve(props: Props) { @@ -38,6 +37,8 @@ function DataCurve(props: Props) { errors, showErrors, color, + width, + interpolation, curveType = CurveType.LineOnly, glyphType = GlyphType.Cross, glyphSize = 6, @@ -50,7 +51,6 @@ function DataCurve(props: Props) { onDataPointEnter, onDataPointLeave, ignoreValue, - interpolation, } = props; return ( @@ -59,9 +59,10 @@ function DataCurve(props: Props) { abscissas={abscissas} ordinates={ordinates} color={color} + width={width} + interpolation={interpolation} ignoreValue={ignoreValue} materialProps={materialProps} - interpolation={interpolation} visible={curveType !== CurveType.GlyphsOnly && visible} onClick={useEventHandler(onLineClick)} onPointerEnter={useEventHandler(onLineEnter)} diff --git a/packages/lib/src/vis/line/Line.tsx b/packages/lib/src/vis/line/Line.tsx index 278bea433..5646876dc 100644 --- a/packages/lib/src/vis/line/Line.tsx +++ b/packages/lib/src/vis/line/Line.tsx @@ -1,11 +1,11 @@ import { type IgnoreValue, type NumArray } from '@h5web/shared/vis-models'; -import { - extend, - type LineBasicMaterialProps, - type Object3DNode, -} from '@react-three/fiber'; +import { extend, type Object3DNode } from '@react-three/fiber'; import { useMemo } from 'react'; -import { Line as Line_ } from 'three'; +import { Line2 } from 'three/addons/lines/Line2.js'; +import { + LineMaterial, + type LineMaterialParameters, +} from 'three/addons/lines/LineMaterial.js'; import { useUpdateGeometry } from '../hooks'; import { useVisCanvasContext } from '../shared/VisCanvasProvider'; @@ -14,23 +14,23 @@ import LineConstantGeometry from './lineConstantGeometry'; import LineGeometry from './lineGeometry'; import { Interpolation } from './models'; -// Alias Three's `Line` to `Line_` to avoid conflict with SVG `line` in JSX -// https://docs.pmnd.rs/react-three-fiber/tutorials/typescript#extending-threeelements -extend({ Line_ }); +extend({ Line2, LineMaterial }); declare module '@react-three/fiber' { interface ThreeElements { - line_: Object3DNode; + line2: Object3DNode; + lineMaterial: Object3DNode; } } -interface Props extends Object3DNode { +interface Props extends Object3DNode { abscissas: NumArray; ordinates: NumArray; color: string; - materialProps?: LineBasicMaterialProps; + width?: number; + interpolation?: Interpolation; + materialProps?: LineMaterialParameters; visible?: boolean; ignoreValue?: IgnoreValue; - interpolation?: Interpolation; } function Line(props: Props) { @@ -38,10 +38,11 @@ function Line(props: Props) { abscissas, ordinates, color, + interpolation, + width = 1, materialProps = {}, visible = true, ignoreValue, - interpolation, ...lineProps } = props; @@ -70,9 +71,9 @@ function Line(props: Props) { }); return ( - - - + + + ); } diff --git a/packages/lib/src/vis/line/lineConstantGeometry.ts b/packages/lib/src/vis/line/lineConstantGeometry.ts index 9cb31dc97..b356437a3 100644 --- a/packages/lib/src/vis/line/lineConstantGeometry.ts +++ b/packages/lib/src/vis/line/lineConstantGeometry.ts @@ -1,58 +1,84 @@ -import { type BufferAttribute, BufferGeometry } from 'three'; +import { + type BufferAttribute, + InstancedInterleavedBuffer, + InterleavedBufferAttribute, +} from 'three'; +import { LineGeometry as ThreeLineGeometry } from 'three/addons/lines/LineGeometry.js'; import { type H5WebGeometry } from '../models'; import { createBufferAttr, Z_OUT } from '../utils'; import { type LineGeometryParams } from './lineGeometry'; -class LineConstantGeometry - extends BufferGeometry> - implements H5WebGeometry -{ +class LineConstantGeometry extends ThreeLineGeometry implements H5WebGeometry { + private readonly length: number; + private readonly positions: BufferAttribute; + public constructor(private readonly params: LineGeometryParams) { super(); - const { length } = params.ordinates; - this.setAttribute('position', createBufferAttr(2 * length - 1)); + + this.length = params.ordinates.length; + this.positions = createBufferAttr((this.length - 1) * 4); // two segments per point, two positions per segment + + // Parts of `LineGeometry#setPositions` that don't need to be called on every update + // https://github.com/mrdoob/three.js/blob/40728556ec00833b54be287807cd6fb04a897313/examples/jsm/lines/LineSegmentsGeometry.js#L97 + const { array } = this.positions; + const instancedBuffer = new InstancedInterleavedBuffer(array, 2 * 3); // two sets of `xyz` coords per line segment + const startAttr = new InterleavedBufferAttribute(instancedBuffer, 3, 0); + const endAttr = new InterleavedBufferAttribute(instancedBuffer, 3, 3); + this.setAttribute('instanceStart', startAttr); + this.setAttribute('instanceEnd', endAttr); + this.instanceCount = startAttr.count; } public update(): void { - const { position: positions } = this.attributes; const { abscissas, ordinates, abscissaScale, ordinateScale, ignoreValue } = this.params; for (const [index, value] of ordinates.entries()) { - const posIndex = 2 * index; + const posIndex = index * 4; - if (ignoreValue?.(value)) { - positions.setXYZ(posIndex, 0, 0, Z_OUT); - positions.setXYZ(posIndex + 1, 0, 0, Z_OUT); + if (index >= this.length - 1) { + this.setInvalidSegments(posIndex); continue; } - const x = abscissaScale(abscissas[index]); - const y = ordinateScale(value); + const nextValue = ordinates[index + 1]; - if (!Number.isFinite(x) || !Number.isFinite(y)) { - positions.setXYZ(posIndex, 0, 0, Z_OUT); - positions.setXYZ(posIndex + 1, 0, 0, Z_OUT); + if (ignoreValue?.(value) || ignoreValue?.(nextValue)) { + this.setInvalidSegments(posIndex); continue; } - positions.setXYZ(posIndex, x, y, 0); + const x = abscissaScale(abscissas[index]); + const y = ordinateScale(value); + const nextX = abscissaScale(abscissas[index + 1]); + const nextY = ordinateScale(nextValue); - if (index >= abscissas.length - 1) { - positions.setXYZ(posIndex + 1, 0, 0, Z_OUT); + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(nextX) || + !Number.isFinite(nextY) + ) { + this.setInvalidSegments(posIndex); continue; } - const nextX = abscissaScale(abscissas[index + 1]); - const nextY = ordinateScale(ordinates[index + 1]); - if (!Number.isFinite(nextX) || !Number.isFinite(nextY)) { - positions.setXYZ(posIndex + 1, 0, 0, Z_OUT); - continue; - } - positions.setXYZ(posIndex + 1, nextX, y, 0); + this.positions.setXYZ(posIndex, x, y, 0); + this.positions.setXYZ(posIndex + 1, nextX, y, 0); + + this.positions.setXYZ(posIndex + 2, nextX, y, 0); + this.positions.setXYZ(posIndex + 3, nextX, nextY, 0); } } + + private setInvalidSegments(posIndex: number): void { + this.positions.setXYZ(posIndex, 0, 0, Z_OUT); + this.positions.setXYZ(posIndex + 1, 0, 0, Z_OUT); + + this.positions.setXYZ(posIndex + 2, 0, 0, Z_OUT); + this.positions.setXYZ(posIndex + 3, 0, 0, Z_OUT); + } } export default LineConstantGeometry; diff --git a/packages/lib/src/vis/line/lineGeometry.ts b/packages/lib/src/vis/line/lineGeometry.ts index 9287cc6aa..2941bff73 100644 --- a/packages/lib/src/vis/line/lineGeometry.ts +++ b/packages/lib/src/vis/line/lineGeometry.ts @@ -1,5 +1,10 @@ import { type IgnoreValue, type NumArray } from '@h5web/shared/vis-models'; -import { type BufferAttribute, BufferGeometry } from 'three'; +import { + type BufferAttribute, + InstancedInterleavedBuffer, + InterleavedBufferAttribute, +} from 'three'; +import { LineGeometry as ThreeLineGeometry } from 'three/addons/lines/LineGeometry.js'; import { type AxisScale, type H5WebGeometry } from '../models'; import { createBufferAttr, Z_OUT } from '../utils'; @@ -12,38 +17,70 @@ interface Params { ignoreValue?: IgnoreValue; } -class LineGeometry - extends BufferGeometry> - implements H5WebGeometry -{ +class LineGeometry extends ThreeLineGeometry implements H5WebGeometry { + private readonly length: number; + private readonly positions: BufferAttribute; + public constructor(private readonly params: Params) { super(); - const { length } = params.ordinates; - this.setAttribute('position', createBufferAttr(length)); + + this.length = params.ordinates.length; + this.positions = createBufferAttr((this.length - 1) * 2); // two positions per line segment + + // Parts of `LineGeometry#setPositions` that don't need to be called on every update + // https://github.com/mrdoob/three.js/blob/40728556ec00833b54be287807cd6fb04a897313/examples/jsm/lines/LineSegmentsGeometry.js#L97 + const { array } = this.positions; + const instancedBuffer = new InstancedInterleavedBuffer(array, 2 * 3); // two sets of `xyz` coords per line segment + const startAttr = new InterleavedBufferAttribute(instancedBuffer, 3, 0); + const endAttr = new InterleavedBufferAttribute(instancedBuffer, 3, 3); + this.setAttribute('instanceStart', startAttr); + this.setAttribute('instanceEnd', endAttr); + this.instanceCount = startAttr.count; } public update(): void { - const { position: positions } = this.attributes; const { abscissas, ordinates, abscissaScale, ordinateScale, ignoreValue } = this.params; for (const [index, value] of ordinates.entries()) { - if (ignoreValue?.(value)) { - positions.setXYZ(index, 0, 0, Z_OUT); + const posIndex = index * 2; + + if (index >= this.length - 1) { + this.setInvalidSegment(posIndex); + continue; + } + + const nextValue = ordinates[index + 1]; + + if (ignoreValue?.(value) || ignoreValue?.(nextValue)) { + this.setInvalidSegment(posIndex); continue; } const x = abscissaScale(abscissas[index]); const y = ordinateScale(value); + const nextX = abscissaScale(abscissas[index + 1]); + const nextY = ordinateScale(nextValue); - if (!Number.isFinite(x) || !Number.isFinite(y)) { - positions.setXYZ(index, 0, 0, Z_OUT); + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(nextX) || + !Number.isFinite(nextY) + ) { + this.setInvalidSegment(posIndex); continue; } - positions.setXYZ(index, x, y, 0); + this.positions.setXYZ(posIndex, x, y, 0); + this.positions.setXYZ(posIndex + 1, nextX, nextY, 0); } } + + private setInvalidSegment(posIndex: number): void { + this.positions.setXYZ(posIndex, 0, 0, Z_OUT); + this.positions.setXYZ(posIndex + 1, 0, 0, Z_OUT); + } } export { type Params as LineGeometryParams };