2015年7月4日土曜日

Windowsの「追加の時計」をいじる

さて、Windowsには追加の時計というものがありますね。


このように、タスクバーの右下の時計を押したら出てくるやつに世界時計を2つまで追加できる機能です。家族、職場の知り合い等が海外に出かけているとき、これを設定しておくとその人が今何時なのかが一目でわかるので、電話を掛けるタイミングが見当付くようになるなど、とても便利なものです。

しかしこれ、設定のUIがとてつもなく不便です。


このように、一覧から表示するタイムゾーンを選択しなければなりません。しかも、表示されるのは国名ではなく、都市名、もしくは地域名です。そして、このリストにはUTCとの時差が同じ都市がいくつもありますが、これの選択を誤るとサマータイムの反映がうまくされず、実際とは異なる時刻が表示されることがあります。
時刻を表示したい地域について、周辺都市やタイムゾーンを熟知している人なら簡単に選択できるでしょうが、それができるのは多分その国に住んでいる人か、もしくはよほどの地理オタクくらいなものです。

すなわち、普通に使うには、これはとても不便なのです。

これをもうちょっと、例えば地図で選択した地点から直接タイムゾーンを判定して表示したり、 国名を入力したら自動的に選択してくれたり、はたまたTwitterの位置情報からタイムゾーンを入力したりするようなUIが必要ですよね。
そういった設定ができるようなソフトウェア開発について考えていきましょう。

「追加の時計」の設定を変更する

さて、それではどうやってこの時計を設定すればいいでしょう。
たいてい、こういうのはレジストリに書き込まれていますから、レジストリでそれっぽいキーを探します。
そうすると、見事に出てきました。

HKEY_CURRENT_USER\Control Panel\TimeDate\AdditionalClocks

ここに「1」というキーと「2」というキーがあり、これがそれぞれ追加の時計1,2に対応しています。


値は非常にシンプルです。
DisplayNameは表示名ですね。時計のタイトルで、任意の文字列です。
Enableはその時計が有効かどうかです。0で無効、1で有効ですね。
TzRegKeyNameがタイムゾーンを表すキー名です。なんのこっちゃってなるかもしれませんが、HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zonesに格納されている各タイムゾーンに対応するレジストリキー名となります。

さて、となると、このタイムゾーンのレジストリキーまでパースしてやらなきゃいけないのかという話になりそうですが、そんなことはありません。.NETのTimeZoneInfoクラスIdプロパティの値が、このレジストリキーに対応しています。TimeZoneInfoはGetSystemTimeZonesメソッドによって一覧を取得できるので、これを適当に選択してレジストリに書き込んであげれば時計は設定できるわけですね。

というわけで、追加の時計を変更するメソッドは下記のような形になります。

/// <summary>
/// 「追加の時計」に時刻を設定するメソッド
/// </summary>
/// <param name="ClockNumber">時計の番号。1または2</param>
/// <param name="DisplayName">表示名</param>
/// <param name="IsEnable">有効にするか、無効にするか。</param>
/// <param name="TimeZone">設定するタイムゾーン</param>
public static void SetAdditionalClock(int ClockNumber, string DisplayName, bool IsEnable, TimeZoneInfo TimeZone)
{
    if(ClockNumber < 1 || ClockNumber > 2)
        throw new ArgumentOutOfRangeException("ClockNumber");
    if(IsEnable) {
        if(DisplayName == null)
            throw new ArgumentNullException("DisplayName");
        if(TimeZone == null)
            throw new ArgumentNullException("TimeZone");
    }

    using(RegistryKey Key = Registry.CurrentUser.CreateSubKey(string.Format(@"Control Panel\TimeDate\AdditionalClocks\{0}", ClockNumber))) {
        if(IsEnable) {
            Key.SetValue("DisplayName", DisplayName, RegistryValueKind.String);
            Key.SetValue("Enable", 1, RegistryValueKind.DWord);
            Key.SetValue("TzRegKeyName", TimeZone.Id, RegistryValueKind.String);
        } else
            Key.SetValue("Enable", 0, RegistryValueKind.DWord);
    }
}

今まで追加の時計を設定したことが無い環境ではレジストリキーすら存在しないようなので、そのような環境のためにCreateSubKeyにてキーを作成(もしくは存在する場合はそれを開く)しています。
あとは上記の3つのパラメーターを書き込んであげているだけなので至ってシンプルです。これで追加の時計の設定ができてしまいます。驚くほどあっさりしていました。

緯度経度からタイムゾーンを取得する

さて、追加の時計の設定変更は簡単にできましたが、そもそもTimeZoneInfo.GetSystemTimeZonesが先ほどのWindows標準のタイムゾーン一覧を表示するコンボボックスの内容を返すだけなので、どうにかして何かしら別の情報(緯度経度など)をタイムゾーンに変換する必要があります。

まあ、もっともお手頃そうなのはGoogle Time Zone APIですかね。
時刻と緯度経度を指定すると、JSONでその夏時刻を含めたタイムゾーンを返してくれます。なので、いつも通りJson.NETを使ってタイムゾーンを取得するクラスを作りました。

public class GoogleTimeZone
{
    private GoogleTimeZone(InternalGoogleTimeZone Source)
    {
        DaylightSavingTimeOffset = TimeSpan.FromSeconds(Source.dstOffset);
        RawOffset = TimeSpan.FromSeconds(Source.rawOffset);
        Status = Source.status;
        TimeZoneId = Source.timeZoneId;
        TimeZoneName = Source.timeZoneName;
    }

    /// <summary>
    /// サマータイムのオフセット
    /// </summary>
    public TimeSpan DaylightSavingTimeOffset { get; private set; }

    /// <summary>
    /// サマータイム関係なしのオフセット
    /// </summary>
    public TimeSpan RawOffset { get; private set; }

    /// <summary>
    /// ステータス
    /// </summary>
    public string Status { get; private set; }

    /// <summary>
    /// タイムゾーンのID
    /// </summary>
    public string TimeZoneId { get; private set; }

    /// <summary>
    /// タイムゾーン名
    /// </summary>
    public string TimeZoneName { get; private set; }

    public override string ToString()
    {
        if(Status != "OK")
            return Status;
        else {
            string RawOffsetText = (RawOffset < TimeSpan.Zero ? "-" : "+") + RawOffset.ToString("hh\\:mm");
            string DSTText = string.Empty;

            if(DaylightSavingTimeOffset != TimeSpan.Zero) {
                DSTText = (DaylightSavingTimeOffset < TimeSpan.Zero ? "-" : "+") + DaylightSavingTimeOffset.ToString("hh\\:mm");
                DSTText += "(DST)";
            }

            return string.Format("(UTC{0}{1}) {2}/{3}", RawOffsetText, DSTText, TimeZoneName, TimeZoneId);
        }
    }

    public static async Task<GoogleTimeZone> CoordinateToTimeZoneAsync(double Latitude, double Longitude, DateTime? UtcTime = null, string ApiKey = null)
    {
        if(UtcTime == null)
            UtcTime = DateTime.UtcNow;

        long UnixTime = (long)(UtcTime.Value - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds;

        string url = string.Format(@"https://maps.googleapis.com/maps/api/timezone/json?location={0},{1}&timestamp={2}", Latitude, Longitude, UnixTime);

        if(!string.IsNullOrEmpty(ApiKey))
            url += "&key=" + ApiKey;

        string json;

        using(WebClient wc = new WebClient()) {
            json = await wc.DownloadStringTaskAsync(url);
        }
        return new GoogleTimeZone(JsonConvert.DeserializeObject<InternalGoogleTimeZone>(json));
    }
}

internal class InternalGoogleTimeZone
{
    public int dstOffset { get; set; }
    public int rawOffset { get; set; }
    public string status { get; set; }
    public string timeZoneId { get; set; }
    public string timeZoneName { get; set; }
}

Json.NET用に単純なInternalGoogleTimeZoneクラスを作り、そこからプロパティのアクセスレベルや型をしっかり作ったクラスに変換してあげる形にしています。これで、緯度経度をタイムゾーンに変換できるようになりました。
ちなみに、このAPIでは緯度経度のほかに時刻を渡す必要があります。その時刻によって、サマータイムの季節かどうかを判別してくれるようです。

GoogleのタイムゾーンをWindowsのタイムゾーンに変換する

さて、Googleからタイムゾーンが取得できるようになりましたが、Windowsの形式とは若干異なります。UTCからの時差はどちらも出ますが、サマータイムが採用されているかどうかの判別はGoogleのほうには無く、というよりも、タイムゾーンを求める段階で時刻を指定してサマータイム中ならばサマータイム中だという返答が返ってくるだけになります。
なので、この辺を上手く扱って、GoogleのタイムゾーンをWnidowsのタイムゾーンに変換してやらねばなりません。これは結構苦労しました。

 結果から言うと、下記の手順で求めました。
  1. 子午線が一致するタイムゾーンをWindowsのタイムゾーンのリストから抜き出す
  2. 都市名等を単語ごとに分解し、都市名にかかわらないstandard, time, daylight, city, summerなどの文字列を除外する(それで残った単語を『キーワード』と呼ぶことにする)
  3. GoogleタイムゾーンとWindowsタイムゾーンで、一致するキーワードの個数がもっとも多いペアが、そのGoogleタイムゾーンに対応するWindowsのタイムゾーンということにする。キーワードの個数が同じ組み合わせが複数あった場合は、キーワードの個数がもっとも少ないものを採用する。
まあ何を言いたいかというと、Googleのタイムゾーンでは、例えばロサンゼルス周辺のタイムゾーンを取得しようとすると
America/Los_Angeles
Pacific Standard Time
の2種類の文字列を返してきます。一方、Windowsのタイムゾーンでは
Pacific Standard Time
という文字列が返ってきます。
この「Pacific」という文字列の一致から、対応するタイムゾーンを探し出そうというわけです。
Windowsのタイムゾーンにはほかにも
Pacific Standard Time (Mexico)
などがあります(メキシコの太平洋標準時)が、それらはGoogleのタイムゾーン側にMexicoとかそういった単語が無い場合は最も短い名前の都市名と一致を掛けるので、該当しなくなるわけです。

public async Task GetTimeZone()
{
    GoogleTimeZone gtz = await GoogleTimeZone.CoordinateToTimeZoneAsync(Latitude, Longitude);

    var WindowsTimeZones = TimeZoneInfo.GetSystemTimeZones().Where(p => p.BaseUtcOffset == gtz.RawOffset)
        .Select(p => new { Source = p, Keywords = TimeZoneTextToCityKeywords(p.Id) }).ToArray();
    var GoogleTimeZoneTexts = TimeZoneTextToCityKeywords(gtz.TimeZoneId + " " + gtz.TimeZoneName);

    //子午線が一致する都市のうち
    //1. 一致するキーワードが多いもの
    //2. そのうち、キーワードの長さが最も短いもの
    //を抽出する
    TimeZone = WindowsTimeZones.Select(p => new {
        Source = p.Source,
        SameTextCounts = p.Keywords.Concat(GoogleTimeZoneTexts).Count() - p.Keywords.Concat(GoogleTimeZoneTexts).Distinct().Count(),
        SourceTextCounts = p.Keywords.Count()
    }).OrderByDescending(p => p.SameTextCounts).ThenBy(p => p.SourceTextCounts).First().Source;
}

/// <summary>
/// タイムゾーンのテキストからその地域名に係るキーワードを抜き出すメソッド
/// </summary>
/// <param name="TimeZoneName"></param>
/// <returns></returns>
private static string[] TimeZoneTextToCityKeywords(string TimeZoneName)
{
    string[] spacers = { "/", "_" };
    string[] removes = { "(", ")", "standard", "time", "daylight", "city", "summer" };

    TimeZoneName = TimeZoneName.ToLower();

    foreach(string s in spacers)
        TimeZoneName = TimeZoneName.Replace(s, " ");
    foreach(string r in removes)
        TimeZoneName = TimeZoneName.Replace(r, string.Empty);

    return TimeZoneName.Split(' ').Where(p => !string.IsNullOrEmpty(p)).OrderBy(p => p).Distinct().ToArray();
}

実装はこんな感じになっています。
まあ、本当にこれで完全かと聞かれるとちょっと自信は無いですが、少なくとも最初に子午線の一致を確認しているので、誤判定があったところでサマータイムの問題くらいでしょう。
もしも問題が見つかれば個別にちょっと考えていこうとは思っています。

LINQ to Twitterで位置情報を取得する

さて、緯度経度をどこから持ってくるかと言う話ですが、海外に行っていることをTwitterでつぶやいている人がいたので、Twitterから持ってくることにしました。

しかしこれ、意外とドツボです。

最近、Twitter公式アプリとかを見てもらうとわかりますが、位置情報をつぶやくときに「正確な位置を共有」とかいうオプションがあります。従来のTwitterでは緯度経度ベースの正確な位置情報しかつぶやけませんでしたが、個人情報に配慮したのでしょうか、最近はこういうオプションが生まれました。
そのせいで、位置情報にかかわる機能がややこしくなっているんですね。

Twitterが返す生のJSONまでは調べていませんが、LINQ to Twitterではそのあたりが下記のような仕様になっているようです。

従来の(正確な)位置情報の場合

Status.Coordinates.Latitude
Status.Coordinates.Longitude
に緯度経度が入っています。正確な位置情報が共有されていない場合や、そもそも位置情報を付けてつぶやいていない場合は緯度経度がともにゼロになっています。
なお、正確な位置情報が共有されている場合でも、下記の大雑把な位置情報も入っていますので、そちらも併せて利用することができます。

大雑把な位置情報の場合

Status.Coordinatesはnullにはなりませんが、緯度経度がともにゼロになります。
そして、Status.Place.BoundingBox.Coordinatesに緯度経度がいくつか(多分4つ)入ります。Twitterの開発者向けサイトには
A bounding box of coordinates which encloses this place. 
と書いてあるので、その位置情報のエリアを囲う座標が格納されているという意味なのでしょう。
というわけで、正確な位置情報が共有されていない場合は、そのエリアの頂点の各座標の重心(座標の平均値)を現在位置とすることとしました。

ちなみに、このStatus.Placeには地名の情報も入っています。

Status.Place.FullName: 地域名(東京都千代田区など)
Status.Place.Country: 国名(日本など)

なので、このあたりも使えそうですね。


昔のTwitterクライアントなんかではこの大雑把な位置情報には対応していなかったりしますが、特に今回のタイムゾーンを表示する程度の場合では大雑把な位置情報でも問題はないので、積極的にこちら側の情報も使っていくこととしました。



まあ要するに、
  1. Twitterから緯度経度を取ってくる
  2. Google Time Zone APIでその緯度経度に対応したタイムゾーンを調べる 
  3. キーワード比較でWindowsのタイムゾーンに変換する
  4. その設定を時計に転送する
みたいな流れで時計を自動設定できるようなソフトを作ってみました。はい。
できればGoogleマップで表示している場所のタイムゾーンをみたいな機能も付けたかったのですが、何せWPFのWebBrowserコントロールの闇が深すぎて…

Twitter to WorldClock ver.0.1.0
 ※今回は諸事情あってソースコードは配布しておりません。悪しからず。

0 件のコメント:

コメントを投稿