xref: /aosp_15_r20/external/timezone-boundary-builder/index.js (revision f0df97945b4fdddd066170b120f192941b8d7fbf)
1*f0df9794SNeil Fullervar exec = require('child_process').exec
2*f0df9794SNeil Fullervar fs = require('fs')
3*f0df9794SNeil Fullervar path = require('path')
4*f0df9794SNeil Fuller
5*f0df9794SNeil Fullervar area = require('@mapbox/geojson-area')
6*f0df9794SNeil Fullervar geojsonhint = require('@mapbox/geojsonhint')
7*f0df9794SNeil Fullervar bbox = require('@turf/bbox').default
8*f0df9794SNeil Fullervar helpers = require('@turf/helpers')
9*f0df9794SNeil Fullervar multiPolygon = helpers.multiPolygon
10*f0df9794SNeil Fullervar polygon = helpers.polygon
11*f0df9794SNeil Fullervar asynclib = require('async')
12*f0df9794SNeil Fullervar https = require('follow-redirects').https
13*f0df9794SNeil Fullervar jsts = require('jsts')
14*f0df9794SNeil Fullervar rimraf = require('rimraf')
15*f0df9794SNeil Fullervar overpass = require('query-overpass')
16*f0df9794SNeil Fullervar yargs = require('yargs')
17*f0df9794SNeil Fuller
18*f0df9794SNeil Fullerconst FeatureWriterStream = require('./util/featureWriterStream')
19*f0df9794SNeil Fullerconst ProgressStats = require('./util/progressStats')
20*f0df9794SNeil Fuller
21*f0df9794SNeil Fullervar osmBoundarySources = require('./osmBoundarySources.json')
22*f0df9794SNeil Fullervar zoneCfg = require('./timezones.json')
23*f0df9794SNeil Fullervar expectedZoneOverlaps = require('./expectedZoneOverlaps.json')
24*f0df9794SNeil Fuller
25*f0df9794SNeil Fullerconst argv = yargs
26*f0df9794SNeil Fuller  .option('downloads_dir', {
27*f0df9794SNeil Fuller    description: 'Set the download location',
28*f0df9794SNeil Fuller    default: './downloads',
29*f0df9794SNeil Fuller    type: 'string'
30*f0df9794SNeil Fuller  })
31*f0df9794SNeil Fuller  .option('dist_dir', {
32*f0df9794SNeil Fuller    description: 'Set the dist location',
33*f0df9794SNeil Fuller    default: './dist',
34*f0df9794SNeil Fuller    type: 'string'
35*f0df9794SNeil Fuller  })
36*f0df9794SNeil Fuller  .option('excluded_zones', {
37*f0df9794SNeil Fuller    description: 'Exclude specified zones',
38*f0df9794SNeil Fuller    type: 'array'
39*f0df9794SNeil Fuller  })
40*f0df9794SNeil Fuller  .option('included_zones', {
41*f0df9794SNeil Fuller    description: 'Include specified zones',
42*f0df9794SNeil Fuller    type: 'array'
43*f0df9794SNeil Fuller  })
44*f0df9794SNeil Fuller  .option('skip_analyze_diffs', {
45*f0df9794SNeil Fuller    description: 'Skip analysis of diffs between versions',
46*f0df9794SNeil Fuller    type: 'boolean'
47*f0df9794SNeil Fuller  })
48*f0df9794SNeil Fuller  .option('skip_shapefile', {
49*f0df9794SNeil Fuller    description: 'Skip shapefile creation',
50*f0df9794SNeil Fuller    type: 'boolean'
51*f0df9794SNeil Fuller  })
52*f0df9794SNeil Fuller  .option('skip_validation', {
53*f0df9794SNeil Fuller    description: 'Skip validation',
54*f0df9794SNeil Fuller    type: 'boolean'
55*f0df9794SNeil Fuller  })
56*f0df9794SNeil Fuller  .option('skip_zip', {
57*f0df9794SNeil Fuller    description: 'Skip zip creation',
58*f0df9794SNeil Fuller    type: 'boolean'
59*f0df9794SNeil Fuller  })
60*f0df9794SNeil Fuller  .help()
61*f0df9794SNeil Fuller  .strict()
62*f0df9794SNeil Fuller  .alias('help', 'h')
63*f0df9794SNeil Fuller  .argv
64*f0df9794SNeil Fuller
65*f0df9794SNeil Fuller// Resolve the arguments with paths so relative paths become absolute.
66*f0df9794SNeil Fullerconst downloadsDir = path.resolve(argv.downloads_dir)
67*f0df9794SNeil Fullerconst distDir = path.resolve(argv.dist_dir)
68*f0df9794SNeil Fuller
69*f0df9794SNeil Fuller// allow building of only a specified zones
70*f0df9794SNeil Fullerlet includedZones = []
71*f0df9794SNeil Fullerlet excludedZones = []
72*f0df9794SNeil Fullerif (argv.included_zones || argv.excluded_zones) {
73*f0df9794SNeil Fuller  if (argv.included_zones) {
74*f0df9794SNeil Fuller    const newZoneCfg = {}
75*f0df9794SNeil Fuller    includedZones = argv.included_zones
76*f0df9794SNeil Fuller    includedZones.forEach((zoneName) => {
77*f0df9794SNeil Fuller      newZoneCfg[zoneName] = zoneCfg[zoneName]
78*f0df9794SNeil Fuller    })
79*f0df9794SNeil Fuller    zoneCfg = newZoneCfg
80*f0df9794SNeil Fuller  }
81*f0df9794SNeil Fuller  if (argv.excluded_zones) {
82*f0df9794SNeil Fuller    const newZoneCfg = {}
83*f0df9794SNeil Fuller    excludedZones = argv.excluded_zones
84*f0df9794SNeil Fuller    Object.keys(zoneCfg).forEach((zoneName) => {
85*f0df9794SNeil Fuller      if (!excludedZones.includes(zoneName)) {
86*f0df9794SNeil Fuller        newZoneCfg[zoneName] = zoneCfg[zoneName]
87*f0df9794SNeil Fuller      }
88*f0df9794SNeil Fuller    })
89*f0df9794SNeil Fuller    zoneCfg = newZoneCfg
90*f0df9794SNeil Fuller  }
91*f0df9794SNeil Fuller
92*f0df9794SNeil Fuller  // filter out unneccessary downloads
93*f0df9794SNeil Fuller  var newOsmBoundarySources = {}
94*f0df9794SNeil Fuller  Object.keys(zoneCfg).forEach((zoneName) => {
95*f0df9794SNeil Fuller    zoneCfg[zoneName].forEach((op) => {
96*f0df9794SNeil Fuller      if (op.source === 'overpass') {
97*f0df9794SNeil Fuller        newOsmBoundarySources[op.id] = osmBoundarySources[op.id]
98*f0df9794SNeil Fuller      }
99*f0df9794SNeil Fuller    })
100*f0df9794SNeil Fuller  })
101*f0df9794SNeil Fuller
102*f0df9794SNeil Fuller  osmBoundarySources = newOsmBoundarySources
103*f0df9794SNeil Fuller}
104*f0df9794SNeil Fuller
105*f0df9794SNeil Fullervar geoJsonReader = new jsts.io.GeoJSONReader()
106*f0df9794SNeil Fullervar geoJsonWriter = new jsts.io.GeoJSONWriter()
107*f0df9794SNeil Fullervar precisionModel = new jsts.geom.PrecisionModel(1000000)
108*f0df9794SNeil Fullervar precisionReducer = new jsts.precision.GeometryPrecisionReducer(precisionModel)
109*f0df9794SNeil Fullervar distZones = {}
110*f0df9794SNeil Fullervar lastReleaseJSONfile
111*f0df9794SNeil Fullervar minRequestGap = 4
112*f0df9794SNeil Fullervar curRequestGap = 4
113*f0df9794SNeil Fullerconst bufferDistance = 0.01
114*f0df9794SNeil Fuller
115*f0df9794SNeil Fullervar safeMkdir = function (dirname, callback) {
116*f0df9794SNeil Fuller  fs.mkdir(dirname, function (err) {
117*f0df9794SNeil Fuller    if (err && err.code === 'EEXIST') {
118*f0df9794SNeil Fuller      callback()
119*f0df9794SNeil Fuller    } else {
120*f0df9794SNeil Fuller      callback(err)
121*f0df9794SNeil Fuller    }
122*f0df9794SNeil Fuller  })
123*f0df9794SNeil Fuller}
124*f0df9794SNeil Fuller
125*f0df9794SNeil Fullervar debugGeo = function (
126*f0df9794SNeil Fuller  op,
127*f0df9794SNeil Fuller  a,
128*f0df9794SNeil Fuller  b,
129*f0df9794SNeil Fuller  reducePrecision,
130*f0df9794SNeil Fuller  bufferAfterPrecisionReduction
131*f0df9794SNeil Fuller) {
132*f0df9794SNeil Fuller  var result
133*f0df9794SNeil Fuller
134*f0df9794SNeil Fuller  if (reducePrecision) {
135*f0df9794SNeil Fuller    a = precisionReducer.reduce(a)
136*f0df9794SNeil Fuller    b = precisionReducer.reduce(b)
137*f0df9794SNeil Fuller  }
138*f0df9794SNeil Fuller
139*f0df9794SNeil Fuller  try {
140*f0df9794SNeil Fuller    switch (op) {
141*f0df9794SNeil Fuller      case 'union':
142*f0df9794SNeil Fuller        result = a.union(b)
143*f0df9794SNeil Fuller        break
144*f0df9794SNeil Fuller      case 'intersection':
145*f0df9794SNeil Fuller        result = a.intersection(b)
146*f0df9794SNeil Fuller        break
147*f0df9794SNeil Fuller      case 'intersects':
148*f0df9794SNeil Fuller        result = a.intersects(b)
149*f0df9794SNeil Fuller        break
150*f0df9794SNeil Fuller      case 'diff':
151*f0df9794SNeil Fuller        result = a.difference(b)
152*f0df9794SNeil Fuller        break
153*f0df9794SNeil Fuller      default:
154*f0df9794SNeil Fuller        var err = new Error('invalid op: ' + op)
155*f0df9794SNeil Fuller        throw err
156*f0df9794SNeil Fuller    }
157*f0df9794SNeil Fuller  } catch (e) {
158*f0df9794SNeil Fuller    if (e.name === 'TopologyException') {
159*f0df9794SNeil Fuller      if (reducePrecision) {
160*f0df9794SNeil Fuller        if (bufferAfterPrecisionReduction) {
161*f0df9794SNeil Fuller          console.log('Encountered TopologyException, retry with buffer increase')
162*f0df9794SNeil Fuller          return debugGeo(
163*f0df9794SNeil Fuller            op,
164*f0df9794SNeil Fuller            a.buffer(bufferDistance),
165*f0df9794SNeil Fuller            b.buffer(bufferDistance),
166*f0df9794SNeil Fuller            true,
167*f0df9794SNeil Fuller            bufferAfterPrecisionReduction
168*f0df9794SNeil Fuller          )
169*f0df9794SNeil Fuller        } else {
170*f0df9794SNeil Fuller          throw new Error('Encountered TopologyException after reducing precision')
171*f0df9794SNeil Fuller        }
172*f0df9794SNeil Fuller      } else {
173*f0df9794SNeil Fuller        console.log('Encountered TopologyException, retry with GeometryPrecisionReducer')
174*f0df9794SNeil Fuller        return debugGeo(op, a, b, true, bufferAfterPrecisionReduction)
175*f0df9794SNeil Fuller      }
176*f0df9794SNeil Fuller    }
177*f0df9794SNeil Fuller    console.log('op err')
178*f0df9794SNeil Fuller    console.log(e)
179*f0df9794SNeil Fuller    console.log(e.stack)
180*f0df9794SNeil Fuller    fs.writeFileSync('debug_' + op + '_a.json', JSON.stringify(geoJsonWriter.write(a)))
181*f0df9794SNeil Fuller    fs.writeFileSync('debug_' + op + '_b.json', JSON.stringify(geoJsonWriter.write(b)))
182*f0df9794SNeil Fuller    throw e
183*f0df9794SNeil Fuller  }
184*f0df9794SNeil Fuller
185*f0df9794SNeil Fuller  return result
186*f0df9794SNeil Fuller}
187*f0df9794SNeil Fuller
188*f0df9794SNeil Fullervar fetchIfNeeded = function (file, superCallback, downloadCallback, fetchFn) {
189*f0df9794SNeil Fuller  // check for file that got downloaded
190*f0df9794SNeil Fuller  fs.stat(file, function (err) {
191*f0df9794SNeil Fuller    if (!err) {
192*f0df9794SNeil Fuller      // file found, skip download steps
193*f0df9794SNeil Fuller      return superCallback()
194*f0df9794SNeil Fuller    }
195*f0df9794SNeil Fuller    // check for manual file that got fixed and needs validation
196*f0df9794SNeil Fuller    var fixedFile = file.replace('.json', '_fixed.json')
197*f0df9794SNeil Fuller    fs.stat(fixedFile, function (err) {
198*f0df9794SNeil Fuller      if (!err) {
199*f0df9794SNeil Fuller        // file found, return fixed file
200*f0df9794SNeil Fuller        return downloadCallback(null, require(fixedFile))
201*f0df9794SNeil Fuller      }
202*f0df9794SNeil Fuller      // no manual fixed file found, download from overpass
203*f0df9794SNeil Fuller      fetchFn()
204*f0df9794SNeil Fuller    })
205*f0df9794SNeil Fuller  })
206*f0df9794SNeil Fuller}
207*f0df9794SNeil Fuller
208*f0df9794SNeil Fullervar geoJsonToGeom = function (geoJson) {
209*f0df9794SNeil Fuller  try {
210*f0df9794SNeil Fuller    return geoJsonReader.read(JSON.stringify(geoJson))
211*f0df9794SNeil Fuller  } catch (e) {
212*f0df9794SNeil Fuller    console.error('error converting geojson to geometry')
213*f0df9794SNeil Fuller    fs.writeFileSync('debug_geojson_read_error.json', JSON.stringify(geoJson))
214*f0df9794SNeil Fuller    throw e
215*f0df9794SNeil Fuller  }
216*f0df9794SNeil Fuller}
217*f0df9794SNeil Fuller
218*f0df9794SNeil Fullervar geomToGeoJson = function (geom) {
219*f0df9794SNeil Fuller  return geoJsonWriter.write(geom)
220*f0df9794SNeil Fuller}
221*f0df9794SNeil Fuller
222*f0df9794SNeil Fullervar geomToGeoJsonString = function (geom) {
223*f0df9794SNeil Fuller  return JSON.stringify(geoJsonWriter.write(geom))
224*f0df9794SNeil Fuller}
225*f0df9794SNeil Fuller
226*f0df9794SNeil Fullerconst downloadProgress = new ProgressStats(
227*f0df9794SNeil Fuller  'Downloading',
228*f0df9794SNeil Fuller  Object.keys(osmBoundarySources).length
229*f0df9794SNeil Fuller)
230*f0df9794SNeil Fuller
231*f0df9794SNeil Fullervar downloadOsmBoundary = function (boundaryId, boundaryCallback) {
232*f0df9794SNeil Fuller  var cfg = osmBoundarySources[boundaryId]
233*f0df9794SNeil Fuller  var query = '[out:json][timeout:60];('
234*f0df9794SNeil Fuller  if (cfg.way) {
235*f0df9794SNeil Fuller    query += 'way'
236*f0df9794SNeil Fuller  } else {
237*f0df9794SNeil Fuller    query += 'relation'
238*f0df9794SNeil Fuller  }
239*f0df9794SNeil Fuller  var boundaryFilename = downloadsDir + '/' + boundaryId + '.json'
240*f0df9794SNeil Fuller  var debug = 'getting data for ' + boundaryId
241*f0df9794SNeil Fuller  var queryKeys = Object.keys(cfg)
242*f0df9794SNeil Fuller
243*f0df9794SNeil Fuller  for (var i = queryKeys.length - 1; i >= 0; i--) {
244*f0df9794SNeil Fuller    var k = queryKeys[i]
245*f0df9794SNeil Fuller    if (k === 'way') continue
246*f0df9794SNeil Fuller    var v = cfg[k]
247*f0df9794SNeil Fuller
248*f0df9794SNeil Fuller    query += '["' + k + '"="' + v + '"]'
249*f0df9794SNeil Fuller  }
250*f0df9794SNeil Fuller
251*f0df9794SNeil Fuller  query += ';);out body;>;out meta qt;'
252*f0df9794SNeil Fuller
253*f0df9794SNeil Fuller  downloadProgress.beginTask(debug, true)
254*f0df9794SNeil Fuller
255*f0df9794SNeil Fuller  asynclib.auto({
256*f0df9794SNeil Fuller    downloadFromOverpass: function (cb) {
257*f0df9794SNeil Fuller      console.log('downloading from overpass')
258*f0df9794SNeil Fuller      fetchIfNeeded(boundaryFilename, boundaryCallback, cb, function () {
259*f0df9794SNeil Fuller        var overpassResponseHandler = function (err, data) {
260*f0df9794SNeil Fuller          if (err) {
261*f0df9794SNeil Fuller            console.log(err)
262*f0df9794SNeil Fuller            console.log('Increasing overpass request gap')
263*f0df9794SNeil Fuller            curRequestGap *= 2
264*f0df9794SNeil Fuller            makeQuery()
265*f0df9794SNeil Fuller          } else {
266*f0df9794SNeil Fuller            console.log('Success, decreasing overpass request gap')
267*f0df9794SNeil Fuller            curRequestGap = Math.max(minRequestGap, curRequestGap / 2)
268*f0df9794SNeil Fuller            cb(null, data)
269*f0df9794SNeil Fuller          }
270*f0df9794SNeil Fuller        }
271*f0df9794SNeil Fuller        var makeQuery = function () {
272*f0df9794SNeil Fuller          console.log('waiting ' + curRequestGap + ' seconds')
273*f0df9794SNeil Fuller          setTimeout(function () {
274*f0df9794SNeil Fuller            overpass(query, overpassResponseHandler, { flatProperties: true })
275*f0df9794SNeil Fuller          }, curRequestGap * 1000)
276*f0df9794SNeil Fuller        }
277*f0df9794SNeil Fuller        makeQuery()
278*f0df9794SNeil Fuller      })
279*f0df9794SNeil Fuller    },
280*f0df9794SNeil Fuller    validateOverpassResult: ['downloadFromOverpass', function (results, cb) {
281*f0df9794SNeil Fuller      var data = results.downloadFromOverpass
282*f0df9794SNeil Fuller      if (!data.features) {
283*f0df9794SNeil Fuller        var err = new Error('Invalid geojson for boundary: ' + boundaryId)
284*f0df9794SNeil Fuller        return cb(err)
285*f0df9794SNeil Fuller      }
286*f0df9794SNeil Fuller      if (data.features.length === 0) {
287*f0df9794SNeil Fuller        console.error('No data for the following query:')
288*f0df9794SNeil Fuller        console.error(query)
289*f0df9794SNeil Fuller        console.error('To read more about this error, please visit https://git.io/vxKQL')
290*f0df9794SNeil Fuller        return cb(new Error('No data found for from overpass query'))
291*f0df9794SNeil Fuller      }
292*f0df9794SNeil Fuller      cb()
293*f0df9794SNeil Fuller    }],
294*f0df9794SNeil Fuller    saveSingleMultiPolygon: ['validateOverpassResult', function (results, cb) {
295*f0df9794SNeil Fuller      var data = results.downloadFromOverpass
296*f0df9794SNeil Fuller      var combined
297*f0df9794SNeil Fuller
298*f0df9794SNeil Fuller      // union all multi-polygons / polygons into one
299*f0df9794SNeil Fuller      for (var i = data.features.length - 1; i >= 0; i--) {
300*f0df9794SNeil Fuller        var curOsmGeom = data.features[i].geometry
301*f0df9794SNeil Fuller        const curOsmProps = data.features[i].properties
302*f0df9794SNeil Fuller        if (
303*f0df9794SNeil Fuller          (curOsmGeom.type === 'Polygon' || curOsmGeom.type === 'MultiPolygon') &&
304*f0df9794SNeil Fuller          curOsmProps.type === 'boundary' // need to make sure enclaves aren't unioned
305*f0df9794SNeil Fuller        ) {
306*f0df9794SNeil Fuller          console.log('combining border')
307*f0df9794SNeil Fuller          let errors = geojsonhint.hint(curOsmGeom)
308*f0df9794SNeil Fuller          if (errors && errors.length > 0) {
309*f0df9794SNeil Fuller            const stringifiedGeojson = JSON.stringify(curOsmGeom, null, 2)
310*f0df9794SNeil Fuller            errors = geojsonhint.hint(stringifiedGeojson)
311*f0df9794SNeil Fuller            console.error('Invalid geojson received in Overpass Result')
312*f0df9794SNeil Fuller            console.error('Overpass query: ' + query)
313*f0df9794SNeil Fuller            const problemFilename = boundaryId + '_convert_to_geom_error.json'
314*f0df9794SNeil Fuller            fs.writeFileSync(problemFilename, stringifiedGeojson)
315*f0df9794SNeil Fuller            console.error('saved problem file to ' + problemFilename)
316*f0df9794SNeil Fuller            console.error('To read more about this error, please visit https://git.io/vxKQq')
317*f0df9794SNeil Fuller            return cb(errors)
318*f0df9794SNeil Fuller          }
319*f0df9794SNeil Fuller          try {
320*f0df9794SNeil Fuller            var curGeom = geoJsonToGeom(curOsmGeom)
321*f0df9794SNeil Fuller          } catch (e) {
322*f0df9794SNeil Fuller            console.error('error converting overpass result to geojson')
323*f0df9794SNeil Fuller            console.error(e)
324*f0df9794SNeil Fuller
325*f0df9794SNeil Fuller            fs.writeFileSync(boundaryId + '_convert_to_geom_error-all-features.json', JSON.stringify(data))
326*f0df9794SNeil Fuller            return cb(e)
327*f0df9794SNeil Fuller          }
328*f0df9794SNeil Fuller          if (!combined) {
329*f0df9794SNeil Fuller            combined = curGeom
330*f0df9794SNeil Fuller          } else {
331*f0df9794SNeil Fuller            combined = debugGeo('union', curGeom, combined)
332*f0df9794SNeil Fuller          }
333*f0df9794SNeil Fuller        }
334*f0df9794SNeil Fuller      }
335*f0df9794SNeil Fuller      try {
336*f0df9794SNeil Fuller        fs.writeFile(boundaryFilename, geomToGeoJsonString(combined), cb)
337*f0df9794SNeil Fuller      } catch (e) {
338*f0df9794SNeil Fuller        console.error('error writing combined border to geojson')
339*f0df9794SNeil Fuller        fs.writeFileSync(boundaryId + '_combined_border_convert_to_geom_error.json', JSON.stringify(data))
340*f0df9794SNeil Fuller        return cb(e)
341*f0df9794SNeil Fuller      }
342*f0df9794SNeil Fuller    }]
343*f0df9794SNeil Fuller  }, boundaryCallback)
344*f0df9794SNeil Fuller}
345*f0df9794SNeil Fuller
346*f0df9794SNeil Fullervar getTzDistFilename = function (tzid) {
347*f0df9794SNeil Fuller  return distDir + '/' + tzid.replace(/\//g, '__') + '.json'
348*f0df9794SNeil Fuller}
349*f0df9794SNeil Fuller
350*f0df9794SNeil Fuller/**
351*f0df9794SNeil Fuller * Get the geometry of the requested source data
352*f0df9794SNeil Fuller *
353*f0df9794SNeil Fuller * @return {Object} geom  The geometry of the source
354*f0df9794SNeil Fuller * @param {Object} source  An object representing the data source
355*f0df9794SNeil Fuller *   must have `source` key and then either:
356*f0df9794SNeil Fuller *     - `id` if from a file
357*f0df9794SNeil Fuller *     - `id` if from a file
358*f0df9794SNeil Fuller */
359*f0df9794SNeil Fullervar getDataSource = function (source) {
360*f0df9794SNeil Fuller  var geoJson
361*f0df9794SNeil Fuller  if (source.source === 'overpass') {
362*f0df9794SNeil Fuller    geoJson = require(downloadsDir + '/' + source.id + '.json')
363*f0df9794SNeil Fuller  } else if (source.source === 'manual-polygon') {
364*f0df9794SNeil Fuller    geoJson = polygon(source.data).geometry
365*f0df9794SNeil Fuller  } else if (source.source === 'manual-multipolygon') {
366*f0df9794SNeil Fuller    geoJson = multiPolygon(source.data).geometry
367*f0df9794SNeil Fuller  } else if (source.source === 'dist') {
368*f0df9794SNeil Fuller    geoJson = require(getTzDistFilename(source.id))
369*f0df9794SNeil Fuller  } else {
370*f0df9794SNeil Fuller    var err = new Error('unknown source: ' + source.source)
371*f0df9794SNeil Fuller    throw err
372*f0df9794SNeil Fuller  }
373*f0df9794SNeil Fuller  return geoJsonToGeom(geoJson)
374*f0df9794SNeil Fuller}
375*f0df9794SNeil Fuller
376*f0df9794SNeil Fuller/**
377*f0df9794SNeil Fuller * Post process created timezone boundary.
378*f0df9794SNeil Fuller * - remove small holes and exclaves
379*f0df9794SNeil Fuller * - reduce geometry precision
380*f0df9794SNeil Fuller *
381*f0df9794SNeil Fuller * @param  {Geometry} geom  The jsts geometry of the timezone
382*f0df9794SNeil Fuller * @param  {boolean} returnAsObject if true, return as object, otherwise return stringified
383*f0df9794SNeil Fuller * @return {Object|String}         geojson as object or stringified
384*f0df9794SNeil Fuller */
385*f0df9794SNeil Fullervar postProcessZone = function (geom, returnAsObject) {
386*f0df9794SNeil Fuller  // reduce precision of geometry
387*f0df9794SNeil Fuller  const geojson = geomToGeoJson(precisionReducer.reduce(geom))
388*f0df9794SNeil Fuller
389*f0df9794SNeil Fuller  // iterate through all polygons
390*f0df9794SNeil Fuller  const filteredPolygons = []
391*f0df9794SNeil Fuller  let allPolygons = geojson.coordinates
392*f0df9794SNeil Fuller  if (geojson.type === 'Polygon') {
393*f0df9794SNeil Fuller    allPolygons = [geojson.coordinates]
394*f0df9794SNeil Fuller  }
395*f0df9794SNeil Fuller
396*f0df9794SNeil Fuller  allPolygons.forEach((curPolygon, idx) => {
397*f0df9794SNeil Fuller    // remove any polygon with very small area
398*f0df9794SNeil Fuller    const polygonFeature = polygon(curPolygon)
399*f0df9794SNeil Fuller    const polygonArea = area.geometry(polygonFeature.geometry)
400*f0df9794SNeil Fuller
401*f0df9794SNeil Fuller    if (polygonArea < 1) return
402*f0df9794SNeil Fuller
403*f0df9794SNeil Fuller    // find all holes
404*f0df9794SNeil Fuller    const filteredLinearRings = []
405*f0df9794SNeil Fuller
406*f0df9794SNeil Fuller    curPolygon.forEach((curLinearRing, lrIdx) => {
407*f0df9794SNeil Fuller      if (lrIdx === 0) {
408*f0df9794SNeil Fuller        // always keep first linearRing
409*f0df9794SNeil Fuller        filteredLinearRings.push(curLinearRing)
410*f0df9794SNeil Fuller      } else {
411*f0df9794SNeil Fuller        const polygonFromLinearRing = polygon([curLinearRing])
412*f0df9794SNeil Fuller        const linearRingArea = area.geometry(polygonFromLinearRing.geometry)
413*f0df9794SNeil Fuller
414*f0df9794SNeil Fuller        // only include holes with relevant area
415*f0df9794SNeil Fuller        if (linearRingArea > 1) {
416*f0df9794SNeil Fuller          filteredLinearRings.push(curLinearRing)
417*f0df9794SNeil Fuller        }
418*f0df9794SNeil Fuller      }
419*f0df9794SNeil Fuller    })
420*f0df9794SNeil Fuller
421*f0df9794SNeil Fuller    filteredPolygons.push(filteredLinearRings)
422*f0df9794SNeil Fuller  })
423*f0df9794SNeil Fuller
424*f0df9794SNeil Fuller  // recompile to geojson string
425*f0df9794SNeil Fuller  const newGeojson = {
426*f0df9794SNeil Fuller    type: geojson.type
427*f0df9794SNeil Fuller  }
428*f0df9794SNeil Fuller
429*f0df9794SNeil Fuller  if (geojson.type === 'Polygon') {
430*f0df9794SNeil Fuller    newGeojson.coordinates = filteredPolygons[0]
431*f0df9794SNeil Fuller  } else {
432*f0df9794SNeil Fuller    newGeojson.coordinates = filteredPolygons
433*f0df9794SNeil Fuller  }
434*f0df9794SNeil Fuller
435*f0df9794SNeil Fuller  return returnAsObject ? newGeojson : JSON.stringify(newGeojson)
436*f0df9794SNeil Fuller}
437*f0df9794SNeil Fuller
438*f0df9794SNeil Fullerconst buildingProgress = new ProgressStats(
439*f0df9794SNeil Fuller  'Building',
440*f0df9794SNeil Fuller  Object.keys(zoneCfg).length
441*f0df9794SNeil Fuller)
442*f0df9794SNeil Fuller
443*f0df9794SNeil Fullervar makeTimezoneBoundary = function (tzid, callback) {
444*f0df9794SNeil Fuller  buildingProgress.beginTask(`makeTimezoneBoundary for ${tzid}`, true)
445*f0df9794SNeil Fuller
446*f0df9794SNeil Fuller  var ops = zoneCfg[tzid]
447*f0df9794SNeil Fuller  var geom
448*f0df9794SNeil Fuller
449*f0df9794SNeil Fuller  asynclib.eachSeries(ops, function (task, cb) {
450*f0df9794SNeil Fuller    var taskData = getDataSource(task)
451*f0df9794SNeil Fuller    console.log('-', task.op, task.id)
452*f0df9794SNeil Fuller    if (task.op === 'init') {
453*f0df9794SNeil Fuller      geom = taskData
454*f0df9794SNeil Fuller    } else if (task.op === 'intersect') {
455*f0df9794SNeil Fuller      geom = debugGeo('intersection', geom, taskData)
456*f0df9794SNeil Fuller    } else if (task.op === 'difference') {
457*f0df9794SNeil Fuller      geom = debugGeo('diff', geom, taskData)
458*f0df9794SNeil Fuller    } else if (task.op === 'difference-reverse-order') {
459*f0df9794SNeil Fuller      geom = debugGeo('diff', taskData, geom)
460*f0df9794SNeil Fuller    } else if (task.op === 'union') {
461*f0df9794SNeil Fuller      geom = debugGeo('union', geom, taskData)
462*f0df9794SNeil Fuller    } else {
463*f0df9794SNeil Fuller      var err = new Error('unknown op: ' + task.op)
464*f0df9794SNeil Fuller      return cb(err)
465*f0df9794SNeil Fuller    }
466*f0df9794SNeil Fuller    cb()
467*f0df9794SNeil Fuller  },
468*f0df9794SNeil Fuller  function (err) {
469*f0df9794SNeil Fuller    if (err) { return callback(err) }
470*f0df9794SNeil Fuller    fs.writeFile(getTzDistFilename(tzid),
471*f0df9794SNeil Fuller      postProcessZone(geom),
472*f0df9794SNeil Fuller      callback)
473*f0df9794SNeil Fuller  })
474*f0df9794SNeil Fuller}
475*f0df9794SNeil Fuller
476*f0df9794SNeil Fullervar loadDistZonesIntoMemory = function () {
477*f0df9794SNeil Fuller  console.log('load zones into memory')
478*f0df9794SNeil Fuller  var zones = Object.keys(zoneCfg)
479*f0df9794SNeil Fuller  var tzid
480*f0df9794SNeil Fuller
481*f0df9794SNeil Fuller  for (var i = 0; i < zones.length; i++) {
482*f0df9794SNeil Fuller    tzid = zones[i]
483*f0df9794SNeil Fuller    distZones[tzid] = getDataSource({ source: 'dist', id: tzid })
484*f0df9794SNeil Fuller  }
485*f0df9794SNeil Fuller}
486*f0df9794SNeil Fuller
487*f0df9794SNeil Fullervar getDistZoneGeom = function (tzid) {
488*f0df9794SNeil Fuller  return distZones[tzid]
489*f0df9794SNeil Fuller}
490*f0df9794SNeil Fuller
491*f0df9794SNeil Fullervar roundDownToTenth = function (n) {
492*f0df9794SNeil Fuller  return Math.floor(n * 10) / 10
493*f0df9794SNeil Fuller}
494*f0df9794SNeil Fuller
495*f0df9794SNeil Fullervar roundUpToTenth = function (n) {
496*f0df9794SNeil Fuller  return Math.ceil(n * 10) / 10
497*f0df9794SNeil Fuller}
498*f0df9794SNeil Fuller
499*f0df9794SNeil Fullervar formatBounds = function (bounds) {
500*f0df9794SNeil Fuller  let boundsStr = '['
501*f0df9794SNeil Fuller  boundsStr += roundDownToTenth(bounds[0]) + ', '
502*f0df9794SNeil Fuller  boundsStr += roundDownToTenth(bounds[1]) + ', '
503*f0df9794SNeil Fuller  boundsStr += roundUpToTenth(bounds[2]) + ', '
504*f0df9794SNeil Fuller  boundsStr += roundUpToTenth(bounds[3]) + ']'
505*f0df9794SNeil Fuller  return boundsStr
506*f0df9794SNeil Fuller}
507*f0df9794SNeil Fuller
508*f0df9794SNeil Fullervar validateTimezoneBoundaries = function () {
509*f0df9794SNeil Fuller  const numZones = Object.keys(zoneCfg).length
510*f0df9794SNeil Fuller  const validationProgress = new ProgressStats(
511*f0df9794SNeil Fuller    'Validation',
512*f0df9794SNeil Fuller    numZones * (numZones + 1) / 2
513*f0df9794SNeil Fuller  )
514*f0df9794SNeil Fuller
515*f0df9794SNeil Fuller  console.log('do validation... this may take a few minutes')
516*f0df9794SNeil Fuller  var allZonesOk = true
517*f0df9794SNeil Fuller  var zones = Object.keys(zoneCfg)
518*f0df9794SNeil Fuller  var lastPct = 0
519*f0df9794SNeil Fuller  var compareTzid, tzid, zoneGeom
520*f0df9794SNeil Fuller
521*f0df9794SNeil Fuller  for (var i = 0; i < zones.length; i++) {
522*f0df9794SNeil Fuller    tzid = zones[i]
523*f0df9794SNeil Fuller    zoneGeom = getDistZoneGeom(tzid)
524*f0df9794SNeil Fuller
525*f0df9794SNeil Fuller    for (var j = i + 1; j < zones.length; j++) {
526*f0df9794SNeil Fuller      const curPct = Math.floor(validationProgress.getPercentage())
527*f0df9794SNeil Fuller      if (curPct % 10 === 0 && curPct !== lastPct) {
528*f0df9794SNeil Fuller        validationProgress.printStats('Validating zones', true)
529*f0df9794SNeil Fuller        lastPct = curPct
530*f0df9794SNeil Fuller      }
531*f0df9794SNeil Fuller      compareTzid = zones[j]
532*f0df9794SNeil Fuller
533*f0df9794SNeil Fuller      var compareZoneGeom = getDistZoneGeom(compareTzid)
534*f0df9794SNeil Fuller
535*f0df9794SNeil Fuller      var intersects = false
536*f0df9794SNeil Fuller      try {
537*f0df9794SNeil Fuller        intersects = debugGeo('intersects', zoneGeom, compareZoneGeom)
538*f0df9794SNeil Fuller      } catch (e) {
539*f0df9794SNeil Fuller        console.warn('warning, encountered intersection error with zone ' + tzid + ' and ' + compareTzid)
540*f0df9794SNeil Fuller      }
541*f0df9794SNeil Fuller      if (intersects) {
542*f0df9794SNeil Fuller        var intersectedGeom = debugGeo('intersection', zoneGeom, compareZoneGeom)
543*f0df9794SNeil Fuller        var intersectedArea = intersectedGeom.getArea()
544*f0df9794SNeil Fuller
545*f0df9794SNeil Fuller        if (intersectedArea > 0.0001) {
546*f0df9794SNeil Fuller          // check if the intersected area(s) are one of the expected areas of overlap
547*f0df9794SNeil Fuller          const allowedOverlapBounds = expectedZoneOverlaps[`${tzid}-${compareTzid}`] || expectedZoneOverlaps[`${compareTzid}-${tzid}`]
548*f0df9794SNeil Fuller          const overlapsGeoJson = geoJsonWriter.write(intersectedGeom)
549*f0df9794SNeil Fuller
550*f0df9794SNeil Fuller          // these zones are allowed to overlap in certain places, make sure the
551*f0df9794SNeil Fuller          // found overlap(s) all fit within the expected areas of overlap
552*f0df9794SNeil Fuller          if (allowedOverlapBounds) {
553*f0df9794SNeil Fuller            // if the overlaps are a multipolygon, make sure each individual
554*f0df9794SNeil Fuller            // polygon of overlap fits within at least one of the expected
555*f0df9794SNeil Fuller            // overlaps
556*f0df9794SNeil Fuller            let overlapsPolygons
557*f0df9794SNeil Fuller            switch (overlapsGeoJson.type) {
558*f0df9794SNeil Fuller              case 'MultiPolygon':
559*f0df9794SNeil Fuller                overlapsPolygons = overlapsGeoJson.coordinates.map(
560*f0df9794SNeil Fuller                  polygonCoords => ({
561*f0df9794SNeil Fuller                    coordinates: polygonCoords,
562*f0df9794SNeil Fuller                    type: 'Polygon'
563*f0df9794SNeil Fuller                  })
564*f0df9794SNeil Fuller                )
565*f0df9794SNeil Fuller                break
566*f0df9794SNeil Fuller              case 'Polygon':
567*f0df9794SNeil Fuller                overlapsPolygons = [overlapsGeoJson]
568*f0df9794SNeil Fuller                break
569*f0df9794SNeil Fuller              case 'GeometryCollection':
570*f0df9794SNeil Fuller                overlapsPolygons = []
571*f0df9794SNeil Fuller                overlapsGeoJson.geometries.forEach(geom => {
572*f0df9794SNeil Fuller                  if (geom.type === 'Polygon') {
573*f0df9794SNeil Fuller                    overlapsPolygons.push(geom)
574*f0df9794SNeil Fuller                  } else if (geom.type === 'MultiPolygon') {
575*f0df9794SNeil Fuller                    geom.coordinates.forEach(polygonCoords => {
576*f0df9794SNeil Fuller                      overlapsPolygons.push({
577*f0df9794SNeil Fuller                        coordinates: polygonCoords,
578*f0df9794SNeil Fuller                        type: 'Polygon'
579*f0df9794SNeil Fuller                      })
580*f0df9794SNeil Fuller                    })
581*f0df9794SNeil Fuller                  }
582*f0df9794SNeil Fuller                })
583*f0df9794SNeil Fuller                break
584*f0df9794SNeil Fuller              default:
585*f0df9794SNeil Fuller                console.error('unexpected geojson overlap type')
586*f0df9794SNeil Fuller                console.log(overlapsGeoJson)
587*f0df9794SNeil Fuller                break
588*f0df9794SNeil Fuller            }
589*f0df9794SNeil Fuller
590*f0df9794SNeil Fuller            let allOverlapsOk = true
591*f0df9794SNeil Fuller            overlapsPolygons.forEach((polygon, idx) => {
592*f0df9794SNeil Fuller              const bounds = bbox(polygon)
593*f0df9794SNeil Fuller              const polygonArea = area.geometry(polygon)
594*f0df9794SNeil Fuller              if (
595*f0df9794SNeil Fuller                polygonArea > 10 && // ignore small polygons
596*f0df9794SNeil Fuller                !allowedOverlapBounds.some(allowedBounds =>
597*f0df9794SNeil Fuller                  allowedBounds.bounds[0] <= bounds[0] && // minX
598*f0df9794SNeil Fuller                    allowedBounds.bounds[1] <= bounds[1] && // minY
599*f0df9794SNeil Fuller                    allowedBounds.bounds[2] >= bounds[2] && // maxX
600*f0df9794SNeil Fuller                    allowedBounds.bounds[3] >= bounds[3] // maxY
601*f0df9794SNeil Fuller                )
602*f0df9794SNeil Fuller              ) {
603*f0df9794SNeil Fuller                console.error(`Unexpected intersection (${polygonArea} area) with bounds: ${formatBounds(bounds)}`)
604*f0df9794SNeil Fuller                allOverlapsOk = false
605*f0df9794SNeil Fuller              }
606*f0df9794SNeil Fuller            })
607*f0df9794SNeil Fuller
608*f0df9794SNeil Fuller            if (allOverlapsOk) continue
609*f0df9794SNeil Fuller          }
610*f0df9794SNeil Fuller
611*f0df9794SNeil Fuller          // at least one unexpected overlap found, output an error and write debug file
612*f0df9794SNeil Fuller          console.error('Validation error: ' + tzid + ' intersects ' + compareTzid + ' area: ' + intersectedArea)
613*f0df9794SNeil Fuller          const debugFilename = tzid.replace(/\//g, '-') + '-' + compareTzid.replace(/\//g, '-') + '-overlap.json'
614*f0df9794SNeil Fuller          fs.writeFileSync(
615*f0df9794SNeil Fuller            debugFilename,
616*f0df9794SNeil Fuller            JSON.stringify(overlapsGeoJson)
617*f0df9794SNeil Fuller          )
618*f0df9794SNeil Fuller          console.error('wrote overlap area as file ' + debugFilename)
619*f0df9794SNeil Fuller          console.error('To read more about this error, please visit https://git.io/vx6nx')
620*f0df9794SNeil Fuller          allZonesOk = false
621*f0df9794SNeil Fuller        }
622*f0df9794SNeil Fuller      }
623*f0df9794SNeil Fuller      validationProgress.logNext()
624*f0df9794SNeil Fuller    }
625*f0df9794SNeil Fuller  }
626*f0df9794SNeil Fuller
627*f0df9794SNeil Fuller  return allZonesOk ? null : 'Zone validation unsuccessful'
628*f0df9794SNeil Fuller}
629*f0df9794SNeil Fuller
630*f0df9794SNeil Fullerlet oceanZoneBoundaries
631*f0df9794SNeil Fullerlet oceanZones = [
632*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-12', left: 172.5, right: 180 },
633*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-11', left: 157.5, right: 172.5 },
634*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-10', left: 142.5, right: 157.5 },
635*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-9', left: 127.5, right: 142.5 },
636*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-8', left: 112.5, right: 127.5 },
637*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-7', left: 97.5, right: 112.5 },
638*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-6', left: 82.5, right: 97.5 },
639*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-5', left: 67.5, right: 82.5 },
640*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-4', left: 52.5, right: 67.5 },
641*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-3', left: 37.5, right: 52.5 },
642*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-2', left: 22.5, right: 37.5 },
643*f0df9794SNeil Fuller  { tzid: 'Etc/GMT-1', left: 7.5, right: 22.5 },
644*f0df9794SNeil Fuller  { tzid: 'Etc/GMT', left: -7.5, right: 7.5 },
645*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+1', left: -22.5, right: -7.5 },
646*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+2', left: -37.5, right: -22.5 },
647*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+3', left: -52.5, right: -37.5 },
648*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+4', left: -67.5, right: -52.5 },
649*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+5', left: -82.5, right: -67.5 },
650*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+6', left: -97.5, right: -82.5 },
651*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+7', left: -112.5, right: -97.5 },
652*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+8', left: -127.5, right: -112.5 },
653*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+9', left: -142.5, right: -127.5 },
654*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+10', left: -157.5, right: -142.5 },
655*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+11', left: -172.5, right: -157.5 },
656*f0df9794SNeil Fuller  { tzid: 'Etc/GMT+12', left: -180, right: -172.5 }
657*f0df9794SNeil Fuller]
658*f0df9794SNeil Fuller
659*f0df9794SNeil Fullerif (includedZones.length > 0) {
660*f0df9794SNeil Fuller  oceanZones = oceanZones.filter(oceanZone => includedZones.indexOf(oceanZone) > -1)
661*f0df9794SNeil Fuller}
662*f0df9794SNeil Fullerif (excludedZones.length > 0) {
663*f0df9794SNeil Fuller  oceanZones = oceanZones.filter(oceanZone => excludedZones.indexOf(oceanZone) === -1)
664*f0df9794SNeil Fuller}
665*f0df9794SNeil Fuller
666*f0df9794SNeil Fullervar addOceans = function (callback) {
667*f0df9794SNeil Fuller  console.log('adding ocean boundaries')
668*f0df9794SNeil Fuller  const zones = Object.keys(zoneCfg)
669*f0df9794SNeil Fuller
670*f0df9794SNeil Fuller  const oceanProgress = new ProgressStats(
671*f0df9794SNeil Fuller    'Oceans',
672*f0df9794SNeil Fuller    oceanZones.length
673*f0df9794SNeil Fuller  )
674*f0df9794SNeil Fuller
675*f0df9794SNeil Fuller  oceanZoneBoundaries = oceanZones.map(zone => {
676*f0df9794SNeil Fuller    oceanProgress.beginTask(zone.tzid, true)
677*f0df9794SNeil Fuller    const geoJson = polygon([[
678*f0df9794SNeil Fuller      [zone.left, 90],
679*f0df9794SNeil Fuller      [zone.left, -90],
680*f0df9794SNeil Fuller      [zone.right, -90],
681*f0df9794SNeil Fuller      [zone.right, 90],
682*f0df9794SNeil Fuller      [zone.left, 90]
683*f0df9794SNeil Fuller    ]]).geometry
684*f0df9794SNeil Fuller
685*f0df9794SNeil Fuller    let geom = geoJsonToGeom(geoJson)
686*f0df9794SNeil Fuller
687*f0df9794SNeil Fuller    // diff against every zone
688*f0df9794SNeil Fuller    zones.forEach(distZone => {
689*f0df9794SNeil Fuller      geom = debugGeo('diff', geom, getDistZoneGeom(distZone))
690*f0df9794SNeil Fuller    })
691*f0df9794SNeil Fuller
692*f0df9794SNeil Fuller    return {
693*f0df9794SNeil Fuller      geom: postProcessZone(geom, true),
694*f0df9794SNeil Fuller      tzid: zone.tzid
695*f0df9794SNeil Fuller    }
696*f0df9794SNeil Fuller  })
697*f0df9794SNeil Fuller
698*f0df9794SNeil Fuller  callback()
699*f0df9794SNeil Fuller}
700*f0df9794SNeil Fuller
701*f0df9794SNeil Fullervar combineAndWriteZones = function (callback) {
702*f0df9794SNeil Fuller  const regularWriter = new FeatureWriterStream(distDir + '/combined.json')
703*f0df9794SNeil Fuller  const oceanWriter = new FeatureWriterStream(distDir + '/combined-with-oceans.json')
704*f0df9794SNeil Fuller  var zones = Object.keys(zoneCfg)
705*f0df9794SNeil Fuller
706*f0df9794SNeil Fuller  zones.forEach(zoneName => {
707*f0df9794SNeil Fuller    const feature = {
708*f0df9794SNeil Fuller      type: 'Feature',
709*f0df9794SNeil Fuller      properties: { tzid: zoneName },
710*f0df9794SNeil Fuller      geometry: geomToGeoJson(getDistZoneGeom(zoneName))
711*f0df9794SNeil Fuller    }
712*f0df9794SNeil Fuller    const stringified = JSON.stringify(feature)
713*f0df9794SNeil Fuller    regularWriter.add(stringified)
714*f0df9794SNeil Fuller    oceanWriter.add(stringified)
715*f0df9794SNeil Fuller  })
716*f0df9794SNeil Fuller  oceanZoneBoundaries.forEach(boundary => {
717*f0df9794SNeil Fuller    var feature = {
718*f0df9794SNeil Fuller      type: 'Feature',
719*f0df9794SNeil Fuller      properties: { tzid: boundary.tzid },
720*f0df9794SNeil Fuller      geometry: boundary.geom
721*f0df9794SNeil Fuller    }
722*f0df9794SNeil Fuller    oceanWriter.add(JSON.stringify(feature))
723*f0df9794SNeil Fuller  })
724*f0df9794SNeil Fuller  asynclib.parallel([
725*f0df9794SNeil Fuller    cb => regularWriter.end(cb),
726*f0df9794SNeil Fuller    cb => oceanWriter.end(cb)
727*f0df9794SNeil Fuller  ], callback)
728*f0df9794SNeil Fuller}
729*f0df9794SNeil Fuller
730*f0df9794SNeil Fullervar downloadLastRelease = function (cb) {
731*f0df9794SNeil Fuller  // download latest release info
732*f0df9794SNeil Fuller  https.get(
733*f0df9794SNeil Fuller    {
734*f0df9794SNeil Fuller      headers: { 'user-agent': 'timezone-boundary-builder' },
735*f0df9794SNeil Fuller      host: 'api.github.com',
736*f0df9794SNeil Fuller      path: '/repos/evansiroky/timezone-boundary-builder/releases/latest'
737*f0df9794SNeil Fuller    },
738*f0df9794SNeil Fuller    function (res) {
739*f0df9794SNeil Fuller      var data = ''
740*f0df9794SNeil Fuller      res.on('data', function (chunk) {
741*f0df9794SNeil Fuller        data += chunk
742*f0df9794SNeil Fuller      })
743*f0df9794SNeil Fuller      res.on('end', function () {
744*f0df9794SNeil Fuller        data = JSON.parse(data)
745*f0df9794SNeil Fuller        // determine last release version name and download link
746*f0df9794SNeil Fuller        const lastReleaseName = data.name
747*f0df9794SNeil Fuller        lastReleaseJSONfile = `./dist/${lastReleaseName}.json`
748*f0df9794SNeil Fuller        let lastReleaseDownloadUrl
749*f0df9794SNeil Fuller        for (var i = 0; i < data.assets.length; i++) {
750*f0df9794SNeil Fuller          if (data.assets[i].browser_download_url.indexOf('timezones-with-oceans.geojson') > -1) {
751*f0df9794SNeil Fuller            lastReleaseDownloadUrl = data.assets[i].browser_download_url
752*f0df9794SNeil Fuller          }
753*f0df9794SNeil Fuller        }
754*f0df9794SNeil Fuller        if (!lastReleaseDownloadUrl) {
755*f0df9794SNeil Fuller          return cb(new Error('geojson not found'))
756*f0df9794SNeil Fuller        }
757*f0df9794SNeil Fuller
758*f0df9794SNeil Fuller        // check for file that got downloaded
759*f0df9794SNeil Fuller        fs.stat(lastReleaseJSONfile, function (err) {
760*f0df9794SNeil Fuller          if (!err) {
761*f0df9794SNeil Fuller            // file found, skip download steps
762*f0df9794SNeil Fuller            return cb()
763*f0df9794SNeil Fuller          }
764*f0df9794SNeil Fuller          // file not found, download
765*f0df9794SNeil Fuller          console.log(`Downloading latest release to ${lastReleaseJSONfile}.zip`)
766*f0df9794SNeil Fuller          https.get({
767*f0df9794SNeil Fuller            headers: { 'user-agent': 'timezone-boundary-builder' },
768*f0df9794SNeil Fuller            host: 'github.com',
769*f0df9794SNeil Fuller            path: lastReleaseDownloadUrl.replace('https://github.com', '')
770*f0df9794SNeil Fuller          }, function (response) {
771*f0df9794SNeil Fuller            var file = fs.createWriteStream(`${lastReleaseJSONfile}.zip`)
772*f0df9794SNeil Fuller            response.pipe(file)
773*f0df9794SNeil Fuller            file.on('finish', function () {
774*f0df9794SNeil Fuller              file.close((err) => {
775*f0df9794SNeil Fuller                if (err) return cb(err)
776*f0df9794SNeil Fuller                // unzip file
777*f0df9794SNeil Fuller                console.log('unzipping latest release')
778*f0df9794SNeil Fuller                exec(
779*f0df9794SNeil Fuller                  `unzip -o ${lastReleaseJSONfile} -d dist`,
780*f0df9794SNeil Fuller                  err => {
781*f0df9794SNeil Fuller                    if (err) { return cb(err) }
782*f0df9794SNeil Fuller                    console.log('unzipped file')
783*f0df9794SNeil Fuller                    console.log('moving unzipped file')
784*f0df9794SNeil Fuller                    // might need to change this after changes to how files are
785*f0df9794SNeil Fuller                    // zipped after 2020a
786*f0df9794SNeil Fuller                    fs.copyFile(
787*f0df9794SNeil Fuller                      path.join(
788*f0df9794SNeil Fuller                        'dist',
789*f0df9794SNeil Fuller                        'dist',
790*f0df9794SNeil Fuller                        'combined-with-oceans.json'
791*f0df9794SNeil Fuller                      ),
792*f0df9794SNeil Fuller                      lastReleaseJSONfile,
793*f0df9794SNeil Fuller                      cb
794*f0df9794SNeil Fuller                    )
795*f0df9794SNeil Fuller                  }
796*f0df9794SNeil Fuller                )
797*f0df9794SNeil Fuller              })
798*f0df9794SNeil Fuller            })
799*f0df9794SNeil Fuller          }).on('error', cb)
800*f0df9794SNeil Fuller        })
801*f0df9794SNeil Fuller      })
802*f0df9794SNeil Fuller    }
803*f0df9794SNeil Fuller  )
804*f0df9794SNeil Fuller}
805*f0df9794SNeil Fuller
806*f0df9794SNeil Fullervar analyzeChangesFromLastRelease = function (cb) {
807*f0df9794SNeil Fuller  // load last release data into memory
808*f0df9794SNeil Fuller  console.log('loading previous release into memory')
809*f0df9794SNeil Fuller  const lastReleaseData = require(lastReleaseJSONfile)
810*f0df9794SNeil Fuller
811*f0df9794SNeil Fuller  // load each feature's geojson into JSTS format and then organized by tzid
812*f0df9794SNeil Fuller  const lastReleaseZones = {}
813*f0df9794SNeil Fuller  lastReleaseData.features.forEach(
814*f0df9794SNeil Fuller    feature => {
815*f0df9794SNeil Fuller      lastReleaseZones[feature.properties.tzid] = feature
816*f0df9794SNeil Fuller    }
817*f0df9794SNeil Fuller  )
818*f0df9794SNeil Fuller
819*f0df9794SNeil Fuller  // generate set of keys from last release and current
820*f0df9794SNeil Fuller  const zoneNames = new Set()
821*f0df9794SNeil Fuller  Object.keys(distZones).forEach(zoneName => zoneNames.add(zoneName))
822*f0df9794SNeil Fuller  Object.keys(lastReleaseZones).forEach(zoneName => zoneNames.add(zoneName))
823*f0df9794SNeil Fuller
824*f0df9794SNeil Fuller  // create diff for each zone
825*f0df9794SNeil Fuller  const analysisProgress = new ProgressStats(
826*f0df9794SNeil Fuller    'Analyzing diffs',
827*f0df9794SNeil Fuller    zoneNames.size
828*f0df9794SNeil Fuller  )
829*f0df9794SNeil Fuller  const additionsWriter = new FeatureWriterStream(distDir + '/additions.json')
830*f0df9794SNeil Fuller  const removalsWriter = new FeatureWriterStream(distDir + '/removals.json')
831*f0df9794SNeil Fuller  zoneNames.forEach(zoneName => {
832*f0df9794SNeil Fuller    analysisProgress.beginTask(zoneName, true)
833*f0df9794SNeil Fuller    if (distZones[zoneName] && lastReleaseZones[zoneName]) {
834*f0df9794SNeil Fuller      // some zones take forever to diff unless they are buffered, so buffer by
835*f0df9794SNeil Fuller      // just a small amount
836*f0df9794SNeil Fuller      const lastReleaseGeom = geoJsonToGeom(
837*f0df9794SNeil Fuller        lastReleaseZones[zoneName].geometry
838*f0df9794SNeil Fuller      ).buffer(bufferDistance)
839*f0df9794SNeil Fuller      const curDataGeom = getDistZoneGeom(zoneName).buffer(bufferDistance)
840*f0df9794SNeil Fuller
841*f0df9794SNeil Fuller      // don't diff equal geometries
842*f0df9794SNeil Fuller      if (curDataGeom.equals(lastReleaseGeom)) return
843*f0df9794SNeil Fuller
844*f0df9794SNeil Fuller      // diff current - last = additions
845*f0df9794SNeil Fuller      const addition = debugGeo(
846*f0df9794SNeil Fuller        'diff',
847*f0df9794SNeil Fuller        curDataGeom,
848*f0df9794SNeil Fuller        lastReleaseGeom,
849*f0df9794SNeil Fuller        false,
850*f0df9794SNeil Fuller        true
851*f0df9794SNeil Fuller      )
852*f0df9794SNeil Fuller      if (addition.getArea() > 0.0001) {
853*f0df9794SNeil Fuller        additionsWriter.add(JSON.stringify({
854*f0df9794SNeil Fuller          type: 'Feature',
855*f0df9794SNeil Fuller          properties: { tzid: zoneName },
856*f0df9794SNeil Fuller          geometry: geomToGeoJson(addition)
857*f0df9794SNeil Fuller        }))
858*f0df9794SNeil Fuller      }
859*f0df9794SNeil Fuller
860*f0df9794SNeil Fuller      // diff last - current = removals
861*f0df9794SNeil Fuller      const removal = debugGeo(
862*f0df9794SNeil Fuller        'diff',
863*f0df9794SNeil Fuller        lastReleaseGeom,
864*f0df9794SNeil Fuller        curDataGeom,
865*f0df9794SNeil Fuller        false,
866*f0df9794SNeil Fuller        true
867*f0df9794SNeil Fuller      )
868*f0df9794SNeil Fuller      if (removal.getArea() > 0.0001) {
869*f0df9794SNeil Fuller        removalsWriter.add(JSON.stringify({
870*f0df9794SNeil Fuller          type: 'Feature',
871*f0df9794SNeil Fuller          properties: { tzid: zoneName },
872*f0df9794SNeil Fuller          geometry: geomToGeoJson(removal)
873*f0df9794SNeil Fuller        }))
874*f0df9794SNeil Fuller      }
875*f0df9794SNeil Fuller    } else if (distZones[zoneName]) {
876*f0df9794SNeil Fuller      additionsWriter.add(JSON.stringify({
877*f0df9794SNeil Fuller        type: 'Feature',
878*f0df9794SNeil Fuller        properties: { tzid: zoneName },
879*f0df9794SNeil Fuller        geometry: geomToGeoJson(getDistZoneGeom(zoneName))
880*f0df9794SNeil Fuller      }))
881*f0df9794SNeil Fuller    } else {
882*f0df9794SNeil Fuller      removalsWriter.add(JSON.stringify(lastReleaseZones[zoneName]))
883*f0df9794SNeil Fuller    }
884*f0df9794SNeil Fuller  })
885*f0df9794SNeil Fuller
886*f0df9794SNeil Fuller  // write files
887*f0df9794SNeil Fuller  asynclib.parallel([
888*f0df9794SNeil Fuller    wcb => additionsWriter.end(wcb),
889*f0df9794SNeil Fuller    wcb => removalsWriter.end(wcb)
890*f0df9794SNeil Fuller  ], cb)
891*f0df9794SNeil Fuller}
892*f0df9794SNeil Fuller
893*f0df9794SNeil Fullerconst autoScript = {
894*f0df9794SNeil Fuller  makeDownloadsDir: function (cb) {
895*f0df9794SNeil Fuller    overallProgress.beginTask('Creating downloads dir')
896*f0df9794SNeil Fuller    safeMkdir(downloadsDir, cb)
897*f0df9794SNeil Fuller  },
898*f0df9794SNeil Fuller  makeDistDir: function (cb) {
899*f0df9794SNeil Fuller    overallProgress.beginTask('Creating dist dir')
900*f0df9794SNeil Fuller    safeMkdir(distDir, cb)
901*f0df9794SNeil Fuller  },
902*f0df9794SNeil Fuller  getOsmBoundaries: ['makeDownloadsDir', function (results, cb) {
903*f0df9794SNeil Fuller    overallProgress.beginTask('Downloading osm boundaries')
904*f0df9794SNeil Fuller    asynclib.eachSeries(Object.keys(osmBoundarySources), downloadOsmBoundary, cb)
905*f0df9794SNeil Fuller  }],
906*f0df9794SNeil Fuller  cleanDownloadFolder: ['makeDistDir', 'getOsmBoundaries', function (results, cb) {
907*f0df9794SNeil Fuller    overallProgress.beginTask('cleanDownloadFolder')
908*f0df9794SNeil Fuller    const downloadedFilenames = Object.keys(osmBoundarySources).map(name => `${name}.json`)
909*f0df9794SNeil Fuller    fs.readdir(downloadsDir, (err, files) => {
910*f0df9794SNeil Fuller      if (err) return cb(err)
911*f0df9794SNeil Fuller      asynclib.each(
912*f0df9794SNeil Fuller        files,
913*f0df9794SNeil Fuller        (file, fileCb) => {
914*f0df9794SNeil Fuller          if (downloadedFilenames.indexOf(file) === -1) {
915*f0df9794SNeil Fuller            return fs.unlink(path.join(downloadsDir, file), fileCb)
916*f0df9794SNeil Fuller          }
917*f0df9794SNeil Fuller          fileCb()
918*f0df9794SNeil Fuller        },
919*f0df9794SNeil Fuller        cb
920*f0df9794SNeil Fuller      )
921*f0df9794SNeil Fuller    })
922*f0df9794SNeil Fuller  }],
923*f0df9794SNeil Fuller  zipInputData: ['cleanDownloadFolder', function (results, cb) {
924*f0df9794SNeil Fuller    overallProgress.beginTask('Zipping up input data')
925*f0df9794SNeil Fuller    exec('zip -j ' + distDir + '/input-data.zip ' + downloadsDir +
926*f0df9794SNeil Fuller         '/* timezones.json osmBoundarySources.json expectedZoneOverlaps.json', cb)
927*f0df9794SNeil Fuller  }],
928*f0df9794SNeil Fuller  downloadLastRelease: ['makeDistDir', function (results, cb) {
929*f0df9794SNeil Fuller    if (argv.skip_analyze_diffs) {
930*f0df9794SNeil Fuller      overallProgress.beginTask('WARNING: Skipping download of last release for analysis!')
931*f0df9794SNeil Fuller      cb()
932*f0df9794SNeil Fuller    } else {
933*f0df9794SNeil Fuller      overallProgress.beginTask('Downloading last release for analysis')
934*f0df9794SNeil Fuller      downloadLastRelease(cb)
935*f0df9794SNeil Fuller    }
936*f0df9794SNeil Fuller  }],
937*f0df9794SNeil Fuller  createZones: ['makeDistDir', 'getOsmBoundaries', function (results, cb) {
938*f0df9794SNeil Fuller    overallProgress.beginTask('Creating timezone boundaries')
939*f0df9794SNeil Fuller    asynclib.each(Object.keys(zoneCfg), makeTimezoneBoundary, cb)
940*f0df9794SNeil Fuller  }],
941*f0df9794SNeil Fuller  validateZones: ['createZones', function (results, cb) {
942*f0df9794SNeil Fuller    overallProgress.beginTask('Validating timezone boundaries')
943*f0df9794SNeil Fuller    loadDistZonesIntoMemory()
944*f0df9794SNeil Fuller    if (argv.skip_validation) {
945*f0df9794SNeil Fuller      console.warn('WARNING: Skipping validation!')
946*f0df9794SNeil Fuller      cb()
947*f0df9794SNeil Fuller    } else {
948*f0df9794SNeil Fuller      cb(validateTimezoneBoundaries())
949*f0df9794SNeil Fuller    }
950*f0df9794SNeil Fuller  }],
951*f0df9794SNeil Fuller  addOceans: ['validateZones', function (results, cb) {
952*f0df9794SNeil Fuller    overallProgress.beginTask('Adding oceans')
953*f0df9794SNeil Fuller    addOceans(cb)
954*f0df9794SNeil Fuller  }],
955*f0df9794SNeil Fuller  mergeZones: ['addOceans', function (results, cb) {
956*f0df9794SNeil Fuller    overallProgress.beginTask('Merging zones')
957*f0df9794SNeil Fuller    combineAndWriteZones(cb)
958*f0df9794SNeil Fuller  }],
959*f0df9794SNeil Fuller  zipGeoJson: ['mergeZones', function (results, cb) {
960*f0df9794SNeil Fuller    if (argv.skip_zip) {
961*f0df9794SNeil Fuller      overallProgress.beginTask('Skipping zip')
962*f0df9794SNeil Fuller      return cb()
963*f0df9794SNeil Fuller    }
964*f0df9794SNeil Fuller    overallProgress.beginTask('Zipping geojson')
965*f0df9794SNeil Fuller    const zipFile = distDir + '/timezones.geojson.zip'
966*f0df9794SNeil Fuller    const jsonFile = distDir + '/combined.json'
967*f0df9794SNeil Fuller    exec('zip -j ' + zipFile + ' ' + jsonFile, cb)
968*f0df9794SNeil Fuller  }],
969*f0df9794SNeil Fuller  zipGeoJsonWithOceans: ['mergeZones', function (results, cb) {
970*f0df9794SNeil Fuller    if (argv.skip_zip) {
971*f0df9794SNeil Fuller      overallProgress.beginTask('Skipping with oceans zip')
972*f0df9794SNeil Fuller      return cb()
973*f0df9794SNeil Fuller    }
974*f0df9794SNeil Fuller    overallProgress.beginTask('Zipping geojson with oceans')
975*f0df9794SNeil Fuller    const zipFile = distDir + '/timezones-with-oceans.geojson.zip'
976*f0df9794SNeil Fuller    const jsonFile = distDir + '/combined-with-oceans.json'
977*f0df9794SNeil Fuller    exec('zip -j ' + zipFile + ' ' + jsonFile, cb)
978*f0df9794SNeil Fuller  }],
979*f0df9794SNeil Fuller  makeShapefile: ['mergeZones', function (results, cb) {
980*f0df9794SNeil Fuller    if (argv.skip_shapefile) {
981*f0df9794SNeil Fuller      overallProgress.beginTask('Skipping shapefile creation')
982*f0df9794SNeil Fuller      return cb()
983*f0df9794SNeil Fuller    }
984*f0df9794SNeil Fuller    overallProgress.beginTask('Converting from geojson to shapefile')
985*f0df9794SNeil Fuller    const shapeFileGlob = distDir + '/combined-shapefile.*'
986*f0df9794SNeil Fuller    rimraf.sync(shapeFileGlob)
987*f0df9794SNeil Fuller    const shapeFile = distDir + '/combined-shapefile.shp'
988*f0df9794SNeil Fuller    const jsonFile = distDir + '/combined.json'
989*f0df9794SNeil Fuller    exec(
990*f0df9794SNeil Fuller      'ogr2ogr -f "ESRI Shapefile" ' + shapeFile + ' ' + jsonFile,
991*f0df9794SNeil Fuller      function (err, stdout, stderr) {
992*f0df9794SNeil Fuller        if (err) { return cb(err) }
993*f0df9794SNeil Fuller        const shapeFileZip = distDir + '/timezones.shapefile.zip'
994*f0df9794SNeil Fuller        exec('zip -j ' + shapeFileZip + ' ' + shapeFileGlob, cb)
995*f0df9794SNeil Fuller      }
996*f0df9794SNeil Fuller    )
997*f0df9794SNeil Fuller  }],
998*f0df9794SNeil Fuller  makeShapefileWithOceans: ['mergeZones', function (results, cb) {
999*f0df9794SNeil Fuller    if (argv.skip_shapefile) {
1000*f0df9794SNeil Fuller      overallProgress.beginTask('Skipping with oceans shapefile creation')
1001*f0df9794SNeil Fuller      return cb()
1002*f0df9794SNeil Fuller    }
1003*f0df9794SNeil Fuller    overallProgress.beginTask('Converting from geojson with oceans to shapefile')
1004*f0df9794SNeil Fuller    const shapeFileGlob = distDir + '/combined-shapefile-with-oceans.*'
1005*f0df9794SNeil Fuller    rimraf.sync(shapeFileGlob)
1006*f0df9794SNeil Fuller    const shapeFile = distDir + '/combined-shapefile-with-oceans.shp'
1007*f0df9794SNeil Fuller    const jsonFile = distDir + '/combined-with-oceans.json'
1008*f0df9794SNeil Fuller    exec(
1009*f0df9794SNeil Fuller      'ogr2ogr -f "ESRI Shapefile" ' + shapeFile + ' ' + jsonFile,
1010*f0df9794SNeil Fuller      function (err, stdout, stderr) {
1011*f0df9794SNeil Fuller        if (err) { return cb(err) }
1012*f0df9794SNeil Fuller        const shapeFileZip = distDir + '/timezones-with-oceans.shapefile.zip'
1013*f0df9794SNeil Fuller        exec('zip -j ' + shapeFileZip + ' ' + shapeFileGlob, cb)
1014*f0df9794SNeil Fuller      }
1015*f0df9794SNeil Fuller    )
1016*f0df9794SNeil Fuller  }],
1017*f0df9794SNeil Fuller  makeListOfTimeZoneNames: function (cb) {
1018*f0df9794SNeil Fuller    overallProgress.beginTask('Writing timezone names to file')
1019*f0df9794SNeil Fuller    let zoneNames = Object.keys(zoneCfg)
1020*f0df9794SNeil Fuller    oceanZones.forEach(oceanZone => {
1021*f0df9794SNeil Fuller      zoneNames.push(oceanZone.tzid)
1022*f0df9794SNeil Fuller    })
1023*f0df9794SNeil Fuller    if (includedZones.length > 0) {
1024*f0df9794SNeil Fuller      zoneNames = zoneNames.filter(zoneName => includedZones.indexOf(zoneName) > -1)
1025*f0df9794SNeil Fuller    }
1026*f0df9794SNeil Fuller    if (excludedZones.length > 0) {
1027*f0df9794SNeil Fuller      zoneNames = zoneNames.filter(zoneName => excludedZones.indexOf(zoneName) === -1)
1028*f0df9794SNeil Fuller    }
1029*f0df9794SNeil Fuller    fs.writeFile(
1030*f0df9794SNeil Fuller      distDir + '/timezone-names.json',
1031*f0df9794SNeil Fuller      JSON.stringify(zoneNames),
1032*f0df9794SNeil Fuller      cb
1033*f0df9794SNeil Fuller    )
1034*f0df9794SNeil Fuller  },
1035*f0df9794SNeil Fuller  analyzeChangesFromLastRelease: ['downloadLastRelease', 'mergeZones', function (results, cb) {
1036*f0df9794SNeil Fuller    if (argv.skip_analyze_diffs) {
1037*f0df9794SNeil Fuller      overallProgress.beginTask('WARNING: Skipping analysis of changes from last release!')
1038*f0df9794SNeil Fuller      cb()
1039*f0df9794SNeil Fuller    } else {
1040*f0df9794SNeil Fuller      overallProgress.beginTask('Analyzing changes from last release')
1041*f0df9794SNeil Fuller      analyzeChangesFromLastRelease(cb)
1042*f0df9794SNeil Fuller    }
1043*f0df9794SNeil Fuller  }]
1044*f0df9794SNeil Fuller}
1045*f0df9794SNeil Fuller
1046*f0df9794SNeil Fullerconst overallProgress = new ProgressStats('Overall', Object.keys(autoScript).length)
1047*f0df9794SNeil Fuller
1048*f0df9794SNeil Fullerasynclib.auto(autoScript, function (err, results) {
1049*f0df9794SNeil Fuller  console.log('done')
1050*f0df9794SNeil Fuller  if (err) {
1051*f0df9794SNeil Fuller    console.log('error!', err)
1052*f0df9794SNeil Fuller  }
1053*f0df9794SNeil Fuller})
1054