pretix-map 0.1.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pretix-map
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: An overview map of the catchment area of previous orders. Measured by postcode
5
5
  Author-email: MarkenJaden <jjsch1410@gmail.com>
6
6
  Maintainer-email: MarkenJaden <jjsch1410@gmail.com>
@@ -1,12 +1,12 @@
1
- pretix_map-0.1.5.dist-info/licenses/LICENSE,sha256=MNMHjIJIjeQzQboYGsgFSRTBUHctF603DDa8MgCNyAg,554
2
- pretix_mapplugin/__init__.py,sha256=rPSfWgIeq2YWVPyESOAwCBt8vftsTpIkuLAGDEzyRQc,22
1
+ pretix_map-0.1.6.dist-info/licenses/LICENSE,sha256=MNMHjIJIjeQzQboYGsgFSRTBUHctF603DDa8MgCNyAg,554
2
+ pretix_mapplugin/__init__.py,sha256=n3oM6B_EMz93NsTI18NNZd-jKFcUPzUkbIKj5VFK5ok,22
3
3
  pretix_mapplugin/apps.py,sha256=QOo53Z6zX0yB-Rz6I92tAu051sP38hM2iJlP9sk2JEg,802
4
4
  pretix_mapplugin/geocoding.py,sha256=N_JyjYU_JBouwE3oaL3G1wI5jAyezZzS5IBMgI_GdWQ,6053
5
5
  pretix_mapplugin/models.py,sha256=Ofx8S1UlpQVFt-pC9-_LKecF-Hv2uI2rDq5zJ5S_UJQ,2494
6
6
  pretix_mapplugin/signals.py,sha256=_SRT-3TllYzD9kUf0JG3PnuWa6cPHkFFSWInCDGm_j0,3155
7
7
  pretix_mapplugin/tasks.py,sha256=HKDFC-U1TtYDNrN26QVx133hn0K0BdBUm4qCCYHcgOY,6367
8
8
  pretix_mapplugin/urls.py,sha256=gZC74OUSZI2ZUWQXm1pbz8taIV8jA9D2h7FvojRX9Ws,1458
9
- pretix_mapplugin/views.py,sha256=MByLWX2_U7gqs1AV59ZICIZDcEi2nIPPOfFxZgUhCvI,11591
9
+ pretix_mapplugin/views.py,sha256=dUQSXbPVGft-EytZFC7WLc_Uw3s7VLVYn5of8JzepL4,12731
10
10
  pretix_mapplugin/locale/de/LC_MESSAGES/django.mo,sha256=6VVRAqa0ixL-lDA1QwoVvG0wd5ZBwYjaR4P8T73hxhU,269
11
11
  pretix_mapplugin/locale/de/LC_MESSAGES/django.po,sha256=-HtJt_qb8k7C30pVXRuYeh5CIB_ISVt2HEAXGn2rVnw,311
12
12
  pretix_mapplugin/locale/de_Informal/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -20,8 +20,8 @@ pretix_mapplugin/migrations/0002_remove_ordergeocodedata_geocoded_timestamp_and_
20
20
  pretix_mapplugin/migrations/0003_mapmilestone.py,sha256=2fcuMOSWcyF-wReVuGs53_a8pLpYLfNoCIIAekdRSUQ,991
21
21
  pretix_mapplugin/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  pretix_mapplugin/static/pretix_mapplugin/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- pretix_mapplugin/static/pretix_mapplugin/css/salesmap.css,sha256=l-PfqoW8pGYAI30l6KYkmdxRDQdtz84vN_6Qbvo3kNU,913
24
- pretix_mapplugin/static/pretix_mapplugin/js/salesmap.js,sha256=1zJKkHL1WnB8u3MkUJOR_33dGkaiz5ylpJbNg4Tr-Iw,19897
23
+ pretix_mapplugin/static/pretix_mapplugin/css/salesmap.css,sha256=HgKozvQjWTzxJV4ua1hT9PiDzrvUSWrMnYRUvTTj4aI,1870
24
+ pretix_mapplugin/static/pretix_mapplugin/js/salesmap.js,sha256=8mLC6rQqJRDoPls4ykBIuN1fsxTkgDBNL6KtZaJLoos,32678
25
25
  pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/MarkerCluster.Default.css,sha256=YSWCMtmNZNwqex4CEw1nQhvFub2lmU7vcCKP-XVwwXA,1287
26
26
  pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/MarkerCluster.css,sha256=YU3qCpj_P06tdPBJGPax0bm6Q1wltfwjsho5TR4-TYc,872
27
27
  pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/leaflet-heat.js,sha256=65UqrlgGoRAnKfKRuriH3eeDrOhZgZo1SCenduc-SGo,5158
@@ -40,10 +40,10 @@ pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/images/marker-ic
40
40
  pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/images/marker-icon.png,sha256=V0w6XMqF9BFAhbaEFZbWLwDXyJLHsD8oy_owHesdxDc,1466
41
41
  pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/images/marker-shadow.png,sha256=Jk9cZAM58ELdcpBiz8BMF_jqDymIK1OOOEjtjxDttNo,618
42
42
  pretix_mapplugin/templates/pretix_mapplugin/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
- pretix_mapplugin/templates/pretix_mapplugin/map_page.html,sha256=_lUjaaRNf9bBc4aepek4Sp2w7QE1coRLPSF9mdfIFiw,9824
43
+ pretix_mapplugin/templates/pretix_mapplugin/map_page.html,sha256=EqppF1UDMAAbR0paBjStId-1x754XdI5JVLl_hzD7zk,12346
44
44
  pretix_mapplugin/templates/pretix_mapplugin/milestones.html,sha256=2cdcmuA9XxDC3BFZuVL9tO-Nsz7whKasqLM9en3x5cw,2032
45
- pretix_map-0.1.5.dist-info/METADATA,sha256=ls_bUm29Qxk256wtUPHplJOrWxywvFN8J7LPecu2HN0,3575
46
- pretix_map-0.1.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
47
- pretix_map-0.1.5.dist-info/entry_points.txt,sha256=C3NAjeZHoCekafkLMCJynPcABRTK8AUprtQv7sUNDZs,137
48
- pretix_map-0.1.5.dist-info/top_level.txt,sha256=CAtEnkgA73zE9Gadm5mjt1SpXHBPOS-QWP0dQVoNToE,17
49
- pretix_map-0.1.5.dist-info/RECORD,,
45
+ pretix_map-0.1.6.dist-info/METADATA,sha256=JbGXDdFA2oi2A35rLMmMPy19neall4Y8tuIJLLGaG_k,3575
46
+ pretix_map-0.1.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
47
+ pretix_map-0.1.6.dist-info/entry_points.txt,sha256=C3NAjeZHoCekafkLMCJynPcABRTK8AUprtQv7sUNDZs,137
48
+ pretix_map-0.1.6.dist-info/top_level.txt,sha256=CAtEnkgA73zE9Gadm5mjt1SpXHBPOS-QWP0dQVoNToE,17
49
+ pretix_map-0.1.6.dist-info/RECORD,,
@@ -1 +1 @@
1
- __version__ = "0.1.5"
1
+ __version__ = "0.1.6"
@@ -16,6 +16,8 @@
16
16
  position: relative;
17
17
  min-height: 0;
18
18
  border: 1px solid #ccc;
19
+ display: flex;
20
+ flex-direction: column;
19
21
  }
20
22
 
21
23
  #sales-map-container {
@@ -25,6 +27,53 @@
25
27
  position: relative;
26
28
  }
27
29
 
30
+ /* Side-by-side comparison styles */
31
+ .map-split-container {
32
+ display: flex;
33
+ flex-direction: row;
34
+ width: 100%;
35
+ flex-grow: 1;
36
+ min-height: 500px;
37
+ }
38
+
39
+ #sales-map-compare-container {
40
+ width: 50%;
41
+ height: 100%;
42
+ border-left: 2px solid #fff;
43
+ display: none;
44
+ }
45
+
46
+ .map-split-active #sales-map-container {
47
+ width: 50% !important;
48
+ }
49
+
50
+ .map-split-active #sales-map-compare-container {
51
+ display: block;
52
+ }
53
+
54
+ .map-label-overlay {
55
+ position: absolute;
56
+ top: 10px;
57
+ left: 50px;
58
+ z-index: 1000;
59
+ background: rgba(255, 255, 255, 0.8);
60
+ padding: 2px 8px;
61
+ border-radius: 4px;
62
+ pointer-events: none;
63
+ font-weight: bold;
64
+ border: 1px solid #ccc;
65
+ }
66
+
67
+ #timeline-chart-container {
68
+ box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
69
+ }
70
+
71
+ .timeline-milestone-marker:hover span {
72
+ background: rgba(255, 255, 255, 1) !important;
73
+ z-index: 10;
74
+ border: 1px solid #ccc;
75
+ }
76
+
28
77
  #map-status-overlay {
29
78
  position: absolute;
30
79
  top: 0;
@@ -1,5 +1,5 @@
1
1
  document.addEventListener('DOMContentLoaded', function () {
2
- console.log("Sales Map JS Loaded (Charts, CSP Fix, Toggle Mode Fix)");
2
+ console.log("Sales Map JS Loaded (Charts, CSP Fix, Toggle Mode Fix, Split-View)");
3
3
 
4
4
  // --- Leaflet Setup ---
5
5
  try {
@@ -40,23 +40,38 @@ document.addEventListener('DOMContentLoaded', function () {
40
40
  filterCheckboxes: document.getElementById('filter-checkboxes'),
41
41
  radiusIn: document.getElementById('heatmap-radius'),
42
42
  blurIn: document.getElementById('heatmap-blur'),
43
- maxZoomIn: document.getElementById('heatmap-maxZoom'), // NEW
43
+ maxZoomIn: document.getElementById('heatmap-maxZoom'),
44
44
  radiusVal: document.getElementById('radius-value'),
45
45
  blurVal: document.getElementById('blur-value'),
46
- maxZoomVal: document.getElementById('maxzoom-value'), // NEW
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'),
47
55
  heatmapReset: document.getElementById('heatmap-reset-btn'),
48
56
  timeSlider: document.getElementById('timeline-slider'),
49
57
  timeDisplay: document.getElementById('timeline-date-display'),
50
58
  timePlayBtn: document.getElementById('timeline-play-btn'),
51
59
  timeCount: document.getElementById('timeline-count-display'),
52
- intervalSelect: document.getElementById('timeline-interval-select')
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')
53
65
  };
54
66
 
55
67
  // --- State ---
56
- let map = null, allData = [], filteredData = [], displayData = [], milestones = [], currency = "EUR";
68
+ let map = null, mapCompare = null, allData = [], filteredData = [], displayData = [], milestones = [], currency = "EUR";
69
+ let itemAvailData = {};
70
+ let compareData = [], compareFilteredData = [], compareDisplayData = [];
57
71
  let pinLayer = null, heatmapLayer = null, gridLayer = null, comparisonLayer = null, eventMarkerLayer = null;
72
+ let pinLayerComp = null, heatmapLayerComp = null, gridLayerComp = null, eventMarkerLayerComp = null;
58
73
  let currentView = 'pins', isClustering = true, isWeighted = false, showCanceled = false;
59
- let isPlaying = false, playInterval = null;
74
+ let isPlaying = false, playInterval = null, currentCompareSlug = "";
60
75
  let availableItems = new Set(), selectedItems = new Set();
61
76
  let currentDisplayMode = 'map'; // map | list | stats
62
77
  let charts = {};
@@ -73,8 +88,26 @@ document.addEventListener('DOMContentLoaded', function () {
73
88
  function init() {
74
89
  if (!sel.map) return;
75
90
  const dataUrl = sel.map.dataset.dataUrl;
91
+
76
92
  map = L.map('sales-map-container').setView([48.85, 2.35], 5);
77
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
+
78
111
  setupControls();
79
112
  fetchData(dataUrl);
80
113
  }
@@ -85,6 +118,7 @@ document.addEventListener('DOMContentLoaded', function () {
85
118
  if (data.error) throw new Error(data.error);
86
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);
87
120
  milestones = (data.milestones || []).map(m => { m.ts = new Date(m.date).getTime(); return m; });
121
+ itemAvailData = data.item_availability || {};
88
122
  currency = (data.stats && data.stats.currency) ? data.stats.currency : "EUR";
89
123
 
90
124
  extractItems(); buildFilterUI(); initTimeline(); applyFilters();
@@ -123,15 +157,17 @@ document.addEventListener('DOMContentLoaded', function () {
123
157
  radius: parseInt(sel.radiusIn.value),
124
158
  blur: parseInt(sel.blurIn.value),
125
159
  maxZoom: parseInt(sel.maxZoomIn.value),
126
- minOpacity: 0.4 // Improved visibility
160
+ minOpacity: 0.4
127
161
  });
128
162
  }
129
163
  };
130
164
  [sel.radiusIn, sel.blurIn, sel.maxZoomIn].forEach(i => i.oninput = updateHeat);
131
165
  sel.heatmapReset.onclick = () => {
132
166
  sel.radiusIn.value = 25; sel.blurIn.value = 15; sel.maxZoomIn.value = 18;
167
+ sel.gridSizeSlider.value = 70;
133
168
  updateHeat();
134
169
  };
170
+ sel.gridSizeSlider.oninput = () => refreshLayers();
135
171
  }
136
172
 
137
173
  function switchDisplayMode(mode) {
@@ -185,7 +221,6 @@ document.addEventListener('DOMContentLoaded', function () {
185
221
  });
186
222
  }
187
223
 
188
- // --- Timeline Logik ---
189
224
  function initTimeline() {
190
225
  if (!sel.timeSlider) return;
191
226
  updateTimelineSlider();
@@ -207,9 +242,179 @@ document.addEventListener('DOMContentLoaded', function () {
207
242
  const steps = Array.from(uniqueKeys).sort((a, b) => a - b);
208
243
  sel.timeSlider.min = 0; sel.timeSlider.max = steps.length - 1; sel.timeSlider.value = steps.length - 1;
209
244
  sel.timeSlider._steps = steps;
245
+
246
+ renderTimelineVolumeChart(steps);
247
+ renderMilestoneMarkers(steps);
248
+ renderItemAvailability(steps);
210
249
  updateTimelineDisplay();
211
250
  }
212
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
+
213
418
  function updateTimelineDisplay() {
214
419
  const steps = sel.timeSlider._steps;
215
420
  if (!steps) return;
@@ -217,7 +422,12 @@ document.addEventListener('DOMContentLoaded', function () {
217
422
  const dateObj = new Date(currentTs);
218
423
  const activeMilestone = milestones.find(m => new Date(m.date).toDateString() === dateObj.toDateString());
219
424
  sel.timeDisplay.innerHTML = dateObj.toLocaleDateString() + (activeMilestone ? ` — <strong>★ ${activeMilestone.label}</strong>` : "");
425
+
220
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
+
221
431
  if (sel.timeCount) sel.timeCount.textContent = displayData.length;
222
432
  refreshLayers();
223
433
  }
@@ -238,13 +448,38 @@ document.addEventListener('DOMContentLoaded', function () {
238
448
  function refreshLayers() {
239
449
  if (!map) return;
240
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
+
241
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
+ }
242
476
  showCurrentView();
243
477
  }
244
478
 
245
- function createPinLayer() {
246
- if (displayData.length === 0) { pinLayer = null; return; }
247
- const markers = displayData.map(loc => {
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 => {
248
483
  if (loc.lat == null) return null;
249
484
  let icon = icons.blue;
250
485
  if (loc.status === 'pending') icon = icons.orange;
@@ -254,25 +489,32 @@ document.addEventListener('DOMContentLoaded', function () {
254
489
  if (loc.order_url) m.on('click', () => window.open(loc.order_url, '_blank'));
255
490
  return m;
256
491
  }).filter(m => m !== null);
257
- pinLayer = isClustering ? L.markerClusterGroup().addLayers(markers) : L.layerGroup(markers);
492
+ const layer = isClustering ? L.markerClusterGroup().addLayers(markers) : L.layerGroup(markers);
493
+ if (isCompare) pinLayerComp = layer; else pinLayer = layer;
258
494
  }
259
495
 
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, {
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, {
265
502
  radius: parseInt(sel.radiusIn.value),
266
503
  blur: parseInt(sel.blurIn.value),
267
504
  maxZoom: parseInt(sel.maxZoomIn.value),
268
505
  minOpacity: 0.4
269
506
  });
507
+ if (isCompare) heatmapLayerComp = layer; else heatmapLayer = layer;
270
508
  }
271
509
 
272
- function createGridLayer() {
273
- if (displayData.length === 0) { gridLayer = null; return; }
274
- const gridSize = 0.4, bins = {}; let maxVal = 0;
275
- displayData.forEach(l => {
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 => {
276
518
  if (l.lat == null) return;
277
519
  const key = `${Math.floor(l.lat/gridSize)},${Math.floor(l.lon/gridSize)}`;
278
520
  if (!bins[key]) bins[key] = { count: 0, revenue: 0, lat: Math.floor(l.lat/gridSize)*gridSize, lon: Math.floor(l.lon/gridSize)*gridSize };
@@ -284,32 +526,57 @@ document.addEventListener('DOMContentLoaded', function () {
284
526
  const i = (isWeighted ? bin.revenue : bin.count) / maxVal;
285
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}`);
286
528
  });
287
- gridLayer = L.layerGroup(rects);
529
+ const layer = L.layerGroup(rects);
530
+ if (isCompare) gridLayerComp = layer; else gridLayer = layer;
288
531
  }
289
532
 
290
533
  function showCurrentView() {
291
534
  if (!map) return;
292
- [pinLayer, heatmapLayer, gridLayer].forEach(l => { if (l && map.hasLayer(l)) map.removeLayer(l); });
293
535
  if (currentView === 'pins' && pinLayer) map.addLayer(pinLayer);
294
536
  else if (currentView === 'heatmap' && heatmapLayer) map.addLayer(heatmapLayer);
295
537
  else if (currentView === 'grid' && gridLayer) map.addLayer(gridLayer);
296
- sel.heatmapPanel.style.display = (currentView === 'heatmap' ? 'block' : 'none');
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
+
297
543
  if (sel.clusterToggle) sel.clusterToggle.style.display = (currentView === 'pins' ? 'inline-block' : 'none');
298
544
  if (eventMarkerLayer) map.addLayer(eventMarkerLayer);
299
- if (comparisonLayer) map.addLayer(comparisonLayer);
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
+ }
300
553
  }
301
554
 
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);
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
+ }
305
564
  }
306
565
 
307
566
  function loadComparison(slug) {
308
- if (!slug) { if (comparisonLayer) map.removeLayer(comparisonLayer); return; }
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;
309
575
  const newUrl = sel.map.dataset.dataUrl.replace(/\/event\/([^\/]+)\/([^\/]+)\//, `/event/$1/${slug}/`);
310
576
  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);
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();
313
580
  });
314
581
  }
315
582
 
@@ -325,7 +592,9 @@ document.addEventListener('DOMContentLoaded', function () {
325
592
  }
326
593
  function applyFilters() {
327
594
  showCanceled = sel.showCanceledCheck.checked;
328
- filteredData = allData.filter(loc => (loc.items.length === 0 || loc.items.some(i => selectedItems.has(i))) && (showCanceled || loc.status !== 'canceled'));
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);
329
598
  updateTimelineSlider();
330
599
  }
331
600
 
@@ -339,4 +608,4 @@ document.addEventListener('DOMContentLoaded', function () {
339
608
  }
340
609
 
341
610
  init();
342
- });
611
+ });
@@ -52,7 +52,9 @@
52
52
  <div class="form-group">
53
53
  <form action="{% url 'plugins:pretix_mapplugin:event.settings.salesmap.trigger' organizer=request.organizer.slug event=request.event.slug %}" method="post" style="display: inline-block;">
54
54
  {% csrf_token %}
55
- <button type="submit" class="btn btn-warning" title="{% trans "Trigger Geocoding" %}"><i class="fa fa-refresh"></i></button>
55
+ <button type="submit" class="btn btn-warning">
56
+ <i class="fa fa-refresh"></i> {% trans "Geocode all" %}
57
+ </button>
56
58
  </form>
57
59
  </div>
58
60
  {% endif %}
@@ -60,13 +62,22 @@
60
62
 
61
63
  <div id="heatmap-options-panel" class="panel panel-default" style="display: none; padding: 10px; min-width: 300px;">
62
64
  <div style="display: flex; justify-content: space-between;">
63
- <h5 style="margin: 0;">Heatmap</h5>
65
+ <h5 style="margin: 0;">{% trans "View Options" %}</h5>
64
66
  <button id="heatmap-reset-btn" class="btn btn-xs btn-link">Reset</button>
65
67
  </div>
66
68
  <div class="form-horizontal" style="margin-top: 5px;">
67
- <div class="form-group form-group-sm"><label class="col-sm-4">Radius</label><div class="col-sm-6"><input type="range" id="heatmap-radius" min="1" max="100" value="25"></div><div class="col-sm-2"><span id="radius-value">25</span></div></div>
68
- <div class="form-group form-group-sm"><label class="col-sm-4">Blur</label><div class="col-sm-6"><input type="range" id="heatmap-blur" min="1" max="50" value="15"></div><div class="col-sm-2"><span id="blur-value">15</span></div></div>
69
- <div class="form-group form-group-sm"><label class="col-sm-4">Max Zoom</label><div class="col-sm-6"><input type="range" id="heatmap-maxZoom" min="1" max="18" value="18"></div><div class="col-sm-2"><span id="maxzoom-value">18</span></div></div>
69
+ <div id="heatmap-only-controls">
70
+ <div class="form-group form-group-sm"><label class="col-sm-4">Radius</label><div class="col-sm-6"><input type="range" id="heatmap-radius" min="1" max="100" value="25"></div><div class="col-sm-2"><span id="radius-value">25</span></div></div>
71
+ <div class="form-group form-group-sm"><label class="col-sm-4">Blur</label><div class="col-sm-6"><input type="range" id="heatmap-blur" min="1" max="50" value="15"></div><div class="col-sm-2"><span id="blur-value">15</span></div></div>
72
+ <div class="form-group form-group-sm"><label class="col-sm-4">Max Zoom</label><div class="col-sm-6"><input type="range" id="heatmap-maxZoom" min="1" max="18" value="18"></div><div class="col-sm-2"><span id="maxzoom-value">18</span></div></div>
73
+ </div>
74
+ <div id="grid-only-controls" style="display: none;">
75
+ <div class="form-group form-group-sm">
76
+ <label class="col-sm-4">{% trans "Resolution" %}</label>
77
+ <div class="col-sm-6"><input type="range" id="grid-size-slider" min="1" max="100" value="70"></div>
78
+ <div class="col-sm-2"><span id="grid-size-value">0.12</span></div>
79
+ </div>
80
+ </div>
70
81
  </div>
71
82
  </div>
72
83
 
@@ -76,11 +87,28 @@
76
87
  </div>
77
88
  </div>
78
89
 
79
- <div class="map-wrapper" style="position: relative; border: 1px solid #ccc; flex-grow: 1; display: flex; flex-direction: column; min-height: 500px;">
80
- <div id="sales-map-container" style="flex-grow: 1; min-height: 500px;" data-data-url="{% url 'plugins:pretix_mapplugin:event.settings.salesmap.data' organizer=request.organizer.slug event=request.event.slug %}"></div>
90
+ <div class="map-wrapper">
91
+ <div id="map-split-root" class="map-split-container">
92
+ <div id="sales-map-container" style="min-height: 500px;" data-data-url="{% url 'plugins:pretix_mapplugin:event.settings.salesmap.data' organizer=request.organizer.slug event=request.event.slug %}">
93
+ <div class="map-label-overlay" id="main-map-label" style="display: none;">{{ request.event.name }}</div>
94
+ </div>
95
+ <div id="sales-map-compare-container" style="min-height: 500px;">
96
+ <div class="map-label-overlay" id="compare-map-label"></div>
97
+ </div>
98
+ </div>
81
99
 
82
100
  <div id="failed-orders-container" style="display: none; padding: 20px; background: white; flex-grow: 1; overflow-y: auto;">
83
- <h3>{% trans "Orders with missing geodata" %}</h3>
101
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
102
+ <h3 style="margin: 0;">{% trans "Orders with missing geodata" %}</h3>
103
+ {% if "can_change_event_settings" in request.eventperm %}
104
+ <form action="{% url 'plugins:pretix_mapplugin:event.settings.salesmap.trigger' organizer=request.organizer.slug event=request.event.slug %}" method="post">
105
+ {% csrf_token %}
106
+ <button type="submit" class="btn btn-warning">
107
+ <i class="fa fa-refresh"></i> {% trans "Retry all geocoding" %}
108
+ </button>
109
+ </form>
110
+ {% endif %}
111
+ </div>
84
112
  <table class="table table-hover">
85
113
  <thead><tr><th>Code</th><th>Address</th><th>Action</th></tr></thead>
86
114
  <tbody id="failed-orders-tbody"></tbody>
@@ -123,8 +151,8 @@
123
151
  </div>
124
152
  </div>
125
153
 
126
- <div id="timeline-controls" style="margin-top: 10px; padding: 10px; background: #f5f5f5; border-radius: 4px; border: 1px solid #ddd;">
127
- <div style="display: flex; justify-content: space-between; align-items: center;">
154
+ <div id="timeline-controls" style="margin-top: 10px; padding: 10px; background: #f5f5f5; border-radius: 4px; border: 1px solid #ddd; position: relative;">
155
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px;">
128
156
  <div>
129
157
  <label style="margin-bottom: 0;">{% trans "Timeline:" %} <span id="timeline-date-display" style="font-weight: normal;"></span></label>
130
158
  <span id="timeline-count-display" class="badge"></span>
@@ -138,9 +166,16 @@
138
166
  </select>
139
167
  </div>
140
168
  </div>
141
- <div style="display: flex; gap: 10px; align-items: center; margin-top: 5px;">
169
+ <div id="timeline-chart-container" style="position: relative; height: 40px; margin-bottom: 5px; background: white; border: 1px solid #eee; overflow: hidden;">
170
+ <canvas id="timeline-volume-chart" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></canvas>
171
+ <div id="timeline-milestone-markers" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"></div>
172
+ </div>
173
+ <div id="timeline-item-availability" style="margin-bottom: 10px; font-size: 10px; max-height: 100px; overflow-y: auto; background: white; border: 1px solid #eee; display: none;">
174
+ <div id="item-availability-list" style="padding: 5px; position: relative;"></div>
175
+ </div>
176
+ <div style="display: flex; gap: 10px; align-items: center;">
142
177
  <button id="timeline-play-btn" class="btn btn-sm btn-default"><i class="fa fa-play"></i> Play</button>
143
- <input type="range" id="timeline-slider" min="0" max="100" value="100" style="flex-grow: 1; cursor: pointer;">
178
+ <input type="range" id="timeline-slider" min="0" max="100" value="100" style="flex-grow: 1; cursor: pointer; position: relative; z-index: 5;">
144
179
  </div>
145
180
  </div>
146
181
  </div>
pretix_mapplugin/views.py CHANGED
@@ -32,11 +32,17 @@ except ImportError:
32
32
  logger = logging.getLogger(__name__)
33
33
 
34
34
 
35
+ from django import forms
36
+ from pretix.control.forms.widgets import DatePickerWidget
37
+
35
38
  # --- Milestone Management ---
36
39
  MilestoneFormSet = inlineformset_factory(
37
40
  Event,
38
41
  MapMilestone,
39
42
  fields=('date', 'label'),
43
+ widgets={
44
+ 'date': DatePickerWidget(),
45
+ },
40
46
  extra=1,
41
47
  can_delete=True
42
48
  )
@@ -143,6 +149,15 @@ class SalesMapDataView(EventSettingsViewMixin, View):
143
149
  for m in MapMilestone.objects.filter(event=event)
144
150
  ]
145
151
 
152
+ item_availability = {}
153
+ for item in event.items.all():
154
+ item_availability[str(item.name)] = {
155
+ 'available_from': item.available_from.isoformat() if item.available_from else None,
156
+ 'available_until': item.available_until.isoformat() if item.available_until else None,
157
+ 'first_sale': None,
158
+ 'last_sale': None,
159
+ }
160
+
146
161
  locations_data = []
147
162
  failed_orders_data = []
148
163
  city_counter = Counter()
@@ -182,7 +197,14 @@ class SalesMapDataView(EventSettingsViewMixin, View):
182
197
 
183
198
  item_names = [str(p.item.name) for p in order.positions.all() if p.item]
184
199
  if status != 'canceled':
185
- for name in item_names: item_counter[name] += 1
200
+ for name in item_names:
201
+ item_counter[name] += 1
202
+ # Track sales period
203
+ if name in item_availability:
204
+ if not item_availability[name]['first_sale'] or iso_date < item_availability[name]['first_sale']:
205
+ item_availability[name]['first_sale'] = iso_date
206
+ if not item_availability[name]['last_sale'] or iso_date > item_availability[name]['last_sale']:
207
+ item_availability[name]['last_sale'] = iso_date
186
208
 
187
209
  tooltip_parts = [f"<strong>Order:</strong> {order.code} ({status.upper()})"]
188
210
  if order.datetime:
@@ -211,6 +233,7 @@ class SalesMapDataView(EventSettingsViewMixin, View):
211
233
  return JsonResponse({
212
234
  'locations': locations_data, 'failed_orders': failed_orders_data,
213
235
  'event_marker': event_marker, 'milestones': milestones,
236
+ 'item_availability': item_availability,
214
237
  'stats': {
215
238
  'top_cities': city_counter.most_common(10), 'top_items': item_counter.most_common(10),
216
239
  'total_orders': len(locations_data) + len(failed_orders_data),