Visualizers: Behind the Scenes
In the guide to making a visualizer, we
saw how to extend the
P5Visualizer
base class. Now, let's take a peek at how a base class works internally.
This page will be most useful to you if you want to write a new base class.
(See the technical details below as to why you can't
just write a visualizer with no base class.) However, you can also use the
information here to alter the default behavior of a base class you're
extending. By overriding methods like inhabit(), show(), stop(), and
depart(), you can customize your visualizer's behavior more deeply than
usual.
Behind the scenes, a visualizer base class is an implementation of the visualizer interface. To support parameters, the base class also has to implement the parameterizable object interface. To write a new base class, you'll have to implement these interfaces yourself. That means including all the required properties and methods, and making sure they behave in the way the engine expects.
And just as a reminder, only generate random numbers (if needed) using mathjs; see the math documentation.
The visualizer interface
In the list below of properties of this interface, methods are shown with their
arguments and return types, and data properties are shown just with their
types. All of these properties, except for resized(), must be implemented
by any visualizer, so typically a base class will provide default
implementations for all or almost all of them.
usesGL(): boolean
- Should return true if this visualizer requires a WebGL graphics context (of which a limited number are available concurrently in a browser), false otherwise.
view(sequence: SequenceInterface): Promise<void>
- Load sequence into the visualizer for it to display. This sequence must
be stored by the visualizer so that the drawing operations in later
method calls will be able to access it. This method should not itself do
any drawing, but if the visualizer has already been
show()n, it must arrange that the display will change to show sequence at the next opportunity. Note, as indicated by its Promise return type, this method will typically beasync(because it may need to access some information about sequence in preparation).
requestedAspectRatio(): number | undefined
-
Specify the visualizer's desired aspect ratio for its canvas. If it returns a number n, it should be positive, and n gives the desired aspect ratio as width/height, meaning:
Range Shape 0 < n < 1 The canvas is taller than it is wide (portrait orientation) n = 1 The canvas is square n > 1 The canvas is wider than it is tall (landscape orientation) If the visualizer does not wish to request a specific aspect ratio and will instead work with whatever is given, this method may return
undefinedinstead. In that case, frontscope will provide the largest canvas that will fit in the available space.
inhabit(element: HTMLElement, size: ViewSize): Promise<void>
- Insert the display of the visualizer into the given DOM element. This
element is typically a
divwhose size is already set up to comprise the available space for visualization. The visualizer should remove itself from any other location it might have been displaying, and prepare to draw within the provided element. It must be safe to call this with the same element in which the visualizer is already displaying (and the "reset" consisting of removal and preparation should still happen). The size provided in the call toinhabit()is the size the visualizer should assume, and it will respect the preferences returned byrequestedAspectRatio(). In the rare case the visualizer needs the dimensions of the full space that was available (beyond the requested aspect ratio), it can just directly query the size of element. Similarly toview(), this method should not itself do any drawing, and will typically beasync.
show(): void
- Start display of the visualization. When this is called, you can (and
should!) actually start drawing things. However, if the visualizer is not
currently
inhabit()ing an element, this call should do nothing.
stop(max?: number): void
- Stop drawing the visualization after at most max more frames. If
max is not positive or not specified, stops immediately. If max is
Infinity, this call has no effect. You must be able to clear a previously set maximum frame count by calling thecontinue()method.
continue(): void
- Continue drawing the visualization, i.e., clear any frame limit previously
set by a call to
stop().
drawingState: DrawingState
-
The visualizer must maintain the value of its drawingState property to indicate the current status of whether it is actively drawing, via one of the following three constants (the only values of type DrawingState):
Value
Meaning
DrawingUnmounted
The visualizer is currently not
inhabit()ing
any DOM element.Drawing
The visualizer is actively drawing in an element.
DrawingStopped
The visualizer is not currently drawing, although
inhabit()ing an element.For example, suppose a visualizer has successfully
inhabit()ed an element, beenshow()n there,stop()has been called, and any given max frame count has expired. Then the visualizer's drawingState should be equal toDrawingStopped. Ifcontinue()is then called, the drawingState should revert toDrawing.
depart(element: HTMLElement): void
- Remove the visualization from the given DOM element, release its
resources, and do any other required cleanup. It is an error to call this
method if the visualization is not currently
inhabit()ing any element. If the visualization is currentlyinhabit()ing a different location in the DOM than element, it is presumed that the realization within element was already cleaned up, and this can be a no-op. Note that after this call, it must be ok to callinhabit()again, possibly with a different location in the DOM, to reinitialize it.
resized?(size: ViewSize): Promise<boolean>
- This method, if it exists, is called by the frontscope when the size of
the visualizer should change, either because the window is resized, or
the docking configuration has changed. Visualizer writers should take
care to resize their canvas and to make sure that any html elements it
created aren't wider than the requested width. The provided size is the
available space given the new configuration, cut down to respect the
requestedAspectRatio(). Not implementing this method will mean that the visualizer is reset (by re-callinginhabit()andshow()) on resize. If it is implemented, returning true means that the visualizer has itself handled the resize (so it will not be reset by the frontscope), and so returning false means that it will be reset. Note that it is typicallyasync.
Technical details
Note that every Visualizer class instance must be a Paramable object, and
we want the code in a visualizer to be able to directly access its parameters
with correct TypeScript types. For example, if the visualizer has a
parameter speed of ParamType.NUMBER, then a visualizer method should be
able to write this.speed and have it be of type number. These types
are deduced from the "parameter description" object (see its
documentation).
Because of limitations on how TypeScript can inherit from generic classes,
these requirements mean that a Visualizer base class cannot be an ordinary
generic class.
Instead, it should be a generic "class factory function" with a type
parameter PD for the parameter description, and taking an argument of that
type. Then within the class factory function, you can define the base
class. Finally, return the constructor of the base class from the factory
function, with its return type cast to its "natural type" intersected with
ParamValues<PD> (using the TypeScript & operator on types). It's this
cast that ensures classes derived from the return value of your base class
factory function will have their parameter properties properly typed by
TypeScript. That way, supposing your function is called MyVisualizerBase
(and it only takes the parameter description as an argument), then anyone
implementing a visualizer using your base class can just write
class TheirVisualizer extends MyVisualizerBase(paramDesc) {
// Their code goes here...
}
For an example of a working class factory function, see the code in
src/Visualizers/P5Visualizer.ts.
The parameterizable object interface
As mentioned above, any frontscope object that takes parameters, like a
visualizer or a sequence, has to implement the ParamableInterface. Although
the typical way to do this is to extend the base generic implementation
Paramable documented below, we begin with
the details of the required interface itself. We use the same conventions
as above for data properties and methods of the interface.
name: string
- A per-instance identification of the paramable object. It is, however, not required to be unique among all paramable objects.
readonly htmlName: string
- Per-instance html code that should be used where possible in the user interface to display the name of the sequence. If you don't define this, the default implementation will simply reuse the name.
readonly description: string
- A description of the "category" of paramable objects to which this instance belongs. The value of this property should depend only on the class of the instance, not vary from instance to instance of the same class.
params: GenericParamDescription
- A parameterizable object has to come with a collection of user-settable
parameters — even if that collection is empty. The keys of this params
property (which should be a plain object) comprise that collection. The
value for each key describes how that parameter should appear in the
graphical user interface (UI), what kind of values it can take, whether
it is required, and so on. It does so by providing the properties of the
ParamInterfaceinterface. To summarize, the params property should be an object mapping parameter names to instances ofParamInterface.
tentativeValues: GenericStringFields
- This property will hold the latest raw string value for each parameter
that has been set in the UI (even if that string does not translate to
a proper value for the parameter, hence the "tentative" moniker).
Note the frontscope infrastructure will set this property for you.
Specifically, its value will be a plain object, the keys of which
are all of the parameter names, and all of the values of which are strings.
Note that the
assignParameters()method will convert these strings into actual properly-typed values and copy them into top-level properties of theParamableInterfaceinstance.
statusOf: Record<string, ValidationStatus>
- A plain object mapping each parameter name to the validation status of
its current value in the
tentativeValuesobject.
validateIndividual<P extends string>(
param: P
): ParamValues<GenericParamDescription>[string] | undefined
- This method should check the validity of the
tentativeValueof the single parameter named param and update the status of that parameter in the statusOf property accordingly. This method should not perform interdependent validation checks that involve multiple parameters, or assign any value to property named param in this instance of theParamableInterface. If the value of param is valid, this method should return its "realized value" (i.e.,tentativeValueconverted to its intended type per this parameter'sParamType). The return value should beundefinedotherwise.
validationStatus: ValidationStatus
- The latest overall status of this instance of the
ParamableInterfacebased on all of thetentativeValeues. In other words, if all of the tentative parameter values were assigned to this object, would that produce a valid entity (typically Sequence or Visualizer)?
validate(): ValidationStatus
- This method should determine the overall validity of all of the
tentativeValuesof this instance of theParamableInterface. It is expected to check the validity of each parameter individually, and perform any necessary cross-checks between different parameter values. This method should update thevalidationStatusproperty accordingly. Finally, if that outcome is indeed valid, this method must assign the realized values of all tentative parameters to the correspondingly named top-level properties of this instance of theParamableInterface(presumably by calling theassignParameters()method.
assignParameters(
realized?: ParamValues<GenericParamDescription>
): void
-
This method must copy the realized value of the proper type of the tentative value of each paramaeter to the location where this instance of the
ParamableInterfacewill access it in its computations. That is to say, each value is assigned to a top-level instance property with the same name as the parameter (although a specific implementation could use a different convention).The instance should only use parameter values that have been supplied by assignParameters(), because these have been vetted with a validate() call. In contrast, values taken directly from the
tentativeValuesproperty are unvalidated, and can change from valid to invalid at any time unpredictably, as the UI is manipulated.This method may optionally be called with a pre-realized parameter values object, which it may then assume is correct, in order to avoid the computation of re-realizing the tentative values.
refreshParams(which?: Set<string>): void
- This method is the reverse of
assignParameters(); it should copy the string representations of the current internal values of the parameters listed in which back into thetentativeValuesproperty. If which is not specified, it copies all parameters back. The idea is that in its operation, the instance may need to modify one or more of its parameter values. If so, it should call therefreshParams()method afterwards so that the new authoritative values of the parameters can have their representations in the UI updated accordingly. It will disrupt the user interface the least if which is supplied to include only the parameters that were actually changed.
readonly query: string
- An instance of the
ParamableInterfacemust be able to encode the state of its parameters in string form, called thequeryof the entity (because the representation should also be a valid URL query string). The value of thisqueryproperty should be that encoding of the current parameter values.
loadQuery(query: string): ParamableInterface
- This method should decode the provided query string and copy the
values it encodes back into the
tentativeValuesproperty. It should return theParamableInterfaceinstance itself, for chaining purposes (typically you may want to callvalidate()immediately afterloadQuery()).
The Paramable base class
This is a default implementation of the ParamInterface described above. It
takes care of much of the parameter bookkeeping and transference between
tentative and realized values for you. In the guide below, you may presume
that any of the properties of the interface that are not mentioned are
implemented to fulfill the responsibilities outlined above, and will
generally not need to be overridden or extended. There are some methods and
data properties of this base class that are either not present or are likely
to need to be modified in derived classes, and those are listed in this
last section.
name = 'A generic object with parameters'
- (Instances of) derived classes will surely want to override this placeholder value.
static description = 'An object with dynamically-specifiable parameters'
- Similarly, derived classes should override this placeholder value, but
note that it should be made a static property of the class, in line
with the provision that
descriptionshould depend only on the class of aParamableobject, not the individual instance. static category: string- All derived classes should have a static
categoryproperty, giving a class-level analogue of thenameproperty, that will not vary from instance to instance of the same class.
checkParameters(
_params: ParamValues<GenericParamDescription>
): ValidationStatus
-
Given an object containing a potential realized value for each parameter of this
Paramableobject, this method should perform any dependency checks that involve multiple parameters or other state of the object, and return a ValidationStatus accordingly. (Checks that only involve a single parameter should be encoded in thevalidate()method of theParamInterfaceobject describing that parameter.) This method is called from the base class implementation ofvalidate(), and will copy the returned status ofcheckParameters()into thevalidationStatusproperty of theParamableobject. With this mechanism, you don't have to worry about realizing the parameters yourself, and you generally shouldn't need to override or extend thevalidate()method. Just implementcheckParameters()if you have any interdependency checks among your parameters. Note that the base class implementation performs no checks and simply returns a good status. Hence, it is a good habit to start derived implementations with, e.g.,const status = super().checkParamaters()and then update
status(with methods like.addError()and.addWarning()as you perform your checks, finally returning it at the end.Finally, one caveat: this method being called is no guarantee that the provided values will be assigned into the internal properties of the
Paramableobject as the new "official" values, so don't presume the values in theparamsargument are necessarily the new correct ones and save them away or start computing with them, in this method. Wait until afterassignParameters()has been called, and then use the properties that have been written into the object.
async parametersChanged(_name: Set<string>): Promise<void>
- This method will be called (by the base class implementation) whenever
the values of one or more parameters have changed. The name argument
is a Set of the names of parameters that have changed. Note there can
be more than one, since sometimes multiple parameters change
simultaneously, as in a call to
loadQuery(). In the base class itself, this method does nothing, but it may be overridden in derived classes to perform any kind of update actions for the parameters listed in name.