var state = {
graphRaw: null,
graph: {
nodes: new Map(),
edges: [],
incoming: new Map(),
outgoing: new Map(),
edgeById: new Map()
},
focusId: null,
depth: 1,
markdownCache: new Map(),
dotAvailable: false,
hoveredId: null,
kindFilter: null,
graphRenderSeq: 0
};
var model = {};
function padId(n) {
return (n < 100 ? (n < 10 ? '00' : '0') : '') + n;
}
function encodePart(s) {
var result = '';
for (var i = 0; i < s.length; i++) {
var c = s.charAt(i);
if (/[A-Za-z0-9_\-]/.test(c)) {
result += c;
} else {
var hex = c.charCodeAt(0).toString(16);
if (hex.length === 1) hex = '0' + hex;
result += '_' + hex;
}
}
return result;
}
model.encodePart = encodePart;
function splitPrefix(s) {
var lastHyphen = s.lastIndexOf('-');
if (lastHyphen <= 0) return null;
var prefix = s.substring(0, lastHyphen);
var numStr = s.substring(lastHyphen + 1);
if (!/^[A-Za-z]+$/.test(prefix) || !/^\d+$/.test(numStr)) return null;
return { prefix: prefix.toUpperCase(), num: parseInt(numStr, 10) };
}
model.normalizeGraph = function(raw) {
var nodes = new Map();
var edges = [];
var edgeById = new Map();
var incoming = new Map();
var outgoing = new Map();
Object.keys(raw.nodes).forEach(function(key) {
var entry = raw.nodes[key];
var sp = splitPrefix(key);
var kindPrefix = sp ? sp.prefix : '';
nodes.set(key, {
id: key,
title: entry.title,
status: entry.status,
kindPrefix: kindPrefix,
kindLabel: entry.kind_label || '',
raw: entry
});
});
(raw.edges || []).forEach(function(edge) {
if (!('Resolved' in edge.target)) return;
var source = edge.source.prefix + '-' + padId(edge.source.id);
var target = edge.target.Resolved.prefix + '-' + padId(edge.target.Resolved.id);
var edgeId = 'e_' + encodePart(source) + '_' + encodePart(edge.label) + '_' + encodePart(target);
if (edgeById.has(edgeId)) return;
var edgeObj = {
id: edgeId,
source: source,
label: edge.label,
target: target,
raw: edge
};
edgeById.set(edgeId, edgeObj);
edges.push(edgeObj);
if (!incoming.has(target)) incoming.set(target, []);
incoming.get(target).push(edgeObj);
if (!outgoing.has(source)) outgoing.set(source, []);
outgoing.get(source).push(edgeObj);
});
state.graph.nodes = nodes;
state.graph.edges = edges;
state.graph.edgeById = edgeById;
state.graph.incoming = incoming;
state.graph.outgoing = outgoing;
};
model.findFocus = function(query, graph) {
if (query === null || query === '') {
var sortedIds = sortedNodeIds(graph);
return sortedIds.length > 0 ? sortedIds[0] : null;
}
if (graph.nodes.has(query.toUpperCase())) {
return query.toUpperCase();
}
var norm = looseCanonical(query);
if (norm && graph.nodes.has(norm)) {
return norm;
}
var queryLower = query.toLowerCase();
var titleMatch = null;
graph.nodes.forEach(function(node) {
if (node.title.toLowerCase() === queryLower) {
titleMatch = node.id;
}
});
if (titleMatch !== null) return titleMatch;
var best = null;
graph.nodes.forEach(function(node) {
var targets = [
node.id.toLowerCase(),
node.title.toLowerCase(),
node.status.toLowerCase(),
node.kindLabel.toLowerCase()
];
for (var t = 0; t < targets.length; t++) {
if (targets[t].indexOf(queryLower) !== -1) {
if (best === null || node.id.length < best.length) {
best = node.id;
}
break;
}
}
});
if (best !== null) return best;
return null;
};
model.resolveFocus = function(query, graph) {
var result = model.findFocus(query, graph);
if (result !== null) return result;
var sortedIds = sortedNodeIds(graph);
return sortedIds.length > 0 ? sortedIds[0] : null;
};
model.neighbourhood = function(focusId, depth, graph) {
depth = Math.max(0, Math.min(3, depth));
if (depth === 0) {
return { nodes: new Set([focusId]), edges: [] };
}
var visited = new Set();
var collectedEdges = [];
var collectedEdgeIds = new Set();
var queue = [{ id: focusId, dist: 0 }];
visited.add(focusId);
while (queue.length > 0) {
var current = queue.shift();
if (current.dist >= depth) continue;
var outEdges = graph.outgoing.get(current.id) || [];
outEdges.forEach(function(edge) {
if (!visited.has(edge.target)) {
visited.add(edge.target);
queue.push({ id: edge.target, dist: current.dist + 1 });
}
if (!collectedEdgeIds.has(edge.id)) {
collectedEdgeIds.add(edge.id);
collectedEdges.push(edge);
}
});
var inEdges = graph.incoming.get(current.id) || [];
inEdges.forEach(function(edge) {
if (!visited.has(edge.source)) {
visited.add(edge.source);
queue.push({ id: edge.source, dist: current.dist + 1 });
}
if (!collectedEdgeIds.has(edge.id)) {
collectedEdgeIds.add(edge.id);
collectedEdges.push(edge);
}
});
}
return { nodes: visited, edges: collectedEdges };
};
model.kindOrder = {
PRD: 1, SPEC: 1, ADR: 2, POL: 2, STD: 3, SL: 4,
ISS: 5, IMP: 5, CHR: 5, RSK: 5, REV: 6, RV: 7,
REQ: 8, IDE: 9, REC: 10, ASM: 11, DEC: 11, QUE: 12, CON: 12
};
function compareNodes(a, b) {
var ordA = model.kindOrder[a.kindPrefix] || 99;
var ordB = model.kindOrder[b.kindPrefix] || 99;
if (ordA !== ordB) return ordA - ordB;
var numA = parseInt(a.id.split('-').pop(), 10) || 0;
var numB = parseInt(b.id.split('-').pop(), 10) || 0;
if (numA !== numB) return numA - numB;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
function compareEdgesBySource(ea, eb) {
var sa = state.graph.nodes.get(ea.source);
var sb = state.graph.nodes.get(eb.source);
if (!sa || !sb) return ea.id < eb.id ? -1 : 1;
return compareNodes(sa, sb);
}
model.kinds = function(nodes) {
var counts = new Map();
nodes.forEach(function(node) {
var kp = node.kindPrefix;
counts.set(kp, (counts.get(kp) || 0) + 1);
});
var sorted = new Map();
var keys = [];
counts.forEach(function(_, k) { keys.push(k); });
keys.sort();
keys.forEach(function(k) { sorted.set(k, counts.get(k)); });
return sorted;
};
model.searchFilter = function(query, graph) {
var results = [];
if (query === null || query === '') {
graph.nodes.forEach(function(node) { results.push(node); });
results.sort(compareNodes);
return results;
}
var q = query.toLowerCase();
graph.nodes.forEach(function(node) {
if (node.id.toLowerCase().indexOf(q) !== -1 ||
node.title.toLowerCase().indexOf(q) !== -1) {
results.push(node);
}
});
results.sort(compareNodes);
return results;
};
function sortedNodeIds(graph) {
var keys = [];
graph.nodes.forEach(function(_, k) { keys.push(k); });
keys.sort();
return keys;
}
function looseCanonical(query) {
var firstDigit = -1;
for (var i = 0; i < query.length; i++) {
if (/[0-9]/.test(query.charAt(i))) {
firstDigit = i;
break;
}
}
if (firstDigit <= 0) return null;
var prefix = '';
for (var j = 0; j < firstDigit; j++) {
var ch = query.charAt(j);
if (/[A-Za-z]/.test(ch)) {
prefix += ch.toUpperCase();
}
}
if (prefix === '') return null;
var numStr = '';
for (var k = firstDigit; k < query.length; k++) {
var d = query.charAt(k);
if (/[0-9]/.test(d)) {
numStr += d;
}
}
if (numStr === '') return null;
var num = parseInt(numStr, 10);
if (isNaN(num)) return null;
return prefix + '-' + padId(num);
}