diff --git a/extensions/cornerstone-dynamic-volume/src/actions/updateSegmentationsChartDisplaySet.ts b/extensions/cornerstone-dynamic-volume/src/actions/updateSegmentationsChartDisplaySet.ts index 78df25a66..7e901804b 100644 --- a/extensions/cornerstone-dynamic-volume/src/actions/updateSegmentationsChartDisplaySet.ts +++ b/extensions/cornerstone-dynamic-volume/src/actions/updateSegmentationsChartDisplaySet.ts @@ -262,7 +262,6 @@ function _getInstanceFromSegmentations(segmentations, { servicesManager }) { } function updateSegmentationsChartDisplaySet({ servicesManager }: withAppTypes): void { - debugger; const { segmentationService } = servicesManager.services; const segmentations = segmentationService.getSegmentations(); const { seriesMetadata, instance } = diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 2e4558431..f6282f97b 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -6,6 +6,8 @@ import { Types as CoreTypes, cache, BaseVolumeViewport, + triggerEvent, + eventTarget, } from '@cornerstonejs/core'; import { ToolGroupManager, @@ -13,6 +15,7 @@ import { utilities as cstUtils, ReferenceLinesTool, annotation, + Types as ToolTypes, } from '@cornerstonejs/tools'; import * as cornerstoneTools from '@cornerstonejs/tools'; import * as labelmapInterpolation from '@cornerstonejs/labelmap-interpolation'; @@ -35,6 +38,7 @@ import { toolNames } from './initCornerstoneTools'; import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats'; import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport'; + const { DefaultHistoryMemo } = csUtils.HistoryMemo; const toggleSyncFunctions = { imageSlice: toggleImageSliceSync, @@ -436,25 +440,26 @@ function commandsModule({ }, removeMeasurement: ({ uid }) => { - measurementService.remove(uid); + if (Array.isArray(uid)) { + measurementService.removeMany(uid); + } else { + measurementService.remove(uid); + } }, toggleLockMeasurement: ({ uid }) => { measurementService.toggleLockMeasurement(uid); }, - toggleVisibilityMeasurement: ({ uid }) => { - measurementService.toggleVisibilityMeasurement(uid); - }, - - /** - * Clear the measurements - */ - clearMeasurements: options => { - const { measurementFilter } = options; - measurementService.clearMeasurements( - measurementFilter ? measurementFilter.bind(options) : null - ); + toggleVisibilityMeasurement: ({ uid, items, visibility }) => { + if (visibility === undefined && items?.length) { + visibility = !items[0].isVisible; + } + if (Array.isArray(uid)) { + measurementService.toggleVisibilityMeasurementMany(uid, visibility); + } else { + measurementService.toggleVisibilityMeasurement(uid, visibility); + } }, /** @@ -514,16 +519,18 @@ function commandsModule({ viewportGridService.setActiveViewportId(viewportId); }, - arrowTextCallback: ({ callback }) => { + arrowTextCallback: async ({ callback }) => { const labelConfig = customizationService.getCustomization('measurementLabels'); const renderContent = customizationService.getCustomization('ui.labellingComponent'); - callInputDialogAutoComplete({ + const value = await callInputDialogAutoComplete({ uiDialogService, labelConfig, renderContent, }); + callback?.(value); }, + toggleCine: () => { const { viewports } = viewportGridService.getState(); const { isCineEnabled } = cineService.getState(); @@ -879,7 +886,7 @@ function commandsModule({ const options = { imageIndex: jumpIndex }; csUtils.jumpToSlice(viewport.element, options); }, - scroll: ({ direction }) => { + scroll: (options: ToolTypes.ScrollOptions) => { const enabledElement = _getActiveViewportEnabledElement(); if (!enabledElement) { @@ -887,7 +894,6 @@ function commandsModule({ } const { viewport } = enabledElement; - const options = { delta: direction }; csUtils.scroll(viewport, options); }, @@ -1490,6 +1496,7 @@ function commandsModule({ viewportGridService.getActiveViewportId() ); }, + deleteActiveAnnotation: () => { const activeAnnotationsUID = cornerstoneTools.annotation.selection.getAnnotationsSelected(); activeAnnotationsUID.forEach(activeAnnotationUID => { @@ -1577,9 +1584,6 @@ function commandsModule({ updateMeasurement: { commandFn: actions.updateMeasurement, }, - clearMeasurements: { - commandFn: actions.clearMeasurements, - }, jumpToMeasurement: { commandFn: actions.jumpToMeasurement, }, diff --git a/extensions/cornerstone/src/components/AccordionGroup/AccordionGroup.tsx b/extensions/cornerstone/src/components/AccordionGroup/AccordionGroup.tsx new file mode 100644 index 000000000..2091089a8 --- /dev/null +++ b/extensions/cornerstone/src/components/AccordionGroup/AccordionGroup.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import { useSystem } from '@ohif/core'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@ohif/ui-next'; +import { ChevronDownIcon } from '@radix-ui/react-icons'; + +export type AccordionGrouping = { + [name: string]: unknown; +}; + +export type AccordionGroupProps = { + grouping: AccordionGrouping; +}; + +/** + * Searches for the required type from the provided allChildren list and + * renders them. + */ +export const CloneChildren = props => { + const { group, allChildren, children, childType, defaultTypes } = props; + + const subType = group?.subType; + + for (const child of allChildren) { + if (subType && child.props?.subType !== subType) { + continue; + } + if (childType && child.type === childType) { + return React.cloneElement(child, { ...props, children: child.props.children }); + } + if (defaultTypes?.indexOf(child.type) === -1) { + return childType({ ...props, children: child }); + } + } + + if (!children) { + throw new Error(`No children defined for ${props.name} CloneChildren in group ${group?.name}`); + } + return React.cloneElement(children, props); +}; + +/** Used to exclude defaults */ +const DEFAULT_TYPES = [GroupAccordion, Content, Trigger]; + +/** + * An AccordionGroup is a component that splits a set of items into different + * groups according to a set of grouping rules. It then puts the groups + * into a set of accordion folds selected from the body of the accordion group, + * looking for matching trigger/content sections according to the type definition + * in the group with first one found being used. + * + * This design allows for easy customization of the component by declaring grouping + * functions with default grouping setups and then only overriding the specific + * children needing to be changed. See the PanelMeasurement for some example + * possibilities of how to modify the default grouping, or the test-extension + * measurements panel for a practical, working example. + */ +export function AccordionGroup(props) { + const { grouping, items, children, sourceChildren, type } = props; + const childProps = useSystem(); + let defaultValue = props.defaultValue; + const groups = grouping.groupingFunction(items, grouping, childProps); + + if (!defaultValue) { + const defaultGroup = groups.values().find(group => group.isSelected); + defaultValue = defaultGroup?.key || defaultGroup?.title; + } + + const valueArr = + (Array.isArray(defaultValue) && defaultValue) || (defaultValue && [defaultValue]) || []; + const sourceChildrenArr = sourceChildren ? React.Children.toArray(sourceChildren) : []; + const childrenArr = children ? React.Children.toArray(children) : []; + const allChildren = sourceChildrenArr.concat(childrenArr); + + return ( + + + + ); +} + +function DefaultAccordion(props) { + const { groups, defaultValue, grouping, allChildren, asChild } = props; + if (!allChildren || !groups) { + return null; + } + + if (Boolean(asChild)) { + return React.cloneElement(props.children, props); + } + + return ( + + {[...groups.entries()].map(([key, group]) => { + return ( + + + + + ); + })} + + ); +} + +function GroupAccordion(props) { + const { groups, children } = props; + if (!groups) { + return null; + } + return [...groups.values()].map(group => + React.cloneElement(children, { + ...props, + children: children.props.children, + group, + ...group, + key: group.title, + }) + ); +} + +function Content(props) { + const { children, asChild, ...childProps } = props; + const { group } = props; + Object.assign(childProps, group); + + if (!group) { + return null; + } + if (asChild) { + return React.cloneElement(children, { ...group, ...props, children: children.props.children }); + } + return ( + + {React.cloneElement(children, { ...group, ...props, children: children.props.children })} + + ); +} + +function Trigger(props) { + const { children, asChild, ...childProps } = props; + const { group } = props; + Object.assign(childProps, group); + + if (!group) { + return null; + } + if (asChild) { + return React.cloneElement(children, childProps); + } + return ( + +
+ {React.cloneElement(children, childProps)} + +
+
+ ); +} + +AccordionGroup.Content = Content; +AccordionGroup.Trigger = Trigger; +AccordionGroup.Accordion = GroupAccordion; + +export default AccordionGroup; diff --git a/extensions/cornerstone/src/components/AccordionGroup/index.ts b/extensions/cornerstone/src/components/AccordionGroup/index.ts new file mode 100644 index 000000000..c05934e73 --- /dev/null +++ b/extensions/cornerstone/src/components/AccordionGroup/index.ts @@ -0,0 +1,4 @@ +export * from './AccordionGroup'; +import AccordionGroup from './AccordionGroup'; + +export default AccordionGroup; diff --git a/extensions/cornerstone/src/components/MeasurementItems.tsx b/extensions/cornerstone/src/components/MeasurementItems.tsx new file mode 100644 index 000000000..c3fec1d17 --- /dev/null +++ b/extensions/cornerstone/src/components/MeasurementItems.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Accordion, AccordionContent, AccordionItem } from '@ohif/ui-next'; + +import PanelAccordionTrigger from './PanelAccordionTrigger'; +import MeasurementsMenu from './MeasurementsMenu'; +import { useSystem } from '@ohif/core'; + +export function MeasurementItem(props) { + const { index, item } = props; + return ( + + ); +} + +export default function MeasurementAccordion(props) { + const { items } = props; + const system = useSystem(); + + const onClick = (e, group) => { + const { items } = group; + // Just jump to the first measurement in the set, and mark that one as active + // with the set of items. + system.commandsManager.run('jumpToMeasurement', { + uid: items[0].uid, + displayMeasurements: items, + group, + }); + }; + + return ( + + {items.map((item, index) => { + const { displayText: details = {} } = item; + return ( + + + +
+
+ {details.primary?.length > 0 && + details.primary.map((detail, index) => ( + {detail} + ))} +
+
+
+
+ ); + })} +
+ ); +} diff --git a/extensions/cornerstone/src/components/MeasurementTableNested.tsx b/extensions/cornerstone/src/components/MeasurementTableNested.tsx new file mode 100644 index 000000000..dead49d75 --- /dev/null +++ b/extensions/cornerstone/src/components/MeasurementTableNested.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { MeasurementTable } from '@ohif/ui-next'; +import { useSystem } from '@ohif/core'; + +/** + * This is a measurement table that is designed to be nested inside + * the accordion groups. + */ +export default function MeasurementTableNested(props) { + const { title, items, group, customHeader } = props; + const { commandsManager } = useSystem(); + const onAction = (e, command, uid) => { + commandsManager.run(command, { uid, annotationUID: uid, displayMeasurements: items }); + }; + + return ( + + + {customHeader && group.isFirst && customHeader({ ...props, items: props.allItems })} + + + + ); +} diff --git a/extensions/cornerstone/src/components/MeasurementsMenu.tsx b/extensions/cornerstone/src/components/MeasurementsMenu.tsx new file mode 100644 index 000000000..6266e4721 --- /dev/null +++ b/extensions/cornerstone/src/components/MeasurementsMenu.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { useSystem } from '@ohif/core'; +import { + Button, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + Icons, +} from '@ohif/ui-next'; + +export function MeasumentsMenu(props) { + const { group, classNames } = props; + if (!group.items?.length) { + console.log('No items to iterate', group.items); + return null; + } + const { items } = group; + const [item] = items; + const { isSelected, isVisible } = item; + + const system = useSystem(); + + const onAction = (event, command, args?) => { + const uid = items.map(item => item.uid); + // Some commands use displayMeasurements and some use items + system.commandsManager.run(command, { + ...args, + uid, + displayMeasurements: items, + items, + event, + }); + }; + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + return ( +
+ {/* Visibility Toggle Icon */} + + {/* Actions Dropdown Menu */} + setIsDropdownOpen(open)}> + + + + + onAction(e, 'removeMeasurement')}> + + Delete + + + +
+ ); +} + +export default MeasumentsMenu; diff --git a/extensions/cornerstone/src/components/MeasurementsOrAdditionalFindings.tsx b/extensions/cornerstone/src/components/MeasurementsOrAdditionalFindings.tsx new file mode 100644 index 000000000..a420dc0d3 --- /dev/null +++ b/extensions/cornerstone/src/components/MeasurementsOrAdditionalFindings.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import AccordionGroup from './AccordionGroup'; +import { utils } from '@ohif/core'; +import MeasurementTableNested from './MeasurementTableNested'; + +const { filterNot, filterAdditionalFindings } = utils.MeasurementFilters; + +export const MeasurementOrAdditionalFindingSets = [ + { + title: 'Measurements', + filter: filterNot(filterAdditionalFindings), + }, + { + title: 'Additional Findings', + filter: filterAdditionalFindings, + }, +]; + +/** + * Groups measurements by study in order to allow display and saving by study + * @param {Object} servicesManager + */ +export const groupByNamedSets = (items, grouping) => { + const groups = new Map(); + const { namedSets } = grouping; + + for (const namedSet of namedSets) { + const name = namedSet.id || namedSet.title; + groups.set(name, { + ...grouping, + ...namedSet, + items: [], + isFirst: groups.size === 0, + title: name, + key: name, + }); + } + items.forEach(item => { + for (const namedSet of namedSets) { + if (namedSet.filter(item)) { + const name = namedSet.id || namedSet.title; + groups.get(name).items.push(item); + return; + } + } + }); + for (const namedSet of namedSets) { + const name = namedSet.id || namedSet.title; + if (!groups.get(name).items.length) { + groups.delete(name); + } + } + return groups; +}; + +export function MeasurementsOrAdditionalFindings(props): React.ReactNode { + const { items, children, grouping = {}, customHeader, group } = props; + + return ( + + + + + + ); +} + +export default MeasurementsOrAdditionalFindings; diff --git a/extensions/cornerstone/src/components/PanelAccordionTrigger.tsx b/extensions/cornerstone/src/components/PanelAccordionTrigger.tsx new file mode 100644 index 000000000..a756db4f2 --- /dev/null +++ b/extensions/cornerstone/src/components/PanelAccordionTrigger.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { AccordionTrigger, ColorCircle } from '@ohif/ui-next'; +import { ChevronDownIcon } from '@radix-ui/react-icons'; + +function onClickDefault(e) { + const { group, onClick = group?.onClick } = this; + if (!onClick) { + console.log('No onClick function', group); + return; + } + console.log('onClickDefault'); + e.preventDefault(); + e.stopPropagation(); + + onClick(e, group); + + return false; +} + +export default function PanelAccordionTrigger(props) { + const { marginLeft = 8, isActive = false, colorHex, count, text, menu: Menu = null } = props; + + return ( + +
+ + {Menu && ( + + )} + +
+
+ ); +} diff --git a/extensions/cornerstone/src/components/SeriesMeasurements.tsx b/extensions/cornerstone/src/components/SeriesMeasurements.tsx new file mode 100644 index 000000000..8a9c4c7b8 --- /dev/null +++ b/extensions/cornerstone/src/components/SeriesMeasurements.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { useActiveViewportDisplaySets, useSystem } from '@ohif/core'; + +import AccordionGroup from './AccordionGroup'; +import PanelAccordionTrigger from './PanelAccordionTrigger'; +import MeasurementItems from './MeasurementItems'; +import MeasurementsMenu from './MeasurementsMenu'; + +/** + * Groups measurements by study in order to allow display and saving by study + * @param {Object} servicesManager + */ +export const groupByDisplaySet = (items, grouping, childProps) => { + const groups = new Map(); + const { displaySetService } = childProps.servicesManager.services; + const { activeDisplaySetInstanceUID } = grouping; + + items.forEach(item => { + const { displaySetInstanceUID } = item; + + if (!groups.has(displaySetInstanceUID)) { + const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); + groups.set(displaySetInstanceUID, { + header: null, + isSelected: displaySetInstanceUID == activeDisplaySetInstanceUID, + ...grouping, + items: [], + key: displaySetInstanceUID, + title: 'Series Measurements', + displaySet, + }); + } + groups.get(displaySetInstanceUID).items.push(item); + }); + + return groups; +}; + +export function SeriesMeasurementTrigger(props) { + const { group, isSelected, displaySet, menu } = props; + const { SeriesNumber = 1, SeriesDescription } = displaySet; + + return ( + + ); +} + +export function SeriesMeasurements(props): React.ReactNode { + const { items, grouping = {}, children } = props; + const system = useSystem(); + const activeDisplaySets = useActiveViewportDisplaySets(system); + const activeDisplaySetInstanceUID = activeDisplaySets?.[0]?.displaySetInstanceUID; + const onClick = (_e, group) => { + const { items } = group; + system.commandsManager.run('jumpToMeasurement', { + uid: items[0].uid, + displayMeasurements: items, + group, + }); + }; + + // The content of the accordion group will default to the children of the + // parent declaration if present, otherwise to MeasurementItems + return ( + + + + + + + ); +} + +export default SeriesMeasurements; diff --git a/extensions/cornerstone/src/components/SeriesSummaryFromDisplaySet.tsx b/extensions/cornerstone/src/components/SeriesSummaryFromDisplaySet.tsx new file mode 100644 index 000000000..18c390858 --- /dev/null +++ b/extensions/cornerstone/src/components/SeriesSummaryFromDisplaySet.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { utils } from '@ohif/core'; + +const { formatDate } = utils; + +export function SeriesSummaryFromDisplaySet({ displaySet }) { + if (!displaySet) { + return null; + } + + const { SeriesDate, SeriesDescription, SeriesNumber = 1 } = displaySet; + + return ( +
+
+ Series #{SeriesNumber} {SeriesDescription} +
+
{formatDate(SeriesDate)}
+
+ ); +} diff --git a/extensions/cornerstone/src/components/StudyMeasurements.tsx b/extensions/cornerstone/src/components/StudyMeasurements.tsx new file mode 100644 index 000000000..111f30932 --- /dev/null +++ b/extensions/cornerstone/src/components/StudyMeasurements.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { useActiveViewportDisplaySets, useSystem } from '@ohif/core'; +// import { AccordionContent, AccordionItem, AccordionTrigger } from '@ohif/ui-next'; + +import { AccordionGroup } from './AccordionGroup'; +import MeasurementsOrAdditionalFindings from './MeasurementsOrAdditionalFindings'; +import StudySummaryWithActions from './StudySummaryWithActions'; + +/** + * Groups measurements by study in order to allow display and saving by study + * @param {Object} servicesManager + */ +export const groupByStudy = (items, grouping, childProps) => { + const groups = new Map(); + const { activeStudyUID } = grouping; + const { displaySetService } = childProps.servicesManager.services; + + const getItemStudyInstanceUID = item => { + const displaySet = displaySetService.getDisplaySetByUID(item.displaySetInstanceUID); + return displaySet.instances[0].StudyInstanceUID; + }; + + let firstSelected, firstGroup; + + items.forEach(item => { + const studyUID = getItemStudyInstanceUID(item); + if (!groups.has(studyUID)) { + const items = []; + const group = { + ...grouping, + items, + displayMeasurements: items, + key: studyUID, + isSelected: studyUID === activeStudyUID, + StudyInstanceUID: activeStudyUID, + }; + if (group.isSelected && !firstSelected) { + firstSelected = group; + } + firstGroup ||= group; + groups.set(studyUID, group); + } + if (!firstSelected && firstGroup) { + firstGroup.isSelected = true; + } + const group = groups.get(studyUID); + group.items.push(item); + }); + + return groups; +}; + +export function StudyMeasurements(props): React.ReactNode { + const { items, grouping = {}, children } = props; + + const system = useSystem(); + const activeDisplaySets = useActiveViewportDisplaySets(system); + const activeStudyUID = activeDisplaySets?.[0]?.StudyInstanceUID; + + return ( + + + + + + + ); +} + +export default StudyMeasurements; diff --git a/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx b/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx new file mode 100644 index 000000000..08f151234 --- /dev/null +++ b/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { Button, Icons } from '@ohif/ui-next'; +import { useSystem } from '@ohif/core'; + +export function StudyMeasurementsActions({ items, StudyInstanceUID, measurementFilter, actions }) { + const { commandsManager } = useSystem(); + const disabled = !items?.length; + + if (disabled) { + return null; + } + + return ( +
+
+ + + + +
+
+ ); +} + +export default StudyMeasurementsActions; diff --git a/extensions/cornerstone/src/components/StudySummaryFromMetadata.tsx b/extensions/cornerstone/src/components/StudySummaryFromMetadata.tsx index 60d8b94d5..caa6260ff 100644 --- a/extensions/cornerstone/src/components/StudySummaryFromMetadata.tsx +++ b/extensions/cornerstone/src/components/StudySummaryFromMetadata.tsx @@ -4,7 +4,8 @@ import { StudySummary } from '@ohif/ui-next'; const { formatDate } = utils; -export function StudySummaryFromMetadata({ StudyInstanceUID }) { +export function StudySummaryFromMetadata(props) { + const { StudyInstanceUID } = props; if (!StudyInstanceUID) { return null; } @@ -20,6 +21,6 @@ export function StudySummaryFromMetadata({ StudyInstanceUID }) { + /> ); } diff --git a/extensions/cornerstone/src/components/StudySummaryWithActions.tsx b/extensions/cornerstone/src/components/StudySummaryWithActions.tsx new file mode 100644 index 000000000..357c65215 --- /dev/null +++ b/extensions/cornerstone/src/components/StudySummaryWithActions.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { StudySummaryFromMetadata } from './StudySummaryFromMetadata'; +import StudyMeasurementsActions from './StudyMeasurementsActions'; + +export function StudySummaryWithActions(props) { + return ( +
+ + +
+ ); +} + +export default StudySummaryWithActions; diff --git a/extensions/cornerstone/src/components/index.ts b/extensions/cornerstone/src/components/index.ts new file mode 100644 index 000000000..a3c25a7b7 --- /dev/null +++ b/extensions/cornerstone/src/components/index.ts @@ -0,0 +1,10 @@ +export * from './AccordionGroup'; +export * from './MeasurementTableNested'; +export * from './StudyMeasurements'; +export * from './MeasurementsMenu'; +export * from './SeriesMeasurements'; +export * from './StudyMeasurementsActions'; +export * from './MeasurementsOrAdditionalFindings'; +import DicomUpload from './DicomUpload/DicomUpload'; + +export { DicomUpload }; diff --git a/extensions/cornerstone/src/getPanelModule.tsx b/extensions/cornerstone/src/getPanelModule.tsx index fb156c404..eea1b3616 100644 --- a/extensions/cornerstone/src/getPanelModule.tsx +++ b/extensions/cornerstone/src/getPanelModule.tsx @@ -51,19 +51,6 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager }: ); }; - const wrappedPanelMeasurement = ({ configuration }) => { - return ( - - ); - }; - return [ { name: 'activeViewportWindowLevel', @@ -76,7 +63,7 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager }: iconName: 'tab-linear', iconLabel: 'Measure', label: 'Measurement', - component: wrappedPanelMeasurement, + component: PanelMeasurement, }, { name: 'panelSegmentation', diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 730a82798..0d39581d4 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -30,6 +30,8 @@ import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledEle import { id } from './id'; import { measurementMappingUtils } from './utils/measurementServiceMappings'; +import PlanarFreehandROI from './utils/measurementServiceMappings/PlanarFreehandROI'; +import RectangleROI from './utils/measurementServiceMappings/RectangleROI'; import type { PublicViewportOptions } from './services/ViewportService/Viewport'; import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool'; import ViewportActionCornersService from './services/ViewportActionCornersService/ViewportActionCornersService'; @@ -51,11 +53,11 @@ import { useMeasurements } from './hooks/useMeasurements'; import getPanelModule from './getPanelModule'; import PanelSegmentation from './panels/PanelSegmentation'; import PanelMeasurement from './panels/PanelMeasurement'; -import DicomUpload from './components/DicomUpload/DicomUpload'; import { useSegmentations } from './hooks/useSegmentations'; import { StudySummaryFromMetadata } from './components/StudySummaryFromMetadata'; import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import utils from './utils'; +export * from './components'; const { imageRetrieveMetadataProvider } = cornerstone.utilities; @@ -234,6 +236,8 @@ const cornerstoneExtension: Types.Extensions.Extension = { export type { PublicViewportOptions }; export { measurementMappingUtils, + PlanarFreehandROI, + RectangleROI, CornerstoneExtensionTypes as Types, toolNames, getActiveViewportEnabledElement, @@ -254,7 +258,6 @@ export { useSegmentations, PanelSegmentation, PanelMeasurement, - DicomUpload, StudySummaryFromMetadata, CornerstoneViewportDownloadForm, utils, diff --git a/extensions/cornerstone/src/panels/PanelMeasurement.tsx b/extensions/cornerstone/src/panels/PanelMeasurement.tsx index c7871d93c..9a11ec178 100644 --- a/extensions/cornerstone/src/panels/PanelMeasurement.tsx +++ b/extensions/cornerstone/src/panels/PanelMeasurement.tsx @@ -1,107 +1,103 @@ -import React, { useEffect, useRef } from 'react'; -import { utils } from '@ohif/core'; -import { MeasurementTable } from '@ohif/ui-next'; -import debounce from 'lodash.debounce'; +import React from 'react'; +import { useSystem } from '@ohif/core'; + import { useMeasurements } from '../hooks/useMeasurements'; +import StudyMeasurements from '../components/StudyMeasurements'; +/** + * The PanelMeasurement is a fairly simple wrapper that gets the filtered + * measurements and then passes it on to the children component, default to + * the StudyMeasurements sub-component if no children are specified. + * Some example customizations that could work: + * + * + * Creates a default study measurements panel with default children: + * ``` + * + * + * + * ``` + * + * A study measurements with body replacement + * ``` + * + * + * + * ``` + * + * A study measurements replacing just the trigger, leaving the default body + * ``` + * + * + * This is a new custom trigger + * + * + * ``` + * + * A study measurements with the trigger and body replaced + * ``` + * + * + * This is a new custom trigger + * + * + * + * ``` + * + * A study measurements with a custom header for the additional findings + * ``` + * + * + * + * + * + * + * + * + * + * + *``` + */ +export default function PanelMeasurement(props): React.ReactNode { + const { + measurementFilter, + emptyComponent: EmptyComponent, + key = 'PanelMeasurement', + children, + } = props; -const { filterAdditionalFindings: filterAdditionalFinding, filterAny } = utils.MeasurementFilters; - -export type withAppAndFilters = withAppTypes & { - measurementFilter: (item) => boolean; -}; - -export default function PanelMeasurement({ - servicesManager, - commandsManager, - customHeader, - measurementFilter = filterAny, -}: withAppAndFilters): React.ReactNode { - const measurementsPanelRef = useRef(null); - - const { measurementService } = servicesManager.services; - - const displayMeasurements = useMeasurements(servicesManager, { + const system = useSystem(); + const displayMeasurements = useMeasurements(system.servicesManager, { measurementFilter, }); - useEffect(() => { - if (displayMeasurements.length > 0) { - debounce(() => { - measurementsPanelRef.current.scrollTop = measurementsPanelRef.current.scrollHeight; - }, 300)(); - } - }, [displayMeasurements.length]); - - const bindCommand = (name: string | string[], options?) => { - return (uid: string) => { - commandsManager.run(name, { ...options, uid }); - }; - }; - - const jumpToImage = bindCommand('jumpToMeasurement', { displayMeasurements }); - const removeMeasurement = bindCommand('removeMeasurement'); - const renameMeasurement = bindCommand(['jumpToMeasurement', 'renameMeasurement'], { - displayMeasurements, - }); - const toggleLockMeasurement = bindCommand('toggleLockMeasurement'); - const toggleVisibilityMeasurement = bindCommand('toggleVisibilityMeasurement'); - - const additionalFilter = filterAdditionalFinding(measurementService); - - const measurements = displayMeasurements.filter( - item => !additionalFilter(item) && measurementFilter(item) - ); - const additionalFindings = displayMeasurements.filter( - item => additionalFilter(item) && measurementFilter(item) - ); - - const onArgs = { - onClick: jumpToImage, - onDelete: removeMeasurement, - onToggleVisibility: toggleVisibilityMeasurement, - onToggleLocked: toggleLockMeasurement, - onRename: renameMeasurement, - }; + if (!displayMeasurements.length) { + return EmptyComponent ? ( + + ) : ( + No Measurements + ); + } + if (children) { + const cloned = React.Children.map(children, child => + React.cloneElement(child, { + items: displayMeasurements, + filter: measurementFilter, + 'data-cy': key, + }) + ); + return cloned; + } + // Need to merge defaults on the content props to ensure they get passed to children return ( - <> -
- - - {customHeader && ( - <> - {typeof customHeader === 'function' - ? customHeader({ - additionalFindings, - measurements, - }) - : customHeader} - - )} - - - - {additionalFindings.length > 0 && ( - - - - )} -
- + ); } diff --git a/extensions/cornerstone/src/types/index.ts b/extensions/cornerstone/src/types/index.ts index c8db85fb7..20967db54 100644 --- a/extensions/cornerstone/src/types/index.ts +++ b/extensions/cornerstone/src/types/index.ts @@ -1,4 +1,5 @@ import * as CornerstoneCacheService from './CornerstoneCacheService'; -import CornerstoneServices from './CornerstoneServices'; +import type CornerstoneServices from './CornerstoneServices'; +export { type SyncGroup } from '../services/SyncGroupService'; export type { CornerstoneCacheService, CornerstoneServices }; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/SegmentBidirectional.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/SegmentBidirectional.ts index 312b5e793..162fc6246 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/SegmentBidirectional.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/SegmentBidirectional.ts @@ -16,7 +16,6 @@ const SegmentBidirectional = { ) => { const { annotation } = csToolsEventDetail; const { metadata, data, annotationUID } = annotation; - debugger; const isLocked = getIsLocked(annotationUID); const isVisible = getIsVisible(annotationUID); diff --git a/extensions/default/src/Components/DataSourceConfigurationComponent.tsx b/extensions/default/src/Components/DataSourceConfigurationComponent.tsx index 68201c6e8..dac608c78 100644 --- a/extensions/default/src/Components/DataSourceConfigurationComponent.tsx +++ b/extensions/default/src/Components/DataSourceConfigurationComponent.tsx @@ -24,7 +24,7 @@ function DataSourceConfigurationComponent({ const dataSourceChangedCallback = async () => { const activeDataSourceDef = extensionManager.getActiveDataSourceDefinition(); - if (!activeDataSourceDef.configuration.configurationAPI) { + if (!activeDataSourceDef?.configuration?.configurationAPI) { return; } diff --git a/extensions/default/src/Panels/createReportDialogPrompt.tsx b/extensions/default/src/Panels/createReportDialogPrompt.tsx index 54adac9e1..5fbd738ab 100644 --- a/extensions/default/src/Panels/createReportDialogPrompt.tsx +++ b/extensions/default/src/Panels/createReportDialogPrompt.tsx @@ -36,6 +36,7 @@ export default function CreateReportDialogPrompt({ dataSourceName: undefined, }); }, + defaultValue: title, }, }); }); diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 47743910f..0cb05c7fa 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -17,6 +17,7 @@ import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocol import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore'; import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore'; import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy'; +import promptSaveReport from './utils/promptSaveReport'; export type HangingProtocolParams = { protocolId?: string; @@ -75,6 +76,14 @@ const commandsModule = ({ } }, + /** Displays a prompt and then save the report if relevant */ + promptSaveReport: props => { + const { StudyInstanceUID } = props; + promptSaveReport({ servicesManager, commandsManager, extensionManager }, props, { + data: { StudyInstanceUID }, + }); + }, + /** * Ensures that the specified study is available for display * Then, if commands is specified, runs the given commands list/instance @@ -140,8 +149,9 @@ const commandsModule = ({ type: type, }); }, - clearMeasurements: () => { - measurementService.clearMeasurements(); + + clearMeasurements: options => { + measurementService.clearMeasurements(options.measurementFilter); }, /** @@ -340,7 +350,6 @@ const commandsModule = ({ const { protocol } = hangingProtocolService.getActiveProtocol(); const onLayoutChange = protocol.callbacks?.onLayoutChange; if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) { - console.log('setViewportGridLayout running', onLayoutChange, numRows, numCols); // Don't apply the layout if the run command returns false return; } @@ -621,6 +630,7 @@ const commandsModule = ({ const definitions = { multimonitor: actions.multimonitor, + promptSaveReport: actions.promptSaveReport, loadStudy: actions.loadStudy, showContextMenu: actions.showContextMenu, closeContextMenu: actions.closeContextMenu, diff --git a/extensions/default/src/utils/callInputDialog.tsx b/extensions/default/src/utils/callInputDialog.tsx index 10efb96a0..089a911bd 100644 --- a/extensions/default/src/utils/callInputDialog.tsx +++ b/extensions/default/src/utils/callInputDialog.tsx @@ -94,12 +94,12 @@ export async function callInputDialogAutoComplete({ const dropDownItems = labelConfig ? labelConfig.items : []; const value = await new Promise>((resolve, reject) => { - const labellingDoneCallback = value => { + const labellingDoneCallback = newValue => { uiDialogService.hide('select-annotation'); - if (typeof value === 'string') { - measurement.label = value; + if (measurement && typeof newValue === 'string') { + measurement.label = newValue; } - resolve(measurement); + resolve(newValue); }; uiDialogService.show({ diff --git a/extensions/default/src/utils/promptSaveReport.tsx b/extensions/default/src/utils/promptSaveReport.tsx index cca0b01c8..874d79986 100644 --- a/extensions/default/src/utils/promptSaveReport.tsx +++ b/extensions/default/src/utils/promptSaveReport.tsx @@ -1,58 +1,76 @@ +import { utils } from '@ohif/core'; + import createReportAsync from '../Actions/createReportAsync'; +import { createReportDialogPrompt } from '../Panels'; import getNextSRSeriesNumber from './getNextSRSeriesNumber'; import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES'; -import createReportDialogPrompt from '../Panels/createReportDialogPrompt'; -/** - * Prompts the user to save a report and handles the report creation process - * @param services - Object containing required services and managers - * @param ctx - The current context containing tracked study and series information - * @param evt - The event object containing viewport and save-related data - */ -async function promptSaveReport(services, ctx, evt) { - const { servicesManager, extensionManager, commandsManager } = services; +const { + filterAnd, + filterMeasurementsByStudyUID, + filterMeasurementsBySeriesUID, + filterPlanarMeasurement, +} = utils.MeasurementFilters; +async function promptSaveReport({ servicesManager, commandsManager, extensionManager }, ctx, evt) { const { measurementService, displaySetService } = servicesManager.services; - - const viewportId = evt.viewportId ?? evt.data?.viewportId; - const isBackupSave = evt.isBackupSave ?? evt.data?.isBackupSave; - const { StudyInstanceUID, SeriesInstanceUID } = evt?.data ?? {}; - - const { trackedStudy, trackedSeries } = ctx; - const dataSources = extensionManager.getDataSources(); + const viewportId = evt.viewportId === undefined ? evt.data.viewportId : evt.viewportId; + const isBackupSave = evt.isBackupSave === undefined ? evt.data.isBackupSave : evt.isBackupSave; + const StudyInstanceUID = evt?.data?.StudyInstanceUID || ctx.trackedStudy; + const SeriesInstanceUID = evt?.data?.SeriesInstanceUID; const { - value: reportName, - dataSourceName: dataSource, - action, - } = await createReportDialogPrompt({ - servicesManager, - extensionManager, - }); + trackedSeries, + measurementFilter = filterAnd( + filterMeasurementsByStudyUID(StudyInstanceUID), + filterMeasurementsBySeriesUID(trackedSeries), + filterPlanarMeasurement + ), + defaultSaveTitle = 'Research Derived Series', + } = ctx; let displaySetInstanceUIDs; try { - if (action === PROMPT_RESPONSES.CREATE_REPORT) { - const selectedDataSource = dataSource ?? dataSources[0]; - const trackedMeasurements = getTrackedMeasurements( - measurementService, - trackedStudy, - trackedSeries - ); + const promptResult = await createReportDialogPrompt({ + title: defaultSaveTitle, + extensionManager, + servicesManager, + }); + + if (promptResult.action === PROMPT_RESPONSES.CREATE_REPORT) { + const dataSources = extensionManager.getDataSources(); + const dataSource = dataSources[0]; + const measurementData = measurementService.getMeasurements(measurementFilter); + + const SeriesDescription = promptResult.value || defaultSaveTitle; const SeriesNumber = getNextSRSeriesNumber(displaySetService); - displaySetInstanceUIDs = await handleReportCreation({ + + const getReport = async () => { + return commandsManager.runCommand( + 'storeMeasurements', + { + measurementData, + dataSource, + additionalFindingTypes: ['ArrowAnnotate'], + options: { + SeriesDescription, + SeriesNumber, + }, + }, + 'CORNERSTONE_STRUCTURED_REPORT' + ); + }; + displaySetInstanceUIDs = await createReportAsync({ servicesManager, - commandsManager, - trackedMeasurements, - selectedDataSource, - reportName, - SeriesNumber, + getReport, }); + } else if (promptResult.action === RESPONSE.CANCEL) { + // Do nothing } return { - userResponse: action, + userResponse: promptResult.action, createdDisplaySetInstanceUIDs: displaySetInstanceUIDs, StudyInstanceUID, SeriesInstanceUID, @@ -60,50 +78,9 @@ async function promptSaveReport(services, ctx, evt) { isBackupSave, }; } catch (error) { + console.warn('Unable to save report', error); return null; } } -/** - * Gets tracked measurements based on study and series criteria - */ -function getTrackedMeasurements(measurementService, trackedStudy, trackedSeries) { - return measurementService - .getMeasurements() - .filter( - m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID) - ) - .filter(m => m.referencedImageId != null); -} - -/** - * Handles the creation of the report using the measurement service - */ -async function handleReportCreation({ - servicesManager, - commandsManager, - trackedMeasurements, - selectedDataSource, - reportName, - SeriesNumber, -}) { - return createReportAsync({ - servicesManager, - getReport: () => - commandsManager.runCommand( - 'storeMeasurements', - { - measurementData: trackedMeasurements, - dataSource: selectedDataSource, - additionalFindingTypes: ['ArrowAnnotate'], - options: { - SeriesDescription: reportName, - SeriesNumber, - }, - }, - 'CORNERSTONE_STRUCTURED_REPORT' - ), - }); -} - export default promptSaveReport; diff --git a/extensions/measurement-tracking/src/getPanelModule.tsx b/extensions/measurement-tracking/src/getPanelModule.tsx index 9dfab4080..66ec57e49 100644 --- a/extensions/measurement-tracking/src/getPanelModule.tsx +++ b/extensions/measurement-tracking/src/getPanelModule.tsx @@ -25,6 +25,7 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }): component: props => ( ( + + + + ); + + const actions = { + createSR: ({ StudyInstanceUID }) => { + sendTrackedMeasurementsEvent('SAVE_REPORT', { + viewportId: viewportGrid.activeViewportId, + isBackupSave: true, + StudyInstanceUID, + measurementFilter, + }); + }, + }; + + const Header = props => ( + +
+ +
+
+ ); return ( - <> - - { - const disabled = additionalFindings.length === 0 && measurements.length === 0; - - if (disableEditing || disabled) { - return null; - } - - return ( -
-
- - - -
-
- ); - }} - >
- + + + +
+ + + + ); } diff --git a/extensions/test-extension/src/getPanelModule.tsx b/extensions/test-extension/src/getPanelModule.tsx new file mode 100644 index 000000000..13b5791fd --- /dev/null +++ b/extensions/test-extension/src/getPanelModule.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { + PanelMeasurement, + SeriesMeasurements, + StudyMeasurements, +} from '@ohif/extension-cornerstone'; + +export default function getPanelModule({ + commandsManager, + servicesManager, + extensionManager, +}: withAppTypes) { + const childProps = { + commandsManager, + servicesManager, + extensionManager, + }; + const wrappedPanelMeasurementSeries = () => { + return ( + + + + + + ); + }; + return [ + { + name: 'panelMeasurementSeries', + iconName: 'tool-freehand-roi', + iconLabel: 'Measure Series', + label: 'Measurement Series', + component: wrappedPanelMeasurementSeries, + }, + ]; +} diff --git a/extensions/test-extension/src/index.tsx b/extensions/test-extension/src/index.tsx index 27513120b..f42e00d81 100644 --- a/extensions/test-extension/src/index.tsx +++ b/extensions/test-extension/src/index.tsx @@ -8,6 +8,7 @@ import getCustomizationModule from './getCustomizationModule'; import sameAs from './custom-attribute/sameAs'; import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets'; import maxNumImageFrames from './custom-attribute/maxNumImageFrames'; +import getPanelModule from './getPanelModule'; /** * The test extension provides additional behavior for testing various @@ -50,6 +51,8 @@ const testExtension: Types.Extensions.Extension = { /** Registers some customizations */ getCustomizationModule, + getPanelModule, + getHangingProtocolModule: () => { return [ // Create a MxN hanging protocol available by default diff --git a/modes/basic-test-mode/src/index.ts b/modes/basic-test-mode/src/index.ts index 353331e64..5390da1e7 100644 --- a/modes/basic-test-mode/src/index.ts +++ b/modes/basic-test-mode/src/index.ts @@ -15,6 +15,10 @@ const ohif = { thumbnailList: '@ohif/extension-default.panelModule.seriesList', }; +const testExtension = { + measurements: '@ohif/extension-test.panelModule.panelMeasurementSeries', +}; + const tracked = { measurements: '@ohif/extension-measurement-tracking.panelModule.trackedMeasurements', thumbnailList: '@ohif/extension-measurement-tracking.panelModule.seriesList', @@ -199,7 +203,7 @@ function modeFactory() { props: { leftPanels: [tracked.thumbnailList], leftPanelResizable: true, - rightPanels: [cornerstone.panel, tracked.measurements, cornerstone.measurements], + rightPanels: [cornerstone.panel, tracked.measurements, testExtension.measurements], rightPanelResizable: true, viewports: [ { diff --git a/platform/app/public/config/kheops.js b/platform/app/public/config/kheops.js index e1ee3aac1..d700b961c 100644 --- a/platform/app/public/config/kheops.js +++ b/platform/app/public/config/kheops.js @@ -4,7 +4,7 @@ window.config = { name: 'config/kheops.js', routerBasename: '/', extensions: [], - modes: [], + modes: ['@ohif/mode-test'], customizationService: {}, showStudyList: true, // some windows systems have issues with more than 3 web workers @@ -26,7 +26,7 @@ window.config = { // filterQueryParam: false, // Uses the ohif datasource as the default - this requires that KHEOPS be // configured with an OHIF path to .../viewer/dicomwebproxy - defaultDataSourceName: 'ohif3', + defaultDataSourceName: 'dicomweb', /* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */ // dangerouslyUseDynamicConfig: { // enabled: true, diff --git a/platform/core/src/hooks/useActiveViewportDisplaySets.ts b/platform/core/src/hooks/useActiveViewportDisplaySets.ts index f5f425249..895e0aedc 100644 --- a/platform/core/src/hooks/useActiveViewportDisplaySets.ts +++ b/platform/core/src/hooks/useActiveViewportDisplaySets.ts @@ -9,9 +9,7 @@ import { DisplaySet } from '../types'; * @returns Array of display sets for the active viewport */ const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => { - const [displaySets, setDisplaySets] = useState([]); const { displaySetService, viewportGridService } = servicesManager.services; - // Move this function outside useEffect and memoize it const getDisplaySetsForViewport = useCallback( (viewportId: string) => { @@ -21,19 +19,22 @@ const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => { [displaySetService, viewportGridService] ); - useEffect(() => { - // Get initial state - const viewportId = viewportGridService.getActiveViewportId(); - setDisplaySets(getDisplaySetsForViewport(viewportId)); + // Get initial state + const viewportId = viewportGridService.getActiveViewportId(); + const displaySetsNew = getDisplaySetsForViewport(viewportId) || []; + const [displaySets, setDisplaySets] = useState(displaySetsNew); + useEffect(() => { const handleViewportChange = ({ viewportId }) => { - setDisplaySets(getDisplaySetsForViewport(viewportId)); + const displaySetsNew = getDisplaySetsForViewport(viewportId); + setDisplaySets(displaySetsNew); }; const handleGridStateChange = ({ state }) => { const activeViewportId = state.activeViewportId; if (activeViewportId) { - setDisplaySets(getDisplaySetsForViewport(activeViewportId)); + const displaySetsNew = getDisplaySetsForViewport(activeViewportId); + setDisplaySets(displaySetsNew); } }; diff --git a/platform/core/src/services/MeasurementService/MeasurementService.ts b/platform/core/src/services/MeasurementService/MeasurementService.ts index 0fa3bf7c8..f85b551f6 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.ts +++ b/platform/core/src/services/MeasurementService/MeasurementService.ts @@ -405,11 +405,14 @@ class MeasurementService extends PubSubService { let measurement = {}; try { measurement = toMeasurementSchema(data); + if (!measurement) { + return; + } measurement.source = source; } catch (error) { log.warn( `Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`, - error.message + error ); return; } @@ -589,6 +592,30 @@ class MeasurementService extends PubSubService { }); } + /** + * Remove multiple measurements at once. + */ + removeMany(measurementUIDs: string[]): void { + const measurements = []; + for (const measurementUID of measurementUIDs) { + const measurement = + this.measurements.get(measurementUID) || this.unmappedMeasurements.get(measurementUID); + + if (!measurementUID || !measurement) { + console.debug(`No uid provided, or unable to find measurement by uid.`); + continue; + } + + this.unmappedMeasurements.delete(measurementUID); + this.measurements.delete(measurementUID); + measurements.push(measurement); + } + if (!measurements.length) { + return; + } + this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements }); + } + /** * Clears measurements that match the filter, defaulting to all of them. * That allows, for example, clearing all of a single studies measurements @@ -769,7 +796,7 @@ class MeasurementService extends PubSubService { }); } - public toggleVisibilityMeasurement(measurementUID: string): void { + public toggleVisibilityMeasurement(measurementUID: string, visibility?: boolean): void { const measurement = this.measurements.get(measurementUID); if (!measurement) { @@ -777,7 +804,10 @@ class MeasurementService extends PubSubService { return; } - measurement.isVisible = !measurement.isVisible; + if (measurement.isVisible === visibility && visibility !== undefined) { + return; + } + measurement.isVisible = visibility !== undefined ? visibility : !measurement.isVisible; this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, { source: measurement.source, @@ -786,6 +816,10 @@ class MeasurementService extends PubSubService { }); } + public toggleVisibilityMeasurementMany(measurementUIDs: string[], visibility?: boolean): void { + return measurementUIDs.forEach(uid => this.toggleVisibilityMeasurement(uid, visibility)); + } + public updateColorMeasurement(measurementUID: string, color: number[]): void { const measurement = this.measurements.get(measurementUID); diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index 856bb3223..14ac3307a 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -130,6 +130,9 @@ class ViewportGridService extends PubSubService { } public setActiveViewportId(id: string) { + if (id === this.getActiveViewportId()) { + return; + } this.serviceImplementation._setActiveViewport(id); // Use queueMicrotask to delay the event broadcast diff --git a/platform/core/src/types/DisplaySet.ts b/platform/core/src/types/DisplaySet.ts index d81d4842b..52c19ac0d 100644 --- a/platform/core/src/types/DisplaySet.ts +++ b/platform/core/src/types/DisplaySet.ts @@ -3,6 +3,7 @@ import { InstanceMetadata } from './StudyMetadata'; export type DisplaySet = { displaySetInstanceUID: string; instances: InstanceMetadata[]; + isReconstructable?: boolean; StudyInstanceUID: string; SeriesInstanceUID?: string; SeriesNumber?: number; diff --git a/platform/core/src/utils/measurementFilters.ts b/platform/core/src/utils/measurementFilters.ts index db36a305c..ef1a887f6 100644 --- a/platform/core/src/utils/measurementFilters.ts +++ b/platform/core/src/utils/measurementFilters.ts @@ -1,11 +1,19 @@ +import MeasurementService from '../services/MeasurementService'; /** * Returns a filter function which filters for measurements belonging to both * the study and series. */ export function filterMeasurementsBySeriesUID(selectedSeries: string[]) { + if (!selectedSeries) { + return; + } return measurement => selectedSeries.includes(measurement.referenceSeriesUID); } +export function filterMeasurementsByStudyUID(studyUID) { + return measurement => measurement.referenceStudyUID == studyUID; +} + /** * @returns true for measurements include referencedImageId (coplanar with an image) */ @@ -13,6 +21,10 @@ export function filterPlanarMeasurement(measurement) { return measurement?.referencedImageId; } +export function filterTool(toolName: string) { + return annotation => annotation.metadata?.toolName === toolName; +} + /** A filter that always returns true */ export function filterAny(_measurement) { return true; @@ -30,6 +42,10 @@ export function filterNone(_measurement) { export function filterOr(...filters) { return function (item) { for (let filter of filters) { + if (!filter) { + // Un undefined filter means all, so return true for the or + return true; + } if (typeof filter === 'string') { filter = this[filter]; } @@ -44,13 +60,14 @@ export function filterOr(...filters) { }; } +const { POINT } = MeasurementService.VALUE_TYPES; + /** * Filters for additional findings, that is, measurements with * a value of type point, and having a referenced image */ -export function filterAdditionalFindings(measurementService) { - const { POINT } = measurementService.VALUE_TYPES; - return dm => dm.type === POINT && dm.referencedImageId; +export function filterAdditionalFindings(dm) { + return dm.type === POINT && dm.referencedImageId; } /** @@ -70,8 +87,15 @@ const isString = s => typeof s === 'string' || s instanceof String; * called on the final filter call. */ export function filterAnd(...filters) { + const nonNullFilters = filters.filter(filter => !!filter); + if (!nonNullFilters.length) { + return; + } + if (nonNullFilters.length === 1 && typeof nonNullFilters[0] === 'function') { + return nonNullFilters[0]; + } return function (item) { - for (const filter of filters) { + for (const filter of nonNullFilters) { if (isString(filter)) { if (!this[filter](item)) { return false; @@ -98,7 +122,7 @@ export function filterAnd(...filters) { */ export function filterNot(...filters) { if (filters.length !== 1) { - return filterAnd.apply(null, filters.map(filterNot)); + return filterAnd(...filters.map(filter => filterNot(filter))); } const [filter] = filters; if (isString(filter)) { diff --git a/platform/docs/src/pages/patterns/DataRowExample.tsx b/platform/docs/src/pages/patterns/DataRowExample.tsx index 16127bf35..cc398eb44 100644 --- a/platform/docs/src/pages/patterns/DataRowExample.tsx +++ b/platform/docs/src/pages/patterns/DataRowExample.tsx @@ -1,7 +1,5 @@ import React from 'react'; import { DataRow } from '../../../../ui-next/src/components/DataRow'; -import { Button } from '../../../../ui-next/src/components/Button'; -import { Icons } from '../../../../ui-next/src/components/Icons'; // Mock data to demonstrate DataRow usage const mockData = [ diff --git a/platform/ui-next/src/components/Accordion/Accordion.tsx b/platform/ui-next/src/components/Accordion/Accordion.tsx index e7e2dc306..9e20e200d 100644 --- a/platform/ui-next/src/components/Accordion/Accordion.tsx +++ b/platform/ui-next/src/components/Accordion/Accordion.tsx @@ -35,8 +35,14 @@ const AccordionTrigger = React.forwardRef< )} {...props} > - {children} - + {props.asChild ? ( + children + ) : ( + <> + {children} + + + )} )); diff --git a/platform/ui-next/src/components/ColorCircle.tsx b/platform/ui-next/src/components/ColorCircle.tsx new file mode 100644 index 000000000..ec2b82d38 --- /dev/null +++ b/platform/ui-next/src/components/ColorCircle.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export function ColorCircle({ colorHex, className = 'inline-flex' }) { + return ( +
+ +
+ ); +} diff --git a/platform/ui-next/src/components/DataRow/DataRow.tsx b/platform/ui-next/src/components/DataRow/DataRow.tsx index 7d084e8a8..31e5ef47d 100644 --- a/platform/ui-next/src/components/DataRow/DataRow.tsx +++ b/platform/ui-next/src/components/DataRow/DataRow.tsx @@ -63,25 +63,25 @@ interface DataRowProps { details?: { primary: string[]; secondary: string[] }; // isSelected?: boolean; - onSelect?: () => void; + onSelect?: (e) => void; // isVisible: boolean; - onToggleVisibility: () => void; + onToggleVisibility: (e) => void; // isLocked: boolean; - onToggleLocked: () => void; + onToggleLocked: (e) => void; // title: string; - onRename: () => void; + onRename: (e) => void; // - onDelete: () => void; + onDelete: (e) => void; // colorHex?: string; - onColor: () => void; + onColor: (e) => void; className?: string; } -const DataRow: React.FC = ({ +export const DataRow: React.FC = ({ number, title, colorHex, @@ -114,16 +114,16 @@ const DataRow: React.FC = ({ e.stopPropagation(); switch (action) { case 'Rename': - onRename(); + onRename(e); break; case 'Lock': - onToggleLocked(); + onToggleLocked(e); break; case 'Delete': - onDelete(); + onDelete(e); break; case 'Color': - onColor(); + onColor(e); break; } }; @@ -269,7 +269,7 @@ const DataRow: React.FC = ({ aria-label={isVisible ? 'Hide' : 'Show'} onClick={e => { e.stopPropagation(); - onToggleVisibility(); + onToggleVisibility(e); }} > {isVisible ? : } diff --git a/platform/ui-next/src/components/DataRow/index.ts b/platform/ui-next/src/components/DataRow/index.ts index 1f3799b2f..058a12755 100644 --- a/platform/ui-next/src/components/DataRow/index.ts +++ b/platform/ui-next/src/components/DataRow/index.ts @@ -1,3 +1 @@ -import DataRow from './DataRow'; - -export { DataRow }; +export * from './DataRow'; diff --git a/platform/ui-next/src/components/MeasurementTable/MeasurementTable.tsx b/platform/ui-next/src/components/MeasurementTable/MeasurementTable.tsx index fd371dee8..7a8b13f6d 100644 --- a/platform/ui-next/src/components/MeasurementTable/MeasurementTable.tsx +++ b/platform/ui-next/src/components/MeasurementTable/MeasurementTable.tsx @@ -5,13 +5,9 @@ import { createContext } from '../../lib/createContext'; interface MeasurementTableContext { data?: any[]; - onClick?: (uid: string) => void; - onDelete?: (uid: string) => void; - onToggleVisibility?: (uid: string) => void; - onToggleLocked?: (uid: string) => void; - onRename?: (uid: string) => void; - onColor?: (uid: string) => void; + onAction?: (e, command: string | string[], uid: string) => void; disableEditing?: boolean; + isExpanded: boolean; } const [MeasurementTableProvider, useMeasurementTableContext] = @@ -24,12 +20,8 @@ interface MeasurementDataProps extends MeasurementTableContext { const MeasurementTable = ({ data = [], - onClick, - onDelete, - onToggleVisibility, - onToggleLocked, - onRename, - onColor, + onAction, + isExpanded = true, title, children, disableEditing = false, @@ -40,19 +32,18 @@ const MeasurementTable = ({ return ( - + {`${t(title)} (${amount})`} - {children} + {children} ); @@ -99,6 +90,7 @@ interface MeasurementItem { isVisible: boolean; isLocked: boolean; toolName: string; + isExpanded: boolean; } interface RowProps { @@ -107,16 +99,10 @@ interface RowProps { } const Row = ({ item, index }: RowProps) => { - const { - onClick, - onDelete, - onToggleVisibility, - onToggleLocked, - onRename, - onColor, - disableEditing, - } = useMeasurementTableContext('MeasurementTable.Row'); + const { onAction, isExpanded, disableEditing } = + useMeasurementTableContext('MeasurementTable.Row'); + const { uid } = item; return ( { colorHex={item.colorHex} isSelected={item.isSelected} details={item.displayText} - onSelect={() => onClick(item.uid)} - onDelete={() => onDelete(item.uid)} + onDelete={e => onAction(e, 'removeMeasurement', uid)} + onSelect={e => onAction(e, 'jumpToMeasurement', uid)} + onRename={e => onAction(e, 'renameMeasurement', uid)} + onToggleVisibility={e => onAction(e, 'toggleVisibilityMeasurement', uid)} + onToggleLocked={e => onAction(e, 'toggleLockMeasurement', uid)} disableEditing={disableEditing} + isExpanded={isExpanded} isVisible={item.isVisible} isLocked={item.isLocked} - onToggleVisibility={() => onToggleVisibility(item.uid)} - onToggleLocked={() => onToggleLocked(item.uid)} - onRename={() => onRename(item.uid)} - // onColor={() => onColor(item.uid)} /> ); }; diff --git a/platform/ui-next/src/components/StudySummary/StudySummary.tsx b/platform/ui-next/src/components/StudySummary/StudySummary.tsx index b4bf78f38..21a2dea22 100644 --- a/platform/ui-next/src/components/StudySummary/StudySummary.tsx +++ b/platform/ui-next/src/components/StudySummary/StudySummary.tsx @@ -14,7 +14,10 @@ interface StudySummaryProps { */ const StudySummary: React.FC = ({ date, description }) => { return ( -
+
{date}
{description}
diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index c438de444..83bbe812b 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -79,8 +79,8 @@ import { } from './DropdownMenu'; import { Onboarding } from './Onboarding'; import { DoubleSlider } from './DoubleSlider'; -import { DataRow } from './DataRow'; -import { MeasurementTable } from './MeasurementTable'; +export { DataRow } from './DataRow'; +export { MeasurementTable } from './MeasurementTable'; import { SegmentationTable, useSegmentationTableContext, @@ -90,6 +90,7 @@ import { import { Toaster, toast } from './Sonner'; import { StudySummary } from './StudySummary'; import { ErrorBoundary } from './Errorboundary'; +export * from './ColorCircle'; import { Header } from './Header'; import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card'; import { @@ -208,8 +209,6 @@ export { TooltipTrigger, TooltipContent, TooltipProvider, - DataRow, - MeasurementTable, Toaster, toast, SegmentationTable, diff --git a/platform/ui-next/src/index.ts b/platform/ui-next/src/index.ts index a73446759..cf07d6bff 100644 --- a/platform/ui-next/src/index.ts +++ b/platform/ui-next/src/index.ts @@ -1,254 +1,6 @@ -import { - Button, - buttonVariants, - ThemeWrapper, - Dialog, - Command, - Popover, - Combobox, - Calendar, - DatePickerWithRange, - Separator, - Tabs, - TabsContent, - TabsList, - Clipboard, - TabsTrigger, - Toggle, - toggleVariants, - ToggleGroup, - ToggleGroupItem, - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, - Icons, - SidePanel, - StudyItem, - StudyBrowser, - StudyBrowserSort, - StudyBrowserViewOptions, - Thumbnail, - ThumbnailList, - PanelSection, - DisplaySetMessageListTooltip, - ToolboxUI, - ToolSettings, - DoubleSlider, - Label, - Slider, - Input, - Switch, - Checkbox, - Onboarding, - PopoverAnchor, - PopoverContent, - PopoverTrigger, - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectValue, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, - Card, - CardHeader, - CardFooter, - CardTitle, - CardDescription, - CardContent, - ScrollArea, - MeasurementTable, - SegmentationTable, - useSegmentationTableContext, - useSegmentationExpanded, - useSegmentStatistics, - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipContent, - StudySummary, - ErrorBoundary, - Header, - ViewportActionButton, - PatientInfo, - ViewportActionBar, - ViewportActionArrows, - ViewportPane, - ViewportActionCorners, - ViewportActionCornersLocations, - ViewportOverlay, - ViewportGrid, - ToolButton, - ToolButtonList, - ToolButtonListDefault, - ToolButtonListDropDown, - ToolButtonListItem, - ToolButtonListDivider, - Numeric, - InputDialog, - PresetDialog, - Modal, - AboutModal, - ImageModal, - UserPreferencesModal, - FooterAction, - InputFilter, -} from './components'; -import { DataRow } from './components/DataRow'; +export * from './components'; +export * from './contextProviders'; -import { - useNotification, - NotificationProvider, - useModal, - ModalProvider, - DialogProvider, - useDialog, - ManagedDialog, -} from './contextProviders'; -import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './contextProviders'; import * as utils from './utils'; -export { - ErrorBoundary, - // components - Button, - Dialog, - Command, - Popover, - Combobox, - Checkbox, - DoubleSlider, - buttonVariants, - ThemeWrapper, - Calendar, - DatePickerWithRange, - Clipboard, - // contextProviders - NotificationProvider, - useNotification, - ViewportGridContext, - ViewportGridProvider, - useViewportGrid, - Separator, - Tabs, - TabsContent, - TabsList, - TabsTrigger, - Toggle, - toggleVariants, - ToggleGroup, - ToggleGroupItem, - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, - Icons, - SidePanel, - StudyItem, - StudyBrowser, - StudyBrowserSort, - StudyBrowserViewOptions, - Thumbnail, - ThumbnailList, - PanelSection, - DisplaySetMessageListTooltip, - ToolboxUI, - Label, - Slider, - Input, - Switch, - Onboarding, - PopoverAnchor, - PopoverContent, - PopoverTrigger, - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectValue, - DataRow, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, - Card, - CardHeader, - CardFooter, - CardTitle, - CardDescription, - CardContent, - ScrollArea, - MeasurementTable, - SegmentationTable, - useSegmentationTableContext, - useSegmentationExpanded, - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipContent, - StudySummary, - Header, - ViewportActionButton, - PatientInfo, - ViewportActionBar, - ViewportActionArrows, - ViewportPane, - ViewportActionCorners, - ViewportActionCornersLocations, - ViewportOverlay, - ViewportGrid, - ToolButton, - ToolButtonList, - ToolButtonListDefault, - ToolButtonListDropDown, - ToolButtonListItem, - ToolButtonListDivider, - utils, - Numeric, - AboutModal, - ImageModal, - UserPreferencesModal, - InputDialog, - PresetDialog, - Modal, - useModal, - ModalProvider, - FooterAction, - DialogProvider, - useDialog, - ManagedDialog, - ToolSettings, - useSegmentStatistics, - InputFilter, -}; +export { utils }; diff --git a/platform/ui/src/components/Labelling/LabellingFlow.tsx b/platform/ui/src/components/Labelling/LabellingFlow.tsx index 91b12da7a..ed4ff7abf 100644 --- a/platform/ui/src/components/Labelling/LabellingFlow.tsx +++ b/platform/ui/src/components/Labelling/LabellingFlow.tsx @@ -31,7 +31,7 @@ class LabellingFlow extends Component { constructor(props) { super(props); - const { label } = props.measurementData; + const label = props.measurementData?.label; const className = props.componentClassName; this.state = { diff --git a/platform/ui/src/components/MeasurementTable/MeasurementTable.tsx b/platform/ui/src/components/MeasurementTable/MeasurementTable.tsx index 72bb0d529..e0f9137cd 100644 --- a/platform/ui/src/components/MeasurementTable/MeasurementTable.tsx +++ b/platform/ui/src/components/MeasurementTable/MeasurementTable.tsx @@ -33,8 +33,13 @@ const MeasurementTable = ({ return (
- {t(title)} - {amount} +
+ + {t(title)} + +   {amount} +
+
{data.length !== 0 &&