月齢カレンダーウィジェット
設定難易度

ホーム画面に置くだけで、今夜の月の形がひと目でわかるScriptableウィジェット。
月相名(三日月・満月など)はもちろん、次の満月までの日数、ストロベリームーンなどの呼び名、スーパームーン・ブルームーン判定、次の月食情報まで表示。
空を見上げる前に、まずホーム画面をチェック。

最初は「今日の月がどんな形なのか、ホーム画面ですぐ分かったらいいな」くらいのシンプルな月齢ウィジェットとして作り始めました。
でも、せっかくなら月齢だけ表示するより、次の満月まであと何日なのか、今月の満月は何ムーンなのか、月食はいつなのかまで分かった方が面白いと思い、いろいろ詰め込んでいます。
月の形も画像を取得しているわけではなく、月齢から計算してScriptable上で描画しています。
天体観測用の本格的なアプリというより、「今日の月、こんな感じなんや」とホーム画面から気軽に眺めるためのウィジェットです。

ウィジェットの表示内容

月の画像はダウンロードせず、計算した月齢から描画します。

サイズごとの表示

  • 小:月の形・月相名・月齢・輝面率を表示します。
  • 中:小サイズの情報に、次の満月までの日数・満月の呼び名・月食などの情報が加わります。
  • 専用レイアウトは小・中サイズです。大サイズ用の表示は用意していません。

設定手順

上から順に進めてください。コードの保存と設定のコピーに必要な操作欄は、それぞれの手順の中にあります。

  1. Scriptableを用意する

    App StoreでScriptableを開き、iPhoneにインストールします。すでに入っている場合は次へ進んでください。

  2. コードをコピーして保存する

    下のコード欄の「コピー」を押します。Scriptableへ移動し、「+」から新しいスクリプトを作ってコード全文を貼り付け、月齢カレンダーなどの名前で保存してください。

    保存したらこのページに戻り、次の手順へ進みます。

    // Version: 1.0.0
    // 月齢カレンダー - Scriptable Widget
    // 今日の月の満ち欠けをイラストで表示します
    // 小/中サイズのホーム画面ウィジェットに対応
    
    // ===== 月齢の計算 =====
    // 既知の新月時刻(2000年1月6日 18:14 UTC)を基準に経過日数から算出
    const KNOWN_NEW_MOON = Date.UTC(2000, 0, 6, 18, 14, 0);
    const SYNODIC_MONTH = 29.530588853; // 朔望月(日)
    
    function getMoonInfo(date) {
      const diffDays = (date.getTime() - KNOWN_NEW_MOON) / 86400000;
      let age = diffDays % SYNODIC_MONTH;
      if (age < 0) age += SYNODIC_MONTH;
    
      const phase = age / SYNODIC_MONTH; // 0 = 新月, 0.5 = 満月
      const theta = phase * 2 * Math.PI;
      const illumination = (1 - Math.cos(theta)) / 2; // 0〜1
    
      // 8区分の月相名(各区分は1/8ずつ、新月だけ前後半分ずつで合計1/8)
      let phaseName;
      if (phase < 1 / 16 || phase >= 15 / 16) phaseName = "新月";
      else if (phase < 3 / 16) phaseName = "三日月";
      else if (phase < 5 / 16) phaseName = "上弦の月";
      else if (phase < 7 / 16) phaseName = "十三夜月";
      else if (phase < 9 / 16) phaseName = "満月";
      else if (phase < 11 / 16) phaseName = "十六夜月";
      else if (phase < 13 / 16) phaseName = "下弦の月";
      else phaseName = "二十六夜月";
    
      return { age, phase, illumination, phaseName };
    }
    
    // ===== 次の満月・前の満月までの日数 =====
    function daysUntilNextFullMoon(phase) {
      // phase: 0(新月)〜1(次の新月直前)、0.5が満月
      if (phase < 0.5) return (0.5 - phase) * SYNODIC_MONTH;
      return (1.5 - phase) * SYNODIC_MONTH;
    }
    
    function getNextFullMoonDate(now, phase) {
      const days = daysUntilNextFullMoon(phase);
      return new Date(now.getTime() + days * 86400000);
    }
    
    function getPrevFullMoonDate(nextFullMoonDate) {
      return new Date(nextFullMoonDate.getTime() - SYNODIC_MONTH * 86400000);
    }
    
    // ===== その月の満月の呼び名(伝統的な英語圏の名称の和訳) =====
    const FULL_MOON_NICKNAMES = [
      "ウルフムーン", "スノームーン", "ワームムーン", "ピンクムーン",
      "フラワームーン", "ストロベリームーン", "バックムーン", "スタージェンムーン",
      "ハーベストムーン", "ハンターズムーン", "ビーバームーン", "コールドムーン",
    ];
    
    function getJSTParts(date) {
      // タイムゾーンに依存せずJSTの年月日を得るため、UTC+9で計算
      const jst = new Date(date.getTime() + 9 * 3600000);
      return { year: jst.getUTCFullYear(), month: jst.getUTCMonth(), day: jst.getUTCDate() };
    }
    
    // ===== ブルームーン判定(同じ月に満月が2回ある場合の2回目) =====
    function isBlueMoon(prevFullMoonDate, nextFullMoonDate) {
      const p = getJSTParts(prevFullMoonDate);
      const n = getJSTParts(nextFullMoonDate);
      return p.year === n.year && p.month === n.month;
    }
    
    // ===== スーパームーン判定(近地点付近の満月。日程は各年ごとに確認して埋め込み) =====
    // 日付は「満月の瞬間」のJST日付(正午基準の目安)。実際の軌道計算はせず既知の日程と照合します。
    const SUPERMOON_DATES_JST = [
      "2026-01-03", "2026-11-24", "2026-12-24",
      "2027-01-22", "2027-02-21",
    ];
    
    function isSupermoon(fullMoonDate) {
      const p = getJSTParts(fullMoonDate);
      const dateStr = `${p.year}-${String(p.month + 1).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`;
      if (SUPERMOON_DATES_JST.includes(dateStr)) return true;
      // 誤差を考慮して前後1日も許容
      const target = new Date(Date.UTC(p.year, p.month, p.day));
      for (const s of SUPERMOON_DATES_JST) {
        const sd = new Date(s + "T00:00:00Z");
        if (Math.abs(sd.getTime() - target.getTime()) <= 86400000) return true;
      }
      return false;
    }
    
    // ===== 月食データ(国立天文台などの情報をもとに手入力) =====
    const LUNAR_ECLIPSES = [
      { dateJST: "2026-03-03T20:33:00+09:00", type: "皆既月食", visibleJapan: "true", note: "日本全国で観察可能" },
      { dateJST: "2026-08-28T13:13:00+09:00", type: "部分月食", visibleJapan: "false", note: "日本では見られません" },
      { dateJST: "2027-02-21T05:00:00+09:00", type: "半影月食", visibleJapan: "partial", note: "半影食のため肉眼ではわかりにくい" },
      { dateJST: "2027-08-17T01:00:00+09:00", type: "半影月食", visibleJapan: "partial", note: "半影食のため肉眼ではわかりにくい" },
      { dateJST: "2028-01-12T12:13:00+09:00", type: "部分月食", visibleJapan: "false", note: "日本では見られません" },
      { dateJST: "2028-07-07T03:20:00+09:00", type: "部分月食", visibleJapan: "true", note: "全国で見られる(一部地域で部分食のまま月の入り)" },
      { dateJST: "2029-01-01T01:52:00+09:00", type: "皆既月食", visibleJapan: "true", note: "日本全国で観察可能" },
      { dateJST: "2029-06-26T09:00:00+09:00", type: "皆既月食", visibleJapan: "false", note: "日本では見られません" },
      { dateJST: "2029-12-22T08:00:00+09:00", type: "皆既月食", visibleJapan: "true", note: "日本全国で観察可能" },
      { dateJST: "2030-06-16T05:00:00+09:00", type: "部分月食", visibleJapan: "partial", note: "地域により見え方が異なります" },
    ];
    
    function getNextLunarEclipse(now) {
      const upcoming = LUNAR_ECLIPSES.map((e) => ({ ...e, dateObj: new Date(e.dateJST) }))
        .filter((e) => e.dateObj.getTime() > now.getTime())
        .sort((a, b) => a.dateObj - b.dateObj);
      return upcoming.length > 0 ? upcoming[0] : null;
    }
    
    // ===== 月のイラストを描画 =====
    function drawMoonImage(phase, size) {
      const ctx = new DrawContext();
      ctx.size = new Size(size, size);
      ctx.opaque = false;
      ctx.respectScreenScale = true;
    
      const cx = size / 2;
      const cy = size / 2;
      const r = size * 0.42;
    
      const darkColor = new Color("#2c2c2e");
      const lightColor = new Color("#f5f0dc");
      const rimColor = new Color("#8e8e93", 0.5);
    
      ctx.setFillColor(darkColor);
      ctx.fillEllipse(new Rect(cx - r, cy - r, r * 2, r * 2));
    
      const sign = phase < 0.5 ? 1 : -1;
      const theta = phase * 2 * Math.PI;
      const cosT = Math.cos(theta);
    
      const steps = 60;
      const path = new Path();
      let started = false;
    
      for (let i = 0; i <= steps; i++) {
        const y = -r + (2 * r * i) / steps;
        const k = Math.sqrt(Math.max(0, r * r - y * y));
        const x = sign * k;
        const pt = new Point(cx + x, cy + y);
        if (!started) {
          path.move(pt);
          started = true;
        } else {
          path.addLine(pt);
        }
      }
      for (let i = steps; i >= 0; i--) {
        const y = -r + (2 * r * i) / steps;
        const k = Math.sqrt(Math.max(0, r * r - y * y));
        const x = cosT * k;
        const pt = new Point(cx + x, cy + y);
        path.addLine(pt);
      }
      path.closeSubpath();
    
      ctx.addPath(path);
      ctx.setFillColor(lightColor);
      ctx.fillPath();
    
      ctx.setStrokeColor(rimColor);
      ctx.setLineWidth(1.5);
      ctx.strokeEllipse(new Rect(cx - r, cy - r, r * 2, r * 2));
    
      const craterColor = new Color("#d8d0b0", 0.6);
      const craters = [
        { dx: -0.15, dy: -0.2, rr: 0.07 },
        { dx: 0.1, dy: 0.05, rr: 0.05 },
        { dx: -0.05, dy: 0.22, rr: 0.09 },
        { dx: 0.2, dy: -0.1, rr: 0.04 },
      ];
      ctx.setFillColor(craterColor);
      for (const c of craters) {
        const px = cx + c.dx * r * 2;
        const py = cy + c.dy * r * 2;
        ctx.fillEllipse(new Rect(px - c.rr * r, py - c.rr * r, c.rr * r * 2, c.rr * r * 2));
      }
    
      return ctx.getImage();
    }
    
    // ===== 星空の背景を描画 =====
    function drawStars(width, height, seed) {
      const ctx = new DrawContext();
      ctx.size = new Size(width, height);
      ctx.opaque = false;
      ctx.respectScreenScale = true;
    
      let x = seed;
      function rand() {
        x ^= x << 13;
        x ^= x >>> 17;
        x ^= x << 5;
        x = x >>> 0;
        return (x % 100000) / 100000;
      }
    
      ctx.setFillColor(new Color("#ffffff", 0.5));
      for (let i = 0; i < 30; i++) {
        const sx = rand() * width;
        const sy = rand() * height;
        const sr = rand() * 1.3 + 0.4;
        ctx.fillEllipse(new Rect(sx, sy, sr, sr));
      }
    
      return ctx.getImage();
    }
    
    function getTodaySeed(date) {
      return date.getFullYear() * 10000 + (date.getMonth() + 1) * 100 + date.getDate();
    }
    
    // ===== 情報の準備 =====
    const now = new Date();
    const moon = getMoonInfo(now);
    const nextFullMoonDate = getNextFullMoonDate(now, moon.phase);
    const prevFullMoonDate = getPrevFullMoonDate(nextFullMoonDate);
    const daysToFull = daysUntilNextFullMoon(moon.phase);
    const nextNickname = FULL_MOON_NICKNAMES[getJSTParts(nextFullMoonDate).month];
    const blueMoonFlag = isBlueMoon(prevFullMoonDate, nextFullMoonDate);
    const superMoonFlag = isSupermoon(nextFullMoonDate);
    const nextEclipse = getNextLunarEclipse(now);
    
    const fullMoonLineText =
      daysToFull < 0.5
        ? `🌕 今夜は満月(${nextNickname})`
        : `🌕 次は${nextNickname}まであと${Math.ceil(daysToFull)}日`;
    
    let badgeText = "";
    if (superMoonFlag && blueMoonFlag) badgeText = "✨🔵 スーパー&ブルームーン";
    else if (superMoonFlag) badgeText = "✨ スーパームーンです";
    else if (blueMoonFlag) badgeText = "🔵 ブルームーンです";
    
    let eclipseLineText = null;
    if (nextEclipse) {
      const daysToEclipse = Math.ceil((nextEclipse.dateObj.getTime() - now.getTime()) / 86400000);
      const edf = new DateFormatter();
      edf.dateFormat = "M/d";
      const visIcon = nextEclipse.visibleJapan === "true" ? "👀" : nextEclipse.visibleJapan === "false" ? "🚫" : "🔎";
      eclipseLineText = `${visIcon} 次の月食 ${edf.string(nextEclipse.dateObj)} ${nextEclipse.type}(あと${daysToEclipse}日)`;
    }
    
    // ===== ウィジェット組み立て =====
    const widget = new ListWidget();
    
    const gradient = new LinearGradient();
    gradient.locations = [0, 1];
    gradient.colors = [new Color("#0b1026"), new Color("#1c2444")];
    widget.backgroundGradient = gradient;
    
    const starsImg = drawStars(300, 300, getTodaySeed(now));
    widget.backgroundImage = starsImg;
    
    const isSmall = config.widgetFamily === "small";
    widget.setPadding(isSmall ? 10 : 12, isSmall ? 10 : 14, isSmall ? 10 : 12, isSmall ? 10 : 14);
    
    // ===== 横並びレイアウト(小・中サイズ共通、画像サイズのみ変える) =====
    const mainStack = widget.addStack();
    mainStack.layoutHorizontally();
    mainStack.centerAlignContent();
    mainStack.spacing = 10;
    
    const moonSize = isSmall ? 70 : 90;
    const moonImg = drawMoonImage(moon.phase, 220);
    const moonImgElement = mainStack.addImage(moonImg);
    moonImgElement.imageSize = new Size(moonSize, moonSize);
    
    const textStack = mainStack.addStack();
    textStack.layoutVertically();
    textStack.spacing = 3;
    
    const nameText = textStack.addText(moon.phaseName);
    nameText.font = Font.boldSystemFont(18);
    nameText.textColor = Color.white();
    
    const ageText = textStack.addText(`月齢 ${moon.age.toFixed(1)}`);
    ageText.font = Font.systemFont(12);
    ageText.textColor = Color.white();
    ageText.textOpacity = 0.8;
    
    const illumText = textStack.addText(`輝面率 ${Math.round(moon.illumination * 100)}%`);
    illumText.font = Font.systemFont(12);
    illumText.textColor = Color.white();
    illumText.textOpacity = 0.8;
    
    // ===== 満月・月食などの詳細情報は中サイズ以上でのみ表示 =====
    if (!isSmall) {
      widget.addSpacer(8);
    
      const infoStack = widget.addStack();
      infoStack.layoutVertically();
      infoStack.spacing = 2;
    
      const fullMoonLine = infoStack.addText(fullMoonLineText);
      fullMoonLine.font = Font.systemFont(11);
      fullMoonLine.textColor = Color.white();
      fullMoonLine.textOpacity = 0.85;
    
      if (badgeText) {
        const badgeLine = infoStack.addText(badgeText);
        badgeLine.font = Font.boldSystemFont(11);
        badgeLine.textColor = new Color("#ffd60a");
      }
    
      if (eclipseLineText) {
        const eclipseLine = infoStack.addText(eclipseLineText);
        eclipseLine.font = Font.systemFont(10);
        eclipseLine.textColor = Color.white();
        eclipseLine.textOpacity = 0.75;
      }
    }
    
    // ===== 表示 =====
    if (config.runsInWidget) {
      Script.setWidget(widget);
    } else {
      await widget.presentMedium();
    }
    Script.complete();
  3. ホーム画面に追加して設定する

    iPhoneのホーム画面を長押しし、ウィジェット追加画面からScriptableを選びます。この記事の対応サイズの説明に合わせてサイズを選んで追加してください。

    追加したウィジェットを長押しして「ウィジェットを編集」を開き、「Script」に保存したスクリプトを選びます。

    「Parameter」は空欄で構いません。

    編集画面を閉じて表示を確認します。ホーム画面への反映には少し時間がかかる場合があります。

表示される月の情報

現在の日付から月齢を計算し、月の満ち欠けをイラストで表示します。
月相は、

  • 新月
  • 三日月
  • 上弦の月
  • 十三夜月
  • 満月
  • 十六夜月
  • 下弦の月
  • 二十六夜月

など、その日の状態に合わせて表示されます。 
また、月がどの程度明るく見えるかを表す**「輝面率」**も確認できます。

次の満月もチェック

中サイズでは、次の満月まであと何日なのかを表示します。
さらに、
ウルフムーン・スノームーン・ピンクムーン・ストロベリームーン
など、その月の満月につけられた呼び名も表示します。 
条件に該当する場合は、スーパームーンやブルームーンについても表示されます。

月食情報について

中サイズでは、次回の月食情報も表示します。
月食の日付や種類、日本から観察できるかどうかなどを確認できます。 
※月食やスーパームーンなど一部の情報は、スクリプト内に登録されているデータをもとに表示しています。

動作確認・使用結果

確認した端末 iPhone 17 ProMAX
iOS iOS27
Scriptable 1.7.19
確認したサイズ 小・中
バージョン 1.0.0
動作確認日 2026-09-17

表示・使用時のポイント

月の画像はダウンロードせず、計算した月齢から描画します。中サイズではコードに登録された天文イベントも表示します。

更新履歴

1.0.0

  • 初回リリース

よくあるエラーと対処

確認すること
表示されない場合は、ウィジェット編集画面の「Script」に保存したスクリプトが選ばれているかも確認してください。コードを貼り直すときは全文を置き換えます。
月齢が天文台の値と少し違う
平均的な月の周期を使う近似計算です。精密な時刻が必要な場合は天文台の情報を確認してください。
月食情報が更新されない
月食や一部のスーパームーン情報はコード内の登録データです。新しい日付を含むコードへの更新が必要です。

注意事項・制限

  • 月齢は既知の新月時刻と平均的な朔望月を使った概算です。
  • 月食などの登録データには掲載期間の限りがあります。
  • ホーム画面の更新タイミングはiOSが管理するため、操作やデータの変更がすぐに反映されない場合があります。

データ提供・参考資料

計算方式:コード内の新月基準時刻と約29.53日の平均的な朔望月を使っています。月食・スーパームーンの一部はコード内の登録データです。外部APIから毎日取得する方式ではありません。

SHARE

この記事をシェア