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:
Vishesh Handa
2020-09-14 17:45:24 +02:00
parent a28fc308c0
commit f7adeafe7d
8 changed files with 507 additions and 0 deletions

View File

@ -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

View File

@ -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
View 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;
}

View File

@ -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
View 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,
);
}
}

View File

@ -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,

View File

@ -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;

View File

@ -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,