concept-atlas-dense-explain 3.0.0 → 3.0.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.
- package/package.json +1 -1
- package/packaged-skill/SKILL.md +1 -1
- package/template/src/app/App.jsx +62 -10
- package/template/src/styles/core.css +104 -12
- package/template/src/views/RelationGraph.jsx +203 -16
package/package.json
CHANGED
package/packaged-skill/SKILL.md
CHANGED
|
@@ -65,7 +65,7 @@ This is the only runner — there is nothing to look for. Do not search for a lo
|
|
|
65
65
|
- **CodeBlock**: a general code block usable anywhere; `Implementation` is the node-bound variant. Pass the code as a string via `code` (or as children) wrapped in a template literal so MDX does not read it as expressions: `<CodeBlock language="bash" title="..." lineNumbers>{`npm run validate`}</CodeBlock>`. `language` adds the badge, `title`/`caption` add labels, `lineNumbers` and `wrap` are booleans.
|
|
66
66
|
- **Chart**: `type` is `bar` | `line` | `pie`; use `data` for bar/pie and `labels` + `series={[{name, values}]}` for line. Charts follow theme colors.
|
|
67
67
|
- **Learning loop**: `WorkedExample` holds `Step` children; give each step a `reason` (the justification) so it teaches reasoning, not just the result. `Quiz question answer` reveals the answer on click with children as the explanation. `KeyTakeaways items={[...]}` closes a section.
|
|
68
|
-
- **Provenance**: `Source kind="spec|rfc|implementation|experiment|experience" label href` marks one claim's origin inline; `Confidence level="high|medium|low" basis` wraps a claim with how strongly it is established; `Term name` (or children as the definition) gives an inline hover definition.
|
|
68
|
+
- **Provenance**: `Source kind="spec|rfc|implementation|experiment|experience|reference" label href` marks one claim's origin inline (`reference` is the default); `Confidence level="high|medium|low" basis` wraps a claim with how strongly it is established; `Term name` (or children as the definition) gives an inline hover definition.
|
|
69
69
|
- **Data and behaviour**: `DataTable headers={[...]} rows={[[...]]}` is the neutral table (no comparative stance). `Metric label value unit delta trend note` is one headline number — compose several with `Grid`/`ScrollGrid`. `StateMachine states={[{id,label,terminal}]} transitions={[{from,to,event,guard}]}` expresses cycles and guards. `DecisionTree branches={[{condition,outcome,tone,branches}]}` nests branches. `FeedbackLoop type="reinforcing|balancing" nodes={[{label,description}]}` closes a loop. `CodeDiff before after language` shows a change.
|
|
70
70
|
- **Structure and code extensions**: `Quote author source href` attributes a quotation. `Checklist title items={[{text,status:'pass'|'fail'|'unknown'|'todo',note}]}` is a verification list. `PropertyList title items={[{name,value,note}]}` is a name/value spec ledger. `TreeView title items={[{label,description,children:[…]}]}` nests a hierarchy. `CodeTabs items={[{label,language,code}]} title caption lineNumbers` gives one collapsible listing per variant. `AnnotatedCode code language title notes={[{line,text}]}` numbers a listing and calls out specific lines. `LastReviewed date by note` is a review stamp.
|
|
71
71
|
- **Figure**: a relative `src` (`./assets/diagram.png`) stays a relative link by default; compile with `--inline-assets` to bake every local image in as base64, or set `inline={true}` / `inline={false}` on one figure to override (precedence: per-figure prop > global flag > link). `http(s)` URLs stay links and warn (`ASSET_REMOTE`). Always set `alt`; 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.
|
package/template/src/app/App.jsx
CHANGED
|
@@ -67,6 +67,19 @@ export function App({ mdxContent, initialData }) {
|
|
|
67
67
|
[globalQuery, graph.nodes]
|
|
68
68
|
);
|
|
69
69
|
|
|
70
|
+
// Combobox state for the global search. The highlight resets whenever the
|
|
71
|
+
// query changes so the first arrow key lands on the first result.
|
|
72
|
+
const [activeResultIndex, setActiveResultIndex] = useState(-1);
|
|
73
|
+
const searchOpen = globalQuery.trim().length > 0 && searchResults.length > 0;
|
|
74
|
+
const activeResultId =
|
|
75
|
+
searchOpen && activeResultIndex >= 0 && searchResults[activeResultIndex]
|
|
76
|
+
? `global-search-option-${searchResults[activeResultIndex].id}`
|
|
77
|
+
: undefined;
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
setActiveResultIndex(-1);
|
|
81
|
+
}, [globalQuery]);
|
|
82
|
+
|
|
70
83
|
const navigateToNode = useCallback(
|
|
71
84
|
(nodeId, { replace = false } = {}) => {
|
|
72
85
|
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
@@ -84,6 +97,16 @@ export function App({ mdxContent, initialData }) {
|
|
|
84
97
|
[nav, graph.nodes]
|
|
85
98
|
);
|
|
86
99
|
|
|
100
|
+
const selectSearchResult = useCallback(
|
|
101
|
+
node => {
|
|
102
|
+
if (!node) return;
|
|
103
|
+
navigateToNode(node.id);
|
|
104
|
+
setGlobalQuery('');
|
|
105
|
+
setActiveResultIndex(-1);
|
|
106
|
+
},
|
|
107
|
+
[navigateToNode]
|
|
108
|
+
);
|
|
109
|
+
|
|
87
110
|
const moveHistory = useCallback(
|
|
88
111
|
direction => {
|
|
89
112
|
// Browser history mirrors the in-app entries one-for-one (every navigation
|
|
@@ -205,28 +228,57 @@ export function App({ mdxContent, initialData }) {
|
|
|
205
228
|
value={globalQuery}
|
|
206
229
|
onChange={event => setGlobalQuery(event.target.value)}
|
|
207
230
|
onKeyDown={event => {
|
|
208
|
-
if (event.key === 'Escape')
|
|
231
|
+
if (event.key === 'Escape') {
|
|
232
|
+
setGlobalQuery('');
|
|
233
|
+
setActiveResultIndex(-1);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!searchOpen) return;
|
|
237
|
+
if (event.key === 'ArrowDown') {
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
setActiveResultIndex(index => (index + 1) % searchResults.length);
|
|
240
|
+
} else if (event.key === 'ArrowUp') {
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
setActiveResultIndex(index =>
|
|
243
|
+
index <= 0 ? searchResults.length - 1 : index - 1
|
|
244
|
+
);
|
|
245
|
+
} else if (event.key === 'Enter' && activeResultIndex >= 0) {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
selectSearchResult(searchResults[activeResultIndex]);
|
|
248
|
+
}
|
|
209
249
|
}}
|
|
210
250
|
placeholder="搜索所有节点…"
|
|
211
251
|
aria-label="搜索所有节点"
|
|
252
|
+
role="combobox"
|
|
253
|
+
aria-expanded={searchOpen}
|
|
254
|
+
aria-controls="global-search-results"
|
|
255
|
+
aria-autocomplete="list"
|
|
256
|
+
aria-activedescendant={activeResultId}
|
|
212
257
|
/>
|
|
213
258
|
{globalQuery && (
|
|
214
|
-
<button
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => {
|
|
262
|
+
setGlobalQuery('');
|
|
263
|
+
setActiveResultIndex(-1);
|
|
264
|
+
}}
|
|
265
|
+
aria-label="清除搜索"
|
|
266
|
+
>
|
|
215
267
|
<X size={13} />
|
|
216
268
|
</button>
|
|
217
269
|
)}
|
|
218
|
-
{
|
|
219
|
-
<div className="global-search-results" role="listbox">
|
|
220
|
-
{searchResults.map(node => (
|
|
270
|
+
{searchOpen && (
|
|
271
|
+
<div id="global-search-results" className="global-search-results" role="listbox">
|
|
272
|
+
{searchResults.map((node, index) => (
|
|
221
273
|
<button
|
|
222
274
|
type="button"
|
|
223
275
|
key={node.id}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
276
|
+
id={`global-search-option-${node.id}`}
|
|
277
|
+
className={index === activeResultIndex ? 'active' : ''}
|
|
278
|
+
onMouseEnter={() => setActiveResultIndex(index)}
|
|
279
|
+
onClick={() => selectSearchResult(node)}
|
|
228
280
|
role="option"
|
|
229
|
-
aria-selected={
|
|
281
|
+
aria-selected={index === activeResultIndex}
|
|
230
282
|
>
|
|
231
283
|
<span>{node.title}</span>
|
|
232
284
|
<small>
|
|
@@ -85,6 +85,8 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
85
85
|
.global-search-results { position: absolute; z-index: 20; top: calc(100% + 6px); left: 0; right: 0; padding: 5px; border: 1px solid var(--border-medium); border-radius: 10px; background: var(--bg-surface); box-shadow: var(--shadow-lg); }
|
|
86
86
|
.global-search-results button { display: grid; gap: 2px; width: 100%; border: 0; border-radius: 7px; background: transparent; color: var(--text-primary); text-align: left; padding: 8px; }
|
|
87
87
|
.global-search-results button:hover { background: var(--bg-subtle); }
|
|
88
|
+
.global-search-results button.active,
|
|
89
|
+
.global-search-results button[aria-selected="true"] { background: var(--bg-highlight); }
|
|
88
90
|
.global-search-results small { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
89
91
|
|
|
90
92
|
/* Optional semantic layout primitives */
|
|
@@ -1298,6 +1300,36 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1298
1300
|
}
|
|
1299
1301
|
}
|
|
1300
1302
|
|
|
1303
|
+
/* Narrow screens: the toolbar becomes a two-row header — controls on the first
|
|
1304
|
+
line, the global search on its own full-width second line — so no label is
|
|
1305
|
+
ever squeezed into a wrapped stub. Content offsets below clear its height. */
|
|
1306
|
+
@media (max-width: 760px) {
|
|
1307
|
+
.app-toolbar {
|
|
1308
|
+
top: 8px;
|
|
1309
|
+
left: 10px;
|
|
1310
|
+
right: 10px;
|
|
1311
|
+
flex-wrap: wrap;
|
|
1312
|
+
gap: 6px;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
.app-toolbar .view-tabs {
|
|
1316
|
+
flex: 1 1 auto;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
.app-toolbar .view-tab {
|
|
1320
|
+
flex: 1 1 auto;
|
|
1321
|
+
justify-content: center;
|
|
1322
|
+
white-space: nowrap;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
.app-toolbar .global-search {
|
|
1326
|
+
order: 2;
|
|
1327
|
+
flex: 1 1 100%;
|
|
1328
|
+
min-width: 0;
|
|
1329
|
+
margin-left: 0;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1301
1333
|
.draft-floating-tools .view-graph-btn {
|
|
1302
1334
|
margin: 0;
|
|
1303
1335
|
padding: 5px 9px;
|
|
@@ -1408,7 +1440,7 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1408
1440
|
|
|
1409
1441
|
.hierarchy-strip-main, .hierarchy-strip-children { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
1410
1442
|
.hierarchy-strip-main { min-height: 30px; }
|
|
1411
|
-
.hierarchy-strip-label { flex: 0 0 auto; color: var(--text-
|
|
1443
|
+
.hierarchy-strip-label { flex: 0 0 auto; color: var(--text-muted); font-size: 11px; font-weight: 700; letter-spacing: .04em; }
|
|
1412
1444
|
|
|
1413
1445
|
.hierarchy-strip-path,
|
|
1414
1446
|
.hierarchy-strip-children {
|
|
@@ -1445,7 +1477,7 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1445
1477
|
.hierarchy-strip-path span {
|
|
1446
1478
|
color: var(--text-dim);
|
|
1447
1479
|
font-family: var(--font-data);
|
|
1448
|
-
font-size:
|
|
1480
|
+
font-size: 10px;
|
|
1449
1481
|
}
|
|
1450
1482
|
|
|
1451
1483
|
.hierarchy-strip-path strong {
|
|
@@ -1461,7 +1493,34 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1461
1493
|
padding-top: 16px;
|
|
1462
1494
|
}
|
|
1463
1495
|
|
|
1464
|
-
.hierarchy-child-scroll {
|
|
1496
|
+
.hierarchy-child-scroll {
|
|
1497
|
+
display: flex;
|
|
1498
|
+
gap: 5px;
|
|
1499
|
+
overflow-x: auto;
|
|
1500
|
+
min-width: 0;
|
|
1501
|
+
overscroll-behavior-x: contain;
|
|
1502
|
+
/* The rail is a chip row: scroll it silently so a desktop layout does not
|
|
1503
|
+
trade a wrapped label for a chunky scrollbar. */
|
|
1504
|
+
scrollbar-width: none;
|
|
1505
|
+
scroll-padding-inline: 2px;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
.hierarchy-child-scroll::-webkit-scrollbar { display: none; }
|
|
1509
|
+
|
|
1510
|
+
/* The drill-down rail scrolls; its items must never be compressed into a
|
|
1511
|
+
one-character-wide column on a narrow screen. */
|
|
1512
|
+
.hierarchy-child-scroll > * {
|
|
1513
|
+
flex: 0 0 auto;
|
|
1514
|
+
white-space: nowrap;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/* Same rule for the breadcrumb: long paths clip at the container edge instead
|
|
1518
|
+
of wrapping each crumb onto its own line. */
|
|
1519
|
+
.hierarchy-strip-path button,
|
|
1520
|
+
.hierarchy-strip-path strong {
|
|
1521
|
+
flex: 0 0 auto;
|
|
1522
|
+
white-space: nowrap;
|
|
1523
|
+
}
|
|
1465
1524
|
|
|
1466
1525
|
.hierarchy-strip-children button {
|
|
1467
1526
|
border: 1px solid var(--border-subtle);
|
|
@@ -1606,7 +1665,7 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1606
1665
|
.inline-relation-type {
|
|
1607
1666
|
display: block;
|
|
1608
1667
|
margin-bottom: 2px;
|
|
1609
|
-
font-size:
|
|
1668
|
+
font-size: 11px;
|
|
1610
1669
|
font-weight: 800;
|
|
1611
1670
|
}
|
|
1612
1671
|
|
|
@@ -1623,7 +1682,7 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1623
1682
|
display: block;
|
|
1624
1683
|
margin-top: 3px;
|
|
1625
1684
|
color: var(--text-dim);
|
|
1626
|
-
font-size:
|
|
1685
|
+
font-size: 11px;
|
|
1627
1686
|
line-height: 1.45;
|
|
1628
1687
|
}
|
|
1629
1688
|
|
|
@@ -1825,7 +1884,8 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1825
1884
|
.node-explorer-layout { display: block; overflow-y: auto; }
|
|
1826
1885
|
.explorer-side { max-height: none; border: 0; }
|
|
1827
1886
|
.left-side { display: none; }
|
|
1828
|
-
|
|
1887
|
+
/* Clear the two-row app toolbar so the hierarchy strip is never beneath it. */
|
|
1888
|
+
.explorer-center { padding: 100px 12px 18px; }
|
|
1829
1889
|
.hierarchy-strip { width: 100%; top: 4px; }
|
|
1830
1890
|
.draft-board { min-width: 0; }
|
|
1831
1891
|
.semantic-split { grid-template-columns: 1fr; }
|
|
@@ -1839,9 +1899,41 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1839
1899
|
.relation-graph-layout { display: flex; flex-direction: column; }
|
|
1840
1900
|
.graph-main { min-height: 62vh; }
|
|
1841
1901
|
.graph-side-panel { max-height: 38vh; border-left: 0; border-top: 1px solid var(--border-medium); padding: 16px; }
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1902
|
+
|
|
1903
|
+
/* Clear the two-row app toolbar, then keep every control reachable: the bar
|
|
1904
|
+
and the legend scroll horizontally instead of hiding filters/legend. */
|
|
1905
|
+
.graph-control-bar {
|
|
1906
|
+
left: 10px;
|
|
1907
|
+
right: 10px;
|
|
1908
|
+
top: 100px;
|
|
1909
|
+
flex-wrap: nowrap;
|
|
1910
|
+
gap: 8px;
|
|
1911
|
+
overflow-x: auto;
|
|
1912
|
+
overscroll-behavior-x: contain;
|
|
1913
|
+
scrollbar-width: none;
|
|
1914
|
+
}
|
|
1915
|
+
.graph-control-bar::-webkit-scrollbar { display: none; }
|
|
1916
|
+
.graph-control-bar .graph-mode-switch,
|
|
1917
|
+
.graph-control-bar .search-box,
|
|
1918
|
+
.graph-control-bar .filter-group { flex: 0 0 auto; }
|
|
1919
|
+
.graph-control-bar .filter-group { display: flex; }
|
|
1920
|
+
|
|
1921
|
+
.graph-legend-ribbon {
|
|
1922
|
+
left: 10px;
|
|
1923
|
+
right: 10px;
|
|
1924
|
+
bottom: 10px;
|
|
1925
|
+
max-width: none;
|
|
1926
|
+
flex-wrap: nowrap;
|
|
1927
|
+
overflow-x: auto;
|
|
1928
|
+
overscroll-behavior-x: contain;
|
|
1929
|
+
scrollbar-width: none;
|
|
1930
|
+
}
|
|
1931
|
+
.graph-legend-ribbon::-webkit-scrollbar { display: none; }
|
|
1932
|
+
/* Same rule as the hierarchy rail: never let a label be squeezed into a
|
|
1933
|
+
one-character-wide column while the ribbon scrolls. */
|
|
1934
|
+
.graph-legend-ribbon > * { flex: 0 0 auto; }
|
|
1935
|
+
.graph-legend-ribbon .legend-title,
|
|
1936
|
+
.graph-legend-ribbon .legend-item { white-space: nowrap; }
|
|
1845
1937
|
}
|
|
1846
1938
|
|
|
1847
1939
|
.graph-side-panel {
|
|
@@ -1963,7 +2055,7 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1963
2055
|
display: flex;
|
|
1964
2056
|
justify-content: space-between;
|
|
1965
2057
|
align-items: center;
|
|
1966
|
-
font-size:
|
|
2058
|
+
font-size: 11px;
|
|
1967
2059
|
margin-bottom: 4px;
|
|
1968
2060
|
}
|
|
1969
2061
|
|
|
@@ -3519,7 +3611,7 @@ html[data-carrier='scroll'] #root {
|
|
|
3519
3611
|
.scroll-toc-list a::before {
|
|
3520
3612
|
content: counter(toc, decimal-leading-zero);
|
|
3521
3613
|
color: var(--text-dim);
|
|
3522
|
-
font-size:
|
|
3614
|
+
font-size: 11px;
|
|
3523
3615
|
font-variant-numeric: tabular-nums;
|
|
3524
3616
|
text-align: center;
|
|
3525
3617
|
transition: color 0.15s ease;
|
|
@@ -3619,7 +3711,7 @@ html[data-carrier='scroll'] #root {
|
|
|
3619
3711
|
.chart-axis-value,
|
|
3620
3712
|
.chart-axis-label {
|
|
3621
3713
|
fill: var(--text-dim);
|
|
3622
|
-
font-size:
|
|
3714
|
+
font-size: 11px;
|
|
3623
3715
|
font-family: var(--font-body);
|
|
3624
3716
|
font-variant-numeric: var(--numeric-variant);
|
|
3625
3717
|
}
|
|
@@ -42,6 +42,12 @@ export function RelationGraph({ graph, currentNodeId, onSelectNode, onSwitchView
|
|
|
42
42
|
// a ref so a new `onSelectNode` identity never tears the graph down.
|
|
43
43
|
onSelectNodeRef.current = onSelectNode;
|
|
44
44
|
|
|
45
|
+
// Distinguishes a user selection from a layout rebuild: a rebuild re-frames
|
|
46
|
+
// the whole graph, a selection re-centres on one node. Filtering/searching
|
|
47
|
+
// rebuilds the layout but must not move the camera.
|
|
48
|
+
const prevSelectedRef = useRef(selectedNodeId);
|
|
49
|
+
const framedModeRef = useRef(null);
|
|
50
|
+
|
|
45
51
|
// Convert nodes map to array
|
|
46
52
|
const allNodes = useMemo(() => Array.from(nodes.values()), [nodes]);
|
|
47
53
|
|
|
@@ -234,6 +240,7 @@ export function RelationGraph({ graph, currentNodeId, onSelectNode, onSwitchView
|
|
|
234
240
|
const positionedNodes = isConceptMode
|
|
235
241
|
? graphNodes.map((node, index) => ({
|
|
236
242
|
...node,
|
|
243
|
+
...labelMetrics(node),
|
|
237
244
|
x: width / 2 + ((index % 4) - 1.5) * 80,
|
|
238
245
|
y: height / 2 + (Math.floor(index / 4) - 1) * 80
|
|
239
246
|
}))
|
|
@@ -272,7 +279,14 @@ export function RelationGraph({ graph, currentNodeId, onSelectNode, onSwitchView
|
|
|
272
279
|
})
|
|
273
280
|
)
|
|
274
281
|
.force('center', forceCenter(width / 2, height / 2))
|
|
275
|
-
|
|
282
|
+
// Radius follows the label extent, not just the node disc, so long
|
|
283
|
+
// Chinese titles keep their own horizontal room.
|
|
284
|
+
.force(
|
|
285
|
+
'collision',
|
|
286
|
+
forceCollide()
|
|
287
|
+
.radius(d => Math.max(34, (d.labelHalf || 0) + 12))
|
|
288
|
+
.strength(0.6)
|
|
289
|
+
)
|
|
276
290
|
: null;
|
|
277
291
|
|
|
278
292
|
// Resolve the shared label stack once; SVG presentation attributes cannot
|
|
@@ -307,7 +321,7 @@ export function RelationGraph({ graph, currentNodeId, onSelectNode, onSwitchView
|
|
|
307
321
|
.append('text')
|
|
308
322
|
.attr('class', 'graph-edge-label')
|
|
309
323
|
.style('fill', d => d.typeInfo?.color || 'var(--rel-default)')
|
|
310
|
-
.attr('font-size', '
|
|
324
|
+
.attr('font-size', '11px')
|
|
311
325
|
.attr('text-anchor', 'middle')
|
|
312
326
|
.attr('dy', -5)
|
|
313
327
|
// Parent-child is visually self-explanatory and its repeated label
|
|
@@ -375,16 +389,26 @@ export function RelationGraph({ graph, currentNodeId, onSelectNode, onSwitchView
|
|
|
375
389
|
.attr('r', d => (d.level === 'L0' ? 10 : d.level === 'L1' ? 7 : 5))
|
|
376
390
|
.style('fill', d => LEVEL_DEFS[d.level]?.color || 'var(--level-l0)');
|
|
377
391
|
|
|
378
|
-
// Node Title Label
|
|
392
|
+
// Node Title Label — wrapped to at most two lines so wide trees stay legible.
|
|
379
393
|
nodeLabelRef.current = nodesSelection
|
|
380
394
|
.append('text')
|
|
381
|
-
.attr('dy', d => (d.level === 'L0' ? 38 : 30))
|
|
382
395
|
.attr('text-anchor', 'middle')
|
|
383
396
|
.style('fill', 'var(--text-primary)')
|
|
384
|
-
.attr('font-size',
|
|
397
|
+
.attr('font-size', `${LABEL_FONT_SIZE}px`)
|
|
385
398
|
.attr('font-family', labelFont)
|
|
386
|
-
.attr('font-weight', d => (d.id === selectedNodeIdRef.current ? '700' : '500'))
|
|
387
|
-
|
|
399
|
+
.attr('font-weight', d => (d.id === selectedNodeIdRef.current ? '700' : '500'));
|
|
400
|
+
|
|
401
|
+
nodeLabelRef.current.each(function renderNodeTitle(d) {
|
|
402
|
+
const firstDy = d.level === 'L0' ? 34 : d.level === 'L1' ? 28 : 24;
|
|
403
|
+
const text = select(this);
|
|
404
|
+
wrapLabel(d.title).forEach((line, index) => {
|
|
405
|
+
text
|
|
406
|
+
.append('tspan')
|
|
407
|
+
.attr('x', 0)
|
|
408
|
+
.attr('dy', index === 0 ? firstDy : LABEL_LINE_HEIGHT)
|
|
409
|
+
.text(line);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
388
412
|
|
|
389
413
|
// Node Level Pill
|
|
390
414
|
nodesSelection
|
|
@@ -392,7 +416,7 @@ export function RelationGraph({ graph, currentNodeId, onSelectNode, onSwitchView
|
|
|
392
416
|
.attr('dy', -22)
|
|
393
417
|
.attr('text-anchor', 'middle')
|
|
394
418
|
.style('fill', d => LEVEL_DEFS[d.level]?.color || 'var(--rel-default)')
|
|
395
|
-
.attr('font-size', '
|
|
419
|
+
.attr('font-size', '10px')
|
|
396
420
|
.text(d => d.level);
|
|
397
421
|
|
|
398
422
|
const renderPositions = () => {
|
|
@@ -411,6 +435,24 @@ export function RelationGraph({ graph, currentNodeId, onSelectNode, onSwitchView
|
|
|
411
435
|
renderPositions();
|
|
412
436
|
}
|
|
413
437
|
|
|
438
|
+
// Frame the whole graph on the first build and on every mode switch so
|
|
439
|
+
// nothing starts off-screen. Filter/search rebuilds deliberately leave the
|
|
440
|
+
// camera where the reader put it.
|
|
441
|
+
const shouldFrame = framedModeRef.current !== graphMode;
|
|
442
|
+
framedModeRef.current = graphMode;
|
|
443
|
+
let framed = false;
|
|
444
|
+
const frameGraph = () => {
|
|
445
|
+
if (framed) return;
|
|
446
|
+
framed = true;
|
|
447
|
+
fitGraphToViewport(svg, zoomBehavior, positionedNodes, width, height);
|
|
448
|
+
};
|
|
449
|
+
if (shouldFrame) {
|
|
450
|
+
// Hierarchy layout is deterministic; the concept force map is framed once
|
|
451
|
+
// it settles so the transform is not stale.
|
|
452
|
+
if (simulation) simulation.on('end.framing', frameGraph);
|
|
453
|
+
else frameGraph();
|
|
454
|
+
}
|
|
455
|
+
|
|
414
456
|
return () => {
|
|
415
457
|
simulation?.stop();
|
|
416
458
|
svg.on('wheel.pan', null);
|
|
@@ -429,7 +471,13 @@ export function RelationGraph({ graph, currentNodeId, onSelectNode, onSwitchView
|
|
|
429
471
|
nodeHaloRef.current?.attr('stroke-width', d => (d.id === selectedNodeId ? 3 : 1.5));
|
|
430
472
|
nodeLabelRef.current?.attr('font-weight', d => (d.id === selectedNodeId ? '700' : '500'));
|
|
431
473
|
|
|
474
|
+
// Only a genuine selection change re-centres; a layout rebuild (filter,
|
|
475
|
+
// search, mode switch) keeps the framing set by the layout effect.
|
|
476
|
+
const selectionChanged = prevSelectedRef.current !== selectedNodeId;
|
|
477
|
+
prevSelectedRef.current = selectedNodeId;
|
|
478
|
+
|
|
432
479
|
if (
|
|
480
|
+
!selectionChanged ||
|
|
433
481
|
graphMode === 'concept' ||
|
|
434
482
|
!selectedNodeId ||
|
|
435
483
|
!svgRef.current ||
|
|
@@ -688,6 +736,128 @@ export function RelationGraph({ graph, currentNodeId, onSelectNode, onSwitchView
|
|
|
688
736
|
);
|
|
689
737
|
}
|
|
690
738
|
|
|
739
|
+
/* --------------------------------------------------------------------------
|
|
740
|
+
Graph label metrics & viewport framing
|
|
741
|
+
-------------------------------------------------------------------------- */
|
|
742
|
+
|
|
743
|
+
const LABEL_FONT_SIZE = 12;
|
|
744
|
+
const LABEL_LINE_HEIGHT = 14;
|
|
745
|
+
const LABEL_LINE_CHARS = 8;
|
|
746
|
+
const MAX_LABEL_LINES = 2;
|
|
747
|
+
const TREE_BASE_GAP = 120;
|
|
748
|
+
const TREE_LEVEL_GAP = 155;
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Split a long node title across at most two lines. A two-line label halves the
|
|
752
|
+
* horizontal footprint of a wide tree, which keeps the framed view legible. The
|
|
753
|
+
* full, untruncated title stays available in the node tooltip.
|
|
754
|
+
*/
|
|
755
|
+
function wrapLabel(text, maxChars = LABEL_LINE_CHARS, maxLines = MAX_LABEL_LINES) {
|
|
756
|
+
const value = String(text ?? '').trim();
|
|
757
|
+
if (!value) return [''];
|
|
758
|
+
const lines = [];
|
|
759
|
+
let rest = value;
|
|
760
|
+
while (rest && lines.length < maxLines) {
|
|
761
|
+
if (rest.length <= maxChars) {
|
|
762
|
+
lines.push(rest);
|
|
763
|
+
rest = '';
|
|
764
|
+
} else if (lines.length === maxLines - 1) {
|
|
765
|
+
lines.push(`${rest.slice(0, maxChars - 1)}…`);
|
|
766
|
+
rest = '';
|
|
767
|
+
} else {
|
|
768
|
+
lines.push(rest.slice(0, maxChars));
|
|
769
|
+
rest = rest.slice(maxChars);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return lines;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Estimate a title's rendered width without a DOM measurement: CJK glyphs are
|
|
777
|
+
* roughly square at the label font size, Latin/digits about 0.55 of it.
|
|
778
|
+
*/
|
|
779
|
+
function estimateLabelWidth(text) {
|
|
780
|
+
let width = 0;
|
|
781
|
+
for (const char of String(text ?? '')) {
|
|
782
|
+
width += /[\u2e80-\u9fff\uff00-\uffef]/.test(char) ? LABEL_FONT_SIZE : LABEL_FONT_SIZE * 0.55;
|
|
783
|
+
}
|
|
784
|
+
return width;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Per-node label geometry, shared by the layout, the renderer and framing. */
|
|
788
|
+
function labelMetrics(node) {
|
|
789
|
+
const lines = wrapLabel(node?.title);
|
|
790
|
+
const width = Math.max(...lines.map(estimateLabelWidth));
|
|
791
|
+
return { labelHalf: Math.max(18, width / 2), labelLines: lines.length };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function nodeLabelHalf(node) {
|
|
795
|
+
return labelMetrics(node).labelHalf;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function prefersReducedMotion() {
|
|
799
|
+
return (
|
|
800
|
+
typeof window !== 'undefined' &&
|
|
801
|
+
typeof window.matchMedia === 'function' &&
|
|
802
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Fit every node (plus its title) into the viewport once. Insets keep the
|
|
808
|
+
* floating control bar and legend from covering the graph on small screens.
|
|
809
|
+
*/
|
|
810
|
+
function fitGraphToViewport(svg, zoomBehavior, nodes, width, height) {
|
|
811
|
+
if (!nodes.length || !zoomBehavior) return;
|
|
812
|
+
|
|
813
|
+
const narrow = width < 760;
|
|
814
|
+
const insetTop = narrow ? 150 : 96;
|
|
815
|
+
const insetBottom = narrow ? 72 : 64;
|
|
816
|
+
const insetSide = narrow ? 24 : 56;
|
|
817
|
+
|
|
818
|
+
let minX = Infinity;
|
|
819
|
+
let maxX = -Infinity;
|
|
820
|
+
let minY = Infinity;
|
|
821
|
+
let maxY = -Infinity;
|
|
822
|
+
|
|
823
|
+
nodes.forEach(node => {
|
|
824
|
+
const half = node.labelHalf || nodeLabelHalf(node);
|
|
825
|
+
const ring = node.level === 'L0' ? 26 : node.level === 'L1' ? 22 : 18;
|
|
826
|
+
const firstDy = node.level === 'L0' ? 34 : node.level === 'L1' ? 28 : 24;
|
|
827
|
+
const lines = node.labelLines || 1;
|
|
828
|
+
minX = Math.min(minX, node.x - half);
|
|
829
|
+
maxX = Math.max(maxX, node.x + half);
|
|
830
|
+
minY = Math.min(minY, node.y - ring - 14);
|
|
831
|
+
maxY = Math.max(maxY, node.y + firstDy + lines * LABEL_LINE_HEIGHT);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
const boxWidth = Math.max(maxX - minX, 1);
|
|
835
|
+
const boxHeight = Math.max(maxY - minY, 1);
|
|
836
|
+
const availableWidth = Math.max(width - insetSide * 2, 1);
|
|
837
|
+
const availableHeight = Math.max(height - insetTop - insetBottom, 1);
|
|
838
|
+
// A floor keeps labels legible on a very wide tree; the reader can pan the
|
|
839
|
+
// overflow rather than squinting at five-pixel text.
|
|
840
|
+
const scaleFloor = narrow ? 0.55 : 0.68;
|
|
841
|
+
const scale = Math.min(
|
|
842
|
+
1.15,
|
|
843
|
+
Math.max(scaleFloor, Math.min(availableWidth / boxWidth, availableHeight / boxHeight))
|
|
844
|
+
);
|
|
845
|
+
const centerX = (minX + maxX) / 2;
|
|
846
|
+
const centerY = (minY + maxY) / 2;
|
|
847
|
+
const translateX = insetSide + availableWidth / 2 - scale * centerX;
|
|
848
|
+
// Centre vertically when the tree fits; otherwise anchor the root's level to
|
|
849
|
+
// the top edge so the reading order starts where it should.
|
|
850
|
+
const translateY =
|
|
851
|
+
boxHeight * scale <= availableHeight
|
|
852
|
+
? insetTop + availableHeight / 2 - scale * centerY
|
|
853
|
+
: insetTop - scale * minY + 8;
|
|
854
|
+
|
|
855
|
+
svg
|
|
856
|
+
.transition()
|
|
857
|
+
.duration(prefersReducedMotion() ? 0 : 420)
|
|
858
|
+
.call(zoomBehavior.transform, zoomIdentity.translate(translateX, translateY).scale(scale));
|
|
859
|
+
}
|
|
860
|
+
|
|
691
861
|
function layoutTree(nodes, width, _height) {
|
|
692
862
|
if (nodes.length === 0) return [];
|
|
693
863
|
|
|
@@ -703,7 +873,15 @@ function layoutTree(nodes, width, _height) {
|
|
|
703
873
|
);
|
|
704
874
|
const treeData = { id: '__atlas-root__', children: roots };
|
|
705
875
|
const root = hierarchy(treeData);
|
|
706
|
-
|
|
876
|
+
// Reserve horizontal room per sibling pair from the estimated label width so
|
|
877
|
+
// long titles never share a column. Siblings sit one base gap apart; separate
|
|
878
|
+
// subtrees keep the doubled default separation.
|
|
879
|
+
const tree = treeLayout()
|
|
880
|
+
.nodeSize([TREE_BASE_GAP, TREE_LEVEL_GAP])
|
|
881
|
+
.separation((a, b) => {
|
|
882
|
+
const gap = nodeLabelHalf(a.data) + nodeLabelHalf(b.data) + 26;
|
|
883
|
+
return (a.parent === b.parent ? 1 : 2) * Math.max(1, gap / TREE_BASE_GAP);
|
|
884
|
+
});
|
|
707
885
|
tree(root);
|
|
708
886
|
|
|
709
887
|
const visible = root.descendants().filter(node => node.data.id !== '__atlas-root__');
|
|
@@ -715,8 +893,9 @@ function layoutTree(nodes, width, _height) {
|
|
|
715
893
|
|
|
716
894
|
return visible.map(node => ({
|
|
717
895
|
...node.data,
|
|
896
|
+
...labelMetrics(node.data),
|
|
718
897
|
x: node.x + offsetX,
|
|
719
|
-
y: node.depth *
|
|
898
|
+
y: node.depth * TREE_LEVEL_GAP + offsetY
|
|
720
899
|
}));
|
|
721
900
|
}
|
|
722
901
|
|
|
@@ -823,12 +1002,20 @@ function linkPath(link) {
|
|
|
823
1002
|
|
|
824
1003
|
function positionEdgeLabels(links, nodes, width, height) {
|
|
825
1004
|
const occupied = [];
|
|
826
|
-
const nodeBoxes = nodes.map(node =>
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1005
|
+
const nodeBoxes = nodes.map(node => {
|
|
1006
|
+
const half = Math.max(28, node.labelHalf || 0);
|
|
1007
|
+
const titleBlock =
|
|
1008
|
+
(node.level === 'L0' ? 34 : node.level === 'L1' ? 28 : 24) +
|
|
1009
|
+
(node.labelLines || 1) * LABEL_LINE_HEIGHT;
|
|
1010
|
+
return {
|
|
1011
|
+
left: node.x - half,
|
|
1012
|
+
right: node.x + half,
|
|
1013
|
+
top: node.y - 28,
|
|
1014
|
+
// Cover the ring *and* the wrapped title beneath it so an edge label
|
|
1015
|
+
// never lands on top of a node name.
|
|
1016
|
+
bottom: node.y + titleBlock
|
|
1017
|
+
};
|
|
1018
|
+
});
|
|
832
1019
|
|
|
833
1020
|
links.forEach(link => {
|
|
834
1021
|
link.labelVisible = false;
|