concept-atlas-dense-explain 0.2.0 → 0.2.2

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.
@@ -1,239 +1,239 @@
1
- import React, { useState, useEffect, useMemo } from 'react';
2
- import { Network, Compass, Sun, Moon, Search, X, Link as LinkIcon } from 'lucide-react';
3
- import { buildGraphModel } from '../model/concept-schema.js';
4
- import { extractConceptData } from '../model/normalize-content.js';
5
- import { NodeExplorer } from '../views/NodeExplorer.jsx';
6
- import { RelationGraph } from '../views/RelationGraph.jsx';
7
- import '../styles/concept-explain.css';
8
-
9
- export function App({ mdxContent, initialData }) {
10
- // Extract graph model from MDX JSX Element or raw data
11
- const [graph] = useState(() => {
12
- if (initialData) {
13
- return buildGraphModel(initialData);
14
- }
15
- if (mdxContent) {
16
- const extracted = extractConceptData(mdxContent);
17
- return buildGraphModel(extracted);
18
- }
19
- return buildGraphModel({ meta: {}, nodes: [], relations: [] });
20
- });
21
-
22
- // Theme state ('light' | 'dark')
23
- const [theme, setTheme] = useState(() => {
24
- return localStorage.getItem('concept_atlas_theme') || 'dark';
25
- });
26
-
27
- useEffect(() => {
28
- document.documentElement.setAttribute('data-theme', theme);
29
- localStorage.setItem('concept_atlas_theme', theme);
30
- }, [theme]);
31
-
32
- const toggleTheme = () => {
33
- setTheme(prev => (prev === 'dark' ? 'light' : 'dark'));
34
- };
35
-
36
- // Global shared state
37
- const [currentView, setCurrentView] = useState('explore'); // 'explore' | 'graph'
38
- const [currentNodeId, setCurrentNodeId] = useState(() => {
39
- const hashNode = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
40
- return hashNode && graph.nodes.has(hashNode) ? hashNode : (graph.meta.rootId || '');
41
- });
42
- const [selectedLevel, setSelectedLevel] = useState(null);
43
- const [history, setHistory] = useState(() => {
44
- const initial = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
45
- return initial && graph.nodes.has(initial) ? [initial] : [graph.meta.rootId].filter(Boolean);
46
- });
47
- const [historyIndex, setHistoryIndex] = useState(0);
48
- const [globalQuery, setGlobalQuery] = useState('');
49
- const [linkCopied, setLinkCopied] = useState(false);
50
-
51
- const searchResults = useMemo(() => {
52
- const query = globalQuery.trim().toLowerCase();
53
- if (!query) return [];
54
- return Array.from(graph.nodes.values()).map(node => {
55
- const searchable = [
56
- node.title, node.id, node.summary, node.definition, node.overview,
57
- node.mechanism, node.input, node.output,
58
- ...(node.examples || []).flatMap(item => [item.title, item.content]),
59
- ...(node.glossary || []).flatMap(item => [item.term, item.definition]),
60
- ...(node.boundaries || []).flatMap(item => [item.title, item.content]),
61
- ].filter(value => typeof value === 'string').join(' ').toLowerCase();
62
- return searchable.includes(query) ? node : null;
63
- }).filter(Boolean).slice(0, 8);
64
- }, [globalQuery, graph.nodes]);
65
-
66
- const navigateToNode = (nodeId, { replace = false } = {}) => {
67
- if (!nodeId || !graph.nodes.has(nodeId)) return;
68
- setCurrentNodeId(nodeId);
69
- setHistory(previous => {
70
- const base = previous.slice(0, historyIndex + 1);
71
- if (base[base.length - 1] === nodeId) return previous;
72
- const next = [...base, nodeId];
73
- setHistoryIndex(next.length - 1);
74
- return next;
75
- });
76
- const nextHash = `#node=${encodeURIComponent(nodeId)}`;
77
- if (replace) window.history.replaceState({}, '', nextHash);
78
- else window.history.pushState({}, '', nextHash);
79
- };
80
-
81
- const moveHistory = (direction) => {
82
- const nextIndex = Math.max(0, Math.min(history.length - 1, historyIndex + direction));
83
- if (nextIndex === historyIndex) return;
84
- setHistoryIndex(nextIndex);
85
- const nodeId = history[nextIndex];
86
- setCurrentNodeId(nodeId);
87
- window.history.pushState({}, '', `#node=${encodeURIComponent(nodeId)}`);
88
- };
89
-
90
- // Keyboard navigation shortcuts
91
- useEffect(() => {
92
- const handleKeyDown = (e) => {
93
- // Toggle views with 1 and 2 or 'g' and 'e' if not focused on input
94
- if (e.isComposing || e.metaKey || e.ctrlKey || e.altKey || ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable) {
95
- return;
96
- }
97
- const key = e.key.toLowerCase();
98
- if (e.key === '1' || key === 'e') {
99
- setCurrentView('explore');
100
- } else if (e.key === '2' || key === 'g') {
101
- setCurrentView('graph');
102
- } else if (key === 't') {
103
- toggleTheme();
104
- } else if (e.key === 'Escape') {
105
- // Alt + arrows move through browsing history.
106
- if (e.altKey && e.key === 'ArrowLeft') {
107
- e.preventDefault();
108
- moveHistory(-1);
109
- return;
110
- }
111
- if (e.altKey && e.key === 'ArrowRight') {
112
- e.preventDefault();
113
- moveHistory(1);
114
- return;
115
- }
116
- // Return to root or parent
117
- const curr = graph.nodes.get(currentNodeId);
118
- if (curr && curr.parent) {
119
- navigateToNode(curr.parent);
120
- }
121
- }
122
- };
123
-
124
- window.addEventListener('keydown', handleKeyDown);
125
- return () => window.removeEventListener('keydown', handleKeyDown);
126
- }, [graph, currentNodeId, historyIndex, history]);
127
-
128
- useEffect(() => {
129
- const handlePopState = () => {
130
- const nodeId = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
131
- if (!nodeId || !graph.nodes.has(nodeId)) return;
132
- setCurrentNodeId(nodeId);
133
- setHistory(previous => {
134
- const index = previous.lastIndexOf(nodeId);
135
- if (index >= 0) {
136
- setHistoryIndex(index);
137
- return previous;
138
- }
139
- const next = [...previous, nodeId];
140
- setHistoryIndex(next.length - 1);
141
- return next;
142
- });
143
- };
144
- window.addEventListener('popstate', handlePopState);
145
- return () => window.removeEventListener('popstate', handlePopState);
146
- }, [graph]);
147
-
148
- return (
149
- <div className="app-container">
150
- {/* Main workspace */}
151
- <main className="app-main">
152
- <div className="app-toolbar">
153
- <div className="view-tabs">
154
- <button
155
- className={`view-tab ${currentView === 'explore' ? 'active' : ''}`}
156
- onClick={() => setCurrentView('explore')}
157
- title="切换到节点探索视图 (按快捷键 E / 1)"
158
- >
159
- <Compass size={15} />
160
- <span>概念探索</span>
161
- </button>
162
- <button
163
- className={`view-tab ${currentView === 'graph' ? 'active' : ''}`}
164
- onClick={() => setCurrentView('graph')}
165
- title="切换到全局拓扑图谱 (按快捷键 G / 2)"
166
- >
167
- <Network size={15} />
168
- <span>知识网络</span>
169
- </button>
170
- </div>
171
-
172
- <div className="global-search">
173
- <Search size={14} />
174
- <input
175
- value={globalQuery}
176
- onChange={event => setGlobalQuery(event.target.value)}
177
- onKeyDown={event => { if (event.key === 'Escape') setGlobalQuery(''); }}
178
- placeholder="搜索所有节点…"
179
- aria-label="搜索所有节点"
180
- />
181
- {globalQuery && <button type="button" onClick={() => setGlobalQuery('')} aria-label="清除搜索"><X size={13} /></button>}
182
- {searchResults.length > 0 && (
183
- <div className="global-search-results" role="listbox">
184
- {searchResults.map(node => (
185
- <button type="button" key={node.id} onClick={() => { navigateToNode(node.id); setGlobalQuery(''); }} role="option">
186
- <span>{node.title}</span><small>{node.level} · {node.summary || node.id}</small>
187
- </button>
188
- ))}
189
- </div>
190
- )}
191
- </div>
192
-
193
- <button
194
- className="theme-toggle-btn"
195
- onClick={toggleTheme}
196
- title={`切换主题 (当前: ${theme === 'dark' ? '暗色' : '亮色'},快捷键 T)`}
197
- aria-label="切换主题"
198
- >
199
- {theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
200
- </button>
201
- <button
202
- className="theme-toggle-btn"
203
- onClick={async () => {
204
- try {
205
- await navigator.clipboard.writeText(window.location.href);
206
- setLinkCopied(true);
207
- window.setTimeout(() => setLinkCopied(false), 1400);
208
- } catch {
209
- setLinkCopied(false);
210
- }
211
- }}
212
- title={linkCopied ? '已复制当前节点链接' : '复制当前节点链接'}
213
- aria-label={linkCopied ? '已复制当前节点链接' : '复制当前节点链接'}
214
- >
215
- <LinkIcon size={16} />
216
- </button>
217
- </div>
218
- {currentView === 'explore' ? (
219
- <NodeExplorer
220
- graph={graph}
221
- currentNodeId={currentNodeId}
222
- onSelectNode={navigateToNode}
223
- onSwitchView={setCurrentView}
224
- selectedLevel={selectedLevel}
225
- onSelectLevel={setSelectedLevel}
226
- />
227
- ) : (
228
- <RelationGraph
229
- graph={graph}
230
- currentNodeId={currentNodeId}
231
- onSelectNode={navigateToNode}
232
- onSwitchView={setCurrentView}
233
- theme={theme}
234
- />
235
- )}
236
- </main>
237
- </div>
238
- );
239
- }
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import { Network, Compass, Sun, Moon, Search, X, Link as LinkIcon } from 'lucide-react';
3
+ import { buildGraphModel } from '../model/concept-schema.js';
4
+ import { extractConceptData } from '../model/normalize-content.js';
5
+ import { NodeExplorer } from '../views/NodeExplorer.jsx';
6
+ import { RelationGraph } from '../views/RelationGraph.jsx';
7
+ import '../styles/concept-explain.css';
8
+
9
+ export function App({ mdxContent, initialData }) {
10
+ // Extract graph model from MDX JSX Element or raw data
11
+ const [graph] = useState(() => {
12
+ if (initialData) {
13
+ return buildGraphModel(initialData);
14
+ }
15
+ if (mdxContent) {
16
+ const extracted = extractConceptData(mdxContent);
17
+ return buildGraphModel(extracted);
18
+ }
19
+ return buildGraphModel({ meta: {}, nodes: [], relations: [] });
20
+ });
21
+
22
+ // Theme state ('light' | 'dark')
23
+ const [theme, setTheme] = useState(() => {
24
+ return localStorage.getItem('concept_atlas_theme') || 'dark';
25
+ });
26
+
27
+ useEffect(() => {
28
+ document.documentElement.setAttribute('data-theme', theme);
29
+ localStorage.setItem('concept_atlas_theme', theme);
30
+ }, [theme]);
31
+
32
+ const toggleTheme = () => {
33
+ setTheme(prev => (prev === 'dark' ? 'light' : 'dark'));
34
+ };
35
+
36
+ // Global shared state
37
+ const [currentView, setCurrentView] = useState('explore'); // 'explore' | 'graph'
38
+ const [currentNodeId, setCurrentNodeId] = useState(() => {
39
+ const hashNode = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
40
+ return hashNode && graph.nodes.has(hashNode) ? hashNode : (graph.meta.rootId || '');
41
+ });
42
+ const [selectedLevel, setSelectedLevel] = useState(null);
43
+ const [history, setHistory] = useState(() => {
44
+ const initial = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
45
+ return initial && graph.nodes.has(initial) ? [initial] : [graph.meta.rootId].filter(Boolean);
46
+ });
47
+ const [historyIndex, setHistoryIndex] = useState(0);
48
+ const [globalQuery, setGlobalQuery] = useState('');
49
+ const [linkCopied, setLinkCopied] = useState(false);
50
+
51
+ const searchResults = useMemo(() => {
52
+ const query = globalQuery.trim().toLowerCase();
53
+ if (!query) return [];
54
+ return Array.from(graph.nodes.values()).map(node => {
55
+ const searchable = [
56
+ node.title, node.id, node.summary, node.definition, node.overview,
57
+ node.mechanism, node.input, node.output,
58
+ ...(node.examples || []).flatMap(item => [item.title, item.content]),
59
+ ...(node.glossary || []).flatMap(item => [item.term, item.definition]),
60
+ ...(node.boundaries || []).flatMap(item => [item.title, item.content]),
61
+ ].filter(value => typeof value === 'string').join(' ').toLowerCase();
62
+ return searchable.includes(query) ? node : null;
63
+ }).filter(Boolean).slice(0, 8);
64
+ }, [globalQuery, graph.nodes]);
65
+
66
+ const navigateToNode = (nodeId, { replace = false } = {}) => {
67
+ if (!nodeId || !graph.nodes.has(nodeId)) return;
68
+ setCurrentNodeId(nodeId);
69
+ setHistory(previous => {
70
+ const base = previous.slice(0, historyIndex + 1);
71
+ if (base[base.length - 1] === nodeId) return previous;
72
+ const next = [...base, nodeId];
73
+ setHistoryIndex(next.length - 1);
74
+ return next;
75
+ });
76
+ const nextHash = `#node=${encodeURIComponent(nodeId)}`;
77
+ if (replace) window.history.replaceState({}, '', nextHash);
78
+ else window.history.pushState({}, '', nextHash);
79
+ };
80
+
81
+ const moveHistory = (direction) => {
82
+ const nextIndex = Math.max(0, Math.min(history.length - 1, historyIndex + direction));
83
+ if (nextIndex === historyIndex) return;
84
+ setHistoryIndex(nextIndex);
85
+ const nodeId = history[nextIndex];
86
+ setCurrentNodeId(nodeId);
87
+ window.history.pushState({}, '', `#node=${encodeURIComponent(nodeId)}`);
88
+ };
89
+
90
+ // Keyboard navigation shortcuts
91
+ useEffect(() => {
92
+ const handleKeyDown = (e) => {
93
+ // Toggle views with 1 and 2 or 'g' and 'e' if not focused on input
94
+ if (e.isComposing || e.metaKey || e.ctrlKey || e.altKey || ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable) {
95
+ return;
96
+ }
97
+ const key = e.key.toLowerCase();
98
+ if (e.key === '1' || key === 'e') {
99
+ setCurrentView('explore');
100
+ } else if (e.key === '2' || key === 'g') {
101
+ setCurrentView('graph');
102
+ } else if (key === 't') {
103
+ toggleTheme();
104
+ } else if (e.key === 'Escape') {
105
+ // Alt + arrows move through browsing history.
106
+ if (e.altKey && e.key === 'ArrowLeft') {
107
+ e.preventDefault();
108
+ moveHistory(-1);
109
+ return;
110
+ }
111
+ if (e.altKey && e.key === 'ArrowRight') {
112
+ e.preventDefault();
113
+ moveHistory(1);
114
+ return;
115
+ }
116
+ // Return to root or parent
117
+ const curr = graph.nodes.get(currentNodeId);
118
+ if (curr && curr.parent) {
119
+ navigateToNode(curr.parent);
120
+ }
121
+ }
122
+ };
123
+
124
+ window.addEventListener('keydown', handleKeyDown);
125
+ return () => window.removeEventListener('keydown', handleKeyDown);
126
+ }, [graph, currentNodeId, historyIndex, history]);
127
+
128
+ useEffect(() => {
129
+ const handlePopState = () => {
130
+ const nodeId = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
131
+ if (!nodeId || !graph.nodes.has(nodeId)) return;
132
+ setCurrentNodeId(nodeId);
133
+ setHistory(previous => {
134
+ const index = previous.lastIndexOf(nodeId);
135
+ if (index >= 0) {
136
+ setHistoryIndex(index);
137
+ return previous;
138
+ }
139
+ const next = [...previous, nodeId];
140
+ setHistoryIndex(next.length - 1);
141
+ return next;
142
+ });
143
+ };
144
+ window.addEventListener('popstate', handlePopState);
145
+ return () => window.removeEventListener('popstate', handlePopState);
146
+ }, [graph]);
147
+
148
+ return (
149
+ <div className="app-container">
150
+ {/* Main workspace */}
151
+ <main className="app-main">
152
+ <div className="app-toolbar">
153
+ <div className="view-tabs">
154
+ <button
155
+ className={`view-tab ${currentView === 'explore' ? 'active' : ''}`}
156
+ onClick={() => setCurrentView('explore')}
157
+ title="切换到节点探索视图 (按快捷键 E / 1)"
158
+ >
159
+ <Compass size={15} />
160
+ <span>概念探索</span>
161
+ </button>
162
+ <button
163
+ className={`view-tab ${currentView === 'graph' ? 'active' : ''}`}
164
+ onClick={() => setCurrentView('graph')}
165
+ title="切换到全局拓扑图谱 (按快捷键 G / 2)"
166
+ >
167
+ <Network size={15} />
168
+ <span>知识网络</span>
169
+ </button>
170
+ </div>
171
+
172
+ <div className="global-search">
173
+ <Search size={14} />
174
+ <input
175
+ value={globalQuery}
176
+ onChange={event => setGlobalQuery(event.target.value)}
177
+ onKeyDown={event => { if (event.key === 'Escape') setGlobalQuery(''); }}
178
+ placeholder="搜索所有节点…"
179
+ aria-label="搜索所有节点"
180
+ />
181
+ {globalQuery && <button type="button" onClick={() => setGlobalQuery('')} aria-label="清除搜索"><X size={13} /></button>}
182
+ {searchResults.length > 0 && (
183
+ <div className="global-search-results" role="listbox">
184
+ {searchResults.map(node => (
185
+ <button type="button" key={node.id} onClick={() => { navigateToNode(node.id); setGlobalQuery(''); }} role="option">
186
+ <span>{node.title}</span><small>{node.level} · {node.summary || node.id}</small>
187
+ </button>
188
+ ))}
189
+ </div>
190
+ )}
191
+ </div>
192
+
193
+ <button
194
+ className="theme-toggle-btn"
195
+ onClick={toggleTheme}
196
+ title={`切换主题 (当前: ${theme === 'dark' ? '暗色' : '亮色'},快捷键 T)`}
197
+ aria-label="切换主题"
198
+ >
199
+ {theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
200
+ </button>
201
+ <button
202
+ className="theme-toggle-btn"
203
+ onClick={async () => {
204
+ try {
205
+ await navigator.clipboard.writeText(window.location.href);
206
+ setLinkCopied(true);
207
+ window.setTimeout(() => setLinkCopied(false), 1400);
208
+ } catch {
209
+ setLinkCopied(false);
210
+ }
211
+ }}
212
+ title={linkCopied ? '已复制当前节点链接' : '复制当前节点链接'}
213
+ aria-label={linkCopied ? '已复制当前节点链接' : '复制当前节点链接'}
214
+ >
215
+ <LinkIcon size={16} />
216
+ </button>
217
+ </div>
218
+ {currentView === 'explore' ? (
219
+ <NodeExplorer
220
+ graph={graph}
221
+ currentNodeId={currentNodeId}
222
+ onSelectNode={navigateToNode}
223
+ onSwitchView={setCurrentView}
224
+ selectedLevel={selectedLevel}
225
+ onSelectLevel={setSelectedLevel}
226
+ />
227
+ ) : (
228
+ <RelationGraph
229
+ graph={graph}
230
+ currentNodeId={currentNodeId}
231
+ onSelectNode={navigateToNode}
232
+ onSwitchView={setCurrentView}
233
+ theme={theme}
234
+ />
235
+ )}
236
+ </main>
237
+ </div>
238
+ );
239
+ }