Merge branch 'v3-stable' into customizable-TS
This commit is contained in:
59 files changed
+1039
-409
No files matched your search
@@ -38,6 +38,7 @@
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-window": "^1.8.9",
|
||||
"webpack": "^5.50.0",
|
||||
"webpack-merge": "^5.7.3"
|
||||
},
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-2
@@ -1,11 +1,11 @@
|
||||
import ContextMenuController from './ContextMenuController';
|
||||
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
|
||||
import defaultContextMenu from './defaultContextMenu';
|
||||
import * as CustomizeableContextMenuTypes from './types';
|
||||
import * as CustomizableContextMenuTypes from './types';
|
||||
|
||||
export {
|
||||
ContextMenuController,
|
||||
CustomizeableContextMenuTypes,
|
||||
CustomizableContextMenuTypes,
|
||||
ContextMenuItemsBuilder,
|
||||
defaultContextMenu,
|
||||
};
|
||||
File renamed without changes.
@@ -1,27 +1,41 @@
|
||||
import dcmjs from 'dcmjs';
|
||||
import moment from 'moment';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { classes } from '@ohif/core';
|
||||
import { Icon, InputRange, Select, Typography } from '@ohif/ui';
|
||||
import debounce from 'lodash.debounce';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import DicomTagTable from './DicomTagTable';
|
||||
import './DicomTagBrowser.css';
|
||||
import { InputRange, Select, Typography } from '@ohif/ui';
|
||||
|
||||
const { ImageSet } = classes;
|
||||
const { DicomMetaDictionary } = dcmjs.data;
|
||||
const { nameMap } = DicomMetaDictionary;
|
||||
|
||||
const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
|
||||
// The column indices that are to be excluded during a filter of the table.
|
||||
// At present the column indices are:
|
||||
// 0: DICOM tag
|
||||
// 1: VR
|
||||
// 2: Keyword
|
||||
// 3: Value
|
||||
const excludedColumnIndicesForFilter: Set<number> = new Set([1]);
|
||||
|
||||
const [
|
||||
selectedDisplaySetInstanceUID,
|
||||
setSelectedDisplaySetInstanceUID,
|
||||
] = useState(displaySetInstanceUID);
|
||||
const [instanceNumber, setInstanceNumber] = useState(1);
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
|
||||
const onSelectChange = value => {
|
||||
setSelectedDisplaySetInstanceUID(value.value);
|
||||
setInstanceNumber(1);
|
||||
};
|
||||
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const activeDisplaySet = displaySets.find(
|
||||
ds => ds.displaySetInstanceUID === selectedDisplaySetInstanceUID
|
||||
);
|
||||
@@ -54,64 +68,130 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
|
||||
});
|
||||
}, [displaySets]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let metadata;
|
||||
if (isImageStack) {
|
||||
metadata = activeDisplaySet.images[instanceNumber - 1];
|
||||
} else {
|
||||
metadata = activeDisplaySet.instance || activeDisplaySet;
|
||||
}
|
||||
const tags = getSortedTags(metadata);
|
||||
return getFormattedRowsFromTags(tags, metadata);
|
||||
}, [instanceNumber, selectedDisplaySetInstanceUID]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!filterValue) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const filterValueLowerCase = filterValue.toLowerCase();
|
||||
return rows.filter(row => {
|
||||
return row.reduce((keepRow, col, colIndex) => {
|
||||
if (keepRow) {
|
||||
// We are already keeping the row, why do more work so return now.
|
||||
return keepRow;
|
||||
}
|
||||
|
||||
if (excludedColumnIndicesForFilter.has(colIndex)) {
|
||||
return keepRow;
|
||||
}
|
||||
|
||||
return keepRow || col.toLowerCase().includes(filterValueLowerCase);
|
||||
}, false);
|
||||
});
|
||||
}, [rows, filterValue]);
|
||||
|
||||
const debouncedSetFilterValue = useMemo(() => {
|
||||
return debounce(setFilterValue, 200);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedSetFilterValue?.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="dicom-tag-browser-content">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<Typography variant="subtitle" className="w-1/2 mr-8">
|
||||
Series
|
||||
</Typography>
|
||||
{showInstanceList && (
|
||||
<Typography variant="subtitle" className="w-1/2">
|
||||
Instance Number
|
||||
<div className="flex flex-row mb-6 items-center pl-1">
|
||||
<div className="flex flex-row items-center w-1/2">
|
||||
<Typography variant="subtitle" className="mr-4">
|
||||
Series
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row items-center mb-6">
|
||||
<div className="w-1/2 mr-8">
|
||||
<Select
|
||||
isClearable={false}
|
||||
onChange={onSelectChange}
|
||||
options={displaySetList}
|
||||
value={displaySetList.find(
|
||||
ds => ds.value === selectedDisplaySetInstanceUID
|
||||
)}
|
||||
className="text-white"
|
||||
/>
|
||||
</div>
|
||||
{showInstanceList ? (
|
||||
<div className="w-1/2">
|
||||
<InputRange
|
||||
value={instanceNumber}
|
||||
key={selectedDisplaySetInstanceUID}
|
||||
onChange={value => {
|
||||
setInstanceNumber(parseInt(value));
|
||||
}}
|
||||
minValue={1}
|
||||
maxValue={activeDisplaySet.images.length}
|
||||
step={1}
|
||||
<div className="grow mr-8">
|
||||
<Select
|
||||
id="display-set-selector"
|
||||
isClearable={false}
|
||||
onChange={onSelectChange}
|
||||
options={displaySetList}
|
||||
value={displaySetList.find(
|
||||
ds => ds.value === selectedDisplaySetInstanceUID
|
||||
)}
|
||||
className="text-white"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-row items-center w-1/2">
|
||||
{showInstanceList && (
|
||||
<Typography variant="subtitle" className="mr-4">
|
||||
Instance Number
|
||||
</Typography>
|
||||
)}
|
||||
{showInstanceList && (
|
||||
<div className="grow">
|
||||
<InputRange
|
||||
value={instanceNumber}
|
||||
key={selectedDisplaySetInstanceUID}
|
||||
onChange={value => {
|
||||
setInstanceNumber(parseInt(value));
|
||||
}}
|
||||
minValue={1}
|
||||
maxValue={activeDisplaySet.images.length}
|
||||
step={1}
|
||||
inputClassName="w-full"
|
||||
labelPosition="left"
|
||||
trackColor={'#3a3f99'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DicomTagTable
|
||||
rows={getFormattedRowsFromTags(activeDisplaySet, instanceNumber)}
|
||||
></DicomTagTable>
|
||||
<div className="w-full h-1 bg-black"></div>
|
||||
<div className="flex flex-row my-3 w-1/2">
|
||||
{/* TODO - refactor the following into its own reusable component */}
|
||||
<label className="relative block w-full mr-8">
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-2">
|
||||
<Icon name="icon-search"></Icon>
|
||||
</span>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="block bg-black w-full shadow transition duration-300 appearance-none border border-inputfield-main focus:border-inputfield-focus focus:outline-none disabled:border-inputfield-disabled rounded w-full py-2 px-9 text-base leading-tight placeholder:text-inputfield-placeholder"
|
||||
placeholder="Search metadata..."
|
||||
onChange={event => debouncedSetFilterValue(event.target.value)}
|
||||
autoComplete="off"
|
||||
></input>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<Icon
|
||||
name="icon-clear-field"
|
||||
className={classNames(
|
||||
'cursor-pointer',
|
||||
filterValue ? '' : 'hidden'
|
||||
)}
|
||||
onClick={() => {
|
||||
searchInputRef.current.value = '';
|
||||
debouncedSetFilterValue('');
|
||||
}}
|
||||
></Icon>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<DicomTagTable rows={filteredRows} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getFormattedRowsFromTags(displaySet, instanceNumber) {
|
||||
const isImageStack = _isImageStack(displaySet);
|
||||
|
||||
let metadata;
|
||||
|
||||
if (isImageStack) {
|
||||
metadata = displaySet.images[instanceNumber - 1];
|
||||
} else {
|
||||
metadata = displaySet;
|
||||
}
|
||||
|
||||
const tags = getSortedTags(metadata);
|
||||
function getFormattedRowsFromTags(tags, metadata) {
|
||||
const rows = [];
|
||||
|
||||
tags.forEach(tagInfo => {
|
||||
@@ -126,7 +206,7 @@ function getFormattedRowsFromTags(displaySet, instanceNumber) {
|
||||
const { values } = tagInfo;
|
||||
|
||||
values.forEach((item, index) => {
|
||||
const formatedRowsFromTags = getFormattedRowsFromTags(item);
|
||||
const formatedRowsFromTags = getFormattedRowsFromTags(item, metadata);
|
||||
|
||||
rows.push([
|
||||
`${item[0].tagIndent}(FFFE,E000)`,
|
||||
@@ -140,34 +220,21 @@ function getFormattedRowsFromTags(displaySet, instanceNumber) {
|
||||
} else {
|
||||
if (tagInfo.vr === 'xs') {
|
||||
try {
|
||||
/* const dataset = metadataProvider.getStudyDataset(
|
||||
meta.StudyInstanceUID
|
||||
);*/
|
||||
// console.log(dataset);
|
||||
// const tag = dcmjs.data.Tag.fromPString(tagInfo.tag).toCleanString();
|
||||
// const originalTagInfo = dataset[tag];
|
||||
// tagInfo.vr = originalTagInfo.vr;
|
||||
const tag = dcmjs.data.Tag.fromPString(tagInfo.tag).toCleanString();
|
||||
const originalTagInfo = metadata[tag];
|
||||
tagInfo.vr = originalTagInfo.vr;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to parse value representation for tag '${tagInfo.keyword}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (tagInfo.vr === 'PN') {
|
||||
rows.push([
|
||||
`${tagInfo.tagIndent}${tagInfo.tag}`,
|
||||
tagInfo.vr,
|
||||
tagInfo.keyword,
|
||||
tagInfo.value,
|
||||
]);
|
||||
} else {
|
||||
rows.push([
|
||||
`${tagInfo.tagIndent}${tagInfo.tag}`,
|
||||
tagInfo.vr,
|
||||
tagInfo.keyword,
|
||||
tagInfo.value,
|
||||
]);
|
||||
}
|
||||
rows.push([
|
||||
`${tagInfo.tagIndent}${tagInfo.tag}`,
|
||||
tagInfo.vr,
|
||||
tagInfo.keyword,
|
||||
tagInfo.value,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,97 +1,224 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import classNames from 'classnames';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
function ColumnHeaders() {
|
||||
const lineHeightPx = 20;
|
||||
const lineHeightClassName = `leading-[${lineHeightPx}px]`;
|
||||
const rowVerticalPaddingPx = 10;
|
||||
const rowBottomBorderPx = 1;
|
||||
const rowVerticalPaddingStyle = { padding: `${rowVerticalPaddingPx}px 0` };
|
||||
const rowStyle = {
|
||||
borderBottomWidth: `${rowBottomBorderPx}px`,
|
||||
...rowVerticalPaddingStyle,
|
||||
};
|
||||
|
||||
function ColumnHeaders({ tagRef, vrRef, keywordRef, valueRef }) {
|
||||
return (
|
||||
<div className="m-auto flex flex-col py-2 bg-secondary-light">
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="px-3 w-5/24">
|
||||
<label className="flex flex-col flex-1 text-white text-lg pl-1 select-none">
|
||||
<span className="flex flex-row items-center focus:outline-none">
|
||||
Tag
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-3 w-4/24">
|
||||
<label className="flex flex-col flex-1 text-white text-lg pl-1 select-none">
|
||||
<span className="flex flex-row items-center focus:outline-none">
|
||||
VR
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-3 w-6/24">
|
||||
<label className="flex flex-col flex-1 text-white text-lg pl-1 select-none">
|
||||
<span className="flex flex-row items-center focus:outline-none">
|
||||
Keyword
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-3 w-5/24">
|
||||
<label className="flex flex-col flex-1 text-white text-lg pl-1 select-none">
|
||||
<span className="flex flex-row items-center focus:outline-none">
|
||||
Value
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-row w-full bg-secondary-dark ohif-scrollbar overflow-y-scroll'
|
||||
)}
|
||||
style={rowVerticalPaddingStyle}
|
||||
>
|
||||
<div className="px-3 w-4/24">
|
||||
<label
|
||||
ref={tagRef}
|
||||
className="flex flex-col flex-1 text-white text-lg pl-1 select-none"
|
||||
>
|
||||
<span className="flex flex-row items-center focus:outline-none">
|
||||
Tag
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-3 w-2/24">
|
||||
<label
|
||||
ref={vrRef}
|
||||
className="flex flex-col flex-1 text-white text-lg pl-1 select-none"
|
||||
>
|
||||
<span className="flex flex-row items-center focus:outline-none">
|
||||
VR
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-3 w-6/24">
|
||||
<label
|
||||
ref={keywordRef}
|
||||
className="flex flex-col flex-1 text-white text-lg pl-1 select-none"
|
||||
>
|
||||
<span className="flex flex-row items-center focus:outline-none">
|
||||
Keyword
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-3 w-5/24 grow">
|
||||
<label
|
||||
ref={valueRef}
|
||||
className="flex flex-col flex-1 text-white text-lg pl-1 select-none"
|
||||
>
|
||||
<span className="flex flex-row items-center focus:outline-none">
|
||||
Value
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DicomTagTable({ rows }) {
|
||||
const listRef = useRef();
|
||||
const canvasRef = useRef();
|
||||
|
||||
const [tagHeaderElem, setTagHeaderElem] = useState(null);
|
||||
const [vrHeaderElem, setVrHeaderElem] = useState(null);
|
||||
const [keywordHeaderElem, setKeywordHeaderElem] = useState(null);
|
||||
const [valueHeaderElem, setValueHeaderElem] = useState(null);
|
||||
|
||||
// Here the refs are inturn stored in state to trigger a render of the table.
|
||||
// This virtualized table does NOT render until the header is rendered because the header column widths are used to determine the row heights in the table.
|
||||
// Therefore whenever the refs change (in particular the first time the refs are set), we want to trigger a render of the table.
|
||||
const tagRef = elem => {
|
||||
if (elem) {
|
||||
setTagHeaderElem(elem);
|
||||
}
|
||||
};
|
||||
const vrRef = elem => {
|
||||
if (elem) {
|
||||
setVrHeaderElem(elem);
|
||||
}
|
||||
};
|
||||
const keywordRef = elem => {
|
||||
if (elem) {
|
||||
setKeywordHeaderElem(elem);
|
||||
}
|
||||
};
|
||||
const valueRef = elem => {
|
||||
if (elem) {
|
||||
setValueHeaderElem(elem);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When new rows are set, scroll to the top and reset the virtualization.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!listRef?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
listRef.current.scrollTo(0);
|
||||
listRef.current.resetAfterIndex(0);
|
||||
}, [rows]);
|
||||
|
||||
/**
|
||||
* When the browser window resizes, update the row virtualization (i.e. row heights)
|
||||
*/
|
||||
useEffect(() => {
|
||||
const debouncedResize = debounce(
|
||||
() => listRef.current.resetAfterIndex(0),
|
||||
100
|
||||
);
|
||||
|
||||
window.addEventListener('resize', debouncedResize);
|
||||
|
||||
return () => {
|
||||
debouncedResize.cancel();
|
||||
window.removeEventListener('resize', debouncedResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const Row = useCallback(
|
||||
({ index, style }) => {
|
||||
const row = rows[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ ...style, ...rowStyle }}
|
||||
className={classNames(
|
||||
'hover:bg-secondary-main transition duration-300 bg-black flex flex-row w-full border-secondary-light items-center text-base break-all',
|
||||
lineHeightClassName
|
||||
)}
|
||||
key={`DICOMTagRow-${index}`}
|
||||
>
|
||||
<div className="px-3 w-4/24">{row[0]}</div>
|
||||
<div className="px-3 w-2/24">{row[1]}</div>
|
||||
<div className="px-3 w-6/24">{row[2]}</div>
|
||||
<div className="px-3 w-5/24 grow">{row[3]}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[rows]
|
||||
);
|
||||
|
||||
/**
|
||||
* Whenever any one of the column headers is set, then the header is rendered.
|
||||
* Here we chose the tag header.
|
||||
*/
|
||||
const isHeaderRendered = useCallback(() => tagHeaderElem !== null, [
|
||||
tagHeaderElem,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get the item/row size. We use the header column widths to calculate the various row heights.
|
||||
* @param index the row index
|
||||
* @returns the row height
|
||||
*/
|
||||
const getItemSize = useCallback(
|
||||
index => {
|
||||
const headerWidths = [
|
||||
tagHeaderElem.offsetWidth,
|
||||
vrHeaderElem.offsetWidth,
|
||||
keywordHeaderElem.offsetWidth,
|
||||
valueHeaderElem.offsetWidth,
|
||||
];
|
||||
|
||||
const context = canvasRef.current.getContext('2d');
|
||||
context.font = getComputedStyle(canvasRef.current).font;
|
||||
|
||||
return rows[index]
|
||||
.map((colText, index) => {
|
||||
const colOneLineWidth = context.measureText(colText).width;
|
||||
const numLines = Math.ceil(colOneLineWidth / headerWidths[index]);
|
||||
return (
|
||||
numLines * lineHeightPx +
|
||||
2 * rowVerticalPaddingPx +
|
||||
rowBottomBorderPx
|
||||
);
|
||||
})
|
||||
.reduce((maxHeight, colHeight) => Math.max(maxHeight, colHeight));
|
||||
},
|
||||
[rows, keywordHeaderElem, tagHeaderElem, valueHeaderElem, vrHeaderElem]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ColumnHeaders()}
|
||||
<canvas
|
||||
style={{ visibility: 'hidden', position: 'absolute' }}
|
||||
className="text-base"
|
||||
ref={canvasRef}
|
||||
/>
|
||||
<ColumnHeaders
|
||||
tagRef={tagRef}
|
||||
vrRef={vrRef}
|
||||
keywordRef={keywordRef}
|
||||
valueRef={valueRef}
|
||||
/>
|
||||
<div
|
||||
className="m-auto relative border-2 border-secondary-light overflow-hidden ohif-scrollbar"
|
||||
className="m-auto relative border-2 border-black bg-black"
|
||||
style={{ height: '32rem' }}
|
||||
>
|
||||
<table className="w-full text-white">
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const className = row.className ? row.className : null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="hover:bg-secondary-main transition duration-300 bg-primary-dark"
|
||||
key={`DICOMTagRow-${index}`}
|
||||
>
|
||||
<td
|
||||
style={{ maxWidth: '0px' }}
|
||||
className="px-4 py-2 text-base break-all border-b border-secondary-light w-5/24"
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="inline-flex max-w-full">{row[0]}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
style={{ maxWidth: '0px' }}
|
||||
className="px-4 py-2 text-base break-all border-b border-secondary-light w-4/24"
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="inline-flex max-w-full">{row[1]}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
style={{ maxWidth: '0px' }}
|
||||
className="px-4 py-2 text-base break-all border-b border-secondary-light w-6/24"
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="inline-flex max-w-full">{row[2]}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
style={{ maxWidth: '0px' }}
|
||||
className="px-4 py-2 text-base break-all border-b border-secondary-light w-14/24"
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="inline-flex max-w-full">{row[3]}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{isHeaderRendered() && (
|
||||
<List
|
||||
ref={listRef}
|
||||
height={500}
|
||||
itemCount={rows.length}
|
||||
itemSize={getItemSize}
|
||||
width={'100%'}
|
||||
className="ohif-scrollbar"
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './retrieveStudyMetadata.js';
|
||||
import StaticWadoClient from './utils/StaticWadoClient';
|
||||
import getDirectURL from '../utils/getDirectURL';
|
||||
import { fixBulkDataURI } from './utils/fixBulkDataURI';
|
||||
|
||||
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
|
||||
|
||||
@@ -432,13 +433,23 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
|
||||
*/
|
||||
const addRetrieveBulkData = instance => {
|
||||
const naturalized = naturalizeDataset(instance);
|
||||
|
||||
// if we konw the server doesn't use bulkDataURI, then don't
|
||||
if (!dicomWebConfig.bulkDataURI?.enabled) {
|
||||
return naturalized;
|
||||
}
|
||||
|
||||
Object.keys(naturalized).forEach(key => {
|
||||
const value = naturalized[key];
|
||||
|
||||
// The value.Value will be set with the bulkdata read value
|
||||
// in which case it isn't necessary to re-read this.
|
||||
if (value && value.BulkDataURI && !value.Value) {
|
||||
// Provide a method to fetch bulkdata
|
||||
value.retrieveBulkData = () => {
|
||||
// handle the scenarios where bulkDataURI is relative path
|
||||
fixBulkDataURI(value, naturalized, dicomWebConfig);
|
||||
|
||||
const options = {
|
||||
// The bulkdata fetches work with either multipart or
|
||||
// singlepart, so set multipart to false to let the server
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Modifies a bulkDataURI to ensure it is absolute based on the DICOMWeb configuration and
|
||||
* instance data. The modification is in-place.
|
||||
*
|
||||
* If the bulkDataURI is relative to the series or study (according to the DICOM standard),
|
||||
* it is made absolute by prepending the relevant paths.
|
||||
*
|
||||
* In scenarios where the bulkDataURI is a server-relative path (starting with '/'), the function
|
||||
* handles two cases:
|
||||
*
|
||||
* 1. If the wado root is absolute (starts with 'http'), it prepends the wado root to the bulkDataURI.
|
||||
* 2. If the wado root is relative, no changes are needed as the bulkDataURI is already correctly relative to the server root.
|
||||
*
|
||||
* @param value - The object containing BulkDataURI to be fixed.
|
||||
* @param instance - The object (DICOM instance data) containing StudyInstanceUID and SeriesInstanceUID.
|
||||
* @param dicomWebConfig - The DICOMWeb configuration object, containing wadoRoot and potentially bulkDataURI.relativeResolution.
|
||||
* @returns The function modifies `value` in-place, it does not return a value.
|
||||
*/
|
||||
function fixBulkDataURI(value, instance, dicomWebConfig) {
|
||||
// in case of the relative path, make it absolute. The current DICOM standard says
|
||||
// the bulkdataURI is relative to the series. However, there are situations where
|
||||
// it can be relative to the study too
|
||||
if (
|
||||
!value.BulkDataURI.startsWith('http') &&
|
||||
!value.BulkDataURI.startsWith('/')
|
||||
) {
|
||||
if (dicomWebConfig.bulkDataURI?.relativeResolution === 'studies') {
|
||||
value.BulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${instance.StudyInstanceUID}/${value.BulkDataURI}`;
|
||||
} else if (
|
||||
dicomWebConfig.bulkDataURI?.relativeResolution === 'series' ||
|
||||
!dicomWebConfig.bulkDataURI?.relativeResolution
|
||||
) {
|
||||
value.BulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${instance.StudyInstanceUID}/series/${instance.SeriesInstanceUID}/${value.BulkDataURI}`;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// in case it is relative path but starts at the server (e.g., /bulk/1e, note the missing http
|
||||
// in the beginning and the first character is /) There are two scenarios, whether the wado root
|
||||
// is absolute or relative. In case of absolute, we need to prepend the wado root to the bulkdata
|
||||
// uri (e.g., bulkData: /bulk/1e, wado root: http://myserver.com/dicomweb, output: http://myserver.com/bulk/1e)
|
||||
// and in case of relative wado root, we need to prepend the bulkdata uri to the wado root (e.g,. bulkData: /bulk/1e
|
||||
// wado root: /dicomweb, output: /bulk/1e)
|
||||
if (value.BulkDataURI[0] === '/') {
|
||||
if (dicomWebConfig.wadoRoot.startsWith('http')) {
|
||||
// Absolute wado root
|
||||
const url = new URL(dicomWebConfig.wadoRoot);
|
||||
value.BulkDataURI = `${url.origin}${value.BulkDataURI}`;
|
||||
} else {
|
||||
// Relative wado root, we don't need to do anything, bulkdata uri is already correct
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { fixBulkDataURI };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { fixBulkDataURI } from './fixBulkDataURI';
|
||||
|
||||
export { fixBulkDataURI };
|
||||
@@ -3,14 +3,14 @@ import { ServicesManager, utils, Types } from '@ohif/core';
|
||||
import {
|
||||
ContextMenuController,
|
||||
defaultContextMenu,
|
||||
} from './CustomizeableContextMenu';
|
||||
} from './CustomizableContextMenu';
|
||||
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
|
||||
import reuseCachedLayouts from './utils/reuseCachedLayouts';
|
||||
import findViewportsByPosition, {
|
||||
findOrCreateViewport as layoutFindOrCreate,
|
||||
} from './findViewportsByPosition';
|
||||
|
||||
import { ContextMenuProps } from './CustomizeableContextMenu/types';
|
||||
import { ContextMenuProps } from './CustomizableContextMenu/types';
|
||||
import { NavigateHistory } from './types/commandModuleTypes';
|
||||
import { history } from '@ohif/viewer';
|
||||
|
||||
@@ -23,6 +23,11 @@ export type HangingProtocolParams = {
|
||||
stageId?: string;
|
||||
};
|
||||
|
||||
export type UpdateViewportDisplaySetParams = {
|
||||
direction: number;
|
||||
excludeNonImageModalities?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if a command is a hanging protocol one.
|
||||
* For now, just use the two hanging protocol commands that are in this
|
||||
@@ -541,6 +546,130 @@ const commandsModule = ({
|
||||
overlays.item(i).classList.toggle('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
scrollActiveThumbnailIntoView: () => {
|
||||
const { activeViewportIndex, viewports } = viewportGridService.getState();
|
||||
|
||||
if (
|
||||
!viewports ||
|
||||
activeViewportIndex < 0 ||
|
||||
activeViewportIndex > viewports.length - 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeViewport = viewports[activeViewportIndex];
|
||||
const activeDisplaySetInstanceUID =
|
||||
activeViewport.displaySetInstanceUIDs[0];
|
||||
|
||||
const thumbnailList = document.querySelector('#ohif-thumbnail-list');
|
||||
|
||||
if (!thumbnailList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const thumbnailListBounds = thumbnailList.getBoundingClientRect();
|
||||
|
||||
const thumbnail = document.querySelector(
|
||||
`#thumbnail-${activeDisplaySetInstanceUID}`
|
||||
);
|
||||
|
||||
if (!thumbnail) {
|
||||
return;
|
||||
}
|
||||
|
||||
const thumbnailBounds = thumbnail.getBoundingClientRect();
|
||||
|
||||
// This only handles a vertical thumbnail list.
|
||||
if (
|
||||
thumbnailBounds.top >= thumbnailListBounds.top &&
|
||||
thumbnailBounds.top <= thumbnailListBounds.bottom
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
thumbnail.scrollIntoView({ behavior: 'smooth' });
|
||||
},
|
||||
|
||||
updateViewportDisplaySet: ({
|
||||
direction,
|
||||
excludeNonImageModalities,
|
||||
}: UpdateViewportDisplaySetParams) => {
|
||||
const nonImageModalities = [
|
||||
'SR',
|
||||
'SEG',
|
||||
'SM',
|
||||
'RTSTRUCT',
|
||||
'RTPLAN',
|
||||
'RTDOSE',
|
||||
];
|
||||
|
||||
// Sort the display sets as per the hanging protocol service viewport/display set scoring system.
|
||||
// The thumbnail list uses the same sorting.
|
||||
const dsSortFn = hangingProtocolService.getDisplaySetSortFunction();
|
||||
const currentDisplaySets = [...displaySetService.activeDisplaySets];
|
||||
|
||||
currentDisplaySets.sort(dsSortFn);
|
||||
|
||||
const { activeViewportIndex, viewports } = viewportGridService.getState();
|
||||
|
||||
const { displaySetInstanceUIDs } = viewports[activeViewportIndex];
|
||||
|
||||
const activeDisplaySetIndex = currentDisplaySets.findIndex(displaySet =>
|
||||
displaySetInstanceUIDs.includes(displaySet.displaySetInstanceUID)
|
||||
);
|
||||
|
||||
let displaySetIndexToShow: number;
|
||||
|
||||
for (
|
||||
displaySetIndexToShow = activeDisplaySetIndex + direction;
|
||||
displaySetIndexToShow > -1 &&
|
||||
displaySetIndexToShow < currentDisplaySets.length;
|
||||
displaySetIndexToShow += direction
|
||||
) {
|
||||
if (
|
||||
!excludeNonImageModalities ||
|
||||
!nonImageModalities.includes(
|
||||
currentDisplaySets[displaySetIndexToShow].Modality
|
||||
)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
displaySetIndexToShow < 0 ||
|
||||
displaySetIndexToShow >= currentDisplaySets.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { displaySetInstanceUID } = currentDisplaySets[
|
||||
displaySetIndexToShow
|
||||
];
|
||||
|
||||
let updatedViewports = [];
|
||||
|
||||
try {
|
||||
updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
|
||||
activeViewportIndex,
|
||||
displaySetInstanceUID
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
uiNotificationService.show({
|
||||
title: 'Navigate Viewport Display Set',
|
||||
message:
|
||||
'The requested display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.',
|
||||
type: 'info',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
viewportGridService.setDisplaySetsForViewports(updatedViewports);
|
||||
|
||||
setTimeout(() => actions.scrollActiveThumbnailIntoView(), 0);
|
||||
},
|
||||
};
|
||||
|
||||
const definitions = {
|
||||
@@ -598,6 +727,11 @@ const commandsModule = ({
|
||||
openDICOMTagViewer: {
|
||||
commandFn: actions.openDICOMTagViewer,
|
||||
},
|
||||
updateViewportDisplaySet: {
|
||||
commandFn: actions.updateViewportDisplaySet,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,8 +13,9 @@ import { id } from './id.js';
|
||||
import preRegistration from './init';
|
||||
import {
|
||||
ContextMenuController,
|
||||
CustomizeableContextMenuTypes,
|
||||
} from './CustomizeableContextMenu';
|
||||
CustomizableContextMenuTypes,
|
||||
} from './CustomizableContextMenu';
|
||||
import * as dicomWebUtils from './DicomWebDataSource/utils';
|
||||
|
||||
const defaultExtension: Types.Extensions.Extension = {
|
||||
/**
|
||||
@@ -47,6 +48,7 @@ export default defaultExtension;
|
||||
|
||||
export {
|
||||
ContextMenuController,
|
||||
CustomizeableContextMenuTypes,
|
||||
CustomizableContextMenuTypes,
|
||||
getStudiesForPatientByMRN,
|
||||
dicomWebUtils,
|
||||
};
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
DicomMetadataStore,
|
||||
IWebApiDataSource,
|
||||
utils,
|
||||
errorHandler,
|
||||
classes,
|
||||
} from '@ohif/core';
|
||||
import { utils } from '@ohif/core';
|
||||
|
||||
/**
|
||||
* Generates a URL that can be used for direct retrieve of the bulkdata
|
||||
@@ -57,31 +51,19 @@ const getDirectURL = (config, params) => {
|
||||
const BulkDataURI =
|
||||
(value && value.BulkDataURI) ||
|
||||
`series/${SeriesInstanceUID}/instances/${SOPInstanceUID}${defaultPath}`;
|
||||
const hasQuery = BulkDataURI.indexOf('?') != -1;
|
||||
const hasAccept = BulkDataURI.indexOf('accept=') != -1;
|
||||
const hasQuery = BulkDataURI.indexOf('?') !== -1;
|
||||
const hasAccept = BulkDataURI.indexOf('accept=') !== -1;
|
||||
const acceptUri =
|
||||
BulkDataURI +
|
||||
(hasAccept ? '' : (hasQuery ? '&' : '?') + `accept=${defaultType}`);
|
||||
if (BulkDataURI.indexOf('http') === 0) {
|
||||
if (tag === 'PixelData' || tag === 'EncapsulatedDocument') {
|
||||
return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/rendered`;
|
||||
} else {
|
||||
return acceptUri;
|
||||
}
|
||||
|
||||
if (tag === 'PixelData' || tag === 'EncapsulatedDocument') {
|
||||
return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/rendered`;
|
||||
}
|
||||
if (BulkDataURI.indexOf('/') === 0) {
|
||||
return wadoRoot + acceptUri;
|
||||
}
|
||||
if (BulkDataURI.indexOf('series/') == 0) {
|
||||
return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`;
|
||||
}
|
||||
if (BulkDataURI.indexOf('instances/') === 0) {
|
||||
return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/${acceptUri}`;
|
||||
}
|
||||
if (BulkDataURI.indexOf('bulkdata/') === 0) {
|
||||
return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`;
|
||||
}
|
||||
throw new Error('BulkDataURI in unknown format:' + BulkDataURI);
|
||||
|
||||
// The DICOMweb standard states that the default is multipart related, and then
|
||||
// separately states that the accept parameter is the URL parameter equivalent of the accept header.
|
||||
return acceptUri;
|
||||
};
|
||||
|
||||
export default getDirectURL;
|
||||
Reference in new issue
Block a user