Newer
Older
import { Component, Output, EventEmitter, ElementRef, OnDestroy, AfterViewInit, Inject, Optional, ChangeDetectionStrategy } from "@angular/core";
import { EnumViewerEvt, IViewer, TViewerEvent } from "src/viewerModule/viewer.interface";
import { combineLatest, concat, forkJoin, from, merge, NEVER, Observable, of, Subject } from "rxjs";
import { catchError, debounceTime, distinctUntilChanged, filter, map, scan, shareReplay, switchMap, withLatestFrom } from "rxjs/operators";
import { ComponentStore } from "src/viewerModule/componentStore";
import { select, Store } from "@ngrx/store";
import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util";
import { MatSnackBar } from "@angular/material/snack-bar";
import { CONST } from 'common/constants'
import { getUuid, switchMapWaitFor } from "src/util/fn";
import { AUTO_ROTATE, TInteralStatePayload, ViewerInternalStateSvc } from "src/viewerModule/viewerInternalState.service";
import { atlasAppearance, atlasSelection } from "src/state";
import { ThreeSurferCustomLabelLayer, ThreeSurferCustomLayer, ColorMapCustomLayer } from "src/state/atlasAppearance/const";
import { SxplrRegion } from "src/atlasComponents/sapi/sxplrTypes"
import { arrayEqual } from "src/util/array";
import { ThreeSurferEffects } from "../store/effects";
import { selectors, actions } from "../store"
const viewerType = 'ThreeSurfer'
type TInternalState = {
camera: {
x: number
y: number
z: number
}
mode: string
hemisphere: 'left' | 'right' | 'both'
}
evMesh?: {
faceIndex: number
verticesIndicies: number[]
}
}
type TLatVtxIdxRecord = LateralityRecord<{
indexLayer: ThreeSurferCustomLabelLayer
vertexIndices: number[]
}>
type TLatMeshRecord = LateralityRecord<{
meshLayer: ThreeSurferCustomLayer
mesh: TThreeGeometry
}>
type MeshVisOp = 'toggle' | 'noop'
type TApplyColorArg = LateralityRecord<{
labelIndices: number[]
idxReg: Record<number, SxplrRegion>
isBaseCm: boolean
showDelin: boolean
selectedRegions: SxplrRegion[]
mesh: TThreeGeometry
vertexIndices: number[]
map?: Map<number, number[]>
}>
type THandleCustomMouseEv = {
latMeshRecord: TLatMeshRecord
latLblIdxRecord: TLatVtxIdxRecord
evDetail: any
latLblIdxReg: TLatIdxReg
meshVisibility: {
label: string
mesh: TThreeGeometry
}[]
}
type TLatIdxReg = LateralityRecord<Record<number, SxplrRegion>>
type TLatCm = LateralityRecord<{
labelIndices: number[]
map: Map<number, number[]>
}>
type TThreeGeometry = {
visible: boolean
}
type TThreeSurfer = {
loadMesh: (url: string) => Promise<TThreeGeometry>
unloadMesh: (geom: TThreeGeometry) => void
redraw: (geom: TThreeGeometry) => void
applyColorMap: (geom: TThreeGeometry, idxMap?: number[], custom?: { usePreset?: any, custom?: Map<number, number[]> }) => void
loadColormap: (url: string) => Promise<GiiInstance>
setupAnimation: () => void
dispose: () => void
control: any
camera: any
customColormap: WeakMap<TThreeGeometry, any>
type LateralityRecord<T> = Record<string, T>
const threshold = 1e-3
function cameraNavsAreSimilar(c1: TCameraOrientation, c2: TCameraOrientation){
if (c1 === c2) return true
if (!!c1 && !!c2) return true
if (!c1 && !!c2) return false
if (!c2 && !!c1) return false
if (Math.abs(c1.perspectiveZoom - c2.perspectiveZoom) > threshold) return false
if ([0, 1, 2, 3].some(
idx => Math.abs(c1.perspectiveOrientation[idx] - c2.perspectiveOrientation[idx]) > threshold
)) {
return false
}
return true
}
@Component({
selector: 'three-surfer-glue-cmp',
templateUrl: './threeSurfer.template.html',
styleUrls: [
'./threeSurfer.style.css'
providers: [ ComponentStore ],
changeDetection: ChangeDetectionStrategy.OnPush
export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit, OnDestroy {
#cameraEv$ = new Subject<{ position: { x: number, y: number, z: number }, zoom: number }>()
#mouseEv$ = new Subject()
viewerEvent = new EventEmitter<TViewerEvent<'threeSurfer'>>()
private mainStoreCameraNav: TCameraOrientation = null
private localCameraNav: TCameraOrientation = null
private internalStateNext: (arg: TInteralStatePayload<TInternalState>) => void
private customLayers$ = this.store$.pipe(
select(atlasAppearance.selectors.customLayers),
distinctUntilChanged(arrayEqual((o, n) => o.id === n.id)),
shareReplay(1)
)
#meshLayers$: Observable<ThreeSurferCustomLayer[]> = this.customLayers$.pipe(
map(layers => layers.filter(l => l.clType === "baselayer/threesurfer") as ThreeSurferCustomLayer[]),
distinctUntilChanged(arrayEqual((o, n) => o.id === n.id)),
)
#lateralMeshRecord$ = new Subject<TLatMeshRecord>()
lateralMeshRecord$ = concat(
of({} as TLatMeshRecord),
this.#lateralMeshRecord$.asObservable()
)
#meshVisOp$ = new Subject<{ op: MeshVisOp, label?: string }>()
meshVisible$ = this.lateralMeshRecord$.pipe(
map(v => {
const returnVal: {
label: string
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
mesh: TThreeGeometry
}[] = []
for (const lat in v) {
returnVal.push({
visible: true,
mesh: v[lat].mesh,
label: lat
})
}
return returnVal
}),
switchMap(arr => concat(
of({ op: 'noop', label: null }),
this.#meshVisOp$
).pipe(
map(({ op, label }) => arr.map(v => {
if (label !== v.label) {
return v
}
if (op === "toggle") {
v.visible = !v.visible
}
return v
}))
))
)
private vertexIndexLayers$: Observable<ThreeSurferCustomLabelLayer[]> = this.customLayers$.pipe(
map(layers => layers.filter(l => l.clType === "baselayer/threesurfer-label") as ThreeSurferCustomLabelLayer[]),
distinctUntilChanged(arrayEqual((o, n) => o.id === n.id)),
)
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
#latVtxIdxRecord$: Observable<TLatVtxIdxRecord> = this.vertexIndexLayers$.pipe(
switchMap(
switchMapWaitFor({
condition: () => !!this.tsRef,
leading: true
})
),
switchMap(layers =>
forkJoin(
layers.map(layer =>
from(
this.tsRef.loadColormap(layer.source)
).pipe(
map(giiInstance => {
let vertexIndices: number[] = giiInstance[0].getData()
if (giiInstance[0].attributes.DataType === 'NIFTI_TYPE_INT16') {
vertexIndices = (window as any).ThreeSurfer.GiftiBase.castF32UInt16(vertexIndices)
}
return {
indexLayer: layer,
vertexIndices
}
})
)
)
)
),
map(layers => {
const returnObj = {}
for (const { indexLayer, vertexIndices } of layers) {
returnObj[indexLayer.laterality] = { indexLayer, vertexIndices }
}
return returnObj
})
)
/**
* maps laterality to label index to sapi region
*/
#latLblIdxToRegionRecord$: Observable<TLatIdxReg> = combineLatest([
),
this.store$.pipe(
select(atlasSelection.selectors.selectedParcAllRegions),
)
]).pipe(
return merge(
...regions.map(region =>
from(this.sapi.getRegionLabelIndices(template, parcellation, region)).pipe(
map(label => ({ region, label })),
catchError(() => NEVER)
)
)
).pipe(
scan((acc, curr) => {
const { label, region } = curr
let key : 'left' | 'right'
if ( /left/i.test(region.name) ) key = 'left'
if ( /right/i.test(region.name) ) key = 'right'
if (!key) {
/**
* TODO
* there are ... more regions than expected, which has label index without laterality
*/
return {
...acc,
[key]: {
...acc[key],
[label]: region
}
}
}, {'left': {}, 'right': {}})
)
})
)
/**
* colormap in use (both base & custom)
*/
#colormaps$: Observable<ColorMapCustomLayer[]> = this.customLayers$.pipe(
map(layers => layers.filter(l => l.clType === "baselayer/colormap" || l.clType === "customlayer/colormap") as ColorMapCustomLayer[]),
distinctUntilChanged(arrayEqual((o, n) => o.id === n.id))
)
#latLblIdxToCm$ = combineLatest([
this.#latLblIdxToRegionRecord$,
this.#colormaps$
]).pipe(
map(([ latIdxReg, cms ]) => {
const cm = cms[0]
const returnValue: TLatCm = {}
for (const lat in latIdxReg) {
returnValue[lat] = {
labelIndices: [],
map: new Map()
}
for (const lblIdx in latIdxReg[lat]) {
returnValue[lat].labelIndices.push(Number(lblIdx))
const reg = latIdxReg[lat][lblIdx]
returnValue[lat].map.set(
Number(lblIdx), (cm.colormap.get(reg) || [255, 255, 255]).map(v => v/255)
)
}
}
return returnValue
})
* when do we need to call apply color?
* - when mesh loads
* - when vertex index layer changes
* - selected region changes
* - custom color map added (by plugin, etc)
* - show delineation updates
public threeSurferSurfaceVariants$ = this.effect.onATPDebounceThreeSurferLayers$.pipe(
map(({ surfaces }) => surfaces.reduce((acc, val) => acc.includes(val.variant) ? acc : [...acc, val.variant] ,[] as string[]))
)
public selectedSurfaceLayerId$ = this.store$.pipe(
private navStateStoreRelay: ComponentStore<{ perspectiveOrientation: [number, number, number, number], perspectiveZoom: number }>,
@Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor,
if (intViewerStateSvc) {
const {
done,
next,
} = intViewerStateSvc.registerEmitter({
"@type": 'TViewerInternalStateEmitter',
viewerType,
applyState: arg => {
if (arg.viewerType === AUTO_ROTATE) {
const autoPlayFlag = (arg.payload as any).play
const reverseFlag = (arg.payload as any).reverse
const autoplaySpeed = (arg.payload as any).speed
this.toTsRef(tsRef => {
tsRef.control.autoRotate = autoPlayFlag
tsRef.control.autoRotateSpeed = autoplaySpeed * (reverseFlag ? -1 : 1)
})
return
}
if (arg.viewerType !== viewerType) return
this.toTsRef(tsRef => {
tsRef.camera.position.copy((arg.payload as any).camera)
})
}
})
this.internalStateNext = next
this.onDestroyCb.push(() => done())
}
/**
* intercept click and act
*/
if (clickInterceptor) {
const handleClick = (ev: MouseEvent) => {
// if does not click inside container, ignore
if (!(this.el.nativeElement as HTMLElement).contains(ev.target as HTMLElement)) {
return true
}
if (this.mouseoverRegions.length === 0) return true
if (this.mouseoverRegions.length > 1) {
this.snackbar.open(CONST.DOES_NOT_SUPPORT_MULTI_REGION_SELECTION, 'Dismiss', {
duration: 3000
})
return true
}
atlasSelection.actions.setSelectedRegions({ regions })
)
return true
}
const { register, deregister } = clickInterceptor
register(handleClick)
this.onDestroyCb.push(
/**
* subscribe to camera custom event
*/
const cameraSub = this.#cameraEv$.pipe(
filter(v => !!v),
debounceTime(160)
const THREE = (window as any).ThreeSurfer.THREE
const q = new THREE.Quaternion()
const t = new THREE.Vector3()
const s = new THREE.Vector3()
/**
* ThreeJS interpretes the scene differently to neuroglancer in subtle ways.
* At [0, 0, 0, 1] decomposed camera quaternion, for example,
* - ThreeJS: view from superior -> inferior, anterior as top, right hemisphere as right
* - NG: view from from inferior -> superior, posterior as top, left hemisphere as right
*
* multiplying the exchange factor [-1, 0, 0, 0] converts ThreeJS convention to NG convention
*/
const cameraM = this.tsRef.camera.matrix
cameraM.decompose(t, q, s)
const exchangeFactor = new THREE.Quaternion(-1, 0, 0, 0)
try {
this.navStateStoreRelay.setState({
perspectiveOrientation: q.multiply(exchangeFactor).toArray(),
// LockError, ignore
}
})
this.onDestroyCb.push(
() => cameraSub.unsubscribe()
)
/**
* subscribe to navstore relay store and negotiate setting global state
*/
const navStateSub = this.navStateStoreRelay.select(s => s).subscribe(v => {
this.store$.dispatch(
navigation: {
position: [0, 0, 0],
orientation: [0, 0, 0, 1],
perspectiveOrientation: v.perspectiveOrientation,
perspectiveZoom: v.perspectiveZoom * pZoomFactor
}
})
)
})
this.onDestroyCb.push(
() => navStateSub.unsubscribe()
)
/**
* subscribe to main store and negotiate with relay to set camera
*/
const navSub = this.store$.pipe(
select(atlasSelection.selectors.navigation),
filter(v => !!v),
).subscribe(nav => {
const { perspectiveOrientation, perspectiveZoom } = nav
this.mainStoreCameraNav = {
perspectiveOrientation,
perspectiveZoom
}
if (!cameraNavsAreSimilar(this.mainStoreCameraNav, this.localCameraNav)) {
this.relayStoreLock = this.navStateStoreRelay.getLock()
const THREE = (window as any).ThreeSurfer.THREE
const cameraQuat = new THREE.Quaternion(...this.mainStoreCameraNav.perspectiveOrientation)
const cameraPos = new THREE.Vector3(0, 0, this.mainStoreCameraNav.perspectiveZoom / pZoomFactor)
/**
* ThreeJS interpretes the scene differently to neuroglancer in subtle ways.
* At [0, 0, 0, 1] decomposed camera quaternion, for example,
* - ThreeJS: view from superior -> inferior, anterior as top, right hemisphere as right
* - NG: view from from inferior -> superior, posterior as top, left hemisphere as right
*
* multiplying the exchange factor [-1, 0, 0, 0] converts ThreeJS convention to NG convention
*/
const exchangeFactor = new THREE.Quaternion(-1, 0, 0, 0)
cameraQuat.multiply(exchangeFactor)
cameraPos.applyQuaternion(cameraQuat)
this.toTsRef(tsRef => {
tsRef.camera.position.copy(cameraPos)
if (this.relayStoreLock) this.relayStoreLock()
})
}
})
this.onDestroyCb.push(
() => navSub.unsubscribe()
)
private relayStoreLock: () => void = null
private tsRefInitCb: ((tsRef: any) => void)[] = []
private toTsRef(callback: (tsRef: any) => void) {
if (this.tsRef) {
callback(this.tsRef)
return
}
this.tsRefInitCb.push(callback)
}
async #loadMeshes(layers: ThreeSurferCustomLayer[], currMeshRecord: TLatMeshRecord) {
if (!this.tsRef) throw new Error(`loadMeshes error: this.tsRef is not defined!!`)
const copiedCurrMeshRecord: TLatMeshRecord = {...currMeshRecord}
/**
* remove the layers...
*/
for (const layer of layers) {
if (!!copiedCurrMeshRecord[layer.laterality]) {
this.tsRef.unloadMesh(copiedCurrMeshRecord[layer.laterality].mesh)
for (const layer of layers) {
const threeMesh = await this.tsRef.loadMesh(layer.source)
copiedCurrMeshRecord[layer.laterality] = {
this.#lateralMeshRecord$.next(copiedCurrMeshRecord)
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
#applyColor$ = combineLatest([
combineLatest([
this.lateralMeshRecord$,
this.store$.pipe(
select(atlasSelection.selectors.selectedRegions),
distinctUntilChanged(arrayEqual((o, n) => o.name === n.name))
),
this.#colormaps$.pipe(
map(cms => cms[0]),
distinctUntilChanged((o, n) => o?.id === n?.id)
),
this.store$.pipe(
select(atlasAppearance.selectors.showDelineation),
distinctUntilChanged()
),
this.#latLblIdxToCm$,
this.#latLblIdxToRegionRecord$,
]),
this.#latVtxIdxRecord$
]).pipe(
debounceTime(16),
map(([[ latMeshDict, selReg, cm, showDelFlag, latLblIdxToCm, latLblIdxToRegionRecord ], latVtxIdx]) => {
const arg: TApplyColorArg = {}
for (const lat in latMeshDict) {
arg[lat] = {
mesh: latMeshDict[lat].mesh,
selectedRegions: selReg,
showDelin: showDelFlag,
isBaseCm: cm.clType === "baselayer/colormap",
labelIndices: latLblIdxToCm[lat].labelIndices,
idxReg: latLblIdxToRegionRecord[lat],
map: latLblIdxToCm[lat].map,
vertexIndices: latVtxIdx[lat].vertexIndices
}
return arg
})
)
private applyColor(applyArg: TApplyColorArg) {
/**
* on apply color map, reset mesh visibility
* this issue is more difficult to solve than first anticiplated.
* test scenarios:
*
* 1/ hide hemisphere, select region
* 2/ hide hemisphere, select region, unhide hemisphere
* 3/ select region, hide hemisphere, deselect region
*/
for (const laterality in applyArg) {
const { labelIndices, map, mesh, showDelin, selectedRegions, isBaseCm, idxReg, vertexIndices } = applyArg[laterality]
if (!map) {
this.tsRef.applyColorMap(mesh, vertexIndices)
const actualApplyMap = new Map<number, number[]>()
if (!showDelin) {
for (const lblIdx of labelIndices){
actualApplyMap.set(lblIdx, [1, 1, 1])
}
this.tsRef.applyColorMap(mesh, vertexIndices, {
custom: actualApplyMap
})
return
}
const highlightIdx = new Set<number>()
if (isBaseCm && selectedRegions.length > 0) {
for (const [idx, region] of Object.entries(idxReg)) {
if (selectedRegions.findIndex(r => r.name === region.name) >= 0) {
highlightIdx.add(Number(idx))
}
}
}
if (isBaseCm && selectedRegions.length > 0) {
for (const lblIdx of labelIndices) {
actualApplyMap.set(
Number(lblIdx),
highlightIdx.has(lblIdx)
? map.get(lblIdx) || [1, 0.8, 0.8]
: [1, 1, 1]
)
}
} else {
for (const lblIdx of labelIndices) {
actualApplyMap.set(
Number(lblIdx),
map.get(lblIdx) || [1, 0.8, 0.8]
)
this.tsRef.applyColorMap(mesh, vertexIndices, {
custom: actualApplyMap
#handleCustomMouseEv$ = this.#mouseEv$.pipe(
withLatestFrom(
this.lateralMeshRecord$,
this.#latLblIdxToRegionRecord$,
this.meshVisible$,
this.#latVtxIdxRecord$,
)
).pipe(
map(([ evDetail, latMeshRecord, latLblIdxReg, meshVis, latVtxIdx ]) => {
const returnVal: THandleCustomMouseEv = {
evDetail,
meshVisibility: meshVis,
latLblIdxReg: latLblIdxReg,
latMeshRecord: latMeshRecord,
latLblIdxRecord: latVtxIdx
}
return returnVal
})
)
#handleCustomMouseEv(arg: THandleCustomMouseEv){
const { evDetail: detail, latMeshRecord, latLblIdxRecord, latLblIdxReg, meshVisibility } = arg
const evMesh = detail.mesh && {
faceIndex: detail.mesh.faceIndex,
// typo in three-surfer
verticesIndicies: detail.mesh.verticesIdicies
}
const custEv: THandlingCustomEv = {
regions: [],
evMesh
}
if (!detail.mesh) {
return this.handleMouseoverEvent(custEv)
}
const {
geometry: evGeometry,
// typo in three-surfer
verticesIdicies: evVerticesIndicies,
} = detail.mesh as { geometry: TThreeGeometry, verticesIdicies: number[] }
for (const laterality in latMeshRecord) {
const meshRecord = latMeshRecord[laterality]
if (meshRecord.mesh !== evGeometry) {
continue
}
/**
* if either labelindex record or colormap record is undefined for this laterality, emit empty event
*/
if (!latLblIdxRecord[laterality] || !latLblIdxReg[laterality]) {
const labelIndexRecord = latLblIdxRecord[laterality]
const regionRecord = latLblIdxReg[laterality]
/**
* check if the mesh is toggled off
* if so, do not proceed
*/
const mVis = meshVisibility.filter(({ mesh }) => mesh === meshRecord.mesh)
if (!mVis.every(m => m.visible)) {
/**
* translate vertex indices to label indicies via set, to remove duplicates
*/
const labelIndexSet = new Set<number>()
for (const idx of evVerticesIndicies){
const labelOfInterest = labelIndexRecord.vertexIndices[idx]
/**
* decode label index to region
*/
if (labelIndexSet.size === 0) {
return this.handleMouseoverEvent(custEv)
}
for (const labelIndex of Array.from(labelIndexSet)) {
if (!regionRecord[labelIndex]) {
custEv.error = `${custEv.error || ''} Cannot decode label index ${labelIndex}`
continue
const region = regionRecord[labelIndex]
custEv.regions.push(region)
}
/**
* return handle event
*/
return this.handleMouseoverEvent(custEv)
}
const customEvHandler = (ev: CustomEvent) => {
const { type, data } = ev.detail
if (type === 'mouseover') {
this.#mouseEv$.next(data)
return
if (this.internalStateNext) {
this.internalStateNext({
"@id": getUuid(),
"@type": 'TViewerInternalStateEmitterEvent',
viewerType,
payload: {
mode: '',
camera: data.position,
hemisphere: 'both'
}
})
}
this.#cameraEv$.next(data)
return
}
}
this.domEl.addEventListener((window as any).ThreeSurfer.CUSTOM_EVENTNAME_UPDATED, customEvHandler)
() => this.domEl.removeEventListener((window as any).ThreeSurfer.CUSTOM_EVENTNAME_UPDATED, customEvHandler)
this.tsRef = new (window as any).ThreeSurfer(this.domEl, {highlightHovered: true})
this.onDestroyCb.push(
() => {
this.tsRef.dispose()
this.tsRef = null
}
)
this.tsRef.control.enablePan = false
while (this.tsRefInitCb.length > 0) {
const tsCb = this.tsRefInitCb.pop()
tsCb(this.tsRef)
}
const meshSub = this.#meshLayers$.pipe(
switchMap(
switchMapWaitFor({
condition: () => !!this.tsRef,
leading: true
})
),
withLatestFrom(
this.lateralMeshRecord$
)
).subscribe(([layers, currMeshRecord]) => {
this.#loadMeshes(layers, currMeshRecord)
const applyColorSub = this.#applyColor$.subscribe(arg => {
this.applyColor(arg)
const mouseSub = this.#handleCustomMouseEv$.subscribe(arg => {
this.#handleCustomMouseEv(arg)
const visibilitySub = this.meshVisible$.subscribe(arr => {
for (const { visible, mesh } of arr) {
mesh.visible = visible
const meshObj = this.tsRef.customColormap.get(mesh)
if (!meshObj) {
throw new Error(`mesh obj not found!`)
}
meshObj.mesh.visible = visible
}
})
this.onDestroyCb.push(() => {
meshSub.unsubscribe()
applyColorSub.unsubscribe()
mouseSub.unsubscribe()
visibilitySub.unsubscribe()
})
this.viewerEvent.emit({
type: EnumViewerEvt.VIEWERLOADED,
data: true
})
private handleMouseoverEvent(ev: THandlingCustomEv){
const { regions: mouseover, evMesh, error } = ev
this.viewerEvent.emit({
type: EnumViewerEvt.VIEWER_CTX,
data: {
viewerType: 'threeSurfer',
payload: {
faceIndex: evMesh?.faceIndex,
vertexIndices: evMesh?.verticesIndicies,
position: [],
this.mouseoverText = ''
if (mouseover.length > 0) {
this.mouseoverText += mouseover.map(el => el.name).join(' / ')
}
if (error) {
this.mouseoverText += `::error: ${error}`
}
if (this.mouseoverText === '') this.mouseoverText = null
public toggleMeshVis(label: string) {
this.#meshVisOp$.next({
label,
op: 'toggle'
})
private onDestroyCb: (() => void) [] = []
while (this.onDestroyCb.length > 0) this.onDestroyCb.pop()()
}
}