Point your iPhone at one of those brown roadside landmark signs and instantly find out what it is β or pick a photo of one from your library, or skip the scan entirely and discover geo-tagged Wikipedia landmarks around you via the Nearby tab, including a pan-to-search map that lets you explore any stretch of road.
A fully on-device OCR + Apple Intelligence pipeline with a three-source landmark resolver (Wikipedia, NPS, Wikidata), location-aware ranking, an interactive map view of every find, and a directions launcher for Google Maps, Waze, and Apple Maps. Completely free: no account, no subscription, no ads.
Live inline viewfinder / library photo / typed text
(Scan leads with the live viewfinder; shutter or the
gallery picker feeds the same ingest pipeline, expand
opens the full-screen CameraView for a distant sign)
β
βΌ
Vision OCR (multi-line, sorted top-to-bottom)
β
βΌ
Apple Intelligence normalization
(extract landmark name, ignore directions/distances)
β
βΌ
βββββββββ΄βββββββββ
β Wikipedia β Wikipedia
β geosearch β text search
β (within 10 km) β (top 15)
βββββββββ¬βββββββββ
β merged, deduped
βΌ
Wikidata enrichment (parallel, per candidate)
β coordinates (P625)
β inception year (P571)
β instance-of type (P31)
β heritage designation flag (P1435)
β dissolution year (P576)
β
βΌ
Filter: blocklist β place-indicator whitelist
β title-token overlap β default reject
β
βΌ
Sort: exact title matches first, then by distance
β
βΌ
Phase-2 enrichment (top candidate only):
β Apple Intelligence summary polish
β Apple Intelligence match score
β Article image download + resize
β
βΌ
SwiftData history (deduped by canonical URL)
User location (or panned map center)
β
βΌ
Wikidata SPARQL geo-spatial query
β P1435 (heritage designation) ββ retried on 5xx via
OR `httpDataWithRetry`
P31/P279* in landmark allowlist
β English Wikipedia article required
β (up to 300 hits, server-side filtered)
βΌ
Distance-sort (haversine) + truncate to the 300 fetch limit
β
ββββββ΄ββββββββββββββββββββββ
βΌ βΌ
Wikipedia REST hydrate Operating-institution gate
(titles β extracts + (parallel Wikidata claims fetch
thumbnails) for ambiguous-titled hits;
strict drops Seymour HS,
lenient keeps NRHP churches)
ββββββ¬ββββββββββββββββββββββ
βΌ
Render list / clustered map (MKMapView, brown numbered
cluster bubbles β tap a cluster to zoom into its members).
Tap a row, or a pin β RichMapCard (thumbnail, distance +
walk time, Details + Directions) β enrich (full Wikidata
+ AI polish + match score + image) and push the detail view.
- Nearby discovery tab β surfaces brown-sign-worthy landmarks around you, no scan required. The fetch primary is a Wikidata SPARQL query that returns only items with a heritage designation (P1435 β NRHP, state register, etc.) or a curated landmark P31 type (museum, park, monument, lighthouse, trail, theatre building, university campus building, β¦) via
wdt:P31/wdt:P279*so subclasses like "state park" or "art museum" come along automatically. List shows the closest hits within an adjustable radius (2/5/10/25 miles, default 2), up to 300 per fetch. Map supports pan-to-search: as you move the map center more than half the current radius away from the last fetch, a new SPARQL query fires at the new location and pins accumulate. Pan across a state and you build up a dotted trail of landmarks as you go. The list follows the map area: once you've panned more than half a radius from your GPS, switching to the list re-scopes and re-sorts the rows to that area (the header flips to "Within N miles of this area" and per-row distances are measured from the map center), while the map keeps the full accumulated pin trail β only the list narrows, which keeps the header honest. A "Back to your location" chip in the list header re-anchors home instantly without discarding the panned pins (distinct from the toolbar refresh, which discards and refetches). Wikipedia summaries and thumbnails hydrate in parallel via the REST endpoints; tap a row (or pin) to enrich (full Wikidata + AI polish + match score + image) in the background and push the standard detail view, just like a scan result. Cold-start auto-retry: if the GPS first-fix didn't land in time, the view waits 1 second and tries once more before showing the empty state. Transient SPARQL failure (Wikidata timeout, retry exhaustion, task cancellation) routes to a distinctCouldn't load landmarksstate with a brand-brown "Try again" button β the empty-area copy is reserved for cases where Wikidata actually answered with zero hits. - Persistent Nearby chrome + shared empty-state house style β the List/Map switcher, search field, and "Within N miles of your location" radius stepper stay on screen in every Nearby state, including the cold-start spinner and the empty / offline / permission screens, so the search radius and view are always adjustable. Radius-header visibility keys off a state rule (
radiusHeaderVisible) rather thanuserLocation, so it shows during the first-fix spinner too; widening an empty or offline search refetches in place (toolbar spinner) instead of flashing the full-screen loader. All five Nearby states (no landmarks, couldn't-load, permission-needed, can't-find-location, no-results) and History's empty state render through one sharedBrandEmptyStatecomponent β a brand-brown SF Symbol on the left, bold title, secondary detail, and an optional.controlSize(.large)action button β matching the Scan how-it-works steps and History's "No finds yet". - Stale-while-revalidate cold-start β the most recent Nearby fetch is cached to disk (
Caches/nearby_results_cache.json) and rendered instantly on the next cold-start while a fresh SPARQL + hydration runs in the background. Pins from your last session show immediately; the fresh results swap in atomically when the stream finishes. Spatial invalidation drops the cached pins if your new GPS fix is more than one search radius from the cached center (you've moved cities). Combined with progressive rendering that hydrates and gates the closest 30 hits as a first batch before the rest, and pre-warming the GPS at app launch viaLocationManager.warmUpIfAuthorized()(no permission prompt β only fires when already authorized), the perceived cold-start time approaches zero on subsequent opens. On the very first open (fresh install β no cache and no warm fix yet, the one path that can't be masked), two things keep it from looking broken:currentLocation(withTimeout:)seeds from iOS's last-known system fix when it's inside the same 5-minute freshness window the location cache already trusts, so the SPARQL fetch can fire immediately instead of blocking on a cold first-fix (skipped on a forced refresh, which still demands a guaranteed-freshrequestLocation()); and the loading view is phase-aware β it narrates locating β searching and adds a "Still working" reassurance line after a 12-second grace period, so a genuinely slow first load reads as working rather than frozen. - Chained thumbnail resolver β articles where MediaWiki's
prop=pageimagesdoesn't have an indexed lead thumbnail get patched in two more steps: REST/page/summary/{title}(smarter "lead image" heuristic), then if that's also empty, the first gallery-worthy image from/page/media-list/{title}. The media-list step catches articles like the MiddletownβPortland Railroad Bridge whose only photo lives inline in the body. The bulk hydration inwikipediaFetchPageDetailsByTitlesruns the chain in parallel for any nil-thumbnail rows so the Nearby list/map agree with what the detail-view's carousel finds. - Hide and restore Nearby landmarks β swipe left on any Nearby row to tuck a place you've already explored into a SwiftData-backed Hidden Landmarks list. The bottom of the Nearby list surfaces a "Hidden landmarks (N)" entry that opens a sheet to swipe-restore individual items, restore in bulk via a custom green eye affordance in edit mode, or "Restore All" at once. The HiddenLandmark model snapshots title, summary, and article-image bytes at hide-time so the sheet renders the same rich row treatment as Nearby/History.
- Live search filtering β both Nearby and History have a search field under the List/Map picker. Partial-word matching against the result title via
localizedStandardContains(case-, accent-, and width-insensitive, so "chateau" matches "ChΓ’teau"); the list (and map pins) shrink in real time as you type. Keyboard "Done" toolbar + scroll-to-dismiss both clear focus. Nearby's list mode shows an explicit "No results"BrandEmptyStatewhen the search narrows to zero β map mode keeps the empty map so the user can pan-search. - Per-row parchment card pattern β Scan, Nearby, and History list rows all carry their own
Color("CardBackground")via.listRowBackground, with the first and last rows using anUnevenRoundedRectangleso only the outer corners of the card are rounded. Combined with a list-levelclipShape(RoundedRectangle)on the viewport, the parchment ends exactly at the last row (no extra below on a sparse list) AND stays rounded at the top as the first row scrolls out of view. Section-header label-and-icon HStacks (signpost.right.fill/location.fill/clock.fill+ label) all use.font(.subheadline.weight(.semibold))and.foregroundStyle(Color.accentColor)with matching 16pt-top / 8pt-bottom padding so the three section labels read identically across tabs and the gap to the list below matches Scan'sVStack(spacing: 8). - Map view in History and Nearby β every saved lookup and every nearby discovery drops as a brown signpost pin on an
MKMapView(the sharedClusteredMapView, aUIViewRepresentablethat replaces the old SwiftUIMapso pins get native clustering). The signpost glyph is a custom narrow staggered mark (SignpostMark/ a matchingUIImageinClusteredMapView), not SF Symbolsignpost.right.and.left.fill. Overlapping pins collapse into a brown numbered cluster bubble (MapClusterBubble) whose size grows with the count; tap a cluster to zoom into its members until they split into individual pins. A custom "recenter on me" button sits lower-left, resetting to the radius overview (Nearby) or all-pins fit (History) rather than the current cluster zoom. Tap a single pin for aRichMapCardβ a solid card with thumbnail, 2-line title, a distance chip + walk-time estimate, and two filled-green actions, Details and Directions; tap anywhere on the card body (or swipe up) to open the full detail view. List/Map toggle on both tabs; History fits the camera to the bounding box of all your finds, Nearby centers on you. Before the first lookup, History keeps the List/Map switcher + search visible and its Map toggle shows a map centered on your current location (~5-mile radius) instead of a blank empty state β so the switcher stays meaningful from a fresh install. - Launcher surfaces β every system entry point opens the app already doing the thing: App Shortcuts with Siri phrases ("Scan a sign with Brown Sign", "Landmarks near me in Brown Sign") via
AppShortcutsProvider, a Control Center / Lock Screen / Action button control (ControlWidgetButtoninBrownSignWidgets, the app's first widget extension), static Home Screen quick actions on the icon long-press, and abrownsign://URL scheme (scan/nearby/history). All of them funnel through one@ObservableAppRouter(tab selection plus a stored pending action, so cold-launch triggers that fire before the view tree exists aren't dropped). The scan surfaces land with the camera already presented: the consume step clears the window's presentation slot at the UIKit level first (a fullScreenCover raised while any sheet is up β even one owned by another tab β is silently dropped) and recovers a wedged flag by toggling it.ScanSignIntentis deliberately compiled into both the app and the extension: the system requires dual target membership for a control to open its app, and runs the intent'sperform()in the app process where it can drive the router. - Look Around in the detail view β a street-level
LookAroundPreview(MapKit) between the metadata card and the description whenever Apple has imagery at the landmark's coordinates, answering "is this worth pulling off for". No coverage or no coordinates renders nothing:MKLookAroundSceneRequestreturns a nil scene on success for uncovered spots (common for rural landmarks), and the fetch task re-keys on the coordinate values so coordinates that arrive from late Wikidata enrichment still produce a preview. - Live inline viewfinder leads the Scan tab β the Scan tab opens on a live
AVCaptureSessionpreview tile (ScanViewfinder+ an@Observable ScanCameraController) with corner-bracket framing chrome and a gallery / shutter / expand control row, replacing the old oversized brown hero. The shutter routes captured bytes through the same downsample β OCR β normalize pipeline; the gallery button beside it opens a photo-library picker for an existing photo (the safe road-trip flow: passenger snaps the sign, you identify it at the next stop). The expand button (and the launcher / Siri / Control-Center paths) opens the full-screenCameraView, which keeps the heavier capture controls: pinch-to-zoom (optical telephoto where the phone has one, digital otherwise) with a live zoom readout, tap-to-focus, auto flash, full-resolution capture when zoomed, and a close button. Zoom there is also exposed as a VoiceOver/Switch Control adjustable element. - On-device OCR via Vision β multi-line output sorted top-to-bottom by bounding box, with structured line-by-line prompting so Apple Intelligence can distinguish "Wadsworth Mansion" from "2 MI"
- On-device Apple Intelligence via FoundationModels:
- Cleans up noisy OCR text into a searchable landmark name
- Polishes long Wikipedia extracts into 2β3 sentence card summaries
- Scores how well a candidate matches the query (0 to 1); when that confidence is low, the result and detail cards show a short "Brown Sign isn't fully sure this is the right landmark" note so you know to check the other matches
- Location-aware search β Wikipedia geosearch within 10 km finds articles near you, merged with global text search. Exact title matches sort first, then by distance. "Wadsworth" near Middletown, CT returns the mansion down the road, not a city in Ohio.
- Smart filtering β three-pass type filter (Wikidata P31 blocklist β place-indicator whitelist β default reject) drops bands, films, food, hoaxes, people, and other non-landmarks. Title-token overlap rejects Wikipedia body-text matches ("East Haven" won't appear for "Fort Nathan Hale"). NPS fallback is also gated on title relevance.
- Operating-institution gate (Nearby second pass) β even after the SPARQL filter, NRHP-listed buildings can still house active institutions: Seymour High School in Connecticut has a heritage-designated building AND is a working high school, so SPARQL surfaces it via P1435. The gate strips these. Strict tier (schools, hospitals, fire stations, post offices, train stations) requires Wikidata P576 (closure date) β heritage designation alone is not enough. Lenient tier (churches) accepts P576 or P1435, since a recognized landmark church is typically both active and historic ("unless they are historic or a landmark"). Titles that already advertise themselves as historic ("Old Greenwich High School", "Former Hartford Hospital") skip the fetch entirely.
- Disambiguation picker β when a query matches multiple landmarks, all candidates are shown sorted by distance. Tap any alternative to swap the result card instantly.
- Directions β tap coordinates or the Directions link to open a sheet with a MapKit preview and one-tap launch into Google Maps, Waze, or Apple Maps
- NPS fallback for historic places without Wikipedia coverage β queries both
/parksand/places(NRHP-listed sites), with article images from the NPSimagesarray - Wikidata enrichment β coordinates, inception year, instance-of type label, heritage-designation flag (P1435), and dissolution year (P576) for every candidate, via exact sitelinks lookup (not fuzzy search)
- Coordinate fallback chain β sparse Wikidata stubs (e.g. Drakes Bay Oyster Company / Q17514736) have no P625 (coordinate location), which used to leave the lookup pinless on the History map even though the Wikipedia article publishes a perfectly good coord in its lead sentence. Phase-2 enrichment now fills in nil coordinates from two cheaper sources before giving up: first MediaWiki
prop=coordinates(some articles tag coords without a Wikidata claim), then a regex pass over the article extract that recognizes DMS (38Β°04'57.3"N 122Β°55'55.0"W), decimal-with-hemisphere (38.0826Β° N, 122.9319Β° W), and signed-pair (38.0826, -122.9319) formats. Structured sources first, regex last. Wikipedia-host gated so NPS extracts can't false-match. The next lookup of an existing nil-coord history row backfills it viaLandmarkLookup.upsert. - Nearby thumbnail reliability β articles that should have had a photo were occasionally rendering blank in the Nearby list and on map pins. Two paths leaked nil thumbnails into the cache: the REST
/page/summary/{title}helper was the only network call inWikipediaSearch.swiftnot going throughhttpDataWithRetry, so a single 502/503/504 during bulk Nearby hydration persisted a nil thumbnail until the spatial-invalidation cache aged out; and tap-time enrichment indownloadArticleImageWithFallbackwas only consulting the REST summary endpoint, not the full chain, so cached-nil rows for body-only-image articles never had a chance to heal. The summary call now retries on transient errors like every other helper in the file, and tap-time enrichment runs the samewikipediaResolvedThumbnailURLchain (REST summary β media-list) as the bulk hydration β so transient hiccups self-recover and a tap on a blank row pulls in the missing image. - Persistent article images β downloaded and resized during enrichment, stored locally in SwiftData. History scrolling is instant; images never disappear from transient network failures.
- Image carousel in the detail view β slot 0 always reserves the primary article image: persisted JPEG bytes when available, otherwise the article-image URL rendered through
AsyncImagewhile those bytes finish downloading. Additional gallery-worthy images from the Wikipedia article (/api/rest_v1/page/media-listfiltered by Wikipedia'sshowInGallery: trueflag, with SVGs and primary-thumbnail duplicates filtered out) populate slides 2..N and load lazily viaAsyncImageonly when the user swipes to them. Each slide has a fixed-frame placeholder so loading states don't shift the layout, and the carousel uses an explicit selection binding pinned to a stable id ("primary") so it never drifts when extras arrive before the persistent bytes do β the Nearby flow opens the detail view beforeenrichDiscoveredLandmarkhas finished downloading the image, and reserving the slot up-front prevents the visible "jump" when the bytes land. Page dots only appear when there's more than one slide. - SwiftData history β newest-first sort, grouped by day into "Today" / "Yesterday" / dated (
EEE, MMM d) headers, swipe-to-delete, Delete All in edit mode, dedupe by canonical URL, push-to-detail with full raw summary. Edit mode hides the picker / search field / "Recently viewed landmarks" header and the tab bar (Mail/Notes pattern) so the user is focused on selection. - Selectable text β titles and descriptions use UITextView (read-only) for full iOS text selection: tap, double-tap, drag handles, copy
- Share sheet β one-tap sharing of the article URL from the result card or detail view
- Source badge links β tap "Wikipedia" or "NPS" in the detail view to open the full article in Safari
- UIKit text input β the search field is a UIViewRepresentable wrapping UITextField for reliable focus, cursor placement, and text selection inside a ScrollView
- Keyboard toolbar β dismiss button (β¨β) and search button (π) built into the text field's input accessory view
- Graceful fallbacks β every external call (Wikipedia, NPS, Wikidata, Apple Intelligence, CoreLocation) fails silently to nil. The app works end-to-end even with no API key, no location permission, and no Apple Intelligence.
- Adaptive color palette β four asset-catalog colors with separate light/dark values:
AccentColorfor caption text and toolbar tinting (forest green light, muted sage dark β tuned for readable contrast on each surface),AccentButtonfor filled controls (slightly more saturated so white-on-green clears WCAG AA in dark mode),CardBackgroundfor list rows, the Scan recents card, and the detail-view metadata block (warm parchment light, warm dark dark), andBrandBrownreserved for brand identity β the 32pt Scan brand mark (the custom signpost glyph on a BrandBrown square), the gradient wash at the top of the Scan tab, the Nearby + History map pins, and placeholder thumbnails. The broad redesign sweep makes green (AccentButton) the action color: the detail view's Read full article / View full details CTAs and the map cards' Details + Directions buttons are filled green, Share is the outlined-green variant, and the detail-view Directions stays a blue text link (the link that it is). This replaces a scatter of inlineColor(red:green:blue:)literals and lighterColor.brownreferences with one canonical token. Lists hide iOS's defaultsystemGroupedBackgroundand paintCardBackgroundinstead, with.scrollContentBackground(.hidden)so swipe-action areas inherit the same surface (no seam between sliding row and page bg). Switched lists from.insetGroupedto.plainstyle with explicitlistRowInsetsso row content lines up with the picker / search field above instead of double-padding. - Unified 12pt corner-radius language β every card-class element across all three tabs renders at 12pt: Scan textfield + result card outer + alternatives panel + metadata chips + recent-finds list, Nearby + History textfields + lists + map clipShape + the
RichMapCardpin cards, primary action buttons (Snap / Look it up) and secondary action buttons (View full details / Read full article / Share). Thumbnails (LandmarkThumbnail) sit slightly tighter at 10pt β equal absolute radii don't read equal, and 12pt on a 56pt tile looks far rounder than 12pt on a screen-wide card, so 10pt is where the perceived curvature matches the rows'. CustomDisplayModeSegmentedPickerreplaces SwiftUI's.pickerStyle(.segmented)(whose innerUISegmentedControlignores.frame(height:)) so the List/Map switcher on Nearby + History matches the textfield height below it and uses the same 12pt outer radius, with a per-trait adaptive selected-pill fill so dark mode lifts the selection instead of sinking it. - Focus-driven textfield strokes β all three search fields (Scan, Nearby, History) carry a
RoundedRectangleoverlay stroke that'sColor.accentColorlineWidth 2 when focused andColor.secondary.opacity(0.35)lineWidth 1 when not, with a leading magnifying-glass icon. Driven from each field's@FocusStateso the visual state always matches keyboard ownership.
| Layer | Technology |
|---|---|
| UI | SwiftUI + UIKit (UIViewRepresentable for text input, camera, Safari) |
| Persistence | SwiftData |
| OCR | Vision (RecognizeTextRequest, async) |
| Camera | AVFoundation (multi-lens virtual device, pinch-to-zoom, full-resolution capture when zoomed, tap-to-focus, auto flash) |
| On-device LLM | FoundationModels (iOS 26+) |
| Location | CoreLocation (async wrapper with in-flight guard + timeout) |
| Maps | MapKit β MKMapView (UIViewRepresentable) for the Nearby + History maps with native annotation clustering (brown numbered cluster bubbles, tap-to-zoom-into-members, custom recenter button), plus LookAroundPreview and the directions preview + URL schemes (Google Maps, Waze, Apple Maps) |
| Launchers | AppIntents (App Shortcuts, Siri) + WidgetKit (Control Center / Lock Screen / Action button control) + UIApplicationShortcutItems (quick actions) |
| Search | Wikipedia MediaWiki API (text search, geosearch, pageimages, extracts) |
| Enrichment | Wikidata API (sitelinks entity lookup, P625/P571/P31 claims) |
| Fallback | NPS developer API (/parks + /places) |
| Article reading | SafariServices (SFSafariViewController) |
No third-party Swift packages. Stock Apple frameworks only.
- iOS 26+ (required for FoundationModels)
- iPhone 15 Pro / iPhone 16 or later for Apple Intelligence. Other iOS 26 devices still work β Apple Intelligence features fall back silently.
- Xcode 26+
- Physical device for camera. Simulator works for typed searches.
- Clone the repo and open
BrownSign/BrownSign.xcodeproj - Create
BrownSign/BrownSign/APIKeys.swift(gitignored) with:import Foundation let npsAPIKey = "YOUR_NPS_KEY"
- NPS: free β developer.nps.gov
- Missing key is handled gracefully (the NPS path returns nil)
- Verify these Info.plist / build settings are set:
NSCameraUsageDescriptionβ "Brown Sign uses the camera to read brown roadside signs and identify the landmark."NSLocationWhenInUseUsageDescriptionβ "Brown Sign uses your location to show landmarks near you and rank results by distance."LSApplicationQueriesSchemesβcomgooglemaps,wazeITSAppUsesNonExemptEncryptionβNO
- Select your development team under Signing & Capabilities
- Cmd-R onto a real device
| File | Purpose |
|---|---|
BrownSignApp.swift |
@main, SwiftData container (registers LandmarkLookup + HiddenLandmark), TabView (Scan / Nearby / History), root .task that pre-warms LocationManager so the Nearby tab doesn't pay GPS cold-radio first-fix latency |
ContentView.swift |
Scan tab β leads with the live inline viewfinder (ScanViewfinder), with a gallery / shutter / expand-to-full-screen row, plus a compact BrandBrown brand bar, text field, result card, alternatives list, recent finds card. The expand button opens the full-screen CameraView |
NearMeView.swift |
Nearby tab β discovery list + map view of brown-sign-worthy landmarks within an adjustable radius (2/5/10/25 mi, default 2; SPARQL-filtered, see WikidataLandmarkSearch.swift); stale-while-revalidate cold-start (renders the cached fetch instantly, swaps fresh results in atomically); progressive rendering of the closest 30 hits before the rest of the list; pan-to-search; swipe-to-hide; cancellation-tracked refresh task |
NearbyResultsCache.swift |
Disk-backed JSON snapshot of the most recent Nearby fetch in Caches/nearby_results_cache.json. Schema-versioned + 7-day TTL'd; NearMeView spatially invalidates when the user moves more than one search radius from the cached center. nonisolated so file IO can run off the main actor under the project's Swift 6 isolation flags |
HistoryView.swift |
History tab β list (rows grouped by day with Today / Yesterday / dated headers) and ClusteredMapView map, RichMapCard pin callout, detail view (with source badge links, selectable text). Pre-first-lookup the Map toggle shows an EmptyHistoryMapView centred on the user dot |
HiddenLandmark.swift |
SwiftData @Model for landmarks the user has hidden from Nearby (keyed on canonical page URL, snapshots title/summary/article-image) |
HiddenLandmarksView.swift |
Modal sheet listing hidden landmarks β swipe-to-restore, custom green eye edit affordance, "Restore All" with confirmation |
SearchField.swift |
Shared search-field component used by Nearby and History β magnifying-glass icon, clear button, FocusState-driven keyboard "Done" toolbar |
Components.swift |
Shared UI used by more than one tab: LandmarkDisplayMode + DisplayModeSegmentedPicker (the List/Map switcher), LandmarkThumbnail (the one square article-image/captured-photo/signpost-placeholder thumbnail every row + map card renders), RichMapCard (the solid pin-tap card β thumbnail, 2-line title, DistanceChip + walk-time, filled-green Details + Directions buttons, shared by Nearby and History), DistanceChip, the custom SignpostMark glyph shape used for map pins + the Scan brand mark, formatLandmarkDistance / formatRadius (locale-aware distance + radius strings), announceForAccessibility, and the shared review-prompt policy |
NearbyViews.swift |
Presentation subviews extracted from NearMeView β cold-start loading view, NearbyRow, NearbyMapView (wraps ClusteredMapView with programmatic-camera-move filtering so the auto-fit never reads as a user pan), radius controls, and SelectedNearbyCard (the pin-tap RichMapCard) |
ScrollViewIntrospection.swift |
Resolves the UIScrollView backing a SwiftUI List so the radius-widen half-row nudge can set an exact contentOffset |
ScanViewfinder.swift |
The Scan tab's inline live viewfinder β an @Observable ScanCameraController driving an AVCaptureSession preview with corner-bracket chrome and a gallery / shutter / expand row; pauses when the app isn't frontmost |
CameraView.swift |
Full-screen UIKit camera VC (opened from the viewfinder's expand button) with pinch-to-zoom (optical + high-res capture), zoom readout, an adjustable VoiceOver/Switch Control zoom element, tap-to-focus, capture button, close button |
SafariView.swift |
SFSafariViewController wrapper |
LandmarkTextField.swift |
UIViewRepresentable wrapping UITextField (keyboard toolbar built in) |
SelectableText.swift |
UIViewRepresentable wrapping read-only UITextView for text selection |
MapsLauncher.swift |
Directions sheet with MapKit preview + Google Maps / Waze / Apple Maps |
ClusteredMapView.swift |
Shared MKMapView (UIViewRepresentable) wrapper behind both the Nearby and History maps β native annotation clustering (clusteringIdentifier) rendered as brown numbered bubbles (MapClusterBubble), tap-a-cluster-to-zoom-into-its-members, the custom SignpostMark pin glyph (UIImage), and a custom recenter button |
OCRHelper.swift |
Vision OCR β returns [String] lines sorted top-to-bottom |
AppleIntelligence.swift |
FoundationModels β normalize (line-aware), polish, match-score |
LocationManager.swift |
CoreLocation async wrapper with in-flight guard + timeout. warmUpIfAuthorized() is called from BrownSignApp's root .task so the GPS first-fix is already populated by the time the user opens Nearby; no-op when authorization is .notDetermined so launch never pops the permission prompt. currentLocation(withTimeout:) also seeds from the system's last-known fix (manager.location) when it's inside the 5-minute freshness window, so a cold-start fetch skips the cold first-fix β skipped when bypassCache is set so a forced refresh still gets a guaranteed-fresh fix |
WikipediaSearch.swift |
Text search, geosearch, batch extracts/pageimages by page-id or title, REST summary fallback for empty intros, chained thumbnail resolver (REST summary β media-list) for articles whose only photo lives inline in the body, word-boundary truncation, REST media-list lookup for the detail-view image carousel |
WikidataSearch.swift |
Sitelinks-based entity lookup (P625 / P571 / P31 / P1435 / P576 + label resolution) and lighter historic-signals fetch for the operating-institution gate |
CoordinateFallback.swift |
Backfills LandmarkResult.coordinates when Wikidata had no P625. Tries MediaWiki prop=coordinates first, then the regex parser. Wikipedia-host gated. |
CoordinateParser.swift |
Pure-logic regex parser for DMS / decimal / signed-pair coordinate strings in article extracts. Zero project dependencies β testable standalone via the sibling CoordinateParserTests Swift Package (swift test --package-path BrownSign/CoordinateParserTests). |
WikidataLandmarkSearch.swift |
SPARQL geo-spatial query against query.wikidata.org β primary fetch for the Nearby tab. Returns only items with a heritage designation (P1435) or a curated landmark P31 type (recursive via P279*). Returns nil on transport failure (HTTP retries exhausted) and [] only on a successful empty response, so the caller can route transient WDQS hiccups to a retryable serviceUnavailable UI rather than silently rendering "No landmarks nearby" |
HTTPRetry.swift |
Shared retry helper (httpDataWithRetry) β default 3 attempts with 500 ms + 1.5 s backoff, retries on 502/503/504/429 + URL errors, honors task cancellation. Used by the SPARQL fetch and all the Wikipedia/Wikidata API calls (search, geosearch, page-detail batches, REST summary/media-list, entity claims/labels) plus the Wikimedia article-image download |
NPSSearch.swift |
/parks + /places fallback with article images |
LandmarkResult.swift |
The unified result model (Coordinate + LandmarkResult). Pure data β orchestration lives in LandmarkSearch.swift. |
LandmarkSearch.swift |
Two-phase scan orchestrator + Nearby orchestrator (SPARQL + Wikipedia hydration + operating-institution gate), type filter, title-match filter, place indicators |
LandmarkLookup.swift |
SwiftData @Model β history entries with all enrichment fields. LandmarkLookup.upsert(result:in:rawSignText:capturedThumb:) is the shared insert-or-update (keyed on canonical page URL; preserve-on-nil for enrichment fields) used by both Scan and Nearby |
UIImage+Resize.swift |
Shared resized(to:) / resized(toMaxDimension:) helpers β used by the scan flow (history thumbnails, captured-photo downscale) and the landmark image-hydration path |
APIKeys.swift |
Gitignored. Local API keys only. |
Brown Sign collects no personal data. Camera images are processed on-device. Location is used only to rank nearby results. Search queries go to Wikipedia, Wikidata, and NPS with no personal identifiers attached. No accounts, no analytics, no ads, no tracking.
Full privacy policy: docs/privacy-policy.md
MIT