mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
## Folder & Project
- Renamed `semantica-explorer/` → `explorer/` (cleaner path)
- Browser tab title: `Semantica Knowledge Explorer`
- Brand pill: `SEM` → `SKE` (tooltip: Semantica Knowledge Explorer)
- Nav rail label: `Explore` → `Knowledge Explorer`
- package.json name: `semantica-knowledge-explorer`
- Downgraded Vite 8 → Vite 5 for Node v20.17.0 compatibility
## App Shell
- Dynamic per-workspace kicker labels replacing static "Workspace" pill:
Graph Studio · Vocabulary Browser · Reasoning Engine · SPARQL Query ·
Decision Intelligence · Knowledge Audit · Graph Governance
## Enrich Workspace — 2 new tabs
### Entity Resolution tab
- Similarity threshold slider (0.50–0.99)
- Run Dedup Scan → POST /api/enrich/dedup
- Flagged pairs list with colour-coded score bars (red/amber/green)
- Expandable inline diff: primary vs duplicate side-by-side
- One-click Merge → POST /api/enrich/merge with logEvent dispatch
- Dismiss per pair; Clear all button
- Merge history sidebar pulled live from Registry store
### Registry tab (Document Registry)
- Live chronological audit log of all KG mutations in-session
- Colour-coded op-type badges: IMPORT · MERGE · ADD NODE · ADD EDGE ·
INFER · DELETE · EXPORT · VOCAB
- Filter pills to narrow by operation type
- Expandable JSON detail rows per entry
- Clear log button
- Entirely client-side via registryStore (no backend needed)
## Manage Workspace — 2 new tabs
### KG Overview tab
- Stats chips: total nodes, edges, graph density
- Node type breakdown bar chart (up to 8 types, colour-coded)
- Edge type breakdown bar chart from /api/graph/stats
- Top-10 most connected nodes ranked by degree
- Skeleton loading states + Refresh button
### Ontology Summary tab
- Read-only SKOS scheme tree (scheme → top concepts → narrower)
- Concept detail panel: labels, notation, description, narrower nav
- "Open Full Browser" button deep-links to Vocabulary Browser tab
## Decision Workspace polish
- CausalFlowDiagram: vertical node cards connected by relationship pills
- Outcome badges: colour-coded (green=approved, red=rejected, amber=deferred)
- Live filter input across decision ID, category, and outcome
- Animated skeleton loading while list fetches
## Graph Inspector polish
- PathFlowViz: clickable node chips connected by edge-type labels;
clicking a chip focuses that node in the canvas
- Link Prediction button shows spinner while computing
- Empty states for path trace and candidate links sections
## Registry dispatch — WebSocket
- ADD_NODE events → logEvent("add-node", …) in GraphWorkspace WS handler
- ADD_EDGE events → logEvent("add-edge", …) in GraphWorkspace WS handler
- Import, Export, Merge already dispatched logEvent on API response
## Graph visibility overhaul
### Edge colours (were nearly transparent, now clearly visible)
- edgeBackbone: rgba(…, 0.04) → rgba(…, 0.38)
- edgeStructure: rgba(…, 0.009) → rgba(…, 0.28)
- edgeInspection: rgba(…, 0.026) → rgba(…, 0.48)
- Muted edges: 0.009–0.02 → 0.12–0.26
- Focus edges: 0.16 → 0.42
### Edge sizes
- default minSize: 0.18 → 0.9 (always at least 1 pixel wide)
- path minSize: 1.8 → 2.4
- inactive/muted: hide:true → hide:false (dimmed not hidden)
### Node sizes
- default sizeMultiplier: 0.72 → 0.92
- default minSize: 0.68 → 3.5 (visible at all zoom levels)
- overview nodeScale: 0.66 → 0.88
- nodeTintMix (colour): 0.03 → 0.14
- nodeCoreMix (brightness): 0.52 → 0.72
### Label budget
- overview: 10 → 28 labels
- structure: 36 → 60 labels
- inspection: 80 → 120 labels
### Sigma settings
- renderEdgeLabels: false → true (relationship type on every edge)
- edgeLabelSize: — → 10
- labelRenderedSizeThreshold: 4 → 2
- labelDensity: 0.86 → 1.1
- hideLabelsOnMove: true → false (labels stay visible while panning)
- hideEdgesOnMove: true → false (edges stay visible while panning)
- minCameraRatio: — → 0.04 (prevents zooming inside a node)
- maxCameraRatio: — → 8 (graph stays visible when zoomed out)
### Zoom controls
- Added Zoom In (+) and Zoom Out (−) buttons to graph toolbar
- Smooth animated zoom via camera.animatedZoom / animatedUnzoom (200ms)
- Mouse scroll wheel clamped between minCameraRatio and maxCameraRatio
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
776 lines
22 KiB
TypeScript
776 lines
22 KiB
TypeScript
import type Graph from "graphology";
|
|
import Sigma from "sigma";
|
|
|
|
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
|
import { blendHex, GRAPH_THEME, withAlpha, zoomTierAtLeast } from "./graphTheme";
|
|
import type {
|
|
GraphAnalyticsSnapshot,
|
|
GraphEffectsState,
|
|
GraphInteractionState,
|
|
GraphTemporalState,
|
|
} from "./types";
|
|
|
|
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
|
|
|
|
export type ViewportPoint = { x: number; y: number };
|
|
|
|
export type PathSegmentOverlay = {
|
|
sourceId: string;
|
|
targetId: string;
|
|
source: ViewportPoint;
|
|
target: ViewportPoint;
|
|
color: string;
|
|
size: number;
|
|
};
|
|
|
|
export type VisibleNodeSample = {
|
|
nodeId: string;
|
|
point: ViewportPoint;
|
|
size: number;
|
|
attrs: NodeAttributes;
|
|
};
|
|
|
|
function isPointNearViewport(point: ViewportPoint, width: number, height: number, padding = 96) {
|
|
return point.x >= -padding
|
|
&& point.y >= -padding
|
|
&& point.x <= width + padding
|
|
&& point.y <= height + padding;
|
|
}
|
|
|
|
function drawGlowHalo(
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
radius: number,
|
|
color: string,
|
|
) {
|
|
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
|
|
gradient.addColorStop(0, color);
|
|
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
|
context.fillStyle = gradient;
|
|
context.beginPath();
|
|
context.arc(x, y, radius, 0, Math.PI * 2);
|
|
context.fill();
|
|
}
|
|
|
|
function createScratchCanvas(width: number, height: number) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
return canvas;
|
|
}
|
|
|
|
function parseCssColor(color: string) {
|
|
if (color.startsWith("#")) {
|
|
const hex = color.slice(1);
|
|
const normalized = hex.length === 3
|
|
? hex.split("").map((char) => `${char}${char}`).join("")
|
|
: hex;
|
|
if (normalized.length === 6) {
|
|
return {
|
|
r: Number.parseInt(normalized.slice(0, 2), 16),
|
|
g: Number.parseInt(normalized.slice(2, 4), 16),
|
|
b: Number.parseInt(normalized.slice(4, 6), 16),
|
|
};
|
|
}
|
|
}
|
|
|
|
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
|
if (match) {
|
|
return {
|
|
r: Number.parseInt(match[1], 10),
|
|
g: Number.parseInt(match[2], 10),
|
|
b: Number.parseInt(match[3], 10),
|
|
};
|
|
}
|
|
|
|
return { r: 130, g: 145, b: 165 };
|
|
}
|
|
|
|
function createThresholdMask(
|
|
alphaValues: Uint8ClampedArray,
|
|
width: number,
|
|
height: number,
|
|
threshold: number,
|
|
) {
|
|
const mask = new Uint8Array(width * height);
|
|
let count = 0;
|
|
let sumX = 0;
|
|
let sumY = 0;
|
|
let sumWeight = 0;
|
|
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
const index = y * width + x;
|
|
const alpha = alphaValues[index];
|
|
if (alpha < threshold) {
|
|
continue;
|
|
}
|
|
|
|
mask[index] = 1;
|
|
count += 1;
|
|
sumX += x * alpha;
|
|
sumY += y * alpha;
|
|
sumWeight += alpha;
|
|
}
|
|
}
|
|
|
|
const centroid = sumWeight > 0
|
|
? { x: sumX / sumWeight, y: sumY / sumWeight }
|
|
: { x: width / 2, y: height / 2 };
|
|
|
|
return { mask, count, centroid };
|
|
}
|
|
|
|
function blurAlphaValues(
|
|
alphaValues: Uint8ClampedArray,
|
|
width: number,
|
|
height: number,
|
|
passes: number,
|
|
) {
|
|
if (passes <= 0) {
|
|
return alphaValues;
|
|
}
|
|
|
|
let source = alphaValues;
|
|
for (let pass = 0; pass < passes; pass += 1) {
|
|
const next = new Uint8ClampedArray(width * height);
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
let sum = 0;
|
|
let samples = 0;
|
|
for (let oy = -1; oy <= 1; oy += 1) {
|
|
const sampleY = y + oy;
|
|
if (sampleY < 0 || sampleY >= height) {
|
|
continue;
|
|
}
|
|
for (let ox = -1; ox <= 1; ox += 1) {
|
|
const sampleX = x + ox;
|
|
if (sampleX < 0 || sampleX >= width) {
|
|
continue;
|
|
}
|
|
sum += source[sampleY * width + sampleX];
|
|
samples += 1;
|
|
}
|
|
}
|
|
next[y * width + x] = Math.round(sum / Math.max(samples, 1));
|
|
}
|
|
}
|
|
source = next;
|
|
}
|
|
|
|
return source;
|
|
}
|
|
|
|
function isolateDominantMask(
|
|
mask: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
alphaValues: Uint8ClampedArray,
|
|
) {
|
|
const visited = new Uint8Array(mask.length);
|
|
const queue = new Int32Array(mask.length);
|
|
let bestPixels: number[] = [];
|
|
let totalCount = 0;
|
|
|
|
for (let index = 0; index < mask.length; index += 1) {
|
|
if (!mask[index] || visited[index]) {
|
|
continue;
|
|
}
|
|
|
|
let head = 0;
|
|
let tail = 0;
|
|
const component: number[] = [];
|
|
visited[index] = 1;
|
|
queue[tail++] = index;
|
|
|
|
while (head < tail) {
|
|
const current = queue[head++];
|
|
component.push(current);
|
|
|
|
const x = current % width;
|
|
const y = Math.floor(current / width);
|
|
const neighbors = [
|
|
current - 1,
|
|
current + 1,
|
|
current - width,
|
|
current + width,
|
|
];
|
|
|
|
for (let n = 0; n < neighbors.length; n += 1) {
|
|
const neighbor = neighbors[n];
|
|
if (neighbor < 0 || neighbor >= mask.length || visited[neighbor] || !mask[neighbor]) {
|
|
continue;
|
|
}
|
|
if ((n === 0 && x === 0) || (n === 1 && x === width - 1) || (n === 2 && y === 0) || (n === 3 && y === height - 1)) {
|
|
continue;
|
|
}
|
|
visited[neighbor] = 1;
|
|
queue[tail++] = neighbor;
|
|
}
|
|
}
|
|
|
|
totalCount += component.length;
|
|
if (component.length > bestPixels.length) {
|
|
bestPixels = component;
|
|
}
|
|
}
|
|
|
|
const dominantMask = new Uint8Array(mask.length);
|
|
let sumX = 0;
|
|
let sumY = 0;
|
|
let sumWeight = 0;
|
|
bestPixels.forEach((index) => {
|
|
dominantMask[index] = 1;
|
|
const alpha = alphaValues[index];
|
|
const x = index % width;
|
|
const y = Math.floor(index / width);
|
|
sumX += x * alpha;
|
|
sumY += y * alpha;
|
|
sumWeight += alpha;
|
|
});
|
|
|
|
return {
|
|
mask: dominantMask,
|
|
count: bestPixels.length,
|
|
centroid: sumWeight > 0
|
|
? { x: sumX / sumWeight, y: sumY / sumWeight }
|
|
: { x: width / 2, y: height / 2 },
|
|
occupancyRatio: bestPixels.length / Math.max(width * height, 1),
|
|
dominantMassRatio: bestPixels.length / Math.max(totalCount, 1),
|
|
};
|
|
}
|
|
|
|
function renderDensityField(
|
|
samples: VisibleNodeSample[],
|
|
width: number,
|
|
height: number,
|
|
) {
|
|
const scale = GRAPH_THEME.effects.semanticRegions.fogResolutionScale;
|
|
const gridWidth = Math.max(72, Math.round(width * scale));
|
|
const gridHeight = Math.max(72, Math.round(height * scale));
|
|
const canvas = createScratchCanvas(gridWidth, gridHeight);
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
return null;
|
|
}
|
|
|
|
context.clearRect(0, 0, gridWidth, gridHeight);
|
|
context.globalCompositeOperation = "source-over";
|
|
|
|
samples.forEach((sample) => {
|
|
const x = sample.point.x * scale;
|
|
const y = sample.point.y * scale;
|
|
const radius = Math.max(
|
|
2.5,
|
|
(GRAPH_THEME.effects.semanticRegions.splatRadius + sample.size * 0.9) * scale,
|
|
);
|
|
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
|
|
gradient.addColorStop(0, "rgba(255,255,255,0.22)");
|
|
gradient.addColorStop(0.58, "rgba(255,255,255,0.08)");
|
|
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
|
context.fillStyle = gradient;
|
|
context.beginPath();
|
|
context.arc(x, y, radius, 0, Math.PI * 2);
|
|
context.fill();
|
|
});
|
|
|
|
const imageData = context.getImageData(0, 0, gridWidth, gridHeight);
|
|
const alphaValues = new Uint8ClampedArray(gridWidth * gridHeight);
|
|
let maxAlpha = 0;
|
|
for (let index = 0; index < alphaValues.length; index += 1) {
|
|
const alpha = imageData.data[index * 4 + 3];
|
|
alphaValues[index] = alpha;
|
|
if (alpha > maxAlpha) {
|
|
maxAlpha = alpha;
|
|
}
|
|
}
|
|
|
|
if (maxAlpha <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const blurredAlphaValues = blurAlphaValues(
|
|
alphaValues,
|
|
gridWidth,
|
|
gridHeight,
|
|
GRAPH_THEME.effects.semanticRegions.blurPasses,
|
|
);
|
|
const blurredMaxAlpha = blurredAlphaValues.reduce((value, alpha) => Math.max(value, alpha), 0);
|
|
if (blurredMaxAlpha <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return { canvas, gridWidth, gridHeight, alphaValues: blurredAlphaValues, maxAlpha: blurredMaxAlpha, scale };
|
|
}
|
|
|
|
function drawDensityFog(
|
|
context: CanvasRenderingContext2D,
|
|
densityField: ReturnType<typeof renderDensityField>,
|
|
color: string,
|
|
alpha: number,
|
|
) {
|
|
if (!densityField) {
|
|
return;
|
|
}
|
|
|
|
const { gridWidth, gridHeight, alphaValues } = densityField;
|
|
const fogCanvas = createScratchCanvas(gridWidth, gridHeight);
|
|
const fogContext = fogCanvas.getContext("2d");
|
|
if (!fogContext) {
|
|
return;
|
|
}
|
|
|
|
const imageData = fogContext.createImageData(gridWidth, gridHeight);
|
|
const rgb = parseCssColor(color);
|
|
for (let index = 0; index < alphaValues.length; index += 1) {
|
|
const sourceAlpha = alphaValues[index] / 255;
|
|
if (sourceAlpha <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const pixelIndex = index * 4;
|
|
imageData.data[pixelIndex] = rgb.r;
|
|
imageData.data[pixelIndex + 1] = rgb.g;
|
|
imageData.data[pixelIndex + 2] = rgb.b;
|
|
imageData.data[pixelIndex + 3] = Math.round(255 * Math.pow(sourceAlpha, 1.7) * alpha);
|
|
}
|
|
|
|
fogContext.putImageData(imageData, 0, 0);
|
|
context.save();
|
|
context.imageSmoothingEnabled = true;
|
|
context.globalCompositeOperation = "lighter";
|
|
context.drawImage(fogCanvas, 0, 0, context.canvas.width, context.canvas.height);
|
|
context.restore();
|
|
}
|
|
|
|
function drawMaskContour(
|
|
context: CanvasRenderingContext2D,
|
|
mask: Uint8Array,
|
|
gridWidth: number,
|
|
gridHeight: number,
|
|
color: string,
|
|
alpha: number,
|
|
) {
|
|
const contourCanvas = createScratchCanvas(gridWidth, gridHeight);
|
|
const contourContext = contourCanvas.getContext("2d");
|
|
if (!contourContext) {
|
|
return;
|
|
}
|
|
|
|
const imageData = contourContext.createImageData(gridWidth, gridHeight);
|
|
const rgb = parseCssColor(color);
|
|
|
|
for (let y = 1; y < gridHeight - 1; y += 1) {
|
|
for (let x = 1; x < gridWidth - 1; x += 1) {
|
|
const index = y * gridWidth + x;
|
|
if (!mask[index]) {
|
|
continue;
|
|
}
|
|
|
|
const isEdge = !mask[index - 1]
|
|
|| !mask[index + 1]
|
|
|| !mask[index - gridWidth]
|
|
|| !mask[index + gridWidth];
|
|
if (!isEdge) {
|
|
continue;
|
|
}
|
|
|
|
const pixelIndex = index * 4;
|
|
imageData.data[pixelIndex] = rgb.r;
|
|
imageData.data[pixelIndex + 1] = rgb.g;
|
|
imageData.data[pixelIndex + 2] = rgb.b;
|
|
imageData.data[pixelIndex + 3] = Math.round(255 * alpha);
|
|
}
|
|
}
|
|
|
|
contourContext.putImageData(imageData, 0, 0);
|
|
context.save();
|
|
context.imageSmoothingEnabled = true;
|
|
context.drawImage(contourCanvas, 0, 0, context.canvas.width, context.canvas.height);
|
|
context.restore();
|
|
}
|
|
|
|
function buildRegionRenderColors(color: string) {
|
|
return {
|
|
fogColor: blendHex("#0E1927", color, 0.46),
|
|
innerContourColor: blendHex("#1C2C3F", color, 0.78),
|
|
outerContourColor: blendHex("#132131", color, 0.6),
|
|
labelBorderColor: blendHex("#2B3F57", color, 0.78),
|
|
};
|
|
}
|
|
|
|
export function collectVisibleNodeSamples(
|
|
sigma: Sigma,
|
|
graphRef: GraphRef,
|
|
viewportWidth: number,
|
|
viewportHeight: number,
|
|
): VisibleNodeSample[] {
|
|
const samples: VisibleNodeSample[] = [];
|
|
|
|
graphRef.forEachNode((nodeId, attrs) => {
|
|
const displayData = sigma.getNodeDisplayData(nodeId);
|
|
if (!displayData) {
|
|
return;
|
|
}
|
|
|
|
const point = sigma.graphToViewport({ x: displayData.x, y: displayData.y });
|
|
if (!isPointNearViewport(point, viewportWidth, viewportHeight, 84)) {
|
|
return;
|
|
}
|
|
|
|
samples.push({
|
|
nodeId,
|
|
point,
|
|
size: displayData.size,
|
|
attrs: attrs as NodeAttributes,
|
|
});
|
|
});
|
|
|
|
return samples;
|
|
}
|
|
|
|
function drawRegionLabel(
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
text: string,
|
|
color: string,
|
|
) {
|
|
const label = text.length > 22 ? `${text.slice(0, 21)}…` : text;
|
|
context.save();
|
|
context.font = `600 11px "IBM Plex Sans", Inter, system-ui, sans-serif`;
|
|
context.textBaseline = "middle";
|
|
const width = context.measureText(label).width + 14;
|
|
const height = 22;
|
|
const left = x - width / 2;
|
|
const top = y - height / 2;
|
|
|
|
context.fillStyle = "rgba(8, 14, 24, 0.84)";
|
|
context.strokeStyle = withAlpha(color, 0.24);
|
|
context.lineWidth = 1;
|
|
context.beginPath();
|
|
context.roundRect(left, top, width, height, 999);
|
|
context.fill();
|
|
context.stroke();
|
|
|
|
context.fillStyle = "rgba(235, 244, 255, 0.88)";
|
|
context.fillText(label, left + 7, y);
|
|
context.restore();
|
|
}
|
|
|
|
export function drawSemanticRegionsLayer(
|
|
context: CanvasRenderingContext2D,
|
|
analytics: GraphAnalyticsSnapshot | null,
|
|
visibleNodes: VisibleNodeSample[],
|
|
interactionState: GraphInteractionState,
|
|
effectsState: GraphEffectsState,
|
|
) {
|
|
if (!effectsState.semanticRegionsEnabled || !analytics?.semanticRegions.ready) {
|
|
return;
|
|
}
|
|
|
|
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.semanticRegions.minZoomTier)) {
|
|
return;
|
|
}
|
|
|
|
const visibleByGroup = new Map<string, VisibleNodeSample[]>();
|
|
visibleNodes.forEach((sample) => {
|
|
const group = String(sample.attrs.semanticGroup || sample.attrs.nodeType || "entity");
|
|
const entry = visibleByGroup.get(group);
|
|
if (entry) {
|
|
entry.push(sample);
|
|
} else {
|
|
visibleByGroup.set(group, [sample]);
|
|
}
|
|
});
|
|
|
|
const summaries = analytics.semanticRegions.summaries
|
|
.slice(0, GRAPH_THEME.effects.semanticRegions.maxRegions);
|
|
const maxProminence = summaries.reduce((value, summary) => Math.max(value, summary.prominence), 1);
|
|
const semanticConfig = GRAPH_THEME.effects.semanticRegions;
|
|
|
|
summaries.forEach((summary) => {
|
|
const samples = visibleByGroup.get(summary.semanticGroup);
|
|
if (!samples || samples.length < semanticConfig.minVisibleSamples) {
|
|
return;
|
|
}
|
|
|
|
const densityField = renderDensityField(samples, context.canvas.width, context.canvas.height);
|
|
if (!densityField) {
|
|
return;
|
|
}
|
|
|
|
const prominenceRatio = 0.52 + (summary.prominence / maxProminence) * 0.48;
|
|
const densityThreshold = Math.max(18, densityField.maxAlpha * semanticConfig.densityThreshold);
|
|
const contourThreshold = Math.max(28, densityField.maxAlpha * semanticConfig.contourThreshold);
|
|
const outerContourThreshold = Math.max(18, contourThreshold * 0.72);
|
|
|
|
const densityMask = createThresholdMask(
|
|
densityField.alphaValues,
|
|
densityField.gridWidth,
|
|
densityField.gridHeight,
|
|
densityThreshold,
|
|
);
|
|
const dominantDensityMask = isolateDominantMask(
|
|
densityMask.mask,
|
|
densityField.gridWidth,
|
|
densityField.gridHeight,
|
|
densityField.alphaValues,
|
|
);
|
|
if (
|
|
dominantDensityMask.count < semanticConfig.minMaskPixels
|
|
|| dominantDensityMask.occupancyRatio < semanticConfig.minOccupancyRatio
|
|
|| dominantDensityMask.dominantMassRatio < semanticConfig.dominantMassRatio
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const contourMask = createThresholdMask(
|
|
densityField.alphaValues,
|
|
densityField.gridWidth,
|
|
densityField.gridHeight,
|
|
contourThreshold,
|
|
);
|
|
const outerContourMask = createThresholdMask(
|
|
densityField.alphaValues,
|
|
densityField.gridWidth,
|
|
densityField.gridHeight,
|
|
outerContourThreshold,
|
|
);
|
|
const dominantContourMask = isolateDominantMask(
|
|
contourMask.mask,
|
|
densityField.gridWidth,
|
|
densityField.gridHeight,
|
|
densityField.alphaValues,
|
|
);
|
|
const dominantOuterContourMask = isolateDominantMask(
|
|
outerContourMask.mask,
|
|
densityField.gridWidth,
|
|
densityField.gridHeight,
|
|
densityField.alphaValues,
|
|
);
|
|
|
|
const colors = buildRegionRenderColors(summary.color);
|
|
drawDensityFog(
|
|
context,
|
|
densityField,
|
|
colors.fogColor,
|
|
semanticConfig.fogAlpha * prominenceRatio,
|
|
);
|
|
if (
|
|
dominantOuterContourMask.count >= semanticConfig.outerContourMinMaskPixels
|
|
&& dominantOuterContourMask.dominantMassRatio >= semanticConfig.dominantMassRatio
|
|
) {
|
|
drawMaskContour(
|
|
context,
|
|
dominantOuterContourMask.mask,
|
|
densityField.gridWidth,
|
|
densityField.gridHeight,
|
|
colors.outerContourColor,
|
|
semanticConfig.outerContourAlpha * prominenceRatio,
|
|
);
|
|
}
|
|
drawMaskContour(
|
|
context,
|
|
dominantContourMask.mask,
|
|
densityField.gridWidth,
|
|
densityField.gridHeight,
|
|
colors.innerContourColor,
|
|
semanticConfig.innerContourAlpha * prominenceRatio,
|
|
);
|
|
|
|
if (summary.visibleNodeCount >= 18) {
|
|
const labelX = dominantDensityMask.centroid.x / densityField.scale;
|
|
const labelY = dominantDensityMask.centroid.y / densityField.scale - 18;
|
|
drawRegionLabel(
|
|
context,
|
|
labelX,
|
|
labelY,
|
|
summary.semanticGroup,
|
|
colors.labelBorderColor,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function drawContourLayer(
|
|
context: CanvasRenderingContext2D,
|
|
analytics: GraphAnalyticsSnapshot | null,
|
|
visibleNodes: VisibleNodeSample[],
|
|
interactionState: GraphInteractionState,
|
|
effectsState: GraphEffectsState,
|
|
) {
|
|
if (!effectsState.contoursEnabled || !analytics?.centrality.ready) {
|
|
return;
|
|
}
|
|
|
|
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.contours.minZoomTier)) {
|
|
return;
|
|
}
|
|
|
|
const visibleById = new Map(visibleNodes.map((sample) => [sample.nodeId, sample] as const));
|
|
|
|
analytics.centrality.topNodes.slice(0, GRAPH_THEME.effects.contours.maxContours).forEach((node) => {
|
|
const sample = visibleById.get(node.id);
|
|
if (!sample) {
|
|
return;
|
|
}
|
|
|
|
drawGlowHalo(
|
|
context,
|
|
sample.point.x,
|
|
sample.point.y,
|
|
GRAPH_THEME.effects.contours.baseRadius + sample.size * 3.2,
|
|
withAlpha(node.color, GRAPH_THEME.effects.contours.glowAlpha),
|
|
);
|
|
});
|
|
}
|
|
|
|
function isTemporalNodeActive(attrs: NodeAttributes, currentTime: Date | null) {
|
|
if (!currentTime || (!attrs.valid_from && !attrs.valid_until)) {
|
|
return false;
|
|
}
|
|
|
|
const time = currentTime.getTime();
|
|
const from = attrs.valid_from ? new Date(attrs.valid_from).getTime() : Number.NEGATIVE_INFINITY;
|
|
const until = attrs.valid_until ? new Date(attrs.valid_until).getTime() : Number.POSITIVE_INFINITY;
|
|
return time >= from && time <= until;
|
|
}
|
|
|
|
export function drawTemporalEmphasisLayer(
|
|
context: CanvasRenderingContext2D,
|
|
visibleNodes: VisibleNodeSample[],
|
|
temporalState: GraphTemporalState | null | undefined,
|
|
interactionState: GraphInteractionState,
|
|
effectsState: GraphEffectsState,
|
|
) {
|
|
if (!effectsState.temporalEmphasisEnabled || !temporalState?.currentTime) {
|
|
return;
|
|
}
|
|
|
|
if (!zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.temporalEmphasis.minZoomTier)) {
|
|
return;
|
|
}
|
|
|
|
visibleNodes
|
|
.filter((sample) => isTemporalNodeActive(sample.attrs, temporalState.currentTime))
|
|
.slice(0, GRAPH_THEME.effects.temporalEmphasis.maxHighlights)
|
|
.forEach((sample) => {
|
|
drawGlowHalo(
|
|
context,
|
|
sample.point.x,
|
|
sample.point.y,
|
|
Math.max(sample.size * GRAPH_THEME.effects.temporalEmphasis.radiusMultiplier, 14),
|
|
withAlpha(GRAPH_THEME.palette.accent.temporal, GRAPH_THEME.effects.temporalEmphasis.glowAlpha),
|
|
);
|
|
});
|
|
}
|
|
|
|
export function drawLensLayer(
|
|
context: CanvasRenderingContext2D,
|
|
sigma: Sigma,
|
|
primaryNodeId: string,
|
|
focusIds: Set<string>,
|
|
) {
|
|
const primaryData = sigma.getNodeDisplayData(primaryNodeId);
|
|
if (!primaryData) {
|
|
return;
|
|
}
|
|
|
|
const center = sigma.graphToViewport({ x: primaryData.x, y: primaryData.y });
|
|
drawGlowHalo(
|
|
context,
|
|
center.x,
|
|
center.y,
|
|
GRAPH_THEME.effects.lens.radius,
|
|
withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.glowAlpha),
|
|
);
|
|
|
|
focusIds.forEach((neighborId) => {
|
|
if (neighborId === primaryNodeId || !graph.hasNode(neighborId)) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!graph.hasDirectedEdge(primaryNodeId, neighborId)
|
|
&& !graph.hasDirectedEdge(neighborId, primaryNodeId)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const neighborData = sigma.getNodeDisplayData(neighborId);
|
|
if (!neighborData) {
|
|
return;
|
|
}
|
|
|
|
const neighborPoint = sigma.graphToViewport({ x: neighborData.x, y: neighborData.y });
|
|
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.edgeAlpha * 0.38);
|
|
context.lineWidth = GRAPH_THEME.effects.lens.edgeLineWidth + 3.2;
|
|
context.lineCap = "round";
|
|
context.beginPath();
|
|
context.moveTo(center.x, center.y);
|
|
context.lineTo(neighborPoint.x, neighborPoint.y);
|
|
context.stroke();
|
|
|
|
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.hovered, GRAPH_THEME.effects.lens.edgeAlpha);
|
|
context.lineWidth = GRAPH_THEME.effects.lens.edgeLineWidth;
|
|
context.beginPath();
|
|
context.moveTo(center.x, center.y);
|
|
context.lineTo(neighborPoint.x, neighborPoint.y);
|
|
context.stroke();
|
|
});
|
|
}
|
|
|
|
export function drawPathEffectsLayer(
|
|
context: CanvasRenderingContext2D,
|
|
segments: PathSegmentOverlay[],
|
|
effectsState: GraphEffectsState,
|
|
effectAvailability: {
|
|
pathPulse: { available: boolean };
|
|
pathFlow: { available: boolean };
|
|
},
|
|
now: number,
|
|
) {
|
|
if (effectsState.pathFlowEnabled && effectAvailability.pathFlow.available) {
|
|
segments.forEach((segment, index) => {
|
|
const t = ((now * GRAPH_THEME.effects.pathFlow.speed) + index * GRAPH_THEME.effects.pathFlow.spacing) % 1;
|
|
const headX = segment.source.x + (segment.target.x - segment.source.x) * t;
|
|
const headY = segment.source.y + (segment.target.y - segment.source.y) * t;
|
|
const tailT = Math.max(0, t - 0.08);
|
|
const tailX = segment.source.x + (segment.target.x - segment.source.x) * tailT;
|
|
const tailY = segment.source.y + (segment.target.y - segment.source.y) * tailT;
|
|
|
|
context.strokeStyle = withAlpha(segment.color, 0.28);
|
|
context.lineWidth = Math.max(segment.size + 3.6, 4.6);
|
|
context.lineCap = "round";
|
|
context.beginPath();
|
|
context.moveTo(tailX, tailY);
|
|
context.lineTo(headX, headY);
|
|
context.stroke();
|
|
|
|
context.strokeStyle = withAlpha(GRAPH_THEME.palette.accent.selected, GRAPH_THEME.effects.pathFlow.opacity);
|
|
context.lineWidth = Math.max(segment.size + 1.3, 2.4);
|
|
context.beginPath();
|
|
context.moveTo(tailX, tailY);
|
|
context.lineTo(headX, headY);
|
|
context.stroke();
|
|
});
|
|
}
|
|
|
|
if (effectsState.pathPulseEnabled && effectAvailability.pathPulse.available) {
|
|
segments.forEach((segment, index) => {
|
|
const t = ((now * GRAPH_THEME.effects.pathPulse.speed) + index * 0.17) % 1;
|
|
const x = segment.source.x + (segment.target.x - segment.source.x) * t;
|
|
const y = segment.source.y + (segment.target.y - segment.source.y) * t;
|
|
const glow = context.createRadialGradient(x, y, 0, x, y, GRAPH_THEME.effects.pathPulse.radius);
|
|
glow.addColorStop(0, withAlpha(GRAPH_THEME.palette.accent.path, GRAPH_THEME.effects.pathPulse.glowAlpha));
|
|
glow.addColorStop(1, "rgba(0,0,0,0)");
|
|
context.fillStyle = glow;
|
|
context.beginPath();
|
|
context.arc(x, y, GRAPH_THEME.effects.pathPulse.radius, 0, Math.PI * 2);
|
|
context.fill();
|
|
});
|
|
}
|
|
}
|