Azure Mapsのヒートマップは手軽ですが、「今見えている範囲で最も密なクラスタだけを常に真っ赤にし、他は相対値で着色する」という要求は標準機能だけでは満たせません。本稿ではその課題を分解し、クライアント側で“可視範囲の最大値”を算出してリアルタイム正規化する実装を、サンプルコードと運用ノウハウまで含めて詳解します。
Azure Mapsのヒートマップが抱える前提と限界
Azure MapsのHeatMapLayerは、データソース(ポイント群)に対してカーネル密度推定を行い、内部的に生成した“密度フィールド”をカラーマップで可視化します。ここで重要なのは次の2点です。
- 各ポイントは
weight(重み)で密度への寄与度を調整できる。 - 着色は
["heatmap-density"]という内部変数を入力に行われるため、APIから密度の実数値や最大値を直接取得できない。
したがって「常に最大密度クラスタだけを赤にする」ためには、ヒートマップの入力側(=ポイント群)を可視範囲ごとに動的に正規化し、HeatMapLayerには常に0〜1にスケールされた重みを与えるアプローチが現実的です。
実現したい挙動の要点
- ズームレベルやパンに関わらず、現在の表示範囲で最も密度が高い箇所だけが最大強度(赤)になる。
- 可視範囲が変わり最大クラスタが入れ替われば、赤い領域も追随して自動更新される。
設計方針(結論)
- ヒートマップに投入する元データ(
FeatureCollection)を自前で保持する。 - 表示範囲のポイントだけを抽出し、その中での最大
weightをmaxとして算出。 - 全ポイントの
weightをweightNormalized = originalWeight / maxで0〜1に正規化してDataSourceを更新。 HeatMapLayerは["get","weight"]で各点の重みを受け取りつつ、着色は["heatmap-density"]を線形補間して0→青、1→赤のグラデーションにする。
これにより、現在の可視範囲で最も密度が高い場所のheatmap-densityが常に1(≒赤)に近づくように導けます。
全体像(完成サンプル)
以下は、Azure Maps Web SDK(azure-maps-control)で動く最小限の実装例です。実際の環境では認証キーの設定やCSSの読み込みなどを行ってください。
<div id="map" style="position:relative;width:100%;height:600px"></div>
<script>
// ---------------------------------------------
// 1) マップ初期化
// ---------------------------------------------
const map = new atlas.Map('map', {
center: [139.767, 35.681], // [lon, lat]
zoom: 10,
view: 'Auto',
language: 'ja-JP',
authOptions: {
authType: 'subscriptionKey',
subscriptionKey: 'YOUR_AZURE_MAPS_KEY'
}
});
map.events.add('ready', () => {
// データソースとレイヤーを初期化
initLayers();
// データを読み込み(疑似データの例)
loadData();
// 初回レンダリング
updateHeatMap();
// カメラ操作の終了時に更新(デバウンス付き)
const debounced = debounce(updateHeatMap, 120);
map.events.add('moveend', debounced);
map.events.add('zoomend', debounced);
});
// ---------------------------------------------
// 2) データ保持
// ---------------------------------------------
// allData は { position: [lon,lat], weight: number } の配列
let allData = [];
let datasource, heatmapLayer;
// 疑似データ生成(実データに置き換えてください)
function loadData() {
const rnd = (a, b) => Math.random() * (b - a) + a;
const base = [
{ c: [139.76, 35.68], n: 800 }, // 東京駅付近
{ c: [139.70, 35.69], n: 500 }, // 新宿
{ c: [139.81, 35.71], n: 300 }, // 上野
{ c: [139.73, 35.66], n: 200 }, // 霞ヶ関
];
base.forEach(b => {
for (let i = 0; i < b.n; i++) {
const lon = b.c[0] + rnd(-0.03, 0.03);
const lat = b.c[1] + rnd(-0.03, 0.03);
// weight は 1 固定でも良いが、属性から重み付けしたい場合はここで設定
allData.push({ position: [lon, lat], weight: 1 });
}
});
}
// ---------------------------------------------
// 3) レイヤー初期化
// ---------------------------------------------
function initLayers() {
datasource = new atlas.source.DataSource();
map.sources.add(datasource);
heatmapLayer = new atlas.layer.HeatMapLayer(datasource, null, {
// 各ポイントの寄与度
weight: ['get', 'weight'],
// ズームに応じて半径スケーリング
radius: [
'interpolate', ['linear'], ['zoom'],
5, 5, // zoom=5 で半径 5px
15, 40 // zoom=15 で半径 40px
],
// 密度(0..1)に対する着色(0:青→1:赤)
color: [
'interpolate', ['linear'], ['heatmap-density'],
0.0, 'rgba(0, 0, 255, 0.0)',
0.2, 'rgb(0, 120, 255)',
0.5, 'rgb(255, 165, 0)',
0.8, 'rgb(255, 80, 0)',
1.0, 'rgb(255, 0, 0)'
],
// 強度のゲイン(通常は1でOK)
intensity: 1.0,
// 背景地図とのブレンド
opacity: 0.9
});
map.layers.add(heatmapLayer, 'labels'); // ラベルの下に敷く場合は順序を調整
}
// ---------------------------------------------
// 4) 可視範囲で正規化して再描画
// ---------------------------------------------
function updateHeatMap() {
if (!allData.length) return;
const b = map.getCamera().bounds; // [west, south, east, north]
if (!b) return;
const [w, s, e, n] = b;
// 反転(反経線)対応: 可視範囲が -180/180 を跨ぐ場合
const crossesAntimeridian = w > e;
// 可視ポイント抽出
const visible = [];
for (let i = 0; i < allData.length; i++) {
const lon = allData[i].position[0];
const lat = allData[i].position[1];
const inLon = crossesAntimeridian ? (lon >= w || lon <= e) : (lon >= w && lon <= e);
const inLat = (lat >= s && lat <= n);
if (inLon && inLat) visible.push(allData[i]);
}
if (!visible.length) return;
// 可視範囲の weight 最大値
let max = 0;
for (let i = 0; i < visible.length; i++) {
if (visible[i].weight > max) max = visible[i].weight;
}
if (max === 0) return;
// 0〜1 正規化して Feature に変換
const features = new Array(allData.length);
for (let i = 0; i < allData.length; i++) {
const p = allData[i];
const wNorm = p.weight / max;
features[i] = new atlas.data.Feature(
new atlas.data.Point(p.position),
{ weight: wNorm }
);
}
// DataSource を差し替えて再描画
datasource.setShapes(features);
}
// ---------------------------------------------
// 5) 軽量デバウンス
// ---------------------------------------------
function debounce(fn, wait) {
let t;
return function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), wait);
};
}
</script>
コードの考え方とカスタマイズのポイント
可視範囲の最大値で正規化する理由
Azure Mapsのヒートマップは可視範囲の変更(パンやズーム)に応じて密度分布が変化します。“今見えている”分布の中で最大が1、他は0〜1に収まるように重みをスケーリングすれば、カラーマップの上限(赤)に自動的に合わせ込みできます。これにより「どのズームでも必ず赤が1箇所存在する」に近い見た目を維持できます。
colorは["heatmap-density"]を使う
HeatMapLayerのcolorは、ヒートマップ内部の密度値を示す["heatmap-density"]を入力に設定します。個々のポイントのプロパティ(["get","weight"])ではなく密度フィールドに基づく色付けになる点に注意してください。重みの正規化は密度フィールドのスケールに効き、結果として最大密度が赤に近づきます。
半径とズームの一貫性
ズームによって点の広がり(半径)を線形補間で拡大・縮小します。下表のように、地図の操作に応じて見え方が破綻しない範囲で設定すると、どのズームでも“赤1箇所”の体感が保ちやすくなります。
| プロパティ | 推奨設定 | 効果 |
|---|---|---|
radius | ['interpolate',['linear'],['zoom'],5,5,15,40] | ズームインで広がり増、ズームアウトで減 |
intensity | 1.0(必要に応じて微調整) | 密度フィールドのゲイン調整 |
opacity | 0.7〜0.95 | 背景地図との馴染み調整 |
color | 0→青、1→赤の連続グラデーション | 最大密度の赤を視認しやすくする |
表示範囲の抽出(境界の扱い)
map.getCamera().boundsは[west, south, east, north](経度・緯度)を返します。世界地図の表示は反経線(±180°)を跨ぐことがあるため、west > eastになるケース(ラップアラウンド)を考慮して経度の包含判定を行います。本稿のサンプルではcrossesAntimeridianで場合分けして可視ポイントを抽出しています。
「必ず1箇所だけ赤」をどこまで保証できるか
数学的には、完全に同じ密度値を持つピークが複数ある場合があります。さらに、ヒートマップはカーネル畳み込みの結果であり、ピクセル解像度や補間の丸め・ブレンドによって同等の最大密度が複数セルに生じ得ます。したがって、厳密に「1箇所だけ」を保証するのは困難です。本手法は見た目として1箇所が最も赤く“なりがち”な状態を維持する現実解であり、要件定義時にこの点を明記しておくと運用が安定します。
パフォーマンス最適化
ポイント数が数万〜数十万になると、毎回の正規化・再構築がボトルネックになり得ます。以下の最適化を段階的に適用してください。
デバウンス
本稿のサンプルのように、moveend/zoomendのハンドラーに120〜200ms程度のデバウンスをかけると、不要な再描画を抑制できます。激しいパン中に何度も再計算するのは避け、操作完了時のみ更新するのが基本です。
Web Workerで正規化をオフロード
// worker.js
self.onmessage = (e) => {
const { allData, bounds } = e.data;
const [w, s, eLon, n] = bounds;
const crosses = w > eLon;
// 可視ポイント最大値
let max = 0;
for (let i = 0; i < allData.length; i++) {
const [lon, lat] = allData[i].position;
const inLon = crosses ? (lon >= w || lon <= eLon) : (lon >= w && lon <= eLon);
const inLat = (lat >= s && lat <= n);
if (inLon && inLat && allData[i].weight > max) max = allData[i].weight;
}
// 正規化
const weights = new Float32Array(allData.length);
if (max > 0) {
for (let i = 0; i < allData.length; i++) {
weights[i] = allData[i].weight / max;
}
}
postMessage({ weights });
};
// メインスレッド側(抜粋)
const worker = new Worker('worker.js');
function updateHeatMapWithWorker() {
const bounds = map.getCamera().bounds;
worker.postMessage({ allData, bounds });
}
worker.onmessage = (e) => {
const { weights } = e.data;
const features = allData.map((p, i) => new atlas.data.Feature(
new atlas.data.Point(p.position),
{ weight: weights[i] }
));
datasource.setShapes(features);
};
メインスレッドのフリーズを避けつつ、数万点規模でもスムーズな操作感を確保できます。
空間インデックスで可視抽出を高速化
すべての点を逐一矩形判定するのではなく、R木やグリッド(タイル)による空間インデックスを用意すると、可視候補だけに走査を絞れます。実装コストを抑えたい場合は、ズームごとに固定サイズの地理グリッドを用意し、各セルの最大weightだけをキャッシュしておく“粗い要約”でも効果があります。
サーバーサイド正規化
データが巨大(百万点以上)で、可視範囲に応じた最大値の計算を常時行うのが難しい場合は、APIにboundsとズームを渡して最大値・正規化済み重みを返すミドル層を設けると、クライアント負荷が大幅に下がります。レスポンスはFeatureCollectionまたはMVT(ベクトルタイル)で返すとスケールしやすいです。
品質と検証のチェックリスト
| 観点 | テスト方法 | 期待結果 |
|---|---|---|
| 最大クラスタの追随 | 赤い領域を画面外へパンし、別のピークを中央に持ってくる | 新しいピークが赤に、旧ピークは相対的に色が下がる |
| ズーム一貫性 | 同地点に対してズームイン/アウトを繰り返す | 赤の位置は変わらず、面積のみがスケール |
| 空データ処理 | 可視範囲に点がない状態を作る | 更新処理が早期リターンし、エラーや点滅が起きない |
| 境界条件 | 反経線を跨ぐ表示で挙動を確認 | 赤領域の選択が破綻しない |
| 性能 | 1万点/5万点/10万点のケースを計測 | moveend/zoomend後の反応が許容レイテンシ内(≦200ms 目安) |
よくある落とし穴と対策
- 色の入力に
["get","weight"]を使ってしまう:ヒートマップは密度フィールドで着色します。colorは["heatmap-density"]を入力にしてください。 - ズームイベントに連続で反応してカクつく:
move/zoomではなくmoveend/zoomend+デバウンスで更新しましょう。 - 最大値が0になる:可視範囲に点がない、または
weightがすべて0のときです。早期リターンで回避します。 - ピークが複数赤く見える:実データ上の重なりが同等のときに起きます。色停止点(カラーストップ)を微調整し、0.9〜1.0域の勾配を急にすると“最赤”が一箇所に見えやすくなります。
カラーマップ設計の実践
人間の視覚は赤に強く引き寄せられます。最大値を赤に固定する設計では、0.5付近の中間色をオレンジ〜黄にするのが直感的です。低密度域は背景との馴染みを考慮して透明度を上げ、rgba(0,0,255,0)のようにフェードアウトさせると地図記号を邪魔しません。プロジェクトのブランディングがある場合は色コードを差し替えても構いませんが、0→寒色、1→暖色の順序は保つのが無難です。
データ前処理と重み設計
weightは単純な出現数だけでなく、ビジネス意味に応じた複合指標にすることで、ヒートマップの意思決定力が上がります。
- イベント件数 × 重要度(例:VIP=3、一般=1)
- 滞在時間(秒)をロジスティック関数で正規化
- 異常値(極端に大きい値)はwinsorize(上位1%を打ち切り)して安定化
前処理後のweightは非負である必要があります。負の値は密度の意味を破壊するため避けてください。
スケールアップのためのアーキテクチャ
タイル先読み+要約統計
ズームレベルに応じたタイル(グリッド)にデータを事前集約し、各タイルに「最大weight」や「上位5パーセンタイル」などの要約統計を持たせておくと、可視範囲の最大値推定がオーダーO(タイル数)で済みます。詳細点は近傍タイルだけを解凍してDataSourceへ供給しましょう。
MVT(ベクトルタイル)配信
さらに負荷が高い場合は、サーバーでズーム・バウンディングボックス別の正規化済み重みをMVTとして配信し、クライアントはほぼ描画専業にします。地図表示の一貫性が上がり、複数ユーザーで同じビジュアルが得やすくなります。
アクセシビリティと可読性
- 色弱に配慮し、赤と青の間に輝度差のある中間色(黄やオレンジ)を挟む。
- 赤の“意味”を凡例や注記で明示(例:「この表示範囲での最大密度」)。
- ズームレスポンスに遅延がある場合は軽いローディングインジケータを表示。
運用Tips
- 凡例の文言:「この表示範囲における相対密度」である旨を明記し、絶対値と誤解されないようにする。
- スナップショット出力:報告資料に使う場合、同じバウンディングボックス・ズームでキャプチャして比較性を担保。
- ピック(クリック)連携:赤領域をクリックしたときに、近傍の元データ一覧をサイドパネルで表示すると分析効率が高い。
追加の実装スニペット
“赤が一箇所”感を強める色停止点の微調整
heatmapLayer.setOptions({
color: [
'interpolate', ['linear'], ['heatmap-density'],
0.00, 'rgba(0, 0, 255, 0.0)',
0.30, 'rgb(0, 120, 255)',
0.60, 'rgb(255, 200, 0)',
0.90, 'rgb(255, 80, 0)',
0.98, 'rgb(255, 20, 0)',
1.00, 'rgb(255, 0, 0)'
]
});
「上位n%を赤」による要件緩和
「絶対に一箇所」へのこだわりを緩め、上位1〜2%を赤、次の3〜10%を橙…とする階級別表現も現場では有効です。最大が横並びでも視認性と操作体験は良好です。
トラブルシューティング
| 症状 | 原因 | 解決策 |
|---|---|---|
| 赤が出ない/全体が淡い | 可視範囲の最大weightが過大で正規化結果が小さい | winsorizeで異常値を抑制する/intensityを上げる |
| 操作時にカクつく | 毎フレーム更新・再構築 | moveend/zoomendのみで更新+デバウンス/Worker化 |
| 反経線で赤領域が消える | 境界判定がwest > eastを未考慮 | 本稿のcrossesAntimeridian分岐で修正 |
| ピークが2箇所赤に見える | 同等の最大密度/丸め誤差 | 0.9以上の色勾配を急峻に/半径を微調整 |
セキュリティとプライバシー
位置情報は個人情報と高い相関を持つことがあります。公開環境では、時間分解能を落とす・ジッタ(微小なランダム移動)を加える・密度化した結果のみ配信などのマスキングを検討してください。内部利用でも、アクセス権や鍵の保護(環境変数・KeyVault利用)を徹底します。
まとめ
Azure Mapsの標準APIだけでは取得できない「現在表示中の最大密度値」を、元データを保持し自前で算出→正規化することで代替する手法を解説しました。要は、ヒートマップの入力を“可視範囲基準で常に0〜1にスケールする”という発想です。colorに["heatmap-density"]を使い、radiusのズーム補間、moveend/zoomendでの更新、デバウンス・Worker・空間インデックスといった最適化を積み上げれば、現実的なコストで「常に最大密度クラスタだけ赤くなる」動的ヒートマップを安定運用できます。
付録:最小テンプレート(コピペ用)
<div id="map" style="width:100%;height:500px"></div>
<script>
// --- 初期化 ---
const map = new atlas.Map('map', {
center: [139.767, 35.681],
zoom: 11,
authOptions: { authType: 'subscriptionKey', subscriptionKey: 'YOUR_KEY' }
});
let datasource;
map.events.add('ready', () => {
datasource = new atlas.source.DataSource();
map.sources.add(datasource);
map.layers.add(new atlas.layer.HeatMapLayer(datasource, null, {
weight: ['get', 'weight'],
radius: ['interpolate', ['linear'], ['zoom'], 5, 6, 16, 45],
color: ['interpolate', ['linear'], ['heatmap-density'],
0, 'rgba(0,0,255,0.0)', 0.5, 'rgb(255,200,0)', 1, 'rgb(255,0,0)'],
intensity: 1, opacity: 0.9
}), 'labels');
// ダミーデータ
const allData = [];
for (let i = 0; i < 5000; i++) {
const lon = 139.6 + Math.random() * 0.4;
const lat = 35.5 + Math.random() * 0.4;
allData.push({ position: [lon, lat], weight: 1 });
}
// 更新関数
function update() {
const [w, s, e, n] = map.getCamera().bounds;
const cross = w > e;
let max = 0;
const visibleIdx = [];
for (let i = 0; i < allData.length; i++) {
const [lon, lat] = allData[i].position;
const inLon = cross ? (lon >= w || lon <= e) : (lon >= w && lon <= e);
const inLat = (lat >= s && lat <= n);
if (inLon && inLat) {
visibleIdx.push(i);
if (allData[i].weight > max) max = allData[i].weight;
}
}
if (!visibleIdx.length || max === 0) return;
const features = allData.map((p, i) => new atlas.data.Feature(
new atlas.data.Point(p.position),
{ weight: p.weight / max }
));
datasource.setShapes(features);
}
const debounced = (fn => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), 120); }; })(update);
map.events.add('moveend', debounced);
map.events.add('zoomend', debounced);
update();
});
FAQ
Q. ヒートマップのクラスタリング結果を直接取得できますか?
いいえ。内部の密度フィールドや最大値は公開されません。可視範囲に基づく最大値を自前で計算する方針が現実的です。
Q. なぜcolorで["get","weight"]を使わないのですか?
色付けはピクセル単位の密度に対して行われるため、["heatmap-density"]を入力にする必要があります。weightはその密度計算に寄与します。
Q. どうしても赤が二つに割れます。
ピークが並ぶデータ構造です。半径や色停止点を微調整し、0.9以上の傾斜を急に、あるいは「上位n%を赤」に要件を変更すると視認性が安定します。
実装チェック表
| 項目 | 完了 | 備考 |
|---|---|---|
| データソース初期化 | □ | atlas.source.DataSourceを追加 |
| HeatMapLayer設定 | □ | weight/radius/color/opacity |
| 可視範囲抽出(反経線対応) | □ | west > east時の判定 |
| 最大値算出と0除算対策 | □ | max===0なら早期return |
| デバウンス | □ | 120〜200ms目安 |
| 性能テスト | □ | 1/5/10万点で評価 |
以上を実装すれば、ズームやパンに追随して「常に最大密度クラスタだけ赤くなる」動的ヒートマップが安定して動作します。運用環境ではデータ特性に合わせ、半径・色停止点・デバウンス・インデックス構造を丁寧にチューニングしてください。

コメント