@qualisoft/ai-skills 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +234 -0
  3. package/bin/cli.mjs +331 -0
  4. package/package.json +53 -0
  5. package/skills/erd-visual/SKILL.md +294 -0
  6. package/skills/erd-visual/build.mjs +404 -0
  7. package/skills/erd-visual/engine/dbml.mjs +133 -0
  8. package/skills/erd-visual/engine/ingest.mjs +273 -0
  9. package/skills/erd-visual/engine/layout.mjs +165 -0
  10. package/skills/erd-visual/engine/overview.mjs +314 -0
  11. package/skills/erd-visual/engine/render.mjs +144 -0
  12. package/skills/erd-visual/engine/router.mjs +401 -0
  13. package/skills/erd-visual/engine/verify.mjs +138 -0
  14. package/skills/erd-visual/engine/wire.mjs +115 -0
  15. package/skills/erd-visual/fixtures/crm-large.dbml +1337 -0
  16. package/skills/erd-visual/fixtures/edge-cases.dbml +43 -0
  17. package/skills/erd-visual/fixtures/map-dynamics.json +32 -0
  18. package/skills/erd-visual/fixtures/shop-basic.dbml +78 -0
  19. package/skills/erd-visual/fixtures/src-json/api.json +38 -0
  20. package/skills/erd-visual/fixtures/src-prisma/schema.prisma +44 -0
  21. package/skills/erd-visual/fixtures/src-sql/shop.sql +62 -0
  22. package/skills/erd-visual/readers/csv.mjs +81 -0
  23. package/skills/erd-visual/readers/index.mjs +55 -0
  24. package/skills/erd-visual/readers/jsonschema.mjs +86 -0
  25. package/skills/erd-visual/readers/prisma.mjs +102 -0
  26. package/skills/erd-visual/readers/sql.mjs +205 -0
  27. package/skills/erd-visual/readers/xlsx.mjs +220 -0
  28. package/skills/erd-visual/readers/xml.mjs +130 -0
  29. package/skills/erd-visual/readers/zip.mjs +74 -0
  30. package/skills/erd-visual/viewer/index.html +539 -0
  31. package/skills/project-build/SKILL.md +171 -0
  32. package/skills/project-build/templates/build-rules.md +88 -0
  33. package/skills/project-build/templates/change-log.md +29 -0
  34. package/skills/project-build/templates/coverage.md +49 -0
  35. package/skills/project-build/templates/parity.html +375 -0
  36. package/skills/project-build/templates/theme.css +45 -0
  37. package/skills/project-design/MEDIUMS.md +104 -0
  38. package/skills/project-design/QUESTIONS.md +126 -0
  39. package/skills/project-design/SKILL.md +365 -0
  40. package/skills/project-design/templates/audit.html +689 -0
  41. package/skills/project-design/templates/change-log.md +53 -0
  42. package/skills/project-design/templates/design-rules.md +118 -0
  43. package/skills/project-design/templates/mockup-app.html +96 -0
  44. package/skills/project-design/templates/mockup-slides.html +82 -0
  45. package/skills/project-design/templates/mockup-web.html +45 -0
  46. package/skills/project-design/templates/styleguide.html +436 -0
  47. package/skills/project-design/templates/tokens.css +69 -0
  48. package/skills/project-design/templates/tone-options.html +138 -0
  49. package/skills/project-init/GUIDE.md +370 -0
  50. package/skills/project-init/RUNBOOK.md +192 -0
  51. package/skills/project-init/SKILL.md +382 -0
  52. package/skills/project-init/build.mjs +2898 -0
  53. package/skills/project-init/evals/RUBRIC.md +81 -0
  54. package/skills/project-init/evals/cases/conflicting.expect.json +17 -0
  55. package/skills/project-init/evals/cases/conflicting.md +9 -0
  56. package/skills/project-init/evals/cases/vague-idea.expect.json +11 -0
  57. package/skills/project-init/evals/cases/vague-idea.md +7 -0
  58. package/skills/project-init/evals/cases/well-formed.expect.json +18 -0
  59. package/skills/project-init/evals/cases/well-formed.md +28 -0
  60. package/skills/project-init/markdown.mjs +0 -0
  61. package/skills/project-init/modules/a11y.json +46 -0
  62. package/skills/project-init/modules/ai.json +77 -0
  63. package/skills/project-init/modules/audience.json +48 -0
  64. package/skills/project-init/modules/backend.json +129 -0
  65. package/skills/project-init/modules/brand.json +78 -0
  66. package/skills/project-init/modules/core.json +132 -0
  67. package/skills/project-init/modules/design.json +72 -0
  68. package/skills/project-init/modules/engineering.json +86 -0
  69. package/skills/project-init/modules/mobile.json +22 -0
  70. package/skills/project-init/modules/ops.json +122 -0
  71. package/skills/project-init/modules/process.json +104 -0
  72. package/skills/project-init/modules/product.json +37 -0
  73. package/skills/project-init/modules/ux.json +52 -0
  74. package/skills/project-init/modules/web.json +129 -0
  75. package/skills/project-init/presets/ai-product.json +12 -0
  76. package/skills/project-init/presets/internal-system.json +15 -0
  77. package/skills/project-init/presets/mobile-app.json +20 -0
  78. package/skills/project-init/presets/web-corporate.json +116 -0
  79. package/skills/project-init/schema.json +80 -0
  80. package/skills/project-init/templates/log.md +54 -0
  81. package/skills/project-init/templates/readme.md +62 -0
  82. package/skills/project-init/templates/reference.md +34 -0
  83. package/skills/project-init/templates/register.md +63 -0
  84. package/skills/project-init/templates/spec.md +46 -0
  85. package/skills/project-interview/INTERVIEW.md +221 -0
  86. package/skills/project-interview/SKILL.md +156 -0
  87. package/skills/project-interview/fixtures/brief.md +49 -0
  88. package/skills/project-interview/fixtures/decisions.md +13 -0
  89. package/skills/project-interview/fixtures/open-questions.md +9 -0
  90. package/skills/project-interview/templates/brief.md +179 -0
  91. package/skills/project-interview/templates/decisions.md +55 -0
  92. package/skills/project-interview/templates/open-questions.md +40 -0
  93. package/skills/project-interview/validate.mjs +49 -0
@@ -0,0 +1,314 @@
1
+ /* 조감도 — 고객 설명용 한 장.
2
+ *
3
+ * 전체 관계도가 "빠짐없이"를 목표로 한다면, 조감도는 "이해되게"가 목표다.
4
+ * 그래서 배치는 자동이 아니라 **사람이 정한다**(overview.json).
5
+ *
6
+ * 대신 **연결선은 자동으로 대조한다.** 조감도의 화살표 하나하나가
7
+ * DBML 의 실제 Ref 로 뒷받침되는지 검사하고, 근거가 없으면 빌드를 세운다.
8
+ * 손으로 그린 그림은 언제든 사실과 어긋날 수 있기 때문이다.
9
+ */
10
+
11
+ import { OrthoC, ports, spread, rounded } from './router.mjs';
12
+ import { fitText } from './verify.mjs';
13
+ import { INK, INK_2, INK_3, LINE, PAPER, PAPER_2, WIRE, PASTEL, esc } from './render.mjs';
14
+
15
+ /** 노드가 대표하는 테이블 집합 */
16
+ const tablesOf = n => (n.tables && n.tables.length ? n.tables : [n.id]);
17
+
18
+ /**
19
+ * 조감도 명세를 DBML 과 대조한다.
20
+ * · edges : 근거가 되는 Ref 가 하나라도 있어야 한다
21
+ * · gaps : 양방향 모두 Ref 가 **없어야** 한다 (없다고 주장하는 선이므로)
22
+ * · nodes : 가리키는 테이블이 실제로 있어야 한다
23
+ */
24
+ export function verifyOverview(spec, model) {
25
+ const nodes = new Map((spec.nodes || []).map(n => [n.id, n]));
26
+ const refKey = new Set(model.refs.map(r => r.from + '>' + r.to));
27
+ const known = model.tables;
28
+
29
+ const missingTables = [], edgeNoEvidence = [], gapContradicted = [], badRef = [];
30
+
31
+ for (const n of spec.nodes || []) {
32
+ for (const t of tablesOf(n)) if (!known.has(t)) missingTables.push({ node: n.id, table: t });
33
+ }
34
+
35
+ const between = (a, b) => {
36
+ const A = tablesOf(nodes.get(a) || { id: a });
37
+ const B = tablesOf(nodes.get(b) || { id: b });
38
+ const hits = [];
39
+ for (const x of A) for (const y of B) {
40
+ if (x === y) continue;
41
+ for (const r of model.refs) if (r.from === x && r.to === y) hits.push(r);
42
+ }
43
+ return hits;
44
+ };
45
+
46
+ for (const e of spec.edges || []) {
47
+ if (!nodes.has(e.from) || !nodes.has(e.to)) { badRef.push(e); continue; }
48
+ const hits = between(e.from, e.to);
49
+ if (!hits.length) edgeNoEvidence.push(e);
50
+ else e._evidence = hits.map(r => r.from + '.' + r.col + ' > ' + r.to);
51
+ e._count = hits.length;
52
+ }
53
+
54
+ for (const gp of spec.gaps || []) {
55
+ if (!nodes.has(gp.from) || !nodes.has(gp.to)) { badRef.push(gp); continue; }
56
+ const fwd = between(gp.from, gp.to), rev = between(gp.to, gp.from);
57
+ gp._contradicted = !!(fwd.length || rev.length);
58
+ if (gp._contradicted) gapContradicted.push({ ...gp, found: [...fwd, ...rev].slice(0, 3) });
59
+ }
60
+
61
+ return {
62
+ ok: !missingTables.length && !edgeNoEvidence.length && !gapContradicted.length && !badRef.length,
63
+ missingTables, edgeNoEvidence, gapContradicted, badRef,
64
+ edges: (spec.edges || []).length,
65
+ gaps: (spec.gaps || []).length,
66
+ };
67
+ }
68
+
69
+ export function renderOverview(spec, model, opt = {}) {
70
+ const W = spec.canvas?.w || 1720, H = spec.canvas?.h || 980;
71
+ const nodes = spec.nodes || [];
72
+ const NI = new Map(nodes.map((n, i) => [n.id, i]));
73
+
74
+ // 그룹별 파스텔 — 지정이 없으면 등장 순서로 배정
75
+ const gseen = [];
76
+ for (const n of nodes) if (n.group && !gseen.includes(n.group)) gseen.push(n.group);
77
+ const colorOf = n => {
78
+ const i = n.group ? gseen.indexOf(n.group) : 0;
79
+ return PASTEL[(i < 0 ? 0 : i) % PASTEL.length];
80
+ };
81
+
82
+ const RECT = nodes.map(n => ({ x: n.x, y: n.y, w: n.w, h: n.h }));
83
+ // 콜아웃도 장애물 — 선이 뚫고 지나가면 안 된다
84
+ const OBST = RECT.slice();
85
+ const call = spec.callout;
86
+ if (call) OBST.push({ x: call.x, y: call.y, w: call.w, h: call.h });
87
+
88
+ const edges = spec.edges || [], gaps = spec.gaps || [];
89
+ const ALL = [...edges, ...gaps].map(e => [NI.get(e.from), NI.get(e.to)]);
90
+
91
+ const { port } = ports(ALL, RECT, 14);
92
+ const lanesX = [], lanesY = [];
93
+ for (const n of nodes) for (const k of [1, 2, 3]) {
94
+ lanesX.push(n.x - 14 * k, n.x + n.w + 14 * k);
95
+ lanesY.push(n.y - 13 * k, n.y + n.h + 13 * k);
96
+ }
97
+ const RT = new OrthoC(OBST, W, H, {
98
+ margin: 16, clear: 10,
99
+ lanesX: lanesX.filter(v => v > 20 && v < W - 20),
100
+ lanesY: lanesY.filter(v => v > 20 && v < H - 20),
101
+ tol: 11, cong: 260,
102
+ });
103
+
104
+ // 긴 선부터 — 짧은 선이 먼저 자리를 잡으면 긴 선이 크게 돈다
105
+ const order = ALL.map((_, k) => k).sort((a, b) => {
106
+ const d = k => Math.abs(RECT[ALL[k][0]].x - RECT[ALL[k][1]].x)
107
+ + Math.abs(RECT[ALL[k][0]].y - RECT[ALL[k][1]].y);
108
+ return d(b) - d(a);
109
+ });
110
+ const paths = new Array(ALL.length);
111
+ for (const k of order) {
112
+ const [ai, bi] = ALL[k];
113
+ const p0 = port.get(k + '|a'), p1 = port.get(k + '|b');
114
+ const pts = RT.route(p0, p1, new Set([ai, bi]), 300);
115
+ if (pts) RT.commit(pts);
116
+ paths[k] = pts || [p0, p1];
117
+ }
118
+ const skips = ALL.map(([a, b]) => new Set([a, b]));
119
+ const out = spread(paths, OBST, skips, { gap: 11, tol: 9, rounds: 4 });
120
+
121
+ const P = [], A = s => P.push(s);
122
+ A('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '" id="overview" ' +
123
+ 'font-family="Pretendard, -apple-system, \'Apple SD Gothic Neo\', sans-serif">');
124
+ A('<defs><marker id="ov-ah" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6" markerHeight="6" ' +
125
+ 'orient="auto-start-reverse"><path d="M0,1 L9,5 L0,9 z" fill="' + WIRE + '"/></marker></defs>');
126
+ A('<rect width="' + W + '" height="' + H + '" fill="' + PAPER + '"/>');
127
+ A('<text x="70" y="68" font-size="34" font-weight="800" fill="' + INK + '" letter-spacing="-.025em">' +
128
+ esc(spec.title || '조감도') + '</text>');
129
+ A('<text x="70" y="98" font-size="14.5" fill="' + INK_2 + '">' + esc(spec.subtitle || '') + '</text>');
130
+ A('<line x1="70" y1="122" x2="' + (W - 70) + '" y2="122" stroke="' + INK + '" stroke-width="1.8"/>');
131
+
132
+ for (const b of spec.bands || []) {
133
+ A('<rect x="' + b.x + '" y="' + b.y + '" width="' + b.w + '" height="' + b.h +
134
+ '" rx="16" fill="' + PAPER_2 + '" stroke="' + LINE + '" stroke-width="1.4"/>');
135
+ A('<text x="' + (b.x + 20) + '" y="' + (b.y + 28) + '" font-size="12.5" font-weight="800" fill="' +
136
+ INK_3 + '" letter-spacing=".14em">' + esc(b.label) + '</text>');
137
+ }
138
+
139
+ // 배선 — 근거를 잃은 선은 눈에 보이게 남긴다.
140
+ // 편집으로 관계가 사라졌는데 그림만 그대로면 조용히 거짓이 된다.
141
+ edges.forEach((e, k) => {
142
+ const has = e._count === undefined ? true : e._count > 0;
143
+ const n = e.weight || e._count || 1;
144
+ A('<path class="ove' + (has ? '' : ' noev') + '" data-a="' + esc(e.from) + '" data-b="' +
145
+ esc(e.to) + '" d="' + rounded(out[k], 10) + '" fill="none" stroke="' +
146
+ (has ? WIRE : '#B42318') + '" stroke-width="' +
147
+ (has ? (1.5 + Math.min(n, 8) * 0.34).toFixed(1) : '2.6') + '" opacity="' +
148
+ (has ? '.5' : '.95') + '"' + (has ? '' : ' stroke-dasharray="3 5"') +
149
+ ' marker-end="url(#ov-ah)"><title>' +
150
+ esc(e.label || (e.from + ' → ' + e.to)) +
151
+ (has ? ' · 외래키 ' + n + '건' : ' · ⚠ DBML 에 근거가 없습니다') + '</title></path>');
152
+ });
153
+ gaps.forEach((gp, j) => {
154
+ const k = edges.length + j;
155
+ const bad = gp._contradicted;
156
+ A('<path class="ove gap' + (bad ? ' noev' : '') + '" data-a="' + esc(gp.from) + '" data-b="' +
157
+ esc(gp.to) + '" d="' + rounded(out[k], 10) + '" fill="none" stroke="' +
158
+ (bad ? '#B42318' : INK) + '" stroke-width="2.6" stroke-dasharray="8 6" opacity="' +
159
+ (bad ? '.95' : '.9') + '"><title>' + esc(gp.title || '') + ' — ' +
160
+ (bad ? '⚠ 실제로는 관계가 있습니다' : esc(gp.note || '외래키 없음')) + '</title></path>');
161
+ const pts = out[k], mid = pts[Math.floor(pts.length / 2)];
162
+ const prev = pts[Math.floor(pts.length / 2) - 1] || mid;
163
+ const mx = (mid[0] + prev[0]) / 2, my = (mid[1] + prev[1]) / 2;
164
+ A('<circle cx="' + mx.toFixed(0) + '" cy="' + my.toFixed(0) + '" r="14.5" fill="' + INK + '"/>');
165
+ A('<text x="' + mx.toFixed(0) + '" y="' + (my + 5.5).toFixed(0) + '" font-size="14.5" ' +
166
+ 'font-weight="800" fill="' + PAPER + '" text-anchor="middle">' + (j + 1) + '</text>');
167
+ });
168
+
169
+ // 노드
170
+ for (const n of nodes) {
171
+ const [bg, ac] = colorOf(n);
172
+ const deg = edges.filter(e => e.from === n.id || e.to === n.id).length;
173
+ A('<g class="ovn" data-id="' + esc(n.id) + '" data-deg="' + deg + '"><title>' +
174
+ esc(n.label) + ' · ' + esc(tablesOf(n).join(', ')) + '</title>');
175
+ A('<rect class="hit" x="' + (n.x - 4) + '" y="' + (n.y - 4) + '" width="' + (n.w + 8) +
176
+ '" height="' + (n.h + 8) + '" rx="15" fill="transparent"/>');
177
+ A('<rect class="bd0" x="' + n.x + '" y="' + n.y + '" width="' + n.w + '" height="' + n.h +
178
+ '" rx="12" fill="' + bg + '" stroke="' + INK + '" stroke-width="1.8"/>');
179
+ A('<rect x="' + n.x + '" y="' + (n.y + 12) + '" width="5" height="' + (n.h - 24) +
180
+ '" rx="2.5" fill="' + ac + '"/>');
181
+ A('<text x="' + (n.x + 20) + '" y="' + (n.y + 31) + '" font-size="18" font-weight="800" fill="' +
182
+ INK + '" letter-spacing="-.015em">' + esc(fitText(n.label, n.w - 34, 18)) + '</text>');
183
+ A('<text x="' + (n.x + 20) + '" y="' + (n.y + 52) + '" font-size="11.5" font-weight="600" fill="' +
184
+ INK_2 + '" font-family="ui-monospace, Menlo, monospace">' +
185
+ esc(fitText(n.sub || tablesOf(n).join(' · '), n.w - 34, 11.5, 1.0, 0.62)) + '</text>');
186
+ A('</g>');
187
+ }
188
+
189
+ // 콜아웃 — 끊긴 고리 설명
190
+ if (call) {
191
+ A('<rect x="' + call.x + '" y="' + call.y + '" width="' + call.w + '" height="' + call.h +
192
+ '" rx="14" fill="' + PAPER + '" stroke="' + INK + '" stroke-width="2"/>');
193
+ A('<rect x="' + call.x + '" y="' + call.y + '" width="' + call.w + '" height="40" rx="14" fill="' + INK + '"/>');
194
+ A('<rect x="' + call.x + '" y="' + (call.y + 26) + '" width="' + call.w + '" height="14" fill="' + INK + '"/>');
195
+ A('<text x="' + (call.x + 20) + '" y="' + (call.y + 27) + '" font-size="15" font-weight="800" fill="' +
196
+ PAPER + '">' + esc(call.title || '확인이 필요한 곳') + '</text>');
197
+ A('<text x="' + (call.x + 20) + '" y="' + (call.y + 60) + '" font-size="11.5" fill="' + INK_2 + '">' +
198
+ esc(call.sub || '') + '</text>');
199
+ gaps.forEach((gp, k) => {
200
+ const yy = call.y + 60 + (k + 1) * 58;
201
+ A('<circle cx="' + (call.x + 30) + '" cy="' + yy + '" r="11" fill="' + INK + '"/>');
202
+ A('<text x="' + (call.x + 30) + '" y="' + (yy + 4.5) + '" font-size="12.5" font-weight="800" fill="' +
203
+ PAPER + '" text-anchor="middle">' + (k + 1) + '</text>');
204
+ A('<text x="' + (call.x + 50) + '" y="' + (yy - 2) + '" font-size="13" font-weight="800" fill="' +
205
+ INK + '">' + esc(fitText(gp.title || '', call.w - 70, 13)) + '</text>');
206
+ A('<text x="' + (call.x + 50) + '" y="' + (yy + 16) + '" font-size="11.5" fill="' + INK_2 + '">' +
207
+ esc(fitText(gp.note || '', call.w - 70, 11.5)) + '</text>');
208
+ });
209
+ if (call.foot)
210
+ A('<text x="' + (call.x + 20) + '" y="' + (call.y + call.h - 14) + '" font-size="11" fill="' +
211
+ INK_3 + '">' + esc(fitText(call.foot, call.w - 40, 11)) + '</text>');
212
+ }
213
+
214
+ // 범례
215
+ A('<line x1="70" y1="' + (H - 72) + '" x2="' + (W - 70) + '" y2="' + (H - 72) +
216
+ '" stroke="' + LINE + '" stroke-width="1.2"/>');
217
+ A('<g transform="translate(70,' + (H - 40) + ')" font-size="13" fill="' + INK_2 + '">');
218
+ A('<path d="M0,-6 L22,-6 L22,-14 L44,-14" fill="none" stroke="' + WIRE + '" stroke-width="2.6" ' +
219
+ 'stroke-linejoin="round" opacity=".55" marker-end="url(#ov-ah)"/>');
220
+ A('<text x="60" y="-1">실제 외래키 (DBML 대조 완료) · 선 굵기 = 참조 수</text>');
221
+ if (gaps.length) {
222
+ A('<path d="M470,-6 L502,-6" fill="none" stroke="' + INK + '" stroke-width="2.6" stroke-dasharray="8 6"/>');
223
+ A('<circle cx="519" cy="-6" r="9.5" fill="' + INK + '"/>');
224
+ A('<text x="538" y="-1" font-weight="700" fill="' + INK + '">없는 연결 — 좌측 박스 참조</text>');
225
+ }
226
+ A('</g></svg>');
227
+ return P.join('\n');
228
+ }
229
+
230
+
231
+ /** 이 조감도 명세가 이 스키마에 해당하는가.
232
+ * 스키마를 통째로 바꾸면 손으로 그린 그림은 '고칠 N건'이 아니라
233
+ * 애초에 해당 없는 그림이다. 그 둘을 구분해야 한다. */
234
+ export function overviewFit(spec, model) {
235
+ const nodes = (spec && spec.nodes) || [];
236
+ if (!nodes.length) return { total: 0, alive: 0, ratio: 0, applicable: false };
237
+ const alive = nodes.filter(n => tablesOf(n).some(t => model.tables.has(t))).length;
238
+ const ratio = alive / nodes.length;
239
+ return { total: nodes.length, alive, ratio, applicable: ratio >= 0.5 };
240
+ }
241
+
242
+ /** DBML 에서 조감도를 만든다 - 손으로 그린 명세가 없거나 안 맞을 때.
243
+ *
244
+ * 큐레이션된 조감도만큼 이야기를 담지는 못한다. 대신 틀리지 않는다 -
245
+ * 전부 실제 Ref 에서 나온 것이고, 배치는 관계도와 같은 순서 계산을 쓴다.
246
+ * 작은 스키마는 테이블 단위, 큰 스키마는 그룹 단위로 묶는다. */
247
+ export function autoOverview(model, lay, opt = {}) {
248
+ const byTable = model.tables.size <= (opt.tableLimit || 14);
249
+ const CW = 260, CH = 92, GX = 120, GY = 66, PADX = 70, TOP = 176;
250
+
251
+ const nodes = [], edges = [];
252
+
253
+ if (byTable) {
254
+ const cells = [];
255
+ for (const [t, p] of lay.pos) cells.push({ t, col: p.col, row: p.row, dom: p.dom });
256
+ const cols = [...new Set(cells.map(c => c.col))].sort((a, b) => a - b);
257
+ const cix = new Map(cols.map((c, i) => [c, i]));
258
+ for (const c of cells) {
259
+ const m = model.tables.get(c.t);
260
+ nodes.push({
261
+ id: c.t, label: m.label || c.t, sub: c.t, group: c.dom, tables: [c.t],
262
+ x: PADX + cix.get(c.col) * (CW + GX),
263
+ y: TOP + c.row * (CH + GY), w: CW, h: CH,
264
+ });
265
+ }
266
+ const seen = new Set();
267
+ for (const r of model.refs) {
268
+ if (r.from === r.to) continue;
269
+ const k = r.from + '>' + r.to;
270
+ if (seen.has(k)) continue;
271
+ seen.add(k);
272
+ const n = model.refs.filter(x => x.from === r.from && x.to === r.to).length;
273
+ edges.push({ from: r.from, to: r.to, label: r.col + (n > 1 ? ' 외 ' + (n - 1) : ''), weight: n });
274
+ }
275
+ } else {
276
+ const cnt = new Map();
277
+ for (const r of model.refs) {
278
+ const a = lay.pos.get(r.from).dom, b = lay.pos.get(r.to).dom;
279
+ if (a === b) continue;
280
+ cnt.set(a + ' ' + b, (cnt.get(a + ' ' + b) || 0) + 1);
281
+ }
282
+ const per = Math.max(1, Math.ceil(Math.sqrt(lay.order.length)));
283
+ lay.order.forEach((d, i) => {
284
+ const members = lay.cols.get(d) || [];
285
+ if (!members.length) return;
286
+ nodes.push({
287
+ id: d, label: d, sub: members.length + '개 테이블 · ' + members.slice(0, 2).join(', '),
288
+ group: d, tables: members,
289
+ x: PADX + (i % per) * (CW + GX),
290
+ y: TOP + Math.floor(i / per) * (CH + GY), w: CW, h: CH,
291
+ });
292
+ });
293
+ const ids = new Set(nodes.map(n => n.id));
294
+ for (const [k, n] of cnt) {
295
+ const [a, b] = k.split(' ');
296
+ if (!ids.has(a) || !ids.has(b)) continue;
297
+ edges.push({ from: a, to: b, label: '외래키 ' + n + '건', weight: n });
298
+ }
299
+ }
300
+
301
+ const W = Math.max(...nodes.map(n => n.x + n.w), 900) + PADX;
302
+ const H = Math.max(...nodes.map(n => n.y + n.h), 500) + 110;
303
+
304
+ return {
305
+ _auto: true,
306
+ title: opt.title || (byTable ? '테이블 관계 한눈에' : '그룹 흐름 한눈에'),
307
+ subtitle: '테이블 ' + model.tables.size + ' · 관계 ' + model.refs.length +
308
+ ' - DBML 에서 자동 생성했습니다. 모든 선은 실제 외래키입니다' +
309
+ (byTable ? '' : ' (그룹 사이 참조를 묶었습니다)'),
310
+ canvas: { w: W, h: H },
311
+ bands: [], gaps: [], callout: null,
312
+ nodes, edges,
313
+ };
314
+ }
@@ -0,0 +1,144 @@
1
+ /* 렌더 — 골조는 흑백, 카드만 파스텔
2
+ *
3
+ * 배선에서 색을 뺀 것이 핵심이다. 200개 선이 도메인별 색으로 갈리면
4
+ * 카드 색과 경쟁해서 어느 쪽도 안 읽힌다. 선은 무채색, 색은 카드에만.
5
+ */
6
+
7
+ import { rounded } from './router.mjs';
8
+ import { fitText } from './verify.mjs';
9
+
10
+ export const INK = '#09090B';
11
+ export const INK_2 = '#52525B';
12
+ export const INK_3 = '#A1A1AA';
13
+ export const LINE = '#E4E4E7';
14
+ export const PAPER = '#FFFFFF';
15
+ export const PAPER_2 = '#FAFAFA';
16
+ export const WIRE = '#18181B';
17
+
18
+ /** 파스텔 팔레트 — 그룹 이름이 아니라 순서로 배정한다.
19
+ * 어떤 DBML 이 와도 동작해야 하므로 도메인명을 박아두지 않는다. */
20
+ export const PASTEL = [
21
+ ['#FFEAD7', '#B4703C'], ['#DEF2E3', '#3F7A52'], ['#FBF1D5', '#9C8233'],
22
+ ['#E9E3FB', '#6A5CA6'], ['#DEEBF9', '#3F6D9B'], ['#FBE1E1', '#A45454'],
23
+ ['#DBF1F3', '#3F7F87'], ['#FBE1EC', '#9E4F73'], ['#DFF2EA', '#3F8069'],
24
+ ['#F3E8D6', '#8A6E43'], ['#E6E9F7', '#5A6296'], ['#F0E7F5', '#7A5E88'],
25
+ ['#EBEBED', '#5C5C64'], ['#F1F1F2', '#6E6E76'], ['#F6F6F7', '#83838B'],
26
+ ['#E4E4E7', '#4A4A51'],
27
+ ];
28
+
29
+ export function palette(order) {
30
+ const m = new Map();
31
+ order.forEach((d, i) => m.set(d, PASTEL[i % PASTEL.length]));
32
+ return m;
33
+ }
34
+
35
+ export const esc = s => String(s ?? '')
36
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
37
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
38
+
39
+ /** 그룹 이름을 헤더용으로 줄인다 */
40
+ const shortName = d => d.length > 12 ? d.slice(0, 11) + '…' : d;
41
+
42
+ export function renderDetail(model, lay, wiring, opt = {}) {
43
+ const { paths, bundlePaths, bundleKeys, bundleCount, isSysRef } = wiring;
44
+ const P = [];
45
+ const A = s => P.push(s);
46
+ const COL = opt.palette || palette(lay.order);
47
+ const title = opt.title || 'ERD';
48
+ const W = lay.W, H = lay.H, g = lay.geo;
49
+ const names = [...lay.pos.keys()];
50
+
51
+ A('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '" id="detail" ' +
52
+ 'font-family="Pretendard, -apple-system, \'Apple SD Gothic Neo\', sans-serif">');
53
+ A('<defs>' +
54
+ '<marker id="d-ah" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="5" markerHeight="5" ' +
55
+ 'orient="auto-start-reverse"><path d="M0,.9 L7.2,4 L0,7.1 z" fill="' + WIRE + '"/></marker>' +
56
+ '<marker id="b-ah" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="5.6" markerHeight="5.6" ' +
57
+ 'orient="auto-start-reverse"><path d="M0,1 L9,5 L0,9 z" fill="' + WIRE + '"/></marker></defs>');
58
+ A('<rect width="' + W + '" height="' + H + '" fill="' + PAPER + '"/>');
59
+ A('<text x="60" y="60" font-size="30" font-weight="800" fill="' + INK + '" letter-spacing="-.02em">' +
60
+ esc(title) + '</text>');
61
+ A('<text x="60" y="90" font-size="14" fill="' + INK_2 + '">' + esc(opt.subtitle || '') + '</text>');
62
+ A('<line x1="60" y1="112" x2="' + (W - 60) + '" y2="112" stroke="' + INK + '" stroke-width="1.6"/>');
63
+
64
+ // 도메인 구획 — 흑백 헤더 + 파스텔 점
65
+ for (const r of lay.domRects) {
66
+ const [bg] = COL.get(r.dom);
67
+ A('<rect x="' + (r.x - 16) + '" y="' + (r.y - 46) + '" width="' + (r.w + 32) + '" height="' + (r.h + 58) +
68
+ '" rx="14" fill="' + PAPER_2 + '" stroke="' + LINE + '" stroke-width="1.4"/>');
69
+ A('<rect x="' + (r.x - 16) + '" y="' + (r.y - 46) + '" width="' + (r.w + 32) + '" height="30" rx="14" fill="' + INK + '"/>');
70
+ A('<rect x="' + (r.x - 16) + '" y="' + (r.y - 30) + '" width="' + (r.w + 32) + '" height="14" fill="' + INK + '"/>');
71
+ A('<circle cx="' + (r.x + 2) + '" cy="' + (r.y - 31) + '" r="5" fill="' + bg + '"/>');
72
+ A('<text x="' + (r.x + 16) + '" y="' + (r.y - 26) + '" font-size="13.5" font-weight="700" fill="' + PAPER + '">' +
73
+ esc(shortName(r.label || r.dom)) + '</text>');
74
+ A('<text x="' + (r.x + r.w - 16) + '" y="' + (r.y - 26) + '" font-size="12.5" font-weight="600" fill="' +
75
+ INK_3 + '" text-anchor="end">' + r.count + '</text>');
76
+ }
77
+
78
+ // 도메인 번들 — 굵기 = 참조 수
79
+ A('<g id="bundles" fill="none" stroke-linejoin="round" stroke-linecap="round">');
80
+ bundlePaths.forEach((pts, k) => {
81
+ const [ga, gb] = bundleKeys[k];
82
+ const n = bundleCount[k];
83
+ A('<path class="bd" d="' + rounded(pts, 12) + '" stroke="' + WIRE + '" stroke-width="' +
84
+ (1.4 + Math.min(n, 11) * 0.56).toFixed(1) + '" opacity=".42" marker-end="url(#b-ah)">' +
85
+ '<title>' + esc(shortName(ga)) + ' → ' + esc(shortName(gb)) + ' · 외래키 ' + n + '건</title></path>');
86
+ });
87
+ A('</g>');
88
+
89
+ // 개별 배선 — 무채색
90
+ A('<g id="edges" fill="none" stroke-linejoin="round" stroke-linecap="round">');
91
+ model.refs.forEach((r, k) => {
92
+ const sys = isSysRef[k];
93
+ A('<path class="ed ' + (sys ? 'sys' : 'biz') + '" data-a="' + esc(r.from) + '" data-b="' + esc(r.to) +
94
+ '" d="' + rounded(paths[k], 8) + '" stroke="' + WIRE + '" stroke-width="1.15" opacity="' +
95
+ (sys ? 0.13 : 0.3) + '" marker-end="url(#d-ah)"><title>' +
96
+ esc(r.from) + '.' + esc(r.col) + ' → ' + esc(r.to) + '</title></path>');
97
+ });
98
+ A('</g>');
99
+
100
+ // 카드 — 파스텔 + 굵은 흑색 글자
101
+ A('<g id="cards">');
102
+ const INNER = g.CW - 30;
103
+ for (const t of names) {
104
+ const p = lay.pos.get(t), m = model.tables.get(t);
105
+ const [bg, ac] = COL.get(p.dom);
106
+ A('<g class="cd" data-t="' + esc(t) + '"><title>' + esc(m.label) + ' · ' + esc(t) + '</title>');
107
+ A('<rect class="hit" x="' + (p.x - 3) + '" y="' + (p.y - 3) + '" width="' + (p.w + 6) +
108
+ '" height="' + (p.h + 6) + '" rx="12" fill="transparent"/>');
109
+ A('<rect class="bd0" x="' + p.x + '" y="' + p.y + '" width="' + p.w + '" height="' + p.h +
110
+ '" rx="10" fill="' + (m.stub ? PAPER : bg) + '" stroke="' + INK + '" stroke-width="1.5"' +
111
+ (m.stub ? ' stroke-dasharray="5 4"' : '') + '/>');
112
+ A('<rect x="' + p.x + '" y="' + (p.y + 10) + '" width="5" height="' + (p.h - 20) +
113
+ '" rx="2.5" fill="' + ac + '"/>');
114
+ A('<text x="' + (p.x + 18) + '" y="' + (p.y + 27) + '" font-size="15.5" font-weight="800" fill="' +
115
+ INK + '" letter-spacing="-.01em">' + esc(fitText(m.label, INNER, 15.5)) + '</text>');
116
+ A('<text x="' + (p.x + 18) + '" y="' + (p.y + 48) + '" font-size="11" font-weight="600" fill="' +
117
+ INK_2 + '" font-family="ui-monospace, Menlo, monospace">' +
118
+ esc(fitText(t, INNER - 26, 11, 1.0, 0.62)) + '</text>');
119
+ if (m.total)
120
+ A('<text x="' + (p.x + p.w - 12) + '" y="' + (p.y + 48) + '" font-size="10.5" font-weight="600" fill="' +
121
+ INK_3 + '" text-anchor="end" font-family="ui-monospace, Menlo, monospace">' + m.total + '</text>');
122
+ if (m.custom)
123
+ A('<circle cx="' + (p.x + p.w - 13) + '" cy="' + (p.y + 17) + '" r="3.6" fill="' + INK + '"/>');
124
+ A('</g>');
125
+ }
126
+ A('</g>');
127
+
128
+ // 범례
129
+ const y = H - 40;
130
+ A('<line x1="60" y1="' + (y - 30) + '" x2="' + (W - 60) + '" y2="' + (y - 30) +
131
+ '" stroke="' + LINE + '" stroke-width="1.2"/>');
132
+ A('<g transform="translate(60,' + y + ')" font-size="12.5" fill="' + INK_2 + '">');
133
+ A('<circle cx="5" cy="-5" r="3.6" fill="' + INK + '"/><text x="18" y="0">커스텀 테이블</text>');
134
+ A('<rect x="150" y="-14" width="24" height="16" rx="5" fill="' + PAPER + '" stroke="' + INK +
135
+ '" stroke-width="1.5" stroke-dasharray="5 4"/><text x="184" y="0">참조되나 정의 없음</text>');
136
+ A('<path d="M400,-5 L426,-5 L426,-13 L452,-13" fill="none" stroke="' + WIRE +
137
+ '" stroke-width="2.4" stroke-linejoin="round" opacity=".5"/>' +
138
+ '<text x="464" y="0">실제 외래키 · 번들 굵기 = 참조 수</text>');
139
+ A('<text x="800" y="0">우측 숫자 = 총 속성</text>');
140
+ A('<rect x="960" y="-13" width="14" height="14" rx="4" fill="#DEEBF9" stroke="' + INK +
141
+ '" stroke-width="1.2"/><text x="984" y="0">카드 색 = 그룹</text>');
142
+ A('</g></svg>');
143
+ return P.join('\n');
144
+ }