Designing for Stressed Users
Immigration advocacy maps serve users who may be:
- Under immediate enforcement pressure
- Unfamiliar with technology
- Using low-end mobile devices
- In areas with poor connectivity
Every interaction must be immediate, intuitive, and privacy-preserving.
Privacy-Preserving Search
The Problem
Commercial geocoders (Google Maps API) transmit sensitive location queries to external servers, creating surveillance risks.
Solution: Open-Source Geocoding
| Service | Description |
|---|---|
| Nominatim | OSM-based, multi-language |
| Pelias | Elasticsearch-based, fast autocomplete |
| Photon | Komoot-hosted, free |
Nominatim Implementation
async function searchLocation(query) {
const url = `https://nominatim.openstreetmap.org/search?` +
`q=${encodeURIComponent(query)}` +
`&format=json` +
`&limit=5` +
`&countrycodes=us`;
const response = await fetch(url, {
headers: {
'Accept-Language': navigator.language
}
});
return response.json();
}
Multi-Language Support
Nominatim parses Accept-Language headers and searches localized name:* tags:
// User's browser set to Spanish
// Nominatim returns Spanish place names
fetch(url, {
headers: {
'Accept-Language': 'es'
}
});
"Find Nearest" Without Tracking
Client-Side Distance Calculation
import * as turf from '@turf/turf';
function findNearestFacility(userLat, userLng, facilities) {
const userPoint = turf.point([userLng, userLat]);
let nearest = null;
let minDistance = Infinity;
facilities.features.forEach(facility => {
const distance = turf.distance(userPoint, facility, {units: 'miles'});
if (distance < minDistance) {
minDistance = distance;
nearest = facility;
}
});
// User location NEVER leaves the browser
return { facility: nearest, distance: minDistance };
}
Geolocation Request
function getUserLocation() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation not supported'));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
// Process locally, never transmit
resolve({
lat: position.coords.latitude,
lng: position.coords.longitude
});
},
(error) => reject(error),
{ enableHighAccuracy: false } // Coarse location is sufficient
);
});
}
Filtering & Layer Controls
Layer Toggle UI
<div class="layer-controls">
<label>
<input type="checkbox" checked data-layer="facilities">
ICE Facilities
</label>
<label>
<input type="checkbox" checked data-layer="checkpoints">
Checkpoints
</label>
<label>
<input type="checkbox" data-layer="287g">
287(g) Jurisdictions
</label>
</div>
document.querySelectorAll('[data-layer]').forEach(input => {
input.addEventListener('change', (e) => {
const layerId = e.target.dataset.layer;
const visible = e.target.checked;
if (visible) {
map.addLayer(layers[layerId]);
} else {
map.removeLayer(layers[layerId]);
}
});
});
Filter by Type
function filterFacilities(type) {
facilitiesLayer.eachLayer(layer => {
const facilityType = layer.feature.properties.facility_type;
if (type === 'all' || facilityType === type) {
layer.setStyle({ opacity: 1, fillOpacity: 0.7 });
} else {
layer.setStyle({ opacity: 0.2, fillOpacity: 0.1 });
}
});
}
Marker Clustering
For Dense Point Data
const clusterGroup = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
iconCreateFunction: (cluster) => {
const count = cluster.getChildCount();
const size = count < 10 ? 'small' : count < 100 ? 'medium' : 'large';
return L.divIcon({
html: `<div>${count}</div>`,
className: `cluster-icon cluster-${size}`,
iconSize: [40, 40]
});
}
});
checkpointsLayer.addTo(clusterGroup);
clusterGroup.addTo(map);
Cluster Styling
.cluster-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: bold;
color: white;
}
.cluster-small { background: #3498db; }
.cluster-medium { background: #e67e22; }
.cluster-large { background: #e74c3c; }
Progressive Disclosure Popups
Initial Click: Summary
function createPopup(feature) {
const props = feature.properties;
return `
<div class="popup-summary">
<h3>${props.name}</h3>
<p>${props.type} • ${props.operator}</p>
<button onclick="showDetails('${props.id}')">
More Information
</button>
</div>
`;
}
Expanded View: Details
function showDetails(facilityId) {
const facility = getFacilityById(facilityId);
const props = facility.properties;
document.querySelector('.popup-content').innerHTML = `
<div class="popup-details">
<h3>${props.name}</h3>
<table>
<tr><td>Type:</td><td>${props.facility_type}</td></tr>
<tr><td>Capacity:</td><td>${props.capacity}</td></tr>
<tr><td>Population:</td><td>${props.current_population}</td></tr>
<tr><td>Standards:</td><td>${props.standards}</td></tr>
</table>
<div class="popup-actions">
<a href="${props.kyr_link}">Know Your Rights</a>
<a href="${props.visitation_link}">Visitation Info</a>
</div>
</div>
`;
}
Mobile-First Interactions
Thumb Zone Design
Primary controls must be reachable with one thumb:
.map-controls {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
}
.map-control-btn {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
}
Bottom Sheet for Filters
<div class="bottom-sheet" id="filter-sheet">
<div class="sheet-handle"></div>
<div class="sheet-content">
<h3>Filter Map</h3>
<!-- Filter controls -->
</div>
</div>
// Swipe up to expand, swipe down to collapse
const sheet = document.getElementById('filter-sheet');
let startY;
sheet.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
});
sheet.addEventListener('touchmove', (e) => {
const deltaY = startY - e.touches[0].clientY;
// Handle swipe gesture
});
Scroll-Trap Prevention
The Problem
Full-width maps on mobile can trap users who are trying to scroll past them.
Solution: Conditional Dragging
const isMobile = L.Browser.mobile;
const map = L.map('map', {
dragging: !isMobile,
tap: !isMobile,
scrollWheelZoom: false
});
// Add explicit interaction instructions
if (isMobile) {
L.control.attribution({
prefix: 'Use two fingers to pan and zoom'
}).addTo(map);
}
Enable on Focus
// Enable dragging when map is focused
map.on('focus', () => {
map.dragging.enable();
});
map.on('blur', () => {
if (isMobile) {
map.dragging.disable();
}
});
Loading States
Skeleton Screen
<div class="map-container">
<div class="map-skeleton" id="map-loading">
<div class="skeleton-pulse"></div>
<p>Loading map...</p>
</div>
<div id="map" style="display: none;"></div>
</div>
map.on('load', () => {
document.getElementById('map-loading').style.display = 'none';
document.getElementById('map').style.display = 'block';
});
Progress Indicator
let loadedLayers = 0;
const totalLayers = 4;
function updateProgress() {
loadedLayers++;
const percent = (loadedLayers / totalLayers) * 100;
document.querySelector('.progress-bar').style.width = `${percent}%`;
if (loadedLayers === totalLayers) {
hideLoadingOverlay();
}
}
Touch Target Sizes
Minimum Sizes
| Element | Minimum Size |
|---|---|
| Buttons | 44x44px |
| Checkboxes | 44x44px tap area |
| Links | 44px height |
| Cluster icons | 40x40px minimum |
Popup Link Styling
.popup-content a {
display: inline-block;
padding: 12px 16px;
min-height: 44px;
line-height: 20px;
background: #2196f3;
color: white;
text-decoration: none;
border-radius: 4px;
}
Error Handling
Graceful Degradation
async function loadMapData() {
try {
const facilities = await fetch('/data/facilities.geojson');
displayFacilities(await facilities.json());
} catch (error) {
showError(`
<p>Unable to load map data.</p>
<p><a href="/facilities-list/">View facility list</a></p>
`);
}
}
Offline Detection
window.addEventListener('offline', () => {
showNotification('You are offline. Some features may be unavailable.');
});
window.addEventListener('online', () => {
hideNotification();
refreshMapData();
});
Related Resources
- Libraries Comparison - Framework selection
- Accessibility - Inclusive design
- Privacy Features - Protecting users
- Mobile Design - Mobile UX patterns