PHPで天気図を描く

(1/1)
気象庁防災情報XMLには、地上実況図として、気圧配置の座標や等圧線・前線の座標が配信されている。
そこで今回は、これらの情報をリアルタイムに取得し、Googleマップなどのマップに天気図を描くPHPプログラムを作ってみることにする。オプション機能として、天気図を含めて Twitter(現・X)や Blueckyに投稿できる機能を加えた。

(2026年1月12日)投稿URL表示機能を追加
(2025年8月13日)GoogleMaps JavaScript APIの変更に対応, pahooInputData.php導入

サンプル・プログラムの実行例

PHPで天気図を描く

目次

サンプル・プログラムのダウンロード

圧縮ファイルの内容
weatherMap.phpサンプル・プログラム本体。
.pahooEnvクラウドサービスを利用するためのアカウント情報などを記入する .env ファイル。
使い方は「各種クラウド連携サービス(WebAPI)の登録方法」を参照。include_path が通ったディレクトリに配置すること。
pahooInputData.phpデータ入力に関わる関数群。
使い方は「数値入力とバリデーション」「文字入力とバリデーション」などを参照。include_path が通ったディレクトリに配置すること。
pahooGeoCode.php住所・緯度・経度に関わるクラス pahooGeoCode。
使い方は「PHPで住所・ランドマークから最寄り駅を求める」「PHPで住所・ランドマークから緯度・経度を求める」などを参照。include_path が通ったディレクトリに配置すること。
pahooCache.phpキャッシュ処理に関わるクラス pahooCache。
キャッシュ処理に関わるクラスの使い方は「PHPで天気予報を求める」を参照。include_path が通ったディレクトリに配置すること。
pahooTwitterAPI.phpTwitter APIを利用するクラス pahooTwitterAPI。
使い方は「PHPでTwitterに投稿(ツイート)する」などを参照。include_path が通ったディレクトリに配置すること。
pahooBlueskyAPI.phpBluesky APIに関わるクラス pahooBlueskyAPI。
使い方は「PHPでPHPでBlueskyに投稿する」などを参照。include_path が通ったディレクトリに配置すること。
weatherMap.php 更新履歴
バージョン 更新日 内容
1.8.0 2026/01/12 mediaBluesky - 投稿URL表示機能を追加
1.7.0 2025/08/13 pahooInputData.php導入
1.6.0 2024/11/02 Bluesky投稿機能を追加
1.5.0 2024/06/21 Twitter(現・X)ボタンを "X" に変更
1.4.1 2023/09/20 js_html2image()--Leaflet用html2image()発火プロセス見直し
pahooInputData.php 更新履歴
バージョン 更新日 内容
2.0.1 2025/08/11 getParam() bug-fix
2.0.0 2025/08/11 pahooLoadEnv() 追加
1.9.0 2025/07/26 getParam() 引数に$trim追加
1.8.1 2025/03/15 validRegexPattern() debug
1.8.0 2024/11/12 validRegexPattern() 追加
pahooGeoCode.php 更新履歴
バージョン 更新日 内容
6.9.1 2025/11/25 PHP8.5対応:double→float
6.9.0 2025/09/21 jsPolygon, jsPolygon_Gmap, jsPolygon_Leaflet, loadGeoJSON, getPrefBorderList を追加
6.8.0 2025/08/10 アクセスキーなどを ".pahooEnd" に分離
6.7.1 2025/07/26 jsLine_Gmap() - bug-fix
6.7.0 2025/07/20 drawJSmap,drawGMap -- 引数 $markerLevel 追加
pahooCache.php 更新履歴
バージョン 更新日 内容
1.3.0 2025/12/06 PHP8.5対応:curl_closeを使わない
1.2.0 2025/09/06 cLoad() HTTPヘッダを送信できるようにした
1.1.3 2025/08/10 var→public
1.1.2 2023/07/22 bug-fix
1.1.1 2023/02/11 コメント追記
pahooTwitterAPI.php 更新履歴
バージョン 更新日 内容
5.6.1 2025/11/30 PHP8.5対応:curl_closeを使わないようにした
5.6.0 2025/08/13 .pahooEnv導入
5.5.1 2024/11/23 __construct() -- PHP8.4における応急処置
5.5.0 2024/06/21 TwitterOAuth 7.0.0 対応
5.4.0 2024/05/18 twitter.com → x.com 変更対応
pahooBlueskyAPI.php 更新履歴
バージョン 更新日 内容
2.7.1 2025/11/22 PHP8.5対応:curl_close,imagedestroyを実行しないようにした
2.7.0 2025/08/17 reductImage,uploadBlob仕様変更←画像に余計な空白が入らないようにするため
2.6.0 2025/08/14 .pahooEnv 導入
2.5.1 2025/08/10 uploadBlob() -- bug-fix
2.5.0 2025/08/02 getOGPInformation() -- og:imageがないページに対応

準備:PHP の https対応

クラウド連携や相手先サイトのデータを読み込むのに https通信を使うため、PHPに OpenSSLモジュールが組み込まれている必要がある。関数  phpinfo  を使って、下図のように表示されればOKだ。
OpenSSL - PHP
そうでない場合は、次の手順に従ってOpenSSLを有効化し、PHPを再起動させる必要がある。

Windowsでは、"php.ini" の下記の行を有効化する。
extension=php_openssl.dll
Linuxでは --with-openssl=/usr オプションを付けて再ビルドする。→OpenSSLインストール手順

これで準備は完了だ。

準備:pahooInputData 関数群

PHPのバージョンや入力データのバリデーションなど、汎用的に使う関数群を収めたファイル "pahooInputData.php" が同梱されているが、include_path が通ったディレクトリに配置してほしい。他のプログラムでも "pahooInputData.php" を利用するが、常に最新のファイルを1つ配置すればよい。

また、各種クラウドサービスに登録したときに取得するアカウント情報、アプリケーションパスワードなどを登録した .pahooEnv ファイルから読み込む関数 pahooLoadEnv を備えている。こちらについては、「各種クラウド連携サービス(WebAPI)の登録方法」をご覧いただきたい。

準備:pahooGeoCode クラス

pahooGeoCode.php

  41: class pahooGeoCode {
  42:     public $items;      // 検索結果格納用
  43:     public $error;      // エラー・フラグ
  44:     public $errmsg;     // エラー・メッセージ
  45:     public $hits;       // 検索ヒット件数
  46:     public $webapi; // 直前に呼び出したWebAPI URL
  47: 
  48:     // 都道府県境界線データ
  49:     // SimpleMaps.com is a product of Pareto Software, LLC. © 2010-2025
  50:     // https://simplemaps.com/gis/country/jp
  51:     // ※各自の環境に合わせて設定すること
  52:     public $GeoJsonJP = __DIR__ . '/jp.json';
  53: 
  54:     // -- 以下のデータは .env ファイルに記述可能
  55:     // Google Cloud Platform APIキー
  56:     // https://cloud.google.com/maps-platform/
  57:     // ※Google Maps APIを利用しないのなら登録不要
  58:     public $GOOGLE_API_KEY_1 = '';      // HTTPリファラ用
  59:     public $GOOGLE_API_KEY_2 = '';      // IP制限用
  60:     public $GOOGLE_MAP_ID    = '';      // GoogleMaps ID
  61: 
  62:     // Yahoo! JAPAN Webサービス アプリケーションID
  63:     // https://e.developer.yahoo.co.jp/register
  64:     // ※Yahoo! JAPAN Webサービスを利用しないのなら登録不要
  65:     public $YAHOO_APPLICATION_ID = '';
  66: 
  67:     // OSM Nominatim Search API利用時に知らせるメールアドレス
  68:     // https://wiki.openstreetmap.org/wiki/JA:Nominatim#.E6.A4.9C.E7.B4.A2
  69:     // ※OSM Nominatim Search APIを利用しないのなら登録不要
  70:     public $NOMINATIM_EMAIL = '';
  71: 
  72:     // IP2Location.io APIキー
  73:     // https://www.ip2location.io/
  74:     // ※IP2Location.ioを利用しないのなら登録不要
  75:     public $IP2LOCATION_API_KEY = '';

GoogleマップやLeafletなどによる地図描画や住所検索を行うためのクラスが pahooGeoCode である。同梱のクラス・ファイル "pahooGeoCode.php" は include_path が通ったディレクトリに配置してほしい。他のプログラムでも pahooGeoCodeクラス を利用するが、常に最新のクラス・ファイルを1つ配置すればよい。

地図や住所検索として Google を利用するのであれば Google Cloud Platform APIキーマップID が必要で、その入手方法は「Google Cloud Platform - WebAPIの登録方法」を、Yahoo!JAPAN を利用するのであれば Yahoo! JAPAN Webサービス アプリケーションIDが必要で、その入手方法は「Yahoo!JAPAN デベロッパーネットワーク - WebAPIの登録方法」を、IP2Location.ioを利用するのであれば「PHPでIPアドレスやホスト名から住所を求める」を、それぞれ参照されたい。

PHPのクラスについては「PHPでクラスを使ってテキストの読みやすさを調べる」を参照されたい。

準備:地図サービスの選択

weatherMap.php

  66: // 地図描画サービスの選択
  67: //    0:Google
  68: //    2:地理院地図・OSM
  69: define('MAPSERVICE', 2);
  70: 
  71: // マップの表示サイズ(単位:ピクセル)
  72: define('MAP_WIDTH',  600);
  73: define('MAP_HEIGHT', 480);
  74: // マップID
  75: define('MAPID', 'map_id');
  76: // 初期値
  77: define('DEF_LATITUDE',  35.0);          // 緯度
  78: define('DEF_LONGITUDE', 137.0);         // 経度
  79: define('DEF_TYPE',      'GSISTD');      // マップタイプ
  80: define('DEF_ZOOM',      4);             // ズーム
  81: 
  82: define('SEMICIRCLE',    30);            // 半円を代替する多角形頂点数

表示する地図は、Googleマップ地理院地図・オープンストリートマップ(OSM)から選べる。あらかじめ、定数 MAPSERVIC に値を設定すること。
住所検索サービスは、GoogleYahoo!JAPANHeartRails Geo APIOSM Nominatim Search API から選べる。あらかじめ、定数 GEOSERVICE に値を設定すること。
PHPで天気図を描く
地理院地図表示
PHPで天気図を描く
オープンストリートマップ表示

準備:pahooCache クラス

pahooCache.php

  13: class pahooCache {
  14:     const LIFE_CACHE = (2 * 60);        // キャッシュ保持時間(デフォルト;分)
  15:     const DEF_DIRCACHE = './pcache/';   // キャッシュ・ディレクトリ(デフォルト)
  16: 
  17:     public $lifeCache;      // キャッシュ保持時間(分)(0:キャッシュしない)
  18:     public $dirCache;       // キャッシュ用ディレクトリ
  19:     public $httpHeader;     // HTTPヘッダ(空文字の時は何も送らない)
  20:     public $error;          // エラーフラグ
  21:     public $errmsg;         // エラーメッセージ
  22:     public $debug;          // デバッグ用ファイル名
  23: 
  24: /**
  25:  * コンストラクタ
  26:  * 参考サイト https://www.pahoo.org/e-soul/webtech/php06/php06-72-01.shtm
  27:  * @param   int    $life キャッシュ保持時間(分)(省略可能)
  28:  * @param   string $dir キャッシュ・ディレクトリ(省略可能)
  29:  * @param   array  $httpHeader httpヘッダに渡す配列(省略可能)
  30:  *          USER AGENT偽装に用いることを想定
  31:  *          (例)
  32:  *           array(
  33:  *              'User-Agent: Mozilla/5.0(Windows NT 10.0; Win64; x64) pahooAppy/pahoo.org AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
  34:  *              'Accept-Language: ja-JP'
  35:  *          );
  36:  * @return  なし
  37: */
  38: function __construct($life=self::LIFE_CACHE, $dir=self::DEF_DIRCACHE, $httpHeader=NULL) {
  39:     if ($life < 0) {
  40:         $life = 0;
  41:     }
  42:     if (preg_match('/\/$/ui', $dir) == 0) {
  43:         $dir = $dir . '/';
  44:     }
  45:     $this->error      = FALSE;
  46:     $this->errmsg     = '';
  47:     $this->debug      = '';
  48:     $this->lifeCache  = $life;
  49:     $this->dirCache   = $dir;
  50:     $this->httpHeader = $httpHeader;
  51: 
  52:     // PHP5以上であることを調べる.
  53:     if (! $this->isphp5over()) {
  54:         $this->error   = TRUE;
  55:         $this->errmsg  = '動作にはPHP5以上が必要です';
  56:         return;
  57:     }
  58: 
  59:     // キャッシュ・ディレクトリが無ければ作成する.
  60:     if (! is_dir($this->dirCache)) {
  61:         $res = mkdir($this->dirCache, 0744);
  62:         if ($res == FALSE) {
  63:             $this->error   = TRUE;
  64:             $this->errmsg  = 'キャッシュ・ディレクトリ "' . $this->$dirCache . '" の作成に失敗しました';
  65:             return;
  66:         }
  67:     }
  68: }

クラウド連携や相手先サイトの公開データを利用する際、こちらのアプリを起動する都度、APIを呼び出したりデータをダウンロードするのでは、相手サーバに負荷をかけてしまう。
そこで、頻繁に変更がないデータについては、一度取り込んだら、こちら側のサーバのローカルストレージにキャッシュしておく仕組みを用意した。それが pahooCacheクラス である。同梱のクラス・ファイル "pahooCache.php" は include_path が通ったディレクトリに配置してほしい。他のプログラムでも pahooCacheクラス を利用するが、常に最新のクラス・ファイルを1つ配置すればよい。

pahooCacheクラス の注意ポイントは、キャッシュ時間(単位:分)とキャッシュを保存するディレクトリをコンストラクタで指定している点だ。これらはプログラムによって変わるものである。インスタンス化するときの値は、pahooCacheクラス を利用するメイン・プログラムの方で解説する。

サイトによっては、User-Agent などを必要とすることがあるだろう。そこで、第3引数に HTTPヘッダ として送信するデータを配列で渡すことができるようにした。配列の構造はコメントを参照していただきたい。

PHPのクラスについては「PHPでクラスを使ってテキストの読みやすさを調べる」を参照されたい。

準備:各種定数など

weatherMap.php

  55: // 各種定数(START) ===========================================================
  56: 
  57: // Twitter(現・X)投稿ボタン  TRUE:有効,FALSE:無効
  58: define('TWITTER', FALSE);
  59: 
  60: // Bluesky投稿ボタン  TRUE:有効,FALSE:無効
  61: define('BLUESKY', FALSE);
  62: 
  63: // 画像化したいオブジェクト
  64: define('TARGET', 'target');
  65: 
  66: // 地図描画サービスの選択
  67: //    0:Google
  68: //    2:地理院地図・OSM
  69: define('MAPSERVICE', 2);
  70: 
  71: // マップの表示サイズ(単位:ピクセル)
  72: define('MAP_WIDTH',  600);
  73: define('MAP_HEIGHT', 480);
  74: // マップID
  75: define('MAPID', 'map_id');
  76: // 初期値
  77: define('DEF_LATITUDE',  35.0);          // 緯度
  78: define('DEF_LONGITUDE', 137.0);         // 経度
  79: define('DEF_TYPE',      'GSISTD');      // マップタイプ
  80: define('DEF_ZOOM',      4);             // ズーム
  81: 
  82: define('SEMICIRCLE',    30);            // 半円を代替する多角形頂点数
  83: 
  84: // キャッシュ保持時間(分) 0:キャッシュしない
  85: // 気象庁へのアクセス負荷軽減のため,60分以上のキャッシュ保持をお勧めします.
  86: define('LIFE_CACHE', 120);
  87: 
  88: // キャッシュ・ディレクトリ
  89: // 書き込み可能で,外部からアクセスされないディレクトリを指定してください.
  90: define('DIR_CACHE', './pcache/');
  91: 
  92: // 気象庁防災情報XML:高頻度フィード - 定時配信(変更不可)
  93: define('FEED_REGULAR', 'https://www.data.jma.go.jp/developer/xml/feed/regular.xml');
  94: 
  95: // 住所・緯度・経度に関わるクラス:include_pathが通ったディレクトリに配置
  96: require_once('pahooGeoCode.php');
  97: 
  98: // キャッシュ処理に関わるクラス:include_pathが通ったディレクトリに配置
  99: require_once('pahooCache.php');
 100: 
 101: // TwitterAPIクラス:include_pathが通ったディレクトリに配置
 102: if (TWITTER) {
 103:     require_once('pahooTwitterAPI.php');
 104: }
 105: 
 106: // BlueskyAPIクラス:include_pathが通ったディレクトリに配置
 107: if (BLUESKY) {
 108:     require_once('pahooBlueskyAPI.php');
 109:     define('BLUESKY_DOMAIN', 'bsky.social');    // あなたのドメインを記入
 110: }
 111: // 各種定数(END) ============================================================

各種定数などの内容は、プログラムのコメントに記載した通りである。「変更不可」以外の定数は自由に変更できる。

出力結果を Twitter(現・X) に投稿することができる。投稿機能を有効化するときは、定数 TWITTER を TRUE にする。ユーザー定義クラス pahooTwitterAPI を利用するので、"pahooTwitterAPI.php" をinclude_pathが通ったディレクトリに配置すること。また、事前にアプリケーションの登録が必要になる。詳しくは「PHPでTwitter(現・X)に投稿(ツイート)する」を参照してほしい。

出力結果を Bluesky に投稿することができる。投稿機能を有効化するときは、定数 BLUESKY を TRUE にする。ユーザー定義クラス pahooBlueskyAPI を利用するので、"pahooBlueskyAPI.php" をinclude_pathが通ったディレクトリに配置すること。また、事前にアプリケーションの登録が必要になる。詳しくは「PHPでPHPでBlueskyに投稿する」を参照してほしい。

Twitter(現・X) や Bluesky のボタン・アイコンについては、「HTMLとCSSでさまざまなアイコンを表示する」を参照して欲しい。

気象庁防災情報XMLフォーマット

気象庁防災情報XMLフォーマットについては、「PHPで天気予報を求める - 気象庁防災情報XMLフォーマット」をご覧いただきたい。
今回は、高頻度 - 定時更新フィードにアクセスし、電文コード VZSA50 の地上実況図を取得する。

VZSA50の構造

地上実況図XML VZSA50 には、ある地域の1週間分の天気予報が収められており、構造は下記の通りである。ここから必要な情報を取り出す。
注意すべきは、1つのXMLファイルの中に1つまたは複数の予報地点が含まれていること。さらに、天気予報(アイコン画像を含む)と降水確率は「地方」に紐付いているが、最低気温・最高気温は「予報地点」に紐付いているということである。地方と予報地点は1対N対応のため、後述する予報値地点データファイルに用意しておくことにした。
また、TimeDefine タグに示される7日分の情報が格納されているのだが、XML情報の取得時間帯によって、1つ目(refID=1)の情報が、今日になるか明日になるか分かれる。そこで、TimeDefine を取得して、現在日時(PCの内蔵時計)と比較して決定することにした。
降水確率や気温は、XML情報の取得タイミングによっては、1つ目(refID=1)~3つ目(refID=3)が「値なし」になっていることがある。そこで、後述する情報XML VPFW50 から、今日~明後日(または明日~明後日)の情報を取り出し、補完することにする。
VZSA50の構造(xml) Report Control Title 地上実況図 DateTime 配信日時 Status ステータス EditorialOffice 編集者 PublishingOffice 配信者 Head Title 地上実況図 ReportDateTime 配信日時 TargetDateTime 対象日時 InfoKind 情報の種類 InfoKindVersion 情報バージョン Headline Text Body MeteorologicalInfos MeteorologicalInfo DateTime 実況日時 Item Kind Property Type 等圧線 IsobarPart Pressure 気圧(hPa) Line 座標列 Item Kind Property Type 低気圧|高気圧 CenterPart Coordinate 中心座標 Direction 移動方向 Speed 移動速度(km/h) Speed 移動速度(ノット) Pressure 中心気圧(hPa) Item Kind Property Type 温暖|寒冷|停滞|閉塞前線 CoordinatePart Line 座標列

解説:最新の地上実況図URLを取得

weatherMap.php

 359: /**
 360:  * 気象庁防災情報XMLから最新の地上実況図URLを取得
 361:  * @param   object $pcc pahooCacheオブジェクト
 362:  * @return  string 地上実況図URL/FALSE:取得失敗
 363: */
 364: function jmaGetWeatherMapURL($pcc) {
 365:     // URLパターン
 366:     $vzsa50 = '/https?\:\/\/www\.data\.jma\.go\.jp\/developer\/xml\/data\/([0-9\_]+)VZSA50\_([0-9\_]+)\.xml/ui';
 367: 
 368:     $xml = $pcc->simplexml_load(FEED_REGULAR);
 369:     // レスポンス・チェック
 370:     if ($pcc->iserror() || !isset($xml->entry)) {
 371:         return FALSE;
 372:     }
 373: 
 374:     // フィード(XMLファイル)解析
 375:     $vzsa50_url = $vzsa50_dt = '';
 376:     $res = FALSE;
 377:     foreach ($xml->entry as $node) {
 378:         // 日時がより新しいURLを採用
 379:         if (preg_match($vzsa50, $node->id, $arr> 0) {
 380:             if ($arr[1> $vzsa50_dt) {
 381:                 $vzsa50_url = $arr[0];
 382:                 $vzsa50_dt  = $arr[1];
 383:                 $res = TRUE;
 384:             }
 385:         }
 386:     }
 387: 
 388:     // エラー・チェック
 389:     if (! $res) {
 390:         return FALSE;
 391:     }
 392: 
 393:     return $vzsa50_url;
 394: }

フィードから最新の地上実況図URLを取得するユーザー関数は jmaGetWeatherMapURL である。
URLを正規表現で分解し、配信日時 yyyymmddhhmmss が最も大きく、VZSA50 を含むURLを返す。

解説:地上実況図を読み込む

weatherMap.php

 396: /**
 397:  * 気象庁防災情報XMLから地上実況図を取得
 398:  * @param   string $url   地上実況図URL
 399:  * @param   string $dt    報告日時格納用
 400:  * @param   array  $items 情報を格納する配列
 401:  * @param   object $pcc   pahooCacheオブジェクト
 402:  * @return  bool TRUE:取得成功/FALSE:失敗
 403: */
 404: function jmaGetIsobar($url, &$dt, &$items, $pcc) {
 405:     // 名前空間
 406:     define('JMX_EB', 'http://xml.kishou.go.jp/jmaxml1/elementBasis1/');
 407: 
 408:     $xml = $pcc->simplexml_load($url);
 409:     // レスポンス・チェック
 410:     if ($pcc->iserror() || !isset($xml->Body->MeteorologicalInfos)) {
 411:         return FALSE;
 412:     }
 413: 
 414:     // 報告日時
 415:     $pat = '/([0-9]+)\-([0-9]+)\-([0-9]+)T([0-9]+)\:/ui';
 416:     $dt = (string)$xml->Body->MeteorologicalInfos->MeteorologicalInfo->DateTime;
 417:     if (preg_match($pat, $dt, $arr> 0) {
 418:         $dt = sprintf('%d年%d月%d日 %d時', $arr[1], $arr[2], $arr[3], $arr[4]);
 419:     } else {
 420:         $dt = '';
 421:     }
 422: 
 423:     // 等圧線情報
 424:     $res = FALSE;
 425:     $cnt = 0;
 426:     foreach ($xml->Body->MeteorologicalInfos->MeteorologicalInfo->Item as $item) {
 427:         $Property = $item->Kind->Property;
 428:         // 等圧線
 429:         if ($Property->Type == '等圧線') {
 430:             $IsobarPart = $Property->IsobarPart->children(JMX_EB);
 431:             $items[$cnt]['type'] = (string)$Property->Type;
 432:             $items[$cnt]['pressure'] = (int)$IsobarPart->Pressure;
 433:             $bar = (string)$IsobarPart->Line;
 434:             $arr = preg_split('/\//ui', $bar);
 435:             // 緯度・経度
 436:             $i = 0;
 437:             foreach ($arr as $ss) {
 438:                 if (preg_match('/([\+\-]+[0-9\.]+)([\+\-]+[0-9\.]+)/ui', $ss, $arr2> 0) {
 439:                     $items[$cnt]['point'][$i]['latitude']  = (float)$arr2[1];
 440:                     $items[$cnt]['point'][$i]['longitude'] = (float)$arr2[2];
 441:                     $i++;
 442:                 }
 443:             }
 444:             $res = TRUE;
 445:             $cnt++;
 446:         // 前線
 447:         } else if (preg_match('/前線/ui', $Property->Type> 0) {
 448:             $CoordinatePart = $Property->CoordinatePart->children(JMX_EB);
 449:             $items[$cnt]['type'] = (string)$Property->Type;
 450:             $bar = (string)$CoordinatePart->Line;
 451:             $arr = preg_split('/\//ui', $bar);
 452:             // 緯度・経度
 453:             $i = 0;
 454:             foreach ($arr as $ss) {
 455:                 if (preg_match('/([\+\-]+[0-9\.]+)([\+\-]+[0-9\.]+)/ui', $ss, $arr2> 0) {
 456:                     $items[$cnt]['point'][$i]['latitude']  = (float)$arr2[1];
 457:                     $items[$cnt]['point'][$i]['longitude'] = (float)$arr2[2];
 458:                     $i++;
 459:                 }
 460:             }
 461:             $res = TRUE;
 462:             $cnt++;
 463:         // 気圧
 464:         } else if (preg_match('/気圧|台風|熱帯/ui', $Property->Type> 0) {
 465:             $items[$cnt]['type']  = (string)$Property->Type;
 466:             $CenterPart = $Property->CenterPart->children(JMX_EB);
 467:             if (preg_match('/([\+\-]+[0-9\.]+)([\+\-]+[0-9\.]+)/ui', (string)$CenterPart->Coordinate, $arr2> 0) {
 468:                 $items[$cnt]['latitude']  = (float)$arr2[1];
 469:                 $items[$cnt]['longitude'] = (float)$arr2[2];
 470:             }
 471:             $items[$cnt]['pressure'] = (int)$CenterPart->Pressure;
 472:             $res = TRUE;
 473:             $cnt++;
 474:         }
 475:     }
 476: 
 477:     return TRUE;
 478: }

VZSA50 から地上実況図を配列 $items へ格納するユーザー関数が jmaGetIsobar である。

XMLファイルを読み込んだら、まず、報告日時を $dt へ格納する。
次に、Type要素の値に応じて、等圧線、前線、気圧の位置情報を配列 $items へ格納していく。

解説:前線記号

PHPで天気図を描く
等圧線や気圧配置は、取得した座標をそのままマッピングすればいいのだが、苦労したのが左図の前線記号である。
寒冷前線系の三角形と、温暖前線系の半円を描く必要がある。いろいろ試行錯誤したのだが、結局、マップサービスの多角形描画機能を使って座標を計算して描かせることにした。

weatherMap.php

 502: /**
 503:  * 二等辺三角形座標(寒冷前線用)
 504:  * @param   float $lat0, $lng0 底辺の中心座標
 505:  * @param   float $lat1, $lng2 底辺の一方の座標
 506:  * @param   int   $way         0:寒冷前線・停滞前線,1:閉塞前線
 507:  * @return  array($lat, $lng)  頂点の座標
 508: */
 509: function isoTriangle($lat0, $lng0, $lat1, $lng1, $way=0) {
 510:     $angle = ($way == 0? 270 : 90;
 511: 
 512:     if ($lng1 - $lng0 == 0) {
 513:         $t = 0.0;
 514:     } else {
 515:         $t = rad2deg(atan(($lat1 - $lat0) / ($lng1 - $lng0)));
 516:     }
 517:     $r = sqrt(pow(($lng1 - $lng0), 2+ pow(($lat1 - $lat0), 2));
 518:     $lat = $r * sin(deg2rad($t + $angle)) + $lat0;
 519:     $lng = $r * cos(deg2rad($t + $angle)) + $lng0;
 520: 
 521:     return array($lat, $lng);
 522: }

PHPで天気図を描く
関連前線記号の三角形は、当初、正三角形を考えていたが、温暖前線記号の半円とのバランスを考えて、AC=BCとなる二等辺三角形とした。
座標系は緯度、経度から成る球面座標系だが、マップサービスがメルカトル図法であることから、平面座標系であると近似して計算式を立てた。
底辺ABの中点 $ O(lat0, lng0) $ を置くと、三角形BCOは直角三角形になる。
ここで頂点 $ B(lat1, lng1) $ の座標から、∠BCOは $ \displaystyle t\ =\ tan^{-1}(\frac{lat_1\ -\ lat_0}{lng_1\ -\ lng_0}) $ である。
辺COの長さは $ \displaystyle r\ =\ \sqrt{(lng_1\ -\ lng_0)^2\ +\ (lat_1\ -\ lat_0)^2} $ であるから、頂点 $ B(lat, lng) $ の座標は $$ \displaystyle \begin{align} lat\ &=\ r\ sin(t\ +\ \frac{3}{2}\pi)\ +\ lat_1 \\ lng\ &=\ r\ cos(t\ +\ \frac{3}{2}\pi)\ +\ lng_1 \end{align} $$
で求められる。

なお、底辺ABは前線であることから必ずしも直線ではなく、頂点A,B,Cを含む多角形としてマップに描画する。

weatherMap.php

 524: /**
 525:  * 半円弧座標(温暖前線記号):多角形で近似する
 526:  * @param   float $lat0, $lng0 中心座標
 527:  * @param   float $lat1, $lng2 円弧の始点座標
 528:  * @param   int   $n           多角形の頂点数
 529:  * @param   array points 円弧の座標を格納する
 530:  * @return  なし
 531: */
 532: function semiCircle($lat0, $lng0, $lat1, $lng1, $n, &$points) {
 533:     if ($lng1 - $lng0 == 0) {
 534:         $t = 0.0;
 535:     } else {
 536:         $t = rad2deg(atan(($lat1 - $lat0) / ($lng1 - $lng0)));
 537:     }
 538:     $r = sqrt(pow(($lng1 - $lng0), 2+ pow(($lat1 - $lat0), 2));
 539:     for ($i = 0$i < $n$i++) {
 540:         $points[$i]['latitude']  = $r * sin(deg2rad($t + 180 / $n * $i)) + $lat0;
 541:         $points[$i]['longitude'] = $r * cos(deg2rad($t + 180 / $n * $i)) + $lng0;
 542:     }
 543: }

次に半円形だが、マップサービスに円弧を描く機能がない。そこで、頂点数Nが十分に大きい多角形として描くことにした。

多角形の頂点座標の求め方は、isoTriangle 関数と同様で、頂点数がN個になったとして計算する。

解説:前線を描く(Googleマップ)

weatherMap.php

 601: /**
 602:  * 前線描画用スクリプト作成:Googleマップ
 603:  * @param   array $item 前線の座標
 604:  * @param   int   $kind 前線の種類(0:温暖前線,1:寒冷前線,2:停滞前線,
 605:  *                                  3:閉塞前線)
 606:  * @return  string 描画用スクリプト
 607: */
 608: function jsStationaryFront_gmap($item, $kind) {
 609:     static $table_color1 = array('red', 'blue', 'red', 'purple');
 610:     static $table_color2 = array('red', 'blue', 'blue', 'purple');
 611:     static $table_angle  = array(0, 0, 0, 1);
 612: 
 613:     $i = 0;
 614:     $flag = TRUE;
 615:     $js = '';
 616:     while ($flag) {
 617:         // 前線(温暖)
 618:         $ss = '';
 619:         $cnt = 0;
 620:         for ($j = $i$j <$i + 10$j++) {
 621:             if (! isset($item['point'][$j])) {
 622:                 $flag = FALSE;
 623:                 break;
 624:             }
 625:             if ($cnt > 0)   $ss .",\n";
 626:             $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 627:             $cnt++;
 628:         }
 629:         $js .=<<< EOT
 630: new google.maps.Polyline({
 631:     map: map,
 632:     path: [
 633: {$ss}
 634:     ],
 635:     strokeColor: '{$table_color1[$kind]}',
 636:     strokeOpacity: 1.0,
 637:     strokeWeight: 2,
 638: });
 639: 
 640: EOT;
 641:         $i +10;
 642: 
 643:         if (isset($item['point'][$i + 20])) {
 644:             // 温暖部
 645:             $ss = '';
 646:             if ($kind !1) {
 647:                 $points = array();
 648:                 semiCircle($item['point'][$i + 5]['latitude'], $item['point'][$i + 5]['longitude'], $item['point'][$i + 10]['latitude'], $item['point'][$i + 10]['longitude'], SEMICIRCLE, $points);
 649:                 $cnt = 0;
 650:                 foreach ($points as $point) {
 651:                     if ($cnt > 0)   $ss .",\n";
 652:                     $ss ."\t\t{ lat: {$point['latitude']}, lng: {$point['longitude']} }";
 653:                     $cnt++;
 654:                 }
 655:                 for ($j = $i$j <$i + 10$j++) {
 656:                     if ($cnt > 0)   $ss .",\n";
 657:                     $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 658:                     $cnt++;
 659:                 }
 660:                 $js .=<<< EOT
 661: new google.maps.Polygon({
 662:     map: map,
 663:     paths: [
 664:     {$ss}
 665:     ],
 666:     strokeColor: '{$table_color1[$kind]}',
 667:     strokeOpacity: 1.0,
 668:     strokeWeight: 2,
 669:     fillColor: '{$table_color1[$kind]}',
 670:     fillOpacity: 1.0,
 671: });
 672: 
 673: EOT;
 674:             } else {
 675:                 $ss = '';
 676:                 $cnt = 0;
 677:                 for ($j = $i$j <$i + 10$j++) {
 678:                     if ($cnt > 0)   $ss .",\n";
 679:                     $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 680:                     $cnt++;
 681:                 }
 682:                 $js .=<<< EOT
 683: new google.maps.Polyline({
 684:     map: map,
 685:     path: [
 686: {$ss}
 687:     ],
 688:     strokeColor: '{$table_color1[$kind]}',
 689:     strokeOpacity: 1.0,
 690:     strokeWeight: 2,
 691: });
 692: 
 693: EOT;
 694:             }
 695: 
 696:             // 寒冷部
 697:             if ($kind !0) {
 698:                 list($lat, $lng) = isoTriangle($item['point'][$i + 15]['latitude'], $item['point'][$i + 15]['longitude'], $item['point'][$i + 19]['latitude'], $item['point'][$i + 19]['longitude'], $table_angle[$kind]);
 699:                 $ss = "\t\t{ lat: {$lat}, lng: {$lng} }";
 700:                 $cnt = 1;
 701:                 for ($j = $i + 10$j <$i + 20$j++) {
 702:                     if ($cnt > 0)   $ss .",\n";
 703:                     $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 704:                     $cnt++;
 705:                 }
 706:                 $js .=<<< EOT
 707: new google.maps.Polygon({
 708:     map: map,
 709:     paths: [
 710:     {$ss}
 711:     ],
 712:     strokeColor: '{$table_color2[$kind]}',
 713:     strokeOpacity: 1.0,
 714:     strokeWeight: 1,
 715:     fillColor: '{$table_color2[$kind]}',
 716:     fillOpacity: 1.0
 717: });
 718: 
 719: EOT;
 720:             } else {
 721:                 $ss = '';
 722:                 $cnt = 0;
 723:                 for ($j = $i + 10$j <$i + 20$j++) {
 724:                     if ($cnt > 0)   $ss .",\n";
 725:                     $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 726:                     $cnt++;
 727:                 }
 728:                 $js .=<<< EOT
 729: new google.maps.Polyline({
 730:     map: map,
 731:     path: [
 732: {$ss}
 733:     ],
 734:     strokeColor: '{$table_color1[$kind]}',
 735:     strokeOpacity: 1.0,
 736:     strokeWeight: 2,
 737: });
 738: 
 739: EOT;
 740: 
 741:             }
 742:             $i +20;
 743:         }
 744: 
 745:         // 前線(寒冷)
 746:         $cnt = 0;
 747:         $ss = '';
 748:         for ($j = $i$j <$i + 10$j++) {
 749:             if (! isset($item['point'][$j])) {
 750:                 $flag = FALSE;
 751:                 break;
 752:             }
 753:             if ($cnt > 0)   $ss .",\n";
 754:             $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 755:             $cnt++;
 756:         }
 757:         $js .=<<< EOT
 758: new google.maps.Polyline({
 759:     map: map,
 760:     path: [
 761: {$ss}
 762:     ],
 763:     strokeColor: '{$table_color2[$kind]}',
 764:     strokeOpacity: 1.0,
 765:     strokeWeight: 2,
 766: });
 767: 
 768: EOT;
 769:         $i +10;
 770:     }
 771:     return $js;
 772: }

前述の関数 isoTrianglesemiCircle を利用し、Googleマップ上に前線を描くユーザー関数が jsStationaryFront_gmap である。
温暖前線、寒冷前線、停滞前線、閉塞前線の識別を変数 $kind にもたせて、すべて1つの関数で描画を行う。
基本形は停滞前線で、他の3つの前線は次のようにして描く。
  • 温暖前線‥‥寒冷前線記号は描かず、替わりに赤い直線(温暖前線)を引く。
  • 寒冷前線‥‥温暖前線記号は描かず、替わりに青い直線(寒冷前線)を引く。
  • 閉塞前線‥‥寒冷前線記号の向きを180度逆転し、紫色で描く。

解説:前線を描く(Leaflet)

weatherMap.php

 775: /**
 776:  * 前線描画用スクリプト作成:Leaflet
 777:  * @param   array $item 前線の座標
 778:  * @param   int   $kind 前線の種類(0:温暖前線,1:寒冷前線,2:停滞前線,
 779:  *                                  3:閉塞前線)
 780:  * @return  string 描画用スクリプト
 781: */
 782: function jsStationaryFront_leaflet($item, $kind) {
 783:     static $table_color1 = array('red', 'blue', 'red', 'purple');
 784:     static $table_color2 = array('red', 'blue', 'blue', 'purple');
 785:     static $table_angle  = array(0, 0, 0, 1);
 786: 
 787:     $i = 0;
 788:     $flag = TRUE;
 789:     $js = '';
 790:     while ($flag) {
 791:         // 前線(温暖)
 792:         $ss = '';
 793:         for ($j = $i$j <$i + 10$j++) {
 794:             if (! isset($item['point'][$j])) {
 795:                 $flag = FALSE;
 796:                 break;
 797:             }
 798:             $ss .=<<< EOT
 799:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 800: 
 801: EOT;
 802:         }
 803:         $js .=<<< EOT
 804: L.polyline([
 805: {$ss}
 806:         ], {
 807:             color: '{$table_color1[$kind]}',
 808:             opacity: 1.0,
 809:             weight: 2
 810:         }
 811:     ).addTo(map);
 812: 
 813: EOT;
 814:         $i +10;
 815: 
 816:         if (isset($item['point'][$i + 20])) {
 817:             // 温暖部
 818:             $ss = '';
 819:             if ($kind !1) {
 820:                 $points = array();
 821:                 semiCircle($item['point'][$i + 5]['latitude'], $item['point'][$i + 5]['longitude'], $item['point'][$i + 10]['latitude'], $item['point'][$i + 10]['longitude'], SEMICIRCLE, $points);
 822:                 foreach ($points as $point) {
 823:                     $ss .=<<< EOT
 824:             [{$point['latitude']}, {$point['longitude']}],
 825: 
 826: EOT;
 827:                 }
 828:                 for ($j = $i + 0$j <$i + 10$j++) {
 829:                     $ss .=<<< EOT
 830:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 831: 
 832: EOT;
 833:                 }
 834:                 $js .=<<< EOT
 835:     L.polygon([
 836: {$ss}
 837:         ], {
 838:             color: '{$table_color1[$kind]}',
 839:             fillColor: '{$table_color1[$kind]}',
 840:             fillOpacity: 1.0,
 841:         }
 842:     ).addTo(map);
 843: 
 844: EOT;
 845:             } else {
 846:                 for ($j = $i + 0$j <$i + 10$j++) {
 847:                     $ss .=<<< EOT
 848:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 849: 
 850: EOT;
 851:                 }
 852:                 $js .=<<< EOT
 853:     L.polyline([
 854: {$ss}
 855:         ], {
 856:             color: '{$table_color1[$kind]}',
 857:             opacity: 1.0,
 858:             weight: 2
 859:         }
 860:     ).addTo(map);
 861: 
 862: EOT;
 863:             }
 864: 
 865:             // 寒冷部
 866:             if ($kind !0) {
 867:                 list($lat, $lng) = isoTriangle($item['point'][$i + 15]['latitude'], $item['point'][$i + 15]['longitude'], $item['point'][$i + 19]['latitude'], $item['point'][$i + 19]['longitude'], $table_angle[$kind]);
 868:                 $ss =<<< EOT
 869:             [{$lat}, {$lng}],
 870: 
 871: EOT;
 872:                 for ($j = $i + 10$j <$i + 20$j++) {
 873:                     $ss .=<<< EOT
 874:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 875: 
 876: EOT;
 877:                 }
 878:                 $js .=<<< EOT
 879: L.polygon([
 880: {$ss}
 881:         ], {
 882:             color: '{$table_color2[$kind]}',
 883:             fillColor: '{$table_color2[$kind]}',
 884:             fillOpacity: 1.0,
 885:         }
 886:     ).addTo(map);
 887: 
 888: EOT;
 889:             } else {
 890:                 $ss = '';
 891:                 for ($j = $i + 10$j <$i + 20$j++) {
 892:                     $ss .=<<< EOT
 893:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 894: 
 895: EOT;
 896:                 }
 897:                 $js .=<<< EOT
 898:     L.polyline([
 899: {$ss}
 900:         ], {
 901:             color: '{$table_color1[$kind]}',
 902:             opacity: 1.0,
 903:             weight: 2
 904:         }
 905:     ).addTo(map);
 906: 
 907: EOT;
 908:             }
 909:             $i +20;
 910:         }
 911:         // 前線(寒冷)
 912:         $ss = '';
 913:         for ($j = $i$j <$i + 10$j++) {
 914:             if (! isset($item['point'][$j])) {
 915:                 $flag = FALSE;
 916:                 break;
 917:             }
 918:             $ss .=<<< EOT
 919:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 920: 
 921: EOT;
 922:         }
 923:         $js .=<<< EOT
 924: L.polyline([
 925: {$ss}
 926:         ], {
 927:             color: '{$table_color2[$kind]}',
 928:             opacity: 1.0,
 929:             weight: 2
 930:         }
 931:     ).addTo(map);
 932: 
 933: EOT;
 934:         $i +10;
 935:     }
 936: 
 937:     return $js;
 938: }

Leaflet上に前線を描くユーザー関数が jsStationaryFront_leaflet である。
アルゴリズムは、Googleマップ用の jsStationaryFront_gmap とほぼ同じで、多角形や直線を描く命令部分を変えている。

解説:ラベルを描く(Googleマップ)

weatherMap.php

 545: /**
 546:  * ラベル表示用スクリプト作成:Googleマップ
 547:  * @param   float  $latitude, $longitude ラベル表示座標
 548:  * @param   string $label ラベル
 549:  * @param   int    $size  フォントサイズ(pt)
 550:  * @param   string $color  フォントカラー
 551:  * @param   string $weight 太さ
 552:  * @return  string 描画用スクリプト
 553: */
 554: function jsLabel_gmap($latitude, $longitude, $label, $size=14, $color='#FF0000', $weight="normal") {
 555:     $js =<<< EOT
 556: new google.maps.Marker({
 557:     map: map,
 558:     position: new google.maps.LatLng({$latitude}, {$longitude}),
 559:     icon: {
 560:         url: 'https://www.pahoo.org/common/space.gif'
 561:     },
 562:     label: {
 563:         text: '{$label}',
 564:         color: '{$color}',
 565:         fontSize: '{$size}px',
 566:         fontWeight: '{$weight}'
 567:     }
 568: });
 569: 
 570: EOT;
 571:     return $js;
 572: }

Googleマップ上に高気圧や低気圧といったラベルや、気圧(数値)を描くユーザー関数が jsLabel_gmap である。
アイコン表示命令を利用し、アイコン画像を表示させないようにしている。

解説:ラベルを描く(Leaflet)

weatherMap.php

 574: /**
 575:  * ラベル表示用スクリプト作成:Leaflet
 576:  * @param   float  $latitude, $longitude ラベル表示座標
 577:  * @param   string $label  ラベル
 578:  * @param   int    $size   フォントサイズ(pt)
 579:  * @param   string $color  フォントカラー
 580:  * @param   string $weight 太さ
 581:  * @return  string 描画用スクリプト
 582: */
 583: function jsLabel_leaflet($latitude, $longitude, $label, $size=14, $color='#FF0000', $weight="normal") {
 584:     $js =<<< EOT
 585: new L.marker(
 586:     [{$latitude}, {$longitude}],
 587:     {
 588:     icon:
 589:         new L.divIcon({
 590:             html: '<span style="color:{$color}; font-size:{$size}px; font-weight:{$weight};">{$label}</span>',
 591:             iconSize: [0, 0],
 592:             iconAnchor: [{$size}, {$size}],
 593:         })
 594:     }
 595: ).addTo(map);
 596: 
 597: EOT;
 598:     return $js;
 599: }

Leaflet上に高気圧や低気圧といったラベルや、気圧(数値)を描くユーザー関数が Leaflet である。
前述の jsLabel_gmap と同様、アイコン表示命令を利用し、アイコン画像を表示させないようにしている。

解説:天気図描画スクリプトを生成する

weatherMap.php

 940: /**
 941:  * 天気図描画スクリプトを生成する
 942:  * @param   string $url    地上実況図URL
 943:  * @param   object $pgc   pahooGeoCodeオブジェクト
 944:  * @param   object $pcc    pahooCacheオブジェクト
 945:  * @param   string $dt     報告日時格納用
 946:  * @param   string $errmsg エラーメッセージ格納用
 947:  * @return  string スクリプト/FALSE:生成失敗
 948: */
 949: function jsWeatherMap($url, $pgc, $pcc, &$dt, &$errmsg) {
 950:     static $table = array('温暖前線', '寒冷前線', '停滞前線', '閉塞前線');
 951:     $errmsg = '';
 952:     $js = '';
 953:     $items = array();
 954:     $res = jmaGetIsobar($url, $dt, $items, $pcc);
 955:     if ($res == FALSE) {
 956:         $errmsg = '気象庁防災情報XMLから地上実況図を取得できません';
 957:     }
 958: 
 959:     // スクリプト生成
 960:     foreach ($items as $key=>$item) {
 961:         if ($item['type'] == '等圧線') {
 962:             $js .$pgc->jsLine($item['point'], 'black', 0.5, 0.5, MAPSERVICE);
 963:         } else if (preg_match('/前線/ui', $item['type']) > 0) {
 964:             $kind = array_search($item['type'], $table);
 965:             if (MAPSERVICE == 0) {
 966:                 $js .jsStationaryFront_gmap($item, $kind);
 967:             } else {
 968:                 $js .jsStationaryFront_leaflet($item, $kind);
 969:             }
 970:         } else if (preg_match('/気圧|台風|熱帯/ui', $item['type']) > 0) {
 971:             switch ($item['type']) {
 972:                 case '高気圧':
 973:                     $label = '高';
 974:                     $color = 'blue';
 975:                     break;
 976:                 case '低気圧':
 977:                     $label = '低';
 978:                     $color = 'red';
 979:                     break;
 980:                 case '台風':
 981:                     $label = '台';
 982:                     $color = 'magenta';
 983:                     break;
 984:                 case '熱帯低気圧':
 985:                     $label = '熱';
 986:                     $color = 'magenta';
 987:                     break;
 988:             }
 989:             if (MAPSERVICE == 0) {
 990:                 $js .jsLabel_gmap($item['latitude'], $item['longitude'], $label, 18, $color, 'bold');
 991:                 $js .jsLabel_gmap($item['latitude'- 1.3, $item['longitude'], $item['pressure'], 16, 'black', 'bold%');
 992:             } else {
 993:                 $js .jsLabel_leaflet($item['latitude'], $item['longitude'], $label, 18, $color, 'bold');
 994:                 $js .jsLabel_leaflet($item['latitude'- 1.3, $item['longitude'], $item['pressure'], 16, 'black', 'bold');
 995:             }
 996:         }
 997:     }
 998: 
 999:     // HTMLの画像化
1000:     $js .js_html2image();
1001: 
1002:     return $js;
1003: }

jmaGetIsobar 関数で取り込んだデータを利用し、いままで紹介してきたユーザー関数、および pahooGeoCode::jsLine メソッドを使い、前線、気圧、等圧線を描くためのJavaScriptを生成するユーザー関数が jsWeatherMap である。

解説:SNS投稿機能

PHPによるSNS投稿機能の概念図
PHPによるSNS投稿機能の概念図
表示したコンテンツを画像として、メッセージと一緒にボタン1つでSNSに投稿する機能を追加した。流れは次の通りである。
コンテンツを描画しているのはクライアントにあるブラウザ(レンダリングエンジン)であることから、クライアント側で画像を作成し、サーバ側で SNS の API をコールするという方針とした。
  1. ブラウザはサーバにコンテンツ描画をリクエストする。
  2. サーバはデータサイトからデータ取得する。(サーバキャッシュにデータがあればそれを利用する)
  3. サーバはコンテンツ描画スクリプトを生成する。
  4. サーバはブラウザへレスポンス(HTML文)を返す。
  5. ブラウザはコンテンツをレンダリングする。
  6. ブラウザはレンダリングしたコンテンツを画像データとしてサーバへアップロードする。
  7. サーバは SNS の API を使ってツイートする。
  8. サーバは SNS へメッセージと画像を送る。
  9. サーバはブラウザへレスポンス(HTML文)を返す。
ブラウザでレンダリングしたグラフを画像に変換する処理は、JavaScriptの html2canvas ライブラリを利用した。このライブラリの使い方は後述する。投稿するSNSのうち、Twitter(現・X) については、「PHPでTwitterに投稿(ツイート)する」で作成した pahooTwitterAPI クラスを利用する。Bluesky については、「PHPでBlueskyに投稿する」で作成した pahooBlueskyAPI クラスを利用する。

解説:html2canvasライブラリ

weatherMap.php

 136: <script src="https://html2canvas.hertzen.com/dist/html2canvas.js"></script>
 137: 

HTML表示を画像化するために、html2canvas ライブラリを利用する。このライブラリは Niklas von Hertzen氏によって開発されたもので、ライセンスはMITとなっている。
画像化を実行するJavaScript関数は html2canvas である。

weatherMap.php

1098: <div id="{$target}" name="{$target}" style="width:{$width2}px;">
1099: <p>
1100: 🌞天気図 {$dt}現在
1101: &nbsp;{$tweet}{$bluesky}{$tweet_bluesky}
1102: </p>
1103: <div id="{$mapid}" style="width:{$width}px; height:{$height}px;"></div>
1104: </div>

画像化するオブジェクトは、<div id="{$target}"> で指定する範囲である。
レンダリングエンジンによって違うのかもしれないが、html2canvas ライブラリによって画像化される範囲が実際よりやや小さいため、あえてマップ領域より、幅を20ピクセル大きくした範囲を画像化範囲としている。

weatherMap.php

 290: /**
 291:  * HTMLオブジェクトの画像化
 292:  * @param   なし
 293:  * @return  string JavaScriptコード
 294: */
 295: function js_html2image() {
 296:     $target = TARGET;
 297:     $js = '';
 298: 
 299:     // Googleマップの場合
 300:     if (MAPSERVICE == 0) {
 301:         $js .=<<< EOT
 302: google.maps.event.addListener(map, 'tilesloaded', function() {
 303:     var capture = document.querySelector('#{$target}');
 304:     html2canvas(capture, {useCORS: true, allowTaint:true}).then(canvas => {
 305:         var base64 = canvas.toDataURL('image/png');     // 画像化
 306:         $('#base64').val(base64);
 307:     });
 308: });
 309: 
 310: EOT;
 311: 
 312:     // Leafletの場合(ブラウザによってはうまく動作しない)
 313:     } else {
 314:         $js .=<<< EOT
 315: HTMLCanvasElement.prototype.getContext = function(origFn) {
 316:     return function(type, attribs) {
 317:         attribs = attribs || {};
 318:         attribs.preserveDrawingBuffer = true;
 319:         return origFn.call(this, type, attribs);
 320:     };
 321: } (HTMLCanvasElement.prototype.getContext);
 322: 
 323: // HTML画像化イベント登録
 324: function html2image() {
 325:     var capture = document.querySelector('#{$target}');
 326:     html2canvas(capture, {useCORS: true, allowTaint:true}).then(canvas => {
 327:         var base64 = canvas.toDataURL('image/png');     // 画像化
 328:         $('#base64').val(base64);
 329:         document.body.innerHTML += '<img src="' + base64 + '"/>';
 330:     });
 331: };
 332: 
 333: // ズーム変更イベント
 334: map.on('zoomend', function() {
 335:     html2image();
 336: });
 337: 
 338: // マップ移動イベント
 339: map.on('moveend', function() {
 340:     html2image();
 341: });
 342: 
 343: // html2image()を発火させるために,ズームアウトして500msec後に元に戻す.
 344: /**
 345: var zoom = map.getZoom();
 346: map.setZoom(zoom - 1);
 347: setTimeout(function() {
 348:     map.setZoom(zoom);
 349: }, 500);
 350: map.setZoom(zoom);
 351: **/
 352: 
 353: EOT;
 354:     }
 355: 
 356:     return $js;
 357: }

html2canvas を呼び出すタイミングだが、Googleマップの場合は tilesloadedイベントにフックする。
Leaflet の場合、これに相当するイベントがないため、ズーム変更完了イベントにフックし、強制的にズーム変更イベントを発生させるタイミングで画像化する。しかし、Leaflet では、画像を描画するレイヤ構造の関係で、html2canvas ライブラリでは位置ずれが起きることがわかっている。その場合は、Googleマップを選択してほしい。また、対策方法をご存じの方がいたらお知らせいただきたい
これらをJavaScriptとして生成するユーザー関数が js_html2image である。

準備:pahooTwitterAPI クラス

pahooTwitterAPI.php

Twitter API」を利用するために、API keyAPI secret keyAccess tokenAccess token secret が必要で、入手方法は「Twitter API - WebAPIの登録方法」を参照されたい。

解説:メディア付き投稿(RAWデータ)

pahooTwitterAPI.php

 610: /**
 611:  * バイナリデータを使ったメディア付きメッセージをツイートする.
 612:  * Tweetet API v2 を使用する.
 613:  * @param   string $message 投稿メッセージ(UTF-8限定)
 614:  * @param   array  $items   メディアデータ(バイナリデータ配列)
 615:  * @return  bool TRUE:リクエスト成功/FALSE:失敗
 616: */
 617: function tweet_media_raw($message, $items) {
 618:     // メディアのアップロード
 619:     $media_ids = array();
 620:     $cnt = 0;
 621:     // Tweetet API v1.1 を使用する(v2にメディアアップロードが未実装のため)
 622:     $this->connection->setApiVersion('1.1'); 
 623:     foreach ($items as $data) {
 624:         $tmpname = $this->saveTempFile($data);
 625: //      $media = $this->connection->upload('media/upload', ['media' => $tmpname]);
 626:         $media = $this->connection->upload('media/upload', ['media' => $tmpname], ['chunkedUpload' => true]);   // twitteroauth 7.0.0 対応
 627:         unlink($tmpname);
 628:         if (! isset($media->media_id_string))   break;      // 処理失敗
 629:         $media_ids[] = (string)$media->media_id_string;
 630:         $cnt++;
 631:         if ($cnt > 3)   break;      // 最大4つまで
 632:     }
 633: 
 634:     // メディア付きツイート(Tweetet API v2 を使用する)
 635:     $this->connection->setApiVersion('2'); 
 636:     $option = [
 637:         'text' => $message,
 638:         'media' => [
 639:             'media_ids' => $media_ids
 640:         ]
 641:     ];
 642: //  $status = $this->connection->post('tweets', $option, TRUE);
 643:     $status = $this->connection->post('tweets', $option, ['jsonPayload' => true]);  // twitteroauth 7.0.0 対応
 644:     $this->webapi = 'https://api.twitter.com/2/tweets';
 645: 
 646:     // 処理に成功した.
 647:     if ($this->isSuccess()) {
 648:         $this->responses = $status->data;
 649:         $this->errcode   = NULL;
 650:         $this->errmsg    = '';
 651:         $this->error     = FALSE;
 652:         $res = TRUE;
 653:     // 処理に失敗した.
 654:     } else {
 655:         if ($this->isAuthError() == FALSE) {
 656:             $this->errmsg = $status->detail;
 657:             $this->error  = TRUE;
 658:         }
 659:         $res = FALSE;
 660:     }
 661:     return $res;
 662: }

メッセージと画像を同時にツイートするのに、「PHPでTwitterに画像付きメッセージ投稿」で作ったメソッド tweet_media を改良し、引数に画像バイナリデータ(RAWデータ)を渡してTwitterAPIを呼び出す tweet_media_raw メソッドを用意した。

解説:Twitter(現・X)へ投稿する

weatherMap.php

 233: /**
 234:  * Twitter(現・X)投稿
 235:  * @param   string $message 投稿文
 236:  * @param   string $res     応答メッセージ格納用
 237:  * @return  bool TRUE:成功/FALSE:失敗または未処理
 238: */
 239: function mediaTweet($message, &$res) {
 240:     if (! TWITTER)  return FALSE;
 241: 
 242:     $ret = TRUE;
 243:     if (isset($_POST['base64']) && ($_POST['base64'!'')) {
 244:         $base64 = preg_replace('/data\:image\/png\;base64\,/ui', '', $_POST['base64']);
 245:         $raws = array(base64_decode($base64));
 246:         $ptw = new pahooTwitterAPI();
 247:         $ptw->tweet_media_raw($message, $raws);
 248:         $errmsg = $ptw->errmsg;
 249:         $ret = ! $ptw->error;
 250:         $ptw = NULL;
 251:         if ($ret) {
 252:             $res = 'ツイートしました';
 253:         }
 254:     }
 255:     return $ret;
 256: }

メッセージと画像を Twitter(現・X)に投稿するのはサーバ側のPHPユーザー関数 mediaTweet である。
ブラウザからPOSTで受け取った画像データ $_POST['base64'] はBASE64でデコードされており、冒頭に余計なヘッダ情報が付いているので、このヘッダ情報を除き( preg_replace )、バイナリデータにデコードする( base64_decode )。
続いて pahooTwitterAPI クラスを呼び出し、tweet_media_raw メソッドを使って、メッセージと画像を一気に投稿する。

準備:pahooBlueskyAPI クラス

pahooBlueskyAPI.php

Bluesky API」を利用するために、ハンドル名アプリケーション・パスワード が必要で、入手方法は「Bluesky API - WebAPIの登録方法」を参照されたい。

解説:Blueskyへ投稿する

typhoon.php

 253: /**
 254:  * Bluesky投稿
 255:  * @param   string $message 投稿文
 256:  * @param   string $res     応答メッセージ格納用
 257:  * @return  bool TRUE:成功/FALSE:失敗または未処理
 258: */
 259: function mediaBluesky($message, &$res) {
 260:     if (! BLUESKY)  return FALSE;
 261: 
 262:     $ret = TRUE;
 263:     if (isset($_POST['base64']) && ($_POST['base64'!'')) {
 264:         $base64 = preg_replace('/data\:image\/png\;base64\,/ui', '', $_POST['base64']);
 265:         $raws = array(base64_decode($base64));
 266:         $pbs = new pahooBlueskyAPI(BLUESKY_DOMAIN);
 267:         $res = $pbs->createSession();
 268:         $pbs->post($message, FALSE, NULL, NULL, $raws);
 269:         $errmsg = $pbs->geterror();
 270:         $ret = ! $pbs->iserror();
 271:         $res = $pbs->deleteSession();
 272:         $pbs = NULL;
 273:         if ($ret) {
 274:             $res = 'Blueskyに投稿しました';
 275:         }
 276:     }
 277:     return $ret;
 278: }

メッセージと画像を Blueskyに投稿するのはサーバ側のPHPユーザー関数 mediaBluesky である。
ブラウザからPOSTで受け取った画像データ $_POST['base64'] はBASE64でデコードされており、冒頭に余計なヘッダ情報が付いているので、このヘッダ情報を除き( preg_replace )、バイナリデータにデコードする( base64_decode )。
続いて pahooBlueskyAPI クラスを呼び出し、post メソッドを使って、メッセージと画像を一気に投稿する。

解説:SNSへ投稿する(メイン・プログラム)

weatherMap.php

1152: // Twitter(現・X)、Blueskyへ投稿
1153: $message =<<< EOT
1154: 🌞天気図 {$dt}現在
1155: 
1156: (ご参考)PHPで天気図を描く https://www.pahoo.org/e-soul/webtech/php06/php06-73-01.shtm
1157: 
1158: EOT;
1159: 
1160: if (TWITTER && isButton('tweet')) {
1161:     mediaTweet($message, $res);
1162: }
1163: if (BLUESKY && isButton('bluesky')) {
1164:     mediaBluesky($message, $res);
1165: }
1166: 
1167: // 表示コンテンツを作成する.

メイン・プログラムでは、mediaTweetmediaBluesky を呼び出し、メッセージと画像をSNSに投稿する。メッセージの内容は自由に変更していただいて構わない。

参考サイト

(この項おわり)
header