PHPで天気図を描く

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

(2023年9月20日)js_html2image()--Leaflet用html2image()発火プロセス見直し
(2022年3月10日)気象庁防災情報XMLのhttps化に対応。キャッシュディレクトリ(定数 DIR_CACHE で指定するもの)は、ディレクトリごと消去してから新しプログラムを起動すること。

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

PHPで天気図を描く

目次

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

圧縮ファイルの内容
weatherMap.phpサンプル・プログラム本体。
pahooGeoCode.php住所・緯度・経度に関わるクラス pahooGeoCode。
使い方は「PHPで住所・ランドマークから最寄り駅を求める」「PHPで住所・ランドマークから緯度・経度を求める」などを参照。include_path が通ったディレクトリに配置すること。
pahooCache.phpキャッシュ処理に関わるクラス pahooCache。
キャッシュ処理に関わるクラスの使い方は「PHPで天気予報を求める」を参照。include_path が通ったディレクトリに配置すること。
pahooTwitterAPI.phpTwitter APIを利用するクラス pahooTwitterAPI。
使い方は「PHPでTwitterに投稿(ツイート)する」などを参照。include_path が通ったディレクトリに配置すること。
weatherMap.php 更新履歴
バージョン 更新日 内容
1.4.1 2023/09/20 js_html2image()--Leaflet用html2image()発火プロセス見直し
1.4 2022/03/10 気象庁防災情報XMLのhttps化に対応
1.3 2021/06/20 ツイート機能を追加
1.2 2021/04/18 台風,熱帯低気圧に対応
1.1 2021/04/10 キャッシュ・システム導入:pahooCacheクラス
pahooGeoCode.php 更新履歴
バージョン 更新日 内容
6.3.1 2023/07/09 bug-fix
6.3.0 2023/07/02 getPointsGSI()追加
6.2.0 2023/07/02 ip2address()追加
6.1.0 2022/12/30 ip2address()追加
6.0.4 2022/12/13 PHP8.2対応
pahooCache.php 更新履歴
バージョン 更新日 内容
1.1.1 2023/02/11 コメント追記
1.1 2021/04/08 simplexml_load()メソッド追加
1.0 2021/04/02 初版
pahooTwitterAPI.php 更新履歴
バージョン 更新日 内容
5.2.0 2023/07/17 oembed() v2対応
5.1.0 2023/07/16 extractMediaURL() -- file:///形式に対応
5.0.0 2023/07/02 メソッドをTwitter API v2へ移行;v1.1は別名or廃止
4.9.0 2023/04/15 tweet3() 追加
4.8.0 2023/01/28 tweet2(),twitter_strcut2(),extractMediaURL()追加

準備:pahooGeoCode クラス

  37: class pahooGeoCode {
  38:     var $items;     //検索結果格納用
  39:     var $error;     //エラー・フラグ
  40:     var $errmsg;    //エラー・メッセージ
  41:     var $hits;      //検索ヒット件数
  42:     var $webapi;    //直前に呼び出したWebAPI URL
  43: 
  44:     //Google Cloud Platform APIキー
  45:     //https://cloud.google.com/maps-platform/
  46:     //※Google Maps APIを利用しないのなら登録不要
  47:     var $GOOGLE_API_KEY_1 = '**************************';   //HTTPリファラ用
  48:     var $GOOGLE_API_KEY_2 = '**************************';   //IP制限用
  49: 
  50:     //Yahoo! JAPAN Webサービス アプリケーションID
  51:     //https://e.developer.yahoo.co.jp/register
  52:     //※Yahoo! JAPAN Webサービスを利用しないのなら登録不要
  53:     var $YAHOO_APPLICATION_ID = '*****************************';

地図サービスを利用するために、クラスファイル "pahooGeoCode.php" を使用する。組み込み関数  require_once  を使って読めるディレクトリに配置する。ディレクトリは、設定ファイル php.ini に記述されているオプション設定 include_path に設定しておく。
クラスについては「PHPでクラスを使ってテキストの読みやすさを調べる」を参照されたい。

地図や住所検索として Google を利用するのであれば、Google Cloud Platform APIキー が必要で、その入手方法は「Google Cloud Platform - WebAPIの登録方法」を参照されたい。

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

  40: //地図描画サービスの選択
  41: //    0:Google
  42: //    2:地理院地図・OSM
  43: define('MAPSERVICE', 2);
  44: 
  45: //マップの表示サイズ(単位:ピクセル)
  46: define('MAP_WIDTH',  600);
  47: define('MAP_HEIGHT', 480);
  48: //マップID
  49: define('MAPID', 'map_id');
  50: //初期値
  51: define('DEF_LATITUDE',  35.0);          //緯度
  52: define('DEF_LONGITUDE', 137.0);         //経度
  53: define('DEF_TYPE',      'GSISTD');      //マップタイプ
  54: define('DEF_ZOOM',      4);             //ズーム
  55: 
  56: define('SEMICIRCLE',    30);            //半円を代替する多角形頂点数

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

準備:キャッシュ・システム

 108: //キャッシュ保持時間(分) 0:キャッシュしない
 109: //気象庁へのアクセス負荷軽減のため,60分以上のキャッシュ保持をお勧めします.
 110: define('LIFE_CACHE', 120);
 111: 
 112: //キャッシュ・ディレクトリ
 113: //書き込み可能で,外部からアクセスされないディレクトリを指定してください.
 114: //最大150Mバイトを消費します.天気予報系プログラムは同じディレクトリで構わない.
 115: define('DIR_CACHE', './pcache/');

気象庁サイトへ負荷をかけないよう、キャッシュ・クラス pahooCache を導入した。使用方法については、「PHPで天気予報を求める - キャッシュ・システム」を参照いただきたい。
キャッシュ保持時間、キャッシュ・ディレクトリともに、自サイトの環境に応じて変更してほしい。

気象庁防災情報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 座標列

準備:表示幅、表示列数など

  45: //マップの表示サイズ(単位:ピクセル)
  46: define('MAP_WIDTH',  600);
  47: define('MAP_HEIGHT', 480);
  48: //マップID
  49: define('MAPID', 'map_id');
  50: //初期値
  51: define('DEF_LATITUDE',  35.0);          //緯度
  52: define('DEF_LONGITUDE', 137.0);         //経度
  53: define('DEF_TYPE',      'GSISTD');      //マップタイプ
  54: define('DEF_ZOOM',      4);             //ズーム
  55: 
  56: define('SEMICIRCLE',    30);            //半円を代替する多角形頂点数

マップの表示サイズなどの初期値は、とくに断りがないかぎりは自由に変更できる。

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

 309: /**
 310:  * 気象庁防災情報XMLから最新の地上実況図URLを取得
 311:  * @param   object $pcc pahooCacheオブジェクト
 312:  * @return  string 地上実況図URL/FALSE:取得失敗
 313: */
 314: function jmaGetWeatherMapURL($pcc) {
 315:     //URLパターン
 316:     $vzsa50 = '/https?\:\/\/www\.data\.jma\.go\.jp\/developer\/xml\/data\/([0-9\_]+)VZSA50\_([0-9\_]+)\.xml/ui';
 317: 
 318:     $xml = $pcc->simplexml_load(FEED_REGULAR);
 319:     //レスポンス・チェック
 320:     if ($pcc->iserror() || !isset($xml->entry)) {
 321:         return FALSE;
 322:     }
 323: 
 324:     //フィード(XMLファイル)解析
 325:     $vzsa50_url = $vzsa50_dt = '';
 326:     $res = FALSE;
 327:     foreach ($xml->entry as $node) {
 328:         //日時がより新しいURLを採用
 329:         if (preg_match($vzsa50, $node->id, $arr> 0) {
 330:             if ($arr[1> $vzsa50_dt) {
 331:                 $vzsa50_url = $arr[0];
 332:                 $vzsa50_dt  = $arr[1];
 333:                 $res = TRUE;
 334:             }
 335:         }
 336:     }
 337: 
 338:     //エラー・チェック
 339:     if (! $res) {
 340:         return FALSE;
 341:     }
 342: 
 343:     return $vzsa50_url;
 344: }

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

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

 346: /**
 347:  * 気象庁防災情報XMLから地上実況図を取得
 348:  * @param   string $url   地上実況図URL
 349:  * @param   string $dt    報告日時格納用
 350:  * @param   array  $items 情報を格納する配列
 351:  * @param   object $pcc   pahooCacheオブジェクト
 352:  * @return  bool TRUE:取得成功/FALSE:失敗
 353: */
 354: function jmaGetIsobar($url, &$dt, &$items, $pcc) {
 355:     //名前空間
 356:     define('JMX_EB', 'http://xml.kishou.go.jp/jmaxml1/elementBasis1/');
 357: 
 358:     $xml = $pcc->simplexml_load($url);
 359:     //レスポンス・チェック
 360:     if ($pcc->iserror() || !isset($xml->Body->MeteorologicalInfos)) {
 361:         return FALSE;
 362:     }
 363: 
 364:     //報告日時
 365:     $pat = '/([0-9]+)\-([0-9]+)\-([0-9]+)T([0-9]+)\:/ui';
 366:     $dt = (string)$xml->Body->MeteorologicalInfos->MeteorologicalInfo->DateTime;
 367:     if (preg_match($pat, $dt, $arr> 0) {
 368:         $dt = sprintf('%d年%d月%d日 %d時', $arr[1], $arr[2], $arr[3], $arr[4]);
 369:     } else {
 370:         $dt = '';
 371:     }
 372: 
 373:     //等圧線情報
 374:     $res = FALSE;
 375:     $cnt = 0;
 376:     foreach ($xml->Body->MeteorologicalInfos->MeteorologicalInfo->Item as $item) {
 377:         $Property = $item->Kind->Property;
 378:         //等圧線
 379:         if ($Property->Type == '等圧線') {
 380:             $IsobarPart = $Property->IsobarPart->children(JMX_EB);
 381:             $items[$cnt]['type'] = (string)$Property->Type;
 382:             $items[$cnt]['pressure'] = (int)$IsobarPart->Pressure;
 383:             $bar = (string)$IsobarPart->Line;
 384:             $arr = preg_split('/\//ui', $bar);
 385:             //緯度・経度
 386:             $i = 0;
 387:             foreach ($arr as $ss) {
 388:                 if (preg_match('/([\+\-]+[0-9\.]+)([\+\-]+[0-9\.]+)/ui', $ss, $arr2> 0) {
 389:                     $items[$cnt]['point'][$i]['latitude']  = (float)$arr2[1];
 390:                     $items[$cnt]['point'][$i]['longitude'] = (float)$arr2[2];
 391:                     $i++;
 392:                 }
 393:             }
 394:             $res = TRUE;
 395:             $cnt++;
 396:         //前線
 397:         } else if (preg_match('/前線/ui', $Property->Type> 0) {
 398:             $CoordinatePart = $Property->CoordinatePart->children(JMX_EB);
 399:             $items[$cnt]['type'] = (string)$Property->Type;
 400:             $bar = (string)$CoordinatePart->Line;
 401:             $arr = preg_split('/\//ui', $bar);
 402:             //緯度・経度
 403:             $i = 0;
 404:             foreach ($arr as $ss) {
 405:                 if (preg_match('/([\+\-]+[0-9\.]+)([\+\-]+[0-9\.]+)/ui', $ss, $arr2> 0) {
 406:                     $items[$cnt]['point'][$i]['latitude']  = (float)$arr2[1];
 407:                     $items[$cnt]['point'][$i]['longitude'] = (float)$arr2[2];
 408:                     $i++;
 409:                 }
 410:             }
 411:             $res = TRUE;
 412:             $cnt++;
 413:         //気圧
 414:         } else if (preg_match('/気圧|台風|熱帯/ui', $Property->Type> 0) {
 415:             $items[$cnt]['type']  = (string)$Property->Type;
 416:             $CenterPart = $Property->CenterPart->children(JMX_EB);
 417:             if (preg_match('/([\+\-]+[0-9\.]+)([\+\-]+[0-9\.]+)/ui', (string)$CenterPart->Coordinate, $arr2> 0) {
 418:                 $items[$cnt]['latitude']  = (float)$arr2[1];
 419:                 $items[$cnt]['longitude'] = (float)$arr2[2];
 420:             }
 421:             $items[$cnt]['pressure'] = (int)$CenterPart->Pressure;
 422:             $res = TRUE;
 423:             $cnt++;
 424:         }
 425:     }
 426: 
 427:     return TRUE;
 428: }

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

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

解説:前線記号

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

 452: /**
 453:  * 二等辺三角形座標(寒冷前線用)
 454:  * @param   float $lat0, $lng0 底辺の中心座標
 455:  * @param   float $lat1, $lng2 底辺の一方の座標
 456:  * @param   int   $way         0:寒冷前線・停滞前線,1:閉塞前線
 457:  * @return  array($lat, $lng)  頂点の座標
 458: */
 459: function isoTriangle($lat0, $lng0, $lat1, $lng1, $way=0) {
 460:     $angle = ($way == 0? 270 : 90;
 461: 
 462:     if ($lng1 - $lng0 == 0) {
 463:         $t = 0.0;
 464:     } else {
 465:         $t = rad2deg(atan(($lat1 - $lat0) / ($lng1 - $lng0)));
 466:     }
 467:     $r = sqrt(pow(($lng1 - $lng0), 2+ pow(($lat1 - $lat0), 2));
 468:     $lat = $r * sin(deg2rad($t + $angle)) + $lat0;
 469:     $lng = $r * cos(deg2rad($t + $angle)) + $lng0;
 470: 
 471:     return array($lat, $lng);
 472: }

PHPで天気図を描く
関連前線記号の三角形は、当初、正三角形を考えていたが、温暖前線記号の半円とのバランスを考えて、AC=BCとなる二等辺三角形とした。
座標系は緯度、経度から成る球面座標系だが、マップサービスがメルカトル図法であることから、平面座標系であると近似して計算式を立てた。
底辺ABの中点  mimetex  を置くと、三角形BCOは直角三角形になる。
ここで頂点  mimetex  の座標から、∠BCOは  mimetex  である。
辺COの長さは  mimetex  であるから、頂点  mimetex  の座標は
 mimetex 
 mimetex 
で求められる。

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

 474: /**
 475:  * 半円弧座標(温暖前線記号):多角形で近似する
 476:  * @param   float $lat0, $lng0 中心座標
 477:  * @param   float $lat1, $lng2 円弧の始点座標
 478:  * @param   int   $n           多角形の頂点数
 479:  * @param   array points 円弧の座標を格納する
 480:  * @return  なし
 481: */
 482: function semiCircle($lat0, $lng0, $lat1, $lng1, $n, &$points) {
 483:     if ($lng1 - $lng0 == 0) {
 484:         $t = 0.0;
 485:     } else {
 486:         $t = rad2deg(atan(($lat1 - $lat0) / ($lng1 - $lng0)));
 487:     }
 488:     $r = sqrt(pow(($lng1 - $lng0), 2+ pow(($lat1 - $lat0), 2));
 489:     for ($i = 0$i < $n$i++) {
 490:         $points[$i]['latitude']  = $r * sin(deg2rad($t + 180 / $n * $i)) + $lat0;
 491:         $points[$i]['longitude'] = $r * cos(deg2rad($t + 180 / $n * $i)) + $lng0;
 492:     }
 493: }

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

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

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

 551: /**
 552:  * 前線描画用スクリプト作成:Googleマップ
 553:  * @param   array $item 前線の座標
 554:  * @param   int   $kind 前線の種類(0:温暖前線,1:寒冷前線,2:停滞前線,
 555:  *                                  3:閉塞前線)
 556:  * @return  string 描画用スクリプト
 557: */
 558: function jsStationaryFront_gmap($item, $kind) {
 559:     static $table_color1 = array('red', 'blue', 'red', 'purple');
 560:     static $table_color2 = array('red', 'blue', 'blue', 'purple');
 561:     static $table_angle  = array(0, 0, 0, 1);
 562: 
 563:     $i = 0;
 564:     $flag = TRUE;
 565:     $js = '';
 566:     while ($flag) {
 567:         //前線(温暖)
 568:         $ss = '';
 569:         $cnt = 0;
 570:         for ($j = $i$j <$i + 10$j++) {
 571:             if (! isset($item['point'][$j])) {
 572:                 $flag = FALSE;
 573:                 break;
 574:             }
 575:             if ($cnt > 0)   $ss .",\n";
 576:             $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 577:             $cnt++;
 578:         }
 579:         $js .=<<< EOT
 580: new google.maps.Polyline({
 581:     map: map,
 582:     path: [
 583: {$ss}
 584:     ],
 585:     strokeColor: '{$table_color1[$kind]}',
 586:     strokeOpacity: 1.0,
 587:     strokeWeight: 2,
 588: });
 589: 
 590: EOT;
 591:         $i +10;
 592: 
 593:         if (isset($item['point'][$i + 20])) {
 594:             //温暖部
 595:             $ss = '';
 596:             if ($kind !1) {
 597:                 $points = array();
 598:                 semiCircle($item['point'][$i + 5]['latitude'], $item['point'][$i + 5]['longitude'], $item['point'][$i + 10]['latitude'], $item['point'][$i + 10]['longitude'], SEMICIRCLE, $points);
 599:                 $cnt = 0;
 600:                 foreach ($points as $point) {
 601:                     if ($cnt > 0)   $ss .",\n";
 602:                     $ss ."\t\t{ lat: {$point['latitude']}, lng: {$point['longitude']} }";
 603:                     $cnt++;
 604:                 }
 605:                 for ($j = $i$j <$i + 10$j++) {
 606:                     if ($cnt > 0)   $ss .",\n";
 607:                     $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 608:                     $cnt++;
 609:                 }
 610:                 $js .=<<< EOT
 611: new google.maps.Polygon({
 612:     map: map,
 613:     paths: [
 614:     {$ss}
 615:     ],
 616:     strokeColor: '{$table_color1[$kind]}',
 617:     strokeOpacity: 1.0,
 618:     strokeWeight: 2,
 619:     fillColor: '{$table_color1[$kind]}',
 620:     fillOpacity: 1.0,
 621: });
 622: 
 623: EOT;
 624:             } else {
 625:                 $ss = '';
 626:                 $cnt = 0;
 627:                 for ($j = $i$j <$i + 10$j++) {
 628:                     if ($cnt > 0)   $ss .",\n";
 629:                     $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 630:                     $cnt++;
 631:                 }
 632:                 $js .=<<< EOT
 633: new google.maps.Polyline({
 634:     map: map,
 635:     path: [
 636: {$ss}
 637:     ],
 638:     strokeColor: '{$table_color1[$kind]}',
 639:     strokeOpacity: 1.0,
 640:     strokeWeight: 2,
 641: });
 642: 
 643: EOT;
 644:             }
 645: 
 646:             //寒冷部
 647:             if ($kind !0) {
 648:                 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]);
 649:                 $ss = "\t\t{ lat: {$lat}, lng: {$lng} }";
 650:                 $cnt = 1;
 651:                 for ($j = $i + 10$j <$i + 20$j++) {
 652:                     if ($cnt > 0)   $ss .",\n";
 653:                     $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 654:                     $cnt++;
 655:                 }
 656:                 $js .=<<< EOT
 657: new google.maps.Polygon({
 658:     map: map,
 659:     paths: [
 660:     {$ss}
 661:     ],
 662:     strokeColor: '{$table_color2[$kind]}',
 663:     strokeOpacity: 1.0,
 664:     strokeWeight: 1,
 665:     fillColor: '{$table_color2[$kind]}',
 666:     fillOpacity: 1.0
 667: });
 668: 
 669: EOT;
 670:             } else {
 671:                 $ss = '';
 672:                 $cnt = 0;
 673:                 for ($j = $i + 10$j <$i + 20$j++) {
 674:                     if ($cnt > 0)   $ss .",\n";
 675:                     $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 676:                     $cnt++;
 677:                 }
 678:                 $js .=<<< EOT
 679: new google.maps.Polyline({
 680:     map: map,
 681:     path: [
 682: {$ss}
 683:     ],
 684:     strokeColor: '{$table_color1[$kind]}',
 685:     strokeOpacity: 1.0,
 686:     strokeWeight: 2,
 687: });
 688: 
 689: EOT;
 690: 
 691:             }
 692:             $i +20;
 693:         }
 694: 
 695:         //前線(寒冷)
 696:         $cnt = 0;
 697:         $ss = '';
 698:         for ($j = $i$j <$i + 10$j++) {
 699:             if (! isset($item['point'][$j])) {
 700:                 $flag = FALSE;
 701:                 break;
 702:             }
 703:             if ($cnt > 0)   $ss .",\n";
 704:             $ss ."\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
 705:             $cnt++;
 706:         }
 707:         $js .=<<< EOT
 708: new google.maps.Polyline({
 709:     map: map,
 710:     path: [
 711: {$ss}
 712:     ],
 713:     strokeColor: '{$table_color2[$kind]}',
 714:     strokeOpacity: 1.0,
 715:     strokeWeight: 2,
 716: });
 717: 
 718: EOT;
 719:         $i +10;
 720:     }
 721:     return $js;
 722: }

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

解説:前線を描く(Leaflet)

 725: /**
 726:  * 前線描画用スクリプト作成:Leaflet
 727:  * @param   array $item 前線の座標
 728:  * @param   int   $kind 前線の種類(0:温暖前線,1:寒冷前線,2:停滞前線,
 729:  *                                  3:閉塞前線)
 730:  * @return  string 描画用スクリプト
 731: */
 732: function jsStationaryFront_leaflet($item, $kind) {
 733:     static $table_color1 = array('red', 'blue', 'red', 'purple');
 734:     static $table_color2 = array('red', 'blue', 'blue', 'purple');
 735:     static $table_angle  = array(0, 0, 0, 1);
 736: 
 737:     $i = 0;
 738:     $flag = TRUE;
 739:     $js = '';
 740:     while ($flag) {
 741:         //前線(温暖)
 742:         $ss = '';
 743:         for ($j = $i$j <$i + 10$j++) {
 744:             if (! isset($item['point'][$j])) {
 745:                 $flag = FALSE;
 746:                 break;
 747:             }
 748:             $ss .=<<< EOT
 749:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 750: 
 751: EOT;
 752:         }
 753:         $js .=<<< EOT
 754: L.polyline([
 755: {$ss}
 756:         ], {
 757:             color: '{$table_color1[$kind]}',
 758:             opacity: 1.0,
 759:             weight: 2
 760:         }
 761:     ).addTo(map);
 762: 
 763: EOT;
 764:         $i +10;
 765: 
 766:         if (isset($item['point'][$i + 20])) {
 767:             //温暖部
 768:             $ss = '';
 769:             if ($kind !1) {
 770:                 $points = array();
 771:                 semiCircle($item['point'][$i + 5]['latitude'], $item['point'][$i + 5]['longitude'], $item['point'][$i + 10]['latitude'], $item['point'][$i + 10]['longitude'], SEMICIRCLE, $points);
 772:                 foreach ($points as $point) {
 773:                     $ss .=<<< EOT
 774:             [{$point['latitude']}, {$point['longitude']}],
 775: 
 776: EOT;
 777:                 }
 778:                 for ($j = $i + 0$j <$i + 10$j++) {
 779:                     $ss .=<<< EOT
 780:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 781: 
 782: EOT;
 783:                 }
 784:                 $js .=<<< EOT
 785:     L.polygon([
 786: {$ss}
 787:         ], {
 788:             color: '{$table_color1[$kind]}',
 789:             fillColor: '{$table_color1[$kind]}',
 790:             fillOpacity: 1.0,
 791:         }
 792:     ).addTo(map);
 793: 
 794: EOT;
 795:             } else {
 796:                 for ($j = $i + 0$j <$i + 10$j++) {
 797:                     $ss .=<<< EOT
 798:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 799: 
 800: EOT;
 801:                 }
 802:                 $js .=<<< EOT
 803:     L.polyline([
 804: {$ss}
 805:         ], {
 806:             color: '{$table_color1[$kind]}',
 807:             opacity: 1.0,
 808:             weight: 2
 809:         }
 810:     ).addTo(map);
 811: 
 812: EOT;
 813:             }
 814: 
 815:             //寒冷部
 816:             if ($kind !0) {
 817:                 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]);
 818:                 $ss =<<< EOT
 819:             [{$lat}, {$lng}],
 820: 
 821: EOT;
 822:                 for ($j = $i + 10$j <$i + 20$j++) {
 823:                     $ss .=<<< EOT
 824:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 825: 
 826: EOT;
 827:                 }
 828:                 $js .=<<< EOT
 829: L.polygon([
 830: {$ss}
 831:         ], {
 832:             color: '{$table_color2[$kind]}',
 833:             fillColor: '{$table_color2[$kind]}',
 834:             fillOpacity: 1.0,
 835:         }
 836:     ).addTo(map);
 837: 
 838: EOT;
 839:             } else {
 840:                 $ss = '';
 841:                 for ($j = $i + 10$j <$i + 20$j++) {
 842:                     $ss .=<<< EOT
 843:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 844: 
 845: EOT;
 846:                 }
 847:                 $js .=<<< EOT
 848:     L.polyline([
 849: {$ss}
 850:         ], {
 851:             color: '{$table_color1[$kind]}',
 852:             opacity: 1.0,
 853:             weight: 2
 854:         }
 855:     ).addTo(map);
 856: 
 857: EOT;
 858:             }
 859:             $i +20;
 860:         }
 861:         //前線(寒冷)
 862:         $ss = '';
 863:         for ($j = $i$j <$i + 10$j++) {
 864:             if (! isset($item['point'][$j])) {
 865:                 $flag = FALSE;
 866:                 break;
 867:             }
 868:             $ss .=<<< EOT
 869:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
 870: 
 871: EOT;
 872:         }
 873:         $js .=<<< EOT
 874: L.polyline([
 875: {$ss}
 876:         ], {
 877:             color: '{$table_color2[$kind]}',
 878:             opacity: 1.0,
 879:             weight: 2
 880:         }
 881:     ).addTo(map);
 882: 
 883: EOT;
 884:         $i +10;
 885:     }
 886: 
 887:     return $js;
 888: }

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

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

 495: /**
 496:  * ラベル表示用スクリプト作成:Googleマップ
 497:  * @param   float  $latitude, $longitude ラベル表示座標
 498:  * @param   string $label ラベル
 499:  * @param   int    $size  フォントサイズ(pt)
 500:  * @param   string $color  フォントカラー
 501:  * @param   string $weight 太さ
 502:  * @return  string 描画用スクリプト
 503: */
 504: function jsLabel_gmap($latitude, $longitude, $label, $size=14, $color='#FF0000', $weight="normal") {
 505:     $js =<<< EOT
 506: new google.maps.Marker({
 507:     map: map,
 508:     position: new google.maps.LatLng({$latitude}, {$longitude}),
 509:     icon: {
 510:         url: 'https://www.pahoo.org/common/space.gif'
 511:     },
 512:     label: {
 513:         text: '{$label}',
 514:         color: '{$color}',
 515:         fontSize: '{$size}px',
 516:         fontWeight: '{$weight}'
 517:     }
 518: });
 519: 
 520: EOT;
 521:     return $js;
 522: }

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

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

 524: /**
 525:  * ラベル表示用スクリプト作成:Leaflet
 526:  * @param   float  $latitude, $longitude ラベル表示座標
 527:  * @param   string $label  ラベル
 528:  * @param   int    $size   フォントサイズ(pt)
 529:  * @param   string $color  フォントカラー
 530:  * @param   string $weight 太さ
 531:  * @return  string 描画用スクリプト
 532: */
 533: function jsLabel_leaflet($latitude, $longitude, $label, $size=14, $color='#FF0000', $weight="normal") {
 534:     $js =<<< EOT
 535: new L.marker(
 536:     [{$latitude}, {$longitude}],
 537:     {
 538:     icon:
 539:         new L.divIcon({
 540:             html: '<span style="color:{$color}; font-size:{$size}px; font-weight:{$weight};">{$label}</span>',
 541:             iconSize: [0, 0],
 542:             iconAnchor: [{$size}, {$size}],
 543:         })
 544:     }
 545: ).addTo(map);
 546: 
 547: EOT;
 548:     return $js;
 549: }

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

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

 890: /**
 891:  * 天気図描画スクリプトを生成する
 892:  * @param   string $url    地上実況図URL
 893:  * @param   object $pgc   pahooGeoCodeオブジェクト
 894:  * @param   object $pcc    pahooCacheオブジェクト
 895:  * @param   string $dt     報告日時格納用
 896:  * @param   string $errmsg エラーメッセージ格納用
 897:  * @return  string スクリプト/FALSE:生成失敗
 898: */
 899: function jsWeatherMap($url, $pgc, $pcc, &$dt, &$errmsg) {
 900:     static $table = array('温暖前線', '寒冷前線', '停滞前線', '閉塞前線');
 901:     $errmsg = '';
 902:     $js = '';
 903:     $items = array();
 904:     $res = jmaGetIsobar($url, $dt, $items, $pcc);
 905:     if ($res == FALSE) {
 906:         $errmsg = '気象庁防災情報XMLから地上実況図を取得できません';
 907:     }
 908: 
 909:     //スクリプト生成
 910:     foreach ($items as $key=>$item) {
 911:         if ($item['type'] == '等圧線') {
 912:             $js .$pgc->jsLine($item['point'], 'black', 0.5, 0.5, MAPSERVICE);
 913:         } else if (preg_match('/前線/ui', $item['type']) > 0) {
 914:             $kind = array_search($item['type'], $table);
 915:             if (MAPSERVICE == 0) {
 916:                 $js .jsStationaryFront_gmap($item, $kind);
 917:             } else {
 918:                 $js .jsStationaryFront_leaflet($item, $kind);
 919:             }
 920:         } else if (preg_match('/気圧|台風|熱帯/ui', $item['type']) > 0) {
 921:             switch ($item['type']) {
 922:                 case '高気圧':
 923:                     $label = '高';
 924:                     $color = 'blue';
 925:                     break;
 926:                 case '低気圧':
 927:                     $label = '低';
 928:                     $color = 'red';
 929:                     break;
 930:                 case '台風':
 931:                     $label = '台';
 932:                     $color = 'magenta';
 933:                     break;
 934:                 case '熱帯低気圧':
 935:                     $label = '熱';
 936:                     $color = 'magenta';
 937:                     break;
 938:             }
 939:             if (MAPSERVICE == 0) {
 940:                 $js .jsLabel_gmap($item['latitude'], $item['longitude'], $label, 18, $color, 'bold');
 941:                 $js .jsLabel_gmap($item['latitude'- 1.3, $item['longitude'], $item['pressure'], 16, 'black', 'bold%');
 942:             } else {
 943:                 $js .jsLabel_leaflet($item['latitude'], $item['longitude'], $label, 18, $color, 'bold');
 944:                 $js .jsLabel_leaflet($item['latitude'- 1.3, $item['longitude'], $item['pressure'], 16, 'black', 'bold');
 945:             }
 946:         }
 947:     }
 948: 
 949:     //HTMLの画像化
 950:     $js .js_html2image();
 951: 
 952:     return $js;
 953: }

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

解説:ツイート機能

表示した天気図(マップ)を画像として、メッセージと一緒にボタン1つでツイートする機能を追加した。流れは「PHPでCOVID-19情報をグラフ表示」で解説したとおりである。

  34: //ツイート・ボタン  TRUE:有効,FALSE:無効
  35: define('TWITTER', FALSE);
  36: 
  37: //画像化したいオブジェクト
  38: define('TARGET', 'target');

ツイート機能を使うかどうかは、定数 TWITTER で指定する。
FALSE なら、pahooTwitterAPI クラスを読み込まず、ツイート・ボタンも表示しない。ツイート・ボタンの作成については、「HTMLとCSSでさまざまなアイコンを表示する」を参照してほしい。
画像化したいオブジェクト(ID名)は定数 TARGET で指定する。

解説:html2canvasライブラリ

 103: <script src="https://html2canvas.hertzen.com/dist/html2canvas.js"></script>
 104: 

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

1027: <div id="{$target}" name="{$target}" style="width:{$width2}px;">
1028: <p>
1029: 🌞天気図 {$dt}現在
1030: &nbsp;{$tweet}
1031: </p>
1032: <div id="{$mapid}" style="width:{$width}px; height:{$height}px;"></div>
1033: </div>

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

 244: /**
 245:  * HTMLオブジェクトの画像化
 246:  * @param   なし
 247:  * @return  string JavaScriptコード
 248: */
 249: function js_html2image() {
 250:     $target = TARGET;
 251:     $js = '';
 252: 
 253:     //Googleマップの場合
 254:     if (MAPSERVICE == 0) {
 255:         $js .=<<< EOT
 256: google.maps.event.addListener(map, 'tilesloaded', function() {
 257:     var capture = document.querySelector('#{$target}');
 258:     html2canvas(capture, {useCORS: true, allowTaint:true}).then(canvas => {
 259:         var base64 = canvas.toDataURL('image/png');     //画像化
 260:         $('#base64').val(base64);
 261:     });
 262: });
 263: 
 264: EOT;
 265: 
 266:     //Leafletの場合(ブラウザによってはうまく動作しない)
 267:     } else {
 268:         $js .=<<< EOT
 269: HTMLCanvasElement.prototype.getContext = function(origFn) {
 270:     return function(type, attribs) {
 271:         attribs = attribs || {};
 272:         attribs.preserveDrawingBuffer = true;
 273:         return origFn.call(this, type, attribs);
 274:     };
 275: } (HTMLCanvasElement.prototype.getContext);
 276: 
 277: //HTML画像化イベント登録
 278: function html2image() {
 279:     var capture = document.querySelector('#{$target}');
 280:     html2canvas(capture, {useCORS: true, allowTaint:true}).then(canvas => {
 281:         var base64 = canvas.toDataURL('image/png');     //画像化
 282:         $('#base64').val(base64);
 283:     });
 284: };
 285: 
 286: //ズーム変更イベント
 287: map.on('zoomend', function() {
 288:     html2image();
 289: });
 290: 
 291: //マップ移動イベント
 292: map.on('moveend', function() {
 293:     html2image();
 294: });
 295: 
 296: //html2image()を発火させるために,ズームアウトして500msec後に元に戻す.
 297: var zoom = map.getZoom();
 298: map.setZoom(zoom - 1);
 299: setTimeout(function() {
 300:     map.setZoom(zoom);
 301: }, 500);
 302: 
 303: EOT;
 304:     }
 305: 
 306:     return $js;
 307: }

html2canvas を呼び出すタイミングだが、Googleマップの場合は tilesloadedイベントにフックする。
Leafletの場合、これに相当するイベントがないため、ズーム変更完了イベントにフックし、強制的にズーム変更イベントを発生させるタイミングで画像化する。しかし、この方法だとブラウザによって、マップ画像が無い状態で画像化されてしまうことがあるようだ。もし対策方法をご存じの方がいたらお知らせいただきたい。
これらをJavaScriptとして生成するユーザー関数が js_html2image である。

準備:pahooTwitterAPI クラス

  27:     //OAuth用パラメータ
  28:     // https://apps.twitter.com/
  29:     var $TWTR_CONSUMER_KEY    = '***************';  //Cunsumer key
  30:     var $TWTR_CONSUMER_SECRET = '***************';  //Consumer secret
  31:     var $TWTR_ACCESS_KEY      = '***************';  //Access Token (oauth_token)
  32:     var $TWTR_ACCESS_SECRET   = '***************';  //Access Token Secret (oauth_token_secret)
  33: 

TwitterAPI を利用するために、クラスファイル "pahooTwitterAPI.php" を使用する。組み込み関数  require_once  を使って読めるディレクトリに配置する。ディレクトリは、設定ファイル php.ini に記述されているオプション設定 include_path に設定しておく。

事前にプログラムを登録しておく必要があり、その方法は「Twitter API - WebAPIの登録方法」を参照されたい。入手したパラメータを、上述の変数に代入しておくこと。

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

 584: /**
 585:  * バイナリデータを使ったメディア付きメッセージをツイートする.
 586:  * Tweetet API v2 を使用する.
 587:  * @param   string $message 投稿メッセージ(UTF-8限定)
 588:  * @param   array  $items   メディアデータ(バイナリデータ配列)
 589:  * @return  bool TRUE:リクエスト成功/FALSE:失敗
 590: */
 591: function tweet_media_raw($message, $items) {
 592:     //メディアのアップロード
 593:     $media_ids = array();
 594:     $cnt = 0;
 595:     //Tweetet API v1.1 を使用する(v2にメディアアップロードが未実装のため)
 596:     $this->connection->setApiVersion('1.1'); 
 597:     foreach ($items as $data) {
 598:         $tmpname = $this->saveTempFile($data);
 599:         $media = $this->connection->upload('media/upload', ['media' => $tmpname]);
 600:         unlink($tmpname);
 601:         if (! isset($media->media_id_string))   break;      //処理失敗
 602:         $media_ids[] = (string)$media->media_id_string;
 603:         $cnt++;
 604:         if ($cnt > 3)   break;      //最大4つまで
 605:     }
 606: 
 607:     //メディア付きツイート(Tweetet API v2 を使用する)
 608:     $this->connection->setApiVersion('2'); 
 609:     $option = [
 610:         'text' => $message,
 611:         'media' => [
 612:             'media_ids' => $media_ids
 613:         ]
 614:     ];
 615:     $status = $this->connection->post('tweets', $option, TRUE);
 616:     $this->webapi = 'https://api.twitter.com/2/tweets';
 617: 
 618:     //処理に成功した.
 619:     if ($this->isSuccess()) {
 620:         $this->responses = $status->data;
 621:         $this->errcode   = NULL;
 622:         $this->errmsg    = '';
 623:         $this->error     = FALSE;
 624:         $res = TRUE;
 625:     //処理に失敗した.
 626:     } else {
 627:         if ($this->isAuthError() == FALSE) {
 628:             $this->errmsg = $status->detail;
 629:             $this->error  = TRUE;
 630:         }
 631:         $res = FALSE;
 632:     }
 633:     return $res;
 634: }

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

解説:ツイート処理

 219: /**
 220:  * ツイート処理
 221:  * @param   string $message 投稿文
 222:  * @param   string $res     応答メッセージ格納用
 223:  * @return  bool TRUE:成功/FALSE:失敗または未処理
 224: */
 225: function mediaTweet($message, &$res) {
 226:     if (! TWITTER)  return FALSE;
 227: 
 228:     $ret = TRUE;
 229:     if (isset($_POST['base64']) && ($_POST['base64'!'')) {
 230:         $base64 = preg_replace('/data\:image\/png\;base64\,/ui', '', $_POST['base64']);
 231:         $raws = array(base64_decode($base64));
 232:         $ptw = new pahooTwitterAPI();
 233:         $ptw->tweet_media_raw($message, $raws);
 234:         $errmsg = $ptw->errmsg;
 235:         $ret = ! $ptw->error;
 236:         $ptw = NULL;
 237:         if ($ret) {
 238:             $res = 'ツイートしました';
 239:         }
 240:     }
 241:     return $ret;
 242: }

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

1080: //ツイート機能
1081: $message =<<< EOT
1082: 🌞天気図 {$dt}現在
1083: 
1084: (ご参考)PHPで天気図を描く https://www.pahoo.org/e-soul/webtech/php06/php06-73-01.shtm
1085: 
1086: EOT;
1087: mediaTweet($message, $res);

ユーザー関数 mediaTweet を呼び出しメインプログラム側のコードは上述の通りである。メッセージの内容は自由に変更していただいて構わない。

活用例

みんなの知識 ちょっと便利帳」では、「日本周辺域の天気図」で本プログラムを利用し、見やすいページを提供している。ありがとうございます。

参考サイト

(この項おわり)
header