diff --git a/src/component/1d/BaselinePreview.tsx b/src/component/1d/BaselinePreview.tsx new file mode 100644 index 0000000000..a28ac00ddf --- /dev/null +++ b/src/component/1d/BaselinePreview.tsx @@ -0,0 +1,254 @@ +import styled from '@emotion/styled'; +import type { Spectrum1D } from '@zakodium/nmrium-core'; +import type { DoubleArray } from 'cheminfo-types'; +import { xFindClosestIndex } from 'ml-spectra-processing'; +import { useMemo, useRef, useState } from 'react'; + +import { isSpectrum1D } from '../../data/data1d/Spectrum1D/isSpectrum1D.ts'; +import { useChartData } from '../context/ChartContext.tsx'; +import { useScaleChecked } from '../context/ScaleContext.tsx'; +import { Anchor } from '../elements/Anchor.tsx'; +import { useActiveSpectrum } from '../hooks/useActiveSpectrum.ts'; +import { useIndicatorLineColor } from '../hooks/useIndicatorLineColor.ts'; +import useSpectrum from '../hooks/useSpectrum.ts'; +import { PathBuilder } from '../utility/PathBuilder.ts'; + +interface AnchorData { + x: number; + id: string; +} + +interface AnchorsProps { + spectrum: Spectrum1D; + initialAnchors: AnchorData[]; + onAnchorsChange: (anchors: AnchorData[]) => void; +} + +const SVGWrapper = styled.svg` + position: absolute; + width: 100%; + left: 0; + top: 0; + height: 100%; + overflow: hidden; + pointer-events: none; +`; + +const Container = styled.div` + position: absolute; + width: 100%; + left: 0; + top: 0; + height: 100%; + overflow: hidden; + pointer-events: none; +`; + +function Anchors(props: AnchorsProps) { + const containerRef = useRef(null); + const { spectrum, initialAnchors, onAnchorsChange } = props; + const [anchors, updateAnchors] = useState(initialAnchors); + const { scaleX, scaleY, shiftY } = useScaleChecked(); + + function handleDragMove(id: string, newX: number) { + updateAnchors((prev) => + prev.map((a) => (a.id === id ? { ...a, x: scaleX().invert(newX) } : a)), + ); + } + + function handleDragEnd(id: string) { + updateAnchors((prev) => { + const next = prev.map((a) => { + if (a.id !== id) return a; + return a; + }); + onAnchorsChange(next); + return next; + }); + } + + function handleDelete(id: string) { + updateAnchors((prev) => { + const next = prev.filter((a) => a.id !== id); + onAnchorsChange(next); + return next; + }); + } + const activeSpectrum = useActiveSpectrum(); + + return ( + <> + + + {anchors.map((anchor) => { + const { x: xPPM } = anchor; + const x = scaleX()(xPPM); + const yPPM = getMedianY(xPPM, spectrum); + const v = shiftY * (activeSpectrum?.index || 0); + const y = scaleY(spectrum.id)(yPPM) - v; + + return ( + handleDragMove(anchor.id, x)} + onDragEnd={() => handleDragEnd(anchor.id)} + onDelete={() => handleDelete(anchor.id)} + anchorStyle={{ size: 10 }} + /> + ); + })} + + + ); +} + +const INITIAL_ANCHORS = [ + { + id: 'a1', + x: 1, + }, + { id: 'a2', x: 5 }, + { id: 'a3', x: 8 }, + { id: 'a4', x: 7 }, +]; + +export function BaselinePreview() { + const [globalAnchors, setGlobalAnchors] = useState(INITIAL_ANCHORS); + const spectrum = useSpectrum(); + const activeSpectrum = useActiveSpectrum(); + const { + toolOptions: { selectedTool }, + } = useChartData(); + + function handleGlobalChange(updated: any) { + setGlobalAnchors(updated); + } + + if ( + !isSpectrum1D(spectrum) || + selectedTool !== 'baselineCorrection' || + !activeSpectrum + ) { return; } + /** + * TODO: Apply the baseline correction on the fly and pass the anchors along with the newly processed spectrum, + * where the y-value of each anchor is also calculated on the fly. + * This removes the need to store the y-value in the filter anchors. + * Preview the spectrum after applying the baseline correction method + */ + + return ( + + ); +} + +function getMedianY(x: number, spectrum: Spectrum1D, windowSize = 20): number { + const { x: xValues, re: yValues } = spectrum.data; + + const centerIndex = xFindClosestIndex(xValues, x); + const halfWindow = Math.floor(windowSize / 2); + + const fromIndex = Math.max(0, centerIndex - halfWindow); + const toIndex = Math.min(xValues.length, centerIndex + halfWindow + 1); + + const yWindow = yValues.slice(fromIndex, toIndex); + + if (yWindow.length === 0) return 0; + + return getMedian(yWindow); +} + +function getMedian(values: DoubleArray): number { + const sorted = values.toSorted((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const isOdd = sorted.length % 2 !== 0; + + if (isOdd) { + return sorted[mid]; + } + + return (sorted[mid - 1] + sorted[mid]) / 2; +} + +function generatePreviewData( + spectrum: Spectrum1D, + anchors: AnchorData[], +): { x: Float64Array; y: Float64Array } { + const { x: xValues } = spectrum.data; + const length = xValues.length; + + const previewX = new Float64Array(xValues); + const previewY = new Float64Array(length); + + const sorted = anchors.toSorted((a, b) => a.x - b.x); + + if (sorted.length < 2) return { x: previewX, y: previewY }; + + const mappedAnchors = sorted.map((a) => ({ + index: xFindClosestIndex(xValues, a.x), + y: getMedianY(a.x, spectrum), + })); + + for (let p = 0; p < mappedAnchors.length - 1; p++) { + const { index, y: fromY } = mappedAnchors[p]; + const { index: nextIndex, y: toY } = mappedAnchors[p + 1]; + const span = nextIndex - index; + + for (let i = index; i <= nextIndex; i++) { + const t = span === 0 ? 0 : (i - index) / span; + previewY[i] = fromY + t * (toY - fromY); + } + } + return { x: previewX, y: previewY }; +} + +interface SpectrumPreviewProps { + spectrum: Spectrum1D; + anchors: AnchorData[]; +} + +function SpectrumPreview({ spectrum, anchors }: SpectrumPreviewProps) { + const { scaleX, scaleY, shiftY } = useScaleChecked(); + const activeSpectrum = useActiveSpectrum(); + const indicatorColor = useIndicatorLineColor(); + + const paths = useMemo(() => { + const data = generatePreviewData(spectrum, anchors); + const _scaleX = scaleX(); + const _scaleY = scaleY(spectrum.id); + + const pathBuilder = new PathBuilder(); + + if (!data.x || !data.y || !_scaleX(0)) return ''; + + const v = shiftY * (activeSpectrum?.index || 0); + + const firstX = _scaleX(data.x[0]); + const firstY = _scaleY(data.y[0]) - v; + pathBuilder.moveTo(firstX, firstY); + + for (let i = 1; i < data.x.length; i++) { + const x = _scaleX(data.x[i]); + const y = _scaleY(data.y[i]) - v; + pathBuilder.lineTo(x, y); + } + + return pathBuilder.toString(); + }, [spectrum, anchors, scaleX, scaleY, shiftY, activeSpectrum?.index]); + return ( + + + + ); +} diff --git a/src/component/1d/Viewer1D.tsx b/src/component/1d/Viewer1D.tsx index 1830200578..7fbc5fdd50 100644 --- a/src/component/1d/Viewer1D.tsx +++ b/src/component/1d/Viewer1D.tsx @@ -10,6 +10,7 @@ import { useChartData } from '../context/ChartContext.js'; import { ScaleProvider } from '../context/ScaleContext.js'; import Spinner from '../loader/Spinner.js'; +import { BaselinePreview } from './BaselinePreview.tsx'; import { BrushTracker1D } from './BrushTracker1D.js'; import FooterBanner from './FooterBanner.js'; import { SVGContent1D } from './SVGContent1D.js'; @@ -61,6 +62,7 @@ function InnerViewer1D(props: InnerViewer1DProps) { )} + )} diff --git a/src/component/1d/tool/BaseLine.tsx b/src/component/1d/tool/BaseLine.tsx index 74f60edfc2..d9533f470c 100644 --- a/src/component/1d/tool/BaseLine.tsx +++ b/src/component/1d/tool/BaseLine.tsx @@ -23,11 +23,7 @@ function BaseLine() { const indicatorColor = useIndicatorLineColor(); const innerWidth = width - left - right; - if ( - ![options.phaseCorrection.id, options.baselineCorrection.id].includes( - selectedTool, - ) - ) { + if (![options.phaseCorrection.id].includes(selectedTool)) { return null; } return ( diff --git a/src/component/elements/Anchor.tsx b/src/component/elements/Anchor.tsx new file mode 100644 index 0000000000..d2b9e02898 --- /dev/null +++ b/src/component/elements/Anchor.tsx @@ -0,0 +1,358 @@ +import styled from '@emotion/styled'; +import { useRef, useState } from 'react'; + +const CUR_MOVE = `url("position:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='12' viewBox='0 0 22 12'%3E%3Cpath d='M1 6h20M1 6l4-4M1 6l4 4M21 6l-4-4M21 6l-4 4' fill='none' stroke='black' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 11 6, ew-resize`; + +type AnchorShape = 'diamond' | 'circle' | 'square' | 'triangle'; + +interface AnchorStyle { + /** Shape size in px. Default: 16 */ + size?: number; + /** Default: "rgba(255,255,255,0.85)" */ + fill?: string; + /** Default: "#7a8fa6" */ + stroke?: string; + /** Default: "rgba(210,220,235,0.6)" */ + hoverFill?: string; + /** Default: "#7a8fa6" */ + hoverStroke?: string; + /** Default: "rgba(210,220,235,0.8)" */ + dragFill?: string; + /** Default: "#7a8fa6" */ + dragStroke?: string; + /** Stroke width in px. Default: 1.2 */ + strokeWidth?: number; + /** Default: "rgba(74,111,168,0.5)" */ + guideColor?: string; + /** Default: "rgba(42,82,160,0.75)" */ + guideDragColor?: string; + /** Default: "solid" */ + guideStyle?: 'dashed' | 'dotted' | 'solid'; +} + +interface AnchorPosition { + x: number; + y: number; +} + +interface AnchorProps { + position: AnchorPosition; + shape?: AnchorShape; + containerRef: React.RefObject; + anchorStyle?: AnchorStyle; + restoreFocusOnLeave?: boolean; + onDragMove: (newX: number) => void; + onDragEnd: (lastX: number) => void; + onDelete: () => void; +} + +interface DragState { + startX: number; + startAnchorX: number; +} + +type InteractionState = 'idle' | 'hovered' | 'dragging'; + +interface VisualTokens { + fill: string; + stroke: string; + cursor: string; +} + +function resolveVisuals( + state: InteractionState, + idleFill: string, + idleStroke: string, + hoverFill: string, + hoverStroke: string, + dragFill: string, + dragStroke: string, +): VisualTokens { + if (state === 'dragging') { + return { fill: dragFill, stroke: dragStroke, cursor: CUR_MOVE }; + } + if (state === 'hovered') { + return { fill: hoverFill, stroke: hoverStroke, cursor: 'default' }; + } + return { fill: idleFill, stroke: idleStroke, cursor: 'default' }; +} + +const AnchorItem = styled.div<{ cursor: string }>` + position: absolute; + width: 50px; + height: 50px; + cursor: ${({ cursor }) => cursor}; + display: flex; + align-items: center; + justify-content: center; + outline: none; + pointer-events: all; +`; + +const HoverEffect = styled.div<{ + isDragging: boolean; + stroke: string; + size: number; +}>` + position: absolute; + border-radius: 50%; + width: ${({ size }) => size}px; + height: ${({ size }) => size}px; + background: radial-gradient( + circle, + ${({ stroke }) => stroke} 0%, + transparent 50% + ); + opacity: ${({ isDragging }) => (isDragging ? 0.2 : 0.15)}; + pointer-events: none; +`; + +const VerticalGuideLine = styled.div<{ color: string; lineStyle: string }>` + position: absolute; + top: 0; + bottom: 0; + width: 1px; + transform: translateX(-50%); + border-left: 1px ${({ lineStyle }) => lineStyle} ${({ color }) => color}; + pointer-events: none; + z-index: 0; +`; + +interface ShapeRendererProps { + shape?: AnchorShape; + fill: string; + stroke: string; + strokeWidth: number; + size: number; +} + +function ShapeRenderer({ + shape, + fill, + stroke, + strokeWidth: sw, + size: s, +}: ShapeRendererProps) { + const half = s / 2; + const inset = sw / 2; + + const svgProps = { + width: s, + height: s, + viewBox: `0 0 ${s} ${s}`, + style: { + display: 'block', + overflow: 'visible', + filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.14))', + } as React.CSSProperties, + }; + + if (shape === 'circle') { + return ( + + + + ); + } + + if (shape === 'diamond') { + const pts = [ + `${half},${inset}`, + `${s - inset},${half}`, + `${half},${s - inset}`, + `${inset},${half}`, + ].join(' '); + return ( + + + + ); + } + + if (shape === 'triangle') { + const pts = [ + `${half},${inset}`, + `${s - inset},${s - inset}`, + `${inset},${s - inset}`, + ].join(' '); + return ( + + + + ); + } + + return ( + + + + ); +} + +function clientXToElementX(clientX: number, el: HTMLElement | null): number { + return el ? clientX - el.getBoundingClientRect().left : clientX; +} + +export function Anchor({ + position, + shape, + containerRef, + anchorStyle = {}, + restoreFocusOnLeave = false, + onDragMove, + onDragEnd, + onDelete, +}: AnchorProps) { + const { + size = 16, + fill: idleFill = '#ffffff', + stroke: idleStroke = '#d1d5db', + hoverFill = '#f3f4f6', + hoverStroke = '#9ca3af', + dragFill = '#f3f4f6', + dragStroke = '#6b7280', + strokeWidth = 1, + guideColor = 'rgba(156,163,175,0.6)', + guideDragColor = 'rgba(107,114,128,0.8)', + guideStyle = 'solid', + } = anchorStyle; + + const [hovered, setHovered] = useState(false); + const [dragging, setDragging] = useState(false); + const dragState = useRef(null); + const prevFocus = useRef(null); + + const interactionState: InteractionState = dragging + ? 'dragging' + : hovered + ? 'hovered' + : 'idle'; + const { fill, stroke, cursor } = resolveVisuals( + interactionState, + idleFill, + idleStroke, + hoverFill, + hoverStroke, + dragFill, + dragStroke, + ); + + function handleMouseDown(e: React.MouseEvent) { + e.preventDefault(); + setDragging(true); + dragState.current = { + startX: clientXToElementX(e.clientX, containerRef.current), + startAnchorX: position.x, + }; + + const onMove = (ev: MouseEvent) => { + const current = dragState.current; + if (!current) return; + + const x = + current.startAnchorX + + clientXToElementX(ev.clientX, containerRef.current) - + current.startX; + + onDragMove(x); + }; + + const onUp = () => { + setDragging(false); + dragState.current = null; + onDragEnd(position.x); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + onDelete(); + } + } + + function handleMouseEnter(e: React.MouseEvent) { + setHovered(true); + prevFocus.current = document.activeElement; + e.currentTarget.focus(); + } + + function handleMouseLeave(e: React.MouseEvent) { + setHovered(false); + if (restoreFocusOnLeave && prevFocus.current instanceof HTMLElement) { + prevFocus.current.focus(); + } else { + e.currentTarget.blur(); + } + prevFocus.current = null; + } + + return ( + <> + {(hovered || dragging) && ( + + )} + + + {(hovered || dragging) && ( + + )} + + + + + ); +}