Open Waters is a modern, open source, mobile-first marine navigation app built with React Native and Expo. See docs/ for feature documentation, specs, and the project vision.
Open Waters is in early alpha with limited users. There are no external consumers of internal APIs, no install base to migrate, and no release compatibility commitments.
Prefer breaking changes over backward compatibility. When refactoring or redesigning:
- Rename, move, or delete APIs freely — don't preserve old names as aliases
- Don't add deprecation shims, compatibility layers, or migration paths for internal code
- Don't keep feature flags, fallbacks, or "legacy" branches around after a change lands
- Change database schemas with a straightforward migration — don't preserve old columns or dual-write
- Update all call sites in the same change; don't leave TODOs to clean up later
A clean codebase beats a compatible one. Revisit this guidance when we have real users.
This project is built with Expo, a framework and platform for universal React applications. To learn more, look at the following resources:
- Expo documentation: Learn fundamentals, or go into advanced topics with our guides.
- Learn Expo tutorial: Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
- Node.js 20+
- Xcode 16+
npm install
cp .env.example .env.local # then fill in any keys you have (optional)
npx expo prebuild # generate native projects
npx expo run:ios.env.local is gitignored and loaded automatically by Expo CLI. Variables
prefixed with EXPO_PUBLIC_ (e.g. EXPO_PUBLIC_AISSTREAM_API_KEY) are inlined
into the JS bundle by Metro's Babel plugin and read at runtime via
process.env.EXPO_PUBLIC_*. Missing keys degrade gracefully — features that
need them stay disabled.
npm start # start Expo dev server
npm run ios # build and run on iOS
npm test # run tests
npm run lint # run eslint| Layer | Technology |
|---|---|
| Framework | React Native + Expo |
| Language | TypeScript (strict mode) |
| Navigation | expo-router (file-based routing) |
| State | Valtio with AsyncStorage persistence |
| Map Engine | MapLibre React Native v11 |
| Testing | Jest |
| Linting | ESLint (expo config) |
app/ Expo Router screens (file-based routing)
_layout.tsx Root layout (Stack navigator)
index.tsx Main screen (renders ChartView)
ViewOptions.tsx Modal for chart/unit selection
components/ React components
ui/ Shared, platform-abstracted UI primitives (Button, BottomSheet, OverlayButton)
ChartView.tsx Main map view (orchestrates all overlays)
hooks/ Valtio stores and custom hooks
styles/ Map style configs (JSON) and style index
lib/ Utility libraries (WMS/WMTS format helpers)
assets/ Images and fonts
Use @/ for all imports. Never use relative paths like ../../hooks/.
import { useCameraState } from "@/hooks/useCameraState";
import mapStyles from "@/styles";Stores are Valtio proxies holding state only. Actions are plain exported functions that mutate the proxy directly. This keeps actions callable from anywhere (components, effects, callbacks, background tasks) without needing a hook at the call site.
Each store file exports:
xyzState— the live proxy (use from imperative code, background tasks, mutators)useXyz()— a tracked snapshot hook (use from React render)
import { proxy, useSnapshot } from "valtio";
type State = {
items: Item[];
};
export const itemsState = proxy<State>({ items: [] });
export function useItems() {
return useSnapshot(itemsState);
}
// Actions: mutate the proxy directly
export async function loadItems() {
itemsState.items = await fetchItems();
}
export async function addItem(fields: ItemFields) {
const item = await insertItem(fields);
itemsState.items.unshift(item);
return item;
}Components destructure the snapshot. useSnapshot tracks each property access, so components only re-render when the fields they actually read change:
import { useItems, loadItems, addItem } from "@/hooks/useItems";
function MyComponent() {
const { items } = useItems();
useEffect(() => { loadItems(); }, []);
return <Button onPress={() => addItem({ name: "New" })} />;
}Computed values are getters on the proxy. They're tracked through their inputs:
export const trackRecordingState = proxy({
track: null as Track | null,
distance: 0,
get isRecording(): boolean {
return this.track !== null;
},
get averageSpeed(): number {
return this.distance > 0 ? this.distance / elapsed : 0;
},
});Persistence uses persistProxy from @/persistProxy, which writes the snapshot to AsyncStorage on every change and hydrates on first call. Getter-only properties are skipped automatically.
import { persistProxy } from "@/persistProxy";
import { proxy, useSnapshot } from "valtio";
export const someState = proxy<State>({ someValue: "default" });
persistProxy(someState, { name: "some-state" });
export function useSome() {
return useSnapshot(someState);
}
export function setSomeValue(value: string) {
someState.someValue = value;
}For partial persistence (only some fields) or hydration side effects, pass partialize and/or hydrate:
persistProxy(activeRouteState, {
name: "active-route",
partialize: (s) => (s.route?.id != null ? { id: s.route.id } : null),
hydrate: (state, persisted) => {
if (persisted?.id != null) loadRoute(persisted.id);
},
});Subscriptions outside React (background tasks, effects that shouldn't re-render):
import { subscribe } from "valtio";
import { subscribeKey } from "valtio/utils";
// Whole-tree subscription
const unsub = subscribe(navigationState, () => { ... });
// Single field
const unsub = subscribeKey(cameraState, "trackingMode", (next) => { ... });Database-backed lists use useDbQuery from @/hooks/useDbQuery instead of a store — the DB is the source of truth, mutations write directly via the DB layer, and useDbQuery re-fetches on table changes. See useTracks and useMarkers for examples.
Watch-outs:
- Snapshots are deeply readonly. Calling
.sort()or passing to APIs typed as mutable arrays needs a copy orReadonly<T>widening at the consumer. Object.keys/values/entrieson auseSnapshotresult can throw on Hermes with Valtio 2.x. We carry patches/valtio+2.3.1.patch viapatch-packageto remove Valtio'sObject.preventExtensions(snap)call until the upstream fix lands.- Don't pass the live proxy to native components — MapLibre's
initialViewState(and similar) freeze the input, which freezes the proxy itself. Spread to a plain object:{ ...proxyState }. - Mutating after
awaitis a race if the same code path can run concurrently. Capture state locally before the await, or serialize the work. - The proxy itself can't be reassigned. Mutate in place; expose a clear/reset action for tests.
Existing stores:
cameraState/useCameraState— follow-user mode, tracking mode (key:"camera")cameraPositionState/useCameraPosition— last viewport center/zoom (key:"camera-position")cameraViewState/useCameraView— current bearing, bounds, zoom (not persisted)themePreferenceState/useThemePreference— chart theme preference (key:"chart-theme")preferredUnitsState/usePreferredUnits— speed/distance/depth/temperature unit preferences (key:"preferred-units")navigationState/useNavigation— unified vessel position/speed/heading from device GPS or Signal K (not persisted)trackRecordingState/useTrackRecording— active recording state, computedisRecording,averageSpeed(key:"track-recording")useTracks({ order, position })/useTrack(id)/useTrackPoints(id)— DB-backed reactive queriesuseMarkers({ order, position })/useMarker(id)— DB-backed reactive queriesactiveRouteState/useActiveRoute— active route in viewing/editing/navigating modes (key:"active-route")useRoutes({ order })/useRoute(id)— DB-backed reactive querieschartStoreState/useChartStore— installed charts index, selected chart (key:"chart-store")offlinePacksState/useOfflinePacks— tile pack download state (not persisted)downloadsState/useDownloads— MBTiles download progress (not persisted)offlineSettingsState/useOfflineSettings— cache size and tile-count limits (key:"offline-settings")connectionsState/useConnections— Signal K / NMEA TCP connections (key:"connections")aisState/useAIS— AIS vessel data, keyed by MMSI (not persisted)atonState/useAtoN— Aids-to-Navigation data, keyed by ID (not persisted)sheetState/useSheets— sheet height tracking for overlay positioning (not persisted)downloadOverlayState/useDownloadOverlay— visibility flag for download region overlay (not persisted)
- PascalCase filenames for components
- camelCase with
useprefix for hooks - Extract small reusable components where possible. Example:
OverlayButtonincomponents/ui/for consistent styling of all map overlay buttons - Prefer small, focused components. If a component grows beyond 100 lines, consider breaking it up.
- Map overlays use
SafeAreaViewwith absolute positioning at screen corners - The chart is always full-screen; all UI is overlay
- Platform-dependent components should be generic and live in
components/ui/(e.g.BottomSheet,Button), then used by cross-platform components (e.g.TrackSheet) that contain the actual content and logic. This keeps platform-specific code isolated and reusable and limits duplication in app-specific features.
The app uses @expo/ui for platform-native components (SwiftUI on iOS)..
Three-tier component strategy:
- Native-first — Use
@expo/uicomponents for standard UI (buttons, forms, pickers, sheets). These automatically look and feel native on each platform. - Bridge with
RNHostView— When React Native views (e.g.Pressable, custom layouts) must live inside a native container (e.g. a bottom sheet), wrap them inRNHostViewfrom@expo/ui. Without this bridge, RN touch handlers won't receive events inside native containers. - Pure RN — For views that exist only in the RN tree (map overlays, HUD), use standard React Native components.
For examples of how to use @expo/ui, see:
Map sources are defined in styles/index.ts as an array of { id, name, style } objects. Styles can be either a URL string or an inline MapLibre style JSON object.
export default [
{ id: "noaa", name: "NOAA (RNC)", style: noaaJson },
{ id: "openseamap", name: "OpenSeaMap", style: openseamapJson },
// ...
];Use SymbolView from expo-symbols directly with SF Symbol names (e.g. "location.fill", "plus", "record.circle"). Browse available symbols in Apple's SF Symbols app.
Use toSpeed() and toDistance() from @/hooks/usePreferredUnits for all unit conversions. Internal data is always in SI/metric units (meters, meters/second); conversion happens at the display layer only.
| What | Convention | Example |
|---|---|---|
| Components | PascalCase | ChartView.tsx |
| Hooks | camelCase, use-prefix | useCameraState.tsx |
| Valtio proxies | xyzState + useXyz |
cameraState, useCameraState |
| Store actions | plain named exports | export function loadItems() |
| State types | State |
type State = { ... } |
| Storage keys | kebab-case string | "preferred-units" |
| Constants | SCREAMING_SNAKE_CASE | SPEED_THRESHOLD |
| Enums | PascalCase | NavigationState.Underway |
- Strict mode is enabled
- Define
Stateinterface for Valtio stores (actions are standalone functions, not typed on the store) - Use type guards (
'href' in props) for polymorphic components - Use
fontVariant: ['tabular-nums']for any dynamically changing numeric display - Avoid
ascasts where possible; prefer proper type definitions - Avoid
anytype; if necessary, isolate it to a single utility function
Navigation software has safety implications — a wrong calculation or rendering bug can put people in danger. The testing approach prioritizes correctness of critical logic and the ability to reproduce real-world scenarios without requiring a boat.
- What to test: Navigation math, unit conversions, API response parsing, state management logic
- What not to test: Map rendering (native OpenGL), GPS accuracy, full E2E flows
- Fixtures: Use saved API responses as JSON fixtures for deterministic tests
Run tests:
npm test- The chart is the app. Full-screen map. Everything else is an overlay or sheet.
- Progressive disclosure. Simple by default, detailed on demand.
- Readable at arm's length. Minimum 15pt text. Bold for critical values. High contrast.
- Respect the platform. Native components for standard UI (
@expo/ui). Custom only for chart overlays. - Offline-first. Every feature works without connectivity. Online is an enhancement.
All contributors must sign the Contributor License Agreement before their pull request can be merged. The CLA grants Open Water Software, LLC the rights needed to distribute your contributions (including through the iOS App Store) while you retain full copyright ownership of your work.
You will be prompted to sign the CLA automatically when you open your first pull request.
This is navigation software. Incorrect calculations or rendering bugs can put people in danger.
- Navigation math (bearing, distance, course) must be tested with known-good reference values
- Unit conversions get tests for every function
- Depth alarms must trigger at correct thresholds across all unit configurations
- Night mode must not leak bright white/blue elements (destroys night vision)
- The app includes a "not for primary navigation" disclaimer