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.