// =============================================================================
/*! countinmultiples */
/*! author: Toni Price (https://toni.rbind.io) */
// =============================================================================
import { Level, ctxt, log } from './logging.js';
import { Config } from './config.js';
import { Dims } from './dims.js';
import { GridAnimator } from './grid-animator.js';
import { GridHelpers } from './grid-helpers.js';
import { InputValidator } from './input-validator.js';
import { MediaHandler } from './media-handler.js';
import { State } from './state.js';
/**
* Class to manage the number grid.
* @typedef {Object} NumberGrid
*/
export class NumberGrid {
// TODO: Make appropriate vars static and move into Config
// Maximum number of cells in the table - slightly arbitrary value
#maxNCell = 4000;
// Maximum number of columns allowed as a user-input value
#maxNColInput = 110;
// Leeway for computing column numbers, i.e. how far up or down from an
// 'ideal' number to go before giving up
// TODO: Decide on a good value for #nColLeeway; could 3 be better?
#nColLeeway = 6;
// TODO: Remove #maxNCol once this is properly set according to media size
// Maximum suggested number of columns in the table (for computed column
// values)
#maxNCol = 12;
#multiple = null;
#nCell = null;
#prevNCell = null;
// Initial font size: clamp(0.9rem, 2.0cqi + 0.4rem, 2.0rem)
#fontSizeMin = Config.fontSizeMin; // rem
#fontSizeSlope = Config.fontSizeSlope; // cqi
#fontSizeIntercept = Config.fontSizeIntercept; // rem
#fontSizeMax = Config.fontSizeMax; // rem
// The number grid HTML element
#grid = null;
// The InputValidator
#validator = null;
// The GridAnimator for running the 'count along' animation
#gridAnimator = null;
// The MediaHandler for dealing with different media and screen resizing
#mediaHandler = null;
#state = State.Initialise;
/**
* Constructor for the NumberGrid.
*
* @param {number} initNCell - The initial value for the number of cells in
* the grid. Default: null which would be set to Config.initNCell in
* NumberGrid.initialise()
* @param {number} initMultiple - The initial value for the multiple. Default:
* null which would be set to Config.initMultiple in NumberGrid.initialise()
* @param {number} initNCols - The initial value for the number of columns in
* the grid, if this is not to be automatically computed. Default:
* null which would be set to Config.initNCols in NumberGrid.initialise()
*/
constructor(initNCell, initMultiple, initNCols) {
// --- --- ---
// Initialise UI elements
const computeColsChkLblId = Config.computeColsChkLblId;
this.nCellInput = document.getElementById(Config.nCellInpId);
this.multInput = document.getElementById(Config.multInpId);
this.highMultChk = document.getElementById(Config.highMultChkId);
this.highMultChkLbl = document.getElementById(Config.highMultChkLblId);
this.highMultLbl = document.getElementById(Config.highMultLblId);
this.speedInput = document.getElementById(Config.speedInpId);
this.speedRange = document.getElementById(Config.speedRangeId);
this.nColInput = document.getElementById(Config.nColInpId);
this.computeColsChk = document.getElementById(Config.computeColsChkId);
this.computeColsChkLbl = document.getElementById(computeColsChkLblId);
this.nRowLbl = document.getElementById(Config.nRowLblId);
this.startBtn = document.getElementById(Config.startBtnId);
this.pauseContinueBtn = document.getElementById(Config.pauseContinueBtnId);
this.clearBtn = document.getElementById(Config.clearBtnId);
this.speedUpBtn = document.getElementById(Config.speedUpBtnId);
this.speedResetBtn = document.getElementById(Config.speedResetBtnId);
this.speedDownBtn = document.getElementById(Config.speedDownBtnId);
this.sizeUpBtn = document.getElementById(Config.sizeUpBtnBtnId);
this.sizeResetBtn = document.getElementById(Config.sizeResetBtnId);
this.sizeDownBtn = document.getElementById(Config.sizeDownBtnBtnId);
this.reloadLink = document.getElementById(Config.countinmultiplesId);
// --- --- ---
this.#grid = document.getElementById(Config.gridId);
this.#gridAnimator = new GridAnimator(this);
this.#mediaHandler = new MediaHandler(this);
this.initialise(initNCell, initMultiple, initNCols);
}
/**
* Getter for the number grid's state.
*/
get state() {
return this.#state;
}
/**
* Setter for the number grid state.
*
* @param {State} state - The number grid state.
*/
set state(state) {
this.#state = state;
}
/**
* Getter for the HTML grid object.
*/
get grid() {
return this.#grid;
}
/**
* Getter for the number of cells in the grid.
*/
get nCell() {
return this.#nCell;
}
/**
* Getter for the multiple to count in.
*/
get multiple() {
return this.#multiple;
}
/**
* Setter for the multiple to count in.
*
* @param {number} num - The multiple to count in.
*/
set multiple(num) {
this.#multiple = parseInt(num);
}
/**
* Getter for the number of rows in the grid.
*
* @return {number} Number of rows in the grid.
*/
get nRow() {
return this.nx;
}
/**
* Getter for the number of columns in the grid.
*
* @return {number} Number of columns in the grid.
*/
get nCol() {
return this.ny;
}
/**
* Setter for the number of cells in the grid.
*
* @param {number} num - The number of cells in the grid.
*/
set nCell(num) {
this.#prevNCell = this.#nCell;
this.#nCell = num;
}
/**
* Getter for the animation execution inteval.
*
* @return {number} The animation exec interval.
*/
get execInterval() {
return this._execInterval;
}
/**
* Setter for the animation execution inteval.
*
* @param {number} num - The animation exec interval.
*/
set execInterval(num) {
this._execInterval = num;
}
/**
* Getter for the column 'leeway' value.
*
* @return {number} An integer value denoting the distance to go
* in either direction (up or down) from the initial number of columns
* (which would be the ceiling of the square root of the number of cells)
* when computing column dimensions. If no values can be found within
* `nColLeeway` from either side of the starting point, the computed
* dimensions will not be a perfect rectangle.
*/
get nColLeeway() {
return this.#nColLeeway;
}
/**
* Setter for the column 'leeway' value.
*
* @param {number} num - An integer value denoting the distance to go
* in either direction (up or down) from the initial number of columns
* (which would be the ceiling of the square root of the number of cells)
* when computing column dimensions. If no values can be found within
* `nColLeeway` from either side of the starting point, the computed
* dimensions will not be a perfect rectangle.
*/
set nColLeeway(num) {
this.#nColLeeway = num;
}
/**
* Getter for the maximum number of columns in the grid.
*
* @return {number} Maximum number of columns in the grid.
*/
get maxNCol() {
return this.#maxNCol;
}
/**
* Setter for the maximum number of columns in the grid.
*
* @param {number} num - Maximum number of columns in the grid.
*/
set maxNCol(num) {
this.#maxNCol = num;
}
/**
* Getter for the number grid's 'media handler' (which manages media queries).
*
* @return {MediaHandler} The number grid's media handler.
*/
get mediaHandler() {
return this.#mediaHandler;
}
/**
* Checks if the number grid's current state is the specified state.
*
* @param {State} state - The state to check for.
* @return {boolean} true if the number grid's current state is the specified
* state, false otherwise.
*/
stateIs = (state) => {
return this.#state === state;
};
/**
* Checks if the current grid state has been set to pause.
*
* @return {boolean} true if the state has been set to pause, false otherwise.
*/
stateIsPause = () => {
return this.stateIs(State.Pause);
};
/**
* Checks if the current grid state has been set to complete.
*
* @return {boolean} true if the state has been set to complete, false
* otherwise.
*/
stateIsComplete = () => {
return this.stateIs(State.Complete);
};
/**
* Retrieves the current state of validity for the field with given key.
*
* @param {string} key - The key for this field in the array of validity
* values.
* @return {boolean} true if the multiple input is valid, false if not.
*/
fieldIsValid = (key) => {
return this.#validator.fieldIsValid(key);
};
/**
* Sets the grid font size to the current clamp string for use with the CSS
* 'font-size' property, consisting of three parts: a minimum, a linear
* scaling function and a maximum.
* For example, 'clamp(0.9rem, 2.0cqi + 0.4rem, 2.0rem)'.
*
* @param {boolean} initialise - Whether to initialise the font size to its
* default before setting the clamp string.
*/
#setFontSize = (initialise) => {
if (initialise) {
log(Level.Debug, `-> Resetting font size to default`, ctxt());
// Initial font size: clamp(0.9rem, 2.0cqi + 0.4rem, 2.0rem)
this.#fontSizeMin = Config.fontSizeMin; // rem
this.#fontSizeSlope = Config.fontSizeSlope; // cqi
this.#fontSizeIntercept = Config.fontSizeIntercept; // rem
this.#fontSizeMax = Config.fontSizeMax; // rem
}
const clamp = GridHelpers.getClampStr(
this.#fontSizeMin,
this.#fontSizeSlope,
this.#fontSizeIntercept,
this.#fontSizeMax,
);
log(Level.Debug, `-> Setting fontSize to clamp '${clamp}'`, ctxt());
this.#grid.style.fontSize = clamp;
};
/**
* Changes the grid font size.
* @param {number} pct The percentage change to apply to the font size.
* @param {number} minSize The minimum font size below which requests for font
* size changes will have no effect. Default: 0.6rem
* @param {number} maxSize The maximum font size above which requests for font
* size changes will have no effect. Default: 6.0rem
*/
changeFontSize = (pct, minSize, maxSize) => {
// Absolute minimum and maximum font sizes (rem)
minSize = minSize || 0.6;
maxSize = maxSize || 6.0;
const fac = 1 + pct / 100.0;
const nextMin = fac * this.#fontSizeMin;
const nextMax = fac * this.#fontSizeMax;
if (nextMin >= minSize && nextMax <= maxSize) {
const nextSlope = fac * this.#fontSizeSlope;
const nextIntercept = fac * this.#fontSizeIntercept;
this.#fontSizeMin = GridHelpers.roundToN(nextMin, 3);
this.#fontSizeSlope = GridHelpers.roundToN(nextSlope, 3);
this.#fontSizeIntercept = GridHelpers.roundToN(nextIntercept, 3);
this.#fontSizeMax = GridHelpers.roundToN(nextMax, 3);
const initialise = false;
this.#setFontSize(initialise);
}
};
/**
* Resets the font size to its default.
*/
resetFontSize = () => {
log(Level.Debug, `-> Resetting font size to default`, ctxt());
const initialise = true;
this.#setFontSize(initialise);
};
/**
* Initialises the app.
*
* @param {number} initNCell - The initial value for the number of cells in
* the grid. Default: Config.initNCell
* @param {number} initMultiple - The initial value for the multiple. Default:
* Config.initMultiple
* @param {number} initNCols - The initial value for the number of columns in
* the grid, if this is not to be automatically computed. Default:
* Config.initNCols
*/
initialise = (initNCell, initMultiple, initNCols) => {
initNCell = initNCell || Config.initNCell;
initMultiple = initMultiple || Config.initMultiple;
initNCols = initNCols || Config.initNCols;
// TODO: Rename nx -> nRow and ny -> nCol for consistency & make private
this.nx = null;
this.ny = null;
this.nCellInput.value = initNCell;
this.multInput.value = initMultiple;
this.#nCell = initNCell;
this.#multiple = initMultiple;
if (this.#validator !== null) {
this.#validator = null;
}
this.#validator = this.constructInputValidator();
// Initial value for number of columns in the grid
if (Config.initComputeColsChecked) {
this.computeColsChk.checked = true;
// This value will be computed in NumberGrid.resetGridDims()
this.nColInput.value = null;
} else {
this.computeColsChk.checked = false;
this.nColInput.value = initNCols;
}
if (Config.initHighMultChecked) {
this.highMultChk.checked = true;
} else {
this.highMultChk.checked = false;
}
this.speedRange.setAttribute('max', Config.maxSpeed);
this.speedRange.setAttribute('min', Config.minSpeed);
// this.#gridAnimator = new GridAnimator(this);
const resetSpeed = true;
this.#initialiseAnimationVars(resetSpeed);
this.validateAllFields();
const initialise = true;
this.#setFontSize(initialise);
log(Level.Debug, `nCell: ${this.#nCell}`, ctxt());
log(Level.Debug, `multiple: ${this.#multiple}`, ctxt());
log(Level.Debug, `(nRow, nCol): (${this.nx}, ${this.ny})`, ctxt());
this.renderHighMultLbl();
this.renderNRowLbl();
this.setAndRenderState(State.Initialise);
// Run the resize media handler
const resetHighlights = true;
this.#mediaHandler.handleResize(resetHighlights);
};
/**
* Initialises animation variables.
*
* @param {boolean} resetSpeed - Whether to reset the speed to its default
* when initialising the animation variables. Default: false
*/
#initialiseAnimationVars = (resetSpeed) => {
log(Level.Debug, `-> Initialising animation vars`, ctxt());
resetSpeed = resetSpeed || false;
this.#gridAnimator.initialiseAnimationVars();
if (resetSpeed) {
this.resetSpeed();
}
this.nColInput.value = this.ny;
if (this.#validator.stateIsValid()) {
this.thawConfigInputs();
this.thawRunCtrls();
}
};
/**
* Computes and sets a 'reasonable' number of rows and columns to set for the
* grid given a number of cells, also setting the grid values to the given
* number of cells.
*
* @param {number} nCell - The number of cells to set for the grid and use for
* computing the number of rows and columns.
*/
setNCellAndDims = (nCell) => {
if (!InputValidator.numIsPresent(nCell)) {
this.#nCell = null;
this.nx = null;
if (this.computeColsChk.checked) {
this.ny = null;
}
} else {
this.#nCell = nCell;
const gridDims = Dims.getGridDimsFromNCell(this.#nCell,
this.#nColLeeway, this.#maxNCol);
log(Level.Debug, `gridDims.nRow: '${gridDims.nRow}'`, ctxt());
log(Level.Debug, `gridDims.nCol: '${gridDims.nCol}'`, ctxt());
this.nx = gridDims.nRow;
this.ny = gridDims.nCol;
}
this.nColInput.value = `${this.ny}`;
};
/**
* Sets the implied number of rows from the specified values for number of
* cells and number of columns, also setting the grid values to the given
* number of cells and columns.
*
* @param {number} nCell - The number of cells to set for the grid.
* @param {number} nCol - The number of columns to set for the grid.
*/
setNColAndDims = (nCell, nCol) => {
const nCellIsPresent = InputValidator.numIsPresent(nCell);
const nColIsPresent = InputValidator.numIsPresent(nCol);
if (!(nCellIsPresent && nColIsPresent)) {
this.#nCell = null;
this.nx = null;
if (this.computeColsChk.checked) {
this.ny = null;
}
} else {
this.ny = nCol;
this.#nCell = nCell;
this.nx = Dims.getNRow(this.ny, this.#nCell);
}
this.nCellInput.value = `${this.#nCell}`;
this.nColInput.value = `${this.ny}`;
};
/**
* Sets the current UI state.
*
* @param {State} state - A valid `State` value which represents the current
* state of the UI to be set.
*/
setState = (state) => {
log(Level.Debug, `-> Setting state: ${this.#state.toString()}`, ctxt());
this.#state = state;
};
/**
* Renders the current UI state.
*/
renderState = () => {
log(Level.Debug, `-> Rendering state: ${this.#state.toString()}`, ctxt());
switch (this.#state) {
case State.Initialise:
this.#renderStateInitialise();
break;
case State.Animate:
this.#renderStateStart();
break;
case State.Stop:
this.#renderStateStop();
break;
case State.Pause:
this.#renderStatePause();
break;
case State.Continue:
this.#renderStateContinue();
break;
case State.Complete:
this.#renderStateComplete();
break;
case State.Restart:
this.#renderStateClear();
break;
case State.Clear:
this.#renderStateInitialise();
break;
case State.InputErr:
this.#renderStateError();
break;
default:
log(Level.Error, `No valid state defined`, ctxt());
log(Level.Error, ` state = '${this.#state}`, ctxt());
break;
}
};
/**
* Sets and renders the current UI state.
*
* @param {State} state - A valid `State` value which represents the current
* state of the UI to be set and rendered.
*/
setAndRenderState = (state) => {
this.setState(state);
this.renderState();
};
/**
* Set the run controls (e.g. stop/start/clear) as they should be before an
* animation starts.
*/
#setInitControls = () => {
if (this.#validator.stateIsValid()) {
this.thawRunCtrls();
this.thawConfigInputs();
}
// Now that all inputs/controls have been enabled, set individual
// requirements
// --- --- ---
// Run controls
this.startBtn.innerHTML = Config.btnStartText;
this.pauseContinueBtn.innerHTML = Config.btnStopText;
this.clearBtn.innerHTML = Config.btnClearText;
this.disableCtrl(this.pauseContinueBtn);
this.pauseContinueBtn.classList.remove(Config.btnAttentionClassName);
this.pauseContinueBtn.style.display = 'none';
this.disableCtrl(this.clearBtn);
this.clearBtn.style.visibility = 'hidden';
// --- --- ---
// Config inputs
// None with individual requirements
};
/**
* Set the run controls (e.g. stop/start/clear) as they should be whilst an
* animation is running.
*/
#setRunControls = () => {
this.#gridAnimator.resetScrollPos();
this.freezeRunCtrls();
this.freezeConfigInputs();
// Now that all inputs/controls have been disabled, set individual
// requirements
// --- --- ---
// Run controls
this.startBtn.innerHTML = Config.btnStartText;
this.pauseContinueBtn.innerHTML = Config.btnPauseText;
this.clearBtn.innerHTML = Config.btnClearText;
this.enableCtrl(this.pauseContinueBtn);
this.pauseContinueBtn.classList.remove(Config.btnAttentionClassName);
this.pauseContinueBtn.style.display = 'block';
this.clearBtn.style.visibility = 'hidden';
// --- --- ---
// Config inputs
// Nothing extra to do here
};
/**
* Renders the UI in its inital state.
*/
#renderStateInitialise = () => {
this.#setInitControls();
};
/**
* Renders the UI when the user requests a running animation to stop (pause).
*/
#renderStateStop = () => {
// No action needed
void(0);
};
/**
* Renders the UI when the user requests an animation to start.
*/
#renderStateStart = () => {
this.#setRunControls();
};
/**
* Renders the UI when the user has paused an animation.
*/
#renderStatePause = () => {
this.#setInitControls();
this.startBtn.innerHTML = Config.btnRestartText;
this.pauseContinueBtn.innerHTML = Config.btnContinueText;
this.clearBtn.innerHTML = Config.btnClearText;
this.enableCtrl(this.pauseContinueBtn);
this.pauseContinueBtn.classList.add(Config.btnAttentionClassName);
this.pauseContinueBtn.style.display = 'block';
this.enableCtrl(this.clearBtn);
this.clearBtn.style.visibility = 'visible';
};
/**
* Renders the UI when the animation was in a paused state and the user
* requests for the animation to continue.
*/
#renderStateContinue = () => {
// No action needed
void(0);
};
/**
* Renders the UI when an animation has completed.
*/
#renderStateComplete = () => {
this.#setInitControls();
this.startBtn.innerHTML = Config.btnRestartText;
this.pauseContinueBtn.innerHTML = Config.btnStopText;
this.clearBtn.innerHTML = Config.btnClearText;
this.disableCtrl(this.pauseContinueBtn);
this.pauseContinueBtn.style.display = 'none';
this.enableCtrl(this.clearBtn);
this.clearBtn.style.visibility = 'visible';
// Once the animation is complete, we no longer want to observe where the
// animated cells are and scroll the grid accordingly, as this would stop
// the user from being able to scroll back to the top of the grid
const selector = `.${Config.cellHighlightClassName}`;
const highlightedCells = document.querySelectorAll(selector);
this.#gridAnimator.unobserveAllElements(highlightedCells);
};
/**
* Renders the UI when the user request the highlights to be cleared.
*/
#renderStateClear = () => {
// No action needed
void(0);
};
/**
* Renders the UI when there is a user input error.
*/
#renderStateError = () => {
this.#setInitControls();
// this.freezeRunCtrls();
};
/**
* Gets the tooltip element which is the sibling of the given element.
*
* @param {Object} elmt - The HTML element for which to find its sibling
* tooltip.
* @param {string} containerClassName - The CSS class name of the element's
* container. Default: Config.tooltipContainerClassName
* @param {string} textClassName - The CSS class name of the tooltip text
* element. Default: Config.tooltipTextClassName
* @return {Object} The siblint HTML element which is the tooltip for the
* given element.
*/
getToolipSibling = (elmt, containerClassName, textClassName) => {
containerClassName = containerClassName || Config.tooltipContainerClassName;
textClassName = textClassName = Config.tooltipTextClassName;
const container = elmt.closest(`.${containerClassName}`);
const tooltip = container.querySelector(`.${textClassName}`);
return tooltip;
};
/**
* Disables an HTML control.
*
* @param {Object} elmt - The HTML control to disable.
* @param {boolean} hasTooltip - Whether the element to disable has a tooltip
* associated with it. Default: false
*/
disableCtrl = (elmt, hasTooltip) => {
hasTooltip = hasTooltip || false;
elmt.disabled = true;
elmt.classList.add(Config.disabledClassName);
if (hasTooltip) {
const tooltip = this.getToolipSibling(elmt);
tooltip.disabled = true;
tooltip.classList.add(Config.disabledClassName);
}
};
/**
* Enables an HTML control.
*
* @param {Object} elmt - The HTML control to enable.
* @param {boolean} hasTooltip - Whether the element to disable has a tooltip
* associated with it. Default: false
*/
enableCtrl = (elmt, hasTooltip) => {
hasTooltip = hasTooltip || false;
elmt.disabled = false;
elmt.classList.remove(Config.disabledClassName);
if (hasTooltip) {
const tooltip = this.getToolipSibling(elmt);
tooltip.disabled = false;
tooltip.classList.remove(Config.disabledClassName);
}
};
/**
* Disables ('freezes') the run controls to stop them from being accessed.
*
* @param {boolean} includeSettings Whether to also disable the settings
* buttons, e.g. speed up/down etc.
*/
freezeRunCtrls = (includeSettings) => {
includeSettings = includeSettings || true;
log(Level.Debug, `-> Freezing controls`, ctxt());
this.disableCtrl(this.startBtn);
this.disableCtrl(this.pauseContinueBtn);
this.disableCtrl(this.clearBtn);
if (includeSettings) {
const hasTooltip = true;
this.disableCtrl(this.speedUpBtn, hasTooltip);
this.disableCtrl(this.speedDownBtn, hasTooltip);
this.disableCtrl(this.speedResetBtn, hasTooltip);
this.disableCtrl(this.sizeUpBtn, hasTooltip);
this.disableCtrl(this.sizeDownBtn, hasTooltip);
this.disableCtrl(this.sizeResetBtn, hasTooltip);
}
};
/**
* Enables ('thaws') the run controls to allow them to be accessed.
*
* @param {boolean} includeSettings Whether to also enable the settings
* buttons, e.g. speed up/down etc.
*/
thawRunCtrls = (includeSettings) => {
includeSettings = includeSettings || true;
log(Level.Debug, `-> Thawing controls`, ctxt());
this.enableCtrl(this.startBtn);
this.enableCtrl(this.pauseContinueBtn);
this.enableCtrl(this.clearBtn);
if (includeSettings) {
const hasTooltip = true;
this.enableCtrl(this.speedUpBtn, hasTooltip);
this.enableCtrl(this.speedDownBtn, hasTooltip);
this.enableCtrl(this.speedResetBtn, hasTooltip);
this.enableCtrl(this.sizeUpBtn, hasTooltip);
this.enableCtrl(this.sizeDownBtn, hasTooltip);
this.enableCtrl(this.sizeResetBtn, hasTooltip);
}
};
/**
* Disables ('freezes') all config inputs to stop user input.
*/
freezeConfigInputs = () => {
this.disableCtrl(this.nCellInput);
this.disableCtrl(this.multInput);
this.disableCtrl(this.highMultChk);
this.disableCtrl(this.speedInput);
this.disableCtrl(this.speedRange);
this.disableCtrl(this.nColInput);
this.disableCtrl(this.computeColsChk);
};
/**
* Enables ('thaws') all config inputs to allow user input.
*/
thawConfigInputs = () => {
this.enableCtrl(this.nCellInput);
this.enableCtrl(this.multInput);
this.enableCtrl(this.highMultChk);
this.enableCtrl(this.speedInput);
this.enableCtrl(this.speedRange);
if (!this.computeColsChk.checked) {
this.enableCtrl(this.nColInput);
} else {
this.disableCtrl(this.nColInput);
}
this.enableCtrl(this.computeColsChk);
};
/**
* Renders the 'no. of rows' label.
*/
renderNRowLbl = () => {
// If this config group has an error, do not re-render the label as this
// would overwrite the error message
const container = InputValidator.getClosestColContainer(this.nColInput);
if (container.classList.contains(Config.inputErrClassName)) {
return;
}
let lbl = '';
let fieldsAreValid = this.fieldIsValid(Config.multInpId);
fieldsAreValid = fieldsAreValid && this.fieldIsValid(Config.nCellInpId);
if (Config.showNRowLbl && fieldsAreValid) {
lbl = `Rows: ${this.nx}`;
}
this.nRowLbl.innerHTML = lbl;
};
/**
* Renders the 'highest multiple' label based on the 'count to' and 'multiple'
* values.
*/
renderHighMultLbl = () => {
// If this config group has an error, do not re-render the label as this
// would overwrite the error message
const container = InputValidator.getClosestColContainer(this.multInput);
if (container.classList.contains(Config.inputErrClassName)) {
return;
}
let lbl = '';
let fieldsAreValid = this.fieldIsValid(Config.multInpId);
fieldsAreValid = fieldsAreValid && this.fieldIsValid(Config.nCellInpId);
if (this.highMultChk.checked && fieldsAreValid) {
const v1 = this.multInput.value;
const v2 = this.nCellInput.value;
const pair = GridHelpers.getHighMultPair(v1, v2);
lbl = `highest: <span class="emph">${pair.highMult}</span>`;
lbl = `${lbl} (next is ${pair.highPlusOneMult})`;
}
this.highMultLbl.innerHTML = lbl;
};
/**
* Handles check/uncheck of the 'show highest multiple' label.
*/
handleHighMultChk = () => {
this.renderHighMultLbl();
};
/**
* Handles check/uncheck of the 'compute cols' label.
*/
handleComputeColsCheck = () => {
this.enableCtrl(this.highMultChk);
if (this.computeColsChk.checked) {
this.disableCtrl(this.nColInput);
} else {
this.enableCtrl(this.nColInput);
}
this.resetGridDims();
const chkNCell = true;
this.validateNColInput(chkNCell);
};
/**
* Sets the animation execution interval from the human-understandable
* animation speed value.
*
* @param {number} invertedSpeed - The speed of animation as would be
* intuitively interpretable to a human being. This is related to the
* exec interval which is the time between animation execution steps.
*/
setExecIntervalFrom = (invertedSpeed) => {
this.#gridAnimator.setExecIntervalFrom(
invertedSpeed,
Config.minSpeed,
Config.maxSpeed,
);
};
/**
* Resets the animation speed to its default.
*/
resetSpeed = () => {
log(Level.Debug, `-> Resetting speed to default`, ctxt());
const minSpeed = Config.minSpeed;
const maxSpeed = Config.maxSpeed;
this.#gridAnimator.execInterval = Config.defaultExecInterval;
const initSpeed = this.#gridAnimator.computeSpeed(minSpeed, maxSpeed);
this.speedInput.value = initSpeed;
this.speedRange.value = initSpeed;
log(Level.Debug, `minExecInterval: ${Config.minExecInterval}`, ctxt());
log(Level.Debug, `maxExecInterval: ${Config.maxExecInterval}`, ctxt());
log(Level.Debug, `speed: ${initSpeed}`, ctxt());
};
/**
* Changes the animation speed.
*
* @param {number} step The step change to apply to the animation speed.
*/
changeSpeed = (step) => {
log(Level.Debug, `-> Changing speed`, ctxt());
let invertedSpeed = parseFloat(this.speedInput.value);
invertedSpeed = Math.min(invertedSpeed + step, Config.maxSpeed);
invertedSpeed = Math.max(invertedSpeed, Config.minSpeed);
invertedSpeed = Math.round(invertedSpeed);
log(Level.Debug, `invertedSpeed: ${invertedSpeed}`, ctxt());
this.setExecIntervalFrom(invertedSpeed);
this.speedInput.value = invertedSpeed;
this.speedRange.value = invertedSpeed;
};
/**
* Adds content (i.e. the actual numbers) to the number grid.
*/
addGridContent = () => {
log(Level.Debug, `-> Adding grid content`, ctxt());
log(Level.Vbose, `nCell=${this.#nCell}, nCol=${this.ny}`, ctxt());
const cells = this.#grid.querySelectorAll('div');
// Note that this.#nCell might *not* be equal to nRow * nCol (as it may not
// always be feasible to construct a grid of square or perfectly rectangular
// dims, depending on the value of nCell)
cells.forEach((cell, cellIndex) => {
if (cellIndex < this.#nCell) {
cell.appendChild(document.createTextNode(cellIndex + 1));
}
});
};
/**
* Constructs an appropriate string for setting the CSS
* 'grid-template-columns'.
*
* @return {string} The CSS 'grid-template-columns' setting.
*/
getGridTemplateCols = () => {
const nCellStr = `${this.#nCell}`;
log(Level.Debug, `nCellStr: '${nCellStr}'`, ctxt());
log(Level.Debug, `max nCell digits: ${nCellStr.length}`, ctxt());
// Config.cellHorzPadding & Config.horzCellSlack units are em
let cellMinWidth = nCellStr.length + Config.horzCellSlack;
cellMinWidth = cellMinWidth + 2 * Config.cellHorzPadding;
const nTimes = `${this.ny}`;
const templateCols = `repeat(${nTimes}, ${cellMinWidth}em)`;
log(Level.Debug, `gridTemplateColumns: '${templateCols}'`, ctxt());
return templateCols;
};
/**
* Sets the HTML number grid's CSS 'grid-template-columns'.
*/
setGridTemplateCols = () => {
// E.g. 'repeat(12, 3.3em)'
const gridTemplateCols = this.getGridTemplateCols();
this.#grid.style.gridTemplateColumns = gridTemplateCols;
};
/**
* Checks if the font size is too large for the current viewport and reduces
* it if so.
*
* @param {number} adjustPct - Percentage value by which to decrease the font
* size.
* @param {number} maxSteps - The maximum number of times to reduce the font
* size (if the grid overshoots the right-hand side of the viewport) before
* stopping.
*/
adjustFontSize = (adjustPct, maxSteps) => {
adjustPct = adjustPct || Config.fontSizeChangePct / 2;
maxSteps = maxSteps || 16;
// Space on right-hand side of grid in pixels
const rightMargin = 10;
let adjust = true;
let count = 0;
while (adjust && count <= maxSteps) {
count += 1;
const bounding = this.#grid.getBoundingClientRect();
// Maximum pixel number on the right-hand side of the window (if the grid
// which goes past this, the font size should be decreased)
const rightMax = Math.ceil(window.innerWidth - rightMargin);
// Check whether the element is partially outside the viewport
const overshootsRight = bounding.right > rightMax;
log(Level.Debug, 'grid width [${count}]:', ctxt());
log(Level.Debug, ` ${Math.ceil(bounding.right)} / ${rightMax}`, ctxt());
log(Level.Debug, ` grid overshoots RHS? ${overshootsRight}`, ctxt());
// Adjust font size if need be
if (overshootsRight) {
log(Level.Debug, ` adjust font size by ${-adjustPct}%`, ctxt());
this.changeFontSize(-adjustPct);
} else {
adjust = false;
}
}
};
/**
* Checks if the current grid size is smaller than the previous grid size.
*
* @return {boolean} true if the current grid size is smaller than the
* previous grid size, false otherwise.
*/
#gridSizeHasShrunk = () => {
return (this.#nCell || 0) < (this.#prevNCell || 0);
};
/**
* Handles adding or removing of the number grid's border, for use when there
* is an error which prevents the grid from being drawn.
*
* @param {boolean} remove Whether to remove the border. If true, the border
* will be removed; if false the border will be set to its usual value.
*/
#addOrRemoveGridBorder = (remove) => {
const grid = document.querySelector(`#${Config.gridId}`);
if (remove) {
// Set the grid's border to 'none' so as not to show a collapsed border
// when there is an error that prevents the grid from being drawn
grid.style.border = 'none';
} else {
// Reset the grid's border to its usual value
grid.style.border = Config.gridBorderStyle;
}
};
/**
* Draws the grid.
*
* @param {boolean} reinitialise - Whether to reinitialise the grid config or
* to redraw the grid using current config values. Default: false
* @param {boolean} retainHighlights - Whether to retain current highlights on
* the grid or to clear them. Default: true
*/
#draw = (reinitialise, retainHighlights) => {
log(Level.Info, `---> Drawing grid`, ctxt());
// /**
// * Checks if none of the number of rows, columns or cells are 0, NaN or
// * null.
// *
// * @return {boolean} true if none of the number of rows, columns or cells
// * are 0, NaN or null.
// */
// gridDimsArePresent = function() {
// const nRowIsOk = InputValidator.numIsPresent(this.nx);
// const nColIsOk = InputValidator.numIsPresent(this.ny);
// const nCellIsOk = InputValidator.numIsPresent(this.#nCell);
// return nRowIsOk && nColIsOk && nCellIsOk;
// };
this.#gridAnimator.resetScrollPos();
reinitialise = reinitialise || false;
retainHighlights = retainHighlights || true;
const gridHasShrunk = this.#gridSizeHasShrunk();
log(Level.Debug, `Prev nCell: ${this.#prevNCell}`, ctxt());
log(Level.Debug, `Curr nCell: ${this.#nCell}`, ctxt());
log(Level.Debug, `Grid size shrunk? ${gridHasShrunk}`, ctxt());
let nCell = null;
let nRow = null;
let nCol = null;
let dimsAreValid = this.fieldIsValid(Config.nCellInpId);
dimsAreValid = dimsAreValid && this.fieldIsValid(Config.nColInpId);
dimsAreValid = dimsAreValid && InputValidator.numIsPresent(this.nx);
if (dimsAreValid) {
nCell = this.#nCell;
nRow = this.nx;
nCol = this.ny;
}
let highlightedCellNums = [];
if (retainHighlights || reinitialise || !dimsAreValid || gridHasShrunk) {
highlightedCellNums = this.#gridAnimator.getHighlightedCellNums();
}
if (reinitialise) {
this.#initialiseAnimationVars();
}
if (reinitialise || !dimsAreValid) {
// If the grid is being replaced altogether, stop observing any previously
// highlighted cells
const highlighted = highlightedCellNums.toHighlight;
highlighted.concat(highlightedCellNums.prevHighlighted);
this.#gridAnimator.unobserveAll(highlighted);
} else if (gridHasShrunk) {
// If the grid has shrunk, stop observing any previously highlighted cells
this.#gridAnimator.unobserveAll(
highlightedCellNums.prevHighlighted,
);
}
GridHelpers.replaceGrid(this.#grid, nCell, nRow, nCol);
if (!dimsAreValid) {
const removeGridBorder = true;
this.#addOrRemoveGridBorder(removeGridBorder);
return;
}
const keepGridBorder = false;
this.#addOrRemoveGridBorder(keepGridBorder);
GridHelpers.setCellIds(this.#grid, Config.cellIdClassPrefix);
this.addGridContent();
// TODO: Could this be replaced by using better css (e.g. auto-fill??)
this.setGridTemplateCols();
if (retainHighlights) {
this.#gridAnimator.reapplyHighlights(highlightedCellNums.toHighlight);
const isInitialising = this.stateIs(State.Initialise);
const nextHighlight = this.#gridAnimator.computeNextHighlight();
if (!isInitialising && (nextHighlight <= nCell) && !reinitialise) {
if (this.#validator.stateIsValid()) {
this.setAndRenderState(State.Pause);
} else {
this.setAndRenderState(State.Error);
}
}
}
this.adjustFontSize();
};
/**
* Redraws the grid.
*/
redrawGrid = () => {
log(Level.Debug, `======> Redrawing grid`, ctxt());
// If the grid should only be reinitialised if the state is not pause or
// complete. If the state is pause then the grid should remain as-is; if the
// state is complete, the grid size could have been changed by the user in
// which case the animation may need to be continued, not restarted, and the
// grid should also remain as-is.
const reinitialise = !(this.stateIsPause() || this.stateIsComplete());
const retainHighlights = !this.stateIs(State.Clear);
this.#draw(reinitialise, retainHighlights);
};
/**
* Resets (or, if during initialisation, sets) the grid column and row
* dimensions and redraws the grid. These may be automatically computed
* from the number of cells or the implied number of rows may be computed
* from a given number of columns if the UI is configured to do so.
*
* @param {number} nCell - Number of cells in the grid. Default: Current value
* of HTML input for the number of cells.
* @param {number} nCol - Number of columns to use for computing the number
* of rows in the event that 'compute cols' is unchecked. Default: Current
* value of HTML input for the number of columns.
*/
resetGridDims = (nCell, nCol) => {
const currNCellIsValid = this.fieldIsValid(Config.nCellInpId);
if (currNCellIsValid) {
// In this case, compute a 'reasonable' number of columns from the
// number of grid cells
nCell = nCell || parseInt(this.nCellInput.value);
}
if (this.computeColsChk.checked) {
if (currNCellIsValid) {
// In this case, compute a 'reasonable' number of columns from the
// number of grid cells
this.setNCellAndDims(nCell);
// Since we have now potentially changed the no. of columns we must also
// reset its validation flag (this will be important if the number of
// columns was in error before resetting the grid dimensions)
const chkNCell = true;
this.validateNColInput(chkNCell);
}
} else {
// Check if the user-input value for no. of columns is valid
const currNColIsValid = this.fieldIsValid(Config.nColInpId);
if (currNColIsValid) {
// In this case, use the current number of columns to set the implied
// number of rows
nCol = nCol || parseInt(this.nColInput.value);
this.setNColAndDims(nCell, nCol);
}
}
if (!this.#validator.stateIsValid()) {
this.#setInitControls();
}
this.redrawGrid();
};
/**
* Initialises the current cell to be animated.
*/
initialiseCurrCell = () => {
this.#gridAnimator.initialiseCurrCell();
};
/**
* Resets cell highlights.
*/
resetHighlights = () => {
this.#gridAnimator.resetHighlights();
};
/**
* Clears cell highlights.
*/
clearHighlights = () => {
this.#gridAnimator.clearHighlights(State.Clear);
};
/**
* Initialises and constructs an input validator for the validatable fields.
*
* @return {InputValidator} An input validator object.
*/
constructInputValidator = () => {
const keyOrder = [
Config.nCellInpId,
Config.multInpId,
Config.speedInpId,
Config.nColInpId,
];
const fields = {};
fields[Config.nCellInpId] = this.nCellInput;
fields[Config.multInpId] = this.multInput;
fields[Config.speedInpId] = this.speedInput;
fields[Config.nColInpId] = this.nColInput;
return new InputValidator(keyOrder, fields);
};
/**
* Checks if the value of the no. of cells input is a counting integer within
* the required range.
* @return {boolean} true if the no. of cells input is valid, false if not.
*/
validateNCellInput = () => {
const isValid = this.#validator.validateCountField(
Config.nCellInpId, 1, this.#maxNCell, 'range',
);
if (!isValid) {
this.nx = null;
if (this.computeColsChk.checked) {
this.ny = null;
}
} else if (this.computeColsChk.checked) {
this.setNCellAndDims(parseInt(this.nCellInput.value));
}
this.renderHighMultLbl();
this.renderNRowLbl();
return isValid;
};
/**
* Checks if the value of the multiple input is a counting integer within the
* required range. Optionally also checks if its value is less than the
* number of cells in the grid.
* @param {boolean} chkNCell - Set to true to check the multiple against the
* no. of cells (i.e. if it is less than or equal to the number of cells).
* @return {boolean} true if the multiple input is valid, false if not.
*/
validateMultInput = (chkNCell) => {
// const key = Config.multInpId;
const minVal = 1;
let maxVal = null;
if (chkNCell) {
maxVal = parseInt(this.nCellInput.value);
} else {
maxVal = this.#maxNCell;
}
// // If `chkNCell` is true and the value of the multiple input is a valid
// // number but larger than the number of cells, reset it to its max
// const resetToMax = chkNCell;
const resetToMax = false;
const isValid = this.#validator.validateCountField(
Config.multInpId, minVal, maxVal, 'max', resetToMax,
);
// Since the field value could have been reset, we need to sync it here
if (resetToMax) {
this.multiple = parseInt(this.multInput.value);
}
// Note that validating the field could have reset the info label (as the
// space for this is shared with error messages); further, the field could
// have been reset to its max possible value so it may need to be
// re-rendered
this.renderHighMultLbl();
return isValid;
};
/**
* Checks if the value of the speed input is a counting integer within the
* required range.
* @return {boolean} true if the speed input is valid, false if not.
*/
validateSpeedInput = () => {
// const key = Config.speedInpId;
return this.#validator.validateCountField(
Config.speedInpId, Config.minSpeed, Config.maxSpeed, 'range',
);
};
/**
* Checks if the value of the no. of columns input is a counting integer
* within the required range. Optionally also checks if its value is less
* than the number of cells in the grid.
* @param {boolean} chkNCell Set to true to check the no. of columns against
* no. of cells (i.e. if it is less than or equal to the number of cells).
* @return {boolean} true if the no. of columns input is valid, false if not.
*/
validateNColInput = (chkNCell) => {
const minVal = 1;
let maxVal = null;
if (chkNCell) {
maxVal = this.nCellInput.value;
} else {
maxVal = this.#maxNColInput;
}
const nCellIsValid = this.#validator.fieldIsValid(Config.nCellInpId);
let isValid = true;
const resetToMax = false;
isValid = this.#validator.validateCountField(
Config.nColInpId, minVal, maxVal, 'max', resetToMax,
);
if (nCellIsValid) {
// Since the field value could have been reset, we need to sync it here
this.ny = parseInt(this.nColInput.value);
}
// Note that validating the field could have reset the info label (as the
// space for this is shared with error messages); further, the field could
// have been reset to its max possible value so it may need to be
// re-rendered
this.renderNRowLbl();
return isValid;
};
/**
* Validates all input fields.
*
* @param {boolean} chkNCell Set to true to check other values against the
* no. of cells (i.e. if the multiple and no. of columns are less than or
* equal to the number of cells).
* @return {boolean} true if all input fields are valid, false otherwise.
*/
validateAllFields = (chkNCell) => {
log(Level.Vbose, `-> validating all fields`, ctxt());
// Note that validity of multiple & no. of cols depend on validity of no. of
// cells, hence no. of cells must be validated before these
this.validateNCellInput();
this.validateMultInput(chkNCell);
this.validateSpeedInput();
this.validateNColInput(chkNCell);
return this.#validator.stateIsValid();
};
/**
* Sets the number grid's state according to its validator status.
*
* @param {State} stateToSetIfValid - A valid `State` value for setting the
* number grid's state if it is valid.
*/
setGridState = (stateToSetIfValid) => {
stateToSetIfValid = stateToSetIfValid || State.Initialise;
if (this.#validator.stateIsValid()) {
this.#state = stateToSetIfValid;
} else {
this.#state = State.InputErr;
const firstInvalid = this.#validator.getFirstInvalidField();
log(Level.Debug, `this.#validator.valid:`, ctxt());
log(Level.Debug, this.#validator.valid, ctxt(), 'dir');
log(Level.Debug, `first invalid: ${firstInvalid}`, ctxt());
this.#validator.setFieldFocus(firstInvalid);
}
};
/**
* Checks if the number grid is in a state such that it can proceed
* with animation, according to whether its fields are valid.
*
* @return {boolean} true if it is okay for animation to proceed, false
* otherwise.
*/
#canProceed = () => {
let stateOk = true;
if (!this.stateIsComplete()) {
log(Level.Debug, `-> Checking if okay to proceed`, ctxt());
const chkNCell = true;
stateOk = this.validateAllFields(chkNCell);
}
if (!stateOk) {
log(Level.Debug, `At least one field is invalid`, ctxt());
log(Level.Debug, `Animation cannot proceed.`, ctxt());
}
return stateOk;
};
/**
* Runs an animation.
*
* @param {boolean} restart Whether to restart an animation from the beginning
* or to continue from the current position.
*/
animate = (restart) => {
if (this.#canProceed()) {
this.#gridAnimator.animate(restart);
} else {
// TODO: Could this logical branch ever be called? Remove if not!
const stateToSetIfValid = State.Initialise;
this.setGridState(stateToSetIfValid);
}
};
/**
* Pauses or continues an animation, depending on the current state of the
* number grid.
*/
pauseOrContinueAnimation = () => {
if (this.#canProceed()) {
this.#gridAnimator.pauseOrContinueAnimation();
} else {
// TODO: Could this logical branch ever be called? Remove if not!
const stateToSetIfValid = this.#state;
this.setGridState(stateToSetIfValid);
}
};
}
// =============================================================================