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.
@@ -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,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
  ![](assets/screenshot_venice_marker-cluster.png?raw=true)
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|street,alley|bridge" --vis-heat --p-flickr-APIKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
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
+ ![](assets/screenshot_voronoi_kyoto.png?raw=true)
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
  ### 比較キーワードの指定
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.19",
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,
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([lat, lon], 12);
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: '&copy; 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
+ }