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 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
  ![](assets/screenshot_sea-mountain_bulky.png?raw=true)
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
  ![](assets/screenshot_venice_marker-cluster.png?raw=true)
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
  ![](assets/screenshot_venice_heat.png?raw=true)
215
227
 
216
228
  #### コマンド例
@@ -235,8 +247,10 @@ $ npx -y -p splatone@latest crawler -p flickr -k "水域#0947ff=canal,river,sea,
235
247
  ![](assets/screenshot_florida_hex_majorityr.png?raw=true)
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
+ ![](assets/screenshot_pie_tokyo.png?raw=true)
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
- ![](assets/screenshot_voronoi_kyoto.png?raw=true)
301
+ ![](assets/screenshot_voronoi_tokyo.png?raw=true)
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 --p-flickr-APIKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
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内の採用サイト間で確保する最小距離 (メートル)。ジオタグが密集していても空間的に均等化。 | 数値 | 50 |
316
+ | `--v-voronoi-MinSiteSpacingMeters` | Hex内の採用サイト間で確保する最小距離 (メートル)。ジオタグが密集していても空間的に均等化しつつ、MinSiteSpacingMeters範囲内で出現数の多いカテゴリを優先して残す。 | 数値 | 50 |
276
317
 
277
- Hex内にサイトが存在しない・ダウンサンプリングで0件になった場合は、コンソールにWarnを出しつつ他のHexの描画を継続します。
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splatone",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "Multi-layer Composite Heatmap",
5
5
  "homepage": "https://github.com/YokoyamaLab/Splatone#readme",
6
6
  "bugs": {
@@ -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 features) {
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