533 lines
16 KiB
TypeScript
533 lines
16 KiB
TypeScript
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.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;
|
||
} |