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(arr: T, property: keyof T[number]) { if (arr.length === 0) { return { most: undefined, least: undefined, } } const counts = new Map(); 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; 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 = { 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; }