splatone 0.0.22 → 0.0.23

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/views/index.ejs CHANGED
@@ -31,10 +31,14 @@
31
31
 
32
32
  <div class="panel-body">
33
33
  <section class="panel-section">
34
+ <% if (browseMode) { %>
35
+ <p class="muted">browseモードでは地図の閲覧のみ可能です。範囲描画とクロール開始は無効化されています。</p>
36
+ <% } else { %>
34
37
  <p class="muted">
35
38
  地図上で <strong>矩形をドラッグ</strong> して BBOX を指定してください。<br>
36
39
  クリックした三角形と、<em>共有辺を持つ別Hexの三角形</em>が同時にハイライトされます。
37
40
  </p>
41
+ <% } %>
38
42
  </section>
39
43
 
40
44
  <section class="panel-section">
@@ -56,23 +60,25 @@
56
60
  </select>
57
61
  </label>
58
62
 
59
- <label class="check">
60
- <input id="toggleTriangles" type="checkbox" checked />
61
- <span>Show triangles</span>
62
- </label>
63
+ <div class="bbox-row">
64
+ <span id="bboxLabel" class="bbox-label"></span>
65
+ </div>
66
+ </section>
63
67
 
64
- <label class="check">
65
- <input id="toggleHexLabels" type="checkbox" />
66
- <span>Show Hex IDs</span>
68
+ <section class="panel-section">
69
+ <label class="field">
70
+ <span>CLI Command</span>
71
+ <textarea id="cliCommand" class="cli-command" rows="4" readonly spellcheck="false"></textarea>
67
72
  </label>
68
-
69
- <div class="panel-actions">
70
- <button id="clear" class="btn">Clear</button>
73
+ <div class="copy-row">
74
+ <button id="copyCliCommand" class="btn" type="button">Copy to clipboard</button>
71
75
  </div>
76
+ </section>
72
77
 
73
- <div class="bbox-row">
74
- <span id="bboxLabel" class="bbox-label"></span>
75
- </div>
78
+ <section class="panel-section" id="statsSection" hidden>
79
+ <h3>統計情報</h3>
80
+ <div id="statsSummary" class="stats-summary muted"></div>
81
+ <div id="statsHexList" class="stats-hex-list"></div>
76
82
  </section>
77
83
 
78
84
  <section class="panel-section">
@@ -91,6 +97,15 @@
91
97
  <!-- パネルの背面オーバーレイ -->
92
98
  <div id="panelOverlay" class="panel-overlay" hidden></div>
93
99
 
100
+ <% if (browseMode) { %>
101
+ <div id="dropOverlay" class="drop-overlay" hidden>
102
+ <div class="drop-overlay__inner">
103
+ <p class="drop-overlay__title">結果ファイルをドロップ</p>
104
+ <p class="drop-overlay__subtitle">crawlerのresult*.jsonやエクスポートJSONを読み込めます</p>
105
+ </div>
106
+ </div>
107
+ <% } %>
108
+
94
109
  <!-- 地図 -->
95
110
  <div id="map"></div>
96
111
 
@@ -111,6 +126,13 @@
111
126
  <script type="module">
112
127
  const lat = <%= lat %>;
113
128
  const lon = <%= lon %>;
129
+ const defaultGeometry = <%- JSON.stringify(defaultGeometry || {}) %>;
130
+ const browseMode = <%- JSON.stringify(Boolean(browseMode)) %>;
131
+ const cliContext = {
132
+ baseCommand: <%- JSON.stringify(cliBaseCommand || '') %>,
133
+ plugin: <%- JSON.stringify(selectedPlugin || '') %>,
134
+ visualizers: <%- JSON.stringify(selectedVisualizers || []) %>,
135
+ };
114
136
  const socket = io({
115
137
  path: "/socket",
116
138
  reconnection: false
@@ -155,6 +177,54 @@
155
177
  if (label) label.textContent = v + '%';
156
178
  }
157
179
 
180
+ function formatPercentValue(value) {
181
+ if (!Number.isFinite(value)) return '0%';
182
+ return (Math.round(Math.max(0, Math.min(1, value)) * 1000) / 10) + '%';
183
+ }
184
+
185
+ function renderStatsPanel(stats) {
186
+ const section = document.getElementById('statsSection');
187
+ const summaryEl = document.getElementById('statsSummary');
188
+ const hexListEl = document.getElementById('statsHexList');
189
+ if (!section || !summaryEl || !hexListEl) return;
190
+ if (!stats || !stats.totals) {
191
+ section.hidden = true;
192
+ summaryEl.textContent = '';
193
+ hexListEl.textContent = '';
194
+ return;
195
+ }
196
+ const totals = stats.totals;
197
+ section.hidden = false;
198
+ summaryEl.innerHTML = `
199
+ <div>Hex総数: <strong>${totals.hexes ?? 0}</strong></div>
200
+ <div>カテゴリ数: <strong>${totals.categories ?? 0}</strong></div>
201
+ <div>取得済み: <strong>${totals.crawled ?? 0}</strong></div>
202
+ <div>残り推定: <strong>${totals.remaining ?? 0}</strong></div>
203
+ <div>進捗: <strong>${formatPercentValue(totals.percent ?? 0)}</strong></div>
204
+ `;
205
+
206
+ const entries = Object.entries(stats.hexes ?? {});
207
+ if (!entries.length) {
208
+ hexListEl.innerHTML = '<p class="muted">Hex単位の統計はまだありません。</p>';
209
+ return;
210
+ }
211
+ entries.sort((a, b) => (b[1]?.percent ?? 0) - (a[1]?.percent ?? 0));
212
+ const rows = entries.slice(0, 6).map(([hexId, info]) => {
213
+ const percent = formatPercentValue(info?.percent ?? 0);
214
+ const crawled = info?.crawled ?? 0;
215
+ const expected = info?.expected ?? info?.total ?? (crawled + (info?.remaining ?? 0));
216
+ const remaining = info?.remaining ?? Math.max(0, expected - crawled);
217
+ return `
218
+ <div class="stats-hex-row">
219
+ <span class="stats-hex-label">Hex ${hexId}</span>
220
+ <span class="stats-hex-progress">${percent}</span>
221
+ <span class="stats-hex-detail">${crawled}/${expected} (残り${remaining})</span>
222
+ </div>
223
+ `;
224
+ });
225
+ hexListEl.innerHTML = rows.join('');
226
+ }
227
+
158
228
  function downloadJSON(filename, obj) {
159
229
  const json = JSON.stringify(obj, null, 2);
160
230
  const blob = new Blob([json], {
@@ -170,6 +240,89 @@
170
240
  URL.revokeObjectURL(url);
171
241
  }
172
242
 
243
+ let currentDrawnGeoJSON = null;
244
+ let currentDrawKind = null;
245
+ let currentBboxArray = null;
246
+
247
+ const cliTextarea = document.getElementById('cliCommand');
248
+ const copyCliButton = document.getElementById('copyCliCommand');
249
+
250
+ function quoteSingle(value) {
251
+ const safe = String(value ?? '').replace(/'/g, "''");
252
+ return "'" + safe + "'";
253
+ }
254
+
255
+ function buildCliCommandString() {
256
+ if (!cliContext?.baseCommand) return '';
257
+ const parts = [cliContext.baseCommand.trim()].filter(Boolean);
258
+ if (cliContext.plugin) {
259
+ parts.push('-p', cliContext.plugin);
260
+ }
261
+ if (Array.isArray(cliContext.visualizers)) {
262
+ cliContext.visualizers.forEach((vis) => {
263
+ if (vis) parts.push(`--vis-${vis}`);
264
+ });
265
+ }
266
+ const keywordsInput = document.getElementById('keywords');
267
+ const keywordsVal = keywordsInput?.value?.trim();
268
+ if (keywordsVal) {
269
+ parts.push('-k', quoteSingle(keywordsVal));
270
+ }
271
+ const cellSizeInput = document.getElementById('cellSize');
272
+ const cellSizeVal = cellSizeInput?.value?.trim();
273
+ if (cellSizeVal) {
274
+ parts.push('--ui-cell-size', cellSizeVal);
275
+ }
276
+ const unitsSelect = document.getElementById('units');
277
+ const unitsVal = unitsSelect?.value;
278
+ if (unitsVal) {
279
+ parts.push('--ui-units', unitsVal);
280
+ }
281
+ if (currentDrawKind === 'rectangle' && Array.isArray(currentBboxArray)) {
282
+ parts.push('--ui-bbox', currentBboxArray.join(','));
283
+ } else if (currentDrawnGeoJSON) {
284
+ parts.push('--ui-polygon', quoteSingle(JSON.stringify(currentDrawnGeoJSON)));
285
+ }
286
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
287
+ }
288
+
289
+ function updateCliCommand() {
290
+ if (!cliTextarea) return;
291
+ cliTextarea.value = buildCliCommandString();
292
+ }
293
+
294
+ copyCliButton?.addEventListener('click', async () => {
295
+ if (!cliTextarea) return;
296
+ const text = cliTextarea.value;
297
+ try {
298
+ if (navigator.clipboard?.writeText) {
299
+ await navigator.clipboard.writeText(text);
300
+ } else {
301
+ cliTextarea.focus();
302
+ cliTextarea.select();
303
+ document.execCommand('copy');
304
+ window.getSelection()?.removeAllRanges?.();
305
+ }
306
+ Toastify({
307
+ text: 'CLIコマンドをコピーしました',
308
+ duration: 3000,
309
+ gravity: 'bottom',
310
+ position: 'center',
311
+ className: 'toast-info'
312
+ }).showToast();
313
+ } catch (err) {
314
+ Toastify({
315
+ text: `コピーに失敗しました: ${err?.message || err}`,
316
+ duration: 4000,
317
+ gravity: 'bottom',
318
+ position: 'center',
319
+ className: 'toast-error'
320
+ }).showToast();
321
+ }
322
+ });
323
+
324
+ updateCliCommand();
325
+
173
326
  socket.on('connect', () => {
174
327
  //console.log('Connected to server');
175
328
  });
@@ -178,11 +331,20 @@
178
331
  //console.log('Disconnected from server');
179
332
  });
180
333
 
181
- let sessionId = null;
182
- let visualizers = {};
183
- let results = {};
184
- let files = {};
185
- let layerControl = null;
334
+ document.getElementById('keywords')?.addEventListener('input', updateCliCommand);
335
+ document.getElementById('cellSize')?.addEventListener('input', updateCliCommand);
336
+ document.getElementById('units')?.addEventListener('change', updateCliCommand);
337
+
338
+ let sessionId = null;
339
+ let visualizers = {};
340
+ let results = {};
341
+ let files = {};
342
+ let layerControl = null;
343
+ const hexProgress = {};
344
+ let welcomeReadyResolve;
345
+ const welcomeReady = new Promise((resolve) => {
346
+ welcomeReadyResolve = resolve;
347
+ });
186
348
  socket.on("welcome", async (res) => {
187
349
  //console.log(`welcome ${res.sessionId} at ${new Date(res.time).toLocaleTimeString()}`);
188
350
  //console.log("VIS", res.visualizers);
@@ -193,6 +355,10 @@
193
355
  return [vis, m.entry ?? m.default ?? m[vis]];
194
356
  })));
195
357
  //console.log(visualizers);
358
+ if (welcomeReadyResolve) {
359
+ welcomeReadyResolve();
360
+ welcomeReadyResolve = null;
361
+ }
196
362
  });
197
363
 
198
364
  function generateLegend(legends) {
@@ -251,28 +417,35 @@
251
417
  }
252
418
  });
253
419
  socket.on('progress', (res) => {
254
- //console.table(res.progress);
255
- //console.log(Object.values(res.progress));
256
- let {
420
+ //res.currentHexはカテゴリごとのcrawled/totalを保持している
421
+ const { crawled, total } = Object.values(res.currentHex ?? {}).reduce(
422
+ (acc, cat) => (cat == null ? acc : {
423
+ crawled: acc.crawled + (cat.crawled ?? 0),
424
+ total: acc.total + (cat.total ?? 0)
425
+ }),
426
+ { crawled: 0, total: 0 }
427
+ );
428
+ const safeTotal = Math.max(1, total);
429
+ const normalized = total === 0 ? 1 : Math.min(1, crawled / safeTotal);
430
+ hexProgress[res.hexId] = {
257
431
  crawled,
258
- total
259
- } = Object.values(res.progress).reduce(
260
- (acc, g) => (g == null ? acc : {
261
- crawled: acc.crawled + (g.crawled ?? 0),
262
- total: acc.total + (g.total ?? 0)
263
- }), {
264
- crawled: 0,
265
- total: 0
266
- }
432
+ total: safeTotal,
433
+ normalized
434
+ };
435
+
436
+ const aggregated = Object.values(hexProgress).reduce(
437
+ (acc, hex) => ({
438
+ crawled: acc.crawled + (hex?.crawled ?? 0),
439
+ total: acc.total + (hex?.total ?? 0)
440
+ }),
441
+ { crawled: 0, total: 0 }
267
442
  );
268
- if (total == 0) {
269
- crawled = 1, total = 1;
270
- }
271
- const percent = Math.round((crawled / total) * 100000) / 1000;
272
- //console.log(`[Done] ${crawled} / ${total} --> ${percent}`);
443
+ const aggSafeTotal = Math.max(1, aggregated.total);
444
+ const overallNormalized = aggregated.total === 0 ? 1 : Math.min(1, aggregated.crawled / aggSafeTotal);
445
+ const percent = Math.round(overallNormalized * 100000) / 1000;
273
446
  setProgress(document.getElementById("progressCrawl"), percent);
274
447
  //ここでHexのプログレスグラデーションレイヤのOpacity調整
275
- highlightHexById(res.hexId, 1 - res.progress[res.hexId].percent, "#263238");
448
+ highlightHexById(res.hexId, 1 - normalized, "#263238");
276
449
  });
277
450
 
278
451
  function isPlainObject(a) {
@@ -348,79 +521,171 @@
348
521
  }); // only one argument is expected
349
522
  });
350
523
 
351
- let latestResult = {};
524
+ let latestResult = null;
352
525
  let latestFile = null;
353
526
  const overlays = {};
354
- socket.on('result', async (res, callback) => {
355
- //console.log("result");
356
- latestResult["_"]["visualizers"] = res.visualizers;
357
- latestResult["_"]["plugin"] = res.plugin;
358
- clearHexHighlight();
359
- if (res.geoJson == null) {
360
- res.geoJson = results[res.resultId];
361
- } else {
362
- results[res.resultId] = res.geoJson;
527
+ const resultExtras = {};
528
+
529
+ async function visualizeBundle(bundle, { sourceLabel = 'server' } = {}) {
530
+ await welcomeReady;
531
+ if (!bundle || typeof bundle !== 'object') {
532
+ Toastify({
533
+ text: '結果データを読み込めませんでした',
534
+ duration: 4000,
535
+ gravity: 'bottom',
536
+ position: 'center',
537
+ className: 'toast-error'
538
+ }).showToast();
539
+ return false;
363
540
  }
364
- latestResult = {
365
- ...latestResult,
366
- ...res.geoJson
541
+
542
+ clearHexHighlight();
543
+ const geoJson = bundle.geoJson ?? {};
544
+ const palette = bundle.palette ?? {};
545
+ const visOptionsFromServer = bundle.visOptions ?? {};
546
+
547
+ renderStatsPanel(bundle.context?.stats ?? null);
548
+ bundle.extra = {
549
+ ...(bundle.extra ?? {}),
550
+ ...resultExtras
367
551
  };
368
- for (const vis in visualizers) {
369
- const layers = await visualizers[vis](map, res.geoJson[vis], {
370
- palette: res.palette,
371
- visOptions:res.visOptions[vis]
372
- });
373
- //console.log("【レイヤ】\n", JSON.stringify(layers,null,4));
374
- if ((layers == null)) {
375
- //SKIP
376
- } else if (layers.hasOwnProperty("type") && layers["type"] == "FeatureCollection") {
377
- //レイヤ一つ
378
- overlays[`[${vis}]`] = layers;
379
- } else {
552
+ latestResult = bundle;
553
+ latestFile = null;
554
+
555
+ const bundleHexGrid = bundle.context?.hexGrid ?? bundle.extra?.hex ?? null;
556
+ let bundleTriangles = bundle.context?.triangles ?? null;
557
+ if (!bundleTriangles) {
558
+ const extraTriangles = bundle.extra?.triangles;
559
+ if (Array.isArray(extraTriangles)) {
560
+ bundleTriangles = extraTriangles.find((entry) => entry?.type === 'FeatureCollection') ?? null;
561
+ } else if (extraTriangles?.type === 'FeatureCollection') {
562
+ bundleTriangles = extraTriangles;
563
+ }
564
+ }
565
+ if (bundleHexGrid || bundleTriangles) {
566
+ refreshGridLayers(bundleHexGrid ?? null, bundleTriangles ?? null, { addToMap: false });
567
+ }
568
+
569
+ if (layerControl) {
570
+ map.removeControl(layerControl);
571
+ layerControl = null;
572
+ }
573
+
574
+ Object.keys(overlays).forEach((key) => {
575
+ const layer = overlays[key];
576
+ if (!layer) return;
577
+ if (typeof layer.remove === 'function') {
578
+ try { layer.remove(); } catch (err) { console.warn('failed to remove layer', err); }
579
+ } else if (typeof layer.removeFrom === 'function') {
580
+ try { layer.removeFrom(map); } catch (err) { console.warn('failed to remove layer', err); }
581
+ } else if (layer instanceof L.Layer) {
582
+ try { map.removeLayer(layer); } catch (err) { console.warn('failed to remove layer', err); }
583
+ }
584
+ delete overlays[key];
585
+ });
586
+
587
+ for (const vis of Object.keys(visualizers)) {
588
+ const handler = visualizers[vis];
589
+ if (typeof handler !== 'function') continue;
590
+ let layers = null;
591
+ try {
592
+ layers = await handler(map, geoJson[vis], {
593
+ palette,
594
+ visOptions: visOptionsFromServer[vis]
595
+ });
596
+ } catch (err) {
597
+ console.error(`[visualizer:${vis}] failed`, err);
598
+ continue;
599
+ }
600
+ if (!layers) continue;
601
+ if (isPlainObject(layers) && !layers.type) {
380
602
  for (const name in layers) {
381
- //console.log("Lay", vis, name);
382
603
  overlays[`[${vis}] ${name}`] = layers[name];
383
604
  }
605
+ } else {
606
+ overlays[`[${vis}]`] = layers;
384
607
  }
385
608
  }
386
- //進捗バーを消して凡例を表示
387
- if (myProgress) map.removeControl(myProgress);
388
- myLegend = new BottomLeftLegend().addTo(map);
609
+
610
+ if (myProgress) {
611
+ map.removeControl(myProgress);
612
+ myProgress = null;
613
+ }
614
+ if (myLegend) {
615
+ map.removeControl(myLegend);
616
+ myLegend = null;
617
+ }
389
618
  document.getElementById('map_legend')?.replaceChildren();
619
+ myLegend = new BottomLeftLegend().addTo(map);
390
620
  const ul = document.createElement('div');
391
621
  ul.className = 'legend_ul';
392
- const palette = [];
393
- for (const cat in res.palette) {
394
- palette.push({
395
- category: cat,
396
- fill: res.palette[cat].color,
397
- stroke: res.palette[cat].darken
398
- });
622
+ const paletteEntries = Object.entries(palette).map(([category, colors]) => ({
623
+ category,
624
+ fill: colors?.color ?? '#ffffff',
625
+ stroke: colors?.darken ?? colors?.color ?? '#ffffff'
626
+ }));
627
+ ul.innerHTML = generateLegend(paletteEntries);
628
+ document.getElementById('map_legend')?.appendChild(ul);
629
+
630
+ const overlayOptions = { ...overlays };
631
+ if (drawnItems && drawnItems.getLayers().length > 0) {
632
+ overlayOptions['Boundary'] = drawnItems;
633
+ }
634
+ if (hexLayer) {
635
+ overlayOptions['Hex Grid'] = hexLayer;
636
+ }
637
+ if (triLayer) {
638
+ overlayOptions['Tri Grid'] = triLayer;
399
639
  }
400
- ul.innerHTML = generateLegend(palette);
401
- //console.log(overlays);
402
- layerControl = L.control.layers([baseLayer], {
403
- ...overlays,
404
- "Boundary": drawnItems,
405
- "Hex Grid": hexLayer,
406
- "Tri Grid": triLayer
407
- }, {
640
+
641
+ if (layerControl) {
642
+ map.removeControl(layerControl);
643
+ }
644
+ layerControl = L.control.layers([baseLayer], overlayOptions, {
408
645
  position: 'topleft'
409
646
  });
410
647
  layerControl.addTo(map);
411
- document.getElementById("map_legend")?.appendChild(ul);
412
- document.getElementById("download-json").addEventListener("click", async () => {
413
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
414
- if(latestFile===null){
415
- downloadJSON(`splatone-${stamp}.json`, latestResult);
416
- }else{
417
- await downloadJSONFile(`splatone-${stamp}.json`, latestFile);
418
- }
419
- let sessionId = null;
420
- });
648
+
649
+ const downloadBtn = document.getElementById('download-json');
650
+ if (downloadBtn && downloadBtn.dataset.bound !== '1') {
651
+ downloadBtn.dataset.bound = '1';
652
+ downloadBtn.addEventListener('click', async () => {
653
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
654
+ if (latestFile === null) {
655
+ if (latestResult) {
656
+ downloadJSON(`splatone-${stamp}.json`, latestResult);
657
+ }
658
+ } else {
659
+ await downloadJSONFile(`splatone-${stamp}.json`, latestFile);
660
+ }
661
+ });
662
+ }
663
+
664
+ Toastify({
665
+ text: `結果を読み込みました (${sourceLabel})`,
666
+ duration: 2600,
667
+ gravity: 'bottom',
668
+ position: 'center',
669
+ className: 'toast-success'
670
+ }).showToast();
671
+ return true;
672
+ }
673
+ socket.on('result', async (res, callback) => {
674
+ let bundle = res.bundle ?? null;
675
+ if (!bundle) {
676
+ bundle = results[res.resultId];
677
+ } else {
678
+ results[res.resultId] = bundle;
679
+ }
680
+ if (!bundle) {
681
+ console.warn('[result] bundle not available yet');
682
+ callback({ pong: Date.now(), ok: false });
683
+ return;
684
+ }
685
+ const success = await visualizeBundle(bundle, { sourceLabel: 'server' });
421
686
  callback({
422
687
  pong: Date.now(),
423
- ok: true
688
+ ok: Boolean(success)
424
689
  });
425
690
  });
426
691
 
@@ -456,32 +721,30 @@
456
721
  map.addLayer(drawnItems);
457
722
 
458
723
  // Draw control(矩形・ポリゴンのみ)
459
- const drawControl = new L.Control.Draw({
460
- position: 'topleft',
461
- draw: {
462
- polygon: {
463
- allowIntersection: false,
464
- showArea: true,
465
- metric: true,
466
- },
467
- polyline: false,
468
- marker: false,
469
- circle: false,
470
- circlemarker: false,
471
- rectangle: {
472
- shapeOptions: {
473
- weight: 1,
474
- fillOpacity: 0
724
+ let drawControl = null;
725
+ if (!browseMode) {
726
+ drawControl = new L.Control.Draw({
727
+ position: 'topleft',
728
+ draw: {
729
+ polygon: {
730
+ allowIntersection: false,
731
+ showArea: true,
732
+ metric: true,
733
+ },
734
+ polyline: false,
735
+ marker: false,
736
+ circle: false,
737
+ circlemarker: false,
738
+ rectangle: {
739
+ shapeOptions: {
740
+ weight: 1,
741
+ fillOpacity: 0
742
+ }
475
743
  }
476
- }
477
- },
478
- /* edit: {
479
- featureGroup: drawnItems,
480
- edit: true,
481
- remove: true
482
- }*/
483
- });
484
- map.addControl(drawControl);
744
+ },
745
+ });
746
+ map.addControl(drawControl);
747
+ }
485
748
 
486
749
  //印刷
487
750
  L.easyPrint({
@@ -500,7 +763,6 @@
500
763
  let myProgress = null;
501
764
  let hexLayerIndex = {}; // hexId -> layer
502
765
  let triLayerIndex = {}; // triangleId -> layer
503
- let hexLabelLayer = L.layerGroup(); // Hex IDラベル群
504
766
 
505
767
  // Util: Bounds → "minLon,minLat,maxLon,maxLat"
506
768
  function boundsToBbox(bounds) {
@@ -564,193 +826,185 @@
564
826
  }
565
827
  }
566
828
 
567
- // Hex IDラベルを作成
568
- function renderHexLabels(hexFC) {
569
- hexLabelLayer.clearLayers();
570
- hexFC.features.forEach(f => {
571
- const id = f.properties?.hexId;
572
- if (!id) return;
573
- const coords = f.geometry.coordinates[0];
574
- let sumLat = 0,
575
- sumLon = 0,
576
- n = 0;
577
- coords.slice(0, -1).forEach(([lon, lat]) => {
578
- sumLat += lat;
579
- sumLon += lon;
580
- n++;
829
+ function refreshGridLayers(hexFC = null, trianglesFC = null, { addToMap = true } = {}) {
830
+ const hexWasVisible = hexLayer ? map.hasLayer(hexLayer) : false;
831
+ const triWasVisible = triLayer ? map.hasLayer(triLayer) : false;
832
+
833
+ if (hexLayer) {
834
+ map.removeLayer(hexLayer);
835
+ hexLayer = null;
836
+ }
837
+ if (triLayer) {
838
+ map.removeLayer(triLayer);
839
+ triLayer = null;
840
+ }
841
+ hexLayerIndex = {};
842
+ triLayerIndex = {};
843
+
844
+ if (hexFC) {
845
+ const nextHexLayer = L.geoJSON(hexFC, {
846
+ style: {
847
+ weight: 2,
848
+ opacity: 0.5,
849
+ fillOpacity: 0.1,
850
+ color: "#37474F",
851
+ fillColor: "#546E7A",
852
+ },
853
+ onEachFeature: (feature, layer) => {
854
+ const hid = feature.properties?.hexId;
855
+ if (hid != null) hexLayerIndex[hid] = layer;
856
+ const triIds = feature.properties?.triIds?.length ? feature.properties.triIds.join(', ') : 'N/A';
857
+ layer.bindPopup(`Hex ID: ${hid}<br>Triangles: ${triIds}`);
858
+ }
581
859
  });
582
- const lat = sumLat / n,
583
- lon = sumLon / n;
860
+ hexLayer = nextHexLayer;
861
+ if (addToMap || hexWasVisible) {
862
+ nextHexLayer.addTo(map);
863
+ }
864
+ }
865
+
866
+ if (trianglesFC) {
867
+ const nextTriLayer = L.geoJSON(trianglesFC, {
868
+ style: {
869
+ weight: 1,
870
+ opacity: 0.8,
871
+ fillOpacity: 0,
872
+ fillColor: "#EC407A",
873
+ color: "#F50057",
874
+ dashArray: "15,30",
875
+ dashOffset: 0,
876
+ },
877
+ onEachFeature: (feature, layer) => {
878
+ const pid = feature.properties?.parentHexId ?? 'N/A';
879
+ const tid = feature.properties?.triangleId ?? 'N/A';
880
+ const cross = feature.properties?.crossNeighbors ?? [];
881
+ const neighborHexIds = feature.properties?.neighborHexIds ?? [];
882
+
883
+ triLayerIndex[tid] = layer;
884
+
885
+ layer.bindPopup(
886
+ `Triangle: ${tid}<br>` +
887
+ `Parent Hex: ${pid}<br>` +
888
+ `Cross neighbors: ${cross.length ? cross.join(', ') : 'None'}<br>` +
889
+ `Neighbor Hexes: ${neighborHexIds.length ? neighborHexIds.join(', ') : 'None'}`
890
+ );
891
+
892
+ layer.on('click', () => {
893
+ clearHexHighlight();
894
+ clearTriHighlight();
895
+ highlightHexById(pid);
896
+ highlightTriangle(tid, {
897
+ weight: 3,
898
+ opacity: 1,
899
+ fillOpacity: 0.30
900
+ });
901
+
902
+ cross.forEach(cid => {
903
+ highlightTriangle(cid, {
904
+ weight: 2,
905
+ opacity: 1,
906
+ fillOpacity: 0.25
907
+ });
908
+ const neighborLayer = triLayerIndex[cid];
909
+ if (neighborLayer) {
910
+ const pHex = neighborLayer.feature?.properties?.parentHexId;
911
+ if (pHex != null) highlightHexById(pHex);
912
+ }
913
+ });
914
+ });
584
915
 
585
- const icon = L.divIcon({
586
- html: `<div style="font-size:12px;font-weight:600;background:rgba(255,255,255,.85);padding:2px 4px;border-radius:4px;border:1px solid #999;">${id}</div>`,
587
- className: '',
588
- iconSize: [0, 0]
916
+ layer.on('mouseout', () => {
917
+ clearTriHighlight();
918
+ });
919
+ }
589
920
  });
590
- L.marker([lat, lon], {
591
- icon
592
- }).addTo(hexLabelLayer);
593
- });
921
+ triLayer = nextTriLayer;
922
+ if (addToMap || triWasVisible) {
923
+ nextTriLayer.addTo(map);
924
+ }
925
+ }
594
926
  }
595
927
 
596
928
  // Hex / Triangles を描画
597
929
  function renderLayers(hexFC, trianglesFC) {
598
- if (hexLayer) map.removeLayer(hexLayer);
599
- if (triLayer) map.removeLayer(triLayer);
600
930
  document.getElementById('map_legend')?.replaceChildren();
601
931
  if (myLegend) map.removeControl(myLegend);
602
932
  if (myStartBtn) map.removeControl(myStartBtn);
603
- hexLabelLayer.removeFrom(map);
604
- hexLayerIndex = {};
605
- triLayerIndex = {};
606
933
 
607
- // 六角形
608
- hexLayer = L.geoJSON(hexFC, {
609
- style: {
610
- weight: 2,
611
- opacity: 0.5,
612
- fillOpacity: 0.1,
613
- color: "#37474F",
614
- fillColor: "#546E7A",
615
- },
616
- onEachFeature: (feature, layer) => {
617
- const hid = feature.properties?.hexId;
618
- if (hid != null) hexLayerIndex[hid] = layer;
619
- const triIds = feature.properties?.triIds?.length ? feature.properties.triIds.join(', ') : 'N/A';
620
- layer.bindPopup(`Hex ID: ${hid}<br>Triangles: ${triIds}`);
621
- }
622
- }).addTo(map);
934
+ refreshGridLayers(hexFC, trianglesFC, { addToMap: true });
623
935
 
624
- const BottomLeftPanel = L.Control.extend({
625
- options: {
626
- position: 'bottomleft'
627
- },
628
- onAdd(map) {
629
- const div = L.DomUtil.create('div', 'map-bottom-left-control');
630
- div.innerHTML = `
936
+ if (!browseMode) {
937
+ const BottomLeftPanel = L.Control.extend({
938
+ options: {
939
+ position: 'bottomleft'
940
+ },
941
+ onAdd(map) {
942
+ const div = L.DomUtil.create('div', 'map-bottom-left-control');
943
+ div.innerHTML = `
631
944
  <div class="panel-contents">
632
945
  <button class="btn"id="doSomething">Start Crawling!</button>
633
946
  </div>`;
634
- // コントロール内の操作が地図ドラッグに奪われないようにする
635
- L.DomEvent.disableClickPropagation(div);
636
- L.DomEvent.disableScrollPropagation(div);
637
-
638
- const btn = div.querySelector('#doSomething');
639
- btn.addEventListener('click', (ev) => {
640
- ev.preventDefault();
641
- ev.stopPropagation(); // 念のため地図へのバブリングも止める
642
- if (myStartBtn) map.removeControl(myStartBtn);
643
- myProgress = new BottomLeftProgressBar().addTo(map);
644
- // ---- 任意の処理 ----
645
- socket.emit("crawling", {
646
- sessionId: sessionId,
647
- });
648
- }, {
649
- once: true
650
- });
651
-
652
- return div;
653
- }
654
- });
655
- myStartBtn = new BottomLeftPanel().addTo(map);
656
-
657
- // 三角形
658
- triLayer = L.geoJSON(trianglesFC, {
659
- style: {
660
- weight: 1,
661
- opacity: 0.8,
662
- fillOpacity: 0,
663
- fillColor: "#EC407A",
664
- color: "#F50057",
665
- dashArray: "15,30",
666
- dashOffset: 0,
667
- },
668
- onEachFeature: (feature, layer) => {
669
- const pid = feature.properties?.parentHexId ?? 'N/A';
670
- const tid = feature.properties?.triangleId ?? 'N/A';
671
- const cross = feature.properties?.crossNeighbors ?? [];
672
- const neighborHexIds = feature.properties?.neighborHexIds ?? [];
673
-
674
- triLayerIndex[tid] = layer;
675
-
676
- layer.bindPopup(
677
- `Triangle: ${tid}<br>` +
678
- `Parent Hex: ${pid}<br>` +
679
- `Cross neighbors: ${cross.length ? cross.join(', ') : 'None'}<br>` +
680
- `Neighbor Hexes: ${neighborHexIds.length ? neighborHexIds.join(', ') : 'None'}`
681
- );
682
-
683
- layer.on('click', () => {
684
- // クリック時:当該三角形+交差隣接三角形をハイライト
685
- clearHexHighlight();
686
- clearTriHighlight();
687
- highlightHexById(pid); // 親Hexも強調
688
- highlightTriangle(tid, {
689
- weight: 3,
690
- opacity: 1,
691
- fillOpacity: 0.30
692
- }); // クリック対象
693
-
694
- // 隣接(別Hex)の三角形を同時に強調
695
- cross.forEach(cid => {
696
- highlightTriangle(cid, {
697
- weight: 2,
698
- opacity: 1,
699
- fillOpacity: 0.25
947
+ L.DomEvent.disableClickPropagation(div);
948
+ L.DomEvent.disableScrollPropagation(div);
949
+
950
+ const btn = div.querySelector('#doSomething');
951
+ btn.addEventListener('click', (ev) => {
952
+ ev.preventDefault();
953
+ ev.stopPropagation();
954
+ if (myStartBtn) map.removeControl(myStartBtn);
955
+ myProgress = new BottomLeftProgressBar().addTo(map);
956
+ socket.emit("crawling", {
957
+ sessionId: sessionId,
700
958
  });
701
- // その親Hexも薄く強調
702
- const neighborLayer = triLayerIndex[cid];
703
- if (neighborLayer) {
704
- const pHex = neighborLayer.feature?.properties?.parentHexId;
705
- if (pHex != null) highlightHexById(pHex);
706
- }
959
+ }, {
960
+ once: true
707
961
  });
708
- });
709
-
710
- // マウスアウトで三角形のハイライトだけ戻す(Hexは維持)
711
- layer.on('mouseout', () => {
712
- clearTriHighlight();
713
- });
714
- }
715
- });
716
962
 
717
- // 三角形トグル
718
- if (document.getElementById('toggleTriangles').checked) {
719
- triLayer.addTo(map);
963
+ return div;
964
+ }
965
+ });
966
+ myStartBtn = new BottomLeftPanel().addTo(map);
720
967
  }
721
968
 
722
- // Hex ID ラベル
723
- renderHexLabels(hexFC);
724
- if (document.getElementById('toggleHexLabels').checked) {
725
- hexLabelLayer.addTo(map);
969
+ const layers = [hexLayer, triLayer].filter(Boolean);
970
+ if (layers.length > 0) {
971
+ const bounds = L.featureGroup(layers).getBounds();
972
+ if (bounds.isValid()) map.fitBounds(bounds, {
973
+ padding: [20, 20]
974
+ });
726
975
  }
727
-
728
- // ビュー調整
729
- const layers = [hexLayer];
730
- if (document.getElementById('toggleTriangles').checked) layers.push(triLayer);
731
- const bounds = L.featureGroup(layers).getBounds();
732
- if (bounds.isValid()) map.fitBounds(bounds, {
733
- padding: [20, 20]
734
- });
735
976
  }
736
977
 
737
978
  // API呼び出し
738
- async function loadHexgridByBounds(bounds, drawn = null) {
979
+ async function loadHexgridByBounds(bounds, drawn = null, shapeKind = null) {
980
+ if (browseMode) {
981
+ return;
982
+ }
983
+ currentBboxArray = [
984
+ Number(bounds.getWest().toFixed(6)),
985
+ Number(bounds.getSouth().toFixed(6)),
986
+ Number(bounds.getEast().toFixed(6)),
987
+ Number(bounds.getNorth().toFixed(6)),
988
+ ];
989
+ currentDrawnGeoJSON = drawn || null;
990
+ currentDrawKind = drawn ? (shapeKind || 'polygon') : null;
739
991
  const bbox = boundsToBbox(bounds);
740
992
  document.getElementById('bboxLabel').textContent = `BBOX: ${bbox}`;
993
+ updateCliCommand();
741
994
 
742
995
  const cellSize = document.getElementById('cellSize').value.trim();
743
996
  const units = document.getElementById('units').value.trim();
744
997
  const keywords = document.getElementById('keywords').value.trim();
745
- latestResult["_"] ??= {};
746
- latestResult["_"]["keywords"] = keywords;
998
+ resultExtras.keywords = keywords;
747
999
  socket.once("hexgrid", (res) => {
748
1000
  if (res.sessionId) sessionId = res.sessionId;
749
1001
  //console.log(`hexgrid received (${res.hex.features.length} hexes, ${res.triangles.features.length} triangles)`);
1002
+ Object.keys(hexProgress).forEach((key) => delete hexProgress[key]);
750
1003
  const hex = res.hex;
751
1004
  const triangles = res.triangles;
752
- latestResult["_"]["hex"] = hex;
753
- latestResult["_"]["triangles"] = [triangles];
1005
+ resultExtras.hex = hex;
1006
+ resultExtras.triangles = [triangles];
1007
+ renderStatsPanel(null);
754
1008
  renderLayers(hex, triangles);
755
1009
  });
756
1010
  socket.emit("target", {
@@ -765,51 +1019,133 @@
765
1019
  });
766
1020
  }
767
1021
 
768
- // Draw イベント
769
- map.on(L.Draw.Event.CREATED, async (e) => {
770
- drawnItems.clearLayers();
771
- const layer = e.layer;
772
- drawnItems.addLayer(layer);
773
- try {
774
- await loadHexgridByBounds(layer.getBounds(), layer.toGeoJSON());
775
- } catch (err) {
776
- alert(err.message);
777
- }
778
- });
779
-
780
- map.on(L.Draw.Event.EDITSTOP, async () => {
781
- const layers = drawnItems.getLayers();
782
- if (layers.length === 1) {
1022
+ if (!browseMode) {
1023
+ map.on(L.Draw.Event.CREATED, async (e) => {
1024
+ drawnItems.clearLayers();
1025
+ const layer = e.layer;
1026
+ layer.__splatoneShape = e.layerType === 'rectangle' ? 'rectangle' : 'polygon';
1027
+ drawnItems.addLayer(layer);
783
1028
  try {
784
- await loadHexgridByBounds(layers[0].getBounds(), layers[0].toGeoJSON());
1029
+ await loadHexgridByBounds(layer.getBounds(), layer.toGeoJSON(), layer.__splatoneShape);
785
1030
  } catch (err) {
786
1031
  alert(err.message);
787
1032
  }
788
- }
789
- });
1033
+ });
790
1034
 
791
- map.on(L.Draw.Event.DELETED, () => {
792
- drawnItems.clearLayers();
793
- if (hexLayer) {
794
- map.removeLayer(hexLayer);
795
- hexLayer = null;
796
- }
797
- if (triLayer) {
798
- map.removeLayer(triLayer);
799
- triLayer = null;
800
- }
801
- if (myStartBtn) {
802
- map.removeControl(myStartBtn);
803
- myStartBtn = null;
804
- }
805
- if (myLegend) {
806
- document.getElementById('map_legend')?.replaceChildren();
807
- map.removeControl(myLegend);
808
- myLegend = null;
1035
+ map.on(L.Draw.Event.EDITSTOP, async () => {
1036
+ const layers = drawnItems.getLayers();
1037
+ if (layers.length === 1) {
1038
+ try {
1039
+ const layer = layers[0];
1040
+ const shapeKind = layer.__splatoneShape || (layer instanceof L.Rectangle ? 'rectangle' : 'polygon');
1041
+ await loadHexgridByBounds(layer.getBounds(), layer.toGeoJSON(), shapeKind);
1042
+ } catch (err) {
1043
+ alert(err.message);
1044
+ }
1045
+ }
1046
+ });
1047
+
1048
+ map.on(L.Draw.Event.DELETED, () => {
1049
+ drawnItems.clearLayers();
1050
+ if (hexLayer) {
1051
+ map.removeLayer(hexLayer);
1052
+ hexLayer = null;
1053
+ }
1054
+ if (triLayer) {
1055
+ map.removeLayer(triLayer);
1056
+ triLayer = null;
1057
+ }
1058
+ if (myStartBtn) {
1059
+ map.removeControl(myStartBtn);
1060
+ myStartBtn = null;
1061
+ }
1062
+ if (myLegend) {
1063
+ document.getElementById('map_legend')?.replaceChildren();
1064
+ map.removeControl(myLegend);
1065
+ myLegend = null;
1066
+ }
1067
+ document.getElementById('bboxLabel').textContent = '';
1068
+ currentDrawnGeoJSON = null;
1069
+ currentDrawKind = null;
1070
+ currentBboxArray = null;
1071
+ updateCliCommand();
1072
+ });
1073
+ }
1074
+
1075
+ if (browseMode) {
1076
+ initDropImport();
1077
+ }
1078
+
1079
+ function initDropImport() {
1080
+ const overlay = document.getElementById('dropOverlay');
1081
+ if (!overlay) return;
1082
+ let dragDepth = 0;
1083
+ const showOverlay = () => {
1084
+ overlay.hidden = false;
1085
+ overlay.classList.add('visible');
1086
+ };
1087
+ const hideOverlay = () => {
1088
+ overlay.classList.remove('visible');
1089
+ overlay.hidden = true;
1090
+ };
1091
+ const hasFiles = (event) => Array.from(event.dataTransfer?.types ?? []).includes('Files');
1092
+
1093
+ window.addEventListener('dragenter', (event) => {
1094
+ if (!hasFiles(event)) return;
1095
+ dragDepth += 1;
1096
+ showOverlay();
1097
+ event.preventDefault();
1098
+ });
1099
+ window.addEventListener('dragover', (event) => {
1100
+ if (!hasFiles(event)) return;
1101
+ event.preventDefault();
1102
+ event.dataTransfer.dropEffect = 'copy';
1103
+ });
1104
+ window.addEventListener('dragleave', (event) => {
1105
+ if (dragDepth > 0) {
1106
+ dragDepth -= 1;
1107
+ }
1108
+ if (dragDepth <= 0) {
1109
+ dragDepth = 0;
1110
+ hideOverlay();
1111
+ }
1112
+ });
1113
+ window.addEventListener('drop', async (event) => {
1114
+ if (!hasFiles(event)) return;
1115
+ event.preventDefault();
1116
+ dragDepth = 0;
1117
+ hideOverlay();
1118
+ const file = event.dataTransfer?.files?.[0] ?? null;
1119
+ if (file) {
1120
+ await importResultFile(file);
1121
+ }
1122
+ });
1123
+ }
1124
+
1125
+ async function importResultFile(file) {
1126
+ try {
1127
+ const text = await file.text();
1128
+ let parsed;
1129
+ try {
1130
+ parsed = JSON.parse(text);
1131
+ } catch (err) {
1132
+ throw new Error('JSONとして解析できませんでした');
1133
+ }
1134
+ const bundle = parsed?.bundle ?? parsed;
1135
+ if (!bundle || typeof bundle !== 'object' || !bundle.geoJson) {
1136
+ throw new Error('結果バンドルではありません');
1137
+ }
1138
+ await visualizeBundle(bundle, { sourceLabel: file.name || 'file' });
1139
+ } catch (err) {
1140
+ Toastify({
1141
+ text: `読み込みに失敗しました: ${err?.message || err}`,
1142
+ duration: 4000,
1143
+ gravity: 'bottom',
1144
+ position: 'center',
1145
+ className: 'toast-error'
1146
+ }).showToast();
809
1147
  }
810
- hexLabelLayer.removeFrom(map);
811
- document.getElementById('bboxLabel').textContent = '';
812
- });
1148
+ }
813
1149
 
814
1150
  // ======= ハンバーガー / パネル制御 =======
815
1151
  const hamburger = document.getElementById('hamburger');
@@ -843,46 +1179,53 @@
843
1179
  if (e.key === 'Escape') closePanel();
844
1180
  });
845
1181
 
846
- // UIトグル
847
- document.getElementById('clear').addEventListener('click', () => {
848
- drawnItems.clearLayers();
849
- if (hexLayer) {
850
- map.removeLayer(hexLayer);
851
- hexLayer = null;
852
- }
853
- if (triLayer) {
854
- map.removeLayer(triLayer);
855
- triLayer = null;
856
- }
857
- if (myStartBtn) {
858
- map.removeControl(myStartBtn);
859
- myStartBtn = null;
1182
+ async function applyDefaultGeometry() {
1183
+ if (!defaultGeometry || (!defaultGeometry.polygon && !Array.isArray(defaultGeometry.bbox))) {
1184
+ return;
860
1185
  }
861
- if (myLegend) {
862
- document.getElementById('map_legend')?.replaceChildren();
863
- map.removeControl(myLegend);
864
- myLegend = null;
865
- }
866
- hexLabelLayer.removeFrom(map);
867
- document.getElementById('bboxLabel').textContent = '';
868
- });
869
1186
 
870
- document.getElementById('toggleTriangles').addEventListener('change', () => {
871
- if (!triLayer) return;
872
- if (document.getElementById('toggleTriangles').checked) triLayer.addTo(map);
873
- else map.removeLayer(triLayer);
874
- });
1187
+ const ensureDraw = async (layer) => {
1188
+ await welcomeReady;
1189
+ drawnItems.clearLayers();
1190
+ layer.__splatoneShape = layer.__splatoneShape || (layer instanceof L.Rectangle ? 'rectangle' : 'polygon');
1191
+ drawnItems.addLayer(layer);
1192
+ await loadHexgridByBounds(layer.getBounds(), layer.toGeoJSON(), layer.__splatoneShape);
1193
+ };
875
1194
 
876
- document.getElementById('toggleHexLabels').addEventListener('change', () => {
877
- if (document.getElementById('toggleHexLabels').checked) {
878
- hexLabelLayer.addTo(map);
879
- } else {
880
- hexLabelLayer.removeFrom(map);
1195
+ if (defaultGeometry.polygon && typeof defaultGeometry.polygon === 'object') {
1196
+ const geoLayer = L.geoJSON(defaultGeometry.polygon);
1197
+ const layers = geoLayer.getLayers();
1198
+ if (layers.length > 0) {
1199
+ const layer = layers[0];
1200
+ if (layer.getBounds().isValid()) {
1201
+ map.fitBounds(layer.getBounds(), { padding: [20, 20] });
1202
+ }
1203
+ layer.__splatoneShape = 'polygon';
1204
+ await ensureDraw(layer);
1205
+ return;
1206
+ }
881
1207
  }
882
- });
883
1208
 
884
- // 初期は描画待ち(必要なら初期BBOXを有効化)
885
- // loadHexgridByBounds(L.latLngBounds([35.53, 139.55], [35.80, 139.92]));
1209
+ if (Array.isArray(defaultGeometry.bbox) && defaultGeometry.bbox.length === 4) {
1210
+ const [minLon, minLat, maxLon, maxLat] = defaultGeometry.bbox;
1211
+ if ([minLon, minLat, maxLon, maxLat].every(val => Number.isFinite(Number(val)))) {
1212
+ const bounds = L.latLngBounds([
1213
+ [minLat, minLon],
1214
+ [maxLat, maxLon]
1215
+ ]);
1216
+ const rectangle = L.rectangle(bounds, { weight: 1, fillOpacity: 0 });
1217
+ rectangle.__splatoneShape = 'rectangle';
1218
+ if (bounds.isValid()) {
1219
+ map.fitBounds(bounds, { padding: [20, 20] });
1220
+ }
1221
+ await ensureDraw(rectangle);
1222
+ }
1223
+ }
1224
+ }
1225
+
1226
+ if (!browseMode && (defaultGeometry?.polygon || Array.isArray(defaultGeometry?.bbox))) {
1227
+ applyDefaultGeometry().catch((err) => console.error('Failed to apply default geometry', err));
1228
+ }
886
1229
  </script>
887
1230
  </body>
888
1231