設定難易度

今夜、星は見える?

「今夜の星空指数」は、雲量・降水確率・月齢から、今夜の星の見えやすさを100点満点で表示するScriptableウィジェットです。

今夜のスコアだけでなく、星が見えやすいベストタイムや、これから1週間の星空指数も確認できます。

「今日より明日の方が星が見えそう」「今夜なら何時ごろが良さそう」といった判断にも使えます。

データはOpen-Meteoの天気予報を利用し、月齢はウィジェット内で計算しています。

ウィジェットの表示内容

郵便番号の地域の予報と月の条件から、星空の見やすさを独自に計算します。

サイズごとの表示

  • 小:今夜の星空指数・ベストタイム・雲量・雨・月の目安を表示します。
  • 中:7日間の見通しを確認できます。
  • 大:今夜21時〜翌3時の時間別情報と7日間の見通しを表示します。

設定手順

上から順に進めてください。コードを保存したら、ホーム画面に追加して郵便番号を入力します。

  1. Scriptableを用意する

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

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

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

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

    // Variables used by Scriptable.
    // These must be at the very top of the file. Do not edit.
    // icon-color: deep-blue; icon-glyph: star;
    // Version: 1.0.0
    
    // =====================================================
    // 今夜の星空指数 - Scriptable Widget
    // =====================================================
    //
    // Parameter:
    //   郵便番号
    //
    // 例:
    //   1000001
    //   100-0001
    //
    // 未入力:
    //   1000001(東京都千代田区)
    //
    // 小:今夜の指数 + 今夜のベストタイム
    // 中:今夜の指数 + 今夜のベストタイム + 週間スコア
    // 大:今夜の指数 + 時間別指数 + 週間スコア
    //
    // 星空指数:
    //   ・雲量
    //   ・降水確率
    //   ・月の明るさ
    //
    // 地点取得:
    //   HeartRails Geo API
    //
    // 天気:
    //   Open-Meteo
    //
    // ・APIキー不要
    // ・位置情報許可不要
    // =====================================================
    
    
    // =====================================================
    // 設定
    // =====================================================
    
    const CONFIG = {
    
    DEFAULT_ZIP: "1000001",
    
    START_HOUR: 21,
    END_HOUR: 27,
    
    BG_TOP: "#080D24",
    BG_BOTTOM: "#17264D",
    
    TEXT: "#FFFFFF",
    SUBTEXT: "#AEB8E8",
    
    BEST: "#7CFFB2",
    
    SCORE_HIGH: "#7CFFB2",
    SCORE_GOOD: "#B6F26B",
    SCORE_MID: "#FFD966",
    SCORE_LOW: "#FFA366",
    SCORE_BAD: "#FF7A7A"
    };
    
    
    // =====================================================
    // Parameter
    // =====================================================
    
    const rawParameter =
    args.widgetParameter ||
    args.shortcutParameter ||
    CONFIG.DEFAULT_ZIP;
    
    const postalCode =
    rawParameter
    .toString()
    .replace(/[^0-9]/g, "");
    
    
    // =====================================================
    // 共通
    // =====================================================
    
    async function fetchJSON(url) {
    
    const request =
    new Request(url);
    
    request.timeoutInterval = 15;
    
    return await request.loadJSON();
    }
    
    
    function clamp(v, min, max) {
    
    return Math.max(
    min,
    Math.min(max, v)
    );
    }
    
    
    function average(arr) {
    
    const valid =
    arr.filter(
      v =>
    	typeof v === "number" &&
    	Number.isFinite(v)
    );
    
    if (!valid.length) {
    return null;
    }
    
    return (
    valid.reduce(
      (a, b) => a + b,
      0
    ) / valid.length
    );
    }
    
    
    // =====================================================
    // 郵便番号 → 住所・緯度経度
    // HeartRails Geo API
    // =====================================================
    
    async function getLocation(zip) {
    
    if (!/^\d{7}$/.test(zip)) {
    
    throw new Error(
      "郵便番号は7桁で入力してください"
    );
    }
    
    
    const url =
    "https://geoapi.heartrails.com/api/json" +
    "?method=searchByPostal" +
    "&postal=" +
    encodeURIComponent(zip);
    
    
    const request =
    new Request(url);
    
    request.timeoutInterval = 15;
    
    
    const json =
    await request.loadJSON();
    
    
    if (
    !json ||
    !json.response ||
    !json.response.location ||
    json.response.location.length === 0
    ) {
    
    throw new Error(
      "郵便番号が見つかりません"
    );
    }
    
    
    const data =
    json.response.location[0];
    
    
    // HeartRails
    // x = 経度
    // y = 緯度
    
    const latitude =
    Number(data.y);
    
    const longitude =
    Number(data.x);
    
    
    if (
    !Number.isFinite(latitude) ||
    !Number.isFinite(longitude)
    ) {
    
    throw new Error(
      "緯度経度を取得できません"
    );
    }
    
    
    return {
    
    prefecture:
      data.prefecture || "",
    
    city:
      data.city || "",
    
    town:
      data.town || "",
    
    postal:
      data.postal || zip,
    
    latitude,
    longitude,
    
    // 市区町村を優先して表示
    // 取得できない場合のみ都道府県
    displayName:
      data.city ||
      data.prefecture ||
      ""
    };
    }
    
    
    // =====================================================
    // 月齢・月の明るさ
    // =====================================================
    
    function moonInfo(date) {
    
    const SYNODIC =
    29.530588853;
    
    const REF =
    Date.UTC(
      2000,
      0,
      6,
      18,
      14
    );
    
    
    const days =
    (
      date.getTime() -
      REF
    ) / 86400000;
    
    
    const age =
    (
      (
    	days % SYNODIC
      ) +
      SYNODIC
    ) % SYNODIC;
    
    
    const illum =
    (
      1 -
      Math.cos(
    	(
    	  2 *
    	  Math.PI *
    	  age
    	) /
    	SYNODIC
      )
    ) / 2;
    
    
    return {
    age,
    illum
    };
    }
    
    
    // =====================================================
    // 天気取得
    // Open-Meteo
    // =====================================================
    
    async function getWeather(
    latitude,
    longitude
    ) {
    
    const url =
    "https://api.open-meteo.com/v1/forecast" +
    
    "?latitude=" +
    encodeURIComponent(latitude) +
    
    "&longitude=" +
    encodeURIComponent(longitude) +
    
    "&hourly=" +
    encodeURIComponent(
      "cloud_cover,precipitation_probability"
    ) +
    
    "&past_days=1" +
    "&forecast_days=8" +
    "&timezone=Asia%2FTokyo";
    
    
    return await fetchJSON(url);
    }
    
    
    // =====================================================
    // 星空指数
    // =====================================================
    
    function calcScore(
    cloud,
    precipitation,
    moonIllum
    ) {
    
    const safeCloud =
    typeof cloud === "number"
      ? clamp(cloud, 0, 100)
      : 100;
    
    
    // 雲量を最重要視
    
    const clear =
    Math.pow(
      1 - safeCloud / 100,
      1.25
    );
    
    
    // 降水確率
    
    let rain = 1;
    
    
    if (
    typeof precipitation === "number" &&
    Number.isFinite(precipitation)
    ) {
    
    rain =
      1 -
      (
    	clamp(
    	  precipitation,
    	  0,
    	  100
    	) / 100
      ) * 0.75;
    }
    
    
    // 月明かり
    // 満月で最大30%減点
    
    const moon =
    1 -
    clamp(
      moonIllum,
      0,
      1
    ) * 0.30;
    
    
    return Math.round(
    clamp(
      clear *
      rain *
      moon *
      100,
      0,
      100
    )
    );
    }
    
    
    // =====================================================
    // 判定
    // =====================================================
    
    function scoreInfo(score) {
    
    if (score >= 85) {
    
    return {
      text: "最高の星空 ✨",
      color: CONFIG.SCORE_HIGH
    };
    }
    
    
    if (score >= 65) {
    
    return {
      text: "よく見えそう 🌟",
      color: CONFIG.SCORE_GOOD
    };
    }
    
    
    if (score >= 40) {
    
    return {
      text: "まずまず 🌙",
      color: CONFIG.SCORE_MID
    };
    }
    
    
    if (score >= 20) {
    
    return {
      text: "ちょっと厳しい ☁️",
      color: CONFIG.SCORE_LOW
    };
    }
    
    
    return {
    text: "星は期待薄 ☔",
    color: CONFIG.SCORE_BAD
    };
    }
    
    
    // =====================================================
    // 日付
    // =====================================================
    
    function formatDate(date) {
    
    return (
    date.getFullYear() +
    "-" +
    String(
      date.getMonth() + 1
    ).padStart(2, "0") +
    "-" +
    String(
      date.getDate()
    ).padStart(2, "0")
    );
    }
    
    
    function addDays(
    dateString,
    days
    ) {
    
    const parts =
    dateString.split("-");
    
    
    const d =
    new Date(
      Number(parts[0]),
      Number(parts[1]) - 1,
      Number(parts[2]),
      12,
      0,
      0
    );
    
    
    d.setDate(
    d.getDate() + days
    );
    
    
    return formatDate(d);
    }
    
    
    function weekdayLabel(
    dateString,
    index
    ) {
    
    if (index === 0) {
    return "今夜";
    }
    
    
    if (index === 1) {
    return "明日";
    }
    
    
    const parts =
    dateString.split("-");
    
    
    const d =
    new Date(
      Number(parts[0]),
      Number(parts[1]) - 1,
      Number(parts[2]),
      12,
      0,
      0
    );
    
    
    const week = [
    "日",
    "月",
    "火",
    "水",
    "木",
    "金",
    "土"
    ];
    
    
    return week[d.getDay()];
    }
    
    
    // =====================================================
    // 夜間データ
    // 21:00〜27:00
    // =====================================================
    
    function getNightData(
    weather,
    baseDate,
    dayOffset
    ) {
    
    const date =
    addDays(
      baseDate,
      dayOffset
    );
    
    
    const next =
    addDays(
      baseDate,
      dayOffset + 1
    );
    
    
    const rows = [];
    
    
    for (
    let hour = CONFIG.START_HOUR;
    hour <= CONFIG.END_HOUR;
    hour++
    ) {
    
    let searchDate;
    let actualHour;
    
    
    if (hour < 24) {
    
      searchDate = date;
      actualHour = hour;
    
    } else {
    
      searchDate = next;
      actualHour = hour - 24;
    }
    
    
    const time =
      searchDate +
      "T" +
      String(actualHour)
    	.padStart(2, "0") +
      ":00";
    
    
    const index =
      weather.hourly.time.indexOf(
    	time
      );
    
    
    if (index < 0) {
      continue;
    }
    
    
    const cloudValue =
      weather.hourly
    	.cloud_cover[index];
    
    
    const cloud =
      (
    	typeof cloudValue === "number" &&
    	Number.isFinite(cloudValue)
      )
    	? cloudValue
    	: null;
    
    
    const rainValue =
      weather.hourly
    	.precipitation_probability[index];
    
    
    const precipitation =
      (
    	typeof rainValue === "number" &&
    	Number.isFinite(rainValue)
      )
    	? rainValue
    	: null;
    
    
    const moonDate =
      new Date(
    	searchDate +
    	"T" +
    	String(actualHour)
    	  .padStart(2, "0") +
    	":00:00+09:00"
      );
    
    
    const moon =
      moonInfo(moonDate);
    
    
    const score =
      calcScore(
    	cloud,
    	precipitation,
    	moon.illum
      );
    
    
    rows.push({
    
      displayHour: hour,
    
      cloud,
      precipitation,
      moon,
      score
    });
    }
    
    
    return rows;
    }
    
    
    // =====================================================
    // 夜全体の集計
    // =====================================================
    
    function summarizeNight(rows) {
    
    if (!rows.length) {
    
    return {
      score: 0,
      cloud: null,
      precipitation: null,
      moonAge: 0,
      best: null
    };
    }
    
    
    const score =
    Math.round(
      average(
    	rows.map(v => v.score)
      ) || 0
    );
    
    
    const cloudAvg =
    average(
      rows.map(v => v.cloud)
    );
    
    
    const rainAvg =
    average(
      rows.map(
    	v => v.precipitation
      )
    );
    
    
    const moonAvg =
    average(
      rows.map(
    	v => v.moon.age
      )
    );
    
    
    const best =
    rows.reduce(
      (a, b) =>
    	b.score > a.score
    	  ? b
    	  : a
    );
    
    
    return {
    
    score,
    
    cloud:
      cloudAvg === null
    	? null
    	: Math.round(cloudAvg),
    
    precipitation:
      rainAvg === null
    	? null
    	: Math.round(rainAvg),
    
    moonAge:
      moonAvg === null
    	? 0
    	: moonAvg,
    
    best
    };
    }
    
    
    // =====================================================
    // 表示用
    // =====================================================
    
    function percentText(value) {
    
    if (
    value === null ||
    value === undefined
    ) {
    
    return "—";
    }
    
    
    return value + "%";
    }
    
    
    function createWidget() {
    
    const widget =
    new ListWidget();
    
    
    const gradient =
    new LinearGradient();
    
    
    gradient.colors = [
    
    new Color(
      CONFIG.BG_TOP
    ),
    
    new Color(
      CONFIG.BG_BOTTOM
    )
    ];
    
    
    gradient.locations =
    [0, 1];
    
    
    widget.backgroundGradient =
    gradient;
    
    
    return widget;
    }
    
    
    // =====================================================
    // スコア表示
    // 数字大 / 「点」小
    // =====================================================
    
    function addScoreDisplay(
    parent,
    score,
    numberSize,
    pointSize,
    center = false
    ) {
    
    const row =
    parent.addStack();
    
    
    row.layoutHorizontally();
    
    
    if (center) {
    row.addSpacer();
    }
    
    
    const number =
    row.addText(
      String(score)
    );
    
    
    number.font =
    Font.boldRoundedSystemFont(
      numberSize
    );
    
    
    number.textColor =
    new Color(
      scoreInfo(score).color
    );
    
    
    row.addSpacer(3);
    
    
    const pointStack =
    row.addStack();
    
    
    pointStack.layoutVertically();
    
    
    pointStack.addSpacer(
    Math.max(
      0,
      numberSize -
      pointSize -
      5
    )
    );
    
    
    const point =
    pointStack.addText("点");
    
    
    point.font =
    Font.boldSystemFont(
      pointSize
    );
    
    
    point.textColor =
    new Color(
      scoreInfo(score).color
    );
    
    
    if (center) {
    row.addSpacer();
    }
    
    
    return row;
    }
    
    
    // =====================================================
    // タイトル
    // =====================================================
    
    function addTitle(
    widget,
    displayName,
    mode
    ) {
    
    const row =
    widget.addStack();
    
    
    row.layoutHorizontally();
    
    
    // 小サイズのみ中央
    
    if (mode === "small") {
    
    row.addSpacer();
    
    
    const title =
      row.addText(
    	"⭐️ 今夜の星空指数"
      );
    
    
    title.font =
      Font.semiboldSystemFont(13);
    
    
    title.textColor =
      Color.white();
    
    
    row.addSpacer();
    
    return;
    }
    
    
    const title =
    row.addText(
      "⭐️ 今夜の星空指数"
    );
    
    
    title.font =
    Font.semiboldSystemFont(13);
    
    
    title.textColor =
    Color.white();
    
    
    row.addSpacer();
    
    
    const loc =
    row.addText(
      displayName
    );
    
    
    loc.font =
    Font.systemFont(11);
    
    
    loc.textColor =
    new Color(
      CONFIG.SUBTEXT
    );
    }
    
    
    // =====================================================
    // 小サイズ
    // =====================================================
    
    function buildSmall(
    displayName,
    tonight
    ) {
    
    const widget =
    createWidget();
    
    
    widget.setPadding(
    13,
    12,
    12,
    12
    );
    
    
    addTitle(
    widget,
    displayName,
    "small"
    );
    
    widget.addSpacer();
    
    
    // メインスコア
    
    addScoreDisplay(
    widget,
    tonight.score,
    55,
    18,
    true
    );
    
    
    // 判定
    
    const msg =
    widget.addText(
      scoreInfo(
    	tonight.score
      ).text
    );
    
    
    msg.font =
    Font.semiboldSystemFont(12);
    
    
    msg.textColor =
    Color.white();
    
    
    msg.centerAlignText();
    
    
    widget.addSpacer(2);
    
    
    // 今夜のベストタイム
    
    if (tonight.best) {
    
      const best =
    	widget.addText(
    	  "今夜のベストタイム  " +
    	  tonight.best.displayHour +
    	  ":00"
    	);
    
      best.font =
    	Font.systemFont(9);
    
      best.textColor =
    	new Color(
    	  CONFIG.SUBTEXT
    	);
    
      best.centerAlignText();
    }
    
    
    // 市区町村
    
    const place =
      widget.addText(
    	displayName
      );
    
    place.font =
      Font.mediumSystemFont(8);
    
    place.textColor =
      new Color(
    	CONFIG.SUBTEXT
      );
    
    place.centerAlignText();
    
    place.lineLimit = 1;
    
    
    widget.addSpacer();
    
    
    // 詳細
    
    const info =
    widget.addText(
    	"☁️ " +
    	percentText(tonight.cloud) +
    	"   💧 " +
    	percentText(
    	tonight.precipitation
    	) +
    	"   🌙 " +
    	tonight.moonAge.toFixed(1)
    );
    
    
    info.font =
    Font.systemFont(9);
    
    
    info.textColor =
    new Color(
    	CONFIG.SUBTEXT
    );
    
    
    info.centerAlignText();
    
    
    return widget;
    }
    
    
    // =====================================================
    // 中サイズ
    // =====================================================
    
    function buildMedium(
    displayName,
    tonight,
    week
    ) {
    
    const widget =
    createWidget();
    
    
    widget.setPadding(
    12,
    14,
    11,
    14
    );
    
    
    // ===================================================
    // タイトル
    // ===================================================
    
    addTitle(
    widget,
    displayName,
    "medium"
    );
    
    
    widget.addSpacer(6);
    
    
    // ===================================================
    // メイン
    // 左:今夜の詳細
    // 右:今週のスコア
    // ===================================================
    
    const upper =
    widget.addStack();
    
    upper.layoutHorizontally();
    
    
    // ===================================================
    // 左
    // ===================================================
    
    const left =
    upper.addStack();
    
    left.layoutVertically();
    
    left.size =
    new Size(
      190,
      0
    );
    
    
    // ---------------------------------------------------
    // スコア
    // ---------------------------------------------------
    
    addScoreDisplay(
    left,
    tonight.score,
    45,
    15,
    false
    );
    
    
    // ---------------------------------------------------
    // 判定
    // ---------------------------------------------------
    
    const msg =
    left.addText(
      scoreInfo(
    	tonight.score
      ).text
    );
    
    msg.font =
    Font.semiboldSystemFont(11);
    
    msg.textColor =
    Color.white();
    
    msg.lineLimit = 1;
    
    
    left.addSpacer(3);
    
    
    // ---------------------------------------------------
    // 今夜のベストタイム
    // ---------------------------------------------------
    
    if (tonight.best) {
    
    const best =
      left.addText(
    	"今夜のベストタイム  " +
    	tonight.best.displayHour +
    	":00"
      );
    
    best.font =
      Font.systemFont(9);
    
    best.textColor =
      new Color(
    	CONFIG.SUBTEXT
      );
    
    best.lineLimit = 1;
    }
    
    
    left.addSpacer(6);
    
    
    // ---------------------------------------------------
    // 雲量 + 降水
    // ---------------------------------------------------
    
    const weatherRow =
    left.addStack();
    
    weatherRow.layoutHorizontally();
    
    
    const cloud =
    weatherRow.addText(
      "☁️ 雲量 " +
      percentText(
    	tonight.cloud
      )
    );
    
    cloud.font =
    Font.systemFont(9);
    
    cloud.textColor =
    new Color(
      CONFIG.SUBTEXT
    );
    
    cloud.lineLimit = 1;
    
    
    weatherRow.addSpacer(12);
    
    
    const rain =
    weatherRow.addText(
      "💧 降水 " +
      percentText(
    	tonight.precipitation
      )
    );
    
    rain.font =
    Font.systemFont(9);
    
    rain.textColor =
    new Color(
      CONFIG.SUBTEXT
    );
    
    rain.lineLimit = 1;
    
    
    left.addSpacer(3);
    
    
    // ---------------------------------------------------
    // 月齢
    // ---------------------------------------------------
    
    const moon =
    left.addText(
      "🌙 月齢 " +
      tonight.moonAge.toFixed(1)
    );
    
    moon.font =
    Font.systemFont(9);
    
    moon.textColor =
    new Color(
      CONFIG.SUBTEXT
    );
    
    moon.lineLimit = 1;
    
    
    // ===================================================
    // 左右の空白
    // ===================================================
    
    upper.addSpacer(14);
    
    
    // ===================================================
    // 右
    // 今週のスコア
    // ===================================================
    
    const right =
    upper.addStack();
    
    right.layoutVertically();
    
    right.size =
    new Size(
      95,
      0
    );
    
    
    const weekTitle =
    right.addText(
      "今週のスコア"
    );
    
    weekTitle.font =
    Font.semiboldSystemFont(10);
    
    weekTitle.textColor =
    Color.white();
    
    weekTitle.lineLimit = 1;
    
    
    right.addSpacer(3);
    
    
    // ---------------------------------------------------
    // 今週最高スコア
    // ---------------------------------------------------
    
    const bestWeek =
    Math.max(
      ...week.map(
    	v => v.summary.score
      )
    );
    
    
    // ---------------------------------------------------
    // 7日分
    // ---------------------------------------------------
    
    for (
    let i = 0;
    i < week.length;
    i++
    ) {
    
    const item =
      week[i];
    
    
    const row =
      right.addStack();
    
    row.layoutHorizontally();
    
    row.centerAlignContent();
    
    
    // 日
    
    const day =
      row.addText(
    	item.label
      );
    
    day.font =
      Font.mediumSystemFont(9);
    
    day.textColor =
      new Color(
    	CONFIG.SUBTEXT
      );
    
    day.lineLimit = 1;
    
    
    row.addSpacer();
    
    
    // スコア
    
    const score =
      row.addText(
    	String(
    	  item.summary.score
    	)
      );
    
    score.font =
      Font.boldRoundedSystemFont(11);
    
    score.textColor =
      new Color(
    	item.summary.score === bestWeek
    	  ? CONFIG.BEST
    	  : CONFIG.TEXT
      );
    
    score.lineLimit = 1;
    
    
    if (
      i <
      week.length - 1
    ) {
    
      right.addSpacer(1);
    }
    }
    
    
    return widget;
    }
    
    
    // =====================================================
    // 大サイズ
    // =====================================================
    
    function buildLarge(
    displayName,
    tonightRows,
    tonight,
    week
    ) {
    
    const widget =
    createWidget();
    
    
    widget.setPadding(
    15,
    16,
    15,
    16
    );
    
    
    addTitle(
    widget,
    displayName,
    "large"
    );
    
    
    widget.addSpacer(6);
    
    
    // ===================================================
    // 今日のスコア + 判定
    // ===================================================
    
    const result =
    widget.addStack();
    
    
    result.layoutHorizontally();
    
    result.centerAlignContent();
    
    
    addScoreDisplay(
    result,
    tonight.score,
    70,
    14,
    false
    );
    
    
    result.addSpacer(12);
    
    
    // ---------------------------------------------------
    // 判定
    // ---------------------------------------------------
    
    const msg =
    result.addText(
      scoreInfo(
    	tonight.score
      ).text
    );
    
    msg.font =
    Font.semiboldSystemFont(15);
    
    msg.textColor =
    Color.white();
    
    msg.lineLimit = 1;
    
    // ---------------------------------------------------
    
    widget.addSpacer(11);
    
    
    // ===================================================
    // 下段
    // 45% / 10% / 45%
    // ===================================================
    
    const columns =
    widget.addStack();
    
    
    columns.layoutHorizontally();
    
    
    // ===================================================
    // 左
    // ===================================================
    
    const left =
    columns.addStack();
    
    
    left.layoutVertically();
    
    
    left.size =
    new Size(
      135,
      0
    );
    
    
    const lt =
    left.addText(
      "今夜のベストタイム"
    );
    
    
    lt.font =
    Font.semiboldSystemFont(12);
    
    
    lt.textColor =
    Color.white();
    
    
    left.addSpacer(6);
    
    
    const bestHour =
    Math.max(
      ...tonightRows.map(
    	v => v.score
      )
    );
    
    
    for (
    const row of tonightRows
    ) {
    
    const r =
      left.addStack();
    
    
    r.layoutHorizontally();
    
    
    const hour =
      r.addText(
    	row.displayHour +
    	":00"
      );
    
    
    hour.font =
      Font.mediumSystemFont(11);
    
    
    hour.textColor =
      new Color(
    	CONFIG.SUBTEXT
      );
    
    
    r.addSpacer();
    
    
    const sc =
      r.addText(
    	String(row.score)
      );
    
    
    sc.font =
      Font.boldRoundedSystemFont(
    	14
      );
    
    
    sc.textColor =
      new Color(
    	row.score === bestHour
    	  ? CONFIG.BEST
    	  : CONFIG.TEXT
      );
    
    
    left.addSpacer(4);
    }
    
    
    // ===================================================
    // 中央
    // ===================================================
    
    columns.addSpacer(30);
    
    
    // ===================================================
    // 右
    // ===================================================
    
    const right =
    columns.addStack();
    
    
    right.layoutVertically();
    
    
    right.size =
    new Size(
      135,
      0
    );
    
    
    const rt =
    right.addText(
      "今週のスコア"
    );
    
    
    rt.font =
    Font.semiboldSystemFont(12);
    
    
    rt.textColor =
    Color.white();
    
    
    right.addSpacer(6);
    
    
    const bestWeek =
    Math.max(
      ...week.map(
    	v => v.summary.score
      )
    );
    
    
    for (
    const item of week
    ) {
    
    const r =
      right.addStack();
    
    
    r.layoutHorizontally();
    
    
    const day =
      r.addText(
    	item.label
      );
    
    
    day.font =
      Font.mediumSystemFont(11);
    
    
    day.textColor =
      new Color(
    	CONFIG.SUBTEXT
      );
    
    
    r.addSpacer();
    
    
    const sc =
      r.addText(
    	String(
    	  item.summary.score
    	)
      );
    
    
    sc.font =
      Font.boldRoundedSystemFont(
    	14
      );
    
    
    sc.textColor =
      new Color(
    	item.summary.score ===
    	bestWeek
    	  ? CONFIG.BEST
    	  : CONFIG.TEXT
      );
    
    
    right.addSpacer(4);
    }
    
    
    // ===================================================
    // 下段
    // 雲量 → 降水 → 月齢
    // ===================================================
    
    widget.addSpacer();
    
    
    const details =
      widget.addText(
    	"☁️ 雲量 " +
    	percentText(
    	  tonight.cloud
    	) +
    	"    💧 降水 " +
    	percentText(
    	  tonight.precipitation
    	) +
    	"    🌙 月齢 " +
    	tonight.moonAge.toFixed(1)
      );
    
    
    details.font =
      Font.systemFont(11);
    
    
    details.textColor =
      new Color(
    	CONFIG.SUBTEXT
      );
    
    return widget;
    }
    
    
    // =====================================================
    // エラーWidget
    // =====================================================
    
    function createErrorWidget(
    message
    ) {
    
    const widget =
    createWidget();
    
    
    widget.setPadding(
    15,
    15,
    15,
    15
    );
    
    
    const title =
    widget.addText(
      "⭐️ 今夜の星空指数"
    );
    
    
    title.font =
    Font.semiboldSystemFont(13);
    
    
    title.textColor =
    Color.white();
    
    
    widget.addSpacer(10);
    
    
    const error =
    widget.addText(
      message
    );
    
    
    error.font =
    Font.systemFont(11);
    
    
    error.textColor =
    new Color(
      CONFIG.SUBTEXT
    );
    
    
    error.lineLimit = 4;
    
    
    return widget;
    }
    
    
    // =====================================================
    // メイン
    // =====================================================
    
    let widget;
    
    
    try {
    
    // ===================================================
    // 地点
    // ===================================================
    
    const location =
    await getLocation(
      postalCode
    );
    
    
    // ===================================================
    // 天気
    // ===================================================
    
    const weather =
    await getWeather(
      location.latitude,
      location.longitude
    );
    
    
    if (
    !weather ||
    !weather.hourly ||
    !weather.hourly.time
    ) {
    
    throw new Error(
      "天気予報を取得できませんでした"
    );
    }
    
    
    // ===================================================
    // 日本時間
    // ===================================================
    
    const now =
    new Date();
    
    
    const formatter =
    new DateFormatter();
    
    
    formatter.dateFormat =
    "yyyy-MM-dd";
    
    
    formatter.timeZone =
    "Asia/Tokyo";
    
    
    let baseDate =
    formatter.string(now);
    
    
    const hourFormatter =
    new DateFormatter();
    
    
    hourFormatter.dateFormat =
    "H";
    
    
    hourFormatter.timeZone =
    "Asia/Tokyo";
    
    
    const currentHour =
    Number(
      hourFormatter.string(now)
    );
    
    
    // 0〜3時は前日の夜扱い
    
    if (currentHour < 4) {
    
    const yesterday =
      new Date(
    	now.getTime() -
    	86400000
      );
    
    
    baseDate =
      formatter.string(
    	yesterday
      );
    }
    
    
    // ===================================================
    // 今夜
    // ===================================================
    
    const tonightRows =
    getNightData(
      weather,
      baseDate,
      0
    );
    
    
    if (!tonightRows.length) {
    
    throw new Error(
      "今夜の予報データを取得できませんでした"
    );
    }
    
    
    const tonight =
    summarizeNight(
      tonightRows
    );
    
    
    // ===================================================
    // 週間
    // ===================================================
    
    const week = [];
    
    
    for (
    let i = 0;
    i < 7;
    i++
    ) {
    
    const rows =
      getNightData(
    	weather,
    	baseDate,
    	i
      );
    
    
    const date =
      addDays(
    	baseDate,
    	i
      );
    
    
    week.push({
    
      date,
    
      label:
    	weekdayLabel(
    	  date,
    	  i
    	),
    
      rows,
    
      summary:
    	summarizeNight(rows)
    });
    }
    
    
    // ===================================================
    // Widget
    // ===================================================
    
    const family =
    config.widgetFamily ||
    "small";
    
    
    if (family === "large") {
    
    widget =
      buildLarge(
    	location.displayName,
    	tonightRows,
    	tonight,
    	week
      );
    
    } else if (
    family === "medium"
    ) {
    
    widget =
      buildMedium(
    	location.displayName,
    	tonight,
    	week
      );
    
    } else {
    
    widget =
      buildSmall(
    	location.displayName,
    	tonight
      );
    }
    
    
    // ===================================================
    // Shortcut
    // ===================================================
    
    if (args.shortcutParameter) {
    
    Script.setShortcutOutput(
      location.prefecture +
      location.city +
      " 星空指数 " +
      tonight.score +
      "点 " +
      scoreInfo(
    	tonight.score
      ).text
    );
    }
    
    
    } catch (error) {
    
    console.error(error);
    
    
    widget =
    createErrorWidget(
      String(
    	error.message ||
    	error
      )
    );
    }
    
    
    // =====================================================
    // 表示
    // =====================================================
    
    if (args.shortcutParameter) {
    
    // Shortcutは出力のみ
    
    } else if (
    config.runsInWidget
    ) {
    
    Script.setWidget(widget);
    
    } else {
    
    const family =
    config.widgetFamily ||
    "small";
    
    
    if (family === "large") {
    
    await widget.presentLarge();
    
    } else if (
    family === "medium"
    ) {
    
    await widget.presentMedium();
    
    } else {
    
    await widget.presentSmall();
    }
    }
    
    
    Script.complete();
  3. ホーム画面に追加して設定する

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

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

    「Parameter」に確認したい地域の7桁の郵便番号を入力します。例:1000001。ハイフンあり・なしのどちらでも使えます。

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

星空指数について

星空指数は、主に次の3つから算出しています。

  • ☁️ 雲量:雲が少ないほど高得点
  • 💧 降水確率:雨の可能性が低いほど高得点
  • 🌙 月齢・月明かり:月が明るいほど星が見えにくいため減点

特に雲量を重視しているため、よく晴れていて月明かりが少ない夜ほど高いスコアになります。

なお、星空指数は独自に算出した目安です。実際の星の見え方は、街明かり・霧・大気の状態・周囲の環境などによっても変わります。

今夜のベストタイム

21:00〜翌3:00までを1時間ごとに計算し、もっとも星空指数が高くなる時間帯を表示します。

大サイズでは、

21:00 64
22:00 78
23:00 94
24:00 91
25:00 84
のように時間ごとのスコアも確認できます。

※24:00以降は、翌日の0:00〜3:00を24:00〜27:00として表示しています。

動作確認・使用結果

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

表示・使用時のポイント

郵便番号の地域の予報と月の条件から、星空の見やすさを独自に計算します。大サイズの24〜27時は翌日の0〜3時を指します。

更新履歴

1.0.0

  • 初回リリース

よくあるエラーと対処

確認すること
表示されない場合は、ウィジェット編集画面の「Script」に保存したスクリプトが選ばれているかも確認してください。コードを貼り直すときは全文を置き換えます。
地域が違う
Parameterの7桁の郵便番号を確認してください。
時間別の値が出ない
対象時間帯の予報が不足している場合があります。通信を確認して再取得してください。
指数が高いのに星が見えない
光害や局地的な霧など、計算に含まれない条件もあります。

注意事項・制限

  • 星空指数は独自の目安で、天文台の観測評価ではありません。
  • 予報は更新されるため、夜のベストタイムも変わる場合があります。
  • ホーム画面の更新タイミングはiOSが管理するため、操作やデータの変更がすぐに反映されない場合があります。

データ提供・参考資料

Open-Meteo:天気予報データ。
HeartRails Geo API:郵便番号から地域と座標を取得します。
月の条件はコード内の計算を使っています。

SHARE

この記事をシェア