iwmlib/lib/utils.js

1025 lines
28 KiB
JavaScript
Executable File

/* globals */
/** Tests whether an object is empty
* @param {Object} obj - the object to be tested
* @return {boolean}
*/
export function isEmpty(obj) {
// > isEmpty({})
// true
for (let i in obj) {
return false
}
return true
}
/** Returns a universal unique id
* @return {string}
* See https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript/21963136#21963136
*/
export function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
let r = (Math.random() * 16) | 0,
v = c == 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
export function lerp(start, stop, amt) {
return amt * (stop - start) + start
}
export function sample(population, k) {
/*
From https://stackoverflow.com/questions/19269545/how-to-get-n-no-elements-randomly-from-an-array/38571132
Chooses k unique random elements from a population sequence or set.
Returns a new list containing elements from the population while
leaving the original population unchanged. The resulting list is
in selection order so that all sub-slices will also be valid random
samples. This allows raffle winners (the sample) to be partitioned
into grand prize and second place winners (the subslices).
Members of the population need not be hashable or unique. If the
population contains repeats, then each occurrence is a possible
selection in the sample.
To choose a sample in a range of integers, use range as an argument.
This is especially fast and space efficient for sampling from a
large population: sample(range(10000000), 60)
Sampling without replacement entails tracking either potential
selections (the pool) in a list or previous selections in a set.
When the number of selections is small compared to the
population, then tracking selections is efficient, requiring
only a small set and an occasional reselection. For
a larger number of selections, the pool tracking method is
preferred since the list takes less space than the
set and it doesn't suffer from frequent reselections.
*/
if (!Array.isArray(population)) throw new TypeError('Population must be an array.')
let n = population.length
if (k < 0 || k > n) throw new RangeError('Sample larger than population or is negative')
let result = new Array(k)
let setsize = 21 // size of a small set minus size of an empty list
if (k > 5) setsize += Math.pow(4, Math.ceil(Math.log(k * 3, 4)))
if (n <= setsize) {
// An n-length list is smaller than a k-length set
let pool = population.slice()
for (let i = 0; i < k; i++) {
// inletiant: non-selected at [0,n-i)
let j = (Math.random() * (n - i)) | 0
result[i] = pool[j]
pool[j] = pool[n - i - 1] // move non-selected item into vacancy
}
} else {
let selected = new Set()
for (let i = 0; i < k; i++) {
let j = (Math.random() * (n - i)) | 0
while (selected.has(j)) {
j = (Math.random() * (n - i)) | 0
}
selected.add(j)
result[i] = population[j]
}
}
return result
}
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
// Taken from: https://davidwalsh.name/essential-javascript-functions
export function debounce(func, wait, immediate) {
let timeout
return function() {
let context = this,
args = arguments
let later = function() {
timeout = null
if (!immediate) func.apply(context, args)
}
let callNow = immediate && !timeout
clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) func.apply(context, args)
}
}
/** Returns an id that is guaranteed to be unique within the livetime of the
* application
* @return {string}
*/
let _idGenerator = 0
export function getId() {
return 'id' + _idGenerator++
}
export function randomInt(min = 0, max = 100) {
return Math.floor(Math.random() * (max - min + 1) + min)
}
export function randomFloat(min = 0.0, max = 1.0) {
return Math.random() * (max - min) + min
}
export class Dates {
static create(fullYear, month, day) {
return new Date(Date.UTC(fullYear, month, day))
}
static daysInMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
}
static startYearRange(date) {
return new Date(Date.UTC(date.getFullYear() - 1, 11, 31, 23, 59, 59, 999))
}
static endYearRange(date) {
return new Date(Date.UTC(date.getFullYear() + 1, 0, 1))
}
static prevYear(date, offset = 1) {
return this.create(date.getFullYear() - offset, 0, 1)
}
static nextYear(date, offset = 1) {
return this.create(date.getFullYear() + offset, 0, 1)
}
static nextMonth(date) {
return this.create(date.getFullYear(), date.getMonth() + 1, 1)
}
static nextDay(date) {
return this.create(date.getFullYear(), date.getMonth(), date.getDate() + 1)
}
static nextHour(date) {
// See http://stackoverflow.com/questions/1050720/adding-hours-to-javascript-date-object
return new Date(date.getTime() + 60 * 60 * 1000)
}
static nextMinute(date) {
// See above
return new Date(date.getTime() + 60 * 1000)
}
static nextSecond(date) {
// See above
return new Date(date.getTime() + 1000)
}
static nextMillisecond(date) {
// See above
return new Date(date.getTime() + 1)
}
static *iterYears(start, end) {
let date = this.create(start.getFullYear(), 0, 1)
while (date <= end) {
yield date
date = this.nextYear(date)
}
yield date
}
static *iterMonths(year, limit = 12) {
let month = 0
while (month < limit) {
let date = this.create(year.getFullYear(), month, 1)
yield date
month += 1
}
}
static *iterMonthsOfYears(years) {
for (let year of years) {
for (let month of this.iterMonths(year)) {
yield month
}
}
}
static *iterDays(month) {
let day = 1
let limit = Dates.daysInMonth(month)
while (day <= limit) {
let date = this.create(month.getFullYear(), month.getMonth(), day)
yield date
day += 1
}
}
static *iterDaysOfMonths(months) {
for (let month of months) {
for (let day of this.iterDays(month)) {
yield day
}
}
}
}
/* Color conversion functions */
export class Colors {
// http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
static rgb2num(red, green, blue) {
let rgb = blue | (green << 8) | (red << 16)
return 0x000000 + rgb
}
static rgb2hex(red, green, blue) {
let rgb = blue | (green << 8) | (red << 16)
return '#' + (0x1000000 + rgb).toString(16).slice(1)
}
static hex2rgb(hex) {
// long version
let r = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
if (r) {
return r.slice(1, 4).map(x => {
return parseInt(x, 16)
})
}
// short version
r = hex.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i)
if (r) {
return r.slice(1, 4).map(x => {
return 0x11 * parseInt(x, 16)
})
}
return null
}
static rgb(r, g, b) {
return { r, g, b }
}
static string2hex(str) {
return parseInt('0x' + str.slice(1))
}
static lerp(rgb1, rgb2, amount) {
return {
r: Math.round(lerp(rgb1.r, rgb2.r, amount)),
g: Math.round(lerp(rgb1.g, rgb2.g, amount)),
b: Math.round(lerp(rgb1.b, rgb2.b, amount))
}
}
static get violet() {
return Colors.rgb2num(89, 34, 131)
}
static get steelblue() {
return Colors.rgb2num(0, 130, 164)
}
static get ochre() {
return Colors.rgb2num(181, 157, 0)
}
static get turquoise() {
return Colors.rgb2num(34, 164, 131)
}
static get eminence() {
return Colors.rgb2num(150, 60, 134)
}
static random() {
let r = Math.round(Math.random() * 255)
let g = Math.round(Math.random() * 255)
let b = Math.round(Math.random() * 255)
return Colors.rgb2num(r, g, b)
}
}
export class Cycle extends Array {
constructor(...items) {
super()
for (let item of items) {
this.push(item)
}
this.index = 0
}
next() {
if (this.index == this.length) {
this.index = 0
}
return this[this.index++]
}
current() {
if (this.index === this.length) {
this.index = 0
}
return this[this.index]
}
}
/** Static methods to compute 2D points with x and y coordinates.
*/
export class Points {
static length(a) {
return Math.sqrt(a.x * a.x + a.y * a.y)
}
static normalize(p) {
let len = this.length(p)
return this.multiplyScalar(p, 1 / len)
}
static mean(a, b) {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
}
static subtract(a, b) {
return { x: a.x - b.x, y: a.y - b.y }
}
static multiply(a, b) {
return { x: a.x * b.x, y: a.y * b.y }
}
static divide(a, b) {
return { x: a.x / b.x, y: a.y / b.y }
}
static multiplyScalar(a, b) {
return { x: a.x * b, y: a.y * b }
}
static add(a, b) {
return { x: a.x + b.x, y: a.y + b.y }
}
static negate(p) {
return { x: -p.x, y: -p.y }
}
static angle(p1, p2) {
return Math.atan2(p1.y - p2.y, p1.x - p2.x)
}
static normalizedAngle(p1, p2) {
return Angle.normalize(this.angle(p1, p2))
}
static normalized2Angle(p1, p2) {
return Angle.normalize2(this.angle(p1, p2))
}
static arc(p, alpha, radius) {
return {
x: p.x + radius * Math.cos(alpha),
y: p.y + radius * Math.sin(alpha)
}
}
static distance(a, b) {
let dx = a.x - b.x
let dy = a.y - b.y
return Math.sqrt(dx * dx + dy * dy)
}
// Distance == 0.0 indicates an inside relation.
static distanceToRect(p, r) {
let dx = 0
let dy = 0
if (p.x < r.x) dx = p.x - r.x
else if (p.x > r.x + r.width) dx = p.x - (r.x + r.width)
if (p.y < r.y) dy = p.y - r.y
else if (p.y > r.y + r.height) dy = p.y - (r.y + r.height)
return Math.sqrt(dx * dx + dy * dy)
/* let cx = Math.max(Math.min(p.x, r.x + r.width), r.x)
let cy = Math.max(Math.min(p.y, r.y + r.height), r.y)
let result = Math.sqrt((p.x - cx) * (p.x - cx) + (p.y - cy) * (p.y - cy))
console.log("distanceToRect", p, r, result)
return result */
}
static fromPageToNode(element, p) {
// if (window.webkitConvertPointFromPageToNode) {
// return window.webkitConvertPointFromPageToNode(element,
// new WebKitPoint(p.x, p.y))
// }
return window.convertPointFromPageToNode(element, p.x, p.y)
}
static fromNodeToPage(element, p) {
// if (window.webkitConvertPointFromNodeToPage) {
// return window.webkitConvertPointFromNodeToPage(element,
// new WebKitPoint(p.x, p.y))
// }
return window.convertPointFromNodeToPage(element, p.x, p.y)
}
}
/**
* A helper class for common set operations.
*
* @export
* @class Sets
*/
export class Sets {
/**
* Returns the intersection of all sets
* https://stackoverflow.com/questions/31930894/javascript-set-data-structure-intersect
* @static
* @param {*} sets
* @returns
* @memberof Sets
*/
static intersect(...sets) {
if (!sets.length) return new Set()
const i = sets.reduce((m, s, i) => (s.size < sets[m].size ? i : m), 0)
const [smallest] = sets.splice(i, 1)
const res = new Set()
for (let val of smallest) if (sets.every(s => s.has(val))) res.add(val)
return res
}
/**
* Returns the union of all sets
*
* @static
* @param {*} sets
* @returns
* @memberof Sets
*/
static union(...sets) {
let result = new Set()
for (let set of sets) {
for (let m of set) {
result.add(m)
}
}
return result
}
/**
* Returns the difference of the given sets. Starts with the first set and removing all elements of the following sets.
*
* @static
* @param {*} set
* @param {*} sets
* @returns
* @memberof Sets
*/
static difference(set, ...sets) {
let result = new Set()
for (let m of set) {
result.add(m)
}
for (let s of sets) {
for (let m of s) {
result.delete(m)
}
}
return result
}
}
/** Static methods to compute angles.
*/
export class Angle {
static normalize(angle) {
let TAU = Math.PI * 2.0
while (angle > Math.PI) {
angle -= TAU
}
while (angle < -Math.PI) {
angle += TAU
}
return angle
}
static normalize2(angle) {
let TAU = Math.PI * 2.0
while (angle > TAU) {
angle -= TAU
}
while (angle < 0) {
angle += TAU
}
return angle
}
static normalizeDegree(angle) {
let full = 360.0
while (angle > 180.0) {
angle -= full
}
while (angle < -180.0) {
angle += full
}
return angle
}
static normalizedDiff(a, b) {
return this.normalize(this.diff(a, b))
}
static normalized2Diff(a, b) {
return this.normalize2(this.diff(a, b))
}
static diff(a, b) {
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
}
static degree2radian(degree) {
return (Math.PI * degree) / 180.0
}
static radian2degree(rad) {
return (180.0 / Math.PI) * rad
}
}
export class Elements {
static setStyle(element, styles) {
for (let key in styles) {
element.style[key] = styles[key]
}
}
static addClass(element, cssClass) {
element.classList.add(cssClass)
}
static removeClass(element, cssClass) {
element.classList.remove(cssClass)
}
static toggleClass(element, cssClass) {
element.classList.toggle(cssClass)
}
static hasClass(element, cssClass) {
return element.classList.contains(cssClass)
}
}
export class MapProxy {
/* This class is needed if we want to use the interaction classes
in Firefox 45.8 and modern Browsers.
A workaround for https://github.com/babel/babel/issues/2334
*/
constructor() {
this.map = new Map()
}
get size() {
return this.map.size
}
get(key) {
return this.map.get(key)
}
set(key, value) {
return this.map.set(key, value)
}
delete(key) {
return this.map.delete(key)
}
clear() {
return this.map.clear()
}
has(key) {
return this.map.has(key)
}
keys() {
return this.map.keys()
}
values() {
return this.map.values()
}
entries() {
return this.map.entries()
}
forEach(func) {
this.map.forEach(func)
}
}
/* Based om https://gist.github.com/cwleonard/e124d63238bda7a3cbfa */
export class Polygon {
/*
* This is the Polygon constructor. All points are center-relative.
*/
constructor(center) {
this.points = new Array()
this.center = center
}
/*
* Point x and y values should be relative to the center.
*/
addPoint(p) {
this.points.push(p)
}
/*
* Point x and y values should be absolute coordinates.
*/
addAbsolutePoint(p) {
this.points.push({ x: p.x - this.center.x, y: p.y - this.center.y })
}
/*
* Returns the number of sides. Equal to the number of vertices.
*/
getNumberOfSides() {
return this.points.length
}
/*
* rotate the polygon by a number of radians
*/
rotate(rads) {
for (let i = 0; i < this.points.length; i++) {
let x = this.points[i].x
let y = this.points[i].y
this.points[i].x = Math.cos(rads) * x - Math.sin(rads) * y
this.points[i].y = Math.sin(rads) * x + Math.cos(rads) * y
}
}
/*
* The draw function takes as a parameter a Context object from
* a Canvas element and draws the polygon on it.
*/
draw(context, { lineWidth = 2, stroke = '#000000', fill = null } = {}) {
context.beginPath()
context.moveTo(this.points[0].x + this.center.x, this.points[0].y + this.center.y)
for (let i = 1; i < this.points.length; i++) {
context.lineTo(this.points[i].x + this.center.x, this.points[i].y + this.center.y)
}
context.closePath()
context.lineWidth = lineWidth
if (stroke) {
context.strokeStyle = stroke
context.stroke()
}
if (fill) {
context.fillStyle = fill
context.fill()
}
}
absolutePoints() {
let result = new Array()
for (let p of this.points) {
result.push(Points.add(p, this.center))
}
return result
}
flatAbsolutePoints() {
let result = new Array()
for (let p of this.points) {
let a = Points.add(p, this.center)
result.push(a.x)
result.push(a.y)
}
return result
}
/*
* This function returns true if the given point is inside the polygon,
* and false otherwise.
*/
containsPoint(pnt) {
let nvert = this.points.length
let testx = pnt.x
let testy = pnt.y
let vertx = new Array()
for (let q = 0; q < this.points.length; q++) {
vertx.push(this.points[q].x + this.center.x)
}
let verty = new Array()
for (let w = 0; w < this.points.length; w++) {
verty.push(this.points[w].y + this.center.y)
}
let i,
j = 0
let c = false
for (i = 0, j = nvert - 1; i < nvert; j = i++) {
if (
verty[i] > testy != verty[j] > testy &&
testx < ((vertx[j] - vertx[i]) * (testy - verty[i])) / (verty[j] - verty[i]) + vertx[i]
)
c = !c
}
return c
}
multiplyScalar(scale) {
let center = Points.multiplyScalar(this.center, scale)
let clone = new Polygon(center)
for (let p of this.points) {
clone.addPoint(Points.multiplyScalar(p, scale))
}
return clone
}
/*
* To detect intersection with another Polygon object, this
* function uses the Separating Axis Theorem. It returns false
* if there is no intersection, or an object if there is. The object
* contains 2 fields, overlap and axis. Moving the polygon by overlap
* on axis will get the polygons out of intersection.
*/
intersectsWith(other) {
let axis = { x: 0, y: 0 }
let tmp, minA, maxA, minB, maxB
let side, i
let smallest = null
let overlap = 99999999
/* test polygon A's sides */
for (side = 0; side < this.getNumberOfSides(); side++) {
/* get the axis that we will project onto */
if (side == 0) {
axis.x = this.points[this.getNumberOfSides() - 1].y - this.points[0].y
axis.y = this.points[0].x - this.points[this.getNumberOfSides() - 1].x
} else {
axis.x = this.points[side - 1].y - this.points[side].y
axis.y = this.points[side].x - this.points[side - 1].x
}
/* normalize the axis */
tmp = Math.sqrt(axis.x * axis.x + axis.y * axis.y)
axis.x /= tmp
axis.y /= tmp
/* project polygon A onto axis to determine the min/max */
minA = maxA = this.points[0].x * axis.x + this.points[0].y * axis.y
for (i = 1; i < this.getNumberOfSides(); i++) {
tmp = this.points[i].x * axis.x + this.points[i].y * axis.y
if (tmp > maxA) maxA = tmp
else if (tmp < minA) minA = tmp
}
/* correct for offset */
tmp = this.center.x * axis.x + this.center.y * axis.y
minA += tmp
maxA += tmp
/* project polygon B onto axis to determine the min/max */
minB = maxB = other.points[0].x * axis.x + other.points[0].y * axis.y
for (i = 1; i < other.getNumberOfSides(); i++) {
tmp = other.points[i].x * axis.x + other.points[i].y * axis.y
if (tmp > maxB) maxB = tmp
else if (tmp < minB) minB = tmp
}
/* correct for offset */
tmp = other.center.x * axis.x + other.center.y * axis.y
minB += tmp
maxB += tmp
/* test if lines intersect, if not, return false */
if (maxA < minB || minA > maxB) {
return false
} else {
let o = maxA > maxB ? maxB - minA : maxA - minB
if (o < overlap) {
overlap = o
smallest = { x: axis.x, y: axis.y }
}
}
}
/* test polygon B's sides */
for (side = 0; side < other.getNumberOfSides(); side++) {
/* get the axis that we will project onto */
if (side == 0) {
axis.x = other.points[other.getNumberOfSides() - 1].y - other.points[0].y
axis.y = other.points[0].x - other.points[other.getNumberOfSides() - 1].x
} else {
axis.x = other.points[side - 1].y - other.points[side].y
axis.y = other.points[side].x - other.points[side - 1].x
}
/* normalize the axis */
tmp = Math.sqrt(axis.x * axis.x + axis.y * axis.y)
axis.x /= tmp
axis.y /= tmp
/* project polygon A onto axis to determine the min/max */
minA = maxA = this.points[0].x * axis.x + this.points[0].y * axis.y
for (i = 1; i < this.getNumberOfSides(); i++) {
tmp = this.points[i].x * axis.x + this.points[i].y * axis.y
if (tmp > maxA) maxA = tmp
else if (tmp < minA) minA = tmp
}
/* correct for offset */
tmp = this.center.x * axis.x + this.center.y * axis.y
minA += tmp
maxA += tmp
/* project polygon B onto axis to determine the min/max */
minB = maxB = other.points[0].x * axis.x + other.points[0].y * axis.y
for (i = 1; i < other.getNumberOfSides(); i++) {
tmp = other.points[i].x * axis.x + other.points[i].y * axis.y
if (tmp > maxB) maxB = tmp
else if (tmp < minB) minB = tmp
}
/* correct for offset */
tmp = other.center.x * axis.x + other.center.y * axis.y
minB += tmp
maxB += tmp
/* test if lines intersect, if not, return false */
if (maxA < minB || minA > maxB) {
return false
} else {
let o = maxA > maxB ? maxB - minA : maxA - minB
if (o < overlap) {
overlap = o
smallest = { x: axis.x, y: axis.y }
}
}
}
return { overlap: overlap + 0.001, axis: smallest }
}
static fromPoints(points) {
let min = { x: Number.MAX_VALUE, y: Number.MAX_VALUE }
let max = { x: Number.MIN_VALUE, y: Number.MIN_VALUE }
for (let p of points) {
min.x = Math.min(p.x, min.x)
max.x = Math.max(p.x, max.x)
min.y = Math.min(p.y, min.y)
max.y = Math.max(p.y, max.y)
}
let center = Points.mean(min, max)
let polygon = new Polygon(center)
for (let p of points) {
polygon.addAbsolutePoint(p)
}
return polygon
}
}
/**
* Util functions to deal with DOMRects.
*/
export class Rect {
/**
* Test if a given point is contained by the provided Rect.
*
* @static
* @param {DOMRect} rect - Rectangle to check the collision with.
* @param {Point} point - Point that should be tested.
* @returns {boolean} - True if point is inside of rect, otherwise false.
* @memberof Rect
*/
static contains(rect, point) {
return point.x > rect.left && point.x < rect.x + rect.right && point.y > rect.top && point.y < rect.bottom
}
/**
*Returns the position of an rect as point object.
*
* @static
* @param {Rect} rect - The rectangle we want to get the position from.
* @returns {Point} - Returns the position as Point.
* @memberof Rect
*/
static getPosition(rect) {
return { x: rect.x, y: rect.y }
}
}
/** String utility functions */
export class Strings {
static toUpperCaseFirstChar(str) {
return str.substr(0, 1).toUpperCase() + str.substr(1)
}
static toLowerCaseFirstChar(str) {
return str.substr(0, 1).toLowerCase() + str.substr(1)
}
static toUpperCaseEachWord(str, delim = ' ') {
return str
.split(delim)
.map(v => v.toUpperCaseFirstChar())
.join(delim)
}
static toLowerCaseEachWord(str, delim = ' ') {
return str
.split(delim)
.map(v => v.toLowerCaseFirstChar())
.join(delim)
}
}
export class LowPassFilter {
constructor(smoothing = 0.5, bufferMaxSize = 10) {
this.smoothing = smoothing // must be smaller than 1
this.buffer = [] // FIFO queue
this.bufferMaxSize = bufferMaxSize
}
/**
* Setup buffer with array of values
*
* @param {array} values
* @returns {array}
* @access public
*/
setup(values) {
for (let i = 0; i < values.length; i++) {
this.__push(values[i])
}
return this.buffer
}
/**
* Clear buffer to prepare for new values.
*
* @access public
*/
clear() {
this.buffer = []
}
/**
* Add new value to buffer (FIFO queue)
*
* @param {integer|float} value
* @returns {integer|float}
* @access private
*/
__push(value) {
let removed = this.buffer.length === this.bufferMaxSize ? this.buffer.shift() : 0
this.buffer.push(value)
return removed
}
/**
* Smooth value from stream
*
* @param {integer|float} nextValue
* @returns {integer|float}
* @access public
*/
next(nextValue) {
// push new value to the end, and remove oldest one
let removed = this.__push(nextValue)
// smooth value using all values from buffer
let result = this.buffer.reduce((last, current) => {
return this.smoothing * current + (1 - this.smoothing) * last
}, removed)
// replace smoothed value
this.buffer[this.buffer.length - 1] = result
return result
}
/**
* Smooth array of values
*
* @param {array} values
* @returns {undefined}
* @access public
*/
smoothArray(values) {
let value = values[0]
for (let i = 1; i < values.length; i++) {
let currentValue = values[i]
value += (currentValue - value) * this.smoothing
values[i] = Math.round(value)
}
return values
}
}