@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,539 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="ko"><head><meta charset="utf-8">
|
|
3
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4
|
+
<title>ERD</title>
|
|
5
|
+
<style>
|
|
6
|
+
:root{--bg:#FFFFFF;--pnl:#FFFFFF;--pnl2:#FAFAFA;--ink:#09090B;--ink2:#52525B;--ink3:#A1A1AA;
|
|
7
|
+
--line:#E4E4E7;--line2:#D4D4D8;--bad:#B42318;--ok:#0A7B4B}
|
|
8
|
+
*{box-sizing:border-box}
|
|
9
|
+
body{margin:0;background:var(--bg);color:var(--ink);word-break:keep-all;-webkit-font-smoothing:antialiased;
|
|
10
|
+
font-family:Pretendard,-apple-system,"Apple SD Gothic Neo","Noto Sans KR",system-ui,sans-serif}
|
|
11
|
+
header{position:sticky;top:0;z-index:20;background:rgba(255,255,255,.94);backdrop-filter:blur(10px);
|
|
12
|
+
border-bottom:1.5px solid var(--ink);padding:14px 26px;display:flex;gap:16px;align-items:center;flex-wrap:wrap}
|
|
13
|
+
h1{font-size:17px;margin:0;font-weight:800;letter-spacing:-.02em}
|
|
14
|
+
.sub{font-size:12px;color:var(--ink2);margin-top:2px}
|
|
15
|
+
.sp{flex:1}
|
|
16
|
+
.seg{display:flex;border:1.5px solid var(--ink);border-radius:9px;overflow:hidden}
|
|
17
|
+
.seg button{border:0;background:var(--pnl);padding:7px 14px;font-size:13px;font-weight:700;
|
|
18
|
+
color:var(--ink2);cursor:pointer;font-family:inherit;white-space:nowrap;transition:background .12s,color .12s}
|
|
19
|
+
.seg button+button{border-left:1.5px solid var(--ink)}
|
|
20
|
+
.seg button.on{background:var(--ink);color:#fff}
|
|
21
|
+
.lab{font-size:11px;color:var(--ink3);font-weight:800;letter-spacing:.12em}
|
|
22
|
+
#find{border:1.5px solid var(--ink);border-radius:8px;padding:6px 11px;font-size:13px;
|
|
23
|
+
font-family:inherit;width:190px;outline:0}
|
|
24
|
+
#find:focus{box-shadow:0 0 0 3px rgba(9,9,11,.12)}
|
|
25
|
+
#fstat{font-size:12px;color:var(--ink2);min-width:52px}
|
|
26
|
+
.zoom{display:flex;align-items:center;gap:6px}
|
|
27
|
+
.zoom button{border:1.5px solid var(--ink);background:var(--pnl);border-radius:7px;width:28px;height:28px;
|
|
28
|
+
font-size:14px;font-weight:700;cursor:pointer;line-height:1;font-family:inherit}
|
|
29
|
+
#zval{font-size:12px;color:var(--ink2);min-width:38px;text-align:center;font-variant-numeric:tabular-nums}
|
|
30
|
+
/* 검색 결과 강조 — 나머지를 접지 않고 찾은 것만 띄운다 */
|
|
31
|
+
.seek .cd:not(.found){opacity:.12}
|
|
32
|
+
.seek .ed,.seek .bd{opacity:.05!important}
|
|
33
|
+
.cd.found .bd0{stroke-width:3.4}
|
|
34
|
+
#vpanel{position:fixed;right:18px;bottom:18px;width:330px;background:var(--pnl);
|
|
35
|
+
border:1.5px solid var(--ink);border-radius:12px;padding:16px 18px;z-index:35;display:none;
|
|
36
|
+
box-shadow:0 8px 28px rgba(0,0,0,.14)}
|
|
37
|
+
#vpanel.on{display:block}
|
|
38
|
+
#vpanel h3{margin:0 0 10px;font-size:14px;font-weight:800}
|
|
39
|
+
#vpanel .r{display:flex;justify-content:space-between;font-size:12.5px;padding:5px 0;
|
|
40
|
+
border-bottom:1px solid var(--line)}
|
|
41
|
+
#vpanel .r b{font-variant-numeric:tabular-nums}
|
|
42
|
+
#vpanel .pass b{color:var(--ok)} #vpanel .fail b{color:var(--bad)}
|
|
43
|
+
#vpanel .sum{margin-top:11px;font-size:12.5px;font-weight:800}
|
|
44
|
+
#vpanel .note{margin-top:9px;font-size:11.5px;color:var(--ink2);line-height:1.5}
|
|
45
|
+
#vpanel .cl2{position:absolute;right:12px;top:12px;border:0;background:none;cursor:pointer;
|
|
46
|
+
font-size:15px;color:var(--ink2)}
|
|
47
|
+
.hint{font-size:12.5px;color:var(--ink2);padding:9px 26px;border-bottom:1px solid var(--line);background:var(--pnl2)}
|
|
48
|
+
.hint b{color:var(--ink);font-weight:700}
|
|
49
|
+
.scroll{overflow:auto;padding:22px}
|
|
50
|
+
.view{display:none}.view.on{display:block}
|
|
51
|
+
#ovstage svg{width:100%;height:auto;max-width:1720px;margin:0 auto;display:block}
|
|
52
|
+
#ov svg.dim .ove:not(.hot){opacity:.06}
|
|
53
|
+
#ov svg.dim .ovn:not(.hot):not(.src){opacity:.16}
|
|
54
|
+
.ove{transition:opacity .14s}
|
|
55
|
+
.ove.noev{opacity:.95!important}
|
|
56
|
+
#ovwarn{background:#FEF3F2;border-bottom:1.5px solid var(--bad);color:var(--bad);
|
|
57
|
+
padding:10px 26px;font-size:12.5px;line-height:1.6}
|
|
58
|
+
#ovwarn.info{background:var(--pnl2);border-bottom-color:var(--ink);color:var(--ink2)}
|
|
59
|
+
#ovwarn b{font-weight:800}
|
|
60
|
+
#ovwarn.info b{color:var(--ink)}
|
|
61
|
+
.ovn{transition:opacity .14s;cursor:pointer}
|
|
62
|
+
.ovn .hit{fill:transparent}
|
|
63
|
+
.ove.hot{opacity:1!important;stroke:#09090B;stroke-width:3.4;
|
|
64
|
+
stroke-dasharray:11 5;animation:flow .75s linear infinite}
|
|
65
|
+
.ove.hot.gap{stroke-width:3;animation:flow .9s linear infinite}
|
|
66
|
+
.ovn.hot .bd0,.ovn.src .bd0{stroke-width:3.4}
|
|
67
|
+
.ovn.src .bd0{animation:pulse 1.15s ease-in-out infinite}
|
|
68
|
+
@media (prefers-reduced-motion:reduce){
|
|
69
|
+
.ove.hot,.ovn.src .bd0{animation:none!important}.ove.hot{stroke-dasharray:none}}
|
|
70
|
+
#stage svg{display:block;max-width:none}
|
|
71
|
+
.ed,.bd{transition:opacity .12s,stroke-width .12s}
|
|
72
|
+
.cd .hit{fill:transparent}
|
|
73
|
+
.cd{cursor:pointer}
|
|
74
|
+
.m-bundle .ed{display:none}
|
|
75
|
+
.m-biz .bd,.m-biz .ed.sys{display:none}
|
|
76
|
+
.m-all .bd{display:none}
|
|
77
|
+
.dim .ed:not(.hot),.dim .bd:not(.hot){opacity:.04!important}
|
|
78
|
+
.dim .cd:not(.hot){opacity:.16}
|
|
79
|
+
.ed.hot,.bd.hot{opacity:1!important;stroke:#09090B}
|
|
80
|
+
.ed.hot{stroke-width:2.4}
|
|
81
|
+
.cd.hot .bd0{stroke-width:2.6}
|
|
82
|
+
.cd.src .bd0{stroke-width:3.4}
|
|
83
|
+
@keyframes flow{to{stroke-dashoffset:-32}}
|
|
84
|
+
@keyframes pulse{0%,100%{stroke-width:2}50%{stroke-width:4.6}}
|
|
85
|
+
.ed.hot,.bd.hot{stroke-dasharray:11 5;animation:flow .75s linear infinite}
|
|
86
|
+
.cd.src .bd0{animation:pulse 1.15s ease-in-out infinite}
|
|
87
|
+
@media (prefers-reduced-motion:reduce){
|
|
88
|
+
.ed.hot,.bd.hot,.cd.src .bd0{animation:none!important}
|
|
89
|
+
.ed.hot,.bd.hot{stroke-dasharray:none}}
|
|
90
|
+
|
|
91
|
+
aside{position:fixed;right:0;top:0;bottom:0;width:344px;background:var(--pnl);border-left:1.5px solid var(--ink);
|
|
92
|
+
padding:24px;overflow:auto;transform:translateX(100%);transition:transform .18s;z-index:30}
|
|
93
|
+
aside.on{transform:none}
|
|
94
|
+
aside h2{margin:0 0 3px;font-size:20px;font-weight:800;letter-spacing:-.02em}
|
|
95
|
+
aside .ln{font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:600;color:var(--ink2);margin-bottom:16px}
|
|
96
|
+
aside .kv{display:flex;justify-content:space-between;font-size:13px;padding:7px 0;border-bottom:1px solid var(--line)}
|
|
97
|
+
aside .kv span:first-child{color:var(--ink2)}
|
|
98
|
+
aside .kv span:last-child{font-weight:700}
|
|
99
|
+
aside h3{font-size:11px;color:var(--ink3);margin:22px 0 8px;letter-spacing:.1em;font-weight:800}
|
|
100
|
+
aside ul{list-style:none;margin:0;padding:0}
|
|
101
|
+
aside li{font-size:12.5px;padding:7px 0;border-bottom:1px solid var(--line);line-height:1.45}
|
|
102
|
+
aside li a{color:var(--ink);text-decoration:none;font-weight:700;border-bottom:1.5px solid var(--line2);cursor:pointer}
|
|
103
|
+
aside li b{font-family:ui-monospace,Menlo,monospace;font-weight:500;color:var(--ink3);font-size:11.5px}
|
|
104
|
+
aside .cl{position:absolute;right:18px;top:18px;border:1.5px solid var(--ink);background:var(--pnl);
|
|
105
|
+
width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:15px;line-height:1}
|
|
106
|
+
.tagx{display:inline-block;padding:2px 8px;border-radius:5px;font-size:10.5px;font-weight:800;border:1.2px solid var(--ink)}
|
|
107
|
+
|
|
108
|
+
/* ── DBML 편집 ── */
|
|
109
|
+
#editor{position:fixed;left:0;right:0;bottom:0;height:52vh;background:var(--pnl);
|
|
110
|
+
border-top:2px solid var(--ink);z-index:40;transform:translateY(100%);transition:transform .2s;
|
|
111
|
+
display:flex;flex-direction:column}
|
|
112
|
+
#editor.on{transform:none}
|
|
113
|
+
#editor .bar{display:flex;gap:10px;align-items:center;padding:11px 20px;border-bottom:1px solid var(--line)}
|
|
114
|
+
#editor .bar strong{font-size:14px;font-weight:800}
|
|
115
|
+
#editor textarea{flex:1;border:0;outline:0;resize:none;padding:16px 20px;font-size:12.5px;line-height:1.6;
|
|
116
|
+
font-family:ui-monospace,Menlo,monospace;color:var(--ink);background:var(--pnl2)}
|
|
117
|
+
.btn{border:1.5px solid var(--ink);background:var(--pnl);color:var(--ink);border-radius:8px;
|
|
118
|
+
padding:6px 13px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit}
|
|
119
|
+
.btn.pri{background:var(--ink);color:#fff}
|
|
120
|
+
.btn:disabled{opacity:.4;cursor:default}
|
|
121
|
+
#stat{font-size:12px;font-weight:700}
|
|
122
|
+
#stat.ok{color:var(--ok)} #stat.bad{color:var(--bad)}
|
|
123
|
+
.busy{position:fixed;inset:0;background:rgba(255,255,255,.72);z-index:50;display:none;
|
|
124
|
+
align-items:center;justify-content:center;font-weight:800;font-size:15px}
|
|
125
|
+
.busy.on{display:flex}
|
|
126
|
+
@media print{header,aside,#editor,.hint{display:none}.scroll{overflow:visible;padding:0}}
|
|
127
|
+
</style></head><body>
|
|
128
|
+
|
|
129
|
+
<header>
|
|
130
|
+
<div><h1 id="ttl">ERD</h1><div class="sub" id="sub"></div></div>
|
|
131
|
+
<div class="sp"></div>
|
|
132
|
+
<div class="seg" id="views" hidden>
|
|
133
|
+
<button data-v="ov">업무 흐름</button>
|
|
134
|
+
<button class="on" data-v="dt">전체 관계도</button>
|
|
135
|
+
</div>
|
|
136
|
+
<span class="lab" id="wlab">배선</span>
|
|
137
|
+
<div class="seg" id="modes">
|
|
138
|
+
<button class="on" data-m="bundle">그룹 흐름</button>
|
|
139
|
+
<button data-m="biz">업무</button>
|
|
140
|
+
<button data-m="all">전체</button>
|
|
141
|
+
</div>
|
|
142
|
+
<input id="find" placeholder="테이블 검색" autocomplete="off">
|
|
143
|
+
<span id="fstat"></span>
|
|
144
|
+
<div class="zoom">
|
|
145
|
+
<button id="zout" title="축소">−</button>
|
|
146
|
+
<span id="zval">100%</span>
|
|
147
|
+
<button id="zin" title="확대">+</button>
|
|
148
|
+
<button id="zfit" title="화면 맞춤" style="width:auto;padding:0 9px;font-size:12px">맞춤</button>
|
|
149
|
+
</div>
|
|
150
|
+
<button class="btn" id="verify">검증</button>
|
|
151
|
+
<button class="btn" id="edit">DBML 편집</button>
|
|
152
|
+
</header>
|
|
153
|
+
<div class="hint" id="hint"></div>
|
|
154
|
+
<div class="view" id="ov">
|
|
155
|
+
<div id="ovwarn" style="display:none"></div>
|
|
156
|
+
<div class="scroll" id="ovstage">__OVSVG__</div>
|
|
157
|
+
</div>
|
|
158
|
+
<div class="view on" id="dtview"><div class="scroll" id="stage">__SVG__</div></div>
|
|
159
|
+
|
|
160
|
+
<aside id="pn"><button class="cl" onclick="closePn()">×</button>
|
|
161
|
+
<h2 id="pk"></h2><div class="ln" id="pl"></div><div id="pm"></div><div id="po"></div><div id="pi"></div></aside>
|
|
162
|
+
|
|
163
|
+
<div id="editor">
|
|
164
|
+
<div class="bar">
|
|
165
|
+
<strong>DBML</strong>
|
|
166
|
+
<span id="stat"></span>
|
|
167
|
+
<div class="sp"></div>
|
|
168
|
+
<button class="btn pri" id="apply">적용 · 재배선</button>
|
|
169
|
+
<button class="btn" id="copy">복사</button>
|
|
170
|
+
<button class="btn" id="down">내려받기</button>
|
|
171
|
+
<button class="btn" id="reset">되돌리기</button>
|
|
172
|
+
<button class="btn" id="close">닫기</button>
|
|
173
|
+
</div>
|
|
174
|
+
<textarea id="src" spellcheck="false"></textarea>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="busy" id="busy">재배선 중…</div>
|
|
177
|
+
<div id="vpanel"><button class="cl2" onclick="document.querySelector('#vpanel').classList.remove('on')">×</button>
|
|
178
|
+
<h3>검증</h3><div id="vbody"></div></div>
|
|
179
|
+
|
|
180
|
+
<script>
|
|
181
|
+
/*__ENGINE__*/
|
|
182
|
+
|
|
183
|
+
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
|
184
|
+
const ORIGINAL=__DBML__;
|
|
185
|
+
let META=__META__, NBR=__NBR__, TMETA=__TMETA__;
|
|
186
|
+
const OVSPEC=__OVSPEC__;
|
|
187
|
+
let mode='bundle', locked=false, dbmlText=ORIGINAL;
|
|
188
|
+
|
|
189
|
+
const HINTS={
|
|
190
|
+
bundle:'<b>그룹 흐름</b> — 영역 사이 외래키를 묶은 굵은 선입니다. <b>굵기 = 참조 수.</b> 선에 마우스를 올리면 건수가 나옵니다.',
|
|
191
|
+
biz:'<b>업무 관계</b> — 담당자·통화·감사 같은 시스템 배선을 뺀 외래키입니다. 카드에 <b>마우스를 올리면</b> 미리보기, <b>클릭</b>하면 고정됩니다.',
|
|
192
|
+
all:'<b>전체 관계</b> — 시스템 배선을 포함한 모든 외래키입니다. 밀도가 높으니 카드를 클릭해서 보세요.'};
|
|
193
|
+
|
|
194
|
+
function head(){
|
|
195
|
+
$('#ttl').textContent=META.title;
|
|
196
|
+
$('#sub').textContent='테이블 '+META.tables+' · 관계 '+META.refs+' (업무 '+META.biz+' · 시스템 '+META.sys+')'
|
|
197
|
+
+' · 그룹 '+META.groups+(META.check&&!META.check.ok?' ⚠ 검증 실패':'');
|
|
198
|
+
$('#hint').innerHTML=HINTS[mode];
|
|
199
|
+
}
|
|
200
|
+
function setMode(m){
|
|
201
|
+
mode=m;
|
|
202
|
+
$$('#modes button').forEach(b=>b.classList.toggle('on',b.dataset.m===m));
|
|
203
|
+
$('#stage').className='scroll m-'+m;
|
|
204
|
+
closePn(); head();
|
|
205
|
+
}
|
|
206
|
+
$$('#modes button').forEach(b=>b.onclick=()=>setMode(b.dataset.m));
|
|
207
|
+
|
|
208
|
+
/* ── 강조 ── */
|
|
209
|
+
function clearHot(){ const s=$('#detail'); if(!s)return;
|
|
210
|
+
s.classList.remove('dim'); $$('#detail .hot,#detail .src').forEach(x=>x.classList.remove('hot','src')); }
|
|
211
|
+
function closePn(){ locked=false; $('#pn').classList.remove('on'); clearHot(); }
|
|
212
|
+
function hot(t){
|
|
213
|
+
const s=$('#detail'); s.classList.add('dim');
|
|
214
|
+
$$('#detail .hot,#detail .src').forEach(x=>x.classList.remove('hot','src'));
|
|
215
|
+
const keep=new Set([t]);
|
|
216
|
+
$$('#detail .ed').forEach(p=>{
|
|
217
|
+
if(p.classList.contains('sys')&&mode!=='all')return;
|
|
218
|
+
if(p.dataset.a===t||p.dataset.b===t){p.classList.add('hot');keep.add(p.dataset.a);keep.add(p.dataset.b);
|
|
219
|
+
p.parentNode.appendChild(p);}});
|
|
220
|
+
$$('#detail .cd').forEach(c=>{ if(c.dataset.t===t)c.classList.add('hot','src');
|
|
221
|
+
else if(keep.has(c.dataset.t))c.classList.add('hot'); });
|
|
222
|
+
}
|
|
223
|
+
function bindCards(){
|
|
224
|
+
$$('#detail .cd').forEach(g=>{
|
|
225
|
+
g.addEventListener('mouseenter',()=>{ if(!locked) hot(g.dataset.t); });
|
|
226
|
+
g.addEventListener('mouseleave',()=>{ if(!locked) clearHot(); });
|
|
227
|
+
g.addEventListener('click',ev=>{ ev.stopPropagation(); focusOn(g.dataset.t); });
|
|
228
|
+
});
|
|
229
|
+
const s=$('#detail');
|
|
230
|
+
if(s) s.addEventListener('click',ev=>{ if(!ev.target.closest('.cd')) closePn(); });
|
|
231
|
+
}
|
|
232
|
+
function focusOn(t){
|
|
233
|
+
locked=true;
|
|
234
|
+
if(mode==='bundle') setMode('biz');
|
|
235
|
+
locked=true; hot(t); panel(t);
|
|
236
|
+
}
|
|
237
|
+
const kv=(k,v)=>'<div class="kv"><span>'+k+'</span><span>'+v+'</span></div>';
|
|
238
|
+
function sec(title,arr){
|
|
239
|
+
if(!arr.length) return '<h3>'+title+'</h3><div style="font-size:12.5px;color:var(--ink3);padding:6px 0">없음</div>';
|
|
240
|
+
return '<h3>'+title+' · '+arr.length+'</h3><ul>'+arr.map(x=>
|
|
241
|
+
'<li><a onclick="focusOn(\''+x.o+'\')">'+((TMETA[x.o]||{}).ko||x.o)+'</a> <b>'+x.col+'</b></li>').join('')+'</ul>';
|
|
242
|
+
}
|
|
243
|
+
function panel(t){
|
|
244
|
+
const m=TMETA[t]; if(!m) return;
|
|
245
|
+
const c=META.palette[m.dom]||['#eee','#666'];
|
|
246
|
+
$('#pk').textContent=m.ko; $('#pl').textContent=t;
|
|
247
|
+
const ls=(NBR[t]||[]).filter(x=>mode==='all'||!isSys({to:x.dir==='out'?x.o:t,col:x.col}));
|
|
248
|
+
$('#pm').innerHTML='<div style="margin-bottom:14px;display:flex;gap:6px;flex-wrap:wrap">'
|
|
249
|
+
+'<span class="tagx" style="background:'+c[0]+'">'+m.dom+'</span>'
|
|
250
|
+
+(m.custom?'<span class="tagx" style="background:#fff">커스텀</span>':'')
|
|
251
|
+
+(m.stub?'<span class="tagx" style="background:#09090B;color:#fff;border-color:#09090B">정의 없음</span>':'')
|
|
252
|
+
+'</div>'+kv('총 속성',m.stub?'알 수 없음':m.total+'개')
|
|
253
|
+
+kv('참조함',ls.filter(x=>x.dir==='out').length+'건')
|
|
254
|
+
+kv('참조받음',ls.filter(x=>x.dir==='in').length+'건');
|
|
255
|
+
$('#po').innerHTML=sec('참조함 — 이 테이블의 외래키',ls.filter(x=>x.dir==='out'));
|
|
256
|
+
$('#pi').innerHTML=sec('참조받음 — 여기를 가리키는 것',ls.filter(x=>x.dir==='in'));
|
|
257
|
+
$('#pn').classList.add('on');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ── DBML 편집 → 재배선 ── */
|
|
261
|
+
const ed=$('#editor'), ta=$('#src'), stat=$('#stat');
|
|
262
|
+
$('#edit').onclick=()=>{ ta.value=dbmlText; ed.classList.add('on'); stat.textContent=''; ta.focus(); };
|
|
263
|
+
$('#close').onclick=()=>ed.classList.remove('on');
|
|
264
|
+
$('#reset').onclick=()=>{ ta.value=ORIGINAL; stat.textContent='원본으로 되돌렸습니다. 적용을 누르세요.'; stat.className=''; };
|
|
265
|
+
$('#copy').onclick=async()=>{
|
|
266
|
+
try{ await navigator.clipboard.writeText(ta.value); stat.textContent='복사했습니다'; stat.className='ok'; }
|
|
267
|
+
catch(e){ ta.select(); document.execCommand('copy'); stat.textContent='복사했습니다'; stat.className='ok'; }
|
|
268
|
+
};
|
|
269
|
+
$('#down').onclick=()=>{
|
|
270
|
+
const b=new Blob([ta.value],{type:'text/plain'});
|
|
271
|
+
const a=document.createElement('a');
|
|
272
|
+
a.href=URL.createObjectURL(b); a.download='schema.dbml'; a.click();
|
|
273
|
+
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
|
|
274
|
+
stat.textContent='내려받았습니다'; stat.className='ok';
|
|
275
|
+
};
|
|
276
|
+
$('#apply').onclick=()=>{
|
|
277
|
+
const busy=$('#busy'); busy.classList.add('on');
|
|
278
|
+
setTimeout(()=>{
|
|
279
|
+
try{
|
|
280
|
+
const errs=findBlockErrors(ta.value);
|
|
281
|
+
if(errs.length) throw Object.assign(new Error(errs[0].msg),{line:errs[0].line});
|
|
282
|
+
const warn=rebuild(ta.value);
|
|
283
|
+
stat.textContent='적용됨 · 테이블 '+META.tables+' · 관계 '+META.refs
|
|
284
|
+
+(ovWarn?' ⚠ 조감도 '+ovWarn+'건 어긋남':'')
|
|
285
|
+
+(warn.length?' (경고 '+warn.length+'건: '+warn[0]+')':'');
|
|
286
|
+
stat.className=ovWarn?'bad':(warn.length?'':'ok');
|
|
287
|
+
}
|
|
288
|
+
catch(e){
|
|
289
|
+
stat.textContent=(e.line?e.line+'행 — ':'')+'실패: '+e.message;
|
|
290
|
+
stat.className='bad';
|
|
291
|
+
if(e.line){ // 해당 줄로 커서를 옮긴다
|
|
292
|
+
const upto=ta.value.split('\n').slice(0,e.line-1).join('\n').length+1;
|
|
293
|
+
ta.focus(); ta.setSelectionRange(upto, upto+(ta.value.split('\n')[e.line-1]||'').length);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
busy.classList.remove('on');
|
|
297
|
+
},30);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
function rebuild(src){
|
|
301
|
+
const model=parseDbml(src);
|
|
302
|
+
if(!model.tables.size) throw new Error('Table 을 하나도 읽지 못했습니다');
|
|
303
|
+
const lay=layout(model);
|
|
304
|
+
const w=wire(model,lay);
|
|
305
|
+
const pal=palette(lay.order);
|
|
306
|
+
const svg=renderDetail(model,lay,w,{
|
|
307
|
+
title:META.title,
|
|
308
|
+
subtitle:'테이블 '+model.tables.size+' · 관계 '+model.refs.length+' · 그룹 '+lay.order.length+' · 직각 배선',
|
|
309
|
+
palette:pal});
|
|
310
|
+
|
|
311
|
+
// 검증 — 편집 결과도 같은 기준으로 판정한다
|
|
312
|
+
const chk={pierce:checkPierce(w.paths,w.RECT,w.skips).length,
|
|
313
|
+
cardOverlap:checkCardOverlap(w.RECT,w.names).length,
|
|
314
|
+
overlap:measureOverlap(w.paths)};
|
|
315
|
+
|
|
316
|
+
dbmlText=src;
|
|
317
|
+
$('#stage').innerHTML=svg;
|
|
318
|
+
META={...META,tables:model.tables.size,refs:model.refs.length,groups:lay.order.length,
|
|
319
|
+
biz:w.isSysRef.filter(x=>!x).length,sys:w.isSysRef.filter(x=>x).length,
|
|
320
|
+
bundles:w.bundleKeys.length,order:lay.order,
|
|
321
|
+
palette:Object.fromEntries(lay.order.map(d=>[d,pal.get(d)])),
|
|
322
|
+
check:{ok:chk.pierce===0&&chk.cardOverlap===0}};
|
|
323
|
+
NBR={};
|
|
324
|
+
for(const r of model.refs){
|
|
325
|
+
(NBR[r.from]=NBR[r.from]||[]).push({o:r.to,col:r.col,dir:'out'});
|
|
326
|
+
(NBR[r.to]=NBR[r.to]||[]).push({o:r.from,col:r.col,dir:'in'});
|
|
327
|
+
}
|
|
328
|
+
TMETA={};
|
|
329
|
+
for(const [t,p] of lay.pos){
|
|
330
|
+
const m=model.tables.get(t);
|
|
331
|
+
TMETA[t]={ko:m.label,dom:p.dom,total:m.total,stub:m.stub,custom:m.custom};
|
|
332
|
+
}
|
|
333
|
+
$('#stage').className='scroll m-'+mode;
|
|
334
|
+
bindCards(); head(); applyZoom(); runFind();
|
|
335
|
+
|
|
336
|
+
// 조감도 — 손으로 그린 명세가 '이 스키마에 해당하는지' 먼저 본다.
|
|
337
|
+
// 해당함 → 배치는 그대로 두고 근거만 다시 대조 (붉은 점선으로 표시)
|
|
338
|
+
// 해당없음 → 다른 스키마용 그림이므로 DBML 에서 자동 생성으로 갈아끼운다
|
|
339
|
+
{
|
|
340
|
+
const fit = OVSPEC ? overviewFit(OVSPEC, model) : null;
|
|
341
|
+
const useCurated = fit && fit.applicable;
|
|
342
|
+
const spec = useCurated ? OVSPEC : autoOverview(model, lay);
|
|
343
|
+
const ovc = verifyOverview(spec, model);
|
|
344
|
+
$('#ov .scroll').innerHTML = renderOverview(spec, model);
|
|
345
|
+
bindOverview();
|
|
346
|
+
META.overviewCheck = ovc;
|
|
347
|
+
META.overviewAuto = !useCurated;
|
|
348
|
+
|
|
349
|
+
const n = ovc.edgeNoEvidence.length + ovc.gapContradicted.length + ovc.missingTables.length;
|
|
350
|
+
let msg = '';
|
|
351
|
+
if (!useCurated && OVSPEC) {
|
|
352
|
+
msg = '<b>이 스키마에는 준비된 조감도가 없어 자동 생성했습니다.</b> '
|
|
353
|
+
+ 'overview.json 의 노드 ' + fit.total + '개 중 ' + fit.alive + '개만 현재 DBML 에 있습니다 — '
|
|
354
|
+
+ '다른 스키마용 그림이라 쓰지 않았습니다. 모든 선은 실제 외래키입니다.';
|
|
355
|
+
} else if (n) {
|
|
356
|
+
msg = '<b>조감도 ' + n + '건이 편집된 DBML 과 어긋납니다.</b> '
|
|
357
|
+
+ (ovc.edgeNoEvidence.length ? '근거 잃은 연결 ' + ovc.edgeNoEvidence.length + '건(붉은 점선) ' : '')
|
|
358
|
+
+ (ovc.gapContradicted.length ? '실제로는 있는 관계 ' + ovc.gapContradicted.length + '건 ' : '')
|
|
359
|
+
+ (ovc.missingTables.length ? '없는 테이블 ' + ovc.missingTables.length + '건' : '');
|
|
360
|
+
}
|
|
361
|
+
$('#ovwarn').innerHTML = msg;
|
|
362
|
+
$('#ovwarn').style.display = msg ? '' : 'none';
|
|
363
|
+
$('#ovwarn').className = (!useCurated && OVSPEC) ? 'info' : '';
|
|
364
|
+
ovWarn = useCurated ? n : 0;
|
|
365
|
+
}
|
|
366
|
+
if(chk.pierce||chk.cardOverlap) console.warn('검증 실패', chk);
|
|
367
|
+
return model.warnings;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/* ── 검색 ─────────────────────────────────────────────── */
|
|
371
|
+
const findEl=$('#find');
|
|
372
|
+
function runFind(){
|
|
373
|
+
const q=findEl.value.trim().toLowerCase();
|
|
374
|
+
const stage=$('#stage');
|
|
375
|
+
$$('#detail .found').forEach(c=>c.classList.remove('found'));
|
|
376
|
+
if(!q){ stage.classList.remove('seek'); $('#fstat').textContent=''; return; }
|
|
377
|
+
let n=0, first=null;
|
|
378
|
+
$$('#detail .cd').forEach(c=>{
|
|
379
|
+
const t=c.dataset.t, m=TMETA[t]||{};
|
|
380
|
+
if(t.toLowerCase().includes(q)||String(m.ko||'').toLowerCase().includes(q)){
|
|
381
|
+
c.classList.add('found'); n++; if(!first) first=c;
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
stage.classList.toggle('seek', n>0);
|
|
385
|
+
$('#fstat').textContent = n ? n+'개' : '없음';
|
|
386
|
+
if(first) first.scrollIntoView({block:'center',inline:'center'});
|
|
387
|
+
}
|
|
388
|
+
findEl.addEventListener('input',runFind);
|
|
389
|
+
findEl.addEventListener('keydown',e=>{
|
|
390
|
+
if(e.key==='Escape'){ findEl.value=''; runFind(); findEl.blur(); }
|
|
391
|
+
if(e.key==='Enter'){ const f=$('#detail .cd.found'); if(f) focusOn(f.dataset.t); }
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
/* ── 확대·축소 ─────────────────────────────────────────── */
|
|
395
|
+
let zoom=1;
|
|
396
|
+
function applyZoom(){
|
|
397
|
+
const svg=$('#detail'); if(!svg) return;
|
|
398
|
+
const vb=svg.getAttribute('viewBox').split(' ').map(Number);
|
|
399
|
+
svg.setAttribute('width', Math.round(vb[2]*zoom));
|
|
400
|
+
svg.setAttribute('height', Math.round(vb[3]*zoom));
|
|
401
|
+
$('#zval').textContent=Math.round(zoom*100)+'%';
|
|
402
|
+
}
|
|
403
|
+
function fitZoom(){
|
|
404
|
+
const svg=$('#detail'); if(!svg) return;
|
|
405
|
+
const vb=svg.getAttribute('viewBox').split(' ').map(Number);
|
|
406
|
+
const box=$('#stage').clientWidth-48;
|
|
407
|
+
zoom=Math.min(1, Math.max(0.1, box/vb[2]));
|
|
408
|
+
applyZoom();
|
|
409
|
+
}
|
|
410
|
+
$('#zin').onclick=()=>{ zoom=Math.min(3,zoom*1.25); applyZoom(); };
|
|
411
|
+
$('#zout').onclick=()=>{ zoom=Math.max(0.1,zoom/1.25); applyZoom(); };
|
|
412
|
+
$('#zfit').onclick=fitZoom;
|
|
413
|
+
|
|
414
|
+
/* ── 검증 — 브라우저 실측 ───────────────────────────────
|
|
415
|
+
Node 검사는 텍스트 폭이 추정치다. 실제 폰트 메트릭은 브라우저만 안다.
|
|
416
|
+
함정 둘을 코드로 막는다:
|
|
417
|
+
· getBBox() 는 숨겨진 요소에서 0 을 반환한다 → 보이는지 먼저 확인
|
|
418
|
+
· transform 그룹 안에서는 로컬 좌표를 준다 → getScreenCTM 으로 변환 */
|
|
419
|
+
function userBox(el, svg){
|
|
420
|
+
const b=el.getBBox();
|
|
421
|
+
const toRoot=svg.getScreenCTM().inverse();
|
|
422
|
+
const m=el.getScreenCTM();
|
|
423
|
+
if(!m) return null;
|
|
424
|
+
const pts=[[b.x,b.y],[b.x+b.width,b.y],[b.x,b.y+b.height],[b.x+b.width,b.y+b.height]]
|
|
425
|
+
.map(([x,y])=>{ const p=svg.createSVGPoint(); p.x=x; p.y=y;
|
|
426
|
+
const q=p.matrixTransform(m).matrixTransform(toRoot); return [q.x,q.y]; });
|
|
427
|
+
const xs=pts.map(p=>p[0]), ys=pts.map(p=>p[1]);
|
|
428
|
+
return {x:Math.min(...xs),y:Math.min(...ys),x2:Math.max(...xs),y2:Math.max(...ys)};
|
|
429
|
+
}
|
|
430
|
+
function runVerify(){
|
|
431
|
+
const svg=$('#detail');
|
|
432
|
+
const panel=$('#vpanel'), body=$('#vbody');
|
|
433
|
+
panel.classList.add('on');
|
|
434
|
+
if(!svg || !svg.getBoundingClientRect().width){
|
|
435
|
+
body.innerHTML='<div class="note">도면이 화면에 보이지 않아 잴 수 없습니다. '
|
|
436
|
+
+'숨겨진 요소에서는 getBBox 가 0 을 반환해 <b>거짓 통과</b>가 납니다.</div>';
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const model=parseDbml(dbmlText);
|
|
440
|
+
const lay=layout(model), w=wire(model,lay);
|
|
441
|
+
const geo={pierce:checkPierce(w.paths,w.RECT,w.skips).length,
|
|
442
|
+
bundle:checkPierce(w.bundlePaths,w.bundleRect,w.bundleSkips).length,
|
|
443
|
+
card:checkCardOverlap(w.RECT,w.names).length,
|
|
444
|
+
ov:measureOverlap(w.paths)};
|
|
445
|
+
|
|
446
|
+
// 실제 렌더 텍스트가 카드를 넘는가 (추정이 아니라 실측)
|
|
447
|
+
let over=0, sample='';
|
|
448
|
+
$$('#detail .cd').forEach(g=>{
|
|
449
|
+
const r=g.querySelector('.bd0'); if(!r) return;
|
|
450
|
+
const rx=+r.getAttribute('x'), rw=+r.getAttribute('width');
|
|
451
|
+
const ry=+r.getAttribute('y'), rh=+r.getAttribute('height');
|
|
452
|
+
g.querySelectorAll('text').forEach(t=>{
|
|
453
|
+
if(t.getAttribute('text-anchor')==='end') return;
|
|
454
|
+
const b=userBox(t,svg); if(!b) return;
|
|
455
|
+
if(b.x2>rx+rw-4||b.y2>ry+rh-1){ over++; if(!sample) sample=g.dataset.t+' "'+t.textContent+'"'; }
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
// 캔버스 이탈
|
|
459
|
+
const vb=svg.getAttribute('viewBox').split(' ').map(Number);
|
|
460
|
+
let esc=0;
|
|
461
|
+
$$('#detail text').forEach(t=>{ const b=userBox(t,svg);
|
|
462
|
+
if(b&&(b.x<0||b.y<0||b.x2>vb[2]||b.y2>vb[3])) esc++; });
|
|
463
|
+
|
|
464
|
+
const rows=[['카드 관통',geo.pierce],['번들 관통',geo.bundle],['카드 겹침',geo.card],
|
|
465
|
+
['텍스트 넘침 (실측)',over],['캔버스 이탈 (실측)',esc]];
|
|
466
|
+
const ok=rows.every(r=>r[1]===0);
|
|
467
|
+
body.innerHTML=rows.map(([k,v])=>
|
|
468
|
+
'<div class="r '+(v===0?'pass':'fail')+'"><span>'+k+'</span><b>'+v+'</b></div>').join('')
|
|
469
|
+
+'<div class="r"><span>선 겹침</span><b>'+geo.ov.overlapPairs+'쌍 / '+geo.ov.overlapLen+'px</b></div>'
|
|
470
|
+
+'<div class="sum" style="color:'+(ok?'var(--ok)':'var(--bad)')+'">'
|
|
471
|
+
+(ok?'통과 — 필수 항목 0건':'실패'+(sample?' · 예: '+sample:''))+'</div>'
|
|
472
|
+
+(META.overviewCheck ? '<div class="r '+(META.overviewCheck.ok?'pass':'fail')+'">'
|
|
473
|
+
+'<span>조감도 ↔ DBML 대조</span><b>'
|
|
474
|
+
+(META.overviewCheck.ok?'통과':'실패 '+(META.overviewCheck.edgeNoEvidence.length
|
|
475
|
+
+META.overviewCheck.gapContradicted.length)+'건')+'</b></div>' : '')
|
|
476
|
+
+'<div class="note">텍스트는 <b>추정 폭이 아니라 브라우저 실측</b>입니다. '
|
|
477
|
+
+'선 겹침은 실패 항목이 아닙니다 — 허브 앞에서 모이는 건 자연스럽습니다.</div>';
|
|
478
|
+
}
|
|
479
|
+
$('#verify').onclick=runVerify;
|
|
480
|
+
|
|
481
|
+
/* 단축키 */
|
|
482
|
+
addEventListener('keydown',e=>{
|
|
483
|
+
if(e.target.tagName==='TEXTAREA'||e.target.tagName==='INPUT') return;
|
|
484
|
+
if(e.key==='/'){ e.preventDefault(); findEl.focus(); }
|
|
485
|
+
if(e.key==='0'){ fitZoom(); }
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
/* ── 뷰 전환 (조감도 / 전체 관계도) ─────────────────────── */
|
|
489
|
+
const HINT_OV='<b>업무 흐름 조감도</b> — 고객 설명용 한 장입니다. 카드에 <b>마우스를 올리면</b> '
|
|
490
|
+
+'연결된 곳과 선이 흐르며 강조됩니다. 모든 연결선은 DBML 과 대조된 실제 외래키이고, '
|
|
491
|
+
+'검정 점선 <b>①②</b>는 <b>있어야 할 것 같으나 실제로는 없는 연결</b>입니다.';
|
|
492
|
+
function setView(v){
|
|
493
|
+
$$('#views button').forEach(b=>b.classList.toggle('on',b.dataset.v===v));
|
|
494
|
+
$('#ov').classList.toggle('on',v==='ov');
|
|
495
|
+
$('#dtview').classList.toggle('on',v==='dt');
|
|
496
|
+
const dt = v==='dt';
|
|
497
|
+
$('#modes').style.display = dt?'':'none';
|
|
498
|
+
$('#wlab').style.display = dt?'':'none';
|
|
499
|
+
$('#find').style.display = dt?'':'none';
|
|
500
|
+
$('#fstat').style.display = dt?'':'none';
|
|
501
|
+
$('.zoom').style.display = dt?'':'none';
|
|
502
|
+
closePn(); ovClear();
|
|
503
|
+
$('#hint').innerHTML = dt ? HINTS[mode] : HINT_OV;
|
|
504
|
+
}
|
|
505
|
+
if($('#ov svg')){
|
|
506
|
+
$('#views').hidden=false;
|
|
507
|
+
$$('#views button').forEach(b=>b.onclick=()=>setView(b.dataset.v));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* ── 조감도 hover — 재렌더 후에도 다시 걸 수 있게 함수로 둔다 ── */
|
|
511
|
+
const ovSvg=()=>$('#ov svg');
|
|
512
|
+
function ovClear(){ const s=ovSvg(); if(!s)return; s.classList.remove('dim');
|
|
513
|
+
s.querySelectorAll('.ove,.ovn').forEach(x=>x.classList.remove('hot','src')); }
|
|
514
|
+
function ovHover(id){
|
|
515
|
+
const s=ovSvg(); if(!s)return;
|
|
516
|
+
s.classList.add('dim');
|
|
517
|
+
s.querySelectorAll('.ove,.ovn').forEach(x=>x.classList.remove('hot','src'));
|
|
518
|
+
const keep=new Set([id]);
|
|
519
|
+
s.querySelectorAll('.ove').forEach(p=>{ if(p.dataset.a===id||p.dataset.b===id){
|
|
520
|
+
p.classList.add('hot'); keep.add(p.dataset.a); keep.add(p.dataset.b);
|
|
521
|
+
p.parentNode.appendChild(p); }});
|
|
522
|
+
s.querySelectorAll('.ovn').forEach(n=>{ if(n.dataset.id===id) n.classList.add('src','hot');
|
|
523
|
+
else if(keep.has(n.dataset.id)) n.classList.add('hot'); });
|
|
524
|
+
}
|
|
525
|
+
function bindOverview(){
|
|
526
|
+
const s=ovSvg(); if(!s)return;
|
|
527
|
+
s.querySelectorAll('.ovn').forEach(n=>{
|
|
528
|
+
n.addEventListener('mouseenter',()=>ovHover(n.dataset.id));
|
|
529
|
+
n.addEventListener('mouseleave',e=>{
|
|
530
|
+
if(!e.relatedTarget||!e.relatedTarget.closest||!e.relatedTarget.closest('.ovn')) ovClear(); });
|
|
531
|
+
});
|
|
532
|
+
s.addEventListener('mouseleave',ovClear);
|
|
533
|
+
}
|
|
534
|
+
bindOverview();
|
|
535
|
+
|
|
536
|
+
head(); setMode('bundle'); bindCards(); applyZoom();
|
|
537
|
+
if($('#ov svg')) setView('dt');
|
|
538
|
+
window.focusOn=focusOn; window.closePn=closePn;
|
|
539
|
+
</script></body></html>
|