ZhuoQi Devzhuoqidev.comRSSllms.txt

← All entries~/design/hand-drawn-diagrams

Charts & 3DOriginal

Hand-Drawn Diagrams + Image Viewer

Article flowcharts as hand-drawn SVG; click to open a viewer with wheel / pinch zoom, drag to pan, and Esc to close.

html · css · javascript · svgMITAdded #diagrams#hand-drawn#flowchart#image viewer

Live demoOpen in a new tab ↗

What makes it work

  • Hand-drawn look: each stroke is drawn twice with a slight wobble, color fills are “scribbled” with diagonal hatching, and a handwriting font such as a Kai-style typeface is used, which feels much friendlier than a regular flowchart.
  • Just an image on the page: the diagram is drawn at build time, and the article body contains only an <img>. No drawing library is loaded, and there is no “flash of code before it turns into a diagram”.
  • The sheet doesn’t follow the theme: in dark mode the diagram is still drawn on light paper (--sheet), so line colors don’t need a second set for dark.
  • Fits the window when opened: when the viewer opens, it computes the width that “just fits the window” from the image’s aspect ratio (CSS variable --r), then scales between 1x and 4x with --s.
  • Zoom stays put: wheel, pinch and double-click all zoom around the cursor (or the midpoint of the two fingers); that point doesn’t move on screen before and after zooming, so you don’t have to search for your place when inspecting details.
  • Full controls: − / + buttons, wheel (trackpad pinch also works), two-finger pinch, double-click toggle, keyboard + − 0 and arrow keys; drag to pan; Esc or a click outside the image closes it, and the release at the end of a drag won’t close it by mistake.

How to use it

  1. Prepare the diagrams: write the diagram’s text source in Mermaid, and at build time load @excalidraw/mermaid-to-excalidraw in a headless browser to convert it to Excalidraw elements, then export SVG with exportToSvg from @excalidraw/excalidraw (the exported SVG carries the handwriting font, containing only the glyphs used). Also save the scene as a .excalidraw file; to hand-edit, drag it into excalidraw.com, edit, and export again. The two diagrams in the example are static SVGs pre-drawn in the same style.
  2. In the article, write each diagram as <figure class="diagram"><img src="xxx.svg" width height alt></figure>, with width / height set to the diagram’s original size to avoid layout shift while loading.
  3. Copy the dialog.zoom HTML, the related CSS and the script. The script automatically wraps .diagram img in a button, and clicking it opens the viewer.
  4. To make ordinary photos clickable too, change the selector to the one matching images in your articles; for photos you can point the viewer at a larger image URL.

Pitfalls

  • The aspect ratio must be known before opening, otherwise “fit to window” can’t be computed before a large image finishes loading; an SVG with width / height attributes has a naturalWidth.
  • The stage needs touch-action: none, otherwise on phones a two-finger pinch zooms the whole page instead of this image.
  • The browser still fires an extra click at the end of a drag, so record the release time and don’t close the viewer on background clicks within 300ms.
  • When converting Mermaid to Excalidraw, the handwriting font must be loaded before measuring text width, otherwise boxes are sized with the fallback font and text overflows the boxes.
  • Chinese subgraph names make the converter unable to find the subgraph, so replace non-ASCII subgraph ids with English ones before converting.
$For agents: index.md

Source MIT licensed. Take it.

<!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>

Say hi on WeChat

Liu ZhuoQi's WeChat QR code

Scan it in WeChat, or long-press it on your phone

Mention why you're reaching out, or I might miss it.