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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concept-atlas-dense-explain",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Portable dense-explanation skill and MDX concept atlas template",
5
5
  "type": "module",
6
6
  "bin": {
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. Use the concept-atlas-dense-explain npm CLI to generate AI-editable MDX, 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.
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 and do not recreate the React/Vite app. If a command name is unclear, run `npx concept-atlas-dense-explain help`.
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 needs to drill into concepts or see relations; recommend `scroll` for linear argument, tutorials, and reports. The component library is shared. If the user already has an `.mdx` file, detect its shell and work with it.
15
- 2. **Learn the components from the canonical guide before authoring.** Generate the guide for the chosen shell into a temporary path and read it:
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 a real, compilable MDX file that demonstrates the components for that shell and their exact props. Search it for a component name to copy the correct prop shape instead of guessing. Delete it when done.
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
- Every diagnostic is `CODE line:column message`. Fix all `error`s and re-run; warnings are quality signals you should also address when cheap.
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; use `--no-validate` only to force a knowingly broken build. Mermaid loads from a CDN at runtime by default (fast build, small HTML, needs network); pass `--inline-mermaid` when the user needs a fully offline single file.
30
- 7. **Appearance (optional)**: pages ship with a reader-facing appearance menu — palette (`aurora` cool indigo, `ember` warm gold, `verdant` forest green, `sakura` pink-plum, `noir` achromatic 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. You can bake different compile-time defaults: `--skin ember --default-mode dark --style classic` (or env `CONCEPT_ATLAS_SKIN` / `CONCEPT_ATLAS_DEFAULT_MODE` / `CONCEPT_ATLAS_STYLE` on the repo build). `--default-mode` only honors `dark`/`light`; `system` is accepted by the CLI but resolves to the carrier default (`light`). Bake a default only when the user asks for one. Content MDX never sets appearance — it is carrier/tooling territory, not content.
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]`. `-o` is then a directory. The batch validates everything first and builds in parallel. Builds only bundle the heavy renderers the content uses: a page with no `<Math>` skips KaTeX (its ~1.4MB inlined fonts), and Mermaid is served from a CDN by default rather than bundled. Do not add dummy `<Math>`/`<Mermaid>` nodes to "enable" them — write the components only when the content needs them. Add `--link-assets` when the page carries many screenshots and size matters.
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="..."` (atlas) or `ScrollHeader title="..."` (scroll) becomes the `<title>`, so give it a real, specific document name — never leave a placeholder like "主题名称". The favicon is a fixed 📃 document emoji on every generated page.
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 produces an `ASSET_MISSING` warning and a placeholder. Readers can click any figure to open it full-screen (wheel/`+`/`−` zoom, drag to pan, double-click for 1x/2x, `Esc` to close) — mention this when a page carries dense diagrams.
58
- - **Figure size**: inlining is what makes a screenshot-heavy page large. When a document carries many images and the user cares about size, compile with `--link-assets` to keep them as relative links (measured: 1.51MB → 270KB on one page). The output then has to live beside the MDX's `assets/` directory, and the CLI warns if `-o` points elsewhere — tell the user that trade-off instead of choosing silently.
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 `MULTIPLE_ROOT_LEVEL` are warnings that `--strict` promotes to errors.
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
 
@@ -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 [currentNodeId, setCurrentNodeId] = useState(() => {
32
- const hashNode = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
33
- return hashNode && graph.nodes.has(hashNode) ? hashNode : (graph.meta.rootId || '');
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 [history, setHistory] = useState(() => {
37
- const initial = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
38
- return initial && graph.nodes.has(initial) ? [initial] : [graph.meta.rootId].filter(Boolean);
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
- const query = globalQuery.trim().toLowerCase();
46
- if (!query) return [];
47
- return Array.from(graph.nodes.values()).map(node => {
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
- setHistory(previous => {
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 nextIndex = Math.max(0, Math.min(history.length - 1, historyIndex + direction));
76
- if (nextIndex === historyIndex) return;
77
- setHistoryIndex(nextIndex);
78
- const nodeId = history[nextIndex];
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
- // Toggle views with 1 and 2 or 'g' and 'e' if not focused on input
87
- if (e.isComposing || e.metaKey || e.ctrlKey || e.altKey || ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable) {
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
- // Alt + arrows move through browsing history.
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, historyIndex, history]);
119
+ }, [graph, currentNodeId, nav]);
120
120
 
121
121
  useEffect(() => {
122
122
  const handlePopState = () => {
123
- const nodeId = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
123
+ const nodeId = readNodeFromHash();
124
124
  if (!nodeId || !graph.nodes.has(nodeId)) return;
125
125
  setCurrentNodeId(nodeId);
126
- setHistory(previous => {
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
- <div
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
- </div>
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, Filter, ZoomIn, ZoomOut, RotateCcw, ArrowRight, Layers, Eye } from 'lucide-react';
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 === selectedNodeId ? 'selected' : ''}`)
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
- nodesSelection.append('circle')
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 => LEVEL_DEFS[d.level]?.color || 'var(--level-l0)')
313
- .attr('stroke-width', d => d.id === selectedNodeId ? 3 : 1.5);
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 === selectedNodeId ? '700' : '500')
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, selectedNodeId, graphMode]);
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-badge" style={{ color: LEVEL_DEFS[focusedNode.level]?.color }}>
449
- {LEVEL_DEFS[focusedNode.level]?.tag || 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"