core_temporal_TimeSlicer.js - hiro-nyon/cesium-heatbox GitHub Wiki

Source: core/temporal/TimeSlicer.js

日本語 | English

English

See also: Class: TimeSlicer

import * as Cesium from 'cesium';

/**
 * TimeSlicer class for managing and retrieving time-series data.
 * 時系列データの管理と高速検索を担当するクラス。
 */
export class TimeSlicer {
    /**
     * @param {Array} rawData - Array of temporal data entries
     * @param {Object} options - Options
     */
    constructor(rawData, options = {}) {
        this._options = options;
        this._entries = this._normalizeAndSort(rawData);
        this._currentIndex = 0;
        this._currentEntry = null;
        this._globalStatsCache = {};

        // Performance metrics
        this._searchCount = 0;
        this._cacheHits = 0;
    }

    /**
     * Normalize and sort raw data.
     * データの正規化とソートを行います。
     * @param {Array} rawData 
     * @returns {Array} Normalized entries
     * @private
     */
    _normalizeAndSort(rawData) {
        if (!Array.isArray(rawData) || rawData.length === 0) {
            throw new Error('Temporal data must be a non-empty array');
        }

        // Normalize
        const normalized = rawData.map((entry, index) => {
            if (!entry.start || !entry.stop || !entry.data) {
                throw new Error(
                    `Invalid entry at index ${index}: missing start, stop, or data`
                );
            }

            const start = this._toJulianDate(entry.start);
            const stop = this._toJulianDate(entry.stop);

            // Time validation
            if (Cesium.JulianDate.greaterThan(start, stop)) {
                throw new Error(
                    `Invalid time range at index ${index}: start > stop`
                );
            }

            // Handle single point in time
            if (Cesium.JulianDate.equals(start, stop)) {
                Cesium.JulianDate.addSeconds(start, 1, stop);
            }

            return { start, stop, data: entry.data };
        });

        // Sort
        normalized.sort((a, b) => Cesium.JulianDate.compare(a.start, b.start));

        // Overlap handling
        const resolution = this._options.overlapResolution || 'prefer-earlier';
        if (resolution === 'skip') {
            this._validateNoOverlap(normalized);
            return normalized;
        }
        if (resolution === 'prefer-earlier') {
            return this._resolvePreferEarlier(normalized);
        }
        if (resolution === 'prefer-later') {
            return this._resolvePreferLater(normalized);
        }

        return normalized;
    }

    /**
     * Convert various time formats to JulianDate.
     * 様々な時刻形式を JulianDate に変換します。
     * @param {Cesium.JulianDate|string|Date|number} value 
     * @returns {Cesium.JulianDate}
     * @private
     */
    _toJulianDate(value) {
        if (value instanceof Cesium.JulianDate) return value;
        if (typeof value === 'string') {
            return Cesium.JulianDate.fromIso8601(value);
        }
        if (value instanceof Date) {
            return Cesium.JulianDate.fromDate(value);
        }
        if (typeof value === 'number') {
            return Cesium.JulianDate.fromDate(new Date(value * 1000));
        }
        throw new Error(`Unsupported time format: ${typeof value}`);
    }

    /**
     * Validate that there are no overlaps between entries.
     * エントリー間に重複がないことを検証します。
     * @param {Array} entries 
     * @private
     */
    _validateNoOverlap(entries) {
        for (let i = 0; i < entries.length - 1; i++) {
            const current = entries[i];
            const next = entries[i + 1];

            if (Cesium.JulianDate.greaterThan(current.stop, next.start)) {
                throw new Error(
                    `Data overlap detected: entry ${i} stops at ${current.stop}, ` +
                    `but entry ${i + 1} starts at ${next.start}`
                );
            }
        }
    }

    /**
     * Resolve overlaps by keeping the earlier entry and trimming later entries.
     * 早いエントリーを優先し、後続エントリーをトリミングまたは破棄します。
     * @param {Array} entries 
     * @returns {Array}
     * @private
     */
    _resolvePreferEarlier(entries) {
        const resolved = [];

        for (const entry of entries) {
            const previous = resolved[resolved.length - 1];
            if (!previous) {
                resolved.push(entry);
                continue;
            }

            if (Cesium.JulianDate.greaterThan(previous.stop, entry.start)) {
                if (Cesium.JulianDate.greaterThanOrEquals(previous.stop, entry.stop)) {
                    // Entry is fully overlapped by previous, drop it
                    continue;
                }
                // Trim start to previous stop
                entry.start = Cesium.JulianDate.clone(previous.stop);
                if (!Cesium.JulianDate.lessThan(entry.start, entry.stop)) {
                    continue;
                }
            }

            resolved.push(entry);
        }

        return resolved;
    }

    /**
     * Resolve overlaps by prioritizing later entries.
     * 遅いエントリーを優先し、手前のエントリー終端を調整します。
     * @param {Array} entries 
     * @returns {Array}
     * @private
     */
    _resolvePreferLater(entries) {
        const resolved = [];

        for (const entry of entries) {
            while (resolved.length > 0) {
                const previous = resolved[resolved.length - 1];
                if (!Cesium.JulianDate.greaterThan(previous.stop, entry.start)) {
                    break;
                }

                if (
                    Cesium.JulianDate.lessThan(entry.start, previous.start) ||
                    Cesium.JulianDate.equals(entry.start, previous.start)
                ) {
                    // Later entry fully replaces previous
                    resolved.pop();
                    continue;
                }

                previous.stop = Cesium.JulianDate.clone(entry.start);
                if (!Cesium.JulianDate.lessThan(previous.start, previous.stop)) {
                    resolved.pop();
                    continue;
                }

                break;
            }

            resolved.push(entry);
        }

        return resolved;
    }

    /**
   * Get entry for the current time.
   * 現在時刻に対応するエントリーを取得します。
   * @param {Cesium.JulianDate} currentTime 
   * @returns {Object|null} Entry or null if not found
   */
    getEntry(currentTime) {
        this._searchCount++;

        // Cache check
        if (this._currentEntry) {
            if (
                Cesium.JulianDate.greaterThanOrEquals(currentTime, this._currentEntry.start) &&
                Cesium.JulianDate.lessThan(currentTime, this._currentEntry.stop)
            ) {
                this._cacheHits++;
                return this._currentEntry;
            }
        }

        // Nearby search (Phase 2)
        const nearbyIndices = [
            this._currentIndex,
            this._currentIndex + 1,
            this._currentIndex - 1
        ];

        for (const idx of nearbyIndices) {
            if (idx >= 0 && idx < this._entries.length) {
                const entry = this._entries[idx];
                if (this._isInRange(currentTime, entry)) {
                    this._currentIndex = idx;
                    this._currentEntry = entry;
                    return entry;
                }
            }
        }

        // Binary search (Phase 2)
        const index = this._binarySearch(currentTime);
        if (index >= 0) {
            this._currentIndex = index;
            this._currentEntry = this._entries[index];
            return this._currentEntry;
        }

        // Not found
        this._currentEntry = null;
        return null;
    }

    /**
     * Check if time is within entry range.
     * 時刻がエントリーの範囲内かチェックします。
     * @param {Cesium.JulianDate} time 
     * @param {Object} entry 
     * @returns {boolean}
     * @private
     */
    _isInRange(time, entry) {
        return (
            Cesium.JulianDate.greaterThanOrEquals(time, entry.start) &&
            Cesium.JulianDate.lessThan(time, entry.stop)
        );
    }

    /**
     * Binary search for the entry containing the time.
     * 二分探索で時刻を含むエントリーを探します。
     * @param {Cesium.JulianDate} time 
     * @returns {number} Index or -1 if not found
     * @private
     */
    _binarySearch(time) {
        let left = 0;
        let right = this._entries.length - 1;

        while (left <= right) {
            const mid = Math.floor((left + right) / 2);
            const entry = this._entries[mid];

            if (this._isInRange(time, entry)) {
                return mid;
            }

            if (Cesium.JulianDate.lessThan(time, entry.start)) {
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }

        return -1;
    }

    /**
   * Calculate global statistics across all time entries.
   * 全時間のエントリーにまたがる統計量を計算します。
   * @param {string} valueProperty - Property name to use for value (default: 'weight')
   * @returns {Object} Global statistics
   */
    calculateGlobalStats(valueProperty = 'weight') {
        const cacheKey = valueProperty;

        if (this._globalStatsCache[cacheKey]) {
            return this._globalStatsCache[cacheKey];
        }

        let min = Infinity;
        let max = -Infinity;
        let sum = 0;
        let count = 0;
        const allValues = [];

        for (const entry of this._entries) {
            if (!Array.isArray(entry.data)) continue;

            for (const point of entry.data) {
                const value = point[valueProperty] ?? 1; // Default to 1 if property missing
                if (typeof value !== 'number') continue;

                min = Math.min(min, value);
                max = Math.max(max, value);
                sum += value;
                count++;
                allValues.push(value);
            }
        }

        if (count === 0) {
            return null;
        }

        // Mean & Median
        const mean = sum / count;
        allValues.sort((a, b) => a - b);
        const median = this._calculateMedian(allValues);

        // Quantiles
        const quantiles = this._calculateQuantiles(allValues, [0.25, 0.5, 0.75]);

        this._globalStatsCache[cacheKey] = {
            min,
            max,
            mean,
            median,
            quantiles,
            domain: [min, max],
            count
        };

        return this._globalStatsCache[cacheKey];
    }

    _calculateMedian(sortedValues) {
        if (sortedValues.length === 0) return 0;
        const mid = Math.floor(sortedValues.length / 2);
        if (sortedValues.length % 2 === 0) {
            return (sortedValues[mid - 1] + sortedValues[mid]) / 2;
        }
        return sortedValues[mid];
    }

    _calculateQuantiles(sortedValues, quantiles) {
        return quantiles.map(q => {
            const index = Math.floor(sortedValues.length * q);
            return sortedValues[Math.min(index, sortedValues.length - 1)];
        });
    }

    /**
     * Get cache hit rate.
     * キャッシュヒット率を取得します。
     * @returns {number}
     */
    getCacheHitRate() {
        return this._searchCount > 0
            ? this._cacheHits / this._searchCount
            : 0;
    }

    /**
     * Get time range of all data.
     * 全データの時間範囲を取得します。
     * @returns {Object|null} {start, stop}
     */
    getTimeRange() {
        if (this._entries.length === 0) {
            return null;
        }
        return {
            start: this._entries[0].start,
            stop: this._entries[this._entries.length - 1].stop
        };
    }
}

日本語

関連: TimeSlicerクラス

import * as Cesium from 'cesium';

/**
 * TimeSlicer class for managing and retrieving time-series data.
 * 時系列データの管理と高速検索を担当するクラス。
 */
export class TimeSlicer {
    /**
     * @param {Array} rawData - Array of temporal data entries
     * @param {Object} options - Options
     */
    constructor(rawData, options = {}) {
        this._options = options;
        this._entries = this._normalizeAndSort(rawData);
        this._currentIndex = 0;
        this._currentEntry = null;
        this._globalStatsCache = {};

        // Performance metrics
        this._searchCount = 0;
        this._cacheHits = 0;
    }

    /**
     * Normalize and sort raw data.
     * データの正規化とソートを行います。
     * @param {Array} rawData 
     * @returns {Array} Normalized entries
     * @private
     */
    _normalizeAndSort(rawData) {
        if (!Array.isArray(rawData) || rawData.length === 0) {
            throw new Error('Temporal data must be a non-empty array');
        }

        // Normalize
        const normalized = rawData.map((entry, index) => {
            if (!entry.start || !entry.stop || !entry.data) {
                throw new Error(
                    `Invalid entry at index ${index}: missing start, stop, or data`
                );
            }

            const start = this._toJulianDate(entry.start);
            const stop = this._toJulianDate(entry.stop);

            // Time validation
            if (Cesium.JulianDate.greaterThan(start, stop)) {
                throw new Error(
                    `Invalid time range at index ${index}: start > stop`
                );
            }

            // Handle single point in time
            if (Cesium.JulianDate.equals(start, stop)) {
                Cesium.JulianDate.addSeconds(start, 1, stop);
            }

            return { start, stop, data: entry.data };
        });

        // Sort
        normalized.sort((a, b) => Cesium.JulianDate.compare(a.start, b.start));

        // Overlap handling
        const resolution = this._options.overlapResolution || 'prefer-earlier';
        if (resolution === 'skip') {
            this._validateNoOverlap(normalized);
            return normalized;
        }
        if (resolution === 'prefer-earlier') {
            return this._resolvePreferEarlier(normalized);
        }
        if (resolution === 'prefer-later') {
            return this._resolvePreferLater(normalized);
        }

        return normalized;
    }

    /**
     * Convert various time formats to JulianDate.
     * 様々な時刻形式を JulianDate に変換します。
     * @param {Cesium.JulianDate|string|Date|number} value 
     * @returns {Cesium.JulianDate}
     * @private
     */
    _toJulianDate(value) {
        if (value instanceof Cesium.JulianDate) return value;
        if (typeof value === 'string') {
            return Cesium.JulianDate.fromIso8601(value);
        }
        if (value instanceof Date) {
            return Cesium.JulianDate.fromDate(value);
        }
        if (typeof value === 'number') {
            return Cesium.JulianDate.fromDate(new Date(value * 1000));
        }
        throw new Error(`Unsupported time format: ${typeof value}`);
    }

    /**
     * Validate that there are no overlaps between entries.
     * エントリー間に重複がないことを検証します。
     * @param {Array} entries 
     * @private
     */
    _validateNoOverlap(entries) {
        for (let i = 0; i < entries.length - 1; i++) {
            const current = entries[i];
            const next = entries[i + 1];

            if (Cesium.JulianDate.greaterThan(current.stop, next.start)) {
                throw new Error(
                    `Data overlap detected: entry ${i} stops at ${current.stop}, ` +
                    `but entry ${i + 1} starts at ${next.start}`
                );
            }
        }
    }

    /**
     * Resolve overlaps by keeping the earlier entry and trimming later entries.
     * 早いエントリーを優先し、後続エントリーをトリミングまたは破棄します。
     * @param {Array} entries 
     * @returns {Array}
     * @private
     */
    _resolvePreferEarlier(entries) {
        const resolved = [];

        for (const entry of entries) {
            const previous = resolved[resolved.length - 1];
            if (!previous) {
                resolved.push(entry);
                continue;
            }

            if (Cesium.JulianDate.greaterThan(previous.stop, entry.start)) {
                if (Cesium.JulianDate.greaterThanOrEquals(previous.stop, entry.stop)) {
                    // Entry is fully overlapped by previous, drop it
                    continue;
                }
                // Trim start to previous stop
                entry.start = Cesium.JulianDate.clone(previous.stop);
                if (!Cesium.JulianDate.lessThan(entry.start, entry.stop)) {
                    continue;
                }
            }

            resolved.push(entry);
        }

        return resolved;
    }

    /**
     * Resolve overlaps by prioritizing later entries.
     * 遅いエントリーを優先し、手前のエントリー終端を調整します。
     * @param {Array} entries 
     * @returns {Array}
     * @private
     */
    _resolvePreferLater(entries) {
        const resolved = [];

        for (const entry of entries) {
            while (resolved.length > 0) {
                const previous = resolved[resolved.length - 1];
                if (!Cesium.JulianDate.greaterThan(previous.stop, entry.start)) {
                    break;
                }

                if (
                    Cesium.JulianDate.lessThan(entry.start, previous.start) ||
                    Cesium.JulianDate.equals(entry.start, previous.start)
                ) {
                    // Later entry fully replaces previous
                    resolved.pop();
                    continue;
                }

                previous.stop = Cesium.JulianDate.clone(entry.start);
                if (!Cesium.JulianDate.lessThan(previous.start, previous.stop)) {
                    resolved.pop();
                    continue;
                }

                break;
            }

            resolved.push(entry);
        }

        return resolved;
    }

    /**
   * Get entry for the current time.
   * 現在時刻に対応するエントリーを取得します。
   * @param {Cesium.JulianDate} currentTime 
   * @returns {Object|null} Entry or null if not found
   */
    getEntry(currentTime) {
        this._searchCount++;

        // Cache check
        if (this._currentEntry) {
            if (
                Cesium.JulianDate.greaterThanOrEquals(currentTime, this._currentEntry.start) &&
                Cesium.JulianDate.lessThan(currentTime, this._currentEntry.stop)
            ) {
                this._cacheHits++;
                return this._currentEntry;
            }
        }

        // Nearby search (Phase 2)
        const nearbyIndices = [
            this._currentIndex,
            this._currentIndex + 1,
            this._currentIndex - 1
        ];

        for (const idx of nearbyIndices) {
            if (idx >= 0 && idx < this._entries.length) {
                const entry = this._entries[idx];
                if (this._isInRange(currentTime, entry)) {
                    this._currentIndex = idx;
                    this._currentEntry = entry;
                    return entry;
                }
            }
        }

        // Binary search (Phase 2)
        const index = this._binarySearch(currentTime);
        if (index >= 0) {
            this._currentIndex = index;
            this._currentEntry = this._entries[index];
            return this._currentEntry;
        }

        // Not found
        this._currentEntry = null;
        return null;
    }

    /**
     * Check if time is within entry range.
     * 時刻がエントリーの範囲内かチェックします。
     * @param {Cesium.JulianDate} time 
     * @param {Object} entry 
     * @returns {boolean}
     * @private
     */
    _isInRange(time, entry) {
        return (
            Cesium.JulianDate.greaterThanOrEquals(time, entry.start) &&
            Cesium.JulianDate.lessThan(time, entry.stop)
        );
    }

    /**
     * Binary search for the entry containing the time.
     * 二分探索で時刻を含むエントリーを探します。
     * @param {Cesium.JulianDate} time 
     * @returns {number} Index or -1 if not found
     * @private
     */
    _binarySearch(time) {
        let left = 0;
        let right = this._entries.length - 1;

        while (left <= right) {
            const mid = Math.floor((left + right) / 2);
            const entry = this._entries[mid];

            if (this._isInRange(time, entry)) {
                return mid;
            }

            if (Cesium.JulianDate.lessThan(time, entry.start)) {
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }

        return -1;
    }

    /**
   * Calculate global statistics across all time entries.
   * 全時間のエントリーにまたがる統計量を計算します。
   * @param {string} valueProperty - Property name to use for value (default: 'weight')
   * @returns {Object} Global statistics
   */
    calculateGlobalStats(valueProperty = 'weight') {
        const cacheKey = valueProperty;

        if (this._globalStatsCache[cacheKey]) {
            return this._globalStatsCache[cacheKey];
        }

        let min = Infinity;
        let max = -Infinity;
        let sum = 0;
        let count = 0;
        const allValues = [];

        for (const entry of this._entries) {
            if (!Array.isArray(entry.data)) continue;

            for (const point of entry.data) {
                const value = point[valueProperty] ?? 1; // Default to 1 if property missing
                if (typeof value !== 'number') continue;

                min = Math.min(min, value);
                max = Math.max(max, value);
                sum += value;
                count++;
                allValues.push(value);
            }
        }

        if (count === 0) {
            return null;
        }

        // Mean & Median
        const mean = sum / count;
        allValues.sort((a, b) => a - b);
        const median = this._calculateMedian(allValues);

        // Quantiles
        const quantiles = this._calculateQuantiles(allValues, [0.25, 0.5, 0.75]);

        this._globalStatsCache[cacheKey] = {
            min,
            max,
            mean,
            median,
            quantiles,
            domain: [min, max],
            count
        };

        return this._globalStatsCache[cacheKey];
    }

    _calculateMedian(sortedValues) {
        if (sortedValues.length === 0) return 0;
        const mid = Math.floor(sortedValues.length / 2);
        if (sortedValues.length % 2 === 0) {
            return (sortedValues[mid - 1] + sortedValues[mid]) / 2;
        }
        return sortedValues[mid];
    }

    _calculateQuantiles(sortedValues, quantiles) {
        return quantiles.map(q => {
            const index = Math.floor(sortedValues.length * q);
            return sortedValues[Math.min(index, sortedValues.length - 1)];
        });
    }

    /**
     * Get cache hit rate.
     * キャッシュヒット率を取得します。
     * @returns {number}
     */
    getCacheHitRate() {
        return this._searchCount > 0
            ? this._cacheHits / this._searchCount
            : 0;
    }

    /**
     * Get time range of all data.
     * 全データの時間範囲を取得します。
     * @returns {Object|null} {start, stop}
     */
    getTimeRange() {
        if (this._entries.length === 0) {
            return null;
        }
        return {
            start: this._entries[0].start,
            stop: this._entries[this._entries.length - 1].stop
        };
    }
}