トップ地図システム基礎技術 > 地図に文字を描く

地図に文字を描く

はじめに

地図に文字を描くということについては色んなニーズがある。

  1. 長い名称は3~5文字くらいの幅で改行し、複数行に分けて描画する。
  2. 道路や建物など別のものが描かれた上に重ね書きするため、読みやすいように縁取り文字とする。
  3. 道路名や川名などは中央に曲線(折れ線)に沿って描画する。 都道府県や市区町村境界は境界線に沿って、その領域側に名称を描く。

    現在は1文字ごとに角度を変えて描画しているのではなく、全文字を描けるだけの直線区間に描いている。 水平、垂直ではなく、角度のある描画が必要である。

    1文字ずつ角度を変えて描画できればその方がよい。

これまではこれらのニーズに自前で対応してきたため、プログラム行数が嵩んでいる。 改めて、できるだけ楽な方法を探りたい。

文字コード

現在、インターネットでは UTF-8 が主流になっている。 自分がホームページを開設したのは15以上前であり、ファイルサイズを小さくするために、Shift-JIS を使っていた。

地図システムとしては日本地図が主体のため、日本語の比重が高い。 文字列をコード化しているため、その辞書をメモリに常駐する。メモリサイズはできるだけ小さくするには、 日本語(Shift-JISコードだけ)中心の場合、Shift-JIS、のACSIIコードだけまたは、 Shift-JISに含まれないマルチバイトコードの場合 UTF-8 とした時期もある。

そこまでファイルサイズに拘るのはやめ、現在は、UTF-16コード一本にしている。 2バイト固定長であり、C# とか Java の内部文字コードでもあるため、プログラム的には扱いやすい。

日本語が中心であれば、Shift-JISコードとほぼ同じサイズとなる。 ASCII文字が多い場合は、Shify-JISコードよりファイルサイズが大きくなる。

UTF-16BE(Big Endian)と UTF-16LE(Little Endian)があり、 Java は Big Endian、C# は Little Endian であるため、注意が必要になる。

文字列を複数行に分けて描画する

C# の場合、Graphics.DrawStringメソッドを使えば、文字列を複数行に分けて描画できる[1]。 しかし、Graphics.DrawStringメソッドで GraphicsPath が使えなければ、縁取り文字が描けない。

これまでは、複数行分割は自前で行い、GraphicsPath で縁取り文字を描いてきた[2]。

改めて、調べたが、結局、これまでのプログラムを手直しして、いくぶん、分かりやすくした。 描画結果を下に示す。

文字列を複数行に分けて描画するときの描画位置

地名の場合は、描画位置に多少のずれがあっても問題はないが、 多くの地物はアイコンの下に名称を描画するため、的確な位置に描画しないと見栄えが悪くなる。

OSMデータの座標位置を (x, y) とする。タイルの左上を原点とする画素単位の値である。

この座標(x, y) を中心としてアイコンを描画するには次のようにする。 引数に指定するのは、左上の座標と幅、高さである。

  g.DrawImage(img,  x - img.Width/2, y - img.Height/2, img.Width, img.Height);

文字列描画の位置決めはアイコンに比べるとはるかに面倒である。 複数行に分割した場合、描画も複数回に分かれる。 縁取り文字を描画するため、GraphicsPath に登録する際に座標位置を指定する。

アイコンの場合と同様に、文字列描画領域の左上の座標を指定する。 従って、予め、文字列描画領域を求める必要がある。

アイコンを描画しない場合は、複数行の文字列全体の中心が座標(x、y)に一致させる。 アイコンを描画する場合は、アイコンの真下に文字列が描画されるように、 文字列の描画位置を下にずらす。

文字列の描画領域は Graphics#MeasureStringメソッドで得られるが、 マージンがあるため、実際の描画領域とピッタリ一致するわけではない。

以上のことから、位置決めは面倒であるが、C# でも Android Java でも経験済みであるため、 これらを参考にして、なるべく分かりやすくしたい。

PC/タブレット版地図システムのプログラム

以前のプログラムに次のメソッドがあった。もし、これが複数行に対して有効に働くならば、 その後のプログラムよりも簡単である。

まずは試してみる。

    public bool DrawString(Graphics g,  Pnt point, string fontname, FontStyle fontstyle,
          Brush brush, float fontsize, int dy, int margin, string name0, Pen penHalo=null) {
        if (name0 == null || fontsize <= 0) return false;
        float x = point.xf;
        float y = point.yf;

        Font fnt = new Font(fontname, fontsize/1.2f, fontstyle);

        var name = Wrap(name0);

        SizeF size = g.MeasureString(name, fnt);
        float w = size.Width + 1.8f;
        float h = size.Height + 0.1f;

        Rectangle rect = new Rectangle(         // 描画領域
            (int)(x-w/2), dy==0 ? (int)(y-h/2) : (int)(y+dy/1.33f), (int)w, (int)h);

        Rectangle rectMargin = new Rectangle(   // 描画領域+マージン
            rect.X-margin, rect.Y-margin, rect.Width+margin*2, rect.Height+margin*2);

        foreach (Rectangle r in listRect) {
            if (r.IntersectsWith(rectMargin)) {
                return false;     // 重なり回避のため描画しない
            }
        }

        GraphicsPath gp = new GraphicsPath();
        gp.AddString (name, new FontFamily(fontname), (int)fontstyle, fontsize, 
                       rect, StringFormat.GenericDefault);
        if (penHalo != null) g.DrawPath(penHalo, gp);
        g.FillPath(brush, gp);

        listRect.Add(rect);     // 地物名が地名に重ならないように常に地名も登録
        return true;
    }

まず、MesureStringが複数行に対応しているかどうかが心配だったが、対応していることが確認できた。

最終的には、以下のようにした。MeasureStringは大きめの値が返されることから、0.8がけとした。 駅のアイコンと駅名描画で所望の結果が得られることを確認した。 今後、描画領域の幅と高さについての補正は修正するかも知れない。 また、この段階では、重なり回避は行っていない。地名同士の重なり回避は行っている。

    public void DrawText(Graphics g, Font font, Brush br, float halo, TileToRender tile,
                  string text, float dy,  bool fRect) {
        if (text == null) return;

        var name = Wrap(text);
        SizeF size = g.MeasureString(name, font);
        float w = size.Width * 0.8f;
        float h = size.Height * 0.8f;

        double fact = tile.fact;
        double xpx = tile.xpx;
        double ypx = tile.ypx;
        float x = (float) (xc * fact - xpx);    // 中心X座標
        float y = (float) (yc * fact - ypx);    // 中心Y座標

        PointF pnt = new PointF(x-w/2, dy==0 ? y-h/2 : y+dy);

        GraphicsPath gp = new GraphicsPath();
        gp.AddString(name, font.FontFamily, (int)font.Style,
                font.Size, pnt, StringFormat.GenericDefault);

        Pen drawPen = new Pen(Color.White, halo);
        g.DrawPath(drawPen, gp);  // パスの線分を描画(縁取り)
        g.FillPath(br, gp);
    }

重なり回避を入れた最終プログラムを以下に示す。

    public void DrawText(Graphics g, Font font, Brush br, float halo, TileToRender tile,
                  string text, float dy=0,  bool fRect=true) {
        if (text == null) return;

        var name = Wrap(text);
        SizeF size = g.MeasureString(name, font);
        float w = size.Width * 0.8f;
        float h = size.Height * 0.8f;

        double fact = tile.fact;
        double xpx = tile.xpx;
        double ypx = tile.ypx;
        float x = (float) (xc * fact - xpx);    // 中心X座標
        float y = (float) (yc * fact - ypx);    // 中心Y座標

        PointF pnt = new PointF(x-w/2, dy==0 ? y-h/2 : y+dy);
        RectangleF rect = new RectangleF(pnt.X, pnt.Y, w, h);

        GraphicsPath gp = new GraphicsPath();
        gp.AddString(name, font.FontFamily, (int)font.Style,
                font.Size, pnt, StringFormat.GenericDefault);

        foreach (RectangleF r in tile.listRect) {
            if (r.IntersectsWith(rect)) return;     // 重なり回避のため描画しない
        }
        tile.listRect.Add(rect);

        Pen drawPen = new Pen(Color.White, halo);
        g.DrawPath(drawPen, gp);  // パスの線分を描画(縁取り)
        g.FillPath(br, gp);
    }

リファレンス

[1] 文字を描く
[2] 縁取り文字を描く

来歴およびノート