import {Utils} from "../Utils"; export class UIEventSource { private static allSources: UIEventSource[] = UIEventSource.PrepPerf(); public data: T; public trace: boolean; private readonly tag: string; private _callbacks: ((t: T) => (boolean | void | any)) [] = []; constructor(data: T, tag: string = "") { this.tag = tag; this.data = data; if (tag === undefined || tag === "") { const callstack = new Error().stack.split("\n") this.tag = callstack[1] } UIEventSource.allSources.push(this); } static PrepPerf(): UIEventSource[] { if (Utils.runningFromConsole) { return []; } // @ts-ignore window.mapcomplete_performance = () => { console.log(UIEventSource.allSources.length, "uieventsources created"); const copy = [...UIEventSource.allSources]; copy.sort((a, b) => b._callbacks.length - a._callbacks.length); console.log("Topten is:") for (let i = 0; i < 10; i++) { console.log(copy[i].tag, copy[i]); } return UIEventSource.allSources; } return []; } public static flatten(source: UIEventSource>, possibleSources?: UIEventSource[]): UIEventSource { const sink = new UIEventSource(source.data?.data); source.addCallback((latestData) => { sink.setData(latestData?.data); latestData.addCallback(data => { if (source.data !== latestData) { return true; } sink.setData(data) }) }); for (const possibleSource of possibleSources ?? []) { possibleSource?.addCallback(() => { sink.setData(source.data?.data); }) } return sink; } public static Chronic(millis: number, asLong: () => boolean = undefined): UIEventSource { const source = new UIEventSource(undefined); function run() { source.setData(new Date()); if (asLong === undefined || asLong()) { window.setTimeout(run, millis); } } run(); return source; } /** * Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated. * If the promise fails, the value will stay undefined * @param promise * @constructor */ public static FromPromise(promise: Promise): UIEventSource { const src = new UIEventSource(undefined) promise?.then(d => src.setData(d)) promise?.catch(err => console.warn("Promise failed:", err)) return src } /** * Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated. * If the promise fails, the value will stay undefined * @param promise * @constructor */ public static FromPromiseWithErr(promise: Promise): UIEventSource<{ success: T } | { error: any }> { const src = new UIEventSource<{ success: T } | { error: any }>(undefined) promise?.then(d => src.setData({success: d})) promise?.catch(err => src.setData({error: err})) return src } /** * Given a UIEVentSource with a list, returns a new UIEventSource which is only updated if the _contents_ of the list are different. * E.g. * const src = new UIEventSource([1,2,3]) * const stable = UIEventSource.ListStabilized(src) * src.addCallback(_ => console.log("src pinged")) * stable.addCallback(_ => console.log("stable pinged)) * src.setDate([...src.data]) * * This will only trigger 'src pinged' * * @param src * @constructor */ public static ListStabilized(src: UIEventSource): UIEventSource { const stable = new UIEventSource(src.data) src.addCallback(list => { if (list === undefined) { stable.setData(undefined) return; } const oldList = stable.data if (oldList === list) { return; } if (oldList === undefined || oldList.length !== list.length) { stable.setData(list); return; } for (let i = 0; i < list.length; i++) { if (oldList[i] !== list[i]) { stable.setData(list); return; } } // No actual changes, so we don't do anything return; }) return stable } public static asFloat(source: UIEventSource): UIEventSource { return source.map( (str) => { let parsed = parseFloat(str); return isNaN(parsed) ? undefined : parsed; }, [], (fl) => { if (fl === undefined || isNaN(fl)) { return undefined; } return ("" + fl).substr(0, 8); } ) } public AsPromise(condition?: ((t: T )=> boolean)): Promise { const self = this; condition = condition ?? (t => t !== undefined) return new Promise((resolve, reject) => { if (condition(self.data)) { resolve(self.data) } else { self.addCallbackD(data => { resolve(data) return true; // return true to unregister as we only need to be called once }) } }) } public WaitForPromise(promise: Promise, onFail: ((any) => void)): UIEventSource { const self = this; promise?.then(d => self.setData(d)) promise?.catch(err => onFail(err)) return this } public withEqualityStabilized(comparator: (t: T | undefined, t1: T | undefined) => boolean): UIEventSource { let oldValue = undefined; return this.map(v => { if (v == oldValue) { return oldValue } if (comparator(oldValue, v)) { return oldValue } oldValue = v; return v; }) } /** * Adds a callback * * If the result of the callback is 'true', the callback is considered finished and will be removed again * @param callback */ public addCallback(callback: ((latestData: T) => (boolean | void | any))): UIEventSource { if (callback === console.log) { // This ^^^ actually works! throw "Don't add console.log directly as a callback - you'll won't be able to find it afterwards. Wrap it in a lambda instead." } if (this.trace) { console.trace("Added a callback") } this._callbacks.push(callback); return this; } public addCallbackAndRun(callback: ((latestData: T) => (boolean | void | any))): UIEventSource { const doDeleteCallback = callback(this.data); if (doDeleteCallback !== true) { this.addCallback(callback); } return this; } public setData(t: T): UIEventSource { if (this.data == t) { // MUST COMPARE BY REFERENCE! return; } this.data = t; this.ping(); return this; } public ping(): void { let toDelete = undefined let startTime = new Date().getTime() / 1000; for (const callback of this._callbacks) { if (callback(this.data) === true) { // This callback wants to be deleted // Note: it has to return precisely true in order to avoid accidental deletions if (toDelete === undefined) { toDelete = [callback] } else { toDelete.push(callback) } } } let endTime = new Date().getTime() / 1000 if ((endTime - startTime) > 500) { console.trace("Warning: a ping of ", this.tag, " took more then 500ms; this is probably a performance issue") } if (toDelete !== undefined) { for (const toDeleteElement of toDelete) { this._callbacks.splice(this._callbacks.indexOf(toDeleteElement), 1) } } } /** * Monadic bind function */ public bind(f: ((t: T) => UIEventSource)): UIEventSource { const mapped = this.map(f) const sink = new UIEventSource(undefined) const seenEventSources = new Set>(); mapped.addCallbackAndRun(newEventSource => { if (newEventSource === null) { sink.setData(null) } else if (newEventSource === undefined) { sink.setData(undefined) } else if (!seenEventSources.has(newEventSource)) { seenEventSources.add(newEventSource) newEventSource.addCallbackAndRun(resultData => { if (mapped.data === newEventSource) { sink.setData(resultData); } }) } else { // Already seen, so we don't have to add a callback, just update the value sink.setData(newEventSource.data) } }) return sink; } /** * Monoidal map: * Given a function 'f', will construct a new UIEventSource where the contents will always be "f(this.data)' * @param f: The transforming function * @param extraSources: also trigger the update if one of these sources change * @param g: a 'backfunction to let the sync run in two directions. (data of the new UIEVEntSource, currentData) => newData * @param allowUnregister: if set, the update will be halted if no listeners are registered */ public map(f: ((t: T) => J), extraSources: UIEventSource[] = [], g: ((j: J, t: T) => T) = undefined, allowUnregister = false): UIEventSource { const self = this; const stack = new Error().stack.split("\n"); const callee = stack[1] const newSource = new UIEventSource( f(this.data), "map(" + this.tag + ")@" + callee ); const update = function () { newSource.setData(f(self.data)); return allowUnregister && newSource._callbacks.length === 0 } this.addCallback(update); for (const extraSource of extraSources) { extraSource?.addCallback(update); } if (g !== undefined) { newSource.addCallback((latest) => { self.setData(g(latest, self.data)); }) } return newSource; } public syncWith(otherSource: UIEventSource, reverseOverride = false): UIEventSource { this.addCallback((latest) => otherSource.setData(latest)); const self = this; otherSource.addCallback((latest) => self.setData(latest)); if (reverseOverride) { if(otherSource.data !== undefined){ this.setData(otherSource.data); } } else if (this.data === undefined) { this.setData(otherSource.data); } else { otherSource.setData(this.data); } return this; } public stabilized(millisToStabilize): UIEventSource { if (Utils.runningFromConsole) { return this; } const newSource = new UIEventSource(this.data); let currentCallback = 0; this.addCallback(latestData => { currentCallback++; const thisCallback = currentCallback; window.setTimeout(() => { if (thisCallback === currentCallback) { newSource.setData(latestData); } }, millisToStabilize) }); return newSource; } addCallbackAndRunD(callback: (data: T) => void) { this.addCallbackAndRun(data => { if (data !== undefined && data !== null) { return callback(data) } }) } addCallbackD(callback: (data: T) => void) { this.addCallback(data => { if (data !== undefined && data !== null) { return callback(data) } }) } }