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. πŸš€
diff --git a/demo/index.html b/demo/index.html index ad1b083..d5092ba 100644 --- a/demo/index.html +++ b/demo/index.html @@ -32,6 +32,9 @@ {{ darkMode ? 'light' : 'dark' }}
+ @@ -137,6 +140,119 @@ + + +
@@ -147,4 +263,4 @@

WebGPU Not Available

- + \ No newline at end of file diff --git a/demo/src/main.ts b/demo/src/main.ts index 41f5038..2d29b31 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -26,6 +26,19 @@ interface DeleteModalState { label: string; } +interface SettingsModalState { + open: boolean; + nodeSize: number; + edgeOpacity: number; + showLabels: boolean; + 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 +48,18 @@ const LAYOUT_OPTS = { maxIterations: 1000, } as const; +const DEFAULT_SETTINGS: Omit = { + nodeSize: 8, + edgeOpacity: 0.8, + showLabels: true, + 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 +105,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 +238,62 @@ 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 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()); + } + } + // ── Init ── onMounted(async () => { @@ -310,10 +395,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, onToggleSetting, resetSettings, }; }, }).mount('#app'); diff --git a/demo/src/style.scss b/demo/src/style.scss index e69ba04..a1ca026 100644 --- a/demo/src/style.scss +++ b/demo/src/style.scss @@ -556,6 +556,128 @@ 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: 130px; + } +} + +.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; +} + +.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 // ============================================================ diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 6f1679a..601d4ac 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++; @@ -566,6 +572,21 @@ 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; + } + + /** 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) { @@ -644,72 +665,41 @@ 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 nodeScreenR = nodeWorldSize * camZoomGlobal * cw * 0.5; + nodeScreenData[id] = { sx, sy, r: nodeScreenR }; + } - const fontSize = Math.max(7, Math.min(nodeScreenR * 0.28, 22)); - lctx.font = `600 ${fontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; + // ---- 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); - 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) + '…'; + // 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 (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); } - // ---- 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)); - - // 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); - - // Only render edge labels when zoomed in enough to read them - if (edgeFontSize >= 9) { - lctx.font = `500 ${edgeFontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; + 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; - // 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()) { const src = edgeIndices[eid * 2]; const tgt = edgeIndices[eid * 2 + 1]; @@ -718,12 +708,26 @@ 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; 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 @@ -731,23 +735,89 @@ 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; + // 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 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; + 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(); } } @@ -774,4 +844,4 @@ export class Renderer { visibleNodes: 0, visibleEdges: 0, }; } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 49916bc..594195e 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,27 @@ 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); + } + + /** + * Show or hide all labels (node + edge). + */ + setLabelsVisible(visible: boolean): void { + this.renderer.setLabelsVisible(visible); + } + // ========================================================= // Selection // =========================================================