archrip 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/templates/slash-commands/claude/archrip-scan.md +53 -5
- package/dist/templates/slash-commands/codex/archrip-scan.md +53 -5
- package/dist/templates/slash-commands/gemini/archrip-scan.md +53 -5
- package/dist/utils/layout.js +3 -2
- package/dist/utils/layout.js.map +1 -1
- package/dist/viewer-template/src/App.tsx +93 -5
- package/dist/viewer-template/src/components/CommandPalette.tsx +174 -0
- package/dist/viewer-template/src/components/Legend.tsx +35 -5
- package/dist/viewer-template/src/components/UseCaseFilter.tsx +62 -3
- package/dist/viewer-template/src/hooks/useCategoryFilter.ts +36 -0
- package/dist/viewer-template/src/hooks/useCommandPalette.ts +91 -0
- package/dist/viewer-template/src/hooks/useFlowAnimation.ts +82 -0
- package/dist/viewer-template/src/hooks/useKeyboardShortcuts.ts +39 -0
- package/dist/viewer-template/src/hooks/useUseCaseFilter.ts +166 -21
- package/dist/viewer-template/src/index.css +19 -0
- package/dist/viewer-template/src/types.ts +3 -2
- package/dist/viewer-template/src/utils/layout.ts +3 -2
- package/package.json +1 -1
|
@@ -2,9 +2,12 @@ import { getCategoryColors, getCategoryLabel } from '../types.ts';
|
|
|
2
2
|
|
|
3
3
|
interface LegendProps {
|
|
4
4
|
categories: string[];
|
|
5
|
+
hiddenCategories: Set<string>;
|
|
6
|
+
onToggleCategory: (category: string) => void;
|
|
7
|
+
onShowAll: () => void;
|
|
5
8
|
}
|
|
6
9
|
|
|
7
|
-
export function Legend({ categories }: LegendProps) {
|
|
10
|
+
export function Legend({ categories, hiddenCategories, onToggleCategory, onShowAll }: LegendProps) {
|
|
8
11
|
return (
|
|
9
12
|
<div
|
|
10
13
|
className="absolute bottom-3 right-3 z-10 rounded-lg p-3 border"
|
|
@@ -23,19 +26,46 @@ export function Legend({ categories }: LegendProps) {
|
|
|
23
26
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
|
24
27
|
{categories.map((cat) => {
|
|
25
28
|
const colors = getCategoryColors(cat);
|
|
29
|
+
const hidden = hiddenCategories.has(cat);
|
|
26
30
|
return (
|
|
27
|
-
<
|
|
31
|
+
<button
|
|
32
|
+
key={cat}
|
|
33
|
+
onClick={() => onToggleCategory(cat)}
|
|
34
|
+
className="flex items-center gap-1.5 cursor-pointer bg-transparent border-none p-0 text-left"
|
|
35
|
+
>
|
|
28
36
|
<div
|
|
29
37
|
className="w-3 h-3 rounded-sm border"
|
|
30
|
-
style={{
|
|
38
|
+
style={{
|
|
39
|
+
background: colors.bg,
|
|
40
|
+
borderColor: colors.border,
|
|
41
|
+
opacity: hidden ? 0.3 : 1,
|
|
42
|
+
transition: 'opacity 0.2s',
|
|
43
|
+
}}
|
|
31
44
|
/>
|
|
32
|
-
<span
|
|
45
|
+
<span
|
|
46
|
+
className="text-xs"
|
|
47
|
+
style={{
|
|
48
|
+
color: 'var(--color-content-secondary)',
|
|
49
|
+
opacity: hidden ? 0.4 : 1,
|
|
50
|
+
textDecoration: hidden ? 'line-through' : 'none',
|
|
51
|
+
transition: 'opacity 0.2s',
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
33
54
|
{getCategoryLabel(cat)}
|
|
34
55
|
</span>
|
|
35
|
-
</
|
|
56
|
+
</button>
|
|
36
57
|
);
|
|
37
58
|
})}
|
|
38
59
|
</div>
|
|
60
|
+
{hiddenCategories.size > 0 && (
|
|
61
|
+
<button
|
|
62
|
+
onClick={onShowAll}
|
|
63
|
+
className="mt-2 text-xs cursor-pointer hover:underline bg-transparent border-none p-0"
|
|
64
|
+
style={{ color: 'var(--color-interactive-primary)' }}
|
|
65
|
+
>
|
|
66
|
+
Show All
|
|
67
|
+
</button>
|
|
68
|
+
)}
|
|
39
69
|
</div>
|
|
40
70
|
);
|
|
41
71
|
}
|
|
@@ -1,12 +1,53 @@
|
|
|
1
1
|
import type { UseCase } from '../types.ts';
|
|
2
|
+
import type { FlowAnimationState } from '../hooks/useFlowAnimation.ts';
|
|
2
3
|
|
|
3
4
|
interface UseCaseFilterProps {
|
|
4
5
|
useCases: UseCase[];
|
|
5
6
|
selectedUseCase: string | null;
|
|
6
7
|
onSelect: (useCaseId: string | null) => void;
|
|
8
|
+
flowInfo: FlowAnimationState;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
function FlowButton({ label, onClick }: { label: string; onClick: () => void }) {
|
|
12
|
+
return (
|
|
13
|
+
<button
|
|
14
|
+
onClick={onClick}
|
|
15
|
+
className="w-7 h-7 flex items-center justify-center rounded border text-xs cursor-pointer"
|
|
16
|
+
style={{
|
|
17
|
+
background: 'var(--color-surface-secondary)',
|
|
18
|
+
borderColor: 'var(--color-border-primary)',
|
|
19
|
+
color: 'var(--color-content-secondary)',
|
|
20
|
+
}}
|
|
21
|
+
aria-label={label}
|
|
22
|
+
>
|
|
23
|
+
{label === 'Previous' && (
|
|
24
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
25
|
+
<path d="M8 1.5L3.5 6L8 10.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
26
|
+
</svg>
|
|
27
|
+
)}
|
|
28
|
+
{label === 'Play' && (
|
|
29
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
30
|
+
<path d="M3 1.5L10 6L3 10.5V1.5Z" />
|
|
31
|
+
</svg>
|
|
32
|
+
)}
|
|
33
|
+
{label === 'Pause' && (
|
|
34
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
35
|
+
<rect x="2.5" y="1.5" width="2.5" height="9" rx="0.5" />
|
|
36
|
+
<rect x="7" y="1.5" width="2.5" height="9" rx="0.5" />
|
|
37
|
+
</svg>
|
|
38
|
+
)}
|
|
39
|
+
{label === 'Next' && (
|
|
40
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
41
|
+
<path d="M4 1.5L8.5 6L4 10.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
42
|
+
</svg>
|
|
43
|
+
)}
|
|
44
|
+
</button>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function UseCaseFilter({ useCases, selectedUseCase, onSelect, flowInfo }: UseCaseFilterProps) {
|
|
49
|
+
const selectedUc = selectedUseCase ? useCases.find((uc) => uc.id === selectedUseCase) : undefined;
|
|
50
|
+
|
|
10
51
|
return (
|
|
11
52
|
<div
|
|
12
53
|
className="rounded-lg p-3 w-64 border"
|
|
@@ -40,11 +81,29 @@ export function UseCaseFilter({ useCases, selectedUseCase, onSelect }: UseCaseFi
|
|
|
40
81
|
</option>
|
|
41
82
|
))}
|
|
42
83
|
</select>
|
|
43
|
-
{
|
|
84
|
+
{selectedUc && (
|
|
44
85
|
<div className="mt-2">
|
|
45
86
|
<p className="text-xs" style={{ color: 'var(--color-content-secondary)' }}>
|
|
46
|
-
{
|
|
87
|
+
{selectedUc.description}
|
|
47
88
|
</p>
|
|
89
|
+
{flowInfo.flowNodeIds && (
|
|
90
|
+
<div className="mt-1.5">
|
|
91
|
+
<p
|
|
92
|
+
className="text-xs font-mono mb-1.5"
|
|
93
|
+
style={{ color: 'var(--color-interactive-primary)' }}
|
|
94
|
+
>
|
|
95
|
+
Step {flowInfo.activeStep + 1}/{flowInfo.flowNodeIds.length}
|
|
96
|
+
</p>
|
|
97
|
+
<div className="flex items-center gap-1">
|
|
98
|
+
<FlowButton label="Previous" onClick={flowInfo.prev} />
|
|
99
|
+
<FlowButton
|
|
100
|
+
label={flowInfo.isPlaying ? 'Pause' : 'Play'}
|
|
101
|
+
onClick={flowInfo.isPlaying ? flowInfo.pause : flowInfo.play}
|
|
102
|
+
/>
|
|
103
|
+
<FlowButton label="Next" onClick={flowInfo.next} />
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
48
107
|
<button
|
|
49
108
|
onClick={() => onSelect(null)}
|
|
50
109
|
className="mt-1.5 text-xs cursor-pointer hover:underline"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useCallback, useMemo } from 'react';
|
|
2
|
+
import { useQueryState, parseAsString } from 'nuqs';
|
|
3
|
+
|
|
4
|
+
interface CategoryFilterState {
|
|
5
|
+
hiddenCategories: Set<string>;
|
|
6
|
+
toggleCategory: (category: string) => void;
|
|
7
|
+
showAll: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useCategoryFilter(): CategoryFilterState {
|
|
11
|
+
const [hideParam, setHideParam] = useQueryState('hide', parseAsString.withOptions({ history: 'replace' }));
|
|
12
|
+
|
|
13
|
+
const hiddenCategories = useMemo(() => {
|
|
14
|
+
if (!hideParam) return new Set<string>();
|
|
15
|
+
return new Set(hideParam.split(',').filter(Boolean));
|
|
16
|
+
}, [hideParam]);
|
|
17
|
+
|
|
18
|
+
const toggleCategory = useCallback(
|
|
19
|
+
(category: string) => {
|
|
20
|
+
const next = new Set(hiddenCategories);
|
|
21
|
+
if (next.has(category)) {
|
|
22
|
+
next.delete(category);
|
|
23
|
+
} else {
|
|
24
|
+
next.add(category);
|
|
25
|
+
}
|
|
26
|
+
void setHideParam(next.size > 0 ? [...next].join(',') : null);
|
|
27
|
+
},
|
|
28
|
+
[hiddenCategories, setHideParam],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const showAll = useCallback(() => {
|
|
32
|
+
void setHideParam(null);
|
|
33
|
+
}, [setHideParam]);
|
|
34
|
+
|
|
35
|
+
return { hiddenCategories, toggleCategory, showAll };
|
|
36
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ArchFlowNode } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
export interface CommandPaletteResult {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
category: string;
|
|
9
|
+
description: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CommandPaletteState {
|
|
13
|
+
isOpen: boolean;
|
|
14
|
+
toggle: () => void;
|
|
15
|
+
close: () => void;
|
|
16
|
+
query: string;
|
|
17
|
+
setQuery: (q: string) => void;
|
|
18
|
+
results: CommandPaletteResult[];
|
|
19
|
+
activeIndex: number;
|
|
20
|
+
moveUp: () => void;
|
|
21
|
+
moveDown: () => void;
|
|
22
|
+
activeItem: CommandPaletteResult | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAX_RESULTS = 20;
|
|
26
|
+
|
|
27
|
+
export function useCommandPalette(
|
|
28
|
+
nodes: ArchFlowNode[],
|
|
29
|
+
hiddenCategories: Set<string>,
|
|
30
|
+
): CommandPaletteState {
|
|
31
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
32
|
+
const [query, setQueryRaw] = useState('');
|
|
33
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
34
|
+
|
|
35
|
+
const toggle = useCallback(() => {
|
|
36
|
+
setIsOpen((prev) => {
|
|
37
|
+
if (!prev) {
|
|
38
|
+
setQueryRaw('');
|
|
39
|
+
setActiveIndex(0);
|
|
40
|
+
}
|
|
41
|
+
return !prev;
|
|
42
|
+
});
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const close = useCallback(() => {
|
|
46
|
+
setIsOpen(false);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const setQuery = useCallback((q: string) => {
|
|
50
|
+
setQueryRaw(q);
|
|
51
|
+
setActiveIndex(0);
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const results = useMemo(() => {
|
|
55
|
+
const searchable = nodes.filter(
|
|
56
|
+
(n) => !n.hidden && !hiddenCategories.has(n.data.category),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (!query.trim()) {
|
|
60
|
+
return searchable.slice(0, MAX_RESULTS).map((n) => ({
|
|
61
|
+
id: n.id,
|
|
62
|
+
label: n.data.label,
|
|
63
|
+
category: n.data.category,
|
|
64
|
+
description: n.data.description,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const q = query.toLowerCase();
|
|
69
|
+
return searchable
|
|
70
|
+
.filter((n) => n.data.label.toLowerCase().includes(q))
|
|
71
|
+
.slice(0, MAX_RESULTS)
|
|
72
|
+
.map((n) => ({
|
|
73
|
+
id: n.id,
|
|
74
|
+
label: n.data.label,
|
|
75
|
+
category: n.data.category,
|
|
76
|
+
description: n.data.description,
|
|
77
|
+
}));
|
|
78
|
+
}, [nodes, hiddenCategories, query]);
|
|
79
|
+
|
|
80
|
+
const moveUp = useCallback(() => {
|
|
81
|
+
setActiveIndex((prev) => (prev <= 0 ? prev : prev - 1));
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const moveDown = useCallback(() => {
|
|
85
|
+
setActiveIndex((prev) => (prev >= results.length - 1 ? prev : prev + 1));
|
|
86
|
+
}, [results.length]);
|
|
87
|
+
|
|
88
|
+
const activeItem = results[activeIndex] ?? null;
|
|
89
|
+
|
|
90
|
+
return { isOpen, toggle, close, query, setQuery, results, activeIndex, moveUp, moveDown, activeItem };
|
|
91
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { UseCase } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
export interface FlowAnimationState {
|
|
6
|
+
activeStep: number;
|
|
7
|
+
flowNodeIds: string[] | null;
|
|
8
|
+
isPlaying: boolean;
|
|
9
|
+
play: () => void;
|
|
10
|
+
pause: () => void;
|
|
11
|
+
next: () => void;
|
|
12
|
+
prev: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NOOP = () => {};
|
|
16
|
+
|
|
17
|
+
const IDLE_STATE: FlowAnimationState = {
|
|
18
|
+
activeStep: -1,
|
|
19
|
+
flowNodeIds: null,
|
|
20
|
+
isPlaying: false,
|
|
21
|
+
play: NOOP,
|
|
22
|
+
pause: NOOP,
|
|
23
|
+
next: NOOP,
|
|
24
|
+
prev: NOOP,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function useFlowAnimation(
|
|
28
|
+
useCases: UseCase[],
|
|
29
|
+
selectedUseCase: string | null,
|
|
30
|
+
): FlowAnimationState {
|
|
31
|
+
const [activeStep, setActiveStep] = useState(0);
|
|
32
|
+
const [isPlaying, setIsPlaying] = useState(true);
|
|
33
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
+
|
|
35
|
+
const uc = selectedUseCase ? useCases.find((u) => u.id === selectedUseCase) : undefined;
|
|
36
|
+
const flow = uc?.flow;
|
|
37
|
+
const flowLength = flow?.length ?? 0;
|
|
38
|
+
|
|
39
|
+
// Reset on use case change
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
setActiveStep(0);
|
|
42
|
+
setIsPlaying(true);
|
|
43
|
+
}, [selectedUseCase]);
|
|
44
|
+
|
|
45
|
+
// Auto-advance timer
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!flow || flowLength < 2 || !isPlaying) return;
|
|
48
|
+
|
|
49
|
+
timerRef.current = setTimeout(() => {
|
|
50
|
+
setActiveStep((prev) => {
|
|
51
|
+
const next = prev + 1;
|
|
52
|
+
return next >= flowLength ? 0 : next;
|
|
53
|
+
});
|
|
54
|
+
}, 800);
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
if (timerRef.current !== null) {
|
|
58
|
+
clearTimeout(timerRef.current);
|
|
59
|
+
timerRef.current = null;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}, [flow, flowLength, isPlaying, activeStep]);
|
|
63
|
+
|
|
64
|
+
const play = useCallback(() => setIsPlaying(true), []);
|
|
65
|
+
const pause = useCallback(() => setIsPlaying(false), []);
|
|
66
|
+
|
|
67
|
+
const next = useCallback(() => {
|
|
68
|
+
setIsPlaying(false);
|
|
69
|
+
setActiveStep((prev) => (prev + 1 >= flowLength ? prev : prev + 1));
|
|
70
|
+
}, [flowLength]);
|
|
71
|
+
|
|
72
|
+
const prev = useCallback(() => {
|
|
73
|
+
setIsPlaying(false);
|
|
74
|
+
setActiveStep((prev) => (prev <= 0 ? 0 : prev - 1));
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
if (!flow || flowLength < 2) {
|
|
78
|
+
return IDLE_STATE;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { activeStep, flowNodeIds: flow, isPlaying, play, pause, next, prev };
|
|
82
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
interface KeyboardShortcutActions {
|
|
4
|
+
onTogglePalette: () => void;
|
|
5
|
+
onEscape: () => void;
|
|
6
|
+
isPaletteOpen: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useKeyboardShortcuts(actions: KeyboardShortcutActions): void {
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
12
|
+
// Skip when focused on form elements
|
|
13
|
+
const tag = (e.target as HTMLElement).tagName;
|
|
14
|
+
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
|
|
15
|
+
// Allow Escape even in input
|
|
16
|
+
if (e.key === 'Escape') {
|
|
17
|
+
actions.onEscape();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Cmd/Ctrl+K: toggle palette
|
|
24
|
+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
actions.onTogglePalette();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (e.key === 'Escape') {
|
|
31
|
+
actions.onEscape();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
37
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
38
|
+
}, [actions]);
|
|
39
|
+
}
|
|
@@ -3,6 +3,7 @@ import { useQueryState, parseAsString } from 'nuqs';
|
|
|
3
3
|
import type { Edge } from '@xyflow/react';
|
|
4
4
|
|
|
5
5
|
import type { ArchFlowNode, UseCase } from '../types.ts';
|
|
6
|
+
import { useFlowAnimation } from './useFlowAnimation.ts';
|
|
6
7
|
|
|
7
8
|
function isNodeActive(node: ArchFlowNode, activeIds: Set<string>): boolean {
|
|
8
9
|
if (node.data.isGroup && node.data.memberNodes) {
|
|
@@ -11,8 +12,20 @@ function isNodeActive(node: ArchFlowNode, activeIds: Set<string>): boolean {
|
|
|
11
12
|
return activeIds.has(node.id);
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/** Resolve a node's effective ID for flow matching (group → first active member) */
|
|
16
|
+
function getFlowId(node: ArchFlowNode, flowSet: Set<string>): string | null {
|
|
17
|
+
if (flowSet.has(node.id)) return node.id;
|
|
18
|
+
if (node.data.isGroup && node.data.memberNodes) {
|
|
19
|
+
const member = node.data.memberNodes.find((m) => flowSet.has(m.id));
|
|
20
|
+
if (member) return member.id;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
export function useUseCaseFilter(nodes: ArchFlowNode[], edges: Edge[], useCases: UseCase[]) {
|
|
15
26
|
const [selectedUseCase, setSelectedUseCase] = useQueryState('uc', parseAsString.withOptions({ history: 'replace' }));
|
|
27
|
+
const flowState = useFlowAnimation(useCases, selectedUseCase);
|
|
28
|
+
const { activeStep, flowNodeIds } = flowState;
|
|
16
29
|
|
|
17
30
|
const categories = useMemo(() => {
|
|
18
31
|
const set = new Set<string>();
|
|
@@ -27,19 +40,77 @@ export function useUseCaseFilter(nodes: ArchFlowNode[], edges: Edge[], useCases:
|
|
|
27
40
|
const uc = useCases.find((u) => u.id === selectedUseCase);
|
|
28
41
|
if (!uc) return nodes;
|
|
29
42
|
const activeIds = new Set(uc.nodeIds);
|
|
43
|
+
|
|
44
|
+
// No flow — static highlight (backward compat)
|
|
45
|
+
if (!flowNodeIds) {
|
|
46
|
+
return nodes.map((node) => {
|
|
47
|
+
if (node.hidden) return node;
|
|
48
|
+
const active = isNodeActive(node, activeIds);
|
|
49
|
+
return {
|
|
50
|
+
...node,
|
|
51
|
+
style: {
|
|
52
|
+
...node.style,
|
|
53
|
+
opacity: active ? 1 : 0.15,
|
|
54
|
+
transition: 'opacity 0.3s',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Flow animation mode
|
|
61
|
+
const flowSet = new Set(flowNodeIds);
|
|
62
|
+
const activeFlowId = flowNodeIds[activeStep];
|
|
63
|
+
const passedIds = new Set(flowNodeIds.slice(0, activeStep));
|
|
64
|
+
|
|
30
65
|
return nodes.map((node) => {
|
|
31
66
|
if (node.hidden) return node;
|
|
32
67
|
const active = isNodeActive(node, activeIds);
|
|
68
|
+
if (!active) {
|
|
69
|
+
// Non-participating node
|
|
70
|
+
return {
|
|
71
|
+
...node,
|
|
72
|
+
style: { ...node.style, opacity: 0.08, transition: 'opacity 0.3s' },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const flowId = getFlowId(node, flowSet);
|
|
77
|
+
if (!flowId) {
|
|
78
|
+
// In use case but not in flow — dim slightly
|
|
79
|
+
return {
|
|
80
|
+
...node,
|
|
81
|
+
style: { ...node.style, opacity: 0.3, transition: 'opacity 0.3s' },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (flowId === activeFlowId) {
|
|
86
|
+
// Active step — glow + scale
|
|
87
|
+
return {
|
|
88
|
+
...node,
|
|
89
|
+
style: {
|
|
90
|
+
...node.style,
|
|
91
|
+
opacity: 1,
|
|
92
|
+
boxShadow: 'var(--shadow-node-flow-active)',
|
|
93
|
+
transform: 'scale(1.02)',
|
|
94
|
+
transition: 'opacity 0.3s, box-shadow 0.3s, transform 0.3s',
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (passedIds.has(flowId)) {
|
|
100
|
+
// Already passed
|
|
101
|
+
return {
|
|
102
|
+
...node,
|
|
103
|
+
style: { ...node.style, opacity: 1, transition: 'opacity 0.3s' },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Not yet reached
|
|
33
108
|
return {
|
|
34
109
|
...node,
|
|
35
|
-
style: {
|
|
36
|
-
...node.style,
|
|
37
|
-
opacity: active ? 1 : 0.15,
|
|
38
|
-
transition: 'opacity 0.3s',
|
|
39
|
-
},
|
|
110
|
+
style: { ...node.style, opacity: 0.3, transition: 'opacity 0.3s' },
|
|
40
111
|
};
|
|
41
112
|
});
|
|
42
|
-
}, [nodes, selectedUseCase, useCases]);
|
|
113
|
+
}, [nodes, selectedUseCase, useCases, flowNodeIds, activeStep]);
|
|
43
114
|
|
|
44
115
|
const filteredEdges = useMemo(() => {
|
|
45
116
|
if (!selectedUseCase) return edges;
|
|
@@ -55,19 +126,93 @@ export function useUseCaseFilter(nodes: ArchFlowNode[], edges: Edge[], useCases:
|
|
|
55
126
|
}
|
|
56
127
|
}
|
|
57
128
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
...edge
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
129
|
+
// No flow — static highlight (backward compat)
|
|
130
|
+
if (!flowNodeIds) {
|
|
131
|
+
return edges.map((edge) => ({
|
|
132
|
+
...edge,
|
|
133
|
+
style: {
|
|
134
|
+
...edge.style,
|
|
135
|
+
opacity: activeNodeIds.has(edge.source) && activeNodeIds.has(edge.target) ? 1 : 0.08,
|
|
136
|
+
transition: 'opacity 0.3s',
|
|
137
|
+
},
|
|
138
|
+
labelStyle: {
|
|
139
|
+
...((edge.labelStyle as Record<string, unknown>) ?? {}),
|
|
140
|
+
opacity: activeNodeIds.has(edge.source) && activeNodeIds.has(edge.target) ? 1 : 0,
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Flow animation mode — build flow edge lookup
|
|
146
|
+
// A flow edge is one connecting flow[i] → flow[i+1]
|
|
147
|
+
const flowEdgeMap = new Map<string, 'active' | 'passed' | 'upcoming'>();
|
|
148
|
+
for (let i = 0; i < flowNodeIds.length - 1; i++) {
|
|
149
|
+
const src = flowNodeIds[i];
|
|
150
|
+
const tgt = flowNodeIds[i + 1];
|
|
151
|
+
// Check both edge ID conventions
|
|
152
|
+
const key1 = `${src}->${tgt}`;
|
|
153
|
+
const key2 = `${tgt}->${src}`;
|
|
154
|
+
let status: 'active' | 'passed' | 'upcoming';
|
|
155
|
+
if (i + 1 === activeStep) {
|
|
156
|
+
status = 'active';
|
|
157
|
+
} else if (i + 1 <= activeStep) {
|
|
158
|
+
status = 'passed';
|
|
159
|
+
} else {
|
|
160
|
+
status = 'upcoming';
|
|
161
|
+
}
|
|
162
|
+
flowEdgeMap.set(key1, status);
|
|
163
|
+
flowEdgeMap.set(key2, status);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return edges.map((edge) => {
|
|
167
|
+
const bothActive = activeNodeIds.has(edge.source) && activeNodeIds.has(edge.target);
|
|
168
|
+
if (!bothActive) {
|
|
169
|
+
return {
|
|
170
|
+
...edge,
|
|
171
|
+
style: { ...edge.style, opacity: 0.08, transition: 'opacity 0.3s' },
|
|
172
|
+
className: '',
|
|
173
|
+
labelStyle: {
|
|
174
|
+
...((edge.labelStyle as Record<string, unknown>) ?? {}),
|
|
175
|
+
opacity: 0,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const flowStatus = flowEdgeMap.get(edge.id);
|
|
181
|
+
if (flowStatus === 'active') {
|
|
182
|
+
return {
|
|
183
|
+
...edge,
|
|
184
|
+
style: { ...edge.style, opacity: 1, stroke: undefined, strokeWidth: undefined },
|
|
185
|
+
className: 'flow-edge-active',
|
|
186
|
+
labelStyle: {
|
|
187
|
+
...((edge.labelStyle as Record<string, unknown>) ?? {}),
|
|
188
|
+
opacity: 1,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (flowStatus === 'passed') {
|
|
193
|
+
return {
|
|
194
|
+
...edge,
|
|
195
|
+
style: { ...edge.style, opacity: 1, transition: 'opacity 0.3s' },
|
|
196
|
+
className: '',
|
|
197
|
+
labelStyle: {
|
|
198
|
+
...((edge.labelStyle as Record<string, unknown>) ?? {}),
|
|
199
|
+
opacity: 1,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// In use case but not a flow edge, or upcoming flow edge
|
|
205
|
+
return {
|
|
206
|
+
...edge,
|
|
207
|
+
style: { ...edge.style, opacity: 0.3, transition: 'opacity 0.3s' },
|
|
208
|
+
className: '',
|
|
209
|
+
labelStyle: {
|
|
210
|
+
...((edge.labelStyle as Record<string, unknown>) ?? {}),
|
|
211
|
+
opacity: 0.3,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
}, [edges, nodes, selectedUseCase, useCases, flowNodeIds, activeStep]);
|
|
216
|
+
|
|
217
|
+
return { selectedUseCase, setSelectedUseCase, categories, filteredNodes, filteredEdges, flowInfo: flowState };
|
|
73
218
|
}
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
--shadow-panel: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
|
34
34
|
--shadow-node: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
35
35
|
--shadow-node-selected: 0 0 0 2px rgba(37, 99, 235, 0.25);
|
|
36
|
+
--shadow-node-flow-active: 0 0 12px 2px rgba(59, 130, 246, 0.5);
|
|
36
37
|
|
|
37
38
|
/* Category colors (light) */
|
|
38
39
|
--cat-controller-bg: #dbeafe;
|
|
@@ -50,6 +51,9 @@
|
|
|
50
51
|
--cat-model-bg: #fee2e2;
|
|
51
52
|
--cat-model-border: #ef4444;
|
|
52
53
|
--cat-model-text: #991b1b;
|
|
54
|
+
--cat-database-bg: #fef3c7;
|
|
55
|
+
--cat-database-border: #d97706;
|
|
56
|
+
--cat-database-text: #92400e;
|
|
53
57
|
--cat-external-bg: #f3f4f6;
|
|
54
58
|
--cat-external-border: #6b7280;
|
|
55
59
|
--cat-external-text: #374151;
|
|
@@ -88,6 +92,7 @@
|
|
|
88
92
|
--shadow-panel: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px -1px rgba(0, 0, 0, 0.4);
|
|
89
93
|
--shadow-node: 0 1px 3px rgba(0, 0, 0, 0.3);
|
|
90
94
|
--shadow-node-selected: 0 0 0 2px rgba(96, 165, 250, 0.3);
|
|
95
|
+
--shadow-node-flow-active: 0 0 12px 2px rgba(96, 165, 250, 0.4);
|
|
91
96
|
|
|
92
97
|
/* Category colors (dark) */
|
|
93
98
|
--cat-controller-bg: #1e293b;
|
|
@@ -105,6 +110,9 @@
|
|
|
105
110
|
--cat-model-bg: #2b1111;
|
|
106
111
|
--cat-model-border: #f87171;
|
|
107
112
|
--cat-model-text: #fca5a5;
|
|
113
|
+
--cat-database-bg: #2b1f06;
|
|
114
|
+
--cat-database-border: #fbbf24;
|
|
115
|
+
--cat-database-text: #fde68a;
|
|
108
116
|
--cat-external-bg: #1e2330;
|
|
109
117
|
--cat-external-border: #9ca3af;
|
|
110
118
|
--cat-external-text: #d1d5db;
|
|
@@ -119,6 +127,17 @@
|
|
|
119
127
|
--cat-fallback-text: #d6d3d1;
|
|
120
128
|
}
|
|
121
129
|
|
|
130
|
+
/* ─── Flow Animation ─── */
|
|
131
|
+
@keyframes dash-flow {
|
|
132
|
+
to { stroke-dashoffset: -20; }
|
|
133
|
+
}
|
|
134
|
+
.react-flow__edge.flow-edge-active .react-flow__edge-path {
|
|
135
|
+
stroke: var(--color-interactive-primary) !important;
|
|
136
|
+
stroke-width: 2.5 !important;
|
|
137
|
+
stroke-dasharray: 8 4;
|
|
138
|
+
animation: dash-flow 0.6s linear infinite;
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
html,
|
|
123
142
|
body,
|
|
124
143
|
#root {
|