PHPで台風情報を取得する

(1/1)
今年(2013年)は台風の被害が多い年となった。
そこで、気象庁の台風情報のサイトから、現在日本に接近している台風の情報を取得するPHPスクリプトを作成してみることにする。

2021年(令和3年)2月24日の気象庁サイト・リニューアルにより、スクレイピングによる取り出しが難しくなったため、気象庁防災情報XMLからの情報取得に変更した。あわせてキャッシュ・システムを導入した。

(2024年11月1日)Bluesky投稿機能を追加
(2024年6月21日)TwitterOAuth 7.0.0 対応,Twitter(現・X)ボタンを "X" に変更

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

PHPで台風情報を取得する

目次

サンプル・プログラム

圧縮ファイルの内容
typhoon.phpサンプル・プログラム本体
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 が通ったディレクトリに配置すること。
typhoon.php 更新履歴
バージョン 更新日 内容
2.6.0 2024/11/01 Bluesky投稿機能を追加, isButton()修正
2.5.0 2024/06/21 Twitter(現・X)ボタンを "X" に変更
2.4.1 2023/09/20 js_html2image()--Leaflet用html2image()発火プロセス見直し
2.4.0 2023/08/26 台風中心と予報円が入るようにズーム値を計算
2.3.0 2023/08/26 基準座標から近い方の台風を地図中心にくるように
pahooGeoCode.php 更新履歴
バージョン 更新日 内容
6.3.3 2024/09/14 $this->NOMINATIM_EMAIL 追加
6.3.2 2024/02/14 getStaticMap() -- bug-fix
6.3.1 2023/07/09 bug-fix
6.3.0 2023/07/02 getPointsGSI()追加
6.2.0 2023/07/02 ip2address()追加
pahooCache.php 更新履歴
バージョン 更新日 内容
1.1.1 2023/02/11 コメント追記
1.1 2021/04/08 simplexml_load()メソッド追加
1.0 2021/04/02 初版
pahooTwitterAPI.php 更新履歴
バージョン 更新日 内容
5.5.0 2024/06/21 TwitterOAuth 7.0.0 対応
5.4.0 2024/05/18 twitter.com → x.com 変更対応
5.3.0 2023/08/15 tweet3() -- メディアのシャフル機能
5.2.1 2023/07/22 bug-fix
5.2.0 2023/07/17 oembed() v2対応
pahooBlueskyAPI.php 更新履歴
バージョン 更新日 内容
1.3.4 2024/10/31 getOGPInformation() -- 文字化け対策
1.3.3 2024/10/22 post() -- 画像投稿をカード情報投稿より優先
1.3.2 2024/10/22 extractMediaURL() -- ローカル画像bugfix
1.3.1 2024/10/20 post() -- 返信,引用の引数仕様変更
1.3.0 2024/10/20 getProfile, getDID, getRootParentID追加

準備:pahooGeoCode クラス

pahooGeoCode.php

  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の登録方法」を参照されたい。

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

typhoon.php

  68: //地図描画サービスの選択
  69: //    0:Google
  70: //    2:地理院地図・OSM
  71: define('MAPSERVICE', 2);

表示する地図は、Googleマップ地理院地図・オープンストリートマップ(OSM)から選べる。あらかじめ、定数 MAPSERVICE に値を設定すること。
PHPで台風情報を取得する
地理院地図表示
PHPで台風情報を取得する
オープンストリートマップ表示

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

typhoon.php

 101: //キャッシュ保持時間(分) 0:キャッシュしない
 102: //気象庁へのアクセス負荷軽減+台風の進路プロット
 103: define('LIFE_CACHE_FEED',   15);        //高頻度 - 随時フィードに対して
 104: define('LIFE_CACHE_FEED_L', 120);       //長期   - 随時フィードに対して
 105: define('LIFE_CACHE_DATA', (60 * 24 * 14));  //台風情報の保持時間(進路プロット)
 106: 
 107: //キャッシュ・ディレクトリ
 108: //書き込み可能で,外部からアクセスされないディレクトリを指定してください.
 109: define('DIR_CACHE_FEED',   './pcache_typhoon1/');
 110: define('DIR_CACHE_FEED_L', './pcache_typhoon2/');
 111: define('DIR_CACHE_DATA',   './pcache_typhoon3/');

気象庁サイトへ負荷を掛けないこと、また、過去の台風の進路をプロットすることを目的として、キャッシュ・クラス pahooCache を導入した。使用方法については、「PHPで天気予報を求める - キャッシュ・システム」を参照いただきたい。
キャッシュ保持時間、キャッシュ・ディレクトリともに、自サイトの環境に応じて変更してほしい。
LIFE_CACHE_DATA は、過去の台風の進路をプロットできるように、初期値では14日間を指定してある。

準備:各種定数など

typhoon.php

  42: // ツイート・ボタン  TRUE:有効,FALSE:無効
  43: define('TWITTER', FALSE);
  44: 
  45: // Bluesky投稿ボタン  TRUE:有効,FALSE:無効
  46: define('BLUESKY', FALSE);
  47: 
  48: // 住所・緯度・経度に関わるクラス:include_pathが通ったディレクトリに配置
  49: require_once('pahooGeoCode.php');
  50: 
  51: // キャッシュ処理に関わるクラス:include_pathが通ったディレクトリに配置
  52: require_once('pahooCache.php');
  53: 
  54: // Twitterクラス:include_pathが通ったディレクトリに配置
  55: if (TWITTER) {
  56:     require_once('pahooTwitterAPI.php');
  57: }
  58: 
  59: // BlueskyAPIクラス:include_pathが通ったディレクトリに配置
  60: if (BLUESKY) {
  61:     require_once('pahooBlueskyAPI.php');
  62:     define('BLUESKY_DOMAIN', 'bsky.social');    //あなたのドメインを記入
  63: }
  64: 
  65: //画像化したいオブジェクト
  66: define('TARGET', 'target');
  67: 
  68: //地図描画サービスの選択
  69: //    0:Google
  70: //    2:地理院地図・OSM
  71: define('MAPSERVICE', 2);
  72: 
  73: //古い台風情報を捨てる条件:現在日時との差(秒数)
  74: define('SCRAP_TIME',  (60 * 60 * 12));
  75: 
  76: //予報円を間引く条件(km):次の予報円がこれより近ければ描画しない
  77: define('THIN_OUT', 50);
  78: 
  79: //マップの表示サイズ(単位:ピクセル)
  80: define('MAP_WIDTH',  600);
  81: define('MAP_HEIGHT', 480);
  82: //マップID
  83: define('MAPID', 'map_id');
  84: //マップ座標
  85: define('DEF_LATITUDE',  35.0);          //緯度
  86: define('DEF_LONGITUDE', 137.0);         //経度
  87: define('DEF_TYPE',      'GSISTD');      //マップタイプ
  88: define('DEF_ZOOM',      6);             //ズーム
  89: //マップ描画色
  90: define('COLOR_NAME1',    '#FF8800');    //台風名称の描画色
  91: define('COLOR_NAME2',    '#0000FF');    //熱低名称の描画色
  92: define('COLOR_LINE',     '#0000FF');    //過去経路の描画色
  93: define('COLOR_WIND1',    '#FFFF00');    //強風域の描画色
  94: define('COLOR_WIND2',    '#FF0000');    //暴風域の描画色
  95: define('COLOR_FORECAST', '#FFFFFF');    //予報円の描画色
  96: 
  97: //基準座標;台風との距離計算に使う(これは東京駅の値;変更可能)
  98: define('LATITUDE00',  35.681111,);      //緯度
  99: define('LONGITUDE00', 139.766667);      //経度
 100: 
 101: //キャッシュ保持時間(分) 0:キャッシュしない
 102: //気象庁へのアクセス負荷軽減+台風の進路プロット
 103: define('LIFE_CACHE_FEED',   15);        //高頻度 - 随時フィードに対して
 104: define('LIFE_CACHE_FEED_L', 120);       //長期   - 随時フィードに対して
 105: define('LIFE_CACHE_DATA', (60 * 24 * 14));  //台風情報の保持時間(進路プロット)
 106: 
 107: //キャッシュ・ディレクトリ
 108: //書き込み可能で,外部からアクセスされないディレクトリを指定してください.
 109: define('DIR_CACHE_FEED',   './pcache_typhoon1/');
 110: define('DIR_CACHE_FEED_L', './pcache_typhoon2/');
 111: define('DIR_CACHE_DATA',   './pcache_typhoon3/');
 112: 
 113: //気象庁防災情報XML:高頻度フィード - 随時【変更不可】
 114: define('FEED', 'https://www.data.jma.go.jp/developer/xml/feed/extra.xml');
 115: 
 116: //気象庁防災情報XML:長期フィード - 随時【変更不可】
 117: define('FEED_L', 'https://www.data.jma.go.jp/developer/xml/feed/extra_l.xml');

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

予報円を間引く条件 THIN_OUT は、台風が停滞していたり、移動速度が極めてゆっくりの場合、予報円の密度が高すぎて地図が見にくくなる。そこで、前回予報円がこの距離以下であれば次の予報円を描かない(間引く)ことができる。0を代入すれば、すべての予報円を描くようになる。

出力結果を 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フォーマット」をご覧いただきたい。
今回は、高頻度 - 随時フィード および 長期 - 随時フィード にアクセスし、電文コード VPTW60 の台風解析・予報情報(5日予報)を取得する。

VPTW60の構造

台風解析・予報情報(5日予報)XML VPTW60 には、その時点の台風情報と、5日先の予報円(予報進路)が収められており、構造は下記の通りである。ここから必要な情報を取り出す。
注意すべきは、1つのXMLファイルの中に1つまたは複数の台風情報が含まれていること。
また、過去の中心位置は分からないので、キャッシュ・ファイルとして保存した VZSA50 から中心位置を拾い出し、それを台風の過去進路にする方針である。
また、VPTW60 の発表時刻によっては予報円の情報が無い。その場合、予報円情報が存在する最も新しい VPTW60 から予報円情報を取り出すことにする。
VPTW60の構造(xml) Report Control Title 台風解析・予報情報(5日予報)(H30) DateTime 配信日時 Status ステータス EditorialOffice 編集者 PublishingOffice 配信者 Head Title タイトル ReportDateTime 配信日時 TargetDateTime 予報対象日時 TargetDuration 予報間隔 InfoType 発表 InfoKind 情報の種類 Serial 通し番号 InfoKindVersion 情報バージョン Body MeteorologicalInfos MeteorologicalInfo DateTime 予報日時 Item Kind Property Type 呼称 TyphoonNamePart Name 名称(英語) NameKana 名称(日本語) Number 番号(西暦下2桁+連番2桁) Remark Kind Property Type 階級 ClassPart Kind Property Type 中心 CenterPart Location 位置(テキスト) Kind Property Type WindPart WarningAreaPart WarningAreaPart MeteorologicalInfo DateTime 24時間後予報日時 Item Kind Property Type 階級 ClassPart Kind Property Type 中心 CenterPart ProbabilityCircle Location 位置(テキスト) Kind Property Type WindPart WarningAreaPart WarningAreaPart MeteorologicalInfo DateTime 48時間後予報日時 MeteorologicalInfo DateTime 72時間後予報日時 MeteorologicalInfo DateTime 96時間後予報日時 MeteorologicalInfo DateTime 120時間後予報日時

解説:台風に関する情報URLを取得

typhoon.php

 393: /**
 394:  * 気象庁防災情報XMLから台風に関する情報URLを取得
 395:  * @param   array  $urls    URL格納配列
 396:  * @param   string $errmsg  エラーメッセージ格納用
 397:  * @return  bool TRUE:取得成功/FALSE:取得失敗
 398: */
 399: function jmaGetTyphoonURLs(&$urls, &$errmsg) {
 400:     //URLパターン
 401:     $vptw = '/https?\:\/\/www\.data\.jma\.go\.jp\/developer\/xml\/data\/([0-9\_]+)VPTW6[0-9]+\_[0-9]+\.xml/ui';
 402: 
 403:     //随時フィードの解析
 404:     $cnt = 0;
 405:     $pcc = new pahooCache(LIFE_CACHE_FEED, DIR_CACHE_FEED);
 406:     $xml = $pcc->simplexml_load(FEED);
 407:     //レスポンス・チェック
 408:     if ($pcc->iserror() || !isset($xml->entry)) {
 409:         $errmsg = '気象庁防災情報XMLにアクセスできません';
 410:         return FALSE;
 411:     }
 412:     foreach ($xml->entry as $node) {
 413:         //URLを取得
 414:         if (preg_match($vptw, $node->id, $arr> 0) {
 415:             $urls[$cnt] = $arr[0];
 416:             $cnt++;
 417:         }
 418:     }
 419:     $pcc = NULL;
 420: 
 421:     //長期フィードの解析
 422:     $pcc = new pahooCache(LIFE_CACHE_FEED_L, DIR_CACHE_FEED_L);
 423:     $xml = $pcc->simplexml_load(FEED_L);
 424:     //レスポンス・チェック
 425:     if ($pcc->iserror() || !isset($xml->entry)) {
 426:         $errmsg = '気象庁防災情報XMLにアクセスできません';
 427:         return FALSE;
 428:     }
 429:     foreach ($xml->entry as $node) {
 430:         //URLを取得
 431:         if (preg_match($vptw, $node->id, $arr> 0) {
 432:             if (array_search($arr[0], $urls) === FALSE) {
 433:                 $urls[$cnt] = $arr[0];
 434:                 $cnt++;
 435:             }
 436:         }
 437:     }
 438:     $pcc = NULL;
 439: 
 440:     //エラー・チェック
 441:     if ($cnt == 0) {
 442:         $errmsg = '直近の台風情報はありません';
 443:         return FALSE;
 444:     }
 445: 
 446:     //URLを日時の新しい順にソート
 447:     rsort($urls);
 448: 
 449:     return TRUE;
 450: }

フィードから台風に関する情報URLを取得するユーザー関数は jmaGetTyphoonURLs である。
正規表現で VPTW60 を含むURLを取り出し配列に格納し、日時の新しい順に並べ替えておく。

解説:台風情報を読み込む

typhoon.php

 452: /**
 453:  * 新しい台風報かどうか
 454:  * @param   object $xml     気象庁防災情報XML
 455:  * @param   array  $items   台風情報を格納する配列
 456:  * @return  bool TRUE:新しい情報
 457: */
 458: function isNewTyphoon($xml, $items) {
 459:     $res = FALSE;
 460: 
 461:     if (isset($xml->Body->MeteorologicalInfos->MeteorologicalInfo->Item->Kind->Property->TyphoonNamePart->Number)) {
 462:         $num = (string)$xml->Body->MeteorologicalInfos->MeteorologicalInfo->Item->Kind->Property->TyphoonNamePart->Number;
 463:         if ($num !'') {
 464:             if (! isset($items[$num])) {
 465:                 $res = TRUE;
 466:             }
 467:         }
 468:     }
 469: 
 470:     return $res;
 471: }

typhoon.php

 473: /**
 474:  * 台風報取得(気象庁防災情報XMLから)
 475:  * @param   object $pgc     pahooGeoCodeオブジェクト
 476:  * @param   array  $items   台風情報を格納する配列
 477:  * @param   string $urls    情報XMLのURLを格納する配列
 478:  * @param   string $errmsg  エラーメッセージ格納用
 479:  * @return  bool TRUE:取得成功/FALSE:失敗
 480: */
 481: function getTyphoon($pgc, &$items, &$urls, &$errmsg) {
 482:     //名前空間
 483:     define('JMX_EB', 'http://xml.kishou.go.jp/jmaxml1/elementBasis1/');
 484:     //マッチングパターン
 485:     $pat2 = '/([\+\-][0-9]{1,2}\.[0-9]+)([\+\-][0-9]{1,3}\.[0-9]+)/ui'//緯度・経度
 486:     $pat3 = '/(予報)[  ]*([01234567890-9]+)時間後/ui';   //推定|予報
 487:     //予報円は取得済みか否か
 488:     $flag_forecast = array();
 489: 
 490:     //オブジェクト生成
 491:     $pcc = new pahooCache(LIFE_CACHE_DATA, DIR_CACHE_DATA);
 492: 
 493:     //最新の台風に関する情報URLを取得
 494:     jmaGetTyphoonURLs($urls, $errmsg);
 495:     if ($errmsg !'')  return FALSE;
 496: 
 497:     foreach ($urls as $key=>$vptw) {
 498:         //台風情報の取得
 499:         $xml = $pcc->simplexml_load($vptw);
 500:         //レスポンス・チェック
 501:         if ($pcc->iserror() || !isset($xml->Body->MeteorologicalInfos)) {
 502:             $errmsg = '気象庁防災情報XMLから台風情報を取得できません';
 503:             return FALSE;
 504:         }
 505:         $flag_f = FALSE;        //予報円取得済みか否か
 506: 
 507:         //最新の台風情報
 508:         if (isNewTyphoon($xml, $items)) {
 509:             foreach ($xml->Body->MeteorologicalInfos as $infos) {
 510:                 $cnt = 0;
 511:                 foreach ($infos->MeteorologicalInfo as $info) {
 512:                     //実況
 513:                     if ($info->DateTime['type'] == '実況') {
 514:                         foreach ($info->Item->Kind as $kind) {
 515:                             if (isset($kind->Property->Type)) {
 516:                                 //呼称
 517:                                 if ($kind->Property->Type == '呼称') {
 518:                                     $num = (string)$kind->Property->TyphoonNamePart->Number;
 519:                                     if ($num == '')     break;
 520:                                     $items[$num]['Name'] = (string)$kind->Property->TyphoonNamePart->Name;
 521:                                     $items[$num]['NameKana'] = (string)$kind->Property->TyphoonNamePart->NameKana;
 522:                                     $items[$num][$cnt]['kind'] = '実況';
 523:                                     $items[$num][$cnt]['DateTime'] = (string)$info->DateTime;
 524:                                     //その台風の予報円は未取得
 525:                                     $flag_forecast[$num] = FALSE;
 526:                                     //古い台風情報かどうか
 527:                                     $tt = time() - strtotime($info->DateTime);
 528:                                     $items[$num]['Valid'] = ($tt < SCRAP_TIME? TRUE : FALSE;
 529:                                 //階級
 530:                                 } else if ($kind->Property->Type == '階級') {
 531:                                     $node = $kind->Property->ClassPart->children(JMX_EB);
 532:                                     $items[$num][$cnt]['TyphoonClass'] = (string)$node->TyphoonClass;
 533:                                     $items[$num][$cnt]['AreaClass'] = (string)$node->AreaClass;
 534:                                     $items[$num][$cnt]['IntensityClass'] = (string)$node->IntensityClass;
 535:                                 //中心
 536:                                 } else if ($kind->Property->Type == '中心') {
 537:                                     $node = $kind->Property->CenterPart->children(JMX_EB);
 538:                                     //中心位置
 539:                                     $items[$num][$cnt]['Location'] = (string)$kind->Property->CenterPart->Location;
 540:                                     foreach ($node->Coordinate as $val) {
 541:                                         if (preg_match($pat2, $val, $arr> 0) {
 542:                                             $items[$num][$cnt]['latitude'] = (float)$arr[1];
 543:                                             $items[$num][$cnt]['longitude'] = (float)$arr[2];
 544:                                         }
 545:                                     }
 546:                                     //移動速度
 547:                                     $items[$num][$cnt]['Direction'] = (string)$node->Direction;
 548:                                     foreach ($node->Speed as $val) {
 549:                                         if (isset($val->attributes()['condition'])) {
 550:                                             $items[$num][$cnt]['Speed'] = (string)$val->attributes()['condition'];
 551:                                         } else if ($val->attributes()['unit'] == 'km/h') {
 552:                                             $items[$num][$cnt]['Speed'] = (string)$val;
 553:                                         }
 554:                                     }
 555:                                     //中心気圧
 556:                                     $items[$num][$cnt]['Pressure'] = (int)$node->Pressure;
 557:                                 //風
 558:                                 } else if ($kind->Property->Type == '風') {
 559:                                     $node = $kind->Property->WindPart->children(JMX_EB);
 560:                                     foreach ($node->WindSpeed as $val) {
 561:                                         if (($val->attributes()['condition'] == '中心付近'&& ($val->attributes()['unit'] == 'm/s')) {
 562:                                             $items[$num][$cnt]['centerWindSpeed'] = (int)$val;
 563:                                         } else if (($val->attributes()['type'] == '最大瞬間風速'&& ($val->attributes()['unit'] == 'm/s')) {
 564:                                             $items[$num][$cnt]['maxWindSpeed'] = (int)$val;
 565:                                         }
 566:                                     }
 567:                                     //暴風域・強風域
 568:                                     foreach ($kind->Property->WarningAreaPart as $val) {
 569:                                         $key = (string)$val['type'];
 570:                                         //半径
 571:                                         $node = $val->children(JMX_EB);
 572:                                         $n = 0;
 573:                                         foreach ($node->Circle->Axes->Axis as $axis) {
 574:                                             if (isset($axis->Direction&& ($axis->Direction !'')) {
 575:                                                 $items[$num][$cnt][$key][$n]['direction'] = (string)$axis->Direction;
 576:                                             } else {
 577:                                                 $items[$num][$cnt][$key][$n]['direction'] = (string)$axis->Direction->attributes()['condition'];
 578:                                             }
 579:                                             foreach ($axis->Radius as $val) {
 580:                                                 if ($val->attributes()['unit'] == 'km') {
 581:                                                     $items[$num][$cnt][$key][$n]['radius'] = (int)$val;
 582:                                                 }
 583:                                             }
 584:                                             $n++;
 585:                                         }
 586:                                     }
 587:                                 }
 588:                             }
 589:                         }
 590:                     //予報
 591:                     } else if (preg_match($pat3, (string)$info->DateTime['type'], $arr> 0) {
 592:                         if ($num == '')     break;      //ver.2.04
 593:                         $cnt++;
 594:                         $items[$num][$cnt]['kind'] = '予報';
 595:                         $items[$num][$cnt]['DateTime'] = (string)$info->DateTime;
 596:                         $flag_f = TRUE;     //予報円取得済み
 597:                         foreach ($info->Item->Kind as $kind) {
 598:                             //階級
 599:                             if ($kind->Property->Type == '階級') {
 600:                                 $node = $kind->Property->ClassPart->children(JMX_EB);
 601:                                 $items[$num][$cnt]['TyphoonClass'] = (string)$node->TyphoonClass;
 602:                                 $items[$num][$cnt]['AreaClass'] = isset($node->AreaClass? (string)$node->AreaClass : '';
 603:                                 $items[$num][$cnt]['IntensityClass'] = isset($node->IntensityClass? (string)$node->IntensityClass : '';
 604:                             //中心位置(予報円)
 605:                             } else if ($kind->Property->Type == '中心') {
 606:                                 $node = $kind->Property->CenterPart->ProbabilityCircle->children(JMX_EB);
 607:                                 foreach ($node->BasePoint as $val) {
 608:                                     if (preg_match($pat2, $val, $arr> 0) {
 609:                                         $items[$num][$cnt]['latitude'] = (float)$arr[1];
 610:                                         $items[$num][$cnt]['longitude'] = (float)$arr[2];
 611:                                     }
 612:                                 }
 613:                                 //半径
 614:                                 $n = 0;
 615:                                 foreach ($node->Axes->Axis as $axis) {
 616:                                     if (isset($axis->Direction&& ($axis->Direction !'')) {
 617:                                         $items[$num][$cnt]['予報円'][$n]['direction'] = (string)$axis->Direction;
 618:                                     } else {
 619:                                         $items[$num][$cnt]['予報円'][$n]['direction'] = (string)$axis->Direction->attributes()['condition'];
 620:                                     }
 621:                                     foreach ($axis->Radius as $val) {
 622:                                         if ($val->attributes()['unit'] == 'km') {
 623:                                             $items[$num][$cnt]['予報円'][$n]['radius'] = (int)$val;
 624:                                         }
 625:                                     }
 626:                                     $n++;
 627:                                 }
 628:                             //風
 629:                             } else if ($kind->Property->Type == '風') {
 630:                                 $node = $kind->Property->WindPart->children(JMX_EB);
 631:                                 foreach ($node->WindSpeed as $val) {
 632:                                     if (($val->attributes()['condition'] == '中心付近'&& ($val->attributes()['unit'] == 'm/s')) {
 633:                                         $items[$num][$cnt]['centerWindSpeed'] = (int)$val;
 634:                                     } else if (($val->attributes()['type'] == '最大瞬間風速'&& ($val->attributes()['unit'] == 'm/s')) {
 635:                                         $items[$num][$cnt]['maxWindSpeed'] = (int)$val;
 636:                                     }
 637:                                 }
 638:                             }
 639:                         }
 640:                     }
 641:                 }
 642:             }
 643:         //過去の台風情報
 644:         } else {
 645:             foreach ($xml->Body->MeteorologicalInfos as $infos) {
 646:                 foreach ($infos->MeteorologicalInfo as $info) {
 647:                     //過去の位置
 648:                     if ($info->DateTime['type'] == '実況') {
 649:                         foreach ($info->Item->Kind as $kind) {
 650:                             if (isset($kind->Property->Type)) {
 651:                                 //呼称
 652:                                 if ($kind->Property->Type == '呼称') {
 653:                                     $num = (string)$kind->Property->TyphoonNamePart->Number;
 654:                                     if ($num == '')     break;
 655:                                     $cnt = 0;
 656:                                     while (isset($items[$num][$cnt]))   $cnt++;
 657:                                     $items[$num][$cnt]['kind'] = '過去';
 658:                                     $items[$num][$cnt]['DateTime'] = (string)$info->DateTime;
 659:                                 //中心
 660:                                 } else if ($kind->Property->Type == '中心') {
 661:                                     $node = $kind->Property->CenterPart->children(JMX_EB);
 662:                                     //中心位置
 663:                                     foreach ($node->Coordinate as $val) {
 664:                                         if (preg_match($pat2, $val, $arr> 0) {
 665:                                             $items[$num][$cnt]['latitude'] = (float)$arr[1];
 666:                                             $items[$num][$cnt]['longitude'] = (float)$arr[2];
 667:                                         }
 668:                                     }
 669:                                 }
 670:                             }
 671:                         }
 672:                     //予報
 673:                     } else if (isset($flag_forecast[$num]) && ! $flag_forecast[$num&& (preg_match($pat3, (string)$info->DateTime['type'], $arr> 0)) {
 674:                         if ($num == '')     break;      //ver.2.04
 675:                         $cnt++;
 676:                         $items[$num][$cnt]['kind'] = '予報';
 677:                         $items[$num][$cnt]['DateTime'] = (string)$info->DateTime;
 678:                         $flag_f = TRUE;     //予報円取得済み
 679:                         foreach ($info->Item->Kind as $kind) {
 680:                             //階級
 681:                             if ($kind->Property->Type == '階級') {
 682:                                 $node = $kind->Property->ClassPart->children(JMX_EB);
 683:                                 $items[$num][$cnt]['TyphoonClass'] = (string)$node->TyphoonClass;
 684:                                 $items[$num][$cnt]['AreaClass'] = isset($node->AreaClass? (string)$node->AreaClass : '';
 685:                                 $items[$num][$cnt]['IntensityClass'] = isset($node->IntensityClass? (string)$node->IntensityClass : '';
 686:                             //中心位置(予報円)
 687:                             } else if ($kind->Property->Type == '中心') {
 688:                                 $node = $kind->Property->CenterPart->ProbabilityCircle->children(JMX_EB);
 689:                                 foreach ($node->BasePoint as $val) {
 690:                                     if (preg_match($pat2, $val, $arr> 0) {
 691:                                         $items[$num][$cnt]['latitude'] = (float)$arr[1];
 692:                                         $items[$num][$cnt]['longitude'] = (float)$arr[2];
 693:                                     }
 694:                                 }
 695:                                 //半径
 696:                                 $n = 0;
 697:                                 foreach ($node->Axes->Axis as $axis) {
 698:                                     if (isset($axis->Direction&& ($axis->Direction !'')) {
 699:                                         $items[$num][$cnt]['予報円'][$n]['direction'] = (string)$axis->Direction;
 700:                                     } else {
 701:                                         $items[$num][$cnt]['予報円'][$n]['direction'] = (string)$axis->Direction->attributes()['condition'];
 702:                                     }
 703:                                     foreach ($axis->Radius as $val) {
 704:                                         if ($val->attributes()['unit'] == 'km') {
 705:                                             $items[$num][$cnt]['予報円'][$n]['radius'] = (int)$val;
 706:                                         }
 707:                                     }
 708:                                     $n++;
 709:                                 }
 710:                             //風
 711:                             } else if ($kind->Property->Type == '風') {
 712:                                 $node = $kind->Property->WindPart->children(JMX_EB);
 713:                                 foreach ($node->WindSpeed as $val) {
 714:                                     if (($val->attributes()['condition'] == '中心付近'&& ($val->attributes()['unit'] == 'm/s')) {
 715:                                         $items[$num][$cnt]['centerWindSpeed'] = (int)$val;
 716:                                     } else if (($val->attributes()['type'] == '最大瞬間風速'&& ($val->attributes()['unit'] == 'm/s')) {
 717:                                         $items[$num][$cnt]['maxWindSpeed'] = (int)$val;
 718:                                     }
 719:                                 }
 720:                             }
 721:                         }
 722:                     }
 723:                 }
 724:             }
 725:         }
 726:         //その台風の予報円は取得済み
 727:         if ($flag_f) {
 728:             $flag_forecast[$num] = TRUE;
 729:         }
 730:     }
 731:     //オブジェクト解放
 732:     $pcc = NULL;
 733: 
 734:     return TRUE;
 735: }

VPTW60 から台風情報をを配列 $items へ格納するユーザー関数が getTyphoon である。

XMLファイルを読み込んだら、まず、実況情報か予報情報かを識別する。
予報情報の場合、ユーザー関数 isNewTyphoon により、台風の番号を参照し、まだ登録されていない情報であれば、最新の実況情報として配列に代入する。登録済みの情報であれば、過去の台風情報として配列に代入する。過去の情報は、台風の過去の進路としてマッピングするときに参照する。
なお、現在日時から実況日時を減じ、定数 SCRAP_TIME を超えていたら、古い台風情報として、配列には記録するものの、要素 ValidにFALSEを代入し、古い台風情報であることを明示する。

暴風域・強風域、予報円については、半径の値が複数存在する。たとえば、強風域が北東240km、南西200kmとなっていたら、台風の中心から北東へズレたところに強風域の中心がある。

前述の通り、VPTW60 の発表日時によっては予報円情報を含まないことがある。
そこで、台風毎に配列 $flag_forecast に予報円を取得したかどうかのフラグを持たせる。もし isNewTyphoon で取得した最新の VPTW60 に予報円情報が無ければ、過去の台風情報から予報円情報を取得する。
このため、ほぼ同じ予報取得プロセスが2箇所に書く格好になっており、美しさに欠けてしまった。

解説:台風情報を描くスクリプトを生成

typhoon.php

 782: /**
 783:  * 暴風域、強風域、予報円の描画スクリプトを生成する
 784:  * @param   object $pgc   pahooGeoCodeオブジェクト
 785:  * @param   array  $infos  台風情報
 786:  * @return  string スクリプト/FALSE:生成失敗
 787: */
 788: function jsTyphoonMap($pgc, $infos) {
 789:     $js = '';
 790:     foreach ($infos as $info) {
 791:         $key = 0;
 792:         //台風以外ならスキップ
 793:         if (! isset($info[$key]['TyphoonClass']))   continue;   //v.2.11
 794:         if (preg_match('/台風/ui', $info[$key]['TyphoonClass']) == 0)   continue;
 795:         //古い台風情報ならスキップ
 796:         if ($info['Valid'] == FALSE)    continue;
 797: 
 798:         //暴風域
 799:         list($latitude, $longitude, $radius) = shiftCircle($pgc, $info[$key]['latitude'], $info[$key]['longitude'], $info[$key]['暴風域']);
 800:         $radius *1000;
 801:         $js .$pgc->jsCircle($longitude, $latitude, $radius, COLOR_WIND2, '1.0', 2, MAPSERVICE);
 802:         //強風域
 803:         list($latitude, $longitude, $radius) = shiftCircle($pgc, $info[$key]['latitude'], $info[$key]['longitude'], $info[$key]['強風域']);
 804:         $radius *1000;
 805:         $js .$pgc->jsCircle($longitude, $latitude, $radius, COLOR_WIND1, '1.0', 2, MAPSERVICE);
 806: 
 807:         $key = 1;
 808:         $cnt = 1;
 809:         $lng_0 = $points[0]['longitude'] = $info[0]['longitude'];
 810:         $lat_0 = $points[0]['latitude']  = $info[0]['latitude'];
 811:         while (isset($info[$key])) {
 812:             //予報円
 813:             if ($info[$key]['kind'] == '予報') {
 814:                 //予報円を間引くかどうか
 815:                 $dd = $pgc->greatCircleDistance($lng_0, $lat_0, $info[$key]['longitude'], $info[$key]['latitude']);
 816:                 if ($dd > THIN_OUT) {
 817:                     list($latitude, $longitude, $radius) = shiftCircle($pgc, $info[$key]['latitude'], $info[$key]['longitude'], $info[$key]['予報円']);
 818:                     $radius *1000;
 819:                     $js .$pgc->jsCircle($longitude, $latitude, $radius, COLOR_FORECAST, '1.0', 2, MAPSERVICE);
 820:                     preg_match('/[0-9]{4}\-[0-9]{2}\-([0-9]{2})T([0-9]{2})/ui', $info[$key]['DateTime'], $arr);
 821:                     $dt = sprintf('%d日%d時', $arr[1], $arr[2]);
 822:                     list($lat, $lng) = $pgc->getPointDistance($longitude, $latitude, 0 - $radius - 30000, 9);
 823:                     $js .$pgc->jsLabel($lat, $lng, $dt, 12, COLOR_FORECAST, 'normal', MAPSERVICE);
 824:                     $lat_0 = $info[$key]['latitude'];
 825:                     $lng_0 = $info[$key]['longitude'];
 826:                 }
 827: 
 828:             //過去の位置
 829:             } else {
 830:                 $points[$cnt]['longitude'] = $info[$key]['longitude'];
 831:                 $points[$cnt]['latitude']  = $info[$key]['latitude'];
 832:                 $cnt++;
 833:             }
 834:             $key++;
 835:         }
 836:         //過去の移動経路
 837:         if ($js !'') {
 838:             $js .$pgc->jsLine($points, COLOR_LINE, '1.0', 3, MAPSERVICE);
 839:             $points = array();
 840:             $cnt = 0;
 841:             if (isset($info[$key]['longitude'])) {
 842:                 $points[0]['longitude'] = $info[$key]['longitude'];
 843:                 $points[0]['latitude']  = $info[$key]['latitude'];
 844:             }
 845:         }
 846:     }
 847: 
 848:     //HTMLの画像化
 849:     $js .js_html2image();
 850: 
 851:     return $js;
 852: }

ユーザー関数が jsTyphoonMap は、暴風域・強風域、予報円を含め、マップ上に台風情報を描くためのスクリプトを生成する。
前述の通り、円の中心がズレている場合があり、そのためのユーザー関数 shiftCircle を呼び出して使う。

前回の予報円の中心座標 ($lat_0, $lng_0) (初回は現在の台風の中心座標)からの大圏航路距離をメソッド greatCircleDistance によって計算し、予報円の間引き条件 THIN_OUT 以下であれば予報円を描かない。

解説:マップ描画用情報を生成

typhoon.php

 854: /**
 855:  * 台風情報からマップ描画用情報を生成する
 856:  * @param   array  $infos  台風情報
 857:  * @param   array  $items  マップ描画用情報を格納
 858:  * @param   string $table  HTML文(表形式)を格納
 859:  * @param   int    $count  有効な台風情報の数
 860:  * @param   objct  $pgc    pahooGeoCodeオブジェクト
 861:  * @return  array(日時,緯度,経度) 発表日時,予報円の最後の中心座標,地図ズーム
 862: */
 863: function getTyphoonInfo($infos, &$items, &$table, &$count, $pgc) {
 864:     //地図ズーム値=距離換算表
 865:     $zooms = array(10000, 5000, 3000, 1000, 500, 300);
 866: 
 867:     //台風情報一覧
 868:     $table =<<< EOT
 869: <table class="plists">
 870: <th>名称</th>
 871: <th>位置</th>
 872: <th>中心気圧</th>
 873: <th>最大瞬間風速</th>
 874: <th>進路</th>
 875: </tr>
 876: 
 877: EOT;
 878: 
 879:     $dt0 = $lat0 = $lng0 = $zoom = FALSE;
 880:     $distance = 99999;
 881:     $cnt = 1;
 882:     foreach ($infos as $nn=>$info) {                            //v.2.11
 883:         $key = 0;                                               //v.2.11
 884:         if (! isset($info[$key]['TyphoonClass']))   continue;   //v.2.11
 885:         if (preg_match('/台風/ui', $info[0]['TyphoonClass']) > 0) {
 886:             $num = sprintf('台%d', (int)substr($nn, 2, 2)); //v.2.11
 887:             $name = sprintf('台風%d号(%s)', (int)substr($nn, 2, 2), $info['NameKana']);
 888:         } else {
 889:             continue;
 890:         }
 891:         //古い台風情報ならスキップ
 892:         if ($info['Valid'] == FALSE)    continue;
 893: 
 894:         $items[$cnt]['longitude'] = $info[0]['longitude'];
 895:         $items[$cnt]['latitude']  = $info[0]['latitude'];
 896:         if ($cnt == 1) {
 897:             preg_match('/([0-9]{4})\-([0-9]{2})\-([0-9]{2})T([0-9]{2})\:/ui', $info[0]['DateTime'], $arr);
 898:             $dt0  = $info[0]['DateTime'] = sprintf('%d年%d月%d日%d時', $arr[1], $arr[2], $arr[3], $arr[4]);
 899:         }
 900:         $items[$cnt]['label']     = $num;
 901:         $items[$cnt]['label_color'] = COLOR_NAME1;
 902:         $items[$cnt]['label_size'] = 16;
 903:         $items[$cnt]['label_weight'] = 'bold';
 904:         //情報ウィンドウ
 905:         if ($info[0]['AreaClass'!'') {
 906:             $AreaClass = $info[0]['AreaClass'];
 907:         } else {
 908:             $AreaClass = '-';
 909:         }
 910:         if ($info[0]['IntensityClass'!'') {
 911:             $IntensityClass = $info[0]['IntensityClass'];
 912:         } else {
 913:             $IntensityClass = '-';
 914:         }
 915:         if ($items[$cnt]['longitude'>0) {
 916:             $lng = sprintf('東経%.1f度', $items[$cnt]['longitude']);
 917:         } else {
 918:             $lng = sprintf('西経%.1f度', 0 - $items[$cnt]['longitude']);
 919:         }
 920:         if ($items[$cnt]['latitude'>0) {
 921:             $lat = sprintf('北緯%.1f度', $items[$cnt]['latitude']);
 922:         } else {
 923:             $lat = sprintf('南緯%.1f度', 0 - $items[$cnt]['latitude']);
 924:         }
 925:         if ($info[0]['Direction'!'') {
 926:             $directioin = $info[0]['Direction'. 'へ';
 927:         } else {
 928:             $directioin = '';
 929:         }
 930:         if (is_numeric($info[0]['Speed'])) {
 931:             $speed = sprintf('時速%dkm', $info[0]['Speed']);
 932:         } else {
 933:             $speed = $info[0]['Speed'];
 934:         }
 935:         if (isset($info[0]['centerWindSpeed']) && is_numeric($info[0]['centerWindSpeed'])) {
 936:             $centerWindSpeed = $info[0]['centerWindSpeed'. 'メートル';
 937:         } else {
 938:             $centerWindSpeed = '-';
 939:         }
 940:         if (isset($info[0]['maxWindSpeed']) && is_numeric($info[0]['maxWindSpeed'])) {
 941:             $maxWindSpeed = $info[0]['maxWindSpeed'. 'メートル';
 942:         } else {
 943:             $maxWindSpeed = '-';
 944:         }
 945:         $items[$cnt]['title'] = '';
 946:         $items[$cnt]['description'] =<<< EOT
 947: <span style="font-size:120%; font-weight:bold;">{$name}</span><br />大きさ:{$AreaClass}<br />強さ:{$IntensityClass}</br />中心位置:{$lat},{$lng}<br />({$info[0]['Location']})<br />進路:{$directioin}{$speed}<br />中心気圧:{$info[0]['Pressure']}hPa<br />中心付近の最大風速:{$centerWindSpeed}<br />最大瞬間風速:{$maxWindSpeed}<br />
 948: EOT;
 949:         //台風情報一覧
 950:         $table .=<<< EOT
 951: <tr>
 952: <td>{$name}</td>
 953: <td>{$info[0]['Location']}</td>
 954: <td>{$info[0]['Pressure']}hPa</td>
 955: <td>{$maxWindSpeed}</td>
 956: <td>{$directioin}{$speed}</td>
 957: </tr>
 958: 
 959: EOT;
 960:         //3日以内の予報円の中心座標を求める v.2.01
 961:         $m = 1;
 962:         while (isset($info[$m])) {
 963:             if ($info[$m]['kind'] == '予報') {
 964:                 $lat1 = $info[$m]['latitude'];
 965:                 $lng1 = $info[$m]['longitude'];
 966:                 if ($m >3)    break;
 967:             }
 968:             $m++;
 969:         }
 970:         //基準座標に近い方をマップ中心にする
 971:         $dist = $pgc->greatCircleDistance($lng1, $lat1, LONGITUDE00, LATITUDE00);
 972:         if ($dist < $distance) {
 973:             $lat0 = $lat1;
 974:             $lng0 = $lng1;
 975:             $distance = $dist;
 976:             //台風中心と3日後予報円の距離から地図ズーム値を決める
 977:             $dist = $pgc->greatCircleDistance($lng0, $lat0, $info[0]['longitude'], $info[0]['latitude']);
 978:             $zoom = count($zooms+ 1;
 979:             foreach ($zooms as $key=>$val) {
 980:                 if ($dist >$val) {
 981:                     $zoom = $key + 1;
 982:                     break;
 983:                 }
 984:             }
 985:         }
 986: 
 987:         $cnt++;
 988:     }
 989: 
 990:     $table .=<<< EOT
 991: </table>
 992: 
 993: EOT;
 994:     //有効な台風情報の数
 995:     $count = $cnt - 1;
 996: 
 997:     return array($dt0, $lat0, $lng0, $zoom);
 998: }

ユーザー関数が getTyphoonInfo は、台風情報一覧や、マップ上のアイコンをクリックするとあらわれる情報ウィンドウの内容(テキスト)を生成するユーザー関数である。

解説:メイン・プログラム

typhoon.php

1152: //気象庁防災情報XMLから台風情報を取得
1153: $infos = array();
1154: $urls  = array();
1155: $items = array();
1156: $dt    = $date->format('Y年m月d日H時');
1157: $table = $errmsg = '';
1158: $count = 0;
1159: $js    = FALSE;
1160: $ret   = getTyphoon($pgc, $infos, $urls, $errmsg);
1161: if ($ret == FALSE) {
1162:     $errmsg = $pgc->getError();
1163: else {
1164:     $js = jsTyphoonMap($pgc, $infos, $errmsg);
1165:     list($dt, $latitude, $longitude, $zoom) = getTyphoonInfo($infos, $items, $table, $count, $pgc);
1166:     if ($count == 0) {
1167:         $longitude = DEF_LONGITUDE;
1168:         $latitude  = DEF_LATITUDE;
1169:         $zoom      = DEF_ZOOM;
1170:         $type      = DEF_TYPE;
1171:         $dt        = $date->format('Y年m月d日H時');
1172:     }
1173: }
1174: 
1175: //マップ作成
1176: if (($errmsg == ''&& ($js !FALSE)) {
1177:     $jsmap = $pgc->drawJSMap(MAPID, $latitude, $longitude, $type, $zoom, NULL, $items, MAPSERVICE, $js, (MAP_WIDTH / 2));
1178: else {
1179:     $jsmap = $pgc->drawJSMap(MAPID, $latitude, $longitude, $type, $zoom, NULL, $items, MAPSERVICE);
1180: }
1181: 
1182: // Twitter(現・X)、Blueskyへ投稿する.
1183: $message =<<< EOT
1184: 🌀台風情報 {$dt}現在
1185: 
1186: (ご参考)PHPで台風情報を取得する https://www.pahoo.org/e-soul/webtech/php05/php05-15-01.shtm #台風 #台風情報 #nhk #ntv #tbs #fujitv #tvasahi
1187: 
1188: EOT;
1189: if (TWITTER && isButton('tweet')) {
1190:     mediaTweet($message, $res);
1191: }
1192: if (BLUESKY && isButton('bluesky')) {
1193:     mediaBluesky($message, $res);
1194: }
1195: 
1196: // 表示コンテンツを作成する.
1197: $HtmlBody = makeCommonBody($dt, $jsmap, $table, $urls, $res, $errmsg, $count);
1198: 
1199: // ブラウザに表示する.
1200: echo $HtmlHeader;
1201: echo $HtmlBody;
1202: echo $HtmlFooter;

メイン・プログラムでは、まず、getTyphoon 関数で台風情報を取得し、エラーがなかったら、jsTyphoonMap 関数で描画スクリプトを生成する。
ここまででエラーがなければ、マップを生成する。
エラーがあれば、台風情報を描かずにマップのみ生成する。

解説: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ライブラリ

typhoon.php

 142: <script src="https://html2canvas.hertzen.com/dist/html2canvas.js"></script>
 143: 
 144: <style>
 145: p.werror {
 146:     color: red;
 147: }

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

typhoon.php

1106: <div id="{$target}" name="{$target}" style="width:{$width2}px;">
1107: <p>
1108: 🌀台風情報 {$dt}現在{$res2}
1109: &nbsp;{$tweet}{$bluesky}
1110: </p>
1111: <div id="{$mapid}" style="width:{$width}px; height:{$height}px;"></div>
1112: {$table}
1113: </div>

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

typhoon.php

 328: /**
 329:  * HTMLオブジェクトの画像化
 330:  * @param   なし
 331:  * @return  string JavaScriptコード
 332: */
 333: function js_html2image() {
 334:     $target = TARGET;
 335:     $js = '';
 336: 
 337:     //Googleマップの場合
 338:     if (MAPSERVICE == 0) {
 339:         $js .=<<< EOT
 340: google.maps.event.addListener(map, 'tilesloaded', function() {
 341:     var capture = document.querySelector('#{$target}');
 342:     html2canvas(capture, {useCORS: true, allowTaint:true}).then(canvas => {
 343:         var base64 = canvas.toDataURL('image/png');     //画像化
 344:         $('#base64').val(base64);
 345:     });
 346: });
 347: 
 348: EOT;
 349: 
 350:     //Leafletの場合(ブラウザによってはうまく動作しない)
 351:     } else {
 352:         $js .=<<< EOT
 353: HTMLCanvasElement.prototype.getContext = function(origFn) {
 354:     return function(type, attribs) {
 355:         attribs = attribs || {};
 356:         attribs.preserveDrawingBuffer = true;
 357:         return origFn.call(this, type, attribs);
 358:     };
 359: } (HTMLCanvasElement.prototype.getContext);
 360: 
 361: //HTML画像化イベント登録
 362: function html2image() {
 363:     var capture = document.querySelector('#{$target}');
 364:     html2canvas(capture, {useCORS: true, allowTaint:true}).then(canvas => {
 365:         var base64 = canvas.toDataURL('image/png');     //画像化
 366:         $('#base64').val(base64);
 367:     });
 368: };
 369: 
 370: //ズーム変更イベント
 371: map.on('zoomend', function() {
 372:     html2image();
 373: });
 374: 
 375: //マップ移動イベント
 376: map.on('moveend', function() {
 377:     html2image();
 378: });
 379: 
 380: //html2image()を発火させるために,ズームアウトして500msec後に元に戻す.
 381: var zoom = map.getZoom();
 382: map.setZoom(zoom - 1);
 383: setTimeout(function() {
 384:     map.setZoom(zoom);
 385: }, 500);
 386: 
 387: EOT;
 388:     }
 389: 
 390:     return $js;
 391: }

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

準備:pahooTwitterAPI クラス

pahooTwitterAPI.php

  19: class pahooTwitterAPI {
  20:     var $responses;     //応答データ
  21:     var $webapi;        //直前に呼び出したWebAPI URL
  22:     var $error;         //エラーフラグ
  23:     var $errmsg;        //エラーメッセージ
  24:     var $errcode;       //エラーコード
  25:     var $connection;
  26: 
  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)

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

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

pahooTwitterAPI.php

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

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

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

typhoon.php

 303: /**
 304:  * ツイート処理
 305:  * @param   string $message 投稿文
 306:  * @param   string $res     応答メッセージ格納用
 307:  * @return  bool TRUE:成功/FALSE:失敗または未処理
 308: */
 309: function mediaTweet($message, &$res) {
 310:     if (! TWITTER)  return FALSE;
 311: 
 312:     $ret = TRUE;
 313:     if (isset($_POST['base64']) && ($_POST['base64'!'')) {
 314:         $base64 = preg_replace('/data\:image\/png\;base64\,/ui', '', $_POST['base64']);
 315:         $raws = array(base64_decode($base64));
 316:         $ptw = new pahooTwitterAPI();
 317:         $ptw->tweet_media_raw($message, $raws);
 318:         $errmsg = $ptw->errmsg;
 319:         $ret = ! $ptw->error;
 320:         $ptw = NULL;
 321:         if ($ret) {
 322:             $res = 'ツイートしました';
 323:         }
 324:     }
 325:     return $ret;
 326: }

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

準備:pahooBlueskyAPI クラス

pahooBlueskyAPI.php

  15: class pahooBlueskyAPI {
  16:     var $pds;           // PDSドメイン
  17:     var $webapi;        // 直前に呼び出したWebAPI URL
  18:     var $errmsg;        // エラーメッセージ
  19:     var $accessJwt;     // accessJwt
  20: 
  21:     const INTERNAL_ENCODING = 'UTF-8';  //内部エンコーディング
  22:     const MAX_MESSAGE_LEN = 300;        // 投稿可能なメッセージ文字数
  23:     const URL_LEN = 30;                 // メッセージ中のURL文字数(相当)
  24:     const MAX_IMAGE_WIDTH  = 1200;      // 投稿可能な最大画像幅(ピクセル)
  25:     const MAX_IMAGE_HEIGHT = 900;       // 投稿可能な最大画像高さ(ピクセル)
  26:                                         // これより大きいときは自動縮小する
  27: 
  28:     // Bluesky API アプリパスワード
  29:     // https://bsky.app/
  30:     var $BLUESKY_HANDLE   = '***************';      // ハンドル名
  31:     var $BLUESKY_PASSWORD = '***************';      // アプリケーション・パスワード

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

解説:Blueskyへ投稿する

typhoon.php

 276: /**
 277:  * Bluesky投稿処理
 278:  * @param   string $message 投稿文
 279:  * @param   string $res     応答メッセージ格納用
 280:  * @return  bool TRUE:成功/FALSE:失敗または未処理
 281: */
 282: function mediaBluesky($message, &$res) {
 283:     if (! BLUESKY)  return FALSE;
 284: 
 285:     $ret = TRUE;
 286:     if (isset($_POST['base64']) && ($_POST['base64'!'')) {
 287:         $base64 = preg_replace('/data\:image\/png\;base64\,/ui', '', $_POST['base64']);
 288:         $raws = array(base64_decode($base64));
 289:         $pbs = new pahooBlueskyAPI(BLUESKY_DOMAIN);
 290:         $res = $pbs->createSession();
 291:         $pbs->post($message, FALSE, NULL, NULL, $raws);
 292:         $errmsg = $pbs->geterror();
 293:         $ret = ! $pbs->iserror();
 294:         $res = $pbs->deleteSession();
 295:         $pbs = NULL;
 296:         if ($ret) {
 297:             $res = 'Blueskyに投稿しました';
 298:         }
 299:     }
 300:     return $ret;
 301: }

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

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

typhoon.php

1182: // Twitter(現・X)、Blueskyへ投稿する.
1183: $message =<<< EOT
1184: 🌀台風情報 {$dt}現在
1185: 
1186: (ご参考)PHPで台風情報を取得する https://www.pahoo.org/e-soul/webtech/php05/php05-15-01.shtm #台風 #台風情報 #nhk #ntv #tbs #fujitv #tvasahi
1187: 
1188: EOT;
1189: if (TWITTER && isButton('tweet')) {
1190:     mediaTweet($message, $res);
1191: }
1192: if (BLUESKY && isButton('bluesky')) {
1193:     mediaBluesky($message, $res);
1194: }
1195: 
1196: // 表示コンテンツを作成する.

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

活用例

みんなの知識 ちょっと便利帳では、「日本付近の台風情報 - 気象庁発表」のコーナーで、このサンプル・プログラムを活用している。ありがとうございます。

参考サイト

(この項おわり)
header