UNPKG

peepee

Version:

Visual Programming Language Where You Connect Ports Of One EventEmitter to Ports Of Another EventEmitter

385 lines (339 loc) 16 kB
export const manifest = { map:{ key: "map", name: "Map", description: "Transforms each value emitted by the source signal using a provided function", properties: [{ key: "predicate", name: "Map Function Predicate", description: "Function to transform each emitted value", ports: [{ key: "fn" }] }], inputs: [{ key: "source", name: "Source Signal", description: "Input signal to transform" }], outputs: [{ key: "result", name: "Mapped Signal", description: "Signal with transformed values" }], }, combineLatest:{ key: "combineLatest", name: "Combine Latest", description: "Combines the latest values from multiple signals into a single signal", properties: [], inputs: [{ key: "signals", name: "Source Signals", description: "Array of signals to combine" }], outputs: [{ key: "result", name: "Combined Signal", description: "Signal emitting array of latest values from all input signals" }], }, fromEvent:{ key: "fromEvent", name: "From Event", description: "Creates a signal from DOM events on a specified element", properties: [ { key: "element", name: "HTML Element", description: "DOM element to listen for events on", ports: [{ key: "el" }] }, { key: "event", name: "Event Name", description: 'Name of the DOM event to listen for (e.g., "click", "input")', ports: [{ key: "name" }] }, ], inputs: [], outputs: [{ key: "result", name: "Event Signal", description: "Signal that emits DOM event objects when the specified event occurs" }], }, toTextContentOf:{ key: "toTextContentOf", name: "To textContent Of", description: "Updates the textContent of an HTML element whenever the source signal emits a value", properties: [{ key: "element", name: "HTML Element", description: "DOM element whose textContent will be updated", ports: [{ key: "el" }] }], inputs: [{ key: "signal", name: "Source Signal", description: "Signal whose values will be displayed as textContent in the target element" }], outputs: [], }, toSignal:{ key: "toSignal", name: "To Signal", description: "Forwards values from a source signal to a destination signal, creating a signal bridge", properties: [], inputs: [ { key: "source", name: "Source Signal", description: "Signal to read values from" }, { key: "destination", name: "Destination Signal", description: "Signal to forward values to" }, ], outputs: [{ key: "result", name: "Destination Signal Reference", description: "Reference to the destination signal for chaining" }], }, filter:{ key: "filter", name: "Filter", description: "Emits only those values from the source signal that pass a predicate test", properties: [{ key: "predicate", name: "Filter Predicate", description: "Function that returns true for values to keep", ports: [{ key: "fn" }] }], inputs: [{ key: "source", name: "Source Signal", description: "Input signal to filter" }], outputs: [{ key: "result", name: "Filtered Signal", description: "Signal containing only values that passed the predicate test" }], }, debounceTime:{ key: "debounceTime", name: "Debounce Time", description: "Delays emissions from the source signal until a specified time has passed without another emission", properties: [{ key: "delay", name: "Delay (ms)", description: "Time in milliseconds to wait before emitting", ports: [{ key: "ms" }] }], inputs: [{ key: "source", name: "Source Signal", description: "Input signal to debounce" }], outputs: [{ key: "result", name: "Debounced Signal", description: "Signal with debounced emissions" }], }, distinctUntilChanged:{ key: "distinctUntilChanged", name: "Distinct Until Changed", description: "Only emits when the current value is different from the previous value", properties: [], inputs: [{ key: "source", name: "Source Signal", description: "Input signal to filter for distinct values" }], outputs: [{ key: "result", name: "Distinct Signal", description: "Signal that only emits when values change" }], }, startWith:{ key: "startWith", name: "Start With", description: "Emits specified initial values before emitting values from the source signal", properties: [{ key: "initialValue", name: "Initial Value", description: "Value to emit first", ports: [{ key: "value" }] }], inputs: [{ key: "source", name: "Source Signal", description: "Signal to prepend initial value to" }], outputs: [{ key: "result", name: "Signal With Initial Value", description: "Signal that starts with the initial value" }], }, scan:{ key: "scan", name: "Scan", description: "Applies an accumulator function to each value and emits the accumulated result", properties: [ { key: "accumulator", name: "Accumulator Function", description: "Function to accumulate values (acc, current) => newAcc", ports: [{ key: "fn" }] }, { key: "initialValue", name: "Initial Value", description: "Starting value for accumulation", ports: [{ key: "seed" }] }, ], inputs: [{ key: "source", name: "Source Signal", description: "Input signal to accumulate" }], outputs: [{ key: "result", name: "Accumulated Signal", description: "Signal emitting accumulated values" }], }, merge:{ key: "merge", name: "Merge", description: "Combines multiple signals into one by emitting values from any source signal as they arrive", properties: [], inputs: [{ key: "signals", name: "Source Signals", description: "Array of signals to merge" }], outputs: [{ key: "result", name: "Merged Signal", description: "Signal emitting values from all input signals" }], }, switchMap:{ key: "switchMap", name: "Switch Map", description: "Maps each value to a new signal and switches to the latest inner signal, cancelling previous ones", properties: [{ key: "project", name: "Project Function", description: "Function that maps values to signals", ports: [{ key: "fn" }] }], inputs: [{ key: "source", name: "Source Signal", description: "Input signal to switch map" }], outputs: [{ key: "result", name: "Switched Signal", description: "Signal from the latest projected inner signal" }], }, take:{ key: "take", name: "Take", description: "Emits only the first n values from the source signal, then completes", properties: [{ key: "count", name: "Count", description: "Number of values to take", ports: [{ key: "n" }] }], inputs: [{ key: "source", name: "Source Signal", description: "Input signal to take values from" }], outputs: [{ key: "result", name: "Limited Signal", description: "Signal containing only the first n values" }], }, tap:{ key: "tap", name: "Tap", description: "Performs side effects with each emitted value without modifying the signal stream", properties: [{ key: "sideEffect", name: "Side Effect Function", description: "Function to execute for each value (for debugging/logging)", ports: [{ key: "fn" }] }], inputs: [{ key: "source", name: "Source Signal", description: "Input signal to tap into" }], outputs: [{ key: "result", name: "Tapped Signal", description: "Original signal passed through unchanged" }], }, fromValue:{ key: "fromValue", name: "From Value", description: "Creates a signal that emits a single static value", properties: [{ key: "value", name: "Static Value", description: "Value to emit", ports: [{ key: "val" }] }], inputs: [], outputs: [{ key: "result", name: "Value Signal", description: "Signal that emits the specified value" }], }, interval:{ key: "interval", name: "Interval", description: "Creates a signal that emits sequential numbers at specified time intervals", properties: [{ key: "period", name: "Interval (ms)", description: "Time between emissions in milliseconds", ports: [{ key: "ms" }] }], inputs: [], outputs: [{ key: "result", name: "Interval Signal", description: "Signal emitting incremental numbers at regular intervals" }], }, }; let id = 1; function generateId() { // const randomChars = (length = 8) => Array.from({ length }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join(""); // return `${randomChars()}-${randomChars(4)}-${randomChars(4)}-${randomChars(4)}-${randomChars(12)}`; return 'id'+id++; } class Graph { #nodes = new Map(); #edges = new Map(); add(id, node, label = "unnamed") { const data = { label, node }; this.#nodes.set(id, data); return () => this.remove(id); } remove(id) { this.#nodes.delete(id); } connect(from, to, label = "relation") { if (from == null) throw new Error(`from may not be nullish`); if (to == null) throw new Error(`to may not be nullish`); const id = generateId(); const data = { from, to, label }; this.#edges.set(id, data); return () => this.disconnect(from, to, label); } disconnect(from, to, label = "relation") { const id = `${label}::${from}::${to}`; this.#edges.delete(id); } } const graph = new Graph(); globalThis.signalGraph = graph; export class Pulse { #id; #value; #subscribers; #disposables; constructor(value, {id, label}={label:'unlabeled'}) { this.#id = id??generateId(); this.#value = value; this.#subscribers = new Set(); this.#disposables = new Set(); graph.add(this.#id, this, label + ':' + this.#id); } get id(){ return this.#id} get value() { return this.#value; } set value(newValue) { if (newValue == this.#value) return; // IMPORTANT FEATURE: if value is the same, exit early, don't disturb if you don't need to this.#value = newValue; this.notify(); // all observers } subscribe(subscriber) { if (this.#value != null) subscriber(this.#value); // IMPORTANT FEATURE: instant notification (initialization on subscribe), but don't notify on null/undefined, predicate functions will look simpler, less error prone this.#subscribers.add(subscriber); return () => this.#subscribers.delete(subscriber); // IMPORTANT FEATURE: return unsubscribe function, execute this to stop getting notifications. } notify() { for (const subscriber of this.#subscribers) subscriber(this.#value); } clear() { // shutdown procedure this.#subscribers.clear(); // destroy subscribers this.#disposables.forEach((disposable) => disposable()); this.#disposables.clear(); // execute and clear disposables graph.remove(this.#id); } // add related trash that makes sense to clean when the signal is shutdown collect(...input) { [input].flat(Infinity).forEach((disposable) => this.#disposables.add(disposable)); } [Symbol.toPrimitive](hint) { if (hint === "string") { return this.#id; } else if (hint === "number") { return 0; } return this.#id; // default case } } export class Signal extends Pulse { map(fn) { return map(this, fn) } filter(fn) { return filter(this, fn) } combineLatest(...signals) { return combineLatest(this, ...signals) } // flat(...signals) { return combineLatest(this, ...signals) } switchMap(mapperFn) { return switchMap(this, mapperFn) } scan(reducerFn, initialValue) { return scan(this, reducerFn, initialValue) } reduce(reducerFn, initialValue) { return reduce(this, reducerFn, initialValue) } debounce(ms) {} delay(ms) {} throttle(ms) {} merge(signal) {} // NOTE: to* methods return subscriptions not signals toInnerTextOf(el) { return toInnerTextOf(this, el); } toSignal(destination) { return toSignal(this, destination); } } // THIS IS THE MAP FUNCTION, it can be used standalone as map(usernameSignal, v=>`Hello ${v}`), // but it looks nicer when you use the method: usernameSignal.map(v=>`Hello ${v}`).subscribe(v=>console.log(v)) export function filter(parent, test) { const child = new Signal(undefined, {name: manifest.filter.name}); const subscription = parent.subscribe((v) => { if (test(v)) { child.value = v; } }); child.collect(subscription); child.collect(graph.connect(parent.id, child.id, "filter")); return child; } export function map(parent, map) { const child = new Signal(undefined, {name: manifest.map.name}); const subscription = parent.subscribe((v) => (child.value = map(v))); child.collect(subscription); child.collect(graph.connect(parent.id, child.id, "map")); return child; } export function scan(parent, reducer, initialValue) { const child = new Signal(initialValue, { name: manifest.scan.name }); const subscription = parent.subscribe((v) => { // console.log('ggg g' , v); child.value = v.reduce(reducer, initialValue); }); child.collect(subscription); child.collect(graph.connect(parent.id, child.id, "scan")); return child; } export function reduce(parent, reducer, initialValue) { const child = new Signal(initialValue, { name: manifest.scan.name }); const subscription = parent.subscribe((v) => { child.value = v.reduce(reducer, initialValue); }); child.collect(subscription); child.collect(graph.connect(parent.id, child.id, "scan")); return child; } export function combineLatest(...parents) { const child = new Signal(undefined,{name: manifest.combineLatest.name}); const updateCombinedValue = () => { const values = [...parents.map((signal) => signal.value)]; const nullish = values.some((value) => value == null); if (!nullish) child.value = values; }; const subscriptions = parents.map((signal) => signal.subscribe(updateCombinedValue)); child.collect(subscriptions); child.collect(parents.map((parent) => graph.connect(parent.id, child.id, "combineLatest"))); return child; } export function switchMap(parent, mapper) { const child = new Signal(undefined, { name: manifest.switchMap.name }); let innerSubscription = null; const parentSubscription = parent.subscribe((v) => { if (innerSubscription) innerSubscription(); // On data from parent Unsubscribe from the previous innerSubscription if it exists const newSignal = mapper(v); // Map the value to a new signal innerSubscription = newSignal.subscribe((newValue) => child.value = newValue ); // Subscribe to the new signal }); child.collect(innerSubscription); // clean the final one on terminate child.collect(parentSubscription); child.collect(graph.connect(parent.id, child.id, "switchMap")); return child; } // INTEGRATIONS export function fromEvent(el, eventType, options = {}) { const child = new Signal(); const handler = (event) => (child.value = event); el.addEventListener(eventType, handler, options); child.collect(() => el.removeEventListener(eventType, handler, options)); return child; } // SUBSCRIPTIONS = NOTE: to* functions return subscriptions not signals export function toInnerTextOf(signal, el) { const subscription = signal.subscribe((v) => (el.innerText = v)); return subscription; } export function toSignal(source, destination) { const subscription = source.subscribe((v) => (destination.value = v)); return subscription; } export function fromBetweenEvents(startElement, startEvent, endElement, endEvent) { const child = new Signal(); let hasActivated = false; const handleDown = () => { hasActivated = true; child.value = true; }; const handleUp = () => { if(hasActivated){ child.value = false; hasActivated = false; } }; // Add event listeners startElement.addEventListener(startEvent, handleDown); endElement.addEventListener(endEvent, handleUp); // Cleanup function to remove event listeners const cleanup = () => { startElement.removeEventListener(startEvent, handleDown); endElement.removeEventListener(endEvent, handleUp); }; child.collect(cleanup); return child; } // function main() { // const count1 = new Signal(1, {name: 'count1'}); // const count2 = new Signal(0, {name: 'count2'}); // combineLatest(count1, count2) // .map(([value1, value2]) => value1 + value2) // .subscribe(console.log); // graph.print(); // } // main();