2026.5.0
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 693 B |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 714 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1015 B |
@@ -0,0 +1,429 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import sharp from "sharp";
|
||||
|
||||
/**
|
||||
* 経度からX方向の座標情報を計算します
|
||||
*
|
||||
* 指定された経度とズームレベルに基づき、以下の4つの値を返します:
|
||||
* - 世界座標系でのX(0〜1の範囲、グリニッジ子午線が0)
|
||||
* - ズームレベルを考慮したピクセル単位のグローバルX座標
|
||||
* - タイルグリッドにおけるX方向のタイル番号(tileNumberX)
|
||||
* - タイル内(256×256ピクセル)でのX方向ピクセル位置(tileInnerX)
|
||||
* @param lat - 経度
|
||||
* @param z - ズームレベル(0以上の整数)
|
||||
* @returns {{
|
||||
* worldCoordX: number, // 世界座標(0~1, グリニッジ子午線が原点(0))
|
||||
* globalPixelX: number, // ズームレベルを考慮したピクセル単位のY座標
|
||||
* tileNumberX: number, // 該当するタイル番号(0オリジン)
|
||||
* tileInnerX: number, // タイル内のピクセルY座標(0〜255)
|
||||
* }}
|
||||
* @copyright murasuke on Qiita - https://qiita.com/murasuke/items/ad81b7b726a3463fa3fe#%E7%B5%8C%E5%BA%A6
|
||||
*/
|
||||
function calcLngToTileX(lng: number, z: number) {
|
||||
// 経度(-180〜180)を世界座標系のX(0〜1)に変換
|
||||
const worldCoordX = (lng + 180) / 360;
|
||||
|
||||
// ズームレベル0(256×256px)の座標の位置を計算
|
||||
let globalPixelX = worldCoordX * 256;
|
||||
|
||||
// ズームレベル(2^z)を考慮した座標を計算
|
||||
globalPixelX = globalPixelX * Math.pow(2, z);
|
||||
|
||||
// 1つの画像タイルが256pxなので、256で割って左端からのタイルの枚数(位置)を求める
|
||||
const tileNumberX = Math.floor(globalPixelX / 256);
|
||||
|
||||
// 該当タイル内のピクセル位置を算出する(タイル幅合計を引いた余り)
|
||||
const tileInnerX = Math.floor(globalPixelX - tileNumberX * 256);
|
||||
|
||||
// 計算した値を返す
|
||||
return {
|
||||
worldCoordX, // 世界座標(0~1, グリニッジ子午線が原点(0))
|
||||
globalPixelX, // ズームレベルを考慮したピクセル単位のY座標
|
||||
tileNumberX, // 該当するタイル番号(0オリジン)
|
||||
tileInnerX, // タイル内のピクセルY座標(0〜255)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 緯度からWebメルカトル投影によるY方向の座標情報を計算します。
|
||||
*
|
||||
* 指定された緯度とズームレベルに基づき、以下の4つの値を返します:
|
||||
* - 世界座標系でのY(0〜1の範囲、北極が0)
|
||||
* - ズームレベルを考慮したピクセル単位のグローバルY座標
|
||||
* - タイルグリッドにおけるY方向のタイル番号(tileNumberY)
|
||||
* - タイル内(256×256ピクセル)でのY方向ピクセル位置(tileInnerY)
|
||||
*
|
||||
* メルカトル図法で緯度から位置を算出する式 (https://qiita.com/Seo-4d696b75/items/aa6adfbfba404fcd65aa)
|
||||
* R ln(tan(π/4 + ϕ/2))
|
||||
* R: 半径
|
||||
* ϕ: 緯度(ラジアン)
|
||||
* @param lat - 緯度(-85〜+85度の範囲が推奨)
|
||||
* @param z - ズームレベル(0以上の整数)
|
||||
* @returns {{
|
||||
* worldCoordY: number, // 世界座標系Y(0〜1)
|
||||
* globalPixelY: number, // ズームレベルを考慮したピクセル単位のY座標
|
||||
* tileNumberY: number, // Y方向のタイル番号(0オリジン)
|
||||
* tileInnerY: number // タイル内のピクセルY座標(0〜255)
|
||||
* }}
|
||||
* @copyright murasuke on Qiita - https://qiita.com/murasuke/items/ad81b7b726a3463fa3fe#%E7%B7%AF%E5%BA%A6
|
||||
*/
|
||||
function calcLatToTileY(lat: number, z: number) {
|
||||
// 緯度からメルカトル図法の座標に変換して、世界座標系のY(0~1)を計算
|
||||
let worldCoordY = latToWorldY(lat);
|
||||
|
||||
// ズームレベル0(256×256px)の座標の位置を計算
|
||||
let globalPixelY = worldCoordY * 256;
|
||||
|
||||
// ズームレベル(2^z)を考慮した座標を計算
|
||||
globalPixelY = globalPixelY * Math.pow(2, z);
|
||||
|
||||
// 1つの画像タイルが256pxなので、256で割って上端からのタイルの枚数(位置)を求める
|
||||
const tileNumberY = Math.floor(globalPixelY / 256);
|
||||
|
||||
// 該当タイル内のピクセル位置を算出する(タイル幅合計を引いた余り)
|
||||
const tileInnerY = Math.floor(globalPixelY - tileNumberY * 256);
|
||||
|
||||
// 計算した値を返す
|
||||
return {
|
||||
worldCoordY, // 世界座標(0~1, 北極側が原点(0))
|
||||
globalPixelY, // ズームレベルを考慮したピクセル単位のY座標
|
||||
tileNumberY, // 該当するタイル番号(0オリジン)
|
||||
tileInnerY, // タイル内のピクセルY座標(0〜255)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* メルカトル図法で投影した位置を元に世界座標系でのY(0〜1、北極が0)を計算する
|
||||
*
|
||||
* メルカトル図法の式(半径1の球体を想定)
|
||||
* ϕ = (π / 180) * 緯度
|
||||
* y = ln(tan(π/4 + ϕ/2))
|
||||
* ϕ:緯度(ラジアン)
|
||||
* y:投影した位置
|
||||
*
|
||||
* * 赤道を中心(0)にして、約-85〜+85度の範囲で-π~πの値になる
|
||||
* * 高緯度では値が急増し無限大になる
|
||||
* * y = ln((tan(ϕ) + 1) / cos(ϕ)) という式もある(同じ値になる)
|
||||
*
|
||||
* 地図で位置を計算するため、北極側が原点(0)で南極側が1となるように正規化した値を返す
|
||||
*
|
||||
* @param lat 緯度(-85~85)
|
||||
* @returns {number} 世界座標系でのY(0〜1、北極が0)
|
||||
* @copyright murasuke on Qiita - https://qiita.com/murasuke/items/ad81b7b726a3463fa3fe#%E7%B7%AF%E5%BA%A6
|
||||
*/
|
||||
function latToWorldY(lat: number) {
|
||||
// 経度をラジアンに変換
|
||||
const latRad = (Math.PI / 180) * lat;
|
||||
// 緯度からメルカトル図法の座標に変換する
|
||||
const mercatorY = Math.log(Math.tan(Math.PI / 4 + latRad / 2));
|
||||
|
||||
// -1~1 の範囲になるように調整(-85度~85度の範囲)
|
||||
let worldCoordY = mercatorY / Math.PI;
|
||||
|
||||
// 原点(0)を赤道から、北極へ変換(0~1)
|
||||
worldCoordY = 1 - (worldCoordY + 1) / 2;
|
||||
|
||||
return worldCoordY;
|
||||
}
|
||||
|
||||
function getEdge<T extends any[]>(arr: T, property: keyof T[number]) {
|
||||
if (arr.length === 0) {
|
||||
return {
|
||||
most: undefined,
|
||||
least: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const counts = new Map<any, number>();
|
||||
for (const item of arr) {
|
||||
const val = item[property];
|
||||
counts.set(val, (counts.get(val) || 0) + 1);
|
||||
}
|
||||
|
||||
const uniqueValues = Array.from(counts.keys());
|
||||
|
||||
const minVal = uniqueValues.reduce((a, b) => (a < b ? a : b));
|
||||
const maxVal = uniqueValues.reduce((a, b) => (a > b ? a : b));
|
||||
|
||||
return {
|
||||
most: maxVal,
|
||||
least: minVal,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function generateImage(message: any) {
|
||||
if (
|
||||
message.earthquake.hypocenter === undefined &&
|
||||
message.points === undefined
|
||||
)
|
||||
return "input_lack";
|
||||
|
||||
const ZOOM_LEVEL = 8;
|
||||
|
||||
// タイル・地点取得
|
||||
const tiles: {
|
||||
tileX: number;
|
||||
tileY: number;
|
||||
}[] = [];
|
||||
|
||||
const positions: ({
|
||||
tileIndex: number;
|
||||
innerX: number;
|
||||
innerY: number;
|
||||
} & ({
|
||||
type: "epicenter";
|
||||
} | {
|
||||
type: "point";
|
||||
scale: number;
|
||||
}))[] = [];
|
||||
|
||||
if (
|
||||
message.earthquake.hypocenter?.latitude !== undefined &&
|
||||
message.earthquake.hypocenter?.latitude !== -200 &&
|
||||
message.earthquake.hypocenter?.longitude !== undefined &&
|
||||
message.earthquake.hypocenter?.longitude !== -200
|
||||
) {
|
||||
// 震源地
|
||||
const lng = message.earthquake.hypocenter.longitude;
|
||||
const lat = message.earthquake.hypocenter.latitude;
|
||||
const xData = calcLngToTileX(lng, ZOOM_LEVEL);
|
||||
const yData = calcLatToTileY(lat, ZOOM_LEVEL);
|
||||
|
||||
tiles.push({
|
||||
tileX: xData.tileNumberX,
|
||||
tileY: yData.tileNumberY,
|
||||
});
|
||||
|
||||
positions.push({
|
||||
tileIndex: 0,
|
||||
innerX: xData.tileInnerX,
|
||||
innerY: yData.tileInnerY,
|
||||
type: "epicenter",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(message.points) &&
|
||||
message.points.length > 0
|
||||
) {
|
||||
// 観測点・区域
|
||||
const areas = JSON.parse(readFileSync(`${import.meta.dirname}/areas.json`, "utf-8"));
|
||||
const points = JSON.parse(readFileSync(`${import.meta.dirname}/points.json`, "utf-8"));
|
||||
message.points.forEach((point: any) => {
|
||||
let areaData: {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
}
|
||||
|
||||
if (point.isArea) {
|
||||
areaData = areas[point.addr];
|
||||
} else {
|
||||
areaData = points[point.addr];
|
||||
}
|
||||
|
||||
const lng = areaData.longitude;
|
||||
const lat = areaData.latitude;
|
||||
const xData = calcLngToTileX(lng, ZOOM_LEVEL);
|
||||
const yData = calcLatToTileY(lat, ZOOM_LEVEL);
|
||||
|
||||
const tilePosition = {
|
||||
tileX: xData.tileNumberX,
|
||||
tileY: yData.tileNumberY,
|
||||
}
|
||||
|
||||
let tileIndex: number;
|
||||
|
||||
const sameTile = tiles
|
||||
.map((value, index) => ({ value, index }))
|
||||
.filter(tile => JSON.stringify(tile.value) === JSON.stringify(tilePosition))[0];
|
||||
|
||||
if (sameTile) {
|
||||
tileIndex = sameTile.index;
|
||||
} else {
|
||||
tiles.push(tilePosition);
|
||||
tileIndex = tiles.length - 1;
|
||||
}
|
||||
|
||||
if (tiles.filter(tile => JSON.stringify(tile) === JSON.stringify(tilePosition)).length === 0) {
|
||||
tiles.push(tilePosition);
|
||||
}
|
||||
|
||||
positions.push({
|
||||
tileIndex,
|
||||
innerX: xData.tileInnerX,
|
||||
innerY: yData.tileInnerY,
|
||||
type: "point",
|
||||
scale: point.scale,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 画像生成
|
||||
const WIDTH = 1920;
|
||||
const HEIGHT = 1080;
|
||||
|
||||
let result = sharp({
|
||||
create: {
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
channels: 4,
|
||||
background: "#ffffff",
|
||||
}
|
||||
});
|
||||
|
||||
let compounds: (sharp.OverlayOptions & ({
|
||||
type: "tile" | "epicenter" | "other";
|
||||
} | {
|
||||
type: "scale";
|
||||
scale: number;
|
||||
}))[] = [];
|
||||
|
||||
// タイルのXYの各最大、最小を取得
|
||||
const tileXCount = getEdge(tiles, "tileX");
|
||||
const tileYCount = getEdge(tiles, "tileY");
|
||||
|
||||
// タイルサイズ決定
|
||||
let tileSize: number;
|
||||
|
||||
const xSize = tileXCount.most - tileXCount.least + 1;
|
||||
const ySize = tileYCount.most - tileYCount.least + 1;
|
||||
if (xSize > ySize) {
|
||||
tileSize = Math.round(WIDTH / xSize);
|
||||
} else {
|
||||
tileSize = Math.round(HEIGHT / ySize);
|
||||
}
|
||||
|
||||
// アセット
|
||||
const assets: Record<string, Buffer<ArrayBuffer>> = {}
|
||||
const assetsList = [
|
||||
"scales/10",
|
||||
"scales/20",
|
||||
"scales/30",
|
||||
"scales/40",
|
||||
"scales/45",
|
||||
"scales/46",
|
||||
"scales/50",
|
||||
"scales/55",
|
||||
"scales/60",
|
||||
"scales/70",
|
||||
"epicenter",
|
||||
];
|
||||
|
||||
assetsList.forEach(name => {
|
||||
const asset = readFileSync(`${import.meta.dirname}/assets/${name}.png`);
|
||||
|
||||
const key = name.split("/").at(-1);
|
||||
if (!key)
|
||||
return;
|
||||
|
||||
assets[key] = asset;
|
||||
});
|
||||
|
||||
// 各画像処理
|
||||
await Promise.all(tiles.map(async (tile, index) => {
|
||||
const TYPE = "blank";
|
||||
const BASE_URL = "https://cyberjapandata.gsi.go.jp/xyz";
|
||||
|
||||
const request = await fetch(`${BASE_URL}/${TYPE}/${ZOOM_LEVEL}/${tile.tileX}/${tile.tileY}.png`);
|
||||
|
||||
if (!request.ok)
|
||||
return;
|
||||
|
||||
const tileImg = await sharp(await request.arrayBuffer()).resize({
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
}).png().toBuffer();
|
||||
|
||||
const top = tileSize * (tile.tileY - tileYCount.least);
|
||||
const left = tileSize * (tile.tileX - tileXCount.least);
|
||||
|
||||
compounds.push({
|
||||
type: "tile",
|
||||
input: tileImg,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
|
||||
const itTilePositions = positions.filter(position => position.tileIndex === index);
|
||||
itTilePositions.forEach(position => {
|
||||
const requireAssetName = position.type === "epicenter"
|
||||
? "epicenter"
|
||||
: String(position.scale);
|
||||
|
||||
const asset = assets[requireAssetName];
|
||||
if (!asset)
|
||||
return;
|
||||
|
||||
const tileResizedSize = tileSize / 256;
|
||||
|
||||
let about: any;
|
||||
|
||||
if (position.type === "epicenter") {
|
||||
about = {
|
||||
type: "epicenter",
|
||||
}
|
||||
} else {
|
||||
about = {
|
||||
type: "epicenter",
|
||||
scale: position.scale,
|
||||
}
|
||||
}
|
||||
|
||||
compounds.push({
|
||||
...about,
|
||||
input: asset,
|
||||
top: Math.round(top + position.innerY * tileResizedSize),
|
||||
left: Math.round(left + position.innerX * tileResizedSize),
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
// compounds並び替え
|
||||
const TYPE_PRIORITY: Record<string, number> = {
|
||||
tile: 1,
|
||||
scale: 2,
|
||||
epicenter: 3,
|
||||
}
|
||||
|
||||
compounds = [...compounds].sort((a, b) => {
|
||||
if (TYPE_PRIORITY[a.type] !== TYPE_PRIORITY[b.type]) {
|
||||
return (TYPE_PRIORITY[a.type] ?? 99) - (TYPE_PRIORITY[b.type] ?? 99);
|
||||
}
|
||||
|
||||
if (a.type === "scale" && b.type === "scale") {
|
||||
return a.scale - b.scale;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
// 案内部分
|
||||
const informationPath = `${import.meta.dirname}/assets/information.png`;
|
||||
const informationBuffer = readFileSync(informationPath);
|
||||
const informationMeta = await sharp(informationPath).metadata();
|
||||
const informationMargin = 20;
|
||||
compounds.push({
|
||||
type: "other",
|
||||
input: informationBuffer,
|
||||
top: HEIGHT - informationMeta.height - informationMargin,
|
||||
left: WIDTH - informationMeta.width - informationMargin,
|
||||
});
|
||||
|
||||
// クレジット部分
|
||||
const creditPath = `${import.meta.dirname}/assets/credit.png`;
|
||||
const creditBuffer = readFileSync(creditPath);
|
||||
const creditMeta = await sharp(creditPath).metadata();
|
||||
compounds.push({
|
||||
type: "other",
|
||||
input: creditBuffer,
|
||||
top: HEIGHT - creditMeta.height,
|
||||
left: 0,
|
||||
});
|
||||
|
||||
result.composite(compounds);
|
||||
|
||||
const buffer = await result.png().toBuffer();
|
||||
return buffer;
|
||||
}
|
||||
|
||||
writeFileSync("result.png", await generateImage(JSON.parse(readFileSync(`${import.meta.dirname}/data.json`, "utf8"))));
|
||||
@@ -0,0 +1,331 @@
|
||||
import { createUeuse } from "@/lib/client";
|
||||
import config from "@/lib/config";
|
||||
import initI18n from "@/lib/i18n";
|
||||
import Memory from "@/lib/memory";
|
||||
import { format } from "date-fns";
|
||||
import i18next from "i18next";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { EOL } from "node:os";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
await initI18n();
|
||||
|
||||
if (config.earthquake?.useHistoryData) {
|
||||
console.log("過去の地震情報を配信します");
|
||||
const history = JSON.parse(readFileSync(`${import.meta.dirname}/../../260420.json`, "utf-8"));
|
||||
history.reverse();
|
||||
|
||||
let i = 0;
|
||||
setInterval(() => {
|
||||
processMessage(history[i]);
|
||||
i++;
|
||||
}, 10 * 1000);
|
||||
} else {
|
||||
const connect = () => {
|
||||
const WEBSOCKET_URL = config.debug
|
||||
? "wss://api-realtime-sandbox.p2pquake.net/v2/ws"
|
||||
: "wss://api.p2pquake.net/v2/ws";
|
||||
|
||||
console.log("P2P地震情報のWebSocketに接続します");
|
||||
const socket = new WebSocket(WEBSOCKET_URL);
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
console.log("P2P地震情報のWebSocketに接続しました");
|
||||
});
|
||||
|
||||
socket.addEventListener("close", (err) => {
|
||||
const interval = config.earthquake.reconnectInterval;
|
||||
console.log(`WebSocketが切断されました。${interval / 1000}秒後に再接続します:`, err.reason);
|
||||
setTimeout(() => {
|
||||
connect();
|
||||
}, interval);
|
||||
});
|
||||
|
||||
socket.addEventListener("error", (err) => {
|
||||
console.error("WebSocketでエラーが発生しました:", err);
|
||||
socket.close();
|
||||
});
|
||||
|
||||
socket.addEventListener("message", async (event) => {
|
||||
let message;
|
||||
|
||||
try {
|
||||
message = typeof event.data === "string"
|
||||
? JSON.parse(event.data)
|
||||
: event.data;
|
||||
} catch (err) {
|
||||
console.error("地震情報メッセージのパースでエラーが発生:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = message.id ?? message._id;
|
||||
const mem = Memory.memory;
|
||||
if (mem.processedInfo.includes(id) && !config.debug) {
|
||||
console.log("重複した地震情報:", message.id);
|
||||
return;
|
||||
}
|
||||
|
||||
processMessage(message);
|
||||
|
||||
if (!config.debug) {
|
||||
const mem = Memory.memory;
|
||||
mem.processedInfo = mem.processedInfo.concat([id]);
|
||||
Memory.memory = mem;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
const processMessage = async (message: any) => {
|
||||
try {
|
||||
const scaleMessages: Record<string, string> = {
|
||||
"-1": "不明",
|
||||
"0": "震度0",
|
||||
"10": "震度1",
|
||||
"20": "震度2",
|
||||
"30": "震度3",
|
||||
"40": "震度4",
|
||||
"45": "震度5弱",
|
||||
"46": "**推定**震度5弱以上***(不明)***",
|
||||
"50": "震度5強",
|
||||
"55": "震度6弱",
|
||||
"60": "震度6強",
|
||||
"70": "震度7",
|
||||
"99": "程度以上",
|
||||
}
|
||||
|
||||
switch (message.code) {
|
||||
case 555:
|
||||
console.log("ピアの地域分布情報を受信しました");
|
||||
break;
|
||||
case 551:
|
||||
{
|
||||
console.log("地震発生情報を受信しました");
|
||||
|
||||
if (
|
||||
(message.earthquake.maxScale === -1 ||
|
||||
message.earthquake.maxScale === undefined) &&
|
||||
!config.earthquake.canUnknownMaxScale
|
||||
) {
|
||||
console.log("最大震度が不明であり、最大震度が不明な場合の投稿が許可されていないため、スキップします");
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
message.earthquake.maxScale !== -1 &&
|
||||
message.earthquake.maxScale < config.earthquake.requireMaxScale
|
||||
) {
|
||||
console.log(`最大震度(${scaleMessages[message.earthquake.maxScale]})が投稿に必要な値(${scaleMessages[config.earthquake.requireMaxScale]})に満たないため、スキップします`);
|
||||
break;
|
||||
}
|
||||
|
||||
const typeMessage: Record<string, string> = {
|
||||
"ScalePrompt": "震度速報",
|
||||
"Destination": "震源情報",
|
||||
}
|
||||
|
||||
const domesticTsunamiMessages: Record<string, string> = {
|
||||
"None": "😌この地震による**国内**の津波の心配はありません。",
|
||||
"Unknown": "😕この地震による**国内**の***津波情報は***不明です。",
|
||||
"Checking": "🧐この地震による**国内**の津波情報を**調査中です。**",
|
||||
"NonEffective": "😌この地震による**国内**の**海面変動が予想されますが**、被害の心配はありません。",
|
||||
"Watch": "⚠️この地震により**国内**で津波注意報が発令しています。",
|
||||
"Warning": "🚨この地震による**国内**の津波予報があります。",
|
||||
}
|
||||
|
||||
const foreignTsunamiMessages: Record<string, string> = {
|
||||
"None": "😌この地震による**国外**の津波の心配はありません。",
|
||||
"Unknown": "😕この地震による**国外**の***津波情報は***不明です。",
|
||||
"Checking": "🧐この地震による**国外**の津波情報を**調査中です。**",
|
||||
"NonEffectiveNearby": "😌この地震によって**国外**にて震源の近傍で**小さな津波の可能性はありますが**、被害の心配はありません。",
|
||||
"WarningNearby": "⚠️この地震によって**国外**にて震源の近傍で**津波の可能性**があります。",
|
||||
"WarningPacific": "⚠️この地震によって**太平洋**にて**津波の可能性**があります。",
|
||||
"WarningPacificWide": "🚨この地震によって**太平洋の広域**にて**津波の可能性**があります。",
|
||||
"WarningIndian": "⚠️この地震によって**インド洋**にて**津波の可能性**があります。",
|
||||
"WarningIndianWide": "🚨この地震によって**インド洋の広域**にて**津波の可能性**があります。",
|
||||
"Potential": "🚨この地震によって**一般的に**この規模では津波の可能性があると考えられています。",
|
||||
}
|
||||
|
||||
const grouped: Record<string, { scale: number; prefs: Record<string, string[]> }> = {};
|
||||
|
||||
for (const point of message.points) {
|
||||
const { addr, scale, pref } = point;
|
||||
const label = scaleMessages[String(scale)] ?? "不明";
|
||||
|
||||
if (!grouped[label]) {
|
||||
grouped[label] = { scale, prefs: {} };
|
||||
}
|
||||
|
||||
if (!grouped[label].prefs[pref]) {
|
||||
grouped[label].prefs[pref] = [];
|
||||
}
|
||||
|
||||
grouped[label].prefs[pref].push(addr);
|
||||
}
|
||||
|
||||
const pointsMsg = Object.entries(grouped)
|
||||
.sort((a, b) => b[1].scale - a[1].scale)
|
||||
.map(([label, { prefs }]) => {
|
||||
const prefLines = Object.entries(prefs)
|
||||
.map(([pref, addrs]) => `**${pref}:** ${addrs.join("・")}`)
|
||||
.join(EOL);
|
||||
|
||||
return `【${label}】${EOL}${prefLines}`;
|
||||
})
|
||||
.join(EOL.repeat(2))
|
||||
.trim();
|
||||
|
||||
await createUeuse({
|
||||
text: i18next.t("earthquakeNotice", {
|
||||
type: typeMessage[message.issue.type] ?? "地震情報",
|
||||
occuredTime: format(new Date(message.earthquake.time), "yyyy年M月d日 H:mm"),
|
||||
maxScale: scaleMessages[String(message.earthquake.maxScale)],
|
||||
epicenter: message.earthquake.hypocenter.name === ""
|
||||
? "不明"
|
||||
: message.earthquake.hypocenter.name,
|
||||
magnitude: message.earthquake.hypocenter.magnitude === -1
|
||||
? "不明"
|
||||
: `M${message.earthquake.hypocenter.magnitude.toFixed(1)}`,
|
||||
depth: message.earthquake.hypocenter.depth === 0
|
||||
? "ごく浅い"
|
||||
: (message.earthquake.hypocenter.depth === -1
|
||||
? "不明"
|
||||
: `${message.earthquake.hypocenter.depth}km`),
|
||||
domesticTsunami: domesticTsunamiMessages[(message.earthquake.domesticTsunami ?? "Unknown")],
|
||||
foreignTsunami: foreignTsunamiMessages[(message.earthquake.foreignTsunami ?? "Unknown")],
|
||||
points: pointsMsg === ""
|
||||
? ""
|
||||
: EOL.repeat(2) + pointsMsg,
|
||||
source: message.issue.source ?? "不明",
|
||||
comment: message.comments.freeFormComment === ""
|
||||
? ""
|
||||
: EOL + message.comments.freeFormComment + EOL,
|
||||
}),
|
||||
}, "地震発生情報");
|
||||
}
|
||||
break;
|
||||
case 552:
|
||||
{
|
||||
console.log("津波予報情報を受信しました");
|
||||
|
||||
if (message.cancelled) {
|
||||
await createUeuse({
|
||||
text: i18next.t("tsunamiCancelNotice", {
|
||||
announceTime: format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss"),
|
||||
source: message.issue.source ?? "不明",
|
||||
}),
|
||||
}, "津波予報解除情報");
|
||||
break;
|
||||
}
|
||||
|
||||
const gradeMessages: Record<string, string> = {
|
||||
"Unknown": "不明",
|
||||
"Watch": "津波注意報",
|
||||
"Warning": "津波警報",
|
||||
"MajorWarning": "大津波警報",
|
||||
}
|
||||
|
||||
let areasMsg = "";
|
||||
for (const area of message.areas) {
|
||||
areasMsg += i18next.t("tsunamiAreaMsg", {
|
||||
name: area.name,
|
||||
immediate: area.immediate
|
||||
? EOL + "🚨***直ちに津波が来襲すると予想されています。***"
|
||||
: "",
|
||||
grade: gradeMessages[area.grade],
|
||||
arrivalTime: format(new Date(area.firstHeight.arrivalTime), "yyyy年M月d日 H:mm"),
|
||||
condition: area.firstHeight.condition
|
||||
? `${area.firstHeight.condition}されています`
|
||||
: "不明",
|
||||
maxHeight: area.maxHeight.value === 0.2
|
||||
? "0.2m未満"
|
||||
: (area.maxHeight.value
|
||||
? `${area.maxHeight.value}m`
|
||||
: area.maxHeight.description),
|
||||
}) + EOL.repeat(2);
|
||||
}
|
||||
|
||||
await createUeuse({
|
||||
text: i18next.t("tsunamiForecastNotice", {
|
||||
announceTime: format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss"),
|
||||
areasMsg: areasMsg.trim(),
|
||||
source: message.issue.source ?? "不明",
|
||||
}),
|
||||
}, "津波予報情報");
|
||||
}
|
||||
break;
|
||||
case 556:
|
||||
{
|
||||
console.log("緊急地震速報(警報)を受信しました");
|
||||
|
||||
if (message.cancelled) {
|
||||
await createUeuse({
|
||||
text: i18next.t("eewCancelNotice", {
|
||||
isTest: message.test
|
||||
? "⚒️これは**テストです。**"
|
||||
: "🚨これは**テストではありません。**",
|
||||
announceTime: format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss"),
|
||||
}),
|
||||
}, "緊急地震速報(警報)解除情報");
|
||||
}
|
||||
|
||||
const kindMessages: Record<string, string> = {
|
||||
"10": "⏳主要動は、**未到達と予測**されています。",
|
||||
"11": "🫨主要動が、**既に到達していると予測**されています。",
|
||||
"19": "🧐PLUM法によると、主要動の***到達予想は***ありません。",
|
||||
}
|
||||
|
||||
let areasMsg = "";
|
||||
for (const area of message.areas) {
|
||||
areasMsg += i18next.t("eewAreaMsg", {
|
||||
name: area.name,
|
||||
maxScale: scaleMessages[String(Math.floor(area.scaleFrom))] +
|
||||
(area.scaleTo === 99
|
||||
? "程度以上"
|
||||
: area.scaleFrom !== area.scaleTo
|
||||
? `から${scaleMessages[String(Math.floor(area.scaleTo))]}`
|
||||
: ""),
|
||||
kind: kindMessages[area.kindCode] ?? "😕主要動の***到達予想は***ありません。",
|
||||
arrivalTime: typeof area.arrivalTime === "string" && area.arrivalTime !== ""
|
||||
? format(new Date(area.arrivalTime), "yyyy年M月d日 H:mm:ss")
|
||||
: "不明",
|
||||
}) + EOL.repeat(2);
|
||||
}
|
||||
|
||||
await createUeuse({
|
||||
text: i18next.t("eewNotice", {
|
||||
isTest: message.test
|
||||
? "⚒️これは**テストです。**"
|
||||
: "🚨これは**テストではありません。**",
|
||||
announceTime: format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss"),
|
||||
occuredTime: format(new Date(message.earthquake.originTime), "yyyy年M月d日 H:mm:ss"),
|
||||
arrivalTime: format(new Date(message.earthquake.arrivalTime), "yyyy年M月d日 H:mm:ss"),
|
||||
isAssume: message.earthquake.condition === "仮定震源要素"
|
||||
? `${EOL}❓これは、仮定震源要素です。そのため、震源に関する情報が保証できません。`
|
||||
: "",
|
||||
epicenter: message.earthquake.hypocenter.name ?? "不明",
|
||||
depth: message.earthquake.hypocenter.depth === undefined ||
|
||||
message.earthquake.hypocenter.depth === -1
|
||||
? "不明"
|
||||
: `${Math.floor(message.earthquake.hypocenter.depth)}km`,
|
||||
magnitude: message.earthquake.hypocenter.magnitude === undefined ||
|
||||
message.earthquake.hypocenter.magnitude === -1
|
||||
? "不明"
|
||||
: `M${message.earthquake.hypocenter.magnitude.toFixed(1)}`,
|
||||
areas: areasMsg !== ""
|
||||
? EOL.repeat(2) + areasMsg.trim()
|
||||
: "",
|
||||
}),
|
||||
}, "緊急地震速報(警報)情報");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log("未対応の情報:", message);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("メッセージの処理に失敗しました:", err);
|
||||
}
|
||||
}
|
||||