diff --git a/packages/core/src/RenderingEngine/GenericViewport/ECG/CanvasECGRenderPath.ts b/packages/core/src/RenderingEngine/GenericViewport/ECG/CanvasECGRenderPath.ts index 6412125b15..ffc0c23aa3 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/ECG/CanvasECGRenderPath.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/ECG/CanvasECGRenderPath.ts @@ -3,8 +3,8 @@ import triggerEvent from '../../../utilities/triggerEvent'; import { computeECGChannelLayouts, computeECGRenderMetrics, + computeECGTimeWindow, drawECGGrid, - drawECGLabels, drawECGTraces, ensureECGCanvasSize, getVisibleECGChannels, @@ -26,8 +26,20 @@ import type { } from './ECGViewportTypes'; import { resolveECGCanvasMapping } from './ecgViewportCamera'; -/** @internal */ +/** + * Render path implementation for rendering 2D ECG waveforms on an HTML5 canvas. + * @internal + */ export class CanvasECGRenderPath implements RenderPath { + /** + * Adds an ECG waveform dataset to the canvas render context and returns + * life-cycle control callbacks. + * + * @param ctx - Canvas render context + * @param data - Loaded ECG waveform data + * @param options - Render attachment options + * @returns Render path attachment handle + */ async addData( ctx: ECGCanvasRenderContext, data: LoadedData, @@ -69,6 +81,12 @@ export class CanvasECGRenderPath implements RenderPath { }; } + /** + * Updates presentation properties (e.g. visible channels, line width, grid) for the ECG rendering. + * + * @param rendering - Target canvas rendering object + * @param props - Updated presentation properties + */ private updateDataPresentation( rendering: ECGCanvasRendering, props: unknown @@ -78,16 +96,35 @@ export class CanvasECGRenderPath implements RenderPath { | undefined; } + /** + * Applies the current camera / view state to the rendering object. + * + * @param rendering - Target canvas rendering object + * @param camera - Updated camera view state + */ private applyViewState(rendering: ECGCanvasRendering, camera: unknown): void { rendering.currentCamera = camera as ECGViewState; } + /** + * Returns the viewport-scoped Frame of Reference UID. + * + * @param ctx - Canvas render context + * @returns Frame of reference UID string + */ private getFrameOfReferenceUID( ctx: ECGCanvasRenderContext ): string | undefined { return `ecg-viewport-${ctx.viewportId}`; } + /** + * Triggers a draw frame pass onto the canvas. + * + * @param ctx - Canvas render context + * @param rendering - Target canvas rendering object + * @param waveform - Waveform data payload + */ private render( ctx: ECGCanvasRenderContext, rendering: ECGCanvasRendering, @@ -96,27 +133,48 @@ export class CanvasECGRenderPath implements RenderPath { drawFrame(ctx, rendering, waveform); } + /** + * Cleans up data when removed from the viewport. + */ private removeData(): void { // Canvas lifecycle is owned by the viewport element. } } -/** @internal */ +/** + * Render path definition for 2D canvas ECG rendering. + * @internal + */ export class CanvasECGPath implements RenderPathDefinition { readonly id = 'ecg:canvas-signal'; readonly type = ViewportType.ECG_NEXT; + /** + * Checks if this render path handles the given data and render options. + * + * @param data - Loaded dataset + * @param options - Attachment options + * @returns boolean indicating if this path matches + */ matches(data: LoadedData, options: DataAddOptions): boolean { return data.type === 'ecg' && options.renderMode === 'signal2d'; } + /** + * Creates a new instance of CanvasECGRenderPath. + * + * @returns New CanvasECGRenderPath instance + */ createRenderPath() { return new CanvasECGRenderPath(); } } +/** + * Resolves the effective 2D transform ratio and pixel offsets for canvas rendering. + */ function getEffectiveTransform( metrics: RenderWindowMetrics, camera: ECGViewState | undefined, @@ -135,43 +193,10 @@ function getEffectiveTransform( }; } -function computeTimeWindow( - waveform: ECGWaveformPayload, - camera: ECGViewState -): { - startMs: number; - endMs: number; - startIndex: number; - endIndex: number; -} { - const durationMs = - (waveform.numberOfSamples / waveform.samplingFrequency) * 1000; - const startMs = Math.max(0, Math.min(camera.timeRange[0], durationMs)); - const requestedEnd = Math.max(startMs + 1, camera.timeRange[1]); - const endMs = Math.max(startMs + 1, Math.min(requestedEnd, durationMs)); - const startIndex = Math.max( - 0, - Math.min( - waveform.numberOfSamples - 1, - Math.floor((startMs / 1000) * waveform.samplingFrequency) - ) - ); - const endIndex = Math.max( - startIndex + 1, - Math.min( - waveform.numberOfSamples, - Math.ceil((endMs / 1000) * waveform.samplingFrequency) - ) - ); - - return { - startMs, - endMs, - startIndex, - endIndex, - }; -} - +/** + * Executes a full canvas render pass for an ECG frame, including background, + * grid lines, baselines, and waveform traces. + */ function drawFrame( ecgCtx: ECGCanvasRenderContext, ecgRendering: ECGCanvasRendering, @@ -199,12 +224,13 @@ function drawFrame( currentCamera.timeRange[1] - currentCamera.timeRange[0] ), valueRange: currentCamera.valueRange, + traceRegions: currentDataPresentation?.traceRegions, }) as RenderWindowMetrics; const layouts = computeECGChannelLayouts({ visibleChannels, channelScale: metrics.channelScale, }); - const timeWindow = computeTimeWindow(waveform, currentCamera); + const timeWindow = computeECGTimeWindow(waveform, currentCamera); const dpr = window.devicePixelRatio || 1; ecgRendering.metrics = metrics; @@ -240,13 +266,17 @@ function drawFrame( ctx: canvasContext, layouts, ecgWidth: metrics.ecgWidth, + ecgHeight: metrics.ecgHeight, channelScale: metrics.channelScale, startIndex: timeWindow.startIndex, endIndex: timeWindow.endIndex, lineWidth: currentDataPresentation?.lineWidth, amplitudeScale: currentDataPresentation?.amplitudeScale, + traceRegions: currentDataPresentation?.traceRegions, + channels: waveform.channels, + numberOfSamples: waveform.numberOfSamples, + visibleChannels: currentDataPresentation?.visibleChannels, }); - drawECGLabels(canvasContext, layouts, metrics.worldToCanvasRatio); canvasContext.resetTransform(); canvasContext.globalAlpha = 1; diff --git a/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGResolvedView.ts b/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGResolvedView.ts index 27fba336e4..9039d9f725 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGResolvedView.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGResolvedView.ts @@ -1,6 +1,8 @@ import type { ICamera, Point2, Point3 } from '../../../types'; import { computeECGChannelLayouts, + computeECGRegionSampleRange, + computeECGTimeWindow, getVisibleECGChannels, } from '../../../utilities/ECGUtilities'; import ResolvedViewportView from '../ResolvedViewportView'; @@ -16,6 +18,7 @@ import type { ECGDataPresentation, ECGWaveformPayload, RenderWindowMetrics, + ChannelLayout, } from './ECGViewportTypes'; type ECGResolvedViewState = { @@ -38,69 +41,179 @@ class ECGResolvedView extends ResolvedViewportView { return getPanForECGCanvasMapping(this.getCanvasMapping()); } + /** + * Converts 2D canvas coordinates into 3D world coordinates for ECG viewports, + * resolving both horizontal sample index, amplitude, and channel/lead index. + * + * @param canvasPos - 2D pixel coordinates on the canvas + * @returns 3D world coordinates [sampleIndex, amplitudeValue, leadIndex] + */ canvasToWorld(canvasPos: Point2): Point3 { const mapping = this.getCanvasMapping(); const channelLayouts = this.getChannelLayouts(); + + if (!channelLayouts.length) { + return [0, 0, 0]; + } + const subCanvasPos: Point2 = [ (canvasPos[0] - mapping.xOffset) / mapping.effectiveRatio, (canvasPos[1] - mapping.yOffset) / mapping.effectiveRatio, ]; - let z = 0; - for (let index = 0; index < channelLayouts.length; index++) { - const channelLayout = channelLayouts[index]; + const normX = subCanvasPos[0] / Math.max(1, this.state.metrics.ecgWidth); + const normY = subCanvasPos[1] / Math.max(1, this.state.metrics.ecgHeight); + + let matchingLayout: ChannelLayout | undefined; + const has2DBounds = channelLayouts.some((l) => l.minX !== undefined); + + if (has2DBounds) { + matchingLayout = channelLayouts.find( + (l) => + normX >= (l.minX ?? 0) && + normX <= (l.maxX ?? 1) && + normY >= (l.minY ?? 0) && + normY <= (l.maxY ?? 1) + ); + + if (!matchingLayout) { + let minDistanceSq = Infinity; + for (const layout of channelLayouts) { + const midX = ((layout.minX ?? 0) + (layout.maxX ?? 1)) / 2; + const midY = ((layout.minY ?? 0) + (layout.maxY ?? 1)) / 2; + const distSq = (normX - midX) ** 2 + (normY - midY) ** 2; + if (distSq < minDistanceSq) { + minDistanceSq = distSq; + matchingLayout = layout; + } + } + } + } + + if (!matchingLayout) { + for (let index = 0; index < channelLayouts.length; index++) { + const layout = channelLayouts[index]; - if ( - subCanvasPos[1] <= channelLayout.yOffset || - index === channelLayouts.length - 1 - ) { - z = index; - break; + if ( + subCanvasPos[1] <= layout.yOffset || + index === channelLayouts.length - 1 + ) { + matchingLayout = layout; + break; + } } } - const channelLayout = channelLayouts[z]; + const channelLayout = matchingLayout || channelLayouts[0]; + + if (!channelLayout) { + return [0, 0, 0]; + } + + const z = + channelLayout.regionIndex !== undefined + ? channelLayout.regionIndex * 1000 + (channelLayout.leadIndex ?? 0) + : (channelLayout.leadIndex ?? 0); + const minX = channelLayout.minX ?? 0; + const maxX = channelLayout.maxX ?? 1; + const startIndex = channelLayout.startIndex ?? 0; + const endIndex = + channelLayout.endIndex ?? this.state.waveform.numberOfSamples; + + const spanX = Math.max(1e-6, maxX - minX); + const fracX = Math.max(0, Math.min(1, (normX - minX) / spanX)); + const sampleIndex = Math.max( + 0, + Math.min( + this.state.waveform.numberOfSamples - 1, + startIndex + fracX * (endIndex - startIndex) + ) + ); return [ - Math.max( - 0, - Math.min( - this.state.waveform.numberOfSamples - 1, - (subCanvasPos[0] * this.state.waveform.numberOfSamples) / - this.state.metrics.ecgWidth - ) - ), + sampleIndex, (channelLayout.baseline - subCanvasPos[1]) / - this.state.metrics.channelScale, + Math.max(1e-6, this.state.metrics.channelScale), z, ]; } + /** + * Converts 3D world coordinates [sampleIndex, amplitudeValue, leadIndex] + * into 2D canvas pixel coordinates. + * + * @param worldPos - 3D world coordinates + * @returns 2D canvas pixel coordinates + */ worldToCanvas(worldPos: Point3): Point2 { const mapping = this.getCanvasMapping(); const channelLayouts = this.getChannelLayouts(); - const z = Math.round(worldPos[2]); + const rawZ = Math.round(worldPos[2]); + const sampleIndex = worldPos[0]; - if (z < 0 || z >= channelLayouts.length) { + if (!channelLayouts.length) { return [0, 0]; } + const regionIdx = rawZ >= 1000 ? Math.floor(rawZ / 1000) : undefined; + const leadIdx = rawZ >= 1000 ? rawZ % 1000 : rawZ; + + let layout: ChannelLayout | undefined; + + if (regionIdx !== undefined) { + layout = + channelLayouts.find( + (l) => + l.regionIndex === regionIdx && + (l.leadIndex === leadIdx || l.leadIndex === undefined) + ) || channelLayouts.find((l) => l.regionIndex === regionIdx); + } else { + const matchingLayouts = channelLayouts.filter( + (l) => l.leadIndex === leadIdx + ); + layout = + matchingLayouts.find((l) => { + const s = l.startIndex ?? 0; + const e = l.endIndex ?? this.state.waveform.numberOfSamples; + return sampleIndex >= s && sampleIndex <= e; + }) || matchingLayouts[0]; + } + + if (!layout) { + return [NaN, NaN]; + } + + const minX = layout.minX ?? 0; + const maxX = layout.maxX ?? 1; + const startIndex = layout.startIndex ?? 0; + const endIndex = layout.endIndex ?? this.state.waveform.numberOfSamples; + const sampleSpan = Math.max(1, endIndex - startIndex); + const fracX = (sampleIndex - startIndex) / sampleSpan; + const normX = minX + fracX * (maxX - minX); + return [ - (worldPos[0] / this.state.waveform.numberOfSamples) * - this.state.metrics.ecgWidth * - mapping.effectiveRatio + + normX * this.state.metrics.ecgWidth * mapping.effectiveRatio + mapping.xOffset, - (channelLayouts[z].baseline - - worldPos[1] * this.state.metrics.channelScale) * + (layout.baseline - worldPos[1] * this.state.metrics.channelScale) * mapping.effectiveRatio + mapping.yOffset, ]; } + /** + * Returns the Frame of Reference UID associated with this resolved view. + */ getFrameOfReferenceUID(): string | undefined { return this.state.frameOfReferenceUID; } + /** + * Creates a new ECGResolvedView instance with the updated zoom level. + * + * @param zoom - New scale multiplier + * @param canvasPoint - Optional pivot point on canvas for zooming + * @returns Updated ECGResolvedView instance + */ withZoom(zoom: number, canvasPoint?: Point2): ECGResolvedView { const nextZoom = Math.max(zoom, 0.001); @@ -127,6 +240,12 @@ class ECGResolvedView extends ResolvedViewportView { }); } + /** + * Creates a new ECGResolvedView instance with the updated pan position. + * + * @param pan - 2D pan offset in canvas coordinates + * @returns Updated ECGResolvedView instance + */ withPan(pan: Point2): ECGResolvedView { return this.cloneWithViewState({ ...this.state.viewState, @@ -137,6 +256,9 @@ class ECGResolvedView extends ResolvedViewportView { }); } + /** + * Constructs the Cornerstone ICamera representation for this ECG view. + */ protected buildICamera(): ICamera { const mapping = this.getCanvasMapping(); const canvasCenter: Point2 = [ @@ -157,6 +279,9 @@ class ECGResolvedView extends ResolvedViewportView { }; } + /** + * Resolves and caches the canvas transformation mapping based on current view state. + */ private getCanvasMapping(): ECGCanvasMapping { this.cachedCanvasMapping ||= resolveECGCanvasMapping({ canvas: this.state.canvas, @@ -167,14 +292,99 @@ class ECGResolvedView extends ResolvedViewportView { return this.cachedCanvasMapping; } - private getChannelLayouts() { + /** + * Generates the channel layouts for the current presentation, preserving 2D region bounds + * and original lead indices for segmented multi-lead configurations. + */ + private getChannelLayouts(): ChannelLayout[] { + const traceRegions = this.state.dataPresentation?.traceRegions; + const allChannels = this.state.waveform.channels; + const visibleSet = this.state.dataPresentation?.visibleChannels + ? new Set(this.state.dataPresentation.visibleChannels) + : null; + + const timeWindow = computeECGTimeWindow( + this.state.waveform, + this.state.viewState + ); + const effectiveStart = timeWindow.startIndex; + const effectiveEnd = timeWindow.endIndex; + const windowSpan = Math.max(1, effectiveEnd - effectiveStart); + + if (traceRegions && traceRegions.length > 0) { + const layouts: ChannelLayout[] = []; + for (let i = 0; i < traceRegions.length; i++) { + const region = traceRegions[i]; + const leadIndices = region.leadIndices?.length + ? region.leadIndices + : [i]; + const leadCount = leadIndices.length; + const minX = region.bounds?.minX ?? 0; + const maxX = region.bounds?.maxX ?? 1; + const totalMinY = region.bounds?.minY ?? 0; + const totalMaxY = region.bounds?.maxY ?? 1; + const slotHeight = (totalMaxY - totalMinY) / leadCount; + + for (let k = 0; k < leadCount; k++) { + const leadIdx = leadIndices[k]; + if (visibleSet && !visibleSet.has(leadIdx)) { + continue; + } + const channel = allChannels[leadIdx]; + if (!channel) { + continue; + } + + const minY = totalMinY + k * slotHeight; + const maxY = minY + slotHeight; + const baseline = ((minY + maxY) / 2) * this.state.metrics.ecgHeight; + const itemHeight = (maxY - minY) * this.state.metrics.ecgHeight; + + const { segStartIndex, segEndIndex } = computeECGRegionSampleRange({ + region, + channelDataLength: channel.data.length, + effectiveStart, + effectiveEnd, + }); + + layouts.push({ + channel, + itemHeight, + yOffset: maxY * this.state.metrics.ecgHeight, + baseline, + minX, + maxX, + minY, + maxY, + leadIndex: leadIdx, + regionIndex: i, + timeWindow: region.timeWindow, + startIndex: segStartIndex, + endIndex: segEndIndex, + }); + } + } + return layouts; + } + + const visibleChannels = getVisibleECGChannels( + allChannels, + this.state.dataPresentation?.visibleChannels + ); + return computeECGChannelLayouts({ - visibleChannels: getVisibleECGChannels( - this.state.waveform.channels, - this.state.dataPresentation?.visibleChannels - ), + visibleChannels, channelScale: this.state.metrics.channelScale, - }); + }).map((layout) => ({ + ...layout, + minX: 0, + maxX: 1, + minY: 0, + maxY: 1, + startIndex: effectiveStart, + endIndex: effectiveEnd, + leadIndex: allChannels.indexOf(layout.channel), + })); } private cloneWithViewState(viewState: ECGViewState): ECGResolvedView { diff --git a/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGViewport.ts b/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGViewport.ts index bf98521276..0db1e81d89 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGViewport.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGViewport.ts @@ -3,11 +3,14 @@ import type { LoadedData } from '../ViewportArchitectureTypes'; import GenericViewport from '../GenericViewport'; import { ViewportType } from '../../../enums'; import { getDefaultECGValueRange } from '../../../utilities/ECGUtilities'; +import imageIdToURI from '../../../utilities/imageIdToURI'; import type { CPUIImageData, + IImageCalibration, Mat3, Point2, Point3, + ReferenceCompatibleOptions, ViewReference, ViewReferenceSpecifier, } from '../../../types'; @@ -55,6 +58,8 @@ class ECGViewport extends GenericViewport< this.renderingEngineId = args.renderingEngineId; this.canvas = getOrCreateCanvas(this.element); this.canvasContext = this.canvas.getContext('2d'); + this.canvasToWorld = this.canvasToWorld.bind(this); + this.worldToCanvas = this.worldToCanvas.bind(this); this.dataProvider = args.dataProvider || new DefaultECGDataProvider(); this.renderPathResolver = args.renderPathResolver || createECGRenderPathResolver(); @@ -120,11 +125,15 @@ class ECGViewport extends GenericViewport< getViewReference(_specifier: ViewReferenceSpecifier = {}): ViewReference { const dataId = this.getFirstBinding()?.data.id; + const currentImageId = this.getCurrentImageId(); return { FrameOfReferenceUID: this.getFrameOfReferenceUID(), dataId, - referencedImageId: this.getCurrentImageId(), + referencedImageId: currentImageId, + referencedImageURI: currentImageId + ? imageIdToURI(currentImageId) + : undefined, sliceIndex: 0, }; } @@ -296,10 +305,14 @@ class ECGViewport extends GenericViewport< } /** - * No-op: ECG viewports are not slice stacks. + * Scrolls the ECG viewport horizontally in time. */ - scroll(): void { - // no-op + scroll(delta = 0): void { + if (!delta) { + return; + } + const [panX, panY] = this.getPan(); + this.setPan([panX - delta * 50, panY]); } /** @@ -327,6 +340,70 @@ class ECGViewport extends GenericViewport< return binding ? [binding.data.id] : []; } + /** + * Returns whether this viewport is rendering the given image ID. + */ + hasImageId(imageId: string): boolean { + return this.getCurrentImageId() === imageId; + } + + /** + * Returns whether this viewport is rendering the given image URI. + * Required by ImageSliceViewport interface for tool measurement resolution. + * + * @param imageURI - Image URI to check + * @returns boolean indicating if the image URI matches current ECG dataset + */ + hasImageURI(imageURI: string): boolean { + const currentImageId = this.getCurrentImageId(); + if (!currentImageId) { + return false; + } + return imageIdToURI(currentImageId) === imageURI; + } + + /** + * Returns the Frame of Reference UID scoped to this ECG viewport. + */ + getFrameOfReferenceUID(): string { + return `ecg-viewport-${this.id}`; + } + + /** + * Returns whether a view reference is viewable in this ECG viewport. + * + * @param viewRef - View reference to check + * @param _options - Optional reference compatibility options + * @returns boolean indicating if the reference belongs to this viewport + */ + isReferenceViewable( + viewRef: ViewReference, + _options: ReferenceCompatibleOptions = {} + ): boolean { + if (!viewRef) { + return false; + } + + const currentImageId = this.getCurrentImageId(); + if (!currentImageId) { + return false; + } + + if (viewRef.referencedImageId) { + return this.hasImageId(viewRef.referencedImageId); + } + + if (viewRef.referencedImageURI) { + return this.hasImageURI(viewRef.referencedImageURI); + } + + if (viewRef.FrameOfReferenceUID) { + return viewRef.FrameOfReferenceUID === this.getFrameOfReferenceUID(); + } + + return false; + } + /** * Returns image data compatible with the Cornerstone tools annotation system. * Amplitude is mapped to [0, ECG_AMPLITUDE_INDEX_SIZE) so annotation @@ -341,7 +418,14 @@ class ECGViewport extends GenericViewport< const nSamples = waveform.numberOfSamples; const nChannels = waveform.channels.length; - const dimensions: Point3 = [nSamples, ECG_AMPLITUDE_INDEX_SIZE, nChannels]; + const traceRegions = this.getDisplaySetPresentation( + waveform.id + )?.traceRegions; + const maxZ = + traceRegions && traceRegions.length > 0 + ? (traceRegions.length + 1) * 1000 + : nChannels; + const dimensions: Point3 = [nSamples, ECG_AMPLITUDE_INDEX_SIZE, maxZ]; const spacing: Point3 = [1, 1, 1]; const origin: Point3 = [0, 0, 0]; const direction: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1]; @@ -371,6 +455,7 @@ class ECGViewport extends GenericViewport< hasPixelSpacing: false, preScale: { scaled: false }, metadata: { Modality: 'ECG', FrameOfReferenceUID: '' }, + calibration: waveform.calibration as IImageCalibration, }; } diff --git a/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGViewportTypes.ts b/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGViewportTypes.ts index f10e89c02c..fc066dccae 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGViewportTypes.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/ECG/ECGViewportTypes.ts @@ -1,3 +1,4 @@ +import type { AABB2 } from '../../../types'; import type { BaseViewportRenderContext, BasePresentationProps, @@ -14,6 +15,14 @@ export interface ECGChannelData { max: number; } +/** Normalized 2D bounding region for multi-lead or segmented ECG layouts */ +export interface TraceRegion { + id?: string; + bounds: AABB2; + leadIndices: number[]; + timeWindow?: [number, number]; +} + /** @internal */ export interface ECGWaveformPayload { channels: ECGChannelData[]; @@ -28,6 +37,7 @@ export interface ECGWaveformPayload { export interface ECGPresentationProps extends BasePresentationProps { visibleChannels?: number[]; + traceRegions?: TraceRegion[]; } export interface ECGViewState extends ViewportCameraBase<[number, number]> { @@ -61,6 +71,15 @@ export interface ChannelLayout { itemHeight: number; yOffset: number; baseline: number; + minX?: number; + maxX?: number; + minY?: number; + maxY?: number; + leadIndex?: number; + regionIndex?: number; + timeWindow?: [number, number]; + startIndex?: number; + endIndex?: number; } /** @internal */ diff --git a/packages/core/src/RenderingEngine/GenericViewport/ECG/index.ts b/packages/core/src/RenderingEngine/GenericViewport/ECG/index.ts index 7f890c9470..5c8ceb6918 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/ECG/index.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/ECG/index.ts @@ -27,6 +27,7 @@ export type { } from './ecgProjectionAdapter'; export { default } from './ECGViewport'; export type { + TraceRegion, ECGViewState, ECGDataPresentation, ECGChannelData, diff --git a/packages/core/src/RenderingEngine/GenericViewport/index.ts b/packages/core/src/RenderingEngine/GenericViewport/index.ts index e0403a0ced..094018e7bc 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/index.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/index.ts @@ -76,6 +76,7 @@ export { ecgProjection, } from './ECG'; export type { + TraceRegion, ECGViewState, ECGDataPresentation, ECGChannelData, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad4280a400..accb90a43e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -34,6 +34,8 @@ import ECGGenericViewport, { createECGRenderPathResolver, DefaultECGDataProvider, ecgProjection, + type TraceRegion, + type ECGProperties, } from './RenderingEngine/GenericViewport/ECG'; import VideoGenericViewport, { createDefaultVideoRenderPaths, @@ -276,6 +278,8 @@ export type { RetrieveStage, ImageLoadListener, IImagesLoader, + TraceRegion, + ECGProperties, }; export { diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 66e9709a7e..fc495a3250 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -136,6 +136,14 @@ import type { ECGChannel, ECGWaveformData, } from './ECGViewportTypes'; +import type { + TraceRegion, + ECGChannelData, + ECGPresentationProps, + ECGViewState, + ECGDataPresentation, + ECGProperties, +} from '../RenderingEngine/GenericViewport/ECG/ECGViewportTypes'; import type ECGViewportProperties from './ECGViewportProperties'; import type { ISurface } from './ISurface'; import type BoundsIJK from './BoundsIJK'; @@ -314,6 +322,12 @@ export type { ECGChannel, ECGWaveformData, ECGViewportProperties, + TraceRegion, + ECGChannelData, + ECGPresentationProps, + ECGViewState, + ECGDataPresentation, + ECGProperties, BoundsIJK, BoundsLPS, Color, diff --git a/packages/core/src/utilities/ECGUtilities.ts b/packages/core/src/utilities/ECGUtilities.ts index 743c2e182f..cbd18d77a5 100644 --- a/packages/core/src/utilities/ECGUtilities.ts +++ b/packages/core/src/utilities/ECGUtilities.ts @@ -1,5 +1,6 @@ import { MetadataModules } from '../enums'; import * as metaData from '../metaData'; +import type { TraceRegion } from '../RenderingEngine/GenericViewport/ECG/ECGViewportTypes'; export const ECG_SECONDS_WIDTH = 150; export const ECG_CHANNEL_SPACING = 5; @@ -13,6 +14,21 @@ export const ECG_RENDERING_COLORS = { background: '#000000', } as const; +export const STANDARD_12_LEADS = [ + 'I', + 'II', + 'III', + 'aVR', + 'aVL', + 'aVF', + 'V1', + 'V2', + 'V3', + 'V4', + 'V5', + 'V6', +] as const; + export interface ECGChannelLike { name: string; data: Int16Array; @@ -58,6 +74,12 @@ export interface ECGGridMetrics { channelScale: number; } +/** + * Loads an ECG waveform dataset from metadata and retrieves the channel data arrays. + * + * @param dataId - The unique data ID / image ID for the ECG dataset + * @returns Object containing the parsed waveform data and calibration metadata + */ export async function loadECGWaveform(dataId: string): Promise<{ waveform: ECGWaveformLike; calibration: unknown; @@ -85,10 +107,19 @@ export async function loadECGWaveform(dataId: string): Promise<{ for (let index = 0; index < numberOfChannels; index++) { const channelDefinition = channelDefinitions[index] || {}; - const name = + const rawName = channelDefinition.channelSourceSequence?.codeMeaning || channelDefinition.ChannelSourceSequence?.CodeMeaning || - `Channel ${index + 1}`; + ''; + const cleanName = rawName + .replace(/^Lead\s+/i, '') + .replace(/\s*\([^)]*\)/g, '') + .trim(); + + const name = + cleanName || + (index < STANDARD_12_LEADS.length ? STANDARD_12_LEADS[index] : '') || + `${index + 1}`; const data = channelArrays[index] || new Int16Array(0); const { min, max } = computeECGMinMax(data); @@ -214,13 +245,116 @@ export function computeECGChannelLayouts< return layouts; } +/** + * Computes the visible sample range [startIndex, endIndex] and time range [startMs, endMs] + * for the given waveform and camera time range. + * + * @param waveform - Waveform metadata and sample parameters + * @param camera - Current ECG camera/view state containing timeRange + * @returns Object with calculated time boundaries and sample indices + */ +export function computeECGTimeWindow( + waveform: { numberOfSamples: number; samplingFrequency?: number }, + camera?: { timeRange?: [number, number] } +): { + startMs: number; + endMs: number; + startIndex: number; + endIndex: number; +} { + const samplingFrequency = Math.max(1, waveform?.samplingFrequency || 1000); + const numberOfSamples = Math.max(1, waveform?.numberOfSamples ?? 5000); + const durationMs = (numberOfSamples / samplingFrequency) * 1000; + const startMs = Math.max( + 0, + Math.min(camera?.timeRange?.[0] ?? 0, durationMs) + ); + const requestedEnd = Math.max( + startMs + 1, + camera?.timeRange?.[1] ?? durationMs + ); + const endMs = Math.max(startMs + 1, Math.min(requestedEnd, durationMs)); + const startIndex = Math.max( + 0, + Math.min( + numberOfSamples - 1, + Math.floor((startMs / 1000) * samplingFrequency) + ) + ); + const endIndex = Math.max( + startIndex + 1, + Math.min(numberOfSamples, Math.ceil((endMs / 1000) * samplingFrequency)) + ); + + return { + startMs, + endMs, + startIndex, + endIndex, + }; +} + +/** + * Computes the sample index range [segStartIndex, segEndIndex] for a trace region, + * taking into account explicit timeWindow boundaries or proportional bounds within + * the active viewport window. + * + * @param args - Calculation parameters including region, channel length, and timeline window bounds + * @returns Object with segStartIndex and segEndIndex + */ +export function computeECGRegionSampleRange(args: { + region: TraceRegion; + channelDataLength: number; + effectiveStart: number; + effectiveEnd: number; +}): { + segStartIndex: number; + segEndIndex: number; +} { + const { region, channelDataLength, effectiveStart, effectiveEnd } = args; + const windowSpan = Math.max(1, effectiveEnd - effectiveStart); + const minX = region.bounds?.minX ?? 0; + const maxX = region.bounds?.maxX ?? 1; + + if (region.timeWindow && region.timeWindow.length === 2) { + const segStartIndex = Math.max( + effectiveStart, + Math.min(channelDataLength, region.timeWindow[0]) + ); + const segEndIndex = Math.max( + segStartIndex, + Math.min(effectiveEnd, channelDataLength, region.timeWindow[1]) + ); + return { segStartIndex, segEndIndex }; + } + + const segStartIndex = Math.max( + 0, + Math.floor(effectiveStart + minX * windowSpan) + ); + const segEndIndex = Math.min( + channelDataLength, + Math.ceil(effectiveStart + maxX * windowSpan) + ); + + return { segStartIndex, segEndIndex }; +} + +/** + * Computes rendering metrics (dimensions, channel scaling, and world-to-canvas ratio) + * for an ECG viewport based on canvas size, visible channels, and layout regions. + * + * @param args - Metric calculation parameters including canvas, channels, window, and traceRegions + * @returns Computed ECGRenderMetrics object + */ export function computeECGRenderMetrics(args: { canvas: HTMLCanvasElement; visibleChannels: TChannel[]; windowMs: number; valueRange: [number, number]; + traceRegions?: TraceRegion[]; }): ECGRenderMetrics { - const { canvas, visibleChannels, windowMs, valueRange } = args; + const { canvas, visibleChannels, windowMs, valueRange, traceRegions } = args; const ecgWidth = Math.max( 1, Math.ceil((windowMs / 1000) * ECG_SECONDS_WIDTH) @@ -232,12 +366,21 @@ export function computeECGRenderMetrics(args: { ? 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 hasRegions = traceRegions && traceRegions.length > 0; + const distinctRows = hasRegions + ? new Set(traceRegions.map((r) => Math.round((r.bounds?.minY ?? 0) * 100))) + .size + : visibleChannels.length; + const rowCount = Math.max(1, distinctRows); + + const totalSpacing = ECG_CHANNEL_SPACING * rowCount; + const heightPerChannel = (targetTotalHeight - totalSpacing) / rowCount; const channelScale = heightPerChannel / (range * 1.25); - const ecgHeight = computeECGHeight(visibleChannels, channelScale); + const ecgHeight = hasRegions + ? targetTotalHeight + : computeECGHeight(visibleChannels, channelScale); + const worldToCanvasRatio = Math.min( canvas.clientWidth / Math.max(1, ecgWidth), canvas.clientHeight / Math.max(1, ecgHeight) @@ -314,27 +457,123 @@ export function drawECGGrid( ctx.stroke(); } +/** + * Renders ECG waveform traces and baselines onto a 2D canvas context, supporting + * both continuous stacked channel layouts and segmented multi-lead trace regions. + * + * @param args - Drawing parameters including canvas context, layout configuration, metrics, and traceRegions + */ export function drawECGTraces(args: { ctx: CanvasRenderingContext2D; layouts: ECGChannelLayout[]; ecgWidth: number; + ecgHeight?: number; channelScale: number; startIndex?: number; endIndex?: number; lineWidth?: number; amplitudeScale?: number; + traceRegions?: TraceRegion[]; + channels?: TChannel[]; + numberOfSamples?: number; + visibleChannels?: number[]; }): void { const { ctx, layouts, ecgWidth, + ecgHeight = 1, channelScale, startIndex = 0, endIndex, lineWidth = 1, amplitudeScale = 1, + traceRegions, + channels, + numberOfSamples, + visibleChannels, } = args; + if ( + traceRegions && + traceRegions.length > 0 && + channels && + channels.length > 0 + ) { + const totalSamples = numberOfSamples ?? channels[0]?.data?.length ?? 5000; + const effectiveStart = startIndex ?? 0; + const effectiveEnd = endIndex ?? totalSamples; + const windowSpan = Math.max(1, effectiveEnd - effectiveStart); + const visibleChannelsSet = visibleChannels + ? new Set(visibleChannels) + : null; + + traceRegions.forEach((region) => { + const leadIndices = region.leadIndices?.length ? region.leadIndices : [0]; + const leadCount = leadIndices.length; + + const minX = region.bounds?.minX ?? 0; + const maxX = region.bounds?.maxX ?? 1; + const totalMinY = region.bounds?.minY ?? 0; + const totalMaxY = region.bounds?.maxY ?? 1; + const slotHeight = (totalMaxY - totalMinY) / leadCount; + + const startX = minX * ecgWidth; + const endX = maxX * ecgWidth; + const spanWidth = Math.max(1, endX - startX); + + leadIndices.forEach((channelIdx, leadOffset) => { + if (visibleChannelsSet && !visibleChannelsSet.has(channelIdx)) { + return; + } + + const channel = channels[channelIdx]; + if (!channel || !channel.data.length) { + return; + } + + const minY = totalMinY + leadOffset * slotHeight; + const maxY = minY + slotHeight; + const baseline = ((minY + maxY) / 2) * ecgHeight; + + const { segStartIndex, segEndIndex } = computeECGRegionSampleRange({ + region, + channelDataLength: channel.data.length, + effectiveStart, + effectiveEnd, + }); + const sampleCount = Math.max(1, segEndIndex - segStartIndex); + + ctx.strokeStyle = ECG_RENDERING_COLORS.baseline; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(startX, baseline); + ctx.lineTo(endX, baseline); + ctx.stroke(); + + ctx.strokeStyle = ECG_RENDERING_COLORS.trace; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + + for (let index = segStartIndex; index < segEndIndex; index++) { + const x = + startX + ((index - segStartIndex) * spanWidth) / sampleCount; + const y = + baseline - channel.data[index] * channelScale * amplitudeScale; + + if (index === segStartIndex) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + + ctx.stroke(); + }); + }); + return; + } + layouts.forEach(({ channel, baseline }) => { const resolvedEndIndex = Math.min( endIndex ?? channel.data.length, diff --git a/packages/metadata/src/utilities/metadataProvider/ecgFromInstance.ts b/packages/metadata/src/utilities/metadataProvider/ecgFromInstance.ts index 6a62cf6428..f6e802a8da 100644 --- a/packages/metadata/src/utilities/metadataProvider/ecgFromInstance.ts +++ b/packages/metadata/src/utilities/metadataProvider/ecgFromInstance.ts @@ -305,27 +305,29 @@ const ECG_AMPLITUDE_OFFSET = 32768; */ const ecgCalibrationProvider: TypedProvider = (next, query, data, options) => { const instance = data as Record | undefined; - const raw = instance?.WaveformSequence; + const raw = instance?.WaveformSequence ?? instance?.waveformSequence; const groups = toArray(raw as ArrayLike> | undefined); if (!groups.length) return next(query, data, options); const group = groups[0]; const numberOfWaveformSamples = - (group.NumberOfWaveformSamples as number) ?? 0; - const samplingFrequency = (group.SamplingFrequency as number) ?? 1; - const physicalDeltaX = 1 / (samplingFrequency || 1); + ((group.NumberOfWaveformSamples ?? + group.numberOfWaveformSamples) as number) || 100000; + const samplingFrequency = + ((group.SamplingFrequency ?? group.samplingFrequency) as number) || 1000; + const physicalDeltaX = 1000 / (samplingFrequency || 1); const physicalDeltaY = 0.001; return { sequenceOfUltrasoundRegions: [ { regionLocationMinX0: 0, - regionLocationMaxX1: numberOfWaveformSamples, + regionLocationMaxX1: Math.max(numberOfWaveformSamples, 100000), regionLocationMinY0: 0, regionLocationMaxY1: ECG_AMPLITUDE_INDEX_SIZE - 1, referencePixelX0: 0, referencePixelY0: ECG_AMPLITUDE_OFFSET, physicalDeltaX, physicalDeltaY, - physicalUnitsXDirection: 4, + physicalUnitsXDirection: -2, physicalUnitsYDirection: -1, regionDataType: 1, }, diff --git a/packages/tools/src/utilities/getCalibratedUnits.ts b/packages/tools/src/utilities/getCalibratedUnits.ts index 6be7c609c9..500b22cf85 100644 --- a/packages/tools/src/utilities/getCalibratedUnits.ts +++ b/packages/tools/src/utilities/getCalibratedUnits.ts @@ -18,6 +18,7 @@ const SUPPORTED_PROBE_VARIANT = [ '4,3', // x: seconds & y : cm '4,7', // x: seconds & y : cm/sec '4,-1', // x: seconds & y : mV (ECG) + '-2,-1', // x: ms & y : mV (ECG) ]; /** @@ -38,6 +39,8 @@ const UNIT_MAPPING = { 0xc: 'degrees', /** Extension for ECG amplitude (not in DICOM table). */ [-1]: 'mV', + /** Extension for ECG time in milliseconds (not in DICOM table). */ + [-2]: 'ms', }; const EPS = 1e-3; @@ -130,10 +133,14 @@ const getCalibratedLengthUnitsAndScale = (image, handles) => { scaleY = 1 / physicalDeltaY; calibrationType = 'ECG Region'; - unit = - UNIT_MAPPING[region.physicalUnitsXDirection] || - UNIT_MAPPING[region.physicalUnitsYDirection] || - 'unknown'; + const isHorizontal = + handles?.length >= 2 + ? Math.abs(handles[1][0] - handles[0][0]) >= + Math.abs(handles[1][1] - handles[0][1]) + : true; + unit = isHorizontal + ? UNIT_MAPPING[region.physicalUnitsXDirection] || 'ms' + : UNIT_MAPPING[region.physicalUnitsYDirection] || 'mV'; areaUnit = (UNIT_MAPPING[region.physicalUnitsYDirection] || 'px') + SQUARE; } diff --git a/utils/demo/helpers/ecgLayouts.ts b/utils/demo/helpers/ecgLayouts.ts new file mode 100644 index 0000000000..e746e94fdf --- /dev/null +++ b/utils/demo/helpers/ecgLayouts.ts @@ -0,0 +1,75 @@ +import { utilities, type TraceRegion } from '@cornerstonejs/core'; + +export type ECGLayoutType = string; + +export interface ECGLayoutOption { + id: string; + name: string; +} + +export const ecgLayouts = new Map([ + ['12x1', { id: '12x1', name: '12x1 (Stacked)' }], + ['6x2', { id: '6x2', name: '6x2' }], + ['3x4', { id: '3x4', name: '3x4' }], + ['3x4+1', { id: '3x4+1', name: '3x4 + 1 Rhythm' }], +]); + +const { STANDARD_12_LEADS } = utilities.ECGUtilities; + +/** + * Generic layout preset generator for AABB2 percentage traceRegions. + * Dynamically parses any `Rows x Cols` layout (e.g. '12x1', '6x2', '3x4', '5x3', '15x1') + * and optional `+1` rhythm strip (e.g. '3x4+1', '6x2+1') into normalized 0.0 to 1.0 AABB2 boxes. + * + * @param layout - The layout specification string (e.g. '12x1', '6x2', '3x4', '3x4+1') + * @param totalChannels - Total number of waveform channels (defaults to 12) + * @returns Array of TraceRegion bounding boxes with lead indices + */ +export function createLayoutRegions( + layout: ECGLayoutType, + totalChannels = 12 +): TraceRegion[] { + const regions: TraceRegion[] = []; + const hasRhythmStrip = layout.includes('+1'); + const baseLayout = layout.replace('+1', ''); + const [rowsStr, colsStr] = baseLayout.split('x'); + + const rows = parseInt(rowsStr, 10) || totalChannels; + const cols = parseInt(colsStr, 10) || 1; + + const topHeight = hasRhythmStrip ? 0.75 : 1.0; + const rowHeight = topHeight / rows; + const colWidth = 1.0 / cols; + + let channelIdx = 0; + for (let c = 0; c < cols; c++) { + for (let r = 0; r < rows; r++) { + if (channelIdx < totalChannels) { + regions.push({ + id: STANDARD_12_LEADS[channelIdx] || `${channelIdx + 1}`, + bounds: { + minX: c * colWidth, + maxX: (c + 1) * colWidth, + minY: r * rowHeight, + maxY: (r + 1) * rowHeight, + }, + leadIndices: [channelIdx], + }); + channelIdx++; + } + } + } + + // If +1 rhythm strip requested, add continuous lead across bottom 25% + if (hasRhythmStrip) { + regions.push({ + id: 'II (Rhythm)', + bounds: { minX: 0, maxX: 1, minY: topHeight, maxY: 1.0 }, + leadIndices: [1], // Standard Lead II rhythm strip + }); + } + + return regions; +} + +export default createLayoutRegions; diff --git a/utils/demo/helpers/index.js b/utils/demo/helpers/index.js index a1b4478365..963baed2a4 100644 --- a/utils/demo/helpers/index.js +++ b/utils/demo/helpers/index.js @@ -52,6 +52,8 @@ import { createAndCacheGeometriesFromContours } from './createAndCacheGeometries export * from './constants'; export * from './addUploadToToolbar'; +import { createLayoutRegions, ecgLayouts } from './ecgLayouts'; + export { addBrushSizeSlider, addButtonToToolbar, @@ -73,6 +75,8 @@ export { createElement, createImageIdsAndCacheMetaData, createInfoSection, + createLayoutRegions, + ecgLayouts, ctVoiRange, downloadSurfacesData, getLocalUrl,