Skip to content

Commit 964328e

Browse files
committed
feat: Add new map application test, for cmp
1 parent fb48756 commit 964328e

9 files changed

Lines changed: 6827 additions & 0 deletions

File tree

static/mapa/app.js

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
// ========================================
2+
// CMP Bandas Filarmónicas — Map Application
3+
// ========================================
4+
5+
(function () {
6+
'use strict';
7+
8+
// Initialize Lucide icons
9+
lucide.createIcons();
10+
11+
// State
12+
let bands = [];
13+
let map = null;
14+
let markers = null;
15+
let markerRefs = {};
16+
17+
// DOM refs
18+
const mapEl = document.getElementById('map');
19+
const mapLoading = document.getElementById('mapLoading');
20+
const bandPanel = document.getElementById('bandPanel');
21+
const panelTitle = document.getElementById('panelTitle');
22+
const panelBody = document.getElementById('panelBody');
23+
const panelClose = document.getElementById('panelClose');
24+
const searchToggle = document.getElementById('searchToggle');
25+
const searchBar = document.getElementById('searchBar');
26+
const searchInput = document.getElementById('searchInput');
27+
const searchClear = document.getElementById('searchClear');
28+
const searchResults = document.getElementById('searchResults');
29+
const bandCountEl = document.getElementById('bandCount');
30+
const cityCountEl = document.getElementById('cityCount');
31+
const oldestBandEl = document.getElementById('oldestBand');
32+
33+
// ========================================
34+
// Initialize Map
35+
// ========================================
36+
37+
function initMap() {
38+
map = L.map('map', {
39+
center: [39.5, -8.0],
40+
zoom: 7,
41+
minZoom: 4,
42+
maxZoom: 18,
43+
zoomControl: true,
44+
attributionControl: true
45+
});
46+
47+
// OpenStreetMap tiles
48+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
49+
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>',
50+
maxZoom: 19
51+
}).addTo(map);
52+
53+
// Marker cluster group
54+
markers = L.markerClusterGroup({
55+
maxClusterRadius: 50,
56+
spiderfyOnMaxZoom: true,
57+
showCoverageOnHover: false,
58+
zoomToBoundsOnClick: true,
59+
disableClusteringAtZoom: 14,
60+
iconCreateFunction: function (cluster) {
61+
const count = cluster.getChildCount();
62+
let size = 'small';
63+
if (count >= 50) size = 'large';
64+
else if (count >= 20) size = 'medium';
65+
66+
return L.divIcon({
67+
html: '<div>' + count + '</div>',
68+
className: 'marker-cluster marker-cluster-' + size,
69+
iconSize: L.point(40, 40)
70+
});
71+
}
72+
});
73+
74+
map.addLayer(markers);
75+
}
76+
77+
// ========================================
78+
// Load Band Data
79+
// ========================================
80+
81+
async function loadBands() {
82+
try {
83+
const res = await fetch('./bands.json');
84+
bands = await res.json();
85+
populateMap();
86+
updateStats();
87+
mapLoading.classList.add('hidden');
88+
} catch (err) {
89+
console.error('Error loading bands:', err);
90+
mapLoading.innerHTML = '<p style="color:#a13544;">Erro ao carregar dados das bandas.</p>';
91+
}
92+
}
93+
94+
// ========================================
95+
// Populate Map with Markers
96+
// ========================================
97+
98+
function populateMap() {
99+
bands.forEach(function (band) {
100+
if (!band.lat || !band.lng) return;
101+
102+
const marker = L.marker([band.lat, band.lng], {
103+
icon: L.divIcon({
104+
className: 'band-marker',
105+
iconSize: [12, 12],
106+
iconAnchor: [6, 6]
107+
})
108+
});
109+
110+
marker.bindPopup(function () {
111+
return createPopupContent(band);
112+
}, {
113+
maxWidth: 320,
114+
minWidth: 260,
115+
closeButton: true
116+
});
117+
118+
markerRefs[band.nr] = marker;
119+
markers.addLayer(marker);
120+
});
121+
}
122+
123+
// ========================================
124+
// Create Popup Content
125+
// ========================================
126+
127+
function createPopupContent(band) {
128+
const fields = [];
129+
130+
if (band.founding_date) {
131+
fields.push(fieldHTML('calendar', 'Data de Fundação', band.founding_date));
132+
}
133+
134+
if (band.nr) {
135+
fields.push(fieldHTML('hash', 'N.º Filiação CMP', band.nr));
136+
}
137+
138+
if (band.address || band.postal_code || band.city) {
139+
const addr = [band.address, band.postal_code, band.city].filter(Boolean).join(', ');
140+
fields.push(fieldHTML('map-pin', 'Morada', addr));
141+
}
142+
143+
if (band.telefone) {
144+
const phones = band.telefone.split(';').map(function (p) {
145+
p = p.trim();
146+
return '<a href="tel:' + p + '">' + p + '</a>';
147+
}).join(' / ');
148+
fields.push(fieldHTML('phone', 'Telefone', phones));
149+
}
150+
151+
if (band.telemovel) {
152+
const mobiles = band.telemovel.split(';').map(function (p) {
153+
p = p.trim();
154+
return '<a href="tel:' + p + '">' + p + '</a>';
155+
}).join(' / ');
156+
fields.push(fieldHTML('smartphone', 'Telemóvel', mobiles));
157+
}
158+
159+
if (band.email) {
160+
const emails = band.email.split(';').map(function (e) {
161+
e = e.trim();
162+
return '<a href="mailto:' + e + '">' + e + '</a>';
163+
}).join('<br>');
164+
fields.push(fieldHTML('mail', 'Email', emails));
165+
}
166+
167+
const hasFields = fields.length > 0;
168+
169+
return '<div class="popup-card">' +
170+
'<div class="popup-header">' +
171+
'<div class="popup-nr">Filiação CMP N.º ' + band.nr + '</div>' +
172+
'<div class="popup-name">' + escapeHtml(band.name) + '</div>' +
173+
'</div>' +
174+
(hasFields
175+
? '<div class="popup-body">' + fields.join('') + '</div>'
176+
: '<div class="popup-empty">Sem informações de contacto disponíveis.</div>') +
177+
'</div>';
178+
}
179+
180+
function fieldHTML(icon, label, value) {
181+
return '<div class="popup-field">' +
182+
'<svg class="popup-field-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
183+
getIconPath(icon) +
184+
'</svg>' +
185+
'<div class="popup-field-content">' +
186+
'<span class="popup-field-label">' + label + '</span>' +
187+
'<span class="popup-field-value">' + value + '</span>' +
188+
'</div></div>';
189+
}
190+
191+
function getIconPath(name) {
192+
const icons = {
193+
'calendar': '<rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
194+
'hash': '<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>',
195+
'map-pin': '<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>',
196+
'phone': '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>',
197+
'smartphone': '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>',
198+
'mail': '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>'
199+
};
200+
return icons[name] || '';
201+
}
202+
203+
function escapeHtml(str) {
204+
const div = document.createElement('div');
205+
div.textContent = str;
206+
return div.innerHTML;
207+
}
208+
209+
// ========================================
210+
// Statistics
211+
// ========================================
212+
213+
function updateStats() {
214+
// Total bands
215+
bandCountEl.textContent = bands.length;
216+
217+
// Unique cities
218+
const cities = new Set();
219+
bands.forEach(function (b) {
220+
if (b.city) cities.add(b.city.toUpperCase());
221+
});
222+
cityCountEl.textContent = cities.size;
223+
224+
// Oldest band
225+
let oldest = null;
226+
let oldestDate = new Date();
227+
bands.forEach(function (b) {
228+
if (b.founding_date) {
229+
const parts = b.founding_date.split('/');
230+
if (parts.length === 3) {
231+
const d = new Date(parts[2], parts[1] - 1, parts[0]);
232+
if (d < oldestDate) {
233+
oldestDate = d;
234+
oldest = b;
235+
}
236+
}
237+
}
238+
});
239+
if (oldest) {
240+
oldestBandEl.textContent = oldestDate.getFullYear();
241+
oldestBandEl.title = oldest.name + ' (' + oldest.founding_date + ')';
242+
}
243+
}
244+
245+
// ========================================
246+
// Search
247+
// ========================================
248+
249+
searchToggle.addEventListener('click', function () {
250+
searchBar.classList.toggle('active');
251+
if (searchBar.classList.contains('active')) {
252+
searchInput.focus();
253+
}
254+
});
255+
256+
searchClear.addEventListener('click', function () {
257+
searchInput.value = '';
258+
searchResults.innerHTML = '';
259+
});
260+
261+
searchInput.addEventListener('input', function () {
262+
const query = this.value.trim().toLowerCase();
263+
if (query.length < 2) {
264+
searchResults.innerHTML = '';
265+
return;
266+
}
267+
268+
const results = bands.filter(function (b) {
269+
return b.name.toLowerCase().includes(query) ||
270+
(b.city && b.city.toLowerCase().includes(query)) ||
271+
(b.postal_code && b.postal_code.includes(query)) ||
272+
(b.nr && b.nr.toString().includes(query));
273+
}).slice(0, 15);
274+
275+
searchResults.innerHTML = results.map(function (b) {
276+
return '<div class="search-result-item" data-nr="' + b.nr + '">' +
277+
'<span class="result-nr">#' + b.nr + '</span>' +
278+
'<span class="result-name">' + escapeHtml(b.name) + '</span>' +
279+
'<span class="result-city">' + escapeHtml(b.city || '') + '</span>' +
280+
'</div>';
281+
}).join('');
282+
});
283+
284+
searchResults.addEventListener('click', function (e) {
285+
const item = e.target.closest('.search-result-item');
286+
if (!item) return;
287+
288+
const nr = item.dataset.nr;
289+
const band = bands.find(function (b) { return b.nr === nr; });
290+
if (!band || !band.lat || !band.lng) return;
291+
292+
// Zoom to band
293+
map.setView([band.lat, band.lng], 15, { animate: true });
294+
295+
// Open popup
296+
const marker = markerRefs[nr];
297+
if (marker) {
298+
markers.zoomToShowLayer(marker, function () {
299+
marker.openPopup();
300+
});
301+
}
302+
303+
// Close search
304+
searchBar.classList.remove('active');
305+
searchInput.value = '';
306+
searchResults.innerHTML = '';
307+
});
308+
309+
// Close search on escape
310+
document.addEventListener('keydown', function (e) {
311+
if (e.key === 'Escape') {
312+
searchBar.classList.remove('active');
313+
bandPanel.classList.remove('active');
314+
}
315+
});
316+
317+
// ========================================
318+
// Panel
319+
// ========================================
320+
321+
panelClose.addEventListener('click', function () {
322+
bandPanel.classList.remove('active');
323+
});
324+
325+
// ========================================
326+
// Init
327+
// ========================================
328+
329+
initMap();
330+
loadBands();
331+
332+
})();
19.5 KB
Loading

static/mapa/assets/cmp-logo-sm.png

22.5 KB
Loading

static/mapa/assets/cmp-logo.png

83.8 KB
Loading

static/mapa/assets/cmp-logo.svg

Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)