Skip to content

Commit 71a7a54

Browse files
authored
[Vis Tools] Define standardized vis tools in Flask and allow customization (#6066)
## Issue [b/489513837](https://buganizer.corp.google.com/issues/489513837) ## Description Pre-standardized versions of the vis tools allowed for customization of the examples. The default examples (for the main site) were provided in Flask as JSON, and these could be overridden by duplicating them into a particular custom_dc directory and editing them. While this functionality still exists, it is not applied to the (very different) examples given by the new standardizecd tools. These were provided directly in the TS, and were not easily customizable. This PR moves the definitions of examples out of TypeScript and into Flask. We have a few different ways that we transfer information from Flask to the frontend. In this case, we opted to use the same method as we used for the old tools, in order to keep the process of customizing these nearly identical to what it was before. ## Details In `server/template/tools`, the same directory where the existing default examples for older tools was provided, we now supply files for each of the three tool types: `scatter_vis_tool_examples.json`, `map_vis_tool_examples.json` and `timeline_vis_tool_examples.json`. These three files populate the primary DC website and also serve as the main examples. The files are JSON, in the following format: ``` [ { "id": "unique_id_1", "titleMessageId": "optional id for i18n, does not need to be provided if a title is given. This will be translated by the frontend, and will not generally be used by custom DCs", "title": "Title to be used in lieu of a translatable message ID. This will be used by custom DCs. "url": "/tools/timeline#{...url to chart...}" }, ... subsequent examples ``` As described in the example above, you only need to give either a titleMessageId (for i18n conversion) or a title, which will be used directly. Custom DCs will almost certainly only use the `title`. ### Process for customizing The process for a custom DC to customize these examples is very similar to what it was for the old tools. You take the example file from the root `server/template/tools` directory, and copy it into `server/custom_dc/{custom dc name}`. You would then edit the file, giving the titles and URLs that you want for the examples. The frontend will then pick those up. As noted above, a custom DC is very unlikely to use a titleMessageId, because they won't be using the i18n library. ## Testing If you do not provide a custom example set, the site should look identical to how it did before. In other words the following URL should look the same on both master and locally: https://datacommons.org/tools/map http://localhost:8080/tools/map However, if you either edit the default examples, or load a custom DC and create an override for that custom DC as above, you will see those examples, rather than the default ones. A note that if you are running a custom DC, you will want to turn the standardized vis tool flag on in order to see the changes. ## Screenshots ### Before <img width="1294" height="669" alt="Screenshot 2026-03-06 at 8 35 19 AM" src="https://github.com/user-attachments/assets/7e531088-0f24-4a79-acb9-4c1e56289071" /> ### After <img width="1681" height="679" alt="Screenshot 2026-03-06 at 8 37 05 AM" src="https://github.com/user-attachments/assets/3f5771cb-9776-4cfa-a6cb-8c173a181a36" />
1 parent 6439ade commit 71a7a54

12 files changed

Lines changed: 212 additions & 193 deletions

File tree

server/routes/tools/html.py

Lines changed: 73 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,42 @@ def get_example_file(tool):
3838
'templates/tools/{}_examples.json'.format(tool))
3939

4040

41+
def load_example_file(tool_or_filename, default=None):
42+
"""Loads a JSON example file, returning a default if missing."""
43+
if default is None:
44+
default = {}
45+
filepath = get_example_file(tool_or_filename)
46+
if os.path.exists(filepath):
47+
try:
48+
with open(filepath) as f:
49+
return json.load(f)
50+
except json.JSONDecodeError:
51+
current_app.logger.error('Malformed JSON in %s', filepath)
52+
return default
53+
54+
55+
def _get_vis_tool_examples(tool_name):
56+
"""Returns (info_json, vis_tool_examples_json, use_standardized_ui)"""
57+
use_standardized_ui = is_feature_enabled(STANDARDIZED_VIS_TOOL_FEATURE_FLAG,
58+
request=request)
59+
if use_standardized_ui:
60+
return {}, load_example_file(f'{tool_name}_vis_tool', default=[]), True
61+
return load_example_file(tool_name, default={}), [], False
62+
63+
4164
@bp.route('/timeline')
4265
def timeline():
43-
with open(get_example_file('timeline')) as f:
44-
info_json = json.load(f)
45-
return flask.render_template(
46-
'tools/timeline.html',
47-
info_json=info_json,
48-
use_standardized_ui=is_feature_enabled(
49-
STANDARDIZED_VIS_TOOL_FEATURE_FLAG, request=request),
50-
maps_api_key=current_app.config['MAPS_API_KEY'],
51-
sample_questions=json.dumps(
52-
current_app.config.get('HOMEPAGE_SAMPLE_QUESTIONS', [])))
66+
info_json, vis_tool_examples_json, use_standardized_ui = _get_vis_tool_examples(
67+
'timeline')
68+
69+
return flask.render_template('tools/timeline.html',
70+
info_json=info_json,
71+
vis_tool_examples_json=vis_tool_examples_json,
72+
use_standardized_ui=use_standardized_ui,
73+
maps_api_key=current_app.config['MAPS_API_KEY'],
74+
sample_questions=json.dumps(
75+
current_app.config.get(
76+
'HOMEPAGE_SAMPLE_QUESTIONS', [])))
5377

5478

5579
# This tool was used by several data science course (but no traffic in 2025).
@@ -60,30 +84,32 @@ def timeline_bulk_download():
6084

6185
@bp.route('/map')
6286
def map():
63-
with open(get_example_file('map')) as f:
64-
info_json = json.load(f)
65-
return flask.render_template(
66-
'tools/map.html',
67-
maps_api_key=current_app.config['MAPS_API_KEY'],
68-
info_json=info_json,
69-
use_standardized_ui=is_feature_enabled(
70-
STANDARDIZED_VIS_TOOL_FEATURE_FLAG, request=request),
71-
sample_questions=json.dumps(
72-
current_app.config.get('HOMEPAGE_SAMPLE_QUESTIONS', [])))
87+
info_json, vis_tool_examples_json, use_standardized_ui = _get_vis_tool_examples(
88+
'map')
89+
90+
return flask.render_template('tools/map.html',
91+
maps_api_key=current_app.config['MAPS_API_KEY'],
92+
info_json=info_json,
93+
vis_tool_examples_json=vis_tool_examples_json,
94+
use_standardized_ui=use_standardized_ui,
95+
sample_questions=json.dumps(
96+
current_app.config.get(
97+
'HOMEPAGE_SAMPLE_QUESTIONS', [])))
7398

7499

75100
@bp.route('/scatter')
76101
def scatter():
77-
with open(get_example_file('scatter')) as f:
78-
info_json = json.load(f)
79-
return flask.render_template(
80-
'tools/scatter.html',
81-
info_json=info_json,
82-
use_standardized_ui=is_feature_enabled(
83-
STANDARDIZED_VIS_TOOL_FEATURE_FLAG, request=request),
84-
maps_api_key=current_app.config['MAPS_API_KEY'],
85-
sample_questions=json.dumps(
86-
current_app.config.get('HOMEPAGE_SAMPLE_QUESTIONS', [])))
102+
info_json, vis_tool_examples_json, use_standardized_ui = _get_vis_tool_examples(
103+
'scatter')
104+
105+
return flask.render_template('tools/scatter.html',
106+
info_json=info_json,
107+
vis_tool_examples_json=vis_tool_examples_json,
108+
use_standardized_ui=use_standardized_ui,
109+
maps_api_key=current_app.config['MAPS_API_KEY'],
110+
sample_questions=json.dumps(
111+
current_app.config.get(
112+
'HOMEPAGE_SAMPLE_QUESTIONS', [])))
87113

88114

89115
@bp.route('/statvar')
@@ -98,24 +124,24 @@ def stat_var():
98124
def download():
99125
# List of DCIDs displayed in the info page for download tool
100126
# NOTE: EXACTLY 2 EXAMPLES REQUIRED.
101-
with open(get_example_file('download')) as f:
102-
info_places = json.load(f)
103-
return flask.render_template(
104-
'tools/download.html',
105-
info_places=json.dumps(info_places),
106-
maps_api_key=current_app.config['MAPS_API_KEY'],
107-
sample_questions=json.dumps(
108-
current_app.config.get('HOMEPAGE_SAMPLE_QUESTIONS', [])))
127+
info_places = load_example_file('download', default=[])
128+
129+
return flask.render_template('tools/download.html',
130+
info_places=json.dumps(info_places),
131+
maps_api_key=current_app.config['MAPS_API_KEY'],
132+
sample_questions=json.dumps(
133+
current_app.config.get(
134+
'HOMEPAGE_SAMPLE_QUESTIONS', [])))
109135

110136

111137
@bp.route('/visualization')
112138
def visualization():
113-
with open(get_example_file('visualization')) as f:
114-
info_json = json.load(f)
115-
return flask.render_template(
116-
'tools/visualization.html',
117-
manual_ga_pageview=True,
118-
info_json=info_json,
119-
maps_api_key=current_app.config['MAPS_API_KEY'],
120-
sample_questions=json.dumps(
121-
current_app.config.get('HOMEPAGE_SAMPLE_QUESTIONS', [])))
139+
info_json = load_example_file('visualization', default={})
140+
141+
return flask.render_template('tools/visualization.html',
142+
manual_ga_pageview=True,
143+
info_json=info_json,
144+
maps_api_key=current_app.config['MAPS_API_KEY'],
145+
sample_questions=json.dumps(
146+
current_app.config.get(
147+
'HOMEPAGE_SAMPLE_QUESTIONS', [])))

server/templates/tools/map.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
<div id="metadata" class="d-none" data-footer="{{ config['MAP_TOOL_FOOTER'] }}"></div>
3333
<div id="main-pane"></div>
3434
<script>
35-
infoConfig = {{info_json|tojson|safe}};
35+
globalThis.infoConfig = {{ info_json | tojson | safe }};
36+
globalThis.visToolExamples = {{ vis_tool_examples_json | default([]) | tojson | safe }};
3637
globalThis.minStatVarGeoCoverage = {{ config['MIN_STAT_VAR_GEO_COVERAGE'] | int(1) }};
3738
</script>
3839
{% endblock %}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[
2+
{
3+
"id": "map_water_withdrawal_rate_in_the_usa",
4+
"titleMessageId": "waterWithdrawalRateInUsa",
5+
"url": "/tools/map#%26sv%3DWithdrawalRate_Water%26pc%3D0%26denom%3DCount_Person%26pd%3Dcountry%2FUSA%26ept%3DCounty"
6+
},
7+
{
8+
"id": "map_unemployment_rate_in_new_jersey",
9+
"titleMessageId": "unemploymentRateInNewJersey",
10+
"url": "/tools/map#%26sv%3DUnemploymentRate_Person%26pc%3D0%26denom%3DCount_Person%26pd%3DgeoId%2F34%26ept%3DCounty"
11+
},
12+
{
13+
"id": "map_median_income_in_texas",
14+
"titleMessageId": "medianIncomeInTexas",
15+
"url": "/tools/map#%26sv%3DMedian_Income_Person%26pc%3D0%26denom%3DCount_Person%26pd%3DgeoId%2F48%26ept%3DCounty"
16+
},
17+
{
18+
"id": "map_attainment_of_bachelor_degree_in_colorado",
19+
"titleMessageId": "attainmentOfBachelorDegreeInColorado",
20+
"url": "/tools/map#%26sv%3DCount_Person_EducationalAttainmentBachelorsDegreeOrHigher%26pc%3D1%26denom%3DCount_Person%26pd%3DgeoId%2F08%26ept%3DCounty"
21+
},
22+
{
23+
"id": "map_projected_temperature_rise_in_the_usa",
24+
"titleMessageId": "projectedTemperatureRiseInUsa",
25+
"url": "/tools/map#%26sv%3DDifferenceRelativeToBaseDate2006_Max_Temperature_RCP45%26pc%3D0%26denom%3DCount_Person%26pd%3Dcountry%2FUSA%26ept%3DCounty"
26+
},
27+
{
28+
"id": "map_median_age_in_usa",
29+
"titleMessageId": "medianAgeInUsa",
30+
"url": "/tools/map#%26sv%3DMedian_Age_Person%26pc%3D0%26denom%3DCount_Person%26pd%3Dcountry%2FUSA%26ept%3DCounty"
31+
},
32+
{
33+
"id": "map_no_schooling_completed_in_usa",
34+
"titleMessageId": "noSchoolingCompletedInUsa",
35+
"url": "/tools/map#%26sv%3DCount_Person_EducationalAttainmentNoSchoolingCompleted%26pc%3D0%26denom%3DCount_Person%26pd%3Dcountry%2FUSA%26ept%3DCounty"
36+
}
37+
]

server/templates/tools/scatter.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
<div id="metadata" class="d-none"></div>
3232
<div id="main-pane"></div>
3333
<script>
34-
infoConfig = {{info_json|tojson|safe}};
34+
globalThis.infoConfig = {{ info_json | tojson | safe }};
35+
globalThis.visToolExamples = {{ vis_tool_examples_json | default([]) | tojson | safe }};
3536
globalThis.minStatVarGeoCoverage = {{ config['MIN_STAT_VAR_GEO_COVERAGE'] | int(1) }};
3637
</script>
3738
{% endblock %}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[
2+
{
3+
"id": "scatter_prevalence_of_coronary_heart_disease_vs_projected_temperature_rise_in_the_usa",
4+
"titleMessageId": "coronaryHeartDiseaseVsProjectedTemperatureRise",
5+
"url": "/tools/scatter#%26svx%3DDifferenceRelativeToBaseDate2006_Max_Temperature_RCP45%26dx%3DCount_Person%26svy%3DPercent_Person_WithCoronaryHeartDisease%26dy%3DCount_Person%26epd%3Dcountry%2FUSA%26ept%3DCounty%26qd%3D1%26dd%3D1%26pp%3D"
6+
},
7+
{
8+
"id": "scatter_literate_population_per_capita_vs_population_below_poverty_level_per_capita_for_states_in_india",
9+
"titleMessageId": "literatePopulationVsPopulationBelowPovertyLevel",
10+
"url": "/tools/scatter#svx%3DCount_Person_BelowPovertyLevelInThePast12Months_AsFractionOf_Count_Person%26dx%3DCount_Person%26svy%3DCount_Person_Literate%26pcy%3D1%26dy%3DCount_Person%26epd%3Dcountry%2FIND%26ept%3DAdministrativeArea1"
11+
}
12+
]

server/templates/tools/timeline.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
<div id="metadata" class="d-none"></div>
3232
<div id="main-pane"></div>
3333
<script>
34-
infoConfig = {{info_json|tojson|safe}};
34+
globalThis.infoConfig = {{ info_json | tojson | safe }};
35+
globalThis.visToolExamples = {{ vis_tool_examples_json | default([]) | tojson | safe }};
3536
</script>
3637
{% endblock %}
3738

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[
2+
{
3+
"id": "timeline_water_withdrawal_rate_in_california",
4+
"titleMessageId": "waterWithdrawalRateInCalifornia",
5+
"url": "/tools/timeline#place=geoId%2F06&statsVar=WithdrawalRate_Water_Thermoelectric__WithdrawalRate_Water_PublicSupply__WithdrawalRate_Water_Irrigation__WithdrawalRate_Water_Aquaculture"
6+
},
7+
{
8+
"id": "timeline_university_towns_by_income",
9+
"titleMessageId": "universityTownsByIncome",
10+
"url": "/tools/timeline#&place=geoId/0606000,geoId/2511000,geoId/2603000,geoId/1777005,geoId/1225175,geoId/4815976&statsVar=Median_Income_Person"
11+
},
12+
{
13+
"id": "timeline_close_but_different_berkeley_and_piedmont",
14+
"titleMessageId": "closeButDifferentBerkeleyAndPiedmont",
15+
"url": "/tools/timeline#place=geoId%2F0606000%2CgeoId%2F0656938&statsVar=Median_Income_Person__Percent_Person_18OrMoreYears_WithPoorGeneralHealth__Monthly_Median_GrossRent_HousingUnit__Count_CriminalActivities_CombinedCrime&chart=%7B%22count-none%22%3A%7B%22pc%22%3Afalse%2C%22delta%22%3Afalse%7D%2C%22age-none%22%3A%7B%22pc%22%3Afalse%2C%22delta%22%3Afalse%7D%2C%22grossRent-none%22%3A%7B%22pc%22%3Afalse%2C%22delta%22%3Afalse%7D%2C%22unemploymentRate-none%22%3A%7B%22pc%22%3Afalse%2C%22delta%22%3Afalse%7D%7D"
16+
},
17+
{
18+
"id": "timeline_close_but_different_palo_alto_and_east_palo_alto",
19+
"titleMessageId": "closeButDifferentPaloAltoAndEastPaloAlto",
20+
"url": "/tools/timeline#place=geoId%2F0655282%2CgeoId%2F0620956&statsVar=Median_Income_Person__UnemploymentRate_Person__Count_Person_HispanicOrLatino__Count_Person_AsianAlone__Count_Person_BlackOrAfricanAmericanAlone__Count_Person_WhiteAlone__Percent_Person_18OrMoreYears_WithPoorGeneralHealth__Monthly_Median_GrossRent_HousingUnit&chart=%7B%22count-none%22%3A%7B%22pc%22%3Afalse%2C%22delta%22%3Afalse%7D%2C%22age-none%22%3A%7B%22pc%22%3Afalse%2C%22delta%22%3Afalse%7D%2C%22grossRent-none%22%3A%7B%22pc%22%3Afalse%2C%22delta%22%3Afalse%7D%7D"
21+
}
22+
]

static/js/tools/map/app.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function App(): ReactElement {
6666
statVar.value,
6767
placeInfo.value
6868
);
69+
const visToolExamples = globalThis.visToolExamples || [];
6970

7071
return (
7172
<React.StrictMode>
@@ -99,7 +100,10 @@ function App(): ReactElement {
99100
margin-top: ${theme.spacing.xl}px;
100101
`}
101102
>
102-
<ChartLinkChips toolType="map" />
103+
<ChartLinkChips
104+
toolType="map"
105+
visToolExamples={visToolExamples}
106+
/>
103107
</div>
104108
)
105109
) : (

static/js/tools/scatter/app.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ function App(): ReactElement {
6666
STANDARDIZED_VIS_TOOL_FEATURE_FLAG
6767
);
6868
const theme = useTheme();
69+
const visToolExamples = globalThis.visToolExamples || [];
70+
6971
return (
7072
<>
7173
<StatVarChooser
@@ -119,7 +121,10 @@ function App(): ReactElement {
119121
margin-top: ${theme.spacing.xl}px;
120122
`}
121123
>
122-
<ChartLinkChips toolType="scatter" />
124+
<ChartLinkChips
125+
toolType="scatter"
126+
visToolExamples={visToolExamples}
127+
/>
123128
</Row>
124129
)
125130
) : (

static/js/tools/shared/vis_tools/chart_link_chips.tsx

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,58 @@ import React from "react";
2323
import { LinkChips } from "../../../components/content/link_chips";
2424
import { Link } from "../../../components/elements/link_chip";
2525
import { intl } from "../../../i18n/i18n";
26-
import { toolMessages } from "../../../i18n/i18n_tool_messages";
27-
import { landingPageLinks } from "./landing_page_example_links";
26+
import {
27+
toolMessages,
28+
VisToolExampleChartMessages,
29+
} from "../../../i18n/i18n_tool_messages";
30+
31+
declare global {
32+
interface Window {
33+
visToolExamples?: VisToolExample[];
34+
}
35+
}
36+
37+
export interface VisToolExample {
38+
id: string;
39+
title?: string;
40+
titleMessageId?: string;
41+
url: string;
42+
}
2843

2944
interface ChartLinkChipsProps {
3045
toolType: "map" | "scatter" | "timeline";
46+
visToolExamples: VisToolExample[];
3147
}
3248

33-
/** Gets the set of link chips for the correct tool from the config */
34-
function getLinkChips(props: ChartLinkChipsProps): Link[] {
35-
switch (props.toolType) {
36-
case "map": {
37-
return landingPageLinks.mapLinks;
38-
}
39-
case "scatter": {
40-
return landingPageLinks.scatterLinks;
49+
function getLinkChips(config: VisToolExample[]): Link[] {
50+
if (!config || !Array.isArray(config)) {
51+
return [];
52+
}
53+
54+
const links: Link[] = [];
55+
56+
for (const item of config) {
57+
let finalTitle = item.title;
58+
59+
const messageKey =
60+
item.titleMessageId as keyof typeof VisToolExampleChartMessages;
61+
if (!finalTitle && messageKey && VisToolExampleChartMessages[messageKey]) {
62+
finalTitle = intl.formatMessage(VisToolExampleChartMessages[messageKey]);
4163
}
42-
default: {
43-
return landingPageLinks.timelineLinks;
64+
65+
// if a given example does not contain an id or final title, we omit
66+
if (!finalTitle || !item.id) {
67+
continue;
4468
}
69+
70+
links.push({
71+
id: item.id,
72+
title: finalTitle,
73+
url: item.url,
74+
});
4575
}
76+
77+
return links;
4678
}
4779

4880
export function ChartLinkChips(props: ChartLinkChipsProps): JSX.Element {
@@ -52,7 +84,7 @@ export function ChartLinkChips(props: ChartLinkChipsProps): JSX.Element {
5284
header={intl.formatMessage(toolMessages.ExamplesHeader)}
5385
headerComponent="h4"
5486
section={`${props.toolType}_tool_example_charts`}
55-
linkChips={getLinkChips(props)}
87+
linkChips={getLinkChips(props.visToolExamples)}
5688
chipTextSize="sm"
5789
/>
5890
);

0 commit comments

Comments
 (0)