lupi-gallery.html 60 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220
  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 — Lupi Gallery · 20 张参考实现</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. <style>
  10. :root{--bg:#F0EFEB;--dark:#1C1C1A;--ink:#1C1C1A;--muted:#8F8E88;--faint:#C6C5BF;--grid:#DEDDD6}
  11. *{margin:0;padding:0;box-sizing:border-box}
  12. body{background:var(--bg);font-family:'Inter',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
  13. .grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1400px;margin:0 auto}
  14. .card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
  15. .card.dark{background:var(--dark);color:#F0EFEB}
  16. .card.dark .sub{color:#8F8E88}
  17. .card.dark .src{color:#55554F}
  18. .card.wide{grid-column:1/-1}
  19. .split{display:grid;grid-template-columns:250px 1fr;gap:30px;align-items:start}
  20. h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
  21. .sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
  22. .src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
  23. .note{font-size:11px;color:#6A6963;line-height:1.65;margin-bottom:16px}
  24. .legend{font-size:9px;letter-spacing:.1em;font-weight:600;color:var(--muted);line-height:2}
  25. svg{width:100%;max-height:330px;display:block;margin:0 auto}
  26. svg text{font-family:'Inter',sans-serif}
  27. .pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
  28. @keyframes pop{from{transform:scale(0)}to{transform:none}}
  29. .fade{animation:fade .9s ease both}
  30. @keyframes fade{from{opacity:0}}
  31. .draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
  32. @keyframes draw{to{stroke-dashoffset:0}}
  33. @media (prefers-reduced-motion:reduce){
  34. .pop,.fade{animation:none}
  35. .draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
  36. }
  37. .pagehead{max-width:1400px;margin:0 auto 26px;padding:0 4px}
  38. .pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
  39. .pagehead p{font-size:12px;color:#8F8E88;margin-top:5px;line-height:1.7}
  40. .pagehead .badge{margin:0 2px}
  41. .badge{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.08em;
  42. padding:3px 10px;border-radius:99px;margin-bottom:10px}
  43. .badge.del{background:#1C1C1A;color:#F0EFEB}
  44. .badge.pick{border:1.5px solid #1C1C1A;color:#1C1C1A}
  45. .badge.pair{border:1px dashed #B0AFA9;color:#8F8E88}
  46. .card.dark .badge.del{background:#F0EFEB;color:#1C1C1A}
  47. .card.dark .badge.pick{border-color:#F0EFEB;color:#F0EFEB}
  48. .card.dark .badge.pair{border-color:#55554F;color:#8F8E88}
  49. </style>
  50. </head>
  51. <body>
  52. <div class="pagehead">
  53. <h1>Lupi 系(细读)· 20 张参考实现</h1>
  54. <p>发丝线 · 逐记录 · 30 秒阅读。</p>
  55. </div>
  56. <div class="grid2">
  57. <!-- 1 · launch fan -->
  58. <div class="card">
  59. <h2>Twelve features, fanned out</h2>
  60. <div class="sub">one spoke per feature · big dot = launch week, sized by MAU today · small dots = every week since</div>
  61. <svg id="fan" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  62. <div class="src">LAUNCH FAN · MONO-EDITORIAL · PRODUCT ANALYTICS</div>
  63. </div>
  64. <!-- 4 · dot cascade on dark -->
  65. <div class="card dark">
  66. <h2>What breaks, stacked and ranked</h2>
  67. <div class="sub">incidents by root cause · 2019 → 2026 · one dot = 2 incidents · sorted ascending</div>
  68. <svg id="cascade" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  69. <div class="src">DOT CASCADE · MONO-EDITORIAL · INCIDENT LOG</div>
  70. </div>
  71. <!-- 2 · barcode lollipop, wide + editorial text column -->
  72. <div class="card wide">
  73. <div class="split">
  74. <div>
  75. <h2>Ninety days as a barcode</h2>
  76. <div class="sub">peak concurrent users, daily · Apr – Jun 2026</div>
  77. <div class="note">Every hairline is a day, whether or not anything
  78. happened in it. The dot marks the day's peak; the stem below it is
  79. just gravity. Read the field, not the numbers — the season has a
  80. texture before it has a value.</div>
  81. <div class="legend">
  82. ● &nbsp;WEEKDAY PEAK<br>
  83. ○ &nbsp;WEEKEND PEAK<br>
  84. │ &nbsp;ONE CALENDAR DAY<br>
  85. ◉ &nbsp;TOP-3, LABELED
  86. </div>
  87. </div>
  88. <div>
  89. <svg id="barcode" viewBox="0 0 800 300" preserveAspectRatio="xMidYMid meet" style="max-height:340px"></svg>
  90. <div class="src">BARCODE LOLLIPOP · MONO-EDITORIAL · LIVE OPS</div>
  91. </div>
  92. </div>
  93. </div>
  94. <!-- 3 · arc bubble matrix -->
  95. <div class="card">
  96. <h2>Eight products land in twelve cities</h2>
  97. <div class="sub">bubble = paying accounts · each row bows like a horizon</div>
  98. <svg id="arcmatrix" viewBox="0 0 430 320" preserveAspectRatio="xMidYMid meet"></svg>
  99. <div class="src">ARC MATRIX · MONO-EDITORIAL · SALES GEO</div>
  100. </div>
  101. <!-- 5 · radial convergence -->
  102. <div class="card">
  103. <h2>48 requests pull toward five themes</h2>
  104. <div class="sub">every rim node = a feature request · lines bundle into the theme that claimed it</div>
  105. <svg id="converge" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  106. <div class="src">RADIAL CONVERGENCE · MONO-EDITORIAL · FEEDBACK BOARD</div>
  107. </div>
  108. <!-- 6 · cluster field, wide -->
  109. <div class="card wide">
  110. <h2>The contributor field</h2>
  111. <div class="sub">80 core contributors orbit the main repo · satellite islands keep their own gravity · dotted paths = cross-contribution</div>
  112. <svg id="clusters" viewBox="0 0 840 320" preserveAspectRatio="xMidYMid meet" style="max-height:360px"></svg>
  113. <div class="src">CLUSTER FIELD · MONO-EDITORIAL · CONTRIBUTOR GRAPH</div>
  114. </div>
  115. <!-- 1 · brand spectrum -->
  116. <div class="card">
  117. <h2>Where the brand sits</h2>
  118. <div class="sub">panel of 120 customers placed us between opposing traits · big dot = us · small dots = competitors</div>
  119. <svg id="spectrum" viewBox="0 0 460 300" preserveAspectRatio="xMidYMid meet"></svg>
  120. <div class="src">BRAND SPECTRUM · MONO-EDITORIAL2 · BRAND SURVEY</div>
  121. </div>
  122. <!-- 2 · dotty matrix -->
  123. <div class="card">
  124. <h2>Four squads, stacked in space</h2>
  125. <div class="sub">each plane = one squad · 6 × 6 weeks of activity · dot area = tasks closed</div>
  126. <svg id="dotty" viewBox="0 0 400 372" preserveAspectRatio="xMidYMid meet" style="max-height:340px"></svg>
  127. <div class="src">DOTTY MATRIX · MONO-EDITORIAL2 · SPRINT BOARD</div>
  128. </div>
  129. <!-- 3 · ticket almanac, wide -->
  130. <div class="card wide">
  131. <h2>Eight years of tickets, one almanac</h2>
  132. <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>
  133. <svg id="almanac" viewBox="0 0 840 400" preserveAspectRatio="xMidYMid meet" style="max-height:420px"></svg>
  134. <div class="src">BUBBLE ALMANAC · MONO-EDITORIAL2 · SUPPORT DESK</div>
  135. </div>
  136. <!-- 4 · deploy patchwork -->
  137. <div class="card">
  138. <h2>A quarter of deploys, overlaid</h2>
  139. <div class="sub">each wedge = one deploy · angle = time of day · reach = files touched · ink builds where deploys pile up</div>
  140. <svg id="patchwork" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  141. <div class="src">RADIAL PATCHWORK · MONO-EDITORIAL2 · DEPLOY LOG</div>
  142. </div>
  143. <!-- 6 · trend lineage -->
  144. <div class="card">
  145. <h2>Features rise, fall, come back</h2>
  146. <div class="sub">● shipped · ○ reworked · dashed = dormant years · line runs on if it's still alive today</div>
  147. <svg id="lineage" viewBox="0 0 400 340" preserveAspectRatio="xMidYMid meet"></svg>
  148. <div class="src">TREND LINEAGE · MONO-EDITORIAL2 · PRODUCT HISTORY</div>
  149. </div>
  150. <!-- 8 · type colonnade -->
  151. <div class="card">
  152. <h2>Forty-four repos, ten owners</h2>
  153. <div class="sub">every hairline hands a repo to the team that owns it · the pile-ups are the org chart nobody drew</div>
  154. <svg id="colonnade" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  155. <div class="src">TYPE COLONNADE · MONO-EDITORIAL2 · CODE OWNERSHIP</div>
  156. </div>
  157. <!-- 9 · funnel hourglass -->
  158. <div class="card">
  159. <h2>The funnel, poured</h2>
  160. <div class="sub">each strip's width = people at that stage · threads trickle down · Q2 2026 cohort</div>
  161. <svg id="hourglass" viewBox="0 0 400 330" preserveAspectRatio="xMidYMid meet"></svg>
  162. <div class="src">HOURGLASS STREAM · MONO-EDITORIAL2 · GROWTH</div>
  163. </div>
  164. <!-- 14 · hundred field -->
  165. <div class="card">
  166. <span class="badge pair">小数据 · 单位分解</span>
  167. <h2>A hundred of us, four minds</h2>
  168. <div class="sub">one dot = one person in a hundred · four attitude clusters, sized by share · 2026 survey</div>
  169. <svg id="hundredfield" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  170. <div class="src">HUNDRED FIELD · MONO-SMALL · ATTITUDE SURVEY</div>
  171. </div>
  172. <!-- 15 · ballot tally -->
  173. <div class="card">
  174. <span class="badge pair">小数据 · 单位分解</span>
  175. <h2>What they fear, tick by tick</h2>
  176. <div class="sub">one tick = one respondent in a hundred · inked = picked it · they could pick several</div>
  177. <svg id="ballottally" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  178. <div class="src">BALLOT TALLY · MONO-SMALL · WORKPLACE SURVEY</div>
  179. </div>
  180. <!-- L16 · matrix heat -->
  181. <div class="card">
  182. <span class="badge">LUPI 编辑型 · 矩阵热力</span>
  183. <h2>Which features get used together</h2>
  184. <div class="sub">8 × 8 co-usage · shade = share of accounts using both · dashed square = strongest pair</div>
  185. <svg id="matheat" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  186. <div class="src">MATRIX HEAT · MONO-EDITORIAL · PRODUCT ANALYTICS</div>
  187. </div>
  188. <!-- L17 · calendar heat -->
  189. <div class="card wide">
  190. <span class="badge">LUPI 编辑型 · 日历热力</span>
  191. <h2>A year of deploys, day by day</h2>
  192. <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>
  193. <svg id="calheat" viewBox="0 0 840 232" preserveAspectRatio="xMidYMid meet" style="max-height:260px"></svg>
  194. <div class="src">CALENDAR HEAT · MONO-EDITORIAL · DEPLOY LOG</div>
  195. </div>
  196. <!-- L18 · beeswarm -->
  197. <div class="card">
  198. <span class="badge">LUPI 编辑型 · 蜂群</span>
  199. <h2>A hundred and twenty deals, swarming</h2>
  200. <div class="sub">one dot = one closed deal · the swarm piles up where deals cluster · hollow = enterprise · dashed = median</div>
  201. <svg id="beeswarm" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  202. <div class="src">BEESWARM · MONO-EDITORIAL · SALES OPS</div>
  203. </div>
  204. <!-- L19 · ridgeline -->
  205. <div class="card">
  206. <span class="badge">LUPI 编辑型 · RIDGELINE</span>
  207. <h2>Five pipelines, five tempos</h2>
  208. <div class="sub">each crest = one pipeline's completion-time shape · the area is made of hairlines · dot = the peak</div>
  209. <svg id="ridge" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  210. <div class="src">RIDGELINE · MONO-EDITORIAL · DATA PLATFORM</div>
  211. </div>
  212. <!-- L20 · parallel coordinates -->
  213. <div class="card">
  214. <span class="badge">LUPI 编辑型 · 平行坐标</span>
  215. <h2>Twelve products, four dimensions</h2>
  216. <div class="sub">one hairline = one product across price · satisfaction · retention · growth · ink = the one that wins everywhere</div>
  217. <svg id="parallel" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
  218. <div class="src">PARALLEL COORDS · MONO-EDITORIAL · PRODUCT PANEL</div>
  219. </div>
  220. </div>
  221. <script>
  222. // ── shared Mono tokens ──
  223. const INK='#1C1C1A',PAPER='#F0EFEB',MUTED='#8F8E88',GRID='#DEDDD6';
  224. const NS='http://www.w3.org/2000/svg';
  225. 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};
  226. const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
  227. const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
  228. const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000; // deterministic
  229. const D2R=Math.PI/180;
  230. const pol=(cx,cy,r,deg)=>[cx+r*Math.cos(deg*D2R),cy+r*Math.sin(deg*D2R)];
  231. const sect=(cx,cy,r0,r1,a0,a1)=>{
  232. const big=a1-a0>180?1:0;
  233. const [xa,ya]=pol(cx,cy,r1,a0),[xb,yb]=pol(cx,cy,r1,a1);
  234. const [xc,yc]=pol(cx,cy,r0,a1),[xd,yd]=pol(cx,cy,r0,a0);
  235. return `M${xa} ${ya} A${r1} ${r1} 0 ${big} 1 ${xb} ${yb} L${xc} ${yc} A${r0} ${r0} 0 ${big} 0 ${xd} ${yd} Z`;
  236. };
  237. // unified reveal: draw when scrolled into view; click replays
  238. const obsReveal=(id,fn)=>{
  239. const n=document.getElementById(id);
  240. const go=()=>{n.innerHTML='';fn(n)};
  241. const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
  242. io.observe(n);
  243. n.style.cursor='pointer';
  244. n.addEventListener('click',go);
  245. };
  246. // ════ 1 · launch fan ════
  247. // spokes sorted by launch week → the big dots cascade along a diagonal arc
  248. (()=>{
  249. const FEAT=['Editor','Boards','Docs','Chat','Flows','Vault','Pages','Sync','Grid','Views','Hub','Forms'];
  250. const LAUNCH=[1,3,4,6,8,9,11,12,14,16,17,19]; // week of launch
  251. const CX=40,CY=304,rOf=w=>56+w*9.7;
  252. obsReveal('fan',s=>{
  253. const ang=i=>-84+i*(76/11);
  254. // dotted week guides
  255. [5,10,15,20].forEach((w,gi)=>{
  256. const r=rOf(w),[x1,y1]=pol(CX,CY,r,-84),[x2,y2]=pol(CX,CY,r,-8);
  257. el(s,'path',{d:`M${x1} ${y1} A${r} ${r} 0 0 1 ${x2} ${y2}`,fill:'none',
  258. stroke:'#D8D6CE','stroke-width':1,'stroke-dasharray':'2 5',
  259. class:'fade',style:`animation-delay:${gi*.1}s`});
  260. const [lx,ly]=pol(CX,CY,r,-87.5);
  261. txt(s,{x:lx,y:ly,'font-size':6.5,fill:'#B0AFA9','text-anchor':'middle',
  262. class:'fade',style:`animation-delay:${gi*.1}s`},'W'+w);
  263. });
  264. FEAT.forEach((name,i)=>{
  265. const a=ang(i),L=LAUNCH[i];
  266. const mau=Math.round(48-i*3+rnd(i+1,3)*10);
  267. // hairline spoke from launch to rim
  268. const [sx,sy]=pol(CX,CY,rOf(L),a),[ex,ey]=pol(CX,CY,rOf(20),a);
  269. el(s,'line',{x1:sx,y1:sy,x2:ex,y2:ey,stroke:'#E3E2DB','stroke-width':.7,
  270. class:'fade',style:`animation-delay:${.15+i*.05}s`});
  271. // weekly texture dots
  272. for(let w=L+1;w<=20;w++){
  273. const [x,y]=pol(CX,CY,rOf(w),a);
  274. el(s,'circle',{cx:x,cy:y,r:1.1+rnd(i+1,w)*1.2,fill:'#B0AFA9',opacity:.8,
  275. class:'pop',style:`animation-delay:${.2+i*.06+(w-L)*.015}s`});
  276. }
  277. // launch dot, sized by MAU
  278. const [bx,by]=pol(CX,CY,rOf(L),a);
  279. const big=el(s,'circle',{cx:bx,cy:by,r:Math.sqrt(mau)*1.15,fill:INK,
  280. class:'pop',style:`animation-delay:${.15+i*.07}s`});
  281. tip(big,`${name} — launched W${L} · ${mau}k MAU`);
  282. // rim label along the spoke
  283. const [tx,ty]=pol(CX,CY,rOf(20)+9,a);
  284. txt(s,{x:tx,y:ty,'font-size':7,'font-weight':600,fill:'#6A6963',
  285. transform:`rotate(${a} ${tx} ${ty})`,'dominant-baseline':'middle',
  286. class:'fade',style:`animation-delay:${.3+i*.05}s`},name);
  287. });
  288. });
  289. })();
  290. // ════ 4 · dot cascade on dark ════
  291. (()=>{
  292. const CAUSE=['DNS','CDN','QUOTA','DISK','CERT','CACHE','QUEUE','LOCK','OOM','NET','3P API','CONFIG','DB','DEPLOY','CODE','HUMAN'];
  293. const COUNT=[1,1,2,3,3,4,5,6,7,9,11,13,15,18,22,27];
  294. obsReveal('cascade',s=>{
  295. const x=i=>34+i*22.2, base=i=>272-i*7.5;
  296. // dotted diagonal baseline
  297. el(s,'line',{x1:x(0)-8,y1:base(0)+4,x2:x(15)+8,y2:base(15)+4,
  298. stroke:'#4A4944','stroke-width':1,'stroke-dasharray':'2 4',class:'fade'});
  299. CAUSE.forEach((name,i)=>{
  300. const n=Math.ceil(COUNT[i]/2),bx=x(i),by=base(i);
  301. for(let k=0;k<n-1;k++)
  302. el(s,'circle',{cx:bx,cy:by-12-k*8.8,r:2.2,fill:'#B3B0A4',opacity:.9,
  303. class:'pop',style:`animation-delay:${i*.05+k*.03}s`});
  304. const topY=by-12-(n-1)*8.8;
  305. const top=el(s,'circle',{cx:bx,cy:topY,r:4.6,fill:PAPER,
  306. class:'pop',style:`animation-delay:${i*.05+n*.03}s`});
  307. tip(top,`${name} — ${COUNT[i]} incidents`);
  308. txt(s,{x:bx,y:topY-10,'font-size':9,'font-weight':700,fill:PAPER,'text-anchor':'middle',
  309. class:'fade',style:`animation-delay:${.2+i*.05}s`},COUNT[i]);
  310. txt(s,{x:bx,y:by+12,'font-size':6.5,'font-weight':600,fill:'#6A6963','text-anchor':'end',
  311. transform:`rotate(-90 ${bx} ${by+12})`,
  312. class:'fade',style:`animation-delay:${.1+i*.05}s`},name);
  313. });
  314. });
  315. })();
  316. // ════ 2 · barcode lollipop ════
  317. (()=>{
  318. const N=90;
  319. const val=d=>95+55*Math.sin(d/9.5)+30*Math.sin(d/3.7)+rnd(d+1,5)*40;
  320. obsReveal('barcode',s=>{
  321. // the barcode: one hairline per day, full height
  322. for(let d=0;d<N;d++){
  323. const x=18+d*8.53;
  324. el(s,'line',{x1:x,y1:8,x2:x,y2:258,stroke:GRID,'stroke-width':.7,
  325. class:'fade',style:`animation-delay:${d*.004}s`});
  326. }
  327. const vs=Array.from({length:N},(_,d)=>val(d));
  328. // top-3 peaks, kept at least 6 days apart so labels never collide
  329. const top3=[];
  330. for(const d of [...vs.keys()].sort((a,b)=>vs[b]-vs[a])){
  331. if(top3.every(t=>Math.abs(t-d)>=6))top3.push(d);
  332. if(top3.length===3)break;
  333. }
  334. for(let d=0;d<N;d++){
  335. const x=18+d*8.53,v=vs[d],y=252-v*.75;
  336. const weekend=d%7===5||d%7===6;
  337. const stemEnd=Math.min(256,y+14+rnd(d+1,9)*26);
  338. el(s,'line',{x1:x,y1:y,x2:x,y2:stemEnd,stroke:INK,'stroke-width':1.1,
  339. class:'fade',style:`animation-delay:${.3+d*.008}s`});
  340. const big=top3.includes(d);
  341. const dot=el(s,'circle',{cx:x,cy:y,r:big?4.6:2.7,
  342. fill:weekend?PAPER:INK,stroke:INK,'stroke-width':weekend?1.2:0,
  343. class:'pop',style:`animation-delay:${.3+d*.008}s`});
  344. tip(dot,`Day ${d+1} — ${Math.round(v)}k peak`);
  345. if(big)txt(s,{x,y:y-10,'font-size':9,'font-weight':800,fill:INK,'text-anchor':'middle',
  346. class:'fade',style:`animation-delay:${.9+d*.004}s`},Math.round(v)+'k');
  347. }
  348. [[0,'APR'],[30,'MAY'],[61,'JUN']].forEach(([d,m])=>
  349. txt(s,{x:18+d*8.53,y:276,'font-size':8,'font-weight':600,fill:MUTED,
  350. 'letter-spacing':'.12em',class:'fade'},m));
  351. });
  352. })();
  353. // ════ 3 · arc bubble matrix ════
  354. (()=>{
  355. const PROD=['Editor','Boards','Docs','Flows','Chat','Vault','Pages','Sync'];
  356. const CITY=['SF','NYC','LON','BER','TOK','SYD','SIN','PAR','AMS','TOR','SEO','SAO'];
  357. const W=[9,8,7,7,6,5,5,4,4,3,3,2]; // market weight
  358. obsReveal('arcmatrix',s=>{
  359. const rowY=i=>74+i*29, colX=j=>92+j*27, dy=j=>-16*Math.sin(Math.PI*j/11);
  360. const v=(i,j)=>{
  361. if(rnd(i*13+1,j*7+3)<.09)return 0; // a few true absences
  362. const age=1-i*.085; // older rows reach further
  363. return Math.min(40,Math.round(W[j]*3.4*age*(.45+rnd(i+1,j+1)*.85)));
  364. };
  365. // find top-4 cells for labels
  366. const all=[];
  367. PROD.forEach((_,i)=>CITY.forEach((_,j)=>all.push([v(i,j),i,j])));
  368. const top=all.sort((a,b)=>b[0]-a[0]).slice(0,4).map(d=>d[1]*100+d[2]);
  369. PROD.forEach((p,i)=>{
  370. // the horizon arc
  371. const d='M'+CITY.map((_,j)=>`${colX(j)} ${rowY(i)+dy(j)}`).join(' L ');
  372. el(s,'path',{d,fill:'none',stroke:'#E3E2DB','stroke-width':1,pathLength:1,
  373. class:'draw',style:`animation-delay:${i*.08}s`});
  374. txt(s,{x:84,y:rowY(i)+3,'font-size':8,'font-weight':600,fill:'#6A6963','text-anchor':'end',
  375. class:'fade',style:`animation-delay:${i*.08}s`},p);
  376. CITY.forEach((c,j)=>{
  377. const x=colX(j),y=rowY(i)+dy(j),vv=v(i,j);
  378. if(!vv){el(s,'circle',{cx:x,cy:y,r:.9,fill:'#D8D6CE',
  379. class:'pop',style:`animation-delay:${.2+i*.08+j*.02}s`});return}
  380. const fill=vv>=25?INK:vv>=12?'#6A6963':'#B0AFA9';
  381. const dot=el(s,'circle',{cx:x,cy:y,r:Math.sqrt(vv)*1.3,fill,
  382. class:'pop',style:`animation-delay:${.2+i*.08+j*.02}s`});
  383. tip(dot,`${p} · ${c} — ${vv} accounts`);
  384. if(top.includes(i*100+j))
  385. txt(s,{x,y:y-Math.sqrt(vv)*1.3-4,'font-size':7,'font-weight':800,fill:INK,
  386. 'text-anchor':'middle',class:'fade',style:`animation-delay:${.6}s`},vv);
  387. });
  388. });
  389. CITY.forEach((c,j)=>{
  390. const x=colX(j),y=74+dy(j)-24;
  391. txt(s,{x,y,'font-size':7,'font-weight':700,fill:MUTED,'letter-spacing':'.08em',
  392. transform:`rotate(-55 ${x} ${y})`,class:'fade',style:`animation-delay:${j*.03}s`},c);
  393. });
  394. });
  395. })();
  396. // ════ 5 · radial convergence ════
  397. (()=>{
  398. const N=48,CX=200,CY=162,R=116;
  399. const THEME=[['PERF',15,-75],['INTEGRATIONS',10,-3],['PRICING',9,69],['MOBILE',8,141],['UX',6,213]];
  400. obsReveal('converge',s=>{
  401. // hub assignment: contiguous blocks with a little organic leakage
  402. const blocks=THEME.flatMap(([,n],h)=>Array(n).fill(h));
  403. const hubOf=i=>rnd(i+1,11)>.92?(blocks[i]+2)%5:blocks[i];
  404. const hubPt=h=>pol(CX,CY,34,THEME[h][2]);
  405. const counts=[0,0,0,0,0];
  406. for(let i=0;i<N;i++)counts[hubOf(i)]++;
  407. for(let i=0;i<N;i++){
  408. const deg=i/N*360-90,[px,py]=pol(CX,CY,R,deg);
  409. const h=hubOf(i),[hx,hy]=hubPt(h);
  410. const c1x=CX+(px-CX)*.42,c1y=CY+(py-CY)*.42;
  411. const c2x=CX+(hx-CX)*.3,c2y=CY+(hy-CY)*.3;
  412. el(s,'path',{d:`M${px} ${py} C${c1x} ${c1y} ${c2x} ${c2y} ${hx} ${hy}`,
  413. fill:'none',stroke:'#A8A7A0','stroke-width':.7,opacity:.55,pathLength:1,
  414. class:'draw',style:`animation-delay:${i*.018}s`});
  415. el(s,'circle',{cx:px,cy:py,r:1.6,fill:'#6A6963',
  416. class:'pop',style:`animation-delay:${i*.018}s`});
  417. // tiny rim code, flipped on the left half so it stays readable
  418. const flip=deg>90&&deg<270,[lx,ly]=pol(CX,CY,R+7,deg);
  419. txt(s,{x:lx,y:ly,'font-size':5.5,fill:MUTED,
  420. 'text-anchor':flip?'end':'start','dominant-baseline':'middle',
  421. transform:`rotate(${flip?deg+180:deg} ${lx} ${ly})`,
  422. class:'fade',style:`animation-delay:${.2+i*.01}s`},'R-'+String(i+1).padStart(2,'0'));
  423. }
  424. THEME.forEach(([name,,deg],h)=>{
  425. const [hx,hy]=hubPt(h);
  426. const hub=el(s,'circle',{cx:hx,cy:hy,r:Math.sqrt(counts[h])*1.55,fill:INK,
  427. class:'pop',style:`animation-delay:${.5+h*.08}s`});
  428. tip(hub,`${name} — ${counts[h]} requests`);
  429. // theme label parked outside the rim, past the code ring
  430. const [tx,ty]=pol(CX,CY,R+34,deg);
  431. txt(s,{x:tx,y:ty,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
  432. 'dominant-baseline':'middle','letter-spacing':'.08em',
  433. style:`animation-delay:${.6+h*.08}s`,
  434. class:'fade'},`${name} · ${counts[h]}`);
  435. // hairline tying the label to its hub, skimming past the rim
  436. const [gx,gy]=pol(CX,CY,R+22,deg);
  437. el(s,'line',{x1:hx,y1:hy,x2:gx,y2:gy,stroke:'#C6C5BF','stroke-width':.7,
  438. 'stroke-dasharray':'1 3',class:'fade',style:`animation-delay:${.7+h*.08}s`});
  439. });
  440. });
  441. })();
  442. // ════ 6 · cluster field ════
  443. (()=>{
  444. const SAT=[[622,78,'PLUGINS',22],[706,214,'THEMES',16],[540,268,'DOCS',13],[92,72,'FORKS',12],[774,58,'MIRRORS',9]];
  445. obsReveal('clusters',s=>{
  446. const HX=300,HY=172;
  447. // dotted cross-contribution paths, hub → each island
  448. SAT.forEach(([x,y],ci)=>{
  449. const mx=(HX+x)/2,my=(HY+y)/2-30;
  450. el(s,'path',{d:`M${HX} ${HY} Q${mx} ${my} ${x} ${y}`,fill:'none',
  451. stroke:GRID,'stroke-width':.9,'stroke-dasharray':'2 4',pathLength:1,
  452. class:'draw',style:`animation-delay:${.8+ci*.12}s;animation-duration:.7s`});
  453. });
  454. // main hub: 80 golden-angle spokes
  455. for(let i=0;i<80;i++){
  456. const a=i*137.508,len=28+rnd(i+1,7)*88;
  457. const [x,y]=pol(HX,HY,len,a);
  458. el(s,'line',{x1:HX,y1:HY,x2:x,y2:y,stroke:'#C6C5BF','stroke-width':.6,pathLength:1,
  459. class:'draw',style:`animation-delay:${i*.012}s;animation-duration:.5s`});
  460. const dot=el(s,'circle',{cx:x,cy:y,r:1.6+rnd(i+1,4)*1.8,fill:INK,opacity:.85,
  461. class:'pop',style:`animation-delay:${.15+i*.012}s`});
  462. tip(dot,`core contributor #${i+1}`);
  463. }
  464. el(s,'circle',{cx:HX,cy:HY,r:6.5,fill:INK,class:'pop'});
  465. txt(s,{x:HX,y:HY+140,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
  466. 'letter-spacing':'.1em',style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px`,
  467. class:'fade'},'CORE · 80');
  468. // satellite islands
  469. SAT.forEach(([x,y,name,n],ci)=>{
  470. el(s,'circle',{cx:x,cy:y,r:3.5,fill:'#4A4944',
  471. class:'pop',style:`animation-delay:${.9+ci*.12}s`});
  472. for(let k=0;k<n;k++){
  473. const a=k*137.508+ci*40,rr=9+rnd(k+1,ci+2)*26;
  474. const [dx,dy]=pol(x,y,rr,a);
  475. if(k%2===0)el(s,'line',{x1:x,y1:y,x2:dx,y2:dy,stroke:'#D8D6CE','stroke-width':.5,
  476. class:'fade',style:`animation-delay:${1+ci*.12+k*.02}s`});
  477. el(s,'circle',{cx:dx,cy:dy,r:1.4+rnd(k+3,ci+5)*1.4,fill:'#6A6963',
  478. class:'pop',style:`animation-delay:${1+ci*.12+k*.02}s`});
  479. }
  480. txt(s,{x,y:y+44,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
  481. 'letter-spacing':'.12em',style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px`,
  482. class:'fade'},`${name} · ${n}`);
  483. });
  484. });
  485. })();
  486. // ════ 1 · brand spectrum ════
  487. // paired opposites; the pale ribbon threads our position down the page
  488. (()=>{
  489. const ROWS=[['FRIEND','AUTHORITY'],['SERIOUS','PLAYFUL'],['RELIABLE','RISK-TAKING'],['CONTEMPORARY','CLASSIC']];
  490. const US=[.74,.80,.40,.22]; // 0 = left trait, 1 = right trait
  491. const COMP=[[.42,.52,.57],[.60,.65,.86],[.30,.34,.50],[.36,.41,.73]];
  492. const X0=118,X1=342,Y=i=>58+i*60,px=t=>X0+t*(X1-X0);
  493. obsReveal('spectrum',s=>{
  494. // the ribbon first, underneath everything
  495. let d=`M${px(US[0])} ${Y(0)}`;
  496. for(let i=1;i<4;i++)
  497. d+=` C${px(US[i-1])} ${Y(i-1)+30} ${px(US[i])} ${Y(i)-30} ${px(US[i])} ${Y(i)}`;
  498. el(s,'path',{d,fill:'none',stroke:'#F0EFEB','stroke-width':30,'stroke-linecap':'round',
  499. 'stroke-linejoin':'round',opacity:.9,pathLength:1,class:'draw',
  500. style:'animation-duration:1.4s'});
  501. ROWS.forEach(([a,b],i)=>{
  502. const y=Y(i);
  503. el(s,'line',{x1:X0,y1:y,x2:X1,y2:y,stroke:'#B0AFA9','stroke-width':1,
  504. class:'fade',style:`animation-delay:${i*.08}s`});
  505. [X0,X1].forEach(x=>el(s,'line',{x1:x,y1:y-4,x2:x,y2:y+4,stroke:'#B0AFA9','stroke-width':1,
  506. class:'fade',style:`animation-delay:${i*.08}s`}));
  507. txt(s,{x:X0-14,y:y+3,'font-size':8,'font-weight':600,fill:'#6A6963','text-anchor':'end',
  508. 'letter-spacing':'.1em',class:'fade',style:`animation-delay:${i*.08}s`},a);
  509. txt(s,{x:X1+14,y:y+3,'font-size':8,'font-weight':600,fill:'#6A6963',
  510. 'letter-spacing':'.1em',class:'fade',style:`animation-delay:${i*.08}s`},b);
  511. COMP[i].forEach((t,k)=>{
  512. const c=el(s,'circle',{cx:px(t),cy:y,r:3.2,fill:'#8F8E88',
  513. class:'pop',style:`animation-delay:${.3+i*.1+k*.05}s`});
  514. tip(c,`competitor ${'ABC'[k]} — ${Math.round(t*100)}% toward ${b}`);
  515. });
  516. const us=el(s,'circle',{cx:px(US[i]),cy:y,r:8,fill:INK,
  517. class:'pop',style:`animation-delay:${.5+i*.1}s`});
  518. tip(us,`us — ${Math.round(US[i]*100)}% toward ${b}`);
  519. });
  520. txt(s,{x:X0,y:284,'font-size':7.5,'font-weight':600,fill:'#B0AFA9','letter-spacing':'.12em',
  521. class:'fade',style:'animation-delay:.9s'},'● US ● COMPETITORS A · B · C');
  522. });
  523. })();
  524. // ════ 2 · dotty matrix — isometric stacked dot planes ════
  525. // pitch 20/10, dot radius capped well under half-pitch, decks 64px apart:
  526. // each plane reads as its own quiet grid instead of a smudge
  527. (()=>{
  528. const SQUAD=['PLATFORM','GROWTH','MOBILE','INFRA'];
  529. const SHADE=['#C6C5BF','#A8A7A0','#6A6963',INK];
  530. const P=(c,r,k)=>[196+(c-r)*20,236+(c+r)*10-k*64];
  531. obsReveal('dotty',s=>{
  532. for(let k=0;k<4;k++){
  533. // plane slab: paper fill so upper decks occlude lower ones
  534. const cs=[P(-.8,-.8,k),P(5.8,-.8,k),P(5.8,5.8,k),P(-.8,5.8,k)];
  535. el(s,'path',{d:'M'+cs.map(p=>p.join(' ')).join(' L ')+' Z',
  536. fill:PAPER,'fill-opacity':.96,stroke:'#DEDDD6','stroke-width':.9,
  537. class:'fade',style:`animation-delay:${k*.16}s`});
  538. for(let r=0;r<6;r++)for(let c=0;c<6;c++){
  539. const load=rnd(k*37+r*6+c+1,k+2);
  540. const v=load<.3?0:Math.round(load*12); // sparser: silence is part of the texture
  541. const [x,y]=P(c,r,k);
  542. if(!v){el(s,'circle',{cx:x,cy:y,r:.7,fill:'#D8D6CE',
  543. class:'pop',style:`animation-delay:${k*.16+(r*6+c)*.008}s`});continue}
  544. const dot=el(s,'circle',{cx:x,cy:y,r:1+Math.sqrt(v)*1.15,fill:SHADE[k],
  545. class:'pop',style:`animation-delay:${k*.16+(r*6+c)*.008}s`});
  546. tip(dot,`${SQUAD[k]} · week ${c+1} × lane ${r+1} — ${v} tasks`);
  547. }
  548. // label off the plane's right corner, tied by a short hairline
  549. const [cxr,cyr]=P(5.8,-.8,k);
  550. el(s,'line',{x1:cxr+4,y1:cyr,x2:cxr+22,y2:cyr,stroke:'#C6C5BF','stroke-width':.8,
  551. class:'fade',style:`animation-delay:${.2+k*.16}s`});
  552. txt(s,{x:cxr+27,y:cyr+2.5,'font-size':7.5,'font-weight':700,fill:k===3?INK:'#8F8E88',
  553. 'letter-spacing':'.12em',class:'fade',style:`animation-delay:${.2+k*.16}s`},SQUAD[k]);
  554. }
  555. });
  556. })();
  557. // ════ 3 · bubble almanac ════
  558. // the vintage-almanac feel lives in three things: ledger-paper hairlines
  559. // under everything, bubbles big enough to collide, and marginalia. do all three.
  560. (()=>{
  561. const AREAS=['EDITOR','BOARDS','DOCS','CHAT','FLOWS','VAULT','PAGES','SYNC','GRID','VIEWS','HUB','FORMS'];
  562. const BETA={GRID:2023,VIEWS:2024,HUB:2025,FORMS:2025}; // beta until this year
  563. 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};
  564. obsReveal('almanac',s=>{
  565. const rowY=i=>62+i*34,colX=j=>132+j*57;
  566. // ledger paper: hairlines every 6.8px, edge to edge, under everything
  567. for(let y=40;y<=318;y+=6.8)
  568. el(s,'line',{x1:56,y1:y,x2:822,y2:y,stroke:'#E4E3DC','stroke-width':.5,
  569. class:'fade',style:`animation-delay:${(y-40)*.001}s`});
  570. const vals=[];
  571. for(let i=0;i<8;i++){
  572. const yr=2019+i;
  573. el(s,'line',{x1:56,y1:rowY(i),x2:822,y2:rowY(i),stroke:'#D3D2CA','stroke-width':.9,
  574. class:'fade',style:`animation-delay:${i*.04}s`});
  575. txt(s,{x:48,y:rowY(i)+3,'font-size':9,'font-weight':700,fill:'#8F8E88','text-anchor':'end',
  576. class:'fade',style:`animation-delay:${i*.04}s`},yr);
  577. AREAS.forEach((a,j)=>{
  578. if(yr<BORN[a])return;
  579. const age=yr-BORN[a];
  580. // steep spread: quiet areas stay pinpricks, busy ones balloon
  581. const v=Math.round((2+age*5)*(0.25+rnd(i*12+j+1,j+3)**2*2.2));
  582. vals.push([Math.max(1,v),i,j,a,yr]);
  583. });
  584. }
  585. // draw small over large so the giants sit underneath as washes
  586. vals.sort((a,b)=>b[0]-a[0]);
  587. const top3=vals.slice(0,3);
  588. // hand-drawn circle: radius wobbles around the rim on two slow sine waves,
  589. // so every bubble is its own slightly-off print — never a compass circle
  590. const blob=(x,y,r,seed)=>{
  591. const n=Math.max(14,Math.round(r*1.6)),pts=[];
  592. for(let t=0;t<n;t++){
  593. const a=t/n*Math.PI*2;
  594. const w=1+.055*Math.sin(a*2+seed*7)+.04*Math.sin(a*3+seed*13)+(rnd(seed+t,3)-.5)*.03;
  595. pts.push([x+Math.cos(a)*r*w,y+Math.sin(a)*r*w]);
  596. }
  597. let d=`M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
  598. for(let t=0;t<n;t++){
  599. const p=pts[t],q=pts[(t+1)%n];
  600. d+=` Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${((p[0]+q[0])/2).toFixed(1)} ${((p[1]+q[1])/2).toFixed(1)}`;
  601. }
  602. return d+' Z';
  603. };
  604. vals.forEach(([v,i,j,a,yr],k)=>{
  605. const x=colX(j)+(rnd(i*7+j+2,j+9)-.5)*10, // jitter off the gridline
  606. y=rowY(i),r=Math.sqrt(v)*3.8; // big: rows are 34 apart, giants reach ~28 and collide
  607. const beta=BETA[a]&&yr<=BETA[a];
  608. const g=el(s,'g',{class:'pop',style:`animation-delay:${.15+k*.012}s`});
  609. const seed=i*12+j;
  610. const dot=el(g,'path',beta
  611. ?{d:blob(x,y,r,seed),fill:'none',stroke:'#8F8E88','stroke-width':1,'stroke-dasharray':'3 3'}
  612. :{d:blob(x,y,r,seed),fill:INK,'fill-opacity':.09+rnd(i+2,j+4)*.1,
  613. stroke:INK,'stroke-opacity':.3+rnd(i+5,j+7)*.35,'stroke-width':.6+rnd(i+3,j+11)*.9});
  614. tip(dot,`${a} · ${yr} — ${v*10} tickets${beta?' (beta)':''}`);
  615. // escalation core: small wobbly blot, nudged off-center like a press mark
  616. if(!beta){
  617. const ox=(rnd(seed+1,17)-.5)*r*.3,oy=(rnd(seed+3,19)-.5)*r*.3;
  618. el(g,'path',{d:blob(x+ox,y+oy,Math.max(1.4,r*.17),seed+29),fill:INK});
  619. }
  620. if(top3.includes(vals[k]))
  621. txt(s,{x,y:y-r-5,'font-size':8.5,'font-weight':800,fill:INK,'text-anchor':'middle',
  622. class:'fade',style:'animation-delay:1s'},v*10);
  623. });
  624. AREAS.forEach((a,j)=>{
  625. const x=colX(j);
  626. txt(s,{x,y:34,'font-size':7.5,'font-weight':700,fill:MUTED,'letter-spacing':'.1em',
  627. transform:`rotate(-32 ${x} 34)`,class:'fade',style:`animation-delay:${j*.03}s`},a);
  628. });
  629. // marginalia: two annotations with pointer hairlines, like the original's notes
  630. 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}`,
  631. fill:'none',stroke:'#B0AFA9','stroke-width':.7,class:'fade',style:'animation-delay:1.1s'});
  632. txt(s,{x:colX(3)+124,y:rowY(5)-35,'font-size':7,fill:'#6A6963','font-style':'italic',
  633. class:'fade',style:'animation-delay:1.1s'},'chat tickets triple after mobile GA');
  634. 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}`,
  635. fill:'none',stroke:'#B0AFA9','stroke-width':.7,class:'fade',style:'animation-delay:1.2s'});
  636. txt(s,{x:colX(9)+86,y:rowY(2)+33,'font-size':7,fill:'#6A6963','font-style':'italic',
  637. class:'fade',style:'animation-delay:1.2s'},'betas barely ticket — nobody files bugs on toys');
  638. // footer strip: important events, the almanac's timeline shelf
  639. const evY=356;
  640. el(s,'line',{x1:56,y1:evY-16,x2:822,y2:evY-16,stroke:'#C6C5BF','stroke-width':.8,
  641. class:'fade',style:'animation-delay:1.2s'});
  642. txt(s,{x:56,y:evY-24,'font-size':7,'font-weight':800,fill:'#8F8E88','letter-spacing':'.16em',
  643. class:'fade',style:'animation-delay:1.2s'},'IMPORTANT EVENTS');
  644. [['2020','SLA introduced'],['2022','self-serve help center'],['2023','the GA wave'],['2025','AI deflection live']]
  645. .forEach(([yr,note],k)=>{
  646. const x=90+k*196;
  647. el(s,'line',{x1:x,y1:evY-16,x2:x,y2:evY-6,stroke:'#8F8E88','stroke-width':1,
  648. class:'fade',style:`animation-delay:${1.3+k*.08}s`});
  649. txt(s,{x,y:evY+4,'font-size':8,'font-weight':800,fill:INK,
  650. class:'fade',style:`animation-delay:${1.3+k*.08}s`},yr);
  651. txt(s,{x,y:evY+15,'font-size':7,fill:'#8F8E88',
  652. class:'fade',style:`animation-delay:${1.3+k*.08}s`},note);
  653. });
  654. });
  655. })();
  656. // ════ 4 · radial patchwork ════
  657. (()=>{
  658. const CX=200,CY=168,N=46;
  659. obsReveal('patchwork',s=>{
  660. // 24h rim ticks
  661. for(let h=0;h<96;h++){
  662. const a=-90+h*3.75,[x1,y1]=pol(CX,CY,140,a),[x2,y2]=pol(CX,CY,h%4===0?146:143,a);
  663. el(s,'line',{x1,y1,x2,y2,stroke:'#C6C5BF','stroke-width':h%4===0?1:.5,
  664. class:'fade',style:`animation-delay:${h*.006}s`});
  665. }
  666. [0,6,12,18].forEach(h=>{
  667. const [x,y]=pol(CX,CY,157,-90+h*15);
  668. txt(s,{x,y:y+3,'font-size':8,'font-weight':700,fill:MUTED,'text-anchor':'middle',
  669. class:'fade'},String(h).padStart(2,'0'));
  670. });
  671. // the patchwork: translucent wedges pile into ink
  672. const wedges=[];
  673. for(let i=0;i<N;i++){
  674. // deploys cluster around 11h and 16h, thin overnight
  675. const peak=rnd(i+1,2)>.5?11:16;
  676. const hour=(peak+(rnd(i+1,3)-.5)*7+24)%24;
  677. const a0=-90+hour*15,sw=10+rnd(i+1,4)*34;
  678. const r1=34+rnd(i+1,5)*100;
  679. wedges.push([a0,sw,r1,hour,i]);
  680. }
  681. wedges.forEach(([a0,sw,r1,hour,i])=>{
  682. const w=el(s,'path',{d:sect(CX,CY,16,r1,a0,a0+sw),fill:INK,
  683. 'fill-opacity':.07+rnd(i+1,6)*.09,class:'fade',
  684. style:`animation-delay:${.2+i*.022}s`});
  685. 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`);
  686. });
  687. // three outlined wedges: the ones that paged somebody
  688. [4,17,31].forEach((i,k)=>{
  689. const [a0,sw,r1]=wedges[i];
  690. const w=el(s,'path',{d:sect(CX,CY,16,r1,a0,a0+sw),fill:'none',
  691. stroke:INK,'stroke-width':1.1,class:'fade',style:`animation-delay:${1.2+k*.15}s`});
  692. tip(w,`deploy #${i+1} — triggered an incident`);
  693. });
  694. el(s,'circle',{cx:CX,cy:CY,r:3,fill:INK,class:'pop',style:'animation-delay:1.3s'});
  695. txt(s,{x:CX,y:308,'font-size':7.5,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  696. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.4s'},'OUTLINED = TRIGGERED AN INCIDENT');
  697. });
  698. })();
  699. // ════ 6 · trend lineage ════
  700. (()=>{
  701. // [name, events [year, type f|o], aliveToday]
  702. const TR=[
  703. ['Dark mode',[[2017,'f'],[2022,'o']],true],
  704. ['Kanban',[[2016,'f']],true],
  705. ['Wikis',[[2016,'f'],[2019,'o']],false],
  706. ['Slash cmds',[[2018,'f']],true],
  707. ['Templates',[[2019,'f'],[2024,'o']],true],
  708. ['Inbox',[[2020,'f']],false],
  709. ['Live cursors',[[2020,'f'],[2023,'o']],true],
  710. ['AI drafts',[[2023,'f'],[2025,'o']],true],
  711. ['Voice notes',[[2021,'f']],false],
  712. ['Offline',[[2018,'f'],[2026,'o']],true],
  713. ];
  714. const Y0=2016,yY=yr=>34+(yr-Y0)*24,colX=c=>76+c*31;
  715. obsReveal('lineage',s=>{
  716. for(let yr=Y0;yr<=2026;yr++){
  717. el(s,'line',{x1:60,y1:yY(yr),x2:372,y2:yY(yr),stroke:'#E3E2DB','stroke-width':.8,
  718. class:'fade',style:`animation-delay:${(yr-Y0)*.03}s`});
  719. if(yr%2===0)txt(s,{x:52,y:yY(yr)+3,'font-size':8,'font-weight':600,fill:MUTED,'text-anchor':'end',
  720. class:'fade',style:`animation-delay:${(yr-Y0)*.03}s`},yr);
  721. }
  722. TR.forEach(([name,ev,alive],c)=>{
  723. const x=colX(c);
  724. // segments between events: solid while fresh, dashed once dormant
  725. for(let k=0;k<ev.length-1;k++){
  726. const gap=ev[k+1][0]-ev[k][0];
  727. el(s,'line',{x1:x,y1:yY(ev[k][0])+7,x2:x,y2:yY(ev[k+1][0])-7,
  728. stroke:'#B0AFA9','stroke-width':1,
  729. 'stroke-dasharray':gap>2?'2 4':'none',pathLength:1,
  730. class:'draw',style:`animation-delay:${.3+c*.07}s;animation-duration:.6s`});
  731. }
  732. const last=ev[ev.length-1][0];
  733. if(alive)
  734. el(s,'line',{x1:x,y1:yY(last)+7,x2:x,y2:yY(2026)+16,stroke:'#B0AFA9','stroke-width':1,
  735. pathLength:1,class:'draw',style:`animation-delay:${.45+c*.07}s;animation-duration:.6s`});
  736. ev.forEach(([yr,t])=>{
  737. const node=el(s,'circle',t==='f'
  738. ?{cx:x,cy:yY(yr),r:5.5,fill:INK}
  739. :{cx:x,cy:yY(yr),r:5.5,fill:PAPER,stroke:INK,'stroke-width':1.4});
  740. node.setAttribute('class','pop');
  741. node.style.animationDelay=(.35+c*.07)+'s';
  742. tip(node,`${name} — ${t==='f'?'shipped':'reworked'} ${yr}`);
  743. });
  744. // terminal dot: alive lands on today's baseline, dead stops where it stopped
  745. el(s,'circle',{cx:x,cy:alive?yY(2026)+16:yY(last)+12,r:alive?3:1.6,
  746. fill:alive?INK:'#C6C5BF',class:'pop',style:`animation-delay:${.6+c*.07}s`});
  747. const ly=yY(2026)+26;
  748. txt(s,{x,y:ly,'font-size':6.8,'font-weight':600,fill:alive?'#4A4944':'#B0AFA9',
  749. transform:`rotate(-38 ${x} ${ly})`,'text-anchor':'end',
  750. class:'fade',style:`animation-delay:${.7+c*.05}s`},name);
  751. });
  752. });
  753. })();
  754. // ════ 8 · type colonnade ════
  755. (()=>{
  756. const PRE=['core','ui','api','data','auth','sync','mail','flag','edge','doc','bot'];
  757. const SUF=['-kit','-svc','-web','-cli','-db','-gw','-sdk','-jobs'];
  758. const TEAM=['PLATFORM','FRONTEND','BACKEND','DATA','INFRA','MOBILE','SECURITY','GROWTH','ML','DESIGN'];
  759. obsReveal('colonnade',s=>{
  760. const N=44;
  761. const teamY=j=>36+j*28,repoY=i=>16+i*6.7;
  762. const counts=Array(10).fill(0);
  763. const owner=i=>{const w=rnd(i+1,17);return Math.min(9,Math.floor(w*w*10))}; // skewed: low teams hoard
  764. for(let i=0;i<N;i++)counts[owner(i)]++;
  765. for(let i=0;i<N;i++){
  766. const name=PRE[i%11]+SUF[Math.floor(rnd(i+1,8)*8)%8]+(i>21?'-v2':'');
  767. const yi=repoY(i),j=owner(i),yj=teamY(j);
  768. txt(s,{x:112,y:yi+2,'font-size':5,fill:'#8F8E88','text-anchor':'end',
  769. class:'fade',style:`animation-delay:${i*.012}s`},name);
  770. el(s,'rect',{x:117,y:yi-1.2,width:4,height:2.4,fill:'#B0AFA9',
  771. class:'fade',style:`animation-delay:${i*.012}s`});
  772. el(s,'path',{d:`M123 ${yi} C 200 ${yi} 220 ${yj} 288 ${yj}`,fill:'none',
  773. stroke:'#A8A7A0','stroke-width':.6,opacity:.6,pathLength:1,
  774. class:'draw',style:`animation-delay:${.2+i*.015}s;animation-duration:.7s`});
  775. }
  776. TEAM.forEach((t,j)=>{
  777. const y=teamY(j);
  778. el(s,'circle',{cx:293,cy:y,r:2.6+counts[j]*.55,fill:counts[j]>=7?INK:counts[j]>=4?'#6A6963':'#B0AFA9',
  779. class:'pop',style:`animation-delay:${.6+j*.05}s`});
  780. const lab=txt(s,{x:302+counts[j]*.55,y:y+2.5,'font-size':7,'font-weight':700,fill:'#4A4944',
  781. class:'fade',style:`animation-delay:${.65+j*.05}s`},`${t} · ${counts[j]}`);
  782. tip(lab,`${t} owns ${counts[j]} repos`);
  783. });
  784. });
  785. })();
  786. // ════ 9 · hourglass stream ════
  787. (()=>{
  788. const STAGE=[['VISITORS',4200],['SIGN-UPS',1900],['ACTIVATED',960],['RETAINED',540],['PAYING',310]];
  789. obsReveal('hourglass',s=>{
  790. const CXm=185,sy=k=>34+k*64,w=c=>c/4200*290;
  791. STAGE.forEach(([name,c],k)=>{
  792. const y=sy(k),hw=w(c)/2;
  793. // barcode strip: one tick ≈ 40 people
  794. const n=Math.round(c/40);
  795. for(let t=0;t<n;t++){
  796. const x=CXm-hw+(t+.5)/n*hw*2+(rnd(t+1,k+3)-.5)*3;
  797. el(s,'line',{x1:x,y1:y-6,x2:x,y2:y+6,stroke:INK,'stroke-width':.8,
  798. opacity:.45+rnd(t+2,k+5)*.5,class:'fade',style:`animation-delay:${k*.12+t*.004}s`});
  799. }
  800. // threads trickling to the next strip
  801. if(k<4){
  802. const hw1=w(STAGE[k+1][1])/2;
  803. for(let t=0;t<34;t++){
  804. const xt=CXm+(rnd(t+1,k*7+1)-.5)*2*hw*.94;
  805. const xb=CXm+(rnd(t+3,k*7+5)-.5)*2*hw1*.94;
  806. el(s,'path',{d:`M${xt} ${y+8} C${xt} ${y+34} ${xb} ${sy(k+1)-34} ${xb} ${sy(k+1)-8}`,
  807. fill:'none',stroke:'#B0AFA9','stroke-width':.5,opacity:.32,pathLength:1,
  808. class:'draw',style:`animation-delay:${.2+k*.15+t*.008}s;animation-duration:.8s`});
  809. }
  810. // conversion between stages, parked in the left margin
  811. const pct=Math.round(STAGE[k+1][1]/c*100);
  812. txt(s,{x:26,y:(y+sy(k+1))/2+3,'font-size':8.5,'font-weight':800,fill:'#8F8E88',
  813. class:'fade',style:`animation-delay:${.5+k*.15}s`},pct+'%');
  814. txt(s,{x:26,y:(y+sy(k+1))/2+13,'font-size':6,'font-weight':600,fill:'#C6C5BF',
  815. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${.5+k*.15}s`},'GET THROUGH');
  816. }
  817. // stage label, tied to the strip edge
  818. el(s,'line',{x1:CXm+hw+6,y1:y,x2:340,y2:y,stroke:'#DEDDD6','stroke-width':.8,
  819. class:'fade',style:`animation-delay:${.3+k*.12}s`});
  820. txt(s,{x:344,y:y-1,'font-size':7.5,'font-weight':700,fill:'#4A4944',
  821. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${.35+k*.12}s`},name);
  822. txt(s,{x:344,y:y+10,'font-size':9.5,'font-weight':800,fill:INK,
  823. class:'fade',style:`animation-delay:${.4+k*.12}s`},c.toLocaleString());
  824. });
  825. });
  826. })();
  827. // ════ 14 · hundred field ════
  828. // small data, unit-decomposed: four shares of 100% become 100 people.
  829. // phyllotaxis discs (golden angle) so每簇自带肌理; share is countable, not just readable.
  830. (()=>{
  831. const SEG=[['CHARGED',41,INK],['TORN',35,'#55554F'],['ADRIFT',12,'#8F8E88'],['AVERSE',12,'#B0AFA9']];
  832. const POS=[[132,140],[276,116],[186,252],[322,238]];
  833. obsReveal('hundredfield',s=>{
  834. // dashed constellation hairlines tying the cluster cores
  835. [[0,1],[0,2],[1,3],[2,3]].forEach(([a,b],k)=>{
  836. el(s,'line',{x1:POS[a][0],y1:POS[a][1],x2:POS[b][0],y2:POS[b][1],
  837. stroke:GRID,'stroke-width':.7,'stroke-dasharray':'2 5',
  838. class:'fade',style:`animation-delay:${.9+k*.1}s`});
  839. });
  840. SEG.forEach(([name,v,shade],ci)=>{
  841. const [cx,cy]=POS[ci];
  842. let edge=0;
  843. for(let k=0;k<v;k++){
  844. const a=k*137.508+ci*55;
  845. const rr=4+Math.sqrt(k)*5.9+rnd(k+1,ci+2)*3;
  846. edge=Math.max(edge,rr);
  847. const [x,y]=pol(cx,cy,rr,a);
  848. // hairline spoke for every 5th person, core to dot
  849. if(k%5===0)el(s,'line',{x1:cx,y1:cy,x2:x,y2:y,stroke:'#CDCCC5','stroke-width':.6,
  850. class:'fade',style:`animation-delay:${ci*.14+k*.012}s`});
  851. const dot=el(s,'circle',{cx:x,cy:y,r:1.5+rnd(k+2,ci+3)*1.7,fill:shade,opacity:.9,
  852. class:'pop',style:`animation-delay:${ci*.14+k*.012}s`});
  853. tip(dot,`${name} — one of ${v} in 100`);
  854. }
  855. el(s,'circle',{cx,cy,r:2.4,fill:INK,class:'pop',style:`animation-delay:${ci*.14}s`});
  856. txt(s,{x:cx,y:cy+edge+13,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
  857. 'letter-spacing':'.1em',style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.5+ci*.12}s`,
  858. class:'fade'},`${name} · ${v}`);
  859. });
  860. txt(s,{x:200,y:314,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  861. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.3s'},'ONE DOT = ONE PERSON · 41 + 35 + 12 + 12 = 100');
  862. });
  863. })();
  864. // ════ 15 · ballot tally ════
  865. // multi-select survey (each option independent, 0–100): every option is a row of
  866. // 100 hairline ticks — a hundred people standing in line, the ones who picked it inked.
  867. (()=>{
  868. const ASK=[['DO MORE WITH THE SAME PAY',51],['AN UNSUSTAINABLE PACE',46],['QUALITY OF WORK SLIPPING',41],['BEING REPLACED OUTRIGHT',22]];
  869. obsReveal('ballottally',s=>{
  870. ASK.forEach(([name,v],i)=>{
  871. const base=78+i*62;
  872. txt(s,{x:28,y:base-26,'font-size':7.5,'font-weight':700,fill:'#6A6963',
  873. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.1}s`},name);
  874. el(s,'line',{x1:28,y1:base,x2:372,y2:base,stroke:GRID,'stroke-width':.6,
  875. class:'fade',style:`animation-delay:${i*.1}s`});
  876. for(let k=0;k<100;k++){
  877. const x=28+k*3.44,picked=k<v;
  878. const h=picked?12+rnd(k+1,i+2)*5:4.5+rnd(k+1,i+5)*2;
  879. el(s,'line',{x1:x,y1:base,x2:x,y2:base-h,
  880. stroke:picked?INK:'#CFCEC7','stroke-width':picked?.9:.55,
  881. class:'fade',style:`animation-delay:${i*.1+k*.006}s`});
  882. if(k%10===0)el(s,'circle',{cx:x,cy:base+4.5,r:.8,fill:'#C6C5BF',
  883. class:'fade',style:`animation-delay:${i*.1+k*.006}s`});
  884. }
  885. const lab=txt(s,{x:28+(v-1)*3.44+9,y:base-11,'font-size':11,'font-weight':800,fill:INK,
  886. style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.5+i*.1}s`,
  887. class:'fade'},v);
  888. tip(lab,`${v} of 100 picked this — they could pick several`);
  889. });
  890. txt(s,{x:200,y:314,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  891. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},'ONE TICK = ONE RESPONDENT · DOT MARKS EVERY TENTH');
  892. });
  893. })();
  894. // ── New chart-family helpers (scoped to the added templates) ──
  895. (()=>{
  896. const INK='#1C1C1A',PAPER='#F0EFEB',MUTED='#8F8E88',GRID='#DEDDD6';
  897. const NS='http://www.w3.org/2000/svg';
  898. 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};
  899. const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
  900. const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
  901. const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
  902. // ════ L16 · matrix heat ════
  903. // 通用矩阵热力:任意 分类×分类×连续值。明度即数据——五档灰阶方块,
  904. // 越黑越高。对角线不参与(自己×自己),零格留一粒小点(沉默可见)。
  905. // 最强的一对上虚线框 + 标数。区别于 F10 dot heat:这里编码用明度不用面积,
  906. // 值是占比/强度而不是可数件数。
  907. (()=>{
  908. const FEAT=['EDITOR','BOARDS','DOCS','CHAT','FLOWS','VAULT','PAGES','SYNC'];
  909. const v=(i,j)=>{
  910. if(i===j)return -1;
  911. const a=Math.min(i,j),b=Math.max(i,j);
  912. const pop=(8-a)/8*(8-b)/8; // 头部功能重合度高
  913. const raw=pop*62*(0.35+rnd(a*8+b+1,a+b+3)*.9);
  914. return Math.round(raw);
  915. };
  916. const shade=t=>t>36?INK:t>24?'#4A4944':t>14?'#8F8E88':t>6?'#B0AFA9':'#D8D7D1';
  917. obsReveal('matheat',s=>{
  918. const X0=118,Y0=70,P=26,C=22;
  919. let max=0,mi=0,mj=0;
  920. 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}}
  921. FEAT.forEach((f,i)=>{
  922. txt(s,{x:X0-10,y:Y0+i*P+C/2+3,'font-size':7,'font-weight':700,fill:'#6A6963','text-anchor':'end',
  923. 'letter-spacing':'.06em',class:'fade',style:`animation-delay:${i*.04}s`},f);
  924. const cy=Y0-10;
  925. txt(s,{x:X0+i*P+C/2,y:cy,'font-size':7,'font-weight':700,fill:MUTED,'letter-spacing':'.06em',
  926. transform:`rotate(-55 ${X0+i*P+C/2} ${cy})`,class:'fade',style:`animation-delay:${i*.04}s`},f);
  927. });
  928. for(let i=0;i<8;i++)for(let j=0;j<8;j++){
  929. const x=X0+j*P,y=Y0+i*P,t=v(i,j),d=(i+j)*.02;
  930. if(t<0){el(s,'rect',{x:x+C/2-3,y:y+C/2-.6,width:6,height:1.2,fill:'#D8D6CE',
  931. class:'fade',style:`animation-delay:${d}s`});continue}
  932. if(!t){el(s,'circle',{cx:x+C/2,cy:y+C/2,r:.9,fill:'#D8D6CE',
  933. class:'pop',style:`animation-delay:${d}s`});continue}
  934. const cell=el(s,'rect',{x,y,width:C,height:C,rx:4,fill:shade(t),
  935. class:'pop',style:`animation-delay:${d}s`});
  936. tip(cell,`${FEAT[i]} × ${FEAT[j]} — ${t}% of accounts use both`);
  937. if(i===mi&&j===mj){
  938. el(s,'rect',{x:x-3.5,y:y-3.5,width:C+7,height:C+7,rx:6,fill:'none',
  939. stroke:INK,'stroke-width':1,'stroke-dasharray':'2 3',
  940. class:'fade',style:'animation-delay:.9s'});
  941. txt(s,{x:x+C/2,y:y+C/2+3.5,'font-size':9,'font-weight':800,fill:PAPER,'text-anchor':'middle',
  942. class:'fade',style:'animation-delay:1s'},t);
  943. }
  944. }
  945. // shade legend, countable buckets
  946. const LG=[['#D8D7D1','1–6'],['#B0AFA9','7–14'],['#8F8E88','15–24'],['#4A4944','25–36'],[INK,'37+']];
  947. LG.forEach(([c,lab],k)=>{
  948. const x=118+k*54;
  949. el(s,'rect',{x,y:292,width:9,height:9,rx:2,fill:c,class:'fade',style:`animation-delay:${1+k*.05}s`});
  950. txt(s,{x:x+13,y:300,'font-size':6.5,'font-weight':600,fill:'#8F8E88',
  951. class:'fade',style:`animation-delay:${1+k*.05}s`},lab+'%');
  952. });
  953. txt(s,{x:36,y:300,'font-size':7,'font-weight':600,fill:'#B0AFA9','letter-spacing':'.1em',
  954. class:'fade',style:'animation-delay:1s'},'SHADE = %');
  955. });
  956. })();
  957. // ════ L17 · calendar heat ════
  958. // 日历热力:GitHub 贡献墙的 Lupi 皮。一格一天,52 周 × 7 天,
  959. // 点面积 = 当天量(sqrt 换算),静默日一粒小点。月份写在上檐,
  960. // 峰值日虚线圈 + 旁注引线。逐记录世界观:一年 365 天每天都在场。
  961. (()=>{
  962. const MON=['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
  963. const DAY=['MON','WED','FRI','SUN'];
  964. const val=(w,d)=>{
  965. const wd=d<5;
  966. const season=1+.55*Math.sin((w-8)/9); // 春秋两个发布季
  967. if(!wd)return rnd(w*7+d+1,3)>.82?1:0; // 周末基本静默
  968. const raw=season*(2.5+rnd(w*7+d+1,d+2)*9)*(rnd(w+1,d+5)>.12?1:0);
  969. return Math.round(raw);
  970. };
  971. obsReveal('calheat',s=>{
  972. const X0=56,Y0=42,P=14.8;
  973. let max=0,mw=0,md=0;
  974. 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}}
  975. DAY.forEach((lab,k)=>{
  976. 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',
  977. 'letter-spacing':'.06em',class:'fade',style:`animation-delay:${k*.04}s`},lab);
  978. });
  979. MON.forEach((m,k)=>{
  980. const x=X0+Math.round(k*52/12)*P;
  981. txt(s,{x,y:Y0-16,'font-size':7,'font-weight':700,fill:MUTED,'letter-spacing':'.1em',
  982. class:'fade',style:`animation-delay:${k*.03}s`},m);
  983. el(s,'line',{x1:x,y1:Y0-11,x2:x,y2:Y0-5,stroke:'#C6C5BF','stroke-width':.7,
  984. class:'fade',style:`animation-delay:${k*.03}s`});
  985. });
  986. for(let w=0;w<52;w++)for(let d=0;d<7;d++){
  987. const x=X0+w*P,y=Y0+d*P,t=val(w,d),delay=w*.012+d*.004;
  988. if(!t){el(s,'circle',{cx:x,cy:y,r:.75,fill:'#D8D6CE',
  989. class:'pop',style:`animation-delay:${delay}s`});continue}
  990. const r=1.1+Math.sqrt(t)*1.55;
  991. const dot=el(s,'circle',{cx:x,cy:y,r,
  992. fill:t>max*.66?INK:t>max*.33?'#6A6963':'#B0AFA9',
  993. class:'pop',style:`animation-delay:${delay}s`});
  994. tip(dot,`W${w+1} ${['MON','TUE','WED','THU','FRI','SAT','SUN'][d]} — ${t} deploys`);
  995. }
  996. // peak: dashed ring + marginalia hairline
  997. const px=X0+mw*P,py=Y0+md*P,pr=1.1+Math.sqrt(max)*1.55;
  998. el(s,'circle',{cx:px,cy:py,r:pr+3.6,fill:'none',stroke:INK,'stroke-width':1,
  999. 'stroke-dasharray':'2 3',class:'fade',style:'animation-delay:1s'});
  1000. el(s,'path',{d:`M${px+pr+6} ${py+6} C${px+34} ${py+30} ${px+52} ${py+36} ${px+72} ${py+36}`,
  1001. fill:'none',stroke:'#B0AFA9','stroke-width':.7,class:'fade',style:'animation-delay:1.1s'});
  1002. txt(s,{x:px+78,y:py+39,'font-size':7,fill:'#6A6963','font-style':'italic',
  1003. class:'fade',style:'animation-delay:1.15s'},`the release-week spike — ${max} deploys in a day`);
  1004. txt(s,{x:420,y:222,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  1005. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
  1006. 'ONE DOT = ONE DAY · DOT AREA = DEPLOYS · TINY DOT = A QUIET DAY');
  1007. });
  1008. })();
  1009. // ════ L18 · beeswarm ════
  1010. // 蜂群:violin 的 Lupi 姊妹。不聚合密度,把 120 条真实记录沿数值轴
  1011. // 逐颗排开,撞上了就往两边让——堆得高的地方就是分布的峰。
  1012. // 空心 = enterprise 单,虚线 = 中位数。每颗点背后是一条真实成交。
  1013. (()=>{
  1014. const N=120;
  1015. const deals=Array.from({length:N},(_,i)=>{
  1016. const u=rnd(i+1,3),ent=rnd(i+2,11)>.86;
  1017. const v=Math.round(4+(ent?60:6)+150*Math.pow(u,2.6)+rnd(i+3,7)*10);
  1018. return [Math.min(178,v),ent];
  1019. });
  1020. obsReveal('beeswarm',s=>{
  1021. const X0=36,X1=376,BASE=246,R=4,GAP=R*2+.8;
  1022. const mapX=v=>X0+v/180*(X1-X0);
  1023. // axis rail + barcode ticks
  1024. el(s,'line',{x1:X0-4,y1:BASE,x2:X1+4,y2:BASE,stroke:GRID,'stroke-width':.8,class:'fade'});
  1025. for(let g=0;g<=9;g++){
  1026. const x=X0+g/9*(X1-X0);
  1027. el(s,'line',{x1:x,y1:BASE,x2:x,y2:BASE+(g%3===0?7:4),stroke:'#CFCEC7','stroke-width':.6,
  1028. class:'fade',style:`animation-delay:${g*.02}s`});
  1029. if(g%3===0)txt(s,{x,y:BASE+18,'font-size':7,'font-weight':600,fill:'#C6C5BF','text-anchor':'middle',
  1030. class:'fade',style:`animation-delay:${g*.02}s`},'$'+Math.round(g/9*180)+'k');
  1031. }
  1032. // swarm as clean stacks: x snaps to a GAP-wide lane, dots pile upward —
  1033. // the pile height IS the density. no half-overlaps, every dot countable.
  1034. const lanes={};
  1035. const sorted=deals.map((d,i)=>[d[0],d[1],i]).sort((a,b)=>a[0]-b[0]);
  1036. sorted.forEach(([v,ent,i])=>{
  1037. const lane=Math.round((mapX(v)-X0)/GAP);
  1038. const x=X0+lane*GAP;
  1039. const row=lanes[lane]||0;lanes[lane]=row+1;
  1040. const y=BASE-R-1-row*GAP;
  1041. const dot=ent
  1042. ?el(s,'circle',{cx:x,cy:y,r:R,fill:PAPER,stroke:INK,'stroke-width':1.1,
  1043. class:'pop',style:`animation-delay:${.1+i*.007}s`})
  1044. :el(s,'circle',{cx:x,cy:y,r:R,fill:INK,opacity:.82,
  1045. class:'pop',style:`animation-delay:${.1+i*.007}s`});
  1046. tip(dot,`deal #${i+1} — $${v}k${ent?' · enterprise':''}`);
  1047. });
  1048. // median
  1049. const vs=deals.map(d=>d[0]).sort((a,b)=>a-b);
  1050. const med=vs[Math.floor(N/2)];
  1051. const mx=mapX(med);
  1052. el(s,'line',{x1:mx,y1:52,x2:mx,y2:BASE+8,stroke:'#8F8E88','stroke-width':.9,
  1053. 'stroke-dasharray':'2 4',class:'fade',style:'animation-delay:1s'});
  1054. txt(s,{x:mx,y:44,'font-size':9.5,'font-weight':800,fill:INK,'text-anchor':'middle',
  1055. style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1.1s`,
  1056. class:'fade'},'MEDIAN $'+med+'k');
  1057. txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  1058. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
  1059. 'ONE DOT = ONE DEAL · THE PILE IS THE DISTRIBUTION · HOLLOW = ENTERPRISE');
  1060. });
  1061. })();
  1062. // ════ L19 · ridgeline ════
  1063. // 多组分布的 Lupi 皮:每行一条完成时长的密度山脊,面积由竖发丝组成
  1064. // (F3 hairline area 的语法),行与行轻微重叠,后排画在前、前排糊上
  1065. // 纸色 slab 遮挡(dotty matrix 的手法)。峰值点标中心时长。
  1066. (()=>{
  1067. 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]];
  1068. obsReveal('ridge',s=>{
  1069. const X0=92,X1=372,MAXH=24,N=72,LIFT=54;
  1070. const rowY=i=>96+i*44;
  1071. ROWS.forEach(([name,c,sp],i)=>{
  1072. const y0=rowY(i);
  1073. const dens=Array.from({length:N},(_,k)=>{
  1074. const h=k/(N-1)*MAXH;
  1075. return Math.exp(-((h-c)**2)/(2*sp*sp))+.26*Math.exp(-((h-c*2.1)**2)/(2*(sp*1.7)**2));
  1076. });
  1077. const dmax=Math.max(...dens);
  1078. const pts=dens.map((d,k)=>[X0+k/(N-1)*(X1-X0),y0-d/dmax*LIFT]);
  1079. const crest='M'+pts.map(p=>p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' L ');
  1080. el(s,'path',{d:crest+` L${X1} ${y0} L${X0} ${y0} Z`,fill:PAPER,'fill-opacity':.96,
  1081. class:'fade',style:`animation-delay:${i*.1}s`});
  1082. pts.forEach(([px,py],k)=>{
  1083. if(k%2)return;
  1084. el(s,'line',{x1:px,y1:y0,x2:px,y2:py,stroke:'#8F8E88','stroke-width':.5,
  1085. opacity:.28+rnd(k+1,i+3)*.32,class:'fade',style:`animation-delay:${i*.1+k*.004}s`});
  1086. });
  1087. el(s,'path',{d:crest,fill:'none',stroke:INK,'stroke-width':.9,pathLength:1,
  1088. class:'draw',style:`animation-delay:${.1+i*.1}s;animation-duration:.8s`});
  1089. el(s,'line',{x1:X0,y1:y0,x2:X1,y2:y0,stroke:GRID,'stroke-width':.7,
  1090. class:'fade',style:`animation-delay:${i*.1}s`});
  1091. const pk=dens.indexOf(dmax),[px,py]=pts[pk];
  1092. const dot=el(s,'circle',{cx:px,cy:py,r:2.6,fill:INK,class:'pop',style:`animation-delay:${.6+i*.1}s`});
  1093. tip(dot,`${name} — most runs finish around ${c.toFixed(1)}h`);
  1094. txt(s,{x:px+7,y:py+1,'font-size':8,'font-weight':800,fill:INK,
  1095. style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.7+i*.1}s`,
  1096. class:'fade'},c.toFixed(1)+'h');
  1097. txt(s,{x:X0-10,y:y0-3,'font-size':7.5,'font-weight':700,fill:'#6A6963','text-anchor':'end',
  1098. 'letter-spacing':'.06em',class:'fade',style:`animation-delay:${i*.1}s`},name);
  1099. });
  1100. const yb=rowY(4);
  1101. [0,6,12,18,24].forEach(h=>{
  1102. const x=X0+h/MAXH*(X1-X0);
  1103. el(s,'line',{x1:x,y1:yb+4,x2:x,y2:yb+9,stroke:'#CFCEC7','stroke-width':.6,
  1104. class:'fade',style:'animation-delay:.8s'});
  1105. txt(s,{x,y:yb+20,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
  1106. class:'fade',style:'animation-delay:.85s'},h+'h');
  1107. });
  1108. txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  1109. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
  1110. 'EACH CREST = ONE PIPELINE · THE AREA IS MADE OF HAIRLINES · DOT = THE PEAK');
  1111. });
  1112. })();
  1113. // ════ L20 · parallel coordinates ════
  1114. // 多维记录对比的 Lupi 皮:一条发丝线 = 一个产品走过四根轴。
  1115. // 线束的形状就是数据结构(哪两维相关、谁全线领先)。
  1116. // 全线最优的上墨标名,其余发丝退后。轴顶名、轴脚 min/max。
  1117. (()=>{
  1118. const DIMS=[['PRICE $',8,30],['CSAT',6,9.6],['RETENTION %',55,95],['GROWTH %',-5,40]];
  1119. const P=[['Editor',12,9.1,91,22],['Boards',18,8.4,86,18],['Forms',9,8.8,78,31],
  1120. ['Docs',15,8.0,82,12],['Chat',7,7.2,64,8],['Vault',24,7.8,88,6],
  1121. ['Flows',21,8.6,90,38],['Views',11,7.5,71,14],['Sync',16,6.9,58,-2],
  1122. ['Pages',8,8.2,74,19],['Grid',19,7.1,62,4],['Hub',13,6.6,52,9]];
  1123. obsReveal('parallel',s=>{
  1124. const AX=[64,156,248,340],TOP=64,BOT=252;
  1125. const mapY=(di,v)=>{const [,lo,hi]=DIMS[di];return BOT-(v-lo)/(hi-lo)*(BOT-TOP)};
  1126. // hero: judged on csat+retention+growth (price is a fact, not a virtue)
  1127. const score=p=>(p[2]-6)/3.6+(p[3]-55)/40+(p[4]+5)/45;
  1128. const hero=P.reduce((a,b)=>score(b)>score(a)?b:a);
  1129. DIMS.forEach(([name,lo,hi],di)=>{
  1130. el(s,'line',{x1:AX[di],y1:TOP-8,x2:AX[di],y2:BOT+8,stroke:'#B0AFA9','stroke-width':1,
  1131. class:'fade',style:`animation-delay:${di*.06}s`});
  1132. [[TOP-8],[BOT+8]].forEach(([yy])=>
  1133. el(s,'line',{x1:AX[di]-4,y1:yy,x2:AX[di]+4,y2:yy,stroke:'#B0AFA9','stroke-width':1,
  1134. class:'fade',style:`animation-delay:${di*.06}s`}));
  1135. txt(s,{x:AX[di],y:TOP-22,'font-size':7,'font-weight':800,fill:'#6A6963','text-anchor':'middle',
  1136. 'letter-spacing':'.08em',class:'fade',style:`animation-delay:${di*.06}s`},name);
  1137. txt(s,{x:AX[di]+8,y:TOP-5,'font-size':6.5,'font-weight':600,fill:'#C6C5BF',
  1138. class:'fade',style:`animation-delay:${.1+di*.06}s`},hi);
  1139. txt(s,{x:AX[di]+8,y:BOT+11,'font-size':6.5,'font-weight':600,fill:'#C6C5BF',
  1140. class:'fade',style:`animation-delay:${.1+di*.06}s`},lo);
  1141. });
  1142. P.forEach((p,i)=>{
  1143. const isHero=p===hero;
  1144. const ys=[mapY(0,p[1]),mapY(1,p[2]),mapY(2,p[3]),mapY(3,p[4])];
  1145. let d=`M${AX[0]} ${ys[0].toFixed(1)}`;
  1146. for(let k=0;k<3;k++){
  1147. const mx=(AX[k]+AX[k+1])/2;
  1148. d+=` C${mx} ${ys[k].toFixed(1)} ${mx} ${ys[k+1].toFixed(1)} ${AX[k+1]} ${ys[k+1].toFixed(1)}`;
  1149. }
  1150. const ln=el(s,'path',{d,fill:'none',
  1151. stroke:isHero?INK:'#8F8E88','stroke-width':isHero?2:.65,
  1152. opacity:isHero?1:.5+rnd(i+1,5)*.3,pathLength:1,
  1153. class:'draw',style:`animation-delay:${.2+i*.05}s;animation-duration:.8s`});
  1154. tip(ln,`${p[0]} — $${p[1]} · CSAT ${p[2]} · ${p[3]}% retained · ${p[4]}% growth`);
  1155. ys.forEach((y,di)=>el(s,'circle',{cx:AX[di],cy:y,r:isHero?3:1.4,
  1156. fill:isHero?INK:'#8F8E88',opacity:isHero?1:.7,
  1157. class:'pop',style:`animation-delay:${.3+i*.05+di*.04}s`}));
  1158. if(isHero)
  1159. txt(s,{x:AX[3]+12,y:ys[3]+3,'font-size':9,'font-weight':800,fill:INK,
  1160. style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
  1161. class:'fade'},p[0]);
  1162. });
  1163. txt(s,{x:200,y:302,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
  1164. 'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
  1165. 'ONE HAIRLINE = ONE PRODUCT · INK = BEST ACROSS THE BOARD');
  1166. });
  1167. })();
  1168. })();
  1169. </script>
  1170. </body>
  1171. </html>