Files
RouterOS/RouterOSAssistant/Features/Overview/OverviewView.swift
T
KayandClaude Sonnet 5 c9671b017c M28: Übersicht Fokus-Modus + einheitliches Close-Button-Layout
Klick auf einen Knoten in der Übersicht öffnet jetzt ein schwebendes
Popup mit der kompletten verbundenen Kette (neu OverviewGraph.
connectedChain, volle transitive Hülle unabhängig vom Knotentyp,
bewusst getrennt von der bestehenden highlightedNodeIDs), sauber im
selben Spalten-Layout neu angeordnet, Rest des Diagramms abgedunkelt.
Nicht-modales Overlay statt .sheet, damit die rechte Seitenleiste
währenddessen bedienbar bleibt (Knoten direkt aus dem Popup heraus
bearbeitbar). Popup-Größe passt sich automatisch dem Inhalt an, keine
Scrollbalken.

Der dabei entstandene Close-Button-Header (Titel + Spacer + X, fest
oben, Divider direkt darunter) wurde auf alle vier Popup-Formulare der
App vereinheitlicht: Experte-Bearbeiten-Sheet und die drei
Devices-Sheets (Rohdaten, Netzwerk-Test, Port-Scan) — dort ersetzt er
jeweils den bisherigen einzelnen "Schließen"-Button unten.

Mehrere Design-Iterationen live mit dem User durchgespielt (Trennlinie
im Canvas → separates Panel → Popup → .sheet → non-modales Overlay),
finale Version live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:46:39 +02:00

974 lines
48 KiB
Swift

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"
@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<String> {
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<String>? {
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 {
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,
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))
.font(.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
)
.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)
.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)).font(.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)),
hoveredEdge: $panelHoveredEdge,
hoverPoint: $panelHoverPoint,
selectedEdge: $selectedEdge,
onBackgroundTap: {
viewModel.selectedNodeID = nil
selectedEdge = nil
}
)
.frame(width: subLayout.canvasSize.width, height: subLayout.canvasSize.height)
ForEach(subGraph.nodes) { node in
if let point = subLayout.positions[node.id] {
NodeCardView(
node: node,
isSelected: node.id == viewModel.selectedNodeID,
isHovered: node.id == hoveredNodeID
)
.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)
.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) { 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, onEdit: editNode) { nodeID in
selectedEdge = nil
viewModel.selectedNodeID = nodeID
}
} else {
LegendView(appLanguage: appLanguage)
}
}
}
/// Draws every edge as a bezier curve, colored by its `kind` (see `OverviewStyle.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<String>
@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 = OverviewStyle.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
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))
.font(.caption.bold())
Text("\(fromTitle)\(toTitle)")
.font(.caption2)
if let label = edge.label {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 6).fill(.regularMaterial))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(OverviewStyle.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 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(OverviewStyle.color(for: edge.kind))
Text(L10n.t(edge.kind.rawValue, appLanguage)).font(.title3.bold())
}
Text(L10n.t("Verbindung", appLanguage)).font(.caption).foregroundStyle(.secondary)
Divider()
Text(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
if let label = edge.label {
Text(label)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Divider()
Text(L10n.t("Verbunden", appLanguage)).font(.subheadline.bold())
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).font(.caption).foregroundStyle(.secondary)
HStack(spacing: 4) {
if let node {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.color(for: node.category))
}
Text(fallbackTitle).font(.callout.bold())
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.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
var body: some View {
VStack(spacing: 2) {
HStack(spacing: 4) {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.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 : OverviewStyle.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 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(OverviewStyle.color(for: node.category))
Text(node.title).font(.title3.bold())
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))
.font(.caption)
.foregroundStyle(.secondary)
if !node.detail.isEmpty {
Divider()
VStack(alignment: .leading, spacing: 4) {
ForEach(node.detail, id: \.key) { pair in
HStack(alignment: .top) {
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
Spacer()
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
}
}
}
}
let connected = graph.edges.filter { $0.from == node.id || $0.to == node.id }
if !connected.isEmpty {
Divider()
Text(L10n.t("Verbindungen", appLanguage)).font(.subheadline.bold())
ForEach(connected) { 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(OverviewStyle.color(for: edge.kind))
.font(.caption)
}
.buttonStyle(.plain)
.help(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
}
}
}
.padding()
}
}
}
private struct LegendView: View {
let appLanguage: String
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(L10n.t("Übersicht", appLanguage)).font(.title3.bold())
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))
.font(.caption)
Divider()
Text(L10n.t("Auf ein Feld klicken zeigt hier rechts Details und alle Verbindungen.", appLanguage))
.font(.caption)
Divider()
Text(L10n.t("Nicht im Diagramm (aber im Experte-Tab erreichbar):", appLanguage)).font(.caption.bold())
VStack(alignment: .leading, spacing: 3) {
ForEach(OverviewGraph.unmappedAreas, id: \.self) { area in
Text(\(L10n.t(area, appLanguage))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Divider()
Text(L10n.t("Spalten", appLanguage)).font(.caption.bold())
VStack(alignment: .leading, spacing: 4) {
ForEach(OverviewNode.Category.allCases) { category in
Label(L10n.t(category.rawValue, appLanguage), systemImage: "square.fill")
.foregroundStyle(OverviewStyle.color(for: category))
.font(.caption)
}
}
Divider()
Text(L10n.t("Verbindungsarten", appLanguage)).font(.caption.bold())
VStack(alignment: .leading, spacing: 4) {
ForEach(OverviewEdgeKind.allCases) { kind in
Label(L10n.t(kind.rawValue, appLanguage), systemImage: "minus")
.foregroundStyle(OverviewStyle.color(for: kind))
.font(.caption)
}
}
}
.padding()
}
}
}
enum OverviewStyle {
static func color(for category: OverviewNode.Category) -> Color {
switch category {
case .interface: return .blue
case .ipAddress: return .teal
case .service: return .purple
case .route: return .orange
case .firewall: return .red
}
}
static func color(for kind: OverviewEdgeKind) -> Color {
switch kind {
case .vlan: return .indigo
case .bridgePort: return .cyan
case .wireguardPeer: return .pink
case .ipAddress: return .teal
case .dhcp: return .purple
case .route: return .orange
case .firewallInterface: return .red
case .addressList: return .yellow
}
}
/// 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())
}