/** * 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} 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 '*'; } }