Skip to content

Commit 297e06d

Browse files
authored
Merge pull request #1331 from FalkorDB/staging
Staging
2 parents ca6763c + 1cb66ac commit 297e06d

23 files changed

+1581
-81
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ The search bar now indexes schema, nodes, and metadata. Results are ranked more
2727
#### Expanded Settings & User Controls
2828
New options let you fine-tune database behavior, toggle visual preferences, and manage users without falling back to CLI tools.
2929

30+
#### Node Style Customization
31+
Customize the visual appearance of nodes per label. Select custom colors from a palette, adjust node sizes, and choose which property to display as the node caption. All customizations are saved per graph and persist across sessions.
32+
3033

3134

3235
| ![image (4)](https://github.com/user-attachments/assets/658fa59f-5316-475c-8bd7-b26651e9902c) | ![FalkorDB browser 06-25](https://github.com/user-attachments/assets/ee907fa6-038c-462b-9240-456a2d2c2a99) |

app/api/graph/model.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ const getSchemaValue = (value: string): string[] => {
5252
return [type, description, unique, required];
5353
};
5454

55+
// Constant for empty display name
56+
export const EMPTY_DISPLAY_NAME: [string, string] = ['', ''];
57+
5558
export type Node = NodeObject<{
5659
id: number;
5760
labels: string[];
@@ -123,16 +126,54 @@ export const DEFAULT_COLORS = [
123126
"hsl(180, 66%, 70%)",
124127
];
125128

129+
// Color palette for node customization
130+
export const STYLE_COLORS = [
131+
// Reds & Pinks
132+
"#FB7185", // Rose
133+
"#F472B6", // Pink
134+
// Oranges & Ambers
135+
"#FB923C", // Orange
136+
"#FBBF24", // Amber
137+
// Yellows
138+
"#FDE047", // Yellow
139+
"#FDE68A", // Light Yellow
140+
// Greens
141+
"#86EFAC", // Green
142+
"#6EE7B7", // Emerald
143+
// Cyans & Teals
144+
"#67E8F9", // Cyan
145+
"#14B8A6", // Teal
146+
// Blues
147+
"#60A5FA", // Blue
148+
"#818CF8", // Indigo
149+
// Purples
150+
"#C084FC", // Purple
151+
"#E9D5FF", // Light Purple
152+
// Grays
153+
"#A3A3A3", // Gray
154+
"#E5E5E5", // Light Gray
155+
];
156+
157+
// Size options for node customization (relative to base NODE_SIZE)
158+
export const NODE_SIZE_OPTIONS = [0.5, 0.7, 0.85, 1, 1.15, 1.3, 1.5, 1.7, 2, 2.3, 2.6];
159+
126160
export interface InfoLabel {
127161
name: string;
128162
color: string;
129163
show: boolean;
130164
}
131165

166+
export interface LabelStyle {
167+
customColor?: string; // Custom color override
168+
customSize?: number; // Custom size multiplier (1 = default)
169+
customCaption?: string; // Custom property to display as caption
170+
}
171+
132172
export interface Label extends InfoLabel {
133173
elements: Node[];
134174
textWidth?: number;
135175
textHeight?: number;
176+
style?: LabelStyle; // Style customization
136177
}
137178

138179
export interface InfoRelationship {
@@ -754,7 +795,8 @@ export class Graph {
754795
(l) => this.labelsMap.get(l) || this.createLabel([l])[0]
755796
)
756797
);
757-
node.color = label.color;
798+
// Use custom color if available, otherwise use default label color
799+
node.color = label.style?.customColor || label.color;
758800
});
759801

760802
// remove empty category if there are no more empty nodes category
@@ -779,6 +821,9 @@ export class Graph {
779821
elements: [],
780822
};
781823

824+
// Load saved style from localStorage
825+
Graph.loadLabelStyle(c);
826+
782827
this.labelsMap.set(c.name, c);
783828
this.labels.push(c);
784829
}
@@ -791,6 +836,27 @@ export class Graph {
791836
});
792837
}
793838

839+
public static loadLabelStyle(label: Label): void {
840+
if (typeof window === "undefined") return;
841+
842+
const storageKey = `labelStyle_${label.name}`;
843+
const savedStyle = localStorage.getItem(storageKey);
844+
845+
if (savedStyle) {
846+
try {
847+
const style = JSON.parse(savedStyle);
848+
label.style = style;
849+
850+
// Apply custom color if present
851+
if (style.customColor) {
852+
label.color = style.customColor;
853+
}
854+
} catch (e) {
855+
// Ignore invalid JSON
856+
}
857+
}
858+
}
859+
794860
public createRelationship(relationship: string): Relationship {
795861
let l = this.relationshipsMap.get(relationship);
796862

@@ -998,6 +1064,12 @@ export class Graph {
9981064
const [emptyCategory] = this.createLabel([""], selectedElement);
9991065
selectedElement.labels.push(emptyCategory.name);
10001066
selectedElement.color = emptyCategory.color;
1067+
} else {
1068+
// Update node color to reflect the remaining label
1069+
const remainingLabel = this.LabelsMap.get(selectedElement.labels[0]);
1070+
if (remainingLabel) {
1071+
selectedElement.color = remainingLabel.color;
1072+
}
10011073
}
10021074
}
10031075

@@ -1057,6 +1129,9 @@ export class Graph {
10571129

10581130
selectedElement.labels.push(label);
10591131

1132+
// Update node color to reflect the new label
1133+
selectedElement.color = category.color;
1134+
10601135
return this.labels;
10611136
}
10621137

app/components/ForceGraph.tsx

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66

77
import { Dispatch, SetStateAction, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
88
import ForceGraph2D from "react-force-graph-2d"
9-
import { securedFetch, GraphRef, handleZoomToFit, getTheme, Tab, ViewportState, getNodeDisplayText } from "@/lib/utils"
9+
import { securedFetch, GraphRef, handleZoomToFit, getTheme, Tab, ViewportState, getNodeDisplayText, getContrastTextColor } from "@/lib/utils"
1010
import { useToast } from "@/components/ui/use-toast"
1111
import * as d3 from "d3"
1212
import { useTheme } from "next-themes"
13-
import { Link, Node, Relationship, Graph, getLabelWithFewestElements, GraphData } from "../api/graph/model"
13+
import { Link, Node, Relationship, Graph, getLabelWithFewestElements, GraphData, EMPTY_DISPLAY_NAME } from "../api/graph/model"
1414
import { BrowserSettingsContext, IndicatorContext } from "./provider"
1515
import Spinning from "./ui/spinning"
1616

@@ -294,10 +294,12 @@ export default function ForceGraph({
294294
});
295295
}
296296

297-
// Add collision force to prevent node overlap (scale radius by node degree)
297+
// Add collision force to prevent node overlap (scale radius by node degree and custom size)
298298
chartRef.current.d3Force('collision', d3.forceCollide((node: Node) => {
299299
const degree = nodeDegreeMap.get(node.id) || 0;
300-
return COLLISION_BASE_RADIUS + Math.sqrt(degree) * HIGH_DEGREE_PADDING;
300+
const label = getLabelWithFewestElements(node.labels.map(l => graph.LabelsMap.get(l) || graph.createLabel([l])[0]));
301+
const customSize = label.style?.customSize || 1;
302+
return (COLLISION_BASE_RADIUS * customSize) + Math.sqrt(degree) * HIGH_DEGREE_PADDING;
301303
}).strength(COLLISION_STRENGTH).iterations(2));
302304

303305
// Center force to keep graph centered
@@ -324,7 +326,8 @@ export default function ForceGraph({
324326
// Clear cached display names when displayTextPriority changes
325327
useEffect(() => {
326328
data.nodes.forEach(node => {
327-
node.displayName = ['', ''];
329+
// eslint-disable-next-line no-param-reassign
330+
node.displayName = [...EMPTY_DISPLAY_NAME];
328331
});
329332
// Force a re-render by reheating the simulation
330333
if (chartRef.current) {
@@ -441,9 +444,14 @@ export default function ForceGraph({
441444
nodeLabel={(node) => type === "graph" ? handleGetNodeDisplayText(node) : node.labels[0]}
442445
graphData={data}
443446
nodeRelSize={NODE_SIZE}
444-
nodeCanvasObjectMode={() => 'after'}
447+
nodeVal={(node) => {
448+
// Return the square of customSize because library uses Math.sqrt(nodeVal)
449+
const label = getLabelWithFewestElements(node.labels.map(l => graph.LabelsMap.get(l) || graph.createLabel([l])[0]));
450+
const customSize = label.style?.customSize || 1;
451+
return customSize * customSize; // Squared because library will sqrt it
452+
}}
453+
nodeCanvasObjectMode={() => 'replace'}
445454
linkCanvasObjectMode={() => 'after'}
446-
linkDirectionalArrowRelPos={1}
447455
linkDirectionalArrowLength={(link) => {
448456
let length = 0;
449457

@@ -453,6 +461,7 @@ export default function ForceGraph({
453461

454462
return length;
455463
}}
464+
linkDirectionalArrowRelPos={1}
456465
linkDirectionalArrowColor={(link) => link.color}
457466
linkWidth={(link) => isLinkSelected(link) ? 2 : 1}
458467
nodeCanvasObject={(node, ctx) => {
@@ -462,17 +471,26 @@ export default function ForceGraph({
462471
node.y = 0
463472
}
464473

474+
// Get label style customization
475+
const label = getLabelWithFewestElements(node.labels.map(l => graph.LabelsMap.get(l) || graph.createLabel([l])[0]));
476+
const customSize = label.style?.customSize || 1;
477+
const nodeSize = NODE_SIZE * customSize;
478+
479+
// Draw the node circle with custom color and size
480+
ctx.fillStyle = node.color;
481+
ctx.beginPath();
482+
ctx.arc(node.x, node.y, nodeSize, 0, 2 * Math.PI, false);
483+
ctx.fill();
484+
485+
// Draw the border
465486
ctx.lineWidth = ((selectedElements.length > 0 && selectedElements.some(el => el.id === node.id && !el.source)))
466487
|| (hoverElement && !hoverElement.source && hoverElement.id === node.id)
467488
? 1.5 : 0.5
468489
ctx.strokeStyle = foreground;
469-
470-
ctx.beginPath();
471-
ctx.arc(node.x, node.y, NODE_SIZE, 0, 2 * Math.PI, false);
472490
ctx.stroke();
473-
ctx.fill();
474491

475-
ctx.fillStyle = 'black';
492+
// Set text color based on node background color for better contrast
493+
ctx.fillStyle = getContrastTextColor(node.color);
476494
ctx.textAlign = 'center';
477495
ctx.textBaseline = 'middle';
478496
ctx.font = `400 2px SofiaSans`;
@@ -485,13 +503,27 @@ export default function ForceGraph({
485503
let text = '';
486504

487505
if (type === "graph") {
488-
text = handleGetNodeDisplayText(node);
506+
// Check if label has custom caption property
507+
const customCaption = label.style?.customCaption;
508+
if (customCaption) {
509+
if (customCaption === "Description") {
510+
text = handleGetNodeDisplayText(node);
511+
} else if (customCaption === "id") {
512+
text = String(node.id);
513+
} else if (node.data[customCaption]) {
514+
text = String(node.data[customCaption]);
515+
} else {
516+
text = handleGetNodeDisplayText(node);
517+
}
518+
} else {
519+
text = handleGetNodeDisplayText(node);
520+
}
489521
} else {
490-
text = getLabelWithFewestElements(node.labels.map(label => graph.LabelsMap.get(label) || graph.createLabel([label])[0])).name;
522+
text = label.name;
491523
}
492524

493525
// Calculate text wrapping for circular node
494-
const textRadius = NODE_SIZE - PADDING / 2; // Leave some padding inside the circle
526+
const textRadius = nodeSize - PADDING / 2; // Leave some padding inside the circle
495527
[line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
496528

497529
// Cache the result

0 commit comments

Comments
 (0)