` and publishes a
+single event whenever viewed state changes.
+
+Typical usage is to:
+
+- mark a data item as viewed when users land on a slice
+- query whether a data item has been viewed to seed UI state
+- subscribe to viewed changes for incremental updates
+- clear viewed state when a context reset is needed
+
+## Events
+
+The following events are published by `ViewedDataService`.
+
+| Event | Description |
+| --- | --- |
+| `VIEWED_DATA_CHANGED` | Fired when one data item is newly marked viewed, or when all viewed data is cleared. |
+
+### Event payload
+
+```ts
+type ViewedDataPayload = {
+ viewedDataId?: string;
+ viewedDataCleared?: boolean;
+};
+```
+
+- When a single data item is marked viewed: `{ viewedDataId: string }`
+- When all viewed data is cleared: `{ viewedDataCleared: true }`
+
+## API
+
+- `markDataViewed(dataId: string): void`
+
+ Marks one data item as viewed and emits `VIEWED_DATA_CHANGED` only if:
+
+ - `dataId` is truthy, and
+ - it was not already marked viewed.
+
+- `isDataViewed(dataId: string): boolean`
+
+
+ Returns `true` if `dataId` is currently in the viewed set.
+
+- `clearViewedData(): void`
+
+ Clears all viewed dataIds and emits `VIEWED_DATA_CHANGED` with
+`{ viewedDataCleared: true }`.
+
+- `subscribeViewedDataChanges(listener): Subscription`
+
+ Subscribes to `VIEWED_DATA_CHANGED` payloads.
+
+ ```ts
+ const subscription = viewedDataService.subscribeViewedDataChanges(payload => {
+ if (payload.viewedDataCleared) {
+ // reset local viewed state
+ return;
+ }
+
+ if (payload.viewedDataId) {
+ // mark one data item as viewed locally
+ }
+ });
+
+ // later
+ subscription.unsubscribe();
+ ```
+
+## Notes
+
+- Service registration name: `viewedDataService`
+- Alternate registration name: `ViewedDataService`
+- The service is session-scoped in-memory state; it is not persisted by default.
diff --git a/platform/docs/docs/platform/services/data/index.md b/platform/docs/docs/platform/services/data/index.md
index 6a30a6fbc..6c2c2e03a 100644
--- a/platform/docs/docs/platform/services/data/index.md
+++ b/platform/docs/docs/platform/services/data/index.md
@@ -23,6 +23,7 @@ We maintain the following non-ui Services:
- [Measurement Service](../data/MeasurementService.md)
- [Customization Service](./../customization-service/customizationService.md)
- [Panel Service](../data/PanelService.md)
+- [Viewed Data Service](../data/ViewedDataService.md)
## Service Architecture
diff --git a/platform/docs/docs/platform/services/index.md b/platform/docs/docs/platform/services/index.md
index 814bd446b..5acb0ee96 100644
--- a/platform/docs/docs/platform/services/index.md
+++ b/platform/docs/docs/platform/services/index.md
@@ -105,6 +105,17 @@ The following services is available in the `OHIF-v3`.
ToolBarService
+
+
+
+ ViewedDataService
+
+
+ Data Service
+
+ viewedDataService
+
+
diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx
index 1ace166a4..a7181d31a 100644
--- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx
+++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx
@@ -9,8 +9,8 @@ import React, {
Children,
isValidElement,
} from 'react';
-import { getIndicatorLayout } from './utils';
import { SmartScrollbarIndicator } from './SmartScrollbarIndicator';
+import { DEFAULT_INDICATOR_CONFIG } from './defaultSmartScrollbarIndicatorConfig';
// ── Child validation ────────────────────────────────────────────
function validateChildren(children: React.ReactNode): void {
@@ -33,19 +33,56 @@ function validateChildren(children: React.ReactNode): void {
const TRACK_WIDTH = 8;
const RESTING_WIDTH = 4;
const FILL_PADDING = 3;
-const INDICATOR_SIZE = 8;
-const INDICATOR_BORDER_WIDTH = 1;
const SETTLE_DELAY = 600;
+/** Package-internal type; not re-exported from `@ohif/ui-next` barrels. */
+export interface SmartScrollbarIndicatorConfig {
+ totalWidth?: number;
+ totalHeight?: number;
+ renderIndicator?: (react: typeof React) => React.ReactNode;
+}
+
+/**
+ * Maps a raw record payload into partial `SmartScrollbarIndicatorConfig`.
+ *
+ * Supported keys on `raw`:
+ * - `totalWidth`, `totalHeight` — positive numbers
+ * - `renderIndicator` — `(React) => ReactNode`
+ */
+function normalizeIndicatorRecord(
+ raw: Record | null | undefined
+): SmartScrollbarIndicatorConfig {
+ if (raw == null) {
+ return {};
+ }
+ const config: SmartScrollbarIndicatorConfig = {};
+ if (typeof raw.totalWidth === 'number' && raw.totalWidth > 0) {
+ config.totalWidth = raw.totalWidth;
+ }
+ if (typeof raw.totalHeight === 'number' && raw.totalHeight > 0) {
+ config.totalHeight = raw.totalHeight;
+ }
+
+ if (typeof raw.renderIndicator === 'function') {
+ config.renderIndicator = raw.renderIndicator as (react: typeof React) => React.ReactNode;
+ }
+
+ return config;
+}
+
// ── Contexts ───────────────────────────────────────────────────
export interface SmartScrollbarLayoutContextValue {
total: number;
trackHeight: number;
isLoading: boolean;
+ isDragging: boolean;
effectiveWidth: number;
trackWidth: number;
fillPadding: number;
stableLayerEl: HTMLDivElement | null;
+ indicatorTotalWidth: number;
+ indicatorTotalHeight: number;
+ renderIndicator: (react: typeof React) => React.ReactNode;
}
const SmartScrollbarLayoutContext = createContext(null);
@@ -74,6 +111,10 @@ interface SmartScrollbarProps {
enableKeyboardNavigation?: boolean;
'aria-label'?: string;
className?: string;
+ /**
+ * Indicator payload parsed inside the component.
+ */
+ indicator?: Record;
children: React.ReactNode;
}
@@ -86,10 +127,24 @@ export function SmartScrollbar({
enableKeyboardNavigation = false,
'aria-label': ariaLabel = 'Scroll position',
className,
+ indicator,
children,
}: SmartScrollbarProps) {
validateChildren(children);
+ const resolvedIndicator = useMemo(() => {
+ const defaultIndicatorConfig = DEFAULT_INDICATOR_CONFIG;
+ const parsed = normalizeIndicatorRecord(indicator);
+ if (parsed.totalWidth && parsed.totalHeight && parsed.renderIndicator) {
+ return {
+ totalWidth: parsed.totalWidth,
+ totalHeight: parsed.totalHeight,
+ renderIndicator: parsed.renderIndicator,
+ };
+ }
+ return defaultIndicatorConfig;
+ }, [indicator]);
+
// ── ResizeObserver for trackHeight ───────────────────────────
const containerRef = useRef(null);
const [trackHeight, setTrackHeight] = useState(0);
@@ -97,6 +152,12 @@ export function SmartScrollbar({
useEffect(() => {
const el = containerRef.current;
if (!el) return;
+ const syncTrackHeight = () => {
+ const measuredHeight = el.getBoundingClientRect().height;
+ setTrackHeight(prev => (prev === measuredHeight ? prev : measuredHeight));
+ };
+ // Capture an immediate measurement so we don't rely solely on async ResizeObserver delivery.
+ syncTrackHeight();
const ro = new ResizeObserver(([entry]) => {
setTrackHeight(entry.contentRect.height);
});
@@ -127,9 +188,8 @@ export function SmartScrollbar({
const isExpanded = !hasSettled || isHovered || isDragging;
const effectiveWidth = isExpanded ? TRACK_WIDTH : RESTING_WIDTH;
- // ── Hit zone extension ───────────────────────────────────────
- const { leftPos } = getIndicatorLayout(TRACK_WIDTH, INDICATOR_SIZE, INDICATOR_BORDER_WIDTH);
- const hitZoneLeftExtension = Math.max(0, -leftPos);
+ // ── Hit zone: extend past `TRACK_WIDTH` on both sides when the pill is wider than the track ──
+ const hitZoneSideExtension = Math.max(0, resolvedIndicator.totalWidth / 2 - TRACK_WIDTH / 2);
// ── Stable layer (for elements that shouldn't move during contraction) ──
// Uses useState + callback ref so React triggers a re-render when the
@@ -137,14 +197,21 @@ export function SmartScrollbar({
const [stableLayerEl, setStableLayerEl] = useState(null);
// ── Pointer helpers ──────────────────────────────────────────
- const clamp = useCallback(
- (val: number) => Math.max(0, Math.min(total - 1, val)),
- [total]
- );
+ const clamp = useCallback((val: number) => Math.max(0, Math.min(total - 1, val)), [total]);
const indexFromPointerY = useCallback(
(clientY: number) => {
- const ratio = Math.max(0, Math.min(1, (clientY - trackTopRef.current) / trackHeight));
+ if (trackHeight <= 0) {
+ return 0;
+ }
+
+ // Map pointer Y within the same padded fill strip used by fill/indicator.
+ const fillTop = trackTopRef.current + FILL_PADDING;
+ const fillHeight = trackHeight - FILL_PADDING * 2;
+ if (fillHeight <= 0) {
+ return 0;
+ }
+ const ratio = Math.max(0, Math.min(1, (clientY - fillTop) / fillHeight));
return Math.round(ratio * (total - 1));
},
[trackHeight, total]
@@ -153,6 +220,9 @@ export function SmartScrollbar({
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
trackTopRef.current = e.currentTarget.getBoundingClientRect().top;
+ if (trackHeight <= 0) {
+ return;
+ }
isDraggingRef.current = true;
setIsDragging(true);
@@ -160,7 +230,7 @@ export function SmartScrollbar({
onValueChange(clamp(indexFromPointerY(e.clientY)));
},
- [clamp, indexFromPointerY, onValueChange]
+ [clamp, indexFromPointerY, onValueChange, trackHeight]
);
const handlePointerMove = useCallback(
@@ -216,15 +286,32 @@ export function SmartScrollbar({
);
// ── Context values ───────────────────────────────────────────
- const layoutCtx = useMemo(() => ({
- total,
- trackHeight,
- isLoading,
- effectiveWidth,
- trackWidth: TRACK_WIDTH,
- fillPadding: FILL_PADDING,
- stableLayerEl,
- }), [total, trackHeight, isLoading, effectiveWidth, stableLayerEl]);
+ const layoutCtx = useMemo(
+ () => ({
+ total,
+ trackHeight,
+ isLoading,
+ isDragging,
+ effectiveWidth,
+ trackWidth: TRACK_WIDTH,
+ fillPadding: FILL_PADDING,
+ stableLayerEl,
+ indicatorTotalWidth: resolvedIndicator.totalWidth,
+ indicatorTotalHeight: resolvedIndicator.totalHeight,
+ renderIndicator: resolvedIndicator.renderIndicator,
+ }),
+ [
+ total,
+ trackHeight,
+ isLoading,
+ isDragging,
+ effectiveWidth,
+ stableLayerEl,
+ resolvedIndicator.totalWidth,
+ resolvedIndicator.totalHeight,
+ resolvedIndicator.renderIndicator,
+ ]
+ );
return (
@@ -239,10 +326,10 @@ export function SmartScrollbar({
tabIndex={0}
className={className}
style={{
- width: TRACK_WIDTH + hitZoneLeftExtension,
+ width: TRACK_WIDTH + hitZoneSideExtension * 2,
height: '100%',
position: 'relative',
- marginLeft: -hitZoneLeftExtension,
+ marginLeft: -hitZoneSideExtension,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
}}
@@ -258,7 +345,7 @@ export function SmartScrollbar({
-
- {/* Border rect */}
-
- {/* Fill rect */}
-
-
+ {renderIndicator(React)}
);
}
diff --git a/platform/ui-next/src/components/SmartScrollbar/defaultSmartScrollbarIndicatorConfig.tsx b/platform/ui-next/src/components/SmartScrollbar/defaultSmartScrollbarIndicatorConfig.tsx
new file mode 100644
index 000000000..6a1652bf2
--- /dev/null
+++ b/platform/ui-next/src/components/SmartScrollbar/defaultSmartScrollbarIndicatorConfig.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { type SmartScrollbarIndicatorConfig } from './SmartScrollbar';
+
+/** Ring inset for the default pill SVG (outer size is indicator total width × height). */
+const BORDER_WIDTH = 1;
+
+const INDICATOR_COLOR = 'hsl(var(--foreground) / 0.9)';
+const BORDER_COLOR = 'hsl(var(--neutral) / 0.9)';
+const TOTAL_WIDTH = 12;
+const TOTAL_HEIGHT = 7;
+
+function DefaultIndicator() {
+ const borderWidth = BORDER_WIDTH;
+ const fillWidth = Math.max(0, TOTAL_WIDTH - borderWidth * 2);
+ const fillHeight = Math.max(0, TOTAL_HEIGHT - borderWidth * 2);
+ return (
+
+
+
+
+ );
+}
+
+export const DEFAULT_INDICATOR_CONFIG: SmartScrollbarIndicatorConfig = {
+ totalWidth: TOTAL_WIDTH,
+ totalHeight: TOTAL_HEIGHT,
+ renderIndicator: (_react: typeof React) => ,
+};
diff --git a/platform/ui-next/src/components/SmartScrollbar/index.ts b/platform/ui-next/src/components/SmartScrollbar/index.ts
index 1137a80c5..68c7a9716 100644
--- a/platform/ui-next/src/components/SmartScrollbar/index.ts
+++ b/platform/ui-next/src/components/SmartScrollbar/index.ts
@@ -1,5 +1,8 @@
-export { SmartScrollbar, useSmartScrollbarLayoutContext, useSmartScrollbarScrollContext } from './SmartScrollbar';
-export type { SmartScrollbarLayoutContextValue } from './SmartScrollbar';
+export {
+ SmartScrollbar,
+ useSmartScrollbarLayoutContext,
+ useSmartScrollbarScrollContext,
+} from './SmartScrollbar';
export { SmartScrollbarTrack } from './SmartScrollbarTrack';
export { SmartScrollbarFill } from './SmartScrollbarFill';
export { SmartScrollbarIndicator } from './SmartScrollbarIndicator';
diff --git a/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts b/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts
index 84024fce0..fe9319784 100644
--- a/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts
+++ b/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts
@@ -1,5 +1,4 @@
-import debounce from 'lodash.debounce';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
export interface ByteArrayHandle {
bytes: Uint8Array;
@@ -16,45 +15,68 @@ export interface ByteArrayHandle {
* detection via an incrementing version counter.
*
* @param size - Number of positions (e.g. total slices in a viewport).
- * @param debounceMs - When > 0, version bumps are debounced by this many
- * milliseconds. Byte writes are always immediate. Use for
- * high-frequency sources (cache prefetch) to batch renders.
- * Omit or pass 0 for immediate re-renders (e.g. viewed tracking).
+ * @param batchIntervalMs - When > 0, writes are coalesced into a scheduled
+ * flush: the first write starts a timer, the next
+ * flush bumps `version`, and the timer stops. New
+ * writes start a new interval window. Omit or pass 0
+ * for immediate re-renders on every write.
*/
-export function useByteArray(size: number, debounceMs = 0): ByteArrayHandle {
+export function useByteArray(size: number, batchIntervalMs = 0): ByteArrayHandle {
const bytesRef = useRef(new Uint8Array(size));
const countRef = useRef(0);
const [version, setVersion] = useState(0);
+ const timeoutIdRef = useRef(null);
- // Debounced bump — recreated when debounceMs changes; cancelled on unmount
- // or when debounceMs changes, following the lodash.debounce pattern used
- // throughout ui-next (InputFilter, CinePlayer).
- const debouncedBump = useMemo(
- () => (debounceMs > 0 ? debounce(() => setVersion(v => v + 1), debounceMs) : null),
- [debounceMs]
- );
+ const clearScheduledFlush = useCallback(() => {
+ if (timeoutIdRef.current !== null) {
+ window.clearTimeout(timeoutIdRef.current);
+ timeoutIdRef.current = null;
+ }
+ }, []);
- useEffect(() => {
- return () => debouncedBump?.cancel();
- }, [debouncedBump]);
+ const flushScheduledVersion = useCallback(() => {
+ // End this timeout window after the scheduled flush.
+ clearScheduledFlush();
+ setVersion(v => v + 1);
+ }, [clearScheduledFlush]);
// Reset array only when size actually changes — skip on initial mount since
// bytesRef is already initialised to the correct size via useRef.
useEffect(() => {
if (bytesRef.current.length === size) return;
- debouncedBump?.cancel();
+ // Drop any in-flight timeout window when resetting the underlying array.
+ clearScheduledFlush();
bytesRef.current = new Uint8Array(size);
countRef.current = 0;
setVersion(v => v + 1);
- }, [size, debouncedBump]);
+ }, [size, clearScheduledFlush]);
+
+ useEffect(() => {
+ // If timing changes mid-window, restart that timeout using the new timing.
+ const pendingTimeoutId = timeoutIdRef.current;
+ clearScheduledFlush();
+ if (batchIntervalMs <= 0) {
+ if (pendingTimeoutId !== null) {
+ setVersion(v => v + 1);
+ }
+ return;
+ }
+ if (pendingTimeoutId !== null) {
+ timeoutIdRef.current = window.setTimeout(flushScheduledVersion, batchIntervalMs);
+ }
+ return () => clearScheduledFlush();
+ }, [batchIntervalMs, clearScheduledFlush, flushScheduledVersion]);
const bump = useCallback(() => {
- if (debouncedBump) {
- debouncedBump();
- } else {
+ if (batchIntervalMs <= 0) {
setVersion(v => v + 1);
+ return;
}
- }, [debouncedBump]);
+
+ if (timeoutIdRef.current === null) {
+ timeoutIdRef.current = window.setTimeout(flushScheduledVersion, batchIntervalMs);
+ }
+ }, [batchIntervalMs, flushScheduledVersion]);
const setByte = useCallback(
(index: number) => {
diff --git a/platform/ui-next/src/components/SmartScrollbar/utils.test.ts b/platform/ui-next/src/components/SmartScrollbar/utils.test.ts
index c03434c5a..7ecfac598 100644
--- a/platform/ui-next/src/components/SmartScrollbar/utils.test.ts
+++ b/platform/ui-next/src/components/SmartScrollbar/utils.test.ts
@@ -1,4 +1,8 @@
-import { computeContiguousRuns, computePixelFilledFromMarked } from './utils';
+import {
+ computeContiguousRuns,
+ computeIndicatorTopOffsetInFill,
+ computePixelFilledFromMarked,
+} from './utils';
function u8(values: number[]): Uint8Array {
return new Uint8Array(values);
@@ -57,6 +61,43 @@ describe('computePixelFilledFromMarked', () => {
});
});
+describe('computeIndicatorTopOffsetInFill', () => {
+ it('top-aligns first bucket, centers middle, bottom-aligns last (pixelCount > total)', () => {
+ const total = 3;
+ const pixelCount = 9;
+ const indicatorHeight = 2;
+ // Buckets: [0,3), [3,6), [6,9)
+ expect(computeIndicatorTopOffsetInFill(0, total, pixelCount, indicatorHeight)).toBe(0);
+ expect(computeIndicatorTopOffsetInFill(1, total, pixelCount, indicatorHeight)).toBe(3);
+ expect(computeIndicatorTopOffsetInFill(2, total, pixelCount, indicatorHeight)).toBe(7);
+ });
+
+ it('top-aligns to fill pixel row when total >= pixelCount (slices > pixels)', () => {
+ const total = 10;
+ const pixelCount = 3;
+ const indicatorHeight = 1;
+ // Same buckets as computePixelFilledFromMarked: row0 [0,3), row1 [3,6), row2 [6,10)
+ expect(computeIndicatorTopOffsetInFill(0, total, pixelCount, indicatorHeight)).toBe(0);
+ expect(computeIndicatorTopOffsetInFill(2, total, pixelCount, indicatorHeight)).toBe(0);
+ expect(computeIndicatorTopOffsetInFill(3, total, pixelCount, indicatorHeight)).toBe(1);
+ expect(computeIndicatorTopOffsetInFill(5, total, pixelCount, indicatorHeight)).toBe(1);
+ expect(computeIndicatorTopOffsetInFill(6, total, pixelCount, indicatorHeight)).toBe(2);
+ expect(computeIndicatorTopOffsetInFill(9, total, pixelCount, indicatorHeight)).toBe(2);
+ });
+
+ it('clamps when indicator is taller than remaining fill space', () => {
+ const total = 2;
+ const pixelCount = 5;
+ const indicatorHeight = 10;
+ expect(computeIndicatorTopOffsetInFill(0, total, pixelCount, indicatorHeight)).toBe(0);
+ expect(computeIndicatorTopOffsetInFill(1, total, pixelCount, indicatorHeight)).toBe(0);
+ });
+
+ it('returns 0 when pixelCount is 0', () => {
+ expect(computeIndicatorTopOffsetInFill(0, 5, 0, 4)).toBe(0);
+ });
+});
+
describe('computeContiguousRuns', () => {
it('returns [] for empty input', () => {
expect(computeContiguousRuns(new Uint8Array(0))).toEqual([]);
diff --git a/platform/ui-next/src/components/SmartScrollbar/utils.ts b/platform/ui-next/src/components/SmartScrollbar/utils.ts
index e6467094f..c61b7d97d 100644
--- a/platform/ui-next/src/components/SmartScrollbar/utils.ts
+++ b/platform/ui-next/src/components/SmartScrollbar/utils.ts
@@ -26,6 +26,52 @@ export function computeContiguousRuns(bytes: Uint8Array): ContiguousRun[] {
return runs;
}
+/**
+ * When `total >= pixelRowCount`: half-open item index range `[itemStart, itemEnd)`
+ * covered by fill pixel row `pixelIndex`. `pixelRowCount` is `floor(pixelCount)` from callers.
+ * Shared by `computePixelFilledFromMarked` and `computeIndicatorTopOffsetInFill` — keep in sync.
+ */
+function itemRangeForPixelRow(
+ pixelIndex: number,
+ total: number,
+ pixelRowCount: number
+): { itemStart: number; itemEnd: number } {
+ const itemStart = Math.floor((pixelIndex * total) / pixelRowCount);
+ const itemEnd = Math.floor(((pixelIndex + 1) * total) / pixelRowCount);
+ return { itemStart, itemEnd };
+}
+
+/**
+ * When `total >= pixelRowCount`: which fill pixel row owns `itemIndex` for the same
+ * partition as `itemRangeForPixelRow` (inverse of scanning rows until
+ * `itemIndex ∈ [itemStart, itemEnd)`).
+ *
+ * Readable form: `ceil((itemIndex + 1) * pixelRowCount / total) - 1`, clamped to `[0, pixelRowCount - 1]`.
+ */
+function pixelRowIndexForItemDownsample(
+ itemIndex: number,
+ total: number,
+ pixelRowCount: number
+): number {
+ const rowIndex = Math.ceil(((itemIndex + 1) * pixelRowCount) / total) - 1;
+ return Math.max(0, Math.min(pixelRowCount - 1, rowIndex));
+}
+
+/**
+ * When `pixelRowCount > total`: half-open pixel row range `[pixelStart, pixelEnd)`
+ * covered by `itemIndex`. `pixelRowCount` is `floor(pixelCount)` from callers.
+ * Shared by `computePixelFilledFromMarked` and `computeIndicatorTopOffsetInFill` — keep in sync.
+ */
+function pixelSpanForItem(
+ itemIndex: number,
+ total: number,
+ pixelRowCount: number
+): { pixelStart: number; pixelEnd: number } {
+ const pixelStart = Math.floor((itemIndex * pixelRowCount) / total);
+ const pixelEnd = Math.floor(((itemIndex + 1) * pixelRowCount) / total);
+ return { pixelStart, pixelEnd };
+}
+
/**
* Convert marked items (0/1 bytes) into a per-pixel fill mask (0/1 bytes).
* The result is conservative in the sense that a pixel row is filled only when
@@ -41,20 +87,19 @@ export function computePixelFilledFromMarked(
pixelCount: number
): Uint8Array {
const total = marked.length;
- const count = Math.max(0, Math.floor(pixelCount));
- if (count === 0 || total <= 0) return new Uint8Array(0);
+ const pixelRowCount = Math.max(0, Math.floor(pixelCount));
+ if (pixelRowCount === 0 || total <= 0) return new Uint8Array(0);
- const pixelFilled = new Uint8Array(count);
+ const pixelFilled = new Uint8Array(pixelRowCount);
- if (total >= count) {
- for (let pixelIndex = 0; pixelIndex < count; pixelIndex++) {
- const start = Math.floor((pixelIndex * total) / count);
- const end = Math.floor(((pixelIndex + 1) * total) / count);
- if (end <= start) continue;
+ if (total >= pixelRowCount) {
+ for (let pixelIndex = 0; pixelIndex < pixelRowCount; pixelIndex++) {
+ const { itemStart, itemEnd } = itemRangeForPixelRow(pixelIndex, total, pixelRowCount);
+ if (itemEnd <= itemStart) continue;
let filled = 1;
- for (let itemIndex = start; itemIndex < end; itemIndex++) {
- if (marked[itemIndex] === 0) {
+ for (let i = itemStart; i < itemEnd; i++) {
+ if (marked[i] === 0) {
filled = 0;
break;
}
@@ -64,9 +109,8 @@ export function computePixelFilledFromMarked(
} else {
for (let itemIndex = 0; itemIndex < total; itemIndex++) {
if (marked[itemIndex] === 0) continue;
- const topPx = Math.floor((itemIndex * count) / total);
- const bottomPx = Math.floor(((itemIndex + 1) * count) / total);
- for (let pixel = topPx; pixel < bottomPx; pixel++) {
+ const { pixelStart, pixelEnd } = pixelSpanForItem(itemIndex, total, pixelRowCount);
+ for (let pixel = pixelStart; pixel < pixelEnd; pixel++) {
pixelFilled[pixel] = 1;
}
}
@@ -76,22 +120,49 @@ export function computePixelFilledFromMarked(
}
/**
- * Compute the indicator's total visual dimensions and horizontal position.
- * Design 27: pill shape, center position, 1px border.
+ * Vertical offset (px) of the indicator within the fill strip `[0, pixelCount)`,
+ * clamped so the indicator stays inside the track.
+ *
+ * - When `total >= pixelCount` (many slices, few pixel rows): same partition as
+ * `computePixelFilledFromMarked` (per-pixel item buckets); **top-align** the
+ * indicator to that row (then clamp so the thumb does not overflow the strip).
+ * - When `pixelCount > total`: same per-item pixel spans as the fill’s upsample
+ * branch; first item top-aligned, last bottom-aligned, middle centered in bucket.
+ * - Zero-height bucket (upsample only): top-aligned `pixelStart` from `pixelSpanForItem`.
*/
-export function getIndicatorLayout(
- trackWidth: number,
- indicatorSize: number,
- borderWidth: number,
-): { totalWidth: number; totalHeight: number; fillWidth: number; fillHeight: number; leftPos: number } {
- const visualSize = indicatorSize * 1.25;
- const fillWidth = visualSize;
- const fillHeight = Math.round(visualSize / 2); // pill = half height
- const totalWidth = fillWidth + borderWidth * 2;
- const totalHeight = fillHeight + borderWidth * 2;
+export function computeIndicatorTopOffsetInFill(
+ itemIndex: number,
+ total: number,
+ pixelCount: number,
+ indicatorHeight: number
+): number {
+ const pixelRowCount = Math.max(0, Math.floor(pixelCount));
+ const indicatorHeightPx = Math.max(0, Math.floor(indicatorHeight));
+ const maxTopInFill = Math.max(0, pixelRowCount - indicatorHeightPx);
+ if (pixelRowCount === 0 || total <= 0) {
+ return 0;
+ }
- const centerX = trackWidth / 2;
- const leftPos = centerX - totalWidth / 2;
+ const clamp = (x: number) => Math.max(0, Math.min(maxTopInFill, x));
- return { totalWidth, totalHeight, fillWidth, fillHeight, leftPos };
+ if (total >= pixelRowCount) {
+ const pixelRowIndex = pixelRowIndexForItemDownsample(itemIndex, total, pixelRowCount);
+ return clamp(pixelRowIndex);
+ } else {
+ const { pixelStart, pixelEnd } = pixelSpanForItem(itemIndex, total, pixelRowCount);
+ const bucketHeight = pixelEnd - pixelStart;
+
+ let topOffset: number;
+ if (bucketHeight <= 0) {
+ topOffset = pixelStart;
+ } else if (itemIndex === 0) {
+ topOffset = pixelStart;
+ } else if (itemIndex === total - 1) {
+ topOffset = pixelEnd - indicatorHeightPx;
+ } else {
+ topOffset = pixelStart + Math.floor((bucketHeight - indicatorHeightPx) / 2);
+ }
+
+ return clamp(topOffset);
+ }
}
diff --git a/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx b/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx
index 0758a2f84..ce8c34607 100644
--- a/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx
+++ b/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx
@@ -27,7 +27,7 @@ const locationClasses = {
),
[ViewportActionCornersLocations.topRight]: classNames(
commonClasses,
- 'absolute top-[4px] right-[16px] right-viewport-scrollbar'
+ 'absolute top-[4px] right-viewport-scrollbar'
),
[ViewportActionCornersLocations.bottomLeft]: classNames(
commonClasses,
@@ -35,7 +35,7 @@ const locationClasses = {
),
[ViewportActionCornersLocations.bottomRight]: classNames(
commonClasses,
- 'absolute bottom-[3px] right-[16px] right-viewport-scrollbar'
+ 'absolute bottom-[3px] right-viewport-scrollbar'
),
[ViewportActionCornersLocations.topMiddle]: classNames(
commonClasses,
@@ -52,7 +52,7 @@ const locationClasses = {
),
[ViewportActionCornersLocations.rightMiddle]: classNames(
commonClasses,
- 'absolute right-[16px] top-1/2 -translate-y-1/2 right-viewport-scrollbar'
+ 'absolute top-1/2 -translate-y-1/2 right-viewport-scrollbar'
),
};
diff --git a/platform/ui-next/src/components/Viewport/ViewportOverlay.tsx b/platform/ui-next/src/components/Viewport/ViewportOverlay.tsx
index 799d1ebad..7a92a9906 100644
--- a/platform/ui-next/src/components/Viewport/ViewportOverlay.tsx
+++ b/platform/ui-next/src/components/Viewport/ViewportOverlay.tsx
@@ -31,14 +31,12 @@ function ViewportOverlay({ topLeft, topRight, bottomRight, bottomLeft, color = '
{topRight}
{bottomRight}
diff --git a/platform/ui/tailwind.config.js b/platform/ui/tailwind.config.js
index 585983bf1..1a9f7820e 100644
--- a/platform/ui/tailwind.config.js
+++ b/platform/ui/tailwind.config.js
@@ -306,7 +306,7 @@ module.exports = {
full: '100%',
viewport: '0.5rem',
'1/2': '50%',
- 'viewport-scrollbar': '1.3rem',
+ 'viewport-scrollbar': '1.5rem',
}),
letterSpacing: {
tighter: '-0.05em',
diff --git a/tests/ContourSegLocking.spec.ts b/tests/ContourSegLocking.spec.ts
index 786b54dbb..8c6a85027 100644
--- a/tests/ContourSegLocking.spec.ts
+++ b/tests/ContourSegLocking.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test, visitStudy } from './utils';
+import { addOHIFGlobalCustomizations, expect, test, visitStudy } from './utils';
import { simulateNormalizedDragOnElement } from './utils/simulateDragOnElement';
const studyInstanceUID = '1.2.840.113619.2.290.3.3767434740.226.1600859119.501';
@@ -63,13 +63,8 @@ test('should not allow contours to be edited when panelSegmentation.disableEditi
await page.waitForTimeout(5000);
// disable editing of segmentations via the customization service
- await page.evaluate(() => {
- window.services.customizationService.setGlobalCustomization(
- 'panelSegmentation.disableEditing',
- {
- $set: true,
- }
- );
+ await addOHIFGlobalCustomizations(page, {
+ 'panelSegmentation.disableEditing': true,
});
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
@@ -117,13 +112,8 @@ test('should allow contours to be edited when panelSegmentation.disableEditing i
await page.waitForTimeout(5000);
// disable editing of segmentations via the customization service
- await page.evaluate(() => {
- window.services.customizationService.setGlobalCustomization(
- 'panelSegmentation.disableEditing',
- {
- $set: false,
- }
- );
+ await addOHIFGlobalCustomizations(page, {
+ 'panelSegmentation.disableEditing': false,
});
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
diff --git a/tests/LabelMapSegLocking.spec.ts b/tests/LabelMapSegLocking.spec.ts
index 782ad7bcf..b681f3c60 100644
--- a/tests/LabelMapSegLocking.spec.ts
+++ b/tests/LabelMapSegLocking.spec.ts
@@ -1,4 +1,10 @@
-import { checkForScreenshot, screenShotPaths, test, visitStudy } from './utils';
+import {
+ addOHIFGlobalCustomizations,
+ checkForScreenshot,
+ screenShotPaths,
+ test,
+ visitStudy,
+} from './utils';
import { press } from './utils/keyboardUtils';
test.beforeEach(async ({ page }) => {
@@ -15,13 +21,8 @@ test('should prevent editing of label map segmentations when panelSegmentation.d
viewportPageObject,
}) => {
// disable editing of segmentations via the customization service
- await page.evaluate(() => {
- window.services.customizationService.setGlobalCustomization(
- 'panelSegmentation.disableEditing',
- {
- $set: true,
- }
- );
+ await addOHIFGlobalCustomizations(page, {
+ 'panelSegmentation.disableEditing': true,
});
await rightPanelPageObject.labelMapSegmentationPanel.select();
@@ -71,13 +72,8 @@ test('should allow editing of label map segmentations when panelSegmentation.dis
viewportPageObject,
}) => {
// disable editing of segmentations via the customization service
- await page.evaluate(() => {
- window.services.customizationService.setGlobalCustomization(
- 'panelSegmentation.disableEditing',
- {
- $set: false,
- }
- );
+ await addOHIFGlobalCustomizations(page, {
+ 'panelSegmentation.disableEditing': false,
});
await rightPanelPageObject.labelMapSegmentationPanel.select();
diff --git a/tests/TMTVRendering.spec.ts b/tests/TMTVRendering.spec.ts
index 138e83246..9a79ed27b 100644
--- a/tests/TMTVRendering.spec.ts
+++ b/tests/TMTVRendering.spec.ts
@@ -1,5 +1,4 @@
-import { test } from 'playwright-test-coverage';
-import { visitStudy, checkForScreenshot, screenShotPaths } from './utils/index.js';
+import { test, visitStudy, checkForScreenshot, screenShotPaths } from './utils';
test.skip('should render TMTV correctly.', async ({ page }) => {
const studyInstanceUID = '1.2.840.113619.2.290.3.3767434740.226.1600859119.501';
diff --git a/tests/Worklist.spec.ts b/tests/Worklist.spec.ts
index 8c01e1683..abef2ff6e 100644
--- a/tests/Worklist.spec.ts
+++ b/tests/Worklist.spec.ts
@@ -1,5 +1,4 @@
-import { test } from 'playwright-test-coverage';
-import { checkForScreenshot, screenShotPaths } from './utils';
+import { test, checkForScreenshot, screenShotPaths } from './utils';
test.beforeEach(async ({ page }) => {
await page.goto(`/?datasources=ohif`);
diff --git a/tests/mpr2.spec.ts b/tests/mpr2.spec.ts
index 03d866cfa..6d73c1c8b 100644
--- a/tests/mpr2.spec.ts
+++ b/tests/mpr2.spec.ts
@@ -7,11 +7,19 @@ test.beforeEach(async ({ page }) => {
await visitStudy(page, studyInstanceUID, mode, 10000);
});
-test('should properly display MPR for MR', async ({ page, mainToolbarPageObject }) => {
+test('should properly display MPR for MR', async ({
+ page,
+ mainToolbarPageObject,
+ viewportPageObject,
+}) => {
await mainToolbarPageObject.waitForVolumeLoad();
await page.getByTestId('side-panel-header-right').click();
// await page.getByTestId('study-browser-thumbnail-no-image').dblclick();
- await checkForScreenshot(page, page, screenShotPaths.mpr2.mprDisplayedCorrectly);
+ await checkForScreenshot({
+ page,
+ locator: viewportPageObject.grid,
+ screenshotPath: screenShotPaths.mpr2.mprDisplayedCorrectly,
+ });
await page.evaluate(() => {
// Access cornerstone directly from the window object
@@ -35,5 +43,9 @@ test('should properly display MPR for MR', async ({ page, mainToolbarPageObject
}
});
- await checkForScreenshot(page, page, screenShotPaths.mpr2.mprDisplayedCorrectlyZoomed);
+ await checkForScreenshot({
+ page,
+ locator: viewportPageObject.grid,
+ screenshotPath: screenShotPaths.mpr2.mprDisplayedCorrectlyZoomed,
+ });
});
diff --git a/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectly.png b/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectly.png
index 6faf89d2b..27227adb1 100644
Binary files a/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectly.png and b/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectly.png differ
diff --git a/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectlyZoomed.png b/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectlyZoomed.png
index aa8cb6aac..1fd82a4d3 100644
Binary files a/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectlyZoomed.png and b/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectlyZoomed.png differ
diff --git a/tests/utils/OHIFConfiguration.ts b/tests/utils/OHIFConfiguration.ts
index 429e40f96..0abd70988 100644
--- a/tests/utils/OHIFConfiguration.ts
+++ b/tests/utils/OHIFConfiguration.ts
@@ -1,6 +1,31 @@
-import { Page } from 'playwright-test-coverage';
+import type { Page } from '@playwright/test';
+
+// Global per-test configuration skeleton.
+// Keep this empty by default and add top-level window.config overrides here when needed.
+export const DEFAULT_E2E_OHIF_CONFIGURATION: Record = {};
+
+// Global per-test customization baseline.
+export const DEFAULT_E2E_OHIF_CUSTOMIZATIONS: Record = {
+ 'viewportScrollbar.showViewedFill': false,
+ 'viewportScrollbar.showLoadingPattern': false,
+ 'viewportScrollbar.showLoadedFill': false,
+ 'viewportScrollbar.showLoadedEndpoints': false,
+};
export async function addOHIFConfiguration(page: Page, configToAdd: Record) {
+ const customizationSetters = Object.fromEntries(
+ Object.entries(DEFAULT_E2E_OHIF_CUSTOMIZATIONS || {}).map(([key, value]) => [
+ key,
+ { $set: value },
+ ])
+ );
+
+ const baselinePlusConfigToAdd = {
+ ...DEFAULT_E2E_OHIF_CONFIGURATION,
+ customizationService: [customizationSetters],
+ ...configToAdd,
+ };
+
await page.addInitScript(config => {
let _config;
Object.defineProperty(window, 'config', {
@@ -15,5 +40,21 @@ export async function addOHIFConfiguration(page: Page, configToAdd: Record
+) {
+ await page.evaluate(customizations => {
+ const customizationService = (window as any).services?.customizationService;
+ if (!customizationService?.setGlobalCustomization) {
+ return;
+ }
+
+ Object.entries(customizations || {}).forEach(([key, value]) => {
+ customizationService.setGlobalCustomization(key, value);
+ });
+ }, customizationsToAdd);
}
diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts
index 007768deb..2ab26e463 100644
--- a/tests/utils/fixture.ts
+++ b/tests/utils/fixture.ts
@@ -1,4 +1,5 @@
import { test as base } from 'playwright-test-coverage';
+import { addOHIFConfiguration } from './OHIFConfiguration';
import {
DOMOverlayPageObject,
MainToolbarPageObject,
@@ -17,7 +18,18 @@ type PageObjects = {
notFoundStudyPageObject: NotFoundStudyPageObject;
};
-export const test = base.extend({
+type TestFixtures = PageObjects & {
+ _applyGlobalE2EOHIFBaseline: void;
+};
+
+export const test = base.extend({
+ _applyGlobalE2EOHIFBaseline: [
+ async ({ page }, use) => {
+ await addOHIFConfiguration(page, {});
+ await use();
+ },
+ { auto: true },
+ ],
DOMOverlayPageObject: async ({ page }, use) => {
await use(new DOMOverlayPageObject(page));
},
diff --git a/tests/utils/index.ts b/tests/utils/index.ts
index 7ed55bade..c15967fa8 100644
--- a/tests/utils/index.ts
+++ b/tests/utils/index.ts
@@ -1,5 +1,8 @@
import { visitStudy } from './visitStudy';
-import { addOHIFConfiguration } from './OHIFConfiguration';
+import {
+ addOHIFConfiguration,
+ addOHIFGlobalCustomizations,
+} from './OHIFConfiguration';
import { checkForScreenshot } from './checkForScreenshot';
import { screenShotPaths } from './screenShotPaths';
import {
@@ -25,6 +28,7 @@ import { subscribeToMeasurementAdded } from './subscribeToMeasurement';
export {
visitStudy,
addOHIFConfiguration,
+ addOHIFGlobalCustomizations,
checkForScreenshot,
screenShotPaths,
simulateClicksOnElement,