3

I want to create a grid like What3Words app did.
What3Words
Once the camera zooms past a certain level the grid shows up and scales in size as the user zooms.

I've tried TileOverlay and successfully created a grid. The problem was that the grid redraws itself with every zoom. I want the grid rather than redrawn, to be scaled with the zoom level.

I've then moved on to GroundOverlay and used an already drawn grid and I found two issues: The image quality is worse than the original and, like the TileOverlay, it doesn't scale with the zooming.

Then I tried to use polygons to draw the squares and use the longitude and latitude provided by (Map.getProjection().getVisibleRegion()) and because the earth is a sphere the grid's size is inconsistent on different areas.

And now I'm using a canvas to draw it manually.

Does any of you have any idea how to achieve what I'm trying to do?

Thanks in advance.

Community
  • 1
  • 1
tech chief
  • 33
  • 2
  • 4
  • What _should_ happen when the camera moves left-right up-down at same zoom level - is grid "tied" to surface points so it moves as well or is it "stationary". If the latter then I have a suggestion using views... –  Sep 17 '18 at 16:50
  • Thanks @Andy . No, I want the polygon to move with the camera. It is going to be centered, but it moves with the camera and keeps being fixated to the center of the map (not the layout) by getting the latitude and longitude points of the current visible region. The polygon is a square, so when we move closer to the equator, for example, it becomes a rectangle because the earth is a sphere; same happens when moving to the north or south pole. I want to maintain the same ratio across the map. Still, can you tell me your suggestion for stationary points. – tech chief Sep 18 '18 at 06:45
  • After looking at the what3words, I realize the static grid is not what you want; the what3words grid (3m x 3m cells) are aligned to the modeled surface (earth) and subject to the map mercator projection as displayed on screen. This is a very interesting problem; I would assume the grid cell displayed should always display the properly aligned 3x3 (or multiple depending on zoom) square. I'll leave the static view but will for fun try to create the proper grid view for this measurement system and replace. –  Sep 18 '18 at 16:00
  • I was able to get a square, that could be later changed into a grid, in the middle of the screen using the latitude and longitude points and scaling them in the corners to get a square. But, the issue was that when I get close to the equator the square gets squished down slowly into a rectangle that gets smaller in height the closer I am to the equator until it becomes almost a line. I understand that this is due to the spherical shape of the earth; that's the reason I came here looking for answers. Thanks for giving this your time. I'm looking forward to your suggestions. – tech chief Sep 18 '18 at 18:23
  • Posted a moving grid solution which aligns with the w3w reference grid using canvas translate. –  Sep 21 '18 at 02:05

2 Answers2

1

Ok, this answer demonstrates the graphic update to draw and move the grid and also an attempt to align the grid using the w3w API.

So one issue with using the w3w seems to be how to compute the location of a grid cell. Since the algorithm evidently is private, for this implementation the 'grid' rest api call is used for the current screen center point (on idle) and the json response parsed for a candidate reference point.

In this example a polygon is always drawn for the "reference" grid cell obtained from the w3w grid call.

The grid view implementation uses the canvas.translate call to properly align and draw the grid using an offset computed from the reference point.

This works at any latitude due to the use of the SphericalUtil usage for mapping distance to screen pixels.

Recording at bottom (low quality).

Main Activity

Here the w3w grid rest call is made on camera idle and zoom far enough out (there's no need to keep realigning in close zoom) and the result (a corner point of a nearby grid cell to be used as a reference point) is fed to the grid view. A filled polygon is drawn to denote the reference grid cell.

On camera movement, the same reference point is used but the current screen position is used to maintain the proper offsets.

public void what3words() {

    // some initial default position
    LatLng pt = new LatLng(38.547279, -121.461019);

    mMap.setMapType(GoogleMap.MAP_TYPE_SATELLITE);

    // move map to a coordinate
    CameraUpdate cu = CameraUpdateFactory.newLatLng(pt);
    mMap.moveCamera(cu);
    cu = CameraUpdateFactory.zoomTo(14);
    mMap.moveCamera(cu);

    mMap.setOnMapClickListener(new GoogleMap.OnMapClickListener() {
        @Override
        public void onMapClick(LatLng latLng) {
            mMap.addCircle(new CircleOptions().radius(4).strokeColor(Color.BLUE).center(latLng));
        }
    });

    // while the camera is moving just move the grid (realign on idle)
    mMap.setOnCameraMoveListener(new GoogleMap.OnCameraMoveListener() {
        @Override
        public void onCameraMove() {
            ((GridView) findViewById(R.id.grid_any)).setAlignment(
                    null, mMap.getProjection(), mMap.getProjection().getVisibleRegion());
        }
    });

    // on idle fetch the grid using the screen center point
    mMap.setOnCameraIdleListener(new GoogleMap.OnCameraIdleListener() {
        @Override
        public void onCameraIdle() {
            Log.d(TAG,"idle");


            final LatLng centerOfGridCell = mMap.getCameraPosition().target;

            if (!gridSet || mMap.getCameraPosition().zoom < 10) {
                getGrid(centerOfGridCell, new Response.Listener<String>() {
                    @Override
                    public void onResponse(String response) {
                        Log.d(TAG, "reqpt: " + centerOfGridCell + " volley response: " + response);
                        try {
                            JSONObject jsonObject = new JSONObject(response);
                            JSONArray jsonArray = jsonObject.getJSONArray("lines");
                            JSONObject firstList = jsonArray.getJSONObject(1);
                            JSONObject firstPt = firstList.getJSONObject("start");
                            String lat = firstPt.getString("lat");
                            String lng = firstPt.getString("lng");
                            Log.d(TAG, "lat: " + lat + " lng: " + lng);

                            LatLng alignmentPt = new LatLng(Double.parseDouble(lat), Double.parseDouble(lng));
                            Projection p = mMap.getProjection();
                            VisibleRegion vr = p.getVisibleRegion();


                            ((GridView) findViewById(R.id.grid_any)).setAlignment(alignmentPt, p, vr);

                            if (polygon != null) {
                                polygon.remove();
                            }

                            // take alignment point and draw 3 meter square polygon
                            LatLng pt1 = SphericalUtil.computeOffset(alignmentPt, 3, 90);
                            LatLng pt2 = SphericalUtil.computeOffset(pt1, 3, 180);
                            LatLng pt3 = SphericalUtil.computeOffset(pt2, 3, 270);

                            polygon = mMap.addPolygon(new PolygonOptions().add(alignmentPt,
                                    pt1, pt2, pt3, alignmentPt)
                                    .strokeColor(Color.BLUE).strokeWidth(4).fillColor(Color.BLUE));
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    }

                });

                gridSet = true;
            }


        }
    });
}


// Issue request to w3w - REMEMBER TO REPLACE **YOURKEY** ...
private void getGrid(LatLng pt, Response.Listener<String> listener) {

    // something 9 meters to east
    LatLng otherPt = SphericalUtil.computeOffset(pt, 6.0, 225);
    String bboxStr = Double.toString(pt.latitude)+","+
            Double.toString(pt.longitude)+","+
            Double.toString(otherPt.latitude)+","+
            Double.toString(otherPt.longitude);
    RequestQueue rq = Volley.newRequestQueue(this);
    String url = "https://api.what3words.com/v2/grid?bbox="+bboxStr+"&format=json&key=YOURKEY";

    Log.d(TAG,"url="+url);
    StringRequest req = new StringRequest(Request.Method.GET, url, listener, new Response.ErrorListener() {

        @Override
        public void onErrorResponse(VolleyError error) {
            Log.e(TAG, "volley error: "+error);
        }
    });

    rq.add(req);
}

Grid View

The grid view extends View and is in the map layout as a sibling to the map fragment.

The interestings parts are:

setAlignment - here the screen pixel extent of 3 meters is computed using the SphericalUtil class. This screen pixel dimension representing a 3 meter extent (at the provided reference location) is then used to align the grid by computing x/y offsets (which are then used in the onDraw). Note this auto-scales the grid using the 'SphericalUtil.computeOffset' utility to measure a point 3 meters east and as a result compute the screen pixel equivalent of 3 meters.

onDraw - here the translate method of canvas is used to repeat the grid shape starting at the computed offset (in setAlignment).

public class GridView extends View {

    private static final String TAG = GridView.class.getSimpleName();

    BitmapDrawable bm;
    Bitmap bitmap;

    GradientDrawable gd;
    int offsetX = 0;
    int offsetY = 0;
    private int width = 16;


    public GridView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }


    public void setWidth(int w) {
        width = w;
        render();
        invalidate();
    }


    private void render() {
        setShape();
        if (gd != null) {
            bitmap = drawableToBitmap(gd);
            bm = new BitmapDrawable(getResources(), bitmap);
            bm.setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT);
            bm.setBounds(0, 0, width, width);
        }
    }

    Point startPt;
    LatLng savedAlignmentPt;


    public void setAlignment(LatLng alignmentPt, Projection p, VisibleRegion vr) {

        if (alignmentPt == null) {
            alignmentPt = savedAlignmentPt;
        }

        if (alignmentPt == null) {
            return;
        }
        // the alignment point is the a corner of a grid square "near" the center of the screen
        savedAlignmentPt = alignmentPt;

        // compute how many screen pixels are in 3 meters using alignment point
        startPt =  p.toScreenLocation(alignmentPt);
        LatLng upperRight = SphericalUtil.computeOffset(alignmentPt, 3, 90);
        Point upperRtPt = p.toScreenLocation(upperRight);

        int pixelsOf3meters = upperRtPt.x - startPt.x;

        // don't draw grid if too small
        if (pixelsOf3meters < 16) {
            return;
        }

        setWidth(pixelsOf3meters);

        // startPt is screen location of alignment point
        offsetX = (pixelsOf3meters - (startPt.x % pixelsOf3meters));
        offsetY = (pixelsOf3meters - (startPt.y % pixelsOf3meters));

        invalidate();

    }

    private void setShape() {
        int left, right, top, bottom;
        gd = new GradientDrawable();
        gd.setShape(GradientDrawable.RECTANGLE);
        gd.setSize(width, width);
        gd.setStroke(2, Color.argb(0xff, 0xcc, 0x22, 0x22));

    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        Rect rect = canvas.getClipBounds();


        final int cWidth = canvas.getWidth();
        final int cHeight = canvas.getHeight();

        if (bm == null) {
            return;
        }

        final Rect bmRect = bm.getBounds();
        if (bmRect.width() <= 8 || bmRect.height() <= 8) {
            return;
        }


        final int iterX = iterations(cWidth, -offsetX, bmRect.width());
        final int iterY = iterations(cHeight, -offsetY, bmRect.height());

        canvas.translate(-offsetX, -offsetY);

        for (int x = 0; x < iterX; x++) {
            for (int y = 0; y < iterY; y++) {
                bm.draw(canvas);
                canvas.translate(.0F, bmRect.height());
            }
            canvas.translate(bmRect.width(), -bmRect.height() * iterY);
        }
    }

    private static int iterations(int total, int start, int side) {
        final int diff = total - start;
        final int base = diff / side;
        return base + (diff % side > 0 ? 1 : 0);
    }

    public static Bitmap drawableToBitmap (Drawable drawable) {
        Bitmap bitmap = null;

        if (drawable instanceof BitmapDrawable) {
            BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
            if(bitmapDrawable.getBitmap() != null) {
                return bitmapDrawable.getBitmap();
            }
        }

        if(drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) {
            bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); // Single color bitmap will be created of 1x1 pixel
        } else {
            bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
        }

        Canvas canvas = new Canvas(bitmap);
        drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
        drawable.draw(canvas);
        return bitmap;
    }
}

Notes:

enter image description here enter image description here


I'm having my own battle with the w3w grid API. When I compute the distance between start/end points for each point in the list returned, I get 4.24264 meters so clearly I'm not getting something. Here's a simple method to show results and the screen shot (white=current center used in request, any other color=start-end of a point pair in list; end points have black outline). Here also it becomes clear which point is used to align the grid.

Interestingly though, the start of one "line" does appear to be 3 meters from the start of the next line (compare red-start to blue-start):

enter image description here

Code:

 // plot each point as a circle
 for (int i = 0; i < jsonArray.length(); i++) {
     JSONObject startPt = jsonArray.getJSONObject(i).getJSONObject("start");
     JSONObject endPt = jsonArray.getJSONObject(i).getJSONObject("end");
     LatLng start = new LatLng(Double.parseDouble(startPt.getString("lat")), Double.parseDouble(startPt.getString("lng")));
     LatLng end = new LatLng(Double.parseDouble(endPt.getString("lat")), Double.parseDouble(endPt.getString("lng")));
     int c = colors[(i % colors.length)];
     mMap.addCircle(new CircleOptions().center(start).strokeColor(c).fillColor(c).radius(1));
     mMap.addCircle(new CircleOptions().center(end).strokeColor(Color.BLACK).fillColor(c).radius(1).strokeWidth(2));

     Log.d(TAG, "d  = "+SphericalUtil.computeDistanceBetween(start,end));
 }
 mMap.addCircle(new CircleOptions().center(centerOfGridCell).strokeColor(Color.WHITE).radius(1).strokeWidth(4));
  • Your answer is exactly what I want. Thank you @Andy. I'm trying to get it to work. I've worked through most errors except ` getGrid(centerOfGridCell, new Response.Listener()` it gives me an error in `getGrid` and Listener in Response. What libraries did you use for these two? Again, thanks. – tech chief Sep 22 '18 at 10:35
  • @techchief I updated Main activity code section with the 'getGrid' method; it just uses Volley to issue a request to the w3w site. The Response.Listener is part of android's volley: https://developer.android.com/training/volley/. –  Sep 22 '18 at 11:31
  • If you reach a good understanding of what the grid api is returning please post - use the extra code to plot the circles to help see what are the points. –  Sep 22 '18 at 11:37
  • I'm still reading the w3w api, but frankly, I want to build my own thing. Thanks to your help I have a pretty good understanding of what I'm going to do about the polygon, which was the main reason why I asked this question. Do you think I can use the grid class to create the grid without using the w3w api? – tech chief Sep 22 '18 at 13:33
0

Here's the stationary approach which you may already have tried. In summary, create Shapes for each grid size (in this example small, medium, large); create views in main layout, one for each grid size (all visibility 'Gone' or one visible); create a class to extend View just so the shape can be tiled (I did not know how to set shape tiled in xml); and in this test the zoom listener changes the "Visible" views on zoom < 10 (small) ; zoom = 11 (medium); zoom > 11 (large).

First the recording and then the code:

enter image description here

Code

Shapes

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" 
android:shape="rectangle" >
    <size android:width="8dp" android:height="8dp" />
    <stroke android:width="1px" android:color="@color/gridLine" />
</shape>

<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"  >
    <size android:width="12dp" android:height="12dp" />
    <stroke android:width="1px" android:color="@color/gridLine" />
</shape>

<shape xmlns:android="http://schemas.android.com/apk/res/android"  android:shape="rectangle" >
    <size android:width="16dp" android:height="16dp" />
    <stroke android:width="1px" android:color="@color/gridLine" />
</shape>

Main Layout (the grid views (only at most one of which is ever visible) are peers to the map and are overtop)

<RelativeLayout android:layout_height="match_parent" android:layout_width="match_parent"
    xmlns:android="http://schemas.android.com/apk/res/android">

<fragment xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:map="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/map"
    android:name="com.google.android.gms.maps.SupportMapFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="stuff.MapsActivity" />

<stuff.GridView
    android:id="@+id/grid_small"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/grid_square_small"
    android:visibility="visible"/>

    <stuff.GridView
        android:id="@+id/grid_medium"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@drawable/grid_square_medium"
        android:visibility="gone"/>

    <stuff.GridView
        android:id="@+id/grid_large"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@drawable/grid_square_large"
        android:visibility="gone"/>

</RelativeLayout>

GridView (nothing exciting here; just used to set shape as REPEAT which I did not know how to do in xml).

package stuff;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Shader;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.View;

public class GridView extends View {
    BitmapDrawable bm;

    public GridView(Context context, AttributeSet attrs) {
        super(context, attrs);

        Drawable d= getBackground();
        if (d != null) {
            Bitmap b = drawableToBitmap(d);
            bm = new BitmapDrawable(getResources(), b);
            bm.setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT);
        }

    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        bm.setBounds(canvas.getClipBounds());
        bm.draw(canvas);
    }

    public static Bitmap drawableToBitmap (Drawable drawable) {
        Bitmap bitmap = null;

        if (drawable instanceof BitmapDrawable) {
            BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
            if(bitmapDrawable.getBitmap() != null) {
                return bitmapDrawable.getBitmap();
            }
        }

        if(drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) {
            bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); // Single color bitmap will be created of 1x1 pixel
        } else {
            bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
        }

        Canvas canvas = new Canvas(bitmap);
        drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
        drawable.draw(canvas);
        return bitmap;
    }
}

Main Activity (Map) (just the camera listener)

int lastZoom = -1;
public void gridTest() {
    mMap.setOnCameraMoveListener(new GoogleMap.OnCameraMoveListener() {
        @Override
        public void onCameraMove() {
            int zoomInt = (int) mMap.getCameraPosition().zoom;
            if (lastZoom == -1) {
                lastZoom = zoomInt;
                return;
            }
            Log.d(TAG, "zoom= "+zoomInt);
            if (zoomInt < 10) {
                findViewById(R.id.grid_small).setVisibility(View.VISIBLE);
                findViewById(R.id.grid_medium).setVisibility(View.GONE);
                findViewById(R.id.grid_large).setVisibility(View.GONE);
            } else if (zoomInt < 11) {
                findViewById(R.id.grid_small).setVisibility(View.GONE);
                findViewById(R.id.grid_medium).setVisibility(View.VISIBLE);
                findViewById(R.id.grid_large).setVisibility(View.GONE);
            } else {
                findViewById(R.id.grid_small).setVisibility(View.GONE);
                findViewById(R.id.grid_medium).setVisibility(View.GONE);
                findViewById(R.id.grid_large).setVisibility(View.VISIBLE);
            }
            lastZoom = zoomInt;
        }
    });
}

Notes:

  • The drawableToBitmap is from here: How to convert a Drawable to a Bitmap?
  • Notice the zoom control is occluded so that's not nice.
  • Maybe this could be modified to support panning as well - in the onDraw method of view to offset the grid in the x/y.
  • In theory, you could use the setBounds in the GridView.onDraw to support panning by adjusting 'left', 'top' based on panning movement. The range of adjustment would be [0, gridwidth) (since it's a square and repeating).
  • Thank you so much @Andy for taking the time to write this in-depth. I appreciate it. But like you said in your last comment this is not what I'm looking for. I played around with some methods and found that I can create a perfect square by getting the square points in pixel and convert them to LatLng using `getProjection().fromScreenLocation(Point)` but I'm still going in circles when it gets to getting what the what3words grid looks like. I'm getting close to an answer, but I fear it won't be a solid one. Of course, if I find it, I'll share it. Thank you, again. – tech chief Sep 18 '18 at 18:19