Files
noticeUwuzu/src/feature/earthquake/generateImage/index.ts
T

533 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.earthquake.hypocenter?.name === "" ||
!(Array.isArray(message.points)) ||
message.points.length === 0
)
return "input_lack";
const ZOOM_LEVEL = 9;
// タイル・地点取得
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;
}))[] = [];
// タイルの幅、最大最小
let tileXCount = getEdge(tiles, "tileX");
let tileYCount = getEdge(tiles, "tileY");
let xSize = tileXCount.most - tileXCount.least + 1;
let ySize = tileYCount.most - tileYCount.least + 1;
// タイルサイズ仮計算
let tileSize: number;
xSize = tileXCount.most - tileXCount.least + 1;
ySize = tileYCount.most - tileYCount.least + 1;
if (xSize > ySize) {
tileSize = Math.round(WIDTH / xSize);
} else {
tileSize = Math.round(HEIGHT / ySize);
}
// 震源を中心とする
const epicenterPosition = positions.filter(position => position.type === "epicenter")[0];
if (!epicenterPosition)
return "input_lack";
const epicenterTile = tiles[epicenterPosition.tileIndex];
if (!epicenterTile)
return "input_lack";
const leftPx = (epicenterTile.tileX - tileXCount.least) * tileSize + epicenterPosition.innerX;
const rightPx = xSize * tileSize - leftPx;
const topPx = (epicenterTile.tileY - tileYCount.least) * tileSize + epicenterPosition.innerY;
const bottomPx = ySize * tileSize - topPx;
if (leftPx > rightPx) {
const leftTiles = epicenterTile.tileX - tileXCount.least;
tileXCount = {
...tileXCount,
most: epicenterTile.tileX + leftTiles,
}
} else {
const rightTiles = tileXCount.most - epicenterTile.tileX;
tileXCount = {
...tileXCount,
least: epicenterTile.tileX - rightTiles,
}
}
if (topPx > bottomPx) {
const topTiles = epicenterTile.tileY - tileYCount.least;
tileYCount = {
...tileYCount,
most: epicenterTile.tileY + topTiles,
}
} else {
const bottomTiles = tileYCount.most - epicenterTile.tileY;
tileYCount = {
...tileYCount,
least: epicenterTile.tileY - bottomTiles,
}
}
// タイルサイズ再計算
xSize = tileXCount.most - tileXCount.least + 1;
ySize = tileYCount.most - tileYCount.least + 1;
if (xSize > ySize) {
tileSize = Math.round(WIDTH / xSize);
} else {
tileSize = Math.round(HEIGHT / ySize);
}
// 全画面
if (WIDTH - xSize * tileSize > 5) {
const requireTilesX = Math.ceil(WIDTH / tileSize);
const halfTilesX = Math.ceil(requireTilesX / 2);
tileXCount = {
least: tileXCount.least - halfTilesX,
most: tileXCount.most + halfTilesX,
}
}
if (HEIGHT - ySize * tileSize > 5) {
const requireTilesY = Math.ceil(HEIGHT / tileSize);
const halfTilesY = Math.ceil(requireTilesY / 2);
tileYCount = {
least: tileYCount.least - halfTilesY,
most: tileYCount.most + halfTilesY,
}
}
// タイル幅再計算
xSize = tileXCount.most - tileXCount.least + 1;
ySize = tileYCount.most - tileYCount.least + 1;
// 欠けているタイルを取得
for (let xIndex = 0; xIndex < xSize; xIndex++) {
const itX = xIndex + tileXCount.least;
for (let yIndex = 0; yIndex < ySize; yIndex++) {
const itY = yIndex + tileYCount.least;
const itTile = tiles.filter(tile => tile.tileX === itX && tile.tileY === itY)[0];
if (itTile)
continue;
tiles.push({
tileX: itX,
tileY: itY,
});
}
}
// アセット
const assets: Record<string, {
buffer: Buffer<ArrayBuffer>;
size: number;
}> = {}
const assetsList = [
"scales/10",
"scales/20",
"scales/30",
"scales/40",
"scales/45",
"scales/46",
"scales/50",
"scales/55",
"scales/60",
"scales/70",
"epicenter",
];
await Promise.all(assetsList.map(async name => {
const key = name.split("/").at(-1);
if (!key)
return;
const asset = readFileSync(`${import.meta.dirname}/assets/${name}.png`);
const assetMeta = await sharp(asset).metadata();
const assetSize = assetMeta.width;
assets[key] = {
buffer: asset,
size: assetSize,
}
}));
// 各画像処理
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: "scale",
scale: position.scale,
}
}
compounds.push({
...about,
input: asset.buffer,
top: Math.round(top + position.innerY * tileResizedSize - asset.size / 2),
left: Math.round(left + position.innerX * tileResizedSize - asset.size / 2),
});
});
}));
// 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;
}