From bdef65411feb87943d0fd104aab18d1b4b2d6900 Mon Sep 17 00:00:00 2001 From: Dan Noble Date: Thu, 5 Dec 2024 03:12:06 -0500 Subject: [PATCH] Dev place page UI (#4747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates dev place page to include: * Topic tabs (Overview, Economics, etc) * Place Overview panel that includes place overview text, map, and key demographics * Child places link section * Charts for selected categories ## Screenshots ![Screenshot 2024-11-19 at 4 42 27 PM](https://github.com/user-attachments/assets/1ad752a5-3faa-485d-bba5-85eb6e672dc0) ![Screenshot 2024-11-19 at 4 42 36 PM](https://github.com/user-attachments/assets/868647cf-64e4-4014-a704-b62c60feb5a4) ## Still TODO: * A better 404 page * Only render the place overview panel if we have data to display in the panel * The "Places in " link section can get really large. Limit it to a maximum of "n" items * Update chart section styles to match mockups * Re-generate place summary text * Merge /dev-place/ endpoint with /place/ endpoint * Add experiment setup to only show the new place page for certain places but not others * Add user engagement tracking * Add i18n Includes changes from https://github.com/datacommonsorg/website/pull/4738 . I'll rebase this PR once it's merged. --- .../client/src/data_commons_web_client.ts | 34 ++ .../src/data_commons_web_client_types.ts | 43 ++ server/lib/fetch.py | 2 +- server/routes/dev_place/html.py | 1 + server/routes/dev_place/utils.py | 4 +- server/templates/dev_place.html | 14 +- server/tests/routes/api/dev_place_test.py | 174 +----- server/tests/routes/api/mock_data.py | 160 ++++++ server/webdriver/tests/dev_place_test.py | 34 ++ static/css/place/dev_place_page.scss | 41 +- static/css/shared/item_list.scss | 4 + static/js/components/subject_page/block.tsx | 11 + .../js/components/subject_page/main_pane.tsx | 4 +- static/js/place/dev_place.ts | 38 +- static/js/place/dev_place_main.tsx | 534 ++++++++++++++++++ 15 files changed, 890 insertions(+), 208 deletions(-) create mode 100644 server/webdriver/tests/dev_place_test.py create mode 100644 static/js/place/dev_place_main.tsx diff --git a/packages/client/src/data_commons_web_client.ts b/packages/client/src/data_commons_web_client.ts index bc871e6177..fb74822b54 100644 --- a/packages/client/src/data_commons_web_client.ts +++ b/packages/client/src/data_commons_web_client.ts @@ -21,7 +21,9 @@ import { ApiNodePropvalOutResponse, ObservationDatesApiResponse, + PlaceChartsApiResponse, PointApiResponse, + RelatedPlacesApiResponse, SeriesApiResponse, } from "./data_commons_web_client_types"; import { parseWebsiteApiRoot, toURLSearchParams } from "./utils"; @@ -203,6 +205,38 @@ class DataCommonsWebClient { const response = await fetch(url); return (await response.json()) as ObservationDatesApiResponse; } + + /** + * Gets place charts for the given category + * Uses /api/dev-place/charts/ endpoint + * @param params.category [optional] place category + * @param params.placeDcid place dcid to fetch data for + */ + async getPlaceCharts(params: { + category?: string; + placeDcid: string; + }): Promise { + const url = `${this.apiRoot || ""}/api/dev-place/charts/${ + params.placeDcid + }${params.category ? "?category=" + params.category : ""}`; + const response = await fetch(url); + return (await response.json()) as PlaceChartsApiResponse; + } + + /** + * Gets related place info charts for the given place + * Uses /api/dev-place/related-places/ endpoint + * @param params.placeDcid place dcid to fetch data for + */ + async getRelatedPLaces(params: { + placeDcid: string; + }): Promise { + const url = `${this.apiRoot || ""}/api/dev-place/related-places/${ + params.placeDcid + }`; + const response = await fetch(url); + return (await response.json()) as RelatedPlacesApiResponse; + } } export { DataCommonsWebClient }; diff --git a/packages/client/src/data_commons_web_client_types.ts b/packages/client/src/data_commons_web_client_types.ts index c0ef9ad960..6300805272 100644 --- a/packages/client/src/data_commons_web_client_types.ts +++ b/packages/client/src/data_commons_web_client_types.ts @@ -144,3 +144,46 @@ export type ObservationDatesApiResponse = { }; }; }; + +type ChartType = "BAR" | "LINE" | "MAP" | "RANKING"; +export interface Chart { + type: ChartType; + title: string; + category: string; + description: string; + statisticalVariableDcids: string[]; + topicDcids: string[]; + denominator?: string; // Optional + unit?: string; // Optional + scaling?: number; // Optional +} + +export interface Place { + dcid: string; + name: string; + types: string[]; +} + +/** + * Website API response for /api/dev-place/charts/ + */ +export interface PlaceChartsApiResponse { + charts: Chart[]; + childPlaceType: string; + childPlaces: Place[]; + nearbyPlaces: Place[]; + place: Place; + similarPlaces: Place[]; + translatedCategoryStrings: Record; +} + +/** + * Website API response for /api/dev-place/related-places/ + */ +export interface RelatedPlacesApiResponse { + childPlaceType: string; + childPlaces: Place[]; + nearbyPlaces: Place[]; + place: Place; + similarPlaces: Place[]; +} diff --git a/server/lib/fetch.py b/server/lib/fetch.py index 523b27a73b..c9cb9b48ff 100644 --- a/server/lib/fetch.py +++ b/server/lib/fetch.py @@ -407,7 +407,7 @@ def multiple_property_values(nodes: List[str], Fetches specified properties for given nodes using the /v2/node API. Args: - nodes (List[str]): List of node identifiers. + nodes (List[str]): List of node dcids. props (List[str]): Properties to retrieve for each node. out (bool): If True, fetches outgoing properties; otherwise, incoming (default: True). diff --git a/server/routes/dev_place/html.py b/server/routes/dev_place/html.py index 21b6f0f90f..df8aed5e87 100644 --- a/server/routes/dev_place/html.py +++ b/server/routes/dev_place/html.py @@ -40,6 +40,7 @@ def dev_place(place_dcid=None): place_dcid) place_names = place_api.get_i18n_name([place_dcid]) or {} place_name = place_names.get(place_dcid, place_dcid) + # Place summaries are currently only supported in English if g.locale == DEFAULT_LOCALE: place_summary = get_place_summaries(place_dcid).get(place_dcid, {}).get("summary", "") diff --git a/server/routes/dev_place/utils.py b/server/routes/dev_place/utils.py index 2cbc1c2f2f..d4365da230 100644 --- a/server/routes/dev_place/utils.py +++ b/server/routes/dev_place/utils.py @@ -281,11 +281,13 @@ def chart_config_to_overview_charts(chart_config, child_place_type: str): unit=page_config_related_chart.get("unit"), ) if page_config_related_chart.get("denominator"): + # Related charts only specify one denominator, so we repeat it for each stat var related_chart.denominator = [ page_config_related_chart.get("denominator") - ] + ] * len(chart.statisticalVariableDcids) if is_map_chart: related_chart.childPlaceType = child_place_type + charts.append(related_chart) return charts diff --git a/server/templates/dev_place.html b/server/templates/dev_place.html index 0820ce8ea1..b236661dcb 100644 --- a/server/templates/dev_place.html +++ b/server/templates/dev_place.html @@ -25,6 +25,7 @@ {% set description = _('Statistics about {category} in {place_name}.'.format(category=category, place_name=place_name)) %} {% endif %} {% set place_category = category %} + {% set is_show_header_search_bar = true %} {% block head %} @@ -41,10 +42,17 @@ {% block content %}
+
- -

{{ place_name }}

-

{{ place_type_with_parent_places_links | safe }}

+
+
{% endblock %} diff --git a/server/tests/routes/api/dev_place_test.py b/server/tests/routes/api/dev_place_test.py index 22226b5c4f..b16ee71151 100644 --- a/server/tests/routes/api/dev_place_test.py +++ b/server/tests/routes/api/dev_place_test.py @@ -17,63 +17,14 @@ from server.routes.dev_place.types import Place import server.routes.dev_place.utils as place_utils +from server.tests.routes.api.mock_data import MULTIPLE_PROPERTY_VALUES_RESPONSE +from server.tests.routes.api.mock_data import \ + MULTIPLE_PROPERTY_VALUES_RESPONSE_WITH_LANGUAGES +from server.tests.routes.api.mock_data import OSERVATION_POINT_RESPONSE +from server.tests.routes.api.mock_data import OSERVATION_WITHIN_POINT_RESPONSE +from server.tests.routes.api.mock_data import SAMPLE_PLACE_PAGE_CHART_CONFIG from web_app import app -SAMPLE_CHART_CONFIG = [{ - "category": "Crime", - "titleId": "CHART_TITLE-Total_crime", - "title": "Total crime", - "description": "Total number of criminal incidents", - "statsVars": ["Count_CriminalActivities_CombinedCrime"], - "isOverview": True, - "relatedChart": { - "titleId": "CHART_TITLE-Crime_rate", - "title": "Crimes per 100,000 people", - "description": "Total number of criminal incidents per 100,000 people", - "scale": True, - "denominator": "Count_Person", - "scaling": 100000 - } -}, { - "category": - "Education", - "titleId": - "CHART_TITLE-Educational_attainment", - "title": - "Education attainment", - "denominator": [ - "Sample_Denominator_1", "Sample_Denominator_2", "Sample_Denominator_3", - "Sample_Denominator_4", "Sample_Denominator_5" - ], - "description": - "Number of people who have attained various educational milestones, e.g. completed high school or have a bachelor's degree", - "statsVars": [ - "Count_Person_EducationalAttainmentNoSchoolingCompleted", - "Count_Person_EducationalAttainmentRegularHighSchoolDiploma", - "Count_Person_EducationalAttainmentBachelorsDegree", - "Count_Person_EducationalAttainmentMastersDegree", - "Count_Person_EducationalAttainmentDoctorateDegree" - ], - "isOverview": - True, - "relatedChart": { - "titleId": - "CHART_TITLE-Educational_attainment_rate", - "title": - "Education attainment rate", - "description": - "Percentage of the adult population who have attained various educational milestones, e.g. completed high school or have a bachelor's degree", - "scale": - True, - "denominator": - "Count_Person_25OrMoreYears", - "scaling": - 100, - "unit": - "%" - } -}] - class TestPlaceAPI(unittest.TestCase): @@ -91,72 +42,15 @@ def test_dev_place_charts(self, mock_obs_point_within, mock_obs_point, place_dcid = "country/USA" # Override the CHART_CONFIG with sample values - app.config['CHART_CONFIG'] = SAMPLE_CHART_CONFIG + app.config['CHART_CONFIG'] = SAMPLE_PLACE_PAGE_CHART_CONFIG # Mock obs_point call with a properly structured response - mock_obs_point.return_value = { - "byVariable": { - "Count_CriminalActivities_CombinedCrime": { - "byEntity": { - place_dcid: { - "dates": { - "2022": { - "value": 1000 - } - } - } - } - }, - # Include one education stat var to simulate data availability - "Count_Person_EducationalAttainmentBachelorsDegree": { - "byEntity": { - place_dcid: { - "dates": { - "2022": { - "value": 500 - } - } - } - } - } - } - } + mock_obs_point.return_value = OSERVATION_POINT_RESPONSE # Mock obs_point_within for finding child places existence check for map-based stat vars - mock_obs_point_within.return_value = { - "byVariable": { - "Count_CriminalActivities_CombinedCrime": { - "byEntity": { - "geoId/123": { - "dates": { - "2022": { - "value": 200 - } - } - } - } - }, - # Simulate a child place with data for an educational stat var - "Count_Person_EducationalAttainmentMastersDegree": { - "byEntity": { - "geoId/456": { - "dates": { - "2022": { - "value": 50 - } - } - } - } - } - } - } + mock_obs_point_within.return_value = OSERVATION_WITHIN_POINT_RESPONSE - mock_multiple_property_values.return_value = { - "country/USA": { - "typeOf": ["Country"], - "name": ['United States'] - } - } + mock_multiple_property_values.return_value = MULTIPLE_PROPERTY_VALUES_RESPONSE # Mock fetch.raw_property_values to return empty lists (no nearby or similar places) mock_raw_property_values.return_value = {place_dcid: []} @@ -174,8 +68,9 @@ def test_dev_place_charts(self, mock_obs_point_within, mock_obs_point, self.assertIn('translatedCategoryStrings', response_json) # Check that the 'charts' field contains the expected number of charts - # Since only two charts have data (Crime and one Education stat var), we expect two charts - self.assertEqual(len(response_json['charts']), 2) + # Two charts have data (Crime and one Education stat var), and each has a + # related chart, so we expect four charts + self.assertEqual(len(response_json['charts']), 4) # Optionally, check that the charts have the correct titles chart_titles = [chart['title'] for chart in response_json['charts']] @@ -192,7 +87,10 @@ def test_dev_place_charts(self, mock_obs_point_within, mock_obs_point, self.assertIn('Education', response_json['translatedCategoryStrings']) # Ensure the denominator is present in chart results - self.assertEqual(5, len(response_json["charts"][1]["denominator"])) + self.assertEqual(None, response_json["charts"][0]["denominator"]) + self.assertEqual(1, len(response_json["charts"][1]["denominator"])) + self.assertEqual(5, len(response_json["charts"][2]["denominator"])) + self.assertEqual(5, len(response_json["charts"][3]["denominator"])) @patch('server.routes.dev_place.utils.fetch.raw_property_values') @patch('server.routes.dev_place.utils.fetch.multiple_property_values') @@ -207,43 +105,7 @@ def test_related_places(self, mock_descendent_places, place_dcid = 'country/USA' # Define side effects for mock_multiple_property_values - mock_multiple_property_values.return_value = { - 'country/USA': { - 'typeOf': ['Country'], - 'name': ['United States of America'], - 'nameWithLanguage': [] - }, - 'country/CAN': { - 'typeOf': ['Country'], - 'name': ['Canada'], - 'nameWithLanguage': [] - }, - 'country/MEX': { - 'typeOf': ['Country'], - 'name': ['Mexico'], - 'nameWithLanguage': [] - }, - 'country/GBR': { - 'typeOf': ['Country'], - 'name': ['United Kingdom'], - 'nameWithLanguage': [] - }, - 'country/AUS': { - 'typeOf': ['Country'], - 'name': ['Australia'], - 'nameWithLanguage': [] - }, - 'geoId/06': { - 'typeOf': ['State'], - 'name': ['California'], - 'nameWithLanguage': [] - }, - 'geoId/07': { - 'typeOf': ['State'], - 'name': ['New York'], - 'nameWithLanguage': [] - } - } + mock_multiple_property_values.return_value = MULTIPLE_PROPERTY_VALUES_RESPONSE_WITH_LANGUAGES # Mock descendent_places to return child places mock_descendent_places.return_value = { diff --git a/server/tests/routes/api/mock_data.py b/server/tests/routes/api/mock_data.py index 552a622685..4b4c385577 100644 --- a/server/tests/routes/api/mock_data.py +++ b/server/tests/routes/api/mock_data.py @@ -462,3 +462,163 @@ matched_query='calif', score=3), ] +# Place page chart config for place page testing +SAMPLE_PLACE_PAGE_CHART_CONFIG = [{ + "category": "Crime", + "titleId": "CHART_TITLE-Total_crime", + "title": "Total crime", + "description": "Total number of criminal incidents", + "statsVars": ["Count_CriminalActivities_CombinedCrime"], + "isOverview": True, + "relatedChart": { + "titleId": "CHART_TITLE-Crime_rate", + "title": "Crimes per 100,000 people", + "description": "Total number of criminal incidents per 100,000 people", + "scale": True, + "denominator": "Count_Person", + "scaling": 100000 + } +}, { + "category": + "Education", + "titleId": + "CHART_TITLE-Educational_attainment", + "title": + "Education attainment", + "denominator": [ + "Sample_Denominator_1", "Sample_Denominator_2", "Sample_Denominator_3", + "Sample_Denominator_4", "Sample_Denominator_5" + ], + "description": + "Number of people who have attained various educational milestones, e.g. completed high school or have a bachelor's degree", + "statsVars": [ + "Count_Person_EducationalAttainmentNoSchoolingCompleted", + "Count_Person_EducationalAttainmentRegularHighSchoolDiploma", + "Count_Person_EducationalAttainmentBachelorsDegree", + "Count_Person_EducationalAttainmentMastersDegree", + "Count_Person_EducationalAttainmentDoctorateDegree" + ], + "isOverview": + True, + "relatedChart": { + "titleId": + "CHART_TITLE-Educational_attainment_rate", + "title": + "Education attainment rate", + "description": + "Percentage of the adult population who have attained various educational milestones, e.g. completed high school or have a bachelor's degree", + "scale": + True, + "denominator": + "Count_Person_25OrMoreYears", + "scaling": + 100, + "unit": + "%" + } +}] + +# Observation point response for place page testing +OSERVATION_POINT_RESPONSE = { + "byVariable": { + "Count_CriminalActivities_CombinedCrime": { + "byEntity": { + "country/USA": { + "dates": { + "2022": { + "value": 1000 + } + } + } + } + }, + # Include one education stat var to simulate data availability + "Count_Person_EducationalAttainmentBachelorsDegree": { + "byEntity": { + "country/USA": { + "dates": { + "2022": { + "value": 500 + } + } + } + } + } + } +} + +# Observation within point response for place page testing +OSERVATION_WITHIN_POINT_RESPONSE = { + "byVariable": { + "Count_CriminalActivities_CombinedCrime": { + "byEntity": { + "geoId/123": { + "dates": { + "2022": { + "value": 200 + } + } + } + } + }, + # Simulate a child place with data for an educational stat var + "Count_Person_EducationalAttainmentMastersDegree": { + "byEntity": { + "geoId/456": { + "dates": { + "2022": { + "value": 50 + } + } + } + } + } + } +} + +# Multiple property values response for place page testing +MULTIPLE_PROPERTY_VALUES_RESPONSE = { + "country/USA": { + "typeOf": ["Country"], + "name": ['United States'] + } +} + +# Multiple property values response with languages for place page testing +MULTIPLE_PROPERTY_VALUES_RESPONSE_WITH_LANGUAGES = { + 'country/USA': { + 'typeOf': ['Country'], + 'name': ['United States of America'], + 'nameWithLanguage': [] + }, + 'country/CAN': { + 'typeOf': ['Country'], + 'name': ['Canada'], + 'nameWithLanguage': [] + }, + 'country/MEX': { + 'typeOf': ['Country'], + 'name': ['Mexico'], + 'nameWithLanguage': [] + }, + 'country/GBR': { + 'typeOf': ['Country'], + 'name': ['United Kingdom'], + 'nameWithLanguage': [] + }, + 'country/AUS': { + 'typeOf': ['Country'], + 'name': ['Australia'], + 'nameWithLanguage': [] + }, + 'geoId/06': { + 'typeOf': ['State'], + 'name': ['California'], + 'nameWithLanguage': [] + }, + 'geoId/07': { + 'typeOf': ['State'], + 'name': ['New York'], + 'nameWithLanguage': [] + } +} \ No newline at end of file diff --git a/server/webdriver/tests/dev_place_test.py b/server/webdriver/tests/dev_place_test.py new file mode 100644 index 0000000000..7f245d2fe9 --- /dev/null +++ b/server/webdriver/tests/dev_place_test.py @@ -0,0 +1,34 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + +from server.webdriver.base import WebdriverBaseTest + + +class TestDevPlacePage(WebdriverBaseTest): + """Tests for (new) Place page.""" + + def test_dev_place_overview_usa(self): + """Ensure place page content loads""" + self.driver.get(self.url_ + '/dev-place/geoId/06') + + # Wait until the related places container has loaded + related_places_callout_el_present = EC.presence_of_element_located( + (By.CLASS_NAME, 'related-places-callout')) + related_places_callout_el = WebDriverWait( + self.driver, self.TIMEOUT_SEC).until(related_places_callout_el_present) + self.assertEqual(related_places_callout_el.text, 'Places in California') diff --git a/static/css/place/dev_place_page.scss b/static/css/place/dev_place_page.scss index 9312cc660c..8b3a1a2000 100644 --- a/static/css/place/dev_place_page.scss +++ b/static/css/place/dev_place_page.scss @@ -28,7 +28,7 @@ $horizontal-divider-style: 1px solid rgba(0, 0, 0, 0.12); main { // Leave space on top for navbar and bottom for footer - padding: 64px 0; + padding: 24px 0; } .page-content-container { @@ -58,10 +58,6 @@ main { font-weight: 500; line-height: 20px; margin-bottom: 0; - - a { - color: currentColor; - } } } @@ -75,12 +71,27 @@ main { } } -.explore-topics-box { - border-top: $horizontal-divider-style; - margin: 8px 0; - padding-top: 10px; +.place-overview { + border-radius: 8px; + border: 1px solid var(--GM3-ref-neutral-neutral90, #e3e3e3); + background: rgba(211, 227, 253, 0.1); + padding: 28px; + .place-name { + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 24px; + } + .place-summary { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + } + .place-map { + margin-top: 16px; + } } - .summary-text { color: var(--gm-3-ref-neutral-neutral-20); font-size: 14px; @@ -90,6 +101,13 @@ main { margin: 24px 0; } +.explore-topics-box { + border-top: $horizontal-divider-style; + margin: 8px 0; + padding-top: 10px; + margin-bottom: 32px; +} + .charts-container { display: flex; flex-direction: column; @@ -131,7 +149,8 @@ main { } .related-places { - width: fit-content; + width: "100%"; + margin-top: 16px; .related-places-callout { font-size: 22px; diff --git a/static/css/shared/item_list.scss b/static/css/shared/item_list.scss index 4a81389e24..783f615b69 100644 --- a/static/css/shared/item_list.scss +++ b/static/css/shared/item_list.scss @@ -40,6 +40,10 @@ text-indent: -12px; } + .item-list-text.selected { + text-decoration: underline; + } + .item-list-text:hover { cursor: pointer; } diff --git a/static/js/components/subject_page/block.tsx b/static/js/components/subject_page/block.tsx index 81e03c61c5..9946458c49 100644 --- a/static/js/components/subject_page/block.tsx +++ b/static/js/components/subject_page/block.tsx @@ -698,6 +698,17 @@ function renderTiles( console.log("Tile type not supported:" + tile.type); } }); + if (tilesJsx.length > 1) { + return ( +
+ {tilesJsx.map((tileJsx, tileJsxIndex) => ( +
+ {tileJsx} +
+ ))} +
+ ); + } return <>{tilesJsx}; } diff --git a/static/js/components/subject_page/main_pane.tsx b/static/js/components/subject_page/main_pane.tsx index 299de3aed2..228eb5185b 100644 --- a/static/js/components/subject_page/main_pane.tsx +++ b/static/js/components/subject_page/main_pane.tsx @@ -46,6 +46,8 @@ interface SubjectPageMainPanePropType { showExploreMore?: boolean; // Whether to to render tiles as web components showWebComponents?: boolean; + // Default enclosed place type + defaultEnclosedPlaceType?: string; } export const SubjectPageMainPane = memo(function SubjectPageMainPane( @@ -56,7 +58,7 @@ export const SubjectPageMainPane = memo(function SubjectPageMainPane( // TODO(shifucun): Further clean up default place type, child place type etc // from subject page client components. The component should respect whatever // the input prop is. - let enclosedPlaceType = ""; + let enclosedPlaceType = props.defaultEnclosedPlaceType || ""; for (const placeType of props.place.types) { if ( props.pageConfig.metadata && diff --git a/static/js/place/dev_place.ts b/static/js/place/dev_place.ts index f33bcb009a..0b8bb0026c 100644 --- a/static/js/place/dev_place.ts +++ b/static/js/place/dev_place.ts @@ -17,47 +17,15 @@ import React from "react"; import ReactDOM from "react-dom"; -import { NlSearchBar } from "../components/nl_search_bar"; -import { intl } from "../i18n/i18n"; -import { - GA_EVENT_NL_SEARCH, - GA_PARAM_QUERY, - GA_PARAM_SOURCE, - GA_VALUE_SEARCH_SOURCE_PLACE_PAGE, - triggerGAEvent, -} from "../shared/ga_events"; +import { DevPlaceMain } from "./dev_place_main"; window.addEventListener("load", (): void => { renderPage(); }); -/** - * Handler for NL search bar - * @param q search query entered by user - */ -function onSearch(q: string): void { - triggerGAEvent(GA_EVENT_NL_SEARCH, { - [GA_PARAM_QUERY]: q, - [GA_PARAM_SOURCE]: GA_VALUE_SEARCH_SOURCE_PLACE_PAGE, - }); - window.location.href = `/explore#q=${encodeURIComponent(q)}`; -} - function renderPage(): void { - // Render NL search bar ReactDOM.render( - React.createElement(NlSearchBar, { - initialValue: "", - inputId: "query-search-input", - onSearch, - placeholder: intl.formatMessage({ - defaultMessage: "Enter a question to explore", - description: - "Text inviting user to search for data using a question in natural language", - id: "nl-search-bar-placeholder-text", - }), - shouldAutoFocus: false, - }), - document.getElementById("nl-search-bar") + React.createElement(DevPlaceMain, {}), + document.getElementById("place-page-content") ); } diff --git a/static/js/place/dev_place_main.tsx b/static/js/place/dev_place_main.tsx new file mode 100644 index 0000000000..ec17c84871 --- /dev/null +++ b/static/js/place/dev_place_main.tsx @@ -0,0 +1,534 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DataRow } from "@datacommonsorg/client"; +import { + Chart, + PlaceChartsApiResponse, + RelatedPlacesApiResponse, +} from "@datacommonsorg/client/dist/data_commons_web_client_types"; +import _ from "lodash"; +import React, { useEffect, useState } from "react"; +import { RawIntlProvider } from "react-intl"; + +import { GoogleMap } from "../components/google_map"; +import { SubjectPageMainPane } from "../components/subject_page/main_pane"; +import { intl } from "../i18n/i18n"; +import { NamedTypedPlace, StatVarSpec } from "../shared/types"; +import { + CategoryConfig, + SubjectPageConfig, + TileConfig, +} from "../types/subject_page_proto_types"; +import { + defaultDataCommonsClient, + defaultDataCommonsWebClient, +} from "../utils/data_commons_client"; + +/** + * Returns the stat var key for a chart. + * + * A stat var key is a unique identifier for a statistical variable for the + * given chart, including its DCID, denominator, log, scaling, and unit. + * + * @param chart The chart object + * @param variableDcid The variable DCID + * @param denom The denominator DCID + * @returns The stat var key + */ +function getStatVarKey( + chart: Chart, + variableDcid: string, + denom?: string +): string { + return `${variableDcid}_denom_${denom}_log_${false}_scaling_${ + chart.scaling + }_unit_${chart.unit}`; +} + +/** + * Converts the API response from getPlaceCharts into a SubjectPageConfig object. + * Groups charts by category and creates the necessary configuration objects for + * rendering the subject page. + * + * @param placeChartsApiResponse The API response containing chart data + * @returns A SubjectPageConfig object with categories, tiles, and stat var specs + */ +function placeChartsApiResponsesToPageConfig( + placeChartsApiResponse: PlaceChartsApiResponse +): SubjectPageConfig { + const chartsByCategory = _.groupBy( + placeChartsApiResponse.charts, + (item) => item.category + ); + const categoryConfig: CategoryConfig[] = Object.keys(chartsByCategory).map( + (categoryName) => { + const charts = chartsByCategory[categoryName]; + + const tiles: TileConfig[] = charts.map((chart) => { + return { + description: chart.description, + title: chart.title, + type: chart.type, + statVarKey: chart.statisticalVariableDcids.map( + (variableDcid, variableIdx) => { + const denom = + chart.denominator && + chart.denominator.length === + chart.statisticalVariableDcids.length + ? chart.denominator[variableIdx] + : undefined; + return getStatVarKey(chart, variableDcid, denom); + } + ), + }; + }); + + const statVarSpec: Record = {}; + charts.forEach((chart) => { + chart.statisticalVariableDcids.forEach((variableDcid, variableIdx) => { + const denom = + chart.denominator && + chart.denominator.length === chart.statisticalVariableDcids.length + ? chart.denominator[variableIdx] + : undefined; + const statVarKey = getStatVarKey(chart, variableDcid, denom); + statVarSpec[statVarKey] = { + denom, + log: false, + scaling: chart.scaling, + statVar: variableDcid, + unit: chart.unit, + }; + }); + }); + + // Group tiles into pairs to show a two-column layout + const column1Tiles: TileConfig[] = []; + const column2Tiles: TileConfig[] = []; + tiles.forEach((tile, index) => { + if (index % 2 === 0) { + column1Tiles.push(tile); + } else { + column2Tiles.push(tile); + } + }); + const category: CategoryConfig = { + blocks: [ + { + columns: [{ tiles }], + }, + ], + statVarSpec, + title: categoryName, + }; + return category; + } + ); + + const pageConfig: SubjectPageConfig = { + metadata: undefined, + categories: categoryConfig, + }; + return pageConfig; +} + +/** + * Component that renders the header section of a place page. + * Displays the place name, category (if not Overview), and subheader text. + * Also shows the place DCID with a link to view it in the Knowledge Graph browser. + * + * @param props.category The current category being viewed + * @param props.place The place object containing name and DCID + * @param props.placeSubheader HTML string with additional place context + * @returns Header component for the place page + */ +const PlaceHeader = (props: { + category: string; + place: NamedTypedPlace; + placeSubheader: string; +}) => { + const { category, place, placeSubheader } = props; + return ( +
+
+

+ {place.name} + {category != "Overview" ? ` • ${category}` : ""}{" "} +

+

+
+
+ dcid: {place.dcid} •{" "} + See Knowledge Graph +
+
+ ); +}; + +/** + * Component that renders the topic navigation tabs. + * Shows tabs for Overview and different categories like Economics, Health, etc. + * Highlights the currently selected category. + * + * @param props.category The currently selected category + * @param props.place The place object containing the DCID for generating URLs + * @returns Navigation component with topic tabs + */ +const PlaceTopicTabs = ({ + category, + place, +}: { + category: string; + place: NamedTypedPlace; +}) => { + return ( + + ); +}; + +/** + * Component that displays a table of key demographic statistics for a place. + * + * Fetches data for population, median income, median age, unemployment rate, + * and crime statistics using the Data Commons API. Displays the values in a + * formatted table with units and dates. + * + * @param props.placeDcid The DCID of the place to show statistics for + * @returns A table component showing key demographic statistics, or null if data not loaded + */ +const PlaceOverviewTable = (props: { placeDcid: string }) => { + const { placeDcid } = props; + const [dataRows, setDataRows] = useState([]); + // Fetch key demographic statistics for the place when it changes + useEffect(() => { + (async () => { + const placeOverviewDataRows = await defaultDataCommonsClient.getDataRows({ + entities: [placeDcid], + variables: [ + "Count_Person", + "Median_Income_Person", + "Median_Age_Person", + "UnemploymentRate_Person", + "Count_CriminalActivities_CombinedCrime", + ], + perCapitaVariables: ["Count_CriminalActivities_CombinedCrime"], + }); + setDataRows(placeOverviewDataRows); + })(); + }, [placeDcid]); + if (!dataRows) { + return null; + } + return ( + + + + + + + + + {dataRows.map((dataRow, index) => { + const unit = dataRow.variable.observation.metadata.unitDisplayName + ? dataRow.variable.observation.metadata.unitDisplayName + : ""; + const formattedObservationValue = + dataRow.variable.observation.value.toLocaleString(); + return ( + + + + + + ); + })} + +
+ Key Demographics +
{dataRow.variable.properties.name} + {formattedObservationValue} {unit} ( + {dataRow.variable.observation.date}) +
+ ); +}; + +/** + * Displays an overview of a place including its name, summary, map and key statistics. + * + * @param props.place The place object containing name and dcid + * @param props.placeSummary A text summary describing the place + * @returns A component with the place overview including icon, name, summary, map and statistics table + */ +const PlaceOverview = (props: { + place: NamedTypedPlace; + placeSummary: string; +}) => { + const { place, placeSummary } = props; + return ( +
+
+
location_city
+
+
{place.name}
+
{placeSummary}
+
+
+ +
+
+ +
+
+
+ ); +}; + +/** + * Component that displays a list of child places for a given place. + * + * @param props.place The parent place containing name and dcid + * @param props.childPlaces Array of child places, each with name and dcid + * @returns A list of links to child places, or null if no child places exist + */ +const RelatedPlaces = (props: { + place: NamedTypedPlace; + childPlaces: NamedTypedPlace[]; +}) => { + const { place, childPlaces } = props; + if (!childPlaces || childPlaces.length === 0) { + return null; + } + return ( +
+
Places in {place.name}
+
+
+ {childPlaces.map((place) => ( + + ))} +
+
+
+ ); +}; + +/** + * Component that renders charts for a place using the SubjectPageMainPane. + * + * @param props.childPlaceType The type of child places (e.g. "State", "County") + * @param props.place The place object containing name, dcid and types + * @param props.pageConfig Configuration for the subject page including chart categories and specs + * @returns Component with charts for the place + */ +const PlaceCharts = (props: { + childPlaceType: string; + place: NamedTypedPlace; + pageConfig: SubjectPageConfig; +}) => { + const { childPlaceType, place, pageConfig } = props; + return ( +
+ +
+ ); +}; + +/** + * Main component for the dev place page. Manages state and data fetching for place information, + * related places, and chart data. + */ +export const DevPlaceMain = () => { + // Core place data + const [place, setPlace] = useState(); + const [placeSummary, setPlaceSummary] = useState(); + const [placeSubheader, setPlaceSubheader] = useState(); + + // API response data + const [relatedPlacesApiResponse, setRelatedPlacesApiResponse] = + useState(); + const [placeChartsApiResponse, setPlaceChartsApiResponse] = + useState(); + + // Derived place data + const [childPlaceType, setChildPlaceType] = useState(); + const [childPlaces, setChildPlaces] = useState([]); + const [pageConfig, setPageConfig] = useState(); + + const urlParams = new URLSearchParams(window.location.search); + const category = urlParams.get("category") || "Overview"; + + /** + * On initial load, get place metadata from the page's metadata element + * and set up initial place state. + */ + useEffect(() => { + const pageMetadata = document.getElementById("page-metadata"); + if (!pageMetadata) { + console.error("Error loading place page metadata element"); + return; + } + setPlace({ + name: pageMetadata.dataset.placeName, + dcid: pageMetadata.dataset.placeDcid, + types: [], + }); + setPlaceSummary(pageMetadata.dataset.placeSummary); + setPlaceSubheader(pageMetadata.dataset.placeSubheader); + }, []); + + /** + * Once we have place data, fetch chart and related places data from the API. + * Updates state with API responses and derived data. + */ + useEffect(() => { + if (!place) { + return; + } + (async () => { + const [placeChartsApiResponse, relatedPlacesApiResponse] = + await Promise.all([ + defaultDataCommonsWebClient.getPlaceCharts({ + category, + placeDcid: place.dcid, + }), + defaultDataCommonsWebClient.getRelatedPLaces({ + placeDcid: place.dcid, + }), + ]); + + setPlaceChartsApiResponse(placeChartsApiResponse); + setRelatedPlacesApiResponse(relatedPlacesApiResponse); + const pageConfig = placeChartsApiResponsesToPageConfig( + placeChartsApiResponse + ); + setChildPlaceType(relatedPlacesApiResponse.childPlaceType); + setChildPlaces(relatedPlacesApiResponse.childPlaces); + setPageConfig(pageConfig); + })(); + }, [place]); + + if (!place) { + return
Loading...
; + } + return ( + + + + + + {place && pageConfig && ( + + )} + + ); +};