diff --git a/lib/chart/base.dart b/lib/chart/base.dart index 1703b1f..ef164af 100644 --- a/lib/chart/base.dart +++ b/lib/chart/base.dart @@ -306,7 +306,8 @@ class ChartState extends WindowState { SeriesData? seriesData = DataCenter().getSeriesData(seriesInfo.id); if (seriesData == null) { // Data not loaded yet, skip this series silently - developer.log("Data not yet loaded for series '${seriesInfo.name}'", name: "rubin_chart.workspace"); + developer.log("Data not yet loaded for series '${seriesInfo.name}'", + name: "rubintv.chart.workspace"); continue; } @@ -315,11 +316,11 @@ class ChartState extends WindowState { } catch (e) { if (e is DataConversionException) { developer.log("Failed to convert series '${seriesInfo.name}': ${e.message}", - name: "rubin_chart.workspace"); + name: "rubintv.chart.workspace"); reportError("Chart rendering error: ${e.message}"); } else { developer.log("Unexpected error converting series '${seriesInfo.name}': $e", - name: "rubin_chart.workspace", error: e); + name: "rubintv.chart.workspace", error: e); } // Skip this series and continue with others } @@ -344,19 +345,28 @@ class ChartState extends WindowState { /// Create a [ChartState] from a JSON object. @override factory ChartState.fromJson(Map json) { - return ChartState( - id: UniqueId.fromString(json["id"]), - series: Map.fromEntries((json["series"] as List).map((e) { + try { + Map series = Map.fromEntries((json["series"] as List).map((e) { SeriesInfo seriesInfo = SeriesInfo.fromJson(e); return MapEntry(seriesInfo.id, seriesInfo); - })), - axisInfo: List.from(json["axisInfo"].map((e) => ChartAxisInfo.fromJson(e))), - legend: json["legend"] == null ? null : Legend.fromJson(json["legend"]), - useGlobalQuery: json["useGlobalQuery"], - windowType: WindowTypes.fromString(json["windowType"]), - tool: MultiSelectionTool.fromString(json["tool"]), - resetController: StreamController.broadcast(), - ); + })); + + return ChartState( + id: UniqueId.fromString(json["id"]), + series: series, + axisInfo: List.from(json["axisInfo"].map((e) => ChartAxisInfo.fromJson(e))), + legend: json["legend"] == null ? null : Legend.fromJson(json["legend"]), + useGlobalQuery: json["useGlobalQuery"], + windowType: WindowTypes.fromString(json["windowType"]), + tool: MultiSelectionTool.fromString(json["tool"]), + resetController: StreamController.broadcast(), + ); + } catch (e, stackTrace) { + developer.log("Error in ChartState.fromJson: $e", + name: "rubintv.chart.base", error: e, stackTrace: stackTrace); + developer.log("Full JSON: $json", name: "rubintv.chart.base"); + rethrow; + } } } @@ -404,8 +414,30 @@ class ChartBloc extends WindowBloc { add(ChartReceiveMessageEvent(message)); }); + /// Subscribe to selection controller to update when points are selected. + developer.log("Subscribing chart ${state.id} to selection controller", name: "rubintv.chart.base"); + ControlCenter().selectionController.subscribe(state.id, (Object? origin, Set dataPoints) { + if (origin == state.id) { + return; + } + developer.log("Processing selection update for chart ${state.id}", name: "rubintv.chart.base"); + // TODO: Handle selection update in chart + }); + + /// Subscribe to drill down controller. + developer.log("Subscribing chart ${state.id} to drill down controller", name: "rubintv.chart.base"); + ControlCenter().drillDownController.subscribe(state.id, (Object? origin, Set dataPoints) { + if (origin == state.id) { + return; + } + developer.log("Processing drill down update for chart ${state.id}", name: "rubintv.chart.base"); + // TODO: Handle drill down update in chart + }); + /// Reload the data if the global query or global dayObs changes. _globalQuerySubscription = ControlCenter().globalQueryStream.listen((GlobalQuery? query) { + developer.log("Chart ${state.id} received global query update: dayObs=${query?.dayObs}", + name: "rubintv.chart.base"); for (SeriesInfo series in state._series.values) { add(UpdateSeriesEvent( series: series, @@ -415,6 +447,8 @@ class ChartBloc extends WindowBloc { } }); + developer.log("Chart bloc created and subscribed to streams", name: "rubintv.chart.base"); + /// Change the selection tool. on((event, emit) { emit(state.copyWith(tool: event.tool)); @@ -513,25 +547,25 @@ class ChartBloc extends WindowBloc { int columns = event.message["content"]["data"].length; developer.log( "received $columns columns and $rows rows for ${event.message["requestId"]} in series $seriesId", - name: "rubin_chart.workspace"); + name: "rubintv.chart.workspace"); // Add validation to ensure series exists and data is not empty final seriesInfo = state.series[seriesId]; if (seriesInfo == null) { - developer.log("Series $seriesId not found in state", name: "rubin_chart.workspace"); + developer.log("Series $seriesId not found in state", name: "rubintv.chart.workspace"); return; } final data = event.message["content"]["data"] as Map?; if (data == null || data.isEmpty) { - developer.log("Received empty data for series $seriesId", name: "rubin_chart.workspace"); + developer.log("Received empty data for series $seriesId", name: "rubintv.chart.workspace"); return; } // Validate that all expected columns are present final plotColumns = List.from(event.message["content"]["columns"].map((e) => e)); if (plotColumns.isEmpty) { - developer.log("No plot columns received for series $seriesId", name: "rubin_chart.workspace"); + developer.log("No plot columns received for series $seriesId", name: "rubintv.chart.workspace"); return; } @@ -566,12 +600,12 @@ class ChartBloc extends WindowBloc { )); } } else { - developer.log("Could not find pending series $seriesId", name: "rubin_chart.workspace"); - developer.log("Pending series: ${_pendingRowCountChecks.keys}", name: "rubin_chart.workspace"); + developer.log("Could not find pending series $seriesId", name: "rubintv.chart.workspace"); + developer.log("Pending series: ${_pendingRowCountChecks.keys}", name: "rubintv.chart.workspace"); } } catch (e) { - developer.log("Error processing count message: $e", name: "rubin_chart.workspace"); - developer.log("Message content: ${event.message["content"]}", name: "rubin_chart.workspace"); + developer.log("Error processing count message: $e", name: "rubintv.chart.workspace"); + developer.log("Message content: ${event.message["content"]}", name: "rubintv.chart.workspace"); } } emit(state.copyWith()); @@ -633,6 +667,7 @@ class ChartBloc extends WindowBloc { on((event, emit) { // When loading from a file, we don't want to trigger the global query update unnecessarily if (!event.skipGlobalUpdate && event.globalQuery != null) { + developer.log("Updating global query in ControlCenter", name: "rubintv.chart.base"); // Update the global query in the control center which will notify all charts ControlCenter().updateGlobalQuery(GlobalQuery( query: event.globalQuery, @@ -643,14 +678,14 @@ class ChartBloc extends WindowBloc { } for (SeriesInfo series in state._series.values) { - developer.log("Synching data for series ${series.id}", name: "rubintv.chart.base.dart"); + developer.log("Synching data for series ${series.id}", name: "rubintv.chart.base"); _fetchSeriesData( series: series, globalQuery: event.globalQuery, dayObs: event.dayObs, ); - developer.log("synch request sent", name: "rubintv.chart.base.dart"); } + developer.log("Data sync requests sent for ${state._series.length} series", name: "rubintv.chart.base"); }); on((event, emit) { @@ -725,7 +760,7 @@ class ChartBloc extends WindowBloc { on((event, emit) { if (state.pendingRowCountDialog != null) { developer.log("User confirmed row count dialog, proceeding with series", - name: "rubin_chart.workspace"); + name: "rubintv.chart.workspace"); final dialogInfo = state.pendingRowCountDialog!; @@ -744,7 +779,7 @@ class ChartBloc extends WindowBloc { /// Handle user canceling the row count dialog on((event, emit) { if (state.pendingRowCountDialog != null) { - developer.log("User canceled row count dialog", name: "rubin_chart.workspace"); + developer.log("User canceled row count dialog", name: "rubintv.chart.workspace"); // Simply clear the dialog without proceeding emit(state.copyWith(pendingRowCountDialog: null)); @@ -785,7 +820,7 @@ class ChartBloc extends WindowBloc { // Check that the series has the correct number of columns and axes if (series.fields.length != state.axisInfo.length) { - developer.log("bad axes", name: "rubin_chart.core.chart.dart"); + developer.log("bad axes", name: "rubintv.core.chart.dart"); return null; } @@ -876,7 +911,7 @@ class ChartBloc extends WindowBloc { if (workspace.info?.instrument?.schema == null) { throw ArgumentError("The chosen instrument has no schema"); } - developer.log("New series fields: ${series.fields}", name: "rubin_chart.core.chart.dart"); + developer.log("New series fields: ${series.fields}", name: "rubintv.core.chart.dart"); return showDialog( context: context, builder: (BuildContext context) => Dialog( @@ -1008,6 +1043,8 @@ class ChartBloc extends WindowBloc { @override Future close() async { + ControlCenter().selectionController.unsubscribe(state.id); + ControlCenter().drillDownController.unsubscribe(state.id); await _subscription.cancel(); await _globalQuerySubscription.cancel(); return super.close(); diff --git a/lib/chart/series.dart b/lib/chart/series.dart index 3ae767b..ffc90d3 100644 --- a/lib/chart/series.dart +++ b/lib/chart/series.dart @@ -18,6 +18,7 @@ /// /// You should have received a copy of the GNU General Public License /// along with this program. If not, see . +import 'dart:developer' as developer; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -161,24 +162,76 @@ class SeriesInfo { "marker": marker?.toJson(), "errorBars": errorBars?.toJson(), "axes": axes.map((e) => e.toJson()).toList(), - "fields": fields.entries.map((entry) => [entry.key.toJson(), entry.value.toJson()]).toList(), + "fields": Map.fromEntries( + fields.entries.map((entry) => MapEntry(entry.key.toJson(), entry.value.toJson()))), "query": query?.toJson(), }; } /// Create a [SeriesInfo] from a JSON object. static SeriesInfo fromJson(Map json) { - return SeriesInfo( - id: SeriesId.fromString(json["id"]), - name: json["name"], - marker: json["marker"] == null ? null : Marker.fromJson(json["marker"]), - errorBars: json["errorBars"] == null ? null : ErrorBars.fromJson(json["errorBars"]), - axes: (json["axes"] as List).map((e) => AxisId.fromJson(e)).toList(), - fields: Map.fromEntries((json["fields"] as List).map((e) { - List entry = e; - return MapEntry(AxisId.fromJson(entry[0]), SchemaField.fromJson(entry[1])); - })), - query: json["query"] == null ? null : QueryExpression.fromJson(json["query"]), - ); + try { + Map fields = {}; + + if (json["fields"] is List) { + // Handle old format: list of pairs + developer.log("Processing fields as list format", name: "rubintv.chart.series"); + for (dynamic fieldItem in json["fields"]) { + if (fieldItem is List && fieldItem.length == 2) { + var axisData = fieldItem[0]; + var fieldData = fieldItem[1]; + + developer.log("Processing field pair: $axisData -> $fieldData", name: "rubintv.chart.series"); + + if (axisData != null && fieldData != null) { + AxisId axisId = AxisId.fromJson(axisData); + SchemaField field = SchemaField.fromJson(fieldData as Map); + fields[axisId] = field; + developer.log("Field processed from list: $axisId -> ${field.name}", + name: "rubintv.chart.series"); + } else { + developer.log("Skipping null field pair: $axisData -> $fieldData", + name: "rubintv.chart.series"); + } + } else { + developer.log("Invalid field item format: $fieldItem", name: "rubintv.chart.series"); + } + } + } else if (json["fields"] is Map) { + // Handle new format: map + developer.log("Processing fields as map format", name: "rubintv.chart.series"); + Map fieldsMap = json["fields"] as Map; + for (MapEntry entry in fieldsMap.entries) { + developer.log("Processing field: ${entry.key} -> ${entry.value}", name: "rubintv.chart.series"); + if (entry.value != null) { + AxisId axisId = AxisId.fromJson(entry.key); + SchemaField field = SchemaField.fromJson(entry.value as Map); + fields[axisId] = field; + developer.log("Field processed from map: ${axisId} -> ${field.name}", + name: "rubintv.chart.series"); + } else { + developer.log("Skipping null field value for key: ${entry.key}", name: "rubintv.chart.series"); + } + } + } else { + developer.log("Unknown fields format: ${json["fields"].runtimeType}", name: "rubintv.chart.series"); + throw ArgumentError("Unknown fields format in JSON"); + } + + return SeriesInfo( + id: SeriesId.fromString(json["id"]), + name: json["name"], + axes: List.from(json["axes"].map((e) => AxisId.fromJson(e))), + fields: fields, + query: json.containsKey("query") && json["query"] != null + ? QueryExpression.fromJson(json["query"]) + : null, + ); + } catch (e, stackTrace) { + developer.log("Error in SeriesInfo.fromJson: $e", + name: "rubintv.chart.series", error: e, stackTrace: stackTrace); + developer.log("Full JSON: $json", name: "rubintv.chart.series"); + rethrow; + } } } diff --git a/lib/editors/series.dart b/lib/editors/series.dart index 49e89b7..ac606ff 100644 --- a/lib/editors/series.dart +++ b/lib/editors/series.dart @@ -345,7 +345,8 @@ class ColumnEditorState extends State { @override Widget build(BuildContext context) { if (_field != null) { - _table = _field!.schema; + // Ensure we're using the table instance from the database, not a deserialized copy + _table = widget.databaseSchema.tables[_field!.schema.name]; } List> tableEntries = []; @@ -376,7 +377,10 @@ class ColumnEditorState extends State { onChanged: (TableSchema? newTable) { setState(() { _table = newTable; - _field = _table!.fields.values.first; + // Ensure we get the field from the new table instance + if (_table != null && _table!.fields.isNotEmpty) { + _field = _table!.fields.values.first; + } }); widget.onChanged(_field); }, diff --git a/lib/focal_plane/chart.dart b/lib/focal_plane/chart.dart index 85037df..5100c6c 100644 --- a/lib/focal_plane/chart.dart +++ b/lib/focal_plane/chart.dart @@ -282,36 +282,11 @@ class FocalPlaneChartBloc extends WindowBloc { } FocalPlaneChartBloc(super.initialState) { - _websocketSubscription = WebSocketManager().messages.listen((message) { - add(FocalPlaneReceiveMessageEvent(message)); - }); - - /// Subscribe to the selection controller to update the chart when points are selected. - /// We use a timer so that we don't load data until the selection has stopped - ControlCenter().selectionController.subscribe(state.id, (Object? origin, Set dataPoints) { - if (origin == state.id) { - return; - } - _selectionTimer?.cancel(); - _selectionTimer = Timer(const Duration(milliseconds: 500), () { - _updateSeries(dataPoints); - }); - }); - - /// Subscribe to the global query stream to update the chart when the query changes. - _globalQuerySubscription = ControlCenter().globalQueryStream.listen((GlobalQuery? query) { - Set? selected = - ControlCenter().selectionController.selectedDataPoints.map((e) => e as DataId).toSet(); - if (selected.isEmpty) { - selected = ControlCenter().drillDownController.selectedDataPoints.map((e) => e as DataId).toSet(); - } - if (selected.isEmpty) { - selected = null; - } - _fetchSeriesData(series: state.series, query: query?.query, dayObs: query?.dayObs, selected: selected); - }); + // Subscribe to selection updates + ControlCenter().selectionController.subscribe(state.id, _onSelectionUpdate); + developer.log("Focal plane chart bloc created and subscribed", name: "rubintv.focal_plane.chart"); - /// Initialize the chart. + /// Initialize the focal plane chart on((event, emit) { ColorbarController colorbarController = event.colorbarController; emit(FocalPlaneChartState( @@ -329,7 +304,7 @@ class FocalPlaneChartBloc extends WindowBloc { )); }); - /// Process data received from the websocket. + /// A message has been received from the websocket on((event, emit) { List? splitId = event.message["requestId"]?.split(","); if (splitId == null || splitId.length != 2) { @@ -345,7 +320,7 @@ class FocalPlaneChartBloc extends WindowBloc { } }); - /// Update the Series and fetch the data. + /// Update the column being displayed on((event, emit) { final String tableName = event.field.schema.name; SchemaField detectorField; @@ -367,7 +342,7 @@ class FocalPlaneChartBloc extends WindowBloc { }, ); - developer.log("Selected data points: ${event.selected}", name: "rubin_chart.focal_plane.chart.dart"); + developer.log("Selected data points: ${event.selected}", name: "rubintv.focal_plane.chart.dart"); bool isNewPlot = state.data.isEmpty; @@ -380,19 +355,19 @@ class FocalPlaneChartBloc extends WindowBloc { emit(state.copyWith(series: newSeries)); }); - /// Update the data index. + /// Update the data index on((event, emit) { emit(state.copyWith(dataIndex: event.index)); }); - /// Increase the data index. + /// Increase the data index on((event, emit) { if (state.dataIndex < state.dataIds.length - 1) { emit(state.copyWith(dataIndex: state.dataIndex + 1)); } }); - /// Update the playback speed. + /// Update the playback speed on((event, emit) { emit(state.copyWith(playbackSpeed: event.speed)); if (state.isPlaying) { @@ -400,7 +375,7 @@ class FocalPlaneChartBloc extends WindowBloc { } }); - /// Start the playback timer, which increases the [dataIndex] periodically. + /// Start the timer on((event, emit) { _createTimer(); int dataIndex = state.dataIndex; @@ -410,14 +385,14 @@ class FocalPlaneChartBloc extends WindowBloc { emit(state.copyWith(isPlaying: true, dataIndex: dataIndex)); }); - /// Stop the playback timer. + /// Stop the timer on((event, emit) { _playTimer?.cancel(); _playTimer = null; emit(state.copyWith(isPlaying: false)); }); - /// Update the data index when a tick is received. + /// A tick has occurred on((event, emit) { if (state.dataIndex < state.dataIds.length - 1) { emit(state.copyWith(dataIndex: state.dataIndex + 1)); @@ -429,14 +404,55 @@ class FocalPlaneChartBloc extends WindowBloc { } }); - /// Toggle the loop playback. + /// Toggle loop on((event, emit) { emit(state.copyWith(loopPlayback: !state.loopPlayback)); }); - /// Reload all of the data from the server. + /// Subscribe to global query updates + _globalQuerySubscription = ControlCenter().globalQueryStream.listen((GlobalQuery? query) { + Set? selected = + ControlCenter().selectionController.selectedDataPoints.map((e) => e as DataId).toSet(); + if (selected.isEmpty) { + selected = ControlCenter().drillDownController.selectedDataPoints.map((e) => e as DataId).toSet(); + } + if (selected.isEmpty) { + selected = null; + } + _fetchSeriesData(series: state.series, query: query?.query, dayObs: query?.dayObs, selected: selected); + }); + + /// Subscribe to websocket messages + _websocketSubscription = WebSocketManager().messages.listen((message) { + add(FocalPlaneReceiveMessageEvent(message)); + }); + + /// Handle synchronization events (used when loading workspaces) on((event, emit) { - _updateSeries(); + // Fetch data for the current series with the provided dayObs + Set? selected = + ControlCenter().selectionController.selectedDataPoints.map((e) => e as DataId).toSet(); + if (selected.isEmpty) { + selected = ControlCenter().drillDownController.selectedDataPoints.map((e) => e as DataId).toSet(); + } + if (selected.isEmpty) { + selected = null; + } + + _fetchSeriesData( + series: state.series, + query: event.globalQuery, + dayObs: event.dayObs, + selected: selected, + ); + }); + } + + /// Callback when selection is updated + void _onSelectionUpdate(Object? origin, Set dataPoints) { + _selectionTimer?.cancel(); + _selectionTimer = Timer(const Duration(milliseconds: 500), () { + _updateSeries(dataPoints); }); } @@ -455,8 +471,7 @@ class FocalPlaneChartBloc extends WindowBloc { int rows = event.message["content"]["data"].values.first.length; int columns = event.message["content"]["data"].length; developer.log("received $columns columns and $rows rows for ${event.message["requestId"]}", - name: "rubin_chart.workspace"); - + name: "rubintv.workspace"); if (rows > 0) { // Extract the data from the message Map> allData = Map>.from( @@ -544,9 +559,18 @@ class FocalPlaneChartBloc extends WindowBloc { /// Close the bloc. @override - Future close() { - _websocketSubscription.cancel(); - _globalQuerySubscription.cancel(); + Future close() async { + _playTimer?.cancel(); + _selectionTimer?.cancel(); + + await _websocketSubscription.cancel(); + await _globalQuerySubscription.cancel(); + developer.log("Subscriptions cancelled", name: "rubintv.focal_plane.chart"); + + ControlCenter().selectionController.unsubscribe(state.id); + developer.log("Unsubscribed from selection controller", name: "rubintv.focal_plane.chart"); + + developer.log("Focal plane chart bloc closed", name: "rubintv.focal_plane.chart"); return super.close(); } } @@ -584,7 +608,7 @@ class FocalPlaneChartViewerState extends State { /// We use a special editor for the series in a focal plane chart. Future _editSeries(BuildContext context, SeriesInfo series) async { WorkspaceViewerState workspace = WorkspaceViewer.of(context); - developer.log("New series fields: ${series.fields}", name: "rubin_chart.core.chart.dart"); + developer.log("New series fields: ${series.fields}", name: "rubintv.core.chart.dart"); DatabaseSchema schema = DataCenter().databases[workspace.info!.instrument!.schema]!; SchemaField field; if (series.fields.isNotEmpty) { @@ -771,13 +795,15 @@ class FocalPlaneChartViewerState extends State { width: 50, child: IconButton( icon: state.isPlaying ? const Icon(Icons.pause) : const Icon(Icons.play_arrow), - onPressed: () { - if (!state.isPlaying) { - context.read().add(FocalPlaneStartTimerEvent()); - } else { - context.read().add(FocalPlaneStopTimerEvent()); - } - }, + onPressed: state.dataIds.length > 1 + ? () { + if (!state.isPlaying) { + context.read().add(FocalPlaneStartTimerEvent()); + } else { + context.read().add(FocalPlaneStopTimerEvent()); + } + } + : null, ), )), const SizedBox(width: 10), @@ -785,46 +811,47 @@ class FocalPlaneChartViewerState extends State { message: "Previous data ID", child: Material( color: Colors.grey[300], - shape: const CircleBorder(), child: IconButton( icon: const Icon(Icons.remove), - onPressed: () { - if (state.dataIndex > 0) { - context - .read() - .add(FocalPlaneUpdateDataIndexEvent(state.dataIndex - 1)); - } - }, + onPressed: (state.dataIds.length > 1 && state.dataIndex > 0) + ? () { + context + .read() + .add(FocalPlaneUpdateDataIndexEvent(state.dataIndex - 1)); + } + : null, ), )), Expanded( child: Slider( value: state.dataIndex.toDouble(), min: 0, - max: state.dataIds.isNotEmpty ? state.dataIds.length.toDouble() - 1 : 2, - divisions: state.dataIds.isNotEmpty ? state.dataIds.length - 1 : 2, + max: state.dataIds.isNotEmpty ? state.dataIds.length.toDouble() - 1 : 1, + divisions: state.dataIds.length > 1 ? state.dataIds.length - 1 : null, label: state.dataIndex.round().toString(), - onChanged: (value) { - context - .read() - .add(FocalPlaneUpdateDataIndexEvent(value.round().toInt())); - }, + onChanged: state.dataIds.length > 1 + ? (value) { + context + .read() + .add(FocalPlaneUpdateDataIndexEvent(value.round().toInt())); + } + : null, ), ), Tooltip( message: "Next data ID", child: Material( color: Colors.grey[300], - shape: const CircleBorder(), child: IconButton( icon: const Icon(Icons.add), - onPressed: () { - if (state.dataIndex < state.dataIds.length - 1) { - context - .read() - .add(FocalPlaneUpdateDataIndexEvent(state.dataIndex + 1)); - } - }, + onPressed: + (state.dataIds.length > 1 && state.dataIndex < state.dataIds.length - 1) + ? () { + context + .read() + .add(FocalPlaneUpdateDataIndexEvent(state.dataIndex + 1)); + } + : null, ), )), ]), diff --git a/lib/main.dart b/lib/main.dart index 34069fb..ed2359a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,6 +31,7 @@ import 'package:rubintv_visualization/app.dart'; import 'package:rubintv_visualization/workspace/data.dart'; import 'package:rubintv_visualization/error.dart'; import 'package:rubintv_visualization/utils/browser_logger.dart'; +import 'package:rubintv_visualization/id.dart'; /// A function to get the current version of the application. Future getAppVersion() async { @@ -49,7 +50,9 @@ Future getAppVersion() async { /// The main function for the application. Future main() async { // Initialize browser-based logging + final firstId = UniqueId.next(); developer.log('Starting application with browser-based logging', name: 'rubinTV.visualization.main'); + developer.log('Test value of global _nextId: $firstId', name: 'rubinTV.visualization.main'); FlutterError.onError = (FlutterErrorDetails details) { if (details.exception is FlutterError) { diff --git a/lib/utils/browser_logger.dart b/lib/utils/browser_logger.dart index 869e94e..f222fb9 100644 --- a/lib/utils/browser_logger.dart +++ b/lib/utils/browser_logger.dart @@ -84,7 +84,7 @@ String _getLevelString(int level) { // Helper to expose logs through the console void printLogsToConsole() { try { - final key = 'rubintv_visualization_log'; + const key = 'rubintv_visualization_log'; final logs = web.window.localStorage[key] ?? 'No logs found'; developer.log('=== BEGIN LOGS ==='); developer.log(logs); diff --git a/lib/workspace/data.dart b/lib/workspace/data.dart index 1ad19ae..1b9768d 100644 --- a/lib/workspace/data.dart +++ b/lib/workspace/data.dart @@ -174,9 +174,43 @@ class SchemaField { /// that is already loaded by the [DataCenter]. static SchemaField fromJson(Map json) { DataCenter dataCenter = DataCenter(); - TableSchema schema = - dataCenter.databases[json["database"]]!.tables.values.firstWhere((e) => e.name == json["schema"]); - return schema.fields[json["name"]]!; + developer.log("Available databases: ${dataCenter.databases.keys}", name: "rubintv.workspace.data"); + + if (!dataCenter.databases.containsKey(json["database"])) { + String errorMsg = + "Database '${json["database"]}' not found. Available databases: ${dataCenter.databases.keys.join(', ')}"; + developer.log(errorMsg, name: "rubintv.workspace.data"); + reportError("Workspace load error: $errorMsg"); + throw ArgumentError(errorMsg); + } + + DatabaseSchema database = dataCenter.databases[json["database"]]!; + developer.log("Available tables in database: ${database.tables.keys}", name: "rubintv.workspace.data"); + + TableSchema? schema; + try { + schema = database.tables.values.firstWhere((e) => e.name == json["schema"]); + developer.log("Found schema: ${schema.name}", name: "rubintv.workspace.data"); + } catch (e) { + String errorMsg = "Table '${json["schema"]}' not found in database '${json["database"]}'. " + "Available tables: ${database.tables.keys.join(', ')}. " + "This workspace may have been saved with a different instrument schema."; + developer.log(errorMsg, name: "rubintv.workspace.data"); + reportError("Workspace load error: $errorMsg"); + throw ArgumentError(errorMsg); + } + + if (!schema.fields.containsKey(json["name"])) { + String errorMsg = "Field '${json["name"]}' not found in table '${schema.name}'. " + "Available fields: ${schema.fields.keys.join(', ')}"; + developer.log(errorMsg, name: "rubintv.workspace.data"); + reportError("Workspace load error: $errorMsg"); + throw ArgumentError(errorMsg); + } + + SchemaField result = schema.fields[json["name"]]!; + developer.log("Schema field found successfully: ${result.name}", name: "rubintv.workspace.data"); + return result; } } @@ -199,6 +233,15 @@ class TableSchema { field.schema = this; } } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TableSchema && other.name == name && other.database.name == database.name; + } + + @override + int get hashCode => Object.hash(name, database.name); } /// A data source. @@ -384,18 +427,28 @@ class DataCenter { }) { // Extensive validation if (data.isEmpty) { + developer.log("No data found for series ${series.id}", name: "rubintv_visualization.workspace.data"); reportError("No data found for the selected columns."); return; } // Check if any data lists are empty if (data.values.any((list) => list.isEmpty)) { + developer.log("Empty data lists found for series ${series.id}", + name: "rubintv_visualization.workspace.data"); + developer.log( + "Data keys with empty lists: ${data.entries.where((e) => e.value.isEmpty).map((e) => e.key)}", + name: "rubintv_visualization.workspace.data"); reportError("One or more columns contain no data."); return; } int rows = data.values.first.length; + developer.log("Data has $rows rows", name: "rubintv_visualization.workspace.data"); + if (rows == 0) { + developer.log("No non-null data found for series ${series.id}", + name: "rubintv_visualization.workspace.data"); reportError("No non-null data found for the selected columns."); return; } @@ -455,18 +508,37 @@ class DataCenter { } SchemaField field = dataSource.tables[tableName]!.fields[columnName]!; - if (series.fields.containsValue(field)) { + + // Find matching field by name and table instead of object reference + SchemaField? matchingSeriesField; + AxisId? matchingAxisId; + + for (MapEntry entry in series.fields.entries) { + SchemaField seriesField = entry.value; + if (seriesField.name == field.name && + seriesField.schema.name == field.schema.name && + seriesField.database.name == field.database.name) { + matchingSeriesField = field; + matchingAxisId = entry.key; + break; + } + } + + if (matchingSeriesField != null && matchingAxisId != null) { if (field.isString) { - columns[field] = List.from(data[plotColumn]!.map((e) => e)); + columns[matchingSeriesField] = List.from(data[plotColumn]!.map((e) => e)); } else if (field.isNumerical) { - columns[field] = List.from(data[plotColumn]!.map((e) => e.toDouble())); + columns[matchingSeriesField] = List.from(data[plotColumn]!.map((e) => e.toDouble())); } else if (field.isDateTime) { - columns[field] = List.from(data[plotColumn]!.map((e) => convertRubinDate(e))); + columns[matchingSeriesField] = + List.from(data[plotColumn]!.map((e) => convertRubinDate(e))); } // Add the column to the series columns - AxisId axisId = series.axes[series.fields.values.toList().indexOf(field)]; - seriesColumns[axisId] = field; + seriesColumns[matchingAxisId] = matchingSeriesField; + } else { + reportError("Plot column '$plotColumn' does not match any series fields."); + return; } } @@ -491,22 +563,16 @@ class DataCenter { dataIds: dataIds, ); + developer.log("Series data updated successfully for ${series.id}", + name: "rubintv_visualization.workspace.data"); _seriesData[series.id] = seriesData; } else { throw DataAccessException("Unknown data source: $dataSource"); } } - /// Check if two [SchemaField]s are compatible - bool isFieldCompatible(SchemaField field1, SchemaField field2) => { - field1.dataType == field2.dataType, - field1.unit == field2.unit, - }.every((e) => e); - - @override - String toString() => "DataCenter:[${databases.keys}]"; - void removeSeriesData(SeriesId id) { + developer.log("Removing series data for $id", name: "rubintv_visualization.workspace.data"); _seriesData.remove(id); } @@ -517,6 +583,12 @@ class DataCenter { void dispose() { _subscription.cancel(); } + + /// Check if two [SchemaField]s are compatible + bool isFieldCompatible(SchemaField field1, SchemaField field2) => { + field1.dataType == field2.dataType, + field1.unit == field2.unit, + }.every((e) => e); } /// DataId for an entry in the exposure or visit table diff --git a/lib/workspace/state.dart b/lib/workspace/state.dart index 1558433..eab5d3a 100644 --- a/lib/workspace/state.dart +++ b/lib/workspace/state.dart @@ -432,15 +432,50 @@ class WorkspaceState extends WorkspaceStateBase { AppVersion fileVersion = AppVersion.fromJson(json["version"]); if (fileVersion != version) { developer.log("File version $fileVersion does not match current version $version. ", - name: "rubin_chart.workspace"); + name: "rubintv.workspace"); json = convertWorkspace(json, theme, version); } + developer.log("Processing windows: ${json['windows']?.keys}", name: "rubintv.workspace.state"); + Map windows = {}; + + if (json["windows"] != null) { + for (var entry in (json["windows"] as Map).entries) { + try { + developer.log("Processing window ${entry.key}", name: "rubintv.workspace.state"); + UniqueId windowId = UniqueId.fromString(entry.key); + WindowMetaData window = WindowMetaData.fromJson(entry.value, theme.chartTheme); + windows[windowId] = window; + developer.log("Window ${entry.key} processed successfully", name: "rubintv.workspace.state"); + } catch (e, stackTrace) { + developer.log("Error processing window ${entry.key}: $e", + name: "rubintv.workspace.state", error: e, stackTrace: stackTrace); + developer.log("Window JSON: ${entry.value}", name: "rubintv.workspace.state"); + + // Report a user-friendly error but continue loading other windows + if (e is ArgumentError && e.message.toString().contains("not found")) { + reportError("Skipped window ${entry.key}: ${e.message}"); + developer.log("Skipping window ${entry.key} due to schema mismatch", + name: "rubintv.workspace.state"); + continue; // Skip this window but continue loading others + } + rethrow; // Re-throw other errors + } + } + } + + // If we couldn't load any windows, provide a helpful error + if (json["windows"] != null && (json["windows"] as Map).isNotEmpty && windows.isEmpty) { + String errorMsg = "Could not load any windows from workspace. " + "The workspace may have been saved with a different instrument schema. " + "Please create a new workspace or update the saved workspace to match the current instrument."; + reportError(errorMsg); + developer.log(errorMsg, name: "rubintv.workspace.state"); + } + return WorkspaceState( version: AppVersion.fromJson(json["version"]), - windows: (json["windows"] as Map).map((key, value) { - return MapEntry(UniqueId.fromString(key), WindowMetaData.fromJson(value, theme.chartTheme)); - }), + windows: windows, instrument: json.containsKey("instrument") ? Instrument.fromJson(json["instrument"]) : null, globalQuery: json.containsKey("globalQuery") ? QueryExpression.fromJson(json["globalQuery"]) : null, dayObs: json.containsKey("dayObs") ? DateTime.parse(json["dayObs"]) : null, @@ -623,7 +658,7 @@ class WorkspaceBloc extends Bloc { /// A message is received from the websocket. on((event, emit) { - developer.log("Workspace Received message: ${event.message["type"]}", name: "rubin_chart.workspace"); + developer.log("Workspace Received message: ${event.message["type"]}", name: "rubintv.workspace"); if (event.message["type"] == "instrument info") { // Update the workspace to use the new instrument WorkspaceState state = this.state as WorkspaceState; @@ -640,19 +675,14 @@ class WorkspaceBloc extends Bloc { if (state.status == WorkspaceStatus.loadingInstrument && state.pendingJson != null) { // Build new workspace from JSON - WorkspaceState newState = WorkspaceState.fromJson( - state.pendingJson!, - (this.state as WorkspaceState).theme, - state.version, - ); - _applyWorkspaceJson(emit, newState); + _applyWorkspaceJsonWithClear(emit, state.pendingJson!, state); } } else if (event.message["type"] == "file content") { // Load the workspace from the file content add(LoadWorkspaceFromTextEvent(event.message["content"]["content"])); } else if (event.message["type"] == "error") { // Display the error message - developer.log("Received error message: ${event.message["content"]}", name: "rubin_chart.workspace"); + developer.log("Received error message: ${event.message["content"]}", name: "rubintv.workspace"); // Extract error details Map errorContent = event.message["content"]; @@ -684,7 +714,7 @@ class WorkspaceBloc extends Bloc { /// Update the global observation date. on((event, emit) { - developer.log("updating date to ${event.dayObs}!", name: "rubin_chart.workspace"); + developer.log("updating date to ${event.dayObs}!", name: "rubintv.workspace"); WorkspaceState state = this.state as WorkspaceState; state = state.updateObsDate(event.dayObs); ControlCenter().updateGlobalQuery(state.getGlobalQuery()); @@ -723,7 +753,7 @@ class WorkspaceBloc extends Bloc { Map windows = {...state.windows}; windows[newWindow.id] = newWindow; - developer.log("Added new focal plane window: $newWindow", name: "rubin_chart.workspace"); + developer.log("Added new focal plane window: $newWindow", name: "rubintv.workspace"); emit(state.copyWith(windows: windows)); }); @@ -742,7 +772,16 @@ class WorkspaceBloc extends Bloc { on((event, emit) { WorkspaceState state = this.state as WorkspaceState; Map windows = {...state.windows}; - windows.remove(event.windowId); + WindowMetaData? windowToRemove = windows[event.windowId]; + if (windowToRemove != null) { + // Close the bloc to cancel all its subscriptions + windowToRemove.bloc.close(); + + // Remove the window from the map + windows.remove(event.windowId); + + developer.log("Window ${event.windowId} removed and bloc closed", name: "rubintv.workspace.state"); + } emit(state.copyWith(windows: windows)); }); @@ -868,14 +907,18 @@ class WorkspaceBloc extends Bloc { } /// Load a workspace from a text string. - void _onLoadWorkspaceFromText(LoadWorkspaceFromTextEvent event, Emitter emit) { + void _onLoadWorkspaceFromText(LoadWorkspaceFromTextEvent event, Emitter emit) async { WorkspaceState state = this.state as WorkspaceState; try { Map json = jsonDecode(event.text); + Instrument newInstrument = Instrument.fromJson(json["instrument"]); + developer.log("New instrument: ${newInstrument.name}, current: ${state.instrument?.name}", + name: "rubintv.workspace.load"); if (state.instrument?.name != newInstrument.name) { + developer.log("Instrument mismatch - waiting for instrument load", name: "rubintv.workspace.load"); emit(state.copyWith( status: WorkspaceStatus.loadingInstrument, pendingJson: json, @@ -883,30 +926,36 @@ class WorkspaceBloc extends Bloc { WebSocketManager().sendMessage(LoadInstrumentAction(instrument: newInstrument.name).toJson()); } else { // Build new workspace from JSON - WorkspaceState newState = WorkspaceState.fromJson( - json, - (this.state as WorkspaceState).theme, - state.version, - ); - _applyWorkspaceJson(emit, newState); + await _applyWorkspaceJsonWithClear(emit, json, state); } - } catch (e) { + } catch (e, stackTrace) { + developer.log("Error loading workspace: $e", + name: "rubintv.workspace.load", error: e, stackTrace: stackTrace); emit(state.copyWith(status: WorkspaceStatus.error, errorMessage: "Failed to load workspace: $e")); } } - /// Build a workspace from a JSON object. - void _applyWorkspaceJson(Emitter emit, WorkspaceState newState) async { - WorkspaceState state = this.state as WorkspaceState; + Future _applyWorkspaceJsonWithClear( + Emitter emit, Map json, WorkspaceState currentState) async { + await _clearWorkspace(currentState, skipGlobalQueryReset: true); + + WorkspaceState newState = WorkspaceState.fromJson( + json, + currentState.theme, + currentState.version, + ); - // Skip calling ControlCenter().reset() which would broadcast a null global query - // Instead, we'll explicitly close old windows and reset controllers but avoid unnecessary global query updates - await _clearWorkspace(state, skipGlobalQueryReset: true); + await _applyWorkspaceJson(emit, newState); + } - // First emit the new state so it's available everywhere + /// Build a workspace from a JSON object. + Future _applyWorkspaceJson(Emitter emit, WorkspaceState newState) async { + // Emit the new state BEFORE syncing data so the UI updates emit(newState); + developer.log("New workspace state emitted", name: "rubintv.workspace.load"); String? dayObs = getFormattedDate(newState.dayObs); + developer.log("DayObs for sync: $dayObs", name: "rubintv.workspace.load"); // Use a flag to track whether the global query update was made, to avoid duplicate updates bool globalQueryUpdated = false; @@ -914,9 +963,12 @@ class WorkspaceBloc extends Bloc { // Then sync data for all windows, but we don't need to trigger the global query stream // as the charts will get their data directly for (var window in newState.windows.values) { + developer.log("Processing window ${window.id} of type ${window.windowType}", + name: "rubintv.workspace.load"); + if (window.bloc is ChartBloc) { - developer.log("Syncing data for window ${window.id} with dayObs=$dayObs", - name: "rubin_chart.workspace"); + developer.log("Syncing ChartBloc data for window ${window.id} with dayObs=$dayObs", + name: "rubintv.workspace.load"); // Send direct SynchData event instead of going through global query stream (window.bloc as ChartBloc).add(SynchDataEvent( @@ -926,11 +978,14 @@ class WorkspaceBloc extends Bloc { )); if (!globalQueryUpdated) { + developer.log("Updating global query (first time)", name: "rubintv.workspace.load"); // Update global query only once, after the first window is processed ControlCenter().updateGlobalQuery(newState.getGlobalQuery()); globalQueryUpdated = true; } } else if (window.bloc is FocalPlaneChartBloc) { + developer.log("Syncing FocalPlaneChartBloc data for window ${window.id}", + name: "rubintv.workspace.load"); (window.bloc as FocalPlaneChartBloc).add(SynchDataEvent( dayObs: dayObs, globalQuery: newState.globalQuery, @@ -947,22 +1002,41 @@ class WorkspaceBloc extends Bloc { /// Clear the workspace and the DataCenter. Future _clearWorkspace(WorkspaceState state, {bool skipGlobalQueryReset = false}) async { - // Close all of the windows and cancel their subscriptions. + // First unsubscribe all windows from controllers BEFORE closing them for (WindowMetaData window in state.windows.values) { - await window.bloc.close(); + if (window.windowType.isChart) { + developer.log("Unsubscribing chart ${window.id} from controllers", name: "rubintv.workspace.clear"); + ControlCenter().selectionController.unsubscribe(window.id); + ControlCenter().drillDownController.unsubscribe(window.id); + } else if (window.windowType == WindowTypes.focalPlane) { + developer.log("Unsubscribing focal plane ${window.id} from controllers", + name: "rubintv.workspace.clear"); + ControlCenter().selectionController.unsubscribe(window.id); + } + } + + // Now close all of the windows and cancel their subscriptions + for (WindowMetaData window in state.windows.values) { + if (window.windowType.isChart || window.windowType == WindowTypes.focalPlane) { + developer.log("Closing window ${window.id} of type ${window.windowType}", + name: "rubintv.workspace.clear"); + await window.bloc.close(); + } } + developer.log("All window blocs closed", name: "rubintv.workspace.clear"); if (skipGlobalQueryReset) { - // Only reset selection controllers without affecting global query + developer.log("Resetting selection controllers only", name: "rubintv.workspace.clear"); ControlCenter().selectionController.reset(); ControlCenter().drillDownController.reset(); } else { - // Full reset of all controllers including global query stream + developer.log("Full ControlCenter reset", name: "rubintv.workspace.clear"); ControlCenter().reset(); } // Clear the DataCenter Series Data. DataCenter().clearSeriesData(); + developer.log("DataCenter series data cleared", name: "rubintv.workspace.clear"); } /// Cancel the subscription to the websocket. diff --git a/lib/workspace/viewer.dart b/lib/workspace/viewer.dart index fcd0664..7bfe3d5 100644 --- a/lib/workspace/viewer.dart +++ b/lib/workspace/viewer.dart @@ -33,6 +33,7 @@ import 'package:rubintv_visualization/workspace/controller.dart'; import 'package:rubintv_visualization/workspace/state.dart'; import 'package:rubintv_visualization/workspace/toolbar.dart'; import 'package:rubintv_visualization/workspace/window.dart'; +import 'package:rubintv_visualization/id.dart'; /// A [Widget] used to display a set of re-sizable and translatable [WindowMetaData] widgets in a container. class WorkspaceViewer extends StatefulWidget { @@ -85,21 +86,29 @@ class WorkspaceViewerState extends State { /// The current state of the workspace. WorkspaceState? info; - UniqueKey get id => UniqueKey(); + + /// Use a stable ID for the workspace viewer's subscription + final Object _viewerId = Object(); @override void initState() { - developer.log("Initializing WorkspaceViewerState", name: "rubin_chart.workspace"); super.initState(); + ControlCenter().selectionController.subscribe(_viewerId, _onSelectionUpdate); + } - ControlCenter().selectionController.subscribe(id, _onSelectionUpdate); + @override + void dispose() { + ControlCenter().selectionController.unsubscribe(_viewerId); + super.dispose(); } /// Update the selection data points. /// This isn't used now, but can be used in the future if any plots cannot be /// matched to obs_date,seq_num data IDs. void _onSelectionUpdate(Object? origin, Set dataPoints) { - developer.log("Selection updated: ${dataPoints.length}", name: "rubin_chart.workspace"); + developer.log("=== WORKSPACE VIEWER SELECTION UPDATE ===", name: "rubintv.workspace.viewer"); + developer.log("Workspace viewer received selection update from $origin: ${dataPoints.length} points", + name: "rubintv.workspace.viewer"); } @override @@ -107,15 +116,60 @@ class WorkspaceViewerState extends State { return BlocProvider( create: (context) => WorkspaceBloc()..add(InitializeWorkspaceEvent(theme, version)), child: BlocBuilder( + buildWhen: (previous, current) { + // Always rebuild if state types are different + if (previous.runtimeType != current.runtimeType) { + developer.log("State type changed - rebuilding", name: "rubintv.workspace.viewer"); + return true; + } + + // Always rebuild if we're coming from or going to initial state + if (previous is WorkspaceStateInitial || current is WorkspaceStateInitial) { + developer.log("Initial state transition - rebuilding", name: "rubintv.workspace.viewer"); + return true; + } + + if (previous is WorkspaceState && current is WorkspaceState) { + // Always rebuild if instrument changed - this indicates a new workspace + if (previous.instrument != current.instrument) { + developer.log("Instrument changed - rebuilding", name: "rubintv.workspace.viewer"); + return true; + } + + // Rebuild if window count changed + if (previous.windows.length != current.windows.length) { + developer.log("Window count changed - rebuilding", name: "rubintv.workspace.viewer"); + return true; + } + + // If we have the same windows but different references, rebuild + // This handles updates to individual windows + for (UniqueId id in current.windows.keys) { + if (previous.windows.containsKey(id) && previous.windows[id] != current.windows[id]) { + return true; + } + } + + return false; + } + + // For any other case, rebuild to be safe + developer.log("Unhandled state combination - rebuilding", name: "rubintv.workspace.viewer"); + return true; + }, builder: (context, state) { if (state is WorkspaceStateInitial) { + developer.log("Workspace state is initial - showing progress indicator", + name: "rubintv.workspace.viewer"); return const Center( child: CircularProgressIndicator(), ); } - + info = state as WorkspaceState?; if (state is WorkspaceState) { - info = state; + developer.log( + "Workspace state loaded: ${state.windows.length} windows, instrument=${state.instrument?.name}", + name: "rubintv.workspace.viewer"); return Column(children: [ Toolbar(workspace: state), SizedBox( @@ -124,13 +178,17 @@ class WorkspaceViewerState extends State { child: Builder( builder: (BuildContext context) { List children = []; - for (WindowMetaData window in info!.windows.values) { + for (WindowMetaData window in state.windows.values) { + developer.log("Building window ${window.id} of type ${window.windowType}", + name: "rubintv.workspace.viewer"); children.add(Positioned( + key: ValueKey(window.id), // Add unique key to force recreation left: window.offset.dx, top: window.offset.dy, child: buildWindow(window, state), )); } + developer.log("Built ${children.length} windows", name: "rubintv.workspace.viewer"); return Stack( children: children, @@ -149,20 +207,25 @@ class WorkspaceViewerState extends State { /// Build a window widget based on the type of the window. Widget buildWindow(WindowMetaData window, WorkspaceState workspace) { + developer.log("Building window widget for ${window.id} type ${window.windowType}", + name: "rubintv.workspace.viewer"); + if (window.windowType == WindowTypes.cartesianScatter || window.windowType == WindowTypes.polarScatter) { - return ScatterPlotWidget(window: window, bloc: window.bloc as ChartBloc); + return ScatterPlotWidget(key: ValueKey(window.id), window: window, bloc: window.bloc as ChartBloc); } if (window.windowType == WindowTypes.histogram || window.windowType == WindowTypes.box) { - return BinnedChartWidget(window: window, bloc: window.bloc as ChartBloc); + return BinnedChartWidget(key: ValueKey(window.id), window: window, bloc: window.bloc as ChartBloc); } if (window.windowType == WindowTypes.detectorSelector) { return DetectorSelector( + key: ValueKey(window.id), window: window, workspace: workspace, ); } if (window.windowType == WindowTypes.focalPlane) { return FocalPlaneChartViewer( + key: ValueKey(window.id), window: window, workspace: workspace, bloc: window.bloc as FocalPlaneChartBloc,