splatone 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,8 +13,13 @@ SNSのジオタグ付きポストをキーワードに基づいて収集する
13
13
 
14
14
  ## 既知のバグ
15
15
 
16
- - JSON.stringify(json)で変換できる大きさに制限があり、数十万件等の大きな結果を生み出すクエリは、クロール後、結果のブラウザへの転送で失敗します。
17
-
16
+ - <s>JSON.stringify(json)で変換できる大きさに制限があり、数十万件等の大きな結果を生み出すクエリは、クロール後、結果のブラウザへの転送で失敗します。</s>
17
+ - サイズの問題は、一度ファイルに書き出す事で解決しました。(v0.0.6)
18
+ - **--no-filed**オプションを付ける事で、従来の方法で実行できます。(ファイルに書き出すより多少速い)
19
+ - 以下の画像のように大きな範囲で多くのジオタグが収集できるようになりました。
20
+
21
+ ![](/assets/screenshot_massive_points_bulky.png)
22
+
18
23
  # 使い方
19
24
 
20
25
  - [Node.js](https://nodejs.org/ja/download)をインストール後、npxで実行します。
@@ -25,13 +30,18 @@ SNSのジオタグ付きポストをキーワードに基づいて収集する
25
30
  ```shell
26
31
  $ npx -y -- splatone@latest crawler --help
27
32
  使い方: crawler.js [options]
28
-
29
33
  Basic Options
30
- -p, --plugin 実行するプラグイン
31
- [文字列] [必須] [選択してください: "flickr"]
34
+ -p, --plugin 実行するプラグイン[文字列] [必須] [選択してください: "flickr"]
32
35
  -o, --options プラグインオプション [文字列] [デフォルト: "{}"]
33
36
  -k, --keywords 検索キーワード(|区切り) [文字列] [デフォルト:
34
37
  "nature,tree,flower|building,house|water,sea,river,pond"]
38
+ -f, --filed 大きなデータをファイルとして送受信する
39
+ [真偽] [デフォルト: true]
40
+ -c, --chopped 大きなデータを細分化して送受信する
41
+ [非推奨] [真偽] [デフォルト: false]
42
+
43
+ Debug
44
+ --debug-verbose デバッグ情報出力 [真偽] [デフォルト: false]
35
45
 
36
46
  Visualization (最低一つの指定が必須です)
37
47
  --vis-bulky 全データをCircleMarkerとして地図上に表示
@@ -41,7 +51,7 @@ Visualization (最低一つの指定が必須です)
41
51
 
42
52
  オプション:
43
53
  --help ヘルプを表示 [真偽]
44
- --version バージョンを表示 [真偽]
54
+ --version バージョンを表示 [真偽]
45
55
  ```
46
56
  ## クローリングの実行
47
57
 
@@ -76,7 +86,7 @@ APIキーは以下の3種類の方法で与える事ができます
76
86
  - 環境変数で渡す
77
87
  - ```API_KEY_plugin```という環境変数に格納する
78
88
  - コマンドに毎回含めなくて良くなる。
79
- - **flickr**の場合は```.API_KEY_flickr```になります。
89
+ - **flickr**の場合は```API_KEY_flickr```になります。
80
90
  - ```plugin```はプラグイン名(flickr等)に置き換えてください。
81
91
  - 一時的な環境変数を定義する事も可能です。(bash等)
82
92
  - ```API_KEY_flickr="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" node crawler.js -p flickr -k "sea,ocean|mountain,mount" --vis-bulky```
package/crawler.js CHANGED
@@ -8,7 +8,7 @@ import os from 'node:os';
8
8
  import { EventEmitter } from 'node:events';
9
9
  import path, { resolve, dirname } from 'node:path';
10
10
  import { fileURLToPath, pathToFileURL } from 'node:url';
11
- import { existsSync, constants } from 'node:fs';
11
+ import { existsSync, writeFileSync, constants } from 'node:fs';
12
12
  import { access, readdir, readFile } from 'node:fs/promises';
13
13
 
14
14
  // -------------------------------
@@ -28,6 +28,7 @@ import { hideBin } from 'yargs/helpers';
28
28
  // -------------------------------
29
29
  import { loadPlugins } from './lib/pluginLoader.js';
30
30
  import paletteGenerator from './lib/paletteGenerator.js';
31
+ import { dfsObject, saveGeoJsonObjectAsStream } from './lib/splatone.js';
31
32
 
32
33
  const __filename = fileURLToPath(import.meta.url);
33
34
  const __dirname = dirname(__filename);
@@ -107,6 +108,7 @@ try {
107
108
  // コマンド例
108
109
  // 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
109
110
  // 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,temple,shrine" --vis-bulky
111
+ // node crawler.js -p flickr -k "水辺=sea,ocean,beach|山岳=mountain,mount,hill" --vis-bulky --chopped
110
112
  let yargv = await yargs(hideBin(process.argv))
111
113
  .strict() // 未定義オプションはエラー
112
114
  .usage('使い方: $0 [options]')
@@ -131,6 +133,29 @@ try {
131
133
  type: 'string',
132
134
  default: 'nature,tree,flower|building,house|water,sea,river,pond',
133
135
  description: '検索キーワード(|区切り)'
136
+ }).option('filed', {
137
+ group: 'Basic Options',
138
+ alias: 'f',
139
+ type: 'boolean',
140
+ default: true,
141
+ description: '大きなデータをファイルとして送受信する'
142
+ }).option('chopped', {
143
+ group: 'Basic Options',
144
+ alias: 'c',
145
+ type: 'boolean',
146
+ default: false,
147
+ deprecate: true,
148
+ description: '大きなデータを細分化して送受信する'
149
+ // }).option('debug-save', {
150
+ // group: 'Debug',
151
+ // type: 'boolean',
152
+ // default: false,
153
+ // description: 'サーバ側にクロールデータを保存'
154
+ }).option('debug-verbose', {
155
+ group: 'Debug',
156
+ type: 'boolean',
157
+ default: false,
158
+ description: 'デバッグ情報出力'
134
159
  })
135
160
  .version()
136
161
  .coerce({
@@ -151,10 +176,13 @@ try {
151
176
  if (Object.keys(all_visualizers).filter(v => argv["vis-" + v]).length == 0) {
152
177
  throw new Error('可視化ツールの指定がありません。最低一つは指定してください。');
153
178
  }
179
+ if (argv.filed && argv.chopped) {
180
+ console.warn("--filedと--choppedが両方指定されています。--filedが優先されます。");
181
+ argv.chopped = false;
182
+ }
154
183
  return true;
155
184
  });
156
185
  const argv = await yargv.parseAsync();
157
-
158
186
  const visualizers = {};
159
187
  for (const vis of Object.keys(all_visualizers).filter(v => argv[`vis-${v}`])) {
160
188
  visualizers[vis] = new all_visualizers[vis]();
@@ -164,11 +192,15 @@ try {
164
192
  try {
165
193
  plugin_options.API_KEY = await loadAPIKey("flickr") ?? plugin_options.API_KEY;
166
194
  } catch (e) {
167
- console.error("Error loading API key:", e.message);
195
+ if (!plugin_options.API_KEY) {
196
+ console.error("Error loading API key:", e.message);
197
+ }
168
198
  //Nothing to do
169
199
  }
170
200
  await plugins.call(argv.plugin, 'init', plugin_options);
171
- console.table([["Visualizer", Object.keys(visualizers)], ["Plugin", argv.plugin]]);
201
+ if (argv.debugVerbose) {
202
+ console.table([["Visualizer", Object.keys(visualizers)], ["Plugin", argv.plugin]]);
203
+ }
172
204
 
173
205
  /* API Key読み込み */
174
206
  async function loadAPIKey(plugin = 'flickr') {
@@ -182,11 +214,11 @@ try {
182
214
  await access(file, constants.F_OK | constants.R_OK);
183
215
  // 読み込み & トリム
184
216
  const raw = await readFile(file, 'utf8');
185
- console.log(`[API KEY (${plugin}})] Read from FILE`);
217
+ //console.log(`[API KEY (${plugin}})] Read from FILE`);
186
218
  key = raw.trim();
187
219
  } catch (err) {
188
220
  if (Object.prototype.hasOwnProperty.call(process.env, "API_KEY_" + plugin)) {
189
- console.log(`[API KEY (${plugin}})] Read from ENV`);
221
+ //console.log(`[API KEY (${plugin}})] Read from ENV`);
190
222
  key = process.env["API_KEY_" + plugin] ?? null;
191
223
  } else {
192
224
  const code = /** @type {{ code?: string }} */(err).code || 'UNKNOWN';
@@ -325,7 +357,10 @@ try {
325
357
  console.warn("invalid sessionId:", req.sessionId);
326
358
  return;
327
359
  }
328
- const { bbox, drawn, cellSize = '0.5', units = 'kilometers', tags = 'sea,beach|mountain,forest' } = req.query;
360
+ const { bbox, drawn, cellSize = 0, units = 'kilometers', tags = 'sea,beach|mountain,forest' } = req.query;
361
+ if (cellSize == 0) {
362
+ //セルサイズ自動決定()
363
+ }
329
364
  const fallbackBbox = [139.55, 35.53, 139.92, 35.80];
330
365
  let bboxArray = fallbackBbox;
331
366
 
@@ -527,7 +562,7 @@ try {
527
562
  return piscina.run(data, { filename });
528
563
  }
529
564
 
530
- const nParallel = Math.max(1, Math.min(12, os.cpus().length))
565
+ const nParallel = Math.max(1, Math.min(6, os.cpus().length))
531
566
  const piscina = new Piscina({
532
567
  minThreads: 1,
533
568
  maxThreads: nParallel,
@@ -547,7 +582,9 @@ try {
547
582
  crawlers[p.sessionId][rtn.hexId][rtn.category].crawled ??= 0;
548
583
  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;
549
584
  crawlers[p.sessionId][rtn.hexId][rtn.category].crawled = crawlers[p.sessionId][rtn.hexId][rtn.category].ids.size;
550
- 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}`);
585
+ if (argv.debugVerbose) {
586
+ 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}`);
587
+ }
551
588
  const photos = featureCollection(rtn.photos.features.filter((f) => !duplicates.has(f.properties.id)));
552
589
  crawlers[p.sessionId][rtn.hexId][rtn.category].items
553
590
  = concatFC(crawlers[p.sessionId][rtn.hexId][rtn.category].items, photos);
@@ -559,22 +596,111 @@ try {
559
596
  //console.log("next max_upload_date:", p.max_upload_date);
560
597
  api.emit('splatone:start', p);
561
598
  } else if (finish) {
562
- console.table(stats);
599
+ if (argv.debugVerbose) {
600
+ console.table(stats);
601
+ }
563
602
  api.emit('splatone:finish', p);
564
603
  }
565
604
  });
566
605
 
567
606
  await subscribe('splatone:finish', async p => {
607
+ const resultId = uniqid();
568
608
  const result = crawlers[p.sessionId];
569
609
  const target = targets[p.sessionId];
610
+
611
+ let geoJson = Object.fromEntries(Object.entries(visualizers).map(([vis, v]) => [vis, v.getFutureCollection(result, target)]));
612
+
570
613
  console.log('[splatone:finish]');
571
- const geoJson = Object.fromEntries(Object.entries(visualizers).map(([vis, v]) => [vis, v.getFutureCollection(result, target)]));
572
- io.to(p.sessionId).emit('result', {
573
- geoJson,
574
- palette: target["splatonePalette"],
575
- visualizers: Object.keys(visualizers),
576
- plugin: argv.plugin
577
- });
614
+ try {
615
+ if (argv.chopped || argv.filed) {
616
+ throw new RangeError("Invalid string length");
617
+ }
618
+ await io.to(p.sessionId).timeout(120000).emitWithAck('result', {
619
+ resultId,
620
+ geoJson,
621
+ palette: target["splatonePalette"],
622
+ visualizers: Object.keys(visualizers),
623
+ plugin: argv.plugin
624
+ });
625
+ } catch (e) {
626
+ if (e instanceof RangeError && /Invalid string length/.test(String(e.message))) {
627
+ const msg = ((argv.chopped || argv.filed) ? "ユーザの指定により" : "結果サイズが巨大なので") + (argv.chopped ? "断片化送信" : "保存ファイル送信") + "モードでクライアントに送ります";
628
+ if (argv.debugVerbose) {
629
+ console.warn("[WARN] " + msg);
630
+ }
631
+ io.to(p.sessionId).timeout(5000).emit('toast', {
632
+ text: msg,
633
+ class: "warning"
634
+ });
635
+ if (argv.chopped) {
636
+ //サイズ集計
637
+ const total_features = ((s = 0, st = [geoJson], v, seen = new WeakSet) => { for (; st.length;)if ((v = st.pop()) && typeof v === 'object' && !seen.has(v)) { seen.add(v); if (Array.isArray(v.features)) s += v.features.length; for (const k in v) { const x = v[k]; if (x && typeof x === 'object') st.push(x) } } return s })();
638
+ let current_features = 0
639
+ await dfsObject(geoJson, async ({ path, value, kind, type }) => {
640
+ if (path.length !== 0) {
641
+ if (kind === "primitive" || kind === "null") {
642
+ //console.log(path.join("."), "=>", `(${kind}:${type})`, value);
643
+ const ackrtn = await io.to(p.sessionId).timeout(120000).emitWithAck('result-chunk', {
644
+ resultId,
645
+ path,
646
+ kind,
647
+ type,
648
+ value
649
+ });
650
+ //console.log("\tACK", ackrtn);
651
+ } else if (kind === "object") {
652
+ //console.log(path.join("."), "=>", `(${kind}:${type})`);
653
+ if (path.at(-2) == "features" && Number.isInteger(path.at(-1))) {
654
+ current_features++;
655
+ }
656
+ const ackrtn = await io.to(p.sessionId).timeout(120000).emitWithAck('result-chunk', {
657
+ resultId,
658
+ path,
659
+ kind,
660
+ type,
661
+ progress: { current: current_features, total: total_features }
662
+ });
663
+ //console.log("\tACK", ackrtn);
664
+ } else if (kind === "array") {
665
+ //console.log(path.join("."), "=>", `(${kind}:${type})`);
666
+ const ackrtn = await io.to(p.sessionId).timeout(120000).emitWithAck('result-chunk', {
667
+ resultId,
668
+ path,
669
+ kind,
670
+ type
671
+ });
672
+ //console.log("\tACK", ackrtn);
673
+ }
674
+ } else {
675
+ //console.log("SKIP---------------------");
676
+ }
677
+ });
678
+ //console.log("finish chunks");
679
+ } else {
680
+ //保存ファイル送信(--filed)
681
+ try {
682
+ const outPath = await saveGeoJsonObjectAsStream(geoJson, 'result.' + resultId + '.json');
683
+ console.log('saved:', outPath);
684
+ const ackrtn = await io.to(p.sessionId).timeout(120000).emitWithAck('result-file', {
685
+ resultId,
686
+ });
687
+ } catch (err) {
688
+ console.error('failed:', err);
689
+ process.exitCode = 1;
690
+ }
691
+ }
692
+ await io.to(p.sessionId).timeout(120000).emitWithAck('result', {
693
+ resultId,
694
+ geoJson: null, /*geoJsonは送らない*/
695
+ palette: target["splatonePalette"],
696
+ visualizers: Object.keys(visualizers),
697
+ plugin: argv.plugin
698
+ });
699
+ console.log("[Done]");
700
+ } else {
701
+ throw e; // 他の例外はそのまま
702
+ }
703
+ }
578
704
  });
579
705
 
580
706
  server.listen(port, async () => {
@@ -61,7 +61,7 @@ const paletteGenerator = (function(undefined){
61
61
  distanceType = 'Default';
62
62
  ultra_precision = ultra_precision || false
63
63
 
64
- console.log('Generate palettes for '+colorsCount+' colors using color distance "'+distanceType+'"')
64
+ //console.log('Generate palettes for '+colorsCount+' colors using color distance "'+distanceType+'"')
65
65
 
66
66
  if(forceMode){
67
67
  // Force Vector Mode
@@ -0,0 +1,151 @@
1
+ // 各種関数のまとめ場
2
+ // @turf/turf v6/v7 どちらでもOK(rhumbDistanceはv7で統合済み)
3
+ import { point, distance, rhumbDistance, bbox as turfBbox } from '@turf/turf';
4
+ import { createWriteStream } from 'node:fs';
5
+ import { mkdir } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { pipeline as pipelineCb } from 'node:stream';
8
+ import { promisify } from 'node:util';
9
+ import { JsonStreamStringify } from 'json-stream-stringify';
10
+
11
+ /**
12
+ * bbox の幅・高さを指定単位で返す
13
+ * @param {number[]} bbox [minX,minY,maxX,maxY] (経度・緯度, WGS84)
14
+ * @param {"meters"|"kilometers"|"miles"|"nauticalmiles"|"inches"|"yards"|"feet"} units
15
+ * @param {"geodesic"|"rhumb"} method 距離の定義:大円 or 等角航程
16
+ * @returns {{width:number, height:number, units:string}}
17
+ */
18
+ export function bboxSize(bbox, units = "kilometers", method = "geodesic") {
19
+ const [minX, minY, maxX, maxY] = bbox;
20
+
21
+ // 中央経度・緯度(幅は中緯度、 高さは中経度で測るのが安定)
22
+ const midLat = (minY + maxY) / 2;
23
+ const midLon = (minX + maxX) / 2;
24
+
25
+ // 測りたい2点を作成
26
+ const west = point([minX, midLat]);
27
+ const east = point([maxX, midLat]);
28
+ const south = point([midLon, minY]);
29
+ const north = point([midLon, maxY]);
30
+
31
+ // 距離関数を選択
32
+ const distFn = method === "rhumb" ? rhumbDistance : distance;
33
+
34
+ return {
35
+ width: distFn(west, east, { units }),
36
+ height: distFn(south, north, { units }),
37
+ units
38
+ };
39
+ }
40
+
41
+ /**
42
+ * GeoJSON(Feature/FeatureCollection/Geometry)から計算したいときのヘルパー
43
+ */
44
+ export function bboxSizeOf(geojson, units = "kilometers", method = "geodesic") {
45
+ return bboxSize(turfBbox(geojson), units, method);
46
+ }
47
+
48
+
49
+ // visit({ key, value, path, parent, depth, kind, type, isLeaf, isNull })
50
+ // kind: 'array' | 'object' | 'primitive' | 'null'
51
+ // type: primitive は typeof、配列は 'Array'、オブジェクトは constructor.name
52
+ export async function dfsObject(root, visit, options = {}) {
53
+ const {
54
+ includeArrays = true,
55
+ maxDepth = Infinity,
56
+ stopOnTrue = false,
57
+ } = options;
58
+
59
+ const meta = describeType(root);
60
+
61
+ // root が非オブジェクトの場合も visit だけ呼んで終了
62
+ if (meta.isLeaf) {
63
+ await visit({
64
+ key: undefined,
65
+ value: root,
66
+ path: [],
67
+ parent: null,
68
+ depth: 0,
69
+ ...meta,
70
+ });
71
+ return false;
72
+ }
73
+
74
+ const seen = new WeakSet();
75
+ const stack = [
76
+ { key: undefined, value: root, path: /** @type {(string|number)[]} */([]), parent: null, depth: 0 }
77
+ ];
78
+
79
+ while (stack.length) {
80
+ const node = stack.pop();
81
+ const { key, value, path, parent } = node;
82
+
83
+ const m = describeType(value);
84
+ const depth = path.length; // path 配列の長さをそのまま深さに
85
+ const ret = await visit({ key, value, path, parent, depth, ...m });
86
+ if (stopOnTrue && ret === true) return true;
87
+
88
+ if (m.isLeaf) continue; // primitive/null は展開しない
89
+ if (seen.has(value)) continue; // 循環参照対策
90
+ seen.add(value);
91
+
92
+ if (depth >= maxDepth) continue;
93
+
94
+ const isArr = Array.isArray(value);
95
+ if (!includeArrays && isArr) continue; // 配列を辿らない設定
96
+
97
+ const entries = isArr
98
+ ? value.map((v, i) => [i, v]) // index は number
99
+ : Object.entries(value); // key は string
100
+
101
+ // 左→右の自然な DFS にしたいので逆順 push
102
+ for (let i = entries.length - 1; i >= 0; i--) {
103
+ const [k, v] = entries[i]; // k: string|number
104
+ const childPath = path.concat([k]);
105
+ stack.push({
106
+ key: k,
107
+ value: v,
108
+ path: childPath,
109
+ parent: value,
110
+ depth: childPath.length,
111
+ });
112
+ }
113
+ }
114
+ return false;
115
+ }
116
+
117
+ function describeType(value) {
118
+ if (value === null) {
119
+ return { kind: "null", type: "null", isLeaf: true, isNull: true };
120
+ }
121
+ const t = typeof value;
122
+ if (t !== "object") {
123
+ return {
124
+ kind: "primitive",
125
+ type: t, // 'number' | 'string' | 'boolean' | 'bigint' | 'symbol' | 'undefined'
126
+ isLeaf: true,
127
+ isNull: false,
128
+ };
129
+ }
130
+ if (Array.isArray(value)) {
131
+ return { kind: "array", type: "Array", isLeaf: false, isNull: false };
132
+ }
133
+ const ctor = value?.constructor?.name ?? "Object";
134
+ return { kind: "object", type: ctor, isLeaf: false, isNull: false };
135
+ }
136
+ const pipeline = promisify(pipelineCb);
137
+ export async function saveGeoJsonObjectAsStream(geoJsonObject, outfile) {
138
+ const dir = path.join('public', 'out');
139
+ await mkdir(dir, { recursive: true });
140
+ const destPath = path.join(dir, path.basename(outfile));
141
+
142
+ // ここでも JSON.stringify は呼ばず、ストリームで直に文字列化
143
+ const src = new JsonStreamStringify(geoJsonObject);
144
+ const dest = createWriteStream(destPath, { flags: 'w' });
145
+ await pipeline(src, dest);
146
+ return destPath;
147
+ }
148
+ export default {
149
+ dfsObject, bboxSize, saveGeoJsonObjectAsStream
150
+ };
151
+ //const {width,height} = bboxSize(b, "kilometers", "geodesic");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splatone",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Multi-layer Composite Heatmap",
5
5
  "homepage": "https://github.com/YokoyamaLab/Splatone#readme",
6
6
  "bugs": {
@@ -27,6 +27,7 @@
27
27
  "express": "^5.1.0",
28
28
  "flickr-sdk": "^7.1.0",
29
29
  "iwanthue": "^2.0.0",
30
+ "json-stream-stringify": "^3.1.6",
30
31
  "open": "^10.2.0",
31
32
  "piscina": "^5.1.3",
32
33
  "socket.io": "^4.8.1",
@@ -58,10 +58,11 @@ export default async function ({
58
58
  = res.photos.photo.length > 0
59
59
  ? (res.photos.photo[res.photos.photo.length - 1].dateupload) - (res.photos.photo[res.photos.photo.length - 1].dateupload == res.photos.photo[0].dateupload ? 1 : 0)
60
60
  : null;
61
- if (Object.keys(authors).length == 1) {
62
- const window = res.photos.photo[res.photos.photo.length - 1].dateupload - res.photos.photo[0].dateupload;
63
- console.warn("[Warning]", `High posting activity detected for ${Object.keys(authors)} within ${window} s. the crawler will skip the next 24 hours.`);
64
- next_max_upload_date -= 60 * 60 * 24;
61
+ const window = res.photos.photo.length == 0 ? 0 : res.photos.photo[0].dateupload - res.photos.photo[res.photos.photo.length - 1].dateupload;
62
+ if (Object.keys(authors).length == 1 && window < 60 * 60) {
63
+ const skip = window < 5 ? 0.1 : 12;
64
+ console.warn("[Warning]", `High posting activity detected for ${Object.keys(authors)} within ${window} s. the crawler will skip the next ${skip} hours.`);
65
+ next_max_upload_date -= 60 * 60 * skip;
65
66
  }
66
67
  return {
67
68
  photos,
package/public/style.css CHANGED
@@ -315,4 +315,14 @@ text-shadow: 0 1px 1px #0006;
315
315
  .center-x {
316
316
  display: flex;
317
317
  justify-content: center; /* 横中央 */
318
- }
318
+ }
319
+
320
+ .toast-info {
321
+ background: linear-gradient(to right, #43cea2, #185a9d)!important;
322
+ }
323
+ .toast-warning{
324
+ background: linear-gradient(to bottom, #c21500, #ffc500)!important;
325
+ }
326
+ .toast-error {
327
+ background: linear-gradient(to bottom, #333333, #dd1818)!important;
328
+ }
@@ -12,6 +12,21 @@ function addGeoJSONLayer(map, geojson, options = {}) {
12
12
  return geojsonLayer;
13
13
  }
14
14
 
15
+ function setAt(obj, path, value) {
16
+ if (path.length === 0) return value;
17
+ let cur = obj;
18
+ for (let i = 0; i < path.length - 1; i++) {
19
+ const k = path[i];
20
+ const nextK = path[i + 1];
21
+ if (cur[k] == null) {
22
+ // 次のキーが数値なら []、文字なら {}
23
+ cur[k] = Number.isInteger(nextK) ? [] : {};
24
+ }
25
+ cur = cur[k];
26
+ }
27
+ cur[path[path.length - 1]] = value;
28
+ return obj;
29
+ }
15
30
  /*
16
31
  使用例:
17
32
  const layer = addGeoJSONLayer(map, myGeoJSON, {
@@ -46,4 +61,77 @@ function loadAsset(url) {
46
61
  }
47
62
 
48
63
  return Promise.reject(new Error(`Unsupported extension: ${url}`));
49
- }
64
+ }
65
+
66
+ async function fetchJsonObject(url, { signal } = {}) {
67
+ const res = await fetch(url, {
68
+ method: 'GET',
69
+ headers: { 'Accept': 'application/json' },
70
+ // same-originのときは不要。CORS越えならサーバ側のCORS許可が必要
71
+ signal,
72
+ cache: 'no-cache', // 必要なら更新を強制
73
+ });
74
+
75
+ if (!res.ok) {
76
+ // ステータスコード付きで例外
77
+ throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
78
+ }
79
+
80
+ // 自動で JSON parse され、JSオブジェクトになる
81
+ return await res.json();
82
+ }
83
+
84
+ /**
85
+ * 指定のサーバ上パス (filePath) から JSON を取得し、
86
+ * ブラウザで downloadFileName という名前でダウンロードさせる。
87
+ *
88
+ * @param {string} downloadFileName - 例: "data.geojson" / "result.json"
89
+ * @param {string} filePath - 例: "/public/out/data.geojson" or "https://example.com/data.json"
90
+ * @param {object} [opts]
91
+ * @param {number} [opts.timeoutMs=15000] - タイムアウト(ms)
92
+ * @param {RequestInit} [opts.fetchInit] - fetch の追加オプション(ヘッダ/credentials等)
93
+ */
94
+ async function downloadJSONFile(downloadFileName, filePath, opts = {}) {
95
+ const { timeoutMs = 15000, fetchInit = {} } = opts;
96
+
97
+ // タイムアウト制御
98
+ const ac = new AbortController();
99
+ const timer = setTimeout(() => ac.abort(new Error('timeout')), timeoutMs);
100
+
101
+ try {
102
+ const res = await fetch(filePath, {
103
+ method: 'GET',
104
+ headers: { 'Accept': 'application/json', ...(fetchInit.headers || {}) },
105
+ signal: ac.signal,
106
+ // CORS が必要なら credentials / mode などを fetchInit で渡す(例: { credentials: 'include' })
107
+ ...fetchInit,
108
+ });
109
+
110
+ if (!res.ok) {
111
+ throw new Error(`HTTP ${res.status} ${res.statusText} for ${filePath}`);
112
+ }
113
+
114
+ // そのまま Blob 化(JSON で返ってくる想定)
115
+ // MIME が不正のときもこちらで application/json に寄せる
116
+ const blob = await res.blob();
117
+ const type = res.headers.get('content-type');
118
+ const jsonBlob = type && type.includes('application/json')
119
+ ? blob
120
+ : new Blob([await blob.arrayBuffer()], { type: 'application/json;charset=utf-8' });
121
+
122
+ const url = URL.createObjectURL(jsonBlob);
123
+ try {
124
+ const a = document.createElement('a');
125
+ a.href = url;
126
+ a.download = downloadFileName; // ダウンロード名を強制
127
+ document.body.appendChild(a);
128
+ a.click();
129
+ a.remove();
130
+ } finally {
131
+ // 生成したオブジェクトURLは必ず破棄
132
+ URL.revokeObjectURL(url);
133
+ }
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
package/views/index.ejs CHANGED
@@ -11,7 +11,9 @@
11
11
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="anonymous" />
12
12
  <!-- Leaflet.draw CSS -->
13
13
  <link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" crossorigin="anonymous" />
14
- <link rel="stylesheet" href="/style.css" />
14
+ <link rel="stylesheet" href="style.css" />
15
+ <!-- Toastify -->
16
+ <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
15
17
  </head>
16
18
 
17
19
  <body>
@@ -99,7 +101,8 @@
99
101
  <!-- Map to Image -->
100
102
  <script src="https://cdn.jsdelivr.net/npm/leaflet-easyprint@2.1.9/dist/bundle.min.js"></script>
101
103
  <link href="https://cdn.jsdelivr.net/npm/leaflet-easyprint@2.1.9/libs/leaflet.min.css" rel="stylesheet">
102
-
104
+ <!-- Toastify -->
105
+ <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
103
106
  <!-- Socket.IO JS -->
104
107
  <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
105
108
  <!-- Visualize.js -->
@@ -145,15 +148,21 @@
145
148
  //console.log('Disconnected from server');
146
149
  });
147
150
 
148
- let sessionId=null;
151
+ let sessionId = null;
149
152
  let visualizers = {};
150
- let layerControl=null;
151
- socket.on("welcome",async (res) => {
153
+ let results = {};
154
+ let files = {};
155
+ let layerControl = null;
156
+ socket.on("welcome", async (res) => {
152
157
  //console.log(`welcome ${res.sessionId} at ${new Date(res.time).toLocaleTimeString()}`);
153
- console.log("VIS",res.visualizers);
158
+ //console.log("VIS", res.visualizers);
154
159
  sessionId = res.sessionId;
155
- visualizers = Object.fromEntries(await Promise.all(res.visualizers.map(async vis => { const m = await import(new URL(`./visualizer/${vis}/web.js`, import.meta.url).href); return [vis, m.entry ?? m.default ?? m[vis]]; })));
156
- console.log(visualizers);
160
+ visualizers = Object.fromEntries(await Promise.all(res.visualizers.map(async vis => {
161
+ const m = await import(new URL(`./visualizer/${vis}/web.js`,
162
+ import.meta.url).href);
163
+ return [vis, m.entry ?? m.default ?? m[vis]];
164
+ })));
165
+ //console.log(visualizers);
157
166
  });
158
167
 
159
168
  function generateLegend(legends) {
@@ -222,46 +231,124 @@
222
231
  crawled: acc.crawled + (g.crawled ?? 0),
223
232
  total: acc.total + (g.total ?? 0)
224
233
  }), {
225
- crawled: 0,
226
- total: 0
227
- }
234
+ crawled: 0,
235
+ total: 0
236
+ }
228
237
  );
229
238
  if (total == 0) {
230
239
  crawled = 1, total = 1;
231
240
  }
232
- const percent = Math.round((crawled / total) * 100);
233
- console.log(`[Done] ${crawled} / ${total} --> ${percent}`);
241
+ const percent = Math.round((crawled / total) * 100000) / 1000;
242
+ //console.log(`[Done] ${crawled} / ${total} --> ${percent}`);
234
243
  setProgress(document.getElementById("progressCrawl"), percent);
235
244
  //ここでHexのプログレスグラデーションレイヤのOpacity調整
236
245
  highlightHexById(res.hexId, 1 - res.progress[res.hexId].percent, "#263238");
237
246
  });
238
247
 
239
248
  function isPlainObject(a) {
240
- if (a === null || typeof a !== 'object') return false; // 原始値/関数など除外
249
+ if (a === null || typeof a !== 'object') return false; // 原始値/関数など除外
241
250
  const proto = Object.getPrototypeOf(a);
242
- return proto === Object.prototype || proto === null; // {} / Object.create(null)
251
+ return proto === Object.prototype || proto === null; // {} / Object.create(null)
243
252
  }
253
+ socket.on("toast", (arg) => {
254
+ const option = {
255
+ text: "",
256
+ duration: arg.close ? -1 : 5000,
257
+ close: false,
258
+ gravity: 'bottom',
259
+ position: 'center',
260
+ className: "toast-" + arg.class,
261
+ ...arg
262
+ };
263
+ Toastify(option).showToast();
264
+ //https://apvarun.github.io/toastify-js/#
265
+ });
266
+ socket.on("result-file", async(arg, callback) => {
267
+ try {
268
+ const file_path = '/out/result.' + arg.resultId + '.json';
269
+ latestFile = file_path;
270
+ files[arg.resultId] = file_path;
271
+ console.log("file path",file_path);
272
+ results[arg.resultId] = await fetchJsonObject(file_path);
273
+ console.log('結果ファイル取得完了');
274
+ } catch (e) {
275
+ console.error('結果ファイル取得失敗:', e);
276
+ }
277
+ //console.log("ARK");
278
+ callback({
279
+ pong: Date.now(),
280
+ ok: true
281
+ }); // only one argument is expected
282
+ });
283
+ socket.on("result-chunk", (arg, callback) => {
284
+ if (arg.progress) {
285
+ setProgress(document.getElementById("progressCrawl"), Math.round((arg.progress.current / arg.progress.total) * 100000) / 1000);
286
+ }
287
+ results[arg.resultId] ??= {};
288
+ if (arg.kind === "primitive" || arg.kind === "null") {
289
+ //'number' | 'string' | 'boolean' | 'bigint' | 'symbol' | 'undefined'
290
+ //console.log(arg.path,arg.value);
291
+ switch (arg.type) {
292
+ case 'number':
293
+ setAt(results[arg.resultId], arg.path, Number(arg.value));
294
+ break;
295
+ case 'boolean':
296
+ setAt(results[arg.resultId], arg.path, arg.value);
297
+ case 'string':
298
+ setAt(results[arg.resultId], arg.path, String(arg.value));
299
+ break;
300
+ case 'bigint':
301
+ setAt(results[arg.resultId], arg.path, BigInt(arg.value));
302
+ break;
303
+ case 'symbol':
304
+ setAt(results[arg.resultId], arg.path, Symbol(arg.value));
305
+ break;
306
+ default:
307
+ setAt(results[arg.resultId], arg.path, null);
308
+ }
309
+ } else if (arg.kind === "object") {
310
+ setAt(results[arg.resultId], arg.path, {});
311
+ } else if (arg.kind === "array") {
312
+ setAt(results[arg.resultId], arg.path, []);
313
+ }
314
+ //console.log("ARK");
315
+ callback({
316
+ pong: Date.now(),
317
+ ok: true
318
+ }); // only one argument is expected
319
+ });
244
320
 
245
321
  let latestResult = {};
322
+ let latestFile = null;
246
323
  const overlays = {};
247
- socket.on('result',async (res) => {
324
+ socket.on('result', async (res, callback) => {
325
+ //console.log("result");
248
326
  latestResult["_"]["visualizers"] = res.visualizers;
249
327
  latestResult["_"]["plugin"] = res.plugin;
250
328
  clearHexHighlight();
251
- latestResult = {...latestResult, ...res.geoJson};
252
- console.log("result");
253
- console.log("geoJSON",res.geoJson);
254
- for(const vis in visualizers){
255
- const layers = await visualizers[vis](map, res.geoJson[vis], {palette:res.palette});
256
- console.log("【レイヤ】",layers);
257
- if((layers == null) ){
329
+ if (res.geoJson == null) {
330
+ res.geoJson = results[res.resultId];
331
+ } else {
332
+ results[res.resultId] = res.geoJson;
333
+ }
334
+ latestResult = {
335
+ ...latestResult,
336
+ ...res.geoJson
337
+ };
338
+ for (const vis in visualizers) {
339
+ const layers = await visualizers[vis](map, res.geoJson[vis], {
340
+ palette: res.palette
341
+ });
342
+ //console.log("【レイヤ】\n", JSON.stringify(layers,null,4));
343
+ if ((layers == null)) {
258
344
  //SKIP
259
- }else if(layers.hasOwnProperty("type") && layers["type"]=="FeatureCollection"){
345
+ } else if (layers.hasOwnProperty("type") && layers["type"] == "FeatureCollection") {
260
346
  //レイヤ一つ
261
- overlays[`[${vis}]`] = layers;
262
- }else{
263
- for(const name in layers){
264
- overlays[`[${vis}] ${name}`] = layers[name];
347
+ overlays[`[${vis}]`] = layers;
348
+ } else {
349
+ for (const name in layers) {
350
+ console.log("Lay", vis, name);
351
+ overlays[`[${vis}] ${name}`] = layers[name];
265
352
  }
266
353
  }
267
354
  }
@@ -280,7 +367,7 @@
280
367
  });
281
368
  }
282
369
  ul.innerHTML = generateLegend(palette);
283
- console.log(overlays);
370
+ //console.log(overlays);
284
371
  layerControl = L.control.layers([baseLayer], {
285
372
  ...overlays,
286
373
  "Hex Grid": hexLayer,
@@ -290,16 +377,24 @@
290
377
  });
291
378
  layerControl.addTo(map);
292
379
  document.getElementById("map_legend")?.appendChild(ul);
293
- document.getElementById("download-json").addEventListener("click", () => {
380
+ document.getElementById("download-json").addEventListener("click", async () => {
294
381
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
295
- downloadJSON(`data-${stamp}.json`, latestResult);
382
+ if(latestFile===null){
383
+ downloadJSON(`splatone-${stamp}.json`, latestResult);
384
+ }else{
385
+ await downloadJSONFile(`splatone-${stamp}.json`, latestFile);
386
+ }
296
387
  let sessionId = null;
297
388
  });
389
+ callback({
390
+ pong: Date.now(),
391
+ ok: true
392
+ });
298
393
  });
299
394
 
300
395
  // 地図
301
396
  const map = L.map('map', {
302
- preferCanvas:true,
397
+ preferCanvas: true,
303
398
  zoomControl: true
304
399
  }).setView([lat, lon], 12);
305
400
  const baseLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
@@ -348,11 +443,11 @@
348
443
  L.easyPrint({
349
444
  title: 'マップの画像化とダウンロード',
350
445
  position: 'bottomright',
351
- sizeModes: [ 'A4Landscape'],
446
+ sizeModes: ['A4Landscape'],
352
447
  exportOnly: true,
353
448
  filename: 'splatone_export'
354
449
  }).addTo(map);
355
-
450
+
356
451
  // レイヤ参照
357
452
  let hexLayer = null;
358
453
  let triLayer = null;
@@ -603,7 +698,7 @@
603
698
  const cellSize = document.getElementById('cellSize').value.trim();
604
699
  const units = document.getElementById('units').value.trim();
605
700
  const keywords = document.getElementById('keywords').value.trim();
606
- latestResult["_"]??={};
701
+ latestResult["_"] ??= {};
607
702
  latestResult["_"]["keywords"] = keywords;
608
703
  socket.once("hexgrid", (res) => {
609
704
  if (res.sessionId) sessionId = res.sessionId;
@@ -611,7 +706,7 @@
611
706
  const hex = res.hex;
612
707
  const triangles = res.triangles;
613
708
  latestResult["_"]["hex"] = hex;
614
- latestResult["_"]["triangles"]=[triangles];
709
+ latestResult["_"]["triangles"] = [triangles];
615
710
  renderLayers(hex, triangles);
616
711
  });
617
712
  socket.emit("target", {
@@ -1,6 +1,5 @@
1
1
  import { VisualizerBase } from '../../lib/VisualizerBase.js';
2
2
  import { featureCollection } from "@turf/turf";
3
- import { writeFileSync } from 'node:fs';
4
3
 
5
4
  export default class BulkyVisualizer extends VisualizerBase {
6
5
  static id = 'bulky'; // 一意ID(フォルダ名と一致させると運用しやすい)
@@ -1,9 +1,11 @@
1
1
  let booted = false;
2
2
  export default async function main(map, geojson, options = {}) {
3
+ console.log("main");
3
4
  if (booted) return;
4
5
  booted = true;
5
6
  const layers = {};
6
7
  for (const cat in geojson) {
8
+ console.log(cat);
7
9
  const layer = addGeoJSONLayer(map, geojson[cat], {
8
10
  pointToLayer: (feature, latlng) => {
9
11
  return L.circleMarker(latlng, {