跳转至

F1 · 梯级柱状图

少类目比较的 Lupi Basics 版本:远看保留柱状图剪影,近看柱身由一格格真实单位组成。点击图表可以重播入场动画。

数据契约

  • 数据形状:2–8 个类目的非负数值比较
  • 场合:年报、故事页、产品复盘
  • 阅读时间:约 30 秒
  • 视觉编码:柱高与数值成正比;一根横档 = 一个 $1k MRR
  • 适用边界:数值必须能拆成读者理解的诚实单位;极端值不使用断轴

模板要点

  • 柱身不是实心色块,而是一列可数的横档;每第五档用侧边小点辅助计数
  • 横档宽度与透明度只做确定性微扰,刷新后保持一致
  • 标题、副标题、图、来源行四件套固定;标题写结论,不写“柱状图”
  • 颜色只使用纸灰、炭黑和中间灰阶,不使用渐变、阴影或装饰色
  • 滚入视野才播放,点击重播;prefers-reduced-motion 下关闭动画

模板来源:Lieflat Charts F1 Rung Bars,真实骨架取自 templates/basics-gallery.html 的 “Revenue by plan, rung by rung” 卡片与对应渲染块。

f1-rung-bars.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 — F1 梯级柱状图</title>
<link rel="stylesheet" href="../../../../vendor/fonts/fonts.css">
<style>
  :root {
    --paper: #F0EFEB;
    --ink: #1C1C1A;
    --muted: #8F8E88;
    --faint: #C6C5BF;
    --grid: #DEDDD6;
  }
  * { box-sizing: border-box; }
  html, body { min-height: 100%; margin: 0; }
  body {
    display: grid;
    place-items: center;
    padding: 28px;
    background: var(--paper);
    color: var(--ink);
    font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
    -webkit-font-smoothing: antialiased;
  }
  .card {
    width: min(100%, 760px);
    min-height: 520px;
    padding: 38px 42px 24px;
    border-radius: 24px;
    background: var(--paper);
  }
  .badge {
    display: inline-block;
    margin-bottom: 13px;
    padding: 4px 11px;
    border: 1px dashed #B0AFA9;
    border-radius: 99px;
    color: var(--muted);
    font-size: 10px;
    font-weight: 700;
    letter-spacing: .08em;
  }
  h2 {
    margin: 0 0 5px;
    font-size: clamp(19px, 3vw, 27px);
    font-weight: 700;
    letter-spacing: -.03em;
  }
  .sub {
    margin-bottom: 18px;
    color: var(--muted);
    font-size: 12px;
    line-height: 1.6;
  }
  svg {
    display: block;
    width: 100%;
    max-height: 350px;
    margin: 0 auto;
  }
  svg text { font-family: inherit; }
  .src {
    margin-top: 10px;
    color: var(--faint);
    font-size: 9.5px;
    font-weight: 600;
    letter-spacing: .1em;
  }
  .hint {
    float: right;
    color: var(--muted);
    font-weight: 500;
    letter-spacing: .04em;
  }
  .fade { animation: fade .9s cubic-bezier(.165,.84,.44,1) both; }
  @keyframes fade {
    from { opacity: 0; transform: translateY(5px); }
  }
  @media (max-width: 560px) {
    body { padding: 10px; }
    .card { min-height: 500px; padding: 26px 18px 20px; }
    .hint { display: none; }
  }
  @media (prefers-reduced-motion: reduce) {
    .fade { animation: none !important; }
  }
</style>
</head>
<body>
<main class="card">
  <span class="badge">LUPI BASICS · F1</span>
  <h2>免费版仍是收入升级的最大入口</h2>
  <div class="sub">一根横档 = $1k MRR · 侧边圆点标记每第五档 · 2026 Q2</div>
  <svg id="rungs" viewBox="0 0 400 320" role="img"
       aria-label="六种方案的月度经常性收入梯级柱状图"></svg>
  <div class="src">
    RUNG BARS · MONO-BASIC · BILLING
    <span class="hint">点击图表重播</span>
  </div>
</main>
<script>
const DATA = [
  ['FREE', 38],
  ['STARTER', 27],
  ['PRO', 22],
  ['TEAM', 16],
  ['SCALE', 11],
  ['ENT', 7]
];

const INK = '#1C1C1A';
const MUTED = '#8F8E88';
const GRID = '#DEDDD6';
const NS = 'http://www.w3.org/2000/svg';

const el = (parent, tag, attrs) => {
  const node = document.createElementNS(NS, tag);
  for (const key in attrs) node.setAttribute(key, attrs[key]);
  parent.appendChild(node);
  return node;
};

const txt = (parent, attrs, value) => {
  const node = el(parent, 'text', attrs);
  node.textContent = value;
  return node;
};

const tip = (node, value) => {
  const title = document.createElementNS(NS, 'title');
  title.textContent = value;
  node.appendChild(title);
};

const rnd = (i, k) =>
  Math.abs(((i * 73856093) ^ (k * 19349663)) % 1000) / 1000;

function render(svg) {
  svg.innerHTML = '';
  const x0 = i => 56 + i * 56;
  const base = 266;
  const step = 5.6;
  const halfWidth = 14;

  DATA.forEach(([name, value], i) => {
    const x = x0(i);

    for (let k = 0; k < value; k++) {
      const y = base - k * step;
      const width = halfWidth - 1.5 + rnd(k + 1, i + 2) * 3;
      el(svg, 'line', {
        x1: x - width,
        y1: y,
        x2: x + width,
        y2: y,
        stroke: INK,
        'stroke-width': 1,
        opacity: .5 + rnd(k + 2, i + 4) * .5,
        class: 'fade',
        style: `animation-delay:${i * .08 + k * .012}s`
      });

      if (k % 5 === 4) {
        el(svg, 'circle', {
          cx: x + halfWidth + 4.5,
          cy: y,
          r: .8,
          fill: '#C6C5BF',
          class: 'fade',
          style: `animation-delay:${i * .08 + k * .012}s`
        });
      }
    }

    const topY = base - (value - 1) * step;
    const number = txt(svg, {
      x,
      y: topY - 10,
      'font-size': 11,
      'font-weight': 800,
      fill: INK,
      'text-anchor': 'middle',
      class: 'fade',
      style: `animation-delay:${.4 + i * .08}s`
    }, value);
    tip(number, `${name} — $${value}k MRR`);

    txt(svg, {
      x,
      y: base + 18,
      'font-size': 7.5,
      'font-weight': 700,
      fill: MUTED,
      'text-anchor': 'middle',
      'letter-spacing': '.08em',
      class: 'fade',
      style: `animation-delay:${i * .08}s`
    }, name);
  });

  el(svg, 'line', {
    x1: 28,
    y1: base + 4,
    x2: 372,
    y2: base + 4,
    stroke: GRID,
    'stroke-width': .8,
    class: 'fade'
  });

  txt(svg, {
    x: 200,
    y: 306,
    'font-size': 7,
    'font-weight': 600,
    fill: '#B0AFA9',
    'text-anchor': 'middle',
    'letter-spacing': '.12em',
    class: 'fade',
    style: 'animation-delay:.9s'
  }, 'ONE RUNG = $1K · DOT MARKS EVERY FIFTH');
}

const chart = document.getElementById('rungs');
const observer = new IntersectionObserver(entries => {
  if (entries[0].isIntersecting) {
    render(chart);
    observer.disconnect();
  }
}, { threshold: .3 });

observer.observe(chart);
chart.style.cursor = 'pointer';
chart.addEventListener('click', () => render(chart));
</script>
</body>
</html>