From 9196b87b370577054ba5b95863d2cd121c0d4444 Mon Sep 17 00:00:00 2001 From: chejennifer <69875368+chejennifer@users.noreply.github.com> Date: Mon, 13 Feb 2023 17:39:47 -0800 Subject: [PATCH] [disasters] draw geojsons for drought area and storm path (#2229) - add new flask endpoint for getting geojsons for a list of entities and a specific geojson prop - fix and add new choropleth flask endpoint tests (some tests were not being run and those tests had some bugs) - add new drawPolygonLayer and drawPathLayer functions & refactor some of the map drawing functions ![storm](https://user-images.githubusercontent.com/69875368/218555500-84051f08-7b49-4c09-8b7a-1fa3157fd2e5.jpg) ![drought](https://user-images.githubusercontent.com/69875368/218555536-a6a066a1-a54c-461e-86eb-bd604ecfa9f2.jpg) --- .../config/disaster_dashboard/Earth.textproto | 4 +- server/routes/api/choropleth.py | 129 ++++--- .../lib/subject_page_config_content_test.py | 17 +- server/tests/routes/api/choropleth_test.py | 327 +++++++++++++----- static/css/shared/_tiles.scss | 18 +- static/js/chart/draw_d3_map.ts | 201 +++++++++-- static/js/chart/draw_map_utils.ts | 2 +- static/js/chart/types.ts | 2 +- .../subject_page/disaster_event_block.tsx | 71 ++-- .../tiles/disaster_event_map_tile.tsx | 178 ++++++++-- static/js/utils/disaster_event_map_utils.tsx | 50 ++- .../tests/disaster_event_map_utils.test.ts | 66 +--- static/tsconfig.json | 2 +- 13 files changed, 776 insertions(+), 291 deletions(-) diff --git a/server/config/disaster_dashboard/Earth.textproto b/server/config/disaster_dashboard/Earth.textproto index 580dc124d9..ce59bde527 100644 --- a/server/config/disaster_dashboard/Earth.textproto +++ b/server/config/disaster_dashboard/Earth.textproto @@ -230,7 +230,7 @@ categories { tiles { type: DISASTER_EVENT_MAP disaster_event_map_tile_spec: { - point_event_type_key: "drought" + polygon_event_type_key: "drought" } } tiles { @@ -389,7 +389,7 @@ categories { tiles { type: DISASTER_EVENT_MAP disaster_event_map_tile_spec: { - point_event_type_key: "storm" + path_event_type_key: "storm" } } tiles { diff --git a/server/routes/api/choropleth.py b/server/routes/api/choropleth.py index 5ae25bca80..b88702e229 100644 --- a/server/routes/api/choropleth.py +++ b/server/routes/api/choropleth.py @@ -14,6 +14,7 @@ """This module defines the endpoints that support drawing a choropleth map. """ import json +from typing import List import urllib.parse from flask import Blueprint @@ -72,6 +73,9 @@ "EurostatNUTS3": "geoJsonCoordinatesDP1", "IPCCPlace_50": "geoJsonCoordinates", } +MULTILINE_GEOJSON_TYPE = "MultiLineString" +MULTIPOLYGON_GEOJSON_TYPE = "MultiPolygon" +POLYGON_GEOJSON_TYPE = "Polygon" @cache.memoize(timeout=3600 * 24) # Cache for one day. @@ -130,30 +134,67 @@ def get_choropleth_display_level(geoDcid): return geoDcid, display_level -def reverse_geojson_righthand_rule(geoJsonCords, obj_type): - """Changes GeoJSON handedness to the reverse of the righthand_rule. +def get_multipolygon_geojson_coordinates(geojson): + """ + Gets geojson coordinates in the form of multipolygon geojson coordinates that + are in the reverse of the righthand_rule. GeoJSON is stored in DataCommons following the right hand rule as per rfc spec (https://www.rfc-editor.org/rfc/rfc7946). However, d3 requires geoJSON that violates the right hand rule (see explanation on d3 winding order here: - https://stackoverflow.com/a/49311635). This function fixes these lists to be - in the format expected by D3 and turns all polygons into multipolygons for + https://stackoverflow.com/a/49311635). This function returns coordinates in + the format expected by D3 and turns all polygons into multipolygons for downstream consistency. Args: - geoJsonCords: Nested list of geojson. - obj_type: Object feature type. + geojson: geojson of type MultiPolygon or Polygon Returns: - Nested list of geocoords. + Nested list of geo coordinates. """ - if obj_type == "Polygon": - geoJsonCords[0].reverse() - return [geoJsonCords] - elif obj_type == "MultiPolygon": - for polygon in geoJsonCords: + # The geojson data for each place varies in whether it follows the + # righthand rule or not. We want to ensure geojsons for all places + # does follow the righthand rule. + right_handed_geojson = rewind(geojson) + geojson_type = right_handed_geojson['type'] + geojson_coords = right_handed_geojson['coordinates'] + if geojson_type == POLYGON_GEOJSON_TYPE: + geojson_coords[0].reverse() + return [geojson_coords] + elif geojson_type == MULTIPOLYGON_GEOJSON_TYPE: + for polygon in geojson_coords: polygon[0].reverse() - return geoJsonCords + return geojson_coords else: - assert False, f"Type {obj_type} unknown!" + assert False, f"Type {geojson_type} unknown!" + + +def get_geojson_feature(geo_id: str, geo_name: str, json_text: List[str]): + """ + Gets a single geojson feature from a list of json strings + """ + # Exclude geo if no or multiple renderings are present. + if len(json_text) != 1: + return None + geojson = json.loads(json_text[0]) + geo_feature = { + "type": "Feature", + "id": geo_id, + "properties": { + "name": geo_name, + "geoDcid": geo_id, + } + } + geojson_type = geojson.get("type", "") + if geojson_type == MULTILINE_GEOJSON_TYPE: + geo_feature['geometry'] = geojson + elif geojson_type == POLYGON_GEOJSON_TYPE or geojson_type == MULTIPOLYGON_GEOJSON_TYPE: + coordinates = get_multipolygon_geojson_coordinates(geojson) + geo_feature['geometry'] = { + "type": "MultiPolygon", + "coordinates": coordinates + } + else: + geo_feature = None + return geo_feature @bp.route('/geojson') @@ -189,39 +230,43 @@ def geojson(): ['geoId/46102'], 'geoJsonCoordinates').get('geoId/46102', '') for geo_id, json_text in geojson_by_geo.items(): if json_text and geo_id in names_by_geo: - geo_feature = { - "type": "Feature", - "geometry": { - "type": "MultiPolygon", - }, - "id": geo_id, - "properties": { - "name": names_by_geo.get(geo_id, "Unnamed Area"), - "geoDcid": geo_id, - } - } - # Load, simplify, and add geoJSON coordinates. - # Exclude geo if no or multiple renderings are present. - if len(json_text) != 1: - continue - geojson = json.loads(json_text[0]) - # The geojson data for each place varies in whether it follows the - # righthand rule or not. We want to ensure geojsons for all places - # does follow the righthand rule. - geojson = rewind(geojson) - geo_feature['geometry']['coordinates'] = ( - reverse_geojson_righthand_rule(geojson['coordinates'], - geojson['type'])) - features.append(geo_feature) - return Response(json.dumps({ + geo_name = names_by_geo.get(geo_id, "Unnamed Area") + geo_feature = get_geojson_feature(geo_id, geo_name, json_text) + if geo_feature: + features.append(geo_feature) + result = { "type": "FeatureCollection", "features": features, "properties": { "current_geo": place_dcid } - }), - 200, - mimetype='application/json') + } + return Response(json.dumps(result), 200, mimetype='application/json') + + +@bp.route('/entity-geojson', methods=['POST']) +def entity_geojson(): + """Gets geoJson data for a list of entities and a specified property to use to + get the geoJson data""" + entities = request.json.get("entities", []) + geojson_prop = request.json.get("geoJsonProp") + if not geojson_prop: + return "error: must provide a geoJsonProp field", 400 + features = [] + geojson_by_entity = dc.property_values(entities, geojson_prop) + for entity_id, json_text in geojson_by_entity.items(): + if json_text: + geo_feature = get_geojson_feature(entity_id, entity_id, json_text) + if geo_feature: + features.append(geo_feature) + result = { + "type": "FeatureCollection", + "features": features, + "properties": { + "current_geo": "" + } + } + return Response(json.dumps(result), 200, mimetype='application/json') def get_choropleth_configs(): diff --git a/server/tests/lib/subject_page_config_content_test.py b/server/tests/lib/subject_page_config_content_test.py index 37e3195b4f..04b727f943 100644 --- a/server/tests/lib/subject_page_config_content_test.py +++ b/server/tests/lib/subject_page_config_content_test.py @@ -77,21 +77,20 @@ def verify_tile(self, tile, stat_vars, msg, event_type_specs): for i, event_type_id in enumerate( tile.disaster_event_map_tile_spec.point_event_type_key): self.assertTrue(event_type_id in event_type_specs, - f"{msg}[event={i},{event_type_id}]") + f"{msg}[pointEvent={i},{event_type_id}]") for i, event_type_id in enumerate( tile.disaster_event_map_tile_spec.polygon_event_type_key): self.assertTrue(event_type_id in event_type_specs, - f"{msg}[event={i},{event_type_id}]") - self.assertTrue( - event_type_specs[event_type_id].get("polygonGeoJsonProp", None), - f"{msg}[event={i},{event_type_id}]") + f"{msg}[polygonEvent={i},{event_type_id}]") + self.assertIsNotNone( + event_type_specs[event_type_id].polygon_geo_json_prop, + f"{msg}[polygonEvent={i},{event_type_id}]") for i, event_type_id in enumerate( tile.disaster_event_map_tile_spec.path_event_type_key): self.assertTrue(event_type_id in event_type_specs, - f"{msg}[event={i},{event_type_id}]") - self.assertTrue( - event_type_specs[event_type_id].get("pathGeoJsonProp", None), - f"{msg}[event={i},{event_type_id}]") + f"{msg}[pathEvent={i},{event_type_id}]") + self.assertIsNotNone(event_type_specs[event_type_id].path_geo_json_prop, + f"{msg}[pathEvent={i},{event_type_id}]") if (tile.type == TileType.HIGHLIGHT or tile.type == TileType.DESCRIPTION): self.assertNotEqual(tile.description, '', msg) diff --git a/server/tests/routes/api/choropleth_test.py b/server/tests/routes/api/choropleth_test.py index 719ac3314f..bf15851f88 100644 --- a/server/tests/routes/api/choropleth_test.py +++ b/server/tests/routes/api/choropleth_test.py @@ -20,6 +20,24 @@ import server.routes.api.shared as shared_api from web_app import app +GEOJSON_MULTIPOLYGON_GEOMETRY = { + "coordinates": [[[[180.0, 40.0], [180.0, 50.0], [170.0, 50.0], + [170.0, 40.0], [180.0, 40.0]]], + [[[-170.0, 40.0], [-170.0, 50.0], [-180.0, 50.0], + [-180.0, 40.0], [-170.0, 40.0]]]], + "type": "MultiPolygon" +} +GEOJSON_POLYGON_GEOMETRY = { + "coordinates": [[[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], + [100.0, 0.0]]], + "type": "Polygon" +} +GEOJSON_MULTILINE_GEOMETRY = { + "coordinates": [[[170.0, 45.0], [180.0, 45.0]], + [[-180.0, 45.0], [-170.0, 45.0]]], + "type": "MultiLineString" +} + class TestChoroplethPlaces(unittest.TestCase): @@ -94,85 +112,143 @@ def test_get_choropleth_display_level_parent_has_equivalent( result = choropleth_api.get_choropleth_display_level(dcid) assert result == (parent_dcid, "County") - class TestGetGeoJson(unittest.TestCase): - - @staticmethod - def side_effect(*args): - return args[0] - - @patch('server.routes.api.choropleth.dc.get_places_in') - @patch('server.routes.api.choropleth.coerce_geojson_to_righthand_rule') - @patch('server.routes.api.choropleth.dc.property_values') - @patch('server.routes.api.choropleth.place_api.get_display_name') - @patch('server.routes.api.choropleth.get_choropleth_display_level') - def test_get_geojson(self, mock_display_level, mock_display_name, - mock_geojson_values, mock_choropleth_helper, - mock_places): - dcid1 = "dcid1" - dcid2 = "dcid2" - mock_display_level.return_value = ("parentDcid", "State") - - def get_places_in_(*args): - if args[0] == ["parentDcid"] and args[1] == "State": - return {"parentDcid": [dcid1, dcid2]} - else: - return {args[0]: []} - - mock_places.side_effect = get_places_in_ - mock_display_name.return_value = {dcid1: dcid1, dcid2: dcid2} - mock_geojson_values.return_value = { - dcid1: json.dumps({ - "coordinates": [], - "type": "Polygon" - }), - dcid2: json.dumps({ - "coordinates": [], - "type": "MultiPolygon" - }) - } - mock_choropleth_helper.side_effect = self.side_effect - response = app.test_client().get('/api/choropleth/geojson?placeDcid=' + - dcid1) - assert response.status_code == 200 - response_data = json.loads(response.data) - assert len(response_data['features']) == 2 - assert len(response_data['properties']['current_geo']) == dcid1 - - @patch('server.routes.api.choropleth.dc.get_places_in') - @patch('server.routes.api.choropleth.coerce_geojson_to_righthand_rule') - @patch('server.routes.api.choropleth.dc.property_values') - @patch('server.routes.api.choropleth.place_api.get_display_name') - def test_get_geojson_with_place_type(self, mock_display_name, - mock_geojson_values, - mock_choropleth_helper, mock_places): - dcid1 = "dcid1" - dcid2 = "dcid2" - - def get_places_in_(*args): - if args[0] == ["parentDcid"] and args[1] == "State": - return {"parentDcid": [dcid1, dcid2]} - else: - return {args[0]: []} - - mock_places.side_effect = get_places_in_ - mock_display_name.return_value = {dcid1: dcid1, dcid2: dcid2} - mock_geojson_values.return_value = { - dcid1: json.dumps({ - "coordinates": [], - "type": "Polygon" - }), - dcid2: json.dumps({ - "coordinates": [], - "type": "MultiPolygon" - }) - } - mock_choropleth_helper.side_effect = self.side_effect - response = app.test_client().get( - f'/api/choropleth/geojson?placeDcid=${dcid1}&placeType=State') - assert response.status_code == 200 - response_data = json.loads(response.data) - assert len(response_data['features']) == 2 - assert len(response_data['properties']['current_geo']) == dcid1 + +class TestGetGeoJson(unittest.TestCase): + + @staticmethod + def side_effect(*args): + return args[0] + + @patch('server.routes.api.choropleth.dc.get_places_in') + @patch('server.routes.api.choropleth.rewind') + @patch('server.routes.api.choropleth.dc.property_values') + @patch('server.routes.api.choropleth.place_api.get_display_name') + @patch('server.routes.api.choropleth.get_choropleth_display_level') + def test_get_geojson(self, mock_display_level, mock_display_name, + mock_geojson_values, mock_rewind_geojson, mock_places): + dcid1 = "dcid1" + dcid2 = "dcid2" + parentDcid = "parentDcid" + mock_display_level.return_value = (parentDcid, "State") + + def get_places_in_(*args): + if args[0] == [parentDcid] and args[1] == "State": + return {parentDcid: [dcid1, dcid2]} + else: + return None + + mock_places.side_effect = get_places_in_ + mock_display_name.return_value = {dcid1: dcid1, dcid2: dcid2} + mock_geojson_values.return_value = { + dcid1: [json.dumps(GEOJSON_POLYGON_GEOMETRY)], + dcid2: [json.dumps(GEOJSON_MULTIPOLYGON_GEOMETRY)] + } + mock_rewind_geojson.side_effect = self.side_effect + response = app.test_client().get( + f'/api/choropleth/geojson?placeDcid={parentDcid}') + assert response.status_code == 200 + response_data = json.loads(response.data) + assert response_data == { + 'type': 'FeatureCollection', + 'features': [{ + 'type': 'Feature', + 'id': 'dcid1', + 'properties': { + 'name': 'dcid1', + 'geoDcid': 'dcid1' + }, + 'geometry': { + 'type': + 'MultiPolygon', + 'coordinates': [[[[100.0, 0.0], [100.0, 1.0], [101.0, 1.0], + [101.0, 0.0], [100.0, 0.0]]]] + } + }, { + 'type': 'Feature', + 'id': 'dcid2', + 'properties': { + 'name': 'dcid2', + 'geoDcid': 'dcid2' + }, + 'geometry': { + 'type': + 'MultiPolygon', + 'coordinates': [[[[180.0, 40.0], [170.0, 40.0], [170.0, 50.0], + [180.0, 50.0], [180.0, 40.0]]], + [[[-170.0, 40.0], [-180.0, + 40.0], [-180.0, 50.0], + [-170.0, 50.0], [-170.0, 40.0]]]] + } + }], + 'properties': { + 'current_geo': 'parentDcid' + } + } + + @patch('server.routes.api.choropleth.dc.get_places_in') + @patch('server.routes.api.choropleth.rewind') + @patch('server.routes.api.choropleth.dc.property_values') + @patch('server.routes.api.choropleth.place_api.get_display_name') + def test_get_geojson_with_place_type(self, mock_display_name, + mock_geojson_values, mock_rewind_geojson, + mock_places): + dcid1 = "dcid1" + dcid2 = "dcid2" + parentDcid = "parentDcid" + + def get_places_in_(*args): + if args[0] == [parentDcid] and args[1] == "State": + return {parentDcid: [dcid1, dcid2]} + else: + return None + + mock_places.side_effect = get_places_in_ + mock_display_name.return_value = {dcid1: dcid1, dcid2: dcid2} + mock_geojson_values.return_value = { + dcid1: [json.dumps(GEOJSON_POLYGON_GEOMETRY)], + dcid2: [json.dumps(GEOJSON_MULTIPOLYGON_GEOMETRY)] + } + mock_rewind_geojson.side_effect = self.side_effect + response = app.test_client().get( + f'/api/choropleth/geojson?placeDcid={parentDcid}&placeType=State') + assert response.status_code == 200 + response_data = json.loads(response.data) + assert response_data == { + 'type': 'FeatureCollection', + 'features': [{ + 'type': 'Feature', + 'id': 'dcid1', + 'properties': { + 'name': 'dcid1', + 'geoDcid': 'dcid1' + }, + 'geometry': { + 'type': + 'MultiPolygon', + 'coordinates': [[[[100.0, 0.0], [100.0, 1.0], [101.0, 1.0], + [101.0, 0.0], [100.0, 0.0]]]] + } + }, { + 'type': 'Feature', + 'id': 'dcid2', + 'properties': { + 'name': 'dcid2', + 'geoDcid': 'dcid2' + }, + 'geometry': { + 'type': + 'MultiPolygon', + 'coordinates': [[[[180.0, 40.0], [170.0, 40.0], [170.0, 50.0], + [180.0, 50.0], [180.0, 40.0]]], + [[[-170.0, 40.0], [-180.0, + 40.0], [-180.0, 50.0], + [-170.0, 50.0], [-170.0, 40.0]]]] + } + }], + 'properties': { + 'current_geo': 'parentDcid' + } + } class TestChoroplethDataHelpers(unittest.TestCase): @@ -477,3 +553,96 @@ def denom_data_side_effect(*args): } } assert response_data == expected_data + + +class TestGetEntityGeoJson(unittest.TestCase): + + @patch('server.routes.api.choropleth.rewind') + @patch('server.routes.api.choropleth.dc.property_values') + def test_get_geojson(self, mock_geojson_values, mock_geojson_rewind): + dcid1 = "dcid1" + dcid2 = "dcid2" + dcid3 = "dcid3" + dcid4 = "dcid4" + dcid5 = "dcid5" + geojson_prop = "geoJsonProp" + test_geojson_1 = json.dumps(GEOJSON_POLYGON_GEOMETRY) + test_geojson_2 = json.dumps(GEOJSON_MULTIPOLYGON_GEOMETRY) + test_geojson_3 = json.dumps(GEOJSON_MULTILINE_GEOMETRY) + + def geojson_side_effect(entities, prop): + if entities == [dcid1, dcid2, dcid3, dcid4, dcid5 + ] and prop == geojson_prop: + return { + dcid1: [test_geojson_1, test_geojson_2], + dcid2: [test_geojson_1], + dcid3: [test_geojson_2], + dcid4: [], + dcid5: [test_geojson_3] + } + else: + return None + + mock_geojson_values.side_effect = geojson_side_effect + + def geojson_rewind_side_effect(geojson): + return geojson + + mock_geojson_rewind.side_effect = geojson_rewind_side_effect + + response = app.test_client().post( + '/api/choropleth/entity-geojson', + json={ + "entities": [dcid1, dcid2, dcid3, dcid4, dcid5], + "geoJsonProp": geojson_prop + }) + assert response.status_code == 200 + response_data = json.loads(response.data) + assert response_data == { + 'type': 'FeatureCollection', + 'features': [{ + 'type': 'Feature', + 'id': 'dcid2', + 'properties': { + 'name': 'dcid2', + 'geoDcid': 'dcid2' + }, + 'geometry': { + 'type': + 'MultiPolygon', + 'coordinates': [[[[100.0, 0.0], [100.0, 1.0], [101.0, 1.0], + [101.0, 0.0], [100.0, 0.0]]]] + } + }, { + 'type': 'Feature', + 'id': 'dcid3', + 'properties': { + 'name': 'dcid3', + 'geoDcid': 'dcid3' + }, + 'geometry': { + 'type': + 'MultiPolygon', + 'coordinates': [[[[180.0, 40.0], [170.0, 40.0], [170.0, 50.0], + [180.0, 50.0], [180.0, 40.0]]], + [[[-170.0, 40.0], [-180.0, + 40.0], [-180.0, 50.0], + [-170.0, 50.0], [-170.0, 40.0]]]] + } + }, { + 'type': 'Feature', + 'id': 'dcid5', + 'properties': { + 'name': 'dcid5', + 'geoDcid': 'dcid5' + }, + 'geometry': { + 'coordinates': [[[170.0, 45.0], [180.0, 45.0]], + [[-180.0, 45.0], [-170.0, 45.0]]], + 'type': 'MultiLineString' + } + }], + 'properties': { + 'current_geo': '' + } + } diff --git a/static/css/shared/_tiles.scss b/static/css/shared/_tiles.scss index b424923e0b..ba55df3241 100644 --- a/static/css/shared/_tiles.scss +++ b/static/css/shared/_tiles.scss @@ -290,9 +290,25 @@ $box-shadow-8dp: 0 8px 10px 1px rgba(0, 0, 0, .14), 0 3px 14px 2px rgba(0, 0, 0, .dot { stroke-width: 0.3px; - opacity: .7; + } + + .dot, + .map-polygon-layer, + .map-path-layer path { + opacity: .7 } + .map-path-highlight { + stroke-width: 1.5px; + opacity: 1; + } + + .map-polygon-highlight { + stroke: black; + stroke-width: 0.5px; + opacity: 1; + } + .dot:hover, .dot:focus { stroke: black; diff --git a/static/js/chart/draw_d3_map.ts b/static/js/chart/draw_d3_map.ts index 31eb30cf7f..dfcb8dbf71 100644 --- a/static/js/chart/draw_d3_map.ts +++ b/static/js/chart/draw_d3_map.ts @@ -61,6 +61,10 @@ const ZOOMED_SCALE_AMOUNT = 0.7; const MAP_ITEMS_GROUP_ID = "map-items"; const MAP_GEO_REGIONS_ID = "map-geo-regions"; const STARTING_ZOOM_TRANSFORMATION = d3.zoomIdentity.scale(1).translate(0, 0); +const MAP_POLYGON_LAYER_CLASS = "map-polygon-layer"; +const MAP_POLYGON_HIGHLIGHT_CLASS = "map-polygon-highlight"; +const MAP_PATH_LAYER_CLASS = "map-path-layer"; +const MAP_PATH_HIGHLIGHT_CLASS = "map-path-highlight"; /** * From https://bl.ocks.org/HarryStevens/0e440b73fbd88df7c6538417481c9065 @@ -139,13 +143,21 @@ const onMouseOver = containerElement, geo.properties.geoDcid, canClickRegion(geo.properties.geoDcid) + ? HOVER_HIGHLIGHTED_CLASS_NAME + : HOVER_HIGHLIGHTED_NO_CLICK_CLASS_NAME ); }; const onMouseOut = (containerElement: HTMLDivElement) => (geo: GeoJsonFeature): void => { - mouseOutAction(containerElement, geo.properties.geoDcid); + mouseOutAction(containerElement, geo.properties.geoDcid, [ + HOVER_HIGHLIGHTED_CLASS_NAME, + HOVER_HIGHLIGHTED_NO_CLICK_CLASS_NAME, + ]); + d3.select(containerElement) + .select(`#${TOOLTIP_ID}`) + .style("display", "none"); }; const onMouseMove = @@ -156,7 +168,13 @@ const onMouseMove = ) => (geo: GeoJsonFeature) => { const placeDcid = geo.properties.geoDcid; - mouseHoverAction(containerElement, placeDcid, canClickRegion(placeDcid)); + mouseHoverAction( + containerElement, + placeDcid, + canClickRegion(placeDcid) + ? HOVER_HIGHLIGHTED_CLASS_NAME + : HOVER_HIGHLIGHTED_NO_CLICK_CLASS_NAME + ); const place = { dcid: placeDcid, name: geo.properties.name, @@ -173,41 +191,42 @@ const onMapClick = (geo: GeoJsonFeature) => { if (!canClickRegion(geo.properties.geoDcid)) return; redirectAction(geo.properties); - mouseOutAction(containerElement, geo.properties.geoDcid); + mouseOutAction(containerElement, geo.properties.geoDcid, [ + HOVER_HIGHLIGHTED_CLASS_NAME, + HOVER_HIGHLIGHTED_NO_CLICK_CLASS_NAME, + ]); + d3.select(containerElement) + .select(`#${TOOLTIP_ID}`) + .style("display", "none"); }; function mouseOutAction( containerElement: HTMLDivElement, - placeDcid: string + placeDcid: string, + hoverClassNames: string[] ): void { const container = d3.select(containerElement); container.classed(HOVER_HIGHLIGHTED_CLASS_NAME, false); - container - .select(`#${getPlacePathId(placeDcid)}`) - .classed(HOVER_HIGHLIGHTED_CLASS_NAME, false) - .classed(HOVER_HIGHLIGHTED_NO_CLICK_CLASS_NAME, false); + const pathSelection = container.select(`#${getPlacePathId(placeDcid)}`); + for (const className of hoverClassNames) { + pathSelection.classed(className, false); + } // bring original highlighted region back to the top container.select("." + HIGHLIGHTED_CLASS_NAME).raise(); - container.select(`#${TOOLTIP_ID}`).style("display", "none"); } function mouseHoverAction( containerElement: HTMLDivElement, placeDcid: string, - canClick: boolean + hoverClassName: string ): void { const container = d3 .select(containerElement) .classed(HOVER_HIGHLIGHTED_CLASS_NAME, true); - const geoPath = container.select(`#${getPlacePathId(placeDcid)}`).raise(); - // show highlighted border and show cursor as a pointer - if (canClick) { - geoPath.classed(HOVER_HIGHLIGHTED_CLASS_NAME, true); - } else { - geoPath.classed(HOVER_HIGHLIGHTED_NO_CLICK_CLASS_NAME, true); - } - // show tooltip - container.select(`#${TOOLTIP_ID}`).style("display", "block"); + container + .select(`#${getPlacePathId(placeDcid)}`) + .raise() + .classed(hoverClassName, true); } function addTooltip(containerElement: HTMLDivElement): void { @@ -312,6 +331,36 @@ function getValue( return undefined; } +// Adds a layer of geojson features to a map and returns that layer +function addGeoJsonLayer( + containerElement: HTMLDivElement, + geoJson: GeoJsonData, + projection: d3.GeoProjection, + layerClassName?: string, + layerId?: string +): d3.Selection { + // Create the new layer + const mapObjectsLayer = d3 + .select(containerElement) + .select(`#${MAP_ITEMS_GROUP_ID}`) + .append("g"); + if (layerClassName) { + mapObjectsLayer.attr("class", layerClassName); + } + if (layerId) { + mapObjectsLayer.attr("id", layerId); + } + + // Add the map objects on the layer + const geomap = d3.geoPath().projection(projection); + return mapObjectsLayer + .selectAll("path") + .data(geoJson.features) + .enter() + .append("path") + .attr("d", geomap); +} + /** * Draws the base d3 map * @param containerElement div element to draw the choropleth in @@ -353,22 +402,15 @@ export function drawD3Map( .attr("viewBox", `0 0 ${chartWidth} ${chartHeight}`) .attr("preserveAspectRatio", "xMidYMid meet"); const map = svg.append("g").attr("id", MAP_ITEMS_GROUP_ID); - - // Combine path elements from D3 content. - const mapRegionsLayer = map - .append("g") - .attr("class", "map-regions") - .attr("id", MAP_GEO_REGIONS_ID) - .selectAll("path") - .data(geoJson.features); - - const geomap = d3.geoPath().projection(projection); - - // Build map objects. - const mapObjects = mapRegionsLayer - .enter() - .append("path") - .attr("d", geomap) + // Build the map objects + const mapObjects = addGeoJsonLayer( + containerElement, + geoJson, + projection, + "", + MAP_GEO_REGIONS_ID + ); + mapObjects .attr("class", (geo: GeoJsonFeature) => { // highlight the place of the current page if ( @@ -552,3 +594,92 @@ export function addMapPoints( } return mapPointsLayer; } + +/** + * Adds a layer of polygons on top of a map + * @param containerElement + * @param geoJson + * @param projection + * @param getRegionColor + * @param onClick + */ +export function addPolygonLayer( + containerElement: HTMLDivElement, + geoJson: GeoJsonData, + projection: d3.GeoProjection, + getRegionColor: (geoDcid: string) => string, + onClick: (geoFeature: GeoJsonFeature) => void +): void { + // Build the map objects + const mapObjects = addGeoJsonLayer( + containerElement, + geoJson, + projection, + MAP_POLYGON_LAYER_CLASS + ); + mapObjects + .attr("fill", (d: GeoJsonFeature) => { + return getRegionColor(d.properties.geoDcid); + }) + .attr("id", (d: GeoJsonFeature) => { + return getPlacePathId(d.properties.geoDcid); + }) + .on("mouseover", (d: GeoJsonFeature) => { + mouseHoverAction( + containerElement, + d.properties.geoDcid, + MAP_POLYGON_HIGHLIGHT_CLASS + ); + }) + .on("mouseout", (d: GeoJsonFeature) => { + mouseOutAction(containerElement, d.properties.geoDcid, [ + MAP_POLYGON_HIGHLIGHT_CLASS, + ]); + }) + .on("click", onClick); +} + +/** + * Adds a layer of paths on top of a map + * @param containerElement + * @param geoJson + * @param projection + * @param getRegionColor + * @param onClick + */ +export function addPathLayer( + containerElement: HTMLDivElement, + geoJson: GeoJsonData, + projection: d3.GeoProjection, + getRegionColor: (geoDcid: string) => string, + onClick: (feature: GeoJsonFeature) => void +): void { + // Build map objects. + const mapObjects = addGeoJsonLayer( + containerElement, + geoJson, + projection, + MAP_PATH_LAYER_CLASS + ); + mapObjects + .attr("id", (d: GeoJsonFeature) => { + return getPlacePathId(d.properties.geoDcid); + }) + .attr("stroke-width", STROKE_WIDTH) + .attr("stroke", (d: GeoJsonFeature) => { + return getRegionColor(d.properties.geoDcid); + }) + .on("mouseover", (d: GeoJsonFeature) => { + mouseHoverAction( + containerElement, + d.properties.geoDcid, + MAP_PATH_HIGHLIGHT_CLASS + ); + }) + .on("mouseout", (d: GeoJsonFeature) => { + mouseOutAction(containerElement, d.properties.geoDcid, [ + MAP_PATH_HIGHLIGHT_CLASS, + ]); + }) + .on("click", onClick); +} diff --git a/static/js/chart/draw_map_utils.ts b/static/js/chart/draw_map_utils.ts index db7c6f78b3..ff83e2f520 100644 --- a/static/js/chart/draw_map_utils.ts +++ b/static/js/chart/draw_map_utils.ts @@ -254,5 +254,5 @@ export function getPlacePathId(placeDcid: string): string { if (_.isEmpty(placeDcid)) { return ""; } - return placeDcid.replace("/", "-"); + return placeDcid.replaceAll("/", "-"); } diff --git a/static/js/chart/types.ts b/static/js/chart/types.ts index 964f84e39f..00dde0e260 100644 --- a/static/js/chart/types.ts +++ b/static/js/chart/types.ts @@ -117,7 +117,7 @@ export interface GeoJsonFeatureProperties { } export type GeoJsonFeature = GeoJSON.Feature< - GeoJSON.MultiPolygon, + GeoJSON.MultiPolygon | GeoJSON.MultiLineString, GeoJsonFeatureProperties >; diff --git a/static/js/components/subject_page/disaster_event_block.tsx b/static/js/components/subject_page/disaster_event_block.tsx index 773b34a379..cb30513a2c 100644 --- a/static/js/components/subject_page/disaster_event_block.tsx +++ b/static/js/components/subject_page/disaster_event_block.tsx @@ -221,6 +221,39 @@ export function DisasterEventBlock( } } +// Get the relevant event type specs for a tile +function getTileEventTypeSpecs( + fullEventTypeSpec: Record, + tile: TileConfig +): Record { + const relevantEventSpecs = {}; + if (tile.disasterEventMapTileSpec) { + const pointEventTypeKeys = + tile.disasterEventMapTileSpec.pointEventTypeKey || []; + const polygonEventTypeKeys = + tile.disasterEventMapTileSpec.polygonEventTypeKey || []; + const pathEventTypeKeys = + tile.disasterEventMapTileSpec.pathEventTypeKey || []; + [ + ...pointEventTypeKeys, + ...polygonEventTypeKeys, + ...pathEventTypeKeys, + ].forEach((specId) => { + relevantEventSpecs[specId] = fullEventTypeSpec[specId]; + }); + } + if (tile.topEventTileSpec) { + const specId = tile.topEventTileSpec.eventTypeKey; + relevantEventSpecs[specId] = fullEventTypeSpec[specId]; + } + if (tile.histogramTileSpec) { + const specId = tile.histogramTileSpec.eventTypeKey; + relevantEventSpecs[specId] = fullEventTypeSpec[specId]; + } + return relevantEventSpecs; +} + +// Gets all the relevant event type specs for a list of columns function getBlockEventTypeSpecs( fullEventTypeSpec: Record, columns: ColumnConfig[] @@ -228,19 +261,8 @@ function getBlockEventTypeSpecs( const relevantEventSpecs: Record = {}; for (const column of columns) { for (const t of column.tiles) { - if (t.disasterEventMapTileSpec) { - t.disasterEventMapTileSpec.pointEventTypeKey.forEach((specId) => { - relevantEventSpecs[specId] = fullEventTypeSpec[specId]; - }); - } - if (t.topEventTileSpec) { - const specId = t.topEventTileSpec.eventTypeKey; - relevantEventSpecs[specId] = fullEventTypeSpec[specId]; - } - if (t.histogramTileSpec) { - const specId = t.histogramTileSpec.eventTypeKey; - relevantEventSpecs[specId] = fullEventTypeSpec[specId]; - } + const tileSpecs = getTileEventTypeSpecs(fullEventTypeSpec, t); + Object.assign(relevantEventSpecs, tileSpecs); } } return relevantEventSpecs; @@ -270,25 +292,11 @@ function renderTiles( const className = classNameList.join(" "); switch (tile.type) { case "DISASTER_EVENT_MAP": { - const eventTypeSpec = {}; - const specEventData = { - eventPoints: [], - provenanceInfo: {}, - }; - tile.disasterEventMapTileSpec.pointEventTypeKey.forEach((eventKey) => { - if (!(eventKey in disasterEventData)) { - return; - } - eventTypeSpec[eventKey] = props.eventTypeSpec[eventKey]; - specEventData.eventPoints.push( - ...disasterEventData[eventKey].eventPoints - ); - Object.assign( - specEventData.provenanceInfo, - disasterEventData[eventKey].provenanceInfo - ); + const eventTypeSpec = getTileEventTypeSpecs(props.eventTypeSpec, tile); + const specEventData = {}; + Object.keys(eventTypeSpec).forEach((specId) => { + specEventData[specId] = disasterEventData[specId]; }); - return ( ); } diff --git a/static/js/components/tiles/disaster_event_map_tile.tsx b/static/js/components/tiles/disaster_event_map_tile.tsx index 1c6c200961..c04bcc9ebe 100644 --- a/static/js/components/tiles/disaster_event_map_tile.tsx +++ b/static/js/components/tiles/disaster_event_map_tile.tsx @@ -24,10 +24,16 @@ import React, { useContext, useEffect, useRef, useState } from "react"; import { addMapPoints, + addPathLayer, + addPolygonLayer, drawD3Map, getProjection, } from "../../chart/draw_d3_map"; -import { GeoJsonData, GeoJsonFeatureProperties } from "../../chart/types"; +import { + GeoJsonData, + GeoJsonFeature, + GeoJsonFeatureProperties, +} from "../../chart/types"; import { EARTH_NAMED_TYPED_PLACE, EUROPE_NAMED_TYPED_PLACE, @@ -40,8 +46,12 @@ import { DisasterEventPoint, DisasterEventPointData, } from "../../types/disaster_event_map_types"; -import { EventTypeSpec } from "../../types/subject_page_proto_types"; import { + DisasterEventMapTileSpec, + EventTypeSpec, +} from "../../types/subject_page_proto_types"; +import { + fetchEventGeoJson, getMapPointsData, onPointClicked, } from "../../utils/disaster_event_map_utils"; @@ -72,8 +82,10 @@ interface DisasterEventMapTilePropType { enclosedPlaceType: string; // Map of eventType id to EventTypeSpec eventTypeSpec: Record; - // disaster event data to show on the event map - disasterEventData: DisasterEventPointData; + // DisasterEventPointData to use for this tile + disasterEventData: Record; + // Tile spec with information about what to show on this map + tileSpec: DisasterEventMapTileSpec; } export function DisasterEventMapTile( @@ -82,11 +94,36 @@ export function DisasterEventMapTile( const svgContainerRef = useRef(null); const infoCardRef = useRef(null); const europeanPlaces = useRef([]); - const prevDisasterEventData = useRef(null); const [placeInfo, setPlaceInfo] = useState(null); const { geoJsonData } = useContext(DataContext); + const [polygonGeoJson, setPolygonGeoJson] = useState(null); + const [pathGeoJson, setPathGeoJson] = useState(null); const shouldShowMap = - placeInfo && !_.isEmpty(geoJsonData) && !_.isEmpty(geoJsonData.features); + placeInfo && + !_.isEmpty(geoJsonData) && + !_.isEmpty(geoJsonData.features) && + !_.isNull(polygonGeoJson) && + !_.isNull(pathGeoJson); + + useEffect(() => { + // re-fetch event geojson data for paths and polygons when disaster event + // data changes or tile spec changes + fetchEventGeoJsonData( + props.tileSpec.pathEventTypeKey, + "pathGeoJsonProp", + setPathGeoJson + ); + fetchEventGeoJsonData( + props.tileSpec.polygonEventTypeKey, + "polygonGeoJsonProp", + setPolygonGeoJson + ); + }, [ + props.disasterEventData, + props.tileSpec, + setPathGeoJson, + setPolygonGeoJson, + ]); useEffect(() => { // On initial loading of the component, get list of all European countries @@ -104,15 +141,22 @@ export function DisasterEventMapTile( }, [props]); useEffect(() => { - // re-draw map if placeInfo, geoJsonData, or disasterEventData changes - if ( - shouldShowMap && - !_.isEqual(props.disasterEventData, prevDisasterEventData.current) - ) { - prevDisasterEventData.current = props.disasterEventData; - draw(placeInfo, geoJsonData, props.disasterEventData); + if (shouldShowMap) { + draw( + placeInfo, + geoJsonData, + props.disasterEventData, + polygonGeoJson, + pathGeoJson + ); } - }, [placeInfo, geoJsonData, props.disasterEventData]); + }, [ + placeInfo, + geoJsonData, + props.disasterEventData, + polygonGeoJson, + pathGeoJson, + ]); if (geoJsonData == null || !placeInfo) { return null; @@ -124,8 +168,10 @@ export function DisasterEventMapTile( }; const sources = new Set(); - Object.values(props.disasterEventData.provenanceInfo).forEach((provInfo) => { - sources.add(provInfo.provenanceUrl); + Object.values(props.disasterEventData).forEach((eventData) => { + Object.values(eventData.provenanceInfo).forEach((provInfo) => { + sources.add(provInfo.provenanceUrl); + }); }); return ( @@ -210,13 +256,58 @@ export function DisasterEventMapTile( }); } + /** + * Fetches and sets the geojson for a list of event types and the key to use + * for getting the geojson prop. + */ + function fetchEventGeoJsonData( + eventTypeKeys: string[], + geoJsonPropKey: string, + setEventTypeGeoJson: (eventTypeGeoJson: Record) => void + ): void { + if (_.isEmpty(eventTypeKeys)) { + setEventTypeGeoJson({}); + return; + } + const geoJsonPromises = eventTypeKeys.map((eventType) => { + if ( + props.disasterEventData[eventType] && + geoJsonPropKey in props.eventTypeSpec[eventType] + ) { + const eventDcids = props.disasterEventData[eventType].eventPoints.map( + (point) => point.placeDcid + ); + return fetchEventGeoJson( + eventDcids, + props.eventTypeSpec[eventType][geoJsonPropKey] + ); + } + }); + Promise.all(geoJsonPromises) + .then((geoJsons) => { + const eventTypeGeoJsonData = {}; + eventTypeKeys.forEach((type, i) => { + if (!geoJsons[i]) { + return; + } + eventTypeGeoJsonData[type] = geoJsons[i]; + }); + setEventTypeGeoJson(eventTypeGeoJsonData); + }) + .catch(() => { + setEventTypeGeoJson({}); + }); + } + /** * Draws the disaster event map */ function draw( placeInfo: DisasterEventMapPlaceInfo, geoJsonData: GeoJsonData, - disasterEventData: DisasterEventPointData + disasterEventData: Record, + polygonGeoJson: GeoJsonData, + pathGeoJson: GeoJsonData ): void { const width = svgContainerRef.current.offsetWidth; const height = Math.max( @@ -256,11 +347,54 @@ export function DisasterEventMapTile( placeInfo.selectedPlace.dcid, zoomParams ); - const allMapPointsData = getMapPointsData( - disasterEventData.eventPoints, - props.eventTypeSpec - ); - for (const mapPointsData of Object.values(allMapPointsData)) { + // map of disaster event point id to the disaster event point + const pointsMap = {}; + Object.values(disasterEventData).forEach((data) => { + data.eventPoints.forEach((point) => { + pointsMap[point.placeDcid] = point; + }); + }); + for (const eventType of props.tileSpec.polygonEventTypeKey || []) { + if (!(eventType in polygonGeoJson)) { + continue; + } + addPolygonLayer( + svgContainerRef.current, + polygonGeoJson[eventType], + projection, + () => props.eventTypeSpec[eventType].color, + (geoFeature: GeoJsonFeature) => + onPointClicked( + infoCardRef.current, + svgContainerRef.current, + pointsMap[geoFeature.properties.geoDcid], + d3.event + ) + ); + } + for (const eventType of props.tileSpec.pathEventTypeKey || []) { + if (!(eventType in pathGeoJson)) { + continue; + } + addPathLayer( + svgContainerRef.current, + pathGeoJson[eventType], + projection, + () => props.eventTypeSpec[eventType].color, + (geoFeature: GeoJsonFeature) => + onPointClicked( + infoCardRef.current, + svgContainerRef.current, + pointsMap[geoFeature.properties.geoDcid], + d3.event + ) + ); + } + for (const eventType of props.tileSpec.pointEventTypeKey || []) { + const mapPointsData = getMapPointsData( + disasterEventData[eventType].eventPoints, + props.eventTypeSpec[eventType] + ); const pointsLayer = addMapPoints( svgContainerRef.current, mapPointsData.points, diff --git a/static/js/utils/disaster_event_map_utils.tsx b/static/js/utils/disaster_event_map_utils.tsx index eb45ffefeb..36a601fbd4 100644 --- a/static/js/utils/disaster_event_map_utils.tsx +++ b/static/js/utils/disaster_event_map_utils.tsx @@ -104,6 +104,28 @@ export function fetchGeoJsonData( }); } +/** + * Get promise for geojson data for a list of events and a geojson prop + * @param eventDcids events to get geojson for + * @param geoJsonProp prop to use to get the geojson + */ +export function fetchEventGeoJson( + eventDcids: string[], + geoJsonProp: string +): Promise { + return axios + .post("/api/choropleth/entity-geojson", { + entities: eventDcids, + geoJsonProp, + }) + .then((resp) => { + return resp.data as GeoJsonData; + }) + .catch(() => { + return null; + }); +} + /** * Get a list of dates that encompass all events for a place and a given list of event types * @param eventTypeDcids the list of event types to get dates for @@ -588,14 +610,13 @@ export function getUseCache(): boolean { /** * gets the severity value for a disaster event point * @param eventPoint event point to get the severity value from - * @param eventTypeSpec event type spec used for the disaster event map + * @param eventTypeSpec event type spec to use to get the severity value */ function getSeverityValue( eventPoint: DisasterEventPoint, - eventTypeSpec: Record + eventTypeSpec: EventTypeSpec ): number { - const severityFilter = - eventTypeSpec[eventPoint.disasterType].defaultSeverityFilter; + const severityFilter = eventTypeSpec.defaultSeverityFilter; if (!severityFilter || !(severityFilter.prop in eventPoint.severity)) { return null; } @@ -603,27 +624,24 @@ function getSeverityValue( } /** - * Gets the map points data for each disaster type for a list of disaster event + * Gets the map points data for a disaster type for a list of disaster event * points. * @param eventPoints event points to use for the map points data * @param eventTypeSpec the event type spec for the disaster event map */ export function getMapPointsData( eventPoints: DisasterEventPoint[], - eventTypeSpec: Record -): Record { - const mapPointsData = {}; + eventTypeSpec: EventTypeSpec +): MapPointsData { + const mapPointsData = { + points: [], + values: {}, + }; eventPoints.forEach((point) => { - if (!(point.disasterType in mapPointsData)) { - mapPointsData[point.disasterType] = { - points: [], - values: {}, - }; - } - mapPointsData[point.disasterType].points.push(point); + mapPointsData.points.push(point); const severityValue = getSeverityValue(point, eventTypeSpec); if (severityValue != null) { - mapPointsData[point.disasterType].values[point.placeDcid] = severityValue; + mapPointsData.values[point.placeDcid] = severityValue; } }); return mapPointsData; diff --git a/static/js/utils/tests/disaster_event_map_utils.test.ts b/static/js/utils/tests/disaster_event_map_utils.test.ts index f5da66cac0..73ec52e937 100644 --- a/static/js/utils/tests/disaster_event_map_utils.test.ts +++ b/static/js/utils/tests/disaster_event_map_utils.test.ts @@ -739,61 +739,25 @@ test("fetch data for single event with date as YYYY", () => { }); test("getMapPointsData", () => { - const eventPoints = [ - FIRE_EVENT_POINT_1, - FIRE_EVENT_POINT_2, - EARTHQUAKE_EVENT_1_PROCESSED, - EARTHQUAKE_EVENT_2_PROCESSED, - TORNADO_EVENT_1_PROCESSED, - ]; + const eventPoints = [FIRE_EVENT_POINT_1, FIRE_EVENT_POINT_2]; const eventSpec = { - [STORM_DISASTER_TYPE_ID]: { - id: STORM_DISASTER_TYPE_ID, - name: "", - eventTypeDcids: [], - color: "", - defaultSeverityFilter: null, - displayProp: [], - endDateProp: [], - }, - [FIRE_DISASTER_TYPE_ID]: { - id: FIRE_DISASTER_TYPE_ID, - name: "", - eventTypeDcids: [], - color: "", - defaultSeverityFilter: { - prop: "area", - unit: "SquareKilometer", - lowerLimit: 1, - upperLimit: 10, - }, - displayProp: [], - endDateProp: [], - }, - [EARTHQUAKE_DISASTER_TYPE_ID]: { - id: EARTHQUAKE_DISASTER_TYPE_ID, - name: "", - eventTypeDcids: [], - color: "", - defaultSeverityFilter: null, - displayProp: [], - endDateProp: [], + id: FIRE_DISASTER_TYPE_ID, + name: "", + eventTypeDcids: [], + color: "", + defaultSeverityFilter: { + prop: "area", + unit: "SquareKilometer", + lowerLimit: 1, + upperLimit: 10, }, + displayProp: [], + endDateProp: [], }; const expectedMapPointsData = { - [STORM_DISASTER_TYPE_ID]: { - points: [TORNADO_EVENT_1_PROCESSED], - values: {}, - }, - [FIRE_DISASTER_TYPE_ID]: { - points: [FIRE_EVENT_POINT_1, FIRE_EVENT_POINT_2], - values: { - fire2: 2, - }, - }, - [EARTHQUAKE_DISASTER_TYPE_ID]: { - points: [EARTHQUAKE_EVENT_1_PROCESSED, EARTHQUAKE_EVENT_2_PROCESSED], - values: {}, + points: [FIRE_EVENT_POINT_1, FIRE_EVENT_POINT_2], + values: { + fire2: 2, }, }; const result = getMapPointsData(eventPoints, eventSpec); diff --git a/static/tsconfig.json b/static/tsconfig.json index 6f36775d97..beec139404 100644 --- a/static/tsconfig.json +++ b/static/tsconfig.json @@ -5,7 +5,7 @@ "name": "typescript-tslint-plugin", } ], - "lib": ["es2019"], + "lib": ["ES2021"], "outDir": "./dist/", "sourceMap": true, "noImplicitAny": false,