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>
This commit is contained in:
@@ -778,6 +778,7 @@ enum L10n {
|
||||
// MARK: - Overview tab (OverviewView.swift + OverviewGraph.swift)
|
||||
"Aktualisieren": "Refresh",
|
||||
"Zurücksetzen": "Reset",
|
||||
"Fokus": "Focus",
|
||||
"Anordnung zurücksetzen — setzt manuell verschobene Kästchen auf die ursprüngliche Anordnung zurück.":
|
||||
"Reset layout — moves manually dragged cards back to their original arrangement.",
|
||||
"Lade Router-Konfiguration…": "Loading router configuration…",
|
||||
@@ -879,6 +880,7 @@ enum L10n {
|
||||
"Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.":
|
||||
"All fields RouterOS returned for this entry — useful if the status/port looks wrong here.",
|
||||
"Schließen": "Close",
|
||||
"Schließen, ohne zu speichern": "Close without saving",
|
||||
"Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.":
|
||||
"Run from the router itself (its own SSH connection) — tests reachability from the router to this device, not from this Mac.",
|
||||
"(keine Ausgabe)": "(no output)",
|
||||
|
||||
@@ -124,4 +124,30 @@ struct OverviewGraph: Equatable {
|
||||
}
|
||||
return visited
|
||||
}
|
||||
|
||||
/// Every node reachable from `nodeID` by following edges in either direction, regardless of
|
||||
/// category — used by the Übersicht "Fokus-Modus" (`OverviewView`) to decide which nodes move
|
||||
/// into their own strip below a divider when a node is clicked. Deliberately separate from
|
||||
/// `highlightedNodeIDs`: that one only expands past one hop for interface nodes (a distinct,
|
||||
/// already-live-confirmed scope for edge-brightness highlighting), while the focus strip is
|
||||
/// meant to always show the complete parent/child chain of whatever was clicked.
|
||||
func connectedChain(startingAt nodeID: String) -> Set<String> {
|
||||
guard nodes.contains(where: { $0.id == nodeID }) else { return [] }
|
||||
var visited: Set<String> = [nodeID]
|
||||
var frontier = [nodeID]
|
||||
while !frontier.isEmpty {
|
||||
var next: [String] = []
|
||||
for current in frontier {
|
||||
for edge in edges {
|
||||
if edge.from == current, visited.insert(edge.to).inserted {
|
||||
next.append(edge.to)
|
||||
} else if edge.to == current, visited.insert(edge.from).inserted {
|
||||
next.append(edge.from)
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return visited
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,30 +455,45 @@ private struct RawFieldsSheet: View {
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)")
|
||||
.font(.headline)
|
||||
Text(L10n.t("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// Same header layout/behavior as every other popup's close button (see `OverviewView
|
||||
// .focusPanel`): fixed title + spacer + `xmark.circle.fill`, `.padding(10)`, `Divider()`
|
||||
// directly below, separate from the scrollable content's own padding.
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
onClose()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(L10n.t("Schließen", appLanguage))
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(device.rawFields, id: \.key) { pair in
|
||||
HStack(alignment: .top) {
|
||||
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(device.rawFields, id: \.key) { pair in
|
||||
HStack(alignment: .top) {
|
||||
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(L10n.t("Schließen", appLanguage)) { onClose() }
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 380, minHeight: 300)
|
||||
}
|
||||
}
|
||||
@@ -491,25 +506,37 @@ private struct NetworkToolResultSheet: View {
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(result.title)
|
||||
.font(.headline)
|
||||
Text(L10n.t("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
ScrollView {
|
||||
Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(result.title)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button(L10n.t("Schließen", appLanguage)) { onClose() }
|
||||
Button {
|
||||
onClose()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(L10n.t("Schließen", appLanguage))
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 420, minHeight: 320)
|
||||
}
|
||||
}
|
||||
@@ -524,27 +551,39 @@ private struct PortScanResultSheet: View {
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
|
||||
.font(.headline)
|
||||
Text(L10n.t("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
onClose()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(L10n.t("Schließen", appLanguage))
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 { Divider() }
|
||||
PortScanRow(entry: entry, appLanguage: appLanguage)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 { Divider() }
|
||||
PortScanRow(entry: entry, appLanguage: appLanguage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(L10n.t("Schließen", appLanguage)) { onClose() }
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 420, minHeight: 420)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,29 @@ struct ExpertItemEditView: View {
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
// Header fixed outside the `Form` (not a `Section`, which would scroll away with the
|
||||
// content) — same layout/behavior as the Übersicht focus popup's header (`OverviewView
|
||||
// .focusPanel`): title + spacer + `xmark.circle.fill`, `.padding(10)`, `Divider()`
|
||||
// directly below, per explicit request to keep every popup's close button consistent.
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t(schema.displayName, appLanguage)).font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.cancelEditing()
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(L10n.t("Schließen, ohne zu speichern", appLanguage))
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
Divider()
|
||||
|
||||
Form {
|
||||
ForEach(schema.fields) { field in
|
||||
fieldEditor(for: field)
|
||||
}
|
||||
@@ -191,10 +213,6 @@ struct ExpertItemEditView: View {
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button(L10n.t("Abbrechen", appLanguage)) {
|
||||
viewModel.cancelEditing()
|
||||
dismiss()
|
||||
}
|
||||
Spacer()
|
||||
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
|
||||
showApplyConfirmation = true
|
||||
@@ -202,9 +220,10 @@ struct ExpertItemEditView: View {
|
||||
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(minWidth: 900, idealWidth: 900, minHeight: 480, idealHeight: 620)
|
||||
.frame(minWidth: 900, idealWidth: 900, minHeight: 520, idealHeight: 660)
|
||||
.confirmationDialog(
|
||||
L10n.t("Jetzt am Router anwenden?", appLanguage),
|
||||
isPresented: $showApplyConfirmation,
|
||||
|
||||
@@ -17,6 +17,12 @@ struct OverviewView: View {
|
||||
@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.
|
||||
@@ -63,9 +69,39 @@ struct OverviewView: View {
|
||||
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.
|
||||
/// 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)
|
||||
@@ -189,15 +225,20 @@ struct OverviewView: View {
|
||||
description: Text(L10n.t("Der Router meldet aktuell keine Interfaces/Adressen.", appLanguage))
|
||||
)
|
||||
} else {
|
||||
ZStack {
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
EdgesCanvas(
|
||||
graph: viewModel.graph,
|
||||
positions: effectivePositions,
|
||||
highlightedNodeIDs: highlightedNodeIDs,
|
||||
highlightedNodeIDs: focusChainIDs ?? highlightedNodeIDs,
|
||||
hoveredEdge: $hoveredEdge,
|
||||
hoverPoint: $hoverPoint,
|
||||
selectedEdge: $selectedEdge
|
||||
selectedEdge: $selectedEdge,
|
||||
onBackgroundTap: {
|
||||
viewModel.selectedNodeID = nil
|
||||
selectedEdge = nil
|
||||
}
|
||||
)
|
||||
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
|
||||
|
||||
@@ -213,6 +254,7 @@ struct OverviewView: View {
|
||||
|
||||
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,
|
||||
@@ -220,6 +262,7 @@ struct OverviewView: View {
|
||||
)
|
||||
.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
|
||||
@@ -254,14 +297,112 @@ struct OverviewView: View {
|
||||
.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()
|
||||
.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 {
|
||||
@@ -293,6 +434,12 @@ private struct EdgesCanvas: View {
|
||||
@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`,
|
||||
@@ -415,7 +562,10 @@ private struct EdgesCanvas: View {
|
||||
.gesture(
|
||||
SpatialTapGesture()
|
||||
.onEnded { value in
|
||||
guard let edge = nearestEdge(to: value.location) else { return }
|
||||
guard let edge = nearestEdge(to: value.location) else {
|
||||
onBackgroundTap()
|
||||
return
|
||||
}
|
||||
selectedEdge = (selectedEdge?.id == edge.id) ? nil : edge
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user