teaching-diagrams.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. 'use client';
  2. import { useId } from 'react';
  3. import {
  4. getLessonDiagrams,
  5. getModuleDiagrams,
  6. type DiagramEdge,
  7. type DiagramNode,
  8. type DiagramPoint,
  9. type TeachingDiagram,
  10. } from './diagram-catalog';
  11. import './teaching-diagrams.css';
  12. export { hasLessonDiagrams } from './diagram-catalog';
  13. const NODE_WIDTH = 210;
  14. const NODE_HEIGHT = 104;
  15. // Wide glyphs need more space than Latin glyphs. Never clip educational text.
  16. function linesFor(text: string, maxUnits: number) {
  17. const lines: string[] = [];
  18. let line = '';
  19. let units = 0;
  20. for (const character of text) {
  21. const width = /[\u0020-\u007e]/.test(character) ? 1 : 2;
  22. if (units + width > maxUnits && line) {
  23. lines.push(line.trim());
  24. line = '';
  25. units = 0;
  26. }
  27. line += character;
  28. units += width;
  29. }
  30. if (line) lines.push(line.trim());
  31. return lines;
  32. }
  33. function port(
  34. node: DiagramNode,
  35. side: NonNullable<DiagramEdge['fromPort']>,
  36. ): DiagramPoint {
  37. if (side === 'left') return [node.x, node.y + NODE_HEIGHT / 2];
  38. if (side === 'right') return [node.x + NODE_WIDTH, node.y + NODE_HEIGHT / 2];
  39. if (side === 'top') return [node.x + NODE_WIDTH / 2, node.y];
  40. return [node.x + NODE_WIDTH / 2, node.y + NODE_HEIGHT];
  41. }
  42. function edgeGeometry(edge: DiagramEdge, nodes: DiagramNode[]) {
  43. const source = nodes.find((node) => node.id === edge.from)!;
  44. const target = nodes.find((node) => node.id === edge.to)!;
  45. const sameRow = source.y === target.y;
  46. const sameColumn = source.x === target.x;
  47. const right = target.x > source.x;
  48. const down = target.y > source.y;
  49. const fromSide =
  50. edge.fromPort ??
  51. (sameRow ? (right ? 'right' : 'left') : down ? 'bottom' : 'top');
  52. const toSide =
  53. edge.toPort ??
  54. (sameRow ? (right ? 'left' : 'right') : down ? 'top' : 'bottom');
  55. const start = port(source, fromSide);
  56. const end = port(target, toSide);
  57. const middleY = (start[1] + end[1]) / 2;
  58. const via =
  59. edge.via ??
  60. (sameRow || sameColumn
  61. ? []
  62. : ([
  63. [start[0], middleY],
  64. [end[0], middleY],
  65. ] as DiagramPoint[]));
  66. const points: DiagramPoint[] = [start, ...via, end];
  67. const path = points
  68. .map(([x, y], index) => `${index ? 'L' : 'M'} ${x} ${y}`)
  69. .join(' ');
  70. const labelAt =
  71. edge.labelAt ??
  72. (sameRow || sameColumn
  73. ? ([(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as DiagramPoint)
  74. : ([(start[0] + end[0]) / 2, middleY] as DiagramPoint));
  75. const vertical = sameColumn && !edge.via;
  76. return { path, labelAt, vertical };
  77. }
  78. function DiagramFigure({
  79. diagram,
  80. index,
  81. }: {
  82. diagram: TeachingDiagram;
  83. index: number;
  84. }) {
  85. const reactId = useId().replace(/:/g, '');
  86. const instanceId = `teaching-${diagram.id}-${reactId}`;
  87. const titleId = `${instanceId}-title`;
  88. const descriptionId = `${instanceId}-description`;
  89. const height = diagram.rows.length * 185 + 60;
  90. return (
  91. <figure
  92. className="teaching-figure"
  93. aria-labelledby={`${instanceId}-heading`}
  94. >
  95. <figcaption className="teaching-caption">
  96. <span className="teaching-number">
  97. 图解 {String(index + 1).padStart(2, '0')}
  98. </span>
  99. <h3 id={`${instanceId}-heading`}>{diagram.title}</h3>
  100. <p>{diagram.summary}</p>
  101. </figcaption>
  102. <ol className="teaching-row-guide" aria-label="图中区域说明">
  103. {diagram.rows.map((label, row) => (
  104. <li key={label}>
  105. <span>区域 {row + 1}</span>
  106. {label}
  107. </li>
  108. ))}
  109. </ol>
  110. {/* oxlint-disable jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to focus and scroll the full-size diagram. */}
  111. <section
  112. className="teaching-scroll"
  113. tabIndex={0}
  114. aria-label={`${diagram.title},窄屏可横向滚动;下方有完整文字说明`}
  115. >
  116. {/* oxlint-disable jsx-a11y/prefer-tag-over-role -- Inline SVG needs image semantics and native title/desc labels. */}
  117. <svg
  118. className="teaching-svg"
  119. viewBox={`0 0 900 ${height}`}
  120. role="img"
  121. aria-labelledby={`${titleId} ${descriptionId}`}
  122. >
  123. {/* oxlint-enable jsx-a11y/no-noninteractive-tabindex, jsx-a11y/prefer-tag-over-role */}
  124. <title id={titleId}>{diagram.title}</title>
  125. <desc id={descriptionId}>
  126. {diagram.summary}{' '}
  127. {diagram.edges
  128. .map(
  129. (edge) =>
  130. `${diagram.nodes.find((node) => node.id === edge.from)?.title},${edge.label},到${diagram.nodes.find((node) => node.id === edge.to)?.title}。`,
  131. )
  132. .join(' ')}{' '}
  133. 完整步骤与边界说明见图下文字。
  134. </desc>
  135. <defs>
  136. {(['sync', 'async', 'risk'] as const).map((kind) => (
  137. <marker
  138. key={kind}
  139. id={`${instanceId}-${kind}`}
  140. viewBox="0 0 10 10"
  141. refX="9"
  142. refY="5"
  143. markerWidth="7"
  144. markerHeight="7"
  145. orient="auto-start-reverse"
  146. >
  147. <path
  148. d="M 1 1 L 9 5 L 1 9 Z"
  149. className={`teaching-arrow-${kind}`}
  150. />
  151. </marker>
  152. ))}
  153. </defs>
  154. {diagram.rows.map((label, row) => (
  155. <g key={label}>
  156. <rect
  157. x="12"
  158. y={row * 185 + 7}
  159. width="876"
  160. height="173"
  161. rx="10"
  162. className={
  163. row % 2
  164. ? 'teaching-lane teaching-lane-secondary'
  165. : 'teaching-lane'
  166. }
  167. />
  168. <text x="30" y={row * 185 + 30} className="teaching-row-title">
  169. 区域 {row + 1}
  170. </text>
  171. </g>
  172. ))}
  173. {diagram.edges.map((edge, edgeIndex) => {
  174. const geometry = edgeGeometry(edge, diagram.nodes);
  175. return (
  176. <path
  177. key={`path-${edgeIndex}`}
  178. d={geometry.path}
  179. className={`teaching-edge teaching-edge-${edge.kind}`}
  180. markerEnd={`url(#${instanceId}-${edge.kind})`}
  181. />
  182. );
  183. })}
  184. {diagram.nodes.map((node) => {
  185. const titleLines = linesFor(node.title, 24);
  186. const detailLines = linesFor(node.detail, 29);
  187. return (
  188. <g
  189. key={node.id}
  190. className={`teaching-node teaching-node-${node.tone}`}
  191. >
  192. <rect
  193. x={node.x}
  194. y={node.y}
  195. width={NODE_WIDTH}
  196. height={NODE_HEIGHT}
  197. rx="9"
  198. />
  199. <rect
  200. x={node.x}
  201. y={node.y + 15}
  202. width="3"
  203. height={NODE_HEIGHT - 30}
  204. rx="1.5"
  205. className="teaching-node-accent"
  206. />
  207. <text
  208. x={node.x + 16}
  209. y={node.y + 26}
  210. className="teaching-node-title"
  211. >
  212. {titleLines.map((line, lineIndex) => (
  213. <tspan
  214. key={lineIndex}
  215. x={node.x + 16}
  216. dy={lineIndex ? 19 : 0}
  217. >
  218. {line}
  219. </tspan>
  220. ))}
  221. </text>
  222. <text
  223. x={node.x + 16}
  224. y={node.y + 26 + titleLines.length * 19 + 5}
  225. className="teaching-node-detail"
  226. >
  227. {detailLines.map((line, lineIndex) => (
  228. <tspan
  229. key={lineIndex}
  230. x={node.x + 16}
  231. dy={lineIndex ? 16 : 0}
  232. >
  233. {line}
  234. </tspan>
  235. ))}
  236. </text>
  237. </g>
  238. );
  239. })}
  240. {diagram.edges.map((edge, edgeIndex) => {
  241. const { labelAt, vertical } = edgeGeometry(edge, diagram.nodes);
  242. const labelLines = linesFor(edge.label, vertical ? 20 : 17);
  243. const x = labelAt[0] + (vertical ? 10 : 0);
  244. const y =
  245. labelAt[1] -
  246. (vertical
  247. ? (labelLines.length - 1) * 7
  248. : 10 + (labelLines.length - 1) * 14);
  249. return (
  250. <text
  251. key={`label-${edgeIndex}`}
  252. x={x}
  253. y={y}
  254. textAnchor={vertical ? 'start' : 'middle'}
  255. className={`teaching-edge-label teaching-edge-label-${edge.kind}`}
  256. >
  257. {labelLines.map((line, lineIndex) => (
  258. <tspan key={lineIndex} x={x} dy={lineIndex ? 14 : 0}>
  259. {line}
  260. </tspan>
  261. ))}
  262. </text>
  263. );
  264. })}
  265. </svg>
  266. </section>
  267. <div className="teaching-legend" aria-label="图例">
  268. <span>
  269. <i className="teaching-legend-sync" aria-hidden="true" />
  270. 调用 / 数据或状态流向
  271. </span>
  272. {diagram.edges.some((edge) => edge.kind === 'async') && (
  273. <span>
  274. <i className="teaching-legend-async" aria-hidden="true" />
  275. 异步交接 / 后续处理
  276. </span>
  277. )}
  278. {diagram.edges.some((edge) => edge.kind === 'risk') && (
  279. <span>
  280. <i className="teaching-legend-risk" aria-hidden="true" />
  281. 失败 / 恢复分支
  282. </span>
  283. )}
  284. <span>沿箭头与编号阅读,颜色仅辅助区分</span>
  285. </div>
  286. <div className="teaching-explanation">
  287. <h4>顺着图走一遍</h4>
  288. <ol>
  289. {diagram.steps.map((step, stepIndex) => (
  290. <li key={stepIndex}>{step}</li>
  291. ))}
  292. </ol>
  293. <p className="teaching-misconception">
  294. <strong>容易误解</strong>
  295. {diagram.misconception}
  296. </p>
  297. {diagram.source && (
  298. <a
  299. className="teaching-source"
  300. href={diagram.source.url}
  301. target="_blank"
  302. rel="noreferrer"
  303. >
  304. 进一步核对:{diagram.source.label}
  305. <span aria-hidden="true"> ↗</span>
  306. </a>
  307. )}
  308. </div>
  309. </figure>
  310. );
  311. }
  312. export function ModuleDiagrams({ module }: { module: string }) {
  313. const diagrams = getModuleDiagrams(module);
  314. if (!diagrams.length) return null;
  315. return (
  316. <section
  317. id="module-diagrams"
  318. className="teaching-diagrams"
  319. aria-labelledby="module-diagrams-heading"
  320. >
  321. <div className="teaching-section-heading">
  322. <span>先建立画面</span>
  323. <h2 id="module-diagrams-heading">这个模块怎样与其他组件协作</h2>
  324. </div>
  325. <p className="teaching-section-intro">
  326. 先认清谁接收请求、谁保存数据、谁负责执行,再进入每节课理解细节。相关课时中还有针对具体机制的图解。
  327. </p>
  328. {diagrams.map((diagram, index) => (
  329. <DiagramFigure key={diagram.id} diagram={diagram} index={index} />
  330. ))}
  331. </section>
  332. );
  333. }
  334. export function LessonDiagrams({
  335. module,
  336. lesson,
  337. }: {
  338. module: string;
  339. lesson: string;
  340. }) {
  341. const diagrams = getLessonDiagrams(module, lesson);
  342. if (!diagrams.length) return null;
  343. return (
  344. <section
  345. id="diagrams"
  346. className="teaching-diagrams"
  347. aria-labelledby="lesson-diagrams-heading"
  348. >
  349. <div className="teaching-section-heading">
  350. <span>做过之后,再看整体</span>
  351. <h2 id="lesson-diagrams-heading">
  352. {module === 'database' ? '回看数据设计与事务' : '把刚才的过程连起来'}
  353. </h2>
  354. </div>
  355. <p className="teaching-section-intro">
  356. {module === 'database'
  357. ? '结合表关系与事务流程,理解数据怎样组织、约束在哪里生效,以及修改何时提交。'
  358. : '箭头表示信息或控制如何传递;下方步骤说明等待、交接和失败时分别由谁负责。'}
  359. </p>
  360. {diagrams.map((diagram, index) => (
  361. <DiagramFigure key={diagram.id} diagram={diagram} index={index} />
  362. ))}
  363. </section>
  364. );
  365. }