/* **/
+ /** xmd
+: A plain object mapping each parameter name to the validation status of
+ its current value in the `tentativeValues` object.
+ **/
+ /** xmd */
+ validateIndividual(
+ param: P
+ ): ParamValues[string] | undefined /* **/
+ /** xmd
+: 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.
+ **/
+ /** xmd */
+ validationStatus: ValidationStatus /* **/
+ /** xmd
+: 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)?
+ **/
+ /** xmd */
+ validate(): ValidationStatus /* **/
+ /** xmd
+: 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.
+ **/
+ /** xmd */
+ assignParameters(
+ realized?: ParamValues
+ ): void /* **/
+ /** xmd
+: 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.
+ **/
+ /** xmd */
+ refreshParams(): void /* **/
+ /** xmd
+: This method is the reverse of `assignParameters()`; it should copy the
+ string representations of all of the current internal values of the
+ parameters back into the `tentativeValues` property. 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.
+ **/
+ /** xmd */
+ readonly query: string /* **/
+ /** xmd
+: 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.
+ **/
+ /** xmd */
+ loadQuery(query: string): ParamableInterface /* **/
+ /** xmd
+: 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()`).
+ **/
+ /** xmd */
}
-/**
- * @class Paramable
- * a generic implementation of a class with parameters to be exposed in the UI
- * Designed to be used as a common base class for such classes.
+/* Helper functions to realize parameters given parameter description(s)
+ * and (a list of) tentative string value(s)
*/
+export function realizeOne(
+ spec: ParamInterface,
+ tentative: string
+): RealizedPropertyType[T] {
+ if (!spec.required && tentative === '') return spec.default
+ return typeFunctions[spec.type].realize.call(spec, tentative)
+}
+
+function realizeAll(
+ desc: PD,
+ tentative: StringFields
+): ParamValues {
+ const params: (keyof PD)[] = Object.keys(desc)
+ return Object.fromEntries(
+ params.map(p => [p, realizeOne(desc[p], tentative[p])])
+ ) as ParamValues
+}
+
+/** xmd
+## 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.
+**/
export class Paramable implements ParamableInterface {
- name = 'Paramable'
- description = 'A class which can have parameters set'
- params: {[key: string]: ParamInterface} = {}
- isValid = false
-
- /**
- * All implementations based on this default delegate the checking of
- * parameters to the checkParameters() method.
- * That leaves the required validate() method to just call checkParameters
- * and set the isValid property based on the result. Also, if the
- * parameters are valid, it calls assignParameters to copy them into
- * top-level properties of the Sequence implementation. validate() should
- * generally not need to be overridden or extended; extend
- * checkParameters() instead.
- */
- validate(): ValidationStatus {
- const status = this.checkParameters()
- this.isValid = status.isValid
- if (this.isValid) {
- this.assignParameters()
+ /** xmd */
+ name = 'A generic object with parameters' /* **/
+ /** xmd
+: (Instances of) derived classes will surely want to override this
+ placeholder value.
+ **/
+ get htmlName() {
+ return this.name
+ }
+ /** xmd */
+ static description = 'An object with dynamically-specifiable parameters'
+ /* **/
+ /** xmd
+: 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.
+ **/
+ params: GenericParamDescription
+ tentativeValues: GenericStringFields
+ statusOf: Record = {}
+ validationStatus: ValidationStatus
+ parChangePromise: Promise | undefined = undefined
+
+ constructor(params: GenericParamDescription) {
+ this.params = params
+
+ // Hack: make sure Specimen URLs remain unambiguous
+ if (seqKey in params)
+ throw new Error(`Paramable objects may not use key '${seqKey}'`)
+
+ // Start with empty string for every tentative value
+ this.tentativeValues = makeStringFields(params)
+ // Now fill in the string forms of all of the default values of
+ // the parameters as the tentative values
+ for (const prop in params) {
+ // Be a little paranoid: only include "own" properties:
+ if (!hasField(params, prop)) continue
+ const param = params[prop]
+ // Because of how function types are unioned, we have to circumvent
+ // typescript a little bit
+ if (param.required)
+ this.tentativeValues[prop] = typeFunctions[
+ param.type
+ ].derealize.call(param, param.default as never)
+ // We assume the default value is valid
+ this.statusOf[prop] = ValidationStatus.ok()
}
- return status
+ // We assume that the default values together make a valid object:
+ this.validationStatus = ValidationStatus.ok()
+ }
+
+ get description() {
+ // Need to let Typescript know there is a static description property
+ return (
+ this.constructor as typeof this.constructor & {
+ description: string
+ }
+ ).description
}
- /**
- * checkParameters should check that all parameters are well-formed,
- * in-range, etc.
- * @returns {ValidationStatus}
- */
- checkParameters(): ValidationStatus {
- return new ValidationStatus(true)
+
+ get category() {
+ return (
+ this.constructor as typeof this.constructor & {category: string}
+ ).category
}
- /**
- * assignParameters() copies parameters into top-level properties. It
- * should not generally need to be overridden or extended, and it should
- * only be called when isValid is true.
- */
- assignParameters(): void {
- for (const prop in this.params) {
- if (!Object.prototype.hasOwnProperty.call(this, prop)) continue
- const param = this.params[prop].value
- const paramType = typeof param
+
+ validate() {
+ // first handle the individual validations
+ const parmNames = Object.keys(this.params)
+ const realized = Object.fromEntries(
+ parmNames.map(p => [p, this.validateIndividual(p) ?? ''])
+ )
+ const invalidParms = parmNames.filter(parm =>
+ this.statusOf[parm].invalid()
+ )
+ if (invalidParms.length) {
+ this.validationStatus = ValidationStatus.error(
+ `Invalid parameters: ${invalidParms.join(', ')}`
+ )
+ return this.validationStatus
+ }
+ this.validationStatus = this.checkParameters(realized)
+ if (this.validationStatus.isValid()) {
+ this.assignParameters(realized)
+ }
+ return this.validationStatus
+ }
+
+ validateIndividual(
+ param: P
+ ): ParamValues[string] | undefined {
+ const paramSpec = this.params[param]
+ const tentative = this.tentativeValues[param]
+ if (param in this.statusOf) {
+ this.statusOf[param].reset()
+ } else this.statusOf[param] = ValidationStatus.ok()
+ const status = this.statusOf[param]
+ if (!paramSpec.required && tentative === '') {
+ return paramSpec.default
+ }
+ typeFunctions[paramSpec.type].validate.call(
+ paramSpec,
+ tentative,
+ status
+ )
+ if (status.invalid()) return undefined
+ const realizer = typeFunctions[paramSpec.type].realize as (
+ this: typeof paramSpec,
+ t: string
+ ) => RealizedPropertyType[typeof paramSpec.type]
+ const realization = realizer.call(paramSpec, tentative)
+ if (paramSpec.validate) {
+ paramSpec.validate.call(this, realization, status)
+ }
+ if (status.invalid()) return undefined
+ return realization
+ }
+ /** xmd */
+ checkParameters(
+ _params: ParamValues
+ ): ValidationStatus /* **/ {
+ return ValidationStatus.ok()
+ }
+ /** xmd
+: 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](../shared/Paramable.md) 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.
+ **/
+
+ assignParameters(realized?: ParamValues) {
+ if (!realized)
+ realized = realizeAll(this.params, this.tentativeValues)
+
+ const props: string[] = []
+ const changed: string[] = []
+ for (const prop in realized) {
+ if (!hasField(realized, prop)) continue
+
const me = this as Record
- const myType = typeof me[prop]
- if (paramType === myType) {
- me[prop] = this.params[prop].value
- } else if (myType === 'number' && param === '') {
- me[prop] = 0
- } else {
- throw Error(
- `figure out ${this.params[prop].value} (`
- + `${typeof this.params[prop].value}) `
- + `to ${typeof me[prop]}`
+ props.push(prop)
+ if (realized[prop] !== me[prop]) {
+ // Looks like we might need to change my value of the prop
+ // However, we only want to do this if the two items
+ // derealize into different strings:
+ const param = this.params[prop]
+ const derealizer = typeFunctions[param.type].derealize
+ const myVersion = derealizer.call(param, me[prop] as never)
+ const newVersion = derealizer.call(
+ param,
+ realized[prop] as never
+ )
+ if (newVersion !== myVersion) {
+ // OK, really have to change
+ me[prop] = realized[prop]
+ changed.push(prop)
+ }
+ }
+ }
+ if (changed.length > 0) {
+ if (this.parChangePromise) {
+ this.parChangePromise.then(() =>
+ this.parametersChanged(changed)
)
+ } else {
+ this.parChangePromise = this.parametersChanged(changed)
}
}
}
- /**
- * refreshParams() copies the current values of top-level properties into
- * the params object. It should not generally need to be overridden or
- * extended. However, it is (currently) never called automatically; it
- * should be called whenever implementation code changes the working values,
- * to keep the values reflected correctly in the UI.
- */
+
refreshParams(): void {
- const me = this as Record
+ const me = this as unknown as ParamValues
for (const prop in this.params) {
- if (!Object.prototype.hasOwnProperty.call(this, prop)) continue
- this.params[prop].value = me[prop]
+ if (!hasField(this, prop)) continue
+ const param = this.params[prop]
+
+ const tentative = this.tentativeValues[prop]
+ const status = ValidationStatus.ok()
+ // No need to validate empty optional params
+ if (tentative || param.required)
+ typeFunctions[param.type].validate.call(
+ param,
+ tentative,
+ status
+ )
+ if (status.isValid()) {
+ // Skip any parameters that already produce the current value
+ if (me[prop] === realizeOne(param, tentative)) continue
+ }
+
+ // Can remove any optional values that are the default
+ if (!param.required && me[prop] === param.default) {
+ this.tentativeValues[prop] = ''
+ continue
+ }
+
+ this.tentativeValues[prop] = typeFunctions[
+ param.type
+ ].derealize.call(
+ param,
+ me[prop] as never // looks odd, TypeScript hack
+ )
+ }
+ }
+ /** xmd */
+ async parametersChanged(_name: string[]): Promise /* **/ {
+ return
+ }
+ /** xmd
+: 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 list 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_.
+ **/
+
+ get query(): string {
+ const tv = this.tentativeValues // just because we use it so many times
+ const saveParams: Record = {}
+ for (const key in tv) {
+ // leave out blank/default parameters
+ if (tv[key]) {
+ const param = this.params[key]
+ const defaultString = typeFunctions[
+ param.type
+ ].derealize.call(param, param.default as never)
+ if (tv[key] !== defaultString) {
+ // Avoid percent-encoding for colors
+ let qv = tv[key]
+ if (param.type === ParamType.COLOR && qv[0] === '#') {
+ qv = qv.substring(1)
+ }
+ saveParams[key] = qv
+ }
+ }
+ }
+ const urlParams = new URLSearchParams(saveParams)
+ return urlParams.toString()
+ }
+
+ loadQuery(query: string): ParamableInterface {
+ const params = new URLSearchParams(query)
+ for (const [key, value] of params) {
+ if (key in this.tentativeValues) {
+ const param = this.params[key]
+ if (
+ param.type === ParamType.COLOR
+ && value.match(/^[0-9a-fA-F]{6}$/)
+ )
+ this.tentativeValues[key] = '#' + value
+ else this.tentativeValues[key] = value
+ } else console.warn(`Invalid property ${key} for ${this.name}`)
}
+ return this
}
}
diff --git a/src/shared/Specimen.ts b/src/shared/Specimen.ts
new file mode 100644
index 00000000..eda91bcf
--- /dev/null
+++ b/src/shared/Specimen.ts
@@ -0,0 +1,310 @@
+import {alertMessage} from './alertMessage'
+import {math} from './math'
+import {specimenQuery, parseSpecimenQuery} from './specimenEncoding'
+
+import type {SequenceInterface} from '@/sequences/SequenceInterface'
+import {produceSequence} from '@/sequences/sequences'
+import {nullSize, sameSize} from '@/visualizers/VisualizerInterface'
+import type {
+ VisualizerInterface,
+ ViewSize,
+} from '@/visualizers/VisualizerInterface'
+import vizMODULES from '@/visualizers/visualizers'
+
+/**
+ * This class represents a "specimen," which is a container for a sequence
+ * and a visualizer that is viewing it, along with information about whether
+ * and where the resulting visualization is being displayed and some other
+ * setup data such as the random seed that was set when the specimen was
+ * created. You can load a specification of a sequence and a visualizer into
+ * the specimen, interact with and possibly modify the sequence and/or
+ * visualizer, insert it into an HTML element for display (via the `setup()`
+ * method), and obtain the string specifying the current sequence and
+ * visualizer in the specimen.
+ *
+ * Currently, these specifications are in the form of reasonably
+ * human-readable URL query strings (some special characters have to be
+ * %-encoded).
+ */
+export class Specimen {
+ name: string
+ private randomSeed?: string | null
+ private _visualizerKey = ''
+ private _sequenceKey = ''
+ private _visualizer?: VisualizerInterface
+ private _sequence?: SequenceInterface
+ private location?: HTMLElement
+ private isSetup = false
+ private size = nullSize
+
+ /**
+ * Constructs an empty specimen. If you supply both a seqKey
+ * and a vizKey, it makes the specimen have those sorts of
+ * sequence and visualizer, but it will still be a sort of
+ * "dummy" specimen, not really able to operate, until
+ * loadQuery() is called.
+ */
+ constructor(seqKey?: string, vizKey?: string) {
+ this.name = '*Uninitialized Specimen*'
+ if (seqKey && vizKey) {
+ this._sequenceKey = seqKey
+ this._sequence = Specimen.makeSequence(seqKey)
+ this._visualizerKey = vizKey
+ this._visualizer = new vizMODULES[vizKey].visualizer(
+ this._sequence
+ )
+ }
+ }
+
+ /**
+ * Loads new contents specified by a URL query string.
+ * Gracefully wraps up any prior visualization in this specimen.
+ * @param {string} query query string encoding the visualizer
+ * @return {Specimen} this specimen
+ */
+ async loadQuery(query: string) {
+ // Do we need to destroy the current visualization and create anew?
+ let reload = false
+
+ // First deal with the random seed as that potentially affects
+ // everything:
+ const specs = parseSpecimenQuery(query)
+ const newRandomSeed = specs.seed ?? null
+ if (newRandomSeed != this.randomSeed) {
+ this.randomSeed = newRandomSeed
+ math.config({randomSeed: newRandomSeed})
+ reload = true
+ }
+
+ // Check if the visualizer changed:
+ if (
+ specs.visualizerKind !== this._visualizerKey
+ || specs.visualizerQuery !== this.visualizer?.query
+ ) {
+ reload = true
+ }
+ // If the visualizer kind and parameters match and we have not
+ // changed the random seed, it should be OK to proceed with the
+ // existing visualizer.
+
+ // Load the specs into the specimen:
+ this.name = specs.name
+ let sequenceChanged = false
+ if (
+ specs.sequenceKind !== this._sequenceKey
+ || specs.sequenceQuery !== this.sequence?.query
+ ) {
+ sequenceChanged = true
+ this._sequenceKey = specs.sequenceKind
+ this._sequence = Specimen.makeSequence(
+ this._sequenceKey,
+ specs.sequenceQuery
+ )
+ }
+ if (reload) {
+ const displayed = this.isSetup
+ if (displayed) this.visualizer?.depart(this.location!)
+
+ this._visualizerKey = specs.visualizerKind
+
+ this._visualizer = new vizMODULES[this._visualizerKey].visualizer(
+ this._sequence!
+ )
+ if (specs.visualizerQuery) {
+ this._visualizer.loadQuery(specs.visualizerQuery)
+ }
+ await this._sequence?.fill() // maybe needed to get index range
+ this._visualizer.validate()
+ this._visualizer.stop(specs.frames)
+
+ if (displayed) this.setup(this.location!)
+ } else if (sequenceChanged) {
+ this._visualizer?.view(this._sequence!)
+ }
+ return this
+ }
+
+ // Helper for loadQuery and for extracting sequence name
+ static makeSequence(key: string, query?: string) {
+ const sequence = produceSequence(key)
+ if (query) sequence.loadQuery(query)
+ sequence.validate()
+ sequence.initialize()
+ return sequence
+ }
+
+ /**
+ * Displays a specimen within an HTML element
+ * @param {HTMLElement} location where in the DOM to insert the specimen
+ */
+ setup(location: HTMLElement) {
+ if (!this._sequence || !this._visualizer) {
+ throw new Error('Attempt to display uninitialized Specimen.')
+ }
+
+ this.location = location
+ this.isSetup = true
+ this.reset().catch(e => {
+ console.error(`ERROR in Specimen "${this.name}" reset:`, e)
+ window.alert(alertMessage(e))
+ })
+ }
+
+ /**
+ * Hard resets the specimen
+ */
+ async reset() {
+ if (!this.isSetup) return
+ this.size = this.calculateSize(
+ {
+ width: this.location?.clientWidth ?? 0,
+ height: this.location?.clientHeight ?? 0,
+ },
+ this._visualizer?.requestedAspectRatio()
+ )
+ await this._visualizer?.inhabit(this.location!, this.size)
+ this._visualizer?.show()
+ }
+
+ /**
+ * Returns the query-encoding of the specimen
+ * @return {string} a URL query string that specifies this specimen
+ */
+ get query(): string {
+ return specimenQuery(
+ this.name,
+ this._visualizerKey,
+ this._sequenceKey,
+ this._visualizer?.query,
+ this._sequence?.query
+ )
+ }
+
+ /**
+ * Returns the specimen's visualizer key
+ * @returns {string} what kind of visualizer the specimen uses
+ */
+ get visualizerKey() {
+ return this._visualizerKey
+ }
+
+ /** Returns the specimen's sequence key
+ * @returns {string} what kind of sequence the visualizer displays
+ */
+ get sequenceKey() {
+ return this._sequenceKey
+ }
+
+ /**
+ * Returns the specimen's visualizer
+ * @returns {VisualizerInterface} the visualizer displaying this specimen
+ */
+ get visualizer(): VisualizerInterface {
+ if (!this._visualizer) {
+ throw new Error('Attempt to get visualizer of empty specimen')
+ }
+ return this._visualizer
+ }
+ /**
+ * Returns the name of the specimen's visualizer, or '' if not yet
+ * initialized
+ * @returns {string} name
+ */
+ visualizerName(): string {
+ if (this._visualizer) return this._visualizer.name
+ return ''
+ }
+ /**
+ * Returns the specimen's sequence
+ * @returns {SequenceInterface} the sequence shown in this specimen
+ */
+ get sequence(): SequenceInterface {
+ if (!this._sequence) {
+ throw new Error('Attempt to get sequence of empty specimen')
+ }
+ return this._sequence
+ }
+
+ /**
+ * Returns the name of the specimen's sequence, or '' if not yet initialized
+ * @returns {string} name
+ */
+ sequenceName(): string {
+ if (this._sequence) return this._sequence.name
+ return ''
+ }
+
+ /**
+ * Ensures that the visualizer is aware that the sequence has been
+ * updated.
+ */
+ updateSequence() {
+ return this._visualizer?.view(this._sequence!)
+ }
+
+ /**
+ * Calculates the size of the visualizer in its container.
+ * @param {number} containerWidth width of the container
+ * @param {number} containerHeight height of the container
+ * @param {number?} aspectRatio aspect ratio requested by visualizer
+ * @returns {{width: number, height: number}} resulting size of visualizer
+ */
+ calculateSize(inSize: ViewSize, aspectRatio?: number): ViewSize {
+ if (aspectRatio === undefined) return inSize
+ const constraint = inSize.width / inSize.height < aspectRatio
+ return {
+ width: constraint ? inSize.width : inSize.height * aspectRatio,
+ height: constraint ? inSize.width / aspectRatio : inSize.height,
+ }
+ }
+
+ /**
+ * This function should be called when the size of the visualizer container
+ * has changed. It calculates the size of the contents according to the
+ * aspect ratio requested and calls the resize function.
+ * @param {ViewSize} toSize
+ * New width and height of the visualizer container
+ */
+ async resized(toSize: ViewSize): Promise {
+ const newSize = this.calculateSize(
+ toSize,
+ this._visualizer?.requestedAspectRatio()
+ )
+ if (sameSize(this.size, newSize)) return
+ this.size = newSize
+ // Reset the visualizer if the resized function isn't implemented
+ // or returns false, meaning it didn't handle the redisplay
+ let handled = false
+ if (this._visualizer?.resized) {
+ handled = await this._visualizer.resized(this.size)
+ }
+ if (!handled) this.reset()
+ }
+
+ /**
+ * Generates a specimen from a URL query string (as produced by the
+ * query getter of a Specimen instance).
+ * @param {string} query the URL query string encoding of a specimen
+ * @return {Specimen} the corresponding specimen
+ */
+ static async fromQuery(query: string) {
+ const result = new Specimen()
+ return result.loadQuery(query)
+ }
+
+ /**
+ * Extracts the name of the variety of sequence a specimen is showing
+ * from its query string encoding
+ * @param {string} query The URL query string encoding a specimen
+ * @return {string} the name of the sequence variety the specimen uses
+ */
+ static getSequenceNameFromQuery(query: string, html = ''): string {
+ const specs = parseSpecimenQuery(query)
+ const sequence = Specimen.makeSequence(
+ specs.sequenceKind,
+ specs.sequenceQuery
+ )
+ if (html) return sequence.htmlName
+ return sequence.name
+ }
+}
diff --git a/src/shared/ValidationStatus.ts b/src/shared/ValidationStatus.ts
index c45684ee..73220cdd 100644
--- a/src/shared/ValidationStatus.ts
+++ b/src/shared/ValidationStatus.ts
@@ -1,9 +1,105 @@
export class ValidationStatus {
- public isValid: boolean
public errors: string[]
+ public warnings: string[]
- constructor(isValid: boolean, errors?: string[]) {
- this.isValid = isValid
- this.errors = errors !== undefined ? errors : []
+ constructor(errors?: string[], warnings?: string[]) {
+ this.errors = errors ?? []
+ this.warnings = warnings ?? []
+ }
+ /**
+ * Decides whether or not this `ValidationStatus` represents a valid
+ * or invalid state, returning `true` if valid, and `false` if invalid.
+ * Internally, this decision is based on the presence of error messages.
+ * @returns the validity state of this instance
+ */
+ isValid(): boolean {
+ return this.errors.length === 0
+ }
+ /**
+ * The negation of .isValid()
+ * @returns {boolean} true if the status is not valid
+ */
+ invalid(): boolean {
+ return !this.isValid()
+ }
+ /**
+ * True if there are any warnings
+ * @returns {boolean}
+ */
+ isWarned(): boolean {
+ return !!this.warnings.length
+ }
+ /**
+ * True if there are any errors or warnings
+ * @returns {boolean}
+ */
+ defective(): boolean {
+ return !!this.errors.length || !!this.warnings.length
+ }
+ /**
+ * Adds one or more error messages to this `ValidationStatus`. As
+ * a result, this instance shifts from a valid state to an invalid
+ * one if not already.
+ * @param {string, string, ...} error1, error2, ...
+ * the error messages to add
+ */
+ addError(...errors: string[]) {
+ this.errors.push(...errors)
+ }
+ /**
+ * Adds one or more warning messages to this `ValidationStatus`. Note
+ * that this does not affect the validity state.
+ * @param {string, string, ...} warn1, warn2, ...
+ * the error messages to add
+ */
+ addWarning(...warnings: string[]) {
+ this.warnings.push(...warnings)
+ }
+ /**
+ * Returns the status to the OK state, with no errors or warnings
+ */
+ reset() {
+ this.errors.length = 0
+ this.warnings.length = 0
+ }
+ /**
+ * Returns a `ValidationStatus` in a valid state, with no warnings
+ * @returns the resulting `ValidationStatus`
+ */
+ static ok(): ValidationStatus {
+ return new ValidationStatus()
+ }
+ /**
+ * Returns a `ValidationStatus` in an invalid state with specified
+ * error message(s).
+ * @param {string, string, ...} error1, error2, ...
+ * the error message(s)
+ * @returns {ValidationStatus}
+ */
+ static error(...errors: string[]): ValidationStatus {
+ return new ValidationStatus(errors)
+ }
+ /**
+ * Returns a `ValidationStatus` with no errors but the specified
+ * warning(s).
+ * @param {string, string, ...} warn1, warn2, ...
+ * the warning(s)
+ * @returns {ValidationStatus}
+ */
+ static warn(...warnings: string[]): ValidationStatus {
+ return new ValidationStatus(undefined, warnings)
+ }
+ /**
+ * Returns a `ValidationStatus` which is valid if the given predicate
+ * is false, but invalid with appropriate error messages if the given
+ * predicate is true.
+ * @param {boolean} pred the predicate to check against
+ * @param {string, string, ...} error1, error2, ...
+ * the error message(s) if the predicate succeeds
+ * @returns {ValidationStatus}
+ */
+ static errorIf(pred: boolean, ...errors: string[]): ValidationStatus {
+ if (pred) return this.error(...errors)
+ else return this.ok()
}
}
diff --git a/src/shared/__tests__/browserCaching.spec.ts b/src/shared/__tests__/browserCaching.spec.ts
new file mode 100644
index 00000000..c063d305
--- /dev/null
+++ b/src/shared/__tests__/browserCaching.spec.ts
@@ -0,0 +1,94 @@
+import {describe, it, expect, vi, beforeEach} from 'vitest'
+import {
+ getCurrent,
+ updateCurrent,
+ saveSpecimen,
+ deleteSpecimen,
+} from '../browserCaching'
+
+// Mocks localStorage
+const localStorageMock = (() => {
+ let store: Record = {}
+
+ return {
+ getItem(key: string) {
+ return store[key] || null
+ },
+ setItem(key: string, value: string) {
+ store[key] = value
+ },
+ clear() {
+ store = {}
+ },
+ removeItem(key: string) {
+ delete store[key]
+ },
+ }
+})()
+
+// eslint-disable-next-line no-undef
+Object.defineProperty(global, 'localStorage', {
+ value: localStorageMock,
+})
+
+// Mock date
+const mockDate = '06/27/2024, 10:00:00'
+const mockQuery = 'name=Test&viz=ModFill&seq=Random'
+const anotherQuery = 'name=Test&viz=Turtle&seq=Formula'
+const twoSIMs = [
+ {query: mockQuery, date: mockDate, canDelete: true},
+ {query: 'name=Another', date: mockDate, canDelete: true},
+]
+
+vi.useFakeTimers()
+vi.setSystemTime(new Date(mockDate))
+
+beforeEach(() => {
+ localStorage.clear()
+})
+
+describe('SIM functions', () => {
+ it('should get the current SIM', () => {
+ const current = {query: mockQuery, date: mockDate, canDelete: true}
+ localStorage.setItem('currentSpecimen', JSON.stringify(current))
+ expect(getCurrent()).toEqual(current)
+ })
+
+ it('should update the current SIM', () => {
+ updateCurrent({query: mockQuery})
+ const current = JSON.parse(
+ localStorage.getItem('currentSpecimen') as string
+ )
+ expect(current.query).toBe(mockQuery)
+ })
+
+ it('should save a new specimen', () => {
+ saveSpecimen(mockQuery)
+ const savedUrls = JSON.parse(
+ localStorage.getItem('savedSpecimens') as string
+ )
+ expect(savedUrls).toEqual([
+ {query: mockQuery, date: mockDate, canDelete: true},
+ ])
+ })
+
+ it('should update an existing specimen', () => {
+ saveSpecimen(mockQuery)
+ saveSpecimen(anotherQuery)
+ const savedUrls = JSON.parse(
+ localStorage.getItem('savedSpecimens') as string
+ )
+ expect(savedUrls).toEqual([
+ {query: anotherQuery, date: mockDate, canDelete: true},
+ ])
+ })
+
+ it('should delete a specimen by name', () => {
+ localStorage.setItem('savedSpecimens', JSON.stringify(twoSIMs))
+ deleteSpecimen('Test')
+ const updatedUrls = JSON.parse(
+ localStorage.getItem('savedSpecimens') as string
+ )
+ expect(updatedUrls).toEqual([twoSIMs[1]])
+ })
+})
diff --git a/src/shared/__tests__/defineFeatured.spec.ts b/src/shared/__tests__/defineFeatured.spec.ts
new file mode 100644
index 00000000..3f15760c
--- /dev/null
+++ b/src/shared/__tests__/defineFeatured.spec.ts
@@ -0,0 +1,21 @@
+import {describe, it, expect} from 'vitest'
+
+import {getFeatured} from '../defineFeatured'
+
+describe('getFeatured', () => {
+ it('returns an array of specimen-in-memory structures', () => {
+ const featured = getFeatured()
+ expect(featured[0]).toHaveProperty('query')
+ expect(featured[0]).toHaveProperty('date')
+ expect(featured[0]).toHaveProperty('canDelete')
+ })
+ it('that are not delete-able', () => {
+ const featured = getFeatured()
+ const last = featured.length - 1
+ expect(featured[last].canDelete).toBeFalsy()
+ })
+ it('should generate at least three specimens', () => {
+ const featured = getFeatured()
+ expect(featured.length).toBeGreaterThan(2)
+ })
+})
diff --git a/src/shared/__tests__/math.spec.ts b/src/shared/__tests__/math.spec.ts
index a361dce0..9d3275a6 100644
--- a/src/shared/__tests__/math.spec.ts
+++ b/src/shared/__tests__/math.spec.ts
@@ -1,6 +1,7 @@
-import * as math from '../math'
import {describe, it, expect} from 'vitest'
+import {math} from '../math'
+
const large = 9007199254740993n
describe('safeNumber', () => {
@@ -39,6 +40,7 @@ describe('modulo', () => {
it('gives the bigint remainder upon division', () => {
expect(math.modulo(7, 5)).toBe(2n)
expect(math.modulo(large, 10)).toBe(3n)
+ expect(math.modulo(large, 1)).toBe(0n)
expect(math.modulo(12, 5n)).toBe(2n)
expect(math.modulo(99999999999999999999999999n, 100n)).toBe(99n)
})
@@ -46,8 +48,14 @@ describe('modulo', () => {
expect(math.modulo(-7, 5)).toBe(3n)
expect(math.modulo(-large, 100n)).toBe(7n)
expect(math.modulo(25, 5n)).toBe(0n)
+ expect(math.modulo(0, 1n)).toBe(0n)
+ expect(math.modulo(1, 1n)).toBe(0n)
+ expect(math.modulo(1n, 1)).toBe(0n)
+ expect(math.modulo(-1, 1n)).toBe(0n)
})
it('requires a positive modulus', () => {
+ expect(() => math.modulo(77, 0)).toThrowError('Attempt')
+ expect(() => math.modulo(large, 0n)).toThrowError('Attempt')
expect(() => math.modulo(77, -7)).toThrowError('Attempt')
expect(() => math.modulo(-109n, -6n)).toThrowError('Attempt')
})
@@ -92,3 +100,67 @@ describe('natlog', () => {
).toBeCloseTo(204.93007327647007, 15)
})
})
+
+describe('divides', () => {
+ it('gives true when integers divide', () => {
+ expect(math.divides(7, 5)).toBe(false)
+ expect(math.divides(10, large)).toBe(false)
+ expect(math.divides(10, large - 3n)).toBe(true)
+ expect(math.divides(120, 5n)).toBe(false)
+ expect(math.divides(9, 99999999999999999999999999n)).toBe(true)
+ })
+ it('handles zeroes and ones', () => {
+ expect(math.divides(1, large)).toBe(true)
+ expect(math.divides(1n, 1)).toBe(true)
+ expect(math.divides(1, 0n)).toBe(true)
+ expect(math.divides(0n, 0)).toBe(true)
+ expect(math.divides(1n, 0)).toBe(true)
+ expect(math.divides(0, 1n)).toBe(false)
+ expect(math.divides(0, -1n)).toBe(false)
+ })
+})
+
+const pow =
+ 89907201863535854420702290135762284537312963394702682637089810488324824507n
+describe('valuation', () => {
+ it('reports the correct valuation', () => {
+ expect(math.valuation(pow, 3)).toBe(155)
+ expect(math.valuation(20, 2n)).toBe(2)
+ expect(math.valuation(300000000000000000000000000n, 3n)).toBe(1)
+ })
+ it('handles a == 0', () => {
+ expect(math.valuation(0, 5)).toBe(+Infinity)
+ expect(math.valuation(0n, 2)).toBe(+Infinity)
+ })
+ it('handles negative a', () => {
+ expect(math.valuation(-7, 5)).toBe(0)
+ expect(math.valuation(-large, 2)).toBe(0)
+ expect(math.valuation(-25, 5n)).toBe(2)
+ })
+ it('requires a big enough divisor', () => {
+ expect(() => math.valuation(77, 0)).toThrowError('Attempt')
+ expect(() => math.valuation(large, 1n)).toThrowError('Attempt')
+ expect(() => math.valuation(773, -7)).toThrowError('Attempt')
+ expect(() => math.valuation(-109n, -6n)).toThrowError('Attempt')
+ })
+})
+
+describe('biggcd', () => {
+ it('gives correct gcd on numbers & bigints', () => {
+ expect(math.biggcd(7, 28)).toBe(7n)
+ expect(math.biggcd(large, 15)).toBe(3n)
+ expect(math.biggcd(125, 15n)).toBe(5n)
+ expect(math.biggcd(99999999999999999999999999n, 900n)).toBe(9n)
+ })
+ it('deals with minus signs correctly', () => {
+ expect(math.biggcd(-25, 5)).toBe(5n)
+ expect(math.biggcd(large, -15n)).toBe(3n)
+ expect(math.biggcd(-25, -5n)).toBe(5n)
+ })
+ it('deals with zero correctly', () => {
+ expect(math.biggcd(-25, 0)).toBe(25n)
+ expect(math.biggcd(0, large)).toBe(large)
+ expect(math.biggcd(0, 0)).toBe(0n)
+ expect(math.biggcd(0, 0n)).toBe(0n)
+ })
+})
diff --git a/src/shared/__tests__/specimen.spec.ts b/src/shared/__tests__/specimen.spec.ts
new file mode 100644
index 00000000..06a20e80
--- /dev/null
+++ b/src/shared/__tests__/specimen.spec.ts
@@ -0,0 +1,22 @@
+import {describe, it, expect} from 'vitest'
+
+import {Specimen} from '../Specimen'
+import {specimenQuery} from '../specimenEncoding'
+
+describe('url', () => {
+ it('remains the same when re-encoding', async () => {
+ const specimen1 = new Specimen()
+ specimen1.loadQuery(specimenQuery('Hello', 'ModFill', 'Random'))
+ specimen1.visualizer.tentativeValues.modDimension = '50'
+ const enc1 = specimen1.query
+
+ const specimen2 = await Specimen.fromQuery(enc1)
+ const enc2 = specimen2.query
+
+ expect(specimen2.name).toBe('Hello')
+ expect(specimen2.visualizerKey).toBe('ModFill')
+ expect(specimen2.sequenceKey).toBe('Random')
+ expect(specimen2.visualizer.tentativeValues.modDimension).toBe('50')
+ expect(enc1).toBe(enc2)
+ })
+})
diff --git a/src/shared/browserCaching.ts b/src/shared/browserCaching.ts
new file mode 100644
index 00000000..4d89a906
--- /dev/null
+++ b/src/shared/browserCaching.ts
@@ -0,0 +1,350 @@
+import {getFeatured} from './defineFeatured'
+import {math} from './math'
+import {parseSpecimenQuery, specimenQuery} from './specimenEncoding'
+
+/* This file is responsible for all of the state of Numberscope that is kept
+ in browser localStorage. Currently that state consists of
+
+ 1. A list of saved Specimens,
+ 2. A list ("history", since it is kept in most-recently-used order
+ except for the undeletable "standard" sequences at the front) of
+ Sequences that will be shown in the Sequence Switcher.
+ 3. The "preferred" (most recently used) style of displaying each Gallery
+ of specimens: as THUMBNAILS or LIST.
+*/
+
+/* A "SIM" (Specimen In Memory) is an object with two string properties:
+ query - gives the url-query-encoding of the specimen
+ date - gives the date on which it was last saved.
+
+ In a prior version of the code, SIMs used a base64-encoding of specimens,
+ so there is code below that reinterprets such encoding into query strings
+ on the fly, for backwards compatibility with specimens that may have been
+ saved before the switch to query-encoding.
+*/
+
+// NON MEMORY RELATED HELPER FUNCTIONS
+export interface SIM {
+ query: string
+ date: string
+ canDelete?: boolean // if not present, defaults to false
+}
+
+function getCurrentDate(): string {
+ const currentDate = new Date()
+ const options: Intl.DateTimeFormatOptions = {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ }
+ return new Intl.DateTimeFormat('en-US', options).format(currentDate)
+}
+
+/* Returns a link to the OEIS related to the first OEIS ID that appears in the
+ given string
+*/
+export function oeisLinkFor(words: string) {
+ const id = words.match(/A\d{6}/)
+ let url = 'https://oeis.org'
+ if (id) url += `/${id[0]}`
+ return url
+}
+
+// MEMORY RELATED HELPER FUNCTIONS AND VARIABLES
+
+// Keys used to save the different pieces of state in browser localStorage.
+// Each key is arbitrary, but they must all be distinct.
+const cacheKey = 'savedSpecimens'
+const currentKey = 'currentSpecimen'
+const cannedKey = 'sequenceHistory'
+const galleryKey = 'preferredGalleries'
+
+// For backward compatibility:
+function newSIMfromOld(oldSim: {date: string; en64: string}): SIM {
+ const data = JSON.parse(window.atob(oldSim.en64))
+ if (!('name' in data && 'sequence' in data && 'visualizer' in data))
+ return {
+ query: specimenQuery('Conversion Error', 'Unknown', 'Unknown'),
+ date: '',
+ canDelete: true,
+ }
+ let vizQuery = ''
+ if ('visualizerParams' in data && data.visualizerParams) {
+ const vizData = JSON.parse(window.atob(data.visualizerParams))
+ const vizParams = new URLSearchParams(vizData)
+ vizQuery = vizParams.toString()
+ }
+ let seqQuery = ''
+ if ('sequenceParams' in data && data.sequenceParams) {
+ const seqData = JSON.parse(window.atob(data.sequenceParams))
+ const seqParams = new URLSearchParams(seqData)
+ seqQuery = seqParams.toString()
+ }
+ return {
+ date: oldSim.date,
+ query: specimenQuery(
+ data.name,
+ data.visualizer,
+ data.sequence,
+ vizQuery,
+ seqQuery
+ ),
+ canDelete: true,
+ }
+}
+
+/**
+ * Fetches the array of SIMs represented in memory.
+ * @return {SIM[]}
+ */
+export function getSIMs(): SIM[] {
+ // Retrieves the saved SIMs from browser cache
+ const savedSIMsJson = localStorage.getItem(cacheKey)
+ // Creates empty list in case none is found in browser storage
+ const savedSIMs: SIM[] = []
+
+ // Parses the saved SIMs if they exist
+ if (savedSIMsJson)
+ for (const sim of JSON.parse(savedSIMsJson))
+ if ('date' in sim)
+ if ('query' in sim) savedSIMs.push(sim)
+ else if ('en64' in sim) savedSIMs.push(newSIMfromOld(sim))
+
+ return savedSIMs
+}
+
+/**
+ * Overwrites the array of SIMS in local storage.
+ * @param {SIM[]} sims
+ */
+function putSIMs(sims: SIM[]) {
+ localStorage.setItem(cacheKey, JSON.stringify(sims))
+}
+
+export function nameOfQuery(query: string): string {
+ const params = new URLSearchParams(query)
+ return params.get('name') || 'Error: name not specified'
+}
+
+/**
+ * Fetches the SIM associated with a certain name.
+ *
+ * @param {string} name Name of SIM to look up
+ * @param {SIM[]?} sims Optional list to look in, defaults to getSIMs()
+ * @return {SIM|undefined} Associated SIM, or undefined if none
+ */
+export function getSIMByName(name: string, sims = getSIMs()) {
+ // Finds the SIM that matches the given name
+ const theSIM = sims.find(s => name === nameOfQuery(s.query))
+
+ // Return the found SIM object or null if not found
+ if (theSIM) return theSIM
+ return undefined
+}
+
+//MAIN FUNCTIONS
+
+/**
+ * Loads the last remembered current into the memory slot.
+ * To be called whenever the website is booted up.
+ * @return {SIM} the current SIM
+ */
+export function getCurrent(): SIM {
+ // Retrieves the saved SIM in the current slot
+ const savedCurrent = localStorage.getItem(currentKey)
+
+ if (savedCurrent) {
+ const data = JSON.parse(savedCurrent)
+ if ('query' in data) return data
+ if ('en64' in data) return newSIMfromOld(data)
+ }
+
+ // If there wasn't any current, set up the current to be
+ // a random selection from the featured items. Shallow clone
+ // it for the sake of updateCurrent().
+ const current = {...math.pickRandom(getFeatured())}
+ localStorage.setItem(currentKey, JSON.stringify(current))
+ return current
+}
+
+// Helper type for updateCurrent
+interface Queryable {
+ query: string
+}
+
+/**
+ * Overrides query in the current slot.
+ * To be called whenever changes are made to the current specimen.
+ *
+ * @param {{query: string}} specimen new current specimen
+ */
+export function updateCurrent(specimen: Queryable): void {
+ const current = getCurrent()
+ current.query = specimen.query
+ localStorage.setItem(currentKey, JSON.stringify(current))
+}
+
+/**
+ * Saves the specimen corresponding to the given query, updating its
+ * "last saved" property.
+ * If the name in the query corresponds to an already existing SIM,
+ * it is overwritten.
+ * It should be called when the user presses the save button.
+ *
+ * @param {string} query encoding of the specimen to save
+ */
+
+export function saveSpecimen(query: string): void {
+ const date = getCurrentDate()
+ const savedSIMs = getSIMs()
+ const existing = getSIMByName(nameOfQuery(query), savedSIMs)
+ if (existing) {
+ existing.query = query
+ existing.date = date
+ } else {
+ savedSIMs.push({query, date, canDelete: true})
+ }
+ putSIMs(savedSIMs)
+ // We also save the current sequence:
+ const {sequenceKind, sequenceQuery} = parseSpecimenQuery(query)
+ addSequence(sequenceKind, sequenceQuery)
+}
+
+/**
+ * Deletes a specimen specified by name from the cached array.
+ * It should be called when the user presses the delete button.
+ *
+ * @param {string} name Name of specimen to delete
+ */
+export function deleteSpecimen(name: string): void {
+ const savedSIMs = getSIMs()
+ const index = savedSIMs.findIndex(s => name === nameOfQuery(s.query))
+ // If the SIM object is found, remove it from the array
+ if (index !== -1) savedSIMs.splice(index, 1)
+ putSIMs(savedSIMs)
+}
+
+/**
+ * Fetches the array of canned sequences stored locally.
+ * Each sequence is stored as a pair of strings [sequenceKey, queryString]
+ * @return {string[][]}
+ */
+export const standardSequences = [
+ ['Formula', ''],
+ ['Random', ''],
+]
+const defaultSequences = [
+ ...standardSequences,
+ ['Formula', 'formula=%28sqrt%282%29n%29+%25+3'],
+ ['OEIS A000040', ''],
+ ['OEIS A000045', ''],
+]
+export function getSequences(): string[][] {
+ const cannedJson = localStorage.getItem(cannedKey)
+ return cannedJson ? JSON.parse(cannedJson) : defaultSequences
+}
+
+/** Utility to find a sequence in a list of sequences **/
+const cannedIgnore = new Set(['first', 'last', 'length'])
+function findMatchingSequence(
+ canned: string[][],
+ key: string,
+ query: string
+): number {
+ const params = new URLSearchParams(query)
+ return canned.findIndex(element => {
+ if (element[0] !== key) return false
+ const cannedParams = new URLSearchParams(element[1])
+ for (const [prop, val] of params) {
+ if (cannedIgnore.has(prop)) continue
+ if (!cannedParams.has(prop)) return false
+ if (cannedParams.get(prop) !== val) return false
+ }
+ // OK, cannedParams has all of the properties of params with the
+ // same values. But it might have other properties, so check that:
+ for (const [prop] of cannedParams) {
+ if (cannedIgnore.has(prop)) continue
+ if (!params.has(prop)) return false
+ }
+ // Good enough match!
+ return true
+ })
+}
+
+/**
+ * Adds another sequence to the ones stored locally, if it is not already
+ * present. Note that in looking up whether the sequence is already present,
+ * the extent parameters 'first', 'last', and 'length' are ignored; but their
+ * new values overwrite the previously present values if it is.
+ * @param {string} key The sequence key of the sequence to potentially add
+ * @param {string} query The query of the sequence to potentially add
+ */
+
+export function addSequence(key: string, query: string): void {
+ const canned = getSequences()
+ const present = findMatchingSequence(canned, key, query)
+ // remove prior version of this sequence if there and is not standard
+ if (present >= standardSequences.length) canned.splice(present, 1)
+ // And put this sequence just after standard ones if it is not standard
+ if (present < 0 || present >= standardSequences.length) {
+ canned.splice(standardSequences.length, 0, [key, query])
+ }
+ localStorage.setItem(cannedKey, JSON.stringify(canned))
+}
+
+/**
+ * Removes a sequence from the ones stored locally, if it is present.
+ * @param {string} key The sequence key to potentially delete
+ * @param {string} query The query describing the sequence to delete.
+ */
+export function deleteSequence(key: string, query: string): void {
+ const canned = getSequences()
+ // We can use equality here because we will only ever try to remove
+ // exactly the sequence that is already there.
+ const index = canned.findIndex(
+ element => element[0] === key && element[1] === query
+ )
+ if (index !== -1) {
+ canned.splice(index, 1)
+ localStorage.setItem(cannedKey, JSON.stringify(canned))
+ }
+}
+
+/**
+ * Constants to use for getting/saving display state
+ */
+
+export const THUMBNAILS = false
+export const LIST = true
+export type GalleryPreference = typeof THUMBNAILS | typeof LIST
+
+function getGalleryPrefs() {
+ const prefsJson = localStorage.getItem(galleryKey)
+ return prefsJson ? JSON.parse(prefsJson) : {}
+}
+/**
+ * Retrieves the preferred method of display for the Gallery named _gallery_.
+ * @param {string} gallery name of Gallery to fetch the preference for
+ * @returns {GalleryPreference} preferred format, THUMBNAILS or LIST
+ */
+export function getPreferredGallery(gallery: string) {
+ return getGalleryPrefs()[gallery] ? LIST : THUMBNAILS
+}
+
+/**
+ * Sets the preferred method of display for the Gallery named _gallery_.
+ * @param {string} gallery name of Gallery to set the preference for
+ * @param {GalleryPreference} pref new preferred display format
+ */
+export function setPreferredGallery(
+ gallery: string,
+ pref: GalleryPreference
+) {
+ const prefs = getGalleryPrefs()
+ prefs[gallery] = pref
+ localStorage.setItem(galleryKey, JSON.stringify(prefs))
+}
diff --git a/src/shared/defineFeatured.ts b/src/shared/defineFeatured.ts
new file mode 100644
index 00000000..bf7c77a7
--- /dev/null
+++ b/src/shared/defineFeatured.ts
@@ -0,0 +1,165 @@
+import {specimenQuery} from './specimenEncoding'
+
+// Encodings of the featured specimens
+
+const featuredSIMs = [
+ specimenQuery(
+ 'Thue Trellis',
+ 'Turtle',
+ 'OEIS A010060',
+ 'domain=0+1&turns=15+-165&steps=2+3'
+ + '&pathLook=true&speed=10&bgColor=e0def7&strokeColor=5e8d85'
+ ),
+ specimenQuery(
+ 'Divisor Square',
+ 'Chaos',
+ 'OEIS A000005',
+ 'corners=8&walkers=8&alpha=0.7&pixelsPerFrame=2000'
+ ),
+ specimenQuery(
+ 'Dance no. 163',
+ 'ModFill',
+ 'Formula',
+ 'modDimension=600&fillColor=a51d2d&alpha=min(0.4%2C+0.01*m)'
+ + '&highlightFormula=n+mod+163+%3E+81&highColor=ff7800',
+ 'formula=163n'
+ ),
+ specimenQuery(
+ "Virahanka's Prime Construct",
+ 'ModFill',
+ 'OEIS A000045',
+ 'modDimension=130&backgroundColor=62a0ea&fillColor=613583'
+ + '&alpha=0.05&aspectRatio=false&highlightFormula=isPrime%28n%29'
+ + '&highColor=e5a50a',
+ ''
+ ),
+ specimenQuery(
+ 'Prime Residues',
+ 'ModFill',
+ 'Formula',
+ 'fillColor=1a5fb4&alpha=0.1&highlightFormula=isPrime%28n%29'
+ + '&highColor=f66151',
+ 'formula=n'
+ ),
+ specimenQuery(
+ 'Baffling Beatty Bars',
+ 'ModFill',
+ 'Formula',
+ 'modDimension=350&fillColor=26a269&alpha=0.3'
+ + '&highlightFormula=floor%28sqrt%283%29n%29&highColor=1a5fb4',
+ 'formula=floor%28sqrt%282%29*n%29'
+ ),
+ specimenQuery(
+ 'Woven Residues',
+ 'ModFill',
+ 'Random',
+ 'modDimension=5000',
+ 'min=10000&max=100000'
+ ),
+ specimenQuery(
+ "Picasso's Periods",
+ 'ModFill',
+ 'Formula',
+ 'modDimension=100&backgroundColor=000000&fillColor=1a5fb4'
+ + '&alpha=0.15&aspectRatio=false&highlightFormula=isPrime%28a%29'
+ + '&highColor=bf8383&alphaHigh=0.4&sunzi=0.03&frameRate=24',
+ 'formula=n%5E3%2B2n%2B1'
+ ),
+ specimenQuery(
+ 'Chaos Game',
+ 'Chaos',
+ 'Random',
+ 'corners=3&colorStyle=1&dummyDotControl=true'
+ + '&circSize=2&alpha=0.4&darkMode=true',
+ 'max=2'
+ ),
+ specimenQuery(
+ 'Polyfactors',
+ 'Histogram',
+ 'Formula',
+ 'binSize=1',
+ 'formula=n%5E3-n%5E2&length=1000'
+ ),
+ specimenQuery(
+ 'Wait For It',
+ 'Turtle',
+ 'Formula',
+ 'domain=-1+1&turns=30+120&steps=30+30&pathLook=true&strokeWeight=2'
+ + '&bgColor=5d509f&strokeColor=7a9f6f',
+ 'formula=sign%28sin%28n%2B1%29%29'
+ ),
+ specimenQuery(
+ 'Tau Many Primes',
+ 'FactorFence',
+ 'OEIS A000594',
+ 'signs=false'
+ ),
+ specimenQuery(
+ 'VFib Snowflake',
+ 'Turtle',
+ 'OEIS A000045',
+ 'domain=0+1&turns=8+120&steps=40+400'
+ + '&animationControls=true&folds=200+0'
+ + '&bgColor=4f4875&strokeColor=cec0c0',
+ 'modulus=9&last=999&length=1000'
+ ),
+ specimenQuery(
+ 'Beatty DNA',
+ 'Turtle',
+ 'OEIS A001951',
+ '&domain=0+1+2&turns=79+0+45&steps=2.5+1.5+3'
+ + '&pathLook=true&speed=10&bgColor=6c162b&strokeColor=be9b9b',
+ '&modulus=3'
+ ),
+ specimenQuery(
+ 'IntegerStellar',
+ 'SelfSimilarity',
+ 'Formula',
+ '&width=400&height=400&shiftFormula=m&distance=2'
+ ),
+ specimenQuery(
+ 'SquareSwirl',
+ 'SelfSimilarity',
+ 'Formula',
+ '&width=200&height=200&shiftFormula=1m&modulus=80'
+ + '&backgroundColor=202946&fillColor=9bb0ee',
+ '&formula=n%5E2'
+ ),
+ specimenQuery(
+ 'Coprimality',
+ 'SelfSimilarity',
+ 'Formula',
+ '&width=100&shiftFormula=1m&distance=2&modulus=80'
+ + '&backgroundColor=52156a&fillColor=c18fa7&opacityControl=true'
+ + '&opacityFormula=d%3D%3D1'
+ ),
+ specimenQuery(
+ 'Gaussian Split Prime Soup',
+ 'SelfSimilarity',
+ 'Formula',
+ '&shiftFormula=m*1&modulus=80&backgroundColor=45344c'
+ + '&fillColor=95c18f&opacityControl=true'
+ + '&opacityFormula=isPrime%28s%5E2%2Bt%5E2%29&height=150'
+ ),
+ specimenQuery(
+ 'Modular Multiplication Table',
+ 'SelfSimilarity',
+ 'Formula',
+ '&width=200&shiftFormula=1*m&modulus=80&fillColor=e66100'
+ + '&opacityControl=true'
+ + '&opacityFormula=%28%28s*t%29+mod+200%29%2F200'
+ + '&star=1&height=200'
+ ),
+]
+
+// Is there any reason for us to associate dates with featured specimens? Do
+// we want to record when they were added and show that information somehow?
+// Also, we freeze each featured specimen to make sure it is an error if
+// any part of frontscope tries to modify it.
+const theSIMs = featuredSIMs.map(query => {
+ return Object.freeze({query, date: '', canDelete: false})
+})
+
+export function getFeatured() {
+ return theSIMs
+}
diff --git a/src/shared/fields.ts b/src/shared/fields.ts
new file mode 100644
index 00000000..7a7ad44b
--- /dev/null
+++ b/src/shared/fields.ts
@@ -0,0 +1,23 @@
+/* Some utilities for querying and checking fields (= own properties)
+ of objects
+*/
+
+export type StringFields = {
+ [K in keyof T]: string
+}
+
+export type GenericStringFields = Record
+
+export function makeStringFields(
+ example: T
+): StringFields {
+ // TypeScript not currently up to deducing the type of Object.fromEntries
+ // in any very specific way
+ return Object.fromEntries(
+ Object.keys(example).map(k => [k, ''])
+ ) as StringFields
+}
+
+export function hasField(obj: object, field: string) {
+ return Object.prototype.hasOwnProperty.call(obj, field)
+}
diff --git a/src/shared/layoutUtilities.ts b/src/shared/layoutUtilities.ts
new file mode 100644
index 00000000..7f1a863a
--- /dev/null
+++ b/src/shared/layoutUtilities.ts
@@ -0,0 +1,9 @@
+/* Helper functions for laying out the user interface */
+export function isMobile() {
+ const tabletBreakpoint = parseInt(
+ window
+ .getComputedStyle(document.documentElement)
+ .getPropertyValue('--ns-breakpoint-tablet')
+ )
+ return window.innerWidth < tabletBreakpoint
+}
diff --git a/src/shared/math.ts b/src/shared/math.ts
index ec599417..27b054d0 100644
--- a/src/shared/math.ts
+++ b/src/shared/math.ts
@@ -1,49 +1,77 @@
/** md
## Math utilities for numberscope
-The primary resource for doing math inside the frontscope code is the
-[mathjs](http://mathjs.org) package, which this repository depends on.
-The `shared/math` module of Numberscope provides some additional utilities
-aimed at making common mathematical operations more convenient, especially
-when working with bigints.
+Generally speaking, you should obtain all of your functions for doing
+math in frontscope code from this incorporated `shared/math` module. It is
+based primarily on the [mathjs](http://mathjs.org) package.
-Note that currently every one of these functions accepts either
-`number` or `bigint` inputs for all arguments and simply converts them
-to bigints.
+Note in particular that you should only use the random number generators from
+mathjs supplied by this module, namely
+[`math.random()`](https://mathjs.org/docs/reference/functions/random.html),
+[`math.randomInt()`](
+https://mathjs.org/docs/reference/functions/randomInt.html),
+and/or
+[`math.pickRandom()`](https://mathjs.org/docs/reference/functions/random.html).
+The testing framework used for frontscope will fail if the built-in JavaScript
+`Math.random()` is used.
+
+Other than that, only the Numberscope extensions to mathjs are documented
+below; refer to the [mathjs documentation](http://mathjs.org/docs) to see all
+of the other facilities available. We also have some additional tips for
+[working with bigint numbers](../../doc/working-with-bigints.md) in
+Numberscope.
+
+Note that currently every one of the extension functions described
+below accepts either `number` or `bigint` inputs for all arguments and
+simply converts them to bigints as needed.
### Example usage
```ts
-import {
- safeNumber,
- floorSqrt,
- modulo,
- powmod,
- natlog
-} from '../shared/math'
+import {math} from '@/shared/math'
+
+// Example of a standard mathjs function: random integer
+// from 1, 2, 3, 4, 5, 6 (note right endpoint is exclusive).
+const myDie: number = math.randomInt(1, 7)
+
+// Remaining examples are Numberscope extensions
try {
- const myNumber = safeNumber(9007199254740992n)
+ const myNumber = math.safeNumber(9007199254740992n)
} catch (err: unknown) {
console.log('This will always be logged')
}
try {
- const myNumber = safeNumber(9007n)
+ const myNumber = math.safeNumber(9007n)
} catch (err: unknown) {
console.log('This will never be logged and myNumber will be 9007')
}
-const five: bigint = floorSqrt(30n)
-const negativeTwo: bigint = floorSqrt(-2n)
+// ExtendedBigint is the type of bigints completed with ±infinity
+const inf: ExtendedBigint = math.posInfinity
+const neginf: ExtendedBigint = math.negInfinity
-const three: bigint = modulo(-7n, 5)
-const two: bigint = modulo(7, 5n)
+// Like Math.floor, but with BigInt result type:
+const negTwo: bigint = math.bigInt(-1.5)
-const six: bigint = powmod(6, 2401n, 7n)
+const five: bigint = math.floorSqrt(30n)
+const negativeTwo: bigint = math.floorSqrt(-2n)
+
+const three: bigint = math.modulo(-7n, 5)
+const two: bigint = math.modulo(7, 5n)
+
+const isFactor: boolean = math.divides(6, 24n) // true
+const isntFactor: boolean = math.divides(7n, 12) // false
+
+const six: bigint = math.powmod(6, 2401n, 7n)
// n to the p is congruent to n mod a prime p,
// so a to any power of p is as well.
-const fortysixish: number = natlog(100000000000000000000n)
+const fortysixish: number = math.natlog(100000000000000000000n)
+
+const seven: bigint = math.bigabs(-7n)
-const seven: bigint = bigabs(-7n)
+const twelve: ExtendedBigint = math.bigmax(5n, 12, -3)
+const negthree: ExtendedBigint = math.bigmin(5n, 12, -3)
+const anotherNegInf = math.bigmin(5n, math.negInfinity, -3)
```
### Detailed function reference
@@ -51,6 +79,44 @@ const seven: bigint = bigabs(-7n)
import isqrt from 'bigint-isqrt'
import {modPow} from 'bigint-mod-arith'
+import {create, all} from 'mathjs'
+import type {EvalFunction, MathJsInstance, MathType, SymbolNode} from 'mathjs'
+import temml from 'temml'
+
+import type {ValidationStatus} from './ValidationStatus'
+
+export type {MathNode, SymbolNode} from 'mathjs'
+type Integer = number | bigint
+
+// eslint-disable-next-line no-loss-of-precision
+export type TposInfinity = 1e999 // since that's above range for number,
+// it becomes the type for IEEE Infinity ("official" hack to make this type,
+// see https://github.com/microsoft/TypeScript/issues/31752)
+// eslint-disable-next-line no-loss-of-precision
+export type TnegInfinity = -1e999 // similarly
+export type ExtendedBigint = bigint | TposInfinity | TnegInfinity
+
+type ExtendedMathJs = MathJsInstance & {
+ negInfinity: TnegInfinity
+ posInfinity: TposInfinity
+ safeNumber(n: Integer): number
+ floorSqrt(n: Integer): bigint
+ modulo(n: Integer, modulus: Integer): bigint
+ divides(a: Integer, b: Integer): boolean
+ powmod(n: Integer, exponent: Integer, modulus: Integer): bigint
+ natlog(n: Integer): number
+ bigInt(a: Integer): bigint
+ bigabs(a: Integer): bigint
+ bigmax(...args: Integer[]): ExtendedBigint
+ bigmin(...args: Integer[]): ExtendedBigint
+ valuation(a: Integer, b: Integer): number
+ biggcd(a: Integer, b: Integer): ExtendedBigint
+}
+
+export const math = create(all) as ExtendedMathJs
+
+math.negInfinity = -Infinity as TnegInfinity
+math.posInfinity = Infinity as TposInfinity
const maxSafeNumber = BigInt(Number.MAX_SAFE_INTEGER)
const minSafeNumber = BigInt(Number.MIN_SAFE_INTEGER)
@@ -61,7 +127,7 @@ const minSafeNumber = BigInt(Number.MIN_SAFE_INTEGER)
Returns the `number` mathematically equal to _n_ if there is one, or
throws an error otherwise.
**/
-export function safeNumber(n: number | bigint): number {
+math.safeNumber = (n: Integer): number => {
const bn = BigInt(n)
if (bn < minSafeNumber || bn > maxSafeNumber) {
throw new RangeError(`Attempt to use ${bn} as a number`)
@@ -76,10 +142,11 @@ Returns the largest bigint _r_ such that the square of _r_ is less than or
equal to _n_, if there is one; otherwise returns the bigint mathematically
equal to _n_. (Thus, it leaves negative bigints unchanged.)
**/
-export function floorSqrt(n: number | bigint): bigint {
+export function floorSqrt(n: Integer): bigint {
const bn = BigInt(n)
return isqrt(bn)
}
+math.floorSqrt = (n: Integer): bigint => isqrt(BigInt(n))
/** md
#### modulo(n: number | bigint, modulus: number | bigint): bigint
@@ -91,7 +158,7 @@ value, unlike the built-in JavaScript `%` operator.
Throws a RangeError if _modulus_ is nonpositive.
**/
-export function modulo(n: number | bigint, modulus: number | bigint): bigint {
+math.modulo = (n: Integer, modulus: Integer): bigint => {
const bn = BigInt(n)
const bmodulus = BigInt(modulus)
if (bmodulus <= 0n) {
@@ -101,6 +168,19 @@ export function modulo(n: number | bigint, modulus: number | bigint): bigint {
return result < 0n ? result + bmodulus : result
}
+/** md
+#### divides(a: number| bigint, b: number | bigint): boolean
+
+Returns true if and only if the integer _a_ divides (evenly into) the integer
+_b_.
+**/
+math.divides = (a: Integer, b: Integer): boolean => {
+ let an = BigInt(a)
+ if (an === 0n) return b >= 0 && b <= 0 // b==0 that works with more types
+ if (an < 0n) an = -an
+ return math.modulo(b, a) === 0n
+}
+
/** md
#### powmod(n, exponent, modulus): bigint
@@ -115,7 +195,7 @@ If _exponent_ is negative, first computes `i = powmod(n, -exponent, modulus)`.
If _i_ has a multiplicative inverse modulo _modulus_, returns that inverse,
otherwise throws a RangeError.
**/
-export const powmod = modPow // just need to fix the name
+math.powmod = modPow
const nlg16 = Math.log(16)
@@ -124,7 +204,7 @@ const nlg16 = Math.log(16)
Returns the natural log of the input.
**/
-export function natlog(n: number | bigint): number {
+math.natlog = (n: Integer): number => {
if (typeof n === 'number') return Math.log(n)
if (n < 0) return NaN
@@ -135,11 +215,227 @@ export function natlog(n: number | bigint): number {
}
/** md
-#### bigabs(n: bigint): bigint
+#### bigInt(n: number | bigint): bigint
+
+Returns the floor of n as a bigint
+**/
+math.bigInt = (n: Integer): bigint => {
+ if (typeof n === 'bigint') return n
+ return BigInt(Math.floor(n))
+}
+
+/** md
+#### bigabs(n: number | bigint): bigint
-returns the absolute value of a bigint
+returns the absolute value of a bigint or an integer number
**/
-export function bigabs(n: bigint): bigint {
+math.bigabs = (a: Integer): bigint => {
+ const n = BigInt(a)
if (n < 0n) return -n
return n
}
+
+/** md
+#### bigmax(...args: number | bigint): ExtendedBigint
+
+returns the largest its arguments, which may be bigints and/or integer
+numbers. Note the result has to be an extended bigint because one of the
+numbers might be Infinity.
+**/
+math.bigmax = (...args: Integer[]): ExtendedBigint => {
+ let ret: ExtendedBigint = math.negInfinity
+ for (const a of args) {
+ if (a > ret) {
+ if (a === math.posInfinity) return math.posInfinity
+ if (typeof a === 'number') ret = BigInt(a)
+ else ret = a
+ }
+ }
+ return ret
+}
+
+/** md
+#### bigmin(...args: number | bigint): ExtendedBigint
+
+returns the smallest of its arguments, with the same conditions as bigmax.
+**/
+math.bigmin = (...args: Integer[]): ExtendedBigint => {
+ let ret: ExtendedBigint = math.posInfinity
+ for (const a of args) {
+ if (a < ret) {
+ if (a === math.negInfinity) return math.negInfinity
+ if (typeof a === 'number') ret = BigInt(a)
+ else ret = a
+ }
+ }
+ return ret
+}
+
+/** md
+#### valuation(a: number | bigint, b: number | bigint): number
+
+Returns the number of times the integer _b_ divides the integer _a_.
+The integer _b_ must exceed 1.
+If _b_ is prime, this is also known as the _b_-adic valuation of _a_.
+The returned number will be less than MAX_SAFE_INTEGER simply
+because to exceed that answer, the input _a_ would need to be at
+least 2^53 binary digits -- which would take over a thousand
+terabytes to store.
+**/
+math.valuation = (a: Integer, b: Integer): number => {
+ const bn = BigInt(b)
+ if (bn < 2n) {
+ throw new RangeError(
+ `Attempt to use valuation with '
+ + 'respect to too-small divisor ${bn}`
+ )
+ }
+ let an = BigInt(a)
+ if (an == 0n) {
+ return +Infinity
+ }
+ let v = 0
+ while (math.divides(bn, an)) {
+ an = an / bn
+ v++
+ }
+ return v
+}
+
+/** md
+#### biggcd(a: number| bigint, b: number | bigint): boolean
+
+Returns the greatest common divisor of _a_ and _b_.
+**/
+math.biggcd = (a: Integer, b: Integer): bigint => {
+ let an = BigInt(a)
+ let bn = BigInt(b)
+ if (an < 0n) an = -an
+ if (bn < 0n) bn = -bn
+ if (an <= 0n) return bn
+ while (bn > 0n) {
+ an = math.modulo(an, bn)
+ if (an <= 0n) return bn
+ bn = math.modulo(bn, an)
+ }
+ return an
+}
+
+/* Helper for outputting scopes: */
+type ScopeType = Record>
+function scopeToString(scope: ScopeType) {
+ return Object.entries(scope)
+ .map(([variable, value]) => `${variable}=${math.format(value)}`)
+ .join(', ')
+}
+const maxWarns = 3
+const mathMark = 'mathjs: '
+/**
+ * Class to encapsulate a mathjs formula
+ */
+export class MathFormula {
+ evaluator: EvalFunction
+ inputs: string[]
+ source: string
+ canonical: string
+ latex: string
+ mathml: string
+ freevars: string[]
+ constructor(fmla: string, inputs?: string[]) {
+ const parsetree = math.parse(fmla)
+ this.freevars = parsetree
+ .filter((node, path) => math.isSymbolNode(node) && path !== 'fn')
+ .map(node => (node as SymbolNode).name)
+ if (inputs) {
+ this.inputs = inputs
+ } else {
+ // inputs default to all free variables
+ this.inputs = this.freevars
+ }
+ this.source = fmla
+ this.canonical = parsetree.toString({parenthesis: 'auto'})
+ this.latex = 'a_n = ' + parsetree.toTex({parenthesis: 'auto'})
+ this.mathml = temml.renderToString(this.latex, {wrap: 'tex'})
+ this.evaluator = parsetree.compile()
+ }
+ /**
+ * The recommended way to obtain the value of a mathjs formula object:
+ * Call it with a ValidationStatus object, and either the values for
+ * the input variables (if any) as additional arguments, or a single
+ * plain object with keys the input variable names and values their
+ * values to be used in computing the formula.
+ * It checks for errors in the computation and if any occur, adds
+ * warnings to the provided status object.
+ * @param {ValidationStatus} status The object to report warnings to
+ * @param {object | MathType, MathType, ...} the inputs to the formula
+ * @returns {MathType} the result of evaluating the formula
+ */
+ computeWithStatus(
+ status: ValidationStatus,
+ a: number | Record,
+ ...rst: number[]
+ ) {
+ let scope: ScopeType = {}
+ if (typeof a === 'object' && this.inputs.every(i => i in a)) {
+ scope = a
+ } else {
+ scope = {[this.inputs[0]]: a}
+ for (let ix = 0; ix < rst.length; ++ix) {
+ scope[this.inputs[ix + 1]] = rst[ix]
+ }
+ }
+ let result: MathType = 0
+ try {
+ result = this.evaluator.evaluate(scope)
+ } catch (err: unknown) {
+ let nWarns = status.warnings.reduce(
+ (k, warning) => k + (warning.startsWith(mathMark) ? 1 : 0),
+ 0
+ )
+ nWarns++ // We're about to add one
+ if (nWarns < maxWarns) {
+ status.addWarning(
+ `${mathMark}value for ${scopeToString(scope)} set to `
+ + `${result} because of `
+ + (err instanceof Error
+ ? err.message
+ : 'of unknown error.')
+ )
+ } else if (nWarns === maxWarns) {
+ status.addWarning(`${mathMark}1 additional warning discarded`)
+ } else {
+ // replace discarded message
+ const warnIndex = status.warnings.findIndex(
+ element =>
+ element.startsWith(mathMark)
+ && element.endsWith('discarded')
+ )
+ if (warnIndex >= 0) {
+ const oldWarn = status.warnings[warnIndex]
+ status.warnings.splice(warnIndex, 1)
+ nWarns = parseInt(oldWarn.substr(mathMark.length)) + 1
+ }
+ status.addWarning(
+ `${mathMark}${nWarns} additional warnings discarded`
+ )
+ }
+ }
+ return result
+ }
+
+ /**
+ * The non-recommended way to compute with a mathjs formula object;
+ * operates just like computeWithStatus except takes no status
+ * argument and does no error checking.
+ */
+ compute(a: number | Record, ...rst: number[]) {
+ if (typeof a === 'object' && this.inputs.every(i => i in a)) {
+ return this.evaluator.evaluate(a)
+ }
+ const scope = {[this.inputs[0]]: a}
+ for (let ix = 0; ix < rst.length; ++ix) {
+ scope[this.inputs[ix + 1]] = rst[ix]
+ }
+ return this.evaluator.evaluate(scope)
+ }
+}
diff --git a/src/shared/specimenEncoding.ts b/src/shared/specimenEncoding.ts
new file mode 100644
index 00000000..a5a880a1
--- /dev/null
+++ b/src/shared/specimenEncoding.ts
@@ -0,0 +1,106 @@
+/* This file defines how specimens are encoded into strings, and how
+ strings are decoded into the information needed to create a specimen.
+ At the moment (and likely permanently), the encodes are URL query
+ parameter strings.
+*/
+
+const vizKey = 'viz'
+export const seqKey = 'seq'
+type QuerySpec = {
+ name: string
+ visualizerKind: string
+ sequenceKind: string
+ visualizerQuery: string
+ sequenceQuery: string
+}
+/**
+ * Generates a URL query string from the information specifying a specimen.
+ *
+ * @param {string | QuerySpec} nameOrSpec
+ * The name of the specimen, or an object with key `name` and all of
+ * the other argument names as keys, in which case the other arguments
+ * are taken from this object instead
+ * @param {string} visualizerKind The kind of Visualizer
+ * @param {string} sequenceKind The kind of Sequence
+ * @param {string?} visualizerQuery Optional visualizer query parameter string
+ * @param {string?} sequenceQuery Optional sequence query parameter string
+ * @return {string} the URL query string encoding of the parameter
+ */
+export function specimenQuery(
+ nameOrSpec: string | QuerySpec,
+ visualizerKind?: string,
+ sequenceKind?: string,
+ visualizerQuery?: string,
+ sequenceQuery?: string
+): string {
+ let name = ''
+ if (!visualizerKind) {
+ // Only one arg, must be query
+ const spec = nameOrSpec as QuerySpec
+ name = spec.name
+ visualizerKind = spec.visualizerKind
+ sequenceKind = spec.sequenceKind
+ visualizerQuery = spec.visualizerQuery
+ sequenceQuery = spec.sequenceQuery
+ } else {
+ name = nameOrSpec as string
+ }
+ if (!sequenceKind) return ''
+ const leadQuery = new URLSearchParams({
+ name,
+ [vizKey]: visualizerKind,
+ })
+ const sepQuery = new URLSearchParams({[seqKey]: sequenceKind})
+ const queries = [leadQuery.toString()]
+ if (visualizerQuery) queries.push(visualizerQuery)
+ queries.push(sepQuery.toString())
+ if (sequenceQuery) queries.push(sequenceQuery)
+ return queries.join('&')
+}
+/**
+ * Splits a URL query string for a specimen into its constituent parts
+ * Returns an object with keys `name`, `visualizerKind`, `specimenKind`,
+ * `visualizerQuery`, and `sequenceQuery`, corresponding to the five
+ * arguments of specimenQuery(). I.e., this function inverts specimenQuery().
+ *
+ * @param {string} query A URL query string encoding a specimen
+ * @return {object} representation of components as decribed above.
+ */
+export function parseSpecimenQuery(query: string) {
+ const params = new URLSearchParams(query)
+ const name = params.get('name') || 'Error: Unknown Name'
+ // We never insert a frame count in queries we generate, but
+ // we do parse it out in case it was specified, e.g. to make
+ // tests reproducible
+ const frames = parseFloat(params.get('frames') || 'Infinity')
+ // Similarly, we never insert a seed, but parse it in case it
+ // was specified
+ const seed = params.get('randomSeed') || null
+ const visualizerKind =
+ params.get(vizKey) || 'Error: No visualizer kind specified'
+ const sequenceKind =
+ params.get(seqKey) || 'Error: No sequence kind specified'
+ const vizPat = new RegExp(`&${vizKey}=[^&]*&`, 'd')
+ const seqPat = new RegExp(`&${seqKey}=[^&]*&?`, 'd')
+ let visualizerQuery = ''
+ let sequenceQuery = ''
+ const vizMatch = query.match(vizPat)
+ const seqMatch = query.match(seqPat)
+ if (vizMatch?.indices && seqMatch?.index && seqMatch?.indices) {
+ const firstAfterViz = vizMatch.indices[0][1]
+ if (seqMatch.index > firstAfterViz)
+ visualizerQuery = query.substring(firstAfterViz, seqMatch.index)
+ const firstAfterSeq = seqMatch.indices[0][1]
+ if (firstAfterSeq < query.length)
+ sequenceQuery = query.substring(firstAfterSeq)
+ }
+ return {
+ name,
+ frames,
+ seed,
+ visualizerKind,
+ sequenceKind,
+ visualizerQuery,
+ sequenceQuery,
+ }
+}
diff --git a/src/views/Gallery.vue b/src/views/Gallery.vue
new file mode 100644
index 00000000..8c79af8e
--- /dev/null
+++ b/src/views/Gallery.vue
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
+
+
Featured Gallery
+
+ keyboard_arrow_up
+
+
+
+
+
+
Saved Specimens
+
+ keyboard_arrow_up
+
+
+
+
+
+
+
+
+
diff --git a/src/views/Home.vue b/src/views/Home.vue
deleted file mode 100644
index 8fbffdfb..00000000
--- a/src/views/Home.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-
-
-
-
- The Online Tool for Visualizing Integer Sequences
-
-
- Go to the 'Scope
-
-
- By using this website, you agree that any images or media you
- create using the tools on this website are released by you into
- the public domain according to
- CC0 1.0 .
-
-
-
-
-
-
-
diff --git a/src/views/Scope.vue b/src/views/Scope.vue
index 330184d3..c28e05cd 100644
--- a/src/views/Scope.vue
+++ b/src/views/Scope.vue
@@ -1,156 +1,781 @@
-
-
-
-
-
-
-
+
+
+
+
+
{
+ continueVisualizer()
+ changeSequenceOpen = false
+ }
+ " />
+
+ {
+ continueVisualizer()
+ changeVisualizerOpen = false
+ }
+ " />
+
+ {
+ await specimen.updateSequence()
+ continueVisualizer()
+ updateURL()
+ }
+ " />
+
+
+ {
+ continueVisualizer()
+ updateURL()
+ }
+ " />
+
+
+
+
+
+
-
diff --git a/src/views/__tests__/Gallery.spec.ts b/src/views/__tests__/Gallery.spec.ts
new file mode 100644
index 00000000..6516c0f7
--- /dev/null
+++ b/src/views/__tests__/Gallery.spec.ts
@@ -0,0 +1,8 @@
+import Gallery from '../Gallery.vue'
+import {expect, test} from 'vitest'
+import {mount} from '@vue/test-utils'
+
+test('should contain specimen gallery', () => {
+ const wrapper = mount(Gallery, {shallow: true})
+ expect(wrapper.html()).toContain('Specimen gallery')
+})
diff --git a/src/views/__tests__/Home.spec.ts b/src/views/__tests__/Home.spec.ts
deleted file mode 100644
index dc3f7f4b..00000000
--- a/src/views/__tests__/Home.spec.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import Home from '../Home.vue'
-import {expect, test} from 'vitest'
-import {mount} from '@vue/test-utils'
-
-test('should contain link to scope', () => {
- const wrapper = mount(Home, {shallow: true})
- expect(wrapper.html()).toContain('/scope')
-})
diff --git a/src/views/minor/Footer.vue b/src/views/minor/Footer.vue
index a469658b..1bf51d84 100644
--- a/src/views/minor/Footer.vue
+++ b/src/views/minor/Footer.vue
@@ -1,39 +1,55 @@
-
+
+
Thank you very much to
+ those who made Numberscope possible
+
over the years!
-
-
-
-
-
-
-
-
+
+
+
+
+
-
diff --git a/src/views/minor/__tests__/NavBar.spec.ts b/src/views/minor/__tests__/NavBar.spec.ts
index 2ce984a7..84ec2f14 100644
--- a/src/views/minor/__tests__/NavBar.spec.ts
+++ b/src/views/minor/__tests__/NavBar.spec.ts
@@ -1,11 +1,13 @@
-import NavBar from '../NavBar.vue'
import {expect, test} from 'vitest'
import {mount} from '@vue/test-utils'
+import NavBar from '../NavBar.vue'
+import {Specimen} from '../../../shared/Specimen'
+
test('should have links to home, scope, about, and documentation', () => {
- const wrapper = mount(NavBar, {shallow: true})
+ const specimen = new Specimen('Formula', 'Turtle')
+ const wrapper = mount(NavBar, {props: {specimen}, shallow: true})
expect(wrapper.html()).toContain('/')
- expect(wrapper.html()).toContain('/scope')
expect(wrapper.html()).toContain('/about')
expect(wrapper.html()).toContain('/doc')
})
diff --git a/src/visualizers/Grid.ts b/src/visualizers-workbench/Grid.ts
similarity index 86%
rename from src/visualizers/Grid.ts
rename to src/visualizers-workbench/Grid.ts
index 62f3d891..f9fd6912 100644
--- a/src/visualizers/Grid.ts
+++ b/src/visualizers-workbench/Grid.ts
@@ -1,12 +1,18 @@
-import {VisualizerExportModule} from '@/visualizers/VisualizerInterface'
-import {P5Visualizer} from '@/visualizers/P5Visualizer'
-import {bigabs, floorSqrt, modulo} from '@/shared/math'
-import type {ParamInterface} from '@/shared/Paramable'
+import {P5Visualizer} from '../visualizers/P5Visualizer'
+import {VisualizerExportModule} from '../visualizers/VisualizerInterface'
+
import type {
SequenceInterface,
Factorization,
} from '@/sequences/SequenceInterface'
import simpleFactor from '@/sequences/simpleFactor'
+import {math} from '@/shared/math'
+import type {GenericParamDescription} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
+
+// NOTE: Grid visualizer is not currently working due to the new Paramable
+// system, which is why it has been moved to `visualizers-workbench`
+// Perhaps an issue should be opened to fix this
/** md
# Grid Visualizer
@@ -239,6 +245,7 @@ function getPropertyParams(index: number, prop: PropertyObject) {
return {
[`property${index}`]: {
value: prop.property,
+ type: ParamType.ENUM,
from: Property,
displayName: `Property ${index + 1}`,
required: false,
@@ -247,6 +254,7 @@ function getPropertyParams(index: number, prop: PropertyObject) {
},
[`prop${index}Vis`]: {
value: prop.visualization,
+ type: ParamType.ENUM,
from: PropertyVisualization,
displayName: 'Display',
required: false,
@@ -255,7 +263,7 @@ function getPropertyParams(index: number, prop: PropertyObject) {
},
[`prop${index}Color`]: {
value: prop.color,
- forceType: 'color',
+ type: ParamType.COLOR,
displayName: 'Color',
required: false,
visibleDependency: `property${index}`,
@@ -263,6 +271,7 @@ function getPropertyParams(index: number, prop: PropertyObject) {
},
[`prop${index}Aux`]: {
value: prop.aux,
+ type: ParamType.BIGINT,
displayName: (d: Property) => propertyAuxName[Property[d]] || '',
required: false,
visibleDependency: `property${index}`,
@@ -341,9 +350,9 @@ function isPolygonal(num: bigint, order = 3n): boolean {
return M === num
}
-const isAbundant = (n: bigint) => getSumOfProperDivisors(n) > bigabs(n)
+const isAbundant = (n: bigint) => getSumOfProperDivisors(n) > math.bigabs(n)
const isPerfect = (n: bigint) => n > 1n && getSumOfProperDivisors(n) === n
-const isDeficient = (n: bigint) => getSumOfProperDivisors(n) < bigabs(n)
+const isDeficient = (n: bigint) => getSumOfProperDivisors(n) < math.bigabs(n)
function isSemiPrime(factors: Factorization): boolean {
if (factors === null) throw new Error('Internal error in Grid')
@@ -373,7 +382,7 @@ function divisibleBy(value: bigint, divisor = 3n) {
}
function congruenceIndicator(modulus: bigint, residue: bigint) {
- return (value: bigint) => modulo(value, modulus) === residue
+ return (value: bigint) => math.divides(modulus, value - residue)
}
function lastDigitIs(value: bigint, digit = 0n) {
@@ -415,43 +424,13 @@ const propertyIndicatorFunction: {
Semi_Prime: isSemiPrime,
}
-class Grid extends P5Visualizer {
- static visualizationName = 'Grid'
-
- // Grid variables
- amountOfNumbers = 4096
- sideOfGrid = 64
- currentIndex = 0
- startingIndex = 0
- currentNumber = 0n
- showNumbers = false
- preset = Preset.Custom
- pathType = PathType.Spiral
- resetAndAugmentByOne = false
- backgroundColor = BLACK
- numberColor = WHITE
-
- // Path variables
- x = 0
- y = 0
- scalingFactor = 0
- currentDirection = Direction.Right
- numberToTurnAtForSpiral = 0
- incrementForNumberToTurnAt = 1
- whetherIncrementShouldIncrement = true
-
- // Properties
- propertyObjects: PropertyObject[] = []
- primaryProperties: number[] = []
- secondaryProperties: number[] = []
-
- params: {[key: string]: ParamInterface} = {
- /** md
+const paramDesc = {
+ /** md
### Presets: Which preset to display
If a preset other than `Custom` is selected, then the `Properties`
-portion of the dialog is overriden. For details on the meanings of the terms
-below, see the
+portion of the dialog is overriden. For details on the meanings of the
+terms below, see the
[Properties](#property-1-2-etc-properties-to-display-by-coloring-cells)
section of the documentation.
@@ -459,100 +438,111 @@ section of the documentation.
- Primes: primes are shown in red
- Abundant_Numbers: the abundant numbers are shown in black
- Abundant_Numbers_And_Primes: the primes are shown in red and the abundant
- numbers in black
+numbers in black
- Polygonal_Numbers: the polygonal numbers are shown in a variety of
- different colors (one for each type of polygon)
+different colors (one for each type of polygon)
- Color_By_Last_Digit_1: the last digit is shown (one color for each digit
- in a rainbow style)
+in a rainbow style)
- Color_By_Last_Digit_2: a variation on the last, where odd digits are
- indicated by smaller boxes
- **/
- preset: {
- value: this.preset,
- from: Preset,
- displayName: 'Presets',
- required: false,
- description:
- 'If a preset is selected, properties no longer function.',
- },
-
- /** md
-### Grid cells: The number of cells to display in the grid
-
-This will be rounded down to the nearest square integer.
-This may get laggy when it is in the thousands or higher, depending on the
-property being tested.
- **/
- amountOfNumbers: {
- value: this.amountOfNumbers,
- displayName: 'Grid cells',
- required: false,
- description: 'Warning: display lags over 10,000 cells',
- },
-
- /** md
-### Starting Index: The sequence index at which to begin
- **/
- startingIndex: {
- value: this.startingIndex,
- displayName: 'Starting Index',
- required: false,
- description: '',
- },
-
- /** md
+indicated by smaller boxes
+ **/
+ preset: {
+ default: Preset.Custom,
+ type: ParamType.ENUM,
+ from: Preset,
+ displayName: 'Presets',
+ required: false,
+ description:
+ 'If a preset is selected, properties no longer function.',
+ },
+
+ /** md
### Path in grid: The path to follow while filling numbers into the grid.
- Spiral: An Ulam-type square spiral starting at the center of grid.
- Rows: Left-to-right, top-to-bottom in rows.
- Rows_Augment: Each row restarts the sequence from the starting index,
- but adds the row number to the sequence _values_.
- **/
- pathType: {
- value: this.pathType,
- from: PathType,
- displayName: 'Path in grid',
- required: false,
- },
-
- /** md
+but adds the row number to the sequence _values_.
+ **/
+ pathType: {
+ default: PathType.Spiral,
+ type: ParamType.ENUM,
+ from: PathType,
+ displayName: 'Path in grid',
+ required: false,
+ },
+
+ /** md
### Show numbers: Whether to show values overlaid on cells
-When this is selected, the number of cells in the grid will be limited to 400
+When this is selected, the number of cells in the grid will be
+limited to 400
even if you choose more.
- **/
- showNumbers: {
- value: this.showNumbers,
- forceType: 'boolean',
- displayName: 'Show numbers',
- required: false,
- description: 'When true, grid is limited to 400 cells',
- },
-
- /** md
+ **/
+ showNumbers: {
+ default: false,
+ type: ParamType.BOOLEAN,
+ displayName: 'Show numbers',
+ required: false,
+ description: 'When true, grid is limited to 400 cells',
+ },
+
+ /** md
### Number color: The font color of displayed numbers
-This parameter is only available when the "Show Numbers" parameter is checked.
- **/
- numberColor: {
- value: this.numberColor,
- forceType: 'color',
- displayName: 'Number color',
- required: false,
- visibleDependency: 'showNumbers',
- visiblePredicate: (dependentValue: boolean) =>
- dependentValue === true,
- },
- /** md
+This parameter is only available when the "Show Numbers" parameter is
+checked.
+ **/
+ numberColor: {
+ default: WHITE,
+ type: ParamType.COLOR,
+ displayName: 'Number color',
+ required: false,
+ visibleDependency: 'showNumbers',
+ visiblePredicate: (dependentValue: boolean) =>
+ dependentValue === true,
+ },
+ /** md
### Background Color: Background color of the grid
- **/
- backgroundColor: {
- value: this.backgroundColor,
- forceType: 'color',
- displayName: 'Background color',
- required: false,
- },
- }
+ **/
+ backgroundColor: {
+ default: BLACK,
+ type: ParamType.COLOR,
+ displayName: 'Background color',
+ required: false,
+ },
+} satisfies GenericParamDescription
+
+class Grid extends P5Visualizer(paramDesc) {
+ static category = 'Grid'
+ static description =
+ 'Puts numbers in a grid, highlighting cells based on various properties'
+
+ // Grid variables
+ nEntries = 4096n
+ sideOfGrid = 64
+ currentIndex = 0n
+ currentNumber = 0n
+ showNumbers = false
+ preset = Preset.Custom
+ pathType = PathType.Spiral
+ resetAndAugmentByOne = false
+ backgroundColor = BLACK
+ numberColor = WHITE
+
+ // Path variables
+ x = 0
+ y = 0
+ scalingFactor = 0
+ currentDirection = Direction.Right
+ numberToTurnAtForSpiral = 0
+ incrementForNumberToTurnAt = 1
+ whetherIncrementShouldIncrement = true
+
+ // Properties
+ propertyObjects: PropertyObject[] = []
+ primaryProperties: number[] = []
+ secondaryProperties: number[] = []
constructor(seq: SequenceInterface) {
super(seq)
@@ -616,19 +606,25 @@ earlier ones that use the _same_ style.)
}
}
- assignParameters(): void {
- super.assignParameters()
+ async assignParameters() {
+ await super.assignParameters()
+ // NOTE: This is commented out because it breaks the new type safety
+ // of the parameters. I wasn't able to figure out exactly what the
+ // intent is, but the strings being accessed are not parameters
+ // according to the parameter description
+ /*
for (let i = 0; i < MAXIMUM_ALLOWED_PROPERTIES; i++) {
- this.propertyObjects[i].property = this.params[`property${i}`]
- .value as Property
- this.propertyObjects[i].visualization = this.params[`prop${i}Vis`]
- .value as PropertyVisualization
- this.propertyObjects[i].color = this.params[`prop${i}Color`]
- .value as string
- this.propertyObjects[i].aux = this.params[`prop${i}Aux`]
- .value as bigint
+ this.propertyObjects[i].property =
+ this.tentativeValues[`property${i}`] as Property
+ this.propertyObjects[i].visualization =
+ this.tentativeValues[`prop${i}Vis`] as PropertyVisualization
+ this.propertyObjects[i].color =
+ this.tentativeValues[`prop${i}Color`].value as string
+ this.propertyObjects[i].aux =
+ this.tentativeValues[`prop${i}Aux`].value as bigint
}
+ */
}
setup(): void {
@@ -646,35 +642,28 @@ earlier ones that use the _same_ style.)
PropertyVisualization.Box_In_Cell
)
- this.amountOfNumbers = Math.min(
- this.amountOfNumbers,
- this.seq.last - this.seq.first + 1
- )
+ if (typeof this.seq.length === 'bigint') {
+ this.nEntries = this.seq.length
+ } // else TODO: Post warning about not using all terms
// Round down amount of numbers so that it is a square number.
- this.sideOfGrid = Number(floorSqrt(this.amountOfNumbers))
- this.amountOfNumbers = this.sideOfGrid * this.sideOfGrid
+ const side = math.floorSqrt(this.nEntries)
+ this.sideOfGrid = Number(side)
+ this.nEntries = side * side
this.scalingFactor = this.sketch.width / this.sideOfGrid
this.setPathVariables(this.sideOfGrid)
}
draw(): void {
- this.currentIndex = Math.max(this.startingIndex, this.seq.first)
+ this.currentIndex = this.seq.first
let augmentForRowReset = 0n
- for (
- let iteration = 0;
- iteration < this.amountOfNumbers;
- iteration++
- ) {
+ for (let iteration = 0; iteration < this.nEntries; iteration++) {
// Reset current sequence for row reset and augment by one.
if (this.currentDirection === Direction.StartNewRow) {
if (this.resetAndAugmentByOne) {
- this.currentIndex = Math.max(
- this.startingIndex,
- this.seq.first
- )
+ this.currentIndex = this.seq.first
augmentForRowReset++
}
}
@@ -684,7 +673,7 @@ earlier ones that use the _same_ style.)
this.currentIndex++
this.moveCoordinatesUsingPath(iteration)
}
- this.sketch.noLoop()
+ this.stop()
}
setPresets() {
@@ -707,12 +696,12 @@ earlier ones that use the _same_ style.)
this.resetAndAugmentByOne = true
}
- if (this.showNumbers && this.amountOfNumbers > 400) {
- this.amountOfNumbers = 400
+ if (this.showNumbers && this.nEntries > 400n) {
+ this.nEntries = 400n
}
}
- setCurrentNumber(currentIndex: number, augmentForRow: bigint) {
+ setCurrentNumber(currentIndex: bigint, augmentForRow: bigint) {
this.currentNumber = this.seq.getElement(currentIndex)
this.currentNumber = this.currentNumber + augmentForRow
}
@@ -792,7 +781,7 @@ earlier ones that use the _same_ style.)
return retval
}
- hasProperty(ind: number, property: Property, aux?: bigint) {
+ hasProperty(ind: bigint, property: Property, aux?: bigint) {
const propertyName = Property[property] as PropertyName
if (propertyName in propertyOfFactorization) {
let factors: Factorization = null
@@ -857,7 +846,7 @@ earlier ones that use the _same_ style.)
// Go to new row when the row is complete
if ((iteration + 1) % this.sideOfGrid === 0) {
this.currentDirection = Direction.StartNewRow
- } else if (iteration === this.amountOfNumbers) {
+ } else if (BigInt(iteration) === this.nEntries) {
this.currentDirection = Direction.None
} else {
this.currentDirection = Direction.Right
@@ -885,10 +874,7 @@ earlier ones that use the _same_ style.)
}
}
-export const exportModule = new VisualizerExportModule(
- Grid,
- 'Puts numbers in a grid, highlighting cells based on various properties'
-)
+export const exportModule = new VisualizerExportModule(Grid)
/** md
diff --git a/src/visualizers-workbench/P5VisualizerTemplate.ts b/src/visualizers-workbench/P5VisualizerTemplate.ts
index b9d6fb3b..fecc51fb 100644
--- a/src/visualizers-workbench/P5VisualizerTemplate.ts
+++ b/src/visualizers-workbench/P5VisualizerTemplate.ts
@@ -13,8 +13,22 @@
//
// These comments get compiled into the Visualizer's user guide page.
-import {P5Visualizer} from '../visualizers/P5Visualizer'
-import {VisualizerExportModule} from '@/visualizers/VisualizerInterface'
+// === Import statements ===
+// These import functionality that is used to implement aspects of
+// the visualizer. See the referenced files for
+// further information on what these each do.
+//
+// Standard visualizer class and export:
+// INVALID_COLOR allows for initializing p5 color variables
+import {P5Visualizer, INVALID_COLOR} from '../visualizers/P5Visualizer'
+import {VisualizerExportModule} from '../visualizers/VisualizerInterface'
+import type {ViewSize} from '../visualizers/VisualizerInterface'
+
+// Standard parameter functionality:
+import type {GenericParamDescription} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
+// ValidationStatus allows for validation checking in parameters
+import {ValidationStatus} from '@/shared/ValidationStatus'
/** md
# Entries (p5 Template)
@@ -31,35 +45,56 @@ each entry as a written number.
_This visualizer is meant to be used as a template for new visualizers based on
the p5.js library. It includes explanatory comments and minimal examples of
required and commonly used features._
-**/
-
-class P5VisualizerTemplate extends P5Visualizer {
- // === Visualizer name ===
- // Appears in the visualizer list and bundle card titles
- static visualizationName = 'Entries (p5 Template)'
- // === Parameters ===
- // Top-level properties that the user can choose while creating the
- // visualizer bundle. If a parameter is meant to have a default value, we
- // conventionally use that as the initial value, so we can refer to it when
- // we set the default value below
- stepSize = 1
+## Parameters
+**/
+// === User-modifiable parameters ===
+const paramDesc = {
+ // Will be interpreted by the UI and presented to the user
+ // in the form of controls such as fields, drop-downs and
+ // color-pickers. More information can be found in
+ // src/shared/Paramable.ts
/** md
-## Parameters
- **/
- params = {
- /** md
- **Step size:** How far to step when the user presses an arrow key. _(Positive
- integer.)_
- **/
- stepSize: {
- value: this.stepSize, // === Default value ===
- forceType: 'integer',
- displayName: 'Step size',
- required: true,
+integer.)_
+ **/
+ stepSize: {
+ default: 1n, // Default value
+ type: ParamType.BIGINT, // Type validated by UI on user input
+ displayName: 'Step size', // Title of the field
+ // By convention, complete sentences with periods:
+ description:
+ 'The step size is added or subtracted to the current index '
+ + 'when you move right or left in the sequence.',
+ hideDescription: true, // put the description in a tooltip
+ // If required = true, default value is entered in field
+ // If required = false, a greyed-out default or
+ // placeholder is shown until user interacts with field
+ // In both cases, default value is used, but the visual
+ // impact of the variable in the parameter panel differs
+ required: true,
+ // The type is validated automatically, but any further
+ // restriction on the input should be validated with
+ // a custom function here
+ validate: function (n: bigint, status: ValidationStatus) {
+ // By convention, individual-parameter messages are
+ // uncapitalized phrases without periods
+ if (n <= 0) status.addError('must be positive')
+ // If you create diagnostics involving multiple parameters
+ // with a checkParameters method in the Visualizer class below,
+ // (there isn't one in this example), those messages should
+ // again be complete sentences with periods, as they appear by
+ // themselves at the top of the parameter tab.
},
- }
+ },
+} satisfies GenericParamDescription
+
+class P5VisualizerTemplate extends P5Visualizer(paramDesc) {
+ // === Visualizer category (name of the class) and description ===
+ // Appears in the visualizer list and bundle card titles
+ static category = 'Entries (p5 Template)'
+ static description = 'Step through entries one at a time'
// === Internal properties ===
// Top-level properties that are set and updated while the visualizer is
@@ -71,23 +106,33 @@ class P5VisualizerTemplate extends P5Visualizer {
// to generate valid colors that p5 can draw with.
// navigation state
- index = 0
+ index = 0n
// palette colors
- bgColor = P5Visualizer.INVALID_COLOR
- textColor = P5Visualizer.INVALID_COLOR
- outlineColor = P5Visualizer.INVALID_COLOR
-
- checkParameters() {
- const status = super.checkParameters()
-
- // make sure the step size is positive
- if (this.params.stepSize.value <= 0) {
- status.isValid = false
- status.errors.push('Step size must be positive')
- }
-
- return status
+ bgColor = INVALID_COLOR
+ textColor = INVALID_COLOR
+ outlineColor = INVALID_COLOR
+
+ async presketch(size: ViewSize) {
+ // === Asynchronous setup ===
+ // If any pre-computations must be run before the sketch is created,
+ // placing them in the `presketch()` function will allow them
+ // to run asynchronously, i.e. without blocking the browser.
+ // The sketch will not be created until this function completes.
+
+ await super.presketch(size)
+ // The above call performs the default behavior of intializing the
+ // first cache block of the sequence.
+ // So down here is where you can do any computation-heavy preparation
+ // for your visualization; for example, you could ask that the
+ // _entire_ sequence and its factorizations be preloaded via
+ // `await this.seq.fill(this.seq.last)`.
+ // (But don't do that unless you really need _all_ the sequence
+ // values before you can draw anything at all, as for some sequences
+ // it would create a noticeable delay before the sketch appears.)
+ // Note also that this entire function is not needed if, as in this
+ // case, you have no such computations. (We only included it in this
+ // template for the sake of discussion.)
}
setup() {
@@ -157,7 +202,7 @@ class P5VisualizerTemplate extends P5Visualizer {
// draw a progress bar; see documentation below
const barScale = 7
- const sqrtDist = Math.sqrt(this.index - this.seq.first)
+ const sqrtDist = Math.sqrt(Number(this.index - this.seq.first))
const progress = 1 - barScale / (barScale + sqrtDist)
const barLen = 0.6 * smallDim
const barWidth = 0.02 * smallDim
@@ -185,7 +230,10 @@ class P5VisualizerTemplate extends P5Visualizer {
// (i.e., you're not animating anything or drawing progressively),
// prevent the browser from using excess processor effort by stopping
// the drawing loop:
- sketch.noLoop()
+ this.stop()
+ // Note you should not call the usual p5.js `noLoop()` or `loop()`
+ // methods on the sketch; use `this.stop()` and `this.continue()`
+ // instead.
}
// === Event handling ===
@@ -223,9 +271,9 @@ class P5VisualizerTemplate extends P5Visualizer {
} else {
// === Restarting the animation loop ===
// If your visualizer finished drawing for a while and so
- // called noLoop(), but an event changes what needs to be
- // displayed, make sure to restart by calling loop().
- sketch.loop()
+ // called stop(), but an event changes what needs to be
+ // displayed, make sure to restart by calling continue().
+ this.continue()
}
}
@@ -253,7 +301,4 @@ because infinity is, well, infinitely far away!
// Putting this at the end of the source file makes it easy for other people
// to find. Put the visualizer class and a short description string into the
// export module constructor
-export const exportModule = new VisualizerExportModule(
- P5VisualizerTemplate,
- 'Step through entries one at a time'
-)
+export const exportModule = new VisualizerExportModule(P5VisualizerTemplate)
diff --git a/src/visualizers/Chaos.ts b/src/visualizers/Chaos.ts
index b2b7e8a1..92e060a1 100644
--- a/src/visualizers/Chaos.ts
+++ b/src/visualizers/Chaos.ts
@@ -1,8 +1,13 @@
import p5 from 'p5'
-import {modulo} from '../shared/math'
-import {P5Visualizer} from './P5Visualizer'
+
+import {P5Visualizer, INVALID_COLOR} from './P5Visualizer'
import {VisualizerExportModule} from './VisualizerInterface'
+import {math} from '@/shared/math'
+import type {GenericParamDescription, ParamValues} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
+import {ValidationStatus} from '@/shared/ValidationStatus'
+
/** md
# Chaos Visualizer
@@ -31,8 +36,8 @@ class Palette {
this.backgroundColor = sketch.color(hexBack)
this.textColor = sketch.color(hexText)
} else {
- this.backgroundColor = P5Visualizer.INVALID_COLOR
- this.textColor = P5Visualizer.INVALID_COLOR
+ this.backgroundColor = INVALID_COLOR
+ this.textColor = INVALID_COLOR
}
}
}
@@ -44,197 +49,168 @@ enum ColorStyle {
Highlighting_one_walker,
}
-// other ideas: previous parts of the sequence fade over time,
-// or shrink over time;
-// circles fade to the outside
-
-class Chaos extends P5Visualizer {
- static visualizationName = 'Chaos'
- corners = 4
- frac = 0.5
- walkers = 1
- colorStyle = ColorStyle.Walker
- gradientLength = 10000
- highlightWalker = 0
- first = NaN
- last = NaN
- circSize = 1
- alpha = 0.9
- pixelsPerFrame = 400
- showLabels = false
- darkMode = false
-
- params = {
- corners: {
- value: this.corners,
- forceType: 'integer',
- displayName: 'Number of corners',
- required: true,
- description:
- 'The number of vertices of the polygon; this value is also '
- + 'used as a modulus applied to the entries.',
- },
- frac: {
- value: this.frac,
- displayName: 'Fraction to walk',
- required: true,
- description:
- 'What fraction of the way each step takes you toward the '
- + 'vertex specified by the entry. It should be a '
- + 'value between 0 and 1 inclusive.',
- },
- walkers: {
- value: this.walkers,
- forceType: 'integer',
- displayName: 'Number of walkers',
- required: true,
- description:
- 'The number w of walkers. The sequence will be broken into '
- + 'subsequences based on the residue mod w '
- + 'of the index, each with a separate walker.',
- },
- colorStyle: {
- value: this.colorStyle,
- from: ColorStyle,
- displayName: 'Color dots by',
- required: true,
- },
- gradientLength: {
- value: this.gradientLength,
- forceType: 'integer',
- displayName: 'Color cycling length',
- required: false,
- visibleDependency: 'colorStyle',
- visibleValue: ColorStyle.Index,
- description:
- 'The number of entries before recycling the color sequence.',
- },
- highlightWalker: {
- value: this.highlightWalker,
- forceType: 'integer',
- displayName: 'Number of walker to highlight',
- required: false,
- visibleDependency: 'colorStyle',
- visibleValue: ColorStyle.Highlighting_one_walker,
+const paramDesc = {
+ corners: {
+ default: 4,
+ type: ParamType.INTEGER,
+ displayName: 'Number of corners',
+ required: true,
+ description:
+ 'The number of vertices of the polygon; this value is also '
+ + 'used as a modulus applied to the entries.',
+ validate(c: number, status: ValidationStatus) {
+ if (c < 2) status.addError('must be at least 2')
},
- first: {
- value: '' as string | number,
- forceType: 'integer',
- displayName: 'Starting index',
- required: false,
- description:
- 'Index of the first entry to use. If this is blank or less '
- + 'than the first valid index, visualization will start '
- + 'at the first valid index.',
- },
- last: {
- value: '' as string | number,
- forceType: 'integer',
- displayName: 'Ending index',
- required: false,
- description:
- 'Index of the last entry to use. If this is blank or greater '
- + 'than the last valid index, visualization will end at the '
- + 'last valid index.',
+ },
+ frac: {
+ default: 0.5,
+ type: ParamType.NUMBER,
+ displayName: 'Fraction to walk',
+ required: false,
+ description:
+ 'What fraction of the way each step takes you toward the '
+ + 'vertex specified by the entry.',
+ validate(f: number, status: ValidationStatus) {
+ if (f < 0 || f > 1) {
+ status.addError('must be between 0 and 1, inclusive')
+ }
},
- dummyDotControl: {
- value: false,
- displayName: 'Show additional parameters for the dots ↴',
- required: false,
+ },
+ walkers: {
+ default: 1,
+ type: ParamType.INTEGER,
+ displayName: 'Number of walkers',
+ required: false,
+ description:
+ 'The number w of walkers. The sequence will be broken into '
+ + 'subsequences based on the residue mod w '
+ + 'of the index, each with a separate walker.',
+ validate(w: number, status: ValidationStatus) {
+ if (w < 1) status.addError('must be at least 1')
},
- circSize: {
- value: this.circSize,
- displayName: 'Size (pixels)',
- required: true,
- visibleDependency: 'dummyDotControl',
- visibleValue: true,
+ },
+ colorStyle: {
+ default: ColorStyle.Walker,
+ type: ParamType.ENUM,
+ from: ColorStyle,
+ displayName: 'Color dots by',
+ required: true,
+ description: 'The way the dots should be colored.',
+ },
+ gradientLength: {
+ default: 10000,
+ type: ParamType.INTEGER,
+ displayName: 'Color cycling length',
+ required: false,
+ visibleDependency: 'colorStyle',
+ visibleValue: ColorStyle.Index,
+ description:
+ 'The number of entries before recycling the color sequence.',
+ validate(gl: number, status: ValidationStatus) {
+ if (gl < 1) status.addError('must be at least 1')
},
- alpha: {
- value: this.alpha,
- displayName: 'Alpha',
- required: true,
- description:
- 'Alpha factor (from 0.0=transparent to 1.0=solid) of the dots.',
- visibleDependency: 'dummyDotControl',
- visibleValue: true,
+ },
+ highlightWalker: {
+ default: 0,
+ type: ParamType.INTEGER,
+ displayName: 'Number of walker to highlight',
+ required: false,
+ visibleDependency: 'colorStyle',
+ visibleValue: ColorStyle.Highlighting_one_walker,
+ validate(hw: number, status: ValidationStatus) {
+ if (hw < 0) status.addError('must be 0 or higher')
},
- pixelsPerFrame: {
- value: this.pixelsPerFrame,
- forceType: 'integer',
- displayName: 'Dots to draw per frame',
- required: true,
- description: '(more = faster).',
- visibleDependency: 'dummyDotControl',
- visibleValue: true,
+ },
+ dummyDotControl: {
+ default: false,
+ type: ParamType.BOOLEAN,
+ displayName: 'Show additional parameters for the dots ↴',
+ required: false,
+ },
+ circSize: {
+ default: 1,
+ type: ParamType.NUMBER,
+ displayName: 'Size (pixels)',
+ required: false,
+ visibleDependency: 'dummyDotControl',
+ visibleValue: true,
+ validate(cs: number, status: ValidationStatus) {
+ if (cs <= 0) status.addError('must be positive')
},
- showLabels: {
- value: this.showLabels,
- displayName: 'Label corners of polygon?',
- required: false,
+ },
+ alpha: {
+ default: 0.9,
+ type: ParamType.NUMBER,
+ displayName: 'Alpha',
+ required: false,
+ description:
+ 'Alpha factor (from 0.0=transparent to 1.0=solid) of the dots.',
+ visibleDependency: 'dummyDotControl',
+ visibleValue: true,
+ validate(a: number, status: ValidationStatus) {
+ if (a < 0 || a > 1) {
+ status.addError('must be between 0 and 1, inclusive')
+ }
},
- darkMode: {
- value: this.darkMode,
- displayName: 'Use dark mode?',
- required: false,
- description: 'If checked, uses light colors on a dark background',
+ },
+ pixelsPerFrame: {
+ default: 400n,
+ type: ParamType.BIGINT,
+ displayName: 'Dots to draw per frame',
+ required: false,
+ description: '(more = faster).',
+ visibleDependency: 'dummyDotControl',
+ visibleValue: true,
+ validate(p: number, status: ValidationStatus) {
+ if (p < 1) status.addError('must be at least 1')
},
- }
+ },
+ showLabels: {
+ default: false,
+ type: ParamType.BOOLEAN,
+ displayName: 'Label corners of polygon?',
+ required: false,
+ },
+ darkMode: {
+ default: false,
+ type: ParamType.BOOLEAN,
+ displayName: 'Use dark mode?',
+ required: false,
+ description: 'If checked, uses light colors on a dark background.',
+ },
+} satisfies GenericParamDescription
+
+// other ideas: previous parts of the sequence fade over time,
+// or shrink over time;
+// circles fade to the outside
+
+class Chaos extends P5Visualizer(paramDesc) {
+ static category = 'Chaos'
+ static description = 'Chaos game played using a sequence to select moves'
// current state variables (used in setup and draw)
- private seqLength = 0
- private myIndex = 0
+ private myIndex = 0n
private cornersList: p5.Vector[] = []
private walkerPositions: p5.Vector[] = []
// colour palette
private currentPalette = new Palette()
- checkParameters() {
- const status = super.checkParameters()
+ checkParameters(params: ParamValues) {
+ const status = super.checkParameters(params)
- const p = this.params
- if (p.corners.value < 2) {
- status.errors.push(
- 'The number of corners must be an integer > 1.'
- )
- }
- if (p.frac.value < 0 || p.frac.value > 1) {
- status.errors.push(
- 'The fraction must be between 0 and 1 inclusive.'
- )
- }
- if (p.walkers.value < 1) {
- status.errors.push(
- 'The number of walkers must be a positive integer.'
+ if (params.highlightWalker >= params.walkers) {
+ status.addError(
+ 'The highlighted walker must be less than '
+ + 'the number of walkers.'
)
- }
- if (p.gradientLength.value < 1) {
- status.errors.push(
- 'The colour cycle length must be a positive integer.'
+ this.statusOf.highlightWalker.addWarning(
+ 'must be less than the number of walkers'
)
- }
- if (
- p.highlightWalker.value < 0
- || p.highlightWalker.value >= p.walkers.value
- ) {
- status.errors.push(
- 'The highlighted walker must be an integer '
- + 'between 0 and one less than the number of walkers.'
- )
- }
- if (p.circSize.value < 0) {
- status.errors.push('The circle size must be positive.')
- }
- if (p.alpha.value < 0 || p.alpha.value > 1) {
- status.errors.push('The alpha must be between 0 and 1 inclusive.')
- }
- if (p.pixelsPerFrame.value < 1) {
- status.errors.push(
- 'The dots per frame must be a positive integer.'
+ this.statusOf.walkers.addWarning(
+ 'must be larger than the highlighted walker'
)
}
-
- if (status.errors.length > 0) status.isValid = false
return status
}
@@ -296,7 +272,7 @@ class Chaos extends P5Visualizer {
for (let c = 0; c < paletteSize; c++) {
let hexString = ''
for (let h = 0; h < 6; h++) {
- hexString += Math.floor(Math.random() * 16).toString(16)
+ hexString += math.randomInt(16).toString(16)
}
colorList.push('#' + hexString)
}
@@ -323,25 +299,7 @@ class Chaos extends P5Visualizer {
// No stroke right now, but could be added
const textStroke = this.sketch.width * 0
- // Adjust the starting and ending points if need be
- let adjusted = false
- if (
- typeof this.params.first.value === 'string'
- || this.first < this.seq.first
- ) {
- this.first = this.seq.first
- adjusted = true
- }
- if (
- typeof this.params.last.value === 'string'
- || this.last > this.seq.last
- ) {
- this.last = this.seq.last
- adjusted = true
- }
- if (adjusted) this.refreshParams()
- this.seqLength = this.last - this.first
- this.myIndex = this.first
+ this.myIndex = this.seq.first
// set up arrays of walkers
this.walkerPositions = Array.from({length: this.walkers}, () =>
@@ -383,24 +341,33 @@ class Chaos extends P5Visualizer {
draw() {
const sketch = this.sketch
- // we do pixelsPerFrame pixels each time through the draw cycle;
- // this speeds things up essentially
- const pixelsLimit =
- this.myIndex
- + Math.min(this.last - this.myIndex + 1, this.pixelsPerFrame)
-
+ // We attempt to draw pixelsPerFrame pixels each time through the
+ // draw cycle; this "chunking" speeds things up -- that's essential,
+ // because otherwise the overall patterns created by the chaos are
+ // much too slow to show up, especially at small pixel sizes.
+ // Note that we might end up drawing fewer pixels if, for example,
+ // we hit a cache boundary during a frame (at which point getElement
+ // will throw a CachingError, breaking out of draw() altogether). But
+ // in the next frame, likely the caching is done (or at least has moved
+ // to significantly higher indices), and drawing just picks up where
+ // it left off.
+ let pixelsLimit = this.myIndex + this.pixelsPerFrame
+ if (pixelsLimit > this.seq.last) {
+ pixelsLimit = BigInt(this.seq.last) + 1n
+ // have to add one to make sure we eventually stop
+ }
for (; this.myIndex < pixelsLimit; this.myIndex++) {
// get the term
const myTerm = this.seq.getElement(this.myIndex)
// check its modulus to see which corner to walk toward
// (Safe to convert to number since this.corners is "small")
- const myCorner = Number(modulo(myTerm, this.corners))
+ const myCorner = Number(math.modulo(myTerm, this.corners))
const myCornerPosition = this.cornersList[myCorner]
// check the index modulus to see which walker is walking
// (Ditto on safety.)
- const myWalker = Number(modulo(this.myIndex, this.walkers))
+ const myWalker = Number(math.modulo(this.myIndex, this.walkers))
// update the walker position
this.walkerPositions[myWalker].lerp(myCornerPosition, this.frac)
@@ -415,11 +382,14 @@ class Chaos extends P5Visualizer {
myColor = this.currentPalette.colorList[myCorner]
break
case ColorStyle.Index:
- if (this.seqLength < +Infinity) {
+ if (typeof this.seq.length === 'bigint') {
myColor = sketch.lerpColor(
this.currentPalette.colorList[0],
this.currentPalette.colorList[1],
- this.myIndex / this.seqLength
+ Number(
+ (this.myIndex - this.seq.first)
+ / this.seq.length
+ )
)
} else {
myColor = sketch.lerpColor(
@@ -427,7 +397,7 @@ class Chaos extends P5Visualizer {
this.currentPalette.colorList[1],
Number(
// Safe since gradientLength is "small"
- modulo(this.myIndex, this.gradientLength)
+ math.modulo(this.myIndex, this.gradientLength)
) / this.gradientLength
)
}
@@ -452,12 +422,9 @@ class Chaos extends P5Visualizer {
this.circSize
)
}
- // stop drawing if we exceed decreed terms
- if (this.myIndex > this.last) sketch.noLoop()
+ // stop drawing if we exceed available terms
+ if (this.myIndex > this.seq.last) this.stop()
}
}
-export const exportModule = new VisualizerExportModule(
- Chaos,
- 'Chaos game played using a sequence to select moves'
-)
+export const exportModule = new VisualizerExportModule(Chaos)
diff --git a/src/visualizers/Differences.ts b/src/visualizers/Differences.ts
index 54e43f32..93260d1f 100644
--- a/src/visualizers/Differences.ts
+++ b/src/visualizers/Differences.ts
@@ -1,7 +1,10 @@
-import {VisualizerExportModule} from '@/visualizers/VisualizerInterface'
import {P5Visualizer} from './P5Visualizer'
+import {VisualizerExportModule} from './VisualizerInterface'
-const min = Math.min
+import {math} from '@/shared/math'
+import type {GenericParamDescription} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
+import {ValidationStatus} from '@/shared/ValidationStatus'
/** md
# Difference Visualizer
@@ -17,78 +20,49 @@ differences between entries, followed by a row of differences between
differences, and so on, for as many rows as you like. The rows are shifted so
that each difference appears between and below the two numbers it's the
difference of.
-**/
-
-class Differences extends P5Visualizer {
- static visualizationName = 'Differences'
- // parameters
- n = 20
- levels = 5
+## Parameters
+**/
+const paramDesc = {
/** md
-## Parameters
- **/
- params = {
- /** md
-- **Entries in top row:** How many sequence entries to display in the top
- row. _(Positive integer or zero. Zero means all available entries.)_
- **/
- n: {
- value: this.n,
- forceType: 'integer',
- displayName: 'Entries in top row',
- required: true,
- },
- /** md
- **Number of rows:** How many rows to produce. _(Positive integer, no larger
- than 'Entries in top row.')_
- **/
- levels: {
- value: this.levels,
- forceType: 'integer',
- displayName: 'Number of rows',
- required: false,
- description: 'If zero, defaults to the length of top row',
+than the number of elements in the sequence')_
+ **/
+ levels: {
+ default: 12n,
+ type: ParamType.BIGINT,
+ displayName: 'Number of rows',
+ required: false,
+ validate: function (l: bigint, stat: ValidationStatus) {
+ if (l < 1n) stat.addError('Need at least one row')
},
- }
+ },
+} satisfies GenericParamDescription
- first = 0
+class Differences extends P5Visualizer(paramDesc) {
+ static category = 'Differences'
+ static description =
+ 'Produces a table of differences '
+ + 'between consecutive entries, potentially iterated several times'
- checkParameters() {
- const status = super.checkParameters()
+ // Dummy values, actually computed in setup()
+ useTerms = -1n
+ useLevels = -1n
- if (this.params.levels.value < 1) {
- status.isValid = false
- status.errors.push('Number of rows must be positive')
- }
- if (this.params.n.value < 0) {
- status.isValid = false
- status.errors.push(
- "Number of entries in top row can't be negative"
- )
- }
- if (this.params.n.value < this.params.levels.value) {
- status.isValid = false
- status.errors.push(
- "Number of rows can't exceed length of first row"
- )
- }
-
- return status
- }
-
- setup(): void {
+ setup() {
super.setup()
- if (!this.levels) {
- this.levels = this.n
- this.refreshParams()
+ this.useTerms = 40n // Typically more than enough to fill screen
+ if (this.seq.last < this.seq.first + this.useTerms - 1n) {
+ this.useTerms = BigInt(this.seq.last) - this.seq.first + 1n
}
- if (this.seq.last - this.seq.first + 1 < this.levels) {
- throw Error(
- `Sequence ${this.seq.name} has too few entries `
- + `for ${this.levels} levels.`
- )
+ this.useLevels = this.levels
+ if (this.useLevels > this.useTerms) {
+ this.useLevels = this.useTerms
+ // TODO IN OVERHAUL: Should really warn about this situation
+ // So some of this code will have to move into checkParameters
+ // maybe there will need to be a common helper function called
+ // from both there and here.
}
}
@@ -109,11 +83,13 @@ class Differences extends P5Visualizer {
let myColor = sketch.color(100, 255, 150)
let hue = 0
- const workingSequence = []
- const end = min(sequence.first + this.n - 1, sequence.last)
- const levels = min(this.levels, end - this.first + 1)
+ const end = BigInt(
+ math.bigmin(sequence.first + this.useTerms - 1n, sequence.last)
+ )
+ const levels = math.bigmin(this.useLevels, end - sequence.first + 1n)
// workingSequence cannibalizes the first n elements
+ const workingSequence = []
for (let i = sequence.first; i <= end; i++) {
workingSequence.push(sequence.getElement(i))
}
@@ -139,12 +115,8 @@ class Differences extends P5Visualizer {
// Move the next row forward half an entry, for a pyramid shape.
firstX = firstX + (1 / 2) * xDelta
}
- sketch.noLoop()
+ this.stop()
}
}
-export const exportModule = new VisualizerExportModule(
- Differences,
- 'Produces a table of differences between consecutive entries, '
- + 'potentially iterated several times'
-)
+export const exportModule = new VisualizerExportModule(Differences)
diff --git a/src/visualizers/FactorFence.ts b/src/visualizers/FactorFence.ts
new file mode 100644
index 00000000..3b6b8926
--- /dev/null
+++ b/src/visualizers/FactorFence.ts
@@ -0,0 +1,943 @@
+import p5 from 'p5'
+
+import {P5Visualizer, INVALID_COLOR} from './P5Visualizer'
+import {VisualizerExportModule} from './VisualizerInterface'
+import type {ViewSize} from './VisualizerInterface'
+
+import {math} from '@/shared/math'
+import type {ExtendedBigint} from '@/shared/math'
+import type {GenericParamDescription} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
+import {ValidationStatus} from '@/shared/ValidationStatus'
+
+/** md
+# Factor Fence Visualizer
+
+[ ](../assets/img/FactorFence/naturals.png)
+
+[ ](../assets/img/FactorFence/ramanujan-tau.png)
+
+This visualizer shows the factorization of the terms of the sequence
+as a sort of coloured graph. At position _n_ horizontally, there is a bar,
+or fencepost, which is of height log(_n_) and broken into different pieces
+of height log(_p_) for each prime divisor _p_ (with multiplicity).
+**/
+
+// colour palette class
+class FactorPalette {
+ gradientBar: {[key: string]: p5.Color} = {}
+ gradientHighlight: {[key: string]: p5.Color} = {}
+ gradientMouse: {[key: string]: p5.Color} = {}
+ backgroundColor: p5.Color
+ constructor(
+ sketch: p5 | undefined = undefined,
+ // bottom to top
+ hexBar: string[] = ['#876BB8', '#C3B7DB'], // violet, dark bottom
+ // orange, dark bottom
+ hexHighlight: string[] = ['#EC7E2B', '#F5CD95'],
+ hexMouse: string[] = ['#589C48', '#7BB662'], // green, dark bottom
+ hexBack = '#EBEAF3' // light violet
+ ) {
+ if (sketch) {
+ this.gradientBar = {
+ bottom: sketch.color(hexBar[0]),
+ top: sketch.color(hexBar[1]),
+ }
+ this.gradientHighlight = {
+ bottom: sketch.color(hexHighlight[0]),
+ top: sketch.color(hexHighlight[1]),
+ }
+ this.gradientMouse = {
+ bottom: sketch.color(hexMouse[0]),
+ top: sketch.color(hexMouse[1]),
+ }
+ this.backgroundColor = sketch.color(hexBack)
+ } else {
+ this.gradientBar = {
+ bottom: INVALID_COLOR,
+ top: INVALID_COLOR,
+ }
+ this.gradientHighlight = {
+ bottom: INVALID_COLOR,
+ top: INVALID_COLOR,
+ }
+ this.backgroundColor = INVALID_COLOR
+ }
+ }
+}
+
+/** md
+## Parameters
+**/
+const paramDesc = {
+ /** md
+- highlight: A natural number, the prime factors of which will be highlighted
+ in the display
+ **/
+ highlight: {
+ default: 1n,
+ type: ParamType.BIGINT,
+ displayName: 'Your favourite number',
+ required: true,
+ description:
+ 'We highlight primes dividing this number.'
+ + ' To highlight none, put 1.',
+ hideDescription: true,
+ validate: function (n: bigint, status: ValidationStatus) {
+ if (n <= 0) {
+ status.addError('Your favourite number must be positive.')
+ }
+ },
+ },
+ /** md
+- labels: Specifies whether the chart legend should be displayed
+ **/
+ labels: {
+ default: true,
+ type: ParamType.BOOLEAN,
+ displayName: 'Show text info',
+ required: true,
+ description: 'If true, some text info appears onscreen',
+ hideDescription: true,
+ },
+
+ /** md
+- signs: Specifies whether negative terms should display below the horizontal
+ axis of the chart
+ **/
+ signs: {
+ default: true,
+ type: ParamType.BOOLEAN,
+ displayName: 'Take into account signs',
+ required: true,
+ description: 'If true, negative terms display below axis',
+ hideDescription: true,
+ },
+} satisfies GenericParamDescription
+
+// vertical bar representing factor
+interface Bar {
+ prime: bigint
+ log: number
+ highlighted?: boolean
+}
+
+// "sort" a list of Bars so that the divisors of a given number come first
+// also labels the divisors as highlighted
+function divisorsFirst(bars: Bar[], n: bigint) {
+ const divisors: Bar[] = []
+ const nondivisors: Bar[] = []
+ bars.forEach(bar => {
+ bar.highlighted = math.divides(bar.prime, n)
+ ;(bar.highlighted ? divisors : nondivisors).push(bar)
+ })
+ divisors.push(...nondivisors)
+ return divisors
+}
+
+// data on which bars on screen
+interface BarsData {
+ minBars: bigint
+ maxBars: bigint
+ numBars: number // should be safe because not so many pixels on screen
+}
+
+// "Natural" width of the factor bars:
+const recWidth = 12
+// Shift from one bar to the next:
+const recSpace = new p5.Vector(recWidth + 2, 0)
+// How many frames to draw at a time
+const tryFrames = 3
+
+// helper function
+const isTrivial = (term: bigint) => term > -2n && term < 2n
+
+class FactorFence extends P5Visualizer(paramDesc) {
+ static category = 'FactorFence'
+ static description = 'Show the factors of your sequence log-visually.'
+
+ // mouse control
+ private mousePrime = 1n
+ private mouseIndex: ExtendedBigint = math.negInfinity
+ private mouseLast: MouseEvent | undefined = undefined // last mouse pos
+ private mouseDown = false
+ private dragging = false
+ private dragStart = new p5.Vector()
+ private graphCornerStart = new p5.Vector()
+
+ // store factorizations in FactorFence's internal format
+ private factorizations: Record = {}
+
+ // scaling control
+ private scaleFactor = 1.0 // zooming
+ private initialLimitTerms = 10000n // initial max number of terms
+ private heightScale = 55 // stretching
+
+ // for vertical scaling, store max/min sequence vals displayed
+ private maxVal = 1
+ private minVal = 1
+
+ // lower left corner of graph
+ private graphCorner = new p5.Vector()
+ // left margin of text
+ private textLeft = 0
+ // lowest extent of any bar
+ private lowestBar = 0
+
+ // text control
+ private textInterval = 0
+ private textSize = 0
+
+ // for issues of caching taking time
+ private collectFailed = false
+
+ // colour palette
+ private palette = new FactorPalette()
+
+ // first factorization failure
+ private firstFailure = -1024n // dummy, must be replaced
+
+ barsShowing(size: ViewSize): BarsData {
+ // determine which terms will be on the screen
+ // in order to decide how to initialize the graph
+ // and to be efficient in computing only the portion shown
+
+ // minimum bar to compute
+ // no less than first term
+ let minBars = this.seq.first
+ const offset = -math.bigInt(this.graphCorner.x / recSpace.x) - 1n
+ if (offset > 0) minBars += offset
+
+ // number of bars on screen
+ // two extra to slightly bleed over edge
+ let bigNumBars = math.bigInt(
+ size.width / this.scaleFactor / recSpace.x + 2
+ )
+ let maxBars = minBars + bigNumBars - 1n
+
+ if (this.seq.last < maxBars) {
+ maxBars = BigInt(this.seq.last)
+ bigNumBars = maxBars - minBars + 1n
+ }
+
+ return {minBars, maxBars, numBars: Number(bigNumBars)}
+ }
+
+ collectDataForScale(barsInfo: BarsData, size: ViewSize) {
+ // collect some info on the sequence in order to decide
+ // how best to vertically scale the initial graph
+ this.collectFailed = false
+ const seqVals = Array.from(Array(barsInfo.numBars), (_, i) => {
+ let elt = 1n
+ try {
+ elt = this.seq.getElement(BigInt(i) + barsInfo.minBars)
+ } catch {
+ this.collectFailed = true
+ return 0
+ }
+ let sig = 1n // sign of element
+ if (elt < 0n) {
+ if (this.signs) sig = -1n
+ else elt = -elt
+ }
+ // in case elt = 0, store 1
+ if (elt === 0n) elt = 1n
+
+ // store height of bar (log) but with sign info
+ return math.natlog(elt * sig) * Number(sig)
+ })
+ this.maxVal = Math.max(...seqVals)
+ this.minVal = Math.min(...seqVals)
+
+ // we compute the graphHeight to scale graph to fit
+ let heightMax =
+ Math.sign(this.maxVal) * Math.max(2, Math.abs(this.maxVal))
+ let heightMin =
+ Math.sign(this.minVal) * Math.max(2, Math.abs(this.minVal))
+ heightMax = Math.max(heightMax, 0)
+ heightMin = Math.min(heightMin, 0)
+
+ // scale according to total graph height
+ const graphHeight = Math.abs(heightMax - heightMin)
+ if (graphHeight != 0) {
+ this.heightScale = (0.4 * size.height) / graphHeight
+ } else {
+ // should occur only for constant 0 seq
+ this.heightScale = 0.4 * size.height
+ }
+ // adjust the x-axis upward to make room
+ this.graphCorner.y = this.graphCorner.y + heightMin * this.heightScale
+ }
+
+ storeFactors(barsInfo: BarsData) {
+ // put all factorizations into an array for easy access
+
+ // track factorization progress in case backend is slow
+ let firstFailure = barsInfo.maxBars
+
+ // only try to store the factorizations of the bars that are showing,
+ // and we have not previously done
+ for (
+ let myIndex = this.firstFailure;
+ myIndex <= barsInfo.maxBars;
+ myIndex++
+ ) {
+ let facsRaw: bigint[][] = []
+ try {
+ facsRaw = this.seq.getFactors(myIndex) ?? []
+ } catch {
+ if (firstFailure > myIndex) firstFailure = myIndex
+ }
+
+ // change the factors into just a list of factors with repeats
+ // suitable for looping through to make the bars
+ // format: [prime, log(prime)]
+ const factors: Bar[] = []
+ if (facsRaw) {
+ for (const [base, power] of facsRaw) {
+ if (base != -1n && base != 0n) {
+ const thisBar = {prime: base, log: math.natlog(base)}
+ for (let i = 0; i < power; i++) {
+ factors.push(thisBar)
+ }
+ }
+ }
+ }
+ this.factorizations[myIndex.toString()] = factors
+ }
+ return firstFailure
+ }
+
+ // put the view in a good starting state:
+ async standardizeView(size: ViewSize) {
+ this.scaleFactor = 1
+ // lower left graph corner as proportion of space avail
+ this.graphCorner = new p5.Vector(
+ size.width * 0.05,
+ size.height * 0.75
+ )
+ // Text sizing:
+ // left margin of text
+ this.textLeft = size.width * 0.05
+ // vertical text spacing
+ this.textInterval = size.height * 0.027
+ this.textSize = size.height * 0.023
+
+ // initial scaling
+ const barsInfo = this.barsShowing(size)
+ this.firstFailure = barsInfo.minBars
+ await this.seq.fill(barsInfo.maxBars) // must await because used now
+ this.collectDataForScale(barsInfo, size)
+ }
+
+ async presketch(size: ViewSize) {
+ await super.presketch(size)
+
+ // Warn the backend we plan to factor: (Note we don't await because
+ // we don't actually use the factors until later.)
+ this.seq.fill(this.seq.first + this.initialLimitTerms, 'factor')
+
+ await this.standardizeView(size)
+ }
+
+ setup() {
+ super.setup()
+
+ this.palette = new FactorPalette(this.sketch)
+ // text formatting
+ this.sketch.textFont('Courier New')
+ this.sketch.textStyle(this.sketch.NORMAL)
+
+ // no stroke (rectangles without borders)
+ this.sketch.strokeWeight(0)
+ this.sketch.frameRate(30)
+
+ // Only need a couple of frames to start with:
+ this.stop(tryFrames)
+ }
+
+ /** md
+## Controls
+Moving the mouse over the bar chart will highlight all occurrences of the
+prime that the mouse is currently over, and display information about the
+term that the mouse is above. Clicking a prime will set it as the persistent
+highlight value. You can drag the chart in any direction to pan the view.
+
+ **/
+ mouseCheckDrag() {
+ const movement = new p5.Vector(this.sketch.mouseX, this.sketch.mouseY)
+ movement.sub(this.dragStart)
+ // The number below is an arbitrary cutoff so as not to detect
+ // "jitter" as a bona fide drag
+ if (movement.mag() > 4) {
+ this.dragging = true
+ movement.mult(1 / this.scaleFactor)
+ this.graphCorner = this.graphCornerStart.copy().add(movement)
+ }
+ }
+
+ /** md
+In addition, several keypress commands are recognized:
+
+ **/
+ keyPresses() {
+ // keyboard control for zoom, pan, stretch
+ /** md
+- right and left arrow: zoom in and out, respectively
+ **/
+ if (
+ this.sketch.keyIsDown(this.sketch.LEFT_ARROW)
+ || this.sketch.keyIsDown(this.sketch.RIGHT_ARROW)
+ ) {
+ // zoom in RIGHT
+ // zoom out LEFT
+ const keyScale = this.sketch.keyIsDown(this.sketch.RIGHT_ARROW)
+ ? 1.03
+ : 0.97
+ this.scaleFactor *= keyScale
+ this.graphCorner.y = this.graphCorner.y / keyScale
+ }
+ /** md
+- up and down arrow: stretch the bars vertically
+ **/
+ if (this.sketch.keyIsDown(this.sketch.UP_ARROW)) {
+ // stretch up UP
+ this.heightScale *= 1.03
+ }
+ if (this.sketch.keyIsDown(this.sketch.DOWN_ARROW)) {
+ // contract down DOWN
+ this.heightScale *= 0.97
+ }
+ /** md
+- J/I/K/L: pan the chart left/up/down/right
+ **/
+ if (this.sketch.keyIsDown(74)) {
+ // pan left J
+ this.graphCorner.x -= 10 / this.scaleFactor
+ }
+ if (this.sketch.keyIsDown(76)) {
+ // pan right L
+ this.graphCorner.x += 10 / this.scaleFactor
+ }
+ if (this.sketch.keyIsDown(73)) {
+ // pan up I
+ this.graphCorner.y -= 10 / this.scaleFactor
+ }
+ if (this.sketch.keyIsDown(75)) {
+ // pan down K
+ this.graphCorner.y += 10 / this.scaleFactor
+ }
+ }
+
+ draw() {
+ // countdown to timeout when no key pressed
+ // if key is pressing, do what it directs
+ // there's a bug here that when the keyboard is
+ // pressed in this window but
+ // released in another window, keyIsPressed stays
+ // positive and the sketch keeps computing,
+ // e.g. if you tab away from the window
+ // [GTW 20240827: Unable to reproduce; if someone else can,
+ // please file an issue with specific instructions. Otherwise,
+ // this comment should be removed at the next maintenance.]
+ if (this.sketch.keyIsPressed) this.keyPresses()
+ if (this.mouseDown) this.mouseCheckDrag()
+
+ // clear the sketch
+ this.sketch.clear(0, 0, 0, 0)
+ this.sketch.background(this.palette.backgroundColor)
+
+ // determine which terms will be on the screen so we only
+ // bother with those
+ const barsInfo = this.barsShowing({
+ width: this.sketch.width,
+ height: this.sketch.height,
+ })
+
+ this.sketch.push()
+ // The next call scales the whole sketch, which is why we
+ // encapsulate it in the above push()
+ this.sketch.scale(this.scaleFactor)
+
+ // try again if need more terms from cache
+ if (this.collectFailed) {
+ this.collectDataForScale(barsInfo, {
+ width: this.sketch.width,
+ height: this.sketch.height,
+ })
+ }
+
+ // set factoring needed pointer
+ this.firstFailure = this.storeFactors(barsInfo)
+ this.lowestBar = 0
+
+ this.mouseIndex = math.negInfinity
+ this.mousePrime = 1n
+ // Determine what the mouse is over, if anything:
+ if (this.mouseOnSketch()) {
+ const horiz =
+ this.sketch.mouseX / this.scaleFactor - this.graphCorner.x
+ if (horiz % recSpace.x < recWidth) {
+ const rawIndex =
+ math.bigInt(horiz / recSpace.x) + this.seq.first
+ if (
+ rawIndex >= barsInfo.minBars
+ && rawIndex <= barsInfo.maxBars
+ ) {
+ this.mouseIndex = rawIndex
+ // draw that bar first to find the prime, if any:
+ this.drawTerm(rawIndex, 'extract prime')
+ }
+ }
+ }
+ // loop through the terms of the seq and draw the bars for each
+ for (
+ let myIndex = barsInfo.minBars;
+ myIndex <= barsInfo.maxBars;
+ myIndex++
+ ) {
+ // Note the drawTerm function also updates this.lowestBar
+ this.drawTerm(myIndex)
+ }
+
+ // return to ordinary scaling
+ this.sketch.pop()
+ // text at base of sketch, if not small canvas
+ if (this.sketch.height > 400 && this.labels) this.bottomText()
+
+ // If we are waiting on elements or factorizations, extend lifetime
+ if (this.collectFailed || this.firstFailure < barsInfo.maxBars) {
+ this.extendLoop()
+ }
+ }
+
+ drawTerm(myIndex: bigint, extractPrime?: string) {
+ // This function draws the full stacked bars for a single term
+ // Input is index of the term
+ const myTerm = this.seq.getElement(myIndex)
+
+ // get sign of term
+ let mySign = 1
+ if (myTerm < 0 && this.signs) mySign = -1
+
+ // get factors of term
+ const factors = this.factorizations[myIndex.toString()]
+
+ // determine where to put lower left corner of graph
+ const barStart = this.graphCorner.copy()
+ // for negative terms, bar should extend below "empty" axis
+ if (mySign < 0 || myTerm === 0n) barStart.add(new p5.Vector(0, 1))
+
+ // move over based on which term
+ const moveOver = recSpace.copy()
+ moveOver.mult(math.safeNumber(myIndex - this.seq.first))
+ barStart.add(moveOver)
+
+ // Now draw the bars:
+ // special cases with no factors
+ if (factors.length === 0) {
+ if (isTrivial(myTerm)) {
+ // draw a one pixel high placeholder bar for 0/1/-1
+ this.grad_rect(
+ barStart.x,
+ barStart.y,
+ recWidth,
+ mySign,
+ this.palette.gradientBar.top,
+ this.palette.gradientBar.bottom
+ )
+ // in case no bars on screen, lowestBar must be set somewhere
+ this.lowestBar = Math.max(this.lowestBar, barStart.y)
+ } else {
+ // draw an empty bar for unknown factorizations
+
+ // height of rectangle is log of term
+ // times scaling parameter
+ const recHeight =
+ mySign
+ * math.natlog(math.bigabs(myTerm))
+ * this.heightScale
+
+ // draw the rectangle
+ const emptyColor = this.palette.gradientBar.top
+ this.grad_rect(
+ barStart.x,
+ barStart.y,
+ recWidth,
+ recHeight,
+ emptyColor,
+ emptyColor
+ )
+ this.lowestBar = Math.max(
+ barStart.y,
+ barStart.y - recHeight,
+ this.lowestBar
+ )
+ }
+ return
+ }
+
+ // The "usual" case: draw a stack of bars
+ for (const factor of divisorsFirst(factors, this.highlight)) {
+ const recHeight = mySign * factor.log * this.heightScale
+ // check where mouse is
+ if (extractPrime) {
+ this.mousePrimeSet(recHeight, barStart, mySign, factor.prime)
+ }
+
+ const gradient = this.chooseGradient(factor)
+ // draw the rectangle
+ this.grad_rect(
+ barStart.x,
+ barStart.y,
+ recWidth,
+ recHeight,
+ gradient.top,
+ gradient.bottom
+ )
+ this.lowestBar = Math.max(
+ barStart.y,
+ barStart.y - recHeight,
+ this.lowestBar
+ )
+
+ // move up in preparation for next bar
+ barStart.y -= recHeight
+ }
+ }
+
+ chooseGradient(b: Bar) {
+ if (b.prime === this.mousePrime) return this.palette.gradientMouse
+ if (b.highlighted) return this.palette.gradientHighlight
+ return this.palette.gradientBar
+ }
+
+ mouseOnSketch(): boolean {
+ const {mouseX, mouseY} = this.sketch
+ if (
+ mouseX < 0
+ || mouseX > this.sketch.width
+ || mouseY < 0
+ || mouseY > this.sketch.height
+ ) {
+ return false
+ }
+ if (!this.mouseLast) return false
+ const where = document.elementFromPoint(
+ this.mouseLast.clientX,
+ this.mouseLast.clientY
+ )
+ if (!where || !this.within) return false
+ return where === this.within || where.contains(this.within)
+ }
+
+ mousePrimeSet(
+ barHeight: number,
+ barStart: p5.Vector,
+ mySign: number,
+ prime: bigint
+ ) {
+ // if the mouse is over the rectangle being drawn
+ // then we make note of the prime factor
+ // and term we are hovering over
+ const mouseV =
+ mySign * (this.sketch.mouseY / this.scaleFactor - barStart.y)
+ const barLimit = -barHeight * mySign
+ if (mouseV <= 0 && mouseV >= barLimit) this.mousePrime = prime
+ }
+
+ extendLoop() {
+ this.continue() // clear the frames remaining
+ this.stop(tryFrames) // and reset it
+ }
+
+ async resized(toSize: ViewSize) {
+ await this.standardizeView(toSize)
+ return false // Let the framework handle the redisplay
+ }
+
+ keyPressed() {
+ this.continue()
+ }
+
+ keyReleased() {
+ if (!this.sketch.keyIsPressed) this.stop()
+ }
+
+ mouseMoved(event: MouseEvent) {
+ this.mouseLast = event
+ this.extendLoop()
+ }
+
+ mousePressed() {
+ if (!this.mouseOnSketch()) return
+ this.mouseDown = true
+ this.dragStart = new p5.Vector(this.sketch.mouseX, this.sketch.mouseY)
+ this.graphCornerStart = this.graphCorner.copy()
+ this.continue()
+ }
+
+ mouseReleased() {
+ if (this.dragging) {
+ this.dragging = false
+ } else if (this.mouseDown) {
+ // set highlight prime by click
+ this.highlight = this.mousePrime
+ this.refreshParams()
+ }
+ this.mouseDown = false
+ this.extendLoop()
+ }
+
+ /** md
+
+ You can also zoom the view using the scroll wheel.
+ **/
+ mouseWheel(event: WheelEvent) {
+ // this adjusts scaling by adjusting
+ // this.scaleFactor and (mouse position - scaled graph corner)
+ // by the same factor
+
+ // current mouse - scaledcorner (vector corner -> mouse)
+ const mouse = new p5.Vector(this.sketch.mouseX, this.sketch.mouseY)
+ const cornerToMouse = mouse
+ .copy()
+ .sub(this.graphCorner.copy().mult(this.scaleFactor))
+
+ // change scale factor
+ let scaleFac = 1
+ if (event.deltaY > 0) scaleFac = 1.03
+ else scaleFac = 0.97
+ this.scaleFactor *= scaleFac
+
+ // new scaledcorner = mouse - (mouse - scaledcorner)*scaled
+ this.graphCorner = mouse
+ .copy()
+ .sub(cornerToMouse.mult(scaleFac))
+ .mult(1 / this.scaleFactor)
+ this.extendLoop()
+ }
+
+ // Displays text at given position, returning number of pixels used
+ // Uses ... if it will run off screen
+ textCareful(
+ text: string,
+ textLeft: number,
+ textBottom: number,
+ showDigits?: boolean
+ ) {
+ if (textLeft > this.sketch.width) return 0
+ const overflow =
+ this.sketch.textWidth(text) - this.sketch.width + textLeft
+ if (overflow > 0) {
+ let surplusCharacters =
+ Math.ceil(overflow / this.sketch.textWidth('1')) + 3
+ let digitCount = 0
+ if (showDigits) {
+ const textNoSign = text
+ textNoSign.replace('-', '')
+ digitCount = textNoSign.length
+ const digitCountDigitCount = digitCount.toString().length
+ surplusCharacters += digitCountDigitCount + 9 // space for dig's
+ }
+ let newText =
+ text.substring(0, text.length - surplusCharacters) + '...'
+ if (showDigits)
+ newText += '[' + digitCount.toString() + ' digits]'
+ this.sketch.text(newText, textLeft, textBottom)
+ return this.sketch.textWidth(newText)
+ }
+ this.sketch.text(text, textLeft, textBottom)
+ return this.sketch.textWidth(text)
+ }
+
+ bottomText() {
+ // text size and position
+ this.sketch.textSize(this.textSize)
+ this.sketch.strokeWeight(0) // no outline
+ let textLeft = this.textLeft
+
+ // spacing between lines
+ const lineHeight = this.textInterval
+ let textBottom = Math.min(
+ this.lowestBar * this.scaleFactor + 2 * lineHeight,
+ this.sketch.height - 5.5 * lineHeight
+ )
+
+ // colours match graph colours
+ const infoColors = [
+ this.palette.gradientMouse.bottom,
+ this.palette.gradientHighlight.bottom,
+ ]
+
+ // always visible static text info, line by line
+ // boolean represents whether to line break
+ const info = [
+ {
+ text: 'Click to select a prime;',
+ color:
+ this.mouseDown && !this.dragging
+ ? infoColors[1]
+ : infoColors[0],
+ },
+ {
+ text: ' drag to move;',
+ color: infoColors[this.dragging ? 1 : 0],
+ },
+ {text: ' scroll or '},
+ {
+ text: '← → keys ',
+ color:
+ this.sketch.keyIsDown(this.sketch.RIGHT_ARROW)
+ || this.sketch.keyIsDown(this.sketch.LEFT_ARROW)
+ ? infoColors[1]
+ : infoColors[0],
+ },
+ {
+ text: 'to zoom;',
+ color:
+ this.sketch.keyIsDown(this.sketch.RIGHT_ARROW)
+ || this.sketch.keyIsDown(this.sketch.LEFT_ARROW)
+ ? infoColors[1]
+ : infoColors[0],
+ },
+ {
+ text: ' ↑↓ to stretch',
+ color:
+ this.sketch.keyIsDown(this.sketch.UP_ARROW)
+ || this.sketch.keyIsDown(this.sketch.DOWN_ARROW)
+ ? infoColors[1]
+ : infoColors[0],
+ linebreak: true,
+ },
+ {
+ text:
+ this.highlight === 1n
+ ? 'Not highlighting '
+ : `Highlighting factors of ${this.highlight} `,
+ color: infoColors[1],
+ },
+ {
+ text:
+ this.highlight === 1n
+ ? '(favourite number is 1)'
+ : '(and displaying them first)',
+ color: infoColors[1],
+ linebreak: true,
+ },
+ ]
+
+ // display mouse invariant info
+ let continuingLine = false
+ for (const item of info) {
+ if (
+ this.sketch.textWidth(item.text)
+ > this.sketch.width - textLeft
+ && continuingLine
+ ) {
+ textBottom += lineHeight
+ textLeft = this.textLeft
+ }
+ this.sketch.fill(item.color || infoColors[0])
+ textLeft += this.textCareful(item.text, textLeft, textBottom)
+ if (item.linebreak) {
+ textBottom += lineHeight
+ textLeft = this.textLeft
+ continuingLine = false
+ } else {
+ continuingLine = true
+ }
+ }
+
+ // factorization text shown upon mouseover of graph
+ const mIndex = this.mouseIndex
+ if (typeof mIndex !== 'bigint') return
+ const reorderedFactors = divisorsFirst(
+ this.factorizations[mIndex.toString()],
+ this.highlight
+ )
+ // term and sign
+ const mTerm = this.seq.getElement(mIndex)
+ let mSign = 1n
+ if (mTerm < 0n) mSign = -1n
+
+ // display mouseover info line
+ const infoLineFunction = `a(${mIndex})`
+ const infoLineStart = `${infoLineFunction} = `
+ this.sketch.fill(infoColors[0])
+ textLeft += this.textCareful(infoLineStart, textLeft, textBottom)
+ this.textCareful(mTerm.toString(), textLeft, textBottom, true)
+ textBottom += lineHeight
+
+ if (isTrivial(mTerm)) return
+
+ const infoLineContinue = ' = '
+ textLeft = this.textLeft + this.sketch.textWidth(infoLineFunction)
+ textLeft += this.textCareful(infoLineContinue, textLeft, textBottom)
+
+ if (reorderedFactors.length === 0) {
+ this.sketch.text('(factorization unknown)', textLeft, textBottom)
+ return
+ }
+ if (reorderedFactors.length === 1) {
+ this.sketch.fill(this.chooseGradient(reorderedFactors[0]).bottom)
+ this.sketch.text('(prime)', textLeft, textBottom)
+ return
+ }
+ if (mSign < 0n) {
+ this.sketch.text('-', textLeft, textBottom)
+ textLeft += this.sketch.textWidth('-') + 1
+ }
+ let first = true
+ for (const bar of reorderedFactors) {
+ if (!first) {
+ this.sketch.fill(infoColors[0])
+ textLeft += this.textCareful('×', textLeft, textBottom)
+ textLeft += 1
+ }
+
+ this.sketch.fill(this.chooseGradient(bar).bottom)
+ textLeft += this.textCareful(
+ bar.prime.toString(),
+ textLeft,
+ textBottom
+ )
+ textLeft += 1
+ first = false
+ }
+ }
+
+ // draw a gradient rectangle
+ grad_rect(
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ colorTop: p5.Color,
+ colorBottom: p5.Color
+ ) {
+ const barGradient = this.sketch.drawingContext.createLinearGradient(
+ x,
+ y,
+ x,
+ y - height
+ )
+ barGradient.addColorStop(0, colorTop)
+ barGradient.addColorStop(1, colorBottom)
+ const fillStyle = this.sketch.drawingContext.fillStyle
+ this.sketch.drawingContext.fillStyle = barGradient
+ this.sketch.strokeWeight(0)
+ this.sketch.rect(x, y - height, width, height)
+ this.sketch.drawingContext.fillStyle = fillStyle
+ }
+}
+
+export const exportModule = new VisualizerExportModule(FactorFence)
diff --git a/src/visualizers/Histogram.ts b/src/visualizers/Histogram.ts
index 923ae183..94bb796d 100644
--- a/src/visualizers/Histogram.ts
+++ b/src/visualizers/Histogram.ts
@@ -1,5 +1,11 @@
-import {VisualizerExportModule} from '@/visualizers/VisualizerInterface'
-import {P5Visualizer} from './P5Visualizer'
+import {VisualizerExportModule} from './VisualizerInterface'
+import {P5GLVisualizer} from './P5GLVisualizer'
+
+import interFont from '@/assets/fonts/inter/Inter-VariableFont_slnt,wght.ttf'
+import {math} from '@/shared/math'
+import type {GenericParamDescription} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
+import {ValidationStatus} from '@/shared/ValidationStatus'
/** md
# Factor Histogram
@@ -9,7 +15,7 @@ width="320" style="float: right; margin-left: 1em;" />](
../assets/img/FactorHistogram/ExampleImage.png)
This visualizer counts the number of prime factors (with multiplicity)
-of each entry in the sequence and creates a histogram of the results.
+of each entry in the sequence and creates a histogram of the results.
The number of prime factors with multiplicity is a function commonly
called
@@ -25,146 +31,108 @@ have a corresponding value of Omega.
## Parameters
**/
-class FactorHistogram extends P5Visualizer {
- static visualizationName = 'Factor Histogram'
-
- binSize = 1
- terms = 100
- firstIndex = NaN
- mouseOver = true
-
- binFactorArray: number[] = []
-
- params = {
- /** md
+const paramDesc = {
+ /** md
- Bin Size: The size (number of Omega values) for each bin
- of the histogram.
- **/
- binSize: {
- value: this.binSize,
- forceType: 'integer',
- displayName: 'Bin Size',
- required: true,
+of the histogram.
+ **/
+ binSize: {
+ default: 1,
+ type: ParamType.INTEGER,
+ displayName: 'Bin Size',
+ required: false,
+ validate(s: number, status: ValidationStatus) {
+ if (s < 1) status.addError('cannot be less than 1')
},
- /** md
-- First Index: The first index included in the statistics.
- If the first index is before the first term
- of the series then the first term of the series will be used.
- **/
- firstIndex: {
- value: '' as string | number,
- forceType: 'integer',
- displayName: 'First Index',
- required: false,
- },
-
- /** md
-- Number of Terms: The number of terms included in the statistics.
- If this goes past the last term of the sequence it will
- show all terms of the sequence after the first index.
- **/
- terms: {
- value: this.terms,
- forceType: 'integer',
- displayName: 'Number of Terms',
- required: true,
- },
-
- /** md
+ },
+ /** md
- Mouse Over: This turns on a mouse over feature that shows you the height
- of the bin that you are currently hovering over, as well as
- the bin label (i.e., which Omega values are included).
- **/
- mouseOver: {
- value: this.mouseOver,
- forceType: 'boolean',
- displayName: 'Mouse Over',
- required: true,
- },
- }
-
- checkParameters() {
- const status = super.checkParameters()
-
- if (this.params.binSize.value < 1) {
- status.isValid = false
- status.errors.push('Bin Size can not be less than 1')
- }
-
- return status
- }
-
- // Obtain the true first index
- startIndex(): number {
- if (
- typeof this.params.firstIndex.value === 'string'
- || this.firstIndex < this.seq.first
- ) {
- return this.seq.first
- } else {
- return this.firstIndex
- }
- }
-
- // Obtain the true number of terms
- endIndex(): number {
- return Math.min(this.terms + this.startIndex(), this.seq.last)
- }
+ of the bin that you are currently hovering over, as well as
+the bin label (i.e., which Omega values are included).
+ **/
+ mouseOver: {
+ default: true,
+ type: ParamType.BOOLEAN,
+ displayName: 'Mouse Over',
+ required: true,
+ },
+} satisfies GenericParamDescription
+
+class FactorHistogram extends P5GLVisualizer(paramDesc) {
+ static category = 'Factor Histogram'
+ static description =
+ 'Displays a histogram of the number of prime factors of a sequence'
+
+ factoring = true
+ binFactorArray: number[] = []
+ numUnknown = 0
+ fontsLoaded = false
// Obtain the binned difference of an input
binOf(input: number): number {
return Math.trunc(input / this.binSize)
}
- // Create an array with the number of factors of
- // the element at the corresponding index of the array
- factorArray(): number[] {
- const factorArray = []
- for (let i = this.startIndex(); i < this.endIndex(); i++) {
+ endIndex(): bigint {
+ // TODO: Should post warning about artificial limitation here
+ // (when it takes effect)
+ return typeof this.seq.last === 'bigint'
+ ? this.seq.last
+ : this.seq.first + 9999n
+ }
+
+ // Create an array with the value at n being the number of entries
+ // of the sequence having n factors. Entries with unknown factorization
+ // are put into -1
+ factorCounts(): number[] {
+ const factorCount = []
+ for (let i = this.seq.first; i <= this.endIndex(); i++) {
let counter = 0
const factors = this.seq.getFactors(i)
if (factors) {
for (const [base, power] of factors) {
- if (base > 0n) {
- counter += Number(power)
- } else if (base === 0n) {
+ if (base === 0n) {
counter = 0
+ break
}
+ counter += math.safeNumber(power)
}
}
-
- factorArray[i] = counter
+ if (counter === 0 && math.bigabs(this.seq.getElement(i)) > 1) {
+ counter = -1
+ }
+ if (counter in factorCount) factorCount[counter]++
+ else factorCount[counter] = 1
}
- return factorArray
+ return factorCount
}
// Create an array with the frequency of each number
// of factors in the corresponding bins
- binFactorArraySetup() {
- const factorArray = this.factorArray()
- const largestValue = factorArray.reduce(
- (a: number, b: number) => Math.max(a, b),
- -Infinity
+ async binFactorArraySetup() {
+ await this.seq.fill(this.endIndex(), 'factors')
+ const factorCount = this.factorCounts()
+ let largestValue = factorCount.length - 1
+ if (largestValue < 0) largestValue = 0
+ this.binFactorArray = new Array(this.binOf(largestValue) + 1).fill(0)
+ factorCount.forEach(
+ (count, ix) => (this.binFactorArray[this.binOf(ix)] += count)
)
- for (let i = 0; i < this.binOf(largestValue) + 1; i++) {
- this.binFactorArray.push(0)
- }
-
- for (let i = 0; i < factorArray.length; i++) {
- this.binFactorArray[this.binOf(factorArray[i])]++
- }
+ if ((-1) in factorCount) {
+ this.numUnknown = factorCount[-1]
+ this.binFactorArray[0] += this.numUnknown
+ } else this.numUnknown = 0
+ this.factoring = false
}
// Create a number that represents how
// many pixels wide each bin should be
binWidth(): number {
const width = this.sketch.width
+ let nBars = this.binFactorArray.length
+ if (nBars > 30) nBars = 30
// 0.95 Creates a small offset from the side of the screen
- if (this.binFactorArray.length <= 30) {
- return (0.95 * width) / this.binFactorArray.length - 1
- } else {
- return (0.95 * width) / 30 - 1
- }
+ return (0.95 * width) / nBars - 1
}
// Create a number that represents how many pixels high
@@ -181,98 +149,121 @@ class FactorHistogram extends P5Visualizer {
// check if mouse is in the given bin
mouseOverInBin(xAxisHeight: number, binIndex: number): boolean {
- const y = this.sketch.mouseY
+ const {pY} = this.mouseToPlot()
// hard to mouseover tiny bars; min height to catch mouse
return (
- y
+ pY
> Math.min(
xAxisHeight
- this.height() * this.binFactorArray[binIndex],
xAxisHeight - 10
- ) && y < xAxisHeight
+ ) && pY < xAxisHeight
) // and above axis
}
+ setup() {
+ super.setup()
+ this.fontsLoaded = false
+ this.sketch.loadFont(interFont, font => {
+ this.sketch.textFont(font)
+ this.fontsLoaded = true
+ })
+ this.factoring = true
+ this.binFactorArraySetup()
+ }
+
+ barLabel(binIndex: number) {
+ if (this.binSize === 1) return binIndex.toString()
+ const binStart = this.binSize * binIndex
+ return `${binStart}-${binStart + this.binSize - 1}`
+ }
+
+ write(txt: string, x: number, y: number) {
+ if (this.fontsLoaded) this.sketch.text(txt, x, y)
+ }
+
drawHoverBox(binIndex: number, offset: number) {
const sketch = this.sketch
- const mouseX = sketch.mouseX
- const mouseY = sketch.mouseY
- const boxWidth = sketch.width * 0.15
- const textVerticalSpacing = sketch.textAscent()
- const boxHeight = textVerticalSpacing * 2.3
+ const {pX, pY, scale} = this.mouseToPlot()
+ const showUnknown = binIndex === 0 && this.numUnknown > 0
+ let textVerticalSpacing = sketch.textAscent() + 1
+ // Literally no idea why we only have to scale when scale > 1 :-/
+ // but there's no arguing with it looking right
+ if (scale > 1) textVerticalSpacing *= scale
+ let boxHeight = textVerticalSpacing * 2.4
+ if (showUnknown) boxHeight += textVerticalSpacing
+ const margin = offset
+ const boxRadius = Math.floor(margin)
+
+ // Set up the texts to display:
+ const captions = ['Factors: ', 'Height: ']
+ const values = [
+ this.barLabel(binIndex),
+ this.binFactorArray[binIndex].toString(),
+ ]
+ if (showUnknown) {
+ captions.push('Unknown: ')
+ values.push(this.numUnknown.toString())
+ }
+ let captionWidth = 0
+ let totalWidth = 0
+ for (let i = 0; i < captions.length; ++i) {
+ let width = sketch.textWidth(captions[i])
+ if (width > captionWidth) captionWidth = width
+ width += sketch.textWidth(values[i])
+ if (width > totalWidth) totalWidth = width
+ }
+ totalWidth += 2 * margin
+
// don't want box to wander past right edge of canvas
- const boxX = Math.min(mouseX, sketch.width - boxWidth)
- const boxY = mouseY - boxHeight
- const boxOffset = offset
- const boxRadius = Math.floor(boxOffset)
+ const boxX = Math.min(pX, sketch.width - totalWidth)
+ const boxY = pY - boxHeight
// create the box itself
- sketch
- .fill('white')
- .rect(
- boxX,
- boxY,
- boxWidth,
- boxHeight,
- boxRadius,
- boxRadius,
- boxRadius,
- boxRadius
- )
+ sketch.push()
+ sketch.translate(0, 0, 2)
+ sketch.fill('white')
+ sketch.rect(boxX, boxY, totalWidth, boxHeight, boxRadius)
// Draws the text for the number of prime factors
// that bin represents
- sketch
- .fill('black')
- .text('Factors:', boxX + boxOffset, boxY + textVerticalSpacing)
- let binText = ''
- if (this.binSize != 1) {
- binText = (
- this.binSize * binIndex
- + '-'
- + (this.binSize * (binIndex + 1) - 1)
- ).toString()
- } else {
- binText = binIndex.toString()
- }
- const binTextSize = sketch.textWidth(binText) + 3 * boxOffset
- sketch.text(
- binText,
- boxX + boxWidth - binTextSize,
- boxY + textVerticalSpacing
- )
+ sketch.fill('black')
- // Draws the text for the number of elements of the sequence
- // in the bin
- sketch.text(
- 'Height:',
- boxX + boxOffset,
- boxY + textVerticalSpacing * 2
- )
- const heightText = this.binFactorArray[binIndex].toString()
- sketch.text(
- heightText,
- boxX + boxWidth - 3 * boxOffset - sketch.textWidth(heightText),
- boxY + textVerticalSpacing * 2
- )
+ for (let i = 0; i < captions.length; ++i) {
+ this.write(
+ captions[i] + values[i],
+ boxX + margin,
+ boxY + (i + 1) * textVerticalSpacing
+ )
+ }
+ sketch.pop()
}
draw() {
+ this.handleDrags()
const sketch = this.sketch
- if (this.binFactorArray.length == 0) {
- this.binFactorArraySetup()
- }
sketch.background(176, 227, 255) // light blue
- sketch.textSize(0.02 * sketch.height)
- const height = this.height()
- const binWidth = this.binWidth()
+ // Convert back to the ordinary p5 coordinates as this was
+ // originally written with:
+ sketch.translate(-this.size.width / 2, -this.size.height / 2)
+ const {pX, scale} = this.mouseToPlot()
+ sketch.textSize(Math.max(0.02 * sketch.height * scale, 10))
+ const height = this.height() // "unit" height
+ const textHeight = sketch.textAscent() * scale
const largeOffsetScalar = 0.945 // padding between axes and edge
const smallOffsetScalar = 0.996
const largeOffsetNumber = (1 - largeOffsetScalar) * sketch.width
const smallOffsetNumber = (1 - smallOffsetScalar) * sketch.width
- const binIndex = Math.floor(
- (sketch.mouseX - largeOffsetNumber) / binWidth
- )
+
+ if (this.factoring) {
+ sketch.fill('red')
+ this.write('Factoring ...', largeOffsetNumber, textHeight * 2)
+ this.continue()
+ this.stop(3)
+ }
+
+ const binWidth = this.binWidth()
+ const binIndex = Math.floor((pX - largeOffsetNumber) / binWidth)
const xAxisHeight = largeOffsetScalar * sketch.height
// Checks to see whether the mouse is in the bin drawn on the screen
@@ -296,95 +287,117 @@ class FactorHistogram extends P5Visualizer {
}
// Draw the rectangles for the Histogram
sketch.rect(
- largeOffsetNumber + binWidth * i + 1,
- largeOffsetScalar * sketch.height
- - height * this.binFactorArray[i],
+ yAxisPosition + binWidth * i + 1,
+ xAxisHeight - height * this.binFactorArray[i],
binWidth - 2,
height * this.binFactorArray[i]
)
- if (this.binFactorArray.length > 30) {
- sketch.text(
- 'Too many unique factors.',
- sketch.width * 0.75,
- sketch.height * 0.03
- )
- sketch.text(
- 'Displaying the first 30',
- sketch.width * 0.75,
- sketch.height * 0.05
- )
- }
-
sketch.fill('black') // text must be filled
- if (this.binSize != 1) {
- // Draws text in the case the bin size is not 1
- const binText = (
- this.binSize * i
- + ' - '
- + (this.binSize * (i + 1) - 1)
- ).toString()
- sketch.text(
- binText,
- 1 - largeOffsetScalar + binWidth * (i + 1 / 2),
- smallOffsetScalar * sketch.width
- )
- } else {
- // Draws text in the case the bin size is 1
- const binText = i.toString()
- sketch.text(
- binText,
- largeOffsetNumber + (binWidth * (i + 1) - binWidth / 2),
- smallOffsetScalar * sketch.width
- )
- }
+ const barLabel = this.barLabel(i)
+ const labelWidth = sketch.textWidth(barLabel)
+ this.write(
+ barLabel,
+ yAxisPosition + binWidth * (i + 0.5) - labelWidth / 2,
+ xAxisHeight + 1.3 * textHeight
+ )
}
- let tickHeight = Math.floor((0.95 * sketch.height) / (height * 5))
+ let nTicks = 5
+ let tickHeight = Math.floor(
+ (0.95 * sketch.height) / (height * nTicks)
+ )
// Sets the tickHeight to 1 if the calculated value is less than 1
if (tickHeight === 0) {
tickHeight = 1
}
+ // Make tickHeight a round number:
+ let roundHeight = 1
+ let bigCandidate = 1
+ const multipliers = [2, 2.5, 2]
+ while (bigCandidate < tickHeight) {
+ for (const mult of multipliers) {
+ bigCandidate *= mult
+ if (bigCandidate <= tickHeight) roundHeight = bigCandidate
+ else break
+ }
+ }
+ tickHeight = roundHeight
+ nTicks = Math.floor(sketch.height / (height * tickHeight))
+ const bigTick = nTicks * tickHeight
+ const bigTickWidth = sketch.textWidth(bigTick.toString())
// Draws the markings on the Y-axis
- for (let i = 0; i < 9; i++) {
+ const tickLeft = yAxisPosition - largeOffsetNumber / 5
+ const tickRight = yAxisPosition + largeOffsetNumber / 5
+ const rightJustify = bigTickWidth < tickLeft - 2 * smallOffsetNumber
+ for (let i = 1; i <= nTicks; i++) {
// Draws the tick marks
- sketch.line(
- (largeOffsetNumber * 3) / 4,
- sketch.height
- - largeOffsetNumber
- - tickHeight * height * (i + 1),
- (3 * largeOffsetNumber) / 2,
- sketch.height
- - largeOffsetNumber
- - tickHeight * height * (i + 1)
- )
-
- // Places the numbers on the right side of the axis if
- // they are 4 digits or more; left side otherwise
- let tickNudge = 0
- if (tickHeight > 999) {
- tickNudge = (3 * largeOffsetNumber) / 2
+ let tickY = xAxisHeight - tickHeight * height * i
+ sketch.line(tickLeft, tickY, tickRight, tickY)
+
+ const label = (tickHeight * i).toString()
+ let tickPos = tickRight + smallOffsetNumber
+ if (rightJustify) {
+ const labelWidth = sketch.textWidth(label)
+ tickPos = tickLeft - labelWidth - smallOffsetNumber
}
+
// Avoid placing text that will get cut off
- const tickYPosition =
- sketch.height
- - largeOffsetNumber
- - tickHeight * height * (i + 1)
- + (3 * smallOffsetNumber) / 2
- if (tickYPosition > sketch.textAscent()) {
- sketch.text(tickHeight * (i + 1), tickNudge, tickYPosition)
+ tickY += textHeight / 2.5
+ if (tickY > sketch.textAscent()) {
+ this.write(label, tickPos, tickY)
}
}
+ // Possible bug workaround (see drawHoverBox):
+ this.write(' ', 0, 0)
+
+ // hatch the unknown factors
+ if (this.numUnknown > 0) {
+ sketch.fill('white')
+ this.hatchRect(
+ largeOffsetNumber + 1,
+ largeOffsetScalar * sketch.height
+ - height * this.binFactorArray[0],
+ binWidth - 2,
+ height * this.numUnknown
+ )
+ }
+ if (this.binFactorArray.length > 30) {
+ sketch.fill('chocolate')
+ const {pX, pY} = this.canvasToPlot(
+ 0.75 * sketch.width,
+ 0.1 * sketch.height
+ )
+ this.write(
+ `Too many bins (${this.binFactorArray.length}),`,
+ pX,
+ pY - textHeight * 3
+ )
+ this.write('Displaying the first 30.', pX, pY - textHeight * 1.3)
+ }
// If mouse interaction, draw hover box
- if (this.mouseOver === true && inBin === true) {
+ if (this.mouseOver && inBin) {
this.drawHoverBox(binIndex, smallOffsetNumber)
}
- // If no mouse interaction, don't loop
- if (this.mouseOver === false) {
- sketch.noLoop()
+ // Once everything is loaded, no need to redraw until mouse moves
+ if (!this.fontsLoaded || this.factoring || sketch.mouseIsPressed) {
+ this.continue()
+ this.stop(3)
}
}
+
+ mouseMoved() {
+ if (this.mouseOver || this.sketch.mouseIsPressed) {
+ this.continue()
+ this.stop(3)
+ }
+ }
+
+ mousePressed() {
+ super.mousePressed()
+ this.stop(3)
+ }
}
/** md
@@ -392,7 +405,4 @@ class FactorHistogram extends P5Visualizer {
_Originally contributed by Devlin Costello._
**/
-export const exportModule = new VisualizerExportModule(
- FactorHistogram,
- 'Displays a histogram of the number of prime factors of a sequence'
-)
+export const exportModule = new VisualizerExportModule(FactorHistogram)
diff --git a/src/visualizers/ModFill.ts b/src/visualizers/ModFill.ts
index 5fddebca..205927f1 100644
--- a/src/visualizers/ModFill.ts
+++ b/src/visualizers/ModFill.ts
@@ -1,86 +1,393 @@
-import {modulo} from '../shared/math'
-import type {SequenceInterface} from '../sequences/SequenceInterface'
-import {P5Visualizer} from '../visualizers/P5Visualizer'
-import {VisualizerExportModule} from '@/visualizers/VisualizerInterface'
-import type p5 from 'p5'
+import {P5Visualizer, INVALID_COLOR} from './P5Visualizer'
+import {VisualizerExportModule} from './VisualizerInterface'
+import type {ViewSize} from './VisualizerInterface'
+
+import {math, MathFormula} from '@/shared/math'
+import type {GenericParamDescription} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
+import {ValidationStatus} from '@/shared/ValidationStatus'
+
+/* Helper for parameter specifications: */
+function nontrivialFormula(fmla: string) {
+ return fmla !== '' && fmla !== '0' && fmla !== 'false'
+}
/** md
# Mod Fill Visualizer
-[image should go here]
+[ ](../assets/img/ModFill/PrimeResidues.png)
+[ ](../assets/img/ModFill/DanceNo73.png)
+[ ](../assets/img/ModFill/OEISA070826.png)
-The _n_-th row of this triangular diagram has _n_ cells which are turned on
-or off according to whether the corresponding residue modulo _n_ occurs for
-some entry of the sequence. The entries are considered in order, filling the
-corresponding cells in turn, so you can get an idea of when various residues
-occur by watching the order the cells are filled in as the diagram is drawn.
+The _m_-th column of this triangular diagram (reading left to right)
+has _m_ cells (lowest is 0, highest is m-1), which are colored
+each time the corresponding residue modulo _m_ occurs for
+some entry of the sequence. The sequence terms _a_(_n_) are considered in
+order, filling the corresponding cells in turn, so you can get an
+idea of when various residues occur by watching the order
+the cells are filled in as the diagram is drawn. There are options
+to control color and transparency of the fill.
## Parameters
**/
-
-class ModFill extends P5Visualizer {
- static visualizationName = 'Mod Fill'
- modDimension = 10n
- params = {
- /** md
-- modDimension: The number of rows to display, which corresponds to the largest
- modulus to consider.
- **/
- // note will be small enough to fit in a `number` when we need it to.
- modDimension: {
- value: this.modDimension,
- displayName: 'Mod dimension',
- required: true,
+const paramDesc = {
+ /** md
+- Highest modulus: The number of columns to display, which corresponds
+to the largest modulus to consider.
+ **/
+ // note will be small enough to fit in a `number` when we need it to.
+ modDimension: {
+ default: 150n,
+ type: ParamType.BIGINT,
+ displayName: 'Highest modulus shown',
+ required: true,
+ validate: function (n: number, status: ValidationStatus) {
+ if (n <= 0) status.addError('Must be positive.')
},
- }
+ },
+ /** md
+- Background color: The color of the background
+ **/
+ backgroundColor: {
+ default: '#FFFFFF',
+ type: ParamType.COLOR,
+ displayName: 'Background color',
+ required: true,
+ },
+ /** md
+- Fill color: The color used to fill each cell by default.
+ **/
+ fillColor: {
+ default: '#000000',
+ type: ParamType.COLOR,
+ displayName: 'Fill color',
+ required: true,
+ },
+ /** md
+- Opacity: The rate at which cells darken with repeated drawing. This
+should be set between 0 (transparent) and 1 (solid), typically as a constant,
+but can be set as a function of _n_, the sequence index, _a_, the sequence
+entry, and/or _m_, the modulus.
+If the function evaluates to a number less than 0, it will behave as 0; if it
+ evaluates to more than 1, it will behave as 1. Default:
+ **/
+ alpha: {
+ default: new MathFormula(
+ /** md */
+ `1`
+ /* **/
+ ),
+ type: ParamType.FORMULA,
+ inputs: ['n', 'a', 'm'],
+ displayName: 'Opacity',
+ description:
+ 'The opacity of each new rectangle (rate at which cells'
+ + ' darken with repeated drawing). Between 0 '
+ + '(transparent) and 1 (solid). '
+ + "Can be a function in 'n' (index), 'a' (entry) "
+ + "and 'm' (modulus).",
+ required: false,
+ },
+ /** md
+- Square canvas: If true, force canvas to be aspect ratio 1 (square).
+Defaults to false.
+ **/
+ aspectRatio: {
+ default: 0,
+ type: ParamType.BOOLEAN,
+ displayName: 'Square canvas',
+ required: false,
+ },
+ /** md
+- Highlight formula: A formula whose output, modulo 2, determines whether
+to apply the highlight color (residue 0) or fill color (residue 1).
+Note that a boolean `true` value counts as 1 and `false` as 0. As with
+Opacity, the formula can involve variables _n_ (index), _a_ (entry) and/or
+_m_ (modulus). Default:
+**/
+ highlightFormula: {
+ default: new MathFormula(
+ // Note: he markdown comment closed with */ means to include code
+ // into the docs, until mkdocs reaches a comment ending with **/
+ /** md */
+ `false`
+ /* **/
+ ),
+ type: ParamType.FORMULA,
+ inputs: ['n', 'a', 'm'],
+ displayName: 'Highlighting',
+ description:
+ "A function in 'n' (index), 'a' (entry) "
+ + "and 'm' (modulus); "
+ + 'when output is odd '
+ + '(number) or true (boolean), draws residue of '
+ + 'a(n) in the highlight color.'
+ /** md
+{! ModFill.ts extract:
+ start: '[*] EXAMPLES [*]'
+ stop: 'required[:]'
+ replace: [['^\s*[+]\s"(.*)"[\s,]*$', ' \1']]
+!}
+ **/
+ /* EXAMPLES */
+ + 'Examples: `isPrime(n)` highlights entries with prime index; '
+ + '`a` highlights entries with odd value; and `m == 30` '
+ + 'highlights the modulus 30 column.',
+ required: false,
+ },
+ /** md
+- Highlight color: The color used for highlighting.
+ **/
+ highColor: {
+ default: '#c98787',
+ type: ParamType.COLOR,
+ displayName: 'Highlight color',
+ required: true,
+ visibleDependency: 'highlightFormula',
+ visiblePredicate: (dependentValue: MathFormula) =>
+ nontrivialFormula(dependentValue.source),
+ },
+ /** md
+- Highlight opacity: The rate at which cells darken with repeated
+highlighting. This should be set between 0 (transparent) and 1 (opaque),
+and has the analogous meaning and may use the same variables as Opacity.
+Default: if this parameter is not specified, the same value/formula for
+Opacity as described above will be used.
+ **/
+ alphaHigh: {
+ default: new MathFormula(''),
+ type: ParamType.FORMULA,
+ inputs: ['n', 'a', 'm'],
+ displayName: 'Highlight opacity',
+ description:
+ 'The opacity of each new rectangle (rate at which cells'
+ + ' darken with repeated drawing). Between 0'
+ + '(transparent) and 1 (opaque). '
+ + "Can be a function in 'n' (index), 'a' (value) "
+ + "and 'm' (modulus).",
+ placeholder: '[same as Opacity]',
+ required: false,
+ visibleDependency: 'highlightFormula',
+ visiblePredicate: (dependentValue: MathFormula) =>
+ nontrivialFormula(dependentValue.source),
+ },
+ /** md
+- Sunzi mode: Warning: can create a stroboscopic effect.
+This sets the opacity of the background color
+overlay added at each step. If 0, there is no effect.
+If 1, then the canvas completely blanks between terms,
+allowing you to see each term of the sequence individually.
+In that case, it helps to turn down the Frame rate (it
+can create quite a stroboscopic effect). If
+set in the region of 0.05, it has a "history fading effect"
+in that the contribution of long past terms fades into the background.
+This parameter is named for Sunzi's Theorem (also known as the
+Chinese Remainder Theorem).
+ **/
+ sunzi: {
+ default: 0,
+ type: ParamType.NUMBER,
+ displayName: 'Sunzi effect',
+ description:
+ 'The canvas background colour is painted at this '
+ + 'opacity between '
+ + 'each term of the '
+ + 'sequence. '
+ + 'If 0, no effect. If 1, canvas completely '
+ + 'blanks between terms (warning! can be '
+ + 'stroboscopic), so the residues of only a '
+ + 'single term are shown '
+ + 'in each frame. '
+ + 'Otherwise a history fading effect (try 0.05).',
+ required: false,
+ validate: function (n: number, status: ValidationStatus) {
+ if (n < 0 || n > 1) status.addError('Must be between 0 and 1.')
+ },
+ },
+ /** md
+- Frame rate: Entries displayed per second. Can be useful in combination with
+Sunzi mode. Only visible when Sunzi mode is nonzero.
+ **/
+ frameRate: {
+ default: 60,
+ type: ParamType.NUMBER,
+ displayName: 'Frame rate',
+ required: false,
+ visibleDependency: 'sunzi',
+ visiblePredicate: s => s !== 0,
+ validate: function (n: number, status: ValidationStatus) {
+ if (n < 0 || n > 100)
+ status.addError('Must be between 0 and 100.')
+ },
+ },
+} satisfies GenericParamDescription
+
+class ModFill extends P5Visualizer(paramDesc) {
+ static category = 'Mod Fill'
+ static description =
+ 'An array showing which residues occur, for each modulus'
+ maxModulus = 0
rectWidth = 0
rectHeight = 0
- i = 0
-
- checkParameters() {
- const status = super.checkParameters()
+ useMod = 0
+ useFillColor = INVALID_COLOR
+ useHighColor = INVALID_COLOR
+ useBackColor = INVALID_COLOR
+ i = 0n
- if (this.params.modDimension.value <= 0n) {
- status.isValid = false
- status.errors.push('Mod dimension must be positive')
+ trySafeNumber(input: bigint) {
+ let use = 0
+ try {
+ use = math.safeNumber(input)
+ } catch {
+ // should we notify the user that we are stopping?
+ this.stop()
+ return 0
}
-
- return status
+ return use
}
- drawNew(sketch: p5, num: number, seq: SequenceInterface) {
- sketch.fill(0)
- for (let mod = 1n; mod <= this.modDimension; mod++) {
- const s = seq.getElement(num)
- const x = Number(mod - 1n) * this.rectWidth
+ drawNew(num: bigint) {
+ let drawColor = this.useFillColor
+ let alphaFormula = this.alpha
+ let alphaStatus = this.statusOf.alpha
+ let alphaVars = this.alpha.freevars
+ const value = this.seq.getElement(num)
+
+ // determine alpha
+ const vars = this.highlightFormula.freevars
+ let useNum = 0
+ let useValue = 0
+
+ // because safeNumber can fail, we conly want to try it
+ // if we need it in the formula
+ if (vars.includes('n')) useNum = this.trySafeNumber(num)
+ if (vars.includes('a')) useValue = this.trySafeNumber(value)
+ let x = 0
+ for (let mod = 1; mod <= this.useMod; mod++) {
+ // needs to take BigInt when implemented
+ const highValue = this.highlightFormula.computeWithStatus(
+ this.statusOf.highlightFormula,
+ useNum,
+ useValue,
+ mod
+ )
+ let high = false
+ if (typeof highValue === 'boolean') high = highValue
+ else if (
+ typeof highValue === 'number'
+ || typeof highValue === 'bigint'
+ ) {
+ high = math.modulo(highValue, 2) === 1n
+ }
+ // set color
+ if (high) {
+ drawColor = this.useHighColor
+ if (this.alphaHigh.source !== '') {
+ alphaFormula = this.alphaHigh
+ alphaStatus = this.statusOf.alphaHigh
+ alphaVars = this.alphaHigh.freevars
+ }
+ }
+ if (alphaVars.includes('n')) useNum = this.trySafeNumber(num)
+ if (alphaVars.includes('a')) useValue = this.trySafeNumber(value)
+ const alphaValue = alphaFormula.computeWithStatus(
+ alphaStatus,
+ useNum,
+ useValue,
+ mod
+ )
+ if (typeof alphaValue === 'number') {
+ drawColor.setAlpha(255 * alphaValue)
+ }
+
+ // draw rectangle
+ this.sketch.fill(drawColor)
const y =
- sketch.height - Number(modulo(s, mod) + 1n) * this.rectHeight
- sketch.rect(x, y, this.rectWidth, this.rectHeight)
+ this.sketch.height
+ - Number(math.modulo(value, mod) + 1n) * this.rectHeight
+ this.sketch.rect(x, y, this.rectWidth, this.rectHeight)
+ x += this.rectWidth
}
}
+ requestedAspectRatio(): number | undefined {
+ return this.aspectRatio == true ? 1 : undefined
+ }
+
+ async presketch(size: ViewSize) {
+ await super.presketch(size)
+ const minDimension = Math.min(size.width, size.height)
+ // 16 was chosen in the following expression by doubling the
+ // multiplier until the traces were almost too faint to see at all.
+ this.maxModulus = 16 * minDimension
+ }
+
setup() {
super.setup()
- if (!this.sketch) {
- throw 'Attempt to show ModFill before injecting into element'
- }
- this.rectWidth = this.sketch.width / Number(this.modDimension)
- this.rectHeight = this.sketch.height / Number(this.modDimension)
- this.sketch.noStroke()
+ const modDimWarning = 'Running with maximum modulus'
+
+ // We need to check if the "mod dimension" fits on screen,
+ // and adjust if not.
+
+ // First, remove any prior modDimWarning that might be there
+ // (so they don't accumulate from repeated parameter changes):
+ const warnings = this.statusOf.modDimension.warnings
+ const oldWarning = warnings.findIndex(warn =>
+ warn.startsWith(modDimWarning)
+ )
+ if (oldWarning >= 0) warnings.splice(oldWarning, 1)
+
+ // Now check the dimension and warn if need be:
+ if (this.modDimension > this.maxModulus) {
+ warnings.push(
+ `${modDimWarning} ${this.maxModulus}, since `
+ + `${this.modDimension} will not fit on screen.`
+ )
+ this.useMod = this.maxModulus
+ } else this.useMod = Number(this.modDimension)
+
+ // Now we can calculate the cell size:
+ this.rectWidth = this.sketch.width / this.useMod
+ this.rectHeight = this.sketch.height / this.useMod
+
+ // set color info
+ this.useBackColor = this.sketch.color(this.backgroundColor)
+ this.useFillColor = this.sketch.color(this.fillColor)
+ this.useHighColor = this.sketch.color(this.highColor)
+
+ // Set up to draw:
+ this.sketch
+ .frameRate(this.frameRate)
+ .noStroke()
+ .background(this.useBackColor)
this.i = this.seq.first
+ this.useBackColor.setAlpha(255 * this.sunzi)
}
draw() {
- this.drawNew(this.sketch, this.i, this.seq)
- this.i++
- if (this.i == 1000 || this.i > this.seq.last) {
- this.sketch.noLoop()
+ if (this.i > this.seq.last) {
+ this.stop()
+ return
}
+ // sunzi effect
+ this.sketch.fill(this.useBackColor)
+ this.sketch.rect(0, 0, this.sketch.width, this.sketch.height)
+
+ // draw residues
+ this.drawNew(this.i)
+ // Important to increment _after_ drawNew completes, because it
+ // won't complete on a cache miss, and in that case we don't want to
+ // increment the index because we didn't actually draw anything.
+ ++this.i
}
}
-export const exportModule = new VisualizerExportModule(
- ModFill,
- 'A triangular grid showing which residues occur, to each modulus'
-)
+export const exportModule = new VisualizerExportModule(ModFill)
diff --git a/src/visualizers/NumberGlyph.ts b/src/visualizers/NumberGlyph.ts
index a4656a92..41ce588f 100644
--- a/src/visualizers/NumberGlyph.ts
+++ b/src/visualizers/NumberGlyph.ts
@@ -1,34 +1,37 @@
import p5 from 'p5'
+
import {P5Visualizer} from './P5Visualizer'
-import type {SequenceInterface} from '../sequences/SequenceInterface'
-//import type {Factorization} from '../sequences/SequenceInterface'
-import {VisualizerExportModule} from '@/visualizers/VisualizerInterface'
-import * as math from 'mathjs'
+import {VisualizerExportModule} from './VisualizerInterface'
+import type {ViewSize} from './VisualizerInterface'
+
+import {math, MathFormula} from '@/shared/math'
+import type {GenericParamDescription} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
/** md
# Number Glyphs
-[ ](../assets/img/glyph/ring1.png)
The terms of the sequence are laid out in a grid, left-to-right and top
to bottom. For each term, a glyph is generated: an image that depends
on the term, and has distinctive visual features. The glyph generating
-algorithm may depend on all the terms shown on screen, but
+algorithm may depend on all the terms shown on screen, but
repeated terms in the sequence will give repeated glyphs.
-The default glyph generation algorithm is as follows. Each glyph has a
+The default glyph generation algorithm is as follows. Each glyph has a
colour that reflects the prime factorization of the number, obtained by
blending colours assigned to all the primes appearing as divisors in the
terms of the sequence which appear on the screen.
-The term is drawn as a disk, whose brightness
+The term is drawn as a disk, whose brightness
varies according to a given function from outer rim to center.
-The function grows faster for larger terms, and incorporates a
+The function grows faster for larger terms, and incorporates a
modulus function so that one observes 'growth rings;' that is,
tighter growth rings indicate a larger integer. Growth rings
-that are drawn more frequently than the pixel distance will be suffer
+that are drawn more frequently than the pixel distance will be suffer
from a sort of aliasing effect, appearing as if they are less frequent.
### Bigint errors
@@ -37,9 +40,9 @@ Because `math.js` does not handle bigints, this visualizer will produce
errors when any of the following occur:
- terms do not fit in the javascript Number type
-- the growth function evaluated at a term does not fit in the Number
+- the growth function evaluated at a term does not fit in the Number
type
-- as with any visualizer, if the visualizer is used with a Sequence
+- as with any visualizer, if the visualizer is used with a Sequence
From Formula which produces invalid or incorrect output because of overflow
The latter two types of errors occur inside `math.js` and may
@@ -50,96 +53,88 @@ exceeds \( 2^{53}-1 \) to be 0.
### Parameters
**/
-class NumberGlyph extends P5Visualizer {
- static visualizationName = 'Number Glyphs'
- n = 64
- customize = false
- brightCap = 25
- formula = 'log(max(abs(n),2)^x) % 25'
-
- params = {
- /** md
-##### Number of Terms
-
-The number of terms to display onscreen. The sizes of the discs will
-be sized so that there are \(N^2\) disc positions, where \(N^2\) is the
-smallest square exceeding the number of terms (so that the terms mostly fill
-the screen). Choose a perfect square number of terms to fill the square.
-If the sequence does not have that many terms, the visualizer will
-only attempt to show the available terms.
-**/
- n: {
- value: this.n,
- forceType: 'integer',
- displayName: 'Number of Terms',
- required: true,
- },
- /** md
+const paramDesc = {
+ /** md
##### Customize Glyphs
This is a boolean which, if selected, will reveal further customization
options for the glyph generation function.
**/
- customize: {
- value: this.customize,
- forceType: 'boolean',
- displayName: 'Customize Glyphs',
- required: true,
- },
- /** md
+ customize: {
+ default: false,
+ type: ParamType.BOOLEAN,
+ displayName: 'Customize Glyphs',
+ required: true,
+ },
+ /** md
##### Growth Function
This is a function in two variables, n and x. Most standard math notations
-are accepted (+, -, *, / , ^, log, sin, cos etc.) The variable n
+are accepted (+, -, *, / , ^, log, sin, cos etc.) The variable n
represents the
-term of which this disc is a representation. The variable x takes the value
-0 at the outer rim of the disk, increasing once per pixel until the
+term of which this disc is a representation. The variable x takes the value
+0 at the outer rim of the disk, increasing once per pixel until the
center. The absolute value of this function determines the brightness of the
disk at that radius. A value of 0 is black
-and higher values are brighter.
+and higher values are brighter.
-The default value is `log(max(abs(n),2)^x) % 25`.
+The default value is
**/
- formula: {
- value: this.formula,
- displayName: 'Growth Function',
- description: "A function in 'n' (term) and 'x' (growth variable)",
- visibleDependency: 'customize',
- visibleValue: true,
- required: true,
- },
- /** md
+ growthFormula: {
+ default: new MathFormula(
+ // Note: he markdown comment closed with */ means to include code
+ // into the docs, until mkdocs reaches a comment ending with **/
+ /** md */
+ `(log(max(abs(n),2)) * x) % 25`
+ /* **/
+ ),
+ type: ParamType.FORMULA,
+ inputs: ['n', 'x'],
+ displayName: 'Growth Function',
+ description: "A function in 'n' (term) and 'x' (growth variable)",
+ visibleDependency: 'customize',
+ visibleValue: true,
+ required: false,
+ },
+ /** md
##### Brightness Adjustment
-This is a brightness adjustment. It acts as a brightness cap or
-cutoff: brightness values will range from zero to this value. If set
-high (higher than most values of the function) it will produce dull dark
-glyphs. If set low,
-brightnesses above the cutoff will be rounded down to the cutoff, resulting in
+This is a brightness adjustment. It acts as a brightness cap or
+cutoff: brightness values will range from zero to this value. If set
+high (higher than most values of the function) it will produce dull dark
+glyphs. If set low,
+brightnesses above the cutoff will be rounded down to the cutoff, resulting in
bright `flat' glyphs with less brightness variation.
In general, results are nice when the brightness adjustment
is set to the maximum attained by the Growth Function.
-The default value is 25.
+The default value is 25.
**/
- brightCap: {
- value: this.brightCap,
- forceType: 'integer',
- displayName: 'Brightness Adjustment',
- description:
- 'Smaller values make brighter glyphs with decreased variation',
- visibleDependency: 'customize',
- visibleValue: true,
- required: false,
- },
- }
-
- private evaluator: math.EvalFunction
-
- colorMap = new Map()
- private last = 0
- private currentIndex = 0
+ brightCap: {
+ default: 25,
+ type: ParamType.INTEGER,
+ displayName: 'Brightness Adjustment',
+ description:
+ 'Smaller values make brighter glyphs with decreased variation',
+ visibleDependency: 'customize',
+ visibleValue: true,
+ required: false,
+ },
+} satisfies GenericParamDescription
+
+const minIncrement = 8 // smallest allowed spacing between glyphs
+
+class NumberGlyph extends P5Visualizer(paramDesc) {
+ static category = 'Number Glyphs'
+ static description =
+ 'Map entries to colorful glyphs '
+ + 'using their magnitudes and prime factors'
+
+ hueMap = new Map()
+ private n = 0n
+ private last = 0n
+ private currentIndex = 0n
private position = new p5.Vector()
private initialPosition = new p5.Vector()
private positionIncrement = 100
@@ -147,7 +142,6 @@ The default value is 25.
private boxIsShow = false
private primeNum: bigint[] = []
private countPrime = 0
- private firstDraw = true
private showLabel = false
private brightAdjust = 100
@@ -155,86 +149,32 @@ The default value is 25.
private radii = 50 // increments of radius in a dot
private initialRadius = 50 // size of dots
- constructor(seq: SequenceInterface) {
- super(seq)
- // It is mandatory to initialize the `evaluator` property here,
- // so just use a simple dummy formula until the user provides one.
- this.evaluator = math.compile(this.formula)
- }
-
- checkParameters() {
- // code currently re-used from SequenceFormula.ts
- const status = super.checkParameters()
-
- let parsetree = undefined
- try {
- parsetree = math.parse(this.params.formula.value)
- } catch (err: unknown) {
- status.isValid = false
- status.errors.push(
- 'Could not parse formula: ' + this.params.formula.value
- )
- status.errors.push((err as Error).message)
- return status
- }
- const othersymbs = parsetree.filter(
- (node, path, parent) =>
- math.isSymbolNode(node)
- && parent?.type !== 'FunctionNode'
- && node.name !== 'n'
- && node.name !== 'x'
- )
- if (othersymbs.length > 0) {
- status.isValid = false
- status.errors.push(
- "Only 'n' and 'x' may occur as a free variable in formula.",
- `Please remove '${(othersymbs[0] as math.SymbolNode).name}'`
- )
+ adjustTermsAndColumns(size: ViewSize) {
+ // Calculate the number of terms we are actually going to show:
+ this.n = typeof this.seq.length === 'bigint' ? this.seq.length : 64n
+ this.columns = math.safeNumber(math.floorSqrt(this.n))
+ if (this.n > this.columns * this.columns) ++this.columns
+
+ // Adjust columns downwards so that the discs will not be
+ // too microscopic:
+ const fitTo = Math.min(size.width, size.height)
+ this.columns = Math.min(this.columns, Math.ceil(fitTo / minIncrement))
+ if (this.n > this.columns * this.columns) {
+ this.n = BigInt(this.columns * this.columns)
}
- this.evaluator = parsetree.compile()
- return status
- }
+ // TODO: if this.n is less than this.seq.length, we should post a
+ // warning; note that by construction, it can't be more.
- growthFunction(n: number, x: number) {
- return this.evaluator.evaluate({n: n, x: x})
+ this.positionIncrement = fitTo / this.columns
+ this.last = this.seq.first + this.n - 1n
}
- setup() {
- super.setup()
-
- this.currentIndex = this.seq.first
- this.position = this.sketch.createVector(0, 0)
- const canvasSize = this.sketch.createVector(
- this.sketch.width,
- this.sketch.height
- )
- this.columns = Math.ceil(Math.sqrt(this.n))
- this.last = this.n + this.seq.first // adjust for offset
- if (this.last > this.seq.last) {
- this.last = this.seq.last
- }
- this.positionIncrement = Math.floor(
- Math.min(canvasSize.x, canvasSize.y) / this.columns
- )
- this.initialRadius = Math.floor(this.positionIncrement / 2)
- this.radii = this.initialRadius
-
- this.sketch
- .background('black')
- .colorMode(this.sketch.HSB, 360, 100, 100)
- .frameRate(30)
-
- this.firstDraw = true
-
- // Set position of the circle
- this.initialPosition = this.sketch.createVector(
- this.initialRadius,
- this.initialRadius
- )
- this.position = this.sketch.createVector(
- this.initialPosition.x,
- this.initialPosition.y
- )
+ async presketch(size: ViewSize) {
+ await super.presketch(size)
+ this.adjustTermsAndColumns(size)
+ // NumberGlyph needs access to its entire range of values
+ // before the sketch setup is even called
+ await this.seq.fill(this.last, 'factors')
// Obtain all prime numbers that appear as factors in the sequence
for (let i = this.seq.first; i < this.last; i++) {
@@ -256,39 +196,54 @@ The default value is 25.
}
}
}
+ //assign hue to each prime number
+ const hueIncrement = 360 / this.countPrime
- //assign color to each prime number
- const colorNum = 360 / this.countPrime
-
- this.colorMap.set(1, 0)
- let tmp = 0
+ this.hueMap.set(1, 0)
+ let hue = 0
for (let i = 0; i < this.primeNum.length; i++) {
- if (this.colorMap.has(this.primeNum[i]) == false) {
- tmp += colorNum
- this.colorMap.set(this.primeNum[i], tmp)
+ if (this.hueMap.has(this.primeNum[i]) == false) {
+ hue += hueIncrement
+ this.hueMap.set(this.primeNum[i], hue)
}
}
}
- draw() {
- if (this.firstDraw == true && this.currentIndex < this.last) {
- this.sketch.noStroke()
+ setup() {
+ super.setup()
- this.drawCircle(this.currentIndex)
+ this.adjustTermsAndColumns(this.size)
+ this.currentIndex = this.seq.first
+ this.position = this.sketch.createVector(0, 0)
+ this.initialRadius = Math.floor(this.positionIncrement / 2)
+ this.radii = this.initialRadius
- this.changePosition()
+ this.sketch
+ .background('black')
+ .colorMode(this.sketch.HSB, 360, 100, 100)
+ .frameRate(30)
- this.currentIndex++
+ // Set position of the circle
+ this.initialPosition = this.sketch.createVector(
+ this.initialRadius,
+ this.initialRadius
+ )
+ this.position = this.initialPosition.copy()
+ }
- // Check if drawing finished
- if (this.currentIndex >= this.last) {
- this.firstDraw = false
- }
+ draw() {
+ this.sketch.noStroke()
+ if (this.currentIndex > this.last) {
+ this.stop()
+ return
}
+ this.drawCircle(this.currentIndex)
+ this.changePosition()
+ ++this.currentIndex
}
- drawCircle(ind: number) {
+ drawCircle(ind: bigint) {
let numberNowBigint = this.seq.getElement(ind)
// temporary fix while math.js doesn't handle bigint
if (
@@ -305,21 +260,26 @@ The default value is 25.
let bright = 0
// Obtain the color of the circle
- let combinedColor = this.factorColor(ind)
+ let combinedHue = this.factorHue(ind)
let saturation = 100
// greyscale if no primes
// (occurs for -1,0,1 or couldn't factor)
- if (combinedColor == -1) {
+ if (combinedHue == -1) {
saturation = 0
- combinedColor = 0
+ combinedHue = 0
}
- this.sketch.fill(combinedColor, saturation, bright)
+ this.sketch.fill(combinedHue, saturation, bright)
// iterate smaller and smaller circles
for (let x = 0; x < this.radii; x++) {
// set brightness based on function value
- const val = this.growthFunction(numberNow, x)
- bright = val
+ const val = this.growthFormula.computeWithStatus(
+ this.statusOf.growthFormula,
+ numberNow,
+ x
+ )
+ //@ts-expect-error numeric not in .d.ts, mathjs update will fix
+ bright = math.numeric(val)
if (bright < 0) {
bright = -bright
}
@@ -329,7 +289,7 @@ The default value is 25.
bright = this.brightAdjust * (bright / this.brightCap)
// draw the circle
- this.sketch.fill(combinedColor, saturation, bright)
+ this.sketch.fill(combinedHue, saturation, bright)
this.sketch.ellipse(
this.position.x,
this.position.y,
@@ -344,13 +304,18 @@ The default value is 25.
changePosition() {
this.position.add(this.positionIncrement, 0)
// if we need to go to next line
- if ((this.currentIndex - this.seq.first + 1) % this.columns == 0) {
+ if (
+ math.divides(
+ this.columns,
+ this.currentIndex - this.seq.first + 1n
+ )
+ ) {
this.position.x = this.initialPosition.x
this.position.add(0, this.positionIncrement)
}
}
- isPrime(ind: number): boolean {
+ isPrime(ind: bigint): boolean {
const factors = this.seq.getFactors(ind)
if (
factors === null // if we can't factor, it isn't prime
@@ -373,39 +338,35 @@ The default value is 25.
}
//return a number which represents the color
- factorColor(ind: number) {
+ factorHue(ind: bigint) {
const factors = this.seq.getFactors(ind)
if (factors === null) {
return -1
} // factoring failed
//Combine color for each prime factor
- let colorAll = -1
+ let hue = -1
for (let i = 0; i < factors.length; i++) {
const thisPrime = factors[i][0]
const thisExp = factors[i][1]
for (let j = 0; j < this.primeNum.length; j++) {
if (thisPrime == this.primeNum[j]) {
for (let k = 0; k < thisExp; k++) {
- if (colorAll == -1) {
- colorAll = this.colorMap.get(thisPrime)
+ if (hue == -1) {
+ hue = this.hueMap.get(thisPrime)
} else {
- colorAll =
- (colorAll + this.colorMap.get(thisPrime)) / 2
+ hue = (hue + this.hueMap.get(thisPrime)) / 2
}
}
}
}
}
- return colorAll
+ return hue
}
}
-export const exportModule = new VisualizerExportModule(
- NumberGlyph,
- 'Map entries to colorful glyphs using their magnitudes and prime factors'
-)
+export const exportModule = new VisualizerExportModule(NumberGlyph)
/** md
@@ -415,37 +376,37 @@ Click on any image to expand it.
###### The Positive Integers
-[ ](../assets/img/glyph/integers.png)
-[ ](../assets/img/glyph/ring1.png)
-First, the non-negative integers, with
-the default settings. Next, with growth function taken
-modulo 1 (instead of the default 25) and Brightness Adjustment set
-to 1. The second example shows some interesting
+First, the non-negative integers, with
+the default settings. Next, with growth function taken
+modulo 1 (instead of the default 25) and Brightness Adjustment set
+to 1. The second example shows some interesting
effects because the rings
-occur more rapidly than once per pixel.
+occur more rapidly than once per pixel.
###### The semi-primes
-[ ](../assets/img/glyph/semiprimes.png)
-This image shows the semi-primes
-([A001358](https://oeis.org/A001358)). In this example, 121
+This image shows the semi-primes
+([A001358](https://oeis.org/A001358)). In this example, 121
elements are shown.
###### Another growth function
-[ ](../assets/img/glyph/diff-func.png)
-This shows the integers under the growth
+This shows the integers under the growth
function \( 25(1-\cos(nx)) \) modulo 25.
## Credit
diff --git a/src/visualizers/P5GLVisualizer.ts b/src/visualizers/P5GLVisualizer.ts
new file mode 100644
index 00000000..09f3a189
--- /dev/null
+++ b/src/visualizers/P5GLVisualizer.ts
@@ -0,0 +1,141 @@
+import p5 from 'p5'
+import {markRaw} from 'vue'
+
+import {P5Visualizer} from './P5Visualizer'
+import type {P5VizInterface} from './P5Visualizer'
+
+import type {SequenceInterface} from '@/sequences/SequenceInterface'
+import type {GenericParamDescription, ParamValues} from '@/shared/Paramable'
+
+// Base class for implementing Visualizers that use p5.js in WebGL mode
+
+export function P5GLVisualizer(desc: PD) {
+ const P5GLVisualizer = class extends (P5Visualizer(
+ desc
+ ) as unknown as new (
+ seq: SequenceInterface
+ ) => ReturnType> & P5VizInterface) {
+ name = 'uninitialized P5-based WebGL visualizer'
+ camera: p5.Camera | undefined = undefined
+ lastMX: number | undefined = undefined
+ lastMY: number | undefined = undefined
+ initialCameraZ = 800
+
+ // Have to reassign name as category because of JavaScript default
+ // initialization order rules
+ constructor(seq: SequenceInterface) {
+ super(seq)
+ this.name = this.category
+ }
+
+ usesGL() {
+ return true
+ }
+
+ // Just like P5Visualizer, but use WebGL renderer, load the brush,
+ // and create a camera.
+ // However we override rather than extend so there is only one call
+ // to createCanvas.
+ // Note that derived Visualizers _must_ call this first.
+ setup() {
+ this._canvas = this.sketch
+ .background('white')
+ .createCanvas(
+ this.size.width,
+ this.size.height,
+ this.sketch.WEBGL
+ )
+ this.camera = markRaw(this.sketch.createCamera())
+ this.initialCameraZ = this.camera.eyeZ
+ }
+
+ // returns the coordinates and scaling of an absolute viewport position
+ // vX, vY transformed into the plot-plane coordinates, as a
+ // {pX: number, pY: number, scale: number} object:
+ canvasToPlot(vX: number, vY: number) {
+ const cameraXoff = this.camera?.eyeX ?? 0
+ const cameraYoff = this.camera?.eyeY ?? 0
+ const cameraZ = this.camera?.eyeZ ?? this.initialCameraZ
+ const scale = cameraZ / this.initialCameraZ
+ // Center of the field of view:
+ const cX = this.sketch.width / 2 + cameraXoff
+ const cY = this.sketch.height / 2 + cameraYoff
+ // Translate of the viewport position:
+ let pX = vX + cameraXoff
+ let pY = vY + cameraYoff
+ // Dilate from center:
+ pX = (pX - cX) * scale + cX
+ pY = (pY - cY) * scale + cY
+ return {pX, pY, scale}
+ }
+
+ // returns the current mouse position in plot-plane coordinates, as a
+ // {pX: number, pY: number, scale: number} object:
+ mouseToPlot() {
+ return this.canvasToPlot(this.sketch.mouseX, this.sketch.mouseY)
+ }
+
+ mousePressed() {
+ // Remember where the mouse was pressed
+ this.lastMX = this.sketch.mouseX
+ this.lastMY = this.sketch.mouseY
+ // And restart the animation in case there's a move
+ this.continue()
+ }
+
+ mouseReleased() {
+ // clear the mousepress info so we don't then respond to stray
+ // clicks elsewhere on the screen
+ this.lastMX = undefined
+ this.lastMY = undefined
+ }
+
+ // Call this at the top of your draw() function to handle
+ // left drag pan, right drag rotate.
+ // NOTE: returns true if the camera moved
+ handleDrags() {
+ const sketch = this.sketch
+ if (
+ !sketch.mouseIsPressed
+ || !this.camera
+ || this.lastMX === undefined
+ || this.lastMY === undefined
+ ) {
+ return false
+ }
+ const {pX: lX, pY: lY} = this.canvasToPlot(
+ this.lastMX,
+ this.lastMY
+ )
+ const {pX, pY} = this.mouseToPlot()
+ this.lastMX = sketch.mouseX
+ this.lastMY = sketch.mouseY
+ if (sketch.mouseButton === 'left') {
+ if (lX === pX && lY === pY) return false
+ this.camera.move(lX - pX, lY - pY, 0)
+ return true
+ }
+ if (sketch.mouseButton === 'right') {
+ const rotateSpeed = 0.002
+ if (lX === pX) return false
+ // @ts-expect-error The @types/p5 package omits roll
+ this.camera.roll((lX - pX) * rotateSpeed)
+ return true
+ }
+ return false
+ }
+
+ // Provides default mouse wheel behavior: zoom
+ mouseWheel(event: WheelEvent) {
+ if (this.camera) {
+ this.camera.move(0, 0, event.deltaY / 10)
+ this.continue()
+ }
+ }
+ }
+
+ type P5GLVisInstance = InstanceType
+ return P5GLVisualizer as unknown as new (
+ seq: SequenceInterface
+ ) => P5GLVisInstance & P5VizInterface & ParamValues
+}
diff --git a/src/visualizers/P5Visualizer.ts b/src/visualizers/P5Visualizer.ts
index c3664c7b..5759e271 100644
--- a/src/visualizers/P5Visualizer.ts
+++ b/src/visualizers/P5Visualizer.ts
@@ -1,8 +1,22 @@
-import type {VisualizerInterface} from './VisualizerInterface'
-import {Paramable} from '../shared/Paramable'
-import type {SequenceInterface} from '../sequences/SequenceInterface'
-import {CachingError} from '../sequences/Cached'
import p5 from 'p5'
+import {markRaw} from 'vue'
+
+import {
+ DrawingUnmounted,
+ Drawing,
+ DrawingStopped,
+ nullSize,
+} from './VisualizerInterface'
+import type {
+ DrawingState,
+ ViewSize,
+ VisualizerInterface,
+} from './VisualizerInterface'
+
+import {CachingError} from '@/sequences/Cached'
+import type {SequenceInterface} from '@/sequences/SequenceInterface'
+import {Paramable} from '@/shared/Paramable'
+import type {GenericParamDescription, ParamValues} from '@/shared/Paramable'
// Ugh, the gyrations we go through to keep TypeScript happy
// while only listing the p5 methods once:
@@ -11,244 +25,443 @@ class WithP5 extends Paramable {
deviceMoved() {}
deviceShaken() {}
deviceTurned() {}
- doubleClicked() {}
- keyPressed() {}
- keyReleased() {}
- keyTyped() {}
- mouseClicked() {}
- mouseDragged() {}
- mouseMoved() {}
- mousePressed() {}
+ doubleClicked(_event: MouseEvent) {}
+ keyPressed(_event: KeyboardEvent) {}
+ keyReleased(_event: KeyboardEvent) {}
+ keyTyped(_event: KeyboardEvent) {}
+ mouseClicked(_event: MouseEvent) {}
+ mouseDragged(_event: MouseEvent) {}
+ mouseMoved(_event: MouseEvent) {}
+ mousePressed(_event: MouseEvent) {}
mouseReleased() {}
- mouseWheel() {}
- setup() {}
+ mouseWheel(_event: WheelEvent) {}
touchEnded() {}
touchMoved() {}
touchStarted() {}
windowResized() {}
+ setup() {}
}
+// The following is used to check if a visualizer has defined
+// any of the above methods:
type P5Methods = Exclude
-const dummyWithP5 = new WithP5()
+const dummyWithP5 = new WithP5({})
const p5methods: P5Methods[] = Object.getOwnPropertyNames(
Object.getPrototypeOf(dummyWithP5)
).filter(name => name != 'constructor') as P5Methods[]
+/* A convenience HACK so that visualizer writers can initialize
+ p5 color properties without a sketch. Don't try to draw with this!
+ TODO: replace with a safe-to-use black or white, or at least an
+ object that throws an understandable instead of cryptic error
+ if p5 tries to draw with it, e.g.
+ `Attempt for p5 to use INVALID_COLOR; please initialize your
+ color from a sketch object.`
+*/
+export const INVALID_COLOR = {} as p5.Color
+
+/* Flag to force a call to presketch in a reset() call: */
+export const P5ForcePresketch = true
+
+export interface P5VizInterface extends VisualizerInterface, WithP5 {
+ _sketch?: p5
+ _canvas?: p5.Renderer
+ _framesRemaining: number
+ size: ViewSize
+ drawingState: DrawingState
+ readonly sketch: p5
+ readonly canvas: p5.Renderer
+ seq: SequenceInterface
+ _initializeSketch(): (sketch: p5) => void
+ presketch(size: ViewSize): Promise
+ hatchRect(x: number, y: number, w: number, h: number): void
+ reset(): Promise
+}
+
// Base class for implementing Visualizers that use p5.js
-export abstract class P5Visualizer
- extends WithP5
- implements VisualizerInterface
-{
- private _sketch?: p5
- private _canvas?: p5.Renderer
-
- /* In the P5Visualizer hierarchy, the visualization() string of the
- * visualizer is supplied by a static member called `visualizationName`.
- */
- static visualizationName = 'abstract P5-based Visualizer'
-
- /* A convenience HACK so that visualizer writers can initialize
- p5 color properties without a sketch. Don't try to draw with this!
- TODO: replace with a safe-to-use black or white, or at least an
- object that throws an understandable instead of cryptic error
- if p5 tries to draw with it, e.g.
- `Attempt for p5 to use INVALID_COLOR; please initialize your
- color from a sketch object.`
- */
- static INVALID_COLOR = {} as p5.Color
-
- visualization(): string {
- return (
- Object.getPrototypeOf(this).constructor.visualizationName
- || 'monkeypod'
- )
- }
- within?: HTMLElement
- get sketch(): p5 {
- if (!this._sketch) {
- throw 'Attempt to access p5 sketch while Visualizer is unmounted.'
+export function P5Visualizer(desc: PD) {
+ const defaultObject = Object.fromEntries(
+ Object.keys(desc).map(param => [param, desc[param].default])
+ )
+ const P5Visualizer = class extends WithP5 implements P5VizInterface {
+ name = 'uninitialized P5-based visualizer'
+ _sketch?: p5
+ _canvas?: p5.Renderer
+ _framesRemaining = Infinity
+ size = nullSize
+ drawingState: DrawingState = DrawingUnmounted
+
+ within?: HTMLElement
+ usesGL() {
+ return false
}
- return this._sketch
- }
- get canvas(): p5.Renderer {
- if (!this._canvas) {
- throw 'Attempt to access canvas while Visualizer is unmounted.'
+ get sketch(): p5 {
+ if (this._sketch === undefined) {
+ throw (
+ 'Attempt to access p5 sketch '
+ + 'while Visualizer is unmounted.'
+ )
+ }
+ return this._sketch
}
- return this._canvas
- }
- seq: SequenceInterface
-
- /***
- * Create a P5-based visualizer
- * @param seq SequenceInterface The initial sequence to draw
- */
- constructor(seq: SequenceInterface) {
- super()
- this.seq = seq
- }
+ get canvas(): p5.Renderer {
+ if (!this._canvas) {
+ throw 'Attempt to access canvas while Visualizer is unmounted.'
+ }
+ return this._canvas
+ }
+ seq: SequenceInterface
- /***
- * Places the sketch into the given HTML element, and prepares to draw.
- * This has to create the sketch and generate the methods it needs. In
- * p5-based visualizers, we presume that initialization will generally
- * take place in the standard p5 setup() method, so only the rare
- * visualizer that needs to interact with the DOM or affect the
- * of the p5 object itself would need to implement an extended or replaced
- * inhabit() method.
- * @param element HTMLElement Where the visualizer should inject itself
- */
- inhabit(element: HTMLElement): void {
- if (this.within === element) return // already inhabiting there
- if (this.within) {
- // oops, already inhabiting somewhere else; depart there
- this.depart(this.within)
- }
- if (!this.isValid) {
- throw (
- 'The visualizer is not valid. '
- + 'Run validate and address any errors.'
- )
+ /***
+ * Create a P5-based visualizer
+ * @param seq SequenceInterface The initial sequence to draw
+ */
+ constructor(seq: SequenceInterface) {
+ super(desc)
+ this.name = this.category // Not currently using per-instance names
+ this.seq = seq
+ Object.assign(this, defaultObject)
}
- this.within = element
- this._sketch = new p5(sketch => {
- this._sketch = sketch // must assign here, as setup is called
- // before the `new p5` returns; I think that makes the outer
- // (re-) assigning of that result to this._sketch redundant, but
- // I also think it makes this code a bit clearer than if inhabit()
- // simply calls `new p5(...)` and discards the result, so I left
- // that outer reassignment there.
-
- // Now, a little bit of gymnastics to set all of the p5 methods
- // in the sketch that exist on the Visualizser. Since TypeScript
- // seems to require that they be methods in this base class, I
- // couldn't find a way to arrange the code so that the below could
- // be simplified to just check whether or not `this[method]` is
- // defined or undefined:
- for (const method of p5methods) {
- const definition = this[method]
- const dummyText = method + '(){}'
- const defText = definition.toString().replace(/\s/g, '')
- if (defText !== dummyText) {
- sketch[method] = definition.bind(this)
+
+ /***
+ * Supplies the argument to the p5 constructor that initializes a
+ * new sketch object, supplying its methods, etc. That is to say,
+ * the arrow function this method returns will simply be passed to
+ * `new p5(__, element)` when the visualizer `inhabit`s an element.
+ *
+ * @returns {(sketch: p5) => void} The sketch initializer function
+ */
+ _initializeSketch(): (sketch: p5) => void {
+ return sketch => {
+ // The following assignment is necessary even though it
+ // may seem a bit redundant with the assignment in inhabit()
+ // below -- that's because this.sketch is often used in
+ // setup(). Also, the call to markRaw is needed so that Vue's
+ // reactivity API does not alter behavior inside of p5 (and
+ // I don't think we need Vue to re-render when a sketch
+ // changes anyway; the sketch does that itself).
+ this._sketch = markRaw(sketch)
+
+ // Now, a little bit of gymnastics to set all of the p5 methods
+ // in the sketch that exist on the Visualizer. Since TypeScript
+ // seems to require that they be methods in this base class,
+ // I couldn't find a way to arrange the code so that the below
+ // could be simplified to just check whether or not
+ // `this[method]` is defined or undefined:
+ for (const method of p5methods) {
+ const definition = this[method]
+ if (definition !== dummyWithP5[method]) {
+ if (
+ method === 'mousePressed'
+ || method === 'mouseClicked'
+ || method === 'mouseWheel'
+ ) {
+ sketch[method] = (event: MouseEvent) => {
+ if (!this.within) return true
+ // Check that the event position is in bounds
+ const rect =
+ this.within.getBoundingClientRect()
+ const x = event.clientX
+ if (x < rect.left || x >= rect.right) {
+ return true
+ }
+ const y = event.clientY
+ if (y < rect.top || y >= rect.bottom) {
+ return true
+ }
+ // Check also that the event element is OK
+ const where = document.elementFromPoint(x, y)
+ if (!where) return true
+ if (
+ where !== this.within
+ && !where.contains(this.within)
+ ) {
+ return true
+ }
+ return this[method](event as never)
+ // Cast makes typescript happy :-/
+ }
+ continue
+ }
+ if (
+ method === 'keyPressed'
+ || method === 'keyReleased'
+ || method === 'keyTyped'
+ ) {
+ sketch[method] = (event: KeyboardEvent) => {
+ const active = document.activeElement
+ if (active && active.tagName === 'INPUT') {
+ return true
+ }
+ return this[method](event)
+ }
+ continue
+ }
+ // Otherwise no special condition, just forward event
+ sketch[method] = definition.bind(this) as () => void
+ }
}
- }
- // And draw is special because of the error handling, so we
- // treat it separately.
- sketch.draw = () => {
- try {
- this.draw()
- } catch (e) {
- if (e instanceof CachingError) {
- sketch.cursor('progress')
- return
- } else {
- throw e
+
+ // And draw is special because of the error handling:
+ sketch.draw = () => {
+ try {
+ this.draw()
+ if (--this._framesRemaining <= 0) {
+ this.stop(0)
+ }
+ } catch (e) {
+ if (e instanceof CachingError) {
+ sketch.cursor('progress')
+ return
+ } else {
+ // TODO: This throw is typically going to go
+ // uncaught (because there is a try..finally
+ // block in p5.js with no catch) and so
+ // just result in a console error message.
+ // Should we pop up an alert message?
+ throw e
+ }
+ }
+ if (this.within) {
+ sketch.cursor(
+ getComputedStyle(this.within).cursor || 'default'
+ )
}
}
- sketch.cursor(sketch.ARROW)
}
- }, element)
- }
+ }
- /**
- * Change the sequence being shown by the visualizer
- * @param seq SequenceInterface The sequence to show
- */
- view(seq: SequenceInterface): void {
- this.seq = seq
- }
+ /***
+ * Places the sketch into the given HTML element, and prepares to draw.
+ * This has to create the sketch and generate the methods it needs. In
+ * p5-based visualizers, we presume that initialization will generally
+ * take place in the standard p5 setup() method, so only the rare
+ * visualizer that needs to interact with the DOM or affect the
+ * of the p5 object itself would need to implement an extended or
+ * replaced inhabit() method.
+ * @param {HTMLElement} element
+ * Where the visualizer should inject itself
+ * @param {ViewSize} size
+ * The width and height the visualizer should occupy
+ */
+ async inhabit(element: HTMLElement, size: ViewSize) {
+ let needsPresketch = true
+ if (this.within) {
+ // oops, already inhabiting somewhere else; depart there
+ this.depart(this.within)
+ // Only do the presketch initialization if the size has
+ // changed, though:
+ needsPresketch =
+ size.width !== this.size.width
+ || size.height !== this.size.height
+ }
+ this.size = size
+ this.within = element
+ // Perform any necessary asynchronous preparation before
+ // creating sketch. For example, some Visualizers need sequence
+ // factorizations in setup().
+ if (needsPresketch) await this.presketch(size)
+ // TODO: Can presketch() sometimes take so long that we should
+ // show an hourglass icon in the meantime, or something like that?
- /**
- * Display the visualizer within the element that it is inhabiting.
- * All it has to do is call draw, since p5 calls setup for us.
- */
- show(): void {
- this._sketch?.draw()
- }
+ // Now we can create the sketch
+ this._sketch = new p5(this._initializeSketch(), element)
+ }
- /**
- * Stop displaying the visualizer
- */
- stop(): void {
- this._sketch?.noLoop()
- }
+ /**
+ * Extend this default presketch() function if you have other
+ * things that must happen asynchronously before a p5 visualizer
+ * can create its sketch.
+ */
+ async presketch(_size: ViewSize) {
+ await this.seq.fill()
+ }
- /**
- * Determining the maximum pixel width and height the containing
- * element allows.
- * @returns [number, number] Maximum width and height of inhabited element
- */
- measure(): [number, number] {
- if (!this.within) return [0, 0]
- return [this.within.clientWidth, this.within.clientHeight]
- }
+ /**
+ * Change the sequence being shown by the visualizer
+ * @param seq SequenceInterface The sequence to show
+ */
+ async view(seq: SequenceInterface) {
+ this.seq = seq
+ await seq.fill()
+ this.validationStatus = this.checkParameters(
+ this as unknown as ParamValues
+ )
+ if (this.validationStatus.isValid()) this.reset(P5ForcePresketch)
+ }
- /**
- * The p5 setup for this visualizer. Note that derived Visualizers
- * _must_ call this first.
- */
- setup() {
- const [w, h] = this.measure()
- this._canvas = this.sketch.background('white').createCanvas(w, h)
- }
+ /**
+ * Display the visualizer within the element that it is inhabiting.
+ * All it has to do is call draw, since p5 calls setup for us.
+ */
+ show(): void {
+ // If not inhabiting an element, do nothing
+ if (!this._sketch) return
+ // In the event that the rendering context isn't ready, this value
+ // represents how long in milliseconds we should wait before trying
+ // again
+ const displayTimeout = 5
- /**
- * The p5 drawing function. This must be implemented by derived classes
- * that actually wish to serve as Visualizers. It should use p5 methods
- * on this.sketch to create the desired image connected with the
- * associated sequence/parameters.
- */
- abstract draw(): void
-
- /**
- * What to do when the window resizes
- */
- windowResized(): void {
- if (!this._sketch) return
- // Make sure the canvas isn't acting as a "strut" keeping the div big:
- this._sketch.resizeCanvas(10, 10)
- const [w, h] = this.measure()
- this._sketch.resizeCanvas(w, h)
- }
+ if (this._canvas) {
+ this.drawingState = Drawing
+ this._sketch.loop()
+ this._sketch.draw()
+ } else {
+ // If the rendering context is not yet ready, start an interval
+ // that waits until the canvas is ready and shows when finished
+ const interval = setInterval(() => {
+ if (this._canvas) {
+ clearInterval(interval)
+ this.drawingState = Drawing
+ this._sketch?.draw()
+ }
+ }, displayTimeout)
+ }
+ }
- /**
- * Get rid of the visualization altogether
- */
- depart(element: HTMLElement): void {
- if (!this._sketch || !this.within) {
- throw 'Attempt to dispose P5Visualizer that is not on view.'
- }
- if (this.within !== element) return // that view already departed
- this._sketch.remove()
- this._sketch = undefined
- this._canvas = undefined
- this.within = undefined
- }
- /* Further remarks on realizing visualizers in the DOM with inhabit()
- and depart():
-
- If an HTML element (usually a div) gets removed from the DOM while
- a visualizer is still inhabiting it, the visualizer becomes a
- troublesome "ghost": the user can't see or interact with it, but
- it's still listening for events and consuming system resources.
- There are two ways to prevent this:
-
- 1. Immediately tell the visualizer to depart the div you're removing.
-
- 2. Immediately tell the visualizer to inhabit a div that is still in
- the DOM.
-
- It's safe for these calls to happen redundantly. If you tell a
- visualizer to depart a div that it's not inhabiting, or to inhabit
- a div that it's already inhabiting, nothing will happen.
- */
-
- /* By default, a P5 visualizer returns undefined from this function,
- * meaning it will not request an aspect ratio and instead be given a
- * canvas of any arbitrary width and height. Visualizers can override
- * this to request a specific aspect ratio.
- */
- requestedAspectRatio(): number | undefined {
- return undefined
+ /**
+ * Stop displaying the visualizer
+ */
+ stop(max: number = 0): void {
+ if (max <= 0) {
+ // hard stop now
+ this.drawingState = DrawingStopped
+ this._sketch?.noLoop()
+ } else if (max < this._framesRemaining) {
+ this._framesRemaining = max
+ }
+ }
+
+ /**
+ * Continue displaying the visualizer
+ */
+ continue(): void {
+ this.drawingState = Drawing
+ this._framesRemaining = Infinity
+ this._sketch?.loop()
+ }
+
+ /**
+ * The p5 setup for this visualizer. Note that derived Visualizers
+ * _must_ call this first.
+ */
+ setup() {
+ this._canvas = this.sketch
+ .background('white')
+ .createCanvas(this.size.width, this.size.height)
+ }
+
+ /**
+ * The p5 drawing function. This must be implemented by derived classes
+ * that actually wish to serve as Visualizers. It should use p5 methods
+ * on this.sketch to create the desired image connected with the
+ * associated sequence/parameters.
+ *
+ * Note that because this is an anonymous class, methods can't directly
+ * be abstract. However, visualizer writers are still expected to
+ * implement this function.
+ */
+ draw(): void {}
+
+ /**
+ * Utility for drawing a hatched rectangle. Takes the same arguments
+ * as p5 `rect()` in mode CORNER. Does nothing if there is no sketch.
+ * @param {number} x coordinate of corner
+ * @param {number} y coordinate of corner
+ * @param {number} w width of rectangle
+ * @param {number} h height of rectangle
+ */
+ hatchRect(x: number, y: number, w: number, h: number): void {
+ const sketch = this.sketch
+ if (!sketch) return
+ sketch.push()
+ sketch.rectMode(sketch.CORNER)
+ sketch.rect(x, y, w, h)
+ const xdir = Math.sign(w)
+ w = w * xdir
+ const ydir = Math.sign(h)
+ h = h * ydir
+ const dist = w + h
+ const step = 12
+ for (let place = step; place < dist; place += step) {
+ const sx = x + xdir * Math.min(place, w)
+ const sy = y + ydir * Math.max(0, place - w)
+ const ex = x + xdir * Math.max(0, place - h)
+ const ey = y + ydir * Math.min(place, h)
+ sketch.line(sx, sy, ex, ey)
+ }
+ sketch.pop()
+ }
+
+ /**
+ * Get rid of the visualization altogether
+ */
+ depart(element: HTMLElement): void {
+ if (!this._sketch || !this.within) {
+ throw 'Attempt to dispose P5Visualizer that is not on view.'
+ }
+ if (this.within !== element) return // that view already departed
+ this.drawingState = DrawingUnmounted
+ this._sketch.remove()
+ this._sketch = undefined
+ this._canvas = undefined
+ this.within = undefined
+ }
+ /* Further remarks on realizing visualizers in the DOM with inhabit()
+ and depart():
+
+ If an HTML element (usually a div) gets removed from the DOM while
+ a visualizer is still inhabiting it, the visualizer becomes a
+ troublesome "ghost": the user can't see or interact with it, but
+ it's still listening for events and consuming system resources.
+ There are two ways to prevent this:
+
+ 1. Immediately tell the visualizer to depart the div you're removing
+
+ 2. Immediately tell the visualizer to inhabit a div that is still in
+ the DOM.
+
+ It's safe for these calls to happen redundantly. If you tell a
+ visualizer to depart a div that it's not inhabiting, or to inhabit
+ a div that it's already inhabiting, nothing will happen.
+ */
+
+ async parametersChanged(name: string[]) {
+ await super.parametersChanged(name)
+ await this.reset()
+ }
+
+ /* By default, a P5 visualizer returns undefined from this function,
+ * meaning it will not request an aspect ratio and instead be given a
+ * canvas of any arbitrary width and height. Visualizers can override
+ * this to request a specific aspect ratio.
+ */
+ requestedAspectRatio(): number | undefined {
+ return undefined
+ }
+
+ /* If the visualizer is currently being displayed, we reset the display
+ * by stopping the visualizer, re-inhabiting the element, and showing
+ * it again. In other words, a hard reset. If a visualizer wishes to
+ * have any of its internal state be reset during a hard reset event,
+ * it should override this function.
+ * @param {boolean} forcePresketch
+ * if true, ensures presketch initialization will also be redone;
+ * defaults to false. Call with constant P5ForcePresketch for
+ * readability of calling code.
+ */
+ async reset(forcePresketch: boolean = false) {
+ if (!this._sketch) return
+ const element = this.within!
+ this.stop()
+ if (forcePresketch) this.depart(element)
+ await this.inhabit(element, this.size)
+ this.show()
+ }
}
+
+ type P5VisInstance = InstanceType
+ return P5Visualizer as unknown as new (
+ seq: SequenceInterface
+ ) => P5VisInstance & P5VizInterface & ParamValues
}
diff --git a/src/visualizers/SelfSimilarity.ts b/src/visualizers/SelfSimilarity.ts
new file mode 100644
index 00000000..fbbcfd29
--- /dev/null
+++ b/src/visualizers/SelfSimilarity.ts
@@ -0,0 +1,481 @@
+import {P5Visualizer, INVALID_COLOR} from './P5Visualizer'
+import {VisualizerExportModule} from './VisualizerInterface'
+import type {ViewSize} from './VisualizerInterface'
+
+import {math, MathFormula} from '@/shared/math'
+import type {GenericParamDescription} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
+import {ValidationStatus} from '@/shared/ValidationStatus'
+
+/** md
+# Self Similarity Visualizer
+
+[ ](../assets/img/ModFill/PrimeResidues.png)
+[ ](../assets/img/ModFill/DanceNo73.png)
+[ ](../assets/img/ModFill/OEISA070826.png)
+
+The n-th position in the m-th row of the diagram represents
+the distance between a(n) and a(f(n,m))),
+where f is a function of the user's choice, and
+the notion of distance can be specified from amongst options.
+
+The default example of distance is d(x,y) = gcd(x,y),
+and the default comparison function f is f(n,m) = m. This
+shows the gcd between a(n) and a(m), i.e. a grid showing
+pairwise gcd's between terms of the sequence.
+
+## Parameters
+**/
+
+enum DistanceType {
+ Absolute_Difference,
+ Modular_Difference,
+ GCD,
+ Difference_of_Valuation,
+ Valuation_of_Difference,
+}
+
+enum StarType {
+ Circle,
+ Rectangle,
+}
+
+const paramDesc = {
+ /** md
+- width: The number of columns to display, which corresponds to the
+length of the subsequence we are considering.
+ **/
+ // note will be small enough to fit in a `number` when we need it to.
+ width: {
+ default: 150n,
+ type: ParamType.BIGINT,
+ displayName: 'Width',
+ required: true,
+ validate: function (n: number, status: ValidationStatus) {
+ if (n <= 0) status.addError('Must be positive.')
+ },
+ },
+ /** md
+- shiftFormula: The formula f(n,m) used to determine the index of the term at
+position (row, column) = (m,n) which will be compared to a(n). In other
+words, that position will compare a(f(n,m)) with a(n). Default:
+**/
+ shiftFormula: {
+ default: new MathFormula(
+ // Note: The markdown comment closed with */ means to include code
+ // into the docs, until mkdocs reaches a comment ending with **/
+ /** md */
+ 'm',
+ /* **/
+ ['n', 'm']
+ ),
+ type: ParamType.FORMULA,
+ inputs: ['n', 'm'],
+ displayName: 'Transformation Formula (what we compare)',
+ description:
+ "A function in 'n' (column) and 'm' (row); "
+ + 'this determines the index of the term which '
+ + 'in position (m,n) will be compared to a(n). '
+ + "Example: 'm' will compare a(n) to a(m).",
+ visibleValue: true,
+ required: false,
+ },
+
+ /** md
+### Distance function: How to measure the notion of "similarity."
+
+- Modular Difference: smallest distance between residues modulo
+a specified modulus (i.e. shortest path around the mod `clock').
+- Absolute Difference: absolute value of distance between terms
+(brighter = closer).
+- GCD: gcd of terms (brighter = relatively larger).
+- Difference of Valuation: take valuations of the two terms,
+return difference (brighter = closer). The valuation
+of an integer with respect to a divisor is the number
+of times the divisor divides the integer.
+- Valuation of Difference: take valuation of difference
+(brighter = larger valuation)
+ **/
+ distance: {
+ default: DistanceType.Modular_Difference,
+ type: ParamType.ENUM,
+ from: DistanceType,
+ displayName: 'Distance function (how we compare)',
+ required: true,
+ },
+ /** md
+- divisor: for use in valuation-based differences.
+ **/
+ divisor: {
+ default: 2n,
+ type: ParamType.BIGINT,
+ displayName: 'Divisor for valuation',
+ required: true,
+ validate: function (n: number, status: ValidationStatus) {
+ if (n <= 1) status.addError('Must exceed one.')
+ },
+ visibleDependency: 'distance',
+ visiblePredicate: (dependentValue: DistanceType) =>
+ dependentValue === DistanceType.Difference_of_Valuation
+ || dependentValue === DistanceType.Valuation_of_Difference
+ ? true
+ : false,
+ },
+ /** md
+- modulus: for use in modular distance.
+ **/
+ modulus: {
+ default: 30n,
+ type: ParamType.BIGINT,
+ displayName: 'Modulus',
+ required: true,
+ validate: function (n: number, status: ValidationStatus) {
+ if (n <= 0) status.addError('Must be positive.')
+ },
+ visibleDependency: 'distance',
+ visiblePredicate: (dependentValue: DistanceType) =>
+ dependentValue === DistanceType.Modular_Difference ? true : false,
+ },
+ /** md
+- backgroundColor: The color used for the background.
+ **/
+ backgroundColor: {
+ default: '#000000',
+ type: ParamType.COLOR,
+ displayName: 'Background color',
+ required: true,
+ visibleValue: true,
+ },
+ /** md
+- Fill color: The color used to fill each cell by default.
+ **/
+ fillColor: {
+ default: '#CFAF24',
+ type: ParamType.COLOR,
+ displayName: 'Fill color',
+ required: true,
+ visibleValue: true,
+ },
+ /** md
+- Opacity Control: override in-built opacity function.
+ **/
+ opacityControl: {
+ default: false,
+ type: ParamType.BOOLEAN,
+ displayName: 'Opacity control',
+ required: true,
+ },
+ /** md
+- Opacity Adjustment: this is a function of s and t (the two terms
+being compared) and d (the distance between them) to opacity
+(between 0 = transparent and 1 = opaque). This can be used
+to define your own distance function in terms of s and t, for
+example, try `isPrime(s^2+t^2)` to see which pairs of terms
+have a prime sum of squares. Default:
+ **/
+ opacityFormula: {
+ default: new MathFormula(
+ /** md */
+ `(d % 255)/255`
+ /* **/
+ ),
+ type: ParamType.FORMULA,
+ displayName: 'Opacity Adjustment',
+ inputs: ['s', 't', 'd'],
+ description:
+ "This function of the two terms ('s' and 't') "
+ + "and the distance 'd', giving an output between 0 "
+ + '(transparent) and 1 (opaque).',
+ required: true,
+ visibleDependency: 'opacityControl',
+ visibleValue: true,
+ },
+ /** md
+### Indicator shape: circle or rectangle
+ **/
+ star: {
+ default: StarType.Circle,
+ type: ParamType.ENUM,
+ from: StarType,
+ displayName: 'Indicator shape',
+ required: true,
+ },
+ /** md
+- Manual height control: override using square grid.
+ **/
+ heightControl: {
+ default: false,
+ type: ParamType.BOOLEAN,
+ displayName: 'Manual height control',
+ required: true,
+ },
+ /** md
+- height: The number of rows to display.
+ **/
+ // note will be small enough to fit in a `number` when we need it to.
+ height: {
+ default: 100n, // ideally, default to whatever is currently happening
+ type: ParamType.BIGINT,
+ displayName: 'Height',
+ required: true,
+ visibleDependency: 'heightControl',
+ visibleValue: true,
+ validate: function (n: number, status: ValidationStatus) {
+ if (n <= 0) status.addError('Must be positive.')
+ },
+ },
+} satisfies GenericParamDescription
+
+class SelfSimilarity extends P5Visualizer(paramDesc) {
+ static category = 'Self Similarity'
+ static description =
+ 'Successive rows compare the sequence to its translates,'
+ + ' dilations, or other transforms'
+
+ useHeight = 0
+ useWidth = 0
+ maxHeight = 0
+ maxWidth = 0
+ rectWidth = 0
+ rectHeight = 0
+ useMod = 0
+ useFillColor = INVALID_COLOR
+ useBackColor = INVALID_COLOR
+ setBack = false
+ gain = 3.07
+ i = 0
+
+ trySafeNumber(input: bigint) {
+ let use = 0
+ try {
+ use = math.safeNumber(input)
+ } catch {
+ // should we notify the user that we are stopping?
+ console.log('not safe', input)
+ this.stop()
+ return 0
+ }
+ return use
+ }
+
+ drawNew(position: number): boolean {
+ // we draw from left to right, top to bottom
+ const X = math.safeNumber(math.modulo(position, this.useWidth))
+ const Y = (position - X) / this.useWidth
+
+ // the two sequence elements to compare
+ const compareIndex = this.shiftFormula.compute(X, Y)
+ let s = 0n
+ let t = 0n
+ try {
+ s = this.seq.getElement(BigInt(X))
+ t = this.seq.getElement(BigInt(compareIndex))
+ } catch {
+ // don't draw if can't retrieve elements
+ return false
+ }
+
+ // difference and alpha computation
+ let alpha = 0
+ let diff = 0n
+ const termSize = BigInt(
+ math.bigmax(math.bigmin(math.bigabs(s), math.bigabs(t)), 1)
+ )
+ if (this.distance == DistanceType.Modular_Difference) {
+ const sResidue = BigInt(math.modulo(BigInt(s), this.modulus))
+ const tResidue = BigInt(math.modulo(BigInt(t), this.modulus))
+ let diffa = math.modulo(sResidue - tResidue, this.modulus)
+ if (2n * diffa > this.modulus) diffa -= this.modulus
+ diffa = math.bigabs(diffa)
+ let diffb = math.modulo(tResidue - sResidue, this.modulus)
+ if (2n * diffa > this.modulus) diffa -= this.modulus
+ diffb = math.bigabs(diffb)
+ diff = BigInt(math.bigmin(diffa, diffb))
+ if (!this.opacityControl) {
+ alpha = math.safeNumber((2n * 255n * diff) / this.modulus)
+ }
+ }
+ if (this.distance == DistanceType.Absolute_Difference) {
+ diff = BigInt(math.bigabs(s - t))
+ if (!this.opacityControl) {
+ alpha = math.safeNumber((255n * diff) / termSize)
+ }
+ }
+ if (this.distance == DistanceType.GCD) {
+ diff = BigInt(math.biggcd(s, t))
+ if (!this.opacityControl) {
+ const tryalpha = (255n * diff) / termSize
+ if (tryalpha < BigInt(Number.MAX_SAFE_INTEGER)) {
+ alpha = math.safeNumber(tryalpha)
+ } else {
+ alpha = 255
+ }
+ }
+ }
+ if (this.distance == DistanceType.Difference_of_Valuation) {
+ const diffs = math.valuation(s, this.divisor)
+ const difft = math.valuation(t, this.divisor)
+ const diffnum = math.abs(diffs - difft)
+ if (!isFinite(diffs) || !isFinite(difft)) {
+ alpha = 1
+ } else {
+ diff = BigInt(diffnum)
+ const denom = BigInt(
+ math.bigmax(
+ BigInt(math.valuation(termSize, this.divisor)),
+ 1
+ )
+ )
+ if (!this.opacityControl) {
+ alpha = math.safeNumber(
+ (255n * math.bigabs(diff)) / denom
+ )
+ }
+ }
+ }
+ if (this.distance == DistanceType.Valuation_of_Difference) {
+ const diffnum = math.valuation(math.bigabs(s - t), this.divisor)
+ if (!isFinite(diffnum)) {
+ alpha = 1
+ } else {
+ diff = BigInt(diffnum)
+ const denom = BigInt(
+ math.bigmax(
+ BigInt(math.valuation(termSize, this.divisor)),
+ 1
+ )
+ )
+ if (!this.opacityControl) {
+ alpha = math.safeNumber((255n * diff) / denom)
+ }
+ }
+ }
+
+ // manual opacity control
+ if (this.opacityControl) {
+ const vars = this.opacityFormula.freevars
+ let useS = 0
+ let useT = 0
+ let useD = 0
+ if (vars.includes('s')) useS = this.trySafeNumber(s)
+ if (vars.includes('t')) useT = this.trySafeNumber(t)
+ if (vars.includes('d')) useD = this.trySafeNumber(diff)
+ alpha = this.opacityFormula.compute(useS, useT, useD) * 255
+ }
+
+ // draw
+ this.useFillColor.setAlpha(alpha)
+ this.sketch.fill(this.useFillColor)
+ if (this.star == StarType.Circle) {
+ this.sketch.circle(
+ (X + 0.5) * this.rectWidth,
+ (Y + 0.5) * this.rectHeight,
+ Math.min(this.rectWidth, this.rectHeight)
+ )
+ }
+ if (this.star == StarType.Rectangle) {
+ this.sketch.rect(
+ X * this.rectWidth,
+ Y * this.rectHeight,
+ this.rectWidth,
+ this.rectHeight
+ )
+ }
+
+ return true
+ }
+
+ async presketch(size: ViewSize) {
+ await super.presketch(size)
+ const minWidth = size.width
+ const minHeight = size.height
+ // 16 was chosen as in ModFill
+ this.maxWidth = 16 * minWidth
+ this.maxHeight = 16 * minHeight
+ }
+
+ setup() {
+ console.log('startup')
+ super.setup()
+
+ // We need to check if the requested dimensions fit on screen,
+ // and adjust if not.
+
+ const dimensions = [
+ {
+ param: this.height,
+ max: this.maxHeight,
+ value: 0,
+ startWarn: 'Running with maximum height',
+ warnings: this.statusOf.height.warnings,
+ },
+ {
+ param: this.width,
+ max: this.maxWidth,
+ value: 0,
+ startWarn: 'Running with maximum width',
+ warnings: this.statusOf.width.warnings,
+ },
+ ]
+
+ dimensions.forEach(dimension => {
+ // First, remove any prior dimWarning that might be there
+ // (so they don't accumulate from repeated parameter changes):
+ const oldWarning = dimension.warnings.findIndex(warn =>
+ warn.startsWith(dimension.startWarn)
+ )
+ if (oldWarning >= 0) dimension.warnings.splice(oldWarning, 1)
+
+ // Now check the dimension and warn if need be:
+ // need to do the same with width
+ if (dimension.param > dimension.max) {
+ dimension.warnings.push(
+ `${dimension.warnings} ${dimension.max}, since `
+ + `${dimension.param} will not fit on screen.`
+ )
+ dimension.value = dimension.max
+ } else dimension.value = Number(dimension.param)
+ })
+
+ // Now we can calculate the cell size and set up to draw:
+ this.useWidth = dimensions[1].value
+ this.rectWidth = this.sketch.width / this.useWidth
+ if (this.heightControl) {
+ this.useHeight = dimensions[0].value
+ this.rectHeight = this.sketch.height / this.useHeight
+ } else {
+ this.rectHeight = this.rectWidth
+ this.useHeight = this.sketch.height / this.rectHeight
+ }
+ this.sketch.noStroke()
+ this.i = 0
+
+ // set fill color info
+ this.useFillColor = this.sketch.color(this.fillColor)
+ this.useBackColor = this.sketch.color(this.backgroundColor)
+
+ // set background
+ this.sketch.background(this.backgroundColor)
+ this.i = 0
+ console.log('setupdone')
+ }
+
+ draw() {
+ console.log('draw', this.i)
+ if (this.i > this.useHeight * this.useWidth) {
+ this.stop()
+ return
+ }
+ for (let j = 0; j < 500; j++) {
+ if (this.drawNew(this.i)) this.i++
+ }
+ }
+}
+
+export const exportModule = new VisualizerExportModule(SelfSimilarity)
diff --git a/src/visualizers/ShiftCompare.ts b/src/visualizers/ShiftCompare.ts
index 27a4ada3..e2c6cc20 100644
--- a/src/visualizers/ShiftCompare.ts
+++ b/src/visualizers/ShiftCompare.ts
@@ -1,7 +1,12 @@
import p5 from 'p5'
-import {modulo} from '../shared/math'
+
import {P5Visualizer} from './P5Visualizer'
-import {VisualizerExportModule} from '@/visualizers/VisualizerInterface'
+import {VisualizerExportModule} from './VisualizerInterface'
+
+import {math} from '@/shared/math'
+import type {GenericParamDescription} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
+import {ValidationStatus} from '@/shared/ValidationStatus'
/** md
# Shift Compare Visualizer
@@ -17,34 +22,32 @@ modulus). The pixel is colored black otherwise.
## Parameters
**/
-// CAUTION: This is unstable with some sequences
-// Using it may crash your browser
-class ShiftCompare extends P5Visualizer {
- static visualizationName = 'Shift Compare'
- private img: p5.Image = new p5.Image(1, 1) // just a dummy
- mod = 2n
- params = {
- /** md
+const paramDesc = {
+ /** md
- mod: The modulus to use when comparing entries.
- **/
- mod: {
- value: this.mod,
- displayName: 'Modulo',
- required: true,
- description: 'Modulus used to compare sequence elements',
+ **/
+ mod: {
+ default: 2n,
+ type: ParamType.BIGINT,
+ displayName: 'Modulus',
+ required: true,
+ description: 'Modulus used to compare sequence elements.',
+ validate(m: number, status: ValidationStatus) {
+ if (m <= 0n) status.addError('must be positive')
},
- }
+ },
+} satisfies GenericParamDescription
- checkParameters() {
- const status = super.checkParameters()
-
- if (this.params.mod.value <= 0n) {
- status.isValid = false
- status.errors.push('Modulo must be positive')
- }
+// CAUTION: This is unstable with some sequences
+// Using it may crash your browser
+class ShiftCompare extends P5Visualizer(paramDesc) {
+ static category = 'Shift Compare'
+ static description =
+ 'A grid showing pairwise congruence '
+ + 'of sequence entries, to some modulus'
- return status
- }
+ private img: p5.Image = new p5.Image(1, 1) // just a dummy
+ mod = 2n
setup() {
super.setup()
@@ -81,18 +84,20 @@ class ShiftCompare extends P5Visualizer {
}
// since settings.mod can be any of string | number | bool,
// assign it here explictly to a number, to avoid type errors
- const xLim = Math.min(sketch.width - 1, this.seq.last)
- const yLim = Math.min(sketch.height - 1, this.seq.last)
+ const xLim = math.bigmin(sketch.width - 1, this.seq.last)
+ const yLim = math.bigmin(sketch.height - 1, this.seq.last)
// Write to image, then to screen for speed.
for (let x = this.seq.first; x <= xLim; x++) {
- const xResidue = modulo(this.seq.getElement(x), this.mod)
+ const xResidue = math.modulo(this.seq.getElement(x), this.mod)
for (let y = this.seq.first; y <= yLim; y++) {
- const yResidue = modulo(this.seq.getElement(y), this.mod)
+ const yResidue = math.modulo(this.seq.getElement(y), this.mod)
for (let i = 0; i < d; i++) {
for (let j = 0; j < d; j++) {
const index =
- ((y * d + j) * sketch.width * d + (x * d + i)) * 4
+ ((Number(y) * d + j) * sketch.width * d
+ + (Number(x) * d + i))
+ * 4
if (xResidue == yResidue) {
this.img.pixels[index] = 255
this.img.pixels[index + 1] = 255
@@ -115,7 +120,4 @@ class ShiftCompare extends P5Visualizer {
}
}
-export const exportModule = new VisualizerExportModule(
- ShiftCompare,
- 'A grid showing pairwise congruence of sequence entries, to some modulus'
-)
+export const exportModule = new VisualizerExportModule(ShiftCompare)
diff --git a/src/visualizers/ShowFactors.ts b/src/visualizers/ShowFactors.ts
index 087a9ba6..4ded74af 100644
--- a/src/visualizers/ShowFactors.ts
+++ b/src/visualizers/ShowFactors.ts
@@ -1,5 +1,7 @@
-import {VisualizerExportModule} from '@/visualizers/VisualizerInterface'
import {P5Visualizer} from './P5Visualizer'
+import {VisualizerExportModule} from './VisualizerInterface'
+
+import {math} from '@/shared/math'
/** md
# Show Factors Visualizer
@@ -12,33 +14,9 @@ the sequence, and below each term, its prime factors.
## Parameters
**/
-class ShowFactors extends P5Visualizer {
- static visualizationName = 'Show Factors'
-
- start = 1
- end = 20
-
- params = {
- /** md
-- start: The index of the first entry to display
- **/
- start: {
- value: this.start,
- forceType: 'integer',
- displayName: 'First index to show',
- required: true,
- },
- /** md
-- end: The index of the last entry to display
- **/
- end: {
- value: this.end,
- forceType: 'integer',
- displayName: 'Last index to show',
- required: true,
- },
- }
- first = 0
+class ShowFactors extends P5Visualizer({}) {
+ static category = 'Show Factors'
+ static description = 'Produces a table of factors of a sequence'
draw() {
const sketch = this.sketch
@@ -53,17 +31,15 @@ class ShowFactors extends P5Visualizer {
const yDelta = 50
const firstX = 30
const firstY = 30
- let myColor = sketch.color(100, 255, 150)
- let hue
- for (
- let i = Math.max(this.start, this.seq.first);
- i <= Math.min(this.end, this.seq.last);
- i++
- ) {
- const xCoord = firstX + (i - this.start) * xDelta
- hue = ((i * 255) / 6) % 255
- myColor = sketch.color(hue, 150, 200)
+ const last =
+ typeof this.seq.last === 'bigint'
+ ? this.seq.last
+ : this.seq.first + 100n
+ for (let i = this.seq.first; i <= last; i++) {
+ const xCoord = firstX + Number(i - this.seq.first) * xDelta
+ const hue = Number(math.modulo((i * 255n) / 6n, 255))
+ const myColor = sketch.color(hue, 150, 200)
sketch
.fill(myColor)
.text(this.seq.getElement(i).toString(), xCoord, firstY)
@@ -81,11 +57,8 @@ class ShowFactors extends P5Visualizer {
}
}
}
- sketch.noLoop()
+ this.stop()
}
}
-export const exportModule = new VisualizerExportModule(
- ShowFactors,
- 'Produces a table of factors of a sequence'
-)
+export const exportModule = new VisualizerExportModule(ShowFactors)
diff --git a/src/visualizers/Turtle.ts b/src/visualizers/Turtle.ts
index 9d7adcd4..f3c64cfa 100644
--- a/src/visualizers/Turtle.ts
+++ b/src/visualizers/Turtle.ts
@@ -1,167 +1,627 @@
import p5 from 'p5'
-import {P5Visualizer} from './P5Visualizer'
-import {VisualizerExportModule} from '@/visualizers/VisualizerInterface'
+import {markRaw} from 'vue'
+
+import {P5GLVisualizer} from './P5GLVisualizer'
+import {VisualizerExportModule} from './VisualizerInterface'
+import type {ViewSize} from './VisualizerInterface'
+
+import {CachingError} from '@/sequences/Cached'
+import type {GenericParamDescription, ParamValues} from '@/shared/Paramable'
+import {ParamType} from '@/shared/ParamType'
+import {ValidationStatus} from '@/shared/ValidationStatus'
/** md
# Turtle Visualizer
-[image should go here]
+[ ](../assets/img/Turtle/turtle-waitforit.png)
This visualizer interprets a sequence as instructions for a drawing machine,
-with each entry determining what angle to turn before drawing the next
-straight segment. It displays the resulting polygonal path.
+often known as a "turtle" after a simple drawing robot built in the 1960s.
+For some domain of possible values, each entry in the sequence determines
+a turn angle and step length. The visualizer displays the resulting polygonal
+path.
+
+There are two ways to animate the resulting path:
+
+1. By setting the speed parameter to be positive, you can watch the path
+being drawn some number of steps per frame, as long as more terms of the
+sequence are available.
+
+2. By setting non-zero values for the fold and stretch parameters in the
+animation section, the user can set the turn angles and/or step lengths to
+gradually increase or decrease over time, resulting in a visual effect
+reminiscent of protein folding. In this mode, the same fixed number of steps
+of the path are re-drawn in every frame.
## Parameters
**/
-// Turtle needs work
-// Throwing the same error on previous Numberscope website
-class Turtle extends P5Visualizer {
- static visualizationName = 'Turtle'
- private rotMap = new Map()
- domain = [0n, 1n, 2n, 3n, 4n]
- range = [30, 45, 60, 90, 120]
- stepSize = 20
- start = new p5.Vector()
- strokeWeight = 5
- bgColor = '#666666'
- strokeColor = '#ff0000'
-
- params = {
- /** md
-- domain: A comma-separated list of all of the values that may occur in the
- sequence. (One way to ensure a small number of possible values is to use a
- sequence that has been reduced with respect to some small modulus. But
- some sequences, like A014577 "The regular paper-folding sequence", naturally
- have a small domain.)
- **/
- domain: {
- value: this.domain,
- displayName: 'Sequence Domain',
- required: true,
- description: '(comma-separated list of values)',
+const paramDesc = {
+ /** md
+- Domain: a list of numbers. These are the values that
+that the turtle should pay attention to when appearing as
+entries of the sequence. Values of the sequence
+not occurring in this list will be skipped.
+(One way to ensure a small number of possible values is to use a
+sequence that has been reduced with respect to some small modulus. But
+some sequences, like A014577 "The regular paper-folding sequence", naturally
+have a small domain.)
+ **/
+ domain: {
+ default: [0n, 1n, 2n],
+ type: ParamType.BIGINT_ARRAY,
+ displayName: 'Domain',
+ required: true,
+ description:
+ 'Sequence values to interpret as rules; entries not '
+ + 'matching any value here are skipped.',
+ hideDescription: false,
+ validate: function (dom: bigint[], status: ValidationStatus) {
+ const seen = new Set()
+ for (const element of dom) {
+ if (seen.has(element)) {
+ status.addError(
+ `elements may only occur once (${element} `
+ + 'is repeated.'
+ )
+ return
+ }
+ seen.add(element)
+ }
},
- /** md
-- range: a comma-separated list of numbers. These are turning angles,
- corresponding positionally to the domain elements. Range and domain must
- be the same length.
- **/
- range: {
- value: this.range,
- displayName: 'Angles',
- required: true,
- description: '(comma-separated list of values in degrees)',
- },
- /** md
-- stepSize: a number. Gives the length of the segment drawn for each entry.
- **/
- stepSize: {
- value: this.stepSize,
- forceType: 'integer',
- displayName: 'Step Size',
- required: true,
- },
- /**
-- start: x,y coordinates of the point where drawing will start
- **/
- start: {
- value: this.start,
- displayName: 'Start',
- required: true,
- description: 'coordinates of the point where drawing will start',
- },
- /**
-- strokeWeight: a number. Gives the width of the segment drawn for each entry.
- **/
- strokeWeight: {
- value: this.strokeWeight,
- forceType: 'integer',
- displayName: 'Stroke Width',
- required: true,
- },
- /**
-- bgColor: The background color of the visualizer canvas
- **/
- bgColor: {
- value: this.bgColor,
- forceType: 'color',
- displayName: 'Background Color',
- required: false,
+ },
+ /** md
+
+The next four parameters give the instructions for the turtle's path (and how
+it changes from frame to frame). Each one can be a single number, in which
+case it is used for every element of the domain. Or it can be a list of
+numbers the same length as the domain, in which case the numbers correspond in
+order: the first number is used when an entry is equal to the first value in
+the domain, the second number is used for entries equal to the second value,
+and so on. For example, if the "Steps" parameter below is "10", then a segment
+10 pixels long will be drawn for every sequence entry in the domain, whereas if
+the domain is "0 1 2" and "Steps" is "20 10 0", then the turtle will move 20
+pixels each time it sees a sequence entry of 0, 10 pixels for each 1, and it
+won't draw anything when the entry is equal to 2 (but it might turn).
+
+- Turn angle(s): Specifies (in degrees) how much the turtle should turn for
+each sequence entry in the domain. Positive values turn counterclockwise,
+and negative values clockwise.
+ **/
+ turns: {
+ default: [30, 45, 60],
+ type: ParamType.NUMBER_ARRAY,
+ displayName: 'Turn angle(s)',
+ required: true,
+ description:
+ 'An angle (in degrees) or a list of angles, in order '
+ + 'corresponding to the sequence values listed in Domain.',
+ hideDescription: false,
+ },
+ /** md
+- Step length(s): Specifies (in pixels) how far the turtle should move (and
+draw a line segment as it goes) for each sequence entry in the domain. Note
+negative values (for moving backward) are allowed.
+ **/
+ steps: {
+ default: [20],
+ type: ParamType.NUMBER_ARRAY,
+ displayName: 'Step length(s)',
+ required: true,
+ description:
+ 'A length (in pixels), or a list of lengths, in order '
+ + 'corresponding to the sequence values listed in Domain.',
+ hideDescription: false,
+ },
+ /**
+- animationControls: boolean. If true, show folding controls
+ **/
+ animationControls: {
+ default: false,
+ type: ParamType.BOOLEAN,
+ displayName: 'Animation ↴',
+ required: false,
+ },
+ /** md
+- Fold rate(s): Specifies (in units of 0.00001 degree) how each turn angle
+changes from one frame to the next. For example, if there is just one entry
+of "5" here, then in each frame of the animation, the turn angle for every
+element of the domain will increase by 0.00005 degree. Similarly, if the
+domain is "0 1" and this list is "200 -100" then in each frame the turn angle
+for 0 entries will increase by 0.002 degrees, and the turn angle for 1 entries
+will decrease by 0.001 degree. These increments might seem small, but with
+so many turns in a turtle path, a little goes a long way.
+ **/
+ folds: {
+ default: [0],
+ type: ParamType.NUMBER_ARRAY,
+ displayName: 'Folding rate(s)',
+ required: false,
+ description:
+ 'An angle increment (in units of 0.00001 degree), or list of '
+ + 'angle increments in order corresponding to the sequence '
+ + 'values listed in Domain. If any are nonzero, the path will '
+ + 'animate, adding the increment(s) to the turn angle(s) before '
+ + 'each frame; these additions accumulate from frame to frame. '
+ + 'Produces a visual effect akin to protein folding.',
+ hideDescription: false,
+ visibleDependency: 'animationControls',
+ visibleValue: true,
+ },
+ /** md
+- Stretch rate(s): Specifies (in units of 0.01 pixel) how each step length
+changes from one frame to the next.
+ **/
+ stretches: {
+ default: [0],
+ type: ParamType.NUMBER_ARRAY,
+ displayName: 'Stretch rate(s)',
+ required: false,
+ description:
+ 'A length increment (in units of 0.01 pixel), or list of length '
+ + 'increments in order corresponding to the sequence values '
+ + 'listed in Domain. Similarly to Fold rate, these increment(s) '
+ + 'are added to the step length(s) each frame.',
+ hideDescription: false,
+ visibleDependency: 'animationControls',
+ visibleValue: true,
+ },
+ /**
+- pathLook: boolean. If true, show path style controls
+ **/
+ pathLook: {
+ default: true,
+ type: ParamType.BOOLEAN,
+ displayName: 'Path speed/styling ↴',
+ required: false,
+ },
+ /** md
+
+(Note that as an advanced convenience feature, useful mainly when the domain is
+large, you may specify fewer entries in one of these lists than there are
+elements in the domain, and the last one will be re-used as many times as
+necessary. Using this feature will display a warning, in case you inadvertently
+left out a value.)
+
+The remaining parameters control the speed and style of the turtle path
+display.
+
+- Turtle speed: a number. If zero (or if any of the fold or stretch rates
+are nonzero), the full path is drawn all at once. Otherwise, the path drawing
+will be animated, and the Turtle speed specifies the number of steps of the
+path to draw per frame. The drawing continues as long as there are additional
+entries of the sequence to display. The Turtle visualizer has a brake on it
+to prevent lag: this speed cannot exceed 1000 steps per frame.
+ **/
+ speed: {
+ default: 1,
+ type: ParamType.INTEGER,
+ displayName: 'Turtle speed',
+ required: false,
+ description: 'Steps added per frame.',
+ hideDescription: false,
+ visibleDependency: 'pathLook',
+ visibleValue: true,
+ validate: function (n: number, status: ValidationStatus) {
+ if (n < 0) status.addError('must non-negative')
+ if (n > 1000) status.addError('the speed is capped at 1000')
},
- /**
-- strokeColor: The color used for drawing the path.
- **/
- strokeColor: {
- value: this.strokeColor,
- forceType: 'color',
- displayName: 'Stroke Color',
- required: false,
+ },
+ /** md
+- Stroke width: a number. Gives the width of the segment drawn for each entry,
+in pixels.
+ **/
+ strokeWeight: {
+ default: 1,
+ type: ParamType.INTEGER,
+ displayName: 'Stroke width',
+ required: false,
+ validate: function (n: number, status: ValidationStatus) {
+ if (n <= 0) status.addError('must be positive')
},
- }
+ visibleDependency: 'pathLook',
+ visibleValue: true,
+ },
+ /** md
+- Background color: The color of the visualizer canvas.
+ **/
+ bgColor: {
+ default: '#6b1a1a',
+ type: ParamType.COLOR,
+ displayName: 'Background color',
+ required: true,
+ visibleDependency: 'pathLook',
+ visibleValue: true,
+ },
+ /** md
+- Stroke color: The color used for drawing the path.
+ **/
+ strokeColor: {
+ default: '#c98787',
+ type: ParamType.COLOR,
+ displayName: 'Stroke color',
+ required: true,
+ visibleDependency: 'pathLook',
+ visibleValue: true,
+ },
+} satisfies GenericParamDescription
+
+const ruleParams = ['turns', 'steps', 'folds', 'stretches'] as const
+
+// How many segments to gather into a reusable Geometry object
+// Might need tuning
+const CHUNK_SIZE = 1000
+
+class Turtle extends P5GLVisualizer(paramDesc) {
+ static category = 'Turtle'
+ static description =
+ 'Use a sequence to steer a virtual turtle that leaves a visible trail'
+
+ // maps from domain to rotations and steps
+ private rotMap = markRaw(new Map())
+ private stepMap = markRaw(new Map())
+ private foldMap = markRaw(new Map())
+ private stretchMap = markRaw(new Map())
+ // private copies of rule arrays
+ private turnsInternal: number[] = []
+ private stepsInternal: number[] = []
+ private foldsInternal: number[] = []
+ private stretchesInternal: number[] = []
- private currentIndex = 0
- private orientation = 0
- private X = 0
- private Y = 0
+ // variables recording the path
+ private vertices = markRaw([new p5.Vector()]) // nodes of path
+ private chunks: p5.Geometry[] = markRaw([]) // "frozen" chunks of path
+ private bearing = 0 // heading at tip of path
+ private cursor = 0 // vertices up to this one have already been drawn
- checkParameters() {
- const status = super.checkParameters()
+ // variables holding the parameter values
+ // these don't change except in setup()
+ private firstIndex = 0n // first term
+ private animating = false // whether there's any fold/stretch
+ private growth = 0 // growth of path per frame
+ private maxLength = -1 // longest we will allow path to get
+ // controlling the folding smoothness/speed/units
+ // the units of the folding entry field are 1/denom degrees
+ private foldDenom = 100000 // larger = more precision/slower
+ private stretchDenom = 100 // larger = more precision/slower
+
+ // throttling (max step lengths for animating)
+ private throttleWarn = 5000
+ private throttleLimit = 15000
+
+ // handling slow caching & mouse
+ private pathFailure = false
+ private mouseCount = 0
+
+ checkParameters(params: ParamValues) {
+ const status = super.checkParameters(params)
+
+ // lengths of rulesets should match length of domain
+ for (const rule of ruleParams) {
+ const entries = params[rule].length
+ if (entries > 1 && entries < params.domain.length) {
+ this.statusOf[rule].addWarning(
+ `fewer entries than the ${params.domain.length}-element `
+ + 'Domain; reusing last entry '
+ + `${params[rule][entries - 1]} for the remainder`
+ )
+ }
+ if (entries > params.domain.length) {
+ this.statusOf[rule].addWarning(
+ `more entries than the ${params.domain.length}-element `
+ + 'Domain; ignoring extras'
+ )
+ }
+ }
+
+ const animating =
+ params.folds.some(value => value !== 0)
+ || params.stretches.some(value => value !== 0)
+ // warn when animation is turned on for long paths
+ // BUG: when sequence params change this isn't re-run
+ // so the warning may be out of date
if (
- this.params.domain.value.length != this.params.range.value.length
+ animating
+ && this.seq.length > this.throttleWarn
+ && this.seq.length <= this.throttleLimit
) {
- status.isValid = false
- status.errors.push(
- 'Domain and range must have the same number of entries'
+ // bug... why isn't this displaying?
+ status.addWarning(
+ `Animating with more than ${this.throttleWarn} steps is `
+ + 'likely to be quite laggy.'
+ )
+ }
+ if (animating && this.seq.length > this.throttleLimit) {
+ status.addWarning(
+ `Path animation limited to the first ${this.throttleLimit} `
+ + 'entries of the sequence.'
)
}
+ // Check that the domain seems suitable for the sequence:
+ const toTest = 100n
+ const threshold = 10
+ if (this.seq.length >= toTest) {
+ let nIn = 0
+ const limit = this.seq.first + toTest
+ for (let i = this.seq.first; i < limit; ++i) {
+ try {
+ const val = this.seq.getElement(i)
+ if (params.domain.includes(val)) ++nIn
+ } catch {
+ // bag the test
+ return status
+ }
+ }
+ if (nIn < threshold) {
+ this.statusOf.domain.addWarning(
+ `only ${nIn} of the first ${toTest} sequence entries `
+ + 'are in this list; consider adjusting'
+ )
+ }
+ }
+
return status
}
- setup() {
- super.setup()
- this.currentIndex = this.seq.first
- this.orientation = 0
- this.X = this.sketch.width / 2
- this.Y = this.sketch.height / 2
+ storeRules() {
+ // this function creates the internal rule maps from user input
+
+ // create an adjusted internal copy of the rules
+ const ruleParams = [
+ {
+ param: this.turns,
+ local: [0],
+ },
+ {
+ param: this.steps,
+ local: [0],
+ },
+ {
+ param: this.folds,
+ local: [0],
+ },
+ {
+ param: this.stretches,
+ local: [0],
+ },
+ ]
+ ruleParams.forEach(rule => {
+ rule.local = [...rule.param]
+ })
+ // ignore (remove) or add extra rules for excess/missing
+ // terms compared to domain length
+ ruleParams.forEach(rule => {
+ while (rule.local.length < this.domain.length) {
+ rule.local.push(rule.local[rule.local.length - 1])
+ }
+ while (rule.local.length > this.domain.length) {
+ rule.local.pop()
+ }
+ })
+ this.turnsInternal = ruleParams[0].local
+ this.stepsInternal = ruleParams[1].local
+ this.foldsInternal = ruleParams[2].local
+ this.stretchesInternal = ruleParams[3].local
+ // create a map from sequence values to rotations
for (let i = 0; i < this.domain.length; i++) {
this.rotMap.set(
this.domain[i].toString(),
- (Math.PI / 180) * this.range[i]
+ (Math.PI / 180) * this.turnsInternal[i]
)
}
+ // create a map from sequence values to step lengths
+ for (let i = 0; i < this.domain.length; i++) {
+ this.stepMap.set(this.domain[i].toString(), this.stepsInternal[i])
+ }
+
+ // create a map from sequence values to turn increments
+ // notice if path is static or we are folding
+ this.animating = false
+ for (let i = 0; i < this.domain.length; i++) {
+ // cumulative effect of two ways to turn on folding
+ const thisFold = this.foldsInternal[i]
+ if (thisFold != 0) this.animating = true
+ this.foldMap.set(
+ this.domain[i].toString(),
+ (Math.PI / 180) * (thisFold / this.foldDenom)
+ )
+ }
+
+ // create a map from sequence values to stretch increments
+ // notice if path is static or we are animating
+ // rename folding to animating?
+ for (let i = 0; i < this.domain.length; i++) {
+ if (this.stretchesInternal[i] != 0) this.animating = true
+ this.stretchMap.set(
+ this.domain[i].toString(),
+ this.stretchesInternal[i] / this.stretchDenom
+ )
+ }
+ }
+
+ setup() {
+ super.setup()
+
+ // create internal rule maps
+ this.storeRules()
+
+ // reset variables
+ this.firstIndex = this.seq.first
+ this.maxLength = this.animating
+ ? this.throttleLimit
+ : Number.MAX_SAFE_INTEGER
+ if (this.seq.length < this.maxLength) {
+ this.maxLength = Number(this.seq.length)
+ }
+ this.growth = this.speed
+ // draw the entire path every frame if folding
+ if (this.animating) this.growth = this.maxLength
+
+ this.refresh()
+ }
+
+ refresh() {
+ // eliminates the path so it will be recomputed, and redraws
+ this.vertices = markRaw([new p5.Vector()]) // nodes of path
+ this.chunks = markRaw([])
+ this.bearing = 0
+ this.redraw()
+ }
+
+ redraw() {
+ // blanks the screen and sets up to redraw the path
+ this.cursor = 0
+ // prepare sketch
this.sketch
.background(this.bgColor)
+ .noFill()
.stroke(this.strokeColor)
.strokeWeight(this.strokeWeight)
.frameRate(30)
}
+ // Adds the vertices between start and end INCLUSIVE to the current shape
+ addVertices(start: number, end: number) {
+ let lastPos: undefined | p5.Vector = undefined
+ for (let i = start; i <= end; ++i) {
+ const pos = this.vertices[i]
+ if (pos.x !== lastPos?.x || pos.y !== lastPos?.y) {
+ this.sketch.vertex(pos.x, pos.y)
+ }
+ lastPos = pos
+ }
+ }
+
draw() {
- const currElement = this.seq.getElement(this.currentIndex++)
- const angle = this.rotMap.get(currElement.toString())
+ if (this.handleDrags()) this.cursor = 0
+ const sketch = this.sketch
+ if (this.animating) this.refresh()
+ else if (this.cursor === 0) this.redraw()
+
+ // compute more of path as needed:
+ const targetLength = Math.min(
+ this.vertices.length - 1 + this.growth,
+ this.maxLength
+ )
+ this.extendPath(sketch.frameCount, targetLength)
+
+ // draw path from cursor to tip:
+ const newCursor = this.vertices.length - 1
+ // First see if we can use any chunks:
+ const fullChunksIn = Math.floor(this.cursor / CHUNK_SIZE)
+ let drewSome = false
+ for (let chunk = fullChunksIn; chunk < this.chunks.length; ++chunk) {
+ sketch.model(this.chunks[chunk])
+ drewSome = true
+ }
+ if (drewSome) this.cursor = this.chunks.length * CHUNK_SIZE
+ if (this.cursor < newCursor) {
+ sketch.beginShape()
+ this.addVertices(this.cursor, newCursor)
+ sketch.endShape()
+ this.cursor = newCursor
+ }
+
+ // See if we can create a new chunk:
+ const fullChunks = Math.floor(this.cursor / CHUNK_SIZE)
+ if (!this.animating && fullChunks > this.chunks.length) {
+ // @ts-expect-error The @types/p5 package omitted this function
+ sketch.beginGeometry()
+ sketch.beginShape()
+ this.addVertices(
+ (fullChunks - 1) * CHUNK_SIZE,
+ fullChunks * CHUNK_SIZE
+ )
+ sketch.endShape()
+ // @ts-expect-error Ditto :-(
+ this.chunks.push(sketch.endGeometry())
+ }
- if (angle == undefined) {
- this.sketch.noLoop()
- return
+ // stop drawing if no animation
+ if (
+ !this.animating
+ && !sketch.mouseIsPressed
+ && this.vertices.length > this.maxLength
+ && !this.pathFailure
+ ) {
+ this.stop()
}
+ }
- const oldX = this.X
- const oldY = this.Y
+ // This should be run each time the path needs to be extended.
+ // If folding, increment parameters by current frames.
+ // Resulting path should be targetLength steps
+ // meaning that vertices.length = that + 1
+ extendPath(currentFrames: number, targetLength: number) {
+ // First compute the rotMap and stepMap to use:
+ let rotMap = this.rotMap
+ if (this.animating) {
+ rotMap = new Map()
+ for (const [entry, rot] of this.rotMap) {
+ const extra = currentFrames * (this.foldMap.get(entry) ?? 0)
+ rotMap.set(entry, rot + extra)
+ }
+ }
+ let stepMap = this.stepMap
+ if (this.animating) {
+ stepMap = new Map()
+ for (const [entry, step] of this.stepMap) {
+ const extra =
+ currentFrames * (this.stretchMap.get(entry) ?? 0)
+ stepMap.set(entry, step + extra)
+ }
+ }
- this.orientation = this.orientation + angle
- this.X += this.stepSize * Math.cos(this.orientation)
- this.Y += this.stepSize * Math.sin(this.orientation)
+ // Now compute the new vertices in the path:
+ this.pathFailure = false
+ const len = this.vertices.length - 1
+ const position = this.vertices[len].copy()
+ const startIndex = this.firstIndex + BigInt(len)
+ const endIndex = this.firstIndex + BigInt(targetLength)
+ for (let i = startIndex; i < endIndex; i++) {
+ // get the current sequence element and infer
+ // the rotation/step
+ let currElement = 0n
+ try {
+ currElement = this.seq.getElement(i)
+ } catch (e) {
+ this.pathFailure = true
+ if (e instanceof CachingError) {
+ return // need to wait for more elements
+ } else {
+ // don't know what to do with this
+ throw e
+ }
+ }
+ const currElementString = currElement.toString()
+ const turnAngle = rotMap.get(currElementString)
+ if (turnAngle !== undefined) {
+ const stepLength = stepMap.get(currElementString) ?? 0
+ this.bearing += turnAngle
+ position.x += Math.cos(this.bearing) * stepLength
+ position.y += Math.sin(this.bearing) * stepLength
+ }
+ this.vertices.push(position.copy())
+ }
+ }
+
+ async resized(_toSize: ViewSize) {
+ this.cursor = 0 // make sure we redraw
+ return true // we handled it; framework need not do anything
+ }
- this.sketch.line(oldX, oldY, this.X, this.Y)
- if (this.currentIndex > this.seq.last) this.sketch.noLoop()
+ mouseWheel(event: WheelEvent) {
+ super.mouseWheel(event)
+ this.cursor = 0 // make sure we redraw
}
}
-export const exportModule = new VisualizerExportModule(
- Turtle,
- 'Use a sequence to steer a virtual turtle that leaves a visible trail'
-)
+export const exportModule = new VisualizerExportModule(Turtle)
diff --git a/src/visualizers/VisualizerInterface.ts b/src/visualizers/VisualizerInterface.ts
index 7bf3c98e..4d98f126 100644
--- a/src/visualizers/VisualizerInterface.ts
+++ b/src/visualizers/VisualizerInterface.ts
@@ -1,77 +1,283 @@
import type {SequenceInterface} from '../sequences/SequenceInterface'
import type {ParamableInterface} from '../shared/Paramable'
+/** md
+# Visualizers: Behind the Scenes
+In the guide to [making a visualizer](../../doc/visualizer-overview.md), we
+saw how to extend the
+[`P5Visualizer`](../../doc/visualizer-in-depth.md#a-p5-visualizer-in-detail)
+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](#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](#the-visualizer-interface). To support parameters, the
+base class also has to implement the
+[parameterizable object interface](#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](../shared/math.md).
+
+**/
+
interface VisualizerConstructor {
/**
* Constructs a visualizer
* @param seq SequenceInterface The initial sequence to visualize
*/
new (seq: SequenceInterface): VisualizerInterface
- visualizationName: string
+ // Enforce that all visualizers have standard static properties
+ category: string
+ description: string
}
export class VisualizerExportModule {
- name: string
- description: string
visualizer: VisualizerConstructor
+ category: string
+ description: string
- constructor(viz: VisualizerConstructor, description: string) {
- this.name = viz.visualizationName
+ constructor(
+ viz: VisualizerConstructor,
+ category?: string,
+ description?: string
+ ) {
this.visualizer = viz
- this.description = description
+ this.category = category || viz.category
+ this.description = description || viz.description
}
}
+export interface ViewSize {
+ width: number
+ height: number
+}
+export const nullSize = {width: 0, height: 0}
+export function sameSize(size1: ViewSize, size2: ViewSize) {
+ return size1.width === size2.width && size1.height === size2.height
+}
+
+// A visualizer may be either unmounted, drawing, or stopped:
+export const DrawingUnmounted = 0
+export const Drawing = 1
+export const DrawingStopped = 2
+export type DrawingState =
+ | typeof DrawingUnmounted
+ | typeof Drawing
+ | typeof DrawingStopped
+
+/** md
+## 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.
+
+**/
+
export interface VisualizerInterface extends ParamableInterface {
- /* Returns a string identifying what sort of Visualizer this is
- * (typically would depend only on the class of the Visualizer)
- */
- visualization(): string
- /**
- * Change the sequence the visualizer is showing.
- */
- view(seq: SequenceInterface): void
- /**
- * Cause the visualizer to realize itself within a DOM element.
- * The visualizer should remove itself from any other location it might
- * have been displaying, and prepare to draw within the provided element.
- * It is safe to call this with the same element in which
- * the visualizer is already displaying.
- * @param element HTMLElement The DOM node where the visualizer should
- * insert itself.
- */
- inhabit(element: HTMLElement): void
- /**
- * Show the sequence according to this visualizer.
- */
+ /** md */
+ usesGL(): boolean
+ /* **/
+ /** md
+: Should return true if this visualizer requires a WebGL graphics context
+ (of which a limited number are available concurrently in a browser),
+ false otherwise.
+
+ **/
+ /** md */
+ view(sequence: SequenceInterface): Promise
+ /* **/
+ /** md
+: 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).
+
+ **/
+ /** md */
+ requestedAspectRatio(): number | undefined
+ /* **/
+ /** md
+: 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.
+
+ **/
+ /** md */
+ inhabit(element: HTMLElement, size: ViewSize): Promise
+ /* **/
+ /** md
+: 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`.
+
+ **/
+ /** md */
show(): void
- /**
- * Stop drawing the visualization
- */
- stop(): void
- /**
- * Remove the visualization from a DOM element, release its resources, etc.
- * It is an error to call this if the visualization is not currently
- * inhabit()ing any element. If the visualization is currently
- * inhabit()ing a different element, it is presumed that the realization
- * in that element was already cleaned up, and this is a no-op.
- * Note that after this call, it is ok to call inhabit() again,
- * possibly with a different div, to reinitialize it.
- * @param element HTMLElement The DOM node the visualizer was inhabit()ing
- */
+ /* **/
+ /** md
+: 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.
+
+ **/
+ /** md */
+ stop(max?: number): void
+ /* **/
+ /** md
+: 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.
+
+ **/
+ /** md */
+ continue(): void
+ /* **/
+ /** md
+: Continue drawing the visualization, i.e., clear any frame limit previously
+ set by a call to `stop()`.
+
+ **/
+ /** md */
+ drawingState: DrawingState
+ /* **/
+ /** md
+: 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`.
+
+ **/
+ /** md */
depart(element: HTMLElement): void
+ /* **/
+ /** md
+: 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.
+
+ **/
+ /** md */
+ resized?(size: ViewSize): Promise
+ /* **/
+ /** md
+: 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`.
+
+ **/
+}
- /**
- * Provides a way for visualizers to request a specific aspect ratio for
- * its canvas. This aspect ratio is specified as a positive n > 0 where
- * n = width/height, meaning:
- * 0 < n < 1: The canvas is taller than it is wide
- * n = 1: The canvas is a square
- * n > 1: The canvas is wider than it is tall
- * If the visualizer does not wish to request a specific aspect ratio and
- * will instead work with whatever is given, this function may return
- * `undefined` instead.
- * @return the aspect ratio requested by the visualizer, or undefined if any
- */
- requestedAspectRatio(): number | undefined
+/** md
+### 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](../../doc/visualizer-basics.md#parameters-often-used)).
+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` (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`](https://github.com/numberscope
+/frontscope/blob/main/src/visualizers/P5Visualizer.ts).
+
+
+{! ../shared/Paramable.ts extract:
+ start: '^\s*[/]\*\*+\W?xmd\b' # Opening comment with xmd
+ stop: '^(.*?)\s*(?:/\*.*)?\*\*[/].*$' # comment closed with double *
+ # Note that content preceding the comment close will be
+ # extracted, except possibly for a comment open if that's needed.
+!}
+**/
diff --git a/src/visualizers/__tests__/visualizers.spec.ts b/src/visualizers/__tests__/visualizers.spec.ts
index 1fc21ebd..7ae58885 100644
--- a/src/visualizers/__tests__/visualizers.spec.ts
+++ b/src/visualizers/__tests__/visualizers.spec.ts
@@ -9,7 +9,7 @@ describe.todo('visualizers', () => {
TypeError: document.hasFocus is not a function
❯ Object.254../constants node_modules/p5/lib/p5.min.js:3:401058
- The test suite isn't providing the document object. I'll leave this file
+ The test suite isn't providing the document object. I'll leave this file
as a placeholder. Maybe we'll figure out a way to test this in the future.
*/
})
diff --git a/tools/cleancommit.js b/tools/cleancommit.js
new file mode 100644
index 00000000..0fbd1690
--- /dev/null
+++ b/tools/cleancommit.js
@@ -0,0 +1,34 @@
+import * as child_process from 'child_process'
+import * as path from 'path'
+import * as process from 'process'
+
+// Checks whether there are any unstaged changes or untracked files
+// If so, issue a message and fail
+
+process.argv.shift() // remove the node path
+const toolPath = process.argv.shift()
+const packageDir = path.dirname(path.dirname(toolPath))
+process.chdir(packageDir)
+
+const findCommand = `git ls-files . --exclude-standard --others -m`
+console.log('>>', findCommand, '\n')
+
+try {
+ let out = child_process.execSync(findCommand)
+ out = out.toString().trim()
+ if (out === '') {
+ console.log('No uncommitted changes or untracked files. Proceeding.')
+ process.exit(0)
+ } else {
+ console.log(out)
+ }
+} catch (err) {
+ console.error('Error:', err.message)
+ console.error('git ls-files failed, please investigate.')
+ process.exit(2)
+}
+console.warn(`----
+The above files are untracked or have uncommitted changes, the presence of
+which could affect pre-commit testing. Please 'git stash' and/or store
+untracked files elsewhere, and re-try your commit.`)
+process.exit(1)
diff --git a/tools/editor/autoformat.el b/tools/editor/autoformat.el
index 2de4c279..08e62e66 100644
--- a/tools/editor/autoformat.el
+++ b/tools/editor/autoformat.el
@@ -1,69 +1,76 @@
-;; Automatically run prettier-eslint on the current file when saving in emacs
+;; Automatically run tools/prettiest.js on the current file when saving in emacs
;;
;; You can copy this file into your emacs configuration file (~/.emacs or
;; ~/.config/emacs or such), or copy the two functions into another file,
;; and load that file and just put the two `add-hook` calls into your
;; configuration file.
+;; Note that you must fill in the full path to the prettiest.js file on
+;; your system.
;; Inspired by https://emacs.stackexchange.com/questions/54351/how-to-run-a-custom-formatting-tool-on-save
-(defun prettier-eslint-buffer ()
+(defun prettiest-buffer ()
(interactive)
(let ((this-buffer (current-buffer))
- (temp-buffer (generate-new-buffer " *prettier-eslint*"))
+ (temp-buffer (generate-new-buffer " *prettiest*"))
;; Use for format output or stderr in the case of failure.
- (temp-file (make-temp-file "prettier-eslint" nil ".err"))
+ (temp-file (make-temp-file "prettiest" nil ".err"))
;; Always use 'utf-8-unix' & ignore the buffer coding system.
(default-process-coding-system '(utf-8-unix . utf-8-unix)))
(condition-case err
(unwind-protect
(let ((status
- (call-process-region nil nil "npx" nil
+ (call-process-region nil nil "node" nil
;; stdout is a temp buffer, stderr is file.
(list temp-buffer temp-file) nil
;; arguments.
- "prettier-eslint" "--stdin" "--stdin-filepath"
- (buffer-file-name)))
+ "[/FULL/PATH/TO/]tools/prettiest.js"
+ "--write" "--named"
+ (buffer-file-name) "-"))
(stderr
(with-temp-buffer
- (unless (zerop (cadr (insert-file-contents temp-file)))
- (insert ": "))
- (buffer-substring-no-properties (point-min) (point-max))
- )))
+ (insert-file-contents temp-file)
+ (buffer-substring-no-properties
+ (point-min) (point-max)))))
(cond
((stringp status)
- (error "(prettier-eslint killed by signal %s%s)"
+ (error "(prettier-eslint killed by signal %s: %s)"
status stderr))
- ((not (zerop status))
- (error "(prettier-eslint failed with code %d%s)"
+ ((> status 1)
+ (error "(prettier-eslint failed with code %d: %s)"
status stderr))
(t
;; Include the stderr as a message,
;; useful to check on how the program runs.
(unless (string-equal stderr "")
- (message "%s" stdout))))
+ (with-output-to-temp-buffer "*prettiest-error*"
+ (princ stderr)))
+ (message "%s" stderr)))
;; Replace this-buffer's contents with stdout.
- (replace-buffer-contents temp-buffer)))
- ;; Show error as message, so we can clean-up below.
- (error (message "%s" (error-message-string err))))
+ (unless (= (buffer-size buffer) 0)
+ (replace-buffer-contents temp-buffer))))
+ ;; Show error as message, so we can clean up below.
+ (message "%s" (error-message-string err)))
;; Cleanup.
(delete-file temp-file)
(when (buffer-name temp-buffer)
- (kill-buffer temp-buffer))))
+ (kill-buffer temp-buffer))
+ ))
-(defun prettier-eslint-save-hook-for-this-buffer ()
+(defun prettiest-save-hook-for-this-buffer ()
(add-hook 'before-save-hook
- (lambda () (progn
- (prettier-eslint-buffer)
- ;; Continue to save.
- nil))
+ (lambda ()
+ (progn
+ (prettiest-buffer)
+ ;; Continue to save.
+ nil))
nil
;; Buffer local hook.
t))
(add-hook 'typescript-mode-hook
- (lambda () (prettier-eslint-save-hook-for-this-buffer)))
+ (lambda () (prettiest-save-hook-for-this-buffer)))
(add-hook 'vue-mode-hook
- (lambda () (prettier-eslint-save-hook-for-this-buffer)))
+ (lambda () (prettiest-save-hook-for-this-buffer)))
diff --git a/tools/prettier-list.js b/tools/prettier-list.js
new file mode 100644
index 00000000..0d156257
--- /dev/null
+++ b/tools/prettier-list.js
@@ -0,0 +1,28 @@
+const PARSER_NAME = 'listing-parser'
+const AST_FORMAT_NAME = 'listing-dummy-ast'
+
+function parse(_text, _options) {
+ return {node: 'dummy'}
+}
+function locStart(_node) {
+ return 0
+}
+function locEnd(_node) {
+ return 1
+}
+function print(_path, _options, _print) {
+ return ''
+}
+
+export const languages = [{name: 'JustListTheFiles', parsers: [PARSER_NAME]}]
+
+export const parsers = {
+ [PARSER_NAME]: {parse, astFormat: AST_FORMAT_NAME, locStart, locEnd},
+}
+
+export const printers = {
+ [AST_FORMAT_NAME]: {print},
+}
+
+export const options = {}
+export const defaultOptions = {}
diff --git a/tools/prettiest.js b/tools/prettiest.js
new file mode 100644
index 00000000..e33ced92
--- /dev/null
+++ b/tools/prettiest.js
@@ -0,0 +1,316 @@
+import * as child_process from 'child_process'
+import {createTwoFilesPatch} from 'diff'
+import {ESLint} from 'eslint'
+import * as fs from 'fs'
+import * as path from 'path'
+import * as prettier from 'prettier'
+import * as process from 'process'
+import * as util from 'util'
+
+// Takes a collection of glob arguments and determines all of the
+// files that prettier and eslint (with the frontscope configuration)
+// would process given those arguments
+
+process.argv.shift() // remove the node path
+const toolPath = process.argv.shift()
+const packageDir = path.dirname(path.dirname(toolPath))
+const toolName = path.relative(packageDir, toolPath)
+const options = {
+ help: {type: 'boolean', short: 'h', help: 'Print a help message'},
+ 'list-different': {
+ type: 'boolean',
+ short: 'l',
+ help: 'Write a list of nonconformant files to stdout',
+ },
+ 'show-differences': {
+ type: 'boolean',
+ short: 's',
+ help: 'Show differences between current and reformatted',
+ },
+ named: {
+ type: 'string',
+ help: `The file name to use for processing stdin, which is
+ specified by '-' as a file/dir/glob argument`,
+ },
+ quiet: {type: 'boolean', short: 'q', help: "Don't display errors"},
+ write: {type: 'boolean', help: 'Modify nonconformant files in place'},
+}
+let parsedArgs
+try {
+ parsedArgs = util.parseArgs({
+ args: process.argv, // have already extracted execPath and filename
+ allowPositionals: true,
+ options,
+ })
+} catch (err) {
+ process.stderr.write(`prettiest: ${err.code}:\n ${err.message}\n`)
+ process.stderr.write(` or try \`node ${toolName} -h\` for help.\n`)
+ process.exit(1)
+}
+const {values: opt, positionals: globs} = parsedArgs
+
+if (opt.help) {
+ console.log(
+ 'Check and/or correct code formatting with prettier then eslint.\n'
+ )
+ console.log(`Usage: node ${toolName} [options] [file/dir/glob ...]`)
+ let maxHeader = 0
+ for (const flag in options) {
+ const short =
+ 'short' in options[flag] ? `-${options[flag].short}, ` : ` `
+ options[flag].header = `${short}--${flag}`
+ maxHeader = Math.max(maxHeader, options[flag].header.length)
+ }
+ maxHeader += 3
+ for (const flag in options) {
+ console.log(
+ `${options[flag].header.padEnd(maxHeader)}${options[flag].help}`
+ )
+ }
+ console.log('\nNote that when not using --write, line numbers on error')
+ console.log('messages may be approximate, as they are inferred from the')
+ console.log('transformed results.')
+ process.exit(0)
+}
+
+let files = new Set() // the collection of files
+
+// Create the ESLint instance we will use throughout this process:
+const overrideConfigFile = path.join(packageDir, 'etc/eslint.config.js')
+const eslint = new ESLint({overrideConfigFile, cwd: packageDir, fix: true})
+
+// First check if every glob is actually an explicit file:
+let allFiles = false
+try {
+ allFiles = globs.every(glob => {
+ return glob === '-' || fs.lstatSync(glob).isFile()
+ })
+} catch {
+ /* pass */
+}
+
+if (allFiles) {
+ globs.forEach(files.add, files)
+} else {
+ await addGlobsToSet(globs, files)
+}
+
+// Time to load up prettier and apply it and eslint to each file
+const config = path.join(packageDir, 'etc/prettier.config.js')
+const prettierOptions = await prettier.resolveConfig('', {config})
+
+let unchanged = 0
+let changed = 0
+let status = 0
+let wroteErr = false
+let formatter
+for (let filePath of files) {
+ let original = ''
+ let wasStdin = false
+ if (filePath === '-') {
+ // grab all of stdin
+ original = fs.readFileSync(0, 'utf-8')
+ filePath = opt.named || ''
+ wasStdin = true
+ } else {
+ original = fs.readFileSync(filePath, 'utf-8')
+ }
+ prettierOptions.filepath = filePath
+ let prettified
+ try {
+ prettified = await prettier.format(original, prettierOptions)
+ } catch (err) {
+ if (err.name === 'UndefinedParserError') {
+ prettified = original
+ } else {
+ if (!opt.quiet) {
+ wroteErr = true
+ process.stderr.write(
+ `${filePath}: ${err.name}: ${err.message}\n`
+ )
+ }
+ continue
+ }
+ }
+ const result = (await eslint.lintText(prettified, {filePath}))[0]
+ if (
+ (result.warningCount > result.fixableWarningCount
+ || result.errorCount > result.fixableErrorCount)
+ && (result.warningCount !== 1
+ || !result.messages[0]?.message
+ || !result.messages[0].message.startsWith('File ignored'))
+ ) {
+ // eslint noticed something bad about this file, tell the user
+ status = 2
+ if (!formatter) formatter = await eslint.loadFormatter()
+ if (prettified !== original && !opt.write && !opt.quiet) {
+ // Have to update the line numbers of the messages
+ // Since prettier can only map positions forward,
+ // and it does it by reformatting the text (!)
+ // we perform a search based on offsets from new to old.
+ // We assume that the eslint messages occur in order in the file.
+
+ // First, generate the map from lines to cursors:
+ let origLineToCursor = linesToCursors(original)
+ let pretLineToCursor = linesToCursors(prettified)
+ const pretLineFromOrigLine = [0]
+ for (const message of result.messages) {
+ const pretLine = message.line
+ while (!(pretLine in pretLineFromOrigLine)) {
+ const tryOrigLine =
+ pretLine + guessOffset(pretLine, pretLineFromOrigLine)
+ const curOptions = Object.assign({}, prettierOptions, {
+ cursorOffset: origLineToCursor[tryOrigLine],
+ })
+ const {cursorOffset} = await prettier.formatWithCursor(
+ original,
+ curOptions
+ )
+ const tryPretLine = pretLineToCursor.findLastIndex(
+ n => n < cursorOffset
+ )
+ if (tryPretLine in pretLineFromOrigLine) {
+ // Not making progress, so time to make final guess
+ pretLineFromOrigLine[pretLine] = finalGuess(
+ pretLine,
+ pretLineFromOrigLine
+ )
+ break
+ }
+ pretLineFromOrigLine[tryPretLine] = tryOrigLine
+ }
+ message.line = pretLineFromOrigLine[pretLine]
+ }
+ }
+ if (!opt.quiet) {
+ const formatted = await formatter.format([result])
+ wroteErr = true
+ process.stderr.write(formatted)
+ }
+ }
+ const prettiest = result.output || result.source || prettified
+ let isChanged = prettiest !== original
+ if (isChanged) {
+ ++changed
+ if (opt['list-different']) console.log(filePath)
+ if (opt['show-differences']) {
+ const origName = opt['list-different'] ? 'original' : filePath
+ const pretName = opt['list-different']
+ ? 'reformatted'
+ : origName + '[reformatted]'
+ console.log(
+ createTwoFilesPatch(origName, pretName, original, prettiest)
+ )
+ }
+ } else ++unchanged
+ if (opt.write && (isChanged || wasStdin)) {
+ if (wasStdin) {
+ process.stdout.write(prettiest)
+ } else {
+ fs.renameSync(filePath, filePath + '~')
+ fs.writeFileSync(filePath, prettiest)
+ }
+ }
+}
+let nonconform = opt.write ? 'changed' : 'nonconformant'
+let conform = opt.write ? 'unchanged' : 'conformant'
+let changeMessage = ''
+if (changed > 0) {
+ changeMessage =
+ `${changed} ${pluralize('file', changed)} ${nonconform}, ` + 'and '
+}
+if (!opt.quiet && (wroteErr || changed + unchanged > 1)) {
+ process.stderr.write(
+ `\n${changeMessage}`
+ + `${unchanged} ${pluralize('file', unchanged)} ${conform}.\n`
+ )
+}
+
+if (changed > 0) {
+ // Differences are not by themselves errors when writing:
+ if (!opt.write) {
+ if (!opt.quiet) {
+ process.stderr.write(
+ 'Nonconforming files detected, try `npm run lint` to fix.\n'
+ )
+ }
+ process.exit(1)
+ }
+}
+process.exit(status)
+
+function pluralize(s, num) {
+ if (num === 1) return s
+ return s + 's'
+}
+
+async function addGlobsToSet(inglobs, fileSet) {
+ const hadStdin = inglobs.includes('-')
+ const globs = inglobs.filter(g => g !== '-')
+ // First get files from prettier. Note as per
+ // https://stackoverflow.com/questions/78984297/
+ // there does not currently appear to be a way to do this through
+ // the api, but rather one must go through the cli, alas.
+ let prettierList = `prettier --config etc/prettier.config.js
+ --parser listing-parser --plugin tools/prettier-list.js
+ --list-different`.split(/\s+/)
+ prettierList = prettierList.concat(globs)
+ if (!opt['list-different']) {
+ console.log(`>> ${prettierList.join(' ')}`)
+ }
+ const plistResult = child_process.spawnSync('npx', prettierList, {
+ cwd: packageDir,
+ encoding: 'utf-8',
+ })
+ const files = plistResult.stdout
+ .split('\n')
+ .filter(f => !!f)
+ .map(f => path.join(packageDir, f))
+ files.forEach(fileSet.add, fileSet)
+
+ // Now it's eslint's turn; we can do this with the api. Sadly, this
+ // is a wasted run because we don't yet know if the "real" prettier
+ // run is going to change any of these files.
+ let eslintAll = await eslint.lintFiles(globs)
+ for (const result of eslintAll) fileSet.add(result.filePath)
+
+ // Finally, add stdin back in if we had it
+ if (hadStdin) fileSet.add('-')
+}
+
+function linesToCursors(text) {
+ let cursor = 0
+ const lines = text.split('\n')
+ return lines.map(line => {
+ const lastCursor = cursor
+ cursor += line.length + 1
+ return lastCursor
+ })
+}
+
+function guessOffset(line, knownMap) {
+ let guess = 0
+ let from = 0
+ for (const knownLine in knownMap) {
+ if (knownLine < line) {
+ guess = knownMap[knownLine] - knownLine
+ from = knownLine
+ } else if (knownLine === line) return knownMap[knownLine] - knownLine
+ else {
+ const newGuess = knownMap[knownLine] - knownLine
+ const oldWeight = knownLine - line
+ const newWeight = line - from
+ return Math.floor(
+ (guess * oldWeight + newGuess * newWeight)
+ / (oldWeight + newWeight)
+ )
+ }
+ }
+ // Never seen a bigger line, so just go with the current offset
+ return guess
+}
+
+function finalGuess(line, knownMap) {
+ return line + guessOffset(line, knownMap) + 1 // so we don't just
+ // return the same thing we last tried
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 1e36ee39..986f7231 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -1,12 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
- "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
- "exclude": ["src/**/__tests__/*"],
+ "include": ["./src/**/*", "./src/**/*.vue"],
+ "exclude": ["./src/**/__tests__/*"],
"compilerOptions": {
+ "baseUrl": "..",
"composite": true,
- "baseUrl": ".",
+ "lib": ["ES2023"],
"paths": {
"@/*": ["./src/*"]
- }
+ },
+ "target": "ES2023"
}
}
diff --git a/tsconfig.json b/tsconfig.json
index 25eba27c..157a4d10 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,4 +1,8 @@
{
+ "compilerOptions": {
+ "lib": ["ES2023"],
+ "target": "ES2023"
+ },
"files": [],
"references": [
{
diff --git a/tsconfig.vite-config.json b/tsconfig.vite-config.json
index 38a48845..da8acdad 100644
--- a/tsconfig.vite-config.json
+++ b/tsconfig.vite-config.json
@@ -3,9 +3,11 @@
"@tsconfig/node20/tsconfig.json",
"@vue/tsconfig/tsconfig.json"
],
- "include": ["vite.config.*"],
+ "include": ["etc/vite.config.*"],
"compilerOptions": {
"composite": true,
+ "lib": ["ES2023"],
+ "target": "ES2023",
"types": ["node", "vitest"]
}
}
diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json
index 9dc5c63f..e246d3fe 100644
--- a/tsconfig.vitest.json
+++ b/tsconfig.vitest.json
@@ -2,8 +2,13 @@
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
+ "baseUrl": ".",
"composite": true,
- "lib": [],
+ "lib": ["ES2023"],
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "target": "ES2023",
"types": ["node", "jsdom", "vite/client"]
}
}
diff --git a/vitest.config.ts b/vitest.config.ts
deleted file mode 100644
index f852d211..00000000
--- a/vitest.config.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-///
-
-import {defineConfig} from 'vite'
-import Vue from '@vitejs/plugin-vue'
-
-export default defineConfig({
- plugins: [Vue()],
- test: {
- globals: true,
- environment: 'happy-dom',
- },
-})