Skip to content

Commit e0172b0

Browse files
authored
Merge pull request #39 from code4policy/D3_summonses_query
Add searchable data table with violation filtering and Boston design style
2 parents 9fd93d5 + e2ab502 commit e0172b0

File tree

4 files changed

+748
-7
lines changed

4 files changed

+748
-7
lines changed

2024parkingall/2024parking.html

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,51 @@ <h1 class="header-text">2024 All Parking Violations</h1>
2222
</header>
2323
<div class="separator-bar"></div>
2424

25-
<html>
25+
<main class="container">
26+
<section class="table-section">
27+
<h2 class="section-title">2024 Parking Summonses</h2>
28+
<p class="section-description">Explore 2024 parking violations by street name or violation type.</p>
29+
30+
<div class="table-controls">
31+
<div class="search-box">
32+
<input type="text" id="searchInput" placeholder="Search Street Name.." class="search-input">
33+
</div>
34+
35+
<div class="dropdown-container">
36+
<button id="violationDropdownBtn" class="dropdown-btn">Filter by Violation ▼</button>
37+
<div id="violationDropdown" class="dropdown-menu" style="display: none;">
38+
<div class="dropdown-header">
39+
<button id="selectAllBtn" class="select-btn">Select All</button>
40+
<button id="deselectAllBtn" class="select-btn">Deselect All</button>
41+
</div>
42+
<div id="violationCheckboxes" class="violation-checkboxes">
43+
<!-- Checkboxes will be generated here -->
44+
</div>
45+
</div>
46+
</div>
47+
48+
<div class="filter-info">
49+
<span>Showing <span id="rowCount">0</span> of <span id="totalCount">0</span> violations</span>
50+
</div>
51+
</div>
52+
53+
<div class="table-wrapper">
54+
<table id="dataTable" class="data-table">
55+
<thead id="tableHead">
56+
<!-- Headers will be generated from CSV -->
57+
</thead>
58+
<tbody id="tableBody">
59+
<!-- Data will be populated here -->
60+
</tbody>
61+
</table>
62+
</div>
63+
64+
<div class="table-footer">
65+
<p class="no-results" id="noResults" style="display: none;">No violations found matching your search.</p>
66+
</div>
67+
</section>
68+
</main>
69+
70+
<script src="searchable-table.js"></script>
71+
</body>
72+
</html>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:8470d14d5eeaebaa857bec193bd7dae96c221b0c4fddb73191b647f67a3a5351
3+
size 1312326

2024parkingall/searchable-table.js

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
let parkingData = [];
2+
let csvHeaders = [];
3+
let sortState = { column: null, ascending: true };
4+
let searchTerm = '';
5+
let filteredData = [];
6+
let searchTimeout;
7+
let selectedViolations = {};
8+
9+
document.addEventListener('DOMContentLoaded', function() {
10+
loadCSV();
11+
});
12+
13+
function loadCSV() {
14+
fetch('data/violations_by_street.csv')
15+
.then(response => response.text())
16+
.then(data => {
17+
const parsed = parseCSV(data);
18+
csvHeaders = parsed.headers;
19+
parkingData = parsed.data;
20+
filteredData = [...parkingData];
21+
generateTableHeaders();
22+
renderTable();
23+
setupEventListeners();
24+
})
25+
.catch(error => console.error('Error loading CSV:', error));
26+
}
27+
28+
function parseCSV(csvText) {
29+
const lines = csvText.trim().split('\n');
30+
const allHeaders = lines[0].split(',').map(h => h.trim());
31+
32+
// Remove street_name from headers (it's displayed as the sticky first column)
33+
const headers = allHeaders.slice(1);
34+
35+
const data = [];
36+
37+
// Process each row starting from line 1 (skip header)
38+
for (let i = 1; i < lines.length; i++) {
39+
const line = lines[i].trim();
40+
if (!line) continue;
41+
42+
// Simple CSV parsing - handles basic cases
43+
const values = parseCSVLine(line);
44+
if (values.length > 0) {
45+
const streetName = values[0];
46+
47+
// Skip rows with invalid street names
48+
if (!streetName || streetName === '' || streetName.length < 1) continue;
49+
50+
const row = {
51+
street: streetName,
52+
violations: {}
53+
};
54+
55+
// Map each violation type to its count (skip first value which is street name)
56+
for (let j = 1; j < values.length && j < allHeaders.length; j++) {
57+
const count = parseInt(values[j]) || 0;
58+
row.violations[allHeaders[j]] = count;
59+
}
60+
61+
data.push(row);
62+
}
63+
}
64+
65+
return { headers, data };
66+
}
67+
68+
function parseCSVLine(line) {
69+
const values = [];
70+
let current = '';
71+
let insideQuotes = false;
72+
73+
for (let i = 0; i < line.length; i++) {
74+
const char = line[i];
75+
76+
if (char === '"') {
77+
insideQuotes = !insideQuotes;
78+
} else if (char === ',' && !insideQuotes) {
79+
values.push(current.trim().replace(/^"|"$/g, ''));
80+
current = '';
81+
} else {
82+
current += char;
83+
}
84+
}
85+
86+
values.push(current.trim().replace(/^"|"$/g, ''));
87+
return values;
88+
}
89+
90+
function generateTableHeaders() {
91+
const thead = document.getElementById('tableHead');
92+
const headerRow = document.createElement('tr');
93+
94+
// Add street name header (always first, not sortable in same way)
95+
const streetHeader = document.createElement('th');
96+
streetHeader.className = 'sortable street-name-header';
97+
streetHeader.setAttribute('data-column', '0');
98+
streetHeader.innerHTML = 'Street Name <span class="sort-icon">▼</span>';
99+
headerRow.appendChild(streetHeader);
100+
101+
// Add violation type headers
102+
csvHeaders.forEach((header, index) => {
103+
const th = document.createElement('th');
104+
th.className = 'sortable';
105+
th.setAttribute('data-column', index + 1);
106+
th.setAttribute('data-violation', header);
107+
th.innerHTML = `${header} <span class="sort-icon">▼</span>`;
108+
headerRow.appendChild(th);
109+
});
110+
111+
thead.appendChild(headerRow);
112+
document.getElementById('totalCount').textContent = parkingData.length;
113+
114+
// Initialize violation filter
115+
initializeViolationFilter();
116+
}
117+
118+
function renderTable() {
119+
const tableBody = document.getElementById('tableBody');
120+
121+
// Build HTML string instead of creating elements one by one
122+
let html = '';
123+
filteredData.forEach(row => {
124+
html += '<tr class="data-row"><td class="street-cell">' + row.street + '</td>';
125+
126+
csvHeaders.forEach(header => {
127+
const count = row.violations[header] || 0;
128+
html += '<td class="count-cell" data-violation="' + header + '">' + count + '</td>';
129+
});
130+
131+
html += '</tr>';
132+
});
133+
134+
tableBody.innerHTML = html;
135+
updateRowCount();
136+
updateColumnVisibility();
137+
}
138+
139+
function setupEventListeners() {
140+
const searchInput = document.getElementById('searchInput');
141+
// Debounce search to prevent excessive filtering
142+
searchInput.addEventListener('input', (e) => {
143+
clearTimeout(searchTimeout);
144+
searchTimeout = setTimeout(() => handleSearch(e), 150);
145+
}, { passive: true });
146+
147+
const sortHeaders = document.querySelectorAll('.sortable');
148+
sortHeaders.forEach(header => {
149+
header.addEventListener('click', handleSort);
150+
});
151+
152+
// Dropdown functionality
153+
const dropdownBtn = document.getElementById('violationDropdownBtn');
154+
const dropdownMenu = document.getElementById('violationDropdown');
155+
156+
dropdownBtn.addEventListener('click', (e) => {
157+
e.stopPropagation();
158+
dropdownMenu.style.display = dropdownMenu.style.display === 'none' ? 'block' : 'none';
159+
});
160+
161+
document.addEventListener('click', (e) => {
162+
if (!e.target.closest('.dropdown-container')) {
163+
dropdownMenu.style.display = 'none';
164+
}
165+
});
166+
167+
document.getElementById('selectAllBtn').addEventListener('click', selectAllViolations);
168+
document.getElementById('deselectAllBtn').addEventListener('click', deselectAllViolations);
169+
}
170+
171+
function handleSearch(e) {
172+
searchTerm = e.target.value.toLowerCase();
173+
174+
filterAndRenderTable();
175+
}
176+
177+
function handleSort(e) {
178+
const columnIndex = parseInt(e.currentTarget.getAttribute('data-column'));
179+
180+
// Toggle sort direction if same column clicked
181+
if (sortState.column === columnIndex) {
182+
sortState.ascending = !sortState.ascending;
183+
} else {
184+
sortState.column = columnIndex;
185+
sortState.ascending = false; // Default to descending (largest first)
186+
}
187+
188+
// Sort filteredData array in-memory (not DOM)
189+
filteredData.sort((a, b) => {
190+
let aVal, bVal;
191+
192+
if (columnIndex === 0) {
193+
// Street name column
194+
aVal = a.street;
195+
bVal = b.street;
196+
} else {
197+
// Violation columns
198+
const header = csvHeaders[columnIndex - 1];
199+
aVal = a.violations[header] || 0;
200+
bVal = b.violations[header] || 0;
201+
}
202+
203+
// Numeric comparison
204+
if (typeof aVal === 'number' && typeof bVal === 'number') {
205+
return sortState.ascending ? aVal - bVal : bVal - aVal;
206+
}
207+
208+
// String comparison
209+
aVal = String(aVal).toLowerCase();
210+
bVal = String(bVal).toLowerCase();
211+
return sortState.ascending
212+
? aVal.localeCompare(bVal)
213+
: bVal.localeCompare(aVal);
214+
});
215+
216+
// Render sorted table
217+
renderTable();
218+
219+
// Update sort indicators
220+
document.querySelectorAll('.sortable .sort-icon').forEach(icon => {
221+
icon.textContent = '▼';
222+
icon.style.opacity = '0.3';
223+
});
224+
225+
const activeIcon = e.currentTarget.querySelector('.sort-icon');
226+
activeIcon.style.opacity = '1';
227+
activeIcon.textContent = sortState.ascending ? '▲' : '▼';
228+
}
229+
230+
function updateRowCount() {
231+
document.getElementById('rowCount').textContent = filteredData.length;
232+
}
233+
234+
function initializeViolationFilter() {
235+
const checkboxContainer = document.getElementById('violationCheckboxes');
236+
checkboxContainer.innerHTML = '';
237+
238+
// Initialize all violations as selected by default
239+
csvHeaders.forEach(violation => {
240+
selectedViolations[violation] = true;
241+
242+
const label = document.createElement('label');
243+
label.className = 'violation-checkbox-item';
244+
245+
const checkbox = document.createElement('input');
246+
checkbox.type = 'checkbox';
247+
checkbox.checked = true;
248+
checkbox.value = violation;
249+
checkbox.addEventListener('change', applyViolationFilter);
250+
251+
const labelText = document.createElement('label');
252+
labelText.textContent = violation;
253+
254+
label.appendChild(checkbox);
255+
label.appendChild(labelText);
256+
checkboxContainer.appendChild(label);
257+
});
258+
}
259+
260+
function selectAllViolations() {
261+
csvHeaders.forEach(violation => {
262+
selectedViolations[violation] = true;
263+
});
264+
265+
document.querySelectorAll('#violationCheckboxes input[type="checkbox"]').forEach(cb => {
266+
cb.checked = true;
267+
});
268+
269+
applyViolationFilter();
270+
}
271+
272+
function deselectAllViolations() {
273+
csvHeaders.forEach(violation => {
274+
selectedViolations[violation] = false;
275+
});
276+
277+
document.querySelectorAll('#violationCheckboxes input[type="checkbox"]').forEach(cb => {
278+
cb.checked = false;
279+
});
280+
281+
applyViolationFilter();
282+
}
283+
284+
function applyViolationFilter() {
285+
// Update selectedViolations from checkboxes
286+
document.querySelectorAll('#violationCheckboxes input[type="checkbox"]').forEach(cb => {
287+
selectedViolations[cb.value] = cb.checked;
288+
});
289+
290+
// Filter data
291+
filterAndRenderTable();
292+
}
293+
294+
function filterAndRenderTable() {
295+
filteredData = parkingData.filter(row => {
296+
// Check search term
297+
const matchesSearch = row.street.toLowerCase().includes(searchTerm);
298+
299+
// Check if row has any selected violations with count > 0
300+
const hasSelectedViolation = csvHeaders.some(violation => {
301+
return selectedViolations[violation] && (row.violations[violation] || 0) > 0;
302+
});
303+
304+
return matchesSearch && hasSelectedViolation;
305+
});
306+
307+
renderTable();
308+
document.getElementById('noResults').style.display = filteredData.length === 0 ? 'block' : 'none';
309+
}
310+
311+
function updateColumnVisibility() {
312+
// Hide/show headers based on selected violations
313+
csvHeaders.forEach(violation => {
314+
const headers = document.querySelectorAll(`th[data-violation="${violation}"]`);
315+
const cells = document.querySelectorAll(`td[data-violation="${violation}"]`);
316+
317+
const isHidden = !selectedViolations[violation];
318+
319+
headers.forEach(header => {
320+
header.style.display = isHidden ? 'none' : '';
321+
});
322+
323+
cells.forEach(cell => {
324+
cell.style.display = isHidden ? 'none' : '';
325+
});
326+
});
327+
}

0 commit comments

Comments
 (0)