トップ地図アプリMap4 > 曲線に沿って文字を描画する
曲線に沿って文字を描画する

はじめに

道路名や川名など地図には曲線に沿って描く文字(列)が多い。車道の場合、カーブは緩やかなことが多いが、 都道府県や市区町村などの境界線に沿って(境界線の内側に)描く文字列の場合、曲がりくねっていることがある。 このような場合、変化が激しい区間は避けて描画する。

描画方法

予め、道路名や都道府県名などを水平方向に文字単位で描画した Bitmap データを用意する。

このビットマップを道路や境界線の角度だけ回転して、描画位置に平行移動する。

文字は必要に応じて縁取りする[1]。この縁取りは最初に文字毎の Bitmap を作成するときに行っておく。

文字毎に正味の Bitmap のサイズは異なる。 文字列 text 中の1文字毎の描画領域は次のようにして得られる。ここで i は 0 から 文字数 - 1 である。 textBounds は結果をセットする矩形 Rect である。

Rect textBounds にセットされるのは正味の描画領域である。個々の文字を描画する Bitmapサイズは 中心を一致させ、上下左右に適切なマージンが必要となる。縁取りの場合にはその厚み分が必須となる。 縁取りをしない場合も描画結果を確認して、適度なマージンを加える。

Matrix操作により、文字の中心を曲線の中心に合わせることと、 文字の下または上をピッタリ曲線に合わせることは簡単である。

Matrix操作で任意の幅だけ曲線から離して描画することも可能である。

 paint.getTextBounds(text, i, i+1, textBounds);

変更前のプログラムを Map421 として残し、書き換え後を Map422 とする。

文字列からBitmap配列を生成する

    Bitmap[] stringToBitmaps(Renderer r, String text, int color, float size, float dy, float halo) {
        halo *= Map.Scale;
        Bitmap[] bmps = new Bitmap[text.length()];
        Paint paint = r.getPaintText(color, size*Map.Scale);
        float mx = halo == 0 ? 2 : 0;
        float my =  halo == 0 ? 2 : 0;;// + Math.abs(dy);
        for (int i = 0; i < bmps.length; i++) {
            String chr = text.substring(i, i+1);
            paint.getTextBounds(text, i, i+1, bounds);
            float cx = bounds.exactCenterX();
            float cy = bounds.exactCenterY();
            float w = bounds.width();//+halo*2;
            float h = bounds.height();//+halo*2;
            bmps[i] = Bitmap.createBitmap((
                    int)(w+mx*2+halo*2), (int)(h+my*2+halo*2), Bitmap.Config.ARGB_8888);
            Canvas canvas = new Canvas(bmps[i]);
            if (halo > 0) {     // 縁取りを先に描画
                Paint paintHalo = r.paintHalo;
                paintHalo.setStrokeWidth(halo*2);          // 描画の幅
                paintHalo.setTextSize(paint.getTextSize());  // テキストサイズ
                canvas.drawText(chr, mx+w/2-cx+halo, my+h/2-cy+halo, paintHalo);
            }
            canvas.drawText(chr, mx+w/2-cx+halo, my+h/2-cy+halo, paint);
            //canvas.drawRect(0, 0, w+mx*2+halo*2, h+my*2+halo*2, r.paintRedPen);
        }
        return bmps;
    }

文字列描画プログラム

二度コールする。一回目は指定された位置から文字列を描画できるかを調べる。 描画できる場合、二度目の実行で文字列を描画する。

    Bitmap[] reverse(Bitmap[] v) {
        Bitmap[] reversed = new Bitmap[v.length];
        for (int i = 0; i < v.length; i++) {
            reversed[v.length - i - 1] = v[i];
        }
        return reversed;
    }

    // chrs[] をラインに沿って描画する
    static boolean inner;
    RectF rectTile80 = new RectF(Map.PX * 0.1f, Map.PX * 0.1f, Map.PX * 0.9f, Map.PX * 0.9f);
    int drawBitmapMulti(Canvas canvas, Renderer r, Bitmap[] chrs,
                        float dy0, int addDegree, boolean check) {
        float dist = 0, deg = 0, num;
        float xb = 0, yb = 0, xe = 0, ye = 0;
        float sumDegree = 0;
        int nDeg = 0;;

        int n = r.ixNode * 2;  // 線分を指す
        xe = pnts[n++];   // 最初の始点座標にセットされる
        ye = pnts[n++];   // 同上
        float offset = r.offset;
        for ( ; offset >= 0 && n < num_nodes * 2; offset -= dist) {
            xb = xe;            // 線分の始点x座標
            yb = ye;            // 線分の始点y座標
            xe = pnts[n++];     // 線分の終点x座標
            ye = pnts[n++];     // 線分の終点y座標
            dist = (float) distance(xb, yb, xe, ye);
            deg = (float) degree(xb, yb, xe, ye);
            if (dist > offset) break;      // この線分から描画できる
        }
        if (offset > 0) {
            num = dist / offset;
            xb += (xe - xb) / num;
            yb += (ye - yb) / num;
            dist -= offset;
        }   // 残りのoffsetなし。(xb, yb)から文字を描く
        sumDegree = deg;
        nDeg++;

        Matrix matrix = r.matrixMultiIcon;
        float lastDeg = deg;    // 最初の線分の角度

        float avgHeight = 0;
        for (Bitmap chr : chrs) {
            avgHeight += chr.getHeight() / chrs.length;
        }

        inner = true;
        for (Bitmap chr : chrs) {
            float w = chr.getWidth();
            float h = chr.getHeight();
            num = dist / w;    // 何文字描けるか
            float dx = (xe - xb) / num;   // 一文字描画で進める大きさ
            float dy = (ye - yb) / num;
            float x1 = xb-w/2+dx/2;
            float y1 = yb-h/2+dy/2;
            float x2 = xb+w/2+dx/2;
            float y2 = yb+h/2+dy/2;
            if (check) {
                //canvas.drawRect(x1, y1, x2, y2, r.paintRedPen);
                if (r.intersects(x1, y1, x2, y2)) {
                    return 1;   // 登録文字やアイコンと交差有り
                }
            } else {
                //==== 一文字描画する ====
                matrix.reset();
                matrix.preRotate((float) (deg + addDegree), w / 2, h / 2);
                if (dy0 != 0) matrix.preTranslate(0, avgHeight);
                matrix.postTranslate(x1, y1);
                //canvas.drawRect(x1, y1, x2, y2, r.paintRedPen);
                canvas.drawBitmap(chr, matrix, null);
                if (rectTile120.intersects(x1, y1, x2, y2)) {
                    RectF rect = r.rects[r.num_rects++];
                    rect.set(x1, y1, x2, y2);   // 追加登録
                }
            }
            xb += dx;    // 一文字分 xeに向かって進む
            yb += dy;    // 一文字分 yeに向かって進む
            dist -= w;   // 一文字分減らす
            if (dist < w) {     // w/2
                // 次の文字(幅は同じと仮定した)はこの線分には描けない
                if (n >= num_nodes * 2) {
                    return 2;   // このレコードには全文字は描けない
                }
                float rest = dist;  // 前の線分の末尾の空き
                xb = xe;
                yb = ye;
                xe = pnts[n++];   // 線分の終点x座標
                ye = pnts[n++];   // 線分の終点y座標
                dist = (float) distance(xb, yb, xe, ye);
                deg = (float) degree(xb, yb, xe, ye);
                if (check && Math.abs(deg - lastDeg) >= 20) {
                    return 1;   // 描画しない
                }
                lastDeg = deg;
                sumDegree += deg;
                nDeg++;
                if (rest != 0) {
                    num = dist / rest;    // 空きの何倍のおおきさか
                    xb -= ((xe - xb) / num);   // 空き描画で進める大きさ
                    yb -= ((ye - yb) / num);
                    dist += rest;
                }
            }
        }
        if (!check) {
            r.ixNode = (n-2)/2;
        }
        float avgDeg = sumDegree / nDeg;
        if (avgDeg > 90) return -180;
        if (avgDeg < -90) return +180;
        return 0;   // 全文字の処理を終わった
    }

文字列描画の繰り返しをコントロールする

    /* Renderer のメンバー変数
    int ixNode;                     // 現在の線分は #ixNode - #ixNode+1 である
    float offset;                   // 文字列描画のスタート位置
    */
    void drawBitmapMulti(Canvas canvas, Renderer r, Bitmap[] chrs, float dy) {
        r.ixNode = 0;
        r.offset = 0;   // ixNodeからのオフセット。複数線分になることもある。
        if (num_nodes == 2) {
            float width = getWidth(chrs);
            float dist = (float)distance(pnts[0], pnts[1], pnts[2], pnts[3]);
            if (dist > width && dist < width*5) {
                r.offset = (dist - width) / 2;
            }
        }   // 短い橋などは中央に1回だけ描画する
        Bitmap[] reversed = reverse(chrs);
        while (true) {
            int rtn = drawBitmapMulti(canvas, r, chrs, dy, 0, true);   // チェック
            if (rtn == 2)  {
                break;    // 終了
            } else if (rtn == 1) {
                r.offset += Map.PX * 0.05f; // 再試行 スペース(狭い)を空ける
                // r.ixNode は更新されていない
            } else {
                Val boundary = getVal(Key.boundary);
                boolean fAdmin = (boundary == Val.administrative);
                int addDegree = fAdmin ? 0 : rtn;
                if (inner) {
                    drawBitmapMulti(canvas, r, addDegree==0 ? chrs : reversed, dy, addDegree, false);    // 描画実行
                }
                r.offset = Map.PX * (fAdmin ? 2 : 0.5f);  // 境界線に沿って描く都道府県名などは間隔を広くする
                // r.ixNode は更新されている。ofset は ixnode からの値
            }
        }
    }

今後の課題

試行錯誤の結果、まずまずの描画結果が得られるようになった。しかし、初めてのプログラムであり、 かなりの煩雑さがある。

リファインは急がず、少しずつ、簡略化できればよい。

道路や境界線に沿って描く市町村名などは同じ文字列を何度も描くので、 曲線の変化が激しい所やタイル境界をまたぐような場所への描画は避けた方がよい。

しかし、鉄道名は長いものがあるため、タイル境界をまたぐことを禁止すると、殆ど描画できないことがある。

一つの線分上に描く文字については問題ないが、線分と線分の継ぎ目に当たる文字の描画位置や 角度については改善の余地がある。

曲線に沿って描く文字列に限らず、文字列やアイコンがタイル境界で途切れることがある。 タイル毎の描画であれば、タイルをまたがるアイコンや文字列が隣のタイルで描画されるか、 されないか確実なことは分からない。

完璧を期すには、日本地図の場合、日本地図領域全てのタイルを予め決めて順で1タイルずつ描画するか、 事前に文字列およびアイコンをタイル境界無しで仮想的に描画してみて、zoom 毎に、 あるアイコンあるいは文字列を描画するかしないかを決定して置き、その結果をOSMバイナリレコードに付加しておけばよい。

日本地図領域全体の情報であるが、実際にタイル地図をレンダリングするわけではなく、 アイコンおよび文字列情報だけに限定されることから、パフォーマンス上極端に難しいというものではない。

以前は、地名に限定して、この方法を採っていた。プログラムをなるべくスリムにするために、Map4ではこの方法を採っていない。

よく考えて、なるべく簡単な方法で、文字列やアイコンの描画がタイル境界で途切れることを完璧でないまでも、 ほとんど見つからないようにしたい。

PCで今以上の大きな処理をするのはなるべく避けたい。日本全土のタイルについて、西から東、北から南に、 アイコンや文字列の仮想的な描画を行うのは、Androidタブレットかスマホの方がやりやすいであろう。 結果は OSMバイナリレコードファイルに反映するのではなく、これとは独立したファイルで管理する方がいいであろう。

ただし、レコードとの関連付けがいるかも知れない。レコードの識別子としては osm_id も候補ではあるが、 独自のユニークなレコード番号の方がいいであろう。全てのレコードが対象というわけではない。 恐らく、全体の1,2割のレコードだけに必要となるので、OSMバイナリレコード形式を変更するのではなく、 特殊タグの形でついかすればよい。

管理ファイルはレコード毎がいいのか、タイル毎がいいのかはまだ不明である。 道路に沿って何度も描く道路名は対象道路レコード毎に、ノード番号とその線分でのオフセットの配列となる。 描画する amenity や shop のアイコンなどはそのレコード番号だけを記録しておけばよい。

レンダリングでは重なり判定はいらない。例えば、zoom 毎、タイル毎に管理ファイルがあり、 そこに描画するアイコンや文字列のレコード番号が登録されている。

アイコンや文字列の描画が一切ないタイルについては管理ファイルは要らない。 しかし、それでも zoom 16、17以上になると膨大な数のファイルとなるため、zoom毎、タイル毎には無理がある。 OSMバイナリレコードと同様に、例えば zoom 12 にまとめておくなどの対策がいるであろう。

以前の地名管理では、zoom毎に全タイルの情報を管理していた。 ファイルサイズ的にはこの10~100倍となるため、全タイル分を1ファイルにするのは無理がある。 やはり zoom 7~12 くらいで分散管理することになろう。

ごく簡単なもので済むという見通しがつかない限り、プログラム開発は行わない。 当面は、現状のプログラムの見直しで、タイル境界で描画が途切れることを極力減らしたい。

リファレンス

[1] TextViewの文字をフチありにする