diff --git a/server/routes/shared_api/place.py b/server/routes/shared_api/place.py index 481db08f00..a1d641ab00 100644 --- a/server/routes/shared_api/place.py +++ b/server/routes/shared_api/place.py @@ -339,9 +339,10 @@ def child_fetch(parent_dcid): return result -@bp.route('/parent/') -@cache.cache.memoize(timeout=cache.TIMEOUT) -def api_parent_places(dcid): +@bp.route('/parent') +@cache.cache.cached(timeout=cache.TIMEOUT, query_string=True) +def api_parent_places(): + dcid = request.args.get("dcid") result = parent_places([dcid])[dcid] return Response(json.dumps(result), 200, mimetype='application/json') diff --git a/static/js/components/subject_page/disaster_event_block.tsx b/static/js/components/subject_page/disaster_event_block.tsx index 5058dd372b..0f7ad33975 100644 --- a/static/js/components/subject_page/disaster_event_block.tsx +++ b/static/js/components/subject_page/disaster_event_block.tsx @@ -107,14 +107,24 @@ export const DisasterEventBlock = memo(function DisasterEventBlock( props.eventTypeSpec, props.columns ); - fetchDisasterEventData(props, fetchData).then((disasterData) => { + fetchDisasterEventData( + props.id, + blockEventTypeSpecs.current, + props.place.dcid, + fetchData + ).then((disasterData) => { setDisasterEventData(disasterData); }); function handleHashChange() { const spinnerId = getSpinnerId(); loadSpinner(spinnerId); - fetchDisasterEventData(props, fetchData).then((disasterData) => { + fetchDisasterEventData( + props.id, + blockEventTypeSpecs.current, + props.place.dcid, + fetchData + ).then((disasterData) => { setDisasterEventData(disasterData); removeSpinner(spinnerId); }); @@ -213,37 +223,37 @@ function getDataFetchCacheKey(dataOptions: DisasterDataOptions): string { /** * Fetches disaster event data for a disaster event block - * @param props the props for a disaster event block + * @param blockId id of the block + * @param eventTypeSpecs event type specs used in current block + * @param placeDcid dcid of the place to * @param fetchData function to use to fetch data with a given cache key and * data promise */ export function fetchDisasterEventData( - props: DisasterEventBlockPropType, + blockId: string, + eventTypeSpecs: Record, + placeDcid: string, fetchData?: ( cacheKey: string, dataPromise: () => Promise - ) => Promise + ) => Promise, + apiRoot?: string ): Promise> { const promises = []; // list of spec ids that correspond to the spec id used for the promise at // that index in the list of promises. const specIds = []; - const blockEventTypeSpecs = getBlockEventTypeSpecs( - props.eventTypeSpec, - props.columns - ); - Object.values(blockEventTypeSpecs).forEach((spec) => { + Object.values(eventTypeSpecs).forEach((spec) => { const specDataOptions = { eventTypeSpec: spec, - selectedDate: getDate(props.id), - severityFilters: getSeverityFilters(props.eventTypeSpec, props.id), + selectedDate: getDate(blockId), + severityFilters: getSeverityFilters(eventTypeSpecs, blockId), useCache: getUseCache(), - place: props.place.dcid, + place: placeDcid, }; specIds.push(spec.id); const cacheKey = getDataFetchCacheKey(specDataOptions); - const promiseFn = () => - fetchDisasterEventPoints(specDataOptions, props.apiRoot); + const promiseFn = () => fetchDisasterEventPoints(specDataOptions, apiRoot); const promise = fetchData ? fetchData(cacheKey, promiseFn) : promiseFn(); promises.push(promise); }); @@ -264,7 +274,7 @@ export function fetchDisasterEventData( } // Gets all the relevant event type specs for a list of columns -function getBlockEventTypeSpecs( +export function getBlockEventTypeSpecs( fullEventTypeSpec: Record, columns: ColumnConfig[] ): Record { diff --git a/static/js/components/tiles/bar_tile.tsx b/static/js/components/tiles/bar_tile.tsx index 44d8a0df33..62be7a9709 100644 --- a/static/js/components/tiles/bar_tile.tsx +++ b/static/js/components/tiles/bar_tile.tsx @@ -86,7 +86,7 @@ export interface BarTilePropType { yAxisMargin?: number; } -interface BarChartData { +export interface BarChartData { dataGroup: DataGroup[]; sources: Set; unit: string; diff --git a/static/js/components/tiles/bivariate_tile.tsx b/static/js/components/tiles/bivariate_tile.tsx index 01ec241c27..119c1f71d1 100644 --- a/static/js/components/tiles/bivariate_tile.tsx +++ b/static/js/components/tiles/bivariate_tile.tsx @@ -212,7 +212,7 @@ export const fetchData = async ( ) .then((resp) => resp.data); const parentPlacesPromise = axios - .get(`/api/place/parent/${place.dcid}`) + .get(`/api/place/parent?dcid=${place.dcid}`) .then((resp) => resp.data); try { const [placeStats, population, placeNames, geoJson, parentPlaces] = diff --git a/static/js/components/tiles/disaster_event_map_tile.tsx b/static/js/components/tiles/disaster_event_map_tile.tsx index 2348e0eaa1..ef7429ca5c 100644 --- a/static/js/components/tiles/disaster_event_map_tile.tsx +++ b/static/js/components/tiles/disaster_event_map_tile.tsx @@ -83,7 +83,7 @@ const PLACE_TYPE_GEOJSON_PROP = { EurostatNUTS3: "geoJsonCoordinatesDP1", }; -interface DisasterEventMapTilePropType { +export interface DisasterEventMapTilePropType { // Id for this tile id: string; // Title for this tile @@ -104,7 +104,7 @@ interface DisasterEventMapTilePropType { apiRoot?: string; } -interface DisasterMapChartData { +export interface DisasterMapChartData { // Geojson data for the base map baseMapGeoJson: GeoJsonData; // Whether the base map is a map of just the current place. If false, the base diff --git a/static/js/components/tiles/line_tile.tsx b/static/js/components/tiles/line_tile.tsx index c8872432b3..c6ec23ffa3 100644 --- a/static/js/components/tiles/line_tile.tsx +++ b/static/js/components/tiles/line_tile.tsx @@ -55,7 +55,7 @@ export interface LineTilePropType { svgChartWidth?: number; } -interface LineChartData { +export interface LineChartData { dataGroup: DataGroup[]; sources: Set; unit: string; diff --git a/static/js/components/tiles/map_tile.tsx b/static/js/components/tiles/map_tile.tsx index 66e91b1afc..95b291db9d 100644 --- a/static/js/components/tiles/map_tile.tsx +++ b/static/js/components/tiles/map_tile.tsx @@ -84,7 +84,7 @@ interface RawData { borderGeoJson?: GeoJsonData; } -interface MapChartData { +export interface MapChartData { dataValues: { [dcid: string]: number }; metadata: { [dcid: string]: DataPointMetadata }; sources: Set; @@ -257,7 +257,7 @@ export const fetchData = async ( const parentPlacesPromise = props.parentPlaces ? Promise.resolve(props.parentPlaces) : axios - .get(`${props.apiRoot || ""}/api/place/parent/${props.place.dcid}`) + .get(`${props.apiRoot || ""}/api/place/parent?dcid=${props.place.dcid}`) .then((resp) => resp.data); try { const [geoJson, placeStat, population, parentPlaces, borderGeoJsonData] = diff --git a/static/js/components/tiles/scatter_tile.tsx b/static/js/components/tiles/scatter_tile.tsx index a27fbd2495..41fff8065f 100644 --- a/static/js/components/tiles/scatter_tile.tsx +++ b/static/js/components/tiles/scatter_tile.tsx @@ -44,7 +44,7 @@ import { getStatVarName, ReplacementStrings } from "../../utils/tile_utils"; import { ChartTileContainer } from "./chart_tile"; import { useDrawOnResize } from "./use_draw_on_resize"; -interface ScatterTilePropType { +export interface ScatterTilePropType { id: string; title: string; place: NamedTypedPlace; diff --git a/static/js/tools/download/mock_functions.ts b/static/js/tools/download/mock_functions.ts index c93f86eac1..ba49534550 100644 --- a/static/js/tools/download/mock_functions.ts +++ b/static/js/tools/download/mock_functions.ts @@ -206,7 +206,7 @@ export function axiosMock(): void { // get parent place for geoId/06 when(axios.get) - .calledWith("/api/place/parent/geoId/06") + .calledWith("/api/place/parent?dcid=geoId/06") .mockResolvedValue({ data: [{ dcid: "country/USA", type: "Country", name: "United States" }], }); diff --git a/static/js/tools/scatter/app.test.tsx b/static/js/tools/scatter/app.test.tsx index d905afe32f..3afdbac0a2 100644 --- a/static/js/tools/scatter/app.test.tsx +++ b/static/js/tools/scatter/app.test.tsx @@ -459,7 +459,7 @@ function mockAxios(): void { }); when(axios.get) - .calledWith("/api/place/parent/geoId/10") + .calledWith("/api/place/parent?dcid=geoId/10") .mockResolvedValue({ data: [{ dcid: "country/USA", type: "Country", name: "United States" }], }); diff --git a/static/js/utils/place_utils.ts b/static/js/utils/place_utils.ts index d51e1553ad..8286f41d7f 100644 --- a/static/js/utils/place_utils.ts +++ b/static/js/utils/place_utils.ts @@ -74,7 +74,7 @@ export function getParentPlacesPromise( apiRoot?: string ): Promise> { return axios - .get(`${apiRoot || ""}/api/place/parent/${placeDcid}`) + .get(`${apiRoot || ""}/api/place/parent?dcid=${placeDcid}`) .then((resp) => { const parentsData = resp.data; const filteredParentsData = parentsData.filter((parent) => { diff --git a/static/nodejs_server/bar_tile.ts b/static/nodejs_server/bar_tile.ts index a335090cbe..4b20426b98 100644 --- a/static/nodejs_server/bar_tile.ts +++ b/static/nodejs_server/bar_tile.ts @@ -15,12 +15,14 @@ */ /** - * Functions for getting tile result for a bar tile + * Functions for getting results for a bar tile */ import _ from "lodash"; import { + BarChartData, + BarTilePropType, draw, fetchData, getReplacementStrings, @@ -30,27 +32,18 @@ import { NamedTypedPlace, StatVarSpec } from "../js/shared/types"; import { TileConfig } from "../js/types/subject_page_proto_types"; import { dataGroupsToCsv } from "../js/utils/chart_csv_utils"; import { getChartTitle } from "../js/utils/tile_utils"; -import { DOM_ID, SVG_HEIGHT, SVG_WIDTH } from "./constants"; +import { CHART_ID, DOM_ID, SVG_HEIGHT, SVG_WIDTH } from "./constants"; import { TileResult } from "./types"; -import { getProcessedSvg, getSources } from "./utils"; +import { getChartUrl, getProcessedSvg, getSources, getSvgXml } from "./utils"; -/** - * Gets the Tile Result for a bar tile - * @param id id of the chart - * @param tileConfig config for the bar tile - * @param place place to show the bar chart for - * @param enclosedPlaceType enclosed place type to use for bar chart - * @param statVarSpec list of stat var specs to show in the bar chart - * @param apiRoot API root to use to fetch data - */ -export async function getBarTileResult( +function getTileProp( id: string, tileConfig: TileConfig, place: NamedTypedPlace, enclosedPlaceType: string, statVarSpec: StatVarSpec[], apiRoot: string -): Promise { +): BarTilePropType { const comparisonPlaces = tileConfig.comparisonPlaces ? tileConfig.comparisonPlaces.map((p) => p == SELF_PLACE_DCID_PLACEHOLDER ? place.dcid : p @@ -58,7 +51,7 @@ export async function getBarTileResult( : undefined; const useLollipop = tileConfig.barTileSpec && tileConfig.barTileSpec.useLollipop; - const tileProp = { + return { id, title: tileConfig.title, place, @@ -69,12 +62,48 @@ export async function getBarTileResult( comparisonPlaces, useLollipop, }; +} + +function getBarChartSvg( + tileProp: BarTilePropType, + chartData: BarChartData +): SVGSVGElement { + const tileContainer = document.createElement("div"); + tileContainer.setAttribute("id", tileProp.id); + document.getElementById(DOM_ID).appendChild(tileContainer); + draw(tileProp, chartData, tileContainer, SVG_WIDTH); + return getProcessedSvg(tileContainer.querySelector("svg")); +} + +/** + * Gets the Tile Result for a bar tile + * @param id id of the chart + * @param tileConfig config for the bar tile + * @param place place to show the bar chart for + * @param enclosedPlaceType enclosed place type to use for bar chart + * @param statVarSpec list of stat var specs to show in the bar chart + * @param apiRoot API root to use to fetch data + */ +export async function getBarTileResult( + id: string, + tileConfig: TileConfig, + place: NamedTypedPlace, + enclosedPlaceType: string, + statVarSpec: StatVarSpec[], + apiRoot: string, + urlRoot: string, + useChartUrl: boolean +): Promise { + const tileProp = getTileProp( + id, + tileConfig, + place, + enclosedPlaceType, + statVarSpec, + apiRoot + ); try { const chartData = await fetchData(tileProp); - const tileContainer = document.createElement("div"); - tileContainer.setAttribute("id", id); - document.getElementById(DOM_ID).appendChild(tileContainer); - draw(tileProp, chartData, tileContainer, SVG_WIDTH); let legend = []; if ( !_.isEmpty(chartData.dataGroup) && @@ -82,10 +111,7 @@ export async function getBarTileResult( ) { legend = chartData.dataGroup[0].value.map((dp) => dp.label); } - const svg = getProcessedSvg(tileContainer.querySelector("svg")); - tileContainer.remove(); - return { - svg, + const result: TileResult = { data_csv: dataGroupsToCsv(chartData.dataGroup), srcs: getSources(chartData.sources), legend, @@ -95,8 +121,54 @@ export async function getBarTileResult( ), type: "BAR", }; + if (useChartUrl) { + result.chartUrl = getChartUrl( + tileConfig, + place.dcid, + statVarSpec, + enclosedPlaceType, + null, + urlRoot + ); + return result; + } + const svg = getBarChartSvg(tileProp, chartData); + result.svg = getSvgXml(svg); + return result; } catch (e) { console.log("Failed to get bar tile result for: " + id); return null; } } + +/** + * Gets the bar chart for a given tile config + * @param tileConfig the tile config for the chart + * @param place the place to get the chart for + * @param enclosedPlaceType the enclosed place type to get the chart for + * @param statVarSpec list of stat var specs to show in the chart + * @param apiRoot API root to use to fetch data + */ +export async function getBarChart( + tileConfig: TileConfig, + place: NamedTypedPlace, + enclosedPlaceType: string, + statVarSpec: StatVarSpec[], + apiRoot: string +): Promise { + const tileProp = getTileProp( + CHART_ID, + tileConfig, + place, + enclosedPlaceType, + statVarSpec, + apiRoot + ); + try { + const chartData = await fetchData(tileProp); + return getBarChartSvg(tileProp, chartData); + } catch (e) { + console.log("Failed to get bar chart"); + return null; + } +} diff --git a/static/nodejs_server/constants.ts b/static/nodejs_server/constants.ts index 87133f5bda..a83bf9a293 100644 --- a/static/nodejs_server/constants.ts +++ b/static/nodejs_server/constants.ts @@ -29,6 +29,8 @@ export const SVG_HEIGHT = 300; // Width of the svg to render. export const SVG_WIDTH = 500; export const DOM_ID = "dom-id"; +// id to use for drawing charts. +export const CHART_ID = "chart-id"; // Font family to use for all the text on the charts. If this is updated, need // to also update CHAR_WIDTHS and CHAR_AVG_WIDTHS. export const FONT_FAMILY = "Roboto"; @@ -38,3 +40,11 @@ export const FONT_SIZE = "10px"; // Width of the constant sized part of the map legend export const MAP_LEGEND_CONSTANT_WIDTH = LEGEND_IMG_WIDTH + LEGEND_MARGIN_RIGHT + LEGEND_TICK_LABEL_MARGIN; +// Url params used for getting a single chart +export const CHART_URL_PARAMS = { + TILE_CONFIG: "config", + PLACE: "place", + ENCLOSED_PLACE_TYPE: "enclosedPlaceType", + STAT_VAR_SPEC: "svSpec", + EVENT_TYPE_SPEC: "eventTypeSpec", +}; diff --git a/static/nodejs_server/disaster_map_tile.ts b/static/nodejs_server/disaster_map_tile.ts index b2e00fc614..72cb38c539 100644 --- a/static/nodejs_server/disaster_map_tile.ts +++ b/static/nodejs_server/disaster_map_tile.ts @@ -15,12 +15,15 @@ */ /** - * Functions for getting tile result for a disaster map tile + * Functions for getting results for a disaster map tile */ import _ from "lodash"; +import { fetchDisasterEventData } from "../js/components/subject_page/disaster_event_block"; import { + DisasterEventMapTilePropType, + DisasterMapChartData, draw, fetchChartData, getReplacementStrings, @@ -32,9 +35,49 @@ import { TileConfig, } from "../js/types/subject_page_proto_types"; import { getChartTitle } from "../js/utils/tile_utils"; -import { SVG_HEIGHT, SVG_WIDTH } from "./constants"; +import { CHART_ID, SVG_HEIGHT, SVG_WIDTH } from "./constants"; import { TileResult } from "./types"; -import { getProcessedSvg, getSources } from "./utils"; +import { getChartUrl, getProcessedSvg, getSources, getSvgXml } from "./utils"; + +function getTileProp( + id: string, + tileConfig: TileConfig, + place: NamedTypedPlace, + enclosedPlaceType: string, + eventTypeSpec: Record, + eventData: Record, + apiRoot: string +): DisasterEventMapTilePropType { + return { + id, + title: tileConfig.title, + place, + enclosedPlaceType, + eventTypeSpec, + disasterEventData: eventData, + tileSpec: tileConfig.disasterEventMapTileSpec, + apiRoot, + }; +} + +function getDisasterMapSvg( + tileProp: DisasterEventMapTilePropType, + chartData: DisasterMapChartData, + eventTypeSpec: Record +): SVGSVGElement { + const mapContainer = document.createElement("div"); + draw( + tileProp, + chartData, + mapContainer, + new Set(Object.keys(eventTypeSpec)), + SVG_HEIGHT, + SVG_WIDTH + ); + const svg = mapContainer.querySelector("svg"); + svg.style.background = "#eee"; + return getProcessedSvg(svg); +} /** * Gets the Tile Result for a disaster map tile @@ -53,7 +96,9 @@ export async function getDisasterMapTileResult( enclosedPlaceType: string, eventTypeSpec: Record, disasterEventDataPromise: Promise>, - apiRoot: string + apiRoot: string, + urlRoot: string, + useChartUrl: boolean ): Promise { let tileEventData = null; try { @@ -62,37 +107,78 @@ export async function getDisasterMapTileResult( Object.keys(eventTypeSpec).forEach((specId) => { tileEventData[specId] = disasterEventData[specId]; }); - const tileProp = { - id: "test", - title: tileConfig.title, + const tileProp = getTileProp( + id, + tileConfig, place, enclosedPlaceType, eventTypeSpec, - disasterEventData: tileEventData, - tileSpec: tileConfig.disasterEventMapTileSpec, - apiRoot, - }; - const chartData = await fetchChartData(tileProp); - const mapContainer = document.createElement("div"); - draw( - tileProp, - chartData, - mapContainer, - new Set(Object.keys(eventTypeSpec)), - SVG_HEIGHT, - SVG_WIDTH + disasterEventData, + apiRoot ); - const svg = mapContainer.querySelector("svg"); - svg.style.background = "#eee"; - return { - svg: getProcessedSvg(svg), + const chartData = await fetchChartData(tileProp); + const result: TileResult = { legend: Object.values(eventTypeSpec).map((spec) => spec.name), srcs: getSources(chartData.sources), title: getChartTitle(tileConfig.title, getReplacementStrings(tileProp)), type: "EVENT_MAP", }; + if (useChartUrl) { + result.chartUrl = getChartUrl( + tileConfig, + place.dcid, + [], + enclosedPlaceType, + eventTypeSpec, + urlRoot + ); + return result; + } + const svg = getDisasterMapSvg(tileProp, chartData, eventTypeSpec); + result.svg = getSvgXml(svg); + return result; } catch (e) { console.log("Failed to get disaster event map tile result for: " + id); return null; } } + +/** + * Gets the disaster map chart for a given tile config + * @param tileConfig the tile config for the chart + * @param place the place to get the chart for + * @param enclosedPlaceType the enclosed place type to get the chart for + * @param eventTypeSpec map of event types to show in the chart to their specs + * @param apiRoot API root to use to fetch data + */ +export async function getDisasterMapChart( + tileConfig: TileConfig, + place: NamedTypedPlace, + enclosedPlaceType: string, + eventTypeSpec: Record, + apiRoot: string +): Promise { + try { + const disasterEventData = await fetchDisasterEventData( + CHART_ID, + eventTypeSpec, + place.dcid, + null, + apiRoot + ); + const tileProp = getTileProp( + CHART_ID, + tileConfig, + place, + enclosedPlaceType, + eventTypeSpec, + disasterEventData, + apiRoot + ); + const chartData = await fetchChartData(tileProp); + return getDisasterMapSvg(tileProp, chartData, eventTypeSpec); + } catch (e) { + console.log("Failed to get disaster map chart."); + return null; + } +} diff --git a/static/nodejs_server/line_tile.ts b/static/nodejs_server/line_tile.ts index 4e6771755c..ee553a23a4 100644 --- a/static/nodejs_server/line_tile.ts +++ b/static/nodejs_server/line_tile.ts @@ -15,21 +15,52 @@ */ /** - * Functions for getting tile result for a line tile + * Functions for getting results for the line tile */ import { draw, fetchData, getReplacementStrings, + LineChartData, + LineTilePropType, } from "../js/components/tiles/line_tile"; import { NamedTypedPlace, StatVarSpec } from "../js/shared/types"; import { TileConfig } from "../js/types/subject_page_proto_types"; import { dataGroupsToCsv } from "../js/utils/chart_csv_utils"; import { getChartTitle } from "../js/utils/tile_utils"; -import { DOM_ID, SVG_HEIGHT, SVG_WIDTH } from "./constants"; +import { CHART_ID, DOM_ID, SVG_HEIGHT, SVG_WIDTH } from "./constants"; import { TileResult } from "./types"; -import { getProcessedSvg, getSources } from "./utils"; +import { getChartUrl, getProcessedSvg, getSources, getSvgXml } from "./utils"; + +function getTileProp( + id: string, + tileConfig: TileConfig, + place: NamedTypedPlace, + statVarSpec: StatVarSpec[], + apiRoot: string +): LineTilePropType { + return { + apiRoot, + id, + place, + statVarSpec, + svgChartHeight: SVG_HEIGHT, + svgChartWidth: SVG_WIDTH, + title: tileConfig.title, + }; +} + +function getLineChartSvg( + tileProp: LineTilePropType, + chartData: LineChartData +): SVGSVGElement { + const tileContainer = document.createElement("div"); + tileContainer.setAttribute("id", CHART_ID); + document.getElementById(DOM_ID).appendChild(tileContainer); + draw(tileProp, chartData, tileContainer); + return getProcessedSvg(tileContainer.querySelector("svg")); +} /** * Gets the Tile Result for a line tile @@ -44,35 +75,65 @@ export async function getLineTileResult( tileConfig: TileConfig, place: NamedTypedPlace, statVarSpec: StatVarSpec[], - apiRoot: string + apiRoot: string, + urlRoot: string, + useChartUrl: boolean ): Promise { - const tileProp = { - apiRoot, - id, - place, - statVarSpec, - svgChartHeight: SVG_HEIGHT, - svgChartWidth: SVG_WIDTH, - title: tileConfig.title, - }; + const tileProp = getTileProp(id, tileConfig, place, statVarSpec, apiRoot); try { const chartData = await fetchData(tileProp); - const tileContainer = document.createElement("div"); - tileContainer.setAttribute("id", id); - document.getElementById(DOM_ID).appendChild(tileContainer); - draw(tileProp, chartData, tileContainer); - const svg = getProcessedSvg(tileContainer.querySelector("svg")); - tileContainer.remove(); - return { - svg, + const result: TileResult = { data_csv: dataGroupsToCsv(chartData.dataGroup), srcs: getSources(chartData.sources), legend: chartData.dataGroup.map((dg) => dg.label || "A"), title: getChartTitle(tileConfig.title, getReplacementStrings(tileProp)), type: "LINE", }; + if (useChartUrl) { + result.chartUrl = getChartUrl( + tileConfig, + place.dcid, + statVarSpec, + "", + null, + urlRoot + ); + return result; + } + const svg = getLineChartSvg(tileProp, chartData); + result.svg = getSvgXml(svg); + return result; } catch (e) { console.log("Failed to get line tile result for: " + id); return null; } } + +/** + * Gets the line chart for a given tile config + * @param tileConfig the tile config for the line chart + * @param place the place to get the line chart for + * @param statVarSpec list of stat var specs to show in the chart + * @param apiRoot API root to use to fetch data + */ +export async function getLineChart( + tileConfig: TileConfig, + place: NamedTypedPlace, + statVarSpec: StatVarSpec[], + apiRoot: string +): Promise { + const tileProp = getTileProp( + CHART_ID, + tileConfig, + place, + statVarSpec, + apiRoot + ); + try { + const chartData = await fetchData(tileProp); + return getLineChartSvg(tileProp, chartData); + } catch (e) { + console.log("Failed to get line chart"); + return null; + } +} diff --git a/static/nodejs_server/map_tile.ts b/static/nodejs_server/map_tile.ts index ba0b31ed84..d7478547e5 100644 --- a/static/nodejs_server/map_tile.ts +++ b/static/nodejs_server/map_tile.ts @@ -15,7 +15,7 @@ */ /** - * Functions for getting tile result for a map tile + * Functions for getting results for a map tile */ // This import is unused in this file, but needed for draw functions @@ -27,14 +27,77 @@ import { draw, fetchData, getReplacementStrings, + MapChartData, + MapTilePropType, } from "../js/components/tiles/map_tile"; import { NamedTypedPlace, StatVarSpec } from "../js/shared/types"; import { TileConfig } from "../js/types/subject_page_proto_types"; import { mapDataToCsv } from "../js/utils/chart_csv_utils"; import { getChartTitle } from "../js/utils/tile_utils"; -import { MAP_LEGEND_CONSTANT_WIDTH, SVG_HEIGHT, SVG_WIDTH } from "./constants"; +import { + CHART_ID, + MAP_LEGEND_CONSTANT_WIDTH, + SVG_HEIGHT, + SVG_WIDTH, +} from "./constants"; import { TileResult } from "./types"; -import { getProcessedSvg, getSources } from "./utils"; +import { getChartUrl, getProcessedSvg, getSources, getSvgXml } from "./utils"; + +function getTileProp( + id: string, + tileConfig: TileConfig, + place: NamedTypedPlace, + enclosedPlaceType: string, + statVarSpec: StatVarSpec, + apiRoot: string +): MapTilePropType { + return { + id, + title: tileConfig.title, + place, + enclosedPlaceType, + statVarSpec, + svgChartHeight: SVG_HEIGHT - LEGEND_MARGIN_VERTICAL * 2, + apiRoot, + }; +} + +function getMapChartSvg( + tileProp: MapTilePropType, + chartData: MapChartData +): SVGSVGElement { + const legendContainer = document.createElement("div"); + const mapContainer = document.createElement("div"); + draw(chartData, tileProp, null, legendContainer, mapContainer, SVG_WIDTH); + // Get the width of the text in the legend + let legendTextWidth = 0; + Array.from(legendContainer.querySelectorAll("text")).forEach((node) => { + legendTextWidth = Math.max(node.getBBox().width, legendTextWidth); + }); + const legendWidth = legendTextWidth + MAP_LEGEND_CONSTANT_WIDTH; + // Create a single merged svg to hold both the map and the legend svgs + const mergedSvg = document.createElementNS( + "http://www.w3.org/2000/svg", + "svg" + ); + mergedSvg.setAttribute("height", String(SVG_HEIGHT)); + mergedSvg.setAttribute("width", String(SVG_WIDTH)); + // Get the map svg and add it to the merged svg + const mapSvg = mapContainer.querySelector("svg"); + const mapWidth = SVG_WIDTH - legendWidth; + mapSvg.setAttribute("width", String(mapWidth)); + const mapG = document.createElementNS("http://www.w3.org/2000/svg", "g"); + mapG.appendChild(mapSvg); + mergedSvg.appendChild(mapG); + // Get the legend svg and add it to the merged svg + const legendSvg = legendContainer.querySelector("svg"); + legendSvg.setAttribute("width", String(legendWidth)); + const legendG = document.createElementNS("http://www.w3.org/2000/svg", "g"); + legendG.setAttribute("transform", `translate(${mapWidth})`); + legendG.appendChild(legendSvg); + mergedSvg.appendChild(legendG); + return getProcessedSvg(mergedSvg); +} /** * Gets the Tile Result for a map tile @@ -51,52 +114,21 @@ export async function getMapTileResult( place: NamedTypedPlace, enclosedPlaceType: string, statVarSpec: StatVarSpec, - apiRoot: string + apiRoot: string, + urlRoot: string, + useChartUrl: boolean ): Promise { - const tileProp = { + const tileProp = getTileProp( id, - title: tileConfig.title, + tileConfig, place, enclosedPlaceType, statVarSpec, - svgChartHeight: SVG_HEIGHT - LEGEND_MARGIN_VERTICAL * 2, - apiRoot, - }; + apiRoot + ); try { const chartData = await fetchData(tileProp); - const legendContainer = document.createElement("div"); - const mapContainer = document.createElement("div"); - draw(chartData, tileProp, null, legendContainer, mapContainer, SVG_WIDTH); - // Get the width of the text in the legend - let legendTextWidth = 0; - Array.from(legendContainer.querySelectorAll("text")).forEach((node) => { - legendTextWidth = Math.max(node.getBBox().width, legendTextWidth); - }); - const legendWidth = legendTextWidth + MAP_LEGEND_CONSTANT_WIDTH; - // Create a single merged svg to hold both the map and the legend svgs - const mergedSvg = document.createElementNS( - "http://www.w3.org/2000/svg", - "svg" - ); - mergedSvg.setAttribute("height", String(SVG_HEIGHT)); - mergedSvg.setAttribute("width", String(SVG_WIDTH)); - // Get the map svg and add it to the merged svg - const mapSvg = mapContainer.querySelector("svg"); - const mapWidth = SVG_WIDTH - legendWidth; - mapSvg.setAttribute("width", String(mapWidth)); - const mapG = document.createElementNS("http://www.w3.org/2000/svg", "g"); - mapG.appendChild(mapSvg); - mergedSvg.appendChild(mapG); - // Get the legend svg and add it to the merged svg - const legendSvg = legendContainer.querySelector("svg"); - legendSvg.setAttribute("width", String(legendWidth)); - const legendG = document.createElementNS("http://www.w3.org/2000/svg", "g"); - legendG.setAttribute("transform", `translate(${mapWidth})`); - legendG.appendChild(legendSvg); - mergedSvg.appendChild(legendG); - - return { - svg: getProcessedSvg(mergedSvg), + const result: TileResult = { data_csv: mapDataToCsv(chartData.geoJson, chartData.dataValues), srcs: getSources(chartData.sources), title: getChartTitle( @@ -105,8 +137,54 @@ export async function getMapTileResult( ), type: "MAP", }; + if (useChartUrl) { + result.chartUrl = getChartUrl( + tileConfig, + place.dcid, + [statVarSpec], + enclosedPlaceType, + null, + urlRoot + ); + return result; + } + const svg = getMapChartSvg(tileProp, chartData); + result.svg = getSvgXml(svg); + return result; } catch (e) { console.log("Failed to get map tile result for: " + id); return null; } } + +/** + * Gets the map chart for a given tile config + * @param tileConfig the tile config for the chart + * @param place the place to get the chart for + * @param enclosedPlaceType the enclosed place type to get the chart for + * @param statVarSpec list of stat var specs to show in the chart + * @param apiRoot API root to use to fetch data + */ +export async function getMapChart( + tileConfig: TileConfig, + place: NamedTypedPlace, + enclosedPlaceType: string, + statVarSpec: StatVarSpec, + apiRoot: string +): Promise { + const tileProp = getTileProp( + CHART_ID, + tileConfig, + place, + enclosedPlaceType, + statVarSpec, + apiRoot + ); + try { + const chartData = await fetchData(tileProp); + return getMapChartSvg(tileProp, chartData); + } catch (e) { + console.log("Failed to get map chart."); + return null; + } +} diff --git a/static/nodejs_server/ranking_tile.ts b/static/nodejs_server/ranking_tile.ts index 09a8e4c34c..ecf5dcbae2 100644 --- a/static/nodejs_server/ranking_tile.ts +++ b/static/nodejs_server/ranking_tile.ts @@ -15,14 +15,17 @@ */ /** - * Functions for getting tile result for a ranking tile + * Functions for getting results for a ranking tile */ import _ from "lodash"; import React from "react"; import ReactDOMServer from "react-dom/server"; -import { fetchData } from "../js/components/tiles/ranking_tile"; +import { + fetchData, + RankingTilePropType, +} from "../js/components/tiles/ranking_tile"; import { getRankingUnit, getRankingUnitTitle, @@ -32,26 +35,47 @@ import { RankingGroup } from "../js/types/ranking_unit_types"; import { TileConfig } from "../js/types/subject_page_proto_types"; import { rankingPointsToCsv } from "../js/utils/chart_csv_utils"; import { htmlToSvg } from "../js/utils/svg_utils"; -import { FONT_FAMILY, FONT_SIZE, SVG_HEIGHT, SVG_WIDTH } from "./constants"; +import { + CHART_ID, + FONT_FAMILY, + FONT_SIZE, + SVG_HEIGHT, + SVG_WIDTH, +} from "./constants"; import { TileResult } from "./types"; -import { getProcessedSvg, getSources } from "./utils"; +import { getChartUrl, getProcessedSvg, getSources, getSvgXml } from "./utils"; -/** - * Get the result for a single ranking unit - */ -function getRankingUnitResult( +function getTileProp( + id: string, tileConfig: TileConfig, + place: NamedTypedPlace, + enclosedPlaceType: string, + statVarSpec: StatVarSpec[], + apiRoot: string +): RankingTilePropType { + return { + id, + title: tileConfig.title, + place, + enclosedPlaceType, + statVarSpec, + rankingMetadata: tileConfig.rankingTileSpec, + apiRoot, + }; +} + +function getRankingChartSvg( rankingGroup: RankingGroup, sv: string, - isHighest: boolean -): TileResult { + tileConfig: TileConfig +): SVGSVGElement { const rankingHtml = ReactDOMServer.renderToString( getRankingUnit( tileConfig.title, sv, rankingGroup, tileConfig.rankingTileSpec, - isHighest + tileConfig.rankingTileSpec.showHighest ) ); const style = { @@ -59,9 +83,24 @@ function getRankingUnitResult( "font-size": FONT_SIZE, }; const svg = htmlToSvg(rankingHtml, SVG_WIDTH, SVG_HEIGHT, "", style); - const processedSvg = getProcessedSvg(svg); - return { - svg: processedSvg, + return getProcessedSvg(svg); +} + +/** + * Get the result for a single ranking unit + */ +function getRankingUnitResult( + tileConfig: TileConfig, + rankingGroup: RankingGroup, + sv: string, + isHighest: boolean, + place: NamedTypedPlace, + enclosedPlaceType: string, + statVarSpec: StatVarSpec[], + urlRoot: string, + useChartUrl: boolean +): TileResult { + const result: TileResult = { data_csv: rankingPointsToCsv(rankingGroup.points, rankingGroup.svName), srcs: getSources(rankingGroup.sources), title: getRankingUnitTitle( @@ -73,6 +112,35 @@ function getRankingUnitResult( ), type: "TABLE", }; + + if (useChartUrl) { + // Get a tile config to pass in the chart url so that only one ranking unit + // will be created. i.e., only one of highest or lowest. + const urlTileConfig = _.cloneDeep(tileConfig); + urlTileConfig.rankingTileSpec = { + ...tileConfig.rankingTileSpec, + showHighest: isHighest, + showLowest: !isHighest, + }; + // Get a list of stat var specs so that only one ranking unit will be created. + // i.e., If the tile is a multi-column tile, use the entire list of stat var + // specs, otherwise, only use the spec for the current stat var. + const urlSvSpec = tileConfig.rankingTileSpec.showMultiColumn + ? statVarSpec + : statVarSpec.filter((spec) => spec.statVar === sv); + result.chartUrl = getChartUrl( + urlTileConfig, + place.dcid, + urlSvSpec, + enclosedPlaceType, + null, + urlRoot + ); + return result; + } + const svg = getRankingChartSvg(rankingGroup, sv, tileConfig); + result.svg = getSvgXml(svg); + return result; } /** @@ -90,17 +158,18 @@ export async function getRankingTileResult( place: NamedTypedPlace, enclosedPlaceType: string, statVarSpec: StatVarSpec[], - apiRoot: string + apiRoot: string, + urlRoot: string, + useChartUrl: boolean ): Promise { - const tileProp = { + const tileProp = getTileProp( id, - title: tileConfig.title, + tileConfig, place, enclosedPlaceType, statVarSpec, - rankingMetadata: tileConfig.rankingTileSpec, - apiRoot, - }; + apiRoot + ); try { const rankingData = await fetchData(tileProp); const tileResults: TileResult[] = []; @@ -108,12 +177,32 @@ export async function getRankingTileResult( const rankingGroup = rankingData[sv]; if (tileConfig.rankingTileSpec.showHighest) { tileResults.push( - getRankingUnitResult(tileConfig, rankingGroup, sv, true) + getRankingUnitResult( + tileConfig, + rankingGroup, + sv, + true, + place, + enclosedPlaceType, + statVarSpec, + urlRoot, + useChartUrl + ) ); } if (tileConfig.rankingTileSpec.showLowest) { tileResults.push( - getRankingUnitResult(tileConfig, rankingGroup, sv, false) + getRankingUnitResult( + tileConfig, + rankingGroup, + sv, + false, + place, + enclosedPlaceType, + statVarSpec, + urlRoot, + useChartUrl + ) ); } } @@ -123,3 +212,39 @@ export async function getRankingTileResult( return null; } } + +/** + * Gets the ranking chart for a given tile config. Assumes that the tile config + * is only going to create a single ranking unit. + * @param tileConfig the tile config for the chart + * @param place the place to get the chart for + * @param enclosedPlaceType the enclosed place type to get the chart for + * @param statVarSpec list of stat var specs to show in the chart + * @param apiRoot API root to use to fetch data + */ +export async function getRankingChart( + tileConfig: TileConfig, + place: NamedTypedPlace, + enclosedPlaceType: string, + statVarSpec: StatVarSpec[], + apiRoot: string +): Promise { + const tileProp = getTileProp( + CHART_ID, + tileConfig, + place, + enclosedPlaceType, + statVarSpec, + apiRoot + ); + try { + const rankingData = await fetchData(tileProp); + for (const sv of Object.keys(rankingData)) { + const rankingGroup = rankingData[sv]; + return getRankingChartSvg(rankingGroup, sv, tileConfig); + } + } catch (e) { + console.log("Failed to get ranking chart"); + return null; + } +} diff --git a/static/nodejs_server/scatter_tile.ts b/static/nodejs_server/scatter_tile.ts index 76c86d0344..02bd232bc2 100644 --- a/static/nodejs_server/scatter_tile.ts +++ b/static/nodejs_server/scatter_tile.ts @@ -15,7 +15,7 @@ */ /** - * Functions for getting tile result for a scatter tile + * Functions for getting results for the scatter tile */ // This import is unused in this file, but needed for draw functions @@ -25,14 +25,35 @@ import { draw, fetchData, getReplacementStrings, + ScatterTilePropType, } from "../js/components/tiles/scatter_tile"; import { NamedTypedPlace, StatVarSpec } from "../js/shared/types"; import { TileConfig } from "../js/types/subject_page_proto_types"; import { scatterDataToCsv } from "../js/utils/chart_csv_utils"; import { getChartTitle } from "../js/utils/tile_utils"; -import { SVG_HEIGHT, SVG_WIDTH } from "./constants"; +import { CHART_ID, SVG_HEIGHT, SVG_WIDTH } from "./constants"; import { TileResult } from "./types"; -import { getProcessedSvg, getSources } from "./utils"; +import { getChartUrl, getProcessedSvg, getSources, getSvgXml } from "./utils"; + +function getTileProp( + id: string, + tileConfig: TileConfig, + place: NamedTypedPlace, + enclosedPlaceType: string, + statVarSpec: StatVarSpec[], + apiRoot: string +): ScatterTilePropType { + return { + id, + title: tileConfig.title, + place, + enclosedPlaceType, + statVarSpec, + svgChartHeight: SVG_HEIGHT, + scatterTileSpec: tileConfig.scatterTileSpec, + apiRoot, + }; +} /** * Gets the Tile Result for a scatter tile @@ -49,32 +70,22 @@ export async function getScatterTileResult( place: NamedTypedPlace, enclosedPlaceType: string, statVarSpec: StatVarSpec[], - apiRoot: string + apiRoot: string, + urlRoot: string, + useChartUrl: boolean ): Promise { - const tileProp = { + const tileProp = getTileProp( id, - title: tileConfig.title, + tileConfig, place, enclosedPlaceType, statVarSpec, - svgChartHeight: SVG_HEIGHT, - scatterTileSpec: tileConfig.scatterTileSpec, - apiRoot, - }; + apiRoot + ); try { const chartData = await fetchData(tileProp); - const svgContainer = document.createElement("div"); - draw( - chartData, - svgContainer, - SVG_HEIGHT, - null /* tooltipHtml */, - tileConfig.scatterTileSpec, - SVG_WIDTH - ); - return { - svg: getProcessedSvg(svgContainer.querySelector("svg")), + const result: TileResult = { data_csv: scatterDataToCsv( chartData.xStatVar.statVar, chartData.xStatVar.denom, @@ -89,8 +100,72 @@ export async function getScatterTileResult( ), type: "SCATTER", }; + if (useChartUrl) { + result.chartUrl = getChartUrl( + tileConfig, + place.dcid, + statVarSpec, + enclosedPlaceType, + null, + urlRoot + ); + return result; + } + const svgContainer = document.createElement("div"); + draw( + chartData, + svgContainer, + SVG_HEIGHT, + null /* tooltipHtml */, + tileConfig.scatterTileSpec, + SVG_WIDTH + ); + const svg = getProcessedSvg(svgContainer.querySelector("svg")); + result.svg = getSvgXml(svg); + return result; } catch (e) { console.log("Failed to get scatter tile result for: " + id); return null; } } + +/** + * Gets the scatter chart for a given tile config + * @param tileConfig the tile config for the chart + * @param place the place to get the chart for + * @param enclosedPlaceType the enclosed place type to get the chart for + * @param statVarSpec list of stat var specs to show in the chart + * @param apiRoot API root to use to fetch data + */ +export async function getScatterChart( + tileConfig: TileConfig, + place: NamedTypedPlace, + enclosedPlaceType: string, + statVarSpec: StatVarSpec[], + apiRoot: string +): Promise { + const tileProp = getTileProp( + CHART_ID, + tileConfig, + place, + enclosedPlaceType, + statVarSpec, + apiRoot + ); + try { + const chartData = await fetchData(tileProp); + const svgContainer = document.createElement("div"); + draw( + chartData, + svgContainer, + SVG_HEIGHT, + null /* tooltipHtml */, + tileConfig.scatterTileSpec, + SVG_WIDTH + ); + return getProcessedSvg(svgContainer.querySelector("svg")); + } catch (e) { + console.log("Failed to get scatter chart."); + return null; + } +} diff --git a/static/nodejs_server/types.ts b/static/nodejs_server/types.ts index 550469bac7..19ac77d925 100644 --- a/static/nodejs_server/types.ts +++ b/static/nodejs_server/types.ts @@ -20,8 +20,6 @@ // The result for a single tile export interface TileResult { - // The svg for the chart in the tile as an xml string - svg: string; // List of sources of the data in the chart srcs: { name: string; url: string }[]; // The title of the tile @@ -32,4 +30,8 @@ export interface TileResult { legend?: string[]; // The data for the chart in the tile as a csv string data_csv?: string; + // The url to get the chart in the tile. One of chartUrl or svg should be set. + chartUrl?: string; + // The svg for the chart in the tile as an xml string. One of chartUrl or svg should be set. + svg?: string; } diff --git a/static/nodejs_server/utils.ts b/static/nodejs_server/utils.ts index de655857c3..147fb25f1a 100644 --- a/static/nodejs_server/utils.ts +++ b/static/nodejs_server/utils.ts @@ -20,8 +20,13 @@ import * as xmlserializer from "xmlserializer"; +import { StatVarSpec } from "../js/shared/types"; import { urlToDomain } from "../js/shared/util"; -import { FONT_FAMILY, FONT_SIZE } from "./constants"; +import { + EventTypeSpec, + TileConfig, +} from "../js/types/subject_page_proto_types"; +import { CHART_URL_PARAMS, FONT_FAMILY, FONT_SIZE } from "./constants"; /** * Gets a list of source objects with name and url from a set of source urls. @@ -39,12 +44,13 @@ export function getSources( } /** - * Processes and serializes a svg for a chart. + * Processes and serializes a svg for a chart and returns the image element for + * that chart. * @param chartSvg the svg element for the chart to process */ -export function getProcessedSvg(chartSvg: SVGSVGElement): string { +export function getProcessedSvg(chartSvg: SVGSVGElement): SVGSVGElement { if (!chartSvg) { - return ""; + return null; } // Set the font for all the text in the svg to match the font family and size // used for getBBox calculations. @@ -52,7 +58,57 @@ export function getProcessedSvg(chartSvg: SVGSVGElement): string { node.setAttribute("font-family", FONT_FAMILY); node.setAttribute("font-size", FONT_SIZE); }); - // Get and return the svg as an xml string + return chartSvg; +} + +/** + * Get the serialized xml string for an svg element + * @param chartSvg the svg element to get the xml for + */ +export function getSvgXml(chartSvg: SVGSVGElement): string { + if (!chartSvg) { + return ""; + } const svgXml = xmlserializer.serializeToString(chartSvg); return "data:image/svg+xml," + encodeURIComponent(svgXml); } + +/** + * Gets the url that will return a chart for a specific set of properties. + * @param tileConfig tile config of the chart + * @param placeDcid place to use for the chart + * @param statVarSpec list of stat var specs to use for the chart + * @param enclosedPlaceType enclosed place type to use for the chart + * @param eventTypeSpec map of event type to its event type spec to use for the + * chart + * @param urlRoot url root to use for the returned url + */ +export function getChartUrl( + tileConfig: TileConfig, + placeDcid: string, + statVarSpec: StatVarSpec[], + enclosedPlaceType: string, + eventTypeSpec: Record, + urlRoot: string +): string { + const paramMapping = { + [CHART_URL_PARAMS.EVENT_TYPE_SPEC]: JSON.stringify(eventTypeSpec), + [CHART_URL_PARAMS.PLACE]: placeDcid, + [CHART_URL_PARAMS.ENCLOSED_PLACE_TYPE]: enclosedPlaceType, + [CHART_URL_PARAMS.STAT_VAR_SPEC]: JSON.stringify(statVarSpec), + [CHART_URL_PARAMS.TILE_CONFIG]: JSON.stringify(tileConfig), + }; + let url = `${urlRoot}/nodejs/chart?`; + Object.keys(paramMapping) + .sort() + .forEach((paramKey, idx) => { + const paramVal = paramMapping[paramKey]; + if (!paramVal) { + return; + } + url += `${idx === 0 ? "" : "&"}${paramKey}=${paramVal}`; + }); + // manually escape the # because encodeURI will not escape it + url = url.replaceAll("#", "%23"); + return encodeURI(url); +} diff --git a/static/src/server.ts b/static/src/server.ts index dc5f88b79c..29337bf4db 100644 --- a/static/src/server.ts +++ b/static/src/server.ts @@ -19,20 +19,35 @@ import express, { Request, Response } from "express"; import { JSDOM } from "jsdom"; import _ from "lodash"; -import { fetchDisasterEventData } from "../js/components/subject_page/disaster_event_block"; +import { + fetchDisasterEventData, + getBlockEventTypeSpecs, +} from "../js/components/subject_page/disaster_event_block"; import { NamedTypedPlace, StatVarSpec } from "../js/shared/types"; import { BlockConfig, EventTypeSpec, + TileConfig, } from "../js/types/subject_page_proto_types"; import { getTileEventTypeSpecs } from "../js/utils/tile_utils"; -import { getBarTileResult } from "../nodejs_server/bar_tile"; -import { getDisasterMapTileResult } from "../nodejs_server/disaster_map_tile"; -import { getLineTileResult } from "../nodejs_server/line_tile"; -import { getMapTileResult } from "../nodejs_server/map_tile"; -import { getRankingTileResult } from "../nodejs_server/ranking_tile"; -import { getScatterTileResult } from "../nodejs_server/scatter_tile"; +import { getBarChart, getBarTileResult } from "../nodejs_server/bar_tile"; +import { CHART_URL_PARAMS } from "../nodejs_server/constants"; +import { + getDisasterMapChart, + getDisasterMapTileResult, +} from "../nodejs_server/disaster_map_tile"; +import { getLineChart, getLineTileResult } from "../nodejs_server/line_tile"; +import { getMapChart, getMapTileResult } from "../nodejs_server/map_tile"; +import { + getRankingChart, + getRankingTileResult, +} from "../nodejs_server/ranking_tile"; +import { + getScatterChart, + getScatterTileResult, +} from "../nodejs_server/scatter_tile"; import { TileResult } from "../nodejs_server/types"; +import { getSvgXml } from "../nodejs_server/utils"; const app = express(); const APP_CONFIGS = { local: { @@ -151,7 +166,9 @@ function getBlockTileResults( block: BlockConfig, place: NamedTypedPlace, enclosedPlaceType: string, - svSpec: Record + svSpec: Record, + urlRoot: string, + useChartUrl: boolean ): Promise[] { const tilePromises = []; block.columns.forEach((column, colIdx) => { @@ -162,7 +179,15 @@ function getBlockTileResults( case "LINE": tileSvSpec = tile.statVarKey.map((s) => svSpec[s]); tilePromises.push( - getLineTileResult(tileId, tile, place, tileSvSpec, CONFIG.apiRoot) + getLineTileResult( + tileId, + tile, + place, + tileSvSpec, + CONFIG.apiRoot, + urlRoot, + useChartUrl + ) ); break; case "SCATTER": @@ -174,7 +199,9 @@ function getBlockTileResults( place, enclosedPlaceType, tileSvSpec, - CONFIG.apiRoot + CONFIG.apiRoot, + urlRoot, + useChartUrl ) ); break; @@ -187,7 +214,9 @@ function getBlockTileResults( place, enclosedPlaceType, tileSvSpec, - CONFIG.apiRoot + CONFIG.apiRoot, + urlRoot, + useChartUrl ) ); break; @@ -200,7 +229,9 @@ function getBlockTileResults( place, enclosedPlaceType, tileSvSpec, - CONFIG.apiRoot + CONFIG.apiRoot, + urlRoot, + useChartUrl ) ); break; @@ -213,7 +244,9 @@ function getBlockTileResults( place, enclosedPlaceType, tileSvSpec, - CONFIG.apiRoot + CONFIG.apiRoot, + urlRoot, + useChartUrl ) ); break; @@ -231,20 +264,21 @@ function getDisasterBlockTileResults( block: BlockConfig, place: NamedTypedPlace, enclosedPlaceType: string, - eventTypeSpec: Record + eventTypeSpec: Record, + urlRoot: string, + useChartUrl: boolean ): Promise[] { - const blockProp = { - id, - place, - enclosedPlaceType, - title: block.title, - description: block.description, - footnote: block.footnote, - columns: block.columns, + const blockEventTypeSpec = getBlockEventTypeSpecs( eventTypeSpec, - apiRoot: CONFIG.apiRoot, - }; - const disasterEventDataPromise = fetchDisasterEventData(blockProp); + block.columns + ); + const disasterEventDataPromise = fetchDisasterEventData( + id, + blockEventTypeSpec, + place.dcid, + null, + CONFIG.apiRoot + ); const tilePromises = []; block.columns.forEach((column, colIdx) => { column.tiles.forEach((tile, tileIdx) => { @@ -260,7 +294,9 @@ function getDisasterBlockTileResults( enclosedPlaceType, tileEventTypeSpec, disasterEventDataPromise, - CONFIG.apiRoot + CONFIG.apiRoot, + urlRoot, + useChartUrl ) ); default: @@ -271,6 +307,72 @@ function getDisasterBlockTileResults( return tilePromises; } +// Get the chart html for a tile +function getTileChart( + tileConfig: TileConfig, + placeDcid: string, + childPlaceType: string, + svSpec: StatVarSpec[], + eventTypeSpec: Record +): Promise { + // The name and types of a place are not used when drawing charts, so just + // set default values for them. + const place = { + dcid: placeDcid, + name: placeDcid, + types: [], + }; + switch (tileConfig.type) { + case "LINE": + return getLineChart(tileConfig, place, svSpec, CONFIG.apiRoot); + case "BAR": + return getBarChart( + tileConfig, + place, + childPlaceType, + svSpec, + CONFIG.apiRoot + ); + case "MAP": + return getMapChart( + tileConfig, + place, + childPlaceType, + svSpec.length ? svSpec[0] : null, + CONFIG.apiRoot + ); + case "SCATTER": + return getScatterChart( + tileConfig, + place, + childPlaceType, + svSpec, + CONFIG.apiRoot + ); + case "RANKING": + return getRankingChart( + tileConfig, + place, + childPlaceType, + svSpec, + CONFIG.apiRoot + ); + case "DISASTER_EVENT_MAP": + return getDisasterMapChart( + tileConfig, + place, + childPlaceType, + eventTypeSpec, + CONFIG.apiRoot + ); + default: + console.log( + `Chart of type ${_.escape(tileConfig.type)} is not supported.` + ); + return Promise.resolve(null); + } +} + // Get the elapsed time in seconds given the start and end times in nanoseconds. function getElapsedTime(startTime: bigint, endTime: bigint): number { // Dividing bigints will cause decimals to be lost. Therefore, convert ns to @@ -288,6 +390,8 @@ app.disable("etag"); app.get("/nodejs/query", (req: Request, res: Response) => { const startTime = process.hrtime.bigint(); const query = req.query.q; + const useChartUrl = !!req.query.chartUrl; + const urlRoot = `${req.protocol}://${req.get("host")}`; res.setHeader("Content-Type", "application/json"); axios .post(`${CONFIG.apiRoot}/api/nl/data?q=${query}&detector=heuristic`, {}) @@ -335,7 +439,9 @@ app.get("/nodejs/query", (req: Request, res: Response) => { block, place, enclosedPlaceType, - config["metadata"]["eventTypeSpec"] + config["metadata"]["eventTypeSpec"], + urlRoot, + useChartUrl ); break; default: @@ -344,7 +450,9 @@ app.get("/nodejs/query", (req: Request, res: Response) => { block, place, enclosedPlaceType, - svSpec + svSpec, + urlRoot, + useChartUrl ); } tilePromises.push(...blockTilePromises); @@ -385,6 +493,34 @@ app.get("/nodejs/query", (req: Request, res: Response) => { }); }); +app.get("/nodejs/chart", (req: Request, res: Response) => { + const place = _.escape(req.query[CHART_URL_PARAMS.PLACE] as string); + const enclosedPlaceType = _.escape( + req.query[CHART_URL_PARAMS.ENCLOSED_PLACE_TYPE] as string + ); + const svSpec = JSON.parse( + req.query[CHART_URL_PARAMS.STAT_VAR_SPEC] as string + ); + // Need to convert encoded # back to #. + const eventTypeSpecVal = ( + req.query[CHART_URL_PARAMS.EVENT_TYPE_SPEC] as string + ).replaceAll("%23", "#"); + const eventTypeSpec = JSON.parse(eventTypeSpecVal); + const tileConfig = JSON.parse( + req.query[CHART_URL_PARAMS.TILE_CONFIG] as string + ); + res.setHeader("Content-Type", "text/html"); + getTileChart(tileConfig, place, enclosedPlaceType, svSpec, eventTypeSpec) + .then((chart) => { + const img = document.createElement("img"); + img.src = getSvgXml(chart); + res.status(200).send(img.outerHTML); + }) + .catch(() => { + res.status(500).send("Error retrieving chart."); + }); +}); + app.get("/nodejs/healthz", (_, res: Response) => { res.status(200).send("Node Server Ready"); }); diff --git a/web_app.py b/web_app.py index 5617fd550c..499decaa06 100644 --- a/web_app.py +++ b/web_app.py @@ -37,7 +37,7 @@ WARM_UP_ENDPOINTS = [ "/api/choropleth/geojson?placeDcid=country/USA&placeType=County", "/api/choropleth/geojson?placeDcid=Earth&placeType=Country", - "/api/place/parent/country/USA", + "/api/place/parent?dcid=country/USA", "/api/place/descendent/name?dcid=country/USA&descendentType=County", ]