concept-atlas-dense-explain 0.6.1 → 0.8.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/bin/cli.mjs +15 -5
- package/package.json +1 -1
- package/skill/SKILL.md +15 -17
- package/template/scripts/build.mjs +61 -27
- package/template/src/app/App.jsx +52 -61
- package/template/src/app/navigation.js +41 -0
- package/template/src/app/search.js +84 -0
- package/template/src/components/MDXComponents.jsx +2 -1
- package/template/src/model/concept-schema.js +15 -0
- package/template/src/model/node-kinds.js +68 -0
- package/template/src/model/normalize-content.js +53 -1
- package/template/src/model/skins.js +18 -1
- package/template/src/model/validate-content.js +24 -0
- package/template/src/styles/concept-explain.css +10 -3904
- package/template/src/styles/core.css +4001 -0
- package/template/src/styles/motion.css +189 -0
- package/template/src/styles/packs/elastic.css +517 -0
- package/template/src/styles/packs/manuscript.css +426 -0
- package/template/src/styles/packs/shadcn.css +517 -0
- package/template/src/styles/skins.css +561 -0
- package/template/src/styles/tokens.css +101 -37
- package/template/src/views/NodeExplorer.jsx +33 -2
- package/template/src/views/RelationGraph.jsx +148 -37
- package/template/vite.config.js +63 -6
package/bin/cli.mjs
CHANGED
|
@@ -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] [--link-assets] [--json] [--no-validate]');
|
|
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] [--link-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,6 +22,7 @@ function usage() {
|
|
|
22
22
|
console.log('');
|
|
23
23
|
console.log(' Multiple inputs build in parallel (default 2 at a time, cap 4); -o is then a directory.');
|
|
24
24
|
console.log(' --link-assets keeps figures as relative links instead of inlining them as base64.');
|
|
25
|
+
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.');
|
|
25
26
|
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.`);
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -43,7 +44,7 @@ function fail(message) {
|
|
|
43
44
|
process.exit(1);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
const VALUE_FLAGS = new Set(['--mode', '-o', '--output', '--concurrency', '--skin', '--default-mode', '--style']);
|
|
47
|
+
const VALUE_FLAGS = new Set(['--mode', '-o', '--output', '--concurrency', '--skin', '--default-mode', '--style', '--mermaid-cdn']);
|
|
47
48
|
|
|
48
49
|
/** Splits argv into flags, flag values and positional arguments. */
|
|
49
50
|
function parseFlags(argv) {
|
|
@@ -203,6 +204,8 @@ const strict = parsed.flags.has('--strict');
|
|
|
203
204
|
const skipValidate = parsed.flags.has('--no-validate');
|
|
204
205
|
const force = parsed.flags.has('--force');
|
|
205
206
|
const linkAssets = parsed.flags.has('--link-assets');
|
|
207
|
+
const inlineMermaid = parsed.flags.has('--inline-mermaid');
|
|
208
|
+
const mermaidCdn = parsed.values.get('--mermaid-cdn') || null;
|
|
206
209
|
const modeFlag = parsed.values.get('--mode') || null;
|
|
207
210
|
|
|
208
211
|
// Compile-time appearance defaults. Invalid values fail fast with the valid
|
|
@@ -332,6 +335,10 @@ async function buildOne(job) {
|
|
|
332
335
|
if (skinFlag) define.__ATLAS_DEFAULT_SKIN__ = JSON.stringify(skinFlag);
|
|
333
336
|
if (defaultModeFlag) define.__ATLAS_DEFAULT_MODE__ = JSON.stringify(defaultModeFlag);
|
|
334
337
|
if (styleFlag) define.__ATLAS_DEFAULT_STYLE__ = JSON.stringify(styleFlag);
|
|
338
|
+
if (features.mermaid) {
|
|
339
|
+
define.__ATLAS_MERMAID_MODE__ = JSON.stringify(inlineMermaid ? 'inline' : 'cdn');
|
|
340
|
+
if (mermaidCdn) define.__ATLAS_MERMAID_CDN_URL__ = JSON.stringify(mermaidCdn);
|
|
341
|
+
}
|
|
335
342
|
try {
|
|
336
343
|
await build({
|
|
337
344
|
root: templateRoot,
|
|
@@ -357,9 +364,10 @@ async function buildOne(job) {
|
|
|
357
364
|
|
|
358
365
|
/** Saves the mermaid/KaTeX payload when a document never renders them. */
|
|
359
366
|
function describeFeatures(features) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
367
|
+
const tags = [];
|
|
368
|
+
if (features.mermaid) tags.push(inlineMermaid ? 'mermaid inline' : 'mermaid cdn');
|
|
369
|
+
if (features.math) tags.push('KaTeX');
|
|
370
|
+
return tags.length ? ` [${tags.join(', ')}]` : '';
|
|
363
371
|
}
|
|
364
372
|
|
|
365
373
|
function summarizeFeatures(jobs, results) {
|
|
@@ -368,7 +376,9 @@ function summarizeFeatures(jobs, results) {
|
|
|
368
376
|
if (!pages.length) return '';
|
|
369
377
|
const droppedMath = pages.filter(job => !job.features.math).length;
|
|
370
378
|
const droppedMermaid = pages.filter(job => !job.features.mermaid).length;
|
|
379
|
+
const cdnMermaid = pages.filter(job => job.features.mermaid && !inlineMermaid).length;
|
|
371
380
|
const parts = [];
|
|
381
|
+
if (cdnMermaid) parts.push(`Mermaid via CDN on ${cdnMermaid}/${pages.length}`);
|
|
372
382
|
if (droppedMath) parts.push(`KaTeX dropped on ${droppedMath}/${pages.length}`);
|
|
373
383
|
if (droppedMermaid) parts.push(`Mermaid dropped on ${droppedMermaid}/${pages.length}`);
|
|
374
384
|
return parts.join(', ');
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: concept-atlas-dense-explain
|
|
3
|
-
description: Turn a topic or an existing document into an interactive Concept Atlas explainer.
|
|
3
|
+
description: Turn a topic or an existing document into an interactive Concept Atlas explainer. Generate AI-editable MDX with the concept-atlas-dense-explain npm CLI, validate its structure, and compile standalone HTML with concept nodes, relation graphs, math (KaTeX), charts, figures, citations, and reading aids. Use when a technical explanation, concept map, layered knowledge page, dense explainer, or interactive teaching page is requested.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Concept Atlas Dense Explain
|
|
7
7
|
|
|
8
|
-
Drive everything through the `concept-atlas-dense-explain` npm CLI. This skill is intentionally lightweight: do not copy implementation files from the skill directory
|
|
9
|
-
|
|
10
|
-
If the user only wants the prompt/methodology and not files, still choose a shell and emit valid MDX; the CLI is needed only to compile.
|
|
8
|
+
Drive everything through the `concept-atlas-dense-explain` npm CLI. This skill is intentionally lightweight: do not copy implementation files from the skill directory or recreate the React/Vite app. If a command name is unclear, run `npx concept-atlas-dense-explain help`. If the user only wants the prompt/methodology and not files, still choose a shell and emit valid MDX; the CLI is needed only to compile.
|
|
11
9
|
|
|
12
10
|
## Workflow
|
|
13
11
|
|
|
14
|
-
1. Choose one page shell: `atlas` (concept graph with node navigation) or `scroll` (continuous document). Recommend `atlas` when the reader
|
|
15
|
-
2. **Learn the components from the canonical guide before authoring.** Generate
|
|
12
|
+
1. Choose one page shell: `atlas` (concept graph with node navigation) or `scroll` (continuous document). Recommend `atlas` when the reader drills into concepts or follows relations, `scroll` for linear argument, tutorials, and reports. The library is shared. If an `.mdx` already exists, detect its shell and work with it.
|
|
13
|
+
2. **Learn the components from the canonical guide before authoring.** Generate it for the chosen shell and read it:
|
|
16
14
|
```bash
|
|
17
15
|
npx concept-atlas-dense-explain guide --mode atlas -o ./concept-atlas-atlas-guide.mdx
|
|
18
16
|
npx concept-atlas-dense-explain guide --mode scroll -o ./concept-atlas-scroll-guide.mdx
|
|
19
17
|
```
|
|
20
|
-
It is
|
|
18
|
+
It is real, compilable MDX showing that shell's components and their exact props; search it for a component name instead of guessing. Delete it when done.
|
|
21
19
|
3. Start from a skeleton when useful: `npx concept-atlas-dense-explain create <file>.mdx --mode atlas|scroll`. `create` and `guide` refuse to overwrite an existing file unless `--force` is passed.
|
|
22
20
|
4. Write the semantic MDX into the user's `.mdx` file (see Authoring rules).
|
|
23
21
|
5. Validate before rendering:
|
|
@@ -25,17 +23,17 @@ If the user only wants the prompt/methodology and not files, still choose a shel
|
|
|
25
23
|
npx concept-atlas-dense-explain validate <file>.mdx --mode atlas|scroll
|
|
26
24
|
npx concept-atlas-dense-explain validate <file>.mdx --json
|
|
27
25
|
```
|
|
28
|
-
|
|
29
|
-
6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>]`. Output is a standalone HTML beside the MDX unless `-o` is given. Validation errors abort the build
|
|
30
|
-
7. **Appearance (optional)**: pages ship
|
|
31
|
-
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]
|
|
26
|
+
Diagnostics are `CODE line:column message`. Fix all `error`s and re-run; address warnings when cheap.
|
|
27
|
+
6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [--inline-mermaid] [--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 loads from a CDN at runtime by default (needs network); pass `--inline-mermaid` for a fully offline single file.
|
|
28
|
+
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.
|
|
29
|
+
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.
|
|
32
30
|
9. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
|
|
33
31
|
|
|
34
32
|
## Carriers
|
|
35
33
|
|
|
36
|
-
- `atlas`: `ExplainPage` → `ConceptGraph` → `ConceptNode`, plus `Children`/`ConceptRef` and cross-branch `Relation`s. Exactly one `L0` root, several `L1` branches, depth to `L3`/`L4`. Shared components live inside nodes.
|
|
34
|
+
- `atlas`: `ExplainPage` → `ConceptGraph` → `ConceptNode`, plus `Children`/`ConceptRef` and cross-branch `Relation`s. Exactly one `L0` root, several `L1` branches, depth to `L3`/`L4`. Shared components live inside nodes. `ConceptNode` also takes an optional `kind` (see Authoring rules) that labels a node's knowledge role independently of its level.
|
|
37
35
|
- `scroll`: `ScrollDocument` → `ScrollHeader` + `ScrollSection` (+ `ScrollProse`, `ScrollGrid`). Shared components live inside sections. The shell auto-builds a table of contents and reading progress from section titles — do not hand-build navigation.
|
|
38
|
-
- The browser tab comes from the shell, not the build flags: `ExplainPage title
|
|
36
|
+
- The browser tab comes from the shell, not the build flags: `ExplainPage title` (atlas) or `ScrollHeader title` (scroll) becomes the `<title>`, so give it a real, specific name — never a placeholder like "主题名称". Every page uses a fixed 📃 favicon.
|
|
39
37
|
- Never make one MDX file both shells. When switching shells, convert only the outer structure.
|
|
40
38
|
|
|
41
39
|
## Component families
|
|
@@ -50,19 +48,19 @@ If the user only wants the prompt/methodology and not files, still choose a shel
|
|
|
50
48
|
|
|
51
49
|
- Keep MDX semantic. Never write CSS, coordinates, SVG, or replacement application code; never invent component names or props.
|
|
52
50
|
- Give each important node a claim-like title, a one-sentence `summary`, and real substance (`Definition`, `Mechanism`, `Example`, `Boundary`, `Evidence`, a model, a chart, or math). Do not restate the same text across `Overview`, `Definition`, and `Insight`.
|
|
51
|
+
- **`kind` (optional, atlas)**: tag a node's knowledge role independently of `level`. One of `system`, `stage`, `mechanism`, `artifact`, `failure`, `tool`, `boundary`, `decision`. It powers the graph's "知识类型" filter and a node badge, so use it where the role is clear rather than on every node. Two opt-in contracts fire once you declare one: `kind="mechanism"` should contain an `Invariant` or `Evidence`, and `kind="failure"` should contain a `FailureMode` (with `symptom`/`cause`/`evidence`/`remedy`).
|
|
53
52
|
- Array props are arrays of objects: `Flow steps={[{title, description}]}`, `Timeline events={[{label, content}]}`, `MatrixModel cells={[{title, description, tone}]}`, `DecisionMatrix headers={[...]} rows={[[...]]}`, `Chart data={[{label, value}]}`, `References items={[{id, authors, year, title, url, source}]}`. The validator warns (`PROP_EXPECTS_ARRAY`) when an array prop gets a string or non-array.
|
|
54
53
|
- `Relation type` must be one of `prerequisite`, `causes`, `produces`, `uses`, `implements`, `contrasts`, `depends-on`, `exception-of`, `precedes`, and each `Relation` needs a `label`. Parent/child hierarchy is implicit (via `parent` and `Children`/`ConceptRef`) — do not express it with a `Relation`.
|
|
55
54
|
- **Math**: MDX parses `{ ... }` in children as expressions, so pass LaTeX with braces or backslashes through `formula`: `<Math formula="r_{\text{ann}} = (1 + r)^{12} - 1" />`, `<MathBlock formula="I(x) = -\log_2 p(x)" variables={[{symbol, description}]} />`. Brace-free children such as `<Math>\log_2 N</Math>` are fine. The validator warns (`MATH_CHILDREN_BRACES`).
|
|
56
55
|
- **Chart**: `type` is `bar` | `line` | `pie`; use `data` for bar/pie and `labels` + `series={[{name, values}]}` for line. Charts follow theme colors.
|
|
57
|
-
- **Figure**: a relative `src` (`./assets/diagram.png`) is inlined as base64 at build time so the HTML stays standalone; `http(s)` URLs stay links. Always set `alt`; add `label` and `caption` for a numbered caption. A missing relative file
|
|
58
|
-
- **Figure size**: inlining is what makes a screenshot-heavy page large
|
|
56
|
+
- **Figure**: a relative `src` (`./assets/diagram.png`) is inlined as base64 at build time so the HTML stays standalone; `http(s)` URLs stay links. Always set `alt`; add `label` and `caption` for a numbered caption. 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.
|
|
57
|
+
- **Figure size & cost**: inlining images is what makes a screenshot-heavy page large; a page with no heavy renderers otherwise lands near 250KB. When the user cares, compile with `--link-assets` to keep images as relative links (measured 1.51MB → 270KB); the output must then sit beside the MDX's `assets/`, and the CLI warns if `-o` points elsewhere — tell the user that trade-off instead of choosing silently.
|
|
59
58
|
- **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.
|
|
60
59
|
- 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.
|
|
61
|
-
- Be brief about cost: the build only bundles the heavy optional renderers the document actually uses, so a page with no `<Math>`/`<Mermaid>` comes out around 250KB. Inlined figures are usually the largest remaining cost — a page with a dozen screenshots lands near 1–2MB, which is normal for an offline explainer. Mention it if the user cares about file size.
|
|
62
60
|
|
|
63
61
|
## Validation diagnostics
|
|
64
62
|
|
|
65
|
-
`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_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` and `
|
|
63
|
+
`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.
|
|
66
64
|
|
|
67
65
|
## Before you report
|
|
68
66
|
|
|
@@ -3,12 +3,21 @@ import path from 'path';
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { normalizeSkin, normalizeStyle } from '../src/model/skins.js';
|
|
6
|
-
import { extractPageTitle } from '../src/model/validate-content.js';
|
|
6
|
+
import { extractPageTitle, detectFeatures } from '../src/model/validate-content.js';
|
|
7
7
|
|
|
8
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
9
|
const __dirname = path.dirname(__filename);
|
|
10
10
|
const rootDir = path.resolve(__dirname, '..');
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Mermaid is served from a CDN at runtime by default so its ~2100-module
|
|
14
|
+
* transform stays out of the build. Set CONCEPT_ATLAS_INLINE_MERMAID=1 to bake
|
|
15
|
+
* it back into the HTML for a fully offline single file; CONCEPT_ATLAS_MERMAID_CDN
|
|
16
|
+
* overrides the CDN URL.
|
|
17
|
+
*/
|
|
18
|
+
const INLINE_MERMAID = ['1', 'true', 'yes'].includes((process.env.CONCEPT_ATLAS_INLINE_MERMAID || '').toLowerCase());
|
|
19
|
+
const MERMAID_CDN_URL = process.env.CONCEPT_ATLAS_MERMAID_CDN || '';
|
|
20
|
+
|
|
12
21
|
/**
|
|
13
22
|
* Optional compile-time appearance defaults, read from the environment and
|
|
14
23
|
* forwarded as `define`s. The carrier HTML plugin replaces the placeholders
|
|
@@ -26,28 +35,65 @@ function appearanceDefines() {
|
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
|
-
* The
|
|
30
|
-
* the repository
|
|
31
|
-
*
|
|
32
|
-
*
|
|
38
|
+
* The demo document mounted by each carrier. Unlike the npm template (which
|
|
39
|
+
* aliases the user's MDX), the repository hardcodes its demos in main.jsx /
|
|
40
|
+
* scroll-main.jsx, so the build reads them to derive the tab title and the set
|
|
41
|
+
* of optional renderers actually needed.
|
|
33
42
|
*/
|
|
34
|
-
function
|
|
43
|
+
function demoSourceFor(entry) {
|
|
35
44
|
const demos = entry === 'scroll.html'
|
|
36
45
|
? ['content/scroll-reading-demo.mdx']
|
|
37
46
|
: ['content/components-demo.mdx', 'content/compile-runtime.mdx'];
|
|
38
47
|
const demo = demos.map(name => path.resolve(rootDir, name)).find(file => fs.existsSync(file));
|
|
39
|
-
return demo ?
|
|
48
|
+
return demo ? { demo, source: fs.readFileSync(demo, 'utf8') } : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Builds one standalone carrier HTML. `__ATLAS_FEATURES__` mirrors the CLI's
|
|
53
|
+
* per-document stubbing so a demo that never uses <Math>/<Mermaid> skips the
|
|
54
|
+
* KaTeX fonts and Mermaid module graph instead of bundling them unconditionally.
|
|
55
|
+
*/
|
|
56
|
+
async function buildCarrier(entry, baseDefine) {
|
|
57
|
+
const define = { ...baseDefine };
|
|
58
|
+
const demo = demoSourceFor(entry);
|
|
59
|
+
if (demo) {
|
|
60
|
+
const title = extractPageTitle(demo.source);
|
|
61
|
+
if (title) {
|
|
62
|
+
define.__ATLAS_PAGE_TITLE__ = JSON.stringify(title);
|
|
63
|
+
console.log(`🔖 ${entry} 标签页标题:${title}`);
|
|
64
|
+
}
|
|
65
|
+
const features = detectFeatures(demo.source);
|
|
66
|
+
define.__ATLAS_FEATURES__ = JSON.stringify(features);
|
|
67
|
+
if (features.mermaid) {
|
|
68
|
+
define.__ATLAS_MERMAID_MODE__ = JSON.stringify(INLINE_MERMAID ? 'inline' : 'cdn');
|
|
69
|
+
if (MERMAID_CDN_URL) define.__ATLAS_MERMAID_CDN_URL__ = JSON.stringify(MERMAID_CDN_URL);
|
|
70
|
+
if (!INLINE_MERMAID) console.log(`🌐 ${entry} Mermaid 运行时从 CDN 加载(--inline-mermaid 可内联)`);
|
|
71
|
+
}
|
|
72
|
+
const dropped = [features.math ? null : 'KaTeX', features.mermaid ? null : 'Mermaid'].filter(Boolean);
|
|
73
|
+
if (dropped.length) console.log(`⚡ ${entry} 省略未使用的渲染器:${dropped.join('、')}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await build({
|
|
77
|
+
root: rootDir,
|
|
78
|
+
define,
|
|
79
|
+
build: {
|
|
80
|
+
outDir: 'dist',
|
|
81
|
+
// dist is cleared once up front; parallel carriers must not wipe each
|
|
82
|
+
// other's output mid-build.
|
|
83
|
+
emptyOutDir: false,
|
|
84
|
+
rollupOptions: { input: path.resolve(rootDir, entry) },
|
|
85
|
+
}
|
|
86
|
+
});
|
|
40
87
|
}
|
|
41
88
|
|
|
42
89
|
async function runBuild() {
|
|
43
90
|
console.log('🚀 开始构建 Concept Atlas 知识讲解页面...');
|
|
44
91
|
|
|
45
92
|
try {
|
|
46
|
-
//
|
|
93
|
+
// Clear dist once, then let every carrier write into it concurrently.
|
|
47
94
|
const distDir = path.resolve(rootDir, 'dist');
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
95
|
+
fs.rmSync(distDir, { recursive: true, force: true });
|
|
96
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
51
97
|
|
|
52
98
|
const mode = process.env.CONCEPT_ATLAS_MODE;
|
|
53
99
|
const carriers = mode === 'atlas' ? ['index.html'] : mode === 'scroll' ? ['scroll.html'] : ['index.html', 'scroll.html'];
|
|
@@ -56,22 +102,10 @@ async function runBuild() {
|
|
|
56
102
|
console.log(`🎨 默认外观:skin=${define.__ATLAS_DEFAULT_SKIN__ || '(carrier 默认)'} mode=${define.__ATLAS_DEFAULT_MODE__ || '(carrier 默认)'}`);
|
|
57
103
|
}
|
|
58
104
|
|
|
59
|
-
// vite-plugin-singlefile supports one HTML input per build
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const entryDefine = title ? { ...define, __ATLAS_PAGE_TITLE__: JSON.stringify(title) } : define;
|
|
64
|
-
if (title) console.log(`🔖 ${entry} 标签页标题:${title}`);
|
|
65
|
-
await build({
|
|
66
|
-
root: rootDir,
|
|
67
|
-
define: entryDefine,
|
|
68
|
-
build: {
|
|
69
|
-
outDir: 'dist',
|
|
70
|
-
emptyOutDir: index === 0,
|
|
71
|
-
rollupOptions: { input: path.resolve(rootDir, entry) },
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
}
|
|
105
|
+
// vite-plugin-singlefile supports one HTML input per build and emits no
|
|
106
|
+
// shared assets, so the carriers are independent and build in parallel
|
|
107
|
+
// instead of one full pass after another.
|
|
108
|
+
await Promise.all(carriers.map(entry => buildCarrier(entry, define)));
|
|
75
109
|
|
|
76
110
|
console.log(`✅ 构建成功!产物已生成到 ${carriers.map(entry => `dist/${entry}`).join(' 和 ')}。`);
|
|
77
111
|
} catch (err) {
|
package/template/src/app/App.jsx
CHANGED
|
@@ -3,11 +3,19 @@ import { Network, Compass, Sun, Moon, Search, X, Link as LinkIcon } from 'lucide
|
|
|
3
3
|
import { buildGraphModel } from '../model/concept-schema.js';
|
|
4
4
|
import { extractConceptData } from '../model/normalize-content.js';
|
|
5
5
|
import { useAppearance } from './use-appearance.js';
|
|
6
|
+
import { pushNode, stepHistory, syncFromLocation } from './navigation.js';
|
|
7
|
+
import { searchNodes } from './search.js';
|
|
8
|
+
import { NODE_KINDS } from '../model/node-kinds.js';
|
|
6
9
|
import { SkinPicker } from '../components/SkinPicker.jsx';
|
|
7
10
|
import { NodeExplorer } from '../views/NodeExplorer.jsx';
|
|
8
11
|
import { RelationGraph } from '../views/RelationGraph.jsx';
|
|
9
12
|
import '../styles/concept-explain.css';
|
|
10
13
|
|
|
14
|
+
function readNodeFromHash() {
|
|
15
|
+
if (typeof window === 'undefined') return null;
|
|
16
|
+
return new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
export function App({ mdxContent, initialData }) {
|
|
12
20
|
// Extract graph model from MDX JSX Element or raw data
|
|
13
21
|
const [graph] = useState(() => {
|
|
@@ -28,54 +36,41 @@ export function App({ mdxContent, initialData }) {
|
|
|
28
36
|
|
|
29
37
|
// Global shared state
|
|
30
38
|
const [currentView, setCurrentView] = useState('explore'); // 'explore' | 'graph'
|
|
31
|
-
const
|
|
32
|
-
const hashNode =
|
|
33
|
-
return hashNode && graph.nodes.has(hashNode) ? hashNode :
|
|
34
|
-
});
|
|
39
|
+
const initialHashNode = (() => {
|
|
40
|
+
const hashNode = readNodeFromHash();
|
|
41
|
+
return hashNode && graph.nodes.has(hashNode) ? hashNode : null;
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
const [currentNodeId, setCurrentNodeId] = useState(
|
|
45
|
+
() => initialHashNode || graph.meta.rootId || '',
|
|
46
|
+
);
|
|
35
47
|
const [selectedLevel, setSelectedLevel] = useState(null);
|
|
36
|
-
const [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
40
|
-
const [historyIndex, setHistoryIndex] = useState(0);
|
|
48
|
+
const [nav, setNav] = useState(() => ({
|
|
49
|
+
entries: (initialHashNode ? [initialHashNode] : [graph.meta.rootId]).filter(Boolean),
|
|
50
|
+
index: 0,
|
|
51
|
+
}));
|
|
41
52
|
const [globalQuery, setGlobalQuery] = useState('');
|
|
42
53
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
43
54
|
|
|
44
|
-
const searchResults = useMemo(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const searchable = [
|
|
49
|
-
node.title, node.id, node.summary, node.definition, node.overview,
|
|
50
|
-
node.mechanism, node.input, node.output,
|
|
51
|
-
...(node.examples || []).flatMap(item => [item.title, item.content]),
|
|
52
|
-
...(node.glossary || []).flatMap(item => [item.term, item.definition]),
|
|
53
|
-
...(node.boundaries || []).flatMap(item => [item.title, item.content]),
|
|
54
|
-
].filter(value => typeof value === 'string').join(' ').toLowerCase();
|
|
55
|
-
return searchable.includes(query) ? node : null;
|
|
56
|
-
}).filter(Boolean).slice(0, 8);
|
|
57
|
-
}, [globalQuery, graph.nodes]);
|
|
55
|
+
const searchResults = useMemo(
|
|
56
|
+
() => searchNodes(Array.from(graph.nodes.values()), globalQuery),
|
|
57
|
+
[globalQuery, graph.nodes],
|
|
58
|
+
);
|
|
58
59
|
|
|
59
60
|
const navigateToNode = (nodeId, { replace = false } = {}) => {
|
|
60
61
|
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
61
62
|
setCurrentNodeId(nodeId);
|
|
62
|
-
|
|
63
|
-
const base = previous.slice(0, historyIndex + 1);
|
|
64
|
-
if (base[base.length - 1] === nodeId) return previous;
|
|
65
|
-
const next = [...base, nodeId];
|
|
66
|
-
setHistoryIndex(next.length - 1);
|
|
67
|
-
return next;
|
|
68
|
-
});
|
|
63
|
+
setNav(previous => pushNode(previous, nodeId));
|
|
69
64
|
const nextHash = `#node=${encodeURIComponent(nodeId)}`;
|
|
70
65
|
if (replace) window.history.replaceState({}, '', nextHash);
|
|
71
66
|
else window.history.pushState({}, '', nextHash);
|
|
72
67
|
};
|
|
73
68
|
|
|
74
69
|
const moveHistory = (direction) => {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
const nodeId =
|
|
70
|
+
const next = stepHistory(nav, direction);
|
|
71
|
+
if (!next) return;
|
|
72
|
+
setNav(next);
|
|
73
|
+
const nodeId = next.entries[next.index];
|
|
79
74
|
setCurrentNodeId(nodeId);
|
|
80
75
|
window.history.pushState({}, '', `#node=${encodeURIComponent(nodeId)}`);
|
|
81
76
|
};
|
|
@@ -83,10 +78,26 @@ export function App({ mdxContent, initialData }) {
|
|
|
83
78
|
// Keyboard navigation shortcuts
|
|
84
79
|
useEffect(() => {
|
|
85
80
|
const handleKeyDown = (e) => {
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
if (e.isComposing) return;
|
|
82
|
+
|
|
83
|
+
// Alt + arrows move through browsing history. Handled before the modifier
|
|
84
|
+
// guard below, otherwise the altKey short-circuit makes them unreachable.
|
|
85
|
+
if (e.altKey && e.key === 'ArrowLeft') {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
moveHistory(-1);
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
90
|
+
if (e.altKey && e.key === 'ArrowRight') {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
moveHistory(1);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Toggle views with 1 and 2 or 'g' and 'e' if not focused on input.
|
|
97
|
+
const target = document.activeElement;
|
|
98
|
+
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
|
|
99
|
+
if (e.metaKey || e.ctrlKey || e.altKey || typing) return;
|
|
100
|
+
|
|
90
101
|
const key = e.key.toLowerCase();
|
|
91
102
|
if (e.key === '1' || key === 'e') {
|
|
92
103
|
setCurrentView('explore');
|
|
@@ -95,18 +106,7 @@ export function App({ mdxContent, initialData }) {
|
|
|
95
106
|
} else if (key === 't') {
|
|
96
107
|
toggleTheme();
|
|
97
108
|
} else if (e.key === 'Escape') {
|
|
98
|
-
//
|
|
99
|
-
if (e.altKey && e.key === 'ArrowLeft') {
|
|
100
|
-
e.preventDefault();
|
|
101
|
-
moveHistory(-1);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
if (e.altKey && e.key === 'ArrowRight') {
|
|
105
|
-
e.preventDefault();
|
|
106
|
-
moveHistory(1);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
// Return to root or parent
|
|
109
|
+
// Return to the parent node.
|
|
110
110
|
const curr = graph.nodes.get(currentNodeId);
|
|
111
111
|
if (curr && curr.parent) {
|
|
112
112
|
navigateToNode(curr.parent);
|
|
@@ -116,23 +116,14 @@ export function App({ mdxContent, initialData }) {
|
|
|
116
116
|
|
|
117
117
|
window.addEventListener('keydown', handleKeyDown);
|
|
118
118
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
119
|
-
}, [graph, currentNodeId,
|
|
119
|
+
}, [graph, currentNodeId, nav]);
|
|
120
120
|
|
|
121
121
|
useEffect(() => {
|
|
122
122
|
const handlePopState = () => {
|
|
123
|
-
const nodeId =
|
|
123
|
+
const nodeId = readNodeFromHash();
|
|
124
124
|
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
125
125
|
setCurrentNodeId(nodeId);
|
|
126
|
-
|
|
127
|
-
const index = previous.lastIndexOf(nodeId);
|
|
128
|
-
if (index >= 0) {
|
|
129
|
-
setHistoryIndex(index);
|
|
130
|
-
return previous;
|
|
131
|
-
}
|
|
132
|
-
const next = [...previous, nodeId];
|
|
133
|
-
setHistoryIndex(next.length - 1);
|
|
134
|
-
return next;
|
|
135
|
-
});
|
|
126
|
+
setNav(previous => syncFromLocation(previous, nodeId));
|
|
136
127
|
};
|
|
137
128
|
window.addEventListener('popstate', handlePopState);
|
|
138
129
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
@@ -176,7 +167,7 @@ export function App({ mdxContent, initialData }) {
|
|
|
176
167
|
<div className="global-search-results" role="listbox">
|
|
177
168
|
{searchResults.map(node => (
|
|
178
169
|
<button type="button" key={node.id} onClick={() => { navigateToNode(node.id); setGlobalQuery(''); }} role="option">
|
|
179
|
-
<span>{node.title}</span><small>{node.level} · {node.summary || node.id}</small>
|
|
170
|
+
<span>{node.title}</span><small>{node.level}{node.kind && NODE_KINDS[node.kind] ? ` · ${NODE_KINDS[node.kind].label}` : ''} · {node.summary || node.id}</small>
|
|
180
171
|
</button>
|
|
181
172
|
))}
|
|
182
173
|
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure browsing-history helpers for the atlas carrier.
|
|
3
|
+
*
|
|
4
|
+
* History is a single `{ entries, index }` value instead of two separate
|
|
5
|
+
* `useState` calls. Keeping them together removes the side effect that used to
|
|
6
|
+
* live inside the `setHistory` updater (which React StrictMode can double
|
|
7
|
+
* invoke) and makes the whole navigation model trivially unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Push a node onto the history, truncating any forward entries.
|
|
12
|
+
* Returns the same object when `nodeId` is already the current entry so React
|
|
13
|
+
* can bail out of the state update.
|
|
14
|
+
*/
|
|
15
|
+
export function pushNode(state, nodeId) {
|
|
16
|
+
const base = state.entries.slice(0, state.index + 1);
|
|
17
|
+
if (base[base.length - 1] === nodeId) return state;
|
|
18
|
+
const entries = [...base, nodeId];
|
|
19
|
+
return { entries, index: entries.length - 1 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Move one step through the history. Returns `null` when the move would leave
|
|
24
|
+
* the bounds, so the caller can avoid a redundant re-render.
|
|
25
|
+
*/
|
|
26
|
+
export function stepHistory(state, direction) {
|
|
27
|
+
const nextIndex = Math.max(0, Math.min(state.entries.length - 1, state.index + direction));
|
|
28
|
+
if (nextIndex === state.index) return null;
|
|
29
|
+
return { entries: state.entries, index: nextIndex };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a node id arriving from a `popstate` event (browser back/forward).
|
|
34
|
+
* Reuses an existing entry when present so the back stack stays consistent.
|
|
35
|
+
*/
|
|
36
|
+
export function syncFromLocation(state, nodeId) {
|
|
37
|
+
const existing = state.entries.lastIndexOf(nodeId);
|
|
38
|
+
if (existing >= 0) return { entries: state.entries, index: existing };
|
|
39
|
+
const entries = [...state.entries, nodeId];
|
|
40
|
+
return { entries, index: entries.length - 1 };
|
|
41
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { NODE_KINDS } from '../model/node-kinds.js';
|
|
3
|
+
|
|
4
|
+
const MAX_DEPTH = 6;
|
|
5
|
+
const MAX_LENGTH = 4000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Flatten whatever an MDX component renders — strings, React elements, arrays
|
|
9
|
+
* and prop objects — into searchable plain text. Bounded in depth and length so
|
|
10
|
+
* a pathological tree cannot stall the search box.
|
|
11
|
+
*/
|
|
12
|
+
export function collectText(value, depth = 0) {
|
|
13
|
+
if (depth > MAX_DEPTH || value === null || value === undefined || typeof value === 'boolean') return '';
|
|
14
|
+
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
return value.map(item => collectText(item, depth + 1)).join(' ');
|
|
17
|
+
}
|
|
18
|
+
if (React.isValidElement(value)) {
|
|
19
|
+
const props = value.props || {};
|
|
20
|
+
const parts = [];
|
|
21
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
22
|
+
// `components` is the MDX component registry, never content.
|
|
23
|
+
if (key === 'children' || key === 'components') continue;
|
|
24
|
+
if (typeof prop === 'string' || typeof prop === 'number') parts.push(String(prop));
|
|
25
|
+
else if (prop && typeof prop === 'object') parts.push(collectText(prop, depth + 1));
|
|
26
|
+
}
|
|
27
|
+
parts.push(collectText(props.children, depth + 1));
|
|
28
|
+
return parts.join(' ');
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === 'object') {
|
|
31
|
+
return Object.values(value).map(item => collectText(item, depth + 1)).join(' ');
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build one lowercase haystack per node. Includes the semantic content fields,
|
|
38
|
+
* the structured argument/evidence records (Evidence, Invariant, FailureMode,
|
|
39
|
+
* Tradeoff, …) and the free-form `customSections` that the same components also
|
|
40
|
+
* render through. The kind id and its label are indexed too, so "故障" finds
|
|
41
|
+
* `kind="failure"` nodes.
|
|
42
|
+
*/
|
|
43
|
+
export function nodeSearchText(node) {
|
|
44
|
+
if (!node) return '';
|
|
45
|
+
const kind = node.kind ? NODE_KINDS[node.kind] : null;
|
|
46
|
+
const parts = [
|
|
47
|
+
node.title,
|
|
48
|
+
node.id,
|
|
49
|
+
node.kind,
|
|
50
|
+
kind ? kind.label : '',
|
|
51
|
+
node.summary,
|
|
52
|
+
node.overview,
|
|
53
|
+
node.definition,
|
|
54
|
+
node.mechanism,
|
|
55
|
+
node.input,
|
|
56
|
+
node.output,
|
|
57
|
+
node.implementation,
|
|
58
|
+
node.prerequisites,
|
|
59
|
+
node.examples,
|
|
60
|
+
node.counterexamples,
|
|
61
|
+
node.boundaries,
|
|
62
|
+
node.glossary,
|
|
63
|
+
node.learningObjectives,
|
|
64
|
+
node.keyQuestions,
|
|
65
|
+
node.evidence,
|
|
66
|
+
node.invariants,
|
|
67
|
+
node.failureModes,
|
|
68
|
+
node.tradeoffs,
|
|
69
|
+
node.customSections,
|
|
70
|
+
];
|
|
71
|
+
return parts.map(part => collectText(part)).join(' ').slice(0, MAX_LENGTH).toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rank-free substring search returning at most `limit` nodes. */
|
|
75
|
+
export function searchNodes(nodes, query, limit = 8) {
|
|
76
|
+
const needle = String(query || '').trim().toLowerCase();
|
|
77
|
+
if (!needle) return [];
|
|
78
|
+
const results = [];
|
|
79
|
+
for (const node of nodes) {
|
|
80
|
+
if (nodeSearchText(node).includes(needle)) results.push(node);
|
|
81
|
+
if (results.length >= limit) break;
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
@@ -70,7 +70,7 @@ function configureMermaid() {
|
|
|
70
70
|
lineColor: read('--mermaid-line', MERMAID_FALLBACKS.lineColor),
|
|
71
71
|
secondaryColor: nodeBg,
|
|
72
72
|
tertiaryColor: read('--mermaid-canvas', MERMAID_FALLBACKS.tertiaryColor),
|
|
73
|
-
fontFamily: 'Plus Jakarta Sans, sans-serif
|
|
73
|
+
fontFamily: read('--font-sans', "'Plus Jakarta Sans', sans-serif"),
|
|
74
74
|
},
|
|
75
75
|
});
|
|
76
76
|
}
|
|
@@ -942,6 +942,7 @@ export function Chart({ title = '图表', type = 'bar', data = [], series = [],
|
|
|
942
942
|
<g key={seriesIndex}>
|
|
943
943
|
<polyline
|
|
944
944
|
className="chart-line"
|
|
945
|
+
pathLength="1"
|
|
945
946
|
style={chartColor(seriesIndex)}
|
|
946
947
|
points={entry.values.map((value, idx) => `${xFor(idx)},${yFor(value)}`).join(' ')}
|
|
947
948
|
/>
|