PHPで天気図を描く

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

(2021 年 6 月 14 日)天気図(マップ)画像とメッセージを自動投稿するツイート機能を追加した.
(2021 年 4 月 18 日)台風,熱帯低気圧に対応.
(2021 年 4 月 10 日)キャッシュ・システム導入:pahooCache クラス.

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

PHPで天気図を描く

目次

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

圧縮ファイルの内容
weatherMap.phpサンプル・プログラム本体。
pahooGeoCode.php住所・緯度・経度に関わるクラス pahooGeoCode。
使い方は「PHPで住所・ランドマークから最寄り駅を求める」「PHPで住所・ランドマークから緯度・経度を求める」などを参照。include_path が通ったディレクトリに配置すること。
pahooCache.phpキャッシュ処理に関わるクラス pahooCache。
キャッシュ処理に関わるクラスの使い方は「PHPで天気予報を求める」を参照。include_path が通ったディレクトリに配置すること。
pahooTwitterAPI.phpTwitter APIを利用するクラス pahooTwitterAPI。
使い方は「PHPでTwitterに投稿(ツイート)する」などを参照。include_path が通ったディレクトリに配置すること。

準備:pahooGeoCode クラス

0037: class pahooGeoCode {
0038:     var $items;      //検索結果格納用
0039:     var $error;      //エラーフラグ
0040:     var $hits;       //検索ヒット件数
0041:     var $webapi; //直前に呼び出したWebAPI URL
0042: 
0043:     //Google Cloud Platform APIキー
0044:     //https://cloud.google.com/maps-platform/
0045:     //※Google Maps APIを利用しないのなら登録不要
0046:     var $GOOGLE_API_KEY_1 = '**************************';   //HTTPリファラ用
0047:     var $GOOGLE_API_KEY_2 = '**************************';   //IP制限用
0048: 
0049:     //Yahoo! JAPAN Webサービス アプリケーションID
0050:     //https://e.developer.yahoo.co.jp/register
0051:     //※Yahoo! JAPAN Webサービスを利用しないのなら登録不要
0052:     var $YAHOO_APPLICATION_ID = '*****************************';

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

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

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

0040: //地図描画サービスの選択
0041: //    0:Google
0042: //    2:地理院地図・OSM
0043: define('MAPSERVICE', 2);
0044: 
0045: //マップの表示サイズ(単位:ピクセル)
0046: define('MAP_WIDTH',  600);
0047: define('MAP_HEIGHT', 480);
0048: //マップID
0049: define('MAPID', 'map_id');
0050: //初期値
0051: define('DEF_LATITUDE',  35.0);           //緯度
0052: define('DEF_LONGITUDE', 137.0);          //経度
0053: define('DEF_TYPE',     'GSISTD');      //マップタイプ
0054: define('DEF_ZOOM',     4);              //ズーム
0055: 
0056: define('SEMICIRCLE',   30);         //半円を代替する多角形頂点数

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

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

0083: //キャッシュ保持時間(分) 0:キャッシュしない
0084: //気象庁へのアクセス負荷軽減のため,60分以上のキャッシュ保持をお勧めします.
0085: define('LIFE_CACHE', 120);
0086: 
0087: //キャッシュ・ディレクトリ
0088: //書き込み可能で,外部からアクセスされないディレクトリを指定してください.
0089: //最大150Mバイトを消費します.天気予報系プログラムは同じディレクトリで構わない.
0090: 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 座標列

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

0045: //マップの表示サイズ(単位:ピクセル)
0046: define('MAP_WIDTH',  600);
0047: define('MAP_HEIGHT', 480);
0048: //マップID
0049: define('MAPID', 'map_id');
0050: //初期値
0051: define('DEF_LATITUDE',  35.0);           //緯度
0052: define('DEF_LONGITUDE', 137.0);          //経度
0053: define('DEF_TYPE',     'GSISTD');      //マップタイプ
0054: define('DEF_ZOOM',     4);              //ズーム
0055: 
0056: define('SEMICIRCLE',   30);         //半円を代替する多角形頂点数

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

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

0306: /**
0307:  * 気象庁防災情報XMLから最新の地上実況図URLを取得
0308:  * @param   object $pcc pahooCacheオブジェクト
0309:  * @return  string 地上実況図URL/FALSE:取得失敗
0310: */
0311: function jmaGetWeatherMapURL($pcc) {
0312:     //URLパターン
0313:     $vzsa50 = '/http\:\/\/www\.data\.jma\.go\.jp\/developer\/xml\/data\/([0-9\_]+)VZSA50\_([0-9\_]+)\.xml/ui';
0314: 
0315:     $xml = $pcc->simplexml_load(FEED_REGULAR);
0316:     //レスポンス・チェック
0317:     if ($pcc->iserror() || !isset($xml->entry)) {
0318:         return FALSE;
0319:     }
0320: 
0321:     //フィード(XMLファイル)解析
0322:     $vzsa50_url = $vzsa50_dt = '';
0323:     $res = FALSE;
0324:     foreach ($xml->entry as $node) {
0325:         //日時がより新しいURLを採用
0326:         if (preg_match($vzsa50$node->id$arr) > 0) {
0327:             if ($arr[1] > $vzsa50_dt) {
0328:                 $vzsa50_url = $arr[0];
0329:                 $vzsa50_dt  = $arr[1];
0330:                 $res = TRUE;
0331:             }
0332:         }
0333:     }
0334: 
0335:     //エラー・チェック
0336:     if (! $res) {
0337:         return FALSE;
0338:     }
0339: 
0340:     return $vzsa50_url;
0341: }

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

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

0343: /**
0344:  * 気象庁防災情報XMLから地上実況図を取得
0345:  * @param   string $url   地上実況図URL
0346:  * @param   string $dt    報告日時格納用
0347:  * @param   array  $items 情報を格納する配列
0348:  * @param   object $pcc   pahooCacheオブジェクト
0349:  * @return  bool TRUE:取得成功/FALSE:失敗
0350: */
0351: function jmaGetIsobar($url, &$dt, &$items$pcc) {
0352:     //名前空間
0353:     define('JMX_EB', 'http://xml.kishou.go.jp/jmaxml1/elementBasis1/');
0354: 
0355:     $xml = $pcc->simplexml_load($url);
0356:     //レスポンス・チェック
0357:     if ($pcc->iserror() || !isset($xml->Body->MeteorologicalInfos)) {
0358:         return FALSE;
0359:     }
0360: 
0361:     //報告日時
0362:     $pat = '/([0-9]+)\-([0-9]+)\-([0-9]+)T([0-9]+)\:/ui';
0363:     $dt = (string)$xml->Body->MeteorologicalInfos->MeteorologicalInfo->DateTime;
0364:     if (preg_match($pat$dt$arr) > 0) {
0365:         $dt = sprintf('%d年%d月%d日 %d時', $arr[1]$arr[2]$arr[3]$arr[4]);
0366:     } else {
0367:         $dt = '';
0368:     }
0369: 
0370:     //等圧線情報
0371:     $res = FALSE;
0372:     $cnt = 0;
0373:     foreach ($xml->Body->MeteorologicalInfos->MeteorologicalInfo->Item as $item) {
0374:         $Property = $item->Kind->Property;
0375:         //等圧線
0376:         if ($Property->Type == '等圧線') {
0377:             $IsobarPart = $Property->IsobarPart->children(JMX_EB);
0378:             $items[$cnt]['type'] = (string)$Property->Type;
0379:             $items[$cnt]['pressure'] = (int)$IsobarPart->Pressure;
0380:             $bar = (string)$IsobarPart->Line;
0381:             $arr = preg_split('/\//ui', $bar);
0382:             //緯度・経度
0383:             $i = 0;
0384:             foreach ($arr as $ss) {
0385:                 if (preg_match('/([\+\-]+[0-9\.]+)([\+\-]+[0-9\.]+)/ui', $ss$arr2) > 0) {
0386:                     $items[$cnt]['point'][$i]['latitude']  = (float)$arr2[1];
0387:                     $items[$cnt]['point'][$i]['longitude'] = (float)$arr2[2];
0388:                     $i++;
0389:                 }
0390:             }
0391:             $res = TRUE;
0392:             $cnt++;
0393:         //前線
0394:         } else if (preg_match('/前線/ui', $Property->Type) > 0) {
0395:             $CoordinatePart = $Property->CoordinatePart->children(JMX_EB);
0396:             $items[$cnt]['type'] = (string)$Property->Type;
0397:             $bar = (string)$CoordinatePart->Line;
0398:             $arr = preg_split('/\//ui', $bar);
0399:             //緯度・経度
0400:             $i = 0;
0401:             foreach ($arr as $ss) {
0402:                 if (preg_match('/([\+\-]+[0-9\.]+)([\+\-]+[0-9\.]+)/ui', $ss$arr2) > 0) {
0403:                     $items[$cnt]['point'][$i]['latitude']  = (float)$arr2[1];
0404:                     $items[$cnt]['point'][$i]['longitude'] = (float)$arr2[2];
0405:                     $i++;
0406:                 }
0407:             }
0408:             $res = TRUE;
0409:             $cnt++;
0410:         //気圧
0411:         } else if (preg_match('/気圧|台風|熱帯/ui', $Property->Type) > 0) {
0412:             $items[$cnt]['type']  = (string)$Property->Type;
0413:             $CenterPart = $Property->CenterPart->children(JMX_EB);
0414:             if (preg_match('/([\+\-]+[0-9\.]+)([\+\-]+[0-9\.]+)/ui', (string)$CenterPart->Coordinate$arr2) > 0) {
0415:                 $items[$cnt]['latitude']  = (float)$arr2[1];
0416:                 $items[$cnt]['longitude'] = (float)$arr2[2];
0417:             }
0418:             $items[$cnt]['pressure'] = (int)$CenterPart->Pressure;
0419:             $res = TRUE;
0420:             $cnt++;
0421:         }
0422:     }
0423: 
0424:     return TRUE;
0425: }

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

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

解説:前線記号

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

0449: /**
0450:  * 二等辺三角形座標(寒冷前線用)
0451:  * @param   float $lat0, $lng0 底辺の中心座標
0452:  * @param   float $lat1, $lng2 底辺の一方の座標
0453:  * @param   int   $way         0:寒冷前線・停滞前線,1:閉塞前線
0454:  * @return  array($lat, $lng)  頂点の座標
0455: */
0456: function isoTriangle($lat0$lng0$lat1$lng1$way=0) {
0457:     $angle = ($way == 0) ? 270 : 90;
0458: 
0459:     if ($lng1 - $lng0 == 0) {
0460:         $t = 0.0;
0461:     } else {
0462:         $t = rad2deg(atan(($lat1 - $lat0) / ($lng1 - $lng0)));
0463:     }
0464:     $r = sqrt(pow(($lng1 - $lng0), 2) + pow(($lat1 - $lat0), 2));
0465:     $lat = $r * sin(deg2rad($t + $angle)) + $lat0;
0466:     $lng = $r * cos(deg2rad($t + $angle)) + $lng0;
0467: 
0468:     return array($lat$lng);
0469: }

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

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

0471: /**
0472:  * 半円弧座標(温暖前線記号):多角形で近似する
0473:  * @param   float $lat0, $lng0 中心座標
0474:  * @param   float $lat1, $lng2 円弧の始点座標
0475:  * @param   int   $n           多角形の頂点数
0476:  * @param   array points 円弧の座標を格納する
0477:  * @return  なし
0478: */
0479: function semiCircle($lat0$lng0$lat1$lng1$n, &$points) {
0480:     if ($lng1 - $lng0 == 0) {
0481:         $t = 0.0;
0482:     } else {
0483:         $t = rad2deg(atan(($lat1 - $lat0) / ($lng1 - $lng0)));
0484:     }
0485:     $r = sqrt(pow(($lng1 - $lng0), 2) + pow(($lat1 - $lat0), 2));
0486:     for ($i = 0; $i < $n$i++) {
0487:         $points[$i]['latitude']  = $r * sin(deg2rad($t + 180 / $n * $i)) + $lat0;
0488:         $points[$i]['longitude'] = $r * cos(deg2rad($t + 180 / $n * $i)) + $lng0;
0489:     }
0490: }

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

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

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

0548: /**
0549:  * 前線描画用スクリプト作成:Googleマップ
0550:  * @param   array $item 前線の座標
0551:  * @param   int   $kind 前線の種類(0:温暖前線,1:寒冷前線,2:停滞前線,
0552:  *                                  3:閉塞前線)
0553:  * @return  string 描画用スクリプト
0554: */
0555: function jsStationaryFront_gmap($item$kind) {
0556:     static $table_color1 = array('red', 'blue', 'red', 'purple');
0557:     static $table_color2 = array('red', 'blue', 'blue', 'purple');
0558:     static $table_angle  = array(0, 0, 0, 1);
0559: 
0560:     $i = 0;
0561:     $flag = TRUE;
0562:     $js = '';
0563:     while ($flag) {
0564:         //前線(温暖)
0565:         $ss = '';
0566:         $cnt = 0;
0567:         for ($j = $i$j <= $i + 10; $j++) {
0568:             if (! isset($item['point'][$j])) {
0569:                 $flag = FALSE;
0570:                 break;
0571:             }
0572:             if ($cnt > 0)   $ss .= ",\n";
0573:             $ss .= "\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
0574:             $cnt++;
0575:         }
0576: $js .=<<< EOT
0577: new google.maps.Polyline({
0578:     map: map,
0579:     path: [
0580: {$ss}
0581:     ],
0582:     strokeColor: '{$table_color1[$kind]}',
0583:     strokeOpacity: 1.0,
0584:     strokeWeight: 2,
0585: });
0586: 
0587: EOT;
0588:         $i += 10;
0589: 
0590:         if (isset($item['point'][$i + 20])) {
0591:             //温暖部
0592:             $ss = '';
0593:             if ($kind != 1) {
0594:                 $points = array();
0595:                 semiCircle($item['point'][$i + 5]['latitude'], $item['point'][$i + 5]['longitude'], $item['point'][$i + 10]['latitude'], $item['point'][$i + 10]['longitude'], SEMICIRCLE$points);
0596:                 $cnt = 0;
0597:                 foreach ($points as $point) {
0598:                     if ($cnt > 0)   $ss .= ",\n";
0599:                     $ss .= "\t\t{ lat: {$point['latitude']}, lng: {$point['longitude']} }";
0600:                     $cnt++;
0601:                 }
0602:                 for ($j = $i$j <= $i + 10; $j++) {
0603:                     if ($cnt > 0)   $ss .= ",\n";
0604:                     $ss .= "\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
0605:                     $cnt++;
0606:                 }
0607: $js .=<<< EOT
0608: new google.maps.Polygon({
0609:     map: map,
0610:     paths: [
0611:     {$ss}
0612:     ],
0613:     strokeColor: '{$table_color1[$kind]}',
0614:     strokeOpacity: 1.0,
0615:     strokeWeight: 2,
0616:     fillColor: '{$table_color1[$kind]}',
0617:     fillOpacity: 1.0,
0618: });
0619: 
0620: EOT;
0621:             } else {
0622:                 $ss = '';
0623:                 $cnt = 0;
0624:                 for ($j = $i$j <= $i + 10; $j++) {
0625:                     if ($cnt > 0)   $ss .= ",\n";
0626:                     $ss .= "\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
0627:                     $cnt++;
0628:                 }
0629: $js .=<<< EOT
0630: new google.maps.Polyline({
0631:     map: map,
0632:     path: [
0633: {$ss}
0634:     ],
0635:     strokeColor: '{$table_color1[$kind]}',
0636:     strokeOpacity: 1.0,
0637:     strokeWeight: 2,
0638: });
0639: 
0640: EOT;
0641:             }
0642: 
0643:             //寒冷部
0644:             if ($kind != 0) {
0645:                 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]);
0646:                 $ss = "\t\t{ lat: {$lat}, lng: {$lng} }";
0647:                 $cnt = 1;
0648:                 for ($j = $i + 10; $j <= $i + 20; $j++) {
0649:                     if ($cnt > 0)   $ss .= ",\n";
0650:                     $ss .= "\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
0651:                     $cnt++;
0652:                 }
0653: $js .=<<< EOT
0654: new google.maps.Polygon({
0655:     map: map,
0656:     paths: [
0657:     {$ss}
0658:     ],
0659:     strokeColor: '{$table_color2[$kind]}',
0660:     strokeOpacity: 1.0,
0661:     strokeWeight: 1,
0662:     fillColor: '{$table_color2[$kind]}',
0663:     fillOpacity: 1.0
0664: });
0665: 
0666: EOT;
0667:             } else {
0668:                 $ss = '';
0669:                 $cnt = 0;
0670:                 for ($j = $i + 10; $j <= $i + 20; $j++) {
0671:                     if ($cnt > 0)   $ss .= ",\n";
0672:                     $ss .= "\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
0673:                     $cnt++;
0674:                 }
0675: $js .=<<< EOT
0676: new google.maps.Polyline({
0677:     map: map,
0678:     path: [
0679: {$ss}
0680:     ],
0681:     strokeColor: '{$table_color1[$kind]}',
0682:     strokeOpacity: 1.0,
0683:     strokeWeight: 2,
0684: });
0685: 
0686: EOT;
0687: 
0688:             }
0689:             $i += 20;
0690:         }
0691: 
0692:         //前線(寒冷)
0693:         $cnt = 0;
0694:         $ss = '';
0695:         for ($j = $i$j <= $i + 10; $j++) {
0696:             if (! isset($item['point'][$j])) {
0697:                 $flag = FALSE;
0698:                 break;
0699:             }
0700:             if ($cnt > 0)   $ss .= ",\n";
0701:             $ss .= "\t\t{ lat: {$item['point'][$j]['latitude']}, lng: {$item['point'][$j]['longitude']} }";
0702:             $cnt++;
0703:         }
0704: $js .=<<< EOT
0705: new google.maps.Polyline({
0706:     map: map,
0707:     path: [
0708: {$ss}
0709:     ],
0710:     strokeColor: '{$table_color2[$kind]}',
0711:     strokeOpacity: 1.0,
0712:     strokeWeight: 2,
0713: });
0714: 
0715: EOT;
0716:         $i += 10;
0717:     }
0718:     return $js;
0719: }

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

解説:前線を描く(Leaflet)

0722: /**
0723:  * 前線描画用スクリプト作成:Leaflet
0724:  * @param   array $item 前線の座標
0725:  * @param   int   $kind 前線の種類(0:温暖前線,1:寒冷前線,2:停滞前線,
0726:  *                                  3:閉塞前線)
0727:  * @return  string 描画用スクリプト
0728: */
0729: function jsStationaryFront_leaflet($item$kind) {
0730:     static $table_color1 = array('red', 'blue', 'red', 'purple');
0731:     static $table_color2 = array('red', 'blue', 'blue', 'purple');
0732:     static $table_angle  = array(0, 0, 0, 1);
0733: 
0734:     $i = 0;
0735:     $flag = TRUE;
0736:     $js = '';
0737:     while ($flag) {
0738:         //前線(温暖)
0739:         $ss = '';
0740:         for ($j = $i$j <= $i + 10; $j++) {
0741:             if (! isset($item['point'][$j])) {
0742:                 $flag = FALSE;
0743:                 break;
0744:             }
0745: $ss .=<<< EOT
0746:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
0747: 
0748: EOT;
0749:         }
0750: $js .=<<< EOT
0751: L.polyline([
0752: {$ss}
0753:         ], {
0754:             color: '{$table_color1[$kind]}',
0755:             opacity: 1.0,
0756:             weight: 2
0757:         }
0758:     ).addTo(map);
0759: 
0760: EOT;
0761:         $i += 10;
0762: 
0763:         if (isset($item['point'][$i + 20])) {
0764:             //温暖部
0765:             $ss = '';
0766:             if ($kind != 1) {
0767:                 $points = array();
0768:                 semiCircle($item['point'][$i + 5]['latitude'], $item['point'][$i + 5]['longitude'], $item['point'][$i + 10]['latitude'], $item['point'][$i + 10]['longitude'], SEMICIRCLE$points);
0769:                 foreach ($points as $point) {
0770: $ss .=<<< EOT
0771:             [{$point['latitude']}, {$point['longitude']}],
0772: 
0773: EOT;
0774:                 }
0775:                 for ($j = $i + 0; $j <= $i + 10; $j++) {
0776: $ss .=<<< EOT
0777:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
0778: 
0779: EOT;
0780:                 }
0781: $js .=<<< EOT
0782:     L.polygon([
0783: {$ss}
0784:         ], {
0785:             color: '{$table_color1[$kind]}',
0786:             fillColor: '{$table_color1[$kind]}',
0787:             fillOpacity: 1.0,
0788:         }
0789:     ).addTo(map);
0790: 
0791: EOT;
0792:             } else {
0793:                 for ($j = $i + 0; $j <= $i + 10; $j++) {
0794: $ss .=<<< EOT
0795:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
0796: 
0797: EOT;
0798:                 }
0799: $js .=<<< EOT
0800:     L.polyline([
0801: {$ss}
0802:         ], {
0803:             color: '{$table_color1[$kind]}',
0804:             opacity: 1.0,
0805:             weight: 2
0806:         }
0807:     ).addTo(map);
0808: 
0809: EOT;
0810:             }
0811: 
0812:             //寒冷部
0813:             if ($kind != 0) {
0814:                 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]);
0815: $ss =<<< EOT
0816:             [{$lat}, {$lng}],
0817: 
0818: EOT;
0819:                 for ($j = $i + 10; $j <= $i + 20; $j++) {
0820: $ss .=<<< EOT
0821:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
0822: 
0823: EOT;
0824:                 }
0825: $js .=<<< EOT
0826: L.polygon([
0827: {$ss}
0828:         ], {
0829:             color: '{$table_color2[$kind]}',
0830:             fillColor: '{$table_color2[$kind]}',
0831:             fillOpacity: 1.0,
0832:         }
0833:     ).addTo(map);
0834: 
0835: EOT;
0836:             } else {
0837:                 $ss = '';
0838:                 for ($j = $i + 10; $j <= $i + 20; $j++) {
0839: $ss .=<<< EOT
0840:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
0841: 
0842: EOT;
0843:                 }
0844: $js .=<<< EOT
0845:     L.polyline([
0846: {$ss}
0847:         ], {
0848:             color: '{$table_color1[$kind]}',
0849:             opacity: 1.0,
0850:             weight: 2
0851:         }
0852:     ).addTo(map);
0853: 
0854: EOT;
0855:             }
0856:             $i += 20;
0857:         }
0858:         //前線(寒冷)
0859:         $ss = '';
0860:         for ($j = $i$j <= $i + 10; $j++) {
0861:             if (! isset($item['point'][$j])) {
0862:                 $flag = FALSE;
0863:                 break;
0864:             }
0865: $ss .=<<< EOT
0866:             [{$item['point'][$j]['latitude']}, {$item['point'][$j]['longitude']}],
0867: 
0868: EOT;
0869:         }
0870: $js .=<<< EOT
0871: L.polyline([
0872: {$ss}
0873:         ], {
0874:             color: '{$table_color2[$kind]}',
0875:             opacity: 1.0,
0876:             weight: 2
0877:         }
0878:     ).addTo(map);
0879: 
0880: EOT;
0881:         $i += 10;
0882:     }
0883: 
0884:     return $js;
0885: }

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

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

0492: /**
0493:  * ラベル表示用スクリプト作成:Googleマップ
0494:  * @param   float  $latitude, $longitude ラベル表示座標
0495:  * @param   string $label ラベル
0496:  * @param   int    $size  フォントサイズ(pt)
0497:  * @param   string $color  フォントカラー
0498:  * @param   string $weight 太さ
0499:  * @return  string 描画用スクリプト
0500: */
0501: function jsLabel_gmap($latitude$longitude$label$size=14, $color='#FF0000', $weight="normal") {
0502: $js =<<< EOT
0503: new google.maps.Marker({
0504:     map: map,
0505:     position: new google.maps.LatLng({$latitude}, {$longitude}),
0506:     icon: {
0507:         url: 'https://www.pahoo.org/common/space.gif'
0508:     },
0509:     label: {
0510:         text: '{$label}',
0511:         color: '{$color}',
0512:         fontSize: '{$size}px',
0513:         fontWeight: '{$weight}'
0514:     }
0515: });
0516: 
0517: EOT;
0518:     return $js;
0519: }

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

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

0521: /**
0522:  * ラベル表示用スクリプト作成:Leaflet
0523:  * @param   float  $latitude, $longitude ラベル表示座標
0524:  * @param   string $label  ラベル
0525:  * @param   int    $size   フォントサイズ(pt)
0526:  * @param   string $color  フォントカラー
0527:  * @param   string $weight 太さ
0528:  * @return  string 描画用スクリプト
0529: */
0530: function jsLabel_leaflet($latitude$longitude$label$size=14, $color='#FF0000', $weight="normal") {
0531: $js =<<< EOT
0532: new L.marker(
0533:     [{$latitude}, {$longitude}],
0534:     {
0535:     icon:
0536:         new L.divIcon({
0537:             html: '<span style="color:{$color}; font-size:{$size}px; font-weight:{$weight};">{$label}</span>',
0538:             iconSize: [0, 0],
0539:             iconAnchor: [{$size}, {$size}],
0540:         })
0541:     }
0542: ).addTo(map);
0543: 
0544: EOT;
0545:     return $js;
0546: }

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

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

0887: /**
0888:  * 天気図描画スクリプトを生成する
0889:  * @param   string $url    地上実況図URL
0890:  * @param   object $pgc   pahooGeoCodeオブジェクト
0891:  * @param   object $pcc    pahooCacheオブジェクト
0892:  * @param   string $dt     報告日時格納用
0893:  * @param   string $errmsg エラーメッセージ格納用
0894:  * @return  string スクリプト/FALSE:生成失敗
0895: */
0896: function jsWeatherMap($url$pgc$pcc, &$dt, &$errmsg) {
0897:     static $table = array('温暖前線', '寒冷前線', '停滞前線', '閉塞前線');
0898:     $errmsg = '';
0899:     $js = '';
0900:     $items = array();
0901:     $res = jmaGetIsobar($url$dt$items$pcc);
0902:     if ($res == FALSE) {
0903:         $errmsg = '気象庁防災情報XMLから地上実況図を取得できません';
0904:     }
0905: 
0906:     //スクリプト生成
0907:     foreach ($items as $key=>$item) {
0908:         if ($item['type'] == '等圧線') {
0909:             $js .= $pgc->jsLine($item['point'], 'black', 0.5, 0.5, MAPSERVICE);
0910:         } else if (preg_match('/前線/ui', $item['type']) > 0) {
0911:             $kind = array_search($item['type'], $table);
0912:             if (MAPSERVICE == 0) {
0913:                 $js .= jsStationaryFront_gmap($item$kind);
0914:             } else {
0915:                 $js .= jsStationaryFront_leaflet($item$kind);
0916:             }
0917:         } else if (preg_match('/気圧|台風|熱帯/ui', $item['type']) > 0) {
0918:             switch ($item['type']) {
0919:                 case '高気圧':
0920:                     $label = '';
0921:                     $color = 'blue';
0922:                     break;
0923:                 case '低気圧':
0924:                     $label = '';
0925:                     $color = 'red';
0926:                     break;
0927:                 case '台風':
0928:                     $label = '';
0929:                     $color = 'magenta';
0930:                     break;
0931:                 case '熱帯低気圧':
0932:                     $label = '';
0933:                     $color = 'magenta';
0934:                     break;
0935:             }
0936:             if (MAPSERVICE == 0) {
0937:                 $js .= jsLabel_gmap($item['latitude'], $item['longitude'], $label, 18, $color, 'bold');
0938:                 $js .= jsLabel_gmap($item['latitude'] - 1.3, $item['longitude'], $item['pressure'], 16, 'black', 'bold%');
0939:             } else {
0940:                 $js .= jsLabel_leaflet($item['latitude'], $item['longitude'], $label, 18, $color, 'bold');
0941:                 $js .= jsLabel_leaflet($item['latitude'] - 1.3, $item['longitude'], $item['pressure'], 16, 'black', 'bold');
0942:             }
0943:         }
0944:     }
0945: 
0946:     //HTMLの画像化
0947:     $js .= js_html2image();
0948: 
0949:     return $js;
0950: }

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

解説:ツイート機能

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

0034: //ツイート・ボタン  TRUE:有効,FALSE:無効
0035: define('TWITTER', FALSE);
0036: 
0037: //画像化したいオブジェクト
0038: define('TARGET', 'target');

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

解説:html2canvasライブラリ

0103: <script src="https://html2canvas.hertzen.com/dist/html2canvas.js"></script>
0104: 

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

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

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

0244: /**
0245:  * HTMLオブジェクトの画像化
0246:  * @param   なし
0247:  * @return  string JavaScriptコード
0248: */
0249: function js_html2image() {
0250:     $target = TARGET;
0251:     $js = '';
0252: 
0253:     //Googleマップの場合
0254:     if (MAPSERVICE == 0) {
0255: $js .=<<< EOT
0256: google.maps.event.addListener(map, 'tilesloaded', function() {
0257:     var capture = document.querySelector('#{$target}');
0258:     html2canvas(capture, {useCORS: true, allowTaint:true}).then(canvas => {
0259:         var base64 = canvas.toDataURL('image/png');       //画像化
0260:         $('#base64').val(base64);
0261:     });
0262: });
0263: 
0264: EOT;
0265: 
0266:     //Leafletの場合(ブラウザによってはうまく動作しない)
0267:     } else {
0268: $js .=<<< EOT
0269: HTMLCanvasElement.prototype.getContext = function(origFn) {
0270:     return function(type, attribs) {
0271:         attribs = attribs || {};
0272:         attribs.preserveDrawingBuffer = true;
0273:         return origFn.call(this, type, attribs);
0274:     };
0275: } (HTMLCanvasElement.prototype.getContext);
0276: 
0277: //HTML画像化イベント登録
0278: function html2image() {
0279:     var capture = document.querySelector('#{$target}');
0280:     html2canvas(capture, {useCORS: true, allowTaint:true}).then(canvas => {
0281:         var base64 = canvas.toDataURL('image/png');       //画像化
0282:         $('#base64').val(base64);
0283:     });
0284: };
0285: 
0286: //ズーム変更イベント
0287: map.on('zoomend', function() {
0288:     html2image();
0289: });
0290: 
0291: //マップ移動イベント
0292: map.on('moveend', function() {
0293:     html2image();
0294: });
0295: 
0296: //ズーム変更イベント発生
0297: var zoom = map.getZoom();
0298: map.setZoom(zoom);
0299: 
0300: EOT;
0301:     }
0302: 
0303:     return $js;
0304: }

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

準備:pahooTwitterAPI クラス

0015: class pahooTwitterAPI {
0016:     var $webapi;     //直前に呼び出したWebAPI URL
0017:     var $error;      //エラーフラグ
0018:     var $errmsg;     //エラーメッセージ
0019:     var $errcode;        //エラーコード
0020:     var $responses;  //直前の結果(配列)
0021: 
0022:     //OAuth用パラメータ
0023:     // https://apps.twitter.com/
0024:     var $TWTR_CONSUMER_KEY    = '***************';  //Cunsumer key
0025:     var $TWTR_CONSUMER_SECRET = '***************';  //Consumer secret
0026:     var $TWTR_ACCESS_KEY      = '***************';  //Access Token (oauth_token)
0027:     var $TWTR_ACCESS_SECRET   = '***************';  //Access Token Secret (oauth_token_secret)

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

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

0385: /**
0386:  * メディア付き投稿(RAWデータ)
0387:  * @param   string $message 投稿メッセージ(UTF-8限定)
0388:  * @param   array  $raws    メディアデータ(RAWデータ配列)
0389:  * @return  bool TRUE:リクエスト成功/FALSE:失敗
0390: */
0391: function tweet_media_raw($message$raws) {
0392:     static $url_upload    = 'https://upload.twitter.com/1.1/media/upload.json';
0393:     static $url_tweet     = 'https://api.twitter.com/1.1/statuses/update.json';
0394:     static $method        = 'POST' ;
0395: 
0396:     //メディアのアップロード
0397:     $media_ids = '';
0398:     $cnt = 0;
0399:     foreach ($raws as $raw) {
0400:         $media_id = $this->upload($url_upload, 'POST', 'media', $raw);
0401:         if ($media_id == NULL)      break;
0402:         if ($cnt > 0)   $media_ids .= ',';
0403:         $media_ids .= $media_id;
0404:         $cnt++;
0405:         if ($cnt > 3)   break;       //最大4つまで
0406:     }
0407: 
0408:     //ツイート
0409:     if (! $this->error) {
0410:         $option = array('status' => $message, 'media_ids' => $media_ids);
0411:         $res = $this->request_user($url_tweet$method$option);
0412:     } else {
0413:         $res = FALSE;
0414:     }
0415: 
0416:     return $res;
0417: }

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

解説:ツイート処理

0219: /**
0220:  * ツイート処理
0221:  * @param   string $message 投稿文
0222:  * @param   string $res     応答メッセージ格納用
0223:  * @return  bool TRUE:成功/FALSE:失敗または未処理
0224: */
0225: function mediaTweet($message, &$res) {
0226:     if (! TWITTER)   return FALSE;
0227: 
0228:     $ret = TRUE;
0229:     if (isset($_POST['base64']) && ($_POST['base64'] != '')) {
0230:         $base64 = preg_replace('/data\:image\/png\;base64\,/ui', '', $_POST['base64']);
0231:         $raws = array(base64_decode($base64));
0232:         $ptw = new pahooTwitterAPI();
0233:         $ptw->tweet_media_raw($message$raws);
0234:         $errmsg = $ptw->errmsg;
0235:         $ret = ! $ptw->error;
0236:         $ptw = NULL;
0237:         if ($ret) {
0238:             $res = 'ツイートしました';
0239:         }
0240:     }
0241:     return $ret;
0242: }

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

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

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

活用例

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

参考サイト

(この項おわり)
header