Created
December 13, 2018 13:05
-
-
Save thw0rted/bcea45d0652a7f45f40390cb5cc25c8e to your computer and use it in GitHub Desktop.
Cesium TimeIntervalColleciton that doesn't need individual instances of TimeInterval
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
Event as CesiumEvent, | |
GregorianDate, | |
JulianDate, | |
RuntimeError, | |
TimeInterval, | |
TimeIntervalCollection, | |
} from "cesium"; | |
export interface ConstructorOptions { | |
isStartIncluded?: boolean; | |
isStopIncluded?: boolean; | |
} | |
// Given a selected Interval, what parameter(s) should be sent to the service? | |
const DEFAULT_CALLBACK: TimeIntervalCollection.DataCallback = | |
x => ({time: TimeInterval.toIso8601(x, 0)}); | |
// Some malformed WMS services return time bounds of `START/STOP/` with no | |
// duration specified; we need to pick something, so use this. | |
const DEFAULT_DURATION = new GregorianDate(0, 0, 0, 1); // 1 hour | |
/** | |
* This class implements a TimeIntervalCollection that doesn't actually create | |
* any TimeIntervals when constructed, but rather represents a WMS-style range, | |
* specified with start, stop, and period. The various find/get methods | |
* defined by TimeIntervalCollection perform on-demand creation of a new | |
* TimeInterval that matches the request criteria. | |
*/ | |
export class ISO8601TimeIntervalCollection implements TimeIntervalCollection { | |
public readonly startMsec: number; | |
public readonly stopMsec: number; | |
public readonly periodMsec: number; | |
public readonly isStartIncluded: boolean; | |
public readonly isStopIncluded: boolean; | |
public readonly length: number; | |
// Immutable, never fired | |
public changedEvent: CesiumEvent = new CesiumEvent(); | |
// Can't make an empty one | |
public isEmpty: boolean = false; | |
public constructor( | |
public readonly start: JulianDate, | |
public readonly stop: JulianDate, | |
public readonly period: GregorianDate, | |
private readonly dataCallback: TimeIntervalCollection.DataCallback = DEFAULT_CALLBACK, | |
opts: ConstructorOptions = {} | |
) { | |
this.isStartIncluded = !!opts.isStartIncluded; | |
this.isStopIncluded = !!opts.isStopIncluded; | |
this.startMsec = JulianDate.toDate(start).valueOf(); | |
this.stopMsec = JulianDate.toDate(stop).valueOf(); | |
const periodSec = ISO8601TimeIntervalCollection.durationToSeconds(this.period); | |
this.periodMsec = periodSec * 1000; | |
this.length = JulianDate.secondsDifference(this.stop, this.start) / periodSec; | |
} | |
static fromString( | |
str: string, | |
dataCallback: TimeIntervalCollection.DataCallback = DEFAULT_CALLBACK, | |
opts: ConstructorOptions = {} | |
): ISO8601TimeIntervalCollection { | |
const parts = str.split("/"); | |
if (parts.length !== 3) { | |
throw new RuntimeError("Invalid time span specified: " + str); | |
} | |
const start = JulianDate.fromIso8601(parts[0]); | |
const stop = parseStop(parts[1]); | |
let period = ISO8601TimeIntervalCollection.stringToDuration(parts[2]); | |
if (!period) { period = DEFAULT_DURATION; } | |
return new ISO8601TimeIntervalCollection(start, stop, period, dataCallback, opts); | |
} | |
// Create an ISO8601 time span string from separate start, stop, period | |
// WORKAROUND: calling this "toString" breaks typescript, see GH #27276 | |
static stringify(coll: ISO8601TimeIntervalCollection): string { | |
return JulianDate.toIso8601(coll.start) + "/" + | |
JulianDate.toIso8601(coll.stop) + "/" + | |
ISO8601TimeIntervalCollection.durationToString(coll.period); | |
} | |
// Reverse the above, turn a GregorianDate containing time values into an ISO8601 Duration string | |
static durationToString(duration: GregorianDate): string { | |
let ret = "P"; | |
if (duration.year) { ret += duration.year + "Y"; } | |
if (duration.month) { ret += duration.month + "M"; } | |
if (duration.day) { ret += duration.day + "D"; } | |
let time = ""; | |
if (duration.hour) { time += duration.hour + "H"; } | |
if (duration.minute) { time += duration.minute + "M"; } | |
if (duration.second) { time += duration.second + "S"; } | |
if (time) { return ret + "T" + time; } | |
else { return ret; } | |
} | |
// Total number of seconds in the supplied duration | |
static durationToSeconds(duration: GregorianDate): number { | |
return ((duration.year || 0) * (365 * 24 * 60 * 60)) + | |
((duration.month || 0) * (30 * 24 * 60 * 60)) + | |
((duration.day || 0) * (24 * 60 * 60)) + | |
((duration.hour || 0) * (60 * 60)) + | |
((duration.minute || 0) * 60) + | |
(duration.second || 0); | |
} | |
// Populate a GregorianDate with the passed number of seconds represented | |
// as year/month/day/hour/minute/second duration | |
static secondsToDuration(sec: number): GregorianDate { | |
if (sec <= 0) { return new GregorianDate(); } | |
const year = Math.floor(sec / (365 * 24 * 60 * 60)); | |
sec -= year * 365 * 24 * 60 * 60; | |
const month = Math.floor(sec / (30 * 24 * 60 * 60)); | |
sec -= month * 30 * 24 * 60 * 60; | |
const day = Math.floor(sec / (24 * 60 * 60)); | |
sec -= day * 24 * 60 * 60; | |
const hour = Math.floor(sec / (60 * 60)); | |
sec -= hour * 60 * 60; | |
const minute = Math.floor(sec / 60); | |
sec -= minute * 60; | |
return new GregorianDate(year, month, day, hour, minute, sec); | |
} | |
// Cribbed from Cesium TimeIntervalCollection's parseDuration | |
static stringToDuration(iso8601: string): GregorianDate | undefined { | |
if (!iso8601) { return; } | |
// Reset object | |
const result = new GregorianDate(0,0,0,0,0,0,0,false); | |
if (iso8601[0] === 'P') { | |
var matches = iso8601.match(durationRegex); | |
if (!matches) { | |
return; | |
} | |
if (matches[1]) { // Years | |
result.year = Number(matches[1].replace(',', '.')); | |
} | |
if (matches[2]) { // Months | |
result.month = Number(matches[2].replace(',', '.')); | |
} | |
if (matches[3]) { // Weeks | |
result.day = Number(matches[3].replace(',', '.')) * 7; | |
} | |
if (matches[4]) { // Days | |
result.day += Number(matches[4].replace(',', '.')); | |
} | |
if (matches[5]) { // Hours | |
result.hour = Number(matches[5].replace(',', '.')); | |
} | |
if (matches[6]) { // Weeks | |
result.minute = Number(matches[6].replace(',', '.')); | |
} | |
if (matches[7]) { // Seconds | |
var seconds = Number(matches[7].replace(',', '.')); | |
result.second = Math.floor(seconds); | |
result.millisecond = (seconds % 1) * 1000; | |
} | |
} else { | |
// They can technically specify the duration as a normal date with some caveats. Try our best to load it. | |
if (iso8601[iso8601.length - 1] !== 'Z') { // It's not a date, its a duration, so it always has to be UTC | |
iso8601 += 'Z'; | |
} | |
JulianDate.toGregorianDate(JulianDate.fromIso8601(iso8601), result); | |
} | |
// A duration of 0 will cause an infinite loop, so just make sure something is non-zero | |
if (result.year || result.month || result.day || result.hour || | |
result.minute || result.second || result.millisecond) { | |
return result; | |
} | |
} | |
// Returns true if this date is in our collection | |
contains(date: JulianDate): boolean { return this.indexOf(date) >= 0; } | |
// Returns true if the provided collection is equal to us | |
equals(right?: TimeIntervalCollection, dataComparer?: TimeInterval.DataComparer): boolean { | |
if (!(right instanceof ISO8601TimeIntervalCollection)) { return false; } | |
const us = JulianDate.fromGregorianDate(this.period); | |
const them = JulianDate.fromGregorianDate(right.period); | |
return right.start.equals(this.start) && | |
right.stop.equals(this.stop) && | |
us.equals(them); | |
} | |
// Get the data from a TimeInterval that contains the supplied date. | |
findDataForIntervalContainingDate(date: JulianDate): {} | undefined { | |
const found = this.findIntervalContainingDate(date); | |
if (!found) { return; } | |
return found.data; | |
} | |
// Return the first matching interval, if any. | |
findInterval(options?: TimeIntervalCollection.FindOptions): TimeInterval | undefined { | |
if (!options || !options.start) { return this.get(0); } | |
if (this.isAfterStop(options.start)) { return undefined; } | |
return this.get(this.indexOf(options.start)); | |
} | |
// Get a TimeInterval that contains the specified date | |
findIntervalContainingDate(date: JulianDate): TimeInterval | undefined { | |
return this.findInterval({start: date}); | |
} | |
/** | |
* Return the interval at the specified index, or undefined if no interval | |
* exists as that index. | |
* @param index | |
*/ | |
get(index: number): TimeInterval | undefined { | |
if (index < 0 || index >= this.length) { return } | |
const periodSeconds = this.periodMsec / 1000; | |
const begin = this.start.clone(); | |
begin.secondsOfDay += (index * periodSeconds); | |
const end = begin.clone(); | |
end.secondsOfDay += periodSeconds; | |
const ret = new TimeInterval({ | |
start: begin, | |
stop: end, | |
}); | |
ret.data = this.dataCallback(ret, index); | |
return ret; | |
} | |
/** | |
* Figure out what interval this date would fall into. Per the spec, dates | |
* that don't fall into an interval return bitwise complement of the next | |
* interval that starts after the date. | |
* @param date | |
*/ | |
indexOf(date: JulianDate): number { | |
if (this.isBeforeStart(date)) { return ~0; } | |
else if (this.isAfterStop(date)) { return ~this.length; } | |
return Math.floor( | |
JulianDate.secondsDifference(date, this.start) / (this.periodMsec / 1000) | |
); | |
} | |
// Return a TimeIntervalCollection representing the intersection of ours | |
// with the one passed as an argument. | |
intersect( | |
other: TimeIntervalCollection, | |
dataComparer: TimeInterval.DataComparer = (x,y) => x === y, | |
mergeCallback?: TimeInterval.MergeCallback | |
): TimeIntervalCollection { | |
if (other instanceof ISO8601TimeIntervalCollection) { | |
return this.intersect8601(other, mergeCallback); | |
} | |
// If it's not like us, then use our start/end to filter the owned intervals | |
const keptIntervals: TimeInterval[] = []; | |
for (let i=0; i < other.length; ++i) { | |
const interval = other.get(i); | |
// If the interval falls outside our range, don't include it | |
if (!interval || | |
this.isBeforeStart(interval.start) || | |
this.isAfterStop(interval.stop) | |
) { continue; } | |
// TODO: maybe use dataComparer? Seems like a poor fit because | |
// our intervals' data is unlikely to line up exactly with that of | |
// the passed intervals | |
keptIntervals.push(TimeInterval.clone(interval)); | |
} | |
return new TimeIntervalCollection(keptIntervals); | |
} | |
// WMS-compliant "periodic interval" ("range") string, as start/end/duration | |
toString() { return ISO8601TimeIntervalCollection.stringify(this); } | |
/////////////////////////////////////////////////////////////////////////// | |
// This collection is immutable so any calls to a method that would | |
// attempt to modify it should throw. | |
addInterval(interval: TimeInterval, dataComparer?: TimeInterval.DataComparer): void { | |
throw new Error("This TimeIntervalCollection is immutable."); | |
} | |
removeAll(): void { | |
throw new Error("This TimeIntervalCollection is immutable."); | |
} | |
removeInterval(interval: TimeInterval): void { | |
throw new Error("This TimeIntervalCollection is immutable."); | |
} | |
// | |
/////////////////////////////////////////////////////////////////////////// | |
// Special handling for intersecting two 8601 collections, to avoid | |
// creating unnecessary additional intervals. | |
// Note that the returned collection will have a data callback that runs | |
// our data callback *and* the other collection's data callback, then | |
// passes both results to the supplied merge callback. If no merge | |
// callback is supplied, just return | |
private intersect8601( | |
other: ISO8601TimeIntervalCollection, | |
mergeCallback?: TimeInterval.MergeCallback | |
): ISO8601TimeIntervalCollection { | |
// Find the later start and the earlier end | |
let begin: JulianDate, end: JulianDate; | |
let includeStart: boolean, includeStop: boolean; | |
if (this.isBeforeStart(other.start)) { | |
begin = this.start; | |
includeStart = this.isStartIncluded; | |
} else { | |
begin = other.start; | |
includeStart = other.isStartIncluded; | |
} | |
if (this.isAfterStop(other.stop)) { | |
end = this.stop; | |
includeStop = this.isStopIncluded; | |
} else { | |
end = other.stop; | |
includeStop = other.isStopIncluded; | |
} | |
// See which period is the most granular | |
const us = JulianDate.fromGregorianDate(this.period); | |
const them = JulianDate.fromGregorianDate(other.period); | |
let finest: GregorianDate; | |
if (JulianDate.compare(us, them) >= 0) { | |
finest = this.period; | |
} else { | |
finest = other.period; | |
} | |
if (mergeCallback) { | |
// If we have a merge callback, the new collection's callback | |
// should call ours, theirs, then merge the two. | |
const cb: TimeIntervalCollection.DataCallback = (interval, index) => { | |
const ours = this.dataCallback(interval, index); | |
const theirs = other.dataCallback(interval, index); | |
return mergeCallback(ours, theirs); | |
}; | |
return new ISO8601TimeIntervalCollection(begin, end, finest, | |
cb, {isStartIncluded: includeStart, isStopIncluded: includeStop}); | |
} else { | |
return new ISO8601TimeIntervalCollection(begin, end, finest, | |
this.dataCallback, {isStartIncluded: includeStart, isStopIncluded: includeStop}); | |
} | |
} | |
// Return true if the supplied date is before our range | |
private isBeforeStart(other: JulianDate) { | |
if (this.isStartIncluded) { return JulianDate.lessThan(other, this.start); } | |
else { return JulianDate.lessThanOrEquals(other, this.start); } | |
} | |
// Return true if the supplied date is after our range | |
private isAfterStop(other: JulianDate) { | |
if (this.isStopIncluded) { return JulianDate.greaterThan(other, this.stop); } | |
else { return JulianDate.greaterThanOrEquals(other, this.stop); } | |
} | |
} | |
// Like JulianDate.fromISO8601, except it should return the *latest* date that | |
// could still truncate to the supplied value, to single-second precision. | |
// E.g.: "2018-01-01T13" => Jan 1 2018, 13:59:59 | |
function parseStop(str: string): JulianDate { | |
const ret = JulianDate.fromIso8601(str); | |
// String included YYYY-MM-DDTHH:MM:SS | |
if (str.length >= 4 + 3 + 3 + 3 + 3 + 3) { return ret; } | |
// String did not include seconds | |
JulianDate.addSeconds(ret, 59, ret); | |
if (str.length >= 4 + 3 + 3 + 3 + 3) { return ret; } | |
// String did not include minutes | |
JulianDate.addMinutes(ret, 59, ret); | |
if (str.length >= 4 + 3 + 3 + 3) { return ret; } | |
// String did not include hours | |
JulianDate.addHours(ret, 23, ret); | |
return ret; | |
} | |
// Copied from Cesium source | |
const durationRegex = /P(?:([\d.,]+)Y)?(?:([\d.,]+)M)?(?:([\d.,]+)W)?(?:([\d.,]+)D)?(?:T(?:([\d.,]+)H)?(?:([\d.,]+)M)?(?:([\d.,]+)S)?)?/; | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment