splatone 0.0.18 → 0.0.20

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
@@ -116,6 +116,36 @@
116
116
  reconnection: false
117
117
  });
118
118
 
119
+ function debounce(fn, wait = 300) {
120
+ let timer = null;
121
+ return (...args) => {
122
+ clearTimeout(timer);
123
+ timer = setTimeout(() => fn(...args), wait);
124
+ };
125
+ }
126
+
127
+ function readMapViewCookie() {
128
+ const entry = document.cookie.split('; ').find(row => row.startsWith('splatoneMapView='));
129
+ if (!entry) return null;
130
+ try {
131
+ const encoded = entry.split('=')[1];
132
+ return JSON.parse(decodeURIComponent(encoded));
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ function writeMapViewCookie({ lat, lon, zoom }) {
139
+ const payload = encodeURIComponent(JSON.stringify({ lat, lon, zoom }));
140
+ const maxAge = 60 * 60 * 24 * 30; // 30 days
141
+ document.cookie = `splatoneMapView=${payload}; path=/; max-age=${maxAge}`;
142
+ }
143
+
144
+ const storedView = readMapViewCookie();
145
+ const initialLat = Number.isFinite(Number(storedView?.lat)) ? Number(storedView.lat) : lat;
146
+ const initialLon = Number.isFinite(Number(storedView?.lon)) ? Number(storedView.lon) : lon;
147
+ const initialZoom = Number.isFinite(Number(storedView?.zoom)) ? Number(storedView.zoom) : 12;
148
+
119
149
  function setProgress(el, value) {
120
150
  //プログレスバー
121
151
  const v = Math.max(0, Math.min(100, Number(value)));
@@ -398,7 +428,19 @@
398
428
  const map = L.map('map', {
399
429
  preferCanvas: true,
400
430
  zoomControl: true
401
- }).setView([lat, lon], 12);
431
+ }).setView([initialLat, initialLon], initialZoom);
432
+
433
+ const persistMapView = debounce(() => {
434
+ const center = map.getCenter();
435
+ writeMapViewCookie({
436
+ lat: Number(center.lat.toFixed(6)),
437
+ lon: Number(center.lng.toFixed(6)),
438
+ zoom: map.getZoom()
439
+ });
440
+ }, 500);
441
+
442
+ map.on('moveend', persistMapView);
443
+ map.on('zoomend', persistMapView);
402
444
  const baseLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
403
445
  maxZoom: 19,
404
446
  attribution: '© OpenStreetMap contributors'
@@ -0,0 +1,162 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { featureCollection, centroid as turfCentroid } from '@turf/turf';
4
+ import { VisualizerBase } from '../../lib/VisualizerBase.js';
5
+
6
+ function buildHexIndex(target = {}) {
7
+ const features = target?.hex?.features;
8
+ const index = new Map();
9
+ if (!Array.isArray(features)) return index;
10
+ for (const feature of features) {
11
+ const hexId = feature?.properties?.hexId;
12
+ if (hexId == null) continue;
13
+ index.set(String(hexId), feature);
14
+ }
15
+ return index;
16
+ }
17
+
18
+ function computeHexCentroid(hexFeature) {
19
+ if (!hexFeature) return null;
20
+ try {
21
+ const center = turfCentroid(hexFeature);
22
+ const coords = center?.geometry?.coordinates;
23
+ if (Array.isArray(coords) && coords.length >= 2) {
24
+ return coords;
25
+ }
26
+ } catch (err) {
27
+ console.warn('[PieChartsVisualizer] Failed to compute centroid', err?.message);
28
+ }
29
+ return null;
30
+ }
31
+
32
+ function extractPrimaryRing(feature) {
33
+ const geometry = feature?.geometry;
34
+ if (!geometry) return null;
35
+ if (geometry.type === 'Polygon') {
36
+ const ring = geometry?.coordinates?.[0];
37
+ if (!Array.isArray(ring) || ring.length < 3) return null;
38
+ return ring.map(coords => Array.isArray(coords) ? [coords[0], coords[1]] : null).filter(Boolean);
39
+ }
40
+ if (geometry.type === 'MultiPolygon') {
41
+ const firstPoly = geometry?.coordinates?.[0]?.[0];
42
+ if (!Array.isArray(firstPoly) || firstPoly.length < 3) return null;
43
+ return firstPoly.map(coords => Array.isArray(coords) ? [coords[0], coords[1]] : null).filter(Boolean);
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function aggregatePieChartFeatures(result = {}, target = {}) {
49
+ const hexIndex = buildHexIndex(target);
50
+ const features = [];
51
+ let globalMaxCategoryCount = 0;
52
+ let globalTotalCount = 0;
53
+
54
+ for (const [hexId, categories] of Object.entries(result ?? {})) {
55
+ if (!categories) continue;
56
+ const breakdown = [];
57
+ let totalCount = 0;
58
+
59
+ for (const [categoryName, payload] of Object.entries(categories)) {
60
+ const count = payload?.items?.features?.length ?? 0;
61
+ if (count <= 0) continue;
62
+ breakdown.push({ name: categoryName, count });
63
+ totalCount += count;
64
+ globalTotalCount += count;
65
+ if (count > globalMaxCategoryCount) {
66
+ globalMaxCategoryCount = count;
67
+ }
68
+ }
69
+
70
+ if (totalCount === 0) continue;
71
+
72
+ const hexFeature = hexIndex.get(String(hexId));
73
+ if (!hexFeature) {
74
+ console.warn(`[PieChartsVisualizer] Missing hex polygon for ${hexId}`);
75
+ continue;
76
+ }
77
+
78
+ const centroidCoords = computeHexCentroid(hexFeature);
79
+ if (!centroidCoords) {
80
+ console.warn(`[PieChartsVisualizer] Unable to place pie chart for hex ${hexId}`);
81
+ continue;
82
+ }
83
+
84
+ const ring = extractPrimaryRing(hexFeature);
85
+ if (!ring || ring.length < 3) {
86
+ console.warn(`[PieChartsVisualizer] Missing polygon ring for hex ${hexId}`);
87
+ continue;
88
+ }
89
+
90
+ breakdown.sort((a, b) => b.count - a.count);
91
+
92
+ features.push({
93
+ type: 'Feature',
94
+ geometry: {
95
+ type: 'Point',
96
+ coordinates: centroidCoords
97
+ },
98
+ properties: {
99
+ hexId,
100
+ totalCount,
101
+ categories: breakdown,
102
+ hexCoordinates: ring
103
+ }
104
+ });
105
+ }
106
+
107
+ return {
108
+ features,
109
+ globalMaxCategoryCount,
110
+ globalTotalCount
111
+ };
112
+ }
113
+
114
+ export default class PieChartsVisualizer extends VisualizerBase {
115
+ static name = 'Pie Charts Visualizer';
116
+ static version = '0.0.1';
117
+ static description = 'Hex中心にカテゴリ割合のPie Chartを描画するビジュアライザ';
118
+
119
+ constructor() {
120
+ super();
121
+ this.id = path.basename(path.dirname(fileURLToPath(import.meta.url)));
122
+ }
123
+
124
+ async yargv(yargv) {
125
+ return yargv
126
+ .option(this.argKey('MaxRadiusScale'), {
127
+ group: 'For ' + this.id + ' Visualizer',
128
+ type: 'number',
129
+ description: 'Hex内接円半径に対する最大半径スケール (0-1.5)',
130
+ default: 0.9
131
+ })
132
+ .option(this.argKey('MinRadiusScale'), {
133
+ group: 'For ' + this.id + ' Visualizer',
134
+ type: 'number',
135
+ description: '最大半径に対する最小半径スケール (0-1)',
136
+ default: 0.25
137
+ })
138
+ .option(this.argKey('StrokeWidth'), {
139
+ group: 'For ' + this.id + ' Visualizer',
140
+ type: 'number',
141
+ description: 'Pie Chart輪郭線の太さ(px)',
142
+ default: 1
143
+ })
144
+ .option(this.argKey('BackgroundOpacity'), {
145
+ group: 'For ' + this.id + ' Visualizer',
146
+ type: 'number',
147
+ description: '最大半径ガイドリングの透明度 (0-1)',
148
+ default: 0.2
149
+ });
150
+ }
151
+
152
+ getFutureCollection(result, target, visOptions) { // visOptions reserved for future use
153
+ const { features, globalMaxCategoryCount, globalTotalCount } = aggregatePieChartFeatures(result, target);
154
+ const collection = featureCollection(features);
155
+ collection.properties = {
156
+ globalMaxCategoryCount,
157
+ globalTotalCount
158
+ };
159
+ return collection;
160
+ }
161
+ }
162
+
@@ -0,0 +1,271 @@
1
+ let layerGroup = null;
2
+ let styleInjected = false;
3
+ let zoomHandler = null;
4
+ let activeMap = null;
5
+ let cachedContext = null;
6
+
7
+ function reset(map) {
8
+ if (layerGroup && map) {
9
+ map.removeLayer(layerGroup);
10
+ }
11
+ if (map && zoomHandler) {
12
+ map.off('zoomend', zoomHandler);
13
+ }
14
+ layerGroup = null;
15
+ zoomHandler = null;
16
+ activeMap = null;
17
+ cachedContext = null;
18
+ }
19
+
20
+ function ensureStyles() {
21
+ if (styleInjected) return;
22
+ const style = document.createElement('style');
23
+ style.textContent = `
24
+ .pie-chart-marker {
25
+ background: transparent !important;
26
+ border: none !important;
27
+ }
28
+ .pie-chart-marker svg {
29
+ pointer-events: none;
30
+ display: block;
31
+ }
32
+ `;
33
+ document.head.appendChild(style);
34
+ styleInjected = true;
35
+ }
36
+
37
+ function normalizeFeatureCollection(payload) {
38
+ if (!payload) return null;
39
+ if (payload.type === 'FeatureCollection') return payload;
40
+ if (payload?.pieCharts?.type === 'FeatureCollection') {
41
+ return payload.pieCharts;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ const DEFAULTS = {
47
+ MaxRadiusScale: 0.9,
48
+ MinRadiusScale: 0.25,
49
+ StrokeWidth: 1,
50
+ BackgroundOpacity: 0.2
51
+ };
52
+
53
+ function polarToCartesian(cx, cy, radius, angle) {
54
+ return {
55
+ x: cx + radius * Math.cos(angle),
56
+ y: cy + radius * Math.sin(angle)
57
+ };
58
+ }
59
+
60
+ function buildSlicePath(cx, cy, radius, startAngle, endAngle) {
61
+ if (radius <= 0 || endAngle <= startAngle) return '';
62
+ const start = polarToCartesian(cx, cy, radius, startAngle);
63
+ const end = polarToCartesian(cx, cy, radius, endAngle);
64
+ const largeArcFlag = (endAngle - startAngle) > Math.PI ? 1 : 0;
65
+ return `M ${cx} ${cy} L ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${end.x} ${end.y} Z`;
66
+ }
67
+
68
+ function clamp(value, min, max) {
69
+ return Math.min(max, Math.max(min, value));
70
+ }
71
+
72
+ function computeHexRadiusPx(map, centroidLatLng, ring = []) {
73
+ if (!map || !centroidLatLng || !Array.isArray(ring) || ring.length === 0) {
74
+ return 32;
75
+ }
76
+ const centerPoint = map.latLngToLayerPoint(centroidLatLng);
77
+ let minDistance = Infinity;
78
+ for (const coord of ring) {
79
+ if (!Array.isArray(coord) || coord.length < 2) continue;
80
+ const latLng = L.latLng(coord[1], coord[0]);
81
+ const point = map.latLngToLayerPoint(latLng);
82
+ const dist = centerPoint.distanceTo(point);
83
+ if (Number.isFinite(dist) && dist > 0) {
84
+ minDistance = Math.min(minDistance, dist);
85
+ }
86
+ }
87
+ if (!Number.isFinite(minDistance) || minDistance === Infinity) {
88
+ return 32;
89
+ }
90
+ return Math.max(4, minDistance - 2);
91
+ }
92
+
93
+ function deriveRadiusRange(map, feature, visOptions) {
94
+ const coords = feature?.geometry?.coordinates;
95
+ const centroidLatLng = Array.isArray(coords) && coords.length >= 2 ? L.latLng(coords[1], coords[0]) : null;
96
+ const ring = feature?.properties?.hexCoordinates ?? [];
97
+ const baseRadius = computeHexRadiusPx(map, centroidLatLng, ring);
98
+ const maxScale = clamp(Number(visOptions.MaxRadiusScale ?? DEFAULTS.MaxRadiusScale), 0.1, 1.5);
99
+ const minScale = clamp(Number(visOptions.MinRadiusScale ?? DEFAULTS.MinRadiusScale), 0, 1);
100
+ const maxRadius = Math.max(4, baseRadius * maxScale);
101
+ const minRadius = clamp(baseRadius * minScale, 0, maxRadius * 0.95);
102
+ return { maxRadius, minRadius };
103
+ }
104
+
105
+ function computeRadiusPixels(count, stats, radiusRange) {
106
+ if (!Number.isFinite(count) || count <= 0) return 0;
107
+ const { maxRadius, minRadius } = radiusRange;
108
+ const globalTotal = Math.max(1, Number(stats.globalTotalCount) || 1);
109
+ const globalMaxCount = Math.max(1, Number(stats.globalMaxCategoryCount) || 1);
110
+ const maxShare = globalMaxCount / globalTotal;
111
+ const share = count / globalTotal;
112
+ const normalized = maxShare > 0 ? clamp(share / maxShare, 0, 1) : 0;
113
+ if (normalized <= 0) return minRadius;
114
+ return minRadius + (maxRadius - minRadius) * normalized;
115
+ }
116
+
117
+ function renderPieSvg(feature, palette, visOptions, stats, radiusRange) {
118
+ const categories = feature?.properties?.categories;
119
+ const totalCount = feature?.properties?.totalCount ?? 0;
120
+ if (!Array.isArray(categories) || categories.length === 0 || totalCount === 0) {
121
+ return null;
122
+ }
123
+
124
+ const maxRadius = radiusRange.maxRadius;
125
+ const strokeWidth = Math.max(0, Number(visOptions.StrokeWidth ?? DEFAULTS.StrokeWidth));
126
+ const backgroundOpacity = Math.min(1, Math.max(0, Number(visOptions.BackgroundOpacity ?? DEFAULTS.BackgroundOpacity)));
127
+ const size = (maxRadius + strokeWidth) * 2;
128
+ const cx = size / 2;
129
+ const cy = size / 2;
130
+
131
+ const validCategories = categories.filter(cat => (cat?.count ?? 0) > 0);
132
+ if (!validCategories.length) return null;
133
+
134
+ const total = validCategories.reduce((sum, cat) => sum + cat.count, 0);
135
+ if (total === 0) return null;
136
+
137
+ let currentAngle = -Math.PI / 2;
138
+ const slices = [];
139
+ for (const cat of validCategories) {
140
+ const ratio = cat.count / total;
141
+ const angleSpan = ratio * Math.PI * 2;
142
+ if (angleSpan <= 0) continue;
143
+ const startAngle = currentAngle;
144
+ const endAngle = currentAngle + angleSpan;
145
+ currentAngle = endAngle;
146
+ const radius = computeRadiusPixels(cat.count, stats, radiusRange);
147
+ if (radius <= 0) continue;
148
+ slices.push({
149
+ path: buildSlicePath(cx, cy, radius, startAngle, endAngle),
150
+ color: palette[cat.name]?.color || '#888888',
151
+ stroke: palette[cat.name]?.darken || 'rgba(0,0,0,0.4)',
152
+ count: cat.count,
153
+ name: cat.name
154
+ });
155
+ }
156
+
157
+ if (!slices.length) return null;
158
+
159
+ const outlineColor = 'rgba(0,0,0,0.35)';
160
+ const svgParts = [`<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-label="hex pie chart">`];
161
+ svgParts.push(`<circle cx="${cx}" cy="${cy}" r="${maxRadius}" fill="rgba(255,255,255,${backgroundOpacity})" stroke="${outlineColor}" stroke-width="${strokeWidth}" />`);
162
+
163
+ for (const slice of slices) {
164
+ if (!slice.path) continue;
165
+ svgParts.push(`<path d="${slice.path}" fill="${slice.color}" stroke="${slice.stroke}" stroke-width="${Math.max(0.5, strokeWidth * 0.6)}" />`);
166
+ }
167
+
168
+ svgParts.push('</svg>');
169
+ return { svg: svgParts.join(''), size };
170
+ }
171
+
172
+ function createMarker(map, feature, palette, visOptions, stats) {
173
+ const coords = feature?.geometry?.coordinates;
174
+ if (!Array.isArray(coords) || coords.length < 2) return null;
175
+ const radiusRange = deriveRadiusRange(map, feature, visOptions);
176
+ const rendered = renderPieSvg(feature, palette, visOptions, stats, radiusRange);
177
+ if (!rendered) return null;
178
+
179
+ const icon = L.divIcon({
180
+ className: 'pie-chart-marker',
181
+ html: rendered.svg,
182
+ iconSize: [rendered.size, rendered.size],
183
+ iconAnchor: [rendered.size / 2, rendered.size / 2]
184
+ });
185
+
186
+ const marker = L.marker([coords[1], coords[0]], { icon });
187
+ const total = feature?.properties?.totalCount ?? 0;
188
+ const breakdown = feature?.properties?.categories ?? [];
189
+ const htmlLines = [`<strong>Hex ${feature?.properties?.hexId ?? ''}</strong>`, `Total: ${total}`];
190
+ for (const cat of breakdown) {
191
+ const color = palette[cat.name]?.color || '#888888';
192
+ const pct = total > 0 ? ((cat.count / total) * 100).toFixed(1) : '0.0';
193
+ htmlLines.push(`<span style="display:inline-flex;align-items:center;gap:4px;">
194
+ <span style="width:10px;height:10px;background:${color};display:inline-block;border-radius:50%;"></span>
195
+ ${cat.name}: ${cat.count} (${pct}%)
196
+ </span>`);
197
+ }
198
+ marker.bindTooltip(htmlLines.join('<br/>'), { direction: 'top', opacity: 0.9 });
199
+ return marker;
200
+ }
201
+
202
+ function renderPieLayer({ fitBounds } = {}) {
203
+ if (!activeMap || !cachedContext) return false;
204
+
205
+ if (layerGroup) {
206
+ activeMap.removeLayer(layerGroup);
207
+ layerGroup = null;
208
+ }
209
+
210
+ const markers = [];
211
+ const { featureCollection, palette, visOptions, stats } = cachedContext;
212
+ for (const feature of featureCollection.features) {
213
+ const marker = createMarker(activeMap, feature, palette, visOptions, stats);
214
+ if (marker) markers.push(marker);
215
+ }
216
+
217
+ if (!markers.length) {
218
+ console.warn('[PieChartsVisualizer] All pie charts skipped due to insufficient data.');
219
+ return false;
220
+ }
221
+
222
+ layerGroup = L.layerGroup(markers).addTo(activeMap);
223
+
224
+ if (fitBounds) {
225
+ const bounds = L.latLngBounds(markers.map(m => m.getLatLng()));
226
+ if (bounds.isValid()) {
227
+ activeMap.fitBounds(bounds, { padding: [16, 16] });
228
+ }
229
+ }
230
+
231
+ return true;
232
+ }
233
+
234
+ function attachZoomHandler(map) {
235
+ if (!map || !cachedContext) return;
236
+ zoomHandler = () => {
237
+ renderPieLayer({ fitBounds: false });
238
+ };
239
+ map.on('zoomend', zoomHandler);
240
+ }
241
+
242
+ export default async function main(map, geojson, options = {}) {
243
+ reset(map);
244
+ ensureStyles();
245
+
246
+ const featureCollection = normalizeFeatureCollection(geojson);
247
+ if (!featureCollection || !Array.isArray(featureCollection.features) || featureCollection.features.length === 0) {
248
+ console.warn('[PieChartsVisualizer] No data to render.');
249
+ return {};
250
+ }
251
+
252
+ const palette = options.palette || {};
253
+ const visOptions = options.visOptions || {};
254
+ const stats = {
255
+ globalMaxCategoryCount: featureCollection.properties?.globalMaxCategoryCount ?? 0,
256
+ globalTotalCount: featureCollection.properties?.globalTotalCount ?? 0
257
+ };
258
+
259
+ activeMap = map;
260
+ cachedContext = { featureCollection, palette, visOptions, stats };
261
+
262
+ const rendered = renderPieLayer({ fitBounds: true });
263
+ if (!rendered) {
264
+ return {};
265
+ }
266
+
267
+ attachZoomHandler(map);
268
+
269
+ return { pieCharts: layerGroup };
270
+ }
271
+