UNPKG

tz-lookup

Version:
454 lines (392 loc) 13.3 kB
"use strict"; const fs = require("fs"); const COLS = 48; const ROWS = 24; const MIN_DEPTH = 3; // Minimum recursion depth to allow lossy compression. const MAX_DEPTH = 10; // Maximum recursion depth (forcing lossy compression). const EPS = 1e-6; // Epsilon value for floating-point equality checks. // NOTE: This value (~0.01°) is arbitrary and ported from a prior version. It // could easily be tuned smaller or larger if appropriate. const URBAN_HACK_RADIUS = 720/49152; const tz_geojson = require("./dist/combined.json"); const urban_geojson = require("./ne_10m_urban_areas.json"); // Make the geojson files consistent. for(const geojson of [tz_geojson, urban_geojson]) { for(const feature of geojson.features) { // Ensure all features are MultiPolygons. switch(feature.geometry.type) { case "MultiPolygon": break; case "Polygon": feature.geometry.type = "MultiPolygon"; feature.geometry.coordinates = [feature.geometry.coordinates]; break; default: throw new Error("unrecognized type " + type); } // geojson includes duplicate vertices at the beginning and end of each // vertex list, so remove them. (This makes some of the algorithms used, // like clipping and the like, simpler.) for(const polygon of feature.geometry.coordinates) { for(const vertices of polygon) { const first = vertices[0]; const last = vertices[vertices.length - 1]; if(first[0] === last[0] && first[1] === last[1]) { vertices.pop(); } } } // Add properties representing the bounding box of the timezone. let min_lat = 90; let min_lon = 180; let max_lat = -90; let max_lon = -180; for(const [vertices] of feature.geometry.coordinates) { for(const [lon, lat] of vertices) { if(lat < min_lat) { min_lat = lat; } if(lon < min_lon) { min_lon = lon; } if(lat > max_lat) { max_lat = lat; } if(lon > max_lon) { max_lon = lon; } } } feature.properties.min_lat = min_lat; feature.properties.min_lon = min_lon; feature.properties.max_lat = max_lat; feature.properties.max_lon = max_lon; } } // HACK: Add custom urban areas in order to fix reported errors. for(const [lat, lon] of [ [ 36.8381, -84.8500], [ 37.9643, -86.7453], [ 58.3168, -134.4397], [ 36.9147, -111.4558], // fix #7 [ 44.9280, -87.1853], // fix #13 [ 50.7029, -57.3511], // fix #13 [ 29.9414, -85.4064], // fix #14 [ 49.7261, -1.9104], // fix #15 [ 65.5280, 23.5570], // fix #16 [ 35.8722, -84.5250], // fix #18 [ 60.0961, 18.7970], // fix #23 [ 59.9942, 18.7794], // fix #23 [ 59.0500, 15.0412], // fix #23 [ 60.0270, 18.7594], // fix #23 [ 60.0779, 18.8102], // fix #23 [ 60.0239, 18.7625], // fix #23 [ 59.9983, 18.8548], // fix #23 [ 37.3458, -85.3456], // fix #24 [ 46.4547, -90.1711], // fix #25 [ 46.4814, -90.0531], // fix #25 [ 46.4753, -89.9400], // fix #25 [ 46.3661, -89.5969], // fix #25 [ 46.2678, -89.1781], // fix #25 [ 39.6217, -87.4522], // fix #27 [ 39.6631, -87.4307], // fix #27 [ 61.7132, 29.3968], // fix #36 [ 41.6724, -86.5082], // fix #38 [ 27.9881, 86.9253], // Mount Everest [ 47.3525, -102.6214], // Dunn Center, North Dakota [ 19.7500, -88.7000], // fix #40 [ 21.1000, -87.4833], // fix #40 [-31.6750, 128.8831], // fix #45 [54.39447, -6.978715], // fix #47 [54.59160, -7.733996], // fix #47 [54.54842, -7.834725], // fix #47 [54.15168, -7.357061], // fix #47 [54.75062, -7.560825], // fix #47 [54.20568, -6.738351], // fix #47 [54.54842, -7.834724], // fix #47 [ 45.6504, -67.5790], // fix #48 [ 46.4392, -67.7450], // fix #48 [ 45.3238, -116.5487], // fix #51 [-37.3786, 140.8362], // fix #52 [ 44.6972, -67.3955], // fix #53 [ 67.9333, 23.4333], // fix #54 [ 67.8167, 23.1667], // fix #54 [ 68.1375, 23.1447], // fix #56 [ 67.8000, 23.1133], // fix #56 [ 67.9458, 23.6242], // fix #56 [ 68.0168, 23.4515], // fix #56 [ 68.1133, 23.3214], // fix #56 ]) { urban_geojson.features.push({ properties: { min_lat: lat - URBAN_HACK_RADIUS, min_lon: lon - URBAN_HACK_RADIUS, max_lat: lat + URBAN_HACK_RADIUS, max_lon: lon + URBAN_HACK_RADIUS, }, }); } // Build up a tree representing a raster version of the timezone map. function box_overlap(feature, min_lat, min_lon, max_lat, max_lon) { return min_lat <= feature.properties.max_lat && min_lon <= feature.properties.max_lon && max_lat >= feature.properties.min_lat && max_lon >= feature.properties.min_lon; } function clip(polygon, min_lat, min_lon, max_lat, max_lon) { const p = Array.from(polygon); const q = []; let b; b = p[p.length - 1]; for(let i = 0; i < p.length; i++) { const a = b; b = p[i]; if((a[0] >= min_lon) !== (b[0] >= min_lon)) { q.push([min_lon, a[1] + (b[1] - a[1]) * (min_lon - a[0]) / (b[0] - a[0])]); } if(b[0] >= min_lon) { q.push(b); } } p.length = 0; b = q[q.length - 1]; for(let i = 0; i < q.length; i++) { const a = b; b = q[i]; if((a[1] >= min_lat) !== (b[1] >= min_lat)) { p.push([a[0] + (b[0] - a[0]) * (min_lat - a[1]) / (b[1] - a[1]), min_lat]); } if(b[1] >= min_lat) { p.push(b); } } q.length = 0; b = p[p.length - 1]; for(let i = 0; i < p.length; i++) { const a = b; b = p[i]; if((a[0] <= max_lon) !== (b[0] <= max_lon)) { q.push([max_lon, a[1] + (b[1] - a[1]) * (max_lon - a[0]) / (b[0] - a[0])]); } if(b[0] <= max_lon) { q.push(b); } } p.length = 0; b = q[q.length - 1]; for(let i = 0; i < q.length; i++) { const a = b; b = q[i]; if((a[1] <= max_lat) !== (b[1] <= max_lat)) { p.push([a[0] + (b[0] - a[0]) * (max_lat - a[1]) / (b[1] - a[1]), max_lat]); } if(b[1] <= max_lat) { p.push(b); } } return p; } function area(polygon) { let sum = 0; let b = polygon[polygon.length - 1]; for(let i = 0; i < polygon.length; i++) { const a = b; b = polygon[i]; sum += a[0] * b[1] - a[1] * b[0]; } return Math.abs(sum * 0.5); } function polygon_overlap(feature, min_lat, min_lon, max_lat, max_lon) { let total = 0; for(const polygon of feature.geometry.coordinates) { total += area(clip(polygon[0], min_lat, min_lon, max_lat, max_lon)); for(let i = 1; i < polygon.length; i++) { total -= area(clip(polygon[i], min_lat, min_lon, max_lat, max_lon)); } } return total / ((max_lat - min_lat) * (max_lon - min_lon)); } function by_coverage_and_tzid([a, a_coverage], [b, b_coverage]) { const order = b_coverage - a_coverage; if(order !== 0) { return order; } return a.properties.tzid.localeCompare(b.properties.tzid); } function contains_city(min_lat, min_lon, max_lat, max_lon) { for(const feature of urban_geojson.features) { if( box_overlap(feature, min_lat, min_lon, max_lat, max_lon) && ( // HACK: If there's no geometry, it's OK: these were manually added // box-shaped zones and we don't want or need the polygon. feature.geometry === undefined || polygon_overlap(feature, min_lat, min_lon, max_lat, max_lon) >= EPS ) ) { return true; } } return false; } function maritime_zone(lon) { const x = Math.round(12 - (lon + 180) / 15); if(x > 0) { return "Etc/GMT+" + x; } if(x < 0) { return "Etc/GMT" + x; } return "Etc/GMT"; } function tile(candidates, etc_tzid, min_lat, min_lon, max_lat, max_lon, depth) { const mid_lat = min_lat + (max_lat - min_lat) / 2; const mid_lon = min_lon + (max_lon - min_lon) / 2; const subset = []; for(const candidate of candidates) { let overlap = polygon_overlap(candidate, min_lat, min_lon, max_lat, max_lon); if(overlap < EPS) { continue; } subset.push([candidate, overlap]); } // No coverage should not happen? if(subset.length === 0) { return etc_tzid; } // One zone means use it. if(subset.length === 1) { return subset[0][0].properties.tzid; } subset.sort(by_coverage_and_tzid); // If the first zone has max coverage OR we hit the maximum recursion depth // OR this is a rural area that doesn't matter, then we're going to return a // leaf node rather than recurse further. if( subset[0][1] > 1 - EPS || depth >= MAX_DEPTH || (depth >= MIN_DEPTH && !contains_city(min_lat, min_lon, max_lat, max_lon)) ) { const a = subset[0][0].properties.tzid; // If the second zone in the list has nearly the same coverage level as the // first, then sort out how to favor one over the other. // NOTE: We assume that no more than two zones ever conflict! This is true // as of 2018i, but... if(subset[0][1] - subset[1][1] < EPS) { const b = subset[1][0].properties.tzid; // Xinjiang conflict. We select Asia/Urumqi in order to make it clear // that there is, in fact, a conflict. if(a === "Asia/Shanghai" && b === "Asia/Urumqi") { return b; } // Israeli-Palestinian conflict. We select Asia/Hebron in order to make // it clear that there is, in fact, a conflict. if(a === "Asia/Hebron" && b === "Asia/Jerusalem") { return a; } // Sudan-South Sudan conflict. We select Africa/Khartoum arbitrarily and // will tweak it if anyone complains. if(a === "Africa/Juba" && b === "Africa/Khartoum") { return b; } // These are just conflicts that occur due to the resolution of our data. // Resolve them arbitrarily and we'll tweak it if anyone complains. if(a === "Europe/Amsterdam" && b === "Europe/Berlin") { return a; } if(a === "Australia/Sydney" && b === "Australia/Melbourne") { return a; } throw new Error("unresolved zone conflict: " + a + " vs " + b); } // Otherwise, we just care about the top zone. return a; } // No easy way to pick a timezone for this tile. Recurse! const subset_candidates = subset.map(x => x[0]); const child_depth = depth + 1; const children = [ tile(subset_candidates, etc_tzid, mid_lat, min_lon, max_lat, mid_lon, child_depth), tile(subset_candidates, etc_tzid, mid_lat, mid_lon, max_lat, max_lon, child_depth), tile(subset_candidates, etc_tzid, min_lat, min_lon, mid_lat, mid_lon, child_depth), tile(subset_candidates, etc_tzid, min_lat, mid_lon, mid_lat, max_lon, child_depth), ]; // If all the children are leaves, and they're either identical or a maritime // zone, then collapse them up into a single node. if(!Array.isArray(children[0]) && !Array.isArray(children[1]) && !Array.isArray(children[2]) && !Array.isArray(children[3])) { const clean_children = children.filter(x => x !== etc_tzid); if(clean_children.length === 0) { return etc_tzid; } let all_equal = true; for(let i = 1; i < clean_children.length; i++) { if(clean_children[0] !== clean_children[i]) { all_equal = false; break; } } if(all_equal) { return clean_children[0]; } } return children; } const root = new Array(COLS * ROWS); for(let row = 0; row < ROWS; row++) { const min_lat = 90 - (row + 1) * 180 / ROWS; const max_lat = 90 - (row + 0) * 180 / ROWS; for(let col = 0; col < COLS; col++) { const min_lon = -180 + (col + 0) * 360 / COLS; const max_lon = -180 + (col + 1) * 360 / COLS; const etc_tzid = maritime_zone(min_lon + (max_lon - min_lon) / 2); // Determine which timezones potentially overlap this tile. const candidates = []; for(const feature of tz_geojson.features) { if(box_overlap(feature, min_lat, min_lon, max_lat, max_lon)) { candidates.push(feature); } } root[row * COLS + col] = tile( candidates, etc_tzid, min_lat, min_lon, max_lat, max_lon, 1 ); } } // Generate list of timezones. const tz_set = new Set(); function add(node) { if(Array.isArray(node)) { node.forEach(add); } else { tz_set.add(node); } } add(root); const tz_list = Array.from(tz_set); tz_list.sort(); // Pack tree into a string. function pack(root) { const list = []; for(const queue = [root]; queue.length; ) { const node = queue.shift(); node.index = list.length; list.push(node); for(let i = 0; i < node.length; i++) { if(Array.isArray(node[i])) { queue.push(node[i]); } else { node[i] = tz_list.indexOf(node[i]); } } } let string = ""; for(let i = 0; i < list.length; i++) { const a = list[i]; for(let j = 0; j < a.length; j++) { const b = a[j]; let x; if(Array.isArray(b)) { x = b.index - a.index - 1; if(x < 0 || x + tz_list.length >= 3136) { throw new Error("cannot pack in the current format"); } } else { x = 3136 - tz_list.length + b; } string += String.fromCharCode(Math.floor(x / 56) + 35, (x % 56) + 35); } } return string; } const tz_data = pack(root); console.log( "%s", fs.readFileSync("tz_template.js", "utf8"). replace(/__TZDATA__/, () => JSON.stringify(tz_data)). replace(/__TZLIST__/, () => JSON.stringify(tz_list)) );