core_DataProcessor.js - hiro-nyon/cesium-heatbox GitHub Wiki
日本語 | English
See also: Class: DataProcessor
/**
* データ処理を担当するクラス(シンプル実装)
*/
import * as Cesium from 'cesium';
import { VoxelGrid } from './VoxelGrid.js';
import { Logger } from '../utils/logger.js';
import { SpatialIdAdapter } from './spatial/SpatialIdAdapter.js';
import { resolvePropertyValue } from '../utils/cesiumProperty.js';
import { getBackend } from '../utils/classificationBackend.js';
import { createClassifier } from '../utils/classification.js';
/**
* Class responsible for processing entity data.
* エンティティデータの処理を担当するクラス。
*/
export class DataProcessor {
/**
* Classify entities into voxels (simple implementation).
* エンティティをボクセルに分類(シンプル実装)。
* @param {Array} entities - Entity array / エンティティ配列
* @param {Object} bounds - Bounds info / 境界情報
* @param {Object} grid - Grid info / グリッド情報
* @param {Object} [options={}] - Processing options (v0.1.17: spatialId support) / 処理オプション
* @returns {Promise<Map>} Voxel data Map (key: voxel key, value: info) / ボクセルデータ(キー: ボクセルキー, 値: ボクセル情報)
*/
static async classifyEntitiesIntoVoxels(entities, bounds, grid, options = {}) {
// v0.1.17: Spatial ID mode (tile-grid) / 空間IDモード(tile-grid)
if (options.spatialId?.enabled) {
return await DataProcessor._classifyBySpatialId(entities, bounds, grid, options);
}
// Uniform grid mode (default) / 一様グリッドモード(デフォルト)
const voxelData = new Map();
let processedCount = 0;
let skippedCount = 0;
// v0.1.18: Layer aggregation setup (ADR-0014)
const aggregationOptions = options.aggregation || {};
const aggregationEnabled = Boolean(aggregationOptions.enabled);
const currentTime = Cesium.JulianDate.now();
const byProperty = typeof aggregationOptions.byProperty === 'string' && aggregationOptions.byProperty.trim() !== ''
? aggregationOptions.byProperty.trim()
: null;
const userResolver = typeof aggregationOptions.keyResolver === 'function' ? aggregationOptions.keyResolver : null;
let resolveLayerKey = null;
if (aggregationEnabled) {
if (userResolver || byProperty) {
resolveLayerKey = (entity, entityIndex) => {
let value;
if (userResolver) {
try {
value = userResolver(entity);
} catch (error) {
Logger.warn(`[aggregation] keyResolver threw error for entity ${entityIndex}, using "unknown"`, error);
return 'unknown';
}
value = resolvePropertyValue(value, currentTime);
} else if (byProperty) {
let resolved;
try {
const bag = entity.properties?.getValue?.(currentTime);
if (bag && typeof bag === 'object' && byProperty in bag) {
resolved = bag[byProperty];
}
} catch (error) {
Logger.warn(`[aggregation] Failed to resolve PropertyBag for ${byProperty}, fallback to direct property`, error);
}
if (resolved === undefined) {
const prop = entity.properties?.[byProperty];
resolved = resolvePropertyValue(prop, currentTime);
}
value = resolved;
}
if (value === undefined || value === null || (typeof value === 'number' && Number.isNaN(value))) {
return 'unknown';
}
const stringValue = String(value);
return stringValue.trim() === '' ? 'unknown' : stringValue;
};
} else {
Logger.warn('[aggregation] enabled but no byProperty or keyResolver specified, using "default" key');
resolveLayerKey = () => 'default';
}
}
Logger.debug(`Processing ${entities.length} entities for classification`);
entities.forEach((entity, index) => {
try {
// エンティティの位置を取得(シンプルなアプローチ)
let position;
if (entity.position) {
if (typeof entity.position.getValue === 'function') {
position = entity.position.getValue(currentTime);
} else {
position = entity.position;
}
}
if (!position) {
skippedCount++;
return; // 位置がない場合はスキップ
}
// Cartesian3からCartographicに変換(テスト環境向けフォールバックあり)
let lon, lat, alt;
const looksLikeDegrees = typeof position?.x === 'number' && typeof position?.y === 'number' &&
Math.abs(position.x) <= 360 && Math.abs(position.y) <= 90;
if (looksLikeDegrees) {
// フォールバック: position を {x:lon, y:lat, z:alt} とみなす(テストの単純モック互換)
lon = position.x;
lat = position.y;
alt = typeof position.z === 'number' ? position.z : 0;
} else if (Cesium.Cartographic && typeof Cesium.Cartographic.fromCartesian === 'function') {
const cartographic = Cesium.Cartographic.fromCartesian(position);
if (!cartographic) {
skippedCount++;
return;
}
// 地理座標に変換
lon = Cesium.Math.toDegrees(cartographic.longitude);
lat = Cesium.Math.toDegrees(cartographic.latitude);
alt = cartographic.height;
} else {
// フォールバック: position を {x:lon, y:lat, z:alt} とみなす(テストの単純モック互換)
if (typeof position.x !== 'number' || typeof position.y !== 'number') {
skippedCount++;
return;
}
lon = position.x;
lat = position.y;
alt = typeof position.z === 'number' ? position.z : 0;
}
// 範囲外チェック(少しマージンを持たせる)
if (lon < bounds.minLon - 0.001 || lon > bounds.maxLon + 0.001 ||
lat < bounds.minLat - 0.001 || lat > bounds.maxLat + 0.001 ||
alt < bounds.minAlt - 1 || alt > bounds.maxAlt + 1) {
skippedCount++;
return;
}
const { x: voxelX, y: voxelY, z: voxelZ } = DataProcessor._normalizeGridIndices(lon, lat, alt, bounds, grid);
// インデックスが有効範囲内かチェック
if (voxelX >= 0 && voxelX < grid.numVoxelsX &&
voxelY >= 0 && voxelY < grid.numVoxelsY &&
voxelZ >= 0 && voxelZ < grid.numVoxelsZ) {
const voxelKey = VoxelGrid.getVoxelKey(voxelX, voxelY, voxelZ);
if (!voxelData.has(voxelKey)) {
const newVoxelInfo = {
x: voxelX,
y: voxelY,
z: voxelZ,
entities: [],
count: 0
};
// v0.1.18: Initialize layerStats if aggregation enabled (ADR-0014)
if (aggregationEnabled) {
newVoxelInfo.layerStats = new Map();
}
voxelData.set(voxelKey, newVoxelInfo);
}
const voxelInfo = voxelData.get(voxelKey);
voxelInfo.entities.push(entity);
voxelInfo.count++;
// v0.1.18: Aggregate by layer (ADR-0014)
if (aggregationEnabled && resolveLayerKey) {
const layerKey = resolveLayerKey(entity, index) || 'unknown';
const currentCount = voxelInfo.layerStats.get(layerKey) || 0;
voxelInfo.layerStats.set(layerKey, currentCount + 1);
}
processedCount++;
} else {
skippedCount++;
}
} catch (error) {
Logger.warn(`エンティティ ${index} の処理に失敗:`, error);
skippedCount++;
}
});
// v0.1.18: Calculate layerTop (most common layer per voxel) (ADR-0014)
if (aggregationEnabled) {
for (const voxelInfo of voxelData.values()) {
if (voxelInfo.layerStats && voxelInfo.layerStats.size > 0) {
let maxCount = 0;
let topLayer = null;
for (const [layerKey, count] of voxelInfo.layerStats) {
if (count > maxCount) {
maxCount = count;
topLayer = layerKey;
}
}
voxelInfo.layerTop = topLayer;
}
}
Logger.debug(`[aggregation] Calculated layerTop for ${voxelData.size} voxels`);
}
Logger.info(`${processedCount}個のエンティティを${voxelData.size}個のボクセルに分類(${skippedCount}個はスキップ)`);
return voxelData;
}
/**
* Calculate statistics from voxel data.
* ボクセルデータから統計情報を計算します。
* @param {Map} voxelData - Voxel data / ボクセルデータ
* @param {Object} grid - Grid info / グリッド情報
* @returns {Object} Statistics / 統計情報
*/
static calculateStatistics(voxelData, grid, options = {}) {
// v1.2.0: Accept external statistics (e.g. from TimeSlicer global stats)
if (options._externalStats) {
Logger.debug('Using external statistics:', options._externalStats);
const stats = {
...options._externalStats,
// Keep dynamic counts that depend on current data
totalVoxels: grid.totalVoxels,
renderedVoxels: 0,
nonEmptyVoxels: voxelData.size,
emptyVoxels: Math.max(0, grid.totalVoxels - voxelData.size),
totalEntities: 0 // Will be calculated below
};
// Recalculate totalEntities for current frame
let totalEntities = 0;
for (const voxel of voxelData.values()) {
totalEntities += voxel.count;
}
stats.totalEntities = totalEntities;
stats.averageCount = voxelData.size > 0 ? totalEntities / voxelData.size : 0;
// Rebuild classification stats with external domain/breaks if needed
// For now, we assume _externalStats contains everything needed for coloring
// But we might need to regenerate classification object if it depends on specific format
if (!stats.classification) {
stats.classification = DataProcessor._buildClassificationStats(
[], // No need to re-scan counts if we trust external stats
options.classification,
stats.min,
stats.max
);
// Override domain with global domain
if (stats.domain) {
stats.classification.domain = stats.domain;
}
}
return stats;
}
if (voxelData.size === 0) {
const emptyStats = {
totalVoxels: grid.totalVoxels,
renderedVoxels: 0,
nonEmptyVoxels: 0,
emptyVoxels: grid.totalVoxels,
totalEntities: 0,
minCount: 0,
maxCount: 0,
averageCount: 0,
// v0.1.4: 自動調整情報の初期化
autoAdjusted: false,
originalVoxelSize: null,
finalVoxelSize: null,
adjustmentReason: null
};
emptyStats.classification = DataProcessor._buildClassificationStats([], options.classification, 0, 0);
return emptyStats;
}
const counts = Array.from(voxelData.values()).map(voxel => voxel.count);
const totalEntities = counts.reduce((sum, count) => sum + count, 0);
// v0.1.17: Spatial ID mode can exceed grid.totalVoxels, clamp emptyVoxels to non-negative
// 空間IDモードではgrid.totalVoxelsを超える可能性があるため、emptyVoxelsを非負にクランプ
const emptyVoxels = Math.max(0, grid.totalVoxels - voxelData.size);
const stats = {
totalVoxels: grid.totalVoxels,
renderedVoxels: 0, // 実際の描画後にVoxelRendererから設定される
nonEmptyVoxels: voxelData.size,
emptyVoxels: emptyVoxels,
totalEntities: totalEntities,
minCount: Math.min(...counts),
maxCount: Math.max(...counts),
averageCount: totalEntities / voxelData.size,
// v0.1.4: 自動調整情報の初期化
autoAdjusted: false,
originalVoxelSize: null,
finalVoxelSize: null,
adjustmentReason: null
};
stats.classification = DataProcessor._buildClassificationStats(
counts,
options.classification,
stats.minCount,
stats.maxCount
);
Logger.debug('統計情報計算完了:', stats);
return stats;
}
/**
* Normalize geographic coordinates into grid indices with zero-span guards.
* ゼロスパン対策付きで地理座標をグリッドインデックスに正規化
*
* @param {number} lon - Longitude / 経度
* @param {number} lat - Latitude / 緯度
* @param {number} alt - Altitude / 高度
* @param {Object} bounds - Bounds info / 境界情報
* @param {Object} grid - Grid info / グリッド情報
* @returns {{x: number, y: number, z: number}}
* @private
*/
static _normalizeGridIndices(lon, lat, alt, bounds, grid) {
const safeVoxelCount = (value) => {
const parsed = Number.isFinite(value) ? Math.floor(value) : 0;
return parsed > 0 ? parsed : 1;
};
const normalizeAxis = (coordinate, minBound, maxBound, voxelCount) => {
const span = Number.isFinite(maxBound - minBound) ? (maxBound - minBound) : 0;
const clampedCoordinate = Number.isFinite(coordinate) ? coordinate : minBound;
const count = safeVoxelCount(voxelCount);
if (span === 0) {
return 0;
}
const ratio = (clampedCoordinate - minBound) / span;
const rawIndex = Math.floor(ratio * count);
const maxIndex = count - 1;
const finiteIndex = Number.isFinite(rawIndex) ? rawIndex : 0;
return Math.max(0, Math.min(maxIndex, finiteIndex));
};
const x = normalizeAxis(lon, bounds.minLon, bounds.maxLon, grid.numVoxelsX);
const y = normalizeAxis(lat, bounds.minLat, bounds.maxLat, grid.numVoxelsY);
const z = normalizeAxis(alt, bounds.minAlt, bounds.maxAlt, grid.numVoxelsZ);
return { x, y, z };
}
/**
* Get top-N densest voxels.
* 上位 N 個のボクセルを取得します。
* @param {Map} voxelData - Voxel data / ボクセルデータ
* @param {number} topN - Number to get / 取得する上位の数
* @returns {Array} Top-N voxel info / 上位N個のボクセル情報
*/
static getTopNVoxels(voxelData, topN) {
if (voxelData.size === 0 || topN <= 0) {
return [];
}
// ボクセルを密度でソート
const sortedVoxels = Array.from(voxelData.values())
.sort((a, b) => b.count - a.count);
// 上位N個を返す
return sortedVoxels.slice(0, Math.min(topN, sortedVoxels.length));
}
/**
* Classify entities using Spatial ID (tile-grid mode).
* 空間IDを使用してエンティティを分類(tile-gridモード)。
* @param {Array} entities - Entity array / エンティティ配列
* @param {Object} bounds - Bounds info / 境界情報
* @param {Object} grid - Grid info (for normalized indices) / グリッド情報(正規化インデックス用)
* @param {Object} options - Processing options with spatialId config / spatialId設定を含む処理オプション
* @returns {Promise<Map>} Voxel data Map (key: zfxyStr, value: info) / ボクセルデータ(キー: zfxyStr, 値: ボクセル情報)
* @private
*/
static async _classifyBySpatialId(entities, bounds, grid, options) {
Logger.debug(`Spatial ID mode enabled: ${options.spatialId.mode}`);
// Initialize SpatialIdAdapter / SpatialIdAdapterを初期化
const adapter = new SpatialIdAdapter({
provider: options.spatialId.provider || 'ouranos-gex'
});
await adapter.loadProvider();
// Determine zoom level (auto or manual) / ズームレベルを決定(auto/manual)
let zoom;
const centerLat = (bounds.minLat + bounds.maxLat) / 2;
if (options.spatialId.zoomControl === 'auto') {
const targetSize = options.voxelSize || 30;
const tolerance = options.spatialId.zoomTolerancePct || 10;
zoom = adapter.calculateOptimalZoom(targetSize, centerLat, tolerance);
Logger.info(`Auto-selected zoom level ${zoom} for target size ${targetSize}m (lat: ${centerLat.toFixed(4)}°)`);
} else {
// Manual mode: validate zoom is a valid number
// 手動モード: ズームが有効な数値であることを検証
const manualZoom = options.spatialId.zoom;
if (typeof manualZoom === 'number' && Number.isFinite(manualZoom) && manualZoom >= 0 && manualZoom <= 35) {
zoom = Math.floor(manualZoom);
} else {
// Invalid or 'auto' passed to manual mode, use default
// 無効な値または'auto'が手動モードに渡された場合、デフォルトを使用
zoom = 25;
Logger.warn(`Invalid zoom value in manual mode (${manualZoom}), using default zoom level ${zoom}`);
}
Logger.info(`Using manual zoom level ${zoom}`);
}
// Store zoom level and provider info for statistics / 統計情報用にズームレベルとプロバイダー情報を保存
options._resolvedZoom = zoom;
options._spatialIdProvider = adapter.fallbackMode ? null : options.spatialId.provider;
// v0.1.18: Layer aggregation setup (ADR-0014)
const aggregationOptions = options.aggregation || {};
const aggregationEnabled = Boolean(aggregationOptions.enabled);
const currentTime = Cesium.JulianDate.now();
const byProperty = typeof aggregationOptions.byProperty === 'string' && aggregationOptions.byProperty.trim() !== ''
? aggregationOptions.byProperty.trim()
: null;
const userResolver = typeof aggregationOptions.keyResolver === 'function' ? aggregationOptions.keyResolver : null;
let resolveLayerKey = null;
if (aggregationEnabled) {
if (userResolver || byProperty) {
resolveLayerKey = (entity, entityIndex) => {
let value;
if (userResolver) {
try {
value = userResolver(entity);
} catch (error) {
Logger.warn(`[aggregation] keyResolver threw error for entity ${entityIndex}, using "unknown"`, error);
return 'unknown';
}
value = resolvePropertyValue(value, currentTime);
} else if (byProperty) {
let resolved;
try {
const bag = entity.properties?.getValue?.(currentTime);
if (bag && typeof bag === 'object' && byProperty in bag) {
resolved = bag[byProperty];
}
} catch (error) {
Logger.warn(`[aggregation] Failed to resolve PropertyBag for ${byProperty}, fallback to direct property`, error);
}
if (resolved === undefined) {
const prop = entity.properties?.[byProperty];
resolved = resolvePropertyValue(prop, currentTime);
}
value = resolved;
}
if (value === undefined || value === null || (typeof value === 'number' && Number.isNaN(value))) {
return 'unknown';
}
const stringValue = String(value);
return stringValue.trim() === '' ? 'unknown' : stringValue;
};
} else {
Logger.warn('[aggregation] enabled but no byProperty or keyResolver specified, using "default" key');
resolveLayerKey = () => 'default';
}
}
// Process entities and aggregate by spatial ID / エンティティを処理して空間IDで集約
const voxelMap = new Map();
let processedCount = 0;
let skippedCount = 0;
let entityIndex = 0;
for (const entity of entities) {
try {
// Get entity position / エンティティの位置を取得
let position;
if (entity.position) {
if (typeof entity.position.getValue === 'function') {
position = entity.position.getValue(currentTime);
} else {
position = entity.position;
}
}
if (!position) {
skippedCount++;
continue;
}
// Convert to lng/lat/alt / lng/lat/altに変換
let lng, lat, alt;
const looksLikeDegrees = typeof position?.x === 'number' && typeof position?.y === 'number' &&
Math.abs(position.x) <= 360 && Math.abs(position.y) <= 90;
if (looksLikeDegrees) {
lng = position.x;
lat = position.y;
alt = typeof position.z === 'number' ? position.z : 0;
} else if (Cesium.Cartographic && typeof Cesium.Cartographic.fromCartesian === 'function') {
const cartographic = Cesium.Cartographic.fromCartesian(position);
if (!cartographic) {
skippedCount++;
continue;
}
lng = Cesium.Math.toDegrees(cartographic.longitude);
lat = Cesium.Math.toDegrees(cartographic.latitude);
alt = cartographic.height;
} else {
if (typeof position.x !== 'number' || typeof position.y !== 'number') {
skippedCount++;
continue;
}
lng = position.x;
lat = position.y;
alt = typeof position.z === 'number' ? position.z : 0;
}
// Get voxel bounds from spatial ID / 空間IDからボクセル境界を取得
const { zfxy, zfxyStr, vertices } = adapter.getVoxelBounds(lng, lat, alt, zoom);
// Aggregate by zfxyStr (public key format) / zfxyStr(公開キー形式)で集約
if (!voxelMap.has(zfxyStr)) {
// Calculate normalized indices for VoxelSelector compatibility
// VoxelSelector互換のために正規化されたインデックスを計算
// Center coordinates from vertices average / 頂点の平均から中心座標を計算
const centerLng = vertices.reduce((sum, v) => sum + v.lng, 0) / 8;
const centerLat = vertices.reduce((sum, v) => sum + v.lat, 0) / 8;
const centerAlt = vertices.reduce((sum, v) => sum + v.alt, 0) / 8;
const { x: safeX, y: safeY, z: safeZ } = DataProcessor._normalizeGridIndices(centerLng, centerLat, centerAlt, bounds, grid);
const newVoxelInfo = {
key: zfxyStr,
// Normalized indices for compatibility with VoxelSelector and other systems
// VoxelSelectorなど他システムとの互換性のための正規化インデックス
x: safeX,
y: safeY,
z: safeZ,
bounds: vertices, // 8 vertices from ouranos-gex or fallback / ouranos-gexまたはフォールバックからの8頂点
spatialId: { ...zfxy, id: zfxyStr },
entities: [],
count: 0
};
// v0.1.18: Initialize layerStats if aggregation enabled (ADR-0014)
if (aggregationEnabled) {
newVoxelInfo.layerStats = new Map();
}
voxelMap.set(zfxyStr, newVoxelInfo);
}
const voxelInfo = voxelMap.get(zfxyStr);
voxelInfo.entities.push(entity);
voxelInfo.count++;
// v0.1.18: Aggregate by layer (ADR-0014)
if (aggregationEnabled && resolveLayerKey) {
const layerKey = resolveLayerKey(entity, entityIndex) || 'unknown';
const currentCount = voxelInfo.layerStats.get(layerKey) || 0;
voxelInfo.layerStats.set(layerKey, currentCount + 1);
}
processedCount++;
} catch (error) {
Logger.warn(`Failed to process entity for spatial ID:`, error);
skippedCount++;
}
entityIndex++;
}
// v0.1.18: Calculate layerTop (most common layer per voxel) (ADR-0014)
if (aggregationEnabled) {
for (const voxelInfo of voxelMap.values()) {
if (voxelInfo.layerStats && voxelInfo.layerStats.size > 0) {
let maxCount = 0;
let topLayer = null;
for (const [layerKey, count] of voxelInfo.layerStats) {
if (count > maxCount) {
maxCount = count;
topLayer = layerKey;
}
}
voxelInfo.layerTop = topLayer;
}
}
Logger.debug(`[aggregation] Calculated layerTop for ${voxelMap.size} voxels (Spatial ID mode)`);
}
Logger.info(`Spatial ID: ${processedCount} entities classified into ${voxelMap.size} voxels (${skippedCount} skipped)`);
return voxelMap;
}
static _buildClassificationStats(counts, classificationOptions = {}, fallbackMin, fallbackMax) {
const normalizedOptions = classificationOptions && typeof classificationOptions === 'object'
? classificationOptions
: {};
const enabled = Boolean(normalizedOptions.enabled);
const scheme = (normalizedOptions.scheme || 'linear').toLowerCase();
const classes = Math.max(2, normalizedOptions.classes || 5);
let domain = null;
if (Array.isArray(normalizedOptions.domain) && normalizedOptions.domain.length === 2) {
const [minDomain, maxDomain] = normalizedOptions.domain;
if (Number.isFinite(minDomain) && Number.isFinite(maxDomain)) {
domain = [minDomain, maxDomain];
}
}
if (!domain) {
const safeMin = Number.isFinite(fallbackMin) ? fallbackMin : 0;
const safeMax = Number.isFinite(fallbackMax) ? fallbackMax : safeMin;
domain = [safeMin, safeMax];
}
const numericValues = Array.isArray(counts)
? counts.filter(value => Number.isFinite(value))
: [];
const sortedValues = [...numericValues].sort((a, b) => a - b);
const stats = {
enabled,
scheme,
domain,
classes: normalizedOptions.classes ?? classes,
thresholds: normalizedOptions.thresholds ?? null,
sampleSize: sortedValues.length,
quantiles: null,
histogram: null,
breaks: null,
jenksBreaks: null,
ckmeansClusters: null
};
if (sortedValues.length === 0) {
return stats;
}
try {
const backend = getBackend();
stats.quantiles = [
backend.quantile(sortedValues, 0.25),
backend.quantile(sortedValues, 0.5),
backend.quantile(sortedValues, 0.75),
backend.quantile(sortedValues, 1)
];
} catch (error) {
Logger.warn('Failed to compute quantiles for classification statistics:', error);
stats.quantiles = null;
}
stats.histogram = DataProcessor._createHistogramFromSorted(sortedValues);
const safeClassCount = Math.min(Math.max(2, classes), sortedValues.length);
if (enabled && scheme === 'jenks' && sortedValues.length >= 2 && safeClassCount >= 2) {
try {
const backend = getBackend();
const jenks = backend.jenksBreaks(sortedValues, safeClassCount);
stats.jenksBreaks = Array.isArray(jenks) && jenks.length > 0 ? jenks : null;
if (typeof backend.ckmeans === 'function') {
const clusters = backend.ckmeans(sortedValues, safeClassCount);
stats.ckmeansClusters = Array.isArray(clusters) && clusters.length > 0 ? clusters : null;
}
} catch (error) {
Logger.warn('Failed to compute jenks statistics:', error);
stats.jenksBreaks = null;
stats.ckmeansClusters = null;
}
}
if (enabled) {
try {
const classifier = createClassifier({
scheme,
classes: normalizedOptions.classes,
thresholds: normalizedOptions.thresholds,
colorMap: normalizedOptions.colorMap,
domain,
values: sortedValues
});
stats.breaks = classifier.breaks || null;
} catch (error) {
Logger.warn('Failed to build classifier for statistics:', error);
stats.breaks = null;
}
}
return stats;
}
static _createHistogramFromSorted(sortedValues, maxBins = 10) {
if (!Array.isArray(sortedValues) || sortedValues.length === 0) {
return null;
}
const minValue = sortedValues[0];
const maxValue = sortedValues[sortedValues.length - 1];
if (!Number.isFinite(minValue) || !Number.isFinite(maxValue)) {
return null;
}
if (minValue === maxValue) {
return {
bins: [{ start: minValue, end: maxValue }],
counts: [sortedValues.length]
};
}
const binCount = Math.max(1, Math.min(maxBins, sortedValues.length));
const binWidth = (maxValue - minValue) / binCount || 1;
const bins = [];
const counts = new Array(binCount).fill(0);
for (let i = 0; i < binCount; i++) {
const start = minValue + binWidth * i;
const end = i === binCount - 1 ? maxValue : start + binWidth;
bins.push({ start, end });
}
for (const value of sortedValues) {
if (!Number.isFinite(value)) {
continue;
}
let binIndex = Math.floor((value - minValue) / binWidth);
if (binIndex < 0) {
binIndex = 0;
} else if (binIndex >= binCount) {
binIndex = binCount - 1;
}
counts[binIndex]++;
}
return { bins, counts };
}
}関連: DataProcessorクラス
/**
* データ処理を担当するクラス(シンプル実装)
*/
import * as Cesium from 'cesium';
import { VoxelGrid } from './VoxelGrid.js';
import { Logger } from '../utils/logger.js';
import { SpatialIdAdapter } from './spatial/SpatialIdAdapter.js';
import { resolvePropertyValue } from '../utils/cesiumProperty.js';
import { getBackend } from '../utils/classificationBackend.js';
import { createClassifier } from '../utils/classification.js';
/**
* Class responsible for processing entity data.
* エンティティデータの処理を担当するクラス。
*/
export class DataProcessor {
/**
* Classify entities into voxels (simple implementation).
* エンティティをボクセルに分類(シンプル実装)。
* @param {Array} entities - Entity array / エンティティ配列
* @param {Object} bounds - Bounds info / 境界情報
* @param {Object} grid - Grid info / グリッド情報
* @param {Object} [options={}] - Processing options (v0.1.17: spatialId support) / 処理オプション
* @returns {Promise<Map>} Voxel data Map (key: voxel key, value: info) / ボクセルデータ(キー: ボクセルキー, 値: ボクセル情報)
*/
static async classifyEntitiesIntoVoxels(entities, bounds, grid, options = {}) {
// v0.1.17: Spatial ID mode (tile-grid) / 空間IDモード(tile-grid)
if (options.spatialId?.enabled) {
return await DataProcessor._classifyBySpatialId(entities, bounds, grid, options);
}
// Uniform grid mode (default) / 一様グリッドモード(デフォルト)
const voxelData = new Map();
let processedCount = 0;
let skippedCount = 0;
// v0.1.18: Layer aggregation setup (ADR-0014)
const aggregationOptions = options.aggregation || {};
const aggregationEnabled = Boolean(aggregationOptions.enabled);
const currentTime = Cesium.JulianDate.now();
const byProperty = typeof aggregationOptions.byProperty === 'string' && aggregationOptions.byProperty.trim() !== ''
? aggregationOptions.byProperty.trim()
: null;
const userResolver = typeof aggregationOptions.keyResolver === 'function' ? aggregationOptions.keyResolver : null;
let resolveLayerKey = null;
if (aggregationEnabled) {
if (userResolver || byProperty) {
resolveLayerKey = (entity, entityIndex) => {
let value;
if (userResolver) {
try {
value = userResolver(entity);
} catch (error) {
Logger.warn(`[aggregation] keyResolver threw error for entity ${entityIndex}, using "unknown"`, error);
return 'unknown';
}
value = resolvePropertyValue(value, currentTime);
} else if (byProperty) {
let resolved;
try {
const bag = entity.properties?.getValue?.(currentTime);
if (bag && typeof bag === 'object' && byProperty in bag) {
resolved = bag[byProperty];
}
} catch (error) {
Logger.warn(`[aggregation] Failed to resolve PropertyBag for ${byProperty}, fallback to direct property`, error);
}
if (resolved === undefined) {
const prop = entity.properties?.[byProperty];
resolved = resolvePropertyValue(prop, currentTime);
}
value = resolved;
}
if (value === undefined || value === null || (typeof value === 'number' && Number.isNaN(value))) {
return 'unknown';
}
const stringValue = String(value);
return stringValue.trim() === '' ? 'unknown' : stringValue;
};
} else {
Logger.warn('[aggregation] enabled but no byProperty or keyResolver specified, using "default" key');
resolveLayerKey = () => 'default';
}
}
Logger.debug(`Processing ${entities.length} entities for classification`);
entities.forEach((entity, index) => {
try {
// エンティティの位置を取得(シンプルなアプローチ)
let position;
if (entity.position) {
if (typeof entity.position.getValue === 'function') {
position = entity.position.getValue(currentTime);
} else {
position = entity.position;
}
}
if (!position) {
skippedCount++;
return; // 位置がない場合はスキップ
}
// Cartesian3からCartographicに変換(テスト環境向けフォールバックあり)
let lon, lat, alt;
const looksLikeDegrees = typeof position?.x === 'number' && typeof position?.y === 'number' &&
Math.abs(position.x) <= 360 && Math.abs(position.y) <= 90;
if (looksLikeDegrees) {
// フォールバック: position を {x:lon, y:lat, z:alt} とみなす(テストの単純モック互換)
lon = position.x;
lat = position.y;
alt = typeof position.z === 'number' ? position.z : 0;
} else if (Cesium.Cartographic && typeof Cesium.Cartographic.fromCartesian === 'function') {
const cartographic = Cesium.Cartographic.fromCartesian(position);
if (!cartographic) {
skippedCount++;
return;
}
// 地理座標に変換
lon = Cesium.Math.toDegrees(cartographic.longitude);
lat = Cesium.Math.toDegrees(cartographic.latitude);
alt = cartographic.height;
} else {
// フォールバック: position を {x:lon, y:lat, z:alt} とみなす(テストの単純モック互換)
if (typeof position.x !== 'number' || typeof position.y !== 'number') {
skippedCount++;
return;
}
lon = position.x;
lat = position.y;
alt = typeof position.z === 'number' ? position.z : 0;
}
// 範囲外チェック(少しマージンを持たせる)
if (lon < bounds.minLon - 0.001 || lon > bounds.maxLon + 0.001 ||
lat < bounds.minLat - 0.001 || lat > bounds.maxLat + 0.001 ||
alt < bounds.minAlt - 1 || alt > bounds.maxAlt + 1) {
skippedCount++;
return;
}
const { x: voxelX, y: voxelY, z: voxelZ } = DataProcessor._normalizeGridIndices(lon, lat, alt, bounds, grid);
// インデックスが有効範囲内かチェック
if (voxelX >= 0 && voxelX < grid.numVoxelsX &&
voxelY >= 0 && voxelY < grid.numVoxelsY &&
voxelZ >= 0 && voxelZ < grid.numVoxelsZ) {
const voxelKey = VoxelGrid.getVoxelKey(voxelX, voxelY, voxelZ);
if (!voxelData.has(voxelKey)) {
const newVoxelInfo = {
x: voxelX,
y: voxelY,
z: voxelZ,
entities: [],
count: 0
};
// v0.1.18: Initialize layerStats if aggregation enabled (ADR-0014)
if (aggregationEnabled) {
newVoxelInfo.layerStats = new Map();
}
voxelData.set(voxelKey, newVoxelInfo);
}
const voxelInfo = voxelData.get(voxelKey);
voxelInfo.entities.push(entity);
voxelInfo.count++;
// v0.1.18: Aggregate by layer (ADR-0014)
if (aggregationEnabled && resolveLayerKey) {
const layerKey = resolveLayerKey(entity, index) || 'unknown';
const currentCount = voxelInfo.layerStats.get(layerKey) || 0;
voxelInfo.layerStats.set(layerKey, currentCount + 1);
}
processedCount++;
} else {
skippedCount++;
}
} catch (error) {
Logger.warn(`エンティティ ${index} の処理に失敗:`, error);
skippedCount++;
}
});
// v0.1.18: Calculate layerTop (most common layer per voxel) (ADR-0014)
if (aggregationEnabled) {
for (const voxelInfo of voxelData.values()) {
if (voxelInfo.layerStats && voxelInfo.layerStats.size > 0) {
let maxCount = 0;
let topLayer = null;
for (const [layerKey, count] of voxelInfo.layerStats) {
if (count > maxCount) {
maxCount = count;
topLayer = layerKey;
}
}
voxelInfo.layerTop = topLayer;
}
}
Logger.debug(`[aggregation] Calculated layerTop for ${voxelData.size} voxels`);
}
Logger.info(`${processedCount}個のエンティティを${voxelData.size}個のボクセルに分類(${skippedCount}個はスキップ)`);
return voxelData;
}
/**
* Calculate statistics from voxel data.
* ボクセルデータから統計情報を計算します。
* @param {Map} voxelData - Voxel data / ボクセルデータ
* @param {Object} grid - Grid info / グリッド情報
* @returns {Object} Statistics / 統計情報
*/
static calculateStatistics(voxelData, grid, options = {}) {
// v1.2.0: Accept external statistics (e.g. from TimeSlicer global stats)
if (options._externalStats) {
Logger.debug('Using external statistics:', options._externalStats);
const stats = {
...options._externalStats,
// Keep dynamic counts that depend on current data
totalVoxels: grid.totalVoxels,
renderedVoxels: 0,
nonEmptyVoxels: voxelData.size,
emptyVoxels: Math.max(0, grid.totalVoxels - voxelData.size),
totalEntities: 0 // Will be calculated below
};
// Recalculate totalEntities for current frame
let totalEntities = 0;
for (const voxel of voxelData.values()) {
totalEntities += voxel.count;
}
stats.totalEntities = totalEntities;
stats.averageCount = voxelData.size > 0 ? totalEntities / voxelData.size : 0;
// Rebuild classification stats with external domain/breaks if needed
// For now, we assume _externalStats contains everything needed for coloring
// But we might need to regenerate classification object if it depends on specific format
if (!stats.classification) {
stats.classification = DataProcessor._buildClassificationStats(
[], // No need to re-scan counts if we trust external stats
options.classification,
stats.min,
stats.max
);
// Override domain with global domain
if (stats.domain) {
stats.classification.domain = stats.domain;
}
}
return stats;
}
if (voxelData.size === 0) {
const emptyStats = {
totalVoxels: grid.totalVoxels,
renderedVoxels: 0,
nonEmptyVoxels: 0,
emptyVoxels: grid.totalVoxels,
totalEntities: 0,
minCount: 0,
maxCount: 0,
averageCount: 0,
// v0.1.4: 自動調整情報の初期化
autoAdjusted: false,
originalVoxelSize: null,
finalVoxelSize: null,
adjustmentReason: null
};
emptyStats.classification = DataProcessor._buildClassificationStats([], options.classification, 0, 0);
return emptyStats;
}
const counts = Array.from(voxelData.values()).map(voxel => voxel.count);
const totalEntities = counts.reduce((sum, count) => sum + count, 0);
// v0.1.17: Spatial ID mode can exceed grid.totalVoxels, clamp emptyVoxels to non-negative
// 空間IDモードではgrid.totalVoxelsを超える可能性があるため、emptyVoxelsを非負にクランプ
const emptyVoxels = Math.max(0, grid.totalVoxels - voxelData.size);
const stats = {
totalVoxels: grid.totalVoxels,
renderedVoxels: 0, // 実際の描画後にVoxelRendererから設定される
nonEmptyVoxels: voxelData.size,
emptyVoxels: emptyVoxels,
totalEntities: totalEntities,
minCount: Math.min(...counts),
maxCount: Math.max(...counts),
averageCount: totalEntities / voxelData.size,
// v0.1.4: 自動調整情報の初期化
autoAdjusted: false,
originalVoxelSize: null,
finalVoxelSize: null,
adjustmentReason: null
};
stats.classification = DataProcessor._buildClassificationStats(
counts,
options.classification,
stats.minCount,
stats.maxCount
);
Logger.debug('統計情報計算完了:', stats);
return stats;
}
/**
* Normalize geographic coordinates into grid indices with zero-span guards.
* ゼロスパン対策付きで地理座標をグリッドインデックスに正規化
*
* @param {number} lon - Longitude / 経度
* @param {number} lat - Latitude / 緯度
* @param {number} alt - Altitude / 高度
* @param {Object} bounds - Bounds info / 境界情報
* @param {Object} grid - Grid info / グリッド情報
* @returns {{x: number, y: number, z: number}}
* @private
*/
static _normalizeGridIndices(lon, lat, alt, bounds, grid) {
const safeVoxelCount = (value) => {
const parsed = Number.isFinite(value) ? Math.floor(value) : 0;
return parsed > 0 ? parsed : 1;
};
const normalizeAxis = (coordinate, minBound, maxBound, voxelCount) => {
const span = Number.isFinite(maxBound - minBound) ? (maxBound - minBound) : 0;
const clampedCoordinate = Number.isFinite(coordinate) ? coordinate : minBound;
const count = safeVoxelCount(voxelCount);
if (span === 0) {
return 0;
}
const ratio = (clampedCoordinate - minBound) / span;
const rawIndex = Math.floor(ratio * count);
const maxIndex = count - 1;
const finiteIndex = Number.isFinite(rawIndex) ? rawIndex : 0;
return Math.max(0, Math.min(maxIndex, finiteIndex));
};
const x = normalizeAxis(lon, bounds.minLon, bounds.maxLon, grid.numVoxelsX);
const y = normalizeAxis(lat, bounds.minLat, bounds.maxLat, grid.numVoxelsY);
const z = normalizeAxis(alt, bounds.minAlt, bounds.maxAlt, grid.numVoxelsZ);
return { x, y, z };
}
/**
* Get top-N densest voxels.
* 上位 N 個のボクセルを取得します。
* @param {Map} voxelData - Voxel data / ボクセルデータ
* @param {number} topN - Number to get / 取得する上位の数
* @returns {Array} Top-N voxel info / 上位N個のボクセル情報
*/
static getTopNVoxels(voxelData, topN) {
if (voxelData.size === 0 || topN <= 0) {
return [];
}
// ボクセルを密度でソート
const sortedVoxels = Array.from(voxelData.values())
.sort((a, b) => b.count - a.count);
// 上位N個を返す
return sortedVoxels.slice(0, Math.min(topN, sortedVoxels.length));
}
/**
* Classify entities using Spatial ID (tile-grid mode).
* 空間IDを使用してエンティティを分類(tile-gridモード)。
* @param {Array} entities - Entity array / エンティティ配列
* @param {Object} bounds - Bounds info / 境界情報
* @param {Object} grid - Grid info (for normalized indices) / グリッド情報(正規化インデックス用)
* @param {Object} options - Processing options with spatialId config / spatialId設定を含む処理オプション
* @returns {Promise<Map>} Voxel data Map (key: zfxyStr, value: info) / ボクセルデータ(キー: zfxyStr, 値: ボクセル情報)
* @private
*/
static async _classifyBySpatialId(entities, bounds, grid, options) {
Logger.debug(`Spatial ID mode enabled: ${options.spatialId.mode}`);
// Initialize SpatialIdAdapter / SpatialIdAdapterを初期化
const adapter = new SpatialIdAdapter({
provider: options.spatialId.provider || 'ouranos-gex'
});
await adapter.loadProvider();
// Determine zoom level (auto or manual) / ズームレベルを決定(auto/manual)
let zoom;
const centerLat = (bounds.minLat + bounds.maxLat) / 2;
if (options.spatialId.zoomControl === 'auto') {
const targetSize = options.voxelSize || 30;
const tolerance = options.spatialId.zoomTolerancePct || 10;
zoom = adapter.calculateOptimalZoom(targetSize, centerLat, tolerance);
Logger.info(`Auto-selected zoom level ${zoom} for target size ${targetSize}m (lat: ${centerLat.toFixed(4)}°)`);
} else {
// Manual mode: validate zoom is a valid number
// 手動モード: ズームが有効な数値であることを検証
const manualZoom = options.spatialId.zoom;
if (typeof manualZoom === 'number' && Number.isFinite(manualZoom) && manualZoom >= 0 && manualZoom <= 35) {
zoom = Math.floor(manualZoom);
} else {
// Invalid or 'auto' passed to manual mode, use default
// 無効な値または'auto'が手動モードに渡された場合、デフォルトを使用
zoom = 25;
Logger.warn(`Invalid zoom value in manual mode (${manualZoom}), using default zoom level ${zoom}`);
}
Logger.info(`Using manual zoom level ${zoom}`);
}
// Store zoom level and provider info for statistics / 統計情報用にズームレベルとプロバイダー情報を保存
options._resolvedZoom = zoom;
options._spatialIdProvider = adapter.fallbackMode ? null : options.spatialId.provider;
// v0.1.18: Layer aggregation setup (ADR-0014)
const aggregationOptions = options.aggregation || {};
const aggregationEnabled = Boolean(aggregationOptions.enabled);
const currentTime = Cesium.JulianDate.now();
const byProperty = typeof aggregationOptions.byProperty === 'string' && aggregationOptions.byProperty.trim() !== ''
? aggregationOptions.byProperty.trim()
: null;
const userResolver = typeof aggregationOptions.keyResolver === 'function' ? aggregationOptions.keyResolver : null;
let resolveLayerKey = null;
if (aggregationEnabled) {
if (userResolver || byProperty) {
resolveLayerKey = (entity, entityIndex) => {
let value;
if (userResolver) {
try {
value = userResolver(entity);
} catch (error) {
Logger.warn(`[aggregation] keyResolver threw error for entity ${entityIndex}, using "unknown"`, error);
return 'unknown';
}
value = resolvePropertyValue(value, currentTime);
} else if (byProperty) {
let resolved;
try {
const bag = entity.properties?.getValue?.(currentTime);
if (bag && typeof bag === 'object' && byProperty in bag) {
resolved = bag[byProperty];
}
} catch (error) {
Logger.warn(`[aggregation] Failed to resolve PropertyBag for ${byProperty}, fallback to direct property`, error);
}
if (resolved === undefined) {
const prop = entity.properties?.[byProperty];
resolved = resolvePropertyValue(prop, currentTime);
}
value = resolved;
}
if (value === undefined || value === null || (typeof value === 'number' && Number.isNaN(value))) {
return 'unknown';
}
const stringValue = String(value);
return stringValue.trim() === '' ? 'unknown' : stringValue;
};
} else {
Logger.warn('[aggregation] enabled but no byProperty or keyResolver specified, using "default" key');
resolveLayerKey = () => 'default';
}
}
// Process entities and aggregate by spatial ID / エンティティを処理して空間IDで集約
const voxelMap = new Map();
let processedCount = 0;
let skippedCount = 0;
let entityIndex = 0;
for (const entity of entities) {
try {
// Get entity position / エンティティの位置を取得
let position;
if (entity.position) {
if (typeof entity.position.getValue === 'function') {
position = entity.position.getValue(currentTime);
} else {
position = entity.position;
}
}
if (!position) {
skippedCount++;
continue;
}
// Convert to lng/lat/alt / lng/lat/altに変換
let lng, lat, alt;
const looksLikeDegrees = typeof position?.x === 'number' && typeof position?.y === 'number' &&
Math.abs(position.x) <= 360 && Math.abs(position.y) <= 90;
if (looksLikeDegrees) {
lng = position.x;
lat = position.y;
alt = typeof position.z === 'number' ? position.z : 0;
} else if (Cesium.Cartographic && typeof Cesium.Cartographic.fromCartesian === 'function') {
const cartographic = Cesium.Cartographic.fromCartesian(position);
if (!cartographic) {
skippedCount++;
continue;
}
lng = Cesium.Math.toDegrees(cartographic.longitude);
lat = Cesium.Math.toDegrees(cartographic.latitude);
alt = cartographic.height;
} else {
if (typeof position.x !== 'number' || typeof position.y !== 'number') {
skippedCount++;
continue;
}
lng = position.x;
lat = position.y;
alt = typeof position.z === 'number' ? position.z : 0;
}
// Get voxel bounds from spatial ID / 空間IDからボクセル境界を取得
const { zfxy, zfxyStr, vertices } = adapter.getVoxelBounds(lng, lat, alt, zoom);
// Aggregate by zfxyStr (public key format) / zfxyStr(公開キー形式)で集約
if (!voxelMap.has(zfxyStr)) {
// Calculate normalized indices for VoxelSelector compatibility
// VoxelSelector互換のために正規化されたインデックスを計算
// Center coordinates from vertices average / 頂点の平均から中心座標を計算
const centerLng = vertices.reduce((sum, v) => sum + v.lng, 0) / 8;
const centerLat = vertices.reduce((sum, v) => sum + v.lat, 0) / 8;
const centerAlt = vertices.reduce((sum, v) => sum + v.alt, 0) / 8;
const { x: safeX, y: safeY, z: safeZ } = DataProcessor._normalizeGridIndices(centerLng, centerLat, centerAlt, bounds, grid);
const newVoxelInfo = {
key: zfxyStr,
// Normalized indices for compatibility with VoxelSelector and other systems
// VoxelSelectorなど他システムとの互換性のための正規化インデックス
x: safeX,
y: safeY,
z: safeZ,
bounds: vertices, // 8 vertices from ouranos-gex or fallback / ouranos-gexまたはフォールバックからの8頂点
spatialId: { ...zfxy, id: zfxyStr },
entities: [],
count: 0
};
// v0.1.18: Initialize layerStats if aggregation enabled (ADR-0014)
if (aggregationEnabled) {
newVoxelInfo.layerStats = new Map();
}
voxelMap.set(zfxyStr, newVoxelInfo);
}
const voxelInfo = voxelMap.get(zfxyStr);
voxelInfo.entities.push(entity);
voxelInfo.count++;
// v0.1.18: Aggregate by layer (ADR-0014)
if (aggregationEnabled && resolveLayerKey) {
const layerKey = resolveLayerKey(entity, entityIndex) || 'unknown';
const currentCount = voxelInfo.layerStats.get(layerKey) || 0;
voxelInfo.layerStats.set(layerKey, currentCount + 1);
}
processedCount++;
} catch (error) {
Logger.warn(`Failed to process entity for spatial ID:`, error);
skippedCount++;
}
entityIndex++;
}
// v0.1.18: Calculate layerTop (most common layer per voxel) (ADR-0014)
if (aggregationEnabled) {
for (const voxelInfo of voxelMap.values()) {
if (voxelInfo.layerStats && voxelInfo.layerStats.size > 0) {
let maxCount = 0;
let topLayer = null;
for (const [layerKey, count] of voxelInfo.layerStats) {
if (count > maxCount) {
maxCount = count;
topLayer = layerKey;
}
}
voxelInfo.layerTop = topLayer;
}
}
Logger.debug(`[aggregation] Calculated layerTop for ${voxelMap.size} voxels (Spatial ID mode)`);
}
Logger.info(`Spatial ID: ${processedCount} entities classified into ${voxelMap.size} voxels (${skippedCount} skipped)`);
return voxelMap;
}
static _buildClassificationStats(counts, classificationOptions = {}, fallbackMin, fallbackMax) {
const normalizedOptions = classificationOptions && typeof classificationOptions === 'object'
? classificationOptions
: {};
const enabled = Boolean(normalizedOptions.enabled);
const scheme = (normalizedOptions.scheme || 'linear').toLowerCase();
const classes = Math.max(2, normalizedOptions.classes || 5);
let domain = null;
if (Array.isArray(normalizedOptions.domain) && normalizedOptions.domain.length === 2) {
const [minDomain, maxDomain] = normalizedOptions.domain;
if (Number.isFinite(minDomain) && Number.isFinite(maxDomain)) {
domain = [minDomain, maxDomain];
}
}
if (!domain) {
const safeMin = Number.isFinite(fallbackMin) ? fallbackMin : 0;
const safeMax = Number.isFinite(fallbackMax) ? fallbackMax : safeMin;
domain = [safeMin, safeMax];
}
const numericValues = Array.isArray(counts)
? counts.filter(value => Number.isFinite(value))
: [];
const sortedValues = [...numericValues].sort((a, b) => a - b);
const stats = {
enabled,
scheme,
domain,
classes: normalizedOptions.classes ?? classes,
thresholds: normalizedOptions.thresholds ?? null,
sampleSize: sortedValues.length,
quantiles: null,
histogram: null,
breaks: null,
jenksBreaks: null,
ckmeansClusters: null
};
if (sortedValues.length === 0) {
return stats;
}
try {
const backend = getBackend();
stats.quantiles = [
backend.quantile(sortedValues, 0.25),
backend.quantile(sortedValues, 0.5),
backend.quantile(sortedValues, 0.75),
backend.quantile(sortedValues, 1)
];
} catch (error) {
Logger.warn('Failed to compute quantiles for classification statistics:', error);
stats.quantiles = null;
}
stats.histogram = DataProcessor._createHistogramFromSorted(sortedValues);
const safeClassCount = Math.min(Math.max(2, classes), sortedValues.length);
if (enabled && scheme === 'jenks' && sortedValues.length >= 2 && safeClassCount >= 2) {
try {
const backend = getBackend();
const jenks = backend.jenksBreaks(sortedValues, safeClassCount);
stats.jenksBreaks = Array.isArray(jenks) && jenks.length > 0 ? jenks : null;
if (typeof backend.ckmeans === 'function') {
const clusters = backend.ckmeans(sortedValues, safeClassCount);
stats.ckmeansClusters = Array.isArray(clusters) && clusters.length > 0 ? clusters : null;
}
} catch (error) {
Logger.warn('Failed to compute jenks statistics:', error);
stats.jenksBreaks = null;
stats.ckmeansClusters = null;
}
}
if (enabled) {
try {
const classifier = createClassifier({
scheme,
classes: normalizedOptions.classes,
thresholds: normalizedOptions.thresholds,
colorMap: normalizedOptions.colorMap,
domain,
values: sortedValues
});
stats.breaks = classifier.breaks || null;
} catch (error) {
Logger.warn('Failed to build classifier for statistics:', error);
stats.breaks = null;
}
}
return stats;
}
static _createHistogramFromSorted(sortedValues, maxBins = 10) {
if (!Array.isArray(sortedValues) || sortedValues.length === 0) {
return null;
}
const minValue = sortedValues[0];
const maxValue = sortedValues[sortedValues.length - 1];
if (!Number.isFinite(minValue) || !Number.isFinite(maxValue)) {
return null;
}
if (minValue === maxValue) {
return {
bins: [{ start: minValue, end: maxValue }],
counts: [sortedValues.length]
};
}
const binCount = Math.max(1, Math.min(maxBins, sortedValues.length));
const binWidth = (maxValue - minValue) / binCount || 1;
const bins = [];
const counts = new Array(binCount).fill(0);
for (let i = 0; i < binCount; i++) {
const start = minValue + binWidth * i;
const end = i === binCount - 1 ? maxValue : start + binWidth;
bins.push({ start, end });
}
for (const value of sortedValues) {
if (!Number.isFinite(value)) {
continue;
}
let binIndex = Math.floor((value - minValue) / binWidth);
if (binIndex < 0) {
binIndex = 0;
} else if (binIndex >= binCount) {
binIndex = binCount - 1;
}
counts[binIndex]++;
}
return { bins, counts };
}
}