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