concept-atlas-dense-explain 2.0.0 → 3.0.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.
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useMemo, useRef } from 'react';
1
+ import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
2
2
  import { Network, Compass, Sun, Moon, Search, X, Link as LinkIcon } from 'lucide-react';
3
3
  import { buildGraphModel } from '../model/concept-schema.js';
4
4
  import { extractConceptData } from '../model/normalize-content.js';
@@ -30,7 +30,14 @@ export function App({ mdxContent, initialData }) {
30
30
  });
31
31
 
32
32
  // Appearance state (skin x mode x component style), shared with the scroll carrier via localStorage
33
- const { skin, mode: theme, style, setSkin, setStyle, toggleMode } = useAppearance({ defaultMode: 'light' });
33
+ const {
34
+ skin,
35
+ mode: theme,
36
+ style,
37
+ setSkin,
38
+ setStyle,
39
+ toggleMode
40
+ } = useAppearance({ defaultMode: 'light' });
34
41
 
35
42
  const toggleTheme = toggleMode;
36
43
 
@@ -42,11 +49,11 @@ export function App({ mdxContent, initialData }) {
42
49
  })();
43
50
 
44
51
  const [currentNodeId, setCurrentNodeId] = useState(
45
- () => initialHashNode || graph.meta.rootId || '',
52
+ () => initialHashNode || graph.meta.rootId || ''
46
53
  );
47
54
  const [nav, setNav] = useState(() => ({
48
55
  entries: (initialHashNode ? [initialHashNode] : [graph.meta.rootId]).filter(Boolean),
49
- index: 0,
56
+ index: 0
50
57
  }));
51
58
  // Popstate handlers must read the *current* history state, not the closure
52
59
  // captured when the effect was created.
@@ -57,35 +64,41 @@ export function App({ mdxContent, initialData }) {
57
64
 
58
65
  const searchResults = useMemo(
59
66
  () => searchNodes(Array.from(graph.nodes.values()), globalQuery),
60
- [globalQuery, graph.nodes],
67
+ [globalQuery, graph.nodes]
61
68
  );
62
69
 
63
- const navigateToNode = (nodeId, { replace = false } = {}) => {
64
- if (!nodeId || !graph.nodes.has(nodeId)) return;
65
- const next = pushNode(nav, nodeId);
66
- setCurrentNodeId(nodeId);
67
- setNav(next);
68
- const nextHash = `#node=${encodeURIComponent(nodeId)}`;
69
- // Each browser entry remembers which in-app history index it maps to, so
70
- // popstate can restore the exact entry even when hashes repeat.
71
- const historyState = { atlasIndex: next.index };
72
- if (next === nav && !replace) return; // re-selecting the current node: don't grow browser history
73
- if (replace) window.history.replaceState(historyState, '', nextHash);
74
- else window.history.pushState(historyState, '', nextHash);
75
- };
76
-
77
- const moveHistory = (direction) => {
78
- // Browser history mirrors the in-app entries one-for-one (every navigation
79
- // pushState'd the matching { atlasIndex }), so step through the real
80
- // browser history. pushState here used to fork the two stacks and grow
81
- // browser history unboundedly; the popstate handler applies the result.
82
- if (!stepHistory(nav, direction)) return;
83
- window.history.go(direction);
84
- };
70
+ const navigateToNode = useCallback(
71
+ (nodeId, { replace = false } = {}) => {
72
+ if (!nodeId || !graph.nodes.has(nodeId)) return;
73
+ const next = pushNode(nav, nodeId);
74
+ setCurrentNodeId(nodeId);
75
+ setNav(next);
76
+ const nextHash = `#node=${encodeURIComponent(nodeId)}`;
77
+ // Each browser entry remembers which in-app history index it maps to, so
78
+ // popstate can restore the exact entry even when hashes repeat.
79
+ const historyState = { atlasIndex: next.index };
80
+ if (next === nav && !replace) return; // re-selecting the current node: don't grow browser history
81
+ if (replace) window.history.replaceState(historyState, '', nextHash);
82
+ else window.history.pushState(historyState, '', nextHash);
83
+ },
84
+ [nav, graph.nodes]
85
+ );
86
+
87
+ const moveHistory = useCallback(
88
+ direction => {
89
+ // Browser history mirrors the in-app entries one-for-one (every navigation
90
+ // pushState'd the matching { atlasIndex }), so step through the real
91
+ // browser history. pushState here used to fork the two stacks and grow
92
+ // browser history unboundedly; the popstate handler applies the result.
93
+ if (!stepHistory(nav, direction)) return;
94
+ window.history.go(direction);
95
+ },
96
+ [nav]
97
+ );
85
98
 
86
99
  // Keyboard navigation shortcuts
87
100
  useEffect(() => {
88
- const handleKeyDown = (e) => {
101
+ const handleKeyDown = e => {
89
102
  if (e.isComposing) return;
90
103
 
91
104
  // Alt + arrows move through browsing history. Handled before the modifier
@@ -103,7 +116,8 @@ export function App({ mdxContent, initialData }) {
103
116
 
104
117
  // Toggle views with 1 and 2 or 'g' and 'e' if not focused on input.
105
118
  const target = document.activeElement;
106
- const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
119
+ const typing =
120
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
107
121
  if (e.metaKey || e.ctrlKey || e.altKey || typing) return;
108
122
 
109
123
  const key = e.key.toLowerCase();
@@ -124,25 +138,25 @@ export function App({ mdxContent, initialData }) {
124
138
 
125
139
  window.addEventListener('keydown', handleKeyDown);
126
140
  return () => window.removeEventListener('keydown', handleKeyDown);
127
- }, [graph, currentNodeId, nav]);
141
+ }, [graph, currentNodeId, navigateToNode, moveHistory, toggleTheme]);
128
142
 
129
143
  useEffect(() => {
130
- const handlePopState = (event) => {
144
+ const handlePopState = event => {
131
145
  const current = navRef.current;
132
146
  const nodeId = readNodeFromHash();
133
147
  if (nodeId && !graph.nodes.has(nodeId)) return; // stale/foreign hash: ignore
134
- const tagged = event.state && Number.isInteger(event.state.atlasIndex)
135
- ? event.state.atlasIndex
136
- : null;
148
+ const tagged =
149
+ event.state && Number.isInteger(event.state.atlasIndex) ? event.state.atlasIndex : null;
137
150
  const taggedValid = tagged !== null && tagged >= 0 && tagged < current.entries.length;
138
151
  let next = null;
139
152
  if (nodeId) {
140
153
  // Prefer the index stamped on the entry: entries may contain the same
141
154
  // node twice, and lastIndexOf alone would resolve backwards steps to a
142
155
  // later duplicate.
143
- next = taggedValid && current.entries[tagged] === nodeId
144
- ? { entries: current.entries, index: tagged }
145
- : syncFromLocation(current, nodeId);
156
+ next =
157
+ taggedValid && current.entries[tagged] === nodeId
158
+ ? { entries: current.entries, index: tagged }
159
+ : syncFromLocation(current, nodeId);
146
160
  } else if (taggedValid) {
147
161
  // Back to the hash-less entry the page loaded on: restore it by index.
148
162
  next = { entries: current.entries, index: tagged };
@@ -190,16 +204,38 @@ export function App({ mdxContent, initialData }) {
190
204
  <input
191
205
  value={globalQuery}
192
206
  onChange={event => setGlobalQuery(event.target.value)}
193
- onKeyDown={event => { if (event.key === 'Escape') setGlobalQuery(''); }}
207
+ onKeyDown={event => {
208
+ if (event.key === 'Escape') setGlobalQuery('');
209
+ }}
194
210
  placeholder="搜索所有节点…"
195
211
  aria-label="搜索所有节点"
196
212
  />
197
- {globalQuery && <button type="button" onClick={() => setGlobalQuery('')} aria-label="清除搜索"><X size={13} /></button>}
213
+ {globalQuery && (
214
+ <button type="button" onClick={() => setGlobalQuery('')} aria-label="清除搜索">
215
+ <X size={13} />
216
+ </button>
217
+ )}
198
218
  {searchResults.length > 0 && (
199
219
  <div className="global-search-results" role="listbox">
200
220
  {searchResults.map(node => (
201
- <button type="button" key={node.id} onClick={() => { navigateToNode(node.id); setGlobalQuery(''); }} role="option">
202
- <span>{node.title}</span><small>{node.level}{node.kind && NODE_KINDS[node.kind] ? ` · ${NODE_KINDS[node.kind].label}` : ''} · {node.summary || node.id}</small>
221
+ <button
222
+ type="button"
223
+ key={node.id}
224
+ onClick={() => {
225
+ navigateToNode(node.id);
226
+ setGlobalQuery('');
227
+ }}
228
+ role="option"
229
+ aria-selected={false}
230
+ >
231
+ <span>{node.title}</span>
232
+ <small>
233
+ {node.level}
234
+ {node.kind && NODE_KINDS[node.kind]
235
+ ? ` · ${NODE_KINDS[node.kind].label}`
236
+ : ''}{' '}
237
+ · {node.summary || node.id}
238
+ </small>
203
239
  </button>
204
240
  ))}
205
241
  </div>
@@ -10,7 +10,8 @@ const MAX_LENGTH = 4000;
10
10
  * a pathological tree cannot stall the search box.
11
11
  */
12
12
  export function collectText(value, depth = 0) {
13
- if (depth > MAX_DEPTH || value === null || value === undefined || typeof value === 'boolean') return '';
13
+ if (depth > MAX_DEPTH || value === null || value === undefined || typeof value === 'boolean')
14
+ return '';
14
15
  if (typeof value === 'string' || typeof value === 'number') return String(value);
15
16
  if (Array.isArray(value)) {
16
17
  return value.map(item => collectText(item, depth + 1)).join(' ');
@@ -28,7 +29,9 @@ export function collectText(value, depth = 0) {
28
29
  return parts.join(' ');
29
30
  }
30
31
  if (typeof value === 'object') {
31
- return Object.values(value).map(item => collectText(item, depth + 1)).join(' ');
32
+ return Object.values(value)
33
+ .map(item => collectText(item, depth + 1))
34
+ .join(' ');
32
35
  }
33
36
  return '';
34
37
  }
@@ -66,14 +69,20 @@ export function nodeSearchText(node) {
66
69
  node.invariants,
67
70
  node.failureModes,
68
71
  node.tradeoffs,
69
- node.customSections,
72
+ node.customSections
70
73
  ];
71
- return parts.map(part => collectText(part)).join(' ').slice(0, MAX_LENGTH).toLowerCase();
74
+ return parts
75
+ .map(part => collectText(part))
76
+ .join(' ')
77
+ .slice(0, MAX_LENGTH)
78
+ .toLowerCase();
72
79
  }
73
80
 
74
81
  /** Rank-free substring search returning at most `limit` nodes. */
75
82
  export function searchNodes(nodes, query, limit = 8) {
76
- const needle = String(query || '').trim().toLowerCase();
83
+ const needle = String(query || '')
84
+ .trim()
85
+ .toLowerCase();
77
86
  if (!needle) return [];
78
87
  const results = [];
79
88
  for (const node of nodes) {
@@ -13,15 +13,19 @@ function normalizeMode(value, fallback = 'dark') {
13
13
  * Compile-time defaults injected via Vite `define` (CLI --skin/--default-mode
14
14
  * or CONCEPT_ATLAS_* env vars). Absent defines resolve to the carrier fallbacks.
15
15
  */
16
- const BUILD_DEFAULT_SKIN = typeof __ATLAS_DEFAULT_SKIN__ === 'string'
17
- ? (normalizeSkin(__ATLAS_DEFAULT_SKIN__) || DEFAULT_SKIN)
18
- : DEFAULT_SKIN;
19
- const BUILD_DEFAULT_MODE = typeof __ATLAS_DEFAULT_MODE__ === 'string' && (__ATLAS_DEFAULT_MODE__ === 'dark' || __ATLAS_DEFAULT_MODE__ === 'light')
20
- ? __ATLAS_DEFAULT_MODE__
21
- : null;
22
- const BUILD_DEFAULT_STYLE = typeof __ATLAS_DEFAULT_STYLE__ === 'string'
23
- ? (normalizeStyle(__ATLAS_DEFAULT_STYLE__) || DEFAULT_STYLE)
24
- : DEFAULT_STYLE;
16
+ const BUILD_DEFAULT_SKIN =
17
+ typeof __ATLAS_DEFAULT_SKIN__ === 'string'
18
+ ? normalizeSkin(__ATLAS_DEFAULT_SKIN__) || DEFAULT_SKIN
19
+ : DEFAULT_SKIN;
20
+ const BUILD_DEFAULT_MODE =
21
+ typeof __ATLAS_DEFAULT_MODE__ === 'string' &&
22
+ (__ATLAS_DEFAULT_MODE__ === 'dark' || __ATLAS_DEFAULT_MODE__ === 'light')
23
+ ? __ATLAS_DEFAULT_MODE__
24
+ : null;
25
+ const BUILD_DEFAULT_STYLE =
26
+ typeof __ATLAS_DEFAULT_STYLE__ === 'string'
27
+ ? normalizeStyle(__ATLAS_DEFAULT_STYLE__) || DEFAULT_STYLE
28
+ : DEFAULT_STYLE;
25
29
 
26
30
  function readStoredAppearance(fallbackMode) {
27
31
  const fallback = normalizeMode(fallbackMode, 'dark');
@@ -34,7 +38,7 @@ function readStoredAppearance(fallbackMode) {
34
38
  // Corrupt/missing mode falls back to the carrier default instead of
35
39
  // silently coercing to dark.
36
40
  mode: normalizeMode(parsed?.mode, fallback),
37
- style: normalizeStyle(parsed?.style) || BUILD_DEFAULT_STYLE,
41
+ style: normalizeStyle(parsed?.style) || BUILD_DEFAULT_STYLE
38
42
  };
39
43
  }
40
44
  } catch {
@@ -67,7 +71,9 @@ export { readStoredAppearance, normalizeMode };
67
71
  * localStorage key so both pages stay in sync.
68
72
  */
69
73
  export function useAppearance({ defaultMode = 'dark' } = {}) {
70
- const [appearance, setAppearance] = useState(() => readStoredAppearance(BUILD_DEFAULT_MODE || defaultMode));
74
+ const [appearance, setAppearance] = useState(() =>
75
+ readStoredAppearance(BUILD_DEFAULT_MODE || defaultMode)
76
+ );
71
77
 
72
78
  useEffect(() => {
73
79
  document.documentElement.setAttribute('data-skin', appearance.skin);
@@ -80,18 +86,26 @@ export function useAppearance({ defaultMode = 'dark' } = {}) {
80
86
  }
81
87
  }, [appearance]);
82
88
 
83
- const setSkin = useCallback((skin) => {
89
+ const setSkin = useCallback(skin => {
84
90
  setAppearance(prev => ({ ...prev, skin: normalizeSkin(skin) || prev.skin }));
85
91
  }, []);
86
- const setMode = useCallback((mode) => {
92
+ const setMode = useCallback(mode => {
87
93
  setAppearance(prev => ({ ...prev, mode: normalizeMode(mode, prev.mode) }));
88
94
  }, []);
89
95
  const toggleMode = useCallback(() => {
90
96
  setAppearance(prev => ({ ...prev, mode: prev.mode === 'dark' ? 'light' : 'dark' }));
91
97
  }, []);
92
- const setStyle = useCallback((style) => {
98
+ const setStyle = useCallback(style => {
93
99
  setAppearance(prev => ({ ...prev, style: normalizeStyle(style) || prev.style }));
94
100
  }, []);
95
101
 
96
- return { skin: appearance.skin, mode: appearance.mode, style: appearance.style, setSkin, setMode, toggleMode, setStyle };
102
+ return {
103
+ skin: appearance.skin,
104
+ mode: appearance.mode,
105
+ style: appearance.style,
106
+ setSkin,
107
+ setMode,
108
+ toggleMode,
109
+ setStyle
110
+ };
97
111
  }