Showing 2 changed files with 1091 additions and 215 deletions
+177 -189
USB Meter.xcodeproj/project.pbxproj
@@ -30,23 +30,6 @@
30 30
 		4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B464240EB6B200DAAEBF /* UserDefault.swift */; };
31 31
 		4383B468240F845500DAAEBF /* MacAdress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B467240F845500DAAEBF /* MacAdress.swift */; };
32 32
 		4383B46A240FE4A600DAAEBF /* MeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B469240FE4A600DAAEBF /* MeterView.swift */; };
33
-		D28F11013C8E4A7A00A10011 /* MeterHomeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */; };
34
-		D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */; };
35
-		D28F11053C8E4A7A00A10015 /* MeterChartTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */; };
36
-		D28F11073C8E4A7A00A10017 /* MeterSettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */; };
37
-		D28F11113C8E4A7A00A10021 /* MeterInfoCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */; };
38
-		D28F11133C8E4A7A00A10023 /* MeterInfoRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */; };
39
-		D28F11153C8E4A7A00A10025 /* MeterNameEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */; };
40
-		D28F11173C8E4A7A00A10027 /* ScreenTimeoutEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */; };
41
-		D28F11193C8E4A7A00A10029 /* ScreenBrightnessEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */; };
42
-		D28F11213C8E4A7A00A10031 /* MeterLiveMetricRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */; };
43
-		D28F11233C8E4A7A00A10033 /* LoadResistanceIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */; };
44
-		D28F11313C8E4A7A00A10041 /* MeterScreenControlButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */; };
45
-		D28F11333C8E4A7A00A10043 /* MeterCurrentScreenSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */; };
46
-		D28F11353C8E4A7A00A10045 /* ChargeRecordMetricsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */; };
47
-		D28F11393C8E4A7A00A10049 /* MeterConnectionStatusBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */; };
48
-		D28F113B3C8E4A7A00A1004B /* MeterConnectionActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */; };
49
-		D28F113D3C8E4A7A00A1004D /* MeterOverviewSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */; };
50 33
 		438695892463F062008855A9 /* Measurements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438695882463F062008855A9 /* Measurements.swift */; };
51 34
 		4386958B2F6A1001008855A9 /* UMProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4386958A2F6A1001008855A9 /* UMProtocol.swift */; };
52 35
 		4386958D2F6A1002008855A9 /* TC66Protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4386958C2F6A1002008855A9 /* TC66Protocol.swift */; };
@@ -68,6 +51,23 @@
68 51
 		43F7792B2465AE1600745DF4 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F7792A2465AE1600745DF4 /* UIView.swift */; };
69 52
 		AAD5F9A72B1CAC0700F8E4F9 /* MeterDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD5F9A32B1CAC0700F8E4F9 /* MeterDetailView.swift */; };
70 53
 		AAD5F9B12B1CAC7A00F8E4F9 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */; };
54
+		D28F11013C8E4A7A00A10011 /* MeterHomeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */; };
55
+		D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */; };
56
+		D28F11053C8E4A7A00A10015 /* MeterChartTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */; };
57
+		D28F11073C8E4A7A00A10017 /* MeterSettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */; };
58
+		D28F11113C8E4A7A00A10021 /* MeterInfoCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */; };
59
+		D28F11133C8E4A7A00A10023 /* MeterInfoRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */; };
60
+		D28F11153C8E4A7A00A10025 /* MeterNameEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */; };
61
+		D28F11173C8E4A7A00A10027 /* ScreenTimeoutEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */; };
62
+		D28F11193C8E4A7A00A10029 /* ScreenBrightnessEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */; };
63
+		D28F11213C8E4A7A00A10031 /* MeterLiveMetricRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */; };
64
+		D28F11233C8E4A7A00A10033 /* LoadResistanceIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */; };
65
+		D28F11313C8E4A7A00A10041 /* MeterScreenControlButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */; };
66
+		D28F11333C8E4A7A00A10043 /* MeterCurrentScreenSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */; };
67
+		D28F11353C8E4A7A00A10045 /* ChargeRecordMetricsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */; };
68
+		D28F11393C8E4A7A00A10049 /* MeterConnectionStatusBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */; };
69
+		D28F113B3C8E4A7A00A1004B /* MeterConnectionActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */; };
70
+		D28F113D3C8E4A7A00A1004D /* MeterOverviewSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */; };
71 71
 		E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */; };
72 72
 /* End PBXBuildFile section */
73 73
 
@@ -124,23 +124,6 @@
124 124
 		4383B464240EB6B200DAAEBF /* UserDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefault.swift; sourceTree = "<group>"; };
125 125
 		4383B467240F845500DAAEBF /* MacAdress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacAdress.swift; sourceTree = "<group>"; };
126 126
 		4383B469240FE4A600DAAEBF /* MeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterView.swift; sourceTree = "<group>"; };
127
-		D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterHomeTabView.swift; sourceTree = "<group>"; };
128
-		D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveTabView.swift; sourceTree = "<group>"; };
129
-		D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterChartTabView.swift; sourceTree = "<group>"; };
130
-		D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterSettingsTabView.swift; sourceTree = "<group>"; };
131
-		D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterInfoCardView.swift; sourceTree = "<group>"; };
132
-		D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterInfoRowView.swift; sourceTree = "<group>"; };
133
-		D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterNameEditorView.swift; sourceTree = "<group>"; };
134
-		D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTimeoutEditorView.swift; sourceTree = "<group>"; };
135
-		D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenBrightnessEditorView.swift; sourceTree = "<group>"; };
136
-		D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveMetricRange.swift; sourceTree = "<group>"; };
137
-		D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadResistanceIconView.swift; sourceTree = "<group>"; };
138
-		D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterScreenControlButtonView.swift; sourceTree = "<group>"; };
139
-		D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterCurrentScreenSummaryView.swift; sourceTree = "<group>"; };
140
-		D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeRecordMetricsTableView.swift; sourceTree = "<group>"; };
141
-		D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterConnectionStatusBadgeView.swift; sourceTree = "<group>"; };
142
-		D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterConnectionActionView.swift; sourceTree = "<group>"; };
143
-		D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterOverviewSectionView.swift; sourceTree = "<group>"; };
144 127
 		438695882463F062008855A9 /* Measurements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Measurements.swift; sourceTree = "<group>"; };
145 128
 		4386958A2F6A1001008855A9 /* UMProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UMProtocol.swift; sourceTree = "<group>"; };
146 129
 		4386958C2F6A1002008855A9 /* TC66Protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TC66Protocol.swift; sourceTree = "<group>"; };
@@ -167,6 +150,23 @@
167 150
 		7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterNameStore.swift; sourceTree = "<group>"; };
168 151
 		AAD5F9A32B1CAC0700F8E4F9 /* MeterDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterDetailView.swift; sourceTree = "<group>"; };
169 152
 		AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
153
+		D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterHomeTabView.swift; sourceTree = "<group>"; };
154
+		D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveTabView.swift; sourceTree = "<group>"; };
155
+		D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterChartTabView.swift; sourceTree = "<group>"; };
156
+		D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterSettingsTabView.swift; sourceTree = "<group>"; };
157
+		D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterInfoCardView.swift; sourceTree = "<group>"; };
158
+		D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterInfoRowView.swift; sourceTree = "<group>"; };
159
+		D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterNameEditorView.swift; sourceTree = "<group>"; };
160
+		D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTimeoutEditorView.swift; sourceTree = "<group>"; };
161
+		D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenBrightnessEditorView.swift; sourceTree = "<group>"; };
162
+		D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveMetricRange.swift; sourceTree = "<group>"; };
163
+		D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadResistanceIconView.swift; sourceTree = "<group>"; };
164
+		D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterScreenControlButtonView.swift; sourceTree = "<group>"; };
165
+		D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterCurrentScreenSummaryView.swift; sourceTree = "<group>"; };
166
+		D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeRecordMetricsTableView.swift; sourceTree = "<group>"; };
167
+		D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterConnectionStatusBadgeView.swift; sourceTree = "<group>"; };
168
+		D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterConnectionActionView.swift; sourceTree = "<group>"; };
169
+		D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterOverviewSectionView.swift; sourceTree = "<group>"; };
170 170
 /* End PBXFileReference section */
171 171
 
172 172
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -312,136 +312,152 @@
312 312
 			path = MeasurementSeries;
313 313
 			sourceTree = "<group>";
314 314
 		};
315
-		D28F11253C8E4A7A00A10035 /* Subviews */ = {
316
-			isa = PBXGroup;
317
-			children = (
318
-				437D47D02415F91B00B7768E /* MeterLiveContentView.swift */,
319
-				D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */,
320
-				D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */,
321
-			);
322
-			path = Subviews;
323
-			sourceTree = "<group>";
324
-		};
325
-		D28F11263C8E4A7A00A10036 /* Subviews */ = {
315
+		437D47CF2415F8CF00B7768E /* Meter */ = {
326 316
 			isa = PBXGroup;
327 317
 			children = (
328
-				4308CF8524176CAB0002E80B /* DataGroupRowView.swift */,
318
+				4383B469240FE4A600DAAEBF /* MeterView.swift */,
319
+				D28F113F3C8E4A7A00A1004F /* Components */,
320
+				D28F11093C8E4A7A00A10019 /* Tabs */,
321
+				D28F10013C8E4A7A00A10001 /* Sheets */,
329 322
 			);
330
-			path = Subviews;
323
+			path = Meter;
331 324
 			sourceTree = "<group>";
332 325
 		};
333
-		D28F11373C8E4A7A00A10047 /* Components */ = {
326
+		4383B463240EB66400DAAEBF /* Templates */ = {
334 327
 			isa = PBXGroup;
335 328
 			children = (
336
-				D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */,
337
-				D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */,
329
+				4383B464240EB6B200DAAEBF /* UserDefault.swift */,
338 330
 			);
339
-			path = Components;
331
+			path = Templates;
340 332
 			sourceTree = "<group>";
341 333
 		};
342
-		D28F11383C8E4A7A00A10048 /* Subviews */ = {
334
+		4383B466240F842700DAAEBF /* DataTypes */ = {
343 335
 			isa = PBXGroup;
344 336
 			children = (
345
-				D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */,
337
+				4383B467240F845500DAAEBF /* MacAdress.swift */,
346 338
 			);
347
-			path = Subviews;
339
+			path = DataTypes;
348 340
 			sourceTree = "<group>";
349 341
 		};
350
-		D28F11273C8E4A7A00A10037 /* ChargeRecord */ = {
342
+		43CBF653240BF3EB00255B8B = {
351 343
 			isa = PBXGroup;
352 344
 			children = (
353
-				437D47D42415FD8C00B7768E /* ChargeRecordSheetView.swift */,
354
-				D28F11383C8E4A7A00A10048 /* Subviews */,
345
+				1C6B6B952A2D4F5100A0B001 /* Documentation */,
346
+				43CBF65E240BF3EB00255B8B /* USB Meter */,
347
+				43CBF65D240BF3EB00255B8B /* Products */,
348
+				4347F01B28D717C1007EE7B1 /* Frameworks */,
355 349
 			);
356
-			path = ChargeRecord;
357 350
 			sourceTree = "<group>";
358 351
 		};
359
-		D28F11283C8E4A7A00A10038 /* Control */ = {
352
+		43CBF65D240BF3EB00255B8B /* Products */ = {
360 353
 			isa = PBXGroup;
361 354
 			children = (
362
-				437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */,
363
-				D28F11373C8E4A7A00A10047 /* Components */,
355
+				43CBF65C240BF3EB00255B8B /* USB Meter.app */,
364 356
 			);
365
-			path = Control;
357
+			name = Products;
366 358
 			sourceTree = "<group>";
367 359
 		};
368
-		D28F10013C8E4A7A00A10001 /* Sheets */ = {
360
+		43CBF65E240BF3EB00255B8B /* USB Meter */ = {
369 361
 			isa = PBXGroup;
370 362
 			children = (
371
-				4308CF89241777130002E80B /* DataGroups */,
372
-				43554B3024444983004E66F5 /* MeasurementSeries */,
373
-				D28F11273C8E4A7A00A10037 /* ChargeRecord */,
363
+				43CBF67A240C0D8A00255B8B /* USB Meter.entitlements */,
364
+				43CBF65F240BF3EB00255B8B /* AppDelegate.swift */,
365
+				43CBF661240BF3EB00255B8B /* SceneDelegate.swift */,
366
+				43CBF678240C047D00255B8B /* Model */,
367
+				43CBF679240C08C600255B8B /* Views */,
368
+				43CBF67F240D14AC00255B8B /* Extensions */,
369
+				4383B463240EB66400DAAEBF /* Templates */,
370
+				4383B466240F842700DAAEBF /* DataTypes */,
371
+				43CBF668240BF3ED00255B8B /* Assets.xcassets */,
372
+				43CBF66D240BF3ED00255B8B /* LaunchScreen.storyboard */,
373
+				43CBF670240BF3ED00255B8B /* Info.plist */,
374
+				43CBF66A240BF3ED00255B8B /* Preview Content */,
374 375
 			);
375
-			path = Sheets;
376
+			path = "USB Meter";
376 377
 			sourceTree = "<group>";
377 378
 		};
378
-		D28F111B3C8E4A7A00A1002B /* Subviews */ = {
379
+		43CBF66A240BF3ED00255B8B /* Preview Content */ = {
379 380
 			isa = PBXGroup;
380 381
 			children = (
381
-				D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */,
382
-				D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */,
383
-				D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */,
382
+				43CBF66B240BF3ED00255B8B /* Preview Assets.xcassets */,
384 383
 			);
385
-			path = Subviews;
384
+			path = "Preview Content";
386 385
 			sourceTree = "<group>";
387 386
 		};
388
-		D28F111C3C8E4A7A00A1002C /* Subviews */ = {
387
+		43CBF678240C047D00255B8B /* Model */ = {
389 388
 			isa = PBXGroup;
390 389
 			children = (
391
-				D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */,
392
-				D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */,
393
-				D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */,
394
-				437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */,
395
-				D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */,
396
-				D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */,
390
+				4383B461240EB5E400DAAEBF /* AppData.swift */,
391
+				7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */,
392
+				43CBF676240C043E00255B8B /* BluetoothManager.swift */,
393
+				4383B45F240EB2D000DAAEBF /* Meter.swift */,
394
+				43ED78AD2420A0BE00974487 /* BluetoothSerial.swift */,
395
+				439D996424234B98008DE3AA /* BluetoothRadio.swift */,
396
+				4386958E2F6A4E3E008855A9 /* MeterCapabilities.swift */,
397
+				4386958A2F6A1001008855A9 /* UMProtocol.swift */,
398
+				4386958C2F6A1002008855A9 /* TC66Protocol.swift */,
399
+				438695882463F062008855A9 /* Measurements.swift */,
400
+				432EA6432445A559006FC905 /* ChartContext.swift */,
397 401
 			);
398
-			path = Subviews;
402
+			path = Model;
399 403
 			sourceTree = "<group>";
400 404
 		};
401
-		D28F110A3C8E4A7A00A1001A /* Home */ = {
405
+		43CBF679240C08C600255B8B /* Views */ = {
402 406
 			isa = PBXGroup;
403 407
 			children = (
404
-				D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */,
405
-				D28F111B3C8E4A7A00A1002B /* Subviews */,
408
+				43CBF666240BF3EB00255B8B /* ContentView.swift */,
409
+				AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */,
410
+				56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */,
411
+				4327461A24619CED0009BE4B /* MeterRowView.swift */,
412
+				437D47CF2415F8CF00B7768E /* Meter */,
413
+				D28F10023C8E4A7A00A10002 /* Components */,
414
+				4311E639241384960080EA59 /* DeviceHelpView.swift */,
415
+				AAD5F9A32B1CAC0700F8E4F9 /* MeterDetailView.swift */,
406 416
 			);
407
-			path = Home;
417
+			path = Views;
408 418
 			sourceTree = "<group>";
409 419
 		};
410
-		D28F110B3C8E4A7A00A1001B /* Live */ = {
420
+		43CBF67F240D14AC00255B8B /* Extensions */ = {
411 421
 			isa = PBXGroup;
412 422
 			children = (
413
-				D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */,
414
-				D28F11253C8E4A7A00A10035 /* Subviews */,
423
+				43CBF680240D153000255B8B /* CBManagerState.swift */,
424
+				4351E7BA24685ACD00E798A3 /* CGPoint.swift */,
425
+				43874C7E2414F3F400525397 /* Float.swift */,
426
+				43F7792A2465AE1600745DF4 /* UIView.swift */,
427
+				43874C842415611200525397 /* Double.swift */,
428
+				43874C82241533AD00525397 /* Data.swift */,
429
+				437D47D22415FB7E00B7768E /* Decimal.swift */,
430
+				43554B3324444B0E004E66F5 /* Date.swift */,
431
+				438B9554246D2D7500E61AE7 /* Path.swift */,
415 432
 			);
416
-			path = Live;
433
+			path = Extensions;
417 434
 			sourceTree = "<group>";
418 435
 		};
419
-		D28F110C3C8E4A7A00A1001C /* Chart */ = {
436
+		AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */ = {
420 437
 			isa = PBXGroup;
421 438
 			children = (
422
-				D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */,
439
+				AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */,
440
+				43BE08E12F78F49500250EEC /* SidebarList */,
423 441
 			);
424
-			path = Chart;
442
+			path = Sidebar;
425 443
 			sourceTree = "<group>";
426 444
 		};
427
-		D28F110D3C8E4A7A00A1001D /* Settings */ = {
445
+		D28F10013C8E4A7A00A10001 /* Sheets */ = {
428 446
 			isa = PBXGroup;
429 447
 			children = (
430
-				D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */,
431
-				D28F111C3C8E4A7A00A1002C /* Subviews */,
448
+				4308CF89241777130002E80B /* DataGroups */,
449
+				43554B3024444983004E66F5 /* MeasurementSeries */,
450
+				D28F11273C8E4A7A00A10037 /* ChargeRecord */,
432 451
 			);
433
-			path = Settings;
452
+			path = Sheets;
434 453
 			sourceTree = "<group>";
435 454
 		};
436
-		D28F11093C8E4A7A00A10019 /* Tabs */ = {
455
+		D28F10023C8E4A7A00A10002 /* Components */ = {
437 456
 			isa = PBXGroup;
438 457
 			children = (
439
-				D28F110A3C8E4A7A00A1001A /* Home */,
440
-				D28F110B3C8E4A7A00A1001B /* Live */,
441
-				D28F110C3C8E4A7A00A1001C /* Chart */,
442
-				D28F110D3C8E4A7A00A1001D /* Settings */,
458
+				D28F10033C8E4A7A00A10003 /* Generic */,
443 459
 			);
444
-			path = Tabs;
460
+			path = Components;
445 461
 			sourceTree = "<group>";
446 462
 		};
447 463
 		D28F10033C8E4A7A00A10003 /* Generic */ = {
@@ -453,152 +469,118 @@
453 469
 			path = Generic;
454 470
 			sourceTree = "<group>";
455 471
 		};
456
-		D28F10023C8E4A7A00A10002 /* Components */ = {
457
-			isa = PBXGroup;
458
-			children = (
459
-				D28F10033C8E4A7A00A10003 /* Generic */,
460
-			);
461
-			path = Components;
462
-			sourceTree = "<group>";
463
-		};
464
-		D28F113F3C8E4A7A00A1004F /* Components */ = {
472
+		D28F11093C8E4A7A00A10019 /* Tabs */ = {
465 473
 			isa = PBXGroup;
466 474
 			children = (
467
-				D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */,
468
-				D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */,
469
-				437F0AB62463108F005DEBEC /* MeasurementChartView.swift */,
475
+				D28F110A3C8E4A7A00A1001A /* Home */,
476
+				D28F110B3C8E4A7A00A1001B /* Live */,
477
+				D28F110C3C8E4A7A00A1001C /* Chart */,
478
+				D28F110D3C8E4A7A00A1001D /* Settings */,
470 479
 			);
471
-			path = Components;
480
+			path = Tabs;
472 481
 			sourceTree = "<group>";
473 482
 		};
474
-		437D47CF2415F8CF00B7768E /* Meter */ = {
483
+		D28F110A3C8E4A7A00A1001A /* Home */ = {
475 484
 			isa = PBXGroup;
476 485
 			children = (
477
-				4383B469240FE4A600DAAEBF /* MeterView.swift */,
478
-				D28F113F3C8E4A7A00A1004F /* Components */,
479
-				D28F11093C8E4A7A00A10019 /* Tabs */,
480
-				D28F10013C8E4A7A00A10001 /* Sheets */,
486
+				D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */,
487
+				D28F111B3C8E4A7A00A1002B /* Subviews */,
481 488
 			);
482
-			path = Meter;
489
+			path = Home;
483 490
 			sourceTree = "<group>";
484 491
 		};
485
-		4383B463240EB66400DAAEBF /* Templates */ = {
492
+		D28F110B3C8E4A7A00A1001B /* Live */ = {
486 493
 			isa = PBXGroup;
487 494
 			children = (
488
-				4383B464240EB6B200DAAEBF /* UserDefault.swift */,
495
+				D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */,
496
+				D28F11253C8E4A7A00A10035 /* Subviews */,
489 497
 			);
490
-			path = Templates;
498
+			path = Live;
491 499
 			sourceTree = "<group>";
492 500
 		};
493
-		4383B466240F842700DAAEBF /* DataTypes */ = {
501
+		D28F110C3C8E4A7A00A1001C /* Chart */ = {
494 502
 			isa = PBXGroup;
495 503
 			children = (
496
-				4383B467240F845500DAAEBF /* MacAdress.swift */,
504
+				D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */,
497 505
 			);
498
-			path = DataTypes;
506
+			path = Chart;
499 507
 			sourceTree = "<group>";
500 508
 		};
501
-		43CBF653240BF3EB00255B8B = {
509
+		D28F110D3C8E4A7A00A1001D /* Settings */ = {
502 510
 			isa = PBXGroup;
503 511
 			children = (
504
-				1C6B6B952A2D4F5100A0B001 /* Documentation */,
505
-				43CBF65E240BF3EB00255B8B /* USB Meter */,
506
-				43CBF65D240BF3EB00255B8B /* Products */,
507
-				4347F01B28D717C1007EE7B1 /* Frameworks */,
512
+				D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */,
513
+				D28F111C3C8E4A7A00A1002C /* Subviews */,
508 514
 			);
515
+			path = Settings;
509 516
 			sourceTree = "<group>";
510 517
 		};
511
-		43CBF65D240BF3EB00255B8B /* Products */ = {
518
+		D28F111B3C8E4A7A00A1002B /* Subviews */ = {
512 519
 			isa = PBXGroup;
513 520
 			children = (
514
-				43CBF65C240BF3EB00255B8B /* USB Meter.app */,
521
+				D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */,
522
+				D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */,
523
+				D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */,
515 524
 			);
516
-			name = Products;
525
+			path = Subviews;
517 526
 			sourceTree = "<group>";
518 527
 		};
519
-		43CBF65E240BF3EB00255B8B /* USB Meter */ = {
528
+		D28F111C3C8E4A7A00A1002C /* Subviews */ = {
520 529
 			isa = PBXGroup;
521 530
 			children = (
522
-				43CBF67A240C0D8A00255B8B /* USB Meter.entitlements */,
523
-				43CBF65F240BF3EB00255B8B /* AppDelegate.swift */,
524
-				43CBF661240BF3EB00255B8B /* SceneDelegate.swift */,
525
-				43CBF678240C047D00255B8B /* Model */,
526
-				43CBF679240C08C600255B8B /* Views */,
527
-				43CBF67F240D14AC00255B8B /* Extensions */,
528
-				4383B463240EB66400DAAEBF /* Templates */,
529
-				4383B466240F842700DAAEBF /* DataTypes */,
530
-				43CBF668240BF3ED00255B8B /* Assets.xcassets */,
531
-				43CBF66D240BF3ED00255B8B /* LaunchScreen.storyboard */,
532
-				43CBF670240BF3ED00255B8B /* Info.plist */,
533
-				43CBF66A240BF3ED00255B8B /* Preview Content */,
531
+				D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */,
532
+				D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */,
533
+				D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */,
534
+				437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */,
535
+				D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */,
536
+				D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */,
534 537
 			);
535
-			path = "USB Meter";
538
+			path = Subviews;
536 539
 			sourceTree = "<group>";
537 540
 		};
538
-		43CBF66A240BF3ED00255B8B /* Preview Content */ = {
541
+		D28F11253C8E4A7A00A10035 /* Subviews */ = {
539 542
 			isa = PBXGroup;
540 543
 			children = (
541
-				43CBF66B240BF3ED00255B8B /* Preview Assets.xcassets */,
544
+				437D47D02415F91B00B7768E /* MeterLiveContentView.swift */,
545
+				D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */,
546
+				D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */,
542 547
 			);
543
-			path = "Preview Content";
548
+			path = Subviews;
544 549
 			sourceTree = "<group>";
545 550
 		};
546
-		43CBF678240C047D00255B8B /* Model */ = {
551
+		D28F11263C8E4A7A00A10036 /* Subviews */ = {
547 552
 			isa = PBXGroup;
548 553
 			children = (
549
-				4383B461240EB5E400DAAEBF /* AppData.swift */,
550
-				7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */,
551
-				43CBF676240C043E00255B8B /* BluetoothManager.swift */,
552
-				4383B45F240EB2D000DAAEBF /* Meter.swift */,
553
-				43ED78AD2420A0BE00974487 /* BluetoothSerial.swift */,
554
-				439D996424234B98008DE3AA /* BluetoothRadio.swift */,
555
-				4386958E2F6A4E3E008855A9 /* MeterCapabilities.swift */,
556
-				4386958A2F6A1001008855A9 /* UMProtocol.swift */,
557
-				4386958C2F6A1002008855A9 /* TC66Protocol.swift */,
558
-				438695882463F062008855A9 /* Measurements.swift */,
559
-				432EA6432445A559006FC905 /* ChartContext.swift */,
554
+				4308CF8524176CAB0002E80B /* DataGroupRowView.swift */,
560 555
 			);
561
-			path = Model;
556
+			path = Subviews;
562 557
 			sourceTree = "<group>";
563 558
 		};
564
-		43CBF679240C08C600255B8B /* Views */ = {
559
+		D28F11273C8E4A7A00A10037 /* ChargeRecord */ = {
565 560
 			isa = PBXGroup;
566 561
 			children = (
567
-				43CBF666240BF3EB00255B8B /* ContentView.swift */,
568
-				AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */,
569
-				56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */,
570
-				4327461A24619CED0009BE4B /* MeterRowView.swift */,
571
-				437D47CF2415F8CF00B7768E /* Meter */,
572
-				D28F10023C8E4A7A00A10002 /* Components */,
573
-				4311E639241384960080EA59 /* DeviceHelpView.swift */,
574
-				AAD5F9A32B1CAC0700F8E4F9 /* MeterDetailView.swift */,
562
+				437D47D42415FD8C00B7768E /* ChargeRecordSheetView.swift */,
563
+				D28F11383C8E4A7A00A10048 /* Subviews */,
575 564
 			);
576
-			path = Views;
565
+			path = ChargeRecord;
577 566
 			sourceTree = "<group>";
578 567
 		};
579
-		43CBF67F240D14AC00255B8B /* Extensions */ = {
568
+		D28F11383C8E4A7A00A10048 /* Subviews */ = {
580 569
 			isa = PBXGroup;
581 570
 			children = (
582
-				43CBF680240D153000255B8B /* CBManagerState.swift */,
583
-				4351E7BA24685ACD00E798A3 /* CGPoint.swift */,
584
-				43874C7E2414F3F400525397 /* Float.swift */,
585
-				43F7792A2465AE1600745DF4 /* UIView.swift */,
586
-				43874C842415611200525397 /* Double.swift */,
587
-				43874C82241533AD00525397 /* Data.swift */,
588
-				437D47D22415FB7E00B7768E /* Decimal.swift */,
589
-				43554B3324444B0E004E66F5 /* Date.swift */,
590
-				438B9554246D2D7500E61AE7 /* Path.swift */,
571
+				D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */,
591 572
 			);
592
-			path = Extensions;
573
+			path = Subviews;
593 574
 			sourceTree = "<group>";
594 575
 		};
595
-		AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */ = {
576
+		D28F113F3C8E4A7A00A1004F /* Components */ = {
596 577
 			isa = PBXGroup;
597 578
 			children = (
598
-				AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */,
599
-				43BE08E12F78F49500250EEC /* SidebarList */,
579
+				D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */,
580
+				D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */,
581
+				437F0AB62463108F005DEBEC /* MeasurementChartView.swift */,
600 582
 			);
601
-			path = Sidebar;
583
+			path = Components;
602 584
 			sourceTree = "<group>";
603 585
 		};
604 586
 /* End PBXGroup section */
@@ -902,7 +884,10 @@
902 884
 				PRODUCT_BUNDLE_IDENTIFIER = "ro.xdev.USB-Meter";
903 885
 				PRODUCT_NAME = "$(TARGET_NAME)";
904 886
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
887
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
905 888
 				SUPPORTS_MACCATALYST = YES;
889
+				SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
890
+				SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
906 891
 				SWIFT_VERSION = 5.0;
907 892
 				TARGETED_DEVICE_FAMILY = "1,2";
908 893
 			};
@@ -926,7 +911,10 @@
926 911
 				PRODUCT_BUNDLE_IDENTIFIER = "ro.xdev.USB-Meter";
927 912
 				PRODUCT_NAME = "$(TARGET_NAME)";
928 913
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
914
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
929 915
 				SUPPORTS_MACCATALYST = YES;
916
+				SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
917
+				SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
930 918
 				SWIFT_VERSION = 5.0;
931 919
 				TARGETED_DEVICE_FAMILY = "1,2";
932 920
 			};
+914 -26
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -8,6 +8,11 @@
8 8
 
9 9
 import SwiftUI
10 10
 
11
+private enum PresentTrackingMode: CaseIterable, Hashable {
12
+    case keepDuration
13
+    case keepStartTimestamp
14
+}
15
+
11 16
 struct MeasurementChartView: View {
12 17
     private enum SeriesKind {
13 18
         case power
@@ -65,6 +70,9 @@ struct MeasurementChartView: View {
65 70
     @State var displayTemperature: Bool = false
66 71
     @State private var showResetConfirmation: Bool = false
67 72
     @State private var chartNow: Date = Date()
73
+    @State private var selectedVisibleTimeRange: ClosedRange<Date>?
74
+    @State private var isPinnedToPresent: Bool = false
75
+    @State private var presentTrackingMode: PresentTrackingMode = .keepDuration
68 76
     @State private var pinOrigin: Bool = false
69 77
     @State private var useSharedOrigin: Bool = false
70 78
     @State private var sharedAxisOrigin: Double = 0
@@ -185,15 +193,38 @@ struct MeasurementChartView: View {
185 193
     }
186 194
 
187 195
     var body: some View {
188
-        let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
189
-        let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
190
-        let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan)
191
-        let temperatureSeries = series(for: measurements.temperature, kind: .temperature, minimumYSpan: minimumTemperatureSpan)
196
+        let availableTimeRange = availableSelectionTimeRange()
197
+        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
198
+        let powerSeries = series(
199
+            for: measurements.power,
200
+            kind: .power,
201
+            minimumYSpan: minimumPowerSpan,
202
+            visibleTimeRange: visibleTimeRange
203
+        )
204
+        let voltageSeries = series(
205
+            for: measurements.voltage,
206
+            kind: .voltage,
207
+            minimumYSpan: minimumVoltageSpan,
208
+            visibleTimeRange: visibleTimeRange
209
+        )
210
+        let currentSeries = series(
211
+            for: measurements.current,
212
+            kind: .current,
213
+            minimumYSpan: minimumCurrentSpan,
214
+            visibleTimeRange: visibleTimeRange
215
+        )
216
+        let temperatureSeries = series(
217
+            for: measurements.temperature,
218
+            kind: .temperature,
219
+            minimumYSpan: minimumTemperatureSpan,
220
+            visibleTimeRange: visibleTimeRange
221
+        )
192 222
         let primarySeries = displayedPrimarySeries(
193 223
             powerSeries: powerSeries,
194 224
             voltageSeries: voltageSeries,
195 225
             currentSeries: currentSeries
196 226
         )
227
+        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
197 228
 
198 229
         Group {
199 230
             if let primarySeries {
@@ -280,6 +311,25 @@ struct MeasurementChartView: View {
280 311
                                     Spacer(minLength: 0)
281 312
                                 }
282 313
                             }
314
+
315
+                            if let availableTimeRange,
316
+                               let selectorSeries,
317
+                               shouldShowRangeSelector(
318
+                                availableTimeRange: availableTimeRange,
319
+                                series: selectorSeries
320
+                               ) {
321
+                                TimeRangeSelectorView(
322
+                                    points: selectorSeries.points,
323
+                                    context: selectorSeries.context,
324
+                                    availableTimeRange: availableTimeRange,
325
+                                    accentColor: selectorSeries.kind.tint,
326
+                                    compactLayout: compactLayout,
327
+                                    minimumSelectionSpan: minimumTimeSpan,
328
+                                    selectedTimeRange: $selectedVisibleTimeRange,
329
+                                    isPinnedToPresent: $isPinnedToPresent,
330
+                                    presentTrackingMode: $presentTrackingMode
331
+                                )
332
+                            }
283 333
                         }
284 334
                         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
285 335
                     }
@@ -695,12 +745,13 @@ struct MeasurementChartView: View {
695 745
     private func series(
696 746
         for measurement: Measurements.Measurement,
697 747
         kind: SeriesKind,
698
-        minimumYSpan: Double
748
+        minimumYSpan: Double,
749
+        visibleTimeRange: ClosedRange<Date>? = nil
699 750
     ) -> SeriesData {
700
-        let points = measurement.points.filter { point in
701
-            guard let timeRange else { return true }
702
-            return timeRange.contains(point.timestamp)
703
-        }
751
+        let points = filteredPoints(
752
+            measurement,
753
+            visibleTimeRange: visibleTimeRange
754
+        )
704 755
         let samplePoints = points.filter { $0.isSample }
705 756
         let context = ChartContext()
706 757
 
@@ -708,7 +759,10 @@ struct MeasurementChartView: View {
708 759
             for: samplePoints,
709 760
             minimumYSpan: minimumYSpan
710 761
         )
711
-        let xBounds = xBounds(for: samplePoints)
762
+        let xBounds = xBounds(
763
+            for: samplePoints,
764
+            visibleTimeRange: visibleTimeRange
765
+        )
712 766
         let lowerBound = resolvedLowerBound(
713 767
             for: kind,
714 768
             autoLowerBound: autoBounds.lowerBound
@@ -739,6 +793,40 @@ struct MeasurementChartView: View {
739 793
         )
740 794
     }
741 795
 
796
+    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
797
+        series(
798
+            for: measurement(for: kind),
799
+            kind: kind,
800
+            minimumYSpan: minimumYSpan(for: kind)
801
+        )
802
+    }
803
+
804
+    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
805
+        switch kind {
806
+        case .power:
807
+            return measurements.power
808
+        case .voltage:
809
+            return measurements.voltage
810
+        case .current:
811
+            return measurements.current
812
+        case .temperature:
813
+            return measurements.temperature
814
+        }
815
+    }
816
+
817
+    private func minimumYSpan(for kind: SeriesKind) -> Double {
818
+        switch kind {
819
+        case .power:
820
+            return minimumPowerSpan
821
+        case .voltage:
822
+            return minimumVoltageSpan
823
+        case .current:
824
+            return minimumCurrentSpan
825
+        case .temperature:
826
+            return minimumTemperatureSpan
827
+        }
828
+    }
829
+
742 830
     private var supportsSharedOrigin: Bool {
743 831
         displayVoltage && displayCurrent && !displayPower
744 832
     }
@@ -844,45 +932,208 @@ struct MeasurementChartView: View {
844 932
     }
845 933
 
846 934
     private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
935
+        let visibleTimeRange = activeVisibleTimeRange
936
+
847 937
         switch kind {
848 938
         case .power:
849
-            return pinOrigin ? powerAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.power), minimumYSpan: minimumPowerSpan).lowerBound
939
+            return pinOrigin
940
+                ? powerAxisOrigin
941
+                : automaticYBounds(
942
+                    for: filteredSamplePoints(
943
+                        measurements.power,
944
+                        visibleTimeRange: visibleTimeRange
945
+                    ),
946
+                    minimumYSpan: minimumPowerSpan
947
+                ).lowerBound
850 948
         case .voltage:
851 949
             if pinOrigin && useSharedOrigin && supportsSharedOrigin {
852 950
                 return sharedAxisOrigin
853 951
             }
854
-            return pinOrigin ? voltageAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.voltage), minimumYSpan: minimumVoltageSpan).lowerBound
952
+            return pinOrigin
953
+                ? voltageAxisOrigin
954
+                : automaticYBounds(
955
+                    for: filteredSamplePoints(
956
+                        measurements.voltage,
957
+                        visibleTimeRange: visibleTimeRange
958
+                    ),
959
+                    minimumYSpan: minimumVoltageSpan
960
+                ).lowerBound
855 961
         case .current:
856 962
             if pinOrigin && useSharedOrigin && supportsSharedOrigin {
857 963
                 return sharedAxisOrigin
858 964
             }
859
-            return pinOrigin ? currentAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.current), minimumYSpan: minimumCurrentSpan).lowerBound
965
+            return pinOrigin
966
+                ? currentAxisOrigin
967
+                : automaticYBounds(
968
+                    for: filteredSamplePoints(
969
+                        measurements.current,
970
+                        visibleTimeRange: visibleTimeRange
971
+                    ),
972
+                    minimumYSpan: minimumCurrentSpan
973
+                ).lowerBound
860 974
         case .temperature:
861
-            return pinOrigin ? temperatureAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.temperature), minimumYSpan: minimumTemperatureSpan).lowerBound
975
+            return pinOrigin
976
+                ? temperatureAxisOrigin
977
+                : automaticYBounds(
978
+                    for: filteredSamplePoints(
979
+                        measurements.temperature,
980
+                        visibleTimeRange: visibleTimeRange
981
+                    ),
982
+                    minimumYSpan: minimumTemperatureSpan
983
+                ).lowerBound
862 984
         }
863 985
     }
864 986
 
865
-    private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
987
+    private var activeVisibleTimeRange: ClosedRange<Date>? {
988
+        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
989
+    }
990
+
991
+    private func filteredPoints(
992
+        _ measurement: Measurements.Measurement,
993
+        visibleTimeRange: ClosedRange<Date>? = nil
994
+    ) -> [Measurements.Measurement.Point] {
866 995
         measurement.points.filter { point in
867
-            point.isSample && (timeRange?.contains(point.timestamp) ?? true)
996
+            guard timeRange?.contains(point.timestamp) ?? true else { return false }
997
+            return visibleTimeRange?.contains(point.timestamp) ?? true
998
+        }
999
+    }
1000
+
1001
+    private func filteredSamplePoints(
1002
+        _ measurement: Measurements.Measurement,
1003
+        visibleTimeRange: ClosedRange<Date>? = nil
1004
+    ) -> [Measurements.Measurement.Point] {
1005
+        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1006
+            point.isSample
868 1007
         }
869 1008
     }
870 1009
 
871 1010
     private func xBounds(
872
-        for samplePoints: [Measurements.Measurement.Point]
1011
+        for samplePoints: [Measurements.Measurement.Point],
1012
+        visibleTimeRange: ClosedRange<Date>? = nil
873 1013
     ) -> ClosedRange<Date> {
1014
+        if let visibleTimeRange {
1015
+            return normalizedTimeRange(visibleTimeRange)
1016
+        }
1017
+
874 1018
         if let timeRange {
875
-            return timeRange
1019
+            return normalizedTimeRange(timeRange)
876 1020
         }
877 1021
 
878 1022
         let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
879 1023
         let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
880 1024
 
881
-        if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
882
-            return lowerBound...upperBound
1025
+        return normalizedTimeRange(lowerBound...upperBound)
1026
+    }
1027
+
1028
+    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1029
+        if let timeRange {
1030
+            return normalizedTimeRange(timeRange)
1031
+        }
1032
+
1033
+        let samplePoints = timelineSamplePoints()
1034
+        guard let lowerBound = samplePoints.first?.timestamp else {
1035
+            return nil
1036
+        }
1037
+
1038
+        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1039
+        return normalizedTimeRange(lowerBound...upperBound)
1040
+    }
1041
+
1042
+    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1043
+        let candidates = [
1044
+            filteredSamplePoints(measurements.power),
1045
+            filteredSamplePoints(measurements.voltage),
1046
+            filteredSamplePoints(measurements.current),
1047
+            filteredSamplePoints(measurements.temperature)
1048
+        ]
1049
+
1050
+        return candidates.first(where: { !$0.isEmpty }) ?? []
1051
+    }
1052
+
1053
+    private func resolvedVisibleTimeRange(
1054
+        within availableTimeRange: ClosedRange<Date>?
1055
+    ) -> ClosedRange<Date>? {
1056
+        guard let availableTimeRange else { return nil }
1057
+        guard let selectedVisibleTimeRange else { return availableTimeRange }
1058
+
1059
+        if isPinnedToPresent {
1060
+            let pinnedRange: ClosedRange<Date>
1061
+
1062
+            switch presentTrackingMode {
1063
+            case .keepDuration:
1064
+                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1065
+                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1066
+            case .keepStartTimestamp:
1067
+                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1068
+            }
1069
+
1070
+            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1071
+        }
1072
+
1073
+        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1074
+    }
1075
+
1076
+    private func clampedTimeRange(
1077
+        _ candidateRange: ClosedRange<Date>,
1078
+        within bounds: ClosedRange<Date>
1079
+    ) -> ClosedRange<Date> {
1080
+        let normalizedBounds = normalizedTimeRange(bounds)
1081
+        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1082
+
1083
+        guard boundsSpan > 0 else {
1084
+            return normalizedBounds
1085
+        }
1086
+
1087
+        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1088
+        let requestedSpan = min(
1089
+            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1090
+            boundsSpan
1091
+        )
1092
+
1093
+        if requestedSpan >= boundsSpan {
1094
+            return normalizedBounds
1095
+        }
1096
+
1097
+        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1098
+        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1099
+
1100
+        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1101
+            if lowerBound == normalizedBounds.lowerBound {
1102
+                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1103
+            } else {
1104
+                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1105
+            }
1106
+        }
1107
+
1108
+        if upperBound > normalizedBounds.upperBound {
1109
+            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1110
+            upperBound = normalizedBounds.upperBound
1111
+            lowerBound = lowerBound.addingTimeInterval(-delta)
883 1112
         }
884 1113
 
885
-        return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound
1114
+        if lowerBound < normalizedBounds.lowerBound {
1115
+            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1116
+            lowerBound = normalizedBounds.lowerBound
1117
+            upperBound = upperBound.addingTimeInterval(delta)
1118
+        }
1119
+
1120
+        return lowerBound...upperBound
1121
+    }
1122
+
1123
+    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1124
+        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1125
+        guard span < minimumTimeSpan else { return range }
1126
+
1127
+        let expansion = (minimumTimeSpan - span) / 2
1128
+        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1129
+    }
1130
+
1131
+    private func shouldShowRangeSelector(
1132
+        availableTimeRange: ClosedRange<Date>,
1133
+        series: SeriesData
1134
+    ) -> Bool {
1135
+        series.samplePoints.count > 1 &&
1136
+        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
886 1137
     }
887 1138
 
888 1139
     private func automaticYBounds(
@@ -1030,15 +1281,37 @@ struct MeasurementChartView: View {
1030 1281
     }
1031 1282
 
1032 1283
     private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
1284
+        let visibleTimeRange = activeVisibleTimeRange
1285
+
1033 1286
         switch kind {
1034 1287
         case .power:
1035
-            return snappedOriginValue(filteredSamplePoints(measurements.power).map(\.value).min() ?? 0)
1288
+            return snappedOriginValue(
1289
+                filteredSamplePoints(
1290
+                    measurements.power,
1291
+                    visibleTimeRange: visibleTimeRange
1292
+                ).map(\.value).min() ?? 0
1293
+            )
1036 1294
         case .voltage:
1037
-            return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0)
1295
+            return snappedOriginValue(
1296
+                filteredSamplePoints(
1297
+                    measurements.voltage,
1298
+                    visibleTimeRange: visibleTimeRange
1299
+                ).map(\.value).min() ?? 0
1300
+            )
1038 1301
         case .current:
1039
-            return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0)
1302
+            return snappedOriginValue(
1303
+                filteredSamplePoints(
1304
+                    measurements.current,
1305
+                    visibleTimeRange: visibleTimeRange
1306
+                ).map(\.value).min() ?? 0
1307
+            )
1040 1308
         case .temperature:
1041
-            return snappedOriginValue(filteredSamplePoints(measurements.temperature).map(\.value).min() ?? 0)
1309
+            return snappedOriginValue(
1310
+                filteredSamplePoints(
1311
+                    measurements.temperature,
1312
+                    visibleTimeRange: visibleTimeRange
1313
+                ).map(\.value).min() ?? 0
1314
+            )
1042 1315
         }
1043 1316
     }
1044 1317
 
@@ -1308,18 +1581,633 @@ struct MeasurementChartView: View {
1308 1581
     
1309 1582
 }
1310 1583
 
1584
+private struct TimeRangeSelectorView: View {
1585
+    private enum DragTarget {
1586
+        case lowerBound
1587
+        case upperBound
1588
+        case window
1589
+    }
1590
+
1591
+    private struct DragState {
1592
+        let target: DragTarget
1593
+        let initialRange: ClosedRange<Date>
1594
+    }
1595
+
1596
+    let points: [Measurements.Measurement.Point]
1597
+    let context: ChartContext
1598
+    let availableTimeRange: ClosedRange<Date>
1599
+    let accentColor: Color
1600
+    let compactLayout: Bool
1601
+    let minimumSelectionSpan: TimeInterval
1602
+
1603
+    @Binding var selectedTimeRange: ClosedRange<Date>?
1604
+    @Binding var isPinnedToPresent: Bool
1605
+    @Binding var presentTrackingMode: PresentTrackingMode
1606
+    @State private var dragState: DragState?
1607
+
1608
+    private var totalSpan: TimeInterval {
1609
+        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
1610
+    }
1611
+
1612
+    private var currentRange: ClosedRange<Date> {
1613
+        resolvedSelectionRange()
1614
+    }
1615
+
1616
+    private var trackHeight: CGFloat {
1617
+        compactLayout ? 72 : 86
1618
+    }
1619
+
1620
+    private var cornerRadius: CGFloat {
1621
+        compactLayout ? 14 : 16
1622
+    }
1623
+
1624
+    private var summaryFont: Font {
1625
+        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
1626
+    }
1627
+
1628
+    private var boundaryFont: Font {
1629
+        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
1630
+    }
1631
+
1632
+    private var symbolButtonSize: CGFloat {
1633
+        compactLayout ? 28 : 32
1634
+    }
1635
+
1636
+    var body: some View {
1637
+        let coversFullRange = selectionCoversFullRange(currentRange)
1638
+
1639
+        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
1640
+            if !coversFullRange || isPinnedToPresent {
1641
+                HStack(spacing: 8) {
1642
+                    alignmentButton(
1643
+                        systemName: "arrow.left.to.line.compact",
1644
+                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
1645
+                        action: alignSelectionToLeadingEdge,
1646
+                        accessibilityLabel: "Align selection to start"
1647
+                    )
1648
+
1649
+                    alignmentButton(
1650
+                        systemName: "arrow.right.to.line.compact",
1651
+                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
1652
+                        action: alignSelectionToTrailingEdge,
1653
+                        accessibilityLabel: "Align selection to present"
1654
+                    )
1655
+
1656
+                    Spacer(minLength: 0)
1657
+
1658
+                    if isPinnedToPresent {
1659
+                        trackingModeToggleButton()
1660
+                    }
1661
+                }
1662
+            }
1663
+
1664
+            GeometryReader { geometry in
1665
+                let selectionFrame = selectionFrame(in: geometry.size)
1666
+                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
1667
+
1668
+                ZStack(alignment: .topLeading) {
1669
+                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
1670
+                        .fill(Color.primary.opacity(0.05))
1671
+
1672
+                    Chart(
1673
+                        points: points,
1674
+                        context: context,
1675
+                        areaChart: true,
1676
+                        strokeColor: accentColor,
1677
+                        areaFillColor: accentColor.opacity(0.22)
1678
+                    )
1679
+                    .opacity(0.94)
1680
+                    .allowsHitTesting(false)
1681
+
1682
+                    Chart(
1683
+                        points: points,
1684
+                        context: context,
1685
+                        strokeColor: accentColor.opacity(0.56)
1686
+                    )
1687
+                    .opacity(0.82)
1688
+                    .allowsHitTesting(false)
1689
+
1690
+                    if selectionFrame.minX > 0 {
1691
+                        Rectangle()
1692
+                            .fill(dimmingColor)
1693
+                            .frame(width: selectionFrame.minX, height: geometry.size.height)
1694
+                            .allowsHitTesting(false)
1695
+                    }
1696
+
1697
+                    if selectionFrame.maxX < geometry.size.width {
1698
+                        Rectangle()
1699
+                            .fill(dimmingColor)
1700
+                            .frame(
1701
+                                width: max(geometry.size.width - selectionFrame.maxX, 0),
1702
+                                height: geometry.size.height
1703
+                            )
1704
+                            .offset(x: selectionFrame.maxX)
1705
+                            .allowsHitTesting(false)
1706
+                    }
1707
+
1708
+                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
1709
+                        .fill(accentColor.opacity(0.18))
1710
+                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
1711
+                        .offset(x: selectionFrame.minX)
1712
+                        .allowsHitTesting(false)
1713
+
1714
+                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
1715
+                        .stroke(accentColor.opacity(0.52), lineWidth: 1.2)
1716
+                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
1717
+                        .offset(x: selectionFrame.minX)
1718
+                        .allowsHitTesting(false)
1719
+
1720
+                    handleView(height: max(geometry.size.height - 18, 16))
1721
+                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
1722
+                        .allowsHitTesting(false)
1723
+
1724
+                    handleView(height: max(geometry.size.height - 18, 16))
1725
+                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
1726
+                        .allowsHitTesting(false)
1727
+                }
1728
+                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
1729
+                .overlay(
1730
+                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
1731
+                        .stroke(Color.secondary.opacity(0.18), lineWidth: 1)
1732
+                )
1733
+                .contentShape(Rectangle())
1734
+                .gesture(selectionGesture(totalWidth: geometry.size.width))
1735
+            }
1736
+            .frame(height: trackHeight)
1737
+
1738
+            HStack {
1739
+                Text(boundaryLabel(for: availableTimeRange.lowerBound))
1740
+                Spacer(minLength: 0)
1741
+                Text(boundaryLabel(for: availableTimeRange.upperBound))
1742
+            }
1743
+            .font(boundaryFont)
1744
+            .foregroundColor(.secondary)
1745
+            .monospacedDigit()
1746
+        }
1747
+    }
1748
+
1749
+    private func handleView(height: CGFloat) -> some View {
1750
+        Capsule(style: .continuous)
1751
+            .fill(Color.white.opacity(0.95))
1752
+            .frame(width: 6, height: height)
1753
+            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
1754
+    }
1755
+
1756
+    private func alignmentButton(
1757
+        systemName: String,
1758
+        isActive: Bool,
1759
+        action: @escaping () -> Void,
1760
+        accessibilityLabel: String
1761
+    ) -> some View {
1762
+        Button(action: action) {
1763
+            Image(systemName: systemName)
1764
+                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
1765
+                .frame(width: symbolButtonSize, height: symbolButtonSize)
1766
+        }
1767
+        .buttonStyle(.plain)
1768
+        .foregroundColor(isActive ? .white : accentColor)
1769
+        .background(
1770
+            RoundedRectangle(cornerRadius: 9, style: .continuous)
1771
+                .fill(isActive ? accentColor : accentColor.opacity(0.14))
1772
+        )
1773
+        .overlay(
1774
+            RoundedRectangle(cornerRadius: 9, style: .continuous)
1775
+                .stroke(accentColor.opacity(0.28), lineWidth: 1)
1776
+        )
1777
+        .accessibilityLabel(accessibilityLabel)
1778
+    }
1779
+
1780
+    private func trackingModeToggleButton() -> some View {
1781
+        Button {
1782
+            presentTrackingMode = presentTrackingMode == .keepDuration
1783
+                ? .keepStartTimestamp
1784
+                : .keepDuration
1785
+        } label: {
1786
+            Image(systemName: trackingModeSymbolName)
1787
+                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
1788
+                .frame(width: symbolButtonSize, height: symbolButtonSize)
1789
+        }
1790
+        .buttonStyle(.plain)
1791
+        .foregroundColor(.white)
1792
+        .background(
1793
+            RoundedRectangle(cornerRadius: 9, style: .continuous)
1794
+                .fill(accentColor)
1795
+        )
1796
+        .overlay(
1797
+            RoundedRectangle(cornerRadius: 9, style: .continuous)
1798
+                .stroke(accentColor.opacity(0.28), lineWidth: 1)
1799
+        )
1800
+        .accessibilityLabel(trackingModeAccessibilityLabel)
1801
+        .accessibilityHint("Toggles how the interval follows the present")
1802
+    }
1803
+
1804
+    private var trackingModeSymbolName: String {
1805
+        switch presentTrackingMode {
1806
+        case .keepDuration:
1807
+            return "arrow.left.and.right"
1808
+        case .keepStartTimestamp:
1809
+            return "arrow.left.to.line.compact"
1810
+        }
1811
+    }
1812
+
1813
+    private var trackingModeAccessibilityLabel: String {
1814
+        switch presentTrackingMode {
1815
+        case .keepDuration:
1816
+            return "Follow present keeping span"
1817
+        case .keepStartTimestamp:
1818
+            return "Follow present keeping start"
1819
+        }
1820
+    }
1821
+
1822
+    private func alignSelectionToLeadingEdge() {
1823
+        let alignedRange = normalizedSelectionRange(
1824
+            availableTimeRange.lowerBound...currentRange.upperBound
1825
+        )
1826
+        applySelection(alignedRange, pinToPresent: false)
1827
+    }
1828
+
1829
+    private func alignSelectionToTrailingEdge() {
1830
+        let alignedRange = normalizedSelectionRange(
1831
+            currentRange.lowerBound...availableTimeRange.upperBound
1832
+        )
1833
+        applySelection(alignedRange, pinToPresent: true)
1834
+    }
1835
+
1836
+    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
1837
+        DragGesture(minimumDistance: 0)
1838
+            .onChanged { value in
1839
+                updateSelectionDrag(value: value, totalWidth: totalWidth)
1840
+            }
1841
+            .onEnded { _ in
1842
+                dragState = nil
1843
+            }
1844
+    }
1845
+
1846
+    private func updateSelectionDrag(
1847
+        value: DragGesture.Value,
1848
+        totalWidth: CGFloat
1849
+    ) {
1850
+        let startingRange = resolvedSelectionRange()
1851
+
1852
+        if dragState == nil {
1853
+            dragState = DragState(
1854
+                target: dragTarget(
1855
+                    for: value.startLocation.x,
1856
+                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
1857
+                ),
1858
+                initialRange: startingRange
1859
+            )
1860
+        }
1861
+
1862
+        guard let dragState else { return }
1863
+
1864
+        let resultingRange = snappedToEdges(
1865
+            adjustedRange(
1866
+                from: dragState.initialRange,
1867
+                target: dragState.target,
1868
+                translationX: value.translation.width,
1869
+                totalWidth: totalWidth
1870
+            ),
1871
+            target: dragState.target,
1872
+            totalWidth: totalWidth
1873
+        )
1874
+
1875
+        applySelection(
1876
+            resultingRange,
1877
+            pinToPresent: shouldKeepPresentPin(
1878
+                during: dragState.target,
1879
+                initialRange: dragState.initialRange,
1880
+                resultingRange: resultingRange
1881
+            ),
1882
+        )
1883
+    }
1884
+
1885
+    private func dragTarget(
1886
+        for startX: CGFloat,
1887
+        selectionFrame: CGRect
1888
+    ) -> DragTarget {
1889
+        let handleZone: CGFloat = compactLayout ? 20 : 24
1890
+
1891
+        if abs(startX - selectionFrame.minX) <= handleZone {
1892
+            return .lowerBound
1893
+        }
1894
+
1895
+        if abs(startX - selectionFrame.maxX) <= handleZone {
1896
+            return .upperBound
1897
+        }
1898
+
1899
+        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
1900
+            return .window
1901
+        }
1902
+
1903
+        return startX < selectionFrame.minX ? .lowerBound : .upperBound
1904
+    }
1905
+
1906
+    private func adjustedRange(
1907
+        from initialRange: ClosedRange<Date>,
1908
+        target: DragTarget,
1909
+        translationX: CGFloat,
1910
+        totalWidth: CGFloat
1911
+    ) -> ClosedRange<Date> {
1912
+        guard totalSpan > 0, totalWidth > 0 else {
1913
+            return availableTimeRange
1914
+        }
1915
+
1916
+        let delta = TimeInterval(translationX / totalWidth) * totalSpan
1917
+        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
1918
+
1919
+        switch target {
1920
+        case .lowerBound:
1921
+            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
1922
+            let newLowerBound = min(
1923
+                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
1924
+                maximumLowerBound
1925
+            )
1926
+            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
1927
+
1928
+        case .upperBound:
1929
+            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
1930
+            let newUpperBound = max(
1931
+                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
1932
+                minimumUpperBound
1933
+            )
1934
+            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
1935
+
1936
+        case .window:
1937
+            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
1938
+            guard span < totalSpan else { return availableTimeRange }
1939
+
1940
+            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
1941
+            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
1942
+
1943
+            if lowerBound < availableTimeRange.lowerBound {
1944
+                upperBound = upperBound.addingTimeInterval(
1945
+                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
1946
+                )
1947
+                lowerBound = availableTimeRange.lowerBound
1948
+            }
1949
+
1950
+            if upperBound > availableTimeRange.upperBound {
1951
+                lowerBound = lowerBound.addingTimeInterval(
1952
+                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
1953
+                )
1954
+                upperBound = availableTimeRange.upperBound
1955
+            }
1956
+
1957
+            return normalizedSelectionRange(lowerBound...upperBound)
1958
+        }
1959
+    }
1960
+
1961
+    private func snappedToEdges(
1962
+        _ candidateRange: ClosedRange<Date>,
1963
+        target: DragTarget,
1964
+        totalWidth: CGFloat
1965
+    ) -> ClosedRange<Date> {
1966
+        guard totalSpan > 0 else {
1967
+            return availableTimeRange
1968
+        }
1969
+
1970
+        let snapInterval = edgeSnapInterval(for: totalWidth)
1971
+        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
1972
+        var lowerBound = candidateRange.lowerBound
1973
+        var upperBound = candidateRange.upperBound
1974
+
1975
+        if target != .upperBound,
1976
+           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
1977
+            lowerBound = availableTimeRange.lowerBound
1978
+            if target == .window {
1979
+                upperBound = lowerBound.addingTimeInterval(selectionSpan)
1980
+            }
1981
+        }
1982
+
1983
+        if target != .lowerBound,
1984
+           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
1985
+            upperBound = availableTimeRange.upperBound
1986
+            if target == .window {
1987
+                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
1988
+            }
1989
+        }
1990
+
1991
+        return normalizedSelectionRange(lowerBound...upperBound)
1992
+    }
1993
+
1994
+    private func edgeSnapInterval(
1995
+        for totalWidth: CGFloat
1996
+    ) -> TimeInterval {
1997
+        guard totalWidth > 0 else { return minimumSelectionSpan }
1998
+
1999
+        let snapWidth = min(
2000
+            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2001
+            totalWidth * 0.18
2002
+        )
2003
+        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2004
+        return min(
2005
+            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2006
+            totalSpan / 4
2007
+        )
2008
+    }
2009
+
2010
+    private func resolvedSelectionRange() -> ClosedRange<Date> {
2011
+        guard let selectedTimeRange else { return availableTimeRange }
2012
+
2013
+        if isPinnedToPresent {
2014
+            switch presentTrackingMode {
2015
+            case .keepDuration:
2016
+                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2017
+                return normalizedSelectionRange(
2018
+                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2019
+                )
2020
+            case .keepStartTimestamp:
2021
+                return normalizedSelectionRange(
2022
+                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2023
+                )
2024
+            }
2025
+        }
2026
+
2027
+        return normalizedSelectionRange(selectedTimeRange)
2028
+    }
2029
+
2030
+    private func normalizedSelectionRange(
2031
+        _ candidateRange: ClosedRange<Date>
2032
+    ) -> ClosedRange<Date> {
2033
+        let availableSpan = totalSpan
2034
+        guard availableSpan > 0 else { return availableTimeRange }
2035
+
2036
+        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2037
+        let requestedSpan = min(
2038
+            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2039
+            availableSpan
2040
+        )
2041
+
2042
+        if requestedSpan >= availableSpan {
2043
+            return availableTimeRange
2044
+        }
2045
+
2046
+        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2047
+        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2048
+
2049
+        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2050
+            if lowerBound == availableTimeRange.lowerBound {
2051
+                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2052
+            } else {
2053
+                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2054
+            }
2055
+        }
2056
+
2057
+        if upperBound > availableTimeRange.upperBound {
2058
+            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2059
+            upperBound = availableTimeRange.upperBound
2060
+            lowerBound = lowerBound.addingTimeInterval(-delta)
2061
+        }
2062
+
2063
+        if lowerBound < availableTimeRange.lowerBound {
2064
+            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2065
+            lowerBound = availableTimeRange.lowerBound
2066
+            upperBound = upperBound.addingTimeInterval(delta)
2067
+        }
2068
+
2069
+        return lowerBound...upperBound
2070
+    }
2071
+
2072
+    private func shouldKeepPresentPin(
2073
+        during target: DragTarget,
2074
+        initialRange: ClosedRange<Date>,
2075
+        resultingRange: ClosedRange<Date>
2076
+    ) -> Bool {
2077
+        let startedPinnedToPresent =
2078
+            isPinnedToPresent ||
2079
+            selectionCoversFullRange(initialRange)
2080
+
2081
+        guard startedPinnedToPresent else {
2082
+            return selectionTouchesPresent(resultingRange)
2083
+        }
2084
+
2085
+        switch target {
2086
+        case .lowerBound:
2087
+            return true
2088
+        case .upperBound, .window:
2089
+            return selectionTouchesPresent(resultingRange)
2090
+        }
2091
+    }
2092
+
2093
+    private func applySelection(
2094
+        _ candidateRange: ClosedRange<Date>,
2095
+        pinToPresent: Bool
2096
+    ) {
2097
+        let normalizedRange = normalizedSelectionRange(candidateRange)
2098
+
2099
+        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2100
+            selectedTimeRange = nil
2101
+        } else {
2102
+            selectedTimeRange = normalizedRange
2103
+        }
2104
+
2105
+        isPinnedToPresent = pinToPresent
2106
+    }
2107
+
2108
+    private func selectionTouchesPresent(
2109
+        _ range: ClosedRange<Date>
2110
+    ) -> Bool {
2111
+        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2112
+        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2113
+    }
2114
+
2115
+    private func selectionCoversFullRange(
2116
+        _ range: ClosedRange<Date>
2117
+    ) -> Bool {
2118
+        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2119
+        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2120
+        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2121
+    }
2122
+
2123
+    private func selectionFrame(in size: CGSize) -> CGRect {
2124
+        selectionFrame(for: currentRange, width: size.width)
2125
+    }
2126
+
2127
+    private func selectionFrame(
2128
+        for range: ClosedRange<Date>,
2129
+        width: CGFloat
2130
+    ) -> CGRect {
2131
+        guard width > 0, totalSpan > 0 else {
2132
+            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2133
+        }
2134
+
2135
+        let minimumX = xPosition(for: range.lowerBound, width: width)
2136
+        let maximumX = xPosition(for: range.upperBound, width: width)
2137
+        return CGRect(
2138
+            x: minimumX,
2139
+            y: 0,
2140
+            width: max(maximumX - minimumX, 2),
2141
+            height: trackHeight
2142
+        )
2143
+    }
2144
+
2145
+    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2146
+        guard width > 0, totalSpan > 0 else { return 0 }
2147
+
2148
+        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2149
+        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2150
+        return CGFloat(normalizedOffset) * width
2151
+    }
2152
+
2153
+    private func boundaryLabel(for date: Date) -> String {
2154
+        date.format(as: boundaryDateFormat)
2155
+    }
2156
+
2157
+    private func selectionSummary(
2158
+        for range: ClosedRange<Date>
2159
+    ) -> String {
2160
+        "\(range.lowerBound.format(as: summaryDateFormat)) - \(range.upperBound.format(as: summaryDateFormat))"
2161
+    }
2162
+
2163
+    private var boundaryDateFormat: String {
2164
+        switch totalSpan {
2165
+        case 0..<86400:
2166
+            return "HH:mm"
2167
+        case 86400..<604800:
2168
+            return "MMM d HH:mm"
2169
+        default:
2170
+            return "MMM d"
2171
+        }
2172
+    }
2173
+
2174
+    private var summaryDateFormat: String {
2175
+        switch totalSpan {
2176
+        case 0..<3600:
2177
+            return "HH:mm:ss"
2178
+        case 3600..<172800:
2179
+            return "MMM d HH:mm"
2180
+        default:
2181
+            return "MMM d"
2182
+        }
2183
+    }
2184
+}
2185
+
1311 2186
 struct Chart : View {
1312 2187
     
1313 2188
     let points: [Measurements.Measurement.Point]
1314 2189
     let context: ChartContext
1315 2190
     var areaChart: Bool = false
1316 2191
     var strokeColor: Color = .black
2192
+    var areaFillColor: Color? = nil
1317 2193
     
1318 2194
     var body : some View {
1319 2195
         GeometryReader { geometry in
1320 2196
             if self.areaChart {
2197
+                let fillColor = areaFillColor ?? strokeColor.opacity(0.2)
1321 2198
                 self.path( geometry: geometry )
1322
-                    .fill(LinearGradient( gradient: .init(colors: [Color.red, Color.green]), startPoint: .init(x: 0.5, y: 0.1), endPoint: .init(x: 0.5, y: 0.9)))
2199
+                    .fill(
2200
+                        LinearGradient(
2201
+                            gradient: .init(
2202
+                                colors: [
2203
+                                    fillColor.opacity(0.72),
2204
+                                    fillColor.opacity(0.18)
2205
+                                ]
2206
+                            ),
2207
+                            startPoint: .init(x: 0.5, y: 0.08),
2208
+                            endPoint: .init(x: 0.5, y: 0.92)
2209
+                        )
2210
+                    )
1323 2211
             } else {
1324 2212
                 self.path( geometry: geometry )
1325 2213
                     .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))