From 8637df2f0d72dd789ab85ebed342e6afdf4ef8b8 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:08:59 +0100 Subject: [PATCH 01/14] added dialog & actions --- demo/index.html | 108 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/demo/index.html b/demo/index.html index ad1b083..ac86e23 100644 --- a/demo/index.html +++ b/demo/index.html @@ -32,6 +32,9 @@ {{ darkMode ? 'light' : 'dark' }}
+ @@ -137,6 +140,109 @@ + + +
@@ -147,4 +253,4 @@

WebGPU Not Available

- + \ No newline at end of file From 80ef369f200ca4f5f41615ff33cae69205e64213 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:09:07 +0100 Subject: [PATCH 02/14] added settings-related styles --- demo/src/style.scss | 93 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/demo/src/style.scss b/demo/src/style.scss index e69ba04..9779f8c 100644 --- a/demo/src/style.scss +++ b/demo/src/style.scss @@ -556,6 +556,99 @@ body { gap: 3px; } +// ============================================================ +// Settings Modal +// ============================================================ + +.settings-card { + width: 420px; + + .modal-body { + padding: 12px 16px 16px; + gap: 16px; + max-height: 70vh; + overflow-y: auto; + } +} + +.settings-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.settings-section-title { + @include mono-label; + color: var(--accent); + padding-bottom: 4px; + border-bottom: 1px solid var(--border-subtle); +} + +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + label { + font-size: 12px; + font-weight: 500; + color: var(--text-dim); + white-space: nowrap; + min-width: 110px; + } +} + +.slider-group { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + + input[type='range'] { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--border); + border-radius: 2px; + outline: none; + cursor: pointer; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg-surface); + box-shadow: 0 0 0 1px var(--border); + cursor: pointer; + transition: transform 0.1s; + + &:hover { transform: scale(1.15); } + } + + &::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg-surface); + box-shadow: 0 0 0 1px var(--border); + cursor: pointer; + } + } +} + +.slider-val { + font-family: $font-mono; + font-size: 11px; + color: var(--text-muted); + min-width: 38px; + text-align: right; +} + // ============================================================ // Fallback // ============================================================ From a3b4fb88f03ba1d5eb2a6aff262a99c8873ef820 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:14:16 +0100 Subject: [PATCH 03/14] also added backend implementation --- demo/src/main.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/demo/src/main.ts b/demo/src/main.ts index 41f5038..05c0b77 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -26,6 +26,18 @@ interface DeleteModalState { label: string; } +interface SettingsModalState { + open: boolean; + nodeSize: number; + edgeOpacity: number; + gravitationalConstant: number; + springLength: number; + springConstant: number; + centralGravity: number; + damping: number; + barnesHutTheta: number; +} + // ── Constants ── const NODE_TYPE_TAGS = ['person', 'movie', 'country', 'book'] as const; @@ -35,6 +47,17 @@ const LAYOUT_OPTS = { maxIterations: 1000, } as const; +const DEFAULT_SETTINGS: Omit = { + nodeSize: 8, + edgeOpacity: 0.8, + gravitationalConstant: -0.25, + springLength: 0.2, + springConstant: 0.06, + centralGravity: 0.012, + damping: 0.18, + barnesHutTheta: 0.3, +}; + const LIGHT_BG: [number, number, number, number] = [0.96, 0.96, 0.965, 1]; const DARK_BG: [number, number, number, number] = [0.118, 0.122, 0.149, 1]; @@ -80,6 +103,10 @@ createApp({ // Modals const editModal = reactive({ open: false, nodeId: null, properties: {} }); const deleteModal = reactive({ open: false, nodeId: null, label: '' }); + const settingsModal = reactive({ + open: false, + ...DEFAULT_SETTINGS, + }); // Legend const legendItems = ref([]); @@ -209,6 +236,54 @@ createApp({ updateCounts(); } + // ── Settings ── + + function getPhysicsOpts() { + return { + gravitationalConstant: settingsModal.gravitationalConstant, + springLength: settingsModal.springLength, + springConstant: settingsModal.springConstant, + centralGravity: settingsModal.centralGravity, + damping: settingsModal.damping, + barnesHutTheta: settingsModal.barnesHutTheta, + maxIterations: 1000, + }; + } + + function onSettingChange(key: string, event: Event): void { + const val = parseFloat((event.target as HTMLInputElement).value); + (settingsModal as any)[key] = val; + + if (!g) return; + + // Appearance — apply immediately + if (key === 'nodeSize') { + g.setNodeSize(val); + } else if (key === 'edgeOpacity') { + g.setEdgeOpacity(val); + } + + // Physics — restart layout with new params + if (['gravitationalConstant', 'springLength', 'springConstant', + 'centralGravity', 'damping', 'barnesHutTheta'].includes(key)) { + if (layoutRunning.value) { + g.stopLayout(); + g.startLayout(getPhysicsOpts()); + } + } + } + + function resetSettings(): void { + Object.assign(settingsModal, DEFAULT_SETTINGS); + if (!g) return; + g.setNodeSize(DEFAULT_SETTINGS.nodeSize); + g.setEdgeOpacity(DEFAULT_SETTINGS.edgeOpacity); + if (layoutRunning.value) { + g.stopLayout(); + g.startLayout(getPhysicsOpts()); + } + } + // ── Init ── onMounted(async () => { @@ -310,10 +385,11 @@ createApp({ hasSelection, tooltipVisible, tooltipStyle, tooltipTag, tooltipName, tooltipProps, tooltipColor, legendItems, paletteNames, - editModal, deleteModal, + editModal, deleteModal, settingsModal, toggleLayout, fitView, resetGraph, toggleAnimated, toggleDarkMode, switchPalette, getPalettePreview, showEditModal, saveEdit, deleteSelected, confirmDelete, + onSettingChange, resetSettings, }; }, }).mount('#app'); From 4fc0e77b0e76173e054fbb595bf5383ff583278f Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:18:27 +0100 Subject: [PATCH 04/14] add public methods to change node size & edge opacity --- src/index.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/index.ts b/src/index.ts index 49916bc..96865d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -270,6 +270,7 @@ export class GraphGPU { */ startLayout(opts?: { gravitationalConstant?: number; + barnesHutTheta?: number; springLength?: number; springConstant?: number; centralGravity?: number; @@ -473,6 +474,20 @@ export class GraphGPU { this.renderer.setBackground(color); } + /** + * Change node size at runtime. + */ + setNodeSize(size: number): void { + this.renderer.setNodeScale(size); + } + + /** + * Change edge opacity at runtime. + */ + setEdgeOpacity(opacity: number): void { + this.renderer.setEdgeOpacity(opacity); + } + // ========================================================= // Selection // ========================================================= From 2d7d9c18084d064006268f23dd9767edc59b4c11 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:18:56 +0100 Subject: [PATCH 05/14] also update edge label size --- src/core/Renderer.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 6f1679a..36adbb2 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -566,6 +566,16 @@ export class Renderer { this.backgroundColor = color; } + /** Change node scale at runtime */ + setNodeScale(scale: number): void { + this.nodeScale = scale; + } + + /** Change edge opacity at runtime */ + setEdgeOpacity(opacity: number): void { + this.edgeOpacity = opacity; + } + /** Set selection state for a node (1.0 = selected, 0.0 = not) */ setSelection(nodeId: number, selected: boolean): void { if (this.selectionData.length <= nodeId) { @@ -689,7 +699,7 @@ export class Renderer { // ---- Edge labels ---- // Render edge tags at the midpoint of each edge, sized by zoom const camZoomGlobal = Math.abs(this.camera.matrix[0]); - const edgeFontSize = Math.max(8, Math.min(camZoomGlobal * cw * 0.012, 16)); + const edgeFontSize = Math.max(9, Math.min(camZoomGlobal * cw * 0.028, 22)); // Compute edge visual width in CSS pixels (mirrors shader logic) // Edge width matches shader: 15% of projected node radius @@ -698,7 +708,7 @@ export class Renderer { const edgeWidthPx = Math.max(2.5, projectedNodeR * 1.0); // Only render edge labels when zoomed in enough to read them - if (edgeFontSize >= 9) { + if (edgeFontSize >= 9.5) { lctx.font = `500 ${edgeFontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; lctx.textAlign = 'center'; lctx.textBaseline = 'middle'; From 911de8a29308bc9526133c84a64d58a049dd51f8 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:21:56 +0100 Subject: [PATCH 06/14] further optimize edge labels --- src/core/Renderer.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 36adbb2..857e58f 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -699,16 +699,17 @@ export class Renderer { // ---- Edge labels ---- // Render edge tags at the midpoint of each edge, sized by zoom const camZoomGlobal = Math.abs(this.camera.matrix[0]); - const edgeFontSize = Math.max(9, Math.min(camZoomGlobal * cw * 0.028, 22)); - // Compute edge visual width in CSS pixels (mirrors shader logic) // Edge width matches shader: 15% of projected node radius const nodeWorldSize = this.nodeScale * 0.01; const projectedNodeR = nodeWorldSize * camZoomGlobal * cw * 0.5; const edgeWidthPx = Math.max(2.5, projectedNodeR * 1.0); + // Edge label size tracks projected node radius (consistent with node labels) + const edgeFontSize = Math.max(8, Math.min(projectedNodeR * 0.45, 18)); + // Only render edge labels when zoomed in enough to read them - if (edgeFontSize >= 9.5) { + if (edgeFontSize >= 8.5) { lctx.font = `500 ${edgeFontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; lctx.textAlign = 'center'; lctx.textBaseline = 'middle'; @@ -784,4 +785,4 @@ export class Renderer { visibleNodes: 0, visibleEdges: 0, }; } -} +} \ No newline at end of file From 95c4915902f85dbd4c57eacb84c408589903267d Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:32:41 +0100 Subject: [PATCH 07/14] another fix --- src/core/Renderer.ts | 125 +++++++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 51 deletions(-) diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 857e58f..472e3ec 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -654,62 +654,32 @@ export class Renderer { const positions = this.graph.positions; const sizes = this.graph.sizes; + const camZoomGlobal = Math.abs(this.camera.matrix[0]); + + // ---- Pre-compute node screen positions & radii for overlap culling ---- + const nodeScreenData: { sx: number; sy: number; r: number }[] = []; for (const id of this.graph.activeNodeIds()) { const wx = positions[id * 2]; const wy = positions[id * 2 + 1]; const [sx, sy] = this.worldToCSS(wx, wy); - - if (sx < -60 || sx > cw + 60 || sy < -60 || sy > ch + 60) continue; - const nodeWorldSize = sizes[id] * this.nodeScale * 0.01; - const camZoom = Math.abs(this.camera.matrix[0]); - const nodeScreenR = nodeWorldSize * camZoom * cw * 0.5; - - if (nodeScreenR < 16) continue; - - const node = this.graph.getNode(id); - if (!node) continue; - const label = (node.properties.name ?? node.properties.title ?? node.tag) as string; - if (!label) continue; - - const fontSize = Math.max(7, Math.min(nodeScreenR * 0.28, 22)); - lctx.font = `600 ${fontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; - - const maxW = nodeScreenR * 1.6; - let disp = label; - if (lctx.measureText(disp).width > maxW) { - let lo = 1, hi = label.length; - while (lo < hi) { - const mid = (lo + hi + 1) >> 1; - if (lctx.measureText(label.slice(0, mid) + '…').width <= maxW) lo = mid; else hi = mid - 1; - } - disp = label.slice(0, lo) + '…'; - } - if (lctx.measureText(disp).width > maxW) continue; - - const r = this.graph.colors[id * 4]; - const g = this.graph.colors[id * 4 + 1]; - const b = this.graph.colors[id * 4 + 2]; - const lum = r * 0.2126 + g * 0.7152 + b * 0.0722; - lctx.fillStyle = lum > 0.189 ? 'rgba(0,0,0,0.88)' : 'rgba(255,255,255,0.93)'; - - lctx.fillText(disp, sx, sy); + const nodeScreenR = nodeWorldSize * camZoomGlobal * cw * 0.5; + nodeScreenData[id] = { sx, sy, r: nodeScreenR }; } - // ---- Edge labels ---- - // Render edge tags at the midpoint of each edge, sized by zoom - const camZoomGlobal = Math.abs(this.camera.matrix[0]); - // Compute edge visual width in CSS pixels (mirrors shader logic) - // Edge width matches shader: 15% of projected node radius - const nodeWorldSize = this.nodeScale * 0.01; - const projectedNodeR = nodeWorldSize * camZoomGlobal * cw * 0.5; + // ---- Edge labels (drawn FIRST so node labels paint over them) ---- + const baseNodeWorldSize = this.nodeScale * 0.01; + const projectedNodeR = baseNodeWorldSize * camZoomGlobal * cw * 0.5; const edgeWidthPx = Math.max(2.5, projectedNodeR * 1.0); - // Edge label size tracks projected node radius (consistent with node labels) - const edgeFontSize = Math.max(8, Math.min(projectedNodeR * 0.45, 18)); + // Edge labels are always 65% of the node label font size. + // Node labels: max(7, min(nodeScreenR * 0.28, 22)), visible when nodeScreenR >= 16. + // Use projectedNodeR (base node size) as the reference. + const nodeRefFontSize = Math.max(7, Math.min(projectedNodeR * 0.28, 22)); + const edgeFontSize = Math.max(7, nodeRefFontSize * 0.65); - // Only render edge labels when zoomed in enough to read them - if (edgeFontSize >= 8.5) { + // Only show edge labels when node labels are visible (projectedNodeR >= 16) + if (projectedNodeR >= 16) { lctx.font = `500 ${edgeFontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; lctx.textAlign = 'center'; lctx.textBaseline = 'middle'; @@ -718,7 +688,6 @@ export class Renderer { const edgeIndices = this.graph.edgeIndices; const pos = this.graph.positions; - // Offset: half the rendered edge line width + comfortable gap + half font height const labelOffset = edgeWidthPx * 0.5 + edgeFontSize * 0.55 + 3; for (const eid of this.graph.activeEdgeIds()) { @@ -734,7 +703,6 @@ export class Renderer { const my = (pos[src * 2 + 1] + pos[tgt * 2 + 1]) * 0.5; const [sx, sy] = this.worldToCSS(mx, my); - // Skip off-screen labels if (sx < -80 || sx > cw + 80 || sy < -40 || sy > ch + 40) continue; // Compute edge angle for rotated text @@ -742,23 +710,78 @@ export class Renderer { const [sx2, sy2] = this.worldToCSS(pos[tgt * 2], pos[tgt * 2 + 1]); let angle = Math.atan2(sy2 - sy1, sx2 - sx1); - // Keep text readable (never upside down) if (angle > Math.PI / 2) angle -= Math.PI; if (angle < -Math.PI / 2) angle += Math.PI; - // Skip very short edges where label won't fit const edgeLen = Math.hypot(sx2 - sx1, sy2 - sy1); if (edgeLen < 40) continue; + // Compute the actual label position (offset perpendicular to edge) + const perpX = -Math.sin(angle) * labelOffset; + const perpY = Math.cos(angle) * labelOffset; + const labelX = sx + perpX; + const labelY = sy - Math.abs(perpY); + + // Skip if label center overlaps any node circle + let overlapsNode = false; + for (const nid of this.graph.activeNodeIds()) { + const nd = nodeScreenData[nid]; + if (!nd) continue; + const dx = labelX - nd.sx; + const dy = labelY - nd.sy; + if (dx * dx + dy * dy < nd.r * nd.r) { + overlapsNode = true; + break; + } + } + if (overlapsNode) continue; + lctx.save(); lctx.translate(sx, sy); lctx.rotate(angle); - // Offset above the edge line, accounting for edge visual thickness lctx.fillText(edge.tag, 0, -labelOffset); lctx.restore(); } } + // ---- Node labels (drawn AFTER edge labels so they paint on top) ---- + for (const id of this.graph.activeNodeIds()) { + const nd = nodeScreenData[id]; + if (!nd) continue; + const { sx, sy, r: nodeScreenR } = nd; + + if (sx < -60 || sx > cw + 60 || sy < -60 || sy > ch + 60) continue; + if (nodeScreenR < 16) continue; + + const node = this.graph.getNode(id); + if (!node) continue; + const label = (node.properties.name ?? node.properties.title ?? node.tag) as string; + if (!label) continue; + + const fontSize = Math.max(7, Math.min(nodeScreenR * 0.28, 22)); + lctx.font = `600 ${fontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; + + const maxW = nodeScreenR * 1.6; + let disp = label; + if (lctx.measureText(disp).width > maxW) { + let lo = 1, hi = label.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lctx.measureText(label.slice(0, mid) + '…').width <= maxW) lo = mid; else hi = mid - 1; + } + disp = label.slice(0, lo) + '…'; + } + if (lctx.measureText(disp).width > maxW) continue; + + const r = this.graph.colors[id * 4]; + const g = this.graph.colors[id * 4 + 1]; + const b = this.graph.colors[id * 4 + 2]; + const lum = r * 0.2126 + g * 0.7152 + b * 0.0722; + lctx.fillStyle = lum > 0.189 ? 'rgba(0,0,0,0.88)' : 'rgba(255,255,255,0.93)'; + + lctx.fillText(disp, sx, sy); + } + lctx.restore(); } } From 40a32af00e85af5e2aa879d03b1a04961a55dd36 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:46:43 +0100 Subject: [PATCH 08/14] actually fix edge label sizes --- src/core/Renderer.ts | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 472e3ec..fa26ef0 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -672,24 +672,23 @@ export class Renderer { const projectedNodeR = baseNodeWorldSize * camZoomGlobal * cw * 0.5; const edgeWidthPx = Math.max(2.5, projectedNodeR * 1.0); - // Edge labels are always 65% of the node label font size. - // Node labels: max(7, min(nodeScreenR * 0.28, 22)), visible when nodeScreenR >= 16. - // Use projectedNodeR (base node size) as the reference. - const nodeRefFontSize = Math.max(7, Math.min(projectedNodeR * 0.28, 22)); - const edgeFontSize = Math.max(7, nodeRefFontSize * 0.65); - - // Only show edge labels when node labels are visible (projectedNodeR >= 16) - if (projectedNodeR >= 16) { - lctx.font = `500 ${edgeFontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; + // Only show edge labels when node labels are visible. + // Node labels appear when nodeScreenR >= 16. Check any active node. + let anyNodeLabelVisible = false; + for (const id of this.graph.activeNodeIds()) { + if (nodeScreenData[id] && nodeScreenData[id].r >= 16) { + anyNodeLabelVisible = true; + break; + } + } + + if (anyNodeLabelVisible) { lctx.textAlign = 'center'; lctx.textBaseline = 'middle'; - lctx.fillStyle = 'rgba(160,155,175,0.85)'; const edgeIndices = this.graph.edgeIndices; const pos = this.graph.positions; - const labelOffset = edgeWidthPx * 0.5 + edgeFontSize * 0.55 + 3; - for (const eid of this.graph.activeEdgeIds()) { const src = edgeIndices[eid * 2]; const tgt = edgeIndices[eid * 2 + 1]; @@ -698,6 +697,21 @@ export class Renderer { const edge = this.graph.getEdge(eid); if (!edge || !edge.tag) continue; + // Use the average screen radius of src and tgt nodes. + // This is the CORRECT nodeScreenR (includes sizes[id] * nodeScale). + const srcR = nodeScreenData[src]?.r ?? 0; + const tgtR = nodeScreenData[tgt]?.r ?? 0; + const avgNodeR = (srcR + tgtR) * 0.5; + + // Edge font = 65% of what the node label would be WITHOUT the 22px cap. + // No cap on edge labels — they scale with zoom just like everything else. + const edgeFontSize = Math.max(7, avgNodeR * 0.28 * 0.65); + + lctx.font = `500 ${edgeFontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; + lctx.fillStyle = 'rgba(160,155,175,0.85)'; + + const labelOffset = edgeWidthPx * 0.5 + edgeFontSize * 0.55 + 3; + // Midpoint in world coords const mx = (pos[src * 2] + pos[tgt * 2]) * 0.5; const my = (pos[src * 2 + 1] + pos[tgt * 2 + 1]) * 0.5; From f785731a50d3dd821a35622827c5dce36667aef1 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:49:42 +0100 Subject: [PATCH 09/14] hide edge labels properly to avoid overlap with node circles --- src/core/Renderer.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index fa26ef0..931e543 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -736,17 +736,34 @@ export class Renderer { const labelX = sx + perpX; const labelY = sy - Math.abs(perpY); - // Skip if label center overlaps any node circle + // Skip if any part of the label overlaps any node circle. + // Test the label's bounding box corners (rotated) against node circles. + const textW = lctx.measureText(edge.tag).width * 0.5 + 4; + const textH = edgeFontSize * 0.6 + 4; + // Four corners of the label bbox relative to (sx, sy), rotated by angle + const cosA = Math.cos(angle); + const sinA = Math.sin(angle); + const offY = -labelOffset; + const corners = [ + { x: sx + cosA * (-textW) - sinA * (offY - textH), y: sy + sinA * (-textW) + cosA * (offY - textH) }, + { x: sx + cosA * ( textW) - sinA * (offY - textH), y: sy + sinA * ( textW) + cosA * (offY - textH) }, + { x: sx + cosA * ( textW) - sinA * (offY + textH), y: sy + sinA * ( textW) + cosA * (offY + textH) }, + { x: sx + cosA * (-textW) - sinA * (offY + textH), y: sy + sinA * (-textW) + cosA * (offY + textH) }, + ]; let overlapsNode = false; for (const nid of this.graph.activeNodeIds()) { const nd = nodeScreenData[nid]; if (!nd) continue; - const dx = labelX - nd.sx; - const dy = labelY - nd.sy; - if (dx * dx + dy * dy < nd.r * nd.r) { - overlapsNode = true; - break; + const rSq = nd.r * nd.r; + for (const c of corners) { + const dx = c.x - nd.sx; + const dy = c.y - nd.sy; + if (dx * dx + dy * dy < rSq) { + overlapsNode = true; + break; + } } + if (overlapsNode) break; } if (overlapsNode) continue; From 09cff8f58a9eb02806fe3656dc2ac95b43269535 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:52:22 +0100 Subject: [PATCH 10/14] removed unused variables --- src/core/Renderer.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 931e543..0b135a8 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -733,8 +733,6 @@ export class Renderer { // Compute the actual label position (offset perpendicular to edge) const perpX = -Math.sin(angle) * labelOffset; const perpY = Math.cos(angle) * labelOffset; - const labelX = sx + perpX; - const labelY = sy - Math.abs(perpY); // Skip if any part of the label overlaps any node circle. // Test the label's bounding box corners (rotated) against node circles. From b56e0ca31358eae5aad6be9c8864aa03ee33c176 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:53:49 +0100 Subject: [PATCH 11/14] added on/off toggle for edge labels --- demo/index.html | 10 ++++++++++ demo/src/main.ts | 12 +++++++++++- demo/src/style.scss | 31 ++++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/demo/index.html b/demo/index.html index ac86e23..d5092ba 100644 --- a/demo/index.html +++ b/demo/index.html @@ -171,6 +171,16 @@ {{ settingsModal.edgeOpacity }}
+ +
+ +
+ +
+
diff --git a/demo/src/main.ts b/demo/src/main.ts index 05c0b77..2d29b31 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -30,6 +30,7 @@ interface SettingsModalState { open: boolean; nodeSize: number; edgeOpacity: number; + showLabels: boolean; gravitationalConstant: number; springLength: number; springConstant: number; @@ -50,6 +51,7 @@ const LAYOUT_OPTS = { const DEFAULT_SETTINGS: Omit = { nodeSize: 8, edgeOpacity: 0.8, + showLabels: true, gravitationalConstant: -0.25, springLength: 0.2, springConstant: 0.06, @@ -273,11 +275,19 @@ createApp({ } } + function onToggleSetting(key: string): void { + if (key === 'showLabels') { + settingsModal.showLabels = !settingsModal.showLabels; + g?.setLabelsVisible(settingsModal.showLabels); + } + } + function resetSettings(): void { Object.assign(settingsModal, DEFAULT_SETTINGS); if (!g) return; g.setNodeSize(DEFAULT_SETTINGS.nodeSize); g.setEdgeOpacity(DEFAULT_SETTINGS.edgeOpacity); + g.setLabelsVisible(DEFAULT_SETTINGS.showLabels); if (layoutRunning.value) { g.stopLayout(); g.startLayout(getPhysicsOpts()); @@ -389,7 +399,7 @@ createApp({ toggleLayout, fitView, resetGraph, toggleAnimated, toggleDarkMode, switchPalette, getPalettePreview, showEditModal, saveEdit, deleteSelected, confirmDelete, - onSettingChange, resetSettings, + onSettingChange, onToggleSetting, resetSettings, }; }, }).mount('#app'); diff --git a/demo/src/style.scss b/demo/src/style.scss index 9779f8c..a1ca026 100644 --- a/demo/src/style.scss +++ b/demo/src/style.scss @@ -595,7 +595,7 @@ body { font-weight: 500; color: var(--text-dim); white-space: nowrap; - min-width: 110px; + min-width: 130px; } } @@ -649,6 +649,35 @@ body { text-align: right; } +.toggle-group { + flex: 1; + display: flex; +} + +.toggle-btn { + font-family: $font-sans; + font-size: 11px; + font-weight: 500; + padding: 3px 14px; + border-radius: $radius; + border: 1px solid var(--border); + background: var(--bg-darker); + color: var(--text-muted); + cursor: pointer; + transition: all 0.12s; + + &:hover { + border-color: var(--text-dim); + color: var(--text-dim); + } + + &.active { + background: var(--accent-soft); + color: var(--accent); + border-color: var(--accent); + } +} + // ============================================================ // Fallback // ============================================================ From 4558c58a51dc620768c7c30c3acd848f14b53028 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:54:30 +0100 Subject: [PATCH 12/14] added `setLabelsVisible` method --- src/core/Renderer.ts | 15 ++++++++++++++- src/index.ts | 7 +++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 0b135a8..3578792 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -76,6 +76,7 @@ export class Renderer { // State private initialized: boolean = false; private animationId: number = 0; + private labelsVisible: boolean = true; constructor( private graph: Graph, @@ -517,7 +518,12 @@ export class Renderer { this.device.queue.submit([encoder.finish()]); // Render edges + labels on Canvas2D overlay - this.renderOverlay(); + if (this.labelsVisible) { + this.renderOverlay(); + } else if (this.labelCtx && this.labelCanvas) { + // Clear overlay when labels are hidden + this.labelCtx.clearRect(0, 0, this.labelCanvas.width, this.labelCanvas.height); + } // FPS this.frameCount++; @@ -576,6 +582,11 @@ export class Renderer { this.edgeOpacity = opacity; } + /** Show or hide all labels (node + edge) */ + setLabelsVisible(visible: boolean): void { + this.labelsVisible = visible; + } + /** Set selection state for a node (1.0 = selected, 0.0 = not) */ setSelection(nodeId: number, selected: boolean): void { if (this.selectionData.length <= nodeId) { @@ -733,6 +744,8 @@ export class Renderer { // Compute the actual label position (offset perpendicular to edge) const perpX = -Math.sin(angle) * labelOffset; const perpY = Math.cos(angle) * labelOffset; + const labelX = sx + perpX; + const labelY = sy - Math.abs(perpY); // Skip if any part of the label overlaps any node circle. // Test the label's bounding box corners (rotated) against node circles. diff --git a/src/index.ts b/src/index.ts index 96865d7..594195e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -488,6 +488,13 @@ export class GraphGPU { this.renderer.setEdgeOpacity(opacity); } + /** + * Show or hide all labels (node + edge). + */ + setLabelsVisible(visible: boolean): void { + this.renderer.setLabelsVisible(visible); + } + // ========================================================= // Selection // ========================================================= From f747da0459161a13e8209dc7add24e617475c2d4 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:55:52 +0100 Subject: [PATCH 13/14] updated README --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 81b4927..da0b007 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,9 @@ new GraphGPU({ |--------|-------------| | `setPalette(name)` | Switch color palette (recolors nodes AND edges) | | `setBackground(rgba)` | Change background color | +| `setNodeSize(size)` | Change node size at runtime | +| `setEdgeOpacity(opacity)` | Change edge opacity at runtime | +| `setLabelsVisible(bool)` | Show or hide all labels (node + edge) | | `getTagColors()` | Get current tag→color assignments | #### Interaction @@ -194,7 +197,7 @@ npm install npm run dev ``` -Features light/dark theme toggle, palette switching, node editing/deletion, animated physics mode, and a status bar with live node properties. +Features light/dark theme toggle, palette switching, node editing/deletion, animated physics mode, a live settings panel (node size, edge opacity, labels, all physics parameters), and a status bar with live node properties. ### Architecture @@ -222,7 +225,8 @@ src/ ### Contributing -You are 100% welcome! Just make a PR. 🚀 +Anything you notice or want to suggest, just [open an issue](https://github.com/drkameleon/GraphGPU/issues). +Want to contribute code? You are 100% welcome too! Just make a PR. 🚀
From 1b0fd78e38bd376475a913c508149284ab26387f Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 13:57:22 +0100 Subject: [PATCH 14/14] cleanup --- src/core/Renderer.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 3578792..601d4ac 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -741,12 +741,6 @@ export class Renderer { const edgeLen = Math.hypot(sx2 - sx1, sy2 - sy1); if (edgeLen < 40) continue; - // Compute the actual label position (offset perpendicular to edge) - const perpX = -Math.sin(angle) * labelOffset; - const perpY = Math.cos(angle) * labelOffset; - const labelX = sx + perpX; - const labelY = sy - Math.abs(perpY); - // Skip if any part of the label overlaps any node circle. // Test the label's bounding box corners (rotated) against node circles. const textW = lctx.measureText(edge.tag).width * 0.5 + 4;