diff --git a/web/src/explorers/ForceGraph/ForceGraph.tsx b/web/src/explorers/ForceGraph/ForceGraph.tsx index 9eb80b7e..5cce2eea 100644 --- a/web/src/explorers/ForceGraph/ForceGraph.tsx +++ b/web/src/explorers/ForceGraph/ForceGraph.tsx @@ -13,7 +13,7 @@ * disabled. */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Canvas } from '@react-three/fiber'; import * as THREE from 'three'; import { Flame } from 'lucide-react'; @@ -79,6 +79,9 @@ export const ForceGraph: React.FC< // filtered data. Reading them via individual selectors keeps zustand's // shallow equality from re-rendering on unrelated store updates. const minConfidence = useGraphStore((s) => s.filters.minConfidence); + const relationshipTypes = useGraphStore((s) => s.filters.relationshipTypes); + const ontologies = useGraphStore((s) => s.filters.ontologies); + const setFilterOptions = useGraphStore((s) => s.setFilterOptions); const appliedTheme = useThemeStore((s) => s.appliedTheme); const canvasBg = explorerTheme.canvas3D[appliedTheme]; const { @@ -190,21 +193,54 @@ export const ForceGraph: React.FC< [vocabStore] ); - // Apply shared-store filters. Both are universal — every explorer - // reads from the same store fields, so a filter set in one place - // applies everywhere. Empty / zero means "show all". + // Publish the distinct relationship types / ontologies from the + // engine data (the SAME strings the filter compares against) so the + // universal filter UI can offer them without taking graph data as a + // prop. Deriving from raw API data instead would mismatch — the + // raw→engine transform maps empty ontology to 'Unknown'. Each option + // carries the colour the graph renders it in so the selector swatch + // matches the screen: relationship type → category colour (the + // canonical/default edge colouring, mirroring the edgeColors 'type' + // branch); ontology → the ontology palette colour. + useEffect(() => { + const rels = [...new Set((data?.edges ?? []).map((e) => e.type))] + .filter(Boolean) + .sort() + .map((value) => ({ + value, + color: getCategoryColor(vocabStore.getCategory(value) || undefined), + })); + const onts = [...new Set((data?.nodes ?? []).map((n) => n.category))] + .filter(Boolean) + .sort() + .map((value) => ({ value, color: palette(value) })); + setFilterOptions({ relationshipTypes: rels, ontologies: onts }); + }, [data, setFilterOptions, vocabStore, palette]); + + // Apply shared-store filters. All universal — every explorer reads + // the same store fields, so a filter set in one place applies + // everywhere. Empty / zero means "show all". The ontology filter + // drops nodes; edges orphaned by that removal are dropped too. const filteredData = useMemo(() => { if (!data) return data; const hasCatFilter = visibleEdgeCategories.size > 0; const hasConfFilter = minConfidence > 0; - if (!hasCatFilter && !hasConfFilter) return data; + const hasRelFilter = relationshipTypes.length > 0; + const hasOntFilter = ontologies.length > 0; + if (!hasCatFilter && !hasConfFilter && !hasRelFilter && !hasOntFilter) return data; + const relSet = hasRelFilter ? new Set(relationshipTypes) : null; + const ontSet = hasOntFilter ? new Set(ontologies) : null; + const nodes = ontSet ? data.nodes.filter((n) => ontSet.has(n.category)) : data.nodes; + const keptIds = ontSet ? new Set(nodes.map((n) => n.id)) : null; const edges = data.edges.filter((e) => { if (hasCatFilter && !visibleEdgeCategories.has(edgeCategory(e))) return false; if (hasConfFilter && (e.weight ?? 1) < minConfidence) return false; + if (relSet && !relSet.has(e.type)) return false; + if (keptIds && (!keptIds.has(e.from) || !keptIds.has(e.to))) return false; return true; }); - return { ...data, edges }; - }, [data, visibleEdgeCategories, minConfidence, edgeCategory]); + return { ...data, nodes, edges }; + }, [data, visibleEdgeCategories, minConfidence, relationshipTypes, ontologies, edgeCategory]); // Per-edge colors driven by edgeColorBy. Parallel to filteredData.edges // by index. Undefined means "use endpoint gradient" — the engine's diff --git a/web/src/explorers/ForceGraph/SettingsPanel.tsx b/web/src/explorers/ForceGraph/SettingsPanel.tsx index feef19f4..0d659bdb 100644 --- a/web/src/explorers/ForceGraph/SettingsPanel.tsx +++ b/web/src/explorers/ForceGraph/SettingsPanel.tsx @@ -16,7 +16,7 @@ import type { SettingsPanelProps } from '../../types/explorer'; import type { ForceGraphSettings } from './types'; import { SLIDER_RANGES } from './types'; import { simBackend } from './scene/useSim'; -import { useGraphStore } from '../../store/graphStore'; +import { useGraphStore, type FilterOption } from '../../store/graphStore'; type Section = 'physics' | 'visual' | 'interaction' | 'filters'; @@ -28,6 +28,9 @@ export const SettingsPanel: React.FC> = ( // Filters live in the shared store, not in per-plugin settings, so they // apply universally across every explorer that consumes rawGraphData. const minConfidence = useGraphStore((s) => s.filters.minConfidence); + const selectedRelationshipTypes = useGraphStore((s) => s.filters.relationshipTypes); + const selectedOntologies = useGraphStore((s) => s.filters.ontologies); + const filterOptions = useGraphStore((s) => s.filterOptions); const setFilters = useGraphStore((s) => s.setFilters); const toggle = (section: Section) => @@ -81,6 +84,79 @@ export const SettingsPanel: React.FC> = ( /> ); + // Multi-select checkbox list for the universal relationship-type / + // ontology filters. Empty selection = show all (matches the store + // convention used by minConfidence/visibleEdgeCategories). Each row + // carries the colour the graph renders that value in — without it, + // 50 same-looking rows are unreadable. select all / none keep large + // type lists usable. + const checkboxList = ( + label: string, + options: FilterOption[], + selected: string[], + onChange: (next: string[]) => void + ) => ( +
+
+ {label} + {options.length > 0 && ( + + + / + + + )} +
+ {options.length === 0 ? ( + None in current data + ) : ( +
+ {options.map((opt) => { + const checked = selected.includes(opt.value); + return ( + + ); + })} +
+ )} +
+ ); + const toggle_ = (value: boolean, onInput: (v: boolean) => void) => ( > = ( minConfidence.toFixed(2), slider(minConfidence, 0, 1, 0.01, (v) => setFilters({ minConfidence: v })) )} + {checkboxList( + 'Relationship types', + filterOptions.relationshipTypes, + selectedRelationshipTypes, + (next) => setFilters({ relationshipTypes: next }) + )} + {checkboxList( + 'Ontologies', + filterOptions.ontologies, + selectedOntologies, + (next) => setFilters({ ontologies: next }) + )}

- Filters edges by confidence (weight). Applies to every explorer - that reads from the shared graph data. + Min confidence filters edges by weight; relationship-type and + ontology selections narrow to the checked values (empty = show + all). All apply to every explorer reading the shared graph data.

)} diff --git a/web/src/store/graphStore.ts b/web/src/store/graphStore.ts index 9dd34aec..6c92e6f8 100644 --- a/web/src/store/graphStore.ts +++ b/web/src/store/graphStore.ts @@ -29,6 +29,13 @@ interface GraphFilters { visibleEdgeCategories: Set; // Track which edge categories are visible } +/** A selectable filter value plus the colour the graph renders it in, + * so the selector swatch matches what's on screen. */ +export interface FilterOption { + value: string; + color: string; +} + interface UISettings { showLabels: boolean; showLegend: boolean; @@ -167,6 +174,15 @@ interface GraphStore { filters: GraphFilters; setFilters: (filters: Partial) => void; resetFilters: () => void; + /** Distinct relationship types / ontologies present in the loaded + * data. Published by the active explorer from the SAME engine data + * the filter compares against, so the option strings match the + * filter exactly (the raw→engine transform normalizes empties to + * 'Unknown'/'Unknown' — deriving options from raw data would + * silently mismatch). Lets the universal filter UI offer choices + * without receiving graph data as a prop. */ + filterOptions: { relationshipTypes: FilterOption[]; ontologies: FilterOption[] }; + setFilterOptions: (options: { relationshipTypes: FilterOption[]; ontologies: FilterOption[] }) => void; toggleEdgeCategoryVisibility: (category: string) => void; setAllEdgeCategoriesVisible: (categories: string[], visible: boolean) => void; @@ -268,6 +284,11 @@ const defaultFilters: GraphFilters = { visibleEdgeCategories: new Set(), // Start with all visible (empty set = show all) }; +const defaultFilterOptions = { + relationshipTypes: [] as FilterOption[], + ontologies: [] as FilterOption[], +}; + const defaultUISettings: UISettings = { showLabels: true, showLegend: true, @@ -364,6 +385,9 @@ export const useGraphStore = create()( })), resetFilters: () => set({ filters: defaultFilters }), + filterOptions: defaultFilterOptions, + setFilterOptions: (filterOptions) => set({ filterOptions }), + // Toggle edge category visibility toggleEdgeCategoryVisibility: (category) => set((state) => {