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:
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
Reference in New Issue
Block a user