From de0620e9031d5d3be35b84adece2a637837ecbd0 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Thu, 6 Mar 2025 21:56:12 -0800 Subject: [PATCH 01/13] Fixed loading issues that appeared in deployment --- extras/log_file_client/lib/utils/http_client.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extras/log_file_client/lib/utils/http_client.dart b/extras/log_file_client/lib/utils/http_client.dart index d375e011..addef817 100644 --- a/extras/log_file_client/lib/utils/http_client.dart +++ b/extras/log_file_client/lib/utils/http_client.dart @@ -189,7 +189,7 @@ abstract class HttpClient { // Filter out header and empty rows logTable.removeWhere((row) => row.isEmpty); - logTable.removeWhere((row) => row[0].substring(0, 1) != 'v'); + logTable.removeWhere((row) => row[0].toString().substring(0, 1) != 'v'); // Parse the date strings for (int i = 0; i < logTable.length; i++) { @@ -255,7 +255,7 @@ class HttpClientProd extends HttpClient { Future fetchData(String filePath) async { Uri url; if (Uri.base.toString() == 'https://oap.cs.wallawalla.edu/') { - url = Uri.https('oap.cs.wallawalla.edu/', filePath); + url = Uri.https('oap.cs.wallawalla.edu', filePath); } else { url = Uri.http('localhost:8080', filePath); } From 308c5fefc23f2bad51b13a494287534728709c24 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Wed, 12 Mar 2025 16:30:43 -0700 Subject: [PATCH 02/13] Dates are displayed --- .../lib/components/graph_view.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index 6ceb2fb0..fe9d3932 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:log_file_client/components/chart_series_selector.dart'; import 'package:log_file_client/components/time_range_selector.dart'; import 'package:log_file_client/utils/http_client.dart'; @@ -26,6 +27,7 @@ class _GraphViewState extends State { max(widget.logData.length - 1400, 0), widget.logData.length, ]; // 1 day + late DateTimeIntervalType timeIntervalType; @override void initState() { @@ -34,6 +36,7 @@ class _GraphViewState extends State { start: widget.logData.first.time, end: widget.logData.last.time, ); + calculateTimeInterval(); } void toggleSeriesView(int index) { @@ -70,9 +73,18 @@ class _GraphViewState extends State { final int startIndex = endIndex - customRange.duration.inMinutes; _displayedTimeRange = [startIndex, endIndex]; } + calculateTimeInterval(); }); } + void calculateTimeInterval() { + if (_displayedTimeRange[1] - _displayedTimeRange[0] < 1440) { + timeIntervalType = DateTimeIntervalType.hours; + } else { + timeIntervalType = DateTimeIntervalType.days; + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -143,8 +155,9 @@ class _GraphViewState extends State { backgroundColor: Colors.white, primaryXAxis: DateTimeAxis( title: AxisTitle(text: 'Time'), - intervalType: DateTimeIntervalType.hours, + intervalType: timeIntervalType, interval: 1, + dateFormat: DateFormat('MMM d hh:mm a'), ), primaryYAxis: NumericAxis( name: 'pHAxis', From 448acd3ec9c87d5a8a8d759b971d29166b69265e Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Wed, 12 Mar 2025 16:43:16 -0700 Subject: [PATCH 03/13] Changed line plot to scatter plot - to not show data where it doesn't exist --- .../lib/components/graph_view.dart | 14 ++++++++++---- extras/log_file_client/test/main_test.dart | 16 ++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index fe9d3932..68ef261a 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -179,8 +179,10 @@ class _GraphViewState extends State { } List _chartSeries(List logData) { + final MarkerSettings markerSettings = MarkerSettings(height: 2, width: 2); + return [ - LineSeries( + ScatterSeries( legendItemText: 'pH', name: 'pH', dataSource: logData, @@ -189,8 +191,9 @@ class _GraphViewState extends State { color: _showPH ? Colors.green : Colors.transparent, yAxisName: 'pHAxis', animationDuration: 0, + markerSettings: markerSettings, ), - LineSeries( + ScatterSeries( legendItemText: 'pH setpoint', name: 'pH setpoint', dataSource: logData, @@ -199,8 +202,9 @@ class _GraphViewState extends State { color: _showPH ? Colors.green.shade800 : Colors.transparent, yAxisName: 'pHAxis', animationDuration: 0, + markerSettings: markerSettings, ), - LineSeries( + ScatterSeries( legendItemText: 'temp', name: 'temp', dataSource: logData, @@ -209,8 +213,9 @@ class _GraphViewState extends State { color: _showTemp ? Colors.blue : Colors.transparent, yAxisName: 'TemperatureAxis', animationDuration: 0, + markerSettings: markerSettings, ), - LineSeries( + ScatterSeries( legendItemText: 'temp setpoint', name: 'temp setpoint', dataSource: logData, @@ -219,6 +224,7 @@ class _GraphViewState extends State { color: _showTemp ? Colors.blue.shade800 : Colors.transparent, yAxisName: 'TemperatureAxis', animationDuration: 0, + markerSettings: markerSettings, ), ]; } diff --git a/extras/log_file_client/test/main_test.dart b/extras/log_file_client/test/main_test.dart index 05f03263..e1e678fd 100644 --- a/extras/log_file_client/test/main_test.dart +++ b/extras/log_file_client/test/main_test.dart @@ -231,14 +231,14 @@ void main() { expect(find.byType(GraphView), findsOneWidget); expect(find.byType(SfCartesianChart), findsOneWidget); - // Verify that the chart contains the correct line series for temperature and pH + // Verify that the chart contains the correct series for temperature and pH expect(find.text('temp'), findsOneWidget); expect(find.text('pH'), findsOneWidget); - // Check for LineSeries widget presence + // Check for ScatterSeries widget presence expect( find.byWidgetPredicate( - (widget) => widget is LineSeries, + (widget) => widget is ScatterSeries, ), findsNWidgets(4), ); @@ -262,10 +262,10 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(ChartSeriesSelector), findsOneWidget); - // Check that ChartSeriesSelector removes line series + // Check that ChartSeriesSelector removes series expect( find.byWidgetPredicate( - (widget) => widget is LineSeries && widget.initialIsVisible, + (widget) => widget is ScatterSeries && widget.initialIsVisible, ), findsNWidgets(4), ); @@ -275,17 +275,17 @@ void main() { expect( find.byWidgetPredicate( - (widget) => widget is LineSeries && widget.color != Colors.transparent, + (widget) => widget is ScatterSeries && widget.color != Colors.transparent, ), findsNWidgets(2), ); - // Check that ChartSeriesSelector adds line series + // Check that ChartSeriesSelector adds series await tester.tap(find.text('pH')); await tester.pumpAndSettle(); expect( find.byWidgetPredicate( - (widget) => widget is LineSeries && widget.color != Colors.transparent, + (widget) => widget is ScatterSeries && widget.color != Colors.transparent, ), findsNWidgets(4), ); From 5c0aab7a5dd05ece927116c44405e6c8fdd0e8e5 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Thu, 27 Mar 2025 11:14:25 -0700 Subject: [PATCH 04/13] Hid tooltip info about hidden ph/temp --- extras/log_file_client/lib/components/graph_view.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index 68ef261a..0f991354 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -78,7 +78,7 @@ class _GraphViewState extends State { } void calculateTimeInterval() { - if (_displayedTimeRange[1] - _displayedTimeRange[0] < 1440) { + if (_displayedTimeRange[1] - _displayedTimeRange[0] <= 1440) { timeIntervalType = DateTimeIntervalType.hours; } else { timeIntervalType = DateTimeIntervalType.days; @@ -192,6 +192,7 @@ class _GraphViewState extends State { yAxisName: 'pHAxis', animationDuration: 0, markerSettings: markerSettings, + enableTrackball: _showPH, ), ScatterSeries( legendItemText: 'pH setpoint', @@ -203,6 +204,7 @@ class _GraphViewState extends State { yAxisName: 'pHAxis', animationDuration: 0, markerSettings: markerSettings, + enableTrackball: _showPH, ), ScatterSeries( legendItemText: 'temp', @@ -214,6 +216,7 @@ class _GraphViewState extends State { yAxisName: 'TemperatureAxis', animationDuration: 0, markerSettings: markerSettings, + enableTrackball: _showTemp, ), ScatterSeries( legendItemText: 'temp setpoint', @@ -225,6 +228,7 @@ class _GraphViewState extends State { yAxisName: 'TemperatureAxis', animationDuration: 0, markerSettings: markerSettings, + enableTrackball: _showTemp, ), ]; } From bbfae25180b3ec148637c26de3c34570217e9d46 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Mon, 31 Mar 2025 19:21:22 -0700 Subject: [PATCH 05/13] Improved layout on mobile --- .../lib/components/page_header.dart | 11 +++--- .../lib/components/tank_card.dart | 4 +-- .../log_file_client/lib/pages/home_page.dart | 35 ++++++++++--------- .../lib/pages/project_page.dart | 18 ++++------ 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/extras/log_file_client/lib/components/page_header.dart b/extras/log_file_client/lib/components/page_header.dart index 4a07eaed..c203af97 100644 --- a/extras/log_file_client/lib/components/page_header.dart +++ b/extras/log_file_client/lib/components/page_header.dart @@ -7,12 +7,15 @@ class PageHeader extends StatelessWidget { @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; + final double fontSize = screenWidth >= 500 ? 60 : 40; + final double sideMargins = screenWidth >= 500 ? 135 : 40; + final double topMargin = screenWidth >= 500 ? 40 : 30; return Container( margin: EdgeInsets.only( - top: 40, - left: 135, - right: 135, + top: topMargin, + left: sideMargins, + right: sideMargins, ), padding: EdgeInsets.only(bottom: 16), width: screenWidth, @@ -27,7 +30,7 @@ class PageHeader extends StatelessWidget { child: Text( text, style: TextStyle( - fontSize: 60, + fontSize: fontSize, letterSpacing: -2, color: const Color(0xFF0C2D48), ), diff --git a/extras/log_file_client/lib/components/tank_card.dart b/extras/log_file_client/lib/components/tank_card.dart index e5303dfe..650806ce 100644 --- a/extras/log_file_client/lib/components/tank_card.dart +++ b/extras/log_file_client/lib/components/tank_card.dart @@ -33,7 +33,7 @@ class TankCard extends StatelessWidget { onTap: onTap, child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { - final double cardWidth = constraints.maxWidth * 0.93; + final double cardWidth = constraints.maxWidth; final double titleFontSize = 20; final double tankInfoFontSize = 16; final double tankInfoHeaderFontSize = 14; @@ -101,7 +101,7 @@ class TankCard extends StatelessWidget { AsyncSnapshot? snapshot, ) { return SizedBox( - height: cardWidth * 0.6, + height: cardWidth * 0.53, child: ClipRRect( borderRadius: BorderRadius.vertical( top: Radius.circular(20), diff --git a/extras/log_file_client/lib/pages/home_page.dart b/extras/log_file_client/lib/pages/home_page.dart index ace8942f..4548fc3c 100644 --- a/extras/log_file_client/lib/pages/home_page.dart +++ b/extras/log_file_client/lib/pages/home_page.dart @@ -58,9 +58,6 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - final gridCrossAxis = screenWidth > 750 ? 3 : 2; - return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, @@ -73,7 +70,7 @@ class _HomePageState extends State { child: Column( children: [ PageHeader(text: 'Projects'), - _projectCards(gridCrossAxis), + _projectCards(), ], ), ), @@ -103,9 +100,13 @@ class _HomePageState extends State { // ); // } - Widget _projectCards(int gridCrossAxis) { + Widget _projectCards() { + final screenWidth = MediaQuery.of(context).size.width; + final int gridCrossAxis = screenWidth > 800 ? 3 : screenWidth > 500 ? 2 : 1; + final double sideMargins = screenWidth > 500 ? 100 : 40; + return _isLoading - ? _skeletonLoader(gridCrossAxis) + ? _skeletonLoader(gridCrossAxis, sideMargins) : Expanded( child: GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -118,16 +119,12 @@ class _HomePageState extends State { onTap: () => unawaited(openProject(_projectList![index])), ); }, - padding: EdgeInsets.only( - left: 100, - right: 100, - top: 16, - ), + padding: _gridViewPadding(sideMargins), ), ); } - Widget _skeletonLoader(int gridCrossAxis) { + Widget _skeletonLoader(int gridCrossAxis, double sideMargins) { return Expanded( child: Skeletonizer( effect: ShimmerEffect( @@ -146,13 +143,17 @@ class _HomePageState extends State { onTap: () {}, ); }, - padding: EdgeInsets.only( - left: 100, - right: 100, - top: 16, - ), + padding: _gridViewPadding(sideMargins), ), ), ); } + + EdgeInsets _gridViewPadding(sideMargins) { + return EdgeInsets.only( + left: sideMargins, + right: sideMargins, + top: 16, + ); + } } diff --git a/extras/log_file_client/lib/pages/project_page.dart b/extras/log_file_client/lib/pages/project_page.dart index de74a033..91eefaff 100644 --- a/extras/log_file_client/lib/pages/project_page.dart +++ b/extras/log_file_client/lib/pages/project_page.dart @@ -37,14 +37,8 @@ class _ProjectPageState extends State { @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; - final int gridCrossAxis; - if (screenWidth > 1370) { - gridCrossAxis = 3; - } else if (screenWidth > 980) { - gridCrossAxis = 2; - } else { - gridCrossAxis = 1; - } + final int gridCrossAxis = screenWidth > 1024 ? 3 : screenWidth > 500 ? 2 : 1; + final double sideMargins = screenWidth > 500 ? 100 : 10; return Scaffold( appBar: AppBar( @@ -55,14 +49,14 @@ class _ProjectPageState extends State { child: Column( children: [ PageHeader(text: '${widget.project.name} Tanks'), - _tankCards(gridCrossAxis), + _tankCards(gridCrossAxis, sideMargins), ], ), ), ); } - Expanded _tankCards(int gridCrossAxis) { + Expanded _tankCards(int gridCrossAxis, double sideMargins){ return Expanded( child: GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -77,8 +71,8 @@ class _ProjectPageState extends State { ); }, padding: EdgeInsets.only( - left: 100, - right: 100, + left: sideMargins, + right: sideMargins, top: 16, ), ), From e41099e9b12409c35c792d2c8bda19b8fa17cf4d Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Mon, 31 Mar 2025 20:58:34 -0700 Subject: [PATCH 06/13] Server API looks at timestamps instead of just counting lines --- .../log_file_server/bin/log_file_server.dart | 34 ++++++++-- extras/log_file_server/pubspec.lock | 16 +++++ extras/log_file_server/pubspec.yaml | 1 + extras/log_file_server/test/server_test.dart | 66 +++++++++++++++++++ 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/extras/log_file_server/bin/log_file_server.dart b/extras/log_file_server/bin/log_file_server.dart index 7109e630..8743c697 100644 --- a/extras/log_file_server/bin/log_file_server.dart +++ b/extras/log_file_server/bin/log_file_server.dart @@ -2,6 +2,7 @@ import 'dart:async' show Future; import 'dart:io'; import 'package:csv/csv.dart'; +import 'package:intl/intl.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart'; import 'package:shelf_router/shelf_router.dart'; @@ -37,12 +38,8 @@ Future _get(Request req, String path) async { const CsvToListConverter(fieldDelimiter: '\t', eol: '\n').convert(body); logTable.removeWhere((row) => row[2] != 'I'); - // Remove old lines - if (!(logTable.isEmpty || logTable.length < snapshotLength)) { - logTable.removeRange(0, logTable.length - snapshotLength); - } - - // Condense to granularity and return + // Condense to time range and granularity + logTable = trimToTimeRange(logTable, snapshotLength, DateTime.now()); logTable = condenseToGranularity(logTable, snapshotGranularity); final finalBody = const ListToCsvConverter(fieldDelimiter: '\t', eol: '\n') .convert(logTable); @@ -78,6 +75,31 @@ Future _post(Request req, String path) async { return Response.ok(null); } +List trimToTimeRange( + List logTable, + int snapshotLength, + DateTime now, +) { + if (logTable.isEmpty || logTable[0].isEmpty) { + return []; + } + + final String targetTime = DateFormat('yyyy-MM-dd HH:mm:ss') + .format(now.subtract(Duration(minutes: snapshotLength))); + + // Find the start index of the log entries that are within the time range + int startIndex = 0; + for (int i = logTable.length - 1; i >= 0; i--) { + final rowTime = logTable[i][3] as String; + if (rowTime.compareTo(targetTime) < 0) { + startIndex = i + 1; + break; + } + } + + return logTable.sublist(startIndex); +} + List condenseToGranularity(List logTable, int snapshotGranularity) { if (snapshotGranularity > 1) { final List condensedLogTable = []; diff --git a/extras/log_file_server/pubspec.lock b/extras/log_file_server/pubspec.lock index 1b67e394..ecf0e291 100644 --- a/extras/log_file_server/pubspec.lock +++ b/extras/log_file_server/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" collection: dependency: transitive description: @@ -137,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: diff --git a/extras/log_file_server/pubspec.yaml b/extras/log_file_server/pubspec.yaml index f3ec8094..bf536afe 100644 --- a/extras/log_file_server/pubspec.yaml +++ b/extras/log_file_server/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: args: ^2.3.0 csv: ^6.0.0 + intl: ^0.20.2 shelf: ^1.4.0 shelf_router: ^1.1.0 diff --git a/extras/log_file_server/test/server_test.dart b/extras/log_file_server/test/server_test.dart index 8cc3e1e7..a78ae6eb 100644 --- a/extras/log_file_server/test/server_test.dart +++ b/extras/log_file_server/test/server_test.dart @@ -33,6 +33,72 @@ void main() { expect(logTable.length, 10); // 10 lines }); + test('trimToTimeRange trims normal list', () async { + final testList = [ + ['v0', 0, 'I', '2023-10-01 00:00:00'], + ['v0', 0, 'I', '2023-10-01 00:01:00'], + ['v0', 0, 'I', '2023-10-01 00:02:00'], + ['v0', 0, 'I', '2023-10-01 00:03:00'], + ['v0', 0, 'I', '2023-10-01 00:04:00'], + ['v0', 0, 'I', '2023-10-01 00:05:00'], + ['v0', 0, 'I', '2023-10-01 00:06:00'], + ['v0', 0, 'I', '2023-10-01 00:07:00'], + ['v0', 0, 'I', '2023-10-01 00:08:00'], + ['v0', 0, 'I', '2023-10-01 00:09:00'], + ]; + + final newList = + trimToTimeRange(testList, 5, DateTime.parse('2023-10-01 00:10:00')); + + expect(newList.length, 5); + expect(newList[0][3], '2023-10-01 00:05:00'); + expect(newList[4][3], '2023-10-01 00:09:00'); + }); + + test('trimToTimeRange trims empty list', () async { + final testList = [[]]; + + final newList = + trimToTimeRange(testList, 5, DateTime.parse('2023-10-01 00:10:00')); + + expect(newList.length, 0); + }); + + test('trimToTimeRange trims list shorter than target range', () async { + final testList = [ + ['v0', 0, 'I', '2023-10-01 00:07:00'], + ['v0', 0, 'I', '2023-10-01 00:08:00'], + ['v0', 0, 'I', '2023-10-01 00:09:00'], + ]; + + final newList = + trimToTimeRange(testList, 5, DateTime.parse('2023-10-01 00:10:00')); + + expect(newList.length, 3); + expect(newList[0][3], '2023-10-01 00:07:00'); + expect(newList[2][3], '2023-10-01 00:09:00'); + }); + + test('trimToTimeRange trims list with missing minutes', () async { + final testList = [ + ['v0', 0, 'I', '2023-10-01 00:00:00'], + ['v0', 0, 'I', '2023-10-01 00:01:00'], + ['v0', 0, 'I', '2023-10-01 00:02:00'], + ['v0', 0, 'I', '2023-10-01 00:03:00'], + ['v0', 0, 'I', '2023-10-01 00:04:00'], + ['v0', 0, 'I', '2023-10-01 00:05:00'], + ['v0', 0, 'I', '2023-10-01 00:06:00'], + ['v0', 0, 'I', '2023-10-01 00:09:00'], + ]; + + final newList = + trimToTimeRange(testList, 5, DateTime.parse('2023-10-01 00:10:00')); + + expect(newList.length, 3); + expect(newList[0][3], '2023-10-01 00:05:00'); + expect(newList[2][3], '2023-10-01 00:09:00'); + }); + test('condenseToGranularity', () async { final testList = [ ['2023-10-01 00:00:00', 'I', 'test'], From 3bf70bc8e1832c0a7b814a992d1cfd56a5d2fdca Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Fri, 4 Apr 2025 19:56:36 -0700 Subject: [PATCH 07/13] If no data for past 12 hours, no thumbnail is displayed --- .../lib/components/tank_card.dart | 12 +++--- .../lib/components/tank_thumbnail.dart | 37 +++++++++++-------- extras/log_file_client/test/main_test.dart | 30 ++++++++++++++- extras/log_file_server/test/server_test.dart | 19 ++++++++++ 4 files changed, 74 insertions(+), 24 deletions(-) diff --git a/extras/log_file_client/lib/components/tank_card.dart b/extras/log_file_client/lib/components/tank_card.dart index 650806ce..1ef79eeb 100644 --- a/extras/log_file_client/lib/components/tank_card.dart +++ b/extras/log_file_client/lib/components/tank_card.dart @@ -241,18 +241,18 @@ class TankCard extends StatelessWidget { String textData = 'mock'; if (mode == TankInfoMode.current) { if (type == TankInfoType.pH) { - textData = 'pH ${snapshot?.data!.pH ?? 'mock'}'; + textData = 'pH ${snapshot?.data!.pH ?? '----'}'; } else { - textData = '${snapshot?.data!.temperature ?? 'mock'}°C'; + textData = '${snapshot?.data!.temperature ?? '----'}°C'; } } else if (mode == TankInfoMode.range) { if (type == TankInfoType.pH) { - final min = snapshot?.data!.minPH ?? 'm.k'; - final max = snapshot?.data!.maxPH ?? 'm.k'; + final min = snapshot?.data!.minPH ?? '---'; + final max = snapshot?.data!.maxPH ?? '---'; textData = 'pH $min - $max'; } else { - final min = snapshot?.data!.minTemp ?? 'mk'; - final max = snapshot?.data!.maxTemp ?? 'mk'; + final min = snapshot?.data!.minTemp ?? '--'; + final max = snapshot?.data!.maxTemp ?? '--'; textData = '$min - $max°C'; } } diff --git a/extras/log_file_client/lib/components/tank_thumbnail.dart b/extras/log_file_client/lib/components/tank_thumbnail.dart index f751037f..4bfc7716 100644 --- a/extras/log_file_client/lib/components/tank_thumbnail.dart +++ b/extras/log_file_client/lib/components/tank_thumbnail.dart @@ -9,15 +9,28 @@ class TankThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - _graph(_pHSeries(), 'pHAxis'), - _graph(_tempSeries(), 'TemperatureAxis'), - ], - ); + if (snapshot.latestData.isEmpty) { + return Center( + child: Text( + 'No data available within past 12 hrs', + style: TextStyle(color: Colors.grey.shade600, fontSize: 16), + textDirection: TextDirection.ltr, + ), + ); + } else { + return Column( + children: [ + _graph(_pHSeries(), 'pHAxis'), + _graph(_tempSeries(), 'TemperatureAxis'), + ], + ); + } } Widget _graph(series, String axis) { + final double setpoint = + axis == 'pHAxis' ? snapshot.pHSetpoint! : snapshot.temperatureSetpoint!; + return Expanded( child: SfCartesianChart( backgroundColor: Colors.white, @@ -26,19 +39,11 @@ class TankThumbnail extends StatelessWidget { intervalType: DateTimeIntervalType.hours, interval: 6, labelStyle: TextStyle(color: Colors.grey.shade700), - // isVisible: !(axis == 'pHAxis'), ), primaryYAxis: NumericAxis( name: axis, - // title: axis == 'pHAxis' - // ? const AxisTitle(text: 'pH') - // : const AxisTitle(text: 'temp'), - minimum: axis == 'pHAxis' - ? snapshot.pHSetpoint! - 0.5 - : snapshot.temperatureSetpoint! - 0.5, - maximum: axis == 'pHAxis' - ? snapshot.pHSetpoint! + 0.5 - : snapshot.temperatureSetpoint! + 0.5, + minimum: setpoint - 0.5, + maximum: setpoint + 0.5, anchorRangeToVisiblePoints: false, labelStyle: TextStyle(color: Colors.grey.shade700), ), diff --git a/extras/log_file_client/test/main_test.dart b/extras/log_file_client/test/main_test.dart index e1e678fd..d4b104d5 100644 --- a/extras/log_file_client/test/main_test.dart +++ b/extras/log_file_client/test/main_test.dart @@ -114,6 +114,30 @@ void main() { // Verify that the TankThumbnail widget is displayed expect(find.byType(TankThumbnail), findsOneWidget); }); + + testWidgets('TankThumbnail is able to build from empty snapshot', + (WidgetTester tester) async { + await tester.pumpWidget( + TankThumbnail( + snapshot: TankSnapshot( + Log('test', 'test.log'), + [], + null, + null, + null, + null, + null, + null, + null, + null, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(TankThumbnail), findsOneWidget); + expect(find.text('No data available within past 12 hrs'), findsOneWidget); + }); testWidgets('TankCard opens graph when selected', (WidgetTester tester) async { tester.view.physicalSize = const Size(1920, 1080); @@ -275,7 +299,8 @@ void main() { expect( find.byWidgetPredicate( - (widget) => widget is ScatterSeries && widget.color != Colors.transparent, + (widget) => + widget is ScatterSeries && widget.color != Colors.transparent, ), findsNWidgets(2), ); @@ -285,7 +310,8 @@ void main() { await tester.pumpAndSettle(); expect( find.byWidgetPredicate( - (widget) => widget is ScatterSeries && widget.color != Colors.transparent, + (widget) => + widget is ScatterSeries && widget.color != Colors.transparent, ), findsNWidgets(4), ); diff --git a/extras/log_file_server/test/server_test.dart b/extras/log_file_server/test/server_test.dart index a78ae6eb..e3bc004a 100644 --- a/extras/log_file_server/test/server_test.dart +++ b/extras/log_file_server/test/server_test.dart @@ -99,6 +99,25 @@ void main() { expect(newList[2][3], '2023-10-01 00:09:00'); }); + test('trimToTimeRange returns empty list when last data is too old', + () async { + final testList = [ + ['v0', 0, 'I', '2023-10-01 00:00:00'], + ['v0', 0, 'I', '2023-10-01 00:01:00'], + ['v0', 0, 'I', '2023-10-01 00:02:00'], + ['v0', 0, 'I', '2023-10-01 00:03:00'], + ['v0', 0, 'I', '2023-10-01 00:04:00'], + ['v0', 0, 'I', '2023-10-01 00:05:00'], + ['v0', 0, 'I', '2023-10-01 00:06:00'], + ['v0', 0, 'I', '2023-10-01 00:09:00'], + ]; + + final newList = + trimToTimeRange(testList, 5, DateTime.parse('2023-10-01 00:20:00')); + + expect(newList, equals([])); + }); + test('condenseToGranularity', () async { final testList = [ ['2023-10-01 00:00:00', 'I', 'test'], From 89739428ee595c23ed96b7b467ab04c02b6bc5fb Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Fri, 4 Apr 2025 20:19:42 -0700 Subject: [PATCH 08/13] "now" is a parameter for easier testing --- extras/log_file_client/lib/components/tank_thumbnail.dart | 6 +++++- extras/log_file_client/test/main_test.dart | 2 ++ extras/log_file_server/bin/log_file_server.dart | 5 ++++- extras/log_file_server/test/server_test.dart | 4 ++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/extras/log_file_client/lib/components/tank_thumbnail.dart b/extras/log_file_client/lib/components/tank_thumbnail.dart index 4bfc7716..8c1b5965 100644 --- a/extras/log_file_client/lib/components/tank_thumbnail.dart +++ b/extras/log_file_client/lib/components/tank_thumbnail.dart @@ -3,9 +3,11 @@ import 'package:log_file_client/utils/http_client.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; class TankThumbnail extends StatelessWidget { - const TankThumbnail({required this.snapshot, super.key}); + TankThumbnail({required this.snapshot, DateTime? now, super.key}) + : now = now ?? DateTime.now(); final TankSnapshot snapshot; + final DateTime now; @override Widget build(BuildContext context) { @@ -39,6 +41,8 @@ class TankThumbnail extends StatelessWidget { intervalType: DateTimeIntervalType.hours, interval: 6, labelStyle: TextStyle(color: Colors.grey.shade700), + maximum: now, + minimum: now.subtract(const Duration(hours: 12)), ), primaryYAxis: NumericAxis( name: axis, diff --git a/extras/log_file_client/test/main_test.dart b/extras/log_file_client/test/main_test.dart index d4b104d5..1f1730f7 100644 --- a/extras/log_file_client/test/main_test.dart +++ b/extras/log_file_client/test/main_test.dart @@ -52,6 +52,8 @@ void main() { testWidgets('ProjectCard opens project page when selected', (WidgetTester tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; await tester.pumpWidget( MaterialApp( home: HomePage( diff --git a/extras/log_file_server/bin/log_file_server.dart b/extras/log_file_server/bin/log_file_server.dart index 8743c697..7fbb81ea 100644 --- a/extras/log_file_server/bin/log_file_server.dart +++ b/extras/log_file_server/bin/log_file_server.dart @@ -26,6 +26,9 @@ Future _get(Request req, String path) async { final snapshotGranularity = uri.queryParameters['granularity'] == null ? 5 : int.parse(uri.queryParameters['granularity']!); + final now = uri.queryParameters['now'] == null + ? DateTime.now() + : DateTime.tryParse(uri.queryParameters['now']!) ?? DateTime.now(); if (!file.existsSync()) { return Response.notFound(null); @@ -39,7 +42,7 @@ Future _get(Request req, String path) async { logTable.removeWhere((row) => row[2] != 'I'); // Condense to time range and granularity - logTable = trimToTimeRange(logTable, snapshotLength, DateTime.now()); + logTable = trimToTimeRange(logTable, snapshotLength, now); logTable = condenseToGranularity(logTable, snapshotGranularity); final finalBody = const ListToCsvConverter(fieldDelimiter: '\t', eol: '\n') .convert(logTable); diff --git a/extras/log_file_server/test/server_test.dart b/extras/log_file_server/test/server_test.dart index e3bc004a..41fb9536 100644 --- a/extras/log_file_server/test/server_test.dart +++ b/extras/log_file_server/test/server_test.dart @@ -10,7 +10,7 @@ void main() { // Start the server in a different shell before running tests test('Get snapshot from /api/snapshotTest.log', () async { - final response = await get(Uri.parse('$host/api/snapshotTest.log')); + final response = await get(Uri.parse('$host/api/snapshotTest.log?now=2025-01-09T16:05:00')); expect(response.statusCode, 200); final logTable = const CsvToListConverter(fieldDelimiter: '\t', eol: '\n') @@ -22,7 +22,7 @@ void main() { test('Get snapshot using parameters', () async { final response = await get( - Uri.parse('$host/api/snapshotTest.log?length=10&granularity=1'), + Uri.parse('$host/api/snapshotTest.log?now=2025-01-09T16:05:00&length=10&granularity=1'), ); expect(response.statusCode, 200); From 24f0498d8a24748804146d731d1913ef694391a4 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Fri, 4 Apr 2025 20:21:45 -0700 Subject: [PATCH 09/13] Formatting --- extras/log_file_client/lib/pages/home_page.dart | 6 +++++- extras/log_file_client/lib/pages/project_page.dart | 8 ++++++-- extras/log_file_server/test/server_test.dart | 6 ++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/extras/log_file_client/lib/pages/home_page.dart b/extras/log_file_client/lib/pages/home_page.dart index 4548fc3c..0b789698 100644 --- a/extras/log_file_client/lib/pages/home_page.dart +++ b/extras/log_file_client/lib/pages/home_page.dart @@ -102,7 +102,11 @@ class _HomePageState extends State { Widget _projectCards() { final screenWidth = MediaQuery.of(context).size.width; - final int gridCrossAxis = screenWidth > 800 ? 3 : screenWidth > 500 ? 2 : 1; + final int gridCrossAxis = screenWidth > 800 + ? 3 + : screenWidth > 500 + ? 2 + : 1; final double sideMargins = screenWidth > 500 ? 100 : 40; return _isLoading diff --git a/extras/log_file_client/lib/pages/project_page.dart b/extras/log_file_client/lib/pages/project_page.dart index 91eefaff..744ffab7 100644 --- a/extras/log_file_client/lib/pages/project_page.dart +++ b/extras/log_file_client/lib/pages/project_page.dart @@ -37,7 +37,11 @@ class _ProjectPageState extends State { @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; - final int gridCrossAxis = screenWidth > 1024 ? 3 : screenWidth > 500 ? 2 : 1; + final int gridCrossAxis = screenWidth > 1024 + ? 3 + : screenWidth > 500 + ? 2 + : 1; final double sideMargins = screenWidth > 500 ? 100 : 10; return Scaffold( @@ -56,7 +60,7 @@ class _ProjectPageState extends State { ); } - Expanded _tankCards(int gridCrossAxis, double sideMargins){ + Expanded _tankCards(int gridCrossAxis, double sideMargins) { return Expanded( child: GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( diff --git a/extras/log_file_server/test/server_test.dart b/extras/log_file_server/test/server_test.dart index 41fb9536..c5179dbf 100644 --- a/extras/log_file_server/test/server_test.dart +++ b/extras/log_file_server/test/server_test.dart @@ -10,7 +10,8 @@ void main() { // Start the server in a different shell before running tests test('Get snapshot from /api/snapshotTest.log', () async { - final response = await get(Uri.parse('$host/api/snapshotTest.log?now=2025-01-09T16:05:00')); + final response = await get( + Uri.parse('$host/api/snapshotTest.log?now=2025-01-09T16:05:00')); expect(response.statusCode, 200); final logTable = const CsvToListConverter(fieldDelimiter: '\t', eol: '\n') @@ -22,7 +23,8 @@ void main() { test('Get snapshot using parameters', () async { final response = await get( - Uri.parse('$host/api/snapshotTest.log?now=2025-01-09T16:05:00&length=10&granularity=1'), + Uri.parse( + '$host/api/snapshotTest.log?now=2025-01-09T16:05:00&length=10&granularity=1'), ); expect(response.statusCode, 200); From f3cc522f5c74ccccf9863e97e14696bd86b99a64 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Wed, 9 Apr 2025 10:35:46 -0700 Subject: [PATCH 10/13] Date axis format changes depending on time range --- .../lib/components/graph_view.dart | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index 0f991354..6c6f2467 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -26,8 +26,10 @@ class _GraphViewState extends State { late List _displayedTimeRange = [ max(widget.logData.length - 1400, 0), widget.logData.length, - ]; // 1 day + ]; // 1 day default late DateTimeIntervalType timeIntervalType; + late double timeInterval; + late DateFormat timeFormat; @override void initState() { @@ -36,7 +38,7 @@ class _GraphViewState extends State { start: widget.logData.first.time, end: widget.logData.last.time, ); - calculateTimeInterval(); + calculateTimeAxisFormat(); } void toggleSeriesView(int index) { @@ -73,15 +75,23 @@ class _GraphViewState extends State { final int startIndex = endIndex - customRange.duration.inMinutes; _displayedTimeRange = [startIndex, endIndex]; } - calculateTimeInterval(); + calculateTimeAxisFormat(); }); } - void calculateTimeInterval() { + void calculateTimeAxisFormat() { + timeInterval = 1; + if (_displayedTimeRange[1] - _displayedTimeRange[0] <= 1440) { timeIntervalType = DateTimeIntervalType.hours; + timeFormat = DateFormat('h a'); + } else if (_displayedTimeRange[1] - _displayedTimeRange[0] <= 4320) { + timeIntervalType = DateTimeIntervalType.days; + timeFormat = DateFormat('h a\nMMM d'); + timeInterval = 0.5; } else { timeIntervalType = DateTimeIntervalType.days; + timeFormat = DateFormat('MMM d'); } } @@ -156,8 +166,8 @@ class _GraphViewState extends State { primaryXAxis: DateTimeAxis( title: AxisTitle(text: 'Time'), intervalType: timeIntervalType, - interval: 1, - dateFormat: DateFormat('MMM d hh:mm a'), + interval: timeInterval, + dateFormat: timeFormat, ), primaryYAxis: NumericAxis( name: 'pHAxis', From fec105dca958d51083fe50171846aac2a2a10d96 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Wed, 9 Apr 2025 10:43:25 -0700 Subject: [PATCH 11/13] Trackball date formatting is independent of axis formatting --- .../lib/components/graph_view.dart | 112 +++++++++++++++++- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index 6c6f2467..902e680a 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -31,6 +31,8 @@ class _GraphViewState extends State { late double timeInterval; late DateFormat timeFormat; + final List _seriesColors = []; + @override void initState() { super.initState(); @@ -147,17 +149,14 @@ class _GraphViewState extends State { final trackballBehavior = TrackballBehavior( enable: true, tooltipDisplayMode: TrackballDisplayMode.groupAllPoints, + activationMode: ActivationMode.singleTap, markerSettings: TrackballMarkerSettings( markerVisibility: TrackballVisibilityMode.visible, ), - tooltipSettings: InteractiveTooltip( - enable: true, - format: 'series.name : point.y', - ), - activationMode: ActivationMode.singleTap, lineColor: Colors.grey.shade600, lineWidth: 1.5, lineDashArray: [2, 2], + builder: _trackballBuilder, ); return Expanded( @@ -188,6 +187,90 @@ class _GraphViewState extends State { ); } + Widget _trackballBuilder(BuildContext context, TrackballDetails details) { + final ThemeData themeData = Theme.of(context); + final TextStyle textStyle = themeData.textTheme.bodySmall!.copyWith( + color: themeData.colorScheme.onInverseSurface, + ); + final TextStyle headerStyle = textStyle.copyWith( + fontWeight: FontWeight.bold, + ); + + final String header = DateFormat('MMM d hh:mm a') + .format(details.groupingModeInfo?.points.first.x); + + final labels = ['pH', 'pH setpoint', 'temp', 'temp setpoint']; + final trackballLines = List.generate( + labels.length, + (i) => _trackballLine( + details.groupingModeInfo!.points[i], + labels[i], + i, + textStyle, + ), + ); + + return Container( + width: 155, + height: 106, + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Color(0xFF2F3036), + borderRadius: BorderRadius.circular(6), + boxShadow: [BoxShadow(color: Colors.grey, blurRadius: 2)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Text( + header, + style: headerStyle, + ), + ), + Divider( + height: 10.0, + thickness: 1, + color: themeData.colorScheme.onInverseSurface, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: trackballLines, + ), + ], + ), + ); + } + + Row _trackballLine( + CartesianChartPoint point, + String seriesName, + int seriesColor, + TextStyle textStyle, + ) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsetsDirectional.only(end: 3.0), + child: Container( + padding: EdgeInsets.all(2.0), + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _seriesColors[seriesColor], + ), + ), + ), + Text( + '$seriesName : ${point.y}', + style: textStyle, + ), + ], + ); + } + List _chartSeries(List logData) { final MarkerSettings markerSettings = MarkerSettings(height: 2, width: 2); @@ -203,6 +286,7 @@ class _GraphViewState extends State { animationDuration: 0, markerSettings: markerSettings, enableTrackball: _showPH, + onCreateRenderer: (series) => _ScatterSeriesRenderer(this), ), ScatterSeries( legendItemText: 'pH setpoint', @@ -215,6 +299,7 @@ class _GraphViewState extends State { animationDuration: 0, markerSettings: markerSettings, enableTrackball: _showPH, + onCreateRenderer: (series) => _ScatterSeriesRenderer(this), ), ScatterSeries( legendItemText: 'temp', @@ -227,6 +312,7 @@ class _GraphViewState extends State { animationDuration: 0, markerSettings: markerSettings, enableTrackball: _showTemp, + onCreateRenderer: (series) => _ScatterSeriesRenderer(this), ), ScatterSeries( legendItemText: 'temp setpoint', @@ -239,7 +325,23 @@ class _GraphViewState extends State { animationDuration: 0, markerSettings: markerSettings, enableTrackball: _showTemp, + onCreateRenderer: (series) => _ScatterSeriesRenderer(this), ), ]; } } + +class _ScatterSeriesRenderer + extends ScatterSeriesRenderer { + _ScatterSeriesRenderer(this._state); + + final _GraphViewState _state; + + @override + void customizeSegment(ChartSegment segment) { + super.customizeSegment(segment); + if (!_state._seriesColors.contains(segment.fillPaint.color)) { + _state._seriesColors.add(segment.fillPaint.color); + } + } +} From 73f014c7adec5c3ce14814299ab91a91378a5f8e Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Wed, 9 Apr 2025 15:54:14 -0700 Subject: [PATCH 12/13] Main graph view calculates time intervals based on datetime, not line number --- .../lib/components/graph_view.dart | 83 ++++++++++++------- .../log_file_client/lib/pages/graph_page.dart | 9 +- extras/log_file_client/test/main_test.dart | 5 +- extras/log_file_server/test/server_test.dart | 6 +- 4 files changed, 68 insertions(+), 35 deletions(-) diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index 902e680a..794597f1 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:log_file_client/components/chart_series_selector.dart'; @@ -10,10 +8,12 @@ import 'package:syncfusion_flutter_charts/charts.dart'; class GraphView extends StatefulWidget { const GraphView({ required this.logData, + this.now, super.key, }); final List logData; + final DateTime? now; @override State createState() => _GraphViewState(); @@ -22,11 +22,10 @@ class GraphView extends StatefulWidget { class _GraphViewState extends State { bool _showPH = true; bool _showTemp = true; + late final DateTime now; late final DateTimeRange avaliableTimeRange; - late List _displayedTimeRange = [ - max(widget.logData.length - 1400, 0), - widget.logData.length, - ]; // 1 day default + late DateTimeRange displayedTimeRange; + late List _displayedTimeRangeIndices; late DateTimeIntervalType timeIntervalType; late double timeInterval; late DateFormat timeFormat; @@ -36,10 +35,12 @@ class _GraphViewState extends State { @override void initState() { super.initState(); + now = widget.now ?? DateTime.now(); avaliableTimeRange = DateTimeRange( start: widget.logData.first.time, end: widget.logData.last.time, ); + toggleTimeRange(2, null); calculateTimeAxisFormat(); } @@ -54,40 +55,60 @@ class _GraphViewState extends State { } void toggleTimeRange(int index, DateTimeRange? customRange) { - final ranges = [ - 360, // 6 hours - 720, // 12 hours - 1440, // 1 day - 4320, // 3 days - 10080, // 7 days - 43200, // 30 days - 9999999, // Max + // index: 0-5 are predefined ranges, 6 is max, 7 is custom + final List durations = [ + Duration(hours: 6), + Duration(hours: 12), + Duration(days: 1), + Duration(days: 3), + Duration(days: 7), + Duration(days: 30), ]; + late DateTimeRange timeRange; + if (index < 6) { + timeRange = DateTimeRange( + start: now.subtract(durations[index]), + end: now, + ); + } else if (index == 6) { + timeRange = avaliableTimeRange; + } else { + timeRange = customRange!; + } + setState(() { - if (index < 7) { - _displayedTimeRange = [ - max(widget.logData.length - ranges[index], 0), - widget.logData.length, - ]; - } else if (index == 7) { - final int endOffset = - avaliableTimeRange.end.difference(customRange!.end).inMinutes; - final int endIndex = widget.logData.length - endOffset; - final int startIndex = endIndex - customRange.duration.inMinutes; - _displayedTimeRange = [startIndex, endIndex]; - } + displayedTimeRange = timeRange; + _displayedTimeRangeIndices = calculateTimeRange(timeRange); calculateTimeAxisFormat(); }); } + List calculateTimeRange(DateTimeRange range) { + int startIndex = 0; + int endIndex = widget.logData.length; + for (int i = widget.logData.length - 1; i >= 0; i--) { + if (widget.logData[i].time.compareTo(range.start) < 0) { + startIndex = i + 1; + break; + } + } + for (int i = widget.logData.length - 1; i >= 0; i--) { + if (widget.logData[i].time.compareTo(range.end) > 0) { + endIndex = i + 1; + break; + } + } + return [startIndex, endIndex]; + } + void calculateTimeAxisFormat() { timeInterval = 1; - if (_displayedTimeRange[1] - _displayedTimeRange[0] <= 1440) { + if (displayedTimeRange.duration <= Duration(hours: 24)) { timeIntervalType = DateTimeIntervalType.hours; timeFormat = DateFormat('h a'); - } else if (_displayedTimeRange[1] - _displayedTimeRange[0] <= 4320) { + } else if (displayedTimeRange.duration <= Duration(days: 3)) { timeIntervalType = DateTimeIntervalType.days; timeFormat = DateFormat('h a\nMMM d'); timeInterval = 0.5; @@ -107,8 +128,8 @@ class _GraphViewState extends State { _topRow(widget.logData), _graph( widget.logData.sublist( - _displayedTimeRange[0], - _displayedTimeRange[1], + _displayedTimeRangeIndices[0], + _displayedTimeRangeIndices[1], ), ), ], @@ -167,6 +188,8 @@ class _GraphViewState extends State { intervalType: timeIntervalType, interval: timeInterval, dateFormat: timeFormat, + minimum: displayedTimeRange.start, + maximum: displayedTimeRange.end, ), primaryYAxis: NumericAxis( name: 'pHAxis', diff --git a/extras/log_file_client/lib/pages/graph_page.dart b/extras/log_file_client/lib/pages/graph_page.dart index 849ffb13..e31bdca6 100644 --- a/extras/log_file_client/lib/pages/graph_page.dart +++ b/extras/log_file_client/lib/pages/graph_page.dart @@ -3,8 +3,14 @@ import 'package:log_file_client/components/graph_view.dart'; import 'package:log_file_client/utils/http_client.dart'; class GraphPage extends StatelessWidget { - const GraphPage({required this.log, required this.httpClient, super.key}); + const GraphPage({ + required this.log, + required this.httpClient, + this.now, + super.key, + }); + final DateTime? now; final HttpClient httpClient; final Log log; @@ -41,6 +47,7 @@ class GraphPage extends StatelessWidget { final logData = snapshot.data!; return GraphView( logData: logData, + now: now, ); } }, diff --git a/extras/log_file_client/test/main_test.dart b/extras/log_file_client/test/main_test.dart index 1f1730f7..90451f17 100644 --- a/extras/log_file_client/test/main_test.dart +++ b/extras/log_file_client/test/main_test.dart @@ -328,6 +328,7 @@ void main() { body: GraphPage( log: Log('sample_long.log', 'sample_long.log'), httpClient: HttpClientTest(), + now: DateTime(2025, 1, 24, 16, 33), ), ), ), @@ -340,8 +341,8 @@ void main() { // Check that 24H (or max) is shown by default await checkOption(tester, '24H', 1440, false); - // Check that selecting 6H shows 6H of data - await checkOption(tester, '6H', 360, true); + // Check that selecting 6H shows last 6H of data (missing minutes in test file add up to 222 lines over the last 6 hrs) + await checkOption(tester, '6H', 222, true); // Check that selecting Max shows all data await checkOption(tester, 'Max', 576, true); diff --git a/extras/log_file_server/test/server_test.dart b/extras/log_file_server/test/server_test.dart index c5179dbf..92d4c5bc 100644 --- a/extras/log_file_server/test/server_test.dart +++ b/extras/log_file_server/test/server_test.dart @@ -11,7 +11,8 @@ void main() { // Start the server in a different shell before running tests test('Get snapshot from /api/snapshotTest.log', () async { final response = await get( - Uri.parse('$host/api/snapshotTest.log?now=2025-01-09T16:05:00')); + Uri.parse('$host/api/snapshotTest.log?now=2025-01-09T16:05:00'), + ); expect(response.statusCode, 200); final logTable = const CsvToListConverter(fieldDelimiter: '\t', eol: '\n') @@ -24,7 +25,8 @@ void main() { test('Get snapshot using parameters', () async { final response = await get( Uri.parse( - '$host/api/snapshotTest.log?now=2025-01-09T16:05:00&length=10&granularity=1'), + '$host/api/snapshotTest.log?now=2025-01-09T16:05:00&length=10&granularity=1', + ), ); expect(response.statusCode, 200); From c9419c6090cf840f38d3f3d5350c0f78cb8ee6de Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Sun, 13 Apr 2025 17:46:48 -0700 Subject: [PATCH 13/13] Trackball works with series switched off --- .../lib/components/graph_view.dart | 138 +++++------------- extras/log_file_client/logs/index.html | 2 + 2 files changed, 37 insertions(+), 103 deletions(-) diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index 794597f1..8ce89a33 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -30,8 +30,6 @@ class _GraphViewState extends State { late double timeInterval; late DateFormat timeFormat; - final List _seriesColors = []; - @override void initState() { super.initState(); @@ -167,6 +165,7 @@ class _GraphViewState extends State { } Widget _graph(List logData) { + final List> trackballData = []; final trackballBehavior = TrackballBehavior( enable: true, tooltipDisplayMode: TrackballDisplayMode.groupAllPoints, @@ -177,7 +176,7 @@ class _GraphViewState extends State { lineColor: Colors.grey.shade600, lineWidth: 1.5, lineDashArray: [2, 2], - builder: _trackballBuilder, + // builder: _trackballBuilder, ); return Expanded( @@ -205,92 +204,44 @@ class _GraphViewState extends State { ), ], trackballBehavior: trackballBehavior, - series: _chartSeries(logData), - ), - ); - } + onTrackballPositionChanging: (TrackballArgs details) { + // Clear previously stored data on each update. + trackballData.clear(); - Widget _trackballBuilder(BuildContext context, TrackballDetails details) { - final ThemeData themeData = Theme.of(context); - final TextStyle textStyle = themeData.textTheme.bodySmall!.copyWith( - color: themeData.colorScheme.onInverseSurface, - ); - final TextStyle headerStyle = textStyle.copyWith( - fontWeight: FontWeight.bold, - ); + // Store the details for the current trackball position. + if (details.chartPointInfo.series != null && + details.chartPointInfo.chartPoint != null) { + final String seriesName = details.chartPointInfo.series!.name; + final DateTime date = + details.chartPointInfo.chartPoint!.x as DateTime; + final double yValue = + details.chartPointInfo.chartPoint!.y as double; - final String header = DateFormat('MMM d hh:mm a') - .format(details.groupingModeInfo?.points.first.x); + // Add this series' details to the trackballData list. + trackballData.add({ + 'seriesName': seriesName, + 'yValue': yValue, + 'date': date, + }); - final labels = ['pH', 'pH setpoint', 'temp', 'temp setpoint']; - final trackballLines = List.generate( - labels.length, - (i) => _trackballLine( - details.groupingModeInfo!.points[i], - labels[i], - i, - textStyle, - ), - ); + // Construct the tooltip dynamically from the stored data. + String tooltipText = ''; + String seriesText = ''; + for (final data in trackballData) { + seriesText += '${data['seriesName']} : ${data['yValue']}'; + } - return Container( - width: 155, - height: 106, - padding: EdgeInsets.all(8), - decoration: BoxDecoration( - color: Color(0xFF2F3036), - borderRadius: BorderRadius.circular(6), - boxShadow: [BoxShadow(color: Colors.grey, blurRadius: 2)], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Text( - header, - style: headerStyle, - ), - ), - Divider( - height: 10.0, - thickness: 1, - color: themeData.colorScheme.onInverseSurface, - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: trackballLines, - ), - ], - ), - ); - } + // Combine the series data into a compact format + tooltipText += seriesText; - Row _trackballLine( - CartesianChartPoint point, - String seriesName, - int seriesColor, - TextStyle textStyle, - ) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsetsDirectional.only(end: 3.0), - child: Container( - padding: EdgeInsets.all(2.0), - width: 10, - height: 10, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _seriesColors[seriesColor], - ), - ), - ), - Text( - '$seriesName : ${point.y}', - style: textStyle, - ), - ], + // Update the trackball tooltip information + details.chartPointInfo.label = tooltipText; + details.chartPointInfo.header = + DateFormat('MMM d hh:mm a').format(date); + } + }, + series: _chartSeries(logData), + ), ); } @@ -309,7 +260,6 @@ class _GraphViewState extends State { animationDuration: 0, markerSettings: markerSettings, enableTrackball: _showPH, - onCreateRenderer: (series) => _ScatterSeriesRenderer(this), ), ScatterSeries( legendItemText: 'pH setpoint', @@ -322,7 +272,6 @@ class _GraphViewState extends State { animationDuration: 0, markerSettings: markerSettings, enableTrackball: _showPH, - onCreateRenderer: (series) => _ScatterSeriesRenderer(this), ), ScatterSeries( legendItemText: 'temp', @@ -335,7 +284,6 @@ class _GraphViewState extends State { animationDuration: 0, markerSettings: markerSettings, enableTrackball: _showTemp, - onCreateRenderer: (series) => _ScatterSeriesRenderer(this), ), ScatterSeries( legendItemText: 'temp setpoint', @@ -348,23 +296,7 @@ class _GraphViewState extends State { animationDuration: 0, markerSettings: markerSettings, enableTrackball: _showTemp, - onCreateRenderer: (series) => _ScatterSeriesRenderer(this), ), ]; } } - -class _ScatterSeriesRenderer - extends ScatterSeriesRenderer { - _ScatterSeriesRenderer(this._state); - - final _GraphViewState _state; - - @override - void customizeSegment(ChartSegment segment) { - super.customizeSegment(segment); - if (!_state._seriesColors.contains(segment.fillPaint.color)) { - _state._seriesColors.add(segment.fillPaint.color); - } - } -} diff --git a/extras/log_file_client/logs/index.html b/extras/log_file_client/logs/index.html index a819d849..3ac95504 100644 --- a/extras/log_file_client/logs/index.html +++ b/extras/log_file_client/logs/index.html @@ -11,6 +11,8 @@

Index of /logs/

ProjectA-tank-24.log ProjectA-tank-70.log ProjectB-tank-58.log + fostja-tank-1.log + fostja-tank-2.log