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.
- package/README.md +3 -3
- package/bin/cli.mjs +197 -80
- package/package.json +1 -1
- package/packaged-skill/SKILL.md +8 -7
- package/packaged-skill/references/scroll-guide.mdx +14 -0
- package/template/references/scroll-guide.mdx +14 -0
- package/template/src/app/App.jsx +77 -41
- package/template/src/app/search.js +14 -5
- package/template/src/app/use-appearance.js +29 -15
- package/template/src/components/MDXComponents.jsx +1029 -196
- package/template/src/components/SkinPicker.jsx +14 -6
- package/template/src/model/concept-schema.js +27 -12
- package/template/src/model/node-kinds.js +9 -9
- package/template/src/model/normalize-content.js +8 -8
- package/template/src/model/relation-types.js +37 -12
- package/template/src/model/validate-content.js +282 -59
- package/template/src/scroll-main.jsx +8 -1
- package/template/src/styles/core.css +191 -0
- package/template/src/styles/packs/elastic.css +37 -0
- package/template/src/styles/packs/manuscript.css +67 -0
- package/template/src/styles/packs/shadcn.css +33 -0
- package/template/src/views/NodeExplorer.jsx +364 -293
- package/template/src/views/RelationGraph.jsx +191 -118
- package/template/vite.config.js +32 -20
package/template/src/app/App.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
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 = (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
141
|
+
}, [graph, currentNodeId, navigateToNode, moveHistory, toggleTheme]);
|
|
128
142
|
|
|
129
143
|
useEffect(() => {
|
|
130
|
-
const handlePopState =
|
|
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 =
|
|
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 =
|
|
144
|
-
|
|
145
|
-
|
|
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 => {
|
|
207
|
+
onKeyDown={event => {
|
|
208
|
+
if (event.key === 'Escape') setGlobalQuery('');
|
|
209
|
+
}}
|
|
194
210
|
placeholder="搜索所有节点…"
|
|
195
211
|
aria-label="搜索所有节点"
|
|
196
212
|
/>
|
|
197
|
-
{globalQuery &&
|
|
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
|
|
202
|
-
|
|
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')
|
|
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)
|
|
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
|
|
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 || '')
|
|
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 =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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(() =>
|
|
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(
|
|
89
|
+
const setSkin = useCallback(skin => {
|
|
84
90
|
setAppearance(prev => ({ ...prev, skin: normalizeSkin(skin) || prev.skin }));
|
|
85
91
|
}, []);
|
|
86
|
-
const setMode = useCallback(
|
|
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(
|
|
98
|
+
const setStyle = useCallback(style => {
|
|
93
99
|
setAppearance(prev => ({ ...prev, style: normalizeStyle(style) || prev.style }));
|
|
94
100
|
}, []);
|
|
95
101
|
|
|
96
|
-
return {
|
|
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
|
}
|