diff --git a/index.html b/index.html
index d2fc4ffa..262467d4 100644
--- a/index.html
+++ b/index.html
@@ -6,6 +6,7 @@
+
diff --git a/src/components/canvas/LocalGraphView.tsx b/src/components/canvas/LocalGraphView.tsx
index df8591b4..a096f9d3 100644
--- a/src/components/canvas/LocalGraphView.tsx
+++ b/src/components/canvas/LocalGraphView.tsx
@@ -12,6 +12,45 @@ const SIZE_CENTER = 18;
const SIZE_DEPTH_1 = 11;
const SIZE_DEPTH_2 = 7;
+/**
+ * Kill a Sigma instance and proactively release its WebGL contexts.
+ *
+ * `sigma.kill()` detaches its canvases but the browser may hold the underlying
+ * WebGL contexts until GC. Browsers cap concurrent contexts per tab (~16 on
+ * Chrome, ~8 on Safari), and rapid re-centers of the local graph can exhaust
+ * the pool — the next `new Sigma` then gets a canvas whose `getContext`
+ * returns null and crashes on its first GL call (e.g. `gl.blendFunc`).
+ * `WEBGL_lose_context.loseContext()` frees the slot immediately.
+ */
+function releaseCanvasWebGlContext(canvas: HTMLCanvasElement) {
+ const gl =
+ (canvas.getContext('webgl2') as WebGL2RenderingContext | null) ??
+ (canvas.getContext('webgl') as WebGLRenderingContext | null);
+ gl?.getExtension('WEBGL_lose_context')?.loseContext();
+}
+
+function releaseCanvases(canvases: HTMLCanvasElement[]) {
+ for (const canvas of canvases) {
+ releaseCanvasWebGlContext(canvas);
+ }
+}
+
+function releaseSigma(sigma: Sigma, container: HTMLElement) {
+ const canvases = Array.from(container.querySelectorAll('canvas'));
+ sigma.kill();
+ releaseCanvases(canvases);
+}
+
+function releasePartialSigmaCanvases(container: HTMLElement, existingCanvases: Set) {
+ const partialCanvases = Array.from(container.querySelectorAll('canvas')).filter(
+ canvas => !existingCanvases.has(canvas)
+ );
+ releaseCanvases(partialCanvases);
+ for (const canvas of partialCanvases) {
+ canvas.remove();
+ }
+}
+
/** Brighten/dim an [r,g,b] triple by a 0..1 factor. */
function modulateRgb(rgb: [number, number, number], factor: number): string {
return `rgb(${Math.round(rgb[0] * factor)},${Math.round(rgb[1] * factor)},${Math.round(rgb[2] * factor)})`;
@@ -233,7 +272,7 @@ export function LocalGraphView() {
if (!container || !graph || graph.atoms.length === 0) return;
if (sigmaRef.current) {
- sigmaRef.current.kill();
+ releaseSigma(sigmaRef.current, container);
sigmaRef.current = null;
}
@@ -329,56 +368,69 @@ export function LocalGraphView() {
}
neighborsRef.current = neighbors;
- const sigma = new Sigma(g, container, {
- // Labels are rendered by our overlay canvas (always-on with collision avoidance).
- renderLabels: false,
- defaultEdgeColor: '#333',
- defaultNodeColor: '#555',
- defaultEdgeType: 'curved',
- zIndex: true,
- edgeProgramClasses: {
- curved: EdgeCurveProgram,
- },
- minCameraRatio: 0.2,
- maxCameraRatio: 4,
- stagePadding: 80,
- defaultDrawNodeHover: () => {}, // Hover ring/pill drawn on overlay
- nodeReducer: (node, attrs) => {
- const hovered = hoveredNodeRef.current;
- if (!hovered) return attrs;
- if (node === hovered) return { ...attrs, zIndex: 2 };
- const isNeighbor = neighborsRef.current.get(hovered)?.has(node);
- if (isNeighbor) return { ...attrs, zIndex: 1 };
- // Non-neighbors fade toward gray. Sizes stay put — in a small ego-network shrinking
- // most of the nodes at once reads as "the whole graph just got smaller", which is
- // disorienting. Color fade is enough to direct attention.
- const dim = hoverAnimRef.current;
- const rgb = parseRgbColor(attrs.color as string);
- const color = rgb
- ? `rgb(${Math.round(rgb[0] + (60 - rgb[0]) * dim)},${Math.round(rgb[1] + (60 - rgb[1]) * dim)},${Math.round(rgb[2] + (60 - rgb[2]) * dim)})`
- : attrs.color;
- return { ...attrs, color };
- },
- edgeReducer: (edge, attrs) => {
- const hovered = hoveredNodeRef.current;
- if (!hovered) return attrs;
- const src = g.source(edge);
- const dst = g.target(edge);
- const incident = src === hovered || dst === hovered;
- const dim = hoverAnimRef.current;
- if (incident) {
- // Brighten incident edges via color (no size pump — same reason as nodeReducer).
- return { ...attrs, zIndex: 1 };
- }
- // Fade non-incident edges toward the background (color only).
- const rgb = parseRgbColor(attrs.color as string);
- const bg = parseRgbColor(themeRef.current.background) ?? [30, 30, 30];
- const color = rgb
- ? `rgb(${Math.round(rgb[0] + (bg[0] - rgb[0]) * dim * 0.85)},${Math.round(rgb[1] + (bg[1] - rgb[1]) * dim * 0.85)},${Math.round(rgb[2] + (bg[2] - rgb[2]) * dim * 0.85)})`
- : attrs.color;
- return { ...attrs, color };
- },
- });
+ let sigma: Sigma;
+ const existingCanvases = new Set(container.querySelectorAll('canvas'));
+ try {
+ sigma = new Sigma(g, container, {
+ // Labels are rendered by our overlay canvas (always-on with collision avoidance).
+ renderLabels: false,
+ defaultEdgeColor: '#333',
+ defaultNodeColor: '#555',
+ defaultEdgeType: 'curved',
+ zIndex: true,
+ edgeProgramClasses: {
+ curved: EdgeCurveProgram,
+ },
+ minCameraRatio: 0.2,
+ maxCameraRatio: 4,
+ stagePadding: 80,
+ defaultDrawNodeHover: () => {}, // Hover ring/pill drawn on overlay
+ nodeReducer: (node, attrs) => {
+ const hovered = hoveredNodeRef.current;
+ if (!hovered) return attrs;
+ if (node === hovered) return { ...attrs, zIndex: 2 };
+ const isNeighbor = neighborsRef.current.get(hovered)?.has(node);
+ if (isNeighbor) return { ...attrs, zIndex: 1 };
+ // Non-neighbors fade toward gray. Sizes stay put — in a small ego-network shrinking
+ // most of the nodes at once reads as "the whole graph just got smaller", which is
+ // disorienting. Color fade is enough to direct attention.
+ const dim = hoverAnimRef.current;
+ const rgb = parseRgbColor(attrs.color as string);
+ const color = rgb
+ ? `rgb(${Math.round(rgb[0] + (60 - rgb[0]) * dim)},${Math.round(rgb[1] + (60 - rgb[1]) * dim)},${Math.round(rgb[2] + (60 - rgb[2]) * dim)})`
+ : attrs.color;
+ return { ...attrs, color };
+ },
+ edgeReducer: (edge, attrs) => {
+ const hovered = hoveredNodeRef.current;
+ if (!hovered) return attrs;
+ const src = g.source(edge);
+ const dst = g.target(edge);
+ const incident = src === hovered || dst === hovered;
+ const dim = hoverAnimRef.current;
+ if (incident) {
+ // Brighten incident edges via color (no size pump — same reason as nodeReducer).
+ return { ...attrs, zIndex: 1 };
+ }
+ // Fade non-incident edges toward the background (color only).
+ const rgb = parseRgbColor(attrs.color as string);
+ const bg = parseRgbColor(themeRef.current.background) ?? [30, 30, 30];
+ const color = rgb
+ ? `rgb(${Math.round(rgb[0] + (bg[0] - rgb[0]) * dim * 0.85)},${Math.round(rgb[1] + (bg[1] - rgb[1]) * dim * 0.85)},${Math.round(rgb[2] + (bg[2] - rgb[2]) * dim * 0.85)})`
+ : attrs.color;
+ return { ...attrs, color };
+ },
+ });
+ } catch (err) {
+ // WebGL context unavailable — typically the browser has hit its per-tab
+ // context limit. Surface a graceful error instead of letting the crash
+ // bubble up to the route-level Error Boundary and blank the page.
+ console.error('LocalGraphView: failed to initialize graph renderer', err);
+ releasePartialSigmaCanvases(container, existingCanvases);
+ setError('Could not initialize the graph renderer. Try closing other tabs that use graph or 3D views, or reload the page.');
+ graphRef.current = null;
+ return;
+ }
sigmaRef.current = sigma;
@@ -591,7 +643,7 @@ export function LocalGraphView() {
return () => {
if (hoverRaf !== null) cancelAnimationFrame(hoverRaf);
- sigma.kill();
+ releaseSigma(sigma, container);
labelCanvas.remove();
sigmaRef.current = null;
graphRef.current = null;