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/lib/util.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// tri-grid-utils.mjs
|
|
2
|
+
// Utilities for triangular lattices on a triangle polygon (GeoJSON) using Turf.js.
|
|
3
|
+
// Exports:
|
|
4
|
+
// - triangleLatticeByDivisions(triangle, divisions, { interiorOnly })
|
|
5
|
+
// - divisionsForClosestCount(N, { interiorOnly })
|
|
6
|
+
// - incircleDiameter(triangle)
|
|
7
|
+
//
|
|
8
|
+
// npm i @turf/turf @turf/helpers @turf/invariant
|
|
9
|
+
import { featureCollection, point } from '@turf/helpers';
|
|
10
|
+
import { area, distance } from '@turf/turf';
|
|
11
|
+
import { getCoords } from '@turf/invariant';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {import('geojson').Position} Position
|
|
15
|
+
* @typedef {import('geojson').Feature<import('geojson').Polygon>} PolygonFeature
|
|
16
|
+
* @typedef {import('geojson').FeatureCollection<import('geojson').Point>} PointFC
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 内部で使う: 三角形ポリゴンから3頂点を取り出す([lng,lat]×3) */
|
|
20
|
+
function extractTriangleVertices(triangle /** @type {PolygonFeature} */) {
|
|
21
|
+
let ring = getCoords(triangle)[0];
|
|
22
|
+
// 閉ループ (v0 == v_last) の場合は末尾を落とす
|
|
23
|
+
if (
|
|
24
|
+
ring.length === 4 &&
|
|
25
|
+
ring[0][0] === ring[3][0] &&
|
|
26
|
+
ring[0][1] === ring[3][1]
|
|
27
|
+
) {
|
|
28
|
+
ring = ring.slice(0, 3);
|
|
29
|
+
}
|
|
30
|
+
if (!ring || ring.length !== 3) {
|
|
31
|
+
throw new Error('triangle polygon must have exactly 3 vertices');
|
|
32
|
+
}
|
|
33
|
+
return /** @type {[Position, Position, Position]} */ (ring);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 三角形ポリゴン内に「辺 m 等分」の正三角格子(頂点格子)を生成
|
|
38
|
+
* @param {PolygonFeature} triangle - 三角形(外輪の最初=最後が同一点でもOK)
|
|
39
|
+
* @param {number} divisions - 辺の等分数 m (1以上の整数)
|
|
40
|
+
* @param {{ interiorOnly?: boolean }} [opts] - true=境界を除外(内部点のみ)
|
|
41
|
+
* @returns {PointFC}
|
|
42
|
+
*/
|
|
43
|
+
export function triangleLatticeByDivisions(triangle, divisions, opts = {}) {
|
|
44
|
+
const { interiorOnly = false } = opts;
|
|
45
|
+
const m = Math.max(1, Math.floor(divisions));
|
|
46
|
+
|
|
47
|
+
const [A, B, C] = extractTriangleVertices(triangle);
|
|
48
|
+
|
|
49
|
+
const pts = [];
|
|
50
|
+
if (!interiorOnly) {
|
|
51
|
+
// 境界含む: i+j+k=m, i,j,k >= 0
|
|
52
|
+
for (let i = 0; i <= m; i++) {
|
|
53
|
+
for (let j = 0; j <= m - i; j++) {
|
|
54
|
+
const k = m - i - j;
|
|
55
|
+
const a = i / m,
|
|
56
|
+
b = j / m,
|
|
57
|
+
c = k / m;
|
|
58
|
+
const lng = a * A[0] + b * B[0] + c * C[0];
|
|
59
|
+
const lat = a * A[1] + b * B[1] + c * C[1];
|
|
60
|
+
pts.push(point([lng, lat], { i, j, k }));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
// 内部のみ: i,j,k >= 1 となる点(m>=2 のときだけ存在)
|
|
65
|
+
if (m < 2) return featureCollection([]);
|
|
66
|
+
for (let i = 1; i <= m - 1; i++) {
|
|
67
|
+
for (let j = 1; j <= m - i - 1; j++) {
|
|
68
|
+
const k = m - i - j;
|
|
69
|
+
if (k < 1) continue;
|
|
70
|
+
const a = i / m,
|
|
71
|
+
b = j / m,
|
|
72
|
+
c = k / m;
|
|
73
|
+
const lng = a * A[0] + b * B[0] + c * C[0];
|
|
74
|
+
const lat = a * A[1] + b * B[1] + c * C[1];
|
|
75
|
+
pts.push(point([lng, lat], { i, j, k }));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return featureCollection(pts);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 目標の点数 N に最も近い「辺の等分数 m」を高速に求める
|
|
84
|
+
* - 境界を含む点数: T_all(m) = (m+1)(m+2)/2
|
|
85
|
+
* - 内部点のみ : T_in (m) = (m-1)m/2
|
|
86
|
+
* @param {number} N - 目標点数 (>0)
|
|
87
|
+
* @param {{ interiorOnly?: boolean }} [opts] - true=内部点のみ基準
|
|
88
|
+
* @returns {{ m:number, count:number, diff:number }}
|
|
89
|
+
*/
|
|
90
|
+
export function divisionsForClosestCount(N, opts = {}) {
|
|
91
|
+
const { interiorOnly = false } = opts;
|
|
92
|
+
if (!Number.isFinite(N) || N <= 0) {
|
|
93
|
+
return { m: 0, count: 0, diff: Math.max(0, Math.floor(N || 0)) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 連続解(正の根)
|
|
97
|
+
// all: (m+1)(m+2)/2 ≈ N → m ≈ (-3 + sqrt(1+8N))/2
|
|
98
|
+
// in : (m-1)m/2 ≈ N → m ≈ (1 + sqrt(1+8N))/2
|
|
99
|
+
const mCont = interiorOnly
|
|
100
|
+
? (1 + Math.sqrt(1 + 8 * N)) / 2
|
|
101
|
+
: (-3 + Math.sqrt(1 + 8 * N)) / 2;
|
|
102
|
+
|
|
103
|
+
const mMin = interiorOnly ? 2 : 0;
|
|
104
|
+
const candidates = [Math.floor(mCont), Math.ceil(mCont)].map((m) =>
|
|
105
|
+
Math.max(mMin, m)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
let best = /** @type {{ m:number, count:number, diff:number } | null} */ (null);
|
|
109
|
+
for (const m of candidates) {
|
|
110
|
+
const count = interiorOnly ? ((m - 1) * m) / 2 : ((m + 1) * (m + 2)) / 2;
|
|
111
|
+
const diff = Math.abs(count - N);
|
|
112
|
+
if (!best || diff < best.diff || (diff === best.diff && m < best.m)) {
|
|
113
|
+
best = { m, count, diff };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return /** @type {any} */ (best);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 三角形の内接円の直径(メートル)を返す
|
|
121
|
+
* D = 4A / P (A: 面積[m^2], P: 周長[m])
|
|
122
|
+
* @param {PolygonFeature} triangle
|
|
123
|
+
* @returns {number} diameterMeters
|
|
124
|
+
*/
|
|
125
|
+
export function incircleDiameter(triangle) {
|
|
126
|
+
const [A, B, C] = extractTriangleVertices(triangle);
|
|
127
|
+
|
|
128
|
+
// 周長 P(m)
|
|
129
|
+
const a = distance(point(B), point(C), { units: 'meters' }); // |BC|
|
|
130
|
+
const b = distance(point(C), point(A), { units: 'meters' }); // |CA|
|
|
131
|
+
const c = distance(point(A), point(B), { units: 'meters' }); // |AB|
|
|
132
|
+
const P = a + b + c;
|
|
133
|
+
|
|
134
|
+
// 面積 A(m^2)
|
|
135
|
+
const A_m2 = area(triangle);
|
|
136
|
+
|
|
137
|
+
if (P <= 0 || A_m2 <= 0) {
|
|
138
|
+
throw new Error('degenerate triangle (zero perimeter or area)');
|
|
139
|
+
}
|
|
140
|
+
return (4 * A_m2) / P; // 直径 D
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// まとめて import したい場合用
|
|
144
|
+
export default {
|
|
145
|
+
triangleLatticeByDivisions,
|
|
146
|
+
divisionsForClosestCount,
|
|
147
|
+
incircleDiameter,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/* -------------------------
|
|
151
|
+
USAGE (example):
|
|
152
|
+
|
|
153
|
+
import { polygon } from '@turf/turf';
|
|
154
|
+
import {
|
|
155
|
+
triangleLatticeByDivisions,
|
|
156
|
+
divisionsForClosestCount,
|
|
157
|
+
incircleDiameter
|
|
158
|
+
} from './util.js';
|
|
159
|
+
|
|
160
|
+
const tri = polygon([[
|
|
161
|
+
[139.70, 35.65],
|
|
162
|
+
[139.75, 35.65],
|
|
163
|
+
[139.725, 35.70],
|
|
164
|
+
[139.70, 35.65],
|
|
165
|
+
]]);
|
|
166
|
+
|
|
167
|
+
// 1) m 等分の格子(境界含む)
|
|
168
|
+
const m = 10;
|
|
169
|
+
const fcAll = triangleLatticeByDivisions(tri, m); // (m+1)(m+2)/2 点
|
|
170
|
+
|
|
171
|
+
// 2) 目標 N に最も近い m を探す → その m で格子生成
|
|
172
|
+
const targetN = 25;
|
|
173
|
+
const { m: bestM } = divisionsForClosestCount(targetN, { interiorOnly: false });
|
|
174
|
+
const fcClosest = triangleLatticeByDivisions(tri, bestM);
|
|
175
|
+
|
|
176
|
+
// 3) 内接円の直径(メートル)
|
|
177
|
+
const D = incircleDiameter(tri);
|
|
178
|
+
console.log('incircle diameter (m):', D);
|
|
179
|
+
|
|
180
|
+
-------------------------- */
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "splatone",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Multi-layer Composite Heatmap",
|
|
5
|
+
"homepage": "https://github.com/YokoyamaLab/Splatone#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/YokoyamaLab/Splatone/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/YokoyamaLab/Splatone.git"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Shohei Yokoyama (Tokyo Metropolitan University)",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "index.js",
|
|
17
|
+
"bin": {
|
|
18
|
+
"crawler": "./crawler.js"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@turf/turf": "^7.2.0",
|
|
25
|
+
"chroma-js": "^3.1.2",
|
|
26
|
+
"ejs": "^3.1.10",
|
|
27
|
+
"express": "^5.1.0",
|
|
28
|
+
"flickr-sdk": "^7.1.0",
|
|
29
|
+
"iwanthue": "^2.0.0",
|
|
30
|
+
"open": "^10.2.0",
|
|
31
|
+
"piscina": "^5.1.3",
|
|
32
|
+
"socket.io": "^4.8.1",
|
|
33
|
+
"uniqid": "^5.4.0",
|
|
34
|
+
"yargs": "^18.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// plugins/hello/index.js
|
|
2
|
+
import { PluginBase } from '../../lib/PluginBase.js';
|
|
3
|
+
import { bbox, polygon, centroid, booleanPointInPolygon, featureCollection } from '@turf/turf';
|
|
4
|
+
|
|
5
|
+
export default class FlickrPlugin extends PluginBase {
|
|
6
|
+
static id = 'flickr'; // 必須
|
|
7
|
+
static name = 'Flickr Plugin'; // 任意
|
|
8
|
+
static description = 'Flickrからジオタグ付きデータを収集する。';
|
|
9
|
+
static version = '1.0.0';
|
|
10
|
+
|
|
11
|
+
async stop() {
|
|
12
|
+
//this.api.log(`[${this.constructor.id}] stop`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 任意の公開メソッド
|
|
16
|
+
async crawl({ hexGrid, triangles/*, tags*/, categories, max_upload_date, sessionId }) {
|
|
17
|
+
if (!this.started) {
|
|
18
|
+
this.start();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const getTrianglesInHex = (hex, triangles) => {
|
|
22
|
+
const hexPoly = polygon(hex.geometry.coordinates);
|
|
23
|
+
const selected = triangles.features.filter(tri => {
|
|
24
|
+
const triPoly = polygon(tri.geometry.coordinates);
|
|
25
|
+
const triCent = centroid(triPoly);
|
|
26
|
+
return booleanPointInPolygon(triCent, hexPoly);
|
|
27
|
+
});
|
|
28
|
+
return featureCollection(selected);
|
|
29
|
+
}
|
|
30
|
+
const hexQuery = {};
|
|
31
|
+
const ks = Object.keys(hexGrid.features);
|
|
32
|
+
await Promise.all(ks.map(async k => {
|
|
33
|
+
const item = hexGrid.features[k];
|
|
34
|
+
hexQuery[item.properties.hexId] = {};
|
|
35
|
+
const cks = Object.keys(categories);
|
|
36
|
+
await Promise.all(cks.map(ck => {
|
|
37
|
+
const tags = categories[ck];
|
|
38
|
+
//console.log("tag=",ck,"/",tags);
|
|
39
|
+
hexQuery[item.properties.hexId][ck] = { photos: [], tags, final: false };
|
|
40
|
+
this.api.emit('splatone:start', {
|
|
41
|
+
plugin: 'flickr',
|
|
42
|
+
API_KEY: this.options.API_KEY,
|
|
43
|
+
hex: item,
|
|
44
|
+
triangles: getTrianglesInHex(item, triangles),
|
|
45
|
+
bbox: bbox(item.geometry),
|
|
46
|
+
category: ck,
|
|
47
|
+
tags,
|
|
48
|
+
max_upload_date,
|
|
49
|
+
sessionId
|
|
50
|
+
});
|
|
51
|
+
}));
|
|
52
|
+
}));
|
|
53
|
+
return `Flickr, ${this.options.API_KEY}, ${hexGrid.features.length} bboxes processed.`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createFlickr } from "flickr-sdk"
|
|
2
|
+
import { point, featureCollection } from "@turf/helpers";
|
|
3
|
+
import booleanPointInPolygon from "@turf/boolean-point-in-polygon";
|
|
4
|
+
|
|
5
|
+
export default async function ({
|
|
6
|
+
API_KEY = "",
|
|
7
|
+
bbox = [0, 0, 0, 0],
|
|
8
|
+
tags = "",
|
|
9
|
+
category = "",
|
|
10
|
+
max_upload_date = null,
|
|
11
|
+
hex = null,
|
|
12
|
+
triangles = null,
|
|
13
|
+
}) {
|
|
14
|
+
const { flickr } = createFlickr(API_KEY);
|
|
15
|
+
const baseParams = {
|
|
16
|
+
bbox: bbox.join(','),
|
|
17
|
+
tags: tags,
|
|
18
|
+
max_upload_date: max_upload_date,
|
|
19
|
+
|
|
20
|
+
};
|
|
21
|
+
const res = await flickr("flickr.photos.search", {
|
|
22
|
+
...baseParams,
|
|
23
|
+
has_geo: 1,
|
|
24
|
+
extras: "date_upload,date_taken,owner_name,geo,url_s,tags",
|
|
25
|
+
per_page: 250,
|
|
26
|
+
page: 1,
|
|
27
|
+
sort: "date-posted-desc"
|
|
28
|
+
});
|
|
29
|
+
//console.log(baseParams);
|
|
30
|
+
//console.log("[(Crawl)", hex.properties.hexId, category, "]", (new Date(max_upload_date * 1000)).toLocaleString(), "-> photos:", res.photos.photo.length, "/", res.photos.total);
|
|
31
|
+
const ids = [];
|
|
32
|
+
const authors = {};
|
|
33
|
+
const photos = featureCollection(res.photos.photo.filter(photo => {
|
|
34
|
+
authors[photo.owner] ??= 0;
|
|
35
|
+
authors[photo.owner]++;
|
|
36
|
+
return booleanPointInPolygon(point([photo.longitude, photo.latitude]), hex);
|
|
37
|
+
}).map(photo => {
|
|
38
|
+
ids.push(photo.id);
|
|
39
|
+
const getTriangleContainingPoint = (point, triangles) => {
|
|
40
|
+
const rtn = triangles.features.filter(tri => {
|
|
41
|
+
return booleanPointInPolygon(point, tri);
|
|
42
|
+
});
|
|
43
|
+
return rtn[0]?.properties?.triangleId.split('-')[1] || null;
|
|
44
|
+
}
|
|
45
|
+
return point(
|
|
46
|
+
[photo.longitude, photo.latitude],
|
|
47
|
+
{
|
|
48
|
+
...photo,
|
|
49
|
+
splatone_plugin: 'flickr',
|
|
50
|
+
splatone_hexId: hex.properties.hexId,
|
|
51
|
+
splatone_triId: getTriangleContainingPoint(point([photo.longitude, photo.latitude]), triangles),
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
}));
|
|
55
|
+
const outside = res.photos.photo.length - photos.features.length;
|
|
56
|
+
//console.log(JSON.stringify(photos, null, 4));
|
|
57
|
+
let next_max_upload_date
|
|
58
|
+
= res.photos.photo.length > 0
|
|
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
|
+
: null;
|
|
61
|
+
if (Object.keys(authors).length == 1) {
|
|
62
|
+
const window = res.photos.photo[res.photos.photo.length - 1].dateupload - res.photos.photo[0].dateupload;
|
|
63
|
+
console.warn("[Warning]", `High posting activity detected for ${Object.keys(authors)} within {$window}. the crawler will skip the next 24 hours.`);
|
|
64
|
+
next_max_upload_date -= 60 * 60 * 24;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
photos,
|
|
68
|
+
hexId: hex.properties.hexId,
|
|
69
|
+
tags,
|
|
70
|
+
category,
|
|
71
|
+
next_max_upload_date,
|
|
72
|
+
total: res.photos.total,
|
|
73
|
+
outside: outside,
|
|
74
|
+
ids,
|
|
75
|
+
final: res.photos.photo.length == res.photos.total
|
|
76
|
+
};
|
|
77
|
+
}
|
package/public/style.css
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/* ====== Map base ====== */
|
|
2
|
+
html,
|
|
3
|
+
body {
|
|
4
|
+
overflow-x: hidden;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
html,
|
|
8
|
+
body,
|
|
9
|
+
#map {
|
|
10
|
+
height: 100%;
|
|
11
|
+
width: 100%;
|
|
12
|
+
margin: 0;
|
|
13
|
+
padding: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* ====== Hamburger Button ====== */
|
|
17
|
+
.hamburger {
|
|
18
|
+
position: absolute;
|
|
19
|
+
top: 12px;
|
|
20
|
+
right: 12px;
|
|
21
|
+
z-index: 1100;
|
|
22
|
+
/* Leaflet control (z-index 1000~) より上 */
|
|
23
|
+
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
24
|
+
background: #fff;
|
|
25
|
+
border-radius: 10px;
|
|
26
|
+
width: 44px;
|
|
27
|
+
height: 44px;
|
|
28
|
+
font-size: 22px;
|
|
29
|
+
line-height: 42px;
|
|
30
|
+
text-align: center;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, .15);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ====== Slide-over Panel (Right) ====== */
|
|
36
|
+
.sidepanel {
|
|
37
|
+
position: absolute;
|
|
38
|
+
top: 0;
|
|
39
|
+
right: 0;
|
|
40
|
+
z-index: 1200;
|
|
41
|
+
height: 100%;
|
|
42
|
+
width: min(88vw, 360px);
|
|
43
|
+
background: #ffffff;
|
|
44
|
+
box-shadow: -8px 0 24px rgba(0, 0, 0, .18);
|
|
45
|
+
transform: translateX(100%);
|
|
46
|
+
transition: transform 0.25s ease;
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.sidepanel.open {
|
|
52
|
+
transform: translateX(0%);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.panel-header {
|
|
56
|
+
display: flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
justify-content: space-between;
|
|
59
|
+
padding: 14px 16px;
|
|
60
|
+
border-bottom: 1px solid #eee;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.panel-header h2 {
|
|
64
|
+
font-size: 18px;
|
|
65
|
+
margin: 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.icon-btn {
|
|
69
|
+
border: none;
|
|
70
|
+
background: transparent;
|
|
71
|
+
font-size: 18px;
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.panel-body {
|
|
76
|
+
padding: 12px 14px 18px;
|
|
77
|
+
overflow-y: auto;
|
|
78
|
+
flex: 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.panel-section {
|
|
82
|
+
margin-bottom: 16px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.muted {
|
|
86
|
+
color: #666;
|
|
87
|
+
font-size: 13px;
|
|
88
|
+
line-height: 1.4;
|
|
89
|
+
margin: 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.field {
|
|
93
|
+
display: flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
justify-content: space-between;
|
|
96
|
+
gap: 10px;
|
|
97
|
+
margin: 8px 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.field>span {
|
|
101
|
+
font-size: 14px;
|
|
102
|
+
color: #333;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.field input[type="number"],
|
|
106
|
+
.field input[type="text"],
|
|
107
|
+
.field select {
|
|
108
|
+
flex: 1;
|
|
109
|
+
border: 1px solid #ccc;
|
|
110
|
+
border-radius: 8px;
|
|
111
|
+
padding: 8px 10px;
|
|
112
|
+
font-size: 14px;
|
|
113
|
+
background: #fff;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.check {
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
gap: 8px;
|
|
120
|
+
margin: 8px 0;
|
|
121
|
+
font-size: 14px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.panel-actions {
|
|
125
|
+
display: flex;
|
|
126
|
+
gap: 8px;
|
|
127
|
+
margin-top: 10px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.btn {
|
|
131
|
+
border: 1px solid #ddd;
|
|
132
|
+
background: #fafafa;
|
|
133
|
+
border-radius: 10px;
|
|
134
|
+
padding: 8px 12px;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
font-size: 14px;
|
|
137
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, .08);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.btn:hover {
|
|
141
|
+
background: #f2f2f2;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.bbox-row {
|
|
145
|
+
margin-top: 10px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.bbox-label {
|
|
149
|
+
display: inline-block;
|
|
150
|
+
font-size: 12px;
|
|
151
|
+
color: #444;
|
|
152
|
+
background: #f7f7f7;
|
|
153
|
+
border: 1px solid #eee;
|
|
154
|
+
border-radius: 8px;
|
|
155
|
+
padding: 3px 6px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ====== Overlay behind the panel ====== */
|
|
159
|
+
.panel-overlay {
|
|
160
|
+
position: absolute;
|
|
161
|
+
inset: 0;
|
|
162
|
+
z-index: 1150;
|
|
163
|
+
background: rgba(0, 0, 0, .25);
|
|
164
|
+
backdrop-filter: blur(1px);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ====== Leaflet.draw Rectangle style (minor tune) ====== */
|
|
168
|
+
.leaflet-draw-toolbar a {
|
|
169
|
+
border-radius: 8px !important;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.map-bottom-left-control {
|
|
173
|
+
width: 160px;
|
|
174
|
+
/* 好きな幅 */
|
|
175
|
+
height: 50px;
|
|
176
|
+
/* 好きな高さ */
|
|
177
|
+
border-radius: 10px;
|
|
178
|
+
background: rgba(255, 255, 255, 0.9);
|
|
179
|
+
border: 1px solid rgba(0, 0, 0, .12);
|
|
180
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, .18);
|
|
181
|
+
overflow: auto;
|
|
182
|
+
/* 中身が溢れたらスクロール */
|
|
183
|
+
backdrop-filter: blur(6px);
|
|
184
|
+
/* 対応ブラウザで半透明の質感UP */
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.map-bottom-left-control .panel-contents {
|
|
188
|
+
padding: 10px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.map-bottom-left-progressbar {
|
|
192
|
+
width: 500px;
|
|
193
|
+
/* 好きな幅 */
|
|
194
|
+
border-radius: 10px;
|
|
195
|
+
background: rgba(255, 255, 255, 0.9);
|
|
196
|
+
border: 1px solid rgba(0, 0, 0, .12);
|
|
197
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, .18);
|
|
198
|
+
overflow: visible;
|
|
199
|
+
backdrop-filter: blur(6px);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.map-bottom-left-legend {
|
|
203
|
+
width: 160px;
|
|
204
|
+
/* 好きな幅 */
|
|
205
|
+
border-radius: 10px;
|
|
206
|
+
background: rgba(255, 255, 255, 0.9);
|
|
207
|
+
border: 1px solid rgba(0, 0, 0, .12);
|
|
208
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, .18);
|
|
209
|
+
overflow: visible;
|
|
210
|
+
backdrop-filter: blur(6px);
|
|
211
|
+
/* 対応ブラウザで半透明の質感UP */
|
|
212
|
+
}
|
|
213
|
+
.map-bottom-left-progressbar .panel-contents,
|
|
214
|
+
.map-bottom-left-legend .panel-contents {
|
|
215
|
+
padding: 10px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.legend_ul {
|
|
219
|
+
list-style: none;
|
|
220
|
+
padding-left: 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.legend_circle {
|
|
224
|
+
display: inline-block;
|
|
225
|
+
width: 16px;
|
|
226
|
+
height: 16px;
|
|
227
|
+
border-radius: 50%;
|
|
228
|
+
border: 1px solid #000;
|
|
229
|
+
background: skyblue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.legend_category {
|
|
233
|
+
padding-left: 0.5em;
|
|
234
|
+
vertical-align: super;
|
|
235
|
+
font-weight: bold;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* プログレスバー */
|
|
239
|
+
:root {
|
|
240
|
+
--pb-height: 14px;
|
|
241
|
+
--pb-radius: 999px;
|
|
242
|
+
--pb-track: hsl(220 10% 18% / 0.25);
|
|
243
|
+
--pb-fill: linear-gradient(90deg, hsl(200 90% 60%), hsl(260 90% 65%));
|
|
244
|
+
--pb-border: hsl(220 10% 90% / 0.6);
|
|
245
|
+
--pb-shadow: 0 1px 0 hsl(0 0% 100% / 0.3) inset,
|
|
246
|
+
0 0 0 1px hsl(220 10% 5% / 0.04) inset,
|
|
247
|
+
0 1px 2px hsl(220 20% 10% / 0.25);
|
|
248
|
+
--pb-transition: 240ms cubic-bezier(.2,.8,.2,1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@media (prefers-color-scheme: dark) {
|
|
253
|
+
:root {
|
|
254
|
+
--pb-track: hsl(220 10% 30% / 0.35);
|
|
255
|
+
--pb-border: hsl(220 10% 25% / 0.9);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
/* 任意幅: 親要素の幅に追従(100%)。必要に応じて親に width を指定してください。 */
|
|
261
|
+
.progress-wrap { width: 100%; max-width: 100%; }
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
.progress {
|
|
265
|
+
--value: 0; /* 0 - 100 */
|
|
266
|
+
position: relative;
|
|
267
|
+
width: 100%;
|
|
268
|
+
height: var(--pb-height);
|
|
269
|
+
background: var(--pb-track);
|
|
270
|
+
border-radius: var(--pb-radius);
|
|
271
|
+
overflow: clip;
|
|
272
|
+
box-shadow: var(--pb-shadow);
|
|
273
|
+
border: 1px solid var(--pb-border);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
.progress__bar {
|
|
278
|
+
width: calc(var(--value) * 1%);
|
|
279
|
+
height: 100%;
|
|
280
|
+
background: var(--pb-fill);
|
|
281
|
+
background-size: 200% 100%;
|
|
282
|
+
transition: width var(--pb-transition);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
.progress__label {
|
|
287
|
+
position: absolute;
|
|
288
|
+
inset: 0; display: grid; place-items: center;
|
|
289
|
+
font-size: 12px; font-variant-numeric: tabular-nums;
|
|
290
|
+
color: hsl(0 0% 100% / .9);
|
|
291
|
+
pointer-events: none;
|
|
292
|
+
text-shadow: 0 1px 1px #0006;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
/* サイズのプリセット(任意で利用) */
|
|
297
|
+
.is-sm { --pb-height: 8px; }
|
|
298
|
+
.is-lg { --pb-height: 22px; }
|
|
299
|
+
|
|
300
|
+
/* ダウンロードボタン */
|
|
301
|
+
.dl-btn{
|
|
302
|
+
display:inline-flex; align-items:center; gap:6px;
|
|
303
|
+
height:32px; padding:0 10px; border:1px solid #ccc; border-radius:8px;
|
|
304
|
+
background:#fff; color:#333; font-size:13px; line-height:1; cursor:pointer;
|
|
305
|
+
box-shadow:0 1px 2px rgba(0,0,0,.06);
|
|
306
|
+
}
|
|
307
|
+
.dl-btn:hover{ background:#f7f7f7; }
|
|
308
|
+
.dl-btn:active{ transform:translateY(1px); }
|
|
309
|
+
.dl-btn:focus{ outline:2px solid #2684ff33; outline-offset:2px; }
|
|
310
|
+
/* 画面が極端に狭い時はラベルを省略(任意) */
|
|
311
|
+
@media (max-width: 420px){
|
|
312
|
+
.dl-btn .label{ display:none; }
|
|
313
|
+
.dl-btn{ width:32px; padding:0; justify-content:center; }
|
|
314
|
+
}
|
|
315
|
+
.center-x {
|
|
316
|
+
display: flex;
|
|
317
|
+
justify-content: center; /* 横中央 */
|
|
318
|
+
}
|