This commit is contained in:
2026-05-11 06:57:43 +09:00
parent f0246d7ce9
commit e099522b7e
25 changed files with 460 additions and 13 deletions
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

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, // 世界座標(01, グリニッジ子午線が原点(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, // 世界座標系Y0〜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 緯度(-8585)
* @returns {number} 世界座標系でのY0〜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"))));
File diff suppressed because one or more lines are too long
+331
View File
@@ -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);
}
}