@tienne/gestalt 0.99.0 → 0.101.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 +6 -1
- package/dist/package.json +5 -3
- package/dist/plugin/research-sources/amplitude/SOURCE.md +28 -0
- package/dist/plugin/research-sources/app-store-reviews/SOURCE.md +25 -0
- package/dist/plugin/research-sources/app-store-search/SOURCE.md +25 -0
- package/dist/plugin/research-sources/tech-blog-search/SOURCE.md +27 -0
- package/dist/plugin/research-sources/uibowl/SOURCE.md +26 -0
- package/dist/plugin/research-sources/web-search/SOURCE.md +24 -0
- package/dist/plugin/skills/_shared/agent-delegation.md +30 -7
- package/dist/plugin/skills/_shared/agent-model.md +3 -1
- package/dist/plugin/skills/_shared/proactive-routing.md +5 -0
- package/dist/plugin/skills/brief/SKILL.md +5 -7
- package/dist/plugin/skills/explain/SKILL.md +4 -5
- package/dist/plugin/skills/jira-create/SKILL.md +3 -4
- package/dist/plugin/skills/market-scan/SKILL.md +61 -0
- package/dist/plugin/skills/pr/SKILL.md +4 -6
- package/dist/plugin/skills/presentation/SKILL.md +18 -16
- package/dist/plugin/skills/research/SKILL.md +438 -0
- package/dist/plugin/skills/research/references/design.md +938 -0
- package/dist/plugin/skills/research/references/output-template.md +366 -0
- package/dist/plugin/skills/research/scripts/pii-vision.swift +327 -0
- package/dist/plugin/skills/research/scripts/slide-extract.mjs +223 -0
- package/dist/plugin/skills/reverse-plan/SKILL.md +52 -0
- package/dist/plugin/skills/review/SKILL.md +20 -25
- package/dist/plugin/skills/review-reply/SKILL.md +4 -5
- package/dist/plugin/skills/screen-ref/SKILL.md +63 -0
- package/dist/plugin/skills/slack-send/SKILL.md +3 -4
- package/dist/plugin/skills/ux-research/SKILL.md +51 -0
- package/dist/src/core/config.d.ts +3 -0
- package/dist/src/core/config.d.ts.map +1 -1
- package/dist/src/core/config.js +4 -0
- package/dist/src/core/config.js.map +1 -1
- package/dist/src/mcp/schemas.d.ts +93 -0
- package/dist/src/mcp/schemas.d.ts.map +1 -1
- package/dist/src/mcp/schemas.js +110 -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 +11 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/research-passthrough.d.ts +21 -0
- package/dist/src/mcp/tools/research-passthrough.d.ts.map +1 -0
- package/dist/src/mcp/tools/research-passthrough.js +566 -0
- package/dist/src/mcp/tools/research-passthrough.js.map +1 -0
- package/dist/src/research/canvas-view.d.ts +5 -0
- package/dist/src/research/canvas-view.d.ts.map +1 -0
- package/dist/src/research/canvas-view.js +274 -0
- package/dist/src/research/canvas-view.js.map +1 -0
- package/dist/src/research/evidence.d.ts +145 -0
- package/dist/src/research/evidence.d.ts.map +1 -0
- package/dist/src/research/evidence.js +50 -0
- package/dist/src/research/evidence.js.map +1 -0
- package/dist/src/research/figma-plan.d.ts +80 -0
- package/dist/src/research/figma-plan.d.ts.map +1 -0
- package/dist/src/research/figma-plan.js +527 -0
- package/dist/src/research/figma-plan.js.map +1 -0
- package/dist/src/research/figma-script.d.ts +19 -0
- package/dist/src/research/figma-script.d.ts.map +1 -0
- package/dist/src/research/figma-script.js +363 -0
- package/dist/src/research/figma-script.js.map +1 -0
- package/dist/src/research/index.d.ts +8 -0
- package/dist/src/research/index.d.ts.map +1 -0
- package/dist/src/research/index.js +8 -0
- package/dist/src/research/index.js.map +1 -0
- package/dist/src/research/pii-core.d.ts +16 -0
- package/dist/src/research/pii-core.d.ts.map +1 -0
- package/dist/src/research/pii-core.js +345 -0
- package/dist/src/research/pii-core.js.map +1 -0
- package/dist/src/research/pii-dom.d.ts +13 -0
- package/dist/src/research/pii-dom.d.ts.map +1 -0
- package/dist/src/research/pii-dom.js +208 -0
- package/dist/src/research/pii-dom.js.map +1 -0
- package/dist/src/research/pii-image.d.ts +71 -0
- package/dist/src/research/pii-image.d.ts.map +1 -0
- package/dist/src/research/pii-image.js +163 -0
- package/dist/src/research/pii-image.js.map +1 -0
- package/dist/src/research/pii.d.ts +21 -0
- package/dist/src/research/pii.d.ts.map +1 -0
- package/dist/src/research/pii.js +47 -0
- package/dist/src/research/pii.js.map +1 -0
- package/dist/src/research/product-profile.d.ts +108 -0
- package/dist/src/research/product-profile.d.ts.map +1 -0
- package/dist/src/research/product-profile.js +104 -0
- package/dist/src/research/product-profile.js.map +1 -0
- package/dist/src/research/render-html.d.ts +203 -0
- package/dist/src/research/render-html.d.ts.map +1 -0
- package/dist/src/research/render-html.js +901 -0
- package/dist/src/research/render-html.js.map +1 -0
- package/dist/src/research/schema-base.d.ts +5 -0
- package/dist/src/research/schema-base.d.ts.map +1 -0
- package/dist/src/research/schema-base.js +10 -0
- package/dist/src/research/schema-base.js.map +1 -0
- package/dist/src/research/schema.d.ts +5142 -0
- package/dist/src/research/schema.d.ts.map +1 -0
- package/dist/src/research/schema.js +991 -0
- package/dist/src/research/schema.js.map +1 -0
- package/dist/src/research/source-create.d.ts +79 -0
- package/dist/src/research/source-create.d.ts.map +1 -0
- package/dist/src/research/source-create.js +226 -0
- package/dist/src/research/source-create.js.map +1 -0
- package/dist/src/research/source-registry.d.ts +29 -0
- package/dist/src/research/source-registry.d.ts.map +1 -0
- package/dist/src/research/source-registry.js +74 -0
- package/dist/src/research/source-registry.js.map +1 -0
- package/dist/src/research/source-schema.d.ts +158 -0
- package/dist/src/research/source-schema.d.ts.map +1 -0
- package/dist/src/research/source-schema.js +215 -0
- package/dist/src/research/source-schema.js.map +1 -0
- package/dist/src/research/source-status.d.ts +40 -0
- package/dist/src/research/source-status.d.ts.map +1 -0
- package/dist/src/research/source-status.js +109 -0
- package/dist/src/research/source-status.js.map +1 -0
- package/dist/src/research/store.d.ts +24 -0
- package/dist/src/research/store.d.ts.map +1 -0
- package/dist/src/research/store.js +60 -0
- package/dist/src/research/store.js.map +1 -0
- package/dist/src/research/target-view.d.ts +33 -0
- package/dist/src/research/target-view.d.ts.map +1 -0
- package/dist/src/research/target-view.js +133 -0
- package/dist/src/research/target-view.js.map +1 -0
- package/dist/src/research/target.d.ts +883 -0
- package/dist/src/research/target.d.ts.map +1 -0
- package/dist/src/research/target.js +323 -0
- package/dist/src/research/target.js.map +1 -0
- package/dist/src/research/video-transfer.d.ts +28 -0
- package/dist/src/research/video-transfer.d.ts.map +1 -0
- package/dist/src/research/video-transfer.js +100 -0
- package/dist/src/research/video-transfer.js.map +1 -0
- package/package.json +5 -3
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/mcp.json +1 -1
- package/plugin/research-sources/amplitude/SOURCE.md +28 -0
- package/plugin/research-sources/app-store-reviews/SOURCE.md +25 -0
- package/plugin/research-sources/app-store-search/SOURCE.md +25 -0
- package/plugin/research-sources/tech-blog-search/SOURCE.md +27 -0
- package/plugin/research-sources/uibowl/SOURCE.md +26 -0
- package/plugin/research-sources/web-search/SOURCE.md +24 -0
- package/plugin/skills/_shared/agent-delegation.md +30 -7
- package/plugin/skills/_shared/agent-model.md +3 -1
- package/plugin/skills/_shared/proactive-routing.md +5 -0
- package/plugin/skills/brief/SKILL.md +5 -7
- package/plugin/skills/explain/SKILL.md +4 -5
- package/plugin/skills/jira-create/SKILL.md +3 -4
- package/plugin/skills/market-scan/SKILL.md +61 -0
- package/plugin/skills/pr/SKILL.md +4 -6
- package/plugin/skills/presentation/SKILL.md +18 -16
- package/plugin/skills/research/SKILL.md +438 -0
- package/plugin/skills/research/references/design.md +938 -0
- package/plugin/skills/research/references/output-template.md +366 -0
- package/plugin/skills/research/scripts/pii-vision.swift +327 -0
- package/plugin/skills/research/scripts/slide-extract.mjs +223 -0
- package/plugin/skills/reverse-plan/SKILL.md +52 -0
- package/plugin/skills/review/SKILL.md +20 -25
- package/plugin/skills/review-reply/SKILL.md +4 -5
- package/plugin/skills/screen-ref/SKILL.md +63 -0
- package/plugin/skills/slack-send/SKILL.md +3 -4
- package/plugin/skills/ux-research/SKILL.md +51 -0
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import { boardMarkup, CANVAS_CSS, CANVAS_JS } from './canvas-view.js';
|
|
2
|
+
import { metricCasesByProduct } from './schema.js';
|
|
3
|
+
import { targetView } from './target-view.js';
|
|
4
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
const MAX_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
6
|
+
const MAX_THUMB_BYTES = 1024 * 1024;
|
|
7
|
+
const MAX_TOTAL_THUMB_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
export const AXIS_LABEL = {
|
|
9
|
+
'same-market': '같은 시장',
|
|
10
|
+
'same-problem': '같은 문제',
|
|
11
|
+
'own-service': '우리 서비스',
|
|
12
|
+
};
|
|
13
|
+
export const DOMAIN_FIT_LABEL = {
|
|
14
|
+
same: '같은 도메인',
|
|
15
|
+
adjacent: '이웃 도메인',
|
|
16
|
+
different: '다른 도메인',
|
|
17
|
+
};
|
|
18
|
+
/** 축 뒤에 도메인 일치를 붙인다. 다른 도메인 사례는 수치를 그대로 빌려오면 안 돼서 표에서 바로 보이게 한다 */
|
|
19
|
+
export const axisText = (p) => p.domainFit ? `${AXIS_LABEL[p.axis]}, ${DOMAIN_FIT_LABEL[p.domainFit]}` : AXIS_LABEL[p.axis];
|
|
20
|
+
export const SOURCE_LABEL = {
|
|
21
|
+
'app-explore': '앱 탐색',
|
|
22
|
+
uibowl: '유아이볼',
|
|
23
|
+
'site-search': '사이트 검색',
|
|
24
|
+
youtube: '영상',
|
|
25
|
+
'store-screenshot': '스토어 스크린샷',
|
|
26
|
+
'tech-blog': '기술 블로그',
|
|
27
|
+
'own-app': '우리 앱 캡처',
|
|
28
|
+
'own-web': '우리 웹 캡처',
|
|
29
|
+
figma: '피그마',
|
|
30
|
+
none: '출처 없음',
|
|
31
|
+
};
|
|
32
|
+
export const CAPTURE_STATE_LABEL = {
|
|
33
|
+
captured: '캡처함',
|
|
34
|
+
'reference-image': '레퍼런스 이미지',
|
|
35
|
+
'stopped-before-payment': '결제 직전에 멈춤',
|
|
36
|
+
'not-captured': '캡처 못 함',
|
|
37
|
+
};
|
|
38
|
+
export const CAPTURE_KIND_LABEL = {
|
|
39
|
+
direct: '직접 캡처',
|
|
40
|
+
'reference-db': '레퍼런스 DB',
|
|
41
|
+
'video-frame': '영상 프레임',
|
|
42
|
+
store: '스토어',
|
|
43
|
+
'own-app': '우리 앱 캡처',
|
|
44
|
+
'own-web': '우리 웹 캡처',
|
|
45
|
+
figma: '피그마',
|
|
46
|
+
};
|
|
47
|
+
export const PII_LABEL = {
|
|
48
|
+
replaced: '개인정보를 가짜 값으로 바꿈',
|
|
49
|
+
none: '탐지 0건',
|
|
50
|
+
unchecked: '개인정보 미확인',
|
|
51
|
+
};
|
|
52
|
+
export const EVIDENCE_LABEL = {
|
|
53
|
+
observation: '관찰',
|
|
54
|
+
'company-statement': '회사 발언',
|
|
55
|
+
interpretation: '해석',
|
|
56
|
+
};
|
|
57
|
+
export const CONFIDENCE_LABEL = {
|
|
58
|
+
likely: '유력',
|
|
59
|
+
trial: '시험',
|
|
60
|
+
hold: '보류',
|
|
61
|
+
out: '제외',
|
|
62
|
+
};
|
|
63
|
+
export const ROW_KIND_LABEL = { graded: '기준', burden: '구현 부담', risk: '위험' };
|
|
64
|
+
export const BASIS_LABEL = {
|
|
65
|
+
observation: '관찰',
|
|
66
|
+
'company-statement': '회사 발언',
|
|
67
|
+
interpretation: '해석',
|
|
68
|
+
unconfirmed: '확인 못함',
|
|
69
|
+
absent: '없음',
|
|
70
|
+
};
|
|
71
|
+
export const PATTERN_LABEL = {
|
|
72
|
+
common: '동일 패턴',
|
|
73
|
+
split: '서비스별 분기',
|
|
74
|
+
unique: '단독 구현',
|
|
75
|
+
};
|
|
76
|
+
export const FINDING_KIND_LABEL = {
|
|
77
|
+
wow: '와우',
|
|
78
|
+
pain: '페인',
|
|
79
|
+
insight: '인사이트',
|
|
80
|
+
design: '설계 힌트',
|
|
81
|
+
};
|
|
82
|
+
export const VIEWPOINT_LABEL = { user: '사용자', biz: '비즈니스' };
|
|
83
|
+
export const OWNER_LABEL = {
|
|
84
|
+
immediate: '바로 정함',
|
|
85
|
+
data: '데이터 확인 선행',
|
|
86
|
+
'other-team': '다른 직군 확인',
|
|
87
|
+
};
|
|
88
|
+
export const ADOPTION_LABEL = {
|
|
89
|
+
adopted: '채택',
|
|
90
|
+
candidate: '채택 후보',
|
|
91
|
+
rejected: '채택하지 않음',
|
|
92
|
+
};
|
|
93
|
+
export const VIDEO_KIND_LABEL = {
|
|
94
|
+
'screen-recording': '화면 녹화',
|
|
95
|
+
seminar: '세미나',
|
|
96
|
+
mixed: '섞임',
|
|
97
|
+
};
|
|
98
|
+
export const VIDEO_ORIGIN_LABEL = { public: '공개 영상', recorded: '직접 녹화' };
|
|
99
|
+
export const VIDEO_GROUP_LABEL = {
|
|
100
|
+
flow: '제품별 흐름',
|
|
101
|
+
slot: '구좌별',
|
|
102
|
+
type: '유형별',
|
|
103
|
+
summary: '종합',
|
|
104
|
+
};
|
|
105
|
+
export const VIDEO_NATURE_LABEL = { direction: '방향성', convention: '관례' };
|
|
106
|
+
export const VIDEO_CADENCE_LABEL = { always: '상시', seasonal: '분기', exclusive: '단독' };
|
|
107
|
+
export const PLATFORM_LABEL = { ios: 'iOS', aos: 'AOS', web: '웹' };
|
|
108
|
+
export const GESTURE_LABEL = {
|
|
109
|
+
tap: '탭',
|
|
110
|
+
swipe: '스와이프',
|
|
111
|
+
drag: '드래그',
|
|
112
|
+
scroll: '스크롤',
|
|
113
|
+
press: '길게 누름',
|
|
114
|
+
pan: '팬',
|
|
115
|
+
input: '입력',
|
|
116
|
+
};
|
|
117
|
+
export const ATTEMPT_LABEL = {
|
|
118
|
+
found: '찾음',
|
|
119
|
+
partial: '일부만 찾음',
|
|
120
|
+
'not-found': '못 찾음',
|
|
121
|
+
'too-many': '너무 많아 못 고름',
|
|
122
|
+
};
|
|
123
|
+
/** 역기획의 화면 주석 카드 이름. 발견의 종류를 그대로 옮긴다 */
|
|
124
|
+
export const ANNOTATION_LABEL = {
|
|
125
|
+
wow: 'WOW',
|
|
126
|
+
pain: 'BAD',
|
|
127
|
+
insight: 'INSIGHT',
|
|
128
|
+
design: 'Design',
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* 근거 여러 개를 괄호 하나로 묶는다. 프레임이 이어지면 "(프레임 08, 09)"처럼 접두어를 한 번만 쓴다.
|
|
132
|
+
* 링크는 글자만으로 갈 곳을 알 수 없어 뺀다(HTML이 따로 건다)
|
|
133
|
+
*/
|
|
134
|
+
export function evidenceGroupLabel(research, list, shared) {
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
const labels = [];
|
|
137
|
+
let prevFrame = false;
|
|
138
|
+
for (const ev of list) {
|
|
139
|
+
if (ev.type === 'url')
|
|
140
|
+
continue;
|
|
141
|
+
const label = evidenceLabel(research, ev, shared);
|
|
142
|
+
if (seen.has(label))
|
|
143
|
+
continue;
|
|
144
|
+
seen.add(label);
|
|
145
|
+
const isFrame = label.startsWith('프레임 ');
|
|
146
|
+
labels.push(isFrame && prevFrame ? label.slice('프레임 '.length) : label);
|
|
147
|
+
prevFrame = isFrame;
|
|
148
|
+
}
|
|
149
|
+
return labels.length > 0 ? `(${labels.join(', ')})` : '';
|
|
150
|
+
}
|
|
151
|
+
const SYMBOL_POINT = { '◎': 2, '○': 1, '△': 0 };
|
|
152
|
+
/** 격자 칸에 붙일 와우와 페인. 발견의 구좌(slot)가 행의 기준 이름과 같은 것만 모은다 */
|
|
153
|
+
export function gridFindings(research, referenceId, criterion) {
|
|
154
|
+
return research.wowPain.findings.filter((f) => f.referenceId === referenceId &&
|
|
155
|
+
f.slot === criterion &&
|
|
156
|
+
(f.kind === 'wow' || f.kind === 'pain'));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 종합 표. 비교표 기호와 판단 순위와 발견에서 코드가 모은 것이라 새 주장은 없다.
|
|
160
|
+
* 두 렌더러가 같은 표를 쓴다
|
|
161
|
+
*/
|
|
162
|
+
export function buildSummaryTables(research) {
|
|
163
|
+
const tables = [];
|
|
164
|
+
const refs = research.references;
|
|
165
|
+
const refName = (id) => {
|
|
166
|
+
const r = refs.find((x) => x.id === id);
|
|
167
|
+
return r ? `${r.symbol}. ${r.typeName}` : id;
|
|
168
|
+
};
|
|
169
|
+
const graded = research.comparison.filter((row) => row.kind === 'graded' && row.cells.some((c) => c.symbol));
|
|
170
|
+
if (graded.length > 0 && refs.length > 0) {
|
|
171
|
+
const score = new Map(refs.map((r) => [r.id, { got: 0, max: 0, none: 0 }]));
|
|
172
|
+
const rows = graded.map((row) => [
|
|
173
|
+
row.phase,
|
|
174
|
+
row.criterion,
|
|
175
|
+
...refs.map((r) => {
|
|
176
|
+
const cell = row.cells.find((c) => c.referenceId === r.id);
|
|
177
|
+
const acc = score.get(r.id);
|
|
178
|
+
if (cell?.symbol) {
|
|
179
|
+
acc.got += SYMBOL_POINT[cell.symbol];
|
|
180
|
+
acc.max += 2;
|
|
181
|
+
return cell.symbol;
|
|
182
|
+
}
|
|
183
|
+
acc.none += 1;
|
|
184
|
+
return '-';
|
|
185
|
+
}),
|
|
186
|
+
]);
|
|
187
|
+
rows.push([
|
|
188
|
+
'',
|
|
189
|
+
'합계',
|
|
190
|
+
...refs.map((r) => {
|
|
191
|
+
const acc = score.get(r.id);
|
|
192
|
+
return `${acc.got}/${acc.max}${acc.none > 0 ? ` (기호 없음 ${acc.none}개)` : ''}`;
|
|
193
|
+
}),
|
|
194
|
+
]);
|
|
195
|
+
tables.push({
|
|
196
|
+
heading: '기준별 점수 (◎ 2점, ○ 1점, △ 0점, 가중치 없음)',
|
|
197
|
+
head: ['단계', '기준', ...refs.map((r) => refName(r.id))],
|
|
198
|
+
rows,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const ranked = research.judgment
|
|
202
|
+
.filter((j) => j.rank !== undefined)
|
|
203
|
+
.sort((a, b) => a.rank - b.rank);
|
|
204
|
+
if (ranked.length > 0) {
|
|
205
|
+
tables.push({
|
|
206
|
+
heading: '우선순위',
|
|
207
|
+
head: ['순위', '영역', '확신', '결론', '할 일'],
|
|
208
|
+
rows: ranked.map((j) => [
|
|
209
|
+
String(j.rank),
|
|
210
|
+
j.area,
|
|
211
|
+
CONFIDENCE_LABEL[j.confidence],
|
|
212
|
+
j.conclusion,
|
|
213
|
+
j.todo ?? '',
|
|
214
|
+
]),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const seen = research.wowPain.findings.filter((f) => f.basis !== 'unconfirmed' && f.basis !== 'absent');
|
|
218
|
+
const ownProducts = new Set(research.productCandidates.filter((p) => p.axis === 'own-service').map((p) => p.id));
|
|
219
|
+
const ownRefs = new Set(research.references
|
|
220
|
+
.filter((r) => r.productIds.some((id) => ownProducts.has(id)))
|
|
221
|
+
.map((r) => r.id));
|
|
222
|
+
const findingRows = (kind, own) => seen
|
|
223
|
+
.filter((f) => f.kind === kind && ownRefs.has(f.referenceId) === own)
|
|
224
|
+
.map((f) => [refName(f.referenceId), f.slot ?? '', f.text, f.concrete ?? '']);
|
|
225
|
+
for (const [kind, own, heading] of [
|
|
226
|
+
['wow', false, '가져올 와우'],
|
|
227
|
+
['pain', false, '피할 페인'],
|
|
228
|
+
['wow', true, '지킬 우리 와우'],
|
|
229
|
+
['pain', true, '고칠 우리 페인'],
|
|
230
|
+
]) {
|
|
231
|
+
const rows = findingRows(kind, own);
|
|
232
|
+
if (rows.length > 0) {
|
|
233
|
+
tables.push({ heading, head: ['레퍼런스', '구좌나 단계', '발견', '조작과 수치'], rows });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return tables;
|
|
237
|
+
}
|
|
238
|
+
/** 화면 흐름 섹션이 그릴 내용이 있는지 */
|
|
239
|
+
export function hasFlowSection(research) {
|
|
240
|
+
const f = research.flow;
|
|
241
|
+
return (f.transitions.length > 0 ||
|
|
242
|
+
f.markers.length > 0 ||
|
|
243
|
+
f.attempts.length > 0 ||
|
|
244
|
+
f.userTypes.length > 0);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* 역기획의 화면 흐름을 표 목록으로 푼다. 전이, 번호 마커, 화면 주석, 시도 기록, 사용자 유형 순이다.
|
|
248
|
+
* 두 렌더러가 같은 표를 쓴다
|
|
249
|
+
*/
|
|
250
|
+
export function buildFlowTables(research, shared) {
|
|
251
|
+
if (!hasFlowSection(research))
|
|
252
|
+
return [];
|
|
253
|
+
const screens = [...research.screenMap]
|
|
254
|
+
.map((s, i) => ({ s, i }))
|
|
255
|
+
.sort((a, b) => (a.s.order ?? a.i) - (b.s.order ?? b.i) || a.i - b.i)
|
|
256
|
+
.map((x) => x.s);
|
|
257
|
+
const screenName = (id) => {
|
|
258
|
+
const s = research.screenMap.find((x) => x.id === id);
|
|
259
|
+
if (!s)
|
|
260
|
+
return id;
|
|
261
|
+
return s.platform ? `${s.screen} (${PLATFORM_LABEL[s.platform]})` : s.screen;
|
|
262
|
+
};
|
|
263
|
+
const evid = (list) => evidenceGroupLabel(research, list, shared);
|
|
264
|
+
const tables = [];
|
|
265
|
+
const { transitions, markers, attempts, userTypes } = research.flow;
|
|
266
|
+
if (transitions.length > 0) {
|
|
267
|
+
tables.push({
|
|
268
|
+
heading: '화면 전이',
|
|
269
|
+
head: ['출발', '사용자 행동', '도착', '조건', '근거'],
|
|
270
|
+
rows: transitions.map((t) => {
|
|
271
|
+
const grey = t.basis === 'unconfirmed' || t.basis === 'absent';
|
|
272
|
+
const act = `${GESTURE_LABEL[t.gesture]}: ${t.trigger}${t.loop ? ' (반복)' : ''}`;
|
|
273
|
+
const basis = t.basis === 'observation' ? '' : `[${BASIS_LABEL[t.basis]}] `;
|
|
274
|
+
const note = t.note ? `\n${t.note}` : '';
|
|
275
|
+
return [
|
|
276
|
+
screenName(t.from),
|
|
277
|
+
act,
|
|
278
|
+
screenName(t.to),
|
|
279
|
+
t.condition ?? '',
|
|
280
|
+
`${basis}${grey ? '' : evid(t.evidence)}${note}`.trim(),
|
|
281
|
+
];
|
|
282
|
+
}),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (markers.length > 0) {
|
|
286
|
+
const rows = [];
|
|
287
|
+
for (const s of screens) {
|
|
288
|
+
for (const m of markers.filter((x) => x.screenMapId === s.id)) {
|
|
289
|
+
rows.push([screenName(s.id), m.no, m.element, m.behavior, m.limit ?? '']);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
tables.push({
|
|
293
|
+
heading: '번호 마커 명세',
|
|
294
|
+
head: ['화면', '번호', '요소', '동작', '상한과 조건'],
|
|
295
|
+
rows,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
const annotated = research.wowPain.findings.filter((f) => f.screenMapId);
|
|
299
|
+
if (annotated.length > 0) {
|
|
300
|
+
const rows = [];
|
|
301
|
+
for (const s of screens) {
|
|
302
|
+
for (const f of annotated.filter((x) => x.screenMapId === s.id)) {
|
|
303
|
+
rows.push([
|
|
304
|
+
screenName(s.id),
|
|
305
|
+
ANNOTATION_LABEL[f.kind],
|
|
306
|
+
`${f.text}${evid(f.evidence)}`,
|
|
307
|
+
f.concrete ?? '',
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
tables.push({ heading: '화면 주석', head: ['화면', '구분', '내용', '조작과 수치'], rows });
|
|
312
|
+
}
|
|
313
|
+
if (attempts.length > 0) {
|
|
314
|
+
tables.push({
|
|
315
|
+
heading: '시도 기록',
|
|
316
|
+
head: ['무엇을 찾았나', '결과', '내용', '상한'],
|
|
317
|
+
rows: attempts.map((a) => [a.what, ATTEMPT_LABEL[a.result], a.text, a.limit ?? '']),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
tables.push({
|
|
321
|
+
heading: '사용자 유형',
|
|
322
|
+
head: userTypes.length > 0 ? ['유형', '설명', '자료'] : ['유형'],
|
|
323
|
+
rows: userTypes.length > 0
|
|
324
|
+
? userTypes.map((u) => [u.name, u.summary, u.source])
|
|
325
|
+
: [['인터뷰 없음']],
|
|
326
|
+
});
|
|
327
|
+
return tables;
|
|
328
|
+
}
|
|
329
|
+
/** 영상 분석 섹션의 제목. 단독 문서는 0, 1번 뒤에 바로 오므로 2번이다 */
|
|
330
|
+
export function videoSectionTitle(research) {
|
|
331
|
+
return research.meta.kind === 'video-analysis' ? '2. 영상 분석' : '9. 영상 분석';
|
|
332
|
+
}
|
|
333
|
+
/** 영상 블록이 있거나 영상 분석 문서일 때만 섹션을 낸다 */
|
|
334
|
+
export function hasVideoSection(research) {
|
|
335
|
+
return research.meta.kind === 'video-analysis' || research.video !== undefined;
|
|
336
|
+
}
|
|
337
|
+
function formatSeconds(sec) {
|
|
338
|
+
return `${Number.isInteger(sec) ? sec : sec.toFixed(1)}초`;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* 영상 섹션을 표 목록으로 푼다. 첫 표는 영상 목록이고 뒤는 design.md 8.2절의 묶음 순서
|
|
342
|
+
* (제품별 흐름, 구좌별, 유형별, 종합)대로 발견을 모은다. 두 렌더러가 같은 표를 쓴다
|
|
343
|
+
*/
|
|
344
|
+
export function buildVideoTables(research) {
|
|
345
|
+
const video = research.video;
|
|
346
|
+
if (!video || video.clips.length === 0)
|
|
347
|
+
return [];
|
|
348
|
+
const productName = new Map(research.productCandidates.map((p) => [p.id, p.name]));
|
|
349
|
+
const captureLabel = new Map(research.captures.map((c) => {
|
|
350
|
+
const screen = c.screenMapId
|
|
351
|
+
? research.screenMap.find((s) => s.id === c.screenMapId)?.screen
|
|
352
|
+
: undefined;
|
|
353
|
+
return [c.id, c.label ?? screen ?? c.id];
|
|
354
|
+
}));
|
|
355
|
+
const clipById = new Map(video.clips.map((c) => [c.id, c]));
|
|
356
|
+
const tables = [
|
|
357
|
+
{
|
|
358
|
+
heading: '영상 목록',
|
|
359
|
+
head: ['제품', '영상', '종류', '출처', '길이'],
|
|
360
|
+
rows: video.clips.map((c) => [
|
|
361
|
+
productName.get(c.productId) ?? c.productId,
|
|
362
|
+
c.url ? `${c.title}\n${c.url}` : c.title,
|
|
363
|
+
VIDEO_KIND_LABEL[c.kind],
|
|
364
|
+
VIDEO_ORIGIN_LABEL[c.origin],
|
|
365
|
+
c.durationSec === undefined ? '-' : formatSeconds(c.durationSec),
|
|
366
|
+
]),
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
for (const group of ['flow', 'slot', 'type', 'summary']) {
|
|
370
|
+
const found = video.findings.filter((f) => f.group === group);
|
|
371
|
+
if (found.length === 0)
|
|
372
|
+
continue;
|
|
373
|
+
tables.push({
|
|
374
|
+
heading: VIDEO_GROUP_LABEL[group],
|
|
375
|
+
head: ['영상', '주제', '내용', '근거', '프레임'],
|
|
376
|
+
rows: found.map((f) => {
|
|
377
|
+
const tags = [
|
|
378
|
+
...(f.nature ? [`[${VIDEO_NATURE_LABEL[f.nature]}]`] : []),
|
|
379
|
+
...(f.cadence ? [`[${VIDEO_CADENCE_LABEL[f.cadence]}]`] : []),
|
|
380
|
+
];
|
|
381
|
+
const evidence = f.at === undefined
|
|
382
|
+
? EVIDENCE_LABEL[f.evidence]
|
|
383
|
+
: `${EVIDENCE_LABEL[f.evidence]} (영상 ${formatSeconds(f.at)})`;
|
|
384
|
+
return [
|
|
385
|
+
clipById.get(f.clipId)?.title ?? f.clipId,
|
|
386
|
+
f.topic,
|
|
387
|
+
[...tags, f.text].join(' '),
|
|
388
|
+
evidence,
|
|
389
|
+
f.captureId ? (captureLabel.get(f.captureId) ?? f.captureId) : '-',
|
|
390
|
+
];
|
|
391
|
+
}),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return tables;
|
|
395
|
+
}
|
|
396
|
+
export function escapeHtml(text) {
|
|
397
|
+
return text
|
|
398
|
+
.replace(/&/g, '&')
|
|
399
|
+
.replace(/</g, '<')
|
|
400
|
+
.replace(/>/g, '>')
|
|
401
|
+
.replace(/"/g, '"')
|
|
402
|
+
.replace(/'/g, ''');
|
|
403
|
+
}
|
|
404
|
+
const e = escapeHtml;
|
|
405
|
+
const CSS = `
|
|
406
|
+
:root{--bg:#fafaf9;--fg:#1c1917;--muted:#78716c;--line:#e7e5e4;--card:#fff;--accent:#2563eb;--warn:#b45309;--ok:#15803d;--chip:#f5f5f4}
|
|
407
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#0c0a09;--fg:#f5f5f4;--muted:#a8a29e;--line:#292524;--card:#1c1917;--accent:#60a5fa;--warn:#fbbf24;--ok:#4ade80;--chip:#292524}}
|
|
408
|
+
:root[data-theme="dark"]{--bg:#0c0a09;--fg:#f5f5f4;--muted:#a8a29e;--line:#292524;--card:#1c1917;--accent:#60a5fa;--warn:#fbbf24;--ok:#4ade80;--chip:#292524}
|
|
409
|
+
*{box-sizing:border-box}
|
|
410
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo","Pretendard","Noto Sans KR",sans-serif}
|
|
411
|
+
main{max-width:1080px;margin:0 auto;padding:32px 16px 96px}
|
|
412
|
+
h1{font-size:26px;margin:0 0 4px}
|
|
413
|
+
h2{font-size:19px;margin:44px 0 12px;padding-top:20px;border-top:1px solid var(--line)}
|
|
414
|
+
h3{font-size:15px;margin:24px 0 8px}
|
|
415
|
+
p{margin:6px 0}
|
|
416
|
+
.sub{color:var(--muted);font-size:14px}
|
|
417
|
+
.fnd{margin-top:6px;font-size:14px}
|
|
418
|
+
.notice{background:var(--chip);border-left:3px solid var(--warn);padding:10px 14px;margin:16px 0;border-radius:4px}
|
|
419
|
+
.wrap{overflow-x:auto}
|
|
420
|
+
table{border-collapse:collapse;width:100%;font-size:14px;background:var(--card)}
|
|
421
|
+
th,td{border:1px solid var(--line);padding:8px 10px;vertical-align:top;text-align:left}
|
|
422
|
+
th{background:var(--chip);font-weight:600}
|
|
423
|
+
td{white-space:pre-line}
|
|
424
|
+
.cmp th:first-child,.cmp td:first-child{white-space:nowrap}
|
|
425
|
+
.chip{display:inline-block;background:var(--chip);border:1px solid var(--line);border-radius:999px;padding:0 8px;font-size:12px;color:var(--muted);margin-right:4px}
|
|
426
|
+
.sym{font-weight:700;margin-right:4px}
|
|
427
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
|
|
428
|
+
figure{margin:0;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px}
|
|
429
|
+
figure img{display:block;width:100%;height:auto;border-radius:4px}
|
|
430
|
+
figcaption{font-size:12px;color:var(--muted);margin-top:6px;white-space:pre-line}
|
|
431
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:12px 14px;margin:10px 0}
|
|
432
|
+
.card h3{margin:0 0 6px}
|
|
433
|
+
ul{margin:4px 0;padding-left:20px}
|
|
434
|
+
.pii-unchecked{color:var(--warn)}
|
|
435
|
+
.pii-replaced{color:var(--ok)}
|
|
436
|
+
.todo{color:var(--muted)}
|
|
437
|
+
.def{margin-top:2px}
|
|
438
|
+
.grey{background:var(--chip);color:var(--muted)}
|
|
439
|
+
.cell .ev{color:var(--muted);font-size:12px;margin-left:4px}
|
|
440
|
+
.cell .why{color:var(--muted);font-size:12px;margin-top:4px}
|
|
441
|
+
.thumbs{display:flex;gap:4px;margin-top:6px;flex-wrap:wrap}
|
|
442
|
+
.thumbs a{display:block;width:56px;border:1px solid var(--line);border-radius:4px;overflow:hidden}
|
|
443
|
+
.thumbs img{display:block;width:100%;height:80px;object-fit:cover;object-position:top}
|
|
444
|
+
.chip.basis-unconfirmed,.chip.basis-absent{color:var(--warn)}
|
|
445
|
+
.chip.own-immediate{color:var(--ok)}
|
|
446
|
+
.chip.own-data{color:var(--warn)}
|
|
447
|
+
.chip.own-other-team{color:var(--accent)}
|
|
448
|
+
.chip.kind-pain{color:var(--warn)}
|
|
449
|
+
.chip.kind-wow{color:var(--ok)}
|
|
450
|
+
`;
|
|
451
|
+
function list(items) {
|
|
452
|
+
if (items.length === 0)
|
|
453
|
+
return '<p class="todo">없음</p>';
|
|
454
|
+
return `<ul>${items.map((i) => `<li>${e(i)}</li>`).join('')}</ul>`;
|
|
455
|
+
}
|
|
456
|
+
function table(head, rows) {
|
|
457
|
+
const th = head.map((h) => `<th>${e(h)}</th>`).join('');
|
|
458
|
+
const body = rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('');
|
|
459
|
+
return `<div class="wrap"><table><thead><tr>${th}</tr></thead><tbody>${body}</tbody></table></div>`;
|
|
460
|
+
}
|
|
461
|
+
class ImageBudget {
|
|
462
|
+
loadImage;
|
|
463
|
+
total = 0;
|
|
464
|
+
warnings = [];
|
|
465
|
+
constructor(loadImage) {
|
|
466
|
+
this.loadImage = loadImage;
|
|
467
|
+
}
|
|
468
|
+
dataUri(capture) {
|
|
469
|
+
const img = this.loadImage(capture.path);
|
|
470
|
+
if (!img) {
|
|
471
|
+
this.warnings.push(`캡처를 읽지 못했다: ${capture.id}`);
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
if (img.data.length > MAX_IMAGE_BYTES) {
|
|
475
|
+
this.warnings.push(`캡처가 5MB를 넘어 건너뛰었다: ${capture.id}`);
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
if (this.total + img.data.length > MAX_TOTAL_IMAGE_BYTES) {
|
|
479
|
+
this.warnings.push(`이미지 합계가 15MB를 넘어 건너뛰었다: ${capture.id}`);
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
this.total += img.data.length;
|
|
483
|
+
return `data:${img.mime};base64,${img.data.toString('base64')}`;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/** 근거 한 줄의 괄호 안 글자. 비교표와 발견 끝에 "(프레임 08)"처럼 붙는다 */
|
|
487
|
+
export function evidenceLabel(research, ev, shared) {
|
|
488
|
+
if (ev.type === 'capture') {
|
|
489
|
+
const c = research.captures.find((x) => x.id === ev.captureId);
|
|
490
|
+
return c?.frameNo ? `프레임 ${c.frameNo}` : (c?.label ?? ev.captureId);
|
|
491
|
+
}
|
|
492
|
+
if (ev.type === 'video') {
|
|
493
|
+
const title = research.video?.clips.find((x) => x.id === ev.clipId)?.title;
|
|
494
|
+
return title && !shared ? `영상 ${ev.at}초, ${title}` : `영상 ${ev.at}초`;
|
|
495
|
+
}
|
|
496
|
+
if (ev.type === 'internal')
|
|
497
|
+
return ev.source;
|
|
498
|
+
return '링크';
|
|
499
|
+
}
|
|
500
|
+
export function renderResearchHtml(research, opts) {
|
|
501
|
+
const shared = opts.audience === 'shared';
|
|
502
|
+
const unchecked = research.captures.filter((c) => c.pii === 'unchecked');
|
|
503
|
+
if (shared && unchecked.length > 0) {
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
code: 'SHARE_BLOCKED',
|
|
507
|
+
captureIds: unchecked.map((c) => c.id),
|
|
508
|
+
message: `개인정보를 확인하지 않은 캡처가 ${unchecked.length}개 있어 공유용 HTML을 만들지 않는다. redact_image로 치환하거나 화면을 확인해 pii 상태를 정한 뒤 다시 렌더한다`,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
const budget = new ImageBudget(opts.loadImage);
|
|
512
|
+
const productName = new Map(research.productCandidates.map((p) => [p.id, p.name]));
|
|
513
|
+
const refById = new Map(research.references.map((r) => [r.id, r]));
|
|
514
|
+
const screenById = new Map(research.screenMap.map((s) => [s.id, s]));
|
|
515
|
+
const refLabel = (id) => {
|
|
516
|
+
const r = refById.get(id);
|
|
517
|
+
return r ? `${r.symbol}. ${r.typeName}` : id;
|
|
518
|
+
};
|
|
519
|
+
const warnings = [];
|
|
520
|
+
const capById = new Map(research.captures.map((c) => [c.id, c]));
|
|
521
|
+
// 같은 이미지를 격자에도 한 번 더 싣는다. 파일이 불어나지 않게 따로 상한을 둔다
|
|
522
|
+
let thumbBytes = 0;
|
|
523
|
+
const thumbUri = (c) => {
|
|
524
|
+
const img = opts.loadImage(c.path);
|
|
525
|
+
if (!img || img.data.length > MAX_THUMB_BYTES)
|
|
526
|
+
return null;
|
|
527
|
+
if (thumbBytes + img.data.length > MAX_TOTAL_THUMB_BYTES)
|
|
528
|
+
return null;
|
|
529
|
+
thumbBytes += img.data.length;
|
|
530
|
+
return `data:${img.mime};base64,${img.data.toString('base64')}`;
|
|
531
|
+
};
|
|
532
|
+
const thumbsHtml = (ids) => {
|
|
533
|
+
const items = ids.flatMap((id) => {
|
|
534
|
+
const c = capById.get(id);
|
|
535
|
+
if (!c)
|
|
536
|
+
return [];
|
|
537
|
+
const name = c.caption ?? c.label ?? c.id;
|
|
538
|
+
const uri = thumbUri(c);
|
|
539
|
+
const inner = uri ? `<img src="${uri}" alt="${e(name)}" loading="lazy">` : e(c.id);
|
|
540
|
+
return [`<a href="#cap-${e(c.id)}" title="${e(name)}">${inner}</a>`];
|
|
541
|
+
});
|
|
542
|
+
return items.length > 0 ? `<div class="thumbs">${items.join('')}</div>` : '';
|
|
543
|
+
};
|
|
544
|
+
const evidenceHtml = (list) => {
|
|
545
|
+
const group = evidenceGroupLabel(research, list, shared);
|
|
546
|
+
const links = list
|
|
547
|
+
.filter((ev) => ev.type === 'url')
|
|
548
|
+
.map((ev) => ev.type === 'url'
|
|
549
|
+
? `<a class="ev" href="${e(ev.url)}" rel="noopener noreferrer">(링크)</a>`
|
|
550
|
+
: '');
|
|
551
|
+
return (group ? `<span class="ev">${e(group)}</span>` : '') + links.join('');
|
|
552
|
+
};
|
|
553
|
+
const basisChip = (basis) => basis && basis !== 'observation'
|
|
554
|
+
? `<span class="chip basis-${basis}">${BASIS_LABEL[basis]}</span>`
|
|
555
|
+
: '';
|
|
556
|
+
const parts = [];
|
|
557
|
+
parts.push(`<h1>${e(research.meta.title)}</h1>`);
|
|
558
|
+
parts.push(`<p class="sub">${e(research.meta.task)}</p>`);
|
|
559
|
+
if (!research.plan.confirmedAt) {
|
|
560
|
+
parts.push('<p class="notice">리서치 구성(제품 후보와 화면 지도)을 아직 확인받지 않았습니다. 확인 전의 결과입니다.</p>');
|
|
561
|
+
}
|
|
562
|
+
if (!shared && research.plan.note)
|
|
563
|
+
parts.push(`<p class="sub">${e(research.plan.note)}</p>`);
|
|
564
|
+
// 종합. 번호 섹션 앞에 둔다
|
|
565
|
+
const summaryTables = buildSummaryTables(research);
|
|
566
|
+
if (summaryTables.length > 0) {
|
|
567
|
+
parts.push('<h2>종합</h2>');
|
|
568
|
+
parts.push('<p class="sub">아래 표는 비교표의 기호와 판단 순위와 발견에서 모은 것이다. 새 주장은 없다.</p>');
|
|
569
|
+
for (const t of summaryTables) {
|
|
570
|
+
parts.push(`<h3>${e(t.heading)}</h3>`);
|
|
571
|
+
parts.push(table(t.head, t.rows.map((row) => row.map((cell) => e(cell)))));
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// 0. 전제
|
|
575
|
+
const pr = research.premise;
|
|
576
|
+
parts.push('<h2>0. 전제</h2>');
|
|
577
|
+
parts.push(table(['항목', '내용'], [
|
|
578
|
+
['목표 지표', e(pr.goalMetric)],
|
|
579
|
+
...(pr.attachTo
|
|
580
|
+
? [
|
|
581
|
+
[
|
|
582
|
+
'붙는 흐름',
|
|
583
|
+
`${e(pr.attachTo.flow)} (${e(pr.attachTo.domain)})${pr.attachTo.note ? `<div class="why">${e(pr.attachTo.note)}</div>` : ''}`,
|
|
584
|
+
],
|
|
585
|
+
]
|
|
586
|
+
: []),
|
|
587
|
+
[
|
|
588
|
+
'규모 가정',
|
|
589
|
+
pr.scaleAssumption ? e(pr.scaleAssumption) : '<span class="todo">없음</span>',
|
|
590
|
+
],
|
|
591
|
+
['구조 제약', list(pr.structureConstraints)],
|
|
592
|
+
['아직 안 온 입력', list(pr.pendingInputs)],
|
|
593
|
+
['근거의 한계', list(pr.evidenceLimits)],
|
|
594
|
+
['기호 범례', e(pr.symbolLegend)],
|
|
595
|
+
]));
|
|
596
|
+
const tv = targetView(research, shared);
|
|
597
|
+
const targetCell = (c) => {
|
|
598
|
+
const grey = c.basis === 'unconfirmed' || c.basis === 'absent';
|
|
599
|
+
const why = c.note ? `<div class="why">${e(c.note)}</div>` : '';
|
|
600
|
+
const sub = c.sub ? `<div class="sub">${e(c.sub)}</div>` : '';
|
|
601
|
+
return `<div class="cell${grey ? ' grey' : ''}">${basisChip(c.basis)}${e(c.text)}${c.evidence ? evidenceHtml(c.evidence) : ''}${why}${sub}</div>`;
|
|
602
|
+
};
|
|
603
|
+
if (tv) {
|
|
604
|
+
parts.push('<h3>목표치 근거</h3>');
|
|
605
|
+
parts.push(table(['항목', '내용'], tv.rows.map(([k, c]) => [e(k), targetCell(c)])));
|
|
606
|
+
if (tv.steps.length > 0) {
|
|
607
|
+
parts.push(table(['단계', '값', '어디서 온 숫자'], tv.steps.map((s) => [e(s.label), targetCell(s.value), e(s.method)])));
|
|
608
|
+
}
|
|
609
|
+
if (tv.coverage)
|
|
610
|
+
parts.push(`<p>${e(tv.coverage)}</p>`);
|
|
611
|
+
if (tv.shortfall)
|
|
612
|
+
parts.push(`<p>${e(tv.shortfall)}</p>`);
|
|
613
|
+
if (tv.power.length > 0) {
|
|
614
|
+
parts.push(table(['가설', '기대 효과', '군당 표본', '걸리는 일수', '기대 효과 근거'], tv.power.map((row) => [...row.cells.map((x) => e(x)), targetCell(row.liftBasis)])));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// 1. 레퍼런스 선정
|
|
618
|
+
parts.push('<h2>1. 레퍼런스 선정</h2>');
|
|
619
|
+
const casesByProduct = metricCasesByProduct(research);
|
|
620
|
+
const caseLines = (productIds) => productIds
|
|
621
|
+
.flatMap((id) => casesByProduct.get(id) ?? [])
|
|
622
|
+
.map((m) => e(`${m.metric} ${m.value}`))
|
|
623
|
+
.join('<br>');
|
|
624
|
+
if (research.references.length > 0) {
|
|
625
|
+
const withCases = research.references.some((r) => r.productIds.some((id) => casesByProduct.has(id)));
|
|
626
|
+
parts.push(table(['유형', '제품', '고른 이유', ...(withCases ? ['지표 사례'] : [])], research.references.map((r) => [
|
|
627
|
+
`<span class="sym">${e(r.symbol)}</span>${e(r.typeName)}`,
|
|
628
|
+
e(r.productIds.map((id) => productName.get(id) ?? id).join(', ')),
|
|
629
|
+
e(r.reason),
|
|
630
|
+
...(withCases ? [caseLines(r.productIds)] : []),
|
|
631
|
+
])));
|
|
632
|
+
}
|
|
633
|
+
if (research.productCandidates.length > 0) {
|
|
634
|
+
parts.push('<h3>제품 후보</h3>');
|
|
635
|
+
parts.push(table(['제품', '축', '이유', '규모', ...(casesByProduct.size > 0 ? ['지표 사례'] : [])], research.productCandidates.map((p) => [
|
|
636
|
+
e(p.name),
|
|
637
|
+
e(axisText(p)),
|
|
638
|
+
e(p.reason),
|
|
639
|
+
p.scale
|
|
640
|
+
? `${e(p.scale.value)}<br><span class="chip">${e(EVIDENCE_LABEL[p.scale.evidence])}</span>${e(p.scale.source)}`
|
|
641
|
+
: '<span class="todo">확인 못함</span>',
|
|
642
|
+
...(casesByProduct.size > 0 ? [caseLines([p.id])] : []),
|
|
643
|
+
])));
|
|
644
|
+
}
|
|
645
|
+
if (research.screenMap.length > 0) {
|
|
646
|
+
parts.push('<h3>화면 지도</h3>');
|
|
647
|
+
parts.push(table(['제품', '화면', '들어가는 길', '출처', '상태'], research.screenMap.map((s) => [
|
|
648
|
+
e(productName.get(s.productId) ?? s.productId),
|
|
649
|
+
e(s.screen),
|
|
650
|
+
e(s.entryPath.join(' > ')),
|
|
651
|
+
e(SOURCE_LABEL[s.source]),
|
|
652
|
+
e(CAPTURE_STATE_LABEL[s.capture]),
|
|
653
|
+
])));
|
|
654
|
+
}
|
|
655
|
+
// 캡처
|
|
656
|
+
if (research.captures.length > 0) {
|
|
657
|
+
parts.push('<h3>캡처</h3>');
|
|
658
|
+
const byProduct = new Map();
|
|
659
|
+
for (const c of research.captures) {
|
|
660
|
+
byProduct.set(c.productId, [...(byProduct.get(c.productId) ?? []), c]);
|
|
661
|
+
}
|
|
662
|
+
for (const [productId, caps] of byProduct) {
|
|
663
|
+
parts.push(`<p><strong>${e(productName.get(productId) ?? productId)}</strong></p>`);
|
|
664
|
+
const figs = caps.map((c) => {
|
|
665
|
+
const uri = budget.dataUri(c);
|
|
666
|
+
const screen = c.screenMapId ? screenById.get(c.screenMapId)?.screen : undefined;
|
|
667
|
+
const first = c.label ?? screen ?? c.id;
|
|
668
|
+
const lines = [
|
|
669
|
+
...(c.caption ? [e(c.caption)] : []),
|
|
670
|
+
...(c.frameNo ? [`프레임 ${e(c.frameNo)}`] : []),
|
|
671
|
+
e(CAPTURE_KIND_LABEL[c.kind]),
|
|
672
|
+
`<span class="pii-${c.pii}">${PII_LABEL[c.pii]}</span>`,
|
|
673
|
+
...(c.sourceNote ? [e(c.sourceNote)] : []),
|
|
674
|
+
...(shared || !c.figmaUrl ? [] : [`<a href="${e(c.figmaUrl)}">피그마 노드</a>`]),
|
|
675
|
+
...(shared ? [] : [e(c.path)]),
|
|
676
|
+
];
|
|
677
|
+
const body = uri
|
|
678
|
+
? `<img src="${uri}" alt="${e(first)}" loading="lazy">`
|
|
679
|
+
: '<p class="todo">이미지 없음</p>';
|
|
680
|
+
return `<figure id="cap-${e(c.id)}">${body}<figcaption><strong>${e(first)}</strong>\n${lines.join('\n')}</figcaption></figure>`;
|
|
681
|
+
});
|
|
682
|
+
parts.push(`<div class="cards">${figs.join('')}</div>`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// 영상 분석 문서는 2~8번을 쓰지 않는다. 아래 구간을 만든 뒤 버린다
|
|
686
|
+
const standalone = research.meta.kind === 'video-analysis';
|
|
687
|
+
const bodyStart = parts.length;
|
|
688
|
+
// 2. 시안
|
|
689
|
+
parts.push('<h2>2. 시안</h2>');
|
|
690
|
+
if (research.mockups.length === 0) {
|
|
691
|
+
parts.push('<p class="todo">시안이 없는 조사라 3번 비교표의 열은 시안 대신 레퍼런스 화면이다.</p>');
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
parts.push(table(['레퍼런스', '시안', '메모'], research.mockups.map((m) => [
|
|
695
|
+
e(refLabel(m.referenceId)),
|
|
696
|
+
m.url ? `<a href="${e(m.url)}" rel="noopener noreferrer">${e(m.url)}</a>` : '',
|
|
697
|
+
m.note ? e(m.note) : '',
|
|
698
|
+
])));
|
|
699
|
+
}
|
|
700
|
+
if (research.adoptions.length > 0) {
|
|
701
|
+
const findingById = new Map(research.wowPain.findings.map((f) => [f.id, f]));
|
|
702
|
+
const findingList = (ids) => list(ids.map((id) => findingById.get(id)?.text ?? id));
|
|
703
|
+
const hasPlacement = research.adoptions.some((a) => a.placement?.trim());
|
|
704
|
+
parts.push('<h3>시안이 가져온 것과 푼 것</h3>');
|
|
705
|
+
parts.push(table([
|
|
706
|
+
'시안',
|
|
707
|
+
...(hasPlacement ? ['자리'] : []),
|
|
708
|
+
'레퍼런스',
|
|
709
|
+
'판단',
|
|
710
|
+
'가져온 것',
|
|
711
|
+
'덜어낸 것',
|
|
712
|
+
'가져오는 와우',
|
|
713
|
+
'푸는 페인',
|
|
714
|
+
], research.adoptions.map((a) => [
|
|
715
|
+
e(a.variant),
|
|
716
|
+
...(hasPlacement ? [e(a.placement ?? '')] : []),
|
|
717
|
+
e(a.referenceIds.map(refLabel).join(', ')),
|
|
718
|
+
`<span class="chip">${ADOPTION_LABEL[a.status]}</span>${a.reason ? e(a.reason) : ''}`,
|
|
719
|
+
list(a.adopted),
|
|
720
|
+
list(a.mitigated),
|
|
721
|
+
findingList(a.takesFindingIds),
|
|
722
|
+
findingList(a.solvesFindingIds),
|
|
723
|
+
])));
|
|
724
|
+
}
|
|
725
|
+
// 3. 비교표
|
|
726
|
+
parts.push('<h2>3. 비교표</h2>');
|
|
727
|
+
if (research.comparison.length === 0) {
|
|
728
|
+
parts.push('<p class="todo">비교표가 없습니다.</p>');
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
const refs = research.references;
|
|
732
|
+
const head = ['단계', '기준', ...refs.map((r) => `${r.symbol}. ${r.typeName}`)];
|
|
733
|
+
const rows = research.comparison.map((row) => {
|
|
734
|
+
const cells = refs.map((r) => {
|
|
735
|
+
const cell = row.cells.find((c) => c.referenceId === r.id);
|
|
736
|
+
if (!cell)
|
|
737
|
+
return '<div class="cell grey">-</div>';
|
|
738
|
+
const grey = cell.basis === 'unconfirmed' || cell.basis === 'absent';
|
|
739
|
+
const sym = cell.symbol ? `<span class="sym">${e(cell.symbol)}</span>` : '';
|
|
740
|
+
const why = grey && cell.note ? `<div class="why">${e(cell.note)}</div>` : '';
|
|
741
|
+
const found = gridFindings(research, r.id, row.criterion)
|
|
742
|
+
.map((f) => `<div class="fnd"><span class="chip kind-${f.kind}">${FINDING_KIND_LABEL[f.kind]}</span>${e(f.text)}</div>`)
|
|
743
|
+
.join('');
|
|
744
|
+
return (`<div class="cell${grey ? ' grey' : ''}">${basisChip(cell.basis)}${sym}${e(cell.text)}` +
|
|
745
|
+
`${evidenceHtml(cell.evidence)}${why}${found}${thumbsHtml(cell.captureIds)}</div>`);
|
|
746
|
+
});
|
|
747
|
+
const chips = (row.kind === 'graded' ? '' : `<span class="chip">${ROW_KIND_LABEL[row.kind]}</span>`) +
|
|
748
|
+
(row.pattern ? `<span class="chip">${PATTERN_LABEL[row.pattern]}</span>` : '');
|
|
749
|
+
const def = row.definition ? `<div class="sub def">${e(row.definition)}</div>` : '';
|
|
750
|
+
return [e(row.phase), `${chips}${e(row.criterion)}${def}`, ...cells];
|
|
751
|
+
});
|
|
752
|
+
parts.push(`<div class="cmp">${table(head, rows)}</div>`);
|
|
753
|
+
}
|
|
754
|
+
// 4. 와우 포인트와 페인 포인트
|
|
755
|
+
parts.push('<h2>4. 와우 포인트와 페인 포인트</h2>');
|
|
756
|
+
if (research.wowPain.userViewpoint)
|
|
757
|
+
parts.push(`<p>사용자 관점: ${e(research.wowPain.userViewpoint)}</p>`);
|
|
758
|
+
if (research.wowPain.bizViewpoint)
|
|
759
|
+
parts.push(`<p>비즈니스 관점: ${e(research.wowPain.bizViewpoint)}</p>`);
|
|
760
|
+
const findingRow = (f) => {
|
|
761
|
+
const where = [f.slot, f.screenMapId ? screenById.get(f.screenMapId)?.screen : undefined]
|
|
762
|
+
.filter((x) => Boolean(x))
|
|
763
|
+
.join(' / ');
|
|
764
|
+
const refs = f.captureId ? [{ type: 'capture', captureId: f.captureId }] : [];
|
|
765
|
+
const grey = f.basis === 'unconfirmed' || f.basis === 'absent';
|
|
766
|
+
const why = grey && f.note ? `<div class="why">${e(f.note)}</div>` : '';
|
|
767
|
+
const excluded = f.excludedReason
|
|
768
|
+
? `<div class="why">이번 시안에서 뺌: ${e(f.excludedReason)}</div>`
|
|
769
|
+
: '';
|
|
770
|
+
return [
|
|
771
|
+
`<span class="chip kind-${f.kind}">${FINDING_KIND_LABEL[f.kind]}</span>${e(VIEWPOINT_LABEL[f.viewpoint])}`,
|
|
772
|
+
`<div class="cell${grey ? ' grey' : ''}">${basisChip(f.basis)}${e(f.text)}${evidenceHtml([...f.evidence, ...refs])}${why}${excluded}${f.captureId ? thumbsHtml([f.captureId]) : ''}</div>`,
|
|
773
|
+
e(where),
|
|
774
|
+
f.concrete ? e(f.concrete) : '',
|
|
775
|
+
];
|
|
776
|
+
};
|
|
777
|
+
for (const ref of research.references) {
|
|
778
|
+
const items = research.wowPain.items.filter((w) => w.referenceId === ref.id);
|
|
779
|
+
const found = research.wowPain.findings.filter((f) => f.referenceId === ref.id);
|
|
780
|
+
if (items.length === 0 && found.length === 0)
|
|
781
|
+
continue;
|
|
782
|
+
parts.push(`<div class="card"><h3>${e(refLabel(ref.id))}</h3>`);
|
|
783
|
+
for (const w of items) {
|
|
784
|
+
parts.push(table(['', '와우 포인트', '페인 포인트'], [
|
|
785
|
+
['사용자 관점', list(w.userWow), list(w.userPain)],
|
|
786
|
+
['비즈니스 관점', list(w.bizWow), list(w.bizPain)],
|
|
787
|
+
]));
|
|
788
|
+
}
|
|
789
|
+
if (found.length > 0) {
|
|
790
|
+
parts.push(table(['구분', '발견', '구좌나 단계', '조작과 수치'], found.map(findingRow)));
|
|
791
|
+
}
|
|
792
|
+
parts.push('</div>');
|
|
793
|
+
}
|
|
794
|
+
// 5. 레퍼런스가 그렇게 한 이유
|
|
795
|
+
parts.push('<h2>5. 레퍼런스가 그렇게 한 이유</h2>');
|
|
796
|
+
for (const r of research.reasons) {
|
|
797
|
+
parts.push(`<div class="card"><h3>${e(refLabel(r.referenceId))}</h3>` +
|
|
798
|
+
table(['항목', '내용'], [
|
|
799
|
+
['비즈니스', e(r.business)],
|
|
800
|
+
['화면', e(r.screens)],
|
|
801
|
+
['우리와 다른 점', e(r.difference)],
|
|
802
|
+
['가져올 것', list(r.take)],
|
|
803
|
+
['가져오지 않을 것', list(r.skip)],
|
|
804
|
+
]) +
|
|
805
|
+
'</div>');
|
|
806
|
+
}
|
|
807
|
+
if (hasFlowSection(research)) {
|
|
808
|
+
for (const t of buildFlowTables(research, opts.audience === 'shared')) {
|
|
809
|
+
parts.push(`<h3>${e(t.heading)}</h3>`);
|
|
810
|
+
parts.push(table(t.head, t.rows.map((row) => row.map((cell) => e(cell).replace(/\n/g, '<br>')))));
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
// 6. 판단
|
|
814
|
+
parts.push('<h2>6. 판단 (해석)</h2>');
|
|
815
|
+
for (const j of research.judgment) {
|
|
816
|
+
const hold = j.confidence === 'hold' && j.holdUntil ? ` (${e(j.holdUntil)} 결정)` : '';
|
|
817
|
+
const rank = j.rank ? `<span class="chip">우선순위 ${j.rank}</span>` : '';
|
|
818
|
+
const todo = j.todo ? `<p>할 일: ${e(j.todo)}</p>` : '';
|
|
819
|
+
parts.push(`<div class="card"><h3>${e(j.area)} <span class="chip">${CONFIDENCE_LABEL[j.confidence]}</span>${rank}</h3>` +
|
|
820
|
+
`<p><strong>${e(j.conclusion)}</strong>${hold}</p><p>${e(j.rationale)}${evidenceHtml(j.evidence)}</p>${todo}</div>`);
|
|
821
|
+
}
|
|
822
|
+
// 7. 검증 제안
|
|
823
|
+
parts.push('<h2>7. 검증 제안</h2>');
|
|
824
|
+
const caseById = new Map(research.metricCases.map((m) => [m.id, m]));
|
|
825
|
+
if (research.metricCases.length > 0) {
|
|
826
|
+
parts.push('<h3>타사 지표 사례</h3>');
|
|
827
|
+
parts.push(table(['회사', '지표', '분모', '값', '바꾼 것', '시점'], research.metricCases.map((m) => {
|
|
828
|
+
const grey = m.basis === 'unconfirmed' || m.basis === 'absent';
|
|
829
|
+
const why = grey && m.note ? `<div class="why">${e(m.note)}</div>` : '';
|
|
830
|
+
return [
|
|
831
|
+
e(m.company),
|
|
832
|
+
e(m.metric),
|
|
833
|
+
e(m.denominator ?? ''),
|
|
834
|
+
`<div class="cell${grey ? ' grey' : ''}">${basisChip(m.basis)}${e(m.value)}${evidenceHtml(m.evidence)}${why}</div>`,
|
|
835
|
+
e(m.change ?? ''),
|
|
836
|
+
e(m.period ?? ''),
|
|
837
|
+
];
|
|
838
|
+
})));
|
|
839
|
+
}
|
|
840
|
+
research.verification.forEach((v, i) => {
|
|
841
|
+
const cases = v.metricCaseIds
|
|
842
|
+
.map((id) => caseById.get(id))
|
|
843
|
+
.filter((m) => Boolean(m))
|
|
844
|
+
.map((m) => `${m.company} ${m.metric} ${m.value}`);
|
|
845
|
+
parts.push(`<div class="card"><h3>가설 ${i + 1}. ${e(v.hypothesis)}</h3>` +
|
|
846
|
+
`<p>볼 지표: ${e(v.metric)}</p><p>나눠 볼 조건: ${e(v.splitBy)}</p>` +
|
|
847
|
+
(cases.length > 0 ? `<p>근거 사례: ${e(cases.join(', '))}</p>` : '') +
|
|
848
|
+
(v.sample ? `<p>표본: ${e(v.sample)}</p>` : '') +
|
|
849
|
+
(v.period ? `<p>기간: ${e(v.period)}</p>` : '') +
|
|
850
|
+
(v.successCriteria ? `<p>성공 기준: ${e(v.successCriteria)}</p>` : '') +
|
|
851
|
+
(v.expectedLiftPp !== undefined ? `<p>기대 효과: +${v.expectedLiftPp}%p</p>` : '') +
|
|
852
|
+
(v.events.length > 0 ? `<p>볼 이벤트: ${e(v.events.join(', '))}</p>` : '') +
|
|
853
|
+
(v.caveat ? `<p class="sub">${e(v.caveat)}</p>` : '') +
|
|
854
|
+
'</div>');
|
|
855
|
+
});
|
|
856
|
+
if (tv && tv.tracking.length > 0) {
|
|
857
|
+
parts.push('<h3>심어야 할 이벤트</h3>');
|
|
858
|
+
parts.push(table(['이벤트', '단계', '상태', '메모'], tv.tracking.map((row) => row.map((x) => e(x)))));
|
|
859
|
+
}
|
|
860
|
+
// 8. 열린 질문
|
|
861
|
+
parts.push('<h2>8. 열린 질문</h2>');
|
|
862
|
+
if (research.openQuestions && research.openQuestions.items.length > 0) {
|
|
863
|
+
parts.push(`<p>${e(research.openQuestions.recipient)}</p><p>${e(research.openQuestions.intro)}</p>`);
|
|
864
|
+
const withOwner = research.openQuestions.items.some((q) => q.owner);
|
|
865
|
+
parts.push(table(withOwner
|
|
866
|
+
? ['질문', '임시로 둔 가정', '영향', '확인 주체']
|
|
867
|
+
: ['질문', '임시로 둔 가정', '영향'], research.openQuestions.items.map((q) => {
|
|
868
|
+
const base = [e(q.question), e(q.temporary), e(q.affects)];
|
|
869
|
+
if (!withOwner)
|
|
870
|
+
return base;
|
|
871
|
+
const owner = q.owner
|
|
872
|
+
? `<span class="chip own-${q.owner}">${OWNER_LABEL[q.owner]}</span>${q.ownerNote ? e(q.ownerNote) : ''}`
|
|
873
|
+
: '';
|
|
874
|
+
return [...base, owner];
|
|
875
|
+
})));
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
parts.push('<p class="todo">열린 질문이 없습니다.</p>');
|
|
879
|
+
}
|
|
880
|
+
if (standalone)
|
|
881
|
+
parts.length = bodyStart;
|
|
882
|
+
// 영상 분석
|
|
883
|
+
if (hasVideoSection(research)) {
|
|
884
|
+
parts.push(`<h2>${e(videoSectionTitle(research))}</h2>`);
|
|
885
|
+
const tables = buildVideoTables(research);
|
|
886
|
+
if (tables.length === 0)
|
|
887
|
+
parts.push('<p class="todo">영상 분석이 없습니다.</p>');
|
|
888
|
+
for (const t of tables) {
|
|
889
|
+
parts.push(`<h3>${e(t.heading)}</h3>`);
|
|
890
|
+
parts.push(table(t.head, t.rows.map((row) => row.map((cell) => e(cell).replace(/\n/g, '<br>')))));
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
warnings.push(...budget.warnings);
|
|
894
|
+
const html = '<!doctype html>\n<html lang="ko">\n<head>\n<meta charset="utf-8">\n' +
|
|
895
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
|
|
896
|
+
`<title>${e(research.meta.title)}</title>\n<style>${CSS}${CANVAS_CSS}</style>\n</head>\n<body>\n<main>\n` +
|
|
897
|
+
boardMarkup(parts, e(research.meta.title)) +
|
|
898
|
+
`\n</main>\n<script>${CANVAS_JS}</script>\n</body>\n</html>\n`;
|
|
899
|
+
return { ok: true, html, warnings };
|
|
900
|
+
}
|
|
901
|
+
//# sourceMappingURL=render-html.js.map
|