@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,273 @@
1
+ /* 흡수 — 판독기 IR 을 DBML 로.
2
+ *
3
+ * 이 단계의 일은 요약이 아니라 **사실과 추정을 가르는 것**이다.
4
+ * 근거(evidence)가 없는 관계는 여기서 나가지 못한다.
5
+ */
6
+
7
+ /** 렌더러가 읽는 Note 형식으로 만든다 */
8
+ function tableNote(t, total, shown, stub) {
9
+ if (stub) return '문서 밖 엔티티 · 이 문서에 정의 없음 · 피참조 ' + (t.inbound || 0) + '건';
10
+ const label = t.label || t.name;
11
+ const kind = t.custom ? '커스텀' : '표준';
12
+ return label + ' (' + t.name + ') · ' + kind + ' · 총 속성 ' + total + '개 · 표시 ' + shown + '개';
13
+ }
14
+
15
+ const q = s => String(s ?? '').replace(/'/g, "\\'").replace(/\n/g, ' ');
16
+
17
+ /**
18
+ * @param irs 판독기 IR 배열
19
+ * @param opt { infer: boolean, project: string }
20
+ */
21
+ export function toDbml(irs, opt = {}) {
22
+ const infer = !!opt.infer;
23
+ const tables = new Map();
24
+ const groups = new Map();
25
+ const refs = [];
26
+ const dropped = [];
27
+ const warnings = [];
28
+ const unparsed = [];
29
+
30
+ // ── 테이블 병합 ──────────────────────────────────────────
31
+ for (const ir of irs) {
32
+ for (const w of ir.warnings) warnings.push(ir.source + ': ' + w);
33
+ for (const u of ir.unparsed) unparsed.push(ir.source + ': ' + u);
34
+ for (const t of ir.tables) {
35
+ if (tables.has(t.name)) {
36
+ warnings.push('중복 테이블 ' + t.name + ' (' + ir.source + ') — 먼저 읽은 정의를 유지합니다');
37
+ continue;
38
+ }
39
+ tables.set(t.name, { ...t, columns: t.columns.slice(), source: ir.source, inbound: 0 });
40
+ if (t.group) {
41
+ if (!groups.has(t.group)) groups.set(t.group, []);
42
+ groups.get(t.group).push(t.name);
43
+ }
44
+ }
45
+ }
46
+
47
+ // ── 관계 — 근거가 있는 것만 ──────────────────────────────
48
+ const seen = new Set();
49
+ for (const ir of irs) {
50
+ for (const r of ir.refs) {
51
+ if (!r.evidence) { dropped.push({ ...r, reason: '근거 문자열이 없음' }); continue; }
52
+ if (r.confidence === 'estimate' && !infer) {
53
+ dropped.push({ ...r, reason: '추정 관계 (--infer 없이는 Ref 로 내보내지 않음)' });
54
+ continue;
55
+ }
56
+ const k = r.from + '.' + r.col + '>' + r.to;
57
+ if (seen.has(k)) continue;
58
+ seen.add(k);
59
+ refs.push({ ...r, source: ir.source });
60
+ }
61
+ }
62
+
63
+ // ── 그룹 힌트 적용 (SiteMap 류 XML 이 준 것) ──────────────
64
+ const hints = new Map();
65
+ for (const ir of irs) for (const h of ir.groupHints || []) hints.set(h.entity, h.group);
66
+ if (hints.size) {
67
+ let applied = 0;
68
+ for (const [name, t] of tables) {
69
+ const g = hints.get(name);
70
+ if (!g || t.group) continue;
71
+ t.group = g; applied++;
72
+ if (!groups.has(g)) groups.set(g, []);
73
+ groups.get(g).push(name);
74
+ }
75
+ warnings.push('그룹 힌트 ' + hints.size + '건 중 ' + applied + '건을 적용했습니다');
76
+ }
77
+
78
+ // ── 참조 대상이 정의에 없으면 스텁으로 남긴다 ─────────────
79
+ for (const r of refs) {
80
+ if (!tables.has(r.to)) {
81
+ tables.set(r.to, {
82
+ name: r.to, stub: true, columns: [{ name: (r.toCol || r.to + 'id'), type: 'uniqueidentifier', pk: true }],
83
+ inbound: 0, source: '(정의 없음)',
84
+ });
85
+ warnings.push('참조 대상이 정의에 없어 스텁으로 남깁니다: ' + r.to);
86
+ }
87
+ }
88
+ for (const r of refs) tables.get(r.to).inbound++;
89
+
90
+ // 스텁의 PK 이름을 실제 참조 컬럼에 맞춘다
91
+ for (const r of refs) {
92
+ const t = tables.get(r.to);
93
+ if (t.stub && r.toCol) t.columns[0].name = r.toCol;
94
+ }
95
+
96
+ // ── 그룹 — 근거가 있을 때만. 없으면 억지로 묶지 않는다 ────
97
+ const grouped = new Set([...groups.values()].flat());
98
+ const ungrouped = [...tables.keys()].filter(n => !grouped.has(n));
99
+ if (ungrouped.length) {
100
+ const stubs = ungrouped.filter(n => tables.get(n).stub);
101
+ const rest = ungrouped.filter(n => !tables.get(n).stub);
102
+ if (rest.length) groups.set(opt.project || '전체', rest);
103
+ if (stubs.length) groups.set('문서 밖', stubs);
104
+ }
105
+
106
+ // ── DBML 직렬화 ─────────────────────────────────────────
107
+ const L = [];
108
+ L.push('// ' + (opt.project || 'schema') + ' — erd-visual ingest');
109
+ L.push('// 원본: ' + irs.map(i => i.source).join(', '));
110
+ L.push('// 생성: ' + new Date().toISOString().slice(0, 10));
111
+ L.push('//');
112
+ L.push('// 관계는 원본의 명시적 근거에서만 뽑았습니다.');
113
+ L.push('// 추정 관계' + (infer ? '는 컬럼 note 에 "추정:" 으로 표시했습니다.' : '은 포함하지 않았습니다.'));
114
+ L.push('');
115
+
116
+ for (const [gname, members] of groups) {
117
+ if (!members.length) continue;
118
+ L.push('// ─── ' + gname + ' ' + '─'.repeat(Math.max(0, 40 - gname.length)));
119
+ L.push('');
120
+ for (const name of members) {
121
+ const t = tables.get(name);
122
+ const cols = t.columns || [];
123
+ L.push('Table ' + name + ' {');
124
+ for (const c of cols) {
125
+ const s = [];
126
+ if (c.pk) s.push('pk');
127
+ if (c.notNull) s.push('not null');
128
+ const rr = refs.find(r => r.from === name && r.col === c.name);
129
+ let note = c.note || c.name;
130
+ if (rr) note += ' → ' + rr.to;
131
+ if (c.estimate) note = '추정: ' + note;
132
+ s.push("note: '" + q(note) + "'");
133
+ L.push(' ' + c.name + ' ' + (c.type || 'unknown') + ' [' + s.join(', ') + ']');
134
+ }
135
+ L.push(" Note: '" + q(tableNote(t, cols.length, cols.length, t.stub)) + "'");
136
+ L.push('}');
137
+ L.push('');
138
+ }
139
+ L.push('TableGroup "' + gname + '" {');
140
+ for (const n of members) L.push(' ' + n);
141
+ L.push('}');
142
+ L.push('');
143
+ }
144
+
145
+ L.push('// ═══════ 관계 — 원본에 근거가 있는 것만 ═══════');
146
+ L.push('');
147
+ for (const r of refs) {
148
+ const target = tables.get(r.to);
149
+ const toCol = r.toCol || (target.columns[0] && target.columns[0].name) || (r.to + 'id');
150
+ L.push('Ref: ' + r.from + '.' + r.col + ' > ' + r.to + '.' + toCol);
151
+ }
152
+ L.push('');
153
+
154
+ return {
155
+ dbml: L.join('\n'),
156
+ tables, groups, refs, dropped, warnings, unparsed,
157
+ stubs: [...tables.values()].filter(t => t.stub).map(t => t.name),
158
+ };
159
+ }
160
+
161
+ /** 뽑은 Ref 가 전부 원본 근거로 되짚어지는가 — 누락·창작 0 이어야 한다 */
162
+ export function reconcile(irs, result) {
163
+ const srcFacts = new Set();
164
+ const srcEstimates = new Set();
165
+ for (const ir of irs) {
166
+ for (const r of ir.refs) {
167
+ const k = r.from + '.' + r.col + '>' + r.to;
168
+ (r.confidence === 'estimate' ? srcEstimates : srcFacts).add(k);
169
+ }
170
+ }
171
+ const out = new Set(result.refs.map(r => r.from + '.' + r.col + '>' + r.to));
172
+
173
+ const invented = [...out].filter(k => !srcFacts.has(k) && !srcEstimates.has(k));
174
+ const missing = [...srcFacts].filter(k => !out.has(k));
175
+ const noEvidence = result.refs.filter(r => !r.evidence);
176
+
177
+ return {
178
+ ok: invented.length === 0 && missing.length === 0 && noEvidence.length === 0,
179
+ sourceFacts: srcFacts.size,
180
+ sourceEstimates: srcEstimates.size,
181
+ emitted: out.size,
182
+ invented,
183
+ missing,
184
+ noEvidence: noEvidence.map(r => r.from + '.' + r.col),
185
+ droppedEstimates: result.dropped.filter(d => d.confidence === 'estimate').length,
186
+ };
187
+ }
188
+
189
+ export function reportMd(irs, result, rec, opt = {}) {
190
+ const L = [];
191
+ const now = new Date().toISOString().slice(0, 10);
192
+ L.push('# ' + (opt.project || 'schema') + ' — 흡수 보고서');
193
+ L.push('');
194
+ L.push('| | |');
195
+ L.push('| --- | --- |');
196
+ L.push('| 생성 | ' + now + ' |');
197
+ L.push('| 원본 | ' + irs.map(i => '`' + i.source + '`').join(' · ') + ' |');
198
+ L.push('| 산출 | 테이블 ' + result.tables.size + ' · 관계 ' + result.refs.length +
199
+ ' · 그룹 ' + result.groups.size + ' |');
200
+ L.push('');
201
+
202
+ L.push('## 관계를 어떻게 뽑았나');
203
+ L.push('');
204
+ L.push('| 항목 | 수 |');
205
+ L.push('| --- | --- |');
206
+ L.push('| 원본의 명시적 근거 (사실) | ' + rec.sourceFacts + ' |');
207
+ L.push('| 원본의 추정 근거 | ' + rec.sourceEstimates + ' |');
208
+ L.push('| **DBML 에 나간 Ref** | **' + rec.emitted + '** |');
209
+ L.push('| 추정이라 제외됨 | ' + rec.droppedEstimates + ' |');
210
+ L.push('');
211
+ L.push('### 1:1 대조');
212
+ L.push('');
213
+ L.push('| 검사 | 결과 |');
214
+ L.push('| --- | --- |');
215
+ L.push('| 원본에 있으나 빠진 관계 | ' + (rec.missing.length || '0') + ' |');
216
+ L.push('| 원본에 없는데 만들어진 관계 | ' + (rec.invented.length || '0') + ' |');
217
+ L.push('| 근거 문자열이 없는 관계 | ' + (rec.noEvidence.length || '0') + ' |');
218
+ L.push('');
219
+ L.push(rec.ok
220
+ ? '**대조 통과.** 모든 Ref 가 원본 근거로 되짚어집니다.'
221
+ : '**대조 실패.** 아래를 확인하세요.');
222
+ if (rec.missing.length) { L.push(''); L.push('빠진 관계:'); rec.missing.slice(0, 20).forEach(k => L.push('- `' + k + '`')); }
223
+ if (rec.invented.length) { L.push(''); L.push('근거 없는 관계:'); rec.invented.slice(0, 20).forEach(k => L.push('- `' + k + '`')); }
224
+ L.push('');
225
+
226
+ if (result.dropped.length) {
227
+ L.push('## 확인이 필요한 것 — 제외된 관계');
228
+ L.push('');
229
+ L.push('아래는 **그리지 않았습니다.** 맞다고 판단되면 `schema.dbml` 에 `Ref:` 를 직접 추가하세요.');
230
+ L.push('');
231
+ L.push('| 출발 | 컬럼 | 대상 | 제외 사유 | 근거 |');
232
+ L.push('| --- | --- | --- | --- | --- |');
233
+ for (const d of result.dropped.slice(0, 40))
234
+ L.push('| `' + d.from + '` | `' + d.col + '` | `' + d.to + '` | ' + d.reason +
235
+ ' | ' + (d.evidence ? '`' + String(d.evidence).slice(0, 60) + '`' : '없음') + ' |');
236
+ L.push('');
237
+ }
238
+
239
+ if (result.stubs.length) {
240
+ L.push('## 참조되지만 정의가 없는 테이블');
241
+ L.push('');
242
+ L.push('관계는 사실이므로 버리지 않고 **점선 스텁**으로 남겼습니다. 컬럼은 알 수 없습니다.');
243
+ L.push('');
244
+ result.stubs.forEach(s => L.push('- `' + s + '`'));
245
+ L.push('');
246
+ }
247
+
248
+ if (result.warnings.length) {
249
+ L.push('## 경고');
250
+ L.push('');
251
+ result.warnings.slice(0, 30).forEach(w => L.push('- ' + w));
252
+ L.push('');
253
+ }
254
+
255
+ if (result.unparsed.length) {
256
+ L.push('## 읽지 못한 원문');
257
+ L.push('');
258
+ L.push('건너뛴 조각입니다. 중요한 정의가 섞여 있으면 알려주세요.');
259
+ L.push('');
260
+ result.unparsed.slice(0, 20).forEach(u => L.push('- `' + String(u).slice(0, 100) + '`'));
261
+ L.push('');
262
+ }
263
+
264
+ L.push('## 확인하지 못한 것');
265
+ L.push('');
266
+ L.push('| 항목 | 이유 |');
267
+ L.push('| --- | --- |');
268
+ L.push('| 카디널리티 (1:1 / 1:N) | 원본에 유일성 제약 정보가 없으면 판정할 수 없습니다. **모든 관계를 N:1 로 그렸습니다.** |');
269
+ L.push('| 삭제 동작 (Cascade / Restrict) | DBML 로 옮기지 않았습니다. |');
270
+ L.push('| 그룹 분류의 타당성 | 스키마 이름을 그대로 썼습니다. 업무 영역과 다르면 `TableGroup` 을 손으로 고치세요. |');
271
+ L.push('');
272
+ return L.join('\n');
273
+ }
@@ -0,0 +1,165 @@
1
+ /* 배치 — 열 순서 최적화 · 행 barycenter · 좌표 산출 */
2
+
3
+ export const GEO = {
4
+ CW: 252, GUT: 164, CH: 66, CG: 48,
5
+ HEAD: 44, TOP: 138, PAD: 60, FOOT: 96,
6
+ // 열당 행 상한. 그룹 하나에 수십 개가 몰리면 캔버스가 좁고 길어지고
7
+ // 그 형태에서 배선 탐색이 폭발한다 (75행 한 열에서 10분을 넘긴 적이 있다).
8
+ // 넘치는 그룹은 같은 색의 이웃 열로 쪼갠다.
9
+ MAX_ROWS: 14,
10
+ };
11
+
12
+ /** 시스템 배선(담당자·통화·감사)인가 — 업무 관계와 나눠 보기 위한 판정 */
13
+ export const SYS_TABLES = new Set(['systemuser', 'team', 'businessunit', 'organization',
14
+ 'transactioncurrency', 'uom', 'uomschedule', 'owner', 'queue', 'workflow', 'asyncoperation',
15
+ 'role', 'site', 'territory', 'position', 'mailbox', 'mobileofflineprofile', 'calendar',
16
+ 'resourcespec', 'constraintbasedgroup', 'equipment', 'solution', 'plugintracelog', 'syncerror',
17
+ 'msdyn_postconfig', 'msdyn_postruleconfig']);
18
+
19
+ export const SYS_COLS = new Set(['createdby', 'modifiedby', 'createdonbehalfby',
20
+ 'modifiedonbehalfby', 'owninguser', 'owningteam', 'owningbusinessunit', 'ownerid',
21
+ 'transactioncurrencyid']);
22
+
23
+ export const isSys = r => SYS_TABLES.has(r.to) || SYS_COLS.has(r.col);
24
+
25
+ /** 도메인 사이 참조 거리 합이 최소가 되도록 열 순서를 고른다 (탐욕 + 국소 교환) */
26
+ export function orderColumns(domains, refs, groupOf) {
27
+ const pair = new Map();
28
+ for (const r of refs) {
29
+ const ga = groupOf.get(r.from), gb = groupOf.get(r.to);
30
+ if (!ga || !gb || ga === gb) continue;
31
+ // 그룹명에 공백이 있을 수 있어 공백으로 나눌 수 없다.
32
+ // 소스에 날 제어문자를 두면 파일이 바이너리로 취급되고
33
+ // 브라우저 인라인 시 U+FFFD 로 바뀔 수 있어 이스케이프로 쓴다.
34
+ const k = [ga, gb].sort().join('\u001f');
35
+ pair.set(k, (pair.get(k) || 0) + 1);
36
+ }
37
+ const pairs = [...pair].map(([k, n]) => {
38
+ const [x, y] = k.split('\u001f');
39
+ return { x, y, n };
40
+ });
41
+ const cost = ord => {
42
+ const ix = new Map(ord.map((d, i) => [d, i]));
43
+ let s = 0;
44
+ for (const p of pairs) s += p.n * Math.abs(ix.get(p.x) - ix.get(p.y));
45
+ return s;
46
+ };
47
+ const deg = d => pairs.reduce((s, p) => s + ((p.x === d || p.y === d) ? p.n : 0), 0);
48
+
49
+ let ord = [...domains].sort((a, b) => deg(b) - deg(a));
50
+ let best = cost(ord), improved = true;
51
+ while (improved) {
52
+ improved = false;
53
+ for (let i = 0; i < ord.length; i++) {
54
+ for (let j = i + 1; j < ord.length; j++) {
55
+ const o = ord.slice();
56
+ [o[i], o[j]] = [o[j], o[i]];
57
+ const c = cost(o);
58
+ if (c < best) { ord = o; best = c; improved = true; }
59
+ }
60
+ }
61
+ }
62
+ return { order: ord, cost: best };
63
+ }
64
+
65
+ export function layout(model, opt = {}) {
66
+ const g = { ...GEO, ...(opt.geo || {}) };
67
+ const { tables, groups, groupOf, refs } = model;
68
+ const fallback = groups.length ? groups[groups.length - 1].name : 'etc';
69
+ const domains = groups.map(x => x.name);
70
+
71
+ const { order, cost } = orderColumns(domains, refs, groupOf);
72
+
73
+ const inb = new Map();
74
+ for (const r of refs) inb.set(r.to, (inb.get(r.to) || 0) + 1);
75
+ const IN = t => inb.get(t) || 0;
76
+
77
+ const cols = new Map(order.map(d => [d, []]));
78
+ for (const t of tables.keys()) {
79
+ const d = groupOf.get(t) || fallback;
80
+ if (!cols.has(d)) { cols.set(d, []); order.push(d); }
81
+ cols.get(d).push(t);
82
+ }
83
+ for (const d of order) {
84
+ cols.get(d).sort((a, b) =>
85
+ IN(b) - IN(a) ||
86
+ tables.get(b).total - tables.get(a).total ||
87
+ (a < b ? -1 : 1));
88
+ }
89
+
90
+ // 행 barycenter — 이웃의 평균 행에 가깝게. 교차가 줄어든다.
91
+ const row = new Map();
92
+ order.forEach(d => cols.get(d).forEach((t, j) => row.set(t, j)));
93
+
94
+ const adj = new Map();
95
+ const push = (k, v) => { if (!adj.has(k)) adj.set(k, []); adj.get(k).push(v); };
96
+ for (const r of refs) { push(r.from, r.to); push(r.to, r.from); }
97
+
98
+ for (let it = 0; it < 14; it++) {
99
+ for (const d of order) {
100
+ const mem = cols.get(d);
101
+ if (mem.length < 2) continue;
102
+ const key = new Map();
103
+ for (const t of mem) {
104
+ const ns = (adj.get(t) || [])
105
+ .filter(o => row.has(o) && groupOf.get(o) !== d)
106
+ .map(o => row.get(o));
107
+ key.set(t, ns.length ? ns.reduce((a, b) => a + b, 0) / ns.length : row.get(t));
108
+ }
109
+ mem.sort((a, b) => key.get(a) - key.get(b) || IN(b) - IN(a));
110
+ mem.forEach((t, j) => row.set(t, j));
111
+ }
112
+ }
113
+
114
+ // 큰 그룹을 이웃 열로 쪼갠다. 같은 그룹이므로 색과 번들은 그대로 묶인다.
115
+ const cap = Math.max(4, g.MAX_ROWS);
116
+ const columns = [];
117
+ for (const d of order) {
118
+ const mem = cols.get(d);
119
+ if (!mem.length) continue;
120
+ const parts = Math.ceil(mem.length / cap);
121
+ const per = Math.ceil(mem.length / parts);
122
+ for (let p = 0; p < parts; p++) {
123
+ const slice = mem.slice(p * per, (p + 1) * per);
124
+ if (slice.length) columns.push({ dom: d, members: slice, part: p + 1, parts });
125
+ }
126
+ }
127
+
128
+ const pos = new Map();
129
+ columns.forEach((c, ci) => {
130
+ c.x = g.PAD + ci * (g.CW + g.GUT);
131
+ c.members.forEach((t, j) => pos.set(t, {
132
+ x: c.x,
133
+ y: g.TOP + g.HEAD + j * (g.CH + g.CG),
134
+ w: g.CW, h: g.CH,
135
+ dom: c.dom, col: ci, row: j,
136
+ }));
137
+ });
138
+
139
+ const maxRows = Math.max(...columns.map(c => c.members.length));
140
+ const W = g.PAD + columns.length * (g.CW + g.GUT) + 20;
141
+ const H = g.TOP + g.HEAD + maxRows * (g.CH + g.CG) + g.FOOT;
142
+
143
+ // 열마다 하나 — 머리말을 붙이는 단위
144
+ const domRects = columns.map(c => {
145
+ const ys = c.members.map(t => pos.get(t).y);
146
+ return {
147
+ dom: c.dom, x: c.x, y: Math.min(...ys) - 14, w: g.CW,
148
+ h: Math.max(...ys) + g.CH - Math.min(...ys) + 28,
149
+ count: c.members.length,
150
+ label: c.parts > 1 ? c.dom + ' ' + c.part + '/' + c.parts : c.dom,
151
+ };
152
+ });
153
+
154
+ // 도메인마다 하나 — 번들이 붙는 단위 (쪼갠 열들을 감싼다)
155
+ const domBounds = [];
156
+ for (const d of order) {
157
+ const rs = domRects.filter(r => r.dom === d);
158
+ if (!rs.length) continue;
159
+ const x1 = Math.min(...rs.map(r => r.x)), x2 = Math.max(...rs.map(r => r.x + r.w));
160
+ const y1 = Math.min(...rs.map(r => r.y)), y2 = Math.max(...rs.map(r => r.y + r.h));
161
+ domBounds.push({ dom: d, x: x1, y: y1, w: x2 - x1, h: y2 - y1, count: cols.get(d).length });
162
+ }
163
+
164
+ return { order, cols, columns, pos, domRects, domBounds, W, H, geo: g, colCost: cost, maxRows };
165
+ }