utils_validation.js - hiro-nyon/cesium-heatbox GitHub Wiki
Source: utils/validation.js
日本語 | English
English
See also: Class: validation
/**
* Validation utility functions.
* バリデーション関連のユーティリティ関数。
*/
import * as Cesium from 'cesium';
import { PERFORMANCE_LIMITS, ERROR_MESSAGES, DEFAULT_OPTIONS } from './constants.js';
import { Logger } from './logger.js';
import { warnOnce } from './deprecate.js';
import { applyProfile, isValidProfile } from './profiles.js';
/**
* Coerce various input types to boolean while respecting common string representations.
* 文字列で渡された真偽値表現にも対応した安全な真偽値変換を行う。
*
* @param {*} value - 値
* @param {boolean} [fallback=false] - 未定義/無効値時のフォールバック
* @returns {boolean} 変換後の真偽値
*/
function coerceBoolean(value, fallback = false) {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === '') {
return fallback;
}
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
return Boolean(normalized);
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
return fallback;
}
return value !== 0;
}
return Boolean(value);
}
/**
* Check whether a CesiumJS Viewer is valid.
* CesiumJS Viewerが有効かチェックします。
* @param {Object} viewer - CesiumJS Viewer
* @returns {boolean} true if valid / 有効な場合は true
*/
export function isValidViewer(viewer) {
if (!viewer) {
return false;
}
// 必要なプロパティが存在するかチェック
if (!viewer.scene || !viewer.entities || !viewer.scene.canvas) {
return false;
}
// WebGL対応チェック(WebGL2 も許容)
const canvas = viewer.scene.canvas;
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) {
return false;
}
return true;
}
/**
* Check whether the entity array is valid.
* エンティティ配列が有効かチェックします。
* @param {Array} entities - Entity array / エンティティ配列
* @returns {boolean} true if valid / 有効な場合は true
*/
export function isValidEntities(entities) {
if (!Array.isArray(entities)) {
return false;
}
if (entities.length === 0) {
return false;
}
if (entities.length > PERFORMANCE_LIMITS.maxEntities) {
Logger.warn(`エンティティ数が推奨値(${PERFORMANCE_LIMITS.maxEntities})を超えています: ${entities.length}`);
}
return true;
}
/**
* Check whether the voxel size is valid.
* ボクセルサイズが有効かチェックします。
* @param {number} voxelSize - Voxel size / ボクセルサイズ
* @returns {boolean} true if valid / 有効な場合は true
*/
export function isValidVoxelSize(voxelSize) {
if (typeof voxelSize !== 'number' || isNaN(voxelSize)) {
return false;
}
if (voxelSize < PERFORMANCE_LIMITS.minVoxelSize || voxelSize > PERFORMANCE_LIMITS.maxVoxelSize) {
return false;
}
return true;
}
/**
* Check whether an entity has a valid position.
* エンティティが有効な位置情報を持つかチェックします。
* @param {Object} entity - Cesium Entity
* @returns {boolean} true if valid / 有効な場合は true
*/
export function hasValidPosition(entity) {
if (!entity || !entity.position) {
return false;
}
// Propertyベースの位置情報の場合
if (typeof entity.position.getValue === 'function') {
const position = entity.position.getValue(Cesium.JulianDate.now());
return position && !isNaN(position.x) && !isNaN(position.y) && !isNaN(position.z);
}
// 直接Cartesian3の場合
if (entity.position.x !== undefined) {
return !isNaN(entity.position.x) && !isNaN(entity.position.y) && !isNaN(entity.position.z);
}
return false;
}
/**
* Validate that total voxel count is within limits.
* 処理するボクセル数が制限内かチェックします。
* @param {number} totalVoxels - Total voxels / 総ボクセル数
* @param {number} voxelSize - Voxel size / ボクセルサイズ
* @returns {Object} Validation result / チェック結果
*/
export function validateVoxelCount(totalVoxels, voxelSize) {
const result = {
valid: true,
warning: false,
error: null,
recommendedSize: null
};
if (totalVoxels > PERFORMANCE_LIMITS.maxVoxels) {
result.valid = false;
result.error = ERROR_MESSAGES.VOXEL_LIMIT_EXCEEDED;
// Safe calculation with bounds checking
const ratio = totalVoxels / PERFORMANCE_LIMITS.maxVoxels;
const scaleFactor = Math.pow(Math.max(1, Math.min(1000, ratio)), 1/3);
const calculatedSize = voxelSize * scaleFactor;
result.recommendedSize = Math.ceil(
Math.max(PERFORMANCE_LIMITS.minVoxelSize,
Math.min(PERFORMANCE_LIMITS.maxVoxelSize, calculatedSize))
);
} else if (totalVoxels > PERFORMANCE_LIMITS.warningThreshold) {
result.warning = true;
result.error = ERROR_MESSAGES.MEMORY_WARNING;
}
return result;
}
/**
* Validate and normalize options.
* オプションを検証して正規化します。
* v0.1.5: batchMode 非推奨化と新機能バリデーションを追加。
* @param {Object} options - User-specified options / ユーザー指定のオプション
* @returns {Object} Normalized options / 正規化されたオプション
*/
export function validateAndNormalizeOptions(options = {}) {
// v0.1.12: Apply profile if specified (before normalization)
let mergedOptions = options;
if (options.profile && options.profile !== 'none') {
if (isValidProfile(options.profile)) {
Logger.debug(`Applying profile: ${options.profile}`);
mergedOptions = applyProfile(options.profile, options);
// Remove profile property after application
delete mergedOptions.profile;
} else {
Logger.warn(`Invalid profile name: ${options.profile}. Available profiles: mobile-fast, desktop-balanced, dense-data, sparse-data`);
}
}
const normalized = { ...mergedOptions };
// v0.1.5: batchMode非推奨化警告(debug時のみ)
if (normalized.batchMode && normalized.debug) {
Logger.warn('batchMode option is deprecated and will be removed in v1.0.0. It is currently ignored.');
}
// ボクセルサイズのバリデーション
if (normalized.voxelSize !== undefined && !isValidVoxelSize(normalized.voxelSize)) {
throw new Error(`${ERROR_MESSAGES.INVALID_VOXEL_SIZE}: ${normalized.voxelSize}`);
}
// 透明度のバリデーション
if (normalized.opacity !== undefined) {
normalized.opacity = Math.max(0, Math.min(1, normalized.opacity));
}
if (normalized.emptyOpacity !== undefined) {
normalized.emptyOpacity = Math.max(0, Math.min(1, normalized.emptyOpacity));
}
// 色のバリデーション
if (normalized.minColor && Array.isArray(normalized.minColor) && normalized.minColor.length === 3) {
normalized.minColor = normalized.minColor.map(c => Math.max(0, Math.min(255, Math.floor(c))));
}
if (normalized.maxColor && Array.isArray(normalized.maxColor) && normalized.maxColor.length === 3) {
normalized.maxColor = normalized.maxColor.map(c => Math.max(0, Math.min(255, Math.floor(c))));
}
// v0.1.5: 新機能のバリデーション
if (normalized.colorMap !== undefined) {
const validColorMaps = ['custom', 'viridis', 'inferno'];
if (!validColorMaps.includes(normalized.colorMap)) {
Logger.warn(`Invalid colorMap: ${normalized.colorMap}. Using 'custom'.`);
normalized.colorMap = 'custom';
}
}
if (normalized.highlightTopN !== undefined && normalized.highlightTopN !== null) {
if (typeof normalized.highlightTopN !== 'number' || normalized.highlightTopN <= 0) {
Logger.warn(`Invalid highlightTopN: ${normalized.highlightTopN}. Must be a positive number.`);
normalized.highlightTopN = null;
}
}
// v0.1.6: 枠線重なり対策のバリデーション
if (normalized.voxelGap !== undefined) {
normalized.voxelGap = Math.max(0, Math.min(100, parseFloat(normalized.voxelGap) || 0));
}
if (normalized.outlineOpacity !== undefined) {
normalized.outlineOpacity = Math.max(0, Math.min(1, parseFloat(normalized.outlineOpacity) || 1));
}
if (normalized.outlineWidth !== undefined) {
const width = parseFloat(normalized.outlineWidth);
normalized.outlineWidth = Number.isFinite(width)
? Math.max(0.5, Math.min(20, width))
: DEFAULT_OPTIONS.outlineWidth;
}
if (normalized.wireframeOnly !== undefined) {
normalized.wireframeOnly = coerceBoolean(normalized.wireframeOnly);
}
if (normalized.heightBased !== undefined) {
normalized.heightBased = coerceBoolean(normalized.heightBased);
}
// v0.1.12: Deprecated Resolver systems - show warnings and remove
if (normalized.outlineWidthResolver !== undefined && normalized.outlineWidthResolver !== null) {
warnOnce('outlineWidthResolver',
'[Heatbox][DEPRECATION][v1.0.0] outlineWidthResolver is deprecated; prefer adaptiveOutlines with outlineWidthPreset and adaptiveParams.');
if (typeof normalized.outlineWidthResolver !== 'function') {
Logger.warn('outlineWidthResolver must be a function. Ignoring.');
normalized.outlineWidthResolver = null;
}
}
// v1.0.0 planned: Resolver deprecation. For now, warn but keep for compatibility.
if (normalized.outlineOpacityResolver !== undefined && normalized.outlineOpacityResolver !== null) {
warnOnce('outlineOpacityResolver',
'[Heatbox][DEPRECATION][v1.0.0] outlineOpacityResolver is deprecated; prefer adaptiveOutlines with adaptiveParams.outlineOpacityRange.');
if (typeof normalized.outlineOpacityResolver !== 'function') {
Logger.warn('outlineOpacityResolver must be a function. Ignoring.');
normalized.outlineOpacityResolver = null;
}
}
if (normalized.boxOpacityResolver !== undefined && normalized.boxOpacityResolver !== null) {
warnOnce('boxOpacityResolver',
'[Heatbox][DEPRECATION][v1.0.0] boxOpacityResolver is deprecated; prefer adaptiveOutlines with adaptiveParams.boxOpacityRange.');
if (typeof normalized.boxOpacityResolver !== 'function') {
Logger.warn('boxOpacityResolver must be a function. Ignoring.');
normalized.boxOpacityResolver = null;
}
}
// v0.1.12: outlineEmulation deprecation and migration to outlineRenderMode
if (normalized.outlineEmulation !== undefined && (normalized.outlineRenderMode === undefined || normalized.outlineRenderMode === 'standard')) {
warnOnce('outlineEmulation',
'[Heatbox][DEPRECATION][v1.0.0] outlineEmulation is deprecated; use outlineRenderMode and emulationScope instead.');
const v = normalized.outlineEmulation;
if (v === false || v === 'off') {
// outlineEmulation: false/off → standard mode + explicit off scope
normalized.outlineRenderMode = 'standard';
normalized.emulationScope = 'off';
} else if (v === true || v === 'all') {
// outlineEmulation: true/all → emulation-only mode
normalized.outlineRenderMode = 'emulation-only';
normalized.emulationScope = 'all';
} else if (v === 'topn') {
// outlineEmulation: topn → standard mode + emulation for topn
normalized.outlineRenderMode = 'standard';
normalized.emulationScope = 'topn';
} else if (v === 'non-topn') {
// outlineEmulation: non-topn → standard mode + emulation for non-topn
normalized.outlineRenderMode = 'standard';
normalized.emulationScope = 'non-topn';
} else {
Logger.warn(`Invalid outlineEmulation: ${v}. Using 'standard' mode.`);
normalized.outlineRenderMode = 'standard';
}
// Remove old property
delete normalized.outlineEmulation;
}
// v0.1.12: outlineWidthPreset legacy name mapping
if (normalized.outlineWidthPreset !== undefined) {
const preset = normalized.outlineWidthPreset;
const legacyMap = { 'uniform': 'medium', 'adaptive-density': 'adaptive', 'topn-focus': 'thick' };
if (legacyMap[preset]) {
warnOnce(`outlineWidthPreset.${preset}`,
`[Heatbox][DEPRECATION][v1.0.0] outlineWidthPreset "${preset}" is deprecated; use "${legacyMap[preset]}".`);
normalized.outlineWidthPreset = legacyMap[preset];
}
}
// v0.1.6.1 (ADR-0004): インセット枠線
if (normalized.outlineInset !== undefined) {
const v = parseFloat(normalized.outlineInset);
normalized.outlineInset = isNaN(v) || v < 0 ? 0 : v;
}
if (normalized.outlineInsetMode !== undefined) {
let mode = normalized.outlineInsetMode;
if (mode === 'off') mode = 'none'; // legacy alias
const validModes = ['all', 'topn', 'none'];
if (!validModes.includes(mode)) {
Logger.warn(`Invalid outlineInsetMode: ${normalized.outlineInsetMode}. Using 'all'.`);
normalized.outlineInsetMode = 'all';
} else {
normalized.outlineInsetMode = mode;
}
}
// v0.1.6.1: インセット枠線(ADR-0004)
if (normalized.outlineInset !== undefined) {
// 0〜100mの範囲にクランプ(安全上限)
const inset = parseFloat(normalized.outlineInset);
normalized.outlineInset = Math.max(0, Math.min(100, isNaN(inset) ? 0 : inset));
}
if (normalized.outlineInsetMode !== undefined) {
let mode2 = normalized.outlineInsetMode;
if (mode2 === 'off') mode2 = 'none'; // legacy alias
const validInsetModes = ['all', 'topn', 'none'];
if (!validInsetModes.includes(mode2)) {
Logger.warn(`Invalid outlineInsetMode: ${normalized.outlineInsetMode}. Using 'all'.`);
normalized.outlineInsetMode = 'all';
} else {
normalized.outlineInsetMode = mode2;
}
}
// 厚い枠線表示
if (normalized.enableThickFrames !== undefined) {
normalized.enableThickFrames = coerceBoolean(normalized.enableThickFrames);
}
// v0.1.9: 適応的レンダリング制限のバリデーション
if (normalized.renderLimitStrategy !== undefined) {
const validStrategies = ['density', 'coverage', 'hybrid'];
if (!validStrategies.includes(normalized.renderLimitStrategy)) {
Logger.warn(`Invalid renderLimitStrategy: ${normalized.renderLimitStrategy}. Using 'density'.`);
normalized.renderLimitStrategy = 'density';
}
}
if (normalized.minCoverageRatio !== undefined) {
const v = parseFloat(normalized.minCoverageRatio);
normalized.minCoverageRatio = isNaN(v) ? 0.2 : Math.max(0, Math.min(1, v));
}
if (normalized.coverageBinsXY !== undefined) {
const v = normalized.coverageBinsXY;
if (v !== 'auto') {
const n = parseInt(v, 10);
if (!Number.isFinite(n) || n <= 0) {
Logger.warn(`Invalid coverageBinsXY: ${v}. Using 'auto'.`);
normalized.coverageBinsXY = 'auto';
} else {
normalized.coverageBinsXY = n;
}
}
}
// v0.1.9: 自動ボクセルサイズ決定の強化
if (normalized.autoVoxelSizeMode !== undefined) {
const validModes = ['basic', 'occupancy'];
if (!validModes.includes(normalized.autoVoxelSizeMode)) {
Logger.warn(`Invalid autoVoxelSizeMode: ${normalized.autoVoxelSizeMode}. Using 'basic'.`);
normalized.autoVoxelSizeMode = 'basic';
}
}
if (normalized.autoVoxelTargetFill !== undefined) {
const v = parseFloat(normalized.autoVoxelTargetFill);
normalized.autoVoxelTargetFill = isNaN(v) ? 0.6 : Math.max(0, Math.min(1, v));
}
// v0.1.9: Auto Render Budget
if (normalized.renderBudgetMode !== undefined) {
const validModes = ['manual', 'auto'];
if (!validModes.includes(normalized.renderBudgetMode)) {
Logger.warn(`Invalid renderBudgetMode: ${normalized.renderBudgetMode}. Using 'manual'.`);
normalized.renderBudgetMode = 'manual';
}
}
// v0.1.9: 自動視点調整 fitView オプション
if (normalized.fitViewOptions !== undefined) {
const f = normalized.fitViewOptions || {};
// v0.1.12: Deprecation warnings for old naming
if (f.pitch !== undefined && f.pitchDegrees === undefined) {
warnOnce('fitViewOptions.pitch',
'[Heatbox][DEPRECATION][v1.0.0] fitViewOptions.pitch is deprecated; use fitViewOptions.pitchDegrees.');
}
if (f.heading !== undefined && f.headingDegrees === undefined) {
warnOnce('fitViewOptions.heading',
'[Heatbox][DEPRECATION][v1.0.0] fitViewOptions.heading is deprecated; use fitViewOptions.headingDegrees.');
}
const padding = parseFloat(f.paddingPercent);
// Prioritize new names, fallback to old names
const pitch = f.pitchDegrees !== undefined ? parseFloat(f.pitchDegrees) : parseFloat(f.pitch);
const heading = f.headingDegrees !== undefined ? parseFloat(f.headingDegrees) : parseFloat(f.heading);
const altitudeStrategy = f.altitudeStrategy;
normalized.fitViewOptions = {
paddingPercent: Number.isFinite(padding) ? Math.max(0, Math.min(1, padding)) : 0.1,
pitchDegrees: Number.isFinite(pitch) ? Math.max(-90, Math.min(0, pitch)) : -30,
headingDegrees: Number.isFinite(heading) ? heading : 0,
altitudeStrategy: altitudeStrategy === 'manual' ? 'manual' : 'auto'
};
}
// v0.1.15: Phase 0 - adaptiveParams の正規化と範囲統一(ADR-0011)
// ユーザー指定の adaptiveParams を保持
const userAdaptiveParams = normalized.adaptiveParams ? { ...normalized.adaptiveParams } : {};
// デフォルト値とマージ(ユーザー指定を優先)
normalized.adaptiveParams = {
...DEFAULT_OPTIONS.adaptiveParams,
...userAdaptiveParams
};
const ap = normalized.adaptiveParams;
// min/max → range への統一(rangeが優先)
if (userAdaptiveParams.minOutlineWidth !== undefined && userAdaptiveParams.maxOutlineWidth !== undefined && userAdaptiveParams.outlineWidthRange === undefined) {
ap.outlineWidthRange = [
Math.max(1.0, parseFloat(userAdaptiveParams.minOutlineWidth) || 1.0),
Math.max(1.0, parseFloat(userAdaptiveParams.maxOutlineWidth) || 5.0)
];
Logger.debug('adaptiveParams: minOutlineWidth/maxOutlineWidth normalized to outlineWidthRange');
}
// range の検証とクランプ
if (ap.outlineWidthRange !== undefined && Array.isArray(ap.outlineWidthRange)) {
const [min, max] = ap.outlineWidthRange;
ap.outlineWidthRange = [
Math.max(1.0, parseFloat(min) || 1.0),
Math.max(1.0, parseFloat(max) || 5.0)
];
// min > max の場合は入れ替え
if (ap.outlineWidthRange[0] > ap.outlineWidthRange[1]) {
ap.outlineWidthRange = [ap.outlineWidthRange[1], ap.outlineWidthRange[0]];
Logger.warn('adaptiveParams.outlineWidthRange: min > max detected, swapped values');
}
}
if (ap.boxOpacityRange !== undefined && Array.isArray(ap.boxOpacityRange)) {
const [min, max] = ap.boxOpacityRange;
ap.boxOpacityRange = [
Math.max(0, Math.min(1, parseFloat(min) || 0)),
Math.max(0, Math.min(1, parseFloat(max) || 1))
];
if (ap.boxOpacityRange[0] > ap.boxOpacityRange[1]) {
ap.boxOpacityRange = [ap.boxOpacityRange[1], ap.boxOpacityRange[0]];
Logger.warn('adaptiveParams.boxOpacityRange: min > max detected, swapped values');
}
}
if (ap.outlineOpacityRange !== undefined && Array.isArray(ap.outlineOpacityRange)) {
const [min, max] = ap.outlineOpacityRange;
ap.outlineOpacityRange = [
Math.max(0, Math.min(1, parseFloat(min) || 0)),
Math.max(0, Math.min(1, parseFloat(max) || 1))
];
if (ap.outlineOpacityRange[0] > ap.outlineOpacityRange[1]) {
ap.outlineOpacityRange = [ap.outlineOpacityRange[1], ap.outlineOpacityRange[0]];
Logger.warn('adaptiveParams.outlineOpacityRange: min > max detected, swapped values');
}
}
// 既定値の検証
if (ap.overlapDetection !== undefined) {
ap.overlapDetection = coerceBoolean(ap.overlapDetection);
}
if (ap.zScaleCompensation !== undefined) {
ap.zScaleCompensation = coerceBoolean(ap.zScaleCompensation);
}
if (ap.adaptiveOpacityEnabled !== undefined) {
ap.adaptiveOpacityEnabled = coerceBoolean(ap.adaptiveOpacityEnabled);
}
// 数値パラメータの検証
if (ap.neighborhoodRadius !== undefined) {
const v = parseFloat(ap.neighborhoodRadius);
ap.neighborhoodRadius = Number.isFinite(v) && v > 0 ? v : 30;
}
if (ap.densityThreshold !== undefined) {
const v = parseFloat(ap.densityThreshold);
ap.densityThreshold = Number.isFinite(v) && v > 0 ? v : 3;
}
if (ap.cameraDistanceFactor !== undefined) {
const v = parseFloat(ap.cameraDistanceFactor);
ap.cameraDistanceFactor = Number.isFinite(v) && v > 0 ? v : 0.8;
}
if (ap.overlapRiskFactor !== undefined) {
const v = parseFloat(ap.overlapRiskFactor);
ap.overlapRiskFactor = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.4;
}
normalized.adaptiveParams = ap;
if (normalized.performanceOverlay) {
const overlay = { ...normalized.performanceOverlay };
overlay.enabled = coerceBoolean(overlay.enabled, false);
overlay.autoShow = coerceBoolean(overlay.autoShow, false);
overlay.autoUpdate = coerceBoolean(overlay.autoUpdate, true);
overlay.position = ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(overlay.position)
? overlay.position
: 'top-right';
if (overlay.updateIntervalMs !== undefined) {
const interval = parseFloat(overlay.updateIntervalMs);
overlay.updateIntervalMs = Number.isFinite(interval) ? Math.max(100, interval) : 500;
}
if (overlay.fpsAveragingWindowMs !== undefined) {
const windowMs = parseFloat(overlay.fpsAveragingWindowMs);
overlay.fpsAveragingWindowMs = Number.isFinite(windowMs) ? Math.max(200, windowMs) : 1000;
}
normalized.performanceOverlay = overlay;
}
// v0.1.17: Spatial ID validation (ADR-0013)
if (mergedOptions.spatialId !== undefined) {
const spatialId = typeof mergedOptions.spatialId === 'object' && mergedOptions.spatialId !== null
? { ...mergedOptions.spatialId }
: {};
// enabled
spatialId.enabled = coerceBoolean(spatialId.enabled, false);
// mode (v0.1.17: tile-grid only)
if (spatialId.mode !== undefined) {
if (spatialId.mode !== 'tile-grid') {
Logger.warn(`Invalid spatialId.mode: ${spatialId.mode}. Using 'tile-grid'.`);
spatialId.mode = 'tile-grid';
}
} else {
spatialId.mode = 'tile-grid';
}
// provider
if (spatialId.provider !== undefined) {
if (spatialId.provider !== 'ouranos-gex') {
Logger.warn(`Unknown spatialId.provider: ${spatialId.provider}. Using 'ouranos-gex'.`);
spatialId.provider = 'ouranos-gex';
}
} else {
spatialId.provider = 'ouranos-gex';
}
// zoom (number or 'auto')
if (spatialId.zoom !== undefined) {
if (spatialId.zoom === 'auto') {
spatialId.zoom = 'auto';
} else {
const zoomNum = parseInt(spatialId.zoom, 10);
if (Number.isNaN(zoomNum) || zoomNum < 0 || zoomNum > 35) {
Logger.warn(`Invalid spatialId.zoom: ${spatialId.zoom}. Using 25.`);
spatialId.zoom = 25;
} else {
spatialId.zoom = zoomNum;
}
}
} else {
spatialId.zoom = 25;
}
// zoomControl
if (spatialId.zoomControl !== undefined) {
if (!['auto', 'manual'].includes(spatialId.zoomControl)) {
Logger.warn(`Invalid spatialId.zoomControl: ${spatialId.zoomControl}. Using 'auto'.`);
spatialId.zoomControl = 'auto';
}
} else {
spatialId.zoomControl = 'auto';
}
// zoomTolerancePct
if (spatialId.zoomTolerancePct !== undefined) {
const tolerance = parseFloat(spatialId.zoomTolerancePct);
if (!Number.isFinite(tolerance) || tolerance <= 0 || tolerance > 100) {
Logger.warn(`Invalid spatialId.zoomTolerancePct: ${spatialId.zoomTolerancePct}. Using 10.`);
spatialId.zoomTolerancePct = 10;
} else {
spatialId.zoomTolerancePct = tolerance;
}
} else {
spatialId.zoomTolerancePct = 10;
}
normalized.spatialId = spatialId;
} else {
// Use defaults from constants
normalized.spatialId = { ...DEFAULT_OPTIONS.spatialId };
}
// v1.0.0: Classification options validation
normalized.classification = normalizeClassificationOptions(normalized.classification);
// v0.1.18: Aggregation options validation (ADR-0014)
if (normalized.aggregation !== undefined) {
normalized.aggregation = validateAggregationOptions(normalized.aggregation);
} else {
normalized.aggregation = { ...DEFAULT_OPTIONS.aggregation };
}
return normalized;
}
/**
* Estimate initial voxel size based on data range.
* データ範囲に基づいて初期ボクセルサイズを推定します。
* @param {Object} bounds - Bounds info / 境界情報
* @param {number} entityCount - Number of entities / エンティティ数
* @param {Object} options - Calculation options / 計算オプション
* @returns {number} Estimated voxel size in meters / 推定ボクセルサイズ(メートル)
*/
export function estimateInitialVoxelSize(bounds, entityCount, options = {}) {
try {
const mode = options.autoVoxelSizeMode || 'basic';
if (mode === 'occupancy') {
return estimateVoxelSizeByOccupancy(bounds, entityCount, options);
} else {
return estimateVoxelSizeBasic(bounds, entityCount);
}
} catch (error) {
Logger.warn('Initial voxel size estimation failed:', error);
return 20; // デフォルトサイズ
}
}
/**
* Basic voxel size estimation (existing algorithm).
* 基本的なボクセルサイズ推定(既存アルゴリズム)
* @param {Object} bounds - Bounds info / 境界情報
* @param {number} entityCount - Number of entities / エンティティ数
* @returns {number} Estimated voxel size in meters / 推定ボクセルサイズ(メートル)
*/
function estimateVoxelSizeBasic(bounds, entityCount) {
// 1. データ範囲(X/Y/Z軸の物理的範囲)を計算
const dataRange = calculateDataRange(bounds);
// 2. エンティティ密度を推定
const volume = dataRange.x * dataRange.y * Math.max(dataRange.z, 10); // 最小高度差10m
const density = entityCount / volume; // エンティティ/立方メートル
// 3. 密度に応じて適切なボクセルサイズを推定
// - 高密度: 細かいサイズ(10-20m)
// - 中密度: 標準サイズ(20-50m)
// - 低密度: 粗いサイズ(50-100m)
let estimatedSize;
if (density > 0.001) {
// 高密度:細かいサイズ
estimatedSize = Math.max(10, Math.min(20, 20 / Math.sqrt(density * 1000)));
} else if (density > 0.0001) {
// 中密度:標準サイズ
estimatedSize = Math.max(20, Math.min(50, 50 / Math.sqrt(density * 10000)));
} else {
// 低密度:粗いサイズ
estimatedSize = Math.max(50, Math.min(100, 100 / Math.sqrt(density * 100000)));
}
// 制限値内に収める
estimatedSize = Math.max(PERFORMANCE_LIMITS.minVoxelSize,
Math.min(PERFORMANCE_LIMITS.maxVoxelSize, estimatedSize));
Logger.debug(`Basic voxel size estimated: ${estimatedSize}m (density: ${density}, volume: ${volume})`);
return Math.round(estimatedSize);
}
/**
* Occupancy-based voxel size estimation with iterative approximation.
* 占有率ベースのボクセルサイズ推定(反復近似)
* @param {Object} bounds - Bounds info / 境界情報
* @param {number} entityCount - Number of entities / エンティティ数
* @param {Object} options - Calculation options / 計算オプション
* @returns {number} Estimated voxel size in meters / 推定ボクセルサイズ(メートル)
*/
function estimateVoxelSizeByOccupancy(bounds, entityCount, options) {
const dataRange = calculateDataRange(bounds);
const maxRenderVoxels = options.maxRenderVoxels || 50000;
const targetFill = options.autoVoxelTargetFill || 0.6;
const maxIterations = 10;
const tolerance = 0.05; // 5%の許容誤差
// 初期推定値(基本アルゴリズムから)
let currentSize = estimateVoxelSizeBasic(bounds, entityCount);
Logger.debug(`Starting occupancy-based estimation: N=${entityCount}, target=${targetFill}, maxVoxels=${maxRenderVoxels}`);
for (let iteration = 0; iteration < maxIterations; iteration++) {
// Safe bounds checking for currentSize
if (!Number.isFinite(currentSize) || currentSize <= 0) {
Logger.warn(`Invalid currentSize detected: ${currentSize}, using fallback`);
currentSize = estimateVoxelSizeBasic(bounds, entityCount);
if (!Number.isFinite(currentSize) || currentSize <= 0) {
currentSize = PERFORMANCE_LIMITS.minVoxelSize;
}
}
// 現在のサイズでの総ボクセル数を計算(安全性チェック付き)
const numVoxelsX = Math.max(1, Math.ceil(Math.max(0, dataRange.x) / currentSize));
const numVoxelsY = Math.max(1, Math.ceil(Math.max(0, dataRange.y) / currentSize));
const numVoxelsZ = Math.max(1, Math.ceil(Math.max(0, dataRange.z) / currentSize));
const totalVoxels = numVoxelsX * numVoxelsY * numVoxelsZ;
// Safeguard against overflow
if (!Number.isFinite(totalVoxels) || totalVoxels <= 0 || totalVoxels > 1e9) {
Logger.warn(`Invalid totalVoxels calculated: ${totalVoxels}, breaking iteration`);
break;
}
// 期待占有セル数の計算: E[occupied] ≈ M × (1 - exp(-N/M))
const expectedOccupied = totalVoxels * (1 - Math.exp(-entityCount / totalVoxels));
// 現在の占有率
const currentFill = Math.min(expectedOccupied / maxRenderVoxels, 1.0);
Logger.debug(`Iteration ${iteration}: size=${currentSize.toFixed(1)}m, totalVoxels=${totalVoxels}, expectedOccupied=${expectedOccupied.toFixed(0)}, fill=${currentFill.toFixed(3)}`);
// 収束判定
const fillError = Math.abs(currentFill - targetFill);
if (fillError < tolerance) {
Logger.debug(`Converged at iteration ${iteration}: size=${currentSize.toFixed(1)}m, fill=${currentFill.toFixed(3)}`);
break;
}
// サイズ調整(Newton法的なアプローチ)- 安全な計算
const fillRatio = Math.max(0.1, Math.min(10.0, currentFill / targetFill));
const adjustmentFactor = Math.pow(fillRatio, 0.3);
if (currentFill > targetFill) {
// 占有率が高すぎる → サイズを大きくしてボクセル数を減らす
currentSize *= adjustmentFactor;
} else {
// 占有率が低すぎる → サイズを小さくしてボクセル数を増やす
currentSize *= adjustmentFactor;
}
// 制限値内に収める
currentSize = Math.max(PERFORMANCE_LIMITS.minVoxelSize,
Math.min(PERFORMANCE_LIMITS.maxVoxelSize, currentSize));
}
const finalSize = Math.round(currentSize);
Logger.info(`Occupancy-based voxel size: ${finalSize}m (target fill: ${targetFill})`);
return finalSize;
}
/**
* Calculate physical span (meters) from geographic bounds.
* 境界からデータ範囲(メートル)を計算します。
* @param {Object} bounds - Bounds information / 境界情報
* @returns {Object} Data range `{x, y, z}` in meters / データ範囲 {x, y, z}(メートル)
*/
export function calculateDataRange(bounds) {
try {
// Validate bounds input
const validBounds = {
minLat: Number.isFinite(bounds.minLat) ? Math.max(-90, Math.min(90, bounds.minLat)) : 0,
maxLat: Number.isFinite(bounds.maxLat) ? Math.max(-90, Math.min(90, bounds.maxLat)) : 0.1,
minLon: Number.isFinite(bounds.minLon) ? Math.max(-180, Math.min(180, bounds.minLon)) : 0,
maxLon: Number.isFinite(bounds.maxLon) ? Math.max(-180, Math.min(180, bounds.maxLon)) : 0.1,
minAlt: Number.isFinite(bounds.minAlt) ? bounds.minAlt : 0,
maxAlt: Number.isFinite(bounds.maxAlt) ? bounds.maxAlt : 100
};
// Ensure valid ranges
if (validBounds.maxLat <= validBounds.minLat) {
validBounds.maxLat = validBounds.minLat + 0.001; // ~100m
}
if (validBounds.maxLon <= validBounds.minLon) {
validBounds.maxLon = validBounds.minLon + 0.001; // ~100m at equator
}
if (validBounds.maxAlt <= validBounds.minAlt) {
validBounds.maxAlt = validBounds.minAlt + 1; // 1m minimum
}
// 緯度経度をメートルに変換(簡易変換)- 安全な計算
const centerLat = (validBounds.minLat + validBounds.maxLat) / 2;
const cosLat = Math.cos(Math.max(-Math.PI/2, Math.min(Math.PI/2, centerLat * Math.PI / 180)));
const lonRangeMeters = Math.abs(validBounds.maxLon - validBounds.minLon) * 111000 * Math.abs(cosLat);
const latRangeMeters = Math.abs(validBounds.maxLat - validBounds.minLat) * 111000;
const altRangeMeters = Math.abs(validBounds.maxAlt - validBounds.minAlt);
return {
x: Math.max(1, Math.min(1e6, lonRangeMeters)), // 1m to 1000km bounds
y: Math.max(1, Math.min(1e6, latRangeMeters)),
z: Math.max(1, Math.min(1e4, altRangeMeters)) // 1m to 10km altitude range
};
} catch (error) {
Logger.warn('Data range calculation failed:', error);
// フォールバック値
return { x: 1000, y: 1000, z: 100 };
}
}
/**
* Validate and normalize aggregation options (v0.1.18 ADR-0014).
* 集約オプションの検証と正規化(v0.1.18 ADR-0014)
* @param {Object} aggregation - Aggregation options / 集約オプション
* @returns {Object} Normalized aggregation options / 正規化された集約オプション
*/
export function validateAggregationOptions(aggregation) {
const normalized = { ...DEFAULT_OPTIONS.aggregation };
if (!aggregation || typeof aggregation !== 'object') {
return normalized;
}
// enabled
if (aggregation.enabled !== undefined) {
normalized.enabled = coerceBoolean(aggregation.enabled, false);
}
// byProperty
if (aggregation.byProperty !== undefined && aggregation.byProperty !== null) {
if (typeof aggregation.byProperty === 'string' && aggregation.byProperty.trim() !== '') {
normalized.byProperty = aggregation.byProperty.trim();
} else {
Logger.warn('[aggregation] byProperty must be a non-empty string, ignoring');
normalized.byProperty = null;
}
}
// keyResolver
if (aggregation.keyResolver !== undefined && aggregation.keyResolver !== null) {
if (typeof aggregation.keyResolver === 'function') {
normalized.keyResolver = aggregation.keyResolver;
} else {
Logger.warn('[aggregation] keyResolver must be a function, ignoring');
normalized.keyResolver = null;
}
}
// showInDescription
if (aggregation.showInDescription !== undefined) {
normalized.showInDescription = coerceBoolean(aggregation.showInDescription, true);
}
// topN
if (aggregation.topN !== undefined) {
const topNValue = Number(aggregation.topN);
if (Number.isInteger(topNValue) && topNValue > 0 && topNValue <= 100) {
normalized.topN = topNValue;
} else {
Logger.warn('[aggregation] topN must be a positive integer <= 100, using default (10)');
normalized.topN = DEFAULT_OPTIONS.aggregation.topN;
}
}
// Validation: if enabled but neither byProperty nor keyResolver is set, warn
if (normalized.enabled && !normalized.byProperty && !normalized.keyResolver) {
Logger.warn('[aggregation] enabled=true but neither byProperty nor keyResolver is set. Will use default key "default".');
}
return normalized;
}
function normalizeClassificationOptions(classification) {
const defaults = {
...DEFAULT_OPTIONS.classification,
classificationTargets: { ...DEFAULT_OPTIONS.classification.classificationTargets }
};
if (classification === undefined) {
return { ...defaults };
}
if (classification === null || classification === false) {
return { ...defaults, enabled: false };
}
const normalized = { ...defaults };
let input = classification;
const allowedClassificationTargets = ['color', 'opacity', 'width'];
if (typeof classification === 'string') {
input = {
scheme: classification,
enabled: classification !== 'none'
};
}
if (typeof input === 'object') {
if (input.enabled !== undefined) {
normalized.enabled = coerceBoolean(input.enabled, defaults.enabled);
} else {
normalized.enabled = true;
}
if (input.scheme !== undefined) {
normalized.scheme = sanitizeClassificationScheme(input.scheme);
}
if (input.classes !== undefined) {
const classesValue = Number(input.classes);
normalized.classes = Number.isInteger(classesValue)
? Math.max(2, Math.min(20, classesValue))
: defaults.classes;
}
if (Array.isArray(input.thresholds)) {
const validThresholds = input.thresholds
.map(value => Number(value))
.filter(value => Number.isFinite(value))
.sort((a, b) => a - b);
normalized.thresholds = validThresholds.length > 0 ? validThresholds : null;
} else if (input.thresholds === null) {
normalized.thresholds = null;
}
if (Array.isArray(input.colorMap)) {
normalized.colorMap = input.colorMap.slice();
} else if (input.colorMap === null) {
normalized.colorMap = null;
} else if (input.colorMap !== undefined) {
Logger.warn('[classification] colorMap should be an array of colors or stop objects. Ignoring provided value.');
normalized.colorMap = null;
}
if (Array.isArray(input.domain) && input.domain.length === 2) {
const [minValue, maxValue] = input.domain.map(value => Number(value));
if (Number.isFinite(minValue) && Number.isFinite(maxValue)) {
normalized.domain = [minValue, maxValue];
}
} else if (input.domain === null) {
normalized.domain = null;
}
const resolvedClassificationTargets = { ...defaults.classificationTargets };
const applyClassificationTargets = (source, sourceName) => {
if (source === undefined || source === null) {
return;
}
if (typeof source !== 'object' || Array.isArray(source)) {
Logger.warn(`[classification] ${sourceName} should be an object with boolean flags. Ignoring provided value.`);
return;
}
for (const key of allowedClassificationTargets) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
resolvedClassificationTargets[key] = coerceBoolean(
source[key],
defaults.classificationTargets[key]
);
}
}
};
// `classification.targets` は後方互換用エイリアス
applyClassificationTargets(input.targets, 'targets');
applyClassificationTargets(input.classificationTargets, 'classificationTargets');
normalized.classificationTargets = resolvedClassificationTargets;
} else {
Logger.warn('[classification] Unsupported configuration type. Expected string or object.');
normalized.enabled = false;
}
if (!normalized.enabled) {
return { ...defaults, enabled: false };
}
if (normalized.scheme === 'threshold' && !Array.isArray(normalized.thresholds)) {
Logger.warn('[classification] threshold scheme requires a thresholds array. Disabling classification.');
return { ...defaults, enabled: false };
}
return normalized;
}
function sanitizeClassificationScheme(scheme) {
if (!scheme || typeof scheme !== 'string') {
return 'linear';
}
const normalizedScheme = scheme.trim().toLowerCase();
const supportedSchemes = ['linear', 'log', 'equal-interval', 'quantize', 'threshold', 'quantile', 'jenks'];
if (supportedSchemes.includes(normalizedScheme)) {
return normalizedScheme;
}
Logger.warn(`[classification] Unknown scheme '${scheme}', falling back to 'linear'.`);
return 'linear';
}
日本語
関連: validationクラス
/**
* Validation utility functions.
* バリデーション関連のユーティリティ関数。
*/
import * as Cesium from 'cesium';
import { PERFORMANCE_LIMITS, ERROR_MESSAGES, DEFAULT_OPTIONS } from './constants.js';
import { Logger } from './logger.js';
import { warnOnce } from './deprecate.js';
import { applyProfile, isValidProfile } from './profiles.js';
/**
* Coerce various input types to boolean while respecting common string representations.
* 文字列で渡された真偽値表現にも対応した安全な真偽値変換を行う。
*
* @param {*} value - 値
* @param {boolean} [fallback=false] - 未定義/無効値時のフォールバック
* @returns {boolean} 変換後の真偽値
*/
function coerceBoolean(value, fallback = false) {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === '') {
return fallback;
}
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
return Boolean(normalized);
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
return fallback;
}
return value !== 0;
}
return Boolean(value);
}
/**
* Check whether a CesiumJS Viewer is valid.
* CesiumJS Viewerが有効かチェックします。
* @param {Object} viewer - CesiumJS Viewer
* @returns {boolean} true if valid / 有効な場合は true
*/
export function isValidViewer(viewer) {
if (!viewer) {
return false;
}
// 必要なプロパティが存在するかチェック
if (!viewer.scene || !viewer.entities || !viewer.scene.canvas) {
return false;
}
// WebGL対応チェック(WebGL2 も許容)
const canvas = viewer.scene.canvas;
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) {
return false;
}
return true;
}
/**
* Check whether the entity array is valid.
* エンティティ配列が有効かチェックします。
* @param {Array} entities - Entity array / エンティティ配列
* @returns {boolean} true if valid / 有効な場合は true
*/
export function isValidEntities(entities) {
if (!Array.isArray(entities)) {
return false;
}
if (entities.length === 0) {
return false;
}
if (entities.length > PERFORMANCE_LIMITS.maxEntities) {
Logger.warn(`エンティティ数が推奨値(${PERFORMANCE_LIMITS.maxEntities})を超えています: ${entities.length}`);
}
return true;
}
/**
* Check whether the voxel size is valid.
* ボクセルサイズが有効かチェックします。
* @param {number} voxelSize - Voxel size / ボクセルサイズ
* @returns {boolean} true if valid / 有効な場合は true
*/
export function isValidVoxelSize(voxelSize) {
if (typeof voxelSize !== 'number' || isNaN(voxelSize)) {
return false;
}
if (voxelSize < PERFORMANCE_LIMITS.minVoxelSize || voxelSize > PERFORMANCE_LIMITS.maxVoxelSize) {
return false;
}
return true;
}
/**
* Check whether an entity has a valid position.
* エンティティが有効な位置情報を持つかチェックします。
* @param {Object} entity - Cesium Entity
* @returns {boolean} true if valid / 有効な場合は true
*/
export function hasValidPosition(entity) {
if (!entity || !entity.position) {
return false;
}
// Propertyベースの位置情報の場合
if (typeof entity.position.getValue === 'function') {
const position = entity.position.getValue(Cesium.JulianDate.now());
return position && !isNaN(position.x) && !isNaN(position.y) && !isNaN(position.z);
}
// 直接Cartesian3の場合
if (entity.position.x !== undefined) {
return !isNaN(entity.position.x) && !isNaN(entity.position.y) && !isNaN(entity.position.z);
}
return false;
}
/**
* Validate that total voxel count is within limits.
* 処理するボクセル数が制限内かチェックします。
* @param {number} totalVoxels - Total voxels / 総ボクセル数
* @param {number} voxelSize - Voxel size / ボクセルサイズ
* @returns {Object} Validation result / チェック結果
*/
export function validateVoxelCount(totalVoxels, voxelSize) {
const result = {
valid: true,
warning: false,
error: null,
recommendedSize: null
};
if (totalVoxels > PERFORMANCE_LIMITS.maxVoxels) {
result.valid = false;
result.error = ERROR_MESSAGES.VOXEL_LIMIT_EXCEEDED;
// Safe calculation with bounds checking
const ratio = totalVoxels / PERFORMANCE_LIMITS.maxVoxels;
const scaleFactor = Math.pow(Math.max(1, Math.min(1000, ratio)), 1/3);
const calculatedSize = voxelSize * scaleFactor;
result.recommendedSize = Math.ceil(
Math.max(PERFORMANCE_LIMITS.minVoxelSize,
Math.min(PERFORMANCE_LIMITS.maxVoxelSize, calculatedSize))
);
} else if (totalVoxels > PERFORMANCE_LIMITS.warningThreshold) {
result.warning = true;
result.error = ERROR_MESSAGES.MEMORY_WARNING;
}
return result;
}
/**
* Validate and normalize options.
* オプションを検証して正規化します。
* v0.1.5: batchMode 非推奨化と新機能バリデーションを追加。
* @param {Object} options - User-specified options / ユーザー指定のオプション
* @returns {Object} Normalized options / 正規化されたオプション
*/
export function validateAndNormalizeOptions(options = {}) {
// v0.1.12: Apply profile if specified (before normalization)
let mergedOptions = options;
if (options.profile && options.profile !== 'none') {
if (isValidProfile(options.profile)) {
Logger.debug(`Applying profile: ${options.profile}`);
mergedOptions = applyProfile(options.profile, options);
// Remove profile property after application
delete mergedOptions.profile;
} else {
Logger.warn(`Invalid profile name: ${options.profile}. Available profiles: mobile-fast, desktop-balanced, dense-data, sparse-data`);
}
}
const normalized = { ...mergedOptions };
// v0.1.5: batchMode非推奨化警告(debug時のみ)
if (normalized.batchMode && normalized.debug) {
Logger.warn('batchMode option is deprecated and will be removed in v1.0.0. It is currently ignored.');
}
// ボクセルサイズのバリデーション
if (normalized.voxelSize !== undefined && !isValidVoxelSize(normalized.voxelSize)) {
throw new Error(`${ERROR_MESSAGES.INVALID_VOXEL_SIZE}: ${normalized.voxelSize}`);
}
// 透明度のバリデーション
if (normalized.opacity !== undefined) {
normalized.opacity = Math.max(0, Math.min(1, normalized.opacity));
}
if (normalized.emptyOpacity !== undefined) {
normalized.emptyOpacity = Math.max(0, Math.min(1, normalized.emptyOpacity));
}
// 色のバリデーション
if (normalized.minColor && Array.isArray(normalized.minColor) && normalized.minColor.length === 3) {
normalized.minColor = normalized.minColor.map(c => Math.max(0, Math.min(255, Math.floor(c))));
}
if (normalized.maxColor && Array.isArray(normalized.maxColor) && normalized.maxColor.length === 3) {
normalized.maxColor = normalized.maxColor.map(c => Math.max(0, Math.min(255, Math.floor(c))));
}
// v0.1.5: 新機能のバリデーション
if (normalized.colorMap !== undefined) {
const validColorMaps = ['custom', 'viridis', 'inferno'];
if (!validColorMaps.includes(normalized.colorMap)) {
Logger.warn(`Invalid colorMap: ${normalized.colorMap}. Using 'custom'.`);
normalized.colorMap = 'custom';
}
}
if (normalized.highlightTopN !== undefined && normalized.highlightTopN !== null) {
if (typeof normalized.highlightTopN !== 'number' || normalized.highlightTopN <= 0) {
Logger.warn(`Invalid highlightTopN: ${normalized.highlightTopN}. Must be a positive number.`);
normalized.highlightTopN = null;
}
}
// v0.1.6: 枠線重なり対策のバリデーション
if (normalized.voxelGap !== undefined) {
normalized.voxelGap = Math.max(0, Math.min(100, parseFloat(normalized.voxelGap) || 0));
}
if (normalized.outlineOpacity !== undefined) {
normalized.outlineOpacity = Math.max(0, Math.min(1, parseFloat(normalized.outlineOpacity) || 1));
}
if (normalized.outlineWidth !== undefined) {
const width = parseFloat(normalized.outlineWidth);
normalized.outlineWidth = Number.isFinite(width)
? Math.max(0.5, Math.min(20, width))
: DEFAULT_OPTIONS.outlineWidth;
}
if (normalized.wireframeOnly !== undefined) {
normalized.wireframeOnly = coerceBoolean(normalized.wireframeOnly);
}
if (normalized.heightBased !== undefined) {
normalized.heightBased = coerceBoolean(normalized.heightBased);
}
// v0.1.12: Deprecated Resolver systems - show warnings and remove
if (normalized.outlineWidthResolver !== undefined && normalized.outlineWidthResolver !== null) {
warnOnce('outlineWidthResolver',
'[Heatbox][DEPRECATION][v1.0.0] outlineWidthResolver is deprecated; prefer adaptiveOutlines with outlineWidthPreset and adaptiveParams.');
if (typeof normalized.outlineWidthResolver !== 'function') {
Logger.warn('outlineWidthResolver must be a function. Ignoring.');
normalized.outlineWidthResolver = null;
}
}
// v1.0.0 planned: Resolver deprecation. For now, warn but keep for compatibility.
if (normalized.outlineOpacityResolver !== undefined && normalized.outlineOpacityResolver !== null) {
warnOnce('outlineOpacityResolver',
'[Heatbox][DEPRECATION][v1.0.0] outlineOpacityResolver is deprecated; prefer adaptiveOutlines with adaptiveParams.outlineOpacityRange.');
if (typeof normalized.outlineOpacityResolver !== 'function') {
Logger.warn('outlineOpacityResolver must be a function. Ignoring.');
normalized.outlineOpacityResolver = null;
}
}
if (normalized.boxOpacityResolver !== undefined && normalized.boxOpacityResolver !== null) {
warnOnce('boxOpacityResolver',
'[Heatbox][DEPRECATION][v1.0.0] boxOpacityResolver is deprecated; prefer adaptiveOutlines with adaptiveParams.boxOpacityRange.');
if (typeof normalized.boxOpacityResolver !== 'function') {
Logger.warn('boxOpacityResolver must be a function. Ignoring.');
normalized.boxOpacityResolver = null;
}
}
// v0.1.12: outlineEmulation deprecation and migration to outlineRenderMode
if (normalized.outlineEmulation !== undefined && (normalized.outlineRenderMode === undefined || normalized.outlineRenderMode === 'standard')) {
warnOnce('outlineEmulation',
'[Heatbox][DEPRECATION][v1.0.0] outlineEmulation is deprecated; use outlineRenderMode and emulationScope instead.');
const v = normalized.outlineEmulation;
if (v === false || v === 'off') {
// outlineEmulation: false/off → standard mode + explicit off scope
normalized.outlineRenderMode = 'standard';
normalized.emulationScope = 'off';
} else if (v === true || v === 'all') {
// outlineEmulation: true/all → emulation-only mode
normalized.outlineRenderMode = 'emulation-only';
normalized.emulationScope = 'all';
} else if (v === 'topn') {
// outlineEmulation: topn → standard mode + emulation for topn
normalized.outlineRenderMode = 'standard';
normalized.emulationScope = 'topn';
} else if (v === 'non-topn') {
// outlineEmulation: non-topn → standard mode + emulation for non-topn
normalized.outlineRenderMode = 'standard';
normalized.emulationScope = 'non-topn';
} else {
Logger.warn(`Invalid outlineEmulation: ${v}. Using 'standard' mode.`);
normalized.outlineRenderMode = 'standard';
}
// Remove old property
delete normalized.outlineEmulation;
}
// v0.1.12: outlineWidthPreset legacy name mapping
if (normalized.outlineWidthPreset !== undefined) {
const preset = normalized.outlineWidthPreset;
const legacyMap = { 'uniform': 'medium', 'adaptive-density': 'adaptive', 'topn-focus': 'thick' };
if (legacyMap[preset]) {
warnOnce(`outlineWidthPreset.${preset}`,
`[Heatbox][DEPRECATION][v1.0.0] outlineWidthPreset "${preset}" is deprecated; use "${legacyMap[preset]}".`);
normalized.outlineWidthPreset = legacyMap[preset];
}
}
// v0.1.6.1 (ADR-0004): インセット枠線
if (normalized.outlineInset !== undefined) {
const v = parseFloat(normalized.outlineInset);
normalized.outlineInset = isNaN(v) || v < 0 ? 0 : v;
}
if (normalized.outlineInsetMode !== undefined) {
let mode = normalized.outlineInsetMode;
if (mode === 'off') mode = 'none'; // legacy alias
const validModes = ['all', 'topn', 'none'];
if (!validModes.includes(mode)) {
Logger.warn(`Invalid outlineInsetMode: ${normalized.outlineInsetMode}. Using 'all'.`);
normalized.outlineInsetMode = 'all';
} else {
normalized.outlineInsetMode = mode;
}
}
// v0.1.6.1: インセット枠線(ADR-0004)
if (normalized.outlineInset !== undefined) {
// 0〜100mの範囲にクランプ(安全上限)
const inset = parseFloat(normalized.outlineInset);
normalized.outlineInset = Math.max(0, Math.min(100, isNaN(inset) ? 0 : inset));
}
if (normalized.outlineInsetMode !== undefined) {
let mode2 = normalized.outlineInsetMode;
if (mode2 === 'off') mode2 = 'none'; // legacy alias
const validInsetModes = ['all', 'topn', 'none'];
if (!validInsetModes.includes(mode2)) {
Logger.warn(`Invalid outlineInsetMode: ${normalized.outlineInsetMode}. Using 'all'.`);
normalized.outlineInsetMode = 'all';
} else {
normalized.outlineInsetMode = mode2;
}
}
// 厚い枠線表示
if (normalized.enableThickFrames !== undefined) {
normalized.enableThickFrames = coerceBoolean(normalized.enableThickFrames);
}
// v0.1.9: 適応的レンダリング制限のバリデーション
if (normalized.renderLimitStrategy !== undefined) {
const validStrategies = ['density', 'coverage', 'hybrid'];
if (!validStrategies.includes(normalized.renderLimitStrategy)) {
Logger.warn(`Invalid renderLimitStrategy: ${normalized.renderLimitStrategy}. Using 'density'.`);
normalized.renderLimitStrategy = 'density';
}
}
if (normalized.minCoverageRatio !== undefined) {
const v = parseFloat(normalized.minCoverageRatio);
normalized.minCoverageRatio = isNaN(v) ? 0.2 : Math.max(0, Math.min(1, v));
}
if (normalized.coverageBinsXY !== undefined) {
const v = normalized.coverageBinsXY;
if (v !== 'auto') {
const n = parseInt(v, 10);
if (!Number.isFinite(n) || n <= 0) {
Logger.warn(`Invalid coverageBinsXY: ${v}. Using 'auto'.`);
normalized.coverageBinsXY = 'auto';
} else {
normalized.coverageBinsXY = n;
}
}
}
// v0.1.9: 自動ボクセルサイズ決定の強化
if (normalized.autoVoxelSizeMode !== undefined) {
const validModes = ['basic', 'occupancy'];
if (!validModes.includes(normalized.autoVoxelSizeMode)) {
Logger.warn(`Invalid autoVoxelSizeMode: ${normalized.autoVoxelSizeMode}. Using 'basic'.`);
normalized.autoVoxelSizeMode = 'basic';
}
}
if (normalized.autoVoxelTargetFill !== undefined) {
const v = parseFloat(normalized.autoVoxelTargetFill);
normalized.autoVoxelTargetFill = isNaN(v) ? 0.6 : Math.max(0, Math.min(1, v));
}
// v0.1.9: Auto Render Budget
if (normalized.renderBudgetMode !== undefined) {
const validModes = ['manual', 'auto'];
if (!validModes.includes(normalized.renderBudgetMode)) {
Logger.warn(`Invalid renderBudgetMode: ${normalized.renderBudgetMode}. Using 'manual'.`);
normalized.renderBudgetMode = 'manual';
}
}
// v0.1.9: 自動視点調整 fitView オプション
if (normalized.fitViewOptions !== undefined) {
const f = normalized.fitViewOptions || {};
// v0.1.12: Deprecation warnings for old naming
if (f.pitch !== undefined && f.pitchDegrees === undefined) {
warnOnce('fitViewOptions.pitch',
'[Heatbox][DEPRECATION][v1.0.0] fitViewOptions.pitch is deprecated; use fitViewOptions.pitchDegrees.');
}
if (f.heading !== undefined && f.headingDegrees === undefined) {
warnOnce('fitViewOptions.heading',
'[Heatbox][DEPRECATION][v1.0.0] fitViewOptions.heading is deprecated; use fitViewOptions.headingDegrees.');
}
const padding = parseFloat(f.paddingPercent);
// Prioritize new names, fallback to old names
const pitch = f.pitchDegrees !== undefined ? parseFloat(f.pitchDegrees) : parseFloat(f.pitch);
const heading = f.headingDegrees !== undefined ? parseFloat(f.headingDegrees) : parseFloat(f.heading);
const altitudeStrategy = f.altitudeStrategy;
normalized.fitViewOptions = {
paddingPercent: Number.isFinite(padding) ? Math.max(0, Math.min(1, padding)) : 0.1,
pitchDegrees: Number.isFinite(pitch) ? Math.max(-90, Math.min(0, pitch)) : -30,
headingDegrees: Number.isFinite(heading) ? heading : 0,
altitudeStrategy: altitudeStrategy === 'manual' ? 'manual' : 'auto'
};
}
// v0.1.15: Phase 0 - adaptiveParams の正規化と範囲統一(ADR-0011)
// ユーザー指定の adaptiveParams を保持
const userAdaptiveParams = normalized.adaptiveParams ? { ...normalized.adaptiveParams } : {};
// デフォルト値とマージ(ユーザー指定を優先)
normalized.adaptiveParams = {
...DEFAULT_OPTIONS.adaptiveParams,
...userAdaptiveParams
};
const ap = normalized.adaptiveParams;
// min/max → range への統一(rangeが優先)
if (userAdaptiveParams.minOutlineWidth !== undefined && userAdaptiveParams.maxOutlineWidth !== undefined && userAdaptiveParams.outlineWidthRange === undefined) {
ap.outlineWidthRange = [
Math.max(1.0, parseFloat(userAdaptiveParams.minOutlineWidth) || 1.0),
Math.max(1.0, parseFloat(userAdaptiveParams.maxOutlineWidth) || 5.0)
];
Logger.debug('adaptiveParams: minOutlineWidth/maxOutlineWidth normalized to outlineWidthRange');
}
// range の検証とクランプ
if (ap.outlineWidthRange !== undefined && Array.isArray(ap.outlineWidthRange)) {
const [min, max] = ap.outlineWidthRange;
ap.outlineWidthRange = [
Math.max(1.0, parseFloat(min) || 1.0),
Math.max(1.0, parseFloat(max) || 5.0)
];
// min > max の場合は入れ替え
if (ap.outlineWidthRange[0] > ap.outlineWidthRange[1]) {
ap.outlineWidthRange = [ap.outlineWidthRange[1], ap.outlineWidthRange[0]];
Logger.warn('adaptiveParams.outlineWidthRange: min > max detected, swapped values');
}
}
if (ap.boxOpacityRange !== undefined && Array.isArray(ap.boxOpacityRange)) {
const [min, max] = ap.boxOpacityRange;
ap.boxOpacityRange = [
Math.max(0, Math.min(1, parseFloat(min) || 0)),
Math.max(0, Math.min(1, parseFloat(max) || 1))
];
if (ap.boxOpacityRange[0] > ap.boxOpacityRange[1]) {
ap.boxOpacityRange = [ap.boxOpacityRange[1], ap.boxOpacityRange[0]];
Logger.warn('adaptiveParams.boxOpacityRange: min > max detected, swapped values');
}
}
if (ap.outlineOpacityRange !== undefined && Array.isArray(ap.outlineOpacityRange)) {
const [min, max] = ap.outlineOpacityRange;
ap.outlineOpacityRange = [
Math.max(0, Math.min(1, parseFloat(min) || 0)),
Math.max(0, Math.min(1, parseFloat(max) || 1))
];
if (ap.outlineOpacityRange[0] > ap.outlineOpacityRange[1]) {
ap.outlineOpacityRange = [ap.outlineOpacityRange[1], ap.outlineOpacityRange[0]];
Logger.warn('adaptiveParams.outlineOpacityRange: min > max detected, swapped values');
}
}
// 既定値の検証
if (ap.overlapDetection !== undefined) {
ap.overlapDetection = coerceBoolean(ap.overlapDetection);
}
if (ap.zScaleCompensation !== undefined) {
ap.zScaleCompensation = coerceBoolean(ap.zScaleCompensation);
}
if (ap.adaptiveOpacityEnabled !== undefined) {
ap.adaptiveOpacityEnabled = coerceBoolean(ap.adaptiveOpacityEnabled);
}
// 数値パラメータの検証
if (ap.neighborhoodRadius !== undefined) {
const v = parseFloat(ap.neighborhoodRadius);
ap.neighborhoodRadius = Number.isFinite(v) && v > 0 ? v : 30;
}
if (ap.densityThreshold !== undefined) {
const v = parseFloat(ap.densityThreshold);
ap.densityThreshold = Number.isFinite(v) && v > 0 ? v : 3;
}
if (ap.cameraDistanceFactor !== undefined) {
const v = parseFloat(ap.cameraDistanceFactor);
ap.cameraDistanceFactor = Number.isFinite(v) && v > 0 ? v : 0.8;
}
if (ap.overlapRiskFactor !== undefined) {
const v = parseFloat(ap.overlapRiskFactor);
ap.overlapRiskFactor = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.4;
}
normalized.adaptiveParams = ap;
if (normalized.performanceOverlay) {
const overlay = { ...normalized.performanceOverlay };
overlay.enabled = coerceBoolean(overlay.enabled, false);
overlay.autoShow = coerceBoolean(overlay.autoShow, false);
overlay.autoUpdate = coerceBoolean(overlay.autoUpdate, true);
overlay.position = ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(overlay.position)
? overlay.position
: 'top-right';
if (overlay.updateIntervalMs !== undefined) {
const interval = parseFloat(overlay.updateIntervalMs);
overlay.updateIntervalMs = Number.isFinite(interval) ? Math.max(100, interval) : 500;
}
if (overlay.fpsAveragingWindowMs !== undefined) {
const windowMs = parseFloat(overlay.fpsAveragingWindowMs);
overlay.fpsAveragingWindowMs = Number.isFinite(windowMs) ? Math.max(200, windowMs) : 1000;
}
normalized.performanceOverlay = overlay;
}
// v0.1.17: Spatial ID validation (ADR-0013)
if (mergedOptions.spatialId !== undefined) {
const spatialId = typeof mergedOptions.spatialId === 'object' && mergedOptions.spatialId !== null
? { ...mergedOptions.spatialId }
: {};
// enabled
spatialId.enabled = coerceBoolean(spatialId.enabled, false);
// mode (v0.1.17: tile-grid only)
if (spatialId.mode !== undefined) {
if (spatialId.mode !== 'tile-grid') {
Logger.warn(`Invalid spatialId.mode: ${spatialId.mode}. Using 'tile-grid'.`);
spatialId.mode = 'tile-grid';
}
} else {
spatialId.mode = 'tile-grid';
}
// provider
if (spatialId.provider !== undefined) {
if (spatialId.provider !== 'ouranos-gex') {
Logger.warn(`Unknown spatialId.provider: ${spatialId.provider}. Using 'ouranos-gex'.`);
spatialId.provider = 'ouranos-gex';
}
} else {
spatialId.provider = 'ouranos-gex';
}
// zoom (number or 'auto')
if (spatialId.zoom !== undefined) {
if (spatialId.zoom === 'auto') {
spatialId.zoom = 'auto';
} else {
const zoomNum = parseInt(spatialId.zoom, 10);
if (Number.isNaN(zoomNum) || zoomNum < 0 || zoomNum > 35) {
Logger.warn(`Invalid spatialId.zoom: ${spatialId.zoom}. Using 25.`);
spatialId.zoom = 25;
} else {
spatialId.zoom = zoomNum;
}
}
} else {
spatialId.zoom = 25;
}
// zoomControl
if (spatialId.zoomControl !== undefined) {
if (!['auto', 'manual'].includes(spatialId.zoomControl)) {
Logger.warn(`Invalid spatialId.zoomControl: ${spatialId.zoomControl}. Using 'auto'.`);
spatialId.zoomControl = 'auto';
}
} else {
spatialId.zoomControl = 'auto';
}
// zoomTolerancePct
if (spatialId.zoomTolerancePct !== undefined) {
const tolerance = parseFloat(spatialId.zoomTolerancePct);
if (!Number.isFinite(tolerance) || tolerance <= 0 || tolerance > 100) {
Logger.warn(`Invalid spatialId.zoomTolerancePct: ${spatialId.zoomTolerancePct}. Using 10.`);
spatialId.zoomTolerancePct = 10;
} else {
spatialId.zoomTolerancePct = tolerance;
}
} else {
spatialId.zoomTolerancePct = 10;
}
normalized.spatialId = spatialId;
} else {
// Use defaults from constants
normalized.spatialId = { ...DEFAULT_OPTIONS.spatialId };
}
// v1.0.0: Classification options validation
normalized.classification = normalizeClassificationOptions(normalized.classification);
// v0.1.18: Aggregation options validation (ADR-0014)
if (normalized.aggregation !== undefined) {
normalized.aggregation = validateAggregationOptions(normalized.aggregation);
} else {
normalized.aggregation = { ...DEFAULT_OPTIONS.aggregation };
}
return normalized;
}
/**
* Estimate initial voxel size based on data range.
* データ範囲に基づいて初期ボクセルサイズを推定します。
* @param {Object} bounds - Bounds info / 境界情報
* @param {number} entityCount - Number of entities / エンティティ数
* @param {Object} options - Calculation options / 計算オプション
* @returns {number} Estimated voxel size in meters / 推定ボクセルサイズ(メートル)
*/
export function estimateInitialVoxelSize(bounds, entityCount, options = {}) {
try {
const mode = options.autoVoxelSizeMode || 'basic';
if (mode === 'occupancy') {
return estimateVoxelSizeByOccupancy(bounds, entityCount, options);
} else {
return estimateVoxelSizeBasic(bounds, entityCount);
}
} catch (error) {
Logger.warn('Initial voxel size estimation failed:', error);
return 20; // デフォルトサイズ
}
}
/**
* Basic voxel size estimation (existing algorithm).
* 基本的なボクセルサイズ推定(既存アルゴリズム)
* @param {Object} bounds - Bounds info / 境界情報
* @param {number} entityCount - Number of entities / エンティティ数
* @returns {number} Estimated voxel size in meters / 推定ボクセルサイズ(メートル)
*/
function estimateVoxelSizeBasic(bounds, entityCount) {
// 1. データ範囲(X/Y/Z軸の物理的範囲)を計算
const dataRange = calculateDataRange(bounds);
// 2. エンティティ密度を推定
const volume = dataRange.x * dataRange.y * Math.max(dataRange.z, 10); // 最小高度差10m
const density = entityCount / volume; // エンティティ/立方メートル
// 3. 密度に応じて適切なボクセルサイズを推定
// - 高密度: 細かいサイズ(10-20m)
// - 中密度: 標準サイズ(20-50m)
// - 低密度: 粗いサイズ(50-100m)
let estimatedSize;
if (density > 0.001) {
// 高密度:細かいサイズ
estimatedSize = Math.max(10, Math.min(20, 20 / Math.sqrt(density * 1000)));
} else if (density > 0.0001) {
// 中密度:標準サイズ
estimatedSize = Math.max(20, Math.min(50, 50 / Math.sqrt(density * 10000)));
} else {
// 低密度:粗いサイズ
estimatedSize = Math.max(50, Math.min(100, 100 / Math.sqrt(density * 100000)));
}
// 制限値内に収める
estimatedSize = Math.max(PERFORMANCE_LIMITS.minVoxelSize,
Math.min(PERFORMANCE_LIMITS.maxVoxelSize, estimatedSize));
Logger.debug(`Basic voxel size estimated: ${estimatedSize}m (density: ${density}, volume: ${volume})`);
return Math.round(estimatedSize);
}
/**
* Occupancy-based voxel size estimation with iterative approximation.
* 占有率ベースのボクセルサイズ推定(反復近似)
* @param {Object} bounds - Bounds info / 境界情報
* @param {number} entityCount - Number of entities / エンティティ数
* @param {Object} options - Calculation options / 計算オプション
* @returns {number} Estimated voxel size in meters / 推定ボクセルサイズ(メートル)
*/
function estimateVoxelSizeByOccupancy(bounds, entityCount, options) {
const dataRange = calculateDataRange(bounds);
const maxRenderVoxels = options.maxRenderVoxels || 50000;
const targetFill = options.autoVoxelTargetFill || 0.6;
const maxIterations = 10;
const tolerance = 0.05; // 5%の許容誤差
// 初期推定値(基本アルゴリズムから)
let currentSize = estimateVoxelSizeBasic(bounds, entityCount);
Logger.debug(`Starting occupancy-based estimation: N=${entityCount}, target=${targetFill}, maxVoxels=${maxRenderVoxels}`);
for (let iteration = 0; iteration < maxIterations; iteration++) {
// Safe bounds checking for currentSize
if (!Number.isFinite(currentSize) || currentSize <= 0) {
Logger.warn(`Invalid currentSize detected: ${currentSize}, using fallback`);
currentSize = estimateVoxelSizeBasic(bounds, entityCount);
if (!Number.isFinite(currentSize) || currentSize <= 0) {
currentSize = PERFORMANCE_LIMITS.minVoxelSize;
}
}
// 現在のサイズでの総ボクセル数を計算(安全性チェック付き)
const numVoxelsX = Math.max(1, Math.ceil(Math.max(0, dataRange.x) / currentSize));
const numVoxelsY = Math.max(1, Math.ceil(Math.max(0, dataRange.y) / currentSize));
const numVoxelsZ = Math.max(1, Math.ceil(Math.max(0, dataRange.z) / currentSize));
const totalVoxels = numVoxelsX * numVoxelsY * numVoxelsZ;
// Safeguard against overflow
if (!Number.isFinite(totalVoxels) || totalVoxels <= 0 || totalVoxels > 1e9) {
Logger.warn(`Invalid totalVoxels calculated: ${totalVoxels}, breaking iteration`);
break;
}
// 期待占有セル数の計算: E[occupied] ≈ M × (1 - exp(-N/M))
const expectedOccupied = totalVoxels * (1 - Math.exp(-entityCount / totalVoxels));
// 現在の占有率
const currentFill = Math.min(expectedOccupied / maxRenderVoxels, 1.0);
Logger.debug(`Iteration ${iteration}: size=${currentSize.toFixed(1)}m, totalVoxels=${totalVoxels}, expectedOccupied=${expectedOccupied.toFixed(0)}, fill=${currentFill.toFixed(3)}`);
// 収束判定
const fillError = Math.abs(currentFill - targetFill);
if (fillError < tolerance) {
Logger.debug(`Converged at iteration ${iteration}: size=${currentSize.toFixed(1)}m, fill=${currentFill.toFixed(3)}`);
break;
}
// サイズ調整(Newton法的なアプローチ)- 安全な計算
const fillRatio = Math.max(0.1, Math.min(10.0, currentFill / targetFill));
const adjustmentFactor = Math.pow(fillRatio, 0.3);
if (currentFill > targetFill) {
// 占有率が高すぎる → サイズを大きくしてボクセル数を減らす
currentSize *= adjustmentFactor;
} else {
// 占有率が低すぎる → サイズを小さくしてボクセル数を増やす
currentSize *= adjustmentFactor;
}
// 制限値内に収める
currentSize = Math.max(PERFORMANCE_LIMITS.minVoxelSize,
Math.min(PERFORMANCE_LIMITS.maxVoxelSize, currentSize));
}
const finalSize = Math.round(currentSize);
Logger.info(`Occupancy-based voxel size: ${finalSize}m (target fill: ${targetFill})`);
return finalSize;
}
/**
* Calculate physical span (meters) from geographic bounds.
* 境界からデータ範囲(メートル)を計算します。
* @param {Object} bounds - Bounds information / 境界情報
* @returns {Object} Data range `{x, y, z}` in meters / データ範囲 {x, y, z}(メートル)
*/
export function calculateDataRange(bounds) {
try {
// Validate bounds input
const validBounds = {
minLat: Number.isFinite(bounds.minLat) ? Math.max(-90, Math.min(90, bounds.minLat)) : 0,
maxLat: Number.isFinite(bounds.maxLat) ? Math.max(-90, Math.min(90, bounds.maxLat)) : 0.1,
minLon: Number.isFinite(bounds.minLon) ? Math.max(-180, Math.min(180, bounds.minLon)) : 0,
maxLon: Number.isFinite(bounds.maxLon) ? Math.max(-180, Math.min(180, bounds.maxLon)) : 0.1,
minAlt: Number.isFinite(bounds.minAlt) ? bounds.minAlt : 0,
maxAlt: Number.isFinite(bounds.maxAlt) ? bounds.maxAlt : 100
};
// Ensure valid ranges
if (validBounds.maxLat <= validBounds.minLat) {
validBounds.maxLat = validBounds.minLat + 0.001; // ~100m
}
if (validBounds.maxLon <= validBounds.minLon) {
validBounds.maxLon = validBounds.minLon + 0.001; // ~100m at equator
}
if (validBounds.maxAlt <= validBounds.minAlt) {
validBounds.maxAlt = validBounds.minAlt + 1; // 1m minimum
}
// 緯度経度をメートルに変換(簡易変換)- 安全な計算
const centerLat = (validBounds.minLat + validBounds.maxLat) / 2;
const cosLat = Math.cos(Math.max(-Math.PI/2, Math.min(Math.PI/2, centerLat * Math.PI / 180)));
const lonRangeMeters = Math.abs(validBounds.maxLon - validBounds.minLon) * 111000 * Math.abs(cosLat);
const latRangeMeters = Math.abs(validBounds.maxLat - validBounds.minLat) * 111000;
const altRangeMeters = Math.abs(validBounds.maxAlt - validBounds.minAlt);
return {
x: Math.max(1, Math.min(1e6, lonRangeMeters)), // 1m to 1000km bounds
y: Math.max(1, Math.min(1e6, latRangeMeters)),
z: Math.max(1, Math.min(1e4, altRangeMeters)) // 1m to 10km altitude range
};
} catch (error) {
Logger.warn('Data range calculation failed:', error);
// フォールバック値
return { x: 1000, y: 1000, z: 100 };
}
}
/**
* Validate and normalize aggregation options (v0.1.18 ADR-0014).
* 集約オプションの検証と正規化(v0.1.18 ADR-0014)
* @param {Object} aggregation - Aggregation options / 集約オプション
* @returns {Object} Normalized aggregation options / 正規化された集約オプション
*/
export function validateAggregationOptions(aggregation) {
const normalized = { ...DEFAULT_OPTIONS.aggregation };
if (!aggregation || typeof aggregation !== 'object') {
return normalized;
}
// enabled
if (aggregation.enabled !== undefined) {
normalized.enabled = coerceBoolean(aggregation.enabled, false);
}
// byProperty
if (aggregation.byProperty !== undefined && aggregation.byProperty !== null) {
if (typeof aggregation.byProperty === 'string' && aggregation.byProperty.trim() !== '') {
normalized.byProperty = aggregation.byProperty.trim();
} else {
Logger.warn('[aggregation] byProperty must be a non-empty string, ignoring');
normalized.byProperty = null;
}
}
// keyResolver
if (aggregation.keyResolver !== undefined && aggregation.keyResolver !== null) {
if (typeof aggregation.keyResolver === 'function') {
normalized.keyResolver = aggregation.keyResolver;
} else {
Logger.warn('[aggregation] keyResolver must be a function, ignoring');
normalized.keyResolver = null;
}
}
// showInDescription
if (aggregation.showInDescription !== undefined) {
normalized.showInDescription = coerceBoolean(aggregation.showInDescription, true);
}
// topN
if (aggregation.topN !== undefined) {
const topNValue = Number(aggregation.topN);
if (Number.isInteger(topNValue) && topNValue > 0 && topNValue <= 100) {
normalized.topN = topNValue;
} else {
Logger.warn('[aggregation] topN must be a positive integer <= 100, using default (10)');
normalized.topN = DEFAULT_OPTIONS.aggregation.topN;
}
}
// Validation: if enabled but neither byProperty nor keyResolver is set, warn
if (normalized.enabled && !normalized.byProperty && !normalized.keyResolver) {
Logger.warn('[aggregation] enabled=true but neither byProperty nor keyResolver is set. Will use default key "default".');
}
return normalized;
}
function normalizeClassificationOptions(classification) {
const defaults = {
...DEFAULT_OPTIONS.classification,
classificationTargets: { ...DEFAULT_OPTIONS.classification.classificationTargets }
};
if (classification === undefined) {
return { ...defaults };
}
if (classification === null || classification === false) {
return { ...defaults, enabled: false };
}
const normalized = { ...defaults };
let input = classification;
const allowedClassificationTargets = ['color', 'opacity', 'width'];
if (typeof classification === 'string') {
input = {
scheme: classification,
enabled: classification !== 'none'
};
}
if (typeof input === 'object') {
if (input.enabled !== undefined) {
normalized.enabled = coerceBoolean(input.enabled, defaults.enabled);
} else {
normalized.enabled = true;
}
if (input.scheme !== undefined) {
normalized.scheme = sanitizeClassificationScheme(input.scheme);
}
if (input.classes !== undefined) {
const classesValue = Number(input.classes);
normalized.classes = Number.isInteger(classesValue)
? Math.max(2, Math.min(20, classesValue))
: defaults.classes;
}
if (Array.isArray(input.thresholds)) {
const validThresholds = input.thresholds
.map(value => Number(value))
.filter(value => Number.isFinite(value))
.sort((a, b) => a - b);
normalized.thresholds = validThresholds.length > 0 ? validThresholds : null;
} else if (input.thresholds === null) {
normalized.thresholds = null;
}
if (Array.isArray(input.colorMap)) {
normalized.colorMap = input.colorMap.slice();
} else if (input.colorMap === null) {
normalized.colorMap = null;
} else if (input.colorMap !== undefined) {
Logger.warn('[classification] colorMap should be an array of colors or stop objects. Ignoring provided value.');
normalized.colorMap = null;
}
if (Array.isArray(input.domain) && input.domain.length === 2) {
const [minValue, maxValue] = input.domain.map(value => Number(value));
if (Number.isFinite(minValue) && Number.isFinite(maxValue)) {
normalized.domain = [minValue, maxValue];
}
} else if (input.domain === null) {
normalized.domain = null;
}
const resolvedClassificationTargets = { ...defaults.classificationTargets };
const applyClassificationTargets = (source, sourceName) => {
if (source === undefined || source === null) {
return;
}
if (typeof source !== 'object' || Array.isArray(source)) {
Logger.warn(`[classification] ${sourceName} should be an object with boolean flags. Ignoring provided value.`);
return;
}
for (const key of allowedClassificationTargets) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
resolvedClassificationTargets[key] = coerceBoolean(
source[key],
defaults.classificationTargets[key]
);
}
}
};
// `classification.targets` は後方互換用エイリアス
applyClassificationTargets(input.targets, 'targets');
applyClassificationTargets(input.classificationTargets, 'classificationTargets');
normalized.classificationTargets = resolvedClassificationTargets;
} else {
Logger.warn('[classification] Unsupported configuration type. Expected string or object.');
normalized.enabled = false;
}
if (!normalized.enabled) {
return { ...defaults, enabled: false };
}
if (normalized.scheme === 'threshold' && !Array.isArray(normalized.thresholds)) {
Logger.warn('[classification] threshold scheme requires a thresholds array. Disabling classification.');
return { ...defaults, enabled: false };
}
return normalized;
}
function sanitizeClassificationScheme(scheme) {
if (!scheme || typeof scheme !== 'string') {
return 'linear';
}
const normalizedScheme = scheme.trim().toLowerCase();
const supportedSchemes = ['linear', 'log', 'equal-interval', 'quantize', 'threshold', 'quantile', 'jenks'];
if (supportedSchemes.includes(normalizedScheme)) {
return normalizedScheme;
}
Logger.warn(`[classification] Unknown scheme '${scheme}', falling back to 'linear'.`);
return 'linear';
}