concept-atlas-dense-explain 0.7.0 → 0.8.0
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/package.json +1 -1
- package/skill/SKILL.md +15 -17
- package/template/src/app/App.jsx +52 -61
- package/template/src/app/navigation.js +41 -0
- package/template/src/app/search.js +84 -0
- package/template/src/model/concept-schema.js +15 -0
- package/template/src/model/node-kinds.js +68 -0
- package/template/src/model/normalize-content.js +53 -1
- package/template/src/model/validate-content.js +24 -0
- package/template/src/styles/core.css +53 -0
- package/template/src/styles/tokens.css +21 -0
- package/template/src/views/NodeExplorer.jsx +33 -2
- package/template/src/views/RelationGraph.jsx +118 -24
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: concept-atlas-dense-explain
|
|
3
|
-
description: Turn a topic or an existing document into an interactive Concept Atlas explainer.
|
|
3
|
+
description: Turn a topic or an existing document into an interactive Concept Atlas explainer. Generate AI-editable MDX with the concept-atlas-dense-explain npm CLI, validate its structure, and compile standalone HTML with concept nodes, relation graphs, math (KaTeX), charts, figures, citations, and reading aids. Use when a technical explanation, concept map, layered knowledge page, dense explainer, or interactive teaching page is requested.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Concept Atlas Dense Explain
|
|
7
7
|
|
|
8
|
-
Drive everything through the `concept-atlas-dense-explain` npm CLI. This skill is intentionally lightweight: do not copy implementation files from the skill directory
|
|
9
|
-
|
|
10
|
-
If the user only wants the prompt/methodology and not files, still choose a shell and emit valid MDX; the CLI is needed only to compile.
|
|
8
|
+
Drive everything through the `concept-atlas-dense-explain` npm CLI. This skill is intentionally lightweight: do not copy implementation files from the skill directory or recreate the React/Vite app. If a command name is unclear, run `npx concept-atlas-dense-explain help`. If the user only wants the prompt/methodology and not files, still choose a shell and emit valid MDX; the CLI is needed only to compile.
|
|
11
9
|
|
|
12
10
|
## Workflow
|
|
13
11
|
|
|
14
|
-
1. Choose one page shell: `atlas` (concept graph with node navigation) or `scroll` (continuous document). Recommend `atlas` when the reader
|
|
15
|
-
2. **Learn the components from the canonical guide before authoring.** Generate
|
|
12
|
+
1. Choose one page shell: `atlas` (concept graph with node navigation) or `scroll` (continuous document). Recommend `atlas` when the reader drills into concepts or follows relations, `scroll` for linear argument, tutorials, and reports. The library is shared. If an `.mdx` already exists, detect its shell and work with it.
|
|
13
|
+
2. **Learn the components from the canonical guide before authoring.** Generate it for the chosen shell and read it:
|
|
16
14
|
```bash
|
|
17
15
|
npx concept-atlas-dense-explain guide --mode atlas -o ./concept-atlas-atlas-guide.mdx
|
|
18
16
|
npx concept-atlas-dense-explain guide --mode scroll -o ./concept-atlas-scroll-guide.mdx
|
|
19
17
|
```
|
|
20
|
-
It is
|
|
18
|
+
It is real, compilable MDX showing that shell's components and their exact props; search it for a component name instead of guessing. Delete it when done.
|
|
21
19
|
3. Start from a skeleton when useful: `npx concept-atlas-dense-explain create <file>.mdx --mode atlas|scroll`. `create` and `guide` refuse to overwrite an existing file unless `--force` is passed.
|
|
22
20
|
4. Write the semantic MDX into the user's `.mdx` file (see Authoring rules).
|
|
23
21
|
5. Validate before rendering:
|
|
@@ -25,17 +23,17 @@ If the user only wants the prompt/methodology and not files, still choose a shel
|
|
|
25
23
|
npx concept-atlas-dense-explain validate <file>.mdx --mode atlas|scroll
|
|
26
24
|
npx concept-atlas-dense-explain validate <file>.mdx --json
|
|
27
25
|
```
|
|
28
|
-
|
|
29
|
-
6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [--inline-mermaid] [--mermaid-cdn <url>]`. Output is a standalone HTML beside the MDX unless `-o` is given. Validation errors abort the build
|
|
30
|
-
7. **Appearance (optional)**: pages ship
|
|
31
|
-
8. For several documents, pass them all in one call: `npx concept-atlas-dense-explain a.mdx b.mdx c.mdx -o dist --force [--concurrency 3]
|
|
26
|
+
Diagnostics are `CODE line:column message`. Fix all `error`s and re-run; address warnings when cheap.
|
|
27
|
+
6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [--inline-mermaid] [--mermaid-cdn <url>]`. Output is a standalone HTML beside the MDX unless `-o` is given. Validation errors abort the build (`--no-validate` forces a knowingly broken build). Mermaid loads from a CDN at runtime by default (needs network); pass `--inline-mermaid` for a fully offline single file.
|
|
28
|
+
7. **Appearance (optional)**: pages ship a reader-facing appearance menu — palette (`aurora` indigo, `ember` gold, `verdant` forest, `sakura` pink-plum, `noir` ink), a dark/light toggle, and a component style pack (`manuscript` editorial marginalia, `classic` boxed cards, `shadcn` hairline-bordered minimal UI, `elastic` bordered observability panels). The shipped default is aurora × manuscript × light; choices persist in localStorage across both carriers. Bake different compile-time defaults with `--skin ember --default-mode dark --style classic` (or `CONCEPT_ATLAS_SKIN` / `CONCEPT_ATLAS_DEFAULT_MODE` / `CONCEPT_ATLAS_STYLE` on the repo build); `--default-mode` honors `dark`/`light` and resolves `system` to the carrier default (`light`). Bake a default only when the user asks for one — content MDX never sets appearance.
|
|
29
|
+
8. For several documents, pass them all in one call: `npx concept-atlas-dense-explain a.mdx b.mdx c.mdx -o dist --force [--concurrency 3]` (`-o` is then a directory; everything validates first, then builds in parallel). Builds bundle only the heavy renderers the content uses: no `<Math>` skips KaTeX's ~1.4MB inlined fonts, and Mermaid stays on a CDN. Never add dummy `<Math>`/`<Mermaid>` nodes to "enable" them.
|
|
32
30
|
9. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
|
|
33
31
|
|
|
34
32
|
## Carriers
|
|
35
33
|
|
|
36
|
-
- `atlas`: `ExplainPage` → `ConceptGraph` → `ConceptNode`, plus `Children`/`ConceptRef` and cross-branch `Relation`s. Exactly one `L0` root, several `L1` branches, depth to `L3`/`L4`. Shared components live inside nodes.
|
|
34
|
+
- `atlas`: `ExplainPage` → `ConceptGraph` → `ConceptNode`, plus `Children`/`ConceptRef` and cross-branch `Relation`s. Exactly one `L0` root, several `L1` branches, depth to `L3`/`L4`. Shared components live inside nodes. `ConceptNode` also takes an optional `kind` (see Authoring rules) that labels a node's knowledge role independently of its level.
|
|
37
35
|
- `scroll`: `ScrollDocument` → `ScrollHeader` + `ScrollSection` (+ `ScrollProse`, `ScrollGrid`). Shared components live inside sections. The shell auto-builds a table of contents and reading progress from section titles — do not hand-build navigation.
|
|
38
|
-
- The browser tab comes from the shell, not the build flags: `ExplainPage title
|
|
36
|
+
- The browser tab comes from the shell, not the build flags: `ExplainPage title` (atlas) or `ScrollHeader title` (scroll) becomes the `<title>`, so give it a real, specific name — never a placeholder like "主题名称". Every page uses a fixed 📃 favicon.
|
|
39
37
|
- Never make one MDX file both shells. When switching shells, convert only the outer structure.
|
|
40
38
|
|
|
41
39
|
## Component families
|
|
@@ -50,19 +48,19 @@ If the user only wants the prompt/methodology and not files, still choose a shel
|
|
|
50
48
|
|
|
51
49
|
- Keep MDX semantic. Never write CSS, coordinates, SVG, or replacement application code; never invent component names or props.
|
|
52
50
|
- Give each important node a claim-like title, a one-sentence `summary`, and real substance (`Definition`, `Mechanism`, `Example`, `Boundary`, `Evidence`, a model, a chart, or math). Do not restate the same text across `Overview`, `Definition`, and `Insight`.
|
|
51
|
+
- **`kind` (optional, atlas)**: tag a node's knowledge role independently of `level`. One of `system`, `stage`, `mechanism`, `artifact`, `failure`, `tool`, `boundary`, `decision`. It powers the graph's "知识类型" filter and a node badge, so use it where the role is clear rather than on every node. Two opt-in contracts fire once you declare one: `kind="mechanism"` should contain an `Invariant` or `Evidence`, and `kind="failure"` should contain a `FailureMode` (with `symptom`/`cause`/`evidence`/`remedy`).
|
|
53
52
|
- Array props are arrays of objects: `Flow steps={[{title, description}]}`, `Timeline events={[{label, content}]}`, `MatrixModel cells={[{title, description, tone}]}`, `DecisionMatrix headers={[...]} rows={[[...]]}`, `Chart data={[{label, value}]}`, `References items={[{id, authors, year, title, url, source}]}`. The validator warns (`PROP_EXPECTS_ARRAY`) when an array prop gets a string or non-array.
|
|
54
53
|
- `Relation type` must be one of `prerequisite`, `causes`, `produces`, `uses`, `implements`, `contrasts`, `depends-on`, `exception-of`, `precedes`, and each `Relation` needs a `label`. Parent/child hierarchy is implicit (via `parent` and `Children`/`ConceptRef`) — do not express it with a `Relation`.
|
|
55
54
|
- **Math**: MDX parses `{ ... }` in children as expressions, so pass LaTeX with braces or backslashes through `formula`: `<Math formula="r_{\text{ann}} = (1 + r)^{12} - 1" />`, `<MathBlock formula="I(x) = -\log_2 p(x)" variables={[{symbol, description}]} />`. Brace-free children such as `<Math>\log_2 N</Math>` are fine. The validator warns (`MATH_CHILDREN_BRACES`).
|
|
56
55
|
- **Chart**: `type` is `bar` | `line` | `pie`; use `data` for bar/pie and `labels` + `series={[{name, values}]}` for line. Charts follow theme colors.
|
|
57
|
-
- **Figure**: a relative `src` (`./assets/diagram.png`) is inlined as base64 at build time so the HTML stays standalone; `http(s)` URLs stay links. Always set `alt`; add `label` and `caption` for a numbered caption. A missing relative file
|
|
58
|
-
- **Figure size**: inlining is what makes a screenshot-heavy page large
|
|
56
|
+
- **Figure**: a relative `src` (`./assets/diagram.png`) is inlined as base64 at build time so the HTML stays standalone; `http(s)` URLs stay links. Always set `alt`; add `label` and `caption` for a numbered caption. A missing relative file warns (`ASSET_MISSING`) and shows a placeholder. Readers can click a figure to open it full-screen (zoom, drag, `Esc`) — mention it for diagram-heavy pages.
|
|
57
|
+
- **Figure size & cost**: inlining images is what makes a screenshot-heavy page large; a page with no heavy renderers otherwise lands near 250KB. When the user cares, compile with `--link-assets` to keep images as relative links (measured 1.51MB → 270KB); the output must then sit beside the MDX's `assets/`, and the CLI warns if `-o` points elsewhere — tell the user that trade-off instead of choosing silently.
|
|
59
58
|
- **Cite/References**: `<Cite id="..." />` renders `[n]` from the matching item's position in `<References items={...} />`. In `scroll`, `References` can sit anywhere. In `atlas`, keep the cites and the `References` block in the same node, because node content only renders when that node is open.
|
|
60
59
|
- Continuous reading is configured on the shell, not with manual CSS: `spacing="compact|comfortable|airy"` for rhythm, `fontSize="compact|normal|large|xlarge"` (or numeric `scale`/`lineHeight`) for text size.
|
|
61
|
-
- Be brief about cost: the build only bundles the heavy optional renderers the document actually uses, so a page with no `<Math>`/`<Mermaid>` comes out around 250KB. Inlined figures are usually the largest remaining cost — a page with a dozen screenshots lands near 1–2MB, which is normal for an offline explainer. Mention it if the user cares about file size.
|
|
62
60
|
|
|
63
61
|
## Validation diagnostics
|
|
64
62
|
|
|
65
|
-
`validate` and the build print `CODE line:column message`. Fix these `error`s before building: `UNKNOWN_COMPONENT`, `CARRIER_MISSING`, `CARRIER_CONFLICT`, `CARRIER_MODE_MISMATCH`, `NODE_MISSING_ID`, `DUPLICATE_NODE_ID`, `NODE_MISSING_TITLE`, `MISSING_PARENT`, `GRAPH_ROOT_UNRESOLVED`, `REF_MISSING_ID`, `REF_UNRESOLVED`, `RELATION_FROM_UNRESOLVED`, `RELATION_TO_UNRESOLVED`. Warnings worth fixing: `NODE_MISSING_SUMMARY`, `NODE_NO_CORE_CONTENT`, `UNKNOWN_LEVEL`, `UNKNOWN_RELATION_TYPE`, `RELATION_MISSING_LABEL`, `RELATION_SELF`, `PROP_EXPECTS_ARRAY`, `MATH_CHILDREN_BRACES`, `PROSE_EXPRESSION`, `FRONTMATTER_UNSUPPORTED`, `GRAPH_MISSING_ROOT`, `FIGURE_MISSING_SRC`, `ASSET_MISSING`, `REF_SELF`; `NO_ROOT_LEVEL` and `
|
|
63
|
+
`validate` and the build print `CODE line:column message`. Fix these `error`s before building: `UNKNOWN_COMPONENT`, `CARRIER_MISSING`, `CARRIER_CONFLICT`, `CARRIER_MODE_MISMATCH`, `NODE_MISSING_ID`, `DUPLICATE_NODE_ID`, `NODE_MISSING_TITLE`, `MISSING_PARENT`, `GRAPH_ROOT_UNRESOLVED`, `REF_MISSING_ID`, `REF_UNRESOLVED`, `RELATION_FROM_UNRESOLVED`, `RELATION_TO_UNRESOLVED`. Warnings worth fixing: `NODE_MISSING_SUMMARY`, `NODE_NO_CORE_CONTENT`, `UNKNOWN_LEVEL`, `UNKNOWN_KIND`, `MECHANISM_KIND_UNVERIFIED`, `FAILURE_KIND_UNSTRUCTURED`, `FAILURE_MODE_EMPTY`, `UNKNOWN_RELATION_TYPE`, `RELATION_MISSING_LABEL`, `RELATION_SELF`, `PROP_EXPECTS_ARRAY`, `MATH_CHILDREN_BRACES`, `PROSE_EXPRESSION`, `FRONTMATTER_UNSUPPORTED`, `GRAPH_MISSING_ROOT`, `FIGURE_MISSING_SRC`, `ASSET_MISSING`, `REF_SELF`; `NO_ROOT_LEVEL`, `MULTIPLE_ROOT_LEVEL`, `MECHANISM_KIND_UNVERIFIED` and `FAILURE_KIND_UNSTRUCTURED` are warnings that `--strict` promotes to errors.
|
|
66
64
|
|
|
67
65
|
## Before you report
|
|
68
66
|
|
package/template/src/app/App.jsx
CHANGED
|
@@ -3,11 +3,19 @@ import { Network, Compass, Sun, Moon, Search, X, Link as LinkIcon } from 'lucide
|
|
|
3
3
|
import { buildGraphModel } from '../model/concept-schema.js';
|
|
4
4
|
import { extractConceptData } from '../model/normalize-content.js';
|
|
5
5
|
import { useAppearance } from './use-appearance.js';
|
|
6
|
+
import { pushNode, stepHistory, syncFromLocation } from './navigation.js';
|
|
7
|
+
import { searchNodes } from './search.js';
|
|
8
|
+
import { NODE_KINDS } from '../model/node-kinds.js';
|
|
6
9
|
import { SkinPicker } from '../components/SkinPicker.jsx';
|
|
7
10
|
import { NodeExplorer } from '../views/NodeExplorer.jsx';
|
|
8
11
|
import { RelationGraph } from '../views/RelationGraph.jsx';
|
|
9
12
|
import '../styles/concept-explain.css';
|
|
10
13
|
|
|
14
|
+
function readNodeFromHash() {
|
|
15
|
+
if (typeof window === 'undefined') return null;
|
|
16
|
+
return new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
export function App({ mdxContent, initialData }) {
|
|
12
20
|
// Extract graph model from MDX JSX Element or raw data
|
|
13
21
|
const [graph] = useState(() => {
|
|
@@ -28,54 +36,41 @@ export function App({ mdxContent, initialData }) {
|
|
|
28
36
|
|
|
29
37
|
// Global shared state
|
|
30
38
|
const [currentView, setCurrentView] = useState('explore'); // 'explore' | 'graph'
|
|
31
|
-
const
|
|
32
|
-
const hashNode =
|
|
33
|
-
return hashNode && graph.nodes.has(hashNode) ? hashNode :
|
|
34
|
-
});
|
|
39
|
+
const initialHashNode = (() => {
|
|
40
|
+
const hashNode = readNodeFromHash();
|
|
41
|
+
return hashNode && graph.nodes.has(hashNode) ? hashNode : null;
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
const [currentNodeId, setCurrentNodeId] = useState(
|
|
45
|
+
() => initialHashNode || graph.meta.rootId || '',
|
|
46
|
+
);
|
|
35
47
|
const [selectedLevel, setSelectedLevel] = useState(null);
|
|
36
|
-
const [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
40
|
-
const [historyIndex, setHistoryIndex] = useState(0);
|
|
48
|
+
const [nav, setNav] = useState(() => ({
|
|
49
|
+
entries: (initialHashNode ? [initialHashNode] : [graph.meta.rootId]).filter(Boolean),
|
|
50
|
+
index: 0,
|
|
51
|
+
}));
|
|
41
52
|
const [globalQuery, setGlobalQuery] = useState('');
|
|
42
53
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
43
54
|
|
|
44
|
-
const searchResults = useMemo(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const searchable = [
|
|
49
|
-
node.title, node.id, node.summary, node.definition, node.overview,
|
|
50
|
-
node.mechanism, node.input, node.output,
|
|
51
|
-
...(node.examples || []).flatMap(item => [item.title, item.content]),
|
|
52
|
-
...(node.glossary || []).flatMap(item => [item.term, item.definition]),
|
|
53
|
-
...(node.boundaries || []).flatMap(item => [item.title, item.content]),
|
|
54
|
-
].filter(value => typeof value === 'string').join(' ').toLowerCase();
|
|
55
|
-
return searchable.includes(query) ? node : null;
|
|
56
|
-
}).filter(Boolean).slice(0, 8);
|
|
57
|
-
}, [globalQuery, graph.nodes]);
|
|
55
|
+
const searchResults = useMemo(
|
|
56
|
+
() => searchNodes(Array.from(graph.nodes.values()), globalQuery),
|
|
57
|
+
[globalQuery, graph.nodes],
|
|
58
|
+
);
|
|
58
59
|
|
|
59
60
|
const navigateToNode = (nodeId, { replace = false } = {}) => {
|
|
60
61
|
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
61
62
|
setCurrentNodeId(nodeId);
|
|
62
|
-
|
|
63
|
-
const base = previous.slice(0, historyIndex + 1);
|
|
64
|
-
if (base[base.length - 1] === nodeId) return previous;
|
|
65
|
-
const next = [...base, nodeId];
|
|
66
|
-
setHistoryIndex(next.length - 1);
|
|
67
|
-
return next;
|
|
68
|
-
});
|
|
63
|
+
setNav(previous => pushNode(previous, nodeId));
|
|
69
64
|
const nextHash = `#node=${encodeURIComponent(nodeId)}`;
|
|
70
65
|
if (replace) window.history.replaceState({}, '', nextHash);
|
|
71
66
|
else window.history.pushState({}, '', nextHash);
|
|
72
67
|
};
|
|
73
68
|
|
|
74
69
|
const moveHistory = (direction) => {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
const nodeId =
|
|
70
|
+
const next = stepHistory(nav, direction);
|
|
71
|
+
if (!next) return;
|
|
72
|
+
setNav(next);
|
|
73
|
+
const nodeId = next.entries[next.index];
|
|
79
74
|
setCurrentNodeId(nodeId);
|
|
80
75
|
window.history.pushState({}, '', `#node=${encodeURIComponent(nodeId)}`);
|
|
81
76
|
};
|
|
@@ -83,10 +78,26 @@ export function App({ mdxContent, initialData }) {
|
|
|
83
78
|
// Keyboard navigation shortcuts
|
|
84
79
|
useEffect(() => {
|
|
85
80
|
const handleKeyDown = (e) => {
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
if (e.isComposing) return;
|
|
82
|
+
|
|
83
|
+
// Alt + arrows move through browsing history. Handled before the modifier
|
|
84
|
+
// guard below, otherwise the altKey short-circuit makes them unreachable.
|
|
85
|
+
if (e.altKey && e.key === 'ArrowLeft') {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
moveHistory(-1);
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
90
|
+
if (e.altKey && e.key === 'ArrowRight') {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
moveHistory(1);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Toggle views with 1 and 2 or 'g' and 'e' if not focused on input.
|
|
97
|
+
const target = document.activeElement;
|
|
98
|
+
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
|
|
99
|
+
if (e.metaKey || e.ctrlKey || e.altKey || typing) return;
|
|
100
|
+
|
|
90
101
|
const key = e.key.toLowerCase();
|
|
91
102
|
if (e.key === '1' || key === 'e') {
|
|
92
103
|
setCurrentView('explore');
|
|
@@ -95,18 +106,7 @@ export function App({ mdxContent, initialData }) {
|
|
|
95
106
|
} else if (key === 't') {
|
|
96
107
|
toggleTheme();
|
|
97
108
|
} else if (e.key === 'Escape') {
|
|
98
|
-
//
|
|
99
|
-
if (e.altKey && e.key === 'ArrowLeft') {
|
|
100
|
-
e.preventDefault();
|
|
101
|
-
moveHistory(-1);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
if (e.altKey && e.key === 'ArrowRight') {
|
|
105
|
-
e.preventDefault();
|
|
106
|
-
moveHistory(1);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
// Return to root or parent
|
|
109
|
+
// Return to the parent node.
|
|
110
110
|
const curr = graph.nodes.get(currentNodeId);
|
|
111
111
|
if (curr && curr.parent) {
|
|
112
112
|
navigateToNode(curr.parent);
|
|
@@ -116,23 +116,14 @@ export function App({ mdxContent, initialData }) {
|
|
|
116
116
|
|
|
117
117
|
window.addEventListener('keydown', handleKeyDown);
|
|
118
118
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
119
|
-
}, [graph, currentNodeId,
|
|
119
|
+
}, [graph, currentNodeId, nav]);
|
|
120
120
|
|
|
121
121
|
useEffect(() => {
|
|
122
122
|
const handlePopState = () => {
|
|
123
|
-
const nodeId =
|
|
123
|
+
const nodeId = readNodeFromHash();
|
|
124
124
|
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
125
125
|
setCurrentNodeId(nodeId);
|
|
126
|
-
|
|
127
|
-
const index = previous.lastIndexOf(nodeId);
|
|
128
|
-
if (index >= 0) {
|
|
129
|
-
setHistoryIndex(index);
|
|
130
|
-
return previous;
|
|
131
|
-
}
|
|
132
|
-
const next = [...previous, nodeId];
|
|
133
|
-
setHistoryIndex(next.length - 1);
|
|
134
|
-
return next;
|
|
135
|
-
});
|
|
126
|
+
setNav(previous => syncFromLocation(previous, nodeId));
|
|
136
127
|
};
|
|
137
128
|
window.addEventListener('popstate', handlePopState);
|
|
138
129
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
@@ -176,7 +167,7 @@ export function App({ mdxContent, initialData }) {
|
|
|
176
167
|
<div className="global-search-results" role="listbox">
|
|
177
168
|
{searchResults.map(node => (
|
|
178
169
|
<button type="button" key={node.id} onClick={() => { navigateToNode(node.id); setGlobalQuery(''); }} role="option">
|
|
179
|
-
<span>{node.title}</span><small>{node.level} · {node.summary || node.id}</small>
|
|
170
|
+
<span>{node.title}</span><small>{node.level}{node.kind && NODE_KINDS[node.kind] ? ` · ${NODE_KINDS[node.kind].label}` : ''} · {node.summary || node.id}</small>
|
|
180
171
|
</button>
|
|
181
172
|
))}
|
|
182
173
|
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure browsing-history helpers for the atlas carrier.
|
|
3
|
+
*
|
|
4
|
+
* History is a single `{ entries, index }` value instead of two separate
|
|
5
|
+
* `useState` calls. Keeping them together removes the side effect that used to
|
|
6
|
+
* live inside the `setHistory` updater (which React StrictMode can double
|
|
7
|
+
* invoke) and makes the whole navigation model trivially unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Push a node onto the history, truncating any forward entries.
|
|
12
|
+
* Returns the same object when `nodeId` is already the current entry so React
|
|
13
|
+
* can bail out of the state update.
|
|
14
|
+
*/
|
|
15
|
+
export function pushNode(state, nodeId) {
|
|
16
|
+
const base = state.entries.slice(0, state.index + 1);
|
|
17
|
+
if (base[base.length - 1] === nodeId) return state;
|
|
18
|
+
const entries = [...base, nodeId];
|
|
19
|
+
return { entries, index: entries.length - 1 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Move one step through the history. Returns `null` when the move would leave
|
|
24
|
+
* the bounds, so the caller can avoid a redundant re-render.
|
|
25
|
+
*/
|
|
26
|
+
export function stepHistory(state, direction) {
|
|
27
|
+
const nextIndex = Math.max(0, Math.min(state.entries.length - 1, state.index + direction));
|
|
28
|
+
if (nextIndex === state.index) return null;
|
|
29
|
+
return { entries: state.entries, index: nextIndex };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a node id arriving from a `popstate` event (browser back/forward).
|
|
34
|
+
* Reuses an existing entry when present so the back stack stays consistent.
|
|
35
|
+
*/
|
|
36
|
+
export function syncFromLocation(state, nodeId) {
|
|
37
|
+
const existing = state.entries.lastIndexOf(nodeId);
|
|
38
|
+
if (existing >= 0) return { entries: state.entries, index: existing };
|
|
39
|
+
const entries = [...state.entries, nodeId];
|
|
40
|
+
return { entries, index: entries.length - 1 };
|
|
41
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { NODE_KINDS } from '../model/node-kinds.js';
|
|
3
|
+
|
|
4
|
+
const MAX_DEPTH = 6;
|
|
5
|
+
const MAX_LENGTH = 4000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Flatten whatever an MDX component renders — strings, React elements, arrays
|
|
9
|
+
* and prop objects — into searchable plain text. Bounded in depth and length so
|
|
10
|
+
* a pathological tree cannot stall the search box.
|
|
11
|
+
*/
|
|
12
|
+
export function collectText(value, depth = 0) {
|
|
13
|
+
if (depth > MAX_DEPTH || value === null || value === undefined || typeof value === 'boolean') return '';
|
|
14
|
+
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
return value.map(item => collectText(item, depth + 1)).join(' ');
|
|
17
|
+
}
|
|
18
|
+
if (React.isValidElement(value)) {
|
|
19
|
+
const props = value.props || {};
|
|
20
|
+
const parts = [];
|
|
21
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
22
|
+
// `components` is the MDX component registry, never content.
|
|
23
|
+
if (key === 'children' || key === 'components') continue;
|
|
24
|
+
if (typeof prop === 'string' || typeof prop === 'number') parts.push(String(prop));
|
|
25
|
+
else if (prop && typeof prop === 'object') parts.push(collectText(prop, depth + 1));
|
|
26
|
+
}
|
|
27
|
+
parts.push(collectText(props.children, depth + 1));
|
|
28
|
+
return parts.join(' ');
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === 'object') {
|
|
31
|
+
return Object.values(value).map(item => collectText(item, depth + 1)).join(' ');
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build one lowercase haystack per node. Includes the semantic content fields,
|
|
38
|
+
* the structured argument/evidence records (Evidence, Invariant, FailureMode,
|
|
39
|
+
* Tradeoff, …) and the free-form `customSections` that the same components also
|
|
40
|
+
* render through. The kind id and its label are indexed too, so "故障" finds
|
|
41
|
+
* `kind="failure"` nodes.
|
|
42
|
+
*/
|
|
43
|
+
export function nodeSearchText(node) {
|
|
44
|
+
if (!node) return '';
|
|
45
|
+
const kind = node.kind ? NODE_KINDS[node.kind] : null;
|
|
46
|
+
const parts = [
|
|
47
|
+
node.title,
|
|
48
|
+
node.id,
|
|
49
|
+
node.kind,
|
|
50
|
+
kind ? kind.label : '',
|
|
51
|
+
node.summary,
|
|
52
|
+
node.overview,
|
|
53
|
+
node.definition,
|
|
54
|
+
node.mechanism,
|
|
55
|
+
node.input,
|
|
56
|
+
node.output,
|
|
57
|
+
node.implementation,
|
|
58
|
+
node.prerequisites,
|
|
59
|
+
node.examples,
|
|
60
|
+
node.counterexamples,
|
|
61
|
+
node.boundaries,
|
|
62
|
+
node.glossary,
|
|
63
|
+
node.learningObjectives,
|
|
64
|
+
node.keyQuestions,
|
|
65
|
+
node.evidence,
|
|
66
|
+
node.invariants,
|
|
67
|
+
node.failureModes,
|
|
68
|
+
node.tradeoffs,
|
|
69
|
+
node.customSections,
|
|
70
|
+
];
|
|
71
|
+
return parts.map(part => collectText(part)).join(' ').slice(0, MAX_LENGTH).toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rank-free substring search returning at most `limit` nodes. */
|
|
75
|
+
export function searchNodes(nodes, query, limit = 8) {
|
|
76
|
+
const needle = String(query || '').trim().toLowerCase();
|
|
77
|
+
if (!needle) return [];
|
|
78
|
+
const results = [];
|
|
79
|
+
for (const node of nodes) {
|
|
80
|
+
if (nodeSearchText(node).includes(needle)) results.push(node);
|
|
81
|
+
if (results.length >= limit) break;
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { RELATION_TYPES, LEVEL_DEFS } from './relation-types.js';
|
|
3
|
+
import { NODE_KIND_SET, normalizeKind } from './node-kinds.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Creates an empty Concept Knowledge Graph structure
|
|
@@ -26,6 +27,7 @@ export function normalizeNode(raw) {
|
|
|
26
27
|
id,
|
|
27
28
|
title: raw.title || id,
|
|
28
29
|
level: raw.level || 'L2',
|
|
30
|
+
kind: normalizeKind(raw.kind),
|
|
29
31
|
parent: raw.parent || null,
|
|
30
32
|
children: Array.isArray(raw.children) ? raw.children : [],
|
|
31
33
|
|
|
@@ -43,6 +45,16 @@ export function normalizeNode(raw) {
|
|
|
43
45
|
boundaries: raw.boundaries || [],
|
|
44
46
|
glossary: raw.glossary || [],
|
|
45
47
|
customSections: raw.customSections || [],
|
|
48
|
+
|
|
49
|
+
// Structured argument / evidence content. These mirror the matching
|
|
50
|
+
// customSections (which still render), but as data the graph, search and
|
|
51
|
+
// validator can reason about.
|
|
52
|
+
learningObjectives: Array.isArray(raw.learningObjectives) ? raw.learningObjectives : [],
|
|
53
|
+
keyQuestions: Array.isArray(raw.keyQuestions) ? raw.keyQuestions : [],
|
|
54
|
+
evidence: Array.isArray(raw.evidence) ? raw.evidence : [],
|
|
55
|
+
invariants: Array.isArray(raw.invariants) ? raw.invariants : [],
|
|
56
|
+
failureModes: Array.isArray(raw.failureModes) ? raw.failureModes : [],
|
|
57
|
+
tradeoffs: Array.isArray(raw.tradeoffs) ? raw.tradeoffs : [],
|
|
46
58
|
};
|
|
47
59
|
}
|
|
48
60
|
|
|
@@ -58,6 +70,9 @@ export function buildGraphModel(rawGraph) {
|
|
|
58
70
|
// Register all nodes
|
|
59
71
|
rawNodes.forEach(n => {
|
|
60
72
|
const node = normalizeNode(n);
|
|
73
|
+
if (n.kind && !NODE_KIND_SET.has(n.kind)) {
|
|
74
|
+
diagnostics.push({ level: 'warning', code: 'UNKNOWN_KIND', nodeId: node.id, kind: n.kind });
|
|
75
|
+
}
|
|
61
76
|
if (nodes.has(node.id)) diagnostics.push({ level: 'error', code: 'DUPLICATE_NODE_ID', nodeId: node.id });
|
|
62
77
|
nodes.set(node.id, node);
|
|
63
78
|
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge node kinds.
|
|
3
|
+
*
|
|
4
|
+
* `level` (L0–L4) answers "how deep in the zoom hierarchy is this node".
|
|
5
|
+
* `kind` answers "what role does this node play in the explanation" — a
|
|
6
|
+
* failure, a decision, a mechanism. The two axes are independent: an L2 node
|
|
7
|
+
* can be a mechanism or a boundary. Kinds let the graph, the validator and the
|
|
8
|
+
* inspector talk about knowledge type instead of only depth.
|
|
9
|
+
*
|
|
10
|
+
* The registry is the single source of truth: the validator checks against it,
|
|
11
|
+
* the graph filters by it, and the UI reads labels/tones from it. `tone` must
|
|
12
|
+
* resolve to a CSS variable defined in `tokens.css` for both themes.
|
|
13
|
+
*/
|
|
14
|
+
export const NODE_KINDS = {
|
|
15
|
+
system: {
|
|
16
|
+
label: '系统',
|
|
17
|
+
description: '整体系统、产品或问题域的全貌',
|
|
18
|
+
tone: 'var(--kind-system)',
|
|
19
|
+
},
|
|
20
|
+
stage: {
|
|
21
|
+
label: '阶段',
|
|
22
|
+
description: '生命周期阶段、处理步骤或子系统',
|
|
23
|
+
tone: 'var(--kind-stage)',
|
|
24
|
+
},
|
|
25
|
+
mechanism: {
|
|
26
|
+
label: '机制',
|
|
27
|
+
description: '可解释、可验证的工作机制',
|
|
28
|
+
tone: 'var(--kind-mechanism)',
|
|
29
|
+
},
|
|
30
|
+
artifact: {
|
|
31
|
+
label: '产物',
|
|
32
|
+
description: '阶段产出的实体、数据或文件',
|
|
33
|
+
tone: 'var(--kind-artifact)',
|
|
34
|
+
},
|
|
35
|
+
failure: {
|
|
36
|
+
label: '故障',
|
|
37
|
+
description: '失败模式、异常与边界情况',
|
|
38
|
+
tone: 'var(--kind-failure)',
|
|
39
|
+
},
|
|
40
|
+
tool: {
|
|
41
|
+
label: '工具',
|
|
42
|
+
description: '使用的工具、库或外部依赖',
|
|
43
|
+
tone: 'var(--kind-tool)',
|
|
44
|
+
},
|
|
45
|
+
boundary: {
|
|
46
|
+
label: '边界',
|
|
47
|
+
description: '约束、前提、限制与非目标',
|
|
48
|
+
tone: 'var(--kind-boundary)',
|
|
49
|
+
},
|
|
50
|
+
decision: {
|
|
51
|
+
label: '决策',
|
|
52
|
+
description: '设计取舍与选择点',
|
|
53
|
+
tone: 'var(--kind-decision)',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const NODE_KIND_NAMES = Object.keys(NODE_KINDS);
|
|
58
|
+
export const NODE_KIND_SET = new Set(NODE_KIND_NAMES);
|
|
59
|
+
|
|
60
|
+
/** Coerce an arbitrary value to a registered kind id, or null. */
|
|
61
|
+
export function normalizeKind(value) {
|
|
62
|
+
return typeof value === 'string' && NODE_KIND_SET.has(value) ? value : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Registry entry for a kind id, or null when unset/unknown. */
|
|
66
|
+
export function kindInfo(kind) {
|
|
67
|
+
return kind ? NODE_KINDS[kind] || null : null;
|
|
68
|
+
}
|
|
@@ -103,6 +103,7 @@ function parseConceptNode(nodeElement) {
|
|
|
103
103
|
id: props.id,
|
|
104
104
|
title: props.title || props.id,
|
|
105
105
|
level: props.level || 'L2',
|
|
106
|
+
kind: props.kind || null,
|
|
106
107
|
parent: props.parent !== undefined ? props.parent : null,
|
|
107
108
|
children: [],
|
|
108
109
|
summary: props.summary || '',
|
|
@@ -117,7 +118,13 @@ function parseConceptNode(nodeElement) {
|
|
|
117
118
|
counterexamples: [],
|
|
118
119
|
boundaries: [],
|
|
119
120
|
glossary: [],
|
|
120
|
-
customSections: []
|
|
121
|
+
customSections: [],
|
|
122
|
+
learningObjectives: [],
|
|
123
|
+
keyQuestions: [],
|
|
124
|
+
evidence: [],
|
|
125
|
+
invariants: [],
|
|
126
|
+
failureModes: [],
|
|
127
|
+
tradeoffs: [],
|
|
121
128
|
};
|
|
122
129
|
|
|
123
130
|
Children.forEach(props.children, child => {
|
|
@@ -195,6 +202,51 @@ function parseConceptNode(nodeElement) {
|
|
|
195
202
|
node.children.push(child.props.id);
|
|
196
203
|
}
|
|
197
204
|
break;
|
|
205
|
+
// Argument / evidence components are stored twice on purpose: as
|
|
206
|
+
// structured data for the graph, search and validator, and as the
|
|
207
|
+
// original element in customSections so existing rendering is unchanged.
|
|
208
|
+
case 'LearningObjectives':
|
|
209
|
+
node.learningObjectives.push(
|
|
210
|
+
...(Array.isArray(child.props.items) ? child.props.items.filter(Boolean) : []),
|
|
211
|
+
);
|
|
212
|
+
node.customSections.push(child);
|
|
213
|
+
break;
|
|
214
|
+
case 'KeyQuestion':
|
|
215
|
+
node.keyQuestions.push({ content: child.props.children });
|
|
216
|
+
node.customSections.push(child);
|
|
217
|
+
break;
|
|
218
|
+
case 'Evidence':
|
|
219
|
+
node.evidence.push({
|
|
220
|
+
command: child.props.command || '',
|
|
221
|
+
observes: child.props.observes || '',
|
|
222
|
+
content: child.props.children,
|
|
223
|
+
});
|
|
224
|
+
node.customSections.push(child);
|
|
225
|
+
break;
|
|
226
|
+
case 'Invariant':
|
|
227
|
+
node.invariants.push({
|
|
228
|
+
title: child.props.title || '不变量',
|
|
229
|
+
content: child.props.children,
|
|
230
|
+
});
|
|
231
|
+
node.customSections.push(child);
|
|
232
|
+
break;
|
|
233
|
+
case 'FailureMode':
|
|
234
|
+
node.failureModes.push({
|
|
235
|
+
symptom: child.props.symptom || '',
|
|
236
|
+
cause: child.props.cause || '',
|
|
237
|
+
evidence: child.props.evidence || '',
|
|
238
|
+
remedy: child.props.remedy || '',
|
|
239
|
+
content: child.props.children,
|
|
240
|
+
});
|
|
241
|
+
node.customSections.push(child);
|
|
242
|
+
break;
|
|
243
|
+
case 'Tradeoff':
|
|
244
|
+
node.tradeoffs.push({
|
|
245
|
+
title: child.props.title || '工程权衡',
|
|
246
|
+
options: Array.isArray(child.props.options) ? child.props.options : [],
|
|
247
|
+
});
|
|
248
|
+
node.customSections.push(child);
|
|
249
|
+
break;
|
|
198
250
|
default:
|
|
199
251
|
// Semantic presentation components inside node
|
|
200
252
|
node.customSections.push(child);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RELATION_TYPES, LEVEL_DEFS } from './relation-types.js';
|
|
2
|
+
import { NODE_KIND_NAMES, NODE_KIND_SET } from './node-kinds.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Single source of truth for the semantic component registry.
|
|
@@ -365,6 +366,7 @@ export function validateMdxSource(source, options = {}) {
|
|
|
365
366
|
id: attrValue(map, 'id'),
|
|
366
367
|
title: attrValue(map, 'title'),
|
|
367
368
|
level: attrValue(map, 'level') || 'L2',
|
|
369
|
+
kind: attrValue(map, 'kind'),
|
|
368
370
|
parent: attrValue(map, 'parent'),
|
|
369
371
|
summary: attrValue(map, 'summary'),
|
|
370
372
|
offset: tag.start,
|
|
@@ -489,12 +491,23 @@ export function validateMdxSource(source, options = {}) {
|
|
|
489
491
|
if (!LEVEL_SET.has(node.level)) {
|
|
490
492
|
add('warning', 'UNKNOWN_LEVEL', `节点 ${label} 的 level 无效:${node.level}`, node.offset, node.id);
|
|
491
493
|
}
|
|
494
|
+
if (node.kind && !NODE_KIND_SET.has(node.kind)) {
|
|
495
|
+
add('warning', 'UNKNOWN_KIND', `节点 ${label} 的 kind 无效:${node.kind}(可选:${NODE_KIND_NAMES.join('、')})`, node.offset, node.id);
|
|
496
|
+
}
|
|
492
497
|
if (node.parent && !nodesById.has(node.parent)) {
|
|
493
498
|
add('error', 'MISSING_PARENT', `节点 ${label} 的 parent 不存在:${node.parent}`, node.offset, node.id);
|
|
494
499
|
}
|
|
495
500
|
if (node.body && ![...CORE_CONTENT_COMPONENTS].some(name => new RegExp(`<${name}\\b`).test(node.body))) {
|
|
496
501
|
add('warning', 'NODE_NO_CORE_CONTENT', `节点 ${label} 缺少核心内容组件(Definition/Mechanism/Example/Boundary 等)`, node.offset, node.id);
|
|
497
502
|
}
|
|
503
|
+
// Opt-in knowledge-kind contracts. They only fire once an author declares
|
|
504
|
+
// `kind`, so the existing corpus keeps validating clean.
|
|
505
|
+
if (node.kind === 'mechanism' && node.body && !/<(Invariant|Evidence)\b/.test(node.body)) {
|
|
506
|
+
add(strict ? 'error' : 'warning', 'MECHANISM_KIND_UNVERIFIED', `机制节点 ${label} 建议至少包含一个 <Invariant> 或 <Evidence>`, node.offset, node.id);
|
|
507
|
+
}
|
|
508
|
+
if (node.kind === 'failure' && node.body && !/<FailureMode\b/.test(node.body)) {
|
|
509
|
+
add(strict ? 'error' : 'warning', 'FAILURE_KIND_UNSTRUCTURED', `故障节点 ${label} 建议使用 <FailureMode> 描述现象、原因、证据与建议`, node.offset, node.id);
|
|
510
|
+
}
|
|
498
511
|
}
|
|
499
512
|
|
|
500
513
|
const l0 = nodes.filter(node => node.level === 'L0');
|
|
@@ -542,6 +555,17 @@ export function validateMdxSource(source, options = {}) {
|
|
|
542
555
|
}
|
|
543
556
|
}
|
|
544
557
|
|
|
558
|
+
// A self-closing <FailureMode /> with no fields carries no information; an
|
|
559
|
+
// open tag with children is left to the author.
|
|
560
|
+
for (const tag of tags) {
|
|
561
|
+
if (tag.name !== 'FailureMode' || tag.kind !== 'self') continue;
|
|
562
|
+
const map = attrsToMap(tag.attrs);
|
|
563
|
+
const hasField = ['symptom', 'cause', 'evidence', 'remedy'].some(field => attrValue(map, field));
|
|
564
|
+
if (!hasField) {
|
|
565
|
+
add('warning', 'FAILURE_MODE_EMPTY', 'FailureMode 缺少 symptom/cause/evidence/remedy,也没有子内容', tag.start, 'FailureMode');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
545
569
|
// Array-prop sanity: catches `steps="foo"` and `steps={obj}` early.
|
|
546
570
|
for (const tag of tags) {
|
|
547
571
|
const props = ARRAY_PROPS[tag.name];
|
|
@@ -478,6 +478,9 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
478
478
|
cursor: pointer;
|
|
479
479
|
color: var(--text-secondary);
|
|
480
480
|
font-size: 13px;
|
|
481
|
+
font-family: inherit;
|
|
482
|
+
text-align: left;
|
|
483
|
+
width: 100%;
|
|
481
484
|
transition: all 0.15s ease;
|
|
482
485
|
background: transparent;
|
|
483
486
|
border: 1px solid transparent;
|
|
@@ -787,6 +790,20 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
787
790
|
border: 1px solid var(--border-accent);
|
|
788
791
|
}
|
|
789
792
|
|
|
793
|
+
/* Knowledge kind: orthogonal to level, so it reads as a small pill next to it.
|
|
794
|
+
Color comes from --kind-* via the registry and is applied inline. */
|
|
795
|
+
.kind-badge {
|
|
796
|
+
display: inline-flex;
|
|
797
|
+
align-items: center;
|
|
798
|
+
padding: 2px 8px;
|
|
799
|
+
border: 1px solid currentColor;
|
|
800
|
+
border-radius: 999px;
|
|
801
|
+
font-size: 11px;
|
|
802
|
+
font-weight: 700;
|
|
803
|
+
letter-spacing: 0.04em;
|
|
804
|
+
opacity: 0.92;
|
|
805
|
+
}
|
|
806
|
+
|
|
790
807
|
.hero-title {
|
|
791
808
|
font-size: 32px;
|
|
792
809
|
font-weight: 800;
|
|
@@ -1377,7 +1394,10 @@ button.flow-box {
|
|
|
1377
1394
|
left: 20px;
|
|
1378
1395
|
display: flex;
|
|
1379
1396
|
align-items: center;
|
|
1397
|
+
flex-wrap: wrap;
|
|
1380
1398
|
gap: 14px;
|
|
1399
|
+
row-gap: 6px;
|
|
1400
|
+
max-width: min(80%, 980px);
|
|
1381
1401
|
background: var(--bg-surface);
|
|
1382
1402
|
backdrop-filter: blur(12px);
|
|
1383
1403
|
border: 1px solid var(--border-medium);
|
|
@@ -1412,6 +1432,17 @@ button.flow-box {
|
|
|
1412
1432
|
border-radius: 50%;
|
|
1413
1433
|
}
|
|
1414
1434
|
|
|
1435
|
+
/* Kind uses a ring (matching the graph's kind ring) instead of a fill. */
|
|
1436
|
+
.legend-ring {
|
|
1437
|
+
background: transparent;
|
|
1438
|
+
border: 2px solid var(--rel-default);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
.legend-kinds {
|
|
1442
|
+
padding-left: 14px;
|
|
1443
|
+
border-left: 1px solid var(--border-medium);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1415
1446
|
.graph-svg {
|
|
1416
1447
|
width: 100%;
|
|
1417
1448
|
height: 100%;
|
|
@@ -2170,6 +2201,13 @@ button.flow-box {
|
|
|
2170
2201
|
gap: 16px;
|
|
2171
2202
|
}
|
|
2172
2203
|
|
|
2204
|
+
.panel-badges {
|
|
2205
|
+
display: flex;
|
|
2206
|
+
align-items: center;
|
|
2207
|
+
gap: 10px;
|
|
2208
|
+
flex-wrap: wrap;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2173
2211
|
.panel-badge {
|
|
2174
2212
|
font-size: 11px;
|
|
2175
2213
|
font-weight: 800;
|
|
@@ -2177,6 +2215,21 @@ button.flow-box {
|
|
|
2177
2215
|
letter-spacing: 0.06em;
|
|
2178
2216
|
}
|
|
2179
2217
|
|
|
2218
|
+
/* Structured argument/evidence counts, derived from the node model. */
|
|
2219
|
+
.panel-verified {
|
|
2220
|
+
display: flex;
|
|
2221
|
+
flex-wrap: wrap;
|
|
2222
|
+
gap: 8px;
|
|
2223
|
+
font-size: 11px;
|
|
2224
|
+
color: var(--text-dim);
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
.panel-verified span {
|
|
2228
|
+
border: 1px solid var(--border-medium);
|
|
2229
|
+
border-radius: 999px;
|
|
2230
|
+
padding: 2px 8px;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2180
2233
|
.panel-node-title {
|
|
2181
2234
|
font-size: 22px;
|
|
2182
2235
|
font-weight: 800;
|
|
@@ -105,6 +105,17 @@
|
|
|
105
105
|
--level-l3: #fbbf24;
|
|
106
106
|
--level-l4: #f87171;
|
|
107
107
|
|
|
108
|
+
/* Knowledge kind accents (orthogonal to level; consumed by kind badges,
|
|
109
|
+
graph rings and legends). Keep in sync with model/node-kinds.js */
|
|
110
|
+
--kind-system: #818cf8;
|
|
111
|
+
--kind-stage: #38bdf8;
|
|
112
|
+
--kind-mechanism: #34d399;
|
|
113
|
+
--kind-artifact: #fbbf24;
|
|
114
|
+
--kind-failure: #f87171;
|
|
115
|
+
--kind-tool: #c084fc;
|
|
116
|
+
--kind-boundary: #94a3b8;
|
|
117
|
+
--kind-decision: #2dd4bf;
|
|
118
|
+
|
|
108
119
|
/* Graph background grids */
|
|
109
120
|
--graph-grid: radial-gradient(circle, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
|
|
110
121
|
--explorer-grid:
|
|
@@ -215,6 +226,16 @@
|
|
|
215
226
|
--level-l3: #d97706;
|
|
216
227
|
--level-l4: #dc2626;
|
|
217
228
|
|
|
229
|
+
/* Knowledge kind accents (see dark block above) */
|
|
230
|
+
--kind-system: #6366f1;
|
|
231
|
+
--kind-stage: #0284c7;
|
|
232
|
+
--kind-mechanism: #059669;
|
|
233
|
+
--kind-artifact: #d97706;
|
|
234
|
+
--kind-failure: #dc2626;
|
|
235
|
+
--kind-tool: #9333ea;
|
|
236
|
+
--kind-boundary: #64748b;
|
|
237
|
+
--kind-decision: #0d9488;
|
|
238
|
+
|
|
218
239
|
/* Graph background grids */
|
|
219
240
|
--graph-grid: radial-gradient(circle, rgba(0, 0, 0, 0.07) 1px, transparent 1px);
|
|
220
241
|
--explorer-grid:
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { ChevronRight, ArrowUpRight, CornerDownRight, ArrowLeft, Network, CornerLeftUp, ZoomIn, ZoomOut, RotateCcw, Link as LinkIcon } from 'lucide-react';
|
|
3
3
|
import { getAncestorPath, getSiblingNodes } from '../model/concept-schema.js';
|
|
4
4
|
import { LEVEL_DEFS } from '../model/relation-types.js';
|
|
5
|
+
import { NODE_KINDS } from '../model/node-kinds.js';
|
|
5
6
|
|
|
6
7
|
export function NodeExplorer({
|
|
7
8
|
graph,
|
|
@@ -133,6 +134,7 @@ export function NodeExplorer({
|
|
|
133
134
|
|
|
134
135
|
// Level definition
|
|
135
136
|
const levelInfo = LEVEL_DEFS[currentNode.level] || { name: currentNode.level, tag: currentNode.level, color: 'var(--level-l0)' };
|
|
137
|
+
const kindDef = currentNode.kind ? NODE_KINDS[currentNode.kind] || null : null;
|
|
136
138
|
|
|
137
139
|
return (
|
|
138
140
|
<div className="node-explorer-layout">
|
|
@@ -144,18 +146,20 @@ export function NodeExplorer({
|
|
|
144
146
|
{ancestorPath.map((node, index) => {
|
|
145
147
|
const isCurrent = node.id === currentNode.id;
|
|
146
148
|
return (
|
|
147
|
-
<
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
148
151
|
key={node.id}
|
|
149
152
|
className={`breadcrumb-node ${isCurrent ? 'active' : ''}`}
|
|
150
153
|
onClick={() => onSelectNode(node.id)}
|
|
151
154
|
title={node.title}
|
|
155
|
+
aria-current={isCurrent ? 'page' : undefined}
|
|
152
156
|
>
|
|
153
157
|
<span className="bc-level-pill" style={{ borderColor: LEVEL_DEFS[node.level]?.color }}>
|
|
154
158
|
{node.level}
|
|
155
159
|
</span>
|
|
156
160
|
<span className="bc-title">{node.title}</span>
|
|
157
161
|
{index < ancestorPath.length - 1 && <ChevronRight size={14} className="bc-arrow" />}
|
|
158
|
-
</
|
|
162
|
+
</button>
|
|
159
163
|
);
|
|
160
164
|
})}
|
|
161
165
|
</div>
|
|
@@ -263,6 +267,15 @@ export function NodeExplorer({
|
|
|
263
267
|
<article className="concept-hero-card">
|
|
264
268
|
<div className="hero-level-banner" style={{ color: levelInfo.color }}>
|
|
265
269
|
<span className="badge">{levelInfo.tag}</span>
|
|
270
|
+
{kindDef && (
|
|
271
|
+
<span
|
|
272
|
+
className="kind-badge"
|
|
273
|
+
style={{ color: kindDef.tone, borderColor: kindDef.tone }}
|
|
274
|
+
title={kindDef.description}
|
|
275
|
+
>
|
|
276
|
+
{kindDef.label}
|
|
277
|
+
</span>
|
|
278
|
+
)}
|
|
266
279
|
<span className="desc">{levelInfo.desc}</span>
|
|
267
280
|
</div>
|
|
268
281
|
|
|
@@ -286,6 +299,24 @@ export function NodeExplorer({
|
|
|
286
299
|
<span className="chip-value">{currentNode.output}</span>
|
|
287
300
|
</div>
|
|
288
301
|
)}
|
|
302
|
+
{currentNode.invariants.length > 0 && (
|
|
303
|
+
<div className="meta-chip">
|
|
304
|
+
<span className="chip-label">不变量</span>
|
|
305
|
+
<span className="chip-value">{currentNode.invariants.length}</span>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
{currentNode.evidence.length > 0 && (
|
|
309
|
+
<div className="meta-chip">
|
|
310
|
+
<span className="chip-label">证据</span>
|
|
311
|
+
<span className="chip-value">{currentNode.evidence.length}</span>
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
{currentNode.failureModes.length > 0 && (
|
|
315
|
+
<div className="meta-chip">
|
|
316
|
+
<span className="chip-label">故障模式</span>
|
|
317
|
+
<span className="chip-value">{currentNode.failureModes.length}</span>
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
289
320
|
<div className="meta-chip">
|
|
290
321
|
<span className="chip-label">完整路径</span>
|
|
291
322
|
<span className="chip-value">{ancestorPath.map(n => n.title).join(' / ')}</span>
|
|
@@ -12,8 +12,9 @@ import {
|
|
|
12
12
|
hierarchy,
|
|
13
13
|
tree as treeLayout,
|
|
14
14
|
} from 'd3';
|
|
15
|
-
import { Search,
|
|
15
|
+
import { Search, ArrowRight, Eye } from 'lucide-react';
|
|
16
16
|
import { RELATION_TYPES, LEVEL_DEFS } from '../model/relation-types.js';
|
|
17
|
+
import { NODE_KINDS, NODE_KIND_NAMES } from '../model/node-kinds.js';
|
|
17
18
|
|
|
18
19
|
export function RelationGraph({
|
|
19
20
|
graph,
|
|
@@ -24,20 +25,36 @@ export function RelationGraph({
|
|
|
24
25
|
const { nodes, relations } = graph;
|
|
25
26
|
const svgRef = useRef(null);
|
|
26
27
|
const containerRef = useRef(null);
|
|
28
|
+
// The D3 layout owns the simulation; selection styling is applied by a
|
|
29
|
+
// separate effect so changing the focused node never rebuilds the layout.
|
|
30
|
+
const nodesSelectionRef = useRef(null);
|
|
31
|
+
const nodeHaloRef = useRef(null);
|
|
32
|
+
const nodeLabelRef = useRef(null);
|
|
33
|
+
const zoomBehaviorRef = useRef(null);
|
|
34
|
+
const positionedNodesRef = useRef([]);
|
|
35
|
+
const selectedNodeIdRef = useRef(null);
|
|
27
36
|
|
|
28
37
|
// States
|
|
29
38
|
const [selectedNodeId, setSelectedNodeId] = useState(currentNodeId || graph.meta.rootId);
|
|
30
39
|
const [searchQuery, setSearchQuery] = useState('');
|
|
31
40
|
const [filterLevel, setFilterLevel] = useState('ALL');
|
|
41
|
+
const [filterKind, setFilterKind] = useState('ALL');
|
|
32
42
|
const [filterRelationType, setFilterRelationType] = useState('ALL');
|
|
33
|
-
const [highlightNeighbors, setHighlightNeighbors] = useState(true);
|
|
34
43
|
const [graphMode, setGraphMode] = useState('hierarchy');
|
|
44
|
+
selectedNodeIdRef.current = selectedNodeId;
|
|
35
45
|
|
|
36
46
|
// Convert nodes map to array
|
|
37
47
|
const allNodes = useMemo(() => Array.from(nodes.values()), [nodes]);
|
|
38
48
|
|
|
49
|
+
// Kinds actually present in this document, for the filter and the legend.
|
|
50
|
+
const presentKinds = useMemo(
|
|
51
|
+
() => NODE_KIND_NAMES.filter(kind => allNodes.some(node => node.kind === kind)),
|
|
52
|
+
[allNodes],
|
|
53
|
+
);
|
|
54
|
+
|
|
39
55
|
// Active focused node details
|
|
40
56
|
const focusedNode = nodes.get(selectedNodeId) || null;
|
|
57
|
+
const focusedKind = focusedNode?.kind ? NODE_KINDS[focusedNode.kind] || null : null;
|
|
41
58
|
|
|
42
59
|
useEffect(() => {
|
|
43
60
|
if (currentNodeId && nodes.has(currentNodeId)) {
|
|
@@ -53,6 +70,12 @@ export function RelationGraph({
|
|
|
53
70
|
filteredNodes = filteredNodes.filter(n => n.level === filterLevel);
|
|
54
71
|
}
|
|
55
72
|
|
|
73
|
+
if (filterKind !== 'ALL') {
|
|
74
|
+
filteredNodes = filterKind === 'NONE'
|
|
75
|
+
? filteredNodes.filter(n => !n.kind)
|
|
76
|
+
: filteredNodes.filter(n => n.kind === filterKind);
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
if (searchQuery.trim()) {
|
|
57
80
|
const q = searchQuery.toLowerCase();
|
|
58
81
|
filteredNodes = filteredNodes.filter(n =>
|
|
@@ -115,7 +138,7 @@ export function RelationGraph({
|
|
|
115
138
|
graphNodes: filteredNodes.map(n => ({ ...n })),
|
|
116
139
|
graphLinks: links
|
|
117
140
|
};
|
|
118
|
-
}, [allNodes, nodes, relations, filterLevel, filterRelationType, searchQuery]);
|
|
141
|
+
}, [allNodes, nodes, relations, filterLevel, filterKind, filterRelationType, searchQuery]);
|
|
119
142
|
|
|
120
143
|
// Neighbors of focused node
|
|
121
144
|
const { connectedNodeIds, directRelations } = useMemo(() => {
|
|
@@ -143,6 +166,10 @@ export function RelationGraph({
|
|
|
143
166
|
|
|
144
167
|
// Setup D3 Force Simulation
|
|
145
168
|
useEffect(() => {
|
|
169
|
+
nodesSelectionRef.current = null;
|
|
170
|
+
nodeHaloRef.current = null;
|
|
171
|
+
nodeLabelRef.current = null;
|
|
172
|
+
positionedNodesRef.current = [];
|
|
146
173
|
if (!svgRef.current || !containerRef.current) return;
|
|
147
174
|
|
|
148
175
|
const width = containerRef.current.clientWidth || 900;
|
|
@@ -183,6 +210,7 @@ export function RelationGraph({
|
|
|
183
210
|
});
|
|
184
211
|
|
|
185
212
|
svg.call(zoomBehavior);
|
|
213
|
+
zoomBehaviorRef.current = zoomBehavior;
|
|
186
214
|
|
|
187
215
|
// Plain wheel scrolls the canvas vertically; Ctrl/Cmd + wheel zooms.
|
|
188
216
|
const handleWheelPan = (event) => {
|
|
@@ -212,6 +240,7 @@ export function RelationGraph({
|
|
|
212
240
|
}))
|
|
213
241
|
: layoutTree(graphNodes, width, height);
|
|
214
242
|
const nodeById = new Map(positionedNodes.map(node => [node.id, node]));
|
|
243
|
+
positionedNodesRef.current = positionedNodes;
|
|
215
244
|
const positionedLinks = decorateParallelLinks(visibleGraphLinks
|
|
216
245
|
.map(link => ({
|
|
217
246
|
...link,
|
|
@@ -278,12 +307,20 @@ export function RelationGraph({
|
|
|
278
307
|
.data(positionedNodes)
|
|
279
308
|
.enter()
|
|
280
309
|
.append('g')
|
|
281
|
-
.attr('class', d => `node-item ${d.id ===
|
|
310
|
+
.attr('class', d => `node-item ${d.id === selectedNodeIdRef.current ? 'selected' : ''}`)
|
|
282
311
|
.on('click', (event, d) => {
|
|
283
312
|
event.stopPropagation();
|
|
284
313
|
setSelectedNodeId(d.id);
|
|
285
314
|
onSelectNode(d.id);
|
|
286
315
|
});
|
|
316
|
+
nodesSelectionRef.current = nodesSelection;
|
|
317
|
+
|
|
318
|
+
// Native SVG tooltip: level is always shown; kind is appended when set.
|
|
319
|
+
nodesSelection.append('title')
|
|
320
|
+
.text(d => {
|
|
321
|
+
const kind = d.kind && NODE_KINDS[d.kind] ? ` · ${NODE_KINDS[d.kind].label}` : '';
|
|
322
|
+
return `${d.title} · ${d.level}${kind}`;
|
|
323
|
+
});
|
|
287
324
|
|
|
288
325
|
if (isConceptMode) {
|
|
289
326
|
nodesSelection.call(drag()
|
|
@@ -304,13 +341,16 @@ export function RelationGraph({
|
|
|
304
341
|
);
|
|
305
342
|
}
|
|
306
343
|
|
|
307
|
-
// Outer glow for selected or level
|
|
308
|
-
|
|
344
|
+
// Outer glow for selected or level. When a knowledge kind is declared its
|
|
345
|
+
// tone recolours the ring, so level (fill) and kind (ring) read together.
|
|
346
|
+
nodeHaloRef.current = nodesSelection.append('circle')
|
|
309
347
|
.attr('r', d => (d.level === 'L0' ? 24 : d.level === 'L1' ? 20 : 16))
|
|
310
348
|
.style('fill', d => LEVEL_DEFS[d.level]?.color || 'var(--level-l0)')
|
|
311
349
|
.attr('fill-opacity', 0.2)
|
|
312
|
-
.style('stroke', d =>
|
|
313
|
-
|
|
350
|
+
.style('stroke', d => (d.kind && NODE_KINDS[d.kind]
|
|
351
|
+
? NODE_KINDS[d.kind].tone
|
|
352
|
+
: (LEVEL_DEFS[d.level]?.color || 'var(--level-l0)')))
|
|
353
|
+
.attr('stroke-width', d => d.id === selectedNodeIdRef.current ? 3 : 1.5);
|
|
314
354
|
|
|
315
355
|
// Inner center dot
|
|
316
356
|
nodesSelection.append('circle')
|
|
@@ -318,13 +358,13 @@ export function RelationGraph({
|
|
|
318
358
|
.style('fill', d => LEVEL_DEFS[d.level]?.color || 'var(--level-l0)');
|
|
319
359
|
|
|
320
360
|
// Node Title Label
|
|
321
|
-
nodesSelection.append('text')
|
|
361
|
+
nodeLabelRef.current = nodesSelection.append('text')
|
|
322
362
|
.attr('dy', d => (d.level === 'L0' ? 38 : 30))
|
|
323
363
|
.attr('text-anchor', 'middle')
|
|
324
364
|
.style('fill', 'var(--text-primary)')
|
|
325
365
|
.attr('font-size', '12px')
|
|
326
366
|
.attr('font-family', labelFont)
|
|
327
|
-
.attr('font-weight', d => d.id ===
|
|
367
|
+
.attr('font-weight', d => d.id === selectedNodeIdRef.current ? '700' : '500')
|
|
328
368
|
.text(d => d.title);
|
|
329
369
|
|
|
330
370
|
// Node Level Pill
|
|
@@ -351,23 +391,34 @@ export function RelationGraph({
|
|
|
351
391
|
renderPositions();
|
|
352
392
|
}
|
|
353
393
|
|
|
354
|
-
// Auto-focus selected node center
|
|
355
|
-
if (!isConceptMode && selectedNodeId) {
|
|
356
|
-
const targetNode = positionedNodes.find(n => n.id === selectedNodeId);
|
|
357
|
-
if (targetNode) {
|
|
358
|
-
const transform = zoomIdentity
|
|
359
|
-
.translate(width / 2 - targetNode.x, height / 2 - targetNode.y)
|
|
360
|
-
.scale(1.1);
|
|
361
|
-
svg.transition().duration(500).call(zoomBehavior.transform, transform);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
394
|
return () => {
|
|
366
395
|
simulation?.stop();
|
|
367
396
|
svg.on('wheel.pan', null);
|
|
368
397
|
svg.on('.zoom', null);
|
|
369
398
|
};
|
|
370
|
-
}, [graphNodes, graphLinks,
|
|
399
|
+
}, [graphNodes, graphLinks, graphMode]);
|
|
400
|
+
|
|
401
|
+
// Selection styling lives outside the layout effect: clicking a node updates
|
|
402
|
+
// its class/stroke and re-centres the canvas without rebuilding the
|
|
403
|
+
// simulation or resetting drag positions.
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
const nodesSelection = nodesSelectionRef.current;
|
|
406
|
+
if (!nodesSelection) return;
|
|
407
|
+
|
|
408
|
+
nodesSelection.attr('class', d => `node-item ${d.id === selectedNodeId ? 'selected' : ''}`);
|
|
409
|
+
nodeHaloRef.current?.attr('stroke-width', d => (d.id === selectedNodeId ? 3 : 1.5));
|
|
410
|
+
nodeLabelRef.current?.attr('font-weight', d => (d.id === selectedNodeId ? '700' : '500'));
|
|
411
|
+
|
|
412
|
+
if (graphMode === 'concept' || !selectedNodeId || !svgRef.current || !containerRef.current || !zoomBehaviorRef.current) return;
|
|
413
|
+
const targetNode = positionedNodesRef.current.find(node => node.id === selectedNodeId);
|
|
414
|
+
if (!targetNode) return;
|
|
415
|
+
const width = containerRef.current.clientWidth || 900;
|
|
416
|
+
const height = containerRef.current.clientHeight || 650;
|
|
417
|
+
const transform = zoomIdentity
|
|
418
|
+
.translate(width / 2 - targetNode.x, height / 2 - targetNode.y)
|
|
419
|
+
.scale(1.1);
|
|
420
|
+
select(svgRef.current).transition().duration(500).call(zoomBehaviorRef.current.transform, transform);
|
|
421
|
+
}, [selectedNodeId, graphNodes, graphMode]);
|
|
371
422
|
|
|
372
423
|
return (
|
|
373
424
|
<div className="relation-graph-layout">
|
|
@@ -413,6 +464,19 @@ export function RelationGraph({
|
|
|
413
464
|
</select>
|
|
414
465
|
</div>
|
|
415
466
|
|
|
467
|
+
{presentKinds.length > 0 && (
|
|
468
|
+
<div className="filter-group">
|
|
469
|
+
<span className="filter-label">知识类型:</span>
|
|
470
|
+
<select value={filterKind} onChange={e => setFilterKind(e.target.value)}>
|
|
471
|
+
<option value="ALL">全部类型</option>
|
|
472
|
+
{presentKinds.map(kindId => (
|
|
473
|
+
<option key={kindId} value={kindId}>{NODE_KINDS[kindId].label} ({kindId})</option>
|
|
474
|
+
))}
|
|
475
|
+
{allNodes.some(node => !node.kind) && <option value="NONE">未分类</option>}
|
|
476
|
+
</select>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
|
|
416
480
|
<div className="filter-group">
|
|
417
481
|
<span className="filter-label">关系类型:</span>
|
|
418
482
|
<select value={filterRelationType} onChange={e => setFilterRelationType(e.target.value)}>
|
|
@@ -435,6 +499,17 @@ export function RelationGraph({
|
|
|
435
499
|
</div>
|
|
436
500
|
))}
|
|
437
501
|
</div>
|
|
502
|
+
{presentKinds.length > 0 && (
|
|
503
|
+
<div className="legend-items legend-kinds">
|
|
504
|
+
<div className="legend-title">知识类型:</div>
|
|
505
|
+
{presentKinds.map(kindId => (
|
|
506
|
+
<div key={kindId} className="legend-item">
|
|
507
|
+
<span className="legend-dot legend-ring" style={{ borderColor: NODE_KINDS[kindId].tone }} />
|
|
508
|
+
<span>{NODE_KINDS[kindId].label}</span>
|
|
509
|
+
</div>
|
|
510
|
+
))}
|
|
511
|
+
</div>
|
|
512
|
+
)}
|
|
438
513
|
</div>
|
|
439
514
|
|
|
440
515
|
{/* SVG Container */}
|
|
@@ -445,12 +520,31 @@ export function RelationGraph({
|
|
|
445
520
|
<aside className="graph-side-panel">
|
|
446
521
|
{focusedNode ? (
|
|
447
522
|
<div className="graph-inspector">
|
|
448
|
-
<div className="panel-
|
|
449
|
-
{LEVEL_DEFS[focusedNode.level]?.
|
|
523
|
+
<div className="panel-badges">
|
|
524
|
+
<div className="panel-badge" style={{ color: LEVEL_DEFS[focusedNode.level]?.color }}>
|
|
525
|
+
{LEVEL_DEFS[focusedNode.level]?.tag || focusedNode.level}
|
|
526
|
+
</div>
|
|
527
|
+
{focusedKind && (
|
|
528
|
+
<span
|
|
529
|
+
className="kind-badge"
|
|
530
|
+
style={{ color: focusedKind.tone, borderColor: focusedKind.tone }}
|
|
531
|
+
title={focusedKind.description}
|
|
532
|
+
>
|
|
533
|
+
{focusedKind.label}
|
|
534
|
+
</span>
|
|
535
|
+
)}
|
|
450
536
|
</div>
|
|
451
537
|
<h2 className="panel-node-title">{focusedNode.title}</h2>
|
|
452
538
|
<p className="panel-summary">{focusedNode.summary || '暂无一句话概览'}</p>
|
|
453
539
|
|
|
540
|
+
{(focusedNode.invariants.length > 0 || focusedNode.failureModes.length > 0 || focusedNode.evidence.length > 0) && (
|
|
541
|
+
<div className="panel-verified">
|
|
542
|
+
{focusedNode.invariants.length > 0 && <span>不变量 {focusedNode.invariants.length}</span>}
|
|
543
|
+
{focusedNode.evidence.length > 0 && <span>证据 {focusedNode.evidence.length}</span>}
|
|
544
|
+
{focusedNode.failureModes.length > 0 && <span>故障模式 {focusedNode.failureModes.length}</span>}
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
547
|
+
|
|
454
548
|
{/* Jump to Node Explorer Button */}
|
|
455
549
|
<button
|
|
456
550
|
className="jump-explorer-btn"
|