Tag List control panel

This commit is contained in:
erhazan 2026-02-26 01:09:45 +01:00
parent 56504517e7
commit 1bf88110d5
15 changed files with 1378 additions and 141 deletions

View file

@ -2,7 +2,7 @@
[Obsidian N-brace Plugin](https://nn-ninja.github.io/n-brace/) Homepage
A more intuitive Graph navigation and visualization for Obsidian.
A slightly different Graph navigation and visualization for Obsidian.
See the demo: <not yet published!>

View file

@ -28,10 +28,10 @@
"baseFolder": "/",
"titleFontSize": 16,
"defaultGraphSpan": 5,
"linkColorTheme": "dark",
"linkColorTheme": "custom",
"linkColorIn": "122, 41, 143",
"linkColorOut": "13, 91, 130",
"linkColorOther": "71, 30, 143",
"linkColorOther": "148, 148, 148",
"maxNodeNumber": 1000,
"searchEngine": "default"
}

4
src/context.ts Normal file
View file

@ -0,0 +1,4 @@
import { createContext } from "react";
import type { App } from "obsidian";
export const AppContext = createContext<App | undefined>(undefined);

View file

@ -1,56 +1,46 @@
import { DirectedGraph } from "graphology";
import type { LinkCache } from "@/graph/Link";
import type { App } from "obsidian";
import { Link } from "@/graph/Link";
import { Node } from "@/graph/Node";
type NodeAttrs = { node: Node };
type EdgeAttrs = { link: Link };
export class Graph {
public rootPath: string | undefined = undefined;
public readonly nodes: Node[];
public readonly links: Link[];
private readonly _graph: DirectedGraph<NodeAttrs, EdgeAttrs>;
// Indexes to quickly retrieve nodes and links by id
private readonly nodeIndex: Map<string, number>;
private readonly linkIndex: Map<string, Map<string, number>>;
constructor(
rootPath: string | undefined,
nodes: Node[],
links: Link[],
nodeIndex: Map<string, number>,
linkIndex: Map<string, Map<string, number>>
) {
constructor(rootPath: string | undefined, graph: DirectedGraph<NodeAttrs, EdgeAttrs>) {
this.rootPath = rootPath;
this.nodes = nodes;
this.links = links;
this.nodeIndex = nodeIndex || new Map<string, number>();
this.linkIndex = linkIndex || new Map<string, Map<string, number>>();
this._graph = graph;
}
public resort() {
this.nodes.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
public get nodes(): Node[] {
return this._graph.mapNodes((_, attrs) => attrs.node);
}
public get links(): Link[] {
return this._graph.mapEdges((_, attrs) => attrs.link);
}
public getNodeById(id: string): Node | null {
const index = this.nodeIndex.get(id);
if (typeof index === "number") {
return this.nodes[index] ?? null;
if (this._graph.hasNode(id)) {
return this._graph.getNodeAttribute(id, "node");
}
return null;
}
public getLinksFromNode(sourceNodeId: string): Link[] {
const linkIndexes = this.linkIndex.get(sourceNodeId);
if (linkIndexes) {
return Array.from(linkIndexes.values())
.map((index) => this.links[index])
.filter(Boolean);
}
return [];
if (!this._graph.hasNode(sourceNodeId)) return [];
return this._graph.mapOutEdges(sourceNodeId, (_, attrs) => attrs.link);
}
public static createEmpty = (): Graph => {
return new Graph(undefined, [], [], new Map(), new Map());
return new Graph(undefined, new DirectedGraph<NodeAttrs, EdgeAttrs>());
};
// Creates a graph using the Obsidian API
@ -97,7 +87,14 @@ export class Graph {
const nodeMap = new Map<string, Node>();
newNodes.forEach((node) => nodeMap.set(node.id, node));
const links: Link[] = [];
const dg = new DirectedGraph<NodeAttrs, EdgeAttrs>();
// Add all nodes to the graphology graph
for (const node of newNodes) {
if (!dg.hasNode(node.id)) {
dg.addNode(node.id, { node });
}
}
Object.entries(map).forEach(([sourceId, targetIds]) => {
const sourceNode = nodeMap.get(sourceId);
@ -111,13 +108,22 @@ export class Graph {
if (!targetNode) {
targetNode = new Node(targetId, targetId, 0, 0);
newNodes.push(targetNode);
nodeMap.set(targetNode.id, targetNode);
if (!dg.hasNode(targetNode.id)) {
dg.addNode(targetNode.id, { node: targetNode });
}
}
// Create new instances of links
const link = new Link(sourceNode, targetNode);
links.push(link);
// As we are creating new nodes, we need to make sure they are properly linked
// Add edge to graphology graph
const edgeKey = `${sourceId}::${targetId}`;
if (!dg.hasEdge(edgeKey)) {
dg.addEdgeWithKey(edgeKey, sourceId, targetId, { link });
}
// Populate Node.links and Node.neighbors for traversal compatibility
sourceNode.addNeighbor(targetNode);
sourceNode.addLink(link, isGlobal);
targetNode.addLink(link, isGlobal);
@ -125,13 +131,7 @@ export class Graph {
});
});
return new Graph(
undefined,
newNodes,
links,
Node.createNodeIndex(newNodes),
Link.createLinkIndex(links)
);
return new Graph(undefined, dg);
}
/**
@ -167,14 +167,6 @@ export class Graph {
return Graph.createFromLinkMap(app, linkMap, filteredNodes);
};
public filterNodes(pred: (node: Node) => boolean): Node[] {
return this.nodes.filter(pred);
}
public applyNodes = (fun: (node: Node) => void): void => {
this.nodes.forEach(fun);
};
private isCyclicUtil = (nodeId: string, visited: Set<string>, recStack: Set<string>): boolean => {
if (!visited.has(nodeId)) {
// Add to visited and recursion stack

View file

@ -27,6 +27,7 @@ export class Node {
public selected: boolean = false;
public expanded: boolean = false;
public imploded: boolean = false;
public tags: string[] = [];
public paras: Record<string, SectionData> = {};

69
src/graph/TagIndex.ts Normal file
View file

@ -0,0 +1,69 @@
export class TagIndex {
private nodeToTags = new Map<string, Set<string>>();
private tagToNodes = new Map<string, Set<string>>();
addTags(nodePath: string, tags: string[]) {
if (tags.length === 0) return;
let nodeSet = this.nodeToTags.get(nodePath);
if (!nodeSet) {
nodeSet = new Set();
this.nodeToTags.set(nodePath, nodeSet);
}
for (const tag of tags) {
nodeSet.add(tag);
let tagSet = this.tagToNodes.get(tag);
if (!tagSet) {
tagSet = new Set();
this.tagToNodes.set(tag, tagSet);
}
tagSet.add(nodePath);
}
}
getTagsForNode(nodePath: string): Set<string> {
return this.nodeToTags.get(nodePath) ?? new Set();
}
getNodesForTag(tag: string): Set<string> {
return this.tagToNodes.get(tag) ?? new Set();
}
has(nodePath: string): boolean {
return this.nodeToTags.has(nodePath);
}
/**
* Update a single tag for an already-indexed node.
* No-op if the node hasn't been indexed yet (avoids storing partial data).
*/
updateTag(nodePath: string, tag: string, add: boolean) {
if (!this.nodeToTags.has(nodePath)) return;
const nodeSet = this.nodeToTags.get(nodePath)!;
if (add) {
nodeSet.add(tag);
let tagSet = this.tagToNodes.get(tag);
if (!tagSet) {
tagSet = new Set();
this.tagToNodes.set(tag, tagSet);
}
tagSet.add(nodePath);
} else {
nodeSet.delete(tag);
const tagSet = this.tagToNodes.get(tag);
if (tagSet) {
tagSet.delete(nodePath);
if (tagSet.size === 0) this.tagToNodes.delete(tag);
}
}
}
clear() {
this.nodeToTags.clear();
this.tagToNodes.clear();
}
}
export const tagIndex = new TagIndex();

View file

@ -5,15 +5,17 @@ import { createRoot } from "react-dom/client";
import type ForceGraphPlugin from "@/main";
import type { PluginSettingManager } from "@/SettingManager";
import type { App, IconName, WorkspaceLeaf } from "obsidian";
import type { IconName, WorkspaceLeaf } from "obsidian";
import type { Root } from "react-dom/client";
import { dimensionsAtom } from "@/atoms/graphAtoms";
import { config } from "@/config";
import { AppContext } from "@/context";
import { Graph } from "@/graph/Graph";
import { tagIndex } from "@/graph/TagIndex";
import { eventBus } from "@/util/EventBus";
import { getNewLocalGraph, implodeGraph, loadImagesForGraph } from "@/views/graph/fileGraphMethods";
import { getNewLocalGraph, implodeGraph, loadImagesForGraph, loadTagsForGraph } from "@/views/graph/fileGraphMethods";
import { ReactForceGraph } from "@/views/graph/ReactForceGraph";
@ -23,7 +25,7 @@ import { ReactForceGraph } from "@/views/graph/ReactForceGraph";
export const VIEW_TYPE_REACT_FORCE_GRAPH = "react-force-graph-view";
export const AppContext = createContext<App | undefined>(undefined);
export { AppContext };
export const ViewContext = createContext<ReactForceGraphView | undefined>(undefined);
export class ReactForceGraphView extends ItemView {
@ -109,6 +111,7 @@ export class ReactForceGraphView extends ItemView {
},
});
await loadImagesForGraph(this.plugin, graph);
loadTagsForGraph(this.app, graph, tagIndex);
graph.rootPath = nodePath;
graph.nodes.forEach((n) => {
if (n.path === nodePath) {

View file

@ -3,14 +3,16 @@ import type { Link } from "@/graph/Link";
import type { Node } from "@/graph/Node";
export class Drawing {
private static readonly SELECTED_COLOR = "21, 0, 158";
private static readonly SELECTED_COLOR = "255, 215, 0";
static drawLink(
link: Link,
ctx: CanvasRenderingContext2D,
globalScale: number,
navDescending: boolean,
graphSettings: GraphSettings
graphSettings: GraphSettings,
tagColorMap?: Map<string, string>,
uncheckedTags?: Set<string>
) {
if (link.color === "parent") {
return;
@ -55,20 +57,93 @@ export class Drawing {
: link.label === "para"
? this.SELECTED_COLOR
: graphSettings.linkColorOther;
// Create a gradient for the line width
const gradient = ctx.createLinearGradient(0, 0, length, 0);
gradient.addColorStop(0, `rgba(${color}, 1`); // Start color 1
gradient.addColorStop(1, `rgba(${color}, 0.5`); // End color 0.25
// Color edge only by tags shared between source and target
const sourceTags = link.source.tags ?? [];
const targetTags = link.target.tags ?? [];
const sumTags = [...new Set([...sourceTags, ...targetTags])];
const coloringTags = uncheckedTags
? sumTags.filter((t) => !uncheckedTags.has(t))
: sumTags;
const tagColors = tagColorMap
? coloringTags.map((t) => tagColorMap.get(t)).filter((c): c is string => c !== undefined)
: [];
// ctx.strokeStyle = `rgba(${color}, 1)`; // gradient;
ctx.strokeStyle = gradient;
if (tagColors.length > 0) {
// Draw multi-color striped taper one horizontal stripe per tag
const n = tagColors.length;
const bandHeight = endWidth / n;
for (let i = 0; i < n; i++) {
const yTopEnd = -endWidth / 2 + i * bandHeight;
const yBotEnd = yTopEnd + bandHeight;
// Interpolate start-width band proportionally
const yTopStart = -startWidth / 2 + (i / n) * startWidth;
const yBotStart = -startWidth / 2 + ((i + 1) / n) * startWidth;
const tagColor = tagColors[i] ?? "#9e9e9e";
const gradient = ctx.createLinearGradient(0, 0, length, 0);
gradient.addColorStop(0, tagColor);
gradient.addColorStop(1, Drawing.hexToRgba(tagColor, 0.5));
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.moveTo(0, yTopStart);
ctx.lineTo(length, yTopEnd);
ctx.lineTo(length, yBotEnd);
ctx.lineTo(0, yBotStart);
ctx.closePath();
ctx.fill();
}
} else {
// Default gradient fill
const gradient = ctx.createLinearGradient(0, 0, length, 0);
gradient.addColorStop(0, `rgba(${graphSettings.linkColorOther}, 1)`);
gradient.addColorStop(1, `rgba(${graphSettings.linkColorOther}, 0.5)`);
ctx.fillStyle = gradient;
// Create a pattern for widening the link
for (let i = 1; i < length; i++) {
ctx.beginPath();
ctx.lineWidth = startWidth + (endWidth - startWidth) * (i / length);
ctx.moveTo(i - 1, 0);
ctx.lineTo(i, 0);
ctx.moveTo(0, -startWidth / 2);
ctx.lineTo(length, -endWidth / 2);
ctx.lineTo(length, endWidth / 2);
ctx.lineTo(0, startWidth / 2);
ctx.closePath();
ctx.fill();
}
// Stroke outline with dash pattern for child/parent edges
if (link.label === "child" || link.label === "parent") {
// Re-draw the full outer taper path so stroke covers the whole shape
ctx.beginPath();
ctx.moveTo(0, -startWidth / 2);
ctx.lineTo(length, -endWidth / 2);
ctx.lineTo(length, endWidth / 2);
ctx.lineTo(0, startWidth / 2);
ctx.closePath();
ctx.strokeStyle = `rgba(${color}, 1.0)`;
ctx.lineWidth = 0.6;
if (link.label === "child") {
ctx.setLineDash([2, 4]);
} else {
ctx.setLineDash([8, 4]);
}
ctx.stroke();
ctx.setLineDash([]);
}
// Draw 3 chevrons pointing from parent toward child (along +x in rotated space)
ctx.setLineDash([]);
ctx.strokeStyle = "rgba(255, 255, 255, 0.75)";
ctx.lineWidth = 1;
ctx.lineJoin = "round";
ctx.strokeStyle = `rgba(${color}, 1.0)`;
const chevSize = 2.0;
for (const t of [0.4, 0.5, 0.6]) {
const cx = t * length;
ctx.beginPath();
ctx.moveTo(cx - chevSize, -chevSize);
ctx.lineTo(cx, 0);
ctx.lineTo(cx - chevSize, chevSize);
ctx.stroke();
}
@ -103,6 +178,82 @@ export class Drawing {
ctx.restore();
}
// Draw a glowing rectangle between two nodes sharing a tag.
// Inset at both ends so the rect is fully hidden under the node clouds.
static drawTagEdge(
x1: number, y1: number,
x2: number, y2: number,
cloudRadius: number,
color: string,
ctx: CanvasRenderingContext2D
) {
const dx = x2 - x1;
const dy = y2 - y1;
const dist = Math.hypot(dx, dy);
if (dist === 0) return;
ctx.save();
ctx.translate(x1, y1);
ctx.rotate(Math.atan2(dy, dx));
const halfW = cloudRadius;
const grad = ctx.createLinearGradient(0, -halfW, 0, halfW);
grad.addColorStop(0, Drawing.hexToRgba(color, 0));
grad.addColorStop(0.1, Drawing.hexToRgba(color, 1.0));
grad.addColorStop(0.9, Drawing.hexToRgba(color, 1.0));
grad.addColorStop(1, Drawing.hexToRgba(color, 0));
ctx.fillStyle = grad;
ctx.fillRect(0, -halfW, dist, halfW * 2);
ctx.restore();
}
// nodeHalfDiag: half-diagonal of the node bounding box in graph coords
static drawNodeTagCloud(
node: Node & Coords,
ctx: CanvasRenderingContext2D,
nodeHalfDiag: number,
tagColorMap: Map<string, string>,
uncheckedTags: Set<string>
) {
const nodeTags = (node.tags ?? []).filter((t) => !uncheckedTags.has(t));
const tagColors = nodeTags
.map((t) => tagColorMap.get(t))
.filter((c): c is string => c !== undefined);
if (tagColors.length === 0) return;
const cx = node.x!;
const cy = node.y!;
const n = tagColors.length;
const angleStep = (2 * Math.PI) / n;
// Circle is 2× the node half-diagonal; color visible only in the outer 10% ring
const cloudRadius = nodeHalfDiag * 2;
ctx.save();
for (let i = 0; i < n; i++) {
const startAngle = i * angleStep - Math.PI / 2;
const endAngle = startAngle + angleStep;
const color = tagColors[i]!;
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, cloudRadius);
grad.addColorStop(0, Drawing.hexToRgba(color, 1.0));
grad.addColorStop(0.9, Drawing.hexToRgba(color, 1.0));
grad.addColorStop(1, Drawing.hexToRgba(color, 0));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, cloudRadius, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
}
ctx.restore();
}
/**
* Draw a custom node
* @param node The node data
@ -334,6 +485,13 @@ export class Drawing {
node.nodeDims = [width, height];
}
private static hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Draw a rounded rectangle
* @param ctx The canvas rendering context

View file

@ -35,16 +35,10 @@
}
.slider-control {
position: absolute;
top: 50px;
left: 10px;
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr;
gap: 5px;
background: rgba(255, 255, 255, 0.8); /* Semi-transparent background */
padding: 5px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
color: var(--color-base-00);
}
@ -63,3 +57,202 @@
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.pm-tag-list {
position: absolute;
top: 50px;
left: 10px;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
background: rgba(255, 255, 255, 0.8);
padding: 6px 8px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
font-size: 12px;
pointer-events: auto;
}
.pm-tag-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.pm-tag-minimize-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: rgba(0, 0, 0, 0.4);
flex-shrink: 0;
align-self: flex-start;
}
.pm-tag-minimize-btn:hover {
color: rgba(0, 0, 0, 0.7);
}
.pm-tag-filter-panel {
display: flex;
flex-direction: column;
gap: 2px;
}
.pm-tag-node-panel {
display: flex;
flex-direction: column;
gap: 2px;
border-left: 1px solid rgba(0, 0, 0, 0.12);
padding-left: 8px;
min-width: 0;
}
.pm-tag-node-panel-header {
font-size: 11px;
font-weight: 600;
color: gray;
white-space: nowrap;
margin-bottom: 16px;
}
.pm-tag-node-items {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.pm-tag-node-items::-webkit-scrollbar {
width: 4px;
}
.pm-tag-node-items::-webkit-scrollbar-thumb {
background: #2563eb;
border-radius: 2px;
}
.pm-tag-pill--neighbor {
opacity: 0.55;
}
.pm-tag-items {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.pm-tag-items::-webkit-scrollbar {
width: 4px;
}
.pm-tag-items::-webkit-scrollbar-track {
background: #2563eb;
}
.pm-tag-items::-webkit-scrollbar-thumb {
background: #2563eb;
border-radius: 2px;
}
.pm-tag-search-wrap {
position: relative;
margin-bottom: 2px;
}
.pm-tag-search {
width: 100%;
box-sizing: border-box;
padding: 2px 20px 2px 5px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 3px;
font-size: 11px;
background: rgba(255, 255, 255, 0.9);
}
.pm-tag-search-clear {
position: absolute;
right: 3px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0;
line-height: 1;
font-size: 13px;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
}
.pm-tag-item {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
cursor: pointer;
}
.pm-tag-item input[type="checkbox"] {
margin: 0;
cursor: pointer;
flex-shrink: 0;
}
.pm-tag-item input[type="checkbox"]:disabled {
cursor: default;
opacity: 0.5;
}
.pm-tag-pill {
border-radius: 8px;
padding: 1px 6px;
color: white;
font-size: 11px;
flex: 1;
}
.pm-tag-select-all-label {
color: gray;
}
.pm-tag-name {
color: var(--color-base-00);
flex: 1;
}
.pm-tag-count {
color: var(--color-base-40);
font-size: 11px;
}
.pm-tag-color-toggle {
display: flex;
align-items: center;
gap: 4px;
}
.pm-tag-color-btn {
flex: 1;
padding: 2px 6px;
font-size: 11px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 3px;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
color: var(--color-base-00);
}
.pm-tag-color-btn.active {
background: var(--color-base-40);
color: white;
}
.pm-tag-color-btn:hover:not(.active) {
background: rgba(0, 0, 0, 0.06);
}

View file

@ -5,7 +5,6 @@ import {
ArrowUp,
FlipVertical,
FlipVertical2,
ScanText,
Sun,
Sunrise,
} from "lucide-react";
@ -50,12 +49,6 @@ export const GraphControls: React.FC<GraphControlsProps> = ({
}
};
const handleImplode = () => {
if (onImplode) {
onImplode();
}
};
const handleSliderChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = Number(event.target.value);
setMaxPathLength(value);
@ -70,52 +63,43 @@ export const GraphControls: React.FC<GraphControlsProps> = ({
}, [maxPathLength, onMaxPathLengthChange]);
return (
<>
<div className="pm-graph-controls right-grid">
<button onClick={handleToggle} title="Flip direction (Ctrl)">
{isDescending ? <FlipVertical /> : <FlipVertical2 />}
</button>
<button onClick={handleHidden} title={isHidden ? "Show" : "Hide"}>
{isHidden ? <Sun /> : <Sunrise />}
</button>
{!isHidden && (
<div className="slider-control">
<label htmlFor="max-path-length" title="Visisble graph span size">
G-span {maxPathLength}
</label>
<input
id="max-path-length"
type="range"
min="0"
max="99"
value={maxPathLength}
onChange={handleSliderChange}
aria-label="Visisble graph span size"
/>
</div>
<>
<button onClick={onPanUp} title="Descend">
<ArrowUp />
</button>
<button onClick={onPanDown} title="Go Back">
<ArrowDown />
</button>
<button onClick={onPanLeft} title="Anti Clockwise">
<ArrowLeft />
</button>
<button onClick={onPanRight} title="Clockwise">
<ArrowRight />
</button>
<div className="slider-control">
<label htmlFor="max-path-length" title="Visible graph span size">
G-span {maxPathLength}
</label>
<input
id="max-path-length"
type="range"
min="0"
max="99"
value={maxPathLength}
onChange={handleSliderChange}
aria-label="Visible graph span size"
/>
</div>
</>
)}
<div className="pm-graph-controls first-row">
<button onClick={handleImplode} title="Implode">
<ScanText />
</button>
</div>
<div className="pm-graph-controls right-grid">
<button onClick={handleToggle} title="Flip direction (Ctrl)">
{isDescending ? <FlipVertical /> : <FlipVertical2 />}
</button>
<button onClick={handleHidden} title={isHidden ? "Show" : "Hide"}>
{isHidden ? <Sun /> : <Sunrise />}
</button>
{!isHidden && (
<>
<button onClick={onPanUp} title="Descend">
<ArrowUp />
</button>
<button onClick={onPanDown} title="Go Back">
<ArrowDown />
</button>
<button onClick={onPanLeft} title="Anti Clockwise">
<ArrowLeft />
</button>
<button onClick={onPanRight} title="Clockwise">
<ArrowRight />
</button>
</>
)}
</div>
</>
</div>
);
};

View file

@ -1,5 +1,5 @@
import { useAtomValue } from "jotai/react";
import React, { useEffect, useRef } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ForceGraph2D from "react-force-graph-2d";
import type { Graph } from "@/graph/Graph";
@ -18,6 +18,8 @@ import {
} from "@/hooks";
import { Drawing } from "@/views/graph/Drawing";
import { GraphControls } from "@/views/graph/GraphControls";
import { buildTagColorMap } from "@/views/graph/tagColors";
import { TagList, UNTAGGED } from "@/views/graph/TagList";
interface GraphComponentProps {
data?: Graph;
@ -35,6 +37,8 @@ export const ReactForceGraph: React.FC<GraphComponentProps> = ({
}) => {
const fgRef = useRef<ForceGraphMethods | undefined>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
const tagCloudCanvasRef = useRef<HTMLCanvasElement | null>(null);
const stableTagColors = useRef<Map<string, string>>(new Map());
const dimensions = useAtomValue(dimensionsAtom);
const graphSettings = useAtomValue(graphSettingsAtom);
@ -74,6 +78,231 @@ export const ReactForceGraph: React.FC<GraphComponentProps> = ({
// Distance-based filtering
const { filteredGraphData, setMaxPathLength } = useGraphFiltering(graphData, selectedNode);
// Tag filtering
const [uncheckedTags, setUncheckedTags] = useState<Set<string>>(new Set());
const [tagColorMode, setTagColorMode] = useState<"edges" | "nodes">("nodes");
const [tagSearch, setTagSearch] = useState("");
const [nodeTagVersion, setNodeTagVersion] = useState(0);
const handleSetTagColorMode = useCallback((mode: "edges" | "nodes") => {
setTagColorMode(mode);
}, []);
const handleNodeTagUpdate = useCallback((nodePath: string, tag: string, add: boolean) => {
const node = graphData.nodes.find((n) => n.path === nodePath);
if (!node) return;
if (add) {
if (!node.tags.includes(tag)) node.tags = [...node.tags, tag];
} else {
node.tags = node.tags.filter((t) => t !== tag);
}
setNodeTagVersion((v) => v + 1);
setGraphData({ nodes: graphData.nodes, links: graphData.links } as Graph);
}, [graphData, setGraphData]);
// After tag mutations are committed, force a canvas redraw (simulation may be idle)
useEffect(() => {
if (nodeTagVersion > 0) {
(fgRef.current as any)?.refresh?.();
}
}, [nodeTagVersion]);
const handleToggleTag = useCallback((tag: string) => {
setUncheckedTags((prev) => {
const next = new Set(prev);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(tag);
}
return next;
});
}, []);
const tagFilteredData = useMemo(() => {
if (uncheckedTags.size === 0) return filteredGraphData;
const nodes = filteredGraphData.nodes as Node[];
const links = filteredGraphData.links as Link[];
// Nodes that have at least one checked tag (or pass the "untagged" filter)
const hideUntagged = uncheckedTags.has(UNTAGGED);
const taggedNodes = new Set<string>();
for (const node of nodes) {
if (node.tags.length === 0) {
if (!hideUntagged) taggedNodes.add(node.path);
} else if (node.tags.some((t) => !uncheckedTags.has(t))) {
taggedNodes.add(node.path);
}
}
// Expand: include direct parents and children of tagged nodes
const passesTagFilter = new Set<string>(taggedNodes);
for (const link of links) {
const srcPath = link.source.path;
const tgtPath = link.target.path;
if (taggedNodes.has(srcPath)) passesTagFilter.add(tgtPath);
if (taggedNodes.has(tgtPath)) passesTagFilter.add(srcPath);
}
// Always include selected node
if (selectedNode.selectedPath) {
passesTagFilter.add(selectedNode.selectedPath);
}
// BFS from selected node to keep only connected nodes
const connected = new Set<string>();
if (selectedNode.selectedPath && passesTagFilter.has(selectedNode.selectedPath)) {
const queue = [selectedNode.selectedPath];
connected.add(selectedNode.selectedPath);
while (queue.length > 0) {
const current = queue.shift()!;
for (const link of links) {
const srcPath = link.source.path;
const tgtPath = link.target.path;
if (srcPath === current && passesTagFilter.has(tgtPath) && !connected.has(tgtPath)) {
connected.add(tgtPath);
queue.push(tgtPath);
} else if (tgtPath === current && passesTagFilter.has(srcPath) && !connected.has(srcPath)) {
connected.add(srcPath);
queue.push(srcPath);
}
}
}
}
const finalNodes = nodes.filter((n) => connected.has(n.path));
const finalPaths = new Set(finalNodes.map((n) => n.path));
const finalLinks = links.filter((l) => finalPaths.has(l.source.path) && finalPaths.has(l.target.path));
return { nodes: finalNodes, links: finalLinks };
}, [filteredGraphData, uncheckedTags, selectedNode.selectedPath]);
const handleCloudDoubleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!(e.target instanceof HTMLCanvasElement)) return;
if (tagColorMode !== "nodes") return;
if (!fgRef.current) return;
const rect = e.target.getBoundingClientRect();
const { x: gx, y: gy } = fgRef.current.screen2GraphCoords(
e.clientX - rect.left,
e.clientY - rect.top
);
const nodes = tagFilteredData.nodes as (Node & Coords & NodeData)[];
for (const node of nodes) {
if (node.label === "para" || node.x === undefined || node.y === undefined) continue;
const nodeTags = (node.tags ?? []).filter((t) => !uncheckedTags.has(t));
if (nodeTags.length === 0) continue;
const nodeHalfDiag = node.nodeDims
? Math.hypot(node.nodeDims[0]!, node.nodeDims[1]!) / 2
: titleFontSize + 12;
const cloudRadius = nodeHalfDiag * 2;
const dx = gx - node.x;
const dy = gy - node.y;
if (Math.hypot(dx, dy) > cloudRadius) continue;
const step = (2 * Math.PI) / nodeTags.length;
const angle = (Math.atan2(dy, dx) + Math.PI / 2 + 2 * Math.PI) % (2 * Math.PI);
const tag = nodeTags[Math.floor(angle / step)];
if (tag) {
setTagSearch(tag === UNTAGGED ? "unspecified" : tag);
// Uncheck all tags except the clicked one
const allTags = new Set<string>();
for (const n of filteredGraphData.nodes as Node[]) {
if (n.tags.length === 0) allTags.add(UNTAGGED);
else for (const t of n.tags) allTags.add(t);
}
allTags.delete(tag);
setUncheckedTags(allTags);
}
break;
}
}, [tagColorMode, tagFilteredData.nodes, filteredGraphData.nodes, uncheckedTags, titleFontSize]);
// Compute tag color map from filtered nodes (same order as TagList's sortedTags)
const tagColorMap = useMemo(() => {
const counts = new Map<string, number>();
for (const node of filteredGraphData.nodes as Node[]) {
for (const tag of node.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
const sorted: [string, number][] = [...counts.entries()].sort((a, b) => b[1] - a[1]);
return buildTagColorMap(sorted, stableTagColors.current);
}, [filteredGraphData.nodes, nodeTagVersion]);
// Draw tag clouds + edge guides under all objects (links + nodes) via pre-render hook.
// All shapes are drawn at full opacity onto an isolated offscreen canvas, then composited
// at globalAlpha=0.35 — this prevents alpha from accumulating where shapes overlap.
const handleRenderFramePre = useCallback((ctx: CanvasRenderingContext2D, globalScale: number) => {
if (tagColorMode !== "nodes") return;
const allNodes = tagFilteredData.nodes as (Node & Coords & NodeData)[];
const visibleNodes = allNodes.filter(
(n) => n.label !== "para" && n.x !== undefined && n.y !== undefined
);
const getHalfDiag = (n: Node & Coords & NodeData) =>
n.nodeDims ? Math.hypot(n.nodeDims[0]!, n.nodeDims[1]!) / 2 : titleFontSize / globalScale + 12;
// Set up (or reuse) an offscreen canvas matching the main canvas size
if (!tagCloudCanvasRef.current) {
tagCloudCanvasRef.current = document.createElement("canvas");
}
const oc = tagCloudCanvasRef.current;
const mainCanvas = ctx.canvas;
if (mainCanvas.width === 0 || mainCanvas.height === 0) return;
oc.width = mainCanvas.width;
oc.height = mainCanvas.height;
const ocCtx = oc.getContext("2d")!;
// Replicate the main canvas transform (pan + zoom) so graph coordinates match
ocCtx.setTransform(ctx.getTransform());
// Build per-tag node map for edge guides
const nodeByPath = new Map<string, Node & Coords & NodeData>();
for (const node of visibleNodes) nodeByPath.set(node.path, node);
// 1st layer: edge guides only along actual graph edges where both nodes share the tag
const drawn = new Set<string>();
for (const link of tagFilteredData.links as Link[]) {
const a = nodeByPath.get(link.source.path);
const b = nodeByPath.get(link.target.path);
if (!a || !b || a.x === undefined || b.x === undefined) continue;
const pairKey = [a.path, b.path].sort().join("\0");
if (drawn.has(pairKey)) continue;
const sharedTags = a.tags.filter(
(t) => !uncheckedTags.has(t) && b.tags.includes(t)
);
for (const tag of sharedTags) {
const color = tagColorMap.get(tag);
if (!color) continue;
const cr = (getHalfDiag(a) + getHalfDiag(b));
Drawing.drawTagEdge(a.x!, a.y!, b.x!, b.y!, cr, color, ocCtx);
drawn.add(pairKey);
break; // one rect per edge (first shared tag wins — clouds show all tags)
}
}
// 2nd layer: node clouds
for (const node of visibleNodes) {
Drawing.drawNodeTagCloud(node, ocCtx, getHalfDiag(node), tagColorMap, uncheckedTags);
}
// Composite offscreen canvas onto main canvas at reduced opacity.
// Reset transform first so drawImage uses raw pixel coordinates.
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 0.9;
ctx.drawImage(oc, 0, 0);
ctx.restore();
}, [tagColorMode, tagFilteredData.nodes, tagFilteredData.links, tagColorMap, uncheckedTags, titleFontSize]);
// D3 force configuration
useGraphForces(fgRef, dimensions, graphData, setGraphData);
@ -97,11 +326,12 @@ export const ReactForceGraph: React.FC<GraphComponentProps> = ({
ref={containerRef}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onDoubleClick={handleCloudDoubleClick}
tabIndex={0}
>
<ForceGraph2D
ref={fgRef}
graphData={filteredGraphData}
graphData={tagFilteredData}
width={dimensions.width}
height={dimensions.height}
cooldownTicks={50}
@ -120,14 +350,33 @@ export const ReactForceGraph: React.FC<GraphComponentProps> = ({
bckgDims[1]
);
}}
onRenderFramePre={handleRenderFramePre}
linkCanvasObject={(link: Link, ctx, globalScale) =>
Drawing.drawLink(link, ctx, globalScale, navigation.isDescending, graphSettings)
Drawing.drawLink(
link, ctx, globalScale, navigation.isDescending, graphSettings,
tagColorMode === "edges" ? tagColorMap : undefined,
tagColorMode === "edges" ? uncheckedTags : undefined,
)
}
nodeLabel="name"
nodeLabel=""
nodeAutoColorBy="group"
onNodeClick={expandNode}
onNodeHover={hoverNode}
/>
<TagList
nodes={filteredGraphData.nodes as Node[]}
links={filteredGraphData.links as Link[]}
selectedNodePath={selectedNode.selectedPath}
uncheckedTags={uncheckedTags}
onToggleTag={handleToggleTag}
onSetUncheckedTags={setUncheckedTags}
tagColorMap={tagColorMap}
tagColorMode={tagColorMode}
onSetTagColorMode={handleSetTagColorMode}
search={tagSearch}
onSearchChange={setTagSearch}
onNodeTagUpdate={handleNodeTagUpdate}
/>
<GraphControls
onPanLeft={navigation.handlePanLeft}
onPanRight={navigation.handlePanRight}

324
src/views/graph/TagList.tsx Normal file
View file

@ -0,0 +1,324 @@
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { TFile } from "obsidian";
import React, { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Link } from "@/graph/Link";
import type { Node } from "@/graph/Node";
import { AppContext } from "@/context";
import { tagIndex } from "@/graph/TagIndex";
export const UNTAGGED = "\0untagged";
interface TagListProps {
nodes: Node[];
links: Link[];
selectedNodePath?: string;
uncheckedTags: Set<string>;
onToggleTag: (tag: string) => void;
onSetUncheckedTags: (tags: Set<string>) => void;
tagColorMap: Map<string, string>;
tagColorMode: "edges" | "nodes";
onSetTagColorMode: (mode: "edges" | "nodes") => void;
search: string;
onSearchChange: (s: string) => void;
onNodeTagUpdate: (nodePath: string, tag: string, add: boolean) => void;
}
const MAX_VISIBLE_TAGS = 5;
export const TagList: React.FC<TagListProps> = ({ nodes, links, selectedNodePath, uncheckedTags, onToggleTag, onSetUncheckedTags, tagColorMap, tagColorMode, onSetTagColorMode, search, onSearchChange, onNodeTagUpdate }) => {
const app = useContext(AppContext);
// Pending toggles: tag → intended checked state (overrides selectedNodeTags until cache refreshes)
const [pendingToggled, setPendingToggled] = useState<Map<string, boolean>>(new Map());
// Reset pending overrides when the selected node changes
useEffect(() => {
setPendingToggled(new Map());
}, [selectedNodePath]);
const sortedTags = useMemo(() => {
const counts = new Map<string, number>();
let untaggedCount = 0;
for (const node of nodes) {
if (node.tags.length === 0) {
untaggedCount++;
} else {
for (const tag of node.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
}
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
if (untaggedCount > 0) {
sorted.push([UNTAGGED, untaggedCount]);
}
return sorted;
}, [nodes]);
const filteredTags = useMemo(() => {
const terms = search
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
if (terms.length === 0) return sortedTags;
return sortedTags.filter(([tag]) => {
const label = tag === UNTAGGED ? "unspecified" : tag.toLowerCase();
return terms.some((t) => label.includes(t));
});
}, [sortedTags, search]);
const selectedNodeTags = useMemo(() => {
if (!selectedNodePath) return new Set<string>();
const node = nodes.find((n) => n.path === selectedNodePath);
if (!node) return new Set<string>();
if (node.tags.length === 0) return new Set([UNTAGGED]);
return new Set(node.tags);
}, [nodes, selectedNodePath]);
// Tags that neighbors have but the selected node doesn't
const neighborOnlyTags = useMemo(() => {
if (!selectedNodePath) return [] as [string, number][];
const neighborPaths = new Set<string>();
for (const link of links) {
if (link.source.path === selectedNodePath) neighborPaths.add(link.target.path);
if (link.target.path === selectedNodePath) neighborPaths.add(link.source.path);
}
const tagCounts = new Map<string, number>();
for (const node of nodes) {
if (!neighborPaths.has(node.path)) continue;
for (const tag of node.tags) {
if (tag === UNTAGGED) continue;
if (!selectedNodeTags.has(tag)) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
}
return [...tagCounts.entries()].sort((a, b) => b[1] - a[1]);
}, [links, nodes, selectedNodePath, selectedNodeTags]);
// Count how many of the current node's tags are still checked
const checkedCurrentTagCount = useMemo(() => {
let count = 0;
for (const tag of selectedNodeTags) {
if (!uncheckedTags.has(tag)) count++;
}
return count;
}, [selectedNodeTags, uncheckedTags]);
const filteredUncheckedCount = useMemo(
() => filteredTags.filter(([tag]) => uncheckedTags.has(tag)).length,
[filteredTags, uncheckedTags],
);
const allChecked = filteredUncheckedCount === 0;
const someUnchecked = filteredUncheckedCount > 0 && filteredUncheckedCount < filteredTags.length;
const firstItemRef = useRef<HTMLLabelElement>(null);
const itemsRef = useRef<HTMLDivElement>(null);
const [itemsMaxHeight, setItemsMaxHeight] = useState<number | undefined>(undefined);
useLayoutEffect(() => {
if (!firstItemRef.current || !itemsRef.current) return;
if (sortedTags.length <= MAX_VISIBLE_TAGS) {
setItemsMaxHeight(undefined);
return;
}
const itemH = firstItemRef.current.offsetHeight;
const gap = parseFloat(getComputedStyle(itemsRef.current).gap) || 0;
setItemsMaxHeight(MAX_VISIBLE_TAGS * itemH + (MAX_VISIBLE_TAGS - 1) * gap);
}, [sortedTags.length]);
const selectAllRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = someUnchecked;
}
}, [someUnchecked]);
const handleSelectAll = () => {
const filteredTagKeys = new Set(filteredTags.map(([tag]) => tag));
if (allChecked) {
onSetUncheckedTags(new Set([...uncheckedTags, ...filteredTagKeys]));
} else {
onSetUncheckedTags(new Set([...uncheckedTags].filter((t) => !filteredTagKeys.has(t))));
}
};
const handleFileTagToggle = useCallback(async (tag: string) => {
if (!app || !selectedNodePath || tag === UNTAGGED) return;
// Effective checked state: pending override wins over graph data
const currentlyChecked = pendingToggled.has(tag)
? pendingToggled.get(tag)!
: selectedNodeTags.has(tag);
// Optimistic update so the checkbox responds immediately
setPendingToggled((prev) => {
const next = new Map(prev);
next.set(tag, !currentlyChecked);
return next;
});
const file = app.vault.getAbstractFileByPath(selectedNodePath);
if (!(file instanceof TFile)) return;
const tagWithHash = tag.startsWith("#") ? tag : `#${tag}`;
const content = await app.vault.read(file);
if (currentlyChecked) {
// Remove: strip the tag and collapse any resulting double-space
const escaped = tagWithHash.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const newContent = content
.replace(new RegExp(`(?:^|\\s)${escaped}(?=\\s|$)`), (match) =>
match.startsWith(" ") && match.length > tagWithHash.length ? " " : ""
)
.trimStart();
if (newContent !== content) {
await app.vault.modify(file, newContent);
tagIndex.updateTag(selectedNodePath, tagWithHash, false);
onNodeTagUpdate(selectedNodePath, tagWithHash, false);
}
} else {
// Add: insert with a space separator after frontmatter (or at beginning of file)
const frontmatterMatch = content.match(/^---\n[\s\S]*?\n---\n/);
let newContent: string;
if (frontmatterMatch) {
const offset = frontmatterMatch[0].length;
newContent = content.slice(0, offset) + tagWithHash + " " + content.slice(offset);
} else {
newContent = tagWithHash + " " + content;
}
await app.vault.modify(file, newContent);
tagIndex.updateTag(selectedNodePath, tagWithHash, true);
onNodeTagUpdate(selectedNodePath, tagWithHash, true);
}
}, [app, selectedNodePath, pendingToggled, selectedNodeTags, onNodeTagUpdate]);
const [isMinimized, setIsMinimized] = useState(false);
if (sortedTags.length === 0) return null;
const ownTags = [...selectedNodeTags].filter((t) => t !== UNTAGGED);
const showNodePanel = selectedNodePath && (ownTags.length > 0 || neighborOnlyTags.length > 0);
return (
<div className="pm-tag-list">
<div className="pm-tag-left">
<div className="pm-tag-color-toggle">
<button
className="pm-tag-minimize-btn"
onClick={() => setIsMinimized((v) => !v)}
title={isMinimized ? "Expand tag list" : "Minimize tag list"}
>
{isMinimized ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />}
</button>
<button
className={`pm-tag-color-btn${tagColorMode === "nodes" ? " active" : ""}`}
onClick={() => onSetTagColorMode("nodes")}
title="Color nodes by tag"
>Nodes view</button>
<button
className={`pm-tag-color-btn${tagColorMode === "edges" ? " active" : ""}`}
onClick={() => onSetTagColorMode("edges")}
title="Color edges by tag"
>Edges view</button>
</div>
{!isMinimized && (
<div className="pm-tag-filter-panel">
<div className="pm-tag-search-wrap">
<input
className="pm-tag-search"
type="text"
placeholder="Filter tags…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSelectAll(); }}
/>
{search && (
<button className="pm-tag-search-clear" onClick={() => onSearchChange("")} title="Clear">×</button>
)}
</div>
<label className="pm-tag-item pm-tag-select-all">
<input
ref={selectAllRef}
type="checkbox"
checked={allChecked}
onChange={handleSelectAll}
/>
<span className="pm-tag-select-all-label">All</span>
</label>
<div
ref={itemsRef}
className="pm-tag-items"
style={itemsMaxHeight !== undefined ? { maxHeight: itemsMaxHeight } : undefined}
>
{filteredTags.map(([tag, count], i) => {
const isOnSelected = selectedNodeTags.has(tag);
const isChecked = !uncheckedTags.has(tag);
const isDisabled = isOnSelected && isChecked && checkedCurrentTagCount <= 1;
return (
<label key={tag} ref={i === 0 ? firstItemRef : undefined} className="pm-tag-item">
<input
type="checkbox"
checked={isChecked}
disabled={isDisabled}
onChange={() => onToggleTag(tag)}
/>
<span
className="pm-tag-pill"
style={{ backgroundColor: tagColorMap.get(tag) ?? "#9e9e9e" }}
>
{tag === UNTAGGED ? "UNSPECIFIED" : tag}
</span>
<span className="pm-tag-count">{count}</span>
</label>
);
})}
</div>
</div>
)}
</div>
{!isMinimized && showNodePanel && (
<div className="pm-tag-node-panel">
<div className="pm-tag-node-panel-header">Node tags</div>
<div className="pm-tag-node-items" style={itemsMaxHeight !== undefined ? { maxHeight: itemsMaxHeight } : undefined}>
{ownTags.map((tag) => (
<label key={tag} className="pm-tag-item">
<input
type="checkbox"
checked={pendingToggled.has(tag) ? pendingToggled.get(tag)! : selectedNodeTags.has(tag)}
onChange={() => handleFileTagToggle(tag)}
/>
<span
className="pm-tag-pill"
style={{ backgroundColor: tagColorMap.get(tag) ?? "#9e9e9e" }}
>
{tag}
</span>
</label>
))}
{neighborOnlyTags.map(([tag, count]) => (
<label key={tag} className="pm-tag-item">
<input
type="checkbox"
checked={pendingToggled.has(tag) ? pendingToggled.get(tag)! : selectedNodeTags.has(tag)}
onChange={() => handleFileTagToggle(tag)}
/>
<span
className="pm-tag-pill pm-tag-pill--neighbor"
style={{ backgroundColor: tagColorMap.get(tag) ?? "#9e9e9e" }}
>
{tag}
</span>
<span className="pm-tag-count">{count}</span>
</label>
))}
</div>
</div>
)}
</div>
);
};

View file

@ -8,6 +8,7 @@ import type { App, Vault } from "obsidian";
import { Graph } from "@/graph/Graph";
import { Link } from "@/graph/Link";
import { Node } from "@/graph/Node";
import { type TagIndex } from "@/graph/TagIndex";
/**
*
@ -124,7 +125,8 @@ export const loadImagesForGraph = async (plugin: ForceGraphPlugin, graph: Graph)
const circularBlob = await offscreenCanvas.convertToBlob();
node.image = await createImageBitmap(circularBlob);
plugin.globalGraph.nodes.find((n) => n.path == node.path)!.image = node.image;
const globalNode = plugin.globalGraph.getNodeById(node.path);
if (globalNode) globalNode.image = node.image;
}
} else if (node.imagePath) {
console.debug(`Image ${node.imagePath} for ${node.path} already loaded!`);
@ -196,8 +198,8 @@ export const getNewLocalGraph = (
};
export const implodeGraph = async (app: App, plugin: ForceGraphPlugin, nodePath: string): Promise<Graph> => {
const node = plugin.globalGraph.nodes.find((n) => n.path == nodePath)
if (node === undefined) {
const node = plugin.globalGraph.getNodeById(nodePath)
if (node === null) {
return Graph.createEmpty();
}
if (!node.imploded) {
@ -236,6 +238,50 @@ export const implodeGraph = async (app: App, plugin: ForceGraphPlugin, nodePath:
} as unknown as Graph;
}
export const loadTagsForGraph = (app: App, graph: Graph, tagIdx: TagIndex) => {
for (const node of graph.nodes) {
if (tagIdx.has(node.path)) {
node.tags = [...tagIdx.getTagsForNode(node.path)];
continue;
}
const file = app.vault.getAbstractFileByPath(node.path);
if (!file || !(file instanceof TFile)) continue;
const cache = app.metadataCache.getFileCache(file);
if (!cache) continue;
const tags: string[] = [];
// Only inline tags on the first content line (after frontmatter)
if (cache.tags) {
const startLine = cache.frontmatterPosition ? cache.frontmatterPosition.end.line + 1 : 0;
for (const tagCache of cache.tags) {
if (tagCache.position.start.line === startLine) {
tags.push(tagCache.tag);
}
}
}
// Frontmatter tags
if (cache.frontmatter) {
const fmTags = cache.frontmatter.tags ?? cache.frontmatter.tag;
if (Array.isArray(fmTags)) {
for (const t of fmTags) {
const normalized = String(t).startsWith("#") ? String(t) : `#${t}`;
tags.push(normalized);
}
} else if (typeof fmTags === "string") {
const normalized = fmTags.startsWith("#") ? fmTags : `#${fmTags}`;
tags.push(normalized);
}
}
tagIdx.addTags(node.path, tags);
node.tags = tags;
}
};
export interface SectionData {
links: string[]; // Resolved note names or paths
level: number; // Heading level (1 for #, 2 for ##, etc.)

View file

@ -0,0 +1,52 @@
const TAG_PALETTE = [
"#471e8f",
"#035b82",
"#7a298f",
"#e91e90", // Magenta
"#7b8a8e", // Metallic
"#2d8659", // Rainforest
"#d4900a", // Amber
"#2563eb", // Sapphire
];
const TAG_COLOR_FALLBACK = "#9e9e9e";
/**
* Builds the display color map. `stableAssignment` is mutated in place to
* preserve palette colors for tags that remain in the top-N across renders.
* Tags that drop out of the top-N lose their slot; new entrants get the next
* available palette color.
*/
export function buildTagColorMap(
sortedTags: [string, number][],
stableAssignment: Map<string, string>,
): Map<string, string> {
const N = TAG_PALETTE.length;
const topNSet = new Set(sortedTags.slice(0, N).map(([tag]) => tag));
// Evict tags no longer in top-N
for (const tag of [...stableAssignment.keys()]) {
if (!topNSet.has(tag)) stableAssignment.delete(tag);
}
// Assign palette colors to new top-N entrants
const usedColors = new Set(stableAssignment.values());
let pi = 0;
for (const tag of topNSet) {
if (!stableAssignment.has(tag)) {
while (pi < TAG_PALETTE.length && usedColors.has(TAG_PALETTE[pi]!)) pi++;
if (pi < TAG_PALETTE.length) {
stableAssignment.set(tag, TAG_PALETTE[pi]!);
usedColors.add(TAG_PALETTE[pi]!);
pi++;
}
}
}
// Build display map: top-N get stable color, rest get fallback
const map = new Map<string, string>();
for (const [tag] of sortedTags) {
map.set(tag, stableAssignment.get(tag) ?? TAG_COLOR_FALLBACK);
}
return map;
}

View file

@ -31,16 +31,10 @@
background: var(--color-base-60);
}
.slider-control {
position: absolute;
top: 50px;
left: 10px;
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr;
gap: 5px;
background: rgba(255, 255, 255, 0.8);
padding: 5px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
color: var(--color-base-00);
}
.slider-control input[type=range] {
@ -57,4 +51,172 @@
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/*# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsic3JjL3ZpZXdzL2dyYXBoL0dyYXBoQ29udHJvbHMuY3NzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyIucG0tZ3JhcGgtY29udHJvbHMge1xuICBwb3NpdGlvbjogYWJzb2x1dGU7XG4gIGRpc3BsYXk6IGdyaWQ7XG4gIGdhcDogNXB4O1xuICBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwgMjU1LCAyNTUsIDAuOCk7IC8qIFNlbWktdHJhbnNwYXJlbnQgYmFja2dyb3VuZCAqL1xuICBwYWRkaW5nOiA1cHg7XG4gIGJvcmRlci1yYWRpdXM6IDVweDtcbiAgYm94LXNoYWRvdzogMCAycHggNHB4IHJnYmEoMCwgMCwgMCwgMC4yKTtcbn1cblxuLnJpZ2h0LWdyaWQge1xuICB0b3A6IDUwcHg7XG4gIHJpZ2h0OiAxMHB4O1xuICBncmlkLXRlbXBsYXRlLWNvbHVtbnM6IDFmciAxZnI7IC8qIDIgY29sdW1ucyAqL1xufVxuXG4uZmlyc3Qtcm93IHtcbiAgdG9wOiA1MHB4O1xuICBsZWZ0OiAxMzBweDtcbn1cblxuLnBtLWdyYXBoLWNvbnRyb2xzIGJ1dHRvbiB7XG4gIGRpc3BsYXk6IGZsZXg7XG4gIGFsaWduLWl0ZW1zOiBjZW50ZXI7XG4gIGp1c3RpZnktY29udGVudDogY2VudGVyO1xuICBiYWNrZ3JvdW5kOiB2YXIoLS1jb2xvci1iYXNlLTQwKTtcbiAgYm9yZGVyOiBub25lO1xuICBib3JkZXItcmFkaXVzOiA0cHg7XG4gIGN1cnNvcjogcG9pbnRlcjtcbiAgZm9udC1zaXplOiAxNnB4O1xufVxuXG4ucG0tZ3JhcGgtY29udHJvbHMgYnV0dG9uOmhvdmVyIHtcbiAgYmFja2dyb3VuZDogdmFyKC0tY29sb3ItYmFzZS02MCk7XG59XG5cbi5zbGlkZXItY29udHJvbCB7XG4gIHBvc2l0aW9uOiBhYnNvbHV0ZTtcbiAgdG9wOiA1MHB4O1xuICBsZWZ0OiAxMHB4O1xuICBkaXNwbGF5OiBncmlkO1xuICBncmlkLXRlbXBsYXRlLWNvbHVtbnM6IDFmcjtcbiAgZ2FwOiA1cHg7XG4gIGJhY2tncm91bmQ6IHJnYmEoMjU1LCAyNTUsIDI1NSwgMC44KTsgLyogU2VtaS10cmFuc3BhcmVudCBiYWNrZ3JvdW5kICovXG4gIHBhZGRpbmc6IDVweDtcbiAgYm9yZGVyLXJhZGl1czogNXB4O1xuICBib3gtc2hhZG93OiAwIDJweCA0cHggcmdiYSgwLCAwLCAwLCAwLjIpO1xuICBjb2xvcjogdmFyKC0tY29sb3ItYmFzZS0wMCk7XG59XG5cbi5zbGlkZXItY29udHJvbCBpbnB1dFt0eXBlPVwicmFuZ2VcIl0ge1xuICB3aWR0aDogMTAwcHg7XG4gIGN1cnNvcjogcG9pbnRlcjtcbn1cblxuLnNsaWRlci12YWx1ZSB7XG4gIHBvc2l0aW9uOiBhYnNvbHV0ZTtcbiAgdG9wOiAtMjBweDsgLyogQWJvdmUgdGhlIHNsaWRlciAqL1xuICBsZWZ0OiA1MCU7XG4gIHRyYW5zZm9ybTogdHJhbnNsYXRlWCgtNTAlKTtcbiAgZm9udC1zaXplOiAxMnB4O1xuICBwYWRkaW5nOiAycHggNXB4O1xuICBib3JkZXItcmFkaXVzOiAzcHg7XG4gIGJveC1zaGFkb3c6IDAgMXB4IDJweCByZ2JhKDAsIDAsIDAsIDAuMSk7XG59XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQUEsQ0FBQztBQUNDLFlBQVU7QUFDVixXQUFTO0FBQ1QsT0FBSztBQUNMLGNBQVksS0FBSyxHQUFHLEVBQUUsR0FBRyxFQUFFLEdBQUcsRUFBRTtBQUNoQyxXQUFTO0FBQ1QsaUJBQWU7QUFDZixjQUFZLEVBQUUsSUFBSSxJQUFJLEtBQUssQ0FBQyxFQUFFLENBQUMsRUFBRSxDQUFDLEVBQUU7QUFDdEM7QUFFQSxDQUFDO0FBQ0MsT0FBSztBQUNMLFNBQU87QUFDUCx5QkFBdUIsSUFBSTtBQUM3QjtBQUVBLENBQUM7QUFDQyxPQUFLO0FBQ0wsUUFBTTtBQUNSO0FBRUEsQ0FyQkMsa0JBcUJrQjtBQUNqQixXQUFTO0FBQ1QsZUFBYTtBQUNiLG1CQUFpQjtBQUNqQixjQUFZLElBQUk7QUFDaEIsVUFBUTtBQUNSLGlCQUFlO0FBQ2YsVUFBUTtBQUNSLGFBQVc7QUFDYjtBQUVBLENBaENDLGtCQWdDa0IsTUFBTTtBQUN2QixjQUFZLElBQUk7QUFDbEI7QUFFQSxDQUFDO0FBQ0MsWUFBVTtBQUNWLE9BQUs7QUFDTCxRQUFNO0FBQ04sV0FBUztBQUNULHlCQUF1QjtBQUN2QixPQUFLO0FBQ0wsY0FBWSxLQUFLLEdBQUcsRUFBRSxHQUFHLEVBQUUsR0FBRyxFQUFFO0FBQ2hDLFdBQVM7QUFDVCxpQkFBZTtBQUNmLGNBQVksRUFBRSxJQUFJLElBQUksS0FBSyxDQUFDLEVBQUUsQ0FBQyxFQUFFLENBQUMsRUFBRTtBQUNwQyxTQUFPLElBQUk7QUFDYjtBQUVBLENBZEMsZUFjZSxLQUFLLENBQUM7QUFDcEIsU0FBTztBQUNQLFVBQVE7QUFDVjtBQUVBLENBQUM7QUFDQyxZQUFVO0FBQ1YsT0FBSztBQUNMLFFBQU07QUFDTixhQUFXLFdBQVc7QUFDdEIsYUFBVztBQUNYLFdBQVMsSUFBSTtBQUNiLGlCQUFlO0FBQ2YsY0FBWSxFQUFFLElBQUksSUFBSSxLQUFLLENBQUMsRUFBRSxDQUFDLEVBQUUsQ0FBQyxFQUFFO0FBQ3RDOyIsCiAgIm5hbWVzIjogW10KfQo= */
.pm-tag-list {
position: absolute;
top: 50px;
left: 10px;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
background: rgba(255, 255, 255, 0.8);
padding: 6px 8px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
font-size: 12px;
pointer-events: auto;
}
.pm-tag-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.pm-tag-minimize-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: rgba(0, 0, 0, 0.4);
flex-shrink: 0;
align-self: flex-start;
}
.pm-tag-minimize-btn:hover {
color: rgba(0, 0, 0, 0.7);
}
.pm-tag-filter-panel {
display: flex;
flex-direction: column;
gap: 2px;
}
.pm-tag-node-panel {
display: flex;
flex-direction: column;
gap: 2px;
border-left: 1px solid rgba(0, 0, 0, 0.12);
padding-left: 8px;
min-width: 0;
}
.pm-tag-node-panel-header {
font-size: 11px;
font-weight: 600;
color: gray;
white-space: nowrap;
margin-bottom: 16px;
}
.pm-tag-node-items {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.pm-tag-node-items::-webkit-scrollbar {
width: 4px;
}
.pm-tag-node-items::-webkit-scrollbar-thumb {
background: #2563eb;
border-radius: 2px;
}
.pm-tag-pill--neighbor {
opacity: 0.55;
}
.pm-tag-items {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.pm-tag-items::-webkit-scrollbar {
width: 4px;
}
.pm-tag-items::-webkit-scrollbar-track {
background: #2563eb;
}
.pm-tag-items::-webkit-scrollbar-thumb {
background: #2563eb;
border-radius: 2px;
}
.pm-tag-search-wrap {
position: relative;
margin-bottom: 2px;
}
.pm-tag-search {
width: 100%;
box-sizing: border-box;
padding: 2px 20px 2px 5px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 3px;
font-size: 11px;
background: rgba(255, 255, 255, 0.9);
}
.pm-tag-search-clear {
position: absolute;
right: 3px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0;
line-height: 1;
font-size: 13px;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
}
.pm-tag-item {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
cursor: pointer;
}
.pm-tag-item input[type=checkbox] {
margin: 0;
cursor: pointer;
flex-shrink: 0;
}
.pm-tag-item input[type=checkbox]:disabled {
cursor: default;
opacity: 0.5;
}
.pm-tag-pill {
border-radius: 8px;
padding: 1px 6px;
color: white;
font-size: 11px;
flex: 1;
}
.pm-tag-select-all-label {
color: gray;
}
.pm-tag-name {
color: var(--color-base-00);
flex: 1;
}
.pm-tag-count {
color: var(--color-base-40);
font-size: 11px;
}
.pm-tag-color-toggle {
display: flex;
align-items: center;
gap: 4px;
}
.pm-tag-color-btn {
flex: 1;
padding: 2px 6px;
font-size: 11px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 3px;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
color: var(--color-base-00);
}
.pm-tag-color-btn.active {
background: var(--color-base-40);
color: white;
}
.pm-tag-color-btn:hover:not(.active) {
background: rgba(0, 0, 0, 0.06);
}