@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,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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
}
|