mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
branch filtering
This commit is contained in:
@@ -1036,6 +1036,8 @@ export function BoardView() {
|
|||||||
currentWorktreePath={currentWorktreePath}
|
currentWorktreePath={currentWorktreePath}
|
||||||
currentWorktreeBranch={currentWorktreeBranch}
|
currentWorktreeBranch={currentWorktreeBranch}
|
||||||
projectPath={currentProject?.path || null}
|
projectPath={currentProject?.path || null}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchQueryChange={setSearchQuery}
|
||||||
onEditFeature={(feature) => setEditingFeature(feature)}
|
onEditFeature={(feature) => setEditingFeature(feature)}
|
||||||
onViewOutput={handleViewOutput}
|
onViewOutput={handleViewOutput}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Feature } from '@/store/app-store';
|
|||||||
export interface DependencyEdgeData {
|
export interface DependencyEdgeData {
|
||||||
sourceStatus: Feature['status'];
|
sourceStatus: Feature['status'];
|
||||||
targetStatus: Feature['status'];
|
targetStatus: Feature['status'];
|
||||||
|
isHighlighted?: boolean;
|
||||||
|
isDimmed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
||||||
@@ -52,11 +54,17 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
curvature: 0.25,
|
curvature: 0.25,
|
||||||
});
|
});
|
||||||
|
|
||||||
const edgeColor = edgeData
|
const isHighlighted = edgeData?.isHighlighted ?? false;
|
||||||
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
|
const isDimmed = edgeData?.isDimmed ?? false;
|
||||||
: 'var(--border)';
|
|
||||||
|
|
||||||
const isCompleted = edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
const edgeColor = isHighlighted
|
||||||
|
? 'var(--brand-500)'
|
||||||
|
: edgeData
|
||||||
|
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
|
||||||
|
: 'var(--border)';
|
||||||
|
|
||||||
|
const isCompleted =
|
||||||
|
edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
||||||
const isInProgress = edgeData?.targetStatus === 'in_progress';
|
const isInProgress = edgeData?.targetStatus === 'in_progress';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,8 +74,9 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
id={`${id}-bg`}
|
id={`${id}-bg`}
|
||||||
path={edgePath}
|
path={edgePath}
|
||||||
style={{
|
style={{
|
||||||
strokeWidth: 4,
|
strokeWidth: isHighlighted ? 6 : 4,
|
||||||
stroke: 'var(--background)',
|
stroke: 'var(--background)',
|
||||||
|
opacity: isDimmed ? 0.3 : 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -78,13 +87,20 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'transition-all duration-300',
|
'transition-all duration-300',
|
||||||
animated && 'animated-edge',
|
animated && 'animated-edge',
|
||||||
isInProgress && 'edge-flowing'
|
isInProgress && 'edge-flowing',
|
||||||
|
isHighlighted && 'graph-edge-highlighted',
|
||||||
|
isDimmed && 'graph-edge-dimmed'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
strokeWidth: selected ? 3 : 2,
|
strokeWidth: isHighlighted ? 4 : selected ? 3 : isDimmed ? 1 : 2,
|
||||||
stroke: edgeColor,
|
stroke: edgeColor,
|
||||||
strokeDasharray: isCompleted ? 'none' : '5 5',
|
strokeDasharray: isCompleted ? 'none' : '5 5',
|
||||||
filter: selected ? 'drop-shadow(0 0 3px var(--brand-500))' : 'none',
|
filter: isHighlighted
|
||||||
|
? 'drop-shadow(0 0 6px var(--brand-500))'
|
||||||
|
: selected
|
||||||
|
? 'drop-shadow(0 0 3px var(--brand-500))'
|
||||||
|
: 'none',
|
||||||
|
opacity: isDimmed ? 0.2 : 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { Panel } from '@xyflow/react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Filter, X, Eye, EyeOff, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { GraphFilterState } from '../hooks/use-graph-filter';
|
||||||
|
|
||||||
|
interface GraphFilterControlsProps {
|
||||||
|
filterState: GraphFilterState;
|
||||||
|
availableCategories: string[];
|
||||||
|
hasActiveFilter: boolean;
|
||||||
|
onSearchQueryChange: (query: string) => void;
|
||||||
|
onCategoriesChange: (categories: string[]) => void;
|
||||||
|
onNegativeFilterChange: (isNegative: boolean) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphFilterControls({
|
||||||
|
filterState,
|
||||||
|
availableCategories,
|
||||||
|
hasActiveFilter,
|
||||||
|
onSearchQueryChange,
|
||||||
|
onCategoriesChange,
|
||||||
|
onNegativeFilterChange,
|
||||||
|
onClearFilters,
|
||||||
|
}: GraphFilterControlsProps) {
|
||||||
|
const { selectedCategories, isNegativeFilter } = filterState;
|
||||||
|
|
||||||
|
const handleCategoryToggle = (category: string) => {
|
||||||
|
if (selectedCategories.includes(category)) {
|
||||||
|
onCategoriesChange(selectedCategories.filter((c) => c !== category));
|
||||||
|
} else {
|
||||||
|
onCategoriesChange([...selectedCategories, category]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAllCategories = () => {
|
||||||
|
if (selectedCategories.length === availableCategories.length) {
|
||||||
|
onCategoriesChange([]);
|
||||||
|
} else {
|
||||||
|
onCategoriesChange([...availableCategories]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryButtonLabel =
|
||||||
|
selectedCategories.length === 0
|
||||||
|
? 'All Categories'
|
||||||
|
: selectedCategories.length === 1
|
||||||
|
? selectedCategories[0]
|
||||||
|
: `${selectedCategories.length} Categories`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel position="top-left" className="flex items-center gap-2">
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||||
|
{/* Category Filter Dropdown */}
|
||||||
|
<Popover>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8 px-2 gap-1.5',
|
||||||
|
selectedCategories.length > 0 && 'bg-brand-500/20 text-brand-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
<span className="text-xs max-w-[100px] truncate">{categoryButtonLabel}</span>
|
||||||
|
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Filter by Category</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<PopoverContent align="start" className="w-56 p-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground px-2 py-1">
|
||||||
|
Categories
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Select All option */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleSelectAllCategories}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
selectedCategories.length === availableCategories.length &&
|
||||||
|
availableCategories.length > 0
|
||||||
|
}
|
||||||
|
onCheckedChange={handleSelectAllCategories}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedCategories.length === availableCategories.length
|
||||||
|
? 'Deselect All'
|
||||||
|
: 'Select All'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-border" />
|
||||||
|
|
||||||
|
{/* Category list */}
|
||||||
|
<div className="max-h-48 overflow-y-auto space-y-0.5">
|
||||||
|
{availableCategories.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground px-2 py-2">
|
||||||
|
No categories available
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availableCategories.map((category) => (
|
||||||
|
<div
|
||||||
|
key={category}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => handleCategoryToggle(category)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedCategories.includes(category)}
|
||||||
|
onCheckedChange={() => handleCategoryToggle(category)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm truncate">{category}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="h-6 w-px bg-border" />
|
||||||
|
|
||||||
|
{/* Positive/Negative Filter Toggle */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onNegativeFilterChange(!isNegativeFilter)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors',
|
||||||
|
isNegativeFilter
|
||||||
|
? 'bg-orange-500/20 text-orange-500'
|
||||||
|
: 'hover:bg-accent text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isNegativeFilter ? (
|
||||||
|
<>
|
||||||
|
<EyeOff className="w-3.5 h-3.5" />
|
||||||
|
<span>Hide</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
<span>Show</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Switch
|
||||||
|
checked={isNegativeFilter}
|
||||||
|
onCheckedChange={onNegativeFilterChange}
|
||||||
|
className="h-5 w-9 data-[state=checked]:bg-orange-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{isNegativeFilter
|
||||||
|
? 'Negative filter: Highlighting non-matching nodes'
|
||||||
|
: 'Positive filter: Highlighting matching nodes'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Clear Filters Button - only show when filters are active */}
|
||||||
|
{hasActiveFilter && (
|
||||||
|
<>
|
||||||
|
<div className="h-6 w-px bg-border" />
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Clear All Filters</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export { TaskNode } from './task-node';
|
|||||||
export { DependencyEdge } from './dependency-edge';
|
export { DependencyEdge } from './dependency-edge';
|
||||||
export { GraphControls } from './graph-controls';
|
export { GraphControls } from './graph-controls';
|
||||||
export { GraphLegend } from './graph-legend';
|
export { GraphLegend } from './graph-legend';
|
||||||
|
export { GraphFilterControls } from './graph-filter-controls';
|
||||||
|
|||||||
@@ -72,14 +72,16 @@ const priorityConfig = {
|
|||||||
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
|
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TaskNode = memo(function TaskNode({
|
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
||||||
data,
|
|
||||||
selected,
|
|
||||||
}: TaskNodeProps) {
|
|
||||||
const config = statusConfig[data.status] || statusConfig.backlog;
|
const config = statusConfig[data.status] || statusConfig.backlog;
|
||||||
const StatusIcon = config.icon;
|
const StatusIcon = config.icon;
|
||||||
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
|
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
|
||||||
|
|
||||||
|
// Filter highlight states
|
||||||
|
const isMatched = data.isMatched ?? false;
|
||||||
|
const isHighlighted = data.isHighlighted ?? false;
|
||||||
|
const isDimmed = data.isDimmed ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Target handle (left side - receives dependencies) */}
|
{/* Target handle (left side - receives dependencies) */}
|
||||||
@@ -89,39 +91,46 @@ export const TaskNode = memo(function TaskNode({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'w-3 h-3 !bg-border border-2 border-background',
|
'w-3 h-3 !bg-border border-2 border-background',
|
||||||
'transition-colors duration-200',
|
'transition-colors duration-200',
|
||||||
'hover:!bg-brand-500'
|
'hover:!bg-brand-500',
|
||||||
|
isDimmed && 'opacity-30'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
|
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
|
||||||
'transition-all duration-200',
|
'transition-all duration-300',
|
||||||
config.borderClass,
|
config.borderClass,
|
||||||
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
||||||
data.isRunning && 'animate-pulse-subtle',
|
data.isRunning && 'animate-pulse-subtle',
|
||||||
data.error && 'border-[var(--status-error)]'
|
data.error && 'border-[var(--status-error)]',
|
||||||
|
// Filter highlight states
|
||||||
|
isMatched && 'graph-node-matched',
|
||||||
|
isHighlighted && !isMatched && 'graph-node-highlighted',
|
||||||
|
isDimmed && 'graph-node-dimmed'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header with status and actions */}
|
{/* Header with status and actions */}
|
||||||
<div className={cn(
|
<div
|
||||||
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
className={cn(
|
||||||
config.bgClass
|
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
||||||
)}>
|
config.bgClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusIcon className={cn('w-4 h-4', config.colorClass)} />
|
<StatusIcon className={cn('w-4 h-4', config.colorClass)} />
|
||||||
<span className={cn('text-xs font-medium', config.colorClass)}>
|
<span className={cn('text-xs font-medium', config.colorClass)}>{config.label}</span>
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Priority badge */}
|
{/* Priority badge */}
|
||||||
{priorityConf && (
|
{priorityConf && (
|
||||||
<span className={cn(
|
<span
|
||||||
'text-[10px] font-bold px-1.5 py-0.5 rounded',
|
className={cn(
|
||||||
priorityConf.colorClass
|
'text-[10px] font-bold px-1.5 py-0.5 rounded',
|
||||||
)}>
|
priorityConf.colorClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
|
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -161,11 +170,7 @@ export const TaskNode = memo(function TaskNode({
|
|||||||
{/* Actions dropdown */}
|
{/* Actions dropdown */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 hover:bg-background/50">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 hover:bg-background/50"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -212,9 +217,7 @@ export const TaskNode = memo(function TaskNode({
|
|||||||
{data.isRunning && (
|
{data.isRunning && (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
<div
|
<div className="h-full bg-[var(--status-in-progress)] rounded-full animate-progress-indeterminate" />
|
||||||
className="h-full bg-[var(--status-in-progress)] rounded-full animate-progress-indeterminate"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground">Running...</span>
|
<span className="text-[10px] text-muted-foreground">Running...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,7 +243,8 @@ export const TaskNode = memo(function TaskNode({
|
|||||||
'hover:!bg-brand-500',
|
'hover:!bg-brand-500',
|
||||||
data.status === 'completed' || data.status === 'verified'
|
data.status === 'completed' || data.status === 'verified'
|
||||||
? '!bg-[var(--status-success)]'
|
? '!bg-[var(--status-success)]'
|
||||||
: ''
|
: '',
|
||||||
|
isDimmed && 'opacity-30'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,8 +14,20 @@ import {
|
|||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components';
|
import {
|
||||||
import { useGraphNodes, useGraphLayout, type TaskNodeData } from './hooks';
|
TaskNode,
|
||||||
|
DependencyEdge,
|
||||||
|
GraphControls,
|
||||||
|
GraphLegend,
|
||||||
|
GraphFilterControls,
|
||||||
|
} from './components';
|
||||||
|
import {
|
||||||
|
useGraphNodes,
|
||||||
|
useGraphLayout,
|
||||||
|
useGraphFilter,
|
||||||
|
type TaskNodeData,
|
||||||
|
type GraphFilterState,
|
||||||
|
} from './hooks';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
||||||
@@ -32,6 +44,8 @@ const edgeTypes: any = {
|
|||||||
interface GraphCanvasProps {
|
interface GraphCanvasProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchQueryChange: (query: string) => void;
|
||||||
onNodeClick?: (featureId: string) => void;
|
onNodeClick?: (featureId: string) => void;
|
||||||
onNodeDoubleClick?: (featureId: string) => void;
|
onNodeDoubleClick?: (featureId: string) => void;
|
||||||
backgroundStyle?: React.CSSProperties;
|
backgroundStyle?: React.CSSProperties;
|
||||||
@@ -41,6 +55,8 @@ interface GraphCanvasProps {
|
|||||||
function GraphCanvasInner({
|
function GraphCanvasInner({
|
||||||
features,
|
features,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
|
searchQuery,
|
||||||
|
onSearchQueryChange,
|
||||||
onNodeClick,
|
onNodeClick,
|
||||||
onNodeDoubleClick,
|
onNodeDoubleClick,
|
||||||
backgroundStyle,
|
backgroundStyle,
|
||||||
@@ -49,10 +65,25 @@ function GraphCanvasInner({
|
|||||||
const [isLocked, setIsLocked] = useState(false);
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
||||||
|
|
||||||
// Transform features to nodes and edges
|
// Filter state (category and negative toggle are local to graph view)
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
|
const [isNegativeFilter, setIsNegativeFilter] = useState(false);
|
||||||
|
|
||||||
|
// Combined filter state
|
||||||
|
const filterState: GraphFilterState = {
|
||||||
|
searchQuery,
|
||||||
|
selectedCategories,
|
||||||
|
isNegativeFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate filter results
|
||||||
|
const filterResult = useGraphFilter(features, filterState);
|
||||||
|
|
||||||
|
// Transform features to nodes and edges with filter results
|
||||||
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
||||||
features,
|
features,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
|
filterResult,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply layout
|
// Apply layout
|
||||||
@@ -80,6 +111,13 @@ function GraphCanvasInner({
|
|||||||
[runLayout]
|
[runLayout]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle clear all filters
|
||||||
|
const handleClearFilters = useCallback(() => {
|
||||||
|
onSearchQueryChange('');
|
||||||
|
setSelectedCategories([]);
|
||||||
|
setIsNegativeFilter(false);
|
||||||
|
}, [onSearchQueryChange]);
|
||||||
|
|
||||||
// Handle node click
|
// Handle node click
|
||||||
const handleNodeClick = useCallback(
|
const handleNodeClick = useCallback(
|
||||||
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
|
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
|
||||||
@@ -158,6 +196,16 @@ function GraphCanvasInner({
|
|||||||
layoutDirection={layoutDirection}
|
layoutDirection={layoutDirection}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GraphFilterControls
|
||||||
|
filterState={filterState}
|
||||||
|
availableCategories={filterResult.availableCategories}
|
||||||
|
hasActiveFilter={filterResult.hasActiveFilter}
|
||||||
|
onSearchQueryChange={onSearchQueryChange}
|
||||||
|
onCategoriesChange={setSelectedCategories}
|
||||||
|
onNegativeFilterChange={setIsNegativeFilter}
|
||||||
|
onClearFilters={handleClearFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
<GraphLegend />
|
<GraphLegend />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface GraphViewProps {
|
|||||||
currentWorktreePath: string | null;
|
currentWorktreePath: string | null;
|
||||||
currentWorktreeBranch: string | null;
|
currentWorktreeBranch: string | null;
|
||||||
projectPath: string | null;
|
projectPath: string | null;
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchQueryChange: (query: string) => void;
|
||||||
onEditFeature: (feature: Feature) => void;
|
onEditFeature: (feature: Feature) => void;
|
||||||
onViewOutput: (feature: Feature) => void;
|
onViewOutput: (feature: Feature) => void;
|
||||||
}
|
}
|
||||||
@@ -19,6 +21,8 @@ export function GraphView({
|
|||||||
currentWorktreePath,
|
currentWorktreePath,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
searchQuery,
|
||||||
|
onSearchQueryChange,
|
||||||
onEditFeature,
|
onEditFeature,
|
||||||
onViewOutput,
|
onViewOutput,
|
||||||
}: GraphViewProps) {
|
}: GraphViewProps) {
|
||||||
@@ -79,6 +83,8 @@ export function GraphView({
|
|||||||
<GraphCanvas
|
<GraphCanvas
|
||||||
features={filteredFeatures}
|
features={filteredFeatures}
|
||||||
runningAutoTasks={runningAutoTasks}
|
runningAutoTasks={runningAutoTasks}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchQueryChange={onSearchQueryChange}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
backgroundStyle={backgroundImageStyle}
|
backgroundStyle={backgroundImageStyle}
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
export { useGraphNodes, type TaskNode, type DependencyEdge, type TaskNodeData } from './use-graph-nodes';
|
export {
|
||||||
|
useGraphNodes,
|
||||||
|
type TaskNode,
|
||||||
|
type DependencyEdge,
|
||||||
|
type TaskNodeData,
|
||||||
|
} from './use-graph-nodes';
|
||||||
export { useGraphLayout } from './use-graph-layout';
|
export { useGraphLayout } from './use-graph-layout';
|
||||||
|
export { useGraphFilter, type GraphFilterState, type GraphFilterResult } from './use-graph-filter';
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Feature } from '@/store/app-store';
|
||||||
|
|
||||||
|
export interface GraphFilterState {
|
||||||
|
searchQuery: string;
|
||||||
|
selectedCategories: string[];
|
||||||
|
isNegativeFilter: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphFilterResult {
|
||||||
|
matchedNodeIds: Set<string>;
|
||||||
|
highlightedNodeIds: Set<string>;
|
||||||
|
highlightedEdgeIds: Set<string>;
|
||||||
|
availableCategories: string[];
|
||||||
|
hasActiveFilter: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverses up the dependency tree to find all ancestors of a node
|
||||||
|
*/
|
||||||
|
function getAncestors(
|
||||||
|
featureId: string,
|
||||||
|
featureMap: Map<string, Feature>,
|
||||||
|
visited: Set<string>
|
||||||
|
): void {
|
||||||
|
if (visited.has(featureId)) return;
|
||||||
|
visited.add(featureId);
|
||||||
|
|
||||||
|
const feature = featureMap.get(featureId);
|
||||||
|
if (!feature?.dependencies) return;
|
||||||
|
|
||||||
|
for (const depId of feature.dependencies) {
|
||||||
|
if (featureMap.has(depId)) {
|
||||||
|
getAncestors(depId, featureMap, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverses down to find all descendants (features that depend on this one)
|
||||||
|
*/
|
||||||
|
function getDescendants(featureId: string, features: Feature[], visited: Set<string>): void {
|
||||||
|
if (visited.has(featureId)) return;
|
||||||
|
visited.add(featureId);
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
if (feature.dependencies?.includes(featureId)) {
|
||||||
|
getDescendants(feature.id, features, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all edges in the highlighted path
|
||||||
|
*/
|
||||||
|
function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[]): Set<string> {
|
||||||
|
const edges = new Set<string>();
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
if (!highlightedNodeIds.has(feature.id)) continue;
|
||||||
|
if (!feature.dependencies) continue;
|
||||||
|
|
||||||
|
for (const depId of feature.dependencies) {
|
||||||
|
if (highlightedNodeIds.has(depId)) {
|
||||||
|
edges.add(`${depId}->${feature.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to calculate graph filter results based on search query, categories, and filter mode
|
||||||
|
*/
|
||||||
|
export function useGraphFilter(
|
||||||
|
features: Feature[],
|
||||||
|
filterState: GraphFilterState
|
||||||
|
): GraphFilterResult {
|
||||||
|
const { searchQuery, selectedCategories, isNegativeFilter } = filterState;
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
// Extract all unique categories
|
||||||
|
const availableCategories = Array.from(
|
||||||
|
new Set(features.map((f) => f.category).filter(Boolean))
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
|
const hasSearchQuery = normalizedQuery.length > 0;
|
||||||
|
const hasCategoryFilter = selectedCategories.length > 0;
|
||||||
|
const hasActiveFilter = hasSearchQuery || hasCategoryFilter || isNegativeFilter;
|
||||||
|
|
||||||
|
// If no filters active, return empty sets (show all nodes normally)
|
||||||
|
if (!hasActiveFilter) {
|
||||||
|
return {
|
||||||
|
matchedNodeIds: new Set<string>(),
|
||||||
|
highlightedNodeIds: new Set<string>(),
|
||||||
|
highlightedEdgeIds: new Set<string>(),
|
||||||
|
availableCategories,
|
||||||
|
hasActiveFilter: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find directly matched nodes
|
||||||
|
const matchedNodeIds = new Set<string>();
|
||||||
|
const featureMap = new Map(features.map((f) => [f.id, f]));
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
let matchesSearch = true;
|
||||||
|
let matchesCategory = true;
|
||||||
|
|
||||||
|
// Check search query match (title or description)
|
||||||
|
if (hasSearchQuery) {
|
||||||
|
const titleMatch = feature.title?.toLowerCase().includes(normalizedQuery);
|
||||||
|
const descMatch = feature.description?.toLowerCase().includes(normalizedQuery);
|
||||||
|
matchesSearch = titleMatch || descMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check category match
|
||||||
|
if (hasCategoryFilter) {
|
||||||
|
matchesCategory = selectedCategories.includes(feature.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both conditions must be true for a match
|
||||||
|
if (matchesSearch && matchesCategory) {
|
||||||
|
matchedNodeIds.add(feature.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply negative filter if enabled (invert the matched set)
|
||||||
|
let effectiveMatchedIds: Set<string>;
|
||||||
|
if (isNegativeFilter) {
|
||||||
|
effectiveMatchedIds = new Set(
|
||||||
|
features.filter((f) => !matchedNodeIds.has(f.id)).map((f) => f.id)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
effectiveMatchedIds = matchedNodeIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate full path (ancestors + descendants) for highlighted nodes
|
||||||
|
const highlightedNodeIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const id of effectiveMatchedIds) {
|
||||||
|
// Add the matched node itself
|
||||||
|
highlightedNodeIds.add(id);
|
||||||
|
|
||||||
|
// Add all ancestors (dependencies)
|
||||||
|
getAncestors(id, featureMap, highlightedNodeIds);
|
||||||
|
|
||||||
|
// Add all descendants (dependents)
|
||||||
|
getDescendants(id, features, highlightedNodeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get edges in the highlighted path
|
||||||
|
const highlightedEdgeIds = getHighlightedEdges(highlightedNodeIds, features);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matchedNodeIds: effectiveMatchedIds,
|
||||||
|
highlightedNodeIds,
|
||||||
|
highlightedEdgeIds,
|
||||||
|
availableCategories,
|
||||||
|
hasActiveFilter: true,
|
||||||
|
};
|
||||||
|
}, [features, searchQuery, selectedCategories, isNegativeFilter]);
|
||||||
|
}
|
||||||
@@ -2,26 +2,37 @@ import { useMemo } from 'react';
|
|||||||
import { Node, Edge } from '@xyflow/react';
|
import { Node, Edge } from '@xyflow/react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
|
import { GraphFilterResult } from './use-graph-filter';
|
||||||
|
|
||||||
export interface TaskNodeData extends Feature {
|
export interface TaskNodeData extends Feature {
|
||||||
isBlocked: boolean;
|
isBlocked: boolean;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
blockingDependencies: string[];
|
blockingDependencies: string[];
|
||||||
|
// Filter highlight states
|
||||||
|
isMatched?: boolean;
|
||||||
|
isHighlighted?: boolean;
|
||||||
|
isDimmed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskNode = Node<TaskNodeData, 'task'>;
|
export type TaskNode = Node<TaskNodeData, 'task'>;
|
||||||
export type DependencyEdge = Edge<{ sourceStatus: Feature['status']; targetStatus: Feature['status'] }>;
|
export type DependencyEdge = Edge<{
|
||||||
|
sourceStatus: Feature['status'];
|
||||||
|
targetStatus: Feature['status'];
|
||||||
|
isHighlighted?: boolean;
|
||||||
|
isDimmed?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
interface UseGraphNodesProps {
|
interface UseGraphNodesProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
|
filterResult?: GraphFilterResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms features into React Flow nodes and edges
|
* Transforms features into React Flow nodes and edges
|
||||||
* Creates dependency edges based on feature.dependencies array
|
* Creates dependency edges based on feature.dependencies array
|
||||||
*/
|
*/
|
||||||
export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps) {
|
export function useGraphNodes({ features, runningAutoTasks, filterResult }: UseGraphNodesProps) {
|
||||||
const { nodes, edges } = useMemo(() => {
|
const { nodes, edges } = useMemo(() => {
|
||||||
const nodeList: TaskNode[] = [];
|
const nodeList: TaskNode[] = [];
|
||||||
const edgeList: DependencyEdge[] = [];
|
const edgeList: DependencyEdge[] = [];
|
||||||
@@ -30,11 +41,22 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
|
|||||||
// Create feature map for quick lookups
|
// Create feature map for quick lookups
|
||||||
features.forEach((f) => featureMap.set(f.id, f));
|
features.forEach((f) => featureMap.set(f.id, f));
|
||||||
|
|
||||||
|
// Extract filter state
|
||||||
|
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
|
||||||
|
const matchedNodeIds = filterResult?.matchedNodeIds ?? new Set<string>();
|
||||||
|
const highlightedNodeIds = filterResult?.highlightedNodeIds ?? new Set<string>();
|
||||||
|
const highlightedEdgeIds = filterResult?.highlightedEdgeIds ?? new Set<string>();
|
||||||
|
|
||||||
// Create nodes
|
// Create nodes
|
||||||
features.forEach((feature) => {
|
features.forEach((feature) => {
|
||||||
const isRunning = runningAutoTasks.includes(feature.id);
|
const isRunning = runningAutoTasks.includes(feature.id);
|
||||||
const blockingDeps = getBlockingDependencies(feature, features);
|
const blockingDeps = getBlockingDependencies(feature, features);
|
||||||
|
|
||||||
|
// Calculate filter highlight states
|
||||||
|
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
|
||||||
|
const isHighlighted = hasActiveFilter && highlightedNodeIds.has(feature.id);
|
||||||
|
const isDimmed = hasActiveFilter && !highlightedNodeIds.has(feature.id);
|
||||||
|
|
||||||
const node: TaskNode = {
|
const node: TaskNode = {
|
||||||
id: feature.id,
|
id: feature.id,
|
||||||
type: 'task',
|
type: 'task',
|
||||||
@@ -44,6 +66,10 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
|
|||||||
isBlocked: blockingDeps.length > 0,
|
isBlocked: blockingDeps.length > 0,
|
||||||
isRunning,
|
isRunning,
|
||||||
blockingDependencies: blockingDeps,
|
blockingDependencies: blockingDeps,
|
||||||
|
// Filter states
|
||||||
|
isMatched,
|
||||||
|
isHighlighted,
|
||||||
|
isDimmed,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,8 +81,14 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
|
|||||||
// Only create edge if the dependency exists in current view
|
// Only create edge if the dependency exists in current view
|
||||||
if (featureMap.has(depId)) {
|
if (featureMap.has(depId)) {
|
||||||
const sourceFeature = featureMap.get(depId)!;
|
const sourceFeature = featureMap.get(depId)!;
|
||||||
|
const edgeId = `${depId}->${feature.id}`;
|
||||||
|
|
||||||
|
// Calculate edge highlight states
|
||||||
|
const edgeIsHighlighted = hasActiveFilter && highlightedEdgeIds.has(edgeId);
|
||||||
|
const edgeIsDimmed = hasActiveFilter && !highlightedEdgeIds.has(edgeId);
|
||||||
|
|
||||||
const edge: DependencyEdge = {
|
const edge: DependencyEdge = {
|
||||||
id: `${depId}->${feature.id}`,
|
id: edgeId,
|
||||||
source: depId,
|
source: depId,
|
||||||
target: feature.id,
|
target: feature.id,
|
||||||
type: 'dependency',
|
type: 'dependency',
|
||||||
@@ -64,6 +96,8 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
|
|||||||
data: {
|
data: {
|
||||||
sourceStatus: sourceFeature.status,
|
sourceStatus: sourceFeature.status,
|
||||||
targetStatus: feature.status,
|
targetStatus: feature.status,
|
||||||
|
isHighlighted: edgeIsHighlighted,
|
||||||
|
isDimmed: edgeIsDimmed,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
edgeList.push(edge);
|
edgeList.push(edge);
|
||||||
@@ -73,7 +107,7 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { nodes: nodeList, edges: edgeList };
|
return { nodes: nodeList, edges: edgeList };
|
||||||
}, [features, runningAutoTasks]);
|
}, [features, runningAutoTasks, filterResult]);
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1049,12 +1049,79 @@
|
|||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Graph Filter Highlight States */
|
||||||
|
|
||||||
|
/* Matched node - direct search/filter match */
|
||||||
|
.graph-node-matched {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px var(--brand-500),
|
||||||
|
0 0 20px 4px var(--brand-500);
|
||||||
|
border-color: var(--brand-500) !important;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated glow for matched nodes */
|
||||||
|
@keyframes matched-node-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px var(--brand-500),
|
||||||
|
0 0 15px 2px var(--brand-500);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px var(--brand-500),
|
||||||
|
0 0 25px 6px var(--brand-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-node-matched {
|
||||||
|
animation: matched-node-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlighted path node - part of the dependency path */
|
||||||
|
.graph-node-highlighted {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px var(--brand-400),
|
||||||
|
0 0 12px 2px var(--brand-400);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dimmed node - not part of filter results */
|
||||||
|
.graph-node-dimmed {
|
||||||
|
opacity: 0.25;
|
||||||
|
filter: grayscale(60%);
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
filter 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-node-dimmed:hover {
|
||||||
|
opacity: 0.4;
|
||||||
|
filter: grayscale(40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlighted edge styles */
|
||||||
|
.graph-edge-highlighted path {
|
||||||
|
stroke: var(--brand-500) !important;
|
||||||
|
stroke-width: 4px !important;
|
||||||
|
filter: drop-shadow(0 0 6px var(--brand-500));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dimmed edge styles */
|
||||||
|
.graph-edge-dimmed path {
|
||||||
|
opacity: 0.15;
|
||||||
|
stroke-width: 1px !important;
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduce motion preference */
|
/* Reduce motion preference */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.graph-canvas .animated-edge path,
|
.graph-canvas .animated-edge path,
|
||||||
.graph-canvas .edge-flowing path,
|
.graph-canvas .edge-flowing path,
|
||||||
.animate-pulse-subtle,
|
.animate-pulse-subtle,
|
||||||
.animate-progress-indeterminate {
|
.animate-progress-indeterminate,
|
||||||
|
.graph-node-matched {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user