Files
mcn-short-video/project/短视频脚本创作/V1.0/subskills/lieflat-charts/templates/lupi-gallery.html
T
maogeigei cad356047c 工作台视频列表页新增点赞/评论趋势折线图(lieflat-charts G8 骨架)
- 引入 lieflat-charts 第三方图表技能到 subskills/(SKILL.md+catalog+templates,已删嵌套 .git)
- ECharts 6.1.0 本地化至 mcn-work-shop/public/vendor/(零 CDN 依赖,离线可用)
- 账号详情视频列表 tab 上方新增趋势图:X=发布时间升序,点赞/评论双序列面积折线
  (G8 Rainfall 骨架:双 grid 独立量级、同 X 联动 pointer、tipLight tooltip、quarticOut 入场)
- 全量视频数据单独拉取(pageSize=500,不随分页变化);numOf 解析 'x.x万' 文本
- V1.0/SKILL.md 登记 lieflat-charts 外部技能依赖
2026-09-02 11:09:12 +08:00

1221 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Lieflat Charts — Lupi Gallery · 20 张参考实现</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{--bg:#F0EFEB;--dark:#1C1C1A;--ink:#1C1C1A;--muted:#8F8E88;--faint:#C6C5BF;--grid:#DEDDD6}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);font-family:'Inter',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1400px;margin:0 auto}
.card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
.card.dark{background:var(--dark);color:#F0EFEB}
.card.dark .sub{color:#8F8E88}
.card.dark .src{color:#55554F}
.card.wide{grid-column:1/-1}
.split{display:grid;grid-template-columns:250px 1fr;gap:30px;align-items:start}
h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
.sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
.src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
.note{font-size:11px;color:#6A6963;line-height:1.65;margin-bottom:16px}
.legend{font-size:9px;letter-spacing:.1em;font-weight:600;color:var(--muted);line-height:2}
svg{width:100%;max-height:330px;display:block;margin:0 auto}
svg text{font-family:'Inter',sans-serif}
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
@keyframes pop{from{transform:scale(0)}to{transform:none}}
.fade{animation:fade .9s ease both}
@keyframes fade{from{opacity:0}}
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
@keyframes draw{to{stroke-dashoffset:0}}
@media (prefers-reduced-motion:reduce){
.pop,.fade{animation:none}
.draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
}
.pagehead{max-width:1400px;margin:0 auto 26px;padding:0 4px}
.pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
.pagehead p{font-size:12px;color:#8F8E88;margin-top:5px;line-height:1.7}
.pagehead .badge{margin:0 2px}
.badge{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.08em;
padding:3px 10px;border-radius:99px;margin-bottom:10px}
.badge.del{background:#1C1C1A;color:#F0EFEB}
.badge.pick{border:1.5px solid #1C1C1A;color:#1C1C1A}
.badge.pair{border:1px dashed #B0AFA9;color:#8F8E88}
.card.dark .badge.del{background:#F0EFEB;color:#1C1C1A}
.card.dark .badge.pick{border-color:#F0EFEB;color:#F0EFEB}
.card.dark .badge.pair{border-color:#55554F;color:#8F8E88}
</style>
</head>
<body>
<div class="pagehead">
<h1>Lupi 系(细读)· 20 张参考实现</h1>
<p>发丝线 · 逐记录 · 30 秒阅读。</p>
</div>
<div class="grid2">
<!-- 1 · launch fan -->
<div class="card">
<h2>Twelve features, fanned out</h2>
<div class="sub">one spoke per feature · big dot = launch week, sized by MAU today · small dots = every week since</div>
<svg id="fan" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">LAUNCH FAN · MONO-EDITORIAL · PRODUCT ANALYTICS</div>
</div>
<!-- 4 · dot cascade on dark -->
<div class="card dark">
<h2>What breaks, stacked and ranked</h2>
<div class="sub">incidents by root cause · 2019 → 2026 · one dot = 2 incidents · sorted ascending</div>
<svg id="cascade" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">DOT CASCADE · MONO-EDITORIAL · INCIDENT LOG</div>
</div>
<!-- 2 · barcode lollipop, wide + editorial text column -->
<div class="card wide">
<div class="split">
<div>
<h2>Ninety days as a barcode</h2>
<div class="sub">peak concurrent users, daily · Apr – Jun 2026</div>
<div class="note">Every hairline is a day, whether or not anything
happened in it. The dot marks the day's peak; the stem below it is
just gravity. Read the field, not the numbers — the season has a
texture before it has a value.</div>
<div class="legend">
● &nbsp;WEEKDAY PEAK<br>
○ &nbsp;WEEKEND PEAK<br>
│ &nbsp;ONE CALENDAR DAY<br>
◉ &nbsp;TOP-3, LABELED
</div>
</div>
<div>
<svg id="barcode" viewBox="0 0 800 300" preserveAspectRatio="xMidYMid meet" style="max-height:340px"></svg>
<div class="src">BARCODE LOLLIPOP · MONO-EDITORIAL · LIVE OPS</div>
</div>
</div>
</div>
<!-- 3 · arc bubble matrix -->
<div class="card">
<h2>Eight products land in twelve cities</h2>
<div class="sub">bubble = paying accounts · each row bows like a horizon</div>
<svg id="arcmatrix" viewBox="0 0 430 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">ARC MATRIX · MONO-EDITORIAL · SALES GEO</div>
</div>
<!-- 5 · radial convergence -->
<div class="card">
<h2>48 requests pull toward five themes</h2>
<div class="sub">every rim node = a feature request · lines bundle into the theme that claimed it</div>
<svg id="converge" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">RADIAL CONVERGENCE · MONO-EDITORIAL · FEEDBACK BOARD</div>
</div>
<!-- 6 · cluster field, wide -->
<div class="card wide">
<h2>The contributor field</h2>
<div class="sub">80 core contributors orbit the main repo · satellite islands keep their own gravity · dotted paths = cross-contribution</div>
<svg id="clusters" viewBox="0 0 840 320" preserveAspectRatio="xMidYMid meet" style="max-height:360px"></svg>
<div class="src">CLUSTER FIELD · MONO-EDITORIAL · CONTRIBUTOR GRAPH</div>
</div>
<!-- 1 · brand spectrum -->
<div class="card">
<h2>Where the brand sits</h2>
<div class="sub">panel of 120 customers placed us between opposing traits · big dot = us · small dots = competitors</div>
<svg id="spectrum" viewBox="0 0 460 300" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">BRAND SPECTRUM · MONO-EDITORIAL2 · BRAND SURVEY</div>
</div>
<!-- 2 · dotty matrix -->
<div class="card">
<h2>Four squads, stacked in space</h2>
<div class="sub">each plane = one squad · 6 × 6 weeks of activity · dot area = tasks closed</div>
<svg id="dotty" viewBox="0 0 400 372" preserveAspectRatio="xMidYMid meet" style="max-height:340px"></svg>
<div class="src">DOTTY MATRIX · MONO-EDITORIAL2 · SPRINT BOARD</div>
</div>
<!-- 3 · ticket almanac, wide -->
<div class="card wide">
<h2>Eight years of tickets, one almanac</h2>
<div class="sub">bubble area = support tickets per product area, per year · bubbles overlap where years pile up · dark core = escalations · dashed ring = still in beta</div>
<svg id="almanac" viewBox="0 0 840 400" preserveAspectRatio="xMidYMid meet" style="max-height:420px"></svg>
<div class="src">BUBBLE ALMANAC · MONO-EDITORIAL2 · SUPPORT DESK</div>
</div>
<!-- 4 · deploy patchwork -->
<div class="card">
<h2>A quarter of deploys, overlaid</h2>
<div class="sub">each wedge = one deploy · angle = time of day · reach = files touched · ink builds where deploys pile up</div>
<svg id="patchwork" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">RADIAL PATCHWORK · MONO-EDITORIAL2 · DEPLOY LOG</div>
</div>
<!-- 6 · trend lineage -->
<div class="card">
<h2>Features rise, fall, come back</h2>
<div class="sub">● shipped · ○ reworked · dashed = dormant years · line runs on if it's still alive today</div>
<svg id="lineage" viewBox="0 0 400 340" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">TREND LINEAGE · MONO-EDITORIAL2 · PRODUCT HISTORY</div>
</div>
<!-- 8 · type colonnade -->
<div class="card">
<h2>Forty-four repos, ten owners</h2>
<div class="sub">every hairline hands a repo to the team that owns it · the pile-ups are the org chart nobody drew</div>
<svg id="colonnade" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">TYPE COLONNADE · MONO-EDITORIAL2 · CODE OWNERSHIP</div>
</div>
<!-- 9 · funnel hourglass -->
<div class="card">
<h2>The funnel, poured</h2>
<div class="sub">each strip's width = people at that stage · threads trickle down · Q2 2026 cohort</div>
<svg id="hourglass" viewBox="0 0 400 330" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">HOURGLASS STREAM · MONO-EDITORIAL2 · GROWTH</div>
</div>
<!-- 14 · hundred field -->
<div class="card">
<span class="badge pair">小数据 · 单位分解</span>
<h2>A hundred of us, four minds</h2>
<div class="sub">one dot = one person in a hundred · four attitude clusters, sized by share · 2026 survey</div>
<svg id="hundredfield" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">HUNDRED FIELD · MONO-SMALL · ATTITUDE SURVEY</div>
</div>
<!-- 15 · ballot tally -->
<div class="card">
<span class="badge pair">小数据 · 单位分解</span>
<h2>What they fear, tick by tick</h2>
<div class="sub">one tick = one respondent in a hundred · inked = picked it · they could pick several</div>
<svg id="ballottally" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">BALLOT TALLY · MONO-SMALL · WORKPLACE SURVEY</div>
</div>
<!-- L16 · matrix heat -->
<div class="card">
<span class="badge">LUPI 编辑型 · 矩阵热力</span>
<h2>Which features get used together</h2>
<div class="sub">8 × 8 co-usage · shade = share of accounts using both · dashed square = strongest pair</div>
<svg id="matheat" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">MATRIX HEAT · MONO-EDITORIAL · PRODUCT ANALYTICS</div>
</div>
<!-- L17 · calendar heat -->
<div class="card wide">
<span class="badge">LUPI 编辑型 · 日历热力</span>
<h2>A year of deploys, day by day</h2>
<div class="sub">52 weeks × 7 days · dot area = deploys that day · tiny dot = a quiet day · dashed ring = the busiest day of 2025</div>
<svg id="calheat" viewBox="0 0 840 232" preserveAspectRatio="xMidYMid meet" style="max-height:260px"></svg>
<div class="src">CALENDAR HEAT · MONO-EDITORIAL · DEPLOY LOG</div>
</div>
<!-- L18 · beeswarm -->
<div class="card">
<span class="badge">LUPI 编辑型 · 蜂群</span>
<h2>A hundred and twenty deals, swarming</h2>
<div class="sub">one dot = one closed deal · the swarm piles up where deals cluster · hollow = enterprise · dashed = median</div>
<svg id="beeswarm" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">BEESWARM · MONO-EDITORIAL · SALES OPS</div>
</div>
<!-- L19 · ridgeline -->
<div class="card">
<span class="badge">LUPI 编辑型 · RIDGELINE</span>
<h2>Five pipelines, five tempos</h2>
<div class="sub">each crest = one pipeline's completion-time shape · the area is made of hairlines · dot = the peak</div>
<svg id="ridge" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">RIDGELINE · MONO-EDITORIAL · DATA PLATFORM</div>
</div>
<!-- L20 · parallel coordinates -->
<div class="card">
<span class="badge">LUPI 编辑型 · 平行坐标</span>
<h2>Twelve products, four dimensions</h2>
<div class="sub">one hairline = one product across price · satisfaction · retention · growth · ink = the one that wins everywhere</div>
<svg id="parallel" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
<div class="src">PARALLEL COORDS · MONO-EDITORIAL · PRODUCT PANEL</div>
</div>
</div>
<script>
// ── shared Mono tokens ──
const INK='#1C1C1A',PAPER='#F0EFEB',MUTED='#8F8E88',GRID='#DEDDD6';
const NS='http://www.w3.org/2000/svg';
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000; // deterministic
const D2R=Math.PI/180;
const pol=(cx,cy,r,deg)=>[cx+r*Math.cos(deg*D2R),cy+r*Math.sin(deg*D2R)];
const sect=(cx,cy,r0,r1,a0,a1)=>{
const big=a1-a0>180?1:0;
const [xa,ya]=pol(cx,cy,r1,a0),[xb,yb]=pol(cx,cy,r1,a1);
const [xc,yc]=pol(cx,cy,r0,a1),[xd,yd]=pol(cx,cy,r0,a0);
return `M${xa} ${ya} A${r1} ${r1} 0 ${big} 1 ${xb} ${yb} L${xc} ${yc} A${r0} ${r0} 0 ${big} 0 ${xd} ${yd} Z`;
};
// unified reveal: draw when scrolled into view; click replays
const obsReveal=(id,fn)=>{
const n=document.getElementById(id);
const go=()=>{n.innerHTML='';fn(n)};
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
io.observe(n);
n.style.cursor='pointer';
n.addEventListener('click',go);
};
// ════ 1 · launch fan ════
// spokes sorted by launch week → the big dots cascade along a diagonal arc
(()=>{
const FEAT=['Editor','Boards','Docs','Chat','Flows','Vault','Pages','Sync','Grid','Views','Hub','Forms'];
const LAUNCH=[1,3,4,6,8,9,11,12,14,16,17,19]; // week of launch
const CX=40,CY=304,rOf=w=>56+w*9.7;
obsReveal('fan',s=>{
const ang=i=>-84+i*(76/11);
// dotted week guides
[5,10,15,20].forEach((w,gi)=>{
const r=rOf(w),[x1,y1]=pol(CX,CY,r,-84),[x2,y2]=pol(CX,CY,r,-8);
el(s,'path',{d:`M${x1} ${y1} A${r} ${r} 0 0 1 ${x2} ${y2}`,fill:'none',
stroke:'#D8D6CE','stroke-width':1,'stroke-dasharray':'2 5',
class:'fade',style:`animation-delay:${gi*.1}s`});
const [lx,ly]=pol(CX,CY,r,-87.5);
txt(s,{x:lx,y:ly,'font-size':6.5,fill:'#B0AFA9','text-anchor':'middle',
class:'fade',style:`animation-delay:${gi*.1}s`},'W'+w);
});
FEAT.forEach((name,i)=>{
const a=ang(i),L=LAUNCH[i];
const mau=Math.round(48-i*3+rnd(i+1,3)*10);
// hairline spoke from launch to rim
const [sx,sy]=pol(CX,CY,rOf(L),a),[ex,ey]=pol(CX,CY,rOf(20),a);
el(s,'line',{x1:sx,y1:sy,x2:ex,y2:ey,stroke:'#E3E2DB','stroke-width':.7,
class:'fade',style:`animation-delay:${.15+i*.05}s`});
// weekly texture dots
for(let w=L+1;w<=20;w++){
const [x,y]=pol(CX,CY,rOf(w),a);
el(s,'circle',{cx:x,cy:y,r:1.1+rnd(i+1,w)*1.2,fill:'#B0AFA9',opacity:.8,
class:'pop',style:`animation-delay:${.2+i*.06+(w-L)*.015}s`});
}
// launch dot, sized by MAU
const [bx,by]=pol(CX,CY,rOf(L),a);
const big=el(s,'circle',{cx:bx,cy:by,r:Math.sqrt(mau)*1.15,fill:INK,
class:'pop',style:`animation-delay:${.15+i*.07}s`});
tip(big,`${name} — launched W${L} · ${mau}k MAU`);
// rim label along the spoke
const [tx,ty]=pol(CX,CY,rOf(20)+9,a);
txt(s,{x:tx,y:ty,'font-size':7,'font-weight':600,fill:'#6A6963',
transform:`rotate(${a} ${tx} ${ty})`,'dominant-baseline':'middle',
class:'fade',style:`animation-delay:${.3+i*.05}s`},name);
});
});
})();
// ════ 4 · dot cascade on dark ════
(()=>{
const CAUSE=['DNS','CDN','QUOTA','DISK','CERT','CACHE','QUEUE','LOCK','OOM','NET','3P API','CONFIG','DB','DEPLOY','CODE','HUMAN'];
const COUNT=[1,1,2,3,3,4,5,6,7,9,11,13,15,18,22,27];
obsReveal('cascade',s=>{
const x=i=>34+i*22.2, base=i=>272-i*7.5;
// dotted diagonal baseline
el(s,'line',{x1:x(0)-8,y1:base(0)+4,x2:x(15)+8,y2:base(15)+4,
stroke:'#4A4944','stroke-width':1,'stroke-dasharray':'2 4',class:'fade'});
CAUSE.forEach((name,i)=>{
const n=Math.ceil(COUNT[i]/2),bx=x(i),by=base(i);
for(let k=0;k<n-1;k++)
el(s,'circle',{cx:bx,cy:by-12-k*8.8,r:2.2,fill:'#B3B0A4',opacity:.9,
class:'pop',style:`animation-delay:${i*.05+k*.03}s`});
const topY=by-12-(n-1)*8.8;
const top=el(s,'circle',{cx:bx,cy:topY,r:4.6,fill:PAPER,
class:'pop',style:`animation-delay:${i*.05+n*.03}s`});
tip(top,`${name} — ${COUNT[i]} incidents`);
txt(s,{x:bx,y:topY-10,'font-size':9,'font-weight':700,fill:PAPER,'text-anchor':'middle',
class:'fade',style:`animation-delay:${.2+i*.05}s`},COUNT[i]);
txt(s,{x:bx,y:by+12,'font-size':6.5,'font-weight':600,fill:'#6A6963','text-anchor':'end',
transform:`rotate(-90 ${bx} ${by+12})`,
class:'fade',style:`animation-delay:${.1+i*.05}s`},name);
});
});
})();
// ════ 2 · barcode lollipop ════
(()=>{
const N=90;
const val=d=>95+55*Math.sin(d/9.5)+30*Math.sin(d/3.7)+rnd(d+1,5)*40;
obsReveal('barcode',s=>{
// the barcode: one hairline per day, full height
for(let d=0;d<N;d++){
const x=18+d*8.53;
el(s,'line',{x1:x,y1:8,x2:x,y2:258,stroke:GRID,'stroke-width':.7,
class:'fade',style:`animation-delay:${d*.004}s`});
}
const vs=Array.from({length:N},(_,d)=>val(d));
// top-3 peaks, kept at least 6 days apart so labels never collide
const top3=[];
for(const d of [...vs.keys()].sort((a,b)=>vs[b]-vs[a])){
if(top3.every(t=>Math.abs(t-d)>=6))top3.push(d);
if(top3.length===3)break;
}
for(let d=0;d<N;d++){
const x=18+d*8.53,v=vs[d],y=252-v*.75;
const weekend=d%7===5||d%7===6;
const stemEnd=Math.min(256,y+14+rnd(d+1,9)*26);
el(s,'line',{x1:x,y1:y,x2:x,y2:stemEnd,stroke:INK,'stroke-width':1.1,
class:'fade',style:`animation-delay:${.3+d*.008}s`});
const big=top3.includes(d);
const dot=el(s,'circle',{cx:x,cy:y,r:big?4.6:2.7,
fill:weekend?PAPER:INK,stroke:INK,'stroke-width':weekend?1.2:0,
class:'pop',style:`animation-delay:${.3+d*.008}s`});
tip(dot,`Day ${d+1} — ${Math.round(v)}k peak`);
if(big)txt(s,{x,y:y-10,'font-size':9,'font-weight':800,fill:INK,'text-anchor':'middle',
class:'fade',style:`animation-delay:${.9+d*.004}s`},Math.round(v)+'k');
}
[[0,'APR'],[30,'MAY'],[61,'JUN']].forEach(([d,m])=>
txt(s,{x:18+d*8.53,y:276,'font-size':8,'font-weight':600,fill:MUTED,
'letter-spacing':'.12em',class:'fade'},m));
});
})();
// ════ 3 · arc bubble matrix ════
(()=>{
const PROD=['Editor','Boards','Docs','Flows','Chat','Vault','Pages','Sync'];
const CITY=['SF','NYC','LON','BER','TOK','SYD','SIN','PAR','AMS','TOR','SEO','SAO'];
const W=[9,8,7,7,6,5,5,4,4,3,3,2]; // market weight
obsReveal('arcmatrix',s=>{
const rowY=i=>74+i*29, colX=j=>92+j*27, dy=j=>-16*Math.sin(Math.PI*j/11);
const v=(i,j)=>{
if(rnd(i*13+1,j*7+3)<.09)return 0; // a few true absences
const age=1-i*.085; // older rows reach further
return Math.min(40,Math.round(W[j]*3.4*age*(.45+rnd(i+1,j+1)*.85)));
};
// find top-4 cells for labels
const all=[];
PROD.forEach((_,i)=>CITY.forEach((_,j)=>all.push([v(i,j),i,j])));
const top=all.sort((a,b)=>b[0]-a[0]).slice(0,4).map(d=>d[1]*100+d[2]);
PROD.forEach((p,i)=>{
// the horizon arc
const d='M'+CITY.map((_,j)=>`${colX(j)} ${rowY(i)+dy(j)}`).join(' L ');
el(s,'path',{d,fill:'none',stroke:'#E3E2DB','stroke-width':1,pathLength:1,
class:'draw',style:`animation-delay:${i*.08}s`});
txt(s,{x:84,y:rowY(i)+3,'font-size':8,'font-weight':600,fill:'#6A6963','text-anchor':'end',
class:'fade',style:`animation-delay:${i*.08}s`},p);
CITY.forEach((c,j)=>{
const x=colX(j),y=rowY(i)+dy(j),vv=v(i,j);
if(!vv){el(s,'circle',{cx:x,cy:y,r:.9,fill:'#D8D6CE',
class:'pop',style:`animation-delay:${.2+i*.08+j*.02}s`});return}
const fill=vv>=25?INK:vv>=12?'#6A6963':'#B0AFA9';
const dot=el(s,'circle',{cx:x,cy:y,r:Math.sqrt(vv)*1.3,fill,
class:'pop',style:`animation-delay:${.2+i*.08+j*.02}s`});
tip(dot,`${p} · ${c} — ${vv} accounts`);
if(top.includes(i*100+j))
txt(s,{x,y:y-Math.sqrt(vv)*1.3-4,'font-size':7,'font-weight':800,fill:INK,
'text-anchor':'middle',class:'fade',style:`animation-delay:${.6}s`},vv);
});
});
CITY.forEach((c,j)=>{
const x=colX(j),y=74+dy(j)-24;
txt(s,{x,y,'font-size':7,'font-weight':700,fill:MUTED,'letter-spacing':'.08em',
transform:`rotate(-55 ${x} ${y})`,class:'fade',style:`animation-delay:${j*.03}s`},c);
});
});
})();
// ════ 5 · radial convergence ════
(()=>{
const N=48,CX=200,CY=162,R=116;
const THEME=[['PERF',15,-75],['INTEGRATIONS',10,-3],['PRICING',9,69],['MOBILE',8,141],['UX',6,213]];
obsReveal('converge',s=>{
// hub assignment: contiguous blocks with a little organic leakage
const blocks=THEME.flatMap(([,n],h)=>Array(n).fill(h));
const hubOf=i=>rnd(i+1,11)>.92?(blocks[i]+2)%5:blocks[i];
const hubPt=h=>pol(CX,CY,34,THEME[h][2]);
const counts=[0,0,0,0,0];
for(let i=0;i<N;i++)counts[hubOf(i)]++;
for(let i=0;i<N;i++){
const deg=i/N*360-90,[px,py]=pol(CX,CY,R,deg);
const h=hubOf(i),[hx,hy]=hubPt(h);
const c1x=CX+(px-CX)*.42,c1y=CY+(py-CY)*.42;
const c2x=CX+(hx-CX)*.3,c2y=CY+(hy-CY)*.3;
el(s,'path',{d:`M${px} ${py} C${c1x} ${c1y} ${c2x} ${c2y} ${hx} ${hy}`,
fill:'none',stroke:'#A8A7A0','stroke-width':.7,opacity:.55,pathLength:1,
class:'draw',style:`animation-delay:${i*.018}s`});
el(s,'circle',{cx:px,cy:py,r:1.6,fill:'#6A6963',
class:'pop',style:`animation-delay:${i*.018}s`});
// tiny rim code, flipped on the left half so it stays readable
const flip=deg>90&&deg<270,[lx,ly]=pol(CX,CY,R+7,deg);
txt(s,{x:lx,y:ly,'font-size':5.5,fill:MUTED,
'text-anchor':flip?'end':'start','dominant-baseline':'middle',
transform:`rotate(${flip?deg+180:deg} ${lx} ${ly})`,
class:'fade',style:`animation-delay:${.2+i*.01}s`},'R-'+String(i+1).padStart(2,'0'));
}
THEME.forEach(([name,,deg],h)=>{
const [hx,hy]=hubPt(h);
const hub=el(s,'circle',{cx:hx,cy:hy,r:Math.sqrt(counts[h])*1.55,fill:INK,
class:'pop',style:`animation-delay:${.5+h*.08}s`});
tip(hub,`${name} — ${counts[h]} requests`);
// theme label parked outside the rim, past the code ring
const [tx,ty]=pol(CX,CY,R+34,deg);
txt(s,{x:tx,y:ty,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
'dominant-baseline':'middle','letter-spacing':'.08em',
style:`animation-delay:${.6+h*.08}s`,
class:'fade'},`${name} · ${counts[h]}`);
// hairline tying the label to its hub, skimming past the rim
const [gx,gy]=pol(CX,CY,R+22,deg);
el(s,'line',{x1:hx,y1:hy,x2:gx,y2:gy,stroke:'#C6C5BF','stroke-width':.7,
'stroke-dasharray':'1 3',class:'fade',style:`animation-delay:${.7+h*.08}s`});
});
});
})();
// ════ 6 · cluster field ════
(()=>{
const SAT=[[622,78,'PLUGINS',22],[706,214,'THEMES',16],[540,268,'DOCS',13],[92,72,'FORKS',12],[774,58,'MIRRORS',9]];
obsReveal('clusters',s=>{
const HX=300,HY=172;
// dotted cross-contribution paths, hub → each island
SAT.forEach(([x,y],ci)=>{
const mx=(HX+x)/2,my=(HY+y)/2-30;
el(s,'path',{d:`M${HX} ${HY} Q${mx} ${my} ${x} ${y}`,fill:'none',
stroke:GRID,'stroke-width':.9,'stroke-dasharray':'2 4',pathLength:1,
class:'draw',style:`animation-delay:${.8+ci*.12}s;animation-duration:.7s`});
});
// main hub: 80 golden-angle spokes
for(let i=0;i<80;i++){
const a=i*137.508,len=28+rnd(i+1,7)*88;
const [x,y]=pol(HX,HY,len,a);
el(s,'line',{x1:HX,y1:HY,x2:x,y2:y,stroke:'#C6C5BF','stroke-width':.6,pathLength:1,
class:'draw',style:`animation-delay:${i*.012}s;animation-duration:.5s`});
const dot=el(s,'circle',{cx:x,cy:y,r:1.6+rnd(i+1,4)*1.8,fill:INK,opacity:.85,
class:'pop',style:`animation-delay:${.15+i*.012}s`});
tip(dot,`core contributor #${i+1}`);
}
el(s,'circle',{cx:HX,cy:HY,r:6.5,fill:INK,class:'pop'});
txt(s,{x:HX,y:HY+140,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
'letter-spacing':'.1em',style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px`,
class:'fade'},'CORE · 80');
// satellite islands
SAT.forEach(([x,y,name,n],ci)=>{
el(s,'circle',{cx:x,cy:y,r:3.5,fill:'#4A4944',
class:'pop',style:`animation-delay:${.9+ci*.12}s`});
for(let k=0;k<n;k++){
const a=k*137.508+ci*40,rr=9+rnd(k+1,ci+2)*26;
const [dx,dy]=pol(x,y,rr,a);
if(k%2===0)el(s,'line',{x1:x,y1:y,x2:dx,y2:dy,stroke:'#D8D6CE','stroke-width':.5,
class:'fade',style:`animation-delay:${1+ci*.12+k*.02}s`});
el(s,'circle',{cx:dx,cy:dy,r:1.4+rnd(k+3,ci+5)*1.4,fill:'#6A6963',
class:'pop',style:`animation-delay:${1+ci*.12+k*.02}s`});
}
txt(s,{x,y:y+44,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
'letter-spacing':'.12em',style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px`,
class:'fade'},`${name} · ${n}`);
});
});
})();
// ════ 1 · brand spectrum ════
// paired opposites; the pale ribbon threads our position down the page
(()=>{
const ROWS=[['FRIEND','AUTHORITY'],['SERIOUS','PLAYFUL'],['RELIABLE','RISK-TAKING'],['CONTEMPORARY','CLASSIC']];
const US=[.74,.80,.40,.22]; // 0 = left trait, 1 = right trait
const COMP=[[.42,.52,.57],[.60,.65,.86],[.30,.34,.50],[.36,.41,.73]];
const X0=118,X1=342,Y=i=>58+i*60,px=t=>X0+t*(X1-X0);
obsReveal('spectrum',s=>{
// the ribbon first, underneath everything
let d=`M${px(US[0])} ${Y(0)}`;
for(let i=1;i<4;i++)
d+=` C${px(US[i-1])} ${Y(i-1)+30} ${px(US[i])} ${Y(i)-30} ${px(US[i])} ${Y(i)}`;
el(s,'path',{d,fill:'none',stroke:'#F0EFEB','stroke-width':30,'stroke-linecap':'round',
'stroke-linejoin':'round',opacity:.9,pathLength:1,class:'draw',
style:'animation-duration:1.4s'});
ROWS.forEach(([a,b],i)=>{
const y=Y(i);
el(s,'line',{x1:X0,y1:y,x2:X1,y2:y,stroke:'#B0AFA9','stroke-width':1,
class:'fade',style:`animation-delay:${i*.08}s`});
[X0,X1].forEach(x=>el(s,'line',{x1:x,y1:y-4,x2:x,y2:y+4,stroke:'#B0AFA9','stroke-width':1,
class:'fade',style:`animation-delay:${i*.08}s`}));
txt(s,{x:X0-14,y:y+3,'font-size':8,'font-weight':600,fill:'#6A6963','text-anchor':'end',
'letter-spacing':'.1em',class:'fade',style:`animation-delay:${i*.08}s`},a);
txt(s,{x:X1+14,y:y+3,'font-size':8,'font-weight':600,fill:'#6A6963',
'letter-spacing':'.1em',class:'fade',style:`animation-delay:${i*.08}s`},b);
COMP[i].forEach((t,k)=>{
const c=el(s,'circle',{cx:px(t),cy:y,r:3.2,fill:'#8F8E88',
class:'pop',style:`animation-delay:${.3+i*.1+k*.05}s`});
tip(c,`competitor ${'ABC'[k]} — ${Math.round(t*100)}% toward ${b}`);
});
const us=el(s,'circle',{cx:px(US[i]),cy:y,r:8,fill:INK,
class:'pop',style:`animation-delay:${.5+i*.1}s`});
tip(us,`us — ${Math.round(US[i]*100)}% toward ${b}`);
});
txt(s,{x:X0,y:284,'font-size':7.5,'font-weight':600,fill:'#B0AFA9','letter-spacing':'.12em',
class:'fade',style:'animation-delay:.9s'},'● US ● COMPETITORS A · B · C');
});
})();
// ════ 2 · dotty matrix — isometric stacked dot planes ════
// pitch 20/10, dot radius capped well under half-pitch, decks 64px apart:
// each plane reads as its own quiet grid instead of a smudge
(()=>{
const SQUAD=['PLATFORM','GROWTH','MOBILE','INFRA'];
const SHADE=['#C6C5BF','#A8A7A0','#6A6963',INK];
const P=(c,r,k)=>[196+(c-r)*20,236+(c+r)*10-k*64];
obsReveal('dotty',s=>{
for(let k=0;k<4;k++){
// plane slab: paper fill so upper decks occlude lower ones
const cs=[P(-.8,-.8,k),P(5.8,-.8,k),P(5.8,5.8,k),P(-.8,5.8,k)];
el(s,'path',{d:'M'+cs.map(p=>p.join(' ')).join(' L ')+' Z',
fill:PAPER,'fill-opacity':.96,stroke:'#DEDDD6','stroke-width':.9,
class:'fade',style:`animation-delay:${k*.16}s`});
for(let r=0;r<6;r++)for(let c=0;c<6;c++){
const load=rnd(k*37+r*6+c+1,k+2);
const v=load<.3?0:Math.round(load*12); // sparser: silence is part of the texture
const [x,y]=P(c,r,k);
if(!v){el(s,'circle',{cx:x,cy:y,r:.7,fill:'#D8D6CE',
class:'pop',style:`animation-delay:${k*.16+(r*6+c)*.008}s`});continue}
const dot=el(s,'circle',{cx:x,cy:y,r:1+Math.sqrt(v)*1.15,fill:SHADE[k],
class:'pop',style:`animation-delay:${k*.16+(r*6+c)*.008}s`});
tip(dot,`${SQUAD[k]} · week ${c+1} × lane ${r+1} — ${v} tasks`);
}
// label off the plane's right corner, tied by a short hairline
const [cxr,cyr]=P(5.8,-.8,k);
el(s,'line',{x1:cxr+4,y1:cyr,x2:cxr+22,y2:cyr,stroke:'#C6C5BF','stroke-width':.8,
class:'fade',style:`animation-delay:${.2+k*.16}s`});
txt(s,{x:cxr+27,y:cyr+2.5,'font-size':7.5,'font-weight':700,fill:k===3?INK:'#8F8E88',
'letter-spacing':'.12em',class:'fade',style:`animation-delay:${.2+k*.16}s`},SQUAD[k]);
}
});
})();
// ════ 3 · bubble almanac ════
// the vintage-almanac feel lives in three things: ledger-paper hairlines
// under everything, bubbles big enough to collide, and marginalia. do all three.
(()=>{
const AREAS=['EDITOR','BOARDS','DOCS','CHAT','FLOWS','VAULT','PAGES','SYNC','GRID','VIEWS','HUB','FORMS'];
const BETA={GRID:2023,VIEWS:2024,HUB:2025,FORMS:2025}; // beta until this year
const BORN={EDITOR:2019,BOARDS:2019,DOCS:2019,CHAT:2020,FLOWS:2020,VAULT:2021,PAGES:2021,SYNC:2022,GRID:2022,VIEWS:2023,HUB:2024,FORMS:2024};
obsReveal('almanac',s=>{
const rowY=i=>62+i*34,colX=j=>132+j*57;
// ledger paper: hairlines every 6.8px, edge to edge, under everything
for(let y=40;y<=318;y+=6.8)
el(s,'line',{x1:56,y1:y,x2:822,y2:y,stroke:'#E4E3DC','stroke-width':.5,
class:'fade',style:`animation-delay:${(y-40)*.001}s`});
const vals=[];
for(let i=0;i<8;i++){
const yr=2019+i;
el(s,'line',{x1:56,y1:rowY(i),x2:822,y2:rowY(i),stroke:'#D3D2CA','stroke-width':.9,
class:'fade',style:`animation-delay:${i*.04}s`});
txt(s,{x:48,y:rowY(i)+3,'font-size':9,'font-weight':700,fill:'#8F8E88','text-anchor':'end',
class:'fade',style:`animation-delay:${i*.04}s`},yr);
AREAS.forEach((a,j)=>{
if(yr<BORN[a])return;
const age=yr-BORN[a];
// steep spread: quiet areas stay pinpricks, busy ones balloon
const v=Math.round((2+age*5)*(0.25+rnd(i*12+j+1,j+3)**2*2.2));
vals.push([Math.max(1,v),i,j,a,yr]);
});
}
// draw small over large so the giants sit underneath as washes
vals.sort((a,b)=>b[0]-a[0]);
const top3=vals.slice(0,3);
// hand-drawn circle: radius wobbles around the rim on two slow sine waves,
// so every bubble is its own slightly-off print — never a compass circle
const blob=(x,y,r,seed)=>{
const n=Math.max(14,Math.round(r*1.6)),pts=[];
for(let t=0;t<n;t++){
const a=t/n*Math.PI*2;
const w=1+.055*Math.sin(a*2+seed*7)+.04*Math.sin(a*3+seed*13)+(rnd(seed+t,3)-.5)*.03;
pts.push([x+Math.cos(a)*r*w,y+Math.sin(a)*r*w]);
}
let d=`M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for(let t=0;t<n;t++){
const p=pts[t],q=pts[(t+1)%n];
d+=` Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${((p[0]+q[0])/2).toFixed(1)} ${((p[1]+q[1])/2).toFixed(1)}`;
}
return d+' Z';
};
vals.forEach(([v,i,j,a,yr],k)=>{
const x=colX(j)+(rnd(i*7+j+2,j+9)-.5)*10, // jitter off the gridline
y=rowY(i),r=Math.sqrt(v)*3.8; // big: rows are 34 apart, giants reach ~28 and collide
const beta=BETA[a]&&yr<=BETA[a];
const g=el(s,'g',{class:'pop',style:`animation-delay:${.15+k*.012}s`});
const seed=i*12+j;
const dot=el(g,'path',beta
?{d:blob(x,y,r,seed),fill:'none',stroke:'#8F8E88','stroke-width':1,'stroke-dasharray':'3 3'}
:{d:blob(x,y,r,seed),fill:INK,'fill-opacity':.09+rnd(i+2,j+4)*.1,
stroke:INK,'stroke-opacity':.3+rnd(i+5,j+7)*.35,'stroke-width':.6+rnd(i+3,j+11)*.9});
tip(dot,`${a} · ${yr} — ${v*10} tickets${beta?' (beta)':''}`);
// escalation core: small wobbly blot, nudged off-center like a press mark
if(!beta){
const ox=(rnd(seed+1,17)-.5)*r*.3,oy=(rnd(seed+3,19)-.5)*r*.3;
el(g,'path',{d:blob(x+ox,y+oy,Math.max(1.4,r*.17),seed+29),fill:INK});
}
if(top3.includes(vals[k]))
txt(s,{x,y:y-r-5,'font-size':8.5,'font-weight':800,fill:INK,'text-anchor':'middle',
class:'fade',style:'animation-delay:1s'},v*10);
});
AREAS.forEach((a,j)=>{
const x=colX(j);
txt(s,{x,y:34,'font-size':7.5,'font-weight':700,fill:MUTED,'letter-spacing':'.1em',
transform:`rotate(-32 ${x} 34)`,class:'fade',style:`animation-delay:${j*.03}s`},a);
});
// marginalia: two annotations with pointer hairlines, like the original's notes
el(s,'path',{d:`M${colX(3)+30} ${rowY(5)-14} C${colX(3)+70} ${rowY(5)-34} ${colX(3)+96} ${rowY(5)-38} ${colX(3)+118} ${rowY(5)-38}`,
fill:'none',stroke:'#B0AFA9','stroke-width':.7,class:'fade',style:'animation-delay:1.1s'});
txt(s,{x:colX(3)+124,y:rowY(5)-35,'font-size':7,fill:'#6A6963','font-style':'italic',
class:'fade',style:'animation-delay:1.1s'},'chat tickets triple after mobile GA');
el(s,'path',{d:`M${colX(9)+16} ${rowY(2)+8} C${colX(9)+40} ${rowY(2)+26} ${colX(9)+60} ${rowY(2)+30} ${colX(9)+80} ${rowY(2)+30}`,
fill:'none',stroke:'#B0AFA9','stroke-width':.7,class:'fade',style:'animation-delay:1.2s'});
txt(s,{x:colX(9)+86,y:rowY(2)+33,'font-size':7,fill:'#6A6963','font-style':'italic',
class:'fade',style:'animation-delay:1.2s'},'betas barely ticket — nobody files bugs on toys');
// footer strip: important events, the almanac's timeline shelf
const evY=356;
el(s,'line',{x1:56,y1:evY-16,x2:822,y2:evY-16,stroke:'#C6C5BF','stroke-width':.8,
class:'fade',style:'animation-delay:1.2s'});
txt(s,{x:56,y:evY-24,'font-size':7,'font-weight':800,fill:'#8F8E88','letter-spacing':'.16em',
class:'fade',style:'animation-delay:1.2s'},'IMPORTANT EVENTS');
[['2020','SLA introduced'],['2022','self-serve help center'],['2023','the GA wave'],['2025','AI deflection live']]
.forEach(([yr,note],k)=>{
const x=90+k*196;
el(s,'line',{x1:x,y1:evY-16,x2:x,y2:evY-6,stroke:'#8F8E88','stroke-width':1,
class:'fade',style:`animation-delay:${1.3+k*.08}s`});
txt(s,{x,y:evY+4,'font-size':8,'font-weight':800,fill:INK,
class:'fade',style:`animation-delay:${1.3+k*.08}s`},yr);
txt(s,{x,y:evY+15,'font-size':7,fill:'#8F8E88',
class:'fade',style:`animation-delay:${1.3+k*.08}s`},note);
});
});
})();
// ════ 4 · radial patchwork ════
(()=>{
const CX=200,CY=168,N=46;
obsReveal('patchwork',s=>{
// 24h rim ticks
for(let h=0;h<96;h++){
const a=-90+h*3.75,[x1,y1]=pol(CX,CY,140,a),[x2,y2]=pol(CX,CY,h%4===0?146:143,a);
el(s,'line',{x1,y1,x2,y2,stroke:'#C6C5BF','stroke-width':h%4===0?1:.5,
class:'fade',style:`animation-delay:${h*.006}s`});
}
[0,6,12,18].forEach(h=>{
const [x,y]=pol(CX,CY,157,-90+h*15);
txt(s,{x,y:y+3,'font-size':8,'font-weight':700,fill:MUTED,'text-anchor':'middle',
class:'fade'},String(h).padStart(2,'0'));
});
// the patchwork: translucent wedges pile into ink
const wedges=[];
for(let i=0;i<N;i++){
// deploys cluster around 11h and 16h, thin overnight
const peak=rnd(i+1,2)>.5?11:16;
const hour=(peak+(rnd(i+1,3)-.5)*7+24)%24;
const a0=-90+hour*15,sw=10+rnd(i+1,4)*34;
const r1=34+rnd(i+1,5)*100;
wedges.push([a0,sw,r1,hour,i]);
}
wedges.forEach(([a0,sw,r1,hour,i])=>{
const w=el(s,'path',{d:sect(CX,CY,16,r1,a0,a0+sw),fill:INK,
'fill-opacity':.07+rnd(i+1,6)*.09,class:'fade',
style:`animation-delay:${.2+i*.022}s`});
tip(w,`deploy #${i+1} — ${String(Math.floor(hour)).padStart(2,'0')}:${String(Math.floor(hour%1*60)).padStart(2,'0')} · ${Math.round(r1*6)} files`);
});
// three outlined wedges: the ones that paged somebody
[4,17,31].forEach((i,k)=>{
const [a0,sw,r1]=wedges[i];
const w=el(s,'path',{d:sect(CX,CY,16,r1,a0,a0+sw),fill:'none',
stroke:INK,'stroke-width':1.1,class:'fade',style:`animation-delay:${1.2+k*.15}s`});
tip(w,`deploy #${i+1} — triggered an incident`);
});
el(s,'circle',{cx:CX,cy:CY,r:3,fill:INK,class:'pop',style:'animation-delay:1.3s'});
txt(s,{x:CX,y:308,'font-size':7.5,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.4s'},'OUTLINED = TRIGGERED AN INCIDENT');
});
})();
// ════ 6 · trend lineage ════
(()=>{
// [name, events [year, type f|o], aliveToday]
const TR=[
['Dark mode',[[2017,'f'],[2022,'o']],true],
['Kanban',[[2016,'f']],true],
['Wikis',[[2016,'f'],[2019,'o']],false],
['Slash cmds',[[2018,'f']],true],
['Templates',[[2019,'f'],[2024,'o']],true],
['Inbox',[[2020,'f']],false],
['Live cursors',[[2020,'f'],[2023,'o']],true],
['AI drafts',[[2023,'f'],[2025,'o']],true],
['Voice notes',[[2021,'f']],false],
['Offline',[[2018,'f'],[2026,'o']],true],
];
const Y0=2016,yY=yr=>34+(yr-Y0)*24,colX=c=>76+c*31;
obsReveal('lineage',s=>{
for(let yr=Y0;yr<=2026;yr++){
el(s,'line',{x1:60,y1:yY(yr),x2:372,y2:yY(yr),stroke:'#E3E2DB','stroke-width':.8,
class:'fade',style:`animation-delay:${(yr-Y0)*.03}s`});
if(yr%2===0)txt(s,{x:52,y:yY(yr)+3,'font-size':8,'font-weight':600,fill:MUTED,'text-anchor':'end',
class:'fade',style:`animation-delay:${(yr-Y0)*.03}s`},yr);
}
TR.forEach(([name,ev,alive],c)=>{
const x=colX(c);
// segments between events: solid while fresh, dashed once dormant
for(let k=0;k<ev.length-1;k++){
const gap=ev[k+1][0]-ev[k][0];
el(s,'line',{x1:x,y1:yY(ev[k][0])+7,x2:x,y2:yY(ev[k+1][0])-7,
stroke:'#B0AFA9','stroke-width':1,
'stroke-dasharray':gap>2?'2 4':'none',pathLength:1,
class:'draw',style:`animation-delay:${.3+c*.07}s;animation-duration:.6s`});
}
const last=ev[ev.length-1][0];
if(alive)
el(s,'line',{x1:x,y1:yY(last)+7,x2:x,y2:yY(2026)+16,stroke:'#B0AFA9','stroke-width':1,
pathLength:1,class:'draw',style:`animation-delay:${.45+c*.07}s;animation-duration:.6s`});
ev.forEach(([yr,t])=>{
const node=el(s,'circle',t==='f'
?{cx:x,cy:yY(yr),r:5.5,fill:INK}
:{cx:x,cy:yY(yr),r:5.5,fill:PAPER,stroke:INK,'stroke-width':1.4});
node.setAttribute('class','pop');
node.style.animationDelay=(.35+c*.07)+'s';
tip(node,`${name} — ${t==='f'?'shipped':'reworked'} ${yr}`);
});
// terminal dot: alive lands on today's baseline, dead stops where it stopped
el(s,'circle',{cx:x,cy:alive?yY(2026)+16:yY(last)+12,r:alive?3:1.6,
fill:alive?INK:'#C6C5BF',class:'pop',style:`animation-delay:${.6+c*.07}s`});
const ly=yY(2026)+26;
txt(s,{x,y:ly,'font-size':6.8,'font-weight':600,fill:alive?'#4A4944':'#B0AFA9',
transform:`rotate(-38 ${x} ${ly})`,'text-anchor':'end',
class:'fade',style:`animation-delay:${.7+c*.05}s`},name);
});
});
})();
// ════ 8 · type colonnade ════
(()=>{
const PRE=['core','ui','api','data','auth','sync','mail','flag','edge','doc','bot'];
const SUF=['-kit','-svc','-web','-cli','-db','-gw','-sdk','-jobs'];
const TEAM=['PLATFORM','FRONTEND','BACKEND','DATA','INFRA','MOBILE','SECURITY','GROWTH','ML','DESIGN'];
obsReveal('colonnade',s=>{
const N=44;
const teamY=j=>36+j*28,repoY=i=>16+i*6.7;
const counts=Array(10).fill(0);
const owner=i=>{const w=rnd(i+1,17);return Math.min(9,Math.floor(w*w*10))}; // skewed: low teams hoard
for(let i=0;i<N;i++)counts[owner(i)]++;
for(let i=0;i<N;i++){
const name=PRE[i%11]+SUF[Math.floor(rnd(i+1,8)*8)%8]+(i>21?'-v2':'');
const yi=repoY(i),j=owner(i),yj=teamY(j);
txt(s,{x:112,y:yi+2,'font-size':5,fill:'#8F8E88','text-anchor':'end',
class:'fade',style:`animation-delay:${i*.012}s`},name);
el(s,'rect',{x:117,y:yi-1.2,width:4,height:2.4,fill:'#B0AFA9',
class:'fade',style:`animation-delay:${i*.012}s`});
el(s,'path',{d:`M123 ${yi} C 200 ${yi} 220 ${yj} 288 ${yj}`,fill:'none',
stroke:'#A8A7A0','stroke-width':.6,opacity:.6,pathLength:1,
class:'draw',style:`animation-delay:${.2+i*.015}s;animation-duration:.7s`});
}
TEAM.forEach((t,j)=>{
const y=teamY(j);
el(s,'circle',{cx:293,cy:y,r:2.6+counts[j]*.55,fill:counts[j]>=7?INK:counts[j]>=4?'#6A6963':'#B0AFA9',
class:'pop',style:`animation-delay:${.6+j*.05}s`});
const lab=txt(s,{x:302+counts[j]*.55,y:y+2.5,'font-size':7,'font-weight':700,fill:'#4A4944',
class:'fade',style:`animation-delay:${.65+j*.05}s`},`${t} · ${counts[j]}`);
tip(lab,`${t} owns ${counts[j]} repos`);
});
});
})();
// ════ 9 · hourglass stream ════
(()=>{
const STAGE=[['VISITORS',4200],['SIGN-UPS',1900],['ACTIVATED',960],['RETAINED',540],['PAYING',310]];
obsReveal('hourglass',s=>{
const CXm=185,sy=k=>34+k*64,w=c=>c/4200*290;
STAGE.forEach(([name,c],k)=>{
const y=sy(k),hw=w(c)/2;
// barcode strip: one tick ≈ 40 people
const n=Math.round(c/40);
for(let t=0;t<n;t++){
const x=CXm-hw+(t+.5)/n*hw*2+(rnd(t+1,k+3)-.5)*3;
el(s,'line',{x1:x,y1:y-6,x2:x,y2:y+6,stroke:INK,'stroke-width':.8,
opacity:.45+rnd(t+2,k+5)*.5,class:'fade',style:`animation-delay:${k*.12+t*.004}s`});
}
// threads trickling to the next strip
if(k<4){
const hw1=w(STAGE[k+1][1])/2;
for(let t=0;t<34;t++){
const xt=CXm+(rnd(t+1,k*7+1)-.5)*2*hw*.94;
const xb=CXm+(rnd(t+3,k*7+5)-.5)*2*hw1*.94;
el(s,'path',{d:`M${xt} ${y+8} C${xt} ${y+34} ${xb} ${sy(k+1)-34} ${xb} ${sy(k+1)-8}`,
fill:'none',stroke:'#B0AFA9','stroke-width':.5,opacity:.32,pathLength:1,
class:'draw',style:`animation-delay:${.2+k*.15+t*.008}s;animation-duration:.8s`});
}
// conversion between stages, parked in the left margin
const pct=Math.round(STAGE[k+1][1]/c*100);
txt(s,{x:26,y:(y+sy(k+1))/2+3,'font-size':8.5,'font-weight':800,fill:'#8F8E88',
class:'fade',style:`animation-delay:${.5+k*.15}s`},pct+'%');
txt(s,{x:26,y:(y+sy(k+1))/2+13,'font-size':6,'font-weight':600,fill:'#C6C5BF',
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${.5+k*.15}s`},'GET THROUGH');
}
// stage label, tied to the strip edge
el(s,'line',{x1:CXm+hw+6,y1:y,x2:340,y2:y,stroke:'#DEDDD6','stroke-width':.8,
class:'fade',style:`animation-delay:${.3+k*.12}s`});
txt(s,{x:344,y:y-1,'font-size':7.5,'font-weight':700,fill:'#4A4944',
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${.35+k*.12}s`},name);
txt(s,{x:344,y:y+10,'font-size':9.5,'font-weight':800,fill:INK,
class:'fade',style:`animation-delay:${.4+k*.12}s`},c.toLocaleString());
});
});
})();
// ════ 14 · hundred field ════
// small data, unit-decomposed: four shares of 100% become 100 people.
// phyllotaxis discs (golden angle) so每簇自带肌理; share is countable, not just readable.
(()=>{
const SEG=[['CHARGED',41,INK],['TORN',35,'#55554F'],['ADRIFT',12,'#8F8E88'],['AVERSE',12,'#B0AFA9']];
const POS=[[132,140],[276,116],[186,252],[322,238]];
obsReveal('hundredfield',s=>{
// dashed constellation hairlines tying the cluster cores
[[0,1],[0,2],[1,3],[2,3]].forEach(([a,b],k)=>{
el(s,'line',{x1:POS[a][0],y1:POS[a][1],x2:POS[b][0],y2:POS[b][1],
stroke:GRID,'stroke-width':.7,'stroke-dasharray':'2 5',
class:'fade',style:`animation-delay:${.9+k*.1}s`});
});
SEG.forEach(([name,v,shade],ci)=>{
const [cx,cy]=POS[ci];
let edge=0;
for(let k=0;k<v;k++){
const a=k*137.508+ci*55;
const rr=4+Math.sqrt(k)*5.9+rnd(k+1,ci+2)*3;
edge=Math.max(edge,rr);
const [x,y]=pol(cx,cy,rr,a);
// hairline spoke for every 5th person, core to dot
if(k%5===0)el(s,'line',{x1:cx,y1:cy,x2:x,y2:y,stroke:'#CDCCC5','stroke-width':.6,
class:'fade',style:`animation-delay:${ci*.14+k*.012}s`});
const dot=el(s,'circle',{cx:x,cy:y,r:1.5+rnd(k+2,ci+3)*1.7,fill:shade,opacity:.9,
class:'pop',style:`animation-delay:${ci*.14+k*.012}s`});
tip(dot,`${name} — one of ${v} in 100`);
}
el(s,'circle',{cx,cy,r:2.4,fill:INK,class:'pop',style:`animation-delay:${ci*.14}s`});
txt(s,{x:cx,y:cy+edge+13,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
'letter-spacing':'.1em',style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.5+ci*.12}s`,
class:'fade'},`${name} · ${v}`);
});
txt(s,{x:200,y:314,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.3s'},'ONE DOT = ONE PERSON · 41 + 35 + 12 + 12 = 100');
});
})();
// ════ 15 · ballot tally ════
// multi-select survey (each option independent, 0–100): every option is a row of
// 100 hairline ticks — a hundred people standing in line, the ones who picked it inked.
(()=>{
const ASK=[['DO MORE WITH THE SAME PAY',51],['AN UNSUSTAINABLE PACE',46],['QUALITY OF WORK SLIPPING',41],['BEING REPLACED OUTRIGHT',22]];
obsReveal('ballottally',s=>{
ASK.forEach(([name,v],i)=>{
const base=78+i*62;
txt(s,{x:28,y:base-26,'font-size':7.5,'font-weight':700,fill:'#6A6963',
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.1}s`},name);
el(s,'line',{x1:28,y1:base,x2:372,y2:base,stroke:GRID,'stroke-width':.6,
class:'fade',style:`animation-delay:${i*.1}s`});
for(let k=0;k<100;k++){
const x=28+k*3.44,picked=k<v;
const h=picked?12+rnd(k+1,i+2)*5:4.5+rnd(k+1,i+5)*2;
el(s,'line',{x1:x,y1:base,x2:x,y2:base-h,
stroke:picked?INK:'#CFCEC7','stroke-width':picked?.9:.55,
class:'fade',style:`animation-delay:${i*.1+k*.006}s`});
if(k%10===0)el(s,'circle',{cx:x,cy:base+4.5,r:.8,fill:'#C6C5BF',
class:'fade',style:`animation-delay:${i*.1+k*.006}s`});
}
const lab=txt(s,{x:28+(v-1)*3.44+9,y:base-11,'font-size':11,'font-weight':800,fill:INK,
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.5+i*.1}s`,
class:'fade'},v);
tip(lab,`${v} of 100 picked this — they could pick several`);
});
txt(s,{x:200,y:314,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},'ONE TICK = ONE RESPONDENT · DOT MARKS EVERY TENTH');
});
})();
// ── New chart-family helpers (scoped to the added templates) ──
(()=>{
const INK='#1C1C1A',PAPER='#F0EFEB',MUTED='#8F8E88',GRID='#DEDDD6';
const NS='http://www.w3.org/2000/svg';
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
// ════ L16 · matrix heat ════
// 通用矩阵热力:任意 分类×分类×连续值。明度即数据——五档灰阶方块,
// 越黑越高。对角线不参与(自己×自己),零格留一粒小点(沉默可见)。
// 最强的一对上虚线框 + 标数。区别于 F10 dot heat:这里编码用明度不用面积,
// 值是占比/强度而不是可数件数。
(()=>{
const FEAT=['EDITOR','BOARDS','DOCS','CHAT','FLOWS','VAULT','PAGES','SYNC'];
const v=(i,j)=>{
if(i===j)return -1;
const a=Math.min(i,j),b=Math.max(i,j);
const pop=(8-a)/8*(8-b)/8; // 头部功能重合度高
const raw=pop*62*(0.35+rnd(a*8+b+1,a+b+3)*.9);
return Math.round(raw);
};
const shade=t=>t>36?INK:t>24?'#4A4944':t>14?'#8F8E88':t>6?'#B0AFA9':'#D8D7D1';
obsReveal('matheat',s=>{
const X0=118,Y0=70,P=26,C=22;
let max=0,mi=0,mj=0;
for(let i=0;i<8;i++)for(let j=0;j<8;j++){const t=v(i,j);if(t>max){max=t;mi=i;mj=j}}
FEAT.forEach((f,i)=>{
txt(s,{x:X0-10,y:Y0+i*P+C/2+3,'font-size':7,'font-weight':700,fill:'#6A6963','text-anchor':'end',
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${i*.04}s`},f);
const cy=Y0-10;
txt(s,{x:X0+i*P+C/2,y:cy,'font-size':7,'font-weight':700,fill:MUTED,'letter-spacing':'.06em',
transform:`rotate(-55 ${X0+i*P+C/2} ${cy})`,class:'fade',style:`animation-delay:${i*.04}s`},f);
});
for(let i=0;i<8;i++)for(let j=0;j<8;j++){
const x=X0+j*P,y=Y0+i*P,t=v(i,j),d=(i+j)*.02;
if(t<0){el(s,'rect',{x:x+C/2-3,y:y+C/2-.6,width:6,height:1.2,fill:'#D8D6CE',
class:'fade',style:`animation-delay:${d}s`});continue}
if(!t){el(s,'circle',{cx:x+C/2,cy:y+C/2,r:.9,fill:'#D8D6CE',
class:'pop',style:`animation-delay:${d}s`});continue}
const cell=el(s,'rect',{x,y,width:C,height:C,rx:4,fill:shade(t),
class:'pop',style:`animation-delay:${d}s`});
tip(cell,`${FEAT[i]} × ${FEAT[j]} — ${t}% of accounts use both`);
if(i===mi&&j===mj){
el(s,'rect',{x:x-3.5,y:y-3.5,width:C+7,height:C+7,rx:6,fill:'none',
stroke:INK,'stroke-width':1,'stroke-dasharray':'2 3',
class:'fade',style:'animation-delay:.9s'});
txt(s,{x:x+C/2,y:y+C/2+3.5,'font-size':9,'font-weight':800,fill:PAPER,'text-anchor':'middle',
class:'fade',style:'animation-delay:1s'},t);
}
}
// shade legend, countable buckets
const LG=[['#D8D7D1','1–6'],['#B0AFA9','7–14'],['#8F8E88','15–24'],['#4A4944','25–36'],[INK,'37+']];
LG.forEach(([c,lab],k)=>{
const x=118+k*54;
el(s,'rect',{x,y:292,width:9,height:9,rx:2,fill:c,class:'fade',style:`animation-delay:${1+k*.05}s`});
txt(s,{x:x+13,y:300,'font-size':6.5,'font-weight':600,fill:'#8F8E88',
class:'fade',style:`animation-delay:${1+k*.05}s`},lab+'%');
});
txt(s,{x:36,y:300,'font-size':7,'font-weight':600,fill:'#B0AFA9','letter-spacing':'.1em',
class:'fade',style:'animation-delay:1s'},'SHADE = %');
});
})();
// ════ L17 · calendar heat ════
// 日历热力:GitHub 贡献墙的 Lupi 皮。一格一天,52 周 × 7 天,
// 点面积 = 当天量(sqrt 换算),静默日一粒小点。月份写在上檐,
// 峰值日虚线圈 + 旁注引线。逐记录世界观:一年 365 天每天都在场。
(()=>{
const MON=['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
const DAY=['MON','WED','FRI','SUN'];
const val=(w,d)=>{
const wd=d<5;
const season=1+.55*Math.sin((w-8)/9); // 春秋两个发布季
if(!wd)return rnd(w*7+d+1,3)>.82?1:0; // 周末基本静默
const raw=season*(2.5+rnd(w*7+d+1,d+2)*9)*(rnd(w+1,d+5)>.12?1:0);
return Math.round(raw);
};
obsReveal('calheat',s=>{
const X0=56,Y0=42,P=14.8;
let max=0,mw=0,md=0;
for(let w=0;w<52;w++)for(let d=0;d<7;d++){const t=val(w,d);if(t>max){max=t;mw=w;md=d}}
DAY.forEach((lab,k)=>{
txt(s,{x:X0-10,y:Y0+[0,2,4,6][k]*P+3,'font-size':6.5,'font-weight':700,fill:'#8F8E88','text-anchor':'end',
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${k*.04}s`},lab);
});
MON.forEach((m,k)=>{
const x=X0+Math.round(k*52/12)*P;
txt(s,{x,y:Y0-16,'font-size':7,'font-weight':700,fill:MUTED,'letter-spacing':'.1em',
class:'fade',style:`animation-delay:${k*.03}s`},m);
el(s,'line',{x1:x,y1:Y0-11,x2:x,y2:Y0-5,stroke:'#C6C5BF','stroke-width':.7,
class:'fade',style:`animation-delay:${k*.03}s`});
});
for(let w=0;w<52;w++)for(let d=0;d<7;d++){
const x=X0+w*P,y=Y0+d*P,t=val(w,d),delay=w*.012+d*.004;
if(!t){el(s,'circle',{cx:x,cy:y,r:.75,fill:'#D8D6CE',
class:'pop',style:`animation-delay:${delay}s`});continue}
const r=1.1+Math.sqrt(t)*1.55;
const dot=el(s,'circle',{cx:x,cy:y,r,
fill:t>max*.66?INK:t>max*.33?'#6A6963':'#B0AFA9',
class:'pop',style:`animation-delay:${delay}s`});
tip(dot,`W${w+1} ${['MON','TUE','WED','THU','FRI','SAT','SUN'][d]} — ${t} deploys`);
}
// peak: dashed ring + marginalia hairline
const px=X0+mw*P,py=Y0+md*P,pr=1.1+Math.sqrt(max)*1.55;
el(s,'circle',{cx:px,cy:py,r:pr+3.6,fill:'none',stroke:INK,'stroke-width':1,
'stroke-dasharray':'2 3',class:'fade',style:'animation-delay:1s'});
el(s,'path',{d:`M${px+pr+6} ${py+6} C${px+34} ${py+30} ${px+52} ${py+36} ${px+72} ${py+36}`,
fill:'none',stroke:'#B0AFA9','stroke-width':.7,class:'fade',style:'animation-delay:1.1s'});
txt(s,{x:px+78,y:py+39,'font-size':7,fill:'#6A6963','font-style':'italic',
class:'fade',style:'animation-delay:1.15s'},`the release-week spike — ${max} deploys in a day`);
txt(s,{x:420,y:222,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
'ONE DOT = ONE DAY · DOT AREA = DEPLOYS · TINY DOT = A QUIET DAY');
});
})();
// ════ L18 · beeswarm ════
// 蜂群:violin 的 Lupi 姊妹。不聚合密度,把 120 条真实记录沿数值轴
// 逐颗排开,撞上了就往两边让——堆得高的地方就是分布的峰。
// 空心 = enterprise 单,虚线 = 中位数。每颗点背后是一条真实成交。
(()=>{
const N=120;
const deals=Array.from({length:N},(_,i)=>{
const u=rnd(i+1,3),ent=rnd(i+2,11)>.86;
const v=Math.round(4+(ent?60:6)+150*Math.pow(u,2.6)+rnd(i+3,7)*10);
return [Math.min(178,v),ent];
});
obsReveal('beeswarm',s=>{
const X0=36,X1=376,BASE=246,R=4,GAP=R*2+.8;
const mapX=v=>X0+v/180*(X1-X0);
// axis rail + barcode ticks
el(s,'line',{x1:X0-4,y1:BASE,x2:X1+4,y2:BASE,stroke:GRID,'stroke-width':.8,class:'fade'});
for(let g=0;g<=9;g++){
const x=X0+g/9*(X1-X0);
el(s,'line',{x1:x,y1:BASE,x2:x,y2:BASE+(g%3===0?7:4),stroke:'#CFCEC7','stroke-width':.6,
class:'fade',style:`animation-delay:${g*.02}s`});
if(g%3===0)txt(s,{x,y:BASE+18,'font-size':7,'font-weight':600,fill:'#C6C5BF','text-anchor':'middle',
class:'fade',style:`animation-delay:${g*.02}s`},'$'+Math.round(g/9*180)+'k');
}
// swarm as clean stacks: x snaps to a GAP-wide lane, dots pile upward —
// the pile height IS the density. no half-overlaps, every dot countable.
const lanes={};
const sorted=deals.map((d,i)=>[d[0],d[1],i]).sort((a,b)=>a[0]-b[0]);
sorted.forEach(([v,ent,i])=>{
const lane=Math.round((mapX(v)-X0)/GAP);
const x=X0+lane*GAP;
const row=lanes[lane]||0;lanes[lane]=row+1;
const y=BASE-R-1-row*GAP;
const dot=ent
?el(s,'circle',{cx:x,cy:y,r:R,fill:PAPER,stroke:INK,'stroke-width':1.1,
class:'pop',style:`animation-delay:${.1+i*.007}s`})
:el(s,'circle',{cx:x,cy:y,r:R,fill:INK,opacity:.82,
class:'pop',style:`animation-delay:${.1+i*.007}s`});
tip(dot,`deal #${i+1} — $${v}k${ent?' · enterprise':''}`);
});
// median
const vs=deals.map(d=>d[0]).sort((a,b)=>a-b);
const med=vs[Math.floor(N/2)];
const mx=mapX(med);
el(s,'line',{x1:mx,y1:52,x2:mx,y2:BASE+8,stroke:'#8F8E88','stroke-width':.9,
'stroke-dasharray':'2 4',class:'fade',style:'animation-delay:1s'});
txt(s,{x:mx,y:44,'font-size':9.5,'font-weight':800,fill:INK,'text-anchor':'middle',
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1.1s`,
class:'fade'},'MEDIAN $'+med+'k');
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
'ONE DOT = ONE DEAL · THE PILE IS THE DISTRIBUTION · HOLLOW = ENTERPRISE');
});
})();
// ════ L19 · ridgeline ════
// 多组分布的 Lupi 皮:每行一条完成时长的密度山脊,面积由竖发丝组成
// (F3 hairline area 的语法),行与行轻微重叠,后排画在前、前排糊上
// 纸色 slab 遮挡(dotty matrix 的手法)。峰值点标中心时长。
(()=>{
const ROWS=[['MOBILE',3.2,1.3],['DESKTOP',5.1,1.9],['API',7.4,2.3],['IMPORTS',10.8,3.2],['BATCH',15.2,4]];
obsReveal('ridge',s=>{
const X0=92,X1=372,MAXH=24,N=72,LIFT=54;
const rowY=i=>96+i*44;
ROWS.forEach(([name,c,sp],i)=>{
const y0=rowY(i);
const dens=Array.from({length:N},(_,k)=>{
const h=k/(N-1)*MAXH;
return Math.exp(-((h-c)**2)/(2*sp*sp))+.26*Math.exp(-((h-c*2.1)**2)/(2*(sp*1.7)**2));
});
const dmax=Math.max(...dens);
const pts=dens.map((d,k)=>[X0+k/(N-1)*(X1-X0),y0-d/dmax*LIFT]);
const crest='M'+pts.map(p=>p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' L ');
el(s,'path',{d:crest+` L${X1} ${y0} L${X0} ${y0} Z`,fill:PAPER,'fill-opacity':.96,
class:'fade',style:`animation-delay:${i*.1}s`});
pts.forEach(([px,py],k)=>{
if(k%2)return;
el(s,'line',{x1:px,y1:y0,x2:px,y2:py,stroke:'#8F8E88','stroke-width':.5,
opacity:.28+rnd(k+1,i+3)*.32,class:'fade',style:`animation-delay:${i*.1+k*.004}s`});
});
el(s,'path',{d:crest,fill:'none',stroke:INK,'stroke-width':.9,pathLength:1,
class:'draw',style:`animation-delay:${.1+i*.1}s;animation-duration:.8s`});
el(s,'line',{x1:X0,y1:y0,x2:X1,y2:y0,stroke:GRID,'stroke-width':.7,
class:'fade',style:`animation-delay:${i*.1}s`});
const pk=dens.indexOf(dmax),[px,py]=pts[pk];
const dot=el(s,'circle',{cx:px,cy:py,r:2.6,fill:INK,class:'pop',style:`animation-delay:${.6+i*.1}s`});
tip(dot,`${name} — most runs finish around ${c.toFixed(1)}h`);
txt(s,{x:px+7,y:py+1,'font-size':8,'font-weight':800,fill:INK,
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.7+i*.1}s`,
class:'fade'},c.toFixed(1)+'h');
txt(s,{x:X0-10,y:y0-3,'font-size':7.5,'font-weight':700,fill:'#6A6963','text-anchor':'end',
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${i*.1}s`},name);
});
const yb=rowY(4);
[0,6,12,18,24].forEach(h=>{
const x=X0+h/MAXH*(X1-X0);
el(s,'line',{x1:x,y1:yb+4,x2:x,y2:yb+9,stroke:'#CFCEC7','stroke-width':.6,
class:'fade',style:'animation-delay:.8s'});
txt(s,{x,y:yb+20,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
class:'fade',style:'animation-delay:.85s'},h+'h');
});
txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
'EACH CREST = ONE PIPELINE · THE AREA IS MADE OF HAIRLINES · DOT = THE PEAK');
});
})();
// ════ L20 · parallel coordinates ════
// 多维记录对比的 Lupi 皮:一条发丝线 = 一个产品走过四根轴。
// 线束的形状就是数据结构(哪两维相关、谁全线领先)。
// 全线最优的上墨标名,其余发丝退后。轴顶名、轴脚 min/max。
(()=>{
const DIMS=[['PRICE $',8,30],['CSAT',6,9.6],['RETENTION %',55,95],['GROWTH %',-5,40]];
const P=[['Editor',12,9.1,91,22],['Boards',18,8.4,86,18],['Forms',9,8.8,78,31],
['Docs',15,8.0,82,12],['Chat',7,7.2,64,8],['Vault',24,7.8,88,6],
['Flows',21,8.6,90,38],['Views',11,7.5,71,14],['Sync',16,6.9,58,-2],
['Pages',8,8.2,74,19],['Grid',19,7.1,62,4],['Hub',13,6.6,52,9]];
obsReveal('parallel',s=>{
const AX=[64,156,248,340],TOP=64,BOT=252;
const mapY=(di,v)=>{const [,lo,hi]=DIMS[di];return BOT-(v-lo)/(hi-lo)*(BOT-TOP)};
// hero: judged on csat+retention+growth (price is a fact, not a virtue)
const score=p=>(p[2]-6)/3.6+(p[3]-55)/40+(p[4]+5)/45;
const hero=P.reduce((a,b)=>score(b)>score(a)?b:a);
DIMS.forEach(([name,lo,hi],di)=>{
el(s,'line',{x1:AX[di],y1:TOP-8,x2:AX[di],y2:BOT+8,stroke:'#B0AFA9','stroke-width':1,
class:'fade',style:`animation-delay:${di*.06}s`});
[[TOP-8],[BOT+8]].forEach(([yy])=>
el(s,'line',{x1:AX[di]-4,y1:yy,x2:AX[di]+4,y2:yy,stroke:'#B0AFA9','stroke-width':1,
class:'fade',style:`animation-delay:${di*.06}s`}));
txt(s,{x:AX[di],y:TOP-22,'font-size':7,'font-weight':800,fill:'#6A6963','text-anchor':'middle',
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${di*.06}s`},name);
txt(s,{x:AX[di]+8,y:TOP-5,'font-size':6.5,'font-weight':600,fill:'#C6C5BF',
class:'fade',style:`animation-delay:${.1+di*.06}s`},hi);
txt(s,{x:AX[di]+8,y:BOT+11,'font-size':6.5,'font-weight':600,fill:'#C6C5BF',
class:'fade',style:`animation-delay:${.1+di*.06}s`},lo);
});
P.forEach((p,i)=>{
const isHero=p===hero;
const ys=[mapY(0,p[1]),mapY(1,p[2]),mapY(2,p[3]),mapY(3,p[4])];
let d=`M${AX[0]} ${ys[0].toFixed(1)}`;
for(let k=0;k<3;k++){
const mx=(AX[k]+AX[k+1])/2;
d+=` C${mx} ${ys[k].toFixed(1)} ${mx} ${ys[k+1].toFixed(1)} ${AX[k+1]} ${ys[k+1].toFixed(1)}`;
}
const ln=el(s,'path',{d,fill:'none',
stroke:isHero?INK:'#8F8E88','stroke-width':isHero?2:.65,
opacity:isHero?1:.5+rnd(i+1,5)*.3,pathLength:1,
class:'draw',style:`animation-delay:${.2+i*.05}s;animation-duration:.8s`});
tip(ln,`${p[0]} — $${p[1]} · CSAT ${p[2]} · ${p[3]}% retained · ${p[4]}% growth`);
ys.forEach((y,di)=>el(s,'circle',{cx:AX[di],cy:y,r:isHero?3:1.4,
fill:isHero?INK:'#8F8E88',opacity:isHero?1:.7,
class:'pop',style:`animation-delay:${.3+i*.05+di*.04}s`}));
if(isHero)
txt(s,{x:AX[3]+12,y:ys[3]+3,'font-size':9,'font-weight':800,fill:INK,
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
class:'fade'},p[0]);
});
txt(s,{x:200,y:302,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
'ONE HAIRLINE = ONE PRODUCT · INK = BEST ACROSS THE BOARD');
});
})();
})();
</script>
</body>
</html>