UNPKG

@praxisui/page-builder

Version:

Page and widget builder utilities for Praxis UI (grid, dynamic widgets, editors).

1 lines 93.1 kB
{"version":3,"file":"praxisui-page-builder-connection-graph.component-C6x--6--.mjs","sources":["../../../projects/praxis-page-builder/src/lib/editor/connection-graph/edge-router.util.ts","../../../projects/praxis-page-builder/src/lib/editor/connection-graph/drag-connect.directive.ts","../../../projects/praxis-page-builder/src/lib/editor/connection-graph/graph-mapper.service.ts","../../../projects/praxis-page-builder/src/lib/editor/connection-graph/connection-graph.component.ts"],"sourcesContent":["export interface Point { x: number; y: number }\nexport interface Rect { x: number; y: number; width: number; height: number }\n\n/**\n * Simple elbow router (L-shape): horizontal then vertical.\n * Produces an SVG path string.\n */\nexport function routeElbow(from: Point, to: Point): string {\n const midX = (from.x + to.x) / 2;\n const p = [\n `M ${from.x} ${from.y}`,\n `L ${midX} ${from.y}`,\n `L ${midX} ${to.y}`,\n `L ${to.x} ${to.y}`,\n ];\n return p.join(' ');\n}\n\n/** Straight line path */\nexport function routeStraight(from: Point, to: Point): string {\n return `M ${from.x} ${from.y} L ${to.x} ${to.y}`;\n}\n\n/** Simple cubic bezier: horizontal tangents from endpoints */\nexport function routeBezier(from: Point, to: Point): string {\n const dx = (to.x - from.x) * 0.5;\n const c1 = { x: from.x + dx, y: from.y };\n const c2 = { x: to.x - dx, y: to.y };\n return `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`;\n}\n\n/** Basic geometry helpers for auto-routing */\nfunction rectContains(r: Rect, p: Point): boolean {\n return p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;\n}\nfunction segmentsIntersect(p1: Point, p2: Point, p3: Point, p4: Point): boolean {\n // cross product approach\n const d = (p4.y - p3.y) * (p2.x - p1.x) - (p4.x - p3.x) * (p2.y - p1.y);\n if (d === 0) return false;\n const ua = ((p4.x - p3.x) * (p1.y - p3.y) - (p4.y - p3.y) * (p1.x - p3.x)) / d;\n const ub = ((p2.x - p1.x) * (p1.y - p3.y) - (p2.y - p1.y) * (p1.x - p3.x)) / d;\n return ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1;\n}\nfunction lineIntersectsRect(p1: Point, p2: Point, r: Rect): boolean {\n if (rectContains(r, p1) || rectContains(r, p2)) return true;\n const tl = { x: r.x, y: r.y };\n const tr = { x: r.x + r.width, y: r.y };\n const bl = { x: r.x, y: r.y + r.height };\n const br = { x: r.x + r.width, y: r.y + r.height };\n return (\n segmentsIntersect(p1, p2, tl, tr) ||\n segmentsIntersect(p1, p2, tr, br) ||\n segmentsIntersect(p1, p2, br, bl) ||\n segmentsIntersect(p1, p2, bl, tl)\n );\n}\n\n/**\n * Auto router: choose straight/bezier if clear, else elbow to avoid boxes.\n * Obstacles are rectangles in world space. Optional padding inflates obstacles.\n */\nexport function routeAuto(from: Point, to: Point, obstacles: Rect[] = [], padding = 6): string {\n const inflated = obstacles.map(r => ({ x: r.x - padding, y: r.y - padding, width: r.width + padding*2, height: r.height + padding*2 }));\n const intersects = inflated.some(r => lineIntersectsRect(from, to, r));\n if (intersects) return routeElbow(from, to);\n // prefer bezier for aesthetics when unobstructed\n return routeBezier(from, to);\n}\n","import { Directive, ElementRef, EventEmitter, HostListener, Input, OnDestroy, Output } from '@angular/core';\n\nexport interface DragStartEvent { from: { nodeId: string; portId: string }; x: number; y: number }\nexport interface DragMoveEvent { x: number; y: number }\nexport interface DragEndEvent { x: number; y: number; endEvent: MouseEvent }\n\n@Directive({\n selector: '[dragConnect]'\n})\nexport class DragConnectDirective implements OnDestroy {\n @Input('dragConnect') from!: { nodeId: string; portId: string };\n @Output() connectDragStart = new EventEmitter<DragStartEvent>();\n @Output() connectDragMove = new EventEmitter<DragMoveEvent>();\n @Output() connectDragEnd = new EventEmitter<DragEndEvent>();\n\n private active = false;\n private onMove = (e: MouseEvent) => this.handleMove(e);\n private onUp = (e: MouseEvent) => this.handleUp(e);\n\n constructor(private el: ElementRef<HTMLElement>) {}\n\n @HostListener('mousedown', ['$event'])\n onMouseDown(ev: MouseEvent): void {\n if (!this.from) return;\n ev.preventDefault();\n ev.stopPropagation();\n this.active = true;\n const rect = (this.el.nativeElement as HTMLElement).getBoundingClientRect();\n const x = rect.left + rect.width / 2;\n const y = rect.top + rect.height / 2;\n try { console.debug('[DragConnect] mousedown start', { from: this.from, x, y, target: (ev.target as HTMLElement)?.tagName }); } catch {}\n this.connectDragStart.emit({ from: this.from, x, y });\n document.addEventListener('mousemove', this.onMove);\n document.addEventListener('mouseup', this.onUp, { once: true });\n }\n\n private handleMove(e: MouseEvent): void {\n if (!this.active) return;\n try { console.debug('[DragConnect] mousemove', { x: e.clientX, y: e.clientY }); } catch {}\n this.connectDragMove.emit({ x: e.clientX, y: e.clientY });\n }\n\n private handleUp(e: MouseEvent): void {\n if (!this.active) return;\n this.active = false;\n document.removeEventListener('mousemove', this.onMove);\n try { console.debug('[DragConnect] mouseup end', { x: e.clientX, y: e.clientY, target: (e.target as HTMLElement)?.tagName, classes: (e.target as HTMLElement)?.className }); } catch {}\n this.connectDragEnd.emit({ x: e.clientX, y: e.clientY, endEvent: e });\n }\n\n ngOnDestroy(): void {\n document.removeEventListener('mousemove', this.onMove);\n }\n}\n","import { Injectable } from '@angular/core';\nimport { ComponentMetadataRegistry } from '@praxisui/core';\nimport { GridPageDefinition, GridWidgetInstance } from '@praxisui/core';\nimport { WidgetConnection } from '@praxisui/core';\nimport { GraphEdge, GraphNode, GraphPort } from './graph-types';\n\n@Injectable({ providedIn: 'root' })\nexport class GraphMapperService {\n constructor(private registry: ComponentMetadataRegistry) {}\n\n /** Build nodes and edges for a given page definition and widgets. */\n mapToGraph(page: GridPageDefinition | undefined, widgets: GridWidgetInstance[]): { nodes: GraphNode[]; edges: GraphEdge[] } {\n const nodes: GraphNode[] = [];\n const nodeById = new Map<string, GraphNode>();\n const tabsCfgByKey = new Map<string, any>();\n const edges: GraphEdge[] = [];\n\n // Layout: simple columns — sources left, targets right\n const colWidth = 240;\n const rowHeight = 96;\n const gap = 28;\n\n let leftY = 0;\n let rightY = 0;\n\n // First pass: create a node per widget\n for (const w of widgets) {\n const meta = this.registry.get(w.definition.id);\n const metaOutputs = new Map<string, { label?: string; description?: string }>();\n for (const o of meta?.outputs || []) metaOutputs.set(o.name, { label: o.label, description: o.description });\n const outputs = new Set<string>([(meta?.outputs || []).map((o) => o.name)].flat() as string[]);\n if (w.definition.id === 'praxis-tabs') outputs.add('widgetEvent');\n if (w.definition.id === 'praxis-stepper') outputs.add('widgetEvent');\n\n const metaInputs = new Map<string, { label?: string; description?: string }>();\n for (const i of meta?.inputs || []) metaInputs.set(i.name, { label: i.label, description: i.description });\n // Show both instance-defined inputs and metadata-declared inputs (e.g., resourceId)\n const inputNames = Array.from(new Set<string>([\n ...Object.keys(w.definition.inputs || {}),\n ...((meta?.inputs || []).map((m) => m.name) as string[]),\n ]));\n const inputPorts: GraphPort[] = inputNames.map((name, i) => ({\n id: `in:${name}`,\n label: metaInputs.get(name)?.label || name,\n description: metaInputs.get(name)?.description,\n kind: 'input',\n anchor: { x: colWidth, y: 24 + i * 22 },\n }));\n // Generic port for new inputs\n inputPorts.push({ id: 'in:*', label: 'novo input…', kind: 'input', anchor: { x: colWidth, y: 24 + inputNames.length * 22 } });\n\n const outputPorts: GraphPort[] = Array.from(outputs.values()).map((name, i) => ({\n id: `out:${name}`,\n label: metaOutputs.get(name)?.label || name,\n description: metaOutputs.get(name)?.description,\n kind: 'output',\n anchor: { x: 0, y: 24 + i * 22 },\n }));\n\n const left = outputPorts.length > 0;\n const pos = left ? { x: gap, y: gap + leftY } : { x: colWidth + gap * 3, y: gap + rightY };\n const node: GraphNode = {\n id: w.key,\n label: meta?.friendlyName || w.definition.id || w.key,\n icon: meta?.icon,\n type: w.definition.id,\n parentId: null,\n collapsed: true,\n bounds: { x: pos.x, y: pos.y, width: colWidth, height: rowHeight },\n ports: [...outputPorts.map((p) => ({ ...p, anchor: { x: pos.x, y: pos.y + p.anchor.y } })), ...inputPorts.map((p) => ({ ...p, anchor: { x: pos.x + colWidth, y: pos.y + p.anchor.y } }))],\n };\n nodes.push(node);\n nodeById.set(node.id, node);\n if (left) leftY += rowHeight + gap; else rightY += rowHeight + gap;\n\n // Expand Tabs internals as child nodes (collapsed by default)\n if (w.definition.id === 'praxis-tabs') {\n const cfg = (w.definition as any)?.inputs?.config;\n tabsCfgByKey.set(w.key, cfg);\n // Group tabs\n const tabs = (cfg?.tabs || []) as Array<{ widgets?: any[] }>;\n for (let ti = 0; ti < tabs.length; ti++) {\n const inner = tabs[ti]?.widgets || [];\n for (let wi = 0; wi < inner.length; wi++) {\n const innerDef = inner[wi];\n const innerMeta = this.registry.get(innerDef?.id);\n const innerInputs = Object.keys(innerDef?.inputs || {});\n const innerMetaInputs = new Map<string, { label?: string; description?: string }>();\n for (const i of innerMeta?.inputs || []) innerMetaInputs.set(i.name, { label: i.label, description: i.description });\n const sectionLabel = cfg?.tabs?.[ti]?.textLabel as string | undefined;\n const containerTitle = sectionLabel ? `Aba: ${sectionLabel}` : `Aba ${ti + 1}`;\n const widgetLabel = innerMeta?.friendlyName || innerDef?.id || `widget ${wi}`;\n const inPorts: GraphPort[] = innerInputs.map((name, ii) => {\n const path = `inputs.config.tabs[${ti}].widgets[${wi}].inputs.${name}`;\n const base = innerMetaInputs.get(name);\n const label = base?.label || name;\n const description = (base?.description ? base.description + '\\n' : '') + `${containerTitle} • ${widgetLabel} • ${name}\\n${path}`;\n return { id: `in:${name}`, label, description, kind: 'input', path, anchor: { x: node.bounds.x + colWidth, y: node.bounds.y + rowHeight + (ti * 72) + ii * 18 } } as GraphPort;\n });\n if (!inPorts.length) inPorts.push({ id: 'in:*', label: 'novo input…', kind: 'input', path: `inputs.config.tabs[${ti}].widgets[${wi}].inputs.`, anchor: { x: node.bounds.x + colWidth, y: node.bounds.y + rowHeight + (ti * 72) } });\n const child: GraphNode = {\n id: `${w.key}#group[${ti}]/${wi}`,\n label: innerMeta?.friendlyName || innerDef?.id || `widget ${wi}`,\n icon: innerMeta?.icon,\n type: innerDef?.id || 'inner',\n parentId: w.key,\n collapsed: false,\n bounds: { x: node.bounds.x + 24, y: node.bounds.y + rowHeight + (ti * 72) + wi * 32, width: colWidth - 48, height: 48 },\n ports: inPorts,\n };\n nodes.push(child);\n nodeById.set(child.id, child);\n }\n }\n // Nav links\n const links = (cfg?.nav?.links || []) as Array<{ widgets?: any[] }>;\n for (let li = 0; li < links.length; li++) {\n const inner = links[li]?.widgets || [];\n for (let wi = 0; wi < inner.length; wi++) {\n const innerDef = inner[wi];\n const innerMeta = this.registry.get(innerDef?.id);\n const innerInputs = Object.keys(innerDef?.inputs || {});\n const innerMetaInputs = new Map<string, { label?: string; description?: string }>();\n for (const i of innerMeta?.inputs || []) innerMetaInputs.set(i.name, { label: i.label, description: i.description });\n const linkLabel = cfg?.nav?.links?.[li]?.label as string | undefined;\n const containerTitle = linkLabel ? `Link: ${linkLabel}` : `Link ${li + 1}`;\n const widgetLabel = innerMeta?.friendlyName || innerDef?.id || `widget ${wi}`;\n const inPorts: GraphPort[] = innerInputs.map((name, ii) => {\n const path = `inputs.config.nav.links[${li}].widgets[${wi}].inputs.${name}`;\n const base = innerMetaInputs.get(name);\n const label = base?.label || name;\n const description = (base?.description ? base.description + '\\n' : '') + `${containerTitle} • ${widgetLabel} • ${name}\\n${path}`;\n return { id: `in:${name}`, label, description, kind: 'input', path, anchor: { x: node.bounds.x + colWidth, y: node.bounds.y + rowHeight + (li * 72) + ii * 18 } } as GraphPort;\n });\n if (!inPorts.length) inPorts.push({ id: 'in:*', label: 'novo input…', kind: 'input', path: `inputs.config.nav.links[${li}].widgets[${wi}].inputs.`, anchor: { x: node.bounds.x + colWidth, y: node.bounds.y + rowHeight + (li * 72) } });\n const child: GraphNode = {\n id: `${w.key}#nav[${li}]/${wi}`,\n label: innerMeta?.friendlyName || innerDef?.id || `link ${wi}`,\n icon: innerMeta?.icon,\n type: innerDef?.id || 'inner',\n parentId: w.key,\n collapsed: false,\n bounds: { x: node.bounds.x + 24, y: node.bounds.y + rowHeight + (li * 72) + wi * 32, width: colWidth - 48, height: 48 },\n ports: inPorts,\n };\n nodes.push(child);\n nodeById.set(child.id, child);\n }\n }\n }\n }\n\n // Second pass: map edges from page.connections\n const conns = (page?.connections || []) as WidgetConnection[];\n for (let i = 0; i < conns.length; i++) {\n const c = conns[i];\n const fromNodeId = c.from.widget;\n const fromPortId = `out:${c.from.output}`;\n let toNodeId = c.to.widget;\n let toPortId = `in:${c.to.input}`;\n\n const deep = this.parseTabsPath(c.to.input);\n let friendlyPath: string | undefined;\n if (deep) {\n const childId = `${c.to.widget}#${deep.kind}[${deep.index}]/${deep.widgetIndex}`;\n // If such child exists, route edge to it and its input name\n if (nodes.some((n) => n.id === childId)) {\n toNodeId = childId;\n toPortId = `in:${deep.input}`;\n } else {\n // Keep to widget but still expose label/path\n toPortId = `in:${deep.input}`;\n }\n const cfg = tabsCfgByKey.get(c.to.widget);\n const labelFromCfg = deep.kind === 'group' ? (cfg?.tabs?.[deep.index]?.textLabel as string | undefined) : (cfg?.nav?.links?.[deep.index]?.label as string | undefined);\n const containerLabel = labelFromCfg ? (deep.kind === 'group' ? `Aba: ${labelFromCfg}` : `Link: ${labelFromCfg}`) : (deep.kind === 'group' ? `Aba ${deep.index + 1}` : `Link ${deep.index + 1}`);\n const child = nodes.find(n => n.id === childId);\n friendlyPath = `${containerLabel} • ${(child?.label || 'Widget')} • ${deep.input}`;\n }\n\n // Ensure ports exist for visualization (create synthetic when missing)\n this.ensurePort(nodeById, fromNodeId, fromPortId, 'output', colWidth);\n this.ensurePort(nodeById, toNodeId, toPortId, 'input', colWidth);\n\n const edge: GraphEdge = {\n id: `e${i}`,\n from: { nodeId: fromNodeId, portId: fromPortId },\n to: { nodeId: toNodeId, portId: toPortId },\n label: c.map || undefined,\n meta: { map: c.map, bindingOrder: c.to.bindingOrder, ...(friendlyPath ? { friendlyPath } : {}) },\n };\n edges.push(edge);\n }\n\n return { nodes, edges };\n }\n\n private ensurePort(nodeById: Map<string, GraphNode>, nodeId: string, portId: string, kind: 'input' | 'output', colWidth: number): void {\n const n = nodeById.get(nodeId);\n if (!n) return;\n const exists = (n.ports || []).some(p => p.id === portId);\n if (exists) return;\n const label = portId.replace(/^(in:|out:)/, '');\n const portsOfKind = (n.ports || []).filter(p => p.kind === kind);\n const idx = portsOfKind.length;\n const y = n.bounds.y + 24 + idx * 22;\n const x = kind === 'output' ? n.bounds.x : n.bounds.x + n.bounds.width;\n const p: GraphPort = { id: portId, label, kind, anchor: { x, y } };\n n.ports = [...(n.ports || []), p];\n // Optionally grow node height to accommodate more ports\n const approxBottom = y + 28 - n.bounds.y;\n if (approxBottom > n.bounds.height) n.bounds = { ...n.bounds, height: approxBottom };\n }\n\n /**\n * Parse dot-path for Tabs internals. Supported:\n * - inputs.config.tabs[<i>].widgets[<j>].inputs.<input>\n * - inputs.config.nav.links[<i>].widgets[<j>].inputs.<input>\n */\n parseTabsPath(path: string | undefined): { kind: 'group' | 'nav'; index: number; widgetIndex: number; input: string } | null {\n if (!path) return null;\n const groupRe = /^inputs\\.config\\.tabs\\[(\\d+)\\]\\.widgets\\[(\\d+)\\]\\.inputs\\.(.+)$/;\n const navRe = /^inputs\\.config\\.nav\\.links\\[(\\d+)\\]\\.widgets\\[(\\d+)\\]\\.inputs\\.(.+)$/;\n const g = path.match(groupRe);\n if (g) return { kind: 'group', index: Number(g[1]), widgetIndex: Number(g[2]), input: g[3] };\n const n = path.match(navRe);\n if (n) return { kind: 'nav', index: Number(n[1]), widgetIndex: Number(n[2]), input: n[3] };\n return null;\n }\n}\n","import { CommonModule } from '@angular/common';\nimport { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnChanges, OnInit, Optional, Output, SimpleChanges, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { MatButtonModule } from '@angular/material/button';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatTooltipModule } from '@angular/material/tooltip';\nimport { GraphEdge, GraphNode, GraphPort } from './graph-types';\nimport { GraphMapperService } from './graph-mapper.service';\nimport { GridPageDefinition, GridWidgetInstance } from '@praxisui/core';\nimport { WidgetConnection } from '@praxisui/core';\nimport { routeElbow } from './edge-router.util';\nimport { DragConnectDirective, DragEndEvent, DragMoveEvent, DragStartEvent } from './drag-connect.directive';\nimport { BehaviorSubject } from 'rxjs';\nimport { SETTINGS_PANEL_DATA, SettingsPanelService } from '@praxisui/settings-panel';\nimport { ConnectionBuilderComponent } from '../connection-builder.component';\n\n@Component({\n selector: 'praxis-connection-graph',\n standalone: true,\n imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, DragConnectDirective],\n template: `\n <div class=\"cg-root\" (wheel)=\"onWheel($event)\" (mousedown)=\"onPanStart($event)\">\n <div class=\"cg-head\">\n <div class=\"cg-title\">Conexões</div>\n <div class=\"spacer\"></div>\n <button mat-button [color]=\"showPortLabels ? 'primary' : undefined\" (click)=\"showPortLabels = !showPortLabels\" matTooltip=\"Mostrar/ocultar labels de portas\" aria-label=\"Mostrar/ocultar labels de portas\">\n <mat-icon>label</mat-icon>\n </button>\n <button mat-button [color]=\"showFriendlyLabels ? 'primary' : undefined\" (click)=\"showFriendlyLabels = !showFriendlyLabels\" matTooltip=\"Alternar nome amigável/técnico\" aria-label=\"Alternar nome amigável/técnico\">\n <mat-icon>translate</mat-icon>\n </button>\n <button mat-button [color]=\"showEdgeLabels ? 'primary' : undefined\" (click)=\"showEdgeLabels = !showEdgeLabels\" matTooltip=\"Mostrar/ocultar rótulos de arestas\" aria-label=\"Mostrar/ocultar rótulos de arestas\">\n <mat-icon>text_fields</mat-icon>\n </button>\n <button mat-button [color]=\"showUnconnectedPorts ? 'primary' : undefined\" (click)=\"showUnconnectedPorts = !showUnconnectedPorts\" matTooltip=\"Mostrar/ocultar portas sem conexão\" aria-label=\"Mostrar/ocultar portas sem conexão\">\n <mat-icon>tune</mat-icon>\n </button>\n <button mat-stroked-button color=\"primary\" (click)=\"openBuilder()\" aria-label=\"Editar no Builder\"><mat-icon>tune</mat-icon><span>Editar no Builder</span></button>\n <div class=\"cg-count\" [attr.aria-label]=\"'Total de conexões: ' + connections().length\">{{ connections().length }}</div>\n </div>\n <div class=\"cg-canvas\">\n <!-- Edge details panel -->\n <div class=\"cg-details\" *ngIf=\"selectedEdgeIndex() >= 0 && showDetails\">\n <div class=\"details-head\">\n <div class=\"details-title\">Detalhes da conexão</div>\n <button mat-icon-button (click)=\"showDetails=false\" aria-label=\"Fechar\"><mat-icon>close</mat-icon></button>\n </div>\n <div class=\"details-body\" *ngIf=\"currentConn() as c\">\n <div><b>De:</b> {{ c.from.widget }}.{{ c.from.output }}</div>\n <div><b>Para:</b> {{ c.to.widget }}.{{ c.to.input }}</div>\n <div><b>map:</b> {{ c.map || 'payload' }}</div>\n <div *ngIf=\"c.to.bindingOrder?.length\"><b>bindingOrder:</b> {{ c.to.bindingOrder?.join(', ') }}</div>\n <div class=\"details-actions\">\n <button mat-button color=\"primary\" (click)=\"openBuilder()\"><mat-icon>tune</mat-icon> Editar no Builder</button>\n <button mat-button color=\"warn\" (click)=\"removeEdge(selectedEdgeIndex())\"><mat-icon>delete</mat-icon> Remover</button>\n </div>\n </div>\n </div>\n\n <svg [attr.width]=\"'100%'\" [attr.height]=\"'100%'\">\n <defs>\n <marker id=\"arrow\" viewBox=\"0 0 10 10\" refX=\"10\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\" orient=\"auto-start-reverse\">\n <path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"currentColor\"></path>\n </marker>\n </defs>\n <g [attr.transform]=\"transform()\">\n <!-- Edges -->\n <g class=\"edges\">\n <g *ngFor=\"let e of edges(); let i = index\" class=\"edge-group\" role=\"button\" tabindex=\"0\"\n (mouseenter)=\"hoverEdgeId = e.id\" (mouseleave)=\"hoverEdgeId = ''\" (click)=\"selectEdge(i)\">\n <path class=\"edge\" [attr.d]=\"edgePath(e)\" marker-end=\"url(#arrow)\" [class.hover]=\"hoverEdgeId===e.id\" [attr.aria-label]=\"e.label || ''\" [matTooltip]=\"edgeTooltip(e)\"></path>\n <ng-container *ngIf=\"showEdgeLabels && e.label\">\n <g [attr.transform]=\"'translate(' + edgeMid(e).x + ',' + edgeMid(e).y + ')'\">\n <text class=\"edge-label\" x=\"0\" y=\"0\">{{ e.label }}</text>\n </g>\n </ng-container>\n <g *ngIf=\"hoverEdgeId===e.id\" class=\"edge-toolbar\">\n <rect [attr.x]=\"edgeMid(e).x-28\" [attr.y]=\"edgeMid(e).y-14\" width=\"56\" height=\"28\" rx=\"6\" ry=\"6\" class=\"edge-toolbar-bg\"></rect>\n <text [attr.x]=\"edgeMid(e).x-14\" [attr.y]=\"edgeMid(e).y+4\" class=\"icon\" (click)=\"removeEdge(i)\" aria-label=\"Remover\">🗑</text>\n <text [attr.x]=\"edgeMid(e).x+8\" [attr.y]=\"edgeMid(e).y+4\" class=\"icon\" (click)=\"openBuilder()\" aria-label=\"Editar\">✎</text>\n </g>\n </g>\n </g>\n\n <!-- Temporary drag edge -->\n <path *ngIf=\"dragPath()\" class=\"edge temp\" [attr.d]=\"dragPath()\" marker-end=\"url(#arrow)\"></path>\n\n <!-- Nodes -->\n <g class=\"nodes\">\n <g *ngFor=\"let n of nodes(); let ni = index\" class=\"node\" [attr.transform]=\"'translate(' + n.bounds.x + ',' + n.bounds.y + ')'\" role=\"group\" [attr.aria-label]=\"n.label\">\n <rect class=\"node-box\" [attr.width]=\"n.bounds.width\" [attr.height]=\"n.bounds.height\" rx=\"8\" ry=\"8\"\n (mousedown)=\"onNodeDragStart($event, ni)\"></rect>\n <text class=\"node-title\" x=\"8\" y=\"20\">{{ n.label }}</text>\n\n <!-- Output ports (left) -->\n <g *ngFor=\"let p of outputPorts(n); trackBy: trackPort\" class=\"port output\"\n [attr.transform]=\"'translate(' + (0) + ',' + (p.anchor.y - n.bounds.y - 4) + ')'\">\n <circle r=\"6\" cx=\"-6\" cy=\"8\" class=\"port-dot\" [attr.data-node-id]=\"n.id\" [attr.data-port-id]=\"p.id\" [matTooltip]=\"portTooltip(p)\"></circle>\n <rect class=\"port-hit\" x=\"-16\" y=\"0\" width=\"20\" height=\"16\" [dragConnect]=\"{ nodeId: n.id, portId: p.id }\" [matTooltip]=\"portTooltip(p)\"\n (connectDragStart)=\"onDragStart($event)\" (connectDragMove)=\"onDragMove($event)\" (connectDragEnd)=\"onDragEnd($event)\"></rect>\n <text *ngIf=\"showPortLabels\" x=\"-4\" y=\"12\" class=\"port-label\" text-anchor=\"end\" [attr.title]=\"portTooltip(p)\">{{ portText(p) }}</text>\n </g>\n\n <!-- Input ports (right) -->\n <g *ngFor=\"let p of inputPorts(n); trackBy: trackPort\" class=\"port input\"\n [attr.transform]=\"'translate(' + (n.bounds.width) + ',' + (p.anchor.y - n.bounds.y - 4) + ')'\">\n <circle r=\"6\" cx=\"6\" cy=\"8\" class=\"port-dot\" [attr.data-node-id]=\"n.id\" [attr.data-port-id]=\"p.id\" data-port-kind=\"input\" [attr.data-port-path]=\"p.path || ''\" [matTooltip]=\"portTooltip(p)\"></circle>\n <rect class=\"port-hit\" x=\"-4\" y=\"0\" width=\"20\" height=\"16\" [matTooltip]=\"portTooltip(p)\"></rect>\n <text *ngIf=\"showPortLabels\" x=\"8\" y=\"12\" class=\"port-label\" [attr.title]=\"portTooltip(p)\">{{ portText(p) }}</text>\n </g>\n </g>\n </g>\n </g>\n </svg>\n <!-- Pending connection popover -->\n <div class=\"popover\" *ngIf=\"pendingPopover() as pop\" [ngStyle]=\"{ left: pop.x + 'px', top: pop.y + 'px' }\">\n <div class=\"popover-body\">\n <div class=\"popover-title\">Nova conexão</div>\n <div style=\"display:grid; gap:6px; min-width:260px;\">\n <label>map:\n <input [(ngModel)]=\"pop.map\" [attr.placeholder]=\"placeholderSample\" />\n </label>\n <div style=\"display:flex; gap:6px; flex-wrap: wrap;\">\n <button mat-stroked-button (click)=\"applyPreset('payload')\">payload</button>\n <button mat-stroked-button (click)=\"applyPreset('payload.id')\">payload.id</button>\n <button mat-stroked-button (click)=\"applyPreset('payload.data')\">payload.data</button>\n </div>\n <label>filter (opcional):\n <input [(ngModel)]=\"pop.filter\" placeholder=\"ex.: = payload != null\" />\n </label>\n <div style=\"display:grid; gap:6px;\">\n <div style=\"display:flex; gap:8px; align-items:center;\">\n <label style=\"display:flex; align-items:center; gap:6px;\">debounce (ms): <input type=\"number\" min=\"0\" style=\"width:90px\" [(ngModel)]=\"pop.debounceMs\" /></label>\n <label style=\"display:flex; align-items:center; gap:6px;\">\n <input type=\"checkbox\" [(ngModel)]=\"pop.distinct\" /> distinct\n </label>\n </div>\n <label>distinctBy (opcional):\n <input [(ngModel)]=\"pop['distinctBy']\" placeholder=\"ex.: payload.id\" />\n </label>\n </div>\n <div style=\"font-size:12px; opacity:.8;\">Filtro: {{ computeFilterPreview(pop.filter) }}</div>\n <div style=\"font-size:12px; opacity:.8;\">Prévia: {{ computePreview(pop.map) }}</div>\n </div>\n <div class=\"actions\">\n <button mat-button (click)=\"cancelPending()\">Cancelar</button>\n <button mat-flat-button color=\"primary\" (click)=\"confirmPending()\">Confirmar</button>\n </div>\n </div>\n </div>\n </div>\n </div>\n `,\n styles: [`\n .cg-root { display: flex; flex-direction: column; height: 100%; width: 100%; }\n .cg-head { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-bottom: 1px solid var(--md-sys-color-outline, #444); }\n .cg-title { font-weight: 600; }\n .spacer { flex: 1; }\n .cg-count { opacity: 0.8; }\n .cg-canvas { position: relative; flex: 1; min-height: 300px; }\n svg { background: var(--md-sys-color-surface-container-lowest, #111); color: var(--md-sys-color-on-surface, #eee); }\n .node-box { fill: var(--md-sys-color-surface-container, #1b1b1b); stroke: var(--md-sys-color-outline, #4a4a4a); }\n .node-title { fill: var(--md-sys-color-on-surface, #eee); font-size: 14px; font-weight: 600; }\n .port-dot { fill: currentColor; r: 6; }\n .port-label { fill: var(--md-sys-color-on-surface-variant, #bbb); font-size: 13px; }\n .edge { stroke: var(--md-sys-color-primary, #56a0ff); stroke-width: 2; fill: none; }\n .edge.hover { stroke: var(--md-sys-color-tertiary, #ffa04d); }\n .edge.temp { stroke-dasharray: 4 4; opacity: 0.7; }\n .edge-label { fill: var(--md-sys-color-on-surface, #ffffff); font-size: 12px; dominant-baseline: middle; text-anchor: middle; }\n .edge-label-bg { fill: rgba(0,0,0,0.35); }\n .edge-toolbar .edge-toolbar-bg { fill: var(--md-sys-color-surface, #222); stroke: var(--md-sys-color-outline, #444); }\n .edge-toolbar .icon { fill: var(--md-sys-color-on-surface, #ffffff); font-size: 12px; cursor: pointer; user-select: none; }\n .popover { position: absolute; transform: translate(-50%, -50%); z-index: 10; }\n .popover-body { background: var(--md-sys-color-surface, #222); color: var(--md-sys-color-on-surface, #eee); border: 1px solid var(--md-sys-color-outline, #444); border-radius: 8px; padding: 8px; min-width: 220px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); }\n .popover-title { font-weight: 600; margin-bottom: 8px; }\n .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }\n .port-hit { fill: transparent; cursor: crosshair; height: 24px; }\n .cg-details { position: absolute; right: 8px; top: 48px; width: 320px; background: var(--md-sys-color-surface, #222); color: var(--md-sys-color-on-surface, #eee); border: 1px solid var(--md-sys-color-outline, #444); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.35); z-index: 12; }\n .details-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid var(--md-sys-color-outline, #444); }\n .details-title { font-weight: 600; }\n .details-body { padding: 8px 10px; display: grid; gap: 6px; }\n .details-actions { display: flex; gap: 8px; margin-top: 8px; }\n `],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class ConnectionGraphComponent implements OnInit, OnChanges {\n @Input() page?: GridPageDefinition | string;\n @Input() widgets?: GridWidgetInstance[];\n @Output() pageChange = new EventEmitter<GridPageDefinition>();\n\n nodes = signal<GraphNode[]>([]);\n edges = signal<GraphEdge[]>([]);\n connections = signal<WidgetConnection[]>([]);\n showPortLabels = true;\n showEdgeLabels = false;\n showUnconnectedPorts = true;\n showDetails = true;\n showFriendlyLabels = true;\n selectedEdgeIndex = signal<number>(-1);\n\n // Panning/zoom\n private pan = { x: 0, y: 0 };\n private zoom = 1;\n transform = signal<string>('');\n\n // Hover state\n hoverEdgeId = '';\n\n // Drag state\n private dragFrom?: { nodeId: string; portId: string };\n dragPath = signal<string>('');\n\n // Pending popover\n pendingPopover = signal<{ x: number; y: number; from: { nodeId: string; portId: string }; to: { nodeId: string; portId: string; path?: string }; map?: string; filter?: string; debounceMs?: number; distinct?: boolean; distinctBy?: string } | null>(null);\n\n // Settings Panel integration\n isDirty$ = new BehaviorSubject<boolean>(false);\n isValid$ = new BehaviorSubject<boolean>(true);\n isBusy$ = new BehaviorSubject<boolean>(false);\n private initialSnapshot = '[]';\n placeholderSample = 'payload | payload.id | ${payload.id}';\n\n private connectedPorts = new Map<string, Set<string>>();\n private isPortConnected(nodeId: string, portId: string): boolean {\n const set = this.connectedPorts.get(nodeId); return !!set && set.has(portId);\n }\n outputPorts(n: GraphNode): GraphPort[] {\n const ports = (n.ports || []).filter((p) => p.kind === 'output');\n return this.showUnconnectedPorts ? ports : ports.filter(p => this.isPortConnected(n.id, p.id));\n }\n inputPorts(n: GraphNode): GraphPort[] {\n const ports = (n.ports || []).filter((p) => p.kind === 'input');\n return this.showUnconnectedPorts ? ports : ports.filter(p => this.isPortConnected(n.id, p.id));\n }\n\n constructor(\n private mapper: GraphMapperService,\n private settingsPanel: SettingsPanelService,\n @Optional() @Inject(SETTINGS_PANEL_DATA) private injectedData?: { page?: GridPageDefinition; widgets?: GridWidgetInstance[] },\n ) {}\n\n ngOnInit(): void {\n const data = this.injectedData || {};\n const p = this.parsePage(data.page);\n const ws = (data.widgets as GridWidgetInstance[]) || this.widgets || p?.widgets || [];\n this.widgets = ws;\n this.page = data.page || (this.page as any);\n const conns = [...(p?.connections || [])];\n this.connections.set(conns);\n this.initialSnapshot = JSON.stringify(conns);\n this.rebuildGraph();\n }\n\n ngOnChanges(changes: SimpleChanges): void {\n if (changes['page'] || changes['widgets']) {\n const p = this.parsePage(this.page);\n this.connections.set([...(p?.connections || [])]);\n this.rebuildGraph();\n this.initialSnapshot = JSON.stringify(this.connections());\n this.isDirty$.next(false);\n }\n }\n\n private parsePage(input?: GridPageDefinition | string | null): GridPageDefinition | undefined {\n if (!input) return undefined;\n if (typeof input === 'string') { try { return JSON.parse(input) as GridPageDefinition; } catch { return undefined; } }\n return input;\n }\n\n private rebuildGraph(): void {\n const p = this.parsePage(this.page) || ({ widgets: this.widgets || [], connections: this.connections() } as GridPageDefinition);\n const { nodes, edges } = this.mapper.mapToGraph({ ...p, connections: this.connections() }, this.widgets || []);\n this.nodes.set(nodes);\n this.edges.set(edges);\n this.recomputeConnectedPorts();\n this.updateTransform();\n }\n private recomputeConnectedPorts(): void {\n const map = new Map<string, Set<string>>();\n const add = (nodeId: string, portId: string) => {\n const s = map.get(nodeId) || new Set<string>(); s.add(portId); map.set(nodeId, s);\n };\n for (const e of this.edges()) {\n add(e.from.nodeId, e.from.portId);\n add(e.to.nodeId, e.to.portId);\n }\n this.connectedPorts = map;\n }\n\n // External focus by connection (from Builder or other callers)\n focusConnection(conn: { from: { widget: string; output: string }; to: { widget: string; input: string } }): void {\n const fromNodeId = conn.from.widget;\n const fromPortId = `out:${conn.from.output}`;\n let toNodeId = conn.to.widget;\n let toPortId = `in:${conn.to.input}`;\n const deep = this.parseTabsPath(conn.to.input);\n if (deep) {\n const childId = `${conn.to.widget}#${deep.kind}[${deep.index}]/${deep.widgetIndex}`;\n if (this.nodes().some(n => n.id === childId)) { toNodeId = childId; toPortId = `in:${deep.input}`; }\n else { toPortId = `in:${deep.input}`; }\n }\n const idx = this.edges().findIndex(e => e.from.nodeId === fromNodeId && e.from.portId === fromPortId && e.to.nodeId === toNodeId && e.to.portId === toPortId);\n if (idx >= 0) this.selectEdge(idx);\n }\n\n private parseTabsPath(path: string | undefined): { kind: 'group' | 'nav'; index: number; widgetIndex: number; input: string } | null {\n if (!path) return null;\n const g = path.match(/^inputs\\.config\\.tabs\\[(\\d+)\\]\\.widgets\\[(\\d+)\\]\\.inputs\\.(.+)$/);\n if (g) return { kind: 'group', index: Number(g[1]), widgetIndex: Number(g[2]), input: g[3] };\n const n = path.match(/^inputs\\.config\\.nav\\.links\\[(\\d+)\\]\\.widgets\\[(\\d+)\\]\\.inputs\\.(.+)$/);\n if (n) return { kind: 'nav', index: Number(n[1]), widgetIndex: Number(n[2]), input: n[3] };\n return null;\n }\n\n // Pan/Zoom handlers\n onWheel(ev: WheelEvent): void {\n ev.preventDefault();\n const delta = Math.sign(ev.deltaY) * -0.05; // invert: wheel down zoom out\n this.zoom = Math.min(2, Math.max(0.5, this.zoom + delta));\n this.updateTransform();\n }\n private panning = false;\n private panStart = { x: 0, y: 0 };\n onPanStart(ev: MouseEvent): void {\n if ((ev.target as HTMLElement).closest('.port-hit')) return; // don't pan when starting on port\n this.panning = true;\n this.panStart = { x: ev.clientX - this.pan.x, y: ev.clientY - this.pan.y };\n const onMove = (e: MouseEvent) => this.onPanMove(e);\n const onUp = () => { this.panning = false; document.removeEventListener('mousemove', onMove); };\n document.addEventListener('mousemove', onMove);\n document.addEventListener('mouseup', onUp, { once: true });\n }\n private onPanMove(ev: MouseEvent): void {\n if (!this.panning) return;\n this.pan = { x: ev.clientX - this.panStart.x, y: ev.clientY - this.panStart.y };\n this.updateTransform();\n }\n private updateTransform(): void {\n this.transform.set(`translate(${this.pan.x},${this.pan.y}) scale(${this.zoom})`);\n }\n\n // Edge/path helpers\n edgePath(e: GraphEdge): string {\n const from = this.findPortAnchor(e.from.nodeId, e.from.portId);\n const to = this.findPortAnchor(e.to.nodeId, e.to.portId);\n return routeElbow(from, to);\n }\n edgeTooltip(e: GraphEdge): string {\n const toNode = this.nodes().find(n => n.id === e.to.nodeId);\n const fromNode = this.nodes().find(n => n.id === e.from.nodeId);\n const parts = [\n `De: ${fromNode?.label || e.from.nodeId}.${e.from.portId.replace('out:','')}`,\n `Para: ${toNode?.label || e.to.nodeId}.${e.to.portId.replace('in:','')}`,\n ];\n if (e.meta && (e.meta as any).friendlyPath) parts.push(`Destino: ${(e.meta as any).friendlyPath}`);\n if (e.meta?.map) parts.push(`map: ${e.meta.map}`);\n return parts.join('\\n');\n }\n\n // Port label/tooltip helpers\n private technicalName(p: GraphPort): string {\n const fromId = p.id?.replace(/^in:|^out:/, '') || '';\n if (fromId) return fromId;\n if (p.path) {\n const m = p.path.match(/\\.inputs\\.([^\\.\\[]+)$/);\n if (m) return m[1];\n }\n return p.label || '';\n }\n portText(p: GraphPort): string { return this.showFriendlyLabels ? (p.label || this.technicalName(p)) : this.technicalName(p); }\n portTooltip(p: GraphPort): string {\n const tech = this.technicalName(p);\n const friendly = p.label || tech;\n const desc = p.description ? `\\n${p.description}` : '';\n if (this.showFriendlyLabels) return `${friendly}${desc}`;\n // when showing technical, include friendly for contexto if different\n return friendly !== tech ? `${tech} \\n(${friendly})${desc}` : `${tech}${desc}`;\n }\n edgeMid(e: GraphEdge): { x: number; y: number } {\n const from = this.findPortAnchor(e.from.nodeId, e.from.portId);\n const to = this.findPortAnchor(e.to.nodeId, e.to.portId);\n return { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };\n }\n private findPortAnchor(nodeId: string, portId: string): { x: number; y: number } {\n const n = this.nodes().find((x) => x.id === nodeId);\n if (!n) return { x: 0, y: 0 };\n const p = n.ports.find((pp) => pp.id === portId);\n return p?.anchor || { x: n.bounds.x, y: n.bounds.y };\n }\n\n // Drag connect flow\n onDragStart(ev: DragStartEvent): void {\n this.dragFrom = ev.from;\n this.dragPath.set(`M ${ev.x} ${ev.y} L ${ev.x} ${ev.y}`);\n try { console.debug('[ConnectionGraph] drag:start', { from: ev.from, at: { x: ev.x, y: ev.y } }); } catch {}\n }\n onDragMove(ev: DragMoveEvent): void {\n if (!this.dragFrom) return;\n const from = this.findPortAnchor(this.dragFrom.nodeId, this.dragFrom.portId);\n this.dragPath.set(routeElbow(from, { x: ev.x, y: ev.y }));\n try { console.debug('[ConnectionGraph] drag:move', { to: { x: ev.x, y: ev.y } }); } catch {}\n }\n onDragEnd(ev: DragEndEvent): void {\n if (!this.dragFrom) return;\n this.dragPath.set('');\n // Find target input port element (robust: handle circle or its container)\n const endEl = ev.endEvent.target as Element;\n const targetInfo = { tag: (endEl as any)?.tagName, class: (endEl as any)?.className } as any;\n let circleEl: Element | null = (endEl as any)?.closest?.('.port.input circle') as Element | null;\n if (!circleEl) {\n const portGroup = (endEl as any)?.closest?.('.port.input') as Element | null;\n if (portGroup) circleEl = (portGroup.querySelector('circle') as Element) || null;\n }\n if (!circleEl && endEl && (endEl as any).getAttribute) {\n const hasData = Boolean((endEl as any).getAttribute('data-port-id'));\n if (hasData) circleEl = endEl as Element;\n }\n if (!circleEl) {\n try { console.warn('[ConnectionGraph] drag:end — no target port found', targetInfo); } catch {}\n this.dragFrom = undefined; return;\n }\n const toNodeId = circleEl.getAttribute('data-node-id') || '';\n const toPortId = circleEl.getAttribute('data-port-id') || '';\n const path = circleEl.getAttribute('data-port-path') || undefined;\n try { console.debug('[ConnectionGraph] drag:end found target', { toNodeId, toPortId, path, at: { x: ev.x, y: ev.y } }); } catch {}\n this.pendingPopover.set({ x: ev.x, y: ev.y, from: this.dragFrom, to: { nodeId: toNodeId, portId: toPortId, path }, map: 'payload' });\n try { console.debug('[ConnectionGraph] popover:open', this.pendingPopover()); } catch {}\n this.dragFrom = undefined;\n }\n cancelPending(): void { this.pendingPopover.set(null); }\n confirmPending(): void {\n const pop = this.pendingPopover();\n if (!pop) return;\n const fromOutput = pop.from.portId.replace(/^out:/, '');\n const toInputName = pop.to.portId.replace(/^in:/, '');\n const toPath = pop.to.path || toInputName;\n const newConn: WidgetConnection = {\n from: { widget: pop.from.nodeId, output: fromOutput },\n to: { widget: this.resolveToWidget(pop.to.nodeId), input: toPath },\n map: pop.map,\n meta: {\n filterExpr: pop.filter || undefined,\n debounceMs: pop.debounceMs || undefined,\n distinct: pop.distinct || undefined,\n distinctBy: pop.distinctBy || undefined,\n },\n } as any;\n const next = [...this.connections(), newConn];\n try { console.debug('[ConnectionGraph] connection:add', newConn); } catch {}\n this.connections.set(next);\n this.pendingPopover.set(null);\n try { console.debug('[ConnectionGraph] popover:close'); } catch {}\n this.rebuildGraph();\n this.emitPageChange();\n this.updateDirty(next);\n }\n applyPreset(map: string): void {\n const pop = this.pendingPopover(); if (!pop) return;\n this.pendingPopover.set({ ...pop, map });\n }\n computePreview(map?: string): string {\n try {\n const sample: any = { payload: { id: 123, data: { id: 123, name: 'Exemplo' }, row: { id: 123 } } };\n const path = (map || 'payload').split('.');\n let cur: any = sample;\n for (const k of path) { if (!k) continue; cur = cur?.[k]; }\n return JSON.stringify(cur);\n } catch { return '—'; }\n }\n computeFilterPreview(expr?: string): string {\n try {\n if (!expr) return '—';\n const sample: any = { payload: { id: 123, data: { id: 123, name: 'Exemplo' }, row: { id: 123 } } };\n if (typeof expr === 'string' && expr.trim().startsWith('=')) {\n const code = expr.trim().slice(1);\n // Controlled eval limited to sample context\n // eslint-disable-next-line no-new-func\n const fn = new Function('payload', `\"use strict\"; return (${code});`);\n const ok = !!fn(sample.payload);\n return ok ? 'pass' : 'skip';\n }\n return '—';\n } catch { return 'erro'; }\n }\n private resolveToWidget(nodeId: string): string {\n // Child nodes are encoded as parent#kind[index]/widgetIndex\n const hash = nodeId.indexOf('#');\n if (hash > 0) return nodeId.slice(0, hash);\n return nodeId;\n }\n\n removeEdge(index: number): void {\n const next = this.connections().slice();\n next.splice(index, 1);\n this.connections.set(next);\n this.rebuildGraph();\n this.emitPageChange();\n this.updateDirty(next);\n }\n\n // Select edge and open details\n selectEdge(index: number): void {\n this.selectedEdgeIndex.set(index);\n this.showDetails = true;\n }\n currentConn(): WidgetConnection | undefined { const i = this.selectedEdgeIndex(); return (i >= 0 ? this.connections()[i] : undefined); }\n\n private emitPageChange(): void {\n const parsed = this.parsePage(this.page) || ({ widgets: this.widgets || [] } as GridPageDefinition);\n const updated: GridPageDefinition = { ...parsed, connections: this.connections() };\n this.pageChange.emit(updated);\n }\n\n // Settings panel value provider methods\n getSettingsValue(): any { return { page: { ...(this.parsePage(this.page) || {}), connections: this.connections() } }; }\n reset(): void {\n try { this.connections.set(JSON.parse(this.initialSnapshot || '[]')); } catch { this.connections.set([]); }\n this.rebuildGraph();\n this.isDirty$.next(false);\n this.isValid$.next(true);\n }\n\n // Node dragging\n private nodeDrag = { active: false, index: -1, startX: 0, startY: 0, origX: 0, origY: 0 };\n onNodeDragStart(ev: MouseEvent, index: number): void {\n ev.preventDefault(); ev.stopPropagation();\n const n = this.nodes()[index]; if (!n) return;\n this.nodeDrag = { active: true, index, startX: ev.clientX, startY: ev.clientY, origX: n.bounds.x, origY: n.bounds.y };\n const onMove = (e: MouseEvent) => this.onNodeDragMove(e);\n const onUp = () => { this.nodeDrag.active = false; document.removeEventListener('mousemove', onMove); };\n document.addEventListener('mousemove', onMove);\n document.addEventListener('mouseup', onUp, { once: true });\n }\n private onNodeDragMove(ev: MouseEvent): void {\n if (!this.nodeDrag.active) return;\n const dx = (ev.clientX - this.nodeDrag.startX) / this.zoom;\n const dy = (ev.clientY - this.nodeDrag.startY) / this.zoom;\n const nodes = this.nodes().slice();\n const n = { ...nodes[this.nodeDrag.index] };\n const newX = this.nodeDrag.origX + dx;\n const newY = this.nodeDrag.origY + dy;\n const ddx = newX - n.bounds.x;\n const ddy = newY - n.bounds.y;\n n.bounds = { ...n.bounds, x: newX, y: newY };\n // Shift port anchors by delta\n n.ports = (n.ports || []).map(p => ({ ...p, anchor: { x: p.anchor.x + ddx, y: p.anchor.y + ddy } }));\n nodes[this.nodeDrag.index] = n;\n this.nodes.set(nodes);\n }\n\n trackPort(_idx: number, p: GraphPort) { return p.id; }\n private updateDirty(next: WidgetConnection[]): void {\n this.isDirty$.next(JSON.stringify(next) !== this.initialSnapshot);\n this.isValid$.next(true);\n }\n\n // Builder integration\n openBuilder(): void {\n const base = this.parsePage(this.page) || ({ widgets: this.widgets || [] } as GridPageDefinition);\n const p: GridPageDefinition = { ...base, connections: this.connections() } as GridPageDefinition;\n try { console.debug('[ConnectionGraph] openBuilder()', { baseHasConns: !!(base as any).connections, graphConns: this.connections().length, widgets: this.widgets?.length }); } catch {}\n const ref = this.settingsPanel.open({ id: 'grid-connections', title: 'Conexões (Builder)', content: { component: ConnectionBuilderComponent, inputs: { page: p, widgets: this.widgets } } });\n ref.applied$.subscribe((val: any) => { const next = (val && val.page) ? (val.page as GridPageDefinition) : undefined; if (next) this.applyConnections(next); });\n ref.saved$.subscribe((val: any) => { const next = (val && val.page) ? (val.page as GridPageDefinition) : undefined; if (next) this.applyConnections(next); });\n }\n private applyConnections(next: GridPageDefinition): void {\n const conns = [...(next.connections || [])];\n try { console.debug('[ConnectionGraph] applyConnections()', { conns: conns.length }); } catch {}\n this.connections.set(conns);\n this.rebuildGraph();\n this.emitPageChange();\n this.initialSnapshot = JSON.stringify(conns);\n this.isDirty$.next(false);\n }\n}\n"],"names":["i1","i6","i7"],"mappings":";;;;;;;;;;;;;;;;;;AAGA;;;AAGG;AACG,SAAU,UAAU,CAAC,IAAW,EAAE,EAAS,EAAA;AAC/C,IAAA,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,CAAC;AAChC,IAAA,MAAM,CAAC,GAAG;AACR,QAAA,CAAA,EAAA,EAAK,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAA,CAAE;AACvB,QAAA,CAAA,EAAA,EAAK,IAAI,CAAA,CAAA,EAAI,IAAI,CAAC,CAAC,CAAA,CAAE;AACrB,QAAA,CAAA,EAAA,EAAK,IAAI,CAAA,CAAA,EAAI,EAAE,CAAC,CAAC,CAAA,CAAE;AACnB,QAAA,CAAA,EAAA,EAAK,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA,CAAE;KACpB;AACD,IAAA,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC;AACpB;AAEA;AACM,SAAU,aAAa,CAAC,IAAW,