pretix-map 0.1.4__py3-none-any.whl → 0.1.5__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.5.dist-info/METADATA +88 -0
  2. {pretix_map-0.1.4.dist-info → pretix_map-0.1.5.dist-info}/RECORD +32 -30
  3. {pretix_map-0.1.4.dist-info → pretix_map-0.1.5.dist-info}/WHEEL +1 -1
  4. {pretix_map-0.1.4.dist-info → pretix_map-0.1.5.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 +51 -51
  17. pretix_mapplugin/static/pretix_mapplugin/js/salesmap.js +342 -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 +154 -88
  28. pretix_mapplugin/templates/pretix_mapplugin/milestones.html +53 -0
  29. pretix_mapplugin/urls.py +38 -21
  30. pretix_mapplugin/views.py +272 -163
  31. pretix_map-0.1.4.dist-info/METADATA +0 -195
  32. {pretix_map-0.1.4.dist-info → pretix_map-0.1.5.dist-info}/entry_points.txt +0 -0
  33. {pretix_map-0.1.4.dist-info → pretix_map-0.1.5.dist-info}/top_level.txt +0 -0
@@ -1,452 +1,342 @@
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)");
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'), // NEW
44
+ radiusVal: document.getElementById('radius-value'),
45
+ blurVal: document.getElementById('blur-value'),
46
+ maxZoomVal: document.getElementById('maxzoom-value'), // NEW
47
+ heatmapReset: document.getElementById('heatmap-reset-btn'),
48
+ timeSlider: document.getElementById('timeline-slider'),
49
+ timeDisplay: document.getElementById('timeline-date-display'),
50
+ timePlayBtn: document.getElementById('timeline-play-btn'),
51
+ timeCount: document.getElementById('timeline-count-display'),
52
+ intervalSelect: document.getElementById('timeline-interval-select')
53
+ };
54
+
55
+ // --- State ---
56
+ let map = null, allData = [], filteredData = [], displayData = [], milestones = [], currency = "EUR";
57
+ let pinLayer = null, heatmapLayer = null, gridLayer = null, comparisonLayer = null, eventMarkerLayer = null;
58
+ let currentView = 'pins', isClustering = true, isWeighted = false, showCanceled = false;
59
+ let isPlaying = false, playInterval = null;
60
+ let availableItems = new Set(), selectedItems = new Set();
61
+ let currentDisplayMode = 'map'; // map | list | stats
62
+ let charts = {};
63
+
64
+ function updateStatus(msg, isErr = false) {
65
+ if (sel.overlay) {
66
+ const p = sel.overlay.querySelector('p');
67
+ if (p) { p.textContent = msg; p.className = isErr ? 'text-danger' : ''; }
68
+ sel.overlay.style.display = 'flex';
69
+ }
70
+ }
71
+ function hideStatus() { if (sel.overlay) sel.overlay.style.display = 'none'; }
72
+
73
+ function init() {
74
+ if (!sel.map) return;
75
+ const dataUrl = sel.map.dataset.dataUrl;
76
+ map = L.map('sales-map-container').setView([48.85, 2.35], 5);
77
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(map);
78
+ setupControls();
79
+ fetchData(dataUrl);
80
+ }
81
+
82
+ function fetchData(url) {
83
+ updateStatus("Loading data...");
84
+ fetch(url).then(r => r.json()).then(data => {
85
+ if (data.error) throw new Error(data.error);
86
+ allData = (data.locations || []).map(loc => { loc.ts = loc.date ? new Date(loc.date).getTime() : 0; return loc; }).sort((a, b) => a.ts - b.ts);
87
+ milestones = (data.milestones || []).map(m => { m.ts = new Date(m.date).getTime(); return m; });
88
+ currency = (data.stats && data.stats.currency) ? data.stats.currency : "EUR";
89
+
90
+ extractItems(); buildFilterUI(); initTimeline(); applyFilters();
91
+ renderFailedOrders(data.failed_orders || []);
92
+ renderStats(data.stats || {});
93
+ if (data.event_marker) renderEventMarker(data.event_marker);
94
+
95
+ [sel.viewToggle, sel.clusterToggle, sel.gridToggle, sel.weightToggle, sel.compareSelect, sel.filterToggle, sel.statsToggle, sel.listToggle].forEach(b => { if (b) b.disabled = false; });
96
+ hideStatus();
97
+ setTimeout(() => map.invalidateSize(), 100);
98
+ }).catch(e => { console.error(e); updateStatus(e.message, true); });
99
+ }
100
+
101
+ function setupControls() {
102
+ sel.viewToggle.onclick = () => { currentView = (currentView === 'pins' ? 'heatmap' : 'pins'); refreshLayers(); };
103
+ sel.gridToggle.onclick = () => { currentView = (currentView === 'grid' ? 'pins' : 'grid'); refreshLayers(); };
104
+ sel.weightToggle.onclick = () => { isWeighted = !isWeighted; sel.weightToggle.className = isWeighted ? 'btn btn-success' : 'btn btn-default'; refreshLayers(); };
105
+ sel.showCanceledCheck.onchange = applyFilters;
106
+ sel.clusterToggle.onclick = () => { isClustering = !isClustering; refreshLayers(); };
107
+ sel.compareSelect.onchange = (e) => loadComparison(e.target.value);
108
+ sel.timeSlider.oninput = updateTimelineDisplay;
109
+ sel.timePlayBtn.onclick = togglePlay;
110
+ sel.intervalSelect.onchange = updateTimelineSlider;
111
+ sel.filterToggle.onclick = () => sel.filterPanel.style.display = (sel.filterPanel.style.display === 'none' ? 'block' : 'none');
112
+
113
+ sel.listToggle.onclick = () => switchDisplayMode(currentDisplayMode === 'list' ? 'map' : 'list');
114
+ sel.statsToggle.onclick = () => switchDisplayMode(currentDisplayMode === 'stats' ? 'map' : 'stats');
115
+
116
+ const updateHeat = () => {
117
+ sel.radiusVal.textContent = sel.radiusIn.value;
118
+ sel.blurVal.textContent = sel.blurIn.value;
119
+ sel.maxZoomVal.textContent = sel.maxZoomIn.value;
120
+
121
+ if (heatmapLayer) {
122
+ heatmapLayer.setOptions({
123
+ radius: parseInt(sel.radiusIn.value),
124
+ blur: parseInt(sel.blurIn.value),
125
+ maxZoom: parseInt(sel.maxZoomIn.value),
126
+ minOpacity: 0.4 // Improved visibility
127
+ });
128
+ }
129
+ };
130
+ [sel.radiusIn, sel.blurIn, sel.maxZoomIn].forEach(i => i.oninput = updateHeat);
131
+ sel.heatmapReset.onclick = () => {
132
+ sel.radiusIn.value = 25; sel.blurIn.value = 15; sel.maxZoomIn.value = 18;
133
+ updateHeat();
134
+ };
135
+ }
136
+
137
+ function switchDisplayMode(mode) {
138
+ currentDisplayMode = mode;
139
+ sel.map.style.display = (mode === 'map' ? 'block' : 'none');
140
+ sel.failedContainer.style.display = (mode === 'list' ? 'block' : 'none');
141
+ sel.analyticsView.style.display = (mode === 'stats' ? 'block' : 'none');
142
+
143
+ sel.listToggle.className = (mode === 'list' ? 'btn btn-success' : 'btn btn-info');
144
+ sel.statsToggle.className = (mode === 'stats' ? 'btn btn-success' : 'btn btn-primary');
145
+ sel.listToggle.textContent = (mode === 'list' ? 'Show Map' : 'Failed Orders');
146
+ sel.statsToggle.textContent = (mode === 'stats' ? 'Show Map' : 'Analytics View');
147
+
148
+ if (mode === 'map') map.invalidateSize();
149
+ }
150
+
151
+ function renderStats(s) {
152
+ if (!sel.statsContent) return;
153
+ sel.statsContent.innerHTML = `
154
+ <table class="table table-condensed">
155
+ <tr><td><strong>Orders Total</strong></td><td class="text-right">${s.total_orders}</td></tr>
156
+ <tr><td><strong>Geocoded</strong></td><td class="text-right">${s.geocoded_count}</td></tr>
157
+ <tr><td><strong>Revenue</strong></td><td class="text-right">${s.total_revenue.toFixed(2)} ${s.currency}</td></tr>
158
+ <tr><td><strong>Avg. Travel</strong></td><td class="text-right">${s.avg_distance_km} km</td></tr>
159
+ </table>
160
+ `;
161
+ renderCharts(s);
162
+ }
163
+
164
+ function renderCharts(s) {
165
+ const ctxCities = document.getElementById('cities-chart').getContext('2d');
166
+ if (charts.cities) charts.cities.destroy();
167
+ charts.cities = new Chart(ctxCities, {
168
+ type: 'bar',
169
+ data: {
170
+ labels: s.top_cities.map(c => c[0]),
171
+ datasets: [{ label: 'Orders', data: s.top_cities.map(c => c[1]), backgroundColor: 'rgba(54, 162, 235, 0.5)' }]
172
+ },
173
+ options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false }
174
+ });
175
+
176
+ const ctxItems = document.getElementById('items-chart').getContext('2d');
177
+ if (charts.items) charts.items.destroy();
178
+ charts.items = new Chart(ctxItems, {
179
+ type: 'doughnut',
180
+ data: {
181
+ labels: s.top_items.map(i => i[0]),
182
+ datasets: [{ data: s.top_items.map(i => i[1]), backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF'] }]
183
+ },
184
+ options: { responsive: true, maintainAspectRatio: false }
185
+ });
186
+ }
187
+
188
+ // --- Timeline Logik ---
189
+ function initTimeline() {
190
+ if (!sel.timeSlider) return;
191
+ updateTimelineSlider();
192
+ }
193
+
194
+ function updateTimelineSlider() {
195
+ const interval = sel.intervalSelect.value;
196
+ const validData = allData.filter(d => d.ts > 0);
197
+ if (validData.length === 0) { sel.timeSlider.disabled = true; return; }
198
+ const uniqueKeys = new Set();
199
+ validData.forEach(d => {
200
+ const date = new Date(d.ts);
201
+ if (interval === 'hour') date.setMinutes(0, 0, 0);
202
+ else if (interval === 'day') date.setHours(0, 0, 0, 0);
203
+ 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); }
204
+ else if (interval === 'month') { date.setDate(1); date.setHours(0, 0, 0, 0); }
205
+ uniqueKeys.add(date.getTime());
206
+ });
207
+ const steps = Array.from(uniqueKeys).sort((a, b) => a - b);
208
+ sel.timeSlider.min = 0; sel.timeSlider.max = steps.length - 1; sel.timeSlider.value = steps.length - 1;
209
+ sel.timeSlider._steps = steps;
210
+ updateTimelineDisplay();
211
+ }
212
+
213
+ function updateTimelineDisplay() {
214
+ const steps = sel.timeSlider._steps;
215
+ if (!steps) return;
216
+ const currentTs = steps[parseInt(sel.timeSlider.value)];
217
+ const dateObj = new Date(currentTs);
218
+ const activeMilestone = milestones.find(m => new Date(m.date).toDateString() === dateObj.toDateString());
219
+ sel.timeDisplay.innerHTML = dateObj.toLocaleDateString() + (activeMilestone ? ` — <strong>★ ${activeMilestone.label}</strong>` : "");
220
+ displayData = filteredData.filter(d => d.ts === 0 || d.ts <= currentTs);
221
+ if (sel.timeCount) sel.timeCount.textContent = displayData.length;
222
+ refreshLayers();
223
+ }
224
+
225
+ function togglePlay() {
226
+ if (isPlaying) { clearInterval(playInterval); isPlaying = false; sel.timePlayBtn.innerHTML = '<i class="fa fa-play"></i> Play'; }
227
+ else {
228
+ isPlaying = true; sel.timePlayBtn.innerHTML = '<i class="fa fa-pause"></i> Pause';
229
+ if (parseInt(sel.timeSlider.value) >= parseInt(sel.timeSlider.max)) sel.timeSlider.value = 0;
230
+ playInterval = setInterval(() => {
231
+ let v = parseInt(sel.timeSlider.value);
232
+ if (v < parseInt(sel.timeSlider.max)) { sel.timeSlider.value = v + 1; updateTimelineDisplay(); }
233
+ else togglePlay();
234
+ }, 400);
235
+ }
236
+ }
237
+
238
+ function refreshLayers() {
239
+ if (!map) return;
240
+ [pinLayer, heatmapLayer, gridLayer].forEach(l => { if (l && map.hasLayer(l)) map.removeLayer(l); });
241
+ createPinLayer(); createHeatmapLayer(); createGridLayer();
242
+ showCurrentView();
243
+ }
244
+
245
+ function createPinLayer() {
246
+ if (displayData.length === 0) { pinLayer = null; return; }
247
+ const markers = displayData.map(loc => {
248
+ if (loc.lat == null) return null;
249
+ let icon = icons.blue;
250
+ if (loc.status === 'pending') icon = icons.orange;
251
+ else if (loc.status === 'canceled') icon = icons.grey;
252
+ const m = L.marker([loc.lat, loc.lon], { icon: icon });
253
+ if (loc.tooltip) m.bindTooltip(loc.tooltip);
254
+ if (loc.order_url) m.on('click', () => window.open(loc.order_url, '_blank'));
255
+ return m;
256
+ }).filter(m => m !== null);
257
+ pinLayer = isClustering ? L.markerClusterGroup().addLayers(markers) : L.layerGroup(markers);
258
+ }
259
+
260
+ function createHeatmapLayer() {
261
+ if (displayData.length === 0) { heatmapLayer = null; return; }
262
+ let maxRev = Math.max(...displayData.map(d => d.revenue || 0), 1);
263
+ const points = displayData.map(l => [l.lat, l.lon, isWeighted ? (l.revenue || 0) / maxRev : 1.0]);
264
+ heatmapLayer = L.heatLayer(points, {
265
+ radius: parseInt(sel.radiusIn.value),
266
+ blur: parseInt(sel.blurIn.value),
267
+ maxZoom: parseInt(sel.maxZoomIn.value),
268
+ minOpacity: 0.4
269
+ });
270
+ }
271
+
272
+ function createGridLayer() {
273
+ if (displayData.length === 0) { gridLayer = null; return; }
274
+ const gridSize = 0.4, bins = {}; let maxVal = 0;
275
+ displayData.forEach(l => {
276
+ if (l.lat == null) return;
277
+ const key = `${Math.floor(l.lat/gridSize)},${Math.floor(l.lon/gridSize)}`;
278
+ if (!bins[key]) bins[key] = { count: 0, revenue: 0, lat: Math.floor(l.lat/gridSize)*gridSize, lon: Math.floor(l.lon/gridSize)*gridSize };
279
+ bins[key].count++; bins[key].revenue += (l.revenue||0);
280
+ const v = isWeighted ? bins[key].revenue : bins[key].count;
281
+ if (v > maxVal) maxVal = v;
282
+ });
283
+ const rects = Object.values(bins).map(bin => {
284
+ const i = (isWeighted ? bin.revenue : bin.count) / maxVal;
285
+ 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}`);
286
+ });
287
+ gridLayer = L.layerGroup(rects);
288
+ }
289
+
290
+ function showCurrentView() {
291
+ if (!map) return;
292
+ [pinLayer, heatmapLayer, gridLayer].forEach(l => { if (l && map.hasLayer(l)) map.removeLayer(l); });
293
+ if (currentView === 'pins' && pinLayer) map.addLayer(pinLayer);
294
+ else if (currentView === 'heatmap' && heatmapLayer) map.addLayer(heatmapLayer);
295
+ else if (currentView === 'grid' && gridLayer) map.addLayer(gridLayer);
296
+ sel.heatmapPanel.style.display = (currentView === 'heatmap' ? 'block' : 'none');
297
+ if (sel.clusterToggle) sel.clusterToggle.style.display = (currentView === 'pins' ? 'inline-block' : 'none');
298
+ if (eventMarkerLayer) map.addLayer(eventMarkerLayer);
299
+ if (comparisonLayer) map.addLayer(comparisonLayer);
300
+ }
301
+
302
+ function renderEventMarker(em) {
303
+ if (eventMarkerLayer) map.removeLayer(eventMarkerLayer);
304
+ eventMarkerLayer = L.marker([em.lat, em.lon], { icon: icons.red, zIndexOffset: 2000 }).bindTooltip(`<strong>EVENT: ${em.name}</strong><br>${em.location}`).addTo(map);
305
+ }
306
+
307
+ function loadComparison(slug) {
308
+ if (!slug) { if (comparisonLayer) map.removeLayer(comparisonLayer); return; }
309
+ const newUrl = sel.map.dataset.dataUrl.replace(/\/event\/([^\/]+)\/([^\/]+)\//, `/event/$1/${slug}/`);
310
+ fetch(newUrl).then(r => r.json()).then(data => {
311
+ if (comparisonLayer) map.removeLayer(comparisonLayer);
312
+ comparisonLayer = L.layerGroup((data.locations || []).map(l => l.lat ? L.circleMarker([l.lat, l.lon], { radius: 4, color: '#888', fillOpacity: 0.5 }) : null).filter(l => l)).addTo(map);
313
+ });
314
+ }
315
+
316
+ function extractItems() { availableItems.clear(); allData.forEach(loc => (loc.items || []).forEach(i => availableItems.add(i))); selectedItems = new Set(availableItems); }
317
+ function buildFilterUI() {
318
+ if (!sel.filterCheckboxes) return; sel.filterCheckboxes.innerHTML = '';
319
+ Array.from(availableItems).sort().forEach(item => {
320
+ const div = document.createElement('div'); div.className = 'checkbox';
321
+ const label = document.createElement('label'); const input = document.createElement('input'); input.type = 'checkbox'; input.checked = true;
322
+ input.onchange = (e) => { if (e.target.checked) selectedItems.add(item); else selectedItems.delete(item); applyFilters(); };
323
+ label.appendChild(input); label.appendChild(document.createTextNode(` ${item}`)); div.appendChild(label); sel.filterCheckboxes.appendChild(div);
324
+ });
325
+ }
326
+ function applyFilters() {
327
+ showCanceled = sel.showCanceledCheck.checked;
328
+ filteredData = allData.filter(loc => (loc.items.length === 0 || loc.items.some(i => selectedItems.has(i))) && (showCanceled || loc.status !== 'canceled'));
329
+ updateTimelineSlider();
330
+ }
331
+
332
+ function renderFailedOrders(orders) {
333
+ const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
334
+ 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>';
335
+ sel.failedTbody.querySelectorAll('.retry-btn').forEach(btn => btn.onclick = () => {
336
+ btn.disabled = true; btn.innerHTML = '...';
337
+ 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); } });
338
+ });
339
+ }
340
+
341
+ init();
342
+ });