diff --git a/.travis.yml b/.travis.yml index fe3178d9..a94b4246 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,11 +2,11 @@ sudo: false language: node_js node_js: + - "10" + - "9" - "8" - "7" - "6" - - "5" - - "4" env: - COVERAGE=false @@ -14,18 +14,18 @@ env: matrix: fast_finish: true include: - - node_js: "4" + - node_js: "6" env: COVERAGE=true script: "npm run codeclimate" allow_failures: - - node_js: "4" + - node_js: "6" env: COVERAGE=true script: "npm run codeclimate" before_script: - npm prune after_success: - - npm run semantic-release + - travis-deploy-once "npm run semantic-release" branches: except: - /^v\d+\.\d+\.\d+$/ diff --git a/README.md b/README.md index 4ee30de2..d5ccc3fd 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,29 @@ The most up-to-date and accurate node.js geographical timezone lookup package. ## Usage +```javascript var geoTz = require('geo-tz') - geoTz(47.650499, -122.350070) // 'America/Los_Angeles' + geoTz.preCache() // optionally load all features into memory + geoTz(47.650499, -122.350070) // ['America/Los_Angeles'] + geoTz(43.839319, 87.526148) // ['Asia/Shanghai', 'Asia/Urumqi'] +``` ## API Docs: -As of Version 4, there is now only one API call and no dependency on moment-timezone. +As of Version 5, the API now returns a list of possible timezones as there are certain coordinates where the timekeeping method will depend on the person you ask. ### geoTz(lat, lon) -Returns the timezone name found at `lat`, `lon`. The timezone name will be a timezone identifier as defined in the [timezone database](https://www.iana.org/time-zones). The underlying geographic data is obtained from the [timezone-boudary-builder](https://github.com/evansiroky/timezone-boundary-builder) project. +Returns the timezone names found at `lat`, `lon`. The timezone names will be the timezone identifiers as defined in the [timezone database](https://www.iana.org/time-zones). The underlying geographic data is obtained from the [timezone-boudary-builder](https://github.com/evansiroky/timezone-boundary-builder) project. -This library does an exact geographic lookup which has tradeoffs. It is perhaps a little bit slower that other libraries, has a large installation size on disk and cannot be used in the browser. However, the results are more accurate than other libraries that compromise by approximating the lookup of the data. +This library does an exact geographic lookup which has tradeoffs. It is perhaps a little bit slower that other libraries, has a larger installation size on disk and cannot be used in the browser. However, the results are more accurate than other libraries that compromise by approximating the lookup of the data. -The data is indexed for fast analysis with automatic caching (with time expiration) of subregions of geographic data for when a precise lookup is needed. +The data is indexed for fast analysis with automatic caching with time expiration (or optional an unexpiring cache of the whole world) of subregions of geographic data for when a precise lookup is needed. + +### geoTz.preCache() + +Loads all geographic features into memory in an unexpiring cache. This has tradeoffs. More memory will be consumed and it will take a little longer before the program is ready to start looking up features, but future lookups will be a lot faster - especially for areas which haven't had a lookup in a while. ## An Important Note About Maintenance diff --git a/data.zip b/data.zip index 5d1818fe..3b5fd7ac 100644 Binary files a/data.zip and b/data.zip differ diff --git a/lib/find.js b/lib/find.js index 46b3ce88..2feafe9f 100644 --- a/lib/find.js +++ b/lib/find.js @@ -1,22 +1,48 @@ var fs = require('fs') +var path = require('path') var geobuf = require('geobuf') var inside = require('@turf/boolean-point-in-polygon').default -var Cache = require( "timed-cache" ) +var Cache = require('timed-cache') var Pbf = require('pbf') var point = require('@turf/helpers').point var tzData = require('../data/index.json') -const featureCache = new Cache() +let featureCache = new Cache() -var loadFeatures = function(quadPos) { +/** + * A function that will load all features into an unexpiring cache + */ +var preCache = function () { + const _eternalCache = {} + featureCache = { + get: (quadPos) => _eternalCache[quadPos] + } + + // shoutout to github user @magwo for an initial version of this recursive function + var preloadFeaturesRecursive = function (curTzData, quadPos) { + if (curTzData === 'f') { + var geoJson = loadFeatures(quadPos) + _eternalCache[quadPos] = geoJson + } else if (typeof curTzData === 'object') { + Object.getOwnPropertyNames(curTzData).forEach(function (value) { + preloadFeaturesRecursive(curTzData[value], quadPos + value) + }) + } + } + preloadFeaturesRecursive(tzData.lookup, '') +} + +var loadFeatures = function (quadPos) { // exact boundaries saved in file // parse geojson for exact boundaries var filepath = quadPos.split('').join('/') - var data = new Pbf(fs.readFileSync(__dirname + '/../data/' + filepath + '/geo.buf')) + var data = new Pbf(fs.readFileSync( + path.join(__dirname, '/../data/', filepath, '/geo.buf')) + ) var geoJson = geobuf.decode(data) - return geoJson; + return geoJson } const oceanZones = [ @@ -137,17 +163,22 @@ var getTimezone = function (lat, lon) { featureCache.put(quadPos, geoJson) } + var timezonesContainingPoint = [] + for (var i = 0; i < geoJson.features.length; i++) { if (inside(pt, geoJson.features[i])) { - return geoJson.features[i].properties.tzid + timezonesContainingPoint.push(geoJson.features[i].properties.tzid) } } - // not within subarea, therefore must be timezone at sea - return getTimezoneAtSea(lon) - } else if (typeof curTzData === 'number') { + // if at least one timezone contained the point, return those timezones, + // otherwise must be timezone at sea + return timezonesContainingPoint.length > 0 + ? timezonesContainingPoint + : getTimezoneAtSea(lon) + } else if (curTzData.length > 0) { // exact match found - return tzData.timezones[curTzData] + return curTzData.map(idx => tzData.timezones[idx]) } else if (typeof curTzData !== 'object') { // not another nested quad index, throw error err = new Error('Unexpected data type') @@ -161,3 +192,4 @@ var getTimezone = function (lat, lon) { } module.exports = getTimezone +module.exports.preCache = preCache diff --git a/lib/geo-index.js b/lib/geo-index.js index 4f0c172d..1679b4fa 100644 --- a/lib/geo-index.js +++ b/lib/geo-index.js @@ -13,39 +13,6 @@ var polygon = helpers.polygon var geoJsonReader = new jsts.io.GeoJSONReader() var geoJsonWriter = new jsts.io.GeoJSONWriter() -var within = function (outer, inner) { - var a = geoJsonReader.read(JSON.stringify(outer)) - var b = geoJsonReader.read(JSON.stringify(inner)) - - return a.contains(b) -} - -var intersects = function (a, b) { - var _a = geoJsonReader.read(JSON.stringify(a)) - var _b = geoJsonReader.read(JSON.stringify(b)) - - return _a.intersects(_b) -} - -// copied and modified from turf-intersect -var intersection = function (a, b) { - var _a = geoJsonReader.read(JSON.stringify(a)) - var _b = geoJsonReader.read(JSON.stringify(b)) - - var result = _a.intersection(_b) - result = geoJsonWriter.write(result) - - if (result.type === 'GeometryCollection' && result.geometries.length === 0) { - return undefined - } else { - return { - type: 'Feature', - properties: {}, - geometry: result - } - } -} - module.exports = function (tzGeojson, dataDir, targetIndexPercent, callback) { console.log('indexing') @@ -54,37 +21,96 @@ module.exports = function (tzGeojson, dataDir, targetIndexPercent, callback) { lookup: {} } - var inspectZones = function (timezonesToInspect, curBoundsGeoJson) { + const timezoneGeometries = tzGeojson.features.map(feature => + geoJsonReader.read(JSON.stringify(feature.geometry)) + ) + + var getIntersectingGeojson = function (tzIdx, curBoundsGeometry) { + // console.log('intersecting', tzGeojson.features[tzIdx].properties) + var intersectedGeometry = timezoneGeometries[tzIdx].intersection(curBoundsGeometry) + var intersectedGeoJson = geoJsonWriter.write(intersectedGeometry) + + if ( + intersectedGeoJson.type === 'GeometryCollection' && + intersectedGeoJson.geometries.length === 0 + ) { + return undefined + } else { + return { + type: 'Feature', + properties: {}, + geometry: intersectedGeoJson + } + } + } + + /** + * Check if certain timezones fall within a specified bounds geometry. + * Also, check if an exact match is found (ie, the bounds are fully contained + * within a particular zone). + * + * @param {Array} timezonesToInspect An array of indexes referencing + * a particular timezone as noted in the tzGeojson.features array. + * @param {Geometry} curBoundsGeometry The geometry to check + */ + var inspectZones = function (timezonesToInspect, curBoundsGeometry) { var intersectedZones = [] - var foundExactMatch = false + var numberOfZonesThatContainBounds = 0 for (var j = timezonesToInspect.length - 1; j >= 0; j--) { var curZoneIdx = timezonesToInspect[j] - var curZoneGeoJson = tzGeojson.features[curZoneIdx].geometry + var curZoneGeometry = timezoneGeometries[curZoneIdx] + + if (curZoneGeometry.intersects(curBoundsGeometry)) { + // bounds and timezone intersect, add to intersected zones + intersectedZones.push(curZoneIdx) - if (intersects(curZoneGeoJson, curBoundsGeoJson)) { - // bounds and timezone intersect // check if tz fully contains bounds - if (within(curZoneGeoJson, curBoundsGeoJson)) { - // bounds fully within tz, note in index - intersectedZones = [curZoneIdx] - foundExactMatch = true - break - } else { - // bounds not fully within tz, add to intersected zones - intersectedZones.push(curZoneIdx) + if (curZoneGeometry.contains(curBoundsGeometry)) { + // bounds fully within tz + numberOfZonesThatContainBounds += 1 } } } - // console.log('found', intersectedZones.length, 'intersecting zones') return { - foundExactMatch: foundExactMatch, - intersectedZones: intersectedZones + intersectedZones, + numberOfZonesThatContainBounds } } var i, j + + // analyze each unindexable area in a queue, otherwise the program may run out + // of memory + var unindexableAreaAnalyzingQueue = async.queue( + function (unindexableData, cb) { + var features = [] + // calculate intersected area for each intersected zone + for (j = unindexableData.intersectedZones.length - 1; j >= 0; j--) { + var tzIdx = unindexableData.intersectedZones[j] + var intersectedGeoJson = getIntersectingGeojson( + tzIdx, + unindexableData.curBoundsGeometry + ) + + if (intersectedGeoJson) { + intersectedGeoJson.properties.tzid = data.timezones[tzIdx] + features.push(intersectedGeoJson) + } + } + + var areaGeoJson = featureCollection(features) + var path = dataDir + '/' + unindexableData.curZone.id.replace(/\./g, '/') + + fileWritingQueue.push( + { folder: path, filename: 'geo.buf', data: areaGeoJson }, + cb + ) + }, + 10 + ) + var fileWritingQueue = async.queue( function (data, cb) { // console.log(data.folder) @@ -132,7 +158,7 @@ module.exports = function (tzGeojson, dataDir, targetIndexPercent, callback) { bounds: [0, -89.9999, 179.9999, 0] } ] - var printMod, curZone, curBounds, curBoundsGeoJson + var printMod, curZone, curBounds, curBoundsGeometry while (curPctIndexed < targetIndexPercent) { var nextZones = [] @@ -150,17 +176,21 @@ module.exports = function (tzGeojson, dataDir, targetIndexPercent, callback) { curZone = curZones[i] curBounds = curZone.bounds - curBoundsGeoJson = polygon( - [ - [ - [curBounds[0], curBounds[1]], - [curBounds[0], curBounds[3]], - [curBounds[2], curBounds[3]], - [curBounds[2], curBounds[1]], - [curBounds[0], curBounds[1]] - ] - ] - ).geometry + curBoundsGeometry = geoJsonReader.read( + JSON.stringify( + polygon( + [ + [ + [curBounds[0], curBounds[1]], + [curBounds[0], curBounds[3]], + [curBounds[2], curBounds[3]], + [curBounds[2], curBounds[1]], + [curBounds[0], curBounds[1]] + ] + ] + ).geometry + ) + ) // calculate intersection with timezone boundaries var timezonesToInspect = [] @@ -175,15 +205,18 @@ module.exports = function (tzGeojson, dataDir, targetIndexPercent, callback) { } } - var result = inspectZones(timezonesToInspect, curBoundsGeoJson) + var result = inspectZones(timezonesToInspect, curBoundsGeometry) var intersectedZones = result.intersectedZones - var foundExactMatch = result.foundExactMatch - var zoneResult = -1 // defaults to no zones found + var numberOfZonesThatContainBounds = result.numberOfZonesThatContainBounds + var zoneResult = -1 // defaults to no zones found // check the results - if (intersectedZones.length === 1 && foundExactMatch) { - // analysis zone can fit completely within timezone - zoneResult = intersectedZones[0] + if ( + intersectedZones.length === numberOfZonesThatContainBounds && + numberOfZonesThatContainBounds > 0 + ) { + // analysis zones can fit completely within current quad + zoneResult = intersectedZones } else if (intersectedZones.length > 0) { // further analysis needed var topRight = { @@ -271,50 +304,43 @@ module.exports = function (tzGeojson, dataDir, targetIndexPercent, callback) { curZone = curZones[i] curBounds = curZone.bounds - curBoundsGeoJson = polygon( - [ - [ - [curBounds[0], curBounds[1]], - [curBounds[0], curBounds[3]], - [curBounds[2], curBounds[3]], - [curBounds[2], curBounds[1]], - [curBounds[0], curBounds[1]] - ] - ] - ).geometry + curBoundsGeometry = geoJsonReader.read( + JSON.stringify( + polygon( + [ + [ + [curBounds[0], curBounds[1]], + [curBounds[0], curBounds[3]], + [curBounds[2], curBounds[3]], + [curBounds[2], curBounds[1]], + [curBounds[0], curBounds[1]] + ] + ] + ).geometry + ) + ) // console.log('writing zone data `', curZone.id, '`', i ,'of', curZones.length) - result = inspectZones(curZone.tzs, curBoundsGeoJson) + result = inspectZones(curZone.tzs, curBoundsGeometry) intersectedZones = result.intersectedZones - foundExactMatch = result.foundExactMatch + numberOfZonesThatContainBounds = result.numberOfZonesThatContainBounds // console.log('intersectedZones', intersectedZones.length, 'exact:', foundExactMatch) - zoneResult = -1 // defaults to no zones found + zoneResult = -1 // defaults to no zones found // check the results - if (intersectedZones.length === 1 && foundExactMatch) { - // analysis zone can fit completely within timezone - zoneResult = intersectedZones[0] + if ( + intersectedZones.length === numberOfZonesThatContainBounds && + numberOfZonesThatContainBounds > 0 + ) { + // analysis zones can fit completely within current quad + zoneResult = intersectedZones } else if (intersectedZones.length > 0) { - var features = [] - // calculate intersected area for each intersected zone - for (j = intersectedZones.length - 1; j >= 0; j--) { - var tzIdx = intersectedZones[j] - - // console.log('intersecting', tzGeojson.features[tzIdx].properties) - var intersectedArea = intersection(tzGeojson.features[tzIdx].geometry, curBoundsGeoJson) - - if (intersectedArea) { - intersectedArea.properties.tzid = data.timezones[tzIdx] - features.push(intersectedArea) - } - } - - var areaGeoJson = featureCollection(features) - var path = dataDir + '/' + curZone.id.replace(/\./g, '/') - - fileWritingQueue.push({ folder: path, filename: 'geo.buf', data: areaGeoJson }) - + unindexableAreaAnalyzingQueue.push({ + curBoundsGeometry, + curZone, + intersectedZones + }) zoneResult = 'f' } diff --git a/package.json b/package.json index 525f341b..8ea10d89 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "homepage": "https://github.com/evansiroky/node-geo-tz#readme", "engines": { - "node": ">=4" + "node": ">=6" }, "main": "index.js", "scripts": { @@ -24,7 +24,7 @@ "codeclimate-send": "./node_modules/.bin/codeclimate-test-reporter < coverage/lcov.info", "cover": "./node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha -- tests/**/*.js", "prepublish": "node scripts/unzip-data.js", - "semantic-release": "semantic-release pre && npm publish && semantic-release post", + "semantic-release": "semantic-release", "test": "mocha \"tests/**/*.js\"", "updateTzData": "node scripts/update-data.js" }, @@ -42,7 +42,8 @@ "mocha": "^4.0.0", "nock": "^9.0.9", "rimraf": "^2.6.1", - "semantic-release": "^8.0.0", + "semantic-release": "^15.12.0", + "travis-deploy-once": "^5.0.9", "yauzl": "^2.6.0", "yazl": "^2.4.2" }, diff --git a/tests/find.test.js b/tests/find.test.js index 253f7197..8865d9f7 100644 --- a/tests/find.test.js +++ b/tests/find.test.js @@ -7,52 +7,73 @@ var issueCoords = require('./fixtures/issues.json') process.chdir('/tmp') -describe('find tests', function () { +/** + * Assert that a lookup includes certain timezones + * + * @param {number} lat + * @param {number} lon + * @param {string | array} tzs can be a string or array of timezone names + */ +function assertTzResultContainsTzs (lat, lon, tzs) { + if (typeof tzs === 'string') { + tzs = [tzs] + } + const result = geoTz(lat, lon) + assert.isArray(result) + assert.sameMembers(result, tzs) +} +describe('find tests', function () { it('should find the timezone name for a valid coordinate', function () { - var tz = geoTz(47.650499, -122.350070) - assert.isString(tz) - assert.equal(tz, 'America/Los_Angeles') + assertTzResultContainsTzs(47.650499, -122.350070, 'America/Los_Angeles') }) it('should find the timezone name for a valid coordinate via subfile examination', function () { - var tz = geoTz(1.44, 104.04) - assert.isString(tz) - assert.equal(tz, 'Asia/Singapore') + assertTzResultContainsTzs(1.44, 104.04, 'Asia/Singapore') }) it('should return null timezone name for coordinate in ocean', function () { - var tz = geoTz(0, 0) - assert.equal(tz, 'Etc/GMT') + assertTzResultContainsTzs(0, 0, 'Etc/GMT') }) describe('issue cases', function () { issueCoords.forEach(function (spot) { it('should find ' + spot.zid + ' (' + spot.description + ')', function () { - var tz = geoTz(spot.lat, spot.lon) - assert.isString(tz) - assert.equal(tz, spot.zid) + assertTzResultContainsTzs(spot.lat, spot.lon, spot.zid || spot.zids) }) }) }) - describe('performance aspects', function() { + describe('performance aspects', function () { this.timeout(20000) var europeTopLeft = [56.432158, -11.9263934] var europeBottomRight = [39.8602076, 34.9127951] var count = 2000 - it('should find timezone of ' + count + ' random european positions', function () { + var findRandomPositions = function () { var timingStr = 'find tz of ' + count + ' random european positions' console.time(timingStr) - for(var i=0; i