@dooc-dev/dooc 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/README.md +26 -2
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +311 -0
  4. package/dist/code.global.js +285 -0
  5. package/dist/compiler/bundle-urls.d.ts +3 -0
  6. package/dist/compiler/bundle-urls.js +294 -0
  7. package/dist/compiler/bundle.d.ts +2 -0
  8. package/dist/compiler/bundle.js +100 -0
  9. package/dist/compiler/check-prose.d.ts +11 -0
  10. package/dist/compiler/check-prose.js +138 -0
  11. package/dist/compiler/check-types.d.ts +44 -0
  12. package/dist/compiler/check-types.js +1 -0
  13. package/dist/compiler/check.d.ts +5 -0
  14. package/dist/compiler/check.js +450 -0
  15. package/dist/compiler/code-blocks.d.ts +17 -0
  16. package/dist/compiler/code-blocks.js +198 -0
  17. package/dist/compiler/code-languages.d.ts +10 -0
  18. package/dist/compiler/code-languages.js +109 -0
  19. package/dist/compiler/compile.d.ts +95 -0
  20. package/dist/compiler/compile.js +410 -0
  21. package/dist/compiler/config.d.ts +7 -0
  22. package/dist/compiler/config.js +52 -0
  23. package/dist/compiler/dialect.d.ts +24 -0
  24. package/dist/compiler/dialect.js +198 -0
  25. package/dist/compiler/drawio.d.ts +26 -0
  26. package/dist/compiler/drawio.js +176 -0
  27. package/dist/compiler/figures.d.ts +14 -0
  28. package/dist/compiler/figures.js +221 -0
  29. package/dist/compiler/file-cache.d.ts +14 -0
  30. package/dist/compiler/file-cache.js +61 -0
  31. package/dist/compiler/files.d.ts +29 -0
  32. package/dist/compiler/files.js +431 -0
  33. package/dist/compiler/fonts.d.ts +10 -0
  34. package/dist/compiler/fonts.js +54 -0
  35. package/dist/compiler/format.d.ts +1 -0
  36. package/dist/compiler/format.js +42 -0
  37. package/dist/compiler/from-mdast.d.ts +16 -0
  38. package/dist/compiler/from-mdast.js +142 -0
  39. package/dist/compiler/icons.d.ts +14 -0
  40. package/dist/compiler/icons.js +85 -0
  41. package/dist/compiler/language-codes.d.ts +7 -0
  42. package/dist/compiler/language-codes.js +17 -0
  43. package/dist/compiler/locales.d.ts +7 -0
  44. package/dist/compiler/locales.js +67 -0
  45. package/dist/compiler/manifest.d.ts +6 -0
  46. package/dist/compiler/manifest.js +30 -0
  47. package/dist/compiler/ops.d.ts +44 -0
  48. package/dist/compiler/ops.js +362 -0
  49. package/dist/compiler/public-file.d.ts +2 -0
  50. package/dist/compiler/public-file.js +93 -0
  51. package/dist/compiler/serialize.d.ts +3 -0
  52. package/dist/compiler/serialize.js +16 -0
  53. package/dist/compiler/terms.d.ts +9 -0
  54. package/dist/compiler/terms.js +44 -0
  55. package/dist/compiler/textfile-remote.d.ts +12 -0
  56. package/dist/compiler/textfile-remote.js +124 -0
  57. package/dist/compiler/textfile.d.ts +9 -0
  58. package/dist/compiler/textfile.js +113 -0
  59. package/dist/compiler/theme-variants.d.ts +4 -0
  60. package/dist/compiler/theme-variants.js +22 -0
  61. package/dist/compiler/zip.d.ts +6 -0
  62. package/dist/compiler/zip.js +48 -0
  63. package/dist/core/blocks.d.ts +99 -0
  64. package/dist/core/blocks.js +29 -0
  65. package/dist/core/code-file.d.ts +12 -0
  66. package/dist/core/code-file.js +68 -0
  67. package/dist/core/content-metadata.d.ts +48 -0
  68. package/dist/core/content-metadata.js +264 -0
  69. package/dist/core/content-types.d.ts +45 -0
  70. package/dist/core/content-types.js +1 -0
  71. package/dist/core/extension-package.d.ts +34 -0
  72. package/dist/core/extension-package.js +147 -0
  73. package/dist/core/figure-frame.d.ts +12 -0
  74. package/dist/core/figure-frame.js +108 -0
  75. package/dist/core/file-embed.d.ts +13 -0
  76. package/dist/core/file-embed.js +43 -0
  77. package/dist/core/files.d.ts +15 -0
  78. package/dist/core/files.js +59 -0
  79. package/dist/core/flow-base-css.generated.d.ts +1 -0
  80. package/dist/core/flow-base-css.generated.js +4 -0
  81. package/dist/core/flow-css.d.ts +9 -0
  82. package/dist/core/flow-css.js +54 -0
  83. package/dist/core/graph-common.d.ts +32 -0
  84. package/dist/core/graph-common.js +113 -0
  85. package/dist/core/graph.d.ts +117 -0
  86. package/dist/core/graph.js +480 -0
  87. package/dist/core/html.d.ts +29 -0
  88. package/dist/core/html.js +444 -0
  89. package/dist/core/icons.d.ts +2 -0
  90. package/dist/core/icons.js +2 -0
  91. package/dist/core/inline.d.ts +27 -0
  92. package/dist/core/inline.js +42 -0
  93. package/dist/core/language.d.ts +41 -0
  94. package/dist/core/language.js +44 -0
  95. package/dist/core/languages/ja.d.ts +2 -0
  96. package/dist/core/languages/ja.js +5 -0
  97. package/dist/core/markdown.d.ts +27 -0
  98. package/dist/core/markdown.js +154 -0
  99. package/dist/core/previewers.d.ts +37 -0
  100. package/dist/core/previewers.js +103 -0
  101. package/dist/core/routing.d.ts +20 -0
  102. package/dist/core/routing.js +64 -0
  103. package/dist/core/schema-comments.d.ts +14 -0
  104. package/dist/core/schema-comments.js +52 -0
  105. package/dist/core/schema-graph-flow.d.ts +9 -0
  106. package/dist/core/schema-graph-flow.js +379 -0
  107. package/dist/core/schema-graph-mount.d.ts +9 -0
  108. package/dist/core/schema-graph-mount.js +105 -0
  109. package/dist/core/schema-graph.d.ts +254 -0
  110. package/dist/core/schema-graph.js +981 -0
  111. package/dist/core/table-ui.d.ts +2 -0
  112. package/dist/core/table-ui.js +180 -0
  113. package/dist/core/taxonomy.d.ts +12 -0
  114. package/dist/core/taxonomy.js +49 -0
  115. package/dist/core/term-reference.d.ts +5 -0
  116. package/dist/core/term-reference.js +19 -0
  117. package/dist/core/terms-ui.d.ts +6 -0
  118. package/dist/core/terms-ui.js +22 -0
  119. package/dist/core/textfile.d.ts +10 -0
  120. package/dist/core/textfile.js +39 -0
  121. package/dist/core/theme-variants.d.ts +3 -0
  122. package/dist/core/theme-variants.js +37 -0
  123. package/dist/core/toc-ui.d.ts +2 -0
  124. package/dist/core/toc-ui.js +161 -0
  125. package/dist/core/video-ui.d.ts +2 -0
  126. package/dist/core/video-ui.js +253 -0
  127. package/dist/core/workspace.d.ts +15 -0
  128. package/dist/core/workspace.js +28 -0
  129. package/dist/edit.global.js +306 -0
  130. package/dist/flow.global.js +4959 -0
  131. package/dist/index.d.ts +17 -0
  132. package/dist/index.js +31 -0
  133. package/dist/mcp.d.ts +24 -0
  134. package/dist/mcp.js +467 -0
  135. package/dist/plugins/actions.d.ts +45 -0
  136. package/dist/plugins/actions.js +125 -0
  137. package/dist/plugins/blocks.d.ts +93 -0
  138. package/dist/plugins/blocks.js +100 -0
  139. package/dist/plugins/index.d.ts +4 -0
  140. package/dist/plugins/index.js +1 -0
  141. package/dist/plugins/local-storage.d.ts +18 -0
  142. package/dist/plugins/local-storage.js +115 -0
  143. package/dist/plugins/oauth.d.ts +14 -0
  144. package/dist/plugins/oauth.js +170 -0
  145. package/dist/plugins/runtime.d.ts +26 -0
  146. package/dist/plugins/runtime.js +395 -0
  147. package/dist/plugins/types.d.ts +59 -0
  148. package/dist/plugins/types.js +1 -0
  149. package/dist/plugins/view.d.ts +7 -0
  150. package/dist/plugins/view.js +21 -0
  151. package/dist/ui.global.js +78 -0
  152. package/dist/viewer/assets.d.ts +2 -0
  153. package/dist/viewer/assets.js +3 -0
  154. package/dist/viewer/build.d.ts +12 -0
  155. package/dist/viewer/build.js +55 -0
  156. package/dist/viewer/bundle-network.d.ts +2 -0
  157. package/dist/viewer/bundle-network.js +88 -0
  158. package/dist/viewer/code-file.d.ts +20 -0
  159. package/dist/viewer/code-file.js +42 -0
  160. package/dist/viewer/editor-tools.d.ts +38 -0
  161. package/dist/viewer/editor-tools.js +211 -0
  162. package/dist/viewer/extension-pack.d.ts +2 -0
  163. package/dist/viewer/extension-pack.js +36 -0
  164. package/dist/viewer/extensions.d.ts +10 -0
  165. package/dist/viewer/extensions.js +302 -0
  166. package/dist/viewer/file-references.d.ts +14 -0
  167. package/dist/viewer/file-references.js +38 -0
  168. package/dist/viewer/font-picker.d.ts +4 -0
  169. package/dist/viewer/font-picker.js +145 -0
  170. package/dist/viewer/font-settings.d.ts +11 -0
  171. package/dist/viewer/font-settings.js +50 -0
  172. package/dist/viewer/fonts/LICENSE.txt +119 -0
  173. package/dist/viewer/fonts/udev-gothic-regular.woff2 +0 -0
  174. package/dist/viewer/fonts.d.ts +2 -0
  175. package/dist/viewer/fonts.js +13 -0
  176. package/dist/viewer/git-diff.d.ts +3 -0
  177. package/dist/viewer/git-diff.js +33 -0
  178. package/dist/viewer/git-types.d.ts +73 -0
  179. package/dist/viewer/git-types.js +1 -0
  180. package/dist/viewer/git.d.ts +5 -0
  181. package/dist/viewer/git.js +337 -0
  182. package/dist/viewer/hmr.d.ts +53 -0
  183. package/dist/viewer/hmr.js +337 -0
  184. package/dist/viewer/i18n.d.ts +6 -0
  185. package/dist/viewer/i18n.js +65 -0
  186. package/dist/viewer/ja-lint.d.ts +3 -0
  187. package/dist/viewer/ja-lint.js +44 -0
  188. package/dist/viewer/language-picker.d.ts +2 -0
  189. package/dist/viewer/language-picker.js +233 -0
  190. package/dist/viewer/languages.d.ts +11 -0
  191. package/dist/viewer/languages.js +20 -0
  192. package/dist/viewer/locales/eng.json +317 -0
  193. package/dist/viewer/locales/jpn.json +317 -0
  194. package/dist/viewer/locales/zho.json +317 -0
  195. package/dist/viewer/nav-focus.d.ts +2 -0
  196. package/dist/viewer/nav-focus.js +64 -0
  197. package/dist/viewer/plugin-blocks.d.ts +3 -0
  198. package/dist/viewer/plugin-blocks.js +115 -0
  199. package/dist/viewer/plugin-config.d.ts +35 -0
  200. package/dist/viewer/plugin-config.js +57 -0
  201. package/dist/viewer/plugin-view.d.ts +2 -0
  202. package/dist/viewer/plugin-view.js +6 -0
  203. package/dist/viewer/plugins.d.ts +12 -0
  204. package/dist/viewer/plugins.js +267 -0
  205. package/dist/viewer/resize.d.ts +4 -0
  206. package/dist/viewer/resize.js +238 -0
  207. package/dist/viewer/resource-library.d.ts +29 -0
  208. package/dist/viewer/resource-library.js +104 -0
  209. package/dist/viewer/search.d.ts +16 -0
  210. package/dist/viewer/search.js +358 -0
  211. package/dist/viewer/serve.d.ts +210 -0
  212. package/dist/viewer/serve.js +3312 -0
  213. package/dist/viewer/site-icon.d.ts +8 -0
  214. package/dist/viewer/site-icon.js +56 -0
  215. package/dist/viewer/textfile.d.ts +9 -0
  216. package/dist/viewer/textfile.js +50 -0
  217. package/dist/viewer/theme-picker.d.ts +2 -0
  218. package/dist/viewer/theme-picker.js +135 -0
  219. package/dist/viewer/workspace.d.ts +4 -0
  220. package/dist/viewer/workspace.js +47 -0
  221. package/package.json +128 -5
@@ -0,0 +1,981 @@
1
+ // SoT for the content dialect `schemath` (table DSL) — DSL parse / graph model / deterministic
2
+ // layout. Browser-safe (no dependencies). html.ts owns the markup (SVG); this module returns only
3
+ // semantics and geometry (= every consumer gets the same picture via docHtml).
4
+ //
5
+ // DSL inside a `schemath` code fence or a .schemath file:
6
+ // ### <label> group boundary (labeled = frame + heading on the graph;
7
+ // unlabeled only splits columns)
8
+ // <table>: table declaration (the following indented lines are its body)
9
+ // <field>: pk primary key
10
+ // <field>: fk(<table>) reference (polymorphic = union `fk(a|b)` = multiple edges)
11
+ // <field>: fk[](<table>) array of references (1:N, drawn dashed)
12
+ // <field>: string scalar (types are the closed set SCHEMA_SCALAR_TYPES)
13
+ // <field>: enum("a"|"b") enum (display label joins the values with `|`; quoted strings
14
+ // and bare tokens are both allowed)
15
+ // <field>: "a"|"b" enum shorthand (only a union whose tokens are all quoted
16
+ // strings; same as `enum(...)`)
17
+ // pk(<field>, <field>, ...) composite primary key (2+ columns; may be written before or
18
+ // after the field lines; `pk` is shown alongside in type labels)
19
+ // unique(<field>, ...) unique constraint (inside the table, at field-line position;
20
+ // unqualified is fine. The qualified form `<table>.<field>` is
21
+ // also allowed but must match the enclosing table)
22
+ // <note> a line without ":" = a note on that table (faint, no edges)
23
+ // // comment source comment to end of line (not shown in the diagram)
24
+ // /* comment */ may span lines; not treated as a comment inside strings
25
+ // unique(<table>.<field>, ...) unique constraint (outside the table, at line start, same
26
+ // column as table declarations. Qualified form required. A table
27
+ // may have several (they coexist). `uq` (only one) / `uq1`,`uq2`..
28
+ // (two or more, declaration order) is shown alongside in type
29
+ // labels)
30
+ //
31
+ // Undeclared reference targets / unknown types / duplicate declarations are all validation
32
+ // errors (= not publishable) (picked up by dialect.ts).
33
+ //
34
+ // Band order (SoT for layoutSchemaGraph): column bands are built from the depth (= level) of
35
+ // inter-group edges (edges closed within a group are excluded). Groups that reference no other
36
+ // group (including groups that are only referenced) are level 0; a referencing group is one
37
+ // level deeper (= the band to the right) than the group it references. Bands run left → right
38
+ // in ascending level; groups of the same level stack top → bottom in declaration order (a band
39
+ // may hold several groups).
40
+ import { depthLevels, hashId, orthPath, round1, splitByHeight, textW } from "./graph-common.js";
41
+ import { scanSchemaComments } from "./schema-comments.js";
42
+ /** Closed set of scalar types (anything else is a validation error). */
43
+ export const SCHEMA_SCALAR_TYPES = ["string", "number", "bool", "json", "timestamp"];
44
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
45
+ const TYPE_HINT = `pk / fk(<table>) / fk[](<table>) / enum("a"|"b") / ${SCHEMA_SCALAR_TYPES.join(" / ")}`;
46
+ // One value of enum(...). A quoted string (`"..."`) keeps its quotes in the label; a bare token
47
+ // is the plain string. A bare token can't contain `|` / `)` / whitespace (splitting on "|" has
48
+ // already happened, so effectively only ")"/whitespace are rejected).
49
+ const ENUM_BARE = /^[^\s|")]+$/;
50
+ // Enum with `enum(...)` omitted (`"a"|"b"`). Treated as an enum only when every token is a quoted
51
+ // string (an unquoted union like `foo|bar` becomes an unknown-type error, catching typos).
52
+ const QUOTED_UNION = /^"[^"]*"(\s*\|\s*"[^"]*")*$/;
53
+ const parseEnumToken = (token) => {
54
+ if (token.startsWith('"')) {
55
+ const m = /^"([^"]*)"$/.exec(token);
56
+ return m == null ? undefined : { label: token, content: m[1] ?? "" };
57
+ }
58
+ return ENUM_BARE.test(token) ? { label: token, content: token } : undefined;
59
+ };
60
+ /**
61
+ * table DSL → graph model + errors. Even with errors, a partial model is returned
62
+ * (so preview can show "validation errors + as much as can be drawn" at the same time).
63
+ */
64
+ export const parseSchemaSource = (source) => {
65
+ const scanned = scanSchemaComments(source);
66
+ const errors = [...scanned.errors];
67
+ const tables = [];
68
+ const groups = [];
69
+ const byName = new Map();
70
+ // Declaration lines (so later cross-check errors point at the right line).
71
+ // key = table name / `<table>.<field>`.
72
+ const declLine = new Map();
73
+ // table name → composite pk (`pk(...)`) declaration (line number + field names, declaration
74
+ // order). Only the first one per table is kept (later ones are rejected as duplicate
75
+ // declarations). Field existence / coexistence with a single pk isn't checked here; a later
76
+ // pass resolves them after the whole table has been read.
77
+ const pkDecl = new Map();
78
+ // table name (owner) → unique(...) declarations (line number + field names, declaration
79
+ // order). Unlike pk, a table may have several, hence an array (`unique(a)` alongside
80
+ // `unique(b, c)` is valid). As with pk(...), checks for field existence / column-set match
81
+ // against the pk / duplicate declarations run in a later pass after the whole table has been
82
+ // read (since it may be written before or after the table).
83
+ const uniqueDecl = new Map();
84
+ let group;
85
+ let current;
86
+ /** Skip the body of a table whose declaration errored (prevents cascading errors). */
87
+ let skipping = false;
88
+ const err = (line, message) => {
89
+ errors.push({ line, message });
90
+ };
91
+ // Validates the body of unique(...) (comma-separated tokens) and records it on the owner
92
+ // table (inside a table: enclosing; outside: determined from each token's qualifier) as a
93
+ // declaration for later resolution. enclosing == null means outside a table (qualified form
94
+ // `<table>.<field>` required); otherwise inside a table (unqualified is fine, but a qualifier
95
+ // must match enclosing). Field existence / cross-check with the pk / duplicate declarations
96
+ // are resolved after the whole table has been read (same approach as pk(...)).
97
+ const recordUnique = (line, inner, enclosing) => {
98
+ const tokens = inner
99
+ .split(",")
100
+ .map((t) => t.trim())
101
+ .filter((t) => t !== "");
102
+ if (tokens.length === 0) {
103
+ err(line, `unique(...) requires at least 1 column (e.g. "unique(<field>)")`);
104
+ return;
105
+ }
106
+ // token → qualifier (table part) / field part. No "." means unqualified (tbl = undefined).
107
+ const parsed = tokens.map((token) => {
108
+ const idx = token.indexOf(".");
109
+ return idx < 0
110
+ ? { token, tbl: undefined, fld: token }
111
+ : { token, tbl: token.slice(0, idx).trim(), fld: token.slice(idx + 1).trim() };
112
+ });
113
+ if (enclosing == null) {
114
+ const bare = parsed.filter((p) => p.tbl == null).map((p) => p.token);
115
+ if (bare.length > 0) {
116
+ err(line, `"${bare.join(", ")}" must be written as "<table>.<field>" outside a table (qualified form required)`);
117
+ return;
118
+ }
119
+ }
120
+ else {
121
+ const mismatched = parsed
122
+ .filter((p) => p.tbl != null && p.tbl !== enclosing)
123
+ .map((p) => p.token);
124
+ if (mismatched.length > 0) {
125
+ err(line, `"${mismatched.join(", ")}" has a qualifier prefix that does not match the enclosing table "${enclosing}"`);
126
+ return;
127
+ }
128
+ }
129
+ const badTokens = parsed
130
+ .filter((p) => (p.tbl != null && !IDENT.test(p.tbl)) || !IDENT.test(p.fld))
131
+ .map((p) => p.token);
132
+ if (badTokens.length > 0) {
133
+ err(line, `unique(...) field name "${badTokens.join(", ")}" must contain only alphanumerics and _ (starting with a letter or _)`);
134
+ return;
135
+ }
136
+ const fields = parsed.map((p) => p.fld);
137
+ const dup = fields.find((f, idx) => fields.indexOf(f) !== idx);
138
+ if (dup != null) {
139
+ err(line, `unique(...) has duplicate field "${dup}"`);
140
+ return;
141
+ }
142
+ const owner = enclosing ?? parsed[0]?.tbl;
143
+ if (owner == null) {
144
+ return;
145
+ }
146
+ if (enclosing == null) {
147
+ // The outside form's grammar lets each token name its own table, but unique is a
148
+ // constraint over the columns of a single table (SchemaTable.unique is confined to
149
+ // one table), so every token must match the first token's table.
150
+ const foreign = parsed.filter((p) => p.tbl !== owner).map((p) => p.token);
151
+ if (foreign.length > 0) {
152
+ err(line, `unique(...) can only list columns of a single table ("${owner}" and "${foreign.join(", ")}" are mixed)`);
153
+ return;
154
+ }
155
+ }
156
+ const entry = { line, fields };
157
+ const list = uniqueDecl.get(owner);
158
+ if (list) {
159
+ list.push(entry);
160
+ }
161
+ else {
162
+ uniqueDecl.set(owner, [entry]);
163
+ }
164
+ };
165
+ // Tables that appear before any group heading go into an implicit unlabeled group
166
+ // (= groups partition all tables).
167
+ const ensureGroup = () => {
168
+ if (group == null) {
169
+ group = { tables: [] };
170
+ groups.push(group);
171
+ }
172
+ return group;
173
+ };
174
+ scanned.source.split("\n").forEach((raw, i) => {
175
+ const body = raw.trim();
176
+ if (body === "") {
177
+ return;
178
+ }
179
+ if (body.startsWith("```")) {
180
+ err(i, "``` is not allowed (write the table DSL directly inside schemath)");
181
+ return;
182
+ }
183
+ if (!/^\s/.test(raw)) {
184
+ // Clear the skip state at each new top-level declaration (= at most one error per
185
+ // declaration).
186
+ skipping = false;
187
+ const heading = /^###\s*(.*)$/.exec(body);
188
+ if (heading) {
189
+ const label = (heading[1] ?? "").trim();
190
+ group = { ...(label === "" ? {} : { label }), tables: [] };
191
+ groups.push(group);
192
+ current = undefined;
193
+ return;
194
+ }
195
+ // unique(...) outside a table (qualified form required). It doesn't end with ":", so
196
+ // without this check it would fall into the "table declaration or group heading
197
+ // required" error below (= check before decl).
198
+ const uniqueOutside = /^unique\((.*)\)$/.exec(body);
199
+ if (uniqueOutside) {
200
+ recordUnique(i, uniqueOutside[1] ?? "", undefined);
201
+ return;
202
+ }
203
+ const decl = /^([^:]*):$/.exec(body);
204
+ if (decl == null) {
205
+ err(i, `a table declaration (\`<name>:\`) or group heading (\`###\`) is required ("${body}") — fields must be indented`);
206
+ current = undefined;
207
+ skipping = true;
208
+ return;
209
+ }
210
+ const name = (decl[1] ?? "").trim();
211
+ if (!IDENT.test(name)) {
212
+ err(i, `table name "${name}" must contain only alphanumerics and _ (starting with a letter or _)`);
213
+ current = undefined;
214
+ skipping = true;
215
+ return;
216
+ }
217
+ if (byName.has(name)) {
218
+ err(i, `table "${name}" is declared twice`);
219
+ current = undefined;
220
+ skipping = true;
221
+ return;
222
+ }
223
+ const table = { name, fields: [], notes: [] };
224
+ tables.push(table);
225
+ byName.set(name, table);
226
+ declLine.set(name, i);
227
+ ensureGroup().tables.push(name);
228
+ current = table;
229
+ return;
230
+ }
231
+ // Silently drop the body of a table whose declaration is broken (no cascading errors).
232
+ if (skipping) {
233
+ return;
234
+ }
235
+ if (current == null) {
236
+ err(i, `content before any table declaration ("${body}")`);
237
+ return;
238
+ }
239
+ const sep = body.indexOf(":");
240
+ if (sep < 0) {
241
+ // Composite primary key (`pk(<field>, <field>, ...)`). It contains no ":", so check
242
+ // it before the note check (= otherwise it would become a note). Field existence /
243
+ // coexistence with a single pk are validated in a later pass after the whole table
244
+ // has been read (so fields may be written before or after pk(...)).
245
+ const pkCall = /^pk\((.*)\)$/.exec(body);
246
+ if (pkCall) {
247
+ const fields = (pkCall[1] ?? "")
248
+ .split(",")
249
+ .map((t) => t.trim())
250
+ .filter((t) => t !== "");
251
+ if (fields.length < 2) {
252
+ err(i, `composite key requires at least 2 columns (use "<field>: pk" for a single column) — "pk(${pkCall[1] ?? ""})"`);
253
+ return;
254
+ }
255
+ const badTokens = fields.filter((t) => !IDENT.test(t));
256
+ if (badTokens.length > 0) {
257
+ err(i, `pk(...) field name "${badTokens.join(", ")}" must contain only alphanumerics and _ (starting with a letter or _)`);
258
+ return;
259
+ }
260
+ const dup = fields.find((t, idx) => fields.indexOf(t) !== idx);
261
+ if (dup != null) {
262
+ err(i, `pk(...) has duplicate field "${dup}"`);
263
+ return;
264
+ }
265
+ if (pkDecl.has(current.name)) {
266
+ err(i, `table "${current.name}" declares pk(...) twice`);
267
+ return;
268
+ }
269
+ pkDecl.set(current.name, { line: i, fields });
270
+ return;
271
+ }
272
+ // unique(...) (inside a table; unqualified is fine, a qualifier must match the
273
+ // enclosing table). Like pk(...), it has no ":", so check it before the note check.
274
+ const uniqueInside = /^unique\((.*)\)$/.exec(body);
275
+ if (uniqueInside) {
276
+ recordUnique(i, uniqueInside[1] ?? "", current.name);
277
+ return;
278
+ }
279
+ current.notes.push(body);
280
+ return;
281
+ }
282
+ const name = body.slice(0, sep).trim();
283
+ const value = body.slice(sep + 1).trim();
284
+ if (!IDENT.test(name)) {
285
+ err(i, `field name "${name}" must contain only alphanumerics and _ (starting with a letter or _)`);
286
+ return;
287
+ }
288
+ if (current.fields.some((f) => f.name === name)) {
289
+ err(i, `table "${current.name}" declares field "${name}" twice`);
290
+ return;
291
+ }
292
+ if (value === "pk") {
293
+ current.fields.push({ name, kind: "pk" });
294
+ return;
295
+ }
296
+ const fk = /^fk(\[\])?\((.*)\)$/.exec(value);
297
+ if (fk) {
298
+ const targets = (fk[2] ?? "")
299
+ .split("|")
300
+ .map((t) => t.trim())
301
+ .filter((t) => t !== "");
302
+ if (targets.length === 0) {
303
+ err(i, `"${name}: ${value}" has an empty reference target — write it as fk(<table>)`);
304
+ return;
305
+ }
306
+ const bad = targets.filter((t) => !IDENT.test(t));
307
+ if (bad.length > 0) {
308
+ err(i, `"${name}" references "${bad.join(", ")}", which is an invalid table name`);
309
+ return;
310
+ }
311
+ declLine.set(`${current.name}.${name}`, i);
312
+ current.fields.push({ name, kind: fk[1] == null ? "fk" : "fkList", targets });
313
+ return;
314
+ }
315
+ if (/^fk(\[\])?$/.test(value)) {
316
+ err(i, `"${name}: ${value}" requires a reference target — write it as fk(<table>) / fk[](<table>)`);
317
+ return;
318
+ }
319
+ // Enum — `enum("a"|"b")` and the shorthand `"a"|"b"` (a union whose tokens are all quoted
320
+ // strings). Both produce the same label (values joined with `|`). Stop at the first error.
321
+ const enumBody = /^enum\((.*)\)$/.exec(value)?.[1] ?? (QUOTED_UNION.test(value) ? value : undefined);
322
+ if (enumBody != null) {
323
+ const values = [];
324
+ const seen = new Set();
325
+ for (const token of enumBody.split("|").map((t) => t.trim())) {
326
+ if (token === "") {
327
+ continue;
328
+ }
329
+ const parsed = parseEnumToken(token);
330
+ if (parsed == null) {
331
+ err(i, `"${name}" value "${token}" must be a "..."-quoted string or a bare token (no |, ) or whitespace)`);
332
+ return;
333
+ }
334
+ if (parsed.content === "") {
335
+ err(i, `"${name}: ${value}" has no values — write it as enum("a"|"b")`);
336
+ return;
337
+ }
338
+ if (seen.has(parsed.content)) {
339
+ err(i, `"${name}" has duplicate value "${parsed.label}"`);
340
+ return;
341
+ }
342
+ seen.add(parsed.content);
343
+ values.push(parsed.label);
344
+ }
345
+ if (values.length === 0) {
346
+ err(i, `"${name}: ${value}" has no values — write it as enum("a"|"b")`);
347
+ return;
348
+ }
349
+ current.fields.push({ name, kind: "scalar", type: values.join("|") });
350
+ return;
351
+ }
352
+ if (value === "enum") {
353
+ err(i, `"${name}: ${value}" requires values — write it as enum("a"|"b")`);
354
+ return;
355
+ }
356
+ if (SCHEMA_SCALAR_TYPES.includes(value)) {
357
+ current.fields.push({ name, kind: "scalar", type: value });
358
+ return;
359
+ }
360
+ err(i, `unknown type "${value}" ("${name}") — allowed: ${TYPE_HINT}`);
361
+ });
362
+ for (const table of tables) {
363
+ if (table.fields.length === 0 && table.notes.length === 0) {
364
+ err(declLine.get(table.name) ?? 0, `table "${table.name}" is empty (no fields or notes)`);
365
+ }
366
+ }
367
+ // Composite pks (`pk(...)`) are resolved after the whole table has been read: validate
368
+ // coexistence with a single-pk field / existence of the referenced fields, and set `table.pk`
369
+ // only when there's no error (keeping the policy of returning a drawable model even with
370
+ // errors = on error, pk is simply left unset).
371
+ for (const [tableName, decl] of pkDecl) {
372
+ const table = byName.get(tableName);
373
+ if (table == null) {
374
+ continue;
375
+ }
376
+ if (table.fields.some((f) => f.kind === "pk")) {
377
+ err(decl.line, `table "${tableName}" cannot combine a single pk ("<field>: pk") with pk(...) (use one or the other)`);
378
+ continue;
379
+ }
380
+ const known = new Set(table.fields.map((f) => f.name));
381
+ const missing = decl.fields.filter((f) => !known.has(f));
382
+ if (missing.length > 0) {
383
+ err(decl.line, `pk(...) field "${missing.join(", ")}" is not declared`);
384
+ continue;
385
+ }
386
+ table.pk = decl.fields;
387
+ }
388
+ // Like pk(...), unique(...) is resolved after the whole table has been read: validate table /
389
+ // field existence, that it isn't the pk's column set (redundant; the pk is already unique),
390
+ // and that no unique with the same column set is duplicated within a table; only constraints
391
+ // without errors are added to `table.unique` (policy of returning a drawable model even with
392
+ // errors = on error, that constraint is simply not added).
393
+ const sameFieldSet = (a, b) => a.length === b.length && [...a].sort().join(" ") === [...b].sort().join(" ");
394
+ for (const [tableName, decls] of uniqueDecl) {
395
+ const table = byName.get(tableName);
396
+ if (table == null) {
397
+ for (const decl of decls) {
398
+ err(decl.line, `unique(...) table "${tableName}" is not declared`);
399
+ }
400
+ continue;
401
+ }
402
+ const known = new Set(table.fields.map((f) => f.name));
403
+ const singlePk = table.fields.find((f) => f.kind === "pk")?.name;
404
+ const pkFieldSet = table.pk ?? (singlePk == null ? undefined : [singlePk]);
405
+ const accepted = [];
406
+ for (const decl of decls) {
407
+ const missing = decl.fields.filter((f) => !known.has(f));
408
+ if (missing.length > 0) {
409
+ err(decl.line, `unique(...) field "${missing.join(", ")}" is not declared`);
410
+ continue;
411
+ }
412
+ if (pkFieldSet != null && sameFieldSet(decl.fields, pkFieldSet)) {
413
+ err(decl.line, `unique(${decl.fields.join(", ")}) has the same columns as the pk — the pk is already unique, so unique is redundant`);
414
+ continue;
415
+ }
416
+ if (accepted.some((f) => sameFieldSet(f, decl.fields))) {
417
+ err(decl.line, `table "${tableName}" declares unique(${decl.fields.join(", ")}) twice (same columns)`);
418
+ continue;
419
+ }
420
+ accepted.push(decl.fields);
421
+ }
422
+ if (accepted.length > 0) {
423
+ table.unique = accepted;
424
+ }
425
+ }
426
+ // Edges in declaration order (table → field → union target). Target existence is validated
427
+ // here.
428
+ const edges = [];
429
+ const seen = new Set();
430
+ for (const table of tables) {
431
+ table.fields.forEach((field, fieldIndex) => {
432
+ if (field.kind !== "fk" && field.kind !== "fkList") {
433
+ return;
434
+ }
435
+ for (const to of field.targets ?? []) {
436
+ if (!byName.has(to)) {
437
+ err(declLine.get(`${table.name}.${field.name}`) ?? 0, `"${table.name}.${field.name}" references table "${to}", which is not declared`);
438
+ continue;
439
+ }
440
+ const key = `${table.name}.${field.name}→${to}`;
441
+ if (seen.has(key)) {
442
+ continue;
443
+ }
444
+ seen.add(key);
445
+ edges.push({
446
+ from: table.name,
447
+ fieldIndex,
448
+ field: field.name,
449
+ to,
450
+ list: field.kind === "fkList",
451
+ });
452
+ }
453
+ });
454
+ }
455
+ return { model: { groups, tables, edges }, errors };
456
+ };
457
+ /**
458
+ * Short id derived deterministically from the model contents. Used as the namespace for SVG
459
+ * markers / classes (no id collisions when one page has several graphs; no randomness / time =
460
+ * reproducible builds).
461
+ */
462
+ export const schemaGraphUid = (model) => hashId(JSON.stringify(model));
463
+ /**
464
+ * Decides each edge's connecting sides from the current box positions (a pure function meant to
465
+ * be re-called on every drag). Target to the right: exit right, enter left; to the left: the
466
+ * reverse; overlapping: rightward.
467
+ */
468
+ export const schemaFlowEdges = (nodes, edges) => {
469
+ const byName = new Map(nodes.map((n) => [n.name, n]));
470
+ return edges.map((e) => {
471
+ const s = byName.get(e.from);
472
+ const t = byName.get(e.to);
473
+ const rightward = s != null && t != null && t.x + t.w / 2 >= s.x + s.w / 2;
474
+ return {
475
+ id: `${e.from}.${e.field}-${e.fieldIndex}→${e.to}`,
476
+ from: e.from,
477
+ to: e.to,
478
+ fieldIndex: e.fieldIndex,
479
+ list: e.list,
480
+ sourceSide: rightward ? "r" : "l",
481
+ targetSide: rightward ? "l" : "r",
482
+ };
483
+ });
484
+ };
485
+ // Geometry constants (px). Font metrics can't be measured, so widths are charWidth estimates
486
+ // (= deterministic).
487
+ const M = {
488
+ headerH: 26,
489
+ rowH: 19,
490
+ padX: 11,
491
+ padBottom: 9,
492
+ gapNameType: 18,
493
+ minW: 158,
494
+ gapY: 22,
495
+ gapInnerX: 30,
496
+ gapGroupX: 108,
497
+ /** Gap between groups stacked in one band (frame bottom → top of next group's label area). */
498
+ gapGroupY: 40,
499
+ framePad: 14,
500
+ frameLabelH: 22,
501
+ maxColH: 1020,
502
+ lane: 10,
503
+ corner: 7,
504
+ margin: 16,
505
+ legendH: 24,
506
+ radius: 10,
507
+ /** How far the text baseline sits below the row center (assumes a ~11.5px font). */
508
+ baselineDrop: 4,
509
+ };
510
+ /** Left edge of the legend / group labels (used by the renderer). */
511
+ export const SCHEMA_LAYOUT_MARGIN = M.margin;
512
+ /** Minimum gap between groups stacked in one band (tests use it to verify non-overlap). */
513
+ export const SCHEMA_GAP_GROUP_Y = M.gapGroupY;
514
+ /** Group frame label-area height / padding (tests use them to derive the "label top"). */
515
+ export const SCHEMA_FRAME_LABEL_H = M.frameLabelH;
516
+ export const SCHEMA_FRAME_PAD = M.framePad;
517
+ // compositePk = whether this field takes part in the table's composite pk (`pk(...)`).
518
+ // uniqueLabel = label of the unique constraint it takes part in ("uq", or "uq1"/"uq2".. in
519
+ // declaration order; undefined if none). The combined order is `pk uq <type>` (e.g. `pk fk` /
520
+ // `uq1 fk` / `pk uq fk[]` / `uq string`). A single pk (`kind: "pk"`) has no "type" slot (base is
521
+ // undefined), so only isPk (from kind or from compositePk) goes first and "pk" isn't listed
522
+ // twice. A single pk and a composite pk never coexist (error at parse time), so a field with
523
+ // compositePk true never has kind "pk". Handling it all here means both the static SVG (html.ts)
524
+ // and React Flow (schemaGraphClientData) pick it up automatically, with no extra render branches.
525
+ const typeLabel = (f, compositePk = false, uniqueLabel) => {
526
+ const isPk = f.kind === "pk" || compositePk;
527
+ const base = f.kind === "pk"
528
+ ? undefined
529
+ : f.kind === "fk"
530
+ ? "fk"
531
+ : f.kind === "fkList"
532
+ ? "fk[]"
533
+ : (f.type ?? "");
534
+ return [
535
+ ...(isPk ? ["pk"] : []),
536
+ ...(uniqueLabel == null ? [] : [uniqueLabel]),
537
+ ...(base == null ? [] : [base]),
538
+ ].join(" ");
539
+ };
540
+ // Builds a reverse lookup from table.unique: field name → uq label ("uq", or "uq1"/"uq2".. in
541
+ // declaration order). A table with a single unique gets no number (every column belongs to the
542
+ // same one constraint, so it's unnecessary); with two or more, declaration-order numbers show
543
+ // which columns share a constraint. If a field takes part in several constraints (rare), the
544
+ // label of the first one found (= earliest in declaration order) wins.
545
+ const uniqueLabelsOf = (table) => {
546
+ const constraints = table.unique ?? [];
547
+ const labels = new Map();
548
+ constraints.forEach((fields, i) => {
549
+ const label = constraints.length > 1 ? `uq${i + 1}` : "uq";
550
+ for (const f of fields) {
551
+ if (!labels.has(f)) {
552
+ labels.set(f, label);
553
+ }
554
+ }
555
+ });
556
+ return labels;
557
+ };
558
+ // Box size + rows (y relative to the table top). Width fits the longest "name + type label" row.
559
+ const measure = (table) => {
560
+ const rows = [];
561
+ let y = M.headerH;
562
+ let inner = textW(table.name, 12.5 / 12);
563
+ const pkFields = new Set(table.pk ?? []);
564
+ const uqLabels = uniqueLabelsOf(table);
565
+ table.fields.forEach((f, fieldIndex) => {
566
+ const type = typeLabel(f, pkFields.has(f.name), uqLabels.get(f.name));
567
+ inner = Math.max(inner, textW(f.name) + M.gapNameType + textW(type, 10.5 / 12));
568
+ rows.push({ kind: f.kind, name: f.name, type, y: y + M.rowH / 2, fieldIndex });
569
+ y += M.rowH;
570
+ });
571
+ for (const note of table.notes) {
572
+ inner = Math.max(inner, textW(note, 10.5 / 12));
573
+ rows.push({ kind: "note", name: note, type: "", y: y + M.rowH / 2 });
574
+ y += M.rowH;
575
+ }
576
+ return {
577
+ w: Math.max(M.minW, Math.ceil(inner + M.padX * 2)),
578
+ h: y + M.padBottom,
579
+ rows,
580
+ };
581
+ };
582
+ // Split tall groups into several internal columns (so they don't grow too tall). The split is
583
+ // greedy = deterministic (graph-common.splitByHeight is the SoT for the rule; this only maps it
584
+ // to table indices).
585
+ const splitColumns = (indices, measured) => splitByHeight(indices.map((i) => measured[i]?.h ?? 0), M.gapY, M.maxColH).map((col) => col.flatMap((k) => {
586
+ const i = indices[k];
587
+ return i == null ? [] : [i];
588
+ }));
589
+ /**
590
+ * Converts a purely horizontal/vertical polyline into an SVG path with rounded corners. A long
591
+ * edge drops from the source-side gutter into a lane below the tables and comes back up via the
592
+ * target-side gutter, so it has more bends than a regular orthPath.
593
+ */
594
+ const roundedOrthogonalPath = (points, corner) => {
595
+ const p = (point) => `${round1(point.x)},${round1(point.y)}`;
596
+ const first = points[0];
597
+ const last = points[points.length - 1];
598
+ if (first == null || last == null) {
599
+ return "";
600
+ }
601
+ const parts = [`M${p(first)}`];
602
+ for (let i = 1; i < points.length - 1; i += 1) {
603
+ const prev = points[i - 1];
604
+ const current = points[i];
605
+ const next = points[i + 1];
606
+ if (prev == null || current == null || next == null) {
607
+ continue;
608
+ }
609
+ const inDx = Math.sign(current.x - prev.x);
610
+ const inDy = Math.sign(current.y - prev.y);
611
+ const outDx = Math.sign(next.x - current.x);
612
+ const outDy = Math.sign(next.y - current.y);
613
+ const incoming = Math.abs(current.x - prev.x) + Math.abs(current.y - prev.y);
614
+ const outgoing = Math.abs(next.x - current.x) + Math.abs(next.y - current.y);
615
+ const radius = Math.min(corner, incoming / 2, outgoing / 2);
616
+ const before = {
617
+ x: current.x - inDx * radius,
618
+ y: current.y - inDy * radius,
619
+ };
620
+ const after = {
621
+ x: current.x + outDx * radius,
622
+ y: current.y + outDy * radius,
623
+ };
624
+ parts.push(`L${p(before)}`, `Q${p(current)} ${p(after)}`);
625
+ }
626
+ parts.push(`L${p(last)}`);
627
+ return parts.join(" ");
628
+ };
629
+ /**
630
+ * graph model → deterministic layout. Column bands are decided by the depth (level) of
631
+ * inter-group references: groups that reference no other group (including groups that are only
632
+ * referenced) are level 0; a referencing group is one level deeper than the group it references
633
+ * (= referenced groups land in bands to the left, referencing groups to the right). Reference
634
+ * cycles are cut deterministically by excluding back-edges to an ancestor on the search path
635
+ * from the level computation (see depthLevels).
636
+ * Bands run left → right in ascending level; groups in the same band stack top → bottom in
637
+ * declaration order (a band may hold several groups; independent groups land in the level-0
638
+ * band).
639
+ * Within a group (one group in a band), splitColumns may split the tables, in declaration order,
640
+ * into several internal columns.
641
+ * Edges leave from the fk field's row and enter the target box's header. Routes are bundled:
642
+ * edges that "enter the same target from the same column" share a trunk (vertical line) in the
643
+ * gutter next to that column and an entry point, so they merge into short exit stubs + one trunk
644
+ * + one crossing (the number of lines depends on target × column, not on the reference count).
645
+ * The same input always yields the same coordinates.
646
+ */
647
+ export const layoutSchemaGraph = (model) => {
648
+ const measured = model.tables.map(measure);
649
+ const indexOf = new Map(model.tables.map((t, i) => [t.name, i]));
650
+ const groupOfTable = new Map();
651
+ model.groups.forEach((g, gi) => {
652
+ for (const name of g.tables) {
653
+ groupOfTable.set(name, gi);
654
+ }
655
+ });
656
+ // Set of groups referenced by group gi (edges closed within a group are excluded).
657
+ const refsOf = model.groups.map(() => new Set());
658
+ for (const edge of model.edges) {
659
+ const gf = groupOfTable.get(edge.from);
660
+ const gt = groupOfTable.get(edge.to);
661
+ if (gf == null || gt == null || gf === gt) {
662
+ continue;
663
+ }
664
+ refsOf[gf]?.add(gt);
665
+ }
666
+ // level(g) = 0 if it references nothing, otherwise 1 + max(level of referenced groups)
667
+ // (SoT for the rule, including cycle cutting = graph-common.depthLevels).
668
+ const level = depthLevels(refsOf);
669
+ // Bands = ascending level. Within a band, stack top-down in declaration order (single rule:
670
+ // level → band. filter keeps the original order, so the order within a band is deterministic).
671
+ const bandLevels = [...new Set(level)].sort((a, b) => a - b);
672
+ const tables = [];
673
+ const groups = [];
674
+ // Annotate explicitly as `number`: M.margin etc. have literal types from `as const`
675
+ // (non-widening), so without it later assignments (e.g. Math.max results) are rejected.
676
+ let bandX = M.margin;
677
+ let bottom = M.margin;
678
+ for (const lv of bandLevels) {
679
+ const bandGroups = model.groups.filter((_, gi) => level[gi] === lv);
680
+ let bandWidth = 0;
681
+ let groupTop = M.margin;
682
+ for (const group of bandGroups) {
683
+ const indices = group.tables
684
+ .map((name) => indexOf.get(name))
685
+ .filter((i) => i != null);
686
+ if (indices.length === 0) {
687
+ continue;
688
+ }
689
+ const labeled = group.label != null && group.label !== "";
690
+ // Reserve the label area (frameLabelH) per group (contentTop is set per group so a
691
+ // lower stacked group's label doesn't collide with the frame of the group above).
692
+ const contentTop = groupTop + (labeled ? M.frameLabelH : 0);
693
+ const left = bandX + (labeled ? M.framePad : 0);
694
+ let colX = left;
695
+ let groupBottom = contentTop;
696
+ for (const col of splitColumns(indices, measured)) {
697
+ const colW = col.reduce((w, i) => Math.max(w, measured[i]?.w ?? 0), 0);
698
+ let y = contentTop;
699
+ for (const i of col) {
700
+ const m = measured[i];
701
+ const table = model.tables[i];
702
+ if (m == null || table == null) {
703
+ continue;
704
+ }
705
+ tables.push({
706
+ name: table.name,
707
+ index: i,
708
+ x: colX,
709
+ y,
710
+ w: m.w,
711
+ h: m.h,
712
+ headerH: M.headerH,
713
+ headerBaseline: y + M.headerH / 2 + M.baselineDrop,
714
+ radius: M.radius,
715
+ rows: m.rows.map((r) => ({
716
+ ...r,
717
+ y: r.y + y,
718
+ baseline: r.y + y + M.baselineDrop,
719
+ x: colX + M.padX,
720
+ typeX: colX + m.w - M.padX,
721
+ })),
722
+ });
723
+ y += m.h + M.gapY;
724
+ }
725
+ groupBottom = Math.max(groupBottom, y - M.gapY);
726
+ colX += colW + M.gapInnerX;
727
+ }
728
+ const contentW = colX - M.gapInnerX - left;
729
+ let groupTotalBottom;
730
+ if (labeled) {
731
+ groups.push({
732
+ label: group.label ?? "",
733
+ x: left - M.framePad,
734
+ y: contentTop - M.framePad,
735
+ w: contentW + M.framePad * 2,
736
+ h: groupBottom - contentTop + M.framePad * 2,
737
+ members: group.tables,
738
+ });
739
+ groupTotalBottom = groupBottom + M.framePad;
740
+ }
741
+ else {
742
+ groupTotalBottom = groupBottom;
743
+ }
744
+ // Band width = max width of the band's groups (frame pad included). Groups in a band
745
+ // are left-aligned to the band's left edge (bandX), so the occupied width is
746
+ // "(framePad margin if labeled) + contentW + (same)".
747
+ const occupiedW = (labeled ? M.framePad * 2 : 0) + contentW;
748
+ bandWidth = Math.max(bandWidth, occupiedW);
749
+ bottom = Math.max(bottom, groupTotalBottom);
750
+ groupTop = groupTotalBottom + M.gapGroupY;
751
+ }
752
+ bandX += bandWidth + M.gapGroupX;
753
+ }
754
+ const routed = routeSchemaEdgesWithExtent(tables, model.edges);
755
+ const edges = routed.edges;
756
+ const hasList = model.edges.some((e) => e.list);
757
+ const width = Math.max(M.minW + M.margin * 2, Math.ceil(tables.reduce((w, t) => Math.max(w, t.x + t.w), 0) + M.margin));
758
+ const contentBottom = Math.max(bottom, routed.bottom);
759
+ const legendY = hasList ? contentBottom + M.legendH - 8 : undefined;
760
+ const height = Math.ceil(contentBottom + M.margin + (hasList ? M.legendH : 0));
761
+ return {
762
+ width,
763
+ height,
764
+ groups,
765
+ tables,
766
+ edges,
767
+ ...(legendY == null ? {} : { legendY }),
768
+ };
769
+ };
770
+ /**
771
+ * Boxes + references → edge routes. A pure function so that layout (at build time) and the
772
+ * client (recomputing after a box drag) share the same routing rules (= one SoT for drawing).
773
+ * Bundling: edges that "enter the same target from the same column" share a trunk (vertical
774
+ * line) in the gutter next to that column and an entry point, merging into short exit stubs +
775
+ * one trunk + one crossing (the number of lines is target × column, not the reference count).
776
+ */
777
+ const routeSchemaEdgesWithExtent = (boxes, edges) => {
778
+ const indexOf = new Map(boxes.map((t) => [t.name, t.index]));
779
+ const byIndex = new Map(boxes.map((t) => [t.index, t]));
780
+ // Right edge of each column (= boxes sharing an x) and the gutters next to columns (vertical
781
+ // free lanes that carry trunks).
782
+ const colRight = new Map();
783
+ for (const t of boxes) {
784
+ colRight.set(t.x, Math.max(colRight.get(t.x) ?? 0, t.x + t.w));
785
+ }
786
+ const colXs = [...colRight.keys()].sort((a, b) => a - b);
787
+ const colIndex = new Map(colXs.map((x, i) => [x, i]));
788
+ // gutter = [left, right]. Outside the outermost columns, a virtual gutter `gapInnerX` wide.
789
+ const gutterRight = (colX) => {
790
+ const right = colRight.get(colX) ?? colX;
791
+ const next = colXs.find((x) => x > colX);
792
+ return [right, next ?? right + M.gapInnerX];
793
+ };
794
+ const gutterLeft = (colX) => {
795
+ const prevs = colXs.filter((x) => x < colX);
796
+ const prev = prevs[prevs.length - 1];
797
+ return [prev == null ? colX - M.gapInnerX : (colRight.get(prev) ?? colX), colX];
798
+ };
799
+ const mid = ([a, b]) => (a + b) / 2;
800
+ // Spread of the trunk lanes (kept within the gutter; 0 in a narrow gutter = all overlap).
801
+ const roomOf = ([a, b]) => Math.max(0, Math.min(12, (b - a) / 2 - 6));
802
+ // 1st pass: decide exit / entry / trunk gutter, and bundle edges that "enter the same target
803
+ // from the same column".
804
+ const routed = [];
805
+ for (const edge of edges) {
806
+ const from = indexOf.get(edge.from);
807
+ const to = indexOf.get(edge.to);
808
+ if (from == null || to == null) {
809
+ continue;
810
+ }
811
+ const s = byIndex.get(from);
812
+ const t = byIndex.get(to);
813
+ if (s == null || t == null) {
814
+ continue;
815
+ }
816
+ const row = s.rows.find((r) => r.fieldIndex === edge.fieldIndex);
817
+ const sy = row?.y ?? s.y + s.headerH / 2;
818
+ // Target to the right = exit right, enter its left edge / to the left = exit left, enter
819
+ // its right edge / same column (incl. self-reference) = detour via the right gutter and
820
+ // enter its right edge.
821
+ const rightward = t.x >= s.x + s.w;
822
+ const sameCol = !rightward && t.x + t.w > s.x;
823
+ const g = rightward || sameCol ? gutterRight(s.x) : gutterLeft(s.x);
824
+ const targetGutter = rightward ? gutterLeft(t.x) : gutterRight(t.x);
825
+ const sourceColumn = colIndex.get(s.x) ?? 0;
826
+ const targetColumn = colIndex.get(t.x) ?? 0;
827
+ routed.push({
828
+ edge,
829
+ from,
830
+ to,
831
+ sx: rightward || sameCol ? s.x + s.w : s.x,
832
+ sy,
833
+ tx: rightward ? t.x : t.x + t.w,
834
+ side: rightward ? "l" : "r",
835
+ bundle: `${to}:${rightward ? "l" : "r"}:${s.x}`,
836
+ base: mid(g),
837
+ room: roomOf(g),
838
+ long: Math.abs(sourceColumn - targetColumn) > 1,
839
+ targetBase: mid(targetGutter),
840
+ });
841
+ }
842
+ // 2nd pass: pick one trunk x and one entry y per bundle (= lines to the same target gather on
843
+ // the trunk, and a long crossing becomes one line per bundle). Bundles sharing a gutter get
844
+ // offset lanes so they don't overlap.
845
+ const bundles = new Map();
846
+ for (const r of routed) {
847
+ const bucket = bundles.get(r.bundle);
848
+ if (bucket) {
849
+ bucket.push(r);
850
+ }
851
+ else {
852
+ bundles.set(r.bundle, [r]);
853
+ }
854
+ }
855
+ const trunkX = new Map();
856
+ const laneGroups = new Map();
857
+ for (const [key, bucket] of bundles) {
858
+ const first = bucket[0];
859
+ if (first == null) {
860
+ continue;
861
+ }
862
+ const laneKey = `${Math.round(first.base)}`;
863
+ const group = laneGroups.get(laneKey);
864
+ if (group) {
865
+ group.push(key);
866
+ }
867
+ else {
868
+ laneGroups.set(laneKey, [key]);
869
+ }
870
+ }
871
+ for (const group of laneGroups.values()) {
872
+ group.forEach((key, k) => {
873
+ const first = bundles.get(key)?.[0];
874
+ if (first == null) {
875
+ return;
876
+ }
877
+ const step = group.length <= 1
878
+ ? 0
879
+ : Math.min(M.lane, (first.room * 2) / (group.length - 1) || 0);
880
+ trunkX.set(key, first.base + (k - (group.length - 1) / 2) * step);
881
+ });
882
+ }
883
+ // Entry y = center of the target box's header. Fan out slightly only when several bundles
884
+ // enter the same side.
885
+ const entryY = new Map();
886
+ const sides = new Map();
887
+ for (const [key, bucket] of bundles) {
888
+ const first = bucket[0];
889
+ if (first == null) {
890
+ continue;
891
+ }
892
+ const sideKey = `${first.to}:${first.side}`;
893
+ const group = sides.get(sideKey);
894
+ if (group) {
895
+ group.push(key);
896
+ }
897
+ else {
898
+ sides.set(sideKey, [key]);
899
+ }
900
+ }
901
+ for (const group of sides.values()) {
902
+ group.forEach((key, k) => {
903
+ const first = bundles.get(key)?.[0];
904
+ const t = first == null ? undefined : byIndex.get(first.to);
905
+ if (t == null) {
906
+ return;
907
+ }
908
+ const step = Math.min(6, (t.h - M.headerH) / Math.max(1, group.length));
909
+ entryY.set(key, t.y + t.headerH / 2 + (k - (group.length - 1) / 2) * step);
910
+ });
911
+ }
912
+ // Edges with another table column in between are diverted, per bundle, below the bottom of
913
+ // the graph so their horizontal lines don't cross those tables. A bundle also shares its lane,
914
+ // so the height doesn't grow in proportion to the number of lines.
915
+ const tableBottom = boxes.reduce((value, box) => Math.max(value, box.y + box.h), 0);
916
+ const longLaneY = new Map();
917
+ const longBundles = [...bundles.entries()].filter(([, bucket]) => bucket[0]?.long === true);
918
+ longBundles.forEach(([key], i) => {
919
+ longLaneY.set(key, tableBottom + M.gapY + i * M.lane);
920
+ });
921
+ const edgeBottom = longBundles.length === 0
922
+ ? tableBottom
923
+ : tableBottom + M.gapY + (longBundles.length - 1) * M.lane;
924
+ return {
925
+ edges: routed.map((r) => {
926
+ const sourceTrunk = trunkX.get(r.bundle) ?? r.base;
927
+ const targetY = entryY.get(r.bundle) ?? r.sy;
928
+ const laneY = longLaneY.get(r.bundle);
929
+ return {
930
+ from: r.from,
931
+ to: r.to,
932
+ fieldIndex: r.edge.fieldIndex,
933
+ list: r.edge.list,
934
+ d: laneY == null
935
+ ? orthPath(r.sx, r.sy, sourceTrunk, targetY, r.tx, M.corner)
936
+ : roundedOrthogonalPath([
937
+ { x: r.sx, y: r.sy },
938
+ { x: sourceTrunk, y: r.sy },
939
+ { x: sourceTrunk, y: laneY },
940
+ { x: r.targetBase, y: laneY },
941
+ { x: r.targetBase, y: targetY },
942
+ { x: r.tx, y: targetY },
943
+ ], M.corner),
944
+ };
945
+ }),
946
+ bottom: edgeBottom,
947
+ };
948
+ };
949
+ export const routeSchemaEdges = (boxes, edges) => routeSchemaEdgesWithExtent(boxes, edges).edges;
950
+ /** layout → interactive-UI data (payload embedded in the static SVG; same positions / content). */
951
+ export const schemaGraphClientData = (uid, model, layout, source) => ({
952
+ uid,
953
+ width: layout.width,
954
+ height: layout.height,
955
+ nodes: layout.tables.map((t) => ({
956
+ name: t.name,
957
+ index: t.index,
958
+ x: t.x,
959
+ y: t.y,
960
+ w: t.w,
961
+ h: t.h,
962
+ headerH: t.headerH,
963
+ rows: t.rows.map((r) => ({
964
+ kind: r.kind,
965
+ label: r.name,
966
+ type: r.type,
967
+ dy: round1(r.y - t.y),
968
+ ...(r.fieldIndex == null ? {} : { fieldIndex: r.fieldIndex }),
969
+ })),
970
+ })),
971
+ edges: model.edges,
972
+ groups: layout.groups.map((g) => ({
973
+ label: g.label,
974
+ x: g.x,
975
+ y: g.y,
976
+ w: g.w,
977
+ h: g.h,
978
+ members: g.members,
979
+ })),
980
+ ...(source == null || source === "" ? {} : { source }),
981
+ });