サンプル・プログラムの実行例
目次
- サンプル・プログラムの実行例
- サンプル・プログラムのダウンロード
- 準備:pahooGeoCode クラス
- 準備:地図サービスの選択
- 準備:キャッシュ・システム
- 準備:各種定数など
- 気象庁防災情報XMLフォーマット
- VZSA50の構造
- 解説:最新の地上実況図URLを取得
- 解説:地上実況図を読み込む
- 解説:前線記号
- 解説:前線を描く(Googleマップ)
- 解説:前線を描く(Leaflet)
- 解説:ラベルを描く(Googleマップ)
- 解説:ラベルを描く(Leaflet)
- 解説:天気図描画スクリプトを生成する
- 解説:SNS投稿機能
- 解説:html2canvasライブラリ
- 準備:pahooTwitterAPI クラス
- 解説:メディア付き投稿(RAWデータ)
- 解説:Twitter(現・X)へ投稿する
- 準備:pahooBlueskyAPI クラス
- 解説:Blueskyへ投稿する
- 解説:SNSへ投稿する(メイン・プログラム)
- 活用例
- 参考サイト
サンプル・プログラムのダウンロード
weatherMap.php | サンプル・プログラム本体。 |
pahooGeoCode.php | 住所・緯度・経度に関わるクラス pahooGeoCode。 使い方は「PHPで住所・ランドマークから最寄り駅を求める」「PHPで住所・ランドマークから緯度・経度を求める」などを参照。include_path が通ったディレクトリに配置すること。 |
pahooCache.php | キャッシュ処理に関わるクラス pahooCache。 キャッシュ処理に関わるクラスの使い方は「PHPで天気予報を求める」を参照。include_path が通ったディレクトリに配置すること。 |
pahooTwitterAPI.php | Twitter APIを利用するクラス pahooTwitterAPI。 使い方は「PHPでTwitterに投稿(ツイート)する」などを参照。include_path が通ったディレクトリに配置すること。 |
pahooBlueskyAPI.php | Bluesky APIに関わるクラス pahooBlueskyAPI。 使い方は「PHPでPHPでBlueskyに投稿する」などを参照。include_path が通ったディレクトリに配置すること。 |
バージョン | 更新日 | 内容 |
---|---|---|
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 | ツイート機能を追加 |
バージョン | 更新日 | 内容 |
---|---|---|
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()追加 |
バージョン | 更新日 | 内容 |
---|---|---|
1.1.1 | 2023/02/11 | コメント追記 |
1.1 | 2021/04/08 | simplexml_load()メソッド追加 |
1.0 | 2021/04/02 | 初版 |
バージョン | 更新日 | 内容 |
---|---|---|
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対応 |
バージョン | 更新日 | 内容 |
---|---|---|
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 = '*****************************';
クラスについては「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、Yahoo!JAPAN、HeartRails Geo API、OSM Nominatim Search API から選べる。あらかじめ、定数 GEOSERVICE に値を設定すること。
準備:キャッシュ・システム
キャッシュ保持時間、キャッシュ・ディレクトリともに、自サイトの環境に応じて変更してほしい。
準備:各種定数など
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フォーマット
今回は、高頻度 - 定時更新フィードにアクセスし、電文コード VZSA50 の地上実況図を取得する。
VZSA50の構造
注意すべきは、1つのXMLファイルの中に1つまたは複数の予報地点が含まれていること。さらに、天気予報(アイコン画像を含む)と降水確率は「地方」に紐付いているが、最低気温・最高気温は「予報地点」に紐付いているということである。地方と予報地点は1対N対応のため、後述する予報値地点データファイルに用意しておくことにした。
また、TimeDefine タグに示される7日分の情報が格納されているのだが、XML情報の取得時間帯によって、1つ目(refID=1)の情報が、今日になるか明日になるか分かれる。そこで、TimeDefine を取得して、現在日時(PCの内蔵時計)と比較して決定することにした。
降水確率や気温は、XML情報の取得タイミングによっては、1つ目(refID=1)~3つ目(refID=3)が「値なし」になっていることがある。そこで、後述する情報XML VPFW50 から、今日~明後日(または明日~明後日)の情報を取り出し、補完することにする。
解説:最新の地上実況図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を正規表現で分解し、配信日時 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: }
XMLファイルを読み込んだら、まず、報告日時を $dt へ格納する。
次に、Type要素の値に応じて、等圧線、前線、気圧の位置情報を配列 $items へ格納していく。
解説:前線記号
寒冷前線系の三角形と、温暖前線系の半円を描く必要がある。いろいろ試行錯誤したのだが、結局、マップサービスの多角形描画機能を使って座標を計算して描かせることにした。
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: }
座標系は緯度、経度から成る球面座標系だが、マップサービスがメルカトル図法であることから、平面座標系であると近似して計算式を立てた。
ここで頂点 \( 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: }
多角形の頂点座標の求め方は、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: }
温暖前線、寒冷前線、停滞前線、閉塞前線の識別を変数 $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: }
アルゴリズムは、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: }
アイコン表示命令を利用し、アイコン画像を表示させないようにしている。
解説:ラベルを描く(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: }
前述の 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: }
解説:SNS投稿機能
コンテンツを描画しているのはクライアントにあるブラウザ(レンダリングエンジン)であることから、クライアント側で画像を作成し、サーバ側で SNS の API をコールするという方針とした。
- ブラウザはサーバにコンテンツ描画をリクエストする。
- サーバはデータサイトからデータ取得する。(サーバキャッシュにデータがあればそれを利用する)
- サーバはコンテンツ描画スクリプトを生成する。
- サーバはブラウザへレスポンス(HTML文)を返す。
- ブラウザはコンテンツをレンダリングする。
- ブラウザはレンダリングしたコンテンツを画像データとしてサーバへアップロードする。
- サーバは SNS の API を使ってツイートする。
- サーバは SNS へメッセージと画像を送る。
- サーバはブラウザへレスポンス(HTML文)を返す。
解説:html2canvasライブラリ
weatherMap.php
114: <script src="https://html2canvas.hertzen.com/dist/html2canvas.js"></script>
115:
画像化を実行するJavaScript関数は html2canvas である。
weatherMap.php
1112: <div id="{$target}" name="{$target}" style="width:{$width2}px;">
1113: <p>
1114: 🌞天気図 {$dt}現在
1115: {$tweet}{$bluesky}{$tweet_bluesky}
1116: </p>
1117: <div id="{$mapid}" style="width:{$width}px; height:{$height}px;"></div>
1118: </div>
レンダリングエンジンによって違うのかもしれないが、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: }
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)
解説:メディア付き投稿(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: }
解説: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: }
ブラウザから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へ投稿する
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: }
ブラウザから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: // 表示コンテンツを作成する.
活用例
参考サイト
- 気象庁防災情報XMLフォーマット 情報提供ページ
- PHPで天気予報を求める:ぱふぅ家のホームページ
- PHPで住所・ランドマークから最寄り駅を求める:ぱふぅ家のホームページ
- JavaScriptでHTML表示を画像保存する:ぱふぅ家のホームページ
- HTMLとCSSでさまざまなアイコンを表示する:ぱふぅ家のホームページ
- Twitter API - WebAPIの登録方法:ぱふぅ家のホームページ
- Bluesky API - WebAPIの登録方法:ぱふぅ家のホームページ
- PHPでTwitterに画像付きメッセージ投稿:ぱふぅ家のホームページ
- PHPでBlueskyに投稿する:ぱふぅ家のホームページ
- 日本周辺域の天気図:みんなの知識 ちょっと便利帳
そこで今回は、これらの情報をリアルタイムに取得し、Googleマップなどのマップに天気図を描くPHPプログラムを作ってみることにする。オプション機能として、天気図を含めて Twitter(現・X)や Blueckyに投稿できる機能を加えた。
(2024年11月2日)Bluesky投稿機能を追加
(2024年6月21日)TwitterOAuth 7.0.0 対応,Twitter(現・X)ボタンを "X" に変更