398 lines
14 KiB
JavaScript
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(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/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 };
|