mirror of
https://github.com/GitJournal/GitJournal.git
synced 2025-07-03 05:59:37 +08:00
First iteration of the Graph View
I'm testing this with the Foam documentation. This has revealed a number of bugs in our link resolver - most of which have now been fixed. The graph layouting is not being done as this is so incredibly slow. This has been added as an experimental feature.
This commit is contained in:
@ -74,6 +74,7 @@ settings:
|
||||
backlinks: Show Backlinks in Markdown Preview
|
||||
fs: Show File System
|
||||
markdownToolbar: Show Markdown Toolbar in Editor
|
||||
graphView: Graph View
|
||||
editors:
|
||||
title: Editor Settings
|
||||
subtitle: Configure how different editors work
|
||||
@ -273,6 +274,7 @@ drawer:
|
||||
all: All Notes
|
||||
folders: Folders
|
||||
fs: File System
|
||||
graph: Graph View
|
||||
tags: Tags
|
||||
share: Share App
|
||||
rate: Rate Us
|
||||
|
@ -23,6 +23,7 @@ import 'package:gitjournal/core/md_yaml_doc_codec.dart';
|
||||
import 'package:gitjournal/iap.dart';
|
||||
import 'package:gitjournal/screens/filesystem_screen.dart';
|
||||
import 'package:gitjournal/screens/folder_listing.dart';
|
||||
import 'package:gitjournal/screens/graph_view.dart';
|
||||
import 'package:gitjournal/screens/note_editor.dart';
|
||||
import 'package:gitjournal/screens/purchase_screen.dart';
|
||||
import 'package:gitjournal/screens/purchase_thankyou_screen.dart';
|
||||
@ -345,6 +346,8 @@ class _JournalAppState extends State<JournalApp> {
|
||||
return FileSystemScreen();
|
||||
case '/tags':
|
||||
return TagListingScreen();
|
||||
case '/graph':
|
||||
return GraphViewScreen();
|
||||
case '/settings':
|
||||
return SettingsScreen();
|
||||
case '/setupRemoteGit':
|
||||
|
313
lib/core/graph.dart
Normal file
313
lib/core/graph.dart
Normal file
@ -0,0 +1,313 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:gitjournal/core/note.dart';
|
||||
import 'package:gitjournal/core/notes_folder.dart';
|
||||
import 'package:gitjournal/utils/link_resolver.dart';
|
||||
|
||||
class Node {
|
||||
Note note;
|
||||
|
||||
double y = 0.0;
|
||||
double x = 0.0;
|
||||
bool pressed = false;
|
||||
|
||||
double forceX = 0.0;
|
||||
double forceY = 0.0;
|
||||
|
||||
Node(this.note);
|
||||
|
||||
String get label => note.pathSpec();
|
||||
|
||||
@override
|
||||
String toString() => "Node{$label, $x, $y}";
|
||||
}
|
||||
|
||||
class Edge {
|
||||
Node a;
|
||||
Node b;
|
||||
|
||||
Edge(this.a, this.b);
|
||||
}
|
||||
|
||||
class Graph extends ChangeNotifier {
|
||||
List<Node> nodes = [];
|
||||
List<Edge> edges = [];
|
||||
|
||||
Map<String, Set<int>> _neighbours = {};
|
||||
Map<String, int> _nodeIndexes;
|
||||
|
||||
var x = 1050.0;
|
||||
var y = 1050.0;
|
||||
|
||||
Graph.fromFolder(NotesFolder folder) {
|
||||
print("Building graph .... ");
|
||||
_addFolder(folder).then((_) {
|
||||
print("Done Building graph");
|
||||
print("Starting layouting ...");
|
||||
|
||||
//startLayout();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addFolder(NotesFolder folder) async {
|
||||
for (var note in folder.notes) {
|
||||
await _addNote(note);
|
||||
}
|
||||
|
||||
for (var subFolder in folder.subFolders) {
|
||||
await _addFolder(subFolder);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _addNote(Note note) async {
|
||||
var node = _getNode(note);
|
||||
|
||||
var links = await node.note.fetchLinks();
|
||||
var linkResolver = LinkResolver(note);
|
||||
for (var l in links) {
|
||||
var noteB = linkResolver.resolveLink(l);
|
||||
if (noteB == null) {
|
||||
print("not found $l");
|
||||
continue;
|
||||
}
|
||||
|
||||
print("Adding edge ..");
|
||||
|
||||
var edge = Edge(node, _getNode(noteB));
|
||||
edges.add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: Make this faster?
|
||||
Node _getNode(Note note) {
|
||||
var i = nodes.indexWhere((n) => n.note.filePath == note.filePath);
|
||||
if (i == -1) {
|
||||
var node = Node(note);
|
||||
node.x = x;
|
||||
node.y = y;
|
||||
|
||||
if (x >= 500) {
|
||||
x = 50;
|
||||
y += 50;
|
||||
}
|
||||
x += 50;
|
||||
|
||||
//print('${node.label} -> ${node.x} ${node.y}');
|
||||
nodes.add(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
return nodes[i];
|
||||
}
|
||||
|
||||
void notify() {
|
||||
notifyListeners();
|
||||
startLayout();
|
||||
}
|
||||
|
||||
List<int> computeNeighbours(Node n) {
|
||||
if (_nodeIndexes == null) {
|
||||
_nodeIndexes = <String, int>{};
|
||||
for (var i = 0; i < this.nodes.length; i++) {
|
||||
var node = this.nodes[i];
|
||||
_nodeIndexes[node.label] = i;
|
||||
}
|
||||
}
|
||||
|
||||
var _nodes = _neighbours[n.label];
|
||||
if (_nodes != null) {
|
||||
return _nodes.union(computeOverlappingNodes(n)).toList();
|
||||
}
|
||||
|
||||
var nodes = <int>{};
|
||||
for (var edge in edges) {
|
||||
if (edge.a.label == n.label) {
|
||||
nodes.add(_nodeIndexes[edge.b.label]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edge.b.label == n.label) {
|
||||
nodes.add(_nodeIndexes[edge.a.label]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_neighbours[n.label] = _nodes;
|
||||
return nodes.union(computeOverlappingNodes(n)).toList();
|
||||
}
|
||||
|
||||
// These nodes aren't actually neighbours, but we don't want nodes to
|
||||
// ever overlap, so I'm making the ones that are close by neighbours
|
||||
Set<int> computeOverlappingNodes(Node n) {
|
||||
var _nodes = <int>{};
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var node = nodes[i];
|
||||
if (node.label == n.label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var dx = node.x - n.x;
|
||||
var dy = node.y - n.y;
|
||||
|
||||
var dist = sqrt((dx * dx) + (dy * dy));
|
||||
if (dist <= 60) {
|
||||
// print('${node.label} and ${n.label} are too close - $dist');
|
||||
_nodes.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
return _nodes;
|
||||
}
|
||||
|
||||
void assignRandomPositions(int maxX, int maxY) {
|
||||
var random = Random(DateTime.now().millisecondsSinceEpoch);
|
||||
|
||||
for (var node in nodes) {
|
||||
node.x = random.nextInt(maxX).toDouble();
|
||||
node.y = random.nextInt(maxY).toDouble();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Timer layoutTimer;
|
||||
|
||||
void startLayout() {
|
||||
if (layoutTimer != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = Duration(milliseconds: 25);
|
||||
layoutTimer = Timer.periodic(interval, (Timer t) {
|
||||
bool shouldStop = _updateGraphPositions(this);
|
||||
print("shouldStop $shouldStop");
|
||||
if (shouldStop) {
|
||||
layoutTimer.cancel();
|
||||
layoutTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
Timer(const Duration(seconds: 5), () {
|
||||
if (layoutTimer != null) {
|
||||
layoutTimer.cancel();
|
||||
layoutTimer = null;
|
||||
}
|
||||
});*/
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Basic Force Directed Layout
|
||||
//
|
||||
const l = 150.0; // sping rest length
|
||||
const k_r = 10000.0; // repulsive force constant
|
||||
const k_s = 20; // spring constant
|
||||
const delta_t = 1.0; // time step
|
||||
const MAX_DISPLACEMENT_SQUARED = 16;
|
||||
const min_movement = 1.0;
|
||||
|
||||
bool _updateGraphPositions(Graph g) {
|
||||
var numNodes = g.nodes.length;
|
||||
|
||||
// Initialize net forces
|
||||
for (var i = 0; i < numNodes; i++) {
|
||||
g.nodes[i].forceX = 0;
|
||||
g.nodes[i].forceY = 0;
|
||||
}
|
||||
|
||||
for (var i1 = 0; i1 < numNodes - 1; i1++) {
|
||||
var node1 = g.nodes[i1];
|
||||
|
||||
for (var i2 = i1 + 1; i2 < numNodes; i2++) {
|
||||
var node2 = g.nodes[i2];
|
||||
var dx = node2.x - node1.x;
|
||||
var dy = node2.y - node1.y;
|
||||
|
||||
if (dx != 0 || dy != 0) {
|
||||
var distSq = (dx * dx) + (dy * dy);
|
||||
var distance = sqrt(distSq);
|
||||
|
||||
var force = k_r / distSq;
|
||||
var fx = force * dx / distance;
|
||||
var fy = force * dy / distance;
|
||||
|
||||
node1.forceX -= fx;
|
||||
node1.forceY -= fy;
|
||||
|
||||
node2.forceX += fx;
|
||||
node2.forceY += fy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spring forces between adjacent pairs
|
||||
for (var i1 = 0; i1 < numNodes; i1++) {
|
||||
var node1 = g.nodes[i1];
|
||||
var node1Neighbours = g.computeNeighbours(node1);
|
||||
|
||||
for (var j = 0; j < node1Neighbours.length; j++) {
|
||||
var i2 = node1Neighbours[j];
|
||||
var node2 = g.nodes[i2];
|
||||
|
||||
if (i1 < i2) {
|
||||
var dx = node2.x - node1.x;
|
||||
var dy = node2.y - node1.y;
|
||||
|
||||
if (dx != 0 || dy != 0) {
|
||||
var distSq = (dx * dx) + (dy * dy);
|
||||
var distance = sqrt(distSq);
|
||||
|
||||
var force = k_s * (distance - l);
|
||||
var fx = force * dx / distance;
|
||||
var fy = force * dy / distance;
|
||||
|
||||
node1.forceX += fx;
|
||||
node1.forceY += fy;
|
||||
|
||||
node2.forceX -= fx;
|
||||
node2.forceY -= fy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update positions
|
||||
var allBelowThreshold = true;
|
||||
for (var i = 0; i < numNodes; i++) {
|
||||
var node = g.nodes[i];
|
||||
|
||||
// Skip Node which is current being controlled
|
||||
if (node.pressed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var dx = delta_t * node.forceX;
|
||||
var dy = delta_t * node.forceY;
|
||||
|
||||
var dispSq = (dx * dx) + (dy * dy);
|
||||
if (dispSq > MAX_DISPLACEMENT_SQUARED) {
|
||||
var s = sqrt(MAX_DISPLACEMENT_SQUARED / dispSq);
|
||||
|
||||
dx *= s;
|
||||
dy *= s;
|
||||
}
|
||||
|
||||
print('${node.label} $dx $dy');
|
||||
node.x += dx;
|
||||
node.y += dy;
|
||||
|
||||
if (dx.abs() > min_movement || dy.abs() > min_movement) {
|
||||
allBelowThreshold = false;
|
||||
}
|
||||
}
|
||||
print('------------------');
|
||||
|
||||
g.notify();
|
||||
return allBelowThreshold;
|
||||
}
|
@ -2,6 +2,7 @@ import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
169
lib/screens/graph_view.dart
Normal file
169
lib/screens/graph_view.dart
Normal file
@ -0,0 +1,169 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:gitjournal/core/graph.dart';
|
||||
import 'package:gitjournal/core/notes_folder_fs.dart';
|
||||
|
||||
class GraphViewScreen extends StatefulWidget {
|
||||
@override
|
||||
_GraphViewScreenState createState() => _GraphViewScreenState();
|
||||
}
|
||||
|
||||
class _GraphViewScreenState extends State<GraphViewScreen> {
|
||||
Graph graph;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (graph == null) {
|
||||
var rootFolder = Provider.of<NotesFolderFS>(context);
|
||||
setState(() {
|
||||
graph = Graph.fromFolder(rootFolder);
|
||||
graph.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
});
|
||||
return Container(width: 2500, height: 2500);
|
||||
}
|
||||
|
||||
return GraphView(graph);
|
||||
}
|
||||
}
|
||||
|
||||
class GraphView extends StatefulWidget {
|
||||
final Graph graph;
|
||||
|
||||
GraphView(this.graph);
|
||||
|
||||
@override
|
||||
_GraphViewState createState() => _GraphViewState();
|
||||
}
|
||||
|
||||
class _GraphViewState extends State<GraphView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
widget.graph.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var children = <Widget>[];
|
||||
|
||||
children.add(CustomPaint(painter: GraphEdgePainter(widget.graph)));
|
||||
|
||||
for (var node in widget.graph.nodes) {
|
||||
var w = Positioned(
|
||||
child: GestureDetector(
|
||||
child: NodeWidget(node),
|
||||
onPanStart: (details) {
|
||||
node.x = details.globalPosition.dx;
|
||||
node.y = details.globalPosition.dy;
|
||||
node.pressed = true;
|
||||
|
||||
widget.graph.notify();
|
||||
print("Pan start ${node.label} $details");
|
||||
},
|
||||
onPanEnd: (DragEndDetails details) {
|
||||
print("Pan end ${node.label} $details");
|
||||
node.pressed = false;
|
||||
widget.graph.notify();
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
node.x = details.globalPosition.dx;
|
||||
node.y = details.globalPosition.dy;
|
||||
|
||||
widget.graph.notify();
|
||||
print("Pan update ${node.label} ${details.globalPosition}");
|
||||
},
|
||||
),
|
||||
left: node.x - 25,
|
||||
top: node.y - 25,
|
||||
);
|
||||
children.add(w);
|
||||
}
|
||||
|
||||
return Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
child: Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
child: Container(
|
||||
width: 2500,
|
||||
height: 2500,
|
||||
child: Stack(
|
||||
children: children,
|
||||
fit: StackFit.expand,
|
||||
),
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
),
|
||||
),
|
||||
scrollDirection: Axis.vertical,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GraphEdgePainter extends CustomPainter {
|
||||
final Graph graph;
|
||||
|
||||
GraphEdgePainter(this.graph) : super(repaint: graph);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// Draw all the edges
|
||||
for (var edge in graph.edges) {
|
||||
var strokeWitdth = 2.5;
|
||||
if (edge.a.pressed || edge.b.pressed) {
|
||||
strokeWitdth *= 2;
|
||||
}
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(edge.a.x, edge.a.y),
|
||||
Offset(edge.b.x, edge.b.y),
|
||||
Paint()
|
||||
..color = Colors.green
|
||||
..strokeWidth = strokeWitdth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class NodeWidget extends StatelessWidget {
|
||||
final Node node;
|
||||
|
||||
NodeWidget(this.node);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var textStyle = theme.textTheme.subtitle1.copyWith(fontSize: 8.0);
|
||||
|
||||
var label = node.label;
|
||||
if (label.startsWith('docs/')) {
|
||||
label = label.substring(5);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.orange,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Text(label, style: textStyle),
|
||||
],
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
);
|
||||
}
|
||||
}
|
@ -48,6 +48,15 @@ class _ExperimentalSettingsScreenState
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(tr('settings.experimental.graphView')),
|
||||
value: settings.experimentalGraphView,
|
||||
onChanged: (bool newVal) {
|
||||
settings.experimentalGraphView = newVal;
|
||||
settings.save();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(tr('settings.experimental.markdownToolbar')),
|
||||
value: settings.experimentalFs,
|
||||
|
@ -64,6 +64,7 @@ class Settings extends ChangeNotifier {
|
||||
bool experimentalBacklinks = true;
|
||||
bool experimentalFs = false;
|
||||
bool experimentalMarkdownToolbar = false;
|
||||
bool experimentalGraphView = false;
|
||||
|
||||
bool zenMode = false;
|
||||
bool saveTitleInH1 = true;
|
||||
|
@ -91,6 +91,15 @@ class AppDrawer extends StatelessWidget {
|
||||
onTap: () => _navTopLevel(context, '/filesystem'),
|
||||
selected: currentRoute == "/filesystem",
|
||||
),
|
||||
if (settings.experimentalGraphView)
|
||||
_buildDrawerTile(
|
||||
context,
|
||||
icon: FontAwesomeIcons.projectDiagram,
|
||||
isFontAwesome: true,
|
||||
title: tr('drawer.graph'),
|
||||
onTap: () => _navTopLevel(context, '/graph'),
|
||||
selected: currentRoute == "/graph",
|
||||
),
|
||||
_buildDrawerTile(
|
||||
context,
|
||||
icon: FontAwesomeIcons.tag,
|
||||
|
Reference in New Issue
Block a user