mirror of
https://github.com/nn-ninja/n-brace.git
synced 2026-07-22 06:42:36 +00:00
Tag List control panel
This commit is contained in:
parent
56504517e7
commit
1bf88110d5
15 changed files with 1378 additions and 141 deletions
|
|
@ -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!>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
4
src/context.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { createContext } from "react";
|
||||
import type { App } from "obsidian";
|
||||
|
||||
export const AppContext = createContext<App | undefined>(undefined);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
69
src/graph/TagIndex.ts
Normal 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();
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
324
src/views/graph/TagList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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.)
|
||||
|
|
|
|||
52
src/views/graph/tagColors.ts
Normal file
52
src/views/graph/tagColors.ts
Normal 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;
|
||||
}
|
||||
178
styles.css
178
styles.css
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue