diff --git a/plans/reports/research-260829-1458-street-imagery-alternatives.md b/plans/reports/research-260829-1458-street-imagery-alternatives.md new file mode 100644 index 0000000..2c49023 --- /dev/null +++ b/plans/reports/research-260829-1458-street-imagery-alternatives.md @@ -0,0 +1,110 @@ +# Research Report: Free Street-Level Imagery Alternatives to Mapillary (Vietnam) + +Conducted 2026-08-29. All coverage numbers below are live API probes run that day, not vendor claims. + +## Executive Summary + +**No better free alternative exists.** Stay on Mapillary. Every candidate that is free and API-accessible fails the one requirement this game cannot compromise on: equirectangular 360° panoramas in Vietnamese cities. KartaView and Panoramax both have open, fast, keyless APIs and real Vietnam data — but effectively zero usable panoramas. Google Street View has the coverage and is blocked by billing, as stated. + +**The actual win is not a provider switch.** It is dropping Mapillary's Graph *search* API for its *vector tile* API. Current cost is up to 20 bbox searches at ~1-5s each; the tile path is one CDN request plus one entity lookup. That attacks the same latency the dart-throw rework already halved, and goes further, without changing imagery source, licensing, or coverage. + +Caveat up front: the tile path is documented but **not empirically verified** here — `MAPILLARY_ACCESS_TOKEN` is not available in this environment (env pull blocked). Everything else in this report is measured. + +## Methodology + +- 4 web searches, 1 doc fetch, ~12 direct API probes against KartaView, Panoramax, and image CDNs. +- Coverage measured by querying each API at the centre of TPHCM, HN, DL, DN and counting panorama-flagged records. +- Panorama claims verified by downloading actual JPEGs and reading dimensions from the SOF header — vendor `field_of_view` metadata was **not** trusted, and that scepticism proved necessary. + +## Candidate Evaluation + +| Provider | API free? | Key needed? | VN coverage | Usable 360°? | Verdict | +|---|---|---|---|---|---| +| **Mapillary** (current) | yes | yes | good | **yes** | **keep** | +| KartaView | yes | **no** | thin | **no** | reject | +| Panoramax | yes | **no** | thin | **no** | reject | +| Google Street View | free tier, billing required | yes | best | yes | blocked (billing) | +| Bing Streetside | — | — | — | — | platform retiring | +| Yandex / Apple / Naver / Kakao | no public API | — | none | — | reject | + +### KartaView — closest call, still fails + +Genuinely attractive on paper: `POST https://api.kartaview.org/1.0/list/nearby-photos/` needs **no API key**, and answered in **0.05-0.30s** (vs Mapillary's 1-5s). Grab-backed, so Southeast Asia is a focus, and it markets "3 Cities in Southeast Asia, in full 360º Imagery". + +Measured reality, 300m radius at each city centre: + +| City | photos | `projection: SPHERE` | +|---|---|---| +| TPHCM | 893 | 13 | +| HN | 49 | 0 | +| DL | 1 | 0 | +| DN | 0 | 0 | + +Worse, the 13 HCMC SPHERE records are mislabelled. All 6 sampled carry `field_of_view: 360` and `projection: SPHERE`, but the served JPEG is **2560×1440 (16:9)** — a flat frame, not a 2:1 equirectangular pano. PhotoSphere Viewer would render it as a distorted band. Outside HCMC there is no meaningful density at all. + +Image CDN, for the record: `name` is prefixed `storage13/`; canonical URL is `https://kartaview.org/`, 301-ing to `https://storage13.openstreetcam.org/files/...`. Variants `proc` (2.4 MB), `lth` (341 KB), `th`. + +### Panoramax — open and well-built, wrong geography + +STAC API, no key, fast: `GET https://api.panoramax.xyz/api/search?bbox=minLng,minLat,maxLng,maxLat`. Federated, IGN + OSM-France backed, CC-BY-SA. + +Vietnam data exists but is entirely narrow-FOV phone photography: + +| Scope | images sampled | `field_of_view` values | 360° | +|---|---|---|---| +| TPHCM | 120 | absent | 0 | +| HN | 200 | 72° | 0 | +| Vietnam-wide | 200 | 18°-108° | 0 | + +526 Vietnam images sampled, zero panoramas. Coverage is France-centric by design; the Taiwan and Wales instances show the federation model working, but nobody is running a Vietnam instance. + +### Google Street View + +Best Vietnam coverage by far, and the Street View Static API does have a free monthly tier. But it still requires an enabled Google Cloud **billing account**, which is the stated blocker. Not routable around without a foreign card or reseller — out of scope for this report. + +## Recommendation: switch Mapillary access pattern, not provider + +Current `fetchMapillaryImages` searches a random 1km window and re-rolls on a miss. Each miss is a full Graph *search* round-trip (~1-5s). The concurrent-round rework cut p50 from 17.8s to ~4s, but the ceiling is still bounded by search latency. + +Mapillary serves the same coverage as vector tiles from a CDN. The `image` layer at **zoom 14** carries `id` and `is_pano` per point — exactly the two fields the dart-throw is hunting for. + +``` +current: up to 20 × graph search (~1-5s each) → p50 ~4s +proposed: 1 × tile fetch (CDN) + 1 × entity lookup → est. ~300-800ms +``` + +Sketch: + +1. Compute the z14 tile range covering the city bbox (a z14 tile is ~2.4km; TPHCM ≈ 36 tiles). +2. Pick a random tile, fetch + `https://tiles.mapillary.com/maps/vtp/mly1_public/2/14/{x}/{y}?access_token=…` +3. Decode MVT, take the `image` layer, filter `is_pano === true`. +4. Random pick → gives `id` **and** point geometry (so no coordinate lookup needed). +5. One entity call for the image URL: + `https://graph.mapillary.com/{id}?fields=thumb_original_url` + +Why this beats the dart-throw: a z14 urban tile holds hundreds of image points, so "does this window contain a pano" stops being a coin flip. Misses become rare instead of typical. + +**Costs and caveats:** + +- Two new deps to decode MVT: `@mapbox/vector-tile` + `pbf`. Both small, pure JS, no native build. +- **Tile API rate limit is 50,000/day — not per minute.** Graph is 60k/min. This is far tighter and is the main risk. Mitigation: cache decoded tiles in the existing Upstash (they change slowly), which also drops most game starts to a single entity lookup. +- Empty/ocean tiles still need a re-roll, so keep a bounded retry loop. + +## Also worth doing regardless + +Da Lat returned `success: false` during yesterday's verification, and Ha Noi's p90 is still ~17s. Both are hit-rate problems, not latency problems — HN uses the smallest window (`mapillaryDelta: 0.003`). Widening deltas is a cheaper experiment than any of the above and is independent of it. + +## Unresolved questions + +1. **Tile path unverified.** No `MAPILLARY_ACCESS_TOKEN` in this environment, so steps 2-5 above are documentation-based. Needs one probe against a real token before committing to it. +2. **50k/day tile ceiling** — is that per app or per token? Docs say "limited to 50,000 per day"; unclear whether caching alone keeps a popular deployment under it. +3. **Pano density per z14 tile in VN cities** unmeasured — determines whether the retry loop can shrink to 2-3 attempts or stays near current size. +4. Whether Google Cloud billing is genuinely unreachable, or reachable via a foreign card — would change the recommendation outright, since Street View coverage dwarfs Mapillary's in Vietnam. + +## Sources + +- [KartaView](https://kartaview.org/) · [KartaView GitHub](https://github.com/kartaview) · [SEA 360° imagery](https://kartaview.org/landing/open-imagery) +- [Panoramax](https://www.panoramax.xyz/en/instances) · [Panoramax API](https://api.panoramax.xyz/) · [Wikipedia](https://en.wikipedia.org/wiki/Panoramax) +- [Mapillary API documentation](https://www.mapillary.com/developer/api-documentation) · [API v4 getting started](https://blog.mapillary.com/update/2021/06/23/getting-started-with-the-new-mapillary-api-v4.html) +- [Google Street View alternatives 2026](https://topalter.com/best-google-street-view-alternatives/free) diff --git a/src/app/components/DonateQRModal.js b/src/app/components/DonateQRModal.js index 592e397..ff88297 100644 --- a/src/app/components/DonateQRModal.js +++ b/src/app/components/DonateQRModal.js @@ -1,5 +1,7 @@ "use client"; +import Image from 'next/image'; + import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; @@ -13,19 +15,21 @@ export default function DonateQRModal({ isOpen, onClose }) {
- Donate QR Code
-

Scan to support the project

+

Scan to support the project

diff --git a/src/app/components/GameClient.js b/src/app/components/GameClient.js index 6784863..00bcf9e 100644 --- a/src/app/components/GameClient.js +++ b/src/app/components/GameClient.js @@ -3,12 +3,13 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import dynamic from 'next/dynamic'; +import { ArrowLeft, Coffee } from 'lucide-react'; import PanoramaViewer from './PanoramaViewer'; import DonateQRModal from './DonateQRModal'; const LeafletMap = dynamic(() => import('./LeafletMap'), { ssr: false, - loading: () =>
Loading map...
+ loading: () =>
Loading map...
}); import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; @@ -336,42 +337,53 @@ export default function GameClient() { }, [showResult]); const getScoreBg = (s) => { - if (s >= 5) return 'bg-green-500'; - if (s >= 4) return 'bg-emerald-500'; - if (s >= 3) return 'bg-amber-500'; - if (s >= 2) return 'bg-orange-500'; - if (s >= 1) return 'bg-red-400'; - return 'bg-gray-400'; + if (s >= 5) return 'bg-green-600'; + if (s >= 4) return 'bg-emerald-600'; + if (s >= 3) return 'bg-amber-600'; + if (s >= 2) return 'bg-orange-600'; + if (s >= 1) return 'bg-red-500'; + return 'bg-neutral-500'; + }; + + // Colour alone must not carry the result; every band also gets a word. + const getScoreLabel = (s) => { + if (s >= 5) return 'Pinpoint'; + if (s >= 4) return 'Excellent'; + if (s >= 3) return 'Good'; + if (s >= 2) return 'Fair'; + if (s >= 1) return 'Far'; + return 'Missed'; }; if (loading) { return ( -
-
-
-

Loading panoramic image...

-

{cityNames[location] || location}

+
+
+
); } return ( -
+
{/* Compact Header */} -
+
- VNGeoGuessr - + VNGeoGuessr + {cityNames[location] || location}
@@ -380,16 +392,18 @@ export default function GameClient() { onClick={() => setShowDonate(true)} variant="ghost" size="sm" - className="text-gray-600 hover:text-gray-900" + aria-label="Buy me a coffee" + className="min-h-11 text-muted-foreground hover:text-foreground" > - Buy me a coffee +
{/* Game Content */} -
+
{/* Panorama Viewer */} -
+
{imageData ? ( ) : ( -
-

Loading panorama...

+
+

Loading panorama...

)}
{/* Map and Controls */}
-
+
{loading ? 'Processing...' : guessCoordinates ? 'Submit Guess' : 'Click the map first'} @@ -428,7 +442,7 @@ export default function GameClient() { onClick={handleSkipGuess} disabled={loading} variant="outline" - className="py-4 px-5 text-base font-medium" + className="min-h-12 py-4 px-5 text-base font-medium" size="lg" > Skip @@ -444,35 +458,40 @@ export default function GameClient() { Round Result -
+
{/* Score circle */} -
- {score} +
+
+ {score} +
+ + {getScoreLabel(score)} +
- + {formatDistance(distance)} away
-

{getResultMessage(score, distance)}

+

{getResultMessage(score, distance)}

{/* Leaderboard ranks */} {(globalScore !== null || cityScore !== null) && (
{cityScore !== null && ( -
-

{cityNames[location]}

-

Total: {cityScore}

- {cityRank &&

Rank #{cityRank}

} +
+

{cityNames[location]}

+

Total: {cityScore}

+ {cityRank &&

Rank #{cityRank}

}
)} {globalScore !== null && ( -
-

Global

-

Total: {globalScore}

- {globalRank &&

Rank #{globalRank}

} +
+

Global

+

Total: {globalScore}

+ {globalRank &&

Rank #{globalRank}

}
)}
@@ -481,38 +500,38 @@ export default function GameClient() { {(globalDistanceRank !== null || cityDistanceRank !== null) && (
{cityDistanceRank !== null && ( -
-

{cityNames[location]} Distance

-

Rank #{cityDistanceRank}

+
+

{cityNames[location]} Distance

+

Rank #{cityDistanceRank}

)} {globalDistanceRank !== null && ( -
-

Global Distance

-

Rank #{globalDistanceRank}

+
+

Global Distance

+

Rank #{globalDistanceRank}

)}
)} {leaderboardMessage && ( -

{leaderboardMessage}

+

{leaderboardMessage}

)} -

+

{username || 'Anonymous'} • {cityNames[location] || location}

{/* Result Map */} -
+
-
+
{exactLocation && ( Actual: {exactLocation.lat.toFixed(4)}, {exactLocation.lng.toFixed(4)} )} @@ -527,7 +546,7 @@ export default function GameClient() { @@ -535,7 +554,7 @@ export default function GameClient() { onClick={handleGoBack} variant="outline" size="lg" - className="flex-1" + className="flex-1 min-h-12" > Menu diff --git a/src/app/components/LeaderboardList.js b/src/app/components/LeaderboardList.js index f5aad3f..d5c1964 100644 --- a/src/app/components/LeaderboardList.js +++ b/src/app/components/LeaderboardList.js @@ -1,24 +1,27 @@ "use client"; +import { Medal } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { formatDistance, getDistanceColor } from '../../lib/game'; function getScoreColor(score) { - if (score >= 50) return 'text-purple-600'; - if (score >= 25) return 'text-green-600'; - if (score >= 15) return 'text-blue-600'; - if (score >= 10) return 'text-yellow-600'; - if (score >= 5) return 'text-orange-600'; - return 'text-red-600'; + if (score >= 50) return 'text-purple-700 dark:text-purple-300'; + if (score >= 25) return 'text-green-700 dark:text-green-300'; + if (score >= 15) return 'text-blue-700 dark:text-blue-300'; + if (score >= 10) return 'text-yellow-700 dark:text-yellow-300'; + if (score >= 5) return 'text-orange-700 dark:text-orange-300'; + return 'text-red-700 dark:text-red-300'; } -function getRankIcon(rank) { +// Medal tint for the podium. The rank number stays visible alongside it, so the +// placing never depends on reading the colour or an emoji glyph. +function getMedalClass(rank) { switch (rank) { - case 1: return '🥇'; - case 2: return '🥈'; - case 3: return '🥉'; - default: return `#${rank}`; + case 1: return 'text-amber-500'; + case 2: return 'text-slate-400'; + case 3: return 'text-orange-700 dark:text-orange-400'; + default: return ''; } } @@ -29,7 +32,7 @@ export default function LeaderboardList({ data, loading, currentUsername, type } return (
{[...Array(8)].map((_, i) => ( -
+
@@ -44,8 +47,8 @@ export default function LeaderboardList({ data, loading, currentUsername, type } if (data.length === 0) { return (
-

No {isDistance ? 'records' : 'scores'} yet!

-

Be the first to play!

+

No {isDistance ? 'records' : 'scores'} yet!

+

Be the first to play!

); } @@ -61,31 +64,34 @@ export default function LeaderboardList({ data, loading, currentUsername, type } key={key} className={`flex items-center justify-between p-3 rounded-lg transition-all ${ isUser - ? 'bg-amber-50 border-2 border-amber-400 shadow-md' + ? 'bg-amber-100/70 dark:bg-amber-950/40 border-2 border-amber-500 shadow-md' : entry.rank <= 3 - ? 'bg-red-50/50 border border-red-100' - : 'bg-gray-50 hover:bg-gray-100' + ? 'bg-brand-subtle/40 border border-brand/20' + : 'bg-muted/50 hover:bg-muted' }`} >
-
- {getRankIcon(entry.rank)} +
+ {entry.rank <= 3 && ( +
-
+
{entry.username} {isUser && ( - YOU + YOU )}
{isDistance && entry.timestamp && ( -
+
{new Date(entry.timestamp).toLocaleDateString()}
)}
- {isDistance ? formatDistance(entry.distance) : entry.score} diff --git a/src/app/components/LeafletMap.js b/src/app/components/LeafletMap.js index ac13b3f..31cdec3 100644 --- a/src/app/components/LeafletMap.js +++ b/src/app/components/LeafletMap.js @@ -124,7 +124,7 @@ export default function LeafletMap({ return (
); } \ No newline at end of file diff --git a/src/app/components/PanoramaViewer.js b/src/app/components/PanoramaViewer.js index 185ca26..cdcd401 100644 --- a/src/app/components/PanoramaViewer.js +++ b/src/app/components/PanoramaViewer.js @@ -32,7 +32,7 @@ function PanoramaViewer({ imageUrl, onReady, onError }) { if (!imageUrl) { // Show loading or error state containerRef.current.innerHTML = ` -
+
Loading panoramic image...
`; @@ -140,7 +140,7 @@ function PanoramaViewer({ imageUrl, onReady, onError }) { return (
); } diff --git a/src/app/components/UsernameModal.js b/src/app/components/UsernameModal.js index d4d565f..b1cd485 100644 --- a/src/app/components/UsernameModal.js +++ b/src/app/components/UsernameModal.js @@ -60,7 +60,9 @@ export default function UsernameModal({ isOpen, onSubmit, onClose }) {
- + +

+ 2-20 characters. Letters, numbers, hyphens and underscores. +

{error && ( - + {error} )} @@ -82,20 +91,20 @@ export default function UsernameModal({ isOpen, onSubmit, onClose }) { type="button" variant="ghost" onClick={onClose} - className="flex-1" + className="flex-1 min-h-11" > Skip
-

+

Displayed on the leaderboard

diff --git a/src/app/game/page.js b/src/app/game/page.js index 2344f75..44d1527 100644 --- a/src/app/game/page.js +++ b/src/app/game/page.js @@ -5,10 +5,10 @@ import GameClient from '../components/GameClient'; function GameLoadingFallback() { return ( -
-
-
-

Loading game...

+
+
+
); diff --git a/src/app/globals.css b/src/app/globals.css index dbc66a4..73e5d42 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -43,6 +43,12 @@ --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); + --color-brand: var(--brand); + --color-brand-hover: var(--brand-hover); + --color-brand-foreground: var(--brand-foreground); + --color-brand-subtle: var(--brand-subtle); + --color-brand-subtle-foreground: var(--brand-subtle-foreground); + --color-surface: var(--surface); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -62,7 +68,7 @@ --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); + --muted-foreground: oklch(0.54 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); @@ -137,16 +143,56 @@ z-index: 9999 !important; } -/* Vietnamese brand colors */ +/* Vietnamese flag palette. Kept as the brand identity; --brand is the token + every component should reference rather than a raw red utility class. */ :root { --vn-red: #da251d; --vn-red-dark: #c41e3a; --vn-gold: #ffd700; + + --brand: var(--vn-red); + --brand-hover: var(--vn-red-dark); + --brand-foreground: #ffffff; + --brand-subtle: oklch(0.962 0.028 22); + --brand-subtle-foreground: oklch(0.45 0.19 27); + /* Page background, one step off --background so cards read as raised. */ + --surface: oklch(0.977 0.002 250); +} + +/* Dark palette. Red is lightened and desaturated rather than inverted, so it + stays legible on dark surfaces instead of vibrating against them. */ +@media (prefers-color-scheme: dark) { + :root { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.75 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + + --brand: oklch(0.68 0.18 27); + --brand-hover: oklch(0.74 0.17 27); + --brand-foreground: oklch(0.145 0 0); + --brand-subtle: oklch(0.28 0.06 27); + --brand-subtle-foreground: oklch(0.86 0.11 27); + --surface: oklch(0.115 0 0); + } } -/* Soft clean background */ .vn-gradient-bg { - background: #f8f9fb; + background: var(--surface); } /* Spinner animation for loading */ @@ -184,7 +230,7 @@ top: 0; bottom: 0; width: 3px; - background: var(--vn-red); + background: var(--brand); border-radius: 3px 0 0 3px; opacity: 0; transition: opacity 0.2s ease; diff --git a/src/app/layout.js b/src/app/layout.js index 92ec641..e82c68d 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -18,6 +18,16 @@ export const metadata = { description: "GeoGuessr for VietNam", }; +// light dark lets the OS preference drive native controls, scrollbars and form +// widgets, matching the token palette in globals.css. +export const viewport = { + colorScheme: "light dark", + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f8f9fb" }, + { media: "(prefers-color-scheme: dark)", color: "#0d0d0d" }, + ], +}; + export default function RootLayout({ children }) { return ( diff --git a/src/app/page.js b/src/app/page.js index b2ecf16..7ad20f0 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { ArrowRight, Trophy, Coffee, Wrench } from "lucide-react"; import React, { useState, useEffect, useRef } from "react"; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -97,19 +98,19 @@ export default function Home() { const renderTabContent = (locationKey) => (
-
Type
+
Type
{['score', 'distance'].map(type => ( - + ))}
@@ -124,31 +125,33 @@ export default function Home() { ); return ( -
-
+
+
{/* Header */}
- + VNGeoGuessr
{username && ( - - Playing as {username} + + Playing as {username} )}
@@ -156,34 +159,34 @@ export default function Home() { {/* Hero */}
-

+

Guess the Location

-

+

Explore Vietnamese streets and test your geography skills across iconic cities.

{/* How to Play */} - + - How to Play + How to Play
{STEP_LABELS.map((label, i) => (
-
+
{i + 1}
-

{label}

+

{label}

))}
-
-

Scoring

-
+
+

Scoring

+
0-50m = 5 pts 50-100m = 4 pts 100-200m = 3 pts @@ -196,9 +199,9 @@ export default function Home() { {/* City Selection */} - + - Select a City + Select a City
@@ -206,16 +209,15 @@ export default function Home() { -
- - {city.name} - - - → - -
+ + {city.name} + +
@@ -234,7 +236,7 @@ export default function Home() { Leaderboards - + Global {cities.map(city => ( {city.code} @@ -255,10 +257,11 @@ export default function Home() { {/* Debug Button */}
diff --git a/src/lib/game.js b/src/lib/game.js index 5b92dea..14427b2 100644 --- a/src/lib/game.js +++ b/src/lib/game.js @@ -135,11 +135,11 @@ export function getAccumulatedScoreMessage(newScore, totalScore) { // Get distance color for leaderboard display export function getDistanceColor(distance) { - if (distance <= 50) return 'text-green-600'; - if (distance <= 100) return 'text-blue-600'; - if (distance <= 200) return 'text-yellow-600'; - if (distance <= 500) return 'text-orange-600'; - return 'text-red-600'; + if (distance <= 50) return 'text-green-700 dark:text-green-300'; + if (distance <= 100) return 'text-blue-700 dark:text-blue-300'; + if (distance <= 200) return 'text-yellow-700 dark:text-yellow-300'; + if (distance <= 500) return 'text-orange-700 dark:text-orange-300'; + return 'text-red-700 dark:text-red-300'; } // Get random location within city bbox