akvo / akvo-lumen

Make sense of your data
https://akvo.org/akvo-lumen
GNU Affero General Public License v3.0
63 stars 18 forks source link

Admin boundaries in maps - investigation #1059

Closed nadiagorchakova closed 7 years ago

nadiagorchakova commented 7 years ago

Context

Part of contractual obligations to the 9 country program

Example from Liberia water atlas:

screen shot 2017-09-11 at 09 29 52

The map feature is also supposed to support 'filtering/grouping on admin boundaries'

Problem or idea

What is the cause?

Solution or next step

How could we solve it?

jonase commented 7 years ago

I did some initial investigation on this and just want to quickly document the steps I took

Preparing the database

shp2pgsql -I -s 4326 \
  UGA_adm_shp/UGA_adm4.shp \
  public.uga_adm4 > uga_adm4.sql

Create a map in Lumen

gabemart commented 7 years ago

I have explored the visual side a bit more, building on what Jonas documented. Here are some more findings:

Slow performance with large geoms

Postgres seems to be slow when the number of datapoints is low and the size (i.e. physical size) of the geoms is large. I discovered this when trying to color the admin boundary for the USA at admin level 0, which is the whole country. This is a bit counter intuitive - I had thought that smaller admin boundaries would be expensive queries, because there will be lots and lots of boundaries.

This is a known issue - see 7.1 in https://postgis.net/docs/performance_tips.html

Here are some example queries running on USA admin boundary level 1 (level 0 is too slow for effective testing), and using a test dataset of food service data

lumen_tenant_1=> explain analyze SELECT avg(ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.c14) AS foo FROM usa1 LEFT JOIN ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e ON st_contains(usa1.geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) GROUP BY usa1.geom;
                                                                            QUERY PLAN                                                                             
-------------------------------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate  (cost=76117.47..76118.65 rows=52 width=662122) (actual time=440359.728..441245.584 rows=52 loops=1)
   Group Key: usa1.geom
   ->  Sort  (cost=76117.47..76117.65 rows=70 width=662122) (actual time=440316.339..440334.623 rows=4112 loops=1)
         Sort Key: usa1.geom
         Sort Method: quicksort  Memory: 543kB
         ->  Nested Loop Left Join  (cost=0.00..56308.83 rows=70 width=662122) (actual time=1765.749..438438.254 rows=4112 loops=1)
               Join Filter: ((usa1.geom ~ ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) AND _st_contains(usa1.geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1))
               Rows Removed by Join Filter: 207367
               ->  Seq Scan on usa1  (cost=0.00..5.52 rows=52 width=662114) (actual time=0.028..0.299 rows=52 loops=1)
               ->  Materialize  (cost=0.00..283.99 rows=4066 width=40) (actual time=0.023..23.598 rows=4066 loops=52)
                     ->  Seq Scan on ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e  (cost=0.00..263.66 rows=4066 width=40) (actual time=0.711..15.258 rows=4066 loops=1)
 Planning time: 2.421 ms
 Execution time: 441254.734 ms
(13 rows)

We can speed this up a lot by only considering the admin boundaries that contain at least one datapoint, by adding a WHERE ST_Intersects(usa1.geom, ${table}.${geopoint}) condition - with this condition, the query runs in 1/170 of the time

lumen_tenant_1=> explain analyze SELECT avg(ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.c14) AS foo FROM usa1 LEFT JOIN ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e ON st_contains(usa1.geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) WHERE ST_Intersects(usa1.geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) GROUP BY usa1.geom;
                                                                            QUERY PLAN                                                                             
-------------------------------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate  (cost=8084.43..8084.79 rows=18 width=662122) (actual time=2021.925..2583.564 rows=5 loops=1)
   Group Key: usa1.geom
   ->  Sort  (cost=8084.43..8084.47 rows=18 width=662122) (actual time=2021.638..2034.276 rows=4065 loops=1)
         Sort Key: usa1.geom
         Sort Method: quicksort  Memory: 414kB
         ->  Nested Loop  (cost=0.14..2991.55 rows=18 width=662122) (actual time=1.549..1373.434 rows=4065 loops=1)
               ->  Seq Scan on ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e  (cost=0.00..263.66 rows=4066 width=40) (actual time=0.157..11.961 rows=4066 loops=1)
               ->  Index Scan using usa1_geom_idx on usa1  (cost=0.14..0.66 rows=1 width=662114) (actual time=0.318..0.323 rows=1 loops=4066)
                     Index Cond: ((geom ~ ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) AND (geom && ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1))
                     Filter: (_st_contains(geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) AND _st_intersects(geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1))
                     Rows Removed by Filter: 1
 Planning time: 0.662 ms
 Execution time: 2584.153 ms
(13 rows)

The "performance tips" document linked above suggests setting SET enable_seqscan TO off to improve performance, but that does not speed up the query in our case:

lumen_tenant_1=> SET enable_seqscan TO off; explain analyze SELECT avg(ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.c14) AS foo FROM usa1 LEFT JOIN ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e ON st_contains(usa1.geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) WHERE ST_Intersects(usa1.geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) GROUP BY usa1.geom;
SET
                                                                                  QUERY PLAN                                                                                   
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate  (cost=10000008084.43..10000008084.79 rows=18 width=662122) (actual time=2073.424..2632.973 rows=5 loops=1)
   Group Key: usa1.geom
   ->  Sort  (cost=10000008084.43..10000008084.47 rows=18 width=662122) (actual time=2073.123..2085.648 rows=4065 loops=1)
         Sort Key: usa1.geom
         Sort Method: quicksort  Memory: 414kB
         ->  Nested Loop  (cost=10000000000.14..10000002991.55 rows=18 width=662122) (actual time=0.976..1411.484 rows=4065 loops=1)
               ->  Seq Scan on ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e  (cost=10000000000.00..10000000263.66 rows=4066 width=40) (actual time=0.130..11.961 rows=4066 loops=1)
               ->  Index Scan using usa1_geom_idx on usa1  (cost=0.14..0.66 rows=1 width=662114) (actual time=0.327..0.332 rows=1 loops=4066)
                     Index Cond: ((geom ~ ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) AND (geom && ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1))
                     Filter: (_st_contains(geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) AND _st_intersects(geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1))
                     Rows Removed by Filter: 1
 Planning time: 0.369 ms
 Execution time: 2633.547 ms
(13 rows)

http://revenant.ca/www/postgis/workshop/indexing.html suggests that adding an index on the geom column might improve performance (on our working dataset - the admin boundary dataset had a geo index created as per jonas's instructions above). We can do this with CREATE INDEX geoidx ON ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e USING GIST (d1); VACUUM ANALYZE ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e. After doing this, I saw a small increase in peformance:

lumen_tenant_1=> explain analyze SELECT avg(ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.c14) AS foo FROM usa1 LEFT JOIN ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e ON st_contains(usa1.geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) WHERE ST_Intersects(usa1.geom, ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e.d1) GROUP BY usa1.geom;
                                                                              QUERY PLAN                                                                              
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate  (cost=10000005433.63..10000005433.99 rows=18 width=662122) (actual time=1814.368..2395.544 rows=5 loops=1)
   Group Key: usa1.geom
   ->  Sort  (cost=10000005433.63..10000005433.67 rows=18 width=662122) (actual time=1814.103..1827.176 rows=4065 loops=1)
         Sort Key: usa1.geom
         Sort Method: quicksort  Memory: 414kB
         ->  Nested Loop  (cost=10000000000.15..10000000340.75 rows=18 width=662122) (actual time=73.710..1280.051 rows=4065 loops=1)
               ->  Seq Scan on usa1  (cost=10000000000.00..10000000005.52 rows=52 width=662114) (actual time=0.025..0.232 rows=52 loops=1)
               ->  Index Scan using geoidx on ds_52f8a8f1_fd45_4321_b6b8_b9e27c16533e  (cost=0.15..6.44 rows=1 width=40) (actual time=1.956..20.762 rows=78 loops=52)
                     Index Cond: ((usa1.geom ~ d1) AND (usa1.geom && d1))
                     Filter: (_st_contains(usa1.geom, d1) AND _st_intersects(usa1.geom, d1))
                     Rows Removed by Filter: 78
 Planning time: 0.365 ms
 Execution time: 2400.522 ms
(13 rows)

Color manipulation

We need to color the admin boundaries in a data-driven way.

One simple method is to pull a value directly from the SQL and use it in cartoCss - e.g. SELECT avg(c14) AS foo... in the SQL, and then #a { polygon-opacity: [foo]}in the cartoCss. This appears to work only for cartocss properties which accept a number directly, like opacity. Trying to use [foo] in a color string, like color: rgb([foo], 0, 0), or in a color function, like lighten(red, [foo]) will not work. Similarly, using a variable like @value: [foo]; color: rgb(@value, 0, 0) will not work.

This could be a problem, as manipulating color using only opacity does not give us much flexibility, and raises additional problems in multi-layer maps - high-opacity colors obscure map details (e.g. labels) from layers further down the stack.

One workaround would be to generate fixed cartocss selectors programatically, like [value > 0.99] { color: rgb(255, 0, 0) } [value > 0.98] { color: rgb(254, 0, 0,) }... but this is clearly not an attractive option.

Another workaround is to generate color strings in the SQL query directly, like SELECT concat('rgb(', avg(c14), ', 0, 0)') AS foo... - when done this way, the resulting string can be used directly in the cartocss, like { color: [foo] }.

I've used RGB in the above examples for simplicity, but in practise we may want to use hsl or a color function.

Things to think about

Example map using a monochrome base layer and color strings generated in sql query

image

iperdomo commented 7 years ago

We have found that depending on the available data (shapefile) we can a more (or less accurate) admin boundaries. In this example we're trying to color the "Grand Cape Mount" county.

GDAM liberia-grand-cape-mount

Liberia Goverment liberia-adm1

iperdomo commented 7 years ago

The same county with GAUL shapefile (http://www.fao.org/geonetwork/srv/en/metadata.show?id=12691) gaul-liberia

iperdomo commented 7 years ago

HDX - https://data.humdata.org/dataset/liberia-admin-level-2-boundaries liberia-humandata

iperdomo commented 7 years ago

I'm currently investigating how to speed up the generation of a custom map based on liberia test dataset in (https://github.com/akvo/akvo-maps).

This is the current made up map visualization to color read those counties with less than 60% of available water points that are Working and protected (this is a made up formula, and SQL statement is not optimized)

{
  "version": "1.5.0",
  "layers": [
    {
      "type": "mapnik",
      "options": {
        "sql": "WITH counties AS (SELECT adm1_name, st_union(geom) AS geom FROM lbr_humandata GROUP BY adm1_name ), valid_data AS (  SELECT functionality, geom FROM liberia WHERE geom IS NOT NULL ), liberia_data AS (SELECT c.adm1_name, c.geom AS geom, d.functionality    FROM counties c, valid_data d   WHERE ST_Contains(c.geom, d.geom) ), totals AS (  SELECT adm1_name, count(*) AS total    FROM counties  GROUP BY adm1_name )SELECT l.adm1_name, l.geom, (SUM(CASE WHEN l.functionality = 'Working and protected' THEN 1 ELSE 0 END) / SUM(t.total)) as ratio FROM liberia_data l, totals t WHERE l.adm1_name = t.adm1_name GROUP BY l.adm1_name, l.geom;",
        "geom_column": "geom",
        "srid": 4326,
        "cartocss": "#s { polygon-opacity: 0.25;} #s[ratio<=0.6] {line-color: #FF0000; line-width: 0.5; polygon-fill: #FF0000;}",
        "cartocss_version": "2.0.0",
        "interactivity": "adm1_name"
      }
    }
  ]
}
iperdomo commented 7 years ago

Cold server just restarted

LOG:  execute <unnamed>: SELECT ST_AsBinary("geom") AS geom,"ratio" FROM (WITH counties AS (SELECT adm1_name, st_union(geom) AS geom FROM lbr_humandata GROUP BY adm1_name ), valid_data AS (  SELECT functionality, geom FROM liberia WHERE geom IS NOT NULL ), liberia_data AS (SELECT c.adm1_name, c.geom AS geom, d.functionality    FROM counties c, valid_data d   WHERE ST_Contains(c.geom, d.geom) ), totals AS (  SELECT adm1_name, count(*) AS total    FROM counties  GROUP BY adm1_name )SELECT l.adm1_name, l.geom, (SUM(CASE WHEN l.functionality = 'Working and protected' THEN 1 ELSE 0 END) / SUM(t.total)) as ratio FROM liberia_data l, totals t WHERE l.adm1_name = t.adm1_name GROUP BY l.adm1_name, l.geom) as cdbq WHERE "geom" && ST_SetSRID('BOX3D(-179.9999996156177 -85.05112874664718,179.9999996156177 85.05112874664718)'::box3d, 4326)
LOG:  duration: 1411.366 ms

1-92s

2017-10-02_1911x797_scrot

An easy approach is to create a materialized view with the data, so the database doesn't need to recalculate it on every request.

CREATE MATERIALIZED VIEW AS [QUERY]

The query in the map config is now SELECT * FROM <view_name>, e.g.

{
  "version": "1.5.0",
  "layers": [
    {
      "type": "mapnik",
      "options": {
        "sql": "select * from map_view_1",
        "geom_column": "geom",
        "srid": 4326,
        "cartocss": "#s { polygon-opacity: 0.25;} #s[ratio<=0.6] {line-color: #FF0000; line-width: 0.5; polygon-fill: #FF0000;}",
        "cartocss_version": "2.0.0",
        "interactivity": "adm1_name"
      }
    }
  ]
}

Now the sever responds in milliseconds and not seconds:

LOG:  execute <unnamed>: SELECT ST_AsBinary("geom") AS geom,"ratio" FROM (select * from map_view_1) as cdbq WHERE "geom" && ST_SetSRID('BOX3D(-179.9999996156177 -85.05112874664718,179.9999996156177 85.05112874664718)'::box3d, 4326)
LOG:  duration: 4.768 ms

The initial POST request is 131ms vs 1.74s and the subsequent GET requests in less than 390ms

ms

The user experience of zooming in is also improved, as the requests are faster:

2017-10-02_1912x795_scrot

iperdomo commented 7 years ago

If we use a table CREATE TABLE AS <query> WITH an index on the geom column we make the query even faster.

LOG:  execute <unnamed>: SELECT ST_AsBinary("geom") AS geom,"ratio" FROM (select * from map_view_1) as cdbq WHERE "geom" && ST_SetSRID('BOX3D(-179.9999996156177 -85.05112874664718,179.9999996156177 85.05112874664718)'::box3d, 4326)
LOG:  duration: 4.648 ms

LOG:  execute <unnamed>: SELECT ST_AsBinary("geom") AS geom,"ratio" FROM (select * from t_view_1) as cdbq WHERE "geom" && ST_SetSRID('BOX3D(-179.9999996156177 -85.05112874664718,179.9999996156177 85.05112874664718)'::box3d, 4326)
LOG:  duration: 1.704 ms
iperdomo commented 7 years ago

Less fancy query not using CTE.

{
  "version": "1.5.0",
  "layers": [
    {
      "type": "mapnik",
      "options": {
        "sql": "SELECT adm1_name, geom, (SUM( CASE WHEN functionality = 'Working and protected' THEN 1 ELSE 0 END ))::float / (count(*))::float as ratio FROM (SELECT c.adm1_name, c.geom AS geom, l.functionality FROM (SELECT adm1_name, ST_Union(geom) AS geom FROM lbr_humandata GROUP BY adm1_name) c, liberia l WHERE ST_Contains(c.geom, l.geom)) AS a GROUP BY adm1_name, geom",
        "geom_column": "geom",
        "srid": 4326,
        "cartocss": "#s { polygon-opacity: 0.25;} #s[ratio<=0.6] {line-color: #FF0000; line-width: 0.5; polygon-fill: #FF0000;}",
        "cartocss_version": "2.0.0",
        "interactivity": "adm1_name"
      }
    }
  ]
}
LOG:  execute <unnamed>: SELECT ST_AsBinary("geom") AS geom,"ratio" FROM (SELECT adm1_name, geom, (SUM( CASE WHEN functionality = 'Working and protected' THEN 1 ELSE 0 END ))::float / (count(*))::float as ratio FROM (SELECT c.adm1_name, c.geom AS geom, l.functionality FROM (SELECT adm1_name, ST_Union(geom) AS geom FROM lbr_humandata GROUP BY adm1_name) c, liberia l WHERE ST_Contains(c.geom, l.geom)) AS a GROUP BY adm1_name, geom) as cdbq WHERE "geom" && ST_SetSRID('BOX3D(-179.9999996156177 -85.05112874664718,179.9999996156177 85.05112874664718)'::box3d, 4326)
LOG:  duration: 985.392 ms

Caching the resulting data in a materialized view

LOG:  execute <unnamed>: SELECT ST_AsBinary("geom") AS geom,"ratio" FROM (select * from map_view_1) as cdbq WHERE "geom" && ST_SetSRID('BOX3D(-179.9999996156177 -85.05112874664718,179.9999996156177 85.05112874664718)'::box3d, 4326)
LOG:  duration: 3.115 ms
iperdomo commented 7 years ago

Partially caching the boundaries of counties can save some aggregation work.

test_database=# EXPLAIN ANALYZE SELECT adm1_name, geom, (SUM( CASE WHEN functionality = 'Working and protected' THEN 1 ELSE 0 END ))::float / (count(*))::float as ratio FROM (SELECT c.adm1_name,                                                                                   c.geom AS geom,                                                                               d.functionality                                                                         FROM  (SELECT   adm1_name,                                                                                    St_union(geom) AS geom                                                                FROM    lbr_humandata                                                                        GROUP BY adm1_name) c,                                                                      (SELECT * FROM liberia WHERE geom IS NOT NULL) d                                        WHERE  St_contains(c.geom, d.geom) ) AS a GROUP BY adm1_name, geom;
                                                                    QUERY PLAN                                                                    
--------------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate  (cost=843.53..844.66 rows=15 width=48) (actual time=890.055..960.752 rows=15 loops=1)
   Group Key: lbr_humandata.adm1_name, (st_union(lbr_humandata.geom))
   ->  Sort  (cost=843.53..843.67 rows=58 width=61) (actual time=882.320..956.354 rows=11523 loops=1)
         Sort Key: lbr_humandata.adm1_name, (st_union(lbr_humandata.geom))
         Sort Method: external merge  Disk: 221936kB
         ->  Nested Loop  (cost=70.19..841.83 rows=58 width=61) (actual time=42.924..520.480 rows=11523 loops=1)
               ->  HashAggregate  (cost=70.04..70.23 rows=15 width=40) (actual time=42.839..263.183 rows=15 loops=1)
                     Group Key: lbr_humandata.adm1_name
                     ->  Seq Scan on lbr_humandata  (cost=0.00..69.36 rows=136 width=4273) (actual time=0.019..0.216 rows=136 loops=1)
               ->  Index Scan using liberia_geom_idx on liberia  (cost=0.15..51.39 rows=4 width=53) (actual time=0.597..17.029 rows=768 loops=15)
                     Index Cond: (((st_union(lbr_humandata.geom)) ~ geom) AND (geom IS NOT NULL))
                     Filter: _st_contains((st_union(lbr_humandata.geom)), geom)
                     Rows Removed by Filter: 556
 Planning time: 0.736 ms
 Execution time: 998.822 ms
(15 rows)

test_database=# EXPLAIN ANALYZE SELECT adm1_name, geom, (SUM( CASE WHEN functionality = 'Working and protected' THEN 1 ELSE 0 END ))::float / (count(*))::float as ratio FROM (SELECT c.adm1_name,                                                                                   c.geom AS geom,                                                                               d.functionality                                                                         FROM  (SELECT  * FROM lbr_counties) c,                                                                      (SELECT * FROM liberia WHERE geom IS NOT NULL) d                                        WHERE  St_contains(c.geom, d.geom) ) AS a GROUP BY adm1_name, geom;
                                                                    QUERY PLAN                                                                    
--------------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate  (cost=774.30..775.43 rows=15 width=98) (actual time=544.045..637.141 rows=15 loops=1)
   Group Key: lbr_counties.adm1_name, lbr_counties.geom
   ->  Sort  (cost=774.30..774.44 rows=58 width=111) (actual time=531.859..533.360 rows=11523 loops=1)
         Sort Key: lbr_counties.adm1_name, lbr_counties.geom
         Sort Method: quicksort  Memory: 1630kB
         ->  Nested Loop  (cost=0.15..772.60 rows=58 width=111) (actual time=0.364..376.871 rows=11523 loops=1)
               ->  Seq Scan on lbr_counties  (cost=0.00..1.15 rows=15 width=90) (actual time=0.018..0.043 rows=15 loops=1)
               ->  Index Scan using liberia_geom_idx on liberia  (cost=0.15..51.39 rows=4 width=53) (actual time=0.629..24.978 rows=768 loops=15)
                     Index Cond: ((lbr_counties.geom ~ geom) AND (geom IS NOT NULL))
                     Filter: _st_contains(lbr_counties.geom, geom)
                     Rows Removed by Filter: 556
 Planning time: 0.707 ms
 Execution time: 637.469 ms
(13 rows)
iperdomo commented 7 years ago
{
  "version": "1.5.0",
  "layers": [
    {
      "type": "mapnik",
      "options": {
        "sql": "SELECT adm1_name, geom, (SUM( CASE WHEN functionality = 'Working and protected' THEN 1 ELSE 0 END ))::float / (count(*))::float as ratio FROM (SELECT c.adm1_name, c.geom AS geom, l.functionality FROM (SELECT adm1_name, ST_Union(geom) AS geom FROM lbr_humandata GROUP BY adm1_name) c, liberia l WHERE ST_Contains(c.geom, l.geom)) AS a GROUP BY adm1_name, geom",
        "geom_column": "geom",
        "srid": 4326,
        "cartocss": "#s { text-name: [adm1_name]; text-face-name: 'DejaVu Sans Condensed';  text-size: 12; text-halo-fill: fadeout(white, 30%); text-halo-radius: 2.5; polygon-opacity: 0.25; line-width: 0.5;} #s[ratio<=0.6] {line-color: #FF0000; polygon-fill: #FF0000;}",
        "cartocss_version": "2.0.0",
        "interactivity": "adm1_name"
      }
    }
  ]
}

county-names

nadiagorchakova commented 7 years ago

the latest mockup by Kiarii https://projects.invisionapp.com/share/F8DR392P7#/screens/256230936

was user-tested with a couple of partners