← back to Homesonspec
apps/mobile/app/(tabs)/map.tsx
412 lines
/**
* Tab 3 — Map (NATIVE — App Store 4.2 differentiator)
*
* Native react-native-maps rendering ~70k+ new-construction homes from
* the live GET /api/map endpoint. Color-codes by construction status.
*
* PERFORMANCE (Cycle 2, DTD verdict C — viewport-cull THEN cluster):
* Rendering all ~70k <Marker> at once freezes the JS thread on device.
* Instead we (1) cull to the padded visible bbox on each region SETTLE
* (onRegionChangeComplete — not per frame), then (2) grid-cluster the
* culled subset into a fixed ~14x14 grid, so the native map never mounts
* more than ~196 annotations at any zoom. A cell with a single home renders
* as a real pin; a cell with many renders as a count bubble that zooms in
* on tap. Zero new dependency (build-safe). A bbox padding buffer prevents
* edge pop-in (the risk every panelist named). supercluster is the future
* accuracy upgrade if density UX needs it.
*
* Endpoint wired: GET https://homesonspec.com/api/map
* MapMarker: { i, la, lo, p, b, s, bl, c }
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
View,
Text,
ActivityIndicator,
TouchableOpacity,
StyleSheet,
SafeAreaView,
Linking,
} from 'react-native';
import MapView, { Marker, Region, Callout } from 'react-native-maps';
import { fetchMapMarkers, type MapMarker, homeDetailUrl } from '../../lib/api';
const BRAND = '#1a3a6e';
const ACCENT = '#e07b39';
// Marker colors by construction status
const STATUS_COLORS: Record<string, string> = {
MOVE_IN_READY: '#16a34a', // green
UNDER_CONSTRUCTION: '#e07b39', // orange
PLANNED: '#6366f1', // indigo
};
const DEFAULT_REGION: Region = {
latitude: 37.09024,
longitude: -95.712891,
latitudeDelta: 30,
longitudeDelta: 40,
};
// Clustering grid resolution (cells across the visible span) and edge padding.
const GRID_CELLS = 10;
const BBOX_PAD = 0.2; // 20% padding around the visible region → no edge pop-in
// Hard cap on rendered markers. react-native-maps holds a native custom view per
// marker and OOM-crashes on the physical device when too many custom-view markers
// (each with a Callout) are mounted at once — so we render at most this many, the
// densest clusters first. The "All (N)" total count is unaffected (it's separate).
const MAX_MARKERS = 120;
type Cluster = {
key: string;
count: number;
latitude: number;
longitude: number;
sample: MapMarker;
};
function formatPrice(price: number | null): string {
if (price === null) return '—';
if (price >= 1_000_000) return `$${(price / 1_000_000).toFixed(1)}M`;
return `$${Math.round(price / 1_000)}k`;
}
function statusLabel(s: string): string {
if (s === 'MOVE_IN_READY') return 'Move-In Ready';
if (s === 'PLANNED') return 'Planned';
return 'Under Construction';
}
/**
* Viewport-cull the full marker set to the padded visible bbox, then bucket
* the survivors into a fixed lat/lng grid. Returns at most ~GRID_CELLS^2 items.
*/
function clusterMarkers(
all: MapMarker[],
region: Region,
filterStatus: string | null,
): Cluster[] {
const { latitude, longitude, latitudeDelta, longitudeDelta } = region;
const padLat = latitudeDelta * BBOX_PAD;
const padLng = longitudeDelta * BBOX_PAD;
const minLat = latitude - latitudeDelta / 2 - padLat;
const maxLat = latitude + latitudeDelta / 2 + padLat;
const minLng = longitude - longitudeDelta / 2 - padLng;
const maxLng = longitude + longitudeDelta / 2 + padLng;
const cellLat = latitudeDelta / GRID_CELLS || 0.01;
const cellLng = longitudeDelta / GRID_CELLS || 0.01;
const cells = new Map<string, { count: number; sumLa: number; sumLo: number; sample: MapMarker }>();
for (let idx = 0; idx < all.length; idx++) {
const m = all[idx];
if (filterStatus && m.s !== filterStatus) continue;
if (m.la < minLat || m.la > maxLat || m.lo < minLng || m.lo > maxLng) continue;
const gx = Math.floor(m.lo / cellLng);
const gy = Math.floor(m.la / cellLat);
const key = `${gx}:${gy}`;
let c = cells.get(key);
if (!c) {
c = { count: 0, sumLa: 0, sumLo: 0, sample: m };
cells.set(key, c);
}
c.count++;
c.sumLa += m.la;
c.sumLo += m.lo;
}
const out: Cluster[] = [];
cells.forEach((c, key) => {
out.push({
key,
count: c.count,
latitude: c.sumLa / c.count,
longitude: c.sumLo / c.count,
sample: c.sample,
});
});
return out;
}
export default function MapTab() {
const mapRef = useRef<MapView>(null);
const [markers, setMarkers] = useState<MapMarker[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
const [filterStatus, setFilterStatus] = useState<string | null>(null);
const [selected, setSelected] = useState<MapMarker | null>(null);
const [region, setRegion] = useState<Region>(DEFAULT_REGION);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchMapMarkers();
setMarkers(data.markers);
setTotal(data.total);
} catch (e) {
setError((e as Error).message || 'Failed to load map data');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
// Recompute clusters only when the data, filter, or SETTLED region changes.
const clusters = useMemo(() => {
const all = clusterMarkers(markers, region, filterStatus);
// Bound the native custom-view marker count to avoid an on-device OOM crash;
// when a viewport would produce more, keep the densest clusters.
if (all.length <= MAX_MARKERS) return all;
return all.sort((a, b) => b.count - a.count).slice(0, MAX_MARKERS);
}, [markers, region, filterStatus]);
const filterKeys: Array<string | null> = ['MOVE_IN_READY', 'UNDER_CONSTRUCTION', 'PLANNED', null];
function handleClusterPress(c: Cluster) {
if (c.count === 1) {
setSelected(c.sample);
return;
}
// Zoom into the cluster: quarter the deltas, centered on the cluster.
mapRef.current?.animateToRegion(
{
latitude: c.latitude,
longitude: c.longitude,
latitudeDelta: Math.max(region.latitudeDelta / 4, 0.02),
longitudeDelta: Math.max(region.longitudeDelta / 4, 0.02),
},
350,
);
}
return (
<SafeAreaView style={styles.container}>
{/* Legend / filter strip */}
<View style={styles.filterBar}>
{filterKeys.map((s) => (
<TouchableOpacity
key={s ?? 'all'}
style={[
styles.filterChip,
filterStatus === s && styles.filterChipActive,
s !== null ? { borderColor: STATUS_COLORS[s] } : {},
]}
onPress={() => setFilterStatus(s)}
>
<Text
style={[
styles.filterChipText,
filterStatus === s && styles.filterChipTextActive,
]}
>
{s === null ? `All (${total.toLocaleString()})` : statusLabel(s)}
</Text>
</TouchableOpacity>
))}
</View>
{error ? (
<View style={styles.centered}>
<Text style={styles.errorTitle}>Could not load map</Text>
<Text style={styles.errorBody}>{error}</Text>
<TouchableOpacity style={styles.retryBtn} onPress={load}>
<Text style={styles.retryBtnText}>Retry</Text>
</TouchableOpacity>
</View>
) : (
<MapView
ref={mapRef}
style={styles.map}
initialRegion={DEFAULT_REGION}
onRegionChangeComplete={setRegion}
showsUserLocation
showsCompass
>
{clusters.map((c) =>
c.count === 1 ? (
<Marker
key={c.key}
coordinate={{ latitude: c.latitude, longitude: c.longitude }}
pinColor={STATUS_COLORS[c.sample.s] ?? BRAND}
onPress={() => setSelected(c.sample)}
tracksViewChanges={false}
>
<Callout tooltip onPress={() => setSelected(c.sample)}>
<View style={styles.callout}>
<Text style={styles.calloutPrice}>{formatPrice(c.sample.p)}</Text>
<Text style={styles.calloutCity}>{c.sample.c}</Text>
<Text style={styles.calloutStatus}>{statusLabel(c.sample.s)}</Text>
</View>
</Callout>
</Marker>
) : (
<Marker
key={c.key}
coordinate={{ latitude: c.latitude, longitude: c.longitude }}
onPress={() => handleClusterPress(c)}
tracksViewChanges={false}
>
<View style={styles.cluster}>
<Text style={styles.clusterText}>
{c.count >= 1000 ? `${Math.round(c.count / 1000)}k` : c.count}
</Text>
</View>
</Marker>
),
)}
</MapView>
)}
{loading && (
<View style={[StyleSheet.absoluteFill, styles.loadingOverlay]} pointerEvents="none">
<ActivityIndicator size="large" color={BRAND} />
<Text style={styles.loadingText}>
Loading {total ? total.toLocaleString() : ''} homes...
</Text>
</View>
)}
{/* Detail bottom sheet for selected marker */}
{selected !== null && (
<View style={styles.detailSheet}>
<View style={styles.detailRow}>
<View style={{ flex: 1 }}>
<Text style={styles.detailPrice}>{formatPrice(selected.p)}</Text>
<Text style={styles.detailCity}>{selected.c}</Text>
<Text style={[styles.detailStatus, { color: STATUS_COLORS[selected.s] ?? BRAND }]}>
{statusLabel(selected.s)}
</Text>
{selected.b != null && (
<Text style={styles.detailMeta}>{selected.b} bed</Text>
)}
</View>
<View style={styles.detailActions}>
<TouchableOpacity
style={styles.viewBtn}
onPress={() => Linking.openURL(homeDetailUrl(selected.i))}
>
<Text style={styles.viewBtnText}>View Details</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.closeBtn}
onPress={() => setSelected(null)}
>
<Text style={styles.closeBtnText}>✕</Text>
</TouchableOpacity>
</View>
</View>
</View>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f9fafb' },
map: { flex: 1 },
filterBar: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
filterChip: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 14,
borderWidth: 1.5,
borderColor: '#d1d5db',
backgroundColor: '#fff',
},
filterChipActive: { backgroundColor: BRAND, borderColor: BRAND },
filterChipText: { fontSize: 11, fontWeight: '600', color: '#374151' },
filterChipTextActive: { color: '#fff' },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 },
errorTitle: { fontSize: 18, fontWeight: '700', color: BRAND, marginBottom: 8 },
errorBody: { fontSize: 14, color: '#6b7280', textAlign: 'center', marginBottom: 20 },
retryBtn: { backgroundColor: BRAND, paddingHorizontal: 28, paddingVertical: 10, borderRadius: 8 },
retryBtnText: { color: '#fff', fontWeight: '700' },
loadingOverlay: {
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.75)',
},
loadingText: { marginTop: 8, fontSize: 13, color: '#6b7280' },
cluster: {
backgroundColor: BRAND,
minWidth: 34,
height: 34,
borderRadius: 17,
paddingHorizontal: 8,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#fff',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.25,
shadowRadius: 2,
elevation: 4,
},
clusterText: { color: '#fff', fontWeight: '800', fontSize: 12 },
callout: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 10,
minWidth: 120,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.15,
shadowRadius: 3,
elevation: 3,
},
calloutPrice: { fontSize: 15, fontWeight: '700', color: BRAND },
calloutCity: { fontSize: 12, color: '#6b7280', marginTop: 2 },
calloutStatus: { fontSize: 11, color: '#888', marginTop: 2 },
detailSheet: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.12,
shadowRadius: 8,
elevation: 8,
},
detailRow: { flexDirection: 'row', alignItems: 'flex-start' },
detailPrice: { fontSize: 20, fontWeight: '800', color: BRAND },
detailCity: { fontSize: 14, color: '#374151', marginTop: 2 },
detailStatus: { fontSize: 13, fontWeight: '600', marginTop: 4 },
detailMeta: { fontSize: 13, color: '#6b7280', marginTop: 2 },
detailActions: { gap: 8, alignItems: 'flex-end' },
viewBtn: {
backgroundColor: ACCENT,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
},
viewBtnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
closeBtn: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 6,
backgroundColor: '#f3f4f6',
},
closeBtnText: { fontSize: 16, color: '#6b7280' },
});