diff --git a/src/component/elements/tanstack_table/elements/table_row.tsx b/src/component/elements/tanstack_table/elements/table_row.tsx index f1c42e1e5..3fb252eb5 100644 --- a/src/component/elements/tanstack_table/elements/table_row.tsx +++ b/src/component/elements/tanstack_table/elements/table_row.tsx @@ -103,6 +103,7 @@ export default function TableRow( table, highlightedSource = 'UNKNOWN', getHighlightExtra, + getHighlightIDs, onContextMenuSelect, contextMenu, onClick, @@ -121,7 +122,10 @@ export default function TableRow( }) as HighlightEventSource, [highlightedSource, row.original, getHighlightExtra], ); - const highlight = useHighlight(getIDs(row), data); + const highlight = useHighlight( + getHighlightIDs?.(row.original) ?? getIDs(row), + data, + ); useEffect(() => { return () => { diff --git a/src/component/elements/tanstack_table/table.tsx b/src/component/elements/tanstack_table/table.tsx index 81d15754d..d11500fb9 100644 --- a/src/component/elements/tanstack_table/table.tsx +++ b/src/component/elements/tanstack_table/table.tsx @@ -118,6 +118,7 @@ function TanStackTableInner( columns, highlightedSource, getHighlightExtra, + getHighlightIDs, contextMenu = [], onContextMenuSelect, onScroll, @@ -265,6 +266,7 @@ function TanStackTableInner( const highlightSourceProps = { highlightedSource, getHighlightExtra, + getHighlightIDs, } as TanStackTableHighlightSourceProps; return ( diff --git a/src/component/elements/tanstack_table/types.ts b/src/component/elements/tanstack_table/types.ts index 2443d3693..4e8b8974f 100644 --- a/src/component/elements/tanstack_table/types.ts +++ b/src/component/elements/tanstack_table/types.ts @@ -44,6 +44,8 @@ export type TanStackTableHeaderGroup = HeaderGroup; export type TanStackTableHighlightSourceProps = { + getHighlightIDs?: (row: TData) => string[]; +} & { [K in HighlightEventSourceType]: HighlightEventSourceExtra extends never ? { highlightedSource?: K; getHighlightExtra?: never } : { diff --git a/src/component/highlight/index.tsx b/src/component/highlight/index.tsx index 0287be9f5..87995d3b9 100644 --- a/src/component/highlight/index.tsx +++ b/src/component/highlight/index.tsx @@ -36,6 +36,7 @@ interface HighlightEventSourceMap { MULTIPLE_ANALYSIS_ZONE: { colKey: string }; DATABASE: { ranges: Range[]; jcampURL: string; baseURL: string }; ATOM: never; + ASSIGNMENT_TABLE: never; UNKNOWN: never; } diff --git a/src/component/hooks/use_publication_strings.ts b/src/component/hooks/use_publication_strings.ts index 17310a45d..4a1117ec2 100644 --- a/src/component/hooks/use_publication_strings.ts +++ b/src/component/hooks/use_publication_strings.ts @@ -1,4 +1,8 @@ -import type { ACSExportOptions, Spectrum } from '@zakodium/nmrium-core'; +import type { + ACSExportOptions, + Spectrum1D, + Spectrum, +} from '@zakodium/nmrium-core'; import { assertSpectrum1D, isSpectrum1D } from '@zakodium/nmrium-core'; import type { RangesToACSOptions } from 'nmr-processing'; import { rangesToACS } from 'nmr-processing'; @@ -39,8 +43,7 @@ export function buildPublicationString(options: BuildPublicationStringOptions) { info, ranges: { values }, } = spectrum; - const { originFrequency, nucleus } = info; - const { signalKind, format, couplingFormat, ...otherOptions } = acs; + const { signalKind } = acs; const ranges = signalKind === 'all' @@ -49,7 +52,16 @@ export function buildPublicationString(options: BuildPublicationStringOptions) { range.signals?.some((signal) => signal.kind === 'signal'), ); - const rangesToACSOptions: RangesToACSOptions = { + return rangesToACS(ranges, getRangesToACSOptions(info, acs)); +} + +export function getRangesToACSOptions( + info: Pick, + acs: ACSExportOptions, +): RangesToACSOptions { + const { originFrequency, nucleus } = info; + const { signalKind, format, couplingFormat, ...otherOptions } = acs; + const options: RangesToACSOptions = { nucleus, observedFrequency: originFrequency, ...otherOptions, @@ -57,9 +69,9 @@ export function buildPublicationString(options: BuildPublicationStringOptions) { }; if (format !== 'D') { - rangesToACSOptions.format = format; - rangesToACSOptions.couplingFormat = couplingFormat; + options.format = format; + options.couplingFormat = couplingFormat; } - return rangesToACS(ranges, rangesToACSOptions); + return options; } diff --git a/src/component/panels/MoleculesPanel/hooks/useAtomAssignment.tsx b/src/component/panels/MoleculesPanel/hooks/useAtomAssignment.tsx index 1a225492c..78d4b41ae 100644 --- a/src/component/panels/MoleculesPanel/hooks/useAtomAssignment.tsx +++ b/src/component/panels/MoleculesPanel/hooks/useAtomAssignment.tsx @@ -102,6 +102,10 @@ export default function useAtomAssignment() { const currentDiaIDsToHighlight = useMemo(() => { const { highlighted, sourceData } = highlightData.highlight; + if (sourceData?.type === 'ASSIGNMENT_TABLE') { + return highlighted; + } + let currentSpectrum = spectrum; if ( diff --git a/src/component/panels/Panels.tsx b/src/component/panels/Panels.tsx index f68c09392..1bc1eb00c 100644 --- a/src/component/panels/Panels.tsx +++ b/src/component/panels/Panels.tsx @@ -5,8 +5,10 @@ import { memo } from 'react'; import { Accordion, Toolbar } from 'react-science/ui'; import { usePreferences } from '../context/PreferencesContext.js'; +import useCheckExperimentalFeature from '../hooks/useCheckExperimentalFeature.js'; import type { AccordionItem } from './accordionItems.js'; +import { AssignmentSummaryPanel } from './assignment-summary/AssignmentSummaryPanel.js'; import { useAccordionItems } from './hooks/useAccordionItems.js'; import { useGetPanelOptions } from './hooks/useGetPanelOptions.js'; import { useTogglePanel } from './hooks/useTogglePanel.js'; @@ -18,6 +20,7 @@ const Container = styled.div` `; function PanelsInner() { + const isExperimental = useCheckExperimentalFeature(); const getPanelPreferences = useGetPanelOptions(); const { dispatch } = usePreferences(); @@ -38,27 +41,41 @@ function PanelsInner() { return ( - {displayedPanels.map((item) => { - const { title, component, id } = item; - return ( - { - dispatch({ - type: 'TOGGLE_PANEL', - payload: { id, options: { open: isOpened } }, - }); - }} - renderToolbar={() => } - > - {component} - - ); - })} + {[ + ...displayedPanels.map((item) => { + const { title, component, id } = item; + return ( + { + dispatch({ + type: 'TOGGLE_PANEL', + payload: { id, options: { open: isOpened } }, + }); + }} + renderToolbar={() => } + > + {component} + + ); + }), + ...(isExperimental + ? [ + + + , + ] + : []), + ]} ); diff --git a/src/component/panels/assignment-summary/AssignmentSummaryPanel.tsx b/src/component/panels/assignment-summary/AssignmentSummaryPanel.tsx new file mode 100644 index 000000000..60ad99153 --- /dev/null +++ b/src/component/panels/assignment-summary/AssignmentSummaryPanel.tsx @@ -0,0 +1,229 @@ +import styled from '@emotion/styled'; +import { Molecule } from 'openchemlib'; +import type { GroupedDiaID } from 'openchemlib-utils'; +import { TopicMolecule } from 'openchemlib-utils'; +import { useMemo, useState } from 'react'; +import { PanelHeader, Toolbar } from 'react-science/ui'; + +import type { StateMoleculeExtended } from '../../../data/molecules/Molecule.js'; +import { useChartData } from '../../context/ChartContext.js'; +import { EmptyText } from '../../elements/EmptyText.js'; +import Select from '../../elements/Select.js'; +import type { ToolbarPopoverMenuItem } from '../../elements/ToolbarPopoverItem.js'; +import { ToolbarPopoverItem } from '../../elements/ToolbarPopoverItem.js'; +import { TablePanel } from '../extra/BasicPanelStyle.js'; + +import { AssignmentsExportModal } from './AssignmentsExportModal.js'; +import { MoleculeAssignmentTable } from './MoleculeAssignmentTable.js'; +import { atomGroupsToAssignmentRows } from './utilities/atomGroupsToAssignmentRows.js'; + +const HeaderControls = styled.div` + display: flex; + align-items: center; + flex: 1; + justify-content: flex-start; + gap: 8px; + min-height: 0; + min-width: 0; + padding: 4px; + + label { + flex-shrink: 0; + white-space: nowrap; + } +`; + +interface MoleculeAssignmentData { + molecule: StateMoleculeExtended; + groups: GroupedDiaID[]; + expandableGroupIds: string[]; +} + +function getMoleculeOptions(moleculeAssignments: MoleculeAssignmentData[]) { + return moleculeAssignments.map(({ molecule }) => ({ + value: molecule.id, + label: molecule.label, + })); +} + +function toggleExpandedRows( + expandedRows: ReadonlySet, + keys: readonly string[], +): Set { + const shouldCollapse = keys.every((key) => expandedRows.has(key)); + const next = new Set(expandedRows); + for (const key of keys) { + if (shouldCollapse) { + next.delete(key); + } else { + next.add(key); + } + } + return next; +} + +function useMoleculeAssignments(molecules: StateMoleculeExtended[]) { + const [selectedMoleculeId, selectMolecule] = useState(); + const moleculeAssignments = useMemo( + () => + molecules.map((molecule) => { + const topicMolecule = new TopicMolecule( + Molecule.fromMolfile(molecule.molfile), + ); + const groups = topicMolecule.getGroupedDiastereotopicAtomIDs() ?? []; + return { + molecule, + groups, + expandableGroupIds: atomGroupsToAssignmentRows(groups, false) + .filter((row) => row.hasChildren) + .map((row) => row.id), + }; + }), + [molecules], + ); + const activeAssignment = + moleculeAssignments.find( + ({ molecule }) => molecule.id === selectedMoleculeId, + ) ?? moleculeAssignments[0]; + + return { moleculeAssignments, activeAssignment, selectMolecule }; +} + +function useAssignmentExpansion(activeAssignment: MoleculeAssignmentData) { + const [expandedGroupsByMolecule, setExpandedGroupsByMolecule] = useState< + ReadonlyMap> + >(() => new Map()); + const moleculeId = activeAssignment.molecule.id; + const { expandableGroupIds } = activeAssignment; + const expandedGroups = + expandedGroupsByMolecule.get(moleculeId) ?? new Set(); + + function toggleRows(groupIds: readonly string[]) { + setExpandedGroupsByMolecule((previous) => { + const next = new Map(previous); + next.set( + moleculeId, + toggleExpandedRows(previous.get(moleculeId) ?? new Set(), groupIds), + ); + return next; + }); + } + + function setAllRows(expanded: boolean) { + setExpandedGroupsByMolecule((previous) => { + const next = new Map(previous); + next.set(moleculeId, new Set(expanded ? expandableGroupIds : [])); + return next; + }); + } + + function toggleRow(groupId: string) { + toggleRows([groupId]); + } + + return { + expandedGroups, + canExpand: expandableGroupIds.some((id) => !expandedGroups.has(id)), + canCollapse: expandableGroupIds.some((id) => expandedGroups.has(id)), + toggleRow, + setAllRows, + }; +} + +type ExpansionAction = 'expand' | 'collapse'; + +const EXPANSION_MENU: Array> = [ + { text: 'Expand all', icon: 'expand-all', data: 'expand' }, + { text: 'Collapse all', icon: 'collapse-all', data: 'collapse' }, +]; + +interface ExpansionMenuProps { + canExpand: boolean; + canCollapse: boolean; + onSelect: (expanded: boolean) => void; +} + +function ExpansionMenu(props: ExpansionMenuProps) { + const { canExpand, canCollapse, onSelect } = props; + const options = EXPANSION_MENU.map((option) => ({ + ...option, + disabled: option.data === 'expand' ? !canExpand : !canCollapse, + })); + + return ( + + icon="expand-all" + tooltip="Expand / collapse rows" + tooltipProps={{ minimal: true }} + options={options} + onClick={(action) => onSelect(action === 'expand')} + /> + ); +} + +export function AssignmentSummaryPanel() { + const { molecules } = useChartData(); + const { moleculeAssignments, activeAssignment, selectMolecule } = + useMoleculeAssignments(molecules); + + if (!activeAssignment) { + return ; + } + + return ( + + ); +} + +interface AssignmentSummaryProps { + moleculeAssignments: MoleculeAssignmentData[]; + activeAssignment: MoleculeAssignmentData; + onSelectMolecule: (moleculeId: string) => void; +} + +function AssignmentSummary(props: AssignmentSummaryProps) { + const { moleculeAssignments, activeAssignment, onSelectMolecule } = props; + const expansion = useAssignmentExpansion(activeAssignment); + const { molecule } = activeAssignment; + + return ( + + + + + + + + {moleculeAssignments.length > 1 && ( +