@itk-viewer/element
Version:
Web Component for multi-dimensional viewer
299 lines (256 loc) • 8.09 kB
text/typescript
/// <reference types="vite/client" />
import { PropertyValues, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { SelectorController } from 'xstate-lit';
import { ActorRefFrom } from 'xstate';
import { Bounds } from '@itk-viewer/utils/bounding-box.js';
import { chunk } from '@itk-viewer/io/dimensionUtils.js';
import { viewportMachine } from '@itk-viewer/viewer/viewport.js';
import { Camera } from '@itk-viewer/viewer/camera.js';
import {
RemoteActor,
createHyphaMachineConfig,
createRemoteViewport,
Image,
} from '@itk-viewer/remote-viewport/remote-viewport.js';
import { ItkViewport } from './itk-viewport.js';
import './itk-camera.js';
type ViewportActor = ActorRefFrom<typeof viewportMachine>;
export class ItkRemoteViewport extends ItkViewport {
serverConfig: unknown | undefined;
density = 30;
canvas: Ref<HTMLCanvasElement> = createRef();
canvasCtx: CanvasRenderingContext2D | null = null;
viewport: ViewportActor;
remote: RemoteActor;
cameraActor: SelectorController<ViewportActor, Camera>;
remoteOnline: SelectorController<RemoteActor, boolean>;
lastRemoteOnlineValue = false;
renderLoopRunning = false;
frame: SelectorController<RemoteActor, Image | undefined>;
lastFrameValue: Image | undefined = undefined;
frameData: ImageData | undefined = undefined;
bounds: SelectorController<
RemoteActor,
{
imageWorldBounds: Bounds;
clipBounds: Bounds;
clipBoundsWithNormalized: Array<readonly [number, number]>;
}
>;
cleanDimension = (v: number) => Math.max(1, Math.floor(v));
private resizer = new ResizeObserver(
(entries: Array<ResizeObserverEntry>) => {
if (!entries.length) return;
const { width, height } = entries[0].contentRect;
const resolution = [width, height].map(this.cleanDimension) as [
number,
number,
];
this.viewport.send({
type: 'setResolution',
resolution,
});
},
);
constructor() {
super();
const { remote, viewport } = createRemoteViewport(
createHyphaMachineConfig(),
);
this.viewport = viewport;
this.remote = remote;
this.cameraActor = new SelectorController(
this,
this.viewport,
(state) => state.context.camera,
);
this.remoteOnline = new SelectorController(this, this.remote, (state) =>
state.matches('root.online'),
);
this.frame = new SelectorController(
this,
this.remote,
(state) => state.context.frame,
);
this.bounds = new SelectorController(
this,
this.remote,
({ context: { imageWorldBounds, clipBounds } }) => {
// Compute normalized bounds
const ranges = chunk(2, imageWorldBounds).map(
([min, max]) => max - min,
);
const normalizedBounds = clipBounds.map(
(worldBound, index) =>
(100 * worldBound) / ranges[Math.floor(index / 2)],
);
const clipBoundsWithNormalized = clipBounds.map(
(bound, i) => [bound, normalizedBounds[i]] as const,
);
return { imageWorldBounds, clipBounds, clipBoundsWithNormalized };
},
(a, b) => {
if (!a || !b) return false;
return (
a.imageWorldBounds === b.imageWorldBounds &&
a.clipBounds === b.clipBounds
);
},
);
}
putFrame() {
if (!this.canvasCtx || !this.frame.value) return;
const { size, data } = this.frame.value;
if (!data) throw new Error('No data in frame');
const [width, height] = size;
// Cache ImageData to avoid allocation
if (
!this.frameData ||
this.frameData.width !== width ||
this.frameData.height !== height
) {
this.canvas.value!.width = width;
this.canvas.value!.height = height;
this.frameData = this.canvasCtx.createImageData(width, height);
}
this.frameData.data.set(data as ArrayLike<number>);
this.canvasCtx.putImageData(this.frameData, 0, 0);
}
startRenderLoop() {
if (this.renderLoopRunning || !this.remoteOnline.value) return;
this.renderLoopRunning = true;
const render = () => {
const remoteSnap = this.remote.getSnapshot();
if (!this.isConnected || remoteSnap.status === 'stopped') {
this.renderLoopRunning = false;
return;
}
this.remote.send({ type: 'render' });
requestAnimationFrame(render);
};
requestAnimationFrame(render);
}
connectedCallback() {
super.connectedCallback();
this.startRenderLoop();
}
firstUpdated() {
const canvas = this.canvas.value;
if (!canvas) throw new Error('canvas not found');
this.canvasCtx = canvas.getContext('2d');
this.resizer.observe(canvas);
}
startConnection(): void {
if (!this.serverConfig) return;
this.remote.send({
type: 'connect',
config: this.serverConfig,
});
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has('serverConfig')) {
this.startConnection();
}
if (changedProperties.has('density')) {
this.remote.send({
type: 'updateRenderer',
state: { density: this.density },
});
}
if (this.frame.value && this.frame.value !== this.lastFrameValue) {
this.lastFrameValue = this.frame.value;
this.putFrame();
}
if (this.remoteOnline.value !== this.lastRemoteOnlineValue) {
this.lastRemoteOnlineValue = this.remoteOnline.value;
if (this.remoteOnline.value) {
this.startRenderLoop();
}
}
}
onDensity(event: Event) {
const target = event.target as HTMLInputElement;
this.density = target.valueAsNumber;
}
onBounds(event: Event, index: number) {
const target = event.target as HTMLInputElement;
const normalizedBound = target.valueAsNumber;
// Find dimension range to go from normalized to world bounds
const { imageWorldBounds } = this.remote.getSnapshot().context;
if (!imageWorldBounds) {
console.error('No image world bounds');
return;
}
const [lowerBound, upperBound] =
index % 2 === 0 ? [index, index + 1] : [index - 1, index];
const range = imageWorldBounds[upperBound] - imageWorldBounds[lowerBound];
const floor = imageWorldBounds[lowerBound];
const currentBounds = [
...this.remote.getSnapshot().context.clipBounds,
] as Bounds;
currentBounds[index] = range * (normalizedBound / 100) + floor;
this.remote.send({
type: 'setClipBounds',
clipBounds: currentBounds,
});
}
render() {
return html`
<h1>Remote viewport</h1>
<p>Server Config: ${JSON.stringify(this.serverConfig)}</p>
<div>
Density: ${this.density}
<input
.valueAsNumber=${this.density}
="${this.onDensity}"
type="range"
min="1.0"
max="70.0"
step="1.0"
/>
</div>
${this.bounds.value.clipBoundsWithNormalized.map(
([world, normalized], index) => html`
<label
>Bound: ${world}
<input
.valueAsNumber=${normalized}
="${(e: Event) => this.onBounds(e, index)}"
type="range"
min="0"
max="100.0"
step="1"
/>
</label>
`,
)}
<itk-camera .actor=${this.cameraActor.value} class="camera">
<canvas ${ref(this.canvas)} class="canvas"></canvas>
</itk-camera>
`;
}
static styles = css`
:host {
display: flex;
flex-direction: column;
}
.camera {
flex: 1;
min-height: 0;
overflow: hidden;
}
.canvas {
width: 100%;
height: 100%;
`;
}
declare global {
interface HTMLElementTagNameMap {
'itk-remote-viewport': ItkRemoteViewport;
}
}