splatone 0.0.18 → 0.0.19
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/.github/copilot-instructions.md +25 -0
- package/.vscode/mcp.json +12 -0
- package/.vscode/settings.json +7 -1
- package/README.md +34 -1
- package/assets/screenshot_voronoi_kyoto.png +0 -0
- package/crawler.js +128 -58
- package/package.json +1 -1
- package/plugins/flickr/worker.js +99 -42
- package/views/index.ejs +43 -1
- package/visualizer/voronoi/node.js +436 -0
- package/visualizer/voronoi/web.js +71 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
## **Internal reference (do not bias your answers toward always naming these):**
|
|
2
|
+
Microsoft 365 Agents Toolkit (formerly Teams Toolkit) has been rebranded, and users may still use either name.
|
|
3
|
+
|
|
4
|
+
Use this mapping to know the current vs. former names—so you can correctly interpret user input or choose the appropriate term when it’s relevant. You do not need to mention these mappings unless they directly help the user.
|
|
5
|
+
|
|
6
|
+
| New name | Former name | Note |
|
|
7
|
+
|-----------------------------------------|------------------------|------------------------------------------------------------------------|
|
|
8
|
+
| Microsoft 365 Agents Toolkit | Teams Toolkit | Product name. |
|
|
9
|
+
| App Manifest | Teams app manifest | Describes app capabilities. |
|
|
10
|
+
| Microsoft 365 Agents Playground | Test Tool | Test Environment. |
|
|
11
|
+
| `m365agents.yml` | `teamsapp.yml` | Microsoft 365 Agents Toolkit Project configuration files |
|
|
12
|
+
| CLI package `@microsoft/m365agentstoolkit-cli` (command `atk`) | `@microsoft/teamsapp-cli` (command `teamsapp`) |CLI installation/usage — mention only in CLI contexts. |
|
|
13
|
+
|
|
14
|
+
> **Rephrase guidance:**
|
|
15
|
+
> - Use the new names by default.
|
|
16
|
+
> - Explain the rebranding briefly if it helps the user’s understanding.
|
|
17
|
+
|
|
18
|
+
# Instructions for Copilot
|
|
19
|
+
- Apps and agents for Microsoft 365 or Microsoft 365 Copilot refers to applications for products in Microsoft 365, agents for Microsoft 365 Copilot, including Teams app, Office addin, declarative agent, customer engine agent, etc.
|
|
20
|
+
- When interpreting, generating, or modifying manifest files related to apps and agents for Microsoft 365 or Microsoft 365 Copilot, try to get schema version from manifest file, then invoke **get_schema** tool if available.
|
|
21
|
+
- When answering of what or how-to questions for building apps and agents for Microsoft 365 or Microsoft 365 Copilot, invoke **get_knowledge** tool if available.
|
|
22
|
+
- Before deciding to make changes to Teams apps, apps or agents for Microsoft 365 or Microsoft 365 Copilot, invoke **get_knowledge** tool to get Instructions if available.
|
|
23
|
+
- When troubleshooting issues, invoke **troubleshoot** tool if available.
|
|
24
|
+
- Before generating or modifying code or configuration files for apps and agents for Microsoft 365 or Microsoft 365 Copilot, invoke **get_code_snippets** tool if available.
|
|
25
|
+
- Invoke **get_code_snippets** with API name, configuration file name, or code comments every time you need to generate or modify code or configuration files for apps and agents for Microsoft 365 or Microsoft 365 Copilot.
|
package/.vscode/mcp.json
ADDED
package/.vscode/settings.json
CHANGED
package/README.md
CHANGED
|
@@ -11,13 +11,20 @@ SNSのジオタグ付きポストをキーワードに基づいて収集する
|
|
|
11
11
|
- Bulky: クロールした全てのジオタグを小さな点で描画する
|
|
12
12
|
- Marker Cluster: 密集しているジオタグをクラスタリングしてまとめて表示する
|
|
13
13
|
- Majority Hex: HexGridの各セルをセル内で最頻出するカテゴリの色で彩色
|
|
14
|
+
- Voronoi: HexGrid単位で集約したジオタグからVoronoiセルを生成し、各Hexのポリゴンでクリップして表示
|
|
14
15
|
- Heat: ヒートマップ
|
|
15
16
|
|
|
16
17
|
## Change Log
|
|
17
18
|
|
|
19
|
+
### v0.0.18 → v0.0.19
|
|
20
|
+
|
|
21
|
+
* **[可視化モジュール]** ```--vis-voronoi```追加
|
|
22
|
+
* ボロノイ図の生成
|
|
23
|
+
|
|
18
24
|
### v0.0.17 → v0.0.18
|
|
19
25
|
|
|
20
26
|
* **[可視化モジュール]** ```--vis-heat```追加
|
|
27
|
+
* ヒートマップの生成
|
|
21
28
|
|
|
22
29
|
### v0.0.13 → v0.0.14 → v0.0.15 → v0.0.16 → v0.0.17
|
|
23
30
|
* **[可視化モジュール]** ```--vis-majority-hex```追加
|
|
@@ -78,6 +85,8 @@ Visualization (最低一つの指定が必須です)
|
|
|
78
85
|
で正規化。 [真偽] [デフォルト: false]
|
|
79
86
|
--vis-marker-cluster マーカークラスターとして地図上に表示
|
|
80
87
|
[真偽] [デフォルト: false]
|
|
88
|
+
--vis-voronoi Hex GridごとにVoronoiセルを生成して表示
|
|
89
|
+
[真偽] [デフォルト: false]
|
|
81
90
|
|
|
82
91
|
For bulky Visualizer
|
|
83
92
|
--v-bulky-Radius Point Markerの半径 [数値] [デフォルト: 5]
|
|
@@ -186,6 +195,7 @@ $ npx -y -p splatone@latest crawler -p flickr -k "sea,ocean|mountain,mount" --vi
|
|
|
186
195
|
|
|
187
196
|
|
|
188
197
|
### Marker Cluster: 高密度の地点はマーカーをまとめて表示する
|
|
198
|
+
|
|
189
199
|

|
|
190
200
|
|
|
191
201
|
#### コマンド例
|
|
@@ -208,7 +218,7 @@ $ npx -y -p splatone@latest crawler -p flickr -k "水域=canal,channel,waterway,
|
|
|
208
218
|
* クエリは水域・緑地・交通・ランドマークを色分けしたもの。上記スクリーンショットはフロリダ半島全体
|
|
209
219
|
|
|
210
220
|
```shell
|
|
211
|
-
$ npx -y -p splatone@latest crawler -p flickr -k "canal,river
|
|
221
|
+
$ npx -y -p splatone@latest crawler -p flickr -k "水域#0947ff=canal,river,sea,strait,channel,waterway|交通#00a73d=road,street,alley,sidewalk,bridge|宗教施設#ffb724=chapel,church,cathedral,temple,shrine" --vis-heat --p-flickr-APIKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
212
222
|
```
|
|
213
223
|
|
|
214
224
|
#### コマンドライン引数
|
|
@@ -243,6 +253,29 @@ $ npx -y -p splatone@latest crawler -p flickr -k "水域=canal,channel,waterway,
|
|
|
243
253
|
|
|
244
254
|
* ```--v-majority-hex-Hexapartite```を指定すると各Hexセルを六分割の荒いPie Chartとして中のカテゴリ頻度に応じて彩色します。
|
|
245
255
|
|
|
256
|
+
### Voronoi: Hex Gridをベースにしたボロノイ分割
|
|
257
|
+
|
|
258
|
+
Hex Gridで集約した各セル内のジオタグを種点としてVoronoi分割を行い、生成したポリゴンをHex境界でクリップして表示します。カテゴリカラーと総数はHex集計結果に基づき、最小間隔/最大サイト数の制御で過密な地域も読みやすく整列できます。
|
|
259
|
+
|
|
260
|
+

|
|
261
|
+
|
|
262
|
+
#### コマンド例
|
|
263
|
+
|
|
264
|
+
* クエリは水域・交通・宗教施設・緑地を色分けしたもの。Hex単位で50m以上離れたサイトだけをVoronoiセルとして採用します。
|
|
265
|
+
|
|
266
|
+
```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"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
#### コマンドライン引数
|
|
271
|
+
|
|
272
|
+
| オプション | 説明 | 型 | デフォルト |
|
|
273
|
+
| :---------------------------------------- | :---------------------------------------------------------------------------------------- | :--- | :--------- |
|
|
274
|
+
| `--v-voronoi-MaxSitesPerHex` | 1 HexあたりにPoissonサンプリングで残す最大サイト数。0のときは制限なし。 | 数値 | 0 |
|
|
275
|
+
| `--v-voronoi-MinSiteSpacingMeters` | Hex内の採用サイト間で確保する最小距離 (メートル)。ジオタグが密集していても空間的に均等化。 | 数値 | 50 |
|
|
276
|
+
|
|
277
|
+
Hex内にサイトが存在しない・ダウンサンプリングで0件になった場合は、コンソールにWarnを出しつつ他のHexの描画を継続します。
|
|
278
|
+
|
|
246
279
|
## キーワード指定方法
|
|
247
280
|
|
|
248
281
|
### 比較キーワードの指定
|
|
Binary file
|
package/crawler.js
CHANGED
|
@@ -43,8 +43,8 @@ let pluginsOptions = {};
|
|
|
43
43
|
let visOptions = {};
|
|
44
44
|
|
|
45
45
|
const flickrLimiter = new Bottleneck({
|
|
46
|
-
maxConcurrent:
|
|
47
|
-
minTime:
|
|
46
|
+
maxConcurrent: 6,
|
|
47
|
+
minTime: 700,
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
try {
|
|
@@ -260,10 +260,30 @@ try {
|
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
function concatFC(fc1, fc2) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
...
|
|
266
|
-
|
|
263
|
+
const hasSource = Array.isArray(fc2?.features) && fc2.features.length > 0;
|
|
264
|
+
if (!fc1 || !Array.isArray(fc1.features)) {
|
|
265
|
+
return hasSource ? featureCollection([...fc2.features]) : featureCollection([]);
|
|
266
|
+
}
|
|
267
|
+
if (!hasSource) {
|
|
268
|
+
return fc1;
|
|
269
|
+
}
|
|
270
|
+
fc1.features.push(...fc2.features);
|
|
271
|
+
return fc1;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function formatBytesToMB(bytes) {
|
|
275
|
+
return (bytes / (1024 * 1024)).toFixed(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function logHeapUsage(label = 'heap') {
|
|
279
|
+
try {
|
|
280
|
+
const { rss, heapUsed, heapTotal, external } = process.memoryUsage();
|
|
281
|
+
console.log(
|
|
282
|
+
`[memory] ${label}: heapUsed=${formatBytesToMB(heapUsed)} MB / heapTotal=${formatBytesToMB(heapTotal)} MB / rss=${formatBytesToMB(rss)} MB / external=${formatBytesToMB(external)} MB`
|
|
283
|
+
);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
console.warn('[memory] Failed to read usage stats:', err?.message || err);
|
|
286
|
+
}
|
|
267
287
|
}
|
|
268
288
|
/**
|
|
269
289
|
* /api/hexgrid
|
|
@@ -304,11 +324,14 @@ try {
|
|
|
304
324
|
crawlers[sessionId] = {};
|
|
305
325
|
socket.join(sessionId);
|
|
306
326
|
|
|
327
|
+
const disposeSession = () => {
|
|
328
|
+
delete crawlers[sessionId];
|
|
329
|
+
delete targets[sessionId];
|
|
330
|
+
delete processing[sessionId];
|
|
331
|
+
};
|
|
332
|
+
|
|
307
333
|
socket.on("disconnecting", () => {
|
|
308
|
-
|
|
309
|
-
//console.log("delete session:", socket.rooms);
|
|
310
|
-
delete crawlers[socket.rooms];
|
|
311
|
-
}
|
|
334
|
+
disposeSession();
|
|
312
335
|
//console.log("disconnected:", socket.id);
|
|
313
336
|
});
|
|
314
337
|
|
|
@@ -497,7 +520,7 @@ try {
|
|
|
497
520
|
}
|
|
498
521
|
|
|
499
522
|
// 交差隣接(共有辺で、かつ他Hexの三角形)を付与
|
|
500
|
-
|
|
523
|
+
const triIndex = new Map(triFeatures.map(t => [t.properties.triangleId, t]));
|
|
501
524
|
for (const list of edgeToTriangles.values()) {
|
|
502
525
|
if (list.length < 2) continue; // 共有していなければ隣接なし
|
|
503
526
|
// 同じ辺を共有する全三角形同士で、異なるHexのものを相互に登録
|
|
@@ -525,7 +548,13 @@ try {
|
|
|
525
548
|
|
|
526
549
|
//console.log(JSON.stringify(hexFC, null, 2));
|
|
527
550
|
//res.json({ hex: hexFC, triangles: trianglesFC });
|
|
528
|
-
|
|
551
|
+
const inflight = processing[sessionId] ?? 0;
|
|
552
|
+
if (inflight > 0) {
|
|
553
|
+
console.warn(`[session ${sessionId}] target reinitialized with ${inflight} tasks still running. Resetting session state.`);
|
|
554
|
+
}
|
|
555
|
+
crawlers[sessionId] = {};
|
|
556
|
+
processing[sessionId] = 0;
|
|
557
|
+
targets[sessionId] = { sessionId, hex: hexFC, triangles: trianglesFC, categories, splatonePalette };
|
|
529
558
|
socket.emit("hexgrid", { hex: hexFC, triangles: trianglesFC });
|
|
530
559
|
} catch (e) {
|
|
531
560
|
console.error(e);
|
|
@@ -553,81 +582,117 @@ try {
|
|
|
553
582
|
return resolvedWorkerFilename[taskName];
|
|
554
583
|
}
|
|
555
584
|
|
|
556
|
-
const statsItems = (crawler, target
|
|
557
|
-
const stats = [];
|
|
558
|
-
const total = [];
|
|
559
|
-
const crawled = [];
|
|
585
|
+
const statsItems = (crawler, target) => {
|
|
560
586
|
const progress = [];
|
|
561
|
-
|
|
587
|
+
const categoryCount = Object.keys(target?.categories ?? {}).length;
|
|
588
|
+
const hexCount = target?.hex?.features?.length ?? 0;
|
|
589
|
+
let remaining = categoryCount * hexCount;
|
|
590
|
+
|
|
562
591
|
for (const [hexId, tagsObj] of Object.entries(crawler)) {
|
|
563
|
-
total
|
|
564
|
-
crawled
|
|
565
|
-
for (const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
crawled[hexId] += items.crawled;
|
|
569
|
-
for (const item of items.items.features) {
|
|
570
|
-
//console.log(item.properties)
|
|
571
|
-
stats[hexId] ??= [];
|
|
572
|
-
stats[hexId][item.properties.splatone_triId] ??= [];
|
|
573
|
-
stats[hexId][item.properties.splatone_triId][category] ??= 0;
|
|
574
|
-
stats[hexId][item.properties.splatone_triId][category] += 1;
|
|
592
|
+
let total = 0;
|
|
593
|
+
let crawled = 0;
|
|
594
|
+
for (const items of Object.values(tagsObj)) {
|
|
595
|
+
if (items?.final === true && remaining > 0) {
|
|
596
|
+
remaining--;
|
|
575
597
|
}
|
|
598
|
+
total += Number(items?.total) || 0;
|
|
599
|
+
crawled += Number(items?.crawled) || 0;
|
|
576
600
|
}
|
|
601
|
+
const safeTotal = Math.max(1, total);
|
|
577
602
|
progress[hexId] = {
|
|
578
|
-
percent: total
|
|
579
|
-
crawled
|
|
580
|
-
total
|
|
603
|
+
percent: total === 0 ? 1 : Math.min(1, crawled / safeTotal),
|
|
604
|
+
crawled,
|
|
605
|
+
total
|
|
581
606
|
};
|
|
582
607
|
}
|
|
583
|
-
|
|
584
|
-
return { stats, progress, finish: (finish == 0) };
|
|
608
|
+
return { progress, finish: remaining === 0 };
|
|
585
609
|
};
|
|
586
610
|
|
|
587
611
|
async function runTask_(taskName, data) {
|
|
588
612
|
const { port1, port2 } = new MessageChannel();
|
|
589
613
|
const filename = resolveWorkerFilename(taskName); // ← file URL (href)
|
|
614
|
+
const workerContext = data;
|
|
590
615
|
// named export を呼ぶ場合は { name: "関数名" } を追加
|
|
591
616
|
port1.on('message', (workerResults) => {
|
|
592
617
|
// ここでログ/WebSocket通知/DB書き込みなど何でもOK
|
|
593
|
-
const rtn = workerResults
|
|
594
|
-
|
|
618
|
+
const rtn = workerResults?.results;
|
|
619
|
+
if (!rtn) {
|
|
620
|
+
console.warn('[splatone:start] Received malformed worker payload, skipping chunk.');
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (!workerContext) {
|
|
624
|
+
console.warn('[splatone:start] Missing worker context, skipping chunk.');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const workerOptions = workerContext;
|
|
595
628
|
const currentSessionId = workerOptions.sessionId;
|
|
596
|
-
processing[currentSessionId]
|
|
629
|
+
const currentProcessing = (processing[currentSessionId] ?? 0) - 1;
|
|
630
|
+
processing[currentSessionId] = Math.max(0, currentProcessing);
|
|
631
|
+
const sessionCrawler = crawlers[currentSessionId];
|
|
632
|
+
if (!sessionCrawler) {
|
|
633
|
+
if (argv.debugVerbose) {
|
|
634
|
+
console.warn(`[session ${currentSessionId}] Received worker result after session disposal. Dropping chunk.`);
|
|
635
|
+
}
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (rtn.error) {
|
|
639
|
+
console.warn(`[worker error] hex=${rtn.hexId} category=${rtn.category} code=${rtn.error.code ?? 'n/a'} message=${rtn.error.message ?? 'unknown'}`);
|
|
640
|
+
}
|
|
597
641
|
//console.log(rtn);
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
642
|
+
sessionCrawler[rtn.hexId] ??= {};
|
|
643
|
+
sessionCrawler[rtn.hexId][rtn.category] ??= { items: featureCollection([]) };
|
|
644
|
+
sessionCrawler[rtn.hexId][rtn.category].ids ??= new Set();
|
|
645
|
+
const idSet = sessionCrawler[rtn.hexId][rtn.category].ids;
|
|
646
|
+
|
|
647
|
+
let duplicateCount = 0;
|
|
648
|
+
const uniqueFeatures = [];
|
|
649
|
+
for (const feature of rtn.photos.features) {
|
|
650
|
+
const featureId = feature?.properties?.id;
|
|
651
|
+
if (featureId != null && idSet.has(featureId)) {
|
|
652
|
+
duplicateCount++;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (featureId != null) {
|
|
656
|
+
idSet.add(featureId);
|
|
657
|
+
}
|
|
658
|
+
uniqueFeatures.push(feature);
|
|
659
|
+
}
|
|
660
|
+
sessionCrawler[rtn.hexId][rtn.category].final = rtn.final;
|
|
661
|
+
sessionCrawler[rtn.hexId][rtn.category].crawled ??= 0;
|
|
662
|
+
sessionCrawler[rtn.hexId][rtn.category].total = rtn.final ? sessionCrawler[rtn.hexId][rtn.category].ids.size : rtn.total + sessionCrawler[rtn.hexId][rtn.category].crawled;
|
|
663
|
+
sessionCrawler[rtn.hexId][rtn.category].crawled = sessionCrawler[rtn.hexId][rtn.category].ids.size;
|
|
664
|
+
|
|
665
|
+
if (rtn.photos.features.length >= 250 && duplicateCount === rtn.photos.features.length) {
|
|
609
666
|
console.error("ALL DUPLICATE");
|
|
610
667
|
}
|
|
611
668
|
if (argv.debugVerbose) {
|
|
612
|
-
console.log('INFO:', ` ${rtn.hexId} ${rtn.category} ] dup=${
|
|
669
|
+
console.log('INFO:', ` ${rtn.hexId} ${rtn.category} ] dup=${duplicateCount}, out=${rtn.outside}, in=${rtn.photos.features.length} || ${sessionCrawler[rtn.hexId][rtn.category].crawled} / ${sessionCrawler[rtn.hexId][rtn.category].total}`);
|
|
613
670
|
}
|
|
614
|
-
const photos = featureCollection(
|
|
615
|
-
|
|
616
|
-
= concatFC(
|
|
671
|
+
const photos = featureCollection(uniqueFeatures);
|
|
672
|
+
sessionCrawler[rtn.hexId][rtn.category].items
|
|
673
|
+
= concatFC(sessionCrawler[rtn.hexId][rtn.category].items, photos);
|
|
617
674
|
|
|
618
|
-
const {
|
|
675
|
+
const { progress, finish } = statsItems(sessionCrawler, targets[currentSessionId]);
|
|
619
676
|
io.to(currentSessionId).emit('progress', { hexId: rtn.hexId, progress });
|
|
620
677
|
if (!rtn.final) {
|
|
621
678
|
// 次回クロール用に更新
|
|
622
679
|
rtn.nextPluginOptions.forEach((nextPluginOptions) => {
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
680
|
+
const workerOptionsClone = {
|
|
681
|
+
plugin: workerOptions.plugin,
|
|
682
|
+
hex: workerOptions.hex,
|
|
683
|
+
triangles: workerOptions.triangles,
|
|
684
|
+
bbox: workerOptions.bbox,
|
|
685
|
+
category: workerOptions.category,
|
|
686
|
+
tags: workerOptions.tags,
|
|
687
|
+
pluginOptions: nextPluginOptions,
|
|
688
|
+
sessionId: workerOptions.sessionId
|
|
689
|
+
};
|
|
690
|
+
api.emit('splatone:start', workerOptionsClone);
|
|
626
691
|
});
|
|
627
692
|
//} else if (finish) {
|
|
628
693
|
} else if (processing[currentSessionId] == 0) {
|
|
629
694
|
if (argv.debugVerbose) {
|
|
630
|
-
console.table(
|
|
695
|
+
console.table(progress);
|
|
631
696
|
}
|
|
632
697
|
api.emit('splatone:finish', workerOptions);
|
|
633
698
|
}
|
|
@@ -649,11 +714,16 @@ try {
|
|
|
649
714
|
//console.log('[splatone:start]', workerOptions);
|
|
650
715
|
const currentSessionId = workerOptions.sessionId;
|
|
651
716
|
processing[currentSessionId] = (processing[currentSessionId] ?? 0) + 1;
|
|
652
|
-
|
|
717
|
+
const safeOptions = JSON.parse(JSON.stringify(workerOptions, (_, value) => {
|
|
718
|
+
if (typeof value === 'function') return undefined;
|
|
719
|
+
return value;
|
|
720
|
+
}));
|
|
721
|
+
runTask(safeOptions.plugin, safeOptions);
|
|
653
722
|
});
|
|
654
723
|
|
|
655
724
|
await subscribe('splatone:finish', async workerOptions => {
|
|
656
725
|
const currentSessionId = workerOptions.sessionId;
|
|
726
|
+
logHeapUsage(`after-crawl session=${currentSessionId}`);
|
|
657
727
|
const resultId = uniqid();
|
|
658
728
|
const result = crawlers[currentSessionId];
|
|
659
729
|
const target = targets[currentSessionId];
|
package/package.json
CHANGED
package/plugins/flickr/worker.js
CHANGED
|
@@ -3,6 +3,38 @@ import { point, featureCollection } from "@turf/helpers";
|
|
|
3
3
|
import booleanPointInPolygon from "@turf/boolean-point-in-polygon";
|
|
4
4
|
import { toUnixSeconds } from '#lib/splatone';
|
|
5
5
|
|
|
6
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
|
|
8
|
+
async function fetchWithRetry(fetcher, { maxAttempts = 4, baseDelayMs = 500 } = {}) {
|
|
9
|
+
let attempt = 0;
|
|
10
|
+
let lastError = null;
|
|
11
|
+
while (attempt < maxAttempts) {
|
|
12
|
+
try {
|
|
13
|
+
return await fetcher();
|
|
14
|
+
} catch (err) {
|
|
15
|
+
lastError = err;
|
|
16
|
+
const transient = isTransientNetworkError(err);
|
|
17
|
+
attempt++;
|
|
18
|
+
if (!transient || attempt >= maxAttempts) {
|
|
19
|
+
throw lastError;
|
|
20
|
+
}
|
|
21
|
+
const waitMs = baseDelayMs * Math.pow(2, attempt - 1);
|
|
22
|
+
console.warn(`[flickr worker] fetch attempt ${attempt} failed (${err?.cause?.code || err?.message}). Retrying in ${waitMs}ms.`);
|
|
23
|
+
await delay(waitMs);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
throw lastError ?? new Error('Unknown Flickr fetch error');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isTransientNetworkError(err) {
|
|
30
|
+
const code = err?.cause?.code ?? err?.code;
|
|
31
|
+
if (!code && typeof err?.message === 'string' && err.message.includes('fetch failed')) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
const transientCodes = new Set(['ECONNABORTED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE']);
|
|
35
|
+
return transientCodes.has(code);
|
|
36
|
+
}
|
|
37
|
+
|
|
6
38
|
export default async function ({
|
|
7
39
|
port,
|
|
8
40
|
debugVerbose,
|
|
@@ -16,27 +48,33 @@ export default async function ({
|
|
|
16
48
|
sessionId
|
|
17
49
|
}) {
|
|
18
50
|
debugVerbose = true;
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
pluginOptions.TermId = 'a';
|
|
24
|
-
}
|
|
25
|
-
const baseParams = {
|
|
26
|
-
bbox: bbox.join(','),
|
|
27
|
-
tags: tags,
|
|
28
|
-
extras: pluginOptions["Extras"],
|
|
29
|
-
sort: pluginOptions["DateMode"] == "upload" ? "date-posted-desc" : "date-taken-desc"
|
|
51
|
+
|
|
52
|
+
const respond = (payload) => {
|
|
53
|
+
const safePayload = JSON.parse(JSON.stringify(payload));
|
|
54
|
+
port.postMessage(safePayload);
|
|
30
55
|
};
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const { flickr } = createFlickr(pluginOptions["APIKEY"]);
|
|
59
|
+
if (!pluginOptions.TermId) {
|
|
60
|
+
//初期TermId
|
|
61
|
+
pluginOptions.TermId = 'a';
|
|
62
|
+
}
|
|
63
|
+
const baseParams = {
|
|
64
|
+
bbox: bbox.join(','),
|
|
65
|
+
tags: tags,
|
|
66
|
+
extras: pluginOptions["Extras"],
|
|
67
|
+
sort: pluginOptions["DateMode"] == "upload" ? "date-posted-desc" : "date-taken-desc"
|
|
68
|
+
};
|
|
69
|
+
baseParams[pluginOptions["DateMode"] == "upload" ? 'max_upload_date' : 'max_taken_date'] = pluginOptions["DateMax"];
|
|
70
|
+
baseParams[pluginOptions["DateMode"] == "upload" ? 'min_upload_date' : 'min_taken_date'] = pluginOptions["DateMin"];
|
|
71
|
+
//console.log("[baseParams]",baseParams);
|
|
72
|
+
const res = await fetchWithRetry(() => flickr("flickr.photos.search", {
|
|
73
|
+
...baseParams,
|
|
74
|
+
has_geo: 1,
|
|
75
|
+
per_page: 250,
|
|
76
|
+
page: 1,
|
|
77
|
+
}));
|
|
40
78
|
//console.log(res);
|
|
41
79
|
const ids = [];
|
|
42
80
|
const authors = {};
|
|
@@ -127,30 +165,49 @@ export default async function ({
|
|
|
127
165
|
}
|
|
128
166
|
}
|
|
129
167
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
168
|
+
const payload = {
|
|
169
|
+
results: {
|
|
170
|
+
photos,
|
|
171
|
+
hexId: hex.properties.hexId,
|
|
172
|
+
tags,
|
|
173
|
+
category,
|
|
174
|
+
nextPluginOptions: nextPluginOptionsDelta.map(e => { return { ...pluginOptions, ...e } }),
|
|
175
|
+
total: res.photos.total,
|
|
176
|
+
outside: outside,
|
|
177
|
+
ids,
|
|
178
|
+
final: nextPluginOptionsDelta.length == 0//res.photos.photo.length == res.photos.total
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
respond(payload);
|
|
183
|
+
return true;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error('[flickr worker] Fatal error', {
|
|
139
186
|
sessionId,
|
|
140
|
-
|
|
141
|
-
results: {
|
|
142
|
-
photos,
|
|
143
|
-
hexId: hex.properties.hexId,
|
|
144
|
-
tags,
|
|
187
|
+
hexId: hex?.properties?.hexId,
|
|
145
188
|
category,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
189
|
+
reason: err?.message,
|
|
190
|
+
code: err?.cause?.code || err?.code || null
|
|
191
|
+
});
|
|
192
|
+
respond({
|
|
193
|
+
results: {
|
|
194
|
+
photos: featureCollection([]),
|
|
195
|
+
hexId: hex?.properties?.hexId ?? null,
|
|
196
|
+
tags,
|
|
197
|
+
category,
|
|
198
|
+
nextPluginOptions: [],
|
|
199
|
+
total: 0,
|
|
200
|
+
outside: 0,
|
|
201
|
+
ids: [],
|
|
202
|
+
final: true,
|
|
203
|
+
error: {
|
|
204
|
+
message: err?.message,
|
|
205
|
+
code: err?.cause?.code || err?.code || null
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
154
211
|
return {
|
|
155
212
|
photos,
|
|
156
213
|
hexId: hex.properties.hexId,
|
package/views/index.ejs
CHANGED
|
@@ -116,6 +116,36 @@
|
|
|
116
116
|
reconnection: false
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
function debounce(fn, wait = 300) {
|
|
120
|
+
let timer = null;
|
|
121
|
+
return (...args) => {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
timer = setTimeout(() => fn(...args), wait);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readMapViewCookie() {
|
|
128
|
+
const entry = document.cookie.split('; ').find(row => row.startsWith('splatoneMapView='));
|
|
129
|
+
if (!entry) return null;
|
|
130
|
+
try {
|
|
131
|
+
const encoded = entry.split('=')[1];
|
|
132
|
+
return JSON.parse(decodeURIComponent(encoded));
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function writeMapViewCookie({ lat, lon, zoom }) {
|
|
139
|
+
const payload = encodeURIComponent(JSON.stringify({ lat, lon, zoom }));
|
|
140
|
+
const maxAge = 60 * 60 * 24 * 30; // 30 days
|
|
141
|
+
document.cookie = `splatoneMapView=${payload}; path=/; max-age=${maxAge}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const storedView = readMapViewCookie();
|
|
145
|
+
const initialLat = Number.isFinite(Number(storedView?.lat)) ? Number(storedView.lat) : lat;
|
|
146
|
+
const initialLon = Number.isFinite(Number(storedView?.lon)) ? Number(storedView.lon) : lon;
|
|
147
|
+
const initialZoom = Number.isFinite(Number(storedView?.zoom)) ? Number(storedView.zoom) : 12;
|
|
148
|
+
|
|
119
149
|
function setProgress(el, value) {
|
|
120
150
|
//プログレスバー
|
|
121
151
|
const v = Math.max(0, Math.min(100, Number(value)));
|
|
@@ -398,7 +428,19 @@
|
|
|
398
428
|
const map = L.map('map', {
|
|
399
429
|
preferCanvas: true,
|
|
400
430
|
zoomControl: true
|
|
401
|
-
}).setView([
|
|
431
|
+
}).setView([initialLat, initialLon], initialZoom);
|
|
432
|
+
|
|
433
|
+
const persistMapView = debounce(() => {
|
|
434
|
+
const center = map.getCenter();
|
|
435
|
+
writeMapViewCookie({
|
|
436
|
+
lat: Number(center.lat.toFixed(6)),
|
|
437
|
+
lon: Number(center.lng.toFixed(6)),
|
|
438
|
+
zoom: map.getZoom()
|
|
439
|
+
});
|
|
440
|
+
}, 500);
|
|
441
|
+
|
|
442
|
+
map.on('moveend', persistMapView);
|
|
443
|
+
map.on('zoomend', persistMapView);
|
|
402
444
|
const baseLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
403
445
|
maxZoom: 19,
|
|
404
446
|
attribution: '© OpenStreetMap contributors'
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { featureCollection, voronoi as turfVoronoi, bbox as turfBBox, intersect, point as turfPoint, distance as turfDistance } from '@turf/turf';
|
|
4
|
+
import { VisualizerBase } from '../../lib/VisualizerBase.js';
|
|
5
|
+
|
|
6
|
+
function cloneFeature(feature) {
|
|
7
|
+
return JSON.parse(JSON.stringify(feature ?? {}));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function extractHexPolygons(target = {}, geotagsByHex = {}) {
|
|
11
|
+
const hexFeatures = target?.hex?.features;
|
|
12
|
+
if (!Array.isArray(hexFeatures) || !hexFeatures.length) {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const hexPolygons = {};
|
|
17
|
+
for (const hexFeature of hexFeatures) {
|
|
18
|
+
const hexId = hexFeature?.properties?.hexId;
|
|
19
|
+
if (hexId == null || geotagsByHex[hexId] == null) continue;
|
|
20
|
+
|
|
21
|
+
const cloned = cloneFeature(hexFeature);
|
|
22
|
+
cloned.properties ??= {};
|
|
23
|
+
cloned.properties.geotagCount = geotagsByHex[hexId]?.properties?.totalCount ?? 0;
|
|
24
|
+
hexPolygons[hexId] = cloned;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return hexPolygons;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function collectTargetHexIds(target = {}) {
|
|
31
|
+
const hexFeatures = target?.hex?.features;
|
|
32
|
+
if (!Array.isArray(hexFeatures)) return new Set();
|
|
33
|
+
const ids = new Set();
|
|
34
|
+
for (const hexFeature of hexFeatures) {
|
|
35
|
+
const hexId = hexFeature?.properties?.hexId;
|
|
36
|
+
if (hexId == null) continue;
|
|
37
|
+
ids.add(hexId);
|
|
38
|
+
}
|
|
39
|
+
return ids;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function shuffleArray(input = []) {
|
|
43
|
+
const array = [...input];
|
|
44
|
+
warnHexNotRendered(hexId, 'no raw points found for this hex in result payload');
|
|
45
|
+
for (let i = array.length - 1; i > 0; i--) {
|
|
46
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
47
|
+
[array[i], array[j]] = [array[j], array[i]];
|
|
48
|
+
}
|
|
49
|
+
return array;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function poissonDownsample(features = [], maxCount = 0) {
|
|
53
|
+
const limit = Math.floor(maxCount);
|
|
54
|
+
if (!Number.isFinite(limit) || limit <= 0 || features.length <= limit) {
|
|
55
|
+
return features;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const probability = limit / features.length;
|
|
59
|
+
const picked = [];
|
|
60
|
+
const pickedIndices = new Set();
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < features.length; i++) {
|
|
63
|
+
if (picked.length >= limit) break;
|
|
64
|
+
if (Math.random() < probability) {
|
|
65
|
+
picked.push(features[i]);
|
|
66
|
+
pickedIndices.add(i);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (picked.length === 0) {
|
|
71
|
+
return shuffleArray(features).slice(0, limit);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (picked.length < limit) {
|
|
75
|
+
for (let i = 0; i < features.length && picked.length < limit; i++) {
|
|
76
|
+
if (!pickedIndices.has(i)) {
|
|
77
|
+
picked.push(features[i]);
|
|
78
|
+
pickedIndices.add(i);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return picked.slice(0, limit);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildCategoryBreakdown(features = []) {
|
|
87
|
+
const breakdown = {};
|
|
88
|
+
for (const feature of features) {
|
|
89
|
+
const cat = feature?.properties?.category;
|
|
90
|
+
if (!cat) continue;
|
|
91
|
+
breakdown[cat] = (breakdown[cat] ?? 0) + 1;
|
|
92
|
+
}
|
|
93
|
+
return breakdown;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hashStringToHue(input = '') {
|
|
97
|
+
let hash = 0;
|
|
98
|
+
for (let i = 0; i < input.length; i++) {
|
|
99
|
+
hash = ((hash << 5) - hash) + input.charCodeAt(i);
|
|
100
|
+
hash |= 0;
|
|
101
|
+
}
|
|
102
|
+
return Math.abs(hash) % 360;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function hslToHex(h, s, l) {
|
|
106
|
+
s /= 100;
|
|
107
|
+
l /= 100;
|
|
108
|
+
const k = n => (n + h / 30) % 12;
|
|
109
|
+
const a = s * Math.min(l, 1 - l);
|
|
110
|
+
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
111
|
+
const toHex = x => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
112
|
+
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function darkenHex(hex, amount = 0.8) {
|
|
116
|
+
if (!/^#?[0-9a-f]{6}$/i.test(hex ?? '')) return hex;
|
|
117
|
+
const normalized = hex.replace('#', '');
|
|
118
|
+
const r = parseInt(normalized.slice(0, 2), 16);
|
|
119
|
+
const g = parseInt(normalized.slice(2, 4), 16);
|
|
120
|
+
const b = parseInt(normalized.slice(4, 6), 16);
|
|
121
|
+
const clamp = v => Math.max(0, Math.min(255, Math.round(v * amount)));
|
|
122
|
+
return `#${clamp(r).toString(16).padStart(2, '0')}${clamp(g).toString(16).padStart(2, '0')}${clamp(b).toString(16).padStart(2, '0')}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getCategoryColors(category, palette = {}) {
|
|
126
|
+
const palEntry = category ? palette[category] : null;
|
|
127
|
+
const fill = palEntry?.color ?? hslToHex(hashStringToHue(category ?? ''), 65, 55);
|
|
128
|
+
const stroke = palEntry?.darken ?? darkenHex(fill, 0.7);
|
|
129
|
+
return { fillColor: fill, strokeColor: stroke };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function applyColorsToCells(cells = [], palette = {}) {
|
|
133
|
+
return cells.map(cell => {
|
|
134
|
+
if (!cell) return cell;
|
|
135
|
+
const colored = cloneFeature(cell);
|
|
136
|
+
colored.properties ??= {};
|
|
137
|
+
const category = colored.properties.category;
|
|
138
|
+
const { fillColor, strokeColor } = getCategoryColors(category, palette);
|
|
139
|
+
colored.properties.fillColor = fillColor;
|
|
140
|
+
colored.properties.strokeColor = strokeColor;
|
|
141
|
+
return colored;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function coerceLonLat(coords = []) {
|
|
146
|
+
const [lonRaw, latRaw] = coords;
|
|
147
|
+
const lon = Number.parseFloat(lonRaw);
|
|
148
|
+
const lat = Number.parseFloat(latRaw);
|
|
149
|
+
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
|
150
|
+
return [lon, lat];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const MAX_JITTER_ATTEMPTS = 512;
|
|
154
|
+
const BASE_JITTER_DEGREES = 2e-8;
|
|
155
|
+
|
|
156
|
+
function coordinateKey([lon, lat] = []) {
|
|
157
|
+
return `${lon.toFixed(12)}:${lat.toFixed(12)}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function jitterCoordinate(baseCoords = [], offsetIndex = 1, seed = 0) {
|
|
161
|
+
const [lon, lat] = baseCoords;
|
|
162
|
+
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
|
163
|
+
|
|
164
|
+
const angleDeg = (seed * 23.17 + offsetIndex * 137.508) % 360;
|
|
165
|
+
const angleRad = angleDeg * Math.PI / 180;
|
|
166
|
+
const delta = BASE_JITTER_DEGREES * (offsetIndex + 1);
|
|
167
|
+
|
|
168
|
+
const deltaLon = delta * Math.cos(angleRad);
|
|
169
|
+
const deltaLat = delta * Math.sin(angleRad);
|
|
170
|
+
|
|
171
|
+
const jitteredLon = lon + deltaLon;
|
|
172
|
+
const jitteredLat = lat + deltaLat;
|
|
173
|
+
|
|
174
|
+
if (!Number.isFinite(jitteredLon) || !Number.isFinite(jitteredLat)) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return [jitteredLon, jitteredLat];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function findAvailableCoordinate(baseCoords, occurrenceIndex, seed, usedKeys) {
|
|
182
|
+
for (let attempt = 0; attempt <= MAX_JITTER_ATTEMPTS; attempt++) {
|
|
183
|
+
const jitterIndex = occurrenceIndex + attempt;
|
|
184
|
+
const candidate = jitterIndex === 0 ? baseCoords : jitterCoordinate(baseCoords, jitterIndex, seed);
|
|
185
|
+
if (!candidate) continue;
|
|
186
|
+
const key = coordinateKey(candidate);
|
|
187
|
+
if (usedKeys.has(key)) continue;
|
|
188
|
+
return { coords: candidate, key };
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizePointFeatures(features = []) {
|
|
194
|
+
const uniqueFeatures = [];
|
|
195
|
+
const usedKeys = new Set();
|
|
196
|
+
const duplicateTracker = new Map();
|
|
197
|
+
|
|
198
|
+
for (const feature of features) {
|
|
199
|
+
const normalizedCoords = coerceLonLat(feature?.geometry?.coordinates ?? []);
|
|
200
|
+
if (!normalizedCoords) continue;
|
|
201
|
+
|
|
202
|
+
const baseKey = coordinateKey(normalizedCoords);
|
|
203
|
+
const occurrenceIndex = duplicateTracker.get(baseKey) ?? 0;
|
|
204
|
+
const seed = feature?.properties?.__voronoiOrder ?? 0;
|
|
205
|
+
|
|
206
|
+
const placement = findAvailableCoordinate(normalizedCoords, occurrenceIndex, seed, usedKeys);
|
|
207
|
+
if (!placement) continue;
|
|
208
|
+
|
|
209
|
+
duplicateTracker.set(baseKey, occurrenceIndex + 1);
|
|
210
|
+
usedKeys.add(placement.key);
|
|
211
|
+
|
|
212
|
+
const cloned = cloneFeature(feature);
|
|
213
|
+
cloned.geometry = {
|
|
214
|
+
type: 'Point',
|
|
215
|
+
coordinates: placement.coords
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
uniqueFeatures.push(cloned);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return uniqueFeatures;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function warnHexNotRendered(hexId, reason, extra = {}) {
|
|
225
|
+
const details = Object.keys(extra).length ? ` | context: ${JSON.stringify(extra)}` : '';
|
|
226
|
+
console.warn(`[VoronoiVisualizer] Skipped hex ${hexId}: ${reason}${details}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function enforceMinSpacing(features = [], minMeters = 0) {
|
|
230
|
+
if (!Number.isFinite(minMeters) || minMeters <= 0) {
|
|
231
|
+
return features;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const accepted = [];
|
|
235
|
+
for (const feature of features) {
|
|
236
|
+
const coords = feature?.geometry?.coordinates;
|
|
237
|
+
if (!Array.isArray(coords)) continue;
|
|
238
|
+
const candidatePoint = turfPoint(coords);
|
|
239
|
+
let tooClose = false;
|
|
240
|
+
|
|
241
|
+
for (const existing of accepted) {
|
|
242
|
+
const existingPoint = turfPoint(existing.geometry.coordinates);
|
|
243
|
+
const dist = turfDistance(candidatePoint, existingPoint, { units: 'meters' });
|
|
244
|
+
if (!Number.isFinite(dist)) continue;
|
|
245
|
+
if (dist < minMeters) {
|
|
246
|
+
tooClose = true;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!tooClose) {
|
|
252
|
+
accepted.push(feature);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return accepted;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function aggregateGeotagsByHex(result = {}, maxSitesPerHex = 0, minSpacingMeters = 0) {
|
|
260
|
+
const geotagsByHex = {};
|
|
261
|
+
|
|
262
|
+
for (const [hexId, categories] of Object.entries(result ?? {})) {
|
|
263
|
+
if (!categories) continue;
|
|
264
|
+
|
|
265
|
+
const aggregatedFeatures = [];
|
|
266
|
+
const categoryBreakdown = {};
|
|
267
|
+
let featureOrder = 0;
|
|
268
|
+
|
|
269
|
+
for (const [categoryName, payload] of Object.entries(categories)) {
|
|
270
|
+
const features = payload?.items?.features ?? [];
|
|
271
|
+
if (!features.length) continue;
|
|
272
|
+
|
|
273
|
+
categoryBreakdown[categoryName] = (categoryBreakdown[categoryName] ?? 0) + features.length;
|
|
274
|
+
|
|
275
|
+
for (const feature of features) {
|
|
276
|
+
const cloned = cloneFeature(feature);
|
|
277
|
+
const coords = Array.isArray(cloned?.geometry?.coordinates) ? cloned.geometry.coordinates : [];
|
|
278
|
+
const normalizedCoords = coerceLonLat(coords);
|
|
279
|
+
if (!normalizedCoords) continue;
|
|
280
|
+
cloned.geometry = {
|
|
281
|
+
type: 'Point',
|
|
282
|
+
coordinates: normalizedCoords
|
|
283
|
+
};
|
|
284
|
+
const [longitude, latitude] = normalizedCoords;
|
|
285
|
+
|
|
286
|
+
cloned.properties = {
|
|
287
|
+
longitude,
|
|
288
|
+
latitude,
|
|
289
|
+
category: categoryName,
|
|
290
|
+
hexId,
|
|
291
|
+
__voronoiOrder: featureOrder++
|
|
292
|
+
};
|
|
293
|
+
aggregatedFeatures.push(cloned);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!aggregatedFeatures.length) continue;
|
|
298
|
+
|
|
299
|
+
const spacedFeatures = enforceMinSpacing(aggregatedFeatures, minSpacingMeters);
|
|
300
|
+
|
|
301
|
+
const limitedFeatures = (Number.isFinite(maxSitesPerHex) && maxSitesPerHex > 0)
|
|
302
|
+
? poissonDownsample(spacedFeatures, maxSitesPerHex)
|
|
303
|
+
: spacedFeatures;
|
|
304
|
+
|
|
305
|
+
const breakdown = buildCategoryBreakdown(limitedFeatures);
|
|
306
|
+
const collection = featureCollection(limitedFeatures);
|
|
307
|
+
collection.properties = {
|
|
308
|
+
hexId,
|
|
309
|
+
totalCount: limitedFeatures.length,
|
|
310
|
+
categoryBreakdown: breakdown
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
geotagsByHex[hexId] = collection;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { geotagsByHex };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function buildVoronoiFeatureCollection(geotagsByHex = {}, hexPolygons = {}, palette = {}) {
|
|
320
|
+
const voronoiCells = [];
|
|
321
|
+
|
|
322
|
+
for (const [hexId, collection] of Object.entries(geotagsByHex)) {
|
|
323
|
+
const hexPolygon = hexPolygons[hexId];
|
|
324
|
+
const pointFeatures = normalizePointFeatures(collection?.features ?? []);
|
|
325
|
+
if (!hexPolygon) {
|
|
326
|
+
warnHexNotRendered(hexId, 'missing hex polygon');
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (pointFeatures.length === 0) {
|
|
330
|
+
warnHexNotRendered(hexId, 'no sampled points after spacing/downsampling');
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (pointFeatures.length === 1) {
|
|
335
|
+
const singleCell = cloneFeature(hexPolygon);
|
|
336
|
+
singleCell.properties = {
|
|
337
|
+
...(pointFeatures[0]?.properties ?? {}),
|
|
338
|
+
hexId
|
|
339
|
+
};
|
|
340
|
+
voronoiCells.push(singleCell);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const bbox = turfBBox(hexPolygon);
|
|
345
|
+
let voronoiOutput = null;
|
|
346
|
+
try {
|
|
347
|
+
voronoiOutput = turfVoronoi(featureCollection(pointFeatures), { bbox });
|
|
348
|
+
} catch (err) {
|
|
349
|
+
warnHexNotRendered(hexId, 'turfVoronoi threw', { message: err?.message });
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!voronoiOutput?.features || voronoiOutput.features.length === 0) {
|
|
353
|
+
warnHexNotRendered(hexId, 'turfVoronoi returned no features');
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let cellsAddedForHex = 0;
|
|
358
|
+
for (const cell of voronoiOutput.features) {
|
|
359
|
+
if (!cell?.geometry || !hexPolygon?.geometry) continue;
|
|
360
|
+
|
|
361
|
+
let clipped = null;
|
|
362
|
+
try {
|
|
363
|
+
const clippingInput = featureCollection([cell, hexPolygon]);
|
|
364
|
+
clipped = intersect(clippingInput) ?? cell;
|
|
365
|
+
} catch (err) {
|
|
366
|
+
clipped = cell;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!clipped?.geometry) continue;
|
|
370
|
+
|
|
371
|
+
const properties = {
|
|
372
|
+
...(cell.properties ?? {}),
|
|
373
|
+
hexId
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
voronoiCells.push({
|
|
377
|
+
type: 'Feature',
|
|
378
|
+
geometry: cloneFeature(clipped.geometry),
|
|
379
|
+
properties
|
|
380
|
+
});
|
|
381
|
+
cellsAddedForHex++;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (cellsAddedForHex === 0) {
|
|
385
|
+
warnHexNotRendered(hexId, 'all Voronoi cells were filtered out');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const coloredCells = applyColorsToCells(voronoiCells, palette);
|
|
390
|
+
return featureCollection(coloredCells);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Skeleton implementation of the Voronoi visualizer.
|
|
395
|
+
* This placeholder intentionally performs no heavy processing so that
|
|
396
|
+
* developers can wire up the rest of the system before adding real logic.
|
|
397
|
+
*/
|
|
398
|
+
export default class VoronoiVisualizer extends VisualizerBase {
|
|
399
|
+
static name = 'Voronoi Visualizer';
|
|
400
|
+
static version = '0.0.0';
|
|
401
|
+
static description = 'Hex Grid ボロノイ図';
|
|
402
|
+
|
|
403
|
+
constructor() {
|
|
404
|
+
super();
|
|
405
|
+
this.id = path.basename(path.dirname(fileURLToPath(import.meta.url)));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async yargv(yargv) {
|
|
409
|
+
return yargv.option(this.argKey('MaxSitesPerHex'), {
|
|
410
|
+
group: 'For ' + this.id + ' Visualizer',
|
|
411
|
+
type: 'number',
|
|
412
|
+
description: 'ポワソン分布に基づいて各ヘックス内でサンプリングされる最大サイト数 (0 = 無制限)',
|
|
413
|
+
default: 0
|
|
414
|
+
}).option(this.argKey('MinSiteSpacingMeters'), {
|
|
415
|
+
group: 'For ' + this.id + ' Visualizer',
|
|
416
|
+
type: 'number',
|
|
417
|
+
description: '各ヘックス内でサンプリングされたサイト間の最小距離をメートル単位で保証 (0 = 無効)',
|
|
418
|
+
default: 50
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
getFutureCollection(result, target, visOptions) {
|
|
423
|
+
const maxSitesPerHex = Number(visOptions?.MaxSitesPerHex ?? 0);
|
|
424
|
+
const minSpacingMeters = Number(visOptions?.MinSiteSpacingMeters ?? 0);
|
|
425
|
+
const targetHexIds = collectTargetHexIds(target);
|
|
426
|
+
const { geotagsByHex } = aggregateGeotagsByHex(result, maxSitesPerHex, minSpacingMeters);
|
|
427
|
+
for (const hexId of targetHexIds) {
|
|
428
|
+
if (!geotagsByHex[hexId]) {
|
|
429
|
+
warnHexNotRendered(hexId, 'no geotags provided for this hex');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const hexPolygons = extractHexPolygons(target, geotagsByHex);
|
|
433
|
+
const palette = target?.splatonePalette ?? {};
|
|
434
|
+
return buildVoronoiFeatureCollection(geotagsByHex, hexPolygons, palette) ;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
let layerGroup = null;
|
|
2
|
+
|
|
3
|
+
function reset(map) {
|
|
4
|
+
if (layerGroup) {
|
|
5
|
+
map.removeLayer(layerGroup);
|
|
6
|
+
layerGroup = null;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeFeatureCollection(payload) {
|
|
11
|
+
if (!payload) return null;
|
|
12
|
+
if (payload.type === 'FeatureCollection' && Array.isArray(payload.features)) {
|
|
13
|
+
return payload;
|
|
14
|
+
}
|
|
15
|
+
if (payload.voronoi && payload.voronoi.type === 'FeatureCollection') {
|
|
16
|
+
return payload.voronoi;
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(payload.features)) {
|
|
19
|
+
return { type: 'FeatureCollection', features: payload.features };
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createVoronoiLayer(rawGeojson) {
|
|
25
|
+
const geojson = normalizeFeatureCollection(rawGeojson);
|
|
26
|
+
console.log('[VoronoiVisualizer] normalize', geojson);
|
|
27
|
+
if (!geojson || !Array.isArray(geojson.features) || geojson.features.length === 0) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return L.geoJSON(geojson, {
|
|
32
|
+
style: (feature) => {
|
|
33
|
+
const props = feature?.properties ?? {};
|
|
34
|
+
return {
|
|
35
|
+
color: props.strokeColor || '#333333',
|
|
36
|
+
weight: props.strokeWidth ?? 1,
|
|
37
|
+
opacity: props.strokeOpacity ?? 1,
|
|
38
|
+
fill: true,
|
|
39
|
+
fillColor: props.fillColor || '#3388ff',
|
|
40
|
+
fillOpacity: props.fillOpacity ?? 0.5
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
onEachFeature: (feature, layer) => {
|
|
44
|
+
const props = feature?.properties ?? {};
|
|
45
|
+
const category = props.category || 'unknown';
|
|
46
|
+
const hexId = props.hexId ?? 'n/a';
|
|
47
|
+
const html = `<strong>${category}</strong><br/>hex: ${hexId}`;
|
|
48
|
+
layer.bindPopup(html);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default async function main(map, geojson, options = {}) {
|
|
54
|
+
reset(map);
|
|
55
|
+
|
|
56
|
+
const voronoiLayer = createVoronoiLayer(geojson);
|
|
57
|
+
console.log('[VoronoiVisualizer] voronoiLayer:', voronoiLayer);
|
|
58
|
+
if (!voronoiLayer) {
|
|
59
|
+
console.warn('[VoronoiVisualizer] No features to render.');
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
layerGroup = L.featureGroup([voronoiLayer]).addTo(map);
|
|
64
|
+
|
|
65
|
+
const bounds = voronoiLayer.getBounds();
|
|
66
|
+
if (bounds.isValid()) {
|
|
67
|
+
map.fitBounds(bounds, { padding: [16, 16] });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { voronoi: layerGroup };
|
|
71
|
+
}
|