const MODE = "text-analysis-pipeline";
const c=document.querySelector('canvas'),g=c.getContext('2d');
const play=document.querySelector('#play'),next=document.querySelector('#next'),reset=document.querySelector('#reset'),range=document.querySelector('#range'),value=document.querySelector('#value'),paramLabel=document.querySelector('#param-label');
const cs=getComputedStyle(document.documentElement);
const color=(key,fallback)=>cs.getPropertyValue(key).trim()||fallback;
const C={bg:color('--bg','#fff'),fg:color('--fg','#182031'),muted:color('--muted','#667085'),line:color('--line','#cbd5e1'),accent:color('--accent','#5b5bf7'),a2:color('--accent-2','#db638d'),a3:color('--accent-3','#26a899'),surface:color('--surface','#f4f5f8')};
const labels={
'text-analysis-pipeline':'글 모음 분석: 단계별 변환','tokenization':'문장을 토큰으로 나누기','morphological-analysis':'형태소 분석: 어근과 조사','bag-of-words':'단어 가방: 문서별 빈도','n-gram':'N-그램: 인접 단어 묶기','tf-idf':'TF-IDF: 흔한 단어 낮추기','log-odds-ratio':'로그오즈비: 매체별 차이','sentence-embedding':'문장 임베딩: 의미 좌표','cosine-similarity':'코사인 유사도: 방향 비교','centroid':'중심 벡터: 평균 위치','vector-space':'벡터 공간: 특징 축','dimensionality-reduction':'차원 축소: 큰 지도를 작게','pca':'PCA: 퍼짐이 큰 방향','umap':'UMAP 단순화한 시연','t-sne':'t-SNE 단순화한 시연','k-means':'K-means: 중심 재배치','dbscan':'DBSCAN: 밀집 연결','hdbscan':'HDBSCAN 단순화한 시연','cluster-keywords':'군집 대표어: TF-IDF','median-and-iqr':'중앙값과 사분위 범위','long-tail-distribution':'긴 꼬리 분포','z-score':'Z 점수: 평균에서 거리'};
const stages={
'text-analysis-pipeline':['원문','형태소·명사','빈도·가중치','벡터·군집'],
'tokenization':['원문','경계 찾기','토큰 목록'], 'morphological-analysis':['문장','형태소 분해','품사 선택'],
'bag-of-words':['문서','단어 세기','빈도 행렬'],'n-gram':['토큰','인접 창','묶음 세기'],
'tf-idf':['원빈도','문서빈도','가중치'],'log-odds-ratio':['두 집단','오즈 비교','표준화'],
'sentence-embedding':['문장','벡터 변환','가까운 뜻'],'cosine-similarity':['두 벡터','각도 비교','유사도'],
'centroid':['점들','좌표 평균','중심 표시'],'vector-space':['특징 추출','축에 배치','거리 비교'],
'dimensionality-reduction':['여러 특징','관계 압축','2D 지도'],'pca':['중심 맞추기','공분산·주축','주축 투영'],
'umap':['이웃 그래프','가까운 점 당기기','먼 점 밀기'],'t-sne':['고차원 이웃','2D 이웃 맞추기','지도 읽기'],
'k-means':['초기 중심','가까운 중심 배정','중심 다시 계산'],'dbscan':['반경 이웃','핵심점 연결','노이즈 분리'],
'hdbscan':['거리 문턱 변화','밀도 계층','안정 군집 선택'],'cluster-keywords':['군집 묶기','단어 가중치','대표어 순위'],
'median-and-iqr':['값 정렬','중앙값','Q1–Q3'],'long-tail-distribution':['빈도 정렬','상위 소수','긴 꼬리'],
'z-score':['평균 구하기','표준편차','표준 점수']};
let w=0,h=0,dpr=1,phase=4,running=true,parameter=4,last=0;
const paramNames={'n-gram':'N','k-means':'K','dbscan':'ε','hdbscan':'최소 군집','umap':'n_neighbors','t-sne':'perplexity'};
paramLabel.textContent=paramNames[MODE]||'속도';
if(MODE==='n-gram'||MODE==='k-means'){range.max='3';range.value='2';parameter=2;value.textContent='2'}
if(MODE==='t-sne'){range.min='5';range.max='25';range.step='5';range.value='10';parameter=10;value.textContent='10'}
const small=()=>w<500;
function resize(){w=innerWidth;h=innerHeight;dpr=Math.min(devicePixelRatio||1,2);c.width=Math.round(w*dpr);c.height=Math.round(h*dpr);g.setTransform(dpr,0,0,dpr,0,0);draw()}
function rng(seed){let a=seed;return ()=>{a+=0x6D2B79F5;let t=a;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return((t^(t>>>14))>>>0)/4294967296}}
const random=rng(305),points=[];
for(let j=0;j<3;j++)for(let i=0;i<9;i++){const a=i*2.4;points.push({x:0.22+j*0.28+(random()-.5)*.19,y:.23+(i%3)*.22+(random()-.5)*.11,group:j})}
function txt(s,x,y,size=12,shade=C.fg,align='left',bold=false){g.fillStyle=shade;g.font=(bold?'700 ':'500 ')+size+'px system-ui,sans-serif';g.textAlign=align;g.fillText(s,x,y)}
function line(x1,y1,x2,y2,shade=C.line,width=1){g.strokeStyle=shade;g.lineWidth=width;g.beginPath();g.moveTo(x1,y1);g.lineTo(x2,y2);g.stroke()}
function rect(x,y,ww,hh,fill=C.surface,stroke=C.line,r=5){g.fillStyle=fill;g.strokeStyle=stroke;g.lineWidth=1;g.beginPath();g.roundRect(x,y,ww,hh,r);g.fill();g.stroke()}
function dot(x,y,r,fill){g.fillStyle=fill;g.beginPath();g.arc(x,y,r,0,Math.PI*2);g.fill()}
function plot(){const x=small()?24:80,y=50,ww=w-(small()?48:160),hh=h-(small()?78:125);return{x,y,ww,hh}}
function caption(s){txt(s,small()?12:25,h-(small()?13:23),small()?10:12,C.muted)}
function drawPoints(opts={}){const p=plot(),colors=[C.accent,C.a2,C.a3];rect(p.x,p.y,p.ww,p.hh,C.surface,C.line,8);points.forEach((v,i)=>{let x=p.x+v.x*p.ww,y=p.y+v.y*p.hh;if(opts.pull){const t=Math.min(1,phase/7);x=p.x+(v.x+(v.group-1)*.08*t)*p.ww;y=p.y+(v.y+(v.group-1)*.03*t)*p.hh}dot(x,y,small()?3.5:5,opts.neutral?C.muted:colors[v.group]);if(opts.focus===i){g.strokeStyle=C.fg;g.lineWidth=2;g.beginPath();g.arc(x,y,small()?7:10,0,7);g.stroke()}});return p}
function head(){g.fillStyle=C.bg;g.fillRect(0,0,w,h);txt(labels[MODE],small()?12:25,small()?21:27,small()?12:17,C.fg,'left',true);const ss=stages[MODE],step=Math.floor(phase/3)%ss.length;txt((step+1)+' / '+ss.length+' '+ss[step],small()?12:25,small()?39:47,small()?10:12,C.muted)}
function words(){const p=plot(),morph=MODE==='morphological-analysis',terms=morph?['개발자','들','이','분석','했다']:['토스','기술','블로그','데이터','분석'];const count=MODE==='n-gram'?Math.max(2,Math.min(3,parameter)):1;const selected=phase%Math.max(1,terms.length-count+1);terms.forEach((t,i)=>{const x=p.x+i*p.ww/5+3,ww=p.ww/5-7,active=morph?(i===0||i===3):MODE==='n-gram'?i>=selected&&i<selected+count:i===phase%terms.length;rect(x,p.y+p.hh*.33,ww,Math.min(42,p.hh*.35),active?C.accent:C.surface,active?C.accent:C.line);txt(t,x+ww/2,p.y+p.hh*.33+Math.min(26,p.hh*.24),small()?9:13,active?C.bg:C.fg,'center');if(morph)txt(i===0||i===3?'명사':i===4?'동사':'조사',x+ww/2,p.y+p.hh*.33+Math.min(60,p.hh*.5),small()?8:11,C.muted,'center')});caption(morph?'형태소와 품사를 나눠 명사만 선택':MODE==='n-gram'?count+'개씩 묶어 순서를 보존':'경계를 옮기며 토큰을 확인')}
function matrix(){const p=plot(),rows=['매체 A','매체 B','매체 C'],cols=['개발','서비스','기술','분석'];const vals=[[8,1,5,0],[2,7,4,0],[1,2,3,8]],df=cols.map((_,i)=>vals.filter(row=>row[i]>0).length);const weight=(v,i)=>MODE==='bag-of-words'?v:v*(Math.log(4/(1+df[i]))+1);const cell=Math.min(p.ww/5,p.hh/4,small()?30:52),ox=p.x+(p.ww-4*cell)/2,oy=p.y+(p.hh-3*cell)/2;cols.forEach((t,i)=>txt(t,ox+i*cell+cell/2,oy-8,small()?9:12,C.muted,'center'));rows.forEach((r,j)=>{txt(r,ox-8,oy+(j+.65)*cell,small()?8:11,C.muted,'right');cols.forEach((_,i)=>{const score=weight(vals[j][i],i),top=Math.max(...vals[j].map((v,k)=>weight(v,k)));const active=MODE==='cluster-keywords'?score===top:(i+j*4)%8<=phase;rect(ox+i*cell,oy+j*cell,cell-3,cell-3,active?C.accent:C.surface,C.line,3);txt(String(Math.round(score*10)/10),ox+(i+.5)*cell-2,oy+(j+.62)*cell,small()?9:12,active?C.bg:C.fg,'center')})});caption(MODE==='bag-of-words'?'행=문서 · 열=단어 · 값=등장 횟수':MODE==='tf-idf'?'문서빈도가 낮은 단어에 IDF를 더함':'각 행에서 가중치가 가장 큰 단어를 대표어로')}
function tfidf(){
const p=plot(),counts=[[8,1,5,0],[2,7,0,0],[1,0,0,8]],df=[3,2,1,1];
const weighted=counts.map(row=>row.map((v,i)=>v*(Math.log(4/(1+df[i]))+1)));
const cell=small()?22:48,gap=small()?26:90,total=8*cell+gap,ox=p.x+(p.ww-total)/2,oy=p.y+(p.hh-3*cell)/2+7;
txt('원 빈도',ox+2*cell,oy-9,small()?9:12,C.muted,'center',true);
txt('TF × IDF',ox+6*cell+gap,oy-9,small()?9:12,C.muted,'center',true);
line(ox+4*cell+4,oy+cell*1.5,ox+4*cell+gap-4,oy+cell*1.5,C.a2,small()?2:3);
[counts,weighted].forEach((table,panel)=>table.forEach((row,j)=>row.forEach((score,i)=>{
const x=ox+panel*(4*cell+gap)+i*cell,y=oy+j*cell,max=panel?14:8;
g.fillStyle=panel?C.accent:C.muted;g.globalAlpha=score?0.16+0.8*score/max:0.04;g.fillRect(x,y,cell-2,cell-2);g.globalAlpha=1;
g.strokeStyle=i===phase%4?C.a2:C.line;g.lineWidth=i===phase%4?1.5:0.7;g.strokeRect(x,y,cell-2,cell-2);
txt(String(Math.round(score*10)/10),x+(cell-2)/2,y+cell*.64,small()?8:11,C.fg,'center');
})));
caption('같은 빈도라도 문서빈도에 따라 모든 칸의 진하기가 바뀜');
}
function sentenceEmbedding(){
const p=plot(),examples=[['캐시 최적화',[.9,.2,.1,.7],C.accent],['DB 성능',[.8,.3,.2,.8],C.a2],['브랜드 캠페인',[.1,.9,.8,.2],C.a3]];
rect(p.x,p.y,p.ww,p.hh,C.surface,C.line,8);
const cols=small()?[.02,.47,.85]:[.04,.43,.83],rowH=(p.hh-22)/3;
['문장','숫자 벡터','의미 점'].forEach((s,i)=>txt(s,p.x+p.ww*cols[i],p.y+15,small()?9:12,C.muted));
examples.forEach(([sentence,vec,col],j)=>{
const y=p.y+24+(j+.55)*rowH,barX=p.x+p.ww*cols[1],barW=small()?8:18;
txt(sentence,p.x+p.ww*cols[0],y+3,small()?8:13,C.fg);
line(p.x+p.ww*.33,y,p.x+p.ww*.41,y,C.line,1);
vec.forEach((v,i)=>{rect(barX+i*(barW+2),y-10,barW,Math.max(3,v*16),col,C.line,2)});
line(barX+4*(barW+2)+3,y,p.x+p.ww*.79,y,C.line,1);
dot(p.x+p.ww*(cols[2]+(j-1)*.017),y,small()?5:7,col);
});
caption('문장 → 고정 길이 벡터 → 가까운 의미 좌표 (개념도)');
}
function dimensionalityReduction(){
const p=plot(),mid=p.x+p.ww*.5,cy=p.y+p.hh*.55,scale=Math.min(p.ww*.38,p.hh*.74),angle=.3+phase*.16;
rect(p.x,p.y,p.ww,p.hh,C.surface,C.line,8);
txt('3D 특징',p.x+p.ww*.24,p.y+16,small()?9:12,C.muted,'center');
txt('2D 투영',p.x+p.ww*.76,p.y+16,small()?9:12,C.muted,'center');
const lx=p.x+p.ww*.24,rx=p.x+p.ww*.76;
line(lx-scale*.44,cy,lx+scale*.44,cy,C.line);line(lx,cy+scale*.39,lx,cy-scale*.39,C.line);line(lx-scale*.22,cy+scale*.2,lx+scale*.22,cy-scale*.2,C.a2);
line(rx-scale*.44,cy,rx+scale*.44,cy,C.line);line(rx,cy+scale*.39,rx,cy-scale*.39,C.line);
points.filter((_,i)=>i%2===0).forEach((v,i)=>{
const x=(v.x-.5)*1.3,y=(v.y-.45)*1.3,z=((i%5)-2)*.17;
const turn=x*Math.cos(angle)-z*Math.sin(angle),depth=x*Math.sin(angle)+z*Math.cos(angle);
const shade=[C.accent,C.a2,C.a3][v.group];
dot(lx+(turn+depth*.28)*scale,cy+(y-depth*.26)*scale,small()?2.6:4,shade);
dot(rx+x*scale,cy+y*scale,small()?2.6:4,shade);
});
txt('→',mid,cy+4,small()?15:22,C.a2,'center',true);
caption('회전하는 3D 점구름의 깊이 축을 접어 2D로');
}
function umap(){
const p=plot(),sample=points.filter((_,i)=>i%2===0),k=Math.min(parameter,sample.length-1),half=p.ww/2;
rect(p.x,p.y,p.ww,p.hh,C.surface,C.line,8);
txt('kNN 그래프 k='+k,p.x+half*.5,p.y+16,small()?9:12,C.muted,'center');
txt('2D 배치',p.x+half*1.5,p.y+16,small()?9:12,C.muted,'center');
line(p.x+half,p.y+6,p.x+half,p.y+p.hh-6,C.line);
const pos=(v,right)=>({x:p.x+(right?half:0)+half*(.1+v.x*.8+(right?(v.group-1)*.05:0)),y:p.y+19+(p.hh-25)*v.y});
sample.forEach((v,i)=>{
const neighbors=sample.map((q,j)=>({j,d:Math.hypot(v.x-q.x,v.y-q.y)})).filter(q=>q.j!==i).sort((a,b)=>a.d-b.d).slice(0,k);
const from=pos(v,false);neighbors.forEach(({j})=>{const to=pos(sample[j],false);line(from.x,from.y,to.x,to.y,C.a2,small()?.7:1)});
});
sample.forEach(v=>{const col=[C.accent,C.a2,C.a3][v.group];for(const right of [false,true]){const q=pos(v,right);dot(q.x,q.y,small()?2.7:4.5,col)}});
caption('n_neighbors='+k+' · 이웃선을 만든 뒤 당겨 놓는 단순화한 시연');
}
function tsne(){
const p=plot(),sample=points,half=p.ww/2;
rect(p.x,p.y,p.ww,p.hh,C.surface,C.line,8);
txt('perplexity 5',p.x+half*.5,p.y+16,small()?9:12,C.muted,'center');
txt('perplexity '+parameter,p.x+half*1.5,p.y+16,small()?9:12,C.muted,'center');
line(p.x+half,p.y+6,p.x+half,p.y+p.hh-6,C.line);
const centers=[[[.2,.47],[.49,.49],[.79,.47]],[[.21,.27],[.77,.34],[.49,.72]]];
sample.forEach(v=>{for(let panel=0;panel<2;panel++){
const center=centers[panel][v.group],spread=panel===0?.68:.52+(parameter-5)*.006;
const localX=(v.x-(.22+v.group*.28))*spread,localY=(v.y-.45)*spread;
const x=p.x+panel*half+half*(center[0]+localX),y=p.y+20+(p.hh-26)*(center[1]+localY);
dot(x,y,small()?2.8:4.5,[C.accent,C.a2,C.a3][v.group]);
}});
caption('같은 데이터, 다른 섬 배치 · 군집 간 거리 해석 금지');
}
function clusterKeywords(){
const p=plot(),terms=['캐시','인증','검색','서비스'],counts=[[7,0,1,6],[0,7,0,5],[1,0,8,4]],colors=[C.accent,C.a2,C.a3];
const df=terms.map((_,i)=>counts.filter(row=>row[i]>0).length);
const weights=counts.map(row=>row.map((count,i)=>count*(Math.log(4/(1+df[i]))+1)));
const names=weights.map(row=>terms[row.indexOf(Math.max(...row))]);
rect(p.x,p.y,p.ww,p.hh,C.surface,C.line,8);
points.forEach(v=>dot(p.x+v.x*p.ww,p.y+v.y*p.hh,small()?3:5,colors[v.group]));
names.forEach((name,j)=>{
const ps=points.filter(v=>v.group===j),mx=ps.reduce((s,v)=>s+v.x,0)/ps.length;
const x=p.x+mx*p.ww,bw=small()?47:85,bh=small()?19:27,y=p.y+6;
rect(x-bw/2,y,bw,bh,C.surface,colors[j],6);
txt(name,x,y+bh*.69,small()?10:14,colors[j],'center',true);
line(x,y+bh,x,p.y+p.hh*.38,colors[j],1);
});
caption('군집 점 위에 TF-IDF 대표어 후보를 붙이고 원문 확인');
}
function bars(){const p=plot(),v=MODE==='long-tail-distribution'?[26,16,10,6,4,3,2,1,1,1,1,1]:MODE==='log-odds-ratio'?[-2,-1.5,-.6,.2,.7,1.3,2.2]:[3,6,9,12,15,18,22];const max=Math.max(...v.map(Math.abs));line(p.x,p.y+p.hh*.55,p.x+p.ww,p.y+p.hh*.55,C.line,2);v.forEach((n,i)=>{const bw=p.ww/v.length*.7,x=p.x+(i+.15)*p.ww/v.length,hh=Math.abs(n)/max*p.hh*.35;const y=n>=0?p.y+p.hh*.55-hh:p.y+p.hh*.55;rect(x,y,bw,Math.max(2,hh),i<=phase?C.accent:C.a2,C.line,2)});caption(MODE==='log-odds-ratio'?'왼쪽=매체 B · 오른쪽=매체 A':'소수 단어는 매우 잦고 대부분은 드묾')}
function vectors(){const p=plot(),cx=p.x+p.ww*.5,cy=p.y+p.hh*.55,scale=Math.min(p.ww,p.hh)*.35;line(p.x+8,cy,p.x+p.ww-8,cy);line(cx,p.y+8,cx,p.y+p.hh-8);const a=[.9,-.5],b=MODE==='cosine-similarity'?[.75,-.62]:[.45,-.8];[[a,C.accent,'A'],[b,C.a2,'B']].forEach(([v,col,name])=>{line(cx,cy,cx+v[0]*scale,cy+v[1]*scale,col,4);dot(cx+v[0]*scale,cy+v[1]*scale,5,col);txt(name,cx+v[0]*scale+7,cy+v[1]*scale-5,11,col)});caption(MODE==='cosine-similarity'?'각도가 작을수록 높음 · 절대값보다 순위':'축마다 하나의 특징값을 놓은 좌표')}
function centroid(){const p=drawPoints(),colors=[C.accent,C.a2,C.a3];for(let j=0;j<3;j++){const ps=points.filter(v=>v.group===j),mx=ps.reduce((s,v)=>s+v.x,0)/ps.length,my=ps.reduce((s,v)=>s+v.y,0)/ps.length;dot(p.x+mx*p.ww,p.y+my*p.hh,small()?7:10,colors[j]);txt('×',p.x+mx*p.ww,p.y+my*p.hh+4,13,C.bg,'center',true)}caption('각 집단 좌표의 산술평균을 중심으로')}
function pca(){const p=drawPoints({neutral:true}),mx=points.reduce((s,v)=>s+v.x,0)/points.length,my=points.reduce((s,v)=>s+v.y,0)/points.length;let xx=0,xy=0,yy=0;for(const q of points){const x=q.x-mx,y=q.y-my;xx+=x*x;xy+=x*y;yy+=y*y}let vx=1,vy=1;for(let i=0;i<12;i++){const nx=xx*vx+xy*vy,ny=xy*vx+yy*vy,n=Math.hypot(nx,ny)||1;vx=nx/n;vy=ny/n}const cx=p.x+mx*p.ww,cy=p.y+my*p.hh,len=Math.min(p.ww,p.hh)*.43;line(cx-vx*len,cy-vy*len,cx+vx*len,cy+vy*len,C.accent,3);dot(cx,cy,5,C.a2);const variance=(xx*vx*vx+2*xy*vx*vy+yy*vy*vy)/(xx+yy);caption('제1주성분 · 2D → 1D · 분산 '+Math.round(variance*100)+'%')}
function clusters(){const p=drawPoints({pull:MODE==='umap'||MODE==='t-sne',neutral:MODE==='hdbscan'});if(MODE==='k-means'){const colors=[C.accent,C.a2,C.a3],k=Math.max(2,Math.min(3,parameter));let centers=[[.2,.25],[.5,.75],[.8,.24]].slice(0,k);for(let z=0;z<Math.max(1,phase);z++){const bins=centers.map(()=>[]);for(const v of points){let idx=0,best=Infinity;centers.forEach((q,j)=>{const d=(v.x-q[0])**2+(v.y-q[1])**2;if(d<best){best=d;idx=j}});bins[idx].push(v)}centers=centers.map((q,j)=>bins[j].length?[bins[j].reduce((s,v)=>s+v.x,0)/bins[j].length,bins[j].reduce((s,v)=>s+v.y,0)/bins[j].length]:q)}centers.forEach((q,j)=>{dot(p.x+q[0]*p.ww,p.y+q[1]*p.hh,9,colors[j]);txt('×',p.x+q[0]*p.ww,p.y+q[1]*p.hh+4,13,C.bg,'center',true)})}
else if(MODE==='hdbscan'){
// A thresholded neighbor graph illustrates the hierarchy; this does not compute HDBSCAN stability.
const threshold=.075+phase*.018,parent=points.map((_,i)=>i),edges=[];
function root(i){while(parent[i]!==i)i=parent[i]=parent[parent[i]];return i}
points.forEach((a,i)=>points.forEach((b,j)=>{if(j<=i)return;if(Math.hypot(a.x-b.x,a.y-b.y)<threshold){parent[root(j)]=root(i);edges.push([i,j])}}));
const sizes=new Map();points.forEach((_,i)=>sizes.set(root(i),(sizes.get(root(i))||0)+1));
edges.forEach(([i,j])=>{if(sizes.get(root(i))>=parameter)line(p.x+points[i].x*p.ww,p.y+points[i].y*p.hh,p.x+points[j].x*p.ww,p.y+points[j].y*p.hh,C.accent,1.5)});
points.forEach((v,i)=>{if(sizes.get(root(i))>=parameter)dot(p.x+v.x*p.ww,p.y+v.y*p.hh,small()?4:6,C.accent)});
}
else if(MODE==='dbscan'){const eps=.065+parameter*.009,minPts=3;points.forEach((v,i)=>{const near=points.filter(q=>Math.hypot(v.x-q.x,v.y-q.y)<=eps);if(i<=phase*4&&near.length>=minPts){g.strokeStyle=C.accent;g.lineWidth=1;g.beginPath();g.arc(p.x+v.x*p.ww,p.y+v.y*p.hh,eps*p.ww,0,7);g.stroke()}})}
caption(MODE==='k-means'?'가까운 중심에 배정 → 중심을 다시 계산':MODE==='dbscan'?'ε 반경의 이웃 수로 핵심점 판정':MODE==='hdbscan'?'회색 점=노이즈(−1) · 안정도 생략한 시연':MODE==='umap'?'근접 그래프의 당김·밀어냄을 단순화한 시연':'이웃 확률을 맞추는 움직임을 단순화한 시연')}
function distribution(){const p=plot(),vals=[2,3,3,4,4,5,6,7,8,9,10,11,12,14,18,29],min=2,max=29,px=v=>p.x+12+(v-min)/(max-min)*(p.ww-24),cy=p.y+p.hh*.54;line(px(min),cy,px(max),cy,C.line,2);vals.forEach((v,i)=>dot(px(v),cy-((i%3)-1)*10,small()?3:4,i<=phase*2?C.accent:C.muted));if(MODE==='median-and-iqr'){const q1=4,q2=7.5,q3=11.5;rect(px(q1),cy-28,px(q3)-px(q1),56,C.surface,C.accent,2);line(px(q2),cy-28,px(q2),cy+28,C.a2,3);txt('Q1',px(q1),cy-36,11,C.fg,'center');txt('중앙값',px(q2),cy-36,11,C.fg,'center');txt('Q3',px(q3),cy-36,11,C.fg,'center')}else{const mean=vals.reduce((a,b)=>a+b,0)/vals.length,sd=Math.sqrt(vals.reduce((a,b)=>a+(b-mean)**2,0)/vals.length);line(px(mean),cy-42,px(mean),cy+42,C.a2,2);txt('평균',px(mean),cy-48,11,C.a2,'center');txt('z = '+((vals[Math.min(vals.length-1,phase+5)]-mean)/sd).toFixed(2),p.x+10,p.y+22,small()?10:13,C.fg)}caption(MODE==='median-and-iqr'?'가운데 50%는 Q1–Q3 상자 안에':'z = (값 − 평균) / 표준편차')}
function pipeline(){const p=plot(),names=['글','명사','빈도','벡터','군집'],n=names.length,bw=Math.min(100,(p.ww-24)/n*.78),cy=p.y+p.hh*.55;names.forEach((t,i)=>{const x=p.x+(i+.5)*p.ww/n;rect(x-bw/2,cy-25,bw,50,i<=phase%n?C.accent:C.surface,C.line,6);txt(t,x,cy+5,small()?10:14,i<=phase%n?C.bg:C.fg,'center',true);if(i<n-1)line(x+bw/2+3,cy,p.x+(i+1.5)*p.ww/n-bw/2-3,cy,C.a2,2)});caption('각 단계의 출력이 다음 단계의 입력')}
function draw(){
if(!w)return;
head();
if(['tokenization','morphological-analysis','n-gram'].includes(MODE))words();
else if(MODE==='bag-of-words')matrix();
else if(MODE==='tf-idf')tfidf();
else if(MODE==='cluster-keywords')clusterKeywords();
else if(MODE==='sentence-embedding')sentenceEmbedding();
else if(MODE==='dimensionality-reduction')dimensionalityReduction();
else if(MODE==='umap')umap();
else if(MODE==='t-sne')tsne();
else if(['log-odds-ratio','long-tail-distribution'].includes(MODE))bars();
else if(['cosine-similarity','vector-space'].includes(MODE))vectors();
else if(MODE==='centroid')centroid();
else if(MODE==='pca')pca();
else if(['k-means','dbscan','hdbscan'].includes(MODE))clusters();
else if(['median-and-iqr','z-score'].includes(MODE))distribution();
else if(MODE==='text-analysis-pipeline')pipeline();
}
function tick(t){const interval=paramNames[MODE]?600:Math.max(300,900-parameter*75);if(running&&t-last>interval){phase=(phase+1)%9;last=t;draw()}requestAnimationFrame(tick)}
play.onclick=()=>{running=!running;play.textContent=running?'일시정지':'재생'};
next.onclick=()=>{running=false;play.textContent='재생';phase=(phase+1)%9;draw()};
reset.onclick=()=>{phase=0;draw()};
range.oninput=()=>{parameter=Number(range.value);value.textContent=String(parameter);phase=0;draw()};
addEventListener('resize',resize);resize();requestAnimationFrame(tick);