
近くの河川の水位を、iPhoneのホーム画面からチェックできるウィジェットです。
郵便番号を設定するだけで、国土交通省「水文水質データベース」の水位観測所から、近くの観測所を自動で選択します。
現在の水位だけでなく、10分前・1時間前との変化や、直近24時間の最高・最低水位も確認できます。
さらに観測所に基準水位が設定されている場合は、現在の水位と比較して危険度を表示します。
🟢 通常 → 🟡 氾濫注意 → 🟠 避難判断 → 🔴 氾濫危険
大サイズでは「氾濫注意水位まで あと○○m」のように、次の基準水位までの差も確認できます。
ウィジェットの表示内容
郵便番号の近くから、取得条件に合う水位観測所を選びます。
サイズごとの表示
- 小:現在の水位・1時間の変化・状態の目安を表示します。
- 中:10分・1時間の変化や直近24時間の最大・最小などを表示します。
- 大:直近24時間のグラフと、観測所に基準水位がある場合はその差も表示します。
設定手順
上から順に進めてください。コードを保存したら、ホーム画面に追加して郵便番号を入力します。
- Scriptableを用意する
App StoreでScriptableを開き、iPhoneにインストールします。すでに入っている場合は次へ進んでください。
- コードをコピーして保存する
下のコード欄の「コピー」を押します。Scriptableへ移動し、「+」から新しいスクリプトを作ってコード全文を貼り付け、河川水位モニターなどの名前で保存してください。
保存したらこのページに戻り、次の手順へ進みます。
// Variables used by Scriptable. // These must be at the very top of the file. Do not edit. // icon-color: blue; icon-glyph: water; // Version: 1.0.0 // ===================================================== // 河川水位モニター - Scriptable Widget (基準水位対応版) // // Parameter: // 郵便番号 (例: 1000001 / 100-0001) // // ・APIキー不要 / 位置情報許可不要 // ・郵便番号から同一都道府県内の最寄り水位観測所を自動選択 // ・国土交通省 水文水質データベース // ・リアルタイム10分水位を取得 // ・観測所の基準水位(氾濫注意 / 避難判断 / 氾濫危険)と比較して // 🟢通常 → 🟡氾濫注意 → 🟠避難判断 → 🔴氾濫危険 を表示 // // ※ 基準水位が設定されていない観測所では「基準水位なし」と表示します。 // ※ あくまで目安です。避難情報は川の防災情報・自治体の発表を確認してください。 // ===================================================== // ===================================================== // 設定 // ===================================================== const CONFIG = { DEFAULT_ZIP: "1000001", ZIP_API: "https://zipcloud.ibsnet.co.jp/api/search?zipcode=", GEO_API: "https://msearch.gsi.go.jp/address-search/AddressSearch?q=", RIVER_BASE: "https://www1.river.go.jp", CACHE_HOURS: 168, REFRESH_MINUTES: 20, // 観測所詳細を同時取得する最大数 STATION_BATCH_SIZE: 8, // リアルタイム水位を確認する最寄り候補数 REALTIME_CANDIDATES: 5, // 基準水位のキャッシュ期間(時間) THRESHOLD_CACHE_HOURS: 720, // true: 近い順に見て「基準水位のある観測所」を優先して選ぶ // (最寄りでない観測所になることがある) // false: 従来どおり、リアルタイム水位が取れる最寄りの観測所 PREFER_THRESHOLD_STATIONS: false, // PREFER_THRESHOLD_STATIONS が true のときに確認する候補数 THRESHOLD_CANDIDATES: 10, // 表示テスト用。数値(例: 3.4)を入れると現在水位をその値として扱う。 // テスト後は必ず null に戻すこと。 TEST_LEVEL: null, // Scriptable内プレビューのサイズ: "small" | "medium" | "large" PREVIEW_SIZE: "medium" }; // ===================================================== // Parameter // ===================================================== let zip = String( args.widgetParameter || CONFIG.DEFAULT_ZIP ).replace(/[^\d]/g, ""); if (zip.length !== 7) { zip = CONFIG.DEFAULT_ZIP; } // ===================================================== // メイン // ===================================================== let result; try { // 郵便番号 → 住所 const address = await getAddress(zip); // 住所 → 座標(町域 → 市区町村 → 都道府県の順に再試行) const point = await getAddressCoordinates(address); // 都道府県 → KEN const ken = getKenCode(address.pref); if (!ken) { throw new Error("都道府県コードを取得できません"); } // 同一都道府県内の観測所一覧 const stations = await getStationsCached(ken); if (!stations.length) { throw new Error("水位観測所が見つかりません"); } // 最寄り観測所ランキング const ranked = stations .filter(s => Number.isFinite(s.lat) && Number.isFinite(s.lon)) .map(s => ({ ...s, distance: distanceKm(point.lat, point.lon, s.lat, s.lon) })) .sort((a, b) => a.distance - b.distance); if (!ranked.length) { throw new Error("観測所座標を取得できません"); } // --------------------------------------------------- // 近い順にリアルタイム水位を確認 // --------------------------------------------------- let selected = null; let waterData = null; let thresholds = null; let fallback = null; const candidateCount = CONFIG.PREFER_THRESHOLD_STATIONS ? CONFIG.THRESHOLD_CANDIDATES : CONFIG.REALTIME_CANDIDATES; for (const station of ranked.slice(0, candidateCount)) { try { const data = await getRealtimeWater(station.id); if (!data || !data.length) continue; if (!CONFIG.PREFER_THRESHOLD_STATIONS) { selected = station; waterData = data; break; } // 基準水位のある観測所を優先 const th = await getThresholdsCached(station.id); if (th) { selected = station; waterData = data; thresholds = th; break; } if (!fallback) { fallback = { station, data }; } } catch (e) { console.log(`リアルタイム取得失敗: ${station.name}`); console.log(String(e)); } } if (!selected && fallback) { selected = fallback.station; waterData = fallback.data; } if (!selected || !waterData || !waterData.length) { throw new Error("リアルタイム水位を取得できません"); } // 優先モードでない場合は、選ばれた観測所の基準水位だけ取得 if (!CONFIG.PREFER_THRESHOLD_STATIONS) { thresholds = await getThresholdsCached(selected.id); } // 日時順を保証(新しい → 古い) waterData = waterData .filter(item => Number.isFinite(item.level) && Number.isFinite(item.timestamp) ) .sort((a, b) => b.timestamp - a.timestamp); if (!waterData.length) { throw new Error("有効な水位データがありません"); } // 表示テスト用の水位上書き const latest = Number.isFinite(CONFIG.TEST_LEVEL) ? { ...waterData[0], level: CONFIG.TEST_LEVEL } : waterData[0]; // 実際の日時から10分前 / 1時間前を検索(最新より過去のみ) const tenMinutesAgo = findClosestPastData( waterData, latest.timestamp - 10 * 60 * 1000, latest.timestamp ); const oneHourAgo = findClosestPastData( waterData, latest.timestamp - 60 * 60 * 1000, latest.timestamp ); const diff10 = calculateDifference(latest, tenMinutesAgo); const diffHour = calculateDifference(latest, oneHourAgo); // 最大24時間 const cutoff24h = latest.timestamp - 24 * 60 * 60 * 1000; const last24h = waterData.filter(item => item.timestamp >= cutoff24h); // 最高 / 最低 let max24 = null; let min24 = null; if (last24h.length) { const levels = last24h.map(item => item.level); max24 = Math.max(...levels); min24 = Math.min(...levels); } const periodHours = getPeriodHours(last24h); const periodLabel = getPeriodLabel(periodHours); // 基準水位との比較 const status = getFloodStatus(latest.level, thresholds); result = { zip, area: address.pref + address.city + address.town, station: selected, latest, tenMinutesAgo, oneHourAgo, diff10, diff: diffHour, max24, min24, data: waterData, last24h, periodHours, periodLabel, thresholds, status }; } catch (e) { console.log("河川水位モニター エラー"); console.log(String(e)); result = { error: String(e) }; } // ===================================================== // Widget // ===================================================== const widget = new ListWidget(); widget.backgroundColor = new Color("#111318"); widget.setPadding(15, 15, 15, 15); widget.refreshAfterDate = new Date( Date.now() + CONFIG.REFRESH_MINUTES * 60 * 1000 ); if (result.error) { renderError(widget, result.error); } else { // 避難判断以上は背景をわずかに着色 const bg = statusBackground(result.status); if (bg) { widget.backgroundColor = new Color(bg); } switch (config.widgetFamily) { case "small": renderSmall(widget, result); break; case "large": renderLarge(widget, result); break; default: renderMedium(widget, result); } } Script.setWidget(widget); // ===================================================== // Scriptable内プレビュー // ===================================================== if (!config.runsInWidget) { if (CONFIG.PREVIEW_SIZE === "small") { await widget.presentSmall(); } else if (CONFIG.PREVIEW_SIZE === "large") { await widget.presentLarge(); } else { await widget.presentMedium(); } } Script.complete(); // ===================================================== // 小サイズ // ===================================================== function renderSmall(w, d) { const title = w.addText("🌊 河川水位"); title.font = Font.boldSystemFont(12); title.textColor = Color.white(); w.addSpacer(4); const river = w.addText( `${d.station.river}・${cleanStationName(d.station.name)}` ); river.font = Font.mediumSystemFont(11); river.textColor = new Color("#AEB4BE"); river.lineLimit = 1; river.minimumScaleFactor = 0.7; w.addSpacer(); const level = w.addText(`${formatLevel(d.latest.level)} m`); level.font = Font.boldSystemFont(30); level.textColor = levelColor(d.status); w.addSpacer(3); const change = w.addText(changeText(d.diff)); change.font = Font.semiboldSystemFont(11); change.textColor = changeColor(d.diff); w.addSpacer(3); const badge = w.addText(statusBadgeText(d.status)); badge.font = Font.semiboldSystemFont(10); badge.textColor = statusTextColor(d.status); badge.lineLimit = 1; badge.minimumScaleFactor = 0.7; w.addSpacer(); const time = w.addText(`${formatTime(d.latest)} 更新`); time.font = Font.systemFont(10); time.textColor = new Color("#777D87"); } // ===================================================== // 中サイズ // ===================================================== function renderMedium(w, d) { const header = w.addStack(); const title = header.addText("🌊 河川水位"); title.font = Font.boldSystemFont(13); title.textColor = Color.white(); header.addSpacer(); const location = header.addText(d.area); location.font = Font.systemFont(10); location.textColor = new Color("#777D87"); location.lineLimit = 1; w.addSpacer(8); const main = w.addStack(); // 左 const left = main.addStack(); left.layoutVertically(); const river = left.addText( `${d.station.river} ${cleanStationName(d.station.name)}` ); river.font = Font.semiboldSystemFont(12); river.textColor = new Color("#AEB4BE"); left.addSpacer(5); const level = left.addText(`${formatLevel(d.latest.level)} m`); level.font = Font.boldSystemFont(28); level.textColor = levelColor(d.status); const label = left.addText("現在水位"); label.font = Font.systemFont(10); label.textColor = new Color("#777D87"); left.addSpacer(3); const badge = left.addText(statusBadgeText(d.status)); badge.font = Font.semiboldSystemFont(11); badge.textColor = statusTextColor(d.status); main.addSpacer(); // 右 const right = main.addStack(); right.layoutVertically(); addStatRow(right, "10分前比", formatDiff(d.diff10), changeColor(d.diff10)); addStatRow(right, "1時間前比", formatDiff(d.diff), changeColor(d.diff)); addStatRow(right, `${d.periodLabel}最高`, `${formatLevel(d.max24)}m`, Color.white()); addStatRow(right, `${d.periodLabel}最低`, `${formatLevel(d.min24)}m`, Color.white()); w.addSpacer(); // Footer const footer = w.addStack(); const station = footer.addText( `${stationLabel()} ${d.station.distance.toFixed(1)}km` ); station.font = Font.systemFont(10); station.textColor = new Color("#777D87"); footer.addSpacer(); const time = footer.addText(`${formatTime(d.latest)} 更新`); time.font = Font.systemFont(10); time.textColor = new Color("#777D87"); } // ===================================================== // 大サイズ // ===================================================== function renderLarge(w, d) { // Header const header = w.addStack(); const title = header.addText("🌊 河川水位モニター"); title.font = Font.boldSystemFont(15); title.textColor = Color.white(); header.addSpacer(); const update = header.addText(`${formatTime(d.latest)} 更新`); update.font = Font.systemFont(10); update.textColor = new Color("#777D87"); w.addSpacer(3); const location = w.addText(d.area); location.font = Font.systemFont(10); location.textColor = new Color("#777D87"); w.addSpacer(8); const river = w.addText( `${d.station.river} ${cleanStationName(d.station.name)}` ); river.font = Font.semiboldSystemFont(14); river.textColor = new Color("#AEB4BE"); w.addSpacer(7); // Summary const summary = w.addStack(); const current = summary.addStack(); current.layoutVertically(); const level = current.addText(`${formatLevel(d.latest.level)} m`); level.font = Font.boldSystemFont(31); level.textColor = levelColor(d.status); const levelLabel = current.addText("現在水位"); levelLabel.font = Font.systemFont(10); levelLabel.textColor = new Color("#777D87"); summary.addSpacer(); const change = summary.addStack(); change.layoutVertically(); const diff = change.addText(formatDiff(d.diff)); diff.font = Font.boldSystemFont(21); diff.textColor = changeColor(d.diff); const diffLabel = change.addText("1時間前比"); diffLabel.font = Font.systemFont(10); diffLabel.textColor = new Color("#777D87"); w.addSpacer(6); // 危険度 const flood = w.addStack(); const badge = flood.addText(statusBadgeText(d.status)); badge.font = Font.boldSystemFont(13); badge.textColor = statusTextColor(d.status); flood.addSpacer(); const nextText = flood.addText(statusNextText(d.status)); nextText.font = Font.systemFont(9); nextText.textColor = new Color("#777D87"); w.addSpacer(8); // Graph title const graphHeader = w.addStack(); const graphTitle = graphHeader.addText(`${d.periodLabel}の水位推移`); graphTitle.font = Font.semiboldSystemFont(12); graphTitle.textColor = new Color("#AEB4BE"); graphHeader.addSpacer(); const range = graphHeader.addText( `${formatLevel(d.min24)} − ${formatLevel(d.max24)}m` ); range.font = Font.systemFont(9); range.textColor = new Color("#777D87"); w.addSpacer(5); // Graph const graph = createWaterGraph(d.last24h, 620, 140, d.thresholds); const image = w.addImage(graph); image.resizable = true; image.imageSize = new Size(310, 70); w.addSpacer(5); // 時刻 const timeRow = w.addStack(); const oldest = d.last24h.length ? d.last24h[d.last24h.length - 1] : null; const leftTime = timeRow.addText(formatGraphTime(oldest)); leftTime.font = Font.systemFont(8); leftTime.textColor = new Color("#60656E"); timeRow.addSpacer(); const nowText = timeRow.addText("現在"); nowText.font = Font.systemFont(8); nowText.textColor = new Color("#60656E"); w.addSpacer(9); // Stats const stats = w.addStack(); const max = stats.addText(`${d.periodLabel}最高 ${formatLevel(d.max24)}m`); max.font = Font.semiboldSystemFont(11); max.textColor = Color.white(); stats.addSpacer(); const min = stats.addText(`${d.periodLabel}最低 ${formatLevel(d.min24)}m`); min.font = Font.semiboldSystemFont(11); min.textColor = Color.white(); w.addSpacer(); // Footer const footer = w.addStack(); const station = footer.addText( `${stationLabel()} ${d.station.distance.toFixed(1)}km` ); station.font = Font.systemFont(9); station.textColor = new Color("#777D87"); footer.addSpacer(); const stationName = footer.addText(cleanStationName(d.station.name)); stationName.font = Font.systemFont(9); stationName.textColor = new Color("#777D87"); w.addSpacer(3); const notice = w.addText("防災判断には国土交通省・自治体の公式情報を確認してください"); notice.font = Font.systemFont(8); notice.textColor = new Color("#60656E"); notice.lineLimit = 1; notice.minimumScaleFactor = 0.7; } // ===================================================== // 水位グラフ(基準水位ライン付き) // ===================================================== function createWaterGraph(source, width, height, thresholds) { const ctx = new DrawContext(); ctx.size = new Size(width, height); ctx.opaque = false; ctx.respectScreenScale = true; if (!source || source.length < 2) { return ctx.getImage(); } // 新しい→古い を 古い→新しい へ const data = [...source] .sort((a, b) => a.timestamp - b.timestamp) .filter(item => Number.isFinite(item.level)); if (data.length < 2) { return ctx.getImage(); } const values = data.map(item => item.level); let min = Math.min(...values); let max = Math.max(...values); let range = max - min; if (range < 0.05) { range = 0.05; } const padding = range * 0.10; min -= padding; max += padding; const chartRange = max - min; // 補助線 ctx.setStrokeColor(new Color("#FFFFFF", 0.08)); ctx.setLineWidth(1); for (let i = 1; i <= 3; i++) { const y = (height / 4) * i; const line = new Path(); line.move(new Point(0, y)); line.addLine(new Point(width, y)); ctx.addPath(line); ctx.strokePath(); } // 基準水位ライン(グラフの表示範囲内にあるものだけ) if (thresholds) { const marks = [ { value: thresholds.caution, color: "#FFD60A" }, { value: thresholds.evac, color: "#FF9F0A" }, { value: thresholds.danger, color: "#FF453A" } ]; for (const mark of marks) { if ( !Number.isFinite(mark.value) || mark.value < min || mark.value > max ) { continue; } const y = height - ((mark.value - min) / chartRange) * height; const line = new Path(); line.move(new Point(0, y)); line.addLine(new Point(width, y)); ctx.addPath(line); ctx.setStrokeColor(new Color(mark.color, 0.75)); ctx.setLineWidth(2); ctx.strokePath(); } } // 実際の時間をX座標にする const startTime = data[0].timestamp; const endTime = data[data.length - 1].timestamp; const timeRange = Math.max(1, endTime - startTime); function getX(item) { return ((item.timestamp - startTime) / timeRange) * width; } function getY(item) { return height - ((item.level - min) / chartRange) * height; } // 塗り const fillPath = new Path(); data.forEach((item, index) => { const p = new Point(getX(item), getY(item)); if (index === 0) { fillPath.move(p); } else { fillPath.addLine(p); } }); fillPath.addLine(new Point(width, height)); fillPath.addLine(new Point(0, height)); fillPath.closeSubpath(); ctx.addPath(fillPath); ctx.setFillColor(new Color("#4DA3FF", 0.10)); ctx.fillPath(); // 折れ線 const path = new Path(); data.forEach((item, index) => { const p = new Point(getX(item), getY(item)); if (index === 0) { path.move(p); } else { path.addLine(p); } }); ctx.addPath(path); ctx.setStrokeColor(new Color("#4DA3FF")); ctx.setLineWidth(4); ctx.strokePath(); // 現在地点 const latest = data[data.length - 1]; ctx.setFillColor(Color.white()); ctx.fillEllipse( new Rect(getX(latest) - 6, getY(latest) - 6, 12, 12) ); return ctx.getImage(); } // ===================================================== // 中サイズ統計行 // ===================================================== function addStatRow(stack, label, value, color) { const row = stack.addStack(); const labelText = row.addText(label); labelText.font = Font.systemFont(10); labelText.textColor = new Color("#777D87"); row.addSpacer(8); const valueText = row.addText(value); valueText.font = Font.semiboldSystemFont(11); valueText.textColor = color; stack.addSpacer(3); } // ===================================================== // 基準水位 // // 観測所詳細諸元ページ (SiteInfoDetail.exe) から取得。 // 例: 水防団待機水位 2.00m はん濫注意水位 2.50m // 避難判断水位 2.90m はん濫危険水位 3.30m 計画高水位 5.00m // // 値は観測所の零点高を基準にした水位で、 // リアルタイム水位と同じ基準のはず(要・実機確認)。 // 値が -999.999 の場合は「諸元なし」なので null にする。 // ===================================================== function parseThresholds(html) { // タグを空白に置き換えてテキスト化 const text = String(html) .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") .replace(/\s+/g, " "); const num = "\\s*(-?\\d+(?:\\.\\d+)?)\\s*m"; function pick(pattern) { const m = text.match(new RegExp(pattern + num)); if (!m) return null; const v = Number(m[1]); // -999.999 は諸元なし if (!Number.isFinite(v) || v <= -900) return null; return v; } const t = { watch: pick("水防団待機水位"), caution: pick("(?:はん濫|氾濫|洪水)注意水位"), evac: pick("避難判断水位"), danger: pick("(?:はん濫危険|氾濫危険|特別警戒|危険)水位"), design: pick("計画高水位") }; // 判定に使う4つが全部ない観測所は「基準水位なし」 if ( t.watch == null && t.caution == null && t.evac == null && t.danger == null ) { return null; } return t; } async function getThresholdsCached(id) { const fm = FileManager.local(); const dir = fm.joinPath(fm.documentsDirectory(), "RiverWaterCache"); if (!fm.fileExists(dir)) { fm.createDirectory(dir); } const path = fm.joinPath(dir, "thresholds.json"); let store = {}; try { if (fm.fileExists(path)) { store = JSON.parse(fm.readString(path)) || {}; } } catch (e) { store = {}; } const maxAge = CONFIG.THRESHOLD_CACHE_HOURS * 60 * 60 * 1000; const entry = store[id]; if (entry && Date.now() - entry.at < maxAge) { return entry.t; } try { const html = await loadEUCJP( `${CONFIG.RIVER_BASE}/cgi-bin/SiteInfoDetail.exe?ID=${id}` ); const t = parseThresholds(html); // 「基準水位なし」(null) も結果としてキャッシュする store[id] = { t, at: Date.now() }; try { fm.writeString(path, JSON.stringify(store)); } catch (e) { console.log("基準水位キャッシュ保存失敗"); console.log(String(e)); } return t; } catch (e) { console.log(`基準水位取得失敗: ${id}`); console.log(String(e)); // 通信エラー時は古いキャッシュがあればそれを使う return entry ? entry.t : null; } } // ===================================================== // 危険度判定 // // 🟢 通常 : 氾濫注意水位未満(水防団待機水位超も「通常」に含める) // 🟡 氾濫注意 : 氾濫注意水位以上 // 🟠 避難判断 : 避難判断水位以上 // 🔴 氾濫危険 : 氾濫危険水位以上 // ===================================================== function getFloodStatus(level, th) { if (!th || !Number.isFinite(level)) { return null; } // 高い方から判定 const steps = [ { rank: 3, label: "氾濫危険", emoji: "🔴", color: "#FF453A", value: th.danger }, { rank: 2, label: "避難判断", emoji: "🟠", color: "#FF9F0A", value: th.evac }, { rank: 1, label: "氾濫注意", emoji: "🟡", color: "#FFD60A", value: th.caution } ]; const current = steps.find( s => Number.isFinite(s.value) && level >= s.value ); // 次の基準水位(現在水位より上で最も低いもの) const next = [...steps] .reverse() .find(s => Number.isFinite(s.value) && level < s.value); if (!current) { return { rank: 0, label: "通常", emoji: "🟢", color: null, next: next ? { label: next.label, remain: next.value - level } : null }; } return { rank: current.rank, label: current.label, emoji: current.emoji, color: current.color, next: next ? { label: next.label, remain: next.value - level } : null }; } function statusBadgeText(status) { if (!status) return "基準水位なし"; if (status.rank === 0) return `${status.emoji} ${status.label}`; return `${status.emoji} ${status.label}水位超`; } function statusNextText(status) { if (!status) return ""; if (!status.next) { return status.rank >= 3 ? "最高の基準水位を超過中" : ""; } return `${status.next.label}水位まで あと${status.next.remain.toFixed(2)}m`; } function statusTextColor(status) { if (!status) return new Color("#777D87"); if (status.rank === 0) return new Color("#8FD19E"); return new Color(status.color); } function statusBackground(status) { if (!status) return null; if (status.rank >= 3) return "#2B1216"; if (status.rank === 2) return "#2A1C10"; return null; } function stationLabel() { return CONFIG.PREFER_THRESHOLD_STATIONS ? "観測所" : "最寄り観測所"; } // ===================================================== // 郵便番号 → 住所 // ===================================================== async function getAddress(zip) { const req = new Request(CONFIG.ZIP_API + zip); const json = await req.loadJSON(); if (!json.results || !json.results.length) { throw new Error("郵便番号が見つかりません"); } const r = json.results[0]; return { pref: r.address1 || "", city: r.address2 || "", town: r.address3 || "" }; } // ===================================================== // 住所 → 座標(フォールバック付き) // ===================================================== async function getAddressCoordinates(address) { const candidates = []; if (address.town) { candidates.push(address.pref + address.city + address.town); } candidates.push(address.pref + address.city); candidates.push(address.pref); for (const candidate of candidates) { try { const point = await getCoordinates(candidate); if (Number.isFinite(point.lat) && Number.isFinite(point.lon)) { return point; } } catch (e) { console.log(`座標取得失敗: ${candidate}`); console.log(String(e)); } } throw new Error("住所の座標を取得できません"); } async function getCoordinates(address) { const url = CONFIG.GEO_API + encodeURIComponent(address); const req = new Request(url); const json = await req.loadJSON(); if (!json || !json.length) { throw new Error("住所の座標を取得できません"); } const coordinates = json[0]?.geometry?.coordinates; if (!coordinates || coordinates.length < 2) { throw new Error("住所の座標が不正です"); } const lon = Number(coordinates[0]); const lat = Number(coordinates[1]); if (!Number.isFinite(lat) || !Number.isFinite(lon)) { throw new Error("住所の座標が不正です"); } return { lon, lat }; } // ===================================================== // 都道府県 → KEN // ===================================================== function getKenCode(pref) { const map = { "北海道": "0101", "青森県": "0201", "岩手県": "0301", "宮城県": "0401", "秋田県": "0501", "山形県": "0601", "福島県": "0701", "茨城県": "0801", "栃木県": "0901", "群馬県": "1001", "埼玉県": "1101", "千葉県": "1201", "東京都": "1301", "神奈川県": "1401", "新潟県": "1501", "富山県": "1601", "石川県": "1701", "福井県": "1801", "山梨県": "1901", "長野県": "2001", "岐阜県": "2101", "静岡県": "2201", "愛知県": "2301", "三重県": "2401", "滋賀県": "2501", "京都府": "2601", "大阪府": "2701", "兵庫県": "2801", "奈良県": "2901", "和歌山県": "3001", "鳥取県": "3101", "島根県": "3201", "岡山県": "3301", "広島県": "3401", "山口県": "3501", "徳島県": "3601", "香川県": "3701", "愛媛県": "3801", "高知県": "3901", "福岡県": "4001", "佐賀県": "4101", "長崎県": "4201", "熊本県": "4301", "大分県": "4401", "宮崎県": "4501", "鹿児島県": "4601", "沖縄県": "4701" }; return map[pref] || null; } // ===================================================== // 観測所一覧 + キャッシュ // // キャッシュ形式: // { complete: boolean, stations: [...] } // // complete が true かつ期限内のときだけ「完成品」として使う。 // それ以外(途中保存・旧形式の配列)は再開用として座標だけ再利用する。 // ===================================================== async function getStationsCached(ken) { const fm = FileManager.local(); const dir = fm.joinPath(fm.documentsDirectory(), "RiverWaterCache"); if (!fm.fileExists(dir)) { fm.createDirectory(dir); } const path = fm.joinPath(dir, `stations_${ken}.json`); const maxAge = CONFIG.CACHE_HOURS * 60 * 60 * 1000; let previous = new Map(); let fresh = false; if (fm.fileExists(path)) { try { const age = Date.now() - fm.modificationDate(path).getTime(); fresh = age < maxAge; const raw = JSON.parse(fm.readString(path)); // 旧形式(配列)は未完成扱い const list = Array.isArray(raw) ? raw : raw?.stations; const complete = !Array.isArray(raw) && raw?.complete === true; if (Array.isArray(list) && list.length) { if (complete && fresh) { return list; } previous = new Map(list.map(s => [s.id, s])); } } catch (e) { console.log("観測所キャッシュ読込失敗"); console.log(String(e)); } } const stations = await loadStations(ken, path, fm, previous, fresh); if (stations.length) { fm.writeString( path, JSON.stringify({ complete: true, stations }) ); } return stations; } // ===================================================== // 都道府県内観測所 // ===================================================== async function loadStations(ken, cachePath, fm, previous, keepFailed) { const baseStations = []; // まず一覧だけ取得 for (let page = 1; page <= 20; page++) { const url = `${CONFIG.RIVER_BASE}/cgi-bin/SrchSite.exe` + `?KOMOKU=02` + `&NAME=` + `&SUIKEI=-00001` + `&KASEN=` + `&KEN=${ken}` + `&CITY=` + `&PAGE=${page}`; let html; try { html = await loadEUCJP(url); } catch (e) { console.log(`観測所一覧 PAGE ${page} 取得失敗`); console.log(String(e)); break; } const rows = html.match(/<TR ALIGN="CENTER">[\s\S]*?<\/TR>/gi) || []; let count = 0; for (const row of rows) { const idMatch = row.match(/id="siteDetail1_(\d+)"/i); if (!idMatch) continue; const cells = extractCells(row); if (cells.length < 6) continue; baseStations.push({ id: idMatch[1], system: cells[2], river: cells[3], name: cells[4], address: cells[5], lat: null, lon: null, failed: false }); count++; } if (count === 0) break; } // 重複除外 const stationMap = new Map(); for (const station of baseStations) { stationMap.set(station.id, station); } const stations = [...stationMap.values()]; // 以前のキャッシュから座標 / 失敗フラグを再利用 for (const station of stations) { const old = previous.get(station.id); if (!old) continue; if (Number.isFinite(old.lat) && Number.isFinite(old.lon)) { station.lat = old.lat; station.lon = old.lon; } else if (old.failed && keepFailed) { station.failed = true; } } // 座標未取得(かつ失敗確定していない)観測所 const missing = stations.filter(s => !Number.isFinite(s.lat) || !Number.isFinite(s.lon) ).filter(s => !s.failed); // 数件ずつ並列取得 for (let i = 0; i < missing.length; i += CONFIG.STATION_BATCH_SIZE) { const batch = missing.slice(i, i + CONFIG.STATION_BATCH_SIZE); await Promise.all( batch.map(async station => { try { const info = await getStationInfo(station.id); station.lat = info.lat; station.lon = info.lon; } catch (e) { console.log(`観測所座標取得失敗: ${station.name}`); console.log(String(e)); // 座標が存在しないことが確定した場合のみ failed を付ける // (通信エラーは次回再試行) if (String(e).includes("観測所座標なし")) { station.failed = true; } } }) ); // バッチごとに途中キャッシュ(complete: false) try { fm.writeString( cachePath, JSON.stringify({ complete: false, stations }) ); } catch (e) { console.log("途中キャッシュ保存失敗"); console.log(String(e)); } } return stations; } // ===================================================== // 観測所詳細 // ===================================================== async function getStationInfo(id) { const url = `${CONFIG.RIVER_BASE}/cgi-bin/SiteInfo.exe?ID=${id}`; const html = await loadEUCJP(url); const m = html.match( /北緯\s*(\d+)度(\d+)分(\d+)秒\s*東経\s*(\d+)度(\d+)分(\d+)秒/ ); if (!m) { throw new Error("観測所座標なし"); } return { lat: Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600, lon: Number(m[4]) + Number(m[5]) / 60 + Number(m[6]) / 3600 }; } // ===================================================== // リアルタイム水位 // ===================================================== async function getRealtimeWater(id) { const url = `${CONFIG.RIVER_BASE}/cgi-bin/DspWaterData.exe?KIND=9&ID=${id}`; const parent = await loadEUCJP(url); const iframeMatch = parent.match(/<IFRAME[^>]+src="([^"]+)"/i); if (!iframeMatch) { return []; } let iframeURL = iframeMatch[1]; if (!/^https?:\/\//i.test(iframeURL)) { iframeURL = CONFIG.RIVER_BASE + (iframeURL.startsWith("/") ? "" : "/") + iframeURL; } const html = await loadEUCJP(iframeURL); const rows = html.match(/<TR[\s\S]*?<\/TR>/gi) || []; const data = []; for (const row of rows) { const cells = extractCells(row); if (cells.length < 3) continue; const date = cells[0].match(/\d{4}\/\d{2}\/\d{2}/); const time = cells[1].match(/\d{1,2}:\d{2}/); const level = cells[2].match(/-?\d+(?:\.\d+)?/); if (!date || !time || !level) continue; const normalizedTime = normalizeTime(time[0]); const timestamp = parseDateTime(date[0], normalizedTime); if (!Number.isFinite(timestamp)) continue; data.push({ date: date[0], time: normalizedTime, level: Number(level[0]), timestamp }); } // 最新 → 過去 data.sort((a, b) => b.timestamp - a.timestamp); return data; } // ===================================================== // 時刻正規化 / 日時 → timestamp // ===================================================== function normalizeTime(time) { const parts = String(time).split(":"); if (parts.length !== 2) return time; return ( String(Number(parts[0])).padStart(2, "0") + ":" + String(Number(parts[1])).padStart(2, "0") ); } function parseDateTime(date, time) { const dateParts = String(date).split("/"); const timeParts = String(time).split(":"); if (dateParts.length !== 3 || timeParts.length !== 2) { return NaN; } return new Date( Number(dateParts[0]), Number(dateParts[1]) - 1, Number(dateParts[2]), Number(timeParts[0]), Number(timeParts[1]), 0, 0 ).getTime(); } // ===================================================== // 指定時刻に最も近い過去データ // // latestTimestamp より前のデータだけを対象にする。 // 10分データなので、20分以上ズレている場合は比較しない。 // ===================================================== function findClosestPastData(data, targetTimestamp, latestTimestamp) { if ( !data || !data.length || !Number.isFinite(targetTimestamp) || !Number.isFinite(latestTimestamp) ) { return null; } let closest = null; let smallestDifference = Infinity; for (const item of data) { if (!Number.isFinite(item.timestamp)) continue; // 最新データ自身(および未来)は除外 if (item.timestamp >= latestTimestamp) continue; const difference = Math.abs(item.timestamp - targetTimestamp); if (difference < smallestDifference) { smallestDifference = difference; closest = item; } } if (smallestDifference > 20 * 60 * 1000) { return null; } return closest; } // ===================================================== // 水位差 // ===================================================== function calculateDifference(latest, past) { if ( !latest || !past || !Number.isFinite(latest.level) || !Number.isFinite(past.level) ) { return null; } return latest.level - past.level; } // ===================================================== // 実際の取得期間 // ===================================================== function getPeriodHours(data) { if (!data || data.length < 2) return 0; const newest = data[0]; const oldest = data[data.length - 1]; if ( !Number.isFinite(newest.timestamp) || !Number.isFinite(oldest.timestamp) ) { return 0; } return (newest.timestamp - oldest.timestamp) / (60 * 60 * 1000); } // ===================================================== // 表示用期間 // ===================================================== function getPeriodLabel(hours) { if (!Number.isFinite(hours) || hours <= 0) { return "直近"; } // 23.5時間以上取れていれば24h扱い if (hours >= 23.5) { return "24h"; } if (hours >= 1) { return `${Math.max(1, Math.floor(hours))}h`; } return `${Math.max(10, Math.round(hours * 60))}分`; } // ===================================================== // EUC-JP HTML // ===================================================== async function loadEUCJP(url) { const req = new Request(url); const data = await req.load(); return data.toRawString("euc-jp"); } // ===================================================== // HTML CELL // ===================================================== function extractCells(row) { const cells = []; const regex = /<T[DH][^>]*>([\s\S]*?)<\/T[DH]>/gi; let m; while ((m = regex.exec(row)) !== null) { cells.push(cleanHTML(m[1])); } return cells; } function cleanHTML(str) { return String(str) .replace(/<[^>]+>/g, "") .replace(/ /gi, " ") .replace(/&/gi, "&") .replace(/\s+/g, " ") .trim(); } // ===================================================== // 距離 // ===================================================== function distanceKm(lat1, lon1, lat2, lon2) { const R = 6371; const rad = d => d * Math.PI / 180; const dLat = rad(lat2 - lat1); const dLon = rad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } // ===================================================== // 表示補助 // ===================================================== function formatLevel(v) { if (v == null || Number.isNaN(v)) { return "--"; } return Number(v).toFixed(2); } function formatDiff(v) { if (v == null) return "-"; if (Math.abs(v) < 0.005) return "→ 0.00m"; return v > 0 ? `↑ +${v.toFixed(2)}m` : `↓ ${v.toFixed(2)}m`; } function changeText(v) { if (v == null) return "変化不明"; if (Math.abs(v) < 0.005) return "→ 変化なし"; return v > 0 ? `↑ +${v.toFixed(2)}m` : `↓ ${v.toFixed(2)}m`; } // 増減の色(上昇=赤 / 下降=青) function changeColor(v) { if (v == null) return new Color("#AEB4BE"); if (v > 0.05) return new Color("#FF5A5F"); if (v < -0.05) return new Color("#4DA3FF"); return new Color("#AEB4BE"); } // 現在水位の数字の色(基準水位の危険度で決まる) function levelColor(status) { if (status && status.rank >= 1) { return new Color(status.color); } return Color.white(); } function formatTime(item) { return item?.time || "--:--"; } function formatGraphTime(item) { if (!item) return "--:--"; return item.time || "--:--"; } function cleanStationName(name) { return String(name) .replace(/([^)]*)/g, "") .trim(); } // ===================================================== // エラー // ===================================================== function renderError(w, message) { const title = w.addText("🌊 河川水位"); title.font = Font.boldSystemFont(13); title.textColor = Color.white(); w.addSpacer(); const error = w.addText("データを取得できません"); error.font = Font.semiboldSystemFont(13); error.textColor = new Color("#FF5A5F"); w.addSpacer(5); const detail = w.addText(message); detail.font = Font.systemFont(9); detail.textColor = new Color("#777D87"); detail.lineLimit = 4; } - ホーム画面に追加して設定する
iPhoneのホーム画面を長押しし、ウィジェット追加画面からScriptableを選びます。この記事の対応サイズの説明に合わせてサイズを選んで追加してください。
追加したウィジェットを長押しして「ウィジェットを編集」を開き、「Script」に保存したスクリプトを選びます。
「Parameter」に確認したい地域の7桁の郵便番号を入力します。例:1000001。ハイフンあり・なしのどちらでも使えます。
編集画面を閉じて表示を確認します。ホーム画面への反映には少し時間がかかる場合があります。
水位の見方
表示される「-0.19m」「0.14m」などの数値は、川の深さではありません。
各観測所に設定されている観測基準に対する水位です。そのため、通常時でもマイナスになる場合があります。
たとえば、
0.14m = 14cm
です。
重要なのは数値そのものよりも、その観測所に設定された氾濫注意水位などの基準と比べて、現在どの位置にあるかです。
危険度について
基準水位を取得できる観測所では、現在水位から次の4段階で表示します。
🟢 通常
🟡 氾濫注意
🟠 避難判断
🔴 氾濫危険
「氾濫注意水位まで あと3.79m」のように表示されるので、現在水位だけを見るより状況を把握しやすくなっています。
※観測所によっては基準水位が設定されていない場合があります。その場合は「基準水位なし」と表示します。
観測所について
このウィジェットは「一番近い川」を探しているわけではありません。
郵便番号から取得した位置を基準に、国土交通省「水文水質データベース」でリアルタイム水位を取得できる近くの観測所を自動選択しています。
そのため、すぐ近くに別の川があっても、その川に対象となる水位観測所がない場合は、別の河川の観測所が選択されることがあります。
動作確認・使用結果
| 確認した端末 | iPhone 17 ProMAX |
|---|---|
| iOS | iOS27 |
| Scriptable | 1.7.19 |
| 確認したサイズ | 小・中・大 |
| バージョン | 1.0.0 |
| 動作確認日 | 2026-10-08 |
表示・使用時のポイント
郵便番号の近くから、取得条件に合う水位観測所を選びます。水位は観測所の基準に対する値で、川の深さを直接表す数値ではありません。
更新履歴
1.0.0
- 初回リリース
よくあるエラーと対処
- 確認すること
- 表示されない場合は、ウィジェット編集画面の「Script」に保存したスクリプトが選ばれているかも確認してください。コードを貼り直すときは全文を置き換えます。
- 観測所が見つからない
- 郵便番号を確認してください。近くに取得条件に合う観測所がない場合もあります。
- 「基準水位なし」と出る
- 観測所に基準水位が設定されていない場合の表示です。取得失敗とは限りません。
- 水位がマイナスになる
- 観測所の基準より低い水位ではマイナスになる場合があります。
- グラフが欠ける
- 公開されていない時刻や欠測がある場合があります。最新の観測時刻も確認してください。
注意事項・制限
- このウィジェットは、河川の状況を手軽に確認するためのものです。
- 表示される「氾濫注意」「避難判断」「氾濫危険」は観測所の基準水位と現在水位を比較した表示であり、自治体から避難情報が発令されたことを示すものではありません。
- 大雨・台風などの際は、必ず国土交通省「川の防災情報」や自治体が発表する最新の防災・避難情報を確認してください。
- 最も近い川ではなく、取得条件に合う観測所を選びます。対象の観測所名を確認してください。
- 防災上の判断には自治体や国の最新情報を確認してください。ウィジェットだけで避難の判断はできません。
- ホーム画面の更新タイミングはiOSが管理するため、操作やデータの変更がすぐに反映されない場合があります。
データ提供・参考資料
国土交通省 水文水質データベース:観測所と水位データ。
zipcloud:郵便番号から住所を取得。
国土地理院の住所検索を座標の取得に使用しています。
SHARE

