/***
This is my new graph nodes program. I don't know what to call it yet.
To compile:
gcc nodes.c -o nodes -ffast-math -O -lm -lGL -lglut -lpthread
This program has runtime dependencies:
File in working directory: "font.data-uint8-1004x19"
Installed packages: 'zenity' and 'leafpad'
Copyright 2022, Elie Goldman Smith
This program is FREE SOFTWARE: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
***/
#include "fullscreen_main.h"
#include "text-quads.h"
#include <pthread.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define MAXTEXTLEVELS 7
const float TEXT_BOX_SIZES[MAXTEXTLEVELS] = {3, 4, 5, 7, 9, 11, 14}; // in 'em' units
typedef struct {
float x,y;
float dx,dy;
float size;
unsigned char r,g,b,a;
char *text;
int nTextLevels;
TQ_Drawable textRenders[MAXTEXTLEVELS];
} Node;
#define MAXNODES 4096
int nNodes=0;
Node nodes[MAXNODES];
typedef struct {
int from, to;
} Link;
#define MAXLINKS 4096
int nLinks=0;
Link links[MAXLINKS];
int focus = 0; // index of node that is in focus (selected)
void addNodeFrom(int id) {
if (nNodes >= MAXNODES) return;
if (nLinks >= MAXLINKS) return;
//focus=nNodes;
nodes[nNodes].x = _mouse_x;
nodes[nNodes].y = _mouse_y;
nodes[nNodes].size = nodes[id].size + RND()*0.02f;
int lum;
lum = nodes[id].r + (rand()&255)-128; if(lum<0)lum=0; if(lum>255)lum=255; nodes[nNodes].r = lum;
lum = nodes[id].g + (rand()&255)-128; if(lum<0)lum=0; if(lum>255)lum=255; nodes[nNodes].g = lum;
lum = nodes[id].b + (rand()&255)-128; if(lum<0)lum=0; if(lum>255)lum=255; nodes[nNodes].b = lum;
nodes[nNodes].a = 255;
links[nLinks].from = id;
links[nLinks].to = nNodes;
nLinks++;
nNodes++;
}
void toggleLink(int from, int to) {
if (to==from) return;
for (int i=0; i<nLinks; i++) {
if (links[i].to==to && links[i].from==from) { links[i] = links[--nLinks]; return; } // delete link XXX: gotta update toMoveLink in some rare cases
if (links[i].to==from && links[i].from==to) { links[i].to=to; links[i].from=from; return; } // flip direction
}
if (nLinks >= MAXLINKS) return;
links[nLinks].to = to;
links[nLinks].from = from;
nLinks++;
}
int nodeNearest(float x, float y) {
int which = 0;
float lowest = 1e36;
for (int i=0; i<nNodes; i++) {
float dsq = (nodes[i].x - x)*(nodes[i].x - x) + (nodes[i].y - y)*(nodes[i].y - y);
if (dsq < lowest) { lowest=dsq; which=i; }
}
return which;
}
void eraseNodeText(int id) {
free(nodes[id].text);
nodes[id].text = NULL;
for (int tl=0; tl < nodes[id].nTextLevels; tl++) tq_delete(&nodes[id].textRenders[tl]);
}
void deleteNode(int id) {
eraseNodeText(id);
nodes[id] = nodes[--nNodes];
memset(&nodes[nNodes], 0, sizeof(Node));
for (int i=0; i<nLinks; i++) {
if (links[i].to==id || links[i].from==id) links[i--] = links[--nLinks];
else {
if (links[i].to == nNodes) links[i].to = id;
if (links[i].from==nNodes) links[i].from=id;
}
} // XXX: also need to update references (toConnect, toPull, toMove, monitorEditNode, anything else?). If it's set to id, set to -1. If it's set to nNodes, set to id.
}
void genNodeTextRenders(int id) {
int tl=0;
while (tl<MAXTEXTLEVELS) {
nodes[id].textRenders[tl] = tq_centered_fitted(nodes[id].text, TEXT_BOX_SIZES[tl], TEXT_BOX_SIZES[tl]);
if (nodes[id].textRenders[tl++].isComplete) break;
}
nodes[id].nTextLevels = tl;
}
void saveToFile(const char *filename) {
FILE *f = fopen(filename,"w");
if (!f) return; // TODO: handle error case
fprintf(f, "view:\nf=%d\nnodes:\n", focus);
for (int i=0; i<nNodes; i++) {
fprintf(f, "i=%d c=%02X%02X%02X t=\"", i, (int)nodes[i].r, (int)nodes[i].g, (int)nodes[i].b);
char *p = nodes[i].text;
if (p) {
while (*p) {
if (*p=='\n') fputs("\\n", f);
else if(*p=='\"') fputs("\\\"",f);
else fputc(*p, f);
p++;
}
}fprintf(f,"\"\n");
}
fprintf(f,"connections:\n");
for (int i=0; i<nLinks; i++) fprintf(f, "a=%d b=%d\n", links[i].from, links[i].to);
fclose(f);
printf("Saved to file %s\n", filename);
}
void loadFile(const char *filename) { // TODO: respond more robustly (i.e. to avoid segfault when trying to load an invalid file)
int success = 0;
FILE *f = fopen(filename, "r");
if (f) {
if (fscanf(f,"view:\nf=%d\nnodes:\n",&focus)>0) {
// clear existing data
for (int i=0; i<nNodes; i++) {
eraseNodeText(i);
nodes[i].x = RND();
nodes[i].y = RND();
}
nNodes = nLinks = 0;
// read nodes from file
for (int i=0; i<MAXNODES; i++) {
int id; int r,g,b; char c;
if (fscanf(f, "i=%d c=%02X%02X%02X t=\"", &id, &r, &g, &b)>0) {
if (id >= 0 && id < MAXNODES) {
nodes[id].r=r; nodes[id].g=g; nodes[id].b=b; nodes[i].a=255;
if (nNodes <= id) nNodes = id+1;
size_t size;
FILE *ss = open_memstream(&nodes[id].text, &size);
while (1) {
c = fgetc(f);
if (c == '\"' || c == EOF) break;
if (c == '\\') {
c = fgetc(f);
if (c == EOF) break;
if (c == '\"') fputc('\"',ss);
else if (c == 'n' ) fputc('\n',ss);
else if (c == '\\') fputc('\\',ss);
else {
fputc('\\',ss);
fputc(c, ss);
}
} else fputc(c, ss);
} fclose(ss);
}
} else i=MAXNODES; // reached the line "connections:" so end this for-loop
while ((c=fgetc(f)) != '\n' && c != EOF); // skip to the next line
}
// read the list of connections from file
for (int i=0; i<MAXLINKS; i++, nLinks++) {
int a,b;
if (fscanf(f,"a=%d b=%d\n",&a,&b)==2) {
links[i].from = a;
links[i].to = b;
} else break;
} success=1;
}
fclose(f);
if (success) {
for (int i=0; i<nNodes; i++) genNodeTextRenders(i); // this is done at the end because it might need a lot of memory (fclose() would have freed some)
printf("Opened file %s\n", filename);
} else printf("Invalid file %s\n", filename);
} else perror(filename);
}
void drawCircle(float x, float y, float radius) {
glBegin(GL_LINE_LOOP);
float da=(float)M_PI/16.f;
for (float a = da*0.5f; a < (float)M_PI*2.f; a += da) glVertex2f(x + cosf(a)*radius, y + sinf(a)*radius);
glEnd();
}
const char * monitorFileName = "/tmp/edit-text-node";
volatile struct timespec monitorFileTime = {0};
volatile int monitorEditNode = -1;
void editTextNode(int id) {
const char *editor_names[] = {"leafpad","defaulttexteditor","gedit","geany","notepad++","wordpad","notepad","nano","vim","vi","emacs",NULL};
FILE *f = fopen(monitorFileName, "w");
if (f) {
if (nodes[id].text) fputs(nodes[id].text, f);
fclose(f);
struct stat st;
stat(monitorFileName, &st);
monitorFileTime = st.st_mtim;
monitorEditNode = id;
if (fork()==0) {
for (int i=0; editor_names[i]; i++) execlp(editor_names[0], editor_names[0], monitorFileName, (char*)NULL);
printf("Can't edit node: No text editor found.\n");
}
}
else {} // TODO: handle error case
}
void *fileMonitor(void *ptr) { // pthread
while (1) {
if (monitorEditNode >= 0) {
struct stat st;
stat(monitorFileName, &st);
if (st.st_mtim.tv_sec != monitorFileTime.tv_sec || st.st_mtim.tv_nsec != monitorFileTime.tv_nsec) {
char *str = malloc(st.st_size+1);
if (!str) return NULL; // TODO: handle error case
FILE *f = fopen(monitorFileName, "r");
if(!f){free(str);return NULL;}// TODO: handle error case
int n = fread(str, 1, st.st_size, f);
str[n]=0;
fclose(f);
eraseNodeText(monitorEditNode);
nodes[monitorEditNode].text = str;
genNodeTextRenders(monitorEditNode);
monitorFileTime = st.st_mtim;
monitorEditNode = -1;
}
} sleep(1);
}
}
void init() {
tq_init(); glDisable(GL_TEXTURE_2D);
show_mouse();
memset(nodes, 0, sizeof(nodes)); // this also initializes any pointers to NULL, so it's safe to call free() on them at any time
memset(links, -1,sizeof(links)); // -1 is safe, will be interpereted as 'not a link'
if (_global_argc==2) loadFile(_global_argv[1]);
if (nNodes < 1) {
// root node
nodes[nNodes].size = 0.04f;
nodes[nNodes].r = 255;
nodes[nNodes].g = 255;
nodes[nNodes].b = 255;
nodes[nNodes].a = 255;
nNodes++;
}
pthread_t fm; // file monitor thread (for editing a node)
pthread_create(&fm,NULL,fileMonitor,NULL);
#ifdef USE_MULTISAMPLING
glLineWidth(1.5f);
#endif
}
void draw() {
//==User interface==
// select node (Left click)
if (_mouse_button_map[0]==KEY_FRESHLY_PRESSED) focus = nodeNearest(_mouse_x, _mouse_y);
// drag node (Right click)
static int toPull = -1;
if (_mouse_button_map[2]==KEY_FRESHLY_PRESSED) toPull = nodeNearest(_mouse_x, _mouse_y);
if (_mouse_button_map[2] && toPull >= 0) { nodes[toPull].x = _mouse_x; nodes[toPull].y = _mouse_y; } else toPull = -1;
// new node (N)
if (keymap['N']==KEY_FRESHLY_PRESSED) addNodeFrom(focus);
// edit node text (E)
if (keymap['E']==KEY_FRESHLY_PRESSED) editTextNode(focus);
// connect nodes (C, and then Enter or Space)
static int toConnect = -1;
if (keymap['C']==KEY_FRESHLY_PRESSED) {
if (toConnect < 0) toConnect = focus;
else toConnect = -1;
}
if (toConnect >= 0) {
if (keymap[13]==KEY_FRESHLY_PRESSED) { toggleLink(toConnect, focus); toConnect = -1; }
if (keymap[' ']==KEY_FRESHLY_PRESSED){ toggleLink(focus, toConnect); toConnect = -1; }
}
// move node (M, and then Enter or Space)
static int toMove = -1;
static int toMoveLink = -1;
if (keymap['M']==KEY_FRESHLY_PRESSED) {
if (toMove != focus) {
toMove = focus;
toMoveLink = -1;
}
while (++toMoveLink < nLinks) { if (links[toMoveLink].to == toMove) break; }
if (toMoveLink >= nLinks) toMoveLink = toMove = -1;
}
if (toMove >= 0 && toMove != focus) {
if (keymap[13]==KEY_FRESHLY_PRESSED) { links[toMoveLink].from=focus; toMoveLink=toMove=-1; }
if (keymap[' ']==KEY_FRESHLY_PRESSED){ links[toMoveLink].from=toMove; links[toMoveLink].to=focus; toMoveLink=toMove=-1; }
}
// delete node (Delete)
if (keymap[127]==KEY_FRESHLY_PRESSED && nNodes>1) { deleteNode(focus); focus = nodeNearest(0,0); }
// help (F1)
if (special_keymap[GLUT_KEY_F1]==KEY_FRESHLY_PRESSED) {} // TODO: implement. First have to write the help info.
// back button (Backspace)
if (keymap[8] == KEY_FRESHLY_PRESSED) {} // TODO: implement. Need stack and need to figure out what to do in case of deleted nodes
// save file (Ctrl-S or F5)
if (keymap[19] ==KEY_FRESHLY_PRESSED || (special_keymap[114] && keymap['S']==KEY_FRESHLY_PRESSED) || special_keymap[GLUT_KEY_F5]==KEY_FRESHLY_PRESSED) {
char filename[4096];
FILE *f = popen("zenity --file --title 'Save as...' --save --confirm-overwrite 'Overwrite existing file?' --maximized --close-on-unfocus", "r");
if (f) {
char *c = fgets(filename,4096,f);
if (pclose(f)==0) {
int len=strlen(filename);
if (len>1) {
filename[len-1] = 0; // to remove the newline
saveToFile(filename);
} else puts("Empty filename");
} else puts("No filename selected");
}
}
// load file (Ctrl-O or F6)
if (keymap[15] ==KEY_FRESHLY_PRESSED || (special_keymap[114] && keymap['O']==KEY_FRESHLY_PRESSED) || special_keymap[GLUT_KEY_F6]==KEY_FRESHLY_PRESSED) {
char filename[4096];
FILE *f = popen("zenity --file --title 'Open graph file...' --maximized --close-on-unfocus", "r"); // or could also use --on-top
if (f) {
char *c = fgets(filename,4096,f);
if (pclose(f)==0) {
int len=strlen(filename);
if (len>1) {
filename[len-1] = 0; // to remove the newline
loadFile(filename);
} else puts("Empty filename");
} else puts("No filename selected");
} // XXX: need to decide: which messages should be in stdout vs stderr
}
//==Physics==
// center the graph
if (!_mouse_button_map[2]) {
static float dx=0; dx *= 0.875f; dx += nodes[focus].x / -128;
static float dy=0; dy *= 0.875f; dy += nodes[focus].y / -128;
for (int i=0; i<nNodes; i++) {
nodes[i].x += dx;
nodes[i].y += dy;
}
}
// establish which nodes are "relevant" aka potentially onscreen
static Node* r[MAXNODES];
int nRelevant=0;
for (int i=0; i<nNodes; i++) {
if (nodes[i].x*nodes[i].x + nodes[i].y*nodes[i].y < 4.f) {
r[nRelevant++] = &nodes[i];
nodes[i].size=0.5f;
}
}
// apply bond forces
for (int i=0; i<nLinks; i++) {
float dx = nodes[links[i].to].x - nodes[links[i].from].x;
float dy = nodes[links[i].to].y - nodes[links[i].from].y;
float inv = 0.002f / sqrtf(dx*dx+dy*dy+1.f);
dx *= inv; dy *= inv; // dy += 0.0002f; // bias to make the graph flow in one direction slightly
nodes[links[i].to ].dx -= dx;
nodes[links[i].to ].dy -= dy;
nodes[links[i].from].dx += dx;
nodes[links[i].from].dy += dy;
}
// apply repel forces
for (int h = 0; h<nRelevant; h++) {
for (int i=h+1; i<nRelevant; i++) {
float dx = r[i]->x - r[h]->x;
float dy = r[i]->y - r[h]->y;
float maxsize = fabs(dx) > fabs(dy) ? fabs(dx) : fabs(dy); maxsize *= 0.44f;
if (r[h]->size > maxsize) r[h]->size = maxsize;
if (r[i]->size > maxsize) r[i]->size = maxsize;
if (maxsize < 0.0001f) { r[i]->x += RND()*0.0001f; r[i]->y += RND()*0.0001f; }
float dsq = dx*dx+dy*dy;
float inv = 1.f/sqrtf(dsq+0.01f); // for normalizing
inv *= 0.00001f*inv*inv; // for inverse square law
float falloff = 0.142857f*(7.f - r[i]->x*r[i]->x - r[i]->y*r[i]->y - r[h]->x*r[h]->x - r[h]->y*r[h]->y);
if (falloff < 0.f) falloff = 0.f;
inv *= falloff*falloff; // for clustering in distance
dx *= inv; dy *= inv;
r[h]->dx -= dx;
r[h]->dy -= dy;
r[i]->dx += dx;
r[i]->dy += dy;
}
}
// update positions
for (int i=0; i<nNodes; i++) {
nodes[i].x += nodes[i].dx;
nodes[i].y += nodes[i].dy;
nodes[i].dx *= 0.9375f;
nodes[i].dy *= 0.9375f;
}
//==Rendering==
glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
// this projection matrix gives us "aspect-ratio-independent" normalized coordinates instead of the standard "normalized device coordinates"
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
glScalef((GLfloat)_screen_size/(GLfloat)_screen_x, (GLfloat)_screen_size/(GLfloat)_screen_y, 1.f);
// draw the links
glBegin(GL_LINES);
glColor3f(1.f,1.f,1.f);
for (int i=0; i<nLinks; i++) {
if (links[i].to >= 0 && links[i].from >= 0) {
// main line
glVertex2f(nodes[links[i].to ].x, nodes[links[i].to ].y);
glVertex2f(nodes[links[i].from].x, nodes[links[i].from].y);
// add subtle chevron at the midpoint of the line, to indicate direction
float mx =(nodes[links[i].to].x + nodes[links[i].from].x)*0.5f;
float my =(nodes[links[i].to].y + nodes[links[i].from].y)*0.5f;
float dx = nodes[links[i].to].x - nodes[links[i].from].x;
float dy = nodes[links[i].to].y - nodes[links[i].from].y;
float norm = 0.007f / sqrtf(dx*dx + dy*dy);
dx *= norm;
dy *= norm;
glVertex2f(mx-dy-dx, my+dx-dy);
glVertex2f(mx +dx, my +dy);
glVertex2f(mx +dx, my +dy);
glVertex2f(mx+dy-dx, my-dx-dy);
}
}
glEnd();
// show new connection to be added (if there is one pending)
if (toConnect >= 0) {
glColor3f(0.7f,0.f,0.f);
glBegin(GL_LINES);
glVertex2f(nodes[toConnect].x, nodes[toConnect].y);
glVertex2f(nodes[focus].x, nodes[focus].y);
glEnd();
}
// show connection that may be moved (if there is one pending)
if (toMoveLink >= 0) {
glColor3f(0.0f,0.5f,0.5f);
glBegin(GL_LINES);
glVertex2f(nodes[links[toMoveLink].from].x, nodes[links[toMoveLink].from].y);
glVertex2f(nodes[links[toMoveLink].to ].x, nodes[links[toMoveLink].to ].y);
glEnd();
}
// precalculate screen boundaries
float bx = _screen_x / _screen_size;
float by = _screen_y / _screen_size;
// draw the nodes
for (int i=0; i<nRelevant; i++) { // this implementation uses Immediate Mode. XXX: instead of this, maybe use a vertex array with GL_POINTS, use point sprites with a shader that makes the rounded square shape?
if (r[i]->size > 0) {
float x1 = r[i]->x-r[i]->size;
float y1 = r[i]->y-r[i]->size;
float x2 = r[i]->x+r[i]->size;
float y2 = r[i]->y+r[i]->size;
float c = r[i]->size*0.57f; if (c>0.01f) c=0.01f; // corner size
if (x1<bx && x2>-bx && y1<by && y2>-by) {
glBegin(GL_POLYGON);
glColor3ub(r[i]->r, r[i]->g, r[i]->b);
glVertex2f(x2-c, y2 );
glVertex2f(x1+c, y2 );
glVertex2f(x1 , y2-c);
glVertex2f(x1 , y1+c);
glVertex2f(x1+c, y1 );
glVertex2f(x2-c, y1 );
glVertex2f(x2 , y1+c);
glVertex2f(x2 , y2-c);
glEnd();
}
}
}
// draw the text on the nodes
glPushAttrib(GL_ENABLE_BIT); tq_mode();
glColor3ub(255,255,255);
for (int i=0; i<nRelevant; i++) {
// filter out nodes with nothing to show
if (r[i]->textRenders[0].n <= 0) continue;
if (r[i]->x - r[i]->size > bx) continue;
if (r[i]->x + r[i]->size < -bx) continue;
if (r[i]->y - r[i]->size > by) continue;
if (r[i]->y + r[i]->size < -by) continue;
// decide which textRender to use, if any.
const float FONT_SIZE = 0.015f; // (nominal minimum)
int tl = r[i]->nTextLevels-1;
while(tl >= 0 && TEXT_BOX_SIZES[tl]*FONT_SIZE*0.5f > r[i]->size) tl--;
if (tl >= 0) {
// decide whether to make the text black or white
if (0.2126f*r[i]->r + 0.7152f*r[i]->g + 0.0722f*r[i]->b > 144) glBlendEquation(GL_FUNC_REVERSE_SUBTRACT); else glBlendEquation(GL_FUNC_ADD);
// render
glPushMatrix();
glTranslatef(r[i]->x, r[i]->y, 0.f);
float scale = r[i]->size * 2.f / TEXT_BOX_SIZES[tl];
glScalef(scale, scale, 1.f);
tq_draw(r[i]->textRenders[tl]);
tq_draw(r[i]->textRenders[tl]); // TODO: instead of drawing twice, make a higher-contrast shader in text-quads.h
glPopMatrix();
}
}
glPopAttrib();
// highlight focused node
glColor3f(1.f,1.f,0.f);
drawCircle(nodes[focus].x, nodes[focus].y, nodes[focus].size*(float)M_SQRT2);
// highlight node to be connected
glColor3f(1.f,0.f,0.f);
drawCircle(nodes[toConnect].x, nodes[toConnect].y, nodes[toConnect].size*(float)M_SQRT2);
// highlight node being edited
glColor3f(0.f,0.f,1.f);
drawCircle(nodes[monitorEditNode].x, nodes[monitorEditNode].y, nodes[monitorEditNode].size*(float)M_SQRT2);
// highlight node to be moved
glColor3f(0.0f,0.5f,0.5f);
drawCircle(nodes[toMove].x, nodes[toMove].y, nodes[toMove].size*(float)M_SQRT2);
}
void done() {
for (int i=0; i<nNodes; i++) eraseNodeText(i);
tq_done();
}