@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
301 lines (229 loc) • 7.75 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { BinaryDataType } from "../../../core/binary/type/BinaryDataType.js";
import { RowFirstTable } from "../../../core/collection/table/RowFirstTable.js";
import { RowFirstTableSpec } from "../../../core/collection/table/RowFirstTableSpec.js";
import { inverseLerp } from "../../../core/math/inverseLerp.js";
import { lerp } from "../../../core/math/lerp.js";
import { max2 } from "../../../core/math/max2.js";
import { min2 } from "../../../core/math/min2.js";
import { validate_enum_schema } from "../../../core/model/validate_enum_schema.js";
import { table_find_min_index_in_ordered_column } from "./table_find_min_index_in_ordered_column.js";
/**
*
* @type {number[]}
*/
const scratch_row = [];
/**
* @template T
*/
export class TimeSeries {
/**
* @type {RowFirstTableSpec}
*/
spec
/**
* @type {RowFirstTable}
*/
table
time_column_index = 0;
/**
*
* @type {Object<BinaryDataType>}
*/
#schema = {}
/**
*
* @type {Object<number>}
*/
#schema_column_names_to_indices = {};
/**
*
* @type {string[]}
*/
#schema_column_indices_to_names = [];
/**
*
* @param {Object<BinaryDataType>} schema
* @param {string} [time_column_name]
*/
constructor(schema, time_column_name = 'time') {
assert.isString(time_column_name, 'time_column_name');
assert.notNull(schema, 'schema');
assert.isObject(schema, 'schema');
let issues = [];
if (!validate_enum_schema(schema, BinaryDataType, (issue) => issues.push(issue))) {
throw new TypeError(`Invalid schema. Problems:\n ${issues.join('\n')}`);
}
this.#schema = schema;
const schema_keys = Object.keys(schema);
const time_row_index = schema_keys.indexOf(time_column_name);
if (time_row_index === -1) {
throw new TypeError(`supplies schema does not include time column '${time_column_name}', exising columns: ${schema_keys.join(', ')}`);
}
this.time_column_index = time_row_index;
this.#schema_column_names_to_indices = {};
for (let i = 0; i < schema_keys.length; i++) {
const key = schema_keys[i];
this.#schema_column_names_to_indices[key] = i;
this.#schema_column_indices_to_names[i] = key;
}
this.spec = RowFirstTableSpec.get(Object.values(schema));
this.table = new RowFirstTable(this.spec);
}
/**
*
* @param {number[]} row
* @param {Object} object
*/
#object_to_row(row, object) {
const columnCount = this.spec.getColumnCount();
for (let i = 0; i < columnCount; i++) {
const key = this.#schema_column_indices_to_names[i];
const value = object[key];
const t = typeof value;
if (t !== "number") {
throw new TypeError(`Expected sample.${key} to be a number, instead was ${t} (=${value})`);
}
row[i] = value;
}
}
/**
*
* @param {Object} object
* @param {number[]} row
*/
#row_to_object(object, row) {
const columnCount = this.spec.getColumnCount();
for (let i = 0; i < columnCount; i++) {
const key = this.#schema_column_indices_to_names[i];
object[key] = row[i];
}
}
/**
*
* @param {number[]} row
* @returns {Object}
*/
#row_to_object_allocating(row) {
const result = {};
this.#row_to_object(result, row);
return result;
}
/**
*
* @param {number} index
* @returns {Object}
*/
getSampleObjectByIndex(index) {
this.table.readRow(index, scratch_row);
return this.#row_to_object_allocating(scratch_row);
}
/**
*
* @returns {number}
*/
get sample_count() {
return this.table.length;
}
/**
*
* @returns {number}
*/
get last_timestamp() {
const table = this.table;
const record_count = table.length;
if (record_count === 0) {
// default
return 0;
}
const time_column = this.time_column_index;
return table.readCellValue(record_count - 1, time_column);
}
/**
*
* @param {number[]} data
*/
validateNextSample(data) {
assert.isArrayLike(data, 'data');
const table = this.table;
const record_count = table.length;
if (record_count > 0) {
const time_column = this.time_column_index;
const last_time = table.readCellValue(record_count - 1, time_column);
const record = data[time_column];
assert.isNumber(record, `data[${time_column}](time)`);
assert.notNaN(record, `data[${time_column}](time)`);
assert.isFiniteNumber(record, `data[${time_column}](time)`);
if (record <= last_time) {
throw new Error(`Sample.time[${time_column}] = ${record}, which is <= to previous time stamp(=${last_time}). Each sample's time stamp must be strictly greater than the previous one`);
}
}
}
/**
*
* @param {number[]} data
*/
addSample(data) {
// validate
this.validateNextSample(data);
const table = this.table;
table.addRow(data);
}
/**
*
* @param {Object} sample
*/
addObjectSample(sample) {
this.#object_to_row(scratch_row, sample);
this.addSample(scratch_row);
}
/**
*
* @param {number[]} result
* @param {number} index
*/
getSampleByIndex(result, index) {
this.table.readRow(index, result)
}
/**
*
* @param {number} time
* @returns {number}
*/
findLowSampleIndexByTime(time) {
const table = this.table;
const time_column_index = this.time_column_index;
return max2(0, table_find_min_index_in_ordered_column(table, time, time_column_index))
}
/**
*
* @param {number[]} result
* @param {number} result_offset
* @param {number} time
*/
sampleLinear(result, result_offset, time) {
const table = this.table;
const time_column_index = this.time_column_index;
// seek to the right sample
const sample_index = this.findLowSampleIndexByTime(time);
const next_sample_index = min2(table.length - 1, sample_index + 1);
const prev = table.readCellValue(sample_index, time_column_index);
const next = table.readCellValue(next_sample_index, time_column_index);
const normalized_offset = inverseLerp(prev, next, time);
const column_count = this.spec.getColumnCount();
for (let i = 0; i < column_count; i++) {
const v0 = table.readCellValue(sample_index, i);
const v1 = table.readCellValue(next_sample_index, i);
result[result_offset + i] = lerp(v0, v1, normalized_offset)
}
}
/**
* Get linearly interpolated sample for given time in object form, following supplied schema
* @param {number} time
* @returns {Object}
*/
sampleObjectLinear(time) {
this.sampleLinear(scratch_row, 0, time);
return this.#row_to_object_allocating(scratch_row);
}
}