splatone 0.0.18 → 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.
@@ -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.
@@ -0,0 +1,12 @@
1
+ {
2
+ "servers": {
3
+ "m365agentstoolkit": {
4
+ "command": "npx",
5
+ "args": [
6
+ "@microsoft/m365agentstoolkit-mcp@latest",
7
+ "server",
8
+ "start"
9
+ ]
10
+ }
11
+ }
12
+ }
@@ -1 +1,7 @@
1
- {}
1
+ {
2
+ "cSpell.words": [
3
+ "Geotag",
4
+ "geotags",
5
+ "Voronoi"
6
+ ]
7
+ }
package/README.md CHANGED
@@ -11,13 +11,24 @@ SNSのジオタグ付きポストをキーワードに基づいて収集する
11
11
  - Bulky: クロールした全てのジオタグを小さな点で描画する
12
12
  - Marker Cluster: 密集しているジオタグをクラスタリングしてまとめて表示する
13
13
  - Majority Hex: HexGridの各セルをセル内で最頻出するカテゴリの色で彩色
14
+ - Pie Charts: Hexセル中心にカテゴリ割合のPie Chartを描画し、カテゴリごとに半径を可変化
15
+ - Voronoi: HexGrid単位で集約したジオタグからVoronoiセルを生成し、各Hexのポリゴンでクリップして表示
14
16
  - Heat: ヒートマップ
17
+ - Pie Charts: 円グラフグリッド
15
18
 
16
19
  ## Change Log
17
20
 
21
+ ### v0.0.18 → v0.0.19 → v0.0.20
22
+
23
+ * **[可視化モジュール]** ```--vis-voronoi```追加
24
+ * ボロノイ図の生成
25
+ * **[可視化モジュール]** ```--vis-pie-charts```追加
26
+ * Hex中心のカテゴリ割合Pie Chart描画
27
+
18
28
  ### v0.0.17 → v0.0.18
19
29
 
20
30
  * **[可視化モジュール]** ```--vis-heat```追加
31
+ * ヒートマップの生成
21
32
 
22
33
  ### v0.0.13 → v0.0.14 → v0.0.15 → v0.0.16 → v0.0.17
23
34
  * **[可視化モジュール]** ```--vis-majority-hex```追加
@@ -76,8 +87,12 @@ Visualization (最低一つの指定が必須です)
76
87
  --vis-majority-hex HexGrid内で最も出現頻度が高いカテゴリの色で彩色。Hex
77
88
  apartiteモードで6分割パイチャート表示。透明度は全体
78
89
  で正規化。 [真偽] [デフォルト: false]
90
+ --vis-pie-charts Hex中心にカテゴリ割合Pie Chartを表示。半径はグローバル
91
+ 出現比に応じてカテゴリ毎に可変化。 [真偽] [デフォルト: false]
79
92
  --vis-marker-cluster マーカークラスターとして地図上に表示
80
93
  [真偽] [デフォルト: false]
94
+ --vis-voronoi Hex GridごとにVoronoiセルを生成して表示
95
+ [真偽] [デフォルト: false]
81
96
 
82
97
  For bulky Visualizer
83
98
  --v-bulky-Radius Point Markerの半径 [数値] [デフォルト: 5]
@@ -165,6 +180,8 @@ APIキーは以下の3種類の方法で与える事ができます
165
180
 
166
181
  ### Bulky: 全ての点を地図上にポイントする
167
182
 
183
+ 全ての点を地図上に表示する。
184
+
168
185
  ![](assets/screenshot_sea-mountain_bulky.png?raw=true)
169
186
 
170
187
  #### コマンド例
@@ -186,6 +203,9 @@ $ npx -y -p splatone@latest crawler -p flickr -k "sea,ocean|mountain,mount" --vi
186
203
 
187
204
 
188
205
  ### Marker Cluster: 高密度の地点はマーカーをまとめて表示する
206
+
207
+ 全マーカーを表示すると、地図上がマーカーで埋め尽くされる問題に対して、高密度地点のマーカー群を一つにまとめてマーカーとする手法。ズームレベルに応じて自動的にマーカーが集約される。
208
+
189
209
  ![](assets/screenshot_venice_marker-cluster.png?raw=true)
190
210
 
191
211
  #### コマンド例
@@ -201,6 +221,8 @@ $ npx -y -p splatone@latest crawler -p flickr -k "水域=canal,channel,waterway,
201
221
 
202
222
  ### Heat: ヒートマップ
203
223
 
224
+ 出現頻度に基づいて点の影響範囲をガウス分布で定め連続的に彩色するヒートマップ。
225
+
204
226
  ![](assets/screenshot_venice_heat.png?raw=true)
205
227
 
206
228
  #### コマンド例
@@ -208,7 +230,7 @@ $ npx -y -p splatone@latest crawler -p flickr -k "水域=canal,channel,waterway,
208
230
  * クエリは水域・緑地・交通・ランドマークを色分けしたもの。上記スクリーンショットはフロリダ半島全体
209
231
 
210
232
  ```shell
211
- $ npx -y -p splatone@latest crawler -p flickr -k "canal,river|street,alley|bridge" --vis-heat --p-flickr-APIKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
233
+ $ 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
234
  ```
213
235
 
214
236
  #### コマンドライン引数
@@ -225,8 +247,10 @@ $ npx -y -p splatone@latest crawler -p flickr -k "canal,river|street,alley|bridg
225
247
  ![](assets/screenshot_florida_hex_majorityr.png?raw=true)
226
248
 
227
249
  #### コマンド例
250
+
228
251
  * クエリは水域・緑地・交通・ランドマークを色分けしたもの。上記スクリーンショットはフロリダ半島全体
229
- *
252
+
253
+
230
254
  ```shell
231
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"
232
256
  ```
@@ -243,6 +267,56 @@ $ npx -y -p splatone@latest crawler -p flickr -k "水域=canal,channel,waterway,
243
267
 
244
268
  * ```--v-majority-hex-Hexapartite```を指定すると各Hexセルを六分割の荒いPie Chartとして中のカテゴリ頻度に応じて彩色します。
245
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
+
297
+ ### Voronoi: Hex Gridをベースにしたボロノイ分割
298
+
299
+ Hex Gridで集約した各セル内のジオタグを種点としてVoronoi分割を行い、生成したポリゴンをHex境界でクリップして表示します。カテゴリカラーと総数はHex集計結果に基づき、最小間隔/最大サイト数の制御で過密な地域も読みやすく整列できます。
300
+
301
+ ![](assets/screenshot_voronoi_tokyo.png?raw=true)
302
+
303
+ #### コマンド例
304
+
305
+ * クエリは水域・交通・宗教施設・緑地を色分けしたもの。Hex単位で50m以上離れたサイトだけをVoronoiセルとして採用します。上記の例は東京を範囲としたもの。皇居の緑地や墨田川の水域がよく現れている。
306
+
307
+ ```shell
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"
309
+ ```
310
+
311
+ #### コマンドライン引数
312
+
313
+ | オプション | 説明 | 型 | デフォルト |
314
+ | :---------------------------------------- | :---------------------------------------------------------------------------------------- | :--- | :--------- |
315
+ | `--v-voronoi-MaxSitesPerHex` | 1 HexあたりにPoissonサンプリングで残す最大サイト数。0のときは制限なし。 | 数値 | 0 |
316
+ | `--v-voronoi-MinSiteSpacingMeters` | Hex内の採用サイト間で確保する最小距離 (メートル)。ジオタグが密集していても空間的に均等化しつつ、MinSiteSpacingMeters範囲内で出現数の多いカテゴリを優先して残す。 | 数値 | 50 |
317
+
318
+ MinSiteSpacingMetersによる間引きは、各サイト周辺 (MinSiteSpacingMeters以内) の同カテゴリ出現数を優先度として利用するため、同距離内で競合した場合も局所的に密度の高いカテゴリのサイトが採用されやすくなります。一方で密度は低いが他の場所に比べて顕著に出現するカテゴリを見逃す可能性があります。なお、Voronoi図の作成は消費メモリが大きい為、デフォルトでは50m間隔に間引きます。厳密解が必要な場合は```--v-voronoi-MinSiteSpacingMeters=0```を指定してください。ただし、その場合はヒープを使い果たしてクラッシュする可能性があります。マシンパワーに余裕がある場合は```npx --node-options='--max-old-space-size=10240'```のようにヒープサイズを拡大して実行する事も可能です。もう一つのオプション```--v-voronoi-MaxSitesPerHex```はHex内の最大アイテム数を制限するものです。ポワソンサンプリングに基づいてアイテムを間引きます。MinSiteSpacingMetersと共に、適切な結果が得られるよう調整してください。
319
+
246
320
  ## キーワード指定方法
247
321
 
248
322
  ### 比較キーワードの指定
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: 5,
47
- minTime: 350, // 約3req/sec
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
- return featureCollection([
264
- ...(fc1?.features ?? []),
265
- ...(fc2?.features ?? []),
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
- if (socket.rooms && crawlers.hasOwnProperty(socket.rooms)) {
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
- const triIndex = new Map(triFeatures.map(t => [t.properties.triangleId, t]));
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
- targets[sessionId] = { hex: hexFC, triangles: trianglesFC, categories, splatonePalette, };
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
- let finish = Object.keys(target.categories).length * target.hex.features.length;
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[hexId] = 0;
564
- crawled[hexId] = 0;
565
- for (const [category, items] of Object.entries(tagsObj)) {
566
- finish -= (items.final === true) ? 1 : 0;
567
- total[hexId] += items.total;
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[hexId] == 0 ? 1 : crawled[hexId] / total[hexId],
579
- crawled: crawled[hexId],
580
- total: total[hexId]
603
+ percent: total === 0 ? 1 : Math.min(1, crawled / safeTotal),
604
+ crawled,
605
+ total
581
606
  };
582
607
  }
583
- //console.table(progress);
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.results;
594
- const workerOptions = workerResults.workerOptions;
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
- crawlers[currentSessionId][rtn.hexId] ??= {};
599
- crawlers[currentSessionId][rtn.hexId][rtn.category] ??= { items: featureCollection([]) };
600
- crawlers[currentSessionId][rtn.hexId][rtn.category].ids ??= new Set();
601
- const duplicates = ((A, B) => new Set([...A].filter(x => B.has(x))))(rtn.ids, crawlers[currentSessionId][rtn.hexId][rtn.category].ids);
602
- crawlers[currentSessionId][rtn.hexId][rtn.category].ids = new Set([...crawlers[currentSessionId][rtn.hexId][rtn.category].ids, ...rtn.ids]);
603
- crawlers[currentSessionId][rtn.hexId][rtn.category].final = rtn.final;
604
- crawlers[currentSessionId][rtn.hexId][rtn.category].crawled ??= 0;
605
- crawlers[currentSessionId][rtn.hexId][rtn.category].total = rtn.final ? crawlers[currentSessionId][rtn.hexId][rtn.category].ids.size : rtn.total + crawlers[currentSessionId][rtn.hexId][rtn.category].crawled;
606
- crawlers[currentSessionId][rtn.hexId][rtn.category].crawled = crawlers[currentSessionId][rtn.hexId][rtn.category].ids.size;
607
-
608
- if (rtn.photos.features.length >= 250 && rtn.photos.features.length == duplicates.size) {
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=${duplicates.size}, out=${rtn.outside}, in=${rtn.photos.features.length} || ${crawlers[currentSessionId][rtn.hexId][rtn.category].crawled} / ${crawlers[currentSessionId][rtn.hexId][rtn.category].total}`);
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(rtn.photos.features.filter((f) => !duplicates.has(f.properties.id)));
615
- crawlers[currentSessionId][rtn.hexId][rtn.category].items
616
- = concatFC(crawlers[currentSessionId][rtn.hexId][rtn.category].items, photos);
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 { stats, progress, finish } = statsItems(crawlers[currentSessionId], targets[currentSessionId]);
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 workerOptions_clone = structuredClone(workerOptions);
624
- workerOptions_clone.pluginOptions = nextPluginOptions;
625
- api.emit('splatone:start', workerOptions_clone);
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(stats);
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
- runTask(workerOptions.plugin, workerOptions);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splatone",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "Multi-layer Composite Heatmap",
5
5
  "homepage": "https://github.com/YokoyamaLab/Splatone#readme",
6
6
  "bugs": {
@@ -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
- //console.log("{PLUGIN}", pluginOptions);
20
- const { flickr } = createFlickr(pluginOptions["APIKEY"]);
21
- if (!pluginOptions.TermId) {
22
- //初期TermId
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
- baseParams[pluginOptions["DateMode"] == "upload" ? 'max_upload_date' : 'max_taken_date'] = pluginOptions["DateMax"];
32
- baseParams[pluginOptions["DateMode"] == "upload" ? 'min_upload_date' : 'min_taken_date'] = pluginOptions["DateMin"];
33
- //console.log("[baseParams]",baseParams);
34
- const res = await flickr("flickr.photos.search", {
35
- ...baseParams,
36
- has_geo: 1,
37
- per_page: 250,
38
- page: 1,
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
- port.postMessage({
131
- workerOptions: {
132
- plugin,
133
- hex,
134
- triangles,
135
- bbox,
136
- category,
137
- tags,
138
- pluginOptions,
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
- nextPluginOptions: nextPluginOptionsDelta.map(e => { return { ...pluginOptions, ...e } }),
147
- total: res.photos.total,
148
- outside: outside,
149
- ids,
150
- final: nextPluginOptionsDelta.length == 0//res.photos.photo.length == res.photos.total
151
- }
152
- });
153
- return true;
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,