Implement force-directed graph view for visualizing note connections:
- Add graph data model parsing [[wiki-style links]]
- Implement Fruchterman-Reingold layout algorithm
- Create character-based canvas renderer with highlights
- Add interactive filtering by tag/folder
- Support navigation (h/j/k/l), zoom (+/-), and node selection
- New commands: :IdeaGraph, :IdeaGraphFilter
New files:
- lua/ideaDrop/ui/graph/{init,types,data,layout,renderer}.lua
Updated documentation in README.md, CHANGELOG.md, and llms.txt
425 lines
12 KiB
Lua
425 lines
12 KiB
Lua
-- ideaDrop/ui/graph/renderer.lua
|
|
-- Character-based canvas renderer for graph visualization
|
|
|
|
local constants = require("ideaDrop.utils.constants")
|
|
local types = require("ideaDrop.ui.graph.types")
|
|
|
|
---@class GraphRendererModule
|
|
---@field render fun(graph: GraphData, view: GraphViewState, width: number, height: number): GraphCanvas
|
|
---@field canvas_to_lines fun(canvas: GraphCanvas): string[]
|
|
local M = {}
|
|
|
|
local VISUAL = constants.GRAPH_SETTINGS.VISUAL
|
|
local COLORS = constants.GRAPH_SETTINGS.COLORS
|
|
local THRESHOLDS = constants.GRAPH_SETTINGS.NODE_DEGREE_THRESHOLDS
|
|
|
|
---Gets the visual size of a node based on its degree
|
|
---@param degree number Node degree
|
|
---@return number Size (1-3)
|
|
local function get_node_size(degree)
|
|
if degree <= THRESHOLDS.SMALL then
|
|
return VISUAL.MIN_NODE_SIZE
|
|
elseif degree <= THRESHOLDS.MEDIUM then
|
|
return 2
|
|
else
|
|
return VISUAL.MAX_NODE_SIZE
|
|
end
|
|
end
|
|
|
|
---Gets the character for a node based on its size
|
|
---@param size number Node size
|
|
---@return string Character
|
|
local function get_node_char(size)
|
|
if size <= 1 then
|
|
return VISUAL.NODE_CHAR_SMALL
|
|
else
|
|
return VISUAL.NODE_CHAR
|
|
end
|
|
end
|
|
|
|
---Gets the highlight group for a node
|
|
---@param node GraphNode The node
|
|
---@param view GraphViewState The view state
|
|
---@return string Highlight group name
|
|
local function get_node_highlight(node, view)
|
|
if node.selected or node.id == view.selected_node then
|
|
return COLORS.NODE_SELECTED
|
|
elseif node.degree == 0 then
|
|
return COLORS.NODE_ORPHAN
|
|
elseif node.degree > THRESHOLDS.MEDIUM then
|
|
return COLORS.NODE_HIGH_DEGREE
|
|
else
|
|
return COLORS.NODE_DEFAULT
|
|
end
|
|
end
|
|
|
|
---Draws a line between two points using Bresenham's algorithm
|
|
---@param canvas GraphCanvas The canvas
|
|
---@param x1 number Start X
|
|
---@param y1 number Start Y
|
|
---@param x2 number End X
|
|
---@param y2 number End Y
|
|
---@param char string|nil Character to use (default: edge char)
|
|
local function draw_line(canvas, x1, y1, x2, y2, char)
|
|
char = char or VISUAL.EDGE_CHAR_SIMPLE
|
|
|
|
-- Round coordinates
|
|
x1 = math.floor(x1 + 0.5)
|
|
y1 = math.floor(y1 + 0.5)
|
|
x2 = math.floor(x2 + 0.5)
|
|
y2 = math.floor(y2 + 0.5)
|
|
|
|
local dx = math.abs(x2 - x1)
|
|
local dy = math.abs(y2 - y1)
|
|
local sx = x1 < x2 and 1 or -1
|
|
local sy = y1 < y2 and 1 or -1
|
|
local err = dx - dy
|
|
|
|
local max_iterations = math.max(dx, dy) * 2 + 10
|
|
local iterations = 0
|
|
|
|
while true do
|
|
iterations = iterations + 1
|
|
if iterations > max_iterations then
|
|
break
|
|
end
|
|
|
|
-- Draw point if within bounds
|
|
if x1 >= 1 and x1 <= canvas.width and y1 >= 1 and y1 <= canvas.height then
|
|
-- Don't overwrite nodes (marked with special characters)
|
|
local current = canvas.buffer[y1][x1]
|
|
if current == " " or current == VISUAL.EDGE_CHAR_SIMPLE then
|
|
canvas.buffer[y1][x1] = char
|
|
|
|
-- Add highlight
|
|
table.insert(canvas.highlights, {
|
|
group = COLORS.EDGE,
|
|
line = y1 - 1, -- 0-indexed
|
|
col_start = x1 - 1,
|
|
col_end = x1,
|
|
})
|
|
end
|
|
end
|
|
|
|
if x1 == x2 and y1 == y2 then
|
|
break
|
|
end
|
|
|
|
local e2 = 2 * err
|
|
if e2 > -dy then
|
|
err = err - dy
|
|
x1 = x1 + sx
|
|
end
|
|
if e2 < dx then
|
|
err = err + dx
|
|
y1 = y1 + sy
|
|
end
|
|
end
|
|
end
|
|
|
|
---Draws a node on the canvas
|
|
---@param canvas GraphCanvas The canvas
|
|
---@param node GraphNode The node
|
|
---@param view GraphViewState The view state
|
|
local function draw_node(canvas, node, view)
|
|
if not node.visible then
|
|
return
|
|
end
|
|
|
|
local x = math.floor(node.x + 0.5)
|
|
local y = math.floor(node.y + 0.5)
|
|
|
|
-- Check bounds
|
|
if x < 1 or x > canvas.width or y < 1 or y > canvas.height then
|
|
return
|
|
end
|
|
|
|
local size = get_node_size(node.degree)
|
|
local char = get_node_char(size)
|
|
local highlight = get_node_highlight(node, view)
|
|
|
|
-- Draw the node
|
|
canvas.buffer[y][x] = char
|
|
|
|
-- Add highlight for the node
|
|
table.insert(canvas.highlights, {
|
|
group = highlight,
|
|
line = y - 1, -- 0-indexed
|
|
col_start = x - 1,
|
|
col_end = x + #char - 1,
|
|
})
|
|
|
|
-- Draw larger nodes as multiple characters
|
|
if size >= 2 then
|
|
-- Draw adjacent characters for larger nodes
|
|
local offsets = { { -1, 0 }, { 1, 0 } }
|
|
if size >= 3 then
|
|
table.insert(offsets, { 0, -1 })
|
|
table.insert(offsets, { 0, 1 })
|
|
end
|
|
|
|
for _, offset in ipairs(offsets) do
|
|
local ox, oy = x + offset[1], y + offset[2]
|
|
if ox >= 1 and ox <= canvas.width and oy >= 1 and oy <= canvas.height then
|
|
canvas.buffer[oy][ox] = VISUAL.NODE_CHAR_SMALL
|
|
table.insert(canvas.highlights, {
|
|
group = highlight,
|
|
line = oy - 1,
|
|
col_start = ox - 1,
|
|
col_end = ox,
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
---Draws a label for a node
|
|
---@param canvas GraphCanvas The canvas
|
|
---@param node GraphNode The node
|
|
---@param view GraphViewState The view state
|
|
local function draw_label(canvas, node, view)
|
|
if not node.visible or not view.show_labels then
|
|
return
|
|
end
|
|
|
|
local x = math.floor(node.x + 0.5)
|
|
local y = math.floor(node.y + 0.5)
|
|
|
|
-- Only show labels for selected node or high-degree nodes
|
|
local show_this_label = node.id == view.selected_node
|
|
or node.id == view.hovered_node
|
|
or node.degree > THRESHOLDS.MEDIUM
|
|
|
|
if not show_this_label then
|
|
return
|
|
end
|
|
|
|
-- Truncate label if too long
|
|
local label = node.name
|
|
if #label > VISUAL.LABEL_MAX_LENGTH then
|
|
label = label:sub(1, VISUAL.LABEL_MAX_LENGTH - 3) .. "..."
|
|
end
|
|
|
|
-- Position label to the right of the node
|
|
local label_x = x + 2
|
|
local label_y = y
|
|
|
|
-- Adjust if label would go off canvas
|
|
if label_x + #label > canvas.width then
|
|
label_x = x - #label - 1
|
|
end
|
|
|
|
-- Draw label characters
|
|
if label_y >= 1 and label_y <= canvas.height then
|
|
for i = 1, #label do
|
|
local char_x = label_x + i - 1
|
|
if char_x >= 1 and char_x <= canvas.width then
|
|
local current = canvas.buffer[label_y][char_x]
|
|
-- Only draw if space is empty or has edge
|
|
if current == " " or current == VISUAL.EDGE_CHAR_SIMPLE then
|
|
canvas.buffer[label_y][char_x] = label:sub(i, i)
|
|
|
|
table.insert(canvas.highlights, {
|
|
group = COLORS.LABEL,
|
|
line = label_y - 1,
|
|
col_start = char_x - 1,
|
|
col_end = char_x,
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
---Renders the graph to a canvas
|
|
---@param graph GraphData The graph data
|
|
---@param view GraphViewState The view state
|
|
---@param width number Canvas width
|
|
---@param height number Canvas height
|
|
---@return GraphCanvas The rendered canvas
|
|
function M.render(graph, view, width, height)
|
|
local canvas = types.create_canvas(width, height)
|
|
|
|
-- Apply zoom and offset transformations
|
|
local function transform_x(x)
|
|
return (x - width / 2) * view.zoom + width / 2 + view.offset_x
|
|
end
|
|
|
|
local function transform_y(y)
|
|
return (y - height / 2) * view.zoom + height / 2 + view.offset_y
|
|
end
|
|
|
|
-- First pass: draw edges (so they appear behind nodes)
|
|
for _, edge in ipairs(graph.edges) do
|
|
if edge.visible then
|
|
local source = graph.nodes[edge.source]
|
|
local target = graph.nodes[edge.target]
|
|
|
|
if source and target and source.visible and target.visible then
|
|
local x1 = transform_x(source.x)
|
|
local y1 = transform_y(source.y)
|
|
local x2 = transform_x(target.x)
|
|
local y2 = transform_y(target.y)
|
|
|
|
draw_line(canvas, x1, y1, x2, y2)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Second pass: draw nodes
|
|
for _, node in ipairs(graph.node_list) do
|
|
if node.visible then
|
|
-- Create a temporary node with transformed coordinates
|
|
local transformed_node = {
|
|
id = node.id,
|
|
name = node.name,
|
|
degree = node.degree,
|
|
visible = node.visible,
|
|
selected = node.selected,
|
|
x = transform_x(node.x),
|
|
y = transform_y(node.y),
|
|
}
|
|
draw_node(canvas, transformed_node, view)
|
|
end
|
|
end
|
|
|
|
-- Third pass: draw labels
|
|
for _, node in ipairs(graph.node_list) do
|
|
if node.visible then
|
|
local transformed_node = {
|
|
id = node.id,
|
|
name = node.name,
|
|
degree = node.degree,
|
|
visible = node.visible,
|
|
x = transform_x(node.x),
|
|
y = transform_y(node.y),
|
|
}
|
|
draw_label(canvas, transformed_node, view)
|
|
end
|
|
end
|
|
|
|
return canvas
|
|
end
|
|
|
|
---Converts a canvas to an array of strings for buffer display
|
|
---@param canvas GraphCanvas The canvas
|
|
---@return string[] Array of lines
|
|
function M.canvas_to_lines(canvas)
|
|
local lines = {}
|
|
|
|
for y = 1, canvas.height do
|
|
local line = table.concat(canvas.buffer[y], "")
|
|
table.insert(lines, line)
|
|
end
|
|
|
|
return lines
|
|
end
|
|
|
|
---Applies highlights to a buffer
|
|
---@param buf number Buffer handle
|
|
---@param canvas GraphCanvas The canvas with highlights
|
|
---@param ns_id number Namespace ID for highlights
|
|
function M.apply_highlights(buf, canvas, ns_id)
|
|
-- Clear existing highlights
|
|
vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1)
|
|
|
|
-- Apply new highlights
|
|
for _, hl in ipairs(canvas.highlights) do
|
|
pcall(vim.api.nvim_buf_add_highlight, buf, ns_id, hl.group, hl.line, hl.col_start, hl.col_end)
|
|
end
|
|
end
|
|
|
|
---Creates the highlight groups for the graph
|
|
function M.setup_highlights()
|
|
-- Node colors
|
|
vim.api.nvim_set_hl(0, COLORS.NODE_DEFAULT, { fg = "#7aa2f7", bold = true })
|
|
vim.api.nvim_set_hl(0, COLORS.NODE_SELECTED, { fg = "#f7768e", bold = true, underline = true })
|
|
vim.api.nvim_set_hl(0, COLORS.NODE_ORPHAN, { fg = "#565f89" })
|
|
vim.api.nvim_set_hl(0, COLORS.NODE_HIGH_DEGREE, { fg = "#bb9af7", bold = true })
|
|
|
|
-- Edge color (semi-transparent effect via dimmed color)
|
|
vim.api.nvim_set_hl(0, COLORS.EDGE, { fg = "#3b4261" })
|
|
|
|
-- Label color
|
|
vim.api.nvim_set_hl(0, COLORS.LABEL, { fg = "#9ece6a", italic = true })
|
|
|
|
-- Background (for the window)
|
|
vim.api.nvim_set_hl(0, COLORS.BACKGROUND, { bg = "#1a1b26" })
|
|
|
|
-- Filter indicator
|
|
vim.api.nvim_set_hl(0, COLORS.FILTER_ACTIVE, { fg = "#e0af68", bold = true })
|
|
end
|
|
|
|
---Generates a status line string showing graph stats and controls
|
|
---@param graph GraphData The graph data
|
|
---@param view GraphViewState The view state
|
|
---@return string Status line
|
|
function M.get_status_line(graph, view)
|
|
local visible_nodes = 0
|
|
local visible_edges = 0
|
|
|
|
for _, node in ipairs(graph.node_list) do
|
|
if node.visible then
|
|
visible_nodes = visible_nodes + 1
|
|
end
|
|
end
|
|
|
|
for _, edge in ipairs(graph.edges) do
|
|
if edge.visible then
|
|
visible_edges = visible_edges + 1
|
|
end
|
|
end
|
|
|
|
local status = string.format(" Nodes: %d | Edges: %d", visible_nodes, visible_edges)
|
|
|
|
if view.filter.active then
|
|
status = status .. string.format(" | Filter: %s=%s", view.filter.type, view.filter.value)
|
|
end
|
|
|
|
if view.selected_node then
|
|
local node = graph.nodes[view.selected_node]
|
|
if node then
|
|
status = status .. string.format(" | Selected: %s (deg: %d)", node.name, node.degree)
|
|
end
|
|
end
|
|
|
|
status = status .. " | [q]uit [t]ag [f]older [r]eset [l]abels [c]enter [Enter]open"
|
|
|
|
return status
|
|
end
|
|
|
|
---Renders help overlay
|
|
---@param width number Canvas width
|
|
---@param height number Canvas height
|
|
---@return string[] Help lines
|
|
function M.render_help()
|
|
return {
|
|
"╭──────────────────────────────────────╮",
|
|
"│ 🕸️ Graph View Help │",
|
|
"├──────────────────────────────────────┤",
|
|
"│ Navigation: │",
|
|
"│ h/j/k/l - Move selection │",
|
|
"│ Enter - Open selected note │",
|
|
"│ c - Center graph │",
|
|
"│ +/- - Zoom in/out │",
|
|
"│ │",
|
|
"│ Filtering: │",
|
|
"│ t - Filter by tag │",
|
|
"│ f - Filter by folder │",
|
|
"│ r - Reset filter │",
|
|
"│ │",
|
|
"│ Display: │",
|
|
"│ l - Toggle labels │",
|
|
"│ ? - Toggle this help │",
|
|
"│ q - Close graph view │",
|
|
"│ │",
|
|
"│ Legend: │",
|
|
"│ ● Large - High connectivity │",
|
|
"│ • Small - Low connectivity │",
|
|
"│ · Dots - Edge connections │",
|
|
"╰──────────────────────────────────────╯",
|
|
}
|
|
end
|
|
|
|
return M
|