forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCanonicalMemoryAtlasView.swift
More file actions
2607 lines (2406 loc) · 98.9 KB
/
Copy pathCanonicalMemoryAtlasView.swift
File metadata and controls
2607 lines (2406 loc) · 98.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import AppKit
import OSLog
import OmiSupport
import OmiTheme
import SwiftUI
private let memoryAtlasLogger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.omi.desktop",
category: "MemoryAtlas"
)
extension Notification.Name {
static let desktopAutomationOpenMemoryAtlasRequested = Notification.Name(
"desktopAutomationOpenMemoryAtlasRequested"
)
static let desktopAutomationMemoryAtlasViewportRequested = Notification.Name(
"desktopAutomationMemoryAtlasViewportRequested"
)
static let desktopAutomationMemoryAtlasTimeRequested = Notification.Name(
"desktopAutomationMemoryAtlasTimeRequested"
)
/// Selecting an entity or a connection is otherwise only reachable by
/// clicking the canvas, which puts the inspector out of reach of every
/// cursor-free check.
static let desktopAutomationMemoryAtlasSelectRequested = Notification.Name(
"desktopAutomationMemoryAtlasSelectRequested"
)
/// Going into a neighbourhood is otherwise only reachable by clicking its
/// caption, which is a small target computed from the live camera — so the
/// one interaction the territory layer exists for was the one no check could
/// reach.
static let desktopAutomationMemoryAtlasRegionRequested = Notification.Name(
"desktopAutomationMemoryAtlasRegionRequested"
)
}
// MARK: - Canonical Atlas Containers
/// Holds the canvas until a complete graph is ready, so the first visit cannot
/// paint a synthetic owner as the whole map.
private struct CanonicalMemoryAtlasLoadGate<Content: View>: View {
@ObservedObject var viewModel: MemoryGraphViewModel
@ViewBuilder var content: () -> Content
var body: some View {
switch MemoryAtlasSurfacePresentation.phase(
isLoading: viewModel.isLoading,
isEmpty: viewModel.isEmpty,
hasProjection: viewModel.canonicalAtlasProjection != nil,
hasAttemptedLoad: viewModel.hasAttemptedCanonicalAtlasLoad
) {
case .loading:
ZStack {
Color.clear
ProgressView()
.controlSize(.regular)
.tint(Ink.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier("canonical_memory_atlas_loading")
case .empty:
VStack(spacing: OmiSpacing.sm) {
Image(systemName: "brain")
.scaledFont(size: OmiType.heading)
.foregroundColor(Ink.secondary)
Text("Brain map will appear once enough linked memories are available.")
.scaledFont(size: 12.5)
.foregroundColor(Ink.secondary)
.multilineTextAlignment(.center)
}
.padding(OmiSpacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier("canonical_memory_atlas_empty")
case .ready:
content()
}
}
}
struct CanonicalMemoryAtlasPage: View {
@ObservedObject var viewModel: MemoryGraphViewModel
let onBack: () -> Void
let evidenceProvider: ([String]) async -> [MemoryAtlasEvidence]
/// Opens a cited memory on the Memories surface this page came from.
let onOpenMemory: (String) -> Void
/// Reads the memoized snapshot drawn below, so the counts cannot drift.
private var headerCountLabel: String {
if let projection = viewModel.canonicalAtlasProjection {
return MemoryAtlasLayoutEngine.countLabel(
entities: projection.snapshot.nodes.filter { !$0.isCatalog }.count,
memories: projection.snapshot.nodes.filter(\.isCatalog).count,
connections: projection.snapshot.edges.count)
}
return MemoryAtlasLayoutEngine.countLabel(
entities: viewModel.graphResponse.atlasNodes.count,
memories: viewModel.graphResponse.catalogNodes?.count,
connections: viewModel.graphResponse.edges.count)
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Button(action: onBack) {
Label("Memories", systemImage: "chevron.left")
.scaledFont(size: 12, weight: .semibold)
.foregroundColor(Ink.secondary)
.padding(.horizontal, 10)
.frame(height: 30)
.glassChip()
}
.buttonStyle(.plain)
.accessibilityIdentifier("memory_atlas_back_to_memories")
// "Brain Map" everywhere the user can see it: the atlas replaces the
// legacy graph on the destination that already had that name, so
// introducing a second name for the same place only splits the domain
// vocabulary. "Atlas" survives in type and symbol names only.
Text("Brain Map")
.scaledFont(size: 17, weight: .semibold)
.foregroundColor(Ink.primary)
Spacer()
// Show the semantic map and the complete canonical-memory catalog as
// distinct counts; catalog records are visible but never fake edges.
Text(headerCountLabel)
.scaledFont(size: 12)
.foregroundColor(Ink.secondary)
.accessibilityIdentifier("memory_atlas_header_counts")
}
.padding(.horizontal, 18)
.frame(height: 44)
.background(Ink.rowFill)
Divider().overlay(Ink.separator.opacity(0.25))
CanonicalMemoryAtlasLoadGate(viewModel: viewModel) {
CanonicalMemoryAtlasSurface(
graph: viewModel.canonicalAtlasProjection?.graph ?? viewModel.graphResponse,
projection: viewModel.canonicalAtlasProjection,
compact: false,
evidenceProvider: evidenceProvider,
onOpenMemory: onOpenMemory,
onRebuild: { Task { await viewModel.rebuildCanonicalAtlas() } },
isRebuilding: viewModel.isRebuilding,
onLeave: onBack
)
}
}
.background(Color.clear)
.accessibilityIdentifier("canonical_memory_atlas_page")
.task { await viewModel.prepareCanonicalAtlas() }
.onAppear {
memoryAtlasLogger.info(
"Atlas page opened nodes=\(viewModel.graphResponse.atlasNodes.count, privacy: .public) edges=\(viewModel.graphResponse.edges.count, privacy: .public)"
)
}
}
}
/// Memory hub presentation of the atlas.
///
/// The hub already owns navigation chrome (the Memory menu selects the
/// destination), so this variant renders the surface full-bleed instead of
/// stacking the page's own back/title bar underneath the hub bar. It is the
/// assertion-backed counterpart to `MemoryGraphPage`, which fills the same tab
/// for users still on the legacy graph.
struct CanonicalMemoryAtlasTabView: View {
@ObservedObject var viewModel: MemoryGraphViewModel
let evidenceProvider: ([String]) async -> [MemoryAtlasEvidence]
/// Opens a cited memory on the hub's Memories destination.
let onOpenMemory: (String) -> Void
@Binding var searchText: String
var showsSearchField = true
/// Where Escape goes once the map has nothing of its own left to undo.
var onLeave: (() -> Void)?
var body: some View {
CanonicalMemoryAtlasLoadGate(viewModel: viewModel) {
CanonicalMemoryAtlasSurface(
graph: viewModel.canonicalAtlasProjection?.graph ?? viewModel.graphResponse,
projection: viewModel.canonicalAtlasProjection,
compact: false,
evidenceProvider: evidenceProvider,
onOpenMemory: onOpenMemory,
onRebuild: { Task { await viewModel.rebuildCanonicalAtlas() } },
isRebuilding: viewModel.isRebuilding,
externalSearchText: $searchText,
showsSearchField: showsSearchField,
onLeave: onLeave
)
}
.background(Color.clear)
.accessibilityIdentifier("canonical_memory_atlas_tab")
.task { await viewModel.prepareCanonicalAtlas() }
.onAppear {
memoryAtlasLogger.info(
"Atlas tab opened nodes=\(viewModel.graphResponse.atlasNodes.count, privacy: .public) edges=\(viewModel.graphResponse.edges.count, privacy: .public)"
)
}
}
}
// MARK: - Interactive Atlas Surface
private struct CanonicalMemoryAtlasSurface: View {
let graph: KnowledgeGraphResponse
/// Normal app surfaces pass the prebuilt projection from their view model.
/// Export previews retain the lightweight fallback so they remain
/// self-contained fixtures.
let projection: MemoryAtlasProjection?
let compact: Bool
/// Resolves the memory ids an entity cites into readable evidence for the
/// inspector. The surface stays independent of the memories layer; callers
/// that have no memories to offer (offscreen exports) return nothing.
///
/// Asynchronous because resolving a citation is a cache read, not a scan of
/// whatever the memories list happens to be showing.
let evidenceProvider: ([String]) async -> [MemoryAtlasEvidence]
/// Leaves the atlas for a cited memory. Absent on surfaces with nowhere to
/// go (offscreen exports, the inline preview).
let onOpenMemory: ((String) -> Void)?
/// Regenerating the server-side graph. Absent on surfaces that have no
/// view model to drive it (the inline preview, offscreen export renders).
let onRebuild: (() -> Void)?
let isRebuilding: Bool
var externalSearchText: Binding<String>? = nil
var showsSearchField = true
/// Where Escape goes once the map itself has nothing left to undo. Absent on
/// surfaces with nowhere to go, which is how those keep passing the key on.
let onLeave: (() -> Void)?
private let snapshot: MemoryAtlasSnapshot
private let renderPlanCache: MemoryAtlasRenderPlanCache
/// The cursor at which each relationship can first be painted. Precomputing
/// this avoids walking every edge again on every 30 Hz replay frame.
private let connectionBirthFractions: [Double]
/// Deterministic offscreen renders (ViewExporter QA) pin the time cursor and
/// suppress auto-play so the timeline captures a stable frame.
private let previewTimeCursor: Double?
private let previewEvidence: [MemoryAtlasEvidence]
@State private var localSearchText = ""
@State private var selectedNodeID: String?
/// Set when the user clicked a painted connection rather than an entity.
/// `selectedNodeID` still holds one endpoint so the map keeps its existing
/// neighborhood emphasis; this only redirects the inspector to the
/// relationship itself.
@State private var selectedEdgeID: String?
/// Entities the user followed connections away from, most recent last.
@State private var selectionTrail: [String] = []
@State private var evidence: [MemoryAtlasEvidence] = []
@State private var evidenceIsLoading = false
/// The ids the current evidence answers, so "how many are missing" compares
/// against what was actually asked for rather than the live selection.
@State private var requestedEvidenceIDs: [String] = []
/// The neighbourhood the user went into, if any.
///
/// Entering a place is a mode, not just a camera move. Inside one, the map
/// stops drawing everyone else's coastline and gives the entities their names
/// back — which is the trade the territory layer makes in the first place:
/// names are hidden under a caption while you are reading the map as a whole,
/// and handed back the moment you pick somewhere to look.
@State private var enteredRegionID: Int?
/// The zoom at which the user counts as having zoomed back out of the place
/// they went into. Set from the camera entering actually used, because a
/// fixed threshold throws the user out of any island big enough to be framed
/// below it — which is every island on a map with only a few regions on it.
@State private var departureZoom: CGFloat?
@State private var zoom: CGFloat = 1
@State private var settledZoom: CGFloat = 1
@State private var pan: CGSize = .zero
@State private var settledPan: CGSize = .zero
@State private var viewportSize: CGSize = .zero
@State private var isCameraMoving = false
@State private var matchingNodeIDs: Set<String>? = nil
@State private var matchingEdges: [MemoryAtlasEdgePlacement]? = nil
/// Normalized as-of position on the time axis, 1 == now (show everything).
@State private var timeCursor: Double = 1
@State private var isTimePlaying = false
@State private var didAutoplay = false
@State private var playbackTask: Task<Void, Never>? = nil
@FocusState private var searchIsFocused: Bool
/// Persisted: once the user pauses or scrubs the timeline, the atlas stops
/// auto-playing its growth animation on open. Playing all the way through is
/// the delightful default; interrupting it is an explicit opt-out.
@AppStorage("memory_atlas_timeline_autoplay") private var autoplayEnabled = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
init(
graph: KnowledgeGraphResponse,
projection: MemoryAtlasProjection? = nil,
compact: Bool,
evidenceProvider: @escaping ([String]) async -> [MemoryAtlasEvidence] = { _ in [] },
onOpenMemory: ((String) -> Void)? = nil,
onRebuild: (() -> Void)? = nil,
isRebuilding: Bool = false,
externalSearchText: Binding<String>? = nil,
showsSearchField: Bool = true,
onLeave: (() -> Void)? = nil,
previewTimeCursor: Double? = nil,
/// Deterministic offscreen renders open the inspector, which is otherwise
/// only reachable by tapping the canvas.
previewSelectedNodeID: String? = nil,
/// Selecting a connection, for the render that has to prove the
/// relationship inspector exists.
previewSelectedEdgeID: String? = nil,
/// Offscreen renders capture a frame before an asynchronous cache read
/// could land, so the export seeds the inspector's evidence directly
/// instead of photographing a spinner.
previewEvidence: [MemoryAtlasEvidence] = []
) {
self.graph = graph
self.projection = projection
self.compact = compact
self.evidenceProvider = evidenceProvider
self.onOpenMemory = onOpenMemory
self.onRebuild = onRebuild
self.isRebuilding = isRebuilding
self.externalSearchText = externalSearchText
self.showsSearchField = showsSearchField
self.onLeave = onLeave
self.previewTimeCursor = previewTimeCursor
self.previewEvidence = previewEvidence
_timeCursor = State(initialValue: previewTimeCursor ?? 1)
_selectedNodeID = State(initialValue: previewSelectedNodeID)
_selectedEdgeID = State(initialValue: previewSelectedEdgeID)
_evidence = State(initialValue: previewEvidence)
_requestedEvidenceIDs = State(initialValue: previewEvidence.map(\.id))
let atlasSnapshot: MemoryAtlasSnapshot
if let projection {
atlasSnapshot = projection.snapshot
} else {
let givenName = AuthService.shared.givenName.trimmingCharacters(in: .whitespacesAndNewlines)
let displayName = AuthService.shared.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
let ownerName = givenName.isEmpty ? displayName : givenName
atlasSnapshot = MemoryAtlasSnapshotCache.shared.snapshot(
for: graph,
userName: ownerName.isEmpty ? nil : ownerName
)
}
snapshot = atlasSnapshot
renderPlanCache = projection?.renderPlanCache ?? MemoryAtlasRenderPlanCache(snapshot: atlasSnapshot)
if let projection {
connectionBirthFractions = projection.connectionBirthFractions
} else if let timeline = atlasSnapshot.timeline {
connectionBirthFractions = atlasSnapshot.edges.map { placement in
let endpointBirth =
[placement.edge.sourceId, placement.edge.targetId].map { nodeID in
nodeID == atlasSnapshot.anchorNodeID ? 0 : (timeline.playbackFractionByNodeID[nodeID] ?? 1)
}.max() ?? 1
return max(timeline.fraction(for: placement.edge.createdAt), endpointBirth)
}
.sorted()
} else {
connectionBirthFractions = []
}
}
private var selectedNode: MemoryAtlasNodePlacement? {
guard let selectedNodeID else { return nil }
return snapshot.nodeByID[selectedNodeID]
}
private var selectedEdges: [MemoryAtlasEdgePlacement] {
guard let selectedNodeID else { return [] }
return snapshot.edgesByNodeID[selectedNodeID] ?? []
}
/// Selecting an edge always anchors `selectedNodeID` to one of its endpoints,
/// so the lookup stays within that node's degree instead of the whole graph.
private var selectedEdge: MemoryAtlasEdgePlacement? {
guard let selectedEdgeID else { return nil }
return selectedEdges.first { $0.id == selectedEdgeID }
}
/// The memory ids the current selection cites, newest-relationship-first and
/// de-duplicated. An edge answers for itself; an entity answers for all of
/// its connections.
private var citedMemoryIDs: [String] {
if let selectedEdge { return selectedEdge.edge.memoryIds }
var seen = Set<String>()
var ordered: [String] = []
// Seed from the selected entity's own memory IDs first. The backend
// writes memory_ids directly onto every extracted node independently of
// its edges, so isolated entities — and memories that mention an entity
// without producing a relationship — show no evidence if only edge IDs
// are collected.
if let selectedNode {
for id in selectedNode.node.memoryIds where seen.insert(id).inserted {
ordered.append(id)
}
}
for id in selectedEdges.flatMap(\.edge.memoryIds) where seen.insert(id).inserted {
ordered.append(id)
}
return ordered
}
/// Changing either half of the selection is a new evidence question.
private var evidenceSelectionKey: String {
"\(selectedNodeID ?? "")|\(selectedEdgeID ?? "")"
}
private var unresolvedEvidenceCount: Int {
evidenceIsLoading ? 0 : max(0, requestedEvidenceIDs.count - evidence.count)
}
private var recentConnectionCount: Int {
let threshold = Date().addingTimeInterval(-7 * 24 * 60 * 60)
return graph.edges.filter { $0.createdAt >= threshold }.count
}
private var timeline: MemoryAtlasTimeline? { snapshot.timeline }
/// The active as-of date, or `nil` when the cursor is parked at "now" (which
/// means: render the whole atlas, no time filtering).
private var asOfDate: Date? {
guard let timeline, timeCursor < 0.9995 else { return nil }
return timeline.date(atFraction: timeCursor)
}
private var visibleEntityCount: Int {
guard let timeline, timeCursor < 0.9995 else { return snapshot.nodes.count }
let anchorIsOutsideCursor = snapshot.anchorNodeID.map { !timeline.isVisible(nodeID: $0, at: timeCursor) } ?? false
return timeline.visibleNodeCount(at: timeCursor) + (anchorIsOutsideCursor ? 1 : 0)
}
private var visibleConnectionCount: Int {
guard timeline != nil, timeCursor < 0.9995 else { return snapshot.edges.count }
return firstConnectionBirthIndex(after: timeCursor)
}
private func firstConnectionBirthIndex(after fraction: Double) -> Int {
var lower = 0
var upper = connectionBirthFractions.count
while lower < upper {
let middle = lower + (upper - lower) / 2
if connectionBirthFractions[middle] > fraction {
upper = middle
} else {
lower = middle + 1
}
}
return lower
}
private var recentConnectionLabel: String {
recentConnectionCount > 99 ? "99+ new connections" : "\(recentConnectionCount) new connections"
}
private var searchBinding: Binding<String> {
externalSearchText ?? $localSearchText
}
private var searchText: String {
searchBinding.wrappedValue
}
var body: some View {
// The inspector is a sibling of the whole map, not an overlay on it: the
// canvas keeps its full height and the map simply narrows, so opening an
// entity never hides the part of the map you were looking at.
HStack(spacing: 0) {
mapColumn
if !compact, let selectedNode {
inspector(anchoredAt: selectedNode)
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.animation(OmiMotion.gated(.easeOut(duration: 0.18)), value: selectedNodeID)
.onChange(of: searchText) { _, query in
updateSearchMatches(query)
}
.task(id: evidenceSelectionKey) { await loadEvidence() }
.onEscapeKey(priority: .content) {
guard !compact else { return false }
return dismissTopmostState()
}
}
@ViewBuilder
private func inspector(anchoredAt placement: MemoryAtlasNodePlacement) -> some View {
if let selectedEdge {
relationshipPanel(for: selectedEdge, anchor: placement)
} else {
detailPanel(for: placement)
}
}
/// Resolves the selection's citations through the provider.
///
/// Stale evidence is cleared before the read rather than after it, so
/// switching entities never shows the previous entity's memories under the
/// new entity's name while the lookup is in flight.
private func loadEvidence() async {
guard previewEvidence.isEmpty else { return }
let ids = citedMemoryIDs
guard !ids.isEmpty else {
evidence = []
requestedEvidenceIDs = []
evidenceIsLoading = false
return
}
evidence = []
requestedEvidenceIDs = ids
evidenceIsLoading = true
let resolved = await evidenceProvider(ids)
guard !Task.isCancelled else { return }
evidence = resolved
evidenceIsLoading = false
}
private var mapColumn: some View {
VStack(spacing: 0) {
atlasToolbar
GeometryReader { proxy in
let plan = renderPlanCache.makePlan(
viewportSize: proxy.size,
zoom: zoom,
pan: pan,
compact: compact,
selectedNodeID: selectedNodeID,
matchingNodeIDs: matchingNodeIDs,
matchingEdges: matchingEdges,
asOf: asOfDate,
timeline: timeline,
timeCursor: timeCursor,
isCameraMoving: isCameraMoving
)
// One placement pass per frame, shared by the tint the canvas paints
// and the buttons laid over it, so the two cannot disagree about where
// a region's name is.
let (regions, quietened) = territory(in: proxy.size, plan: plan)
ZStack {
Color.black // The mat. See `.glassMediaMat` on `.clipped()` below.
atlasCanvas(size: proxy.size, plan: plan, regions: regions, quietened: quietened)
// Camera gestures belong to the painted atlas only. Keeping them
// off the enclosing ZStack prevents a click on zoom, playback, or
// the selection strip from also selecting a node behind the control.
.contentShape(Rectangle())
.gesture(panGesture)
.simultaneousGesture(magnificationGesture(in: proxy.size))
.simultaneousGesture(
SpatialTapGesture().onEnded { value in
selectAtlasElement(at: value.location, in: proxy.size, plan: plan)
}
)
if !isCameraMoving {
ForEach(plan.interactiveNodes) { placement in
nodeButton(
placement,
size: proxy.size,
relatedNodeIDs: plan.relatedNodeIDs,
showLabel: plan.labelNodeIDs.contains(placement.id)
&& !quietened.contains(placement.id),
labelAbove: plan.labelAboveNodeIDs.contains(placement.id)
)
}
// Above the entities: a region name that an entity's own label
// could cover would be the one label on the map with nothing
// underneath it to explain itself.
neighbourhoodCaptions(regions: regions)
}
if hasNoSearchMatches {
searchEmptyState
.allowsHitTesting(false)
}
zoomControls
.padding(compact ? 8 : 12)
.padding(.bottom, selectedNode == nil ? 0 : (compact ? 50 : 56))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
// Compact surfaces have no room for a side panel, so they keep the
// strip. Wide surfaces use the inspector instead.
if compact, let selectedNode {
selectionStrip(for: selectedNode)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
if !compact {
MemoryAtlasInputMonitor(
onScroll: { delta, location in
scrollZoom(by: delta, anchoredAt: location, in: proxy.size)
},
onFocusSearch: { searchIsFocused = true }
)
.accessibilityHidden(true)
}
}
.onAppear { viewportSize = proxy.size }
.onChange(of: proxy.size) { _, newSize in viewportSize = newSize }
// Zooming back out is leaving the place you were in, pressed or not. Without this the
// map keeps hiding every other coastline long after the user stopped looking at one,
// and the only way back is a control they have no reason to know about.
.onChange(of: zoom) { _, level in
guard enteredRegionID != nil, let departureZoom, level < departureZoom else { return }
leaveNeighbourhood()
}
// A neighbourhood id belongs to the snapshot that detected it: rebuild and the same
// ground can return under a different number, or not at all. Being inside a place that
// no longer exists is a mode with nothing on screen to explain it and no way out.
.onChange(of: snapshot.neighbourhoods.map(\.id)) { _, regions in
guard let entered = enteredRegionID, !regions.contains(entered) else { return }
leaveNeighbourhood()
}
// The one dark surface a content page may draw: the map is emissive (light nodes, haloes
// and labels, like a star chart) and vanished on the panel's near-white ground. The mat
// also flips the environment so `Ink` resolves *up* for the chrome laid over it.
.clipped().glassMediaMat()
}
VStack(spacing: 0) {
if !compact, timeline != nil {
timelineBar
} else if !compact {
// No meaningful timestamp spread — keep the legacy legend so the
// level indicator and type key stay available.
atlasLegend
}
}
}
.background(Color.clear)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("canonical_memory_atlas")
.onAppear(perform: maybeAutoplayTimeline)
.onDisappear { stopPlayback(userInitiated: false) }
.onReceive(NotificationCenter.default.publisher(for: .desktopAutomationMemoryAtlasViewportRequested)) {
notification in
let target = notification.userInfo?["target"] as? String ?? "page"
let isInlineTarget = target == "inline"
guard isInlineTarget == compact else { return }
if notification.userInfo?["reset"] as? Bool == true {
resetViewport()
clearSelection()
return
}
if let requestedZoom = notification.userInfo?["zoom"] as? Double {
updateZoom(CGFloat(requestedZoom))
memoryAtlasLogger.debug(
"Automation viewport target=\(target, privacy: .public) zoom=\(requestedZoom, privacy: .public)"
)
}
let requestedPanX = notification.userInfo?["pan_x"] as? Double
let requestedPanY = notification.userInfo?["pan_y"] as? Double
if requestedPanX != nil || requestedPanY != nil {
pan = CGSize(
width: CGFloat(requestedPanX ?? Double(pan.width)),
height: CGFloat(requestedPanY ?? Double(pan.height))
)
settledPan = pan
}
}
.onReceive(
NotificationCenter.default.publisher(for: .desktopAutomationMemoryAtlasRegionRequested)
) { notification in
let target = notification.userInfo?["target"] as? String ?? "page"
guard (target == "inline") == compact else { return }
if notification.userInfo?["leave"] as? Bool == true { return leaveNeighbourhood() }
guard let wanted = notification.userInfo?["caption"] as? String,
let match = snapshot.neighbourhoods.first(where: {
$0.caption.localizedCaseInsensitiveContains(wanted)
})
else { return }
// Its biggest island, which is the one a person would have pressed.
let biggest =
match.coastline
.enumerated()
.max { frame(of: $0.element)?.1 ?? 0 < frame(of: $1.element)?.1 ?? 0 }
enter(
MemoryAtlasNeighbourhoodLabels.Placed(
regionID: match.id, index: biggest?.offset ?? 0, caption: match.caption, rect: .zero,
ring: biggest?.element ?? []))
}
.onReceive(
NotificationCenter.default.publisher(for: .desktopAutomationMemoryAtlasSelectRequested)
) { notification in
let target = notification.userInfo?["target"] as? String ?? "page"
guard (target == "inline") == compact else { return }
if notification.userInfo?["clear"] as? Bool == true {
clearSelection()
return
}
// Drives the same state a canvas click would, so an automated check
// exercises the real inspector rather than a parallel preview path.
if let edgeID = notification.userInfo?["edge_id"] as? String,
let edge = snapshot.edges.first(where: { $0.id == edgeID }),
snapshot.nodeByID[edge.edge.sourceId] != nil
{
selectionTrail.removeAll()
adoptSelection(edge.edge.sourceId, edgeID: edgeID)
return
}
// By name as well as by id, because an entity's id is a server key
// nothing on screen shows. Selecting the entity a QA step is actually
// talking about otherwise means clicking a dot by pixel — which is not
// reachable from a headless check, and on a multi-display machine is not
// reliably reachable from a cursor either.
let named = (notification.userInfo?["label"] as? String).flatMap { wanted in
snapshot.nodes.first { $0.node.label.localizedCaseInsensitiveCompare(wanted) == .orderedSame }
?? snapshot.nodes.first { $0.node.label.localizedCaseInsensitiveContains(wanted) }
}
if let nodeID = (notification.userInfo?["node_id"] as? String).flatMap({
snapshot.nodeByID[$0] != nil ? $0 : nil
}) ?? named?.id {
selectionTrail.removeAll()
adoptSelection(nodeID)
}
}
.onReceive(NotificationCenter.default.publisher(for: .desktopAutomationMemoryAtlasTimeRequested)) {
notification in
let target = notification.userInfo?["target"] as? String ?? "page"
guard (target == "inline") == compact else { return }
if notification.userInfo?["reset"] as? Bool == true {
stopPlayback(userInitiated: true)
withAnimation(.easeOut(duration: 0.2)) { timeCursor = 1 }
return
}
if let fraction = notification.userInfo?["fraction"] as? Double {
stopPlayback(userInitiated: true)
timeCursor = min(max(fraction, 0), 1)
clearSelectionIfHiddenAtCurrentTime()
}
if let play = notification.userInfo?["play"] as? Bool {
if play {
startPlayback(resetToStart: notification.userInfo?["reset_to_start"] as? Bool ?? false)
} else {
stopPlayback(userInitiated: true)
}
}
memoryAtlasLogger.debug(
"Automation timeline target=\(target, privacy: .public) cursor=\(timeCursor, privacy: .public) playing=\(isTimePlaying, privacy: .public)"
)
}
}
private var atlasToolbar: some View {
HStack(spacing: 12) {
if showsSearchField {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.scaledFont(size: 12)
.foregroundColor(Ink.secondary)
TextField("Search your entities", text: searchBinding)
.textFieldStyle(.plain)
.focused($searchIsFocused)
.scaledFont(size: 12)
.foregroundColor(Ink.primary)
.onSubmit { selectFirstSearchResult() }
.accessibilityLabel("Search entities")
.accessibilityIdentifier("memory_atlas_search")
if !searchText.isEmpty {
Button {
searchBinding.wrappedValue = ""
} label: {
Image(systemName: "xmark.circle.fill")
.scaledFont(size: 11)
.foregroundColor(Ink.secondary)
}
.buttonStyle(.plain)
.help("Clear search (Esc)")
.accessibilityLabel("Clear search")
}
}
.padding(.horizontal, 12)
.frame(width: compact ? 250 : 320, height: 30)
.glassChip()
}
Spacer()
if !compact {
typeKey
}
if recentConnectionCount > 0 {
HStack(spacing: 6) {
Circle()
.fill(snapshot.activeClusters.first?.color ?? Ink.secondary)
.frame(width: 6, height: 6)
Text(recentConnectionLabel)
.scaledFont(size: 11, weight: .medium)
}
.foregroundColor(Ink.secondary)
}
// The legacy Brain Map carried a rebuild control; without it a thin or
// stale server graph has no recovery path from inside the atlas.
if let onRebuild {
Menu {
Button(action: onRebuild) {
Label(
isRebuilding ? "Rebuilding Brain Map…" : "Rebuild Brain Map…",
systemImage: "arrow.clockwise")
}
.disabled(isRebuilding)
} label: {
PageQueryActionLabel(icon: "ellipsis", title: "More")
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.help("More Brain Map actions")
.accessibilityLabel("More Brain Map actions")
.accessibilityIdentifier("memory_atlas_more_actions")
}
}
.padding(.horizontal, compact ? 12 : 18)
.frame(height: compact ? 40 : 44)
.background(Color.clear)
.accessibilityHint("Press Command-F to search. Press Return to select the first visible result.")
}
private var hasNoSearchMatches: Bool {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& matchingNodeIDs?.isEmpty == true
&& !snapshot.nodes.isEmpty
}
private var searchEmptyState: some View {
VStack(spacing: OmiSpacing.sm) {
Image(systemName: "magnifyingglass")
.scaledFont(size: OmiType.heading)
.foregroundStyle(Ink.surface)
Text(
"No entities match \u{201c}\(searchText.trimmingCharacters(in: .whitespacesAndNewlines))\u{201d}"
)
.scaledFont(size: OmiType.body, weight: .semibold)
.foregroundStyle(Ink.surface)
.multilineTextAlignment(.center)
Text("Try a different search or clear the search above.")
.scaledFont(size: OmiType.caption)
.foregroundStyle(Ink.surface.opacity(0.78))
.multilineTextAlignment(.center)
}
.padding(.horizontal, OmiSpacing.lg)
.accessibilityElement(children: .combine)
.accessibilityLabel(
"No entities match \(searchText.trimmingCharacters(in: .whitespacesAndNewlines))"
)
.accessibilityHint("Try a different search or clear the search above.")
}
/// Which colour means which kind of entity.
///
/// This used to be printed on the canvas at each type's centre. That made
/// sense when a type owned a region; now that entities are placed by what
/// they relate to, a type's mean position is often somewhere none of its
/// entities actually are — and on a real account the "Places" caption landed
/// on top of the Singapore node's own name. A key states the same thing
/// without claiming a location for it.
private var typeKey: some View {
HStack(spacing: 11) {
Text("Legend")
.scaledFont(size: 10, weight: .semibold)
.foregroundColor(Ink.primary)
ForEach(snapshot.activeClusters) { cluster in
HStack(spacing: 5) {
Circle().fill(cluster.color).frame(width: 5, height: 5)
Text(cluster.title)
.scaledFont(size: 10)
.foregroundColor(Ink.secondary)
}
}
}
// The key identifies the map's colors; it is intentionally not a filter. Naming that contract
// keeps the dots from presenting a false affordance to pointer-free users.
.accessibilityElement(children: .combine)
.accessibilityLabel("Brain Map legend")
.accessibilityValue(
snapshot.activeClusters.map { "\($0.title), color coded" }.joined(separator: "; ")
)
.accessibilityHint("Legend only; these items are not interactive filters.")
.accessibilityIdentifier("memory_atlas_type_key")
}
private func atlasCanvas(
size: CGSize, plan: MemoryAtlasRenderPlan,
regions: [MemoryAtlasNeighbourhoodLabels.Placed],
quietened: Set<String>
) -> some View {
Canvas(opaque: false, colorMode: .linear) { context, _ in
drawTerritories(context: &context, size: size, regions: regions)
drawEdges(context: &context, size: size, plan: plan)
drawNodes(context: &context, size: size, plan: plan)
drawCanvasLabels(context: &context, size: size, plan: plan, quietened: quietened)
}
.accessibilityHidden(true)
}
/// What the map draws as territory right now, and whose names it hides to do
/// it.
///
/// One function because the two answers depend on each other. An entity
/// standing on a named island loses its label to that island's caption, so
/// the caption must not be pushed off its own ground avoiding a name that is
/// about to disappear — which is what left most territories unnamed, and
/// therefore undrawn, when the two were computed separately.
private func territory(
in size: CGSize, plan: MemoryAtlasRenderPlan
) -> (islands: [MemoryAtlasNeighbourhoodLabels.Placed], quietened: Set<String>) {
// The replay and live camera gestures deliberately suppress SwiftUI
// labels/targets. Re-solving caption placement during those frames would
// still walk every visible entity against every coastline, despite none of
// those captions being shown. Keep the camera/replay path to Canvas-only
// work; territories return as soon as the frame settles.
guard !isCameraMoving, !compact, matchingNodeIDs == nil,
MemoryAtlasNeighbourhoodLabels.areVisible(
detailLevel: plan.detailLevel, hasSelection: selectedNodeID != nil,
isInsideNeighbourhood: enteredRegionID != nil)
else { return ([], []) }
let found = MemoryAtlasNeighbourhoodLabels.islands(
snapshot.neighbourhoods,
in: size,
project: { point(for: $0, in: size) },
focused: enteredRegionID)
let captions = Dictionary(lastWriteWins: snapshot.neighbourhoods.map { ($0.id, $0.caption) })
let budget = enteredRegionID == nil ? MemoryAtlasNeighbourhoodLabels.limit : Int.max
// The entity names on the canvas, as boxes to stay out of. They hang below
// their mark, and their width tracks the same estimate the canvas labeller
// uses.
func nameBoxes(hiding hidden: Set<String>) -> [CGRect] {
plan.visibleNodes
.filter { plan.labelNodeIDs.contains($0.id) && !hidden.contains($0.id) }
.map { placement in
let mark = point(for: placement.normalizedPosition, in: size)
let width = min(160, max(48, CGFloat(placement.node.label.count) * 6.4 + 16))
let above = plan.labelAboveNodeIDs.contains(placement.id)
return CGRect(
x: mark.x - width / 2, y: mark.y + (above ? -30 : 8), width: width, height: 20)
}
}
/// Entities standing on ground the map has named. Inside a place, its own
/// entities are the subject and they keep their names.
///
/// Every visible entity against every island's outline is a hundred
/// thousand edge crossings a frame, and this runs twice. The bounding box
/// settles almost all of it in four comparisons: a territory covers a small
/// part of the map, so nearly every entity is nowhere near nearly every
/// island.
func standingOn(_ islands: [MemoryAtlasNeighbourhoodLabels.Placed]) -> Set<String> {
guard enteredRegionID == nil else { return [] }
let bounded = islands.compactMap { island -> (CGRect, [CGPoint])? in
guard let first = island.ring.first else { return nil }
var minimum = first
var maximum = first
for vertex in island.ring {
minimum = CGPoint(x: min(minimum.x, vertex.x), y: min(minimum.y, vertex.y))
maximum = CGPoint(x: max(maximum.x, vertex.x), y: max(maximum.y, vertex.y))
}
return (
CGRect(
x: minimum.x, y: minimum.y, width: maximum.x - minimum.x,
height: maximum.y - minimum.y), island.ring
)
}
var hidden: Set<String> = []
for placement in plan.visibleNodes
where placement.id != snapshot.anchorNodeID && placement.id != selectedNodeID {
let position = placement.normalizedPosition
if bounded.contains(where: {
$0.0.contains(position) && memoryAtlasCoastlineContains([$0.1], position)
}) {
hidden.insert(placement.id)
}
}
return hidden
}
// Placed twice, because the two answers define each other: which names to
// hide depends on which islands are drawn, and which islands can be drawn
// depends on which names are in the way. The first pass finds the islands
// by dodging every name; the second re-places them now that the names
// standing on them are gone.
//
// One pass either way is wrong, and both ways were tried. Dodging every
// name pushes captions off their own island for labels that are about to
// disappear. Dodging none of them puts a caption on top of an entity that
// then keeps its label, which is how "X (TWITTER)" ended up printed across
// "Ho Chi Minh City".
let candidates = MemoryAtlasNeighbourhoodLabels.place(
found, captions: captions, in: size, avoiding: nameBoxes(hiding: []), limit: budget)
let placed = MemoryAtlasNeighbourhoodLabels.place(