@cornerstonejs/core
Version:
Cornerstone3D Core
229 lines (228 loc) • 8.83 kB
JavaScript
import { MetadataModules } from '../enums/index.js';
import * as metaData from '../metaData.js';
export const ECG_SECONDS_WIDTH = 150;
export const ECG_CHANNEL_SPACING = 5;
export const ECG_RENDERING_COLORS = {
gridMajor: '#7f0000',
gridMinor: '#3f0000',
baseline: '#7f4c00',
trace: '#ffffff',
label: '#ffff00',
background: '#000000',
};
export async function loadECGWaveform(dataId) {
const ecgModule = metaData.get(MetadataModules.ECG, dataId);
if (!ecgModule?.waveformData?.retrieveBulkData) {
throw new Error(`[ECG] No ECG waveform data for ${dataId}`);
}
const { numberOfWaveformChannels: numberOfChannels, numberOfWaveformSamples: numberOfSamples, samplingFrequency, waveformBitsAllocated: bitsAllocated = 16, waveformSampleInterpretation: sampleInterpretation = 'SS', multiplexGroupLabel, channelDefinitionSequence: channelDefinitions = [], } = ecgModule;
const channelArrays = await ecgModule.waveformData.retrieveBulkData();
const calibration = metaData.get(MetadataModules.CALIBRATION, dataId);
const channels = [];
for (let index = 0; index < numberOfChannels; index++) {
const channelDefinition = channelDefinitions[index] || {};
const name = channelDefinition.channelSourceSequence?.codeMeaning ||
channelDefinition.ChannelSourceSequence?.CodeMeaning ||
`Channel ${index + 1}`;
const data = channelArrays[index] || new Int16Array(0);
const { min, max } = computeECGMinMax(data);
channels.push({
name,
data,
min,
max,
});
}
return {
waveform: {
channels,
numberOfChannels,
numberOfSamples,
samplingFrequency,
bitsAllocated,
sampleInterpretation,
multiplexGroupLabel,
calibration,
},
calibration,
};
}
export function computeECGMinMax(data) {
let min = 0;
let max = 0;
for (let index = 0; index < data.length; index++) {
if (data[index] < min) {
min = data[index];
}
if (data[index] > max) {
max = data[index];
}
}
return { min, max };
}
export function getDefaultECGValueRange(waveform) {
let min = 0;
let max = 0;
waveform.channels.forEach((channel) => {
min = Math.min(min, channel.min);
max = Math.max(max, channel.max);
});
if (min === max) {
return [-1, 1];
}
return [min, max];
}
export function getVisibleECGChannels(channels, visibleChannels) {
if (!visibleChannels) {
return channels.filter((channel) => channel.data.length > 0);
}
const visible = new Set(visibleChannels);
return channels.filter((_channel, index) => visible.has(index) && channels[index].data.length > 0);
}
export function getVisibleECGChannelsByFlag(channels) {
return channels.filter((channel) => channel.visible !== false && channel.data.length > 0);
}
export function computeECGHeight(visibleChannels, channelScale) {
let totalHeight = 0;
visibleChannels.forEach((channel) => {
totalHeight += (channel.max - channel.min) * channelScale * 1.25;
totalHeight += ECG_CHANNEL_SPACING;
});
return totalHeight || 1;
}
export function computeECGChannelLayouts(args) {
const { visibleChannels, channelScale } = args;
const layouts = [];
let yOffset = 0;
visibleChannels.forEach((channel) => {
const itemHeight = (channel.max - channel.min) * channelScale * 1.25;
yOffset += itemHeight + ECG_CHANNEL_SPACING;
const baseline = yOffset + channel.min * channelScale;
layouts.push({
channel,
itemHeight,
yOffset,
baseline,
});
});
return layouts;
}
export function computeECGRenderMetrics(args) {
const { canvas, visibleChannels, windowMs, valueRange } = args;
const ecgWidth = Math.max(1, Math.ceil((windowMs / 1000) * ECG_SECONDS_WIDTH));
const [minValue, maxValue] = valueRange;
const range = Math.max(1, maxValue - minValue);
const canvasAspect = canvas.clientHeight && canvas.clientWidth
? canvas.clientHeight / canvas.clientWidth
: 2 / 3;
const targetTotalHeight = ecgWidth * canvasAspect;
const totalSpacing = ECG_CHANNEL_SPACING * Math.max(1, visibleChannels.length);
const heightPerChannel = (targetTotalHeight - totalSpacing) / Math.max(1, visibleChannels.length);
const channelScale = heightPerChannel / (range * 1.25);
const ecgHeight = computeECGHeight(visibleChannels, channelScale);
const worldToCanvasRatio = Math.min(canvas.clientWidth / Math.max(1, ecgWidth), canvas.clientHeight / Math.max(1, ecgHeight));
const drawWidth = ecgWidth * worldToCanvasRatio;
const drawHeight = ecgHeight * worldToCanvasRatio;
return {
ecgWidth,
ecgHeight,
channelScale,
worldToCanvasRatio,
xOffsetCanvas: (canvas.clientWidth - drawWidth) / 2,
yOffsetCanvas: (canvas.clientHeight - drawHeight) / 2,
};
}
export function drawECGGrid(ctx, metrics, options) {
if (options?.showGrid === false || metrics.channelScale <= 0) {
return;
}
const { ecgWidth, ecgHeight, channelScale } = metrics;
const minLineSpacing = 8;
let horizontalGridUnit = 100;
while (horizontalGridUnit * channelScale < minLineSpacing) {
horizontalGridUnit *= 2;
}
const minorH = horizontalGridUnit * channelScale;
const majorH = minorH * 5;
const minorV = ECG_SECONDS_WIDTH / 25;
const majorV = ECG_SECONDS_WIDTH / 5;
ctx.strokeStyle = ECG_RENDERING_COLORS.gridMinor;
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let y = minorH; y <= ecgHeight; y += minorH) {
if (Math.round(y / minorH) % 5 !== 0) {
ctx.moveTo(0, y);
ctx.lineTo(ecgWidth, y);
}
}
for (let x = minorV; x <= ecgWidth; x += minorV) {
if (Math.round(x / minorV) % 5 !== 0) {
ctx.moveTo(x, 0);
ctx.lineTo(x, ecgHeight);
}
}
ctx.stroke();
ctx.strokeStyle = ECG_RENDERING_COLORS.gridMajor;
ctx.lineWidth = 1;
ctx.beginPath();
for (let y = majorH; y <= ecgHeight; y += majorH) {
ctx.moveTo(0, y);
ctx.lineTo(ecgWidth, y);
}
for (let x = majorV; x <= ecgWidth; x += majorV) {
ctx.moveTo(x, 0);
ctx.lineTo(x, ecgHeight);
}
ctx.stroke();
}
export function drawECGTraces(args) {
const { ctx, layouts, ecgWidth, channelScale, startIndex = 0, endIndex, lineWidth = 1, amplitudeScale = 1, } = args;
layouts.forEach(({ channel, baseline }) => {
const resolvedEndIndex = Math.min(endIndex ?? channel.data.length, channel.data.length);
const resolvedStartIndex = Math.max(0, Math.min(startIndex, resolvedEndIndex - 1));
const sampleCount = Math.max(1, resolvedEndIndex - resolvedStartIndex);
ctx.strokeStyle = ECG_RENDERING_COLORS.baseline;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, baseline);
ctx.lineTo(ecgWidth, baseline);
ctx.stroke();
ctx.strokeStyle = ECG_RENDERING_COLORS.trace;
ctx.lineWidth = lineWidth;
ctx.beginPath();
for (let index = resolvedStartIndex; index < resolvedEndIndex; index++) {
const x = ((index - resolvedStartIndex) * ecgWidth) / sampleCount;
const y = baseline - channel.data[index] * channelScale * amplitudeScale;
if (index === resolvedStartIndex) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
});
}
export function drawECGLabels(ctx, layouts, worldToCanvasRatio) {
const fontSize = 14 / (worldToCanvasRatio || 1);
layouts.forEach(({ channel, itemHeight, yOffset }) => {
const labelY = yOffset - itemHeight + fontSize;
ctx.font = `${fontSize}px monospace`;
const textWidth = ctx.measureText(channel.name).width;
ctx.fillStyle = ECG_RENDERING_COLORS.background;
ctx.fillRect(5, labelY - fontSize, textWidth + 4, fontSize + 4);
ctx.fillStyle = ECG_RENDERING_COLORS.label;
ctx.fillText(channel.name, 5, labelY);
});
}
export function ensureECGCanvasSize(canvas) {
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, canvas.clientWidth || canvas.width || 1);
const height = Math.max(1, canvas.clientHeight || canvas.height || 1);
const nextWidth = Math.floor(width * dpr);
const nextHeight = Math.floor(height * dpr);
if (canvas.width !== nextWidth || canvas.height !== nextHeight) {
canvas.width = nextWidth;
canvas.height = nextHeight;
}
}