| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- 'use client';
- import { useId } from 'react';
- import {
- getLessonDiagrams,
- getModuleDiagrams,
- type DiagramEdge,
- type DiagramNode,
- type DiagramPoint,
- type TeachingDiagram,
- } from './diagram-catalog';
- import './teaching-diagrams.css';
- export { hasLessonDiagrams } from './diagram-catalog';
- const NODE_WIDTH = 210;
- const NODE_HEIGHT = 104;
- // Wide glyphs need more space than Latin glyphs. Never clip educational text.
- function linesFor(text: string, maxUnits: number) {
- const lines: string[] = [];
- let line = '';
- let units = 0;
- for (const character of text) {
- const width = /[\u0020-\u007e]/.test(character) ? 1 : 2;
- if (units + width > maxUnits && line) {
- lines.push(line.trim());
- line = '';
- units = 0;
- }
- line += character;
- units += width;
- }
- if (line) lines.push(line.trim());
- return lines;
- }
- function port(
- node: DiagramNode,
- side: NonNullable<DiagramEdge['fromPort']>,
- ): DiagramPoint {
- if (side === 'left') return [node.x, node.y + NODE_HEIGHT / 2];
- if (side === 'right') return [node.x + NODE_WIDTH, node.y + NODE_HEIGHT / 2];
- if (side === 'top') return [node.x + NODE_WIDTH / 2, node.y];
- return [node.x + NODE_WIDTH / 2, node.y + NODE_HEIGHT];
- }
- function edgeGeometry(edge: DiagramEdge, nodes: DiagramNode[]) {
- const source = nodes.find((node) => node.id === edge.from)!;
- const target = nodes.find((node) => node.id === edge.to)!;
- const sameRow = source.y === target.y;
- const sameColumn = source.x === target.x;
- const right = target.x > source.x;
- const down = target.y > source.y;
- const fromSide =
- edge.fromPort ??
- (sameRow ? (right ? 'right' : 'left') : down ? 'bottom' : 'top');
- const toSide =
- edge.toPort ??
- (sameRow ? (right ? 'left' : 'right') : down ? 'top' : 'bottom');
- const start = port(source, fromSide);
- const end = port(target, toSide);
- const middleY = (start[1] + end[1]) / 2;
- const via =
- edge.via ??
- (sameRow || sameColumn
- ? []
- : ([
- [start[0], middleY],
- [end[0], middleY],
- ] as DiagramPoint[]));
- const points: DiagramPoint[] = [start, ...via, end];
- const path = points
- .map(([x, y], index) => `${index ? 'L' : 'M'} ${x} ${y}`)
- .join(' ');
- const labelAt =
- edge.labelAt ??
- (sameRow || sameColumn
- ? ([(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as DiagramPoint)
- : ([(start[0] + end[0]) / 2, middleY] as DiagramPoint));
- const vertical = sameColumn && !edge.via;
- return { path, labelAt, vertical };
- }
- function DiagramFigure({
- diagram,
- index,
- }: {
- diagram: TeachingDiagram;
- index: number;
- }) {
- const reactId = useId().replace(/:/g, '');
- const instanceId = `teaching-${diagram.id}-${reactId}`;
- const titleId = `${instanceId}-title`;
- const descriptionId = `${instanceId}-description`;
- const height = diagram.rows.length * 185 + 60;
- return (
- <figure
- className="teaching-figure"
- aria-labelledby={`${instanceId}-heading`}
- >
- <figcaption className="teaching-caption">
- <span className="teaching-number">
- 图解 {String(index + 1).padStart(2, '0')}
- </span>
- <h3 id={`${instanceId}-heading`}>{diagram.title}</h3>
- <p>{diagram.summary}</p>
- </figcaption>
- <ol className="teaching-row-guide" aria-label="图中区域说明">
- {diagram.rows.map((label, row) => (
- <li key={label}>
- <span>区域 {row + 1}</span>
- {label}
- </li>
- ))}
- </ol>
- {/* oxlint-disable jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to focus and scroll the full-size diagram. */}
- <section
- className="teaching-scroll"
- tabIndex={0}
- aria-label={`${diagram.title},窄屏可横向滚动;下方有完整文字说明`}
- >
- {/* oxlint-disable jsx-a11y/prefer-tag-over-role -- Inline SVG needs image semantics and native title/desc labels. */}
- <svg
- className="teaching-svg"
- viewBox={`0 0 900 ${height}`}
- role="img"
- aria-labelledby={`${titleId} ${descriptionId}`}
- >
- {/* oxlint-enable jsx-a11y/no-noninteractive-tabindex, jsx-a11y/prefer-tag-over-role */}
- <title id={titleId}>{diagram.title}</title>
- <desc id={descriptionId}>
- {diagram.summary}{' '}
- {diagram.edges
- .map(
- (edge) =>
- `${diagram.nodes.find((node) => node.id === edge.from)?.title},${edge.label},到${diagram.nodes.find((node) => node.id === edge.to)?.title}。`,
- )
- .join(' ')}{' '}
- 完整步骤与边界说明见图下文字。
- </desc>
- <defs>
- {(['sync', 'async', 'risk'] as const).map((kind) => (
- <marker
- key={kind}
- id={`${instanceId}-${kind}`}
- viewBox="0 0 10 10"
- refX="9"
- refY="5"
- markerWidth="7"
- markerHeight="7"
- orient="auto-start-reverse"
- >
- <path
- d="M 1 1 L 9 5 L 1 9 Z"
- className={`teaching-arrow-${kind}`}
- />
- </marker>
- ))}
- </defs>
- {diagram.rows.map((label, row) => (
- <g key={label}>
- <rect
- x="12"
- y={row * 185 + 7}
- width="876"
- height="173"
- rx="10"
- className={
- row % 2
- ? 'teaching-lane teaching-lane-secondary'
- : 'teaching-lane'
- }
- />
- <text x="30" y={row * 185 + 30} className="teaching-row-title">
- 区域 {row + 1}
- </text>
- </g>
- ))}
- {diagram.edges.map((edge, edgeIndex) => {
- const geometry = edgeGeometry(edge, diagram.nodes);
- return (
- <path
- key={`path-${edgeIndex}`}
- d={geometry.path}
- className={`teaching-edge teaching-edge-${edge.kind}`}
- markerEnd={`url(#${instanceId}-${edge.kind})`}
- />
- );
- })}
- {diagram.nodes.map((node) => {
- const titleLines = linesFor(node.title, 24);
- const detailLines = linesFor(node.detail, 29);
- return (
- <g
- key={node.id}
- className={`teaching-node teaching-node-${node.tone}`}
- >
- <rect
- x={node.x}
- y={node.y}
- width={NODE_WIDTH}
- height={NODE_HEIGHT}
- rx="9"
- />
- <rect
- x={node.x}
- y={node.y + 15}
- width="3"
- height={NODE_HEIGHT - 30}
- rx="1.5"
- className="teaching-node-accent"
- />
- <text
- x={node.x + 16}
- y={node.y + 26}
- className="teaching-node-title"
- >
- {titleLines.map((line, lineIndex) => (
- <tspan
- key={lineIndex}
- x={node.x + 16}
- dy={lineIndex ? 19 : 0}
- >
- {line}
- </tspan>
- ))}
- </text>
- <text
- x={node.x + 16}
- y={node.y + 26 + titleLines.length * 19 + 5}
- className="teaching-node-detail"
- >
- {detailLines.map((line, lineIndex) => (
- <tspan
- key={lineIndex}
- x={node.x + 16}
- dy={lineIndex ? 16 : 0}
- >
- {line}
- </tspan>
- ))}
- </text>
- </g>
- );
- })}
- {diagram.edges.map((edge, edgeIndex) => {
- const { labelAt, vertical } = edgeGeometry(edge, diagram.nodes);
- const labelLines = linesFor(edge.label, vertical ? 20 : 17);
- const x = labelAt[0] + (vertical ? 10 : 0);
- const y =
- labelAt[1] -
- (vertical
- ? (labelLines.length - 1) * 7
- : 10 + (labelLines.length - 1) * 14);
- return (
- <text
- key={`label-${edgeIndex}`}
- x={x}
- y={y}
- textAnchor={vertical ? 'start' : 'middle'}
- className={`teaching-edge-label teaching-edge-label-${edge.kind}`}
- >
- {labelLines.map((line, lineIndex) => (
- <tspan key={lineIndex} x={x} dy={lineIndex ? 14 : 0}>
- {line}
- </tspan>
- ))}
- </text>
- );
- })}
- </svg>
- </section>
- <div className="teaching-legend" aria-label="图例">
- <span>
- <i className="teaching-legend-sync" aria-hidden="true" />
- 调用 / 数据或状态流向
- </span>
- {diagram.edges.some((edge) => edge.kind === 'async') && (
- <span>
- <i className="teaching-legend-async" aria-hidden="true" />
- 异步交接 / 后续处理
- </span>
- )}
- {diagram.edges.some((edge) => edge.kind === 'risk') && (
- <span>
- <i className="teaching-legend-risk" aria-hidden="true" />
- 失败 / 恢复分支
- </span>
- )}
- <span>沿箭头与编号阅读,颜色仅辅助区分</span>
- </div>
- <div className="teaching-explanation">
- <h4>顺着图走一遍</h4>
- <ol>
- {diagram.steps.map((step, stepIndex) => (
- <li key={stepIndex}>{step}</li>
- ))}
- </ol>
- <p className="teaching-misconception">
- <strong>容易误解</strong>
- {diagram.misconception}
- </p>
- {diagram.source && (
- <a
- className="teaching-source"
- href={diagram.source.url}
- target="_blank"
- rel="noreferrer"
- >
- 进一步核对:{diagram.source.label}
- <span aria-hidden="true"> ↗</span>
- </a>
- )}
- </div>
- </figure>
- );
- }
- export function ModuleDiagrams({ module }: { module: string }) {
- const diagrams = getModuleDiagrams(module);
- if (!diagrams.length) return null;
- return (
- <section
- id="module-diagrams"
- className="teaching-diagrams"
- aria-labelledby="module-diagrams-heading"
- >
- <div className="teaching-section-heading">
- <span>先建立画面</span>
- <h2 id="module-diagrams-heading">这个模块怎样与其他组件协作</h2>
- </div>
- <p className="teaching-section-intro">
- 先认清谁接收请求、谁保存数据、谁负责执行,再进入每节课理解细节。相关课时中还有针对具体机制的图解。
- </p>
- {diagrams.map((diagram, index) => (
- <DiagramFigure key={diagram.id} diagram={diagram} index={index} />
- ))}
- </section>
- );
- }
- export function LessonDiagrams({
- module,
- lesson,
- }: {
- module: string;
- lesson: string;
- }) {
- const diagrams = getLessonDiagrams(module, lesson);
- if (!diagrams.length) return null;
- return (
- <section
- id="diagrams"
- className="teaching-diagrams"
- aria-labelledby="lesson-diagrams-heading"
- >
- <div className="teaching-section-heading">
- <span>做过之后,再看整体</span>
- <h2 id="lesson-diagrams-heading">
- {module === 'database' ? '回看数据设计与事务' : '把刚才的过程连起来'}
- </h2>
- </div>
- <p className="teaching-section-intro">
- {module === 'database'
- ? '结合表关系与事务流程,理解数据怎样组织、约束在哪里生效,以及修改何时提交。'
- : '箭头表示信息或控制如何传递;下方步骤说明等待、交接和失败时分别由谁负责。'}
- </p>
- {diagrams.map((diagram, index) => (
- <DiagramFigure key={diagram.id} diagram={diagram} index={index} />
- ))}
- </section>
- );
- }
|