From d6deb7c54c16ecea3927995e9817228f3618c26f Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 22:36:27 -0500 Subject: [PATCH 1/3] feat(web): multi-select relationship-type + ontology filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task #20. The shared store already declared filters.relationshipTypes and filters.ontologies (string[], default []); this wires the UI and filter application, mirroring the existing universal minConfidence pattern. - graphStore: add filterOptions {relationshipTypes, ontologies} + setter — the *available* values (distinct from filters.* which are *selected*). Ephemeral, not persisted (partialize unchanged). - ForceGraph: publish distinct e.type / n.category from the engine data (the SAME strings the filter compares against — deriving from raw API data would mismatch, since the raw->engine transform maps empty ontology to 'Unknown'). filteredData now also narrows by relationship type (edges) and ontology (nodes, dropping orphaned edges). Empty = show all, matching convention. - SettingsPanel: two checkbox-lists in the Filters section, options from store filterOptions, selection via setFilters — same shared-store, universal-across-explorers model as minConfidence. Scoped to Force Graph + universal store per the task; Document Explorer parity (separate data path) deferred. No select-all/none control (not requested). Pre-existing eslint debt in the touched files (unused RawGraphNode/RawGraphLink import, onNodeClick/mergeRawGraphData, one any) left as-is — out of scope, not introduced here, not CI-gated. Typecheck clean, 10 web tests pass, no new lint errors. --- web/src/explorers/ForceGraph/ForceGraph.tsx | 41 +++++++++--- .../explorers/ForceGraph/SettingsPanel.tsx | 62 ++++++++++++++++++- web/src/store/graphStore.ts | 17 +++++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/web/src/explorers/ForceGraph/ForceGraph.tsx b/web/src/explorers/ForceGraph/ForceGraph.tsx index 9eb80b7e..87da6f3a 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,45 @@ 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'. + useEffect(() => { + const rels = [...new Set((data?.edges ?? []).map((e) => e.type))] + .filter(Boolean) + .sort(); + const onts = [...new Set((data?.nodes ?? []).map((n) => n.category))] + .filter(Boolean) + .sort(); + setFilterOptions({ relationshipTypes: rels, ontologies: onts }); + }, [data, setFilterOptions]); + + // 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..521abe06 100644 --- a/web/src/explorers/ForceGraph/SettingsPanel.tsx +++ b/web/src/explorers/ForceGraph/SettingsPanel.tsx @@ -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,48 @@ 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). + const checkboxList = ( + label: string, + options: string[], + selected: string[], + onChange: (next: string[]) => void + ) => ( +
+
{label}
+ {options.length === 0 ? ( + None in current data + ) : ( +
+ {options.map((opt) => { + const checked = selected.includes(opt); + 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..cec993aa 100644 --- a/web/src/store/graphStore.ts +++ b/web/src/store/graphStore.ts @@ -167,6 +167,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: string[]; ontologies: string[] }; + setFilterOptions: (options: { relationshipTypes: string[]; ontologies: string[] }) => void; toggleEdgeCategoryVisibility: (category: string) => void; setAllEdgeCategoriesVisible: (categories: string[], visible: boolean) => void; @@ -268,6 +277,11 @@ const defaultFilters: GraphFilters = { visibleEdgeCategories: new Set(), // Start with all visible (empty set = show all) }; +const defaultFilterOptions = { + relationshipTypes: [] as string[], + ontologies: [] as string[], +}; + const defaultUISettings: UISettings = { showLabels: true, showLegend: true, @@ -364,6 +378,9 @@ export const useGraphStore = create()( })), resetFilters: () => set({ filters: defaultFilters }), + filterOptions: defaultFilterOptions, + setFilterOptions: (filterOptions) => set({ filterOptions }), + // Toggle edge category visibility toggleEdgeCategoryVisibility: (category) => set((state) => { From 3a58fe5118ce6785ece0b281df8df67449819bad Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 22:52:39 -0500 Subject: [PATCH 2/3] feat(web): colour-faithful filter selectors + select all/none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bounded consolidation step (the pin system, colour schemes, Legend reconciliation, and the raw-vs-grouped effector-model decision are deferred — captured in a design note for a future build-and-learn session, not an ADR; pre-build UX ADRs ossify into contrarian artifacts since feel is only known once built). - graphStore: filterOptions entries are now FilterOption {value,color} instead of bare strings. - ForceGraph: publishes each option's rendered colour from the SAME palette the scene uses — relationship type -> category colour (mirrors the edgeColors 'type' branch), ontology -> palette(ontology) — so swatch == on-screen colour. - SettingsPanel: each row shows its colour swatch (50 identical rows were unreadable — explicit user constraint), plus per-list all / none shortcuts. 'none' clears to [] = the store's documented empty-means-show-all (true hide-all is part of the deferred effector-model decision). tsc clean, 10/10 web tests, no new eslint. --- web/src/explorers/ForceGraph/ForceGraph.tsx | 17 +++++-- .../explorers/ForceGraph/SettingsPanel.tsx | 46 +++++++++++++++---- web/src/store/graphStore.ts | 15 ++++-- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/web/src/explorers/ForceGraph/ForceGraph.tsx b/web/src/explorers/ForceGraph/ForceGraph.tsx index 87da6f3a..5cce2eea 100644 --- a/web/src/explorers/ForceGraph/ForceGraph.tsx +++ b/web/src/explorers/ForceGraph/ForceGraph.tsx @@ -197,16 +197,25 @@ export const ForceGraph: React.FC< // 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'. + // 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(); + .sort() + .map((value) => ({ + value, + color: getCategoryColor(vocabStore.getCategory(value) || undefined), + })); const onts = [...new Set((data?.nodes ?? []).map((n) => n.category))] .filter(Boolean) - .sort(); + .sort() + .map((value) => ({ value, color: palette(value) })); setFilterOptions({ relationshipTypes: rels, ontologies: onts }); - }, [data, setFilterOptions]); + }, [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 diff --git a/web/src/explorers/ForceGraph/SettingsPanel.tsx b/web/src/explorers/ForceGraph/SettingsPanel.tsx index 521abe06..eae57b95 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'; @@ -86,24 +86,48 @@ 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). + // 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: string[], + options: FilterOption[], selected: string[], onChange: (next: string[]) => void ) => (
-
{label}
+
+ {label} + {options.length > 0 && ( + + + / + + + )} +
{options.length === 0 ? ( None in current data ) : (
{options.map((opt) => { - const checked = selected.includes(opt); + const checked = selected.includes(opt.value); return ( ); })} diff --git a/web/src/store/graphStore.ts b/web/src/store/graphStore.ts index cec993aa..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; @@ -174,8 +181,8 @@ interface GraphStore { * '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: string[]; ontologies: string[] }; - setFilterOptions: (options: { relationshipTypes: string[]; ontologies: string[] }) => void; + filterOptions: { relationshipTypes: FilterOption[]; ontologies: FilterOption[] }; + setFilterOptions: (options: { relationshipTypes: FilterOption[]; ontologies: FilterOption[] }) => void; toggleEdgeCategoryVisibility: (category: string) => void; setAllEdgeCategoriesVisible: (categories: string[], visible: boolean) => void; @@ -278,8 +285,8 @@ const defaultFilters: GraphFilters = { }; const defaultFilterOptions = { - relationshipTypes: [] as string[], - ontologies: [] as string[], + relationshipTypes: [] as FilterOption[], + ontologies: [] as FilterOption[], }; const defaultUISettings: UISettings = { From 2a0303c081c707d57e54d88e05aa3b0434cc9bcb Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 22:56:35 -0500 Subject: [PATCH 3/3] feat(web): colour the checkbox + label text in the edge/ontology colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swatch dot alone wasn't enough — the checkbox (accent-color) and the label text now also render in the value's rendered colour, matching how the Legend frame reads. tsc clean, 10/10 tests. --- web/src/explorers/ForceGraph/SettingsPanel.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/explorers/ForceGraph/SettingsPanel.tsx b/web/src/explorers/ForceGraph/SettingsPanel.tsx index eae57b95..0d659bdb 100644 --- a/web/src/explorers/ForceGraph/SettingsPanel.tsx +++ b/web/src/explorers/ForceGraph/SettingsPanel.tsx @@ -140,12 +140,15 @@ export const SettingsPanel: React.FC> = ( : [...selected, opt.value] ) } + style={{ accentColor: opt.color }} /> - {opt.value} + + {opt.value} + ); })}