PageSourceSearch

https://hourglass-deploy.vercel.app/js/report.js

js hourglass-deploy.vercel.app collected 2026-10-03 07:05:14 UTC 24,348 bytes, 570 lines download raw bytes

1// 리포트 지표 계산 + 렌더 (SPEC.md 5장)
2// 등급 없음. 그린·옐로·레드를 사용자에게 보여주지 않는다. 첫 문장은 항상 수확.
3
4// 전이 로그 → 구간 목록
5function toIntervals(transitions, durationSec) {
6  const iv = [];
7  for (let i = 0; i < transitions.length; i++) {
8    const start = transitions[i].t;
9    const end = i + 1 < transitions.length ? transitions[i + 1].t : durationSec;
10    if (end > start) {
11      iv.push({ state: transitions[i].to, start, end, signal: transitions[i].signal });
12    }
13  }
14  return iv;
15}
16
17// 같은 유형이 짧은 간격으로 조각난 구간을 하나로 병합 (깜빡임으로 인한 카드 파편화 방지)
18function coalesce(ivs, gapSec) {
19  const out = [];
20  for (const cur of [...ivs].sort((a, b) => a.start - b.start)) {
21    const last = out[out.length - 1];
22    if (last && cur.signal === last.signal && cur.start - last.end <= gapSec) {
23      last.end = Math.max(last.end, cur.end);
24    } else {
25      out.push({ ...cur });
26    }
27  }
28  return out;
29}
30
31// 신호 종류를 가리지 않고 겹치거나 맞닿은 구간을 하나로 병합 (signals 배열로 보존)
32function mergeAcrossSignals(ivs, gapSec) {
33  const out = [];
34  for (const cur of [...ivs].sort((a, b) => a.start - b.start)) {
35    const last = out[out.length - 1];
36    if (last && cur.start - last.end <= gapSec) {
37      last.end = Math.max(last.end, cur.end);
38      if (cur.signal && !last.signals.includes(cur.signal)) last.signals.push(cur.signal);
39    } else {
40      out.push({ ...cur, signals: cur.signal ? [cur.signal] : [] });
41    }
42  }
43  return out;
44}
45
46// 전체 구간 합집합 길이 (유형 겹침의 이중 계산 방지)
47function unionSec(ivs) {
48  let total = 0;
49  let curEnd = -1;
50  for (const v of [...ivs].sort((a, b) => a.start - b.start)) {
51    const s = Math.max(v.start, curEnd);
52    if (v.end > s) {
53      total += v.end - s;
54      curEnd = v.end;
55    }
56  }
57  return total;
58}
59
60export function computeMetrics(transitions, breachCounts, durationSec, hiddenIv = [], frictionIv = [], measureFailed = false) {
61  const iv = measureFailed
62    ? [] // 측정 실패: 이탈 판정 전부 무효 (전 구간 측정 불가로 처리)
63    : toIntervals(transitions, durationSec);
64  // 끊김 = 상태머신 BREACH + 움직임(2026-07-27 승격: 마찰도 끊김으로 집계, 모래 차감)
65  const hard = coalesce(iv.filter((v) => v.state === 'BREACH'), 7);
66  const motion = coalesce(
67    frictionIv.map((f) => ({ ...f, signal: 'motion' })),
68    7
69  );
70  // 신호 종류가 달라도 겹치거나 맞닿으면 사용자에겐 한 순간 → 카드도 하나로 병합
71  const breachIv = mergeAcrossSignals([...hard, ...motion], 3);
72  const breachSec = unionSec(breachIv);
73  // 탭 백그라운드 구간도 판정 불가로 합산 (카메라 판정이 멈춘 시간)
74  const hiddenSec = hiddenIv.reduce((s, v) => s + (v.end - v.start), 0);
75  const uncertainSec = measureFailed
76    ? durationSec
77    : Math.min(
78        durationSec,
79        iv
80          .filter((v) => v.state === 'UNCERTAIN')
81          .reduce((s, v) => s + (v.end - v.start), 0) + hiddenSec
82      );
83
84  // 무이탈 연속 구간 (BREACH만 끊음 — UNCERTAIN·GRACE는 유리하게 유지)
85  const cleans = [];
86  let cursor = 0;
87  for (const b of breachIv) {
88    if (b.start > cursor) cleans.push({ start: cursor, end: b.start });
89    cursor = Math.max(cursor, b.end);
90  }
91  if (cursor < durationSec) cleans.push({ start: cursor, end: durationSec });
92
93  let highlight = null;
94  for (const c of cleans) {
95    if (!highlight || c.end - c.start > highlight.end - highlight.start) highlight = c;
96  }
97
98  // 시동 시간: 최초로 연속 2분 무이탈을 이룬 구간의 시작
99  const WARMUP_NEED = 120;
100  const firstOrbit = cleans.find((c) => c.end - c.start >= WARMUP_NEED);
101  const warmupSec = firstOrbit ? firstOrbit.start : null;
102
103  // 판정은 사용자에게 유리하게: 모래 = 세션 − 이탈 (UNCERTAIN은 모래로 인정)
104  const sandSec = Math.max(0, durationSec - breachSec);
105  const judgeable = Math.max(1, durationSec - uncertainSec);
106  const densityPct = measureFailed
107    ? null // 측정 실패 시 밀도는 표시하지 않음 (0%로 오해 방지)
108    : Math.round(((durationSec - breachSec - uncertainSec) / judgeable) * 100);
109
110  // 움직임은 끊김에 통합됨 → 유형별 카운트에 motion 추가
111  const breaches = { ...breachCounts, motion: motion.length };
112
113  // 회복 시간 (v0.5-4): 끊김 종료 후 FOCUS 안정까지 걸린 평균 시간
114  const recoveries = [];
115  for (let i = 0; i < iv.length - 1; i++) {
116    if (iv[i].state !== 'BREACH') continue;
117    const back = iv.slice(i + 1).find((v) => v.state === 'FOCUS');
118    if (back) recoveries.push(back.start - iv[i].end);
119  }
120  const recoverySec = recoveries.length
121    ? Math.round(recoveries.reduce((s, v) => s + v, 0) / recoveries.length)
122    : null;
123
124  return {
125    sandSec: Math.round(sandSec),
126    densityPct: densityPct == null ? null : Math.max(0, densityPct),
127    recoverySec,
128    breachSec: Math.round(breachSec),
129    uncertainSec: Math.round(uncertainSec),
130    breachIv,
131    highlight,
132    warmupSec,
133    breaches,
134    durationSec,
135  };
136}
137
138// ── 포맷 도우미 ─────────────────────────────────────
139export function fmtMinSec(sec) {
140  sec = Math.round(sec);
141  const m = Math.floor(sec / 60);
142  const s = sec % 60;
143  if (m === 0) return `${s}초`;
144  if (s === 0) return `${m}분`;
145  return `${m}분 ${s}초`;
146}
147
148// 24시간 HH:MM (문구 톤: 숫자가 주어)
149export function fmtHM(date) {
150  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
151}
152
153// ── 렌더 ────────────────────────────────────────────
154const $ = (id) => document.getElementById(id);
155
156// ── v0.5-2 집중 리듬 곡선 ────────────────────────────
157// 시계열 state를 0~1 점수로 바꿔 30초 롤링 평균 → SVG 선그래프.
158// 차트 라이브러리 없이, 격자선 없이, 모래톤 단색.
159const STATE_SCORE = { FOCUS: 1, GRACE: 0.75, CANDIDATE: 0.6, UNCERTAIN: 0.5, BREACH: 0 };
160
161export function renderRhythm(metrics, timeseries) {
162  const box = $('rhythm-box');
163  const svg = $('rhythm-svg');
164  if (!timeseries || timeseries.length < 20 || metrics.densityPct == null) {
165    box.classList.add('hidden');
166    return;
167  }
168  box.classList.remove('hidden');
169
170  const W = 320;
171  const H = 150;
172  const TOP = 10;
173  const BOT = H - 20; // 아래는 시간 눈금 공간
174  const dur = metrics.durationSec || 900;
175  const x = (sec) => (sec / dur) * W;
176  const yOf = (score) => BOT - score * (BOT - TOP);
177
178  // 30초 롤링 평균
179  const pts = [];
180  const win = 30;
181  for (const row of timeseries) {
182    const from = row.t - win;
183    const near = timeseries.filter((r) => r.t >= from && r.t <= row.t);
184    const score =
185      near.reduce((s, r) => s + (STATE_SCORE[r.state] ?? 0.5), 0) / Math.max(1, near.length);
186    pts.push({ t: row.t, y: yOf(score) });
187  }
188  const line = pts.map((p, i) => `${i ? 'L' : 'M'}${x(p.t).toFixed(1)},${p.y.toFixed(1)}`).join(' ');
189  // 곡선 아래 면 채움 — 면적이 곧 "모은 집중"으로 읽힌다
190  const area = `${line} L${x(pts[pts.length - 1].t).toFixed(1)},${BOT} L${x(pts[0].t).toFixed(1)},${BOT} Z`;
191
192  // 팔레트: 잉크 카드(다크) 위 — 곡선은 홈 더미와 같은 초록, 보조선은 아이보리 저채도
193  const hl = metrics.highlight;
194  const hlRect =
195    hl && hl.end - hl.start >= 30
196      ? `<rect x="${x(hl.start)}" y="${TOP}" width="${x(hl.end - hl.start)}" height="${BOT - TOP}" fill="rgba(163,204,82,0.13)" rx="4"/>`
197      : '';
198  // 끊김 세로 밴드: 바로 아래 타임라인 조각과 같은 x에 놓인다 (골 ↔ 조각 연결)
199  // 코랄 = 방해 (랜딩과 같은 색 언어) — 또렷하게 보여야 골의 의미가 읽힌다
200  const breachMarks = (metrics.breachIv ?? [])
201    .map((b) => `<rect x="${x(b.start)}" y="${TOP}" width="${Math.max(3, x(b.end - b.start))}" height="${BOT - TOP}" fill="rgba(224,120,86,0.17)" rx="2"/>`)
202    .join('');
203
204  // 기준선(위=집중) + 시간 눈금 (0 · 5 · 10 · 15분)
205  const guide =
206    `<line x1="0" y1="${TOP}" x2="${W}" y2="${TOP}" stroke="rgba(247,244,236,0.12)" stroke-width="1" stroke-dasharray="2 4"/>` +
207    `<line x1="0" y1="${BOT}" x2="${W}" y2="${BOT}" stroke="rgba(247,244,236,0.2)" stroke-width="1"/>`;
208  const mins = [0, 5, 10, 15].filter((m) => m * 60 <= dur);
209  const ticks = mins
210    .map((m) => {
211      const tx = Math.min(W - 14, Math.max(2, x(m * 60) - (m ? 8 : 0)));
212      return `<text x="${tx}" y="${H - 6}" font-size="9" fill="#8f8772">${m}분</text>`;
213    })
214    .join('');
215  const axisLabels =
216    `<text x="4" y="${TOP + 11}" font-size="9" font-weight="700" fill="#a3cc52">집중</text>` +
217    `<text x="4" y="${BOT - 5}" font-size="9" fill="#8f8772">흐트러짐</text>`;
218
219  // 곡선 아래: 위(초록 진함) → 아래(투명) 그라데이션 — 면적이 "모은 집중"으로 읽힌다
220  const defs =
221    `<defs><linearGradient id="rh-fill" x1="0" y1="0" x2="0" y2="1">` +
222    `<stop offset="0" stop-color="rgba(163,204,82,0.34)"/>` +
223    `<stop offset="1" stop-color="rgba(163,204,82,0.02)"/>` +
224    `</linearGradient></defs>`;
225  const last = pts[pts.length - 1];
226  const endDot =
227    `<circle cx="${x(last.t).toFixed(1)}" cy="${last.y.toFixed(1)}" r="4.5" fill="rgba(163,204,82,0.25)"/>` +
228    `<circle cx="${x(last.t).toFixed(1)}" cy="${last.y.toFixed(1)}" r="2.4" fill="#c4e573"/>`;
229
230  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
231  svg.innerHTML =
232    defs + hlRect + breachMarks + guide +
233    `<path d="${area}" fill="url(#rh-fill)"/>` +
234    `<path d="${line}" fill="none" stroke="#a3cc52" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>` +
235    endDot + ticks + axisLabels;
236}
237
238// ── v0.5-3 세션 추이: 날짜별 밀도% 점 + 추세선 ───────
239// ids로 렌더 대상 지정 (리포트 상세 / 사이드 메뉴 둘 다 사용)
240export function renderTrend(history, ids = {}) {
241  const box = $(ids.box ?? 'trend-box');
242  const svg = $(ids.svg ?? 'trend-svg');
243  const captionEl = $(ids.caption ?? 'trend-caption');
244  const rows = (history ?? []).filter((r) => typeof r.den === 'number' && r.den > 0);
245  if (rows.length < 5) {
246    box.classList.add('hidden');
247    return;
248  }
249  box.classList.remove('hidden');
250
251  const recent = rows.slice(-14);
252  const W = 320;
253  const H = 96;
254  const L = 26; // 왼쪽 % 라벨 공간
255  const x = (i) => L + (i / Math.max(1, recent.length - 1)) * (W - L - 20);
256  const y = (den) => H - 16 - (den / 100) * (H - 32);
257
258  // 기준선: 100% / 50% (읽는 기준점)
259  const refs = [100, 50]
260    .map(
261      (v) =>
262        `<line x1="${L}" y1="${y(v)}" x2="${W - 4}" y2="${y(v)}" stroke="#e7e2d5" stroke-width="1" stroke-dasharray="2 4"/>` +
263        `<text x="2" y="${y(v) + 3}" font-size="9" fill="#9a917f">${v}%</text>`
264    )
265    .join('');
266
267  // 세션 연결선 (흐름이 읽히도록) + 최소제곱 추세선
268  const n = recent.length;
269  const sx = recent.reduce((s, _, i) => s + i, 0);
270  const sy = recent.reduce((s, r) => s + r.den, 0);
271  const sxy = recent.reduce((s, r, i) => s + i * r.den, 0);
272  const sxx = recent.reduce((s, _, i) =>
272 s + i * i, 0);
273  const slope = (n * sxy - sx * sy) / Math.max(1e-6, n * sxx - sx * sx);
274  const intercept = (sy - slope * sx) / n;
275
276  const poly = recent.map((r, i) => `${x(i).toFixed(1)},${y(r.den).toFixed(1)}`).join(' ');
277  const connect = `<polyline points="${poly}" fill="none" stroke="rgba(148,197,63,0.45)" stroke-width="1.6" stroke-linejoin="round"/>`;
278  const trend = `<line x1="${x(0)}" y1="${y(intercept).toFixed(1)}" x2="${x(n - 1)}" y2="${y(intercept + slope * (n - 1)).toFixed(1)}" stroke="#b5ad98" stroke-width="1.4" stroke-dasharray="4 4"/>`;
279  const dots = recent
280    .map((r, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(r.den).toFixed(1)}" r="${i === n - 1 ? 4 : 2.6}" fill="${i === n - 1 ? '#7cab31' : '#94c53f'}"/>`)
281    .join('');
282  // 마지막 세션 값 표기 (지금 어디쯤인지 즉시 읽힘)
283  const last = recent[n - 1];
284  const lastLabel = `<text x="${Math.min(W - 30, x(n - 1) - 10)}" y="${Math.max(10, y(last.den) - 8)}" font-size="10" font-weight="800" fill="#567c1e">${Math.round(last.den)}%</text>`;
285  // 처음·마지막 날짜
286  const fd = new Date(recent[0].d);
287  const ld = new Date(last.d);
288  const dateLabels =
289    `<text x="${L}" y="${H - 3}" font-size="9" fill="#9a917f">${fd.getMonth() + 1}.${fd.getDate()}</text>` +
290    `<text x="${W - 30}" y="${H - 3}" font-size="9" fill="#9a917f">${ld.getMonth() + 1}.${ld.getDate()}</text>`;
291
292  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
293  svg.innerHTML = refs + connect + trend + dots + lastLabel + dateLabels;
294
295  // 시동 시간 병기 (최근 세션 기준 평균)
296  const warms = recent.filter((r) => typeof r.warm === 'number').map((r) => r.warm);
297  captionEl.textContent = warms.length
298    ? `최근 ${n}회 · 밀도 평균 ${Math.round(sy / n)}% · 몰입까지 평균 ${fmtMinSec(warms.reduce((s, v) => s + v, 0) / warms.length)}`
299    : `최근 ${n}회 · 밀도 평균 ${Math.round(sy / n)}%`;
300}
301
302// v0.5-1 기준선: 이번 세션을 제외한 개인 평균 (세션 3개 이상부터)
303function baselineOf(history) {
304  const rows = (history ?? []).filter((r) => (r.sand ?? 0) > 0);
305  if (rows.length < 3) return null;
306  const past = rows.slice(0, -1); // 방금 세션 제외
307  if (past.length < 3) return null;
308  const avg = (f) => past.reduce((s, r) => s + f(r), 0) / past.length;
309  const warms = past.filter((r) => typeof r.warm === 'number');
310  return {
311    sand: avg((r) => r.sand ?? 0),
312    den: avg((r) => r.den ?? 0),
313    warm: warms.length ? warms.reduce((s, r) => s + r.warm, 0) / warms.length : null,
314  };
315}
316
317export function renderReport(metrics, sessionStartDate, early, history = []) {
318  const base = baselineOf(history);
319  // 1. 결과 모래시계 (홈과 같은 오브제 — 이번 몫이 오늘 더미에 얹힌다)
320  primeToday(history);
321  animateHarvest(metrics.sandSec, metrics.durationSec);
322  $('base-line').textContent = base
323    ? `평소 ${fmtMinSec(base.sand)}`
324    : '';
325
326  // 핵심 지표 타일: 밀도 · 몰입까지 · 끊김 (없는 값은 — 로 정직하게)
327  $('stat-density').textContent =
328    metrics.densityPct == null ? '—' : `${metrics.densityPct}%`;
329  $('stat-warmup').textContent =
330    metrics.warmupSec == null ? '—' : fmtMinSec(metrics.warmupSec);
331
332  // 2. 타임라인: 하이라이트(빛나게) > 이탈(흐린 결)
333  const tl = $('timeline');
334  tl.innerHTML = '';
335  const pct = (s) => `${(s / metrics.durationSec) * 100}%`;
336  metrics.breachIv.forEach((b, i) => {
337    const el = document.createElement('div');
338    el.className = 'seg-breach';
339    el.dataset.idx = i;
340    el.style.left = pct(b.start);
341    el.style.width = pct(b.end - b.start);
342    tl.appendChild(el);
343  });
344  // 시작·끝 실제 시각 눈금
345  $('tl-start').textContent = fmtHM(sessionStartDate);
346  $('tl-end').textContent = fmtHM(
347    new Date(sessionStartDate.getTime() + metrics.durationSec * 1000)
348  );
349  const hl = metrics.highlight;
350  if (hl && hl.end - hl.start >= 10) {
351    const el = document.createElement('div');
352    el.className = 'seg-highlight';
353    el.style.left = pct(hl.start);
354    el.style.width = pct(hl.end - hl.start);
355    tl.appendChild(el);
356    const hlStart = new Date(sessionStartDate.getTime() + hl.start * 1000);
357    const hlEnd = new Date(sessionStartDate.getTime() + hl.end * 1000);
358    $('highlight-caption').textContent =
359      `최장 집중 ${fmtMinSec(hl.end - hl.start)} (${fmtHM(hlStart)}–${fmtHM(hlEnd)})`;
360  } else {
361    $('highlight-caption').textContent = '';
362  }
363
364  // 3. 접기 상세
365  const denBase = base?.den ? ` (평소 ${Math.round(base.den)}%)` : '';
366  $('density-line').textContent =
367    metrics.densityPct == null
368      ? '카메라가 얼굴을 인식하지 못해 측정하지 못함 · 위치·조명 확인'
369      : `밀도 ${metrics.densityPct}%${denBase} · 측정 ${fmtMinSec(metrics.durationSec - metrics.uncertainSec)} 중 ` +
370        `집중 ${fmtMinSec(metrics.durationSec - metrics.breachSec - metrics.uncertainSec)}`;
371  const b = metrics.breaches;
372  const total = (b.phone ?? 0) + (b.away ?? 0) + (b.gaze ?? 0) + (b.motion ?? 0);
373  // 신호 유형(폰·시선 등)은 화면에 표시하지 않음 — 오분류 노출 방지, 데이터에만 기록
374  $('stat-breach').textContent = `${total}회`;
375  $('breach-line').textContent = total ? `끊김 ${total}회` : '끊김 없음';
376  // 몰입까지 평소 병기 (타일에는 이번 값만 — 비교는 상세로)
377  const w = metrics.warmupSec;
378  $('warm-line').textContent =
379    w != null && base?.warm != null ? `몰입까지 평소 ${fmtMinSec(base.warm)}` : '';
380  // 회복 시간 (v0.5-4)
381  $('friction-line').textContent =
382    metrics.recoverySec != null ? `회복 평균 ${fmtMinSec(metrics.recoverySec)}` : '';
383  $('uncertain-line').textContent = metrics.uncertainSec > 0
384    ? `측정 불가 ${fmtMinSec(metrics.uncertainSec)}`
385    : '';
386
387  $('early-line').textContent = early ? '중도 종료' : '';
388}
389
390// ── 순간 확인 답변의 실시간 반영 (2026-07-29) ─────────
391// 사람 판정 기준의 유효 끊김 구간으로 링·밀도·끊김 수를 즉시 갱신.
392// 반환값이 최종 기록(metricsFinal)이 되어 홈 카드·서버에 실린다.
393export function updateAdjusted(metrics, effectiveIvs) {
394  const dur = metrics.durationSec;
395  const breachSec = unionSec(effectiveIvs);
396  const sand = Math.max(0, Math.round(dur - breachSec));
397  const judge = Math.max(1, dur - metrics.uncertainSec);
398  const den = metrics.densityPct == null
399    ? null
400    : Math.max(0, Math.round(((dur - breachSec - metrics.uncertainSec) / judge) * 100));
401
402  // 사람 판정 반영: 모래시계·오늘 누적·지표 타일을 즉시 다시 그린다
403  drawResultGlass($('rep-glass'), sand, dur, 1);
404  updateTodayBar(sand);
405  $('harvest-time').textContent = fmtMinSec(sand);
406  if (den != null) $('stat-density').textContent = `${den}%`;
407  $('stat-breach').textContent = `${effectiveIvs.length}회`;
408  $('breach-line').textContent = effectiveIvs.length
409    ? `끊김 ${effectiveIvs.length}회`
410    : '끊김 없음';
411
412  return { sandSec: sand, densityPct: den, breachCount: effectiveIvs.length };
413}
414
415// ── 15분 타임랩스: 프레임 재생 + 타임라인 위 재생 헤드 동기화 ──
416// 프레임은 세션 메모리에만 존재. 저장·전송 없음, 리포트를 떠나면 소멸
417let lapseTimer = null;
418
419export function initLapse(frames, durationSec) {
420  const sec = $('lapse-sec');
421  clearInterval(lapseTimer);
422  if (!frames || frames.length < 3) {
423    sec.classList.add('hidden');
424    return;
425  }
426  sec.classList.remove('hidden');
427  const img = $('lapse-img');
428  const tl = $('timeline');
429  let ph = tl.querySelector('.playhead');
430  if (!ph) {
431    ph = document.createElement('div');
432    ph.className = 'playhead';
433    tl.appendChild(ph);
434  }
435
436  const play = () => {
437    clearInterval(lapseTimer);
438    let i = 0;
439    ph.style.display = '';
440    lapseTimer = setInterval(() => {
441      if (i >= frames.length) {
442        clearInterval(lapseTimer);
443        ph.style.display = 'none';
444        return;
445      }
446      img.src = frames[i].dataUrl;
447      ph.style.left = `${(frames[i].t / durationSec) * 100}%`;
448      i++;
449    }, 1000 / 22);
450  };
451
452  img.src = frames[0].dataUrl;
453  ph.style.display = 'none';
454  $('btn-lapse').onclick = play;
455  setTimeout(play, 900); // 리포트 진입 시 자동 1회 재생
456}
457
458// ── 결과 모래시계: 홈 화면과 같은 오브제 (같은 형상·같은 색 = 연결성) ──
459// 위 구의 모래가 내려가 아래 더미로 쌓인다 = 이번 15분이 오늘 더미에 얹히는 장면
460const RG = { W: 92, NECK: 3.5, TOP0: 12, TOP1: 70, BOT0: 78, BOT1: 136, HALF: 30 };
461
462function rgHalfTop(y) {
463  const t = Math.min(1, Math.max(0, (RG.TOP1 - y) / (RG.TOP1 - RG.TOP0)));
464  return RG.NECK + (RG.HALF - RG.NECK) * Math.pow(t, 0.45);
465}
466function rgHalfBot(y) {
467  const t = Math.min(1, Math.max(0, (y - RG.BOT0) / (RG.BOT1 - RG.BOT0)));
468  return RG.NECK + (RG.HALF - RG.NECK) * Math.pow(t, 0.45);
469}
470
471// 더미 크기 공식은 홈(dayglass.js moundFor)과 같은 결 — 넓게 깔리고 천천히 높아진다
472function drawResultGlass(el, sandSec, durationSec, p) {
473  const CX = RG.W / 2;
474  const topLevel = RG.TOP0 + p * (RG.TOP1 - RG.TOP0);
475  const f = Math.min(1, (sandSec * p) / durationSec);
476  const mh = 3 + Math.pow(f, 0.75) * 40;
477  const mw = 17 + Math.pow(f, 0.5) * 11;
478  const base = RG.BOT1 - 2;
479
480  let pile = `M${CX - mw} ${base}`;
481  for (let x = -mw; x <= mw; x += 1.5) {
482    pile += ` L${(CX + x).toFixed(1)} ${(base - mh * Math.pow(1 - (x / mw) ** 2, 1.15)).toFixed(1)}`;
483  }
484  pile += ` L${CX + mw} ${base} Z`;
485
486  let wl = `M${(CX - rgHalfTop(RG.TOP0)).toFixed(1)} ${RG.TOP0}`;
487  let wr = `M${(CX + rgHalfTop(RG.TOP0)).toFixed(1)} ${RG.TOP0}`;
488  for (let y = RG.TOP0; y <= RG.TOP1; y += 3) {
489    wl += ` L${(CX - rgHalfTop(y)).toFixed(1)} ${y}`;
490    wr += ` L${(CX + rgHalfTop(y)).toFixed(1)} ${y}`;
491  }
492  for (let y = RG.BOT0; y <= RG.BOT1; y += 3) {
493    wl += ` L${(CX - rgHalfBot(y)).toFixed(1)} ${y}`;
494    wr += ` L${(CX + rgHalfBot(y)).toFixed(1)} ${y}`;
495  }
496
497  let top = '';
498  if (topLevel < RG.TOP1 - 0.5) {
499    top = `M${(CX - rgHalfTop(topLevel)).toFixed(1)} ${topLevel.toFixed(1)}`;
500    top += ` L${(CX + rgHalfTop(topLevel)).toFixed(1)} ${topLevel.toFixed(1)}`;
501    for (let y = topLevel; y <= RG.TOP1; y += 3) top += ` L${(CX + rgHalfTop(y)).toFixed(1)} ${y.toFixed(1)}`;
502    for (let y = RG.TOP1; y >= topLevel; y -= 3) top += ` L${(CX - rgHalfTop(y)).toFixed(1)} ${y.toFixed(1)}`;
503    top += ' Z';
504  }
505
506  // 떨어지는 알갱이 (진행 중에만)
507  let grains = '';
508  if (p < 1) {
509    for (let k = 0; k < 3; k++) {
510      const t = ((performance.now() / 700 + k * 0.33) % 1);
511      const y = RG.TOP1 + 2 + t * (base - mh - RG.TOP1 - 4);
512      grains += `<circle cx="${CX}" cy="${y.toFixed(1)}" r="1.5" fill="#a3cc52"/>`;
513    }
514  }
515
516  el.innerHTML = `
517    <defs><linearGradient id="rg-grad" x1="0" y1="0" x2="0" y2="1">
518      <stop offset="0" stop-color="#c4e573"/><stop offset="1" stop-color="#7cab31"/>
519    </linearGradient></defs>
520    <rect x="${CX - RG.HALF - 6}" y="4" width="${(RG.HALF + 6) * 2}" height="7" rx="3.5" class="rg-cap"/>
521    <rect x="${CX - RG.HALF - 6}" y="137" width="${(RG.HALF + 6) * 2}" height="7" rx="3.5" class="rg-cap"/>
522    ${top ? `<path d="${top}" class="rg-rest"/>` : ''}
523    <path d="${pile}" fill="url(#rg-grad)"/>
524    ${grains}
525    <path d="${wl}" class="rg-wall"/><path d="${wr}" class="rg-wall"/>`;
526}
527
528// 결과 모래시계 애니메이션 + 숫자 카운트업 + 오늘 누적 합류
529function animateHarvest(sandSec, durationSec) {
530  const el = $('rep-glass');
531  const label = $('harvest-time');
532  if (!el) return;
533  const T = 1800;
534  const t0 = performance.now();
535  const ease = (x) => 1 - Math.pow(1 - x, 3);
536  const setP = (p) => {
537    drawResultGlass(el, sandSec, durationSec, p);
538    label.textContent = fmtMinSec(sandSec * p);
539    updateTodayBar(sandSec * p);
540  };
541  const step = (now) => {
542    const p = ease(Math.min(1, (now - t0) / T));
543    setP(p);
544    if (p < 1) requestAnimationFrame(step);
545  };
546  setP(0);
547  requestAnimationFrame(step);
548  setTimeout(() => setP(1), T + 300); // rAF 스로틀 대비
549}
550
551// 오늘 누적: 이번 세션이 오늘 전체에 더해진 결과 (홈 더미와 같은 값)
552let todayBefore = 0;      // 이번 세션 이전까지 오늘 누적
553let todaySessions = [];   // 이번 세션 이전 오늘 세션들의 sand
554function updateTodayBar(curSand) {
555  const el = $('ht-total');
556  if (el) el.textContent = `${Math.round((todayBefore + curSand) / 60)}분`;
557}
558
559// 오늘 기록을 미리 읽어둔다 (renderReport 진입 시 1회)
560function primeToday(history) {
561  const now = new Date();
562  const key = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
563  todaySessions = (history ?? [])
564    .filter((r) => {
565      const d = new Date(r.d);
566      return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` === key;
567    })
568    .map((r) => r.sand ?? 0);
569  todayBefore = todaySessions.reduce((s, v) => s + v, 0);
570}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.