diff --git a/.gitignore b/.gitignore index fe4d7d0..84d0af5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ .astro/ +packages/*/dist/ # Generated public assets (rebuilt on every deploy — don't commit) public/graph.json @@ -12,6 +13,7 @@ public/blocks.json public/vault-assets/ # Obsidian workspace state (machine-local, not useful in git) +vault/.obsidian/plugins/ vault/.obsidian/workspace.json vault/.obsidian/workspace-mobile.json diff --git a/package.json b/package.json index 0557dfa..1dc4b87 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "build": "node scripts/sync-titles.mjs && astro build", "preview": "astro preview", "astro": "astro", - "sync-titles": "node scripts/sync-titles.mjs" + "sync-titles": "node scripts/sync-titles.mjs", + "plugin:build": "pnpm --filter galaxybrain-obsidian-plugin build", + "plugin:dev": "pnpm --filter galaxybrain-obsidian-plugin dev", + "plugin:check": "pnpm --filter galaxybrain-obsidian-plugin check" }, "pnpm": { "ignoredBuiltDependencies": [ @@ -40,4 +43,4 @@ "typescript": "^5.9.3", "unified": "^11.0.5" } -} \ No newline at end of file +} diff --git a/packages/obsidian-plugin/esbuild.config.mjs b/packages/obsidian-plugin/esbuild.config.mjs new file mode 100644 index 0000000..2be5e2f --- /dev/null +++ b/packages/obsidian-plugin/esbuild.config.mjs @@ -0,0 +1,69 @@ +import esbuild from 'esbuild'; +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const watch = process.argv.includes('--watch'); +const packageDir = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.join(packageDir, 'dist'); +const vaultPluginDir = path.resolve( + packageDir, + '../../vault/.obsidian/plugins/galaxybrain-preview', +); +const staticFiles = ['manifest.json', 'styles.css', 'versions.json']; + +function ensureDir(dir) { + mkdirSync(dir, { recursive: true }); +} + +function copyStaticFiles(targetDir) { + ensureDir(targetDir); + for (const file of staticFiles) { + copyFileSync(path.join(packageDir, file), path.join(targetDir, file)); + } +} + +function copyBuildOutputs() { + const builtMain = path.join(distDir, 'main.js'); + if (!existsSync(builtMain)) return; + + copyStaticFiles(distDir); + copyStaticFiles(vaultPluginDir); + copyFileSync(builtMain, path.join(vaultPluginDir, 'main.js')); +} + +const copyPluginFiles = { + name: 'copy-plugin-files', + setup(build) { + build.onEnd((result) => { + if (result.errors.length > 0) return; + copyBuildOutputs(); + console.log(`[galaxybrain-preview] Copied build to ${vaultPluginDir}`); + }); + }, +}; + +const buildOptions = { + entryPoints: [path.join(packageDir, 'main.ts')], + outfile: path.join(distDir, 'main.js'), + bundle: true, + format: 'cjs', + platform: 'node', + target: 'es2022', + jsx: 'automatic', + jsxImportSource: 'react', + sourcemap: watch ? 'inline' : false, + external: ['obsidian', 'electron'], + logLevel: 'info', + plugins: [copyPluginFiles], +}; + +ensureDir(distDir); + +if (watch) { + const context = await esbuild.context(buildOptions); + await context.watch(); + console.log('[galaxybrain-preview] Watching for changes...'); +} else { + await esbuild.build(buildOptions); +} diff --git a/packages/obsidian-plugin/main.ts b/packages/obsidian-plugin/main.ts new file mode 100644 index 0000000..f65f7aa --- /dev/null +++ b/packages/obsidian-plugin/main.ts @@ -0,0 +1,93 @@ +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { ItemView, Plugin, WorkspaceLeaf } from 'obsidian'; +import { GalaxyBrainPanel } from './src/galaxybrain-panel'; +import { GalaxyBrainGraphStore } from './src/graph-store'; + +export const VIEW_TYPE_GALAXYBRAIN = 'galaxybrain-view'; +const ICON_NAME = 'git-fork'; + +class GalaxyBrainView extends ItemView { + private readonly plugin: GalaxyBrainPlugin; + private reactRoot: Root | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: GalaxyBrainPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return VIEW_TYPE_GALAXYBRAIN; + } + + getDisplayText(): string { + return 'GalaxyBrain'; + } + + getIcon(): string { + return ICON_NAME; + } + + async onOpen(): Promise { + this.contentEl.empty(); + this.contentEl.addClass('galaxybrain-view__content'); + + const mount = this.contentEl.createDiv({ cls: 'galaxybrain-view__mount' }); + this.reactRoot = createRoot(mount); + this.reactRoot.render(createElement(GalaxyBrainPanel, { + app: this.app, + store: this.plugin.store, + })); + } + + async onClose(): Promise { + this.reactRoot?.unmount(); + this.reactRoot = null; + this.contentEl.empty(); + } +} + +export default class GalaxyBrainPlugin extends Plugin { + readonly store = new GalaxyBrainGraphStore(this.app); + + async onload(): Promise { + this.registerView( + VIEW_TYPE_GALAXYBRAIN, + (leaf) => new GalaxyBrainView(leaf, this), + ); + + this.addRibbonIcon(ICON_NAME, 'Open GalaxyBrain', () => { + void this.activateView(); + }); + + this.addCommand({ + id: 'open-galaxybrain', + name: 'Open GalaxyBrain', + callback: () => { + void this.activateView(); + }, + }); + + void this.store.start(); + } + + onunload(): void { + this.store.stop(); + this.app.workspace.detachLeavesOfType(VIEW_TYPE_GALAXYBRAIN); + } + + async activateView(): Promise { + const { workspace } = this.app; + let leaf = workspace.getLeavesOfType(VIEW_TYPE_GALAXYBRAIN)[0]; + + if (!leaf) { + leaf = workspace.getLeaf(true); + await leaf.setViewState({ + type: VIEW_TYPE_GALAXYBRAIN, + active: true, + }); + } + + workspace.revealLeaf(leaf); + } +} diff --git a/packages/obsidian-plugin/manifest.json b/packages/obsidian-plugin/manifest.json new file mode 100644 index 0000000..2c37fe2 --- /dev/null +++ b/packages/obsidian-plugin/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "galaxybrain-preview", + "name": "GalaxyBrain Preview", + "version": "0.1.0", + "minAppVersion": "1.6.0", + "description": "Native 3D GalaxyBrain graph view for Obsidian vaults.", + "author": "GalaxyBrain contributors", + "authorUrl": "https://github.com/trungnguyenarts/GalaxyBrain", + "isDesktopOnly": true +} diff --git a/packages/obsidian-plugin/package.json b/packages/obsidian-plugin/package.json new file mode 100644 index 0000000..5ef58e9 --- /dev/null +++ b/packages/obsidian-plugin/package.json @@ -0,0 +1,28 @@ +{ + "name": "galaxybrain-obsidian-plugin", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "node esbuild.config.mjs", + "dev": "node esbuild.config.mjs --watch", + "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "gray-matter": "^4.0.3", + "lucide-react": "^0.575.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-force-graph-3d": "^1.29.1", + "three": "^0.183.2" + }, + "devDependencies": { + "@types/node": "^24.9.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/three": "^0.183.1", + "esbuild": "^0.25.3", + "obsidian": "^1.8.10", + "typescript": "^5.9.3" + } +} diff --git a/packages/obsidian-plugin/src/color-presets.ts b/packages/obsidian-plugin/src/color-presets.ts new file mode 100644 index 0000000..6d0716c --- /dev/null +++ b/packages/obsidian-plugin/src/color-presets.ts @@ -0,0 +1,109 @@ +import type { GraphData, GraphNode } from '../../../src/lib/types'; +import type { GraphColorPreset } from './plugin-types'; + +interface ColorPresetDefinition { + filePalette: string[]; + tagPalette: string[]; +} + +const COLOR_PRESETS: Record = { + default: { + filePalette: [ + '#3498db', + ], + tagPalette: [ + '#FF6B6B', '#FFA726', '#FFEE58', '#66BB6A', '#26C6DA', + '#42A5F5', '#7E57C2', '#AB47BC', '#EC407A', + '#8D6E63', '#78909C', '#D4E157', + ], + }, + ocean: { + filePalette: [ + '#3b82f6', '#0ea5e9', '#06b6d4', '#2563eb', + '#14b8a6', '#60a5fa', '#2dd4bf', '#818cf8', + ], + tagPalette: [ + '#38bdf8', '#0ea5e9', '#0284c7', '#2563eb', '#1d4ed8', + '#14b8a6', '#06b6d4', '#22d3ee', '#60a5fa', + '#818cf8', '#2dd4bf', '#67e8f9', + ], + }, + ember: { + filePalette: [ + '#e76f51', '#ef4444', '#f97316', '#fb7185', + '#f59e0b', '#f43f5e', '#dc2626', '#fdba74', + ], + tagPalette: [ + '#ef4444', '#f97316', '#fb7185', '#f59e0b', '#f43f5e', + '#dc2626', '#ea580c', '#facc15', '#b45309', + '#c2410c', '#fda4af', '#fdba74', + ], + }, + forest: { + filePalette: [ + '#2a9d8f', '#22c55e', '#16a34a', '#84cc16', + '#10b981', '#4ade80', '#15803d', '#34d399', + ], + tagPalette: [ + '#2a9d8f', '#22c55e', '#16a34a', '#84cc16', '#65a30d', + '#10b981', '#4ade80', '#15803d', '#86efac', + '#4d7c0f', '#34d399', '#a3e635', + ], + }, + graphite: { + filePalette: [ + '#94a3b8', '#cbd5e1', '#64748b', '#d6d3d1', + '#9ca3af', '#bdb2ff', '#71717a', '#e5e7eb', + ], + tagPalette: [ + '#cbd5e1', '#94a3b8', '#e2e8f0', '#64748b', '#a8a29e', + '#d6d3d1', '#9ca3af', '#bdb2ff', '#f5f5f4', + '#71717a', '#d4d4d8', '#e5e7eb', + ], + }, +}; + +function hashString(input: string): number { + let hash = 5381; + for (let index = 0; index < input.length; index += 1) { + hash = (((hash << 5) + hash) ^ input.charCodeAt(index)) >>> 0; + } + return hash; +} + +function recolorNode(node: GraphNode, preset: ColorPresetDefinition): GraphNode { + if (node.type === 'ghost' || node.colorSource === 'ghost') { + return node; + } + + if (node.type === 'file') { + return { + ...node, + color: preset.filePalette[hashString(node.id) % preset.filePalette.length], + }; + } + + if (node.type === 'tag' || node.colorSource === 'tag') { + const family = node.id.replace(/^tag:/, '').split('/')[0]; + const nextColor = preset.tagPalette[hashString(family) % preset.tagPalette.length]; + return { + ...node, + color: nextColor, + }; + } + + return node; +} + +export function applyColorPreset( + graphData: GraphData | null, + presetId: GraphColorPreset, +): GraphData | null { + if (!graphData || presetId === 'default') return graphData; + + const preset = COLOR_PRESETS[presetId]; + return { + nodes: graphData.nodes.map((node) => recolorNode(node, preset)), + links: graphData.links, + }; +} diff --git a/packages/obsidian-plugin/src/galaxybrain-panel.tsx b/packages/obsidian-plugin/src/galaxybrain-panel.tsx new file mode 100644 index 0000000..1bb09af --- /dev/null +++ b/packages/obsidian-plugin/src/galaxybrain-panel.tsx @@ -0,0 +1,777 @@ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'; +import { App, TFile } from 'obsidian'; +import { Settings2, WandSparkles } from 'lucide-react'; +import InteractiveGraph, { type InteractiveGraphHandle } from '../../../src/components/InteractiveGraph'; +import { applyColorPreset } from './color-presets'; +import { applyGraphFilters } from './graph-filters'; +import { GalaxyBrainGraphStore } from './graph-store'; +import type { GraphFilterState } from './plugin-types'; +import { resolveObsidianGraphThemeTokens } from './theme-tokens'; +import type { GraphNode } from '../../../src/lib/types'; +import type { GraphAnimationMode, GraphAnimationState } from '../../../src/lib/graph-ui'; + +interface GalaxyBrainPanelProps { + app: App; + store: GalaxyBrainGraphStore; +} + +type SectionId = 'filters' | 'colors' | 'camera' | 'forces'; + +const FILTER_STORAGE_KEY = 'galaxybrain-preview-filters'; +const DEFAULT_FILTERS: GraphFilterState = { + searchQuery: '', + recentOnly: false, + recentDays: 7, + recentBasis: 'modified', + showTags: true, + existingFilesOnly: false, + showOrphans: true, + ignoreCollapsible: false, + showAllLabels: false, + impactGlow: true, + colorPreset: 'default', + particleSpeed: 0.35, + particleRandomness: 0.6, + particleStyle: 'dot', + particleTrailLength: 0.55, + animationMode: 'none', +}; +const DEFAULT_OPEN_SECTIONS: Record = { + filters: true, + colors: false, + camera: false, + forces: false, +}; + +function loadFilters(): GraphFilterState { + try { + const raw = localStorage.getItem(FILTER_STORAGE_KEY); + if (!raw) return DEFAULT_FILTERS; + const parsed = JSON.parse(raw) as Partial; + return { + ...DEFAULT_FILTERS, + ...parsed, + searchQuery: '', + }; + } catch { + return DEFAULT_FILTERS; + } +} + +export function GalaxyBrainPanel({ app, store }: GalaxyBrainPanelProps) { + const snapshot = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); + const rootRef = useRef(null); + const graphRef = useRef(null); + const activeSliderRef = useRef<{ + id: string; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + shell: HTMLDivElement; + } | null>(null); + const initialRenderRef = useRef(true); + const animationMenuRef = useRef(null); + const animationButtonRef = useRef(null); + const [themeTokens, setThemeTokens] = useState(() => resolveObsidianGraphThemeTokens()); + const [filters, setFilters] = useState(() => loadFilters()); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [isAnimationMenuOpen, setIsAnimationMenuOpen] = useState(false); + const [openSections, setOpenSections] = useState>(DEFAULT_OPEN_SECTIONS); + const [animationState, setAnimationState] = useState({ mode: 'none', running: false }); + const [activeSliderId, setActiveSliderId] = useState(null); + + useEffect(() => { + const updateTheme = () => setThemeTokens(resolveObsidianGraphThemeTokens()); + const observer = new MutationObserver(updateTheme); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'style', 'data-theme'], + }); + observer.observe(document.head, { + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); + }, []); + + useEffect(() => { + try { + const persisted = { + ...filters, + searchQuery: '', + }; + localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify(persisted)); + } catch { + // Ignore storage failures. + } + }, [filters]); + + useEffect(() => { + if (isSidebarOpen) { + setIsAnimationMenuOpen(false); + } + }, [isSidebarOpen]); + + const filteredGraphData = useMemo( + () => applyGraphFilters(snapshot, { + searchQuery: filters.searchQuery, + recentOnly: filters.recentOnly, + recentDays: filters.recentDays, + recentBasis: filters.recentBasis, + showTags: filters.showTags, + existingFilesOnly: filters.existingFilesOnly, + showOrphans: filters.showOrphans, + ignoreCollapsible: filters.ignoreCollapsible, + showAllLabels: filters.showAllLabels, + impactGlow: filters.impactGlow, + colorPreset: filters.colorPreset, + particleSpeed: filters.particleSpeed, + particleRandomness: filters.particleRandomness, + particleStyle: filters.particleStyle, + particleTrailLength: filters.particleTrailLength, + animationMode: 'none', + }), + [ + snapshot, + filters.searchQuery, + filters.recentOnly, + filters.recentDays, + filters.recentBasis, + filters.showTags, + filters.existingFilesOnly, + filters.showOrphans, + filters.ignoreCollapsible, + filters.showAllLabels, + filters.impactGlow, + ], + ); + + const colorizedGraphData = useMemo( + () => applyColorPreset(filteredGraphData, filters.colorPreset), + [filteredGraphData, filters.colorPreset], + ); + + const activeFilterCount = [ + filters.searchQuery.trim().length > 0, + filters.recentOnly !== DEFAULT_FILTERS.recentOnly, + filters.recentOnly && filters.recentDays !== DEFAULT_FILTERS.recentDays, + filters.recentOnly && filters.recentBasis !== DEFAULT_FILTERS.recentBasis, + filters.showTags !== DEFAULT_FILTERS.showTags, + filters.existingFilesOnly !== DEFAULT_FILTERS.existingFilesOnly, + filters.showOrphans !== DEFAULT_FILTERS.showOrphans, + filters.ignoreCollapsible !== DEFAULT_FILTERS.ignoreCollapsible, + filters.showAllLabels !== DEFAULT_FILTERS.showAllLabels, + filters.impactGlow !== DEFAULT_FILTERS.impactGlow, + filters.colorPreset !== DEFAULT_FILTERS.colorPreset, + ].filter(Boolean).length; + + const shellVars = useMemo(() => ({ + '--gb-background': themeTokens.background, + '--gb-panel-bg': themeTokens.panelBackground, + '--gb-panel-bg-muted': themeTokens.panelBackgroundMuted, + '--gb-panel-border': themeTokens.panelBorder, + '--gb-text': themeTokens.textNormal, + '--gb-text-muted': themeTokens.textMuted, + '--gb-accent': themeTokens.accent, + '--gb-accent-hover': themeTokens.accentHover, + '--gb-accent-text': themeTokens.accentText, + '--gb-panel-top': '44px', + '--gb-panel-right': '18px', + '--gb-panel-header-pad-right': '10px', + '--gb-panel-header-row-height': '44px', + }) as CSSProperties, [themeTokens]); + + const setFilter = useCallback((key: K, value: GraphFilterState[K]) => { + setFilters((current) => ({ ...current, [key]: value })); + }, []); + const toggleSection = useCallback((section: SectionId) => { + setOpenSections((current) => ({ + ...current, + [section]: !current[section], + })); + }, []); + + const handleOpenNode = useCallback(async (node: GraphNode) => { + if (!node.path) return; + + const file = app.vault.getAbstractFileByPath(node.path); + if (!(file instanceof TFile)) return; + + const leaf = app.workspace.getLeaf(true); + await leaf.openFile(file); + }, [app]); + + useEffect(() => { + if (initialRenderRef.current) { + initialRenderRef.current = false; + return; + } + + graphRef.current?.stopAnimation(); + setAnimationState({ mode: 'none', running: false }); + }, [ + filters.searchQuery, + filters.recentOnly, + filters.recentDays, + filters.recentBasis, + filters.showTags, + filters.existingFilesOnly, + filters.showOrphans, + filters.ignoreCollapsible, + filters.showAllLabels, + ]); + + useEffect(() => { + const onPointerDown = (event: PointerEvent) => { + if (!isAnimationMenuOpen) return; + const target = event.target as Node | null; + if ( + target && + (animationMenuRef.current?.contains(target) || animationButtonRef.current?.contains(target)) + ) { + return; + } + setIsAnimationMenuOpen(false); + }; + + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [isAnimationMenuOpen]); + + useEffect(() => { + if (!activeSliderId) return; + + const roundToStep = (value: number, min: number, max: number, step: number) => { + const clamped = Math.min(max, Math.max(min, value)); + if (step <= 0) return clamped; + const precision = step < 1 ? Math.ceil(Math.log10(1 / step)) : 0; + const stepped = Math.round((clamped - min) / step) * step + min; + return Number(stepped.toFixed(precision)); + }; + + const updateSlider = (clientX: number) => { + const active = activeSliderRef.current; + if (!active) return; + + const rect = active.shell.getBoundingClientRect(); + const ratio = rect.width <= 0 + ? 0 + : Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + const rawValue = active.min + ratio * (active.max - active.min); + active.onChange(roundToStep(rawValue, active.min, active.max, active.step)); + }; + + const handlePointerMove = (event: PointerEvent) => { + updateSlider(event.clientX); + }; + + const clearSlider = () => { + activeSliderRef.current = null; + setActiveSliderId(null); + }; + + window.addEventListener('pointermove', handlePointerMove, true); + window.addEventListener('pointerup', clearSlider, true); + window.addEventListener('pointercancel', clearSlider, true); + + return () => { + window.removeEventListener('pointermove', handlePointerMove, true); + window.removeEventListener('pointerup', clearSlider, true); + window.removeEventListener('pointercancel', clearSlider, true); + }; + }, [activeSliderId]); + + const activateAnimationMode = useCallback((mode: GraphAnimationMode) => { + graphRef.current?.stopAnimation(); + setFilter('animationMode', mode); + setIsAnimationMenuOpen(false); + + if (mode === 'none') { + setAnimationState({ mode: 'none', running: false }); + return; + } + + if (mode === 'camera-tour') { + graphRef.current?.startCameraTour(); + return; + } + + graphRef.current?.startOrbitSettleAnimation(); + }, [setFilter]); + + const animationTooltip = useMemo(() => { + if (animationState.running) return 'Stop animation'; + return 'Select animation'; + }, [animationState.running]); + + const renderSlider = ({ + id, + label, + value, + onChange, + formatValue, + min = 0, + max = 100, + step = 1, + }: { + id: string; + label: string; + value: number; + onChange: (value: number) => void; + formatValue: (value: number) => string; + min?: number; + max?: number; + step?: number; + }) => { + const clampedValue = Math.min(max, Math.max(min, value)); + const percent = ((clampedValue - min) / Math.max(max - min, 1)) * 100; + const thumbLeft = `calc(${percent}% - 9px)`; + + return ( +
+
{label}
+
+ {activeSliderId === id ? ( +
+ {formatValue(clampedValue)} +
+ ) : null} +
{ + const shell = event.currentTarget; + const rect = shell.getBoundingClientRect(); + const ratio = rect.width <= 0 + ? 0 + : Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)); + const rawValue = min + ratio * (max - min); + activeSliderRef.current = { + id, + min, + max, + step, + onChange, + shell, + }; + setActiveSliderId(id); + onChange(rawValue); + }} + role="slider" + aria-label={label} + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={clampedValue} + tabIndex={0} + > +
+
+
+
+
+ ); + }; + + const renderSection = ( + section: SectionId, + title: string, + content: ReactNode | null, + options?: { withHeaderActions?: boolean }, + ) => ( +
+
+ + {options?.withHeaderActions ? ( +
+ + +
+ ) : null} +
+ {openSections[section] && content ?
{content}
: null} +
+ ); + + const sidebar = ( + + ); + + return ( +
+
+
+ + + {isAnimationMenuOpen ? ( +
+
Animation
+ + + +
+ ) : null} +
+ + +
+ {isSidebarOpen ? sidebar : null} +
+ ); +} diff --git a/packages/obsidian-plugin/src/graph-filters.ts b/packages/obsidian-plugin/src/graph-filters.ts new file mode 100644 index 0000000..7b31e94 --- /dev/null +++ b/packages/obsidian-plugin/src/graph-filters.ts @@ -0,0 +1,174 @@ +import type { GraphData, GraphNode } from '../../../src/lib/types'; +import type { GalaxyBrainGraphSnapshot, GraphFilterState } from './plugin-types'; + +function resolveId(value: unknown): string { + return typeof value === 'object' && value !== null + ? (value as GraphNode).id + : (value as string); +} + +function matchesQuery(query: string, metadata: { title: string; relativePath: string; tags: string[] }): boolean { + const terms = query + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + + if (terms.length === 0) return true; + + const haystack = `${metadata.title}\n${metadata.relativePath}\n${metadata.tags.join(' ')}` + .toLowerCase(); + + return terms.every((term) => haystack.includes(term)); +} + +function isRecent( + metadata: { createdAt: number; modifiedAt: number }, + filters: GraphFilterState, + now = Date.now(), +): boolean { + if (!filters.recentOnly) return true; + + const cutoff = now - filters.recentDays * 24 * 60 * 60 * 1000; + const timestamp = filters.recentBasis === 'created' + ? metadata.createdAt + : metadata.modifiedAt; + + return timestamp >= cutoff; +} + +function addAncestors(tagId: string, parentsByChild: Map, visible: Set): void { + if (visible.has(tagId)) return; + visible.add(tagId); + + for (const parentId of parentsByChild.get(tagId) ?? []) { + addAncestors(parentId, parentsByChild, visible); + } +} + +export function applyGraphFilters( + snapshot: GalaxyBrainGraphSnapshot | null, + filters: GraphFilterState, +): GraphData | null { + if (!snapshot) return null; + + const { graphData, noteMetadata } = snapshot; + const nodeMap = new Map(graphData.nodes.map((node) => [node.id, node])); + const wikilinks = graphData.links.filter((link) => link.type === 'wikilink'); + const fileTagLinks = graphData.links.filter((link) => link.type === 'file-tag'); + const tagHierarchyLinks = graphData.links.filter((link) => link.type === 'tag-hierarchy'); + + let visibleFiles = new Set( + graphData.nodes + .filter((node) => node.type === 'file') + .map((node) => node.id), + ); + + if (filters.searchQuery.trim()) { + visibleFiles = new Set( + [...visibleFiles].filter((id) => { + const metadata = noteMetadata.get(id); + return metadata ? matchesQuery(filters.searchQuery, metadata) : false; + }), + ); + } + + if (filters.recentOnly) { + visibleFiles = new Set( + [...visibleFiles].filter((id) => { + const metadata = noteMetadata.get(id); + return metadata ? isRecent(metadata, filters) : false; + }), + ); + } + + if (!filters.showOrphans) { + const connectedFiles = new Set(); + + for (const link of wikilinks) { + const sourceNode = nodeMap.get(resolveId(link.source)); + const targetNode = nodeMap.get(resolveId(link.target)); + if (sourceNode?.type === 'file') connectedFiles.add(sourceNode.id); + if (targetNode?.type === 'file') connectedFiles.add(targetNode.id); + } + + visibleFiles = new Set( + [...visibleFiles].filter((id) => connectedFiles.has(id)), + ); + } + + const visibleNodes = new Set(visibleFiles); + + if (!filters.existingFilesOnly) { + for (const link of wikilinks) { + const sourceNode = nodeMap.get(resolveId(link.source)); + const targetNode = nodeMap.get(resolveId(link.target)); + if (sourceNode?.type !== 'file' || !visibleFiles.has(sourceNode.id)) continue; + if (targetNode?.type === 'ghost') visibleNodes.add(targetNode.id); + } + } + + if (filters.showTags) { + const visibleTags = new Set(); + const parentsByChild = new Map(); + + for (const link of tagHierarchyLinks) { + const parents = parentsByChild.get(link.target) ?? []; + parents.push(link.source); + parentsByChild.set(link.target, parents); + } + + for (const link of fileTagLinks) { + const sourceId = resolveId(link.source); + const targetId = resolveId(link.target); + if (visibleFiles.has(sourceId)) { + addAncestors(targetId, parentsByChild, visibleTags); + } + } + + visibleTags.forEach((id) => visibleNodes.add(id)); + } + + const filteredLinks = graphData.links.filter((link) => { + const sourceId = resolveId(link.source); + const targetId = resolveId(link.target); + + if (!visibleNodes.has(sourceId) || !visibleNodes.has(targetId)) return false; + if (!filters.showTags && (link.type === 'file-tag' || link.type === 'tag-hierarchy')) return false; + if (filters.existingFilesOnly) { + const sourceNode = nodeMap.get(sourceId); + const targetNode = nodeMap.get(targetId); + if (sourceNode?.type === 'ghost' || targetNode?.type === 'ghost') return false; + } + return true; + }); + + const linkedNodeIds = new Set(); + filteredLinks.forEach((link) => { + linkedNodeIds.add(resolveId(link.source)); + linkedNodeIds.add(resolveId(link.target)); + }); + + const filteredNodes = graphData.nodes.filter((node) => { + if (!visibleNodes.has(node.id)) return false; + if (node.type === 'tag' || node.type === 'ghost') { + return linkedNodeIds.has(node.id); + } + return true; + }); + + return { + // Clone nodes/links before passing into ForceGraph. The library mutates + // node coordinates and rewrites link endpoints to object refs. + nodes: filteredNodes.map((node) => ({ ...node })), + links: filteredLinks.map((link) => ({ + ...link, + source: resolveId(link.source), + target: resolveId(link.target), + })), + }; +} + +export function countVisibleNodes(graphData: GraphData | null, type: GraphNode['type']): number { + return graphData?.nodes.filter((node) => node.type === type).length ?? 0; +} diff --git a/packages/obsidian-plugin/src/graph-store.ts b/packages/obsidian-plugin/src/graph-store.ts new file mode 100644 index 0000000..5d2865b --- /dev/null +++ b/packages/obsidian-plugin/src/graph-store.ts @@ -0,0 +1,112 @@ +import type { EventRef } from 'obsidian'; +import { App, TAbstractFile, TFile } from 'obsidian'; +import { buildGraphData } from '../../../src/lib/graph-core'; +import { parseVaultNote } from '../../../src/lib/vault-parser'; +import type { ParsedNote } from '../../../src/lib/types'; +import type { GalaxyBrainGraphSnapshot } from './plugin-types'; + +export class GalaxyBrainGraphStore { + private readonly app: App; + private readonly listeners = new Set<() => void>(); + private readonly eventRefs: EventRef[] = []; + private snapshot: GalaxyBrainGraphSnapshot | null = null; + private rebuildTimer: number | null = null; + private started = false; + + constructor(app: App) { + this.app = app; + } + + getSnapshot = (): GalaxyBrainGraphSnapshot | null => this.snapshot; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + async start(): Promise { + if (this.started) return; + this.started = true; + + this.eventRefs.push( + this.app.vault.on('create', (file) => this.onVaultChange(file)), + this.app.vault.on('modify', (file) => this.onVaultChange(file)), + this.app.vault.on('delete', (file) => this.onVaultChange(file)), + this.app.vault.on('rename', (file, oldPath) => this.onVaultChange(file, oldPath)), + ); + + await this.rebuild(); + } + + stop(): void { + if (!this.started) return; + this.started = false; + + if (this.rebuildTimer !== null) { + window.clearTimeout(this.rebuildTimer); + this.rebuildTimer = null; + } + + this.eventRefs.splice(0).forEach((ref) => this.app.vault.offref(ref)); + this.listeners.clear(); + } + + private onVaultChange(file: TAbstractFile, oldPath?: string): void { + if (!this.shouldRebuildFor(file, oldPath)) return; + + if (this.rebuildTimer !== null) { + window.clearTimeout(this.rebuildTimer); + } + + this.rebuildTimer = window.setTimeout(() => { + this.rebuildTimer = null; + void this.rebuild(); + }, 180); + } + + private shouldRebuildFor(file: TAbstractFile, oldPath?: string): boolean { + if (file instanceof TFile) { + return file.extension === 'md'; + } + return Boolean(oldPath?.toLowerCase().endsWith('.md')); + } + + private async rebuild(): Promise { + const notes: ParsedNote[] = []; + const noteMetadataEntries: GalaxyBrainGraphSnapshot['noteMetadata'] extends Map + ? Array<[K, V]> + : never = []; + + for (const file of this.app.vault.getMarkdownFiles()) { + try { + const raw = await this.app.vault.cachedRead(file); + const note = parseVaultNote(raw, file.path); + notes.push(note); + noteMetadataEntries.push([ + note.id, + { + id: note.id, + title: note.frontmatter.title ?? note.id, + relativePath: note.relativePath, + tags: note.tags, + createdAt: file.stat.ctime, + modifiedAt: file.stat.mtime, + }, + ]); + } catch (error) { + console.warn(`[galaxybrain-preview] Failed to read ${file.path}:`, error); + } + } + + this.snapshot = { + graphData: buildGraphData(notes, { + visibility: 'all', + includeCallouts: false, + mapNotePath: (note) => note.relativePath, + }), + noteMetadata: new Map(noteMetadataEntries), + }; + + this.listeners.forEach((listener) => listener()); + } +} diff --git a/packages/obsidian-plugin/src/plugin-types.ts b/packages/obsidian-plugin/src/plugin-types.ts new file mode 100644 index 0000000..ffb61a7 --- /dev/null +++ b/packages/obsidian-plugin/src/plugin-types.ts @@ -0,0 +1,37 @@ +import type { GraphData } from '../../../src/lib/types'; +import type { GraphAnimationMode, GraphParticleStyle } from '../../../src/lib/graph-ui'; + +export type GraphColorPreset = 'default' | 'ocean' | 'ember' | 'forest' | 'graphite'; + +export interface SearchableNoteMetadata { + id: string; + title: string; + relativePath: string; + tags: string[]; + createdAt: number; + modifiedAt: number; +} + +export interface GalaxyBrainGraphSnapshot { + graphData: GraphData; + noteMetadata: Map; +} + +export interface GraphFilterState { + searchQuery: string; + recentOnly: boolean; + recentDays: number; + recentBasis: 'modified' | 'created'; + showTags: boolean; + existingFilesOnly: boolean; + showOrphans: boolean; + ignoreCollapsible: boolean; + showAllLabels: boolean; + impactGlow: boolean; + colorPreset: GraphColorPreset; + particleSpeed: number; + particleRandomness: number; + particleStyle: GraphParticleStyle; + particleTrailLength: number; + animationMode: GraphAnimationMode; +} diff --git a/packages/obsidian-plugin/src/theme-tokens.ts b/packages/obsidian-plugin/src/theme-tokens.ts new file mode 100644 index 0000000..74387cd --- /dev/null +++ b/packages/obsidian-plugin/src/theme-tokens.ts @@ -0,0 +1,36 @@ +import { createDefaultGraphThemeTokens, type GraphThemeTokens } from '../../../src/lib/graph-ui'; + +function readCssVar(style: CSSStyleDeclaration, name: string, fallback: string): string { + return style.getPropertyValue(name).trim() || fallback; +} + +export function resolveObsidianGraphThemeTokens(): GraphThemeTokens { + const mode: 'dark' | 'light' = document.body.classList.contains('theme-light') + ? 'light' + : 'dark'; + const fallback = createDefaultGraphThemeTokens(mode); + const styles = getComputedStyle(document.body); + + return { + mode, + fontFamily: + readCssVar(styles, '--font-text', '') || + readCssVar(styles, '--font-interface', '') || + styles.fontFamily || + fallback.fontFamily, + background: readCssVar(styles, '--background-primary', fallback.background), + panelBackground: readCssVar(styles, '--background-secondary', fallback.panelBackground), + panelBackgroundMuted: readCssVar(styles, '--background-modifier-form-field', fallback.panelBackgroundMuted), + panelBorder: readCssVar(styles, '--background-modifier-border', fallback.panelBorder), + textNormal: readCssVar(styles, '--text-normal', fallback.textNormal), + textMuted: readCssVar(styles, '--text-muted', fallback.textMuted), + accent: readCssVar(styles, '--interactive-accent', fallback.accent), + accentHover: readCssVar(styles, '--interactive-accent-hover', fallback.accentHover), + accentText: readCssVar(styles, '--text-on-accent', fallback.accentText), + error: readCssVar(styles, '--text-error', fallback.error), + labelText: readCssVar(styles, '--text-normal', fallback.labelText), + labelOutline: readCssVar(styles, '--background-primary', fallback.labelOutline), + calloutBackground: readCssVar(styles, '--background-secondary', fallback.calloutBackground), + calloutText: readCssVar(styles, '--interactive-accent', fallback.calloutText), + }; +} diff --git a/packages/obsidian-plugin/styles.css b/packages/obsidian-plugin/styles.css new file mode 100644 index 0000000..f07cd24 --- /dev/null +++ b/packages/obsidian-plugin/styles.css @@ -0,0 +1,547 @@ +.workspace-leaf-content[data-type="galaxybrain-view"] .view-content, +.galaxybrain-view__content { + padding: 0; + overflow: hidden; +} + +.galaxybrain-view__mount, +.galaxybrain-shell { + width: 100%; + height: 100%; + min-height: 100%; +} + +.galaxybrain-shell { + position: relative; + display: block; + background: var(--gb-background); + color: var(--gb-text); +} + +.galaxybrain-shell__graph { + position: relative; + flex: 1 1 auto; + min-width: 0; + min-height: 100%; + height: 100%; +} + +.galaxybrain-sidebar { + width: 244px; + display: flex; + flex-direction: column; + gap: 0; + padding: 0 0 8px; + background: var(--gb-panel-bg); + border: 1px solid var(--gb-panel-border); + overflow-y: auto; + overflow-x: hidden; +} + +.galaxybrain-sidebar--drawer { + width: 100%; + height: 100%; + border: none; + border-radius: 0; + box-shadow: none; +} + +.galaxybrain-sidebar--floating { + position: absolute; + top: var(--gb-panel-top); + right: var(--gb-panel-right); + z-index: 1001; + max-height: calc(100% - 64px); + border-radius: 14px; + background: color-mix(in srgb, var(--gb-panel-bg) 90%, transparent); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.24); + backdrop-filter: blur(18px); +} + +.galaxybrain-floating-stack { + position: absolute; + top: calc(var(--gb-panel-top) + (var(--gb-panel-header-row-height) - 28px) / 2); + right: calc(var(--gb-panel-right) + var(--gb-panel-header-pad-right) - 4px); + z-index: 1000; + display: flex; + flex-direction: column; + gap: 6px; +} + +.galaxybrain-floating-button { + position: relative; + width: 28px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: transparent; + color: var(--gb-text-muted); + appearance: none; + -webkit-appearance: none; + box-shadow: none; + cursor: pointer; +} + +.galaxybrain-floating-button:hover, +.galaxybrain-floating-button:focus, +.galaxybrain-floating-button:focus-visible { + background: var(--gb-panel-bg-muted); + color: var(--gb-text); + outline: none; +} + +.galaxybrain-floating-button.is-active { + background: var(--gb-panel-bg-muted); + color: var(--gb-accent); +} + +.galaxybrain-floating-button.is-armed { + background: color-mix(in srgb, var(--gb-accent) 14%, transparent); + color: var(--gb-text); +} + +.galaxybrain-floating-button.is-open { + background: var(--gb-panel-bg-muted); + color: var(--gb-text); +} + +.galaxybrain-floating-button.is-hidden { + visibility: hidden; + pointer-events: none; +} + +.galaxybrain-animation-menu { + position: absolute; + top: 34px; + right: 34px; + width: 220px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--gb-panel-border); + background: color-mix(in srgb, var(--gb-panel-bg) 94%, transparent); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24); + backdrop-filter: blur(18px); + z-index: 1000; +} + +.galaxybrain-animation-menu__label { + margin-bottom: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--gb-text-muted); +} + +.galaxybrain-section { + display: flex; + flex-direction: column; + border-top: 1px solid var(--gb-panel-border); +} + +.galaxybrain-section:first-child { + border-top: none; +} + +.galaxybrain-accordion { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 44px; + padding: 0 10px 0 12px; + background: transparent !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + appearance: none; + -webkit-appearance: none; + color: var(--gb-text); + text-align: left; + font-size: 14px; +} + +.galaxybrain-accordion__trigger { + min-width: 0; + flex: 1 1 auto; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + width: 100%; + min-height: 44px; + padding: 0; + background: transparent !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + color: inherit; + font: inherit; + cursor: pointer; + text-align: left; + appearance: none; + -webkit-appearance: none; +} + +.galaxybrain-accordion__trigger:hover, +.galaxybrain-accordion__trigger:focus, +.galaxybrain-accordion__trigger:focus-visible, +.galaxybrain-accordion__trigger:active { + background: transparent !important; + border: none !important; + box-shadow: none !important; + outline: none !important; +} + +.galaxybrain-accordion__actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +.galaxybrain-accordion__chevron { + color: var(--gb-text-muted); + transition: transform 120ms ease; + flex: 0 0 auto; +} + +.galaxybrain-accordion.is-open .galaxybrain-accordion__chevron { + transform: rotate(90deg); +} + +.galaxybrain-accordion__body { + display: flex; + flex-direction: column; + gap: 12px; + padding: 2px 12px 12px; +} + +.galaxybrain-sidebar__label { + font-size: 11px; + font-weight: 700; + color: var(--gb-text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.galaxybrain-search-input { + width: 100%; + min-width: 0; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--gb-panel-border); + background: transparent; + color: var(--gb-text); + outline: none; + appearance: none; + -webkit-appearance: none; +} + +.galaxybrain-search-input::placeholder { + color: var(--gb-text-muted); +} + +.galaxybrain-search-input:focus { + border-color: var(--gb-accent); + box-shadow: 0 0 0 1px var(--gb-accent); +} + +.galaxybrain-radio-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 0; +} + +.galaxybrain-radio-row span { + line-height: 1.35; +} + +.galaxybrain-radio-row input { + margin: 0; + accent-color: var(--gb-accent); +} + +.galaxybrain-action-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.galaxybrain-slider-group { + display: flex; + flex-direction: column; + gap: 14px; +} + +.galaxybrain-subsection { + display: flex; + flex-direction: column; + gap: 12px; + padding: 2px 0 4px; +} + +.galaxybrain-slider-field { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + min-width: 0; +} + +.galaxybrain-slider-label { + line-height: 1.3; +} + +.galaxybrain-slider-wrap { + position: relative; + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 18px 0 0; + overflow: visible; +} + +.galaxybrain-slider-shell { + position: relative; + width: 100%; + height: 18px; + cursor: pointer; +} + +.galaxybrain-slider-rail { + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 4px; + border-radius: 999px; + background: color-mix(in srgb, var(--gb-text-muted) 36%, transparent); + transform: translateY(-50%); +} + +.galaxybrain-slider-thumb { + position: absolute; + top: 50%; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--gb-text); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28); + transform: translateY(-50%); +} + +.galaxybrain-slider-bubble { + position: absolute; + top: -22px; + transform: translateX(-50%); + min-width: 42px; + padding: 6px 8px; + border-radius: 8px; + background: #000; + color: #fff; + font-size: 12px; + line-height: 1; + text-align: center; + pointer-events: none; + z-index: 2; +} + +.galaxybrain-slider-bubble::after { + content: ''; + position: absolute; + left: 50%; + bottom: -5px; + width: 10px; + height: 10px; + background: #000; + transform: translateX(-50%) rotate(45deg); +} + +.galaxybrain-slider-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + line-height: 1.3; +} + +.galaxybrain-slider-value { + color: var(--gb-text-muted); + font-size: 12px; +} + +.galaxybrain-number-input { + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--gb-panel-border); + background: transparent; + color: var(--gb-text); + outline: none; + appearance: none; + -webkit-appearance: none; +} + +.galaxybrain-number-input:focus { + border-color: var(--gb-accent); + box-shadow: 0 0 0 1px var(--gb-accent); +} + +.galaxybrain-chip-button, +.galaxybrain-icon-button { + border: 1px solid var(--gb-panel-border); + background: var(--gb-panel-bg-muted); + color: var(--gb-text); + border-radius: 12px; + cursor: pointer; + font: inherit; + appearance: none; + -webkit-appearance: none; + box-shadow: none; + transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; +} + +.galaxybrain-chip-button:hover, +.galaxybrain-icon-button:hover { + background: var(--gb-panel-bg); + border-color: var(--gb-accent); +} + +.galaxybrain-chip-button { + padding: 8px 12px; +} + +.galaxybrain-icon-button { + width: 20px; + height: 20px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0; +} + +.galaxybrain-accordion__actions .galaxybrain-icon-button { + background: transparent !important; + border: none !important; + box-shadow: none !important; + border-radius: 0 !important; + color: var(--gb-text-muted); +} + +.galaxybrain-accordion__actions .galaxybrain-icon-button:hover, +.galaxybrain-accordion__actions .galaxybrain-icon-button:focus, +.galaxybrain-accordion__actions .galaxybrain-icon-button:focus-visible { + background: var(--gb-panel-bg-muted) !important; + border: none !important; + box-shadow: none !important; + color: var(--gb-text); + outline: none !important; +} + +.galaxybrain-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 9px 0; + border-bottom: 1px solid var(--gb-panel-border); +} + +.galaxybrain-toggle-row:last-child { + border-bottom: none; +} + +.galaxybrain-toggle-row span { + line-height: 1.3; +} + +.galaxybrain-toggle { + position: relative; + display: inline-flex; + width: 28px; + height: 16px; + flex: 0 0 auto; +} + +.galaxybrain-toggle__input { + position: absolute; + inset: 0; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.galaxybrain-toggle__switch { + position: absolute; + inset: 0; + border-radius: 999px; + background: var(--gb-panel-border); + transition: background 120ms ease; +} + +.galaxybrain-toggle__switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--gb-text); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); + transition: transform 120ms ease, background 120ms ease; +} + +.galaxybrain-toggle__input:checked + .galaxybrain-toggle__switch { + background: var(--gb-accent); +} + +.galaxybrain-toggle__input:checked + .galaxybrain-toggle__switch::after { + transform: translateX(12px); + background: var(--gb-accent-text); +} + +.galaxybrain-toggle__input:focus-visible + .galaxybrain-toggle__switch { + outline: 2px solid var(--gb-accent); + outline-offset: 2px; +} + +.galaxybrain-stats { + display: grid; + gap: 10px; + margin: 0; +} + +.galaxybrain-stats div { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.galaxybrain-stats dt { + color: var(--gb-text-muted); +} + +.galaxybrain-stats dd { + margin: 0; + font-weight: 600; +} + +.galaxybrain-placeholder { + display: flex; + flex-direction: column; + gap: 8px; + color: var(--gb-text-muted); + font-size: 13px; + line-height: 1.45; +} diff --git a/packages/obsidian-plugin/tsconfig.json b/packages/obsidian-plugin/tsconfig.json new file mode 100644 index 0000000..ae05691 --- /dev/null +++ b/packages/obsidian-plugin/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "jsxImportSource": "react", + "lib": ["DOM", "ES2022"], + "strict": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node", "obsidian"] + }, + "include": [ + "main.ts", + "src/**/*.ts", + "src/**/*.tsx", + "../../src/components/GraphNodeFactory.ts", + "../../src/components/InteractiveGraph.tsx", + "../../src/lib/graph-core.ts", + "../../src/lib/graph-ui.ts", + "../../src/lib/graph-types.ts", + "../../src/lib/link-resolver.ts", + "../../src/lib/types.ts", + "../../src/lib/vault-parser.ts" + ] +} diff --git a/packages/obsidian-plugin/versions.json b/packages/obsidian-plugin/versions.json new file mode 100644 index 0000000..603e6b7 --- /dev/null +++ b/packages/obsidian-plugin/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.6.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f501f06..c723cc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,49 @@ importers: specifier: ^11.0.5 version: 11.0.5 + packages/obsidian-plugin: + dependencies: + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + lucide-react: + specifier: ^0.575.0 + version: 0.575.0(react@19.2.4) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + react-force-graph-3d: + specifier: ^1.29.1 + version: 1.29.1(react@19.2.4) + three: + specifier: ^0.183.2 + version: 0.183.2 + devDependencies: + '@types/node': + specifier: ^24.9.2 + version: 24.12.0 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@types/three': + specifier: ^0.183.1 + version: 0.183.1 + esbuild: + specifier: ^0.25.3 + version: 0.25.12 + obsidian: + specifier: ^1.8.10 + version: 1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages: 3d-force-graph@1.79.1: @@ -201,6 +244,12 @@ packages: resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} + '@codemirror/state@6.5.0': + resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==} + + '@codemirror/view@6.38.6': + resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==} + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -736,89 +785,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -875,6 +940,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@netlify/ai@0.3.8': resolution: {integrity: sha512-qz8XDb/82UzsUMKn+sB84V3ZGqeNQOvGwNo840nHIV9saJwLPTd+FOqSUoKUIxZphNA7kQ0uGeadSUkJzDz7og==} engines: {node: '>=20.6.1'} @@ -1098,36 +1166,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-wasm@2.5.6': resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} @@ -1207,66 +1281,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1345,6 +1432,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/codemirror@5.60.8': + resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1363,6 +1453,9 @@ packages: '@types/nlcst@2.0.3': resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/node@25.3.3': resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} @@ -1383,6 +1476,9 @@ packages: '@types/stats.js@0.17.4': resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + '@types/tern@0.23.9': + resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==} + '@types/three@0.183.1': resolution: {integrity: sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==} @@ -1885,6 +1981,9 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} @@ -3174,6 +3273,9 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + moment@2.29.4: + resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -3305,6 +3407,12 @@ packages: resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} engines: {node: '>= 0.4'} + obsidian@1.12.3: + resolution: {integrity: sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==} + peerDependencies: + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.38.6 + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -3870,6 +3978,9 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -4023,6 +4134,9 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -4223,6 +4337,9 @@ packages: vite: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -4613,6 +4730,17 @@ snapshots: dependencies: fontkitten: 1.0.2 + '@codemirror/state@6.5.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.38.6': + dependencies: + '@codemirror/state': 6.5.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@colors/colors@1.6.0': {} '@dabh/diagnostics@2.0.8': @@ -5030,6 +5158,8 @@ snapshots: - encoding - supports-color + '@marijn/find-cluster-break@1.0.2': {} + '@netlify/ai@0.3.8': dependencies: '@netlify/api': 14.0.16 @@ -5661,6 +5791,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/codemirror@5.60.8': + dependencies: + '@types/tern': 0.23.9 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -5681,6 +5815,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + '@types/node@25.3.3': dependencies: undici-types: 7.18.2 @@ -5700,6 +5838,10 @@ snapshots: '@types/stats.js@0.17.4': {} + '@types/tern@0.23.9': + dependencies: + '@types/estree': 1.0.8 + '@types/three@0.183.1': dependencies: '@dimforge/rapier3d-compat': 0.12.0 @@ -5718,7 +5860,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.3.3 + '@types/node': 24.12.0 optional: true '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': @@ -6340,6 +6482,8 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 + crelt@1.0.6: {} + cron-parser@4.9.0: dependencies: luxon: 3.7.2 @@ -7973,6 +8117,8 @@ snapshots: module-details-from-path@1.0.4: {} + moment@2.29.4: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -8086,6 +8232,13 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obsidian@1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6): + dependencies: + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.38.6 + '@types/codemirror': 5.60.8 + moment: 2.29.4 + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -8826,6 +8979,8 @@ snapshots: strip-final-newline@3.0.0: {} + style-mod@4.1.3: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -8998,6 +9153,8 @@ snapshots: uncrypto@0.1.3: {} + undici-types@7.16.0: {} + undici-types@7.18.2: optional: true @@ -9142,6 +9299,8 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.2) + w3c-keyname@2.2.8: {} + web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4de91a3..2a7363d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - '.' + - 'packages/*' diff --git a/src/components/FullGraph.tsx b/src/components/FullGraph.tsx index ac10970..5bf2851 100644 --- a/src/components/FullGraph.tsx +++ b/src/components/FullGraph.tsx @@ -1,17 +1,11 @@ -import { useRef, useCallback, useEffect, useState, useMemo } from 'react'; -import { createPortal } from 'react-dom'; -import ForceGraph3D from 'react-force-graph-3d'; -import type { ForceGraphMethods } from 'react-force-graph-3d'; -import * as THREE from 'three'; -import { buildNodeObject } from './GraphNodeFactory'; +import { useEffect, useMemo, useState } from 'react'; +import { createDefaultGraphThemeTokens } from '../lib/graph-ui'; import type { GraphData, GraphNode } from '../lib/types'; import { withPublicBase } from '../lib/public-path'; +import InteractiveGraph from './InteractiveGraph'; -// ─── Constants ──────────────────────────────────────────────────────────────── - -const BG_DARK = '#0a0a0a'; -const BG_LIGHT = '#f5f5f5'; const STORAGE_KEY = 'theme'; +const LABELS_STORAGE_KEY = 'gb-show-all-labels'; /** * Set to false once you've replaced the default template content with your @@ -19,66 +13,117 @@ const STORAGE_KEY = 'theme'; */ const SHOW_BUILD_CTA = true; -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function resolveId(v: unknown): string { - return typeof v === 'object' && v !== null ? (v as GraphNode).id : (v as string); -} - -/** Create a "+" sprite texture canvas, cached per call */ -function makePlusSprite(): THREE.Sprite { - const canvas = document.createElement('canvas'); - canvas.width = 128; canvas.height = 128; - const ctx = canvas.getContext('2d')!; - ctx.clearRect(0, 0, 128, 128); - // Circular background - ctx.fillStyle = 'rgba(255,255,255,0.55)'; - ctx.beginPath(); ctx.arc(64, 64, 54, 0, Math.PI * 2); ctx.fill(); - // Subtle stroke - ctx.strokeStyle = 'rgba(255,255,255,0.9)'; - ctx.lineWidth = 4; - ctx.beginPath(); ctx.arc(64, 64, 54, 0, Math.PI * 2); ctx.stroke(); - // "+" symbol - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 72px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('+', 64, 66); - const tex = new THREE.CanvasTexture(canvas); - const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }); - return new THREE.Sprite(mat); +function BuildCta({ + isDark, + onDismiss, +}: { + isDark: boolean; + onDismiss: () => void; +}) { + return ( +
+ + Need setup instructions? + + + Open README → + + +
+ ); } -// ─── Component ──────────────────────────────────────────────────────────────── - export default function FullGraph() { - const fgRef = useRef(undefined); - - // ── Data loading ─────────────────────────────────────────────────────────── const [graphData, setGraphData] = useState(null); const [loadError, setLoadError] = useState(null); useEffect(() => { fetch(withPublicBase('/graph.json')) - .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise; }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; + }) .then(setGraphData) - .catch((err: unknown) => setLoadError(String(err))); + .catch((error: unknown) => setLoadError(String(error))); }, []); - // ── Dark / light mode ────────────────────────────────────────────────────── const [isDark, setIsDark] = useState(() => { - try { return (localStorage.getItem(STORAGE_KEY) ?? 'dark') !== 'light'; } - catch { return true; } + try { + return (localStorage.getItem(STORAGE_KEY) ?? 'dark') !== 'light'; + } catch { + return true; + } }); - // Listen for theme-change dispatched by the global BaseLayout toggle + useEffect(() => { - const onThemeChange = (e: Event) => { - const detail = (e as CustomEvent<{ theme: string }>).detail; + const onThemeChange = (event: Event) => { + const detail = (event as CustomEvent<{ theme: string }>).detail; setIsDark(detail.theme !== 'light'); }; - const onStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEY) setIsDark((e.newValue ?? 'dark') !== 'light'); + const onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) { + setIsDark((event.newValue ?? 'dark') !== 'light'); + } }; + window.addEventListener('theme-change', onThemeChange); window.addEventListener('storage', onStorage); return () => { @@ -87,747 +132,45 @@ export default function FullGraph() { }; }, []); - const bgColor = isDark ? BG_DARK : BG_LIGHT; - const uiTextColor = isDark ? '#e0e0e0' : '#111111'; - const uiBgColor = isDark ? 'rgba(20,20,20,0.9)' : 'rgba(240,240,240,0.9)'; - const uiBorder = isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'; - - // Keep a ref so nodeThreeObject can read current theme without being - // recreated on every toggle (avoids full node-object rebuild on theme change). - const isDarkRef = useRef(isDark); - isDarkRef.current = isDark; - // When theme changes, ask the graph to refresh its node visuals once. - useEffect(() => { - (fgRef.current as { refresh?: () => void } | undefined)?.refresh?.(); - }, [isDark]); - - // ── Collapse state ───────────────────────────────────────────────────────── - const [collapsedNodes, setCollapsedNodes] = useState | null>(null); - useEffect(() => { - if (!graphData) return; - const s = new Set(); - graphData.nodes.forEach((n) => { if (n.collapsible) s.add(n.id); }); - setCollapsedNodes(s); - }, [graphData]); - // ── Build CTA (shown once after the first collapsible node is expanded) ────── - const [showBuildCta, setShowBuildCta] = useState(false); - const hasShownCtaRef = useRef(false); - // ── Tag highlight ────────────────────────────────────────────────────────── - const [highlightedTag, setHighlightedTag] = useState(() => { - try { return new URLSearchParams(window.location.search).get('highlight'); } - catch { return null; } - }); - - /** - * Set of node IDs directly connected to the currently highlighted tag - * (includes the tag itself). Used for visual dim / brighten logic. - */ - const highlightedNodeIds = useMemo>(() => { - if (!highlightedTag || !graphData) return new Set(); - const s = new Set(); - s.add(highlightedTag); - graphData.links.forEach((l) => { - const src = resolveId(l.source); - const tgt = resolveId(l.target); - if (src === highlightedTag) s.add(tgt); - if (tgt === highlightedTag) s.add(src); - }); - return s; - }, [highlightedTag, graphData]); - - // ── Pulsing PointLights for highlighted tag node ─────────────────────────── - const pulsingLightsRef = useRef>(new Map()); - - useEffect(() => { - if (highlightedTag === null) { - pulsingLightsRef.current.clear(); - return; + const [showAllLabels, setShowAllLabels] = useState(() => { + try { + return localStorage.getItem(LABELS_STORAGE_KEY) === 'true'; + } catch { + return false; } - let rafId: number; - const animate = () => { - const t = Date.now() / 1000; - pulsingLightsRef.current.forEach((light) => { - light.intensity = 3 + 2 * Math.sin(t * 3); - }); - rafId = requestAnimationFrame(animate); - }; - rafId = requestAnimationFrame(animate); - return () => cancelAnimationFrame(rafId); - }, [highlightedTag]); - - // ── Ghost-click notification (brief "Not yet created" overlay) ──────────── - const [ghostTooltip, setGhostTooltip] = useState<{ x: number; y: number } | null>(null); - - // ── ?focus=noteId query param — auto-focus camera on mount ───────────────── - const focusNodeId = useMemo(() => { - try { return new URLSearchParams(window.location.search).get('focus'); } - catch { return null; } - }, []); - - useEffect(() => { - if (!focusNodeId || !graphData || !fgRef.current) return; - // Give the force simulation a few seconds to partially settle before flying - const timer = setTimeout(() => { - if (!fgRef.current) return; - const found = graphData.nodes.find((n) => n.id === focusNodeId) as - | (GraphNode & { x?: number; y?: number; z?: number }) - | undefined; - if (!found || found.x == null) return; - const dist = 80; - const mag = Math.hypot(found.x, found.y ?? 0, found.z ?? 0) || 1; - const ratio = 1 + dist / mag; - fgRef.current.cameraPosition( - { x: found.x * ratio, y: (found.y ?? 0) * ratio, z: (found.z ?? 0) * ratio }, - { x: found.x, y: found.y ?? 0, z: found.z ?? 0 }, - 1500, - ); - }, 4000); - return () => clearTimeout(timer); - }, [focusNodeId, graphData]); - - // ── "Start here" callout (shown once to new visitors) ───────────────────── - // Points to the note with `graph.callout: true` in its frontmatter. - // The callout text comes from `graph.calloutText`. - - const calloutTarget = useMemo(() => { - if (!graphData) return null; - return (graphData.nodes.find((n) => n.callout) ?? null) as - (GraphNode & { x?: number; y?: number; z?: number }) | null; - }, [graphData]); - - const calloutLabel = calloutTarget?.calloutText || 'Click to get started'; - - const [showCallout, setShowCallout] = useState(true); - const [calloutPos, setCalloutPos] = useState<{ x: number; y: number } | null>(null); - // Keep a ref so the RAF loop always reads the current node position object - const calloutTargetRef = useRef(calloutTarget); - calloutTargetRef.current = calloutTarget; - // Use a ref so handleNodeClick can read current value without re-creating the callback - const showCalloutRef = useRef(showCallout); - showCalloutRef.current = showCallout; - - const dismissCallout = useCallback(() => { - setShowCallout(false); - setCalloutPos(null); - }, []); - - // (calloutTarget is derived from graphData via useMemo — no separate tracking effect needed) - - // Inject CSS keyframes once - useEffect(() => { - if (!showCallout) return; - const style = document.createElement('style'); - style.id = 'gb-callout-styles'; - style.textContent = ` - @keyframes gb-pulse { - 0% { transform: translate(-50%,-50%) scale(1); opacity: 0.9; } - 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } - } - @keyframes gb-float { - 0%, 100% { transform: translate(-50%,-100%) translateY(0px); } - 50% { transform: translate(-50%,-100%) translateY(-7px); } - } - @keyframes gb-fadein { from { opacity: 0; } to { opacity: 1; } } - `; - document.head.appendChild(style); - return () => { document.getElementById('gb-callout-styles')?.remove(); }; - }, [showCallout]); - - // RAF loop: project the node's 3D world position to 2D screen coordinates - useEffect(() => { - if (!showCallout) return; - let rafId: number; - - const project = () => { - const node = calloutTargetRef.current; - const fg = fgRef.current as ForceGraphMethods & { - camera?: () => THREE.Camera; - renderer?: () => THREE.WebGLRenderer; - }; - if (fg && node && node.x != null) { - const camera = fg.camera?.(); - const renderer = fg.renderer?.(); - if (camera && renderer) { - const size = new THREE.Vector2(); - renderer.getSize(size); - const vec = new THREE.Vector3(node.x, node.y ?? 0, node.z ?? 0); - vec.project(camera); - const sx = (vec.x * 0.5 + 0.5) * size.x; - const sy = (-vec.y * 0.5 + 0.5) * size.y; - if (sx > 0 && sy > 0 && sx < size.x && sy < size.y) { - setCalloutPos({ x: sx, y: sy }); - } - } - } - rafId = requestAnimationFrame(project); - }; - - // Wait for the force simulation to partially settle before tracking - const startTimer = setTimeout(() => { rafId = requestAnimationFrame(project); }, 2500); - // Auto-dismiss after 30 s so return visitors aren't stuck with it - const dismissTimer = setTimeout(() => dismissCallout(), 30000); - - return () => { - clearTimeout(startTimer); - clearTimeout(dismissTimer); - cancelAnimationFrame(rafId); - }; - }, [showCallout, dismissCallout]); - - // ── Dimensions ──────────────────────────────────────────────────────────── - const [dimensions, setDimensions] = useState({ width: window.innerWidth, height: window.innerHeight }); - useEffect(() => { - const onResize = () => setDimensions({ width: window.innerWidth, height: window.innerHeight }); - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); - }, []); - - // ── Cursor tooltip + preview panel (both updated imperatively) ───────────── - const tooltipRef = useRef(null); - const previewRef = useRef(null); + }); - // Use a native document listener — the Three.js canvas consumes pointer events - // and prevents the React synthetic onMouseMove from firing on the wrapper. useEffect(() => { - const onMove = (e: MouseEvent) => { - const el = tooltipRef.current; - if (!el || el.style.display === 'none') return; - el.style.left = `${e.clientX + 14}px`; - el.style.top = `${e.clientY + 14}px`; - }; - document.addEventListener('mousemove', onMove); - return () => document.removeEventListener('mousemove', onMove); - }, []); - - // ── Visible data (stable reference → force sim never restarts spuriously) ── - const visibleData = useMemo((): GraphData => { - if (!graphData) return { nodes: [], links: [] }; - - // Compute collapsed nodes: use state if available, otherwise compute on-demand - const collapsed = collapsedNodes ?? new Set( - graphData.nodes.filter((n) => n.collapsible).map((n) => n.id) - ); - - if (collapsed.size === 0) return graphData; - - // Determine which nodes to hide when a collapsible node is collapsed. - // - // Strategy: forward BFS from "absolute root" nodes — nodes that have ZERO - // incoming wikilinks from ANYWHERE, plus explicitly `pinned: true` nodes. - // - // Key rules: - // • A collapsed node reached by BFS is added to `visible` but NOT queued — - // it shows as a collapsed badge, hiding everything downstream. - // • A collapsed node that is NEVER reached by BFS stays hidden entirely, - // which fixes the "hub shows up disconnected then disappears on expand" bug. - // • `pinned` nodes are always seeded as roots regardless of incoming links. - const wikilinks = graphData.links.filter((l) => l.type === 'wikilink'); - const fileTags = graphData.links.filter((l) => l.type === 'file-tag'); - - // Build a per-node set of ALL nodes that link TO it (collapsed included). - const incomingAll = new Map>(); - graphData.nodes.forEach((n) => incomingAll.set(n.id, new Set())); - wikilinks.forEach((l) => { - const src = resolveId(l.source); - const tgt = resolveId(l.target); - incomingAll.get(tgt)?.add(src); - }); - - // Helper: enqueue a node as visible, but only traverse it if it's not collapsed. - const visible = new Set(); - const queue: string[] = []; - const addVisible = (id: string) => { - if (visible.has(id)) return; - visible.add(id); - if (!collapsed.has(id)) queue.push(id); - // collapsed nodes: visible (shown as badge) but not traversed → children hidden - }; - - // Seeds: zero-incoming nodes (true graph roots) + explicitly pinned notes. - // Tag nodes are excluded (they follow their notes, handled below). - graphData.nodes.forEach((n) => { - if (n.type === 'tag') return; - const isPinned = n.pinned === true; - const isRoot = (incomingAll.get(n.id)?.size ?? 0) === 0; - if (isPinned || isRoot) addVisible(n.id); - }); - - // BFS: propagate visibility forward through wikilinks. - // Collapsed targets are marked visible-but-not-traversed by addVisible(). - while (queue.length > 0) { - const cur = queue.shift()!; - wikilinks.forEach((l) => { - const src = resolveId(l.source); - const tgt = resolveId(l.target); - if (src === cur) addVisible(tgt); - }); + try { + localStorage.setItem(LABELS_STORAGE_KEY, String(showAllLabels)); + } catch { + // Ignore storage failures in restricted contexts. } + }, [showAllLabels]); - // Tag nodes become visible only when at least one of their tagged notes is visible. - fileTags.forEach((l) => { - const src = resolveId(l.source); - const tgt = resolveId(l.target); - if (visible.has(src)) visible.add(tgt); - }); - - return { - nodes: graphData.nodes.filter((n) => visible.has(n.id)), - links: graphData.links.filter((l) => { - const src = resolveId(l.source); - const tgt = resolveId(l.target); - return visible.has(src) && visible.has(tgt); - }), - }; - }, [graphData, collapsedNodes]); - - // ── Node THREE.js objects ───────────────────────────────────────────────── - const nodeThreeObject = useCallback((rawNode: object) => { - const node = rawNode as GraphNode; - - const isHighlightedTag = node.id === highlightedTag; - const isConnected = highlightedTag !== null && highlightedNodeIds.has(node.id); - const isDimmed = highlightedTag !== null && !isConnected && !isHighlightedTag; - const isCollapsed = node.collapsible && (collapsedNodes?.has(node.id) ?? false); - - const group = new THREE.Group(); - - // ── base mesh ──────────────────────────────────────────────────────────── - const mesh = buildNodeObject(node.type, node.shape, node.color, node.val, !isDarkRef.current); - - // Scale up highlighted / connected nodes - if (isHighlightedTag || isConnected) { - mesh.scale.multiplyScalar(1.35); - } - - // Dim non-highlighted nodes when a tag is active - if (isDimmed) { - mesh.traverse((child) => { - if ((child as THREE.Mesh).isMesh) { - const mat = (child as THREE.Mesh).material as THREE.MeshLambertMaterial; - if (mat) { mat.transparent = true; mat.opacity = 0.15; } - } - }); - } - - // Emissive glow on the highlighted tag itself - if (isHighlightedTag) { - mesh.traverse((child) => { - if ((child as THREE.Mesh).isMesh) { - const mat = (child as THREE.Mesh).material as THREE.MeshLambertMaterial; - if (mat) { - mat.emissive = new THREE.Color(node.color); - mat.emissiveIntensity = 0.7; - } - } - }); - // PointLight that will be pulsed by the RAF loop - const light = new THREE.PointLight(node.color, 4, 60); - pulsingLightsRef.current.set(node.id, light); - group.add(light); - } else { - pulsingLightsRef.current.delete(node.id); - } - - group.add(mesh); - - // ── "+" sprite overlay for collapsed nodes ─────────────────────────────── - if (isCollapsed) { - const sprite = makePlusSprite(); - const spriteScale = Math.cbrt(node.val) * 0.8 * 2.8; - sprite.scale.set(spriteScale, spriteScale, 1); - // Float above-right of the mesh - sprite.position.set(spriteScale * 0.38, spriteScale * 0.38, 0); - group.add(sprite); - } - - return group; - }, [highlightedTag, highlightedNodeIds, collapsedNodes]); // isDark via isDarkRef — stable callback, refresh() on theme change - - // ── Hover ────────────────────────────────────────────────────────────────── - const handleNodeHover = useCallback((rawNode: object | null) => { - const el = tooltipRef.current; - const preview = previewRef.current; - - if (!rawNode) { - if (el) el.style.display = 'none'; - if (preview) { preview.style.opacity = '0'; preview.style.transform = 'translateY(6px)'; } - return; - } - - const node = rawNode as GraphNode; - - // ── Name label at cursor ────────────────────────────────────────────────────── - if (el) { - el.textContent = node.type === 'tag' ? `#${node.name}` : node.name; - el.style.background = uiBgColor; - el.style.color = uiTextColor; - el.style.border = `1px solid ${uiBorder}`; - el.style.display = 'block'; - } - - // ── Rich preview panel at bottom-left edge ──────────────────────────────── - if (preview) { - const typeLabel = node.type === 'tag' ? 'tag' : node.type === 'ghost' ? 'unlinked note' : 'note'; - const displayName = node.type === 'tag' ? `#${node.name}` : node.name; - - let excerptHtml = ''; - if (node.type === 'ghost') { - excerptHtml = `This note hasn't been created yet.`; - } else if (node.excerpt) { - excerptHtml = node.excerpt; - } - - let hint = ''; - if (node.collapsible && collapsedNodes?.has(node.id)) hint = 'Click to expand'; - if (node.collapsible && collapsedNodes && !collapsedNodes.has(node.id)) hint = 'Shift+click to collapse'; - - preview.style.background = uiBgColor; - preview.style.color = uiTextColor; - preview.style.borderColor = uiBorder; - preview.innerHTML = ` -
${typeLabel}
-
${displayName}
- ${excerptHtml ? `
${excerptHtml}
` : ''} - ${hint ? `
${hint}
` : ''} - `; - preview.style.opacity = '1'; - preview.style.transform = 'translateY(0)'; - } - }, [collapsedNodes, uiBgColor, uiTextColor, uiBorder]); - - // ── Click ────────────────────────────────────────────────────────────────── - const handleNodeClick = useCallback((rawNode: object, event: MouseEvent) => { - const node = rawNode as GraphNode; - - // Ghost node — show brief "not yet created" tooltip at click position - if (node.type === 'ghost') { - setGhostTooltip({ x: event.clientX, y: event.clientY }); - setTimeout(() => setGhostTooltip(null), 2200); - return; - } - - // Tag node — toggle tag highlight - if (node.type === 'tag') { - setHighlightedTag((p) => (p === node.id ? null : node.id)); - return; - } - - // Shift+click on an expanded collapsible node → re-collapse - if (event.shiftKey && node.collapsible && collapsedNodes && !collapsedNodes.has(node.id)) { - setCollapsedNodes((p) => { const s = new Set(p ?? []); s.add(node.id); return s; }); - return; - } - - // Click on a collapsed node → expand - if (node.collapsible && collapsedNodes?.has(node.id)) { - setCollapsedNodes((p) => { const s = new Set(p ?? []); s.delete(node.id); return s; }); - if (SHOW_BUILD_CTA && !hasShownCtaRef.current) { - hasShownCtaRef.current = true; - setTimeout(() => setShowBuildCta(true), 600); // slight delay so the graph expansion animates first - } - return; - } - - // Dismiss if the callout target node is clicked - if (showCalloutRef.current && node.callout) dismissCallout(); - - // File node with a path → navigate - if (node.path) window.location.href = node.path; - }, [collapsedNodes, dismissCallout]); // eslint-disable-line react-hooks/exhaustive-deps - - // ── Right-click: fly camera ──────────────────────────────────────────────── - const handleNodeRightClick = useCallback((rawNode: object, event: MouseEvent) => { - event.preventDefault(); - const node = rawNode as GraphNode & { x?: number; y?: number; z?: number }; - if (!fgRef.current || node.x == null) return; - const dist = 80; - const mag = Math.hypot(node.x, node.y ?? 0, node.z ?? 0) || 1; - const ratio = 1 + dist / mag; - fgRef.current.cameraPosition( - { x: node.x * ratio, y: (node.y ?? 0) * ratio, z: (node.z ?? 0) * ratio }, - { x: node.x, y: node.y ?? 0, z: node.z ?? 0 }, - 1500, - ); - }, []); - - // ── Link color (respects tag-highlight state) ───────────────────────────── - const linkColor = useCallback((rawLink: object) => { - const l = rawLink as { type: string; source: unknown; target: unknown }; - - if (highlightedTag !== null) { - const src = resolveId(l.source); - const tgt = resolveId(l.target); - const connected = highlightedNodeIds.has(src) && highlightedNodeIds.has(tgt); - if (connected) { - if (l.type === 'file-tag') return '#e74c3ccc'; - if (l.type === 'tag-hierarchy') return '#e67e22cc'; - return isDark ? '#ffffffcc' : '#000000cc'; - } - // Dimmed links - if (l.type === 'file-tag') return '#e74c3c0a'; - if (l.type === 'tag-hierarchy') return '#e67e220a'; - return isDark ? '#ffffff0a' : '#0000000a'; - } - - if (l.type === 'file-tag') return '#e74c3c44'; - if (l.type === 'tag-hierarchy') return '#e67e2244'; - return isDark ? '#ffffff22' : '#00000022'; - }, [isDark, highlightedTag, highlightedNodeIds]); - - // ── Loading / error ──────────────────────────────────────────────────────── - if (loadError) { - return ( -
- - Could not load graph - {loadError} -
- ); - } - - if (!graphData) { - return
; - } + const calloutTarget = useMemo( + () => graphData?.nodes.find((node) => node.callout) ?? null, + [graphData], + ); + const themeTokens = useMemo( + () => createDefaultGraphThemeTokens(isDark ? 'dark' : 'light'), + [isDark], + ); - // ── Render ───────────────────────────────────────────────────────────────── return ( -
- - - {/* Dark / light toggle has moved to BaseLayout */} - - {/* Cursor name label */} -
- - {/* Node preview panel — bottom-left edge, slides in on hover */} -
- - {/* Ghost-click "not yet created" toast */} - {ghostTooltip && ( -
- Note not yet created -
- )} - - {/* Tag filter banner — rendered via portal to escape ForceGraph3D canvas event interception */} - {highlightedTag !== null && createPortal( -
- Filtering by #{graphData.nodes.find((n) => n.id === highlightedTag)?.name ?? highlightedTag} - -
, - document.body, - )} - - {/* Start-here callout — rendered via portal, tracks the target node in world space */} - {showCallout && calloutPos && calloutTarget && createPortal( - <> - {/* Pulsing rings centered on the node */} - {[0, 0.85].map((delay) => ( -
- ))} - - {/* Dashed connector line + arrowhead */} - - - - - - - - - - {/* Label bubble */} -
{ e.stopPropagation(); dismissCallout(); }} - style={{ - position: 'fixed', - left: calloutPos.x - 110, - top: calloutPos.y - 75, - transform: 'translate(-50%, -100%)', - animation: 'gb-float 2.4s ease-in-out infinite, gb-fadein 0.6s ease', - zIndex: 99999, - pointerEvents: 'all', - cursor: 'pointer', - background: isDark ? 'rgba(10,30,60,0.88)' : 'rgba(220,235,255,0.94)', - border: `1px solid ${calloutTarget.color ?? '#3498db'}99`, - borderRadius: 12, - padding: '10px 16px', - color: isDark ? '#74b9ff' : '#1a5fa8', - fontSize: 13, - fontFamily: 'sans-serif', - textAlign: 'center', - backdropFilter: 'blur(6px)', - userSelect: 'none', - whiteSpace: 'nowrap', - boxShadow: `0 4px 20px ${calloutTarget.color ?? '#3498db'}33`, - }} - title="Click to dismiss" - > -
{calloutLabel}
-
- , - document.body, - )} - - {/* Hint bar */} -
- Click file → navigate  |  Shift+click → collapse  |  Click tag → filter  |  Right-click → focus  |  Drag to rotate -
- - {/* Build-your-own CTA — slides up after first expansion */} - {showBuildCta && createPortal( -
- - Need setup instructions? - - - Open README → - - -
, - document.body, - )} -
+ : null} + calloutNodeId={calloutTarget?.id ?? null} + calloutLabel={calloutTarget?.calloutText} + showAllLabels={showAllLabels} + onShowAllLabelsChange={setShowAllLabels} + showInlineLabelToggle={true} + onOpenNode={(node: GraphNode) => { + if (node.path) window.location.href = node.path; + }} + /> ); } diff --git a/src/components/GraphNodeFactory.ts b/src/components/GraphNodeFactory.ts index 278bbf2..132111c 100644 --- a/src/components/GraphNodeFactory.ts +++ b/src/components/GraphNodeFactory.ts @@ -117,7 +117,13 @@ export function buildNodeObject( const effectiveColor = resolveColor(color, isLight); const geo = buildGeometry(type === 'tag' ? 'octahedron' : shape); - const mat = new THREE.MeshLambertMaterial({ color: effectiveColor }); + const mat = new THREE.MeshPhongMaterial({ + color: effectiveColor, + emissive: new THREE.Color(effectiveColor).multiplyScalar(isLight ? 0.12 : 0.22), + emissiveIntensity: 1, + shininess: 22, + specular: new THREE.Color(isLight ? '#ffffff' : '#cfd8ff').multiplyScalar(isLight ? 0.18 : 0.24), + }); const mesh = new THREE.Mesh(geo, mat); mesh.scale.setScalar(scale); return mesh; diff --git a/src/components/InteractiveGraph.tsx b/src/components/InteractiveGraph.tsx new file mode 100644 index 0000000..b1b9a62 --- /dev/null +++ b/src/components/InteractiveGraph.tsx @@ -0,0 +1,2436 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import ForceGraph3D from 'react-force-graph-3d'; +import type { ForceGraphMethods } from 'react-force-graph-3d'; +import * as THREE from 'three'; +import type { GraphAnimationState, GraphParticleStyle, GraphThemeTokens } from '../lib/graph-ui'; +import type { GraphData, GraphNode } from '../lib/types'; +import { buildNodeObject } from './GraphNodeFactory'; + +export interface InteractiveGraphHandle { + fitToGraph: () => void; + resetCamera: () => void; + startCameraTour: () => void; + startOrbitSettleAnimation: () => void; + stopAnimation: () => void; + isAnimationRunning: () => boolean; +} + +const HINT_BAR_STORAGE_KEY = 'gb-hint-bar-expanded'; + +interface InteractiveGraphProps { + graphData: GraphData | null; + themeTokens: GraphThemeTokens; + onOpenNode?: (node: GraphNode) => void; + nodePrimaryAction?: 'open' | 'focus'; + showNodeContextMenu?: boolean; + loadError?: string | null; + emptyMessage?: string; + hintText?: string; + buildCta?: ((dismiss: () => void) => ReactNode) | null; + calloutNodeId?: string | null; + calloutLabel?: string; + ignoreCollapsible?: boolean; + showAllLabels?: boolean; + impactGlow?: boolean; + particleSpeed?: number; + particleRandomness?: number; + particleStyle?: GraphParticleStyle; + particleTrailLength?: number; + onShowAllLabelsChange?: (value: boolean) => void; + showInlineLabelToggle?: boolean; + showHintBar?: boolean; + onAnimationStateChange?: (state: GraphAnimationState) => void; +} + +interface SavedCameraState { + position: { x: number; y: number; z: number }; + target: { x: number; y: number; z: number }; +} + +interface GraphControls { + target?: THREE.Vector3; + enabled?: boolean; + domElement?: EventTarget | null; + saveState?: () => void; + update?: () => void; +} + +interface PositionedGraphNode extends GraphNode { + x?: number; + y?: number; + z?: number; +} + +interface TourCandidate { + id: string; + position: THREE.Vector3; + score: number; +} + +interface NodeRuntimeVisual { + glowSprite: THREE.Sprite; + baseScale: number; +} + +interface ImpactPulseState { + startedAt: number; + until: number; +} + +interface TourSegment { + approachCurve: [THREE.Vector3, THREE.Vector3, THREE.Vector3, THREE.Vector3]; + exitCurve: [THREE.Vector3, THREE.Vector3, THREE.Vector3, THREE.Vector3]; + targetId: string; + nextId: string | null; + targetPosition: THREE.Vector3; + nextPosition: THREE.Vector3; + orbitForward: THREE.Vector3; + orbitRight: THREE.Vector3; + orbitUp: THREE.Vector3; + orbitDistance: number; + orbitRadius: number; + orbitVerticalRadius: number; + orbitAngleStart: number; + orbitAngleSweep: number; + lookSideBias: number; + lookLiftBias: number; + driftAmplitude: number; + driftPhase: number; + driftFreqA: number; + driftFreqB: number; + approachDurationMs: number; + orbitDurationMs: number; + exitDurationMs: number; + durationMs: number; + previewLeadT: number; +} + +function resolveId(v: unknown): string { + return typeof v === 'object' && v !== null ? (v as GraphNode).id : (v as string); +} + +function formatNodeName(node: GraphNode): string { + if (node.type !== 'tag') return node.name; + return node.name.startsWith('#') ? node.name : `#${node.name}`; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function lerpVec(a: THREE.Vector3, b: THREE.Vector3, t: number): THREE.Vector3 { + return new THREE.Vector3( + lerp(a.x, b.x, t), + lerp(a.y, b.y, t), + lerp(a.z, b.z, t), + ); +} + +function cubicBezierPoint( + p0: THREE.Vector3, + p1: THREE.Vector3, + p2: THREE.Vector3, + p3: THREE.Vector3, + t: number, +): THREE.Vector3 { + const u = 1 - t; + const tt = t * t; + const uu = u * u; + const uuu = uu * u; + const ttt = tt * t; + + return new THREE.Vector3( + uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x, + uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y, + uuu * p0.z + 3 * uu * t * p1.z + 3 * u * tt * p2.z + ttt * p3.z, + ); +} + +function cubicBezierTangent( + p0: THREE.Vector3, + p1: THREE.Vector3, + p2: THREE.Vector3, + p3: THREE.Vector3, + t: number, +): THREE.Vector3 { + const u = 1 - t; + + return new THREE.Vector3( + 3 * u * u * (p1.x - p0.x) + 6 * u * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x), + 3 * u * u * (p1.y - p0.y) + 6 * u * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y), + 3 * u * u * (p1.z - p0.z) + 6 * u * t * (p2.z - p1.z) + 3 * t * t * (p3.z - p2.z), + ); +} + +function smoothstep(t: number): number { + const x = clamp(t, 0, 1); + return x * x * (3 - 2 * x); +} + +function springVectorToward( + current: THREE.Vector3, + velocity: THREE.Vector3, + target: THREE.Vector3, + stiffness: number, + damping: number, + dt: number, +): void { + const toTarget = target.clone().sub(current); + velocity.add(toTarget.multiplyScalar(stiffness * dt)); + velocity.multiplyScalar(Math.exp(-damping * dt)); + current.add(velocity.clone().multiplyScalar(dt)); +} + +function pickWeightedIndex(weights: number[]): number { + const total = weights.reduce((sum, weight) => sum + Math.max(0, weight), 0); + if (total <= 0) { + let maxIndex = 0; + let maxWeight = Number.NEGATIVE_INFINITY; + weights.forEach((weight, index) => { + if (weight > maxWeight) { + maxWeight = weight; + maxIndex = index; + } + }); + return maxIndex; + } + + let cursor = Math.random() * total; + for (let index = 0; index < weights.length; index += 1) { + cursor -= Math.max(0, weights[index]); + if (cursor <= 0) return index; + } + + return weights.length - 1; +} + +function buildBasis(forward: THREE.Vector3, worldUp: THREE.Vector3): { + forward: THREE.Vector3; + right: THREE.Vector3; + up: THREE.Vector3; +} { + const safeForward = + forward.lengthSq() > 1e-6 ? forward.clone().normalize() : new THREE.Vector3(0, 0, -1); + let right = new THREE.Vector3().crossVectors(safeForward, worldUp); + if (right.lengthSq() < 1e-6) { + right = new THREE.Vector3().crossVectors(safeForward, new THREE.Vector3(1, 0, 0)); + } + right.normalize(); + const up = new THREE.Vector3().crossVectors(right, safeForward).normalize(); + return { + forward: safeForward, + right, + up, + }; +} + +function hashToUnit(input: string, seed = 0): number { + let hash = 2166136261 ^ seed; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return ((hash >>> 0) % 10000) / 10000; +} + +function easeOutCubic(t: number): number { + const x = clamp(t, 0, 1); + return 1 - Math.pow(1 - x, 3); +} + +function makePlusSprite(): THREE.Sprite { + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 128; + + const ctx = canvas.getContext('2d'); + if (!ctx) return new THREE.Sprite(new THREE.SpriteMaterial()); + + ctx.clearRect(0, 0, 128, 128); + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.beginPath(); + ctx.arc(64, 64, 54, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = 'rgba(255,255,255,0.9)'; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.arc(64, 64, 54, 0, Math.PI * 2); + ctx.stroke(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 72px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('+', 64, 66); + + const tex = new THREE.CanvasTexture(canvas); + const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }); + return new THREE.Sprite(mat); +} + +let glowSpriteTexture: THREE.CanvasTexture | null = null; + +function getGlowSpriteTexture(): THREE.CanvasTexture { + if (glowSpriteTexture) return glowSpriteTexture; + + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 128; + const ctx = canvas.getContext('2d'); + if (!ctx) { + glowSpriteTexture = new THREE.CanvasTexture(canvas); + return glowSpriteTexture; + } + + const gradient = ctx.createRadialGradient(64, 64, 8, 64, 64, 56); + gradient.addColorStop(0, 'rgba(255,255,255,0.95)'); + gradient.addColorStop(0.35, 'rgba(255,255,255,0.45)'); + gradient.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 128, 128); + + glowSpriteTexture = new THREE.CanvasTexture(canvas); + glowSpriteTexture.minFilter = THREE.LinearFilter; + glowSpriteTexture.magFilter = THREE.LinearFilter; + return glowSpriteTexture; +} + +function makeLabelSprite( + text: string, + options: { + opacity?: number; + textColor: string; + outlineColor: string; + fontFamily: string; + }, +): THREE.Sprite { + const fontSize = 26; + const paddingX = 14; + const paddingY = 8; + const pixelRatio = 2; + + const measureCanvas = document.createElement('canvas'); + const measureCtx = measureCanvas.getContext('2d'); + if (!measureCtx) return new THREE.Sprite(new THREE.SpriteMaterial()); + + measureCtx.font = `600 ${fontSize}px ${options.fontFamily}`; + const textWidth = Math.ceil(measureCtx.measureText(text).width); + const width = textWidth + paddingX * 2; + const height = fontSize + paddingY * 2; + + const canvas = document.createElement('canvas'); + canvas.width = width * pixelRatio; + canvas.height = height * pixelRatio; + + const ctx = canvas.getContext('2d'); + if (!ctx) return new THREE.Sprite(new THREE.SpriteMaterial()); + + ctx.scale(pixelRatio, pixelRatio); + ctx.font = `600 ${fontSize}px ${options.fontFamily}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = options.textColor; + ctx.strokeStyle = options.outlineColor; + ctx.lineWidth = 6; + ctx.lineJoin = 'round'; + ctx.strokeText(text, width / 2, height / 2 + 1); + ctx.fillText(text, width / 2, height / 2 + 1); + + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.LinearFilter; + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, + depthWrite: false, + opacity: options.opacity ?? 0.92, + }); + const sprite = new THREE.Sprite(material); + const worldHeight = 7; + const aspect = width / height; + sprite.scale.set(worldHeight * aspect, worldHeight, 1); + return sprite; +} + +function createFallbackTagNode(id: string, accent: string): GraphNode { + return { + id, + name: id, + type: 'tag', + path: null, + val: 1, + shape: 'octahedron', + color: accent, + collapsible: false, + pinned: false, + callout: false, + calloutText: '', + excerpt: null, + }; +} + +const InteractiveGraph = forwardRef(function InteractiveGraph( + { + graphData, + themeTokens, + onOpenNode, + nodePrimaryAction = 'open', + showNodeContextMenu = false, + loadError = null, + emptyMessage = 'No graph data available.', + hintText = 'Click note → open | Shift+click → collapse | Click tag → filter | Right-click → focus | Drag to rotate', + buildCta = null, + calloutNodeId = null, + calloutLabel, + ignoreCollapsible = false, + showAllLabels = false, + impactGlow = true, + particleSpeed: particleSpeedValue = 0.35, + particleRandomness = 0, + particleStyle = 'dot', + particleTrailLength = 0.55, + onShowAllLabelsChange, + showInlineLabelToggle = false, + showHintBar = true, + onAnimationStateChange, + }, + ref, +) { + const fgRef = useRef(undefined); + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const previewRef = useRef(null); + const fitTimerRef = useRef(null); + const controlsReleaseTimerRef = useRef(null); + const savedCameraRef = useRef(null); + const hasShownCtaRef = useRef(false); + const pulsingLightsRef = useRef>(new Map()); + const nodeVisualsRef = useRef>(new Map()); + const impactPulsesRef = useRef>(new Map()); + const linkMaterialCacheRef = useRef>(new Map()); + const sceneLightRigRef = useRef([]); + const calloutTargetRef = useRef(null); + const animationFrameRef = useRef(null); + const animationTimeoutsRef = useRef([]); + const animationRunIdRef = useRef(0); + const animationStateRef = useRef({ mode: 'none', running: false }); + const tourPreviewLockUntilRef = useRef(0); + const isDark = themeTokens.mode === 'dark'; + + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + const [collapsedNodes, setCollapsedNodes] = useState | null>(null); + const [highlightedTag, setHighlightedTag] = useState(null); + const [ghostTooltip, setGhostTooltip] = useState<{ x: number; y: number } | null>(null); + const [nodeContextMenu, setNodeContextMenu] = useState<{ node: GraphNode; x: number; y: number } | null>(null); + const [showBuildCta, setShowBuildCta] = useState(false); + const [showCallout, setShowCallout] = useState(false); + const [calloutPos, setCalloutPos] = useState<{ x: number; y: number } | null>(null); + const [isHintBarExpanded, setIsHintBarExpanded] = useState(() => { + try { + const stored = localStorage.getItem(HINT_BAR_STORAGE_KEY); + return stored !== 'false'; + } catch { + return true; + } + }); + + const emitAnimationState = useCallback((state: GraphAnimationState) => { + animationStateRef.current = state; + onAnimationStateChange?.(state); + }, [onAnimationStateChange]); + + const clearAnimationTimers = useCallback(() => { + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + animationTimeoutsRef.current.forEach((timer) => window.clearTimeout(timer)); + animationTimeoutsRef.current = []; + }, []); + + const rememberCameraState = useCallback(() => { + const graph = fgRef.current as ForceGraphMethods & { + camera?: () => THREE.Camera; + controls?: () => object; + }; + const camera = graph?.camera?.() as THREE.PerspectiveCamera | undefined; + const controls = graph?.controls?.() as GraphControls | undefined; + controls?.saveState?.(); + if (!camera) return; + + savedCameraRef.current = { + position: { + x: camera.position.x, + y: camera.position.y, + z: camera.position.z, + }, + target: { + x: controls?.target?.x ?? 0, + y: controls?.target?.y ?? 0, + z: controls?.target?.z ?? 0, + }, + }; + }, []); + + const releaseControls = useCallback((delayMs = 0) => { + if (controlsReleaseTimerRef.current !== null) { + window.clearTimeout(controlsReleaseTimerRef.current); + } + + controlsReleaseTimerRef.current = window.setTimeout(() => { + const graph = fgRef.current as ForceGraphMethods & { + controls?: () => object; + }; + const controls = graph?.controls?.() as GraphControls | undefined; + if (!controls) return; + + controls.enabled = true; + controls.update?.(); + if (typeof PointerEvent !== 'undefined' && controls.domElement instanceof EventTarget) { + controls.domElement.dispatchEvent(new PointerEvent('pointerup')); + } + controlsReleaseTimerRef.current = null; + }, delayMs); + }, []); + + const fitToGraph = useCallback((durationMs = 650) => { + const graph = fgRef.current as ForceGraphMethods & { + zoomToFit?: (duration?: number, padding?: number) => void; + }; + if (!graph || !graphData || graphData.nodes.length === 0) return; + + graph.zoomToFit?.(durationMs, 70); + + if (fitTimerRef.current !== null) { + window.clearTimeout(fitTimerRef.current); + } + fitTimerRef.current = window.setTimeout(() => { + rememberCameraState(); + fitTimerRef.current = null; + }, durationMs + 80); + releaseControls(durationMs + 40); + }, [graphData, releaseControls, rememberCameraState]); + + const resetCamera = useCallback(() => { + const graph = fgRef.current as ForceGraphMethods & { + cameraPosition?: ( + position: Partial<{ x: number; y: number; z: number }>, + lookAt?: { x: number; y: number; z: number }, + transitionMs?: number, + ) => void; + }; + + const saved = savedCameraRef.current; + if (!saved) { + fitToGraph(); + return; + } + + graph?.cameraPosition?.(saved.position, saved.target, 700); + releaseControls(760); + }, [fitToGraph, releaseControls]); + + const hidePreviewCard = useCallback(() => { + const preview = previewRef.current; + if (!preview) return; + preview.style.opacity = '0'; + preview.style.transform = 'translateY(6px)'; + }, []); + + const showPreviewCardForNode = useCallback((node: GraphNode) => { + const preview = previewRef.current; + if (!preview) return; + + const displayName = formatNodeName(node); + const typeLabel = node.type === 'tag' ? 'tag' : node.type === 'ghost' ? 'unlinked note' : 'note'; + let excerptHtml = ''; + if (node.type === 'ghost') { + excerptHtml = `This note has not been created yet.`; + } else if (node.excerpt) { + excerptHtml = node.excerpt; + } + + let hint = ''; + if (!ignoreCollapsible && node.collapsible && collapsedNodes?.has(node.id)) hint = 'Click to expand'; + if (!ignoreCollapsible && node.collapsible && collapsedNodes && !collapsedNodes.has(node.id)) hint = 'Shift+click to collapse'; + + preview.style.background = themeTokens.panelBackground; + preview.style.color = themeTokens.textNormal; + preview.style.borderColor = themeTokens.panelBorder; + preview.innerHTML = ` +
${typeLabel}
+
${displayName}
+ ${excerptHtml ? `
${excerptHtml}
` : ''} + ${hint ? `
${hint}
` : ''} + `; + preview.style.opacity = '1'; + preview.style.transform = 'translateY(0)'; + }, [collapsedNodes, ignoreCollapsible, themeTokens]); + + const stopAnimation = useCallback(() => { + animationRunIdRef.current += 1; + clearAnimationTimers(); + releaseControls(); + tourPreviewLockUntilRef.current = 0; + hidePreviewCard(); + emitAnimationState({ mode: 'none', running: false }); + }, [clearAnimationTimers, emitAnimationState, hidePreviewCard, releaseControls]); + + const collectTourNodes = useCallback((): PositionedGraphNode[] => { + if (!graphData) return []; + + const visibleNodes = graphData.nodes + .filter((node): node is PositionedGraphNode => node.type === 'file') + .filter((node) => node.x != null && node.y != null && node.z != null); + const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); + + const wikiDegree = new Map(); + const tagDegree = new Map(); + graphData.links.forEach((link) => { + const sourceId = resolveId(link.source); + const targetId = resolveId(link.target); + + if (link.type === 'wikilink') { + wikiDegree.set(sourceId, (wikiDegree.get(sourceId) ?? 0) + 1); + wikiDegree.set(targetId, (wikiDegree.get(targetId) ?? 0) + 1); + } + + if (link.type === 'file-tag') { + const fileId = visibleNodeIds.has(sourceId) ? sourceId : targetId; + tagDegree.set(fileId, (tagDegree.get(fileId) ?? 0) + 1); + } + }); + + const scored = [...visibleNodes].sort((a, b) => { + const score = (node: PositionedGraphNode) => { + const degree = wikiDegree.get(node.id) ?? 0; + const tags = tagDegree.get(node.id) ?? 0; + return ( + (node.pinned ? 180 : 0) + + (node.callout ? 120 : 0) + + node.val * 2.4 + + degree * 7 + + tags * 14 + + Math.random() * 18 + ); + }; + return score(b) - score(a); + }); + + const selected: PositionedGraphNode[] = []; + for (const node of scored) { + const tooClose = selected.some((picked) => { + const dx = (picked.x ?? 0) - (node.x ?? 0); + const dy = (picked.y ?? 0) - (node.y ?? 0); + const dz = (picked.z ?? 0) - (node.z ?? 0); + return Math.hypot(dx, dy, dz) < 18; + }); + if (!tooClose) selected.push(node); + if (selected.length >= 34) break; + } + + if (selected.length >= Math.min(18, scored.length)) { + return selected; + } + + return selected.length > 0 ? selected : scored.slice(0, Math.min(24, scored.length)); + }, [graphData]); + + const startCameraTour = useCallback(() => { + const graph = fgRef.current as ForceGraphMethods & { + camera?: () => THREE.Camera; + controls?: () => object; + }; + const camera = graph?.camera?.() as THREE.PerspectiveCamera | undefined; + const controls = graph?.controls?.() as GraphControls | undefined; + if (!graph || !camera || !controls) return; + + const initialStops = collectTourNodes(); + if (initialStops.length === 0) return; + + stopAnimation(); + const runId = animationRunIdRef.current; + emitAnimationState({ mode: 'camera-tour', running: true }); + + const worldUp = new THREE.Vector3(0, 1, 0); + const controlTarget = controls.target ?? new THREE.Vector3(); + if (!controls.target) { + controls.target = controlTarget; + } + + const recentTargetIds: string[] = []; + const cameraVelocity = new THREE.Vector3(); + const lookVelocity = new THREE.Vector3(); + + const pushRecentTarget = (id: string) => { + recentTargetIds.unshift(id); + while (recentTargetIds.length > 6) { + recentTargetIds.pop(); + } + }; + + const getCandidates = (): TourCandidate[] => { + const nodes = collectTourNodes(); + return nodes.map((node) => ({ + id: node.id, + position: new THREE.Vector3(node.x ?? 0, node.y ?? 0, node.z ?? 0), + score: (node.pinned ? 1000 : 0) + (node.callout ? 500 : 0) + node.val, + })); + }; + + const chooseTarget = ( + origin: THREE.Vector3, + currentId: string | null, + directionHint: THREE.Vector3, + history = recentTargetIds, + candidates = getCandidates(), + ): TourCandidate | null => { + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]; + + const maxNodeScore = Math.max(1, ...candidates.map((candidate) => candidate.score)); + + const heading = + directionHint.lengthSq() > 1e-6 + ? directionHint.clone().normalize() + : new THREE.Vector3(0, 0, -1); + + const weights = candidates.map((candidate) => { + if (currentId !== null && candidate.id === currentId && candidates.length > 1) { + return 0; + } + + const toNode = candidate.position.clone().sub(origin); + const distance = Math.max(toNode.length(), 1); + const direction = toNode.normalize(); + const alignment = direction.dot(heading); + const turnBias = clamp(0.4 + (alignment + 1) * 0.38, 0.06, 1.5); + const reversePenalty = + alignment < -0.55 ? 0.015 : alignment < -0.25 ? 0.07 : alignment < 0 ? 0.32 : 1; + const distanceBias = clamp(distance / 58, 0.22, 2.9); + const nearPenalty = + distance < 26 ? 0.008 : distance < 40 ? 0.04 : distance < 58 ? 0.16 : distance < 80 ? 0.55 : 1; + + let recencyPenalty = 1; + const recentIndex = history.indexOf(candidate.id); + if (recentIndex === 0) recencyPenalty = 0.01; + else if (recentIndex === 1) recencyPenalty = 0.05; + else if (recentIndex === 2) recencyPenalty = 0.15; + else if (recentIndex === 3) recencyPenalty = 0.32; + else if (recentIndex >= 4) recencyPenalty = 0.55; + + const scoreBias = 0.9 + (candidate.score / maxNodeScore) * 0.6; + const randomBias = lerp(0.62, 1.42, Math.random()); + + return scoreBias * distanceBias * nearPenalty * recencyPenalty * turnBias * reversePenalty * randomBias; + }); + + return candidates[pickWeightedIndex(weights)] ?? candidates[0]; + }; + + const buildSegment = ({ + fromPosition, + fromVelocity, + targetId, + history = recentTargetIds, + }: { + fromPosition: THREE.Vector3; + fromVelocity: THREE.Vector3; + targetId?: string | null; + history?: string[]; + }): TourSegment => { + const candidates = getCandidates(); + if (candidates.length === 0) { + throw new Error('No tour candidates available.'); + } + + const travelDirection = + fromVelocity.lengthSq() > 1e-6 + ? fromVelocity.clone().normalize() + : controlTarget.clone().sub(fromPosition).normalize(); + if (travelDirection.lengthSq() < 1e-6) { + travelDirection.set(0, 0, -1); + } + + const resolvedTarget = + (targetId ? candidates.find((candidate) => candidate.id === targetId) : null) + ?? chooseTarget(fromPosition, null, travelDirection, history, candidates) + ?? candidates[0]; + const targetPosition = resolvedTarget.position.clone(); + + let approachDirection = targetPosition.clone().sub(fromPosition); + if (approachDirection.lengthSq() < 1e-6) { + approachDirection = travelDirection.clone(); + } else { + approachDirection.normalize(); + } + + const sideSign = Math.random() < 0.5 ? -1 : 1; + const sideDirection = buildBasis(approachDirection, worldUp).right.multiplyScalar(sideSign); + const orbitDepartureDirection = approachDirection + .clone() + .lerp(sideDirection, 0.78) + .normalize(); + + const nextTarget = + candidates.length > 1 + ? chooseTarget( + targetPosition, + resolvedTarget.id, + orbitDepartureDirection, + [resolvedTarget.id, ...history], + candidates, + ) + : resolvedTarget; + const nextPosition = (nextTarget ?? resolvedTarget).position.clone(); + + let exitDirection = nextPosition.clone().sub(targetPosition); + if (exitDirection.lengthSq() < 1e-6) { + exitDirection = orbitDepartureDirection.clone(); + } else { + exitDirection.normalize().lerp(orbitDepartureDirection, 0.46).normalize(); + } + + const blendedBasis = buildBasis( + travelDirection + .clone() + .lerp(approachDirection, 0.44) + .lerp(orbitDepartureDirection, 0.44) + .lerp(exitDirection, 0.18), + worldUp, + ); + const distance = Math.max(fromPosition.distanceTo(targetPosition), 24); + const sideDistance = clamp(distance * lerp(0.22, 0.36, Math.random()), 16, 78); + const verticalOffset = sideDistance * lerp(-0.16, 0.24, Math.random()); + const startPull = clamp(distance * lerp(0.14, 0.24, Math.random()), 10, 36); + const leadInDistance = clamp(distance * lerp(0.08, 0.14, Math.random()), 8, 22); + const exitDistance = clamp(distance * lerp(0.32, 0.56, Math.random()), 24, 84); + const orbitBasis = buildBasis( + approachDirection + .clone() + .lerp(exitDirection, 0.24) + .lerp(orbitDepartureDirection, 0.3), + worldUp, + ); + const orbitDistance = clamp(distance * lerp(0.52, 0.74, Math.random()), 24, 72); + const orbitRadius = clamp(sideDistance * lerp(0.82, 1.08, Math.random()), 16, 48); + const orbitVerticalRadius = clamp(Math.abs(verticalOffset) * 0.8 + orbitRadius * lerp(0.24, 0.46, Math.random()), 6, 22); + const orbitAngleStart = sideSign > 0 + ? lerp(-Math.PI * 0.78, -Math.PI * 0.56, Math.random()) + : lerp(Math.PI * 0.56, Math.PI * 0.78, Math.random()); + const orbitAngleSweep = sideSign * lerp(Math.PI * 0.92, Math.PI * 1.18, Math.random()); + + const orbitPointAt = (angle: number) => targetPosition.clone() + .add(orbitBasis.forward.clone().multiplyScalar(-orbitDistance)) + .add(orbitBasis.right.clone().multiplyScalar(Math.cos(angle) * orbitRadius)) + .add(orbitBasis.up.clone().multiplyScalar(Math.sin(angle) * orbitVerticalRadius)); + const orbitTangentAt = (angle: number) => orbitBasis.right.clone() + .multiplyScalar(-Math.sin(angle) * orbitRadius) + .add(orbitBasis.up.clone().multiplyScalar(Math.cos(angle) * orbitVerticalRadius)) + .multiplyScalar(Math.sign(orbitAngleSweep) || 1) + .normalize(); + + const orbitEntry = orbitPointAt(orbitAngleStart); + const orbitExit = orbitPointAt(orbitAngleStart + orbitAngleSweep); + const orbitEntryTangent = orbitTangentAt(orbitAngleStart); + const orbitExitTangent = orbitTangentAt(orbitAngleStart + orbitAngleSweep); + + const approachP0 = fromPosition.clone(); + const approachP1 = fromPosition.clone() + .add(travelDirection.clone().multiplyScalar(startPull)) + .add(blendedBasis.right.clone().multiplyScalar(sideSign * sideDistance * 0.2)) + .add(blendedBasis.up.clone().multiplyScalar(verticalOffset * 0.42)); + const approachP2 = orbitEntry.clone() + .sub(orbitEntryTangent.clone().multiplyScalar(leadInDistance)) + .add(approachDirection.clone().multiplyScalar(-leadInDistance * 0.18)); + const approachP3 = orbitEntry.clone(); + + const exitP0 = orbitExit.clone(); + const exitP1 = orbitExit.clone() + .add(orbitExitTangent.clone().multiplyScalar(clamp(exitDistance * 0.24, 10, 28))); + const exitP3 = targetPosition.clone() + .add(orbitDepartureDirection.clone().multiplyScalar(clamp(orbitRadius * lerp(0.72, 1.05, Math.random()), 14, 58))) + .add(exitDirection.clone().multiplyScalar(exitDistance)) + .add(blendedBasis.right.clone().multiplyScalar(sideSign * sideDistance * lerp(0.32, 0.68, Math.random()))) + .add(blendedBasis.up.clone().multiplyScalar(verticalOffset * lerp(0.2, 0.58, Math.random()))); + const exitP2 = exitP3.clone() + .sub(exitDirection.clone().multiplyScalar(clamp(exitDistance * 0.36, 12, 38))) + .add(blendedBasis.right.clone().multiplyScalar(sideSign * sideDistance * 0.16)); + + const approachDurationMs = clamp(1200 + distance * 11 + Math.random() * 700, 1200, 2500); + const orbitDurationMs = 4200; + const exitDurationMs = clamp(1200 + distance * 7 + Math.random() * 550, 1200, 2400); + + return { + approachCurve: [approachP0, approachP1, approachP2, approachP3], + exitCurve: [exitP0, exitP1, exitP2, exitP3], + targetId: resolvedTarget.id, + nextId: nextTarget?.id ?? null, + targetPosition, + nextPosition, + orbitForward: orbitBasis.forward.clone(), + orbitRight: orbitBasis.right.clone(), + orbitUp: orbitBasis.up.clone(), + orbitDistance, + orbitRadius, + orbitVerticalRadius, + orbitAngleStart, + orbitAngleSweep, + lookSideBias: -sideSign * clamp(sideDistance * lerp(0.15, 0.26, Math.random()), 4, 16), + lookLiftBias: clamp(verticalOffset * 0.34 + lerp(-2.5, 4.5, Math.random()), -8, 12), + driftAmplitude: clamp(sideDistance * 0.16, 1.4, 5.2), + driftPhase: Math.random() * Math.PI * 2, + driftFreqA: lerp(0.42, 0.82, Math.random()), + driftFreqB: lerp(0.88, 1.46, Math.random()), + approachDurationMs, + orbitDurationMs, + exitDurationMs, + durationMs: approachDurationMs + orbitDurationMs + exitDurationMs, + previewLeadT: lerp(0.08, 0.16, Math.random()), + }; + }; + + const buildUpcomingSegment = (segment: TourSegment) => { + const endTangent = cubicBezierTangent(...segment.exitCurve, 1); + const seededVelocity = + endTangent.lengthSq() > 1e-6 + ? endTangent.clone().normalize().multiplyScalar(clamp(segment.exitCurve[0].distanceTo(segment.exitCurve[3]) * 0.32, 18, 46)) + : segment.nextPosition.clone().sub(segment.targetPosition).normalize().multiplyScalar(26); + + return buildSegment({ + fromPosition: segment.exitCurve[3].clone(), + fromVelocity: seededVelocity, + targetId: segment.nextId, + history: [segment.targetId, ...recentTargetIds], + }); + }; + + const sampleSegmentState = (segment: TourSegment, progress: number, now: number) => { + const approachRatio = segment.approachDurationMs / segment.durationMs; + const orbitRatio = segment.orbitDurationMs / segment.durationMs; + const exitRatio = segment.exitDurationMs / segment.durationMs; + const orbitStart = approachRatio; + const exitStart = approachRatio + orbitRatio; + const time = now * 0.001; + let basePosition: THREE.Vector3; + let tangent: THREE.Vector3; + let basis: { forward: THREE.Vector3; right: THREE.Vector3; up: THREE.Vector3 }; + let focusBase: THREE.Vector3; + + if (progress < orbitStart) { + const localT = smoothstep(progress / Math.max(approachRatio, 0.0001)); + basePosition = cubicBezierPoint(...segment.approachCurve, localT); + tangent = cubicBezierTangent(...segment.approachCurve, localT); + basis = buildBasis(tangent, worldUp); + focusBase = segment.targetPosition.clone(); + } else if (progress < exitStart) { + const orbitT = smoothstep((progress - orbitStart) / Math.max(orbitRatio, 0.0001)); + const angle = segment.orbitAngleStart + segment.orbitAngleSweep * orbitT; + basePosition = segment.targetPosition.clone() + .add(segment.orbitForward.clone().multiplyScalar(-segment.orbitDistance)) + .add(segment.orbitRight.clone().multiplyScalar(Math.cos(angle) * segment.orbitRadius)) + .add(segment.orbitUp.clone().multiplyScalar(Math.sin(angle) * segment.orbitVerticalRadius)); + tangent = segment.orbitRight.clone() + .multiplyScalar(-Math.sin(angle) * segment.orbitRadius) + .add(segment.orbitUp.clone().multiplyScalar(Math.cos(angle) * segment.orbitVerticalRadius)) + .multiplyScalar(Math.sign(segment.orbitAngleSweep) || 1); + basis = buildBasis(tangent, worldUp); + focusBase = segment.targetPosition.clone(); + } else { + const localT = smoothstep((progress - exitStart) / Math.max(exitRatio, 0.0001)); + basePosition = cubicBezierPoint(...segment.exitCurve, localT); + tangent = cubicBezierTangent(...segment.exitCurve, localT); + basis = buildBasis(tangent, worldUp); + const lookLead = clamp(smoothstep(localT) * 0.24, 0, 0.24); + focusBase = segment.targetPosition.clone().lerp(segment.nextPosition, lookLead); + } + + const positionDrift = basis.right.clone() + .multiplyScalar(Math.sin(time * segment.driftFreqA + segment.driftPhase) * segment.driftAmplitude) + .add( + basis.up.clone().multiplyScalar( + Math.cos(time * segment.driftFreqB + segment.driftPhase * 1.37) * segment.driftAmplitude * 0.58, + ), + ); + const lookDrift = basis.right.clone() + .multiplyScalar(Math.sin(time * 0.73 + segment.driftPhase * 0.72) * segment.driftAmplitude * 0.18) + .add( + basis.up.clone().multiplyScalar( + Math.cos(time * 0.61 + segment.driftPhase * 0.31) * segment.driftAmplitude * 0.12, + ), + ); + + return { + position: basePosition.add(positionDrift), + lookAt: focusBase + .add(basis.right.clone().multiplyScalar(segment.lookSideBias)) + .add(basis.up.clone().multiplyScalar(segment.lookLiftBias)) + .add(lookDrift), + }; + }; + + let initialVelocity = controlTarget.clone().sub(camera.position); + if (initialVelocity.lengthSq() < 1e-6) { + initialVelocity = new THREE.Vector3(0, 0, -1); + } + + let activeSegment = buildSegment({ + fromPosition: camera.position.clone(), + fromVelocity: initialVelocity, + }); + pushRecentTarget(activeSegment.targetId); + let upcomingSegment: TourSegment | null = null; + let previewedTargetId: string | null = null; + let segmentStartTime = performance.now(); + let lastNow = performance.now(); + + const animate = (now: number) => { + if (runId !== animationRunIdRef.current) return; + + const deltaMs = now - lastNow; + lastNow = now; + const dt = clamp(deltaMs / 1000, 0.001, 0.05); + const progress = clamp((now - segmentStartTime) / activeSegment.durationMs, 0, 1); + const orbitStart = activeSegment.approachDurationMs / activeSegment.durationMs; + + if (progress >= orbitStart && previewedTargetId !== activeSegment.targetId) { + const previewNode = graphData?.nodes.find((node) => node.id === activeSegment.targetId); + if (previewNode && previewNode.type === 'file') { + tourPreviewLockUntilRef.current = now + 2000; + showPreviewCardForNode(previewNode); + previewedTargetId = activeSegment.targetId; + } + } + + if (!upcomingSegment && progress >= 0.9) { + upcomingSegment = buildUpcomingSegment(activeSegment); + } + + const activeState = sampleSegmentState(activeSegment, progress, now); + let previewProgress = 0; + let desiredPosition = activeState.position; + let desiredLookAt = activeState.lookAt; + + if (upcomingSegment && progress >= 0.95) { + const blend = smoothstep((progress - 0.95) / 0.05); + previewProgress = blend * upcomingSegment.previewLeadT; + const nextState = sampleSegmentState(upcomingSegment, previewProgress, now + 120); + desiredPosition = lerpVec(activeState.position, nextState.position, blend); + desiredLookAt = lerpVec(activeState.lookAt, nextState.lookAt, blend); + } + + springVectorToward(camera.position, cameraVelocity, desiredPosition, 8.5, 3.8, dt); + springVectorToward(controlTarget, lookVelocity, desiredLookAt, 11.5, 4.4, dt); + controls.update?.(); + + if (progress >= 1) { + if (!upcomingSegment) { + upcomingSegment = buildUpcomingSegment(activeSegment); + } + activeSegment = upcomingSegment; + upcomingSegment = null; + pushRecentTarget(activeSegment.targetId); + previewedTargetId = null; + segmentStartTime = now - previewProgress * activeSegment.durationMs; + } + + animationFrameRef.current = requestAnimationFrame(animate); + }; + + controls.enabled = false; + animationFrameRef.current = requestAnimationFrame(animate); + }, [collectTourNodes, emitAnimationState, graphData, showPreviewCardForNode, stopAnimation]); + + const startOrbitSettleAnimation = useCallback(() => { + const graph = fgRef.current as ForceGraphMethods & { + d3ReheatSimulation?: () => void; + camera?: () => THREE.Camera; + controls?: () => object; + getGraphBbox?: () => { x: [number, number]; y: [number, number]; z: [number, number] } | null; + }; + const camera = graph?.camera?.() as THREE.PerspectiveCamera | undefined; + const controls = graph?.controls?.() as GraphControls | undefined; + const bbox = graph?.getGraphBbox?.(); + if (!graph || !camera || !controls || !bbox) return; + + stopAnimation(); + fitToGraph(900); + graph.d3ReheatSimulation?.(); + controls.enabled = false; + + const runId = animationRunIdRef.current; + emitAnimationState({ mode: 'orbit-settle', running: true }); + + const center = { + x: (bbox.x[0] + bbox.x[1]) / 2, + y: (bbox.y[0] + bbox.y[1]) / 2, + z: (bbox.z[0] + bbox.z[1]) / 2, + }; + const span = Math.max( + bbox.x[1] - bbox.x[0], + bbox.y[1] - bbox.y[0], + bbox.z[1] - bbox.z[0], + 140, + ); + const baseRadius = span * 1.04; + const startTime = performance.now(); + const orbitCycleMs = 22_000; + + const animate = (now: number) => { + if (runId !== animationRunIdRef.current) return; + const elapsed = now - startTime; + const t = elapsed / orbitCycleMs; + const phase = t * Math.PI * 2 * 1.08; + const phase2 = t * Math.PI * 2 * 0.37 + 0.8; + const phase3 = t * Math.PI * 2 * 1.31 + 1.4; + + const radiusX = baseRadius * (1.02 + 0.28 * Math.sin(phase2)); + const radiusZ = baseRadius * (0.68 + 0.24 * Math.cos(phase2 + 1.1)); + const radiusY = baseRadius * (0.16 + 0.12 * (0.5 + 0.5 * Math.sin(phase2 * 0.9))); + const x = center.x + Math.cos(phase) * radiusX + Math.sin(phase3) * baseRadius * 0.08; + const y = center.y + Math.sin(phase * 0.48 + 0.6) * radiusY + Math.cos(phase3 * 0.74) * baseRadius * 0.07; + const z = center.z + Math.sin(phase) * radiusZ + Math.cos(phase * 0.61 + 1.2) * baseRadius * 0.06; + const lookX = center.x + Math.sin(phase * 0.71) * span * 0.035; + const lookY = center.y + Math.cos(phase * 0.93) * span * 0.024; + const lookZ = center.z + Math.sin(phase * 0.57 + 0.4) * span * 0.03; + + camera.position.set( + x, + y, + z, + ); + controls.target?.set(lookX, lookY, lookZ); + controls.update?.(); + + animationFrameRef.current = requestAnimationFrame(animate); + }; + + animationFrameRef.current = requestAnimationFrame(animate); + }, [emitAnimationState, fitToGraph, stopAnimation]); + + useImperativeHandle(ref, () => ({ + fitToGraph: () => fitToGraph(), + resetCamera, + startCameraTour, + startOrbitSettleAnimation, + stopAnimation, + isAnimationRunning: () => animationStateRef.current.running, + }), [fitToGraph, resetCamera, startCameraTour, startOrbitSettleAnimation, stopAnimation]); + + useEffect(() => { + const host = containerRef.current; + if (!host) return; + + const updateSize = () => { + setDimensions({ + width: host.clientWidth, + height: host.clientHeight, + }); + }; + + updateSize(); + + const resizeObserver = new ResizeObserver(updateSize); + resizeObserver.observe(host); + return () => resizeObserver.disconnect(); + }, []); + + useEffect(() => { + (fgRef.current as { refresh?: () => void } | undefined)?.refresh?.(); + }, [graphData, particleRandomness, particleSpeedValue, particleStyle, particleTrailLength, showAllLabels, themeTokens]); + + useEffect(() => { + if (!graphData || dimensions.width === 0 || dimensions.height === 0 || !fgRef.current) { + return; + } + + const graph = fgRef.current as ForceGraphMethods & { + lights?: () => THREE.Light[]; + scene?: () => THREE.Scene; + refresh?: () => void; + }; + const scene = graph.scene?.(); + if (!scene) return; + + sceneLightRigRef.current.forEach((light) => { + if (light.parent === scene) { + scene.remove(light); + } + }); + + const ambient = new THREE.AmbientLight( + themeTokens.mode === 'dark' ? 0xffffff : 0xf6f8ff, + themeTokens.mode === 'dark' ? 2.35 : 1.8, + ); + const key = new THREE.DirectionalLight(0xffffff, 1.95); + key.position.set(140, 180, 120); + + const fill = new THREE.DirectionalLight( + themeTokens.mode === 'dark' ? 0xa8c8ff : 0xffffff, + themeTokens.mode === 'dark' ? 1.12 : 0.76, + ); + fill.position.set(-160, -90, -140); + + sceneLightRigRef.current = [ambient, key, fill]; + sceneLightRigRef.current.forEach((light) => scene.add(light)); + graph.lights?.(sceneLightRigRef.current); + graph.refresh?.(); + + return () => { + sceneLightRigRef.current.forEach((light) => { + if (light.parent === scene) { + scene.remove(light); + } + }); + }; + }, [dimensions.height, dimensions.width, graphData, themeTokens.mode]); + + useEffect(() => { + nodeVisualsRef.current.clear(); + impactPulsesRef.current.clear(); + }, [graphData, ignoreCollapsible, showAllLabels]); + + useEffect(() => { + if (impactGlow) return; + + impactPulsesRef.current.clear(); + nodeVisualsRef.current.forEach((visual) => { + const material = visual.glowSprite.material as THREE.SpriteMaterial; + material.opacity = 0; + visual.glowSprite.scale.set(visual.baseScale, visual.baseScale, 1); + }); + }, [impactGlow]); + + useEffect(() => { + linkMaterialCacheRef.current.forEach((material) => material.dispose()); + linkMaterialCacheRef.current.clear(); + }, [highlightedTag, isDark, themeTokens.accent, themeTokens.accentHover]); + + useEffect(() => { + if (!graphData) { + setCollapsedNodes(null); + return; + } + + const next = new Set(); + graphData.nodes.forEach((node) => { + if (node.collapsible) next.add(node.id); + }); + setCollapsedNodes(next); + }, [graphData]); + + useEffect(() => { + setHighlightedTag(null); + }, [graphData]); + + useEffect(() => { + setShowBuildCta(false); + hasShownCtaRef.current = false; + }, [graphData]); + + useEffect(() => { + setShowCallout(Boolean(calloutNodeId)); + setCalloutPos(null); + }, [calloutNodeId]); + + useEffect(() => { + try { + localStorage.setItem(HINT_BAR_STORAGE_KEY, String(isHintBarExpanded)); + } catch { + // Ignore storage failures. + } + }, [isHintBarExpanded]); + + useEffect(() => { + const styleId = 'gb-interactive-graph-styles'; + if (document.getElementById(styleId)) return; + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + @keyframes gb-pulse { + 0% { transform: translate(-50%,-50%) scale(1); opacity: 0.9; } + 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } + } + @keyframes gb-float { + 0%, 100% { transform: translate(-50%,-100%) translateY(0px); } + 50% { transform: translate(-50%,-100%) translateY(-7px); } + } + @keyframes gb-fadein { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes gb-toast-fade { + 0%, 70% { opacity: 1; transform: translateY(0); } + 100% { opacity: 0; transform: translateY(-6px); } + } + `; + document.head.appendChild(style); + + return () => { + document.getElementById(styleId)?.remove(); + }; + }, []); + + useEffect(() => { + const onMove = (event: MouseEvent) => { + const tooltip = tooltipRef.current; + const host = containerRef.current; + if (!tooltip || !host || tooltip.style.display === 'none') return; + + const rect = host.getBoundingClientRect(); + tooltip.style.left = `${event.clientX - rect.left + 14}px`; + tooltip.style.top = `${event.clientY - rect.top + 14}px`; + }; + + document.addEventListener('mousemove', onMove); + return () => document.removeEventListener('mousemove', onMove); + }, []); + + useEffect(() => { + const host = containerRef.current; + if (!host) return; + + const stopOnInteraction = () => { + setNodeContextMenu(null); + if (animationStateRef.current.running) { + stopAnimation(); + } + }; + + host.addEventListener('pointerdown', stopOnInteraction, { capture: true }); + host.addEventListener('wheel', stopOnInteraction, { capture: true }); + + return () => { + host.removeEventListener('pointerdown', stopOnInteraction, { capture: true }); + host.removeEventListener('wheel', stopOnInteraction, { capture: true }); + }; + }, [stopAnimation]); + + useEffect(() => { + if (!nodeContextMenu) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as HTMLElement | null; + if (target?.closest('[data-gb-node-menu="true"]')) return; + setNodeContextMenu(null); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setNodeContextMenu(null); + } + }; + + document.addEventListener('pointerdown', handlePointerDown, true); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + document.removeEventListener('keydown', handleEscape); + }; + }, [nodeContextMenu]); + + useEffect(() => { + return () => { + if (fitTimerRef.current !== null) { + window.clearTimeout(fitTimerRef.current); + } + if (controlsReleaseTimerRef.current !== null) { + window.clearTimeout(controlsReleaseTimerRef.current); + } + linkMaterialCacheRef.current.forEach((material) => material.dispose()); + linkMaterialCacheRef.current.clear(); + if (!fgRef.current) return; + + try { + (fgRef.current as { pauseAnimation?: () => void }).pauseAnimation?.(); + const renderer = ( + fgRef.current as { renderer?: () => { dispose?: () => void } } + ).renderer?.(); + renderer?.dispose?.(); + } catch { + // Best-effort cleanup. + } + }; + }, []); + + useEffect(() => { + if (animationStateRef.current.running) { + stopAnimation(); + } + }, [graphData, ignoreCollapsible, showAllLabels, stopAnimation]); + + useEffect(() => { + if (!graphData || graphData.nodes.length === 0 || dimensions.width === 0 || dimensions.height === 0) { + return; + } + + const timer = window.setTimeout(() => fitToGraph(420), 180); + return () => window.clearTimeout(timer); + }, [dimensions.height, dimensions.width, fitToGraph, graphData, ignoreCollapsible]); + + const highlightedNodeIds = useMemo>(() => { + if (!highlightedTag || !graphData) return new Set(); + + const ids = new Set([highlightedTag]); + graphData.links.forEach((link) => { + const src = resolveId(link.source); + const tgt = resolveId(link.target); + if (src === highlightedTag) ids.add(tgt); + if (tgt === highlightedTag) ids.add(src); + }); + return ids; + }, [graphData, highlightedTag]); + + useEffect(() => { + if (highlightedTag === null) { + pulsingLightsRef.current.clear(); + return; + } + + let rafId = 0; + const animate = () => { + const t = Date.now() / 1000; + pulsingLightsRef.current.forEach((light) => { + light.intensity = 3 + 2 * Math.sin(t * 3); + }); + rafId = requestAnimationFrame(animate); + }; + + rafId = requestAnimationFrame(animate); + return () => cancelAnimationFrame(rafId); + }, [highlightedTag]); + + const calloutTarget = useMemo(() => { + if (!graphData || !calloutNodeId) return null; + return graphData.nodes.find((node) => node.id === calloutNodeId) ?? null; + }, [graphData, calloutNodeId]); + + calloutTargetRef.current = calloutTarget; + + const dismissCallout = useCallback(() => { + setShowCallout(false); + setCalloutPos(null); + }, []); + + useEffect(() => { + if (!showCallout || !calloutTarget) return; + + let rafId = 0; + const project = () => { + const node = calloutTargetRef.current as (GraphNode & { + x?: number; + y?: number; + z?: number; + }) | null; + const graph = fgRef.current as ForceGraphMethods & { + camera?: () => THREE.Camera; + renderer?: () => THREE.WebGLRenderer; + }; + + if (graph && node && node.x != null) { + const camera = graph.camera?.(); + const renderer = graph.renderer?.(); + if (camera && renderer) { + const size = new THREE.Vector2(); + renderer.getSize(size); + const vec = new THREE.Vector3(node.x, node.y ?? 0, node.z ?? 0); + vec.project(camera); + const sx = (vec.x * 0.5 + 0.5) * size.x; + const sy = (-vec.y * 0.5 + 0.5) * size.y; + if (sx > 0 && sy > 0 && sx < size.x && sy < size.y) { + setCalloutPos({ x: sx, y: sy }); + } + } + } + rafId = requestAnimationFrame(project); + }; + + const startTimer = window.setTimeout(() => { + rafId = requestAnimationFrame(project); + }, 2500); + const dismissTimer = window.setTimeout(() => dismissCallout(), 30000); + + return () => { + window.clearTimeout(startTimer); + window.clearTimeout(dismissTimer); + cancelAnimationFrame(rafId); + }; + }, [calloutTarget, dismissCallout, showCallout]); + + const visibleData = useMemo(() => { + if (!graphData) return { nodes: [], links: [] }; + if (ignoreCollapsible) return graphData; + + const collapsed = collapsedNodes ?? new Set( + graphData.nodes.filter((node) => node.collapsible).map((node) => node.id), + ); + + if (collapsed.size === 0) return graphData; + + const wikilinks = graphData.links.filter((link) => link.type === 'wikilink'); + const fileTags = graphData.links.filter((link) => link.type === 'file-tag'); + const incomingAll = new Map>(); + + graphData.nodes.forEach((node) => incomingAll.set(node.id, new Set())); + wikilinks.forEach((link) => { + const src = resolveId(link.source); + const tgt = resolveId(link.target); + incomingAll.get(tgt)?.add(src); + }); + + const visible = new Set(); + const queue: string[] = []; + const addVisible = (id: string) => { + if (visible.has(id)) return; + visible.add(id); + if (!collapsed.has(id)) queue.push(id); + }; + + graphData.nodes.forEach((node) => { + if (node.type === 'tag') return; + const isPinned = node.pinned === true; + const isRoot = (incomingAll.get(node.id)?.size ?? 0) === 0; + if (isPinned || isRoot) addVisible(node.id); + }); + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + wikilinks.forEach((link) => { + const src = resolveId(link.source); + const tgt = resolveId(link.target); + if (src === current) addVisible(tgt); + }); + } + + fileTags.forEach((link) => { + const src = resolveId(link.source); + const tgt = resolveId(link.target); + if (visible.has(src)) visible.add(tgt); + }); + + return { + nodes: graphData.nodes.filter((node) => visible.has(node.id)), + links: graphData.links.filter((link) => { + const src = resolveId(link.source); + const tgt = resolveId(link.target); + return visible.has(src) && visible.has(tgt); + }), + }; + }, [collapsedNodes, graphData, ignoreCollapsible]); + + const nodeThreeObject = useCallback((rawNode: object) => { + const node = rawNode as GraphNode; + const isHighlighted = node.id === highlightedTag; + const isConnected = highlightedTag !== null && highlightedNodeIds.has(node.id); + const isDimmed = highlightedTag !== null && !isConnected && !isHighlighted; + const isCollapsed = !ignoreCollapsible && node.collapsible && (collapsedNodes?.has(node.id) ?? false); + + const group = new THREE.Group(); + group.renderOrder = 20; + const mesh = buildNodeObject(node.type, node.shape, node.color, node.val, themeTokens.mode === 'light'); + if (isHighlighted || isConnected) { + mesh.scale.multiplyScalar(1.35); + } + + mesh.traverse((child) => { + if (!(child as THREE.Mesh).isMesh) return; + const meshChild = child as THREE.Mesh; + meshChild.renderOrder = 20; + }); + + if (isDimmed) { + mesh.traverse((child) => { + if (!(child as THREE.Mesh).isMesh) return; + const mat = (child as THREE.Mesh).material as THREE.MeshLambertMaterial; + mat.transparent = true; + mat.opacity = 0.15; + }); + } + + if (isHighlighted) { + mesh.traverse((child) => { + if (!(child as THREE.Mesh).isMesh) return; + const mat = (child as THREE.Mesh).material as THREE.MeshLambertMaterial; + mat.emissive.copy(new THREE.Color(node.color)); + mat.emissiveIntensity = 0.7; + }); + + const light = new THREE.PointLight(node.color, 4, 60); + pulsingLightsRef.current.set(node.id, light); + group.add(light); + } else { + pulsingLightsRef.current.delete(node.id); + } + + const glowSprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: getGlowSpriteTexture(), + color: new THREE.Color(node.color), + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + blending: THREE.AdditiveBlending, + }), + ); + glowSprite.renderOrder = 28; + const baseScale = Math.cbrt(node.val) * 6.2; + glowSprite.scale.set(baseScale, baseScale, 1); + group.add(glowSprite); + nodeVisualsRef.current.set(node.id, { + glowSprite, + baseScale, + }); + + group.add(mesh); + + if (isCollapsed) { + const sprite = makePlusSprite(); + const spriteScale = Math.cbrt(node.val) * 0.8 * 2.8; + sprite.scale.set(spriteScale, spriteScale, 1); + sprite.position.set(spriteScale * 0.38, spriteScale * 0.38, 0); + group.add(sprite); + } + + if (showAllLabels) { + const label = makeLabelSprite(formatNodeName(node), { + opacity: isDimmed ? 0.18 : isHighlighted || isConnected ? 1 : 0.92, + textColor: themeTokens.labelText, + outlineColor: themeTokens.labelOutline, + fontFamily: themeTokens.fontFamily, + }); + const labelOffset = Math.cbrt(node.val) * (isHighlighted || isConnected ? 3.8 : 3.2); + label.position.set(0, labelOffset, 0); + group.add(label); + } + + return group; + }, [collapsedNodes, highlightedNodeIds, highlightedTag, ignoreCollapsible, showAllLabels, themeTokens]); + + const handleNodeHover = useCallback((rawNode: object | null) => { + const tooltip = tooltipRef.current; + const preview = previewRef.current; + + if (tourPreviewLockUntilRef.current > performance.now()) { + return; + } + + if (!rawNode) { + if (tooltip) tooltip.style.display = 'none'; + if (preview) hidePreviewCard(); + return; + } + + const node = rawNode as GraphNode; + const displayName = formatNodeName(node); + + if (tooltip) { + tooltip.textContent = displayName; + tooltip.style.background = themeTokens.panelBackground; + tooltip.style.color = themeTokens.textNormal; + tooltip.style.border = `1px solid ${themeTokens.panelBorder}`; + tooltip.style.display = 'block'; + } + + if (preview) { + showPreviewCardForNode(node); + } + }, [hidePreviewCard, showPreviewCardForNode, themeTokens]); + + const focusNode = useCallback((node: GraphNode & { x?: number; y?: number; z?: number }, transitionMs = 1200) => { + if (!fgRef.current || node.x == null) return; + + const dist = 80; + const mag = Math.hypot(node.x, node.y ?? 0, node.z ?? 0) || 1; + const ratio = 1 + dist / mag; + fgRef.current.cameraPosition( + { x: node.x * ratio, y: (node.y ?? 0) * ratio, z: (node.z ?? 0) * ratio }, + { x: node.x, y: node.y ?? 0, z: node.z ?? 0 }, + transitionMs, + ); + }, []); + + const handleNodeClick = useCallback((rawNode: object, event: MouseEvent) => { + if (animationStateRef.current.running) { + stopAnimation(); + } + setNodeContextMenu(null); + const node = rawNode as GraphNode; + + if (node.type === 'ghost') { + const rect = containerRef.current?.getBoundingClientRect(); + setGhostTooltip({ + x: rect ? event.clientX - rect.left : event.clientX, + y: rect ? event.clientY - rect.top : event.clientY, + }); + window.setTimeout(() => setGhostTooltip(null), 2200); + return; + } + + if (node.type === 'tag') { + setHighlightedTag((current) => (current === node.id ? null : node.id)); + return; + } + + if (!ignoreCollapsible && event.shiftKey && node.collapsible && collapsedNodes && !collapsedNodes.has(node.id)) { + setCollapsedNodes((current) => { + const next = new Set(current ?? []); + next.add(node.id); + return next; + }); + return; + } + + if (!ignoreCollapsible && node.collapsible && collapsedNodes?.has(node.id)) { + setCollapsedNodes((current) => { + const next = new Set(current ?? []); + next.delete(node.id); + return next; + }); + if (buildCta && !hasShownCtaRef.current) { + hasShownCtaRef.current = true; + window.setTimeout(() => setShowBuildCta(true), 600); + } + return; + } + + if (showCallout && calloutNodeId === node.id) dismissCallout(); + + if (nodePrimaryAction === 'focus') { + focusNode(node as GraphNode & { x?: number; y?: number; z?: number }); + return; + } + + if (onOpenNode) { + onOpenNode(node); + return; + } + + if (node.path) window.location.href = node.path; + }, [buildCta, calloutNodeId, collapsedNodes, dismissCallout, focusNode, ignoreCollapsible, nodePrimaryAction, onOpenNode, showCallout, stopAnimation]); + + const handleNodeRightClick = useCallback((rawNode: object, event: MouseEvent) => { + if (animationStateRef.current.running) { + stopAnimation(); + } + event.preventDefault(); + const node = rawNode as GraphNode & { x?: number; y?: number; z?: number }; + if (showNodeContextMenu && node.type === 'file') { + const rect = containerRef.current?.getBoundingClientRect(); + const menuWidth = 152; + const menuHeight = 48; + const x = rect + ? Math.min(event.clientX - rect.left, rect.width - menuWidth - 12) + : event.clientX; + const y = rect + ? Math.min(event.clientY - rect.top, rect.height - menuHeight - 12) + : event.clientY; + setNodeContextMenu({ + node, + x: Math.max(12, x), + y: Math.max(12, y), + }); + return; + } + + setNodeContextMenu(null); + focusNode(node, 1500); + }, [focusNode, showNodeContextMenu, stopAnimation]); + + const getLinkVisuals = useCallback((rawLink: object) => { + const link = rawLink as { type: string; source: unknown; target: unknown }; + const src = resolveId(link.source); + const tgt = resolveId(link.target); + const isConnected = highlightedTag !== null && highlightedNodeIds.has(src) && highlightedNodeIds.has(tgt); + + const baseHue = + link.type === 'file-tag' + ? '#e74c3c' + : link.type === 'tag-hierarchy' + ? '#e67e22' + : isDark + ? '#ffffff' + : '#111111'; + + if (highlightedTag !== null) { + if (!isConnected) { + return { + hue: baseHue, + lineOpacity: link.type === 'wikilink' ? 0.035 : 0.05, + particleOpacity: 0, + particleCount: 0, + }; + } + + return { + hue: + link.type === 'file-tag' + ? themeTokens.accent + : link.type === 'tag-hierarchy' + ? themeTokens.accentHover + : isDark + ? '#ffffff' + : '#111111', + lineOpacity: link.type === 'wikilink' ? 0.78 : 0.88, + particleOpacity: link.type === 'wikilink' ? 0.94 : 0.88, + particleCount: 1, + }; + } + + return { + hue: baseHue, + lineOpacity: + link.type === 'file-tag' + ? 0.26 + : link.type === 'tag-hierarchy' + ? 0.22 + : 0.13, + particleOpacity: + link.type === 'file-tag' + ? 0.76 + : link.type === 'tag-hierarchy' + ? 0.68 + : 0.84, + particleCount: 1, + }; + }, [highlightedNodeIds, highlightedTag, isDark, themeTokens]); + + const linkColor = useCallback((rawLink: object) => { + return getLinkVisuals(rawLink).hue; + }, [getLinkVisuals]); + + const linkMaterial = useCallback((rawLink: object) => { + const { hue, lineOpacity } = getLinkVisuals(rawLink); + const key = `${hue}|${lineOpacity.toFixed(3)}`; + const cached = linkMaterialCacheRef.current.get(key); + if (cached) return cached; + + const material = new THREE.MeshBasicMaterial({ + color: new THREE.Color(hue), + transparent: lineOpacity < 1, + opacity: lineOpacity, + depthWrite: lineOpacity >= 1, + }); + linkMaterialCacheRef.current.set(key, material); + return material; + }, [getLinkVisuals]); + + const resolvedParticleSpeed = useMemo( + () => lerp(0.0015, 0.012, clamp(particleSpeedValue, 0, 1)), + [particleSpeedValue], + ); + + const particleSpeedAccessor = useCallback(() => { + return resolvedParticleSpeed; + }, [resolvedParticleSpeed]); + + const particleOffset = useCallback((rawLink: object) => { + const link = rawLink as { type: string; source: unknown; target: unknown }; + const randomness = clamp(particleRandomness, 0, 1); + if (randomness <= 0.001) return 0; + + const key = `${resolveId(link.source)}->${resolveId(link.target)}:${link.type}`; + const offsetSeed = hashToUnit(key, 73); + return offsetSeed * randomness; + }, [particleRandomness]); + + const particleColor = useCallback((rawLink: object) => { + return getLinkVisuals(rawLink).hue; + }, [getLinkVisuals]); + + const particleCount = useCallback((rawLink: object) => { + return getLinkVisuals(rawLink).particleCount; + }, [getLinkVisuals]); + + const particleThreeObject = useCallback((rawLink: object) => { + const link = rawLink as { + type: string; + source: unknown; + target: unknown; + __particleTemplate?: THREE.Mesh; + }; + const key = `${resolveId(link.source)}->${resolveId(link.target)}:${link.type}`; + const { hue, particleOpacity } = getLinkVisuals(rawLink); + const widthSeed = hashToUnit(key, 197); + + if (particleStyle === 'trail') { + const lengthSeed = hashToUnit(key, 131); + const trailLengthFactor = lerp(0.55, 2, clamp(particleTrailLength, 0, 1)); + const length = + (link.type === 'wikilink' ? 4.2 : link.type === 'file-tag' ? 3.5 : 2.9) + + lengthSeed * 1.1; + const resolvedLength = length * trailLengthFactor; + const tailRadius = 0.03 + widthSeed * 0.06; + const headRadius = tailRadius * 2.4; + const geometry = new THREE.CylinderGeometry( + tailRadius, + headRadius, + resolvedLength, + 10, + 1, + true, + ); + geometry.rotateX(Math.PI / 2); + geometry.translate(0, 0, -resolvedLength / 2); + + const material = new THREE.MeshBasicMaterial({ + color: new THREE.Color(hue), + transparent: true, + opacity: particleOpacity, + depthWrite: false, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + }); + + const mesh = new THREE.Mesh(geometry, material); + link.__particleTemplate = mesh; + return mesh; + } + + const radius = 0.18 + widthSeed * 0.1; + const geometry = new THREE.SphereGeometry(radius, 10, 10); + const material = new THREE.MeshBasicMaterial({ + color: new THREE.Color(hue), + transparent: true, + opacity: particleOpacity, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + + const mesh = new THREE.Mesh(geometry, material); + link.__particleTemplate = mesh; + return mesh; + }, [getLinkVisuals, particleStyle, particleTrailLength]); + + useEffect(() => { + if (!impactGlow) { + impactPulsesRef.current.clear(); + nodeVisualsRef.current.forEach((visual) => { + const material = visual.glowSprite.material as THREE.SpriteMaterial; + material.opacity = 0; + visual.glowSprite.scale.set(visual.baseScale, visual.baseScale, 1); + }); + return; + } + + if (!visibleData.links.length) { + impactPulsesRef.current.clear(); + return; + } + + const progressByLink = new Map(); + const pulseDurationMs = 300; + let rafId = 0; + let lastNow = performance.now(); + + const triggerImpact = (nodeId: string, now: number) => { + const current = impactPulsesRef.current.get(nodeId); + impactPulsesRef.current.set(nodeId, { + startedAt: now, + until: Math.max(current?.until ?? 0, now + pulseDurationMs), + }); + }; + + const animate = (now: number) => { + const deltaFrames = clamp((now - lastNow) / (1000 / 60), 0, 6); + lastNow = now; + + visibleData.links.forEach((link) => { + const linkKey = `${resolveId(link.source)}->${resolveId(link.target)}:${link.type}`; + const offset = particleOffset(link); + const baseProgress = progressByLink.get(linkKey) ?? offset; + const nextProgress = baseProgress + resolvedParticleSpeed * deltaFrames; + + if (Math.floor(nextProgress) > Math.floor(baseProgress)) { + triggerImpact(resolveId(link.target), now); + } + + progressByLink.set(linkKey, nextProgress); + }); + + nodeVisualsRef.current.forEach((visual, nodeId) => { + const pulse = impactPulsesRef.current.get(nodeId); + const progress = pulse + ? clamp((now - pulse.startedAt) / (pulse.until - pulse.startedAt), 0, 1) + : 1; + const pulseStrength = pulse ? Math.sin(progress * Math.PI) * 0.85 : 0; + const easedStrength = easeOutCubic(pulseStrength); + + const material = visual.glowSprite.material as THREE.SpriteMaterial; + material.opacity = easedStrength * 0.575; + const scale = visual.baseScale * (1 + easedStrength * 0.24); + visual.glowSprite.scale.set(scale, scale, 1); + + if (pulse && now >= pulse.until) { + impactPulsesRef.current.delete(nodeId); + } + }); + + rafId = requestAnimationFrame(animate); + }; + + rafId = requestAnimationFrame(animate); + return () => cancelAnimationFrame(rafId); + }, [impactGlow, particleOffset, resolvedParticleSpeed, visibleData.links]); + + if (loadError) { + return ( +
+ + + + + + Could not load graph + {loadError} +
+ ); + } + + const hasGraph = Boolean(graphData); + const hasNodes = (graphData?.nodes.length ?? 0) > 0; + + return ( +
+ {hasGraph && hasNodes && dimensions.width > 0 && dimensions.height > 0 ? ( + + ) : ( +
+ {hasGraph ? emptyMessage : 'Loading graph...'} +
+ )} + +
+ +
+ + {ghostTooltip ? ( +
+ Note not yet created +
+ ) : null} + + {nodeContextMenu ? ( +
+ +
+ ) : null} + + {highlightedTag !== null ? ( +
+ + Filtering by {formatNodeName(graphData?.nodes.find((node) => node.id === highlightedTag) ?? createFallbackTagNode(highlightedTag, themeTokens.accent))} + + +
+ ) : null} + + {hasGraph && hasNodes && showInlineLabelToggle && onShowAllLabelsChange ? ( + + ) : null} + + {showCallout && calloutPos && calloutTarget ? ( + <> + {[0, 0.85].map((delay) => ( +
+ ))} + + + + + + + + + + +
{ + event.stopPropagation(); + dismissCallout(); + }} + style={{ + position: 'absolute', + left: calloutPos.x - 110, + top: calloutPos.y - 75, + transform: 'translate(-50%, -100%)', + animation: 'gb-float 2.4s ease-in-out infinite, gb-fadein 0.6s ease', + zIndex: 99999, + pointerEvents: 'all', + cursor: 'pointer', + background: themeTokens.calloutBackground, + border: `1px solid ${calloutTarget.color ?? themeTokens.accent}99`, + borderRadius: 12, + padding: '10px 16px', + color: themeTokens.calloutText, + fontSize: 13, + fontFamily: themeTokens.fontFamily, + textAlign: 'center', + backdropFilter: 'blur(6px)', + userSelect: 'none', + whiteSpace: 'nowrap', + boxShadow: `0 4px 20px ${calloutTarget.color ?? themeTokens.accent}33`, + }} + title="Click to dismiss" + > +
+ {calloutLabel || calloutTarget.calloutText || 'Click to get started'} +
+
+ + ) : null} + + {hasGraph && hasNodes && showHintBar ? ( +
+ {isHintBarExpanded ? ( +
+ {hintText} +
+ ) : null} + + +
+ ) : null} + + {showBuildCta && buildCta ? buildCta(() => setShowBuildCta(false)) : null} +
+ ); +}); + +export default InteractiveGraph; diff --git a/src/integrations/graph-builder.ts b/src/integrations/graph-builder.ts index 84368fe..d6f7a1c 100644 --- a/src/integrations/graph-builder.ts +++ b/src/integrations/graph-builder.ts @@ -15,32 +15,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import fg from 'fast-glob'; -import { parseNote, slugify } from '../lib/vault-parser.js'; -import { buildResolverIndex, resolveWikilink } from '../lib/link-resolver.js'; -import type { GraphNode, GraphLink, GraphData, NodeShape } from '../lib/types.js'; -import type { NoteGraphData, NoteRef } from '../lib/graph-types.js'; +import { buildGraphData, buildNoteGraphData } from '../lib/graph-core.js'; +import { parseNote } from '../lib/vault-parser.js'; import { detectGitHubPagesBase, withBasePath } from '../lib/hosting'; -// ─── Tag colour palette ─────────────────────────────────────────────────────── - -const TAG_COLORS = [ - '#FF6B6B', '#FFA726', '#FFEE58', '#66BB6A', '#26C6DA', - '#42A5F5', '#7E57C2', '#AB47BC', '#EC407A', - '#8D6E63', '#78909C', '#D4E157', -]; - -function hashString(s: string): number { - let h = 5381; - for (let i = 0; i < s.length; i++) { - h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; // unsigned 32-bit - } - return h; -} - -function tagColor(topLevelFamily: string): string { - return TAG_COLORS[hashString(topLevelFamily) % TAG_COLORS.length]; -} - // ─── Core builder ───────────────────────────────────────────────────────────── async function buildGraph(projectRoot: string, logger?: { info: (s: string) => void; warn: (s: string) => void }): Promise { @@ -79,154 +57,14 @@ async function buildGraph(projectRoot: string, logger?: { info: (s: string) => v const publishedNotes = allNotes.filter((n) => n.frontmatter.publish === true); log.info(`${publishedNotes.length} notes have publish: true`); - // ── 4. Build resolver index (all notes → only resolves to published) ─────── - const index = buildResolverIndex(publishedNotes); - - // ── 5. Collect all unique tags from published notes ──────────────────────── - const allTagNames = new Set(); - for (const note of publishedNotes) { - for (const tag of note.tags) allTagNames.add(tag); - } - - // ── 6. Build node maps ───────────────────────────────────────────────────── - - const nodeMap = new Map(); - - // File nodes - for (const note of publishedNotes) { - const fm = note.frontmatter; - const node: GraphNode = { - id: note.id, - name: fm.title ?? note.id, - type: 'file', - path: withBasePath(`/notes/${note.id}`, basePath), - val: 1, - shape: (fm.graph?.shape as NodeShape) ?? 'sphere', - color: fm.graph?.color ?? '#3498db', - collapsible: fm.graph?.collapsible ?? false, - pinned: fm.graph?.pinned ?? false, - callout: fm.graph?.callout ?? false, - calloutText: fm.graph?.calloutText ?? 'Click to get started', - excerpt: note.excerpt || null, - }; - nodeMap.set(note.id, node); - } - - // Tag nodes - for (const tagName of allTagNames) { - const tagId = `tag:${tagName}`; - const topLevel = tagName.split('/')[0]; - const node: GraphNode = { - id: tagId, - name: `#${tagName}`, - type: 'tag', - path: null, - val: 1, - shape: 'octahedron', - color: tagColor(topLevel), - collapsible: false, - pinned: false, - callout: false, - calloutText: '', - excerpt: null, - }; - nodeMap.set(tagId, node); - } - - // ── 7. Build links + collect ghost node targets ──────────────────────────── - - const links: GraphLink[] = []; - // Track ghost targets we haven't created nodes for yet - const ghostTargets = new Map(); // ghostId → display name - - for (const note of publishedNotes) { - // Wikilinks - for (const target of note.wikilinks) { - const resolved = resolveWikilink(target, index); - if (resolved) { - // file → file only if distinct - if (resolved.id !== note.id) { - links.push({ source: note.id, target: resolved.id, type: 'wikilink' }); - } - } else { - // Unresolved → ghost node - const ghostId = `ghost:${slugify(target)}`; - if (!ghostTargets.has(ghostId)) { - ghostTargets.set(ghostId, target); - } - links.push({ source: note.id, target: ghostId, type: 'wikilink' }); - } - } - - // File → tag links - for (const tag of note.tags) { - links.push({ source: note.id, target: `tag:${tag}`, type: 'file-tag' }); - } - } - - // Ghost nodes - for (const [ghostId, displayName] of ghostTargets) { - const node: GraphNode = { - id: ghostId, - name: displayName, - type: 'ghost', - path: null, - val: 1, - shape: 'sphere', - color: '#ffffff', - collapsible: false, - pinned: false, - callout: false, - calloutText: '', - excerpt: null, - }; - nodeMap.set(ghostId, node); - } - - // Tag hierarchy links (tag:programming → tag:programming/systems) - for (const tagName of allTagNames) { - const parts = tagName.split('/'); - if (parts.length > 1) { - const parentTag = parts.slice(0, -1).join('/'); - if (allTagNames.has(parentTag)) { - links.push({ - source: `tag:${parentTag}`, - target: `tag:${tagName}`, - type: 'tag-hierarchy', - }); - } - } - } - - // ── 8. Deduplicate links (same source+target+type may appear multiple times) ─ - - const seenLinks = new Set(); - const dedupedLinks: GraphLink[] = []; - for (const link of links) { - const key = `${link.source}→${link.target}→${link.type}`; - if (!seenLinks.has(key)) { - seenLinks.add(key); - dedupedLinks.push(link); - } - } - - // ── 9. Compute val (link count) per node ─────────────────────────────────── - - const linkCount = new Map(); - const increment = (id: string) => linkCount.set(id, (linkCount.get(id) ?? 0) + 1); - for (const link of dedupedLinks) { - increment(link.source); - increment(link.target); - } - - for (const [id, node] of nodeMap) { - node.val = Math.max(1, linkCount.get(id) ?? 0); - } - - const nodes = [...nodeMap.values()]; - const fullGraph: GraphData = { nodes, links: dedupedLinks }; + // ── 4. Build full graph JSON using the shared graph core ─────────────────── + const fullGraph = buildGraphData(allNotes, { + visibility: 'publish-only', + includeCallouts: true, + mapNotePath: (note) => withBasePath(`/notes/${note.id}`, basePath), + }); - // ── 10. Write public/graph.json ──────────────────────────────────────────── + // ── 5. Write public/graph.json ───────────────────────────────────────────── if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true }); writeFileSync( @@ -234,71 +72,16 @@ async function buildGraph(projectRoot: string, logger?: { info: (s: string) => v JSON.stringify(fullGraph, null, 2), 'utf-8', ); - log.info(`Wrote public/graph.json (${nodes.length} nodes, ${dedupedLinks.length} links)`); + log.info(`Wrote public/graph.json (${fullGraph.nodes.length} nodes, ${fullGraph.links.length} links)`); - // ── 11. Write per-note public/graph/[id].json ────────────────────────────── + // ── 6. Write per-note public/graph/[id].json ─────────────────────────────── if (!existsSync(graphDir)) mkdirSync(graphDir, { recursive: true }); - // Pre-build adjacency: nodeId → Set of adjacent nodeIds - const adjacency = new Map>(); - const addEdge = (a: string, b: string) => { - if (!adjacency.has(a)) adjacency.set(a, new Set()); - if (!adjacency.has(b)) adjacency.set(b, new Set()); - adjacency.get(a)!.add(b); - adjacency.get(b)!.add(a); - }; - for (const link of dedupedLinks) addEdge(link.source, link.target); - - // Pre-build backlink map: noteId → Set of noteIds that link TO it (wikilinks only) - const backlinkMap = new Map>(); - for (const link of dedupedLinks) { - if (link.type !== 'wikilink') continue; - if (!backlinkMap.has(link.target)) backlinkMap.set(link.target, new Set()); - backlinkMap.get(link.target)!.add(link.source); - } - for (const note of publishedNotes) { - const centerNodeId = note.id; - const neighbors = adjacency.get(centerNodeId) ?? new Set(); - const subsetIds = new Set([centerNodeId, ...neighbors]); - - // Nodes in 1-hop neighbourhood - const subNodes = [...subsetIds] - .map((id) => nodeMap.get(id)) - .filter(Boolean) as GraphNode[]; - - // Links where BOTH endpoints are in the subset - const subLinks = dedupedLinks.filter( - (l) => subsetIds.has(l.source) && subsetIds.has(l.target), - ); - - // Backlinks: file nodes that wikilink TO this note - const backlinkIds = backlinkMap.get(centerNodeId) ?? new Set(); - const backlinks: NoteRef[] = [...backlinkIds] - .map((id) => nodeMap.get(id)) - .filter((n): n is GraphNode => n?.type === 'file') - .map((n) => ({ id: n.id, name: n.name, path: n.path })); - - // Forward links: file nodes this note wikilinks TO - const forwardLinkIds = dedupedLinks - .filter((l) => l.source === centerNodeId && l.type === 'wikilink') - .map((l) => l.target); - const forwardLinks: NoteRef[] = forwardLinkIds - .map((id) => nodeMap.get(id)) - .filter((n): n is GraphNode => n?.type === 'file') - .map((n) => ({ id: n.id, name: n.name, path: n.path })); - - const noteGraph: NoteGraphData = { - nodes: subNodes, - links: subLinks, - backlinks, - forwardLinks, - }; - writeFileSync( - path.join(graphDir, `${centerNodeId}.json`), - JSON.stringify(noteGraph, null, 2), + path.join(graphDir, `${note.id}.json`), + JSON.stringify(buildNoteGraphData(fullGraph, note.id), null, 2), 'utf-8', ); } diff --git a/src/lib/graph-core.ts b/src/lib/graph-core.ts new file mode 100644 index 0000000..d00fde9 --- /dev/null +++ b/src/lib/graph-core.ts @@ -0,0 +1,231 @@ +import type { NoteGraphData, NoteRef } from './graph-types'; +import { buildResolverIndex, resolveWikilink } from './link-resolver'; +import { slugify } from './vault-parser'; +import type { GraphData, GraphLink, GraphNode, NodeShape, ParsedNote } from './types'; + +export interface BuildGraphDataOptions { + visibility?: 'all' | 'publish-only'; + mapNotePath?: (note: ParsedNote) => string | null; + includeCallouts?: boolean; +} + +const TAG_COLORS = [ + '#FF6B6B', '#FFA726', '#FFEE58', '#66BB6A', '#26C6DA', + '#42A5F5', '#7E57C2', '#AB47BC', '#EC407A', + '#8D6E63', '#78909C', '#D4E157', +]; + +function hashString(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) { + h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; + } + return h; +} + +function tagColor(topLevelFamily: string): string { + return TAG_COLORS[hashString(topLevelFamily) % TAG_COLORS.length]; +} + +function selectVisibleNotes( + notes: ParsedNote[], + visibility: BuildGraphDataOptions['visibility'], +): ParsedNote[] { + if (visibility === 'publish-only') { + return notes.filter((note) => note.frontmatter.publish === true); + } + return notes; +} + +export function buildGraphData( + notes: ParsedNote[], + options: BuildGraphDataOptions = {}, +): GraphData { + const { + visibility = 'publish-only', + mapNotePath = () => null, + includeCallouts = true, + } = options; + + const visibleNotes = selectVisibleNotes(notes, visibility); + const index = buildResolverIndex(visibleNotes); + const allTagNames = new Set(); + + for (const note of visibleNotes) { + for (const tag of note.tags) allTagNames.add(tag); + } + + const nodeMap = new Map(); + + for (const note of visibleNotes) { + const fm = note.frontmatter; + nodeMap.set(note.id, { + id: note.id, + name: fm.title ?? note.id, + type: 'file', + path: mapNotePath(note), + val: 1, + shape: (fm.graph?.shape as NodeShape) ?? 'sphere', + color: fm.graph?.color ?? '#3498db', + colorSource: fm.graph?.color ? 'manual' : 'default', + collapsible: fm.graph?.collapsible ?? false, + pinned: fm.graph?.pinned ?? false, + callout: includeCallouts ? (fm.graph?.callout ?? false) : false, + calloutText: includeCallouts ? (fm.graph?.calloutText ?? 'Click to get started') : '', + excerpt: note.excerpt || null, + }); + } + + for (const tagName of allTagNames) { + const topLevel = tagName.split('/')[0]; + nodeMap.set(`tag:${tagName}`, { + id: `tag:${tagName}`, + name: `#${tagName}`, + type: 'tag', + path: null, + val: 1, + shape: 'octahedron', + color: tagColor(topLevel), + colorSource: 'tag', + collapsible: false, + pinned: false, + callout: false, + calloutText: '', + excerpt: null, + }); + } + + const links: GraphLink[] = []; + const ghostTargets = new Map(); + + for (const note of visibleNotes) { + for (const target of note.wikilinks) { + const resolved = resolveWikilink(target, index, visibility === 'publish-only'); + if (resolved) { + if (resolved.id !== note.id) { + links.push({ source: note.id, target: resolved.id, type: 'wikilink' }); + } + } else { + const ghostId = `ghost:${slugify(target)}`; + if (!ghostTargets.has(ghostId)) ghostTargets.set(ghostId, target); + links.push({ source: note.id, target: ghostId, type: 'wikilink' }); + } + } + + for (const tag of note.tags) { + links.push({ source: note.id, target: `tag:${tag}`, type: 'file-tag' }); + } + } + + for (const [ghostId, displayName] of ghostTargets) { + nodeMap.set(ghostId, { + id: ghostId, + name: displayName, + type: 'ghost', + path: null, + val: 1, + shape: 'sphere', + color: '#ffffff', + colorSource: 'ghost', + collapsible: false, + pinned: false, + callout: false, + calloutText: '', + excerpt: null, + }); + } + + for (const tagName of allTagNames) { + const parts = tagName.split('/'); + if (parts.length < 2) continue; + + const parentTag = parts.slice(0, -1).join('/'); + if (!allTagNames.has(parentTag)) continue; + + links.push({ + source: `tag:${parentTag}`, + target: `tag:${tagName}`, + type: 'tag-hierarchy', + }); + } + + const seenLinks = new Set(); + const dedupedLinks: GraphLink[] = []; + for (const link of links) { + const key = `${link.source}→${link.target}→${link.type}`; + if (seenLinks.has(key)) continue; + seenLinks.add(key); + dedupedLinks.push(link); + } + + const linkCount = new Map(); + const increment = (id: string) => linkCount.set(id, (linkCount.get(id) ?? 0) + 1); + for (const link of dedupedLinks) { + increment(link.source); + increment(link.target); + } + + for (const [id, node] of nodeMap) { + node.val = Math.max(1, linkCount.get(id) ?? 0); + } + + return { + nodes: [...nodeMap.values()], + links: dedupedLinks, + }; +} + +export function buildNoteGraphData( + graphData: GraphData, + noteId: string, +): NoteGraphData { + const nodeMap = new Map(graphData.nodes.map((node) => [node.id, node])); + const adjacency = new Map>(); + const backlinkMap = new Map>(); + + const addEdge = (a: string, b: string) => { + if (!adjacency.has(a)) adjacency.set(a, new Set()); + if (!adjacency.has(b)) adjacency.set(b, new Set()); + adjacency.get(a)?.add(b); + adjacency.get(b)?.add(a); + }; + + for (const link of graphData.links) { + addEdge(link.source, link.target); + + if (link.type !== 'wikilink') continue; + if (!backlinkMap.has(link.target)) backlinkMap.set(link.target, new Set()); + backlinkMap.get(link.target)?.add(link.source); + } + + const neighbors = adjacency.get(noteId) ?? new Set(); + const subsetIds = new Set([noteId, ...neighbors]); + + const asNoteRef = (node: GraphNode): NoteRef => ({ + id: node.id, + name: node.name, + path: node.path, + }); + + const backlinks: NoteRef[] = [...(backlinkMap.get(noteId) ?? new Set())] + .map((id) => nodeMap.get(id)) + .filter((node): node is GraphNode => node?.type === 'file') + .map(asNoteRef); + + const forwardLinks: NoteRef[] = graphData.links + .filter((link) => link.source === noteId && link.type === 'wikilink') + .map((link) => nodeMap.get(link.target)) + .filter((node): node is GraphNode => node?.type === 'file') + .map(asNoteRef); + + return { + nodes: [...subsetIds] + .map((id) => nodeMap.get(id)) + .filter((node): node is GraphNode => Boolean(node)), + links: graphData.links.filter( + (link) => subsetIds.has(link.source) && subsetIds.has(link.target), + ), + backlinks, + forwardLinks, + }; +} diff --git a/src/lib/graph-ui.ts b/src/lib/graph-ui.ts new file mode 100644 index 0000000..6a1ab49 --- /dev/null +++ b/src/lib/graph-ui.ts @@ -0,0 +1,68 @@ +export type GraphAnimationMode = 'none' | 'camera-tour' | 'orbit-settle'; +export type GraphParticleStyle = 'dot' | 'trail'; + +export interface GraphAnimationState { + mode: GraphAnimationMode; + running: boolean; +} + +export interface GraphThemeTokens { + mode: 'dark' | 'light'; + fontFamily: string; + background: string; + panelBackground: string; + panelBackgroundMuted: string; + panelBorder: string; + textNormal: string; + textMuted: string; + accent: string; + accentHover: string; + accentText: string; + error: string; + labelText: string; + labelOutline: string; + calloutBackground: string; + calloutText: string; +} + +export function createDefaultGraphThemeTokens(mode: 'dark' | 'light'): GraphThemeTokens { + if (mode === 'light') { + return { + mode, + fontFamily: 'sans-serif', + background: '#f5f5f5', + panelBackground: 'rgba(240,240,240,0.9)', + panelBackgroundMuted: 'rgba(255,255,255,0.76)', + panelBorder: 'rgba(0,0,0,0.12)', + textNormal: '#111111', + textMuted: 'rgba(17,17,17,0.62)', + accent: '#2d72d9', + accentHover: '#1f5cb7', + accentText: '#ffffff', + error: '#c23b30', + labelText: '#111111', + labelOutline: 'rgba(255,255,255,0.92)', + calloutBackground: 'rgba(220,235,255,0.94)', + calloutText: '#1a5fa8', + }; + } + + return { + mode, + fontFamily: 'sans-serif', + background: '#0a0a0a', + panelBackground: 'rgba(20,20,20,0.9)', + panelBackgroundMuted: 'rgba(0,0,0,0.55)', + panelBorder: 'rgba(255,255,255,0.12)', + textNormal: '#e0e0e0', + textMuted: 'rgba(224,224,224,0.62)', + accent: '#5aa0ff', + accentHover: '#7fb6ff', + accentText: '#08111f', + error: '#ff6b6b', + labelText: '#f2f4f8', + labelOutline: 'rgba(0,0,0,0.8)', + calloutBackground: 'rgba(10,30,60,0.88)', + calloutText: '#74b9ff', + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 420067e..765ed7c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,6 +12,7 @@ export type NodeShape = export type NodeType = 'file' | 'ghost' | 'tag'; export type LinkType = 'wikilink' | 'file-tag' | 'tag-hierarchy'; +export type NodeColorSource = 'manual' | 'default' | 'tag' | 'ghost'; export interface GraphNode { id: string; @@ -22,6 +23,7 @@ export interface GraphNode { val: number; shape: NodeShape; color: string; + colorSource?: NodeColorSource; /** When true, downstream nodes start hidden until user clicks this node */ collapsible: boolean; /** When true, this node is always visible as a root seed regardless of incoming links */ diff --git a/src/lib/vault-parser.ts b/src/lib/vault-parser.ts index 5205621..777c7a8 100644 --- a/src/lib/vault-parser.ts +++ b/src/lib/vault-parser.ts @@ -114,6 +114,44 @@ export function parseNote( rawContent: string, absolutePath: string, vaultRoot: string, +): ParsedNote { + const relativePath = path + .relative(vaultRoot, absolutePath) + .replace(/\\/g, '/'); + + return parseNoteWithPaths( + rawContent, + absolutePath.replace(/\\/g, '/'), + relativePath, + path.basename(absolutePath, '.md'), + ); +} + +/** + * Parse a vault note when only its vault-relative path is available. + * + * This is used by the Obsidian plugin, which reads notes through `app.vault` + * and therefore doesn't need or always have a stable absolute filesystem path. + */ +export function parseVaultNote( + rawContent: string, + relativePath: string, +): ParsedNote { + const normalizedPath = relativePath.replace(/\\/g, '/'); + + return parseNoteWithPaths( + rawContent, + normalizedPath, + normalizedPath, + path.posix.basename(normalizedPath, '.md'), + ); +} + +function parseNoteWithPaths( + rawContent: string, + filePath: string, + relativePath: string, + title: string, ): ParsedNote { // ── Strip leading blank lines ────────────────────────────────────────────── // gray-matter silently returns empty data ({}) when a file starts with @@ -129,27 +167,21 @@ export function parseNote( try { parsed = matter(sanitised); } catch { - console.warn(`[vault-parser] Failed to parse frontmatter in ${absolutePath} — treating as unpublished`); + console.warn(`[vault-parser] Failed to parse frontmatter in ${filePath} — treating as unpublished`); parsed = matter(''); parsed.content = sanitised; } // An empty frontmatter data object means publish is undefined → treated as false. const fm = (parsed.data ?? {}) as NoteFrontmatter; - // Compute relative path from vault/ root - const relativePath = path - .relative(vaultRoot, absolutePath) - .replace(/\\/g, '/'); - // Derive slug from filename (without .md) - const filename = path.basename(absolutePath); - const id = slugify(filename); + const id = slugify(title); // The Obsidian filename (without .md) is the canonical note title. // It always overrides whatever is in the frontmatter `title` field so that // renaming a note in Obsidian is consistently reflected in the graph and on // the rendered page — Obsidian already enforces unique filenames, so this is safe. - fm.title = path.basename(absolutePath, '.md'); + fm.title = title; const body = parsed.content; @@ -170,7 +202,7 @@ export function parseNote( return { id, - filePath: absolutePath.replace(/\\/g, '/'), + filePath, relativePath, frontmatter: fm, content: body,