【Sass実践勉強会】Cssだけだと無理そうなのでSvelteを使ってSVGの曲線チャートを描いてみる


2022/01/24
蛸壺の技術ブログ|Cssだけだと無理そうなのでSvelteを使ってSVGの曲線チャートを描いてみる

前回までで実用性はさておきCSS純正折れ線チャートを描くやり方を紹介しました。

個人的にもっと欲を言えば、折れ線でなくて、べシェなどの曲線で補間したような滑らかな
「曲線チャート」もCssで出来ないだろうか...?と長らく考えていました。

結論としては、
CSSスタイル単体だけで自由な曲線形状を作成することは現状で残念ながら非常に厳しいため、HTML上でSVG要素を直接扱うためのJSフレームワークを使う方が圧倒的に楽だ、という話に落ち着きました。

今回は本稿で今最注目の軽量JSフレームワーク・
『Svelte』を使ってSassとSVGのPath要素を使いながら、より綺麗な曲線チャートを作成できるかを検証するのが狙いです。


基本のテクニック 〜 SVG要素のPath属性を使った曲線の書き方

SVGのPath要素を使うことで、非常に自由度の高い図形を描くことができます。

Pathによって曲線を描く場合に、
ベジェ曲線を利用することになります。

さらにPathのベシェ曲線には、より複雑で高度な
三次ベジェ曲線を扱えるCコマンドとSコマンドと、もう少し制御点の設定をシンプルにした二次ベジェ曲線を扱えるQコマンドとTコマンドの2つに分かれます。

今回は、シンプルな実装でベシェ曲線を描きたいので、二次のベシェ曲線を制御点を1つだけ設定して、なんとか綺麗な曲線を目指したいので、
Qコマンドだけに絞って説明していきます。

Qコマンドの用法としては、

            
            (...先行してMやQコマンドで始点が必要)

Q [中間制御点のx座標] [中間制御点のy座標]
  [終点のx座標] [終点のy座標]
        
と言うように利用します。

もっとも簡単な例を一つ取り上げてみましょう。

            
            <svg width="300" height="300" viewBox='-10 -10 310 310'>
    <path d="
          M 10 80
          Q 225 250 250 100"
          stroke="black" fill="transparent"/>
</svg>
        
この例でいうと、始点が(10, 80)、終点が(250, 100)、その2点間を補間する制御点に(225, 250)があります。

なおSVGは画面向かって下方向がy軸座標の正方向ですのでご注意ください。

これを描くと以下のようになります。

べシェ曲線(黒線)の他に補助線や点も書き入れていますが、ここではQコマンドの中間制御点の役割と言うものを視覚的に表した図です。

始点から終点へと結ばれる曲線は、始点と制御点を結ぶ線分の中点、および終点と制御点を結ぶ線分の中点、そしてこの2つの中点を更に結んだ線分の中点(オレンジの点)を通過し、更にこの3つの線分に接するような曲線になります。

基本的にこの制御点はどこにとっても曲線自体は描くことができますが、制御点の取り方一つでべシェ曲線の見栄えが変わってきます。

もう一つQコマンドが扱いやすいのは2次のべシェ曲線を連続して記述できる点にあります。

例えば以下のように4つの点を滑らかに繋ぐべシェ曲線を簡単に描くことができます。

            
            <svg width="300" height="300" viewBox='-10 -10 310 310'>
    <path d="
          M 10 80
          Q 45 60 80 80
          Q 140 110 200 200
          Q 225 250 250 100"
          stroke="black" fill="transparent"/>
</svg>
        
このSVGべシェ曲線は以下のようになります(点は追加で表示しています)。

と言うことでSVGのPath要素のQコマンドの復習は簡単にここまでとしまして、以降ではSvelteでSVG要素を直接操作して高度なべシェ曲線を描いていく方法を検討します。


SVG曲線をSvelteで描くやり方

Svelteは非常に軽快なWebアプリケーションを作るのに向いているJavascriptフレームワークです。

Svelteの利点は初心者にも扱いやすい簡潔な記述方法でHTMLを操作できることにあります。

また、近年で複雑化・高機能化の傾向の強くなっている他のJSフレームワークと比較しても学習コストの低めで、割と短時間で習得できるのも魅力になっています。

Svelteの特徴として、HTMLのDOMとスクリプト部分とCSSスタイル部分が密接にハイブリッドされているsvelteコードでアプリを組み上げていくため、今回のテーマのようにSVG要素をCanvas要素やimg要素など無しで直接HTMLに埋め込むことも楽に出来ます。

Svelteアプリの開発環境を準備する

まずはお手元でSvelteアプリをお手軽に作りたいという方向けに、以前ブログ記事を認めたので詳しい手順はそちらをご覧ください。

基本はその記事の中で紹介した
App.svelteを改造していく感じで以降のSVG曲線描画アプリを作成していきます。

この記事では
App.svelteとSassのテクニックの内容の説明に注力しますので、ご留意ください。

Sassコード側からSvelteへ(やや強引に)データを受け渡すやり方

ここでは
前の回で扱った_data.scssはそのままの形でSvelteコード側で使えるようにすることを考えます。

以下のようにTypescript&Sassが使えるようにしたSvelteプロジェクトに前回と同じ
_data.scssをApp.svelteと同じsrcフォルダ内に置いておきます。

            
            $ tree
.
├── node_modules
├── package.json
├── public
│   ├── favicon.png
│   └── index.html
├── rollup.config.js
├── src
│   ├── App.svelte
│   ├── _data.scss #👈ファイルを追加
│   ├── global.d.ts
│   └── main.ts
├── svelte.config.js
├── tsconfig.json
└── yarn.lock
        
通常、Javascriptのコードで静的ファイルから読み込まれるデータは、jsonやjsなどのフォーマットファイルを直接import構文を使って内部に展開する必要があります。

scssフォーマットのファイルは単なるテキストデータであり、importから任意のテキストを読み込むことはセキリティのリスクの観点から不可能になっています。

サーバーサイドで稼働するネイティブなnode.jsアプリにするなら、fsなどのローカルから静的なテキストを読み込むライブラリも使えますが、今回のケースでは使えません。

もっともrollup.jsの知識がある方は、
rollup-plugin-stringのようなプラグインを参考にすると直接任意のテキストファイルを読み込むことが可能です。

では今回どのようにデータを仕込むかというと、Sassファイルの中にあるテキストデータ限定ですが、
何か非表示のHTML要素の擬似要素のcontent属性にテキストを忍ばせておく、という手法を取ります。

            
            @use 'data'; //👈_data.scss

//...

#dataholder {
    display: none;
    &::before { content: "#{data.$sim_xydata}"; }
}
        
こうしておくことで、HTMLの裏側でひっそりとデータがテキストとして張り付いていることになります。

後はjavascriptコード側で
window.getComputedStyleメソッドから擬似要素のcontentを取得すると無事データの受け渡しは完了です。

Svelteコードの実装

では早速今回のメインのSvelteコードを以下のように実装してみます。

            
            <script lang="ts">
import { onMount } from 'svelte';

let dataholderSpan: any;
let dataArr: any[] = [];
let path: string;
const prevData: number[] = [0, 0, 0, 0];
const bendCoeff = 0.4; // 0.2 ~ 0.6

onMount(() => {
    //👇隠しデータセットを仕込んだ擬似要素を取り出し
    const pseudo = window.getComputedStyle(dataholderSpan, '::before');

    //👇隠し要素からcontentの文字列(2次配列)を読み込み、xy座標の2次配列に復元
    dataArr = pseudo.content.replace(/^\"\[/,'').replace(/\]\"$/,'').match(/(\[.*?\])/g).map((xy: string) => {
        const xymatch = xy.match(/\[(.*),(.*)\]/);
        return [ parseFloat(xymatch[1]), parseFloat(xymatch[2]) ];
    });

    //👇SVGのPath要素で2次のベシェ曲線を描くコマンドを構築(☆)
    path = `M${dataArr.map(p => {
        const interpolitedX = (p[0] + prevData[0]) / 2;
        const interpolitedY = (p[1] + prevData[1]) / 2 + bendCoeff * prevData[3];
        prevData[2] = interpolitedX;
        prevData[3] = p[1] - prevData[1];
        prevData[0] = p[0];
        prevData[1] = p[1];
        return `${interpolitedX},${interpolitedY} ${p[0]},${p[1]}`;
    }).join('Q')}`
});
</script>

<svg class="curve" width="100" height="10" viewBox='0 0 100 10'>
    <path d="{path}" stroke="black" fill="transparent"/>
    {#each dataArr as xyset}
        <circle class="dataPoint" cx="{xyset[0]}" cy="{xyset[1]}" r="0.3" />
    {/each}
</svg>

<span id="dataholder" bind:this="{dataholderSpan}"></span>

<style lang="scss">
@use 'data';
svg {
    display: block;
    width: 900px;
    height: 250px;
    .dataPoint { fill:none; stroke:#eea300; stroke-width:0.2; }
    &.curve {
        path { stroke-width: 0.15; }
    }
}
#dataholder {
    display: none;
    &::before { content: "#{data.$sim_xydata}"; }
}
</style>
        
これをコンパイルすると、以下のような曲線に仕上げることができます。

最後に、今回のSvelteコードを簡単に解説しておきます。

とりあえずHTML要素をバインドする
bind:thisや、DOMLoadedイベントにあたるonMountなどのSvelteの基本用法の説明はしませんので、分からないならば公式のドキュメントやチュートリアルの方を良く読んで頂くと良いでしょう。

ここでは2次のベシェ曲線を描くための各制御点を何処にすると良いかという話で、先程の
マークで記した部分の実装に関して説明します。

            
            //...中略

//👇一つ前に使った終点xy座標と制御点の情報を保持
const prevData: number[] = [0, 0, 0, 0];
//👇各始点-終点からなる線分の中点からの制御点の乖離率(0.2 ~ 0.6程度)
const bendCoeff = 0.4;

//...中略

path = `M${dataArr.map(p => {
    //👇制御点のx座標 => 線分の中点のx座標
    const interpolitedX = (p[0] + prevData[0]) / 2;
    //👇制御点のy座標 => 線分の中点のy座標から前回のy座標の差分をモーメンタム量として
    //  適当な乖離率(ここでは0.4)を掛けた値で補正
    const interpolitedY = (p[1] + prevData[1]) / 2 + bendCoeff * prevData[3];

    //👇古い情報を更新
    prevData[3] = p[1] - prevData[1];
    prevData[0] = p[0];
    prevData[1] = p[1];
    prevData[2] = interpolitedX;

    return `${interpolitedX},${interpolitedY} ${p[0]},${p[1]}`;
}).join('Q')}`
        
上記のパートでも解説していたように、2次のベシェ曲線の見栄えの良さは制御点をどのように配置させるかによります。

通常、大量のデータ点から一つ一つ制御点を手動で調整することは困難ですので、ある程度曲線の綺麗さには妥協して、コードの自動処理から程よい制御点に収まってくれるような仕組みを考えなければなりません。

大量のデータ点から良い感じにベシェ曲線を描くための方法は探せば色々とあります。

今回の方法は、現在の制御点を置くより一つ前の区間の、y軸方向の変化量をモーメンタム量(勢いのようなもの)と見なし、その変化量に応じて固定した乖離率を掛けた量をその区間の中点からy軸方向移動させるようにしています。

この方法だと、見栄えはさほど完璧とまでは良いませんが、細かい変化する区間で制御点が激しく振動して大きなスパイクになったり、発散して無限大に高い制御点が発生して有りえないことになることは無いでしょう。

ここらへんはプログラマーの好みで調整してみられると良いと思います。


まとめ

今回はSvelteとSassを連携させる形でSVGのPath要素から2次のベシェ曲線を描く方法を検討してみました。

また、このブログ記事の企画でもっともチャレンジングな課題であった
『CSSだけで.../Sassだけで...』は、現状かなり無謀そうだったので今回からお蔵入りすることにしました。

CSSだけで曲線をもっと楽に書ける方法が今後確立されるかも知れないので、そのときにはまたこのブログにて紹介できれば幸いです。
記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。