feat(studio): Phase G — KPI stats / chart / cardList / groupedTable + canonical container tabs

INV Studio 데이터 뷰 시리즈. 솔루션 개발 단계라 backward-compat alias 없이 깔끔하게.

Backend:
- TableManagementController + Service: /aggregate, /aggregate-group, /select-rows endpoint 추가
  sanitize + hasColumn 검증 + buildAggregateWhere 공유 헬퍼

Frontend canonical view components (신규):
- stats: DB-first KPI editor (CPSegment 메타 chip, 컬럼 dropdown, 디자인 모드 debounce 350ms preview)
- chart: recharts (bar / horizontalBar / line / donut)
- card-list: title/subtitles/metrics 카드 카탈로그 (list / grid 레이아웃)
- grouped-table: 클라이언트 측 groupBy + 그룹 헤더 row

Canonical container (Phase G.2 / G.2.5 / G.2.6):
- containerType='tabs' 활성 탭만 mount, ChildSlot 으로 자식 렌더
- ScreenDesigner.handleComponentDrop 가 canonical container tabs 도 인식
- 우측 V2PropertiesPanel 4-way 분기: tab child / panel child / selected / empty
  nested path update + saveToHistory, delete handler 동기화

Shared utilities:
- useDbColumns hook (모듈 캐시), ColumnPicker (CPSelect 기반)
- OptionFilterRow 자연어 카드 형식 (컬럼 dropdown / 조건 select / 값 입력)
- _shared/use-table-rows.ts (cardList + groupedTable 공용 fetch)
- IconPicker: 한글 키워드 80+ alias, 휠 스크롤 fix, 360px 상한, 결과 80→300

stats DB-first UX (Phase G.4.x):
- DB / 정적 모드 이분법 제거 — 항상 dataSource 시작
- collapsed: 라벨 input + KpiMetaSegment chip (테이블 · 집계 · 컬럼 · 필터수)
- expanded: 데이터 / 필터 / 외형 / 고급 flat CP rows
- useSlideToggle hook 으로 펼침/닫힘 양방향 애니메이션
- 변화량 (delta) 수동 입력 UI 제거 — 향후 DB 자동 계산 영역
- 카드 fetch state 명시: loading / error / 대기 중 / 테이블 미설정

기타:
- ScreenDesigner.tsx → InvyoneStudio.tsx rename (활성 빌더 파일)
- 모든 hardcoded #6c5ce7 fallback 제거, hsl(var(--primary)) 토큰만 사용 (light/dark/테마 자동 적응)
- StatsDefinition default_config 도 DB-first placeholder (value: 0 박지 않음)

Docs:
- notes/gbpark/2026-05-14-studio-data-view-roadmap.md (G.0 ~ G.4.2 진행 기록)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DDD1542
2026-05-14 17:41:50 +09:00
parent c3e04adb23
commit 3883031c0b
51 changed files with 13555 additions and 331 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,747 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Invyone — No-Code · Build Anything</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<link rel="preconnect" href="https://unpkg.com" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<style>
:root {
--purple: #6c5ce7;
--purple-soft: #8b7df0;
--purple-glow: rgba(108, 92, 231, 0.6);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
min-height: 100%;
background: #06061a;
font-family: 'Pretendard', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color: #fff;
-webkit-font-smoothing: antialiased;
display: flex;
align-items: center;
justify-content: center;
}
/* 스테이지 — 영상 녹화용 고정 크기 (1200 x 1500, 약 4:5) */
.stage {
position: relative;
width: 1200px;
height: 1500px;
background: radial-gradient(ellipse 80% 60% at 50% 50%, #0e0e35 0%, #08081f 60%, #050514 100%);
overflow: hidden;
}
/* dot 패턴 배경 */
.dot-bg {
position: absolute;
inset: 0;
background-image: radial-gradient(circle, rgba(170, 180, 230, 0.18) 1px, transparent 1.5px);
background-size: 26px 26px;
opacity: 0.7;
pointer-events: none;
}
/* 좌상단 뱃지 */
.top-badge {
position: absolute;
top: 38px;
left: 38px;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 11px 22px 11px 16px;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 999px;
background: rgba(15, 15, 40, 0.45);
font-size: 12px;
font-weight: 600;
letter-spacing: 2.8px;
color: rgba(255, 255, 255, 0.92);
z-index: 10;
}
.badge-dot {
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--purple);
box-shadow: 0 0 10px var(--purple-glow), 0 0 4px var(--purple);
}
/* SVG 점선 원 — 절대 중앙 정렬 */
.ring-svg {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 1;
}
/* 중앙 글로우 원 — 안쪽 펄스 + 코어 */
.orb-pulse {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 1px solid rgba(140, 125, 240, 0.22);
pointer-events: none;
z-index: 3;
}
.orb-pulse.p1 { width: 320px; height: 320px; }
.orb-pulse.p2 { width: 380px; height: 380px; border-color: rgba(140, 125, 240, 0.14); }
.orb-pulse.p3 { width: 440px; height: 440px; border-color: rgba(140, 125, 240, 0.08); }
.orb {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 300px;
height: 300px;
border-radius: 50%;
background:
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.32) 0%, transparent 38%),
radial-gradient(circle at 50% 55%, #b8acff 0%, #7567e2 42%, #4936b8 82%, #2d1f8a 100%);
box-shadow:
inset -16px -16px 38px rgba(38, 26, 105, 0.4),
inset 14px 18px 28px rgba(255, 255, 255, 0.06),
0 0 55px rgba(108, 92, 231, 0.42),
0 0 110px rgba(108, 92, 231, 0.22),
0 0 170px rgba(108, 92, 231, 0.1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 5;
animation: orb-breathe 5.5s ease-in-out infinite;
}
@keyframes orb-breathe {
0%, 100% {
box-shadow:
inset -16px -16px 38px rgba(38, 26, 105, 0.4),
inset 14px 18px 28px rgba(255, 255, 255, 0.06),
0 0 55px rgba(108, 92, 231, 0.42),
0 0 110px rgba(108, 92, 231, 0.22),
0 0 170px rgba(108, 92, 231, 0.1);
}
50% {
box-shadow:
inset -16px -16px 38px rgba(38, 26, 105, 0.4),
inset 14px 18px 28px rgba(255, 255, 255, 0.08),
0 0 75px rgba(108, 92, 231, 0.55),
0 0 140px rgba(108, 92, 231, 0.3),
0 0 210px rgba(108, 92, 231, 0.14);
}
}
/* 회전하는 점선 ring (orb과 안쪽 점선 원 사이) */
.orb-ring {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 4;
animation: spin-ring 28s linear infinite;
}
.orb-ring.reverse {
animation: spin-ring-rev 36s linear infinite;
}
@keyframes spin-ring {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes spin-ring-rev {
to { transform: translate(-50%, -50%) rotate(-360deg); }
}
.orb-name {
font-size: 56px;
font-weight: 800;
letter-spacing: -0.02em;
color: #fff;
text-shadow: 0 0 12px rgba(255, 255, 255, 0.28);
line-height: 1;
position: relative;
z-index: 2;
}
.orb-tag {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.55em;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 0 6px rgba(255, 255, 255, 0.25);
margin-top: 16px;
padding-top: 14px;
position: relative;
z-index: 2;
}
.orb-tag::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 38px;
height: 1px;
background: rgba(255, 255, 255, 0.55);
}
/* 안쪽 4 노드 — 정사각형 점선 박스 + 시계방향 순차 펄스 */
.inner-node {
position: absolute;
left: 50%;
top: 50%;
transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y)));
width: 86px;
height: 86px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px dashed rgba(170, 180, 230, 0.48);
border-radius: 10px;
background: transparent;
z-index: 6;
animation: inner-pulse 4.8s ease-in-out infinite;
}
@keyframes inner-pulse {
0%, 70%, 100% {
border-color: rgba(170, 180, 230, 0.48);
box-shadow: none;
}
10%, 20% {
border-color: rgba(180, 165, 250, 0.95);
box-shadow:
0 0 18px rgba(108, 92, 231, 0.45),
inset 0 0 12px rgba(108, 92, 231, 0.12);
}
}
.inner-node .ic { transition: color 0.5s; }
.inner-node .ic { color: rgba(255, 255, 255, 0.78); display: flex; }
.inner-node .ic svg { width: 22px; height: 22px; stroke-width: 1.4; }
.inner-node .lb {
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.92);
letter-spacing: 0.01em;
}
/* @property로 scale 변수 transition 가능하게 (Chrome 85+) */
@property --node-scale {
syntax: '<number>';
inherits: false;
initial-value: 1;
}
/* 바깥 11 노드 카드 */
.outer-node {
position: absolute;
left: 50%;
top: 50%;
transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y))) scale(var(--node-scale));
width: 168px;
padding: 20px 14px 18px;
background: linear-gradient(180deg, rgba(30, 32, 70, 0.72) 0%, rgba(18, 20, 50, 0.78) 100%);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
z-index: 6;
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255,255,255,0.03);
transition: --node-scale 0.55s cubic-bezier(0.34, 1.56, 0.64, 1),
border-color 0.45s, background 0.45s, box-shadow 0.45s;
}
.outer-node.selected {
--node-scale: 1.08;
border-color: rgba(155, 140, 250, 1);
background: linear-gradient(180deg, rgba(54, 44, 135, 0.5) 0%, rgba(32, 26, 85, 0.5) 100%);
box-shadow:
0 0 28px rgba(108, 92, 231, 0.55),
0 0 56px rgba(108, 92, 231, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.outer-node.selected::before {
content: '';
position: absolute;
inset: -8px;
border-radius: 18px;
border: 1px solid rgba(155, 140, 250, 0.65);
animation: pulse-ring 1.8s ease-out infinite;
pointer-events: none;
}
.outer-node.selected::after {
content: '';
position: absolute;
top: -1px;
left: 50%;
width: 50%;
height: 1.5px;
transform: translateX(-50%);
background: linear-gradient(90deg, transparent, rgba(180, 165, 255, 1), transparent);
box-shadow: 0 0 8px rgba(155, 140, 250, 0.8);
pointer-events: none;
}
@keyframes pulse-ring {
0% { transform: scale(0.95); opacity: 0.85; }
100% { transform: scale(1.20); opacity: 0; }
}
.outer-node .ic { color: rgba(255, 255, 255, 0.88); display: flex; }
.outer-node .ic svg { width: 34px; height: 34px; stroke-width: 1.3; }
.outer-node.selected .ic { color: #fff; }
.outer-node .en {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.005em;
color: #fff;
}
.outer-node .ko {
font-size: 13px;
font-weight: 400;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.02em;
}
.outer-node.selected .ko { color: rgba(255, 255, 255, 0.82); }
/* 우하단 footer */
.bottom-tag {
position: absolute;
bottom: 34px;
right: 42px;
display: flex;
align-items: center;
gap: 9px;
font-size: 14px;
color: rgba(255, 255, 255, 0.58);
letter-spacing: 0.01em;
z-index: 10;
}
.bottom-tag .plus { color: var(--purple-soft); font-weight: 600; font-size: 16px; }
.bottom-tag .sep { opacity: 0.45; }
/* === 애니메이션 토글 패널 === */
.control-panel {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
padding: 14px 16px;
background: rgba(15, 15, 40, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #fff;
font-size: 12px;
font-family: 'Pretendard', system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 200px;
backdrop-filter: blur(8px);
}
.control-panel .cp-title {
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.18em;
color: rgba(255,255,255,0.6);
text-transform: uppercase;
padding-bottom: 5px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.control-panel label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.control-panel input[type="checkbox"] { accent-color: #8b7df0; cursor: pointer; }
.control-panel button {
margin-top: 4px;
padding: 7px 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 4px;
color: #fff;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.control-panel button:hover { background: rgba(255, 255, 255, 0.12); }
.control-panel.hidden { display: none; }
.show-panel-btn {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
padding: 8px 12px;
background: rgba(15, 15, 40, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #fff;
font-size: 11px;
font-family: 'Pretendard', system-ui, sans-serif;
cursor: pointer;
display: none;
}
/* anim toggle overrides */
body.anim-off-inner-pulse .inner-node { animation: none !important; }
body.anim-off-orb-breathe .orb { animation: none !important; }
body.anim-off-ring-1 .orb-ring:not(.reverse) { animation: none !important; }
body.anim-off-ring-2 .orb-ring.reverse { animation: none !important; }
</style>
</head>
<body>
<div class="control-panel">
<div class="cp-title">Animations</div>
<label><input type="checkbox" data-anim="roulette" checked> 외곽 룰렛 회전</label>
<label><input type="checkbox" data-anim="inner-pulse" checked> 안쪽 4개 펄스</label>
<label><input type="checkbox" data-anim="orb-breathe" checked> orb 호흡</label>
<label><input type="checkbox" data-anim="ring-1" checked> 큰 ring (시계)</label>
<label><input type="checkbox" data-anim="ring-2" checked> 작은 ring (반시계)</label>
<button id="record-btn">⏺ 녹화 시작 (82초)</button>
<button id="hide-panel">패널 숨기기</button>
</div>
<button class="show-panel-btn" id="show-panel">⚙ 애니메이션 패널</button>
<div class="stage">
<div class="dot-bg"></div>
<div class="top-badge">
<span class="badge-dot"></span>
<span>NO-CODE · BUILD ANYTHING</span>
</div>
<!-- 외곽 점선 원 r=460 + 노드 사이 작은 dot 11개 (11등분 균등) -->
<svg class="ring-svg" width="1000" height="1000" viewBox="-500 -500 1000 1000">
<circle cx="0" cy="0" r="460" fill="none" stroke="rgba(170, 180, 230, 0.30)" stroke-width="1" stroke-dasharray="2 6" />
<g fill="rgba(165, 180, 230, 0.55)">
<!-- 인접 노드 호의 중간 (360/11 * (k+0.5)) -->
<circle cx="129.6" cy="-441.4" r="3.5" />
<circle cx="347.6" cy="-301.2" r="3.5" />
<circle cx="455.3" cy="-65.5" r="3.5" />
<circle cx="418.4" cy="191.1" r="3.5" />
<circle cx="248.7" cy="387" r="3.5" />
<circle cx="0" cy="460" r="3.5" />
<circle cx="-248.7" cy="387" r="3.5" />
<circle cx="-418.4" cy="191.1" r="3.5" />
<circle cx="-455.3" cy="-65.5" r="3.5" />
<circle cx="-347.6" cy="-301.2" r="3.5" />
<circle cx="-129.6" cy="-441.4" r="3.5" />
</g>
</svg>
<!-- 안쪽 점선 원 r=240 -->
<svg class="ring-svg" width="600" height="600" viewBox="-300 -300 600 600">
<circle cx="0" cy="0" r="240" fill="none" stroke="rgba(170, 180, 230, 0.22)" stroke-width="1" stroke-dasharray="2 6" />
</svg>
<!-- 중앙 펄스 -->
<div class="orb-pulse p3"></div>
<div class="orb-pulse p2"></div>
<div class="orb-pulse p1"></div>
<!-- 회전 점선 ring 2개 (반대 방향) -->
<svg class="orb-ring" width="380" height="380" viewBox="-190 -190 380 380">
<circle r="178" cx="0" cy="0" fill="none"
stroke="rgba(180, 165, 250, 0.32)" stroke-width="1" stroke-dasharray="3 9" />
</svg>
<svg class="orb-ring reverse" width="340" height="340" viewBox="-170 -170 340 340">
<circle r="158" cx="0" cy="0" fill="none"
stroke="rgba(180, 165, 250, 0.2)" stroke-width="1" stroke-dasharray="2 6" />
</svg>
<!-- 중앙 글로우 원 -->
<div class="orb">
<div class="orb-name">Invyone</div>
<div class="orb-tag">N O - C O D E</div>
</div>
<!-- 안쪽 4 노드 (반경 240, 12/3/6/9시) -->
<div class="inner-node" style="--x: 0px; --y: -240px">
<div class="ic"><i data-lucide="file-lock-2"></i></div>
<div class="lb">PTW/LOTO</div>
</div>
<div class="inner-node" style="--x: 240px; --y: 0px">
<div class="ic"><i data-lucide="trending-up"></i></div>
<div class="lb">KPI/Report</div>
</div>
<div class="inner-node" style="--x: 0px; --y: 240px">
<div class="ic"><i data-lucide="user-round"></i></div>
<div class="lb">HRM</div>
</div>
<div class="inner-node" style="--x: -240px; --y: 0px">
<div class="ic"><i data-lucide="settings"></i></div>
<div class="lb">Workflow</div>
</div>
<!-- 바깥 11 노드 (반경 460, 11등분 균등 32.73°씩) -->
<!-- 0° MES -->
<div class="outer-node" style="--x: 0px; --y: -460px">
<div class="ic"><i data-lucide="factory"></i></div>
<div class="en">MES</div>
<div class="ko">생산관리</div>
</div>
<!-- 32.73° Inventory -->
<div class="outer-node" style="--x: 248.7px; --y: -387px">
<div class="ic"><i data-lucide="package"></i></div>
<div class="en">Inventory</div>
<div class="ko">재고관리</div>
</div>
<!-- 65.45° PLM -->
<div class="outer-node" style="--x: 418.4px; --y: -191.1px">
<div class="ic"><i data-lucide="wrench"></i></div>
<div class="en">PLM</div>
<div class="ko">제품수명</div>
</div>
<!-- 98.18° AI Agents -->
<div class="outer-node" style="--x: 455.3px; --y: 65.5px">
<div class="ic"><i data-lucide="bot"></i></div>
<div class="en">AI Agents</div>
<div class="ko">자동화</div>
</div>
<!-- 130.91° EHS -->
<div class="outer-node" style="--x: 347.6px; --y: 301.2px">
<div class="ic"><i data-lucide="hard-hat"></i></div>
<div class="en">EHS</div>
<div class="ko">환경안전</div>
</div>
<!-- 163.64° ERP -->
<div class="outer-node" style="--x: 129.6px; --y: 441.4px">
<div class="ic"><i data-lucide="briefcase"></i></div>
<div class="en">ERP</div>
<div class="ko">전사자원</div>
</div>
<!-- 196.36° WMS -->
<div class="outer-node" style="--x: -129.6px; --y: 441.4px">
<div class="ic"><i data-lucide="truck"></i></div>
<div class="en">WMS</div>
<div class="ko">창고관리</div>
</div>
<!-- 229.09° QMS -->
<div class="outer-node" style="--x: -347.6px; --y: 301.2px">
<div class="ic"><i data-lucide="shield-check"></i></div>
<div class="en">QMS</div>
<div class="ko">품질관리</div>
</div>
<!-- 261.82° SCM -->
<div class="outer-node" style="--x: -455.3px; --y: 65.5px">
<div class="ic"><i data-lucide="globe"></i></div>
<div class="en">SCM</div>
<div class="ko">공급망</div>
</div>
<!-- 294.55° CRM -->
<div class="outer-node" style="--x: -418.4px; --y: -191.1px">
<div class="ic"><i data-lucide="users"></i></div>
<div class="en">CRM</div>
<div class="ko">고객관리</div>
</div>
<!-- 327.27° SCADA -->
<div class="outer-node" style="--x: -248.7px; --y: -387px">
<div class="ic"><i data-lucide="activity"></i></div>
<div class="en">SCADA</div>
<div class="ko">공정감시</div>
</div>
<!-- 우하단 카피 -->
<div class="bottom-tag">
<span class="plus">+</span>
<span>Build Anything</span>
<span class="sep">·</span>
<span>원하는 대로 자유롭게</span>
</div>
</div>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<script>
lucide.createIcons();
// 안쪽 4 노드 — 시계방향 순차 펄스 (1.2초씩 어긋나게)
document.querySelectorAll('.inner-node').forEach((node, i) => {
node.style.animationDelay = (i * 1.2) + 's';
});
// === 외곽 룰렛 회전 + 랜덤 선택 ===
const TOTAL_NODES = 11;
const RADIUS = 460;
const STEP = 360 / TOTAL_NODES;
const outerNodes = document.querySelectorAll('.outer-node');
let currentAngle = 0;
let lastTargetIndex = -1;
let rouletteEnabled = true;
let rafId = null;
let highlightTimerId = null;
let nextSpinTimerId = null;
function setNodePositions(angle) {
for (let i = 0; i < outerNodes.length; i++) {
const a = (i * STEP + angle) * Math.PI / 180;
const x = Math.sin(a) * RADIUS;
const y = -Math.cos(a) * RADIUS;
outerNodes[i].style.setProperty('--x', x.toFixed(1) + 'px');
outerNodes[i].style.setProperty('--y', y.toFixed(1) + 'px');
}
}
function spinAndPick() {
if (!rouletteEnabled) return;
// === 녹화 트리거 (recorder.start는 외부에서 이미 호출됨) ===
if (recordingActive) {
recordingCycleCount++;
if (recordingCycleCount > RECORD_CYCLES) {
recorder.stop();
recordingActive = false;
} else {
recordBtn.textContent = '⏺ 녹화 중 ' + recordingCycleCount + '/' + RECORD_CYCLES;
}
}
const currMod = ((currentAngle % 360) + 360) % 360;
// 지금 12시 위치에 있는 노드 인덱스 (정규화)
const currentIndex = ((Math.round(-currMod / STEP) % TOTAL_NODES) + TOTAL_NODES) % TOTAL_NODES;
// 시계방향 1칸씩 sequential — 11 cycle 후 처음 노드로 자동 복귀 (seamless loop 보장)
const stepsAhead = 1;
const target = (currentIndex - stepsAhead + TOTAL_NODES) % TOTAL_NODES;
lastTargetIndex = target;
const targetMod = ((-target * STEP) % 360 + 360) % 360;
let delta = targetMod - currMod;
if (delta <= 0) delta += 360;
const startAngle = currentAngle;
const duration = 3000;
const startTime = performance.now();
outerNodes.forEach(n => n.classList.remove('selected'));
function frame(t) {
const p = Math.min((t - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - p, 1.3); // 거의 linear, 끝만 살짝 감속
currentAngle = startAngle + delta * eased;
setNodePositions(currentAngle);
if (p < 1) {
rafId = requestAnimationFrame(frame);
} else {
outerNodes[target].classList.add('selected');
highlightTimerId = setTimeout(() => {
outerNodes[target].classList.remove('selected');
if (rouletteEnabled) nextSpinTimerId = setTimeout(spinAndPick, 500);
}, 4000);
}
}
rafId = requestAnimationFrame(frame);
}
setNodePositions(0);
setTimeout(() => { if (rouletteEnabled) spinAndPick(); }, 1200);
// === 애니메이션 토글 패널 핸들러 ===
const panel = document.querySelector('.control-panel');
const showBtn = document.getElementById('show-panel');
panel.querySelectorAll('input[data-anim]').forEach(input => {
input.addEventListener('change', () => {
const anim = input.dataset.anim;
const on = input.checked;
if (anim === 'roulette') {
rouletteEnabled = on;
if (on) setTimeout(spinAndPick, 200);
} else {
document.body.classList.toggle('anim-off-' + anim, !on);
}
});
});
document.getElementById('hide-panel').addEventListener('click', () => {
panel.classList.add('hidden');
showBtn.style.display = 'block';
});
showBtn.addEventListener('click', () => {
panel.classList.remove('hidden');
showBtn.style.display = 'none';
});
// === 자동 녹화 (MediaRecorder + getDisplayMedia) ===
let recorder, recordChunks;
let recordingActive = false;
let recordingCycleCount = 0;
const RECORD_CYCLES = 11; // 11 cycle = seamless loop
const recordBtn = document.getElementById('record-btn');
recordBtn.addEventListener('click', async () => {
if (recordingActive) return;
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'browser',
frameRate: 30
},
audio: false,
preferCurrentTab: true,
selfBrowserSurface: 'include'
});
// Region Capture — stage element 영역만 crop (Chrome 104+)
const [videoTrack] = stream.getVideoTracks();
try {
if (typeof CropTarget !== 'undefined' && CropTarget.fromElement && videoTrack.cropTo) {
const cropTarget = await CropTarget.fromElement(document.querySelector('.stage'));
await videoTrack.cropTo(cropTarget);
console.log('Region Capture 활성화: stage 영역만 캡쳐됩니다.');
} else {
console.warn('Region Capture 미지원 — 탭 전체가 캡쳐됩니다.');
}
} catch (e) {
console.warn('Region Capture 실패:', e);
}
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
? 'video/webm;codecs=vp9'
: 'video/webm';
recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 8_000_000 });
recordChunks = [];
recorder.ondataavailable = e => { if (e.data.size > 0) recordChunks.push(e.data); };
recorder.onstop = () => {
const blob = new Blob(recordChunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'invyone-intro-' + new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-') + '.webm';
a.click();
URL.revokeObjectURL(url);
stream.getTracks().forEach(t => t.stop());
recordBtn.textContent = '⏺ 녹화 시작 (82초)';
recordBtn.disabled = false;
};
// === 애니메이션 처음 위치로 reset (MES가 12시) ===
cancelAnimationFrame(rafId);
clearTimeout(highlightTimerId);
clearTimeout(nextSpinTimerId);
outerNodes.forEach(n => n.classList.remove('selected'));
currentAngle = 0;
setNodePositions(0);
recordingActive = true;
recordingCycleCount = 0;
recordBtn.disabled = true;
// 녹화 즉시 시작 (정적 1.5초도 영상에 포함 → loop 시 자연스럽게 이어짐)
recorder.start();
recordBtn.textContent = '⏺ 녹화 중... 정적 1.5초';
// 1.5초 정적 후 첫 cycle 시작
setTimeout(spinAndPick, 1500);
} catch (err) {
console.error('녹화 권한 거부:', err);
}
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff