utils/tz.tstypescript
type TimeZoneRing = number[][];
type TimeZonePolygon = TimeZoneRing[];
/**
* Zone data — a plain GeoJSON FeatureCollection:
* - properties.tzid: IANA name ("Europe/Paris", "Etc/GMT+3") — what lookup returns;
* the bundled file also carries zone (standard offset, hours), zone_dst and utc_format.
* - geometry: Polygon or MultiPolygon in lon/lat degrees, first ring outer, rest holes.
* - non-overlapping, lon within [-180, 180] split at the antimeridian, lat within ±89.9
* (the mercator rendering limit; ±90 breaks the triangulation).
* Bundled res/tz/timezones.geojson: timezone-boundary-builder with oceans, dissolved
* by the (standard, DST) offset pair.
*/
export interface ITimeZoneFeature {
properties: { tzid: string };
geometry: {
type: "Polygon" | "MultiPolygon";
coordinates: TimeZonePolygon | TimeZonePolygon[];
};
}
export interface ITimeZoneData {
features: ITimeZoneFeature[];
}
export interface ITimeZoneProviderParams {
src?: string;
data?: ITimeZoneData;
}
const DEFAULT_SRC = "/res/tz/timezones.geojson";
interface ITimeZoneItem {
poly: TimeZonePolygon;
mnx: number;
mxx: number;
mny: number;
mxy: number;
tzid: string;
}
function inRing(x: number, y: number, ring: TimeZoneRing): boolean {
let ins = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0],
yi = ring[i][1],
xj = ring[j][0],
yj = ring[j][1];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
ins = !ins;
}
}
return ins;
}
function inPoly(x: number, y: number, poly: TimeZonePolygon): boolean {
if (!inRing(x, y, poly[0])) return false;
for (let k = 1; k < poly.length; k++) {
if (inRing(x, y, poly[k])) return false;
}
return true;
}
const fmtCache = new Map<string, Intl.DateTimeFormat>();
/**
* UTC offset of the zone at the given instant, minutes. DST is applied by the
* tzdb rules the browser ships, which the zone data deliberately does not carry:
* the file only stores the standard and summer offsets, not the switch dates.
* @param {string} tzid - IANA time zone name.
* @param {Date} date - Instant to read the offset at.
* @returns {number} -
*/
export function tzOffsetMinutes(tzid: string, date: Date): number {
let f = fmtCache.get(tzid);
if (!f) {
f = new Intl.DateTimeFormat("en-US", {
timeZone: tzid,
timeZoneName: "longOffset"
});
fmtCache.set(tzid, f);
}
const name = f.formatToParts(date).find((p) => p.type === "timeZoneName")!.value;
const m = name.match(/GMT([+-])(\d{2}):(\d{2})/);
return m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 0;
}
/**
* Time zone lookup over polygon data, made for {@link Sun.timeZoneProvider}.
* Loads lazily and hides the loading: the first request — a lookup, a load or
* the sun the provider is assigned to — kicks the single fetch.
*
* @example
* const tz = new TimeZoneProvider({ src: "/res/tz/timezones.geojson" });
* globe.sun.timeZoneProvider = tz; // lighting picks the polygons up once loaded
* tz.load().then(() => drawZones(tz.data)); // the very same single request
*
* @param {ITimeZoneProviderParams} [options] - Options:
* @param {string} [options.src="/res/tz/timezones.geojson"] - GeoJSON url: features with an IANA
* name in properties.tzid and Polygon or MultiPolygon geometry in degrees.
* @param {ITimeZoneData} [options.data] - Inline data instead of fetching src.
*/
export class TimeZoneProvider {
public src: string;
protected _data: ITimeZoneData | null;
protected _items: ITimeZoneItem[];
protected _loading: Promise<this> | null;
constructor(options: ITimeZoneProviderParams = {}) {
this.src = options.src || DEFAULT_SRC;
this._data = null;
this._items = [];
this._loading = null;
if (options.data) {
this._setData(options.data);
}
}
/**
* Loaded feature collection, or null before load.
* @public
*/
public get data(): ITimeZoneData | null {
return this._data;
}
/**
* Fetches and indexes the polygons once; repeated calls share the same promise.
* @public
* @returns {Promise<TimeZoneProvider>} -
*/
public load(): Promise<this> {
if (!this._loading) {
this._loading = this._data
? Promise.resolve(this)
: fetch(this.src)
.then((r) => r.json())
.then((data: ITimeZoneData) => {
this._setData(data);
return this;
});
this._loading.catch((err) => console.warn("TimeZoneProvider: failed to load", this.src, err));
}
return this._loading;
}
/**
* IANA zone name of the point, or null outside the data. Called before the
* data is in place, kicks the load and answers null for now.
* @public
* @param {number} lon - Degrees longitude.
* @param {number} lat - Degrees latitude.
* @returns {string | null} -
*/
public lookup(lon: number, lat: number): string | null {
if (!this._data) {
this.load();
}
for (const it of this._items) {
if (lon < it.mnx || lon > it.mxx || lat < it.mny || lat > it.mxy) {
continue;
}
if (inPoly(lon, lat, it.poly)) {
return it.tzid;
}
}
return null;
}
protected _setData(data: ITimeZoneData) {
this._data = data;
this._items = [];
for (const f of data.features) {
const g = f.geometry;
const polys = (g.type === "Polygon" ? [g.coordinates] : g.coordinates) as TimeZonePolygon[];
for (const poly of polys) {
let mnx = 1e9,
mxx = -1e9,
mny = 1e9,
mxy = -1e9;
for (const ring of poly) {
for (const p of ring) {
if (p[0] < mnx) mnx = p[0];
if (p[0] > mxx) mxx = p[0];
if (p[1] < mny) mny = p[1];
if (p[1] > mxy) mxy = p[1];
}
}
this._items.push({ poly, mnx, mxx, mny, mxy, tzid: f.properties.tzid });
}
}
}
}