feat(segmentation): Enhanced segmentation panel design for TMTV (#3988)

Co-authored-by: Alireza <ar.sedghi@gmail.com>
This commit is contained in:
Ibrahim 2024-04-02 23:33:04 -04:00 committed by GitHub
parent e8e209291c
commit 9f3235ff09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2001 additions and 572 deletions

View File

@ -206,6 +206,9 @@ const commandsModule = ({
loadSegmentationDisplaySetsForViewport: async ({ viewportId, displaySets }) => {
// Todo: handle adding more than one segmentation
const displaySet = displaySets[0];
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
displaySet.referencedDisplaySetInstanceUID
);
updateViewportsForSegmentationRendering({
viewportId,
@ -221,7 +224,8 @@ const commandsModule = ({
const boundFn = segmentationService[serviceFunction].bind(segmentationService);
const segmentationId = await boundFn(segDisplaySet, null, suppressEvents);
const segmentation = segmentationService.getSegmentation(segmentationId);
segmentation.description = `S${referencedDisplaySet.SeriesNumber}: ${referencedDisplaySet.SeriesDescription}`;
return segmentationId;
},
});

View File

@ -3,6 +3,7 @@ import React from 'react';
import { useAppConfig } from '@state';
import { Toolbox } from '@ohif/ui';
import PanelSegmentation from './panels/PanelSegmentation';
import { SegmentationPanelMode } from './types/segmentation';
const getPanelModule = ({
commandsManager,
@ -17,6 +18,9 @@ const getPanelModule = ({
const [appConfig] = useAppConfig();
const disableEditingForMode = customizationService.get('segmentation.disableEditing');
const segmentationPanelMode =
customizationService.get('segmentation.segmentationPanelMode')?.value ||
SegmentationPanelMode.Dropdown;
return (
<PanelSegmentation
@ -26,12 +30,18 @@ const getPanelModule = ({
configuration={{
...configuration,
disableEditing: appConfig.disableEditing || disableEditingForMode?.value,
segmentationPanelMode: segmentationPanelMode,
}}
/>
);
};
const wrappedPanelSegmentationWithTools = configuration => {
const [appConfig] = useAppConfig();
const segmentationPanelMode =
customizationService.get('segmentation.segmentationPanelMode')?.value ||
SegmentationPanelMode.Dropdown;
return (
<>
<Toolbox
@ -50,6 +60,7 @@ const getPanelModule = ({
extensionManager={extensionManager}
configuration={{
...configuration,
segmentationPanelMode: segmentationPanelMode,
}}
/>
</>

View File

@ -3,7 +3,7 @@ export function getToolbarModule({ commandsManager, servicesManager }) {
return [
{
name: 'evaluate.cornerstone.segmentation',
evaluate: ({ viewportId, button, toolNames }) => {
evaluate: ({ viewportId, button, toolNames, disabledText }) => {
// Todo: we need to pass in the button section Id since we are kind of
// forcing the button to have black background since initially
// it is designed for the toolbox not the toolbar on top
@ -13,6 +13,7 @@ export function getToolbarModule({ commandsManager, servicesManager }) {
return {
disabled: true,
className: '!text-common-bright !bg-black opacity-50',
disabledText: disabledText ?? 'No segmentations available',
};
}
@ -28,6 +29,7 @@ export function getToolbarModule({ commandsManager, servicesManager }) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
disabledText: disabledText ?? 'Not available on the current viewport',
};
}

View File

@ -1,12 +1,17 @@
import { createReportAsync } from '@ohif/extension-default';
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { SegmentationGroupTable } from '@ohif/ui';
import { SegmentationGroupTable, SegmentationGroupTableExpanded } from '@ohif/ui';
import { SegmentationPanelMode } from '../types/segmentation';
import callInputDialog from './callInputDialog';
import callColorPickerDialog from './colorPickerDialog';
import { useTranslation } from 'react-i18next';
const components = {
[SegmentationPanelMode.Expanded]: SegmentationGroupTableExpanded,
[SegmentationPanelMode.Dropdown]: SegmentationGroupTable,
};
export default function PanelSegmentation({
servicesManager,
commandsManager,
@ -170,6 +175,22 @@ export default function PanelSegmentation({
const onToggleSegmentationVisibility = segmentationId => {
segmentationService.toggleSegmentationVisibility(segmentationId);
const segmentation = segmentationService.getSegmentation(segmentationId);
const isVisible = segmentation.isVisible;
const segments = segmentation.segments;
const toolGroupIds = getToolGroupIds(segmentationId);
toolGroupIds.forEach(toolGroupId => {
segments.forEach((segment, segmentIndex) => {
segmentationService.setSegmentVisibility(
segmentationId,
segmentIndex,
isVisible,
toolGroupId
);
});
});
};
const _setSegmentationConfiguration = useCallback(
@ -221,59 +242,53 @@ export default function PanelSegmentation({
});
};
const SegmentationGroupTableComponent = components[configuration?.segmentationPanelMode];
return (
<>
<div className="ohif-scrollbar flex min-h-0 flex-auto select-none flex-col justify-between overflow-auto">
<SegmentationGroupTable
title={t('Segmentations')}
segmentations={segmentations}
disableEditing={configuration.disableEditing}
activeSegmentationId={selectedSegmentationId || ''}
onSegmentationAdd={onSegmentationAdd}
onSegmentationClick={onSegmentationClick}
onSegmentationDelete={onSegmentationDelete}
onSegmentationDownload={onSegmentationDownload}
onSegmentationDownloadRTSS={onSegmentationDownloadRTSS}
storeSegmentation={storeSegmentation}
onSegmentationEdit={onSegmentationEdit}
onSegmentClick={onSegmentClick}
onSegmentEdit={onSegmentEdit}
onSegmentAdd={onSegmentAdd}
onSegmentColorClick={onSegmentColorClick}
onSegmentDelete={onSegmentDelete}
onToggleSegmentVisibility={onToggleSegmentVisibility}
onToggleSegmentLock={onToggleSegmentLock}
onToggleSegmentationVisibility={onToggleSegmentationVisibility}
showDeleteSegment={true}
segmentationConfig={{ initialConfig: segmentationConfiguration }}
setRenderOutline={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value)
}
setOutlineOpacityActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineOpacity', value)
}
setRenderFill={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderFill', value)
}
setRenderInactiveSegmentations={value =>
_setSegmentationConfiguration(
selectedSegmentationId,
'renderInactiveSegmentations',
value
)
}
setOutlineWidthActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineWidthActive', value)
}
setFillAlpha={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlpha', value)
}
setFillAlphaInactive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value)
}
/>
</div>
</>
<SegmentationGroupTableComponent
title={t('Segmentations')}
segmentations={segmentations}
disableEditing={configuration.disableEditing}
activeSegmentationId={selectedSegmentationId || ''}
onSegmentationAdd={onSegmentationAdd}
onSegmentationClick={onSegmentationClick}
onSegmentationDelete={onSegmentationDelete}
onSegmentationDownload={onSegmentationDownload}
onSegmentationDownloadRTSS={onSegmentationDownloadRTSS}
storeSegmentation={storeSegmentation}
onSegmentationEdit={onSegmentationEdit}
onSegmentClick={onSegmentClick}
onSegmentEdit={onSegmentEdit}
onSegmentAdd={onSegmentAdd}
onSegmentColorClick={onSegmentColorClick}
onSegmentDelete={onSegmentDelete}
onToggleSegmentVisibility={onToggleSegmentVisibility}
onToggleSegmentLock={onToggleSegmentLock}
onToggleSegmentationVisibility={onToggleSegmentationVisibility}
showDeleteSegment={true}
segmentationConfig={{ initialConfig: segmentationConfiguration }}
setRenderOutline={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value)
}
setOutlineOpacityActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineOpacity', value)
}
setRenderFill={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderFill', value)
}
setRenderInactiveSegmentations={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderInactiveSegmentations', value)
}
setOutlineWidthActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineWidthActive', value)
}
setFillAlpha={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlpha', value)
}
setFillAlphaInactive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value)
}
/>
);
}

View File

@ -0,0 +1,4 @@
export enum SegmentationPanelMode {
Expanded = 'expanded',
Dropdown = 'dropdown',
}

View File

@ -21,7 +21,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
// enabled or not
{
name: 'evaluate.cornerstoneTool',
evaluate: ({ viewportId, button }) => {
evaluate: ({ viewportId, button, disabledText }) => {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
@ -34,6 +34,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
disabledText: disabledText ?? 'Not available on the current viewport',
};
}
@ -109,7 +110,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
},
{
name: 'evaluate.cornerstoneTool.toggle',
evaluate: ({ viewportId, button }) => {
evaluate: ({ viewportId, button, disabledText }) => {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
@ -121,6 +122,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
disabledText: disabledText ?? 'Not available on the current viewport',
};
}
@ -168,13 +170,14 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
},
{
name: 'evaluate.not3D',
evaluate: ({ viewportId, button }) => {
evaluate: ({ viewportId, disabledText }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (viewport?.type === 'volume3d') {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
disabledText: disabledText ?? 'Not available on the current viewport',
};
}
},
@ -211,7 +214,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
},
{
name: 'evaluate.mpr',
evaluate: ({ viewportId, button }) => {
evaluate: ({ viewportId, disabledText = 'Selected viewport is not reconstructable' }) => {
const { protocol } = hangingProtocolService.getActiveProtocol();
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
@ -230,6 +233,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
disabledText: disabledText ?? 'Not available on the current viewport',
};
}

View File

@ -988,6 +988,7 @@ class SegmentationService extends PubSubService {
referencedVolumeId: volumeId, // Todo: this is so ugly
},
},
description: `S${displaySet.SeriesNumber}: ${displaySet.SeriesDescription}`,
};
this.addOrUpdateSegmentation(segmentation);

View File

@ -20,6 +20,8 @@ type Segment = {
isVisible: boolean;
// whether the segment is locked
isLocked: boolean;
// display texts
displayText?: string[];
};
type Segmentation = {

View File

@ -21,8 +21,6 @@ export function Toolbar({ servicesManager }) {
}
const { id, Component, componentProps } = toolDef;
const { disabled } = componentProps;
const tool = (
<Component
key={id}
@ -33,16 +31,7 @@ export function Toolbar({ servicesManager }) {
/>
);
return disabled ? (
<Tooltip
key={id}
position="bottom"
content={componentProps.label}
secondaryContent={'Not available on the current viewport'}
>
<div className={classnames('mr-1')}>{tool}</div>
</Tooltip>
) : (
return (
<div
key={id}
className="mr-1"

View File

@ -7,11 +7,14 @@ const pkg = require('./../package.json');
const ROOT_DIR = path.join(__dirname, './..');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
const outputName = `ohif-${pkg.name.split('/').pop()}`;
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
@ -42,6 +45,10 @@ module.exports = (env, argv) => {
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
new MiniCssExtractPlugin({
filename: `./dist/${outputName}.css`,
chunkFilename: `./dist/${outputName}.css`,
}),
],
});
};

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Input, Button } from '@ohif/ui';
import { PanelSection, Input, Button } from '@ohif/ui';
import { DicomMetadataStore, ServicesManager } from '@ohif/core';
import { useTranslation } from 'react-i18next';
@ -126,84 +126,102 @@ export default function PanelPetSUV({ servicesManager, commandsManager }) {
}, 0);
}
return (
<div className="invisible-scrollbar overflow-y-auto overflow-x-hidden">
{
<div className="flex flex-col">
<div className="bg-primary-dark flex flex-col space-y-4 p-4">
<Input
label={t('Patient Sex')}
labelClassName="text-white mb-2"
className="mt-1"
value={metadata.PatientSex || ''}
onChange={e => {
handleMetadataChange({
PatientSex: e.target.value,
});
}}
/>
<Input
label={t('Patient Weight (kg)')}
labelClassName="text-white mb-2"
className="mt-1"
value={metadata.PatientWeight || ''}
onChange={e => {
handleMetadataChange({
PatientWeight: e.target.value,
});
}}
/>
<Input
label={t('Total Dose (bq)')}
labelClassName="text-white mb-2"
className="mt-1"
value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose || ''}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideTotalDose: e.target.value,
},
});
}}
/>
<Input
label={t('Half Life (s)')}
labelClassName="text-white mb-2"
className="mt-1"
value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife || ''}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideHalfLife: e.target.value,
},
});
}}
/>
<Input
label={t('Injection Time (s)')}
labelClassName="text-white mb-2"
className="mt-1"
value={
metadata.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime || ''
}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadiopharmaceuticalStartTime: e.target.value,
},
});
}}
/>
<Input
label={t('Acquisition Time (s)')}
labelClassName="text-white mb-2"
className="mt-1 mb-2"
value={metadata.SeriesTime || ''}
onChange={() => {}}
/>
<Button onClick={updateMetadata}>Reload Data</Button>
<div className="ohif-scrollbar flex min-h-0 flex-auto select-none flex-col justify-between overflow-auto">
<div className="flex min-h-0 flex-col bg-black text-[13px] font-[300]">
<PanelSection title={t('Patient Information')}>
<div className="flex flex-col">
<div className="bg-primary-dark flex flex-col gap-4 p-2">
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Patient Sex')}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.PatientSex || ''}
onChange={e => {
handleMetadataChange({
PatientSex: e.target.value,
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Weight')}
labelChildren={<span className="text-aqua-pale"> kg</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.PatientWeight || ''}
onChange={e => {
handleMetadataChange({
PatientWeight: e.target.value,
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Total Dose')}
labelChildren={<span className="text-aqua-pale"> bq</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose || ''}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideTotalDose: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Half Life')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife || ''}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideHalfLife: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Injection Time')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={
metadata.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime || ''
}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadiopharmaceuticalStartTime: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Acquisition Time')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.SeriesTime || ''}
onChange={() => {}}
/>
<Button
className="!h-[26px] !w-[115px] self-end !p-0"
onClick={updateMetadata}
>
Reload Data
</Button>
</div>
</div>
</div>
}
</PanelSection>
</div>
</div>
);
}

View File

@ -0,0 +1,287 @@
import React, { useEffect, useState, useCallback, useReducer } from 'react';
import PropTypes from 'prop-types';
import { SegmentationTable, Button, Icon } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
import segmentationEditHandler from './segmentationEditHandler';
import ExportReports from './ExportReports';
import ROIThresholdConfiguration, { ROI_STAT } from './ROIThresholdConfiguration';
const LOWER_CT_THRESHOLD_DEFAULT = -1024;
const UPPER_CT_THRESHOLD_DEFAULT = 1024;
const LOWER_PT_THRESHOLD_DEFAULT = 2.5;
const UPPER_PT_THRESHOLD_DEFAULT = 100;
const WEIGHT_DEFAULT = 0.41; // a default weight for suv max often used in the literature
const DEFAULT_STRATEGY = ROI_STAT;
function reducer(state, action) {
const { payload } = action;
const { strategy, ctLower, ctUpper, ptLower, ptUpper, weight } = payload;
switch (action.type) {
case 'setStrategy':
return {
...state,
strategy,
};
case 'setThreshold':
return {
...state,
ctLower: ctLower ? ctLower : state.ctLower,
ctUpper: ctUpper ? ctUpper : state.ctUpper,
ptLower: ptLower ? ptLower : state.ptLower,
ptUpper: ptUpper ? ptUpper : state.ptUpper,
};
case 'setWeight':
return {
...state,
weight,
};
default:
return state;
}
}
export default function LegacyPanelRoiThresholdSegmentation({ servicesManager, commandsManager }) {
const { segmentationService } = servicesManager.services;
const { t } = useTranslation('PanelSUV');
const [showConfig, setShowConfig] = useState(false);
const [labelmapLoading, setLabelmapLoading] = useState(false);
const [selectedSegmentationId, setSelectedSegmentationId] = useState(null);
const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations());
const [config, dispatch] = useReducer(reducer, {
strategy: DEFAULT_STRATEGY,
ctLower: LOWER_CT_THRESHOLD_DEFAULT,
ctUpper: UPPER_CT_THRESHOLD_DEFAULT,
ptLower: LOWER_PT_THRESHOLD_DEFAULT,
ptUpper: UPPER_PT_THRESHOLD_DEFAULT,
weight: WEIGHT_DEFAULT,
});
const [tmtvValue, setTmtvValue] = useState(null);
const runCommand = useCallback(
(commandName, commandOptions = {}) => {
return commandsManager.runCommand(commandName, commandOptions);
},
[commandsManager]
);
const handleTMTVCalculation = useCallback(() => {
const tmtv = runCommand('calculateTMTV', { segmentations });
if (tmtv !== undefined) {
setTmtvValue(tmtv.toFixed(2));
}
}, [segmentations, runCommand]);
const handleROIThresholding = useCallback(() => {
const labelmap = runCommand('thresholdSegmentationByRectangleROITool', {
segmentationId: selectedSegmentationId,
config,
});
const lesionStats = runCommand('getLesionStats', { labelmap });
const suvPeak = runCommand('calculateSuvPeak', { labelmap });
const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue;
// update segDetails with the suv peak for the active segmentation
const segmentation = segmentationService.getSegmentation(selectedSegmentationId);
const cachedStats = {
lesionStats,
suvPeak,
lesionGlyoclysisStats,
};
const notYetUpdatedAtSource = true;
segmentationService.addOrUpdateSegmentation(
{
...segmentation,
...Object.assign(segmentation.cachedStats, cachedStats),
displayText: [`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`],
},
notYetUpdatedAtSource
);
handleTMTVCalculation();
}, [selectedSegmentationId, config]);
/**
* Update UI based on segmentation changes (added, removed, updated)
*/
useEffect(() => {
// ~~ Subscription
const added = segmentationService.EVENTS.SEGMENTATION_ADDED;
const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED;
const subscriptions = [];
[added, updated].forEach(evt => {
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
});
subscriptions.push(unsubscribe);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, []);
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_REMOVED,
() => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
if (segmentations.length > 0) {
setSelectedSegmentationId(segmentations[0].id);
handleTMTVCalculation();
} else {
setSelectedSegmentationId(null);
setTmtvValue(null);
}
}
);
return () => {
unsubscribe();
};
}, []);
/**
* Whenever the segmentations change, update the TMTV calculations
*/
useEffect(() => {
if (!selectedSegmentationId && segmentations.length > 0) {
setSelectedSegmentationId(segmentations[0].id);
}
handleTMTVCalculation();
}, [segmentations, selectedSegmentationId]);
return (
<>
<div className="flex flex-col">
<div className="invisible-scrollbar overflow-y-auto overflow-x-hidden">
<div className="mx-4 my-4 mb-4 flex space-x-4">
<Button
onClick={() => {
setLabelmapLoading(true);
setTimeout(() => {
runCommand('createNewLabelmapFromPT').then(segmentationId => {
setLabelmapLoading(false);
setSelectedSegmentationId(segmentationId);
});
});
}}
>
{labelmapLoading ? 'loading ...' : 'New Label'}
</Button>
<Button onClick={handleROIThresholding}>Run</Button>
</div>
<div
className="bg-secondary-dark border-secondary-light mb-2 flex h-8 cursor-pointer select-none items-center justify-around border-t outline-none first:border-0"
onClick={() => {
setShowConfig(!showConfig);
}}
>
<div className="px-4 text-base text-white">{t('ROI Threshold Configuration')}</div>
</div>
{showConfig && (
<ROIThresholdConfiguration
config={config}
dispatch={dispatch}
runCommand={runCommand}
/>
)}
{/* show segmentation table */}
<div className="mt-4">
{segmentations?.length ? (
<SegmentationTable
title={t('Segmentations')}
segmentations={segmentations}
activeSegmentationId={selectedSegmentationId}
onClick={id => {
runCommand('setSegmentationActiveForToolGroups', {
segmentationId: id,
});
setSelectedSegmentationId(id);
}}
onToggleVisibility={id => {
segmentationService.toggleSegmentationVisibility(id);
}}
onToggleVisibilityAll={ids => {
ids.map(id => {
segmentationService.toggleSegmentationVisibility(id);
});
}}
onDelete={id => {
segmentationService.remove(id);
}}
onEdit={id => {
segmentationEditHandler({
id,
servicesManager,
});
}}
/>
) : null}
</div>
{tmtvValue !== null ? (
<div className="bg-secondary-dark mt-4 flex items-baseline justify-between px-2 py-1">
<span className="text-base font-bold uppercase tracking-widest text-white">
{'TMTV:'}
</span>
<div className="text-white">{`${tmtvValue} mL`}</div>
</div>
) : null}
<ExportReports
segmentations={segmentations}
tmtvValue={tmtvValue}
config={config}
commandsManager={commandsManager}
/>
</div>
</div>
<div
className="mt-auto mb-4 flex cursor-pointer items-center justify-center text-blue-400 opacity-50 hover:opacity-80"
onClick={() => {
// navigate to a url in a new tab
window.open('https://github.com/OHIF/Viewers/blob/master/modes/tmtv/README.md', '_blank');
}}
>
<Icon
width="15px"
height="15px"
name={'info'}
className={'text-primary-active ml-4 mr-3'}
/>
<span>{'User Guide'}</span>
</div>
</>
);
}
LegacyPanelRoiThresholdSegmentation.propTypes = {
commandsManager: PropTypes.shape({
runCommand: PropTypes.func.isRequired,
}),
servicesManager: PropTypes.shape({
services: PropTypes.shape({
segmentationService: PropTypes.shape({
getSegmentation: PropTypes.func.isRequired,
getSegmentations: PropTypes.func.isRequired,
toggleSegmentationVisibility: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
EVENTS: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};

View File

@ -1,67 +1,23 @@
import React, { useEffect, useState, useCallback, useReducer } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { SegmentationTable, Button, Icon } from '@ohif/ui';
import { SegmentationGroupTableExpanded, Icon } from '@ohif/ui';
import { createReportAsync } from '@ohif/extension-default';
import { useTranslation } from 'react-i18next';
import segmentationEditHandler from './segmentationEditHandler';
import ExportReports from './ExportReports';
import ROIThresholdConfiguration, { ROI_STAT } from './ROIThresholdConfiguration';
import callInputDialog from './callInputDialog';
import callColorPickerDialog from './colorPickerDialog';
const LOWER_CT_THRESHOLD_DEFAULT = -1024;
const UPPER_CT_THRESHOLD_DEFAULT = 1024;
const LOWER_PT_THRESHOLD_DEFAULT = 2.5;
const UPPER_PT_THRESHOLD_DEFAULT = 100;
const WEIGHT_DEFAULT = 0.41; // a default weight for suv max often used in the literature
const DEFAULT_STRATEGY = ROI_STAT;
export default function PanelRoiThresholdSegmentation({
servicesManager,
commandsManager,
extensionManager,
}) {
const { segmentationService, viewportGridService, uiDialogService } = servicesManager.services;
function reducer(state, action) {
const { payload } = action;
const { strategy, ctLower, ctUpper, ptLower, ptUpper, weight } = payload;
switch (action.type) {
case 'setStrategy':
return {
...state,
strategy,
};
case 'setThreshold':
return {
...state,
ctLower: ctLower ? ctLower : state.ctLower,
ctUpper: ctUpper ? ctUpper : state.ctUpper,
ptLower: ptLower ? ptLower : state.ptLower,
ptUpper: ptUpper ? ptUpper : state.ptUpper,
};
case 'setWeight':
return {
...state,
weight,
};
default:
return state;
}
}
export default function PanelRoiThresholdSegmentation({ servicesManager, commandsManager }) {
const { segmentationService } = servicesManager.services;
const { t } = useTranslation('PanelSUV');
const [showConfig, setShowConfig] = useState(false);
const [labelmapLoading, setLabelmapLoading] = useState(false);
const [selectedSegmentationId, setSelectedSegmentationId] = useState(null);
const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations());
const [config, dispatch] = useReducer(reducer, {
strategy: DEFAULT_STRATEGY,
ctLower: LOWER_CT_THRESHOLD_DEFAULT,
ctUpper: UPPER_CT_THRESHOLD_DEFAULT,
ptLower: LOWER_PT_THRESHOLD_DEFAULT,
ptUpper: UPPER_PT_THRESHOLD_DEFAULT,
weight: WEIGHT_DEFAULT,
});
const [tmtvValue, setTmtvValue] = useState(null);
const runCommand = useCallback(
(commandName, commandOptions = {}) => {
return commandsManager.runCommand(commandName, commandOptions);
@ -69,46 +25,6 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command
[commandsManager]
);
const handleTMTVCalculation = useCallback(() => {
const tmtv = runCommand('calculateTMTV', { segmentations });
if (tmtv !== undefined) {
setTmtvValue(tmtv.toFixed(2));
}
}, [segmentations, runCommand]);
const handleROIThresholding = useCallback(() => {
const labelmap = runCommand('thresholdSegmentationByRectangleROITool', {
segmentationId: selectedSegmentationId,
config,
});
const lesionStats = runCommand('getLesionStats', { labelmap });
const suvPeak = runCommand('calculateSuvPeak', { labelmap });
const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue;
// update segDetails with the suv peak for the active segmentation
const segmentation = segmentationService.getSegmentation(selectedSegmentationId);
const cachedStats = {
lesionStats,
suvPeak,
lesionGlyoclysisStats,
};
const notYetUpdatedAtSource = true;
segmentationService.addOrUpdateSegmentation(
{
...segmentation,
...Object.assign(segmentation.cachedStats, cachedStats),
displayText: [`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`],
},
notYetUpdatedAtSource
);
handleTMTVCalculation();
}, [selectedSegmentationId, config]);
/**
* Update UI based on segmentation changes (added, removed, updated)
*/
@ -116,9 +32,10 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command
// ~~ Subscription
const added = segmentationService.EVENTS.SEGMENTATION_ADDED;
const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED;
const removed = segmentationService.EVENTS.SEGMENTATION_REMOVED;
const subscriptions = [];
[added, updated].forEach(evt => {
[added, updated, removed].forEach(evt => {
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
@ -133,109 +50,222 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command
};
}, []);
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_REMOVED,
() => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
const onSegmentationClick = (segmentationId: string) => {
segmentationService.setActiveSegmentationForToolGroup(segmentationId);
setSelectedSegmentationId(segmentationId);
};
if (segmentations.length > 0) {
setSelectedSegmentationId(segmentations[0].id);
handleTMTVCalculation();
} else {
setSelectedSegmentationId(null);
setTmtvValue(null);
}
const onSegmentationAdd = async () => {
runCommand('createNewLabelmapFromPT').then(segmentationId => {
setSelectedSegmentationId(segmentationId);
});
};
const onSegmentAdd = segmentationId => {
segmentationService.addSegment(segmentationId);
};
const getToolGroupIds = segmentationId => {
const toolGroupIds = segmentationService.getToolGroupIdsWithSegmentation(segmentationId);
return toolGroupIds;
};
const onSegmentClick = (segmentationId, segmentIndex) => {
segmentationService.setActiveSegment(segmentationId, segmentIndex);
const toolGroupIds = getToolGroupIds(segmentationId);
toolGroupIds.forEach(toolGroupId => {
// const toolGroupId =
segmentationService.setActiveSegmentationForToolGroup(segmentationId, toolGroupId);
segmentationService.jumpToSegmentCenter(segmentationId, segmentIndex, toolGroupId);
});
};
const _setSegmentationConfiguration = useCallback(
(segmentationId, key, value) => {
segmentationService.setConfiguration({
segmentationId,
[key]: value,
});
},
[segmentationService]
);
const onToggleSegmentVisibility = (segmentationId, segmentIndex) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const segmentInfo = segmentation.segments[segmentIndex];
const isVisible = !segmentInfo.isVisible;
const toolGroupIds = getToolGroupIds(segmentationId);
toolGroupIds.forEach(toolGroupId => {
segmentationService.setSegmentVisibility(
segmentationId,
segmentIndex,
isVisible,
toolGroupId
);
});
};
const onSegmentDelete = (segmentationId, segmentIndex) => {
segmentationService.removeSegment(segmentationId, segmentIndex);
};
const onSegmentEdit = (segmentationId, segmentIndex) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const segment = segmentation.segments[segmentIndex];
const { label } = segment;
callInputDialog(uiDialogService, label, (label, actionId) => {
if (label === '') {
return;
}
);
return () => {
unsubscribe();
segmentationService.setSegmentLabel(segmentationId, segmentIndex, label);
});
};
const onToggleSegmentLock = (segmentationId, segmentIndex) => {
segmentationService.toggleSegmentLocked(segmentationId, segmentIndex);
};
const onSegmentColorClick = (segmentationId, segmentIndex) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const segment = segmentation.segments[segmentIndex];
const { color, opacity } = segment;
const rgbaColor = {
r: color[0],
g: color[1],
b: color[2],
a: opacity / 255.0,
};
}, []);
/**
* Whenever the segmentations change, update the TMTV calculations
*/
useEffect(() => {
if (!selectedSegmentationId && segmentations.length > 0) {
setSelectedSegmentationId(segmentations[0].id);
callColorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
if (actionId === 'cancel') {
return;
}
segmentationService.setSegmentRGBAColor(segmentationId, segmentIndex, [
newRgbaColor.r,
newRgbaColor.g,
newRgbaColor.b,
newRgbaColor.a * 255.0,
]);
});
};
const storeSegmentation = async segmentationId => {
const datasources = extensionManager.getActiveDataSource();
const displaySetInstanceUIDs = await createReportAsync({
servicesManager,
getReport: () =>
commandsManager.runCommand('storeSegmentation', {
segmentationId,
dataSource: datasources[0],
}),
reportType: 'Segmentation',
});
// Show the exported report in the active viewport as read only (similar to SR)
if (displaySetInstanceUIDs) {
// clear the segmentation that we exported, similar to the storeMeasurement
// where we remove the measurements and prompt again the user if they would like
// to re-read the measurements in a SR read only viewport
segmentationService.remove(segmentationId);
viewportGridService.setDisplaySetsForViewport({
viewportId: viewportGridService.getActiveViewportId(),
displaySetInstanceUIDs,
});
}
};
handleTMTVCalculation();
}, [segmentations, selectedSegmentationId]);
const onSegmentationDownloadRTSS = segmentationId => {
commandsManager.runCommand('downloadRTSS', {
segmentationId,
});
};
const onSegmentationDownload = segmentationId => {
commandsManager.runCommand('downloadSegmentation', {
segmentationId,
});
};
const tmtvValue = segmentations?.[0]?.cachedStats?.tmtv?.value || null;
const config = segmentations?.[0]?.cachedStats?.tmtv?.config || {};
return (
<>
<div className="flex flex-col">
<div className="invisible-scrollbar overflow-y-auto overflow-x-hidden">
<div className="mx-4 my-4 mb-4 flex space-x-4">
<Button
onClick={() => {
setLabelmapLoading(true);
setTimeout(() => {
runCommand('createNewLabelmapFromPT').then(segmentationId => {
setLabelmapLoading(false);
setSelectedSegmentationId(segmentationId);
});
{/* show segmentation table */}
<div className="flex min-h-0 flex-col bg-black text-[13px] font-[300]">
<SegmentationGroupTableExpanded
disableEditing={false}
showAddSegmentation={true}
showAddSegment={false}
showDeleteSegment={true}
segmentations={segmentations}
onSegmentationAdd={onSegmentationAdd}
onSegmentationClick={onSegmentationClick}
onToggleSegmentationVisibility={id => {
segmentationService.toggleSegmentationVisibility(id);
}}
onToggleSegmentVisibility={onToggleSegmentVisibility}
onSegmentationDelete={id => {
segmentationService.remove(id);
}}
onSegmentationEdit={id => {
segmentationEditHandler({
id,
servicesManager,
});
}}
>
{labelmapLoading ? 'loading ...' : 'New Label'}
</Button>
<Button onClick={handleROIThresholding}>Run</Button>
</div>
<div
className="bg-secondary-dark border-secondary-light mb-2 flex h-8 cursor-pointer select-none items-center justify-around border-t outline-none first:border-0"
onClick={() => {
setShowConfig(!showConfig);
}}
>
<div className="px-4 text-base text-white">{t('ROI Threshold Configuration')}</div>
</div>
{showConfig && (
<ROIThresholdConfiguration
config={config}
dispatch={dispatch}
runCommand={runCommand}
segmentationConfig={{ initialConfig: segmentationService.getConfiguration() }}
onSegmentAdd={onSegmentAdd}
onSegmentClick={onSegmentClick}
onSegmentDelete={onSegmentDelete}
onSegmentEdit={onSegmentEdit}
onToggleSegmentLock={onToggleSegmentLock}
onSegmentColorClick={onSegmentColorClick}
storeSegmentation={storeSegmentation}
onSegmentationDownloadRTSS={onSegmentationDownloadRTSS}
onSegmentationDownload={onSegmentationDownload}
setRenderOutline={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value)
}
setOutlineOpacityActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineOpacity', value)
}
setRenderFill={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderFill', value)
}
setRenderInactiveSegmentations={value =>
_setSegmentationConfiguration(
selectedSegmentationId,
'renderInactiveSegmentations',
value
)
}
setOutlineWidthActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineWidthActive', value)
}
setFillAlpha={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlpha', value)
}
setFillAlphaInactive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value)
}
/>
)}
{/* show segmentation table */}
<div className="mt-4">
{segmentations?.length ? (
<SegmentationTable
title={t('Segmentations')}
segmentations={segmentations}
activeSegmentationId={selectedSegmentationId}
onClick={id => {
runCommand('setSegmentationActiveForToolGroups', {
segmentationId: id,
});
setSelectedSegmentationId(id);
}}
onToggleVisibility={id => {
segmentationService.toggleSegmentationVisibility(id);
}}
onToggleVisibilityAll={ids => {
ids.map(id => {
segmentationService.toggleSegmentationVisibility(id);
});
}}
onDelete={id => {
segmentationService.remove(id);
}}
onEdit={id => {
segmentationEditHandler({
id,
servicesManager,
});
}}
/>
) : null}
</div>
{tmtvValue !== null ? (
<div className="bg-secondary-dark mt-4 flex items-baseline justify-between px-2 py-1">
<div className="bg-secondary-dark mt-1 flex items-baseline justify-between px-2 py-1">
<span className="text-base font-bold uppercase tracking-widest text-white">
{'TMTV:'}
</span>
@ -251,7 +281,7 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command
</div>
</div>
<div
className="mt-auto mb-4 flex cursor-pointer items-center justify-center text-blue-400 opacity-50 hover:opacity-80"
className="absolute bottom-1 flex cursor-pointer items-center justify-center text-blue-400 opacity-50 hover:opacity-80"
onClick={() => {
// navigate to a url in a new tab
window.open('https://github.com/OHIF/Viewers/blob/master/modes/tmtv/README.md', '_blank');

View File

@ -14,7 +14,7 @@ function ROIThresholdConfiguration({ config, dispatch, runCommand }) {
const { t } = useTranslation('ROIThresholdConfiguration');
return (
<div className="bg-primary-dark flex flex-col space-y-4 px-4 py-2">
<div className="bg-primary-dark flex flex-col space-y-4">
<div className="flex items-end space-x-2">
<div className="flex w-1/2 flex-col">
<Select
@ -62,8 +62,8 @@ function ROIThresholdConfiguration({ config, dispatch, runCommand }) {
{config.strategy === ROI_STAT && (
<Input
label={t('Percentage of Max SUV')}
labelClassName="text-white"
className="border-primary-main mt-2 bg-black"
labelClassName="text-[13px] font-inter text-white"
className="border-primary-main bg-black"
type="text"
containerClassName="mr-2"
value={config.weight}
@ -83,11 +83,11 @@ function ROIThresholdConfiguration({ config, dispatch, runCommand }) {
<tbody>
<tr className="mt-2">
<td
className="pr-4 pt-2"
className="pr-4"
colSpan="3"
>
<Label
className="text-white"
className="font-inter text-[13px] text-white"
text="Lower & Upper Ranges"
></Label>
</td>

View File

@ -0,0 +1,62 @@
import React from 'react';
import { Input, Dialog, ButtonEnums } from '@ohif/ui';
function callInputDialog(uiDialogService, label, callback) {
const dialogId = 'enter-segment-label';
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
callback(value.label, action.id);
break;
case 'cancel':
callback('', action.id);
break;
}
uiDialogService.dismiss({ id: dialogId });
};
if (uiDialogService) {
uiDialogService.create({
id: dialogId,
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Segment',
value: { label },
noCloseButton: true,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
actions: [
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
{ id: 'save', text: 'Confirm', type: ButtonEnums.type.primary },
],
onSubmit: onSubmitHandler,
body: ({ value, setValue }) => {
return (
<Input
label="Enter the segment label"
labelClassName="text-white text-[14px] leading-[1.2]"
autoFocus
className="border-primary-main bg-black"
type="text"
value={value.label}
onChange={event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
}}
onKeyPress={event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
}}
/>
);
},
},
});
}
}
export default callInputDialog;

View File

@ -0,0 +1,3 @@
.chrome-picker {
background: #090c29 !important;
}

View File

@ -0,0 +1,58 @@
import React from 'react';
import { Dialog } from '@ohif/ui';
import { ChromePicker } from 'react-color';
import './colorPickerDialog.css';
function callColorPickerDialog(uiDialogService, rgbaColor, callback) {
const dialogId = 'pick-color';
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
callback(value.rgbaColor, action.id);
break;
case 'cancel':
callback('', action.id);
break;
}
uiDialogService.dismiss({ id: dialogId });
};
if (uiDialogService) {
uiDialogService.create({
id: dialogId,
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Segment Color',
value: { rgbaColor },
noCloseButton: true,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
actions: [
{ id: 'cancel', text: 'Cancel', type: 'primary' },
{ id: 'save', text: 'Save', type: 'secondary' },
],
onSubmit: onSubmitHandler,
body: ({ value, setValue }) => {
const handleChange = color => {
setValue({ rgbaColor: color.rgb });
};
return (
<ChromePicker
color={value.rgbaColor}
onChange={handleChange}
presetColors={[]}
width={300}
/>
);
},
},
});
}
}
export default callColorPickerDialog;

View File

@ -0,0 +1,214 @@
import React, { useState, useCallback, useReducer, useEffect } from 'react';
import { Button } from '@ohif/ui';
import ROIThresholdConfiguration, {
ROI_STAT,
} from './PanelROIThresholdSegmentation/ROIThresholdConfiguration';
import * as cs3dTools from '@cornerstonejs/tools';
const LOWER_CT_THRESHOLD_DEFAULT = -1024;
const UPPER_CT_THRESHOLD_DEFAULT = 1024;
const LOWER_PT_THRESHOLD_DEFAULT = 2.5;
const UPPER_PT_THRESHOLD_DEFAULT = 100;
const WEIGHT_DEFAULT = 0.41; // a default weight for suv max often used in the literature
const DEFAULT_STRATEGY = ROI_STAT;
function reducer(state, action) {
const { payload } = action;
const { strategy, ctLower, ctUpper, ptLower, ptUpper, weight } = payload;
switch (action.type) {
case 'setStrategy':
return {
...state,
strategy,
};
case 'setThreshold':
return {
...state,
ctLower: ctLower ? ctLower : state.ctLower,
ctUpper: ctUpper ? ctUpper : state.ctUpper,
ptLower: ptLower ? ptLower : state.ptLower,
ptUpper: ptUpper ? ptUpper : state.ptUpper,
};
case 'setWeight':
return {
...state,
weight,
};
default:
return state;
}
}
function RectangleROIOptions({ servicesManager, commandsManager }) {
const { segmentationService } = servicesManager.services;
const [selectedSegmentationId, setSelectedSegmentationId] = useState(null);
const runCommand = useCallback(
(commandName, commandOptions = {}) => {
return commandsManager.runCommand(commandName, commandOptions);
},
[commandsManager]
);
const [config, dispatch] = useReducer(reducer, {
strategy: DEFAULT_STRATEGY,
ctLower: LOWER_CT_THRESHOLD_DEFAULT,
ctUpper: UPPER_CT_THRESHOLD_DEFAULT,
ptLower: LOWER_PT_THRESHOLD_DEFAULT,
ptUpper: UPPER_PT_THRESHOLD_DEFAULT,
weight: WEIGHT_DEFAULT,
});
const handleROIThresholding = useCallback(() => {
const segmentationId = selectedSegmentationId;
const segmentation = segmentationService.getSegmentation(segmentationId);
const activeSegmentIndex =
cs3dTools.segmentation.segmentIndex.getActiveSegmentIndex(segmentationId);
// run the threshold based on the active segment index
// Todo: later find a way to associate each rectangle with a segment (e.g., maybe with color?)
const labelmap = runCommand('thresholdSegmentationByRectangleROITool', {
segmentationId,
config,
segmentIndex: activeSegmentIndex,
});
// re-calculating the cached stats for the active segmentation
const updatedPerSegmentCachedStats = {};
segmentation.segments = segmentation.segments.map(segment => {
if (!segment || !segment.segmentIndex) {
return segment;
}
const segmentIndex = segment.segmentIndex;
const lesionStats = runCommand('getLesionStats', { labelmap, segmentIndex });
const suvPeak = runCommand('calculateSuvPeak', { labelmap, segmentIndex });
const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue;
// update segDetails with the suv peak for the active segmentation
const cachedStats = {
lesionStats,
suvPeak,
lesionGlyoclysisStats,
};
segment.cachedStats = cachedStats;
segment.displayText = [
`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`,
`Volume: ${lesionStats.volume.toFixed(2)} mm3`,
];
updatedPerSegmentCachedStats[segmentIndex] = cachedStats;
return segment;
});
const notYetUpdatedAtSource = true;
const segmentations = segmentationService.getSegmentations();
const tmtv = runCommand('calculateTMTV', { segmentations });
segmentation.cachedStats = Object.assign(
segmentation.cachedStats,
updatedPerSegmentCachedStats,
{
tmtv: {
value: tmtv.toFixed(3),
config: { ...config },
},
}
);
segmentationService.addOrUpdateSegmentation(
{
...segmentation,
},
false, // don't suppress events
notYetUpdatedAtSource
);
}, [selectedSegmentationId, config]);
useEffect(() => {
const segmentations = segmentationService.getSegmentations();
if (!segmentations.length) {
return;
}
const isActive = segmentations.find(seg => seg.isActive);
setSelectedSegmentationId(isActive.id);
}, []);
/**
* Update UI based on segmentation changes (added, removed, updated)
*/
useEffect(() => {
// ~~ Subscription
const added = segmentationService.EVENTS.SEGMENTATION_ADDED;
const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED;
const subscriptions = [];
[added, updated].forEach(evt => {
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentations();
if (!segmentations.length) {
return;
}
const isActive = segmentations.find(seg => seg.isActive);
setSelectedSegmentationId(isActive.id);
});
subscriptions.push(unsubscribe);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, []);
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_REMOVED,
() => {
const segmentations = segmentationService.getSegmentations();
if (segmentations.length > 0) {
setSelectedSegmentationId(segmentations[0].id);
handleROIThresholding();
} else {
setSelectedSegmentationId(null);
handleROIThresholding();
}
}
);
return () => {
unsubscribe();
};
}, []);
return (
<div className="invisible-scrollbar mb-2 flex flex-col overflow-y-auto overflow-x-hidden">
<ROIThresholdConfiguration
config={config}
dispatch={dispatch}
runCommand={runCommand}
/>
{selectedSegmentationId !== null && (
<Button
className="mt-2 !h-[26px] !w-[75px]"
onClick={handleROIThresholding}
>
Run
</Button>
)}
</div>
);
}
export default RectangleROIOptions;

View File

@ -112,6 +112,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
// Create a segmentation of the same resolution as the source data
// using volumeLoader.createAndCacheDerivedVolume.
const { viewportMatchDetails } = hangingProtocolService.getMatchDetails();
const ptDisplaySet = actions.getMatchingPTDisplaySet({
viewportMatchDetails,
});
@ -121,8 +122,11 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
return;
}
const currentSegmentations = segmentationService.getSegmentations();
const segmentationId = await segmentationService.createSegmentationForDisplaySet(
ptDisplaySet.displaySetInstanceUID
ptDisplaySet.displaySetInstanceUID,
{ label: `Segmentation ${currentSegmentations.length + 1}` }
);
// Add Segmentation to all toolGroupIds in the viewer
@ -141,6 +145,12 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
segmentationService.setActiveSegmentationForToolGroup(segmentationId, toolGroupId);
}
segmentationService.addSegment(segmentationId, {
segmentIndex: 1,
properties: {
label: 'Segment 1',
},
});
return segmentationId;
},
setSegmentationActiveForToolGroups: ({ segmentationId }) => {
@ -150,7 +160,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
segmentationService.setActiveSegmentationForToolGroup(segmentationId, toolGroupId);
});
},
thresholdSegmentationByRectangleROITool: ({ segmentationId, config }) => {
thresholdSegmentationByRectangleROITool: ({ segmentationId, config, segmentIndex }) => {
const segmentation = csTools.segmentation.state.getSegmentation(segmentationId);
const { representationData } = segmentation;
@ -201,12 +211,11 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
{ volume: referencedVolume, lower: ptLower, upper: ptUpper },
{ volume: ctReferencedVolume, lower: ctLower, upper: ctUpper },
],
{ overwrite: true }
{ overwrite: true, segmentIndex }
);
},
calculateSuvPeak: ({ labelmap }) => {
calculateSuvPeak: ({ labelmap, segmentIndex }) => {
const { referencedVolumeId } = labelmap;
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
const annotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
@ -217,7 +226,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
csTools.annotation.state.getAnnotation(annotationUID)
);
const suvPeak = calculateSuvPeak(labelmap, referencedVolume, annotations);
const suvPeak = calculateSuvPeak(labelmap, referencedVolume, annotations, segmentIndex);
return {
suvPeak: suvPeak.mean,
suvMax: suvPeak.max,

View File

@ -1,5 +1,6 @@
import React from 'react';
import { PanelPetSUV, PanelROIThresholdSegmentation } from './Panels';
import { Toolbox } from '@ohif/ui';
// TODO:
// - No loading UI exists yet
@ -12,18 +13,26 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager })
<PanelPetSUV
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
);
};
const wrappedROIThresholdSeg = () => {
return (
<PanelROIThresholdSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
<>
<Toolbox
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
buttonSectionId="tmtvToolbox"
title="Threshold Tools"
/>
<PanelROIThresholdSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
</>
);
};
@ -31,15 +40,15 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager })
{
name: 'petSUV',
iconName: 'tab-patient-info',
iconLabel: 'PET SUV',
label: 'PET SUV',
iconLabel: 'Patient Info',
label: 'Patient Info',
component: wrappedPanelPetSuv,
},
{
name: 'ROIThresholdSeg',
iconName: 'tab-roi-threshold',
iconLabel: 'ROI Threshold',
label: 'ROI Threshold',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedROIThresholdSeg,
},
];

View File

@ -0,0 +1,10 @@
import RectangleROIOptions from './Panels/RectangleROIOptions';
export default function getToolbarModule({ commandsManager, servicesManager }) {
return [
{
name: 'tmtv.RectangleROIThresholdOptions',
defaultComponent: () => RectangleROIOptions({ commandsManager, servicesManager }),
},
];
}

View File

@ -3,6 +3,7 @@ import getHangingProtocolModule from './getHangingProtocolModule';
import getPanelModule from './getPanelModule';
import init from './init';
import commandsModule from './commandsModule';
import getToolbarModule from './getToolbarModule';
/**
*
@ -15,6 +16,7 @@ const tmtvExtension = {
preRegistration({ servicesManager, commandsManager, extensionManager, configuration = {} }) {
init({ servicesManager, commandsManager, extensionManager, configuration });
},
getToolbarModule,
getPanelModule,
getHangingProtocolModule,
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {

View File

@ -43,8 +43,6 @@ const RectangleROIStartEndThreshold = {
displaySet = displaySetService.getDisplaySetsForSeries(SeriesInstanceUID);
}
const { cachedStats } = data;
return {
uid: annotationUID,
SOPInstanceUID,
@ -56,10 +54,8 @@ const RectangleROIStartEndThreshold = {
toolName: metadata.toolName,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: metadata.label,
// displayText: displayText,
data: data.cachedStats,
type: 'RectangleROIStartEndThreshold',
// getReport,
};
},
};

View File

@ -119,7 +119,10 @@ const toolbarButtons: Button[] = [
icon: 'tool-3d-rotate',
label: '3D Rotate',
commands: setToolActiveToolbar,
evaluate: 'evaluate.cornerstoneTool',
evaluate: {
name: 'evaluate.cornerstoneTool',
disabledText: 'Select a 3D viewport to enable this tool',
},
},
},
{
@ -154,7 +157,10 @@ const toolbarButtons: Button[] = [
toolGroupIds: ['mpr'],
},
},
evaluate: 'evaluate.cornerstoneTool',
evaluate: {
name: 'evaluate.cornerstoneTool',
disabledText: 'Select an MPR viewport to enable this tool',
},
},
},
];

View File

@ -25,6 +25,7 @@ const toolbarButtons: Button[] = [
evaluate: {
name: 'evaluate.cornerstone.segmentation',
options: { toolNames: ['CircularBrush', 'SphereBrush'] },
disabledText: 'Create new segmentation to enable this tool.',
},
commands: _createSetToolActiveCommands('CircularBrush'),
options: [
@ -95,7 +96,7 @@ const toolbarButtons: Button[] = [
{
id: 'Threshold',
icon: 'icon-tool-threshold',
label: 'Eraser',
label: 'Threshold Tool',
evaluate: {
name: 'evaluate.cornerstone.segmentation',
options: { toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush'] },

View File

@ -75,7 +75,10 @@ const toolbarButtons: Button[] = [
icon: 'tool-3d-rotate',
label: '3D Rotate',
commands: setToolActiveToolbar,
evaluate: 'evaluate.cornerstoneTool',
evaluate: {
name: 'evaluate.cornerstoneTool',
disabledText: 'Select a 3D viewport to enable this tool',
},
},
},
{
@ -110,7 +113,10 @@ const toolbarButtons: Button[] = [
toolGroupIds: ['mpr'],
},
},
evaluate: 'evaluate.cornerstoneTool',
evaluate: {
name: 'evaluate.cornerstoneTool',
disabledText: 'Select an MPR viewport to enable this tool',
},
},
},
{

View File

@ -89,8 +89,8 @@ function modeFactory({ modeConfiguration }) {
'Crosshairs',
'Pan',
'SyncToggle',
'RectangleROIStartEndThreshold',
]);
toolbarService.createButtonSection('tmtvToolbox', ['RectangleROIStartEndThreshold']);
// For the hanging protocol we need to decide on the window level
// based on whether the SUV is corrected or not, hence we can't hard

View File

@ -1,42 +1,8 @@
import { defaults, ToolbarService } from '@ohif/core';
import { WindowLevelMenuItem } from '@ohif/ui';
import { toolGroupIds } from './initToolGroups';
const { windowLevelPresets } = defaults;
function _createColormap(label, colormap) {
return {
id: label,
label,
type: 'action',
commands: [
{
commandName: 'setFusionPTColormap',
commandOptions: {
toolGroupId: toolGroupIds.Fusion,
colormap,
},
},
],
};
}
function _createWwwcPreset(preset, title, subtitle) {
return {
id: preset.toString(),
title,
subtitle,
commands: [
{
commandName: 'setWindowLevel',
commandOptions: {
...windowLevelPresets[preset],
},
context: 'CORNERSTONE',
},
],
};
}
const setToolActiveToolbar = {
commandName: 'setToolActiveToolbar',
commandOptions: {
@ -141,7 +107,11 @@ const toolbarButtons = [
icon: 'tool-create-threshold',
label: 'Rectangle ROI Threshold',
commands: setToolActiveToolbar,
evaluate: 'evaluate.cornerstoneTool',
evaluate: {
name: 'evaluate.cornerstoneTool',
disabledText: 'Select the PT Axial to enable this tool',
},
options: 'tmtv.RectangleROIThresholdOptions',
},
},
];

View File

@ -233,7 +233,8 @@
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
<div id="react-portal"></div>
<div id="root">
</div>
</body>
</html>

View File

@ -204,6 +204,7 @@ export default class ToolbarService extends PubSubService {
const evaluated = props.evaluate?.({ ...refreshProps, button });
const updatedProps = {
...props,
...evaluated,
disabled: evaluated?.disabled || false,
className: evaluated?.className || '',
isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop
@ -386,7 +387,9 @@ export default class ToolbarService extends PubSubService {
const { id, uiType, component } = btn;
const { groupId } = btn.props;
const buttonType = this._getButtonUITypes()[uiType];
const buttonTypes = this._getButtonUITypes();
const buttonType = buttonTypes[uiType];
if (!buttonType) {
return;
@ -414,7 +417,16 @@ export default class ToolbarService extends PubSubService {
};
handleEvaluate = props => {
const { evaluate } = props;
const { evaluate, options } = props;
if (typeof options === 'string') {
// get the custom option component from the extension manager and set it as the optionComponent
const buttonTypes = this._getButtonUITypes();
const optionComponent = buttonTypes[options]?.defaultComponent;
props.options = {
optionComponent,
};
}
if (typeof evaluate === 'function') {
return;
@ -462,9 +474,8 @@ export default class ToolbarService extends PubSubService {
}
if (typeof evaluate === 'object') {
const { name, options } = evaluate;
const { name, ...options } = evaluate;
const evaluateFunction = this._evaluateFunction[name];
if (evaluateFunction) {
props.evaluate = args => evaluateFunction({ ...args, ...options });
return;

View File

@ -97,6 +97,7 @@ Let's look at one of the evaluators (for `evaluate.cornerstoneTool`)
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
disabledText: 'Tool not available',
};
}
@ -371,6 +372,104 @@ state will get synchronized with the toolbar service automatically.
![alt text](../../../assets/img/toolbox-modal.png)
## Toolbox With Options
Your toolbox toolbar buttons can have options, this is really useful
for advanced tools that require to change some parameters. For example, the brush tool that requires the brush size to change or the mode (2D or 3D).
currently we support three types of options
### Radio option
We use this in segmentation shapes to let the user choose between
three different modes
```js
{
id: 'Shapes',
uiType: 'ohif.radioGroup',
props: {
label: 'Shapes',
evaluate: {
name: 'evaluate.cornerstone.segmentation',
options: { toolNames: ['CircleScissor', 'SphereScissor', 'RectangleScissor'] },
},
icon: 'icon-tool-shape',
commands: _createSetToolActiveCommands('CircleScissor'),
options: [
{
name: 'Shape',
type: 'radio',
value: 'CircleScissor',
id: 'shape-mode',
values: [
{ value: 'CircleScissor', label: 'Circle' },
{ value: 'SphereScissor', label: 'Sphere' },
{ value: 'RectangleScissor', label: 'Rectangle' },
],
commands: 'setToolActiveToolbar',
},
],
},
},
```
### Range option
We use this for brush radius change
```js
{
id: 'Brush',
icon: 'icon-tool-brush',
label: 'Brush',
evaluate: {
name: 'evaluate.cornerstone.segmentation',
options: { toolNames: ['CircularBrush', 'SphereBrush'] },
disabledText: 'Create new segmentation to enable this tool.',
},
commands: _createSetToolActiveCommands('CircularBrush'),
options: [
{
name: 'Radius (mm)',
id: 'brush-radius',
type: 'range',
min: 0.5,
max: 99.5,
step: 0.5,
value: 25,
commands: {
commandName: 'setBrushSize',
commandOptions: { toolNames: ['CircularBrush', 'SphereBrush'] },
},
},
],
},
```
### Custom option
We use this pattern inside `tmtv` mode for `RectangleROIThreshold`
```js
{
id: 'RectangleROIStartEndThreshold',
uiType: 'ohif.radioGroup',
props: {
icon: 'tool-create-threshold',
label: 'Rectangle ROI Threshold',
commands: setToolActiveToolbar,
evaluate: {
name: 'evaluate.cornerstoneTool',
disabledText: 'Select the PT Axial to enable this tool',
},
options: 'tmtv.RectangleROIThresholdOptions',
},
},
```
Note that it is your job to provide the `tmvt.RectangleROIThresholdOptions` in the getToolbarModule of your extension
## Change Toolbar with hanging protocols

View File

@ -1,9 +1,7 @@
/* CUSTOM OHIF SCROLLBAR */
.ohif-scrollbar {
scrollbar-color: #041c4a transparent;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: #173239 transparent;
overflow-y: auto;
}
.study-min-height {

View File

@ -13,6 +13,10 @@ function ToolSettings({ options }) {
return null;
}
if (typeof options === 'function') {
return options();
}
return (
<div className="space-y-2 py-2 text-white">
{options?.map(option => {

View File

@ -27,15 +27,14 @@ const ButtonGroup = ({
vertical: 'flex-col',
};
const wrapperClasses = classnames('inline-flex', orientationClasses[orientation], className);
const wrapperClasses = classnames(
'items-stretch inline-flex',
orientationClasses[orientation],
className
);
return (
<div
className={classnames(
wrapperClasses,
'border-secondary-light rounded-[5px] border bg-black text-[13px]'
)}
>
<div className={classnames(wrapperClasses, 'text-[13px]')}>
{Children.map(children, (child, index) => {
if (React.isValidElement(child)) {
return cloneElement(child, {

View File

@ -1,6 +1,7 @@
import React, { useEffect, useCallback, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import ReactDOM from 'react-dom';
import Icon from '../Icon';
import Typography from '../Typography';
@ -21,7 +22,9 @@ const Dropdown = ({
maxCharactersPerLine,
}) => {
const [open, setOpen] = useState(false);
const element = useRef(null);
const elementRef = useRef(null);
const dropdownRef = useRef(null);
const [coords, setCoords] = useState({ x: 0, y: 0 });
// choose the max characters per line based on the longest title
const longestTitle = list.reduce((acc, item) => {
@ -107,23 +110,54 @@ const Dropdown = ({
};
const handleClick = e => {
if (element.current && !element.current.contains(e.target)) {
if (elementRef.current && !elementRef.current.contains(e.target)) {
setOpen(false);
}
};
useEffect(() => {
if (elementRef.current && dropdownRef.current) {
const triggerRect = elementRef.current.getBoundingClientRect();
const dropdownRect = dropdownRef.current.getBoundingClientRect();
let x, y;
switch (alignment) {
case 'right':
x = triggerRect.right + window.scrollX - dropdownRect.width;
y = triggerRect.bottom + window.scrollY;
break;
case 'left':
x = triggerRect.left + window.scrollX;
y = triggerRect.bottom + window.scrollY;
break;
default:
x = triggerRect.left + window.scrollX;
y = triggerRect.bottom + window.scrollY;
break;
}
setCoords({ x, y });
}
}, [open, alignment, elementRef.current, dropdownRef.current]);
const renderList = () => {
return (
const portalElement = document.getElementById('react-portal');
const listElement = (
<div
className={classnames(
'top-100 border-secondary-main absolute z-10 mt-2 transform rounded border bg-black shadow transition duration-300',
'top-100 border-secondary-main w-max-content absolute mt-2 transform rounded border bg-black shadow transition duration-300',
{
'right-0 origin-top-right': alignment === 'right',
'left-0 origin-top-left': alignment === 'left',
'scale-0': !open,
'scale-100': open,
}
)}
ref={dropdownRef}
style={{
position: 'absolute',
top: `${coords.y}px`,
left: open ? `${coords.x}px` : -999999,
zIndex: 9999,
}}
data-cy={`${id}-dropdown`}
>
{list.map((item, idx) => (
@ -137,6 +171,7 @@ const Dropdown = ({
))}
</div>
);
return ReactDOM.createPortal(listElement, portalElement);
};
useEffect(() => {
@ -150,7 +185,7 @@ const Dropdown = ({
return (
<div
data-cy="dropdown"
ref={element}
ref={elementRef}
className="relative"
>
<div

View File

@ -33,6 +33,7 @@ const Input = ({
onKeyDown,
readOnly,
disabled,
labelChildren,
...otherProps
}) => {
return (
@ -40,6 +41,7 @@ const Input = ({
<Label
className={labelClassName}
text={label}
children={labelChildren}
></Label>
<input
data-cy={`input-${id}`}
@ -83,6 +85,7 @@ Input.propTypes = {
onKeyPress: PropTypes.func,
onKeyDown: PropTypes.func,
disabled: PropTypes.bool,
labelChildren: PropTypes.node,
};
export default Input;

View File

@ -37,7 +37,6 @@ const PanelSection = ({ title, children, actionIcons = [] }) => {
</div>
{areChildrenVisible && (
<>
<div className="h-[2px] bg-black"></div>
<div className="bg-primary-dark rounded-b-[4px]">{children}</div>
</>
)}

View File

@ -2,15 +2,14 @@ import React from 'react';
import Icon from '../Icon';
import { useTranslation } from 'react-i18next';
function AddSegmentRow({ onClick }) {
function AddSegmentRow({ onClick, onToggleSegmentationVisibility = null, segmentation = null }) {
const { t } = useTranslation('SegmentationTable');
return (
<div
className="flex hover:cursor-pointer"
onClick={onClick}
>
<div className="h-[28px] w-[28px]"></div>
<div className="group ml-2.5 mt-1">
<div className="flex justify-between bg-black pl-[34px] hover:cursor-pointer">
<div
className="group pt-[5px] pb-[5px]"
onClick={onClick}
>
<div className="text-primary-active group-hover:bg-secondary-dark flex items-center rounded-[4px] pr-2">
<div className="grid h-[28px] w-[28px] place-items-center">
<Icon name="icon-add" />
@ -18,6 +17,26 @@ function AddSegmentRow({ onClick }) {
<span className="text-[13px]">{t('Add segment')}</span>
</div>
</div>
{segmentation && (
<div className="flex items-center">
<div
className="hover:bg-secondary-dark ml-3 mr-1 grid h-[28px] w-[28px] cursor-pointer place-items-center rounded-[4px]"
onClick={() => onToggleSegmentationVisibility(segmentation.id)}
>
{segmentation.isVisible ? (
<Icon
name="row-shown"
className="text-primary-active"
/>
) : (
<Icon
name="row-hidden"
className="text-primary-active"
/>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Select, Icon, Dropdown } from '../../components';
import { Select, Icon, Dropdown, Tooltip } from '../../components';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
@ -31,7 +31,7 @@ function SegmentationDropDownRow({
}
return (
<div className="group mx-0.5 mt-[8px] flex items-center">
<div className="group mx-0.5 mt-[8px] flex items-center pb-[10px]">
<div
onClick={e => {
e.stopPropagation();
@ -97,7 +97,7 @@ function SegmentationDropDownRow({
],
]}
>
<div className="hover:bg-secondary-dark mx-1 grid h-[28px] w-[28px] cursor-pointer place-items-center rounded-[4px]">
<div className="hover:bg-secondary-dark grid h-[28px] w-[28px] cursor-pointer place-items-center rounded-[4px]">
<Icon name="icon-more-menu"></Icon>
</div>
</Dropdown>
@ -122,8 +122,22 @@ function SegmentationDropDownRow({
/>
)}
<div className="flex items-center">
<Tooltip
position="bottom-right"
content={
<div className="flex flex-col">
<div className="text-[13px] text-white">Series:</div>
<div className="text-aqua-pale text-[13px]">{activeSegmentation.description}</div>
</div>
}
>
<Icon
name="info-action"
className="text-primary-active"
/>
</Tooltip>
<div
className="hover:bg-secondary-dark ml-3 mr-1 grid h-[28px] w-[28px] cursor-pointer place-items-center rounded-[4px]"
className="hover:bg-secondary-dark mr-1 grid h-[28px] w-[28px] cursor-pointer place-items-center rounded-[4px]"
onClick={() => onToggleSegmentationVisibility(activeSegmentation.id)}
>
{activeSegmentation.isVisible ? (

View File

@ -20,6 +20,7 @@ const SegmentItem = ({
onColor,
onToggleVisibility,
onToggleLocked,
displayText,
}) => {
const [isNumberBoxHovering, setIsNumberBoxHovering] = useState(false);
@ -27,7 +28,9 @@ const SegmentItem = ({
return (
<div
className={classnames('text-aqua-pale group/row flex min-h-[28px] bg-black')}
className={classnames('text-aqua-pale group/row bg-primary-dark flex min-h-[28px] flex-col', {
'bg-primary-light border-primary-light rounded-l-[4px] border text-black': isActive,
})}
onClick={e => {
e.stopPropagation();
onClick(segmentationId, segmentIndex);
@ -35,114 +38,140 @@ const SegmentItem = ({
tabIndex={0}
data-cy={'segment-item'}
>
<div
className={classnames('bg-primary-dark group/number grid w-[32px] place-items-center', {
'!bg-primary-light border-primary-light rounded-l-[4px] border text-black': isActive,
'border-primary-dark border': !isActive,
})}
onMouseEnter={() => setIsNumberBoxHovering(true)}
onMouseLeave={() => setIsNumberBoxHovering(false)}
>
{isNumberBoxHovering && showDelete ? (
<Icon
name="close"
className={classnames('h-[8px] w-[8px]', {
'hover:cursor-pointer hover:opacity-60': !disableEditing,
})}
onClick={e => {
if (disableEditing) {
return;
}
e.stopPropagation();
onDelete(segmentationId, segmentIndex);
}}
/>
) : (
<div>{segmentIndex}</div>
)}
</div>
<div
className={classnames('relative flex w-full', {
'border-primary-light bg-primary-dark rounded-r-[4px] border border-l-0': isActive,
'border border-l-0 border-transparent': !isActive,
})}
>
<div className="group-hover/row:bg-primary-dark flex h-full w-full flex-grow items-center">
<div className="pl-2 pr-1.5">
<div
className={classnames('h-[8px] w-[8px] grow-0 rounded-full', {
<div className="flex min-h-[28px]">
<div
className={classnames('group/number grid w-[28px] place-items-center', {
'bg-primary-light border-primary-light rounded-l-[4px] border text-black': isActive,
'bg-primary-dark border-primary-dark border': !isActive,
})}
onMouseEnter={() => setIsNumberBoxHovering(true)}
onMouseLeave={() => setIsNumberBoxHovering(false)}
>
{isNumberBoxHovering && showDelete ? (
<Icon
name="close"
className={classnames('h-[8px] w-[8px]', {
'hover:cursor-pointer hover:opacity-60': !disableEditing,
})}
style={{ backgroundColor: cssColor }}
onClick={e => {
if (disableEditing) {
return;
}
e.stopPropagation();
onColor(segmentationId, segmentIndex);
onDelete(segmentationId, segmentIndex);
}}
/>
</div>
<div className="flex items-center py-1 hover:cursor-pointer">{label}</div>
) : (
<div>{segmentIndex}</div>
)}
</div>
<div
className={classnames(
'absolute right-3 top-0 flex flex-row-reverse rounded-lg pt-[3px]',
{}
)}
className=" h-[30px] bg-black"
style={{ width: '3px' }}
></div>
<div
className={classnames('text-aqua-pale relative flex w-full', {
'border border-l-0 border-transparent': !isActive,
})}
style={{
width: 'calc(100% - 40px)',
}}
>
<div className="group-hover/row:hidden">
{!isVisible && (
<Icon
name="row-hidden"
className="h-5 w-5 text-[#3d5871]"
<div className="bg-primary-dark flex h-full flex-grow items-center">
<div className="pl-2 pr-1.5">
<div
className={classnames('h-[8px] w-[8px] grow-0 rounded-full', {
'hover:cursor-pointer hover:opacity-60': !disableEditing,
})}
style={{ backgroundColor: cssColor }}
onClick={e => {
if (disableEditing) {
return;
}
e.stopPropagation();
onToggleVisibility(segmentationId, segmentIndex);
onColor(segmentationId, segmentIndex);
}}
/>
)}
</div>
<div className="flex items-center py-1 hover:cursor-pointer">{label}</div>
</div>
{/* Icon for 'row-lock' that shows when NOT hovering and 'isLocked' is true */}
<div className="group-hover/row:hidden">
{isLocked && (
<div className="flex">
<div
className={classnames(
'absolute right-3 top-0 flex flex-row-reverse rounded-lg pt-[3px]',
{}
)}
>
<div className="group-hover/row:hidden">
{!isVisible && (
<Icon
name="row-lock"
name="row-hidden"
className="h-5 w-5 text-[#3d5871]"
onClick={e => {
e.stopPropagation();
onToggleLocked(segmentationId, segmentIndex);
onToggleVisibility(segmentationId, segmentIndex);
}}
/>
)}
</div>
{/* This icon is visible when 'isVisible' is true */}
{isVisible && (
{/* Icon for 'row-lock' that shows when NOT hovering and 'isLocked' is true */}
<div className="group-hover/row:hidden">
{isLocked && (
<div className="flex">
<Icon
name="row-hidden"
className="h-5 w-5 opacity-0"
name="row-lock"
className="h-5 w-5 text-[#3d5871]"
onClick={e => {
e.stopPropagation();
onToggleLocked(segmentationId, segmentIndex);
}}
/>
)}
</div>
)}
</div>
{/* Icons that show only when hovering */}
<div className="hidden group-hover/row:flex">
<HoveringIcons
disableEditing={disableEditing}
onEdit={onEdit}
isLocked={isLocked}
isVisible={isVisible}
onToggleLocked={onToggleLocked}
onToggleVisibility={onToggleVisibility}
segmentationId={segmentationId}
segmentIndex={segmentIndex}
/>
{/* This icon is visible when 'isVisible' is true */}
{isVisible && (
<Icon
name="row-hidden"
className="h-5 w-5 opacity-0"
/>
)}
</div>
)}
</div>
{/* Icons that show only when hovering */}
<div className="hidden group-hover/row:flex">
<HoveringIcons
disableEditing={disableEditing}
onEdit={onEdit}
isLocked={isLocked}
isVisible={isVisible}
onToggleLocked={onToggleLocked}
onToggleVisibility={onToggleVisibility}
segmentationId={segmentationId}
segmentIndex={segmentIndex}
/>
</div>
</div>
</div>
</div>
{Array.isArray(displayText) ? (
<div className="flex flex-col py-[5px] pl-[43px]">
{displayText.map(text => (
<div
key={text}
className="text-aqua-pale flex h-full items-center bg-black text-[11px]"
>
{text}
</div>
))}
</div>
) : (
displayText && (
<div className="text-aqua-pale flex h-full items-center bg-black px-2 py-[5px] pl-[45px] text-[11px]">
{displayText}
</div>
)
)}
</div>
);
};
@ -205,6 +234,7 @@ SegmentItem.propTypes = {
onDelete: PropTypes.func.isRequired,
onToggleVisibility: PropTypes.func.isRequired,
onToggleLocked: PropTypes.func,
displayText: PropTypes.string,
};
SegmentItem.defaultProps = {

View File

@ -100,7 +100,7 @@ const SegmentationGroupTable = ({
)}
<div className="bg-primary-dark">
{segmentations?.length === 0 ? (
<div className="select-none rounded-[4px]">
<div className="select-none bg-black pt-[5px] pb-[5px]">
{showAddSegmentation && !disableEditing && (
<NoSegmentationRow onSegmentationAdd={onSegmentationAdd} />
)}
@ -127,7 +127,7 @@ const SegmentationGroupTable = ({
)}
</div>
{activeSegmentation && (
<div className="ohif-scrollbar mt-1.5 flex min-h-0 flex-col overflow-y-hidden">
<div className="ohif-scrollbar flex h-fit max-h-[500px] min-h-0 flex-col overflow-auto bg-black">
{activeSegmentation?.segments?.map(segment => {
if (!segment) {
return null;

View File

@ -0,0 +1,211 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { PanelSection } from '../../components';
import SegmentationConfig from './SegmentationConfig';
import NoSegmentationRow from './NoSegmentationRow';
import { useTranslation } from 'react-i18next';
import SegmentationItem from './SegmentationItem';
const SegmentationGroupTableExpanded = ({
segmentations,
// segmentation initial config
segmentationConfig,
// UI show/hide
disableEditing,
showAddSegmentation,
showAddSegment,
showDeleteSegment,
// segmentation/segment handlers
onSegmentationAdd,
onSegmentationEdit,
onSegmentationClick,
onSegmentationDelete,
onSegmentationDownload,
onSegmentationDownloadRTSS,
storeSegmentation,
// segment handlers
onSegmentClick,
onSegmentAdd,
onSegmentDelete,
onSegmentEdit,
onToggleSegmentationVisibility,
onToggleSegmentVisibility,
onToggleSegmentLock,
onSegmentColorClick,
// segmentation config handlers
setFillAlpha,
setFillAlphaInactive,
setOutlineWidthActive,
setOutlineOpacityActive,
setRenderFill,
setRenderInactiveSegmentations,
setRenderOutline,
}) => {
const [isConfigOpen, setIsConfigOpen] = useState(false);
const [activeSegmentationId, setActiveSegmentationId] = useState(null);
useEffect(() => {
// find the first active segmentation to set
let activeSegmentationIdToSet = segmentations?.find(segmentation => segmentation.isActive)?.id;
// If there is no active segmentation, set the first one to be active
if (!activeSegmentationIdToSet && segmentations?.length > 0) {
activeSegmentationIdToSet = segmentations[0].id;
}
// If there is no segmentation, set the active segmentation to null
if (segmentations?.length === 0) {
activeSegmentationIdToSet = null;
}
setActiveSegmentationId(activeSegmentationIdToSet);
}, [segmentations]);
const activeSegmentation = segmentations?.find(
segmentation => segmentation.id === activeSegmentationId
);
const { t } = useTranslation('SegmentationTable');
return (
<div className="flex min-h-0 flex-col bg-black text-[13px] font-[300]">
<PanelSection
title={t('Segmentation')}
actionIcons={
activeSegmentation && [
{
name: 'settings-bars',
onClick: () => setIsConfigOpen(isOpen => !isOpen),
},
]
}
>
{isConfigOpen && (
<SegmentationConfig
setFillAlpha={setFillAlpha}
setFillAlphaInactive={setFillAlphaInactive}
setOutlineWidthActive={setOutlineWidthActive}
setOutlineOpacityActive={setOutlineOpacityActive}
setRenderFill={setRenderFill}
setRenderInactiveSegmentations={setRenderInactiveSegmentations}
setRenderOutline={setRenderOutline}
segmentationConfig={segmentationConfig}
/>
)}
<div className="bg-primary-dark">
<div className="select-none bg-black pt-[5px] pb-[5px]">
{showAddSegmentation && !disableEditing && (
<NoSegmentationRow onSegmentationAdd={onSegmentationAdd} />
)}
</div>
{segmentations?.length > 0 && (
<div className="ohif-scrollbar flex max-h-[500px] select-none flex-col gap-[5px] overflow-auto bg-black">
{segmentations?.map(segmentation => {
return (
<div key={segmentation.id}>
<SegmentationItem
key={segmentation.id}
segmentation={segmentation}
disableEditing={disableEditing}
onToggleSegmentationVisibility={onToggleSegmentationVisibility}
onSegmentationEdit={onSegmentationEdit}
onSegmentationDelete={onSegmentationDelete}
onSegmentationDownload={onSegmentationDownload}
onSegmentationDownloadRTSS={onSegmentationDownloadRTSS}
storeSegmentation={storeSegmentation}
showAddSegment={showAddSegment}
onSegmentAdd={onSegmentAdd}
onSegmentClick={onSegmentClick}
onSegmentDelete={onSegmentDelete}
onSegmentEdit={onSegmentEdit}
showDeleteSegment={showDeleteSegment}
onSegmentColorClick={onSegmentColorClick}
onToggleSegmentVisibility={onToggleSegmentVisibility}
onToggleSegmentLock={onToggleSegmentLock}
activeSegmentationId={activeSegmentationId}
/>
</div>
);
})}
</div>
)}
</div>
</PanelSection>
</div>
);
};
SegmentationGroupTableExpanded.propTypes = {
segmentations: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
isActive: PropTypes.bool.isRequired,
segments: PropTypes.arrayOf(
PropTypes.shape({
segmentIndex: PropTypes.number.isRequired,
color: PropTypes.array.isRequired,
label: PropTypes.string.isRequired,
isVisible: PropTypes.bool.isRequired,
isLocked: PropTypes.bool.isRequired,
})
),
})
),
segmentationConfig: PropTypes.object.isRequired,
disableEditing: PropTypes.bool,
showAddSegmentation: PropTypes.bool,
showAddSegment: PropTypes.bool,
showDeleteSegment: PropTypes.bool,
onSegmentationAdd: PropTypes.func.isRequired,
onSegmentationEdit: PropTypes.func.isRequired,
onSegmentationClick: PropTypes.func.isRequired,
onSegmentationDelete: PropTypes.func.isRequired,
onSegmentationDownload: PropTypes.func.isRequired,
onSegmentationDownloadRTSS: PropTypes.func,
storeSegmentation: PropTypes.func.isRequired,
onSegmentClick: PropTypes.func.isRequired,
onSegmentAdd: PropTypes.func.isRequired,
onSegmentDelete: PropTypes.func.isRequired,
onSegmentEdit: PropTypes.func.isRequired,
onToggleSegmentationVisibility: PropTypes.func.isRequired,
onToggleSegmentVisibility: PropTypes.func.isRequired,
onToggleSegmentLock: PropTypes.func.isRequired,
onSegmentColorClick: PropTypes.func.isRequired,
setFillAlpha: PropTypes.func.isRequired,
setFillAlphaInactive: PropTypes.func.isRequired,
setOutlineWidthActive: PropTypes.func.isRequired,
setOutlineOpacityActive: PropTypes.func.isRequired,
setRenderFill: PropTypes.func.isRequired,
setRenderInactiveSegmentations: PropTypes.func.isRequired,
setRenderOutline: PropTypes.func.isRequired,
};
SegmentationGroupTableExpanded.defaultProps = {
segmentations: [],
disableEditing: false,
showAddSegmentation: true,
showAddSegment: true,
showDeleteSegment: true,
onSegmentationAdd: () => {},
onSegmentationEdit: () => {},
onSegmentationClick: () => {},
onSegmentationDelete: () => {},
onSegmentationDownload: () => {},
onSemgnetationDownloadRTSS: () => {},
storeSegmentation: () => {},
onSegmentClick: () => {},
onSegmentAdd: () => {},
onSegmentDelete: () => {},
onSegmentEdit: () => {},
onToggleSegmentationVisibility: () => {},
onToggleSegmentVisibility: () => {},
onToggleSegmentLock: () => {},
onSegmentColorClick: () => {},
setFillAlpha: () => {},
setFillAlphaInactive: () => {},
setOutlineWidthActive: () => {},
setOutlineOpacityActive: () => {},
setRenderFill: () => {},
setRenderInactiveSegmentations: () => {},
setRenderOutline: () => {},
};
export default SegmentationGroupTableExpanded;

View File

@ -0,0 +1,211 @@
import React, { useState } from 'react';
import { Icon, Dropdown } from '../../components';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import AddSegmentRow from './AddSegmentRow';
import SegmentationGroupSegment from './SegmentationGroupSegment';
import { Tooltip } from '../../components';
function SegmentationItem({
segmentation,
disableEditing,
onSegmentationEdit,
onSegmentationDownload,
onSegmentationDownloadRTSS,
storeSegmentation,
onSegmentationDelete,
showAddSegment,
onToggleSegmentationVisibility,
onSegmentAdd,
onSegmentClick,
onSegmentDelete,
onSegmentEdit,
showDeleteSegment,
onSegmentColorClick,
onToggleSegmentVisibility,
onToggleSegmentLock,
activeSegmentationId,
}) {
const { t } = useTranslation('SegmentationTable');
const [areChildrenVisible, setChildrenVisible] = useState(true);
const handleHeaderClick = () => {
setChildrenVisible(!areChildrenVisible);
};
return (
<>
<div className="bg-secondary-dark group relative flex items-center justify-start gap-1">
<div
onClick={e => {
e.stopPropagation();
}}
className="flex"
>
<Dropdown
id="segmentation-dropdown"
showDropdownIcon={false}
alignment="left"
itemsClassName="text-primary-active"
showBorders={false}
maxCharactersPerLine={30}
list={[
...(!disableEditing
? [
{
title: t('Rename'),
onClick: () => {
onSegmentationEdit(segmentation.id);
},
},
]
: []),
{
title: t('Delete'),
onClick: () => {
onSegmentationDelete(segmentation.id);
},
},
...(!disableEditing
? [
{
title: t('Export DICOM SEG'),
onClick: () => {
storeSegmentation(segmentation.id);
},
},
]
: []),
...[
{
title: t('Download DICOM SEG'),
onClick: () => {
onSegmentationDownload(segmentation.id);
},
},
{
title: t('Download DICOM RTSTRUCT'),
onClick: () => {
onSegmentationDownloadRTSS(segmentation.id);
},
},
],
]}
>
<div className="hover:bg-secondary-dark grid h-[28px] w-[28px] cursor-pointer place-items-center rounded-[4px]">
<Icon name="icon-more-menu"></Icon>
</div>
</Dropdown>
<div
className=" h-[28px] bg-black"
style={{ width: '3px' }}
></div>
</div>
<div
className="flex h-full w-full cursor-pointer items-center justify-between pr-[8px]"
onClick={handleHeaderClick}
>
<div className="font-inter text-aqua-pale text-[13px]">{segmentation.label}</div>
<div className="flex h-[28px] items-center justify-center gap-2">
<Tooltip
position="bottom-right"
content={
<div className="flex flex-col">
<div className="text-[13px] text-white">Series:</div>
<div className="text-aqua-pale text-[13px]">{segmentation.description}</div>
</div>
}
>
<Icon
name="info-action"
className="text-primary-active"
/>
</Tooltip>
<div className={areChildrenVisible ? '' : 'mr-[4px]'}>
<Icon name={areChildrenVisible ? 'chevron-down-new' : 'chevron-left-new'} />
</div>
</div>
</div>
</div>
{areChildrenVisible && (
<>
{!disableEditing && showAddSegment && (
<AddSegmentRow
onClick={() => onSegmentAdd(segmentation.id)}
onToggleSegmentationVisibility={onToggleSegmentationVisibility}
segmentation={segmentation}
/>
)}
<div
className={classNames('ohif-scrollbar flex min-h-0 flex-col overflow-y-hidden', {
'mt-1': disableEditing || !showAddSegment,
})}
>
{segmentation?.segments?.map(segment => {
if (!segment) {
return null;
}
const { segmentIndex, color, label, isVisible, isLocked, displayText } = segment;
return (
<div key={segmentIndex}>
<SegmentationGroupSegment
segmentationId={segmentation.id}
segmentIndex={segmentIndex}
label={label}
color={color}
isActive={
segmentation.activeSegmentIndex === segmentIndex &&
segmentation.id === activeSegmentationId
}
disableEditing={disableEditing}
isLocked={isLocked}
isVisible={isVisible}
onClick={onSegmentClick}
onEdit={onSegmentEdit}
onDelete={onSegmentDelete}
showDelete={showDeleteSegment}
onColor={onSegmentColorClick}
onToggleVisibility={onToggleSegmentVisibility}
onToggleLocked={onToggleSegmentLock}
displayText={displayText}
/>
</div>
);
})}
</div>
</>
)}
</>
);
}
SegmentationItem.propTypes = {
segmentation: PropTypes.object,
disableEditing: PropTypes.bool,
onToggleSegmentationVisibility: PropTypes.func,
onSegmentationEdit: PropTypes.func,
onSegmentationDownload: PropTypes.func,
onSegmentationDownloadRTSS: PropTypes.func,
storeSegmentation: PropTypes.func,
onSegmentationDelete: PropTypes.func,
showAddSegment: PropTypes.bool,
onSegmentAdd: PropTypes.func,
onSegmentClick: PropTypes.func,
onSegmentDelete: PropTypes.func,
onSegmentEdit: PropTypes.func,
showDeleteSegment: PropTypes.bool,
onSegmentColorClick: PropTypes.func,
onToggleSegmentVisibility: PropTypes.func,
onToggleSegmentLock: PropTypes.func,
activeSegmentationId: PropTypes.string,
};
SegmentationItem.defaultProps = {
segmentation: null,
disableEditing: false,
};
export default SegmentationItem;

View File

@ -1,3 +1,4 @@
import SegmentationGroupTable from './SegmentationGroupTable';
import SegmentationGroupTableExpanded from './SegmentationGroupTableExpanded';
export default SegmentationGroupTable;
export { SegmentationGroupTable, SegmentationGroupTableExpanded };

View File

@ -16,6 +16,7 @@ const ToolbarButton = ({
//
className,
disabled,
disabledText,
size,
toolTipClassName,
disableToolTip = false,
@ -38,7 +39,7 @@ const ToolbarButton = ({
<Tooltip
isSticky={shouldShowDropdown}
content={shouldShowDropdown ? dropdownContent : label}
secondaryContent={disabled ? 'Not available on the current viewport' : null}
secondaryContent={disabledText}
tight={shouldShowDropdown}
className={toolTipClassNameToUse}
isDisabled={disableToolTip}

View File

@ -43,8 +43,14 @@ function Toolbox({ servicesManager, buttonSectionId, commandsManager, title, ...
(accumulator, toolbarButton) => {
const { id: buttonId, componentProps } = toolbarButton;
const createEnhancedOptions = (options, parentId) =>
options.map(option => {
const createEnhancedOptions = (options, parentId) => {
const optionsToUse = Array.isArray(options) ? options : [options];
return optionsToUse.map(option => {
if (typeof option.optionComponent === 'function') {
return option;
}
return {
...option,
value:
@ -72,13 +78,18 @@ function Toolbox({ servicesManager, buttonSectionId, commandsManager, title, ...
},
};
});
};
if (componentProps.items?.length) {
componentProps.items.forEach(item => {
accumulator[item.id] = createEnhancedOptions(item.options, item.id);
const { items, options } = componentProps;
if (items?.length) {
items.forEach(({ options, id }) => {
accumulator[id] = createEnhancedOptions(options, id);
});
} else if (componentProps.options?.length) {
accumulator[buttonId] = createEnhancedOptions(componentProps.options, buttonId);
} else if (options?.length) {
accumulator[buttonId] = createEnhancedOptions(options, buttonId);
} else if (options?.optionComponent) {
accumulator[buttonId] = options.optionComponent;
}
return accumulator;

View File

@ -53,8 +53,21 @@ function ToolboxUI(props) {
<Tooltip
position="bottom"
content={componentProps.label}
secondaryContent={'Not available on the current viewport'}
secondaryContent={componentProps.disabledText}
>
<div className="border-secondary-light rounded border bg-black">
<Component
{...componentProps}
{...props}
id={id}
servicesManager={servicesManager}
onInteraction={onInteraction}
size="toolbox"
/>
</div>
</Tooltip>
) : (
<div className="border-secondary-light rounded border bg-black">
<Component
{...componentProps}
{...props}
@ -63,16 +76,7 @@ function ToolboxUI(props) {
onInteraction={onInteraction}
size="toolbox"
/>
</Tooltip>
) : (
<Component
{...componentProps}
{...props}
id={id}
servicesManager={servicesManager}
onInteraction={onInteraction}
size="toolbox"
/>
</div>
)}
</div>
);

View File

@ -1,7 +1,8 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useTranslation } from 'react-i18next';
import ReactDOM from 'react-dom';
import './tooltip.css';
@ -42,6 +43,10 @@ const Tooltip = ({
}) => {
const [isActive, setIsActive] = useState(false);
const { t } = useTranslation('Buttons');
const tooltipContainer = document.getElementById('react-portal');
const [coords, setCoords] = useState({ x: 999999, y: 999999 });
const parentRef = useRef(null);
const tooltipRef = useRef(null);
const handleMouseOver = () => {
if (!isActive) {
@ -57,8 +62,90 @@ const Tooltip = ({
const isOpen = (isSticky || isActive) && !isDisabled;
useEffect(() => {
if (parentRef.current && tooltipRef.current) {
const parentRect = parentRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const parentWidth = parentRect.width;
const parentHeight = parentRect.height;
const tooltipWidth = tooltipRect.width;
let newX = 0;
let newY = 0;
switch (position) {
case 'bottom':
newX = parentRect.left + parentWidth / 2;
newY = parentRect.top + parentHeight;
break;
case 'top':
newX = parentRect.left + parentWidth / 2;
newY = parentRect.top - parentHeight * 2;
break;
case 'right':
newX = parentRect.left + parentWidth;
newY = parentRect.top + parentHeight / 2;
break;
case 'left':
newX = parentRect.left - tooltipWidth - 10;
newY = parentRect.top + parentHeight / 2;
break;
case 'bottom-left':
newX = parentRect.left;
newY = parentRect.top + parentHeight;
break;
case 'bottom-right':
newX = parentRect.left - tooltipWidth + parentWidth;
newY = parentRect.top + parentHeight;
break;
default:
break;
}
setCoords({ x: newX, y: newY });
}
}, [isOpen, position, parentRef.current, tooltipRef.current]);
const tooltipContent = (
<div
className={classnames(`tooltip tooltip-${position} block`, 'z-50')}
style={{
position: 'fixed',
top: coords.y,
left: isOpen ? coords.x : 999999,
}}
>
<div
ref={tooltipRef}
className={classnames(
'tooltip-box bg-primary-dark border-secondary-light w-max-content relative inset-x-auto top-full rounded border text-base text-white',
{
'py-[6px] px-[8px]': !tight,
}
)}
>
<div>{typeof content === 'string' ? t(content) : content}</div>
<div className="text-aqua-pale">
{typeof secondaryContent === 'string' ? t(secondaryContent) : secondaryContent}
</div>
<svg
className="text-primary-dark stroke-secondary-light absolute h-4"
style={arrowPositionStyle[position]}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M24 22l-12-20l-12 20"
/>
</svg>
</div>
</div>
);
return (
<div
ref={parentRef}
className={classnames('relative', className)}
onMouseOver={handleMouseOver}
onFocus={handleMouseOver}
@ -67,37 +154,7 @@ const Tooltip = ({
role="tooltip"
>
{children}
<div
className={classnames(`tooltip tooltip-${position}`, {
block: isOpen,
hidden: !isOpen,
})}
>
<div
className={classnames(
'tooltip-box bg-primary-dark border-secondary-light w-max-content relative inset-x-auto top-full rounded border text-base text-white',
{
'py-[6px] px-[8px]': !tight,
}
)}
>
<div>{typeof content === 'string' ? t(content) : content}</div>
<div className="text-aqua-pale">
{typeof secondaryContent === 'string' ? t(secondaryContent) : secondaryContent}
</div>
<svg
className="text-primary-dark stroke-secondary-light absolute h-4"
style={arrowPositionStyle[position]}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M24 22l-12-20l-12 20"
/>
</svg>
</div>
</div>
{tooltipContainer && ReactDOM.createPortal(tooltipContent, tooltipContainer)}
</div>
);
};
@ -110,7 +167,6 @@ Tooltip.defaultProps = {
};
Tooltip.propTypes = {
/** prevents tooltip from rendering despite hover/active/sticky */
isDisabled: PropTypes.bool,
content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
position: PropTypes.oneOf(['bottom', 'bottom-left', 'bottom-right', 'left', 'right', 'top']),

View File

@ -29,7 +29,7 @@ import NavBar from './NavBar';
import Notification from './Notification';
import Select from './Select';
import SegmentationTable from './SegmentationTable';
import SegmentationGroupTable from './SegmentationGroupTable';
import { SegmentationGroupTable, SegmentationGroupTableExpanded } from './SegmentationGroupTable';
import SidePanel from './SidePanel';
import SplitButton from './SplitButton';
import StudyBrowser from './StudyBrowser';
@ -144,6 +144,7 @@ export {
Select,
SegmentationTable,
SegmentationGroupTable,
SegmentationGroupTableExpanded,
SidePanel,
SplitButton,
StudyBrowser,

View File

@ -81,6 +81,7 @@ export {
Select,
SegmentationTable,
SegmentationGroupTable,
SegmentationGroupTableExpanded,
SidePanel,
SplitButton,
LegacySplitButton,

View File

@ -2420,7 +2420,7 @@
resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3"
integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==
"@emotion/use-insertion-effect-with-fallbacks@^1.0.1":
"@emotion/use-insertion-effect-with-fallbacks@^1.0.0", "@emotion/use-insertion-effect-with-fallbacks@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz#08de79f54eb3406f9daaf77c76e35313da963963"
integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==