536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
import type * as p from "@bokehjs/core/properties"
|
|
|
|
import {div, canvas} from "@bokehjs/core/dom"
|
|
import {clone} from "@bokehjs/core/util/object"
|
|
import {ColorMapper} from "@bokehjs/models/mappers/color_mapper"
|
|
import {Enum} from "@bokehjs/core/kinds"
|
|
|
|
import {HTMLBox, HTMLBoxView, set_size} from "../layout"
|
|
import type {VolumeType, CSSProperties, Annotation} from "./util"
|
|
import {vtkns, setup_vtkns, majorAxis, applyStyle} from "./util"
|
|
import {VTKColorBar} from "./vtkcolorbar"
|
|
import {VTKAxes} from "./vtkaxes"
|
|
|
|
const INFO_DIV_STYLE: CSSProperties = {
|
|
padding: "0px 2px 0px 2px",
|
|
maxHeight: "150px",
|
|
height: "auto",
|
|
backgroundColor: "rgba(255, 255, 255, 0.4)",
|
|
borderRadius: "10px",
|
|
margin: "2px",
|
|
boxSizing: "border-box",
|
|
overflow: "hidden",
|
|
overflowY: "auto",
|
|
transition: "width 0.1s linear",
|
|
bottom: "0px",
|
|
position: "absolute",
|
|
}
|
|
|
|
const textPositions = Enum("LowerLeft", "LowerRight", "UpperLeft", "UpperRight", "LowerEdge", "RightEdge", "LeftEdge", "UpperEdge")
|
|
|
|
export abstract class AbstractVTKView extends HTMLBoxView {
|
|
declare model: AbstractVTKPlot
|
|
|
|
protected _axes: any
|
|
protected _camera_callbacks: any[]
|
|
protected _orientationWidget: any
|
|
protected _renderable: boolean
|
|
protected _setting_camera: boolean
|
|
protected _vtk_container: HTMLDivElement
|
|
protected _vtk_renwin: any
|
|
protected _widgetManager: any
|
|
protected _annotations_container: HTMLDivElement
|
|
protected _rendered: boolean
|
|
|
|
override initialize(): void {
|
|
super.initialize()
|
|
this._camera_callbacks = []
|
|
this._renderable = true
|
|
this._setting_camera = false
|
|
this._rendered = false
|
|
}
|
|
|
|
_add_colorbars(): void {
|
|
//construct colorbars
|
|
const old_info_div = this.shadow_el.querySelector(".vtk_info")
|
|
if (old_info_div) {
|
|
this.shadow_el.removeChild(old_info_div)
|
|
}
|
|
if (this.model.color_mappers.length < 1) {
|
|
return
|
|
}
|
|
|
|
const info_div = document.createElement("div")
|
|
const expand_width = "350px"
|
|
const collapsed_width = "30px"
|
|
info_div.classList.add("vtk_info")
|
|
applyStyle(info_div, INFO_DIV_STYLE)
|
|
applyStyle(info_div, {width: expand_width})
|
|
this.shadow_el.appendChild(info_div)
|
|
|
|
//construct colorbars
|
|
const colorbars: VTKColorBar[] = []
|
|
this.model.color_mappers.forEach((mapper) => {
|
|
const cb = new VTKColorBar(info_div, mapper)
|
|
colorbars.push(cb)
|
|
})
|
|
|
|
//content when collapsed
|
|
const dots = document.createElement("div")
|
|
applyStyle(dots, {textAlign: "center", fontSize: "20px"})
|
|
dots.innerText = "..."
|
|
|
|
info_div.addEventListener("click", () => {
|
|
if (info_div.style.width === collapsed_width) {
|
|
info_div.removeChild(dots)
|
|
applyStyle(info_div, {height: "auto", width: expand_width})
|
|
colorbars.forEach((cb) => info_div.appendChild(cb.canvas))
|
|
} else {
|
|
colorbars.forEach((cb) => info_div.removeChild(cb.canvas))
|
|
applyStyle(info_div, {height: collapsed_width, width: collapsed_width})
|
|
info_div.appendChild(dots)
|
|
}
|
|
})
|
|
|
|
info_div.click()
|
|
}
|
|
|
|
_init_annotations_container(): void {
|
|
if (!this._annotations_container) {
|
|
this._annotations_container = document.createElement("div")
|
|
this._annotations_container.style.position = "absolute"
|
|
this._annotations_container.style.width = "100%"
|
|
this._annotations_container.style.height = "100%"
|
|
this._annotations_container.style.top = "0"
|
|
this._annotations_container.style.left = "0"
|
|
this._annotations_container.style.pointerEvents = "none"
|
|
}
|
|
}
|
|
|
|
_clean_annotations(): void {
|
|
if (this._annotations_container) {
|
|
while (this._annotations_container.firstElementChild) {
|
|
this._annotations_container.firstElementChild.remove()
|
|
}
|
|
}
|
|
}
|
|
|
|
_add_annotations(): void {
|
|
this._clean_annotations()
|
|
const {annotations} = this.model
|
|
if (annotations != null) {
|
|
for (const annotation of annotations) {
|
|
const {viewport, color, fontSize, fontFamily} = annotation
|
|
textPositions.values.forEach((pos) => {
|
|
const text = annotation[pos]
|
|
if (text) {
|
|
const div = document.createElement("div")
|
|
div.textContent = text
|
|
const {style} = div
|
|
style.position = "absolute"
|
|
style.color = `rgb(${color.map((val)=>255*val).join(",")})`
|
|
style.fontSize = `${fontSize}px`
|
|
style.padding = "5px"
|
|
style.fontFamily = fontFamily
|
|
style.width = "fit-content"
|
|
|
|
if (pos == "UpperLeft") {
|
|
style.top = `${(1 - viewport[3])*100}%`
|
|
style.left = `${viewport[0]*100}%`
|
|
}
|
|
if (pos == "UpperRight") {
|
|
style.top = `${(1 - viewport[3])*100}%`
|
|
style.right = `${(1-viewport[2])*100}%`
|
|
}
|
|
if (pos == "LowerLeft") {
|
|
style.bottom = `${viewport[1]*100}%`
|
|
style.left = `${viewport[0]*100}%`
|
|
}
|
|
if (pos == "LowerRight") {
|
|
style.bottom = `${viewport[1]*100}%`
|
|
style.right = `${(1-viewport[2])*100}%`
|
|
}
|
|
if (pos == "UpperEdge") {
|
|
style.top = `${(1 - viewport[3])*100}%`
|
|
style.left = `${(viewport[0] + (viewport[2] - viewport[0])/2) *100}%`
|
|
style.transform = "translateX(-50%)"
|
|
}
|
|
if (pos == "LowerEdge") {
|
|
style.bottom = `${viewport[1]*100}%`
|
|
style.left = `${(viewport[0] + (viewport[2] - viewport[0])/2) *100}%`
|
|
style.transform = "translateX(-50%)"
|
|
}
|
|
if (pos == "LeftEdge") {
|
|
style.left = `${viewport[0]*100}%`
|
|
style.top = `${(1 - viewport[3] + (viewport[3] - viewport[1])/2) *100}%`
|
|
style.transform = "translateY(-50%)"
|
|
}
|
|
if (pos == "RightEdge") {
|
|
style.right = `${(1-viewport[2])*100}%`
|
|
style.top = `${(1 - viewport[3] + (viewport[3] - viewport[1])/2) *100}%`
|
|
style.transform = "translateY(-50%)"
|
|
}
|
|
this._annotations_container.appendChild(div)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
override connect_signals(): void {
|
|
super.connect_signals()
|
|
this.on_change(this.model.properties.orientation_widget, () => {
|
|
this._orientation_widget_visibility(this.model.orientation_widget)
|
|
})
|
|
this.on_change(this.model.properties.camera, () => this._set_camera_state())
|
|
this.on_change(this.model.properties.axes, () => {
|
|
this._delete_axes()
|
|
if (this.model.axes) {
|
|
this._set_axes()
|
|
}
|
|
this._vtk_render()
|
|
})
|
|
this.on_change(this.model.properties.color_mappers, () => this._add_colorbars())
|
|
this.on_change(this.model.properties.annotations, () => this._add_annotations())
|
|
}
|
|
|
|
override render(): void {
|
|
super.render()
|
|
this._rendered = false
|
|
this._orientationWidget = null
|
|
this._axes = null
|
|
this._vtk_container = div()
|
|
this.init_vtk_renwin()
|
|
this._init_annotations_container()
|
|
set_size(this._vtk_container, this.model)
|
|
this.shadow_el.appendChild(this._vtk_container)
|
|
// update camera model state only at the end of the interaction
|
|
// with the scene (avoid bouncing events and large amount of events)
|
|
this._vtk_renwin.getInteractor().onEndAnimation(() => this._get_camera_state())
|
|
this._remove_default_key_binding()
|
|
this._bind_key_events()
|
|
this.plot()
|
|
this.model.renderer_el = this._vtk_renwin
|
|
this.shadow_el.appendChild(this._annotations_container)
|
|
}
|
|
|
|
override after_layout(): void {
|
|
super.after_layout()
|
|
if (this._renderable) {
|
|
this._vtk_renwin.resize() // resize call render method
|
|
}
|
|
this._vtk_render()
|
|
if (!this._rendered) {
|
|
this._add_colorbars()
|
|
this._add_annotations()
|
|
this._rendered = true
|
|
}
|
|
}
|
|
|
|
override invalidate_render(): void {
|
|
this._unsubscribe_camera_cb()
|
|
super.invalidate_render()
|
|
}
|
|
|
|
override remove(): void {
|
|
this._unsubscribe_camera_cb()
|
|
window.removeEventListener("resize", this._vtk_renwin.resize)
|
|
if (this._orientationWidget != null) {
|
|
this._orientationWidget.delete()
|
|
}
|
|
this._vtk_renwin.getRenderWindow().getInteractor().delete()
|
|
this._vtk_renwin.delete()
|
|
super.remove()
|
|
}
|
|
|
|
abstract init_vtk_renwin(): void
|
|
|
|
abstract plot(): void //here goes the specific implementation pour all concrete model based on vtk-js
|
|
|
|
get _vtk_camera_state(): any {
|
|
const vtk_camera = this._vtk_renwin.getRenderer().getActiveCamera()
|
|
let state: any
|
|
if (vtk_camera) {
|
|
state = clone(vtk_camera.get())
|
|
delete state.cameraLightTransform
|
|
delete state.classHierarchy
|
|
delete state.vtkObject
|
|
delete state.vtkCamera
|
|
delete state.viewPlaneNormal
|
|
delete state.flattenedDepIds
|
|
delete state.managedInstanceId
|
|
delete state.directionOfProjection
|
|
}
|
|
return state
|
|
}
|
|
|
|
get _axes_canvas(): HTMLCanvasElement {
|
|
let axes_canvas = this._vtk_container.querySelector(".axes-canvas") as HTMLCanvasElement
|
|
if (!axes_canvas) {
|
|
axes_canvas = canvas({
|
|
style: {
|
|
position: "absolute",
|
|
top: "0",
|
|
left: "0",
|
|
width: "100%",
|
|
height: "100%",
|
|
},
|
|
})
|
|
axes_canvas.classList.add("axes-canvas")
|
|
this._vtk_container.appendChild(axes_canvas)
|
|
this._vtk_renwin.setResizeCallback(() => {
|
|
if (this._axes_canvas) {
|
|
const dims = this._vtk_container.getBoundingClientRect()
|
|
const width = Math.floor(dims.width * window.devicePixelRatio)
|
|
const height = Math.floor(dims.height * window.devicePixelRatio)
|
|
this._axes_canvas.setAttribute("width", width.toFixed())
|
|
this._axes_canvas.setAttribute("height", height.toFixed())
|
|
}
|
|
})
|
|
}
|
|
return axes_canvas
|
|
}
|
|
|
|
_bind_key_events(): void {
|
|
this.el.addEventListener("mouseenter", () => {
|
|
const interactor = this._vtk_renwin.getInteractor()
|
|
if (this.model.enable_keybindings) {
|
|
document
|
|
.querySelector("body")!
|
|
.addEventListener("keypress", interactor.handleKeyPress)
|
|
document
|
|
.querySelector("body")!
|
|
.addEventListener("keydown", interactor.handleKeyDown)
|
|
document
|
|
.querySelector("body")!
|
|
.addEventListener("keyup", interactor.handleKeyUp)
|
|
}
|
|
})
|
|
this.el.addEventListener("mouseleave", () => {
|
|
const interactor = this._vtk_renwin.getInteractor()
|
|
document
|
|
.querySelector("body")!
|
|
.removeEventListener("keypress", interactor.handleKeyPress)
|
|
document
|
|
.querySelector("body")!
|
|
.removeEventListener("keydown", interactor.handleKeyDown)
|
|
document
|
|
.querySelector("body")!
|
|
.removeEventListener("keyup", interactor.handleKeyUp)
|
|
})
|
|
}
|
|
|
|
_create_orientation_widget(): void {
|
|
const axes = vtkns.AxesActor.newInstance()
|
|
|
|
// add orientation widget
|
|
this._orientationWidget = vtkns.OrientationMarkerWidget.newInstance({
|
|
actor: axes,
|
|
interactor: this._vtk_renwin.getInteractor(),
|
|
})
|
|
this._orientationWidget.setEnabled(true)
|
|
this._orientationWidget.setViewportCorner(vtkns.OrientationMarkerWidget.Corners.BOTTOM_RIGHT)
|
|
this._orientationWidget.setViewportSize(0.15)
|
|
this._orientationWidget.setMinPixelSize(75)
|
|
this._orientationWidget.setMaxPixelSize(300)
|
|
if (this.model.interactive_orientation_widget) {
|
|
this._make_orientation_widget_interactive()
|
|
}
|
|
this._orientation_widget_visibility(this.model.orientation_widget)
|
|
}
|
|
|
|
_make_orientation_widget_interactive(): void {
|
|
this._widgetManager = vtkns.WidgetManager.newInstance()
|
|
this._widgetManager.setRenderer(this._orientationWidget.getRenderer())
|
|
|
|
const axes = this._orientationWidget.getActor()
|
|
const widget = vtkns.InteractiveOrientationWidget.newInstance()
|
|
widget.placeWidget(axes.getBounds())
|
|
widget.setBounds(axes.getBounds())
|
|
widget.setPlaceFactor(1)
|
|
|
|
const vw = this._widgetManager.addWidget(widget)
|
|
|
|
// Manage user interaction
|
|
vw.onOrientationChange(({direction}: any) => {
|
|
const camera = this._vtk_renwin.getRenderer().getActiveCamera()
|
|
const focalPoint = camera.getFocalPoint()
|
|
const position = camera.getPosition()
|
|
const viewUp = camera.getViewUp()
|
|
|
|
const distance = Math.sqrt(
|
|
(position[0] - focalPoint[0])**2 +
|
|
(position[1] - focalPoint[1])**2 +
|
|
(position[2] - focalPoint[2])**2,
|
|
)
|
|
|
|
camera.setPosition(
|
|
focalPoint[0] + direction[0] * distance,
|
|
focalPoint[1] + direction[1] * distance,
|
|
focalPoint[2] + direction[2] * distance,
|
|
)
|
|
|
|
if (direction[0]) {
|
|
camera.setViewUp(majorAxis(viewUp, 1, 2))
|
|
}
|
|
if (direction[1]) {
|
|
camera.setViewUp(majorAxis(viewUp, 0, 2))
|
|
}
|
|
if (direction[2]) {
|
|
camera.setViewUp(majorAxis(viewUp, 0, 1))
|
|
}
|
|
|
|
this._vtk_renwin.getRenderer().resetCameraClippingRange()
|
|
this._vtk_render()
|
|
this._get_camera_state()
|
|
})
|
|
}
|
|
|
|
_delete_axes(): void {
|
|
if (this._axes) {
|
|
Object.keys(this._axes).forEach((key) => {
|
|
this._vtk_renwin.getRenderer().removeActor(this._axes[key])
|
|
})
|
|
this._axes = null
|
|
const textCtx = this._axes_canvas.getContext("2d")
|
|
if (textCtx) {
|
|
textCtx.clearRect(
|
|
0,
|
|
0,
|
|
this._axes_canvas.clientWidth * window.devicePixelRatio,
|
|
this._axes_canvas.clientHeight * window.devicePixelRatio,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
_get_camera_state(): void {
|
|
if (!this._setting_camera) {
|
|
this._setting_camera = true
|
|
this.model.camera = this._vtk_camera_state
|
|
this._setting_camera = false
|
|
}
|
|
}
|
|
|
|
_orientation_widget_visibility(visibility: boolean): void {
|
|
this._orientationWidget.setEnabled(visibility)
|
|
if (this._widgetManager != null) {
|
|
if (visibility) {
|
|
this._widgetManager.enablePicking()
|
|
} else {
|
|
this._widgetManager.disablePicking()
|
|
}
|
|
}
|
|
this._vtk_render()
|
|
}
|
|
|
|
_remove_default_key_binding(): void {
|
|
const interactor = this._vtk_renwin.getInteractor()
|
|
document
|
|
.querySelector("body")!
|
|
.removeEventListener("keypress", interactor.handleKeyPress)
|
|
document
|
|
.querySelector("body")!
|
|
.removeEventListener("keydown", interactor.handleKeyDown)
|
|
document
|
|
.querySelector("body")!
|
|
.removeEventListener("keyup", interactor.handleKeyUp)
|
|
}
|
|
|
|
_set_axes(): void {
|
|
if (this.model.axes && this._vtk_renwin.getRenderer()) {
|
|
const {psActor, axesActor, gridActor} = this.model.axes.create_axes(this._axes_canvas)
|
|
this._axes = {psActor, axesActor, gridActor}
|
|
if (psActor) {
|
|
this._vtk_renwin.getRenderer().addActor(psActor)
|
|
}
|
|
if (axesActor) {
|
|
this._vtk_renwin.getRenderer().addActor(axesActor)
|
|
}
|
|
if (gridActor) {
|
|
this._vtk_renwin.getRenderer().addActor(gridActor)
|
|
}
|
|
}
|
|
}
|
|
|
|
_set_camera_state(): void {
|
|
if (!this._setting_camera && this._vtk_renwin.getRenderer() !== undefined) {
|
|
this._setting_camera = true
|
|
if (
|
|
this.model.camera &&
|
|
JSON.stringify(this.model.camera) != JSON.stringify(this._vtk_camera_state)
|
|
) {
|
|
this._vtk_renwin
|
|
.getRenderer()
|
|
.getActiveCamera()
|
|
.set(this.model.camera)
|
|
}
|
|
this._vtk_renwin.getRenderer().resetCameraClippingRange()
|
|
this._setting_camera = false
|
|
}
|
|
}
|
|
|
|
_unsubscribe_camera_cb(): void {
|
|
this._camera_callbacks
|
|
.splice(0, this._camera_callbacks.length)
|
|
.map((cb) => cb.unsubscribe())
|
|
}
|
|
|
|
_vtk_render(): void {
|
|
if (this._renderable) {
|
|
if (this._orientationWidget) {
|
|
this._orientationWidget.updateMarkerOrientation()
|
|
}
|
|
this._vtk_renwin.getRenderWindow().render()
|
|
}
|
|
}
|
|
}
|
|
|
|
export namespace AbstractVTKPlot {
|
|
export type Attrs = p.AttrsOf<Props>
|
|
export type Props = HTMLBox.Props & {
|
|
axes: p.Property<VTKAxes | null>
|
|
camera: p.Property<any>
|
|
data: p.Property<string | VolumeType | ArrayBuffer | null>
|
|
enable_keybindings: p.Property<boolean>
|
|
orientation_widget: p.Property<boolean>
|
|
color_mappers: p.Property<ColorMapper[]>
|
|
interactive_orientation_widget: p.Property<boolean>
|
|
annotations: p.Property<Annotation[] | null>
|
|
}
|
|
}
|
|
|
|
export interface AbstractVTKPlot extends AbstractVTKPlot.Attrs {}
|
|
|
|
export abstract class AbstractVTKPlot extends HTMLBox {
|
|
declare properties: AbstractVTKPlot.Props
|
|
renderer_el: any
|
|
|
|
static override __module__ = "panel.models.vtk"
|
|
|
|
constructor(attrs?: Partial<AbstractVTKPlot.Attrs>) {
|
|
setup_vtkns()
|
|
super(attrs)
|
|
}
|
|
|
|
getActors(): any[] {
|
|
return this.renderer_el.getRenderer().getActors()
|
|
}
|
|
|
|
static {
|
|
this.define<AbstractVTKPlot.Props>(({Any, Ref, Array, Boolean, Nullable}) => ({
|
|
axes: [ Nullable(Ref(VTKAxes)), null ],
|
|
camera: [ Any, {} ],
|
|
color_mappers: [ Array(Ref(ColorMapper)), [] ],
|
|
orientation_widget: [ Boolean, false ],
|
|
interactive_orientation_widget: [ Boolean, false ],
|
|
annotations: [ Nullable(Array(Any)), null ],
|
|
}))
|
|
|
|
this.override<AbstractVTKPlot.Props>({
|
|
height: 300,
|
|
width: 300,
|
|
})
|
|
}
|
|
}
|