Mar 12, 2026
Audio recording is automated for accessibility. Humans wrote and edited the story. One hundred and twenty-six people gathered at the Grenada County Courthouse on Feb. 6, 2006, for jury selection in the capital murder trial of Terry Pitchford, a 20 year-old Black man who stood accused of killing a beloved white convenience store owner. Defense attorneys and prosecutors worked their way through the potential jurors, narrowing the number to 96. By then, Black people made up about 37% of the jury pool, slightly less than the overall percentage of Black residents in Grenada County. After a judge removes prospective jurors for specific reasons, such as bias or being unable to serve because of work, prosecutors and defense attorneys are given a number of peremptory challenges, or “strikes,” to dismiss a prospective juror without providing a specific reason. There are restrictions, however. The 1986 Supreme Court decision Batson v. Kentucky prohibited strikes based on race, finding they violated a defendant’s 14th Amendment right to equal protection under the law. Following challenges for cause, the number of Black people available to be considered for jury service in Pitchford’s case narrowed dramatically. After striking a white woman, Doug Evans, then the district attorney for Mississippi’s 5th Circuit Court District, struck four Black prospective jurors, one after the other, leaving just one Black person to serve.  Defense attorney Alison Steiner objected, raising what’s known as a Batson challenge. At the time, Steiner worked for the state’s Office of Capital Defense Counsel and was helping Pitchford’s lead attorney conduct jury selection in the case. “This is already a disproportionately white jury for the population of this county,” she told Judge Joseph Loper. “It appears to be a pattern of disproportionately challenging African-American jurors.”  Loper asked Evans to provide reasons for his strikes. Evans said a Black woman named Linda Lee was 15 minutes late in returning to court after lunch and, according to law enforcement, had mental problems. “They have had numerous calls to her house and said she obviously has mental problems,” he said. Evans said that Christopher Tillmon, a Black man, had a brother convicted of manslaughter, and he didn’t want a juror with relatives convicted of offenses similar to the charges against Pitchford. Patricia Tidwell, a Black woman, had a brother with a criminal conviction and was, according to police, “a known drug user.” Finally, Carlos Ward, a Black man, was struck, in part, for being too similar to Pitchford. “One, he had no opinion on the death penalty,” Evans said of Ward. “He has a two-year-old child. He has never been married. He has numerous speeding violations that we are aware of. The reason that I do not want him as a juror is he is too closely related to the defendant.” It was the first time Steiner had faced Evans in a courtroom, though she would meet him again, two years later, when she was a defense attorney in the fifth trial of Curtis Flowers, a Black man accused of murdering four people inside a furniture store in Winona. Evans didn’t state the most obvious similarity between the two men out loud — they are both Black men — but to Steiner, the message was clear. “There was nothing subtle about Doug Evans,” Steiner, who is now retired, recalled. “He didn’t have to dog whistle. He just basically said it.” Under Batson, Evans didn’t need to prove his myriad claims about the potential jurors through police records, sworn testimony or other evidence. Judge Loper was, however, required to evaluate Evans’ reasons for striking them and determine if they were race-neutral. He swiftly accepted all of Evans’ four strikes without asking Steiner for rebuttal. Batson Challenge Database Mississippi Batson cases by county Reset All Cases ✕ % Black Population Loading data… Sources: State case data, Federal case data, U.S. Census Bureau American Community Survey Data visualization by Dennis Dean. `).join(''); } showLoading(text = 'Loading...') { this.loadingText.textContent = text; this.loadingOverlay.classList.add('show'); } hideLoading() { this.loadingOverlay.classList.remove('show'); } /** * Load counties GeoJSON, case data, and Census demographics. */ async loadData() { try { const countiesRes = await fetch(this.dataUrls.counties); if (!countiesRes.ok) throw new Error('Failed to load counties'); this.countiesGeoJson = await countiesRes.json(); // Build a county name lookup from GeoJSON properties this.countiesGeoJson.features.forEach(f => { const id = f.properties.id; const name = f.properties.name; if (id) { if (name) { this.countyNames[id] = name; } } }); this.showLoading('Loading case data...'); const casesRes = await fetch(this.dataUrls.cases); if (!casesRes.ok) throw new Error('Failed to load case data'); const csv = await casesRes.text(); const parsed = Papa.parse(csv, { header: true, skipEmptyLines: true }); this.resolveColumns(parsed.meta.fields || []); this.processCaseData(parsed.data); this.calculateCountyCounts(); this.showLoading('Loading demographic data...'); await this.loadDemographics(); this.createCountiesLayer(); this.addCountLabels(); this.setInitialView(); this.renderCards(); } catch (error) { console.error('Data load error:', error); this.cardsContainer.innerHTML = ` Unable to load data Please refresh the page to try again. `; } this.hideLoading(); } /** * Fetch ACS 5-Year demographic data from the Census API and build * a lookup of % Black population keyed by GeoJSON county ID. * * Census returns rows like: [NAME, B02001_001E, B02001_003E, state, county] * where state=28 (MS) and county is the 3-digit FIPS code. */ async loadDemographics() { try { const res = await fetch(this.dataUrls.demographics); if (!res.ok) throw new Error('Failed to load demographics'); const json = await res.json(); // First row is headers, remaining rows are data const rows = json.slice(1); // Build a lookup: normalized county name -> { pctBlack } const demoByName = {}; rows.forEach(row => { const name = (row[0] || '').replace(/,.*$/, '').trim(); const totalPop = parseInt(row[1], 10) || 0; const blackPop = parseInt(row[2], 10) || 0; const pctBlack = totalPop > 0 ? (blackPop / totalPop) * 100 : 0; const normName = this.normalizeCountyName(name); demoByName[normName] = pctBlack; }); // Map to GeoJSON county IDs using county name matching Object.keys(this.countyNames).forEach(geoId => { const norm = this.normalizeCountyName(this.countyNames[geoId]); if (demoByName.hasOwnProperty(norm)) { this.demographicsByFips[geoId] = demoByName[norm]; } }); } catch (error) { // Demographics are supplemental -- don't block rendering if unavailable console.warn('Demographics load error (non-fatal):', error); } } /** * Inspect CSV column headers and resolve which columns map to * county, case name, year, district, and other display fields. * County is always the 7th column (index 6) regardless of header name. * * @param {string[]} fields */ resolveColumns(fields) { const lower = fields.map(f => f.toLowerCase()); // County is column index 4 ("State Judicial District") this.colCounty = fields[this.colCountyIndex] || null; // Case name / title column const namePatterns = ['case', 'case name', 'case title', 'name', 'defendant', 'project or company', 'title']; this.colCaseName = fields.find((f, i) => namePatterns.includes(lower[i])) || fields[0]; // Year / date column const yearPatterns = ['year', 'date', 'year decided', 'decision year', 'report fiscal year', 'conviction date']; this.colYear = fields.find((f, i) => yearPatterns.includes(lower[i])) || null; // Court column (shown in card metadata) const courtPatterns = ['court', 'district', 'circuit', 'circuit court district', 'circuit district']; this.colCourt = fields.find((f, i) => { if (i === this.colCountyIndex) return false; return courtPatterns.includes(lower[i]); }) || null; // Link column for document chips const linkPatterns = ['document links', 'links', 'documents', 'files']; this.colLinks = fields.find((f, i) => linkPatterns.includes(lower[i])) || null; // All remaining columns are available for display on cards this.displayColumns = fields; } /** * Normalize a county name string for matching against GeoJSON feature names. * Handles values like "Washington County Circuit Court" by stripping * court-related suffixes down to just the county name. * * @param {string} name * @returns {string} */ normalizeCountyName(name) { if (!name) return ''; return name.trim() .toLowerCase() .replace(/\s*(circuit\s*court|chancery\s*court|court)\s*/gi, '') .replace(/\s+county$/i, '') .replace(/[^a-z\s]/g, '') .trim(); } /** * Parse the CSV rows into the internal cases array and index them by * GeoJSON county ID. * * @param {Object[]} data */ processCaseData(data) { // Build a reverse lookup: normalized county name -> GeoJSON county ID const nameLookup = {}; Object.keys(this.countyNames).forEach(id => { const norm = this.normalizeCountyName(this.countyNames[id]); nameLookup[norm] = id; }); this.cases = data.map((row, index) => { const countyRaw = this.colCounty ? (row[this.colCounty] || '').trim() : ''; const countyNorm = this.normalizeCountyName(countyRaw); const countyId = nameLookup[countyNorm] || null; // Clean display name: strip court suffixes but keep "County" const countyDisplay = countyRaw .replace(/\s*(Circuit\s*Court|Chancery\s*Court|Court)\s*/gi, '') .trim() || countyRaw; return { id: index, countyId: countyId, countyRaw: countyRaw, countyDisplay: countyDisplay, _row: row }; }); // Group by county ID this.casesByCounty = {}; this.cases.forEach(c => { if (c.countyId) { if (!this.casesByCounty[c.countyId]) { this.casesByCounty[c.countyId] = []; } this.casesByCounty[c.countyId].push(c); } }); } /** * Tally the number of unique cases per county. * Rows sharing the same Case Name are counted as one case. */ calculateCountyCounts() { this.countyCaseCounts = {}; this.maxCaseCount = 0; Object.keys(this.casesByCounty).forEach(countyId => { const seen = {}; this.casesByCounty[countyId].forEach(c => { const name = this.colCaseName ? (c._row[this.colCaseName] || '').trim() : ''; if (name) seen[name] = true; }); const count = Object.keys(seen).length; this.countyCaseCounts[countyId] = count; if (count > this.maxCaseCount) this.maxCaseCount = count; }); } /** * Get the choropleth fill color for a county based on its % Black population. * * @param {string} countyId - GeoJSON county ID * @returns {string} */ getCountyColor(countyId) { const pct = this.demographicsByFips[countyId]; if (pct === undefined || pct === null) return '#e2e8f0'; for (let i = this.choroplethSteps.length - 1; i >= 0; i--) { const step = this.choroplethSteps[i]; if (pct >= step.min) { return step.color; } } return this.choroplethSteps[0].color; } /** * Return the base Leaflet path style for a county feature. * * @param {Object} feature * @returns {Object} */ getCountyStyle(feature) { const id = String(feature.properties.id); const isSelected = this.selectedCountyId === id; return { fillColor: isSelected ? this.colors.selected : this.getCountyColor(id), fillOpacity: isSelected ? 0.7 : 0.85, color: isSelected ? this.colors.borderHover : this.colors.border, weight: isSelected ? 2.5 : 1, opacity: 1 }; } /** * Create the GeoJSON layer and bindpopups / click handlers per county. */ createCountiesLayer() { this.countiesLayer = L.geoJSON(this.countiesGeoJson, { style: (feature) => this.getCountyStyle(feature), onEachFeature: (feature, layer) => this.onEachCounty(feature, layer) }).addTo(this.map); } /** * Attach popup and interaction events to a single county layer. * Counties with no cases are left non-interactive. * * @param {Object} feature * @param {L.Layer} layer */ onEachCounty(feature, layer) { const id = String(feature.properties.id); const name = feature.properties.name; const caseCount = this.countyCaseCounts[id] || 0; layer._countyId = id; layer._countyName = name; // No interaction for counties without cases if (caseCount === 0) return; const pctBlack = this.demographicsByFips[id]; const pctDisplay = pctBlack !== undefined ? pctBlack.toFixed(1) + '%' : 'N/A'; layer.bindPopup(` ${name} County Batson Cases ${caseCount} Black Population ${pctDisplay} `, { maxWidth: 260 }); layer.on({ mouseover: (e) => { if (this.selectedCountyId !== id) { e.target.setStyle({ weight: 2, color: this.colors.borderHover, fillOpacity: 0.9 }); e.target.bringToFront(); } }, mouseout: (e) => { if (this.selectedCountyId !== id) { this.countiesLayer.resetStyle(e.target); } }, click: () => { this.selectCounty(id, name); } }); } /** * Place dark circle count labels at each county centroid that has cases. * Circle size scales linearly by case count, with 24px as the max. */ addCountLabels() { // Remove any existing labels this.labelMarkers.forEach(m => this.map.removeLayer(m)); this.labelMarkers = []; const minSize = 14; const maxSize = 24; const max = this.maxCaseCount || 1; this.countiesGeoJson.features.forEach(feature => { const id = String(feature.properties.id); const count = this.countyCaseCounts[id] || 0; if (count === 0) return; const centroid = this.getFeatureCentroid(feature); if (!centroid) return; // Scale size linearly between min and max const ratio = Math.sqrt(count / max); const size = Math.round(minSize + (maxSize - minSize) * ratio); const fontSize = Math.round(8 + (11 - 8) * ratio); const icon = L.divIcon({ className: 'bm-county-label', html: `${count}`, iconSize: [size, size], iconAnchor: [size / 2, size / 2] }); const marker = L.marker(centroid, { icon: icon, interactive: false, pane: 'labelsPane' }).addTo(this.map); this.labelMarkers.push(marker); }); } /** * Compute the visual centroid of a GeoJSON feature using the * signed-area (Green's theorem) centroid formula, which places * the point at the true center of mass of the polygon rather * than the average of its vertices. * * For MultiPolygon features, uses the centroid of the largest polygon. * * @param {Object} feature * @returns {L.LatLng|null} */ getFeatureCentroid(feature) { const geom = feature.geometry; let coords; if (geom.type === 'Polygon') { coords = geom.coordinates[0]; } else if (geom.type === 'MultiPolygon') { // Use the largest polygon ring let maxArea = 0; geom.coordinates.forEach(poly => { const ring = poly[0]; const area = this.ringArea(ring); if (area > maxArea) { maxArea = area; coords = ring; } }); } if (!coords || coords.length === 0) return null; // Signed-area centroid (Green's theorem) let cx = 0; let cy = 0; let signedArea = 0; for (let i = 0, len = coords.length; i { sumLng += c[0]; sumLat += c[1]; }); return L.latLng(sumLat / coords.length, sumLng / coords.length); } cx /= (6 * signedArea); cy /= (6 * signedArea); return L.latLng(cy, cx); } /** * Approximate area of a coordinate ring using the shoelace formula. * * @param {number[][]} ring * @returns {number} */ ringArea(ring) { let area = 0; for (let i = 0, j = ring.length - 1; i { this.countiesLayer.resetStyle(layer); if (layer._countyId === id) { layer.setStyle({ fillColor: this.colors.selected, fillOpacity: 0.7, color: this.colors.borderHover, weight: 2.5 }); layer.bringToFront(); } }); // Show filter tag this.filterTagCounty.textContent = name + ' County'; this.filterTag.style.display = 'inline-flex'; this.renderCards(); } /** * Focus a county on the map from a card click. Opens the popup * and highlights the county visually, but does not filter the * cards panel. Filtering only happens when clicking on the map. * * @param {string} countyId */ focusCounty(countyId) { if (!this.countiesLayer) return; // Suppress the popupclose handler so switching counties // doesn't trigger clearCountyFilter mid-transition this._suppressPopupClose = true; this.map.closePopup(); this.countiesLayer.eachLayer(layer => { if (layer._countyId !== countyId) return; // Visual highlight only (no filter state change) this.countiesLayer.eachLayer(l => { this.countiesLayer.resetStyle(l); }); layer.setStyle({ fillColor: this.colors.selected, fillOpacity: 0.7, color: this.colors.borderHover, weight: 2.5 }); layer.bringToFront(); // Open the popup layer.openPopup(); }); } /** * Clear the county filter and reset map styles. */ clearCountyFilter() { this.selectedCountyId = null; this.filterTag.style.display = 'none'; this.map.closePopup(); this.countiesLayer.eachLayer(layer => { this.countiesLayer.resetStyle(layer); }); this.renderCards(); } /** * Reset all filters and return to the default map view. */ resetView() { this.selectedCountyId = null; this.searchQuery = ''; this.searchInput.value = ''; this.filterTag.style.display = 'none'; this.countiesLayer.eachLayer(layer => { this.countiesLayer.resetStyle(layer); }); this.map.closePopup(); this.setInitialView(); this.renderCards(); } /** * Fit the map to the full extent of the counties layer, zoomed * tight enough that county outlines are clearly visible. */ setInitialView() { if (this.countiesLayer) { const bounds = this.countiesLayer.getBounds(); if (bounds.isValid()) { this.map.options.zoomSnap = 0.1; this.map.fitBounds(bounds, {padding:[10,10]}); this.map.options.zoomSnap = 1; } } } /** * Return the cases that match the current county filter and search query. * * @returns {Object[]} */ getFilteredCases() { let filtered = this.cases; // Filter by county if (this.selectedCountyId) { filtered = filtered.filter(c => c.countyId === this.selectedCountyId); } // Filter by search query across all row fields if (this.searchQuery) { filtered = filtered.filter(c => { const searchable = Object.values(c._row).join(' ').toLowerCase(); return searchable.includes(this.searchQuery); }); } return filtered; } /** * Render case cards into the side panel. */ renderCards() { const filtered = this.getFilteredCases(); const uniqueNames = {}; filtered.forEach(c => { const name = this.colCaseName ? (c._row[this.colCaseName] || '').trim() : ''; if (name) uniqueNames[name] = true; }); const uniqueCount = Object.keys(uniqueNames).length; this.panelStats.textContent = uniqueCount + ' cases'; if (filtered.length === 0) { this.cardsContainer.innerHTML = ` No cases found Try adjusting your search or filter. `; return; } const displayLimit = 200; const toDisplay = filtered.slice(0, displayLimit); this.cardsContainer.innerHTML = toDisplay.map(c => this.renderCard(c)).join(''); if (filtered.length > displayLimit) { this.cardsContainer.innerHTML += ` Showing ${displayLimit} of ${filtered.length} cases. Use search or county filter to narrow results. `; } // Apply 2-line clamping after DOM is populated requestAnimationFrame(() => this.applyClamps()); // Bind card clicks to open the corresponding county popup this.cardsContainer.querySelectorAll('.bm-case-card[data-county-id]').forEach(card => { card.addEventListener('click', (e) => { // Don't trigger if clicking the "more/less" toggle or a file chip link if (e.target.closest('.bm-clamp-toggle')) return; if (e.target.closest('.bm-file-chip')) return; const countyId = card.dataset.countyId; if (!countyId) return; this.focusCounty(countyId); }); }); } /** * Render a single case card. * Field labels sit above their values; long values are clamped to 2 lines. * * @param {Object} caseItem * @returns {string} */ renderCard(caseItem) { const row = caseItem._row; const caseName = this.colCaseName ? (row[this.colCaseName] || 'Unknown') : 'Unknown'; const county = caseItem.countyDisplay || 'Unknown'; const year = this.colYear ? (row[this.colYear] || '') : ''; const court = this.colCourt ? (row[this.colCourt] || '') : ''; // Build additional detail rows from all columns not already shown in the header const skipCols = new Set([this.colCaseName, this.colCounty, this.colYear, this.colCourt, this.colLinks].filter(Boolean)); const details = this.displayColumns .filter(col => !skipCols.has(col)) .map(col => { const val = (row[col] || '').toString().trim(); if (!val) return ''; return ` ${this.escapeHtml(col)} 80 ? 'data-clamp="true"' : ''} >${this.escapeHtml(val)} `; }) .filter(Boolean) .join(''); // Render Document Links as file chips const linksVal = this.colLinks ? (row[this.colLinks] || '').toString().trim() : ''; const linksHtml = linksVal ? this.renderFileChipsHtml(linksVal) : ''; return ` ${this.escapeHtml(caseName)} County ${this.escapeHtml(county)} ${year ? ` Year ${this.escapeHtml(String(year))} ` : ''} ${court ? ` Court ${this.escapeHtml(court)} ` : ''} ${details ? `${details}` : ''} ${linksHtml ? ` Documents ${linksHtml} ` : ''} ${details ? `` : ''} `; } /** * Render Document Links URLs as file chip HTML. * * @param {string} val - Newline-separated URLs * @returns {string} */ renderFileChipsHtml(val) { const fileIcon = ''; const urls = val.split(/[\n\r]+/).map(u => u.trim()).filter(Boolean); return '' + urls.map((url, i) => { const label = urls.length > 1 ? 'File ' + (i + 1) : 'File'; return '' + fileIcon + ' ' + label + ''; }).join('') + ''; } /** * After rendering cards, apply 2-line clamping with "more" toggle * to any detail value marked with data-clamp. */ applyClamps() { const clampEls = this.cardsContainer.querySelectorAll('[data-clamp="true"]'); clampEls.forEach(el => this.truncateToFit(el)); } /** * Truncate a detail value element to 2 lines, appending "... more" inline. * Uses binary search to find the cut point. * * @param {HTMLElement} el */ truncateToFit(el) { const fullText = el.textContent; const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 18; const maxHeight = Math.ceil(lineHeight * 2) + 1; // No overflow, no truncation needed if (el.scrollHeight { isExpanded = !isExpanded; if (isExpanded) { textEl.textContent = fullText + ' '; ellipsis.textContent = ''; toggle.textContent = 'less \u25B2'; } else { textEl.textContent = fullText.substring(0, best).replace(/[\s,.;:]+$/, ''); ellipsis.textContent = '... '; toggle.textContent = 'more \u25BC'; } }); } /** * Escape HTML to prevent XSS. * * @param {string} str * @returns {string} */ escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } } const container = document.getElementById('batson-map'); if (container) new BatsonMap(container); })(); ...read more read less
Respond, make new discussions, see other discussions and customize your news...

To add this website to your home screen:

1. Tap tutorialsPoint

2. Select 'Add to Home screen' or 'Install app'.

3. Follow the on-scrren instructions.

Feedback
FAQ
Privacy Policy
Terms of Service