Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,867 changes: 116 additions & 1,751 deletions app/(tabs)/map.tsx

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion app/dev.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect } from "react";
import { ScrollView, Text, TouchableOpacity, View } from "react-native";

import { images } from "@/constants/images";
import { clearAllClientCache } from "@/lib/client-cache";
import { useCountryFeedStore, useSavedCountriesStore } from "@/store";

function DevButton({
Expand All @@ -21,7 +22,9 @@ function DevButton({
className="rounded-lg bg-ocean-blue px-3 py-2"
style={{ opacity: disabled ? 0.5 : 1 }}
>
<Text className="text-center text-sm font-medium text-white">{label}</Text>
<Text className="text-center text-sm font-medium text-white">
{label}
</Text>
</TouchableOpacity>
);
}
Expand Down Expand Up @@ -107,6 +110,10 @@ export default function DevScreen() {
/>
<DevButton label="Toggle saved" onPress={handleToggleSaved} />
<DevButton label="Clear saved" onPress={clearSaved} />
<DevButton
label="Clear local cache"
onPress={() => void clearAllClientCache()}
/>
</View>
</View>
</ScrollView>
Expand Down
10 changes: 10 additions & 0 deletions backend/src/lib/app-region.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,13 @@ export function normalizeAppRegion(

return NORTH_AMERICA;
}

/** Whether a country belongs to an app continent tab (handles legacy `Americas`). */
export function countryMatchesExploreRegion(
country: { name: string; region: string },
targetRegion: string,
): boolean {
return (
normalizeAppRegion(country.region, undefined, country.name) === targetRegion
);
Comment thread
birthrand marked this conversation as resolved.
}
18 changes: 5 additions & 13 deletions backend/src/services/search.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import type { Country, CountryBasic } from "../types/country.js";
import { countryMatchesExploreRegion } from "../lib/app-region.js";
import { HttpError } from "../lib/http.js";
import type { Country, CountryBasic } from "../types/country.js";
import { enrichCountryWithAi } from "./ai.service.js";
import { CACHE_TTL, cacheKeys, getOrSet } from "./cache.service.js";
import { getFeedCountries } from "./country.service.js";
import {
CACHE_TTL,
cacheKeys,
getOrSet,
} from "./cache.service.js";
import { enrichCountryWithImages } from "./image.service.js";

export type SearchResponse = {
Expand All @@ -31,17 +28,12 @@ function filterCountries(
let matches = countries;

if (region) {
const regionLower = region.toLowerCase();
matches = matches.filter(
(c) => c.region.toLowerCase() === regionLower,
);
matches = matches.filter((c) => countryMatchesExploreRegion(c, region));
}

if (query) {
const queryLower = query.toLowerCase();
matches = matches.filter((c) =>
c.name.toLowerCase().includes(queryLower),
);
matches = matches.filter((c) => c.name.toLowerCase().includes(queryLower));
}

return matches.sort((a, b) => a.name.localeCompare(b.name));
Expand Down
3 changes: 3 additions & 0 deletions components/bottom-tab-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const TAB_BAR_BG = "#0b132b";
const TAB_ACTIVE = "#fbbf24";
const TAB_INACTIVE = "#94a3b8";

/** Bar chrome only — add safe-area bottom inset for full tab bar height. */
export const TAB_BAR_CONTENT_HEIGHT = 62;

type IoniconsName = keyof typeof Ionicons.glyphMap;

type TabItem = {
Expand Down
199 changes: 199 additions & 0 deletions components/map/globe-boundary-hit-targets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import type { ThreeEvent } from "@react-three/fiber/native";
import { useCallback, useEffect, useMemo } from "react";
import * as THREE from "three";

import type { MapZoomTier } from "@/components/map/world-map-view";
import { buildGlobeBoundaryHitTargets } from "@/lib/globe-boundary-fills";
import { logGlobeTap } from "@/lib/globe-tap-debug";
import {
countryNamesMatch,
filterBoundaryPolygonsByMapContext,
getCountryBoundaryPolygons,
} from "@/lib/map-country-boundaries";
import { areRegionBoundariesTappable } from "@/lib/map-signal-sources";
import { useMapUiStore } from "@/store/use-map-ui-store";
import type { MapCountry } from "@/types/country";

const countriesGeoJson = require("@/assets/geo/ne_50m_admin_0_countries/ne_50m_admin_0_countries.json");

type GlobeBoundaryHitTargetsProps = {
boundaryCountries: MapCountry[];
selectedName: string | null;
focusedRegion: string | null;
boundaryFocusRegion?: string | null;
zoomTier: MapZoomTier;
onBoundaryCountryPress: (country: MapCountry) => void;
/** Skip taps that exceeded the orbit drag threshold. */
consumeTapThresholdExceeded: () => boolean;
/** Reset drag guard when R3F sees a new pointer down. */
beginPointerTap: () => void;
};

function BoundaryHitMesh({
geometry,
onPress,
beginPointerTap,
}: {
geometry: THREE.BufferGeometry;
onPress: () => void;
beginPointerTap: () => void;
}) {
useEffect(() => {
return () => geometry.dispose();
}, [geometry]);

const handlePointerDown = useCallback(() => {
beginPointerTap();
}, [beginPointerTap]);

const handlePress = useCallback(
(event: ThreeEvent<MouseEvent>) => {
event.stopPropagation();
onPress();
},
[onPress],
);

return (
<mesh
geometry={geometry}
onPointerDown={handlePointerDown}
onPointerUp={handlePress}
>
<meshBasicMaterial
transparent
opacity={0}
depthWrite={false}
side={THREE.DoubleSide}
/>
</mesh>
);
}

export function GlobeBoundaryHitTargets({
boundaryCountries,
selectedName,
focusedRegion,
boundaryFocusRegion = focusedRegion,
zoomTier,
onBoundaryCountryPress,
consumeTapThresholdExceeded,
beginPointerTap,
}: GlobeBoundaryHitTargetsProps) {
const showBoundaryLines = useMapUiStore((s) => s.showBoundaryLines);
const boundaryStyle = useMapUiStore((s) => s.boundaryStyle);

const highlightCountryName = selectedName;
const showCountryHighlight =
!!highlightCountryName && boundaryStyle.countryHighlightEnabled;
const boundariesTappable = areRegionBoundariesTappable(
boundaryFocusRegion,
zoomTier,
{ allowWorldZoomGlobe: true },
);
const showBoundaryStrokes =
boundaryStyle.strokeColorEnabled && showBoundaryLines;
const showWorldBoundaries =
showBoundaryLines && !boundaryFocusRegion && !highlightCountryName;

const countryBoundaries = useMemo(() => {
const all = getCountryBoundaryPolygons(countriesGeoJson);
return filterBoundaryPolygonsByMapContext(all, {
selectedCountryName: highlightCountryName,
focusedRegion: boundaryFocusRegion,
countries: boundaryCountries,
showWorldBoundaries,
});
}, [
boundaryCountries,
boundaryFocusRegion,
highlightCountryName,
showWorldBoundaries,
]);

const hitTargets = useMemo(
() => buildGlobeBoundaryHitTargets(countryBoundaries),
[countryBoundaries],
);

const handleBoundaryPress = useCallback(
(countryName: string | null) => {
if (consumeTapThresholdExceeded()) {
logGlobeTap({
source: "boundary-mesh",
stage: "skip",
outcome: "ignored-drag-threshold",
country: countryName,
});
return;
}
if (!countryName) {
logGlobeTap({
source: "boundary-mesh",
stage: "skip",
outcome: "missing-country-name",
});
return;
}

const country =
boundaryCountries.find((entry) =>
countryNamesMatch(entry.name, countryName),
) ?? null;
if (country) {
logGlobeTap({
source: "boundary-mesh",
stage: "input",
outcome: "boundary-mesh-hit",
country: country.name,
region: country.region,
focusedRegion,
boundaryFocusRegion,
cameraTier: zoomTier,
});
onBoundaryCountryPress(country);
return;
}

logGlobeTap({
source: "boundary-mesh",
stage: "skip",
outcome: "country-not-in-list",
country: countryName,
focusedRegion,
boundaryFocusRegion,
cameraTier: zoomTier,
});
},
[
boundaryCountries,
boundaryFocusRegion,
consumeTapThresholdExceeded,
focusedRegion,
onBoundaryCountryPress,
zoomTier,
],
);

if (
!boundariesTappable ||
!showBoundaryStrokes ||
showCountryHighlight ||
hitTargets.length === 0
) {
return null;
}

return (
<group>
{hitTargets.map((target) => (
<BoundaryHitMesh
key={target.id}
geometry={target.geometry}
onPress={() => handleBoundaryPress(target.countryName)}
beginPointerTap={beginPointerTap}
/>
))}
</group>
);
}
50 changes: 33 additions & 17 deletions components/map/globe-boundary-lines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "@/lib/globe-boundary-lines";
import {
filterBoundaryPolygonsByMapContext,
parseCountryBoundaryPolygons,
getCountryBoundaryPolygons,
} from "@/lib/map-country-boundaries";
import { useMapUiStore } from "@/store/use-map-ui-store";
import type { MapCountry } from "@/types/country";
Expand All @@ -22,18 +22,15 @@ const countriesGeoJson = require("@/assets/geo/ne_50m_admin_0_countries/ne_50m_a
type GlobeBoundaryLinesProps = {
boundaryCountries: MapCountry[];
selectedName: string | null;
focusTransitionName?: string | null;
/** Intent — which polygons to load (continent filter). */
focusedRegion: string | null;
/** Effective region for boundary strokes (may infer from view center). */
boundaryFocusRegion?: string | null;
/** Live globe camera tier — stroke width/color scaling. */
zoomTier: MapZoomTier;
};

function resolveGlobeZoomTier(
selectedName: string | null,
focusedRegion: string | null,
): MapZoomTier {
if (selectedName) return "country";
if (focusedRegion) return "region";
return "world";
}

function BoundaryLineSegment({
geometry,
color,
Expand Down Expand Up @@ -63,25 +60,40 @@ function BoundaryLineSegment({
export function GlobeBoundaryLines({
boundaryCountries,
selectedName,
focusTransitionName = null,
focusedRegion,
boundaryFocusRegion = focusedRegion,
zoomTier,
}: GlobeBoundaryLinesProps) {
const showBoundaryLines = useMapUiStore((s) => s.showBoundaryLines);
const boundaryStyle = useMapUiStore((s) => s.boundaryStyle);

const zoomTier = resolveGlobeZoomTier(selectedName, focusedRegion);
const highlightCountryName = selectedName;
const showCountryHighlight =
!!highlightCountryName && boundaryStyle.countryHighlightEnabled;
const showBoundaryStrokes =
boundaryStyle.strokeColorEnabled && showBoundaryLines;

const showWorldBoundaries =
showBoundaryLines && !focusedRegion && !selectedName;
showBoundaryLines && !boundaryFocusRegion && !highlightCountryName;

const countryBoundaries = useMemo(() => {
const all = parseCountryBoundaryPolygons(countriesGeoJson);
if (!showBoundaryStrokes || showCountryHighlight) return [];
const all = getCountryBoundaryPolygons(countriesGeoJson);
return filterBoundaryPolygonsByMapContext(all, {
selectedCountryName: selectedName,
focusedRegion,
selectedCountryName: null,
focusedRegion: boundaryFocusRegion,
countries: boundaryCountries,
showWorldBoundaries,
});
}, [boundaryCountries, focusedRegion, selectedName, showWorldBoundaries]);
}, [
boundaryCountries,
boundaryFocusRegion,
highlightCountryName,
showBoundaryStrokes,
showCountryHighlight,
showWorldBoundaries,
]);

const strokeColor = resolveBoundaryStrokeColor(boundaryStyle, zoomTier);
const styleKey = boundaryStyleRenderKey(boundaryStyle, zoomTier);
Expand All @@ -96,7 +108,11 @@ export function GlobeBoundaryLines({
[strokeColor],
);

if (!showBoundaryLines || lineSegments.length === 0) {
if (
!showBoundaryStrokes ||
showCountryHighlight ||
lineSegments.length === 0
) {
return null;
}

Expand Down
2 changes: 1 addition & 1 deletion components/map/globe-cluster-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function PulsingClusterBubble({
-1,
false,
);
}, [pulse]);
}, []);

const bubbleStyle = useAnimatedStyle(() => {
const scale = selected ? 1.12 : pulse.value;
Expand Down
Loading