@tienne/gestalt 0.88.0 → 0.90.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/CLAUDE.md +7 -2
- package/README.ko.md +2 -1
- package/README.md +3 -1
- package/dist/package.json +7 -4
- package/dist/plugin/skills/_shared/proactive-routing.md +29 -12
- package/dist/plugin/skills/architecture/SKILL.md +944 -0
- package/dist/plugin/skills/brief/SKILL.md +2 -1
- package/dist/plugin/skills/dispatch/SKILL.md +2 -0
- package/dist/plugin/skills/explain/SKILL.md +4 -2
- package/dist/plugin/skills/jira-create/SKILL.md +2 -0
- package/dist/plugin/skills/pr/SKILL.md +2 -0
- package/dist/plugin/skills/presentation/SKILL.md +2 -0
- package/dist/plugin/skills/review/SKILL.md +1 -0
- package/dist/plugin/skills/review-loop/SKILL.md +3 -1
- package/dist/plugin/skills/review-reply/SKILL.md +2 -0
- package/dist/plugin/skills/ship/SKILL.md +2 -2
- package/dist/plugin/skills/slack-send/SKILL.md +2 -0
- package/dist/schemas/architecture-ir.schema.json +596 -0
- package/dist/src/architecture/canvas-geometry.d.ts +40 -0
- package/dist/src/architecture/canvas-geometry.d.ts.map +1 -0
- package/dist/src/architecture/canvas-geometry.js +212 -0
- package/dist/src/architecture/canvas-geometry.js.map +1 -0
- package/dist/src/architecture/codeowners.d.ts +16 -0
- package/dist/src/architecture/codeowners.d.ts.map +1 -0
- package/dist/src/architecture/codeowners.js +58 -0
- package/dist/src/architecture/codeowners.js.map +1 -0
- package/dist/src/architecture/compare-layout.d.ts +27 -0
- package/dist/src/architecture/compare-layout.d.ts.map +1 -0
- package/dist/src/architecture/compare-layout.js +72 -0
- package/dist/src/architecture/compare-layout.js.map +1 -0
- package/dist/src/architecture/dataflow-layout.d.ts +29 -0
- package/dist/src/architecture/dataflow-layout.d.ts.map +1 -0
- package/dist/src/architecture/dataflow-layout.js +229 -0
- package/dist/src/architecture/dataflow-layout.js.map +1 -0
- package/dist/src/architecture/doc-fresh.d.ts +31 -0
- package/dist/src/architecture/doc-fresh.d.ts.map +1 -0
- package/dist/src/architecture/doc-fresh.js +84 -0
- package/dist/src/architecture/doc-fresh.js.map +1 -0
- package/dist/src/architecture/doc-link.d.ts +84 -0
- package/dist/src/architecture/doc-link.d.ts.map +1 -0
- package/dist/src/architecture/doc-link.js +396 -0
- package/dist/src/architecture/doc-link.js.map +1 -0
- package/dist/src/architecture/doc-routes.d.ts +39 -0
- package/dist/src/architecture/doc-routes.d.ts.map +1 -0
- package/dist/src/architecture/doc-routes.js +123 -0
- package/dist/src/architecture/doc-routes.js.map +1 -0
- package/dist/src/architecture/doc-scan.d.ts +107 -0
- package/dist/src/architecture/doc-scan.d.ts.map +1 -0
- package/dist/src/architecture/doc-scan.js +617 -0
- package/dist/src/architecture/doc-scan.js.map +1 -0
- package/dist/src/architecture/doc-stale.d.ts +32 -0
- package/dist/src/architecture/doc-stale.d.ts.map +1 -0
- package/dist/src/architecture/doc-stale.js +60 -0
- package/dist/src/architecture/doc-stale.js.map +1 -0
- package/dist/src/architecture/drilldown.d.ts +51 -0
- package/dist/src/architecture/drilldown.d.ts.map +1 -0
- package/dist/src/architecture/drilldown.js +1160 -0
- package/dist/src/architecture/drilldown.js.map +1 -0
- package/dist/src/architecture/endpoint-match.d.ts +43 -0
- package/dist/src/architecture/endpoint-match.d.ts.map +1 -0
- package/dist/src/architecture/endpoint-match.js +133 -0
- package/dist/src/architecture/endpoint-match.js.map +1 -0
- package/dist/src/architecture/flow-layout.d.ts +79 -0
- package/dist/src/architecture/flow-layout.d.ts.map +1 -0
- package/dist/src/architecture/flow-layout.js +519 -0
- package/dist/src/architecture/flow-layout.js.map +1 -0
- package/dist/src/architecture/focus.d.ts +14 -0
- package/dist/src/architecture/focus.d.ts.map +1 -0
- package/dist/src/architecture/focus.js +107 -0
- package/dist/src/architecture/focus.js.map +1 -0
- package/dist/src/architecture/global-context.d.ts +23 -0
- package/dist/src/architecture/global-context.d.ts.map +1 -0
- package/dist/src/architecture/global-context.js +67 -0
- package/dist/src/architecture/global-context.js.map +1 -0
- package/dist/src/architecture/html-client.d.ts +35 -0
- package/dist/src/architecture/html-client.d.ts.map +1 -0
- package/dist/src/architecture/html-client.js +1852 -0
- package/dist/src/architecture/html-client.js.map +1 -0
- package/dist/src/architecture/html-renderer.d.ts +22 -0
- package/dist/src/architecture/html-renderer.d.ts.map +1 -0
- package/dist/src/architecture/html-renderer.js +1290 -0
- package/dist/src/architecture/html-renderer.js.map +1 -0
- package/dist/src/architecture/html-theme.d.ts +25 -0
- package/dist/src/architecture/html-theme.d.ts.map +1 -0
- package/dist/src/architecture/html-theme.js +681 -0
- package/dist/src/architecture/html-theme.js.map +1 -0
- package/dist/src/architecture/index.d.ts +9 -0
- package/dist/src/architecture/index.d.ts.map +1 -0
- package/dist/src/architecture/index.js +9 -0
- package/dist/src/architecture/index.js.map +1 -0
- package/dist/src/architecture/ir-schema.d.ts +1832 -0
- package/dist/src/architecture/ir-schema.d.ts.map +1 -0
- package/dist/src/architecture/ir-schema.js +343 -0
- package/dist/src/architecture/ir-schema.js.map +1 -0
- package/dist/src/architecture/kind-text.d.ts +21 -0
- package/dist/src/architecture/kind-text.d.ts.map +1 -0
- package/dist/src/architecture/kind-text.js +42 -0
- package/dist/src/architecture/kind-text.js.map +1 -0
- package/dist/src/architecture/layout.d.ts +227 -0
- package/dist/src/architecture/layout.d.ts.map +1 -0
- package/dist/src/architecture/layout.js +627 -0
- package/dist/src/architecture/layout.js.map +1 -0
- package/dist/src/architecture/mcp-tool-match.d.ts +49 -0
- package/dist/src/architecture/mcp-tool-match.d.ts.map +1 -0
- package/dist/src/architecture/mcp-tool-match.js +53 -0
- package/dist/src/architecture/mcp-tool-match.js.map +1 -0
- package/dist/src/architecture/merge.d.ts +54 -0
- package/dist/src/architecture/merge.d.ts.map +1 -0
- package/dist/src/architecture/merge.js +681 -0
- package/dist/src/architecture/merge.js.map +1 -0
- package/dist/src/architecture/micro-app.d.ts +20 -0
- package/dist/src/architecture/micro-app.d.ts.map +1 -0
- package/dist/src/architecture/micro-app.js +41 -0
- package/dist/src/architecture/micro-app.js.map +1 -0
- package/dist/src/architecture/name-ref-match.d.ts +43 -0
- package/dist/src/architecture/name-ref-match.d.ts.map +1 -0
- package/dist/src/architecture/name-ref-match.js +127 -0
- package/dist/src/architecture/name-ref-match.js.map +1 -0
- package/dist/src/architecture/packs/data.d.ts +109 -0
- package/dist/src/architecture/packs/data.d.ts.map +1 -0
- package/dist/src/architecture/packs/data.js +64 -0
- package/dist/src/architecture/packs/data.js.map +1 -0
- package/dist/src/architecture/packs/generic.d.ts +47 -0
- package/dist/src/architecture/packs/generic.d.ts.map +1 -0
- package/dist/src/architecture/packs/generic.js +35 -0
- package/dist/src/architecture/packs/generic.js.map +1 -0
- package/dist/src/architecture/packs/harness.d.ts +96 -0
- package/dist/src/architecture/packs/harness.d.ts.map +1 -0
- package/dist/src/architecture/packs/harness.js +66 -0
- package/dist/src/architecture/packs/harness.js.map +1 -0
- package/dist/src/architecture/packs/index.d.ts +840 -0
- package/dist/src/architecture/packs/index.d.ts.map +1 -0
- package/dist/src/architecture/packs/index.js +87 -0
- package/dist/src/architecture/packs/index.js.map +1 -0
- package/dist/src/architecture/packs/infra.d.ts +91 -0
- package/dist/src/architecture/packs/infra.d.ts.map +1 -0
- package/dist/src/architecture/packs/infra.js +57 -0
- package/dist/src/architecture/packs/infra.js.map +1 -0
- package/dist/src/architecture/packs/knowledge.d.ts +72 -0
- package/dist/src/architecture/packs/knowledge.d.ts.map +1 -0
- package/dist/src/architecture/packs/knowledge.js +58 -0
- package/dist/src/architecture/packs/knowledge.js.map +1 -0
- package/dist/src/architecture/packs/process.d.ts +87 -0
- package/dist/src/architecture/packs/process.d.ts.map +1 -0
- package/dist/src/architecture/packs/process.js +52 -0
- package/dist/src/architecture/packs/process.js.map +1 -0
- package/dist/src/architecture/packs/render-classes.d.ts +11 -0
- package/dist/src/architecture/packs/render-classes.d.ts.map +1 -0
- package/dist/src/architecture/packs/render-classes.js +92 -0
- package/dist/src/architecture/packs/render-classes.js.map +1 -0
- package/dist/src/architecture/packs/types.d.ts +66 -0
- package/dist/src/architecture/packs/types.d.ts.map +1 -0
- package/dist/src/architecture/packs/types.js +18 -0
- package/dist/src/architecture/packs/types.js.map +1 -0
- package/dist/src/architecture/packs/web-product.d.ts +348 -0
- package/dist/src/architecture/packs/web-product.d.ts.map +1 -0
- package/dist/src/architecture/packs/web-product.js +226 -0
- package/dist/src/architecture/packs/web-product.js.map +1 -0
- package/dist/src/architecture/sequence-layout.d.ts +50 -0
- package/dist/src/architecture/sequence-layout.d.ts.map +1 -0
- package/dist/src/architecture/sequence-layout.js +140 -0
- package/dist/src/architecture/sequence-layout.js.map +1 -0
- package/dist/src/architecture/service-facts.d.ts +30 -0
- package/dist/src/architecture/service-facts.d.ts.map +1 -0
- package/dist/src/architecture/service-facts.js +88 -0
- package/dist/src/architecture/service-facts.js.map +1 -0
- package/dist/src/architecture/store.d.ts +50 -0
- package/dist/src/architecture/store.d.ts.map +1 -0
- package/dist/src/architecture/store.js +320 -0
- package/dist/src/architecture/store.js.map +1 -0
- package/dist/src/architecture/types.d.ts +400 -0
- package/dist/src/architecture/types.d.ts.map +1 -0
- package/dist/src/architecture/types.js +75 -0
- package/dist/src/architecture/types.js.map +1 -0
- package/dist/src/architecture/validator.d.ts +65 -0
- package/dist/src/architecture/validator.d.ts.map +1 -0
- package/dist/src/architecture/validator.js +1249 -0
- package/dist/src/architecture/validator.js.map +1 -0
- package/dist/src/core/errors.d.ts +4 -0
- package/dist/src/core/errors.d.ts.map +1 -1
- package/dist/src/core/errors.js +8 -0
- package/dist/src/core/errors.js.map +1 -1
- package/dist/src/core/types.d.ts +4 -0
- package/dist/src/core/types.d.ts.map +1 -1
- package/dist/src/mcp/schemas.d.ts +317 -0
- package/dist/src/mcp/schemas.d.ts.map +1 -1
- package/dist/src/mcp/schemas.js +151 -0
- package/dist/src/mcp/schemas.js.map +1 -1
- package/dist/src/mcp/server.d.ts.map +1 -1
- package/dist/src/mcp/server.js +7 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/architecture-passthrough.d.ts +9 -0
- package/dist/src/mcp/tools/architecture-passthrough.d.ts.map +1 -0
- package/dist/src/mcp/tools/architecture-passthrough.js +587 -0
- package/dist/src/mcp/tools/architecture-passthrough.js.map +1 -0
- package/dist/src/skills/parser.d.ts.map +1 -1
- package/dist/src/skills/parser.js +1 -0
- package/dist/src/skills/parser.js.map +1 -1
- package/dist/src/skills/types.d.ts +4 -0
- package/dist/src/skills/types.d.ts.map +1 -1
- package/dist/src/utils/claude-projects.d.ts +19 -0
- package/dist/src/utils/claude-projects.d.ts.map +1 -0
- package/dist/src/utils/claude-projects.js +157 -0
- package/dist/src/utils/claude-projects.js.map +1 -0
- package/dist/src/utils/read-only-tools.d.ts +19 -0
- package/dist/src/utils/read-only-tools.d.ts.map +1 -0
- package/dist/src/utils/read-only-tools.js +132 -0
- package/dist/src/utils/read-only-tools.js.map +1 -0
- package/package.json +7 -4
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/mcp.json +1 -1
- package/plugin/skills/_shared/proactive-routing.md +29 -12
- package/plugin/skills/architecture/SKILL.md +944 -0
- package/plugin/skills/brief/SKILL.md +2 -1
- package/plugin/skills/dispatch/SKILL.md +2 -0
- package/plugin/skills/explain/SKILL.md +4 -2
- package/plugin/skills/jira-create/SKILL.md +2 -0
- package/plugin/skills/pr/SKILL.md +2 -0
- package/plugin/skills/presentation/SKILL.md +2 -0
- package/plugin/skills/review/SKILL.md +1 -0
- package/plugin/skills/review-loop/SKILL.md +3 -1
- package/plugin/skills/review-reply/SKILL.md +2 -0
- package/plugin/skills/ship/SKILL.md +2 -2
- package/plugin/skills/slack-send/SKILL.md +2 -0
- package/schemas/architecture-ir.schema.json +596 -0
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
import { BAND_LINE_X, BAND_LINE_Y, BAND_TITLE_HALF, BAND_TITLE_X, CANVAS_PAD_TOP, CANVAS_PAD_X, LANE_INSET_Y, routeCanvas, } from './canvas-geometry.js';
|
|
2
|
+
import { flowCountByService, ROOT_LEVEL_ID } from './drilldown.js';
|
|
3
|
+
import { renderClientScript, THEME_BOOT_SCRIPT } from './html-client.js';
|
|
4
|
+
import { datastoreEngine, iconUse, PRODUCT_COLORS, renderCss, renderIconSprite, VIEW_CSS, DOC_CSS, } from './html-theme.js';
|
|
5
|
+
import { LANE_TITLES, flowBadgeText, MICRO_HOST_SHORT, NODE_KIND_SHORT, PLATFORM_CHIP_TEXT, PLATFORM_NAME, WEB_HOSTING_CHIP_TEXT, WEB_HOSTING_NAME, platformChipText, platformName, } from './kind-text.js';
|
|
6
|
+
import { compareEnvironment, FLAT_BACKWARD_EDGE_KINDS, textUnits, } from './layout.js';
|
|
7
|
+
import { indexMicroApps } from './micro-app.js';
|
|
8
|
+
import { ALL_PACKS_VOCABULARY, irVocabulary } from './packs/index.js';
|
|
9
|
+
import { KNOWLEDGE_COVERAGE_KINDS, KNOWLEDGE_DOC_KINDS } from './packs/knowledge.js';
|
|
10
|
+
import { computeCompareLayout } from './compare-layout.js';
|
|
11
|
+
import { computeDataflowLayout } from './dataflow-layout.js';
|
|
12
|
+
import { computeSequenceLayout } from './sequence-layout.js';
|
|
13
|
+
import { computeServiceFacts } from './service-facts.js';
|
|
14
|
+
import { chipTextOverride, displayKindOf } from './types.js';
|
|
15
|
+
import { maskSharedText, redactForSharing } from './validator.js';
|
|
16
|
+
const DASHED_PATTERN = '6 4';
|
|
17
|
+
const INFERRED_BADGE = '추정';
|
|
18
|
+
const EMPTY_LEVEL_SIZE = { width: 560, height: 240 };
|
|
19
|
+
const VIEW_TITLES = {
|
|
20
|
+
'screen-chain': '화면별 호출 흐름',
|
|
21
|
+
'deploy-path': '배포 경로',
|
|
22
|
+
knowledge: '지식 문서 지도',
|
|
23
|
+
'knowledge-link': '지식과 아키텍처',
|
|
24
|
+
};
|
|
25
|
+
/** 하네스 레포는 화면이 없어 '화면별'이 틀린 말이 된다. 화면이 하나라도 있으면 웹 그림으로 친다 */
|
|
26
|
+
function viewTitle(ir) {
|
|
27
|
+
if (ir.view !== 'screen-chain' || ir.nodes.some((n) => n.kind === 'screen')) {
|
|
28
|
+
return VIEW_TITLES[ir.view];
|
|
29
|
+
}
|
|
30
|
+
if (ir.nodes.some((n) => n.kind === 'skill'))
|
|
31
|
+
return '스킬별 호출 흐름';
|
|
32
|
+
if (ir.nodes.some((n) => n.kind === 'endpoint' && n.protocol === 'mcp'))
|
|
33
|
+
return 'MCP 도구 호출 흐름';
|
|
34
|
+
// 팩을 적은 IR에서 web-product가 빠졌으면 화면도 호출도 없는 그림이다. 팩이 없는 옛 IR은 제목을 그대로 둔다
|
|
35
|
+
if (ir.packs && !ir.packs.includes('web-product')) {
|
|
36
|
+
return ir.nodes.length === 0 && (ir.flows?.length ?? 0) > 0 ? '업무 흐름' : '구성 요소 연결';
|
|
37
|
+
}
|
|
38
|
+
return VIEW_TITLES[ir.view];
|
|
39
|
+
}
|
|
40
|
+
// 페이지에 보이는 글자는 IR 식별자 대신 읽는 사람 말로 바꿔 보여준다
|
|
41
|
+
const NODE_KIND_TEXT = Object.fromEntries(Object.entries(ALL_PACKS_VOCABULARY.looks).map(([k, d]) => [k, d.text]));
|
|
42
|
+
const EDGE_KIND_TEXT = {
|
|
43
|
+
...Object.fromEntries(Object.entries(ALL_PACKS_VOCABULARY.edgeKinds).map(([k, d]) => [k, d.text])),
|
|
44
|
+
contains: '포함',
|
|
45
|
+
};
|
|
46
|
+
const EVIDENCE_TYPE_TEXT = {
|
|
47
|
+
code: '코드',
|
|
48
|
+
spec: '스펙',
|
|
49
|
+
doc: '문서',
|
|
50
|
+
user: '사용자 확인',
|
|
51
|
+
live: '실제 조회',
|
|
52
|
+
};
|
|
53
|
+
const FLOW_TEXT = {
|
|
54
|
+
step: '흐름 단계',
|
|
55
|
+
transition: '상태 전이',
|
|
56
|
+
main: '정상 흐름',
|
|
57
|
+
side: '옆 흐름',
|
|
58
|
+
back: '앞 단계로 되돌아감',
|
|
59
|
+
end: '흐름 끝',
|
|
60
|
+
person: '사람',
|
|
61
|
+
system: '시스템',
|
|
62
|
+
};
|
|
63
|
+
const HINT_DRILL = '항목을 누르면 출처가 보여요. 더블클릭하거나 상세보기를 누르면 한 단계 안으로 들어가고 뒤로가기로 돌아와요. 전체 화면에서 Shift나 ⌘를 누른 채 두 항목을 고르면 그 사이 경로를 보여줘요.';
|
|
64
|
+
const HINT_FLAT = '항목을 누르면 출처가 보여요. 끌어서 옮기고 ⌘나 Ctrl을 누른 채 휠을 굴리면 크게 볼 수 있어요.';
|
|
65
|
+
function compareStr(a, b) {
|
|
66
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
67
|
+
}
|
|
68
|
+
function byId(a, b) {
|
|
69
|
+
return compareStr(a.id, b.id);
|
|
70
|
+
}
|
|
71
|
+
function escapeHtml(s) {
|
|
72
|
+
return s
|
|
73
|
+
.replace(/&/g, '&')
|
|
74
|
+
.replace(/</g, '<')
|
|
75
|
+
.replace(/>/g, '>')
|
|
76
|
+
.replace(/"/g, '"')
|
|
77
|
+
.replace(/'/g, ''');
|
|
78
|
+
}
|
|
79
|
+
/** 객체 키를 코드 유닛 순으로 정렬해 직렬화한다. undefined 값은 JSON.stringify처럼 빠진다 */
|
|
80
|
+
export function stableStringify(value) {
|
|
81
|
+
if (value === null || typeof value !== 'object') {
|
|
82
|
+
return JSON.stringify(value) ?? 'null';
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(value)) {
|
|
85
|
+
return `[${value.map((v) => (v === undefined ? 'null' : stableStringify(v))).join(',')}]`;
|
|
86
|
+
}
|
|
87
|
+
const obj = value;
|
|
88
|
+
const parts = [];
|
|
89
|
+
for (const key of Object.keys(obj).sort(compareStr)) {
|
|
90
|
+
const v = obj[key];
|
|
91
|
+
if (v === undefined)
|
|
92
|
+
continue;
|
|
93
|
+
parts.push(`${JSON.stringify(key)}:${stableStringify(v)}`);
|
|
94
|
+
}
|
|
95
|
+
return `{${parts.join(',')}}`;
|
|
96
|
+
}
|
|
97
|
+
// script 블록 안에서 HTML 파서가 끝 태그나 주석 시작으로 읽을 수 있는 조각만 JSON 이스케이프로 바꾼다
|
|
98
|
+
function embedJson(json) {
|
|
99
|
+
return json.replace(/<\//g, '<\\/').replace(/<!--/g, '\\u003c!--');
|
|
100
|
+
}
|
|
101
|
+
function round2(v) {
|
|
102
|
+
return Math.round(v * 100) / 100;
|
|
103
|
+
}
|
|
104
|
+
// ir은 공유본이면 이미 가린 사본이다. 자동 질문은 검증기가 원본 이름으로 지었으므로 여기서 한 번 더 가린다
|
|
105
|
+
function openQuestions(ir, validated, shared) {
|
|
106
|
+
const fromIr = ir.unresolved.filter((q) => q.answer === undefined || q.answer === '');
|
|
107
|
+
const auto = shared
|
|
108
|
+
? validated.autoUnresolved.map((q) => ({ ...q, question: maskSharedText(q.question) }))
|
|
109
|
+
: validated.autoUnresolved;
|
|
110
|
+
return [...fromIr, ...auto].sort(byId);
|
|
111
|
+
}
|
|
112
|
+
/** 지식과 아키텍처 그림에서 기술 노드 id → 그 노드를 설명하는 문서. 다른 그림이면 undefined */
|
|
113
|
+
function docOverlayOf(ir, drawable) {
|
|
114
|
+
if (ir.view !== 'knowledge-link')
|
|
115
|
+
return undefined;
|
|
116
|
+
const docById = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const e of [...ir.edges].sort(byId)) {
|
|
119
|
+
if (e.kind !== 'describes' || !drawable.has(e.to))
|
|
120
|
+
continue;
|
|
121
|
+
const d = docById.get(e.from);
|
|
122
|
+
if (d?.doc === undefined || !KNOWLEDGE_DOC_KINDS.includes(d.kind))
|
|
123
|
+
continue;
|
|
124
|
+
const gaps = d.doc.gaps ?? [];
|
|
125
|
+
(out[e.to] ??= []).push({
|
|
126
|
+
id: d.id,
|
|
127
|
+
path: d.doc.path,
|
|
128
|
+
...(d.displayName !== undefined ? { title: d.displayName } : {}),
|
|
129
|
+
via: e.docLink?.via ?? 'session',
|
|
130
|
+
verified: e.docLink?.refs.some((r) => r.verified) ?? false,
|
|
131
|
+
gaps: gaps.filter((g) => g.kind === 'gap').length,
|
|
132
|
+
unverified: gaps.filter((g) => g.kind === 'unverified').length,
|
|
133
|
+
broken: d.doc.links?.broken ?? 0,
|
|
134
|
+
...(d.doc.staleSince !== undefined ? { staleSince: d.doc.staleSince } : {}),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/** 카드 배지에 쓰는 문서 수와 낡은 문서 수. 문서가 있어야 할 kind는 0개도 싣는다 */
|
|
140
|
+
function docCoverOf(nodes, overlay) {
|
|
141
|
+
if (overlay === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
// 담당은 공유본에서 빠진다. 담당이 하나도 안 실린 그림에서 "담당 없음"을 달면 전부 비어 보이므로 그때는 안 단다
|
|
144
|
+
const ownersKnown = nodes.some((n) => (n.owners ?? []).length > 0);
|
|
145
|
+
const out = {};
|
|
146
|
+
for (const n of nodes) {
|
|
147
|
+
const list = overlay[n.id] ?? [];
|
|
148
|
+
const slot = KNOWLEDGE_COVERAGE_KINDS.includes(n.kind);
|
|
149
|
+
if (list.length === 0 && !slot)
|
|
150
|
+
continue;
|
|
151
|
+
out[n.id] = {
|
|
152
|
+
docs: list.length,
|
|
153
|
+
stale: list.filter((d) => d.staleSince !== undefined).length,
|
|
154
|
+
...(ownersKnown && slot && (n.owners ?? []).length === 0 ? { unowned: true } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function docCoverSpec(nodes, overlay) {
|
|
160
|
+
const docCover = docCoverOf(nodes, overlay);
|
|
161
|
+
return docCover !== undefined ? { docCover } : {};
|
|
162
|
+
}
|
|
163
|
+
function docCoverageOf(nodes, overlay) {
|
|
164
|
+
if (overlay === undefined)
|
|
165
|
+
return undefined;
|
|
166
|
+
const slots = nodes.filter((n) => KNOWLEDGE_COVERAGE_KINDS.includes(n.kind));
|
|
167
|
+
return {
|
|
168
|
+
covered: slots.filter((n) => (overlay[n.id] ?? []).length > 0).length,
|
|
169
|
+
total: slots.length,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// 페이지가 실제로 쓰는 필드만 싣는다. repo root 같은 로컬 경로와 sourcesUsed 식별자가 공유본으로 새지 않게 한다
|
|
173
|
+
function buildPayload(ir, validated, shared) {
|
|
174
|
+
const nodes = ir.nodes.filter((n) => validated.drawableNodeIds.has(n.id)).sort(byId);
|
|
175
|
+
const edges = ir.edges.filter((e) => validated.drawableEdgeIds.has(e.id)).sort(byId);
|
|
176
|
+
const services = Object.fromEntries(computeServiceFacts(nodes, edges));
|
|
177
|
+
const apps = indexMicroApps(nodes, edges);
|
|
178
|
+
// 호스트 칩 글자를 클라이언트가 새로 그리는 카드에도 달아야 해서 싣는다. 앱이 없는 IR은 키 자체를 안 넣어 바이트가 그대로다
|
|
179
|
+
const microHosts = [...apps.hosts].sort(compareStr);
|
|
180
|
+
const docOverlay = docOverlayOf(ir, validated.drawableNodeIds);
|
|
181
|
+
return {
|
|
182
|
+
schemaVersion: ir.schemaVersion,
|
|
183
|
+
view: ir.view,
|
|
184
|
+
generatedAt: ir.generatedAt,
|
|
185
|
+
repos: ir.repos.map((r) => ({ id: r.id, name: r.name })).sort(byId),
|
|
186
|
+
nodes,
|
|
187
|
+
edges,
|
|
188
|
+
services,
|
|
189
|
+
...(microHosts.length > 0 ? { microHosts } : {}),
|
|
190
|
+
...(docOverlay !== undefined ? { docOverlay } : {}),
|
|
191
|
+
unresolved: openQuestions(ir, validated, shared),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** 양 끝 노드가 서로 다른 계정에 속하면 계정을 넘는 선이다. 한쪽이라도 계정을 모르면 넘는다고 하지 않는다 */
|
|
195
|
+
function crossesAccount(nodeById, edge) {
|
|
196
|
+
const a = nodeById.get(edge.from)?.account;
|
|
197
|
+
const b = nodeById.get(edge.to)?.account;
|
|
198
|
+
return a !== undefined && b !== undefined && a !== b;
|
|
199
|
+
}
|
|
200
|
+
function nodeName(node) {
|
|
201
|
+
return node.displayName ?? node.label;
|
|
202
|
+
}
|
|
203
|
+
function isGuess(node) {
|
|
204
|
+
return node.displayName !== undefined && node.displayNameInferred === true;
|
|
205
|
+
}
|
|
206
|
+
/** 묶음 선 굵기. 건수의 제곱근을 따라야 수백 건짜리 선이 화면을 덮지 않는다 */
|
|
207
|
+
function edgeWidth(count) {
|
|
208
|
+
return round2(Math.min(8, Math.max(1.5, 1.5 + (Math.sqrt(Math.max(count, 1)) - 1) * 1.1)));
|
|
209
|
+
}
|
|
210
|
+
function asCanvasEdge(edge) {
|
|
211
|
+
return {
|
|
212
|
+
id: edge.id,
|
|
213
|
+
from: edge.from,
|
|
214
|
+
to: edge.to,
|
|
215
|
+
kind: edge.kind,
|
|
216
|
+
count: 1,
|
|
217
|
+
lineStyle: edge.lineStyle,
|
|
218
|
+
...(FLAT_BACKWARD_EDGE_KINDS.has(edge.kind) ? { backward: true } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function platformChips(facts) {
|
|
222
|
+
if (!facts)
|
|
223
|
+
return '';
|
|
224
|
+
return facts.platforms
|
|
225
|
+
.map((p) => `<span class="pf pf-${p}" role="img" aria-label="${platformName(p, facts.webHosting)}" title="${platformName(p, facts.webHosting)}">` +
|
|
226
|
+
`${iconUse(`p-${p}`, p === 'web' ? '' : `brand b-${p}`)}${platformChipText(p, facts.webHosting)}</span>`)
|
|
227
|
+
.join('');
|
|
228
|
+
}
|
|
229
|
+
/** 문서 카드의 열린 구멍 수와 깨진 근거 링크 수. 문서가 아닌 카드에는 아무것도 안 붙인다 */
|
|
230
|
+
function docBadges(node) {
|
|
231
|
+
const doc = node.doc;
|
|
232
|
+
if (doc === undefined)
|
|
233
|
+
return '';
|
|
234
|
+
const gaps = doc.gaps?.length ?? 0;
|
|
235
|
+
const broken = doc.links?.broken ?? 0;
|
|
236
|
+
return ((gaps > 0 ? `<span class="doc-badge" title="열린 구멍 ${gaps}개">구멍 ${gaps}</span>` : '') +
|
|
237
|
+
(broken > 0
|
|
238
|
+
? `<span class="doc-badge broken" title="깨진 근거 링크 ${broken}개">깨짐 ${broken}</span>`
|
|
239
|
+
: '') +
|
|
240
|
+
(doc.orphan
|
|
241
|
+
? '<span class="doc-badge none" title="README나 SKILL 같은 진입 문서에서 링크를 따라가도 안 닿아요">고립</span>'
|
|
242
|
+
: '') +
|
|
243
|
+
(doc.aged
|
|
244
|
+
? '<span class="doc-badge none" title="반년 넘게 아무도 안 고친 문서예요">오래됨</span>'
|
|
245
|
+
: ''));
|
|
246
|
+
}
|
|
247
|
+
/** 지식과 아키텍처 그림의 기술 카드 배지. 문서가 없으면 "문서 없음"을 단다. 있으면 수와 낡은 수를 단다 */
|
|
248
|
+
function docCoverBadge(cover) {
|
|
249
|
+
if (cover === undefined)
|
|
250
|
+
return '';
|
|
251
|
+
const owner = cover.unowned
|
|
252
|
+
? '<span class="doc-badge none" title="CODEOWNERS에 이 자리를 맡은 담당이 없어요">담당 없음</span>'
|
|
253
|
+
: '';
|
|
254
|
+
if (cover.docs === 0)
|
|
255
|
+
return ('<span class="doc-badge none" title="이 자리를 설명하는 문서가 없어요">문서 없음</span>' +
|
|
256
|
+
owner);
|
|
257
|
+
return (`<span class="doc-badge cover" title="설명하는 문서 ${cover.docs}개">문서 ${cover.docs}</span>` +
|
|
258
|
+
(cover.stale > 0
|
|
259
|
+
? `<span class="doc-badge stale" title="코드가 문서보다 늦게 바뀐 문서 ${cover.stale}개">낡음 ${cover.stale}</span>`
|
|
260
|
+
: '') +
|
|
261
|
+
owner);
|
|
262
|
+
}
|
|
263
|
+
function renderCard(node, box, enterable, focus, facts, band, micro, flows = 0, products = [], productIds = [], cover, where) {
|
|
264
|
+
const name = nodeName(node);
|
|
265
|
+
const guess = isGuess(node);
|
|
266
|
+
const platforms = facts?.platforms.map((p) => platformName(p, facts.webHosting)) ?? [];
|
|
267
|
+
const dk = displayKindOf(node);
|
|
268
|
+
const cls = ['node', `k-${dk}`];
|
|
269
|
+
if (products.length > 1)
|
|
270
|
+
cls.push('shared');
|
|
271
|
+
if (enterable)
|
|
272
|
+
cls.push('enterable');
|
|
273
|
+
if (focus)
|
|
274
|
+
cls.push('is-focus');
|
|
275
|
+
const aria = `${name}, ${chipTextOverride(node) ?? NODE_KIND_TEXT[dk]}${guess ? `, ${INFERRED_BADGE} 이름` : ''}` +
|
|
276
|
+
(platforms.length > 0 ? `, ${platforms.join(', ')}` : '') +
|
|
277
|
+
(flows > 0 ? `, 사용자 흐름 ${flows}개` : '') +
|
|
278
|
+
(products.length > 1 ? `, 같이 쓰는 제품 ${products.join(', ')}` : '');
|
|
279
|
+
const tooltip = (node.displayName === undefined
|
|
280
|
+
? node.label
|
|
281
|
+
: `${node.displayName}${guess ? ` (${INFERRED_BADGE})` : ''}\n${node.label}`) +
|
|
282
|
+
(facts?.prodDomain !== undefined ? `\n${facts.prodDomain}` : '');
|
|
283
|
+
const subText = where !== undefined
|
|
284
|
+
? `<span class="tc">${escapeHtml(where)}</span>`
|
|
285
|
+
: facts?.prodDomain !== undefined
|
|
286
|
+
? `<span class="tc dom">${escapeHtml(facts.prodDomain)}</span>`
|
|
287
|
+
: node.displayName !== undefined
|
|
288
|
+
? `<span class="tc">${escapeHtml(node.label)}</span>`
|
|
289
|
+
: '';
|
|
290
|
+
const chips = platformChips(facts);
|
|
291
|
+
const second = chips === '' ? subText : `<span class="l2">${subText}${chips}</span>`;
|
|
292
|
+
const engine = datastoreEngine(node);
|
|
293
|
+
const x = round2(box.x + CANVAS_PAD_X);
|
|
294
|
+
const y = round2(box.y + CANVAS_PAD_TOP);
|
|
295
|
+
return (`<div class="${cls.join(' ')}" data-node-id="${escapeHtml(node.id)}"${band !== undefined ? ` data-band="${band}"` : ''}` +
|
|
296
|
+
`${productIds.length > 1 ? ` data-products="${productIds.join(' ')}"` : ''}` +
|
|
297
|
+
`${node.environment !== undefined ? ` data-env="${escapeHtml(node.environment)}"` : ''}` +
|
|
298
|
+
`${micro.frame !== undefined ? ` data-frame="${escapeHtml(micro.frame)}"` : ''} role="button" tabindex="0" ` +
|
|
299
|
+
`aria-label="${escapeHtml(aria)}" title="${escapeHtml(tooltip)}" ` +
|
|
300
|
+
`style="left:${x}px;top:${y}px;width:${box.width}px;height:${box.height}px">` +
|
|
301
|
+
`<span class="kc">${engine !== undefined ? iconUse(`e-${engine}`, `brand b-${engine}`) : iconUse(`i-${dk}`)}${escapeHtml(micro.host ? MICRO_HOST_SHORT : (chipTextOverride(node) ?? NODE_KIND_SHORT[dk]))}</span>` +
|
|
302
|
+
`<span class="nm"><span class="t">${escapeHtml(name)}</span>${guess ? `<span class="guess">${INFERRED_BADGE}</span>` : ''}` +
|
|
303
|
+
(flows > 0
|
|
304
|
+
? `<span class="flow-badge" title="사용자 흐름 보기">${iconUse('u-flow')}${escapeHtml(flowBadgeText(flows))}</span>`
|
|
305
|
+
: '') +
|
|
306
|
+
docBadges(node) +
|
|
307
|
+
docCoverBadge(cover) +
|
|
308
|
+
`</span>` +
|
|
309
|
+
second +
|
|
310
|
+
(enterable ? '<span class="go" aria-hidden="true">›</span>' : '') +
|
|
311
|
+
(products.length > 1 ? renderProductBricks(products, productIds, box.width) : '') +
|
|
312
|
+
`</div>`);
|
|
313
|
+
}
|
|
314
|
+
// 제품 브릭 폭 어림값. 카드 폭처럼 글자 수로 재야 서버 결과가 폰트에 안 흔들린다
|
|
315
|
+
const BRICK_PAD = 12;
|
|
316
|
+
const BRICK_UNIT = 5.6;
|
|
317
|
+
const BRICK_GAP = 2;
|
|
318
|
+
const BRICK_ROW_INSET = 16;
|
|
319
|
+
const BRICK_MORE = 30;
|
|
320
|
+
/**
|
|
321
|
+
* 같이 쓰는 카드 위에 꽂는 제품 브릭 한 줄. 카드 폭에 들어가는 만큼만 꽂고 남은 수는 +N 버튼에 적는다.
|
|
322
|
+
* +N을 누르면 스크립트가 그 카드를 같이 쓰는 제품 전부를 드롭다운으로 띄운다
|
|
323
|
+
*/
|
|
324
|
+
function renderProductBricks(names, ids, cardWidth) {
|
|
325
|
+
const room = cardWidth - BRICK_ROW_INSET;
|
|
326
|
+
let used = 0;
|
|
327
|
+
let shown = 0;
|
|
328
|
+
for (let i = 0; i < names.length; i++) {
|
|
329
|
+
const w = BRICK_PAD + textUnits(names[i]) * BRICK_UNIT;
|
|
330
|
+
const reserve = i < names.length - 1 ? BRICK_MORE : 0;
|
|
331
|
+
if (used + w + reserve > room)
|
|
332
|
+
break;
|
|
333
|
+
used += w + BRICK_GAP;
|
|
334
|
+
shown += 1;
|
|
335
|
+
}
|
|
336
|
+
const bricks = names
|
|
337
|
+
.slice(0, shown)
|
|
338
|
+
.map((n, i) => `<span class="pb p-${ids[i] % PRODUCT_COLORS}">${escapeHtml(n)}</span>`)
|
|
339
|
+
.join('');
|
|
340
|
+
const rest = names.length - shown;
|
|
341
|
+
const more = rest > 0
|
|
342
|
+
? `<button type="button" class="pb more" aria-haspopup="true" aria-expanded="false" ` +
|
|
343
|
+
`aria-label="${escapeHtml(`같이 쓰는 제품 ${names.length}개 모두 보기`)}">+${rest}</button>`
|
|
344
|
+
: '';
|
|
345
|
+
return `<span class="pbricks" aria-hidden="${rest > 0 ? 'false' : 'true'}">${bricks}${more}</span>`;
|
|
346
|
+
}
|
|
347
|
+
// 근거가 섞인 묶음의 배지는 테두리를 점선으로 둔다. 공용 CSS를 고치면 섞인 묶음이 없는 그림까지 바이트가 바뀌어 속성으로 단다
|
|
348
|
+
const MIXED_PILL = ` stroke-dasharray="3 2"`;
|
|
349
|
+
/** 묶음 이름 뒤에 붙는 말. 코드로 확인된 고리와 문서나 사용자 근거뿐인 고리가 섞였을 때만 붙는다 */
|
|
350
|
+
export function inferredNote(inferred) {
|
|
351
|
+
return inferred !== undefined ? ` (문서 근거만 있는 연결 ${inferred}개 포함)` : '';
|
|
352
|
+
}
|
|
353
|
+
function renderPill(count, at, mixed = false) {
|
|
354
|
+
const text = String(count);
|
|
355
|
+
const w = 14 + text.length * 7;
|
|
356
|
+
return (`<g class="pill" transform="translate(${at.x},${at.y})">` +
|
|
357
|
+
`<rect x="${round2(-w / 2)}" y="-9" width="${w}" height="18" rx="9"${mixed ? MIXED_PILL : ''}/><text>${text}</text></g>`);
|
|
358
|
+
}
|
|
359
|
+
function renderLink(edge, route, labelOf, crossAccount) {
|
|
360
|
+
const width = edgeWidth(edge.count);
|
|
361
|
+
const dash = edge.lineStyle === 'dashed' ? ` stroke-dasharray="${DASHED_PATTERN}"` : '';
|
|
362
|
+
const ends = `data-from="${escapeHtml(edge.from)}" data-to="${escapeHtml(edge.to)}"`;
|
|
363
|
+
const xa = crossAccount ? ' x-account' : '';
|
|
364
|
+
const xaText = crossAccount ? ' (계정을 넘는 연결)' : '';
|
|
365
|
+
const hit = `<path class="hit" d="${route.d}" stroke-width="${Math.max(12, width + 8)}"/>`;
|
|
366
|
+
const tip = `<path class="tip" d="${route.tip}"/>`;
|
|
367
|
+
if (edge.kind === 'bundle') {
|
|
368
|
+
const name = `${labelOf(edge.from)} → ${labelOf(edge.to)} ${edge.count}개${inferredNote(edge.inferred)}${xaText}`;
|
|
369
|
+
return (`<g class="link bundle${xa}" data-bundle-id="${escapeHtml(edge.id)}" ${ends} tabindex="0" role="button" aria-label="${escapeHtml(name)}">` +
|
|
370
|
+
`<title>${escapeHtml(name)}</title>${hit}` +
|
|
371
|
+
`<path class="edge" d="${route.d}" stroke-width="${width}"${dash}/>${tip}` +
|
|
372
|
+
`${renderPill(edge.count, route.mid, edge.inferred !== undefined)}</g>`);
|
|
373
|
+
}
|
|
374
|
+
const name = `${EDGE_KIND_TEXT[edge.kind]}: ${labelOf(edge.from)} → ${labelOf(edge.to)}${xaText}`;
|
|
375
|
+
return (`<g class="link e-${edge.kind}${xa}" ${ends}><title>${escapeHtml(name)}</title>${hit}` +
|
|
376
|
+
`<path class="edge" data-edge-id="${escapeHtml(edge.id)}" d="${route.d}" stroke-width="${width}"${dash}/>${tip}</g>`);
|
|
377
|
+
}
|
|
378
|
+
const VIEW_SHAPE_NAME = {
|
|
379
|
+
sequence: '순서도',
|
|
380
|
+
dataflow: '데이터 이동',
|
|
381
|
+
compare: '견주기',
|
|
382
|
+
};
|
|
383
|
+
const STRIP_CARD_W = 240;
|
|
384
|
+
const STRIP_GAP = 12;
|
|
385
|
+
/** 제목 줄과 카드, 지도와 띄우는 여백까지 합친 높이 */
|
|
386
|
+
const STRIP_H = 136;
|
|
387
|
+
/** 질문별 그림이 몇 개 있는지 헤더 단추를 안 눌러도 보이게 지도 위에 카드로 깐다. 섹션을 이만큼 내리고 그 위 빈자리에 둔다 */
|
|
388
|
+
function renderViewStrip(cards) {
|
|
389
|
+
const items = cards
|
|
390
|
+
.map((c, i) => `<a class="view-card" href="#/level/${encodeURIComponent(c.level)}" style="left:${i * (STRIP_CARD_W + STRIP_GAP)}px;width:${STRIP_CARD_W}px">` +
|
|
391
|
+
`<span class="vc-shape">${VIEW_SHAPE_NAME[c.projection.shape]}</span>` +
|
|
392
|
+
`<b>${escapeHtml(c.projection.title)}</b>` +
|
|
393
|
+
`<span class="vc-q">${escapeHtml(c.projection.question)}</span>` +
|
|
394
|
+
`<span class="vc-n">단계 ${c.steps}${c.actors > 0 ? `, 참여 ${c.actors}` : ''}</span></a>`)
|
|
395
|
+
.join('');
|
|
396
|
+
return {
|
|
397
|
+
html: `<nav class="view-strip" aria-label="질문별 그림" style="left:${CANVAS_PAD_X}px;top:-${STRIP_H}px">` +
|
|
398
|
+
`<h3>질문별 그림 <span class="n">${cards.length}</span></h3><div class="vs-row">${items}</div></nav>`,
|
|
399
|
+
width: CANVAS_PAD_X * 2 + cards.length * (STRIP_CARD_W + STRIP_GAP) - STRIP_GAP,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function productNames(layout, id) {
|
|
403
|
+
const regions = layout.regions;
|
|
404
|
+
return (regions?.productsOf[id] ?? []).map((i) => regions.groups[i].name);
|
|
405
|
+
}
|
|
406
|
+
/** 맨 위 같이 쓰는 띠와 제품마다 전용 띠. 띠마다 머리에 점선 구분선과 이름을 단다 */
|
|
407
|
+
function renderBands(layout, width) {
|
|
408
|
+
const regions = layout.regions;
|
|
409
|
+
if (!regions)
|
|
410
|
+
return { regionRects: '', regionTitles: '' };
|
|
411
|
+
const lines = regions.bands.map((b) => {
|
|
412
|
+
const y = round2(b.y + CANVAS_PAD_TOP + BAND_LINE_Y);
|
|
413
|
+
return `<line class="band-line" x1="${BAND_LINE_X}" y1="${y}" x2="${round2(width - BAND_LINE_X)}" y2="${y}"/>`;
|
|
414
|
+
});
|
|
415
|
+
const titles = regions.bands.map((b) => {
|
|
416
|
+
const top = round2(b.y + CANVAS_PAD_TOP + BAND_LINE_Y - BAND_TITLE_HALF);
|
|
417
|
+
const name = b.band === 0
|
|
418
|
+
? '같이 쓰는 카드'
|
|
419
|
+
: `<i class="sw p-${(b.band - 1) % PRODUCT_COLORS}"></i>${escapeHtml(regions.groups[b.band - 1].name)} 전용`;
|
|
420
|
+
return `<div class="band-title" data-band="${b.band}" style="left:${BAND_TITLE_X}px;top:${top}px">${name}</div>`;
|
|
421
|
+
});
|
|
422
|
+
return { regionRects: lines.join(''), regionTitles: titles.join('') };
|
|
423
|
+
}
|
|
424
|
+
// 스타일을 인라인으로 두는 건 공용 CSS에 규칙을 더하면 기존 그림의 바이트가 바뀌어서다
|
|
425
|
+
function emptyFlowLinks(links) {
|
|
426
|
+
const items = links
|
|
427
|
+
.map((l) => `<li><a href="#/level/${encodeURIComponent(l.level)}" style="color:var(--text)">${escapeHtml(l.title)}</a></li>`)
|
|
428
|
+
.join('');
|
|
429
|
+
return (`<div class="empty-state"><div><p style="margin:0 0 8px">이 그림은 흐름만 있어요. 볼 흐름을 고르세요.</p>` +
|
|
430
|
+
`<ul style="margin:0;padding-left:18px;line-height:1.8">${items}</ul></div></div>`);
|
|
431
|
+
}
|
|
432
|
+
/** 레벨 하나. 레인 띠와 선은 SVG, 카드는 그 위에 겹친 HTML이다. 좌표는 전부 서버가 박는다 */
|
|
433
|
+
function renderLevelSection(spec) {
|
|
434
|
+
const open = `<section class="level" data-level-id="${escapeHtml(spec.levelId)}" aria-label="${escapeHtml(spec.title)}"`;
|
|
435
|
+
const hidden = spec.hidden ? ' hidden' : '';
|
|
436
|
+
if (spec.nodes.length === 0) {
|
|
437
|
+
const { width, height } = EMPTY_LEVEL_SIZE;
|
|
438
|
+
return (`${open} data-w="${width}" data-h="${height}" style="width:${width}px;height:${height}px"${hidden}>` +
|
|
439
|
+
`${spec.emptyLinks?.length ? emptyFlowLinks(spec.emptyLinks) : '<p class="empty-state">여기는 보여줄 항목이 없어요.</p>'}</section>`);
|
|
440
|
+
}
|
|
441
|
+
const nodeById = new Map(spec.nodes.map((n) => [n.id, n]));
|
|
442
|
+
const labelOf = (id) => {
|
|
443
|
+
const node = nodeById.get(id);
|
|
444
|
+
return node ? nodeName(node) : id;
|
|
445
|
+
};
|
|
446
|
+
const layout = {
|
|
447
|
+
...spec.layout,
|
|
448
|
+
nodes: [...spec.layout.nodes].sort(byId),
|
|
449
|
+
edges: [...spec.layout.edges].sort(byId),
|
|
450
|
+
};
|
|
451
|
+
const edges = [...spec.edges].sort(byId);
|
|
452
|
+
const canvas = routeCanvas(layout, edges.map((e) => ({
|
|
453
|
+
id: e.id,
|
|
454
|
+
from: e.from,
|
|
455
|
+
to: e.to,
|
|
456
|
+
width: edgeWidth(e.count),
|
|
457
|
+
...(e.backward ? { backward: true } : {}),
|
|
458
|
+
})));
|
|
459
|
+
const frameOf = new Map();
|
|
460
|
+
for (const f of layout.frames ?? [])
|
|
461
|
+
frameOf.set(f.id, f.id);
|
|
462
|
+
const frameRects = (layout.frames ?? [])
|
|
463
|
+
.map((f) => `<rect class="frame" data-frame-id="${escapeHtml(f.id)}" x="${round2(f.x + CANVAS_PAD_X)}" y="${round2(f.y + CANVAS_PAD_TOP)}" ` +
|
|
464
|
+
`width="${f.width}" height="${f.height}" rx="14"/>`)
|
|
465
|
+
.join('');
|
|
466
|
+
const { width, height } = canvas;
|
|
467
|
+
const laneRects = layout.lanes
|
|
468
|
+
.map((l) => `<rect class="lane" x="${round2(l.x + CANVAS_PAD_X)}" y="${LANE_INSET_Y}" width="${l.width}" ` +
|
|
469
|
+
`height="${round2(height - LANE_INSET_Y * 2)}" rx="14"/>`)
|
|
470
|
+
.join('');
|
|
471
|
+
const laneTitles = layout.lanes
|
|
472
|
+
.map((l) => `<div class="lane-title" style="left:${round2(l.x + CANVAS_PAD_X)}px;top:${LANE_INSET_Y + 10}px;width:${l.width}px">` +
|
|
473
|
+
`${escapeHtml(l.title)}<span class="n">${l.count}</span></div>`)
|
|
474
|
+
.join('');
|
|
475
|
+
const { regionRects, regionTitles } = renderBands(layout, width);
|
|
476
|
+
const links = edges
|
|
477
|
+
.map((e) => {
|
|
478
|
+
const route = canvas.edges.get(e.id);
|
|
479
|
+
return route ? renderLink(e, route, labelOf, crossesAccount(nodeById, e)) : '';
|
|
480
|
+
})
|
|
481
|
+
.join('');
|
|
482
|
+
const boxes = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
483
|
+
const cards = spec.nodes
|
|
484
|
+
.map((n) => {
|
|
485
|
+
const box = boxes.get(n.id);
|
|
486
|
+
if (!box)
|
|
487
|
+
return '';
|
|
488
|
+
const target = spec.enter[n.id];
|
|
489
|
+
return renderCard(n, box, target !== undefined && target !== spec.levelId, n.id === spec.focusId, spec.services[n.id], layout.regions?.bandOf[n.id], {
|
|
490
|
+
host: n.kind === 'micro_app' && spec.microHosts.has(n.id),
|
|
491
|
+
...(frameOf.has(n.id) || frameOf.has(n.parent ?? '')
|
|
492
|
+
? { frame: frameOf.get(n.id) ?? frameOf.get(n.parent) }
|
|
493
|
+
: {}),
|
|
494
|
+
}, spec.flows?.[n.id] ?? 0, productNames(layout, n.id), layout.regions?.productsOf[n.id] ?? [], spec.docCover?.[n.id]);
|
|
495
|
+
})
|
|
496
|
+
.join('');
|
|
497
|
+
// 포커스 화면이 띠 이름을 다시 달 때, +N 드롭다운이 제품 이름을 띄울 때 읽는다
|
|
498
|
+
const regionNames = layout.regions
|
|
499
|
+
? ` data-regions="${escapeHtml(JSON.stringify(layout.regions.groups.map((r) => r.name)))}"`
|
|
500
|
+
: '';
|
|
501
|
+
const strip = spec.viewStrip?.length ? renderViewStrip(spec.viewStrip) : undefined;
|
|
502
|
+
// 카드 줄은 섹션 위 여백에 있으므로 화면 맞추기가 그 높이와 폭까지 넣어 재게 크기를 늘려 적는다
|
|
503
|
+
const size = strip
|
|
504
|
+
? `data-w="${Math.max(width, strip.width)}" data-h="${height + STRIP_H}"${regionNames} style="width:${width}px;height:${height}px;margin-top:${STRIP_H}px"`
|
|
505
|
+
: `data-w="${width}" data-h="${height}"${regionNames} style="width:${width}px;height:${height}px"`;
|
|
506
|
+
return (`${open} ${size}${hidden}>` +
|
|
507
|
+
`<svg class="links" id="${spec.svgId}" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
|
|
508
|
+
`viewBox="0 0 ${width} ${height}" role="group" aria-label="${escapeHtml(`${spec.title} 연결`)}">` +
|
|
509
|
+
`<g class="lanes">${laneRects}</g><g class="regions">${regionRects}</g>` +
|
|
510
|
+
`${frameRects !== '' ? `<g class="frames">${frameRects}</g>` : ''}<g class="edges">${links}</g></svg>` +
|
|
511
|
+
`${laneTitles}${regionTitles}${cards}${strip?.html ?? ''}</section>`);
|
|
512
|
+
}
|
|
513
|
+
const FLOW_PAD = 24;
|
|
514
|
+
function pathD(points) {
|
|
515
|
+
return points
|
|
516
|
+
.map((p, i) => `${i === 0 ? 'M' : 'L'}${round2(p.x + FLOW_PAD)} ${round2(p.y + FLOW_PAD)}`)
|
|
517
|
+
.join(' ');
|
|
518
|
+
}
|
|
519
|
+
const BACK_CORNER = 12;
|
|
520
|
+
/** 되돌아가는 선은 모서리를 둥글린다. 앞으로 가는 직각 선들 사이에서 뒤로 가는 선이 모양만으로 갈린다 */
|
|
521
|
+
function roundedPathD(points) {
|
|
522
|
+
const at = (p) => `${round2(p.x + FLOW_PAD)} ${round2(p.y + FLOW_PAD)}`;
|
|
523
|
+
let d = `M${at(points[0])}`;
|
|
524
|
+
for (let i = 1; i < points.length - 1; i += 1) {
|
|
525
|
+
const p = points[i - 1];
|
|
526
|
+
const c = points[i];
|
|
527
|
+
const n = points[i + 1];
|
|
528
|
+
const r = Math.min(BACK_CORNER, Math.hypot(c.x - p.x, c.y - p.y) / 2, Math.hypot(n.x - c.x, n.y - c.y) / 2);
|
|
529
|
+
const toward = (q) => {
|
|
530
|
+
const len = Math.hypot(q.x - c.x, q.y - c.y) || 1;
|
|
531
|
+
return { x: c.x + ((q.x - c.x) / len) * r, y: c.y + ((q.y - c.y) / len) * r };
|
|
532
|
+
};
|
|
533
|
+
d += ` L${at(toward(p))} Q${at(c)} ${at(toward(n))}`;
|
|
534
|
+
}
|
|
535
|
+
return `${d} L${at(points[points.length - 1])}`;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* 흐름 레벨 하나. 행위자마다 가로줄을 깔고 단계 카드를 왼쪽에서 오른쪽으로 놓는다.
|
|
539
|
+
* 카드는 노드 카드와 같은 .node라 강조와 검색, 질문 이동을 그대로 탄다. data-node-id 자리에 단계 id가 들어간다
|
|
540
|
+
*/
|
|
541
|
+
function renderFlowSection(level, flow, questionCount, drawableNodes) {
|
|
542
|
+
const { layout } = level;
|
|
543
|
+
const width = round2(layout.width + FLOW_PAD * 2);
|
|
544
|
+
const height = round2(layout.height + FLOW_PAD * 2);
|
|
545
|
+
const stepById = new Map(flow.steps.map((st) => [st.id, st]));
|
|
546
|
+
const actorById = new Map(flow.actors.map((a) => [a.id, a]));
|
|
547
|
+
const stateText = (state) => flow.stateLabels?.[state] ?? state;
|
|
548
|
+
// 이름이 붙은 상태는 코드 값이 아니라서 고정폭 글꼴을 안 쓴다
|
|
549
|
+
const named = (state) => flow.stateLabels?.[state] !== undefined ? ' named' : '';
|
|
550
|
+
const lanes = layout.lanes
|
|
551
|
+
.map((l, i) => `<rect class="flane${i % 2 === 1 ? ' alt' : ''}" x="${FLOW_PAD}" y="${round2(l.y + FLOW_PAD)}" width="${layout.width}" height="${l.height}" rx="12"/>`)
|
|
552
|
+
.join('');
|
|
553
|
+
// 구간 경계는 행위자 줄 위에 세로 점선으로 긋는다. 첫 구간 왼쪽은 머리 칸과 맞닿아 따로 안 긋는다
|
|
554
|
+
const laneTop = layout.lanes[0]?.y ?? 0;
|
|
555
|
+
const laneBottom = layout.lanes.reduce((n, l) => Math.max(n, l.y + l.height), 0);
|
|
556
|
+
const dividers = layout.stages
|
|
557
|
+
.slice(1)
|
|
558
|
+
.map((st) => `<line class="fstage-line${st.side ? ' side' : ''}" x1="${round2(st.x + FLOW_PAD)}" x2="${round2(st.x + FLOW_PAD)}" ` +
|
|
559
|
+
`y1="${round2(laneTop + FLOW_PAD)}" y2="${round2(laneBottom + FLOW_PAD)}"/>`)
|
|
560
|
+
.join('');
|
|
561
|
+
const stageHeads = layout.stages
|
|
562
|
+
.map((st) => `<div class="fstage${st.side ? ' side' : named(st.label)}" style="left:${round2(st.x + FLOW_PAD + 8)}px;top:${FLOW_PAD}px;width:${round2(st.width - 16)}px" ` +
|
|
563
|
+
`title="${escapeHtml(st.side ? st.label : stateText(st.label))}"><span>${escapeHtml(st.side ? st.label : stateText(st.label))}</span></div>`)
|
|
564
|
+
.join('');
|
|
565
|
+
const heads = layout.lanes
|
|
566
|
+
.map((l) => `<div class="flane-title a-${l.actor.kind}" style="left:${FLOW_PAD + 12}px;top:${round2(l.y + FLOW_PAD + 12)}px">` +
|
|
567
|
+
`${iconUse(l.actor.kind === 'person' ? 'u-person' : l.actor.kind === 'agent' ? 'i-agent' : 'u-system')}<span>${escapeHtml(l.actor.label)}</span></div>`)
|
|
568
|
+
.join('');
|
|
569
|
+
const labelOf = (id) => stepById.get(id)?.label ?? id;
|
|
570
|
+
const links = layout.transitions
|
|
571
|
+
.map((r) => {
|
|
572
|
+
const t = flow.transitions.find((x) => x.id === r.id);
|
|
573
|
+
const dash = t.lineStyle === 'dashed' ? ` stroke-dasharray="${DASHED_PATTERN}"` : '';
|
|
574
|
+
const d = r.back ? roundedPathD(r.points) : pathD(r.points);
|
|
575
|
+
const tip = r.tip.map((p) => `${round2(p.x + FLOW_PAD)},${round2(p.y + FLOW_PAD)}`).join(' ');
|
|
576
|
+
const actorNames = (t.actors ?? []).map((id) => actorById.get(id)?.label ?? id);
|
|
577
|
+
const who = actorNames.join(', ');
|
|
578
|
+
// 선 위에는 괄호 앞 이름만 싣는다. "고객 (앱, QR, 알림톡 웹)"을 다 쓰면 선 글자가 카드 폭을 넘는다
|
|
579
|
+
const whoShort = actorNames.map((n) => n.replace(/\s*\([^)]*\)\s*$/, '')).join(', ');
|
|
580
|
+
const name = `${FLOW_TEXT[r.path]}${r.back ? `, ${FLOW_TEXT.back}` : ''}: ${labelOf(r.from)} → ${labelOf(r.to)}` +
|
|
581
|
+
(t.label !== undefined ? ` (${t.label})` : '') +
|
|
582
|
+
(who !== '' ? `, 누가 ${who}` : '');
|
|
583
|
+
const text = r.labelAt
|
|
584
|
+
? `<text class="t-label" x="${round2(r.labelAt.x + FLOW_PAD)}" y="${round2(r.labelAt.y + FLOW_PAD - 5)}">` +
|
|
585
|
+
(r.back ? '<tspan class="t-back">↩ </tspan>' : '') +
|
|
586
|
+
(t.label !== undefined ? escapeHtml(t.label) : '') +
|
|
587
|
+
(whoShort !== ''
|
|
588
|
+
? `<tspan class="t-who">${t.label !== undefined ? ' · ' : ''}${escapeHtml(whoShort)}</tspan>`
|
|
589
|
+
: '') +
|
|
590
|
+
`</text>`
|
|
591
|
+
: '';
|
|
592
|
+
return (`<g class="link flow-t p-${r.path}${r.back ? ' back' : ''}" data-from="${escapeHtml(r.from)}" data-to="${escapeHtml(r.to)}" ` +
|
|
593
|
+
`data-transition-id="${escapeHtml(r.id)}" tabindex="0" role="button" aria-label="${escapeHtml(name)}">` +
|
|
594
|
+
`<title>${escapeHtml(name)}</title><path class="hit" d="${d}" stroke-width="12"/>` +
|
|
595
|
+
`<path class="edge" d="${d}" stroke-width="2"${dash}/><polygon class="tip" points="${tip}"/>${text}</g>`);
|
|
596
|
+
})
|
|
597
|
+
.join('');
|
|
598
|
+
const cards = layout.steps
|
|
599
|
+
.map((b) => {
|
|
600
|
+
const st = stepById.get(b.id);
|
|
601
|
+
const actor = actorById.get(st.actor);
|
|
602
|
+
const refs = (st.refs ?? []).filter((id) => drawableNodes.has(id));
|
|
603
|
+
const qs = questionCount.get(st.id) ?? 0;
|
|
604
|
+
const dashed = st.evidence.every((ev) => ev.type !== 'code' && ev.type !== 'spec');
|
|
605
|
+
const cls = ['node', 'flow-step', `p-${b.path}`];
|
|
606
|
+
if (dashed)
|
|
607
|
+
cls.push('doc-only');
|
|
608
|
+
const aria = `${st.label}, ${FLOW_TEXT.step}, ${actor?.label ?? st.actor}` +
|
|
609
|
+
(st.state !== undefined ? `, 상태 ${stateText(st.state)}` : '') +
|
|
610
|
+
(st.terminal ? `, ${FLOW_TEXT.end}` : '') +
|
|
611
|
+
(dashed ? ', 문서로만 확인' : '');
|
|
612
|
+
return (`<div class="${cls.join(' ')}" data-node-id="${escapeHtml(st.id)}" role="button" tabindex="0" ` +
|
|
613
|
+
`aria-label="${escapeHtml(aria)}" title="${escapeHtml(st.description ?? st.label)}" ` +
|
|
614
|
+
`style="left:${round2(b.x + FLOW_PAD)}px;top:${round2(b.y + FLOW_PAD)}px;width:${b.width}px;height:${b.height}px">` +
|
|
615
|
+
`<span class="nm"><span class="t">${escapeHtml(st.label)}</span></span>` +
|
|
616
|
+
`<span class="l2">` +
|
|
617
|
+
(st.state !== undefined
|
|
618
|
+
? `<span class="st${named(st.state)}" title="${escapeHtml(st.state)}">${escapeHtml(stateText(st.state))}</span>`
|
|
619
|
+
: '') +
|
|
620
|
+
(st.terminal ? `<span class="end" title="${FLOW_TEXT.end}">끝</span>` : '') +
|
|
621
|
+
(refs.length > 0
|
|
622
|
+
? `<span class="refs" title="이어진 화면과 API">${iconUse('u-link')}${refs.length}</span>`
|
|
623
|
+
: '') +
|
|
624
|
+
(qs > 0
|
|
625
|
+
? `<span class="qb" title="확인할 질문">${iconUse('u-question')}${qs}</span>`
|
|
626
|
+
: '') +
|
|
627
|
+
`</span></div>`);
|
|
628
|
+
})
|
|
629
|
+
.join('');
|
|
630
|
+
return (`<section class="level flow-level" data-level-id="${escapeHtml(level.id)}" data-flow-id="${escapeHtml(level.flowId)}" ` +
|
|
631
|
+
`aria-label="${escapeHtml(flow.title)}" data-w="${width}" data-h="${height}" style="width:${width}px;height:${height}px" hidden>` +
|
|
632
|
+
`<svg class="links" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" ` +
|
|
633
|
+
`role="group" aria-label="${escapeHtml(`${flow.title} 전이`)}"><g class="lanes">${lanes}${dividers}</g><g class="edges">${links}</g></svg>` +
|
|
634
|
+
`${stageHeads}${heads}${cards}</section>`);
|
|
635
|
+
}
|
|
636
|
+
/** 투영 레벨 id 앞머리. 흐름 레벨의 flow:처럼 지도 레벨 id와 안 겹치게 한다 */
|
|
637
|
+
const VIEW_LEVEL_PREFIX = 'view:';
|
|
638
|
+
/** 서랍이 메시지를 누를 때 쓰는 데이터. 그린 메시지만 싣고 걸린 질문 문장을 붙인다 */
|
|
639
|
+
function messagePayload(views, drawn, questions, edges) {
|
|
640
|
+
const edgeById = new Map(edges.map((e) => [e.id, e]));
|
|
641
|
+
const out = {};
|
|
642
|
+
for (const { projection } of views) {
|
|
643
|
+
const blockLabel = new Map((projection.blocks ?? []).map((b) => [b.id, `${SEQUENCE_BLOCK_TEXT[b.kind]} [${b.label}]`]));
|
|
644
|
+
for (const m of projection.messages) {
|
|
645
|
+
if (!drawn.has(m.id))
|
|
646
|
+
continue;
|
|
647
|
+
const asked = questions.filter((q) => q.subject.messageId === m.id).map((q) => q.question);
|
|
648
|
+
out[m.id] = {
|
|
649
|
+
id: m.id,
|
|
650
|
+
view: projection.title,
|
|
651
|
+
from: m.from,
|
|
652
|
+
to: m.to,
|
|
653
|
+
label: m.label,
|
|
654
|
+
lineStyle: m.lineStyle,
|
|
655
|
+
evidence: [
|
|
656
|
+
...m.evidence,
|
|
657
|
+
...(m.edge !== undefined ? (edgeById.get(m.edge)?.evidence ?? []) : []),
|
|
658
|
+
],
|
|
659
|
+
...(projection.shape === 'dataflow' ? { shape: 'dataflow' } : {}),
|
|
660
|
+
...(m.edge !== undefined ? { edge: m.edge } : {}),
|
|
661
|
+
...(m.reply ? { reply: true } : {}),
|
|
662
|
+
...(m.block !== undefined ? { block: blockLabel.get(m.block) } : {}),
|
|
663
|
+
...(m.branch !== undefined ? { branch: m.branch } : {}),
|
|
664
|
+
...(asked.length > 0 ? { questions: asked } : {}),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
const SEQUENCE_BLOCK_TEXT = {
|
|
671
|
+
alt: '분기',
|
|
672
|
+
opt: '조건',
|
|
673
|
+
loop: '반복',
|
|
674
|
+
par: '동시',
|
|
675
|
+
};
|
|
676
|
+
const ARROW = 8;
|
|
677
|
+
/** 투영 그림의 카드. 지도 카드를 그대로 써서 누르면 같은 서랍이 열린다 */
|
|
678
|
+
function viewCards(boxes, nodeById, services, whereOf = () => undefined) {
|
|
679
|
+
return boxes
|
|
680
|
+
.map((b) => renderCard(nodeById.get(b.id), {
|
|
681
|
+
id: b.id,
|
|
682
|
+
x: b.x,
|
|
683
|
+
y: b.y,
|
|
684
|
+
width: b.width,
|
|
685
|
+
height: b.height,
|
|
686
|
+
}, false, false, services[b.id], undefined, { host: false }, 0, [], [], undefined, whereOf(b.id)))
|
|
687
|
+
.join('');
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* 순서도 머리 카드 둘째 줄에 적을 자리. 레포가 여럿인 그림에서만 쓴다.
|
|
691
|
+
* MCP 도구는 레포보다 어느 서버의 도구인지가 궁금한 자리라 서버 이름을 적는다
|
|
692
|
+
*/
|
|
693
|
+
function whereFn(ir) {
|
|
694
|
+
if (ir.repos.length < 2)
|
|
695
|
+
return () => undefined;
|
|
696
|
+
const repoName = new Map(ir.repos.map((r) => [r.id, r.name]));
|
|
697
|
+
const nodes = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
698
|
+
return (id) => {
|
|
699
|
+
const n = nodes.get(id);
|
|
700
|
+
if (!n)
|
|
701
|
+
return undefined;
|
|
702
|
+
if (n.kind === 'endpoint' && n.mcpServer !== undefined)
|
|
703
|
+
return `${n.mcpServer} MCP`;
|
|
704
|
+
return repoName.get(n.repo);
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const VIEW_SVG_LABEL = {
|
|
708
|
+
sequence: '메시지',
|
|
709
|
+
dataflow: '데이터 이동',
|
|
710
|
+
compare: '견주기',
|
|
711
|
+
};
|
|
712
|
+
/** 투영 레벨 하나. 맨 위에 답하는 질문을 적고 그 아래 모양별 선과 카드를 얹는다 */
|
|
713
|
+
function viewSection(levelId, projection, width, height, under, links, cards) {
|
|
714
|
+
return (`<section class="level view-level" data-level-id="${escapeHtml(levelId)}" aria-label="${escapeHtml(projection.title)}" ` +
|
|
715
|
+
`data-w="${width}" data-h="${height}" style="width:${width}px;height:${height}px" hidden>` +
|
|
716
|
+
`<p class="view-q" style="left:${CANVAS_PAD_X}px">${escapeHtml(projection.question)}</p>` +
|
|
717
|
+
`<svg class="links" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" ` +
|
|
718
|
+
`role="group" aria-label="${escapeHtml(`${projection.title} ${VIEW_SVG_LABEL[projection.shape]}`)}"><g class="lanes">${under}</g><g class="edges">${links}</g></svg>` +
|
|
719
|
+
`${cards}</section>`);
|
|
720
|
+
}
|
|
721
|
+
/** 질문 하나에 답하는 sequence 그림. 머리 카드는 지도 카드를 그대로 써서 누르면 같은 서랍이 열린다 */
|
|
722
|
+
function renderSequenceSection(levelId, projection, layout, nodeById, services, whereOf) {
|
|
723
|
+
const px = (x) => round2(x + CANVAS_PAD_X);
|
|
724
|
+
const py = (y) => round2(y + CANVAS_PAD_TOP);
|
|
725
|
+
const width = round2(layout.width + CANVAS_PAD_X * 2);
|
|
726
|
+
const height = round2(layout.height + CANVAS_PAD_TOP);
|
|
727
|
+
const messageById = new Map(projection.messages.map((m) => [m.id, m]));
|
|
728
|
+
const nameOf = (id) => {
|
|
729
|
+
const n = nodeById.get(id);
|
|
730
|
+
return n ? nodeName(n) : id;
|
|
731
|
+
};
|
|
732
|
+
const lifelines = layout.lifelines
|
|
733
|
+
.map((l) => `<line class="lifeline" x1="${px(l.x)}" x2="${px(l.x)}" y1="${py(l.y1)}" y2="${py(l.y2)}"/>`)
|
|
734
|
+
.join('');
|
|
735
|
+
const blocks = layout.blocks
|
|
736
|
+
.map((b) => {
|
|
737
|
+
const tag = SEQUENCE_BLOCK_TEXT[b.kind];
|
|
738
|
+
const tagW = textUnits(tag) * 7 + 16;
|
|
739
|
+
const branches = b.branches
|
|
740
|
+
.map((br) => `<line class="b-branch" x1="${px(b.x)}" x2="${px(b.x + b.width)}" y1="${py(br.y)}" y2="${py(br.y)}"/>` +
|
|
741
|
+
(br.label !== ''
|
|
742
|
+
? `<text class="b-label" x="${px(b.x + 10)}" y="${py(br.y + 16)}">[${escapeHtml(br.label)}]</text>`
|
|
743
|
+
: ''))
|
|
744
|
+
.join('');
|
|
745
|
+
return (`<g class="seq-block b-${b.kind}"><rect x="${px(b.x)}" y="${py(b.y)}" width="${b.width}" height="${b.height}" rx="6"/>` +
|
|
746
|
+
`<path class="b-tab" d="M${px(b.x)} ${py(b.y + 20)}H${px(b.x + tagW)}L${px(b.x + tagW + 8)} ${py(b.y + 12)}V${py(b.y)}"/>` +
|
|
747
|
+
`<text class="b-kind" x="${px(b.x + 8)}" y="${py(b.y + 14)}">${tag}</text>` +
|
|
748
|
+
`<text class="b-label" x="${px(b.x + tagW + 16)}" y="${py(b.y + 14)}">[${escapeHtml(b.label)}]</text>` +
|
|
749
|
+
`${branches}</g>`);
|
|
750
|
+
})
|
|
751
|
+
.join('');
|
|
752
|
+
const links = layout.messages
|
|
753
|
+
.map((box) => {
|
|
754
|
+
const m = messageById.get(box.id);
|
|
755
|
+
const dash = m.lineStyle === 'dashed' ? ` stroke-dasharray="${DASHED_PATTERN}"` : '';
|
|
756
|
+
const y = py(box.y);
|
|
757
|
+
let d;
|
|
758
|
+
let tip;
|
|
759
|
+
let labelX;
|
|
760
|
+
let anchor = 'middle';
|
|
761
|
+
if (box.self) {
|
|
762
|
+
const x = px(box.x1);
|
|
763
|
+
const yb = round2(y + 10);
|
|
764
|
+
d = `M${x} ${round2(y - 10)}H${round2(x + 36)}V${yb}H${x}`;
|
|
765
|
+
tip = [
|
|
766
|
+
`${x},${yb}`,
|
|
767
|
+
`${round2(x + ARROW)},${round2(yb - ARROW / 2)}`,
|
|
768
|
+
`${round2(x + ARROW)},${round2(yb + ARROW / 2)}`,
|
|
769
|
+
];
|
|
770
|
+
labelX = round2(x + 44);
|
|
771
|
+
anchor = 'start';
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
const x1 = px(box.x1);
|
|
775
|
+
const x2 = px(box.x2);
|
|
776
|
+
const back = round2(x2 - (x2 > x1 ? 1 : -1) * ARROW);
|
|
777
|
+
d = `M${x1} ${y}H${x2}`;
|
|
778
|
+
tip = [
|
|
779
|
+
`${x2},${y}`,
|
|
780
|
+
`${back},${round2(y - ARROW / 2)}`,
|
|
781
|
+
`${back},${round2(y + ARROW / 2)}`,
|
|
782
|
+
];
|
|
783
|
+
labelX = round2((x1 + x2) / 2);
|
|
784
|
+
}
|
|
785
|
+
const name = `${box.n}. ${nameOf(m.from)} → ${nameOf(m.to)}: ${m.label}` +
|
|
786
|
+
(m.reply ? ', 응답' : '') +
|
|
787
|
+
(m.lineStyle === 'dashed' ? ', 문서로만 확인' : '');
|
|
788
|
+
// 응답은 열린 화살촉으로 그린다. 요청과 응답이 같은 두 줄 사이를 오갈 때 방향만으로는 구분이 안 된다
|
|
789
|
+
const head = m.reply
|
|
790
|
+
? `<polyline class="tip open" points="${tip[1]} ${tip[0]} ${tip[2]}"/>`
|
|
791
|
+
: `<polygon class="tip" points="${tip.join(' ')}"/>`;
|
|
792
|
+
return (`<g class="link seq-m${m.reply ? ' reply' : ''}" data-from="${escapeHtml(m.from)}" data-to="${escapeHtml(m.to)}" ` +
|
|
793
|
+
`data-message-id="${escapeHtml(m.id)}" tabindex="0" role="button" aria-label="${escapeHtml(name)}">` +
|
|
794
|
+
`<title>${escapeHtml(name)}</title><path class="hit" d="${d}" stroke-width="12"/>` +
|
|
795
|
+
`<path class="edge" d="${d}" stroke-width="1.6"${dash}/>${head}` +
|
|
796
|
+
`<text class="m-label" x="${labelX}" y="${round2(y - 7)}" text-anchor="${anchor}"><tspan class="m-n">${box.n}.</tspan> ${escapeHtml(m.label)}</text></g>`);
|
|
797
|
+
})
|
|
798
|
+
.join('');
|
|
799
|
+
return viewSection(levelId, projection, width, height, `${lifelines}${blocks}`, links, stickyHeadBar(layout.heads, width) + viewCards(layout.heads, nodeById, services, whereOf));
|
|
800
|
+
}
|
|
801
|
+
const STICKY_PAD = 10;
|
|
802
|
+
/** 머리 카드 뒤에 까는 띠. 아래로 내려가 머리 카드가 화면 위에 붙을 때 그 밑을 지나는 선을 가린다 */
|
|
803
|
+
function stickyHeadBar(heads, width) {
|
|
804
|
+
const h = heads.reduce((m, b) => Math.max(m, b.height), 0);
|
|
805
|
+
return `<div class="seq-sticky" style="top:${CANVAS_PAD_TOP - STICKY_PAD}px;width:${width}px;height:${h + STICKY_PAD * 2}px"></div>`;
|
|
806
|
+
}
|
|
807
|
+
/** 데이터가 어디서 어디로 옮겨 가는지. 선 라벨이 옮겨 가는 데이터 이름이다 */
|
|
808
|
+
function renderDataflowSection(levelId, projection, layout, nodeById, services) {
|
|
809
|
+
const px = (x) => round2(x + CANVAS_PAD_X);
|
|
810
|
+
const py = (y) => round2(y + CANVAS_PAD_TOP);
|
|
811
|
+
const shift = (d) => d.replace(/(-?[\d.]+) (-?[\d.]+)/g, (_, x, y) => `${px(Number(x))} ${py(Number(y))}`);
|
|
812
|
+
const shiftPt = (p) => {
|
|
813
|
+
const [x, y] = p.split(',');
|
|
814
|
+
return `${px(Number(x))},${py(Number(y))}`;
|
|
815
|
+
};
|
|
816
|
+
const width = round2(layout.width + CANVAS_PAD_X * 2);
|
|
817
|
+
const height = round2(layout.height + CANVAS_PAD_TOP);
|
|
818
|
+
const messageById = new Map(projection.messages.map((m) => [m.id, m]));
|
|
819
|
+
const nameOf = (id) => {
|
|
820
|
+
const n = nodeById.get(id);
|
|
821
|
+
return n ? nodeName(n) : id;
|
|
822
|
+
};
|
|
823
|
+
const links = layout.edges
|
|
824
|
+
.map((e) => {
|
|
825
|
+
const m = messageById.get(e.id);
|
|
826
|
+
const dash = m.lineStyle === 'dashed' ? ` stroke-dasharray="${DASHED_PATTERN}"` : '';
|
|
827
|
+
const d = shift(e.d);
|
|
828
|
+
const name = `${nameOf(m.from)} → ${nameOf(m.to)}: ${m.label}` +
|
|
829
|
+
(m.lineStyle === 'dashed' ? ', 문서로만 확인' : '');
|
|
830
|
+
return (`<g class="link seq-m df-m" data-from="${escapeHtml(m.from)}" data-to="${escapeHtml(m.to)}" ` +
|
|
831
|
+
`data-message-id="${escapeHtml(m.id)}" tabindex="0" role="button" aria-label="${escapeHtml(name)}">` +
|
|
832
|
+
`<title>${escapeHtml(name)}</title><path class="hit" d="${d}" stroke-width="12"/>` +
|
|
833
|
+
`<path class="edge" d="${d}" stroke-width="1.6"${dash}/><polygon class="tip" points="${e.tip.map(shiftPt).join(' ')}"/>` +
|
|
834
|
+
`<text class="m-label" x="${px(e.labelX)}" y="${py(e.labelY)}" text-anchor="middle">${escapeHtml(m.label)}</text></g>`);
|
|
835
|
+
})
|
|
836
|
+
.join('');
|
|
837
|
+
return viewSection(levelId, projection, width, height, '', links, viewCards(layout.cards, nodeById, services));
|
|
838
|
+
}
|
|
839
|
+
/** 두 묶음 견주기. 왼쪽에만, 둘 다, 오른쪽에만 세 열로 세운다 */
|
|
840
|
+
function renderCompareSection(levelId, projection, layout, nodeById, services) {
|
|
841
|
+
const px = (x) => round2(x + CANVAS_PAD_X);
|
|
842
|
+
const py = (y) => round2(y + CANVAS_PAD_TOP);
|
|
843
|
+
const width = round2(layout.width + CANVAS_PAD_X * 2);
|
|
844
|
+
const height = round2(layout.height + CANVAS_PAD_TOP);
|
|
845
|
+
const columns = layout.columns
|
|
846
|
+
.map((c) => `<g class="cmp-col cmp-${c.key}"><rect x="${px(c.x)}" y="${py(0)}" width="${c.width}" height="${c.height}" rx="10"/>` +
|
|
847
|
+
`<text class="cmp-head" x="${px(c.x + 16)}" y="${py(25)}">${escapeHtml(c.title)}<tspan class="cmp-n" dx="8">${c.count}</tspan></text>` +
|
|
848
|
+
(c.count === 0
|
|
849
|
+
? `<text class="cmp-empty" x="${px(c.x + c.width / 2)}" y="${py(66)}" text-anchor="middle">없음</text>`
|
|
850
|
+
: '') +
|
|
851
|
+
'</g>')
|
|
852
|
+
.join('');
|
|
853
|
+
return viewSection(levelId, projection, width, height, columns, '', viewCards(layout.cards, nodeById, services));
|
|
854
|
+
}
|
|
855
|
+
/** 흐름 단계와 전이마다 열린 질문 수. 카드 배지에 쓴다. 전이 질문은 출발 단계에 센다 */
|
|
856
|
+
function flowQuestionCount(questions, flows) {
|
|
857
|
+
const fromOf = new Map(flows.flatMap((f) => f.transitions.map((t) => [t.id, t.from])));
|
|
858
|
+
const out = new Map();
|
|
859
|
+
for (const q of questions) {
|
|
860
|
+
const at = q.subject.stepId ??
|
|
861
|
+
(q.subject.transitionId !== undefined ? fromOf.get(q.subject.transitionId) : undefined);
|
|
862
|
+
if (at !== undefined)
|
|
863
|
+
out.set(at, (out.get(at) ?? 0) + 1);
|
|
864
|
+
}
|
|
865
|
+
return out;
|
|
866
|
+
}
|
|
867
|
+
/** 클라이언트가 서랍과 흐름 버튼에 쓰는 흐름 데이터. 그린 단계와 전이만 싣는다 */
|
|
868
|
+
function flowPayload(levels, flows, questions) {
|
|
869
|
+
const byId = new Map(flows.map((f) => [f.id, f]));
|
|
870
|
+
// 패널에서 그 단계나 전이에 걸린 질문을 바로 보여주려고 문장만 붙인다. 배지 숫자만으로는 무엇을 물어야 하는지 모른다
|
|
871
|
+
const asked = new Map();
|
|
872
|
+
const ask = (key, text) => {
|
|
873
|
+
asked.set(key, [...(asked.get(key) ?? []), text]);
|
|
874
|
+
};
|
|
875
|
+
for (const q of questions) {
|
|
876
|
+
if (q.subject.stepId !== undefined)
|
|
877
|
+
ask(`s:${q.subject.stepId}`, q.question);
|
|
878
|
+
else if (q.subject.transitionId !== undefined)
|
|
879
|
+
ask(`t:${q.subject.transitionId}`, q.question);
|
|
880
|
+
}
|
|
881
|
+
const withQuestions = (prefix, item) => {
|
|
882
|
+
const list = asked.get(`${prefix}:${item.id}`);
|
|
883
|
+
return list ? { ...item, questions: list } : item;
|
|
884
|
+
};
|
|
885
|
+
return levels.map((l) => {
|
|
886
|
+
const f = byId.get(l.flowId);
|
|
887
|
+
const drawnSteps = new Set(l.layout.steps.map((b) => b.id));
|
|
888
|
+
const pathOf = new Map(l.layout.steps.map((b) => [b.id, b.path]));
|
|
889
|
+
const drawnTransitions = new Set(l.layout.transitions.map((r) => r.id));
|
|
890
|
+
return {
|
|
891
|
+
level: l.id,
|
|
892
|
+
id: f.id,
|
|
893
|
+
...(f.service !== undefined ? { service: f.service } : {}),
|
|
894
|
+
title: f.title,
|
|
895
|
+
...(f.description !== undefined ? { description: f.description } : {}),
|
|
896
|
+
...(f.stateLabels !== undefined ? { stateLabels: f.stateLabels } : {}),
|
|
897
|
+
actors: f.actors,
|
|
898
|
+
steps: f.steps
|
|
899
|
+
.filter((st) => drawnSteps.has(st.id))
|
|
900
|
+
.map((st) => withQuestions('s', { ...st, path: pathOf.get(st.id) })),
|
|
901
|
+
transitions: f.transitions
|
|
902
|
+
.filter((t) => drawnTransitions.has(t.id))
|
|
903
|
+
.map((t) => withQuestions('t', t)),
|
|
904
|
+
};
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
function renderLegend(nodes, edges, drill, generatedAt, nodeCount, edgeCount, hasFlows = false) {
|
|
908
|
+
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
909
|
+
const line = (extra) => `<svg width="36" height="12" aria-hidden="true"><line x1="3" y1="6" x2="33" y2="6" ${extra}/></svg>`;
|
|
910
|
+
const kinds = [...new Set(nodes.map(displayKindOf))]
|
|
911
|
+
.sort(compareStr)
|
|
912
|
+
.map((k) => `<li><span class="swatch k-${k}">${iconUse(`i-${k}`)}</span><span class="kc k-${k}">${escapeHtml(NODE_KIND_SHORT[k])}</span>${escapeHtml(NODE_KIND_TEXT[k])}</li>`)
|
|
913
|
+
.join('');
|
|
914
|
+
return (`<div id="legend" class="pop" role="dialog" aria-label="범례" tabindex="-1" hidden>` +
|
|
915
|
+
`<h3>선</h3><ul class="legend-lines">` +
|
|
916
|
+
`<li>${line('stroke-width="2"')}실선: 코드나 스펙으로 확인한 연결</li>` +
|
|
917
|
+
`<li>${line(`stroke-width="2" stroke-dasharray="${DASHED_PATTERN}"`)}점선: 문서나 사람 말, 실제 조회로만 확인한 연결</li>` +
|
|
918
|
+
(edges.some((e) => crossesAccount(nodeById, e))
|
|
919
|
+
? `<li>${line('stroke-width="2" class="x-account-line"')}색 선: 클라우드 계정을 넘는 연결</li>`
|
|
920
|
+
: '') +
|
|
921
|
+
(drill
|
|
922
|
+
? `<li>${line('stroke-width="6"')}굵은 선: 여러 연결을 묶은 선이에요. 올리면 묶인 수가 보여요</li>`
|
|
923
|
+
: '') +
|
|
924
|
+
(hasFlows
|
|
925
|
+
? `<li>${line('stroke-width="2" class="side-line"')}주황 선: 흐름에서 정상 흐름을 벗어나는 옆 흐름</li>` +
|
|
926
|
+
`<li><span class="swatch-step"></span>점선 테두리 카드: 기획 문서로만 확인한 흐름 단계</li>`
|
|
927
|
+
: '') +
|
|
928
|
+
`</ul>` +
|
|
929
|
+
(kinds ? `<h3>항목</h3><ul class="legend-kinds">${kinds}</ul>` : '') +
|
|
930
|
+
`<p class="foot">만든 시각 ${escapeHtml(generatedAt)}<br>항목 ${nodeCount}개, 연결 ${edgeCount}개</p></div>`);
|
|
931
|
+
}
|
|
932
|
+
function renderQuestions(questions, nodeById, allEdges, flows, drawnSteps, messages = []) {
|
|
933
|
+
const edgeById = new Map(allEdges.map((e) => [e.id, e]));
|
|
934
|
+
const messageById = new Map(messages.map((m) => [m.id, m]));
|
|
935
|
+
const stepById = new Map(flows.flatMap((f) => f.steps.map((st) => [st.id, st])));
|
|
936
|
+
const transitionById = new Map(flows.flatMap((f) => f.transitions.map((t) => [t.id, t])));
|
|
937
|
+
const stepName = (id) => stepById.get(id)?.label ?? id;
|
|
938
|
+
const nameOr = (id) => {
|
|
939
|
+
const node = nodeById.get(id);
|
|
940
|
+
return node ? nodeName(node) : id;
|
|
941
|
+
};
|
|
942
|
+
const items = questions
|
|
943
|
+
.map((q) => {
|
|
944
|
+
let subject;
|
|
945
|
+
let target;
|
|
946
|
+
const { nodeId, edgeId, stepId, transitionId, messageId } = q.subject;
|
|
947
|
+
// 전이 질문은 출발 단계로 데려간다. 선은 카드처럼 고를 자리가 없어서다
|
|
948
|
+
if (stepId !== undefined) {
|
|
949
|
+
subject = stepName(stepId);
|
|
950
|
+
if (drawnSteps.has(stepId))
|
|
951
|
+
target = stepId;
|
|
952
|
+
}
|
|
953
|
+
else if (transitionId !== undefined) {
|
|
954
|
+
const t = transitionById.get(transitionId);
|
|
955
|
+
if (t) {
|
|
956
|
+
subject = `${stepName(t.from)} → ${stepName(t.to)}`;
|
|
957
|
+
target = drawnSteps.has(t.from) ? t.from : drawnSteps.has(t.to) ? t.to : undefined;
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
subject = transitionId;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
else if (nodeId !== undefined) {
|
|
964
|
+
subject = nameOr(nodeId);
|
|
965
|
+
if (nodeById.has(nodeId))
|
|
966
|
+
target = nodeId;
|
|
967
|
+
}
|
|
968
|
+
else if (edgeId !== undefined) {
|
|
969
|
+
const edge = edgeById.get(edgeId);
|
|
970
|
+
if (edge) {
|
|
971
|
+
subject = `${nameOr(edge.from)} → ${nameOr(edge.to)}`;
|
|
972
|
+
target = nodeById.has(edge.from)
|
|
973
|
+
? edge.from
|
|
974
|
+
: nodeById.has(edge.to)
|
|
975
|
+
? edge.to
|
|
976
|
+
: undefined;
|
|
977
|
+
}
|
|
978
|
+
else {
|
|
979
|
+
subject = edgeId;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
else if (messageId !== undefined) {
|
|
983
|
+
const m = messageById.get(messageId);
|
|
984
|
+
subject = m ? `${nameOr(m.from)} → ${nameOr(m.to)}: ${m.label}` : messageId;
|
|
985
|
+
}
|
|
986
|
+
const attr = target !== undefined ? ` data-node-id="${escapeHtml(target)}"` : ' disabled';
|
|
987
|
+
return (`<li><button type="button" class="q"${attr}>` +
|
|
988
|
+
(subject !== undefined ? `<span class="q-subj">${escapeHtml(subject)}</span>` : '') +
|
|
989
|
+
`<span class="q-text">${escapeHtml(q.question)}</span></button></li>`);
|
|
990
|
+
})
|
|
991
|
+
.join('');
|
|
992
|
+
const body = questions.length > 0
|
|
993
|
+
? `<ul class="q-list">${items}</ul>`
|
|
994
|
+
: '<p class="empty">지금은 확인할 질문이 없어요.</p>';
|
|
995
|
+
return (`<div id="questions" class="pop" role="dialog" aria-label="확인할 질문" tabindex="-1" hidden>` +
|
|
996
|
+
`<h3>확인할 질문 ${questions.length}개</h3>${body}</div>`);
|
|
997
|
+
}
|
|
998
|
+
/** 그림에 나오는 환경. 둘 이상일 때만 고르는 버튼을 단다 */
|
|
999
|
+
function environmentsOf(nodes) {
|
|
1000
|
+
const envs = new Set();
|
|
1001
|
+
for (const n of nodes)
|
|
1002
|
+
if (n.environment !== undefined)
|
|
1003
|
+
envs.add(n.environment);
|
|
1004
|
+
return [...envs].sort((a, b) => compareEnvironment(a, b));
|
|
1005
|
+
}
|
|
1006
|
+
// 처음엔 prod만 켠다. 장애나 배포 경로를 볼 때 사람이 먼저 찾는 게 prod라서다. prod가 없으면 맨 앞 환경을 켠다
|
|
1007
|
+
function renderEnvPicker(envs) {
|
|
1008
|
+
if (envs.length < 2)
|
|
1009
|
+
return '';
|
|
1010
|
+
const on = envs.includes('prod') ? 'prod' : envs[0];
|
|
1011
|
+
const buttons = envs
|
|
1012
|
+
.map((e) => `<button type="button" class="btn" data-env="${escapeHtml(e)}" aria-pressed="${e === on}">${escapeHtml(e)}</button>`)
|
|
1013
|
+
.join('');
|
|
1014
|
+
return `<div id="env-picker" class="seg env-picker" role="group" aria-label="보일 환경" title="보일 환경 고르기">${buttons}</div>`;
|
|
1015
|
+
}
|
|
1016
|
+
function renderBar(title, drill, generatedAt, nodeCount, edgeCount, questionCount, envs, hasFlows, hasViews = false, docCoverage) {
|
|
1017
|
+
const qClass = questionCount > 0 ? 'n warn' : 'n';
|
|
1018
|
+
return (`<header class="bar">` +
|
|
1019
|
+
`<h1>${escapeHtml(title)}</h1>` +
|
|
1020
|
+
`<nav id="crumbs" class="crumbs" aria-label="위치"${drill ? '' : ' hidden'}><span class="here" aria-current="page">전체</span></nav>` +
|
|
1021
|
+
`<span id="focus-chip" class="focus-chip" hidden>${iconUse('u-focus')}<span>포커스:</span><b id="focus-name"></b>` +
|
|
1022
|
+
`<button type="button" id="focus-clear" aria-label="포커스 해제" title="포커스 해제 (Esc)">${iconUse('u-close')}</button></span>` +
|
|
1023
|
+
`<div class="spacer"></div>` +
|
|
1024
|
+
`<button type="button" class="btn" id="focus-btn" hidden title="고른 항목과 이어진 것만 보기 (F)">${iconUse('u-focus')}<span class="label">포커스</span></button>` +
|
|
1025
|
+
(hasFlows
|
|
1026
|
+
? `<button type="button" class="btn" id="flow-btn" hidden aria-haspopup="dialog" aria-expanded="false" aria-controls="flow-pop" ` +
|
|
1027
|
+
`title="이 서비스를 쓰는 사람 쪽 흐름 보기">${iconUse('u-flow')}<span class="label">흐름</span></button>`
|
|
1028
|
+
: '') +
|
|
1029
|
+
(hasViews
|
|
1030
|
+
? `<button type="button" class="btn" id="views-btn" aria-haspopup="dialog" aria-expanded="false" aria-controls="views" ` +
|
|
1031
|
+
`title="질문 하나에 답하는 그림 보기">${iconUse('u-flow')}<span class="label">질문별 그림</span></button>`
|
|
1032
|
+
: '') +
|
|
1033
|
+
renderEnvPicker(envs) +
|
|
1034
|
+
`<label class="search">${iconUse('u-search')}<input id="search" type="search" placeholder="이름으로 찾기" aria-label="이름으로 찾기" autocomplete="off" spellcheck="false"><span id="search-count" class="count" aria-live="polite"></span></label>` +
|
|
1035
|
+
`<div class="seg" role="group" aria-label="크기">` +
|
|
1036
|
+
`<button type="button" class="btn icon" id="zoom-out" aria-label="작게 보기" title="작게 보기 (-)">${iconUse('u-minus')}</button>` +
|
|
1037
|
+
`<span id="zoom-level" class="zoom-level" aria-live="polite">100%</span>` +
|
|
1038
|
+
`<button type="button" class="btn icon" id="zoom-in" aria-label="크게 보기" title="크게 보기 (+)">${iconUse('u-plus')}</button></div>` +
|
|
1039
|
+
`<button type="button" class="btn" id="zoom-fit" title="화면에 맞추기 (0)">${iconUse('u-fit')}<span class="label">맞춤</span></button>` +
|
|
1040
|
+
`<button type="button" class="btn" id="legend-btn" aria-haspopup="dialog" aria-expanded="false" aria-controls="legend">${iconUse('u-legend')}<span class="label">범례</span></button>` +
|
|
1041
|
+
`<button type="button" class="btn" id="q-btn" aria-haspopup="dialog" aria-expanded="false" aria-controls="questions">${iconUse('u-question')}<span class="label">확인할 질문</span><span class="${qClass}">${questionCount}</span></button>` +
|
|
1042
|
+
`<button type="button" class="btn icon" id="theme-btn" aria-label="테마 바꾸기">${iconUse('u-moon', 'moon')}${iconUse('u-sun', 'sun')}</button>` +
|
|
1043
|
+
`<span class="meta" title="만든 시각 ${escapeHtml(generatedAt)}"><span>항목 ${nodeCount}</span><span>연결 ${edgeCount}</span>` +
|
|
1044
|
+
(docCoverage !== undefined
|
|
1045
|
+
? `<span title="문서가 붙은 서비스, 앱, 모듈, 기능, 화면">문서 있음 ${docCoverage.covered}/${docCoverage.total}</span>`
|
|
1046
|
+
: '') +
|
|
1047
|
+
`</span>` +
|
|
1048
|
+
`</header>`);
|
|
1049
|
+
}
|
|
1050
|
+
const clientScripts = new Map();
|
|
1051
|
+
/** 칩 글자와 설명, 레인 제목은 IR이 쓰는 팩 것만 싣는다. 팩을 더해도 그 팩을 안 쓰는 그림의 바이트가 그대로다 */
|
|
1052
|
+
function clientScriptFor(vocab, rootFlows, views, mixedBundles) {
|
|
1053
|
+
const key = `${vocab.packIds.join(',')}|${rootFlows}|${views}|${mixedBundles}`;
|
|
1054
|
+
const hit = clientScripts.get(key);
|
|
1055
|
+
if (hit !== undefined)
|
|
1056
|
+
return hit;
|
|
1057
|
+
const pick = (table, keys) => Object.fromEntries([...keys].map((k) => [k, table[k]]));
|
|
1058
|
+
const script = renderClientScript({
|
|
1059
|
+
kindShort: pick(NODE_KIND_SHORT, Object.keys(vocab.looks)),
|
|
1060
|
+
hostShort: MICRO_HOST_SHORT,
|
|
1061
|
+
kindText: {
|
|
1062
|
+
...pick(NODE_KIND_TEXT, Object.keys(vocab.looks)),
|
|
1063
|
+
...pick(EDGE_KIND_TEXT, [...Object.keys(vocab.edgeKinds), 'contains']),
|
|
1064
|
+
},
|
|
1065
|
+
evidenceText: EVIDENCE_TYPE_TEXT,
|
|
1066
|
+
laneTitles: pick(LANE_TITLES, Object.keys(vocab.lanes)),
|
|
1067
|
+
inferredBadge: INFERRED_BADGE,
|
|
1068
|
+
platformChip: PLATFORM_CHIP_TEXT,
|
|
1069
|
+
platformName: PLATFORM_NAME,
|
|
1070
|
+
webHostingChip: WEB_HOSTING_CHIP_TEXT,
|
|
1071
|
+
webHostingName: WEB_HOSTING_NAME,
|
|
1072
|
+
backwardKinds: [...FLAT_BACKWARD_EDGE_KINDS]
|
|
1073
|
+
.filter((k) => k in vocab.edgeKinds)
|
|
1074
|
+
.sort(compareStr),
|
|
1075
|
+
components: 'component' in vocab.nodeKinds,
|
|
1076
|
+
rootFlows,
|
|
1077
|
+
views,
|
|
1078
|
+
mixedBundles,
|
|
1079
|
+
docs: vocab.packIds.includes('knowledge'),
|
|
1080
|
+
});
|
|
1081
|
+
clientScripts.set(key, script);
|
|
1082
|
+
return script;
|
|
1083
|
+
}
|
|
1084
|
+
/** 그림 단추가 여는 목록. 링크라서 주소 해시로 바로 그 레벨로 간다 */
|
|
1085
|
+
function renderViewsPop(views) {
|
|
1086
|
+
const items = views
|
|
1087
|
+
.map((v) => `<li><a href="#/level/${encodeURIComponent(v.level)}"><b>${escapeHtml(v.projection.title)}</b>` +
|
|
1088
|
+
`<span>${escapeHtml(v.projection.question)}</span></a></li>`)
|
|
1089
|
+
.join('');
|
|
1090
|
+
return `<div id="views" class="pop" role="dialog" aria-label="질문별 그림" tabindex="-1" hidden><h3>질문별 그림</h3><ul class="flow-list view-list">${items}</ul></div>`;
|
|
1091
|
+
}
|
|
1092
|
+
function renderPage({ ir, payload, sections, drill, flows, views, mixedBundles = false, }) {
|
|
1093
|
+
const title = viewTitle(ir);
|
|
1094
|
+
const vocab = irVocabulary(ir);
|
|
1095
|
+
const nodeById = new Map(payload.nodes.map((n) => [n.id, n]));
|
|
1096
|
+
const hasFlows = flows !== undefined && flows.flows.length > 0;
|
|
1097
|
+
const hasViews = views !== undefined && views.length > 0;
|
|
1098
|
+
return [
|
|
1099
|
+
'<!doctype html>',
|
|
1100
|
+
'<html lang="ko">',
|
|
1101
|
+
'<head>',
|
|
1102
|
+
'<meta charset="utf-8">',
|
|
1103
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
1104
|
+
`<title>${escapeHtml(title)}</title>`,
|
|
1105
|
+
`<script>${THEME_BOOT_SCRIPT}</script>`,
|
|
1106
|
+
`<style>${renderCss(vocab)}${hasViews ? `\n${VIEW_CSS}` : ''}${vocab.packIds.includes('knowledge') ? `\n${DOC_CSS}` : ''}</style>`,
|
|
1107
|
+
'</head>',
|
|
1108
|
+
'<body>',
|
|
1109
|
+
renderIconSprite(vocab),
|
|
1110
|
+
renderBar(title, drill, ir.generatedAt, payload.nodes.length, payload.edges.length, payload.unresolved.length, environmentsOf(payload.nodes), hasFlows, hasViews, docCoverageOf(payload.nodes, payload.docOverlay)),
|
|
1111
|
+
'<div class="main">',
|
|
1112
|
+
'<div id="stage" class="stage" aria-label="구조도">',
|
|
1113
|
+
'<div id="viewport" class="viewport">',
|
|
1114
|
+
sections,
|
|
1115
|
+
'<section id="pair" class="level pair" aria-live="polite" hidden></section>',
|
|
1116
|
+
'<section id="focus" class="level focus-view" aria-live="polite" hidden></section>',
|
|
1117
|
+
'</div>',
|
|
1118
|
+
'</div>',
|
|
1119
|
+
`<p id="hint" class="hint"><span>${drill ? HINT_DRILL : HINT_FLAT}</span>` +
|
|
1120
|
+
`<button type="button" id="hint-close" aria-label="안내 닫기">${iconUse('u-close')}</button></p>`,
|
|
1121
|
+
'<details id="sheet" class="sheet" hidden><summary id="sheet-title">세부 연결</summary><div id="sheet-body" class="scroll"></div></details>',
|
|
1122
|
+
'<aside id="drawer" class="drawer" role="dialog" aria-modal="false" aria-labelledby="drawer-title" aria-hidden="true" inert>' +
|
|
1123
|
+
`<button type="button" id="drawer-close" class="btn icon dr-close" aria-label="닫기">${iconUse('u-close')}</button>` +
|
|
1124
|
+
'<div id="panel" class="panel" aria-live="polite"></div></aside>',
|
|
1125
|
+
'</div>',
|
|
1126
|
+
renderLegend(payload.nodes, payload.edges, drill, ir.generatedAt, payload.nodes.length, payload.edges.length, hasFlows),
|
|
1127
|
+
renderQuestions(payload.unresolved, nodeById, ir.edges, flows?.flows ?? [], flows?.drawnSteps ?? new Set(), views?.flatMap((v) => v.projection.messages)),
|
|
1128
|
+
(hasFlows
|
|
1129
|
+
? '<div id="flow-pop" class="pop" role="dialog" aria-label="흐름 고르기" tabindex="-1" hidden><h3>흐름</h3><ul class="flow-list"></ul></div>'
|
|
1130
|
+
: '') + (hasViews ? `\n${renderViewsPop(views)}` : ''),
|
|
1131
|
+
`<script id="ir" type="application/json">${embedJson(stableStringify(payload))}</script>`,
|
|
1132
|
+
`<script>${clientScriptFor(vocab, hasFlows && flows.flows.some((f) => f.service === undefined), hasViews, mixedBundles)}</script>`,
|
|
1133
|
+
'</body>',
|
|
1134
|
+
'</html>',
|
|
1135
|
+
'',
|
|
1136
|
+
].join('\n');
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* 검증된 IR과 서버 레이아웃으로 외부 의존 없는 단일 HTML을 만든다.
|
|
1140
|
+
* 같은 입력이면 같은 바이트가 나온다. shared면 private 근거를 가린 사본으로 그린다.
|
|
1141
|
+
*/
|
|
1142
|
+
export function renderArchitectureHtml(validated, layout, opts) {
|
|
1143
|
+
const shared = opts.audience === 'shared';
|
|
1144
|
+
const ir = shared ? redactForSharing(validated.ir) : validated.ir;
|
|
1145
|
+
const payload = buildPayload(ir, validated, shared);
|
|
1146
|
+
const section = renderLevelSection({
|
|
1147
|
+
levelId: ROOT_LEVEL_ID,
|
|
1148
|
+
svgId: 'canvas',
|
|
1149
|
+
title: viewTitle(ir),
|
|
1150
|
+
nodes: payload.nodes,
|
|
1151
|
+
edges: payload.edges.map(asCanvasEdge),
|
|
1152
|
+
layout,
|
|
1153
|
+
enter: {},
|
|
1154
|
+
hidden: false,
|
|
1155
|
+
services: payload.services,
|
|
1156
|
+
microHosts: new Set(payload.microHosts ?? []),
|
|
1157
|
+
...docCoverSpec(payload.nodes, payload.docOverlay),
|
|
1158
|
+
});
|
|
1159
|
+
return renderPage({ ir, payload, sections: section, drill: false });
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* 드릴다운 HTML. 레벨마다 미리 그린 캔버스를 한 파일에 넣고 하나만 보이게 한다.
|
|
1163
|
+
* 같은 입력이면 같은 바이트가 나온다. shared면 private 근거를 가린 사본으로 그린다.
|
|
1164
|
+
*/
|
|
1165
|
+
export function renderDrilldownHtml(validated, drilldown, opts) {
|
|
1166
|
+
const shared = opts.audience === 'shared';
|
|
1167
|
+
const ir = shared ? redactForSharing(validated.ir) : validated.ir;
|
|
1168
|
+
const base = buildPayload(ir, validated, shared);
|
|
1169
|
+
const docCover = docCoverSpec(base.nodes, base.docOverlay);
|
|
1170
|
+
const nodeById = new Map(base.nodes.map((n) => [n.id, n]));
|
|
1171
|
+
// 레벨 제목은 원본 이름으로 지었으므로 공유본에서는 노드 이름과 같이 가린다
|
|
1172
|
+
const levels = drilldown.levels.map((l) => shared ? { ...l, title: maskSharedText(l.title) } : l);
|
|
1173
|
+
const payload = {
|
|
1174
|
+
...base,
|
|
1175
|
+
levels: levels.map((l) => ({
|
|
1176
|
+
id: l.id,
|
|
1177
|
+
kind: l.kind,
|
|
1178
|
+
...(l.focusId !== undefined ? { focusId: l.focusId } : {}),
|
|
1179
|
+
title: l.title,
|
|
1180
|
+
trail: l.trail,
|
|
1181
|
+
edges: l.edges,
|
|
1182
|
+
})),
|
|
1183
|
+
enter: drilldown.enter,
|
|
1184
|
+
};
|
|
1185
|
+
// 좌표는 원본으로 냈고 글자만 공유본에서 가린다. 단계 id는 가리지 않아 그대로 맞물린다
|
|
1186
|
+
const flowById = new Map((ir.flows ?? []).map((f) => [f.id, f]));
|
|
1187
|
+
const flowLevels = drilldown.flows.map((l) => ({ level: l, flow: flowById.get(l.flowId) }));
|
|
1188
|
+
const drawnFlows = flowLevels.map((x) => x.flow);
|
|
1189
|
+
const drawnSteps = new Set(drilldown.flows.flatMap((l) => l.layout.steps.map((b) => b.id)));
|
|
1190
|
+
const questionCount = flowQuestionCount(base.unresolved, drawnFlows);
|
|
1191
|
+
const drawableNodes = new Set(base.nodes.map((n) => n.id));
|
|
1192
|
+
const flowCount = flowCountByService(drilldown.flows);
|
|
1193
|
+
if (flowLevels.length > 0) {
|
|
1194
|
+
Object.assign(payload, {
|
|
1195
|
+
levels: [
|
|
1196
|
+
...payload.levels,
|
|
1197
|
+
...flowLevels.map(({ level, flow }) => ({
|
|
1198
|
+
id: level.id,
|
|
1199
|
+
kind: 'flow',
|
|
1200
|
+
title: flow.title,
|
|
1201
|
+
trail: level.trail,
|
|
1202
|
+
edges: [],
|
|
1203
|
+
})),
|
|
1204
|
+
],
|
|
1205
|
+
flows: flowPayload(drilldown.flows, drawnFlows, base.unresolved),
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
const drawnMessages = validated.drawableMessageIds ?? new Set();
|
|
1209
|
+
const drawnViews = validated.drawableProjectionIds ?? new Set();
|
|
1210
|
+
const whereOf = whereFn(ir);
|
|
1211
|
+
const views = (ir.projections ?? [])
|
|
1212
|
+
.filter((p) => drawnViews.has(p.id))
|
|
1213
|
+
.map((projection) => ({ level: `${VIEW_LEVEL_PREFIX}${projection.id}`, projection }));
|
|
1214
|
+
if (views.length > 0) {
|
|
1215
|
+
Object.assign(payload, {
|
|
1216
|
+
levels: [
|
|
1217
|
+
...payload.levels,
|
|
1218
|
+
...views.map((v) => ({
|
|
1219
|
+
id: v.level,
|
|
1220
|
+
kind: 'view',
|
|
1221
|
+
title: v.projection.title,
|
|
1222
|
+
trail: [ROOT_LEVEL_ID, v.level],
|
|
1223
|
+
edges: [],
|
|
1224
|
+
})),
|
|
1225
|
+
],
|
|
1226
|
+
messages: messagePayload(views, drawnMessages, base.unresolved, ir.edges),
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
const rootFlowLinks = flowLevels
|
|
1230
|
+
.filter(({ flow }) => flow.service === undefined)
|
|
1231
|
+
.map(({ level, flow }) => ({ level: level.id, title: flow.title }))
|
|
1232
|
+
.concat(views.map((v) => ({ level: v.level, title: v.projection.title })));
|
|
1233
|
+
const sections = levels
|
|
1234
|
+
.map((l, i) => renderLevelSection({
|
|
1235
|
+
levelId: l.id,
|
|
1236
|
+
svgId: `canvas-${i}`,
|
|
1237
|
+
title: l.title,
|
|
1238
|
+
nodes: l.nodeIds.map((id) => nodeById.get(id)).filter((n) => !!n),
|
|
1239
|
+
edges: l.edges,
|
|
1240
|
+
layout: l.layout,
|
|
1241
|
+
enter: drilldown.enter,
|
|
1242
|
+
...(l.focusId !== undefined ? { focusId: l.focusId } : {}),
|
|
1243
|
+
hidden: l.id !== ROOT_LEVEL_ID,
|
|
1244
|
+
services: base.services,
|
|
1245
|
+
microHosts: new Set(base.microHosts ?? []),
|
|
1246
|
+
...docCover,
|
|
1247
|
+
flows: flowCount,
|
|
1248
|
+
...(l.id === ROOT_LEVEL_ID && l.nodeIds.length === 0 && rootFlowLinks.length > 0
|
|
1249
|
+
? { emptyLinks: rootFlowLinks }
|
|
1250
|
+
: {}),
|
|
1251
|
+
...(l.id === ROOT_LEVEL_ID && views.length > 0
|
|
1252
|
+
? {
|
|
1253
|
+
viewStrip: views.map((v) => {
|
|
1254
|
+
const drawn = v.projection.messages.filter((m) => drawnMessages.has(m.id));
|
|
1255
|
+
return {
|
|
1256
|
+
...v,
|
|
1257
|
+
steps: drawn.length,
|
|
1258
|
+
actors: v.projection.participants?.length ??
|
|
1259
|
+
new Set(drawn.flatMap((m) => [m.from, m.to])).size,
|
|
1260
|
+
};
|
|
1261
|
+
}),
|
|
1262
|
+
}
|
|
1263
|
+
: {}),
|
|
1264
|
+
}))
|
|
1265
|
+
.concat(flowLevels.map(({ level, flow }) => renderFlowSection(level, flow, questionCount, drawableNodes)))
|
|
1266
|
+
.concat(views.map((v) => {
|
|
1267
|
+
const p = v.projection;
|
|
1268
|
+
if (p.shape === 'dataflow') {
|
|
1269
|
+
const layout = computeDataflowLayout(p, drawnMessages, nodeById);
|
|
1270
|
+
return renderDataflowSection(v.level, p, layout, nodeById, base.services);
|
|
1271
|
+
}
|
|
1272
|
+
if (p.shape === 'compare') {
|
|
1273
|
+
const layout = computeCompareLayout(p, drawableNodes, nodeById);
|
|
1274
|
+
return renderCompareSection(v.level, p, layout, nodeById, base.services);
|
|
1275
|
+
}
|
|
1276
|
+
const layout = computeSequenceLayout(p, drawnMessages, nodeById, whereOf);
|
|
1277
|
+
return renderSequenceSection(v.level, p, layout, nodeById, base.services, whereOf);
|
|
1278
|
+
}))
|
|
1279
|
+
.join('\n');
|
|
1280
|
+
return renderPage({
|
|
1281
|
+
ir,
|
|
1282
|
+
payload,
|
|
1283
|
+
sections,
|
|
1284
|
+
drill: true,
|
|
1285
|
+
...(flowLevels.length > 0 ? { flows: { flows: drawnFlows, drawnSteps } } : {}),
|
|
1286
|
+
...(views.length > 0 ? { views } : {}),
|
|
1287
|
+
mixedBundles: levels.some((l) => l.edges.some((e) => e.inferred !== undefined)),
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
//# sourceMappingURL=html-renderer.js.map
|