splatone 0.0.19 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -7
- package/assets/screenshot_pie_tokyo.png +0 -0
- package/assets/screenshot_voronoi_tokyo.png +0 -0
- package/package.json +1 -1
- package/visualizer/pie-charts/node.js +162 -0
- package/visualizer/pie-charts/web.js +271 -0
- package/visualizer/voronoi/node.js +57 -4
- package/assets/screenshot_voronoi_kyoto.png +0 -0
package/README.md
CHANGED
|
@@ -11,15 +11,19 @@ SNSのジオタグ付きポストをキーワードに基づいて収集する
|
|
|
11
11
|
- Bulky: クロールした全てのジオタグを小さな点で描画する
|
|
12
12
|
- Marker Cluster: 密集しているジオタグをクラスタリングしてまとめて表示する
|
|
13
13
|
- Majority Hex: HexGridの各セルをセル内で最頻出するカテゴリの色で彩色
|
|
14
|
+
- Pie Charts: Hexセル中心にカテゴリ割合のPie Chartを描画し、カテゴリごとに半径を可変化
|
|
14
15
|
- Voronoi: HexGrid単位で集約したジオタグからVoronoiセルを生成し、各Hexのポリゴンでクリップして表示
|
|
15
16
|
- Heat: ヒートマップ
|
|
17
|
+
- Pie Charts: 円グラフグリッド
|
|
16
18
|
|
|
17
19
|
## Change Log
|
|
18
20
|
|
|
19
|
-
### v0.0.18 → v0.0.19
|
|
21
|
+
### v0.0.18 → v0.0.19 → v0.0.20
|
|
20
22
|
|
|
21
23
|
* **[可視化モジュール]** ```--vis-voronoi```追加
|
|
22
24
|
* ボロノイ図の生成
|
|
25
|
+
* **[可視化モジュール]** ```--vis-pie-charts```追加
|
|
26
|
+
* Hex中心のカテゴリ割合Pie Chart描画
|
|
23
27
|
|
|
24
28
|
### v0.0.17 → v0.0.18
|
|
25
29
|
|
|
@@ -83,6 +87,8 @@ Visualization (最低一つの指定が必須です)
|
|
|
83
87
|
--vis-majority-hex HexGrid内で最も出現頻度が高いカテゴリの色で彩色。Hex
|
|
84
88
|
apartiteモードで6分割パイチャート表示。透明度は全体
|
|
85
89
|
で正規化。 [真偽] [デフォルト: false]
|
|
90
|
+
--vis-pie-charts Hex中心にカテゴリ割合Pie Chartを表示。半径はグローバル
|
|
91
|
+
出現比に応じてカテゴリ毎に可変化。 [真偽] [デフォルト: false]
|
|
86
92
|
--vis-marker-cluster マーカークラスターとして地図上に表示
|
|
87
93
|
[真偽] [デフォルト: false]
|
|
88
94
|
--vis-voronoi Hex GridごとにVoronoiセルを生成して表示
|
|
@@ -174,6 +180,8 @@ APIキーは以下の3種類の方法で与える事ができます
|
|
|
174
180
|
|
|
175
181
|
### Bulky: 全ての点を地図上にポイントする
|
|
176
182
|
|
|
183
|
+
全ての点を地図上に表示する。
|
|
184
|
+
|
|
177
185
|

|
|
178
186
|
|
|
179
187
|
#### コマンド例
|
|
@@ -196,6 +204,8 @@ $ npx -y -p splatone@latest crawler -p flickr -k "sea,ocean|mountain,mount" --vi
|
|
|
196
204
|
|
|
197
205
|
### Marker Cluster: 高密度の地点はマーカーをまとめて表示する
|
|
198
206
|
|
|
207
|
+
全マーカーを表示すると、地図上がマーカーで埋め尽くされる問題に対して、高密度地点のマーカー群を一つにまとめてマーカーとする手法。ズームレベルに応じて自動的にマーカーが集約される。
|
|
208
|
+
|
|
199
209
|

|
|
200
210
|
|
|
201
211
|
#### コマンド例
|
|
@@ -211,6 +221,8 @@ $ npx -y -p splatone@latest crawler -p flickr -k "水域=canal,channel,waterway,
|
|
|
211
221
|
|
|
212
222
|
### Heat: ヒートマップ
|
|
213
223
|
|
|
224
|
+
出現頻度に基づいて点の影響範囲をガウス分布で定め連続的に彩色するヒートマップ。
|
|
225
|
+
|
|
214
226
|

|
|
215
227
|
|
|
216
228
|
#### コマンド例
|
|
@@ -235,8 +247,10 @@ $ npx -y -p splatone@latest crawler -p flickr -k "水域#0947ff=canal,river,sea,
|
|
|
235
247
|

|
|
236
248
|
|
|
237
249
|
#### コマンド例
|
|
250
|
+
|
|
238
251
|
* クエリは水域・緑地・交通・ランドマークを色分けしたもの。上記スクリーンショットはフロリダ半島全体
|
|
239
|
-
|
|
252
|
+
|
|
253
|
+
|
|
240
254
|
```shell
|
|
241
255
|
$ npx -y -p splatone@latest crawler -p flickr -k "水域=canal,channel,waterway,river,stream,watercourse,sea,ocean,gulf,bay,strait,lagoon,offshore|緑地=forest,woods,turf,lawn,jungle,trees,rainforest,grove,savanna,steppe|交通=bridge,overpass,flyover,aqueduct,trestle,street,road,thoroughfare,roadway,avenue,boulevard,lane,alley,roadway,carriageway,highway,motorway|ランドマーク=church,chapel,cathedral,basilica,minster,temple,shrine,neon,theater,statue,museum,sculpture,zoo,aquarium,observatory" --vis-majority-hex --v-majority-hex-Hexapartite --p-flickr-APIKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
242
256
|
```
|
|
@@ -253,18 +267,45 @@ $ npx -y -p splatone@latest crawler -p flickr -k "水域=canal,channel,waterway,
|
|
|
253
267
|
|
|
254
268
|
* ```--v-majority-hex-Hexapartite```を指定すると各Hexセルを六分割の荒いPie Chartとして中のカテゴリ頻度に応じて彩色します。
|
|
255
269
|
|
|
270
|
+
### Pie Charts: Hex中心にカテゴリ割合Pie Chartを描画
|
|
271
|
+
|
|
272
|
+

|
|
273
|
+
|
|
274
|
+
Hexセル中心に、カテゴリ比率を角度で、グローバル出現数を半径で示すPie Chartを描画します。カテゴリごとに円弧の半径が異なるため、同じHex内でも「世界的にどのカテゴリが多く集まったか」を直感的に比較できます。Pie Chart自体はHex境界内に収まるよう中央へ配置されます。
|
|
275
|
+
|
|
276
|
+
ズームイン/アウト時にはLeafletのzoomイベントをフックしてPie Chartを再描画し、現在の縮尺でもHex境界にフィットする半径が自動再計算されます。
|
|
277
|
+
|
|
278
|
+
#### コマンド例
|
|
279
|
+
|
|
280
|
+
* クエリは水域・交通・宗教施設・緑地を色分け。Hexサイズに応じて自動計算される最大半径を90%まで、最小半径をその40%に設定しています。
|
|
281
|
+
|
|
282
|
+
```shell
|
|
283
|
+
$ npx -y -p splatone@latest crawler -p flickr -k "水域#0947ff=canal,river,sea,strait,channel,waterway,pond|交通#aaaaaa=road,street,alley,sidewalk,bridge|宗教施設#ffb724=chapel,church,cathedral,temple,shrine|緑地#00a73d=forest,woods,trees,mountain,garden,turf" --vis-pie-charts --v-pie-charts-MaxRadiusScale=0.9 --v-pie-charts-MinRadiusScale=0.4 --p-flickr-APIKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
#### コマンドライン引数
|
|
287
|
+
|
|
288
|
+
| オプション | 説明 | 型 | デフォルト |
|
|
289
|
+
| :--------------------------------------- | :------------------------------------------------------------------------------------------------------------ | :--- | :--------- |
|
|
290
|
+
| `--v-pie-charts-MaxRadiusScale` | Hex内接円半径に対する最大Pie半径の倍率(0-1.5)。1.0でHex境界いっぱい、0.9なら10%余白。 | 数値 | 0.9 |
|
|
291
|
+
| `--v-pie-charts-MinRadiusScale` | 最大半径に対する最小Pie半径の倍率(0-1)。カテゴリが存在する場合に確保する下限割合。 | 数値 | 0.25 |
|
|
292
|
+
| `--v-pie-charts-StrokeWidth` | Pie Chart外周・扇形境界の線幅(px)。 | 数値 | 1 |
|
|
293
|
+
| `--v-pie-charts-BackgroundOpacity` | 最大半径ガイドリングの塗り透明度(0-1)。背景リングの見え方を調整します。 | 数値 | 0.2 |
|
|
294
|
+
|
|
295
|
+
Pie Chartの最大・最小半径は各Hexのジオメトリから算出した内接円半径に基づき動的に決まり、カテゴリごとの扇形半径は「そのHex内カテゴリ出現数 ÷ 全カテゴリ総数」に比例して拡大します。グローバル最大カテゴリのシェアを1として正規化するため、Hex間でもカテゴリ規模を比較できます。
|
|
296
|
+
|
|
256
297
|
### Voronoi: Hex Gridをベースにしたボロノイ分割
|
|
257
298
|
|
|
258
299
|
Hex Gridで集約した各セル内のジオタグを種点としてVoronoi分割を行い、生成したポリゴンをHex境界でクリップして表示します。カテゴリカラーと総数はHex集計結果に基づき、最小間隔/最大サイト数の制御で過密な地域も読みやすく整列できます。
|
|
259
300
|
|
|
260
|
-

|
|
261
302
|
|
|
262
303
|
#### コマンド例
|
|
263
304
|
|
|
264
|
-
* クエリは水域・交通・宗教施設・緑地を色分けしたもの。Hex単位で50m以上離れたサイトだけをVoronoi
|
|
305
|
+
* クエリは水域・交通・宗教施設・緑地を色分けしたもの。Hex単位で50m以上離れたサイトだけをVoronoiセルとして採用します。上記の例は東京を範囲としたもの。皇居の緑地や墨田川の水域がよく現れている。
|
|
265
306
|
|
|
266
307
|
```shell
|
|
267
|
-
$ npx -y -p splatone@latest crawler -p flickr -k "水域#0947ff=canal,river,sea,strait,channel,waterway,pond|交通#aaaaaa=road,street,alley,sidewalk,bridge|宗教施設#ffb724=chapel,church,cathedral,temple,shrine|緑地#00a73d=forest,woods,trees,mountain,garden,turf" --vis-voronoi --v-voronoi-MinSiteSpacingMeters=50
|
|
308
|
+
$ npx -y -p splatone@latest crawler -p flickr -k "水域#0947ff=canal,river,sea,strait,channel,waterway,pond|交通#aaaaaa=road,street,alley,sidewalk,bridge|宗教施設#ffb724=chapel,church,cathedral,temple,shrine|緑地#00a73d=forest,woods,trees,mountain,garden,turf" --vis-voronoi --v-voronoi-MinSiteSpacingMeters=50 --p-flickr-APIKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
268
309
|
```
|
|
269
310
|
|
|
270
311
|
#### コマンドライン引数
|
|
@@ -272,9 +313,9 @@ $ npx -y -p splatone@latest crawler -p flickr -k "水域#0947ff=canal,river,sea,
|
|
|
272
313
|
| オプション | 説明 | 型 | デフォルト |
|
|
273
314
|
| :---------------------------------------- | :---------------------------------------------------------------------------------------- | :--- | :--------- |
|
|
274
315
|
| `--v-voronoi-MaxSitesPerHex` | 1 HexあたりにPoissonサンプリングで残す最大サイト数。0のときは制限なし。 | 数値 | 0 |
|
|
275
|
-
| `--v-voronoi-MinSiteSpacingMeters` | Hex内の採用サイト間で確保する最小距離 (メートル)
|
|
316
|
+
| `--v-voronoi-MinSiteSpacingMeters` | Hex内の採用サイト間で確保する最小距離 (メートル)。ジオタグが密集していても空間的に均等化しつつ、MinSiteSpacingMeters範囲内で出現数の多いカテゴリを優先して残す。 | 数値 | 50 |
|
|
276
317
|
|
|
277
|
-
|
|
318
|
+
MinSiteSpacingMetersによる間引きは、各サイト周辺 (MinSiteSpacingMeters以内) の同カテゴリ出現数を優先度として利用するため、同距離内で競合した場合も局所的に密度の高いカテゴリのサイトが採用されやすくなります。一方で密度は低いが他の場所に比べて顕著に出現するカテゴリを見逃す可能性があります。なお、Voronoi図の作成は消費メモリが大きい為、デフォルトでは50m間隔に間引きます。厳密解が必要な場合は```--v-voronoi-MinSiteSpacingMeters=0```を指定してください。ただし、その場合はヒープを使い果たしてクラッシュする可能性があります。マシンパワーに余裕がある場合は```npx --node-options='--max-old-space-size=10240'```のようにヒープサイズを拡大して実行する事も可能です。もう一つのオプション```--v-voronoi-MaxSitesPerHex```はHex内の最大アイテム数を制限するものです。ポワソンサンプリングに基づいてアイテムを間引きます。MinSiteSpacingMetersと共に、適切な結果が得られるよう調整してください。
|
|
278
319
|
|
|
279
320
|
## キーワード指定方法
|
|
280
321
|
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { featureCollection, centroid as turfCentroid } from '@turf/turf';
|
|
4
|
+
import { VisualizerBase } from '../../lib/VisualizerBase.js';
|
|
5
|
+
|
|
6
|
+
function buildHexIndex(target = {}) {
|
|
7
|
+
const features = target?.hex?.features;
|
|
8
|
+
const index = new Map();
|
|
9
|
+
if (!Array.isArray(features)) return index;
|
|
10
|
+
for (const feature of features) {
|
|
11
|
+
const hexId = feature?.properties?.hexId;
|
|
12
|
+
if (hexId == null) continue;
|
|
13
|
+
index.set(String(hexId), feature);
|
|
14
|
+
}
|
|
15
|
+
return index;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function computeHexCentroid(hexFeature) {
|
|
19
|
+
if (!hexFeature) return null;
|
|
20
|
+
try {
|
|
21
|
+
const center = turfCentroid(hexFeature);
|
|
22
|
+
const coords = center?.geometry?.coordinates;
|
|
23
|
+
if (Array.isArray(coords) && coords.length >= 2) {
|
|
24
|
+
return coords;
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.warn('[PieChartsVisualizer] Failed to compute centroid', err?.message);
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractPrimaryRing(feature) {
|
|
33
|
+
const geometry = feature?.geometry;
|
|
34
|
+
if (!geometry) return null;
|
|
35
|
+
if (geometry.type === 'Polygon') {
|
|
36
|
+
const ring = geometry?.coordinates?.[0];
|
|
37
|
+
if (!Array.isArray(ring) || ring.length < 3) return null;
|
|
38
|
+
return ring.map(coords => Array.isArray(coords) ? [coords[0], coords[1]] : null).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
if (geometry.type === 'MultiPolygon') {
|
|
41
|
+
const firstPoly = geometry?.coordinates?.[0]?.[0];
|
|
42
|
+
if (!Array.isArray(firstPoly) || firstPoly.length < 3) return null;
|
|
43
|
+
return firstPoly.map(coords => Array.isArray(coords) ? [coords[0], coords[1]] : null).filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function aggregatePieChartFeatures(result = {}, target = {}) {
|
|
49
|
+
const hexIndex = buildHexIndex(target);
|
|
50
|
+
const features = [];
|
|
51
|
+
let globalMaxCategoryCount = 0;
|
|
52
|
+
let globalTotalCount = 0;
|
|
53
|
+
|
|
54
|
+
for (const [hexId, categories] of Object.entries(result ?? {})) {
|
|
55
|
+
if (!categories) continue;
|
|
56
|
+
const breakdown = [];
|
|
57
|
+
let totalCount = 0;
|
|
58
|
+
|
|
59
|
+
for (const [categoryName, payload] of Object.entries(categories)) {
|
|
60
|
+
const count = payload?.items?.features?.length ?? 0;
|
|
61
|
+
if (count <= 0) continue;
|
|
62
|
+
breakdown.push({ name: categoryName, count });
|
|
63
|
+
totalCount += count;
|
|
64
|
+
globalTotalCount += count;
|
|
65
|
+
if (count > globalMaxCategoryCount) {
|
|
66
|
+
globalMaxCategoryCount = count;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (totalCount === 0) continue;
|
|
71
|
+
|
|
72
|
+
const hexFeature = hexIndex.get(String(hexId));
|
|
73
|
+
if (!hexFeature) {
|
|
74
|
+
console.warn(`[PieChartsVisualizer] Missing hex polygon for ${hexId}`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const centroidCoords = computeHexCentroid(hexFeature);
|
|
79
|
+
if (!centroidCoords) {
|
|
80
|
+
console.warn(`[PieChartsVisualizer] Unable to place pie chart for hex ${hexId}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ring = extractPrimaryRing(hexFeature);
|
|
85
|
+
if (!ring || ring.length < 3) {
|
|
86
|
+
console.warn(`[PieChartsVisualizer] Missing polygon ring for hex ${hexId}`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
breakdown.sort((a, b) => b.count - a.count);
|
|
91
|
+
|
|
92
|
+
features.push({
|
|
93
|
+
type: 'Feature',
|
|
94
|
+
geometry: {
|
|
95
|
+
type: 'Point',
|
|
96
|
+
coordinates: centroidCoords
|
|
97
|
+
},
|
|
98
|
+
properties: {
|
|
99
|
+
hexId,
|
|
100
|
+
totalCount,
|
|
101
|
+
categories: breakdown,
|
|
102
|
+
hexCoordinates: ring
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
features,
|
|
109
|
+
globalMaxCategoryCount,
|
|
110
|
+
globalTotalCount
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default class PieChartsVisualizer extends VisualizerBase {
|
|
115
|
+
static name = 'Pie Charts Visualizer';
|
|
116
|
+
static version = '0.0.1';
|
|
117
|
+
static description = 'Hex中心にカテゴリ割合のPie Chartを描画するビジュアライザ';
|
|
118
|
+
|
|
119
|
+
constructor() {
|
|
120
|
+
super();
|
|
121
|
+
this.id = path.basename(path.dirname(fileURLToPath(import.meta.url)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async yargv(yargv) {
|
|
125
|
+
return yargv
|
|
126
|
+
.option(this.argKey('MaxRadiusScale'), {
|
|
127
|
+
group: 'For ' + this.id + ' Visualizer',
|
|
128
|
+
type: 'number',
|
|
129
|
+
description: 'Hex内接円半径に対する最大半径スケール (0-1.5)',
|
|
130
|
+
default: 0.9
|
|
131
|
+
})
|
|
132
|
+
.option(this.argKey('MinRadiusScale'), {
|
|
133
|
+
group: 'For ' + this.id + ' Visualizer',
|
|
134
|
+
type: 'number',
|
|
135
|
+
description: '最大半径に対する最小半径スケール (0-1)',
|
|
136
|
+
default: 0.25
|
|
137
|
+
})
|
|
138
|
+
.option(this.argKey('StrokeWidth'), {
|
|
139
|
+
group: 'For ' + this.id + ' Visualizer',
|
|
140
|
+
type: 'number',
|
|
141
|
+
description: 'Pie Chart輪郭線の太さ(px)',
|
|
142
|
+
default: 1
|
|
143
|
+
})
|
|
144
|
+
.option(this.argKey('BackgroundOpacity'), {
|
|
145
|
+
group: 'For ' + this.id + ' Visualizer',
|
|
146
|
+
type: 'number',
|
|
147
|
+
description: '最大半径ガイドリングの透明度 (0-1)',
|
|
148
|
+
default: 0.2
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getFutureCollection(result, target, visOptions) { // visOptions reserved for future use
|
|
153
|
+
const { features, globalMaxCategoryCount, globalTotalCount } = aggregatePieChartFeatures(result, target);
|
|
154
|
+
const collection = featureCollection(features);
|
|
155
|
+
collection.properties = {
|
|
156
|
+
globalMaxCategoryCount,
|
|
157
|
+
globalTotalCount
|
|
158
|
+
};
|
|
159
|
+
return collection;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
let layerGroup = null;
|
|
2
|
+
let styleInjected = false;
|
|
3
|
+
let zoomHandler = null;
|
|
4
|
+
let activeMap = null;
|
|
5
|
+
let cachedContext = null;
|
|
6
|
+
|
|
7
|
+
function reset(map) {
|
|
8
|
+
if (layerGroup && map) {
|
|
9
|
+
map.removeLayer(layerGroup);
|
|
10
|
+
}
|
|
11
|
+
if (map && zoomHandler) {
|
|
12
|
+
map.off('zoomend', zoomHandler);
|
|
13
|
+
}
|
|
14
|
+
layerGroup = null;
|
|
15
|
+
zoomHandler = null;
|
|
16
|
+
activeMap = null;
|
|
17
|
+
cachedContext = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ensureStyles() {
|
|
21
|
+
if (styleInjected) return;
|
|
22
|
+
const style = document.createElement('style');
|
|
23
|
+
style.textContent = `
|
|
24
|
+
.pie-chart-marker {
|
|
25
|
+
background: transparent !important;
|
|
26
|
+
border: none !important;
|
|
27
|
+
}
|
|
28
|
+
.pie-chart-marker svg {
|
|
29
|
+
pointer-events: none;
|
|
30
|
+
display: block;
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
document.head.appendChild(style);
|
|
34
|
+
styleInjected = true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeFeatureCollection(payload) {
|
|
38
|
+
if (!payload) return null;
|
|
39
|
+
if (payload.type === 'FeatureCollection') return payload;
|
|
40
|
+
if (payload?.pieCharts?.type === 'FeatureCollection') {
|
|
41
|
+
return payload.pieCharts;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULTS = {
|
|
47
|
+
MaxRadiusScale: 0.9,
|
|
48
|
+
MinRadiusScale: 0.25,
|
|
49
|
+
StrokeWidth: 1,
|
|
50
|
+
BackgroundOpacity: 0.2
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function polarToCartesian(cx, cy, radius, angle) {
|
|
54
|
+
return {
|
|
55
|
+
x: cx + radius * Math.cos(angle),
|
|
56
|
+
y: cy + radius * Math.sin(angle)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildSlicePath(cx, cy, radius, startAngle, endAngle) {
|
|
61
|
+
if (radius <= 0 || endAngle <= startAngle) return '';
|
|
62
|
+
const start = polarToCartesian(cx, cy, radius, startAngle);
|
|
63
|
+
const end = polarToCartesian(cx, cy, radius, endAngle);
|
|
64
|
+
const largeArcFlag = (endAngle - startAngle) > Math.PI ? 1 : 0;
|
|
65
|
+
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${end.x} ${end.y} Z`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clamp(value, min, max) {
|
|
69
|
+
return Math.min(max, Math.max(min, value));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function computeHexRadiusPx(map, centroidLatLng, ring = []) {
|
|
73
|
+
if (!map || !centroidLatLng || !Array.isArray(ring) || ring.length === 0) {
|
|
74
|
+
return 32;
|
|
75
|
+
}
|
|
76
|
+
const centerPoint = map.latLngToLayerPoint(centroidLatLng);
|
|
77
|
+
let minDistance = Infinity;
|
|
78
|
+
for (const coord of ring) {
|
|
79
|
+
if (!Array.isArray(coord) || coord.length < 2) continue;
|
|
80
|
+
const latLng = L.latLng(coord[1], coord[0]);
|
|
81
|
+
const point = map.latLngToLayerPoint(latLng);
|
|
82
|
+
const dist = centerPoint.distanceTo(point);
|
|
83
|
+
if (Number.isFinite(dist) && dist > 0) {
|
|
84
|
+
minDistance = Math.min(minDistance, dist);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!Number.isFinite(minDistance) || minDistance === Infinity) {
|
|
88
|
+
return 32;
|
|
89
|
+
}
|
|
90
|
+
return Math.max(4, minDistance - 2);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function deriveRadiusRange(map, feature, visOptions) {
|
|
94
|
+
const coords = feature?.geometry?.coordinates;
|
|
95
|
+
const centroidLatLng = Array.isArray(coords) && coords.length >= 2 ? L.latLng(coords[1], coords[0]) : null;
|
|
96
|
+
const ring = feature?.properties?.hexCoordinates ?? [];
|
|
97
|
+
const baseRadius = computeHexRadiusPx(map, centroidLatLng, ring);
|
|
98
|
+
const maxScale = clamp(Number(visOptions.MaxRadiusScale ?? DEFAULTS.MaxRadiusScale), 0.1, 1.5);
|
|
99
|
+
const minScale = clamp(Number(visOptions.MinRadiusScale ?? DEFAULTS.MinRadiusScale), 0, 1);
|
|
100
|
+
const maxRadius = Math.max(4, baseRadius * maxScale);
|
|
101
|
+
const minRadius = clamp(baseRadius * minScale, 0, maxRadius * 0.95);
|
|
102
|
+
return { maxRadius, minRadius };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function computeRadiusPixels(count, stats, radiusRange) {
|
|
106
|
+
if (!Number.isFinite(count) || count <= 0) return 0;
|
|
107
|
+
const { maxRadius, minRadius } = radiusRange;
|
|
108
|
+
const globalTotal = Math.max(1, Number(stats.globalTotalCount) || 1);
|
|
109
|
+
const globalMaxCount = Math.max(1, Number(stats.globalMaxCategoryCount) || 1);
|
|
110
|
+
const maxShare = globalMaxCount / globalTotal;
|
|
111
|
+
const share = count / globalTotal;
|
|
112
|
+
const normalized = maxShare > 0 ? clamp(share / maxShare, 0, 1) : 0;
|
|
113
|
+
if (normalized <= 0) return minRadius;
|
|
114
|
+
return minRadius + (maxRadius - minRadius) * normalized;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderPieSvg(feature, palette, visOptions, stats, radiusRange) {
|
|
118
|
+
const categories = feature?.properties?.categories;
|
|
119
|
+
const totalCount = feature?.properties?.totalCount ?? 0;
|
|
120
|
+
if (!Array.isArray(categories) || categories.length === 0 || totalCount === 0) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const maxRadius = radiusRange.maxRadius;
|
|
125
|
+
const strokeWidth = Math.max(0, Number(visOptions.StrokeWidth ?? DEFAULTS.StrokeWidth));
|
|
126
|
+
const backgroundOpacity = Math.min(1, Math.max(0, Number(visOptions.BackgroundOpacity ?? DEFAULTS.BackgroundOpacity)));
|
|
127
|
+
const size = (maxRadius + strokeWidth) * 2;
|
|
128
|
+
const cx = size / 2;
|
|
129
|
+
const cy = size / 2;
|
|
130
|
+
|
|
131
|
+
const validCategories = categories.filter(cat => (cat?.count ?? 0) > 0);
|
|
132
|
+
if (!validCategories.length) return null;
|
|
133
|
+
|
|
134
|
+
const total = validCategories.reduce((sum, cat) => sum + cat.count, 0);
|
|
135
|
+
if (total === 0) return null;
|
|
136
|
+
|
|
137
|
+
let currentAngle = -Math.PI / 2;
|
|
138
|
+
const slices = [];
|
|
139
|
+
for (const cat of validCategories) {
|
|
140
|
+
const ratio = cat.count / total;
|
|
141
|
+
const angleSpan = ratio * Math.PI * 2;
|
|
142
|
+
if (angleSpan <= 0) continue;
|
|
143
|
+
const startAngle = currentAngle;
|
|
144
|
+
const endAngle = currentAngle + angleSpan;
|
|
145
|
+
currentAngle = endAngle;
|
|
146
|
+
const radius = computeRadiusPixels(cat.count, stats, radiusRange);
|
|
147
|
+
if (radius <= 0) continue;
|
|
148
|
+
slices.push({
|
|
149
|
+
path: buildSlicePath(cx, cy, radius, startAngle, endAngle),
|
|
150
|
+
color: palette[cat.name]?.color || '#888888',
|
|
151
|
+
stroke: palette[cat.name]?.darken || 'rgba(0,0,0,0.4)',
|
|
152
|
+
count: cat.count,
|
|
153
|
+
name: cat.name
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!slices.length) return null;
|
|
158
|
+
|
|
159
|
+
const outlineColor = 'rgba(0,0,0,0.35)';
|
|
160
|
+
const svgParts = [`<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-label="hex pie chart">`];
|
|
161
|
+
svgParts.push(`<circle cx="${cx}" cy="${cy}" r="${maxRadius}" fill="rgba(255,255,255,${backgroundOpacity})" stroke="${outlineColor}" stroke-width="${strokeWidth}" />`);
|
|
162
|
+
|
|
163
|
+
for (const slice of slices) {
|
|
164
|
+
if (!slice.path) continue;
|
|
165
|
+
svgParts.push(`<path d="${slice.path}" fill="${slice.color}" stroke="${slice.stroke}" stroke-width="${Math.max(0.5, strokeWidth * 0.6)}" />`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
svgParts.push('</svg>');
|
|
169
|
+
return { svg: svgParts.join(''), size };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function createMarker(map, feature, palette, visOptions, stats) {
|
|
173
|
+
const coords = feature?.geometry?.coordinates;
|
|
174
|
+
if (!Array.isArray(coords) || coords.length < 2) return null;
|
|
175
|
+
const radiusRange = deriveRadiusRange(map, feature, visOptions);
|
|
176
|
+
const rendered = renderPieSvg(feature, palette, visOptions, stats, radiusRange);
|
|
177
|
+
if (!rendered) return null;
|
|
178
|
+
|
|
179
|
+
const icon = L.divIcon({
|
|
180
|
+
className: 'pie-chart-marker',
|
|
181
|
+
html: rendered.svg,
|
|
182
|
+
iconSize: [rendered.size, rendered.size],
|
|
183
|
+
iconAnchor: [rendered.size / 2, rendered.size / 2]
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const marker = L.marker([coords[1], coords[0]], { icon });
|
|
187
|
+
const total = feature?.properties?.totalCount ?? 0;
|
|
188
|
+
const breakdown = feature?.properties?.categories ?? [];
|
|
189
|
+
const htmlLines = [`<strong>Hex ${feature?.properties?.hexId ?? ''}</strong>`, `Total: ${total}`];
|
|
190
|
+
for (const cat of breakdown) {
|
|
191
|
+
const color = palette[cat.name]?.color || '#888888';
|
|
192
|
+
const pct = total > 0 ? ((cat.count / total) * 100).toFixed(1) : '0.0';
|
|
193
|
+
htmlLines.push(`<span style="display:inline-flex;align-items:center;gap:4px;">
|
|
194
|
+
<span style="width:10px;height:10px;background:${color};display:inline-block;border-radius:50%;"></span>
|
|
195
|
+
${cat.name}: ${cat.count} (${pct}%)
|
|
196
|
+
</span>`);
|
|
197
|
+
}
|
|
198
|
+
marker.bindTooltip(htmlLines.join('<br/>'), { direction: 'top', opacity: 0.9 });
|
|
199
|
+
return marker;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderPieLayer({ fitBounds } = {}) {
|
|
203
|
+
if (!activeMap || !cachedContext) return false;
|
|
204
|
+
|
|
205
|
+
if (layerGroup) {
|
|
206
|
+
activeMap.removeLayer(layerGroup);
|
|
207
|
+
layerGroup = null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const markers = [];
|
|
211
|
+
const { featureCollection, palette, visOptions, stats } = cachedContext;
|
|
212
|
+
for (const feature of featureCollection.features) {
|
|
213
|
+
const marker = createMarker(activeMap, feature, palette, visOptions, stats);
|
|
214
|
+
if (marker) markers.push(marker);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!markers.length) {
|
|
218
|
+
console.warn('[PieChartsVisualizer] All pie charts skipped due to insufficient data.');
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
layerGroup = L.layerGroup(markers).addTo(activeMap);
|
|
223
|
+
|
|
224
|
+
if (fitBounds) {
|
|
225
|
+
const bounds = L.latLngBounds(markers.map(m => m.getLatLng()));
|
|
226
|
+
if (bounds.isValid()) {
|
|
227
|
+
activeMap.fitBounds(bounds, { padding: [16, 16] });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function attachZoomHandler(map) {
|
|
235
|
+
if (!map || !cachedContext) return;
|
|
236
|
+
zoomHandler = () => {
|
|
237
|
+
renderPieLayer({ fitBounds: false });
|
|
238
|
+
};
|
|
239
|
+
map.on('zoomend', zoomHandler);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export default async function main(map, geojson, options = {}) {
|
|
243
|
+
reset(map);
|
|
244
|
+
ensureStyles();
|
|
245
|
+
|
|
246
|
+
const featureCollection = normalizeFeatureCollection(geojson);
|
|
247
|
+
if (!featureCollection || !Array.isArray(featureCollection.features) || featureCollection.features.length === 0) {
|
|
248
|
+
console.warn('[PieChartsVisualizer] No data to render.');
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const palette = options.palette || {};
|
|
253
|
+
const visOptions = options.visOptions || {};
|
|
254
|
+
const stats = {
|
|
255
|
+
globalMaxCategoryCount: featureCollection.properties?.globalMaxCategoryCount ?? 0,
|
|
256
|
+
globalTotalCount: featureCollection.properties?.globalTotalCount ?? 0
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
activeMap = map;
|
|
260
|
+
cachedContext = { featureCollection, palette, visOptions, stats };
|
|
261
|
+
|
|
262
|
+
const rendered = renderPieLayer({ fitBounds: true });
|
|
263
|
+
if (!rendered) {
|
|
264
|
+
return {};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
attachZoomHandler(map);
|
|
268
|
+
|
|
269
|
+
return { pieCharts: layerGroup };
|
|
270
|
+
}
|
|
271
|
+
|
|
@@ -226,13 +226,69 @@ function warnHexNotRendered(hexId, reason, extra = {}) {
|
|
|
226
226
|
console.warn(`[VoronoiVisualizer] Skipped hex ${hexId}: ${reason}${details}`);
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
function computeLocalCategoryDensityScores(features = [], minMeters = 0) {
|
|
230
|
+
const scores = new Map();
|
|
231
|
+
if (!Number.isFinite(minMeters) || minMeters <= 0) {
|
|
232
|
+
for (const feature of features) {
|
|
233
|
+
scores.set(feature, 0);
|
|
234
|
+
}
|
|
235
|
+
return scores;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const points = features.map(feature => {
|
|
239
|
+
const coords = feature?.geometry?.coordinates;
|
|
240
|
+
return Array.isArray(coords) ? turfPoint(coords) : null;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < features.length; i++) {
|
|
244
|
+
const feature = features[i];
|
|
245
|
+
const category = feature?.properties?.category;
|
|
246
|
+
const point = points[i];
|
|
247
|
+
if (!category || !point) {
|
|
248
|
+
scores.set(feature, 0);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let density = 0;
|
|
253
|
+
for (let j = 0; j < features.length; j++) {
|
|
254
|
+
if (i === j) continue;
|
|
255
|
+
const other = features[j];
|
|
256
|
+
if (other?.properties?.category !== category) continue;
|
|
257
|
+
const otherPoint = points[j];
|
|
258
|
+
if (!otherPoint) continue;
|
|
259
|
+
const dist = turfDistance(point, otherPoint, { units: 'meters' });
|
|
260
|
+
if (!Number.isFinite(dist)) continue;
|
|
261
|
+
if (dist <= minMeters) {
|
|
262
|
+
density++;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
scores.set(feature, density);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return scores;
|
|
270
|
+
}
|
|
271
|
+
|
|
229
272
|
function enforceMinSpacing(features = [], minMeters = 0) {
|
|
230
273
|
if (!Number.isFinite(minMeters) || minMeters <= 0) {
|
|
231
274
|
return features;
|
|
232
275
|
}
|
|
233
276
|
|
|
277
|
+
const localDensityScores = computeLocalCategoryDensityScores(features, minMeters);
|
|
278
|
+
|
|
279
|
+
const prioritized = [...features].sort((a, b) => {
|
|
280
|
+
const scoreA = localDensityScores.get(a) ?? 0;
|
|
281
|
+
const scoreB = localDensityScores.get(b) ?? 0;
|
|
282
|
+
if (scoreA !== scoreB) {
|
|
283
|
+
return scoreB - scoreA;
|
|
284
|
+
}
|
|
285
|
+
const orderA = a?.properties?.__voronoiOrder ?? 0;
|
|
286
|
+
const orderB = b?.properties?.__voronoiOrder ?? 0;
|
|
287
|
+
return orderA - orderB;
|
|
288
|
+
});
|
|
289
|
+
|
|
234
290
|
const accepted = [];
|
|
235
|
-
for (const feature of
|
|
291
|
+
for (const feature of prioritized) {
|
|
236
292
|
const coords = feature?.geometry?.coordinates;
|
|
237
293
|
if (!Array.isArray(coords)) continue;
|
|
238
294
|
const candidatePoint = turfPoint(coords);
|
|
@@ -263,15 +319,12 @@ function aggregateGeotagsByHex(result = {}, maxSitesPerHex = 0, minSpacingMeters
|
|
|
263
319
|
if (!categories) continue;
|
|
264
320
|
|
|
265
321
|
const aggregatedFeatures = [];
|
|
266
|
-
const categoryBreakdown = {};
|
|
267
322
|
let featureOrder = 0;
|
|
268
323
|
|
|
269
324
|
for (const [categoryName, payload] of Object.entries(categories)) {
|
|
270
325
|
const features = payload?.items?.features ?? [];
|
|
271
326
|
if (!features.length) continue;
|
|
272
327
|
|
|
273
|
-
categoryBreakdown[categoryName] = (categoryBreakdown[categoryName] ?? 0) + features.length;
|
|
274
|
-
|
|
275
328
|
for (const feature of features) {
|
|
276
329
|
const cloned = cloneFeature(feature);
|
|
277
330
|
const coords = Array.isArray(cloned?.geometry?.coordinates) ? cloned.geometry.coordinates : [];
|
|
Binary file
|