splatone 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +2 -0
- package/.vscode/settings.json +1 -0
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/crawler.js +570 -0
- package/lib/PluginBase.js +23 -0
- package/lib/VisualizerBase.js +15 -0
- package/lib/paletteGenerator.js +532 -0
- package/lib/pluginLoader.js +146 -0
- package/lib/util.js +180 -0
- package/package.json +36 -0
- package/plugins/flickr/index.js +55 -0
- package/plugins/flickr/worker.js +77 -0
- package/public/style.css +318 -0
- package/public/visualizer.js +49 -0
- package/views/index.ejs +740 -0
- package/visualizer/bulky/node.js +41 -0
- package/visualizer/bulky/web.js +29 -0
- package/visualizer/marker-cluster/node.js +50 -0
- package/visualizer/marker-cluster/public/style.css +1 -0
- package/visualizer/marker-cluster/web.js +153 -0
package/views/index.ejs
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ja">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<title>
|
|
7
|
+
<%= title %>
|
|
8
|
+
</title>
|
|
9
|
+
|
|
10
|
+
<!-- Leaflet CSS -->
|
|
11
|
+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="anonymous" />
|
|
12
|
+
<!-- Leaflet.draw CSS -->
|
|
13
|
+
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" crossorigin="anonymous" />
|
|
14
|
+
|
|
15
|
+
<link rel="stylesheet" href="/style.css" />
|
|
16
|
+
</head>
|
|
17
|
+
|
|
18
|
+
<body>
|
|
19
|
+
<!-- ハンバーガー -->
|
|
20
|
+
<button id="hamburger" class="hamburger" aria-label="Open controls" aria-expanded="false" aria-controls="sidepanel">
|
|
21
|
+
☰
|
|
22
|
+
</button>
|
|
23
|
+
|
|
24
|
+
<!-- スライドオーバー(右) -->
|
|
25
|
+
<aside id="sidepanel" class="sidepanel" aria-hidden="true">
|
|
26
|
+
<header class="panel-header">
|
|
27
|
+
<h2>Controls</h2>
|
|
28
|
+
<button id="panelClose" class="icon-btn" aria-label="Close controls">✕</button>
|
|
29
|
+
</header>
|
|
30
|
+
|
|
31
|
+
<div class="panel-body">
|
|
32
|
+
<section class="panel-section">
|
|
33
|
+
<p class="muted">
|
|
34
|
+
地図上で <strong>矩形をドラッグ</strong> して BBOX を指定してください。<br>
|
|
35
|
+
クリックした三角形と、<em>共有辺を持つ別Hexの三角形</em>が同時にハイライトされます。
|
|
36
|
+
</p>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
<section class="panel-section">
|
|
40
|
+
<label class="field">
|
|
41
|
+
<span>Keywords</span>
|
|
42
|
+
<input id="keywords" type="text" value="<%= defaultKeywords %>" />
|
|
43
|
+
</label>
|
|
44
|
+
<label class="field">
|
|
45
|
+
<span>cellSize</span>
|
|
46
|
+
<input id="cellSize" type="number" step="0.1" min="0.05" value="<%= defaultCellSize %>" />
|
|
47
|
+
</label>
|
|
48
|
+
|
|
49
|
+
<label class="field">
|
|
50
|
+
<span>units</span>
|
|
51
|
+
<select id="units">
|
|
52
|
+
<option value="kilometers" <%=defaultUnits==='kilometers' ?'selected':'' %>>kilometers</option>
|
|
53
|
+
<option value="meters" <%=defaultUnits==='meters' ?'selected':'' %>>meters</option>
|
|
54
|
+
<option value="miles" <%=defaultUnits==='miles' ?'selected':'' %>>miles</option>
|
|
55
|
+
</select>
|
|
56
|
+
</label>
|
|
57
|
+
|
|
58
|
+
<label class="check">
|
|
59
|
+
<input id="toggleTriangles" type="checkbox" checked />
|
|
60
|
+
<span>Show triangles</span>
|
|
61
|
+
</label>
|
|
62
|
+
|
|
63
|
+
<label class="check">
|
|
64
|
+
<input id="toggleHexLabels" type="checkbox" />
|
|
65
|
+
<span>Show Hex IDs</span>
|
|
66
|
+
</label>
|
|
67
|
+
|
|
68
|
+
<div class="panel-actions">
|
|
69
|
+
<button id="clear" class="btn">Clear</button>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="bbox-row">
|
|
73
|
+
<span id="bboxLabel" class="bbox-label"></span>
|
|
74
|
+
</div>
|
|
75
|
+
</section>
|
|
76
|
+
|
|
77
|
+
<section class="panel-section">
|
|
78
|
+
<details>
|
|
79
|
+
<summary>ヘルプ</summary>
|
|
80
|
+
<ul class="muted">
|
|
81
|
+
<li>左上の四角いツールで矩形を描画します。</li>
|
|
82
|
+
<li>矩形を編集すると自動で再生成します。</li>
|
|
83
|
+
<li>三角形をクリックすると、隣接する他Hexの三角形を同時ハイライト。</li>
|
|
84
|
+
</ul>
|
|
85
|
+
</details>
|
|
86
|
+
</section>
|
|
87
|
+
</div>
|
|
88
|
+
</aside>
|
|
89
|
+
|
|
90
|
+
<!-- パネルの背面オーバーレイ -->
|
|
91
|
+
<div id="panelOverlay" class="panel-overlay" hidden></div>
|
|
92
|
+
|
|
93
|
+
<!-- 地図 -->
|
|
94
|
+
<div id="map"></div>
|
|
95
|
+
|
|
96
|
+
<!-- Leaflet JS -->
|
|
97
|
+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin="anonymous"></script>
|
|
98
|
+
<!-- Leaflet.draw JS -->
|
|
99
|
+
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js" crossorigin="anonymous"></script>
|
|
100
|
+
|
|
101
|
+
<!-- Socket.IO JS -->
|
|
102
|
+
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
|
103
|
+
<!-- Visualize.js -->
|
|
104
|
+
<script src="visualizer.js"></script>
|
|
105
|
+
<!-- Main JS -->
|
|
106
|
+
<script type="module">
|
|
107
|
+
const lat = <%= lat %>;
|
|
108
|
+
const lon = <%= lon %>;
|
|
109
|
+
const socket = io({
|
|
110
|
+
path: "/socket",
|
|
111
|
+
reconnection: false
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function setProgress(el, value) {
|
|
115
|
+
//プログレスバー
|
|
116
|
+
const v = Math.max(0, Math.min(100, Number(value)));
|
|
117
|
+
el.style.setProperty('--value', v);
|
|
118
|
+
el.setAttribute('aria-valuenow', String(v));
|
|
119
|
+
const label = el.querySelector('.progress__label');
|
|
120
|
+
if (label) label.textContent = v + '%';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function downloadJSON(filename, obj) {
|
|
124
|
+
const json = JSON.stringify(obj, null, 2);
|
|
125
|
+
const blob = new Blob([json], {
|
|
126
|
+
type: "application/json"
|
|
127
|
+
});
|
|
128
|
+
const url = URL.createObjectURL(blob);
|
|
129
|
+
const a = document.createElement("a");
|
|
130
|
+
a.href = url;
|
|
131
|
+
a.download = filename;
|
|
132
|
+
document.body.appendChild(a);
|
|
133
|
+
a.click();
|
|
134
|
+
a.remove();
|
|
135
|
+
URL.revokeObjectURL(url);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
socket.on('connect', () => {
|
|
139
|
+
//console.log('Connected to server');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
socket.on('disconnect', () => {
|
|
143
|
+
//console.log('Disconnected from server');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
let sessionId=null;
|
|
147
|
+
let visualizers = {};
|
|
148
|
+
let layerControl=null;
|
|
149
|
+
socket.on("welcome",async (res) => {
|
|
150
|
+
//console.log(`welcome ${res.sessionId} at ${new Date(res.time).toLocaleTimeString()}`);
|
|
151
|
+
console.log("VIS",res.visualizers);
|
|
152
|
+
sessionId = res.sessionId;
|
|
153
|
+
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]]; })));
|
|
154
|
+
console.log(visualizers);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
function generateLegend(legends) {
|
|
158
|
+
return legends.reduce(
|
|
159
|
+
(memo, legend) => {
|
|
160
|
+
return memo + `<li><span class="legend_circle" style="border-color:${legend.stroke};background:${legend.fill};"></span><span class="legend_category">${legend.category}</spam></li>`
|
|
161
|
+
},
|
|
162
|
+
"");
|
|
163
|
+
}
|
|
164
|
+
const BottomLeftProgressBar = L.Control.extend({
|
|
165
|
+
options: {
|
|
166
|
+
position: 'bottomleft'
|
|
167
|
+
},
|
|
168
|
+
onAdd(map) {
|
|
169
|
+
const div = L.DomUtil.create('div', 'map-bottom-left-progressbar');
|
|
170
|
+
|
|
171
|
+
div.innerHTML = `
|
|
172
|
+
<div class="panel-contents">
|
|
173
|
+
<!-- 幅は親 .progress-wrap の width/max-width で制御。 -->
|
|
174
|
+
<div id="progressCrawl" class="progress is-lg" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="32" aria-label="進捗" style="--value:32">
|
|
175
|
+
<div class="progress__bar" aria-hidden="true"></div>
|
|
176
|
+
<div class="progress__label" aria-hidden="true">32%</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>`;
|
|
179
|
+
// コントロール内の操作が地図ドラッグに奪われないようにする
|
|
180
|
+
L.DomEvent.disableClickPropagation(div);
|
|
181
|
+
L.DomEvent.disableScrollPropagation(div);
|
|
182
|
+
return div;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
const BottomLeftLegend = L.Control.extend({
|
|
186
|
+
options: {
|
|
187
|
+
position: 'bottomleft'
|
|
188
|
+
},
|
|
189
|
+
onAdd(map) {
|
|
190
|
+
const div = L.DomUtil.create('div', 'map-bottom-left-legend');
|
|
191
|
+
|
|
192
|
+
div.innerHTML = `
|
|
193
|
+
<div class="panel-contents">
|
|
194
|
+
<div id="map_legend">
|
|
195
|
+
</div>
|
|
196
|
+
<div class="center-x">
|
|
197
|
+
<button id="download-json" class="dl-btn" title="Download JSON">
|
|
198
|
+
<!-- インラインSVG(ダウンロードアイコン) -->
|
|
199
|
+
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
200
|
+
<path d="M12 3v9m0 0l4-4m-4 4L8 8m-5 9v2a2 2 0 002 2h14a2 2 0 002-2v-2"
|
|
201
|
+
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
202
|
+
</svg>
|
|
203
|
+
<span class="label">ダウンロード</span>
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
</div>`;
|
|
207
|
+
// コントロール内の操作が地図ドラッグに奪われないようにする
|
|
208
|
+
L.DomEvent.disableClickPropagation(div);
|
|
209
|
+
L.DomEvent.disableScrollPropagation(div);
|
|
210
|
+
return div;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
socket.on('progress', (res) => {
|
|
214
|
+
//console.table(res.progress);
|
|
215
|
+
//console.log(Object.values(res.progress));
|
|
216
|
+
let {
|
|
217
|
+
crawled,
|
|
218
|
+
total
|
|
219
|
+
} = Object.values(res.progress).reduce(
|
|
220
|
+
(acc, g) => (g == null ? acc : {
|
|
221
|
+
crawled: acc.crawled + (g.crawled ?? 0),
|
|
222
|
+
total: acc.total + (g.total ?? 0)
|
|
223
|
+
}), {
|
|
224
|
+
crawled: 0,
|
|
225
|
+
total: 0
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
if (total == 0) {
|
|
229
|
+
crawled = 1, total = 1;
|
|
230
|
+
}
|
|
231
|
+
const percent = Math.round((crawled / total) * 100);
|
|
232
|
+
console.log(`[Done] ${crawled} / ${total} --> ${percent}`);
|
|
233
|
+
setProgress(document.getElementById("progressCrawl"), percent);
|
|
234
|
+
//ここでHexのプログレスグラデーションレイヤのOpacity調整
|
|
235
|
+
highlightHexById(res.hexId, 1 - res.progress[res.hexId].percent, "#263238");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
function isPlainObject(a) {
|
|
239
|
+
if (a === null || typeof a !== 'object') return false; // 原始値/関数など除外
|
|
240
|
+
const proto = Object.getPrototypeOf(a);
|
|
241
|
+
return proto === Object.prototype || proto === null; // {} / Object.create(null)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let latestResult = {};
|
|
245
|
+
const overlays = {};
|
|
246
|
+
socket.on('result',async (res) => {
|
|
247
|
+
latestResult["_"]["visualizers"] = res.visualizers;
|
|
248
|
+
latestResult["_"]["plugin"] = res.plugin;
|
|
249
|
+
clearHexHighlight();
|
|
250
|
+
latestResult = {...latestResult, ...res.geoJson};
|
|
251
|
+
console.log("result");
|
|
252
|
+
console.log("geoJSON",res.geoJson);
|
|
253
|
+
for(const vis in visualizers){
|
|
254
|
+
const layers = await visualizers[vis](map, res.geoJson[vis], {palette:res.palette});
|
|
255
|
+
console.log("【レイヤ】",layers);
|
|
256
|
+
if((layers == null) ){
|
|
257
|
+
//SKIP
|
|
258
|
+
}else if(layers.hasOwnProperty("type") && layers["type"]=="FeatureCollection"){
|
|
259
|
+
//レイヤ一つ
|
|
260
|
+
overlays[`[${vis}]`] = layers;
|
|
261
|
+
}else{
|
|
262
|
+
for(const name in layers){
|
|
263
|
+
overlays[`[${vis}] ${name}`] = layers[name];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
//進捗バーを消して凡例を表示
|
|
268
|
+
if (myProgress) map.removeControl(myProgress);
|
|
269
|
+
myLegend = new BottomLeftLegend().addTo(map);
|
|
270
|
+
document.getElementById('map_legend')?.replaceChildren();
|
|
271
|
+
const ul = document.createElement('div');
|
|
272
|
+
ul.className = 'legend_ul';
|
|
273
|
+
const palette = [];
|
|
274
|
+
for (const cat in res.palette) {
|
|
275
|
+
palette.push({
|
|
276
|
+
category: cat,
|
|
277
|
+
fill: res.palette[cat].color,
|
|
278
|
+
stroke: res.palette[cat].darken
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
ul.innerHTML = generateLegend(palette);
|
|
282
|
+
console.log(overlays);
|
|
283
|
+
layerControl = L.control.layers([baseLayer], {
|
|
284
|
+
...overlays,
|
|
285
|
+
"Hex Grid": hexLayer,
|
|
286
|
+
"Tri Grid": triLayer
|
|
287
|
+
}, {
|
|
288
|
+
position: 'topleft'
|
|
289
|
+
});
|
|
290
|
+
layerControl.addTo(map);
|
|
291
|
+
document.getElementById("map_legend")?.appendChild(ul);
|
|
292
|
+
document.getElementById("download-json").addEventListener("click", () => {
|
|
293
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
294
|
+
downloadJSON(`data-${stamp}.json`, latestResult);
|
|
295
|
+
let sessionId = null;
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// 地図
|
|
300
|
+
const map = L.map('map', {
|
|
301
|
+
preferCanvas:true,
|
|
302
|
+
zoomControl: true
|
|
303
|
+
}).setView([lat, lon], 12);
|
|
304
|
+
const baseLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
305
|
+
maxZoom: 19,
|
|
306
|
+
attribution: '© OpenStreetMap contributors'
|
|
307
|
+
}).addTo(map);
|
|
308
|
+
/*
|
|
309
|
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
310
|
+
maxZoom: 19,
|
|
311
|
+
attribution: '© OpenStreetMap contributors'
|
|
312
|
+
}).addTo(map);
|
|
313
|
+
*/
|
|
314
|
+
// Drawn items
|
|
315
|
+
const drawnItems = new L.FeatureGroup();
|
|
316
|
+
map.addLayer(drawnItems);
|
|
317
|
+
|
|
318
|
+
// Draw control(矩形・ポリゴンのみ)
|
|
319
|
+
const drawControl = new L.Control.Draw({
|
|
320
|
+
position: 'topleft',
|
|
321
|
+
draw: {
|
|
322
|
+
polygon: {
|
|
323
|
+
allowIntersection: false,
|
|
324
|
+
showArea: true,
|
|
325
|
+
metric: true,
|
|
326
|
+
},
|
|
327
|
+
polyline: false,
|
|
328
|
+
marker: false,
|
|
329
|
+
circle: false,
|
|
330
|
+
circlemarker: false,
|
|
331
|
+
rectangle: {
|
|
332
|
+
shapeOptions: {
|
|
333
|
+
weight: 1,
|
|
334
|
+
fillOpacity: 0
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
/* edit: {
|
|
339
|
+
featureGroup: drawnItems,
|
|
340
|
+
edit: true,
|
|
341
|
+
remove: true
|
|
342
|
+
}*/
|
|
343
|
+
});
|
|
344
|
+
map.addControl(drawControl);
|
|
345
|
+
|
|
346
|
+
// レイヤ参照
|
|
347
|
+
let hexLayer = null;
|
|
348
|
+
let triLayer = null;
|
|
349
|
+
let myStartBtn = null;
|
|
350
|
+
let myLegend = null;
|
|
351
|
+
let myProgress = null;
|
|
352
|
+
let hexLayerIndex = {}; // hexId -> layer
|
|
353
|
+
let triLayerIndex = {}; // triangleId -> layer
|
|
354
|
+
let hexLabelLayer = L.layerGroup(); // Hex IDラベル群
|
|
355
|
+
|
|
356
|
+
// Util: Bounds → "minLon,minLat,maxLon,maxLat"
|
|
357
|
+
function boundsToBbox(bounds) {
|
|
358
|
+
return [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(',');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ハイライト制御
|
|
362
|
+
function clearHexHighlight() {
|
|
363
|
+
Object.values(hexLayerIndex).forEach(layer => {
|
|
364
|
+
layer.setStyle({
|
|
365
|
+
weight: 2,
|
|
366
|
+
opacity: 0.5,
|
|
367
|
+
fillOpacity: 0.1,
|
|
368
|
+
color: "#37474F",
|
|
369
|
+
fillColor: "#546E7A",
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function highlightHexById(hexId, fillOpacity = 0.7, fillColor = "#3949AB") {
|
|
375
|
+
const layer = hexLayerIndex[hexId];
|
|
376
|
+
if (layer) {
|
|
377
|
+
layer.setStyle({
|
|
378
|
+
weight: 2,
|
|
379
|
+
opacity: 1,
|
|
380
|
+
fillOpacity: fillOpacity,
|
|
381
|
+
color: "#37474F",
|
|
382
|
+
fillColor: fillColor,
|
|
383
|
+
});
|
|
384
|
+
if (layer.bringToFront) layer.bringToFront();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function clearTriHighlight() {
|
|
389
|
+
Object.values(triLayerIndex).forEach(layer => {
|
|
390
|
+
layer.setStyle({
|
|
391
|
+
weight: 1,
|
|
392
|
+
opacity: 0.8,
|
|
393
|
+
fillOpacity: 0,
|
|
394
|
+
fillColor: "#EC407A",
|
|
395
|
+
color: "#F50057",
|
|
396
|
+
dashArray: "10 20",
|
|
397
|
+
dashOffset: 10,
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function highlightTriangle(triId, style = {
|
|
403
|
+
weight: 1,
|
|
404
|
+
opacity: 1,
|
|
405
|
+
fillOpacity: 0.5,
|
|
406
|
+
fillColor: "#EC407A",
|
|
407
|
+
color: "#F50057",
|
|
408
|
+
dashArray: "15,30",
|
|
409
|
+
dashOffset: 0,
|
|
410
|
+
}) {
|
|
411
|
+
const layer = triLayerIndex[triId];
|
|
412
|
+
if (layer) {
|
|
413
|
+
layer.setStyle(style);
|
|
414
|
+
if (layer.bringToFront) layer.bringToFront();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Hex IDラベルを作成
|
|
419
|
+
function renderHexLabels(hexFC) {
|
|
420
|
+
hexLabelLayer.clearLayers();
|
|
421
|
+
hexFC.features.forEach(f => {
|
|
422
|
+
const id = f.properties?.hexId;
|
|
423
|
+
if (!id) return;
|
|
424
|
+
const coords = f.geometry.coordinates[0];
|
|
425
|
+
let sumLat = 0,
|
|
426
|
+
sumLon = 0,
|
|
427
|
+
n = 0;
|
|
428
|
+
coords.slice(0, -1).forEach(([lon, lat]) => {
|
|
429
|
+
sumLat += lat;
|
|
430
|
+
sumLon += lon;
|
|
431
|
+
n++;
|
|
432
|
+
});
|
|
433
|
+
const lat = sumLat / n,
|
|
434
|
+
lon = sumLon / n;
|
|
435
|
+
|
|
436
|
+
const icon = L.divIcon({
|
|
437
|
+
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>`,
|
|
438
|
+
className: '',
|
|
439
|
+
iconSize: [0, 0]
|
|
440
|
+
});
|
|
441
|
+
L.marker([lat, lon], {
|
|
442
|
+
icon
|
|
443
|
+
}).addTo(hexLabelLayer);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Hex / Triangles を描画
|
|
448
|
+
function renderLayers(hexFC, trianglesFC) {
|
|
449
|
+
if (hexLayer) map.removeLayer(hexLayer);
|
|
450
|
+
if (triLayer) map.removeLayer(triLayer);
|
|
451
|
+
document.getElementById('map_legend')?.replaceChildren();
|
|
452
|
+
if (myLegend) map.removeControl(myLegend);
|
|
453
|
+
if (myStartBtn) map.removeControl(myStartBtn);
|
|
454
|
+
hexLabelLayer.removeFrom(map);
|
|
455
|
+
hexLayerIndex = {};
|
|
456
|
+
triLayerIndex = {};
|
|
457
|
+
|
|
458
|
+
// 六角形
|
|
459
|
+
hexLayer = L.geoJSON(hexFC, {
|
|
460
|
+
style: {
|
|
461
|
+
weight: 2,
|
|
462
|
+
opacity: 0.5,
|
|
463
|
+
fillOpacity: 0.1,
|
|
464
|
+
color: "#37474F",
|
|
465
|
+
fillColor: "#546E7A",
|
|
466
|
+
},
|
|
467
|
+
onEachFeature: (feature, layer) => {
|
|
468
|
+
const hid = feature.properties?.hexId;
|
|
469
|
+
if (hid != null) hexLayerIndex[hid] = layer;
|
|
470
|
+
const triIds = feature.properties?.triIds?.length ? feature.properties.triIds.join(', ') : 'N/A';
|
|
471
|
+
layer.bindPopup(`Hex ID: ${hid}<br>Triangles: ${triIds}`);
|
|
472
|
+
}
|
|
473
|
+
}).addTo(map);
|
|
474
|
+
|
|
475
|
+
const BottomLeftPanel = L.Control.extend({
|
|
476
|
+
options: {
|
|
477
|
+
position: 'bottomleft'
|
|
478
|
+
},
|
|
479
|
+
onAdd(map) {
|
|
480
|
+
const div = L.DomUtil.create('div', 'map-bottom-left-control');
|
|
481
|
+
div.innerHTML = `
|
|
482
|
+
<div class="panel-contents">
|
|
483
|
+
<button class="btn"id="doSomething">Start Crawling!</button>
|
|
484
|
+
</div>`;
|
|
485
|
+
// コントロール内の操作が地図ドラッグに奪われないようにする
|
|
486
|
+
L.DomEvent.disableClickPropagation(div);
|
|
487
|
+
L.DomEvent.disableScrollPropagation(div);
|
|
488
|
+
|
|
489
|
+
const btn = div.querySelector('#doSomething');
|
|
490
|
+
btn.addEventListener('click', (ev) => {
|
|
491
|
+
ev.preventDefault();
|
|
492
|
+
ev.stopPropagation(); // 念のため地図へのバブリングも止める
|
|
493
|
+
if (myStartBtn) map.removeControl(myStartBtn);
|
|
494
|
+
myProgress = new BottomLeftProgressBar().addTo(map);
|
|
495
|
+
// ---- 任意の処理 ----
|
|
496
|
+
socket.emit("crawling", {
|
|
497
|
+
sessionId: sessionId,
|
|
498
|
+
});
|
|
499
|
+
}, {
|
|
500
|
+
once: true
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
return div;
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
myStartBtn = new BottomLeftPanel().addTo(map);
|
|
507
|
+
|
|
508
|
+
// 三角形
|
|
509
|
+
triLayer = L.geoJSON(trianglesFC, {
|
|
510
|
+
style: {
|
|
511
|
+
weight: 1,
|
|
512
|
+
opacity: 0.8,
|
|
513
|
+
fillOpacity: 0,
|
|
514
|
+
fillColor: "#EC407A",
|
|
515
|
+
color: "#F50057",
|
|
516
|
+
dashArray: "15,30",
|
|
517
|
+
dashOffset: 0,
|
|
518
|
+
},
|
|
519
|
+
onEachFeature: (feature, layer) => {
|
|
520
|
+
const pid = feature.properties?.parentHexId ?? 'N/A';
|
|
521
|
+
const tid = feature.properties?.triangleId ?? 'N/A';
|
|
522
|
+
const cross = feature.properties?.crossNeighbors ?? [];
|
|
523
|
+
const neighborHexIds = feature.properties?.neighborHexIds ?? [];
|
|
524
|
+
|
|
525
|
+
triLayerIndex[tid] = layer;
|
|
526
|
+
|
|
527
|
+
layer.bindPopup(
|
|
528
|
+
`Triangle: ${tid}<br>` +
|
|
529
|
+
`Parent Hex: ${pid}<br>` +
|
|
530
|
+
`Cross neighbors: ${cross.length ? cross.join(', ') : 'None'}<br>` +
|
|
531
|
+
`Neighbor Hexes: ${neighborHexIds.length ? neighborHexIds.join(', ') : 'None'}`
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
layer.on('click', () => {
|
|
535
|
+
// クリック時:当該三角形+交差隣接三角形をハイライト
|
|
536
|
+
clearHexHighlight();
|
|
537
|
+
clearTriHighlight();
|
|
538
|
+
highlightHexById(pid); // 親Hexも強調
|
|
539
|
+
highlightTriangle(tid, {
|
|
540
|
+
weight: 3,
|
|
541
|
+
opacity: 1,
|
|
542
|
+
fillOpacity: 0.30
|
|
543
|
+
}); // クリック対象
|
|
544
|
+
|
|
545
|
+
// 隣接(別Hex)の三角形を同時に強調
|
|
546
|
+
cross.forEach(cid => {
|
|
547
|
+
highlightTriangle(cid, {
|
|
548
|
+
weight: 2,
|
|
549
|
+
opacity: 1,
|
|
550
|
+
fillOpacity: 0.25
|
|
551
|
+
});
|
|
552
|
+
// その親Hexも薄く強調
|
|
553
|
+
const neighborLayer = triLayerIndex[cid];
|
|
554
|
+
if (neighborLayer) {
|
|
555
|
+
const pHex = neighborLayer.feature?.properties?.parentHexId;
|
|
556
|
+
if (pHex != null) highlightHexById(pHex);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// マウスアウトで三角形のハイライトだけ戻す(Hexは維持)
|
|
562
|
+
layer.on('mouseout', () => {
|
|
563
|
+
clearTriHighlight();
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// 三角形トグル
|
|
569
|
+
if (document.getElementById('toggleTriangles').checked) {
|
|
570
|
+
triLayer.addTo(map);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Hex ID ラベル
|
|
574
|
+
renderHexLabels(hexFC);
|
|
575
|
+
if (document.getElementById('toggleHexLabels').checked) {
|
|
576
|
+
hexLabelLayer.addTo(map);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ビュー調整
|
|
580
|
+
const layers = [hexLayer];
|
|
581
|
+
if (document.getElementById('toggleTriangles').checked) layers.push(triLayer);
|
|
582
|
+
const bounds = L.featureGroup(layers).getBounds();
|
|
583
|
+
if (bounds.isValid()) map.fitBounds(bounds, {
|
|
584
|
+
padding: [20, 20]
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// API呼び出し
|
|
589
|
+
async function loadHexgridByBounds(bounds, drawn = null) {
|
|
590
|
+
const bbox = boundsToBbox(bounds);
|
|
591
|
+
document.getElementById('bboxLabel').textContent = `BBOX: ${bbox}`;
|
|
592
|
+
|
|
593
|
+
const cellSize = document.getElementById('cellSize').value.trim();
|
|
594
|
+
const units = document.getElementById('units').value.trim();
|
|
595
|
+
const keywords = document.getElementById('keywords').value.trim();
|
|
596
|
+
latestResult["_"]??={};
|
|
597
|
+
latestResult["_"]["keywords"] = keywords;
|
|
598
|
+
socket.once("hexgrid", (res) => {
|
|
599
|
+
if (res.sessionId) sessionId = res.sessionId;
|
|
600
|
+
//console.log(`hexgrid received (${res.hex.features.length} hexes, ${res.triangles.features.length} triangles)`);
|
|
601
|
+
const hex = res.hex;
|
|
602
|
+
const triangles = res.triangles;
|
|
603
|
+
latestResult["_"]["hex"] = hex;
|
|
604
|
+
latestResult["_"]["triangles"]=[triangles];
|
|
605
|
+
renderLayers(hex, triangles);
|
|
606
|
+
});
|
|
607
|
+
socket.emit("target", {
|
|
608
|
+
sessionId: sessionId,
|
|
609
|
+
query: {
|
|
610
|
+
bbox: bbox,
|
|
611
|
+
drawn: drawn,
|
|
612
|
+
cellSize: cellSize,
|
|
613
|
+
units: units,
|
|
614
|
+
tags: keywords
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Draw イベント
|
|
620
|
+
map.on(L.Draw.Event.CREATED, async (e) => {
|
|
621
|
+
drawnItems.clearLayers();
|
|
622
|
+
const layer = e.layer;
|
|
623
|
+
drawnItems.addLayer(layer);
|
|
624
|
+
try {
|
|
625
|
+
await loadHexgridByBounds(layer.getBounds(), layer.toGeoJSON());
|
|
626
|
+
} catch (err) {
|
|
627
|
+
alert(err.message);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
map.on(L.Draw.Event.EDITSTOP, async () => {
|
|
632
|
+
const layers = drawnItems.getLayers();
|
|
633
|
+
if (layers.length === 1) {
|
|
634
|
+
try {
|
|
635
|
+
await loadHexgridByBounds(layers[0].getBounds(), layers[0].toGeoJSON());
|
|
636
|
+
} catch (err) {
|
|
637
|
+
alert(err.message);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
map.on(L.Draw.Event.DELETED, () => {
|
|
643
|
+
drawnItems.clearLayers();
|
|
644
|
+
if (hexLayer) {
|
|
645
|
+
map.removeLayer(hexLayer);
|
|
646
|
+
hexLayer = null;
|
|
647
|
+
}
|
|
648
|
+
if (triLayer) {
|
|
649
|
+
map.removeLayer(triLayer);
|
|
650
|
+
triLayer = null;
|
|
651
|
+
}
|
|
652
|
+
if (myStartBtn) {
|
|
653
|
+
map.removeControl(myStartBtn);
|
|
654
|
+
myStartBtn = null;
|
|
655
|
+
}
|
|
656
|
+
if (myLegend) {
|
|
657
|
+
document.getElementById('map_legend')?.replaceChildren();
|
|
658
|
+
map.removeControl(myLegend);
|
|
659
|
+
myLegend = null;
|
|
660
|
+
}
|
|
661
|
+
hexLabelLayer.removeFrom(map);
|
|
662
|
+
document.getElementById('bboxLabel').textContent = '';
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// ======= ハンバーガー / パネル制御 =======
|
|
666
|
+
const hamburger = document.getElementById('hamburger');
|
|
667
|
+
const sidepanel = document.getElementById('sidepanel');
|
|
668
|
+
const panelClose = document.getElementById('panelClose');
|
|
669
|
+
const panelOverlay = document.getElementById('panelOverlay');
|
|
670
|
+
|
|
671
|
+
function openPanel() {
|
|
672
|
+
sidepanel.classList.add('open');
|
|
673
|
+
hamburger.setAttribute('aria-expanded', 'true');
|
|
674
|
+
sidepanel.setAttribute('aria-hidden', 'false');
|
|
675
|
+
panelOverlay.hidden = false;
|
|
676
|
+
// 画面幅が狭い時に、Leafletのツールと干渉しないようパネルを最前面に
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function closePanel() {
|
|
680
|
+
sidepanel.classList.remove('open');
|
|
681
|
+
hamburger.setAttribute('aria-expanded', 'false');
|
|
682
|
+
sidepanel.setAttribute('aria-hidden', 'true');
|
|
683
|
+
panelOverlay.hidden = true;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
hamburger.addEventListener('click', () => {
|
|
687
|
+
if (sidepanel.classList.contains('open')) closePanel();
|
|
688
|
+
else openPanel();
|
|
689
|
+
});
|
|
690
|
+
panelClose.addEventListener('click', closePanel);
|
|
691
|
+
panelOverlay.addEventListener('click', closePanel);
|
|
692
|
+
// ESCで閉じる
|
|
693
|
+
document.addEventListener('keydown', (e) => {
|
|
694
|
+
if (e.key === 'Escape') closePanel();
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// UIトグル
|
|
698
|
+
document.getElementById('clear').addEventListener('click', () => {
|
|
699
|
+
drawnItems.clearLayers();
|
|
700
|
+
if (hexLayer) {
|
|
701
|
+
map.removeLayer(hexLayer);
|
|
702
|
+
hexLayer = null;
|
|
703
|
+
}
|
|
704
|
+
if (triLayer) {
|
|
705
|
+
map.removeLayer(triLayer);
|
|
706
|
+
triLayer = null;
|
|
707
|
+
}
|
|
708
|
+
if (myStartBtn) {
|
|
709
|
+
map.removeControl(myStartBtn);
|
|
710
|
+
myStartBtn = null;
|
|
711
|
+
}
|
|
712
|
+
if (myLegend) {
|
|
713
|
+
document.getElementById('map_legend')?.replaceChildren();
|
|
714
|
+
map.removeControl(myLegend);
|
|
715
|
+
myLegend = null;
|
|
716
|
+
}
|
|
717
|
+
hexLabelLayer.removeFrom(map);
|
|
718
|
+
document.getElementById('bboxLabel').textContent = '';
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
document.getElementById('toggleTriangles').addEventListener('change', () => {
|
|
722
|
+
if (!triLayer) return;
|
|
723
|
+
if (document.getElementById('toggleTriangles').checked) triLayer.addTo(map);
|
|
724
|
+
else map.removeLayer(triLayer);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
document.getElementById('toggleHexLabels').addEventListener('change', () => {
|
|
728
|
+
if (document.getElementById('toggleHexLabels').checked) {
|
|
729
|
+
hexLabelLayer.addTo(map);
|
|
730
|
+
} else {
|
|
731
|
+
hexLabelLayer.removeFrom(map);
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// 初期は描画待ち(必要なら初期BBOXを有効化)
|
|
736
|
+
// loadHexgridByBounds(L.latLngBounds([35.53, 139.55], [35.80, 139.92]));
|
|
737
|
+
</script>
|
|
738
|
+
</body>
|
|
739
|
+
|
|
740
|
+
</html>
|