diff --git a/example/lib/app.dart b/example/lib/app.dart index c5457af..fe70501 100644 --- a/example/lib/app.dart +++ b/example/lib/app.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:infinite_lazy_2d_grid/core/controller.dart'; import 'package:infinite_lazy_2d_grid/infinite_lazy_2d_grid.dart'; class App extends StatefulWidget { diff --git a/lib/core/controller.dart b/lib/core/controller.dart deleted file mode 100644 index bdddf2a..0000000 --- a/lib/core/controller.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:infinite_lazy_2d_grid/utils/conversions.dart'; -import 'package:infinite_lazy_2d_grid/utils/styles.dart'; - -// ------------------------------ Public Types ------------------------------- - -typedef WidgetBuilder = Widget Function(); - -// ------------------------------ Private Types ------------------------------ - -// some simple exceptions -// ignore: non_constant_identifier_names -final _ChildNotFoundException = Exception('Child with the given ID does not exist'); - -class _ChildInfo { - Offset gsPosition; - final WidgetBuilder builder; - - _ChildInfo({required this.gsPosition, required this.builder}); -} - -class ChildInfo { - Offset gsPosition; - Offset ssPosition; - Widget child; - ChildInfo({required this.gsPosition, required this.ssPosition, required this.child}); -} - -// ------------------------------ Controller ------------------------------ - -/// Every canvas view needs one and this handles the positioning logic -class CanvasController with ChangeNotifier { - int _nextId = 0; // surely we won't run out of IDs, Clueless - Offset _gsTopLeftOffset = Offset.zero; - double _baseScale, _scale; - late Size _canvasSize; - final Map _children = {}; // int for IDs - bool debug; - - CanvasController({this.debug = false, double initialScale = 1}) - : _scale = initialScale, - _baseScale = initialScale, - assert(initialScale > 0, 'Initial scale must be greater than 0'); - - // -------------------- getters -------------------- - Offset get offset => _gsTopLeftOffset; - double get scale => _scale; - Size get canvasSize => _canvasSize; - // -------------------- public functions -------------------- - - void onCanvasSizeChange(Size size) { - _canvasSize = size; - } - - // -------- child manip -------- - - int addChild(Offset position, WidgetBuilder builder) { - _children[_nextId] = _ChildInfo(gsPosition: position, builder: builder); - return _nextId++; - } - - void removeChild(int id) { - _children.remove(id); - } - - int updatePosition(int id, Offset newPosition) { - if (_children.containsKey(id)) { - _children[id]!.gsPosition = newPosition; - return id; - } else { - throw _ChildNotFoundException; - } - } - - Offset getPosition(int id) { - if (_children.containsKey(id)) { - return _children[id]!.gsPosition; - } else { - throw _ChildNotFoundException; - } - } - - void onScaleStart(ScaleStartDetails details) { - _baseScale = _scale; - } - - // -------- gesture handling -------- - void onScaleUpdate(ScaleUpdateDetails details) { - // uses usual display conventions and final vector postion - initial vector position - // convention is that if I drag from right to left, dx is negative - // for top to bottom, dy is postive - - // scale + offset is => scale then offset - - if (details.scale != 1) { - final newScale = _baseScale * details.scale; - _gsTopLeftOffset = newGsTopLeftOnScaling(_gsTopLeftOffset, details.localFocalPoint, _scale, newScale); - _scale = newScale; - } - - if (details.focalPointDelta != Offset.zero) { - // if ss distnace is x, and zoom is 2x, gs only moves by x/2 - _gsTopLeftOffset -= details.focalPointDelta / _scale; - } - - notifyListeners(); - } - - void updateScalebyDelta(double delta) { - final focalPoint = Offset(canvasSize.width / 2, canvasSize.height / 2); - final newScale = _scale + delta; - _gsTopLeftOffset = newGsTopLeftOnScaling(_gsTopLeftOffset, focalPoint, _scale, newScale); - _scale = newScale; - notifyListeners(); - } - - List widgetsWithScreenPositions() { - return _children.entries.map((entry) { - final item = entry.value; - final ssPosition = gsToSs(item.gsPosition, _gsTopLeftOffset, _scale); - var child = item.builder(); - if (debug) child = _Debug(id: entry.key, gs: item.gsPosition, ss: ssPosition, child: child); - return ChildInfo(gsPosition: item.gsPosition, ssPosition: ssPosition, child: child); - }).toList(); - } -} - -class _Debug extends StatelessWidget { - final int id; - final Offset gs, ss; - final Widget child; - - const _Debug({required this.id, required this.gs, required this.ss, required this.child}); - - @override - Widget build(BuildContext context) { - return Stack( - clipBehavior: Clip.none, - children: [ - child, - Positioned( - left: 0, - bottom: -60, - child: Text( - 'ID: $id\nGS:(${gs.dx.toInt()},${gs.dy.toInt()})\nSS:(${ss.dx.toInt()},${ss.dy.toInt()})', - style: monospaceStyle, - ), - ), - ], - ); - } -} diff --git a/lib/core/controller/controller.dart b/lib/core/controller/controller.dart new file mode 100644 index 0000000..4004e64 --- /dev/null +++ b/lib/core/controller/controller.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:infinite_lazy_2d_grid/core/spatial_hashing.dart'; +import 'package:infinite_lazy_2d_grid/utils/offset_extensions.dart'; +import '../../utils/conversions.dart'; +import '../../utils/styles.dart'; + +part 'debug.dart'; +part 'types.dart'; + +/// Every canvas view needs one and this handles the positioning logic +class CanvasController with ChangeNotifier { + int _nextId = 0; // surely we won't run out of IDs, Clueless + Offset _gsTopLeftOffset = Offset.zero; + double _baseScale, _scale; + late Size _canvasSize; + final Map _children = {}; // int for IDs + final Offset? buildCacheExtent; + late final Offset _buildExtent; + final Offset _initialBuildExtent; + final Size _hashCellSize; + bool _init = false; + late final SpatialHashing _spatialHash; + + bool debug; + + CanvasController({ + double initialScale = 1, + this.debug = false, + this.buildCacheExtent, + Size hashCellSize = const Size(200, 200), + Offset initialBuildExtent = const Offset(1000, 1000), + }) : _scale = initialScale, + _baseScale = initialScale, + _hashCellSize = hashCellSize, + _initialBuildExtent = initialBuildExtent, + assert(initialScale > 0, 'Initial scale must be greater than 0') { + _spatialHash = SpatialHashing(cellSize: _hashCellSize); + } + + // ==================== Getters ==================== + Offset get offset => _gsTopLeftOffset; + double get scale => _scale; + Size get canvasSize => _canvasSize; + Offset get _ssCenter => Offset(_canvasSize.width / 2, _canvasSize.height / 2); + Offset get _gsCenter => ssToGs(_ssCenter, _gsTopLeftOffset, _scale); + + // ==================== Public Functions ==================== + + /// Update the canvas size when the widget size changes. + void onCanvasSizeChange(Size size) { + if (_init && size == _canvasSize) return; + + _canvasSize = size; // allow resize due to canvas resize + if (!_init) { + _buildExtent = + Offset(size.width, size.height) + (buildCacheExtent ?? Offset(size.width * 0.5, size.height * 0.5)); + _init = true; + } + } + + // ==================== Child Management ==================== + + /// Add a child at a given position with a builder. Returns the child ID. + int addChild(Offset position, WidgetBuilder builder) { + _children[_nextId] = _ChildInfo(gsPosition: position, builder: builder); + _spatialHash.add(position.toPoint(), _nextId); // add to spatial hash + return _nextId++; + } + + /// Remove a child by its ID. + void removeChild(int id) { + if (!_children.containsKey(id)) { + throw _ChildNotFoundException; + } + _spatialHash.remove(_children[id]!.gsPosition.toPoint()); + _children.remove(id); + } + + /// Update the position of a child by its ID. + int updatePosition(int id, Offset newPosition) { + if (_children.containsKey(id)) { + _children[id]!.gsPosition = newPosition; + return id; + } else { + throw _ChildNotFoundException; + } + } + + /// Get the position of a child by its ID. + Offset getPosition(int id) { + if (_children.containsKey(id)) { + return _children[id]!.gsPosition; + } else { + throw _ChildNotFoundException; + } + } + + // ==================== Gesture Handling ==================== + + /// Called when a scale gesture starts. + void onScaleStart(ScaleStartDetails details) { + _baseScale = _scale; + } + + /// Called when a scale gesture updates. + /// Usually you would not want to override this + void onScaleUpdate(ScaleUpdateDetails details) { + // uses usual display conventions and final vector postion - initial vector position + // convention is that if I drag from right to left, dx is negative + // for top to bottom, dy is postive + + // scale + offset => scale then offset + + if (details.scale != 1) { + final newScale = _baseScale * details.scale; + _gsTopLeftOffset = newGsTopLeftOnScaling(_gsTopLeftOffset, details.localFocalPoint, _scale, newScale); + _scale = newScale; + } + + if (details.focalPointDelta != Offset.zero) { + // if ss distnace is x, and zoom is 2x, gs only moves by x/2 + _gsTopLeftOffset -= details.focalPointDelta / _scale; + } + + notifyListeners(); + } + + /// Increment or decrement the scale by a delta value. + void updateScalebyDelta(double delta) { + final focalPoint = Offset(canvasSize.width / 2, canvasSize.height / 2); + final newScale = _scale + delta; + _gsTopLeftOffset = newGsTopLeftOnScaling(_gsTopLeftOffset, focalPoint, _scale, newScale); + _scale = newScale; + notifyListeners(); + } + + // ==================== Positioning Logic ==================== + + /// Currently rendered widgets with their position info + List widgetsWithScreenPositions() { + final idsToBuild = _init + ? _childrenWithinBuildArea(_gsCenter, _buildExtent) + : _childrenWithinBuildArea(Offset.zero, _initialBuildExtent); + + return idsToBuild.map((id) { + final item = _children[id]!; + final ssPosition = gsToSs(item.gsPosition, _gsTopLeftOffset, _scale); + var child = item.builder(); + if (debug) child = _Debug(id: id, gs: item.gsPosition, ss: ssPosition, child: child); + return ChildInfo(gsPosition: item.gsPosition, ssPosition: ssPosition, child: child); + }).toList(); + } + + List _childrenWithinBuildArea(Offset center, Offset extent) { + final items = _spatialHash.getPointsAround(center.toPoint(), extent); + return items.map((item) => item.data).toList(); // data is the child id here + } +} diff --git a/lib/core/controller/debug.dart b/lib/core/controller/debug.dart new file mode 100644 index 0000000..43068f4 --- /dev/null +++ b/lib/core/controller/debug.dart @@ -0,0 +1,27 @@ +part of 'controller.dart'; + +class _Debug extends StatelessWidget { + final int id; + final Offset gs, ss; + final Widget child; + + const _Debug({required this.id, required this.gs, required this.ss, required this.child}); + + @override + Widget build(BuildContext context) { + return Stack( + clipBehavior: Clip.none, + children: [ + child, + Positioned( + left: 0, + bottom: -60, + child: Text( + 'ID: $id\nGS:(${gs.dx.toInt()},${gs.dy.toInt()})\nSS:(${ss.dx.toInt()},${ss.dy.toInt()})', + style: monospaceStyle, + ), + ), + ], + ); + } +} diff --git a/lib/core/controller/types.dart b/lib/core/controller/types.dart new file mode 100644 index 0000000..2e079c4 --- /dev/null +++ b/lib/core/controller/types.dart @@ -0,0 +1,23 @@ +part of 'controller.dart'; + +typedef WidgetBuilder = Widget Function(); + +// ------------------------------ Private Types ------------------------------ + +// some simple exceptions +// ignore: non_constant_identifier_names +final _ChildNotFoundException = Exception('Child with the given ID does not exist'); + +class _ChildInfo { + Offset gsPosition; + final WidgetBuilder builder; + + _ChildInfo({required this.gsPosition, required this.builder}); +} + +class ChildInfo { + Offset gsPosition; + Offset ssPosition; + Widget child; + ChildInfo({required this.gsPosition, required this.ssPosition, required this.child}); +} diff --git a/lib/core/render.dart b/lib/core/render.dart index 5c06d38..6194524 100644 --- a/lib/core/render.dart +++ b/lib/core/render.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:infinite_lazy_2d_grid/core/controller.dart'; +import 'package:infinite_lazy_2d_grid/core/controller/controller.dart'; import 'package:infinite_lazy_2d_grid/utils/offset_extensions.dart'; import 'package:infinite_lazy_2d_grid/utils/styles.dart'; @@ -15,45 +15,42 @@ class CanvasView extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - color: canvasBackground.bgColor, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onScaleUpdate: controller.onScaleUpdate, - onScaleStart: controller.onScaleStart, - child: ListenableBuilder( - listenable: controller, - builder: (_, _) { - final childrenWithPositions = controller.widgetsWithScreenPositions(); - final ssPositions = childrenWithPositions.map((e) => e.ssPosition).toList(); - final children = childrenWithPositions.map((e) => e.child).toList(); - final canvas = _CanvasRenderObject( - canvasBackground: canvasBackground, - ssPositions: ssPositions, - scale: controller.scale, - onCanvasSizeChange: controller.onCanvasSizeChange, - children: children, - ); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onScaleUpdate: controller.onScaleUpdate, + onScaleStart: controller.onScaleStart, + child: ListenableBuilder( + listenable: controller, + builder: (_, _) { + final childrenWithPositions = controller.widgetsWithScreenPositions(); + final ssPositions = childrenWithPositions.map((e) => e.ssPosition).toList(); + final children = childrenWithPositions.map((e) => e.child).toList(); + final canvas = _CanvasRenderObject( + canvasBackground: canvasBackground, + ssPositions: ssPositions, + scale: controller.scale, + onCanvasSizeChange: controller.onCanvasSizeChange, + children: children, + ); - if (controller.debug) { - return Stack( - children: [ - canvas, - Positioned( - top: 16, - left: 16, - child: Text( - 'Offset: ${controller.offset.coord()}\nScale: ${controller.scale.toStringAsFixed(1)}', - style: monospaceStyle, - ), + if (controller.debug) { + return Stack( + children: [ + canvas, + Positioned( + top: 16, + left: 16, + child: Text( + 'Offset: ${controller.offset.coord()}\nScale: ${controller.scale.toStringAsFixed(1)}', + style: monospaceStyle, ), - ], - ); - } else { - return canvas; - } - }, - ), + ), + ], + ); + } else { + return canvas; + } + }, ), ); } @@ -122,7 +119,9 @@ class _CanvasRenderBox extends RenderBox } set ssPositions(List ssPositions) { - assert(ssPositions.length == childCount); + // at this point childCount may not be equal to ssPositions.length + // but since we are only marking for layout this is fine + // at the actual performLayout this will be asserted if (_ssPositions != ssPositions) { _ssPositions = ssPositions; markNeedsLayout(); diff --git a/lib/core/spatial_hashing.dart b/lib/core/spatial_hashing.dart new file mode 100644 index 0000000..2546723 --- /dev/null +++ b/lib/core/spatial_hashing.dart @@ -0,0 +1,74 @@ +import 'dart:collection'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +typedef _CellKey = Point; + +class PointData { + final Point point; + final T data; + + PointData(this.point, this.data); +} + +class SpatialHashing { + final Size cellSize; + final HashMap _pointData = HashMap(); + final HashMap<_CellKey, List> _cellMap = HashMap<_CellKey, List>(); + + SpatialHashing({required this.cellSize}); + + _CellKey _cellKey(Point point) { + int x = (point.x / cellSize.width).floor(); + int y = (point.y / cellSize.height).floor(); + return Point(x, y); + } + + void add(Point point, T data) { + _CellKey cellKey = _cellKey(point); + _pointData[point] = data; + + if (!_cellMap.containsKey(cellKey)) { + _cellMap[cellKey] = []; + } + _cellMap[cellKey]!.add(point); + } + + void remove(Point point) { + final key = _cellKey(point); + if (_pointData.containsKey(point)) { + _pointData.remove(point); // remove the data + + // cell map must have key then + _cellMap[key]!.remove(point); // remove from spatial hash + if (_cellMap[key]!.isEmpty) { + _cellMap.remove(key); + } + } + } + + List> getPointsAround(Point point, Offset offset) { + _CellKey cellKey = _cellKey(point); + List> results = []; + + // Calculate the range of cells to check + int startX = cellKey.x - (offset.dx / cellSize.width).floor(); + int startY = cellKey.y - (offset.dy / cellSize.height).floor(); + int endX = cellKey.x + (offset.dx / cellSize.width).ceil(); + int endY = cellKey.y + (offset.dy / cellSize.height).ceil(); + + for (int x = startX; x <= endX; x++) { + for (int y = startY; y <= endY; y++) { + Point currentCellKey = Point(x, y); + if (_cellMap.containsKey(currentCellKey)) { + for (Point p in _cellMap[currentCellKey]!) { + results.add(PointData(p, _pointData[p] as T)); + } + } + } + } + + return results; + } +} diff --git a/lib/infinite_lazy_2d_grid.dart b/lib/infinite_lazy_2d_grid.dart index 0cf8f07..eae9d44 100644 --- a/lib/infinite_lazy_2d_grid.dart +++ b/lib/infinite_lazy_2d_grid.dart @@ -1,2 +1,3 @@ -export "./core/background.dart"; -export "./core/render.dart"; +export "core/background.dart"; +export "core/render.dart"; +export 'core/controller/controller.dart'; diff --git a/lib/utils/offset_extensions.dart b/lib/utils/offset_extensions.dart index c72a28e..b64887a 100644 --- a/lib/utils/offset_extensions.dart +++ b/lib/utils/offset_extensions.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'dart:ui' show Offset; extension OffsetCartesionOps on Offset { @@ -11,6 +12,10 @@ extension OffsetCartesionOps on Offset { return Offset(dx - other.dx, dy - other.dy); } + Point toPoint() { + return Point(dx, dy); + } + String coord() { return '(${dx.toStringAsFixed(2)}, ${dy.toStringAsFixed(2)})'; } diff --git a/test/core/spatial_hashing_test.dart b/test/core/spatial_hashing_test.dart new file mode 100644 index 0000000..4b57f17 --- /dev/null +++ b/test/core/spatial_hashing_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:infinite_lazy_2d_grid/core/spatial_hashing.dart'; +import 'dart:math'; +import 'package:flutter/material.dart'; + +void main() { + group('SpatialHashing', () { + late SpatialHashing spatialHashing; + + setUp(() { + spatialHashing = SpatialHashing(cellSize: const Size(10, 10)); + }); + + test('add and retrieve single point', () { + final point = Point(5, 5); + spatialHashing.add(point, 'A'); + final results = spatialHashing.getPointsAround(point, const Offset(0, 0)); + expect(results.length, 1); + expect(results.first, (point, 'A')); + }); + + test('remove point', () { + final point = Point(5, 5); + spatialHashing.add(point, 'A'); + spatialHashing.remove(point); + final results = spatialHashing.getPointsAround(point, const Offset(0, 0)); + expect(results, isEmpty); + }); + + test('add multiple points in same cell', () { + final p1 = Point(2, 2); + final p2 = Point(7, 7); + spatialHashing.add(p1, 'A'); + spatialHashing.add(p2, 'B'); + final results = spatialHashing.getPointsAround(p1, const Offset(0, 0)); + expect(results.length, 2); + expect(results, contains((p1, 'A'))); + expect(results, contains((p2, 'B'))); + }); + + test('add points in different cells and query with offset', () { + final p1 = Point(5, 5); + final p2 = Point(15, 15); + spatialHashing.add(p1, 'A'); + spatialHashing.add(p2, 'B'); + // Query with offset to include both cells + final results = spatialHashing.getPointsAround(p1, const Offset(10, 10)); + expect(results.length, 2); + expect(results, contains((p1, 'A'))); + expect(results, contains((p2, 'B'))); + }); + + test('removing one of multiple points in a cell', () { + final p1 = Point(2, 2); + final p2 = Point(7, 7); + spatialHashing.add(p1, 'A'); + spatialHashing.add(p2, 'B'); + spatialHashing.remove(p1); + final results = spatialHashing.getPointsAround(p2, const Offset(0, 0)); + expect(results.length, 1); + expect(results.first, (p2, 'B')); + }); + }); +}