diff --git a/src/NMRiumWrapper.tsx b/src/NMRiumWrapper.tsx index e3cc3fa..ef25ca3 100644 --- a/src/NMRiumWrapper.tsx +++ b/src/NMRiumWrapper.tsx @@ -1,4 +1,4 @@ -import type { NMRiumChangeCb, NMRiumRefAPI } from 'nmrium'; +import type { NMRiumChangeCb, NMRiumRefAPI, NMRiumState } from 'nmrium'; import { NMRium } from 'nmrium'; import type { CSSProperties } from 'react'; import { useCallback, useEffect, useRef } from 'react'; @@ -7,6 +7,9 @@ import { RootLayout } from 'react-science/ui'; import { LoadingIndicator } from './Loadingindicator.js'; import { loadSpectraFromSource } from './data-source/loadSpectraFromSource.js'; import events from './events/event.js'; +import type { HighlightPeakParams } from './events/types.js'; +import { applyPeakHighlight } from './highlight/highlightCommands.js'; +import { resolveHighlightPeak } from './highlight/resolvePeak.js'; import { useLoadSpectra } from './hooks/useLoadSpectra.js'; import { usePreferences } from './hooks/usePreferences.js'; import { useWhiteList } from './hooks/useWhiteList.js'; @@ -18,9 +21,20 @@ const containerStyle: CSSProperties = { position: 'relative', }; +const ACCEPTED_ACTION_TYPES = + "'exportSpectraViewerAsBlob', 'selectTab', 'highlightPeak', or 'clearHighlight'"; + +function reportActionError(message: string) { + const error = new Error(message); + events.trigger('error', error); + // eslint-disable-next-line no-console + console.error(error); +} + export default function NMRiumWrapper() { const { allowedOrigins, isFetchAllowedOriginsPending } = useWhiteList(); const nmriumRef = useRef(null); + const latestStateRef = useRef(null); const { workspace, preferences, @@ -36,9 +50,31 @@ export default function NMRiumWrapper() { if (source === 'view' && state.data.actionType === 'SET_2D_LEVEL') { return; } + latestStateRef.current = state; events.trigger('data-change', { state, source }); }, []); + const handleHighlightPeak = useCallback( + (params: HighlightPeakParams) => { + const resolved = resolveHighlightPeak(latestStateRef.current, params); + if (!resolved.ok) { + reportActionError(resolved.message); + return; + } + + const nucleus = params.nucleus?.trim(); + if (nucleus) { + setActiveTab({ tab: nucleus.toUpperCase() }); + } + + applyPeakHighlight( + resolved.ids, + params.permanent === false ? 'show' : 'permanent', + ); + }, + [setActiveTab], + ); + useEffect(() => { if (!spectraSource) return; @@ -78,9 +114,17 @@ export default function NMRiumWrapper() { setActiveTab({ tab: tab.toUpperCase() }); break; } + case 'highlightPeak': { + handleHighlightPeak(request.params); + break; + } + case 'clearHighlight': { + applyPeakHighlight([], 'clear'); + break; + } default: { - throw new Error( - `ERROR! Property 'type' accepts only 'exportViewerAsBlob'.`, + reportActionError( + `ERROR! Property 'type' accepts only ${ACCEPTED_ACTION_TYPES}.`, ); } } diff --git a/src/demo/NMRiumWrapperDemo.tsx b/src/demo/NMRiumWrapperDemo.tsx index 00388e3..8b81eb3 100644 --- a/src/demo/NMRiumWrapperDemo.tsx +++ b/src/demo/NMRiumWrapperDemo.tsx @@ -105,6 +105,32 @@ export default function NMRiumWrapperDemo() { > Test Logger + + diff --git a/src/events/types.ts b/src/events/types.ts index 4227d51..4344026 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -28,6 +28,22 @@ type LoadData = activeTab?: string; }; +interface HighlightPeakParams { + /** Peak id from `spectrum.peaks.values[].id`. */ + id?: string; + /** Nucleus tab to activate before highlighting, for example `1H` or `13C`. */ + nucleus?: string; + /** Chemical shift in ppm. Used when `id` is omitted. */ + ppm?: number; + /** Maximum distance in ppm when matching by `nucleus` and `ppm`. Defaults to 0.05. */ + tolerance?: number; + /** + * `true` keeps the highlight until the next highlight or `clearHighlight`. + * `false` applies a temporary highlight. Defaults to `true`. + */ + permanent?: boolean; +} + type ActionRequest = | { type: 'exportSpectraViewerAsBlob'; @@ -35,6 +51,13 @@ type ActionRequest = | { type: 'selectTab'; params: { tab: string }; + } + | { + type: 'highlightPeak'; + params: HighlightPeakParams; + } + | { + type: 'clearHighlight'; }; interface ActionResponse { @@ -58,4 +81,4 @@ type EventData = T extends 'data-change' : T extends 'error' ? Error : never; -export type { EventData, EventType }; +export type { EventData, EventType, HighlightPeakParams }; diff --git a/src/highlight/highlightBridge.tsx b/src/highlight/highlightBridge.tsx new file mode 100644 index 0000000..dff4ead --- /dev/null +++ b/src/highlight/highlightBridge.tsx @@ -0,0 +1,69 @@ +import { + HighlightProvider as NmriumHighlightProvider, + useHighlightData, +} from 'nmrium-highlight-original'; +import type { PropsWithChildren } from 'react'; +import { useEffect, useRef } from 'react'; + +import { subscribePeakHighlight } from './highlightCommands.js'; + +export { + isHighlightEventSource, + useHighlight, + useHighlightData, +} from 'nmrium-highlight-original'; + +export { applyPeakHighlight } from './highlightCommands.js'; +export type { HighlightMode } from './highlightCommands.js'; + +function HighlightDispatcher() { + const { dispatch, highlight } = useHighlightData(); + const highlightedRef = useRef(highlight.highlighted); + + useEffect(() => { + highlightedRef.current = highlight.highlighted; + }, [highlight.highlighted]); + + useEffect(() => { + return subscribePeakHighlight(({ ids, mode }) => { + const previous = highlightedRef.current; + if (previous.length > 0) { + dispatch({ + type: 'HIDE', + payload: { convertedHighlights: previous }, + }); + } + dispatch({ type: 'UNSET_PERMANENT' }); + + if (mode === 'clear' || ids.length === 0) { + return; + } + + const sourceData = { + type: 'PEAK' as const, + extra: { id: ids[0] }, + }; + dispatch({ + type: 'SHOW', + payload: { convertedHighlights: ids, sourceData }, + }); + if (mode === 'permanent') { + dispatch({ + type: 'SET_PERMANENT', + payload: { convertedHighlights: ids }, + }); + } + }); + }, [dispatch]); + + return null; +} + +export function HighlightProvider(props: PropsWithChildren) { + return ( + + + {props.children} + + ); +} diff --git a/src/highlight/highlightCommands.ts b/src/highlight/highlightCommands.ts new file mode 100644 index 0000000..90cb1b6 --- /dev/null +++ b/src/highlight/highlightCommands.ts @@ -0,0 +1,30 @@ +export type HighlightMode = 'show' | 'permanent' | 'clear'; + +export interface HighlightCommand { + ids: string[]; + mode: HighlightMode; +} + +type HighlightListener = (command: HighlightCommand) => void; + +const listeners = new Set(); +let queuedCommand: HighlightCommand | null = null; + +export function applyPeakHighlight(ids: string[], mode: HighlightMode): void { + queuedCommand = { ids, mode }; + for (const listener of listeners) { + listener(queuedCommand); + } +} + +export function subscribePeakHighlight( + listener: HighlightListener, +): () => void { + listeners.add(listener); + if (queuedCommand) { + listener(queuedCommand); + } + return () => { + listeners.delete(listener); + }; +} diff --git a/src/highlight/nmrium-highlight-original.d.ts b/src/highlight/nmrium-highlight-original.d.ts new file mode 100644 index 0000000..4cdba8e --- /dev/null +++ b/src/highlight/nmrium-highlight-original.d.ts @@ -0,0 +1,33 @@ +declare module 'nmrium-highlight-original' { + import type { JSX, PropsWithChildren } from 'react'; + + export function isHighlightEventSource( + source: unknown, + ...types: string[] + ): boolean; + + export function HighlightProvider(props: PropsWithChildren): JSX.Element; + + export function useHighlightData(): { + highlight: { + highlighted: string[]; + highlightedPermanently: string[]; + }; + dispatch: (action: { + type: 'HIDE' | 'SHOW' | 'SET_PERMANENT' | 'UNSET_PERMANENT'; + payload?: { + convertedHighlights?: string[]; + sourceData?: { + type: 'PEAK'; + extra: { id: string; spectrumID?: string }; + } | null; + }; + }) => void; + remove: () => void; + }; + + export function useHighlight( + highlights: Array, + sourceData?: unknown, + ): unknown; +} diff --git a/src/highlight/resolvePeak.ts b/src/highlight/resolvePeak.ts new file mode 100644 index 0000000..4bb302b --- /dev/null +++ b/src/highlight/resolvePeak.ts @@ -0,0 +1,147 @@ +import type { HighlightPeakParams } from '../events/types.js'; + +export const DEFAULT_HIGHLIGHT_TOLERANCE_PPM = 0.05; + +interface PeakLike { + id?: string; + x?: number; +} + +interface SpectrumLike { + id?: string; + info?: { nucleus?: string | string[] }; + peaks?: { values?: PeakLike[] }; +} + +export interface HighlightableState { + data?: { + spectra?: SpectrumLike[]; + }; +} + +export type ResolveHighlightResult = + | { ok: true; ids: string[] } + | { + ok: false; + reason: 'invalid' | 'not-loaded' | 'no-peaks' | 'no-match'; + message: string; + }; + +const ACCEPTED_PARAMS = + "Property 'params' requires 'id', or both 'nucleus' and 'ppm'."; + +function nucleusMatches(spectrum: SpectrumLike, nucleus: string): boolean { + const value = spectrum.info?.nucleus; + if (typeof value === 'string') { + return value.toUpperCase() === nucleus.toUpperCase(); + } + if (Array.isArray(value)) { + return value.some( + (entry) => + typeof entry === 'string' && + entry.toUpperCase() === nucleus.toUpperCase(), + ); + } + return false; +} + +function peakValues(spectrum: SpectrumLike): PeakLike[] { + const values = spectrum.peaks?.values; + return Array.isArray(values) ? values : []; +} + +export function resolveHighlightPeak( + state: HighlightableState | null | undefined, + params: HighlightPeakParams, +): ResolveHighlightResult { + const id = params.id?.trim(); + const nucleus = params.nucleus?.trim(); + const ppm = params.ppm; + const tolerance = params.tolerance ?? DEFAULT_HIGHLIGHT_TOLERANCE_PPM; + const hasPpm = typeof ppm === 'number' && Number.isFinite(ppm); + + if (!id && (!nucleus || !hasPpm)) { + return { ok: false, reason: 'invalid', message: ACCEPTED_PARAMS }; + } + + if (!Number.isFinite(tolerance) || tolerance < 0) { + return { + ok: false, + reason: 'invalid', + message: "Property 'tolerance' must be a non-negative number.", + }; + } + + const spectra = state?.data?.spectra; + if (!Array.isArray(spectra)) { + return { + ok: false, + reason: 'not-loaded', + message: 'No spectrum is loaded.', + }; + } + + if (id) { + let sawPeaks = false; + for (const spectrum of spectra) { + const values = peakValues(spectrum); + if (values.length > 0) { + sawPeaks = true; + } + const match = values.find((peak) => peak.id === id); + if (match?.id) { + return { ok: true, ids: [match.id] }; + } + } + if (!sawPeaks) { + return { + ok: false, + reason: 'no-peaks', + message: 'Peaks are not available yet.', + }; + } + return { + ok: false, + reason: 'no-match', + message: `No peak with id '${id}'.`, + }; + } + + const candidates = spectra.filter((spectrum) => + nucleusMatches(spectrum, nucleus ?? ''), + ); + let closest: { id: string; distance: number } | null = null; + let sawPeaks = false; + + for (const spectrum of candidates) { + for (const peak of peakValues(spectrum)) { + if (typeof peak.x !== 'number' || !peak.id) { + continue; + } + sawPeaks = true; + const distance = Math.abs(peak.x - (ppm ?? 0)); + if ( + distance <= tolerance && + (closest === null || distance < closest.distance) + ) { + closest = { id: peak.id, distance }; + } + } + } + + if (closest) { + return { ok: true, ids: [closest.id] }; + } + if (!sawPeaks) { + return { + ok: false, + reason: 'no-peaks', + message: 'Peaks are not available yet.', + }; + } + return { + ok: false, + reason: 'no-match', + message: `No ${nucleus} peak within ${tolerance} ppm of ${ppm}.`, + }; +} diff --git a/test-e2e/core.test.ts b/test-e2e/core.test.ts index 12ff66f..7504d8d 100644 --- a/test-e2e/core.test.ts +++ b/test-e2e/core.test.ts @@ -125,3 +125,44 @@ test('should load test-data.nmrium file', async ({ await nmrium.checkSpectraTabsIsVisible(['1H', '13C', '1H,1H', '1H,13C']) }); + +test('should highlight a peak via nmr-wrapper:action-request', async ({ + page, +}) => { + const nmrium = await NmriumWrapperPage.create(page); + + await nmrium.page.click('text=Test load from json'); + await expect(nmrium.page.locator('.tab-list-item >> text=13C')).toBeVisible(); + await expect(nmrium.page.locator('g.peaks path').first()).toBeVisible(); + + await page.evaluate(() => { + window.postMessage( + { + type: 'nmr-wrapper:action-request', + data: { + type: 'highlightPeak', + params: { nucleus: '13C', ppm: 77.95, tolerance: 0.05 }, + }, + }, + '*', + ); + }); + + await expect( + nmrium.page.locator('g.peaks path[stroke-width="3px"]'), + ).toHaveCount(1); + + await page.evaluate(() => { + window.postMessage( + { + type: 'nmr-wrapper:action-request', + data: { type: 'clearHighlight' }, + }, + '*', + ); + }); + + await expect( + nmrium.page.locator('g.peaks path[stroke-width="3px"]'), + ).toHaveCount(0); +}); diff --git a/tsconfig.json b/tsconfig.json index 14c824c..360d209 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,11 @@ "vite/client", "vite-plugin-pwa/client.d.ts" ], + "paths": { + "nmrium-highlight-original": [ + "./src/highlight/nmrium-highlight-original.d.ts" + ] + }, "outDir": "lib", "allowJs": false, "strict": true, @@ -16,6 +21,7 @@ "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "NodeNext", + "moduleResolution": "NodeNext", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, @@ -24,12 +30,12 @@ "sourceMap": true, "declaration": true, "declarationMap": true, - "skipLibCheck": true, + "skipLibCheck": true }, "include": [ "src", "test-e2e", "playwright.config.ts", - "vite.config.ts", - ], -} \ No newline at end of file + "vite.config.ts" + ] +} diff --git a/vite.config.ts b/vite.config.ts index 3f58be6..671f0a6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,49 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + import react from '@vitejs/plugin-react'; +import type { Plugin } from 'vite'; import { defineConfig } from 'vite'; import { VitePWA, type Options } from 'vite-plugin-pwa'; +const rootDir = path.dirname(fileURLToPath(import.meta.url)); +const nmriumHighlightPath = path.resolve( + rootDir, + 'node_modules/nmrium/lib/component/highlight/index.js', +); +const highlightBridgePath = path.resolve( + rootDir, + 'src/highlight/highlightBridge.tsx', +); + +function nmriumHighlightBridgePlugin(): Plugin { + return { + name: 'nmrium-highlight-bridge', + enforce: 'pre', + resolveId(source, importer) { + if (source === 'nmrium-highlight-original') { + return `${nmriumHighlightPath}?original`; + } + + const isHighlightImport = + source === '../highlight/index.js' || + source === '../../highlight/index.js' || + source.endsWith('/highlight/index.js') || + source === nmriumHighlightPath; + + if (!isHighlightImport) { + return null; + } + + if (importer?.includes('highlightBridge')) { + return null; + } + + return highlightBridgePath; + }, + }; +} + const pwaSettings: Partial = { // cache all the imports // workbox: { @@ -82,6 +124,11 @@ export default () => { }, } }, - plugins: [react(), VitePWA(pwaSettings)], + plugins: [nmriumHighlightBridgePlugin(), react(), VitePWA(pwaSettings)], + resolve: { + alias: { + 'nmrium-highlight-original': `${nmriumHighlightPath}?original`, + }, + }, }); };