diff --git a/server/__init__.py b/server/__init__.py index ce5694aefe..3c8cf2e785 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -275,6 +275,9 @@ def register_routes_common(app): from server.routes.shared_api import variable_group as shared_variable_group app.register_blueprint(shared_variable_group.bp) + from server.routes.shared_api import metadata as shared_metadata + app.register_blueprint(shared_metadata.bp) + from server.routes.shared_api.observation import date as observation_date app.register_blueprint(observation_date.bp) diff --git a/server/routes/shared_api/metadata.py b/server/routes/shared_api/metadata.py new file mode 100644 index 0000000000..71444a62db --- /dev/null +++ b/server/routes/shared_api/metadata.py @@ -0,0 +1,435 @@ +# Copyright 2026 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 asyncio +import collections +import logging +from typing import Any + +from flask import Blueprint +from flask import jsonify +from flask import request +from flask import Response + +from server.services import datacommons as dc + +bp = Blueprint("metadata", __name__, url_prefix='/api/metadata') + +# Limits the recursion when traversing parent hierarchies (memberOf/specializationOf) +# to prevent infinite loops or excessive API calls in deep graphs. +MAX_CATEGORY_DEPTH = 50 + +# A list of specific provenance DCIDs where the 'measurementMethod' attribute +# should be hidden, because it is flawed or not meaningful. +MEASUREMENT_METHODS_SUPPRESSION_PROVENANCES: set[str] = {"WikipediaStatsData"} + + +def title_case(string: str) -> str: + return " ".join([word.capitalize() for word in string.split("_")]) + + +def _get_arc_nodes(data_dict: dict[str, Any], node_id: str, + arc_name: str) -> list[dict[str, Any]]: + """Extracts nodes for a given arc from a v2node response dictionary.""" + return data_dict.get('data', {}).get(node_id, + {}).get('arcs', + {}).get(arc_name, + {}).get('nodes', []) + + +def _get_node_name(node_list: list[dict[str, Any]], + linked_names_map: dict[str, str]) -> str | None: + """Helper to resolve a node's display name from either a literal value or linked reference.""" + if not node_list: + return None + node = node_list[0] + if 'value' in node: + return node['value'] + if 'dcid' in node: + return linked_names_map.get(node['dcid']) + return None + + +def _extract_active_facets( + sv: str, obs_resp: dict[str, Any], + stat_var_to_facets: dict[str, list[str]]) -> list[str]: + """Extracts active facets for a given stat var.""" + active_facets = list(stat_var_to_facets.get(sv, [])) + if not active_facets: + by_entity = obs_resp.get('byVariable', {}).get(sv, {}).get('byEntity', {}) + for ent_data in by_entity.values(): + for f in ent_data.get('orderedFacets', []): + active_facets.append(f.get('facetId')) + return list(set(active_facets)) + + +def _traverse_to_top_category(node: str, parent_map: dict[str, list[str]], + visited: set[str], top_nodes: set[str], + original_sv: str) -> None: + """Recursively traces paths from a node to its top-level ancestors.""" + if node in visited: + return + visited.add(node) + + parents = parent_map.get(node, []) + valid_parents = [p for p in parents if p != 'dc/g/Root'] + + if not valid_parents: + # If the node is not the starting SV, it's a top-level category + # This is for the case where a stat var does not have a category, where + # it should not itself be considered a category. + if node != original_sv: + top_nodes.add(node) + else: + for p in valid_parents: + _traverse_to_top_category(p, parent_map, visited, top_nodes, original_sv) + + +async def fetch_categories_async(stat_vars: list[str]) -> dict[str, list[str]]: + """Traverses the category hierarchy tree up to top-level topics. + + This function identifies the categories (top-level topics) associated with a list + of Statistical Variables. It returns a mapping where each key is a stat_var + DCID and the value is a list of human-readable names of its top-level parents. + + The implementation uses a two-stage traversal: + 1. Breadth-First Search (BFS): Iteratively climbs the 'memberOf' and + 'specializationOf' arcs across all input variables simultaneously to + map the parent hierarchy. + 2. Depth-First Search (DFS): Performed locally on the resulting parent_map + to trace individual paths from each stat_var to its root-level ancestors + (excluding the generic 'dc/g/Root'). + + Args: + stat_vars: A list of Statistical Variable DCIDs. + + Returns: + A dictionary mapping stat_var DCIDs to a list of display names for their + top-level categories. + """ + parent_map = collections.defaultdict(list) + current_nodes = set(stat_vars) + visited = set() + depth = 0 + + # Progressively fetch parent nodes level-by-level (BFS). + # This batches v2node calls by depth to minimize network round-trips. + while current_nodes and depth < MAX_CATEGORY_DEPTH: + visited.update(current_nodes) + + member_task = asyncio.to_thread(dc.v2node, list(current_nodes), + '->memberOf') + spec_task = asyncio.to_thread(dc.v2node, list(current_nodes), + '->specializationOf') + resp_member, resp_spec = await asyncio.gather(member_task, spec_task) + + next_nodes = set() + for node in current_nodes: + parents = set() + parents.update([ + n.get('dcid') + for n in _get_arc_nodes(resp_member, node, 'memberOf') + if n.get('dcid') + ]) + parents.update([ + n.get('dcid') + for n in _get_arc_nodes(resp_spec, node, 'specializationOf') + if n.get('dcid') + ]) + + parent_list = list(parents) + parent_map[node].extend(parent_list) + + for p in parent_list: + # Use visited set to prevent graph cycles + if p != 'dc/g/Root' and p not in visited: + next_nodes.add(p) + + current_nodes = next_nodes + depth += 1 + + sv_top_levels = collections.defaultdict(list) + all_top_level_dcids = set() + + # Traverse individual paths from each stat_var to its top-level categories. + # Using the parent_map built above, we resolve which topic-level topics + # each variable eventually rolls up to. + for sv in stat_vars: + tops = set() + + _traverse_to_top_category(sv, parent_map, set(), tops, sv) + sv_top_levels[sv] = list(tops) + all_top_level_dcids.update(tops) + + # Resolve human-readable names for the top-level categories. + # If a name isn't found in the Knowledge Graph, we fall back to a + # simplified version of the DCID. + category_map: dict[str, list[str]] = {} + if all_top_level_dcids: + parent_name_resp = await asyncio.to_thread(dc.v2node, + list(all_top_level_dcids), + '->name') + parent_name_map = {} + for pid in all_top_level_dcids: + nodes = _get_arc_nodes(parent_name_resp, pid, 'name') + if nodes: + parent_name_map[pid] = nodes[0].get('value') + + for sv in stat_vars: + category_map[sv] = [ + # Use the official name if available; otherwise, extract the last + # chunk of the DCIC (if it contains multiple parts delimited by slashes) + parent_name_map.get(p) or p.split('/')[-1] + for p in sv_top_levels.get(sv, []) + ] + else: + category_map = {sv: [] for sv in stat_vars} + + return category_map + + +def _build_metadata_payload( + stat_vars: list[str], stat_var_names: dict[str, str], + category_map: dict[str, list[str]], sv_active_facets: dict[str, list[str]], + facets: dict[str, Any], facet_date_ranges: dict[str, dict[str, str]], + prov_map: dict[str, dict[str, Any]], linked_names_map: dict[str, str], + mm_map: dict[str, + str], unit_map: dict[str, + str]) -> dict[str, list[dict[str, Any]]]: + """Constructs the final aggregated metadata dictionary.""" + metadata_map = collections.defaultdict(list) + + for sv in stat_vars: + active_facets = sv_active_facets.get(sv, []) + + for fid in active_facets: + finfo = facets.get(fid, {}) + import_name = finfo.get('importName') + if not import_name: + continue + + prov_id = f"dc/base/{import_name}" + pdata = prov_map.get(prov_id) + if not pdata: + continue + + date_ranges = facet_date_ranges.get(fid, {}) + unit = finfo.get('unit') + mm = finfo.get('measurementMethod') + + source_name = _get_node_name(pdata['source'], linked_names_map) + prov_name = _get_node_name(pdata['isPartOf'], linked_names_map) or \ + _get_node_name(pdata['name'], linked_names_map) or import_name + + mm_desc = None + if mm and prov_name not in MEASUREMENT_METHODS_SUPPRESSION_PROVENANCES: + mm_desc = mm_map.get(mm) or title_case(mm) + + resolved_unit = (unit_map.get(unit) or + unit.replace('_', ' ')) if unit else unit + license_name = _get_node_name(pdata['licenseType'], linked_names_map) + license_dcid = pdata['licenseType'][0].get( + 'dcid') if pdata.get('licenseType') and pdata['licenseType'] else None + + metadata_map[sv].append({ + 'statVarId': + sv, + 'statVarName': + stat_var_names.get(sv, sv), + 'categories': + category_map.get(sv, []), + 'sourceName': + source_name, + 'provenanceUrl': + pdata.get('url')[0].get('value') if pdata.get('url') else None, + 'provenanceName': + prov_name, + 'dateRangeStart': + date_ranges.get('earliestDate'), + 'dateRangeEnd': + date_ranges.get('latestDate'), + 'unit': + resolved_unit, + 'observationPeriod': + finfo.get('observationPeriod'), + 'license': + license_name, + 'licenseDcid': + license_dcid, + 'measurementMethod': + mm, + 'measurementMethodDescription': + mm_desc + }) + + return metadata_map + + +async def _fetch_node_data(dcids: set[str], prop: str) -> dict[str, Any]: + """Helper to fetch node data only if the list of DCIDs is not empty.""" + if not dcids: + return {} + return await asyncio.to_thread(dc.v2node, list(dcids), prop) + + +@bp.route('', methods=['POST']) +async def get_metadata() -> tuple[Response, int] | Response: + # Input Validation + req_data = request.get_json(silent=True) + if not req_data: + return jsonify({'error': 'Must provide a valid JSON body'}), 400 + + entities: list[str] = req_data.get('entities', []) + stat_vars: list[str] = req_data.get('statVars', []) + stat_var_to_facets: dict[str, list[str]] = req_data.get('statVarToFacets', {}) + frontend_facets: list[str] = req_data.get('facets', []) + + if not isinstance(entities, list) or not isinstance(stat_vars, list): + return jsonify({'error': 'entities and statVars must be lists'}), 400 + + if not entities or not stat_vars: + return jsonify({'metadata': {}, 'statVarList': []}) + + # Initial Data Fetching + v2obs_kwargs = { + 'select': ['entity', 'variable', 'facet'], + 'entity': { + 'dcids': entities + }, + 'variable': { + 'dcids': stat_vars + } + } + if frontend_facets: + v2obs_kwargs['filter'] = {'facetIds': frontend_facets} + + try: + name_resp, obs_resp, category_map = await asyncio.gather( + asyncio.to_thread(dc.v2node, stat_vars, '->name'), + asyncio.to_thread(dc.v2observation, **v2obs_kwargs), + fetch_categories_async(stat_vars)) + except Exception: + logging.exception("Failed to fetch primary metadata from DC") + return jsonify({'error': 'Failed to communicate with Data Commons service' + }), 502 + + # Process Stat Var Names into a lookup dictionary + stat_var_names: dict[str, str] = {} + stat_var_list: list[dict[str, str]] = [] + if 'data' in name_resp: + for sv in stat_vars: + nodes = _get_arc_nodes(name_resp, sv, 'name') + name = nodes[0].get('value') if nodes else sv + stat_var_names[sv] = name + stat_var_list.append({"dcid": sv, "name": name}) + + # Collate active facets per stat var + sv_active_facets: dict[str, list[str]] = { + sv: _extract_active_facets(sv, obs_resp, stat_var_to_facets) + for sv in stat_vars + } + + facets = obs_resp.get('facets', {}) + + facet_date_ranges: dict[str, dict[str, str]] = collections.defaultdict(dict) + provenance_endpoints: set[str] = set() + measurement_methods: set[str] = set() + units: set[str] = set() + + for sv in stat_vars: + for fid in sv_active_facets[sv]: + # Aggregate measurement methods, units and import names + finfo = facets.get(fid, {}) + if finfo.get('unit'): + units.add(finfo['unit']) + if finfo.get('measurementMethod'): + measurement_methods.add(finfo['measurementMethod']) + if finfo.get('importName'): + provenance_endpoints.add(f"dc/base/{finfo['importName']}") + + # Aggregate Date Ranges + by_entity = obs_resp.get('byVariable', {}).get(sv, {}).get('byEntity', {}) + for ent_data in by_entity.values(): + for f in ent_data.get('orderedFacets', []): + if f.get('facetId') != fid: + continue + + earliest, latest = f.get('earliestDate'), f.get('latestDate') + if earliest and (not facet_date_ranges[fid].get('earliestDate') or + earliest < facet_date_ranges[fid]['earliestDate']): + facet_date_ranges[fid]['earliestDate'] = earliest + if latest and (not facet_date_ranges[fid].get('latestDate') or + latest > facet_date_ranges[fid]['latestDate']): + facet_date_ranges[fid]['latestDate'] = latest + + # Look up names and descriptions of provenances, measurement methods and units + try: + prov_res, mm_res, unit_res = await asyncio.gather( + _fetch_node_data(provenance_endpoints, '->*'), + _fetch_node_data(measurement_methods, '->description'), + _fetch_node_data(units, '->name')) + except Exception: + logging.exception("Failed to fetch secondary metadata from DC") + return jsonify({'error': 'Failed to resolve secondary node data'}), 502 + + # Process secondary lookups + prov_map: dict[str, dict[str, Any]] = {} + linked_prov_dcids: set[str] = set() + + if 'data' in prov_res: + for dcid in prov_res['data']: + prov_map[dcid] = { + 'source': _get_arc_nodes(prov_res, dcid, 'source'), + 'isPartOf': _get_arc_nodes(prov_res, dcid, 'isPartOf'), + 'name': _get_arc_nodes(prov_res, dcid, 'name'), + 'url': _get_arc_nodes(prov_res, dcid, 'url'), + 'licenseType': _get_arc_nodes(prov_res, dcid, 'licenseType'), + } + # Collect DCIDs of linked entities for human-readable resolution + for n in prov_map[dcid]['source'] + prov_map[dcid]['isPartOf'] + prov_map[ + dcid]['licenseType']: + if 'dcid' in n: + linked_prov_dcids.add(n['dcid']) + + linked_names_map: dict[str, str] = {} + if linked_prov_dcids: + try: + linked_names_resp = await asyncio.to_thread(dc.v2node, + list(linked_prov_dcids), + '->name') + for n_dcid in linked_prov_dcids: + n_arcs = _get_arc_nodes(linked_names_resp, n_dcid, 'name') + if n_arcs: + linked_names_map[n_dcid] = n_arcs[0].get('value') + except Exception: + logging.exception("Failed to resolve linked provenance names") + + mm_map: dict[str, str] = { + mm: _get_arc_nodes(mm_res, mm, 'description')[0].get('value') + for mm in measurement_methods + if _get_arc_nodes(mm_res, mm, 'description') + } + unit_map: dict[str, str] = { + u: _get_arc_nodes(unit_res, u, 'name')[0].get('value') + for u in units + if _get_arc_nodes(unit_res, u, 'name') + } + + # Assemble and return the final response + metadata_map = _build_metadata_payload(stat_vars, stat_var_names, + category_map, sv_active_facets, facets, + facet_date_ranges, prov_map, + linked_names_map, mm_map, unit_map) + + return jsonify({'metadata': metadata_map, 'statVarList': stat_var_list}) diff --git a/server/services/datacommons.py b/server/services/datacommons.py index 72f753e307..5cfd201559 100644 --- a/server/services/datacommons.py +++ b/server/services/datacommons.py @@ -268,13 +268,13 @@ def point_within_facet(parent_entity, child_type, variables, date): }) -def v2observation(select, entity, variable): +def v2observation(select, entity, variable, filter=None): """ Args: select: A list of select props. entity: A dict in the form of {'dcids':, 'expression':} variable: A dict in the form of {'dcids':, 'expression':} - + filter: Optional dict in the form of {'facetIds': [...]} etc. """ # Remove None from dcids and sort them. Note do not sort in place to avoid # changing the original input. @@ -283,11 +283,14 @@ def v2observation(select, entity, variable): if "dcids" in variable: variable["dcids"] = sorted([x for x in variable["dcids"] if x]) url = get_service_url("/v2/observation") - return post(url, { + req = { "select": select, "entity": entity, "variable": variable, - }) + } + if filter: + req["filter"] = filter + return post(url, req) def v2node(nodes, prop): diff --git a/static/js/components/tiles/bar_tile.tsx b/static/js/components/tiles/bar_tile.tsx index 2e20cb45ff..430317b5d4 100644 --- a/static/js/components/tiles/bar_tile.tsx +++ b/static/js/components/tiles/bar_tile.tsx @@ -269,6 +269,7 @@ export function BarTile(props: BarTilePropType): ReactElement { facets={barChartData?.facets} statVarToFacets={barChartData?.statVarToFacets} subtitle={props.subtitle} + entities={"places" in props ? props.places : [props.parentPlace]} title={props.title} statVarSpecs={props.variables} forwardRef={containerRef} diff --git a/static/js/components/tiles/chart_tile.tsx b/static/js/components/tiles/chart_tile.tsx index a562eaeef1..b51e669d9c 100644 --- a/static/js/components/tiles/chart_tile.tsx +++ b/static/js/components/tiles/chart_tile.tsx @@ -53,6 +53,8 @@ interface ChartTileContainerProp { statVarToFacets?: StatVarFacetMap; // A map of stat var dcids to their specific min and max date range from the chart statVarDateRanges?: Record; + // A list of entities used within the chart + entities?: string[]; children: React.ReactNode; replacementStrings: ReplacementStrings; // Whether or not to allow chart embedding action. @@ -126,6 +128,7 @@ export function ChartTileContainer( {showSources && ( )} diff --git a/static/js/components/tiles/line_tile.tsx b/static/js/components/tiles/line_tile.tsx index 787ef391a4..c22d6efbc5 100644 --- a/static/js/components/tiles/line_tile.tsx +++ b/static/js/components/tiles/line_tile.tsx @@ -231,6 +231,7 @@ export function LineTile(props: LineTilePropType): ReactElement { getObservationSpecs={getObservationSpecs} errorMsg={chartData && chartData.errorMsg} id={props.id} + entities={getPlaceDcids(props)} isInitialLoading={_.isNull(chartData)} isLoading={isLoading} replacementStrings={getReplacementStrings(props, chartData)} diff --git a/static/js/components/tiles/map_tile.tsx b/static/js/components/tiles/map_tile.tsx index 3325a91ad7..9c709597ff 100644 --- a/static/js/components/tiles/map_tile.tsx +++ b/static/js/components/tiles/map_tile.tsx @@ -243,6 +243,20 @@ export function MapTile(props: MapTilePropType): ReactElement { !!zoomParams && !!mapChartData && _.isEqual(mapChartData.props, props); const dataCommonsClient = getDataCommonsClient(props.apiRoot, props.surface); + const entities = useMemo( + () => + mapChartData + ? Array.from( + new Set( + mapChartData.layerData.flatMap((layer) => + Object.keys(layer.dataValues || {}) + ) + ) + ) + : [], + [mapChartData] + ); + useEffect(() => { if (props.lazyLoad && !shouldLoad) { return; @@ -378,6 +392,7 @@ export function MapTile(props: MapTilePropType): ReactElement { return ( + rankingData + ? Array.from( + new Set( + Object.values(rankingData).flatMap((svData) => + svData.points.map((p) => p.placeDcid) + ) + ) + ) + : [], + [rankingData] + ); + /** * Opens export modal window */ @@ -342,6 +356,7 @@ export function RankingTile(props: RankingTilePropType): ReactElement { facets={allFacets} statVarToFacets={allStatVarToFacets} apiRoot={props.apiRoot} + entities={entities} /> ); diff --git a/static/js/components/tiles/scatter_tile.tsx b/static/js/components/tiles/scatter_tile.tsx index d80b2850d3..b9c5dfc1d0 100644 --- a/static/js/components/tiles/scatter_tile.tsx +++ b/static/js/components/tiles/scatter_tile.tsx @@ -155,6 +155,19 @@ export function ScatterTile(props: ScatterTilePropType): ReactElement { >(null); const [isLoading, setIsLoading] = useState(true); const { shouldLoad, containerRef } = useLazyLoad(props.lazyLoadMargin); + + const entities = useMemo( + () => + scatterChartData + ? Array.from( + new Set( + Object.values(scatterChartData.points).map((p) => p.place.dcid) + ) + ) + : [], + [scatterChartData] + ); + /* TODO: (nick-next) destructure the props similarly to highlight to allow a complete dependency array. @@ -250,6 +263,7 @@ export function ScatterTile(props: ScatterTilePropType): ReactElement { getObservationSpecs={getObservationSpecs} errorMsg={scatterChartData && scatterChartData.errorMsg} id={props.id} + entities={entities} isInitialLoading={_.isNull(scatterChartData)} isLoading={isLoading} replacementStrings={getReplacementStrings(props, scatterChartData)} diff --git a/static/js/components/tiles/sv_ranking_units.tsx b/static/js/components/tiles/sv_ranking_units.tsx index 66f6036c39..f89a6412c4 100644 --- a/static/js/components/tiles/sv_ranking_units.tsx +++ b/static/js/components/tiles/sv_ranking_units.tsx @@ -370,6 +370,12 @@ export function getRankingUnit( rankingGroup, enableScroll ); + const entities = Array.from( + new Set([ + ...(topPoints || []).map((p) => p.placeDcid), + ...(bottomPoints || []).map((p) => p.placeDcid), + ]) + ); const title = getRankingUnitTitle( tileConfigTitle, rankingMetadata, @@ -401,6 +407,7 @@ export function getRankingUnit( containerRef={containerRef} sources={sources || rankingGroup.sources} facets={rankingGroup.facets} + entities={entities} statVarToFacets={rankingGroup.statVarToFacets} statVarSpecs={statVarSpecs} surface={surface} diff --git a/static/js/place/chart_embed.tsx b/static/js/place/chart_embed.tsx index 9acc0ac7ed..0a70a3ebc0 100644 --- a/static/js/place/chart_embed.tsx +++ b/static/js/place/chart_embed.tsx @@ -55,7 +55,10 @@ import { buildCitationParts, CitationPart, } from "../tools/shared/metadata/citations"; -import { fetchMetadata } from "../tools/shared/metadata/metadata_fetcher"; +import { + fetchMetadata, + fetchMetadataV2, +} from "../tools/shared/metadata/metadata_fetcher"; import { getDataCommonsClient } from "../utils/data_commons_client"; // SVG adjustment related constants @@ -70,6 +73,7 @@ interface ChartEmbedPropsType { container?: HTMLElement; statVarSpecs?: StatVarSpec[]; facets?: Record; + entities?: string[]; statVarToFacets?: StatVarFacetMap; // A map of stat var dcids to their specific min and max date range from the chart statVarDateRanges?: Record; @@ -415,13 +419,26 @@ class ChartEmbed extends React.Component< return []; } const dataCommonsClient = getDataCommonsClient(apiRoot, surface); - const metadataResp = await fetchMetadata( - statVarSet, - facets, - dataCommonsClient, - statVarToFacets, - apiRoot - ); + + let metadataResp; + if (this.props.entities && this.props.entities.length > 0) { + metadataResp = await fetchMetadataV2( + this.props.entities, + statVarSet, + statVarToFacets, + apiRoot, + facets + ); + } else { + metadataResp = await fetchMetadata( + statVarSet, + facets, + dataCommonsClient, + statVarToFacets, + apiRoot + ); + } + return buildCitationParts( metadataResp.statVarList, metadataResp.metadata, diff --git a/static/js/tools/shared/metadata/metadata_fetcher.ts b/static/js/tools/shared/metadata/metadata_fetcher.ts index 6c8f8fe181..e250a05fc0 100644 --- a/static/js/tools/shared/metadata/metadata_fetcher.ts +++ b/static/js/tools/shared/metadata/metadata_fetcher.ts @@ -528,3 +528,58 @@ export async function fetchMetadata( return { metadata, statVarList }; } + +//TODO (nick-nlb): Once metadata migration is complete remove old endpoint and remove "V2" from this one. + +/** + * Function to fetch comprehensive metadata for a list of entities and stat vars. + * + * @param entities - Array of entity DCIDs to fetch metadata for + * @param statVarSet - Set of stat var DCIDs to fetch metadata for + * @param statVarToFacets - Optional mapping of stat vars to their facets + * @param apiRoot - Optional API root URL for requests + * @param facets - Optional map of the facet id to StatMetadata + * @returns Promise resolving to an object containing two attributes, metadata and statVarList. + * The metadata attribute is a mapping of stat var ids to metadata. + * The statVarList is list of stat var nodes containing full names. + */ +export async function fetchMetadataV2( + entities: string[], + statVarSet: Set, + statVarToFacets?: StatVarFacetMap, + apiRoot?: string, + facets?: Record +): Promise<{ + metadata: Record; + statVarList: NamedNode[]; +}> { + const statVars = [...statVarSet]; + if (!statVars.length) return { metadata: {}, statVarList: [] }; + + const convertedStatVarToFacets: Record = {}; + if (statVarToFacets) { + for (const [sv, facetSet] of Object.entries(statVarToFacets)) { + convertedStatVarToFacets[sv] = Array.isArray(facetSet) + ? facetSet + : Array.from(facetSet); + } + } + + const response = await fetch(`${apiRoot || ""}/api/metadata`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + entities, + statVars, + statVarToFacets: convertedStatVarToFacets, + facets: facets ? Object.keys(facets) : undefined, + }), + }); + + if (!response.ok) { + console.error("Failed to fetch metadata", await response.text()); + return { metadata: {}, statVarList: [] }; + } + + return response.json(); +} diff --git a/static/js/tools/shared/metadata/tile_metadata_modal.tsx b/static/js/tools/shared/metadata/tile_metadata_modal.tsx index cd1f274c6a..b374212a8c 100644 --- a/static/js/tools/shared/metadata/tile_metadata_modal.tsx +++ b/static/js/tools/shared/metadata/tile_metadata_modal.tsx @@ -42,7 +42,7 @@ import { NamedNode, StatVarFacetMap, StatVarSpec } from "../../../shared/types"; import { getDataCommonsClient } from "../../../utils/data_commons_client"; import { buildCitationParts, citationToPlainText } from "./citations"; import { StatVarMetadata } from "./metadata"; -import { fetchMetadata } from "./metadata_fetcher"; +import { fetchMetadata, fetchMetadataV2 } from "./metadata_fetcher"; import { TileMetadataModalContent } from "./tile_metadata_modal_content"; interface TileMetadataModalPropType { @@ -57,6 +57,8 @@ interface TileMetadataModalPropType { containerRef?: React.RefObject; // root URL used to generate stat var explorer and license links apiRoot?: string; + // array of entity dcids to use for fetching + entities?: string[]; // used in mixer usage logs. Indicates which surface (website, web components, etc) is making the call. surface: string; } @@ -104,13 +106,27 @@ export function TileMetadataModal( setLoading(true); setError(false); - fetchMetadata( - statVarSet, - props.facets, - dataCommonsClient, - props.statVarToFacets, - props.apiRoot - ) + + let fetchPromise; + if (props.entities && props.entities.length > 0) { + fetchPromise = fetchMetadataV2( + props.entities, + statVarSet, + props.statVarToFacets, + props.apiRoot, + props.facets + ); + } else { + fetchPromise = fetchMetadata( + statVarSet, + props.facets, + dataCommonsClient, + props.statVarToFacets, + props.apiRoot + ); + } + + fetchPromise .then((resp) => { // Sort stat vars: non-denominators first, then denominators. // Secondary sort is alphabetical. @@ -143,6 +159,7 @@ export function TileMetadataModal( props.apiRoot, props.statVarToFacets, props.facets, + props.entities, denomStatVarDcids, ]); diff --git a/static/js/tools/shared/metadata/tile_sources.tsx b/static/js/tools/shared/metadata/tile_sources.tsx index bf463e7356..c85dd7d79f 100644 --- a/static/js/tools/shared/metadata/tile_sources.tsx +++ b/static/js/tools/shared/metadata/tile_sources.tsx @@ -47,6 +47,8 @@ export function TileSources(props: { // the detailed metadata modal. If not supplied, we fall back to a simple // modal display using the sources. facets?: Record; + // Array of entity dcids to use for fetching + entities?: string[]; // A mapping of which stat var used which facets statVarToFacets?: StatVarFacetMap; // If available, the stat vars to link to. @@ -67,6 +69,7 @@ export function TileSources(props: { surface: string; }): ReactElement { const { + entities, facets, statVarToFacets, statVarSpecs, @@ -129,6 +132,7 @@ export function TileSources(props: { {facets && statVarToFacets ? (