Repository navigation
Expand file tree
/
Copy pathgridsome.server.js
More file actions
343 lines (298 loc) · 11.6 KB
/
Copy pathgridsome.server.js
File metadata and controls
343 lines (298 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
// Server API makes it possible to hook into various parts of Gridsome
// on server-side and add custom data to the GraphQL data layer.
// Learn more: https://gridsome.org/docs/server-api/
// Changes here require a server restart.
// To restart press CTRL + C in terminal and run `gridsome develop`
/**
* From fetching CSV data:
* https://gridsome.org/docs/fetching-data/#csv
*/
const { readFileSync, copyFileSync } = require('fs');
const build = require('gridsome/lib/build');
const parse = require('csv-parse/sync').parse;
const pageSocialConfigsData = require('./src/constants/page-social-images/page-social-configs.json');
const buildingOwnersData = require('./src/constants/building-owners.json');
const propertyTypesData = require('./src/data/dist/property-types.json');
const { slugifyPropertyType } = require('./src/constants/property-type-helpers.js');
const DataDirectory = './src/data/dist/';
// The search autocomplete index is generated by the data pipeline (see
// src/data/scripts/generate_search_index.py) and committed to src/data/dist.
// We copy it into static/ (served at the site root) at build time so the
// autocomplete can fetch it on demand rather than bundling ~3.5k records into
// the initial page JS. The static/ copy is gitignored since it's generated.
const StaticDirectory = './static/';
const SearchIndexFile = 'building-search-index.csv';
const BuildingEmissionsDataFile = 'building-benchmarks.csv';
const HistoricBenchmarkingDataFile = 'benchmarking-all-years.csv';
const WardStatsDataFile = 'ward-stats.csv';
// Get building owner IDs from the centralized JSON file
const BuildingOwnerIds = Object.keys(buildingOwnersData);
module.exports = function (api) {
// Use the Data Store API here: https://gridsome.org/docs/data-store-api/
api.loadSource(async (actions) => {
loadBuildingBenchmarkData(actions);
loadHistoricBenchmarkDat(actions);
loadWardStatsData(actions);
});
/**
* Use the Pages API to create custom templates for building owners and wards
* @see https://gridsome.org/docs/pages-api
*/
api.createPages(({ createPage }) => {
// Create pages for building owners. This could be a dynamic route, but making it this way
// should let them get statically built as expected
BuildingOwnerIds.forEach((ownerId) => {
createPage({
path: `/owner/${ownerId}`,
component: './src/templates/BuildingOwner.vue',
context: { ownerId },
});
});
// Create pages for each of Chicago's 50 Wards
const ChicagoWardCount = 50;
for (var ward = 1; ward <= ChicagoWardCount; ward++) {
createPage({
path: `/ward/${ward}`,
component: './src/templates/Ward.vue',
// In the CSV the ward is a string, so we pass that to the context as well for GraphQL
context: { ward: ward.toString() },
});
}
// Create pages for each property type
propertyTypesData.propertyTypes.forEach((propertyType) => {
const slug = slugifyPropertyType(propertyType);
createPage({
path: `/property-type/${slug}`,
component: './src/templates/PropertyType.vue',
context: { propertyType },
});
});
// Create dev-only tooling and social card routes (only in development). These pages live in
// src/templates rather than src/pages so they aren't picked up by Gridsome's automatic
// filesystem routing, which would build them (and run their queries) on every build
// regardless of NODE_ENV.
if (process.env.NODE_ENV !== 'production') {
// Create the building image uploader admin page
createPage({
path: '/building-image-uploader',
component: './src/templates/BuildingImageUploader.vue',
});
// Create page social card routes
const pageIds = Object.keys(pageSocialConfigsData);
pageIds.forEach(pageId => {
createPage({
path: `/page-social-card/${pageId}`,
component: './src/templates/social-cards/PageSocialCardPage.vue',
context: {
pageId: pageId
}
});
});
// Create owner social card routes
BuildingOwnerIds.forEach(ownerId => {
createPage({
path: `/owner-social-card/${ownerId}`,
component: './src/templates/social-cards/OwnerSocialCardPage.vue',
context: {
ownerId: ownerId
}
});
});
// Create property type social card routes
propertyTypesData.propertyTypes.forEach((propertyType) => {
const slug = slugifyPropertyType(propertyType);
createPage({
path: `/property-type-social-card/${slug}`,
component: './src/templates/social-cards/PropertyTypeSocialCardPage.vue',
context: {
propertyType: propertyType
}
});
});
}
});
};
/**
* Load in the building benchmark data
*
* @param {unknown} actions The actions class?
*/
function loadBuildingBenchmarkData(actions) {
const latestBenchmarksRaw = readFileSync(
`${DataDirectory}${BuildingEmissionsDataFile}`,
'utf8',
);
/**
* Load in building benchmarks and expose as Buildings collection
*/
const LatestBenchmarksData = parse(latestBenchmarksRaw, {
columns: true,
skip_empty_lines: true,
});
const collection = actions.addCollection({ typeName: 'Building' });
// If we get a duplicate node warning, notate the name here, and then we tack on the ID to the
// URL so '/building/salvation-army` becomes `/building/salvation-army-1234`. Gridsome only logs
// a console warning (not a catchable error) when a node's path collides with an existing one,
// then silently drops the node - so any collision NOT in this list must be caught below and
// fail the build loudly, rather than silently hiding a building.
const duplicateSlugs = [
'illinois-institute-of-technology',
'bricktown-square',
'the-woodlands-of-bronzeville-condominium-association',
'west-side-realty-corporation',
'gateway-centre',
'salvation-army',
'left-bank-at-k-station',
'tech-business-center',
'iit-research-tower',
'oakwood-shores-2d',
// Newly surfaced by the Never Submitted Buildings work - these are multi-building
// complexes where previously only one building in the pair had reported data
'brompton',
'kenwin-venture-lllp',
"the-buyer's-market-incorporated",
'armour',
'sutherland',
'24-east-washington-street',
'park-boulevard',
'encuentro-square',
];
// Tracks every slug we've assigned so far this build, so we can catch NEW collisions (ones not
// yet in duplicateSlugs above) immediately instead of letting Gridsome silently drop the node
const seenSlugs = new Set();
for (const building of LatestBenchmarksData) {
// Make a slugSource that is the property name or the address as a fallback (skip one letter
// names, e.g. '-)
building.slugSource =
building.PropertyName.length > 1
? building.PropertyName
: building.Address;
// The csv parse returns everything as strings, so explicitly parse float columns so GraphQL
// loads them properly and can apply filters (like `TotalGHGEmissions: { gt: 1000.0 }`)
// TODO: Parse all our float and int columns so GraphQL can then filter them
const buildingFloatCols = [
'DistrictChilledWaterUse',
'DistrictSteamUse',
'ElectricityUse',
'GHGIntensity',
'GrossFloorArea',
'NaturalGasUse',
'TotalGHGEmissions',
];
buildingFloatCols.forEach((col) => {
building[col] = parseFloat(building[col]);
});
// Parse integer columns for GraphQL filtering
const buildingIntCols = [
'FirstYearReported',
'LastYearReported',
];
buildingIntCols.forEach((col) => {
if (building[col] && building[col] !== '') {
building[col] = parseInt(building[col], 10);
} else {
building[col] = null;
}
});
if (!building.slugSource || typeof building.slugSource !== 'string') {
throw new Error('No building slug source (name or address)!', building);
}
if (
duplicateSlugs.includes(
building.slugSource.toLowerCase().replaceAll(' ', '-'),
)
) {
building.slugSource = building.slugSource + building.ID;
}
const normalizedSlug = building.slugSource
.toLowerCase()
.replaceAll(' ', '-');
// Fail the build loudly instead of letting Gridsome silently drop the node - a collision
// here means a building would otherwise vanish from the site with no warning
if (seenSlugs.has(normalizedSlug)) {
throw new Error(
`Duplicate building slug "${normalizedSlug}" (building ID ${building.ID}) - add its ` +
"base name/address to the duplicateSlugs list in gridsome.server.js so it gets " +
'disambiguated with its ID instead of silently overwriting another building.',
);
}
seenSlugs.add(normalizedSlug);
collection.addNode(building);
}
copyBuildingSearchIndexToStatic();
}
/**
* Copy the pipeline-generated search autocomplete index from src/data/dist into
* the static folder, so the search UI can fetch it at the site root instead of
* bundling it into page JS. The index itself is generated by the data pipeline
* (src/data/scripts/generate_search_index.py), not here.
*/
function copyBuildingSearchIndexToStatic() {
copyFileSync(
`${DataDirectory}${SearchIndexFile}`,
`${StaticDirectory}${SearchIndexFile}`,
);
}
/**
* Load in pre-computed ward-level stats
*
* @param {unknown} actions The actions class?
*/
function loadWardStatsData(actions) {
const wardStatsRaw = readFileSync(`${DataDirectory}${WardStatsDataFile}`, 'utf8');
const wardStatsData = parse(wardStatsRaw, { columns: true, skip_empty_lines: true });
const collection = actions.addCollection({ typeName: 'WardStats' });
for (const row of wardStatsData) {
// Shape must match IWardStats in src/components/WardsTable.vue
collection.addNode({
Ward: parseInt(row['Ward'], 10),
CompliantBuildings: parseInt(row['Compliant Buildings'], 10),
TotalBuildings: parseInt(row['Total Buildings'], 10),
TotalGHGEmissions: parseFloat(row['Total GHG Emissions (tons CO2 eq.)']),
AvgGHGIntensity: parseFloat(row['Avg GHG Intensity (kg CO2 eq./sqft)']),
TotalSquareFootage: parseFloat(row['Total Square Footage']),
AvgBuildingAge: row['Avg Building Age (years)']
? parseFloat(row['Avg Building Age (years)'])
: null,
});
}
}
/**
* Load in the historic benchmark data
*
* @param {unknown} actions The actions class?
*/
function loadHistoricBenchmarkDat(actions) {
const historicBenchmarksRaw = readFileSync(
`${DataDirectory}${HistoricBenchmarkingDataFile}`,
'utf8',
);
/**
* Load in building benchmarks and expose as Buildings collection
*/
const HistoricBenchmarksData = parse(historicBenchmarksRaw, {
columns: true,
skip_empty_lines: true,
});
const collection = actions.addCollection({ typeName: 'Benchmark' });
for (const benchmark of HistoricBenchmarksData) {
// The csv parse returns everything as strings, so explicitly parse float columns so GraphQL
// loads them properly and can apply filters (like `TotalGHGEmissions: { gt: 1000.0 }`)
// TODO: Parse all our float and int columns so GraphQL can then filter them
const historicFloatCols = [
'DistrictChilledWaterUse',
'DistrictSteamUse',
'ElectricityUse',
'GHGIntensity',
'GrossFloorArea',
'NaturalGasUse',
'SourceEUI',
'TotalGHGEmissions',
];
historicFloatCols.forEach((col) => {
benchmark[col] = parseFloat(benchmark[col]);
});
benchmark.DataYear = parseInt(benchmark.DataYear);
collection.addNode(benchmark);
}
}