basics-gallery.html 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8" />
  5. <meta name="viewport" content="width=device-width,initial-scale=1" />
  6. <title>Lieflat Charts — Basics Gallery · 17 张基础型参考实现</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com">
  8. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
  9. <script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
  10. <style>
  11. :root{--bg:#F0EFEB;--ink:#1C1C1A;--muted:#8F8E88;--faint:#C6C5BF;--grid:#DEDDD6}
  12. *{margin:0;padding:0;box-sizing:border-box}
  13. body{background:var(--bg);font-family:'Inter',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
  14. .grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1100px;margin:0 auto}
  15. .card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
  16. .card.wide{grid-column:1/-1}
  17. h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
  18. .sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
  19. .src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
  20. .badge{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.08em;
  21. padding:3px 10px;border-radius:99px;margin-bottom:10px;border:1px dashed #B0AFA9;color:#8F8E88}
  22. svg{width:100%;max-height:330px;display:block;margin:0 auto}
  23. .chartbox{width:100%;height:360px}
  24. svg text{font-family:'Inter',sans-serif}
  25. .pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
  26. @keyframes pop{from{transform:scale(0)}to{transform:none}}
  27. .fade{animation:fade .9s ease both}
  28. @keyframes fade{from{opacity:0}}
  29. .draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
  30. @keyframes draw{to{stroke-dashoffset:0}}
  31. .pagehead{max-width:1100px;margin:0 auto 26px;padding:0 4px}
  32. .pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
  33. .pagehead p{font-size:12px;color:#8F8E88;margin-top:5px;line-height:1.7}
  34. </style>
  35. </head>
  36. <body>
  37. <div class="pagehead">
  38. <h1>基础型组(F1–F17)· Lupi 语法 × 基础图型剪影</h1>
  39. <p>柱 / 折线 / 面积 / 环形 / 横条 / 分组 / 堆叠 / 散点 / 瀑布 / 热力 / 进度 / 哑铃 / 矩形树图。远看认得出图型剪影,近看每个单位可数(1 档 = $1k · 1 tick = 1 人 / 1%)。为稀疏聚合数据而设:数据不用多,密度来自单位分解。代码检索方式同 lupi-gallery:按 <code>// ════ 图型名 ════</code> 注释块。</p>
  40. </div>
  41. <div class="grid2">
  42. <!-- B1 · rung bars -->
  43. <div class="card">
  44. <span class="badge">LUPI 基础型 · 柱状</span>
  45. <h2>Revenue by plan, rung by rung</h2>
  46. <div class="sub">one rung = one $k of MRR · the bar is a ladder you can count</div>
  47. <svg id="rungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  48. <div class="src">RUNG BARS · MONO-BASIC · BILLING</div>
  49. </div>
  50. <!-- B2 · hairline line -->
  51. <div class="card">
  52. <span class="badge">LUPI 基础型 · 折线</span>
  53. <h2>Thirty days of sign-ups</h2>
  54. <div class="sub">one dot = one day · hollow = weekend · the barcode floor keeps the calendar honest</div>
  55. <svg id="dayline" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  56. <div class="src">HAIRLINE LINE · MONO-BASIC · GROWTH</div>
  57. </div>
  58. <!-- B3 · hairline area -->
  59. <div class="card">
  60. <span class="badge">LUPI 基础型 · 面积</span>
  61. <h2>Concurrent users, filled with days</h2>
  62. <div class="sub">one hairline = one day, floor to peak · the area is made of days, not paint</div>
  63. <svg id="hairarea" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  64. <div class="src">HAIRLINE AREA · MONO-BASIC · LIVE OPS</div>
  65. </div>
  66. <!-- B4 · tick donut -->
  67. <div class="card">
  68. <span class="badge">LUPI 基础型 · 环形</span>
  69. <h2>Where the traffic comes from</h2>
  70. <div class="sub">one tick = one percent · four sources shade the dial · read it like a clock</div>
  71. <svg id="tickdonut" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  72. <div class="src">TICK DONUT · MONO-BASIC · ACQUISITION</div>
  73. </div>
  74. <!-- C1 · tick rows (hbar) -->
  75. <div class="card">
  76. <span class="badge">LUPI 基础型 · 横向条形</span>
  77. <h2>Six teams, shipped and counted</h2>
  78. <div class="sub">one tick = one release this quarter · the row is a queue, not a bar</div>
  79. <svg id="tickrows" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  80. <div class="src">TICK ROWS · MONO-BASIC · RELEASE LOG</div>
  81. </div>
  82. <!-- C2 · paired rungs (grouped bar) -->
  83. <div class="card">
  84. <span class="badge">LUPI 基础型 · 分组柱</span>
  85. <h2>This year against last, plan by plan</h2>
  86. <div class="sub">two ladders per plan · solid rungs = 2026 · faint rungs = 2025 · one rung = $1k</div>
  87. <svg id="pairrungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  88. <div class="src">PAIRED RUNGS · MONO-BASIC · BILLING YoY</div>
  89. </div>
  90. <!-- C3 · stacked rungs -->
  91. <div class="card">
  92. <span class="badge">LUPI 基础型 · 堆叠柱</span>
  93. <h2>Where each region's revenue sits</h2>
  94. <div class="sub">one rung = $1k · three products stack up the ladder, darkest carries the most</div>
  95. <svg id="stackrungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  96. <div class="src">STACKED RUNGS · MONO-BASIC · REVENUE MIX</div>
  97. </div>
  98. <!-- C4 · plumb scatter -->
  99. <div class="card">
  100. <span class="badge">LUPI 基础型 · 散点</span>
  101. <h2>Price against satisfaction, twelve products</h2>
  102. <div class="sub">every dot hangs a plumb line to the floor · read x by the line, y by the height</div>
  103. <svg id="plumb" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  104. <div class="src">PLUMB SCATTER · MONO-BASIC · PRODUCT PANEL</div>
  105. </div>
  106. <!-- C5 · rung waterfall -->
  107. <div class="card">
  108. <span class="badge">LUPI 基础型 · 瀑布</span>
  109. <h2>From gross to net, step by step</h2>
  110. <div class="sub">one rung = $1k · solid rungs add, hollow rungs take away · the ladder walks down</div>
  111. <svg id="fall" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  112. <div class="src">RUNG WATERFALL · MONO-BASIC · P&L</div>
  113. </div>
  114. <!-- C6 · dot heat -->
  115. <div class="card">
  116. <span class="badge">LUPI 基础型 · 热力</span>
  117. <h2>When support gets loud</h2>
  118. <div class="sub">7 days × 12 hours · dot area = tickets · biggest cell labeled · silence stays visible</div>
  119. <svg id="dotheat" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  120. <div class="src">DOT HEAT · MONO-BASIC · SUPPORT DESK</div>
  121. </div>
  122. <!-- C7 · tick gauge -->
  123. <div class="card">
  124. <span class="badge">LUPI 基础型 · 进度</span>
  125. <h2>How far to the quarter's goal</h2>
  126. <div class="sub">one tick = one percent of target · inked = earned · the dial reads left to right</div>
  127. <svg id="gauge" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  128. <div class="src">TICK GAUGE · MONO-BASIC · QUARTERLY TARGET</div>
  129. </div>
  130. <!-- C8 · dumbbell queue -->
  131. <div class="card">
  132. <span class="badge">LUPI 基础型 · 哑铃对比</span>
  133. <h2>Onboarding, before and after the redesign</h2>
  134. <div class="sub">hollow dot = before · solid dot = after · beads on the track = minutes saved, countable</div>
  135. <svg id="dumbbell" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  136. <div class="src">DUMBBELL QUEUE · MONO-BASIC · UX TIMING</div>
  137. </div>
  138. <!-- C9 · nested treemap -->
  139. <div class="card wide">
  140. <span class="badge">LUPI 基础型 · 矩形树图</span>
  141. <h2>Where the work went</h2>
  142. <div class="sub">1,280 shipped hours · area = effort · parent bands preserve the product hierarchy</div>
  143. <div id="treemap" class="chartbox" role="img" aria-label="Treemap of shipped hours nested by product family"></div>
  144. <div class="src">NESTED TREEMAP · MONO-BASIC · WORK PORTFOLIO</div>
  145. </div>
  146. <!-- F14 · rung histogram -->
  147. <div class="card">
  148. <span class="badge">LUPI 基础型 · 直方图</span>
  149. <h2>Most tickets resolve within six hours</h2>
  150. <div class="sub">one rung = one ticket in a hundred · bins of two hours · the ladder shows the pile</div>
  151. <svg id="histo" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  152. <div class="src">RUNG HISTOGRAM · MONO-BASIC · SUPPORT DESK</div>
  153. </div>
  154. <!-- F15 · tick box -->
  155. <div class="card">
  156. <span class="badge">LUPI 基础型 · 箱线图</span>
  157. <h2>Reply times, boxed by plan</h2>
  158. <div class="sub">box = the middle half of tickets · paper tick = median · hollow dot = an outlier nobody enjoyed</div>
  159. <svg id="boxplot" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  160. <div class="src">TICK BOX · MONO-BASIC · SUPPORT DESK</div>
  161. </div>
  162. <!-- F16 · stream ribbon -->
  163. <div class="card wide">
  164. <span class="badge">LUPI 基础型 · STREAMGRAPH</span>
  165. <h2>Three products trade the same river</h2>
  166. <div class="sub">48 weeks of usage share · band width = weekly active accounts · the river swells where the total grows</div>
  167. <svg id="stream" viewBox="0 0 840 300" preserveAspectRatio="xMidYMid meet" style="max-height:300px"></svg>
  168. <div class="src">STREAM RIBBON · MONO-BASIC · PRODUCT ANALYTICS</div>
  169. </div>
  170. <!-- F17 · candlestick -->
  171. <div class="card">
  172. <span class="badge">LUPI 基础型 · K 线</span>
  173. <h2>Six weeks of the token, candle by candle</h2>
  174. <div class="sub">body = open to close · wick = the day's full reach · ink = closed down · hollow = closed up</div>
  175. <svg id="candle" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  176. <div class="src">CANDLESTICK · MONO-BASIC · MARKET DATA</div>
  177. </div>
  178. </div>
  179. <script>
  180. // ── shared Mono tokens(与 lupi-gallery 同源) ──
  181. const INK='#1C1C1A',PAPER='#F0EFEB',MUTED='#8F8E88',GRID='#DEDDD6';
  182. const NS='http://www.w3.org/2000/svg';
  183. 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};
  184. const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
  185. const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
  186. const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
  187. const D2R=Math.PI/180;
  188. const pol=(cx,cy,r,deg)=>[cx+r*Math.cos(deg*D2R),cy+r*Math.sin(deg*D2R)];
  189. const obsReveal=(id,fn)=>{
  190. const n=document.getElementById(id);
  191. const go=()=>{n.innerHTML='';fn(n)};
  192. const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
  193. io.observe(n);
  194. n.style.cursor='pointer';
  195. n.addEventListener('click',go);
  196. };
  197. // ════ B1 · rung bars ════
  198. // 柱状图的 Lupi 化:柱身 = 一格格横档,1 档 = 1 个诚实单位。
  199. // 远看是柱状图剪影,近看每一档都数得出来。档长、透明度 rnd 抖动。
  200. (()=>{
  201. const D=[['FREE',38],['STARTER',27],['PRO',22],['TEAM',16],['SCALE',11],['ENT',7]];
  202. obsReveal('rungs',s=>{
  203. const x0=i=>56+i*56,base=266,step=5.6,HW=14;
  204. D.forEach(([name,v],i)=>{
  205. const x=x0(i);
  206. for(let k=0;k<v;k++){
  207. const y=base-k*step,w=HW-1.5+rnd(k+1,i+2)*3;
  208. el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:INK,'stroke-width':1,
  209. opacity:.5+rnd(k+2,i+4)*.5,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
  210. if(k%5===4)el(s,'circle',{cx:x+HW+4.5,cy:y,r:.8,fill:'#C6C5BF',
  211. class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
  212. }
  213. const topY=base-(v-1)*step;
  214. const num=txt(s,{x,y:topY-10,'font-size':11,'font-weight':800,fill:INK,'text-anchor':'middle',
  215. class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
  216. tip(num,`${name} — $${v}k MRR`);
  217. txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
  218. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
  219. });
  220. el(s,'line',{x1:28,y1:base+4,x2:372,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
  221. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  222. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
  223. 'ONE RUNG = $1K · DOT MARKS EVERY FIFTH');
  224. });
  225. })();
  226. // ════ B2 · hairline line ════
  227. // 折线图的 Lupi 化:barcode 的日历地板(每天一根发丝,无论有没有事)+
  228. // 发丝折线 + 逐日圆点(周末空心)。峰值两个点放大标数。
  229. (()=>{
  230. const N=30;
  231. const val=d=>46+22*Math.sin(d/4.6)+14*Math.sin(d/2.1)+rnd(d+1,5)*12;
  232. obsReveal('dayline',s=>{
  233. const x=d=>30+d*11.7,base=262,map=v=>base-v*2.15;
  234. // calendar floor
  235. for(let d=0;d<N;d++)
  236. el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:base-7,stroke:'#CFCEC7','stroke-width':.6,
  237. class:'fade',style:`animation-delay:${d*.008}s`});
  238. el(s,'line',{x1:24,y1:base,x2:376,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
  239. const vs=Array.from({length:N},(_,d)=>val(d));
  240. // top-2 peaks, apart
  241. const top=[];
  242. for(const d of [...vs.keys()].sort((a,b)=>vs[b]-vs[a])){
  243. if(top.every(t=>Math.abs(t-d)>=5))top.push(d);
  244. if(top.length===2)break;
  245. }
  246. // hairline path
  247. const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
  248. el(s,'path',{d:'M'+pts,fill:'none',stroke:INK,'stroke-width':1,pathLength:1,
  249. class:'draw',style:'animation-duration:1.2s'});
  250. vs.forEach((v,d)=>{
  251. const weekend=d%7===5||d%7===6,big=top.includes(d);
  252. const dot=el(s,'circle',{cx:x(d),cy:map(v),r:big?4.2:2.1,
  253. fill:weekend?PAPER:INK,stroke:INK,'stroke-width':weekend?1:0,
  254. class:'pop',style:`animation-delay:${.2+d*.03}s`});
  255. tip(dot,`Day ${d+1} — ${Math.round(v)} sign-ups`);
  256. if(big)txt(s,{x:x(d),y:map(v)-11,'font-size':9.5,'font-weight':800,fill:INK,'text-anchor':'middle',
  257. style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${1+d*.01}s`,
  258. class:'fade'},Math.round(v));
  259. });
  260. [[0,'JUN 1'],[14,'JUN 15'],[29,'JUN 30']].forEach(([d,m])=>
  261. txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'middle',
  262. 'letter-spacing':'.1em',class:'fade'},m));
  263. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  264. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
  265. 'ONE DOT = ONE DAY · HOLLOW = WEEKEND');
  266. });
  267. })();
  268. // ════ B3 · hairline area ════
  269. // 面积图的 Lupi 化:填充不是色块,是一天一根发丝立到自己的峰值。
  270. // 面积由日子组成。顶边一根细线收轮廓,峰值日加点标数。
  271. (()=>{
  272. const N=45;
  273. const val=d=>34+26*Math.sin(d/7.2)+12*Math.sin(d/2.8)+rnd(d+1,3)*16;
  274. obsReveal('hairarea',s=>{
  275. const x=d=>28+d*7.75,base=262,map=v=>base-v*2.35;
  276. el(s,'line',{x1:22,y1:base,x2:378,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
  277. const vs=Array.from({length:N},(_,d)=>val(d));
  278. const peak=vs.indexOf(Math.max(...vs));
  279. vs.forEach((v,d)=>{
  280. el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:map(v),
  281. stroke:d===peak?INK:'#8F8E88','stroke-width':d===peak?1.1:.55,
  282. opacity:d===peak?1:.5+rnd(d+1,7)*.45,
  283. class:'fade',style:`animation-delay:${d*.014}s`});
  284. });
  285. const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
  286. el(s,'path',{d:'M'+pts,fill:'none',stroke:INK,'stroke-width':1.2,pathLength:1,
  287. class:'draw',style:'animation-delay:.4s;animation-duration:1.2s'});
  288. const pd=el(s,'circle',{cx:x(peak),cy:map(vs[peak]),r:4.2,fill:INK,
  289. class:'pop',style:'animation-delay:1.2s'});
  290. tip(pd,`Day ${peak+1} — ${Math.round(vs[peak])}k peak`);
  291. txt(s,{x:x(peak),y:map(vs[peak])-11,'font-size':9.5,'font-weight':800,fill:INK,'text-anchor':'middle',
  292. style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1.3s`,
  293. class:'fade'},Math.round(vs[peak])+'k');
  294. [[0,'MAY'],[22,'JUN'],[44,'JUL']].forEach(([d,m])=>
  295. txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'middle',
  296. 'letter-spacing':'.1em',class:'fade'},m));
  297. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  298. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.3s'},
  299. 'ONE HAIRLINE = ONE DAY, FLOOR TO PEAK');
  300. });
  301. })();
  302. // ════ B4 · tick donut ════
  303. // 环形图的 Lupi 化:ballot tally 的百人队列绕成整圈表盘,1 tick = 1%,
  304. // 四段来源用灰阶 ladder 分段,段间留一格呼吸。中心只放总数和单位说明。
  305. (()=>{
  306. const D=[['ORGANIC',37,INK],['PAID',28,'#55554F'],['REFERRAL',21,'#8F8E88'],['SOCIAL',14,'#B0AFA9']];
  307. obsReveal('tickdonut',s=>{
  308. const cx=200,cy=148,R0=64;
  309. let k0=0;
  310. D.forEach(([name,v,shade],si)=>{
  311. for(let k=0;k<v;k++){
  312. const idx=k0+k,a=idx*3.6-90;
  313. const len=10+rnd(idx+1,si+2)*6;
  314. const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
  315. el(s,'line',{x1,y1,x2,y2,stroke:shade,'stroke-width':1,
  316. class:'fade',style:`animation-delay:${idx*.012}s`});
  317. if(idx%10===0){
  318. const [dx,dy]=pol(cx,cy,R0-5,a);
  319. el(s,'circle',{cx:dx,cy:dy,r:.8,fill:'#C6C5BF',
  320. class:'fade',style:`animation-delay:${idx*.012}s`});
  321. }
  322. }
  323. // segment label at mid-angle, outside, tied by a dotted hairline
  324. const mid=(k0+v/2)*3.6-90,[lx,ly]=pol(cx,cy,R0+38,mid),[gx,gy]=pol(cx,cy,R0+20,mid);
  325. el(s,'line',{x1:gx,y1:gy,x2:lx,y2:ly,stroke:'#C6C5BF','stroke-width':.7,
  326. 'stroke-dasharray':'1 3',class:'fade',style:`animation-delay:${.6+si*.1}s`});
  327. const anchor=Math.cos(mid*D2R)>0.3?'start':Math.cos(mid*D2R)<-0.3?'end':'middle';
  328. const lab=txt(s,{x:lx,y:ly+3,'font-size':8,'font-weight':800,fill:shade,'text-anchor':anchor,
  329. 'letter-spacing':'.06em',style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.65+si*.1}s`,
  330. class:'fade'},`${name} · ${v}`);
  331. tip(lab,`${name} — ${v}% of traffic`);
  332. k0+=v;
  333. });
  334. txt(s,{x:cx,y:cy-2,'font-size':22,'font-weight':800,fill:INK,'text-anchor':'middle',
  335. class:'fade',style:'animation-delay:.9s'},'100');
  336. txt(s,{x:cx,y:cy+14,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
  337. 'letter-spacing':'.1em',class:'fade',style:'animation-delay:.9s'},'TICKS · ONE = 1%');
  338. txt(s,{x:200,y:296,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  339. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
  340. 'TWELVE O’CLOCK IS ZERO · DOT MARKS EVERY TENTH · READS CLOCKWISE');
  341. });
  342. })();
  343. // ════ C1 · tick rows ════
  344. // 横向条形:一行 = 一支队伍的发布队列,1 tick = 1 次发布。
  345. // tick 高度/透明度抖动,每 5 根一个点标,行尾大数。
  346. (()=>{
  347. const D=[['PLATFORM',34],['GROWTH',28],['MOBILE',22],['INFRA',17],['ML',11],['DESIGN',8]];
  348. obsReveal('tickrows',s=>{
  349. const y0=i=>52+i*44,X0=104,PX=6.9;
  350. D.forEach(([name,v],i)=>{
  351. const y=y0(i);
  352. txt(s,{x:94,y:y+3,'font-size':8,'font-weight':700,fill:'#6A6963','text-anchor':'end',
  353. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
  354. el(s,'line',{x1:X0,y1:y+9,x2:X0+34*PX,y2:y+9,stroke:GRID,'stroke-width':.6,
  355. class:'fade',style:`animation-delay:${i*.08}s`});
  356. for(let k=0;k<v;k++){
  357. const x=X0+k*PX+PX/2,h=9+rnd(k+1,i+2)*6;
  358. el(s,'line',{x1:x,y1:y+9,x2:x,y2:y+9-h,stroke:INK,'stroke-width':.9,
  359. opacity:.55+rnd(k+3,i+5)*.45,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
  360. if(k%5===4)el(s,'circle',{cx:x,cy:y+13,r:.8,fill:'#C6C5BF',
  361. class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
  362. }
  363. const lab=txt(s,{x:X0+v*PX+10,y:y+4,'font-size':11,'font-weight':800,fill:INK,
  364. class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
  365. tip(lab,`${name} — ${v} releases`);
  366. });
  367. txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  368. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
  369. 'ONE TICK = ONE RELEASE · DOT MARKS EVERY FIFTH');
  370. });
  371. })();
  372. // ════ C2 · paired rungs ════
  373. // 分组柱:每类两把梯子并肩,今年实档、去年淡档。差值一眼可数。
  374. (()=>{
  375. const D=[['FREE',31,38],['STARTER',22,27],['PRO',16,22],['TEAM',13,16],['ENT',6,9]];
  376. obsReveal('pairrungs',s=>{
  377. const x0=i=>64+i*66,base=258,step=5.4,HW=10;
  378. D.forEach(([name,was,now],i)=>{
  379. const xa=x0(i)-13,xb=x0(i)+13;
  380. for(let k=0;k<was;k++){
  381. const y=base-k*step,w=HW-1.2+rnd(k+1,i+2)*2.4;
  382. el(s,'line',{x1:xa-w,y1:y,x2:xa+w,y2:y,stroke:'#B0AFA9','stroke-width':1,
  383. opacity:.5+rnd(k+2,i+3)*.4,class:'fade',style:`animation-delay:${i*.08+k*.01}s`});
  384. }
  385. for(let k=0;k<now;k++){
  386. const y=base-k*step,w=HW-1.2+rnd(k+1,i+7)*2.4;
  387. el(s,'line',{x1:xb-w,y1:y,x2:xb+w,y2:y,stroke:INK,'stroke-width':1,
  388. opacity:.6+rnd(k+2,i+8)*.4,class:'fade',style:`animation-delay:${.15+i*.08+k*.01}s`});
  389. }
  390. const topB=base-(now-1)*step;
  391. const num=txt(s,{x:xb,y:topB-9,'font-size':10.5,'font-weight':800,fill:INK,'text-anchor':'middle',
  392. class:'fade',style:`animation-delay:${.5+i*.08}s`},now);
  393. tip(num,`${name} — $${was}k → $${now}k`);
  394. txt(s,{x:xa,y:base-(was-1)*step-9,'font-size':8.5,'font-weight':700,fill:'#B0AFA9','text-anchor':'middle',
  395. class:'fade',style:`animation-delay:${.5+i*.08}s`},was);
  396. txt(s,{x:x0(i),y:base+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
  397. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
  398. });
  399. el(s,'line',{x1:30,y1:base+4,x2:370,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
  400. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  401. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
  402. 'FAINT = 2025 · INK = 2026 · ONE RUNG = $1K');
  403. });
  404. })();
  405. // ════ C3 · stacked rungs ════
  406. // 堆叠柱:同一把梯子分三段灰阶,段间空一格呼吸,段值贴段中。
  407. (()=>{
  408. const D=[['NA',[18,11,7]],['EU',[14,9,5]],['APAC',[9,7,6]],['LATAM',[5,4,2]]];
  409. const SHADE=[INK,'#8F8E88','#C0BFB8'];
  410. const SEG=['CORE','ADD-ONS','SERVICES'];
  411. obsReveal('stackrungs',s=>{
  412. const x0=i=>72+i*76,base=262,step=5.2,HW=13;
  413. D.forEach(([name,segs],i)=>{
  414. const x=x0(i);let k0=0;
  415. segs.forEach((v,si)=>{
  416. for(let k=0;k<v;k++){
  417. const y=base-(k0+k+si)*step,w=HW-1.4+rnd(k+1,i*3+si+2)*2.8;
  418. el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:SHADE[si],'stroke-width':1,
  419. opacity:.6+rnd(k+2,i+si+4)*.4,class:'fade',
  420. style:`animation-delay:${i*.09+(k0+k)*.012}s`});
  421. }
  422. const midY=base-(k0+v/2+si)*step;
  423. const lab=txt(s,{x:x+HW+7,y:midY+2.5,'font-size':8,'font-weight':800,fill:SHADE[si]===`#C0BFB8`?'#8F8E88':SHADE[si],
  424. class:'fade',style:`animation-delay:${.5+i*.09+si*.06}s`},v);
  425. tip(lab,`${name} ${SEG[si]} — $${v}k`);
  426. k0+=v;
  427. });
  428. const total=segs[0]+segs[1]+segs[2];
  429. txt(s,{x,y:base-(k0+2)*step-8,'font-size':10.5,'font-weight':800,fill:INK,'text-anchor':'middle',
  430. class:'fade',style:`animation-delay:${.6+i*.09}s`},total);
  431. txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
  432. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.09}s`},name);
  433. });
  434. el(s,'line',{x1:36,y1:base+4,x2:364,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
  435. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  436. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
  437. 'DARKEST = CORE · MID = ADD-ONS · PALE = SERVICES · ONE RUNG = $1K');
  438. });
  439. })();
  440. // ════ C4 · plumb scatter ════
  441. // 散点:每个点垂一根发丝铅垂线到地板,x 读线脚、y 读高度。
  442. // 地板是 barcode 刻度。最好/最差两个点标名。
  443. (()=>{
  444. const P=[['Editor',72,86],['Boards',58,74],['Docs',44,79],['Chat',38,62],['Flows',66,58],
  445. ['Vault',82,71],['Pages',28,55],['Sync',52,49],['Grid',88,44],['Views',20,68],
  446. ['Hub',76,32],['Forms',34,38]];
  447. obsReveal('plumb',s=>{
  448. const X0=48,X1=368,base=258,mapX=p=>X0+p/100*(X1-X0),mapY=v=>base-(v-20)*2.6;
  449. // barcode floor
  450. for(let g=0;g<=20;g++){
  451. const x=X0+g/20*(X1-X0);
  452. el(s,'line',{x1:x,y1:base,x2:x,y2:base-(g%5===0?7:4),stroke:'#CFCEC7','stroke-width':.6,
  453. class:'fade',style:`animation-delay:${g*.01}s`});
  454. }
  455. el(s,'line',{x1:X0-6,y1:base,x2:X1+6,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
  456. txt(s,{x:X0,y:base+16,'font-size':7,'font-weight':600,fill:'#C6C5BF',class:'fade'},'CHEAP');
  457. txt(s,{x:X1,y:base+16,'font-size':7,'font-weight':600,fill:'#C6C5BF','text-anchor':'end',
  458. class:'fade'},'PREMIUM');
  459. const best=P.reduce((a,b)=>b[2]>a[2]?b:a),worst=P.reduce((a,b)=>b[2]<a[2]?b:a);
  460. P.forEach(([name,px,sat],i)=>{
  461. const x=mapX(px),y=mapY(sat),hero=name===best[0]||name===worst[0];
  462. el(s,'line',{x1:x,y1:base,x2:x,y2:y,stroke:'#B0AFA9','stroke-width':.55,
  463. opacity:.6,class:'fade',style:`animation-delay:${.2+i*.05}s`});
  464. const dot=el(s,'circle',{cx:x,cy:y,r:hero?4.6:2.6,fill:hero?INK:'#55554F',
  465. class:'pop',style:`animation-delay:${.25+i*.05}s`});
  466. tip(dot,`${name} — price ${px} · satisfaction ${sat}`);
  467. if(hero)txt(s,{x,y:y-10,'font-size':8.5,'font-weight':800,fill:INK,'text-anchor':'middle',
  468. style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.8}s`,
  469. class:'fade'},`${name} · ${sat}`);
  470. });
  471. txt(s,{x:26,y:mapY(85),'font-size':7,'font-weight':600,fill:'#C6C5BF',
  472. transform:`rotate(-90 26 ${mapY(85)})`,'text-anchor':'end','letter-spacing':'.08em',class:'fade'},'HAPPIER ↑');
  473. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  474. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
  475. 'EVERY DOT HANGS A PLUMB LINE · READ X AT THE FLOOR');
  476. });
  477. })();
  478. // ════ C5 · rung waterfall ════
  479. // 瀑布:从毛收入走到净利,每级一把小梯子。加项实档、减项空心档(stroke 描边格)。
  480. // 层与层之间虚线台阶承接。
  481. (()=>{
  482. const D=[['GROSS',42,null],['REFUNDS',-6,null],['COGS',-11,null],['OPS',-8,null],['NET',17,'total']];
  483. obsReveal('fall',s=>{
  484. const x0=i=>58+i*72,step=5.2,HW=11;
  485. let run=0;
  486. const levels=[];
  487. D.forEach(([name,v],i)=>{levels.push([name,v,run]);if(v!==17||name!=='NET')run+=(name==='GROSS'?v:(name==='NET'?0:v))});
  488. // recompute cleanly: running level after each step
  489. let lv=0;const rows=[];
  490. D.forEach(([name,v],i)=>{
  491. if(name==='GROSS'){rows.push([name,v,0,v]);lv=v}
  492. else if(name==='NET'){rows.push([name,lv,0,lv])}
  493. else{rows.push([name,v,lv+v,lv]);lv=lv+v}
  494. });
  495. const base=252,yOf=k=>base-k*step;
  496. rows.forEach(([name,v,lo,hi],i)=>{
  497. const x=x0(i),isTotal=name==='GROSS'||name==='NET',neg=v<0&&!isTotal;
  498. const from=isTotal?0:lo,to=isTotal?v:hi,n=Math.abs(to-from);
  499. for(let k=0;k<n;k++){
  500. const y=yOf(from+k),w=HW-1.2+rnd(k+1,i+2)*2.4;
  501. if(neg)el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:'#8F8E88','stroke-width':1,
  502. 'stroke-dasharray':'2.5 2.5',opacity:.7,class:'fade',
  503. style:`animation-delay:${i*.12+k*.014}s`});
  504. else el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:INK,'stroke-width':1,
  505. opacity:.6+rnd(k+2,i+4)*.4,class:'fade',style:`animation-delay:${i*.12+k*.014}s`});
  506. }
  507. // step hand-off: dashed hairline to the next column's start level
  508. if(i<rows.length-1){
  509. const nx=x0(i+1),yl=yOf(isTotal?v:hi- (neg?0:0));
  510. const lvl=isTotal?v:(neg?lo:hi);
  511. el(s,'line',{x1:x+HW+2,y1:yOf(lvl),x2:nx-HW-2,y2:yOf(lvl),stroke:'#C6C5BF',
  512. 'stroke-width':.7,'stroke-dasharray':'2 3',class:'fade',
  513. style:`animation-delay:${.3+i*.12}s`});
  514. }
  515. const topY=yOf(Math.max(from,to));
  516. const num=txt(s,{x,y:topY-8,'font-size':10,'font-weight':800,
  517. fill:neg?'#8F8E88':INK,'text-anchor':'middle',
  518. class:'fade',style:`animation-delay:${.4+i*.12}s`},(neg?'−':'')+Math.abs(v));
  519. tip(num,`${name} — ${neg?'−':''}$${Math.abs(v)}k`);
  520. txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
  521. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.12}s`},name);
  522. });
  523. el(s,'line',{x1:30,y1:base+4,x2:370,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
  524. txt(s,{x:200,y:302,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  525. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
  526. 'SOLID RUNGS ADD · DASHED RUNGS TAKE AWAY · ONE RUNG = $1K');
  527. });
  528. })();
  529. // ════ C6 · dot heat ════
  530. // 热力图:punch card 的 Lupi 皮。7×12 网格,点面积 = 工单量,
  531. // 静默格留一粒极小点(沉默可见)。最热格标数。
  532. (()=>{
  533. const DAY=['MON','TUE','WED','THU','FRI','SAT','SUN'];
  534. obsReveal('dotheat',s=>{
  535. const x0=j=>64+j*27,y0=i=>58+i*30;
  536. let max=0,mi=0,mj=0;
  537. const v=(i,j)=>{
  538. const day=i<5?1:.32; // weekends quiet
  539. const hour=Math.exp(-((j-4.6)**2)/7)+.7*Math.exp(-((j-8.4)**2)/5); // two peaks
  540. const raw=day*hour*22*(0.6+rnd(i*12+j+1,j+3)*.8);
  541. return Math.round(raw);
  542. };
  543. for(let i=0;i<7;i++)for(let j=0;j<12;j++){const t=v(i,j);if(t>max){max=t;mi=i;mj=j}}
  544. for(let i=0;i<7;i++){
  545. txt(s,{x:50,y:y0(i)+3,'font-size':7.5,'font-weight':700,fill:'#6A6963','text-anchor':'end',
  546. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.05}s`},DAY[i]);
  547. for(let j=0;j<12;j++){
  548. const t=v(i,j),x=x0(j),y=y0(i);
  549. if(!t){el(s,'circle',{cx:x,cy:y,r:.8,fill:'#D8D6CE',
  550. class:'pop',style:`animation-delay:${i*.05+j*.015}s`});continue}
  551. const hero=i===mi&&j===mj;
  552. const dot=el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1,
  553. fill:t>max*.66?INK:t>max*.33?'#6A6963':'#B0AFA9',
  554. class:'pop',style:`animation-delay:${i*.05+j*.015}s`});
  555. tip(dot,`${DAY[i]} ${8+j}:00 — ${t} tickets`);
  556. if(hero)el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1+3.4,fill:'none',
  557. stroke:INK,'stroke-width':1,'stroke-dasharray':'2 3',
  558. class:'fade',style:'animation-delay:1s'});
  559. }
  560. }
  561. for(let j=0;j<12;j+=2)
  562. txt(s,{x:x0(j),y:y0(6)+26,'font-size':7,'font-weight':600,fill:'#C6C5BF','text-anchor':'middle',
  563. class:'fade',style:`animation-delay:${j*.02}s`},(8+j)+':00');
  564. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  565. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
  566. `DOT AREA = TICKETS · DASHED RING = THE PEAK, ${max} · TINY DOT = A QUIET HOUR`);
  567. });
  568. })();
  569. // ════ C7 · tick gauge ════
  570. // 进度:ballot tick 弯成 210° 表盘,1 tick = 1% 目标。走到 73 上墨收笔。
  571. // 里程碑 25/50/75/100 点标 + 小字。中心大数 + 剩余量小字。
  572. (()=>{
  573. const GOAL=73;
  574. obsReveal('gauge',s=>{
  575. const cx=200,cy=190,R0=104,A0=-195,SW=210; // -195° → +15°
  576. for(let k=0;k<100;k++){
  577. const a=A0+k/100*SW,inked=k<GOAL;
  578. const len=inked?13+rnd(k+1,3)*6:5+rnd(k+1,7)*2.5;
  579. const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
  580. el(s,'line',{x1,y1,x2,y2,stroke:inked?INK:'#CFCEC7','stroke-width':inked?1:.6,
  581. class:'fade',style:`animation-delay:${k*.012}s`});
  582. }
  583. [25,50,75,100].forEach(m=>{
  584. const a=A0+m/100*SW,[dx,dy]=pol(cx,cy,R0-7,a),[tx2,ty2]=pol(cx,cy,R0-19,a);
  585. el(s,'circle',{cx:dx,cy:dy,r:1,fill:'#B0AFA9',class:'fade',style:'animation-delay:.8s'});
  586. txt(s,{x:tx2,y:ty2+3,'font-size':7,'font-weight':600,fill:'#C6C5BF','text-anchor':'middle',
  587. class:'fade',style:'animation-delay:.85s'},m);
  588. });
  589. // inked tip bead
  590. const aT=A0+GOAL/100*SW,[ex,ey]=pol(cx,cy,R0+20,aT);
  591. el(s,'circle',{cx:ex,cy:ey,r:2.4,fill:INK,class:'pop',style:'animation-delay:1.1s'});
  592. const num=txt(s,{x:cx,y:cy-4,'font-size':34,'font-weight':800,fill:INK,'text-anchor':'middle',
  593. class:'fade',style:'animation-delay:1s'},GOAL+'%');
  594. tip(num,`$730k of the $1M quarter target`);
  595. txt(s,{x:cx,y:cy+16,'font-size':8,'font-weight':600,fill:MUTED,'text-anchor':'middle',
  596. 'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.05s'},'27 TICKS TO GO');
  597. txt(s,{x:200,y:300,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  598. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
  599. 'ONE TICK = 1% OF TARGET · INKED = EARNED');
  600. });
  601. })();
  602. // ════ C8 · dumbbell queue ════
  603. // 哑铃对比:空心点=改版前,实心点=改版后,两点之间的轨道上串珠,
  604. // 一珠 = 省下的一分钟,可数。全部向左走(变快),没有交叉没有 X。
  605. (()=>{
  606. const D=[['INVITE FLOW',14,6],['FIRST BOARD',19,9],['IMPORT DATA',26,13],['TEAM SETUP',31,21],['GO LIVE',38,30]];
  607. obsReveal('dumbbell',s=>{
  608. const y0=i=>56+i*46,X0=118,X1=364,mapX=v=>X0+(v-4)/36*(X1-X0);
  609. D.forEach(([name,was,now],i)=>{
  610. const y=y0(i),xa=mapX(was),xb=mapX(now);
  611. txt(s,{x:108,y:y+3,'font-size':7.5,'font-weight':700,fill:'#6A6963','text-anchor':'end',
  612. 'letter-spacing':'.06em',class:'fade',style:`animation-delay:${i*.08}s`},name);
  613. el(s,'line',{x1:X0-6,y1:y,x2:X1+6,y2:y,stroke:'#E3E2DB','stroke-width':.7,
  614. class:'fade',style:`animation-delay:${i*.08}s`});
  615. // beads: one per minute saved, strung between the two dots
  616. const n=was-now;
  617. for(let k=0;k<n;k++){
  618. const t=(k+.5)/n,x=xb+t*(xa-xb),yy=y+(rnd(k+1,i+3)-.5)*2.6;
  619. el(s,'circle',{cx:x,cy:yy,r:1.5+rnd(k+2,i+4)*.9,fill:'#8F8E88',opacity:.85,
  620. class:'pop',style:`animation-delay:${.3+i*.08+k*.03}s`});
  621. }
  622. el(s,'circle',{cx:xa,cy:y,r:4.2,fill:PAPER,stroke:INK,'stroke-width':1.3,
  623. class:'pop',style:`animation-delay:${.2+i*.08}s`});
  624. const after=el(s,'circle',{cx:xb,cy:y,r:4.6,fill:INK,
  625. class:'pop',style:`animation-delay:${.6+i*.08}s`});
  626. tip(after,`${name} — ${was} min → ${now} min`);
  627. txt(s,{x:xa+10,y:y-8,'font-size':8.5,'font-weight':700,fill:'#B0AFA9',
  628. class:'fade',style:`animation-delay:${.3+i*.08}s`},was);
  629. txt(s,{x:xb-10,y:y-8,'font-size':10,'font-weight':800,fill:INK,'text-anchor':'end',
  630. class:'fade',style:`animation-delay:${.7+i*.08}s`},now);
  631. });
  632. txt(s,{x:X0,y:290,'font-size':7,'font-weight':600,fill:'#C6C5BF',class:'fade'},'FASTER ←');
  633. txt(s,{x:X1,y:290,'font-size':7,'font-weight':600,fill:'#C6C5BF','text-anchor':'end',
  634. class:'fade'},'MINUTES');
  635. txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  636. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
  637. 'ONE BEAD = ONE MINUTE SAVED · HOLLOW = BEFORE · INK = AFTER');
  638. });
  639. })();
  640. // ════ C9 · nested treemap ════
  641. // 面积只编码工时;Mono 不用随机灰阶区分类目,父级靠标题带和组间留白分开。
  642. (()=>{
  643. const TOTAL=1280,FILL='#55554F';
  644. const leaf=(name,value)=>({name,value,itemStyle:{color:FILL},label:{color:PAPER}});
  645. const DATA=[
  646. {name:'PRODUCT · 50%',children:[leaf('Core app',260),leaf('Collaboration',190),leaf('Search',110),leaf('Mobile',80)]},
  647. {name:'PLATFORM · 31%',children:[leaf('AI systems',170),leaf('Data infra',120),leaf('APIs',105)]},
  648. {name:'GROWTH · 19%',children:[leaf('Acquisition',90),leaf('Retention',80),leaf('Onboarding',75)]},
  649. ];
  650. const host=document.getElementById('treemap');
  651. let chart;
  652. const draw=()=>{
  653. chart=chart||echarts.init(host,null,{renderer:'svg'});
  654. chart.clear();
  655. chart.setOption({animationDuration:900,animationEasing:'quarticOut',aria:{enabled:true},
  656. tooltip:{backgroundColor:PAPER,borderColor:INK,borderWidth:1,textStyle:{color:INK,fontSize:12},
  657. formatter:i=>`${i.treePathInfo.slice(1).map(d=>d.name.replace(/ · .*$/,'')).join(' / ')}<br><b>${i.value} hours</b> · ${(i.value/TOTAL*100).toFixed(1)}%`},
  658. series:[{type:'treemap',data:DATA,top:0,right:0,bottom:0,left:0,roam:false,nodeClick:false,
  659. breadcrumb:{show:false},leafDepth:2,squareRatio:1.2,
  660. label:{show:true,position:'insideTopLeft',padding:[10,8],color:INK,fontSize:12,fontWeight:700,lineHeight:18,
  661. formatter:p=>p.treePathInfo.length<=1?'':p.treePathInfo.length===2?p.name:`${p.name}\n${p.value} h`},
  662. upperLabel:{show:true,height:32,padding:[0,10],color:INK,fontSize:10,fontWeight:700,backgroundColor:PAPER},
  663. itemStyle:{borderColor:PAPER,borderWidth:2,gapWidth:2},
  664. emphasis:{focus:'ancestor',itemStyle:{borderColor:INK,borderWidth:1.5}},
  665. levels:[{itemStyle:{borderWidth:0,gapWidth:5}},
  666. {upperLabel:{show:true,height:32},itemStyle:{borderColor:PAPER,borderWidth:3,gapWidth:3}},
  667. {upperLabel:{show:false},itemStyle:{borderColor:PAPER,borderWidth:2,gapWidth:2}}]}]});
  668. };
  669. const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){draw();io.disconnect()}},{threshold:.25});
  670. io.observe(host);
  671. host.style.cursor='pointer';
  672. host.addEventListener('click',draw);
  673. addEventListener('resize',()=>chart?.resize());
  674. })();
  675. // ── New chart-family helpers (scoped to the added templates) ──
  676. (()=>{
  677. const INK='#1C1C1A',PAPER='#F0EFEB',MUTED='#8F8E88',GRID='#DEDDD6';
  678. const NS='http://www.w3.org/2000/svg';
  679. 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};
  680. const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
  681. const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
  682. const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
  683. // ════ F14 · rung histogram ════
  684. // 直方图的 Lupi Basics 皮:连续数值分箱,一箱一把梯子,1 档 = 1 张工单
  685. // (百分之一)。箱界写在梯脚之间——直方图的箱是区间不是类目,
  686. // 这是它和 F1 Rung Bars 的本质区别。峰值箱标数,中位数落点小旗标注。
  687. (()=>{
  688. const BIN=[6,14,22,19,13,9,6,4,3,2,1,1]; // 2h bins, sums to 100
  689. obsReveal('histo',s=>{
  690. const X0=44,PW=27.5,base=252,step=5.4,HW=10.5;
  691. BIN.forEach((v,i)=>{
  692. const x=X0+i*PW+PW/2;
  693. for(let k=0;k<v;k++){
  694. const y=base-k*step,w=HW-1.4+rnd(k+1,i+2)*2.8;
  695. el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:INK,'stroke-width':1,
  696. opacity:.55+rnd(k+2,i+4)*.45,class:'fade',style:`animation-delay:${i*.06+k*.012}s`});
  697. if(k%5===4)el(s,'circle',{cx:x+HW+3.5,cy:y,r:.7,fill:'#C6C5BF',
  698. class:'fade',style:`animation-delay:${i*.06+k*.012}s`});
  699. }
  700. });
  701. // peak label
  702. const pk=BIN.indexOf(Math.max(...BIN));
  703. const px=X0+pk*PW+PW/2;
  704. const num=txt(s,{x:px,y:base-(BIN[pk]-1)*step-10,'font-size':11,'font-weight':800,fill:INK,
  705. 'text-anchor':'middle',class:'fade',style:'animation-delay:.8s'},BIN[pk]);
  706. tip(num,`${BIN[pk]} of 100 tickets took ${pk*2}–${pk*2+2}h`);
  707. // bin edges: the continuous axis is the histogram's identity
  708. el(s,'line',{x1:X0-4,y1:base+4,x2:X0+12*PW+4,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
  709. for(let e=0;e<=12;e++){
  710. const x=X0+e*PW;
  711. el(s,'line',{x1:x,y1:base+4,x2:x,y2:base+(e%2===0?11:8),stroke:'#CFCEC7','stroke-width':.6,
  712. class:'fade',style:`animation-delay:${e*.02}s`});
  713. if(e%2===0)txt(s,{x,y:base+21,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
  714. class:'fade',style:`animation-delay:${e*.02}s`},e*2+'h');
  715. }
  716. // median flag: cumulative crosses 50 inside bin 2
  717. let acc=0,mb=0;for(let i=0;i<12;i++){acc+=BIN[i];if(acc>=50){mb=i;break}}
  718. const mx=X0+mb*PW+PW*.7;
  719. el(s,'line',{x1:mx,y1:base+4,x2:mx,y2:base-BIN[mb]*step-16,stroke:'#8F8E88','stroke-width':.9,
  720. 'stroke-dasharray':'2 4',class:'fade',style:'animation-delay:1s'});
  721. txt(s,{x:mx+4,y:base-BIN[mb]*step-20,'font-size':7.5,'font-weight':800,fill:'#6A6963',
  722. class:'fade',style:'animation-delay:1.1s'},'HALF RESOLVED BY HERE');
  723. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  724. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
  725. 'ONE RUNG = ONE TICKET IN A HUNDRED · BINS OF TWO HOURS · DOT MARKS EVERY FIFTH');
  726. });
  727. })();
  728. // ════ F15 · tick box ════
  729. // 箱线图的 Basics 皮:发丝须线 + 胶囊箱体(IQR),纸色横档 = 中位数,
  730. // 空心点 = 离群值。箱体明度按中位数快慢排:越快越黑。
  731. (()=>{
  732. const G=[['ENT',[.4,.9,1.5,2.6,4.4],[6.2]],
  733. ['PRO',[.8,2.1,3.3,5,7.8],[10.5]],
  734. ['STARTER',[1.5,3.8,6.1,8.9,13.2],[16.8]],
  735. ['FREE',[2.2,6,9.4,13.8,19.6],[22.1,23.5]]];
  736. const SHADE=[INK,'#4A4944','#8F8E88','#B0AFA9'];
  737. obsReveal('boxplot',s=>{
  738. const TOP=42,BASE=260,MAXH=24,mapY=v=>BASE-v/MAXH*(BASE-TOP);
  739. const colX=g=>78+g*84,BW=24;
  740. [0,6,12,18,24].forEach(h=>{
  741. el(s,'line',{x1:36,y1:mapY(h),x2:378,y2:mapY(h),stroke:'#E3E2DB','stroke-width':.8,
  742. class:'fade',style:`animation-delay:${h*.008}s`});
  743. txt(s,{x:30,y:mapY(h)+3,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'end',
  744. class:'fade',style:`animation-delay:${h*.008}s`},h+'h');
  745. });
  746. G.forEach(([name,[mn,q1,md,q3,mx],outs],g)=>{
  747. const x=colX(g);
  748. el(s,'line',{x1:x,y1:mapY(mn),x2:x,y2:mapY(mx),stroke:'#8F8E88','stroke-width':.8,
  749. pathLength:1,class:'draw',style:`animation-delay:${g*.12}s;animation-duration:.6s`});
  750. [mn,mx].forEach(vv=>el(s,'line',{x1:x-7,y1:mapY(vv),x2:x+7,y2:mapY(vv),
  751. stroke:'#8F8E88','stroke-width':1,class:'fade',style:`animation-delay:${.2+g*.12}s`}));
  752. const box=el(s,'rect',{x:x-BW/2,y:mapY(q3),width:BW,height:mapY(q1)-mapY(q3),rx:9,
  753. fill:SHADE[g],class:'pop',style:`animation-delay:${.15+g*.12}s`});
  754. tip(box,`${name} — half of tickets answered in ${q1}–${q3}h`);
  755. el(s,'line',{x1:x-BW/2+3,y1:mapY(md),x2:x+BW/2-3,y2:mapY(md),stroke:PAPER,'stroke-width':2.2,
  756. class:'fade',style:`animation-delay:${.5+g*.12}s`});
  757. txt(s,{x:x+BW/2+6,y:mapY(md)+3,'font-size':9.5,'font-weight':800,fill:INK,
  758. class:'fade',style:`animation-delay:${.6+g*.12}s`},md.toFixed(1)+'h');
  759. outs.forEach((o,k)=>{
  760. const d=el(s,'circle',{cx:x+(rnd(k+1,g+3)-.5)*8,cy:mapY(o),r:2.6,fill:PAPER,
  761. stroke:'#6A6963','stroke-width':1.1,class:'pop',style:`animation-delay:${.7+g*.12+k*.06}s`});
  762. tip(d,`${name} outlier — ${o}h`);
  763. });
  764. txt(s,{x,y:BASE+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
  765. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${g*.12}s`},name);
  766. });
  767. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  768. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
  769. 'BOX = MIDDLE HALF · PAPER TICK = MEDIAN · HOLLOW = OUTLIER · DARKEST = FASTEST');
  770. });
  771. })();
  772. // ════ F16 · stream ribbon ════
  773. // Streamgraph / ThemeRiver:多系列构成随时间此消彼长。轮廓对称于中轴
  774. // (silhouette 基线),带宽 = 该系列当周量,纸色缝分带(treemap 同法)。
  775. // 三带明度按当前份额排:现任霸主最黑。带名标在各自最宽处。
  776. (()=>{
  777. const N=48;
  778. const mk=(base,trend,w1,w2,seed)=>Array.from({length:N},(_,t)=>
  779. Math.max(2,base+trend*t+10*Math.sin(t/w1+seed)+5*Math.sin(t/w2+seed*2)+rnd(t+1,seed)*6));
  780. const SERIES=[
  781. ['LEGACY EDITOR',mk(46,-.62,9,3.7,2),'#C6C5BF'],
  782. ['BOARDS',mk(26,.10,11,4.2,5),'#8F8E88'],
  783. ['FLOWS',mk(12,.78,10,3.1,8),INK],
  784. ];
  785. obsReveal('stream',s=>{
  786. const X0=36,X1=744,CY=138,SC=1.15;
  787. const x=t=>X0+t/(N-1)*(X1-X0);
  788. const tot=Array.from({length:N},(_,t)=>SERIES.reduce((a,S)=>a+S[1][t],0));
  789. // stacked offsets, silhouette baseline: top of stack starts at -total/2
  790. const y0=Array.from({length:N},(_,t)=>CY-tot[t]*SC/2);
  791. const smooth=pts=>{
  792. let d=`M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
  793. for(let k=1;k<pts.length-1;k++){
  794. const p=pts[k],q=pts[k+1];
  795. d+=` Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${((p[0]+q[0])/2).toFixed(1)} ${((p[1]+q[1])/2).toFixed(1)}`;
  796. }
  797. return d+` L${pts[pts.length-1][0].toFixed(1)} ${pts[pts.length-1][1].toFixed(1)}`;
  798. };
  799. let run=y0.slice();
  800. SERIES.forEach(([name,vals,shade],si)=>{
  801. const top=run.map((v,t)=>[x(t),v]);
  802. const bot=run.map((v,t)=>[x(t),v+vals[t]*SC]);
  803. run=bot.map(p=>p[1]);
  804. const d=smooth(top)+' L '+[...bot].reverse().map(p=>`${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' L ')+' Z';
  805. const band=el(s,'path',{d,fill:shade,stroke:PAPER,'stroke-width':2,
  806. class:'fade',style:`animation-delay:${si*.16}s`});
  807. tip(band,`${name} — weekly active accounts, 48 weeks`);
  808. // label at the band's widest week — clamped off both ends so the
  809. // paper-colored text never spills past the band onto paper (invisible)
  810. let wk=4;vals.forEach((v,t)=>{if(t>=4&&t<=N-6&&v>vals[wk])wk=t});
  811. const midY=(top[wk][1]+bot[wk][1])/2;
  812. const dark=shade===INK||shade==='#4A4944';
  813. txt(s,{x:x(wk),y:midY+3,'font-size':9,'font-weight':800,'text-anchor':'middle',
  814. fill:dark?PAPER:'#4A4944','letter-spacing':'.06em',
  815. class:'fade',style:`animation-delay:${.5+si*.14}s`},name);
  816. });
  817. // month floor: barcode ticks + labels every 8 weeks
  818. const base=252;
  819. el(s,'line',{x1:X0-6,y1:base,x2:X1+6,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
  820. for(let t=0;t<N;t++)
  821. el(s,'line',{x1:x(t),y1:base,x2:x(t),y2:base-(t%8===0?7:4),stroke:'#CFCEC7','stroke-width':.6,
  822. class:'fade',style:`animation-delay:${t*.006}s`});
  823. ['JAN','MAR','MAY','JUL','SEP','NOV'].forEach((m,k)=>
  824. txt(s,{x:x(k*8),y:base+15,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'middle',
  825. 'letter-spacing':'.1em',class:'fade'},m));
  826. txt(s,{x:390,y:288,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  827. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
  828. 'BAND WIDTH = WEEKLY ACTIVES · DARKEST = TODAY’S LEADER · THE RIVER IS THE TOTAL');
  829. });
  830. })();
  831. // ════ F17 · candlestick ════
  832. // K 线的 Mono 化:不用红绿——实心墨 = 收跌,空心(纸底墨框)= 收涨,
  833. // 和哑铃图 hollow/ink 的语义一脉。影线是发丝,柱体胶囊圆角。
  834. // OHLC 四值都是真实记录,最高/最低两日标数。30 个交易日。
  835. (()=>{
  836. const N=30;
  837. // deterministic random walk with a dip-and-recover arc
  838. const DAYS=[];
  839. let px=52;
  840. for(let d=0;d<N;d++){
  841. const drift=d<10?.4:d<18?-1.5:1.3;
  842. const open=px;
  843. const close=Math.max(30,open+drift+(rnd(d+1,3)-.5)*4.6);
  844. const hi=Math.max(open,close)+rnd(d+2,7)*2.6;
  845. const lo=Math.min(open,close)-rnd(d+3,11)*2.6;
  846. DAYS.push([open,close,hi,lo]);
  847. px=close;
  848. }
  849. obsReveal('candle',s=>{
  850. const X0=40,PW=11.2,BASE=258,TOP=44;
  851. const vmin=Math.min(...DAYS.map(d=>d[3])),vmax=Math.max(...DAYS.map(d=>d[2]));
  852. const mapY=v=>BASE-(v-vmin)/(vmax-vmin)*(BASE-TOP);
  853. const x=d=>X0+d*PW+PW/2;
  854. // price grid
  855. for(let g=0;g<=4;g++){
  856. const v=vmin+g/4*(vmax-vmin);
  857. el(s,'line',{x1:X0-4,y1:mapY(v),x2:X0+N*PW+4,y2:mapY(v),stroke:'#E3E2DB','stroke-width':.8,
  858. class:'fade',style:`animation-delay:${g*.03}s`});
  859. txt(s,{x:X0-8,y:mapY(v)+3,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'end',
  860. class:'fade',style:`animation-delay:${g*.03}s`},'$'+Math.round(v));
  861. }
  862. let hiD=0,loD=0;
  863. DAYS.forEach(([o,c,h,l],d)=>{
  864. if(h>DAYS[hiD][2])hiD=d;
  865. if(l<DAYS[loD][3])loD=d;
  866. });
  867. DAYS.forEach(([o,c,h,l],d)=>{
  868. const cx=x(d),up=c>=o;
  869. const yT=mapY(Math.max(o,c)),yB=mapY(Math.min(o,c));
  870. // wick: one hairline, full reach
  871. el(s,'line',{x1:cx,y1:mapY(h),x2:cx,y2:mapY(l),stroke:'#6A6963','stroke-width':.7,
  872. class:'fade',style:`animation-delay:${d*.03}s`});
  873. // body: capsule; hollow = up, ink = down
  874. const body=el(s,'rect',{x:cx-3.4,y:yT,width:6.8,height:Math.max(2.5,yB-yT),rx:3,
  875. ...(up?{fill:PAPER,stroke:INK,'stroke-width':1.1}:{fill:INK}),
  876. class:'fade',style:`animation-delay:${.05+d*.03}s`});
  877. tip(body,`Day ${d+1} — open $${o.toFixed(1)} · close $${c.toFixed(1)} · high $${h.toFixed(1)} · low $${l.toFixed(1)}`);
  878. // extremes, labeled with hairline flags
  879. if(d===hiD)txt(s,{x:cx,y:mapY(h)-7,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
  880. style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
  881. class:'fade'},'$'+Math.round(h));
  882. if(d===loD)txt(s,{x:cx,y:mapY(l)+14,'font-size':8,'font-weight':800,fill:'#6A6963','text-anchor':'middle',
  883. style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
  884. class:'fade'},'$'+Math.round(l));
  885. });
  886. // week ticks on the floor
  887. el(s,'line',{x1:X0-4,y1:BASE+8,x2:X0+N*PW+4,y2:BASE+8,stroke:GRID,'stroke-width':.8,class:'fade'});
  888. for(let d=0;d<N;d++)
  889. el(s,'line',{x1:x(d),y1:BASE+8,x2:x(d),y2:BASE+8-(d%5===0?6:3),stroke:'#CFCEC7','stroke-width':.6,
  890. class:'fade',style:`animation-delay:${d*.008}s`});
  891. ['W1','W2','W3','W4','W5','W6'].forEach((w,k)=>
  892. txt(s,{x:x(k*5),y:BASE+21,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
  893. 'letter-spacing':'.08em',class:'fade'},w));
  894. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  895. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
  896. 'INK BODY = CLOSED DOWN · HOLLOW = CLOSED UP · WICK = THE DAY’S FULL REACH');
  897. });
  898. })();
  899. })();
  900. </script>
  901. </body>
  902. </html>