MeshChatX/visualiser-wasm/internal/graph/full.go

367 lines
11 KiB
Go

// SPDX-License-Identifier: 0BSD
package graph
import (
"math"
"strings"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/hashpos"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/icon"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/layout"
)
// InterfaceIn is a preformatted interface row from the JS side.
type InterfaceIn struct {
Name string `json:"name"`
Label string `json:"label"`
Title string `json:"title"`
Online bool `json:"online"`
}
// DiscoveredIn is a preformatted discovered-interface row.
type DiscoveredIn struct {
ID string `json:"id"`
Label string `json:"label"`
Title string `json:"title"`
Connected bool `json:"connected"`
Hops *float64 `json:"hops"`
}
// FullRequest builds the entire visualiser graph in one WASM pass.
type FullRequest struct {
MeLabel string `json:"me_label"`
MeTitle string `json:"me_title"`
MeImage string `json:"me_image"`
IdentityHash string `json:"identity_hash"`
Interfaces []InterfaceIn `json:"interfaces"`
PathOnlyInterfaces []InterfaceIn `json:"path_only_interfaces"`
Discovered []DiscoveredIn `json:"discovered"`
PathTable []filter.PathEntry `json:"path_table"`
Announces map[string]Announce `json:"announces"`
Conversations map[string]Conversation `json:"conversations"`
IconCache map[string]string `json:"icon_cache"`
Positions map[string]XY `json:"positions"`
HopMax *float64 `json:"hop_max"`
Search string `json:"search"`
DarkMode bool `json:"dark_mode"`
LOD string `json:"lod"`
Aspects []string `json:"aspects"`
QueueIcons bool `json:"queue_icons"`
IconGeneration int `json:"icon_generation"`
ShowDiscovered bool `json:"show_discovered"`
}
// FullResult is nodes/edges for the whole mesh plus layout seeds.
type FullResult struct {
Nodes []NodeOut `json:"nodes"`
Edges []EdgeOut `json:"edges"`
IconQueue []icon.QueueItem `json:"icon_queue"`
ProcessedNodeIDs []string `json:"processed_node_ids"`
ProcessedEdgeIDs []string `json:"processed_edge_ids"`
LayoutNodes []LayoutBody `json:"layout_nodes"`
LayoutEdges []LayoutSpring `json:"layout_edges"`
}
// LayoutBody is a compact body for the WASM force settle.
type LayoutBody struct {
ID string `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Mass float64 `json:"mass"`
Fixed bool `json:"fixed"`
Radius float64 `json:"radius"`
}
// LayoutSpring is a compact spring for the WASM force settle.
type LayoutSpring struct {
From string `json:"from"`
To string `json:"to"`
Length float64 `json:"length"`
}
// BuildFullGraph constructs me + interfaces + discovered + announce nodes/edges.
func BuildFullGraph(req FullRequest) FullResult {
searchLower := strings.ToLower(req.Search)
fontColor := "#000000"
if req.DarkMode {
fontColor = "#ffffff"
}
pos := req.Positions
if pos == nil {
pos = map[string]XY{}
}
est := len(req.PathTable) + len(req.Interfaces) + len(req.PathOnlyInterfaces) + len(req.Discovered) + 1
nodes := make([]NodeOut, 0, est)
edges := make([]EdgeOut, 0, est)
iconQueue := make([]icon.QueueItem, 0, 32)
nodeIDs := make([]string, 0, est)
edgeIDs := make([]string, 0, est)
layoutNodes := make([]LayoutBody, 0, est)
layoutEdges := make([]LayoutSpring, 0, est)
seen := make(map[string]struct{}, est)
addNode := func(n NodeOut, mass float64, fixed bool) {
if _, ok := seen[n.ID]; ok {
return
}
seen[n.ID] = struct{}{}
nodes = append(nodes, n)
nodeIDs = append(nodeIDs, n.ID)
layoutNodes = append(layoutNodes, LayoutBody{ID: n.ID, X: n.X, Y: n.Y, Mass: mass, Fixed: fixed, Radius: n.Size})
}
addEdge := func(e EdgeOut, length float64) {
edges = append(edges, e)
edgeIDs = append(edgeIDs, e.ID)
layoutEdges = append(layoutEdges, LayoutSpring{From: e.From, To: e.To, Length: length})
}
meLabel := req.MeLabel
if meLabel == "" {
meLabel = "Local Node"
}
if filter.MatchesSearch(searchLower, meLabel) || filter.MatchesSearch(searchLower, req.IdentityHash) {
mp := resolveOr(pos, "me", 0, 0)
font := map[string]any{"color": fontColor, "size": 16.0, "bold": true}
me := NodeOut{
ID: "me",
Group: "me",
Size: 50,
OriginalSize: 50,
Shape: "circularImage",
OriginalShape: "circularImage",
Image: req.MeImage,
Label: meLabel,
Title: req.MeTitle,
Font: font,
Color: colorBlue(req.DarkMode),
X: mp.X,
Y: mp.Y,
}
applyLOD(&me, req.LOD, fontHighFor(req.DarkMode))
addNode(me, 4, true)
}
radius := 210.0
ifaceN := len(req.Interfaces)
for j, entry := range req.Interfaces {
if !filter.MatchesSearch(searchLower, entry.Label) && !filter.MatchesSearch(searchLower, entry.Name) {
continue
}
angle := 0.0
if ifaceN > 0 {
angle = (float64(j) / float64(ifaceN)) * 2 * math.Pi
}
init := XY{X: math.Cos(angle) * radius, Y: math.Sin(angle) * radius}
p := resolveOr(pos, entry.Name, init.X, init.Y)
node := NodeOut{
ID: entry.Name,
Group: "interface",
Label: entry.Label,
Title: entry.Title,
Size: 35,
OriginalSize: 35,
Shape: "circularImage",
OriginalShape: "circularImage",
Image: ifaceImage(entry.Online),
Color: ifaceColor(entry.Online, req.DarkMode),
Font: map[string]any{"color": fontColor, "size": 12.0, "bold": true},
X: p.X,
Y: p.Y,
}
applyLOD(&node, req.LOD, fontHighFor(req.DarkMode))
addNode(node, 2.5, false)
if _, ok := seen["me"]; ok {
eid := "me~" + entry.Name
col := edgeDirect(req.DarkMode)
if !entry.Online {
col = edgeOffline(req.DarkMode)
}
addEdge(EdgeOut{ID: eid, From: "me", To: entry.Name, Color: col, Width: 3, Hidden: false}, layout.SpringLength(3))
}
}
pathIfaceN := len(req.PathOnlyInterfaces)
for j, entry := range req.PathOnlyInterfaces {
if _, ok := seen[entry.Name]; ok {
continue
}
if !filter.MatchesSearch(searchLower, entry.Label) && !filter.MatchesSearch(searchLower, entry.Name) {
continue
}
angle := 0.0
if pathIfaceN > 0 {
angle = (float64(j) / float64(pathIfaceN)) * 2 * math.Pi
}
init := XY{X: math.Cos(angle) * radius, Y: math.Sin(angle) * radius}
p := resolveOr(pos, entry.Name, init.X, init.Y)
node := NodeOut{
ID: entry.Name,
Group: "interface",
Label: entry.Label,
Title: entry.Title,
Size: 35,
OriginalSize: 35,
Shape: "circularImage",
OriginalShape: "circularImage",
Image: "/assets/images/network-visualiser/interface_connected.png",
Color: ifaceColor(true, req.DarkMode),
Font: map[string]any{"color": fontColor, "size": 12.0, "bold": true},
X: p.X,
Y: p.Y,
}
applyLOD(&node, req.LOD, fontHighFor(req.DarkMode))
addNode(node, 2.5, false)
if _, ok := seen["me"]; ok {
eid := "me~" + entry.Name
addEdge(EdgeOut{ID: eid, From: "me", To: entry.Name, Color: edgeDirect(req.DarkMode), Width: 3, Hidden: false}, layout.SpringLength(3))
}
}
if req.ShowDiscovered {
for _, disc := range req.Discovered {
if req.HopMax != nil && disc.Hops != nil && *disc.Hops > *req.HopMax {
continue
}
if !filter.MatchesSearch(searchLower, disc.Label) {
continue
}
x, y := hashpos.XY(disc.ID, 480, 160)
p := resolveOr(pos, disc.ID, x, y)
node := NodeOut{
ID: disc.ID,
Group: "discovered",
Label: disc.Label,
Title: disc.Title,
Size: 25,
OriginalSize: 25,
Shape: "circularImage",
OriginalShape: "circularImage",
Image: ifaceImage(disc.Connected),
Color: discoveredColor(disc.Connected, req.DarkMode),
Font: map[string]any{"color": fontColor, "size": 10.0},
X: p.X,
Y: p.Y,
}
applyLOD(&node, req.LOD, fontHighFor(req.DarkMode))
addNode(node, 1.2, false)
if _, ok := seen["me"]; ok {
eid := "me~" + disc.ID
col := map[string]any{"color": "#06b6d4", "opacity": 0.35}
if req.DarkMode {
col["color"] = "#155e75"
}
addEdge(EdgeOut{ID: eid, From: "me", To: disc.ID, Color: col, Width: 1, Hidden: false}, layout.SpringLength(1))
}
}
}
pathRes := BuildPathGraph(Request{
PathTable: req.PathTable,
Announces: req.Announces,
Conversations: req.Conversations,
IconCache: req.IconCache,
Positions: pos,
HopMax: req.HopMax,
Search: req.Search,
DarkMode: req.DarkMode,
LOD: req.LOD,
Aspects: req.Aspects,
QueueIcons: req.QueueIcons,
IconGeneration: req.IconGeneration,
})
for _, n := range pathRes.Nodes {
addNode(n, 1, false)
}
for _, e := range pathRes.Edges {
addEdge(e, layout.SpringLength(e.Width))
}
iconQueue = append(iconQueue, pathRes.IconQueue...)
return FullResult{
Nodes: nodes,
Edges: edges,
IconQueue: iconQueue,
ProcessedNodeIDs: nodeIDs,
ProcessedEdgeIDs: edgeIDs,
LayoutNodes: layoutNodes,
LayoutEdges: layoutEdges,
}
}
func resolveOr(pos map[string]XY, id string, x, y float64) XY {
if prev, ok := pos[id]; ok {
return prev
}
p := XY{X: x, Y: y}
pos[id] = p
return p
}
func fontHighFor(dark bool) map[string]any {
if dark {
return fontHighLight
}
return fontHighDark
}
func colorBlue(dark bool) map[string]any {
if dark {
return colorLxmfMultiDark
}
return colorLxmfMultiLight
}
func ifaceImage(online bool) string {
if online {
return "/assets/images/network-visualiser/interface_connected.png"
}
return "/assets/images/network-visualiser/interface_disconnected.png"
}
func ifaceColor(online, dark bool) map[string]any {
if online {
bg := "#ecfdf5"
if dark {
bg = "#064e3b"
}
return nodeColor("#10b981", bg)
}
bg := "#fef2f2"
if dark {
bg = "#7f1d1d"
}
return nodeColor("#ef4444", bg)
}
func edgeDirect(dark bool) map[string]any {
if dark {
return edgeDirectDark
}
return edgeDirectLight
}
func edgeOffline(dark bool) map[string]any {
c := "#ef4444"
if dark {
c = "#f87171"
}
return map[string]any{"color": c, "opacity": 1.0}
}
func discoveredColor(connected, dark bool) map[string]any {
if connected {
bg := "#ecfeff"
if dark {
bg = "#164e63"
}
return nodeColor("#06b6d4", bg)
}
bg := "#f1f5f9"
if dark {
bg = "#1e293b"
}
return nodeColor("#64748b", bg)
}