PHPで天気図を描く

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

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

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

PHPで天気図を描く

目次

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

圧縮ファイルの内容
weatherMap.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 が通ったディレクトリに配置すること。
weatherMap.php 更新履歴
バージョン 更新日 内容
1.6.0 2024/11/02 Bluesky投稿機能を追加
1.5.0 2024/06/21 Twitter(現・X)ボタンを "X" に変更
1.4.1 2023/09/20 js_html2image()--Leaflet用html2image()発火プロセス見直し
1.4 2022/03/10 気象庁防災情報XMLのhttps化に対応
1.3 2021/06/20 ツイート機能を追加
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の登録方法」を参照されたい。

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

weatherMap.php

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

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

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

weatherMap.php

  62: // キャッシュ保持時間(分) 0:キャッシュしない
  63: // 気象庁へのアクセス負荷軽減のため,60分以上のキャッシュ保持をお勧めします.
  64: define('LIFE_CACHE', 120);
  65: 
  66: // キャッシュ・ディレクトリ
  67: // 書き込み可能で,外部からアクセスされないディレクトリを指定してください.
  68: define('DIR_CACHE', './pcache/');

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

準備:各種定数など

weatherMap.php

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

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

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

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

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

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

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

VZSA50の構造

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

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

weatherMap.php

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

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

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

weatherMap.php

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

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

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

解説:前線記号

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

weatherMap.php

 517: /**
 518:  * 二等辺三角形座標(寒冷前線用)
 519:  * @param   float $lat0, $lng0 底辺の中心座標
 520:  * @param   float $lat1, $lng2 底辺の一方の座標
 521:  * @param   int   $way         0:寒冷前線・停滞前線,1:閉塞前線
 522:  * @return  array($lat, $lng)  頂点の座標
 523: */
 524: function isoTriangle($lat0, $lng0, $lat1, $lng1, $way=0) {
 525:     $angle = ($way == 0? 270 : 90;
 526: 
 527:     if ($lng1 - $lng0 == 0) {
 528:         $t = 0.0;
 529:     } else {
 530:         $t = rad2deg(atan(($lat1 - $lat0) / ($lng1 - $lng0)));
 531:     }
 532:     $r = sqrt(pow(($lng1 - $lng0), 2+ pow(($lat1 - $lat0), 2));
 533:     $lat = $r * sin(deg2rad($t + $angle)) + $lat0;
 534:     $lng = $r * cos(deg2rad($t + $angle)) + $lng0;
 535: 
 536:     return array($lat, $lng);
 537: }

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

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

weatherMap.php

 539: /**
 540:  * 半円弧座標(温暖前線記号):多角形で近似する
 541:  * @param   float $lat0, $lng0 中心座標
 542:  * @param   float $lat1, $lng2 円弧の始点座標
 543:  * @param   int   $n           多角形の頂点数
 544:  * @param   array points 円弧の座標を格納する
 545:  * @return  なし
 546: */
 547: function semiCircle($lat0, $lng0, $lat1, $lng1, $n, &$points) {
 548:     if ($lng1 - $lng0 == 0) {
 549:         $t = 0.0;
 550:     } else {
 551:         $t = rad2deg(atan(($lat1 - $lat0) / ($lng1 - $lng0)));
 552:     }
 553:     $r = sqrt(pow(($lng1 - $lng0), 2+ pow(($lat1 - $lat0), 2));
 554:     for ($i = 0$i < $n$i++) {
 555:         $points[$i]['latitude']  = $r * sin(deg2rad($t + 180 / $n * $i)) + $lat0;
 556:         $points[$i]['longitude'] = $r * cos(deg2rad($t + 180 / $n * $i)) + $lng0;
 557:     }
 558: }

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

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

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

weatherMap.php

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

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

解説:前線を描く(Leaflet)

weatherMap.php

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

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

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

weatherMap.php

 560: /**
 561:  * ラベル表示用スクリプト作成:Googleマップ
 562:  * @param   float  $latitude, $longitude ラベル表示座標
 563:  * @param   string $label ラベル
 564:  * @param   int    $size  フォントサイズ(pt)
 565:  * @param   string $color  フォントカラー
 566:  * @param   string $weight 太さ
 567:  * @return  string 描画用スクリプト
 568: */
 569: function jsLabel_gmap($latitude, $longitude, $label, $size=14, $color='#FF0000', $weight="normal") {
 570:     $js =<<< EOT
 571: new google.maps.Marker({
 572:     map: map,
 573:     position: new google.maps.LatLng({$latitude}, {$longitude}),
 574:     icon: {
 575:         url: 'https://www.pahoo.org/common/space.gif'
 576:     },
 577:     label: {
 578:         text: '{$label}',
 579:         color: '{$color}',
 580:         fontSize: '{$size}px',
 581:         fontWeight: '{$weight}'
 582:     }
 583: });
 584: 
 585: EOT;
 586:     return $js;
 587: }

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

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

weatherMap.php

 589: /**
 590:  * ラベル表示用スクリプト作成:Leaflet
 591:  * @param   float  $latitude, $longitude ラベル表示座標
 592:  * @param   string $label  ラベル
 593:  * @param   int    $size   フォントサイズ(pt)
 594:  * @param   string $color  フォントカラー
 595:  * @param   string $weight 太さ
 596:  * @return  string 描画用スクリプト
 597: */
 598: function jsLabel_leaflet($latitude, $longitude, $label, $size=14, $color='#FF0000', $weight="normal") {
 599:     $js =<<< EOT
 600: new L.marker(
 601:     [{$latitude}, {$longitude}],
 602:     {
 603:     icon:
 604:         new L.divIcon({
 605:             html: '<span style="color:{$color}; font-size:{$size}px; font-weight:{$weight};">{$label}</span>',
 606:             iconSize: [0, 0],
 607:             iconAnchor: [{$size}, {$size}],
 608:         })
 609:     }
 610: ).addTo(map);
 611: 
 612: EOT;
 613:     return $js;
 614: }

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

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

weatherMap.php

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

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

解説:SNS投稿機能

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

解説:html2canvasライブラリ

weatherMap.php

 114: <script src="https://html2canvas.hertzen.com/dist/html2canvas.js"></script>
 115: 

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

weatherMap.php

1112: <div id="{$target}" name="{$target}" style="width:{$width2}px;">
1113: <p>
1114: 🌞天気図 {$dt}現在
1115: &nbsp;{$tweet}{$bluesky}{$tweet_bluesky}
1116: </p>
1117: <div id="{$mapid}" style="width:{$width}px; height:{$height}px;"></div>
1118: </div>

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

weatherMap.php

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

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)へ投稿する

weatherMap.php

 253: /**
 254:  * Twitter(現・X)投稿
 255:  * @param   string $message 投稿文
 256:  * @param   string $res     応答メッセージ格納用
 257:  * @return  bool TRUE:成功/FALSE:失敗または未処理
 258: */
 259: function mediaTweet($message, &$res) {
 260:     if (! TWITTER)  return FALSE;
 261: 
 262:     $ret = TRUE;
 263:     if (isset($_POST['base64']) && ($_POST['base64'!'')) {
 264:         $base64 = preg_replace('/data\:image\/png\;base64\,/ui', '', $_POST['base64']);
 265:         $raws = array(base64_decode($base64));
 266:         $ptw = new pahooTwitterAPI();
 267:         $ptw->tweet_media_raw($message, $raws);
 268:         $errmsg = $ptw->errmsg;
 269:         $ret = ! $ptw->error;
 270:         $ptw = NULL;
 271:         if ($ret) {
 272:             $res = 'ツイートしました';
 273:         }
 274:     }
 275:     return $ret;
 276: }

メッセージと画像を Twitter(現・X)に投稿するのはサーバ側のPHPユーザー関数 mediaTweet である。
ブラウザからPOSTで受け取った画像データ $_POST['base64'] は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

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

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

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

weatherMap.php

1166: // Twitter(現・X)、Blueskyへ投稿
1167: $message =<<< EOT
1168: 🌞天気図 {$dt}現在
1169: 
1170: (ご参考)PHPで天気図を描く https://www.pahoo.org/e-soul/webtech/php06/php06-73-01.shtm
1171: 
1172: EOT;
1173: 
1174: if (TWITTER && isButton('tweet')) {
1175:     mediaTweet($message, $res);
1176: }
1177: if (BLUESKY && isButton('bluesky')) {
1178:     mediaBluesky($message, $res);
1179: }
1180: 
1181: // 表示コンテンツを作成する.

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

活用例

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

参考サイト

(この項おわり)
header