diff --git a/analysis_options.yaml b/analysis_options.yaml index be31636..ccc66bf 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/packages/demo/analysis_options.yaml b/packages/demo/analysis_options.yaml index 7161cfd..eacc7d4 100644 --- a/packages/demo/analysis_options.yaml +++ b/packages/demo/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/packages/demo/integration_test/README.md b/packages/demo/integration_test/README.md index 586c6d0..7b9341d 100644 --- a/packages/demo/integration_test/README.md +++ b/packages/demo/integration_test/README.md @@ -1,35 +1,41 @@ # Rendered performance benchmark `node_flow_500_benchmark_test.dart` renders a deterministic graph with 500 -nodes and 955 connections. It warms the renderer and records separate pan, +nodes by default (955 connections). It warms the renderer and records separate pan, zoom, single-node drag/drop, and node-plus-edge topology churn workloads using Flutter's engine-provided `FrameTiming` values. The topology workload alternately creates a visible node with two incident edges and removes that -node with its edges, keeping the fixture near 500 nodes while exercising widget +node with its edges, keeping the fixture near its configured size while exercising widget mounting, the spatial index, adjacency cleanup, and connection-scene -invalidation. By default, it runs the same fixture and workloads in three +invalidation. By default, it runs the same fixture and workloads in four configurations: - `full`: adaptive LOD disabled, so every visible node uses its full widget. -- `navigation`: all 500 nodes use full widgets while idle, but camera gestures +- `retained-full`: every node keeps full labels, card styling, and port marks, + but ordinary nodes are retained as recorded pictures instead of widget + subtrees. Active nodes are promoted back into the widget overlay. +- `navigation`: all nodes use full widgets while idle, but camera gestures replace ordinary nodes with the painted scene. Selected or actively edited nodes remain promoted as a small widget overlay. - `adaptive`: adaptive LOD enabled with `maxInteractiveNodes: 200`, allowing the editor to switch to its batched overview painter. -Run it from `packages/demo` on the target hardware in profile mode: +Run it from `packages/demo` as the production web target: ```sh flutter drive \ - --profile \ - -d macos \ + --release \ + --wasm \ + -d web-server \ + --browser-name chrome \ + --web-port 8080 \ --driver=test_driver/integration_test.dart \ --target=integration_test/node_flow_500_benchmark_test.dart ``` -Replace `macos` with another configured device ID. For the web target, use -`-d chrome`. Keep the window size, device, Flutter version, renderer, and power -state fixed when comparing runs. +Keep the window size, Chrome version, Flutter version, renderer, and power state +fixed when comparing runs. The report records `runtime.wasm`, which must be +`true` for a valid WASM result. ## Local web release testing @@ -54,27 +60,31 @@ cd packages/demo flutter drive \ --release \ --wasm \ - -d chrome \ + -d web-server \ + --browser-name chrome \ + --web-port 8080 \ --driver=test_driver/integration_test.dart \ --target=integration_test/node_flow_500_benchmark_test.dart \ --dart-define=NODE_FLOW_BENCHMARK_RENDER_MODE=all ``` -Use `navigation` or `adaptive` instead of `all` to run one representation. +Use `comparison` to run only the full-widget and retained-full representations, +or name one representation directly. The automated driver opens a visible Chrome window, performs the workloads, -writes `build/node_flow_500_benchmark.json`, and closes the window when done. +writes `build/node_flow__benchmark.json`, and closes the window when done. The driver writes the structured result to -`build/node_flow_500_benchmark.json`. The same report is also printed with a -`NODE_FLOW_500_BENCHMARK` prefix. Each workload reports p50, p95, p99, and +`build/node_flow__benchmark.json`. The same report is also printed with a +`NODE_FLOW_BENCHMARK` prefix. Each workload reports p50, p95, p99, and maximum UI, raster, and total frame spans, plus the number of frames exceeding -the 8.33 ms budget for a 120 Hz display. Warmup is captured as a separate +the 16.67 ms budget for a 60 Hz display. Warmup is captured as a separate measurement phase, while pan, zoom, drag, and topology churn are marked as `steady_state`. Each phase reports requested versus engine-delivered frames, missing or extra timing records, delivery ratio, workload update counters, and frame-budget miss -ratio. The existing `frame_count` and `frames_over_8_33_ms` fields remain as -compatibility aliases. Each mode and scenario also records the effective LOD +ratio. `frame_count` and the historical `frames_over_8_33_ms` metric remain for +compatibility, while `frames_over_16_67_ms` is the current acceptance metric. +Each mode and scenario also records the effective LOD level, widget/thumbnail path, spatially visible node count, and spatially visible connection count. @@ -85,27 +95,52 @@ live-camera update before each requested frame; their `workload.viewport_updates` counter should therefore match `workload.pumped_frames`. The MobX/plugin viewport commits after each measured phase. The topology workload reports its add/remove API calls in -`workload.graph_updates` and restores the original 500-node/955-edge fixture +`workload.graph_updates` and restores the original configured fixture after measurement. -To iterate on only one configuration, set `NODE_FLOW_BENCHMARK_RENDER_MODE` to -`full`, `navigation`, or `adaptive` (`all` is the default): +To compare the two full-detail renderers, set +`NODE_FLOW_BENCHMARK_RENDER_MODE=comparison`. To iterate on one configuration, +use `full`, `retained-full`, `navigation`, or `adaptive` (`all` is the default): ```sh flutter drive \ - --profile \ - -d macos \ + --release \ + --wasm \ + -d web-server \ + --browser-name chrome \ + --web-port 8080 \ --driver=test_driver/integration_test.dart \ --target=integration_test/node_flow_500_benchmark_test.dart \ --dart-define=NODE_FLOW_BENCHMARK_RENDER_MODE=adaptive ``` +Scale the same fixture to larger graphs without editing the benchmark: + +```sh +flutter drive \ + --release \ + --wasm \ + -d web-server \ + --browser-name chrome \ + --web-port 8080 \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/node_flow_500_benchmark_test.dart \ + --dart-define=NODE_FLOW_BENCHMARK_NODE_COUNT=2000 \ + --dart-define=NODE_FLOW_BENCHMARK_RENDER_MODE=retained-full +``` + +The report is written to `build/node_flow__benchmark.json` and grouped +under `node_flow_`, so 500-, 1,000-, and 2,000-node runs can coexist. + For a short diagnostic run while editing the harness, reduce the frame counts: ```sh flutter drive \ - --profile \ - -d macos \ + --release \ + --wasm \ + -d web-server \ + --browser-name chrome \ + --web-port 8080 \ --driver=test_driver/integration_test.dart \ --target=integration_test/node_flow_500_benchmark_test.dart \ --dart-define=NODE_FLOW_BENCHMARK_WARMUP_FRAMES=10 \ @@ -128,7 +163,7 @@ The relevant JSON shape for every phase is: "graph_updates": 0 }, "frame_budget": { - "target_ms": 8.333, + "target_ms": 16.667, "misses": 0, "met": 100, "miss_ratio": 0.0 @@ -141,12 +176,12 @@ The relevant JSON shape for every phase is: - This is a measurement harness, not a normal correctness test, so it has no hard timing assertions. Shared CI and debug-mode results are not stable FPS gates. -- A 120 Hz target has an 8.33 ms total frame budget. Use a physical 120 Hz - display when validating that target; lower-refresh displays cannot prove it. +- The acceptance target is 60 FPS, which gives the complete frame 16.67 ms. + Keep the test display and browser refresh rate fixed for comparisons. - The workloads call controller operations directly and therefore measure graph mutation, Flutter build/layout/paint, and raster work without pointer-event latency or hit-testing overhead. Input latency should be profiled separately. -- The graph is intentionally zoomed so most or all 500 full node widgets are +- The graph is intentionally zoomed so most or all configured full node widgets are visible. A smaller window can change the visible population and must be kept constant between comparisons. - Web engines can report raster timings differently or return zero for fields diff --git a/packages/demo/integration_test/node_flow_500_benchmark_test.dart b/packages/demo/integration_test/node_flow_500_benchmark_test.dart index 52dde75..7b34dd8 100644 --- a/packages/demo/integration_test/node_flow_500_benchmark_test.dart +++ b/packages/demo/integration_test/node_flow_500_benchmark_test.dart @@ -13,15 +13,20 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:vyuh_node_flow/vyuh_node_flow.dart'; -const _nodeCount = 500; -const _columnCount = 20; -const _rowCount = 25; +const _nodeCount = int.fromEnvironment( + 'NODE_FLOW_BENCHMARK_NODE_COUNT', + defaultValue: 500, +); +final _columnCount = math.max(2, math.sqrt(_nodeCount * 0.8).ceil()); +final _rowCount = (_nodeCount / _columnCount).ceil(); const _nodeSize = Size(160, 80); const _columnSpacing = 210.0; const _rowSpacing = 125.0; const _initialZoom = 0.18; -const _targetFrameMicros = 8333; +const _targetFrameMicros = 16667; +const _legacy120HzFrameMicros = 8333; const _adaptiveNodeLimit = 200; +const _isWasm = bool.fromEnvironment('dart.tool.dart2wasm'); const _requestedRenderMode = String.fromEnvironment( 'NODE_FLOW_BENCHMARK_RENDER_MODE', @@ -43,7 +48,7 @@ void main() { for (final renderMode in _selectedRenderModes()) { testWidgets( - '500-node rendered frame benchmark (${renderMode.name})', + '$_nodeCount-node rendered frame benchmark (${renderMode.name})', (tester) async { await _runBenchmark(tester, binding, renderMode); }, @@ -58,34 +63,50 @@ Future _runBenchmark( _RenderMode renderMode, ) async { final fixture = _BenchmarkFixture.create(); + final retainedFull = renderMode == _RenderMode.retainedFull; final controller = NodeFlowController( nodes: fixture.nodes, connections: fixture.connections, initialViewport: const GraphViewport(x: 24, y: 24, zoom: _initialZoom), config: NodeFlowConfig( - minZoom: 0.12, + minZoom: 0.01, maxZoom: 2, showAttribution: false, plugins: [ LodPlugin( enabled: renderMode != _RenderMode.full, - minThreshold: renderMode == _RenderMode.navigation ? 0 : 0.03, - maxInteractiveNodes: renderMode == _RenderMode.navigation - ? _nodeCount * 2 - : _adaptiveNodeLimit, + minThreshold: renderMode == _RenderMode.navigation || retainedFull + ? 0 + : 0.03, + midThreshold: retainedFull ? 0 : 0.1, + maxInteractiveNodes: switch (renderMode) { + _RenderMode.navigation => _nodeCount * 2, + _RenderMode.retainedFull => 1, + _ => _adaptiveNodeLimit, + }, + minVisibility: retainedFull + ? DetailVisibility.full + : DetailVisibility.minimal, + midVisibility: retainedFull + ? DetailVisibility.full + : DetailVisibility.standard, + maxVisibility: DetailVisibility.full, ), ], ), ); addTearDown(controller.dispose); - await tester.pumpWidget(_BenchmarkApp(controller: controller)); + await tester.pumpWidget( + _BenchmarkApp(controller: controller, renderMode: renderMode), + ); await tester.pumpAndSettle(); expect(controller.nodeCount, _nodeCount); expect(controller.connectionCount, fixture.connections.length); + final benchmarkZoom = _fitGraphZoom(controller); - _centerGraph(controller, _initialZoom); + _centerGraph(controller, benchmarkZoom); await tester.pump(); final warmup = await _measureViewportPhase( tester: tester, @@ -102,7 +123,7 @@ Future _runBenchmark( return _oscillatingViewport( controller, phase, - _initialZoom, + benchmarkZoom, panRadius: 12, ); }, @@ -114,7 +135,7 @@ Future _runBenchmark( final results = >{}; - _centerGraph(controller, _initialZoom); + _centerGraph(controller, benchmarkZoom); await tester.pump(); final pan = await _measureViewportPhase( tester: tester, @@ -131,7 +152,7 @@ Future _runBenchmark( return _oscillatingViewport( controller, phase, - _initialZoom, + benchmarkZoom, panRadius: 90, ); }, @@ -141,7 +162,7 @@ Future _runBenchmark( await tester.pump(); results['pan'] = {...pan, 'render_state': _renderState(controller)}; - _centerGraph(controller, _initialZoom); + _centerGraph(controller, benchmarkZoom); await tester.pump(); final zoom = await _measureViewportPhase( tester: tester, @@ -155,7 +176,7 @@ Future _runBenchmark( frameCount: _scenarioFrames, viewportForFrame: (frame) { final phase = frame / math.max(1, _scenarioFrames - 1); - final zoom = _initialZoom + 0.055 * math.sin(phase * math.pi * 2); + final zoom = benchmarkZoom * (1 + 0.2 * math.sin(phase * math.pi * 2)); return _centeredViewport(controller, zoom); }, ), @@ -164,7 +185,7 @@ Future _runBenchmark( await tester.pump(); results['zoom'] = {...zoom, 'render_state': _renderState(controller)}; - _centerGraph(controller, 0.24); + _centerGraph(controller, benchmarkZoom); await tester.pump(); results['single_node_drag'] = { ...await _measurePhase( @@ -172,7 +193,7 @@ Future _runBenchmark( phase: 'steady_state', requestedFrames: _scenarioFrames, action: () async { - const nodeId = 'node-249'; + final nodeId = 'node-${_nodeCount ~/ 2}'; controller.startNodeDrag(nodeId); final counters = await _pumpFrames(tester, _scenarioFrames, (frame) { final direction = frame < _scenarioFrames ~/ 2 ? 1.0 : -1.0; @@ -187,7 +208,7 @@ Future _runBenchmark( // Commit the drag-end state outside the measured steady-state phase. await tester.pump(); - _centerGraph(controller, 0.24); + _centerGraph(controller, benchmarkZoom); await tester.pump(); results['node_and_edge_churn'] = { ...await _measurePhase( @@ -203,7 +224,7 @@ Future _runBenchmark( 'render_state': _renderState(controller), }; // An odd frame count leaves the last transient node mounted. Restore the - // deterministic 500-node fixture outside the measured phase. + // deterministic baseline fixture outside the measured phase. if (_scenarioFrames.isOdd) { controller.removeNode('churn-node-${_scenarioFrames ~/ 2}'); await tester.pump(); @@ -219,6 +240,10 @@ Future _runBenchmark( 'columns': _columnCount, 'rows': _rowCount, }, + 'rendering': { + 'visual_detail': retainedFull ? 'full-retained' : renderMode.name, + 'retained_node_pictures': retainedFull, + }, 'runtime': { 'build_mode': kProfileMode ? 'profile' @@ -226,6 +251,7 @@ Future _runBenchmark( ? 'release' : 'debug', 'web': kIsWeb, + 'wasm': _isWasm, 'platform': defaultTargetPlatform.name, 'logical_surface': { 'width': controller.screenSize.width, @@ -245,28 +271,30 @@ Future _runBenchmark( }; binding.reportData ??= {}; - final modeReports = + final graphReports = binding.reportData!.putIfAbsent( - 'node_flow_500', + 'node_flow_$_nodeCount', () => {}, ) as Map; - modeReports[renderMode.name] = report; - debugPrint('NODE_FLOW_500_BENCHMARK ${jsonEncode(report)}'); + graphReports[renderMode.name] = report; + debugPrint('NODE_FLOW_BENCHMARK ${jsonEncode(report)}'); } -enum _RenderMode { full, navigation, adaptive } +enum _RenderMode { full, retainedFull, navigation, adaptive } List<_RenderMode> _selectedRenderModes() { return switch (_requestedRenderMode) { 'all' => _RenderMode.values, + 'comparison' => const [_RenderMode.full, _RenderMode.retainedFull], 'full' => const [_RenderMode.full], + 'retained-full' => const [_RenderMode.retainedFull], 'navigation' => const [_RenderMode.navigation], 'adaptive' => const [_RenderMode.adaptive], _ => throw ArgumentError.value( _requestedRenderMode, 'NODE_FLOW_BENCHMARK_RENDER_MODE', - 'Expected all, full, navigation, or adaptive', + 'Expected all, comparison, full, retained-full, navigation, or adaptive', ), }; } @@ -294,9 +322,10 @@ Map _renderState(NodeFlowController controller) { } class _BenchmarkApp extends StatelessWidget { - const _BenchmarkApp({required this.controller}); + const _BenchmarkApp({required this.controller, required this.renderMode}); final NodeFlowController controller; + final _RenderMode renderMode; @override Widget build(BuildContext context) { @@ -306,6 +335,9 @@ class _BenchmarkApp extends StatelessWidget { body: NodeFlowEditor( controller: controller, theme: NodeFlowTheme.light, + thumbnailBuilder: renderMode == _RenderMode.retainedFull + ? _paintRetainedBenchmarkNode + : null, nodeBuilder: (context, node) => Container( width: _nodeSize.width, height: _nodeSize.height, @@ -345,6 +377,70 @@ class _BenchmarkApp extends StatelessWidget { } } +bool _paintRetainedBenchmarkNode( + Canvas canvas, + Node node, + Rect bounds, + bool isSelected, +) { + final card = RRect.fromRectAndRadius(bounds, const Radius.circular(8)); + canvas.drawRRect(card, Paint()..color = const Color(0xfff8fafc)); + canvas.drawRRect( + card, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = isSelected ? 2 : 1 + ..color = isSelected ? const Color(0xff2563eb) : const Color(0xffcbd5e1), + ); + + void paintText( + String text, + Offset offset, + TextStyle style, { + double maxWidth = 132, + }) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: '…', + )..layout(maxWidth: maxWidth); + painter.paint(canvas, offset); + } + + paintText( + node.data, + bounds.topLeft + const Offset(12, 10), + const TextStyle( + color: Color(0xff0f172a), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ); + paintText( + node.id, + bounds.topLeft + const Offset(12, 34), + const TextStyle(color: Color(0xff64748b), fontSize: 11), + ); + paintText( + 'Input', + Offset(bounds.left + 10, bounds.bottom - 20), + const TextStyle(color: Color(0xff64748b), fontSize: 9), + maxWidth: 52, + ); + paintText( + 'Output', + Offset(bounds.right - 40, bounds.bottom - 20), + const TextStyle(color: Color(0xff64748b), fontSize: 9), + maxWidth: 34, + ); + + final portPaint = Paint()..color = const Color(0xff2563eb); + canvas.drawCircle(Offset(bounds.left, bounds.center.dy), 4, portPaint); + canvas.drawCircle(Offset(bounds.right, bounds.center.dy), 4, portPaint); + return true; +} + class _BenchmarkFixture { const _BenchmarkFixture({required this.nodes, required this.connections}); @@ -355,6 +451,7 @@ class _BenchmarkFixture { for (var row = 0; row < _rowCount; row++) { for (var column = 0; column < _columnCount; column++) { final index = row * _columnCount + column; + if (index >= _nodeCount) break; nodes.add( _benchmarkNode( id: 'node-$index', @@ -494,7 +591,7 @@ Future<_WorkloadCounters> _pumpTopologyFrames({ controller.addConnections([ Connection( id: 'churn-in-$cycle', - sourceNodeId: 'node-249', + sourceNodeId: 'node-${math.max(0, _nodeCount ~/ 2 - 1)}', sourcePortId: 'out', targetNodeId: nodeId, targetPortId: 'in', @@ -503,7 +600,7 @@ Future<_WorkloadCounters> _pumpTopologyFrames({ id: 'churn-out-$cycle', sourceNodeId: nodeId, sourcePortId: 'out', - targetNodeId: 'node-250', + targetNodeId: 'node-${_nodeCount ~/ 2}', targetPortId: 'in', ), ]); @@ -621,6 +718,9 @@ Map _summarize( final deliveredFrames = timings.length; final budgetMisses = total.where((time) => time > _targetFrameMicros).length; + final legacy120HzMisses = total + .where((time) => time > _legacy120HzFrameMicros) + .length; final undeliveredFrames = math.max(0, requestedFrames - deliveredFrames); final extraDeliveredFrames = math.max(0, deliveredFrames - requestedFrames); @@ -645,7 +745,8 @@ Map _summarize( 'ui': distribution(build), 'raster': distribution(raster), 'total': distribution(total), - 'frames_over_8_33_ms': budgetMisses, + 'frames_over_16_67_ms': budgetMisses, + 'frames_over_8_33_ms': legacy120HzMisses, if (timings.isEmpty) 'note': 'No FrameTiming values were delivered by this target.', }; @@ -678,6 +779,16 @@ int _percentile(List sortedValues, double percentile) { return sortedValues[index.clamp(0, sortedValues.length - 1)]; } +double _fitGraphZoom(NodeFlowController controller) { + final graphWidth = (_columnCount - 1) * _columnSpacing + _nodeSize.width; + final graphHeight = (_rowCount - 1) * _rowSpacing + _nodeSize.height; + final availableWidth = math.max(1.0, controller.screenSize.width - 48); + final availableHeight = math.max(1.0, controller.screenSize.height - 48); + return math + .min(availableWidth / graphWidth, availableHeight / graphHeight) + .clamp(0.01, 0.24); +} + void _centerGraph(NodeFlowController controller, double zoom) { controller.setViewport(_centeredViewport(controller, zoom)); } diff --git a/packages/demo/lib/examples/lod.dart b/packages/demo/lib/examples/lod.dart index d47ce8b..c80c8a3 100644 --- a/packages/demo/lib/examples/lod.dart +++ b/packages/demo/lib/examples/lod.dart @@ -23,7 +23,7 @@ class _LODExampleState extends State { _controller = NodeFlowController, dynamic>( config: NodeFlowConfig( plugins: [ - LodPlugin(), + LodPlugin(enabled: true), AutoPanPlugin(), DebugPlugin(), MinimapPlugin(), diff --git a/packages/demo/macos/Podfile b/packages/demo/macos/Podfile index ff5ddb3..167132a 100644 --- a/packages/demo/macos/Podfile +++ b/packages/demo/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/packages/demo/macos/Runner.xcodeproj/project.pbxproj b/packages/demo/macos/Runner.xcodeproj/project.pbxproj index ef7f79a..fe07f32 100644 --- a/packages/demo/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/demo/macos/Runner.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; 46BF33DCA943FFE0B91A7BA1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D7632E2F55C18F78621CFD63 /* Pods_RunnerTests.framework */; }; C7D1EAF9F458A5FB5EE907D3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 340CDE14DB07D3BD9361F1D5 /* Pods_Runner.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -88,6 +89,7 @@ B9519731CE320125C0445E4B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; D71B0B19DFAD96331AF03ABD /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; D7632E2F55C18F78621CFD63 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -103,6 +105,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, C7D1EAF9F458A5FB5EE907D3 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -164,6 +167,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -231,6 +235,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 33CC10EC2044A3C60003C045 /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -256,6 +263,9 @@ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -557,7 +567,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -639,7 +649,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -689,7 +699,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -796,6 +806,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/packages/demo/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/demo/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a6e20c2..4b2fdc2 100644 --- a/packages/demo/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/demo/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + main() => integrationDriver( - responseDataCallback: (data) => - writeResponseData(data, testOutputFilename: 'node_flow_500_benchmark'), + responseDataCallback: (data) { + var reportName = 'node_flow_500'; + if (data != null) { + for (final key in data.keys) { + if (key.startsWith('node_flow_')) { + reportName = key; + break; + } + } + } + return writeResponseData( + data, + testOutputFilename: '${reportName}_benchmark', + ); + }, ); diff --git a/packages/vyuh_node_flow/CHANGELOG.md b/packages/vyuh_node_flow/CHANGELOG.md index 56f3076..5e9a310 100644 --- a/packages/vyuh_node_flow/CHANGELOG.md +++ b/packages/vyuh_node_flow/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.32.0 + + - **FEAT**(renderer): add immutable scene snapshots and coalesced node/connection deltas without changing the existing controller, node-builder, or MobX integration APIs. + - **PERF**(renderer): repaint retained node pictures directly from scene deltas, reuse unchanged node-local pictures, and keep animated or frequently changing nodes in a live widget overlay. + - **PERF**(wasm): add reproducible 500/1,000-node release SkWasm measurements against a 16.67 ms 60 FPS frame budget. + - **FIX**(lod): keep full widget detail at every zoom level by default; adaptive LOD is now opt-in. + ## 0.31.0 > Note: This release has breaking changes. diff --git a/packages/vyuh_node_flow/analysis_options.yaml b/packages/vyuh_node_flow/analysis_options.yaml index a5744c1..534f26d 100644 --- a/packages/vyuh_node_flow/analysis_options.yaml +++ b/packages/vyuh_node_flow/analysis_options.yaml @@ -1,3 +1,12 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml # Additional information about this file can be found at diff --git a/packages/vyuh_node_flow/dart_test.yaml b/packages/vyuh_node_flow/dart_test.yaml new file mode 100644 index 0000000..f712c78 --- /dev/null +++ b/packages/vyuh_node_flow/dart_test.yaml @@ -0,0 +1,8 @@ +tags: + behavior: + edge_case: + extensions: + integration: + performance: + unit: + widget: diff --git a/packages/vyuh_node_flow/lib/controller.dart b/packages/vyuh_node_flow/lib/controller.dart index 1426927..e356c0b 100644 --- a/packages/vyuh_node_flow/lib/controller.dart +++ b/packages/vyuh_node_flow/lib/controller.dart @@ -8,3 +8,4 @@ library; export 'src/editor/controller/node_flow_controller.dart'; +export 'src/editor/scene/graph_scene.dart'; diff --git a/packages/vyuh_node_flow/lib/src/connections/connection.dart b/packages/vyuh_node_flow/lib/src/connections/connection.dart index e34781c..528d6a7 100644 --- a/packages/vyuh_node_flow/lib/src/connections/connection.dart +++ b/packages/vyuh_node_flow/lib/src/connections/connection.dart @@ -173,6 +173,33 @@ class Connection { final Observable _strokeWidth; final Observable _selectedStrokeWidth; + /// Registers a lightweight listener for renderer-relevant state changes. + /// + /// This is an internal renderer hook. Raw MobX listeners preserve the + /// existing setter behavior without creating a reactive dependency graph. + VoidCallback observeSceneChanges(VoidCallback listener) { + final disposers = [ + _animated.observe((_) => listener()), + _selected.observe((_) => listener()), + _visible.observe((_) => listener()), + _startLabel.observe((_) => listener()), + _label.observe((_) => listener()), + _endLabel.observe((_) => listener()), + _animationEffect.observe((_) => listener()), + _startPoint.observe((_) => listener()), + _endPoint.observe((_) => listener()), + _color.observe((_) => listener()), + _selectedColor.observe((_) => listener()), + _strokeWidth.observe((_) => listener()), + _selectedStrokeWidth.observe((_) => listener()), + ]; + return () { + for (final dispose in disposers) { + dispose(); + } + }; + } + /// Optional typed data to attach to the connection. /// /// This can be used to store custom metadata, validation state, or any other diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/node_flow_controller.dart b/packages/vyuh_node_flow/lib/src/editor/controller/node_flow_controller.dart index 3ff4029..11face1 100644 --- a/packages/vyuh_node_flow/lib/src/editor/controller/node_flow_controller.dart +++ b/packages/vyuh_node_flow/lib/src/editor/controller/node_flow_controller.dart @@ -33,6 +33,7 @@ import '../node_flow_behavior.dart'; import '../node_flow_config.dart'; import '../node_flow_events.dart'; import '../resizer_widget.dart'; +import '../scene/graph_scene.dart'; import '../snap_delegate.dart'; import '../themes/node_flow_theme.dart'; import '../viewport_animation_mixin.dart'; @@ -45,6 +46,7 @@ part 'group_api.dart'; part 'node_api.dart'; part 'node_flow_controller_api.dart'; part 'resize_api.dart'; +part 'scene_projection_api.dart'; part 'viewport_api.dart'; /// Alignment options for node alignment operations @@ -144,6 +146,8 @@ class NodeFlowController { if (nodes != null && nodes.isNotEmpty) { _loadInitialGraph(nodes, connections ?? const []); } + + _initializeSceneProjection(); } /// Loads initial graph data during construction. @@ -340,6 +344,13 @@ class NodeFlowController { ObservableMap>(); final ObservableList> _connections = ObservableList>(); + final GraphSceneProjection _sceneProjection = + GraphSceneProjection(); + final Map _sceneNodeReactions = {}; + final Map _sceneConnectionReactions = {}; + final Map> _sceneNodeSources = {}; + final Map> _sceneConnectionSources = {}; + final List _sceneCollectionReactions = []; final ObservableSet _selectedNodeIds = ObservableSet(); final ObservableSet _selectedConnectionIds = ObservableSet(); final Observable _viewport; @@ -983,6 +994,7 @@ class NodeFlowController { /// } /// ``` void dispose() { + _disposeSceneProjection(); _canvasFocusNode.dispose(); _connectionPainter?.dispose(); _cameraViewport.dispose(); diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/scene_projection_api.dart b/packages/vyuh_node_flow/lib/src/editor/controller/scene_projection_api.dart new file mode 100644 index 0000000..889d191 --- /dev/null +++ b/packages/vyuh_node_flow/lib/src/editor/controller/scene_projection_api.dart @@ -0,0 +1,299 @@ +part of 'node_flow_controller.dart'; + +extension SceneProjectionApi on NodeFlowController { + /// Granular immutable scene used by retained renderers and diagnostics. + GraphSceneProjection get sceneProjection => _sceneProjection; + + /// Captures an immutable full scene for export, debugging, or a new renderer. + /// + /// Hot paint paths should use [sceneProjection] entity lookup and delta + /// listenables so they do not copy the complete graph on every frame. + SceneSnapshot get sceneSnapshot => _sceneProjection.snapshot; +} + +extension _SceneProjectionInternals on NodeFlowController { + void _initializeSceneProjection() { + _sceneProjection.seed(nodes: _nodes.values, connections: _connections); + + for (final node in _nodes.values) { + _observeSceneNode(node); + } + for (final connection in _connections) { + _observeSceneConnection(connection); + } + + _sceneCollectionReactions.add( + reaction( + (_) { + return Object.hashAll( + _nodes.entries.map( + (entry) => Object.hash(entry.key, identityHashCode(entry.value)), + ), + ); + }, + (_) { + final ids = _nodes.keys.toSet(); + final removed = _sceneNodeReactions.keys + .where((id) => !ids.contains(id)) + .toList(growable: false); + for (final id in removed) { + _sceneNodeReactions.remove(id)?.call(); + _sceneNodeSources.remove(id); + _sceneProjection.removeNode(id); + } + for (final id in ids) { + final node = _nodes[id]; + if (node == null || identical(_sceneNodeSources[id], node)) { + continue; + } + _sceneNodeReactions.remove(id)?.call(); + _observeSceneNode(node); + _sceneProjection.upsertNode(node, const {SceneNodeChange.added}); + } + }, + ), + ); + + _sceneCollectionReactions.add( + reaction( + (_) { + return Object.hashAll( + _connections.map( + (connection) => + Object.hash(connection.id, identityHashCode(connection)), + ), + ); + }, + (_) { + final ids = _connections.map((connection) => connection.id).toSet(); + final removed = _sceneConnectionReactions.keys + .where((id) => !ids.contains(id)) + .toList(growable: false); + for (final id in removed) { + _sceneConnectionReactions.remove(id)?.call(); + _sceneConnectionSources.remove(id); + _sceneProjection.removeConnection(id); + } + for (final id in ids) { + final connection = _connectionById[id]; + if (connection == null || + identical(_sceneConnectionSources[id], connection)) { + continue; + } + _sceneConnectionReactions.remove(id)?.call(); + _observeSceneConnection(connection); + _sceneProjection.upsertConnection(connection, const { + SceneConnectionChange.added, + }); + } + }, + ), + ); + } + + void _observeSceneNode(Node node) { + _sceneNodeSources[node.id] = node; + var previous = _SceneNodeObserved.fromNode(node); + _sceneNodeReactions[node.id] = node.observeSceneChanges(() { + final next = _SceneNodeObserved.fromNode(node); + final changes = {}; + if (previous.position != next.position || + previous.visualPosition != next.visualPosition || + previous.size != next.size) { + changes.add(SceneNodeChange.geometry); + } + if (previous.visualKey != next.visualKey || + previous.retainedVisualRevision != next.retainedVisualRevision) { + changes.add(SceneNodeChange.visual); + } + if (previous.isSelected != next.isSelected) { + changes.add(SceneNodeChange.selection); + } + if (previous.isVisible != next.isVisible) { + changes.add(SceneNodeChange.visibility); + } + if (previous.zIndex != next.zIndex) { + changes.add(SceneNodeChange.zOrder); + } + if (previous.portRevision != next.portRevision) { + changes.add(SceneNodeChange.ports); + } + if (previous.isDragging != next.isDragging || + previous.isEditing != next.isEditing) { + changes.add(SceneNodeChange.interaction); + } + previous = next; + if (changes.isEmpty) return; + + _sceneProjection.upsertNode(node, changes); + if (changes.contains(SceneNodeChange.geometry) || + changes.contains(SceneNodeChange.ports) || + changes.contains(SceneNodeChange.visibility)) { + for (final connectionId in _connectionsByNodeId[node.id] ?? const {}) { + final connection = _connectionById[connectionId]; + if (connection != null) { + _sceneProjection.upsertConnection(connection, const { + SceneConnectionChange.geometry, + }); + } + } + } + }); + } + + void _observeSceneConnection(Connection connection) { + _sceneConnectionSources[connection.id] = connection; + var previous = _SceneConnectionObserved.fromConnection(connection); + _sceneConnectionReactions[connection.id] = connection.observeSceneChanges( + () { + final next = _SceneConnectionObserved.fromConnection(connection); + final changes = {}; + if (previous.visualRevision != next.visualRevision || + previous.isAnimated != next.isAnimated) { + changes.add(SceneConnectionChange.visual); + } + if (previous.isSelected != next.isSelected) { + changes.add(SceneConnectionChange.selection); + } + if (previous.isVisible != next.isVisible) { + changes.add(SceneConnectionChange.visibility); + } + previous = next; + if (changes.isNotEmpty) { + _sceneProjection.upsertConnection(connection, changes); + } + }, + ); + } + + void _disposeSceneProjection() { + for (final dispose in _sceneCollectionReactions) { + dispose(); + } + for (final dispose in _sceneNodeReactions.values) { + dispose(); + } + for (final dispose in _sceneConnectionReactions.values) { + dispose(); + } + _sceneCollectionReactions.clear(); + _sceneNodeReactions.clear(); + _sceneConnectionReactions.clear(); + _sceneNodeSources.clear(); + _sceneConnectionSources.clear(); + _sceneProjection.dispose(); + } +} + +class _SceneNodeObserved { + const _SceneNodeObserved({ + required this.position, + required this.visualPosition, + required this.size, + required this.isVisible, + required this.isSelected, + required this.isDragging, + required this.isEditing, + required this.zIndex, + required this.portRevision, + required this.visualKey, + required this.retainedVisualRevision, + }); + + factory _SceneNodeObserved.fromNode(Node node) { + return _SceneNodeObserved( + position: node.position.value, + visualPosition: node.visualPosition.value, + size: node.size.value, + isVisible: node.isVisible, + isSelected: node.isSelected, + isDragging: node.isDragging, + isEditing: node.isEditing, + zIndex: node.currentZIndex, + portRevision: Object.hashAll(node.ports), + visualKey: node.thumbnailCacheKey, + retainedVisualRevision: node.retainedVisualRevision, + ); + } + + final Offset position; + final Offset visualPosition; + final Size size; + final bool isVisible; + final bool isSelected; + final bool isDragging; + final bool isEditing; + final int zIndex; + final int portRevision; + final Object? visualKey; + final int retainedVisualRevision; + + @override + bool operator ==(Object other) => + other is _SceneNodeObserved && + other.position == position && + other.visualPosition == visualPosition && + other.size == size && + other.isVisible == isVisible && + other.isSelected == isSelected && + other.isDragging == isDragging && + other.isEditing == isEditing && + other.zIndex == zIndex && + other.portRevision == portRevision && + other.visualKey == visualKey && + other.retainedVisualRevision == retainedVisualRevision; + + @override + int get hashCode => Object.hash( + position, + visualPosition, + size, + isVisible, + isSelected, + isDragging, + isEditing, + zIndex, + portRevision, + visualKey, + retainedVisualRevision, + ); +} + +class _SceneConnectionObserved { + const _SceneConnectionObserved({ + required this.isVisible, + required this.isSelected, + required this.isAnimated, + required this.visualRevision, + }); + + factory _SceneConnectionObserved.fromConnection( + Connection connection, + ) { + return _SceneConnectionObserved( + isVisible: connection.visible, + isSelected: connection.selected, + isAnimated: connection.animated, + visualRevision: SceneConnectionSnapshot.fromConnection( + connection, + ).visualRevision, + ); + } + + final bool isVisible; + final bool isSelected; + final bool isAnimated; + final int visualRevision; + + @override + bool operator ==(Object other) => + other is _SceneConnectionObserved && + other.isVisible == isVisible && + other.isSelected == isSelected && + other.isAnimated == isAnimated && + other.visualRevision == visualRevision; + + @override + int get hashCode => + Object.hash(isVisible, isSelected, isAnimated, visualRevision); +} diff --git a/packages/vyuh_node_flow/lib/src/editor/layers/nodes_layer.dart b/packages/vyuh_node_flow/lib/src/editor/layers/nodes_layer.dart index 8060f2b..ae0f0d8 100644 --- a/packages/vyuh_node_flow/lib/src/editor/layers/nodes_layer.dart +++ b/packages/vyuh_node_flow/lib/src/editor/layers/nodes_layer.dart @@ -259,6 +259,10 @@ class NodesLayer extends StatelessWidget { // for the ordinary graph while selection/focus survives the mode switch. final promotedIds = { if (promoteSelection) ...controller.selectedNodeIds, + for (final node in nodesList) + if (node.isEditing || + node.retainedRendering == RetainedNodeRendering.live) + node.id, }; final draggedNodeId = controller.interaction.draggedNodeId.value; if (draggedNodeId != null) promotedIds.add(draggedNodeId); diff --git a/packages/vyuh_node_flow/lib/src/editor/layers/nodes_thumbnail_layer.dart b/packages/vyuh_node_flow/lib/src/editor/layers/nodes_thumbnail_layer.dart index e3d8f3d..7628a67 100644 --- a/packages/vyuh_node_flow/lib/src/editor/layers/nodes_thumbnail_layer.dart +++ b/packages/vyuh_node_flow/lib/src/editor/layers/nodes_thumbnail_layer.dart @@ -1,19 +1,24 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart' + show ChangeNotifier, Listenable, listEquals; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import '../../nodes/node.dart'; import '../controller/node_flow_controller.dart'; import '../node_flow_editor.dart'; +import '../scene/graph_scene.dart'; import '../unbounded_widgets.dart'; -/// A layer that renders all nodes using a single CustomPaint. +/// A layer that renders all nodes using retained pictures in one CustomPaint. /// /// Used when zoomed out below the LOD minThreshold or when the visible-node /// count exceeds the adaptive interaction budget. Node tap, selection, and /// drag are handled by [NodeFlowEditor]'s root spatial hit-testing while this /// layer is active. Port rendering and connection editing intentionally resume /// only after the editor returns to full-widget mode. -class NodesThumbnailLayer extends StatelessWidget { +class NodesThumbnailLayer extends StatefulWidget { const NodesThumbnailLayer({ super.key, required this.controller, @@ -32,44 +37,88 @@ class NodesThumbnailLayer extends StatelessWidget { /// controller's visible nodes reactively. final List>? nodes; + @override + State> createState() => _NodesThumbnailLayerState(); +} + +class _NodesThumbnailLayerState extends State> { + late _RetainedNodeScene _scene; + late _SceneConfigurationNotifier _configurationChanged; + late _NodesThumbnailPainter _painter; + + @override + void initState() { + super.initState(); + _createRetainedScene(); + } + + void _createRetainedScene() { + _scene = _RetainedNodeScene(); + _configurationChanged = _SceneConfigurationNotifier(); + _painter = _NodesThumbnailPainter( + projection: widget.controller.sceneProjection, + scene: _scene, + configurationChanged: _configurationChanged, + ); + } + + @override + void didUpdateWidget(covariant NodesThumbnailLayer oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + _configurationChanged.dispose(); + _scene.dispose(); + _createRetainedScene(); + return; + } + if (!identical(oldWidget.thumbnailBuilder, widget.thumbnailBuilder)) { + _scene.clear(); + } + } + + @override + void dispose() { + _configurationChanged.dispose(); + _scene.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Observer( builder: (_) { // Get visible nodes (already cached and sorted), unless NodesLayer has // already supplied the filtered subset. - var visibleNodes = nodes ?? controller.visibleNodes; + var visibleNodes = widget.nodes ?? widget.controller.visibleNodes; - if (nodes == null && layerFilter != null) { + if (widget.nodes == null && widget.layerFilter != null) { visibleNodes = visibleNodes - .where((node) => node.layer == layerFilter) + .where((node) => node.layer == widget.layerFilter) .toList(); } - if (visibleNodes.isEmpty) return const SizedBox.shrink(); - - // Build selected IDs by checking each node's isSelected property. - // This creates MobX dependencies on node.selected.value - same as widget layer. - final selectedIds = { - for (final node in visibleNodes) - if (node.isSelected) node.id, - }; + if (visibleNodes.isEmpty) { + _scene.retainOnly(const {}); + return const SizedBox.shrink(); + } // Get theme for default colors - final theme = controller.theme; + final theme = widget.controller.theme; final defaultColor = theme?.nodeTheme.backgroundColor ?? Colors.grey; final selectedBorderColor = theme?.nodeTheme.selectedBorderColor; + _painter.updateConfiguration( + nodeIds: visibleNodes.map((node) => node.id).toList(growable: false), + defaultColor: defaultColor, + selectedBorderColor: selectedBorderColor, + thumbnailBuilder: widget.thumbnailBuilder, + ); return UnboundedPositioned.fill( child: UnboundedRepaintBoundary( child: CustomPaint( - painter: _NodesThumbnailPainter( - nodes: visibleNodes, - selectedIds: selectedIds, - defaultColor: defaultColor, - selectedBorderColor: selectedBorderColor, - thumbnailBuilder: thumbnailBuilder, - ), + isComplex: true, + willChange: false, + painter: _painter, size: Size.infinite, ), ), @@ -82,52 +131,53 @@ class NodesThumbnailLayer extends StatelessWidget { /// CustomPainter that renders all nodes as thumbnails. class _NodesThumbnailPainter extends CustomPainter { _NodesThumbnailPainter({ - required this.nodes, - required this.selectedIds, - required this.defaultColor, - this.selectedBorderColor, - this.thumbnailBuilder, - }) : _fingerprint = _computeFingerprint(nodes, selectedIds); + required this.projection, + required this.scene, + required this.configurationChanged, + }) : super( + repaint: Listenable.merge([ + projection.nodeDeltas, + configurationChanged, + ]), + ); - final List> nodes; - final Set selectedIds; - final Color defaultColor; - final Color? selectedBorderColor; - final ThumbnailBuilder? thumbnailBuilder; + final GraphSceneProjection projection; + final _RetainedNodeScene scene; + final _SceneConfigurationNotifier configurationChanged; + List _nodeIds = const []; + Color _defaultColor = Colors.grey; + Color? _selectedBorderColor; + ThumbnailBuilder? _thumbnailBuilder; - /// Fingerprint for efficient change detection - final int _fingerprint; - - /// Computes a fingerprint based on nodes and selection state - static int _computeFingerprint( - List> nodes, - Set selectedIds, - ) { - var hash = nodes.length; - - for (final node in nodes) { - // Include all visual properties in fingerprint - hash = Object.hash( - hash, - node.id, - node.position.value.dx.toInt(), - node.position.value.dy.toInt(), - node.size.value.width.toInt(), - node.size.value.height.toInt(), - node.isVisible, - selectedIds.contains(node.id), - node.thumbnailCacheKey, - ); - } + void updateConfiguration({ + required List nodeIds, + required Color defaultColor, + required Color? selectedBorderColor, + required ThumbnailBuilder? thumbnailBuilder, + }) { + final idsChanged = !listEquals(_nodeIds, nodeIds); + final colorsChanged = + _defaultColor != defaultColor || + _selectedBorderColor != selectedBorderColor; + final builderChanged = !identical(_thumbnailBuilder, thumbnailBuilder); + if (!idsChanged && !colorsChanged && !builderChanged) return; - return hash; + _nodeIds = nodeIds; + _defaultColor = defaultColor; + _selectedBorderColor = selectedBorderColor; + _thumbnailBuilder = thumbnailBuilder; + configurationChanged.changed(); } @override void paint(Canvas canvas, Size size) { - for (final node in nodes) { - final position = node.position.value; - final nodeSize = node.size.value; + final retainedIds = {}; + for (final id in _nodeIds) { + final node = projection.nodeSnapshot(id); + if (node == null || !node.isVisible) continue; + retainedIds.add(node.id); + final position = node.position; + final nodeSize = node.size; final bounds = Rect.fromLTWH( position.dx, position.dy, @@ -135,32 +185,136 @@ class _NodesThumbnailPainter extends CustomPainter { nodeSize.height, ); - final isSelected = selectedIds.contains(node.id); + final picture = scene.pictureFor( + node: node, + bounds: bounds, + defaultColor: _defaultColor, + selectedBorderColor: _selectedBorderColor, + thumbnailBuilder: _thumbnailBuilder, + ); + canvas.save(); + canvas.translate(position.dx, position.dy); + canvas.drawPicture(picture); + canvas.restore(); + } + scene.retainOnly(retainedIds); + } - // Try custom thumbnail builder first - if (thumbnailBuilder != null) { - final handled = thumbnailBuilder!(canvas, node, bounds, isSelected); - if (handled) continue; - } + @override + bool shouldRepaint(covariant _NodesThumbnailPainter oldDelegate) => + !identical(this, oldDelegate); +} + +class _SceneConfigurationNotifier extends ChangeNotifier { + void changed() => notifyListeners(); +} + +class _RetainedNodeScene { + final Map _pictures = {}; + + ui.Picture pictureFor({ + required SceneNodeSnapshot node, + required Rect bounds, + required Color defaultColor, + required Color? selectedBorderColor, + required ThumbnailBuilder? thumbnailBuilder, + }) { + final key = _RetainedNodePictureKey( + size: bounds.size, + isSelected: node.isSelected, + thumbnailCacheKey: node.thumbnailCacheKey, + retainedVisualRevision: node.retainedVisualRevision, + defaultColor: defaultColor, + selectedBorderColor: selectedBorderColor, + ); + final retained = _pictures[node.id]; + if (retained != null && retained.key == key) return retained.picture; + + retained?.dispose(); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder)..translate(-bounds.left, -bounds.top); - // Fall back to node's paintThumbnail - node.paintThumbnail( + final handled = + thumbnailBuilder?.call(canvas, node.source, bounds, node.isSelected) ?? + false; + if (!handled) { + node.source.paintThumbnail( canvas, bounds, color: defaultColor, - isSelected: isSelected, + isSelected: node.isSelected, selectedBorderColor: selectedBorderColor, ); } + + final picture = recorder.endRecording(); + _pictures[node.id] = _RetainedNodePicture(key: key, picture: picture); + return picture; } - @override - bool shouldRepaint(covariant _NodesThumbnailPainter oldDelegate) { - // Repaint when fingerprint changes (nodes, positions, visibility, or selection) - if (_fingerprint != oldDelegate._fingerprint) return true; - // Check theme colors - if (defaultColor != oldDelegate.defaultColor) return true; - if (selectedBorderColor != oldDelegate.selectedBorderColor) return true; - return false; + void retainOnly(Set retainedIds) { + final removedIds = [ + for (final id in _pictures.keys) + if (!retainedIds.contains(id)) id, + ]; + for (final id in removedIds) { + _pictures.remove(id)?.dispose(); + } } + + void clear() { + for (final picture in _pictures.values) { + picture.dispose(); + } + _pictures.clear(); + } + + void dispose() => clear(); +} + +class _RetainedNodePicture { + const _RetainedNodePicture({required this.key, required this.picture}); + + final _RetainedNodePictureKey key; + final ui.Picture picture; + + void dispose() => picture.dispose(); +} + +class _RetainedNodePictureKey { + const _RetainedNodePictureKey({ + required this.size, + required this.isSelected, + required this.thumbnailCacheKey, + required this.retainedVisualRevision, + required this.defaultColor, + required this.selectedBorderColor, + }); + + final Size size; + final bool isSelected; + final Object? thumbnailCacheKey; + final int retainedVisualRevision; + final Color defaultColor; + final Color? selectedBorderColor; + + @override + bool operator ==(Object other) => + other is _RetainedNodePictureKey && + other.size == size && + other.isSelected == isSelected && + other.thumbnailCacheKey == thumbnailCacheKey && + other.retainedVisualRevision == retainedVisualRevision && + other.defaultColor == defaultColor && + other.selectedBorderColor == selectedBorderColor; + + @override + int get hashCode => Object.hash( + size, + isSelected, + thumbnailCacheKey, + retainedVisualRevision, + defaultColor, + selectedBorderColor, + ); } diff --git a/packages/vyuh_node_flow/lib/src/editor/node_flow_config.dart b/packages/vyuh_node_flow/lib/src/editor/node_flow_config.dart index ccf91f7..2541173 100644 --- a/packages/vyuh_node_flow/lib/src/editor/node_flow_config.dart +++ b/packages/vyuh_node_flow/lib/src/editor/node_flow_config.dart @@ -40,7 +40,7 @@ export '../plugins/debug/debug_plugin.dart' show DebugMode; /// If no plugins are provided, these defaults are used: /// - [AutoPanPlugin] - autopan near edges (normal mode) /// - [DebugPlugin] - debug overlays (disabled by default) -/// - [LodPlugin] - adaptive level of detail (enabled by default) +/// - [LodPlugin] - adaptive level of detail (disabled by default) /// - [MinimapPlugin] - minimap overlay /// - [SnapPlugin] - grid and alignment snapping (disabled by default) /// - [StatsPlugin] - graph statistics (nodeCount, connectionCount, etc.) diff --git a/packages/vyuh_node_flow/lib/src/editor/scene/graph_scene.dart b/packages/vyuh_node_flow/lib/src/editor/scene/graph_scene.dart new file mode 100644 index 0000000..2b094d8 --- /dev/null +++ b/packages/vyuh_node_flow/lib/src/editor/scene/graph_scene.dart @@ -0,0 +1,361 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/material.dart'; + +import '../../connections/connection.dart'; +import '../../nodes/node.dart'; + +/// The part of a retained node representation changed by a graph mutation. +enum SceneNodeChange { + added, + geometry, + visual, + selection, + visibility, + zOrder, + ports, + interaction, +} + +/// The part of a retained connection representation changed by a mutation. +enum SceneConnectionChange { added, geometry, visual, selection, visibility } + +/// An immutable renderer-facing projection of a [Node]. +/// +/// [source] keeps existing thumbnail painters source-compatible. Rendering and +/// cache decisions should use the copied fields on this snapshot instead of +/// reading mutable MobX state from [source]. +@immutable +class SceneNodeSnapshot { + const SceneNodeSnapshot({ + required this.id, + required this.type, + required this.position, + required this.visualPosition, + required this.size, + required this.isVisible, + required this.isSelected, + required this.isDragging, + required this.isEditing, + required this.zIndex, + required this.layer, + required this.retainedRendering, + required this.portRevision, + required this.thumbnailCacheKey, + required this.retainedVisualRevision, + required this.source, + }); + + factory SceneNodeSnapshot.fromNode(Node node) { + return SceneNodeSnapshot( + id: node.id, + type: node.type, + position: node.position.value, + visualPosition: node.visualPosition.value, + size: node.size.value, + isVisible: node.isVisible, + isSelected: node.isSelected, + isDragging: node.isDragging, + isEditing: node.isEditing, + zIndex: node.currentZIndex, + layer: node.layer, + retainedRendering: node.retainedRendering, + portRevision: Object.hashAll(node.ports), + thumbnailCacheKey: node.thumbnailCacheKey, + retainedVisualRevision: node.retainedVisualRevision, + source: node, + ); + } + + final String id; + final String type; + final Offset position; + final Offset visualPosition; + final Size size; + final bool isVisible; + final bool isSelected; + final bool isDragging; + final bool isEditing; + final int zIndex; + final NodeRenderLayer layer; + final RetainedNodeRendering retainedRendering; + final int portRevision; + final Object? thumbnailCacheKey; + final int retainedVisualRevision; + final Node source; +} + +/// An immutable renderer-facing projection of a [Connection]. +@immutable +class SceneConnectionSnapshot { + const SceneConnectionSnapshot({ + required this.id, + required this.sourceNodeId, + required this.sourcePortId, + required this.targetNodeId, + required this.targetPortId, + required this.isVisible, + required this.isSelected, + required this.isAnimated, + required this.visualRevision, + required this.source, + }); + + factory SceneConnectionSnapshot.fromConnection(Connection connection) { + return SceneConnectionSnapshot( + id: connection.id, + sourceNodeId: connection.sourceNodeId, + sourcePortId: connection.sourcePortId, + targetNodeId: connection.targetNodeId, + targetPortId: connection.targetPortId, + isVisible: connection.visible, + isSelected: connection.selected, + isAnimated: connection.animated, + visualRevision: Object.hash( + connection.style, + connection.startLabel, + connection.label, + connection.endLabel, + connection.startPoint, + connection.endPoint, + connection.color, + connection.selectedColor, + connection.strokeWidth, + connection.selectedStrokeWidth, + connection.animationEffect, + ), + source: connection, + ); + } + + final String id; + final String sourceNodeId; + final String sourcePortId; + final String targetNodeId; + final String targetPortId; + final bool isVisible; + final bool isSelected; + final bool isAnimated; + final int visualRevision; + final Connection source; +} + +/// A stable immutable view of the complete renderer scene. +@immutable +class SceneSnapshot { + SceneSnapshot({ + required this.revision, + required Map> nodes, + required Map> connections, + }) : nodes = UnmodifiableMapView(Map.of(nodes)), + connections = UnmodifiableMapView(Map.of(connections)); + + final int revision; + final Map> nodes; + final Map> connections; +} + +/// A coalesced immutable change between two scene revisions. +@immutable +class SceneDelta { + SceneDelta({ + required this.fromRevision, + required this.toRevision, + required Map> nodes, + required Map> nodeChanges, + required Set removedNodeIds, + required Map> connections, + required Map> connectionChanges, + required Set removedConnectionIds, + }) : nodes = UnmodifiableMapView(Map.of(nodes)), + nodeChanges = UnmodifiableMapView({ + for (final entry in nodeChanges.entries) + entry.key: Set.unmodifiable(entry.value), + }), + removedNodeIds = Set.unmodifiable(removedNodeIds), + connections = UnmodifiableMapView(Map.of(connections)), + connectionChanges = UnmodifiableMapView({ + for (final entry in connectionChanges.entries) + entry.key: Set.unmodifiable(entry.value), + }), + removedConnectionIds = Set.unmodifiable(removedConnectionIds); + + final int fromRevision; + final int toRevision; + final Map> nodes; + final Map> nodeChanges; + final Set removedNodeIds; + final Map> connections; + final Map> connectionChanges; + final Set removedConnectionIds; + + bool get hasNodeChanges => nodes.isNotEmpty || removedNodeIds.isNotEmpty; + bool get hasConnectionChanges => + connections.isNotEmpty || removedConnectionIds.isNotEmpty; +} + +/// A granular signal carrying the most recently published scene delta. +class SceneDeltaListenable extends ChangeNotifier { + SceneDelta? get value => _value; + SceneDelta? _value; + + void publish(SceneDelta delta) { + _value = delta; + notifyListeners(); + } +} + +/// Maintains immutable render snapshots and emits coalesced scene deltas. +/// +/// Writes update O(1) entity entries immediately. Notifications are merged in +/// a microtask so a MobX action or controller batch causes one render invalidation +/// instead of rebuilding the widget tree for every changed entity. +class GraphSceneProjection { + final Map> _nodes = {}; + final Map> _connections = {}; + + final Map> _pendingNodeChanges = {}; + final Map> _pendingConnectionChanges = {}; + final Set _pendingRemovedNodeIds = {}; + final Set _pendingRemovedConnectionIds = {}; + + final SceneDeltaListenable nodeDeltas = SceneDeltaListenable(); + final SceneDeltaListenable connectionDeltas = + SceneDeltaListenable(); + + int _revision = 0; + bool _flushScheduled = false; + bool _disposed = false; + + int get revision => _revision; + + SceneSnapshot get snapshot => SceneSnapshot( + revision: _revision, + nodes: _nodes, + connections: _connections, + ); + + SceneNodeSnapshot? nodeSnapshot(String id) => _nodes[id]; + SceneConnectionSnapshot? connectionSnapshot(String id) => _connections[id]; + + void seed({ + required Iterable> nodes, + required Iterable> connections, + }) { + _nodes + ..clear() + ..addEntries( + nodes.map( + (node) => MapEntry(node.id, SceneNodeSnapshot.fromNode(node)), + ), + ); + _connections + ..clear() + ..addEntries( + connections.map( + (connection) => MapEntry( + connection.id, + SceneConnectionSnapshot.fromConnection(connection), + ), + ), + ); + } + + void upsertNode(Node node, Set changes) { + if (_disposed) return; + _nodes[node.id] = SceneNodeSnapshot.fromNode(node); + _pendingRemovedNodeIds.remove(node.id); + _pendingNodeChanges.putIfAbsent(node.id, () => {}).addAll(changes); + _scheduleFlush(); + } + + void removeNode(String id) { + if (_disposed || _nodes.remove(id) == null) return; + _pendingNodeChanges.remove(id); + _pendingRemovedNodeIds.add(id); + _scheduleFlush(); + } + + void upsertConnection( + Connection connection, + Set changes, + ) { + if (_disposed) return; + _connections[connection.id] = SceneConnectionSnapshot.fromConnection( + connection, + ); + _pendingRemovedConnectionIds.remove(connection.id); + _pendingConnectionChanges + .putIfAbsent(connection.id, () => {}) + .addAll(changes); + _scheduleFlush(); + } + + void removeConnection(String id) { + if (_disposed || _connections.remove(id) == null) return; + _pendingConnectionChanges.remove(id); + _pendingRemovedConnectionIds.add(id); + _scheduleFlush(); + } + + void _scheduleFlush() { + if (_flushScheduled) return; + _flushScheduled = true; + scheduleMicrotask(_flush); + } + + void _flush() { + _flushScheduled = false; + if (_disposed) return; + if (_pendingNodeChanges.isEmpty && + _pendingRemovedNodeIds.isEmpty && + _pendingConnectionChanges.isEmpty && + _pendingRemovedConnectionIds.isEmpty) { + return; + } + + final fromRevision = _revision; + _revision++; + final changedNodes = >{}; + for (final id in _pendingNodeChanges.keys) { + final snapshot = _nodes[id]; + if (snapshot != null) changedNodes[id] = snapshot; + } + final changedConnections = >{}; + for (final id in _pendingConnectionChanges.keys) { + final snapshot = _connections[id]; + if (snapshot != null) changedConnections[id] = snapshot; + } + final delta = SceneDelta( + fromRevision: fromRevision, + toRevision: _revision, + nodes: changedNodes, + nodeChanges: _pendingNodeChanges, + removedNodeIds: _pendingRemovedNodeIds, + connections: changedConnections, + connectionChanges: _pendingConnectionChanges, + removedConnectionIds: _pendingRemovedConnectionIds, + ); + + _pendingNodeChanges.clear(); + _pendingRemovedNodeIds.clear(); + _pendingConnectionChanges.clear(); + _pendingRemovedConnectionIds.clear(); + + if (delta.hasNodeChanges) nodeDeltas.publish(delta); + if (delta.hasConnectionChanges) connectionDeltas.publish(delta); + } + + void dispose() { + _disposed = true; + nodeDeltas.dispose(); + connectionDeltas.dispose(); + _nodes.clear(); + _connections.clear(); + _pendingNodeChanges.clear(); + _pendingConnectionChanges.clear(); + _pendingRemovedNodeIds.clear(); + _pendingRemovedConnectionIds.clear(); + } +} diff --git a/packages/vyuh_node_flow/lib/src/nodes/node.dart b/packages/vyuh_node_flow/lib/src/nodes/node.dart index 8632f2c..68ffd6e 100644 --- a/packages/vyuh_node_flow/lib/src/nodes/node.dart +++ b/packages/vyuh_node_flow/lib/src/nodes/node.dart @@ -61,6 +61,17 @@ enum NodeRenderLayer { foreground, } +/// How a node participates in a retained painted scene. +enum RetainedNodeRendering { + /// Cache the painted representation until its visual key or retained visual + /// revision changes. Best for static content and occasional updates. + cached, + + /// Keep this node as a live Flutter widget over the retained base scene. + /// Use for continuously animated, streaming, or frequently changing content. + live, +} + /// Represents a single node in the flow graph. /// /// A node is a visual element that can be connected to other nodes via [Port]s. @@ -126,6 +137,7 @@ class Node { this.layer = NodeRenderLayer.middle, this.locked = false, this.selectable = true, + this.retainedRendering = RetainedNodeRendering.cached, this.widgetBuilder, this.theme, }) : size = Observable(size ?? const Size(150, 100)), @@ -196,6 +208,9 @@ class Node { /// Whether this node participates in marquee selection. final bool selectable; + /// Whether retained scenes cache this node or keep it as a live widget. + final RetainedNodeRendering retainedRendering; + /// Per-instance widget builder for custom node rendering. /// /// When provided, this builder takes precedence over both: @@ -433,6 +448,45 @@ class Node { /// whenever that painted representation changes. Object? get thumbnailCacheKey => data; + /// Monotonic invalidation revision for retained node pictures. + int get retainedVisualRevision => _retainedVisualRevision.value; + + final Observable _retainedVisualRevision = Observable(0); + + /// Registers a lightweight listener for renderer-relevant state changes. + /// + /// Unlike a MobX reaction, these change listeners do not make the fields + /// observed and therefore preserve the existing direct-Observable API. The + /// returned callback removes every listener installed by this method. + VoidCallback observeSceneChanges(VoidCallback listener) { + final disposers = [ + position.observe((_) => listener()), + visualPosition.observe((_) => listener()), + size.observe((_) => listener()), + selected.observe((_) => listener()), + dragging.observe((_) => listener()), + zIndex.observe((_) => listener()), + _isVisible.observe((_) => listener()), + _isEditing.observe((_) => listener()), + _retainedVisualRevision.observe((_) => listener()), + ports.observe((_) => listener()), + ]; + return () { + for (final dispose in disposers) { + dispose(); + } + }; + } + + /// Invalidates this node's cached retained picture without rebuilding the + /// rest of the scene. + /// + /// Call this after occasional internal visual changes that are not reflected + /// by [thumbnailCacheKey]. Continuously changing content should instead use + /// [RetainedNodeRendering.live]. + void invalidateRetainedVisual() => + runInAction(() => _retainedVisualRevision.value++); + /// Paints a simplified thumbnail representation of this node. /// /// Called when the editor is in thumbnail mode (very zoomed out). diff --git a/packages/vyuh_node_flow/lib/src/nodes/node_container.dart b/packages/vyuh_node_flow/lib/src/nodes/node_container.dart index adfad53..d7d31fb 100644 --- a/packages/vyuh_node_flow/lib/src/nodes/node_container.dart +++ b/packages/vyuh_node_flow/lib/src/nodes/node_container.dart @@ -256,7 +256,7 @@ class NodeContainer extends StatelessWidget { port: port, theme: portTheme, isConnected: isConnected, - snapDistance: portSnapDistance, + snapDistance: port.isConnectable ? portSnapDistance : 0, controller: controller, nodeId: node.id, isOutput: isOutput, diff --git a/packages/vyuh_node_flow/lib/src/plugins/lod/lod_plugin.dart b/packages/vyuh_node_flow/lib/src/plugins/lod/lod_plugin.dart index 23174a7..c45b8cb 100644 --- a/packages/vyuh_node_flow/lib/src/plugins/lod/lod_plugin.dart +++ b/packages/vyuh_node_flow/lib/src/plugins/lod/lod_plugin.dart @@ -60,7 +60,7 @@ class LodPlugin extends NodeFlowPlugin { /// Creates a LOD plugin with optional threshold and visibility settings. /// /// Parameters: - /// - [enabled]: Whether adaptive LOD is enabled (default: true) + /// - [enabled]: Whether adaptive LOD is enabled (default: false) /// - [minThreshold]: Normalized zoom below which [minVisibility] is used (default: 0.03) /// - [midThreshold]: Normalized zoom below which [midVisibility] is used (default: 0.1) /// - [maxInteractiveNodes]: Maximum number of on-screen nodes rendered as @@ -71,7 +71,7 @@ class LodPlugin extends NodeFlowPlugin { /// - [midVisibility]: Visibility settings for medium zoom level (default: standard) /// - [maxVisibility]: Visibility settings for highest zoom level (default: full) LodPlugin({ - bool enabled = true, + bool enabled = false, double minThreshold = 0.03, double midThreshold = 0.1, int maxInteractiveNodes = 200, diff --git a/packages/vyuh_node_flow/pubspec.yaml b/packages/vyuh_node_flow/pubspec.yaml index f3b5871..f34065a 100644 --- a/packages/vyuh_node_flow/pubspec.yaml +++ b/packages/vyuh_node_flow/pubspec.yaml @@ -1,6 +1,6 @@ name: vyuh_node_flow description: A flexible, high-performance node-based flow editor for Flutter. Build visual programming interfaces, workflow editors, diagrams, and data pipelines. -version: 0.31.0 +version: 0.32.0 homepage: https://flow.vyuh.tech repository: https://github.com/vyuh-tech/vyuh_node_flow @@ -28,18 +28,18 @@ resolution: workspace dependencies: collection: ^1.19.1 - equatable: ^2.0.7 + equatable: ^2.1.0 flutter: sdk: flutter flutter_mobx: ^2.3.0 - http: ^1.4.0 - json_annotation: ^4.9.0 - mobx: ^2.5.0 - vector_math: ^2.1.4 + http: ^1.6.0 + json_annotation: ^4.12.0 + mobx: ^2.6.1 + vector_math: ^2.4.2 dev_dependencies: - build_runner: ^2.8.0 + build_runner: ^2.16.0 flutter_lints: ^6.0.0 flutter_test: sdk: flutter - json_serializable: ^6.9.5 + json_serializable: ^6.14.1 diff --git a/packages/vyuh_node_flow/test/helpers/test_factories.dart b/packages/vyuh_node_flow/test/helpers/test_factories.dart index 18ac351..88717d8 100644 --- a/packages/vyuh_node_flow/test/helpers/test_factories.dart +++ b/packages/vyuh_node_flow/test/helpers/test_factories.dart @@ -33,6 +33,7 @@ Node createTestNode({ List? outputPorts, int zIndex = 0, bool visible = true, + RetainedNodeRendering retainedRendering = RetainedNodeRendering.cached, }) { // Use ports if provided, otherwise merge inputPorts and outputPorts final effectivePorts = ports ?? [...?inputPorts, ...?outputPorts]; @@ -46,6 +47,7 @@ Node createTestNode({ ports: effectivePorts, initialZIndex: zIndex, visible: visible, + retainedRendering: retainedRendering, ); // Initialize visual position to match logical position for tests // (in production, the controller calls setVisualPosition with snapping) diff --git a/packages/vyuh_node_flow/test/unit/editor/connections_layer_test.dart b/packages/vyuh_node_flow/test/unit/editor/connections_layer_test.dart index 076bc6f..fbf53ca 100644 --- a/packages/vyuh_node_flow/test/unit/editor/connections_layer_test.dart +++ b/packages/vyuh_node_flow/test/unit/editor/connections_layer_test.dart @@ -1051,7 +1051,9 @@ void main() { ) async { final graph = _createConnectionPartitionGraph( config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 1)], + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 1), + ], ), ); addTearDown(graph.controller.dispose); diff --git a/packages/vyuh_node_flow/test/unit/editor/graph_scene_test.dart b/packages/vyuh_node_flow/test/unit/editor/graph_scene_test.dart new file mode 100644 index 0000000..de9a704 --- /dev/null +++ b/packages/vyuh_node_flow/test/unit/editor/graph_scene_test.dart @@ -0,0 +1,179 @@ +@Tags(['unit']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobx/mobx.dart'; +import 'package:vyuh_node_flow/vyuh_node_flow.dart'; + +import '../../helpers/test_factories.dart'; + +Future flushSceneDelta() => Future.delayed(Duration.zero); + +void main() { + setUp(resetTestCounters); + + group('GraphSceneProjection', () { + test('keeps previously captured snapshots immutable', () async { + final node = createTestNode(id: 'node', position: const Offset(10, 20)); + final projection = GraphSceneProjection() + ..seed(nodes: [node], connections: const []); + addTearDown(projection.dispose); + final before = projection.snapshot; + + runInAction(() => node.position.value = const Offset(80, 90)); + projection.upsertNode(node, const {SceneNodeChange.geometry}); + await flushSceneDelta(); + + expect(before.nodes['node']!.position, const Offset(10, 20)); + expect(projection.snapshot.nodes['node']!.position, const Offset(80, 90)); + expect( + () => before.nodes['other'] = SceneNodeSnapshot.fromNode(node), + throwsUnsupportedError, + ); + }); + + test('coalesces node changes into one revision and notification', () async { + final node = createTestNode(id: 'node'); + final projection = GraphSceneProjection() + ..seed(nodes: [node], connections: const []); + addTearDown(projection.dispose); + var notifications = 0; + projection.nodeDeltas.addListener(() => notifications++); + + projection.upsertNode(node, const {SceneNodeChange.geometry}); + projection.upsertNode(node, const {SceneNodeChange.selection}); + projection.upsertNode(node, const {SceneNodeChange.visual}); + await flushSceneDelta(); + + expect(notifications, 1); + expect(projection.revision, 1); + expect(projection.nodeDeltas.value!.nodeChanges['node'], { + SceneNodeChange.geometry, + SceneNodeChange.selection, + SceneNodeChange.visual, + }); + }); + + test('keeps node and connection repaint signals independent', () async { + final source = createTestNodeWithOutputPort(id: 'source'); + final target = createTestNodeWithInputPort(id: 'target'); + final connection = createTestConnection( + id: 'edge', + sourceNodeId: source.id, + targetNodeId: target.id, + ); + final projection = GraphSceneProjection() + ..seed(nodes: [source, target], connections: [connection]); + addTearDown(projection.dispose); + var nodeNotifications = 0; + var connectionNotifications = 0; + projection.nodeDeltas.addListener(() => nodeNotifications++); + projection.connectionDeltas.addListener(() => connectionNotifications++); + + connection.selected = true; + projection.upsertConnection(connection, const { + SceneConnectionChange.selection, + }); + await flushSceneDelta(); + + expect(nodeNotifications, 0); + expect(connectionNotifications, 1); + expect( + projection.connectionDeltas.value!.connections['edge']!.isSelected, + isTrue, + ); + }); + }); + + group('NodeFlowController scene adapter', () { + test( + 'projects direct legacy node mutations as one granular delta', + () async { + final node = createTestNode(id: 'node'); + final controller = createTestController(nodes: [node]); + addTearDown(controller.dispose); + var notifications = 0; + controller.sceneProjection.nodeDeltas.addListener( + () => notifications++, + ); + + runInAction(() { + node.position.value = const Offset(100, 120); + node.size.value = const Size(220, 140); + node.selected.value = true; + node.invalidateRetainedVisual(); + }); + await flushSceneDelta(); + + expect(notifications, 1); + final delta = controller.sceneProjection.nodeDeltas.value!; + expect(delta.nodeChanges['node'], contains(SceneNodeChange.geometry)); + expect(delta.nodeChanges['node'], contains(SceneNodeChange.selection)); + expect(delta.nodeChanges['node'], contains(SceneNodeChange.visual)); + expect(delta.nodes['node']!.position, const Offset(100, 120)); + expect(delta.nodes['node']!.size, const Size(220, 140)); + expect(delta.nodes['node']!.isSelected, isTrue); + }, + ); + + test('node geometry invalidates only its incident connections', () async { + final source = createTestNodeWithOutputPort(id: 'source'); + final target = createTestNodeWithInputPort(id: 'target'); + final unrelatedA = createTestNodeWithOutputPort(id: 'unrelated-a'); + final unrelatedB = createTestNodeWithInputPort(id: 'unrelated-b'); + final incident = createTestConnection( + id: 'incident', + sourceNodeId: source.id, + targetNodeId: target.id, + ); + final unrelated = createTestConnection( + id: 'unrelated', + sourceNodeId: unrelatedA.id, + targetNodeId: unrelatedB.id, + ); + final controller = createTestController( + nodes: [source, target, unrelatedA, unrelatedB], + connections: [incident, unrelated], + ); + addTearDown(controller.dispose); + + controller.setNodePosition(source.id, const Offset(40, 50)); + await flushSceneDelta(); + + final delta = controller.sceneProjection.connectionDeltas.value!; + expect(delta.connectionChanges.keys, {'incident'}); + expect( + delta.connectionChanges['incident'], + contains(SceneConnectionChange.geometry), + ); + }); + + test('loadGraph replaces same-id entities and their observers', () async { + final original = createTestNode(id: 'same', data: 'old'); + final replacement = createTestNode( + id: 'same', + data: 'new', + position: const Offset(200, 0), + ); + final controller = createTestController(nodes: [original]); + addTearDown(controller.dispose); + + controller.loadGraph( + NodeGraph( + nodes: [replacement], + connections: const [], + viewport: const GraphViewport(x: 0, y: 0, zoom: 1), + ), + ); + await flushSceneDelta(); + replacement.isSelected = true; + await flushSceneDelta(); + + final snapshot = controller.sceneProjection.nodeSnapshot('same')!; + expect(snapshot.source, same(replacement)); + expect(snapshot.position, const Offset(200, 0)); + expect(snapshot.isSelected, isTrue); + }); + }); +} diff --git a/packages/vyuh_node_flow/test/unit/editor/nodes_layer_adaptive_test.dart b/packages/vyuh_node_flow/test/unit/editor/nodes_layer_adaptive_test.dart index af928a6..10ab2bd 100644 --- a/packages/vyuh_node_flow/test/unit/editor/nodes_layer_adaptive_test.dart +++ b/packages/vyuh_node_flow/test/unit/editor/nodes_layer_adaptive_test.dart @@ -24,7 +24,9 @@ void main() { createTestNode(id: 'three'), ], config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 2)], + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 2), + ], ), ); addTearDown(controller.dispose); @@ -76,7 +78,9 @@ void main() { createTestNode(id: 'two'), ], config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 10)], + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 10), + ], ), ); addTearDown(controller.dispose); @@ -125,7 +129,9 @@ void main() { createTestNode(id: 'two'), ], config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 10)], + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 10), + ], ), ); addTearDown(controller.dispose); @@ -158,6 +164,244 @@ void main() { expect(thumbnail.nodes!.map((node) => node.id), ['two']); }); + testWidgets('editing nodes remain promoted in dense retained scenes', ( + tester, + ) async { + final first = createTestNode(id: 'one'); + final controller = NodeFlowController( + nodes: [ + first, + createTestNode(id: 'two'), + ], + config: NodeFlowConfig( + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 1), + ], + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + NodesLayer.middle( + controller, + (context, node) => Text(node.id), + ), + ], + ), + ), + ); + + expect(controller.lod!.sceneMode, NodeSceneMode.overview); + expect(find.text('one'), findsNothing); + + first.isEditing = true; + await tester.pump(); + + expect(find.text('one'), findsOneWidget); + final thumbnail = tester.widget>( + find.byType(NodesThumbnailLayer), + ); + expect(thumbnail.nodes!.map((node) => node.id), ['two']); + }); + + testWidgets('live nodes remain widgets over dense retained scenes', ( + tester, + ) async { + final controller = NodeFlowController( + nodes: [ + createTestNode( + id: 'live', + retainedRendering: RetainedNodeRendering.live, + ), + createTestNode(id: 'cached'), + ], + config: NodeFlowConfig( + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 1), + ], + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + NodesLayer.middle( + controller, + (context, node) => Text(node.id), + ), + ], + ), + ), + ); + + expect(find.text('live'), findsOneWidget); + expect(find.text('cached'), findsNothing); + final thumbnail = tester.widget>( + find.byType(NodesThumbnailLayer), + ); + expect(thumbnail.nodes!.map((node) => node.id), ['cached']); + }); + + testWidgets('retains unchanged node pictures across scene repaints', ( + tester, + ) async { + final controller = NodeFlowController( + nodes: [ + createTestNode(id: 'one'), + createTestNode(id: 'two', position: const Offset(200, 0)), + ], + config: NodeFlowConfig( + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 1), + ], + ), + ); + addTearDown(controller.dispose); + final paintCounts = {}; + bool paintNode( + Canvas canvas, + Node node, + Rect bounds, + bool isSelected, + ) { + paintCounts.update(node.id, (count) => count + 1, ifAbsent: () => 1); + canvas.drawRect(bounds, Paint()..color = Colors.blue); + return true; + } + + await tester.pumpWidget( + MaterialApp( + home: SizedBox( + width: 800, + height: 600, + child: Stack( + children: [ + NodesLayer.middle( + controller, + (context, node) => Text(node.id), + thumbnailBuilder: paintNode, + ), + ], + ), + ), + ), + ); + await tester.pump(); + + expect(paintCounts, {'one': 1, 'two': 1}); + + controller.moveNode('one', const Offset(20, 0)); + await tester.pump(); + expect(paintCounts, { + 'one': 1, + 'two': 1, + }, reason: 'position-only changes should reuse node-local pictures'); + + controller.selectNode('one'); + await tester.pump(); + expect(paintCounts, {'one': 2, 'two': 1}); + + controller.nodes['two']!.invalidateRetainedVisual(); + await tester.pump(); + await tester.pump(); + expect(paintCounts, {'one': 2, 'two': 2}); + }); + + testWidgets('repaints a stable retained delegate when its node set changes', ( + tester, + ) async { + final controller = NodeFlowController( + nodes: [ + createTestNode(id: 'one'), + createTestNode(id: 'two'), + ], + config: NodeFlowConfig( + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 1), + ], + ), + ); + addTearDown(controller.dispose); + final paintCounts = {}; + + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + NodesLayer.middle( + controller, + (context, node) => Text(node.id), + thumbnailBuilder: (canvas, node, bounds, isSelected) { + paintCounts.update( + node.id, + (count) => count + 1, + ifAbsent: () => 1, + ); + return true; + }, + ), + ], + ), + ), + ); + await tester.pump(); + expect(paintCounts, {'one': 1, 'two': 1}); + + controller.addNode(createTestNode(id: 'three')); + await tester.pump(); + await tester.pump(); + + expect(paintCounts, { + 'one': 1, + 'two': 1, + 'three': 1, + }, reason: 'the stable painter must schedule paint for a new visible ID'); + }); + + testWidgets('retained layer follows a replacement controller', ( + tester, + ) async { + final first = NodeFlowController( + nodes: [createTestNode(id: 'first')], + ); + final second = NodeFlowController( + nodes: [createTestNode(id: 'second')], + ); + addTearDown(first.dispose); + addTearDown(second.dispose); + final paintedIds = []; + + Widget buildLayer(NodeFlowController controller) { + return MaterialApp( + home: Stack( + children: [ + NodesThumbnailLayer( + controller: controller, + thumbnailBuilder: (canvas, node, bounds, isSelected) { + paintedIds.add(node.id); + return true; + }, + ), + ], + ), + ); + } + + await tester.pumpWidget(buildLayer(first)); + await tester.pump(); + expect(paintedIds, ['first']); + + paintedIds.clear(); + await tester.pumpWidget(buildLayer(second)); + await tester.pump(); + expect(paintedIds, ['second']); + }); + testWidgets('dragged note remains a full widget in dense overview', ( tester, ) async { @@ -176,7 +420,9 @@ void main() { ), ], config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 1)], + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 1), + ], ), ); addTearDown(controller.dispose); @@ -258,7 +504,9 @@ void main() { createTestNode(id: 'three'), ], config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 1)], + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 1), + ], ), ); addTearDown(controller.dispose); diff --git a/packages/vyuh_node_flow/test/unit/lod/lod_plugin_test.dart b/packages/vyuh_node_flow/test/unit/lod/lod_plugin_test.dart index e81eab2..bd72b38 100644 --- a/packages/vyuh_node_flow/test/unit/lod/lod_plugin_test.dart +++ b/packages/vyuh_node_flow/test/unit/lod/lod_plugin_test.dart @@ -497,18 +497,25 @@ void main() { }); group('LodPlugin - Adaptive Overview', () { - test('500 visible nodes use overview mode with default settings', () { - final controller = NodeFlowController( - nodes: List.generate(500, (index) => createTestNode(id: 'node-$index')), - ); - final lod = controller.lod!; + test( + '500 visible nodes use overview mode when adaptive LOD is enabled', + () { + final controller = NodeFlowController( + nodes: List.generate( + 500, + (index) => createTestNode(id: 'node-$index'), + ), + config: NodeFlowConfig(plugins: [LodPlugin(enabled: true)]), + ); + final lod = controller.lod!; - expect(lod.isEnabled, isTrue); - expect(lod.maxInteractiveNodes, 200); - expect(lod.useThumbnailMode, isTrue); + expect(lod.isEnabled, isTrue); + expect(lod.maxInteractiveNodes, 200); + expect(lod.useThumbnailMode, isTrue); - controller.dispose(); - }); + controller.dispose(); + }, + ); test('visible count crossing threshold switches modes reactively', () { final controller = NodeFlowController( @@ -518,7 +525,9 @@ void main() { createTestNode(id: 'three'), ], config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 2)], + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 2), + ], ), ); final lod = controller.lod!; @@ -539,7 +548,13 @@ void main() { config: NodeFlowConfig( minZoom: 0, maxZoom: 1, - plugins: [LodPlugin(minThreshold: 0.2, maxInteractiveNodes: 200)], + plugins: [ + LodPlugin( + enabled: true, + minThreshold: 0.2, + maxInteractiveNodes: 200, + ), + ], ), initialViewport: const GraphViewport(zoom: 0.1), ); @@ -575,7 +590,9 @@ void main() { ), ], config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 2)], + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 2), + ], ), ); controller.initController( @@ -609,7 +626,13 @@ void main() { final controller = NodeFlowController( nodes: [createTestNode(id: 'one')], config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 10)], + plugins: [ + LodPlugin( + enabled: true, + minThreshold: 0, + maxInteractiveNodes: 10, + ), + ], ), ); final lod = controller.lod!; diff --git a/packages/vyuh_node_flow/test/unit/lod/lod_state_test.dart b/packages/vyuh_node_flow/test/unit/lod/lod_state_test.dart index b6b1103..723ae9a 100644 --- a/packages/vyuh_node_flow/test/unit/lod/lod_state_test.dart +++ b/packages/vyuh_node_flow/test/unit/lod/lod_state_test.dart @@ -111,7 +111,7 @@ void main() { expect(lod.minThreshold, equals(0.03)); expect(lod.midThreshold, equals(0.1)); expect(lod.maxInteractiveNodes, equals(200)); - expect(lod.isEnabled, isTrue); + expect(lod.isEnabled, isFalse); expect(lod.minVisibility, same(DetailVisibility.minimal)); expect(lod.midVisibility, same(DetailVisibility.standard)); expect(lod.maxVisibility, same(DetailVisibility.full)); @@ -119,23 +119,27 @@ void main() { controller.dispose(); }); - test('default adaptive LOD responds to zoom', () { + test('default LOD keeps full widgets and details at every zoom level', () { final controller = NodeFlowController( config: NodeFlowConfig(minZoom: 0.0, maxZoom: 1.0), - initialViewport: const GraphViewport(zoom: 0.1), + initialViewport: const GraphViewport(zoom: 0.0), ); final lod = controller.lod!; - expect(lod.isEnabled, isTrue); - - // Normalized zoom 0.1 is at the max-detail threshold. + expect(lod.isEnabled, isFalse); expect(lod.currentVisibility, same(DetailVisibility.full)); - - controller.setViewport(const GraphViewport(zoom: 0.0)); - expect(lod.currentVisibility, same(DetailVisibility.minimal)); + expect(lod.sceneMode, NodeSceneMode.widgets); + expect(lod.showNodeContent, isTrue); + expect(lod.showPorts, isTrue); + expect(lod.showPortLabels, isTrue); + expect(lod.showConnectionLines, isTrue); + expect(lod.showConnectionLabels, isTrue); + expect(lod.showConnectionEndpoints, isTrue); + expect(lod.showResizeHandles, isTrue); controller.setViewport(const GraphViewport(zoom: 1.0)); expect(lod.currentVisibility, same(DetailVisibility.full)); + expect(lod.sceneMode, NodeSceneMode.widgets); controller.dispose(); }); diff --git a/packages/vyuh_node_flow/test/unit/plugins/builtin_plugins_test.dart b/packages/vyuh_node_flow/test/unit/plugins/builtin_plugins_test.dart index 9a94b81..ef690eb 100644 --- a/packages/vyuh_node_flow/test/unit/plugins/builtin_plugins_test.dart +++ b/packages/vyuh_node_flow/test/unit/plugins/builtin_plugins_test.dart @@ -545,7 +545,7 @@ void main() { test('creates with default values', () { final ext = LodPlugin(); - expect(ext.isEnabled, isTrue); + expect(ext.isEnabled, isFalse); expect(ext.minThreshold, equals(0.03)); expect(ext.midThreshold, equals(0.1)); expect(ext.maxInteractiveNodes, equals(200)); @@ -619,15 +619,16 @@ void main() { }); group('LodPlugin - Visibility Calculations', () { - test('default plugin uses minimal visibility when zoomed out', () { + test('default plugin preserves full visibility when zoomed out', () { final controller = NodeFlowController( config: NodeFlowConfig(minZoom: 0.0, maxZoom: 1.0), initialViewport: const GraphViewport(zoom: 0.0), // Very zoomed out ); final lod = controller.lod!; - expect(lod.isEnabled, isTrue); - expect(lod.currentVisibility, equals(DetailVisibility.minimal)); + expect(lod.isEnabled, isFalse); + expect(lod.currentVisibility, equals(DetailVisibility.full)); + expect(lod.sceneMode, equals(NodeSceneMode.widgets)); controller.dispose(); }); diff --git a/packages/vyuh_node_flow/test/widget/node_flow_editor_test.dart b/packages/vyuh_node_flow/test/widget/node_flow_editor_test.dart index de002a1..91524e1 100644 --- a/packages/vyuh_node_flow/test/widget/node_flow_editor_test.dart +++ b/packages/vyuh_node_flow/test/widget/node_flow_editor_test.dart @@ -302,6 +302,7 @@ void main() { testWidgets( 'camera gesture replaces node widgets with painted navigation', (tester) async { + controller.lod!.enable(); controller.addNodes([ createTestNode(id: 'node-1', position: const Offset(100, 100)), createTestNode(id: 'node-2', position: const Offset(300, 100)), @@ -406,7 +407,9 @@ void main() { ), ], config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 1)], + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 1), + ], ), ); var tapCount = 0; @@ -476,7 +479,9 @@ void main() { ), ], config: NodeFlowConfig( - plugins: [LodPlugin(minThreshold: 0, maxInteractiveNodes: 1)], + plugins: [ + LodPlugin(enabled: true, minThreshold: 0, maxInteractiveNodes: 1), + ], ), ); diff --git a/pubspec.yaml b/pubspec.yaml index f7ecad9..d14e722 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ workspace: dev_dependencies: flutter_lints: ^6.0.0 - melos: ^7.3.0 + melos: ^7.8.1 melos: ide: @@ -33,6 +33,20 @@ melos: exec: concurrency: 1 + test:core: + description: Fast contract gate for graph APIs, rendering, spatial queries, and viewport math + run: >- + flutter test + packages/vyuh_node_flow/test/unit/controller/node_api_test.dart + packages/vyuh_node_flow/test/unit/controller/connection_api_test.dart + packages/vyuh_node_flow/test/unit/controller/graph_api_test.dart + packages/vyuh_node_flow/test/unit/editor/graph_scene_test.dart + packages/vyuh_node_flow/test/unit/editor/nodes_layer_adaptive_test.dart + packages/vyuh_node_flow/test/unit/editor/connections_layer_test.dart + packages/vyuh_node_flow/test/unit/graph/graph_test.dart + packages/vyuh_node_flow/test/unit/spatial/graph_spatial_index_test.dart + packages/vyuh_node_flow/test/unit/viewport/viewport_transform_test.dart + format: description: Format all packages run: dart format . diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index d528492..47dbd06 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -61,13 +61,13 @@ export default defineConfig({ ['link', {rel: 'preconnect', href: 'https://api.iconify.design'}], ['link', {rel: 'preconnect', href: 'https://flow.demo.vyuh.tech'}], // Non-blocking font loading - reduced weights for faster load - // Montserrat: 400 (regular body), 600 (semibold), 700 (bold), 900 (black for headers) - // JetBrains Mono: 400 only (code doesn't need variants) + // Bricolage Grotesque carries the editorial display voice while + // Instrument Sans keeps product copy crisp at smaller sizes. [ 'link', { rel: 'stylesheet', - href: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&family=Montserrat:wght@400;600;700;900&display=swap', + href: 'https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap', media: 'print', onload: "this.media='all'", }, @@ -76,7 +76,7 @@ export default defineConfig({ [ 'noscript', {}, - '', + '', ], ['link', {rel: 'icon', href: '/icon.svg', type: 'image/svg+xml'}], ['meta', {property: 'og:type', content: 'website'}], @@ -136,6 +136,7 @@ export default defineConfig({ nav: [ {text: 'Docs', link: '/docs/start/installation'}, + {text: 'Community', link: '/#community'}, {component: 'NavProBadge'}, { text: 'Links', diff --git a/website/.vitepress/theme/HomePage.vue b/website/.vitepress/theme/HomePage.vue index 18219f5..7eb5d6c 100644 --- a/website/.vitepress/theme/HomePage.vue +++ b/website/.vitepress/theme/HomePage.vue @@ -5,6 +5,7 @@ import Badge from './components/Badge.vue'; import BentoGrid from './components/BentoGrid.vue'; import CtaButton from './components/CtaButton.vue'; import CtaSection from './components/CtaSection.vue'; +import CommunityShowcase from './components/CommunityShowcase.vue'; import DemoFrame from './components/DemoFrame.vue'; import FeatureCard from './components/FeatureCard.vue'; import FeatureSection from './components/FeatureSection.vue'; @@ -18,7 +19,6 @@ import Section from './components/Section.vue'; import SectionConnector from './components/SectionConnector.vue'; import SectionHeader from './components/SectionHeader.vue'; import SiteFooter from './components/SiteFooter.vue'; -import WordFlipper from './components/WordFlipper.vue'; // Demo URL - use localhost in dev mode, production URL otherwise const demoUrl = computed(() => { @@ -29,18 +29,6 @@ const demoUrl = computed(() => { : `https://flow.demo.vyuh.tech/${path}`; }); -// Full words for the flipper -const flipperWords = [ - 'WORK FLOW', - 'DATA FLOW', - 'TASK FLOW', - 'PROCESS FLOW', - 'NODE FLOW', - 'CONTENT FLOW', - 'STATE FLOW', - 'CODE FLOW', -]; - // Marquee content const marqueeLines = [ { @@ -181,7 +169,7 @@ const capabilities = [ { icon: 'ph:eye-fill', title: 'Level of Detail', - desc: 'Progressive rendering that adapts node complexity based on zoom level for smooth scaling.', + desc: 'Opt-in progressive rendering for graph-heavy experiences that need an adaptive overview.', color: 'teal' as const, }, { @@ -323,7 +311,34 @@ const useCases = [ }, ]; -// Blinking grid cells generator +const communityProjects = [ + { + title: 'Vyuh Workflow Editor', + description: + 'A visual workflow editor for building BPMN-inspired workflows in Flutter, powered by Vyuh Node Flow.', + authorHandle: '@vyuh_tech', + href: 'https://editor.flow.vyuh.tech', + media: { + type: 'image' as const, + src: 'https://editor.flow.vyuh.tech/workflow-editor-banner.png', + alt: 'Vyuh Workflow Editor showing a visual workflow built with connected nodes', + }, + }, + { + title: 'Zen Router Dev Tools', + description: + 'A Flutter DevTools extension for exploring route graphs in real time, testing deep links, and debugging navigation with Vyuh Node Flow.', + authorHandle: '@definev2', + href: 'https://pub.dev/packages/zenrouter', + media: { + type: 'video' as const, + src: '/videos/zenrouter-devtools.mp4', + poster: '/images/community/zenrouter-devtools.webp', + alt: 'Zen Router Dev Tools showing a live route graph powered by Vyuh Node Flow', + }, + }, +]; + const generateBlinkCells = (count: number, seed: number) => { const cells = []; for (let i = 0; i < count; i++) { @@ -342,35 +357,24 @@ const heroBlinkCells = generateBlinkCells(20, 42);