splatone 0.0.1

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/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
@@ -0,0 +1 @@
1
+ {}
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Shohei Yokoyama
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # Splatone - Multi-layer Composite Heatmap
2
+
3
+ # 概要
4
+
5
+ SNSのジオタグ付きポストを収集するツールです。現在は以下のSNSに対応しています。
6
+
7
+ - Flickr
8
+
9
+ 集めたデータは保存できる他、地図上で可視化する事が出来ます。以下の可視化に対応しています。
10
+
11
+ - Marker
12
+
13
+ # 使い方
14
+
15
+ - ローカルにCloneしてから以下のコマンドで依存ライブラリをインストール。
16
+
17
+ ```
18
+ npm install
19
+ ```
20
+
21
+ - 以下のサンプルコマンドを参考に実行してください。(FlickrのAPIキーは自身のに置き換える事)
22
+
23
+ ```
24
+ node crawler.js -p flickr -o '{"flickr":{"API_KEY":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}' -k "商業=shop,souvenir,market,supermarket,pharmacy,store,department|食べ物=food,drink,restaurant,cafe,bar|美術館=museum,art,exhibition,expo,sculpture,heritage|公園=park,garden,flower,green,pond,playground" --vB
25
+ ```
26
+
27
+ - ブラウザが立ち上がるので地図上でポリゴンあるいは矩形で領域選択し、実行ボタンを押すとクロールが開始されます。
28
+ - 指定した範囲を内包するHexGrid(六角形グリッド)が生成され、その内側のみが収集されます。
package/crawler.js ADDED
@@ -0,0 +1,570 @@
1
+ // -------------------------------
2
+ // Node.js core (ESM)
3
+ // -------------------------------
4
+ import http from 'node:http';
5
+ import os from 'node:os';
6
+ import { EventEmitter } from 'node:events';
7
+ import path, { resolve, dirname } from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { existsSync, constants } from 'node:fs';
10
+ import { access, readdir, readFile } from 'node:fs/promises';
11
+
12
+ // -------------------------------
13
+ // Third-party
14
+ // -------------------------------
15
+ import express from 'express';
16
+ import open from 'open';
17
+ import Piscina from 'piscina';
18
+ import uniqid from 'uniqid';
19
+ import { Server as IOServer } from 'socket.io';
20
+ import { centroid, featureCollection, hexGrid, polygon } from '@turf/turf';
21
+ import booleanWithin from '@turf/boolean-within';
22
+ import yargs from 'yargs';
23
+ import { hideBin } from 'yargs/helpers';
24
+
25
+ // -------------------------------
26
+ // Local modules
27
+ // -------------------------------
28
+ import { loadPlugins } from './lib/pluginLoader.js';
29
+ import paletteGenerator from './lib/paletteGenerator.js';
30
+
31
+ const __filename = fileURLToPath(import.meta.url);
32
+ const __dirname = dirname(__filename);
33
+ const VIZ_BASE = resolve(__dirname, "visualizer");
34
+ const app = express();
35
+ const port = 3000;
36
+ const title = 'Splatone - Multi-Layer Composite Heatmap Viewer';
37
+
38
+ try {
39
+
40
+ // Plugin 読み込み
41
+ const api = {
42
+ log: (...a) => console.log('[app]', ...a),
43
+ emit: (topic, payload) => bus.emit(topic, payload), // 重要
44
+ getPlugin: (id) => plugins.get(id),
45
+ };
46
+
47
+ const plugins = await loadPlugins({
48
+ dir: './plugins',
49
+ api,
50
+ optionsById: {},
51
+ });
52
+ // Visualizer読み込み
53
+ const all_visualizers = {}; // { [name: string]: class }
54
+ // クラス判定の小ヘルパ
55
+ const isClass = (v) =>
56
+ typeof v === 'function' && /^class\s/.test(Function.prototype.toString.call(v));
57
+ // 1) node.js を import して、クラスを all_visualizers[:name] に格納(公開はしない)
58
+ async function loadVisualizerClasses() {
59
+ const dirs = await readdir(VIZ_BASE, { withFileTypes: true });
60
+ for (const ent of dirs) {
61
+ if (!ent.isDirectory()) continue;
62
+ const name = ent.name;
63
+ const modPath = resolve(VIZ_BASE, name, 'node.js');
64
+ try {
65
+ await access(modPath);
66
+ const mod = await import(pathToFileURL(modPath).href);
67
+ // デフォルト or named export どちらでも拾えるように
68
+ let Cls = null;
69
+ if (isClass(mod.default)) Cls = mod.default;
70
+ else {
71
+ for (const v of Object.values(mod)) {
72
+ if (isClass(v)) { Cls = v; break; }
73
+ }
74
+ }
75
+ if (!Cls) {
76
+ console.warn(`[visualizer] ${name}/node.js にクラスが見つかりません。スキップ`);
77
+ continue;
78
+ }
79
+ all_visualizers[name] = Cls;
80
+ //console.log(`[visualizer] loaded class for "${name}"`);
81
+ } catch (e) {
82
+ // node.js が無ければスキップ
83
+ console.log(e)
84
+ }
85
+ }
86
+ }
87
+ await loadVisualizerClasses();
88
+ // --- 2) node.js への直接アクセスを 404(保険)
89
+ app.use('/visualizer', (req, res, next) => {
90
+ if (/\/node\.js(?:$|\?)/.test(req.path)) return res.sendStatus(404);
91
+ next();
92
+ });
93
+ // --- 3) web.js のみ直リンクで配信(ホワイトリスト式)
94
+ app.get('/visualizer/:name/web.js', async (req, res) => {
95
+ const file = resolve(VIZ_BASE, req.params.name, 'web.js');
96
+ try { await access(file); res.sendFile(file); }
97
+ catch (e) { console.log(e); res.sendStatus(404); }
98
+ });
99
+ // --- 4) 追加アセットは /public/ 以下だけ静的配信(必要なものだけ公開)
100
+ app.use('/visualizer/:name/public', (req, res, next) => {
101
+ // :name を取り出して、そのフォルダの /public だけ公開する
102
+ const name = req.params?.name || (req.url.split('/')[1] || '');
103
+ req.url = req.originalUrl.replace(`/visualizer/${name}/public`, '') || '/';
104
+ express.static(resolve(VIZ_BASE, name, 'public'))(req, res, next);
105
+ });
106
+ // コマンド例
107
+ // node crawler.js -p flickr -o '{"flickr":{"API_KEY":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}' -k "商業=shop,souvenir,market,supermarket,pharmacy,store,department|食べ物=food,drink,restaurant,cafe,bar|美術 館=museum,art,exhibition,expo,sculpture,heritage|公園=park,garden,flower,green,pond,playground" --vis-bulky
108
+ // node crawler.js -p flickr -k "水域=canal,channel,waterway,river,stream,watercourse,sea,ocean,gulf,bay,strait,lagoon,offshore|橋梁=bridge,overpass,flyover,aqueduct,trestle|通路=street,road,thoroughfare,roadway,avenue,boulevard,lane,alley,roadway,carriageway,highway,motorway|ランドマーク=church,sanctuary,chapel,cathedral,basilica,minster,abbey" --vis-bulky
109
+ let yargv = await yargs(hideBin(process.argv))
110
+ .strict() // 未定義オプションはエラー
111
+ .usage('使い方: $0 [options]')
112
+ .option('plugin', {
113
+ group: 'Basic Options',
114
+ alias: 'p',
115
+ choices: ["flickr","gmaps"],
116
+ demandOption: true,
117
+ describe: '実行するプラグイン',
118
+ type: 'string'
119
+ })
120
+ .option('options', {
121
+ group: 'Basic Options',
122
+ alias: 'o',
123
+ default: '{}',
124
+ describe: 'プラグインオプション',
125
+ type: 'string'
126
+ })
127
+ .option('keywords', {
128
+ group: 'Basic Options',
129
+ alias: 'k',
130
+ type: 'string',
131
+ default: 'nature,tree,flower|building,house|water,sea,river,pond',
132
+ description: '検索キーワード(|区切り)'
133
+ })
134
+ .version()
135
+ .coerce({
136
+ options: ((name) => (v) => {
137
+ try { return JSON.parse(v); }
138
+ catch (e) { throw new Error(`--${name}: JSON エラー: ${e.message}`); }
139
+ })()
140
+ });
141
+ Object.keys(all_visualizers).forEach((vis) => {
142
+ yargv = yargv.option('vis-' + vis, {
143
+ group: 'Visualization (最低一つの指定が必須です)',
144
+ type: 'boolean',
145
+ default: false,
146
+ description: all_visualizers[vis].description
147
+ })
148
+ });
149
+ yargv = yargv.check((argv, options) => {
150
+ if (Object.keys(all_visualizers).filter(v => argv["vis-" + v]).length == 0) {
151
+ throw new Error('可視化ツールの指定がありません。最低一つは指定してください。');
152
+ }
153
+ return true;
154
+ });
155
+ const argv = await yargv.parseAsync();
156
+
157
+ const visualizers = {};
158
+ for (const vis of Object.keys(all_visualizers).filter(v => argv[`vis-${v}`])) {
159
+ visualizers[vis] = new all_visualizers[vis]();
160
+ }
161
+
162
+ const plugin_options = argv.options?.[argv.plugin] ?? {}
163
+ try {
164
+ plugin_options.API_KEY = await loadAPIKey("flickr") ?? plugin_options.API_KEY;
165
+ } catch (e) {
166
+ console.error("Error loading API key:", e.message);
167
+ //Nothing to do
168
+ }
169
+ await plugins.call(argv.plugin, 'init', plugin_options);
170
+ console.table([["Visualizer", Object.keys(visualizers)], ["Plugin", argv.plugin]]);
171
+
172
+ /* API Key読み込み */
173
+ async function loadAPIKey(plugin = 'flickr') {
174
+ const filePath = ".API_KEY." + plugin;
175
+ const file = resolve(filePath);
176
+ // 存在&読取権限チェック
177
+ try {
178
+ await access(file, constants.F_OK | constants.R_OK);
179
+ } catch (err) {
180
+ const code = /** @type {{ code?: string }} */(err).code || 'UNKNOWN';
181
+ throw new Error(`APIキーのファイルにアクセスできません: ${file} (code=${code})`);
182
+ }
183
+ // 読み込み & トリム
184
+ const raw = await readFile(file, 'utf8');
185
+ const key = raw.trim();
186
+ if (!key) {
187
+ throw new Error(`APIキーのファイルが空です: ${file}`);
188
+ }
189
+ // ※任意: Flickr APIキーの緩い形式チェック(英数32+文字)
190
+ // 公式に厳格仕様が明示されていないため、緩めのガードに留めます。
191
+ if (!/^[A-Za-z0-9]{32,}$/.test(key)) {
192
+ // 形式が怪しい場合は警告だけにするなら console.warn に変更
193
+ throw new Error(`APIキーの形式が不正の可能性があります(英数字32文字以上を想定): ${file}`);
194
+ }
195
+ return key;
196
+ }
197
+
198
+
199
+ const crawlers = {};
200
+ const targets = {};
201
+ // 初期中心(凱旋門)
202
+ const DEFAULT_CENTER = { lat: 48.873611, lon: 2.294444 };
203
+
204
+ app.use(express.static('public'));
205
+ app.set('view engine', 'ejs');
206
+ app.set('views', './views');
207
+
208
+ const server = http.createServer(app);
209
+ const io = new IOServer(server, {
210
+ path: "/socket"
211
+ });
212
+
213
+ // 購読のユーティリティ
214
+ const bus = new EventEmitter();
215
+ async function subscribe(topic, handler) {
216
+ bus.on(topic, handler);
217
+ return () => bus.off(topic, handler); // unsubscribe
218
+ }
219
+
220
+ // 座標をエッジキー用に丸め&正規化(無向)
221
+ function edgeKey(a, b, digits = 6) {
222
+ const fmt = ([lon, lat]) => `${lon.toFixed(digits)},${lat.toFixed(digits)}`;
223
+ const k1 = `${fmt(a)}|${fmt(b)}`;
224
+ const k2 = `${fmt(b)}|${fmt(a)}`;
225
+ return k1 < k2 ? k1 : k2;
226
+ }
227
+
228
+
229
+ function defaultMaxUploadTime(date = new Date()) {
230
+ return Math.floor(date / 1000) - 360;
231
+ }
232
+
233
+ function concatFC(fc1, fc2) {
234
+ return featureCollection([
235
+ ...(fc1?.features ?? []),
236
+ ...(fc2?.features ?? []),
237
+ ]);
238
+ }
239
+ /**
240
+ * /api/hexgrid
241
+ * クエリ:
242
+ * bbox: "minLon,minLat,maxLon,maxLat"
243
+ * cellSize: 数値
244
+ * units: "kilometers" | "meters" | "miles" など
245
+ *
246
+ * 返り値:
247
+ * {
248
+ * hex: FeatureCollection<Polygon, { hexId:number, triIds:string[] }>,
249
+ * triangles: FeatureCollection<Polygon, {
250
+ * parentHexId:number,
251
+ * triInHex:number,
252
+ * triangleId:string,
253
+ * crossNeighbors:string[], // 共有辺を持つ他Hexの三角形IDs
254
+ * neighborHexIds:number[] // 隣接する他HexのID
255
+ * }>
256
+ * }
257
+ */
258
+
259
+ // 画面
260
+ app.get('/', (_req, res) => {
261
+ res.render('index', {
262
+ title: title,
263
+ lat: DEFAULT_CENTER.lat,
264
+ lon: DEFAULT_CENTER.lon,
265
+ defaultCellSize: 0.5,
266
+ defaultUnits: 'kilometers',
267
+ defaultKeywords: argv.keywords,
268
+ });
269
+ });
270
+
271
+
272
+ io.on("connection", (socket) => {
273
+ //console.log("connected:", socket.id);
274
+ const sessionId = uniqid();
275
+ crawlers[sessionId] = {};
276
+ socket.join(sessionId);
277
+
278
+ socket.on("disconnecting", () => {
279
+ if (socket.rooms && crawlers.hasOwnProperty(socket.rooms)) {
280
+ //console.log("delete session:", socket.rooms);
281
+ delete crawlers[socket.rooms];
282
+ }
283
+ //console.log("disconnected:", socket.id);
284
+ });
285
+
286
+ socket.emit("welcome", { socketId: socket.id, sessionId: sessionId, time: Date.now(), visualizers: Object.keys(visualizers) });
287
+ //クローリング開始
288
+ socket.on("crawling", async (req) => {
289
+ try {
290
+ if (sessionId !== req.sessionId) {
291
+ console.warn("invalid sessionId:", req.sessionId);
292
+ return;
293
+ }
294
+
295
+ await plugins.call('flickr', 'crawl', {
296
+ hexGrid: targets[req.sessionId].hex,
297
+ triangles: targets[req.sessionId].triangles,
298
+ sessionId: req.sessionId,
299
+ //tags: targets[req.sessionId].tags,
300
+ categories: targets[req.sessionId].categories,
301
+ max_upload_date: defaultMaxUploadTime(),
302
+ });
303
+ }
304
+ catch (e) {
305
+ console.error(e);
306
+ //res.status(500).json({ error: 'failed to build hexgrid' });
307
+ socket.emit("error ", { error: 'failed to crawling' });
308
+ }
309
+ });
310
+ // クロール範囲指定
311
+ socket.on("target", (req) => {
312
+ try {
313
+ //console.log("target:", req);
314
+ if (sessionId !== req.sessionId) {
315
+ console.warn("invalid sessionId:", req.sessionId);
316
+ return;
317
+ }
318
+ const { bbox, drawn, cellSize = '0.5', units = 'kilometers', tags = 'sea,beach|mountain,forest' } = req.query;
319
+ const fallbackBbox = [139.55, 35.53, 139.92, 35.80];
320
+ let bboxArray = fallbackBbox;
321
+
322
+ if (bbox) {
323
+ const parts = String(bbox).split(',').map(Number);
324
+ if (parts.length !== 4 || !parts.every(Number.isFinite)) {
325
+ return res.status(400).json({ error: 'bbox must be "minLon,minLat,maxLon,maxLat"' });
326
+ }
327
+ bboxArray = parts;
328
+ }
329
+
330
+ const sizeNum = Number(cellSize);
331
+ if (!Number.isFinite(sizeNum) || sizeNum <= 0) {
332
+ return res.status(400).json({ error: 'cellSize must be a positive number' });
333
+ }
334
+
335
+ //カテゴリ生成
336
+ const categorize = (tags) => {
337
+ let cats = {};
338
+ tags.split('|').forEach((tag_set, i) => {
339
+ const key_val = tag_set.split("=", 2);
340
+ const key = (key_val.length == 1) ? key_val[0].split(",")[0] : key_val[0];
341
+ const val = (key_val.length == 1) ? key_val[0] : key_val[1];
342
+ cats[key] = val;
343
+ });
344
+ return cats;
345
+ };
346
+ const categories = categorize(req.query.tags);
347
+
348
+ //パレット生成
349
+ const colors = paletteGenerator.generate(
350
+ Object.keys(categories).length, // Colors
351
+ function (color) { // This function filters valid colors
352
+ var hcl = color.hcl();
353
+ return hcl[0] >= 0 && hcl[0] <= 360
354
+ && hcl[1] >= 54.96 && hcl[1] <= 134
355
+ && hcl[2] >= 19.14 && hcl[2] <= 90.23;
356
+ },
357
+ true, // Using Force Vector instead of k-Means
358
+ 50, // Steps (quality)
359
+ false, // Ultra precision
360
+ 'CMC' // Color distance type (colorblindness)
361
+ );
362
+ // Sort colors by differenciation first
363
+ const palette = paletteGenerator.diffSort(colors, 'Default');
364
+ const splatonePalette = Object.fromEntries(Object.entries(categories).map(([k, v]) => {
365
+ const color = palette.pop()
366
+ const colors = {
367
+ "color": color.hex(),
368
+ "darken": color.darken(2).hex(),
369
+ "brighten": color.brighten(2).hex()
370
+ }
371
+ return [k, colors];
372
+ }));
373
+ // HexGrid 生成
374
+
375
+ const fc = hexGrid(bboxArray, sizeNum, { units }).features.filter((f => booleanWithin(f, drawn)));
376
+ fc.forEach((f, i) => {
377
+ f.properties = { hexId: i + 1, triIds: [] };
378
+ });
379
+ let hexFC = featureCollection(fc);
380
+ //console.log(JSON.stringify(hexFC, null, 4));
381
+
382
+ // 三角形生成(扇形分割)+ エッジ索引作成
383
+ const triFeatures = [];
384
+ const edgeToTriangles = new Map(); // edgeKey -> [{ triangleId, parentHexId }]
385
+ for (const f of hexFC.features) {
386
+ if (!f.geometry || f.geometry.type !== 'Polygon') continue;
387
+ const ring = f.geometry.coordinates[0];
388
+ if (!ring || ring.length < 4) continue;
389
+
390
+ const c = centroid(f).geometry.coordinates; // [lon,lat]
391
+ const hexId = f.properties.hexId;
392
+
393
+ for (let i = 0; i < ring.length - 1; i++) {
394
+ const a = ring[i];
395
+ const b = ring[(i + 1) % (ring.length - 1)];
396
+ const triIndex = i + 1;
397
+ const triangleId = `${hexId}-${triIndex}`;
398
+
399
+ // 三角形ポリゴン(a-b-c)
400
+ const tri = polygon([[a, b, c, a]], {
401
+ parentHexId: hexId,
402
+ triInHex: triIndex,
403
+ triangleId
404
+ });
405
+
406
+ // 親Hexへ登録
407
+ f.properties.triIds.push(triangleId);
408
+ triFeatures.push(tri);
409
+
410
+ // “境界辺”キー(a-b)で索引(cは共有しない)
411
+ const k = edgeKey(a, b);
412
+ if (!edgeToTriangles.has(k)) edgeToTriangles.set(k, []);
413
+ edgeToTriangles.get(k).push({ triangleId, parentHexId: hexId });
414
+ }
415
+ }
416
+
417
+ // 交差隣接(共有辺で、かつ他Hexの三角形)を付与
418
+ const triIndex = new Map(triFeatures.map(t => [t.properties.triangleId, t]));
419
+ for (const list of edgeToTriangles.values()) {
420
+ if (list.length < 2) continue; // 共有していなければ隣接なし
421
+ // 同じ辺を共有する全三角形同士で、異なるHexのものを相互に登録
422
+ for (let i = 0; i < list.length; i++) {
423
+ for (let j = 0; j < list.length; j++) {
424
+ if (i === j) continue;
425
+ const a = list[i], b = list[j];
426
+ if (a.parentHexId === b.parentHexId) continue; // 同Hexは除外
427
+ const triA = triIndex.get(a.triangleId);
428
+ const triB = triIndex.get(b.triangleId);
429
+ if (!triA || !triB) continue;
430
+
431
+ triA.properties.crossNeighbors ??= [];
432
+ triA.properties.neighborHexIds ??= [];
433
+ if (!triA.properties.crossNeighbors.includes(b.triangleId)) {
434
+ triA.properties.crossNeighbors.push(b.triangleId);
435
+ }
436
+ if (!triA.properties.neighborHexIds.includes(b.parentHexId)) {
437
+ triA.properties.neighborHexIds.push(b.parentHexId);
438
+ }
439
+ }
440
+ }
441
+ }
442
+ const trianglesFC = featureCollection(triFeatures);
443
+
444
+ //console.log(JSON.stringify(hexFC, null, 2));
445
+ //res.json({ hex: hexFC, triangles: trianglesFC });
446
+ targets[sessionId] = { hex: hexFC, triangles: trianglesFC, categories, splatonePalette };
447
+ socket.emit("hexgrid", { hex: hexFC, triangles: trianglesFC });
448
+ } catch (e) {
449
+ console.error(e);
450
+ //res.status(500).json({ error: 'failed to build hexgrid' });
451
+ if (sessionId in targets) delete targets[sessionId];
452
+ socket.emit("error ", { error: 'failed to build hexgrid' });
453
+ }
454
+
455
+ });
456
+ });
457
+
458
+ const resolvedWorkerFilename = {};
459
+ function resolveWorkerFilename(taskName) {
460
+ if (!resolvedWorkerFilename[taskName]) {
461
+ // できれば workers/<taskName>/worker.mjs のように ESM に統一
462
+ const filePath = resolve(__dirname, "plugins", taskName, "worker.js");
463
+ if (!existsSync(filePath)) {
464
+ // URL はログ用途のみ。Piscinaへはこの後 href を渡す
465
+ const url = pathToFileURL(filePath).href;
466
+ throw new Error(`Worker not found for task="${taskName}" at ${url}`);
467
+ }
468
+ // ★ Piscina には file URL を渡す(href 文字列 or URL オブジェクト)
469
+ resolvedWorkerFilename[taskName] = pathToFileURL(filePath).href;
470
+ }
471
+ return resolvedWorkerFilename[taskName];
472
+ }
473
+
474
+ const statsItems = (crawler, target,) => {
475
+ const stats = [];
476
+ const total = [];
477
+ const crawled = [];
478
+ const progress = [];
479
+ let finish = Object.keys(target.categories).length * target.hex.features.length;
480
+ for (const [hexId, tagsObj] of Object.entries(crawler)) {
481
+ total[hexId] = 0;
482
+ crawled[hexId] = 0;
483
+ for (const [category, items] of Object.entries(tagsObj)) {
484
+ finish -= (items.final === true) ? 1 : 0;
485
+ total[hexId] += items.total;
486
+ crawled[hexId] += items.crawled;
487
+ for (const item of items.items.features) {
488
+ //console.log(item.properties)
489
+ stats[hexId] ??= [];
490
+ stats[hexId][item.properties.splatone_triId] ??= [];
491
+ stats[hexId][item.properties.splatone_triId][category] ??= 0;
492
+ stats[hexId][item.properties.splatone_triId][category] += 1;
493
+ }
494
+ }
495
+ progress[hexId] = {
496
+ percent: total[hexId] == 0 ? 1 : crawled[hexId] / total[hexId],
497
+ crawled: crawled[hexId],
498
+ total: total[hexId]
499
+ };
500
+ }
501
+ //console.table(progress);
502
+ return { stats, progress, finish: (finish == 0) };
503
+ };
504
+
505
+ async function runTask(taskName, data) {
506
+ const filename = resolveWorkerFilename(taskName); // ← file URL (href)
507
+ // named export を呼ぶ場合は { name: "関数名" } を追加
508
+ return piscina.run(data, { filename });
509
+ }
510
+
511
+ const nParallel = Math.max(1, Math.min(12, os.cpus().length))
512
+ const piscina = new Piscina({
513
+ minThreads: 1,
514
+ maxThreads: nParallel,
515
+ idleTimeout: 10_000,
516
+ // 注意:ここで filename は渡さない。run 時に切り替える
517
+ });
518
+ await subscribe('splatone:start', async p => {
519
+ //console.log('[splatone:start]', p);
520
+ const rtn = await runTask(p.plugin, p);
521
+ //console.log('[splatone:done]', p.plugin, rtn.photos.features.length,"photos are collected in hex",rtn.hexId,"tags:",rtn.tags,"final:",rtn.final);
522
+ crawlers[p.sessionId][rtn.hexId] ??= {};
523
+ crawlers[p.sessionId][rtn.hexId][rtn.category] ??= { items: featureCollection([]) };
524
+ crawlers[p.sessionId][rtn.hexId][rtn.category].ids ??= new Set();
525
+ const duplicates = ((A, B) => new Set([...A].filter(x => B.has(x))))(rtn.ids, crawlers[p.sessionId][rtn.hexId][rtn.category].ids);
526
+ crawlers[p.sessionId][rtn.hexId][rtn.category].ids = new Set([...crawlers[p.sessionId][rtn.hexId][rtn.category].ids, ...rtn.ids]);
527
+ crawlers[p.sessionId][rtn.hexId][rtn.category].final = rtn.final;
528
+ crawlers[p.sessionId][rtn.hexId][rtn.category].crawled ??= 0;
529
+ crawlers[p.sessionId][rtn.hexId][rtn.category].total = rtn.final ? crawlers[p.sessionId][rtn.hexId][rtn.category].ids.size : rtn.total + crawlers[p.sessionId][rtn.hexId][rtn.category].crawled;
530
+ crawlers[p.sessionId][rtn.hexId][rtn.category].crawled = crawlers[p.sessionId][rtn.hexId][rtn.category].ids.size;
531
+ console.log(`(CRAWL) ${rtn.hexId} ${rtn.category} ] dup=${duplicates.size}, out=${rtn.outside}, in=${rtn.photos.features.length} || ${crawlers[p.sessionId][rtn.hexId][rtn.category].crawled} / ${crawlers[p.sessionId][rtn.hexId][rtn.category].total}`);
532
+ const photos = featureCollection(rtn.photos.features.filter((f) => !duplicates.has(f.properties.id)));
533
+ crawlers[p.sessionId][rtn.hexId][rtn.category].items
534
+ = concatFC(crawlers[p.sessionId][rtn.hexId][rtn.category].items, photos);
535
+ const { stats, progress, finish } = statsItems(crawlers[p.sessionId], targets[p.sessionId]);
536
+ io.to(p.sessionId).emit('progress', { hexId: rtn.hexId, progress });
537
+ if (!rtn.final) {
538
+ // 次回クロール用に更新
539
+ p.max_upload_date = rtn.next_max_upload_date;
540
+ //console.log("next max_upload_date:", p.max_upload_date);
541
+ api.emit('splatone:start', p);
542
+ } else if (finish) {
543
+ console.table(stats);
544
+ api.emit('splatone:finish', p);
545
+ }
546
+ });
547
+
548
+ await subscribe('splatone:finish', async p => {
549
+ const result = crawlers[p.sessionId];
550
+ const target = targets[p.sessionId];
551
+ console.log('[splatone:finish]');
552
+ const geoJson = Object.fromEntries(Object.entries(visualizers).map(([vis, v]) => [vis, v.getFutureCollection(result, target)]));
553
+ io.to(p.sessionId).emit('result', {
554
+ geoJson,
555
+ palette: target["splatonePalette"],
556
+ visualizers: Object.keys(visualizers),
557
+ plugin: argv.plugin
558
+ });
559
+ });
560
+
561
+ server.listen(port, async () => {
562
+ //console.log(`Server running at http://localhost:${port}`);
563
+ await open(`http://localhost:${port}`);
564
+ });
565
+
566
+ process.on('SIGINT', async () => { await plugins.stopAll(); process.exit(0); });
567
+ } catch (e) {
568
+ console.error(e);
569
+
570
+ }
@@ -0,0 +1,23 @@
1
+ // plugins/PluginBase.js
2
+ export class PluginBase {
3
+ /** 例: static id = 'hello'; */
4
+ static id = null; // 一意ID(フォルダ名と一致させると運用しやすい)
5
+ static name = null; // 表示名
6
+ static version = '0.0.0';
7
+ static dependencies = []; // ['auth','core'] のように他プラグインID
8
+ static started = false;
9
+
10
+ /** @param {object} api - ホストが提供する能力(権限を最小化) */
11
+ constructor(api, options = {}) {
12
+ this.api = api;
13
+ this.options = options;
14
+ }
15
+ async init(options = {}) {
16
+ Object.assign(this.options, options);
17
+ }
18
+ async start() {
19
+ this.started = true;
20
+ //this.api.log(`[${this.constructor.id}] start`);
21
+ }
22
+ async stop() {}
23
+ }
@@ -0,0 +1,15 @@
1
+ // lib/VisualizerBase.js
2
+ export class VisualizerBase {
3
+ /** 例: static id = 'hello'; */
4
+ static id = null; // 一意ID(フォルダ名と一致させると運用しやすい)
5
+ static name = null; // 表示名
6
+ static description = "可視化のための抽象クラス";
7
+ static version = '0.0.0';
8
+
9
+ /** @param {object} api - ホストが提供する能力(権限を最小化) */
10
+ constructor() {
11
+ }
12
+ async init() {}
13
+ async start() {}
14
+ async stop() {}
15
+ }