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 be async (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 undefined instead. 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 div whose 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 to inhabit() is the size the visualizer should assume, and it will respect the preferences returned by requestedAspectRatio(). 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 to view(), this method should not itself do any drawing, and will typically be async.
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 the continue() 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, been show()n there, stop() has been called, and any given max frame count has expired. Then the visualizer's drawingState should be equal to DrawingStopped. If continue() is then called, the drawingState should revert to Drawing.

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 currently inhabit()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 call inhabit() 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-calling inhabit() and show()) 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 typically async.

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 ParamInterface interface. To summarize, the params property should be an object mapping parameter names to instances of ParamInterface.
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 the ParamableInterface instance.
statusOf: Record<string, ValidationStatus>
A plain object mapping each parameter name to the validation status of its current value in the tentativeValues object.
validateIndividual<P extends string>(
    param: P
): ParamValues<GenericParamDescription>[string] | undefined
This method should check the validity of the tentativeValue of 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 the ParamableInterface. If the value of param is valid, this method should return its "realized value" (i.e., tentativeValue converted to its intended type per this parameter's ParamType). The return value should be undefined otherwise.
validationStatus: ValidationStatus
The latest overall status of this instance of the ParamableInterface based on all of the tentativeValeues. 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 tentativeValues of this instance of the ParamableInterface. 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 the validationStatus property 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 the ParamableInterface (presumably by calling the assignParameters() 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 ParamableInterface will 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 tentativeValues property 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 the tentativeValues property. 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 the refreshParams() 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 ParamableInterface must be able to encode the state of its parameters in string form, called the query of the entity (because the representation should also be a valid URL query string). The value of this query property 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 tentativeValues property. It should return the ParamableInterface instance itself, for chaining purposes (typically you may want to call validate() immediately after loadQuery()).

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 description should depend only on the class of a Paramable object, not the individual instance.
static category: string
All derived classes should have a static category property, giving a class-level analogue of the name property, 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 Paramable object, 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 the validate() method of the ParamInterface object describing that parameter.) This method is called from the base class implementation of validate(), and will copy the returned status of checkParameters() into the validationStatus property of the Paramable object. With this mechanism, you don't have to worry about realizing the parameters yourself, and you generally shouldn't need to override or extend the validate() method. Just implement checkParameters() 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 Paramable object as the new "official" values, so don't presume the values in the params argument are necessarily the new correct ones and save them away or start computing with them, in this method. Wait until after assignParameters() 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.