mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-08 04:00:15 +00:00
Replace the static biomedical category key in Explorer with a live node color legend derived from the active display graph, sharing the canvas's baseColor -> color -> theme fallback resolver. - Preserve original semantic colors on focused display clones via `semanticBaseColor` so interaction styling (selection, path, neighbor highlights) does not bleed into the legend. - Suppress the semantic legend while distance visualization modes are active. - Refactor plugin fallback legend panels to use the shared legend builder with composite (group, color) keys. - Consolidate duplicate `"test:graph-workspace"` scripts in `explorer/package.json`, recovering 26 previously shadowed tests across markdown and explorer capability suites. - Add `tests/graphColorLegend.test.ts` to workspace test runs and wire `test:graph-legend-e2e` into `.github/workflows/ci.yml`. - Closes #1479
104 lines
5.3 KiB
TypeScript
104 lines
5.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import Graph from "graphology";
|
|
|
|
import { clearGraph, graph as sourceGraph, type NodeAttributes } from "../src/store/graphStore.ts";
|
|
import { buildGraphColorLegend } from "../src/workspaces/GraphWorkspace/graphColorLegend.ts";
|
|
import { resolveDisplayGraph, resolveNodeElementStyle } from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
|
import { GRAPH_THEME, withAlpha } from "../src/workspaces/GraphWorkspace/graphTheme.ts";
|
|
|
|
function attributes(overrides: Partial<NodeAttributes> = {}): NodeAttributes {
|
|
return {
|
|
label: "Example", content: "Example", x: 0, y: 0, size: 8,
|
|
nodeType: "Person", semanticGroup: "Person", color: "#123456",
|
|
properties: {}, ...overrides,
|
|
};
|
|
}
|
|
|
|
test("legend matches normal canvas colors, including color and theme fallbacks", () => {
|
|
const graph = new Graph();
|
|
const samples = [
|
|
attributes({ baseColor: "#abcdef" }),
|
|
attributes({ semanticGroup: "Organization" }),
|
|
attributes({ semanticGroup: "Location", color: "" }),
|
|
];
|
|
samples.forEach((attrs, i) => graph.addNode(String(i), attrs));
|
|
const items = buildGraphColorLegend(graph);
|
|
for (const attrs of samples) {
|
|
const item = items.find((entry) => entry.group === attrs.semanticGroup)!;
|
|
const style = resolveNodeElementStyle(GRAPH_THEME, "inspection", "default", attrs, attrs.label);
|
|
assert.equal(style.color, withAlpha(item.color, GRAPH_THEME.nodes.entityShapes.entity.fillAlpha));
|
|
}
|
|
assert.equal(items.find((item) => item.group === "Person")?.color, "#abcdef");
|
|
assert.equal(items.find((item) => item.group === "Organization")?.color, "#123456");
|
|
assert.equal(items.find((item) => item.group === "Location")?.color, GRAPH_THEME.palette.semantic[0]);
|
|
});
|
|
|
|
test("semantic groups, not shape categories, determine labels and distinct entries", () => {
|
|
const graph = new Graph();
|
|
graph.addNode("one", attributes({ semanticGroup: "Research", entityShape: "compound" }));
|
|
graph.addNode("two", attributes({ semanticGroup: "Research", entityShape: "entity" }));
|
|
graph.addNode("synthetic", attributes({ semanticGroup: "Research", baseColor: "#654321", isCommunityGroup: true }));
|
|
graph.addNode("hidden", { ...attributes(), hidden: true });
|
|
assert.deepEqual(buildGraphColorLegend(graph).map(({ group, color, count }) => ({ group, color, count })), [
|
|
{ group: "Research", color: "#123456", count: 2 },
|
|
{ group: "Research", color: "#654321", count: 1 },
|
|
]);
|
|
assert.equal(new Set(buildGraphColorLegend(graph).map((item) => item.id)).size, 2);
|
|
});
|
|
|
|
test("legend rebuilds after in-place changes and uses only the supplied display graph", () => {
|
|
const graph = new Graph();
|
|
graph.addNode("one", attributes());
|
|
graph.addNode("two", attributes({ semanticGroup: "Location" }));
|
|
const first = buildGraphColorLegend(graph);
|
|
graph.mergeNodeAttributes("one", { semanticGroup: "Project", baseColor: "#fedcba" });
|
|
graph.dropNode("two");
|
|
assert.equal(first.length, 2);
|
|
assert.deepEqual(buildGraphColorLegend(graph).map(({ group, color }) => ({ group, color })), [
|
|
{ group: "Project", color: "#fedcba" },
|
|
]);
|
|
graph.clear();
|
|
assert.deepEqual(buildGraphColorLegend(graph), []);
|
|
});
|
|
|
|
test("fallback labels and ordering are stable and no groups are silently dropped", () => {
|
|
const graph = new Graph();
|
|
graph.addNode("fallback", attributes({ semanticGroup: undefined, nodeType: "" }));
|
|
for (let i = 11; i >= 0; i -= 1) graph.addNode(String(i), attributes({ semanticGroup: undefined, nodeType: `Type ${i}` }));
|
|
const items = buildGraphColorLegend(graph);
|
|
assert.equal(items.length, 13);
|
|
assert.ok(items.some((item) => item.group === "entity"));
|
|
const reverse = new Graph();
|
|
graph.nodes().reverse().forEach((id) => reverse.addNode(id, graph.getNodeAttributes(id)));
|
|
assert.deepEqual(items, buildGraphColorLegend(reverse));
|
|
});
|
|
|
|
|
|
test("focused legend keeps semantic colors for selected, path, and neighbor clones", (t) => {
|
|
clearGraph();
|
|
t.after(clearGraph);
|
|
for (const id of ["selected", "path", "neighbor", "outside"]) {
|
|
sourceGraph.addNode(id, attributes({ label: id, baseColor: "#abcdef" }));
|
|
}
|
|
sourceGraph.addDirectedEdgeWithKey("path-edge", "selected", "path", { weight: 1 });
|
|
sourceGraph.addDirectedEdgeWithKey("neighbor-edge", "selected", "neighbor", { weight: 1 });
|
|
const focused = resolveDisplayGraph("selected", ["selected", "path"], ["path-edge"], "focused").graph;
|
|
|
|
// Verify the fixture exercises baked interaction colors, not ordinary clones.
|
|
assert.equal(focused.getNodeAttribute("selected", "baseColor"), GRAPH_THEME.palette.accent.selected);
|
|
assert.equal(focused.getNodeAttribute("path", "baseColor"), GRAPH_THEME.palette.accent.path);
|
|
assert.notEqual(focused.getNodeAttribute("neighbor", "baseColor"), "#abcdef");
|
|
assert.ok(!focused.hasNode("outside"));
|
|
assert.deepEqual(buildGraphColorLegend(focused).map(({ group, color, count }) => ({ group, color, count })), [
|
|
{ group: "Person", color: "#abcdef", count: 3 },
|
|
]);
|
|
assert.equal(sourceGraph.getNodeAttribute("selected", "baseColor"), "#abcdef");
|
|
|
|
// Changing focus retains the semantic swatch while following the displayed subset.
|
|
const nextFocus = resolveDisplayGraph("neighbor", [], [], "focused").graph;
|
|
assert.deepEqual(buildGraphColorLegend(nextFocus).map(({ color, count }) => ({ color, count })), [
|
|
{ color: "#abcdef", count: 2 },
|
|
]);
|
|
});
|