GODRICH

フローチャート デザイン 9種 — ノードを線でつなぐ画面

フローチャート デザインとは、処理の箱を画面に並べ、その箱どうしを線でつないで仕事の流れを見せる画面の作り方です。ここに置いた9つは、ライブラリを一つも使わずHTMLとSCSS、数行のJavaScriptだけで組んだノードグラフの部品で、箱を置くところから描いた形をJSONに書き出すところまで、貼ればそのまま動きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並びは流行りの順ではなく、この画面を作る人が出会う順です。まず箱を置き(01)、2点を曲線でつなぎ(02)、その線を自分の手で引いてみて(03)、つなぎ間違いを切り(04)、実行中の道に点を流し(05)、終わった結果を色で示し(06)、いくつかをまとめて選び(07)、床を動かして拡大し(08)、最後に描いた形をJSONへ書き出します(09)。舞台の色も紙・インク・イエロー・オレンジ・インク・紙・イエロー・オレンジ・インクと循環させてあるので、同じ見た目の項目が隣り合いません。つないだ先で実際に何を動かすのかはn8n ワークフロー サンプル9選が受け持ち、画面を左右に割って編集する側の話は画面分割 css レイアウト9選にあります。

01ポート付きノードボックス

箱の左の辺に受け取る点、右の辺に送る点を置き、点にカーソルを乗せるとその点だけが1.5倍にふくらみます。ふくらむ軸は transform-origin: 50% 50%、つまり点そのものの中心なので、大きくなっても箱の辺から横へずれません。紙の床の上で動くのは4つの点だけなので、23回のコマ比較のうち絵が変わったのは12回、累積面積は0.79%にとどまります。

transform-origin: 50% 50%aria-describedbygrid-template-columns
// ポート — 左の辺に受け取る点、右の辺に送る点
.ng__port {
  position: absolute; top: 50%;
  width: 14px; height: 14px; padding: 0;
  border: 2px solid $stage-paper; border-radius: 50%; cursor: pointer;
  // ポートはそれ自体で完結した点 — 大きくなるとき横へずれないよう自分の中心から
  transform-origin: 50% 50%;
  transform: translateY(-50%) scale(1);
  transition: transform $dur-quick $easing;
}
.ng__port--in  { left: -7px;  background: $color; }
.ng__port--out { right: -7px; background: $stage-orange; }
.ng__port:hover,
.ng__port:focus-visible { transform: translateY(-50%) scale(1.5); }

022点をつなぐ曲線

送る点と受け取る点を3次ベジェ1本でつなぎ、制御点の長さを2点の横の距離の半分(最小40)にしてあります。制御点が両端から水平に伸びる決め方なので、「近づける」「遠ざける」で距離が変わっても線は必ずポートの横腹から抜けます。暗いインクの床にイエローの線1本が伸びるだけなので、変わった面積は0.802%と小さいのに、強度の平均は128.0と9つで2番目に高い値になります。パスの文字列の組み立て方はMDN の d 属性が早見表になります。

stroke-dasharraystroke-dashoffsetstroke-linecap: round
// 3次ベジェ — 制御点は2点の横の距離の半分、最小40。
// 制御点が両端から水平に伸びるので、線は必ずポートの横腹へ抜ける
function wirePath(x1, y1, x2, y2) {
  var h = Math.max(40, Math.abs(x2 - x1) * 0.5);
  return { d: 'M' + x1 + ',' + y1 + ' C' + (x1 + h) + ',' + y1 + ' ' + (x2 - h) + ',' + y2 + ' ' + x2 + ',' + y2, handle: h };
}

function layout(shift) {
  nodeB.setAttribute('transform', 'translate(' + shift + ',0)');
  var inX = BASE_IN.x + shift;
  var r = wirePath(OUT.x, OUT.y, inX, BASE_IN.y);
  wire.setAttribute('d', r.d);
  var len = wire.getTotalLength();          // 線の長さを測って点線1コマ分にする
  wire.style.setProperty('--len', len);     // 点線のコマ長は scss が var(--len) で受ける
  dxOut.textContent = String(inX - OUT.x);
  handleOut.textContent = String(Math.round(r.handle));
}

03点から引いてつなぐ

送る点を押して引くと仮の線の先が指について動き、受け取る点の中心から28単位の内側に入ると先端がその点へ吸い付きます。距離の判定も線の引き直しも、getBoundingClientRect() で得た倍率で画面のpxをviewBoxの座標へ戻してから行うため、ステージが縮んでもつながる位置がずれません。イエローの床で仮線と偽のカーソルが動き続ける区間が長く、23回のうち20回で絵が変わり、累積面積は2.515%になります。

pointerdownsetPointerCapturegetBoundingClientRect
// 画面座標 → viewBox 座標。ステージが縮んでも倍率ひとつで戻す
function toLocal(e) {
  var box = svg.getBoundingClientRect();
  var k = VIEW_W / box.width;
  return { x: (e.clientX - box.left) * k, y: (e.clientY - box.top) * k };
}

outPort.addEventListener('pointerdown', function (e) {
  e.preventDefault(); wake();
  outPort.setPointerCapture(e.pointerId);        // 指がポートを外れてもこの点が受け続ける
  dragging = true; snapped = false;
  temp.style.opacity = '1';
  drawTo(toLocal(e));
});
outPort.addEventListener('pointermove', function (e) { if (dragging) drawTo(toLocal(e)); });

04接続線を切る

線にカーソルを乗せると中央にハサミのボタンが浮かび、押すと線が中央から両側へ割れて消えます。線は中央から各ノードへ伸びる2本に分けてあり、stroke-dasharray は半分の実長82.14に合わせた83なので、stroke-dashoffset を83まで動かすと中央から順に空きます。つかむ対象は太さ18の透明な線で、pointer-events: stroke により線の上だけが反応し、曲線に囲まれた内側はクリックが素通りします。

stroke-dashoffsetpointer-events: strokearia-label
// つかむ領域だけ太く — 塗りではなく線の上でだけつかまれる
.ng__hit { fill: none; stroke: transparent; stroke-width: 18; pointer-events: stroke; cursor: pointer; }

.ng__wirewrap:hover .ng__wire,
.ng__wirewrap:focus-within .ng__wire { stroke-width: 5; }
.ng__wirewrap.is-cut .ng__wire { stroke-dashoffset: $wire-len; }

@keyframes ngCut {
  0%, 34% { stroke-dashoffset: 0; }
  56%, 68% { stroke-dashoffset: $wire-len; }
  88%, 100% { stroke-dashoffset: 0; }
}

05線に沿って流れる点

実行中の枝だけがミントに変わり、その道の上を点3つが等間隔で流れます。点の道は offset-path にその線の d をそのまま写した文字列で、SVG要素に付けた offset-path はviewBoxのユーザー座標で解決されるため、線と点の位置が食い違いません。動き続けるのはこの3点と到着のリングだけですが、23回のコマ比較すべてで絵が変わり、強度の平均157.6は9つで最も高い値です。

offset-pathoffset-distanceanimation-fill-mode: backwards
.ng__dot {
  offset-path: path("M122,85 C176,85 224,132 278,132");
  offset-distance: 0%;
  offset-rotate: 0deg;
  fill: $subject-cream;
  animation-name: ngFlow; animation-duration: $duration; animation-timing-function: $easing;
  animation-iteration-count: infinite; animation-fill-mode: backwards;
}
// ディレイは負の値。正のディレイだと撮影窓の終わりで点が道の途中に残り、
// 次の周回の0%へ瞬間移動する — 正のディレイは周期の倍数のときだけループが閉じる
.ng__dot--2 { animation-delay: -0.6667s; }
.ng__dot--3 { animation-delay: -1.3333s; }

@keyframes ngFlow {
  from { offset-distance: 0%; }
  to { offset-distance: 100%; }
}

06ノードの実行状態

待機・実行中・成功・失敗の4状態を、枠の色と左の帯と右のチップの3つで同時に示します。チップは4枚を縦に積んで高さ20pxの倍数だけずらす作りで、差し替えは steps(1, end) のカットなので2つの状態が半透明に溶け合う中間コマが出ません。帯だけは実行中の区間を linear で伸ばし、キーフレームごとにイージングを与え分けています。

steps(1, end)aria-livedata-state
// 状態チップ: 4枚を積んで $cell の倍数だけずらし、1枚だけ見せる
.ng__chips { display: block; height: $cell; overflow: hidden; }
.ng__strip { display: grid; }
.ng__chip {
  display: flex; align-items: center; justify-content: flex-end; gap: $sp-1;
  height: $cell; white-space: nowrap;
  font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: 1;
}

// 静的な経路: data-state がそのまま画面になる
.ng__card[data-state="wait"] .ng__strip { transform: translateY(0); }
.ng__card[data-state="run"] .ng__strip { transform: translateY(-$cell); }
.ng__card[data-state="done"] .ng__strip { transform: translateY(-$cell * 2); }
.ng__card[data-state="fail"] .ng__strip { transform: translateY(-$cell * 3); }

07ドラッグで複数選択

空いた床を押したまま引くと点線の四角が広がり、重なったノードの枠が点灯し、バッジの数字が選んだ個数に変わります。どちらの向きへ引いても描けるように、2点の小さい方を左上、差の絶対値を大きさとして正規化しています。四角が広い面を覆うので累積面積は17.009%まで上がりますが、動く中身は薄い面と細い枠なので強度の平均は61.4と低いままです。

pointermoveMath.minaria-live
function pick(x, y, w, h) {
  var n = 0;
  for (var i = 0; i < nodes.length; i++) {
    var b = rectOf(nodes[i]);
    var hit = b.x < x + w && b.x + b.w > x && b.y < y + h && b.y + b.h > y;
    nodes[i].classList.toggle('is-picked', hit);
    if (hit) n++;
  }
  digits.style.transform = 'translateY(' + (-DIGIT_H * n) + 'px)';
  live.textContent = live.getAttribute('data-tpl').replace('N', String(n));
}
canvas.addEventListener('pointermove', function (e) {
  if (!start) return;
  var c = canvas.getBoundingClientRect();
  var cx = e.clientX - c.left, cy = e.clientY - c.top;
  // どちらの向きへ引いても良いように正規化する — 2点の小さい方が左上、
  // 差の絶対値が大きさ。この2行がないと左・上へ引いたとき四角が描かれない
  var x = Math.min(start.x, cx), y = Math.min(start.y, cy);
  var w = Math.abs(cx - start.x), h = Math.abs(cy - start.y);
  place(x, y, w, h);
  pick(x, y, w, h);
});

08方眼の床を動かして拡大する

床を引くと方眼模様がノードと同じ距離だけ一緒に動き、続いて画面の中心を軸に1.2倍まで拡大します。方眼は background-position、ノード層は transform という別々の仕組みで動くため、キーフレームの時刻もイージングもそろえて両方 linear にしてあります。床の模様が全面で動くので累積面積は54.162%と9つで最大になり、そのぶん一点あたりの変化は薄く、強度の平均は41.6と最も低い値です。

background-positiontransform: scaletransform-origin
.ng__world {
  position: absolute; inset: 0;
  // 拡大はステージの真ん中を軸に — 角に置くと大きくなるのではなく横へずれて伸びて見える
  transform-origin: 50% 50%;
  transform: translate(0, 0) scale(1);
}

// 方眼とノード層は同じキーフレーム時刻・同じイージングであること。
// 片方だけ ease にすると中間コマで距離がずれ、床が滑って見える — だから両方 linear
@keyframes ngGridPan {
  0% { background-position: 0 0; }
  40%, 80% { background-position: -36px -20px; }
  88%, 100% { background-position: 0 0; }
}
@keyframes ngWorldPan {
  0% { transform: translate(0, 0) scale(1); }
  40% { transform: translate(-36px, -20px) scale(1); }
  50%, 80% { transform: translate(-36px, -20px) scale(1.2); }
  88%, 100% { transform: translate(0, 0) scale(1); }
}

09描いた形をJSONにする

画面に置いたノードをそのままたどって座標と接続一覧のJSONを作り、コピーボタンを押すと何行コピーしたかを知らせます。行数は書き出した文字列を改行で分割して数えた実際の値で、navigator.clipboard がない環境では失敗を伝えたうえでコードの範囲を選択状態にして手で拾えるようにします(MDN の Clipboard.writeText())。動くのは最初に流れ込むコード行と通知だけなので、絵が変わったのは23回のうち9回と9つで最も少なくなります。

JSON.stringifynavigator.clipboard.writeTextaria-live
function build() {
  // 画面に置かれたノードをそのままたどってオブジェクトにする — 下の文字は全部ここから出る
  var b = board.getBoundingClientRect();
  var graph = { nodes: [], links: [] };
  for (var i = 0; i < nodes.length; i++) {
    var r = nodes[i].getBoundingClientRect();
    graph.nodes.push({
      id: nodes[i].getAttribute('data-id'),
      x: Math.round(r.left - b.left),
      y: Math.round(r.top - b.top)
    });
  }
  for (var j = 1; j < graph.nodes.length; j++) {
    graph.links.push({ from: graph.nodes[j - 1].id, to: graph.nodes[j].id });
  }
  return JSON.stringify(graph, null, 2);
}

どこで壊れるか — 落とし穴

一番手こずったのは05の流れる点です。周期2秒に対して0秒・0.4秒・0.8秒という正のディレイを与えたところ、撮影窓が閉じる瞬間に2番目の点が道の途中に取り残され、次の周回の先頭で出発点へ瞬間移動しました。正のディレイはその分だけ開始が遅れるので、周期の倍数でないかぎり2秒の窓が閉じるまでに1周を終えられないからです。ディレイを負に変えて0秒・−0.6667秒・−1.3333秒、つまり2秒を3等分した値にすると、アニメーションは最初からその分だけ進んだ状態で始まり、3つの点がどの瞬間も等間隔で並びます。この直し方にしてから、動いたコマは23回の比較のうち23回になりました。負のディレイは animation-fill-mode: backwards とセットで使うのが前提で、これがないと開始前の区間に作成時の姿(ただ置かれただけの円)がそのまま写り込みます。到着を告げるリングも同じ割り算から出ていて、周期は2秒の3分の1にあたる0.6667秒、ディレイも同じ0.6667秒です。こちらは正の値のままで構いません。最初の点が道を渡り終える前にリングが開いてしまわないための待ちで、しかもディレイが自分の周期のちょうど1倍なので、窓の終わりと始まりで位相が食い違いません。正のディレイを使うなら自分の周期の倍数に、それ以外は負のディレイにする — ループが閉じるかどうかはこの2つで決まります。

08は transform に書いた2つの関数の順番でつまずきました。translate(-36px, -20px) scale(1.2) と1行で書くと、要素の側から見て先に効くのは右の scale です。つまりノードのxは、まず舞台の中心200pxから1.2倍に広がり、その後で36px左へ動きます。ノードを左18%(400pxの床で72px)に置いていたときの左端は 200+(72−200)×1.2−36 = 10.4px まで寄り、床の縁まで10pxしか残りませんでした。左を22%(88px)へずらすと 200+(88−200)×1.2−36 = 29.6px となり、拡大が一番深い区間でも余白が残ります。軸そのものを角へ動かすと拡大ではなく横滑りに見えるので、transform-origin は中心に固定したまま座標の側を直すのが正解でした(MDN の transform-origin)。同じ08では倍率の表示にももう一手かかりました。1.0倍と1.2倍の2枚を重ねて不透明度で入れ替えると、両方が薄く見えるコマが必ず1枚残ります。そこで入れ替えを steps(1, end) にして45.5%と83.5%のちょうど2点でだけ切り替えるようにしたので、どのコマを切り出しても数字は1つしか写りません。

残りは見えない場所で起きました。04の代表画像を選ぶ仕組みは、前のコマとの差が一番大きいコマを取るのですが、線が切れている間にノードのカード1枚をまるごと半透明にしていたため、消えるコマと戻るコマの変化面積が拮抗してしまい、代表画像が「切る場面」と「戻る場面」の間で揺れました。カード全体を半透明にするのをやめて受け取る点1つだけの色を変える作りにすると面積の競り合いから外れ、いまは代表画像がハサミのボタンと割れた線に固定され、累積面積も0.899%に落ち着いています。半透明の中間コマは同じ04のボタンにも顔を出しました。ハサミが消えるのは68%、線が戻り始めるのも68%にそろえてあるので、薄くなったボタンが戻りかけの線に重なるコマは1枚も残りません。受け取る点の色替えも steps(1, end) の1コマで済ませてあり、クリームとオレンジが混ざった中間色はどこにも出ません。06では逆の失敗をしていて、案内の文を display: none で隠したら列の幅ごと消えてカードが横に動いたので、不透明度だけを0にして場所は残すように直しました。次は、同じ数字を2か所に書いた瞬間に壊れるパターンです。07のバッジは数字を縦に積んで12pxずつ押し上げる作りで、この12はJavaScript側の DIGIT_H とSCSS側の1枚の高さの両方に書いてあります。片方だけ直すと数字は常に1つぶんずれるので、値を変えるときは必ず2つ一緒に動かします。04にも同じ二重管理があり、半分の曲線の実長は82.14なのに stroke-dasharray は83にしてあります。点線の1コマを線より長く取ると、stroke-dashoffset が線の長さを超えた区間はただの待ち時間になってしまうので、余りは1だけに詰めました。最後は02の英語版で、日本語の「顧客リスト」は短いので気づきませんでしたが、英語のラベルが送る点の円(中心x140・半径6なので134から146を占めます)へ届いており、どのはみ出し検査にも引っかかりませんでした — 語彙の対応表の値を Customers に縮めて解決しています。以上の直しを反映したソース一式は圧縮ファイルにしてあり、解錠に使う文字列は 4y5hy4a5 です。

アクセシビリティ

色はWCAG 2.1の相対輝度から直接計算しました。単色どうしの重なりなので、各行の2つのhexがあれば誰でも同じ値を出せます。

どこ 前景 背景
ノード名 インク #17141a クリームのカード #fff7e6 17.11:1
ノード名 インク #17141a 白いカード #ffffff 18.24:1
ノードの説明(インク74%) #534f4f クリームのカード 7.58:1
凡例(インク78%) #4a484c 白い床 9.04:1
曲線キャプションの数値 イエロー #ffd23f インクの舞台 #17141a 12.63:1
案内文(インク82%) #413621 イエローの舞台 #ffd23f 8.20:1
「実行中」 青 #2a5fd8 白いカード 5.63:1
「成功」 緑 #0f7050 白いカード 6.08:1
「失敗」 赤 #c2361a 白いカード 5.47:1
「待機」(インク62%) #6f6d71 白いカード 5.12:1
ボタンの文字 クリーム #fff7e6 青 #2a5fd8 5.29:1
受け取る点 青 #2f6df6 クリームのカード 4.25:1(図形は3:1で通過・文字には使わない)
送る点 オレンジ #ff4d1f クリームのカード 3.11:1(図形のみ)
届かなかった組み合わせ クリーム #fff7e6 オレンジの舞台 #ff4d1f 3.11:1
きわどい組み合わせ 青 #2f6df6 白 #ffffff 4.53:1

最後の2行が今回の色の決め方を決めました。オレンジの舞台にクリームの文字は3.11:1で本文には足りないので、オレンジの上には文字を置かず、読ませたい語はすべてクリームのカードの中へ入れてあります。素の青は白の上で4.53:1とAAの4.5:1すれすれなので、06の状態の文字だけ一段暗い #2a5fd8 に落として5.63:1にしました。

@media (prefers-reduced-motion: reduce) では、9つとも「形は残して動きだけ止める」に統一しています。01はポートの拍動を止めて scale(1) に固定し、ホバー時の補間も切ります。02は線を描く動きを止め、stroke-dashoffset: 0 すなわち引き終わった線をそのまま見せます。03は偽のカーソルと仮線の動きを止め、確定した線は不透明のまま残し、仮線だけを消します。04は線・ハサミ・ポートの動きを全部止めるので、つながった線とノード3枚が静止画として残ります。05は流れる点と到着リングを止めたうえで、2番目の点を道の40%、3番目を75%に置き直すため、3つの点が道の上に散らばって止まり、線とノードの色はそのままです。06は回るアイコン・帯・枠・チップの動きを止め、実行中のカードの帯だけは scaleY(1) で満たした姿にします。07は四角を開いた状態で残し、3枚のノードの枠を点灯させ、数字を「3個選択」の位置に固定します。08は床のパンも拡大も倍率ラベルの入れ替えも止め、translate(0, 0) scale(1)background-position: 0 0 の初期状態に戻します。09はコード行の流れ込みと通知の動きを止め、行も通知も最初から見えている状態にします。

読み上げへの対応も9つで揃えました。03のポートは role="button"tabindex="0" を持ち、送る点でEnterを押してから受け取る点でEnterを押すと、引く操作をしなくても同じ接続ができます(MDN の Pointer events)。06・07・09の知らせは aria-live="polite" の段落が受け持ち、07は選んだ個数、09はコピーした行数を文として読み上げます。04のハサミはSVGの g なのでマウスで押しただけでもブラウザの枠が出てしまい、:focus の枠を消して :focus-visible のときだけクリームの輪を自分で描いています。

FAQ

ライブラリを入れずに本当に足りますか

この9つはHTMLとSCSS、そして素のJavaScriptだけでできています。曲線は d 属性の文字列、接続の操作はPointer events、書き出しは JSON.stringify で、外から読み込むものは一つもありません。数百ノードを並べて仮想化まで必要になったら専用のライブラリへ移ればよく、それまでの画面はこれで足ります。

スマホのタッチでも引いてつなげますか

つなげます。03と07と08はマウス・指・ペンを区別しないPointer eventsで受けており、03は setPointerCapture で指がポートを外れても同じ要素が受け続けます。08の床は touch-action: none を持っているので、引いている最中にページごとスクロールしてしまうことがありません。

書き出したJSONを自動化ツールへそのまま渡せますか

09が作るのは nodeslinks の2つの配列だけの素朴な形なので、受け取る側の形式に合わせてキーの名前を差し替えるのが前提です。座標は画面上のpxを丸めた値なので、送り先が別の単位を使うならそこで換算します。渡した先で何を動かすかの例はコードブロック UI 9選スマホ ジェスチャー操作 9選と同じく、JavaScriptの記事一覧から辿れます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。