diff --git a/src/apps/viewer/index.html b/src/apps/viewer/index.html new file mode 100644 index 0000000000000000000000000000000000000000..e65ee3281561d7e5cf111c03740d03ffa38072fb --- /dev/null +++ b/src/apps/viewer/index.html @@ -0,0 +1,33 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0"> + <title>Mol* Viewer</title> + <style> + * { + margin: 0; + padding: 0; + } + html, body { + width: 100%; + height: 100%; + overflow: hidden; + } + hr { + margin: 10px; + } + h1, h2, h3, h4, h5 { + margin-top: 5px; + margin-bottom: 3px; + } + button { + padding: 2px; + } + </style> + </head> + <body> + <div id="app" style="position: absolute; width: 100%; height: 100%"></div> + <script type="text/javascript" src="./index.js"></script> + </body> +</html> \ No newline at end of file diff --git a/src/apps/viewer/index.ts b/src/apps/viewer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d27e1d06ee511f99acc28aa67e6b241f2d877756 --- /dev/null +++ b/src/apps/viewer/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import { createPlugin } from 'mol-plugin'; +import './index.html' + +createPlugin(document.getElementById('app')!); \ No newline at end of file diff --git a/src/mol-geo/representation/structure/visual/polymer-trace-mesh.ts b/src/mol-geo/representation/structure/visual/polymer-trace-mesh.ts index 4a7bc8dde31d8f678ab5c899bdf296749549299e..a4bf1e8425c592991b4bcd94a795e03f8780dd94 100644 --- a/src/mol-geo/representation/structure/visual/polymer-trace-mesh.ts +++ b/src/mol-geo/representation/structure/visual/polymer-trace-mesh.ts @@ -33,8 +33,8 @@ export type PolymerTraceMeshProps = typeof DefaultPolymerTraceMeshProps async function createPolymerTraceMesh(ctx: RuntimeContext, unit: Unit, structure: Structure, props: PolymerTraceMeshProps, mesh?: Mesh) { const polymerElementCount = unit.polymerElements.length - if (!polymerElementCount) return Mesh.createEmpty(mesh) + if (!polymerElementCount) return Mesh.createEmpty(mesh) const sizeTheme = SizeTheme({ name: props.sizeTheme, value: props.sizeValue, factor: props.sizeFactor }) const { linearSegments, radialSegments, aspectRatio, arrowFactor } = props diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts index 23ec374b12668e0ef431ad16daa8b490d203ae73..9c98a4235abc32a90308f2618a70d30adce64dfe 100644 --- a/src/mol-plugin/context.ts +++ b/src/mol-plugin/context.ts @@ -4,18 +4,103 @@ * @author David Sehnal <david.sehnal@gmail.com> */ -import { State } from 'mol-state'; +import { State, StateTree, StateSelection, Transformer } from 'mol-state'; import Viewer from 'mol-canvas3d/viewer'; +import { StateTransforms } from './state/transforms'; +import { Subject } from 'rxjs'; +import { PluginStateObjects as SO } from './state/objects'; export class PluginContext { state = { - data: State, - behaviour: State, - plugin: State + data: State.create(new SO.Root({ label: 'Root' }, { })), + // behaviour: State, + // plugin: State + }; + + // TODO: better events + events = { + stateUpdated: new Subject<undefined>() }; viewer: Viewer; + initViewer(canvas: HTMLCanvasElement, container: HTMLDivElement) { + try { + this.viewer = Viewer.create(canvas, container); + this.viewer.animate(); + console.log('viewer created'); + return true; + } catch (e) { + console.error(e); + return false; + } + } + + _test_createState(url: string) { + const b = StateTree.build(this.state.data.tree); + const newTree = b.toRoot() + .apply(StateTransforms.Data.Download, { url }) + .apply(StateTransforms.Data.ParseCif) + .apply(StateTransforms.Model.CreateModelsFromMmCif, {}, { ref: 'models' }) + .apply(StateTransforms.Model.CreateStructureFromModel, { modelIndex: 0 }, { ref: 'structure' }) + .apply(StateTransforms.Visuals.CreateStructureRepresentation) + .getTree(); + + this._test_updateStateData(newTree); + } + + async _test_updateStateData(tree: StateTree) { + const newState = await State.update(this.state.data, tree).run(p => console.log(p), 250); + this.state.data = newState; + console.log(newState); + this.events.stateUpdated.next(); + } + + private initEvents() { + this.state.data.context.events.object.created.subscribe(o => { + if (!SO.StructureRepresentation3D.is(o.obj)) return; + console.log('adding repr', o.obj.data.repr); + this.viewer.add(o.obj.data.repr); + this.viewer.requestDraw(true); + }); + this.state.data.context.events.object.updated.subscribe(o => { + const oo = o.obj; + if (!SO.StructureRepresentation3D.is(oo)) return; + console.log('adding repr', oo.data.repr); + this.viewer.add(oo.data.repr); + this.viewer.requestDraw(true); + }); + } + + _test_centerView() { + const sel = StateSelection.select('structure', this.state.data); + const center = (sel[0].obj! as SO.Structure).data.boundary.sphere.center; + console.log({ sel, center, rc: this.viewer.reprCount }); + this.viewer.center(center); + this.viewer.requestDraw(true); + } + + _test_nextModel() { + const models = StateSelection.select('models', this.state.data)[0].obj as SO.Models; + const idx = (this.state.data.tree.getValue('structure')!.params as Transformer.Params<typeof StateTransforms.Model.CreateStructureFromModel>).modelIndex; + console.log({ idx }); + const newTree = StateTree.updateParams(this.state.data.tree, 'structure', { modelIndex: (idx + 1) % models.data.length }); + return this._test_updateStateData(newTree); + // this.viewer.requestDraw(true); + } + + _test_playModels() { + const update = async () => { + await this._test_nextModel(); + setTimeout(update, 1000 / 15); + } + update(); + } + + constructor() { + this.initEvents(); + } + // logger = ; // settings = ; } \ No newline at end of file diff --git a/src/mol-plugin/index.ts b/src/mol-plugin/index.ts index 7c7fe83382a744be37fe5118239b71702aebc60d..203eb0c8e0a6569382277d22cfd3d38ae95f10c7 100644 --- a/src/mol-plugin/index.ts +++ b/src/mol-plugin/index.ts @@ -4,4 +4,13 @@ * @author David Sehnal <david.sehnal@gmail.com> */ -// TODO \ No newline at end of file +import { PluginContext } from './context'; +import { Plugin } from './ui/plugin' +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +export function createPlugin(target: HTMLElement): PluginContext { + const ctx = new PluginContext(); + ReactDOM.render(React.createElement(Plugin, { plugin: ctx }), target); + return ctx; +} \ No newline at end of file diff --git a/src/mol-plugin/state/base.ts b/src/mol-plugin/state/base.ts index 6ca7afee9ce307e8a61d998b8e04addb02ad3016..710e8079bc2f771087c5a1f73063c69239305231 100644 --- a/src/mol-plugin/state/base.ts +++ b/src/mol-plugin/state/base.ts @@ -11,9 +11,9 @@ export type TypeClass = 'root' | 'data' | 'prop' export namespace PluginStateObject { export type TypeClass = 'Root' | 'Group' | 'Data' | 'Object' | 'Representation' | 'Behaviour' export interface TypeInfo { name: string, shortName: string, description: string, typeClass: TypeClass } - export interface PluginStateObjectProps { label: string } + export interface Props { label: string, desctiption?: string } - export const Create = StateObject.factory<TypeInfo, PluginStateObjectProps>(); + export const Create = StateObject.factory<TypeInfo, Props>(); } export namespace PluginStateTransform { diff --git a/src/mol-plugin/state/types.ts b/src/mol-plugin/state/objects.ts similarity index 84% rename from src/mol-plugin/state/types.ts rename to src/mol-plugin/state/objects.ts index 1f0b3ecefa466003528bf80a72c8b27f609675e7..6550334a3c43ba9db8f3c1516dc94fcc35b51300 100644 --- a/src/mol-plugin/state/types.ts +++ b/src/mol-plugin/state/objects.ts @@ -7,6 +7,7 @@ import { PluginStateObject } from './base'; import { CifFile } from 'mol-io/reader/cif'; import { Model as _Model, Structure as _Structure } from 'mol-model/structure' +import { StructureRepresentation } from 'mol-geo/representation/structure'; const _create = PluginStateObject.Create @@ -26,12 +27,14 @@ namespace PluginStateObjects { // }>({ name: 'Data', typeClass: 'Data', shortName: 'MD', description: 'Multiple Keyed Data.' }) { } } - export class Model extends _create<_Model>({ name: 'Molecule Model', typeClass: 'Object', shortName: 'M_M', description: 'A model of a molecule.' }) { } + export class Models extends _create<ReadonlyArray<_Model>>({ name: 'Molecule Model', typeClass: 'Object', shortName: 'M_M', description: 'A model of a molecule.' }) { } export class Structure extends _create<_Structure>({ name: 'Molecule Structure', typeClass: 'Object', shortName: 'M_S', description: 'A structure of a molecule.' }) { } - export class StructureRepresentation extends _create<{ + export class StructureRepresentation3D extends _create<{ + repr: StructureRepresentation<any>, // TODO + // props }>({ name: 'Molecule Structure Representation', typeClass: 'Representation', shortName: 'S_R', description: 'A representation of a molecular structure.' }) { } } diff --git a/src/mol-plugin/state/transforms.ts b/src/mol-plugin/state/transforms.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2b27831faceaef6ca37bcefe0e530a559598134 --- /dev/null +++ b/src/mol-plugin/state/transforms.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import * as Data from './transforms/data' +import * as Model from './transforms/model' +import * as Visuals from './transforms/visuals' + +export const StateTransforms = { + Data, + Model, + Visuals +} \ No newline at end of file diff --git a/src/mol-plugin/state/transforms/data.ts b/src/mol-plugin/state/transforms/data.ts index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..e405712b13100f6db92680ab42a001a85f74cc80 100644 --- a/src/mol-plugin/state/transforms/data.ts +++ b/src/mol-plugin/state/transforms/data.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import { PluginStateTransform } from '../base'; +import { PluginStateObjects as SO } from '../objects'; +import { Task } from 'mol-task'; +import CIF from 'mol-io/reader/cif' + +export const Download = PluginStateTransform.Create<SO.Root, SO.Data.String | SO.Data.Binary, { url: string, isBinary?: boolean, label?: string }>({ + name: 'download', + from: [SO.Root], + to: [SO.Data.String, SO.Data.Binary], + apply({ params: p }) { + return Task.create('Download', async ctx => { + // TODO: track progress + const req = await fetch(p.url); + return p.isBinary + ? new SO.Data.Binary({ label: p.label ? p.label : p.url }, new Uint8Array(await req.arrayBuffer())) + : new SO.Data.String({ label: p.label ? p.label : p.url }, await req.text()); + }); + } +}); + +export const ParseCif = PluginStateTransform.Create<SO.Data.String | SO.Data.Binary, SO.Data.Cif, { }>({ + name: 'parse-cif', + from: [SO.Data.String, SO.Data.Binary], + to: [SO.Data.Cif], + apply({ a }) { + return Task.create('Parse CIF', async ctx => { + const parsed = await (SO.Data.String.is(a) ? CIF.parse(a.data) : CIF.parseBinary(a.data)).runInContext(ctx); + if (parsed.isError) throw new Error(parsed.message); + return new SO.Data.Cif({ label: 'CIF File' }, parsed.result); + }); + } +}); \ No newline at end of file diff --git a/src/mol-plugin/state/transforms/model.ts b/src/mol-plugin/state/transforms/model.ts index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..419894e9d7e954744d3fb665f1c605f526dad2f5 100644 --- a/src/mol-plugin/state/transforms/model.ts +++ b/src/mol-plugin/state/transforms/model.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import { PluginStateTransform } from '../base'; +import { PluginStateObjects as SO } from '../objects'; +import { Task } from 'mol-task'; +import { Model, Format, Structure } from 'mol-model/structure'; + +export const CreateModelsFromMmCif = PluginStateTransform.Create<SO.Data.Cif, SO.Models, { blockHeader?: string }>({ + name: 'create-models-from-mmcif', + from: [SO.Data.Cif], + to: [SO.Models], + defaultParams: a => ({ blockHeader: a.data.blocks[0].header }), + apply({ a, params }) { + return Task.create('Parse mmCIF', async ctx => { + const header = params.blockHeader || a.data.blocks[0].header; + const block = a.data.blocks.find(b => b.header === header); + if (!block) throw new Error(`Data block '${[header]}' not found.`); + const models = await Model.create(Format.mmCIF(block)).runInContext(ctx); + if (models.length === 0) throw new Error('No models found.'); + const label = models.length === 1 ? `${models[0].label}` : `${models[0].label} (${models.length} models)`; + return new SO.Models({ label }, models); + }); + } +}); + +export const CreateStructureFromModel = PluginStateTransform.Create<SO.Models, SO.Structure, { modelIndex: number }>({ + name: 'structure-from-model', + from: [SO.Models], + to: [SO.Structure], + defaultParams: () => ({ modelIndex: 0 }), + apply({ a, params }) { + if (params.modelIndex < 0 || params.modelIndex >= a.data.length) throw new Error(`Invalid modelIndex ${params.modelIndex}`); + // TODO: make Structure.ofModel async? + const s = Structure.ofModel(a.data[params.modelIndex]); + return new SO.Structure({ label: `${a.data[params.modelIndex].label} (model ${s.models[0].modelNum})`, desctiption: s.elementCount === 1 ? '1 element' : `${s.elementCount} elements` }, s); + } +}); \ No newline at end of file diff --git a/src/mol-plugin/state/transforms/visuals.ts b/src/mol-plugin/state/transforms/visuals.ts index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..b1c521865dadc512fb63915ed93f1fc225edc84f 100644 --- a/src/mol-plugin/state/transforms/visuals.ts +++ b/src/mol-plugin/state/transforms/visuals.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import { CartoonRepresentation, DefaultCartoonProps } from 'mol-geo/representation/structure/representation/cartoon'; +import { Transformer } from 'mol-state'; +import { Task } from 'mol-task'; +import { PluginStateTransform } from '../base'; +import { PluginStateObjects as SO } from '../objects'; + +export const CreateStructureRepresentation = PluginStateTransform.Create<SO.Structure, SO.StructureRepresentation3D, { }>({ + name: 'create-structure-representation', + from: [SO.Structure], + to: [SO.StructureRepresentation3D], + defaultParams: () => ({ modelIndex: 0 }), + apply({ a, params }) { + return Task.create('Structure Representation', async ctx => { + const repr = CartoonRepresentation(); + await repr.createOrUpdate({ ...DefaultCartoonProps }, a.data).runInContext(ctx); + return new SO.StructureRepresentation3D({ label: 'Cartoon' }, { repr }); + }); + }, + update({ a, b }) { + return Task.create('Structure Representation', async ctx => { + await b.data.repr.createOrUpdate(b.data.repr.props, a.data).runInContext(ctx); + return Transformer.UpdateResult.Updated; + }); + } +}); \ No newline at end of file diff --git a/src/mol-plugin/ui/controls.tsx b/src/mol-plugin/ui/controls.tsx new file mode 100644 index 0000000000000000000000000000000000000000..33b01ff280ab637a4ccd4b459a5932ceb4f7abf6 --- /dev/null +++ b/src/mol-plugin/ui/controls.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import * as React from 'react'; +import { PluginContext } from '../context'; + +export class Controls extends React.Component<{ plugin: PluginContext }, { id: string }> { + state = { id: '1grm' }; + + private createState = () => { + const url = `http://www.ebi.ac.uk/pdbe/static/entry/${this.state.id.toLowerCase()}_updated.cif`; + // const url = `https://webchem.ncbr.muni.cz/CoordinateServer/${this.state.id.toLowerCase()}/full` + this.props.plugin._test_createState(url); + } + + render() { + return <div> + <input type='text' defaultValue={this.state.id} onChange={e => this.setState({ id: e.currentTarget.value })} /> + <button onClick={this.createState}>Create State</button><br/> + <button onClick={() => this.props.plugin._test_centerView()}>Center View</button><br/> + <button onClick={() => this.props.plugin._test_nextModel()}>Next Model</button><br/> + <button onClick={() => this.props.plugin._test_playModels()}>Play Models</button><br/> + </div>; + } +} \ No newline at end of file diff --git a/src/mol-plugin/ui/plugin.tsx b/src/mol-plugin/ui/plugin.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b5a301b8560cde72e5ecfffc512a9ddafc39da9d --- /dev/null +++ b/src/mol-plugin/ui/plugin.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import * as React from 'react'; +import { PluginContext } from '../context'; +import { Tree } from './tree'; +import { Viewport } from './viewport'; +import { Controls } from './controls'; + +export class Plugin extends React.Component<{ plugin: PluginContext }, { }> { + render() { + return <div style={{ position: 'absolute', width: '100%', height: '100%' }}> + <div style={{ position: 'absolute', width: '250px', height: '100%' }}> + <Tree plugin={this.props.plugin} /> + </div> + <div style={{ position: 'absolute', left: '250px', right: '250px', height: '100%' }}> + <Viewport plugin={this.props.plugin} /> + </div> + <div style={{ position: 'absolute', width: '250px', right: '0', height: '100%' }}> + <Controls plugin={this.props.plugin} /> + </div> + </div>; + } +} \ No newline at end of file diff --git a/src/mol-plugin/ui/tree.tsx b/src/mol-plugin/ui/tree.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0a62f2ca25b4b9431c0ec1e253e7db190a99eea0 --- /dev/null +++ b/src/mol-plugin/ui/tree.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import * as React from 'react'; +import { PluginContext } from '../context'; +import { PluginStateObject } from 'mol-plugin/state/base'; + +export class Tree extends React.Component<{ plugin: PluginContext }, { }> { + + componentWillMount() { + this.props.plugin.events.stateUpdated.subscribe(() => this.forceUpdate()); + } + render() { + const n = this.props.plugin.state.data.tree.nodes.get(this.props.plugin.state.data.tree.rootRef)!; + return <div> + {n.children.map(c => <TreeNode plugin={this.props.plugin} nodeRef={c!} key={c} />)} + </div>; + } +} + +export class TreeNode extends React.Component<{ plugin: PluginContext, nodeRef: string }, { }> { + render() { + const n = this.props.plugin.state.data.tree.nodes.get(this.props.nodeRef)!; + const obj = this.props.plugin.state.data.objects.get(this.props.nodeRef)!; + return <div style={{ borderLeft: '1px solid black', paddingLeft: '5px' }}> + {(obj.obj!.props as PluginStateObject.Props).label} + {n.children.size === 0 + ? void 0 + : <div style={{ marginLeft: '10px' }}>{n.children.map(c => <TreeNode plugin={this.props.plugin} nodeRef={c!} key={c} />)}</div> + } + </div>; + } +} \ No newline at end of file diff --git a/src/mol-plugin/ui/viewport.tsx b/src/mol-plugin/ui/viewport.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fa904d12eef74b4d7b5e4d20f8e0f3af83db03bb --- /dev/null +++ b/src/mol-plugin/ui/viewport.tsx @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Alexander Rose <alexander.rose@weirdbyte.de> + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import * as React from 'react'; +import { PluginContext } from '../context'; +import { Loci, EmptyLoci, areLociEqual } from 'mol-model/loci'; +import { MarkerAction } from 'mol-geo/geometry/marker-data'; + +interface ViewportProps { + plugin: PluginContext +} + +interface ViewportState { + noWebGl: boolean +} + +export class Viewport extends React.Component<ViewportProps, ViewportState> { + private container: HTMLDivElement | null = null; + private canvas: HTMLCanvasElement | null = null; + + state: ViewportState = { + noWebGl: false + }; + + handleResize() { + this.props.plugin.viewer.handleResize(); + } + + componentDidMount() { + if (!this.canvas || !this.container || !this.props.plugin.initViewer(this.canvas, this.container)) { + this.setState({ noWebGl: true }); + } + this.handleResize(); + + const viewer = this.props.plugin.viewer; + viewer.input.resize.subscribe(() => this.handleResize()); + + let prevLoci: Loci = EmptyLoci; + viewer.input.move.subscribe(({x, y, inside, buttons}) => { + if (!inside || buttons) return; + const p = viewer.identify(x, y); + if (p) { + const loci = viewer.getLoci(p); + + if (!areLociEqual(loci, prevLoci)) { + viewer.mark(prevLoci, MarkerAction.RemoveHighlight); + viewer.mark(loci, MarkerAction.Highlight); + prevLoci = loci; + } + } + }) + } + + componentWillUnmount() { + if (super.componentWillUnmount) super.componentWillUnmount(); + // TODO viewer cleanup + } + + renderMissing() { + return <div> + <div> + <p><b>WebGL does not seem to be available.</b></p> + <p>This can be caused by an outdated browser, graphics card driver issue, or bad weather. Sometimes, just restarting the browser helps.</p> + <p>For a list of supported browsers, refer to <a href='http://caniuse.com/#feat=webgl' target='_blank'>http://caniuse.com/#feat=webgl</a>.</p> + </div> + </div> + } + + render() { + if (this.state.noWebGl) return this.renderMissing(); + + return <div style={{ backgroundColor: 'rgb(0, 0, 0)', width: '100%', height: '100%'}}> + <div ref={elm => this.container = elm} style={{width: '100%', height: '100%'}}> + <canvas ref={elm => this.canvas = elm}></canvas> + </div> + </div>; + } +} \ No newline at end of file diff --git a/src/mol-state/object.ts b/src/mol-state/object.ts index 893bf1e48a9875609c60e49f363a1a7b2a73bdf0..aed685c1abdfea650fbc2f06493f84c10d146f50 100644 --- a/src/mol-state/object.ts +++ b/src/mol-state/object.ts @@ -36,10 +36,13 @@ export namespace StateObject { return <D = { }, P = {}>(typeInfo: TypeInfo) => create<P & CommonProps, D, TypeInfo>(typeInfo); } + export type Ctor = { new(...args: any[]): StateObject, type: Type } + export function create<Props, Data, TypeInfo>(typeInfo: TypeInfo) { const dataType: Type<TypeInfo> = { info: typeInfo }; return class implements StateObject<Props, Data> { static type = dataType; + static is(obj?: StateObject): obj is StateObject<Props, Data> { return !!obj && dataType === obj.type; } id = UUID.create(); type = dataType; ref = 'not set' as Transform.Ref;