本稿では、CesiumJS を用いて地理空間上にリアルタイムで回転するレーダー状の遮蔽領域(スキャンエリア)を可視化する手法を解説します。この効果は、半球状の検出範囲と、その内部を扇状に掃引する立体壁(wall)を組み合わせ、時刻更新イベントに基づき動的に位置を再計算することで実現されます。
アーキテクチャ概要
実装は二つのコア要素から構成されます:
1. 静的ベース領域:EllipsoidGraphics を用いた半球体(最大円錐角 90°)による検出範囲の定義。
2. 動的スキャンライン:WallGraphics で表現される垂直断面の扇形構造。これは、中心点から角度方向へ展開される高度変化付きポリゴン列であり、viewer.clock.onTick イベントで逐次再生成されます。
幾何学的計算の再設計
オリジナルの座標変換ロジックを明確性・保守性向上のため再構築しました。以下の関数は、地球表面上の局所座標系(ENU)を活用し、より直感的な角度・距離パラメータから3D頂点配列を生成します。
/**
* 指定された経緯度と方位角から、指定距離先の地理座標を算出
* @param {number} lon - 中心経度(度)
* @param {number} lat - 中心緯度(度)
* @param {number} distance - 距離(メートル)
* @param {number} bearing - 真方位角(度、北が0°)
* @returns {Cesium.Cartographic} 目標地点の地理座標
*/
function computeTargetCartographic(lon, lat, distance, bearing) {
const center = Cesium.Cartesian3.fromDegrees(lon, lat);
const enuToFixed = Cesium.Transforms.eastNorthUpToFixedFrame(center);
// ENU座標系でのオフセット(東, 北, 上)
const east = distance * Math.sin(Cesium.Math.toRadians(bearing));
const north = distance * Math.cos(Cesium.Math.toRadians(bearing));
const up = 0;
const offset = new Cesium.Cartesian3(east, north, up);
const targetCartesian = Cesium.Matrix4.multiplyByPoint(enuToFixed, offset, new Cesium.Cartesian3());
return Cesium.Cartographic.fromCartesian(targetCartesian);
}
/**
* 中心点と端点から、垂直扇形の頂点列(経度, 緯度, 高度)を生成
* @param {number} lon0 - 中心経度
* @param {number} lat0 - 中心緯度
* @param {Cesium.Cartographic} endpoint - 扇形の外周端点
* @param {number} startAngle - スキャン開始角度(度、水平面基準)
* @param {number} sweepAngle - スキャン幅(度)
* @returns {number[]} [lon0, lat0, 0, lon1, lat1, h1, ...]
*/
function generateVerticalSector(lon0, lat0, endpoint, startAngle, sweepAngle) {
const points = [lon0, lat0, 0]; // 基点(地上)
const radius = Cesium.Cartesian3.distance(
Cesium.Cartesian3.fromDegrees(lon0, lat0),
Cesium.Cartesian3.fromDegrees(endpoint.longitude, endpoint.latitude)
);
for (let a = startAngle; a <= startAngle + sweepAngle; a += 2) {
const rad = Cesium.Math.toRadians(a);
const height = radius * Math.sin(rad);
const horizontalScale = Math.cos(rad);
// 外周点への線形補間(水平方向のみ)
const lon = lon0 + (endpoint.longitude - lon0) * horizontalScale;
const lat = lat0 + (endpoint.latitude - lat0) * horizontalScale;
points.push(lon, lat, height);
}
return points;
}
エンティティ登録とアニメーション制御
以下は、上記関数を活用した最小限の実行コードです。スキャン速度や色は容易にカスタマイズ可能です。
const SCAN_RADIUS = 100000; // メートル
const CENTER_LON = -80;
const CENTER_LAT = 35;
let currentBearing = 0;
// 動的頂点配列(初期化)
let scanVertices = generateVerticalSector(
CENTER_LON,
CENTER_LAT,
computeTargetCartographic(CENTER_LON, CENTER_LAT, SCAN_RADIUS, 0),
0,
90
);
// エンティティ追加
const radarEntity = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(CENTER_LON, CENTER_LAT),
ellipsoid: {
radii: new Cesium.Cartesian3(SCAN_RADIUS, SCAN_RADIUS, SCAN_RADIUS),
maximumCone: Cesium.Math.toRadians(90),
material: Cesium.Color.fromCssColorString("#00dcff82"),
outline: true,
outlineColor: Cesium.Color.fromCssColorString("#00dcff"),
outlineWidth: 1.5,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 1.2e7)
},
wall: {
positions: new Cesium.CallbackProperty(() => {
return Cesium.Cartesian3.fromDegreesArrayHeights(scanVertices);
}, false),
material: Cesium.Color.fromCssColorString("#00dcff82"),
outline: true,
outlineColor: Cesium.Color.fromCssColorString("#00dcff"),
outlineWidth: 1.5,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 1.2e7)
}
});
// アニメーションループ(時計更新時)
viewer.clock.onTick.addEventListener(() => {
currentBearing = (currentBearing + 8) % 360; // 回転速度調整可能
const target = computeTargetCartographic(CENTER_LON, CENTER_LAT, SCAN_RADIUS, currentBearing);
scanVertices = generateVerticalSector(CENTER_LON, CENTER_LAT, target, 0, 90);
});
拡張可能なクラスベース実装
再利用性とテスト容易性を高めるため、ES6クラスによるラッパーを提供します。コンストラクタ引数は意図を明示し、内部状態は完全にカプセル化されています。
class RadarScanOverlay {
constructor(options) {
this.viewer = options.viewer;
this.id = options.id || `radar-${Math.random().toString(36).substr(2, 9)}`;
this.center = Cesium.Cartesian3.fromDegrees(options.longitude, options.latitude);
this.radius = options.radius || 100000;
this.color = Cesium.Color.fromCssColorString(options.color || "#00dcff82");
this.outlineColor = Cesium.Color.fromCssColorString(options.outlineColor || "#00dcff");
this.rotationSpeed = options.speed || 8; // deg/frame
this.sweepAngle = options.sweepAngle || 90;
this.bearing = 0;
this.vertices = this._generateSector();
this._createEntity();
this._startAnimation();
}
_generateSector() {
const target = computeTargetCartographic(
options.longitude,
options.latitude,
this.radius,
this.bearing
);
return generateVerticalSector(
options.longitude,
options.latitude,
target,
0,
this.sweepAngle
);
}
_createEntity() {
this.entity = this.viewer.entities.add({
id: this.id,
position: this.center,
ellipsoid: {
radii: new Cesium.Cartesian3(this.radius, this.radius, this.radius),
maximumCone: Cesium.Math.toRadians(90),
material: this.color,
outline: true,
outlineColor: this.outlineColor,
outlineWidth: 1.5,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 1.2e7)
},
wall: {
positions: new Cesium.CallbackProperty(() => {
return Cesium.Cartesian3.fromDegreesArrayHeights(this.vertices);
}, false),
material: this.color,
outline: true,
outlineColor: this.outlineColor,
outlineWidth: 1.5,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 1.2e7)
}
});
}
_startAnimation() {
this._animationHandler = () => {
this.bearing = (this.bearing + this.rotationSpeed) % 360;
this.vertices = this._generateSector();
};
this.viewer.clock.onTick.addEventListener(this._animationHandler);
}
destroy() {
if (this._animationHandler) {
this.viewer.clock.onTick.removeEventListener(this._animationHandler);
}
this.viewer.entities.removeById(this.id);
}
}
// 使用例:
// const scan = new RadarScanOverlay({
// viewer,
// longitude: -80,
// latitude: 35,
// radius: 100000,
// color: "#ff6b6b80",
// speed: 12
// });