agentscript
Version:
AgentScript Model in Model/View architecture
1,484 lines (1,356 loc) • 57.8 kB
JavaScript
// /** @namespace */
/** @module */
// ### Async & I/O
// Return Promise for getting an image.
// - use: imagePromise('./path/to/img').then(img => imageFcn(img))
/**
* Return a Promise for getting an image.
*
* use: imagePromise('./path/to/img').then(img => imageFcn(img))
* or: await imagePromise('./path/to/img')
*
* @param {string} url URL for path to image
* @returns {Promise} A promise resolving to the image
*/
export function imagePromise(url) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'Anonymous'
img.onload = () => resolve(img)
// img.onerror = () => reject(Error(`Could not load image ${url}`))
img.onerror = () => reject(`Could not load image ${url}`)
img.src = url
})
}
// // Convert File blob (actually any blob) to Image
// export function blobImagePromise(blob) {
// const url = URL.createObjectURL(blob)
// return imagePromise(url)
// }
// See https://javascript.info/binary for great blob, dataUrl, objUrl discussion.
// https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/createImageBitmap#parameters
// export async function imageBitmapPromise(urlOrBlob, options = {}) {
// // const blob = await xhrPromise(url, 'blob')
// if (typeof urlOrBlob === 'string')
// urlOrBlob = await fetch(urlOrBlob).then(res => res.blob())
// return createImageBitmap(urlOrBlob, options)
// }
// createImageBitmap(img) with lossless parameters.
// img: image, canvas, video, blob, imageData
export async function imageToImageBitmap(img) {
return createImageBitmap(img, {
premultiplyAlpha: 'none',
colorSpaceConversion: 'none',
})
}
// https://stackoverflow.com/questions/52959839/convert-imagebitmap-to-blob
// https://stackoverflow.com/questions/60031536/difference-between-imagebitmap-and-imagedata
// Return an ImageBitmapRenderingContext from the imageBitmap
// The imageBitmap will have transferred its "ownership" to the ctx.canvas.
// The ctx will NOT have the getImageData function.
// Nor will the ctx.canvas be able to getContext('2d')
// The ctx.canvas can be used to draw on a vanilla canvas
// export async function imageBitmapCtx(imageBitmap) {
// const { width, height } = imageBitmap
// const can = createCanvas(width, height)
// // const can = createCanvas(width, height, true)
// const ctx = can.getContext('bitmaprenderer')
// ctx.transferFromImageBitmap(imageBitmap)
// // downloadCanvas(ctx.canvas) // debug
// return ctx // ctx.getImageData(0, 0, width, height)
// }
export async function imageBitmapRendererCtx(imageBitmap) {
const { width, height } = imageBitmap
const can = new OffscreenCanvas(width, height)
const ctx = can.getContext('bitmaprenderer')
ctx.transferFromImageBitmap(imageBitmap)
return ctx // ctx.getImageData(0, 0, width, height)
}
// Use above to create a new canvas filled with the imageBitmap
// export async function imageBitmapCanvas(imageBitmap) {
// // const ctx = await imageBitmapCtx(imageBitmap)
// // return cloneCanvas(ctx.canvas) // Safe from premultiply?
// return imageBitmapCanvasCtx(imageBitmap).canvas
// }
export function imageBitmap2dCtx(imageBitmap) {
const { width, height } = imageBitmap
const can = new OffscreenCanvas(width, height)
const ctx = can.getContext('2d')
ctx.drawImage(imageBitmap, 0, 0)
return ctx
}
export async function imageBitmapData(imageBitmap) {
const ctx = imageBitmap2dCtx(imageBitmap)
return ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)
}
// Convert canvas.toBlob callback style to a promise
export async function canvasToBlob(can, mimeType = 'png', quality = undefined) {
if (!mimeType.startsWith('image/')) mimeType = 'image/' + mimeType
return new Promise(resolve => {
can.toBlob(blob => resolve(blob), mimeType, quality)
})
}
// ^^ditto for canvasToDataURL()
// export async function imageToBlob(img) {
// return fetch(url).then(res => res[type]())
// }
// export function asyncify(f) {
// let AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
// const f = new AsyncFunction('context', script)
// }
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncFunction
export function AsyncFunction(argsArray, fcnBody) {
const ctor = Object.getPrototypeOf(async function () {}).constructor
const asyncFcn = new ctor(...argsArray, fcnBody)
return asyncFcn
}
// Async convert blob to one of three types:
// Type can be one of Text, ArrayBuffer, DataURL
// Camel case ok: text, arrayBuffer, dataURL
export async function blobToData(blob, type = 'dataURL') {
type = type[0].toUpperCase() + type.slice(1)
const types = ['Text', 'ArrayBuffer', 'DataURL']
if (!types.includes(type))
throw Error('blobToData: data must be one of ' + types.toString())
const reader = new FileReader()
return new Promise((resolve, reject) => {
reader.addEventListener('load', () => resolve(reader.result))
reader.addEventListener('error', e => reject(e))
reader['readAs' + type](blob)
})
}
// Async Fetch of a url with the response of the given type
// types: arrayBuffer, blob, json, text; default is blob
// See https://developer.mozilla.org/en-US/docs/Web/API/Response#methods
export async function fetchData(url, type = 'blob') {
const types = ['arrayBuffer', 'blob', 'json', 'text']
if (!types.includes(type))
throw Error('fetchData: data must be one of ' + types.toString())
return fetch(url).then(res => res[type]())
}
export async function fetchJson(url) {
return fetchData(url, 'json')
}
export async function fetchText(url) {
return fetchData(url, 'text')
}
// Return a dataURL for the given data. type is a mime type: https://t.ly/vzKm
// If data is a canvas, return data.toDataURL(type), defaulting to image/png
// Otherwise, use btoa/base64, default type text/plain;charset=US-ASCII
export function toDataURL(data, type = undefined) {
if (data.toDataURL) return data.toDataURL(type, type)
if (!type) type = 'text/plain;charset=US-ASCII'
return `data:${type};base64,${btoa(data)}}`
}
export async function blobsEqual(blob0, blob1) {
const text0 = await blob0.text()
const text1 = await blob1.text()
return text0 === text1
}
// download canvas as png or jpeg. Canvas can be a dataURL.
// quality is default. For lossless jpeg, set to 1
export function downloadCanvas(can, name = 'download.png', quality = null) {
if (!(name.endsWith('.png') || name.endsWith('.jpeg'))) name + '.png'
const type = name.endsWith('.png') ? 'image/png' : 'image/jpeg'
const url = typeOf(can) === 'string' ? can : can.toDataURL(type, quality)
const link = document.createElement('a')
link.download = name
link.href = url
link.click()
}
// blobable = ArrayBuffer, ArrayBufferView, Blob, String
// Objects & Arrays too, converted to json
export function downloadBlob(blobable, name = 'download', format = true) {
if (isDataSet(blobable) && !Array.isArray(blobable.data))
blobable.data = Array.from(blobable.data)
if (isTypedArray(blobable)) blobable = Array.from(blobable)
if (isObject(blobable) || isArray(blobable))
blobable = format
? JSON.stringify(blobable, null, 2)
: JSON.stringify(blobable)
let blob = typeOf(blobable) === 'blob' ? blobable : new Blob([blobable])
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.download = name
link.href = url
link.click()
URL.revokeObjectURL(url)
}
// export function downloadDataSet(dataSet, name = 'download.txt') {
// if (!Array.isArray(dataSet.data)) dataSet.data = Array.from(dataSet.data)
// const type = name.endsWith('.png') ? 'image/png' : 'image/jpeg'
// const url = typeof can === 'string' ? can : can.toDataURL(type, quality)
// download(url, name)
// }
// Ditto for blobs
// export function downloadBlob(blob, name = 'download.blob') {
// // canvas.toBlob(callback, mimeType, qualityArgument)
// const url = URL.createObjectURL(blob)
// download(url, name)
// URL.revokeObjectURL(url)
// // const link = document.createElement('a')
// // link.download = name
// // link.href = URL.createObjectURL(blob)
// // link.click()
// // URL.revokeObjectURL(link.href)
// }
// Ditto for strings like json, text and so on
// export function downloadString(string, name = 'download.txt') {
// const base64 = 'data:text/plain;base64,' + string
// download(base64, name)
// }
// General download, w/ "legal" href
// export function download(url, name) {
// const link = document.createElement('a')
// link.download = name
// link.href = url
// link.click()
// }
// Return Promise for ajax/xhr data.
// - type: 'arraybuffer', 'blob', 'document', 'json', 'text'.
// - method: 'GET', 'POST'
// - use: xhrPromise('./path/to/data').then(data => dataFcn(data))
/**
* Return Promise for ajax/xhr data.
*
* type: 'arraybuffer', 'blob', 'document', 'json', 'text'.
* method: 'GET', 'POST'
* use: xhrPromise('./path/to/data').then(data => dataFcn(data))
*
* @param {UTL} url A URL path to the data to be retrieved
* @param {string} [type='text'] The type of the data
* @param {string} [method='GET'] The retrieval method
* @returns {any} The resulting data of the given type
*/
export function xhrPromise(url, type = 'text', method = 'GET') {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open(method, url) // POST mainly for security and large files
xhr.responseType = type
xhr.onload = () => resolve(xhr.response)
xhr.onerror = () =>
reject(Error(`Could not load ${url}: ${xhr.status}`))
xhr.send()
})
}
/**
* Return promise for pause of ms.
* Use: await pause(ms)
*
* @param {number} [ms=1000] Number of ms to pause
* @returns {Promise} A promise to wait this number of ms
*/
// export function timeoutPromise(ms = 1000) {
export function pause(ms = 1000) {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
}
// Use above for an animation loop.
// steps < 0: forever (default), steps === 0 is no-op
// Returns a promise for when done. If forever, no need to use it.
/**
* Use pause for an animation loop.
* Calls the fcn each step
* Stops after steps calls, negative means run forever
*
* @param {function} fcn The function to be called.
* @param {number} [steps=-1] How many times.
* @param {number} [ms=0] Number of ms between calls.
*/
export async function timeoutLoop(fcn, steps = -1, ms = 0) {
let i = 0
while (i++ !== steps) {
fcn(i - 1)
await pause(ms)
}
}
export function waitPromise(done, ms = 10) {
return new Promise(resolve => {
function waitOn() {
if (done()) return resolve()
else setTimeout(waitOn, ms)
}
waitOn()
})
}
// deprecated, use: await fetch(url).then(res => res.<type>())
// https://developer.mozilla.org/en-US/docs/Web/API/Response#methods
// type = "arrayBuffer" "blob" "formData" "json" "text"
// export async function fetchType(url, type = 'text') {
// const response = await fetch(url)
// if (!response.ok) throw Error(`Not found: ${url}`)
// const value = await response[type]()
// return value
// }
// // Similar pair for requestAnimationFrame
// export function rafPromise() {
// return new Promise(resolve => requestAnimationFrame(resolve))
// }
// export async function rafLoop(fcn, steps = -1) {
// let i = 0
// while (i++ !== steps) {
// fcn(i - 1)
// await rafPromise()
// }
// }
//
// ### Canvas
// import { inWorker } from './dom.js'
function offscreenOK() {
// return !!self.OffscreenCanvas
// return typeof OffscreenCanvas !== 'undefined'
return inWorker()
}
/**
* Create a blank 2D canvas of a given width/height.
*
* @param {number} width The canvas height in pixels
* @param {number} height The canvas width in pixels
* @param {boolean} [offscreen=offscreenOK()] If true, return "Offscreen" canvas
* @returns {Canvas} The resulting Canvas object
*/
export function createCanvas(width, height, offscreen = offscreenOK()) {
if (offscreen) return new OffscreenCanvas(width, height)
const can = document.createElement('canvas')
can.width = width
can.height = height
return can
}
/**
* As above, but returing the 2D context object instead of the canvas.
* Note ctx.canvas is the canvas for the ctx, and can be use as an image.
*
* @param {number} width The canvas height in pixels
* @param {number} height The canvas width in pixels
* @param {boolean} [offscreen=offscreenOK()] If true, return "Offscreen" canvas
* @returns {Context2D} The resulting Canvas's 2D context
*/
export function createCtx(
width,
height,
offscreen = offscreenOK(),
attrs = {}
) {
const can = createCanvas(width, height, offscreen)
return can.getContext('2d', attrs)
}
// Duplicate a canvas, preserving it's current image/drawing
export function cloneCanvas(can, offscreen = offscreenOK()) {
const ctx = createCtx(can.width, can.height, offscreen)
ctx.drawImage(can, 0, 0)
return ctx.canvas
}
// Resize a ctx in-place and preserve image.
export function resizeCtx(ctx, width, height) {
const copy = cloneCanvas(ctx.canvas)
ctx.canvas.width = width
ctx.canvas.height = height
ctx.drawImage(copy, 0, 0)
}
// Return new canvas scaled by width, height and preserve image.
export function resizeCanvas(
can,
width,
height = (width / can.width) * can.height
) {
const ctx = createCtx(width, height)
ctx.drawImage(can, 0, 0, width, height)
return ctx.canvas
}
// Set the ctx/canvas size if differs from width/height.
// It does not install a transform and assumes there is not one currently installed.
// The World object can do that for AgentSets.
export function setCanvasSize(can, width, height) {
if (can.width !== width || can.height != height) {
can.width = width
can.height = height
}
}
// Install identity transform for this context.
// Call ctx.restore() to revert to previous transform.
export function setIdentity(ctx) {
ctx.save() // NOTE: Does not change state, only saves current state.
ctx.setTransform(1, 0, 0, 1, 0, 0) // or ctx.resetTransform()
}
// Set the text font, align and baseline drawing parameters.
// Ctx can be either a canvas context or a DOM element
// See [reference](http://goo.gl/AvEAq) for details.
// * font is a HTML/CSS string like: "9px sans-serif"
// * align is left right center start end
// * baseline is top hanging middle alphabetic ideographic bottom
export function setTextProperties(
ctx,
font,
textAlign = 'center',
textBaseline = 'middle'
) {
Object.assign(ctx, { font, textAlign, textBaseline })
}
// Draw string of the given color at the xy location, in ctx pixel coords.
// Use setIdentity .. reset if a transform is being used by caller.
export function drawText(ctx, string, x, y, color, useIdentity = true) {
if (useIdentity) setIdentity(ctx)
ctx.fillStyle = color.css || color // OK to use Color.typedColor
ctx.fillText(string, x, y)
if (useIdentity) ctx.restore()
}
// # Draw string of the given color at the xy location, in ctx pixel coords.
// # Use setIdentity .. reset if a transform is being used by caller.
// ctxDrawText: (ctx, string, x, y, color, setIdentity = true) ->
// @setIdentity(ctx) if setIdentity
// ctx.fillStyle = color.css # @colorStr color
// ctx.fillText(string, x, y)
// ctx.restore() if setIdentity
// Return the (complete) ImageData object for this context object
export function ctxImageData(ctx) {
return ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)
}
// Return ctx data as an array of typed array rgba colors
export function ctxImageColors(ctx) {
const typedArray = ctxImageData(ctx).data
const colors = []
step(typedArray.length, 4, i => colors.push(typedArray.subarray(i, i + 4)))
return colors
}
// Return ctx data as an array of Uint32Array rgba pixels
export function ctxImagePixels(ctx) {
const imageData = ctxImageData(ctx)
const pixels = new Uint32Array(imageData.data.buffer)
return pixels
}
// Clear this context using the cssColor.
// If no color or if color === 'transparent', clear to transparent.
export function clearCtx(ctx, cssColor = undefined) {
const { width, height } = ctx.canvas
setIdentity(ctx)
if (!cssColor || cssColor === 'transparent') {
ctx.clearRect(0, 0, width, height)
} else {
cssColor = cssColor.css || cssColor
ctx.fillStyle = cssColor
ctx.fillRect(0, 0, width, height)
}
ctx.restore()
}
// These image functions use "imagable" objects: Image, ImageBitmap, Canvas ...
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasImageSource
export function imageToCtx(img) {
const ctx = createCtx(img.width, img.height)
fillCtxWithImage(ctx, img)
return ctx
}
export function imageToCanvas(img) {
return imageToCtx(img).canvas
}
// Fill this context with the given image. Will scale image to fit ctx size.
export function fillCtxWithImage(ctx, img) {
setIdentity(ctx) // set/restore identity
ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height)
ctx.restore()
}
/**
* Fill this context with the given image, resizing it to img size if needed.
*
* @param {Context2D} ctx a canvas 2D context
* @param {Image} img the Image to install in this ctx
*/
export function setCtxImage(ctx, img) {
setCanvasSize(ctx.canvas, img.width, img.height)
fillCtxWithImage(ctx, img)
}
// ### Debug
// error checking:
let skipChecks = false
export function skipErrorChecks(bool) {
skipChecks = bool
}
export function checkArg(arg, type = 'number', name = 'Function') {
if (skipChecks) return
if (typeof arg !== type) {
throw new Error(`${name} expected a ${type}, got ${arg}`)
}
}
export function checkArgs(argsArray, type = 'number', name = 'Function') {
if (skipChecks) return
if (typeOf(argsArray) === 'arguments') argsArray = Array.from(argsArray)
argsArray.forEach((val, i) => {
checkArg(val, type, name)
})
}
// Print a message just once.
const logOnceMsgSet = new Set()
export function logOnce(msg, useWarn = false) {
if (!logOnceMsgSet.has(msg)) {
if (useWarn) {
console.warn(msg)
} else {
console.log(msg)
}
logOnceMsgSet.add(msg)
}
}
export function warn(msg) {
logOnce(msg, true)
}
// Use chrome/ffox/ie console.time()/timeEnd() performance functions
export function timeit(f, runs = 1e5, name = 'test') {
name = name + '-' + runs
console.time(name)
for (let i = 0; i < runs; i++) f(i)
console.timeEnd(name)
}
// simple performance function.
// Records start & current time, steps, fps
// Each call bumps steps, current time, fps
// Use:
// const perf = fps()
// while (perf.steps != 100) {}
// model.step()
// perf()
// }
// console.log(`Done, steps: ${perf.steps} fps: ${perf.fps}`)
export function fps() {
const timer = typeof performance === 'undefined' ? Date : performance
// const start = performance.now()
const start = timer.now()
let steps = 0
function perf() {
steps++
// const ms = performance.now() - start
const ms = timer.now() - start
const fps = parseFloat((steps / (ms / 1000)).toFixed(2))
Object.assign(perf, { fps, ms, start, steps })
}
perf.steps = 0
return perf
}
// Print Prototype Stack: see your vars all the way down!
export function pps(obj, title = '') {
if (title) console.log(title) // eslint-disable-line
let count = 1
let str = ''
while (obj) {
if (typeof obj === 'function') {
str = obj.constructor.toString()
} else {
const okeys = Object.keys(obj)
str =
okeys.length > 0
? `[${okeys.join(', ')}]`
: `[${obj.constructor.name}]`
}
console.log(`[${count++}]: ${str}`)
obj = Object.getPrototypeOf(obj)
}
}
/**
* Merge a module's obj key/val pairs into to the global/window namespace.
* Primary use is to make console logging easier when debugging
* modules.
*
* @param {Object} obj Object who's key/val pairs will be installed in window.
*/
export function toWindow(obj) {
Object.assign(window, obj)
console.log('toWindow:', Object.keys(obj).join(', '))
// if (logToo) {
// Object.keys(obj).forEach(key => console.log(' ', key, obj[key]))
// }
}
export function logAll(obj) {
Object.keys(obj).forEach(key => console.log(' ', key, obj[key]))
}
// Dump model's patches turtles links to window
export function dump(model = window.model) {
let { patches: ps, turtles: ts, links: ls } = model
Object.assign(window, { ps, ts, ls })
window.p = ps.length > 0 ? ps.oneOf() : {}
window.t = ts.length > 0 ? ts.oneOf() : {}
window.l = ls.length > 0 ? ls.oneOf() : {}
console.log('debug: ps, ts, ls, p, t, l dumped to window')
}
// export function logHistogram(name, array) {
// // const hist = AgentArray.fromArray(dataset.data).histogram()
// const hist = histogram(array)
// const { min, max } = hist.parameters
// console.log(
// `${name}:`, // name + ':'
// hist.toString(),
// 'min/max:',
// min.toFixed(3),
// max.toFixed(3)
// )
// }
// Use JSON to return pretty, printable string of an object, array, other
// Remove ""s around keys. Will fail on circular structures.
// export function objectToString(obj) {
// return JSON.stringify(obj, null, ' ')
// .replace(/ {2}"/g, ' ')
// .replace(/": /g, ': ')
// }
// // Like above, but a single line for small objects.
// export function objectToString1(obj) {
// return JSON.stringify(obj)
// .replace(/{"/g, '{')
// .replace(/,"/g, ',')
// .replace(/":/g, ':')
// }
// import { isObject } from './types.js' // see printToPage
// ### Dom
// export function fetchCssStyle(url) {
// document.head.innerHTML += `<link rel="stylesheet" href="${url}" type="text/css" />`
// }
export function addCssLink(url) {
const link = document.createElement('link')
link.setAttribute('rel', 'stylesheet')
link.setAttribute('href', url)
document.head.appendChild(link)
}
export async function fetchCssStyle(url) {
const response = await fetch(url)
if (!response.ok) throw Error(`fetchCssStyle: Not found: ${url}`)
const css = await response.text()
addCssStyle(css)
return css
}
export function addCssStyle(css) {
// document.head.innerHTML += `<style>${css}</style>`
var style = document.createElement('style')
style.innerHTML = css
document.head.appendChild(style)
}
// REST:
// Parse the query, returning an object of key / val pairs.
export function getQueryString() {
return window.location.search.substr(1)
}
export function parseQueryString(
// paramsString = window.location.search.substr(1)
paramsString = getQueryString()
) {
const results = {}
const searchParams = new URLSearchParams(paramsString)
for (var pair of searchParams.entries()) {
let [key, val] = pair
if (val.match(/^[0-9.]+$/) || val.match(/^[0-9.]+e[0-9]+$/))
val = Number(val)
if (['true', 't', ''].includes(val)) val = true
if (['false', 'f'].includes(val)) val = false
results[key] = val
}
return results
}
// Merge the querystring into the default parameters
export function RESTapi(parameters) {
return Object.assign(parameters, parseQueryString())
}
export function inWorker() {
return !inNode() && typeof self.window === 'undefined'
}
export function inNode() {
return typeof global !== 'undefined'
}
export function inDeno() {
return !!Deno
}
// Print a message to an html element
// Default to document.body if in browser.
// If msg is an object, convert to JSON
// (object canot have cycles etc)
// If element is string, find element by ID
export function printToPage(msg, element = document.body) {
// if (isObject(msg)) {
if (typeof msg === 'object') {
msg = JSON.stringify(msg, null, 2)
// msg = '<pre>' + msg + '</pre>'
}
msg = '<pre>' + msg + '</pre>'
if (typeof element === 'string') {
element = document.getElementById(element)
}
element.style.fontFamily = 'monospace'
element.innerHTML += msg //+ '<br />'
}
// Get element (i.e. canvas) relative x,y position from event/mouse position.
export function getEventXY(element, evt) {
// http://goo.gl/356S91
const rect = element.getBoundingClientRect()
return [evt.clientX - rect.left, evt.clientY - rect.top]
}
// Convert a function into a worker via blob url.
// Adds generic error handler. Scripts only, not modules.
export function fcnToWorker(fcn) {
const href = document.location.href
const root = href.replace(/\/[^\/]+$/, '/')
const fcnStr = `(${fcn.toString(root)})("${root}")`
const objUrl = URL.createObjectURL(
new Blob([fcnStr], { type: 'text/javascript' })
)
const worker = new Worker(objUrl)
worker.onerror = function (e) {
console.log('Worker ERROR: Line ', e.lineno, ': ', e.message)
}
return worker
}
// export function workerScript(script, worker) {
// const srcBlob = new Blob([script], { type: 'text/javascript' })
// const srcURL = URL.createObjectURL(srcBlob)
// worker.postMessage({ cmd: 'script', url: srcURL })
// }
// Create dynamic `<script>` tag, appending to `<head>`
// <script src="./test/src/three0.js" type="module"></script>
// NOTE: Use import(path) for es6 modules.
// I.e. this is legacy, for umd's only.
// export function loadScript(path, props = {}) {
// const scriptTag = document.createElement('script')
// scriptTag.src = path
// Object.assign(scriptTag, props)
// document.querySelector('head').appendChild(scriptTag)
// }
// export function loadScript(path, props = {}) {
// return new Promise((resolve, reject) => {
// const scriptTag = document.createElement('script')
// scriptTag.onload = () => resolve(scriptTag)
// scriptTag.src = path
// Object.assign(scriptTag, props)
// document.querySelector('head').appendChild(scriptTag)
// })
// }
// ### Math
// const { PI, floor, cos, sin, atan2, log, log2, sqrt } = Math
export const PI = Math.PI
// Return random int/float in [0,max) or [min,max) or [-r/2,r/2)
/**
* Returns an int in [0, max), equal or grater than 0, less than max
*
* @param {number} max The max integer to return
* @returns {number} an integer in [0, max)
*/
export function randomInt(max) {
return Math.floor(Math.random() * max)
}
// export const randomInt = max => Math.floor(Math.random() * max)
/**
* Returns an int in [min, max), equal or grater than min, less than max
*
* @param {number} min The min integer to return
* @param {number} max The max integer to return
* @returns {number} an integer in [min, max)
*/
export function randomInt2(min, max) {
return min + Math.floor(Math.random() * (max - min))
}
// export const randomInt2 = (min, max) =>
// min + Math.floor(Math.random() * (max - min))
/**
* Returns a random float in [0, max)
*
* @param {number} max The max float to return
* @returns {number} a float in [0, max)
*/
export function randomFloat(max) {
return Math.random() * max
}
// export const randomFloat = max => Math.random() * max
/**
* Returns a random float in [min, max)
*
* @param {number} min The min float to return
* @param {number} max The max float to return
* @returns {number} a float in [min, max)
*/
export function randomFloat2(min, max) {
return min + Math.random() * (max - min)
}
// export const randomFloat2 = (min, max) => min + Math.random() * (max - min)
/**
* Return a random float centered around r, in [-r/2, r/2)
* @param {number} r The center float
* @returns {number} a float in [-r/2, r/2)
*/
export function randomCentered(r) {
return randomFloat2(-r / 2, r / 2)
}
// export const randomCentered = r => randomFloat2(-r / 2, r / 2)
// Return float Gaussian normal with given mean, std deviation.
export function randomNormal(mean = 0.0, sigma = 1.0) {
// Box-Muller
const [u1, u2] = [1.0 - Math.random(), Math.random()] // ui in 0,1
const norm = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * PI * u2)
return norm * sigma + mean
}
/**
* Install a seeded random generator as Math.random
* Uses an optimized version of the Park-Miller PRNG.
*
* Math.random will return a sequence of "random" numbers in a known
* sequence. Useful for testing to see if the same results
* occur in multiple runs of a model with the same parameters.
*
* @param {number} [seed=123456]
*/
export function randomSeed(seed = 123456) {
// doesn't repeat b4 JS dies.
// https://gist.github.com/blixt/f17b47c62508be59987b
seed = seed % 2147483647
Math.random = () => {
seed = (seed * 16807) % 2147483647
return (seed - 1) / 2147483646
}
}
/**
* Round a number to be of a given decimal precision.
* If the number is an array, round each item in the array
*
* @param {number|Array} num The number to convert/shorten
* @param {number} [digits=4] The number of decimal digits
* @returns {number} The resulting number
*/
export function precision(num, digits = 4) {
if (num === -0) return 0
if (Array.isArray(num)) return num.map(val => precision(val, digits))
const mult = 10 ** digits
return Math.round(num * mult) / mult
}
// Return whether num is [Power of Two](http://goo.gl/tCfg5). Very clever!
export const isPowerOf2 = num => (num & (num - 1)) === 0 // twgl library
// Return next greater power of two. There are faster, see:
// [Stack Overflow](https://goo.gl/zvD78e)
export const nextPowerOf2 = num => Math.pow(2, Math.ceil(Math.log2(num)))
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Remainder
// The modulus is defined as: x - y * floor(x / y)
// It is not %, the remainder function.
/**
* A true modulus function, differing from the % remainder operation.
*
* @param {number} v The value to calculate the modulus of
* @param {number} n The number relative to which the modulus is calculated.
* @returns {number} The value of v mod n
*/
export function mod(v, n) {
return ((v % n) + n) % n // v - n * Math.floor(v / n)
}
// export const mod = (v, n) => ((v % n) + n) % n // v - n * Math.floor(v / n)
// Wrap v around min, max values if v outside min, max
export const wrap = (v, min, max) => min + mod(v - min, max - min)
/**
* Clamp a float to be between [min, max).
*
* @param {number} v value to clamp between min & max
* @param {number} min min value
* @param {number} max max value
* @returns {number} a float between min/max
*/
export function clamp(v, min, max) {
if (v < min) return min
if (v > max) return max
return v
}
// Return true is val in [min, max] enclusive
export const isBetween = (val, min, max) => min <= val && val <= max
// Return a linear interpolation between lo and hi.
// Scale is in [0-1], a percentage, and the result is in [lo,hi]
// If lo>hi, scaling is from hi end of range.
// [Why the name `lerp`?](http://goo.gl/QrzMc)
export const lerp = (lo, hi, scale) =>
lo <= hi ? lo + (hi - lo) * scale : lo - (lo - hi) * scale
// Calculate the lerp scale given lo/hi pair and a number between them.
// Clamps number to be between lo & hi.
export function lerpScale(number, lo, hi) {
if (lo === hi) throw Error('lerpScale: lo === hi')
number = clamp(number, lo, hi)
return (number - lo) / (hi - lo)
}
// ### Geometry
// Degrees & Radians
// Note: quantity, not coord system xfm
// const toDegrees = 180 / PI
// const toRadians = PI / 180
export const toDeg = 180 / Math.PI
export const toRad = Math.PI / 180
// Better names and format for arrays. Change above?
/**
* Convert from degrees to radians
*
* @param {number} degrees a value in degrees: in [0, 360)
* @returns {number} the value as radians: in [0, 2PI)
*/
export function degToRad(degrees) {
return mod2pi(degrees * toRad)
}
/**
* Convert from radians to degrees
*
* @param {number} radians a value in radians: in [0, 2PI)
* @returns {number} the value as degrees: in [0, 360)
*/
export function radToDeg(radians) {
return mod360(radians * toDeg)
}
// export const radToDeg = radians => mod360(radians * toDeg)
// Heading & Radians: coord system
// * Heading is 0-up (y-axis), clockwise angle measured in degrees.
// * Rad is euclidean: 0-right (x-axis), counterclockwise in radians
/**
* Convert from radians to heading
*
* Heading is 0-up (y-axis), clockwise angle measured in degrees.
* Radians is euclidean: 0-right (x-axis), counterclockwise in radians
*
* @param {number} radians a value in radians: in [0, 2PI)
* @returns {number} a value in degrees: in [0, 360)
*/
export function radToHeading(radians) {
const deg = radians * toDeg
return mod360(90 - deg)
}
/**
* Convert from heading to radians
*
* @param {number} heading a value in degrees: in [0, 360)
* @returns {number} a value in radians: in [0, 2PI)
*/
export function headingToRad(heading) {
const deg = mod360(90 - heading)
return deg * toRad
}
// Relative angles in heading space: deg Heading => -deg Eucledian
export function radToHeadingAngle(radians) {
return -radToDeg(radians)
}
export function headingAngleToRad(headingAngle) {
return -degToRad(headingAngle)
}
// Wow. surprise: headingToDeg = degToHeading! Just like above.
// deg is absolute eucledian degrees direction
export const degToHeading = degrees => mod360(90 - degrees)
export const headingToDeg = heading => mod360(90 - heading)
export function mod360(degrees) {
return mod(degrees, 360)
}
export function mod2pi(radians) {
return mod(radians, 2 * PI)
}
export function modpipi(radians) {
return mod(radians, 2 * PI) - PI
}
export function mod180180(degrees) {
return mod360(degrees) - 180
}
// headingsEq === degreesEq
export function degreesEqual(deg1, deg2) {
return mod360(deg1) === mod360(deg2)
}
export function radsEqual(rads1, rads2) {
return mod2pi(rads1) === mod2pi(rads2)
}
export const headingsEq = degreesEqual
// Return angle (radians) in (-pi,pi] that added to rad0 = rad1
// See NetLogo's [subtract-headings](http://goo.gl/CjoHuV) for explanation
export function subtractRadians(rad1, rad0) {
let dr = mod2pi(rad1 - rad0) // - PI
if (dr > PI) dr = dr - 2 * PI
return dr
}
// Above using headings (degrees) returning degrees in (-180, 180]
export function subtractDegrees(deg1, deg0) {
let dAngle = mod360(deg1 - deg0) // - 180
if (dAngle > 180) dAngle = dAngle - 360
return dAngle
}
// export const subtractHeadings = (head1, head0) =>
// degToHeading(subtractDegrees(headingToDeg(head1), headingToDeg(head0)))
/**
* Subtract two headings, returning the smaller difference.
*
* Computes the difference between the given headings, that is,
* the number of degrees in the smallest angle by which heading2
* could be rotated to produce heading1
* See NetLogo's [subtract-headings](http://goo.gl/CjoHuV) for explanation
* @param {number} head1 The first heading in degrees
* @param {number} head0 The second heading in degrees
* @returns {number} The smallest andle from head0 to head1
*/
export function subtractHeadings(head1, head0) {
return -subtractDegrees(head1, head0)
}
// export const subtractHeadings = (head1, head0) => -subtractDegrees(head1, head0)
// Return angle in [-pi,pi] radians from (x,y) to (x1,y1)
// [See: Math.atan2](http://goo.gl/JS8DF)
export function radiansTowardXY(x, y, x1, y1) {
return Math.atan2(y1 - y, x1 - x)
}
// Above using headings (degrees) returning degrees in [-90, 90]
export function headingTowardXY(x, y, x1, y1) {
return radToHeading(radiansTowardXY(x, y, x1, y1))
}
// Above using degrees returning degrees in [-90, 90]
export function degreesTowardXY(x, y, x1, y1) {
return radToDeg(radiansTowardXY(x, y, x1, y1))
}
// AltAz: Alt is deg from xy plane, 180 up, -180 down, Az is heading
// We choose Phi radians from xy plane, "math" is often from Z axis
// REMIND: some prefer -90, 90
// export function altAzToAnglePhi(alt, az) {
// const angle = headingToRad(az)
// const phi = modpipi(alt * toRad)
// return [angle, phi]
// }
// export function anglePhiToAltAz(angle, phi) {
// const az = radToHeading(angle)
// const alt = mod180180(phi * toDeg)
// return [alt, az]
// }
// export function mod180180(degrees) {
// return mod360(degrees) - 180
// }
// export function modpipi(radians) {
// return mod2pi(radians) - PI
// }
// Return distance between (x, y), (x1, y1)
export const sqDistance = (x, y, x1, y1) => (x - x1) ** 2 + (y - y1) ** 2
export const distance = (x, y, x1, y1) => Math.sqrt(sqDistance(x, y, x1, y1))
export const sqDistance3 = (x, y, z, x1, y1, z1) =>
(x - x1) ** 2 + (y - y1) ** 2 + (z - z1) ** 2
export const distance3 = (x, y, z, x1, y1, z1) =>
Math.sqrt(sqDistance3(x, y, z, x1, y1, z1))
// Return true if x,y is within cone.
// Cone: origin x0,y0 in direction angle, with coneAngle width in radians.
// All angles in radians
export function inCone(x, y, radius, coneAngle, direction, x0, y0) {
if (sqDistance(x0, y0, x, y) > radius * radius) return false
const angle12 = radiansTowardXY(x0, y0, x, y) // angle from 1 to 2
return coneAngle / 2 >= Math.abs(subtractRadians(direction, angle12))
}
// export const radians = degrees => mod2pi(degrees * toRad)
// export const degrees = radians => mod360(radians * toDeg)
// export function precision(num, digits = 4) {
// const mult = 10 ** digits
// return Math.round(num * mult) / mult
// }
// Two seedable random number generators
// export function randomSeedSin(seed = PI / 4) {
// // ~3.4 million b4 repeat.
// // https://stackoverflow.com/a/19303725/1791917
// return () => {
// const x = Math.sin(seed++) * 10000
// return x - Math.floor(x)
// }
// }
// export function randomSeedParkMiller(seed = 123456) {
// // doesn't repeat b4 JS dies.
// // https://gist.github.com/blixt/f17b47c62508be59987b
// seed = seed % 2147483647
// return () => {
// seed = (seed * 16807) % 2147483647
// return (seed - 1) / 2147483646
// }
// }
// // Replace Math.random with one of these
// export function randomSeed(seed, useParkMiller = true) {
// Math.random = useParkMiller
// ? randomSeedParkMiller(seed)
// : randomSeedSin(seed)
// }
// ### Models
// import { loadScript, inWorker } from './dom.js'
// import { randomSeed } from './math.js'
// import { repeat } from './objects.js'
// import { timeoutLoop } from './async.js'
export function toJSON(obj, indent = 0, topLevelArrayOK = true) {
let firstCall = topLevelArrayOK
const blackList = ['rectCache']
const json = JSON.stringify(
obj,
(key, val) => {
if (blackList.includes(key)) {
// if (key === 'rectCache') return val.length
return undefined
}
const isAgentArray =
Array.isArray(val) &&
val.length > 0 &&
Number.isInteger(val[0].id)
if (isAgentArray && !firstCall) {
return val.map(v => v.id)
}
firstCall = false
return val
},
indent
)
return json
}
/**
* Return an object with samples of the models components.
* Useful for testing Models without needing a View, just data.
*
* @param {Model} model A model to sample
* @returns {Object} An object with all the samples
*/
export function sampleModel(model) {
const obj = {
ticks: model.ticks,
model: Object.keys(model),
patches: model.patches.length,
patch: model.patches.oneOf(),
turtles: model.turtles.length,
turtle: model.turtles.oneOf(),
links: model.links.length,
link: model.links.oneOf(),
}
const json = toJSON(obj)
return JSON.parse(json)
}
// // params; classPath, steps, seed,
// export async function runModel(params) {
// var worker = inWorker() // fails in test/models.js
// const prefix = worker ? 'worker ' : 'main '
// console.log(prefix + 'params', params)
// if (worker) importScripts(params.classPath)
// else await loadScript(params.classPath)
// if (params.seed) randomSeed()
// // const Model = eval(params.className)
// const model = new defaultModel()
// console.log(prefix + 'model', model)
// await model.startup()
// model.setup()
// if (worker) {
// repeat(params.steps, () => {
// model.step()
// })
// } else {
// await timeoutLoop(() => {
// model.step()
// }, params.steps)
// }
// console.log(prefix + 'done, model', model)
// return sampleModel(model)
// }
// import { randomInt } from './math.js'
// import { convertArrayType } from './types.js'
// ### Arrays, Objects and Iteration
// Three handy functions for defaults & properties
// Identity fcn, returning its argument unchanged. Used in callbacks
export const identityFcn = o => o
// No-op function, does nothing. Used for default callback.
export const noopFcn = () => {}
// Return function returning an object's property. Property in fcn closure.
export const propFcn = prop => o => o[prop]
export function arraysEqual(a1, a2) {
if (a1.length !== a2.length) return false
for (let i = 0; i < a1.length; i++) {
// if (a1[i] !== a2[i]) console.log('arraysEqual: unequal at', i)
if (a1[i] !== a2[i]) return false
}
return true
}
export function removeArrayItem(array, item) {
const ix = array.indexOf(item)
if (ix !== -1) {
array.splice(ix, 1)
} else {
throw Error(`removeArrayItem: ${item} not in array`)
}
// else console.log(`removeArrayItem: ${item} not in array`)
return array // for chaining
}
// Return a string representation of an array of arrays
export const arraysToString = arrays => arrays.map(a => `${a}`).join(',')
// Execute fcn for all own member of an obj or array (typed OK).
// Return input arrayOrObj, transformed by fcn.
// - Unlike forEach, does not skip undefines.
// - Like map, forEach, etc, fcn = fcn(item, key/index, obj).
// - Alternatives are: `for..of`, array map, reduce, filter etc
export function forLoop(arrayOrObj, fcn) {
if (arrayOrObj.slice) {
// typed & std arrays
for (let i = 0, len = arrayOrObj.length; i < len; i++) {
fcn(arrayOrObj[i], i, arrayOrObj)
}
} else {
// obj
Object.keys(arrayOrObj).forEach(k => fcn(arrayOrObj[k], k, arrayOrObj))
}
// return arrayOrObj
}
// Repeat function f(i, a) n times, i in 0, n-1
// a is optional array, default a new Array.
// Return a.
/**
* Repeats the function f(i, a) i in 0, n-1.
* a is an optional array, default a new empty Array
* returns a, only needed if f() places data in a
*
* @param {number} n An integer number of times to run f()
* @param {function} f The function called.
* @param {Array} [a=[]] An optional array for use by f()
* @returns {Array} The result of calling f() n times
*/
export function repeat(n, f, a = []) {
for (let i = 0; i < n; i++) f(i, a)
return a
}
// Repeat function n times, incrementing i by step each call.
export function step(n, step, f) {
for (let i = 0; i < n; i += step) f(i)
}
// Return range [0, length-1]. Note: 6x faster than Array.from!
export function range(length) {
return repeat(length, (i, a) => {
a[i] = i
})
}
// REMIND: use set function on object keys
// export function override(defaults, options) {
// return assign(defaults, options, Object.keys(defaults))
// }
export function override(defaults, options) {
const overrides = defaults
forLoop(defaults, (val, key) => {
if (options[key]) {
overrides[key] = options[key]
}
})
return overrides
}
// Get subset of object by it's keys
// export function getObjectValues(obj, keys) {}
// Return a new array that is the concatination two arrays.
// The resulting Type is that of the first array.
export function concatArrays(array1, array2) {
const Type = array1.constructor
if (Type === Array) {
return array1.concat(convertArrayType(array2, Array))
}
const array = new Type(array1.length + array2.length)
// NOTE: typedArray.set() allows any Array or TypedArray arg
array.set(array1)
array.set(array2, array1.length)
return array
}
// Convert obj to string via JSON. Use indent = 0 for one-liner
// jsKeys true removes the jsonKeys quotes
export function objectToString(obj, indent = 2, jsKeys = true) {
let str = JSON.stringify(obj, null, indent)
if (jsKeys) str = str.replace(/"([^"]+)":/gm, '$1:')
return str
}
// Compare Objects or Arrays via JSON string. Note: TypedArrays !== Arrays
export const objectsEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b)
/**
* Return random item from an array
*
* @param {Array} An array to choose from
* @returns {any} The chosen item
*/
// Return random one of array items.
export function oneOf(array) {
return array[randomInt(array.length)]
}
export function otherOneOf(array, item) {
if (array.length < 2) throw Error('otherOneOf: array.length < 2')
do {
var other = oneOf(array)
} while (item === other) // note var use
return other
}
// Random key/val of object
export const oneKeyOf = obj => oneOf(Object.keys(obj))
export const oneValOf = obj => obj[oneKeyOf(obj)]
// export function oneKeyOf(obj) {
// return oneOf(Object.keys(obj))
// }
// export function oneValOf(obj) {
// return obj[oneKeyOf(obj)]
// }
// You'd think this wasn't necessary, but I always forget. Damn.
// NOTE: this, like sort, sorts in place. Clone array if needed.
export function sortNums(array, ascending = true) {
return array.sort((a, b) => (ascending ? a - b : b - a))
}
// Sort an array of objects w/ fcn(obj) as compareFunction.
// If fcn is a string, convert to propFcn.
export function sortObjs(array, fcn, ascending = true) {
if (typeof fcn === 'string') fcn = propFcn(fcn)
const comp = (a, b) => fcn(a) - fcn(b)
return array.sort((a, b) => (ascending ? comp(a, b) : -comp(a, b)))
}
// Randomize array in-place. Use clone() first if new array needed
// The array is returned for chaining; same as input array.
// See [Durstenfeld / Fisher-Yates-Knuth shuffle](https://goo.gl/mfbdPh)
export function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = randomInt(i)
;[array[j], array[i]] = [array[i], array[j]]
// const temp = array[i]
// array[i] = array[j]
// array[j] = temp
}
return array
}
// Set operations on arrays
// union: elements in a1 or a2
export function union(a1, a2) {
return Array.from(new Set(a1.concat(a2)))
}
// intersection: elements in a1 and a2
export function intersection(a1, a2) {
// intersection = new Set([...set1].filter(x => set2.has(x)))
const set2 = new Set(a2)
return a1.filter(x => set2.has(x))
}
// Difference: elements from a1 not in a2
export function difference(a1, a2) {
// difference = new Set([...set1].filter(x => !set2.has(x)))
const set2 = new Set(a2)
return a1.filter(x => !set2.has(x))
}
// Return a "ramp" (array of uniformly ascending/descending floats)
// in [start,stop] with numItems (positive integer > 1).
// OK for start>stop. Will always include start/stop w/in float accuracy.
export function floatRamp(start, stop, numItems) {
// NOTE: start + step*i, where step is (stop-start)/(numItems-1),
// has float accuracy problems, must recalc step each iteration.
if (numItems <= 1) throw Error('floatRamp: numItems must be > 1')
const a = []
for (let i = 0; i < numItems; i++) {
a.push(start + (stop - start) * (i / (numItems - 1)))
}
return a
}
// Integer version of floatRamp, start & stop integers, rounding each element.
// Default numItems yields unit step between start & stop.
export function integerRamp(start, stop, numItems = stop - start + 1) {
return floatRamp(start, stop, numItems).map(a => Math.round(a))
}
// Return an array normalized (lerp) between lo/hi values
// export function normalize(array, lo = 0, hi = 1) {
// const [min, max] = [arrayMin(array), arrayMax(array)]
// const scale = 1 / (max - min)
// return array.map(n => lerp(lo, hi, scale * (n - min)))
// }
// // Return Uint8ClampedArray normalized in 0-255
// export function normalize8(array) {
// return new Uint8ClampedArray(normalize(array, -0.5, 255.5))
// }
// // Return Array normalized to integers in lo-hi
// export function normalizeInt(array, lo, hi) {
// return normalize(array, lo, hi).map(n => Math.round(n))
// }
// // get nested property like obj.foo.bar.baz:
// // const val = nestedProperty(obj, 'foo.bar.baz')
// // Optimized for path length up to 4, else uses path.reduce()
export function nestedProperty(obj, path) {
if (typeof path === 'string') path = path.split('.')
switch (path.length) {
case 1:
return obj[path[0]]
case 2:
return obj[path[0]][path[1]]
case 3:
return obj[path[0]][path[1]][path[2]]
case 4:
return obj[path[0]][path[1]][path[2]][path[3]]
defau