ncsbe-lib
Version:
JavaScript library for working with North Carolina State Board of Elections (NCSBE) historical election data
375 lines (334 loc) • 13.9 kB
text/typescript
import { Collector } from './collector';
import { CandidateData, PrecinctData, CountyData, ContestData } from './types';
/**
* The `NCSBE` class provides an interface for fetching and querying election data
* from the North Carolina State Board of Elections (NCSBE). It uses the `Collector`
* class to retrieve, parse, and format election data.
*
* This class allows users to:
* - Retrieve election data for a specific date.
* - List available contests (races).
* - List counties where voting occurred for a given contest.
* - List precincts within a county for a specific contest.
*
* Example usage:
* ```typescript
* const electionData = new NCSBE("2024-11-05");
* await electionData.initialize();
* const contests = electionData.listContests();
* ```
*/
class NCSBE {
/** Election date in YYYY-MM-DD format. */
private electionDate: string;
/** URL to fetch the election data ZIP file. */
private url: string;
/** Cached dataset after calling `initialize()`. */
private dataSet: ContestData[] | null;
/**
* Creates a new instance of `NCSBE` for a given election date.
* @param {string} electionDate - The date of the election in YYYY-MM-DD format.
*/
constructor(electionDate: string) {
this.electionDate = electionDate;
this.url = NCSBE.makeBaseUrl(electionDate);
this.dataSet = null;
}
private static makeBaseUrl(date: string): string {
return `https://s3.amazonaws.com/dl.ncsbe.gov/ENRS/${date.replace(/-/g, '_')}/results_pct_${date.replace(/-/g, '')}.zip`;
}
async collect(): Promise<ContestData[]> {
const collector = new Collector(this.url);
return await collector.collect();
}
/**
* Initializes the election dataset by fetching and storing the results in memory.
* This method **must** be called before using `listContests()`, `listCounties()`, etc.
* @returns {Promise<void>} - Resolves when the dataset is loaded.
*/
async initialize(): Promise<void> {
this.dataSet = await this.collect();
}
/**
* Refreshes the election dataset by re-fetching and **completely replacing** `dataSet`
* with a new snapshot of the TSV file.
* @returns {Promise<void>} - Resolves when the dataset is loaded.
*/
async refresh(): Promise<void> {
this.dataSet = await this.collect();
}
/**
* Retrieves contest data for a specific contest name.
* @param {string} contest - The contest name.
* @returns {ContestData | null} The contest data or null if not found.
*/
private getContestData(contest: string): ContestData | null {
return this.dataSet
? this.dataSet.find((row) => row.contestName === contest) || null
: null;
}
/**
* Retrieves the entire election dataset.
* @returns {ContestData[] | null} The dataset, or null if it hasn't been initialized.
* @throws Will return null if `initialize()` has not been called.
*/
getDataset(): ContestData[] | null {
return this.dataSet ? this.dataSet : null;
}
/**
* Retrieves a list of all contests (races) available in the dataset.
* @returns {string[]} An array of contest names.
* @throws Will return an empty array if `initialize()` has not been called.
*/
listContests(): string[] {
return this.dataSet
? [...new Set(this.dataSet.map((row) => row.contestName))]
: [];
}
/**
* Lists all counties where voting took place for a specific contest.
* @param {string} contest - The contest name (e.g., "US Senate").
* @returns {string[]} - An array of county names.
* @throws Will return an empty array if `initialize()` has not been called.
*/
listCounties(contest: string): string[] {
const contestData = this.getContestData(contest);
return contestData
? [...new Set(contestData.counties.map((c) => c.county))]
: [];
}
/**
* Lists all precincts in a given county for a specific contest.
* @param {string} contest - The contest name (e.g., "US Senate").
* @param {string} county - The county name (e.g., "Wake").
* @returns {string[]} - An array of precinct names.
* @throws Will return an empty array if `initialize()` has not been called.
*/
listPrecincts(contest: string, county: string): string[] {
const contestData = this.getContestData(contest);
if (!contestData) return [];
const countyData = contestData.counties.find(
(c) => c.county === county,
);
return countyData ? countyData.precincts.map((p) => p.precinct) : [];
}
/**
* Retrieves a list of candidates in a given contest.
* @param {string} contest - The contest name.
* @returns {string[]} An array of candidate names.
* @throws Will return an empty array if `initialize()` has not been called.
*/
listCandidates(contest: string): string[] {
const contestData = this.getContestData(contest);
return contestData
? [...new Set(contestData.candidates.map((c) => c.candidate))]
: [];
}
/**
* Retrieves a contest object by its name.
* @param {string} contest - The contest name.
* @returns {ContestData | null} The contest data or null if not found.
*/
getContest(contest: string): ContestData | null {
return this.getContestData(contest);
}
/**
* Retrieves detailed information about a specific candidate in a contest.
* @param {string} contest - The contest name.
* @param {string} candidateName - The candidate's name.
* @returns {CandidateData | null} The candidate's data or null if not found.
*/
getCandidateInfo(
contest: string,
candidateName: string,
): CandidateData | null {
const contestData = this.getContestData(contest);
return contestData
? contestData.candidates.find(
(c) => c.candidate === candidateName,
) || null
: null;
}
/**
* Retrieves results for all precincts in a county for a given contest.
* @param {string} contest - The contest name.
* @param {string} county - The county name.
* @returns {CountyData | null} The county's election results or null if not found.
*/
getCountyResults(contest: string, county: string): CountyData | null {
const contestData = this.getContestData(contest);
return contestData
? contestData.counties.find((c) => c.county === county) || null
: null;
}
/**
* Retrieves all election results for a specific candidate across all contests.
* @param {string} candidateName - The candidate's name.
* @returns {CandidateData[]} An array of the candidate's results in different contests.
*/
getAllCandidateResults(candidateName: string): CandidateData[] {
if (!this.dataSet) return [];
return this.dataSet
.flatMap((contest) => contest.candidates)
.filter((c) => c.candidate === candidateName);
}
/**
* Retrieves the total vote count for a specific candidate in a contest.
* @param {string} contest - The contest name.
* @param {string} candidateName - The candidate's name.
* @returns {number} The total vote count for the candidate.
*/
getCandidateVoteTotal(contest: string, candidateName: string): number {
const contestData = this.getContestData(contest);
if (!contestData) return 0;
return contestData.counties
.flatMap((c) => c.precincts)
.flatMap((p) => p.candidates)
.filter((c) => c.candidate === candidateName)
.reduce((sum, c) => sum + c.votes, 0);
}
/**
* Retrieves a dictionary mapping candidates to their total votes in a contest.
* @param {string} contest - The contest name.
* @returns {Record<string, number>} A record mapping candidate names to total vote counts.
*/
getContestVoteTotals(contest: string): Record<string, number> {
const contestData = this.getContestData(contest);
if (!contestData) return {};
return contestData.candidates.reduce(
(acc, candidate) => {
acc[candidate.candidate] = this.getCandidateVoteTotal(
contest,
candidate.candidate,
);
return acc;
},
{} as Record<string, number>,
);
}
/**
* Retrieves the total number of votes for a given contest.
* @param {string} contest - The contest name.
* @returns {number} The total number of votes for a given contest.
*/
getTotalVotesForContest(contest: string): number {
const voteTotals = this.getContestVoteTotals(contest);
return Object.values(voteTotals).reduce((sum, votes) => sum + votes, 0);
}
/**
* Retrieve a candidate's percentage of total votes in a contest.
* @param {string} contest - The contest name
* @param {string} candidateName - The candidate's name
* @returns {number} a percentage (e.g. 25.4, 1.0, 90)
*/
getCandidateVotePercentage(contest: string, candidateName: string): number {
const voteTotals = this.getContestVoteTotals(contest);
if (!(candidateName in voteTotals)) return 0;
const totalVotes = this.getTotalVotesForContest(contest);
return totalVotes > 0
? (voteTotals[candidateName] / totalVotes) * 100
: 0;
}
/**
* Retrieves the data of the candidate who currently has the
* most votes in a given contest. Does not handle the case of a tie.
* @param {string} contest - The name of the contest
* @returns {CandidateData | null} The name of the winning candidate
*/
getContestWinner(contest: string): CandidateData | null {
const contestData = this.getContestData(contest);
if (!contestData || contestData.candidates.length === 0) return null;
const voteTotals = this.getContestVoteTotals(contest);
if (Object.keys(voteTotals).length === 0) return null;
const winnerName = Object.entries(voteTotals).reduce((a, b) =>
b[1] > a[1] ? b : a,
)[0];
return (
contestData.candidates.find((c) => c.candidate == winnerName) ||
null
);
}
/**
* Finds the contest with the smallest margin between the top two candidates.
* @returns {ContestData | null} The closest race contest data, or null if no data is available.
*/
getClosestRace(): ContestData | null {
if (!this.dataSet) return null;
let closestContest: ContestData | null = null;
let smallestMargin = Infinity;
for (const contest of this.dataSet) {
const voteTotals = this.getContestVoteTotals(contest.contestName);
const sortedVotes = Object.values(voteTotals).sort((a, b) => b - a);
if (sortedVotes.length >= 2) {
const margin = sortedVotes[0] - sortedVotes[1];
if (margin < smallestMargin) {
smallestMargin = margin;
closestContest = contest;
}
}
}
return closestContest;
}
/**
* Retrieves all candidates in a given contest.
* @param {string} contest - The contest name.
* @returns {CandidateData[]} An array of candidate data objects.
*/
getCandidates(contest: string): CandidateData[] {
const contestData = this.getContest(contest);
return contestData ? contestData.candidates : [];
}
/**
* Retrieves all counties in a given contest.
* @param {string} contest - The contest name.
* @returns {CountyData[]} An array of count data objects.
*/
getCounties(contest: string): CountyData[] {
const contestData = this.getContest(contest);
return contestData ? contestData.counties : [];
}
/**
* Retrieves all precincts in a given contest.
* @param {string} contest - The contest name.
* @returns {PrecinctData[]} An array of precinct data objects.
*/
getPrecincts(contest: string): PrecinctData[] {
const contestData = this.getContest(contest);
return contestData
? contestData.counties.flatMap((c) => c.precincts)
: [];
}
/**
* Retrieves all contests that a given candidate is a part of.
* @param {string} candidateName - The candidate's name
* @returns {ContestData[]} An array of contest data objects that a candidate is a part of
*/
getContestsByCandidate(candidateName: string): ContestData[] {
if (!this.dataSet) return [];
return this.dataSet.filter((contest) =>
contest.candidates.some((c) => c.candidate === candidateName),
);
}
/**
* Checks whether a given contest exists in the dataset.
* @param {string} contest - The contest's name.
* @returns {boolean} True if the contest exists, false otherwise.
*/
hasContest(contest: string): boolean {
return this.dataSet
? this.dataSet.some((c) => c.contestName === contest)
: false;
}
/**
* Checks whether a given candidate exists in the dataset.
* @param {string} candidate - The candidate's name.
* @returns {boolean} True if the candidate exists, false otherwise.
*/
hasCandidate(candidate: string): boolean {
if (!this.dataSet) return false;
return this.dataSet.some((contest) =>
contest.candidates.some((c) => c.candidate === candidate),
);
}
}
export { NCSBE };