From 1bf88110d59f22e3fc77388ffc7b3adfecac9c56 Mon Sep 17 00:00:00 2001 From: erhazan Date: Thu, 26 Feb 2026 01:09:45 +0100 Subject: [PATCH] Tag List control panel --- README.md | 2 +- data.json | 4 +- src/context.ts | 4 + src/graph/Graph.ts | 88 ++++---- src/graph/Node.ts | 1 + src/graph/TagIndex.ts | 69 ++++++ src/views/ReactForceGraphView.tsx | 9 +- src/views/graph/Drawing.ts | 184 ++++++++++++++-- src/views/graph/GraphControls.css | 207 +++++++++++++++++- src/views/graph/GraphControls.tsx | 88 ++++---- src/views/graph/ReactForceGraph.tsx | 257 +++++++++++++++++++++- src/views/graph/TagList.tsx | 324 ++++++++++++++++++++++++++++ src/views/graph/fileGraphMethods.ts | 52 ++++- src/views/graph/tagColors.ts | 52 +++++ styles.css | 178 ++++++++++++++- 15 files changed, 1378 insertions(+), 141 deletions(-) create mode 100644 src/context.ts create mode 100644 src/graph/TagIndex.ts create mode 100644 src/views/graph/TagList.tsx create mode 100644 src/views/graph/tagColors.ts diff --git a/README.md b/README.md index cd3dffc..3700196 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/data.json b/data.json index 966296e..720c537 100644 --- a/data.json +++ b/data.json @@ -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" } diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..636199e --- /dev/null +++ b/src/context.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import type { App } from "obsidian"; + +export const AppContext = createContext(undefined); diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 9011074..354eb33 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -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; - // Indexes to quickly retrieve nodes and links by id - private readonly nodeIndex: Map; - private readonly linkIndex: Map>; - - constructor( - rootPath: string | undefined, - nodes: Node[], - links: Link[], - nodeIndex: Map, - linkIndex: Map> - ) { + constructor(rootPath: string | undefined, graph: DirectedGraph) { this.rootPath = rootPath; - this.nodes = nodes; - this.links = links; - this.nodeIndex = nodeIndex || new Map(); - this.linkIndex = linkIndex || new Map>(); + 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()); }; // Creates a graph using the Obsidian API @@ -97,7 +87,14 @@ export class Graph { const nodeMap = new Map(); newNodes.forEach((node) => nodeMap.set(node.id, node)); - const links: Link[] = []; + const dg = new DirectedGraph(); + + // 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, recStack: Set): boolean => { if (!visited.has(nodeId)) { // Add to visited and recursion stack diff --git a/src/graph/Node.ts b/src/graph/Node.ts index 091ef88..3f15650 100644 --- a/src/graph/Node.ts +++ b/src/graph/Node.ts @@ -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 = {}; diff --git a/src/graph/TagIndex.ts b/src/graph/TagIndex.ts new file mode 100644 index 0000000..50f6172 --- /dev/null +++ b/src/graph/TagIndex.ts @@ -0,0 +1,69 @@ +export class TagIndex { + private nodeToTags = new Map>(); + private tagToNodes = new Map>(); + + 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 { + return this.nodeToTags.get(nodePath) ?? new Set(); + } + + getNodesForTag(tag: string): Set { + 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(); diff --git a/src/views/ReactForceGraphView.tsx b/src/views/ReactForceGraphView.tsx index 3443093..d74281f 100644 --- a/src/views/ReactForceGraphView.tsx +++ b/src/views/ReactForceGraphView.tsx @@ -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(undefined); +export { AppContext }; export const ViewContext = createContext(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) { diff --git a/src/views/graph/Drawing.ts b/src/views/graph/Drawing.ts index becc567..592a096 100644 --- a/src/views/graph/Drawing.ts +++ b/src/views/graph/Drawing.ts @@ -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, + uncheckedTags?: Set ) { 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, + uncheckedTags: Set + ) { + 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 diff --git a/src/views/graph/GraphControls.css b/src/views/graph/GraphControls.css index 8d9f1d6..c1bc3e1 100644 --- a/src/views/graph/GraphControls.css +++ b/src/views/graph/GraphControls.css @@ -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); +} diff --git a/src/views/graph/GraphControls.tsx b/src/views/graph/GraphControls.tsx index d6d16e5..8d90cee 100644 --- a/src/views/graph/GraphControls.tsx +++ b/src/views/graph/GraphControls.tsx @@ -5,7 +5,6 @@ import { ArrowUp, FlipVertical, FlipVertical2, - ScanText, Sun, Sunrise, } from "lucide-react"; @@ -50,12 +49,6 @@ export const GraphControls: React.FC = ({ } }; - const handleImplode = () => { - if (onImplode) { - onImplode(); - } - }; - const handleSliderChange = (event: React.ChangeEvent) => { const value = Number(event.target.value); setMaxPathLength(value); @@ -70,52 +63,43 @@ export const GraphControls: React.FC = ({ }, [maxPathLength, onMaxPathLengthChange]); return ( - <> +
+ + {!isHidden && ( -
- - -
+ <> + + + + +
+ + +
+ )} -
- -
-
- - - {!isHidden && ( - <> - - - - - - )} -
- +
); }; diff --git a/src/views/graph/ReactForceGraph.tsx b/src/views/graph/ReactForceGraph.tsx index a0fe2b2..eb3b984 100644 --- a/src/views/graph/ReactForceGraph.tsx +++ b/src/views/graph/ReactForceGraph.tsx @@ -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 = ({ }) => { const fgRef = useRef(undefined); const containerRef = useRef(null); + const tagCloudCanvasRef = useRef(null); + const stableTagColors = useRef>(new Map()); const dimensions = useAtomValue(dimensionsAtom); const graphSettings = useAtomValue(graphSettingsAtom); @@ -74,6 +78,231 @@ export const ReactForceGraph: React.FC = ({ // Distance-based filtering const { filteredGraphData, setMaxPathLength } = useGraphFiltering(graphData, selectedNode); + // Tag filtering + const [uncheckedTags, setUncheckedTags] = useState>(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(); + 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(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(); + 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) => { + 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(); + 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(); + 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(); + 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(); + 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 = ({ ref={containerRef} onKeyUp={handleKeyUp} onKeyDown={handleKeyDown} + onDoubleClick={handleCloudDoubleClick} tabIndex={0} > = ({ 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} /> + ; + onToggleTag: (tag: string) => void; + onSetUncheckedTags: (tags: Set) => void; + tagColorMap: Map; + 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 = ({ 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>(new Map()); + + // Reset pending overrides when the selected node changes + useEffect(() => { + setPendingToggled(new Map()); + }, [selectedNodePath]); + + const sortedTags = useMemo(() => { + const counts = new Map(); + 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(); + const node = nodes.find((n) => n.path === selectedNodePath); + if (!node) return new Set(); + 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(); + 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(); + 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(null); + const itemsRef = useRef(null); + const [itemsMaxHeight, setItemsMaxHeight] = useState(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(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 ( +
+
+
+ + + +
+ {!isMinimized && ( +
+
+ onSearchChange(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSelectAll(); }} + /> + {search && ( + + )} +
+ +
+ {filteredTags.map(([tag, count], i) => { + const isOnSelected = selectedNodeTags.has(tag); + const isChecked = !uncheckedTags.has(tag); + const isDisabled = isOnSelected && isChecked && checkedCurrentTagCount <= 1; + return ( + + ); + })} +
+
+ )} +
+ + {!isMinimized && showNodePanel && ( +
+
Node tags
+
+ {ownTags.map((tag) => ( + + ))} + {neighborOnlyTags.map(([tag, count]) => ( + + ))} +
+
+ )} +
+ ); +}; diff --git a/src/views/graph/fileGraphMethods.ts b/src/views/graph/fileGraphMethods.ts index c903cc9..4edbc72 100644 --- a/src/views/graph/fileGraphMethods.ts +++ b/src/views/graph/fileGraphMethods.ts @@ -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 => { - 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.) diff --git a/src/views/graph/tagColors.ts b/src/views/graph/tagColors.ts new file mode 100644 index 0000000..bd80702 --- /dev/null +++ b/src/views/graph/tagColors.ts @@ -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, +): Map { + 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(); + for (const [tag] of sortedTags) { + map.set(tag, stableAssignment.get(tag) ?? TAG_COLOR_FALLBACK); + } + return map; +} diff --git a/styles.css b/styles.css index be07831..44fcd08 100644 --- a/styles.css +++ b/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); +}