@niivue/niivue
Version:
minimal webgl2 nifti image viewer
858 lines (773 loc) • 26 kB
text/typescript
// nvdocument.ts (updated)
// --- imports
import { vec3, vec4 } from 'gl-matrix'
import { NVSerializer } from '@/nvserializer' // adjust path if needed
import { NVUtilities } from '@/nvutilities'
import { ImageFromUrlOptions, NVIMAGE_TYPE, NVImage } from '@/nvimage'
import { NVMesh } from '@/nvmesh'
import { NVLabel3D } from '@/nvlabel'
import { NVConnectome } from '@/nvconnectome'
/**
* Represents a completed measurement between two points
*/
export interface CompletedMeasurement {
startMM: vec3 // World coordinates in mm for start point
endMM: vec3 // World coordinates in mm for end point
distance: number // Distance between points in mm
sliceIndex: number
sliceType: SLICE_TYPE
slicePosition: number
}
/**
* Represents a completed angle measurement between two lines
*/
export interface CompletedAngle {
firstLineMM: { start: vec3; end: vec3 } // World coordinates in mm for first line
secondLineMM: { start: vec3; end: vec3 } // World coordinates in mm for second line
angle: number // Angle in degrees
sliceIndex: number
sliceType: SLICE_TYPE
slicePosition: number
}
/**
* Slice Type
* @ignore
*/
export enum SLICE_TYPE {
AXIAL = 0,
CORONAL = 1,
SAGITTAL = 2,
MULTIPLANAR = 3,
RENDER = 4
}
export enum PEN_TYPE {
PEN = 0,
RECTANGLE = 1,
ELLIPSE = 2
}
export enum SHOW_RENDER {
NEVER = 0,
ALWAYS = 1,
AUTO = 2
}
/**
* Multi-planar layout
* @ignore
*/
export enum MULTIPLANAR_TYPE {
AUTO = 0,
COLUMN = 1,
GRID = 2,
ROW = 3
}
/**
* Drag mode
* @ignore
*/
export enum DRAG_MODE {
none = 0,
contrast = 1,
measurement = 2,
pan = 3,
slicer3D = 4,
callbackOnly = 5,
roiSelection = 6,
angle = 7,
crosshair = 8,
windowing = 9
}
export interface MouseEventConfig {
leftButton: {
primary: DRAG_MODE
withShift?: DRAG_MODE
withCtrl?: DRAG_MODE
}
rightButton: DRAG_MODE
centerButton: DRAG_MODE
}
export interface TouchEventConfig {
singleTouch: DRAG_MODE
doubleTouch: DRAG_MODE
}
/**
* NVConfigOptions
*/
export type NVConfigOptions = {
// ... (kept unchanged for brevity — same as your original file)
textHeight: number
fontSizeScaling: number
fontMinPx: number
colorbarHeight: number
colorbarWidth: number
showColorbarBorder: boolean
crosshairWidth: number
crosshairWidthUnit: 'voxels' | 'mm' | 'percent'
crosshairGap: number
rulerWidth: number
show3Dcrosshair: boolean
backColor: number[]
crosshairColor: number[]
fontColor: Float32List
selectionBoxColor: number[]
clipPlaneColor: number[]
isClipPlanesCutaway: boolean
isClipAllVolumes: boolean
paqdUniforms: number[]
rulerColor: number[]
colorbarMargin: number
trustCalMinMax: boolean
clipPlaneHotKey: string
cycleClipPlaneHotKey: string
viewModeHotKey: string
doubleTouchTimeout: number
longTouchTimeout: number
keyDebounceTime: number
isNearestInterpolation: boolean
atlasOutline: number
atlasActiveIndex: number
isRuler: boolean
isColorbar: boolean
isOrientCube: boolean
tileMargin: number
multiplanarPadPixels: number
multiplanarForceRender: boolean
multiplanarEqualSize: boolean
multiplanarShowRender: SHOW_RENDER
isRadiologicalConvention: boolean
meshThicknessOn2D: number | string
dragMode: DRAG_MODE
dragModePrimary: DRAG_MODE
mouseEventConfig?: MouseEventConfig
touchEventConfig?: TouchEventConfig
yoke3Dto2DZoom: boolean
isDepthPickMesh: boolean
isCornerOrientationText: boolean
isOrientationTextVisible: boolean
showAllOrientationMarkers: boolean
heroImageFraction: number
heroSliceType: SLICE_TYPE
sagittalNoseLeft: boolean
isSliceMM: boolean
isV1SliceShader: boolean
forceDevicePixelRatio: number
logLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent'
loadingText: string
isForceMouseClickToVoxelCenters: boolean
dragAndDropEnabled: boolean
drawingEnabled: boolean
penValue: number
penType: PEN_TYPE
floodFillNeighbors: number
isFilledPen: boolean
thumbnail: string
maxDrawUndoBitmaps: number
sliceType: SLICE_TYPE
isAntiAlias: boolean | null
isAdditiveBlend: boolean
isResizeCanvas: boolean
meshXRay: number
limitFrames4D: number
showLegend: boolean
legendBackgroundColor: number[]
legendTextColor: number[]
multiplanarLayout: MULTIPLANAR_TYPE
renderOverlayBlend: number
sliceMosaicString: string
centerMosaic: boolean
interactive: boolean
penSize: number
clickToSegment: boolean
clickToSegmentRadius: number
clickToSegmentBright: boolean
clickToSegmentAutoIntensity: boolean
clickToSegmentIntensityMax: number
clickToSegmentIntensityMin: number
clickToSegmentPercent: number
clickToSegmentMaxDistanceMM: number
clickToSegmentIs2D: boolean
selectionBoxLineThickness: number
selectionBoxIsOutline: boolean
scrollRequiresFocus: boolean
showMeasureUnits: boolean
measureTextJustify: 'start' | 'center' | 'end'
measureTextColor: number[]
measureLineColor: number[]
measureTextHeight: number
isAlphaClipDark: boolean
gradientOrder: number
gradientOpacity: number
renderSilhouette: number
gradientAmount: number
invertScrollDirection: boolean
is2DSliceShader: boolean
bounds: [[number, number], [number, number]] | null
showBoundsBorder?: boolean
boundsBorderColor?: number[] // [r,g,b,a]
windowingGainFactor: number
// Zarr options
/** Chunk cache size for zarr viewing (default 500) */
zarrCacheSize: number
/** Number of chunk rings to prefetch around the visible region for zarr viewing (0 disables, default 1) */
zarrPrefetchRings: number
/** Smooth drawing surfaces in 3D rendering. 0 = off, > 0 = Box blur radius in voxels (default 0) */
smoothDrawing: number
}
export const DEFAULT_OPTIONS: NVConfigOptions = {
// ... (same defaults as your original file)
textHeight: -1.0,
fontSizeScaling: 0.4,
fontMinPx: 13,
colorbarHeight: 0.05,
colorbarWidth: -1,
showColorbarBorder: true,
crosshairWidth: 1,
crosshairWidthUnit: 'voxels',
crosshairGap: 0,
rulerWidth: 4,
show3Dcrosshair: false,
backColor: [0, 0, 0, 1],
crosshairColor: [1, 0, 0, 1],
fontColor: [0.5, 0.5, 0.5, 1],
selectionBoxColor: [1, 1, 1, 0.5],
clipPlaneColor: [0.7, 0, 0.7, 0.5],
isClipPlanesCutaway: false,
isClipAllVolumes: false,
paqdUniforms: [0.3, 0.5, 0.5, 1.0],
rulerColor: [1, 0, 0, 0.8],
colorbarMargin: 0.05,
trustCalMinMax: true,
clipPlaneHotKey: 'KeyC',
cycleClipPlaneHotKey: 'KeyP',
viewModeHotKey: 'KeyV',
doubleTouchTimeout: 500,
longTouchTimeout: 1000,
keyDebounceTime: 50,
isNearestInterpolation: false,
isResizeCanvas: true,
atlasOutline: 0,
atlasActiveIndex: 0,
isRuler: false,
isColorbar: false,
isOrientCube: false,
tileMargin: 0,
multiplanarPadPixels: 0,
multiplanarForceRender: false,
multiplanarEqualSize: false,
multiplanarShowRender: SHOW_RENDER.AUTO,
isRadiologicalConvention: false,
meshThicknessOn2D: Infinity,
dragMode: DRAG_MODE.contrast,
dragModePrimary: DRAG_MODE.crosshair,
mouseEventConfig: undefined,
touchEventConfig: undefined,
yoke3Dto2DZoom: false,
isDepthPickMesh: false,
isCornerOrientationText: false,
isOrientationTextVisible: true,
showAllOrientationMarkers: false,
heroImageFraction: 0,
heroSliceType: SLICE_TYPE.RENDER,
sagittalNoseLeft: false,
isSliceMM: false,
isV1SliceShader: false,
forceDevicePixelRatio: 0,
logLevel: 'info',
loadingText: 'loading ...',
isForceMouseClickToVoxelCenters: false,
dragAndDropEnabled: true,
drawingEnabled: false,
penValue: 1,
penType: PEN_TYPE.PEN,
floodFillNeighbors: 6,
isFilledPen: false,
thumbnail: '',
maxDrawUndoBitmaps: 8,
sliceType: SLICE_TYPE.MULTIPLANAR,
meshXRay: 0.0,
isAntiAlias: null,
limitFrames4D: NaN,
isAdditiveBlend: false,
showLegend: true,
legendBackgroundColor: [0.3, 0.3, 0.3, 0.5],
legendTextColor: [1.0, 1.0, 1.0, 1.0],
multiplanarLayout: MULTIPLANAR_TYPE.AUTO,
renderOverlayBlend: 1.0,
sliceMosaicString: '',
centerMosaic: false,
penSize: 1,
interactive: true,
clickToSegment: false,
clickToSegmentRadius: 3,
clickToSegmentBright: true,
clickToSegmentAutoIntensity: false,
clickToSegmentIntensityMax: NaN,
clickToSegmentIntensityMin: NaN,
clickToSegmentPercent: 0,
clickToSegmentMaxDistanceMM: Number.POSITIVE_INFINITY,
clickToSegmentIs2D: false,
selectionBoxLineThickness: 4,
selectionBoxIsOutline: false,
scrollRequiresFocus: false,
showMeasureUnits: true,
measureTextJustify: 'center',
measureTextColor: [1, 0, 0, 1],
measureLineColor: [1, 0, 0, 1],
measureTextHeight: 0.06,
isAlphaClipDark: false,
gradientOrder: 1,
gradientOpacity: 0.0,
renderSilhouette: 0.0,
gradientAmount: 0.0,
invertScrollDirection: false,
is2DSliceShader: false,
bounds: null,
showBoundsBorder: false,
boundsBorderColor: [1, 1, 1, 1], // white border by default
windowingGainFactor: 2,
// Zarr options
zarrCacheSize: 1000,
zarrPrefetchRings: 10,
smoothDrawing: 0
}
//
// -- NEW: Recursive encoded type for NVConfigOptions JSON-safe form
//
type EncodeNumbersIn<T> = T extends number ? number | string : T extends Array<infer U> ? Array<EncodeNumbersIn<U>> : T extends object ? { [K in keyof T]: EncodeNumbersIn<T[K]> } : T
type EncodedNVConfigOptions = EncodeNumbersIn<NVConfigOptions>
//
// Utility encode/decode helpers
//
export const DEFAULT_SCENE_DATA = {} // placeholder if needed elsewhere (kept for completeness)
type SceneData = {
gamma: number
azimuth: number
elevation: number
crosshairPos: vec3
clipPlanes: number[][]
clipPlaneDepthAziElevs: number[][]
volScaleMultiplier: number
pan2Dxyzmm: vec4
}
export const INITIAL_SCENE_DATA = {
gamma: 1.0,
azimuth: 110,
elevation: 10,
crosshairPos: vec3.fromValues(0.5, 0.5, 0.5),
clipPlanes: [[0, 0, 0, 0]],
clipPlaneDepthAziElevs: [[2, 0, 0]],
volScaleMultiplier: 1.0,
pan2Dxyzmm: vec4.fromValues(0, 0, 0, 1)
}
export type Scene = {
onAzimuthElevationChange: (azimuth: number, elevation: number) => void
onZoom3DChange: (scale: number) => void
sceneData: SceneData
renderAzimuth: number
renderElevation: number
volScaleMultiplier: number
crosshairPos: vec3
clipPlane: number[]
clipPlanes: number[][]
clipPlaneDepthAziElevs: number[][]
pan2Dxyzmm: vec4
_elevation?: number
_azimuth?: number
gamma?: number
}
/**
* DocumentData / ExportDocumentData types (kept minimal here)
*/
export type DocumentData = {
title?: string
imageOptionsArray?: ImageFromUrlOptions[]
meshOptionsArray?: unknown[]
opts?: Partial<EncodedNVConfigOptions> | Partial<NVConfigOptions>
previewImageDataURL?: string
labels?: NVLabel3D[]
encodedImageBlobs?: string[]
encodedDrawingBlob?: string
meshesString?: string
sceneData?: Partial<SceneData>
connectomes?: string[]
customData?: string
completedMeasurements?: CompletedMeasurement[]
completedAngles?: CompletedAngle[]
}
export type ExportDocumentData = {
title?: string
encodedImageBlobs: string[]
encodedDrawingBlob: string
previewImageDataURL: string
imageOptionsMap: Map<string, number>
imageOptionsArray: ImageFromUrlOptions[]
sceneData: Partial<SceneData>
opts: EncodedNVConfigOptions | Partial<EncodedNVConfigOptions>
meshesString: string
meshOptionsArray?: unknown[]
labels: NVLabel3D[]
connectomes: string[]
customData: string
completedMeasurements: CompletedMeasurement[]
completedAngles: CompletedAngle[]
}
/**
* Returns a partial configuration object containing only the fields in the provided
* options that differ from the DEFAULT_OPTIONS.
*/
// function diffOptions(opts: NVConfigOptions, defaults: NVConfigOptions): Partial<NVConfigOptions> {
// const diff: Partial<NVConfigOptions> = {}
// for (const key in opts) {
// const value = opts[key]
// const def = defaults[key]
// const isArray = Array.isArray(value) && Array.isArray(def)
// if ((isArray && value.some((v, i) => v !== def[i])) || (!isArray && value !== def)) {
// diff[key] = value
// }
// }
// return diff
// }
/**
* NVDocument class (main)
*/
export class NVDocument {
data: DocumentData = {
title: 'Untitled document',
imageOptionsArray: [],
meshOptionsArray: [],
opts: { ...DEFAULT_OPTIONS } as any,
previewImageDataURL: '',
labels: [],
encodedImageBlobs: [],
encodedDrawingBlob: ''
}
scene: Scene
volumes: NVImage[] = []
meshDataObjects?: Array<NVMesh | NVConnectome>
meshes: Array<NVMesh | NVConnectome> = []
drawBitmap: Uint8Array | null = null
imageOptionsMap = new Map()
meshOptionsMap = new Map()
completedMeasurements: CompletedMeasurement[] = []
completedAngles: CompletedAngle[] = []
private _optsProxy: NVConfigOptions | null = null
private _optsChangeCallback: ((propertyName: keyof NVConfigOptions, newValue: NVConfigOptions[keyof NVConfigOptions], oldValue: NVConfigOptions[keyof NVConfigOptions]) => void) | null = null
constructor() {
this.scene = {
onAzimuthElevationChange: (): void => {},
onZoom3DChange: (): void => {},
sceneData: {
...INITIAL_SCENE_DATA,
pan2Dxyzmm: vec4.fromValues(0, 0, 0, 1),
crosshairPos: vec3.fromValues(0.5, 0.5, 0.5)
},
get renderAzimuth(): number {
return this.sceneData.azimuth
},
set renderAzimuth(azimuth: number) {
this.sceneData.azimuth = azimuth
if (this.onAzimuthElevationChange) {
this.onAzimuthElevationChange(this.sceneData.azimuth, this.sceneData.elevation)
}
},
get renderElevation(): number {
return this.sceneData.elevation
},
set renderElevation(elevation: number) {
this.sceneData.elevation = elevation
if (this.onAzimuthElevationChange) {
this.onAzimuthElevationChange(this.sceneData.azimuth, this.sceneData.elevation)
}
},
get volScaleMultiplier(): number {
return this.sceneData.volScaleMultiplier
},
set volScaleMultiplier(scale: number) {
this.sceneData.volScaleMultiplier = scale
this.onZoom3DChange(scale)
},
get crosshairPos(): vec3 {
return this.sceneData.crosshairPos
},
set crosshairPos(crosshairPos: vec3) {
this.sceneData.crosshairPos = crosshairPos
},
get clipPlane(): number[] {
return this.sceneData.clipPlanes[0] ?? []
},
set clipPlane(clipPlane) {
this.sceneData.clipPlanes[0] = clipPlane
},
get clipPlanes(): number[][] {
return this.sceneData.clipPlanes
},
set clipPlanes(planes: number[][]) {
this.sceneData.clipPlanes = planes
},
get clipPlaneDepthAziElevs(): number[][] {
return this.sceneData.clipPlaneDepthAziElevs
},
set clipPlaneDepthAziElevs(values: number[][]) {
this.sceneData.clipPlaneDepthAziElevs = values
},
get pan2Dxyzmm(): vec4 {
return this.sceneData.pan2Dxyzmm
},
set pan2Dxyzmm(pan2Dxyzmm) {
this.sceneData.pan2Dxyzmm = pan2Dxyzmm
},
get gamma(): number {
return this.sceneData.gamma
},
set gamma(newGamma) {
this.sceneData.gamma = newGamma
}
}
}
/**
* Title of the document
*/
get title(): string {
return this.data.title
}
/**
* Gets preview image blob
* @returns dataURL of preview image
*/
get previewImageDataURL(): string {
return this.data.previewImageDataURL
}
/**
* Sets preview image blob
* @param dataURL - encoded preview image
*/
set previewImageDataURL(dataURL: string) {
this.data.previewImageDataURL = dataURL
}
/**
* @param title - title of document
*/
set title(title: string) {
this.data.title = title
}
get imageOptionsArray(): ImageFromUrlOptions[] {
return this.data.imageOptionsArray
}
/**
* Gets the base 64 encoded blobs of associated images
*/
get encodedImageBlobs(): string[] {
return this.data.encodedImageBlobs
}
/**
* Gets the base 64 encoded blob of the associated drawing
*/
get encodedDrawingBlob(): string {
return this.data.encodedDrawingBlob
}
/**
* Gets the options of the {@link Niivue} instance
*/
get opts(): NVConfigOptions {
if (!this._optsProxy) {
this._createOptsProxy()
}
return this._optsProxy as NVConfigOptions
}
/**
* Sets the options of the {@link Niivue} instance
*/
set opts(opts) {
this.data.opts = { ...opts } as any
this._optsProxy = null // Force recreation of proxy
}
/**
* Gets the 3D labels of the {@link Niivue} instance
*/
get labels(): NVLabel3D[] {
return this.data.labels
}
/**
* Sets the 3D labels of the {@link Niivue} instance
*/
set labels(labels: NVLabel3D[]) {
this.data.labels = labels
}
get customData(): string | undefined {
return this.data.customData
}
set customData(data: string) {
this.data.customData = data
}
/**
* Checks if document has an image by id
*/
hasImage(image: NVImage): boolean {
return this.volumes.find((i) => i.id === image.id) !== undefined
}
/**
* Checks if document has an image by url
*/
hasImageFromUrl(url: string): boolean {
return this.data.imageOptionsArray.find((i) => i.url === url) !== undefined
}
/**
* Adds an image and the options an image was created with
*/
addImageOptions(image: NVImage, imageOptions: ImageFromUrlOptions): void {
if (!this.hasImage(image)) {
if (!imageOptions.name) {
if (imageOptions.url) {
const absoluteUrlRE = /^(?:[a-z+]+:)?\/\//i
const url = absoluteUrlRE.test(imageOptions.url) ? new URL(imageOptions.url) : new URL(imageOptions.url, window.location.href)
imageOptions.name = url.pathname.split('/').pop()! // TODO guaranteed?
if (imageOptions.name.toLowerCase().endsWith('.gz')) {
imageOptions.name = imageOptions.name.slice(0, -3)
}
if (!imageOptions.name.toLowerCase().endsWith('.nii')) {
imageOptions.name += '.nii'
}
} else {
imageOptions.name = 'untitled.nii'
}
}
}
imageOptions.imageType = NVIMAGE_TYPE.NII
this.data.imageOptionsArray.push(imageOptions)
this.imageOptionsMap.set(image.id, this.data.imageOptionsArray.length - 1)
}
/**
* Removes image from the document as well as its options
*/
removeImage(image: NVImage): void {
if (this.imageOptionsMap.has(image.id)) {
const index = this.imageOptionsMap.get(image.id)
if (this.data.imageOptionsArray.length > index) {
this.data.imageOptionsArray.splice(index, 1)
}
this.imageOptionsMap.delete(image.id)
}
this.volumes = this.volumes.filter((i) => i.id !== image.id)
}
/**
* Fetch any image data that is missing from this document.
*/
async fetchLinkedData(): Promise<void> {
this.data.encodedImageBlobs = []
if (!this.imageOptionsArray?.length) {
return
}
for (const imgOpt of this.imageOptionsArray) {
if (!imgOpt.url) {
continue
}
try {
const response = await fetch(imgOpt.url)
if (!response.ok) {
console.warn('Failed to fetch image:', imgOpt.url)
continue
}
const buffer = await response.arrayBuffer()
const uint8Array = new Uint8Array(buffer)
const b64 = NVUtilities.uint8tob64(uint8Array)
this.data.encodedImageBlobs.push(b64)
console.info('fetch linked data fetched from ', imgOpt.url)
} catch (err) {
console.warn(`Failed to fetch/encode image from ${imgOpt.url}:`, err)
}
}
}
/**
* Returns the options for the image if it was added by url
*/
getImageOptions(image: NVImage): ImageFromUrlOptions | null {
return this.imageOptionsMap.has(image.id) ? this.data.imageOptionsArray[this.imageOptionsMap.get(image.id)] : null
}
/**
* Serialise the document by delegating to NVSerializer.
*/
json(embedImages = true, embedDrawing = true): ExportDocumentData {
// NVSerializer is responsible for converting typed arrays, encoding special numbers,
// producing meshesString, and returning an ExportDocumentData object.
return NVSerializer.serializeDocument(this, embedImages, embedDrawing)
}
async download(fileName: string, compress: boolean, opts: { embedImages: boolean } = { embedImages: true }): Promise<void> {
const data = this.json(opts.embedImages)
const jsonTxt = JSON.stringify(data)
const mime = compress ? 'application/gzip' : 'application/json'
const payload = compress ? await NVUtilities.compressStringToArrayBuffer(jsonTxt) : jsonTxt
NVUtilities.download(payload, fileName, mime)
}
/**
* Factory method to return an instance of NVDocument from a URL
*/
static async loadFromUrl(url: string): Promise<NVDocument> {
const response = await fetch(url)
const buffer = await response.arrayBuffer()
let documentData: DocumentData
if (NVUtilities.isArrayBufferCompressed(buffer)) {
const documentText = await NVUtilities.decompressArrayBuffer(buffer)
documentData = JSON.parse(documentText)
} else {
const utf8decoder = new TextDecoder()
documentData = JSON.parse(utf8decoder.decode(buffer))
}
return NVDocument.loadFromJSON(documentData)
}
static async loadFromFile(file: Blob): Promise<NVDocument> {
const arrayBuffer = await NVUtilities.readFileAsync(file)
let dataString: string
if (NVUtilities.isArrayBufferCompressed(arrayBuffer)) {
dataString = await NVUtilities.decompressArrayBuffer(arrayBuffer)
} else {
const utf8decoder = new TextDecoder()
dataString = utf8decoder.decode(arrayBuffer)
}
const documentData = JSON.parse(dataString) as DocumentData
return NVDocument.loadFromJSON(documentData)
}
/**
* Factory method to return an instance of NVDocument from JSON.
* Delegates the main parsing to NVSerializer, then applies NVDocument-specific
* post-processing (opts decode, scene defaults, clone measurements/angles).
*/
static async loadFromJSON(data: DocumentData): Promise<NVDocument> {
return await NVSerializer.deserializeDocument(data)
}
/**
* Sets the callback function to be called when opts properties change
*/
setOptsChangeCallback(callback: (propertyName: keyof NVConfigOptions, newValue: NVConfigOptions[keyof NVConfigOptions], oldValue: NVConfigOptions[keyof NVConfigOptions]) => void): void {
this._optsChangeCallback = callback
this._optsProxy = null // Force recreation with new callback
}
/**
* Removes the opts change callback
*/
removeOptsChangeCallback(): void {
this._optsChangeCallback = null
this._optsProxy = null // Force recreation without callback
}
/**
* Creates a Proxy wrapper around the opts object to detect changes
*/
private _createOptsProxy(): void {
const target = this.data.opts as NVConfigOptions
this._optsProxy = new Proxy(target, {
set: (obj: any, prop: string | symbol, value: any): boolean => {
const oldValue = obj[prop]
// Only proceed if the value actually changed
if (oldValue !== value) {
obj[prop] = value
// Call the change callback if one is registered
if (this._optsChangeCallback && typeof prop === 'string' && prop in DEFAULT_OPTIONS) {
this._optsChangeCallback(prop as keyof NVConfigOptions, value, oldValue)
}
}
return true
},
get: (obj: any, prop: string | symbol): any => {
return obj[prop]
}
})
}
}