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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concept-atlas-dense-explain",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "Portable dense-explanation skill and MDX concept atlas template",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.
@@ -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') setGlobalQuery('');
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 type="button" onClick={() => setGlobalQuery('')} aria-label="清除搜索">
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
- {searchResults.length > 0 && (
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
- onClick={() => {
225
- navigateToNode(node.id);
226
- setGlobalQuery('');
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={false}
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-dim); font-size: 10px; font-weight: 700; letter-spacing: .04em; }
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: 9px;
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 { display: flex; gap: 5px; overflow-x: auto; min-width: 0; }
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: 10px;
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: 10px;
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
- .explorer-center { padding: 16px 12px 18px; }
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
- .graph-control-bar { left: 10px; right: 10px; top: 10px; flex-wrap: wrap; gap: 6px; }
1843
- .graph-control-bar .filter-group { display: none; }
1844
- .graph-legend-ribbon { display: none; }
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: 10px;
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: 10px;
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: 10px;
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
- .force('collision', forceCollide().radius(34).strength(0.35))
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', '10px')
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', '12px')
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
- .text(d => d.title);
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', '9px')
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
- const tree = treeLayout().nodeSize([110, 155]);
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 * 155 + offsetY
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
- left: node.x - 28,
828
- right: node.x + 28,
829
- top: node.y - 28,
830
- bottom: node.y + 28
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;