Angular と SVG で Grid 上に Drag 可能な図形を動的に生成する

はじめに

先日、技術書典マーケットにて「入門GUI ーWebブラウザで作る本格インタラクションー」なる本を書い Angular と SVG で何かしたいなぁと思う最近です。

前回の Angular と SVG で Zoom / Drag ができる Grid を作るの続きとして Angular と SVG で Grid 上に Drag 可能な図形を動的に生成する方法 をまとめます。

先に作った stackblitz を貼っておきます。

stackblitz.com

Angular と SVG で Grid 上に Drag 可能な図形を動的に生成する

前回作成した Grid 上に Button を押すことで Drag 可能な SVG を生成していきます。

動的に SVG NodeLayer を生成する

まずは生成する SVG の Node の Interface を規程します。

export interface NodeLayer {
  id: string;
  width: number;
  height: number;
  positionX: number;
  positionY: number;
  rotate: number;
  color: string;
  rx: number;
  ry: number;
  isSelected: boolean;
  shadowFilter: string;
}

次に html 側に ボタンを置き Click すると上記 Interface の NodeLayer が生成されるようにします。

<div>
  <button mat-raised-button (click)="addNodeLayer()" color="primary">Add</button>
</div>

生成の仕方はいくつかあると思いますが、今回は単純に NodeLayer を配列にして ngFor で回して描画する方法とします。
defs としてかっこいい(?)影を nodeLayer.shadowFilter で切り替えてます。

<svg>
  
  ~ 略 ~

  <defs>
    <filter id="shadow">
      <feDropShadow dx="3" dy="3" stdDeviation="0" flood-color="rgba(0, 0, 0, .5)" flood-opacity="0.5"/>
    </filter>
    <filter id="liftedShadow">
      <feDropShadow dx="4" dy="4" stdDeviation="0" flood-color="rgba(0, 0, 0, .5)" flood-opacity="0.7"/>
    </filter>
  </defs>
  <g *ngFor="let nodeLayer of nodeLayers">
    <rect
      (pointerdown)="downHandleNodeLayer($event, nodeLayer)"
      (click)="clickHandleNodeLayer($event, nodeLayer)"
      attr.id="{{nodeLayer.id}}"
      attr.x="{{ nodeLayer.positionX - nodeLayer.width / 2 }}"
      attr.y="{{ nodeLayer.positionY - nodeLayer.height / 2 }}"
      attr.width="{{ nodeLayer.width }}"
      attr.height="{{ nodeLayer.height }}"
      attr.rx="{{ nodeLayer.rx }}"
      attr.ry="{{ nodeLayer.ry }}"
      stroke="black"
      stroke-width="0.1"
      attr.fill="{{ nodeLayer.color }}"
      attr.filter="{{ nodeLayer.shadowFilter }}">
    </rect>
  </g>

  ~ 略 ~

</svg>

addNodeLayer() は typescript 側で以下のように実装します。
ある程度中央っぽい位置に対して round() 関数を適用することで、前回作成した Grid の間隔 10 刻みで配置できるようにしています。

  ~ 略 ~

  addNodeLayer() {
    const randomMin = 250;
    const randomMax = 500;
    const randomColor = ['white'];
    const w = 100;
    const h = 100;
    const pX = this.round(Math.floor( Math.random() * (randomMax + 1 - randomMin) ) + randomMin);
    const pY = this.round(Math.floor( Math.random() * (randomMax + 1 - randomMin) ) + randomMin);

    const newNodeLayer: NodeLayer = {
      id: uuidv4(),
      width: w,
      height: h,
      positionX: pX,
      positionY: pY,
      rotate: 0,
      color: randomColor[ Math.floor( Math.random() * randomColor.length ) ],
      rx: 10,
      ry: 10,
      isSelected: false,
      shadowFilter: 'url(#shadow)'
    }
    this.nodeLayers.push(newNodeLayer);
  }

  round(v) {
    return Math.round(v / 10) * 10;
  }

  ~ 略 ~

Add ボタンを押すと、以下のように SVG 図形が生成されたはずです。

grid add svg node
grid add svg node

生成した SVG NodeLayer を 削除可能にする

生成した NodeLayer を削除できるようにしてみます。
実際には Click で選択された NodeLayer に対して Delete キーを検知して nodeLayers リストから削除する機能を作っていきます。

生成した SVG NodeLayer を選択できるようにする

選択された NodeLayer は selectedNodeLayers という配列に格納していきます。
下記 clickHandleNodeLayer() で Event と Click された nodeLayer を取得しています。

  <rect
    ~ 略 ~
    (click)="clickHandleNodeLayer($event, nodeLayer)"
    ~ 略 ~
  </rect>

ここでは shiftKey を押しながらクリックされているかを検知し shiftKey が押されていなければ新たにクリックされた nodeLayer のみを selectedNodeLayers の配列に格納し、 shiftKey が押された状態であれば複数の nodeLayer を selectedNodeLayers に格納しています。

また、選択されたことをわかりやすくするため #shadow#liftedShadow で影の濃さを変化させています。

stopPropagation() を忘れると Click Event が Grid まで透過してしまうのでうまく機能しませんので気をつけてください。

  ~ 略 ~

  clickHandleNodeLayer(pointerEvent: PointerEvent, nodeLayer: NodeLayer) {
    pointerEvent.preventDefault();
    pointerEvent.stopPropagation();

    if (!pointerEvent.shiftKey) {
      if (this.selectedNodeLayers.length > 0){
        this.selectedNodeLayers.forEach((selectedSVGLayer: NodeLayer) => {
          selectedSVGLayer.isSelected = false;
          selectedSVGLayer.shadowFilter = 'url(#shadow)';
        });
        this.selectedNodeLayers = [];
      }
    }

    nodeLayer.isSelected = true;
    nodeLayer.shadowFilter = 'url(#liftedShadow)';
    this.selectedNodeLayers.push(nodeLayer);
  }

  ~ 略 ~

また、 Grid をクリックした時に全ての選択された nodeLayer を解除する機能も入れておきます。
grid に clickHandleGrid($event) を登録し Click Event を受け取れるようにしておいて、以下のように selectedNodeLayers 配列をリセットします。

  ~ 略 ~
  clickHandleGrid(pointerEvent: PointerEvent) {
    pointerEvent.preventDefault();
    if (this.selectedNodeLayers.length > 0){
      this.selectedNodeLayers.forEach((selectedNodeLayer: NodeLayer) => {
        selectedNodeLayer.isSelected = false;
        selectedNodeLayer.shadowFilter = 'url(#shadow)';
      });
      this.selectedNodeLayers = [];
    }
  }
  ~ 略 ~

Sfiht キーを押しながら nodeLayer をクリックすることで以下のように選択できたかと思います。

grid add svg node
grid add svg node

選択した SVG NodeLayer を削除できるようにする

選択した NodeLayer の削除を行います。
delete キーの Keyup Evetn を受け取って削除を nodeLayer の isSelected のものを削除します。

  ~ 略 ~
  @HostListener('document:keyup', ['$event'])
  public handleKeyboardEvent(keyboardEvent: KeyboardEvent) {
    keyboardEvent.preventDefault();
    if (keyboardEvent.keyCode === 8 || keyboardEvent.keyCode === 46) {
      if (this.selectedNodeLayers.length > 0){
        this.nodeLayers = this.nodeLayers.filter(nodeLayer => !nodeLayer.isSelected);
      }
    }
  }
  ~ 略 ~

生成した SVG NodeLayer を Drag 可能にする

では次に NodeLayer を Drag できるようにします。
NodeLayer で Pointerdown Event を受け取れるようにします。

<rect
  ~ 略 ~
  (pointerdown)="downHandleNodeLayer($event, nodeLayer)"
  ~ 略 ~
</rect>

これで Drag する対象の NodeLayer が判別できるようになりました。

Pointermove は document に対しての Event として受け取ります。
これは Pointer を早く動かした際に Pointer が NodeLayer から外れてしまった際にも NodeLayer の Drag を継続させたいためです。

@HostListener( 'document:pointermove', [ '$event' ] )
public moveHandle(pointerEvent: PointerEvent){
  pointerEvent.preventDefault();
  pointerEvent.stopPropagation();
  if (!this.isDraggingGrid && this.isDraggingNodeLayer) {
    const viewBoxList = this.svgGrid.nativeElement.getAttribute('viewBox').split(' ');
    const aspX = (parseInt(viewBoxList[2], 10) / 500);
    const aspY = (parseInt(viewBoxList[3], 10) / 500);
    // move NodeLayer
    if (pointerEvent.offsetX) {
      this.draggingNodeLayer.positionX = this.round((pointerEvent.offsetX * aspX) + parseInt(viewBoxList[0], 10)) ;
      this.draggingNodeLayer.positionY = this.round((pointerEvent.offsetY * aspY) + parseInt(viewBoxList[1], 10)) ;
    } else {
      const { left, top } = (pointerEvent.srcElement as Element).getBoundingClientRect();
      this.draggingNodeLayer.positionX = pointerEvent.clientX - left + parseInt(viewBoxList[0], 10);
      this.draggingNodeLayer.positionY = pointerEvent.clientY - top + parseInt(viewBoxList[1], 10);
    }
  }
}

以上で生成した SVG NodeLayer を Drag できるようになりました。

まとめ

Angular と SVG で Grid 上に Drag 可能な図形を動的に生成する方法をまとめました。

次回は NodeLayer を path で結線する機能を実装しようと思います。

Angular 9 で PWA を作る

はじめに

iOS 13.4 の safari から getUserMedia() でカメラデバイスの操作が可能になったという Tweet があったので、せっかくなので Angular9 の Progressive Web Apps (PWA) で検証しました。

{{< githubcard repo="nao50/angular9-pwa" >}}

Angular 9 で PWA

Angular の PWA は以下の記事を参考に進めます。

qiita.com

Angular Project の作成

Angular CLI で angular9-pwa という名前のプロジェクトを作成しました。

$ ng new angular9-pwa --routing --style=scss
$ cd angular9-pwa

Service Worker の導入

project を指定して Service Worker を導入します。
ngsw-config.json に書かれている内容がキャッシュされオフラインで動作できるファイルになります。
manifest.webmanifest はアプリケーションのiconの情報などが設定されます。

$ ng add @angular/pwa --project=angular9-pwa
Installing packages for tooling via npm.
Installed packages for tooling via npm.
CREATE ngsw-config.json (620 bytes)
CREATE src/manifest.webmanifest (1348 bytes)
CREATE src/assets/icons/icon-128x128.png (1253 bytes)
CREATE src/assets/icons/icon-144x144.png (1394 bytes)
CREATE src/assets/icons/icon-152x152.png (1427 bytes)
CREATE src/assets/icons/icon-192x192.png (1790 bytes)
CREATE src/assets/icons/icon-384x384.png (3557 bytes)
CREATE src/assets/icons/icon-512x512.png (5008 bytes)
CREATE src/assets/icons/icon-72x72.png (792 bytes)
CREATE src/assets/icons/icon-96x96.png (958 bytes)
UPDATE angular.json (3983 bytes)
UPDATE package.json (1366 bytes)
UPDATE src/app/app.module.ts (604 bytes)
UPDATE src/index.html (479 bytes)

残念ながら iOS はホーム画面に追加する際のアイコンや statusbar の表示をindex.htmlのhead要素内に書く必要がありました。

<!doctype html>
<html lang="en">
<head>
  ~ 略 ~
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
  <meta name="apple-mobile-web-app-title" content="Angular9 PWA">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <link rel="apple-touch-icon" href="assets/icons/icon-72x72.png" sizes="72x72">
  <link rel="apple-touch-icon" href="assets/icons/icon-96x96.png" sizes="114x114">
  <link rel="apple-touch-icon" href="assets/icons/icon-128x128.png" sizes="120x120">
  <link rel="apple-touch-icon" href="assets/icons/icon-144x144.png" sizes="144x144">
  <link rel="apple-touch-icon" href="assets/icons/icon-192x192.png" sizes="180x180">
  ~ 略 ~
</head>

App Shell の導入

App Shell はアプリケーションの読み込み中に表示する画面です。
project を指定して Angular CLI で導入できます。
パスは何も指定しないと /shell になりますが --route を指定することで変更できます。

$ ng g app-shell --client-project=angular9-pwa
CREATE src/main.server.ts (298 bytes)
CREATE src/app/app.server.module.ts (590 bytes)
CREATE tsconfig.server.json (308 bytes)
CREATE src/app/app-shell/app-shell.component.scss (0 bytes)
CREATE src/app/app-shell/app-shell.component.html (24 bytes)
CREATE src/app/app-shell/app-shell.component.spec.ts (643 bytes)
CREATE src/app/app-shell/app-shell.component.ts (287 bytes)
UPDATE package.json (1408 bytes)
UPDATE angular.json (5149 bytes)
UPDATE src/main.ts (432 bytes)
UPDATE src/app/app.module.ts (715 bytes)

これも残念ながら2020年4月時点では iOS で機能しないようです。

Angular でカメラの起動

以前Angular で jsQR を使ってQRCodeを読み取るという記事を書いていたのでこちらのコードを流用します。

$ npm install jsqr --save

コードは stackblitz にも載せています。
typescript 側で getUserMedia を読んでカメラデバイスを取得しています。
これが iOS にインストールした PWA からも利用できることを確認します。

stackblitz.com

Angular で Github Pages にアップロード

PWA を検証するには HTTPS の環境が必要です。
今回は以下の記事を参考に Github Pages へのアップロードを行います。

swfz.hatenablog.com

$ ng add angular-cli-ghpages
$ ng run angular9-pwa:app-shell --configuration=production
$ ng deploy --base-href=/angular9-pwa/ --noBuild

以下の URL で今回作った PWA をアップロードすることができました。
iOS で確認したところ問題なくカメラの起動と QRコードの検知ができました。

nao50.github.io

まとめ

Angular 9 で PWA を作る方法をまとめました。
iOS 13.4 では getUserMedia からカメラデバイスの利用が可能となっていました。

github.com

Angular と SVG で Zoom / Drag ができる Grid を作る

はじめに

先日、技術書典マーケットにて「入門GUI ーWebブラウザで作る本格インタラクションー」なる本を書いました。
React と SVG でバウンデイングボックスを作る章があり、SVG を使ったとこもない私も楽しむことのできる内容でした。

今回はせっかく学んだことがあったので Angular と SVG を使って Zoom / Drag and Drop ができる Grid を作ります。
要は node editor なんかでよく使われている拡大拡小できる方眼紙 を作ります。

先に作った stackblitz を貼っておきます。

stackblitz.com

Angular と SVG で Grid を描画する

今回は Angular Flex-Layout との組み合わせも試してみます。
Angular Flex-Layout 入門 という記事にインストールから使い方までを書いていますので参考にしてください。

注意点としては Flex-Layout で囲むと svg は display: flex; 内の要素として振舞うことになりますが、その際には width と height を指定する必要があります。
そもそも viewBox のみを指定しておけばレスポンシブに振舞うので特別 Flex-Layout を利用する必要がなければそうすべきかもしれません。

<div fxLayout="column">
  <div fxLayout="row" fxFlex="500px" fxLayoutAlign="center center">
    <svg width="500" height="500" viewBox="250 250 500 500" #svgGrid>
      <defs>
        <pattern id="smallGrid" width="10" height="10" patternUnits="userSpaceOnUse">
          <path d="M 10 0 L 0 0 0 10" fill="none" stroke="gray" stroke-width="0.5"/>
        </pattern>
        <pattern id="grid" width="100" height="100" patternUnits="userSpaceOnUse">
          <rect width="100" height="100" fill="url(#smallGrid)"/>
          <path d="M 100 0 L 0 0 0 100" fill="none" stroke="gray" stroke-width="1"/>
        </pattern>
      </defs>
      <rect width="1001" height="1001" fill="url(#grid)" />
    </svg>
  </div>
</div>

この時点で以下のような SVG のグリッドが描画されます。

rect として 一番大きな外枠の四角を 1001 の大きさで描画し、中身をそれぞれ 100, 10 の大きさの四角で埋めています。
線の太さを少しずつ変えることで方眼紙っぽさ(?)を演出できていますね。

viewBox として viewBox="250 250 500 500" を指定し、1001 の大きさの四角内の中央 500 x 500 を 1:1 で描画しています。 ここら辺は stackblitz のような使い捨て環境でいろいろいじって体感してください。

flex-layout grid
flex-layout grid

Angular と SVG で Grid を Drag できるようにする

次に Grid を Drag できるようにしてみます。
Drag は具体的には viewBox の表示位置 viewBox="x, y, 500, 500" の x と y を変えることで実現します。

html で Pointer Event を取得

まずは html 側で pointer Event を取得できるようにします。

<div fxLayout="column">
  <div fxLayout="row" fxFlex="500px" fxLayoutAlign="center center">

    <svg
      (pointerdown)="downHandleGrid($event)"
      (pointermove)="moveHandleGrid($event)"
      width="500"
      height="500"
      viewBox="250 250 500 500"
      #svgGrid>

      <defs>
        <pattern id="smallGrid" width="10" height="10" patternUnits="userSpaceOnUse">
          <path d="M 10 0 L 0 0 0 10" fill="none" stroke="gray" stroke-width="0.5"/>
        </pattern>
        <pattern id="grid" width="100" height="100" patternUnits="userSpaceOnUse">
          <rect width="100" height="100" fill="url(#smallGrid)"/>
          <path d="M 100 0 L 0 0 0 100" fill="none" stroke="gray" stroke-width="1"/>
        </pattern>
      </defs>
      <rect width="1001" height="1001" fill="url(#grid)" />
    </svg>

  </div>
</div>

typescript で Pointer Event を実装

typescript 側で各 Event に対する振る舞いを実装していきます。

PointerDown Event

PointerDown Event は downHandleGrid() としてドラッグが始まったことを isDraggingGrid に格納し、ドラッグが始まった座標を gridDownClientX / gridDownClientY に格納しています。

PointerMove Event

PointerMove Event は moveHandleGrid() としてドラッグが始まった座標からの差分を計算し viewBox の表示位置を変更しています。

PointerUp Event

PointerUp Event は document要素 に対する HostListener として upHandle() を実装しています。
svg 要素外で PointerUp してもドラッグが終了したことを検知するためです。

import { Component, HostListener, ViewChild, ElementRef } from '@angular/core';
import { Point } from './interfaces/point';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent  {
  @ViewChild('svgGrid', { read: ElementRef }) svgGrid: ElementRef<SVGSVGElement>;

  isDraggingGrid = false;
  gridDownClientX: number;
  gridDownClientY: number;

  @HostListener( 'document:pointerup', [ '$event' ] )
  public upHandle(event: PointerEvent) {
    this.isDraggingGrid = false;
  }

  downHandleGrid(pointerEvent: PointerEvent) {
    this.isDraggingGrid = true;
    pointerEvent.preventDefault();
    this.gridDownClientX = pointerEvent.clientX;
    this.gridDownClientY = pointerEvent.clientY;
  }

  moveHandleGrid(pointerEvent: PointerEvent){
    if (this.isDraggingGrid ) {
      pointerEvent.preventDefault();
      const delta: Point = {
        x: pointerEvent.clientX - this.gridDownClientX,
        y: pointerEvent.clientY - this.gridDownClientY
      };
      this.gridDownClientX = pointerEvent.clientX;
      this.gridDownClientY = pointerEvent.clientY;
      this.updateViewBoxMin(delta.x, delta.y);
    }
  }

  updateViewBoxMin(dx: number, dy: number): void {
    const viewBoxList = this.svgGrid.nativeElement.getAttribute('viewBox').split(' ');
    viewBoxList[0] = '' + this.cutoffDragRange(parseInt(viewBoxList[0], 10) - dx);
    viewBoxList[1] = '' + this.cutoffDragRange(parseInt(viewBoxList[1], 10) - dy);
    const viewBox = viewBoxList.join(' ');
    this.svgGrid.nativeElement.setAttribute('viewBox', viewBox);
  }

  cutoffDragRange(draggingPoint: number): number{
    if (draggingPoint < 0) {
      return 0;
    } else if (draggingPoint > 501) {
      return 501;
    }
    return draggingPoint;
  }
}

これで SVG の Drag が実現できました。
要素数が重ならず少量であればとてもシンプルですね。

Angular と SVG で Grid を Zoom ができるようにする

次に Grid を Zoom ができるようにしてみます。
Zoom は具体的には viewBox の表示位置 viewBox="x, y, width, height" の width と height を変えることで実現します。

html で Wheel Event を取得

まずは html 側で Wheel Event を取得できるようにします。

<div fxLayout="column">
  <div fxLayout="row" fxFlex="500px" fxLayoutAlign="center center">

    <svg
      (pointerdown)="downHandleGrid($event)"
      (pointermove)="moveHandleGrid($event)"
      (wheel)="wheelHandleGrid($event)"
      width="500"
      height="500"
      viewBox="250 250 500 500"
      #svgGrid>

      <defs>
        <pattern id="smallGrid" width="10" height="10" patternUnits="userSpaceOnUse">
          <path d="M 10 0 L 0 0 0 10" fill="none" stroke="gray" stroke-width="0.5"/>
        </pattern>
        <pattern id="grid" width="100" height="100" patternUnits="userSpaceOnUse">
          <rect width="100" height="100" fill="url(#smallGrid)"/>
          <path d="M 100 0 L 0 0 0 100" fill="none" stroke="gray" stroke-width="1"/>
        </pattern>
      </defs>
      <rect width="1001" height="1001" fill="url(#grid)" />
    </svg>

  </div>
</div>

typescript で Pointer Event を実装

typescript 側で Pointer Event に対する振る舞いを実装していきます。

Wheel Event

Wheel Event は wheelHandleGrid() として WheelEvent の発生した座標と Zoom なのか Pan なのか (wheelEvent.deltaY が正負どちらか) を取得し this.scaleFactor = 1.02 という倍率で Zoom / Pan を行っています。

  wheelHandleGrid(wheelEvent: WheelEvent){
    wheelEvent.preventDefault();
    const position = this.getEventPosition(wheelEvent);
    const scale = Math.pow(this.scaleFactor, wheelEvent.deltaY < 0 ? 1 : -1);
    this.zoomAtPoint(position, this.svgGrid.nativeElement, scale);
  }

  getEventPosition(wheel: WheelEvent): Point {
    const point: Point = {x: 0, y: 0};
    if (wheel.offsetX) {
      point.x = wheel.offsetX;
      point.y = wheel.offsetY;
    } else {
      const { left, top } = (wheel.srcElement as Element ).getBoundingClientRect();
      point.x = wheel.clientX - left;
      point.y = wheel.clientY - top;
    }
    return point;
  }

  zoomAtPoint(point: Point, svg: SVGSVGElement, scale: number): void {
    const sx = point.x / svg.clientWidth;
    const sy = point.y / svg.clientHeight;
    const [minX, minY, width, height] = svg.getAttribute('viewBox')
      .split(' ')
      .map(s => parseFloat(s));
    const x = minX + width * sx;
    const y = minY + height * sy;
    const scaledMinX = this.cutoffScaledMin(x + scale * (minX - x));
    const scaledMinY = this.cutoffScaledMin(y + scale * (minY - y));
    const scaledWidth = this.cutoffScaledLength(width * scale);
    const scaledHeight = this.cutoffScaledLength(height * scale);
    const scaledViewBox = [scaledMinX, scaledMinY, scaledWidth, scaledHeight]
      .map(s => s.toFixed(2))
      .join(' ');
    svg.setAttribute('viewBox', scaledViewBox);
  }

  cutoffScaledMin(scaledMin: number): number{
    return scaledMin >= 0 ? scaledMin : 0;
  }
  cutoffScaledLength(length: number): number{
    return length <= 750 ? length : 750;
  }

これで SVG の Zoom が実現できました。
Zoom を行うと重なる要素を動かす際の倍率を気にする必要がありますがそれは次回にでも。

まとめ

Angular と SVG で Zoom / Drag ができる Grid を作る方法をまとめました。
最初は rxjs の fromevent を多用して Event の処理を行っていましたが、動的に要素が追加されると少し難しさがあったので従来の方法で実装してみました。

次回はこの Grid に Drag 可能な図形を動的に追加してみます。

参考

wemo.tech

norando.net

go の middleware を束ねて http.Handler を返す

はじめに

Goで始めるMiddlewareの通り、go の HTTP Server で middleware を通す場合、入れ子を何回も書く必要があって可読性が落ちてしまいがちです。

記事の中で記載されていますが justinas/aliceを利用するとこで middleware をスタックし http.Handler を吐き出すことができますが、個人的にgo-chi/chiのように1行ずつ middleware をスタックした方が見やすいなぁと思い作ってみました。

github.com

続きを読む