L9 · 气泡年鉴
分类×年份+量+状态,大跨度(手绘 blob)的 Lupi Editorial 真实参考实现。示例结论为“八年工单,汇成一本年鉴”,副标题已经把图例、单位或时间口径写在图旁:气泡面积代表各产品领域每年的客服工单量 · 年份堆叠时气泡相互覆盖 · 深色内核代表升级事件 · 虚线环代表仍处于测试阶段
技术档案
| 项目 | 约定 |
|---|---|
| 数据形状 | 分类×年份+量+状态,大跨度(手绘 blob) |
| 场合 | 年报(带旁注/大事记) |
| 读者时间 | >30s |
| 渲染引擎 | SVG(通栏) |
| 交互与动效 | 滚入视野播放,点击图表重播;数据点带浏览器原生提示 |
| 示例来源行 | BUBBLE ALMANAC · MONO-EDITORIAL2 · SUPPORT DESK |
实现与使用边界
- 页面直接复用 gallery 中 “Eight years of tickets, one almanac” 的卡片结构和同名渲染块,没有按截图重新绘制。
- 标题、副标题、图形、来源行四件套保持原样;数据编码、比例关系、灰阶和动画节奏由原实现决定。
- 使用时只替换数据与文案,并保持“分类×年份+量+状态,大跨度(手绘 blob)”这一数据契约;若数据本体改变,应回到总目录重新选型。
- 颜色限于 Mono 灰阶,面积编码继续使用平方根换算;柱长、位置与角度编码不得断轴或视觉夸大。
模板来源:Lieflat Charts
L9 Bubble Almanac,真实骨架取自templates/lupi-gallery.html的 “Eight years of tickets, one almanac” 卡片与对应渲染块。
l9-bubble-almanac.html —— 自包含单页源码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Lieflat Charts — L9 气泡年鉴</title>
<link rel="stylesheet" href="../../../../vendor/fonts/fonts.css">
<style>
:root{--bg:#F0EFEB;--dark:#1C1C1A;--ink:#1C1C1A;--muted:#8F8E88;--faint:#C6C5BF;--grid:#DEDDD6}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);font-family:'Inter',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1400px;margin:0 auto}
.card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
.card.dark{background:var(--dark);color:#F0EFEB}
.card.dark .sub{color:#8F8E88}
.card.dark .src{color:#55554F}
.card.wide{grid-column:1/-1}
.split{display:grid;grid-template-columns:250px 1fr;gap:30px;align-items:start}
h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
.sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
.src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
.note{font-size:11px;color:#6A6963;line-height:1.65;margin-bottom:16px}
.legend{font-size:9px;letter-spacing:.1em;font-weight:600;color:var(--muted);line-height:2}
svg{width:100%;max-height:330px;display:block;margin:0 auto}
svg text{font-family:'Inter',sans-serif}
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
@keyframes pop{from{transform:scale(0)}to{transform:none}}
.fade{animation:fade .9s ease both}
@keyframes fade{from{opacity:0}}
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
@keyframes draw{to{stroke-dashoffset:0}}
@media (prefers-reduced-motion:reduce){
.pop,.fade{animation:none}
.draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
}
.pagehead{max-width:1400px;margin:0 auto 26px;padding:0 4px}
.pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
.pagehead p{font-size:12px;color:#8F8E88;margin-top:5px;line-height:1.7}
.pagehead .badge{margin:0 2px}
.badge{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.08em;
padding:3px 10px;border-radius:99px;margin-bottom:10px}
.badge.del{background:#1C1C1A;color:#F0EFEB}
.badge.pick{border:1.5px solid #1C1C1A;color:#1C1C1A}
.badge.pair{border:1px dashed #B0AFA9;color:#8F8E88}
.card.dark .badge.del{background:#F0EFEB;color:#1C1C1A}
.card.dark .badge.pick{border-color:#F0EFEB;color:#F0EFEB}
.card.dark .badge.pair{border-color:#55554F;color:#8F8E88}
html,body{min-height:100%;margin:0}
body{display:grid;place-items:center;padding:28px}
.pagehead{display:none}
.grid2{display:block;width:min(100%,920px);max-width:920px;margin:0 auto}
.card,.card.wide{display:block;width:100%;min-height:520px;padding:38px 42px 24px}
.card .split{grid-template-columns:minmax(190px,250px) 1fr}
.wiki-code{display:inline-block;margin-bottom:12px;padding:4px 11px;border:1px dashed #B0AFA9;border-radius:99px;color:#8F8E88;font-size:10px;font-weight:700;letter-spacing:.08em}
.card.dark .wiki-code{border-color:#55554F}
@media(max-width:620px){
body{padding:10px}
.card,.card.wide{min-height:500px;padding:26px 18px 20px}
.card .split{display:block}
.card .note,.card .legend{display:none}
}
</style>
</head>
<body>
<main class="grid2">
<div class="card wide">
<span class="wiki-code">LUPI 编辑型 · L9</span>
<h2>八年工单,汇成一本年鉴</h2>
<div class="sub">气泡面积代表各产品领域每年的客服工单量 · 年份堆叠时气泡相互覆盖 · 深色内核代表升级事件 · 虚线环代表仍处于测试阶段</div>
<svg id="almanac" viewBox="0 0 840 400" preserveAspectRatio="xMidYMid meet" style="max-height:420px"></svg>
<div class="src">BUBBLE ALMANAC · MONO-EDITORIAL2 · SUPPORT DESK</div>
</div>
</main>
<script>
// ── shared Mono tokens ──
const INK='#1C1C1A',PAPER='#F0EFEB',MUTED='#8F8E88',GRID='#DEDDD6';
const NS='http://www.w3.org/2000/svg';
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000; // deterministic
const D2R=Math.PI/180;
const pol=(cx,cy,r,deg)=>[cx+r*Math.cos(deg*D2R),cy+r*Math.sin(deg*D2R)];
const sect=(cx,cy,r0,r1,a0,a1)=>{
const big=a1-a0>180?1:0;
const [xa,ya]=pol(cx,cy,r1,a0),[xb,yb]=pol(cx,cy,r1,a1);
const [xc,yc]=pol(cx,cy,r0,a1),[xd,yd]=pol(cx,cy,r0,a0);
return `M${xa} ${ya} A${r1} ${r1} 0 ${big} 1 ${xb} ${yb} L${xc} ${yc} A${r0} ${r0} 0 ${big} 0 ${xd} ${yd} Z`;
};
// unified reveal: draw when scrolled into view; click replays
const obsReveal=(id,fn)=>{
const n=document.getElementById(id);
const go=()=>{n.innerHTML='';fn(n)};
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
io.observe(n);
n.style.cursor='pointer';
n.addEventListener('click',go);
};
// ════ 3 · bubble almanac ════
// the vintage-almanac feel lives in three things: ledger-paper hairlines
// under everything, bubbles big enough to collide, and marginalia. do all three.
(()=>{
const AREAS=['EDITOR','BOARDS','DOCS','CHAT','FLOWS','VAULT','PAGES','SYNC','GRID','VIEWS','HUB','FORMS'];
const BETA={GRID:2023,VIEWS:2024,HUB:2025,FORMS:2025}; // beta until this year
const BORN={EDITOR:2019,BOARDS:2019,DOCS:2019,CHAT:2020,FLOWS:2020,VAULT:2021,PAGES:2021,SYNC:2022,GRID:2022,VIEWS:2023,HUB:2024,FORMS:2024};
obsReveal('almanac',s=>{
const rowY=i=>62+i*34,colX=j=>132+j*57;
// ledger paper: hairlines every 6.8px, edge to edge, under everything
for(let y=40;y<=318;y+=6.8)
el(s,'line',{x1:56,y1:y,x2:822,y2:y,stroke:'#E4E3DC','stroke-width':.5,
class:'fade',style:`animation-delay:${(y-40)*.001}s`});
const vals=[];
for(let i=0;i<8;i++){
const yr=2019+i;
el(s,'line',{x1:56,y1:rowY(i),x2:822,y2:rowY(i),stroke:'#D3D2CA','stroke-width':.9,
class:'fade',style:`animation-delay:${i*.04}s`});
txt(s,{x:48,y:rowY(i)+3,'font-size':9,'font-weight':700,fill:'#8F8E88','text-anchor':'end',
class:'fade',style:`animation-delay:${i*.04}s`},yr);
AREAS.forEach((a,j)=>{
if(yr<BORN[a])return;
const age=yr-BORN[a];
// steep spread: quiet areas stay pinpricks, busy ones balloon
const v=Math.round((2+age*5)*(0.25+rnd(i*12+j+1,j+3)**2*2.2));
vals.push([Math.max(1,v),i,j,a,yr]);
});
}
// draw small over large so the giants sit underneath as washes
vals.sort((a,b)=>b[0]-a[0]);
const top3=vals.slice(0,3);
// hand-drawn circle: radius wobbles around the rim on two slow sine waves,
// so every bubble is its own slightly-off print — never a compass circle
const blob=(x,y,r,seed)=>{
const n=Math.max(14,Math.round(r*1.6)),pts=[];
for(let t=0;t<n;t++){
const a=t/n*Math.PI*2;
const w=1+.055*Math.sin(a*2+seed*7)+.04*Math.sin(a*3+seed*13)+(rnd(seed+t,3)-.5)*.03;
pts.push([x+Math.cos(a)*r*w,y+Math.sin(a)*r*w]);
}
let d=`M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for(let t=0;t<n;t++){
const p=pts[t],q=pts[(t+1)%n];
d+=` Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${((p[0]+q[0])/2).toFixed(1)} ${((p[1]+q[1])/2).toFixed(1)}`;
}
return d+' Z';
};
vals.forEach(([v,i,j,a,yr],k)=>{
const x=colX(j)+(rnd(i*7+j+2,j+9)-.5)*10, // jitter off the gridline
y=rowY(i),r=Math.sqrt(v)*3.8; // big: rows are 34 apart, giants reach ~28 and collide
const beta=BETA[a]&&yr<=BETA[a];
const g=el(s,'g',{class:'pop',style:`animation-delay:${.15+k*.012}s`});
const seed=i*12+j;
const dot=el(g,'path',beta
?{d:blob(x,y,r,seed),fill:'none',stroke:'#8F8E88','stroke-width':1,'stroke-dasharray':'3 3'}
:{d:blob(x,y,r,seed),fill:INK,'fill-opacity':.09+rnd(i+2,j+4)*.1,
stroke:INK,'stroke-opacity':.3+rnd(i+5,j+7)*.35,'stroke-width':.6+rnd(i+3,j+11)*.9});
tip(dot,`${a} · ${yr} — ${v*10} tickets${beta?' (beta)':''}`);
// escalation core: small wobbly blot, nudged off-center like a press mark
if(!beta){
const ox=(rnd(seed+1,17)-.5)*r*.3,oy=(rnd(seed+3,19)-.5)*r*.3;
el(g,'path',{d:blob(x+ox,y+oy,Math.max(1.4,r*.17),seed+29),fill:INK});
}
if(top3.includes(vals[k]))
txt(s,{x,y:y-r-5,'font-size':8.5,'font-weight':800,fill:INK,'text-anchor':'middle',
class:'fade',style:'animation-delay:1s'},v*10);
});
AREAS.forEach((a,j)=>{
const x=colX(j);
txt(s,{x,y:34,'font-size':7.5,'font-weight':700,fill:MUTED,'letter-spacing':'.1em',
transform:`rotate(-32 ${x} 34)`,class:'fade',style:`animation-delay:${j*.03}s`},a);
});
// marginalia: two annotations with pointer hairlines, like the original's notes
el(s,'path',{d:`M${colX(3)+30} ${rowY(5)-14} C${colX(3)+70} ${rowY(5)-34} ${colX(3)+96} ${rowY(5)-38} ${colX(3)+118} ${rowY(5)-38}`,
fill:'none',stroke:'#B0AFA9','stroke-width':.7,class:'fade',style:'animation-delay:1.1s'});
txt(s,{x:colX(3)+124,y:rowY(5)-35,'font-size':7,fill:'#6A6963','font-style':'italic',
class:'fade',style:'animation-delay:1.1s'},'chat tickets triple after mobile GA');
el(s,'path',{d:`M${colX(9)+16} ${rowY(2)+8} C${colX(9)+40} ${rowY(2)+26} ${colX(9)+60} ${rowY(2)+30} ${colX(9)+80} ${rowY(2)+30}`,
fill:'none',stroke:'#B0AFA9','stroke-width':.7,class:'fade',style:'animation-delay:1.2s'});
txt(s,{x:colX(9)+86,y:rowY(2)+33,'font-size':7,fill:'#6A6963','font-style':'italic',
class:'fade',style:'animation-delay:1.2s'},'betas barely ticket — nobody files bugs on toys');
// footer strip: important events, the almanac's timeline shelf
const evY=356;
el(s,'line',{x1:56,y1:evY-16,x2:822,y2:evY-16,stroke:'#C6C5BF','stroke-width':.8,
class:'fade',style:'animation-delay:1.2s'});
txt(s,{x:56,y:evY-24,'font-size':7,'font-weight':800,fill:'#8F8E88','letter-spacing':'.16em',
class:'fade',style:'animation-delay:1.2s'},'IMPORTANT EVENTS');
[['2020','SLA introduced'],['2022','self-serve help center'],['2023','the GA wave'],['2025','AI deflection live']]
.forEach(([yr,note],k)=>{
const x=90+k*196;
el(s,'line',{x1:x,y1:evY-16,x2:x,y2:evY-6,stroke:'#8F8E88','stroke-width':1,
class:'fade',style:`animation-delay:${1.3+k*.08}s`});
txt(s,{x,y:evY+4,'font-size':8,'font-weight':800,fill:INK,
class:'fade',style:`animation-delay:${1.3+k*.08}s`},yr);
txt(s,{x,y:evY+15,'font-size':7,fill:'#8F8E88',
class:'fade',style:`animation-delay:${1.3+k*.08}s`},note);
});
});
})();
</script>
</body>
</html>