splatone 0.0.3 → 0.0.5
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 +28 -17
- package/assets/screenshot_massive_points_bulky.png +0 -0
- package/crawler.js +118 -17
- package/lib/paletteGenerator.js +1 -1
- package/lib/splatone.js +135 -0
- package/package.json +1 -1
- package/plugins/flickr/worker.js +5 -4
- package/public/style.css +11 -1
- package/public/visualizer.js +15 -0
- package/views/index.ejs +99 -34
- package/visualizer/bulky/node.js +0 -1
- package/visualizer/bulky/web.js +2 -0
package/README.md
CHANGED
|
@@ -13,8 +13,14 @@ SNSのジオタグ付きポストをキーワードに基づいて収集する
|
|
|
13
13
|
|
|
14
14
|
## 既知のバグ
|
|
15
15
|
|
|
16
|
-
- JSON.stringify(json)
|
|
17
|
-
|
|
16
|
+
- <s>JSON.stringify(json)で変換できる大きさに制限があり、数十万件等の大きな結果を生み出すクエリは、クロール後、結果のブラウザへの転送で失敗します。</s>
|
|
17
|
+
- サイズの問題は一部解決しました。ただし、**データの保存(ダウンロード)はまだできません。**
|
|
18
|
+
- 問題はサーバからブラウザへ結果を渡す時にJSON.stringify(obj)で結果データをJSON文字列にする時にヒープを食いつぶしています。 とりあえず可視化だけ出来るように、データが大きいと自動で分割して送信するようにしています。ただし、送受信に時間がかかります。(左下のプログレスバーに進捗が表示されます)
|
|
19
|
+
- **--chopped**オプションをつけると強制的に分割送信します。(ただ遅くなるだけなので意味ないです)
|
|
20
|
+
- データ保存(ダウンロード)する際にブラウザ上でJSON.stringify(json)を呼ぶ必要があり、そこで詰まります。これの解決策は別途実装します。
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
18
24
|
# 使い方
|
|
19
25
|
|
|
20
26
|
- [Node.js](https://nodejs.org/ja/download)をインストール後、npxで実行します。
|
|
@@ -27,11 +33,15 @@ $ npx -y -- splatone@latest crawler --help
|
|
|
27
33
|
使い方: crawler.js [options]
|
|
28
34
|
|
|
29
35
|
Basic Options
|
|
30
|
-
-p, --plugin 実行するプラグイン
|
|
31
|
-
[文字列] [必須] [選択してください: "flickr"]
|
|
36
|
+
-p, --plugin 実行するプラグイン[文字列] [必須] [選択してください: "flickr"]
|
|
32
37
|
-o, --options プラグインオプション [文字列] [デフォルト: "{}"]
|
|
33
38
|
-k, --keywords 検索キーワード(|区切り) [文字列] [デフォルト:
|
|
34
39
|
"nature,tree,flower|building,house|water,sea,river,pond"]
|
|
40
|
+
-c, --chopped 大きなデータを細分化して送信する
|
|
41
|
+
[真偽] [デフォルト: true]
|
|
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
|
|
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```
|
|
@@ -90,7 +100,7 @@ APIキーは以下の3種類の方法で与える事ができます
|
|
|
90
100
|
|
|
91
101
|
### Bulky: 全ての点を地図上にポイントする
|
|
92
102
|
|
|
93
|
-

|
|
103
|
+

|
|
94
104
|
|
|
95
105
|
* クエリは水域と通路・橋梁・ランドマークを色分けしたもの、上記スクリーンショットはベネチア付近のデータ
|
|
96
106
|
```shell
|
|
@@ -98,7 +108,7 @@ $ node crawler.js -p flickr -o '{"flickr":{"API_KEY":"aaaaaaaaaaaaaaaaaaaaaaaaaa
|
|
|
98
108
|
```
|
|
99
109
|
|
|
100
110
|
### Marker Cluster: 高密度の地点はマーカーをまとめて表示する
|
|
101
|
-

|
|
111
|
+

|
|
102
112
|
|
|
103
113
|
* クエリは水域と通路・橋梁・ランドマークを色分けしたもの、上記スクリーンショットはベネチア付近のデータ
|
|
104
114
|
```shell
|
|
@@ -122,13 +132,6 @@ seaだけでは集められるポストが限定されるので、同様の意
|
|
|
122
132
|
-k "sea,ocean|mountain,mount"
|
|
123
133
|
```
|
|
124
134
|
|
|
125
|
-
### 実行例 (海岸線と山岳の分布)
|
|
126
|
-
|
|
127
|
-
```shell
|
|
128
|
-
$ node crawler.js -p flickr -o '{"flickr":{"API_KEY":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}' -k "sea,ocean|mountain,mount" --vis-bulky
|
|
129
|
-
```
|
|
130
|
-

|
|
131
|
-
|
|
132
135
|
### カテゴリ名の指定
|
|
133
136
|
|
|
134
137
|
複数の類語キーワードを指定した場合、それらをまとめるカテゴリ名を付ける事ができます。たとえはsea,oceanに『海域』、mountain,mountに『山岳』とカテゴリ名をつけるには以下のように指定します。なお、指定は必須ではありません。指定しない場合はそれぞれ1番目のキーワード(seaとmountain)がカテゴリ名になります。
|
|
@@ -137,17 +140,25 @@ $ node crawler.js -p flickr -o '{"flickr":{"API_KEY":"aaaaaaaaaaaaaaaaaaaaaaaaaa
|
|
|
137
140
|
-k "海域=sea,ocean|山岳=mountain,mount"
|
|
138
141
|
```
|
|
139
142
|
|
|
143
|
+
### 実行例 (海岸線と山岳の分布)
|
|
144
|
+
|
|
145
|
+
```shell
|
|
146
|
+
$ node crawler.js -p flickr -o '{"flickr":{"API_KEY":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}' -k "sea,ocean|mountain,mount" --vis-bulky
|
|
147
|
+
```
|
|
148
|
+

|
|
149
|
+
|
|
150
|
+
|
|
140
151
|
## ダウンロード
|
|
141
152
|
|
|
142
153
|
### 画像のダウンロード
|
|
143
154
|
|
|
144
155
|
* 結果の地図を画像(PNG形式)としてダウンロードするには、画面右下のアイコンをクリックしてください。
|
|
145
156
|
|
|
146
|
-

|
|
157
|
+

|
|
147
158
|
|
|
148
159
|
### データのダウンロード
|
|
149
160
|
|
|
150
161
|
* クロール結果をデータとしてダウンロードしたい場合は凡例の下にあるエクスポートボタンをクリックしてください。
|
|
151
162
|
|
|
152
|
-

|
|
163
|
+

|
|
153
164
|
|
|
Binary file
|
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 } 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,22 @@ try {
|
|
|
131
133
|
type: 'string',
|
|
132
134
|
default: 'nature,tree,flower|building,house|water,sea,river,pond',
|
|
133
135
|
description: '検索キーワード(|区切り)'
|
|
136
|
+
}).option('chopped', {
|
|
137
|
+
group: 'Basic Options',
|
|
138
|
+
alias: 'c',
|
|
139
|
+
type: 'boolean',
|
|
140
|
+
default: false,
|
|
141
|
+
description: '大きなデータを細分化して送信する'
|
|
142
|
+
// }).option('debug-save', {
|
|
143
|
+
// group: 'Debug',
|
|
144
|
+
// type: 'boolean',
|
|
145
|
+
// default: false,
|
|
146
|
+
// description: 'サーバ側にクロールデータを保存'
|
|
147
|
+
}).option('debug-verbose', {
|
|
148
|
+
group: 'Debug',
|
|
149
|
+
type: 'boolean',
|
|
150
|
+
default: false,
|
|
151
|
+
description: 'デバッグ情報出力'
|
|
134
152
|
})
|
|
135
153
|
.version()
|
|
136
154
|
.coerce({
|
|
@@ -154,7 +172,6 @@ try {
|
|
|
154
172
|
return true;
|
|
155
173
|
});
|
|
156
174
|
const argv = await yargv.parseAsync();
|
|
157
|
-
|
|
158
175
|
const visualizers = {};
|
|
159
176
|
for (const vis of Object.keys(all_visualizers).filter(v => argv[`vis-${v}`])) {
|
|
160
177
|
visualizers[vis] = new all_visualizers[vis]();
|
|
@@ -164,11 +181,15 @@ try {
|
|
|
164
181
|
try {
|
|
165
182
|
plugin_options.API_KEY = await loadAPIKey("flickr") ?? plugin_options.API_KEY;
|
|
166
183
|
} catch (e) {
|
|
167
|
-
|
|
184
|
+
if (!plugin_options.API_KEY) {
|
|
185
|
+
console.error("Error loading API key:", e.message);
|
|
186
|
+
}
|
|
168
187
|
//Nothing to do
|
|
169
188
|
}
|
|
170
189
|
await plugins.call(argv.plugin, 'init', plugin_options);
|
|
171
|
-
|
|
190
|
+
if (argv.debugVerbose) {
|
|
191
|
+
console.table([["Visualizer", Object.keys(visualizers)], ["Plugin", argv.plugin]]);
|
|
192
|
+
}
|
|
172
193
|
|
|
173
194
|
/* API Key読み込み */
|
|
174
195
|
async function loadAPIKey(plugin = 'flickr') {
|
|
@@ -182,11 +203,11 @@ try {
|
|
|
182
203
|
await access(file, constants.F_OK | constants.R_OK);
|
|
183
204
|
// 読み込み & トリム
|
|
184
205
|
const raw = await readFile(file, 'utf8');
|
|
185
|
-
console.log(`[API KEY (${plugin}})] Read from FILE`);
|
|
206
|
+
//console.log(`[API KEY (${plugin}})] Read from FILE`);
|
|
186
207
|
key = raw.trim();
|
|
187
208
|
} catch (err) {
|
|
188
209
|
if (Object.prototype.hasOwnProperty.call(process.env, "API_KEY_" + plugin)) {
|
|
189
|
-
console.log(`[API KEY (${plugin}})] Read from ENV`);
|
|
210
|
+
//console.log(`[API KEY (${plugin}})] Read from ENV`);
|
|
190
211
|
key = process.env["API_KEY_" + plugin] ?? null;
|
|
191
212
|
} else {
|
|
192
213
|
const code = /** @type {{ code?: string }} */(err).code || 'UNKNOWN';
|
|
@@ -325,7 +346,10 @@ try {
|
|
|
325
346
|
console.warn("invalid sessionId:", req.sessionId);
|
|
326
347
|
return;
|
|
327
348
|
}
|
|
328
|
-
const { bbox, drawn, cellSize =
|
|
349
|
+
const { bbox, drawn, cellSize = 0, units = 'kilometers', tags = 'sea,beach|mountain,forest' } = req.query;
|
|
350
|
+
if (cellSize == 0) {
|
|
351
|
+
//セルサイズ自動決定()
|
|
352
|
+
}
|
|
329
353
|
const fallbackBbox = [139.55, 35.53, 139.92, 35.80];
|
|
330
354
|
let bboxArray = fallbackBbox;
|
|
331
355
|
|
|
@@ -527,7 +551,7 @@ try {
|
|
|
527
551
|
return piscina.run(data, { filename });
|
|
528
552
|
}
|
|
529
553
|
|
|
530
|
-
const nParallel = Math.max(1, Math.min(
|
|
554
|
+
const nParallel = Math.max(1, Math.min(6, os.cpus().length))
|
|
531
555
|
const piscina = new Piscina({
|
|
532
556
|
minThreads: 1,
|
|
533
557
|
maxThreads: nParallel,
|
|
@@ -547,7 +571,9 @@ try {
|
|
|
547
571
|
crawlers[p.sessionId][rtn.hexId][rtn.category].crawled ??= 0;
|
|
548
572
|
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
573
|
crawlers[p.sessionId][rtn.hexId][rtn.category].crawled = crawlers[p.sessionId][rtn.hexId][rtn.category].ids.size;
|
|
550
|
-
|
|
574
|
+
if (argv.debugVerbose) {
|
|
575
|
+
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}`);
|
|
576
|
+
}
|
|
551
577
|
const photos = featureCollection(rtn.photos.features.filter((f) => !duplicates.has(f.properties.id)));
|
|
552
578
|
crawlers[p.sessionId][rtn.hexId][rtn.category].items
|
|
553
579
|
= concatFC(crawlers[p.sessionId][rtn.hexId][rtn.category].items, photos);
|
|
@@ -559,22 +585,97 @@ try {
|
|
|
559
585
|
//console.log("next max_upload_date:", p.max_upload_date);
|
|
560
586
|
api.emit('splatone:start', p);
|
|
561
587
|
} else if (finish) {
|
|
562
|
-
|
|
588
|
+
if (argv.debugVerbose) {
|
|
589
|
+
console.table(stats);
|
|
590
|
+
}
|
|
563
591
|
api.emit('splatone:finish', p);
|
|
564
592
|
}
|
|
565
593
|
});
|
|
566
594
|
|
|
567
595
|
await subscribe('splatone:finish', async p => {
|
|
596
|
+
const resultId = uniqid();
|
|
568
597
|
const result = crawlers[p.sessionId];
|
|
569
598
|
const target = targets[p.sessionId];
|
|
599
|
+
|
|
600
|
+
let geoJson = Object.fromEntries(Object.entries(visualizers).map(([vis, v]) => [vis, v.getFutureCollection(result, target)]));
|
|
601
|
+
|
|
570
602
|
console.log('[splatone:finish]');
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
603
|
+
try {
|
|
604
|
+
if (argv.chopped) {
|
|
605
|
+
throw new RangeError("Invalid string length");
|
|
606
|
+
}
|
|
607
|
+
await io.to(p.sessionId).timeout(5000).emitWithAck('result', {
|
|
608
|
+
resultId,
|
|
609
|
+
geoJson,
|
|
610
|
+
palette: target["splatonePalette"],
|
|
611
|
+
visualizers: Object.keys(visualizers),
|
|
612
|
+
plugin: argv.plugin
|
|
613
|
+
});
|
|
614
|
+
} catch (e) {
|
|
615
|
+
if (e instanceof RangeError && /Invalid string length/.test(String(e.message))) {
|
|
616
|
+
const msg = (argv.chopped ? "ユーザの指定により" : "結果サイズが巨大なので") + "断片化モードでクライアントに送ります";
|
|
617
|
+
if (argv.debugVerbose) {
|
|
618
|
+
console.warn("[WARN] " + msg);
|
|
619
|
+
}
|
|
620
|
+
io.to(p.sessionId).timeout(5000).emit('toast', {
|
|
621
|
+
text: msg,
|
|
622
|
+
class: "warning"
|
|
623
|
+
});
|
|
624
|
+
//サイズ集計
|
|
625
|
+
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 })();
|
|
626
|
+
let current_features = 0
|
|
627
|
+
await dfsObject(geoJson, async ({ path, value, kind, type }) => {
|
|
628
|
+
if (path.length !== 0) {
|
|
629
|
+
if (kind === "primitive" || kind === "null") {
|
|
630
|
+
//console.log(path.join("."), "=>", `(${kind}:${type})`, value);
|
|
631
|
+
const ackrtn = await io.to(p.sessionId).timeout(5000).emitWithAck('result-chunk', {
|
|
632
|
+
resultId,
|
|
633
|
+
path,
|
|
634
|
+
kind,
|
|
635
|
+
type,
|
|
636
|
+
value
|
|
637
|
+
});
|
|
638
|
+
//console.log("\tACK", ackrtn);
|
|
639
|
+
} else if (kind === "object") {
|
|
640
|
+
//console.log(path.join("."), "=>", `(${kind}:${type})`);
|
|
641
|
+
if (path.at(-2) == "features" && Number.isInteger(path.at(-1))) {
|
|
642
|
+
current_features++;
|
|
643
|
+
}
|
|
644
|
+
const ackrtn = await io.to(p.sessionId).timeout(5000).emitWithAck('result-chunk', {
|
|
645
|
+
resultId,
|
|
646
|
+
path,
|
|
647
|
+
kind,
|
|
648
|
+
type,
|
|
649
|
+
progress: { current: current_features, total: total_features }
|
|
650
|
+
});
|
|
651
|
+
//console.log("\tACK", ackrtn);
|
|
652
|
+
} else if (kind === "array") {
|
|
653
|
+
//console.log(path.join("."), "=>", `(${kind}:${type})`);
|
|
654
|
+
const ackrtn = await io.to(p.sessionId).timeout(5000).emitWithAck('result-chunk', {
|
|
655
|
+
resultId,
|
|
656
|
+
path,
|
|
657
|
+
kind,
|
|
658
|
+
type
|
|
659
|
+
});
|
|
660
|
+
//console.log("\tACK", ackrtn);
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
//console.log("SKIP---------------------");
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
//console.log("finish chunks");
|
|
667
|
+
io.to(p.sessionId).timeout(10000).emitWithAck('result', {
|
|
668
|
+
resultId,
|
|
669
|
+
geoJson: null, /*geoJsonは送らない*/
|
|
670
|
+
palette: target["splatonePalette"],
|
|
671
|
+
visualizers: Object.keys(visualizers),
|
|
672
|
+
plugin: argv.plugin
|
|
673
|
+
});
|
|
674
|
+
console.log("[Done]");
|
|
675
|
+
} else {
|
|
676
|
+
throw e; // 他の例外はそのまま
|
|
677
|
+
}
|
|
678
|
+
}
|
|
578
679
|
});
|
|
579
680
|
|
|
580
681
|
server.listen(port, async () => {
|
package/lib/paletteGenerator.js
CHANGED
|
@@ -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
|
package/lib/splatone.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// 各種関数のまとめ場
|
|
2
|
+
// @turf/turf v6/v7 どちらでもOK(rhumbDistanceはv7で統合済み)
|
|
3
|
+
import { point, distance, rhumbDistance, bbox as turfBbox } from '@turf/turf';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* bbox の幅・高さを指定単位で返す
|
|
7
|
+
* @param {number[]} bbox [minX,minY,maxX,maxY] (経度・緯度, WGS84)
|
|
8
|
+
* @param {"meters"|"kilometers"|"miles"|"nauticalmiles"|"inches"|"yards"|"feet"} units
|
|
9
|
+
* @param {"geodesic"|"rhumb"} method 距離の定義:大円 or 等角航程
|
|
10
|
+
* @returns {{width:number, height:number, units:string}}
|
|
11
|
+
*/
|
|
12
|
+
export function bboxSize(bbox, units = "kilometers", method = "geodesic") {
|
|
13
|
+
const [minX, minY, maxX, maxY] = bbox;
|
|
14
|
+
|
|
15
|
+
// 中央経度・緯度(幅は中緯度、 高さは中経度で測るのが安定)
|
|
16
|
+
const midLat = (minY + maxY) / 2;
|
|
17
|
+
const midLon = (minX + maxX) / 2;
|
|
18
|
+
|
|
19
|
+
// 測りたい2点を作成
|
|
20
|
+
const west = point([minX, midLat]);
|
|
21
|
+
const east = point([maxX, midLat]);
|
|
22
|
+
const south = point([midLon, minY]);
|
|
23
|
+
const north = point([midLon, maxY]);
|
|
24
|
+
|
|
25
|
+
// 距離関数を選択
|
|
26
|
+
const distFn = method === "rhumb" ? rhumbDistance : distance;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
width: distFn(west, east, { units }),
|
|
30
|
+
height: distFn(south, north, { units }),
|
|
31
|
+
units
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* GeoJSON(Feature/FeatureCollection/Geometry)から計算したいときのヘルパー
|
|
37
|
+
*/
|
|
38
|
+
export function bboxSizeOf(geojson, units = "kilometers", method = "geodesic") {
|
|
39
|
+
return bboxSize(turfBbox(geojson), units, method);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
// visit({ key, value, path, parent, depth, kind, type, isLeaf, isNull })
|
|
44
|
+
// kind: 'array' | 'object' | 'primitive' | 'null'
|
|
45
|
+
// type: primitive は typeof、配列は 'Array'、オブジェクトは constructor.name
|
|
46
|
+
export async function dfsObject(root, visit, options = {}) {
|
|
47
|
+
const {
|
|
48
|
+
includeArrays = true,
|
|
49
|
+
maxDepth = Infinity,
|
|
50
|
+
stopOnTrue = false,
|
|
51
|
+
} = options;
|
|
52
|
+
|
|
53
|
+
const meta = describeType(root);
|
|
54
|
+
|
|
55
|
+
// root が非オブジェクトの場合も visit だけ呼んで終了
|
|
56
|
+
if (meta.isLeaf) {
|
|
57
|
+
await visit({
|
|
58
|
+
key: undefined,
|
|
59
|
+
value: root,
|
|
60
|
+
path: [],
|
|
61
|
+
parent: null,
|
|
62
|
+
depth: 0,
|
|
63
|
+
...meta,
|
|
64
|
+
});
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const seen = new WeakSet();
|
|
69
|
+
const stack = [
|
|
70
|
+
{ key: undefined, value: root, path: /** @type {(string|number)[]} */([]), parent: null, depth: 0 }
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
while (stack.length) {
|
|
74
|
+
const node = stack.pop();
|
|
75
|
+
const { key, value, path, parent } = node;
|
|
76
|
+
|
|
77
|
+
const m = describeType(value);
|
|
78
|
+
const depth = path.length; // path 配列の長さをそのまま深さに
|
|
79
|
+
const ret = await visit({ key, value, path, parent, depth, ...m });
|
|
80
|
+
if (stopOnTrue && ret === true) return true;
|
|
81
|
+
|
|
82
|
+
if (m.isLeaf) continue; // primitive/null は展開しない
|
|
83
|
+
if (seen.has(value)) continue; // 循環参照対策
|
|
84
|
+
seen.add(value);
|
|
85
|
+
|
|
86
|
+
if (depth >= maxDepth) continue;
|
|
87
|
+
|
|
88
|
+
const isArr = Array.isArray(value);
|
|
89
|
+
if (!includeArrays && isArr) continue; // 配列を辿らない設定
|
|
90
|
+
|
|
91
|
+
const entries = isArr
|
|
92
|
+
? value.map((v, i) => [i, v]) // index は number
|
|
93
|
+
: Object.entries(value); // key は string
|
|
94
|
+
|
|
95
|
+
// 左→右の自然な DFS にしたいので逆順 push
|
|
96
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
97
|
+
const [k, v] = entries[i]; // k: string|number
|
|
98
|
+
const childPath = path.concat([k]);
|
|
99
|
+
stack.push({
|
|
100
|
+
key: k,
|
|
101
|
+
value: v,
|
|
102
|
+
path: childPath,
|
|
103
|
+
parent: value,
|
|
104
|
+
depth: childPath.length,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function describeType(value) {
|
|
112
|
+
if (value === null) {
|
|
113
|
+
return { kind: "null", type: "null", isLeaf: true, isNull: true };
|
|
114
|
+
}
|
|
115
|
+
const t = typeof value;
|
|
116
|
+
if (t !== "object") {
|
|
117
|
+
return {
|
|
118
|
+
kind: "primitive",
|
|
119
|
+
type: t, // 'number' | 'string' | 'boolean' | 'bigint' | 'symbol' | 'undefined'
|
|
120
|
+
isLeaf: true,
|
|
121
|
+
isNull: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
return { kind: "array", type: "Array", isLeaf: false, isNull: false };
|
|
126
|
+
}
|
|
127
|
+
const ctor = value?.constructor?.name ?? "Object";
|
|
128
|
+
return { kind: "object", type: ctor, isLeaf: false, isNull: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
export default {
|
|
133
|
+
dfsObject,bboxSize
|
|
134
|
+
};
|
|
135
|
+
//const {width,height} = bboxSize(b, "kilometers", "geodesic");
|
package/package.json
CHANGED
package/plugins/flickr/worker.js
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
package/public/visualizer.js
CHANGED
|
@@ -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, {
|
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="
|
|
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,20 @@
|
|
|
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
|
|
151
|
-
|
|
153
|
+
let results = {};
|
|
154
|
+
let layerControl = null;
|
|
155
|
+
socket.on("welcome", async (res) => {
|
|
152
156
|
//console.log(`welcome ${res.sessionId} at ${new Date(res.time).toLocaleTimeString()}`);
|
|
153
|
-
console.log("VIS",res.visualizers);
|
|
157
|
+
//console.log("VIS", res.visualizers);
|
|
154
158
|
sessionId = res.sessionId;
|
|
155
|
-
visualizers = Object.fromEntries(await Promise.all(res.visualizers.map(async vis => {
|
|
156
|
-
|
|
159
|
+
visualizers = Object.fromEntries(await Promise.all(res.visualizers.map(async vis => {
|
|
160
|
+
const m = await import(new URL(`./visualizer/${vis}/web.js`,
|
|
161
|
+
import.meta.url).href);
|
|
162
|
+
return [vis, m.entry ?? m.default ?? m[vis]];
|
|
163
|
+
})));
|
|
164
|
+
//console.log(visualizers);
|
|
157
165
|
});
|
|
158
166
|
|
|
159
167
|
function generateLegend(legends) {
|
|
@@ -222,46 +230,102 @@
|
|
|
222
230
|
crawled: acc.crawled + (g.crawled ?? 0),
|
|
223
231
|
total: acc.total + (g.total ?? 0)
|
|
224
232
|
}), {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
233
|
+
crawled: 0,
|
|
234
|
+
total: 0
|
|
235
|
+
}
|
|
228
236
|
);
|
|
229
237
|
if (total == 0) {
|
|
230
238
|
crawled = 1, total = 1;
|
|
231
239
|
}
|
|
232
|
-
const percent = Math.round((crawled / total) *
|
|
233
|
-
console.log(`[Done] ${crawled} / ${total} --> ${percent}`);
|
|
240
|
+
const percent = Math.round((crawled / total) * 100000) / 1000;
|
|
241
|
+
//console.log(`[Done] ${crawled} / ${total} --> ${percent}`);
|
|
234
242
|
setProgress(document.getElementById("progressCrawl"), percent);
|
|
235
243
|
//ここでHexのプログレスグラデーションレイヤのOpacity調整
|
|
236
244
|
highlightHexById(res.hexId, 1 - res.progress[res.hexId].percent, "#263238");
|
|
237
245
|
});
|
|
238
246
|
|
|
239
247
|
function isPlainObject(a) {
|
|
240
|
-
if (a === null || typeof a !== 'object') return false;
|
|
248
|
+
if (a === null || typeof a !== 'object') return false; // 原始値/関数など除外
|
|
241
249
|
const proto = Object.getPrototypeOf(a);
|
|
242
|
-
return proto === Object.prototype || proto === null;
|
|
250
|
+
return proto === Object.prototype || proto === null; // {} / Object.create(null)
|
|
243
251
|
}
|
|
252
|
+
socket.on("toast", (arg) => {
|
|
253
|
+
const option = {
|
|
254
|
+
text:"",
|
|
255
|
+
duration:arg.close?-1:5000,
|
|
256
|
+
close:false,
|
|
257
|
+
gravity:'bottom',
|
|
258
|
+
position:'center',
|
|
259
|
+
className:"toast-" + arg.class,
|
|
260
|
+
...arg};
|
|
261
|
+
Toastify(option).showToast();
|
|
262
|
+
//https://apvarun.github.io/toastify-js/#
|
|
263
|
+
});
|
|
264
|
+
socket.on("result-chunk", (arg, callback) => {
|
|
265
|
+
if(arg.progress){
|
|
266
|
+
setProgress(document.getElementById("progressCrawl"),Math.round((arg.progress.current/arg.progress.total) * 100000) / 1000);
|
|
267
|
+
}
|
|
268
|
+
results[arg.resultId] ??= {};
|
|
269
|
+
if (arg.kind === "primitive" || arg.kind === "null") {
|
|
270
|
+
//'number' | 'string' | 'boolean' | 'bigint' | 'symbol' | 'undefined'
|
|
271
|
+
//console.log(arg.path,arg.value);
|
|
272
|
+
switch (arg.type) {
|
|
273
|
+
case 'number':
|
|
274
|
+
setAt(results[arg.resultId], arg.path, Number(arg.value));
|
|
275
|
+
break;
|
|
276
|
+
case 'boolean':
|
|
277
|
+
setAt(results[arg.resultId], arg.path, arg.value);
|
|
278
|
+
case 'string':
|
|
279
|
+
setAt(results[arg.resultId], arg.path, String(arg.value));
|
|
280
|
+
break;
|
|
281
|
+
case 'bigint':
|
|
282
|
+
setAt(results[arg.resultId], arg.path, BigInt(arg.value));
|
|
283
|
+
break;
|
|
284
|
+
case 'symbol':
|
|
285
|
+
setAt(results[arg.resultId], arg.path, Symbol(arg.value));
|
|
286
|
+
break;
|
|
287
|
+
default:
|
|
288
|
+
setAt(results[arg.resultId], arg.path, null);
|
|
289
|
+
}
|
|
290
|
+
} else if (arg.kind === "object") {
|
|
291
|
+
setAt(results[arg.resultId], arg.path, {});
|
|
292
|
+
} else if (arg.kind === "array") {
|
|
293
|
+
setAt(results[arg.resultId], arg.path, []);
|
|
294
|
+
}
|
|
295
|
+
//console.log("ARK");
|
|
296
|
+
callback({ pong: Date.now(), ok: true }); // only one argument is expected
|
|
297
|
+
});
|
|
244
298
|
|
|
245
299
|
let latestResult = {};
|
|
246
300
|
const overlays = {};
|
|
247
|
-
socket.on('result',async (res) => {
|
|
301
|
+
socket.on('result', async (res,callback) => {
|
|
302
|
+
//console.log("result");
|
|
248
303
|
latestResult["_"]["visualizers"] = res.visualizers;
|
|
249
304
|
latestResult["_"]["plugin"] = res.plugin;
|
|
250
305
|
clearHexHighlight();
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
306
|
+
if(res.geoJson == null){
|
|
307
|
+
res.geoJson = results[res.resultId];
|
|
308
|
+
}else{
|
|
309
|
+
results[res.resultId] = res.geoJson;
|
|
310
|
+
}
|
|
311
|
+
latestResult = {
|
|
312
|
+
...latestResult,
|
|
313
|
+
...res.geoJson
|
|
314
|
+
};
|
|
315
|
+
for (const vis in visualizers) {
|
|
316
|
+
const layers = await visualizers[vis](map, res.geoJson[vis], {
|
|
317
|
+
palette: res.palette
|
|
318
|
+
});
|
|
319
|
+
//console.log("【レイヤ】\n", JSON.stringify(layers,null,4));
|
|
320
|
+
if ((layers == null)) {
|
|
258
321
|
//SKIP
|
|
259
|
-
}else if(layers.hasOwnProperty("type") && layers["type"]=="FeatureCollection"){
|
|
322
|
+
} else if (layers.hasOwnProperty("type") && layers["type"] == "FeatureCollection") {
|
|
260
323
|
//レイヤ一つ
|
|
261
|
-
overlays[`[${vis}]`] =
|
|
262
|
-
}else{
|
|
263
|
-
for(const name in layers){
|
|
264
|
-
|
|
324
|
+
overlays[`[${vis}]`] = layers;
|
|
325
|
+
} else {
|
|
326
|
+
for (const name in layers) {
|
|
327
|
+
console.log("Lay",vis,name);
|
|
328
|
+
overlays[`[${vis}] ${name}`] = layers[name];
|
|
265
329
|
}
|
|
266
330
|
}
|
|
267
331
|
}
|
|
@@ -280,7 +344,7 @@
|
|
|
280
344
|
});
|
|
281
345
|
}
|
|
282
346
|
ul.innerHTML = generateLegend(palette);
|
|
283
|
-
console.log(overlays);
|
|
347
|
+
//console.log(overlays);
|
|
284
348
|
layerControl = L.control.layers([baseLayer], {
|
|
285
349
|
...overlays,
|
|
286
350
|
"Hex Grid": hexLayer,
|
|
@@ -295,11 +359,12 @@
|
|
|
295
359
|
downloadJSON(`data-${stamp}.json`, latestResult);
|
|
296
360
|
let sessionId = null;
|
|
297
361
|
});
|
|
362
|
+
callback({ pong: Date.now(), ok: true });
|
|
298
363
|
});
|
|
299
364
|
|
|
300
365
|
// 地図
|
|
301
366
|
const map = L.map('map', {
|
|
302
|
-
preferCanvas:true,
|
|
367
|
+
preferCanvas: true,
|
|
303
368
|
zoomControl: true
|
|
304
369
|
}).setView([lat, lon], 12);
|
|
305
370
|
const baseLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
@@ -348,11 +413,11 @@
|
|
|
348
413
|
L.easyPrint({
|
|
349
414
|
title: 'マップの画像化とダウンロード',
|
|
350
415
|
position: 'bottomright',
|
|
351
|
-
sizeModes: [
|
|
416
|
+
sizeModes: ['A4Landscape'],
|
|
352
417
|
exportOnly: true,
|
|
353
418
|
filename: 'splatone_export'
|
|
354
419
|
}).addTo(map);
|
|
355
|
-
|
|
420
|
+
|
|
356
421
|
// レイヤ参照
|
|
357
422
|
let hexLayer = null;
|
|
358
423
|
let triLayer = null;
|
|
@@ -603,7 +668,7 @@
|
|
|
603
668
|
const cellSize = document.getElementById('cellSize').value.trim();
|
|
604
669
|
const units = document.getElementById('units').value.trim();
|
|
605
670
|
const keywords = document.getElementById('keywords').value.trim();
|
|
606
|
-
latestResult["_"]??={};
|
|
671
|
+
latestResult["_"] ??= {};
|
|
607
672
|
latestResult["_"]["keywords"] = keywords;
|
|
608
673
|
socket.once("hexgrid", (res) => {
|
|
609
674
|
if (res.sessionId) sessionId = res.sessionId;
|
|
@@ -611,7 +676,7 @@
|
|
|
611
676
|
const hex = res.hex;
|
|
612
677
|
const triangles = res.triangles;
|
|
613
678
|
latestResult["_"]["hex"] = hex;
|
|
614
|
-
latestResult["_"]["triangles"]=[triangles];
|
|
679
|
+
latestResult["_"]["triangles"] = [triangles];
|
|
615
680
|
renderLayers(hex, triangles);
|
|
616
681
|
});
|
|
617
682
|
socket.emit("target", {
|
package/visualizer/bulky/node.js
CHANGED
|
@@ -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(フォルダ名と一致させると運用しやすい)
|
package/visualizer/bulky/web.js
CHANGED
|
@@ -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, {
|