Implement map legend overlay (#1653)

* Add map legend feature (issue #924)

Implement a map legend overlay accessible from both the mesh map and node
detail map views. The legend explains all visual map elements including:
- Online/offline node markers with pulsing animation
- Detection sensor nodes
- Waypoints
- Position precision circles
- Position history points and heading arrows
- Route start/end markers and route lines
- Convex hull mesh coverage outline

A new "map" button is added to the floating control buttons on both map
views, opening the legend as a sheet.

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/23f75e1e-549b-46a1-84c9-fb0a6375dcd9

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Improve legend descriptions for online/offline nodes

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/23f75e1e-549b-46a1-84c9-fb0a6375dcd9

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* map button glass and cleanup

* Update Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update incorect online timeframe

* Update Meshtastic/Views/Nodes/MeshMap.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* translation file

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
Co-authored-by: Garth Vander Houwen <garth@meshtastic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot 2026-04-05 14:40:54 -07:00 committed by GitHub
parent 21794d004b
commit bac376edcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 446 additions and 19 deletions

View file

@ -3118,6 +3118,13 @@
}
}
}
},
"A previous position report for this node." : {
"comment" : "A description of a position history point.",
"isCommentAutoGenerated" : true
},
"A previous position report showing the direction of travel." : {
},
"A red open lock means the channel is not securely encrypted and is used for precise location data, it uses either no key at all or a 1 byte known key." : {
"localizations" : {
@ -3163,6 +3170,10 @@
}
}
},
"A shared point of interest. Long-press the map to create one." : {
"comment" : "A description of a waypoint.",
"isCommentAutoGenerated" : true
},
"A Trace Route was sent, no response has been received." : {
"localizations" : {
"da" : {
@ -6009,6 +6020,10 @@
}
}
},
"An outline enclosing all LoRa node positions on the mesh." : {
"comment" : "A description of the convex hull of a mesh.",
"isCommentAutoGenerated" : true
},
"Any missed messages will be delivered again." : {
"localizations" : {
"da" : {
@ -14154,6 +14169,10 @@
}
}
},
"Dashed line showing a recorded route path." : {
"comment" : "A description of a dashed line that shows a recorded route path.",
"isCommentAutoGenerated" : true
},
"Date" : {
"localizations" : {
"da" : {
@ -25286,6 +25305,16 @@
}
}
}
},
"Hide legend" : {
"comment" : "A label for a button that hides the map legend.",
"isCommentAutoGenerated" : true
},
"Hide map legend" : {
},
"Hides the map legend." : {
},
"HIGH" : {
"localizations" : {
@ -27578,6 +27607,10 @@
}
}
},
"Indicates reduced GPS precision. The node is somewhere within the shaded area." : {
"comment" : "A description of a position precision circle.",
"isCommentAutoGenerated" : true
},
"Indoor Air Quality" : {
"extractionState" : "stale",
"localizations" : {
@ -30697,6 +30730,10 @@
}
}
},
"Map Legend" : {
"comment" : "A title for a view that displays a map legend.",
"isCommentAutoGenerated" : true
},
"Map Options" : {
"localizations" : {
"da" : {
@ -31159,6 +31196,10 @@
}
}
},
"Mesh Coverage" : {
"comment" : "A heading for the convex hull of the mesh.",
"isCommentAutoGenerated" : true
},
"Mesh Live Activity" : {
"localizations" : {
"da" : {
@ -35253,6 +35294,10 @@
}
}
},
"Node heard within the last 2 hours. Shown with a pulsing ring on the map." : {
"comment" : "A description of the pulsing ring on the map.",
"isCommentAutoGenerated" : true
},
"Node History" : {
"localizations" : {
"da" : {
@ -35404,6 +35449,10 @@
}
}
},
"Node not heard recently. Shown without a pulsing ring on the map." : {
"comment" : "A description of a node that is not heard by the user.",
"isCommentAutoGenerated" : true
},
"Node Number" : {
"localizations" : {
"da" : {
@ -35456,6 +35505,10 @@
}
}
},
"Node with an active detection sensor module." : {
"comment" : "A description of a sensor node.",
"isCommentAutoGenerated" : true
},
"Nodes" : {
"localizations" : {
"da" : {
@ -36190,6 +36243,10 @@
}
}
},
"Offline Node" : {
"comment" : "A description of an offline node.",
"isCommentAutoGenerated" : true
},
"Ok" : {
"localizations" : {
"es" : {
@ -36808,6 +36865,10 @@
}
}
},
"Online Node" : {
"comment" : "A label displayed in the map legend for an online node.",
"isCommentAutoGenerated" : true
},
"Only permitted for SENSOR, TRACKER and TAK_TRACKER roles, this will inhibit all rebroadcasts, not unlike CLIENT_MUTE role." : {
"extractionState" : "stale",
"localizations" : {
@ -39546,6 +39607,14 @@
}
}
},
"Position History" : {
"comment" : "A heading for the position history of a node.",
"isCommentAutoGenerated" : true
},
"Position History Point" : {
"comment" : "A label displayed in the map legend for a position history point.",
"isCommentAutoGenerated" : true
},
"Position Log" : {
"localizations" : {
"da" : {
@ -39684,6 +39753,13 @@
}
}
},
"Position Precision" : {
},
"Position Precision Circle" : {
"comment" : "A description of a map element that indicates reduced GPS precision.",
"isCommentAutoGenerated" : true
},
"Position Sent" : {
"localizations" : {
"da" : {
@ -39736,6 +39812,10 @@
}
}
},
"Position with Heading" : {
"comment" : "A description of a position history point that shows the direction of travel.",
"isCommentAutoGenerated" : true
},
"Positions Enabled" : {
"localizations" : {
"da" : {
@ -44715,6 +44795,14 @@
}
}
},
"Route End" : {
"comment" : "A label for a route end point on a map.",
"isCommentAutoGenerated" : true
},
"Route Line" : {
"comment" : "A description of a route line on a map.",
"isCommentAutoGenerated" : true
},
"Route Lines" : {
"localizations" : {
"da" : {
@ -44893,6 +44981,10 @@
}
}
},
"Route Start" : {
"comment" : "A label displayed for the start of a route.",
"isCommentAutoGenerated" : true
},
"Route: %@" : {
"localizations" : {
"da" : {
@ -50389,6 +50481,13 @@
}
}
}
},
"Show legend" : {
"comment" : "A label for a button that shows/hides the map legend.",
"isCommentAutoGenerated" : true
},
"Show map legend" : {
},
"Show nodes" : {
"localizations" : {
@ -50608,6 +50707,9 @@
}
}
}
},
"Shows the map legend." : {
},
"Shut Down" : {
"localizations" : {
@ -56164,6 +56266,10 @@
}
}
},
"Toggles the map legend" : {
"comment" : "A hint for the user to toggle the map legend.",
"isCommentAutoGenerated" : true
},
"Topic: %@" : {
"extractionState" : "stale",
"localizations" : {
@ -60865,6 +60971,10 @@
}
}
},
"Waypoint" : {
"comment" : "A caption displayed underneath the name of a waypoint.",
"isCommentAutoGenerated" : true
},
"Waypoint Failed to Send" : {
"localizations" : {
"es" : {

View file

@ -216,6 +216,7 @@
DD93800B2BA3F968008BEC06 /* NodeMapContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD93800A2BA3F968008BEC06 /* NodeMapContent.swift */; };
DD93800E2BA74D0C008BEC06 /* ChannelForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD93800D2BA74D0C008BEC06 /* ChannelForm.swift */; };
DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD94B73F2ACCE3BE00DCD1D1 /* MapSettingsForm.swift */; };
DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09240002E7FAD600E70001 /* MapLegend.swift */; };
DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */; };
DD964FC62975DBFD007C176F /* QueryCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC52975DBFD007C176F /* QueryCoreData.swift */; };
DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97E96528EFD9820056DDA4 /* MeshtasticLogo.swift */; };
@ -579,6 +580,7 @@
DD93800A2BA3F968008BEC06 /* NodeMapContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeMapContent.swift; sourceTree = "<group>"; };
DD93800D2BA74D0C008BEC06 /* ChannelForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelForm.swift; sourceTree = "<group>"; };
DD94B73F2ACCE3BE00DCD1D1 /* MapSettingsForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapSettingsForm.swift; sourceTree = "<group>"; };
DD09240002E7FAD600E70001 /* MapLegend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLegend.swift; sourceTree = "<group>"; };
DD964FC029724F6D007C176F /* MeshtasticDataModelV6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV6.xcdatamodel; sourceTree = "<group>"; };
DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaypointEntityExtension.swift; sourceTree = "<group>"; };
DD964FC52975DBFD007C176F /* QueryCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryCoreData.swift; sourceTree = "<group>"; };
@ -1181,6 +1183,7 @@
children = (
DDDC22362BA9232C002C44F1 /* MapContent */,
3D3417D32E2DC293006A988B /* MapDataFiles.swift */,
DD09240002E7FAD600E70001 /* MapLegend.swift */,
DD94B73F2ACCE3BE00DCD1D1 /* MapSettingsForm.swift */,
DDB6CCFA2AAF805100945AF6 /* NodeMapSwiftUI.swift */,
DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */,
@ -1804,6 +1807,7 @@
DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */,
DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */,
DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */,
DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */,
DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */,
23AD546B2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift in Sources */,
23F488122E32980B002C776F /* AccessoryManager+Position.swift in Sources */,

View file

@ -46,6 +46,18 @@ extension View {
self
}
}
@ViewBuilder
func glassButtonStyle() -> some View {
if #available(iOS 26.0, macOS 26.0, *) {
self.buttonStyle(.glass)
} else {
self
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
}
}
}
private struct FirstAppear: ViewModifier {

View file

@ -0,0 +1,277 @@
//
// MapLegend.swift
// Meshtastic
//
// Implements a map legend overlay that explains the visual elements
// displayed on the map (issue #924).
//
import SwiftUI
struct MapLegendItem: View {
let symbol: AnyView
let title: String
let subtitle: String?
init(symbol: AnyView, title: String, subtitle: String? = nil) {
self.symbol = symbol
self.title = title
self.subtitle = subtitle
}
var body: some View {
HStack(spacing: 12) {
symbol
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
}
}
struct MapLegend: View {
@Environment(\.dismiss) private var dismiss
let isMeshMap: Bool
var body: some View {
NavigationStack {
List {
nodeSection
if isMeshMap {
waypointSection
}
precisionSection
if !isMeshMap {
historySection
}
routeSection
if isMeshMap {
convexHullSection
}
}
.navigationTitle("Map Legend")
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Sections
private var nodeSection: some View {
Section {
MapLegendItem(
symbol: AnyView(onlineNodeSymbol),
title: String(localized: "Online Node"),
subtitle: String(localized: "Node heard within the last 2 hours. Shown with a pulsing ring on the map.")
)
MapLegendItem(
symbol: AnyView(offlineNodeSymbol),
title: String(localized: "Offline Node"),
subtitle: String(localized: "Node not heard recently. Shown without a pulsing ring on the map.")
)
MapLegendItem(
symbol: AnyView(sensorNodeSymbol),
title: String(localized: "Detection Sensor"),
subtitle: String(localized: "Node with an active detection sensor module.")
)
} header: {
Text("Nodes")
}
}
private var waypointSection: some View {
Section {
MapLegendItem(
symbol: AnyView(waypointSymbol),
title: String(localized: "Waypoint"),
subtitle: String(localized: "A shared point of interest. Long-press the map to create one.")
)
} header: {
Text("Waypoints")
}
}
private var precisionSection: some View {
Section {
MapLegendItem(
symbol: AnyView(precisionCircleSymbol),
title: String(localized: "Position Precision Circle"),
subtitle: String(localized: "Indicates reduced GPS precision. The node is somewhere within the shaded area.")
)
} header: {
Text("Position Precision")
}
}
private var historySection: some View {
Section {
MapLegendItem(
symbol: AnyView(historyPointSymbol),
title: String(localized: "Position History Point"),
subtitle: String(localized: "A previous position report for this node.")
)
MapLegendItem(
symbol: AnyView(historyArrowSymbol),
title: String(localized: "Position with Heading"),
subtitle: String(localized: "A previous position report showing the direction of travel.")
)
} header: {
Text("Position History")
}
}
private var routeSection: some View {
Section {
MapLegendItem(
symbol: AnyView(routeStartSymbol),
title: String(localized: "Route Start"),
subtitle: nil
)
MapLegendItem(
symbol: AnyView(routeEndSymbol),
title: String(localized: "Route End"),
subtitle: nil
)
MapLegendItem(
symbol: AnyView(routeLineSymbol),
title: String(localized: "Route Line"),
subtitle: String(localized: "Dashed line showing a recorded route path.")
)
} header: {
Text("Routes")
}
}
private var convexHullSection: some View {
Section {
MapLegendItem(
symbol: AnyView(convexHullSymbol),
title: String(localized: "Convex Hull"),
subtitle: String(localized: "An outline enclosing all LoRa node positions on the mesh.")
)
} header: {
Text("Mesh Coverage")
}
}
// MARK: - Symbols
private var onlineNodeSymbol: some View {
ZStack {
Circle()
.fill(Color.green.opacity(0.3))
.frame(width: 38, height: 38)
CircleText(text: "ON", color: .green, circleSize: 28)
}
}
private var offlineNodeSymbol: some View {
CircleText(text: "OFF", color: .gray, circleSize: 28)
}
private var sensorNodeSymbol: some View {
ZStack {
Circle()
.fill(Color.blue)
.frame(width: 28, height: 28)
Image(systemName: "sensor.fill")
.font(.system(size: 14))
.foregroundStyle(.white)
}
}
private var waypointSymbol: some View {
CircleText(text: "📍", color: .orange, circleSize: 28)
}
private var precisionCircleSymbol: some View {
ZStack {
Circle()
.fill(Color.blue.opacity(0.25))
.frame(width: 36, height: 36)
Circle()
.strokeBorder(Color.white, lineWidth: 1)
.frame(width: 36, height: 36)
Circle()
.fill(Color.blue)
.frame(width: 8, height: 8)
}
}
private var historyPointSymbol: some View {
ZStack {
Circle()
.fill(Color.blue)
.frame(width: 12, height: 12)
Circle()
.stroke(Color.primary, lineWidth: 1)
.frame(width: 12, height: 12)
}
}
private var historyArrowSymbol: some View {
Image(systemName: "location.north.circle.fill")
.font(.system(size: 20))
.foregroundStyle(.blue)
}
private var routeStartSymbol: some View {
Circle()
.fill(Color.green)
.strokeBorder(Color.white, lineWidth: 2)
.frame(width: 15, height: 15)
}
private var routeEndSymbol: some View {
Circle()
.fill(Color.black)
.strokeBorder(Color.white, lineWidth: 2)
.frame(width: 15, height: 15)
}
private var routeLineSymbol: some View {
ZStack {
Path { path in
path.move(to: CGPoint(x: 4, y: 20))
path.addLine(to: CGPoint(x: 36, y: 20))
}
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [6, 6]))
.foregroundStyle(Color.blue)
}
}
private var convexHullSymbol: some View {
ZStack {
// Draw a simplified polygon shape
ConvexHullShape()
.fill(Color.indigo.opacity(0.4))
ConvexHullShape()
.stroke(Color.blue, lineWidth: 2)
}
.frame(width: 32, height: 32)
}
}
private struct ConvexHullShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let w = rect.width
let h = rect.height
path.move(to: CGPoint(x: w * 0.5, y: h * 0.1))
path.addLine(to: CGPoint(x: w * 0.85, y: h * 0.3))
path.addLine(to: CGPoint(x: w * 0.9, y: h * 0.7))
path.addLine(to: CGPoint(x: w * 0.6, y: h * 0.9))
path.addLine(to: CGPoint(x: w * 0.15, y: h * 0.8))
path.addLine(to: CGPoint(x: w * 0.1, y: h * 0.35))
path.closeSubpath()
return path
}
}

View file

@ -52,6 +52,7 @@ struct NodeMapSwiftUI: View {
@State var isLookingAround = false
@State var isShowingAltitude = false
@State var isEditingSettings = false
@State var isShowingLegend = false
@State var isMeshMap = false
@State var enabledOverlayConfigs: Set<UUID> = Set()
@ -97,6 +98,13 @@ struct NodeMapSwiftUI: View {
.sheet(isPresented: $isEditingSettings) {
MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs)
}
.sheet(isPresented: $isShowingLegend) {
MapLegend(isMeshMap: false)
.presentationDetents([.medium, .large])
.presentationContentInteraction(.scrolls)
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
}
.onChange(of: selectedMapLayer) { _, newMapLayer in
updateMapStyle(for: newMapLayer)
}
@ -168,17 +176,25 @@ struct NodeMapSwiftUI: View {
private var controlButtons: some View {
HStack {
Button(action: {
withAnimation {
isShowingLegend = !isShowingLegend
}
}) {
Image(systemName: isShowingLegend ? "map.fill" : "map")
}
.accessibilityLabel(isShowingLegend ? Text("Hide legend") : Text("Show legend"))
.accessibilityHint(Text("Toggles the map legend"))
.glassButtonStyle()
Button(action: {
withAnimation {
isEditingSettings = !isEditingSettings
}
}) {
Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
.glassButtonStyle()
if scene != nil {
Button(action: {
@ -188,11 +204,8 @@ struct NodeMapSwiftUI: View {
isLookingAround = !isLookingAround
}) {
Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
.glassButtonStyle()
}
if node.positions?.count ?? 0 > 1 {
@ -203,11 +216,8 @@ struct NodeMapSwiftUI: View {
isShowingAltitude = !isShowingAltitude
}) {
Image(systemName: isShowingAltitude ? "mountain.2.fill" : "mountain.2")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
.glassButtonStyle()
}
}
.controlSize(.regular)

View file

@ -41,6 +41,7 @@ struct MeshMap: View {
@State var selectedWaypointId: String?
@State var newWaypointCoord: CLLocationCoordinate2D?
@State var isMeshMap = true
@State private var showLegend = false
/// Filter
@StateObject var filters = NodeFilterParameters()
@ -158,20 +159,34 @@ struct MeshMap: View {
filters: filters
)
}
.sheet(isPresented: $showLegend) {
MapLegend(isMeshMap: true)
.presentationDetents([.medium, .large])
.presentationContentInteraction(.scrolls)
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
}
.safeAreaInset(edge: .bottom, alignment: .trailing) {
HStack {
Spacer()
Button(action: {
withAnimation {
showLegend = !showLegend
}
}) {
Image(systemName: showLegend ? "map.fill" : "map")
}
.accessibilityLabel(showLegend ? "Hide map legend" : "Show map legend")
.accessibilityHint(showLegend ? "Hides the map legend." : "Shows the map legend.")
.glassButtonStyle()
Button(action: {
withAnimation {
editingSettings = !editingSettings
}
}) {
Image(systemName: editingSettings ? "info.circle.fill" : "info.circle")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
.glassButtonStyle()
}
.controlSize(.regular)
.padding(5)

View file

@ -74,8 +74,7 @@ struct RouteRecorder: View {
.symbolRenderingMode(.multicolor)
.foregroundColor(.red)
}
.buttonStyle(.bordered)
.foregroundColor(.red)
.glassButtonStyle()
.buttonBorderShape(.circle)
.matchedGeometryEffect(id: "Details Button", in: namespace)

View file

@ -281,7 +281,7 @@ struct Routes: View {
} label: {
Label("Export", systemImage: "square.and.arrow.down")
}
.buttonStyle(.bordered)
.glassButtonStyle()
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding(.bottom)