---
title: "手绘风图表 + 大图查看器"
summary: "文章里的流程图画成手绘风 SVG，点开是能滚轮 / 双指缩放、拖动平移、Esc 关闭的大图查看器。"
category: "visual (图表与 3D)"
origin: "original"
stack: ["html","css","javascript","svg"]
tags: ["图表","手绘","流程图","图片查看器"]
demo: "https://zhuoqidev.com/design/demos/hand-drawn-diagrams/"
url: "https://zhuoqidev.com/design/hand-drawn-diagrams/"
license: "MIT"
added: "2026-10-14"
translation: "https://zhuoqidev.com/en/design/hand-drawn-diagrams/index.md"
---

# 手绘风图表 + 大图查看器

> 文章里的流程图画成手绘风 SVG，点开是能滚轮 / 双指缩放、拖动平移、Esc 关闭的大图查看器。

## 看点

- **手绘风**：线条每笔画两遍、有一点抖，色块用斜线「涂」出来，配楷体一类的手写字体，比正规流程图亲切得多。
- **页面上只是图片**：图在构建时就画好了，正文里只放一张 `<img>`，不加载任何画图库，也不会出现「先闪一下代码再变成图」。
- **图纸不跟主题变**：深色模式下图依旧画在浅色纸上（`--sheet`），线条颜色不用为深色另配一套。
- **打开即适应窗口**：查看器打开时按图片宽高比算出「刚好放进窗口」的宽度（CSS 变量 `--r`），再用 `--s` 在 1–4 倍之间缩放。
- **缩放不跑偏**：滚轮、双指、双击都以光标（或两指中点）为中心放大，放大前后这一点在屏幕上不动，看细节不用再找位置。
- **操作齐全**：− / + 按钮、滚轮（触控板捏合也行）、双指、双击切换、键盘 `+ − 0` 和方向键；拖动平移；Esc 或点图外关闭，拖完松手那一下不会误关。

## 怎么用

1. 准备图：用 Mermaid 写图的文字源，构建时用无头浏览器加载 `@excalidraw/mermaid-to-excalidraw` 把它转成 Excalidraw 元素，再用 `@excalidraw/excalidraw` 的 `exportToSvg` 导出 SVG（导出的 SVG 自带手写字体，只含用到的字）。同时把场景存成 `.excalidraw` 文件，想手改就拖进 excalidraw.com 改完再导出。示例里的两张图是按同样风格预先画好的静态 SVG。
2. 文章里每张图写成 `<figure class="diagram"><img src="xxx.svg" width height alt></figure>`，`width` / `height` 写上图的原始尺寸，避免加载时页面跳动。
3. 拷贝 `dialog.zoom` 这段 HTML、相关 CSS 和脚本。脚本会自动把 `.diagram img` 包进按钮，点击就打开查看器。
4. 想让普通照片也能点开，把选择器改成你文章里图片的选择器即可；照片可以给查看器换一张更大的图片地址。

## 坑

- 打开前就要知道图片宽高比，不然大图还没加载完时没法算「适应窗口」的尺寸；SVG 写上 `width` / `height` 属性就有 `naturalWidth`。
- 舞台要 `touch-action: none`，否则手机上双指会缩放整个页面而不是这张图。
- 拖动结束时浏览器还会补发一次 `click`，要记下松手时间，300ms 内的背景点击不关闭查看器。
- Mermaid 转 Excalidraw 时，量文字宽度前必须先把手写字体加载好，否则框按后备字体算宽，字会撑出框外。
- 中文子图名会让转换器找不到子图，转换前先把非 ASCII 的子图 id 换成英文。

## 参考实现（完整源码）

在线演示：https://zhuoqidev.com/design/demos/hand-drawn-diagrams/。单文件，存成 .html 就能打开。

### index.html

```html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>手绘风图表与大图查看器</title>
    <style>
      :root {
        --paper: #fbf8f1;
        --paper-2: #f3eee2;
        --card: #fffdf8;
        --ink: #1c2230;
        --ink-2: #4a5163;
        --ink-3: #636877;
        --line: #e6dfd0;
        --dot: #d8d0bf;
        --blue: #2f5bd3;
        --seal: #c0391f;
        --sheet: #fffdf8; /* diagrams are drawn on light paper in both themes */
        --shadow: 0 1px 0 rgba(28, 34, 48, 0.04), 0 10px 28px -14px rgba(28, 34, 48, 0.28);
        --spring: linear(
          0,
          0.062,
          0.215,
          0.411,
          0.615,
          0.799,
          0.95,
          1.059,
          1.126,
          1.158,
          1.161,
          1.145,
          1.116,
          1.083,
          1.051,
          1.023,
          1.001,
          0.986,
          0.977,
          0.974,
          0.974,
          0.978,
          0.983,
          0.988,
          0.994,
          0.998,
          1.001,
          1.003,
          1.004,
          1.004,
          1
        );
        --f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
        --f-serif: "Songti SC", STSong, "Noto Serif CJK SC", "Source Han Serif SC", SimSun, serif;
        --f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #14161b;
          --paper-2: #1c1f26;
          --card: #1e2129;
          --ink: #ece7dc;
          --ink-2: #bab6ac;
          --ink-3: #959aa8;
          --line: #31353f;
          --dot: #2c3039;
          --blue: #8eaaff;
          --seal: #f0694e;
          --shadow: 0 1px 0 rgba(0, 0, 0, 0.25), 0 10px 28px -14px rgba(0, 0, 0, 0.8);
          color-scheme: dark;
        }
      }
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
        color: var(--ink);
        font: 16px/1.85 var(--f-sans);
      }
      article {
        max-width: 780px;
        margin: 0 auto;
        padding: 36px 24px 72px;
      }
      .crumb {
        margin-bottom: 14px;
        font: 400 12px/1 var(--f-mono);
        color: var(--ink-3);
      }
      .crumb b {
        color: var(--seal);
        font-weight: 400;
      }
      h1 {
        margin: 0 0 6px;
        font: 900 32px/1.3 var(--f-serif);
      }
      .lede {
        margin: 0 0 4px;
        color: var(--ink-2);
      }
      p {
        color: var(--ink-2);
      }
      code {
        font: 400 0.88em var(--f-mono);
        color: var(--seal);
      }

      /* ---------- a diagram in the article: light paper, dashed edge, click to enlarge ---------- */
      .diagram {
        margin: 1.4em 0;
        padding: 14px;
        background: var(--sheet);
        border: 1px dashed var(--ink-3);
        border-radius: 12px;
      }
      .diagram img {
        display: block;
        max-width: 100%;
        height: auto;
        margin-inline: auto;
      }
      .diagram figcaption {
        margin-top: 6px;
        text-align: center;
        font: 400 12px/1.5 var(--f-mono);
        color: #6b7080;
      }
      .zoom-btn {
        display: block;
        width: 100%;
        padding: 0;
        border: none;
        background: none;
        cursor: zoom-in;
      }
      .zoom-btn:focus-visible {
        outline: 2px solid var(--blue);
        outline-offset: 4px;
        border-radius: 10px;
      }

      /* ---------- the viewer ----------
         It opens with the whole picture in the window: the image takes the largest size that fits
         both the width and the height (--r is its aspect ratio, set on open), and --s (1 = fit, up
         to 4) zooms from there. Zoomed in, the stage scrolls. 26px = padding + border; 70px adds
         the bar. */
      dialog.zoom {
        --zoom-w: min(96vw, 1600px);
        --zoom-h: 94vh;
        width: fit-content;
        max-width: var(--zoom-w);
        max-height: var(--zoom-h);
        padding: 12px;
        overflow: hidden;
        border: 1px solid var(--line);
        border-radius: 14px;
        background: var(--sheet);
        box-shadow: var(--shadow);
      }
      @supports (height: 1dvh) {
        dialog.zoom {
          --zoom-h: 94dvh;
        }
      }
      dialog.zoom[open] {
        animation: pop 0.45s var(--spring);
      }
      @keyframes pop {
        from {
          opacity: 0;
          scale: 0.94;
        }
      }
      dialog.zoom::backdrop {
        background: rgba(10, 12, 16, 0.66);
        -webkit-backdrop-filter: blur(4px);
        backdrop-filter: blur(4px);
      }
      .zoom-bar {
        display: flex;
        align-items: center;
        justify-content: flex-end;
        gap: 6px;
        height: 32px;
        margin-bottom: 8px;
      }
      .zoom-bar form {
        margin: 0;
      }
      .chip-btn {
        padding: 7px 11px;
        border: 1px dashed #8b90a0;
        border-radius: 10px;
        background: var(--sheet);
        color: #1c2230;
        font: 500 13px/1 var(--f-sans);
        white-space: nowrap;
        cursor: pointer;
        transition: scale 0.45s var(--spring);
      }
      .chip-btn:hover {
        border-style: solid;
      }
      .chip-btn:active {
        scale: 0.93;
      }
      .chip-btn:disabled {
        opacity: 0.4;
        cursor: default;
      }
      [data-zoom="in"],
      [data-zoom="out"] {
        min-width: 34px;
        font-weight: 600;
      }
      .zoom-level {
        min-width: 84px;
        font-variant-numeric: tabular-nums;
      }
      .zoom-hint {
        margin-right: auto;
        padding-right: 10px;
        font: 400 11px/1.3 var(--f-mono);
        color: #6b7080;
        white-space: nowrap;
      }
      .zoom-stage {
        max-width: calc(var(--zoom-w) - 26px);
        max-height: calc(var(--zoom-h) - 70px);
        overflow: auto;
        overscroll-behavior: contain;
        touch-action: none;
        cursor: zoom-in;
      }
      .zoom-stage.zoomed {
        cursor: grab;
      }
      .zoom-stage.dragging {
        cursor: grabbing;
      }
      dialog.zoom img {
        display: block;
        max-width: none;
        height: auto;
        margin-inline: auto;
        user-select: none;
        -webkit-user-drag: none;
        width: calc(min(var(--zoom-w) - 26px, (var(--zoom-h) - 70px) * var(--r, 1.5)) * var(--s, 1));
      }

      @media (max-width: 720px) {
        .zoom-hint {
          display: none;
        }
      }
      @media (max-width: 560px) {
        article {
          padding: 28px 16px 56px;
        }
        h1 {
          font-size: 26px;
        }
        .diagram {
          padding: 8px;
        }
        .zoom-level {
          min-width: 0;
        }
      }
      @media (prefers-reduced-motion: reduce) {
        dialog.zoom[open] {
          animation: none;
        }
        .chip-btn {
          transition: none;
        }
      }
    </style>
  </head>
  <body>
    <article>
      <div class="crumb">~/notes/ <b>cat 手绘图表.md</b></div>
      <h1>图表也可以像手画的</h1>
      <p class="lede">文章里的流程图画成手绘风：线条有点抖、色块是斜线涂的。点一下图，在大图里缩放、拖着看细节。</p>

      <figure class="diagram">
        <img
          src="flow.svg"
          decoding="sync"
          width="980"
          height="330"
          alt="流程图：写草稿，自己读一遍，有错别字就改一改再读一遍，没有就排版、配图，然后发布"
        />
        <figcaption>图 1 · 发一篇文章的流程（点图放大）</figcaption>
      </figure>

      <p>
        写文章的时候只写 Mermaid 文本，构建时在无头浏览器里把它转成 Excalidraw 场景，再导出 SVG。页面上只是一张普通的
        <code>&lt;img&gt;</code>，不跑任何画图脚本。
      </p>

      <figure class="diagram">
        <img
          src="pipeline.svg"
          decoding="sync"
          width="760"
          height="700"
          alt="流程图：Mermaid 代码块在构建时转换成 Excalidraw 场景和手绘风 SVG，场景改完可以重新导出，页面只放一张图片，点开能缩放拖动"
        />
        <figcaption>图 2 · 这些图是怎么来的（点图放大）</figcaption>
      </figure>

      <p>
        查看器打开时整张图刚好放进窗口，最多放大到 4 倍；按钮、滚轮、双指、双击和键盘（+ − 0、方向键）都能操作，Esc
        或点图外面关闭。
      </p>
    </article>

    <dialog class="zoom" aria-label="查看大图">
      <div class="zoom-bar">
        <span class="zoom-hint">滚轮或双指缩放 · 拖动平移 · 双击切换</span>
        <button class="chip-btn" type="button" data-zoom="out" aria-label="缩小">−</button>
        <button class="chip-btn zoom-level" type="button" data-zoom="fit" data-fit="适应窗口">适应窗口</button>
        <button class="chip-btn" type="button" data-zoom="in" aria-label="放大">+</button>
        <form method="dialog">
          <button class="chip-btn" type="submit" autofocus>关闭 ✕</button>
        </form>
      </div>
      <div class="zoom-stage">
        <img alt="" decoding="async" draggable="false" />
      </div>
    </dialog>

    <script>
      // Click a diagram to see it large. The viewer opens with all of the picture in the window and
      // zooms from there: the − / + buttons, the wheel or a pinch, a double-click, or the + − 0 keys;
      // drag to pan.
      const dialog = document.querySelector("dialog.zoom");
      if (dialog && typeof dialog.showModal === "function") {
        const stage = dialog.querySelector(".zoom-stage");
        const view = stage.querySelector("img");
        const btn = (k) => dialog.querySelector(`[data-zoom="${k}"]`);
        const [zoomOut, zoomFit, zoomIn] = [btn("out"), btn("fit"), btn("in")];
        const MAX = 4;
        const STEP = 1.5;
        let scale = 1;

        const centre = () => {
          const r = stage.getBoundingClientRect();
          return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
        };
        const sync = () => {
          zoomOut.disabled = scale <= 1;
          zoomIn.disabled = scale >= MAX;
          zoomFit.textContent = scale <= 1 ? zoomFit.dataset.fit : `${Math.round(scale * 100)}%`;
          stage.classList.toggle("zoomed", scale > 1);
        };
        // Zoom so that the picture point under `at` (a cursor, a pinch, the stage centre) stays put.
        const zoomTo = (next, at = centre()) => {
          next = Math.min(MAX, Math.max(1, next));
          if (Math.abs(next - scale) < 0.001) return;
          const a = view.getBoundingClientRect();
          const fx = (at.x - a.left) / a.width;
          const fy = (at.y - a.top) / a.height;
          scale = next;
          view.style.setProperty("--s", String(scale));
          const b = view.getBoundingClientRect();
          stage.scrollLeft += b.left + fx * b.width - at.x;
          stage.scrollTop += b.top + fy * b.height - at.y;
          sync();
        };

        // Wrap each diagram image in a button, so it opens with a click, Enter or Space.
        for (const img of document.querySelectorAll(".diagram img")) {
          const opener = document.createElement("button");
          opener.type = "button";
          opener.className = "zoom-btn";
          opener.setAttribute("aria-label", `查看大图：${img.alt}`);
          img.replaceWith(opener);
          opener.append(img);
          opener.addEventListener("click", () => {
            // The aspect ratio is needed before the large copy loads, to fit it in the window.
            const ratio =
              img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : img.width / img.height;
            if (ratio > 0) view.style.setProperty("--r", String(ratio));
            else view.style.removeProperty("--r");
            scale = 1;
            view.style.removeProperty("--s");
            view.alt = img.alt;
            view.src = img.currentSrc || img.src;
            dialog.showModal();
            stage.scrollTo(0, 0);
            sync();
          });
        }

        zoomIn.addEventListener("click", () => zoomTo(scale * STEP));
        zoomOut.addEventListener("click", () => zoomTo(scale / STEP));
        zoomFit.addEventListener("click", () => zoomTo(1));
        dialog.addEventListener("keydown", (e) => {
          if (e.ctrlKey || e.metaKey || e.altKey) return;
          const pan = { ArrowLeft: [-80, 0], ArrowRight: [80, 0], ArrowUp: [0, -80], ArrowDown: [0, 80] }[e.key];
          if (e.key === "+" || e.key === "=") zoomTo(scale * STEP);
          else if (e.key === "-" || e.key === "_") zoomTo(scale / STEP);
          else if (e.key === "0") zoomTo(1);
          else if (pan && scale > 1) stage.scrollBy(pan[0], pan[1]);
          else return;
          e.preventDefault();
        });
        // A wheel notch is ~100px and zooms ~16%; a trackpad pinch sends small deltas with ctrlKey set.
        stage.addEventListener(
          "wheel",
          (e) => {
            e.preventDefault();
            const per = e.deltaMode === 1 ? 0.05 : e.ctrlKey ? 0.01 : 0.0015;
            zoomTo(scale * Math.exp(-e.deltaY * per), { x: e.clientX, y: e.clientY });
          },
          { passive: false },
        );
        stage.addEventListener("dblclick", (e) => zoomTo(scale > 1 ? 1 : 2.5, { x: e.clientX, y: e.clientY }));

        // One pointer pans (mouse drag or one finger), two pinch. touch-action: none hands touches to us.
        const pointers = new Map();
        let pinch = null;
        let dragged = 0; // when the last drag ended, so its trailing click doesn't close the viewer
        let travel = 0;
        const spread = () => {
          const [a, b] = [...pointers.values()];
          return Math.hypot(a.x - b.x, a.y - b.y) || 1;
        };
        stage.addEventListener("pointerdown", (e) => {
          if (e.pointerType === "mouse" && e.button !== 0) return;
          e.preventDefault();
          stage.setPointerCapture(e.pointerId);
          pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
          travel = 0;
          pinch = pointers.size === 2 ? { d: spread(), s: scale } : null;
        });
        stage.addEventListener("pointermove", (e) => {
          const last = pointers.get(e.pointerId);
          if (!last) return;
          const now = { x: e.clientX, y: e.clientY };
          pointers.set(e.pointerId, now);
          if (pinch && pointers.size === 2) {
            const [a, b] = [...pointers.values()];
            zoomTo(pinch.s * (spread() / pinch.d), { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
            travel = Infinity;
          } else if (pointers.size === 1) {
            stage.scrollLeft -= now.x - last.x;
            stage.scrollTop -= now.y - last.y;
            travel += Math.abs(now.x - last.x) + Math.abs(now.y - last.y);
            if (travel > 4) stage.classList.add("dragging");
          }
        });
        const release = (e) => {
          if (!pointers.delete(e.pointerId)) return;
          if (pointers.size < 2) pinch = null;
          if (!pointers.size) {
            stage.classList.remove("dragging");
            if (travel > 4) dragged = e.timeStamp;
          }
        };
        stage.addEventListener("pointerup", release);
        stage.addEventListener("pointercancel", release);

        // A click on the backdrop (outside the picture) closes the viewer; Esc closes it natively.
        dialog.addEventListener("click", (e) => {
          if (e.target === dialog && e.timeStamp - dragged > 300) dialog.close();
        });
        dialog.addEventListener("close", () => {
          view.removeAttribute("src");
          pointers.clear();
          pinch = null;
        });
      }
    </script>
  </body>
</html>
```
