← 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' },
});