pretix-map 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl

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.
Files changed (33) hide show
  1. pretix_map-0.1.6.dist-info/METADATA +88 -0
  2. {pretix_map-0.1.4.dist-info → pretix_map-0.1.6.dist-info}/RECORD +32 -30
  3. {pretix_map-0.1.4.dist-info → pretix_map-0.1.6.dist-info}/WHEEL +1 -1
  4. {pretix_map-0.1.4.dist-info → pretix_map-0.1.6.dist-info}/licenses/LICENSE +15 -15
  5. pretix_mapplugin/__init__.py +1 -1
  6. pretix_mapplugin/apps.py +28 -28
  7. pretix_mapplugin/geocoding.py +162 -102
  8. pretix_mapplugin/locale/de/LC_MESSAGES/django.po +12 -12
  9. pretix_mapplugin/locale/de_Informal/LC_MESSAGES/django.po +12 -12
  10. pretix_mapplugin/management/commands/geocode_existing_orders.py +271 -271
  11. pretix_mapplugin/migrations/0001_initial.py +27 -27
  12. pretix_mapplugin/migrations/0002_remove_ordergeocodedata_geocoded_timestamp_and_more.py +32 -32
  13. pretix_mapplugin/migrations/0003_mapmilestone.py +27 -0
  14. pretix_mapplugin/models.py +71 -47
  15. pretix_mapplugin/signals.py +77 -92
  16. pretix_mapplugin/static/pretix_mapplugin/css/salesmap.css +100 -51
  17. pretix_mapplugin/static/pretix_mapplugin/js/salesmap.js +611 -452
  18. pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/MarkerCluster.Default.css +59 -59
  19. pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/MarkerCluster.css +14 -14
  20. pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/leaflet-heat.js +10 -10
  21. pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/leaflet-src.esm.js +14419 -14419
  22. pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/leaflet-src.js +14512 -14512
  23. pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/leaflet.css +661 -661
  24. pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/leaflet.js +5 -5
  25. pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/leaflet.markercluster.js +2 -2
  26. pretix_mapplugin/tasks.py +144 -113
  27. pretix_mapplugin/templates/pretix_mapplugin/map_page.html +189 -88
  28. pretix_mapplugin/templates/pretix_mapplugin/milestones.html +53 -0
  29. pretix_mapplugin/urls.py +38 -21
  30. pretix_mapplugin/views.py +295 -163
  31. pretix_map-0.1.4.dist-info/METADATA +0 -195
  32. {pretix_map-0.1.4.dist-info → pretix_map-0.1.6.dist-info}/entry_points.txt +0 -0
  33. {pretix_map-0.1.4.dist-info → pretix_map-0.1.6.dist-info}/top_level.txt +0 -0
@@ -1,452 +1,611 @@
1
- document.addEventListener('DOMContentLoaded', function () {
2
- console.log("Sales Map JS Loaded (Cluster Toggle & Heatmap Opts)");
3
-
4
- // --- L.Icon.Default setup ---
5
- try {
6
- delete L.Icon.Default.prototype._getIconUrl;
7
- L.Icon.Default.mergeOptions({
8
- iconRetinaUrl: '/static/leaflet/images/marker-icon-2x.png',
9
- iconUrl: '/static/leaflet/images/marker-icon.png',
10
- shadowUrl: '/static/leaflet/images/marker-shadow.png',
11
- iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34],
12
- tooltipAnchor: [16, -28], shadowSize: [41, 41]
13
- });
14
- console.log("Set Leaflet default icon image paths explicitly.");
15
- } catch (e) {
16
- console.error("Error setting icon path:", e);
17
- }
18
- // --- End L.Icon.Default setup ---
19
-
20
-
21
- // --- Configuration ---
22
- const mapContainerId = 'sales-map-container';
23
- const statusOverlayId = 'map-status-overlay';
24
- const viewToggleButtonId = 'view-toggle-btn';
25
- const clusterToggleButtonId = 'cluster-toggle-btn';
26
- const heatmapOptionsPanelId = 'heatmap-options-panel';
27
- const initialZoom = 5;
28
- const defaultMapView = 'pins';
29
-
30
- // --- Globals & State ---
31
- let map = null;
32
- let coordinateData = [];
33
- let pinLayer = null;
34
- let heatmapLayer = null;
35
- let currentView = defaultMapView;
36
- let dataUrl = null;
37
- let isClusteringEnabled = true;
38
- let heatmapOptions = {
39
- radius: 25, blur: 15, maxZoom: 18, minOpacity: 0.2
40
- };
41
-
42
- // --- DOM Elements ---
43
- const mapElement = document.getElementById(mapContainerId);
44
- const statusOverlay = document.getElementById(statusOverlayId);
45
- const viewToggleButton = document.getElementById(viewToggleButtonId);
46
- const clusterToggleButton = document.getElementById(clusterToggleButtonId);
47
- const heatmapOptionsPanel = document.getElementById(heatmapOptionsPanelId);
48
- const heatmapRadiusInput = document.getElementById('heatmap-radius');
49
- const heatmapBlurInput = document.getElementById('heatmap-blur');
50
- const heatmapMaxZoomInput = document.getElementById('heatmap-maxZoom');
51
- const radiusValueSpan = document.getElementById('radius-value');
52
- const blurValueSpan = document.getElementById('blur-value');
53
- const maxZoomValueSpan = document.getElementById('maxzoom-value');
54
-
55
- // --- Status Update Helpers ---
56
- function updateStatus(message, isError = false) {
57
- if (statusOverlay) {
58
- const p = statusOverlay.querySelector('p');
59
- if (p) {
60
- p.textContent = message;
61
- p.className = isError ? 'text-danger' : '';
62
- }
63
- statusOverlay.style.display = 'flex';
64
- } else {
65
- console.warn("Status overlay element not found.");
66
- }
67
- }
68
-
69
- function hideStatus() {
70
- if (statusOverlay) {
71
- statusOverlay.style.display = 'none';
72
- }
73
- }
74
-
75
- // --- End Status Helpers ---
76
-
77
-
78
- // --- Initialization ---
79
- function initializeMap() {
80
- if (!mapElement) {
81
- console.error(`Map container #${mapContainerId} not found.`);
82
- return;
83
- }
84
- if (!statusOverlay) {
85
- console.warn("Status overlay element not found.");
86
- }
87
- if (!viewToggleButton) console.warn("View toggle button not found.");
88
- if (!clusterToggleButton) console.warn("Cluster toggle button not found.");
89
- if (!heatmapOptionsPanel) console.warn("Heatmap options panel not found.");
90
- if (!heatmapRadiusInput || !heatmapBlurInput || !heatmapMaxZoomInput) console.warn("Heatmap input elements missing.");
91
-
92
- dataUrl = mapElement.dataset.dataUrl;
93
- if (!dataUrl) {
94
- updateStatus("Configuration Error: Missing data source URL.", true);
95
- return;
96
- }
97
- console.log(`Data URL found: ${dataUrl}`);
98
- updateStatus("Initializing map...");
99
-
100
- try {
101
- map = L.map(mapContainerId).setView([48.85, 2.35], initialZoom);
102
- console.log("L.map() called successfully.");
103
-
104
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
105
- attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
106
- maxZoom: 18,
107
- }).on('load', function () {
108
- console.log('Base tiles loaded.');
109
- }).addTo(map);
110
- console.log("Tile layer added successfully.");
111
-
112
- if (viewToggleButton) setupViewToggleButton();
113
- if (clusterToggleButton) setupClusterToggleButton();
114
- setupHeatmapControls();
115
- fetchDataAndDraw();
116
-
117
- } catch (error) {
118
- console.error("ERROR during Leaflet initialization:", error);
119
- updateStatus(`Leaflet Init Failed: ${error.message}`, true);
120
- }
121
- }
122
-
123
-
124
- // --- Data Fetching & Initial Drawing ---
125
- function fetchDataAndDraw() {
126
- if (!dataUrl) return;
127
- console.log("Fetching coordinates from:", dataUrl);
128
- updateStatus("Loading ticket locations...");
129
- if (viewToggleButton) viewToggleButton.disabled = true;
130
- if (clusterToggleButton) clusterToggleButton.disabled = true;
131
- disableHeatmapControls(true);
132
-
133
- fetch(dataUrl)
134
- .then(response => {
135
- if (!response.ok) throw new Error(`HTTP error! Status: ${response.status} ${response.statusText}`);
136
- return response.json();
137
- })
138
- .then(data => {
139
- if (data.error) throw new Error(`API Error: ${data.error}`);
140
- if (!data || !data.locations || !Array.isArray(data.locations)) {
141
- console.warn("Invalid data format:", data);
142
- updateStatus("No valid locations found.", false);
143
- coordinateData = [];
144
- hideStatus();
145
- return;
146
- }
147
- if (data.locations.length === 0) {
148
- console.log("No locations received.");
149
- updateStatus("No locations found for event.", false);
150
- coordinateData = [];
151
- hideStatus();
152
- return;
153
- }
154
-
155
- coordinateData = data.locations;
156
- console.log(`Received ${coordinateData.length} coordinates.`);
157
-
158
- if (viewToggleButton) viewToggleButton.disabled = false;
159
- if (clusterToggleButton) clusterToggleButton.disabled = (currentView !== 'pins');
160
- disableHeatmapControls(false);
161
-
162
- createAllLayers();
163
- showCurrentView();
164
- adjustMapBounds();
165
- hideStatus();
166
-
167
- setTimeout(function () {
168
- console.log("Forcing map.invalidateSize() after data load...");
169
- const container = document.getElementById(mapContainerId);
170
- if (map && container && container.offsetWidth > 0) {
171
- map.invalidateSize();
172
- } else {
173
- console.warn(`Skipping invalidateSize. Map: ${!!map}, Container: ${!!container}, OffsetWidth: ${container ? container.offsetWidth : 'N/A'}`);
174
- }
175
- }, 100);
176
- })
177
- .catch(error => {
178
- console.error('Error fetching/processing data:', error);
179
- updateStatus(`Error loading map data: ${error.message}.`, true);
180
- });
181
- }
182
-
183
-
184
- // --- Layer Creation Functions ---
185
- function createAllLayers() {
186
- createPinLayer();
187
- createHeatmapLayer();
188
- console.log("Layers created/updated.");
189
- }
190
-
191
- function createPinLayer() {
192
- console.log(`Creating pin layer (Clustering: ${isClusteringEnabled})...`);
193
- pinLayer = null;
194
- if (coordinateData.length === 0) {
195
- console.warn("No data for pin layer.");
196
- return;
197
- }
198
- const markers = [];
199
- coordinateData.forEach((loc, index) => {
200
- try {
201
- if (loc.lat == null || loc.lon == null || isNaN(loc.lat) || isNaN(loc.lon)) return;
202
- const marker = L.marker(L.latLng(loc.lat, loc.lon));
203
- if (loc.tooltip) marker.bindTooltip(loc.tooltip);
204
- if (loc.order_url) {
205
- marker.on('click', () => window.open(loc.order_url, '_blank'));
206
- }
207
- markers.push(marker);
208
- } catch (e) {
209
- console.error(`Error creating marker ${index}:`, e);
210
- }
211
- });
212
- if (markers.length === 0) {
213
- console.warn("No valid markers created.");
214
- return;
215
- }
216
- if (isClusteringEnabled) {
217
- pinLayer = L.markerClusterGroup();
218
- pinLayer.addLayers(markers);
219
- console.log("Marker cluster populated.");
220
- } else {
221
- pinLayer = L.layerGroup(markers);
222
- console.log("Simple layer group populated.");
223
- }
224
- }
225
-
226
- function createHeatmapLayer() {
227
- console.log("Creating heatmap layer...");
228
- heatmapLayer = null;
229
- if (coordinateData.length === 0) {
230
- console.warn("No data for heatmap.");
231
- return;
232
- }
233
- try {
234
- const heatPoints = coordinateData.map(loc => {
235
- if (loc.lat != null && loc.lon != null && !isNaN(loc.lat) && !isNaN(loc.lon)) {
236
- return [loc.lat, loc.lon, 1.0];
237
- }
238
- return null;
239
- }).filter(p => p !== null);
240
- if (heatPoints.length > 0) {
241
- heatmapLayer = L.heatLayer(heatPoints, heatmapOptions);
242
- console.log("Heatmap created:", heatmapOptions);
243
- } else {
244
- console.warn("No valid points for heatmap.");
245
- }
246
- } catch (e) {
247
- console.error("Error creating heatmap:", e);
248
- }
249
- }
250
-
251
-
252
- // --- Layer Update Functions ---
253
- function redrawPinLayer() {
254
- if (!map) return;
255
- console.log("Redrawing pin layer...");
256
- if (pinLayer && map.hasLayer(pinLayer)) map.removeLayer(pinLayer);
257
- pinLayer = null;
258
- createPinLayer();
259
- if (currentView === 'pins' && pinLayer) {
260
- console.log("Adding new pin layer.");
261
- map.addLayer(pinLayer);
262
- }
263
- }
264
-
265
- function updateHeatmap() {
266
- if (!map || !heatmapLayer) {
267
- console.warn("Cannot update heatmap.");
268
- return;
269
- }
270
- console.log("Updating heatmap opts:", heatmapOptions);
271
- try {
272
- heatmapLayer.setOptions(heatmapOptions);
273
- console.log("Heatmap opts updated.");
274
- } catch (e) {
275
- console.error("Error setting heatmap opts:", e);
276
- }
277
- }
278
-
279
-
280
- // --- Adjust Map Bounds (Corrected) ---
281
- function adjustMapBounds() {
282
- if (!map || coordinateData.length === 0) return;
283
- try {
284
- let bounds = null;
285
- if (currentView === 'pins' && pinLayer && typeof pinLayer.getBounds === 'function') {
286
- bounds = pinLayer.getBounds();
287
- console.log("Attempting bounds from pin layer.");
288
- }
289
- // Calculate from raw if pin layer bounds unavailable/invalid or if in heatmap view
290
- if (!bounds || !bounds.isValid()) {
291
- console.log("Calculating bounds from raw coordinates.");
292
- // Filter valid lat/lon pairs
293
- const latLngs = coordinateData
294
- .map(loc => {
295
- if (loc.lat != null && loc.lon != null && !isNaN(loc.lat) && !isNaN(loc.lon)) {
296
- return [loc.lat, loc.lon];
297
- }
298
- return null;
299
- })
300
- .filter(p => p !== null);
301
- if (latLngs.length > 0) bounds = L.latLngBounds(latLngs);
302
- }
303
-
304
- // Apply bounds if valid
305
- if (bounds && bounds.isValid()) {
306
- console.log("Fitting map to bounds...");
307
- map.fitBounds(bounds, {padding: [50, 50]});
308
- console.log("Bounds fitted.");
309
- // Handle single point case
310
- } else if (coordinateData.filter(loc => loc.lat != null && loc.lon != null && !isNaN(loc.lat) && !isNaN(loc.lon)).length === 1) {
311
- console.log("Setting view for single coordinate.");
312
- // --- Corrected logic to find the single valid coordinate ---
313
- const singleCoord = coordinateData.find(loc => loc.lat != null && loc.lon != null && !isNaN(loc.lat) && !isNaN(loc.lon));
314
- // --- End Correction ---
315
- if (singleCoord) map.setView([singleCoord.lat, singleCoord.lon], 13);
316
- else console.warn("Could not find single valid coordinate to set view.");
317
- // Removed extra ')' here
318
- } else {
319
- console.warn("Could not determine valid bounds.");
320
- }
321
- } catch (e) {
322
- console.error("Error fitting map bounds:", e);
323
- }
324
- } // End adjustMapBounds function
325
-
326
-
327
- // --- Control Setup Functions ---
328
- function setupViewToggleButton() {
329
- updateViewToggleButtonText();
330
- viewToggleButton.addEventListener('click', () => {
331
- console.log("View toggle clicked!");
332
- currentView = (currentView === 'pins') ? 'heatmap' : 'pins';
333
- showCurrentView();
334
- updateViewToggleButtonText();
335
- if (clusterToggleButton) clusterToggleButton.disabled = (currentView !== 'pins');
336
- });
337
- console.log("View toggle listener setup.");
338
- }
339
-
340
- function setupClusterToggleButton() {
341
- updateClusterToggleButtonText();
342
- clusterToggleButton.disabled = (currentView !== 'pins');
343
- clusterToggleButton.addEventListener('click', () => {
344
- if (currentView !== 'pins') return;
345
- console.log("Cluster toggle clicked!");
346
- isClusteringEnabled = !isClusteringEnabled;
347
- redrawPinLayer();
348
- updateClusterToggleButtonText();
349
- });
350
- console.log("Cluster toggle listener setup.");
351
- }
352
-
353
- function setupHeatmapControls() {
354
- if (!heatmapRadiusInput || !heatmapBlurInput || !heatmapMaxZoomInput || !radiusValueSpan || !blurValueSpan || !maxZoomValueSpan) {
355
- console.error("Heatmap controls missing.");
356
- return;
357
- }
358
- radiusValueSpan.textContent = heatmapOptions.radius;
359
- heatmapRadiusInput.value = heatmapOptions.radius;
360
- blurValueSpan.textContent = heatmapOptions.blur;
361
- heatmapBlurInput.value = heatmapOptions.blur;
362
- maxZoomValueSpan.textContent = heatmapOptions.maxZoom;
363
- heatmapMaxZoomInput.value = heatmapOptions.maxZoom;
364
- heatmapRadiusInput.addEventListener('input', (e) => {
365
- const v = parseFloat(e.target.value);
366
- heatmapOptions.radius = v;
367
- radiusValueSpan.textContent = v;
368
- updateHeatmap();
369
- });
370
- heatmapBlurInput.addEventListener('input', (e) => {
371
- const v = parseFloat(e.target.value);
372
- heatmapOptions.blur = v;
373
- blurValueSpan.textContent = v;
374
- updateHeatmap();
375
- });
376
- heatmapMaxZoomInput.addEventListener('input', (e) => {
377
- const v = parseInt(e.target.value, 10);
378
- heatmapOptions.maxZoom = v;
379
- maxZoomValueSpan.textContent = v;
380
- updateHeatmap();
381
- });
382
- console.log("Heatmap control listeners setup.");
383
- }
384
-
385
- function disableHeatmapControls(disabled) {
386
- if (heatmapRadiusInput) heatmapRadiusInput.disabled = disabled;
387
- if (heatmapBlurInput) heatmapBlurInput.disabled = disabled;
388
- if (heatmapMaxZoomInput) heatmapMaxZoomInput.disabled = disabled;
389
- }
390
-
391
-
392
- // --- View Switching Logic ---
393
- function showCurrentView() {
394
- console.log(`Showing view: ${currentView}`);
395
- if (!map) {
396
- console.warn("Map not init.");
397
- return;
398
- }
399
- console.log("Removing layers...");
400
- if (pinLayer && map.hasLayer(pinLayer)) map.removeLayer(pinLayer);
401
- if (heatmapLayer && map.hasLayer(heatmapLayer)) map.removeLayer(heatmapLayer);
402
- console.log(`Adding ${currentView} layer...`);
403
- let layerToAdd = null;
404
- if (currentView === 'pins') {
405
- layerToAdd = pinLayer;
406
- if (heatmapOptionsPanel) heatmapOptionsPanel.style.display = 'none';
407
- if (clusterToggleButton) {
408
- clusterToggleButton.style.display = 'inline-block';
409
- clusterToggleButton.disabled = false;
410
- }
411
- } else {
412
- layerToAdd = heatmapLayer;
413
- if (heatmapOptionsPanel) heatmapOptionsPanel.style.display = 'block';
414
- if (clusterToggleButton) {
415
- clusterToggleButton.style.display = 'none';
416
- clusterToggleButton.disabled = true;
417
- }
418
- }
419
- if (layerToAdd) {
420
- try {
421
- map.addLayer(layerToAdd);
422
- console.log(`Added ${currentView} layer.`);
423
- } catch (e) {
424
- console.error(`Error adding ${currentView}:`, e);
425
- updateStatus(`Error display ${currentView}.`, true);
426
- }
427
- } else {
428
- console.warn(`Layer instance missing for ${currentView}.`);
429
- updateStatus(`No data for ${currentView}.`, false);
430
- }
431
- }
432
-
433
-
434
- // --- Button Text Update Functions ---
435
- function updateViewToggleButtonText() {
436
- if (!viewToggleButton) return;
437
- const next = (currentView === 'pins') ? 'Heatmap' : 'Pin';
438
- viewToggleButton.textContent = `Switch to ${next} View`;
439
- console.log(`View Btn text: ${viewToggleButton.textContent}`);
440
- }
441
-
442
- function updateClusterToggleButtonText() {
443
- if (!clusterToggleButton) return;
444
- clusterToggleButton.textContent = isClusteringEnabled ? 'Disable Clustering' : 'Enable Clustering';
445
- console.log(`Cluster Btn text: ${clusterToggleButton.textContent}`);
446
- }
447
-
448
-
449
- // --- Start Initialization ---
450
- initializeMap();
451
-
452
- }); // End DOMContentLoaded
1
+ document.addEventListener('DOMContentLoaded', function () {
2
+ console.log("Sales Map JS Loaded (Charts, CSP Fix, Toggle Mode Fix, Split-View)");
3
+
4
+ // --- Leaflet Setup ---
5
+ try {
6
+ delete L.Icon.Default.prototype._getIconUrl;
7
+ L.Icon.Default.mergeOptions({
8
+ iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
9
+ iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
10
+ shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png'
11
+ });
12
+ } catch (e) { console.error(e); }
13
+
14
+ const icons = {
15
+ red: new L.Icon({ iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png', shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41] }),
16
+ orange: new L.Icon({ iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-orange.png', shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41] }),
17
+ grey: new L.Icon({ iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-grey.png', shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41] }),
18
+ blue: new L.Icon({ iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png', shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41] })
19
+ };
20
+
21
+ // --- Selectors ---
22
+ const sel = {
23
+ map: document.getElementById('sales-map-container'),
24
+ overlay: document.getElementById('map-status-overlay'),
25
+ analyticsView: document.getElementById('analytics-view-container'),
26
+ viewToggle: document.getElementById('view-toggle-btn'),
27
+ clusterToggle: document.getElementById('cluster-toggle-btn'),
28
+ gridToggle: document.getElementById('grid-toggle-btn'),
29
+ weightToggle: document.getElementById('weight-toggle-btn'),
30
+ showCanceledCheck: document.getElementById('show-canceled-check'),
31
+ filterToggle: document.getElementById('filter-toggle-btn'),
32
+ listToggle: document.getElementById('list-toggle-btn'),
33
+ statsToggle: document.getElementById('stats-toggle-btn'),
34
+ compareSelect: document.getElementById('compare-event-select'),
35
+ heatmapPanel: document.getElementById('heatmap-options-panel'),
36
+ filterPanel: document.getElementById('filter-options-panel'),
37
+ statsContent: document.getElementById('stats-summary-text'),
38
+ failedContainer: document.getElementById('failed-orders-container'),
39
+ failedTbody: document.getElementById('failed-orders-tbody'),
40
+ filterCheckboxes: document.getElementById('filter-checkboxes'),
41
+ radiusIn: document.getElementById('heatmap-radius'),
42
+ blurIn: document.getElementById('heatmap-blur'),
43
+ maxZoomIn: document.getElementById('heatmap-maxZoom'),
44
+ radiusVal: document.getElementById('radius-value'),
45
+ blurVal: document.getElementById('blur-value'),
46
+ maxZoomVal: document.getElementById('maxzoom-value'),
47
+ gridSizeSlider: document.getElementById('grid-size-slider'),
48
+ gridSizeVal: document.getElementById('grid-size-value'),
49
+ heatmapControls: document.getElementById('heatmap-only-controls'),
50
+ gridControls: document.getElementById('grid-only-controls'),
51
+ mapSplitRoot: document.getElementById('map-split-root'),
52
+ compareMapContainer: document.getElementById('sales-map-compare-container'),
53
+ compareLabel: document.getElementById('compare-map-label'),
54
+ mainLabel: document.getElementById('main-map-label'),
55
+ heatmapReset: document.getElementById('heatmap-reset-btn'),
56
+ timeSlider: document.getElementById('timeline-slider'),
57
+ timeDisplay: document.getElementById('timeline-date-display'),
58
+ timePlayBtn: document.getElementById('timeline-play-btn'),
59
+ timeCount: document.getElementById('timeline-count-display'),
60
+ intervalSelect: document.getElementById('timeline-interval-select'),
61
+ timelineChart: document.getElementById('timeline-volume-chart'),
62
+ timelineMarkers: document.getElementById('timeline-milestone-markers'),
63
+ itemAvailContainer: document.getElementById('timeline-item-availability'),
64
+ itemAvailList: document.getElementById('item-availability-list')
65
+ };
66
+
67
+ // --- State ---
68
+ let map = null, mapCompare = null, allData = [], filteredData = [], displayData = [], milestones = [], currency = "EUR";
69
+ let itemAvailData = {};
70
+ let compareData = [], compareFilteredData = [], compareDisplayData = [];
71
+ let pinLayer = null, heatmapLayer = null, gridLayer = null, comparisonLayer = null, eventMarkerLayer = null;
72
+ let pinLayerComp = null, heatmapLayerComp = null, gridLayerComp = null, eventMarkerLayerComp = null;
73
+ let currentView = 'pins', isClustering = true, isWeighted = false, showCanceled = false;
74
+ let isPlaying = false, playInterval = null, currentCompareSlug = "";
75
+ let availableItems = new Set(), selectedItems = new Set();
76
+ let currentDisplayMode = 'map'; // map | list | stats
77
+ let charts = {};
78
+
79
+ function updateStatus(msg, isErr = false) {
80
+ if (sel.overlay) {
81
+ const p = sel.overlay.querySelector('p');
82
+ if (p) { p.textContent = msg; p.className = isErr ? 'text-danger' : ''; }
83
+ sel.overlay.style.display = 'flex';
84
+ }
85
+ }
86
+ function hideStatus() { if (sel.overlay) sel.overlay.style.display = 'none'; }
87
+
88
+ function init() {
89
+ if (!sel.map) return;
90
+ const dataUrl = sel.map.dataset.dataUrl;
91
+
92
+ map = L.map('sales-map-container').setView([48.85, 2.35], 5);
93
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(map);
94
+
95
+ mapCompare = L.map('sales-map-compare-container', { zoomControl: false }).setView([48.85, 2.35], 5);
96
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(mapCompare);
97
+
98
+ // Synchronize maps
99
+ let isSyncing = false;
100
+ const sync = (src, dest) => {
101
+ if (isSyncing) return;
102
+ isSyncing = true;
103
+ dest.setView(src.getCenter(), src.getZoom(), { animate: false });
104
+ isSyncing = false;
105
+ };
106
+ map.on('move', () => sync(map, mapCompare));
107
+ mapCompare.on('move', () => sync(mapCompare, map));
108
+ map.on('zoomend', () => sync(map, mapCompare));
109
+ mapCompare.on('zoomend', () => sync(mapCompare, map));
110
+
111
+ setupControls();
112
+ fetchData(dataUrl);
113
+ }
114
+
115
+ function fetchData(url) {
116
+ updateStatus("Loading data...");
117
+ fetch(url).then(r => r.json()).then(data => {
118
+ if (data.error) throw new Error(data.error);
119
+ allData = (data.locations || []).map(loc => { loc.ts = loc.date ? new Date(loc.date).getTime() : 0; return loc; }).sort((a, b) => a.ts - b.ts);
120
+ milestones = (data.milestones || []).map(m => { m.ts = new Date(m.date).getTime(); return m; });
121
+ itemAvailData = data.item_availability || {};
122
+ currency = (data.stats && data.stats.currency) ? data.stats.currency : "EUR";
123
+
124
+ extractItems(); buildFilterUI(); initTimeline(); applyFilters();
125
+ renderFailedOrders(data.failed_orders || []);
126
+ renderStats(data.stats || {});
127
+ if (data.event_marker) renderEventMarker(data.event_marker);
128
+
129
+ [sel.viewToggle, sel.clusterToggle, sel.gridToggle, sel.weightToggle, sel.compareSelect, sel.filterToggle, sel.statsToggle, sel.listToggle].forEach(b => { if (b) b.disabled = false; });
130
+ hideStatus();
131
+ setTimeout(() => map.invalidateSize(), 100);
132
+ }).catch(e => { console.error(e); updateStatus(e.message, true); });
133
+ }
134
+
135
+ function setupControls() {
136
+ sel.viewToggle.onclick = () => { currentView = (currentView === 'pins' ? 'heatmap' : 'pins'); refreshLayers(); };
137
+ sel.gridToggle.onclick = () => { currentView = (currentView === 'grid' ? 'pins' : 'grid'); refreshLayers(); };
138
+ sel.weightToggle.onclick = () => { isWeighted = !isWeighted; sel.weightToggle.className = isWeighted ? 'btn btn-success' : 'btn btn-default'; refreshLayers(); };
139
+ sel.showCanceledCheck.onchange = applyFilters;
140
+ sel.clusterToggle.onclick = () => { isClustering = !isClustering; refreshLayers(); };
141
+ sel.compareSelect.onchange = (e) => loadComparison(e.target.value);
142
+ sel.timeSlider.oninput = updateTimelineDisplay;
143
+ sel.timePlayBtn.onclick = togglePlay;
144
+ sel.intervalSelect.onchange = updateTimelineSlider;
145
+ sel.filterToggle.onclick = () => sel.filterPanel.style.display = (sel.filterPanel.style.display === 'none' ? 'block' : 'none');
146
+
147
+ sel.listToggle.onclick = () => switchDisplayMode(currentDisplayMode === 'list' ? 'map' : 'list');
148
+ sel.statsToggle.onclick = () => switchDisplayMode(currentDisplayMode === 'stats' ? 'map' : 'stats');
149
+
150
+ const updateHeat = () => {
151
+ sel.radiusVal.textContent = sel.radiusIn.value;
152
+ sel.blurVal.textContent = sel.blurIn.value;
153
+ sel.maxZoomVal.textContent = sel.maxZoomIn.value;
154
+
155
+ if (heatmapLayer) {
156
+ heatmapLayer.setOptions({
157
+ radius: parseInt(sel.radiusIn.value),
158
+ blur: parseInt(sel.blurIn.value),
159
+ maxZoom: parseInt(sel.maxZoomIn.value),
160
+ minOpacity: 0.4
161
+ });
162
+ }
163
+ };
164
+ [sel.radiusIn, sel.blurIn, sel.maxZoomIn].forEach(i => i.oninput = updateHeat);
165
+ sel.heatmapReset.onclick = () => {
166
+ sel.radiusIn.value = 25; sel.blurIn.value = 15; sel.maxZoomIn.value = 18;
167
+ sel.gridSizeSlider.value = 70;
168
+ updateHeat();
169
+ };
170
+ sel.gridSizeSlider.oninput = () => refreshLayers();
171
+ }
172
+
173
+ function switchDisplayMode(mode) {
174
+ currentDisplayMode = mode;
175
+ sel.map.style.display = (mode === 'map' ? 'block' : 'none');
176
+ sel.failedContainer.style.display = (mode === 'list' ? 'block' : 'none');
177
+ sel.analyticsView.style.display = (mode === 'stats' ? 'block' : 'none');
178
+
179
+ sel.listToggle.className = (mode === 'list' ? 'btn btn-success' : 'btn btn-info');
180
+ sel.statsToggle.className = (mode === 'stats' ? 'btn btn-success' : 'btn btn-primary');
181
+ sel.listToggle.textContent = (mode === 'list' ? 'Show Map' : 'Failed Orders');
182
+ sel.statsToggle.textContent = (mode === 'stats' ? 'Show Map' : 'Analytics View');
183
+
184
+ if (mode === 'map') map.invalidateSize();
185
+ }
186
+
187
+ function renderStats(s) {
188
+ if (!sel.statsContent) return;
189
+ sel.statsContent.innerHTML = `
190
+ <table class="table table-condensed">
191
+ <tr><td><strong>Orders Total</strong></td><td class="text-right">${s.total_orders}</td></tr>
192
+ <tr><td><strong>Geocoded</strong></td><td class="text-right">${s.geocoded_count}</td></tr>
193
+ <tr><td><strong>Revenue</strong></td><td class="text-right">${s.total_revenue.toFixed(2)} ${s.currency}</td></tr>
194
+ <tr><td><strong>Avg. Travel</strong></td><td class="text-right">${s.avg_distance_km} km</td></tr>
195
+ </table>
196
+ `;
197
+ renderCharts(s);
198
+ }
199
+
200
+ function renderCharts(s) {
201
+ const ctxCities = document.getElementById('cities-chart').getContext('2d');
202
+ if (charts.cities) charts.cities.destroy();
203
+ charts.cities = new Chart(ctxCities, {
204
+ type: 'bar',
205
+ data: {
206
+ labels: s.top_cities.map(c => c[0]),
207
+ datasets: [{ label: 'Orders', data: s.top_cities.map(c => c[1]), backgroundColor: 'rgba(54, 162, 235, 0.5)' }]
208
+ },
209
+ options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false }
210
+ });
211
+
212
+ const ctxItems = document.getElementById('items-chart').getContext('2d');
213
+ if (charts.items) charts.items.destroy();
214
+ charts.items = new Chart(ctxItems, {
215
+ type: 'doughnut',
216
+ data: {
217
+ labels: s.top_items.map(i => i[0]),
218
+ datasets: [{ data: s.top_items.map(i => i[1]), backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF'] }]
219
+ },
220
+ options: { responsive: true, maintainAspectRatio: false }
221
+ });
222
+ }
223
+
224
+ function initTimeline() {
225
+ if (!sel.timeSlider) return;
226
+ updateTimelineSlider();
227
+ }
228
+
229
+ function updateTimelineSlider() {
230
+ const interval = sel.intervalSelect.value;
231
+ const validData = allData.filter(d => d.ts > 0);
232
+ if (validData.length === 0) { sel.timeSlider.disabled = true; return; }
233
+ const uniqueKeys = new Set();
234
+ validData.forEach(d => {
235
+ const date = new Date(d.ts);
236
+ if (interval === 'hour') date.setMinutes(0, 0, 0);
237
+ else if (interval === 'day') date.setHours(0, 0, 0, 0);
238
+ else if (interval === 'week') { const day = date.getDay(), diff = date.getDate() - day + (day === 0 ? -6 : 1); date.setDate(diff); date.setHours(0, 0, 0, 0); }
239
+ else if (interval === 'month') { date.setDate(1); date.setHours(0, 0, 0, 0); }
240
+ uniqueKeys.add(date.getTime());
241
+ });
242
+ const steps = Array.from(uniqueKeys).sort((a, b) => a - b);
243
+ sel.timeSlider.min = 0; sel.timeSlider.max = steps.length - 1; sel.timeSlider.value = steps.length - 1;
244
+ sel.timeSlider._steps = steps;
245
+
246
+ renderTimelineVolumeChart(steps);
247
+ renderMilestoneMarkers(steps);
248
+ renderItemAvailability(steps);
249
+ updateTimelineDisplay();
250
+ }
251
+
252
+ function renderItemAvailability(steps) {
253
+ if (!sel.itemAvailList || !steps || steps.length === 0) return;
254
+ sel.itemAvailList.innerHTML = '';
255
+ sel.itemAvailContainer.style.display = 'block';
256
+
257
+ const minTs = steps[0];
258
+ const maxTs = steps[steps.length - 1];
259
+ const range = maxTs - minTs;
260
+
261
+ Object.keys(itemAvailData).forEach(itemName => {
262
+ const d = itemAvailData[itemName];
263
+ const firstTs = d.first_sale ? new Date(d.first_sale).getTime() : null;
264
+ const lastTs = d.last_sale ? new Date(d.last_sale).getTime() : null;
265
+ const availFrom = d.available_from ? new Date(d.available_from).getTime() : null;
266
+ const availUntil = d.available_until ? new Date(d.available_until).getTime() : null;
267
+
268
+ if (!firstTs && !availFrom) return;
269
+
270
+ const row = document.createElement('div');
271
+ row.style.height = '14px';
272
+ row.style.marginBottom = '2px';
273
+ row.style.position = 'relative';
274
+ row.style.width = '100%';
275
+ row.style.display = 'flex';
276
+ row.style.alignItems = 'center';
277
+
278
+ const label = document.createElement('div');
279
+ label.textContent = itemName;
280
+ label.style.width = '120px';
281
+ label.style.overflow = 'hidden';
282
+ label.style.textOverflow = 'ellipsis';
283
+ label.style.whiteSpace = 'nowrap';
284
+ label.style.marginRight = '5px';
285
+ row.appendChild(label);
286
+
287
+ const barContainer = document.createElement('div');
288
+ barContainer.style.flexGrow = '1';
289
+ barContainer.style.height = '8px';
290
+ barContainer.style.background = '#f9f9f9';
291
+ barContainer.style.position = 'relative';
292
+ barContainer.style.borderRadius = '4px';
293
+ row.appendChild(barContainer);
294
+
295
+ // Planned availability (light grey bar)
296
+ if (availFrom || availUntil) {
297
+ const start = availFrom ? Math.max(availFrom, minTs) : minTs;
298
+ const end = availUntil ? Math.min(availUntil, maxTs) : maxTs;
299
+ if (start < end) {
300
+ const left = ((start - minTs) / range) * 100;
301
+ const width = ((end - start) / range) * 100;
302
+ const bar = document.createElement('div');
303
+ bar.style.position = 'absolute';
304
+ bar.style.left = `${left}%`;
305
+ bar.style.width = `${width}%`;
306
+ bar.style.height = '100%';
307
+ bar.style.background = '#eee';
308
+ bar.style.borderRadius = '4px';
309
+ bar.title = `${itemName} (Configured Availability)`;
310
+ barContainer.appendChild(bar);
311
+ }
312
+ }
313
+
314
+ // Actual sales period (blue bar)
315
+ if (firstTs && lastTs) {
316
+ const start = Math.max(firstTs, minTs);
317
+ const end = Math.min(lastTs, maxTs);
318
+ if (start <= end) {
319
+ const left = ((start - minTs) / range) * 100;
320
+ const width = Math.max(((end - start) / range) * 100, 0.5); // Min width for visibility
321
+ const bar = document.createElement('div');
322
+ bar.style.position = 'absolute';
323
+ bar.style.left = `${left}%`;
324
+ bar.style.width = `${width}%`;
325
+ bar.style.height = '100%';
326
+ bar.style.background = 'rgba(54, 162, 235, 0.6)';
327
+ bar.style.borderRadius = '4px';
328
+ bar.style.zIndex = '1';
329
+ bar.title = `${itemName} (Actual Sales)`;
330
+ barContainer.appendChild(bar);
331
+ }
332
+ }
333
+
334
+ sel.itemAvailList.appendChild(row);
335
+ });
336
+ }
337
+
338
+ function renderTimelineVolumeChart(steps) {
339
+ if (!sel.timelineChart) return;
340
+ const ctx = sel.timelineChart.getContext('2d');
341
+ if (charts.timeline) charts.timeline.destroy();
342
+
343
+ // Calculate counts per step from filteredData
344
+ const stepCounts = steps.map(ts => {
345
+ const nextTsIndex = steps.indexOf(ts) + 1;
346
+ const nextTs = nextTsIndex < steps.length ? steps[nextTsIndex] : Infinity;
347
+ return filteredData.filter(d => d.ts >= ts && d.ts < nextTs).length;
348
+ });
349
+
350
+ charts.timeline = new Chart(ctx, {
351
+ type: 'bar',
352
+ data: {
353
+ labels: steps.map(ts => new Date(ts).toLocaleDateString()),
354
+ datasets: [{
355
+ data: stepCounts,
356
+ backgroundColor: 'rgba(54, 162, 235, 0.4)',
357
+ borderWidth: 0,
358
+ barPercentage: 1.0,
359
+ categoryPercentage: 1.0
360
+ }]
361
+ },
362
+ options: {
363
+ responsive: true,
364
+ maintainAspectRatio: false,
365
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
366
+ scales: {
367
+ x: { display: false },
368
+ y: { display: false, beginAtZero: true }
369
+ }
370
+ }
371
+ });
372
+ }
373
+
374
+ function renderMilestoneMarkers(steps) {
375
+ if (!sel.timelineMarkers || !steps || steps.length === 0) return;
376
+ sel.timelineMarkers.innerHTML = '';
377
+
378
+ milestones.forEach(m => {
379
+ // Find closest step index
380
+ let closestIdx = -1;
381
+ let minDiff = Infinity;
382
+ steps.forEach((ts, idx) => {
383
+ const diff = Math.abs(ts - m.ts);
384
+ if (diff < minDiff) {
385
+ minDiff = diff;
386
+ closestIdx = idx;
387
+ }
388
+ });
389
+
390
+ if (closestIdx !== -1 && minDiff < 86400000 * 2) { // Only show if close enough (2 days)
391
+ const percent = (closestIdx / (steps.length - 1)) * 100;
392
+ const marker = document.createElement('div');
393
+ marker.className = 'timeline-milestone-marker';
394
+ marker.style.position = 'absolute';
395
+ marker.style.left = `${percent}%`;
396
+ marker.style.top = '0';
397
+ marker.style.width = '2px';
398
+ marker.style.height = '100%';
399
+ marker.style.background = '#d9534f';
400
+ marker.style.zIndex = '2';
401
+
402
+ const label = document.createElement('span');
403
+ label.textContent = m.label;
404
+ label.style.position = 'absolute';
405
+ label.style.top = '2px';
406
+ label.style.left = '4px';
407
+ label.style.fontSize = '9px';
408
+ label.style.whiteSpace = 'nowrap';
409
+ label.style.background = 'rgba(255,255,255,0.7)';
410
+ label.style.padding = '0 2px';
411
+
412
+ marker.appendChild(label);
413
+ sel.timelineMarkers.appendChild(marker);
414
+ }
415
+ });
416
+ }
417
+
418
+ function updateTimelineDisplay() {
419
+ const steps = sel.timeSlider._steps;
420
+ if (!steps) return;
421
+ const currentTs = steps[parseInt(sel.timeSlider.value)];
422
+ const dateObj = new Date(currentTs);
423
+ const activeMilestone = milestones.find(m => new Date(m.date).toDateString() === dateObj.toDateString());
424
+ sel.timeDisplay.innerHTML = dateObj.toLocaleDateString() + (activeMilestone ? ` <strong>★ ${activeMilestone.label}</strong>` : "");
425
+
426
+ displayData = filteredData.filter(d => d.ts === 0 || d.ts <= currentTs);
427
+ if (compareFilteredData.length > 0) {
428
+ compareDisplayData = compareFilteredData.filter(d => d.ts === 0 || d.ts <= currentTs);
429
+ }
430
+
431
+ if (sel.timeCount) sel.timeCount.textContent = displayData.length;
432
+ refreshLayers();
433
+ }
434
+
435
+ function togglePlay() {
436
+ if (isPlaying) { clearInterval(playInterval); isPlaying = false; sel.timePlayBtn.innerHTML = '<i class="fa fa-play"></i> Play'; }
437
+ else {
438
+ isPlaying = true; sel.timePlayBtn.innerHTML = '<i class="fa fa-pause"></i> Pause';
439
+ if (parseInt(sel.timeSlider.value) >= parseInt(sel.timeSlider.max)) sel.timeSlider.value = 0;
440
+ playInterval = setInterval(() => {
441
+ let v = parseInt(sel.timeSlider.value);
442
+ if (v < parseInt(sel.timeSlider.max)) { sel.timeSlider.value = v + 1; updateTimelineDisplay(); }
443
+ else togglePlay();
444
+ }, 400);
445
+ }
446
+ }
447
+
448
+ function refreshLayers() {
449
+ if (!map) return;
450
+ [pinLayer, heatmapLayer, gridLayer].forEach(l => { if (l && map.hasLayer(l)) map.removeLayer(l); });
451
+ if (mapCompare) {
452
+ [pinLayerComp, heatmapLayerComp, gridLayerComp, eventMarkerLayerComp].forEach(l => { if (l && mapCompare.hasLayer(l)) mapCompare.removeLayer(l); });
453
+ }
454
+ if (comparisonLayer && map.hasLayer(comparisonLayer)) map.removeLayer(comparisonLayer);
455
+
456
+ const isSplitNeeded = currentCompareSlug && (currentView === 'heatmap' || currentView === 'grid');
457
+ if (isSplitNeeded) {
458
+ sel.mapSplitRoot.classList.add('map-split-active');
459
+ sel.mainLabel.style.display = 'block';
460
+ sel.compareMapContainer.style.display = 'block';
461
+ setTimeout(() => { map.invalidateSize(); mapCompare.invalidateSize(); }, 50);
462
+ } else {
463
+ sel.mapSplitRoot.classList.remove('map-split-active');
464
+ sel.mainLabel.style.display = 'none';
465
+ sel.compareMapContainer.style.display = 'none';
466
+ setTimeout(() => { map.invalidateSize(); }, 50);
467
+ }
468
+
469
+ createPinLayer(); createHeatmapLayer(); createGridLayer();
470
+ if (isSplitNeeded) {
471
+ createPinLayer(true); createHeatmapLayer(true); createGridLayer(true);
472
+ } else if (currentCompareSlug && currentView === 'pins') {
473
+ const dots = compareData.map(l => l.lat ? L.circleMarker([l.lat, l.lon], { radius: 4, color: '#888', fillOpacity: 0.5 }).bindTooltip("Comparison") : null).filter(l => l);
474
+ comparisonLayer = L.layerGroup(dots);
475
+ }
476
+ showCurrentView();
477
+ }
478
+
479
+ function createPinLayer(isCompare = false) {
480
+ const data = isCompare ? compareDisplayData : displayData;
481
+ if (data.length === 0) { if(isCompare) pinLayerComp = null; else pinLayer = null; return; }
482
+ const markers = data.map(loc => {
483
+ if (loc.lat == null) return null;
484
+ let icon = icons.blue;
485
+ if (loc.status === 'pending') icon = icons.orange;
486
+ else if (loc.status === 'canceled') icon = icons.grey;
487
+ const m = L.marker([loc.lat, loc.lon], { icon: icon });
488
+ if (loc.tooltip) m.bindTooltip(loc.tooltip);
489
+ if (loc.order_url) m.on('click', () => window.open(loc.order_url, '_blank'));
490
+ return m;
491
+ }).filter(m => m !== null);
492
+ const layer = isClustering ? L.markerClusterGroup().addLayers(markers) : L.layerGroup(markers);
493
+ if (isCompare) pinLayerComp = layer; else pinLayer = layer;
494
+ }
495
+
496
+ function createHeatmapLayer(isCompare = false) {
497
+ const data = isCompare ? compareDisplayData : displayData;
498
+ if (data.length === 0) { if(isCompare) heatmapLayerComp = null; else heatmapLayer = null; return; }
499
+ let maxRev = Math.max(...data.map(d => d.revenue || 0), 1);
500
+ const points = data.map(l => [l.lat, l.lon, isWeighted ? (l.revenue || 0) / maxRev : 1.0]);
501
+ const layer = L.heatLayer(points, {
502
+ radius: parseInt(sel.radiusIn.value),
503
+ blur: parseInt(sel.blurIn.value),
504
+ maxZoom: parseInt(sel.maxZoomIn.value),
505
+ minOpacity: 0.4
506
+ });
507
+ if (isCompare) heatmapLayerComp = layer; else heatmapLayer = layer;
508
+ }
509
+
510
+ function createGridLayer(isCompare = false) {
511
+ const data = isCompare ? compareDisplayData : displayData;
512
+ if (data.length === 0) { if(isCompare) gridLayerComp = null; else gridLayer = null; return; }
513
+ const rawVal = parseInt(sel.gridSizeSlider.value);
514
+ const gridSize = 0.4 * Math.pow(rawVal / 100, 2);
515
+ sel.gridSizeVal.textContent = gridSize.toFixed(3);
516
+ const bins = {}; let maxVal = 0;
517
+ data.forEach(l => {
518
+ if (l.lat == null) return;
519
+ const key = `${Math.floor(l.lat/gridSize)},${Math.floor(l.lon/gridSize)}`;
520
+ if (!bins[key]) bins[key] = { count: 0, revenue: 0, lat: Math.floor(l.lat/gridSize)*gridSize, lon: Math.floor(l.lon/gridSize)*gridSize };
521
+ bins[key].count++; bins[key].revenue += (l.revenue||0);
522
+ const v = isWeighted ? bins[key].revenue : bins[key].count;
523
+ if (v > maxVal) maxVal = v;
524
+ });
525
+ const rects = Object.values(bins).map(bin => {
526
+ const i = (isWeighted ? bin.revenue : bin.count) / maxVal;
527
+ return L.rectangle([[bin.lat, bin.lon], [bin.lat+gridSize, bin.lon+gridSize]], { color: 'transparent', fillColor: isWeighted ? 'green' : 'blue', fillOpacity: 0.2 + (i*0.6) }).bindTooltip(isWeighted ? `Revenue: ${bin.revenue.toFixed(2)}` : `Count: ${bin.count}`);
528
+ });
529
+ const layer = L.layerGroup(rects);
530
+ if (isCompare) gridLayerComp = layer; else gridLayer = layer;
531
+ }
532
+
533
+ function showCurrentView() {
534
+ if (!map) return;
535
+ if (currentView === 'pins' && pinLayer) map.addLayer(pinLayer);
536
+ else if (currentView === 'heatmap' && heatmapLayer) map.addLayer(heatmapLayer);
537
+ else if (currentView === 'grid' && gridLayer) map.addLayer(gridLayer);
538
+
539
+ sel.heatmapPanel.style.display = (currentView === 'heatmap' || currentView === 'grid' ? 'block' : 'none');
540
+ sel.heatmapControls.style.display = (currentView === 'heatmap' ? 'block' : 'none');
541
+ sel.gridControls.style.display = (currentView === 'grid' ? 'block' : 'none');
542
+
543
+ if (sel.clusterToggle) sel.clusterToggle.style.display = (currentView === 'pins' ? 'inline-block' : 'none');
544
+ if (eventMarkerLayer) map.addLayer(eventMarkerLayer);
545
+ if (comparisonLayer && currentView === 'pins') map.addLayer(comparisonLayer);
546
+
547
+ if (mapCompare && sel.mapSplitRoot.classList.contains('map-split-active')) {
548
+ if (currentView === 'pins' && pinLayerComp) mapCompare.addLayer(pinLayerComp);
549
+ else if (currentView === 'heatmap' && heatmapLayerComp) mapCompare.addLayer(heatmapLayerComp);
550
+ else if (currentView === 'grid' && gridLayerComp) mapCompare.addLayer(gridLayerComp);
551
+ if (eventMarkerLayerComp) mapCompare.addLayer(eventMarkerLayerComp);
552
+ }
553
+ }
554
+
555
+ function renderEventMarker(em, isCompare = false) {
556
+ const layer = L.marker([em.lat, em.lon], { icon: icons.red, zIndexOffset: 2000 }).bindTooltip(`<strong>EVENT: ${em.name}</strong><br>${em.location}`);
557
+ if (isCompare) {
558
+ if (eventMarkerLayerComp && mapCompare.hasLayer(eventMarkerLayerComp)) mapCompare.removeLayer(eventMarkerLayerComp);
559
+ eventMarkerLayerComp = layer.addTo(mapCompare);
560
+ } else {
561
+ if (eventMarkerLayer && map.hasLayer(eventMarkerLayer)) map.removeLayer(eventMarkerLayer);
562
+ eventMarkerLayer = layer.addTo(map);
563
+ }
564
+ }
565
+
566
+ function loadComparison(slug) {
567
+ currentCompareSlug = slug;
568
+ if (!slug) {
569
+ compareData = []; compareFilteredData = []; compareDisplayData = [];
570
+ sel.compareLabel.textContent = "";
571
+ refreshLayers(); return;
572
+ }
573
+ const option = sel.compareSelect.options[sel.compareSelect.selectedIndex];
574
+ sel.compareLabel.textContent = option.text;
575
+ const newUrl = sel.map.dataset.dataUrl.replace(/\/event\/([^\/]+)\/([^\/]+)\//, `/event/$1/${slug}/`);
576
+ fetch(newUrl).then(r => r.json()).then(data => {
577
+ compareData = (data.locations || []).map(loc => { loc.ts = loc.date ? new Date(loc.date).getTime() : 0; return loc; }).sort((a, b) => a.ts - b.ts);
578
+ if (data.event_marker) renderEventMarker(data.event_marker, true);
579
+ applyFilters();
580
+ });
581
+ }
582
+
583
+ function extractItems() { availableItems.clear(); allData.forEach(loc => (loc.items || []).forEach(i => availableItems.add(i))); selectedItems = new Set(availableItems); }
584
+ function buildFilterUI() {
585
+ if (!sel.filterCheckboxes) return; sel.filterCheckboxes.innerHTML = '';
586
+ Array.from(availableItems).sort().forEach(item => {
587
+ const div = document.createElement('div'); div.className = 'checkbox';
588
+ const label = document.createElement('label'); const input = document.createElement('input'); input.type = 'checkbox'; input.checked = true;
589
+ input.onchange = (e) => { if (e.target.checked) selectedItems.add(item); else selectedItems.delete(item); applyFilters(); };
590
+ label.appendChild(input); label.appendChild(document.createTextNode(` ${item}`)); div.appendChild(label); sel.filterCheckboxes.appendChild(div);
591
+ });
592
+ }
593
+ function applyFilters() {
594
+ showCanceled = sel.showCanceledCheck.checked;
595
+ const filterFn = loc => (loc.items.length === 0 || loc.items.some(i => selectedItems.has(i))) && (showCanceled || loc.status !== 'canceled');
596
+ filteredData = allData.filter(filterFn);
597
+ if (compareData.length > 0) compareFilteredData = compareData.filter(filterFn);
598
+ updateTimelineSlider();
599
+ }
600
+
601
+ function renderFailedOrders(orders) {
602
+ const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
603
+ sel.failedTbody.innerHTML = orders.map(o => `<tr><td><code>${o.code}</code></td><td>${o.address}</td><td><div class="btn-group"><a href="${o.url}" target="_blank" class="btn btn-default btn-xs">View</a><button class="btn btn-warning btn-xs retry-btn" data-url="${o.retry_url}">Retry</button></div></td></tr>`).join('') || '<tr><td colspan="3">None</td></tr>';
604
+ sel.failedTbody.querySelectorAll('.retry-btn').forEach(btn => btn.onclick = () => {
605
+ btn.disabled = true; btn.innerHTML = '...';
606
+ fetch(btn.dataset.url, { method: 'POST', headers: { 'X-CSRFToken': csrfToken } }).then(r => r.json()).then(d => { if(d.success) { btn.className = 'btn btn-success btn-xs'; btn.innerHTML = '✔'; setTimeout(() => fetchData(sel.map.dataset.dataUrl), 1000); } });
607
+ });
608
+ }
609
+
610
+ init();
611
+ });