@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.
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/bin/cli.mjs +331 -0
- package/package.json +53 -0
- package/skills/erd-visual/SKILL.md +294 -0
- package/skills/erd-visual/build.mjs +404 -0
- package/skills/erd-visual/engine/dbml.mjs +133 -0
- package/skills/erd-visual/engine/ingest.mjs +273 -0
- package/skills/erd-visual/engine/layout.mjs +165 -0
- package/skills/erd-visual/engine/overview.mjs +314 -0
- package/skills/erd-visual/engine/render.mjs +144 -0
- package/skills/erd-visual/engine/router.mjs +401 -0
- package/skills/erd-visual/engine/verify.mjs +138 -0
- package/skills/erd-visual/engine/wire.mjs +115 -0
- package/skills/erd-visual/fixtures/crm-large.dbml +1337 -0
- package/skills/erd-visual/fixtures/edge-cases.dbml +43 -0
- package/skills/erd-visual/fixtures/map-dynamics.json +32 -0
- package/skills/erd-visual/fixtures/shop-basic.dbml +78 -0
- package/skills/erd-visual/fixtures/src-json/api.json +38 -0
- package/skills/erd-visual/fixtures/src-prisma/schema.prisma +44 -0
- package/skills/erd-visual/fixtures/src-sql/shop.sql +62 -0
- package/skills/erd-visual/readers/csv.mjs +81 -0
- package/skills/erd-visual/readers/index.mjs +55 -0
- package/skills/erd-visual/readers/jsonschema.mjs +86 -0
- package/skills/erd-visual/readers/prisma.mjs +102 -0
- package/skills/erd-visual/readers/sql.mjs +205 -0
- package/skills/erd-visual/readers/xlsx.mjs +220 -0
- package/skills/erd-visual/readers/xml.mjs +130 -0
- package/skills/erd-visual/readers/zip.mjs +74 -0
- package/skills/erd-visual/viewer/index.html +539 -0
- package/skills/project-build/SKILL.md +171 -0
- package/skills/project-build/templates/build-rules.md +88 -0
- package/skills/project-build/templates/change-log.md +29 -0
- package/skills/project-build/templates/coverage.md +49 -0
- package/skills/project-build/templates/parity.html +375 -0
- package/skills/project-build/templates/theme.css +45 -0
- package/skills/project-design/MEDIUMS.md +104 -0
- package/skills/project-design/QUESTIONS.md +126 -0
- package/skills/project-design/SKILL.md +365 -0
- package/skills/project-design/templates/audit.html +689 -0
- package/skills/project-design/templates/change-log.md +53 -0
- package/skills/project-design/templates/design-rules.md +118 -0
- package/skills/project-design/templates/mockup-app.html +96 -0
- package/skills/project-design/templates/mockup-slides.html +82 -0
- package/skills/project-design/templates/mockup-web.html +45 -0
- package/skills/project-design/templates/styleguide.html +436 -0
- package/skills/project-design/templates/tokens.css +69 -0
- package/skills/project-design/templates/tone-options.html +138 -0
- package/skills/project-init/GUIDE.md +370 -0
- package/skills/project-init/RUNBOOK.md +192 -0
- package/skills/project-init/SKILL.md +382 -0
- package/skills/project-init/build.mjs +2898 -0
- package/skills/project-init/evals/RUBRIC.md +81 -0
- package/skills/project-init/evals/cases/conflicting.expect.json +17 -0
- package/skills/project-init/evals/cases/conflicting.md +9 -0
- package/skills/project-init/evals/cases/vague-idea.expect.json +11 -0
- package/skills/project-init/evals/cases/vague-idea.md +7 -0
- package/skills/project-init/evals/cases/well-formed.expect.json +18 -0
- package/skills/project-init/evals/cases/well-formed.md +28 -0
- package/skills/project-init/markdown.mjs +0 -0
- package/skills/project-init/modules/a11y.json +46 -0
- package/skills/project-init/modules/ai.json +77 -0
- package/skills/project-init/modules/audience.json +48 -0
- package/skills/project-init/modules/backend.json +129 -0
- package/skills/project-init/modules/brand.json +78 -0
- package/skills/project-init/modules/core.json +132 -0
- package/skills/project-init/modules/design.json +72 -0
- package/skills/project-init/modules/engineering.json +86 -0
- package/skills/project-init/modules/mobile.json +22 -0
- package/skills/project-init/modules/ops.json +122 -0
- package/skills/project-init/modules/process.json +104 -0
- package/skills/project-init/modules/product.json +37 -0
- package/skills/project-init/modules/ux.json +52 -0
- package/skills/project-init/modules/web.json +129 -0
- package/skills/project-init/presets/ai-product.json +12 -0
- package/skills/project-init/presets/internal-system.json +15 -0
- package/skills/project-init/presets/mobile-app.json +20 -0
- package/skills/project-init/presets/web-corporate.json +116 -0
- package/skills/project-init/schema.json +80 -0
- package/skills/project-init/templates/log.md +54 -0
- package/skills/project-init/templates/readme.md +62 -0
- package/skills/project-init/templates/reference.md +34 -0
- package/skills/project-init/templates/register.md +63 -0
- package/skills/project-init/templates/spec.md +46 -0
- package/skills/project-interview/INTERVIEW.md +221 -0
- package/skills/project-interview/SKILL.md +156 -0
- package/skills/project-interview/fixtures/brief.md +49 -0
- package/skills/project-interview/fixtures/decisions.md +13 -0
- package/skills/project-interview/fixtures/open-questions.md +9 -0
- package/skills/project-interview/templates/brief.md +179 -0
- package/skills/project-interview/templates/decisions.md +55 -0
- package/skills/project-interview/templates/open-questions.md +40 -0
- 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
|
+
}
|