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

はじめに

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

描画方法

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

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

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

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

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

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

Matrix操作で任意の幅だけ曲線から離して描画することも可能なはずであるが、今の所、これには成功していない。 そこで、Bitmapに文字を描画する時点で、上下に同じサイズのマージンを加えておくことにする。 こうすれば、文字は曲線からマージン分だけ離れることになる。

 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;
            }
        }
        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] TextViewの文字をフチありにする