feat: el-ui — activation-based frontend framework, spreading activation reactivity, graph state
This commit is contained in:
@@ -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