diff --git a/README.md b/README.md index a6eae44..499dbf4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # Voter Canvassing App Team members: -* ... -* ... \ No newline at end of file +* Henry Feinstein +* Morgan Griffiths +* Promit Chatterjee + +Our Approach to Collaboration: +* A week after the project was assigned, we met up in person to decide how we would start to break up the project. At that meeting, we each decided to take an initial chunk of the project to work on for the next week. We followed the same approach for the remainder of the project, meeting up in person each week, and a few time virtually, to discuss our progress, help each other understand the material, and decide on what next steps needed to be taken and who would tackle each task. We tried to make sure that each of us was working on at least one part of the project each week. We also kept in touch via Slack to update each other on our progress between meetings and to ask each other questions about the project as we came across them. We split up the tasks into pretty small chunks, so it is hard to parse apart exactly what we each did. Morgan led the map initialization and creation of input objects, Henry focused primarily on the data parsing and organization, and Promit led the CSS work and local storage behavior. We all contributed to all of those different aspects, however. + +Stretch goals attempted: +* None \ No newline at end of file diff --git a/site/css/styles.css b/site/css/styles.css new file mode 100644 index 0000000..b95cf7c --- /dev/null +++ b/site/css/styles.css @@ -0,0 +1,119 @@ +@import "https://fonts.googleapis.com/css2?family=Open+Sans"; + +html { + font-family: "Open Sans", sans-serif; +} + +h1 { + background-color: #217e79; + color: whitesmoke; + margin: 0; + padding: 25px; +} + +body { + display: flex; + flex-direction: column; + height: 100vh; + margin: 0; + padding: 0; + z-index: 1; +} + +.map { + height: 100%; + z-index: 1; +} + +textarea { + width: 100%; + display: block; + margin-bottom: 1rem; + height: 1rem; + z-index: -1; +} + +button { + margin-right: 5px; + z-index: -1; + display: flex; + flex-flow: row wrap; + font-size: 10px; + background-color: #217e79; + color: aliceblue; + bottom: 30%; +} + +.voter-list-container { + width: 100%; + display: flex; + flex-flow: row wrap; + height: 10rem; + overflow-y: scroll; + overflow-x: auto; + z-index: 1; +} + +#response-container { + background-color: #1a5e5e; + position: absolute; + top: 0%; + left: 65%; + right: 20%; + margin: 5px; + padding: 5px; + border-radius: 15px; + border: 40px black; + height: auto; + width: 300px; + flex-flow: row-reverse wrap; + justify-content: center; + align-items: center; + z-index: 1; + font-size: 10px; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + color: azure; + transform: inherit; +} + +#voter-notes { + top: 25%; + display: flex; + height: 300px; + width: 300px; + flex-direction: column; + align-items: center; +} + +h3 { + top: 10%; +} + +.voter-address { + font-style: italic; + font-weight: bold; +} + +#voter-list { + width: 100%; + display: flex; + flex-flow: row wrap; +} + +.voter-list-item { + margin: 10px; + display: flex; + flex-wrap: wrap; + padding: 5px; + height: 50px; + width: 200px; + background-color: #1a5e5e; + color: #f0ffff; + font-size: 10px; + border-radius: 5px; +} + +#blank { + height: auto; + width: auto; +} diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..4aa7c85 --- /dev/null +++ b/site/index.html @@ -0,0 +1,44 @@ + + + + + + + Voter Canvassing App + + + + + + + +

Voter Canvassing App

+ +
+ +
+ +
+ +
+ + + + +
+ +
+ +
+

+

+

+
+ + + + + + + diff --git a/site/js/dataPull.js b/site/js/dataPull.js new file mode 100644 index 0000000..b43851f --- /dev/null +++ b/site/js/dataPull.js @@ -0,0 +1,132 @@ +import { populateVoterMap } from './map.js'; +import { populateVoterMenu } from './list.js'; + +let voterList = []; + +function makeCoordinates(coords){ + + //console.log(id); + //console.log(parseFloat(coords.substring(19,36))); + //console.log(parseFloat(coords.substring(0,18))); + + let x = 0, y = 0; + + try { + + if (isNaN(parseFloat(coords.substring(19, 36))) || parseFloat(coords.substring(19, 36)) === undefined ){ + x = 0; + y = 0; + } else { + x = parseFloat(coords.substring(0, 18)); + y = parseFloat(coords.substring(19, 36)); + } + + } + + catch(e){ + console.log(e); + //pass + } + + return { + latitude : x, + longitude : y, + }; +} + +function constructVoter(v) { + let voterElement; + try { + voterElement = { + name : v['First Name'].concat(" ", v['Middle Name'], " ", v['Last Name']), + firstName : v['First Name'], + middleName : v['Middle Name'], + lastName : v['Last Name'], + gender : v['Gender'], + address : v['TIGER/Line Matched Address'], + city : v['City'], + county : v['County'], + state : v['State'], + zipCode : v['Zip'], + id: v['ID Number'], + stillLivesThere: "", + votingPlan: "", + languageAssistance: "", + }; + return voterElement; + } catch(e) { + voterElement = null; + return voterElement; + } +} + +function makeVoterFeature(data){ + voterList = []; + const voter = data; + for (let v of voter){ + //console.log(v); + let addressIndex = voterList.findIndex(element => element.properties.address === v['TIGER/Line Matched Address']); + if (addressIndex !== -1) { + let voterObj = constructVoter(v); + if (voterObj) { + voterList[addressIndex].voters.push(voterObj); + } + } else { + let geom = makeCoordinates(v['TIGER/Line Lng/Lat']); + let addressElement = { + type : "Feature", + geometry : { + type : "Point", + coordinates : [geom.latitude, geom.longitude], + }, + properties : { + address : v['TIGER/Line Matched Address'], + }, + }; + let voterObj = constructVoter(v); + if (voterObj) { + addressElement['voters'] = [voterObj]; + } + voterList.push(addressElement); + } + } + return voterList; +} + +function populateVoterList(listNum, map, voterListObj) { + fetch('data/voters_lists/' + listNum + '.csv') + .then(function (resp) { + + if(!resp.ok){ + alert("Invalid List Number."); + } + + else{ + console.log("Working"); + return resp.text(); + } + + } ) // can we handle the error right here? // now handled invalid list number issue in this promise + .then(text => { + // TODO: try/catch HTTP error for nonexistent list number // done. + const data = Papa.parse(text, { header: true, skipEmptyLines: true }); + let voterList = makeVoterFeature(data['data']); + populateVoterMap(voterList, map); + populateVoterMenu(voterList, voterListObj); + }); +} + +export { + populateVoterList, + makeVoterFeature, +}; + +window.voterList = voterList; + +// "-75.16145216099994,39.92993551500007" +// "-75.15802336899998,39.93087322800005" +// "-75.15842544799995,39.93086332400003" +// "-75.15947777399998,39.93155817300004" + +// "112545002-51" // problem element in list '0101' // eliminated in the try-catch block in makeCoordinates +// "015653961-51" \ No newline at end of file diff --git a/site/js/list.js b/site/js/list.js new file mode 100644 index 0000000..6aa35f2 --- /dev/null +++ b/site/js/list.js @@ -0,0 +1,36 @@ +import { htmlToElement } from "./template-tools.js"; + +function populateVoterMenu(voterList, voterListObj) { + voterListObj.innerHTML = ``; + + // if (myLocation) { // if we have user's location, sort the list by how close the houses are to the user at that moment before populating list + // for (let voter of voterList) { + // voter.properties.distToMe = (Math.abs(voter.geometry.coordinates[0] - myLocation.coordinates[0]) + + // Math.abs(voter.geometry.coordinates[1] - myLocation.coordinates[1])); + // } + // voterList.sort(function (a, b) {b.properties.distToMe - a.properties.distToMe}); + // } + + + for (let address of voterList) { + + //TODO: figure out how to format span objects for voters in CSS + let voterHTML = ``; + for (let voter of address['voters']) { + voterHTML = voterHTML + `${voter['firstName']} ${voter['lastName']}`; + } + + const html = ` + + ${address.properties['address']} + ${voterHTML} + + `; + const li = htmlToElement(html); + voterListObj.append(li); + } +} + +export { + populateVoterMenu, +}; \ No newline at end of file diff --git a/site/js/main.js b/site/js/main.js new file mode 100644 index 0000000..a1e02fb --- /dev/null +++ b/site/js/main.js @@ -0,0 +1,74 @@ +import { initializeMap } from './map.js'; +import { populateVoterList } from './dataPull.js'; + +let map = initializeMap(); +let listNum = "0101"; +let myLocation; // made a global myLocation variable that can be accessed when looking for other point features in the vicinity + +let voterFileInput = document.querySelector('.input'); // saves input DOM element as variable +//need to restrict line of input to just 1. currently accepts enter and can fit a paragraph. + +let voterFileLoadButton = document.querySelector('#load-voter-list-button'); // saves load voter list button DOM element as variable + +let voterListObj = document.querySelector("#voter-list"); // get list we'll put voters in from DOM + +let clearInputTextButton = document.querySelector('#clear-text-button'); //saves clear input text button as a DOM + +let clearMapButton = document.querySelector('#clear-map-button'); + +function onClearMapButtonClicked() { + map.removeLayer(map.voterLayer); + voterListObj.innerHTML = ``; + console.log("Map cleared"); +} + +function clearMap() { + clearMapButton.addEventListener('click', onClearMapButtonClicked); +} +clearMap(); + +function onButtonClicked() { // maybe add functionality that listens for enter button pressed + listNum = voterFileInput.value; + console.log(listNum); + onClearMapButtonClicked(); + populateVoterList(listNum, map, voterListObj, myLocation); +} // creates function that passes the value entered in input text box to the variable listNum. + +function loadVoterListClick() { + voterFileLoadButton.addEventListener('click', onButtonClicked); +} //adds an event listener that executes the onButtonClicked function when the voter load button is clicked +loadVoterListClick(); + +function loadVoterListEnter() { + voterFileInput.addEventListener('keypress', function(event) { + if (event.keyCode == 13) + voterFileLoadButton.click(); +}); +} + +loadVoterListEnter(); + //adds an event listener that executes the onButtonClicked function when a user hits enter while typing in the Voter File Input text box + +function onClearInputButtonClicked () { + voterFileInput.value = ''; +} + +function clearInputTextBox () { + clearInputTextButton.addEventListener('click', onClearInputButtonClicked); +} +clearInputTextBox(); +//adds an event listener that clears the voter list input text box when the user clicks the Clear Input Text button + + + +//locateMe(map, myLocation); // runs function to return your location and mark it on a map. Have to use localhost:8080 for the location to be accessed though. + +//declares variables in Global Scope +window.voterFileInput = voterFileInput; +window.voterFileLoadButton = voterFileLoadButton; +window.voterListObj = voterListObj; +window.voterMap = map; +window.myLocation = myLocation; + + + diff --git a/site/js/map.js b/site/js/map.js new file mode 100644 index 0000000..5401363 --- /dev/null +++ b/site/js/map.js @@ -0,0 +1,432 @@ +let responseContainer = document.getElementById("response-container"); +responseContainer.style.display = "none"; + +//let firstStage = responseContainer; + +let voterCard = document.createElement("div"); +voterCard.id = "voterCard"; + +//let address = ""; +//let ID = ""; + +let people = document.createElement("ul"); +let voterAddress = document.createElement("h2"); + +let closeVoterInfoButton = document.createElement("button"); + +//let openVoterNotesButton = document.createElement("button"); + +let voterNotes = document.createElement("div"); +voterNotes.id = "voter-notes"; + +let saveVoterNotesButton = document.createElement("button"); + +let closeVoterNotesButton = document.createElement("button"); + +let loadNotes = document.createElement("p"); +loadNotes.style.height = "50px"; +loadNotes.style.border = "10px black"; +loadNotes.innerHTML = "No notes for the residents of this building so far..."; + +let writeNotes = document.createElement("textarea"); +writeNotes.style.height = "100px"; +writeNotes.style.width = "250px"; + +//default-icon + + +const voter = { + currentAddress: null, + currentID: null, + currentName: null, + currentNotes: null, + stillLivesThere: null, + votingPlan: null, + languageAssistance: null, + }; + + let map; + +function initializeMap () { + map = L.map('map', { maxZoom: 22, preferCanvas: true }).setView([39.95, -75.16], 13); // made map global so that other functions can addTo 'map' + const mapboxAccount = 'mapbox'; + const mapboxStyle = 'light-v10'; + const mapboxToken = 'pk.eyJ1IjoibW9yZ2FuZ3IiLCJhIjoiY2w4dzF2bHZsMDJqdDN3czJwOGg0ZXBsbSJ9.tXRhvJAL-t7cJCrCyAEhUw'; + L.tileLayer(`https://api.mapbox.com/styles/v1/${mapboxAccount}/${mapboxStyle}/tiles/256/{z}/{x}/{y}@2x?access_token=${mapboxToken}`, { + maxZoom: 19, + attribution: '© Mapbox © OpenStreetMap Improve this map', + }).addTo(map); + + // layer for user location + map.positionLayer = L.geoJSON(null).addTo(map); + // layer for voter locations + map.voterLayer = L.geoJSON(null).addTo(map); + + return map; +} + +// function locateMe(map){ + +// const successCallback = (pos) => { +// if (map.positionLayer !== undefined) { +// map.removeLayer(map.positionLayer); +// } + +// myLocation = { +// 'type': 'Point', +// 'coordinates': [pos.coords.longitude, pos.coords.latitude] +// }; + +// map.positionLayer = L.geoJSON(myLocation).addTo(map); + +// // un-comment following line if we want the map to zoom to user location on startup: +// //map.setView([pos.coords.latitude, pos.coords.longitude], 19); + +// return myLocation; + +// } +// const errorCallback = (e) => console.log(e); + +// const options = { enableHighAccuracy: true, timeout: 10000 }; + +// const id = navigator.geolocation.watchPosition(successCallback, errorCallback, options); + +// //navigator.geolocation.clearWatch(id); // will need this when we change location in real-time. + +// } + +function setTag(status){ // sets tag as green/red if any of the voter object yes/no questions are answered, grey if " " (blank) + + switch(status){ + case 'true': + return "green"; + case 'false': + return "red"; + case null: + return "grey"; + } +} + +function openVoterNotes(p){ + + //openVoterNotesButton.style.display = "none"; + //responseContainer = blank ; + + let notes = document.createElement("div"); + notes.id = "notes"; + let tags = document.createElement("div"); + tags.id = "tags"; + let checkboxes = document.createElement("div"); + checkboxes.id = "checkboxes"; + let buttons = document.createElement("div"); + buttons.id = "buttons"; + + voterNotes.innerHTML = " "; + + voterNotes.style.zIndex = "2"; + voterNotes.style.display = "flex"; + voterNotes.style.justifyContent = "space-between"; + voterNotes.style.width = "250px"; + voterNotes.style.height = "auto"; + + let voterInfoQuestions = ['stillLivesThere', 'votingPlan', 'languageAssistance']; + + voter.currentID = p.id; + voter.currentName = p.name; + + for( let n of voterInfoQuestions ){ + + let item = voter.currentID.concat(" ").concat(n); + console.log(item); + voter[n] = localStorage.getItem(item); + } + + console.log(voter); + + // NOTES ONLY + + if(localStorage.getItem(voter.currentID) === null){ + loadNotes.innerText = "No notes for ID " + voter.currentID + " so far..."; + } + + else{ + loadNotes.innerText = localStorage.getItem(voter.currentID); + } + + loadNotes.style.backgroundColor = "#217e79"; + loadNotes.style.width = "100%"; + loadNotes.style.height = "50%"; + loadNotes.border = "10px"; + loadNotes.borderRadius = "10px"; + + writeNotes.text = " "; + writeNotes.style.width = "100%"; + writeNotes.style.height = "50%"; + + notes.style.position = "center"; + + notes.appendChild(loadNotes); + notes.appendChild(writeNotes); + + voterNotes.appendChild(notes); + + //TAGS ONLY in VOTER NOTES + + for( let t of voterInfoQuestions ){ + + let item = voter.currentID.concat(" ").concat(t); + console.log(item); + + let tag = document.createElement("button"); + tag.textContent = t; + tag.style.color = "white"; + tag.style.backgroundColor = setTag(localStorage.getItem(item)); + tag.style.padding = "5px"; + tag.style.margin = "3px"; + tag.style.borderRadius = "3px"; + tag.style.fontSize = "10px"; + tag.style.zIndex ="2"; + + tag.addEventListener('click', ()=>{ + console.log(voter[t]); + switch(voter[t]){ + case 'true': + localStorage.setItem(item, 'false'); + setTag(localStorage.getItem(item)); + break; + case 'false': + localStorage.setItem(item, 'true'); + setTag(localStorage.getItem(item)); + break; + case null: + localStorage.setItem(item, 'true'); + setTag(localStorage.getItem(item)); + } + + console.log(setTag(localStorage.getItem(item))); + tag.style.backgroundColor = setTag(localStorage.getItem(item)); + + }); + + tags.appendChild(tag); + + } + + tags.style.display = "flex"; + tags.style.flexDirection = "row"; + + voterNotes.appendChild(tags); + + //CHECKBOXES ONLY in VOTER NOTES + + + for( let q of voterInfoQuestions ){ + + let questionCheckbox = document.createElement("input"); + let checkboxLabel = document.createElement("label"); + questionCheckbox.type = "checkbox"; + questionCheckbox.id = q; + questionCheckbox.value = q; + + if(voter[q] === true){ + questionCheckbox.checked = true; + } + else{ + questionCheckbox.checked = false; + } + + checkboxLabel.htmlFor = q; + checkboxLabel.appendChild(document.createTextNode(q)); + questionCheckbox.addEventListener('change', ()=>{ + let item = voter.currentID.concat(" ").concat(q); + + if(questionCheckbox.isChecked){ + localStorage.setItem(item, true); + console.log('false, changing to true'); + } + else if(!questionCheckbox.isChecked){ + localStorage.setItem(item, false); + } + else{ + localStorage.setItem(item, true); + } + }); + + checkboxes.appendChild(questionCheckbox); + checkboxes.appendChild(checkboxLabel); + checkboxes.appendChild(document.createElement("br")); + + } + + checkboxes.style.display = "flex"; + checkboxes.style.alignItems = "flex-start"; + checkboxes.style.margin = "0px"; + + //voterNotes.appendChild(checkboxes); + + //BUTTONS ONLY in VOTER NOTES + + + saveVoterNotesButton.textContent = "Save Voter Notes"; + buttons.appendChild(saveVoterNotesButton); + + closeVoterNotesButton.textContent = "Close Voter Notes"; + buttons.appendChild(closeVoterNotesButton); + + buttons.style.display = "flex"; + buttons.style.flexDirection = "row"; + buttons.style.margin = "5px"; + buttons.style.zIndex = "3"; + + voterNotes.appendChild(buttons); + + // PUT VOTER NOTES IN RESPONSE CONTAINER + + responseContainer.appendChild(voterNotes); + + closeVoterNotesButton.addEventListener('click', ()=>{ + voterNotes.innerHTML = " "; + voterNotes.style.display = "none"; + //responseContainer = firstStage; + //openVoterNotesButton.style.display = "flex"; + + }); + + saveVoterNotesButton.addEventListener('click', () =>{ + + const notes = ("::").concat(writeNotes.value); + console.log(notes); + voter.currentNotes = notes; + + localStorage.setItem(voter.currentID, voter.currentNotes); + + }); + + +} + + +function showPoint(point){ + if(!map.chosenLayer){ + map.chosenLayer = L.geoJSON(point, { pointToLayer: (geoJsonPoint, latlng) => L.circleMarker(latlng), + style: { + fillColor: "#1a5e5e", + stroke: 0.6, + color : "#c2b397", + fillOpacity: 0.9, + radius: 10, + } }).addTo(map); + } + else{ + map.removeLayer(map.chosenLayer); + map.chosenLayer = L.geoJSON(point, { pointToLayer: (geoJsonPoint, latlng) => L.circleMarker(latlng), + style: { + fillColor: "#1a5e5e", + stroke: 0.6, + color : "#c2b397", + fillOpacity: 0.9, + radius: 10, + } }).addTo(map); + } +} + +function onEachFeature(feature, layer) { + + layer.on('click', function () { + //console.log(feature.geometry) + //L.marker(feature.geometry.coordinates).addTo(map); + + //residence.currentResidence = feature.properties.address; + //residence.notes = localStorage.getItem(residence.currentResidence); + //console.log(localStorage.getItem(voter.currentID)); + + console.log(feature.geometry.coordinates); + + showPoint(feature); + + voter.currentAddress = feature.properties.address; // start by moving pointer to selected building + //voter.currentID = feature.voters + //voter.currentNotes = localStorage.getItem(voter.currentID); + voterNotes.style.display = "none"; // right now sub-menu is inactive + + //alert(feature.properties.address); + responseContainer.style.display = "flex"; // address details become live + people.innerHTML = ""; // people in this building will be populated, blank for now + voterAddress.innerHTML = voter.currentAddress; // the HTML element showing the address now has the current objects address - DYNAMIC! + + //firstStage = responseContainer; + + responseContainer.appendChild(voterAddress); + let residents = feature.voters; // an array of residents living in this building + + for( let r = 0; r < residents.length; r++ ){ + + console.log(residents[r].name); + + let person = document.createElement("button"); // create a button for each resident - will use this to fill the voter object - DYNAMIC! + person.textContent = residents[r].name; + person.innerHTML = residents[r].name; + person.value = residents[r].id; + + person.style.display = "flex"; + + person.addEventListener('click', ()=>{ + openVoterNotes(residents[r]); + }); + + people.appendChild(person); + people.style.margin = "0px"; + } + + responseContainer.appendChild(people); + + closeVoterInfoButton.textContent = "Exit Address"; + responseContainer.appendChild(closeVoterInfoButton); + + }); +} + +closeVoterInfoButton.addEventListener('click', () =>{ + responseContainer.style.display = "none"; +}); + +function populateVoterMap(people, map) { // receives data from makeVoterFeature and plots them on the map + + console.log("These are the voters"); + map.removeLayer(map.voterLayer); + + // if (map.voterLayer !== undefined) { + // map.removeLayer(map.voterLayer); + map.voterLayer = L.geoJSON(people, { pointToLayer: (geoJsonPoint, latlng) => L.circleMarker(latlng), + style: { + fillColor: "orange", + stroke: null, + fillOpacity: 0.9, + radius: 7, + }, + onEachFeature : onEachFeature, + }).addTo(map); + //map.flyTo(map.voterLayer, 16); + // } + + // for( let ppl of people ){ + // try{ + // //L.marker(ppl.geometry.coordinates).bindPopup(ppl.properties['address']).addTo(map); + // // TODO: figure out how to get geoJSONs to work in new layer + // // map.voterLayer.addData(ppl); + // } + // catch(e){ + // // pass + // } + // } + +} + +//Tried to create a function to clear the voterLayer markers from the map, but it's not working! + +export { + initializeMap, + populateVoterMap, + }; + + window.voter = voter; \ No newline at end of file diff --git a/site/js/template-tools.js b/site/js/template-tools.js new file mode 100644 index 0000000..821f7c6 --- /dev/null +++ b/site/js/template-tools.js @@ -0,0 +1,36 @@ +/* ==================== +The following two functions take a string of HTML and create DOM element objects +representing the tags, using the `template` feature of HTML. See the following +for more information: https://stackoverflow.com/a/35385518/123776 +==================== */ + +/* eslint-disable no-unused-vars */ + +/** + * @param {String} HTML representing a single element + * @return {Element} + */ + function htmlToElement(html) { + const template = document.createElement('template'); + const trimmedHtml = html.trim(); // Never return a text node of whitespace as the result + template.innerHTML = trimmedHtml; + return template.content.firstChild; + } + + /** + * @param {String} HTML representing any number of sibling elements + * @return {NodeList} + */ + function htmlToElements(html) { + const template = document.createElement('template'); + template.innerHTML = html; + return template.content.childNodes; + } + + window.htmlToElement = htmlToElement; + window.htmlToElements = htmlToElements; + + export { + htmlToElement, + htmlToElements, + }; \ No newline at end of file diff --git a/site/res/default_home.png b/site/res/default_home.png new file mode 100644 index 0000000..fc403f3 Binary files /dev/null and b/site/res/default_home.png differ