Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions web/src/explorers/ForceGraph/ForceGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
95 changes: 92 additions & 3 deletions web/src/explorers/ForceGraph/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -28,6 +28,9 @@ export const SettingsPanel: React.FC<SettingsPanelProps<ForceGraphSettings>> = (
// 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) =>
Expand Down Expand Up @@ -81,6 +84,79 @@ export const SettingsPanel: React.FC<SettingsPanelProps<ForceGraphSettings>> = (
/>
);

// 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
) => (
<div className="pt-1">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-medium text-foreground">{label}</span>
{options.length > 0 && (
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<button
type="button"
className="hover:text-foreground transition-colors"
onClick={() => onChange(options.map((o) => o.value))}
>
all
</button>
<span>/</span>
<button
type="button"
className="hover:text-foreground transition-colors"
onClick={() => onChange([])}
>
none
</button>
</span>
)}
</div>
{options.length === 0 ? (
<span className="text-[10px] text-muted-foreground">None in current data</span>
) : (
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto pr-1">
{options.map((opt) => {
const checked = selected.includes(opt.value);
return (
<label
key={opt.value}
className="flex items-center gap-1.5 text-[11px] cursor-pointer"
>
<input
type="checkbox"
checked={checked}
onChange={() =>
onChange(
checked
? selected.filter((s) => s !== opt.value)
: [...selected, opt.value]
)
}
style={{ accentColor: opt.color }}
/>
<span
className="inline-block w-2.5 h-2.5 rounded-full shrink-0 border border-border"
style={{ background: opt.color }}
/>
<span className="truncate" style={{ color: opt.color }}>
{opt.value}
</span>
</label>
);
})}
</div>
)}
</div>
);

const toggle_ = (value: boolean, onInput: (v: boolean) => void) => (
<input
type="checkbox"
Expand Down Expand Up @@ -298,9 +374,22 @@ export const SettingsPanel: React.FC<SettingsPanelProps<ForceGraphSettings>> = (
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 })
)}
<p className="text-[10px] text-muted-foreground">
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.
</p>
</div>
)}
Expand Down
24 changes: 24 additions & 0 deletions web/src/store/graphStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ interface GraphFilters {
visibleEdgeCategories: Set<string>; // 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;
Expand Down Expand Up @@ -167,6 +174,15 @@ interface GraphStore {
filters: GraphFilters;
setFilters: (filters: Partial<GraphFilters>) => 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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -364,6 +385,9 @@ export const useGraphStore = create<GraphStore>()(
})),
resetFilters: () => set({ filters: defaultFilters }),

filterOptions: defaultFilterOptions,
setFilterOptions: (filterOptions) => set({ filterOptions }),

// Toggle edge category visibility
toggleEdgeCategoryVisibility: (category) =>
set((state) => {
Expand Down
Loading