import SwiftUI /// "Übersicht" tab: read-only diagram of the connected router's complete current configuration /// (IST-Zustand) — interfaces, IP addresses, DHCP/pools, routes and firewall/NAT, connected by /// lines that follow the router's own reference fields. For someone handed a router with unknown /// existing config, this is meant to answer "what's actually set up here" at a glance, before /// diving into the Experte-Tab to change anything. struct OverviewView: View { @ObservedObject var connectionService: ConnectionService @StateObject private var viewModel: OverviewViewModel /// Reused only to open the Experte-Tab's own add/edit sheet (`ExpertItemEditView`) for a /// clicked node's underlying RouterOS item — same field schemas, same idempotent write path, /// instead of duplicating either here. @StateObject private var expertViewModel: ExpertViewModel @AppStorage("appLanguage") private var appLanguage: String = "de" @AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard } @State private var scale: CGFloat = 1.0 @State private var hoveredNodeID: String? @State private var hoveredEdge: OverviewEdge? @State private var hoverPoint: CGPoint = .zero /// The focus panel's own hover/tooltip state — kept separate from the main diagram's, since /// the panel is a genuinely separate view further down, not sharing the main canvas' /// coordinate space (a shared `hoverPoint` would position the main diagram's floating tooltip /// using panel-local coordinates while hovering an edge down there). @State private var panelHoveredEdge: OverviewEdge? @State private var panelHoverPoint: CGPoint = .zero /// Clicking one of a selected node's highlighted lines shows a full explanation in the right /// panel (mirroring node clicks) — the floating hover tooltip alone only has room for the /// bare kind/from/to, not a plain-language explanation of what the connection means. @State private var selectedEdge: OverviewEdge? /// A click "locks in" a node's highlight until something else is clicked (or the same node /// again, to deselect) — it takes priority over hover. Hover only drives the highlight while /// nothing is selected yet, for a quick preview while sweeping across the diagram. Selection /// winning is what lets a clicked node's connections stay highlighted while tracing the mouse /// along one of its curves to read the tooltip, even where that curve passes close to some /// other, unrelated node's card — confirmed live (2026-09-15): the previous "hover always /// wins" order made the highlight jump to whatever card the cursor grazed en route. private var highlightNodeID: String? { viewModel.selectedNodeID ?? hoveredNodeID } /// Node IDs whose edges should draw at full strength — see `OverviewGraph.highlightedNodeIDs` /// for the one-hop-vs-interface-transitive-closure logic itself. private var highlightedNodeIDs: Set { guard let id = highlightNodeID else { return [] } return viewModel.graph.highlightedNodeIDs(startingAt: id) } /// Manual per-node drag adjustments, on top of `OverviewLayout`'s computed grid position — /// per explicit request: nodes can be dragged around for a clearer layout, connection lines /// follow automatically, and a toolbar button resets back to the original arrangement. Kept /// as plain view state (not persisted) — "Ausgangszustand" only ever means this session's /// freshly computed column layout, not something remembered across relaunches. @State private var nodeOffsets: [String: CGSize] = [:] /// The node currently mid-drag, if any, plus its live (not-yet-committed) translation — kept /// separate from `nodeOffsets` so `EdgesCanvas`/the dragged card itself update every frame of /// the drag, not just once it ends. @GestureState private var activeDrag: ActiveNodeDrag? private struct ActiveNodeDrag: Equatable { let nodeID: String let translation: CGSize } private func offset(forNodeID id: String) -> CGSize { var result = nodeOffsets[id] ?? .zero if let activeDrag, activeDrag.nodeID == id { result.width += activeDrag.translation.width result.height += activeDrag.translation.height } return result } /// "Fokus-Modus": the currently selected node's complete parent/child chain — `nil` while /// nothing is selected (focus mode off). Driven by `viewModel.selectedNodeID` directly (not a /// separate flag) so the existing click-to-select/click-again-to-deselect toggle already in /// the node tap gesture doubles as "enter/exit focus" for free. private var focusChainIDs: Set? { guard let id = viewModel.selectedNodeID else { return nil } return viewModel.graph.connectedChain(startingAt: id) } /// Sub-graph containing only the focused chain — feeds both `focusSubLayout` (below) and the /// focus panel's own node/edge rendering. private var focusSubGraph: OverviewGraph? { guard let chain = focusChainIDs else { return nil } return OverviewGraph( nodes: viewModel.graph.nodes.filter { chain.contains($0.id) }, edges: viewModel.graph.edges.filter { chain.contains($0.from) && chain.contains($0.to) } ) } /// Laid out with the exact same `OverviewLayout` column logic as the main diagram (per /// explicit request), just scoped to the chain, so the focus panel reads as a smaller version /// of the same diagram rather than a different layout style. private var focusSubLayout: OverviewLayoutResult? { guard let subGraph = focusSubGraph else { return nil } return OverviewLayout.layout(for: subGraph) } /// `OverviewLayout`'s computed grid positions, shifted by each node's current (committed + /// live-dragging) offset — what both the node cards and `EdgesCanvas`'s line endpoints /// actually draw at. The main diagram's own layout never changes in focus mode — the focused /// chain is shown a second time, neatly re-laid-out, in the separate panel below (see /// `focusPanel`); nodes here just dim if they're not part of the chain. private var effectivePositions: [String: CGPoint] { var result: [String: CGPoint] = [:] result.reserveCapacity(layout.positions.count) for (id, base) in layout.positions { let off = offset(forNodeID: id) result[id] = CGPoint(x: base.x + off.width, y: base.y + off.height) } return result } init(connectionService: ConnectionService) { self.connectionService = connectionService _viewModel = StateObject(wrappedValue: OverviewViewModel(connectionService: connectionService)) _expertViewModel = StateObject(wrappedValue: ExpertViewModel(connectionService: connectionService)) } private var layout: OverviewLayoutResult { OverviewLayout.layout(for: viewModel.graph) } /// The curated schema for this menu if one exists (so the edit sheet gets the same tooltips/ /// pickers as the Experte-Tab), otherwise a minimal generic one — every field then falls back /// to the sheet's free-form key=value editor, same as opening an uncurated path there directly. private func schema(for editTarget: OverviewNode.EditTarget) -> RouterOSMenuSchema { RouterOSSchemaCatalog.all.first(where: { $0.menuPath == editTarget.menuPath }) ?? RouterOSMenuSchema( menuPath: editTarget.menuPath, restPath: editTarget.restPath, category: .system, displayName: editTarget.menuPath, summary: "Generischer Zugriff ohne kuratierte Felder.", explanation: "Alle Felder erscheinen unten als freie Schlüssel/Wert-Paare." ) } /// Opens the Experte-Tab's edit sheet for a clicked node — reconstructs a `RouterOSMenuItem` /// from the node's already-loaded `detail` fields (no extra round-trip needed) and reuses /// `ExpertViewModel.startEditing`/`open` exactly as the Experte-Tab itself does. private func editNode(_ node: OverviewNode) { guard let editTarget = node.editTarget else { return } let resolvedSchema = schema(for: editTarget) expertViewModel.open(resolvedSchema) let fields = Dictionary(uniqueKeysWithValues: node.detail) expertViewModel.startEditing(RouterOSMenuItem(id: editTarget.itemID, fields: fields)) } var body: some View { NavigationStack { Group { if connectionService.credentials == nil { ContentUnavailableView( LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)), systemImage: "network.slash", description: Text(L10n.t("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.", appLanguage)) ) } else { HSplitView { diagramArea .frame(minWidth: 480, minHeight: 320) detailPanel .frame(minWidth: 260, idealWidth: 300, maxWidth: 380) } } } .navigationTitle(LocalizedStringKey(L10n.t("Übersicht", appLanguage))) .toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabOverview) } } .toolbar { ToolbarItemGroup { if viewModel.isLoading { ProgressView().controlSize(.small) } Button { Task { await viewModel.load() } } label: { Label(L10n.t("Aktualisieren", appLanguage), systemImage: "arrow.clockwise") } .disabled(viewModel.isLoading) Button { scale = max(0.4, scale - 0.15) } label: { Image(systemName: "minus.magnifyingglass") } Button { scale = 1.0 } label: { Text("100%") } Button { scale = min(2.0, scale + 0.15) } label: { Image(systemName: "plus.magnifyingglass") } Button(L10n.t("Zurücksetzen", appLanguage)) { nodeOffsets = [:] } .disabled(nodeOffsets.isEmpty) .help(L10n.t("Anordnung zurücksetzen — setzt manuell verschobene Kästchen auf die ursprüngliche Anordnung zurück.", appLanguage)) } } .onAppear { // SwiftUI keeps every tab's content alive when switching tabs on macOS (this view // isn't torn down and recreated) — `.task` only ever runs once per view lifetime, // so it wouldn't refire just from revisiting this tab. `.onAppear` does fire again // each time, which is what actually picks up changes made elsewhere (e.g. a route // added in the Experte-Tab) without a manual "Aktualisieren" click. Confirmed live // (2026-09-15): the tab didn't refresh after switching away and back. Task { await viewModel.load() } } .sheet(item: $expertViewModel.editingItem) { _ in // `selectedSchema` is always set by `editNode` right before `editingItem`, so this // is never actually reached — required only because the view needs a non-optional // schema to construct. if let resolvedSchema = expertViewModel.selectedSchema { ExpertItemEditView(viewModel: expertViewModel, schema: resolvedSchema, isNew: false) } } .onChange(of: expertViewModel.editingItem == nil) { _, isNil in guard isNil else { return } Task { await viewModel.load() } } } } @ViewBuilder private var diagramArea: some View { if viewModel.isLoading && viewModel.graph.nodes.isEmpty { ProgressView(L10n.t("Lade Router-Konfiguration…", appLanguage)) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = viewModel.loadError, viewModel.graph.nodes.isEmpty { ContentUnavailableView( L10n.t("Konnte Konfiguration nicht laden", appLanguage), systemImage: "exclamationmark.triangle", description: Text(error) ) } else if viewModel.graph.nodes.isEmpty { ContentUnavailableView( L10n.t("Keine Konfiguration gefunden", appLanguage), systemImage: "square.dashed", description: Text(L10n.t("Der Router meldet aktuell keine Interfaces/Adressen.", appLanguage)) ) } else { ZStack { ScrollView([.horizontal, .vertical]) { ZStack(alignment: .topLeading) { EdgesCanvas( graph: viewModel.graph, positions: effectivePositions, highlightedNodeIDs: focusChainIDs ?? highlightedNodeIDs, theme: colorTheme, hoveredEdge: $hoveredEdge, hoverPoint: $hoverPoint, selectedEdge: $selectedEdge, onBackgroundTap: { viewModel.selectedNodeID = nil selectedEdge = nil } ) .frame(width: layout.canvasSize.width, height: layout.canvasSize.height) ForEach(Array(OverviewLayout.columnOrder.enumerated()), id: \.offset) { index, category in Text(L10n.t(category.rawValue, appLanguage)) .appFont(.headline) .foregroundStyle(.secondary) .position( x: OverviewLayout.leftInset + CGFloat(index) * OverviewLayout.columnWidth + OverviewLayout.nodeWidth / 2, y: 14 ) } ForEach(viewModel.graph.nodes) { node in if let point = effectivePositions[node.id] { let isChainMember = focusChainIDs?.contains(node.id) == true NodeCardView( node: node, isSelected: node.id == viewModel.selectedNodeID, isHovered: node.id == hoveredNodeID, theme: colorTheme ) .frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight) .position(point) .opacity(focusChainIDs == nil || isChainMember ? 1.0 : 0.25) .onTapGesture { selectedEdge = nil viewModel.selectedNodeID = (viewModel.selectedNodeID == node.id) ? nil : node.id } .onHover { isHovering in hoveredNodeID = isHovering ? node.id : (hoveredNodeID == node.id ? nil : hoveredNodeID) } // `.simultaneousGesture` (not `.gesture`) so this doesn't steal // the tap above — a small `minimumDistance` lets a plain click // still register as a tap-to-select instead of a zero-length drag. .simultaneousGesture( DragGesture(minimumDistance: 2, coordinateSpace: .local) .updating($activeDrag) { value, state, _ in state = ActiveNodeDrag(nodeID: node.id, translation: value.translation) } .onEnded { value in var committed = nodeOffsets[node.id] ?? .zero committed.width += value.translation.width committed.height += value.translation.height nodeOffsets[node.id] = committed } ) } } if let edge = hoveredEdge { EdgeTooltipView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme) .position( x: min(max(hoverPoint.x + 90, 90), layout.canvasSize.width - 90), y: max(hoverPoint.y - 26, 16) ) .allowsHitTesting(false) } } .frame(width: layout.canvasSize.width, height: layout.canvasSize.height) .scaleEffect(scale, anchor: .topLeading) .frame(width: layout.canvasSize.width * scale, height: layout.canvasSize.height * scale) .padding() } if let subGraph = focusSubGraph, let subLayout = focusSubLayout { // Not a `.sheet` — a `.sheet` is a genuine OS-level modal that blocks the rest of // the window, including the right-hand sidebar's "Bearbeiten" button. Per explicit // follow-up request, editing a node from the sidebar has to work while this popup // is still open, so it's a plain non-modal overlay instead — the sidebar // (`detailPanel`, a separate `HSplitView` pane, never covered by this overlay) // stays fully interactive. Color.black.opacity(0.25) .ignoresSafeArea() .onTapGesture { viewModel.selectedNodeID = nil selectedEdge = nil } focusPanel(subGraph: subGraph, subLayout: subLayout) } } } } /// Separate panel below the main diagram showing the focused node's complete chain, neatly /// re-laid-out with the same `OverviewLayout` column logic — per explicit request: not a /// divider splitting the same scrollable canvas (an earlier iteration of this), a genuinely /// separate panel spanning the main diagram's own viewport width, height fit to its content, /// with its own close button (background click / re-clicking the same node up in the main /// diagram still also close it, same as before — this just adds an explicit affordance). @ViewBuilder private func focusPanel(subGraph: OverviewGraph, subLayout: OverviewLayoutResult) -> some View { VStack(spacing: 0) { HStack { Text(L10n.t("Fokus", appLanguage)).appFont(.headline) Spacer() Button { viewModel.selectedNodeID = nil selectedEdge = nil } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) .help(L10n.t("Fokus-Ansicht schließen", appLanguage)) } .padding(10) Divider() // No `ScrollView` here on purpose — per explicit request, this sheet sizes itself // exactly to `subLayout.canvasSize` (below) instead of clipping/scrolling a fixed // viewport, same "form sheet" sizing model as `ExpertItemEditView`. ZStack(alignment: .topLeading) { EdgesCanvas( graph: subGraph, positions: subLayout.positions, highlightedNodeIDs: Set(subGraph.nodes.map(\.id)), theme: colorTheme, hoveredEdge: $panelHoveredEdge, hoverPoint: $panelHoverPoint, selectedEdge: $selectedEdge, onBackgroundTap: { viewModel.selectedNodeID = nil selectedEdge = nil } ) .frame(width: subLayout.canvasSize.width, height: subLayout.canvasSize.height) ForEach(Array(OverviewLayout.columnOrder.enumerated()), id: \.offset) { index, category in Text(L10n.t(category.rawValue, appLanguage)) .appFont(.headline) .foregroundStyle(.secondary) .position( x: OverviewLayout.leftInset + CGFloat(index) * OverviewLayout.columnWidth + OverviewLayout.nodeWidth / 2, y: 14 ) } ForEach(subGraph.nodes) { node in if let point = subLayout.positions[node.id] { NodeCardView( node: node, isSelected: node.id == viewModel.selectedNodeID, isHovered: node.id == hoveredNodeID, theme: colorTheme ) .frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight) .position(point) .onTapGesture { selectedEdge = nil viewModel.selectedNodeID = node.id } .onHover { isHovering in hoveredNodeID = isHovering ? node.id : (hoveredNodeID == node.id ? nil : hoveredNodeID) } } } if let edge = panelHoveredEdge { EdgeTooltipView(edge: edge, graph: subGraph, appLanguage: appLanguage, theme: colorTheme) .position( x: min(max(panelHoverPoint.x + 90, 90), subLayout.canvasSize.width - 90), y: max(panelHoverPoint.y - 26, 16) ) .allowsHitTesting(false) } } .frame(width: subLayout.canvasSize.width, height: subLayout.canvasSize.height) } .fixedSize() .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.25), lineWidth: 1)) .shadow(color: .black.opacity(0.3), radius: 20, y: 8) } @ViewBuilder private var detailPanel: some View { if let edge = selectedEdge { EdgeDetailView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme) { nodeID in selectedEdge = nil viewModel.selectedNodeID = nodeID } } else if let id = viewModel.selectedNodeID, let node = viewModel.graph.nodes.first(where: { $0.id == id }) { NodeDetailView(node: node, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme, onEdit: editNode) { nodeID in selectedEdge = nil viewModel.selectedNodeID = nodeID } } else { LegendView(appLanguage: appLanguage, theme: colorTheme) } } } /// Draws every edge as a bezier curve, colored by its `kind` (see `colorTheme.color(for:)`) /// so the different dependency types are visually separated even with nothing selected. Hovering /// or clicking a node pushes its own edges to full strength and fades every other edge down to /// a hint, so "what connects to this" reads instantly in a busy diagram. Also tracks the mouse /// against every edge's actual curve (not just its bounding box) so hovering a connection line /// itself — not just a node — reports which edge is closest, for the tooltip in `OverviewView`. private struct EdgesCanvas: View { let graph: OverviewGraph let positions: [String: CGPoint] let highlightedNodeIDs: Set let theme: ColorTheme @Binding var hoveredEdge: OverviewEdge? @Binding var hoverPoint: CGPoint @Binding var selectedEdge: OverviewEdge? /// Fires when a tap lands on the canvas but not on any edge — this `Canvas` already covers /// the full diagram area underneath the node cards, so its own tap gesture is the simplest /// place to catch "clicked empty space" without a second, competing gesture recognizer. Used /// by `OverviewView` to exit "Fokus-Modus" on a background click, mirroring the existing /// click-the-same-node-again toggle. let onBackgroundTap: () -> Void /// How close the mouse needs to be to an edge's curve (in the canvas' own, unscaled point /// space — SwiftUI reports hover coordinates already adjusted for any ancestor `.scaleEffect`, /// so this threshold stays a constant regardless of the current zoom level) to count as /// hovering it. private static let hitTestDistance: CGFloat = 8 private struct EdgeGeometry { let edge: OverviewEdge let start: CGPoint let control1: CGPoint let control2: CGPoint let end: CGPoint } /// Same curve construction as the drawing loop below, computed once and shared with hit- /// testing so hovering can never disagree with what's actually drawn. /// /// Not every edge runs left-to-right between two different columns: a VLAN/bridge-port edge /// connects two nodes in the *same* column (interface → interface), and a firewall/NAT rule's /// outgoing-interface edge points *back* to an earlier column. The original single formula /// always exited the right edge of "from" and entered the left edge of "to" — for a /// same-column pair that put both control points ~180pt apart outside the nodes, and for a /// genuinely backward edge it put them past each other in opposite directions; both cases /// pushed the curve's control points well past x=0, clipped by the canvas' own bounds /// (confirmed live 2026-09-15: exactly the "connection lines cut off on the left" report). private var geometries: [EdgeGeometry] { graph.edges.compactMap { edge in guard let from = positions[edge.from], let to = positions[edge.to] else { return nil } let dx = to.x - from.x let halfWidth = OverviewLayout.nodeWidth / 2 let start: CGPoint let end: CGPoint let control1: CGPoint let control2: CGPoint if abs(dx) < 1 { // Same column: bulge out from the right edge of both nodes instead of looping // around the far sides. start = CGPoint(x: from.x + halfWidth, y: from.y) end = CGPoint(x: to.x + halfWidth, y: to.y) let bulge: CGFloat = 26 control1 = CGPoint(x: start.x + bulge, y: start.y) control2 = CGPoint(x: end.x + bulge, y: end.y) } else if dx > 0 { // Forward: exit the right edge of "from", enter the left edge of "to". start = CGPoint(x: from.x + halfWidth, y: from.y) end = CGPoint(x: to.x - halfWidth, y: to.y) let offset = max(abs(end.x - start.x) / 2, 30) control1 = CGPoint(x: start.x + offset, y: start.y) control2 = CGPoint(x: end.x - offset, y: end.y) } else { // Backward: mirror of the forward case — exit the left edge of "from", enter the // right edge of "to". start = CGPoint(x: from.x - halfWidth, y: from.y) end = CGPoint(x: to.x + halfWidth, y: to.y) let offset = max(abs(end.x - start.x) / 2, 30) control1 = CGPoint(x: start.x - offset, y: start.y) control2 = CGPoint(x: end.x + offset, y: end.y) } return EdgeGeometry(edge: edge, start: start, control1: control1, control2: control2, end: end) } } /// One "on, off" cycle of the flow animation's dash pattern, in points. private static let flowDash: [CGFloat] = [5, 5] /// How fast the dash pattern travels along a line, in points/second — slow enough to read as /// "gentle flow", not a distracting marquee. private static let flowSpeed: CGFloat = 12 /// Per explicit request: every connection line gets an animated flow direction (from "from" /// to "to", the same direction the diagram's own dependency arrows already document) rather /// than a static stroke. `TimelineView(.animation)` re-invokes the `Canvas` closure on every /// display frame with a fresh `timeline.date`, which is what lets a `dashPhase` computed from /// elapsed time actually animate — a plain `@State` `withAnimation(.repeatForever)` value /// wouldn't: `Canvas` is immediate-mode, so nothing outside of `TimelineView` re-triggers its /// drawing closure on every frame. The phase decreases over time, which moves the visible /// dashes in the direction the path is stroked (start → end, i.e. "from" → "to"). private func dashPhase(at date: Date) -> CGFloat { let cycleLength = Self.flowDash.reduce(0, +) let elapsed = CGFloat(date.timeIntervalSinceReferenceDate) return -(elapsed * Self.flowSpeed).truncatingRemainder(dividingBy: cycleLength) } var body: some View { TimelineView(.animation) { timeline in let phase = dashPhase(at: timeline.date) Canvas { context, _ in for geometry in geometries { let edge = geometry.edge let isConnected = highlightedNodeIDs.contains(edge.from) || highlightedNodeIDs.contains(edge.to) let isDimmed = !highlightedNodeIDs.isEmpty && !isConnected let isHovered = hoveredEdge?.id == edge.id || selectedEdge?.id == edge.id var path = Path() path.move(to: geometry.start) path.addCurve(to: geometry.end, control1: geometry.control1, control2: geometry.control2) let baseColor = theme.color(for: edge.kind) context.stroke( path, with: .color(baseColor.opacity(isHovered ? 1.0 : (isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6)))), style: StrokeStyle( lineWidth: isHovered ? 3.2 : (isConnected ? 2.6 : 1.3), lineCap: .round, dash: Self.flowDash, dashPhase: phase ) ) } } .onContinuousHover { phase in switch phase { case .active(let location): hoverPoint = location hoveredEdge = stickyNearestEdge(to: location) case .ended: hoveredEdge = nil } } .gesture( SpatialTapGesture() .onEnded { value in guard let edge = nearestEdge(to: value.location) else { onBackgroundTap() return } selectedEdge = (selectedEdge?.id == edge.id) ? nil : edge } ) } } /// Edges eligible for hover right now — every edge normally, but narrowed down to just the /// ones touching `highlightNodeID` once a node is clicked/hovered. A clicked interface can /// easily have half a dozen edges leaving the same corner; searching the whole diagram for /// "nearest curve" among all of them (plus everything else on screen) is exactly what made /// hovering feel like it kept jumping to the wrong line. Once the user has already narrowed /// things down to one node's connections, hover only needs to disambiguate between those. private var candidateGeometries: [EdgeGeometry] { guard !highlightedNodeIDs.isEmpty else { return geometries } return geometries.filter { highlightedNodeIDs.contains($0.edge.from) || highlightedNodeIDs.contains($0.edge.to) } } /// Several edges often fan out from the exact same point (e.g. every edge leaving one /// interface's right edge) — right there, they're all near-equidistant from the cursor, so /// picking the single closest one on every mouse-move event flickers between them on the /// slightest movement. Once an edge is hovered, keep showing it as long as the cursor stays /// within a wider tolerance of it, and only re-pick the nearest candidate once the cursor has /// actually moved away from the current one. private func stickyNearestEdge(to point: CGPoint) -> OverviewEdge? { if let current = hoveredEdge, let currentGeometry = candidateGeometries.first(where: { $0.edge.id == current.id }) { let distanceToCurrent = distanceToCurve( from: point, p0: currentGeometry.start, p1: currentGeometry.control1, p2: currentGeometry.control2, p3: currentGeometry.end ) if distanceToCurrent < Self.hitTestDistance * 2 { return current } } return nearestEdge(to: point) } private func nearestEdge(to point: CGPoint) -> OverviewEdge? { var best: (edge: OverviewEdge, distance: CGFloat)? for geometry in candidateGeometries { let distance = distanceToCurve( from: point, p0: geometry.start, p1: geometry.control1, p2: geometry.control2, p3: geometry.end ) if distance < Self.hitTestDistance, best == nil || distance < best!.distance { best = (geometry.edge, distance) } } return best?.edge } /// Approximates point-to-cubic-bezier distance by sampling the curve — exact enough for an /// 8pt hit-test threshold, and far simpler than solving it analytically. private func distanceToCurve(from point: CGPoint, p0: CGPoint, p1: CGPoint, p2: CGPoint, p3: CGPoint) -> CGFloat { let samples = 24 var minDistance = CGFloat.greatestFiniteMagnitude for step in 0...samples { let t = CGFloat(step) / CGFloat(samples) let u = 1 - t let x = u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x let y = u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y let dx = x - point.x let dy = y - point.y minDistance = min(minDistance, (dx * dx + dy * dy).squareRoot()) } return minDistance } } /// Floating label shown next to the cursor while hovering a connection line — what it connects /// and what kind of dependency it is, without needing to click either endpoint node first. private struct EdgeTooltipView: View { let edge: OverviewEdge let graph: OverviewGraph let appLanguage: String let theme: ColorTheme private var fromTitle: String { graph.nodes.first(where: { $0.id == edge.from })?.title ?? edge.from } private var toTitle: String { graph.nodes.first(where: { $0.id == edge.to })?.title ?? edge.to } var body: some View { VStack(alignment: .leading, spacing: 2) { Text(L10n.t(edge.kind.rawValue, appLanguage)) .appFont(.caption, bold: true) Text("\(fromTitle) → \(toTitle)") .appFont(.caption2) if let label = edge.label { Text(label) .appFont(.caption2) .foregroundStyle(.secondary) } } .padding(8) .background(RoundedRectangle(cornerRadius: 6).fill(.regularMaterial)) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(theme.color(for: edge.kind), lineWidth: 1) ) .fixedSize() } } /// Full-detail view for a clicked connection line, shown in the right-hand panel exactly like a /// clicked node's details — the floating hover tooltip only has room for the bare kind/from/to, /// this adds a plain-language explanation of what the connection actually means plus quick jumps /// to either endpoint's own detail view. private struct EdgeDetailView: View { let edge: OverviewEdge let graph: OverviewGraph let appLanguage: String let theme: ColorTheme let onJumpToNode: (String) -> Void private var fromNode: OverviewNode? { graph.nodes.first(where: { $0.id == edge.from }) } private var toNode: OverviewNode? { graph.nodes.first(where: { $0.id == edge.to }) } private var fromTitle: String { fromNode?.title ?? edge.from } private var toTitle: String { toNode?.title ?? edge.to } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack { Image(systemName: "arrow.triangle.branch") .foregroundStyle(theme.color(for: edge.kind)) Text(L10n.t(edge.kind.rawValue, appLanguage)).appFont(.title3, bold: true) } Text(L10n.t("Verbindung", appLanguage)).appFont(.caption).foregroundStyle(.secondary) Divider() Text(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage)) .appFont(.callout) .fixedSize(horizontal: false, vertical: true) if let label = edge.label { Text(label) .appFont(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Divider() Text(L10n.t("Verbunden", appLanguage)).appFont(.subheadline, bold: true) endpointRow(title: L10n.t("Von", appLanguage), node: fromNode, fallbackTitle: fromTitle, id: edge.from) endpointRow(title: L10n.t("Nach", appLanguage), node: toNode, fallbackTitle: toTitle, id: edge.to) } .padding() } } @ViewBuilder private func endpointRow(title: String, node: OverviewNode?, fallbackTitle: String, id: String) -> some View { Button { onJumpToNode(id) } label: { HStack { VStack(alignment: .leading, spacing: 2) { Text(title).appFont(.caption).foregroundStyle(.secondary) HStack(spacing: 4) { if let node { Image(systemName: OverviewStyle.icon(for: node.kind)) .foregroundStyle(theme.color(for: node.category)) } Text(fallbackTitle).appFont(.callout, bold: true) } } Spacer() Image(systemName: "chevron.right") .appFont(.caption) .foregroundStyle(.secondary) } .padding(8) .background(RoundedRectangle(cornerRadius: 8).fill(.quaternary.opacity(0.3))) } .buttonStyle(.plain) } } private struct NodeCardView: View { let node: OverviewNode let isSelected: Bool let isHovered: Bool let theme: ColorTheme var body: some View { VStack(spacing: 2) { HStack(spacing: 4) { Image(systemName: OverviewStyle.icon(for: node.kind)) .foregroundStyle(theme.color(for: node.category)) Text(node.title) .font(.system(size: 12, weight: .semibold)) .lineLimit(1) } if let subtitle = node.subtitle, !subtitle.isEmpty { Text(subtitle) .font(.system(size: 10)) .foregroundStyle(.secondary) .lineLimit(1) } } .padding(6) .frame(maxWidth: .infinity) .background(RoundedRectangle(cornerRadius: 8).fill(.background)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke( isSelected || isHovered ? Color.accentColor : theme.color(for: node.category).opacity(0.5), lineWidth: isSelected || isHovered ? 2 : 1 ) ) .shadow(color: .black.opacity(isHovered ? 0.22 : 0.08), radius: isHovered ? 4 : 1, y: isHovered ? 2 : 1) .scaleEffect(isHovered ? 1.05 : 1.0) .animation(.easeOut(duration: 0.12), value: isHovered) .contentShape(Rectangle()) } } private struct NodeDetailView: View { let node: OverviewNode let graph: OverviewGraph let appLanguage: String let theme: ColorTheme let onEdit: (OverviewNode) -> Void let onSelectNode: (String) -> Void var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack { Image(systemName: OverviewStyle.icon(for: node.kind)) .foregroundStyle(theme.color(for: node.category)) Text(node.title).appFont(.title3, bold: true) Spacer() if node.editTarget != nil { Button { onEdit(node) } label: { Label(L10n.t("Bearbeiten", appLanguage), systemImage: "pencil") } .help(L10n.t("Öffnet dasselbe Bearbeiten-Formular wie im Experte-Tab und schreibt Änderungen direkt an den Router.", appLanguage)) } } Text(L10n.t(node.category.rawValue, appLanguage)) .appFont(.caption) .foregroundStyle(.secondary) if !node.detail.isEmpty { Divider() VStack(alignment: .leading, spacing: 4) { ForEach(Array(node.detail.enumerated()), id: \.element.key) { index, pair in HStack(alignment: .top) { Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary) Spacer() Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing) } .padding(.vertical, 2) .background(TableZebra.color(for: index)) } } } let connected = graph.edges.filter { $0.from == node.id || $0.to == node.id } if !connected.isEmpty { Divider() Text(L10n.t("Verbindungen", appLanguage)).appFont(.subheadline, bold: true) ForEach(Array(connected.enumerated()), id: \.element.id) { index, edge in let otherID = edge.from == node.id ? edge.to : edge.from let otherTitle = graph.nodes.first(where: { $0.id == otherID })?.title ?? otherID Button { onSelectNode(otherID) } label: { Label( edge.label.map { "\(otherTitle) (\($0))" } ?? otherTitle, systemImage: edge.from == node.id ? "arrow.right" : "arrow.left" ) .foregroundStyle(theme.color(for: edge.kind)) .appFont(.caption) } .buttonStyle(.plain) .help(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 2) .background(TableZebra.color(for: index)) } } } .padding() } } } private struct LegendView: View { let appLanguage: String let theme: ColorTheme var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text(L10n.t("Übersicht", appLanguage)).appFont(.title3, bold: true) Text(L10n.t("Zeigt den kompletten IST-Zustand des verbundenen Routers als Diagramm: Interfaces links, darauf aufbauend IP-Adressen, DHCP/Pools, Routen und rechts die Firewall/NAT-Regeln, die auf sie zugreifen. Linien zeigen echte Abhängigkeiten (z.B. \"DHCP-Server nutzt diesen Pool\"), keine Vermutungen.", appLanguage)) .appFont(.caption) Divider() Text(L10n.t("Auf ein Feld klicken zeigt hier rechts Details und alle Verbindungen.", appLanguage)) .appFont(.caption) Divider() Text(L10n.t("Nicht im Diagramm (aber im Experte-Tab erreichbar):", appLanguage)).appFont(.caption, bold: true) VStack(alignment: .leading, spacing: 3) { ForEach(OverviewGraph.unmappedAreas, id: \.self) { area in Text("· \(L10n.t(area, appLanguage))") .appFont(.caption2) .foregroundStyle(.secondary) } } Divider() Text(L10n.t("Spalten", appLanguage)).appFont(.caption, bold: true) VStack(alignment: .leading, spacing: 4) { ForEach(OverviewNode.Category.allCases) { category in Label(L10n.t(category.rawValue, appLanguage), systemImage: "square.fill") .foregroundStyle(theme.color(for: category)) .appFont(.caption) } } Divider() Text(L10n.t("Verbindungsarten", appLanguage)).appFont(.caption, bold: true) VStack(alignment: .leading, spacing: 4) { ForEach(OverviewEdgeKind.allCases) { kind in Label(L10n.t(kind.rawValue, appLanguage), systemImage: "minus") .foregroundStyle(theme.color(for: kind)) .appFont(.caption) } } } .padding() } } } /// Colors moved to `ColorTheme` (`AppPreferences.swift`) so they respond to the Settings window's /// "Farbschema" picker — this enum now only holds the parts that don't vary by theme. enum OverviewStyle { /// Plain-language explanation of what this connection kind actually means — shown in /// `EdgeDetailView` so clicking a line answers "why is this connected" for someone who /// doesn't already know RouterOS' internal terminology. static func explanation(for kind: OverviewEdgeKind) -> String { switch kind { case .vlan: return "Das VLAN-Interface baut auf dem Basis-Interface auf — es ist ein eigenes, per VLAN-Kennung getrenntes Netzwerk auf demselben physischen Anschluss." case .bridgePort: return "Dieser physische Port ist Mitglied dieser Bridge — Geräte an diesem Port verhalten sich, als hingen sie am selben Kabel wie alle anderen Bridge-Ports." case .wireguardPeer: return "Dieser WireGuard-VPN-Tunnel läuft über dieses Interface." case .ipAddress: return "Diesem Interface ist diese IP-Adresse zugewiesen." case .dhcp: return "Diese DHCP-Komponente (Server, Pool oder Netzwerk-Optionen) gehört zu diesem Interface bzw. dieser Adresse." case .route: return "Diese Route führt über dieses Interface bzw. dieses Gateway." case .firewallInterface: return "Diese Firewall- oder NAT-Regel bezieht sich auf dieses Interface (als Eingang oder Ausgang)." case .addressList: return "Diese Regel prüft, ob eine Adresse in dieser Adress-Liste steht." } } static func icon(for kind: String) -> String { switch kind { case "ether": return "cable.connector" case "vlan": return "square.stack.3d.up" case "bridge": return "point.3.connected.trianglepath.dotted" case "wlan", "wireless", "wifi": return "wifi" case "wireguard": return "lock.shield" case "wireguard-peer": return "person.crop.circle.badge.checkmark" case "address": return "number" case "pool": return "tray.full" case "dhcp-server": return "server.rack" case "dhcp-network": return "network" case "dhcp-client": return "arrow.down.circle" case "route": return "signpost.right.and.left" case "filter": return "shield.lefthalf.filled" case "nat": return "arrow.left.arrow.right" case "address-list": return "list.bullet.rectangle" default: return "questionmark.circle" } } } #Preview { OverviewView(connectionService: ConnectionService()) }