/** typhoonwin.cpp
 * 最新の台風情報
 *
 * @copyright	(c)studio pahoo
 * @author		パパぱふぅ
 * @開発環境	MinGW C++ + cURL + OpenSSL + Boost C++ Libraries
 *					+ WebView2Loader.dll + Leaflet + OSM (+ GoogleMap)
 * @参考URL		https://www.pahoo.org/e-soul/webtech/cpp01/cpp01-22-01.shtm
*/
// 初期化処理 ======================================================
#include <iostream>
#include <fstream>
#include <stdio.h>
#include <stdlib.h>
#include <tchar.h>
#define _USE_MATH_DEFINES
#include <time.h>
#include <math.h>
#include <sstream>
#include <string>
#include <locale>
#include <regex>
#include <winsock2.h>
#include <windows.h>
#include <shlobj.h>
#include <commctrl.h>
#include <objbase.h>
#include <wrl.h>
#include <richedit.h>

#include <curl/curl.h>
#include <boost/format.hpp>
#include <boost/property_tree/xml_parser.hpp>
#include <boost/algorithm/string.hpp>
#include "mystrings.h"
#include "apikey.h"
#include "WebView2.h"
#include "pahooWebView2.hpp"
#include "pahooCache.hpp"
#include "pahooGeocode.hpp"
#include "resource.h"

using namespace std;
using namespace boost;
using namespace boost::property_tree;

// 初期値(START) ==============================================================
#define MAKER		"pahoo.org"				// 作成者
#define APPNAME		"typhoonwin"			// アプリケーション名
#define APPNAMEJP	"最新の地震情報"		// アプリケーション名（日本語）
#define APPVERSION	"1.0.0"					// バージョン
#define APPYEAR		"2026"					// 作成年
#define REFERENCE	"https://www.pahoo.org/e-soul/webtech/cpp01/cpp01-22-01.shtm"	// 参考サイト

// ヘルプ・ファイル
#define HELPFILE    ".\\etc\\help.chm"

// デフォルト保存ファイル名
#define SAVEFILE	"tp_%04d%02d%02d_%02d.csv"

// キャッシュ・ディレクトリ
#define DIR_CACHE_FEED    "pcache_tw1\\"
#define DIR_CACHE_FEED_L  "pcache_tw2\\"
#define DIR_CACHE_DATA    "pcache_tw3\\"

// キャッシュ保持時間（分）
#define LIFE_CACHE_FEED    15
#define LIFE_CACHE_FEED_L  120
#define LIFE_CACHE_DATA    (60 * 24 * 14)

// 古い台風情報を捨てる条件（秒）
#define SCRAP_TIME  (60 * 60 * 12)

// 予報円を間引く条件（km）
#define THIN_OUT    50

// 気象庁防災情報 XML フィード URL
#define FEED    "https://www.data.jma.go.jp/developer/xml/feed/extra.xml"
#define FEED_L  "https://www.data.jma.go.jp/developer/xml/feed/extra_l.xml"

// マップ設定
#define MAP_ID      "map_id"
#define MAP_WIDTH   640
#define MAP_HEIGHT  480
#define DEF_LONGITUDE   137.0
#define DEF_LATITUDE     35.0
#define DEF_ZOOM         6
#define DEF_MAPTYPE     "GSISTD"
#define INFO_WIDTH      (int)(MAP_WIDTH * 0.75)
#define INFO_OFFSET_X   0
#define INFO_OFFSET_Y   -10

// 地図描画色
#define COLOR_NAME1    "#FF8800"    // 台風名称
#define COLOR_LINE     "#0000FF"    // 過去経路
#define COLOR_WIND1    "#FFFF00"    // 強風域
#define COLOR_WIND2    "#FF0000"    // 暴風域
#define COLOR_FORECAST "#FFFFFF"    // 予報円

// 基準座標（東京駅）
#define LATITUDE00   35.681111
#define LONGITUDE00 139.766667

// リストビュー列
#define MAX_TYPHOON 30

// 初期値(END) ===============================================================

// データ構造(START) ========================================================
// 風域の半径情報
struct TyphoonAxis {
	wstring direction;
	int     radius = 0;
};

// 台風の1時点データ
struct TyphoonRecord {
	wstring kind;               // 実況 / 予報 / 過去
	wstring dateTime;
	wstring typhoonClass;
	wstring areaClass;
	wstring intensityClass;
	wstring location;
	double  latitude  = 0.0;
	double  longitude = 0.0;
	wstring direction;
	wstring speed;
	int     pressure        = 0;
	int     centerWindSpeed = 0;
	int     maxWindSpeed    = 0;
	vector<TyphoonAxis> stormArea;
	vector<TyphoonAxis> windArea;
	vector<TyphoonAxis> forecastCircle;
};

// 台風1個分
struct TyphoonInfo {
	wstring name;
	wstring nameKana;
	bool    valid = false;
	vector<TyphoonRecord> records;
};

// 台風番号（文字列） → TyphoonInfo
using TyphoonItems = map<wstring, TyphoonInfo>;

// データ構造(END) =========================================================

// グローバル変数(START) ==================================================

HINSTANCE hInst;
HWND      hParent;
HWND      hWebView2;

bool      flagFinishProgram = false;

extern ICoreWebView2* webView2;

char    tmpFname[MAX_PATH + 1];
wchar_t wUri[MAX_PATH * 2 + 1];

unsigned hParent_X, hParent_Y;

string  ErrorMessage;
string  UserAgent;

pahooGeocode* pGC;

double Longitude = DEF_LONGITUDE;
double Latitude  = DEF_LATITUDE;
int    Zoom      = DEF_ZOOM;
string Maptype   = DEF_MAPTYPE;

// 台風情報格納
TyphoonItems gTyphoonItems;
vector<wstring> gTyphoonURLs;

// 発表日時・地図中心
wstring gTyphoonDt;
int     gTyphoonCount = 0;

// WebView2 動作モード
enum class eAction {
	GetLatLong,
	GetZoomType,
	GetLatLongZoomType,
	Finish
};
eAction selectAction = eAction::GetLatLongZoomType;

// グローバル変数(END) ====================================================

// サブ・プログラム =======================================================
/**
 * パラメータの初期化
 * @param	なし
 * @return	なし
 */
void initParameter(void) {
	Longitude  = DEF_LONGITUDE;
	Latitude   = DEF_LATITUDE;
	Zoom       = DEF_ZOOM;
	Maptype    = DEF_MAPTYPE;
	hParent_X  = 0;
	hParent_Y  = 0;
}

/**
 * パラメータの読み込み
 * @param	なし
 * @return	なし
 */
void loadParameter(void) {
	ptree pt;

	// 初期値設定
	initParameter();

	// XMLファイル読み込み
	try {
		xml_parser::read_xml(getMyPath(APPNAME) + APPNAME + ".xml", pt);

		// XML解釈
		try {
			// 形式チェック
			if (optional<string>str = pt.get_optional<string>("parameter")) {
			} else {
				return;
			}
			// パラメータ読み込み
			for (auto it : pt.get_child("parameter")) {
				string type= it.second.get_optional<string>("<xmlattr>.type").value();
				if (type == "latitude") {
					Latitude = stod(it.second.data());
				} else if (type == "longitude") {
					Longitude = stod(it.second.data());
				} else if (type == "zoom") {
					Zoom = stoi(it.second.data());
				} else if (type == "maptype") {
					Maptype = (string)it.second.data();
				} else if (type == "wx") {
					hParent_X = (unsigned)stoi(it.second.data());
				} else if (type == "wy") {
					hParent_Y = (unsigned)stoi(it.second.data());
				}
			}
		// 解釈失敗したら初期値設定
		} catch (xml_parser_error& e) {
			initParameter();
			return;
		}
	// 読み込み失敗したら初期値設定
	} catch (xml_parser_error& e) {
		initParameter();
		return;
	}

	// アプリケーション・ウィンドウの位置（デスクトップ範囲外なら原点移動）
	HWND hDesktop = GetDesktopWindow();
	WINDOWINFO windowInfo;
	windowInfo.cbSize = sizeof(WINDOWINFO);
	GetWindowInfo(hDesktop, &windowInfo);
	if (hParent_X >= (unsigned)windowInfo.rcWindow.right) {
		hParent_X = 0;
	}
	if (hParent_Y >= (unsigned)windowInfo.rcWindow.bottom) {
		hParent_Y = 0;
	}
}

/**
 * パラメータの保存
 * @param	なし
 * @return	なし
 */
void saveParameter(void) {
#ifndef CMDAPP
	// アプリケーション・ウィンドウの位置取得
	WINDOWINFO windowInfo;
	windowInfo.cbSize = sizeof(WINDOWINFO);
	GetWindowInfo(hParent, &windowInfo);
	hParent_X = (unsigned)windowInfo.rcWindow.left;
	hParent_Y = (unsigned)windowInfo.rcWindow.top;
	if (hParent_X >= (unsigned)windowInfo.rcWindow.right) {
		hParent_X = 0;
	}
	if (hParent_Y >= (unsigned)windowInfo.rcWindow.bottom) {
		hParent_Y = 0;
	}
#endif

	char lng[SIZE_BUFF + 1], lat[SIZE_BUFF + 1];
	snprintf(lng, SIZE_BUFF, "%.5f", Longitude);
	snprintf(lat, SIZE_BUFF, "%.5f", Latitude);

	// XMLファイルへ書き込む
	ptree pt;
	ptree& child1 = pt.add("parameter.param", lat);
	child1.add("<xmlattr>.type", "latitude");
	ptree& child2 = pt.add("parameter.param", lng);
	child2.add("<xmlattr>.type", "longitude");
	ptree& child3 = pt.add("parameter.param", to_string(Zoom));
	child3.add("<xmlattr>.type", "zoom");
	ptree& child4 = pt.add("parameter.param", Maptype);
	child4.add("<xmlattr>.type", "maptype");
	ptree& child5 = pt.add("parameter.param", (string)to_string(hParent_X));
	child5.add("<xmlattr>.type", "wx");
	ptree& child6 = pt.add("parameter.param", (string)to_string(hParent_Y));
	child6.add("<xmlattr>.type", "wy");

	const int indent = 4;
	write_xml(getMyPath(APPNAME) + APPNAME + ".xml", pt, std::locale(),
		xml_writer_make_settings<std::string>(' ', indent));
}

/**
 * パラメータの削除
 * @param	なし
 * @return	なし
 */
void delParameter(void) {
	remove((getMyPath(APPNAME) + APPNAME + ".xml").c_str());

	// 初期値設定
	initParameter();
}

/**
 * イベントハンドラ：バージョン表示ダイアログ
 * @param	HWND hDlg			ウィンドウ・ハンドラ
 * @paramm	UINT uMsg			メッセージ識別子
 * @param	WPARAM wParam		メッセージの最初のパラメータ
 * @paramL	PARAM lParam		メッセージの2番目のパラメータ
 * @return	INT_PTR CALLBACK	TRUE：メッセージ処理完了／FALSE：未完了
*/
INT_PTR CALLBACK processHelp(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam) {
	string help;

	switch(uMsg){
		// ダイアログ初期化
		case WM_INITDIALOG:
			CenterWindow(hDlg);
			// メッセージ作成
			help = (boost::format(R"(
%1%  バージョン %2%

本アプリケーションはMIT Licenseです．
商用を含む無償利用が可能です．自由に改造できます．
再配布の際は，下記の著作権表記，およびURLと本使用条件を必ず明記してください．

Copyright by (c)studio pahoo, %3%
https://www.pahoo.org/

なお，本アプリケーションの利用または改造することによって生じた得失ついては一切関知いたしません．気象庁防災情報XMLの変更・停止によって正常に機能しなくなる場合があります．また，二次利用先の組織・企業・団体の目的・内容・活動については一切関知いたしません．

本アプリケーションは，下記のライブラリ，フレームワーク，インターネット上のリソースを利用しています．
　MinGW（g++）／Boost C++ライブラリ／libcurl／OpenSSL+／
　WebView2Loader.dll／　Leaflet／OpenStreetMap／Googleマップ／
　気象庁防災情報XML
)")
% APPNAME
% APPVERSION
% APPYEAR
).str();
			setStrEditBox(hDlg, IDC_TEXT_HELP, help);
			break;

		// ボタン押下
		case WM_COMMAND:
			 switch (LOWORD(wParam)) {
				// 実行
				case IDC_BUTTON_OK:
					EndDialog(hDlg, 0);
					DestroyWindow(hDlg);
					break;
				default:
					return 1;
			}
			break;
		// プログラム終了
		case WM_CLOSE:
			EndDialog(hDlg, 0);
			DestroyWindow(hDlg);
			break;
	}
    return 0;
}

/**
 * ヘルプ・ダイアログを作成
 * @param  HWND hDlg		親ウィンドウ・ハンドラ
 * @param  DLGPROC handler	イベント・ハンドラ
 * @return なし
*/
void createHelp(HWND hDlg, DLGPROC handler) {
	DialogBox(hInst, MAKEINTRESOURCE(IDD_DIALOG_VER), hDlg, (DLGPROC)handler);
}

/**
 * XML→ptree変換
 * @param  sdtring& xml XML文字列
 * @return ptree
*/

/**
 * UTF-8文字列をwstringに変換する（ptreeから取り出した値の変換用）
 * @param  string& utf8  UTF-8文字列
 * @return wstring
 */
static wstring _XW(const string& utf8) {
	if (utf8.empty()) return wstring();
	int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, nullptr, 0);
	if (wlen <= 0) return wstring();
	wstring wide(wlen, L'\0');
	MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, &wide[0], wlen);
	if (!wide.empty() && wide.back() == L'\0') wide.pop_back();
	return wide;
}

static ptree xmlFromString(const string& xml) {
	stringstream ss(xml);
	ptree pt;
	xml_parser::read_xml(ss, pt);
	return pt;
}

/**
 * 座標変換可能かどうか
 * 例："+35.5+139.7" → (lat, lng)
 * @param  string& coord	経度・緯度の組み合わせ
 * @return bool true / false
*/
static bool parseCoordinate(const string& coord, double& lat, double& lng) {
	regex re("([\\+\\-][0-9]{1,2}\\.[0-9]+)([\\+\\-][0-9]{1,3}\\.[0-9]+)");
	smatch m;
	if (regex_search(coord, m, re)) {
		lat = stod(m[1].str());
		lng = stod(m[2].str());
		return true;
	}
	return false;
}

/**
 * ISO 8601時刻表記を日本ローカル時間に変換
 * @param  string& dt	ISO 8601時刻表記
 * @return time_t 日本ローカル時間
*/
static time_t parseISO8601(const string& dt) {
	struct tm t = {};
	sscanf(dt.c_str(), "%d-%d-%dT%d:%d:%d",
		   &t.tm_year, &t.tm_mon, &t.tm_mday,
		   &t.tm_hour, &t.tm_min, &t.tm_sec);
	t.tm_year -= 1900;
	t.tm_mon  -= 1;
	return mktime(&t);
}

/**
 * Axesノード → vector<TyphoonAxis>
 * @param  ptree& axesNode	Axesノード
 * @return vector<TyphoonAxis>
*/
static vector<TyphoonAxis> parseAxes(const ptree& axesNode) {
	vector<TyphoonAxis> result;
	for (auto& axisIt : axesNode) {
		if (axisIt.first != "jmx_eb:Axis") continue;
		const ptree& axis = axisIt.second;
		TyphoonAxis ta;
		try {
			ta.direction = _XW(axis.get<string>("jmx_eb:Direction.<xmlattr>.condition"));
		} catch (...) {}
		for (auto& radIt : axis.get_child("")) {
			if (radIt.first != "jmx_eb:Radius") continue;
			try {
				if (radIt.second.get<string>("<xmlattr>.unit") == "km")
					ta.radius = radIt.second.get_value<int>();
			} catch (...) {}
		}
		result.push_back(ta);
	}
	return result;
}

/**
 * 2地点間の大圏航路距離を求める
 * @param	double $lng1, $lat1  A地点の経度，緯度（世界測地系）
 * @param	double $lng2, $lat2  B地点の経度，緯度（世界測地系）
 * @return	double 大圏航路距離
 *
*/
static double greatCircleDistance(double lng1, double lat1,
								   double lng2, double lat2) {
	const double R   = 6371.0;
	const double D2R = M_PI / 180.0;
	double dlat = (lat2 - lat1) * D2R;
	double dlng = (lng2 - lng1) * D2R;
	double a = sin(dlat/2)*sin(dlat/2)
			 + cos(lat1*D2R)*cos(lat2*D2R)*sin(dlng/2)*sin(dlng/2);
	return R * 2.0 * atan2(sqrt(a), sqrt(1.0-a));
}

/**
 * 気象庁防災情報XMLから台風に関する情報URLを取得
 * @param	vector<wstring>& urls    URL格納用
 * @param	wstring& errmsg			エラーメッセージ格納用
 * @return	bool true：取得成功／false：取得失敗
*/
static bool jmaGetTyphoonURLs(vector<wstring>& urls, wstring& errmsg) {
	regex rePtw(
		"https?://www\\.data\\.jma\\.go\\.jp/developer/xml/data/"
		"([0-9_]+)VPTW6[0-9]+_[0-9]+\\.xml",
		regex_constants::icase);
	smatch mt;
	pahooCache* pCC;
	static string contents = "";

	// 高頻度フィードの解析
	pCC = new pahooCache(LIFE_CACHE_FEED, getMyPath(APPNAME) + DIR_CACHE_FEED, UserAgent);
	if (pCC->load(FEED, &contents) == FALSE) {
		errmsg = _SW("気象庁防災情報XMLにアクセスできません");
		delete pCC;
		return false;
	}
	{
		string::const_iterator it = contents.cbegin();
		while (regex_search(it, contents.cend(), mt, rePtw)) {
			wstring wurl = _UW(mt[0].str());
			if (find(urls.begin(), urls.end(), wurl) == urls.end())
				urls.push_back(wurl);
			it = mt.suffix().first;
		}
	}
	contents.clear();
	delete pCC;

	// 長期フィードの解析
	pCC = new pahooCache(LIFE_CACHE_FEED_L, getMyPath(APPNAME) + DIR_CACHE_FEED_L, UserAgent);
	if (pCC->load(FEED_L, &contents) == FALSE) {
		errmsg = _SW("気象庁防災情報XMLにアクセスできません");
		delete pCC;
		return false;
	}
	{
		string::const_iterator it = contents.cbegin();
		while (regex_search(it, contents.cend(), mt, rePtw)) {
			wstring wurl = _UW(mt[0].str());
			if (find(urls.begin(), urls.end(), wurl) == urls.end())
				urls.push_back(wurl);
			it = mt.suffix().first;
		}
	}
	contents.clear();
	delete pCC;

	if (urls.empty()) {
		errmsg = _SW("直近の台風情報はありません");
		return false;
	}
	sort(urls.rbegin(), urls.rend());
	return true;
}

/**
 * 新しい台風報かどうか
 * @param	string& xmlStr		気象庁防災情報XML
 * @param	TyphoonItems& items	台風情報を格納
 * @return	bool true：新しい情報
*/
static bool isNewTyphoon(const string& xmlStr, const TyphoonItems& items) {
	try {
		ptree pt = xmlFromString(xmlStr);
		auto numOpt = pt.get_optional<string>(
			"Report.Body.MeteorologicalInfos.MeteorologicalInfo"
			".Item.Kind.Property.TyphoonNamePart.Number");
		if (!numOpt || numOpt->empty()) return false;
		return (items.find(_XW(*numOpt)) == items.end());
	} catch (...) { return false; }
}

/**
 * 台風報取得（気象庁防災情報XMLから）
 * @param	TyphoonItems& items		台風情報を格納
 * @param	vector<wstring>& urls	情報XMLのURLを格納
 * @param	wstring& errmsg			エラーメッセージ格納用
 * @return	bool true：取得成功／false：失敗
*/
static bool getTyphoon(TyphoonItems& items,
					   vector<wstring>& urls,
					   wstring& errmsg) {
	map<wstring, bool> flagForecast;

	if (!jmaGetTyphoonURLs(urls, errmsg)) return false;

	pahooCache* pCC2 = new pahooCache(LIFE_CACHE_DATA,
				   getMyPath(APPNAME) + DIR_CACHE_DATA, UserAgent);

	// "Forecast NN時間後" にマッチ
	wregex rePat3(_SW("(") + wstring(L"\x4e88") + _SW("報)[ 　]*([0-9０-９]+時間後)"),
					  regex_constants::icase);

	for (const wstring& wptwUrl : urls) {
		string vptw = _WS(wptwUrl);
		string xmlStr;

/**
		// デバッグ：気象庁防災情報URL
		ofstream _dbg(".\\typhoonwin_url.txt", ios::app);
		_dbg << "[urll] " << vptw << endl;
		_dbg.close();
**/
		if (pCC2->load(vptw.c_str(), &xmlStr) == FALSE) {
			errmsg = _SW("気象庁防災情報XMLから台風情報を取得できません");
			delete pCC2;
			return false;
		}

		ptree pt;
		try { pt = xmlFromString(xmlStr); }
		catch (...) {
			errmsg = _SW("気象庁防災情報XMLのパースに失敗しました");
			delete pCC2;
			return false;
		}

		if (!pt.get_child_optional("Report.Body.MeteorologicalInfos")) {
			errmsg = _SW("気象庁防災情報XMLから台風情報を取得できません");
			delete pCC2;
			return false;
		}

		bool    isNew  = isNewTyphoon(xmlStr, items);
		bool    flag_f = false;
		wstring curNum;

		for (auto& infosIt : pt.get_child("Report.Body")) {
			if (infosIt.first != "MeteorologicalInfos") continue;

			for (auto& infoIt : infosIt.second) {
				if (infoIt.first != "MeteorologicalInfo") continue;
				const ptree& info = infoIt.second;

				string dtTypeStr;
				try { dtTypeStr = info.get<string>("DateTime.<xmlattr>.type"); }
				catch (...) { continue; }
				wstring dtType = _XW(dtTypeStr);

				string dtStr;
				try { dtStr = info.get<string>("DateTime"); } catch (...) {}

				// ── 実況 ──
				if (dtType == _SW("実況")) {
					for (auto& kindIt : info.get_child("Item")) {
						if (kindIt.first != "Kind") continue;
						const ptree& kind = kindIt.second;

						string propTypeStr;
						try { propTypeStr = kind.get<string>("Property.Type"); }
						catch (...) { continue; }
						wstring propType = _XW(propTypeStr);

						// 呼称
						if (propType == _SW("呼称")) {
							try {
								string numStr = kind.get<string>(
									"Property.TyphoonNamePart.Number");
								if (numStr.empty()) break;
								curNum = _XW(numStr);

								if (isNew && items.find(curNum) == items.end()) {
									TyphoonInfo ti;
									ti.name     = _XW(kind.get<string>(
										"Property.TyphoonNamePart.Name", ""));
									ti.nameKana = _XW(kind.get<string>(
										"Property.TyphoonNamePart.NameKana", ""));
									TyphoonRecord rec;
									rec.kind     = _SW("実況");
									rec.dateTime = _XW(dtStr);
									time_t tt = time(nullptr) - parseISO8601(dtStr);
									ti.valid = (tt < SCRAP_TIME);
/**
									{
										ofstream _dbg(".\\typhoonwin_url.txt", ios::app);
										_dbg << "[登録] num=" << _WS(curNum)
										     << " dt=" << dtStr
										     << " age=" << (long long)tt << "sec"
										     << " valid=" << ti.valid << endl;
										_dbg.close();
									}
**/
									ti.records.push_back(rec);
									items[curNum] = ti;
									flagForecast[curNum] = false;
								} else if (!isNew) {
									string numStr2 = kind.get<string>(
										"Property.TyphoonNamePart.Number", "");
									if (!numStr2.empty()) {
										curNum = _XW(numStr2);
										TyphoonRecord rec;
										rec.kind     = _SW("過去");
										rec.dateTime = _XW(dtStr);
										items[curNum].records.push_back(rec);
									}
								}
							} catch (...) {}

						// 階級
						} else if (propType == _SW("階級") && !curNum.empty()
								   && !items[curNum].records.empty()) {
							TyphoonRecord& rec = items[curNum].records.back();
							try {
								rec.typhoonClass   = _XW(kind.get<string>("Property.ClassPart.jmx_eb:TyphoonClass",   ""));
								rec.areaClass      = _XW(kind.get<string>("Property.ClassPart.jmx_eb:AreaClass",      ""));
								rec.intensityClass = _XW(kind.get<string>("Property.ClassPart.jmx_eb:IntensityClass", ""));
/**
								{
									ofstream _dbg(".\\typhoonwin_url.txt", ios::app);
									_dbg << "  [階級] num=" << _WS(curNum)
									     << " class=\"" << _WS(rec.typhoonClass) << "\"" << endl;
									_dbg.close();
								}
**/
							} catch (...) {}
						// 中心
						} else if (propType == _SW("中心") && !curNum.empty()
								   && !items[curNum].records.empty()) {
							TyphoonRecord& rec = items[curNum].records.back();
							try { rec.location = _XW(kind.get<string>("Property.CenterPart.Location", "")); }
							catch (...) {}
							try {
								for (auto& c : kind.get_child("Property.CenterPart")) {
									if (c.first != "jmx_eb:Coordinate") continue;
									double la, ln;
									if (parseCoordinate(c.second.get_value<string>(), la, ln)) {
										rec.latitude  = la;
										rec.longitude = ln;
									}
								}
							} catch (...) {}
							try {
								for (auto& d : kind.get_child("Property.CenterPart")) {
									if (d.first != "jmx_eb:Direction") continue;
									rec.direction = _XW(d.second.get_value<string>());
									break;
								}
							} catch (...) {}
							try {
								for (auto& s : kind.get_child("Property.CenterPart")) {
									if (s.first != "jmx_eb:Speed") continue;
									string cond, unit;
									try { cond = s.second.get<string>("<xmlattr>.condition"); } catch (...) {}
									try { unit = s.second.get<string>("<xmlattr>.unit"); }      catch (...) {}
									if (unit == "km/h") rec.speed = _XW(s.second.get_value<string>());
								}
							} catch (...) {}
							try { rec.pressure = kind.get<int>("Property.CenterPart.jmx_eb:Pressure", 0); }
							catch (...) {}

						// 風（暴風域・強風域）
						} else if (propType == _SW("風") && !curNum.empty()
								   && !items[curNum].records.empty()) {
							TyphoonRecord& rec = items[curNum].records.back();
							try {
								for (auto& ws : kind.get_child("Property.WindPart")) {
									if (ws.first != "jmx_eb:WindSpeed") continue;
									string cond, wtype, unit;
									try { cond  = ws.second.get<string>("<xmlattr>.condition"); } catch (...) {}
									try { wtype = ws.second.get<string>("<xmlattr>.type"); }      catch (...) {}
									try { unit  = ws.second.get<string>("<xmlattr>.unit"); }      catch (...) {}
									int v = ws.second.get_value<int>(0);
									wstring wtype_w = _XW(wtype);
									bool isMaxInstant = (wtype_w.find(_SW("瞬間")) != wstring::npos);
									if (!isMaxInstant && !cond.empty() && unit == "m/s") rec.centerWindSpeed = v;
									else if (isMaxInstant && unit == "m/s") rec.maxWindSpeed = v;
								}
							} catch (...) {}
							try {
								for (auto& wap : kind.get_child("Property")) {
										if (wap.first != "WarningAreaPart") continue;
									wstring atype = _XW(wap.second.get<string>("<xmlattr>.type", ""));
									auto axesCh = wap.second.get_child_optional("jmx_eb:Circle.jmx_eb:Axes");
									if (!axesCh) continue;
									auto axes = parseAxes(*axesCh);
									if (atype == wstring(L"\x66b4\x98a8\x57df")) rec.stormArea = axes;
									else if (atype == wstring(L"\x5f37\x98a8\x57df")) rec.windArea  = axes;
								}
							} catch (...) {}
						}
					} // for kindIt（実況）

				// ── 予報 ──
				} else {
					wsmatch m3;
					if (!regex_search(dtType, m3, rePat3)) continue;
					if (curNum.empty()) continue;
					if (!isNew && flagForecast.count(curNum) && flagForecast[curNum]) continue;

					TyphoonRecord rec;
					rec.kind     = L"\u4e88\u5831";
					rec.dateTime = _XW(dtStr);
					flag_f = true;

					for (auto& kindIt : info.get_child("Item")) {
						if (kindIt.first != "Kind") continue;
						const ptree& kind = kindIt.second;
						string propTypeStr;
						try { propTypeStr = kind.get<string>("Property.Type"); } catch (...) { continue; }
						wstring propType = _XW(propTypeStr);

						if (propType == _SW("階級")) {
							try {
								rec.typhoonClass   = _XW(kind.get<string>("Property.ClassPart.jmx_eb:TyphoonClass",   ""));
								rec.areaClass      = _XW(kind.get<string>("Property.ClassPart.jmx_eb:AreaClass",      ""));
								rec.intensityClass = _XW(kind.get<string>("Property.ClassPart.jmx_eb:IntensityClass", ""));
							} catch (...) {}
						} else if (propType == _SW("中心")) {
							try {
								for (auto& bp : kind.get_child("Property.CenterPart.ProbabilityCircle")) {
									if (bp.first != "jmx_eb:BasePoint") continue;
									double la, ln;
									if (parseCoordinate(bp.second.get_value<string>(), la, ln)) {
										rec.latitude  = la;
										rec.longitude = ln;
									}
								}
								auto axesCh = kind.get_child_optional(
									"Property.CenterPart.ProbabilityCircle.jmx_eb:Axes");
								if (axesCh) rec.forecastCircle = parseAxes(*axesCh);
							} catch (...) {}
						} else if (propType == _SW("風")) {
							try {
								for (auto& ws : kind.get_child("Property.WindPart")) {
									if (ws.first != "jmx_eb:WindSpeed") continue;
									string cond, wtype, unit;
									try { cond  = ws.second.get<string>("<xmlattr>.condition"); } catch (...) {}
									try { wtype = ws.second.get<string>("<xmlattr>.type"); }      catch (...) {}
									try { unit  = ws.second.get<string>("<xmlattr>.unit"); }      catch (...) {}
									int v = ws.second.get_value<int>(0);
									wstring wtype_w = _XW(wtype);
									bool isMaxInstant = (wtype_w.find(_SW("瞬間")) != wstring::npos);
									if (!isMaxInstant && !cond.empty() && unit == "m/s") rec.centerWindSpeed = v;
									else if (isMaxInstant && unit == "m/s") rec.maxWindSpeed = v;
								}
							} catch (...) {}
						}
					}
					items[curNum].records.push_back(rec);
				} // else 予報
			} // for infoIt
		} // for infosIt

		if (flag_f && !curNum.empty())
			flagForecast[curNum] = true;
	} // for url

	delete pCC2;
/**
	// デバッグ：取得した items をダンプ
	{
		ofstream _dbg(".\\typhoonwin_url.txt", ios::app);
		_dbg << "[getTyphoon finished] items=" << items.size() << endl;
		for (auto& kv : items) {
			const TyphoonRecord& r0 = kv.second.records.empty()
				? TyphoonRecord() : kv.second.records[0];
			_dbg << "  num=" << _WS(kv.first)
			     << " name=" << _WS(kv.second.nameKana)
			     << " class=" << _WS(r0.typhoonClass)
			     << " valid=" << kv.second.valid
			     << " recs=" << kv.second.records.size() << endl;
		}
		_dbg.close();
	}
**/
	return true;
}

/**
 * 中心からズレている円の中心座標・半径を求める
 * @param	double longitude			経度（世界測地系）
 * @param	double latitude				緯度（世界測地系）
 * @param	vector<TyphoonAxis>& axes	台風情報
 * @param	double& outLat				中心の緯度
 * @param	double& outLng				中心の経度
 * param	double& outRadius			半径（km）
 * @return	なし
 * @Note
 *	方位テーブル（台風円のズレ計算用）
 *	SJIS で 0x5C が含まれる文字（予・暴など）と同様の問題を回避するため
 *	方位文字列は wchar_t リテラルで定義する
*/
static void shiftCircle(double latitude, double longitude,
						const vector<TyphoonAxis>& axes,
						double& outLat, double& outLng, double& outRadius) {
	// 方位テーブル: {正方向, 逆方向, 角度(度)}
	static const wstring sc_d1[] = {
		_SW("北"),_SW("北東"),_SW("東"),_SW("南東"),
		_SW("南"),_SW("南西"),_SW("西"),_SW("北西") };
	static const wstring sc_d2[] = {
		_SW("南"),_SW("南西"),_SW("西"),_SW("北西"),
		_SW("北"),_SW("北東"),_SW("東"),_SW("南東") };
	static const double sc_deg[] = {
		0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0 };
	static const int sc_n = 8;

	for (const TyphoonAxis& a1 : axes) {
		if (a1.direction == wstring(_SW("全域"))) {
			outLat = latitude; outLng = longitude; outRadius = a1.radius;
			return;
		}
		for (int sc_i = 0; sc_i < sc_n; sc_i++) {
			if (a1.direction != wstring(sc_d1[sc_i])) continue;
			double sc_r1 = a1.radius, sc_r2 = sc_r1, sc_th = 0.0;
			for (const TyphoonAxis& a2 : axes) {
				if (a2.direction == wstring(sc_d2[sc_i])) {
					sc_r2 = a2.radius; sc_th = sc_deg[sc_i]; break;
				}
			}
			double sc_r   = (sc_r1 + sc_r2) / 2.0;
			double sc_sh  = (sc_r1 - sc_r) * 1000.0; // m
			double sc_d2r = M_PI / 180.0;
			outLat    = latitude  + sc_sh * cos(sc_th * sc_d2r) / 111111.0;
			outLng    = longitude + sc_sh * sin(sc_th * sc_d2r) / (111111.0 * cos(latitude * sc_d2r));
			outRadius = sc_r;
			return;
		}
	}
	outLat    = latitude;
	outLng    = longitude;
	outRadius = axes.empty() ? 0.0 : (double)axes[0].radius;
}

/**
 * 暴風域、強風域、予報円の描画スクリプトを生成する
 * @param	TyphoonItems& items		台風情報
 * @return	string スクリプト
*/
static string jsTyphoonMapScript(const TyphoonItems& items) {
	string js;
	js = "if (typeof __typhoonDrawn === 'undefined') { __typhoonDrawn = true;\n";
	for (const auto& kv : items) {
		const TyphoonInfo& info = kv.second;
		if (info.records.empty()) continue;
		const TyphoonRecord& rec0 = info.records[0];
		if (rec0.typhoonClass.find(_SW("台風")) == wstring::npos) continue;
		if (!info.valid) continue;

		// 暴風域（赤）
		if (!rec0.stormArea.empty()) {
			double la, ln, rad;
			shiftCircle(rec0.latitude, rec0.longitude, rec0.stormArea, la, ln, rad);
			rad *= 1000.0;
			js += (boost::format(
				"L.circle([%1%, %2%], {radius: %3%, color: '%4%', weight: 2, fillOpacity: 0.2}).addTo(map);\n")
				% la % ln % (int)rad % COLOR_WIND2).str();
		}
		// 強風域（黄）
		if (!rec0.windArea.empty()) {
			double la, ln, rad;
			shiftCircle(rec0.latitude, rec0.longitude, rec0.windArea, la, ln, rad);
			rad *= 1000.0;
			js += (boost::format(
				"L.circle([%1%, %2%], {radius: %3%, color: '%4%', weight: 2, fillColor: '%4%', fillOpacity: 0.15}).addTo(map);\n")
				% la % ln % (int)rad % COLOR_WIND1).str();
		}

		// 経路ポイントリスト
		string points = (boost::format("[%1%, %2%]") % rec0.latitude % rec0.longitude).str();
		double lat0 = rec0.latitude, lng0 = rec0.longitude;

		for (size_t i = 1; i < info.records.size(); i++) {
			const TyphoonRecord& rec = info.records[i];
			if (rec.kind == L"\u4e88\u5831" && !rec.forecastCircle.empty()) {
				double dd = greatCircleDistance(lng0, lat0, rec.longitude, rec.latitude);
				if (dd > THIN_OUT) {
					double la, ln, rad;
					shiftCircle(rec.latitude, rec.longitude, rec.forecastCircle, la, ln, rad);
					rad *= 1000.0;
					// 予報円（白）
					js += (boost::format(
						"L.circle([%1%, %2%], {radius: %3%, color: '%4%', weight: 2, fill: false}).addTo(map);\n")
						% la % ln % (int)rad % COLOR_FORECAST).str();
					// 予報日時ラベル
					string dtStr = _WS(rec.dateTime);
					smatch dtm;
					if (regex_search(dtStr, dtm, regex("[0-9]{4}-[0-9]{2}-([0-9]{2})T([0-9]{2})"))) {
						string label = dtm[1].str() + "日" + dtm[2].str() + "時";
						js += (boost::format(
							"L.marker([%1%, %2%], {icon: L.divIcon({html: '<span style=\"color:%3%;font-size:13px;font-weight:bold;white-space:nowrap;text-shadow:1px 1px 2px #000;\">%4%</span>', className: '', iconAnchor: [25, -5]})}).addTo(map);\n")
							% (la - rad/111111.0) % ln % COLOR_FORECAST % label).str();
					}
					lat0 = rec.latitude; lng0 = rec.longitude;
				}
			} else if (rec.kind == _SW("過去")) {
				points += (boost::format(", [%1%, %2%]") % rec.latitude % rec.longitude).str();
			}
		}
		// 過去経路（青線）
		js += (boost::format(
			"L.polyline([%1%], {color: '%2%', weight: 3, opacity: 0.9}).addTo(map);\n")
			% points % COLOR_LINE).str();
	}
	js += "}\n";
	return js;
}

/**
 * 台風情報からマップ描画用情報を生成する
 * @param	TyphoonItems& items		台風情報
 * @param	wstring& outDt			
 * @return	int 台風の数
*/
static int buildTyphoonMapInfo(const TyphoonItems& items, wstring& outDt) {
	static const int zooms[] = { 10000, 5000, 3000, 1000, 500, 300 };
	static const int zoomsN  = (int)(sizeof(zooms) / sizeof(zooms[0]));

	outDt = L"";

	double distance = 99999.0;
	bool   first    = true;
	int    cnt      = 0;

	for (const auto& kv : items) {
		const wstring&     nn   = kv.first;
		const TyphoonInfo& info = kv.second;
		if (info.records.empty()) continue;
		const TyphoonRecord& rec0 = info.records[0];
/**
		// デバッグ：スキップ理由
		{
			ofstream _dbg(".\\typhoonwin_url.txt", ios::app);
			_dbg << "[buildMap] num=" << _WS(nn)
			     << " class=\"" << _WS(rec0.typhoonClass) << "\""
			     << " valid=" << info.valid
			     << " recs=" << info.records.size() << endl;
			_dbg.close();
		}
**/
		if (rec0.typhoonClass.find(_SW("台風")) == wstring::npos) continue;
		if (!info.valid) continue;

		// 台風番号 (YYNN形式 → NN部)
		wstring numStr = nn.length() >= 4 ? nn.substr(2, 2) : nn;
		int typhoonNo  = 0;
		try { typhoonNo = stoi(_WS(numStr)); } catch (...) {}
		wstring name = (boost::wformat(_SW("台風%1%号(%2%)")) % typhoonNo % info.nameKana).str();

		// 発表日時（最初の台風のみ）
		if (first) {
			string dtStr = _WS(rec0.dateTime);
			smatch m;
			if (regex_search(dtStr, m,
				regex("([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):")))
			{
				outDt = (boost::wformat(_SW("%1%年%2%月%3%日%4%時"))
						 % stoi(m[1]) % stoi(m[2]) % stoi(m[3]) % stoi(m[4])).str();
			}
			first = false;
		}

		wstring areaClass      = rec0.areaClass.empty()      ? L"-" : rec0.areaClass;
		wstring intensityClass = rec0.intensityClass.empty() ? L"-" : rec0.intensityClass;

		wstring latStr = rec0.latitude  >= 0
			? (boost::wformat(_SW("北緯%.1f度")) % rec0.latitude).str()
			: (boost::wformat(_SW("南緯%.1f度")) % -rec0.latitude).str();
		wstring lngStr = rec0.longitude >= 0
			? (boost::wformat(_SW("東経%.1f度")) % rec0.longitude).str()
			: (boost::wformat(_SW("西経%.1f度")) % -rec0.longitude).str();

		wstring dirStr = rec0.direction.empty() ? L"" : rec0.direction + _SW("へ");
		wstring spdStr;
		{
			string sv = _WS(rec0.speed);
			if (regex_match(sv, regex("^[0-9]+$")))
				spdStr = (boost::wformat(_SW("時速%1%km")) % _UW(sv)).str();
			else
				spdStr = rec0.speed;
		}
		wstring maxWS = rec0.maxWindSpeed > 0
			? to_wstring(rec0.maxWindSpeed) + L"m/s" : L"-";
		wstring cwWS  = rec0.centerWindSpeed > 0
			? to_wstring(rec0.centerWindSpeed) + L"m/s" : L"-";

		// マーカー設定
		if (cnt < __SIZE_PPOINTS) {
			pGC->Ppoints[cnt].latitude   = rec0.latitude;
			pGC->Ppoints[cnt].longitude  = rec0.longitude;
			pGC->Ppoints[cnt].address    = L"";
			pGC->Ppoints[cnt].icon       = "";
			pGC->Ppoints[cnt].label      =
					(boost::wformat(_SW("台%1%")) % typhoonNo).str();
			pGC->Ppoints[cnt].label_color  = COLOR_NAME1;
			pGC->Ppoints[cnt].label_size   = "16";
			pGC->Ppoints[cnt].label_weight = "bold";

			// 情報ウィンドウ
			pGC->Ppoints[cnt].description =
					(boost::wformat(wstring(L"<b>%1%</b><br>")
						+ _SW("大きさ：%2%<br>強さ：%3%<br>")
						+ _SW("%4%，%5%<br>（%6%）<br>")
						+ _SW("進路：%7%%8%<br>")
						+ _SW("中心気圧：%9%hPa<br>")
						+ _SW("最大風速：%10%<br>")
						+ _SW("最大瞬間風速：%11%"))
				% name % areaClass % intensityClass
				% latStr % lngStr
				% (rec0.location.empty() ? wstring() : rec0.location)
				% dirStr % spdStr
				% rec0.pressure % cwWS % maxWS).str();
		}

		// 地図中心候補：3日以内の予報円中心から東京駅に近い方を選ぶ
		double lat1 = rec0.latitude, lng1 = rec0.longitude;
		for (size_t m2 = 1; m2 < info.records.size(); m2++) {
			if (info.records[m2].kind == L"\u4e88\u5831") {
				lat1 = info.records[m2].latitude;
				lng1 = info.records[m2].longitude;
				if (m2 >= 3) break;
			}
		}
		double dist = greatCircleDistance(lng1, lat1, LONGITUDE00, LATITUDE00);
		if (dist < distance) {
			Latitude   = lat1;
			Longitude  = lng1;
			distance   = dist;
			double d2  = greatCircleDistance(lng1, lat1, rec0.longitude, rec0.latitude);
			Zoom = zoomsN + 1;
			for (int zi = 0; zi < zoomsN; zi++) {
				if (d2 >= zooms[zi]) { Zoom = zi + 1; break; }
			}
		}

		cnt++;
	}

	return cnt;
}

/**
 * 台風情報をグローバル変数に代入する
 * @param	なし
 * @return	bool true：代入成功／false：失敗
*/
static bool loadTyphoonData(void) {
/**
	// デバッグ：URLログ初期化
	{
		time_t _now = time(nullptr);
		char _tbuf[32];
		strftime(_tbuf, sizeof(_tbuf), "%Y/%m/%d %H:%M:%S", localtime(&_now));
		ofstream _dbg(".\\typhoonwin_url.txt");
		_dbg << "=== typhoonwin debug log " << _tbuf << " ===" << endl;
		_dbg.close();
	}
**/
	gTyphoonItems.clear();
	gTyphoonURLs.clear();
	gTyphoonCount = 0;
	gTyphoonDt    = L"";
	ErrorMessage  = "";

	// pGC の Ppoints を初期化
	for (int i = 0; i < __SIZE_PPOINTS; i++) {
		pGC->Ppoints[i].latitude     = 0.0;
		pGC->Ppoints[i].longitude    = 0.0;
		pGC->Ppoints[i].description  = L"";
		pGC->Ppoints[i].title        = L"";
		pGC->Ppoints[i].address      = L"";
		pGC->Ppoints[i].icon         = "";
		pGC->Ppoints[i].label        = L"";
		pGC->Ppoints[i].label_size   = "";
		pGC->Ppoints[i].label_weight = "";
		pGC->Ppoints[i].label_color  = "";
	}

	wstring errmsg;
	if (!getTyphoon(gTyphoonItems, gTyphoonURLs, errmsg)) {
		ErrorMessage = _WS(errmsg);
		return false;
	}

	gTyphoonCount = buildTyphoonMapInfo(gTyphoonItems, gTyphoonDt);

	if (gTyphoonCount == 0) {
		Longitude = DEF_LONGITUDE;
		Latitude  = DEF_LATITUDE;
		Zoom      = DEF_ZOOM;
		Maptype   = DEF_MAPTYPE;
		if (gTyphoonDt.empty()) {
			time_t now = time(nullptr);
			struct tm* t = localtime(&now);
			gTyphoonDt = (boost::wformat(_SW("%1%年%2%月%3%日%4%時"))
						  % (t->tm_year+1900) % (t->tm_mon+1)
						  % t->tm_mday % t->tm_hour).str();
		}
	}
	return true;
}

/**
 * 地図表示用HTML生成
 * @param	なし
 * @return	string 生成したHTML文
 */
static string makeMapHTML(void) {
	// Leaflet ベースマップ + 台風円・経路スクリプト
	string typhoonJs = jsTyphoonMapScript(gTyphoonItems);

	// マーカー（台風現在位置）
	string script = pGC->makeMapLeaflet(
		MAP_ID, Longitude, Latitude, Zoom, Maptype,
		pGC->Ppoints, (size_t)gTyphoonCount,
		typhoonJs, "",          // call1 に台風描画スクリプトを挿入
		INFO_WIDTH, INFO_OFFSET_X, INFO_OFFSET_Y);
	// makeMapLeaflet が生成した window.onload 末尾に setTimeout を注入
	{
		size_t pos = script.rfind("}\n</script>");
		if (pos != string::npos) {
			script.insert(pos, "\tsetTimeout(function(){ map.fire('moveend'); }, 200);\n");
		}
	}

	return (boost::format(R"(<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="SJIS">
<title>%1%</title>
<meta name="author" content="studio pahoo" />
<meta name="ROBOTS" content="NOINDEX,NOFOLLOW" />
<meta http-equiv="pragma" content="no-cache">
<meta http-equiv="cache-control" content="no-cache">
%3%
</head>
<body>
<div id="%4%" style="width:%2%px; height:%5%px;"></div>
<form>
<input id="latitude"  type="hidden" value="%6%" />
<input id="longitude" type="hidden" value="%7%" />
<input id="zoom"      type="hidden" value="%8%" />
<input id="maptype"   type="hidden" value="%9%" />
</form>
</body>
</html>
)")
% APPNAMEJP     // %1  <title>
% MAP_WIDTH     // %2  地図の幅
% script        // %3  Leaflet CSS+JS
% MAP_ID        // %4  地図 div ID
% MAP_HEIGHT    // %5  地図高さ
% Latitude      // %6  hidden latitude
% Longitude     // %7  hidden longitude
% Zoom          // %8  hidden zoom
% Maptype       // %9  hidden maptype
).str();
}

/**
 * エラーメッセージをHTML文にする
 * @param	なし
 * @return	string HTML文
*/
static string makeErrorHTML(void) {
	return (boost::format(R"(<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="SJIS"><title>%2%</title>
<meta http-equiv="pragma" content="no-cache">
</head>
<body>
<p style="color:red; font-size:large;">%1%</p>
</body>
</html>
)")
% ErrorMessage % APPNAMEJP).str();
}

/**
 * ブラウザ・コントロールを表示
 * @param	wstring info 情報ウィンドウに表示するテキスト
 * @param	char* tmpname 読み込むHTMLファイル名
 * @return	なし
*/
static void viewBrowser(const char* tmpname) {
	string html = (ErrorMessage == "" && !pGC->isError())
				  ? makeMapHTML()
				  : makeErrorHTML();
	// WebView2 表示用テンポラリファイルへ書き込む
	ofstream ofs(tmpname);
	ofs << html;
	ofs.close();

/**
	// デバッグ用：カレントディレクトリに HTML ファイルを保存する
	{
		ofstream dbg(".\\typhoonwin_debug.html");
		dbg << html;
		dbg.close();
	}
**/

	if (isWeb2Ready()) webView2->Reload();
}

// 台風一覧作成（リストビュー） ============================================
// リストビューの列
enum {
	COL_TW_NO,
	COL_TW_NAME,
	COL_TW_LOCATION,
	COL_TW_PRESSURE,
	COL_TW_MAXWIND,
	COL_TW_DIRECTION
};

/**
 * 地震情報一覧フレーム作成
 * @param	HWND hWnd	ウィンドウハンドル
 * @return	なし
*/
static void makeListViewFrame(HWND hWnd) {
	LVCOLUMNA lvcol;
	lvcol.mask = LVCF_TEXT | LVCF_SUBITEM | LVCF_WIDTH | LVCF_FMT;

	auto addCol = [&](int col, int w, const char* txt) {
		lvcol.cx      = w;
		lvcol.fmt     = LVCFMT_CENTER;
		lvcol.pszText = const_cast<char*>(txt);
		SendMessage(hWnd, LVM_INSERTCOLUMNA, col, (WPARAM)&lvcol);
	};
	addCol(COL_TW_NO,        40,  "番号");
	addCol(COL_TW_NAME,     130,  "名称");
	addCol(COL_TW_LOCATION, 120,  "位置");
	addCol(COL_TW_PRESSURE,  70,  "中心気圧");
	addCol(COL_TW_MAXWIND,   80,  "最大瞬間風速");
	addCol(COL_TW_DIRECTION, 120, "進路");

	DWORD dwStyle = ListView_GetExtendedListViewStyle(hWnd);
	dwStyle |= LVS_EX_GRIDLINES | LVS_EX_FULLROWSELECT;
	ListView_SetExtendedListViewStyle(hWnd, dwStyle);
}

/**
 * 台風情報一覧表示
 * @param	HWND hWnd	ウィンドウハンドル
 * @return	なし
*/
static void makeListView(HWND hWnd) {
	// 既存行を全削除
	ListView_DeleteAllItems(hWnd);

	int row = 0;
	for (const auto& kv : gTyphoonItems) {
		const TyphoonInfo& info = kv.second;
		if (info.records.empty()) continue;
		const TyphoonRecord& rec0 = info.records[0];
		if (rec0.typhoonClass.find(_SW("台風")) == wstring::npos) continue;
		if (!info.valid) continue;

		wstring nn = kv.first;
		int typhoonNo = 0;
		try { typhoonNo = stoi(_WS(nn.length() >= 4 ? nn.substr(2,2) : nn)); } catch (...) {}

		LVITEMA item;
		item.mask     = LVIF_TEXT;
		item.iItem    = row;
		item.iSubItem = COL_TW_NO;
		string noStr  = to_string(typhoonNo) + "号";
		item.pszText  = const_cast<char*>(noStr.c_str());
		SendMessage(hWnd, LVM_INSERTITEMA, 0, (WPARAM)&item);

		auto setSubItem = [&](int sub, const string& s) {
			item.iSubItem = sub;
			item.pszText  = const_cast<char*>(s.c_str());
			SendMessage(hWnd, LVM_SETITEMA, 0, (WPARAM)&item);
		};

		string nameStr = _WS((boost::wformat(_SW("台風%1%号(%2%)"))
							  % typhoonNo % info.nameKana).str());
		setSubItem(COL_TW_NAME,     nameStr);
		setSubItem(COL_TW_LOCATION, _WS(rec0.location));

		string pstr = rec0.pressure > 0
			? to_string(rec0.pressure) + "hPa" : "-";
		setSubItem(COL_TW_PRESSURE, pstr);

		string mwstr = rec0.maxWindSpeed > 0
			? to_string(rec0.maxWindSpeed) + "m/s" : "-";
		setSubItem(COL_TW_MAXWIND, mwstr);

		string dirStr = _WS(rec0.direction);
		if (!dirStr.empty()) dirStr += "へ";
		string spdStr = _WS(rec0.speed);
		if (regex_match(spdStr, regex("^[0-9]+$")))
			spdStr = "時速" + spdStr + "km";
		setSubItem(COL_TW_DIRECTION, dirStr + spdStr);

		row++;
		if (row >= MAX_TYPHOON) break;
	}
}

/**
 * タイトル表示
 * @param	HWND hDlg	親ウィンドウ・ハンドラ
 * @return	なし
*/
static void makeTitle(HWND hDlg) {
	string title;
	if (gTyphoonCount == 0) {
		title = (boost::format("●台風情報　%1%現在，台風情報はありません．")
				  % _WS(gTyphoonDt)).str();
	} else {
		title = (boost::format("●台風情報　%1%現在")
				  % _WS(gTyphoonDt)).str();
	}
	setStrEditBox(hDlg, IDC_TEXT_TYPHOON, title);
	static HFONT hFontTyphoon = NULL;
	if (!hFontTyphoon) {
		hFontTyphoon = CreateFont(
			-MulDiv(16, GetDeviceCaps(GetDC(NULL), LOGPIXELSY), 72),
			0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE,
			SHIFTJIS_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
			DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE,
			TEXT("MS Shell Dlg"));
	}
	SendDlgItemMessage(hDlg, IDC_TEXT_TYPHOON, WM_SETFONT,
		(WPARAM)hFontTyphoon, TRUE);
}


// 保存・コピー =============================================================
/**
 * 一覧情報をCSVファイルに保存する：保存処理
 * @param	const char* fname 保存ファイル名
 * @return	なし
 */
void __saveCSV(const char *fname) {
	ofstream outputfile(fname);

	// ラベル
	outputfile << "\"" << "番号"         << "\",";
	outputfile << "\"" << "名称"         << "\",";
	outputfile << "\"" << "位置"         << "\",";
	outputfile << "\"" << "中心気圧"     << "\",";
	outputfile << "\"" << "最大瞬間風速" << "\",";
	outputfile << "\"" << "進路"         << "\",";
	outputfile << endl;

	// データ本体
	int row = 0;
	for (const auto& kv : gTyphoonItems) {
		const TyphoonInfo& info = kv.second;
		if (info.records.empty()) continue;
		const TyphoonRecord& rec0 = info.records[0];
		if (rec0.typhoonClass.find(_SW("台風")) == wstring::npos) continue;
		if (!info.valid) continue;

		// 番号
		wstring nn = kv.first;
		int typhoonNo = 0;
		try { typhoonNo = stoi(_WS(nn.length() >= 4 ? nn.substr(2,2) : nn)); } catch (...) {}
		outputfile << "\"" << (std::string("台風 ") + std::to_string(typhoonNo) + "号") << "\",";

		// 名称
		outputfile << "\"" << _WS(info.nameKana) << "\",";

		// 位置
		outputfile << "\"" << _WS(rec0.location) << "\",";

		// 中心気圧
		string pstr = rec0.pressure > 0
			? to_string(rec0.pressure) + "hPa" : "-";
		outputfile << "\"" << pstr << "\",";

		// 最大瞬間風速
		string mwstr = rec0.maxWindSpeed > 0
			? to_string(rec0.maxWindSpeed) + "m/s" : "-";
		outputfile << "\"" << mwstr << "\",";

		// 進路
		string dirStr = _WS(rec0.direction);
		if (!dirStr.empty()) dirStr += "へ";
		string spdStr = _WS(rec0.speed);
		if (regex_match(spdStr, regex("^[0-9]+$")))
			spdStr = "時速" + spdStr + "km";
		outputfile << "\"" << dirStr << spdStr << "\",";

		outputfile << endl;
		row++;
		if (row >= MAX_TYPHOON) break;

		if (outputfile.bad()) {
			ErrorMessage = "CSVファイル書き込みエラー";
			break;
		}
	}
	outputfile.close();
}

/**
 * 一覧情報をCSVファイルに保存する：ファイルダイアログ
 * @param	なし
 * @return	なし
 */
void saveCSV(void) {
	static char fname[MAX_PATH + 1];
	static const std::wregex re(
		L"(\\d+)\x5E74(\\d+)\x6708(\\d+)\x65E5(\\d+)\x6642"
	);
	std::wsmatch match;
	if (std::regex_match(gTyphoonDt, match, re)) {
		snprintf(fname, MAX_PATH, SAVEFILE, stoi(match[1].str()), stoi(match[2].str()), stoi(match[3].str()), stoi(match[4].str()));
	} else {
		time_t now = time(nullptr);
		struct tm* t = localtime(&now);
		snprintf(fname, MAX_PATH, SAVEFILE, t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, t->tm_hour);
	}
	OPENFILENAME of;

	// OPENFILENAME構造体のサイズをセット
	memset(&of, 0, sizeof(OPENFILENAME));
	of.lStructSize = sizeof(OPENFILENAME);
	// ダイアログボックスを所有するウィンドウへのハンドル
	of.hwndOwner = hParent;
	of.lpstrFilter = TEXT("CSVファイル(*.csv;*.txt)\0*.csv;*.txt\0すべてのファイル(*.txt)\0*.txt\0\0");
	// ファイル名を格納したバッファのアドレス
	of.lpstrFile = (LPTSTR)fname; 
	// lpstrFileメンバで指定されるバッファのサイズ
	of.nMaxFile = MAX_PATH;
	of.Flags = OFN_OVERWRITEPROMPT;
	// デフォルトの拡張子を格納したバッファのアドレス
	of.lpstrDefExt = TEXT("csv");
	// コモンダイアログの表示
	GetSaveFileName(&of);
	// ファイル保存
	__saveCSV(fname);
}

/**
 * クリップボードにコピーする文字列を生成
 * @param	なし
 * @return	string コピーする文字列
 */
string copyText(void) {
	string str;
	ostringstream oss;

	// ラベル
	oss << "番号"         << "\t";
	oss << "名称"         << "\t";
	oss << "位置"         << "\t";
	oss << "中心気圧"     << "\t";
	oss << "最大瞬間風速" << "\t";
	oss << "進路"         << endl;

	// データ本体
	int row = 0;
	for (const auto& kv : gTyphoonItems) {
		const TyphoonInfo& info = kv.second;
		if (info.records.empty()) continue;
		const TyphoonRecord& rec0 = info.records[0];
		if (rec0.typhoonClass.find(_SW("台風")) == wstring::npos) continue;
		if (!info.valid) continue;

		// 番号
		wstring nn = kv.first;
		int typhoonNo = 0;
		try { typhoonNo = stoi(_WS(nn.length() >= 4 ? nn.substr(2,2) : nn)); } catch (...) {}
		oss << "\"" << "台風 " + to_string(typhoonNo) + "号" << "\t";

		// 名称
		oss << "\"" << _WS(info.nameKana) << "\"\t";

		// 位置
		oss << "\"" << _WS(rec0.location) << "\"\t";

		// 中心気圧
		string pstr = rec0.pressure > 0
			? to_string(rec0.pressure) + "hPa" : "-";
		oss << "\"" << pstr << "\"\t";

		// 最大瞬間風速
		string mwstr = rec0.maxWindSpeed > 0
			? to_string(rec0.maxWindSpeed) + "m/s" : "-";
		oss << "\"" << mwstr << "\"\t";

		// 進路
		string dirStr = _WS(rec0.direction);
		if (!dirStr.empty()) dirStr += "へ";
		string spdStr = _WS(rec0.speed);
		if (regex_match(spdStr, regex("^[0-9]+$")))
			spdStr = "時速" + spdStr + "km";
		oss << "\"" << dirStr << spdStr << "\"";

		oss << endl;
		row++;
		if (row >= MAX_TYPHOON) break;
	}

	return oss.str();
}

// WebView2制御用関数群 ======================================================
/**
 * WebView2：ダイアログを初期化する
 * @param	HWND hDlg	親ウィンドウ・ハンドラ
 * @return	なし
*/
static void initDialog(HWND hDlg) {
	HICON hIcon = (HICON)LoadImage(hInst, MAKEINTRESOURCE(IDI_ICON),
								   IMAGE_ICON, 16, 16, 0);
	SendMessage(hDlg, WM_SETICON, ICON_SMALL, (LPARAM)hIcon);
	ErrorMessage = "";

	SetCursor(LoadCursor(NULL, IDC_WAIT));
	loadParameter();
	SetWindowPos(hDlg, NULL, hParent_X, hParent_Y, 0, 0,
				 SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER);

	getTempFname(tmpFname);
	size_t wLen = 0;
	mbstowcs_s(&wLen, wUri, MAX_PATH*2, tmpFname, MAX_PATH);
	hWebView2 = createWebView2(hInst, hDlg, 10, 40,
							   MAP_WIDTH + 20, MAP_HEIGHT + 20, wUri);

	// Leaflet 疎通確認
	string contents; int httpStatus = 0;
	if (!readWebContents("https://unpkg.com/leaflet@latest/", UserAgent,
						 &contents, &httpStatus)
		|| httpStatus < 200 || httpStatus > 299) {
		ErrorMessage = "地図描画ライブラリ（Leaflet）が利用できません";
	}

	MSG msg;
	static bool flagWebView2 = false;
	while (GetMessage(&msg, nullptr, 0, 0)) {
		if (msg.message == WM_QUIT) { DestroyWindow(hDlg); }
		TranslateMessage(&msg);
		DispatchMessage(&msg);
		if (!flagWebView2 && isWeb2Ready()) {
			SetCursor(LoadCursor(NULL, IDC_WAIT));
			loadTyphoonData();
			viewBrowser(tmpFname);
			webView2->Reload();
			flagWebView2 = true;
			makeListViewFrame(GetDlgItem(hDlg, IDC_LISTVIEW_TYPHOON));
			makeListView(GetDlgItem(hDlg, IDC_LISTVIEW_TYPHOON));
			makeTitle(hDlg);
		}
	}

}

/**
 * WebView2のパラメータを取り出す．
 * selectActionの値によって動作を変える。
 * @param	string result	JavaScript終了ハンドラから渡るデータ
 * @return	なし
*/
static void execScriptCompleted(string result) {
	vector<string> tokens;
	split(tokens, result, is_any_of(",\""));
	int cnt = 0;
	for (const string& ss : tokens) {
		switch (cnt) {
			case 1: Longitude = stod(ss); break;
			case 2: Latitude  = stod(ss); break;
			case 3: Zoom      = stoi(ss); break;
			case 4: Maptype   = ss;       break;
		}
		cnt++;
	}
	switch (selectAction) {
	case eAction::Finish:
		remove(tmpFname);
		saveParameter();
		EndDialog(hParent, 0);
		DestroyWindow(hParent);
		break;
	default:
		viewBrowser(tmpFname);
		break;
	}
}

/**
 * WebView2：JavaScript終了ハンドラ
 * @param	HWND hDlg			親ウィンドウ・ハンドラ
 * @paramm	UINT uMsg			メッセージ識別子
 * @param	WPARAM wParam		メッセージの最初のパラメータ
 * @paramL	PARAM lParam		メッセージの2番目のパラメータ
 * @return	INT_PTR CALLBACK	TRUE：メッセージ処理完了／FALSE：未完了
*/
class MyScriptCompletedHandler : public ICoreWebView2ExecuteScriptCompletedHandler {
public:
	HRESULT STDMETHODCALLTYPE Invoke(HRESULT error, PCWSTR result) override {
		if (SUCCEEDED(error)) {
			int sz = WideCharToMultiByte(CP_UTF8, 0, result, -1, nullptr, 0, nullptr, nullptr);
			char* buf = new char[sz];
			WideCharToMultiByte(CP_UTF8, 0, result, -1, buf, sz, nullptr, nullptr);
			execScriptCompleted(string(buf));
			delete[] buf;
		}
		return S_OK;
	}
	HRESULT STDMETHODCALLTYPE QueryInterface(REFIID, void**) override { return E_NOINTERFACE; }
	ULONG   STDMETHODCALLTYPE AddRef()   override { return 1; }
	ULONG   STDMETHODCALLTYPE Release()  override { return 1; }
};

// WebView2のJavaScriptを実行してDOM要素を取得
MyScriptCompletedHandler* scriptCompletedHandler = new MyScriptCompletedHandler();

/**
 * WebView2のパラメータを保存する．
 * selectActionの値によって動作を変える。
 * @param	なし
 * @return	なし
*/
static void execScriptAndAction(void) {
	LPCWSTR js =
		L"document.getElementById('longitude').value + ',' "
		L"+ document.getElementById('latitude').value + ',' "
		L"+ document.getElementById('zoom').value + ',' "
		L"+ document.getElementById('maptype').value;";
	webView2->ExecuteScript(js, scriptCompletedHandler);
}

// メインウィンドウ ==========================================================
/**
 * イベントハンドラ：メインウィンドウ
 * @param	HWND hDlg			親ウィンドウ・ハンドラ
 * @paramm	UINT uMsg			メッセージ識別子
 * @param	WPARAM wParam		メッセージの最初のパラメータ
 * @paramL	PARAM lParam		メッセージの2番目のパラメータ
 * @return	INT_PTR CALLBACK	TRUE：メッセージ処理完了／FALSE：未完了
*/
INT_PTR CALLBACK processMain(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam) {
	pahooCache* pCC;

	switch (uMsg) {
	case WM_INITDIALOG:
		break;

	case WM_COMMAND:
		switch (LOWORD(wParam)) {
		// 再取得
		case IDM_EXEC:
		case IDC_BUTTON_EXEC:
			ErrorMessage = "";
			SetCursor(LoadCursor(NULL, IDC_WAIT));
			loadTyphoonData();
			execScriptAndAction();
			makeListView(GetDlgItem(hDlg, IDC_LISTVIEW_TYPHOON));
			makeTitle(hDlg);
			break;

		// 保存
		case IDC_BUTTON_SAVE:
		case IDM_SAVE:
			saveCSV();
			break;

		// コピー
		case IDM_COPY:
		case IDC_BUTTON_COPY:
			setClipboardData(copyText());
			break;

		// Google API キー設定
		case IDM_GOOGLE_APIKEY:
			createSetAPIkey(hDlg, LABEL_GOOGLE_API, FNAME_GOOGLE_API,
							URL_GOOGLE_API, &pGC->GoogleAPIkey);
			pGC->readGoogleApiKey();
			ErrorMessage = "";
			pGC->resetError();
			Maptype = pGC->isGoogleApiKey() ? "GMRD" : DEF_MAPTYPE;
			viewBrowser(tmpFname);
			makeListView(GetDlgItem(hDlg, IDC_LISTVIEW_TYPHOON));
			makeTitle(hDlg);
			break;

		// キャッシュ・クリア
		case IDM_CLEAR_CACHE:
			pCC = new pahooCache(LIFE_CACHE_FEED_L,
								 getMyPath(APPNAME) + DIR_CACHE_FEED_L, UserAgent);
			pCC->delCache(".+", 0); delete pCC;
			pCC = new pahooCache(LIFE_CACHE_FEED,
								 getMyPath(APPNAME) + DIR_CACHE_FEED, UserAgent);
			pCC->delCache(".+", 0); delete pCC;
			pCC = new pahooCache(LIFE_CACHE_DATA,
								 getMyPath(APPNAME) + DIR_CACHE_DATA, UserAgent);
			pCC->delCache(".+", 0); delete pCC;
			break;

		// 設定クリア＋終了
		case IDM_CLEAR_PARAMETER:
			delParameter();
			remove(tmpFname);
			EndDialog(hParent, 0);
			DestroyWindow(hDlg);
			return 0;

		// ヘルプ
		case IDM_HELP:
			ShellExecute(hParent, _T("open"), _T(HELPFILE), NULL, NULL, SW_RESTORE);
			break;

		// バージョン表示
		case IDM_VERSION:
			createHelp(hParent, processHelp);
			break;

		// 解説サイト
		case IDM_PAHOO:
			ShellExecute(NULL, _T("open"), _T(REFERENCE), NULL, NULL, SW_RESTORE);
			break;

		// 終了
		case IDM_QUIT:
			selectAction = eAction::Finish;
			execScriptAndAction();
			break;

		default:
			return 1;
		}
		break;

	case WM_CLOSE:
		selectAction = eAction::Finish;
		execScriptAndAction();
		break;
	}
	return 0;
}

// メイン・プログラム =======================================================
/**
 * Windowsメインプログラム
 * @param	HINSTANCE hInstance			インスタンスハンドル
 * @paramm	HINSTANCE hPrevInstance		未使用(常にNULL)：Win16時代の名残
 * @param	LPSTR lpCmdLine				コマンドライン引数
 * @paramL	int nShowCmd				ウィンドウの表示方法
 * @return	int リターンコード
*/
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE, LPSTR, int) {
	LoadLibrary("RICHED20.DLL");

	// UserAgent \u751f\u6210
	static OSVERSIONINFOEX os;
	GetVersion2(&os);
	UserAgent = (string)"Mozilla/5.0 (" APPNAME "/"
		APPVERSION "/" MAKER ", Windows NT "
		+ to_string(os.dwMajorVersion) + "."
		+ to_string(os.dwMinorVersion) + ")";

	// pahooGeocode オブジェクト生成
	pGC = new pahooGeocode(APPNAME);

	// メイン・ダイアログ
	HWND hDlg = CreateDialog(hInstance, MAKEINTRESOURCE(IDD_MAIN),
							  NULL, (DLGPROC)processMain);
	hInst   = hInstance;
	hParent = hDlg;

	// ダイアログ初期化（内部にメッセージ・ループを含む）
	initDialog(hDlg);

	delete pGC;
	return 0;
}

/*
** バージョンアップ履歴 =====================================================
 *
 * @version 1.0.0  2026/06/20 初版
 */
