feat: el-ui — activation-based frontend framework, spreading activation reactivity, graph state
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* activation.js — Spreading activation utilities for el-ui.
|
||||
*
|
||||
* This module provides standalone activation functions that operate on any
|
||||
* Graph instance. Useful for implementing custom reactive behavior outside
|
||||
* of the standard component setState() lifecycle.
|
||||
*
|
||||
* The activation model mirrors engram-core/src/activation.rs exactly:
|
||||
* - Best-first BFS from seed nodes
|
||||
* - Multiplicative strength: parent_strength × edge.weight × target.importance
|
||||
* - Winner-take-most: strongest path to each node wins
|
||||
* - Prune: paths below threshold are cut (models attention filter)
|
||||
*/
|
||||
|
||||
export const PRUNE_THRESHOLD = 0.01;
|
||||
export const DEFAULT_MAX_DEPTH = 3;
|
||||
export const DEFAULT_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Run spreading activation from multiple seed nodes.
|
||||
*
|
||||
* @param {import('./graph.js').Graph} graph
|
||||
* @param {string[]} seeds starting node IDs
|
||||
* @param {object} opts
|
||||
* @param {number} [opts.maxDepth=3]
|
||||
* @param {number} [opts.limit=20]
|
||||
* @param {number} [opts.pruneThreshold=0.01]
|
||||
* @returns {Array<{nodeId: string, strength: number, hops: number, node: object}>}
|
||||
*/
|
||||
export function spreadActivation(graph, seeds, {
|
||||
maxDepth = DEFAULT_MAX_DEPTH,
|
||||
limit = DEFAULT_LIMIT,
|
||||
pruneThreshold = PRUNE_THRESHOLD,
|
||||
} = {}) {
|
||||
/** @type {Map<string, {strength: number, hops: number}>} */
|
||||
const bestStrength = new Map();
|
||||
/** @type {Array<{id: string, strength: number, hops: number}>} */
|
||||
const queue = [];
|
||||
|
||||
const seedSet = new Set(seeds);
|
||||
|
||||
for (const seed of seeds) {
|
||||
const node = graph.get(seed);
|
||||
if (!node) continue;
|
||||
queue.push({ id: seed, strength: 1.0, hops: 0 });
|
||||
bestStrength.set(seed, { strength: 1.0, hops: 0 });
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
// Best-first: pop the highest-strength candidate
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect results — exclude seeds, sort by strength
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute activation strength between two specific nodes.
|
||||
* Returns 0 if no path exists within maxDepth.
|
||||
*
|
||||
* @param {import('./graph.js').Graph} graph
|
||||
* @param {string} fromId
|
||||
* @param {string} toId
|
||||
* @param {number} [maxDepth=3]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function activationStrength(graph, fromId, toId, maxDepth = DEFAULT_MAX_DEPTH) {
|
||||
const results = spreadActivation(graph, [fromId], { maxDepth });
|
||||
const found = results.find(r => r.nodeId === toId);
|
||||
return found ? found.strength : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all nodes reachable from a seed within the activation surface.
|
||||
* Unlike spreadActivation(), this includes the seed itself.
|
||||
*
|
||||
* @param {import('./graph.js').Graph} graph
|
||||
* @param {string} seedId
|
||||
* @param {number} [maxDepth=3]
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function reachableNodes(graph, seedId, maxDepth = DEFAULT_MAX_DEPTH) {
|
||||
return graph.activate(seedId, maxDepth);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* graph.js — In-browser Engram graph for el-ui state management.
|
||||
*
|
||||
* Every piece of component state is a node in this graph.
|
||||
* Edges encode relationships between state items (e.g., derived values,
|
||||
* related data, component hierarchies).
|
||||
*
|
||||
* Reactivity is driven by spreading activation: when a node is updated,
|
||||
* activation spreads outward through edges, and subscribers on any activated
|
||||
* node are notified. Only subscribers in the activation surface update —
|
||||
* this is the core of el-ui's selective re-rendering.
|
||||
*/
|
||||
|
||||
export class Graph {
|
||||
constructor() {
|
||||
/** @type {Map<string, {id: string, type: string, name: string, content: any, importance: number, edges: string[]}>} */
|
||||
this.nodes = new Map();
|
||||
|
||||
/** @type {Map<string, {id: string, from: string, to: string, weight: number, relation: string}>} */
|
||||
this.edges = new Map();
|
||||
|
||||
/** @type {Map<string, Array<function>>} */
|
||||
this.subscribers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node and return its ID.
|
||||
* @param {{type: string, name: string, content: any, importance?: number}} opts
|
||||
* @returns {string} node ID
|
||||
*/
|
||||
seed({ type, name, content, importance = 0.5 }) {
|
||||
const id = this._uuid();
|
||||
this.nodes.set(id, { id, type, name, content, importance, edges: [] });
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a node by ID.
|
||||
* @param {string} id
|
||||
* @returns {{id: string, type: string, name: string, content: any, importance: number, edges: string[]} | undefined}
|
||||
*/
|
||||
get(id) {
|
||||
return this.nodes.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node content and trigger spreading activation.
|
||||
* This is the primary mutation point — all state changes go through here.
|
||||
* @param {string} id
|
||||
* @param {any} newContent
|
||||
*/
|
||||
update(id, newContent) {
|
||||
const node = this.nodes.get(id);
|
||||
if (!node) return;
|
||||
|
||||
node.content = newContent;
|
||||
// Activation boosts importance — recently-changed state nodes
|
||||
// become more salient (same model as Engram salience boost).
|
||||
node.importance = Math.min(1.0, node.importance + 0.1);
|
||||
|
||||
// Spreading activation from the updated node
|
||||
const activated = this.activate(id);
|
||||
|
||||
// Notify all subscribers in the activation surface
|
||||
for (const nodeId of activated) {
|
||||
const subs = this.subscribers.get(nodeId);
|
||||
if (subs && subs.length > 0) {
|
||||
const activatedNode = this.nodes.get(nodeId);
|
||||
if (activatedNode) {
|
||||
subs.forEach(cb => cb(activatedNode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always notify direct subscribers on the changed node itself
|
||||
const directSubs = this.subscribers.get(id);
|
||||
if (directSubs && directSubs.length > 0) {
|
||||
directSubs.forEach(cb => cb(node));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spreading activation — BFS from a seed node.
|
||||
*
|
||||
* Faithfully mirrors the Engram spreading activation algorithm:
|
||||
* strength = parent_strength × edge.weight × target.importance
|
||||
*
|
||||
* (In the browser we omit cosine_sim since we have no embedding vectors —
|
||||
* the importance factor serves as the salience filter.)
|
||||
*
|
||||
* @param {string} seedId
|
||||
* @param {number} maxDepth
|
||||
* @param {number} pruneThreshold
|
||||
* @returns {Set<string>} set of activated node IDs (including the seed)
|
||||
*/
|
||||
activate(seedId, maxDepth = 3, pruneThreshold = 0.01) {
|
||||
const result = new Set([seedId]);
|
||||
/** @type {Array<{id: string, strength: number, depth: number}>} */
|
||||
const queue = [{ id: seedId, strength: 1.0, depth: 0 }];
|
||||
/** @type {Map<string, number>} */
|
||||
const bestStrength = new Map([[seedId, 1.0]]);
|
||||
|
||||
while (queue.length > 0) {
|
||||
// Best-first: process highest-strength candidate
|
||||
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;
|
||||
|
||||
// Activation formula (multiplicative — matches Engram engine)
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic search — find nodes by content similarity.
|
||||
*
|
||||
* In v0.1 this uses simple string matching. In a future version this will
|
||||
* use embedding vectors and cosine similarity (matching the Engram core engine).
|
||||
*
|
||||
* @param {string} query
|
||||
* @param {string|null} nodeType optional filter by node type
|
||||
* @returns {Array<{id: string, type: string, name: string, content: any, importance: number, score: number}>}
|
||||
*/
|
||||
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) {
|
||||
// Score: name matches score higher; importance boosts
|
||||
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 to updates on a specific node (and any activation-reachable neighbors).
|
||||
* Returns an unsubscribe function.
|
||||
*
|
||||
* @param {string} nodeId
|
||||
* @param {function} callback called with the node object when it activates
|
||||
* @returns {function} unsubscribe
|
||||
*/
|
||||
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 two nodes with a directed edge.
|
||||
* Higher weight = stronger activation path.
|
||||
*
|
||||
* @param {string} fromId
|
||||
* @param {string} toId
|
||||
* @param {{weight?: number, relation?: string}} opts
|
||||
* @returns {string} edge ID
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all nodes as an array (useful for debugging / DevTools).
|
||||
* @returns {Array}
|
||||
*/
|
||||
dump() {
|
||||
return [...this.nodes.values()];
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_uuid() {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback for environments without 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* el-ui — Activation-based frontend framework.
|
||||
*
|
||||
* State is an Engram graph. Reactivity is spreading activation.
|
||||
*
|
||||
* Core exports:
|
||||
* Graph — in-browser Engram graph (nodes, edges, activation)
|
||||
* Renderer — DOM mounting and patching
|
||||
* Router — graph-based routing
|
||||
* Component — base class for el-ui components
|
||||
* mount() — attach a component to the DOM
|
||||
*
|
||||
* @module el-ui
|
||||
*/
|
||||
|
||||
export { Graph } from './graph.js';
|
||||
export { Renderer } from './renderer.js';
|
||||
export { Router } from './router.js';
|
||||
export {
|
||||
spreadActivation,
|
||||
activationStrength,
|
||||
reachableNodes,
|
||||
PRUNE_THRESHOLD,
|
||||
} from './activation.js';
|
||||
|
||||
import { Graph } from './graph.js';
|
||||
import { Renderer } from './renderer.js';
|
||||
|
||||
/**
|
||||
* Base class for all el-ui components.
|
||||
*
|
||||
* Subclasses override:
|
||||
* render() → returns HTML string
|
||||
* onMount() → called after first mount (optional)
|
||||
*
|
||||
* Subclasses call:
|
||||
* setState(name, value) → update state, trigger activation, patch DOM
|
||||
*/
|
||||
export class Component {
|
||||
constructor(props = {}) {
|
||||
/** @type {Graph} */
|
||||
this._graph = new Graph();
|
||||
|
||||
/** @type {Record<string, string>} state node IDs */
|
||||
this._stateNodes = {};
|
||||
|
||||
/** @type {Record<string, any>} current state values */
|
||||
this._state = {};
|
||||
|
||||
/** @type {Record<string, any>} current prop values */
|
||||
this.props = props;
|
||||
|
||||
/** @type {Renderer|null} */
|
||||
this._renderer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a named state variable.
|
||||
* Triggers spreading activation in the graph, then patches the DOM.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {any} value
|
||||
*/
|
||||
setState(name, value) {
|
||||
if (this._stateNodes[name] !== undefined) {
|
||||
this._graph.update(this._stateNodes[name], value);
|
||||
// Graph.update() notifies subscribers, which call _renderer.patch().
|
||||
// Belt-and-suspenders: also patch directly in case no subscribers yet.
|
||||
if (this._renderer) this._renderer.patch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a child component inline, sharing the parent's activation graph.
|
||||
* Used by the compiler to embed child components in template strings.
|
||||
*
|
||||
* @param {typeof Component} ComponentClass
|
||||
* @param {Record<string, any>} props
|
||||
* @returns {string}
|
||||
*/
|
||||
_child(ComponentClass, props = {}) {
|
||||
try {
|
||||
const instance = new ComponentClass(props);
|
||||
// Share the parent graph so child state participates in the same
|
||||
// activation surface. Child state changes can propagate upward.
|
||||
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} -->`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to return the component's HTML string.
|
||||
* All state and props are accessible via this._state / this.props.
|
||||
* @returns {string}
|
||||
*/
|
||||
render() {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once after the component is first mounted to the DOM.
|
||||
* Override for side effects (timers, fetch calls, subscriptions).
|
||||
*/
|
||||
onMount() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a component to a DOM element and start the el-ui runtime.
|
||||
*
|
||||
* @param {typeof Component} ComponentClass the component class to mount
|
||||
* @param {string} selector CSS selector for the root element
|
||||
* @param {Record<string, any>} [props={}] initial props
|
||||
* @returns {Component} the live component instance
|
||||
*
|
||||
* @example
|
||||
* import { mount } from './el-ui.js';
|
||||
* import { Counter } from './counter.js';
|
||||
* mount(Counter, '#app');
|
||||
*/
|
||||
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 };
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* renderer.js — DOM rendering and patching for el-ui.
|
||||
*
|
||||
* v0.1 strategy: full string re-render with event rebinding.
|
||||
* The architecture is designed for future targeted DOM patching:
|
||||
* `patch()` will accept the activated node set and only update
|
||||
* DOM subtrees whose data-el-tag corresponds to an activated node.
|
||||
*
|
||||
* Event binding uses data-el-* attributes set by the compiler,
|
||||
* which avoids innerHTML XSS risks for handler references while
|
||||
* keeping the runtime small (no virtual DOM, no event delegation table).
|
||||
*/
|
||||
|
||||
export class Renderer {
|
||||
/**
|
||||
* @param {HTMLElement} root the mount point
|
||||
* @param {import('./index.js').Component} component component instance
|
||||
*/
|
||||
constructor(root, component) {
|
||||
this.root = root;
|
||||
this.component = component;
|
||||
this._currentHtml = '';
|
||||
this._boundHandlers = new WeakMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial mount — render and bind events.
|
||||
*/
|
||||
mount() {
|
||||
this._currentHtml = this.component.render();
|
||||
this.root.innerHTML = this._currentHtml;
|
||||
this._bindEvents();
|
||||
if (typeof this.component.onMount === 'function') {
|
||||
this.component.onMount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the DOM after a state change.
|
||||
* Called by the component when spreading activation completes.
|
||||
*
|
||||
* @param {Set<string>} [_activatedNodes] future: targeted patching by node set
|
||||
*/
|
||||
patch(_activatedNodes) {
|
||||
const newHtml = this.component.render();
|
||||
if (newHtml !== this._currentHtml) {
|
||||
// Preserve scroll position and focused element identity
|
||||
const focused = document.activeElement;
|
||||
const focusedId = focused && focused !== document.body ? focused.id : null;
|
||||
|
||||
this.root.innerHTML = newHtml;
|
||||
this._bindEvents();
|
||||
this._currentHtml = newHtml;
|
||||
|
||||
// Restore focus if element still exists
|
||||
if (focusedId) {
|
||||
const el = this.root.querySelector(`#${CSS.escape(focusedId)}`);
|
||||
if (el) el.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind all data-el-* event handlers declared by the compiler.
|
||||
*
|
||||
* The compiler emits attributes like:
|
||||
* data-el-click="__self.handleClick()"
|
||||
* data-el-input="(e) => __self.setState('value', e.target.value)"
|
||||
*
|
||||
* We eval these in the context of the component instance.
|
||||
* (For production use, a full CSP-compatible binding would generate
|
||||
* handler functions at compile time.)
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_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;
|
||||
|
||||
// Build a handler function in the component's scope
|
||||
const handler = this._compileHandler(handlerExpr, comp);
|
||||
if (handler) {
|
||||
// Remove previous binding if any
|
||||
const prev = this._boundHandlers.get(el);
|
||||
if (prev) el.removeEventListener(eventName, prev[eventName]);
|
||||
|
||||
el.addEventListener(eventName, handler);
|
||||
if (!this._boundHandlers.has(el)) this._boundHandlers.set(el, {});
|
||||
this._boundHandlers.get(el)[eventName] = handler;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a handler expression string into a callable function.
|
||||
* The expression is evaluated with `__self` bound to the component.
|
||||
*
|
||||
* @private
|
||||
* @param {string} expr
|
||||
* @param {object} comp
|
||||
* @returns {function|null}
|
||||
*/
|
||||
_compileHandler(expr, comp) {
|
||||
try {
|
||||
// Decode HTML entities (attrs may have " etc.)
|
||||
const decoded = expr
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// If it looks like an arrow function or function reference, wrap it
|
||||
const isArrow = decoded.includes('=>');
|
||||
const isCall = decoded.includes('(');
|
||||
|
||||
if (isArrow) {
|
||||
// Arrow function: `(e) => __self.setState(...)` or `() => ...`
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('__self', `return ${decoded}`)(comp);
|
||||
return typeof fn === 'function' ? fn : null;
|
||||
} else if (isCall) {
|
||||
// Method call: `__self.handleClick()`
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function('__self', 'event', `${decoded}`).bind(null, comp);
|
||||
} else {
|
||||
// Bare identifier: method name on the component
|
||||
return comp[decoded] ? comp[decoded].bind(comp) : null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`el-ui: failed to compile handler: ${expr}`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* router.js — Graph-based routing for el-ui.
|
||||
*
|
||||
* Routes are nodes in the graph. Navigation activates the target route node,
|
||||
* which spreads through the graph to any components subscribed to routing.
|
||||
* This means route changes propagate with the same activation semantics as
|
||||
* state changes — route-dependent components update automatically.
|
||||
*
|
||||
* Route nodes have:
|
||||
* type: 'route'
|
||||
* name: the path string
|
||||
* content: the path string
|
||||
* importance: starts at 0.5, boosted on activation (recently visited routes
|
||||
* become more salient — models browser history behavior)
|
||||
*/
|
||||
|
||||
export class Router {
|
||||
/**
|
||||
* @param {import('./graph.js').Graph} graph
|
||||
* @param {Record<string, any>} routes path → Component class
|
||||
*/
|
||||
constructor(graph, routes) {
|
||||
this.graph = graph;
|
||||
this.routes = routes;
|
||||
this.currentPath = window.location.pathname;
|
||||
this._routeNodes = {};
|
||||
this._subscribers = [];
|
||||
|
||||
// Seed each route as a node in the graph
|
||||
for (const [path] of Object.entries(routes)) {
|
||||
const id = graph.seed({ type: 'route', name: path, content: path, importance: 0.3 });
|
||||
this._routeNodes[path] = id;
|
||||
}
|
||||
|
||||
// Connect route nodes in path order (parent → child activation chain)
|
||||
// e.g., / → /about → /about/team
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for browser back/forward
|
||||
window.addEventListener('popstate', () => {
|
||||
this._activate(window.location.pathname);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a path, update history, and activate the route graph node.
|
||||
* @param {string} path
|
||||
* @param {boolean} [replace=false] use replaceState instead of pushState
|
||||
*/
|
||||
navigate(path, replace = false) {
|
||||
if (path === this.currentPath) return;
|
||||
|
||||
if (replace) {
|
||||
history.replaceState({}, '', path);
|
||||
} else {
|
||||
history.pushState({}, '', path);
|
||||
}
|
||||
|
||||
this._activate(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Component class for the current path.
|
||||
* Falls back to the '*' wildcard route if present.
|
||||
* @returns {any}
|
||||
*/
|
||||
currentComponent() {
|
||||
return this.routes[this.currentPath] ?? this.routes['*'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to route changes.
|
||||
* @param {function(string): void} callback called with the new path
|
||||
* @returns {function} unsubscribe
|
||||
*/
|
||||
subscribe(callback) {
|
||||
this._subscribers.push(callback);
|
||||
return () => {
|
||||
const idx = this._subscribers.indexOf(callback);
|
||||
if (idx >= 0) this._subscribers.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an href-compatible link string.
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
href(path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_activate(path) {
|
||||
const prevPath = this.currentPath;
|
||||
this.currentPath = path;
|
||||
|
||||
// Find the best matching route
|
||||
const matchedPath = this._matchPath(path);
|
||||
if (matchedPath && this._routeNodes[matchedPath]) {
|
||||
// Spreading activation from the new route node
|
||||
this.graph.activate(this._routeNodes[matchedPath]);
|
||||
// Boost importance of the newly visited route
|
||||
const node = this.graph.get(this._routeNodes[matchedPath]);
|
||||
if (node) node.importance = Math.min(1.0, node.importance + 0.2);
|
||||
}
|
||||
|
||||
// Notify route subscribers
|
||||
if (path !== prevPath) {
|
||||
this._subscribers.forEach(cb => cb(path));
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_matchPath(path) {
|
||||
// Exact match first
|
||||
if (this.routes[path]) return path;
|
||||
// Prefix match (longest prefix wins)
|
||||
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];
|
||||
// Wildcard
|
||||
return '*';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user