月の満ち欠けの簡易表示

- CanvasAPI を使って、月相らしき簡易形状を表示します。
- スライダーを動かすと形状が変化します。
- 日時や月齢などの計算は一切行っていません。

<p class="codepen" data-height="840" data-default-tab="result" data-slug-hash="jOXayvG" data-user="ikiuo" style="height: 840px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/ikiuo/pen/jOXayvG">
  月の満ち欠け</a> by Ikiuo (<a href="https://codepen.io/ikiuo">@ikiuo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

# 輪郭の計算

以下のパラメータ

```math:
\begin{eqnarray}
L & = & 視点からの距離 (380,000) \\
R & = & 球体の半径 (1,738) \\
h & = & 球体の経度 (0=右端, \pi=左端) \\
p & = & 球体の緯度 \\
\end{eqnarray}
```

から球体の表面座標を

```math:
\begin{eqnarray}
x & = & R \cos{p} \cos{h} \\
y & = & R \sin{p} \\
z & = & L - R \cos{p} \sin{h} \\
\end{eqnarray}
```

として、透視変換したもの

```math:
\begin{eqnarray}
X & = & \frac{x}{z} \\
Y & = & \frac{y}{z} \\
\end{eqnarray}
```

を使用しています。見かけの大きさ（角度）は、球の中心から球面への接線方向の角度

```math:
h_a = \sin^{-1}{ \frac{R}{L} }
```

の２倍で、経度 $0$ への角度

```math:
h_0 = \tan^{-1}{ \frac{R}{L} }
```

よりも大きくなります。$(h_0 \lt h_a)$


# ソース コード

```HTML:sample.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>月の満ち欠け</title>
  </head>
  <body>

    <table>
      <tr>
        <td>
          <fieldset>
            <legend>各種設定</legend>

            <div>
              <span>画像：</span>
              <label>幅</label>
              <input id="ScreenWidth" type="number"
                     value="500" min="10" max="10000" step="1"
                     oninput="updateParameter()">
              <label>高</label>
              <input id="ScreenHeight" type="number"
                     value="500" min="10" max="10000" step="1"
                     oninput="updateParameter()">
              <span>&nbsp;</span>
              <input type="button" value="リセット"
                     onclick="resetScreenSize()">
            </div>

            <div>
              <label>月の大きさ：</label>
              <input id="MoonSizeValue" type="number"
                     value="500" min="10" max="10000" step="1"
                     oninput="updateParameter()">
              <input type="button" value="自動"
                     onclick="resetMoonSize()">
            </div>

            <div>
              <label>月の色：</label>
              <input id="MoonLightColor" type="text" value="yellow"
                     size="12" oninput="updateParameter()">
              <input id="MoonLight" type="checkbox" checked
                     onclick="updateParameter()">
            </div>

            <div>
              <label>影の色：</label>
              <input id="MoonShadowColor" type="text" value="darkblue"
                     size="12" oninput="updateParameter()">
              <input id="MoonShadow" type="checkbox" checked
                     onclick="updateParameter()">
            </div>

            <div>
              <label>背景色：</label>
              <input id="BackgroundColor" type="text" value="black"
                     size="12" oninput="updateParameter()">
              <input id="Background" type="checkbox" checked
                     onclick="updateParameter()">
            </div>

            <div>
              <label>傾斜：</label>
              <input id="MoonRotateValue" type="number"
                     value="0" min="-1.0" max="1.0" step="0.001"
                     oninput="updateRotate()">
              <input type="button" value="リセット"
                     onclick="resetMoonRotate()">
              <br>&nbsp;&nbsp;
              <input id="MoonRotateSlider" type="range"
                     value="0" min="-1.0" max="1.0" step="0.001"
                     style="width: 20em;"
                     oninput="updateRotateSlider()">
            </div>

            <div>
              <label>満ち欠け：</label>
              <input id="MoonPhaseValue" type="number"
                     value="0" min="-1.0" max="1.0" step="0.001"
                     oninput="updatePhase()">
              <input type="button" value="リセット"
                     onclick="resetMoonPhase()">
              <br>&nbsp;&nbsp;
              <input id="MoonPhaseSlider" type="range"
                     value="0" min="-1.0" max="1.0" step="0.001"
                     style="width: 20em;"
                     oninput="updatePhaseSlider()">
            </div>

          </fieldset>

          <canvas id="Screen" width="500" height="500"
                  style="margin: 2px; border: 0px; background-color: gray;">
          </canvas>
        </td>
      </tr>
    </table>

    <script>

     class TinyMoonPhase {
         /*
          * コンストラクタ
          *
          *   radius = 視半径
          *   phase = 月光の位置
          *       -1.0 = -180 度 : 新月
          *        0.0 =    0 度 : 満月
          *       +1.0 = +180 度 : 新月
          */
         constructor(radius, phase, distance, sphereSize) {
             if (distance == null)
                 distance = 3800000;
             if (sphereSize == null)
                 sphereSize = 1738;

             this.S = TinyMoonPhase;
             this.radius = radius;
             this.phase = phase;
             this.distance = distance;
             this.sphereSize = sphereSize;
             this.latitudeMax = Math.acos(sphereSize / distance);

             this.initCircle();
             this.initPhase();
         }

         initCircle() {
             const PI = Math.PI;
             const quaterPI = PI * 0.25;
             const radius = this.radius;
             const count = radius >> 1;
             const co1 = [...Array(count + 1)].map(function(_, n){
                 const r = quaterPI * n / count;
                 return [radius * Math.sin(r), radius * Math.cos(r)]
             });
             const co2 = co1.map(v => [v[1], v[0]]);
             const cq1 = co1.concat(co2.toReversed().slice(1));
             const cq2 = cq1.map(v => [v[0], -v[1]]);
             const ch1 = cq1.concat(cq2.toReversed().slice(1));
             const ch2 = ch1.map(v => [-v[0], v[1]]);

             this.circleLeft = ch2;
             this.circleRight = ch1;
         }

         initPhase() {
             const PI = Math.PI;
             const halfPI = PI * 0.5;

             const radius = this.radius;
             const distance = this.distance;
             const sphereSize = this.sphereSize;
             const mr = halfPI - this.latitudeMax;
             const ml = PI - mr;
             {
                 const x = sphereSize * Math.cos(mr);
                 const z = distance - sphereSize * Math.sin(mr);
                 this.viewScale = radius * z / x;
             }

             const pr = this.phase * PI;
             const pl = pr + PI;

             this.light = null;
             this.shadow = null;

             let ptype, sep;
             if (ml <= pr || pl <= mr)
                 ptype = 2;
             else if (pr > mr) {
                 ptype = 0;
                 sep = pr;
             } else if (pl < ml) {
                 ptype = 1;
                 sep = pl;
             } else
                 ptype = 3;

             const cl = this.circleLeft;
             const cr = this.circleRight;

             if ((ptype & 2) != 0) {
                 const cd = cr.concat(cl.toReversed().slice(1, -1));

                 if ((ptype & 1) != 0)
                     this.light = cd;
                 else
                     this.shadow = cd;
             } else {
                 const cc = this.getSeparator(sep);
                 const vr = cr.concat(cc.toReversed());
                 const vl = cc.concat(cl.toReversed());

                 if ((ptype & 1) != 0) {
                     this.light = vr;
                     this.shadow = vl;
                 } else {
                     this.light = vl;
                     this.shadow = vr;
                 }
             }
         }

         getSeparator(sep) {
             const radius = this.radius;
             const distance = this.distance;
             const sphereSize = this.sphereSize;
             const viewScale = this.viewScale;
             const latitudeMax = this.latitudeMax;
             const sepCos = Math.cos(sep);
             const sepSin = Math.sin(sep);

             const count = radius >> 0;
             const shp = [...Array(count + 1)].map(function(_, n) {
                 const t = latitudeMax * n / count;
                 const x = sphereSize * Math.cos(t) * sepCos;
                 const y = sphereSize * Math.sin(t);
                 const z = distance - sphereSize * Math.cos(t) * sepSin;
                 const v = viewScale / z;
                 return [v * x, v * y];
             });
             const shm = shp.map(v => [v[0], -v[1]]);

             shp.reverse();
             return shp.concat(shm.slice(1))
         }
     }

     function getTagValue(tag) {
         return Math.min(tag.max, Math.max(tag.min, tag.value));
     }

     function updateParameter() {
         const halfPI = Math.PI / 2;

         const canvas = document.getElementById('Screen');
         const ctx = canvas.getContext("2d");

         const w = getTagValue(ScreenWidth);
         const h = getTagValue(ScreenHeight);
         const cr = getTagValue(MoonSizeValue) / 2.0;
         const mp = getTagValue(MoonPhaseValue);
         const mr = getTagValue(MoonRotateValue);

         const Ra = - mr * halfPI;
         const cx = w / 2;
         const cy = h / 2;

         canvas.width = w;
         canvas.height = h;

         const moon = new TinyMoonPhase(cr, -mp);

         getPath = function(pos) {
             const rc = Math.cos(Ra);
             const rs = Math.sin(Ra);

             const path = new Path2D();
             const vpos = pos.map(v => [
                 cx + (v[0] * rc - v[1] * rs),
                 cy - (v[1] * rc + v[0] * rs)
             ]);
             vpos.forEach((v, n) => n ?
                                    path.lineTo(v[0], v[1]) :
                                    path.moveTo(v[0], v[1]));
             path.closePath();
             return path;
         }

         ctx.clearRect(0, 0, w, h);
         if (Background.checked) {
             ctx.fillStyle = BackgroundColor.value;
             ctx.fillRect(0, 0, w, h);
         }
         if (moon.shadow && MoonShadow.checked) {
             ctx.fillStyle = MoonShadowColor.value;
             ctx.fill(getPath(moon.shadow));
         }
         if (moon.light && MoonLight.checked) {
             ctx.fillStyle = MoonLightColor.value;
             ctx.fill(getPath(moon.light));
         }
     }

     function resetScreenSize() {
         ScreenWidth.value = 500;
         ScreenHeight.value = 500;
         updateParameter();
     }

     function resetMoonSize() {
         const w = getTagValue(ScreenWidth);
         const h = getTagValue(ScreenHeight);
         MoonSizeValue.value = Math.min(w, h);
         updateParameter();
     }

     function resetMoonRotate() {
         MoonRotateValue.value = 0.0;
         MoonRotateSlider.value = 0.0;
         updateParameter();
     }

     function resetMoonPhase() {
         MoonPhaseValue.value = 0.0;
         MoonPhaseSlider.value = 0.0;
         updateParameter();
     }

     function updatePhase() {
         const phase = getTagValue(MoonPhaseValue);
         MoonPhaseSlider.value = phase;
         updateParameter();
     }

     function updatePhaseSlider() {
         const phase = getTagValue(MoonPhaseSlider);
         MoonPhaseValue.value = phase;
         updateParameter();
     }

     function updateRotate() {
         const angle = getTagValue(MoonRotateValue);
         MoonRotateSlider.value = angle;
         updateParameter();
     }

     function updateRotateSlider() {
         const angle = getTagValue(MoonRotateSlider);
         MoonRotateValue.value = angle;
         updateParameter();
     }

     window.onload = updateParameter;

    </script>
  </body>
</html>
```