kakaotalk-chat-analyzer 0.2.0 → 0.2.2

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.
@@ -5,10 +5,11 @@ export function renderReportHtml(data) {
5
5
  <head>
6
6
  <meta charset="utf-8">
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <meta name="color-scheme" content="light dark">
8
9
  <title>카카오톡 대화 리포트 · kca</title>
9
10
  <style>
10
11
  :root {
11
- color-scheme: light;
12
+ color-scheme: light dark;
12
13
  --bg: #f4f1ea;
13
14
  --bg2: #e8e2d6;
14
15
  --ink: #141a1f;
@@ -19,59 +20,345 @@ export function renderReportHtml(data) {
19
20
  --accent2: #c45c2a;
20
21
  --gold: #b8860b;
21
22
  --shadow: 0 18px 50px rgba(20, 26, 31, 0.08);
23
+ --bar-bg: #e5dfd4;
24
+ --glow: rgba(15, 107, 92, 0.18);
22
25
  font-family: "Pretendard Variable", Pretendard, "Apple SD Gothic Neo", "Malgun Gothic", ui-sans-serif, system-ui, sans-serif;
23
26
  }
27
+ @media (prefers-color-scheme: dark) {
28
+ :root:not([data-theme="light"]) {
29
+ --bg: #070a0e;
30
+ --bg2: #0e1218;
31
+ --ink: #e9eef5;
32
+ --muted: #8b98a8;
33
+ --line: rgba(255, 255, 255, 0.1);
34
+ --panel: rgba(255, 255, 255, 0.045);
35
+ --accent: #3ee8c5;
36
+ --accent2: #ff9f43;
37
+ --gold: #fbbf24;
38
+ --shadow: 0 28px 90px rgba(0, 0, 0, 0.55);
39
+ --bar-bg: rgba(255, 255, 255, 0.08);
40
+ --glow: rgba(62, 232, 197, 0.15);
41
+ }
42
+ }
43
+ :root[data-theme="dark"] {
44
+ --bg: #070a0e;
45
+ --bg2: #0e1218;
46
+ --ink: #e9eef5;
47
+ --muted: #8b98a8;
48
+ --line: rgba(255, 255, 255, 0.1);
49
+ --panel: rgba(255, 255, 255, 0.045);
50
+ --accent: #3ee8c5;
51
+ --accent2: #ff9f43;
52
+ --gold: #fbbf24;
53
+ --shadow: 0 28px 90px rgba(0, 0, 0, 0.55);
54
+ --bar-bg: rgba(255, 255, 255, 0.08);
55
+ --glow: rgba(62, 232, 197, 0.15);
56
+ }
57
+ :root[data-theme="light"] {
58
+ --bg: #f4f1ea;
59
+ --bg2: #e8e2d6;
60
+ --ink: #141a1f;
61
+ --muted: #5c6670;
62
+ --line: #d4cdc2;
63
+ --panel: #fffcf7;
64
+ --accent: #0f6b5c;
65
+ --accent2: #c45c2a;
66
+ --gold: #b8860b;
67
+ --shadow: 0 18px 50px rgba(20, 26, 31, 0.08);
68
+ --bar-bg: #e5dfd4;
69
+ --glow: rgba(15, 107, 92, 0.18);
70
+ }
24
71
  * { box-sizing: border-box; }
25
- body { margin: 0; background: radial-gradient(1200px 500px at 10% -10%, rgba(15,107,92,0.12), transparent), linear-gradient(180deg, var(--bg), var(--bg2)); color: var(--ink); }
72
+ body {
73
+ margin: 0;
74
+ background:
75
+ radial-gradient(1000px 520px at 12% -8%, var(--glow), transparent 55%),
76
+ radial-gradient(800px 420px at 92% 0%, rgba(196, 92, 42, 0.12), transparent 45%),
77
+ linear-gradient(180deg, var(--bg), var(--bg2));
78
+ color: var(--ink);
79
+ transition: background 0.28s ease, color 0.2s ease;
80
+ }
26
81
  main { width: min(1200px, calc(100% - 36px)); margin: 0 auto; padding: 36px 0 56px; }
82
+ .toolbar {
83
+ display: flex;
84
+ flex-wrap: wrap;
85
+ align-items: center;
86
+ gap: 10px;
87
+ margin-bottom: 18px;
88
+ padding: 10px 14px;
89
+ border-radius: 12px;
90
+ border: 1px solid var(--line);
91
+ background: var(--panel);
92
+ box-shadow: var(--shadow);
93
+ }
94
+ .toolbar-label { font-size: 12px; font-weight: 700; color: var(--muted); margin-right: 4px; }
95
+ .theme-btn {
96
+ font: inherit;
97
+ font-size: 12px;
98
+ font-weight: 650;
99
+ padding: 7px 12px;
100
+ border-radius: 999px;
101
+ border: 1px solid var(--line);
102
+ background: transparent;
103
+ color: var(--ink);
104
+ cursor: pointer;
105
+ }
106
+ .theme-btn:hover { border-color: var(--accent); color: var(--accent); }
27
107
  .hero { display: grid; gap: 20px; grid-template-columns: 1.35fr 1fr; align-items: stretch; padding-bottom: 28px; }
28
108
  h1 { margin: 0; font-size: clamp(28px, 4.2vw, 48px); line-height: 1.08; letter-spacing: -0.03em; font-weight: 800; }
29
109
  .sub { margin: 12px 0 0; color: var(--muted); line-height: 1.65; font-size: 15px; max-width: 52ch; }
30
110
  .badge-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
31
- .badge { font-size: 12px; padding: 6px 10px; border-radius: 999px; border: 1px solid var(--line); background: rgba(255,255,255,0.65); color: var(--muted); }
111
+ .badge { font-size: 12px; padding: 6px 10px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); color: var(--muted); }
32
112
  .card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px 20px; box-shadow: var(--shadow); }
33
113
  .side-card { display: flex; flex-direction: column; gap: 10px; justify-content: center; }
34
114
  .side-card p { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.5; }
35
115
  .side-card strong { color: var(--ink); }
36
116
  h2 { margin: 0 0 12px; font-size: 17px; font-weight: 750; letter-spacing: -0.02em; }
37
117
  .grid { display: grid; gap: 14px; }
38
- .metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); }
39
- .metrics6 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
40
118
  .two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
41
119
  .three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
42
- .metric .label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 6px; }
43
- .metric .value { font-size: 26px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
44
- .metric .sub { display: block; color: var(--muted); font-size: 11px; margin-top: 6px; }
45
120
  .highlights { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
46
121
  .highlights li { padding: 12px 14px; border-radius: 10px; background: linear-gradient(120deg, rgba(15,107,92,0.08), rgba(196,92,42,0.06)); border: 1px solid rgba(15,107,92,0.15); font-size: 14px; line-height: 1.55; }
47
122
  .highlights strong { color: var(--accent); font-weight: 750; }
48
123
  .bars { display: grid; gap: 8px; }
49
124
  .bar-row { display: grid; grid-template-columns: minmax(72px, 1fr) minmax(0, 2.2fr) 52px; gap: 10px; align-items: center; min-height: 22px; }
50
125
  .bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
51
- .bar-track { height: 9px; background: #e5dfd4; border-radius: 999px; overflow: hidden; }
52
- .bar-fill { height: 100%; width: var(--w); background: linear-gradient(90deg, var(--accent), #1a9d87); border-radius: inherit; }
126
+ .bar-track { height: 9px; background: var(--bar-bg); border-radius: 999px; overflow: hidden; }
127
+ .bar-fill {
128
+ display: block;
129
+ height: 100%;
130
+ min-width: 2px;
131
+ background: linear-gradient(90deg, var(--accent), #1a9d87);
132
+ border-radius: inherit;
133
+ }
53
134
  .bar-value { text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; }
54
- .calendar { display: grid; gap: 3px; grid-template-columns: repeat(auto-fill, minmax(34px, 1fr)); }
55
- .day { aspect-ratio: 1; border-radius: 6px; background: color-mix(in srgb, var(--accent) var(--level), #e5dfd4); display: grid; place-items: center; font-size: 9px; color: #0c2a24; font-weight: 650; }
56
- .hours { display: grid; grid-template-columns: repeat(24, 1fr); gap: 3px; align-items: end; height: 140px; }
57
- .hour { min-width: 0; background: linear-gradient(180deg, var(--accent2), #e07a45); border-radius: 3px 3px 0 0; height: var(--h); }
135
+ .chart-hint { margin: 0 0 10px; font-size: 12px; color: var(--muted); line-height: 1.45; }
136
+ .chart-hint strong { color: var(--ink); font-weight: 700; }
137
+ .calendar-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 4px; margin: 0 -2px; }
138
+ .calendar {
139
+ display: grid;
140
+ gap: 4px;
141
+ width: max-content;
142
+ max-width: 100%;
143
+ box-sizing: border-box;
144
+ }
145
+ .day {
146
+ border-radius: 6px;
147
+ display: flex;
148
+ flex-direction: column;
149
+ align-items: center;
150
+ justify-content: center;
151
+ gap: 2px;
152
+ min-width: 42px;
153
+ min-height: 46px;
154
+ padding: 4px 3px;
155
+ font-weight: 650;
156
+ border: 1px solid var(--line);
157
+ }
158
+ .day-k { font-size: 10px; line-height: 1.15; font-weight: 750; letter-spacing: -0.02em; }
159
+ .day-n { font-size: 12px; line-height: 1; font-weight: 800; font-variant-numeric: tabular-nums; }
160
+ .hours-wrap { display: flex; flex-direction: column; gap: 0; }
161
+ .hours-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 2px; margin: 0 -2px; }
162
+ .hours-chart-inner { min-width: 520px; display: flex; flex-direction: column; gap: 5px; }
163
+ .hours-bars { display: grid; grid-template-columns: repeat(24, 1fr); gap: 3px; align-items: end; height: 140px; }
164
+ .hour {
165
+ min-width: 0;
166
+ width: 100%;
167
+ align-self: end;
168
+ background: linear-gradient(180deg, var(--accent2), #e07a45);
169
+ border-radius: 3px 3px 0 0;
170
+ }
171
+ .hours-labels {
172
+ display: grid;
173
+ grid-template-columns: repeat(24, 1fr);
174
+ gap: 2px;
175
+ font-size: 9px;
176
+ line-height: 1.15;
177
+ color: var(--muted);
178
+ text-align: center;
179
+ font-variant-numeric: tabular-nums;
180
+ }
181
+ .hours-labels span { min-width: 0; }
58
182
  .table { width: 100%; border-collapse: collapse; font-size: 13px; }
59
183
  .table th, .table td { text-align: left; border-bottom: 1px solid var(--line); padding: 9px 6px; }
60
184
  .table th { color: var(--muted); font-weight: 650; font-size: 11px; text-transform: none; }
61
185
  .table td.num { text-align: right; font-variant-numeric: tabular-nums; }
186
+ .self-serve {
187
+ margin-top: 14px;
188
+ border: 1px dashed rgba(15, 107, 92, 0.38);
189
+ border-radius: 12px;
190
+ padding: 16px 18px;
191
+ background: linear-gradient(135deg, rgba(15, 107, 92, 0.06), rgba(196, 92, 42, 0.04));
192
+ font-size: 13px;
193
+ line-height: 1.6;
194
+ color: var(--muted);
195
+ }
196
+ .self-serve h2 { margin: 0 0 10px; font-size: 16px; font-weight: 750; color: var(--ink); letter-spacing: -0.02em; }
197
+ .self-serve p { margin: 0 0 8px; }
198
+ .self-serve ol { margin: 0 0 10px; padding-left: 1.25rem; }
199
+ .self-serve li { margin: 4px 0; }
200
+ .self-serve code { font-size: 11.5px; background: var(--bar-bg); padding: 1px 5px; border-radius: 4px; color: var(--ink); }
201
+ .self-serve .cmd {
202
+ margin: 10px 0 12px;
203
+ padding: 11px 13px;
204
+ border-radius: 8px;
205
+ background: var(--bar-bg);
206
+ border: 1px solid var(--line);
207
+ font-family: ui-monospace, "Cascadia Code", "Consolas", monospace;
208
+ font-size: 12px;
209
+ line-height: 1.45;
210
+ color: var(--ink);
211
+ overflow-x: auto;
212
+ white-space: pre-wrap;
213
+ overflow-wrap: anywhere;
214
+ word-break: break-word;
215
+ }
216
+ .self-serve .links { margin: 10px 0 0; font-size: 12px; }
217
+ .self-serve .links a { font-weight: 650; }
218
+ .insight-hero { position: relative; overflow: hidden; }
219
+ .insight-hero::before {
220
+ content: "";
221
+ position: absolute;
222
+ inset: -40% 40% auto -20%;
223
+ height: 120%;
224
+ background: radial-gradient(closest-side, var(--glow), transparent 70%);
225
+ pointer-events: none;
226
+ }
227
+ .insight-head { display: flex; flex-wrap: wrap; gap: 20px; justify-content: space-between; align-items: flex-start; position: relative; z-index: 1; }
228
+ .insight-lede { margin: 8px 0 0; color: var(--muted); font-size: 14px; line-height: 1.65; max-width: 62ch; }
229
+ .insight-grid {
230
+ display: grid;
231
+ grid-template-columns: repeat(3, minmax(0, 1fr));
232
+ gap: 10px;
233
+ margin-top: 18px;
234
+ position: relative;
235
+ z-index: 1;
236
+ }
237
+ @media (min-width: 1080px) {
238
+ .insight-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
239
+ }
240
+ @media (max-width: 720px) { .insight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
241
+ .ins-metric {
242
+ padding: 12px 14px;
243
+ border-radius: 12px;
244
+ border: 1px solid var(--line);
245
+ background: linear-gradient(145deg, rgba(255,255,255,0.04), transparent);
246
+ }
247
+ .ins-m-label { display: block; font-size: 11px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
248
+ .ins-m-val { font-size: 22px; font-weight: 850; letter-spacing: -0.03em; line-height: 1.1; }
249
+ .ins-m-sub { display: block; font-size: 11px; color: var(--muted); margin-top: 6px; }
250
+ .insight-split { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 22px; position: relative; z-index: 1; }
251
+ @media (max-width: 900px) { .insight-split { grid-template-columns: 1fr; } }
252
+ .insight-sub { margin: 0 0 6px; font-size: 14px; font-weight: 750; color: var(--ink); }
253
+ .daypart-bar { display: flex; height: 14px; border-radius: 999px; overflow: hidden; border: 1px solid var(--line); }
254
+ .dp-seg { min-width: 2px; height: 100%; transition: opacity 0.2s; }
255
+ .dp-seg:hover { opacity: 0.85; }
256
+ .daypart-legend { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 10px 16px; font-size: 12px; color: var(--muted); }
257
+ .daypart-legend li { display: flex; align-items: center; gap: 6px; }
258
+ .daypart-legend i { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
259
+ .rh-wrap { position: relative; width: 108px; height: 108px; flex-shrink: 0; }
260
+ .rh-ring {
261
+ width: 108px;
262
+ height: 108px;
263
+ border-radius: 50%;
264
+ background: conic-gradient(from -90deg, var(--accent) calc(var(--p) * 1%), var(--bar-bg) 0);
265
+ display: grid;
266
+ place-items: center;
267
+ box-shadow: 0 0 0 1px var(--line) inset;
268
+ }
269
+ .rh-ring span {
270
+ width: 74px;
271
+ height: 74px;
272
+ border-radius: 50%;
273
+ background: var(--panel);
274
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
275
+ border: 1px solid var(--line);
276
+ }
277
+ .rh-cap {
278
+ position: absolute;
279
+ inset: 0;
280
+ display: flex;
281
+ flex-direction: column;
282
+ align-items: center;
283
+ justify-content: center;
284
+ pointer-events: none;
285
+ font-size: 11px;
286
+ color: var(--muted);
287
+ font-weight: 700;
288
+ }
289
+ .rh-cap span { font-size: 22px; font-weight: 900; color: var(--ink); letter-spacing: -0.04em; }
290
+ .rh-cap small { font-size: 11px; font-weight: 700; color: var(--muted); margin-left: 1px; }
291
+ .sc-plot {
292
+ position: relative;
293
+ height: 200px;
294
+ border-radius: 12px;
295
+ border: 1px dashed var(--line);
296
+ background: linear-gradient(180deg, rgba(0,0,0,0.02), transparent);
297
+ margin-top: 4px;
298
+ }
299
+ .sc-dot {
300
+ position: absolute;
301
+ width: 12px;
302
+ height: 12px;
303
+ margin-left: -6px;
304
+ margin-top: -6px;
305
+ border-radius: 50%;
306
+ box-shadow: 0 0 0 2px var(--panel), 0 4px 12px rgba(0, 0, 0, 0.18);
307
+ cursor: default;
308
+ }
309
+ .sc-axis { position: absolute; font-size: 10px; color: var(--muted); font-weight: 650; }
310
+ .sc-x { right: 8px; bottom: 6px; }
311
+ .sc-y { left: 8px; top: 8px; }
312
+ .fact-card { margin-bottom: 14px; padding: 14px 16px; }
313
+ .fact-card h2 { margin: 0 0 6px; font-size: 15px; }
314
+ .fact-hint { margin: 0 0 10px; font-size: 11px; color: var(--muted); line-height: 1.45; }
315
+ .fact-grid {
316
+ display: grid;
317
+ grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
318
+ gap: 5px 7px;
319
+ }
320
+ .fact-cell {
321
+ padding: 5px 7px;
322
+ border-radius: 8px;
323
+ border: 1px solid var(--line);
324
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.04), transparent);
325
+ min-height: 42px;
326
+ }
327
+ .fact-cell b {
328
+ display: block;
329
+ color: var(--muted);
330
+ font-size: 9px;
331
+ font-weight: 750;
332
+ letter-spacing: 0.03em;
333
+ line-height: 1.15;
334
+ margin-bottom: 2px;
335
+ }
336
+ .fact-cell span {
337
+ font-weight: 850;
338
+ font-size: 12.5px;
339
+ font-variant-numeric: tabular-nums;
340
+ letter-spacing: -0.02em;
341
+ }
62
342
  footer { margin-top: 28px; color: var(--muted); font-size: 11px; line-height: 1.5; }
63
343
  @media (max-width: 900px) {
64
- .hero, .two, .three, .metrics, .metrics6 { grid-template-columns: 1fr; }
65
- .hours { grid-template-columns: repeat(12, 1fr); height: 120px; }
344
+ .hero, .two, .three { grid-template-columns: 1fr; }
345
+ .hours-chart-inner { min-width: 480px; }
66
346
  }
67
347
  </style>
68
348
  </head>
69
349
  <body>
70
350
  <main>
351
+ <div class="toolbar" role="toolbar" aria-label="표시 테마">
352
+ <span class="toolbar-label">테마</span>
353
+ <button type="button" class="theme-btn" data-theme-set="light">라이트</button>
354
+ <button type="button" class="theme-btn" data-theme-set="dark">다크</button>
355
+ <button type="button" class="theme-btn" data-theme-set="system">시스템</button>
356
+ </div>
357
+ ${renderFactMatrix(data)}
71
358
  <header class="hero">
72
359
  <div>
73
360
  <h1>카카오톡 대화 리포트</h1>
74
- <p class="sub">원문 메시지·전체 URL은 저장하지 않습니다. 참여자는 <strong>부분 마스킹된 표시명</strong>으로만 보여요. 아래는 집계·리듬·키워드 중심의 인사이트입니다.</p>
361
+ <p class="sub">원문 메시지·전체 URL은 저장하지 않습니다. 참여자는 <strong>부분 마스킹된 표시명</strong>으로만 보여요. 핵심 수치는 상단 <strong>팩트 매트릭스</strong>에 모았고, 아래는 차트·고급 인사이트입니다.</p>
75
362
  <div class="badge-row">
76
363
  <span class="badge">프라이버시: ${escapeHtml(privacyLabel(data.privacy))}</span>
77
364
  <span class="badge">인코딩: ${escapeHtml(data.source.encoding)}</span>
@@ -89,21 +376,7 @@ export function renderReportHtml(data) {
89
376
  ? `<section class="card" style="margin-bottom:16px"><h2>하이라이트</h2><ul class="highlights">${data.highlights.map((h) => `<li>${renderHighlightLine(h)}</li>`).join("")}</ul></section>`
90
377
  : ""}
91
378
 
92
- <section class="grid metrics" style="margin-bottom:14px">
93
- ${metric("총 메시지", formatNumber(data.summary.totalMessages), `활동일 ${formatNumber(data.summary.activeDays)}일`)}
94
- ${metric("참여자", formatNumber(data.summary.participants), "부분 마스킹 표시")}
95
- ${metric("평균 길이", `${data.summary.averageMessageLength}`, "글자 수 기준")}
96
- ${metric("URL 포함", formatNumber(data.summary.messagesWithLinks), "메시지 수")}
97
- </section>
98
-
99
- <section class="grid metrics6" style="margin-bottom:14px">
100
- ${metric("활동일당 평균", `${data.summary.messagesPerActiveDay}`, "메시지 / 활동일")}
101
- ${metric("최장 연속일", `${data.summary.longestActiveStreakDays}`, "메시지가 있었던 날 기준")}
102
- ${metric("심야 비중", `${data.summary.nightSharePercent}%`, "23~05시")}
103
- ${metric("응답 간격 중앙값", data.summary.medianReplyGapMinutes !== null ? `${data.summary.medianReplyGapMinutes}분` : "—", "연속 메시지 기준")}
104
- ${metric("피크 타임", data.summary.peakHour !== null ? `${data.summary.peakHour}시` : "—", "가장 붐빈 시각")}
105
- ${metric("이모지 느낌", formatNumber(data.summary.emojiMessages), "감지된 메시지")}
106
- </section>
379
+ ${renderInsightDeck(data)}
107
380
 
108
381
  <section class="grid two" style="margin-bottom:14px">
109
382
  ${panel("일별 활동 히트맵", renderDaily(data.daily))}
@@ -125,6 +398,37 @@ export function renderReportHtml(data) {
125
398
  ${panel("키워드 스냅샷", renderCountBars(data.keywords))}
126
399
  </section>
127
400
 
401
+ ${renderSelfServeCallout()}
402
+
403
+ <script>
404
+ (function () {
405
+ var KEY = "kca-report-theme";
406
+ var root = document.documentElement;
407
+ function apply(v) {
408
+ if (v === "system" || !v) {
409
+ root.removeAttribute("data-theme");
410
+ try {
411
+ localStorage.removeItem(KEY);
412
+ } catch (e) {}
413
+ } else {
414
+ root.setAttribute("data-theme", v);
415
+ try {
416
+ localStorage.setItem(KEY, v);
417
+ } catch (e) {}
418
+ }
419
+ }
420
+ try {
421
+ var s = localStorage.getItem(KEY);
422
+ if (s === "dark" || s === "light") root.setAttribute("data-theme", s);
423
+ } catch (e) {}
424
+ document.querySelectorAll(".theme-btn").forEach(function (btn) {
425
+ btn.addEventListener("click", function () {
426
+ apply(btn.getAttribute("data-theme-set") || "system");
427
+ });
428
+ });
429
+ })();
430
+ </script>
431
+
128
432
  <script type="application/json" id="report-data">${escapeJsonForHtml(data)}</script>
129
433
  <footer>${escapeHtml(data.source.fileName)} · 경고 ${data.source.warnings}건 · 본 리포트는 통계 목적이며 법적·회계적 증빙으로 사용할 수 없습니다.</footer>
130
434
  </main>
@@ -136,6 +440,145 @@ export function renderReportHtml(data) {
136
440
  }
137
441
  return html;
138
442
  }
443
+ function renderFactMatrix(data) {
444
+ const s = data.summary;
445
+ const ins = data.insights;
446
+ const cells = [
447
+ ["총 메시지", formatNumber(s.totalMessages)],
448
+ ["참여자", formatNumber(s.participants)],
449
+ ["활동일", formatNumber(s.activeDays)],
450
+ ["활동일당", String(s.messagesPerActiveDay)],
451
+ ["캘린더 밀도", ins.densityMessagesPerCalendarDay === null ? "—" : String(ins.densityMessagesPerCalendarDay)],
452
+ ["링크·100", String(ins.linksPer100)],
453
+ ["첨부·100", String(ins.attachmentsPer100)],
454
+ ["사진÷첨부%", ins.photoShareOfAllAttachmentMarkers === null ? "—" : `${ins.photoShareOfAllAttachmentMarkers}%`],
455
+ ["참여 중앙값", ins.medianMessagesPerParticipant === null ? "—" : String(ins.medianMessagesPerParticipant)],
456
+ ["리듬 점수", `${ins.rhythmScore}/100`],
457
+ ["주말%", `${ins.weekendSharePercent}%`],
458
+ ["심야%", `${s.nightSharePercent}%`],
459
+ ["1분내 응답%", ins.burstGapUnder1mPercent === null ? "—" : `${ins.burstGapUnder1mPercent}%`],
460
+ ["60분+ 간격%", ins.gapOver60mPercent === null ? "—" : `${ins.gapOver60mPercent}%`],
461
+ ["독백3+%", `${ins.monologueMessagesPercent}%`],
462
+ ["간격 중앙", s.medianReplyGapMinutes === null ? "—" : `${s.medianReplyGapMinutes}분`],
463
+ ["간격 P90", ins.replyGapP90Minutes === null ? "—" : `${ins.replyGapP90Minutes}분`],
464
+ ["간격 CV", ins.replyGapCoeffVariation === null ? "—" : String(ins.replyGapCoeffVariation)],
465
+ ["활성 시각 수", String(ins.activeHoursCount)],
466
+ ["피크 시각", s.peakHour === null ? "—" : `${s.peakHour}시`],
467
+ ["최장 연속일", String(s.longestActiveStreakDays)],
468
+ ["최장 공백", ins.maxSilenceBetweenActiveDays === null ? "—" : `${ins.maxSilenceBetweenActiveDays}일`],
469
+ ["피크일 점유", `${ins.peakDaySharePercent}%`],
470
+ ["도메인 종류", String(ins.uniqueDomainCount)],
471
+ ["도메인 H", ins.linkDomainEntropyBits === null ? "—" : `${ins.linkDomainEntropyBits} bit`],
472
+ ["키워드 1위%", ins.keywordTop1SharePercent === null ? "—" : `${ins.keywordTop1SharePercent}%`],
473
+ ["상위3 점유", `${ins.top3ParticipantSharePercent}%`],
474
+ ["Gini", ins.participantGini === null ? "—" : String(ins.participantGini)],
475
+ ["화자전환·100", String(ins.speakerSwitchRatePer100)],
476
+ ["질문느낌·100", String(ins.questionLikeMessagesPer100)],
477
+ ["이모지", formatNumber(s.emojiMessages)],
478
+ ["평균 길이", String(s.averageMessageLength)],
479
+ ["URL 포함 건", formatNumber(s.messagesWithLinks)],
480
+ ["첨부 포함 건", formatNumber(s.messagesWithAttachments)],
481
+ ];
482
+ const inner = cells
483
+ .map(([k, v]) => `<div class="fact-cell"><b>${escapeHtml(k)}</b><span>${escapeHtml(v)}</span></div>`)
484
+ .join("");
485
+ return `<section class="card fact-card" aria-label="핵심 지표 요약">
486
+ <h2>팩트 매트릭스</h2>
487
+ <p class="fact-hint">외부 API·무거운 모델 없이, 메시지·시간·메타만으로 계산한 <strong>운영형 KPI</strong> 밀집 뷰입니다. MPU·응답 분포·첨부 구성 등 대시보드에서 자주 보는 축을 한 화면에 모았습니다.</p>
488
+ <div class="fact-grid">${inner}</div>
489
+ </section>`;
490
+ }
491
+ function renderInsightDeck(data) {
492
+ const ins = data.insights;
493
+ const giniStr = ins.participantGini === null ? "—" : String(ins.participantGini);
494
+ const p90 = ins.replyGapP90Minutes === null ? "—" : `${ins.replyGapP90Minutes}분`;
495
+ const silence = ins.maxSilenceBetweenActiveDays === null ? "—" : `${ins.maxSilenceBetweenActiveDays}일`;
496
+ const entropy = ins.linkDomainEntropyBits === null ? "—" : `${ins.linkDomainEntropyBits} bit`;
497
+ const density = ins.densityMessagesPerCalendarDay === null ? "—" : String(ins.densityMessagesPerCalendarDay);
498
+ const daypartBar = ins.daypartPercents
499
+ .map((d) => {
500
+ const w = Math.max(0, d.percent);
501
+ const c = daypartColor(d.key);
502
+ return `<span class="dp-seg" style="width:${w}%;background:${c}" title="${escapeHtml(d.label)} ${w}%"></span>`;
503
+ })
504
+ .join("");
505
+ const scatter = renderParticipantScatter(data.participants);
506
+ return `<section class="card insight-hero" style="margin-bottom:14px">
507
+ <div class="insight-head">
508
+ <div>
509
+ <h2>고급 인사이트 · 행동 지표</h2>
510
+ <p class="insight-lede">참여 <strong>불균형(Gini)</strong>·응답 간격 <strong>꼬리(90% 백분위)</strong>·링크 도메인 <strong>엔트로피</strong> 등, 그룹 대화 분석에서 자주 보는 지표를 CSV 집계에 맞게 넣었습니다. 원문·전체 URL은 계속 저장하지 않습니다.</p>
511
+ </div>
512
+ <div class="rh-wrap" aria-label="리듬 점수">
513
+ <div class="rh-ring" style="--p:${ins.rhythmScore}"><span></span></div>
514
+ <div class="rh-cap"><strong>리듬</strong><span>${ins.rhythmScore}<small>/100</small></span></div>
515
+ </div>
516
+ </div>
517
+ <div class="insight-grid">
518
+ ${insMetric("주말 비중", `${ins.weekendSharePercent}%`, "토·일")}
519
+ ${insMetric("참여 Gini", giniStr, "0=균등 ·↑집중")}
520
+ ${insMetric("간격 P90", p90, "연속 메시지")}
521
+ ${insMetric("최장 공백", silence, "활동일 사이")}
522
+ ${insMetric("상위3 점유", `${ins.top3ParticipantSharePercent}%`, "메시지 기준")}
523
+ ${insMetric("도메인 H", entropy, "Shannon bit")}
524
+ ${insMetric("캘린더 밀도", density, "일평균 메시지")}
525
+ ${insMetric("질문 느낌", `${ins.questionLikeMessagesPer100}/100`, "? 포함")}
526
+ ${insMetric("화자 전환", `${ins.speakerSwitchRatePer100}/100`, "메시지당 교대")}
527
+ </div>
528
+ <div class="insight-split">
529
+ <div>
530
+ <h3 class="insight-sub">시간대 세그먼트</h3>
531
+ <p class="chart-hint">하루를 네 구간으로 나눈 <strong>메시지 비중</strong>입니다.</p>
532
+ <div class="daypart-bar" role="img" aria-label="시간대 비중">${daypartBar}</div>
533
+ <ul class="daypart-legend">${ins.daypartPercents
534
+ .map((d) => `<li><i style="background:${daypartColor(d.key)}"></i>${escapeHtml(d.label)} <strong>${d.percent}%</strong></li>`)
535
+ .join("")}</ul>
536
+ </div>
537
+ <div>
538
+ <h3 class="insight-sub">참여자 말풍선 맵</h3>
539
+ <p class="chart-hint">가로 <strong>점유율(%)</strong>, 세로 <strong>평균 길이</strong> (상위 12명).</p>
540
+ ${scatter}
541
+ </div>
542
+ </div>
543
+ </section>`;
544
+ }
545
+ function insMetric(label, value, sub) {
546
+ return `<div class="ins-metric"><span class="ins-m-label">${escapeHtml(label)}</span><span class="ins-m-val">${escapeHtml(value)}</span><span class="ins-m-sub">${escapeHtml(sub)}</span></div>`;
547
+ }
548
+ function daypartColor(key) {
549
+ switch (key) {
550
+ case "dawn":
551
+ return "linear-gradient(180deg,#818cf8,#4f46e5)";
552
+ case "morning":
553
+ return "linear-gradient(180deg,#22d3ee,#0891b2)";
554
+ case "afternoon":
555
+ return "linear-gradient(180deg,#4ade80,#059669)";
556
+ case "evening":
557
+ return "linear-gradient(180deg,#fb923c,#ea580c)";
558
+ default:
559
+ return "#64748b";
560
+ }
561
+ }
562
+ function renderParticipantScatter(parts) {
563
+ const top = parts.slice(0, 12);
564
+ if (top.length === 0) {
565
+ return `<p style="margin:0;color:var(--muted);font-size:13px">데이터가 없습니다.</p>`;
566
+ }
567
+ const maxShare = Math.max(...top.map((p) => p.sharePercent), 0.1);
568
+ const maxLen = Math.max(...top.map((p) => p.averageLength), 1);
569
+ const minLen = Math.min(...top.map((p) => p.averageLength));
570
+ const lenSpan = Math.max(maxLen - minLen, 0.1);
571
+ const dots = top
572
+ .map((p, i) => {
573
+ const x = 8 + (p.sharePercent / maxShare) * 82;
574
+ const yRaw = (p.averageLength - minLen) / lenSpan;
575
+ const y = 12 + (1 - yRaw) * 76;
576
+ const hue = (i * 53) % 360;
577
+ return `<div class="sc-dot" style="left:${x}%;top:${y}%;background:hsl(${hue} 72% 52%)" title="${escapeHtml(p.alias)} · ${p.sharePercent}% · 평균 ${p.averageLength}자"></div>`;
578
+ })
579
+ .join("");
580
+ return `<div class="sc-plot">${dots}<span class="sc-axis sc-x">점유 →</span><span class="sc-axis sc-y">길이 ↑</span></div>`;
581
+ }
139
582
  function privacyLabel(mode) {
140
583
  if (mode === "public-masked")
141
584
  return "부분 마스킹(기본)";
@@ -143,22 +586,53 @@ function privacyLabel(mode) {
143
586
  return "완전 별칭(User 001)";
144
587
  return mode;
145
588
  }
146
- function metric(label, value, sub) {
147
- return `<div class="card metric"><span class="label">${escapeHtml(label)}</span><span class="value">${escapeHtml(value)}</span><span class="sub">${escapeHtml(sub)}</span></div>`;
148
- }
149
589
  function panel(title, content) {
150
590
  return `<div class="card"><h2>${escapeHtml(title)}</h2>${content}</div>`;
151
591
  }
592
+ function renderSelfServeCallout() {
593
+ const gh = "https://github.com/claudianus/kakaotalk-chat-analyzer";
594
+ const site = "https://claudianus.github.io/kakaotalk-chat-analyzer/";
595
+ const npmShort = "https://www.npmjs.com/package/kcachat";
596
+ const npmFull = "https://www.npmjs.com/package/kakaotalk-chat-analyzer";
597
+ return `<section class="card self-serve" aria-label="리포트 직접 만들기">
598
+ <h2>비슷한 리포트, 다른 대화에도 만들어보기</h2>
599
+ <p>이 페이지는 <strong>KakaoTalk Chat Analyzer</strong>(CLI 이름 <strong>kca</strong>)로 만든 <strong>집계 전용</strong> 리포트예요. 카카오톡에서 CSV로 보낸 뒤 같은 방식으로 돌려볼 수 있습니다.</p>
600
+ <ol>
601
+ <li>카카오톡에서 채팅방 → <strong>더보기(≡)</strong> → <strong>대화보내기</strong> → <strong>CSV 보내기</strong>로 파일 저장</li>
602
+ <li><strong>Node.js 22+</strong>가 있는 Mac/Windows/Linux에서 터미널을 열고, 보낸 파일 경로를 넣어 실행해 보세요.</li>
603
+ </ol>
604
+ <div class="cmd">npx kcachat@latest "./KakaoTalk_Chat_보낸파일.csv" --local</div>
605
+ <p><code>--local</code> 은 PC에만 <code>index.html</code> 을 만들고 업로드는 건너뜁니다. BrewPage 등으로 올리고 싶다면 이 플래그만 빼면 됩니다.</p>
606
+ <p>짧은 이름이 부담스럽다면 전체 패키지명으로도 동일해요: <code>npx kakaotalk-chat-analyzer@latest "./파일.csv" --local</code></p>
607
+ <p class="links">
608
+ <a href="${gh}">GitHub 소스</a>
609
+ · <a href="${npmShort}">npm · kcachat</a>
610
+ · <a href="${npmFull}">npm · kakaotalk-chat-analyzer</a>
611
+ · <a href="${site}">소개 페이지</a>
612
+ </p>
613
+ </section>`;
614
+ }
152
615
  function renderDaily(days) {
153
616
  if (days.length === 0)
154
617
  return `<p style="margin:0;color:var(--muted);font-size:13px">날짜가 있는 메시지가 없습니다.</p>`;
155
618
  const max = Math.max(...days.map((day) => day.count), 1);
156
- return `<div class="calendar">${days
619
+ const cols = `repeat(${days.length}, minmax(42px, 1fr))`;
620
+ const cells = days
157
621
  .map((day) => {
158
- const level = Math.max(8, Math.round((day.count / max) * 85));
159
- return `<div class="day" title="${escapeHtml(day.date)} · ${day.count}건" style="--level: ${level}%">${day.count}</div>`;
622
+ const ratio = day.count / max;
623
+ const alpha = Math.min(0.92, Math.max(0.1, 0.1 + ratio * 0.82));
624
+ const bg = `rgba(15, 107, 92, ${alpha.toFixed(2)})`;
625
+ const fg = ratio > 0.42 ? "#f4f8f7" : "#0c2a24";
626
+ const short = formatDayMd(day.date);
627
+ return `<div class="day" title="${escapeHtml(day.date)} · ${day.count}건" style="background-color:${bg};color:${fg}"><span class="day-k">${escapeHtml(short)}</span><span class="day-n">${day.count}</span></div>`;
160
628
  })
161
- .join("")}</div>`;
629
+ .join("");
630
+ return `<div class="calendar-wrap">
631
+ <p class="chart-hint">각 칸: <strong>월/일</strong>(위) · 해당일 <strong>메시지 수</strong>(아래). 온전한 날짜는 칸에 마우스를 올리면 툴팁으로 보입니다.</p>
632
+ <div class="calendar-scroll">
633
+ <div class="calendar" style="grid-template-columns:${cols}">${cells}</div>
634
+ </div>
635
+ </div>`;
162
636
  }
163
637
  function renderMonthly(months) {
164
638
  if (months.length === 0)
@@ -167,12 +641,29 @@ function renderMonthly(months) {
167
641
  }
168
642
  function renderHours(hours) {
169
643
  const max = Math.max(...hours, 1);
170
- return `<div class="hours">${hours
644
+ const bars = hours
171
645
  .map((count, hour) => {
172
646
  const height = Math.max(2, Math.round((count / max) * 100));
173
- return `<div class="hour" title="${hour}시 · ${count}건" style="--h: ${height}%"></div>`;
647
+ return `<div class="hour" title="${hour}시 · ${formatNumber(count)}건" style="height:${height}%"></div>`;
174
648
  })
175
- .join("")}</div>`;
649
+ .join("");
650
+ const labels = Array.from({ length: 24 }, (_, hour) => `<span title="${hour}시">${hour}</span>`).join("");
651
+ return `<div class="hours-wrap">
652
+ <p class="chart-hint">가로 한 칸이 <strong>1시간</strong>입니다. 아래 숫자는 <strong>시각(0~23시)</strong>이며, 막대 높이는 해당 시간대 메시지 수 비율입니다.</p>
653
+ <div class="hours-scroll">
654
+ <div class="hours-chart-inner">
655
+ <div class="hours-bars">${bars}</div>
656
+ <div class="hours-labels">${labels}</div>
657
+ </div>
658
+ </div>
659
+ </div>`;
660
+ }
661
+ /** YYYY-MM-DD → M/D (앞자리 0 제거) */
662
+ function formatDayMd(ymd) {
663
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd.trim());
664
+ if (!m)
665
+ return ymd;
666
+ return `${Number(m[2])}/${Number(m[3])}`;
176
667
  }
177
668
  function renderParticipants(participants) {
178
669
  if (participants.length === 0) {
@@ -189,7 +680,7 @@ function renderCountBars(items) {
189
680
  return `<div class="bars">${items
190
681
  .map((item) => {
191
682
  const width = Math.max(2, Math.round((item.count / max) * 100));
192
- return `<div class="bar-row"><span class="bar-label" title="${escapeHtml(item.label)}">${escapeHtml(item.label)}</span><span class="bar-track"><span class="bar-fill" style="--w: ${width}%"></span></span><span class="bar-value">${formatNumber(item.count)}</span></div>`;
683
+ return `<div class="bar-row"><span class="bar-label" title="${escapeHtml(item.label)}">${escapeHtml(item.label)}</span><span class="bar-track"><span class="bar-fill" style="width:${width}%"></span></span><span class="bar-value">${formatNumber(item.count)}</span></div>`;
193
684
  })
194
685
  .join("")}</div>`;
195
686
  }