デザイナーの角丸へのこだわりに応えよう【HTML/CSS/JavaScript編】

## はじめに

先日以下のツイートを拝見しました。

https://x.com/oka_yu148/status/1848731593445412905

「スーパー楕円...?なんだそれ？」と思うと同時に、「デザイナーからこれ要求されたらどうしよ」とも思ったので実装方法を検討してみました。

https://spinners.work/posts/kudakurage-superellipse-desgin/

上記の記事にもありますが、スーパー楕円のように美しい角丸系は以下の方程式で導き出せるようです。
このnが`2.5`の時にスーパー楕円になるようです。

```math
\left|\frac{x}{a}\right|^n + \left|\frac{y}{b}\right|^n = 1
```

:::note warn
数式が出てきて頭が痛くなる方もいるかもしれませんが理解できなくて大丈夫です！
:::

では、早速実装方法です。
前編はHTML, CSS, JavaScriptでの実装方法について記載します。
後編はモバイル向けにFlutterでの実装方法を調査します！

## HTML, CSSでの実装


```html: sample.html
<html>
<head>
    <title>SQUARE</title>
    <link rel="stylesheet" type="text/css" href="sample.css">
</html>
<body>
    <div class="container">
        <div class="normal"></div>
        <div class="smooth"></div>
        <div class="super-ellipse"></div>
    </div>
</body>
```


```css: sample.css
.container {
    margin: 28px;
    display: flex;
}

.normal, .smooth, .super-ellipse {
    margin: 20px;
    width: 200px;
    height: 200px;
    background: rgb(48, 48, 48);
}

.normal {
    border-radius: 40px;
}

.smooth {
    clip-path: path("M100,0 C195,0 200,10 200,100 C200,190 190,200 100,200 C10,200 0,190 0,100 C0,10 10,0 100,0 Z");
}

.super-ellipse {
    clip-path: path("M100,0 C180,0 200,20 200,100 C200,180 180,200 100,200 C20,200 0,180 0,100 C0,15 15,0 100,0 Z");
}
```

以下が結果です。

![スクリーンショット 2024-12-14 23.13.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/784433eb-b8b1-9588-1865-9f37289976e2.png)

コードにある通り、複雑な曲線は`clip-path`を用いて実装が可能です。
```css
clip-path: path("M100,0 C195,0 200,10 200,100 C200,190 190,200 100,200 C10,200 0,190 0,100 C0,10 10,0 100,0 Z")
```

この数値については、今回半径を200pxとしているので200pxを基準とした数値をとっています。
そのため、こちらを流用する場合はそれぞれの1辺の長さに合わせて数値の調整をしてください。


## JavaScript

嫌な人は見なくて大丈夫ですが、先ほどの方程式を解いていき、それを参考に実装してみます。

#### 方程式

```math
\left|\frac{x}{a}\right|^n + \left|\frac{y}{b}\right|^n = 1
```

(1) yを求めるため移項する

```math
\left|\frac{x}{a}\right|^n + \left|\frac{y}{b}\right|^n = 1 \implies \left|\frac{y}{b}\right|^n = 1 - \left|\frac{x}{a}\right|^n
```

(2) n乗根を打ち消す

```math
\left|\frac{y}{b}\right| = \left(1 - \left|\frac{x}{a}\right|^n\right)^{\frac{1}{n}}
```

(3) bをかけてyの式が出せる

```math
|y| = b\left(1 - \left|\frac{x}{a}\right|^n\right)^{1/n}
```

### JSのコード

複雑かもしれませんが、a, b, nを変えてもらえば良いだけです。

```js
<canvas id="myCanvas" width="400" height="400"></canvas>
<script>
    // ********** パラメータ設定 **********
    const canvas = document.getElementById('myCanvas');
    const ctx = canvas.getContext('2d');
    const a = 100;   // x方向スケール
    const b = 100;   // y方向スケール
    const n = 2.5;     // 楕円形状パラメータ（n=2.5 でスーパー楕円化など）
    const step = 1;  // x座標をこのピクセル刻みでサンプリング
    const centerX = canvas.width / 2;
    const centerY = canvas.height / 2;

    // ********** 描画関数定義 **********
    /**
     * 指定した n, a, b に基づいて、スーパー楕円(もしくは一般的なn乗楕円)の上半分または下半分を描画する。
     * @param {CanvasRenderingContext2D} ctx コンテキスト
     * @param {number} a x方向の半径
     * @param {number} b y方向の半径
     * @param {number} n 楕円パラメータ
     * @param {number} step x座標をサンプリングするピクセル刻み
     * @param {boolean} isUpper true なら上半分、false なら下半分を描く
     */
    function drawSuperEllipseHalf(ctx, a, b, n, step, isUpper) {
        const direction = isUpper ? 1 : -1; 
        // xを -a から a まで動かすが、上半分は左→右、下半分は右→左へと回すことで閉路を形成
        const start = isUpper ? -a : a;
        const end = isUpper ? a : -a;
        const increment = isUpper ? step : -step;

        for (let x = start; isUpper ? (x <= end) : (x >= end); x += increment) {
            const ratio = Math.abs(x / a);
            const yPart = 1 - Math.pow(ratio, n);
            if (yPart < 0) continue;

            const yVal = b * Math.pow(yPart, 1/n);
            const drawX = centerX + x;
            const drawY = centerY + (isUpper ? -yVal : yVal);

            if ((isUpper && x === -a) || (!isUpper && x === a)) {
                // パス移動開始
                ctx.moveTo(drawX, drawY);
            } else {
                ctx.lineTo(drawX, drawY);
            }
        }
    }

    // ********** 実際の描画処理 **********
    ctx.beginPath();
    // 上半分を描画
    drawSuperEllipseHalf(ctx, a, b, n, step, true);
    // 下半分を描画
    drawSuperEllipseHalf(ctx, a, b, n, step, false);

    ctx.closePath();
    ctx.fillStyle = 'rgb(48,48,48)';
    ctx.fill();
</script>
```

| n = 4.5 | n = 3 | n = 2.5 |
|:-:|:-:|:-:|
| ![スクリーンショット 2024-12-15 0.03.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/e60863c2-04d8-2111-169f-831b1642b57f.png) | ![スクリーンショット 2024-12-15 0.03.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/9faded87-f174-4eb5-6916-69a637c6ccd4.png) | ![スクリーンショット 2024-12-15 0.03.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/5af6d213-49ed-67bc-7406-6a6949f677b9.png) |

もう少しスマートな書き方があれば後日更新させていただきます。
あくまで数式ベースな感じなのでCSSらしい書き方などができればベストですね。