Files
el/ui/dist/el-ui.js
T

398 lines
14 KiB
JavaScript

/**
* el-ui v0.1.0 — Activation-based frontend framework.
* State is an Engram graph. Reactivity is spreading activation.
*
* Bundled single-file runtime. Import this from your compiled .el components:
* import { Component, Graph, Renderer, Router, mount } from './el-ui.js';
*
* Source: runtime/src/
* Build: cat runtime/src/graph.js runtime/src/activation.js
* runtime/src/renderer.js runtime/src/router.js runtime/src/index.js > dist/el-ui.js
*/
// ── graph.js ──────────────────────────────────────────────────────────────────
export class Graph {
constructor() {
this.nodes = new Map();
this.edges = new Map();
this.subscribers = new Map();
}
seed({ type, name, content, importance = 0.5 }) {
const id = this._uuid();
this.nodes.set(id, { id, type, name, content, importance, edges: [] });
return id;
}
get(id) {
return this.nodes.get(id);
}
update(id, newContent) {
const node = this.nodes.get(id);
if (!node) return;
node.content = newContent;
node.importance = Math.min(1.0, node.importance + 0.1);
const activated = this.activate(id);
for (const nodeId of activated) {
const subs = this.subscribers.get(nodeId);
if (subs && subs.length > 0) {
const n = this.nodes.get(nodeId);
if (n) subs.forEach(cb => cb(n));
}
}
// Always notify direct subscribers
const directSubs = this.subscribers.get(id);
if (directSubs && directSubs.length > 0) {
directSubs.forEach(cb => cb(node));
}
}
activate(seedId, maxDepth = 3, pruneThreshold = 0.01) {
const result = new Set([seedId]);
const queue = [{ id: seedId, strength: 1.0, depth: 0 }];
const bestStrength = new Map([[seedId, 1.0]]);
while (queue.length > 0) {
let bestIdx = 0;
for (let i = 1; i < queue.length; i++) {
if (queue[i].strength > queue[bestIdx].strength) bestIdx = i;
}
const { id, strength, depth } = queue.splice(bestIdx, 1)[0];
if (depth >= maxDepth) continue;
const node = this.nodes.get(id);
if (!node) continue;
for (const edgeId of node.edges) {
const edge = this.edges.get(edgeId);
if (!edge) continue;
const target = this.nodes.get(edge.to);
if (!target) continue;
const targetStrength = strength * edge.weight * Math.max(0, target.importance);
if (targetStrength <= pruneThreshold) continue;
const prevBest = bestStrength.get(edge.to) ?? 0;
if (targetStrength > prevBest) {
bestStrength.set(edge.to, targetStrength);
result.add(edge.to);
queue.push({ id: edge.to, strength: targetStrength, depth: depth + 1 });
}
}
}
return result;
}
search(query, nodeType = null) {
const results = [];
const q = query.toLowerCase();
for (const [, node] of this.nodes) {
if (nodeType && node.type !== nodeType) continue;
const content = String(node.content).toLowerCase();
const nameMatch = node.name.toLowerCase().includes(q);
const contentMatch = content.includes(q);
if (nameMatch || contentMatch) {
const score = (nameMatch ? 0.6 : 0) + (contentMatch ? 0.4 : 0);
results.push({ ...node, score: score * node.importance });
}
}
return results.sort((a, b) => b.score - a.score);
}
subscribe(nodeId, callback) {
if (!this.subscribers.has(nodeId)) this.subscribers.set(nodeId, []);
this.subscribers.get(nodeId).push(callback);
return () => {
const subs = this.subscribers.get(nodeId);
if (!subs) return;
const idx = subs.indexOf(callback);
if (idx >= 0) subs.splice(idx, 1);
};
}
connect(fromId, toId, { weight = 1.0, relation = 'related' } = {}) {
const edgeId = this._uuid();
this.edges.set(edgeId, { id: edgeId, from: fromId, to: toId, weight, relation });
const node = this.nodes.get(fromId);
if (node) node.edges.push(edgeId);
return edgeId;
}
dump() {
return [...this.nodes.values()];
}
_uuid() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
}
// ── activation.js ─────────────────────────────────────────────────────────────
export const PRUNE_THRESHOLD = 0.01;
export function spreadActivation(graph, seeds, {
maxDepth = 3,
limit = 20,
pruneThreshold = PRUNE_THRESHOLD,
} = {}) {
const bestStrength = new Map();
const queue = [];
const seedSet = new Set(seeds);
for (const seed of seeds) {
if (!graph.get(seed)) continue;
queue.push({ id: seed, strength: 1.0, hops: 0 });
bestStrength.set(seed, { strength: 1.0, hops: 0 });
}
while (queue.length > 0) {
let bestIdx = 0;
for (let i = 1; i < queue.length; i++) {
if (queue[i].strength > queue[bestIdx].strength) bestIdx = i;
}
const { id, strength, hops } = queue.splice(bestIdx, 1)[0];
if (hops >= maxDepth) continue;
const node = graph.get(id);
if (!node) continue;
for (const edgeId of node.edges) {
const edge = graph.edges.get(edgeId);
if (!edge) continue;
const target = graph.get(edge.to);
if (!target) continue;
const targetStrength = strength * edge.weight * Math.max(0, target.importance);
if (targetStrength <= pruneThreshold) continue;
const prev = bestStrength.get(edge.to);
if (!prev || targetStrength > prev.strength) {
const nextHops = hops + 1;
bestStrength.set(edge.to, { strength: targetStrength, hops: nextHops });
queue.push({ id: edge.to, strength: targetStrength, hops: nextHops });
}
}
}
const results = [];
for (const [nodeId, { strength, hops }] of bestStrength) {
if (seedSet.has(nodeId)) continue;
const node = graph.get(nodeId);
if (node) results.push({ nodeId, strength, hops, node });
}
results.sort((a, b) => b.strength - a.strength);
return results.slice(0, limit);
}
export function activationStrength(graph, fromId, toId, maxDepth = 3) {
const results = spreadActivation(graph, [fromId], { maxDepth });
const found = results.find(r => r.nodeId === toId);
return found ? found.strength : 0;
}
export function reachableNodes(graph, seedId, maxDepth = 3) {
return graph.activate(seedId, maxDepth);
}
// ── renderer.js ───────────────────────────────────────────────────────────────
export class Renderer {
constructor(root, component) {
this.root = root;
this.component = component;
this._currentHtml = '';
this._boundHandlers = new WeakMap();
}
mount() {
this._currentHtml = this.component.render();
this.root.innerHTML = this._currentHtml;
this._bindEvents();
if (typeof this.component.onMount === 'function') {
this.component.onMount();
}
}
patch(_activatedNodes) {
const newHtml = this.component.render();
if (newHtml !== this._currentHtml) {
const focused = document.activeElement;
const focusedId = focused && focused !== document.body ? focused.id : null;
this.root.innerHTML = newHtml;
this._bindEvents();
this._currentHtml = newHtml;
if (focusedId) {
const el = this.root.querySelector(`#${CSS.escape(focusedId)}`);
if (el) el.focus();
}
}
}
_bindEvents() {
const comp = this.component;
const eventNames = [
'click', 'input', 'change', 'mouseenter', 'mouseleave',
'keydown', 'keyup', 'keypress', 'focus', 'blur',
'submit', 'mousedown', 'mouseup', 'dblclick', 'contextmenu',
];
for (const eventName of eventNames) {
const attr = `data-el-${eventName}`;
this.root.querySelectorAll(`[${attr}]`).forEach(el => {
const handlerExpr = el.getAttribute(attr);
if (!handlerExpr) return;
const handler = this._compileHandler(handlerExpr, comp);
if (handler) {
const prev = this._boundHandlers.get(el);
if (prev && prev[eventName]) el.removeEventListener(eventName, prev[eventName]);
el.addEventListener(eventName, handler);
if (!this._boundHandlers.has(el)) this._boundHandlers.set(el, {});
this._boundHandlers.get(el)[eventName] = handler;
}
});
}
}
_compileHandler(expr, comp) {
try {
const decoded = expr
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
if (decoded.includes('=>')) {
// eslint-disable-next-line no-new-func
const fn = new Function('__self', `return ${decoded}`)(comp);
return typeof fn === 'function' ? fn : null;
} else if (decoded.includes('(')) {
// eslint-disable-next-line no-new-func
return new Function('__self', 'event', `${decoded}`).bind(null, comp);
} else {
return comp[decoded] ? comp[decoded].bind(comp) : null;
}
} catch (e) {
console.warn(`el-ui: failed to compile handler: ${expr}`, e);
return null;
}
}
}
// ── router.js ─────────────────────────────────────────────────────────────────
export class Router {
constructor(graph, routes) {
this.graph = graph;
this.routes = routes;
this.currentPath = window.location.pathname;
this._routeNodes = {};
this._subscribers = [];
for (const [path] of Object.entries(routes)) {
const id = graph.seed({ type: 'route', name: path, content: path, importance: 0.3 });
this._routeNodes[path] = id;
}
const paths = Object.keys(routes).sort();
for (let i = 0; i < paths.length - 1; i++) {
const parent = paths[i];
const child = paths[i + 1];
if (child.startsWith(parent) && parent !== child) {
graph.connect(this._routeNodes[parent], this._routeNodes[child], {
weight: 0.8,
relation: 'subroute',
});
}
}
window.addEventListener('popstate', () => this._activate(window.location.pathname));
}
navigate(path, replace = false) {
if (path === this.currentPath) return;
if (replace) history.replaceState({}, '', path);
else history.pushState({}, '', path);
this._activate(path);
}
currentComponent() {
return this.routes[this.currentPath] ?? this.routes['*'] ?? null;
}
subscribe(callback) {
this._subscribers.push(callback);
return () => {
const idx = this._subscribers.indexOf(callback);
if (idx >= 0) this._subscribers.splice(idx, 1);
};
}
href(path) { return path; }
_activate(path) {
const prevPath = this.currentPath;
this.currentPath = path;
const matchedPath = this._matchPath(path);
if (matchedPath && this._routeNodes[matchedPath]) {
this.graph.activate(this._routeNodes[matchedPath]);
const node = this.graph.get(this._routeNodes[matchedPath]);
if (node) node.importance = Math.min(1.0, node.importance + 0.2);
}
if (path !== prevPath) this._subscribers.forEach(cb => cb(path));
}
_matchPath(path) {
if (this.routes[path]) return path;
const candidates = Object.keys(this.routes)
.filter(r => r !== '*' && path.startsWith(r))
.sort((a, b) => b.length - a.length);
if (candidates.length > 0) return candidates[0];
return '*';
}
}
// ── Component base class & mount() ───────────────────────────────────────────
export class Component {
constructor(props = {}) {
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
this.props = props;
this._renderer = null;
}
setState(name, value) {
if (this._stateNodes[name] !== undefined) {
this._graph.update(this._stateNodes[name], value);
if (this._renderer) this._renderer.patch();
}
}
_child(ComponentClass, props = {}) {
try {
const instance = new ComponentClass(props);
instance._graph = this._graph;
return instance.render();
} catch (e) {
console.warn(`el-ui: failed to render child ${ComponentClass?.name}`, e);
return `<!-- el-ui: render error in ${ComponentClass?.name} -->`;
}
}
render() { return ''; }
onMount() {}
}
export function mount(ComponentClass, selector, props = {}) {
const root = document.querySelector(selector);
if (!root) throw new Error(`el-ui: no element found for selector '${selector}'`);
const component = new ComponentClass(props);
const renderer = new Renderer(root, component);
component._renderer = renderer;
renderer.mount();
return component;
}
export default { mount, Component, Graph, Renderer, Router };