concept-atlas-dense-explain 1.2.0 → 2.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 +5 -2
- package/bin/cli.mjs +27 -10
- package/package.json +1 -1
- package/packaged-skill/SKILL.md +7 -5
- package/packaged-skill/references/atlas-guide.mdx +5 -4
- package/packaged-skill/references/scroll-guide.mdx +2 -2
- package/template/references/atlas-guide.mdx +5 -4
- package/template/references/scroll-guide.mdx +2 -2
- package/template/src/app/App.jsx +0 -3
- package/template/src/components/FigureScope.jsx +17 -0
- package/template/src/components/MDXComponents.jsx +42 -5
- package/template/src/model/figures.js +61 -0
- package/template/src/model/validate-content.js +32 -2
- package/template/src/scroll-main.jsx +4 -1
- package/template/src/styles/core.css +24 -56
- package/template/src/styles/packs/elastic.css +0 -4
- package/template/src/styles/packs/shadcn.css +0 -4
- package/template/src/views/NodeExplorer.jsx +7 -25
- package/template/vite.config.js +90 -16
package/README.md
CHANGED
|
@@ -35,14 +35,17 @@ Mermaid loads from a CDN at runtime by default (fast builds, needs network);
|
|
|
35
35
|
`--inline-mermaid` bakes it into the HTML for a fully offline single file, and
|
|
36
36
|
`--mermaid-cdn` overrides the CDN URL. Appearance defaults can be baked with
|
|
37
37
|
`--skin`, `--default-mode` and `--style`; readers can still switch in the UI.
|
|
38
|
+
Local figures link by default (small HTML, ship `assets/` beside the output);
|
|
39
|
+
`--inline-assets` bakes them in as base64 for a self-contained file, and a
|
|
40
|
+
per-figure `inline={true|false}` overrides that choice.
|
|
38
41
|
|
|
39
42
|
Run `npx concept-atlas-dense-explain help` for the full flag list.
|
|
40
43
|
|
|
41
44
|
## Links
|
|
42
45
|
|
|
43
46
|
- Repository: https://github.com/wurenrumian/concept-atlas
|
|
44
|
-
- Framework guide: [`docs/FRAMEWORK.md`](https://github.com/wurenrumian/concept-atlas/blob/
|
|
45
|
-
- Usage recipes: [`docs/USAGE.md`](https://github.com/wurenrumian/concept-atlas/blob/
|
|
47
|
+
- Framework guide: [`docs/FRAMEWORK.md`](https://github.com/wurenrumian/concept-atlas/blob/master/docs/FRAMEWORK.md)
|
|
48
|
+
- Usage recipes: [`docs/USAGE.md`](https://github.com/wurenrumian/concept-atlas/blob/master/docs/USAGE.md)
|
|
46
49
|
|
|
47
50
|
## License
|
|
48
51
|
|
package/bin/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { access, constants, copyFile, cp, mkdir, readFile, rm, rename, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, statSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { build } from 'vite';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -14,7 +14,7 @@ let buildCounter = 0;
|
|
|
14
14
|
|
|
15
15
|
function usage() {
|
|
16
16
|
console.log('Usage:');
|
|
17
|
-
console.log(' npx concept-atlas-dense-explain <input.mdx>... [--mode atlas|scroll] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [-o output.html|dir] [--force] [--concurrency N] [--
|
|
17
|
+
console.log(' npx concept-atlas-dense-explain <input.mdx>... [--mode atlas|scroll] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [-o output.html|dir] [--force] [--concurrency N] [--inline-assets] [--inline-mermaid] [--mermaid-cdn <url>] [--json] [--no-validate]');
|
|
18
18
|
console.log(' npx concept-atlas-dense-explain render <input.mdx>... [-o output.html|dir]');
|
|
19
19
|
console.log(' npx concept-atlas-dense-explain validate <input.mdx> [--mode atlas|scroll] [--strict] [--json]');
|
|
20
20
|
console.log(' npx concept-atlas-dense-explain create <output.mdx> [--mode atlas|scroll] [--force]');
|
|
@@ -22,7 +22,7 @@ function usage() {
|
|
|
22
22
|
console.log('');
|
|
23
23
|
console.log(' --help shows this text; --version prints the package version.');
|
|
24
24
|
console.log(' Multiple inputs build in parallel (default 2 at a time, cap 4); -o is then a directory.');
|
|
25
|
-
console.log('
|
|
25
|
+
console.log(' Figures are kept as relative links by default (small HTML; ship the assets/ dir beside it). --inline-assets bakes every local image into the HTML as base64 instead; a per-tag inline={true|false} on <Figure> overrides that for one image. --link-assets is kept as an explicit alias for the default.');
|
|
26
26
|
console.log(' Mermaid diagrams load from a CDN at runtime by default (fast builds, needs network); --inline-mermaid bakes Mermaid into the HTML for a fully offline single file; --mermaid-cdn overrides the CDN URL.');
|
|
27
27
|
console.log(` --skin bakes a default palette (${SKINS.map(skin => skin.id).join(', ')}); --default-mode bakes a default dark/light mode; --style bakes a default component style (${COMPONENT_STYLES.map(style => style.id).join(', ')}). Readers can still switch in the UI.`);
|
|
28
28
|
}
|
|
@@ -31,6 +31,11 @@ async function exists(filePath) {
|
|
|
31
31
|
try { await access(filePath, constants.F_OK); return true; } catch { return false; }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Byte size of an asset, or null when it does not exist. */
|
|
35
|
+
function assetByteSize(filePath) {
|
|
36
|
+
try { return statSync(filePath).size; } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
function flagValue(flags, names) {
|
|
35
40
|
for (const name of names) {
|
|
36
41
|
const index = flags.indexOf(name);
|
|
@@ -46,7 +51,7 @@ function fail(message) {
|
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
const VALUE_FLAGS = new Set(['--mode', '-o', '--output', '--concurrency', '--skin', '--default-mode', '--style', '--mermaid-cdn']);
|
|
49
|
-
const BOOLEAN_FLAGS = new Set(['--force', '--json', '--strict', '--no-validate', '--link-assets', '--inline-mermaid', '--help', '-h', '--version', '-v']);
|
|
54
|
+
const BOOLEAN_FLAGS = new Set(['--force', '--json', '--strict', '--no-validate', '--link-assets', '--inline-assets', '--inline-mermaid', '--help', '-h', '--version', '-v']);
|
|
50
55
|
const COMMAND_NAMES = ['help', 'create', 'new', 'render', 'validate', 'guide'];
|
|
51
56
|
|
|
52
57
|
/** Splits argv into flags, flag values and positional arguments. */
|
|
@@ -252,6 +257,10 @@ const strict = parsed.flags.has('--strict');
|
|
|
252
257
|
const skipValidate = parsed.flags.has('--no-validate');
|
|
253
258
|
const force = parsed.flags.has('--force');
|
|
254
259
|
const linkAssets = parsed.flags.has('--link-assets');
|
|
260
|
+
const inlineAssets = parsed.flags.has('--inline-assets');
|
|
261
|
+
if (linkAssets && inlineAssets) {
|
|
262
|
+
fail('--link-assets and --inline-assets are mutually exclusive (linking is the default).');
|
|
263
|
+
}
|
|
255
264
|
const inlineMermaid = parsed.flags.has('--inline-mermaid');
|
|
256
265
|
const mermaidCdn = parsed.values.get('--mermaid-cdn') || null;
|
|
257
266
|
const modeFlag = parsed.values.get('--mode') || null;
|
|
@@ -285,7 +294,9 @@ if (command === 'validate') {
|
|
|
285
294
|
filePath: target,
|
|
286
295
|
mode: modeFlag,
|
|
287
296
|
strict,
|
|
297
|
+
inlineAssets,
|
|
288
298
|
assetExists: spec => existsSync(path.resolve(path.dirname(target), spec)),
|
|
299
|
+
assetSize: spec => assetByteSize(path.resolve(path.dirname(target), spec)),
|
|
289
300
|
}, { json });
|
|
290
301
|
process.exit(countBySeverity(result.diagnostics).error ? 1 : 0);
|
|
291
302
|
}
|
|
@@ -326,7 +337,9 @@ const validations = sources.map((source, index) => printDiagnostics(source, {
|
|
|
326
337
|
filePath: inputs[index],
|
|
327
338
|
mode: modeFlag,
|
|
328
339
|
strict,
|
|
340
|
+
inlineAssets,
|
|
329
341
|
assetExists: spec => existsSync(path.resolve(path.dirname(inputs[index]), spec)),
|
|
342
|
+
assetSize: spec => assetByteSize(path.resolve(path.dirname(inputs[index]), spec)),
|
|
330
343
|
}, json
|
|
331
344
|
? { json: false, quiet: true }
|
|
332
345
|
: { json: false, label: multi ? inputs[index] : null }));
|
|
@@ -350,10 +363,13 @@ const jobs = inputs.map((input, index) => {
|
|
|
350
363
|
console.error(`Could not detect the MDX carrier for ${input}; choose --mode atlas or --mode scroll.`);
|
|
351
364
|
process.exit(1);
|
|
352
365
|
}
|
|
353
|
-
|
|
354
|
-
|
|
366
|
+
// Figures link by default. If the HTML is written outside the MDX's directory
|
|
367
|
+
// its relative image paths no longer resolve, so warn unless --inline-assets
|
|
368
|
+
// bakes them in.
|
|
369
|
+
if (!inlineAssets && path.resolve(path.dirname(outputs[index])) !== path.resolve(path.dirname(input))) {
|
|
370
|
+
console.error(`警告:默认外链图片,但 ${outputs[index]} 不在 ${path.dirname(input)} 内,相对图片路径会失效;请用 --inline-assets,或把 assets/ 一并放到输出目录。`);
|
|
355
371
|
}
|
|
356
|
-
return { input, output: outputs[index], mode, title: extractPageTitle(sources[index]), features: detectFeatures(sources[index]),
|
|
372
|
+
return { input, output: outputs[index], mode, title: extractPageTitle(sources[index]), features: detectFeatures(sources[index]), inlineAssets };
|
|
357
373
|
});
|
|
358
374
|
|
|
359
375
|
const limit = clampConcurrency(parsed.values.get('--concurrency'), jobs.length);
|
|
@@ -369,7 +385,7 @@ if (failures.length) {
|
|
|
369
385
|
}
|
|
370
386
|
|
|
371
387
|
async function buildOne(job) {
|
|
372
|
-
const { input, output, mode, title, features,
|
|
388
|
+
const { input, output, mode, title, features, inlineAssets: inline } = job;
|
|
373
389
|
const templateEntry = mode === 'atlas' ? 'index.html' : 'scroll.html';
|
|
374
390
|
// Each build gets its own scratch outDir: the template always writes
|
|
375
391
|
// `index.html`/`scroll.html`, so concurrent builds sharing a directory would
|
|
@@ -378,7 +394,8 @@ async function buildOne(job) {
|
|
|
378
394
|
await mkdir(path.dirname(output), { recursive: true });
|
|
379
395
|
await mkdir(scratch, { recursive: true });
|
|
380
396
|
const define = { __ATLAS_FEATURES__: JSON.stringify(features) };
|
|
381
|
-
|
|
397
|
+
// 'false' is the default (link); only --inline-assets sets 'true'.
|
|
398
|
+
define.__ATLAS_INLINE_ASSETS__ = inline ? 'true' : 'false';
|
|
382
399
|
if (title) define.__ATLAS_PAGE_TITLE__ = JSON.stringify(title);
|
|
383
400
|
if (skinFlag) define.__ATLAS_DEFAULT_SKIN__ = JSON.stringify(skinFlag);
|
|
384
401
|
if (defaultModeFlag) define.__ATLAS_DEFAULT_MODE__ = JSON.stringify(defaultModeFlag);
|
|
@@ -424,7 +441,7 @@ async function buildOne(job) {
|
|
|
424
441
|
}
|
|
425
442
|
if (hasBackup) await rm(backup, { force: true });
|
|
426
443
|
}
|
|
427
|
-
console.log(`Built ${mode} HTML: ${output}${title ? ` [tab: ${title}]` : ''}${describeFeatures(features)}${
|
|
444
|
+
console.log(`Built ${mode} HTML: ${output}${title ? ` [tab: ${title}]` : ''}${describeFeatures(features)}${inline ? ' [figures inlined]' : ' [figures linked]'}`);
|
|
428
445
|
return { ok: true, input, output };
|
|
429
446
|
} catch (error) {
|
|
430
447
|
return { ok: false, input, output, error };
|
package/package.json
CHANGED
package/packaged-skill/SKILL.md
CHANGED
|
@@ -32,7 +32,7 @@ This is the only runner — there is nothing to look for. Do not search for a lo
|
|
|
32
32
|
npx concept-atlas-dense-explain validate <file>.mdx --json
|
|
33
33
|
```
|
|
34
34
|
Diagnostics are `CODE line:column message`. Fix all `error`s and re-run; address warnings when cheap.
|
|
35
|
-
6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [--
|
|
35
|
+
6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [--mermaid-cdn <url>]`. Output is a standalone HTML beside the MDX unless `-o` is given. Validation errors abort the build (`--no-validate` forces a knowingly broken build). **Mermaid stays on its runtime CDN by default — do not pass `--inline-mermaid` on your own.** Only add `--inline-mermaid` when the user explicitly asks for a fully offline single file, since it bloats the HTML with the whole Mermaid bundle.
|
|
36
36
|
7. **Appearance (optional)**: pages ship a reader-facing appearance menu — palette (`aurora` indigo, `ember` gold, `verdant` forest, `sakura` pink-plum, `noir` ink), a dark/light toggle, and a component style pack (`manuscript` editorial marginalia, `classic` boxed cards, `shadcn` hairline-bordered minimal UI, `elastic` bordered observability panels). The shipped default is aurora × manuscript × light; choices persist in localStorage across both carriers. Bake different compile-time defaults with `--skin ember --default-mode dark --style classic` (or `CONCEPT_ATLAS_SKIN` / `CONCEPT_ATLAS_DEFAULT_MODE` / `CONCEPT_ATLAS_STYLE` on the repo build); `--default-mode` honors `dark`/`light` and resolves `system` to the carrier default (`light`). Bake a default only when the user asks for one — content MDX never sets appearance.
|
|
37
37
|
8. For several documents, pass them all in one call: `npx concept-atlas-dense-explain a.mdx b.mdx c.mdx -o dist --force [--concurrency 3]` (`-o` is then a directory; everything validates first, then builds in parallel). Builds bundle only the heavy renderers the content uses: no `<Math>` skips KaTeX's ~1.4MB inlined fonts, and Mermaid stays on a CDN. Never add dummy `<Math>`/`<Mermaid>` nodes to "enable" them.
|
|
38
38
|
9. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
|
|
@@ -52,7 +52,7 @@ This is the only runner — there is nothing to look for. Do not search for a lo
|
|
|
52
52
|
- Information models: `Flow`, `Timeline`, `Compare`, `DecisionMatrix`, `FrameworkModel`, `MatrixModel`, `FormulaModel`, `PyramidModel`, `FunnelModel`
|
|
53
53
|
- Data and behaviour: `DataTable`, `Metric`, `StateMachine`, `DecisionTree`, `FeedbackLoop`, `CodeDiff`
|
|
54
54
|
- Reading and layout: `Insight`, `Callout`, `Details`, `NoteGrid`, `Tabs`, `Columns`, `Stack`, `Grid`, `Split`, `ScrollGrid`, `ScrollPair`, `ScrollToc`
|
|
55
|
-
- Graphics and extensions: `Mermaid`, `RelationMap`, `RelationPath`, `Math`, `MathBlock`, `Chart`, `Figure` (alias `Image`), `Cite`, `References`
|
|
55
|
+
- Graphics and extensions: `Mermaid`, `RelationMap`, `RelationPath`, `Math`, `MathBlock`, `Chart`, `Figure` (alias `Image`), `FigureRef`, `Cite`, `References`
|
|
56
56
|
|
|
57
57
|
## Authoring rules
|
|
58
58
|
|
|
@@ -67,20 +67,22 @@ This is the only runner — there is nothing to look for. Do not search for a lo
|
|
|
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
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.
|
|
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
|
-
- **Figure**: a relative `src` (`./assets/diagram.png`)
|
|
71
|
-
- **Figure
|
|
70
|
+
- **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.
|
|
71
|
+
- **Figure numbering**: give a figure an `id` and reference it with `<FigureRef id="..." />` to render "图 N". Numbering follows document order (per node in `atlas`, whole document in `scroll`) and reflows automatically, so never hand-write "图 1"; an explicit `label` still wins. A ref with no matching `id` warns (`FIGURE_REF_UNRESOLVED`).
|
|
72
|
+
- **Figure size & cost**: linking keeps the HTML small but requires the output to sit beside the MDX's `assets/` (the CLI warns when `-o` points elsewhere). Inlining is what makes a screenshot-heavy page large (a page with no heavy renderers otherwise lands near 250KB), and an inlined asset over 512KB warns (`ASSET_LARGE`). Decide the trade-off with the user instead of choosing silently.
|
|
72
73
|
- **Cite/References**: `<Cite id="..." />` renders `[n]` from the matching item's position in `<References items={...} />`. In `scroll`, `References` can sit anywhere. In `atlas`, keep the cites and the `References` block in the same node, because node content only renders when that node is open.
|
|
73
74
|
- Continuous reading is configured on the shell, not with manual CSS: `spacing="compact|comfortable|airy"` for rhythm, `fontSize="compact|normal|large|xlarge"` (or numeric `scale`/`lineHeight`) for text size.
|
|
74
75
|
|
|
75
76
|
## Validation diagnostics
|
|
76
77
|
|
|
77
|
-
`validate` and the build print `CODE line:column message`. Fix these `error`s before building: `UNKNOWN_COMPONENT`, `CARRIER_MISSING`, `CARRIER_CONFLICT`, `CARRIER_MODE_MISMATCH`, `NODE_MISSING_ID`, `DUPLICATE_NODE_ID`, `NODE_MISSING_TITLE`, `MISSING_PARENT`, `GRAPH_ROOT_UNRESOLVED`, `REF_MISSING_ID`, `REF_UNRESOLVED`, `RELATION_FROM_UNRESOLVED`, `RELATION_TO_UNRESOLVED`. Warnings worth fixing: `NODE_MISSING_SUMMARY`, `NODE_NO_CORE_CONTENT`, `UNKNOWN_LEVEL`, `UNKNOWN_KIND`, `MECHANISM_KIND_UNVERIFIED`, `FAILURE_KIND_UNSTRUCTURED`, `FAILURE_MODE_EMPTY`, `UNKNOWN_RELATION_TYPE`, `RELATION_MISSING_LABEL`, `RELATION_SELF`, `PROP_EXPECTS_ARRAY`, `MATH_CHILDREN_BRACES`, `PROSE_EXPRESSION`, `FRONTMATTER_UNSUPPORTED`, `GRAPH_MISSING_ROOT`, `FIGURE_MISSING_SRC`, `ASSET_MISSING`, `REF_SELF`; `NO_ROOT_LEVEL`, `MULTIPLE_ROOT_LEVEL`, `MECHANISM_KIND_UNVERIFIED` and `FAILURE_KIND_UNSTRUCTURED` are warnings that `--strict` promotes to errors.
|
|
78
|
+
`validate` and the build print `CODE line:column message`. Fix these `error`s before building: `UNKNOWN_COMPONENT`, `CARRIER_MISSING`, `CARRIER_CONFLICT`, `CARRIER_MODE_MISMATCH`, `NODE_MISSING_ID`, `DUPLICATE_NODE_ID`, `NODE_MISSING_TITLE`, `MISSING_PARENT`, `GRAPH_ROOT_UNRESOLVED`, `REF_MISSING_ID`, `REF_UNRESOLVED`, `RELATION_FROM_UNRESOLVED`, `RELATION_TO_UNRESOLVED`. Warnings worth fixing: `NODE_MISSING_SUMMARY`, `NODE_NO_CORE_CONTENT`, `UNKNOWN_LEVEL`, `UNKNOWN_KIND`, `MECHANISM_KIND_UNVERIFIED`, `FAILURE_KIND_UNSTRUCTURED`, `FAILURE_MODE_EMPTY`, `UNKNOWN_RELATION_TYPE`, `RELATION_MISSING_LABEL`, `RELATION_SELF`, `PROP_EXPECTS_ARRAY`, `MATH_CHILDREN_BRACES`, `PROSE_EXPRESSION`, `FRONTMATTER_UNSUPPORTED`, `GRAPH_MISSING_ROOT`, `FIGURE_MISSING_SRC`, `ASSET_MISSING`, `ASSET_REMOTE`, `ASSET_LARGE`, `FIGURE_REF_UNRESOLVED`, `FIGURE_REF_MISSING_ID`, `FIGURE_DUPLICATE_ID`, `REF_SELF`; `NO_ROOT_LEVEL`, `MULTIPLE_ROOT_LEVEL`, `MECHANISM_KIND_UNVERIFIED` and `FAILURE_KIND_UNSTRUCTURED` are warnings that `--strict` promotes to errors.
|
|
78
79
|
|
|
79
80
|
## Before you report
|
|
80
81
|
|
|
81
82
|
- All `error` diagnostics resolved (or `--no-validate` explicitly justified).
|
|
82
83
|
- All `ConceptRef`, `Relation` endpoints, and `ConceptGraph root` point at existing node ids.
|
|
83
84
|
- Every node has `title` + `summary`; every `Relation` has a `label`.
|
|
85
|
+
- You left Mermaid on its CDN default and did not pass `--inline-mermaid` unless the user explicitly asked for a fully offline single file.
|
|
84
86
|
- The HTML file exists at the reported path.
|
|
85
87
|
|
|
86
88
|
If `npx` cannot reach the registry, report the blocker. Never copy implementation files into the skill directory.
|
|
@@ -132,10 +132,11 @@
|
|
|
132
132
|
<Chart title="留存趋势" type="line" labels={['第1周','第2周','第3周','第4周']} series={[{name:'留存率',values:[100,72,58,49]}]} />
|
|
133
133
|
</ConceptNode>
|
|
134
134
|
|
|
135
|
-
<ConceptNode id="figure-demo" title="Figure:图片与题注一起出现" level="L2" parent="extension-family" summary="Figure
|
|
136
|
-
<Definition
|
|
137
|
-
<Figure src="./assets/sample-diagram.svg" alt="概念缩放示意"
|
|
138
|
-
<
|
|
135
|
+
<ConceptNode id="figure-demo" title="Figure:图片与题注一起出现" level="L2" parent="extension-family" summary="Figure 把图片、题注编号和说明绑定;默认外链,按需内联。">
|
|
136
|
+
<Definition>相对路径的图片默认保持外链,构建时用 --inline-assets 可全部内联,单张图也可用 `inline={true}` 或 `inline={false}` 覆盖;远程 URL 保持不变。给图一个 id,正文就能用 FigureRef 自动引用“图 N”。</Definition>
|
|
137
|
+
<Figure id="zoom-scale" src="./assets/sample-diagram.svg" alt="概念缩放示意" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
|
|
138
|
+
<Example title="自动图号">正文写“见 <FigureRef id="zoom-scale" />”,编号会随插图自动重排,不必手写“图 1”。</Example>
|
|
139
|
+
<Boundary>内联大图会显著增大 HTML;截图类内容建议控制尺寸,或对单张图设 `inline={false}`。</Boundary>
|
|
139
140
|
</ConceptNode>
|
|
140
141
|
|
|
141
142
|
<ConceptNode id="code-demo" title="CodeBlock:把命令、配置或伪代码放进正文" level="L2" parent="extension-family" summary="与绑定节点的 Implementation 不同,CodeBlock 可以出现在任何位置,并支持标题、题注、换行和行号。">
|
|
@@ -102,8 +102,8 @@ npm run dev`}</CodeBlock>
|
|
|
102
102
|
</ScrollSection>
|
|
103
103
|
|
|
104
104
|
<ScrollSection title="补充配图与出处">
|
|
105
|
-
<ScrollProse>配图和引用让讲解从断言变成可追溯的论述<Cite id="tufte1983"
|
|
106
|
-
<Figure src="./assets/sample-diagram.svg" alt="概念缩放示意"
|
|
105
|
+
<ScrollProse>配图和引用让讲解从断言变成可追溯的论述<Cite id="tufte1983" />。图片默认保持外链(`--inline-assets` 可全部内联),给图一个 id 后正文可用 <FigureRef id="zoom-scale" /> 自动引用编号。</ScrollProse>
|
|
106
|
+
<Figure id="zoom-scale" src="./assets/sample-diagram.svg" alt="概念缩放示意" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
|
|
107
107
|
<ScrollGrid columns="2">
|
|
108
108
|
<DecisionMatrix title="需要确认" headers={['变量','检查']} rows={[['新体验','是否改善核心任务'],['旧体验','能否满足需求'],['替换成本','迁移是否可接受']]} />
|
|
109
109
|
<Compare items={[{label:'连续阅读',rows:['信息密度','适用场景'],values:['高','按章节论证']},{label:'概念图谱',rows:['信息密度','适用场景'],values:['中','概念下钻']}]} />
|
|
@@ -132,10 +132,11 @@
|
|
|
132
132
|
<Chart title="留存趋势" type="line" labels={['第1周','第2周','第3周','第4周']} series={[{name:'留存率',values:[100,72,58,49]}]} />
|
|
133
133
|
</ConceptNode>
|
|
134
134
|
|
|
135
|
-
<ConceptNode id="figure-demo" title="Figure:图片与题注一起出现" level="L2" parent="extension-family" summary="Figure
|
|
136
|
-
<Definition
|
|
137
|
-
<Figure src="./assets/sample-diagram.svg" alt="概念缩放示意"
|
|
138
|
-
<
|
|
135
|
+
<ConceptNode id="figure-demo" title="Figure:图片与题注一起出现" level="L2" parent="extension-family" summary="Figure 把图片、题注编号和说明绑定;默认外链,按需内联。">
|
|
136
|
+
<Definition>相对路径的图片默认保持外链,构建时用 --inline-assets 可全部内联,单张图也可用 `inline={true}` 或 `inline={false}` 覆盖;远程 URL 保持不变。给图一个 id,正文就能用 FigureRef 自动引用“图 N”。</Definition>
|
|
137
|
+
<Figure id="zoom-scale" src="./assets/sample-diagram.svg" alt="概念缩放示意" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
|
|
138
|
+
<Example title="自动图号">正文写“见 <FigureRef id="zoom-scale" />”,编号会随插图自动重排,不必手写“图 1”。</Example>
|
|
139
|
+
<Boundary>内联大图会显著增大 HTML;截图类内容建议控制尺寸,或对单张图设 `inline={false}`。</Boundary>
|
|
139
140
|
</ConceptNode>
|
|
140
141
|
|
|
141
142
|
<ConceptNode id="code-demo" title="CodeBlock:把命令、配置或伪代码放进正文" level="L2" parent="extension-family" summary="与绑定节点的 Implementation 不同,CodeBlock 可以出现在任何位置,并支持标题、题注、换行和行号。">
|
|
@@ -102,8 +102,8 @@ npm run dev`}</CodeBlock>
|
|
|
102
102
|
</ScrollSection>
|
|
103
103
|
|
|
104
104
|
<ScrollSection title="补充配图与出处">
|
|
105
|
-
<ScrollProse>配图和引用让讲解从断言变成可追溯的论述<Cite id="tufte1983"
|
|
106
|
-
<Figure src="./assets/sample-diagram.svg" alt="概念缩放示意"
|
|
105
|
+
<ScrollProse>配图和引用让讲解从断言变成可追溯的论述<Cite id="tufte1983" />。图片默认保持外链(`--inline-assets` 可全部内联),给图一个 id 后正文可用 <FigureRef id="zoom-scale" /> 自动引用编号。</ScrollProse>
|
|
106
|
+
<Figure id="zoom-scale" src="./assets/sample-diagram.svg" alt="概念缩放示意" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
|
|
107
107
|
<ScrollGrid columns="2">
|
|
108
108
|
<DecisionMatrix title="需要确认" headers={['变量','检查']} rows={[['新体验','是否改善核心任务'],['旧体验','能否满足需求'],['替换成本','迁移是否可接受']]} />
|
|
109
109
|
<Compare items={[{label:'连续阅读',rows:['信息密度','适用场景'],values:['高','按章节论证']},{label:'概念图谱',rows:['信息密度','适用场景'],values:['中','概念下钻']}]} />
|
package/template/src/app/App.jsx
CHANGED
|
@@ -44,7 +44,6 @@ export function App({ mdxContent, initialData }) {
|
|
|
44
44
|
const [currentNodeId, setCurrentNodeId] = useState(
|
|
45
45
|
() => initialHashNode || graph.meta.rootId || '',
|
|
46
46
|
);
|
|
47
|
-
const [selectedLevel, setSelectedLevel] = useState(null);
|
|
48
47
|
const [nav, setNav] = useState(() => ({
|
|
49
48
|
entries: (initialHashNode ? [initialHashNode] : [graph.meta.rootId]).filter(Boolean),
|
|
50
49
|
index: 0,
|
|
@@ -239,8 +238,6 @@ export function App({ mdxContent, initialData }) {
|
|
|
239
238
|
currentNodeId={currentNodeId}
|
|
240
239
|
onSelectNode={navigateToNode}
|
|
241
240
|
onSwitchView={setCurrentView}
|
|
242
|
-
selectedLevel={selectedLevel}
|
|
243
|
-
onSelectLevel={setSelectedLevel}
|
|
244
241
|
/>
|
|
245
242
|
) : (
|
|
246
243
|
<RelationGraph
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Numbering scope for figures. `App`'s atlas view provides the current node id
|
|
5
|
+
* so each node numbers its own figures; the scroll carrier keeps the default
|
|
6
|
+
* `document` scope so the whole article shares one sequence. Kept out of the
|
|
7
|
+
* component barrel on purpose: it is renderer plumbing, not an MDX component.
|
|
8
|
+
*/
|
|
9
|
+
export const FigureScope = React.createContext('document');
|
|
10
|
+
|
|
11
|
+
export function FigureScopeProvider({ scope, children }) {
|
|
12
|
+
return <FigureScope.Provider value={scope || 'document'}>{children}</FigureScope.Provider>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useFigureScope() {
|
|
16
|
+
return React.useContext(FigureScope);
|
|
17
|
+
}
|
|
@@ -5,6 +5,8 @@ import katex from 'katex';
|
|
|
5
5
|
import { ZoomIn } from 'lucide-react';
|
|
6
6
|
import 'katex/dist/katex.min.css';
|
|
7
7
|
import { registerReferences, subscribeReferences, getReferenceIndex } from '../model/citations.js';
|
|
8
|
+
import { registerFigure, subscribeFigures, getFigureNumber } from '../model/figures.js';
|
|
9
|
+
import { useFigureScope } from './FigureScope.jsx';
|
|
8
10
|
|
|
9
11
|
// Mermaid reads the active skin/mode from CSS variables so diagrams follow
|
|
10
12
|
// the current appearance. A MutationObserver re-renders charts when the
|
|
@@ -1568,12 +1570,28 @@ function ImageZoom({ src, alt, caption, label, onClose }) {
|
|
|
1568
1570
|
);
|
|
1569
1571
|
}
|
|
1570
1572
|
|
|
1571
|
-
export function Figure({ src, alt = '', caption, label, width = 'auto', height = 'auto', x = 0, y = 0, position = 'flow' }) {
|
|
1573
|
+
export function Figure({ id, src, alt = '', caption, label, inline, width = 'auto', height = 'auto', x = 0, y = 0, position = 'flow' }) {
|
|
1574
|
+
const scope = useFigureScope();
|
|
1575
|
+
// `inline` is a build-time hint consumed by vite's asset plugin; binding it
|
|
1576
|
+
// here keeps it out of the DOM and documents that the component accepts it.
|
|
1577
|
+
void inline;
|
|
1578
|
+
const number = React.useSyncExternalStore(
|
|
1579
|
+
subscribeFigures,
|
|
1580
|
+
() => getFigureNumber(scope, id),
|
|
1581
|
+
() => getFigureNumber(scope, id),
|
|
1582
|
+
);
|
|
1583
|
+
React.useEffect(() => { registerFigure(scope, id); }, [scope, id]);
|
|
1572
1584
|
const [zoomed, setZoomed] = React.useState(false);
|
|
1573
1585
|
const title = alt || caption || '图片';
|
|
1586
|
+
// An explicit `label` wins; otherwise a named figure numbers itself.
|
|
1587
|
+
const displayLabel = label || (id && number ? `图 ${number}` : undefined);
|
|
1574
1588
|
|
|
1575
1589
|
return (
|
|
1576
|
-
<figure
|
|
1590
|
+
<figure
|
|
1591
|
+
id={id ? `fig-${id}` : undefined}
|
|
1592
|
+
className={`semantic-figure ${widgetClass(position)}`}
|
|
1593
|
+
style={widgetStyle({ width, height, x, y, position })}
|
|
1594
|
+
>
|
|
1577
1595
|
{src ? (
|
|
1578
1596
|
<button
|
|
1579
1597
|
type="button"
|
|
@@ -1585,19 +1603,38 @@ export function Figure({ src, alt = '', caption, label, width = 'auto', height =
|
|
|
1585
1603
|
<span className="figure-zoom-hint" aria-hidden="true"><ZoomIn size={12} />点击放大</span>
|
|
1586
1604
|
</button>
|
|
1587
1605
|
) : <div className="figure-placeholder">缺少图片 src</div>}
|
|
1588
|
-
{(caption ||
|
|
1606
|
+
{(caption || displayLabel) && (
|
|
1589
1607
|
<figcaption>
|
|
1590
|
-
{
|
|
1608
|
+
{displayLabel && <span className="figure-label">{displayLabel}</span>}
|
|
1591
1609
|
{caption}
|
|
1592
1610
|
</figcaption>
|
|
1593
1611
|
)}
|
|
1594
|
-
{zoomed && <ImageZoom src={src} alt={alt} caption={caption} label={
|
|
1612
|
+
{zoomed && <ImageZoom src={src} alt={alt} caption={caption} label={displayLabel} onClose={() => setZoomed(false)} />}
|
|
1595
1613
|
</figure>
|
|
1596
1614
|
);
|
|
1597
1615
|
}
|
|
1598
1616
|
Figure.displayName = 'Figure';
|
|
1599
1617
|
export const Image = Figure;
|
|
1600
1618
|
|
|
1619
|
+
/**
|
|
1620
|
+
* In-text reference to a numbered figure. `<Figure id="arch" />` numbers itself
|
|
1621
|
+
* in document order and `<FigureRef id="arch" />` renders "图 N", so inserting a
|
|
1622
|
+
* figure never requires renumbering the prose by hand. Links to the figure.
|
|
1623
|
+
*/
|
|
1624
|
+
export function FigureRef({ id, children }) {
|
|
1625
|
+
const scope = useFigureScope();
|
|
1626
|
+
const number = React.useSyncExternalStore(
|
|
1627
|
+
subscribeFigures,
|
|
1628
|
+
() => getFigureNumber(scope, id),
|
|
1629
|
+
() => getFigureNumber(scope, id),
|
|
1630
|
+
);
|
|
1631
|
+
const text = children ?? (number ? `图 ${number}` : '图 ?');
|
|
1632
|
+
return id
|
|
1633
|
+
? <a className="semantic-figure-ref" href={`#fig-${id}`}>{text}</a>
|
|
1634
|
+
: <span className="semantic-figure-ref" data-missing="true">{text}</span>;
|
|
1635
|
+
}
|
|
1636
|
+
FigureRef.displayName = 'FigureRef';
|
|
1637
|
+
|
|
1601
1638
|
// Citations -----------------------------------------------------------------
|
|
1602
1639
|
|
|
1603
1640
|
export function Cite({ id, children }) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figure numbering registry shared by <Figure> and <FigureRef>.
|
|
3
|
+
*
|
|
4
|
+
* Numbers are assigned per *scope*: a scroll document numbers its figures in
|
|
5
|
+
* document order, while an atlas node numbers only the figures it shows. The
|
|
6
|
+
* <FigureScope> context supplies that key, so browsing from one node to another
|
|
7
|
+
* does not leak a running count between them. Registration is idempotent, so a
|
|
8
|
+
* figure that re-renders (or remounts under React StrictMode) keeps its number.
|
|
9
|
+
*
|
|
10
|
+
* The store is module-level for the same reason as citations.js: a <FigureRef>
|
|
11
|
+
* may appear before the <Figure> it points at, so the reference subscribes and
|
|
12
|
+
* resolves once the target registers.
|
|
13
|
+
*/
|
|
14
|
+
const scopes = new Map();
|
|
15
|
+
let version = 0;
|
|
16
|
+
const listeners = new Set();
|
|
17
|
+
|
|
18
|
+
function emit() {
|
|
19
|
+
version += 1;
|
|
20
|
+
for (const listener of listeners) listener();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function scopeKey(scope) {
|
|
24
|
+
return scope || 'document';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Registers `id` at the next free position in `scope` (no-op if already there). */
|
|
28
|
+
export function registerFigure(scope, id) {
|
|
29
|
+
if (!id) return;
|
|
30
|
+
const key = scopeKey(scope);
|
|
31
|
+
const order = scopes.get(key) || [];
|
|
32
|
+
if (order.includes(id)) return;
|
|
33
|
+
order.push(id);
|
|
34
|
+
scopes.set(key, order);
|
|
35
|
+
emit();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Clears one scope, or every scope when called without arguments. */
|
|
39
|
+
export function resetFigures(scope) {
|
|
40
|
+
if (scope === undefined) scopes.clear();
|
|
41
|
+
else scopes.delete(scopeKey(scope));
|
|
42
|
+
emit();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function subscribeFigures(listener) {
|
|
46
|
+
listeners.add(listener);
|
|
47
|
+
return () => listeners.delete(listener);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getFiguresVersion() {
|
|
51
|
+
return version;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 1-based document position of `id` in `scope`, or null when unregistered. */
|
|
55
|
+
export function getFigureNumber(scope, id) {
|
|
56
|
+
if (!id) return null;
|
|
57
|
+
const order = scopes.get(scopeKey(scope));
|
|
58
|
+
if (!order) return null;
|
|
59
|
+
const index = order.indexOf(id);
|
|
60
|
+
return index < 0 ? null : index + 1;
|
|
61
|
+
}
|
|
@@ -87,6 +87,7 @@ export const KNOWN_COMPONENTS = [
|
|
|
87
87
|
'Chart',
|
|
88
88
|
'Figure',
|
|
89
89
|
'Image',
|
|
90
|
+
'FigureRef',
|
|
90
91
|
'Cite',
|
|
91
92
|
'References',
|
|
92
93
|
];
|
|
@@ -101,6 +102,9 @@ export const RELATION_TYPE_SET = new Set(RELATION_TYPE_NAMES);
|
|
|
101
102
|
export const LEVEL_NAMES = Object.keys(LEVEL_DEFS);
|
|
102
103
|
export const LEVEL_SET = new Set(LEVEL_NAMES);
|
|
103
104
|
|
|
105
|
+
/** Inlined images above this size are reported: base64 inflates the HTML ~33%. */
|
|
106
|
+
export const LARGE_ASSET_BYTES = 512 * 1024;
|
|
107
|
+
|
|
104
108
|
/** Props that must be arrays, mapped to the component that owns them. */
|
|
105
109
|
export const ARRAY_PROPS = {
|
|
106
110
|
Flow: ['steps'],
|
|
@@ -358,10 +362,12 @@ function attrValue(map, name) {
|
|
|
358
362
|
* @param {'atlas'|'scroll'|null} [options.mode] Expected carrier
|
|
359
363
|
* @param {boolean} [options.strict] Promote structural warnings to errors
|
|
360
364
|
* @param {(file:string)=>boolean} [options.assetExists] Asset existence probe
|
|
365
|
+
* @param {(file:string)=>number|null} [options.assetSize] Asset size probe (bytes)
|
|
366
|
+
* @param {boolean} [options.inlineAssets] Whether the build inlines local images
|
|
361
367
|
* @returns {{diagnostics:object[], stats:object, carrier:string|null}}
|
|
362
368
|
*/
|
|
363
369
|
export function validateMdxSource(source, options = {}) {
|
|
364
|
-
const { filePath = null, mode = null, strict = false, assetExists = null } = options;
|
|
370
|
+
const { filePath = null, mode = null, strict = false, assetExists = null, assetSize = null, inlineAssets = false } = options;
|
|
365
371
|
const lineStarts = computeLineStarts(source);
|
|
366
372
|
const masked = maskIgnored(source);
|
|
367
373
|
const tags = tokenize(masked);
|
|
@@ -376,6 +382,8 @@ export function validateMdxSource(source, options = {}) {
|
|
|
376
382
|
const graphRoots = [];
|
|
377
383
|
const relations = [];
|
|
378
384
|
const refs = [];
|
|
385
|
+
const figureIds = new Map();
|
|
386
|
+
const figureRefs = [];
|
|
379
387
|
const usedComponents = new Map();
|
|
380
388
|
const stack = [];
|
|
381
389
|
|
|
@@ -434,15 +442,31 @@ export function validateMdxSource(source, options = {}) {
|
|
|
434
442
|
const map = attrsToMap(tag.attrs);
|
|
435
443
|
const src = attrValue(map, 'src');
|
|
436
444
|
const quoted = map.src ? map.src.quoted : false;
|
|
445
|
+
const figureId = attrValue(map, 'id');
|
|
446
|
+
if (figureId) {
|
|
447
|
+
if (figureIds.has(figureId)) add('warning', 'FIGURE_DUPLICATE_ID', `重复的图片 id:${figureId}`, tag.start, figureId);
|
|
448
|
+
else figureIds.set(figureId, tag.start);
|
|
449
|
+
}
|
|
437
450
|
if (!src) add('warning', 'FIGURE_MISSING_SRC', 'Figure 缺少 src', tag.start, tag.name);
|
|
438
|
-
else if (quoted &&
|
|
451
|
+
else if (quoted && /^https?:/i.test(src)) {
|
|
452
|
+
add('warning', 'ASSET_REMOTE', `远程图片需要联网,离线打开时不可见:${src}`, tag.start, src);
|
|
453
|
+
} else if (quoted && !/^(data:|\/)/i.test(src) && filePath && assetExists) {
|
|
439
454
|
const resolved = src.startsWith('.') ? src : `./${src}`;
|
|
440
455
|
if (!assetExists(resolved)) {
|
|
441
456
|
add('warning', 'ASSET_MISSING', `找不到图片文件:${src}(将显示占位符)`, tag.start, src);
|
|
457
|
+
} else if (inlineAssets && assetSize) {
|
|
458
|
+
const bytes = assetSize(resolved);
|
|
459
|
+
if (typeof bytes === 'number' && bytes > LARGE_ASSET_BYTES) {
|
|
460
|
+
add('warning', 'ASSET_LARGE', `图片约 ${Math.round(bytes / 1024)}KB,内联会显著增大 HTML;可用 inline={false} 或改为外链:${src}`, tag.start, src);
|
|
461
|
+
}
|
|
442
462
|
}
|
|
443
463
|
}
|
|
444
464
|
}
|
|
445
465
|
|
|
466
|
+
if (tag.name === 'FigureRef' && tag.kind !== 'close') {
|
|
467
|
+
figureRefs.push({ id: attrValue(attrsToMap(tag.attrs), 'id'), offset: tag.start });
|
|
468
|
+
}
|
|
469
|
+
|
|
446
470
|
if (tag.kind === 'close') {
|
|
447
471
|
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
448
472
|
if (stack[i].tag.name === tag.name) {
|
|
@@ -465,6 +489,12 @@ export function validateMdxSource(source, options = {}) {
|
|
|
465
489
|
}
|
|
466
490
|
}
|
|
467
491
|
|
|
492
|
+
// <FigureRef> must point at a <Figure id="..."> in the same document.
|
|
493
|
+
for (const ref of figureRefs) {
|
|
494
|
+
if (!ref.id) add('warning', 'FIGURE_REF_MISSING_ID', 'FigureRef 缺少 id', ref.offset, 'FigureRef');
|
|
495
|
+
else if (!figureIds.has(ref.id)) add('warning', 'FIGURE_REF_UNRESOLVED', `FigureRef 指向不存在的图片 id:${ref.id}`, ref.offset, ref.id);
|
|
496
|
+
}
|
|
497
|
+
|
|
468
498
|
// `{` inside <Math> children is parsed by MDX as an expression, not LaTeX.
|
|
469
499
|
for (const match of masked.matchAll(/<Math(?![^>]*\/>)[^>]*>([\s\S]*?)<\/Math>/g)) {
|
|
470
500
|
if (match[1].includes('{')) {
|
|
@@ -5,6 +5,7 @@ import * as Components from './components/index.js';
|
|
|
5
5
|
import UserDocument from '@concept-atlas/content';
|
|
6
6
|
import { useAppearance } from './app/use-appearance.js';
|
|
7
7
|
import { SkinPicker } from './components/SkinPicker.jsx';
|
|
8
|
+
import { FigureScopeProvider } from './components/FigureScope.jsx';
|
|
8
9
|
import './styles/concept-explain.css';
|
|
9
10
|
|
|
10
11
|
const rootElement = document.getElementById('root');
|
|
@@ -27,7 +28,9 @@ function ScrollApp() {
|
|
|
27
28
|
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
|
28
29
|
</button>
|
|
29
30
|
</div>
|
|
30
|
-
<
|
|
31
|
+
<FigureScopeProvider scope="document">
|
|
32
|
+
<UserDocument components={Components} />
|
|
33
|
+
</FigureScopeProvider>
|
|
31
34
|
</main>
|
|
32
35
|
);
|
|
33
36
|
}
|
|
@@ -348,12 +348,14 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
/* ==========================================================================
|
|
351
|
-
NODE EXPLORER (
|
|
351
|
+
NODE EXPLORER (2-COLUMN EDITORIAL KNOWLEDGE LAYOUT)
|
|
352
352
|
========================================================================== */
|
|
353
353
|
|
|
354
354
|
.node-explorer-layout {
|
|
355
355
|
display: grid;
|
|
356
|
-
|
|
356
|
+
/* Left sidebar + the scrollable center. The floating app toolbar overlays the
|
|
357
|
+
top-right; app-main reserves its height so nothing sits underneath. */
|
|
358
|
+
grid-template-columns: 220px minmax(0, 1fr);
|
|
357
359
|
height: 100%;
|
|
358
360
|
min-height: 0;
|
|
359
361
|
}
|
|
@@ -620,44 +622,6 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
620
622
|
margin: 0 auto;
|
|
621
623
|
}
|
|
622
624
|
|
|
623
|
-
/* Toolbar */
|
|
624
|
-
.center-toolbar {
|
|
625
|
-
display: flex;
|
|
626
|
-
align-items: center;
|
|
627
|
-
justify-content: space-between;
|
|
628
|
-
margin-bottom: 24px;
|
|
629
|
-
padding-bottom: 14px;
|
|
630
|
-
border-bottom: 1px solid var(--border-medium);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
.level-indicators {
|
|
634
|
-
display: flex;
|
|
635
|
-
gap: 5px;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
.level-pill {
|
|
639
|
-
padding: 4px 9px;
|
|
640
|
-
border-radius: 6px;
|
|
641
|
-
font-size: 11px;
|
|
642
|
-
font-weight: 700;
|
|
643
|
-
background: var(--bg-surface);
|
|
644
|
-
color: var(--text-dim);
|
|
645
|
-
border: 1px solid var(--border-medium);
|
|
646
|
-
transition: all 0.2s ease;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
.level-pill:hover {
|
|
650
|
-
border-color: var(--text-muted);
|
|
651
|
-
color: var(--text-primary);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
.level-pill.active {
|
|
655
|
-
background: var(--primary);
|
|
656
|
-
color: var(--on-accent);
|
|
657
|
-
border-color: var(--primary);
|
|
658
|
-
box-shadow: var(--shadow-sm);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
625
|
.view-graph-btn {
|
|
662
626
|
display: flex;
|
|
663
627
|
align-items: center;
|
|
@@ -1298,15 +1262,6 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1298
1262
|
flex-direction: column;
|
|
1299
1263
|
}
|
|
1300
1264
|
|
|
1301
|
-
.center-toolbar {
|
|
1302
|
-
flex: 0 0 auto;
|
|
1303
|
-
gap: 10px;
|
|
1304
|
-
min-height: 24px;
|
|
1305
|
-
margin-bottom: 4px;
|
|
1306
|
-
padding-bottom: 4px;
|
|
1307
|
-
border-bottom: 0;
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
1265
|
.draft-floating-tools {
|
|
1311
1266
|
position: fixed;
|
|
1312
1267
|
right: 10px;
|
|
@@ -1501,6 +1456,9 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1501
1456
|
|
|
1502
1457
|
.hierarchy-strip-children {
|
|
1503
1458
|
padding-left: 0;
|
|
1459
|
+
/* The floating app toolbar hangs into the strip's top-right. Nudge the
|
|
1460
|
+
drill-down row below its bottom edge so its buttons are never covered. */
|
|
1461
|
+
padding-top: 16px;
|
|
1504
1462
|
}
|
|
1505
1463
|
|
|
1506
1464
|
.hierarchy-child-scroll { display: flex; gap: 5px; overflow-x: auto; min-width: 0; }
|
|
@@ -1552,10 +1510,6 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
1552
1510
|
box-shadow: none;
|
|
1553
1511
|
}
|
|
1554
1512
|
|
|
1555
|
-
.node-explorer-layout {
|
|
1556
|
-
grid-template-columns: 220px minmax(0, 1fr);
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
1513
|
.inline-inspector {
|
|
1560
1514
|
margin-top: 18px;
|
|
1561
1515
|
padding: 18px 20px;
|
|
@@ -2787,7 +2741,6 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
|
|
|
2787
2741
|
.meta-chip,
|
|
2788
2742
|
.insp-pill,
|
|
2789
2743
|
.inline-relation,
|
|
2790
|
-
.level-pill,
|
|
2791
2744
|
.view-graph-btn,
|
|
2792
2745
|
.framework-model-element,
|
|
2793
2746
|
.model-pyramid-level,
|
|
@@ -3485,7 +3438,7 @@ html[data-carrier='scroll'] #root {
|
|
|
3485
3438
|
display: block;
|
|
3486
3439
|
width: 100%;
|
|
3487
3440
|
height: 100%;
|
|
3488
|
-
background:
|
|
3441
|
+
background: var(--progress-gradient);
|
|
3489
3442
|
transform: scaleX(0);
|
|
3490
3443
|
transform-origin: left center;
|
|
3491
3444
|
transition: transform 80ms linear;
|
|
@@ -3891,6 +3844,22 @@ html[data-carrier='scroll'] #root {
|
|
|
3891
3844
|
font-weight: 700;
|
|
3892
3845
|
}
|
|
3893
3846
|
|
|
3847
|
+
/* In-text reference to a numbered figure; mirrors the citation affordance. */
|
|
3848
|
+
.semantic-figure-ref {
|
|
3849
|
+
color: var(--text-accent);
|
|
3850
|
+
font-weight: 600;
|
|
3851
|
+
text-decoration: none;
|
|
3852
|
+
border-bottom: 1px dashed currentColor;
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
.semantic-figure-ref:hover {
|
|
3856
|
+
border-bottom-style: solid;
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
.semantic-figure-ref[data-missing="true"] {
|
|
3860
|
+
color: var(--text-dim);
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3894
3863
|
.figure-placeholder {
|
|
3895
3864
|
padding: 32px;
|
|
3896
3865
|
border: 1px dashed var(--border-medium);
|
|
@@ -4025,7 +3994,6 @@ a.reference-title:hover { color: var(--text-accent); }
|
|
|
4025
3994
|
|
|
4026
3995
|
/* Index chips pop in a beat after the surface they sit on. */
|
|
4027
3996
|
.step-num,
|
|
4028
|
-
.level-pill,
|
|
4029
3997
|
.model-pyramid-level .framework-model-index,
|
|
4030
3998
|
.framework-model-element .framework-model-index {
|
|
4031
3999
|
animation: ca-pop var(--motion-base) var(--ease-out) backwards;
|
|
@@ -372,10 +372,6 @@
|
|
|
372
372
|
border-bottom: 1px solid var(--border-subtle);
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
[data-style='elastic'] .reading-progress-bar {
|
|
376
|
-
background: var(--primary);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
375
|
[data-style='elastic'] .scroll-toc-title {
|
|
380
376
|
color: var(--text-muted);
|
|
381
377
|
font-weight: 600;
|
|
@@ -366,10 +366,6 @@
|
|
|
366
366
|
border-bottom: 1px solid var(--border-subtle);
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
-
[data-style='shadcn'] .reading-progress-bar {
|
|
370
|
-
background: var(--primary);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
369
|
[data-style='shadcn'] .scroll-toc-title {
|
|
374
370
|
color: var(--text-muted);
|
|
375
371
|
font-weight: 600;
|
|
@@ -3,14 +3,13 @@ import { ChevronRight, ArrowUpRight, CornerDownRight, ArrowLeft, Network, Corner
|
|
|
3
3
|
import { getAncestorPath, getSiblingNodes } from '../model/concept-schema.js';
|
|
4
4
|
import { LEVEL_DEFS } from '../model/relation-types.js';
|
|
5
5
|
import { NODE_KINDS } from '../model/node-kinds.js';
|
|
6
|
+
import { FigureScopeProvider } from '../components/FigureScope.jsx';
|
|
6
7
|
|
|
7
8
|
export function NodeExplorer({
|
|
8
9
|
graph,
|
|
9
10
|
currentNodeId,
|
|
10
11
|
onSelectNode,
|
|
11
12
|
onSwitchView,
|
|
12
|
-
selectedLevel,
|
|
13
|
-
onSelectLevel
|
|
14
13
|
}) {
|
|
15
14
|
const { nodes, relations } = graph;
|
|
16
15
|
const [canvasScale, setCanvasScale] = React.useState(1);
|
|
@@ -125,8 +124,6 @@ export function NodeExplorer({
|
|
|
125
124
|
const childNodes = currentNode.children
|
|
126
125
|
.filter(id => nodes.has(id))
|
|
127
126
|
.map(id => nodes.get(id));
|
|
128
|
-
const visibleChildNodes = selectedLevel ? childNodes.filter(node => node.level === selectedLevel) : childNodes;
|
|
129
|
-
const visibleSiblings = selectedLevel ? siblings.filter(node => node.level === selectedLevel) : siblings;
|
|
130
127
|
|
|
131
128
|
// Node-specific relations
|
|
132
129
|
const outgoingRelations = relations.filter(r => r.from === currentNode.id);
|
|
@@ -168,8 +165,8 @@ export function NodeExplorer({
|
|
|
168
165
|
<div className="side-section">
|
|
169
166
|
<div className="side-label">同层节点</div>
|
|
170
167
|
<div className="sibling-list">
|
|
171
|
-
{
|
|
172
|
-
|
|
168
|
+
{siblings.length > 0 ? (
|
|
169
|
+
siblings.map(sib => (
|
|
173
170
|
<button
|
|
174
171
|
key={sib.id}
|
|
175
172
|
className="sibling-btn"
|
|
@@ -195,6 +192,7 @@ export function NodeExplorer({
|
|
|
195
192
|
</aside>
|
|
196
193
|
|
|
197
194
|
{/* 2. Middle Column: Current Node Explanation Card */}
|
|
195
|
+
<FigureScopeProvider scope={currentNode.id}>
|
|
198
196
|
<main className="explorer-center">
|
|
199
197
|
<HierarchyStrip
|
|
200
198
|
ancestorPath={ancestorPath}
|
|
@@ -203,23 +201,6 @@ export function NodeExplorer({
|
|
|
203
201
|
onSelectNode={onSelectNode}
|
|
204
202
|
/>
|
|
205
203
|
<div className="center-scrollable">
|
|
206
|
-
{/* Header toolbar */}
|
|
207
|
-
<div className="center-toolbar">
|
|
208
|
-
<div className="level-indicators">
|
|
209
|
-
{Object.keys(LEVEL_DEFS).map(lvl => (
|
|
210
|
-
<button
|
|
211
|
-
key={lvl}
|
|
212
|
-
className={`level-pill ${(selectedLevel === lvl || (!selectedLevel && currentNode.level === lvl)) ? 'active' : ''}`}
|
|
213
|
-
onClick={() => onSelectLevel && onSelectLevel(selectedLevel === lvl ? null : lvl)}
|
|
214
|
-
title={LEVEL_DEFS[lvl].desc}
|
|
215
|
-
>
|
|
216
|
-
{lvl}
|
|
217
|
-
</button>
|
|
218
|
-
))}
|
|
219
|
-
{selectedLevel && <button className="level-pill level-pill-clear" onClick={() => onSelectLevel && onSelectLevel(null)}>全部</button>}
|
|
220
|
-
</div>
|
|
221
|
-
</div>
|
|
222
|
-
|
|
223
204
|
<div
|
|
224
205
|
ref={viewportRef}
|
|
225
206
|
className={`draft-viewport ${isDragging ? 'is-dragging' : ''}`}
|
|
@@ -419,14 +400,14 @@ export function NodeExplorer({
|
|
|
419
400
|
))}
|
|
420
401
|
|
|
421
402
|
{/* Sub-node Exploration Cards (Drill Down Entrance) */}
|
|
422
|
-
{
|
|
403
|
+
{childNodes.length > 0 && (
|
|
423
404
|
<section className="node-block drill-down-section">
|
|
424
405
|
<div className="block-head">
|
|
425
406
|
<h2>深入下钻:子概念节点</h2>
|
|
426
407
|
<small>点击卡片探索更深机制</small>
|
|
427
408
|
</div>
|
|
428
409
|
<div className="subnodes-grid">
|
|
429
|
-
{
|
|
410
|
+
{childNodes.map(child => (
|
|
430
411
|
<button
|
|
431
412
|
key={child.id}
|
|
432
413
|
className="subnode-card"
|
|
@@ -457,6 +438,7 @@ export function NodeExplorer({
|
|
|
457
438
|
</div>
|
|
458
439
|
</div>
|
|
459
440
|
</main>
|
|
441
|
+
</FigureScopeProvider>
|
|
460
442
|
</div>
|
|
461
443
|
);
|
|
462
444
|
}
|
package/template/vite.config.js
CHANGED
|
@@ -18,37 +18,111 @@ const MIME_TYPES = {
|
|
|
18
18
|
'.bmp': 'image/bmp',
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// An opening/self-closing JSX tag. Quoted attribute values may contain `>`, so
|
|
22
|
+
// the attribute body is matched as a sequence of quoted strings or bare chars.
|
|
23
|
+
const JSX_TAG_RE = /<([A-Za-z][\w.]*)((?:"[^"]*"|'[^']*'|[^>"'])*)\/?>/g;
|
|
24
|
+
// A markdown image:  or . Base64 has no spaces or
|
|
25
|
+
// parens, so it survives the round trip.
|
|
26
|
+
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)(\s+["'][^"']*["'])?\)/g;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads a tag's per-image `inline` prop. Returns `true`/`false` when it is set
|
|
30
|
+
* (bare `inline`, `inline={true}`, `inline={false}`) and `null` when absent.
|
|
31
|
+
* Quoted attribute values are blanked first so a value like "inline" cannot be
|
|
32
|
+
* mistaken for the prop itself.
|
|
33
|
+
*/
|
|
34
|
+
function readInlineProp(tag) {
|
|
35
|
+
const stripped = tag.replace(/"[^"]*"|'[^']*'/g, '""');
|
|
36
|
+
const match = stripped.match(/\binline\b(?:\s*=\s*(\{[^}]*\}|[^\s/>]+))?/);
|
|
37
|
+
if (!match) return null;
|
|
38
|
+
if (match[1] === undefined) return true;
|
|
39
|
+
return match[1].replace(/^\{|\}$/g, '').trim() !== 'false';
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
43
|
+
* Reversibly hides fenced code blocks and inline code spans so documented
|
|
44
|
+
* samples (`<Figure src="./x.png" />` inside a fence) are not rewritten into
|
|
45
|
+
* giant data URIs.
|
|
46
|
+
*/
|
|
47
|
+
function maskCode(code) {
|
|
48
|
+
const blocks = [];
|
|
49
|
+
const token = index => `\u0000atlas-mask-${index}\u0000`;
|
|
50
|
+
const hide = (text) => { const key = token(blocks.length); blocks.push(text); return key; };
|
|
51
|
+
let fence = null;
|
|
52
|
+
const lines = code.split('\n').map((line) => {
|
|
53
|
+
const marker = line.match(/^\s*(`{3,}|~{3,})/);
|
|
54
|
+
if (fence) {
|
|
55
|
+
if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = null;
|
|
56
|
+
return hide(line);
|
|
57
|
+
}
|
|
58
|
+
if (marker) { fence = marker[1]; return hide(line); }
|
|
59
|
+
// Keep the character before the span so we never eat a JSX opening brace.
|
|
60
|
+
return line.replace(/([^{])`[^`\n]*`/g, match => match[0] + hide(match.slice(1)));
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
masked: lines.join('\n'),
|
|
64
|
+
restore: text => blocks.reduce((acc, block, index) => acc.split(token(index)).join(block), text),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolves relative image paths on MDX components and markdown images, or leaves
|
|
70
|
+
* them alone. The default is to LEAVE them as relative links: the HTML stays
|
|
71
|
+
* small and the assets travel beside it. Inlining (a self-contained single file)
|
|
72
|
+
* is opt-in because it is the expensive choice, and it composes in one order:
|
|
73
|
+
*
|
|
74
|
+
* per-tag `inline` prop > `--inline-assets` global flag > default (link)
|
|
26
75
|
*
|
|
27
|
-
* `--
|
|
28
|
-
*
|
|
29
|
-
*
|
|
76
|
+
* `--inline-assets` (define `__ATLAS_INLINE_ASSETS__ === 'true'`) inlines every
|
|
77
|
+
* local image; a per-tag `inline={true|false}` overrides that choice for one
|
|
78
|
+
* image. Remote (http/https), data URIs and absolute paths are never touched.
|
|
30
79
|
*/
|
|
31
80
|
function inlineMdxAssets() {
|
|
32
|
-
const state = {
|
|
81
|
+
const state = { defaultInline: false };
|
|
33
82
|
return {
|
|
34
83
|
name: 'concept-atlas-inline-assets',
|
|
35
84
|
enforce: 'pre',
|
|
36
85
|
configResolved(config) {
|
|
37
|
-
|
|
86
|
+
const raw = (config.define || {}).__ATLAS_INLINE_ASSETS__;
|
|
87
|
+
const value = typeof raw === 'string' ? raw.replace(/^"([\s\S]*)"$/, '$1') : raw;
|
|
88
|
+
state.defaultInline = value === 'true' || value === true;
|
|
38
89
|
},
|
|
39
90
|
transform(code, id) {
|
|
40
|
-
if (!
|
|
91
|
+
if (!id.endsWith('.mdx')) return null;
|
|
41
92
|
const dir = path.dirname(id.split('?')[0]);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (/^(https?:|data:|\/|#)/i.test(src)) return match;
|
|
93
|
+
const resolveLocal = (src) => {
|
|
94
|
+
if (/^(https?:|data:|\/|#)/i.test(src)) return null;
|
|
45
95
|
const file = path.resolve(dir, src);
|
|
46
96
|
const mime = MIME_TYPES[path.extname(file).toLowerCase()];
|
|
47
|
-
if (!mime || !fs.existsSync(file)) return
|
|
97
|
+
if (!mime || !fs.existsSync(file)) return null;
|
|
98
|
+
return `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const { masked, restore } = maskCode(code);
|
|
102
|
+
let changed = false;
|
|
103
|
+
let output = masked.replace(JSX_TAG_RE, (tag) => {
|
|
104
|
+
const srcMatch = tag.match(/\bsrc=(["'])([^"']+)\1/);
|
|
105
|
+
if (!srcMatch) return tag;
|
|
106
|
+
const prop = readInlineProp(tag);
|
|
107
|
+
const effective = prop === null ? state.defaultInline : prop;
|
|
108
|
+
if (!effective) return tag;
|
|
109
|
+
const uri = resolveLocal(srcMatch[2]);
|
|
110
|
+
if (!uri) return tag;
|
|
48
111
|
changed = true;
|
|
49
|
-
return
|
|
112
|
+
return tag.replace(srcMatch[0], `src=${srcMatch[1]}${uri}${srcMatch[1]}`);
|
|
50
113
|
});
|
|
51
|
-
|
|
114
|
+
|
|
115
|
+
// Markdown images have no prop slot, so they follow the global flag only.
|
|
116
|
+
if (state.defaultInline) {
|
|
117
|
+
output = output.replace(MARKDOWN_IMAGE_RE, (match, alt, src, title = '') => {
|
|
118
|
+
const uri = resolveLocal(src);
|
|
119
|
+
if (!uri) return match;
|
|
120
|
+
changed = true;
|
|
121
|
+
return ``;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return changed ? { code: restore(output), map: null } : null;
|
|
52
126
|
},
|
|
53
127
|
};
|
|
54
128
|
}
|