feat: Phase 4 — MapLibre route map + SVG elevation chart on activity screen
- Add /api/activity/{id}/geojson and /api/activity/{id}/timeseries endpoints
(bearer-token-gated, falls back from _merged to raw activities dir)
- Rewrite activity detail screen with MapLibreGL v11 API (Map, Camera,
GeoJSONSource, Layer) and react-native-svg area chart with gradient fill
- On-demand fetch for remote activities that have no local geojson/timeseries
- Add react-native-svg dependency; requires dev build (npx expo run:android)
This commit is contained in:
@@ -455,6 +455,38 @@ async def stats() -> JSONResponse:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/activity/{activity_id}/geojson")
|
||||||
|
async def get_activity_geojson(
|
||||||
|
activity_id: str,
|
||||||
|
user: User = Depends(_require_auth),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Return GeoJSON track for an activity (mobile detail screen)."""
|
||||||
|
_check_id(activity_id)
|
||||||
|
dd = _get_data_dir()
|
||||||
|
user_dir = dd / user.handle
|
||||||
|
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
|
||||||
|
p = base / f"{activity_id}.geojson"
|
||||||
|
if p.exists():
|
||||||
|
return JSONResponse(json.loads(p.read_text()))
|
||||||
|
raise HTTPException(404, "GeoJSON not found")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/activity/{activity_id}/timeseries")
|
||||||
|
async def get_activity_timeseries(
|
||||||
|
activity_id: str,
|
||||||
|
user: User = Depends(_require_auth),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Return timeseries for an activity (mobile detail screen)."""
|
||||||
|
_check_id(activity_id)
|
||||||
|
dd = _get_data_dir()
|
||||||
|
user_dir = dd / user.handle
|
||||||
|
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
|
||||||
|
p = base / f"{activity_id}.timeseries.json"
|
||||||
|
if p.exists():
|
||||||
|
return JSONResponse(json.loads(p.read_text()))
|
||||||
|
raise HTTPException(404, "Timeseries not found")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/wheel/version")
|
@app.get("/api/wheel/version")
|
||||||
async def wheel_version() -> JSONResponse:
|
async def wheel_version() -> JSONResponse:
|
||||||
"""Public endpoint: current bincio wheel version for mobile app update checks."""
|
"""Public endpoint: current bincio wheel version for mobile app update checks."""
|
||||||
|
|||||||
+85
-65
@@ -76,11 +76,13 @@ instantly via Metro (the JS bundler) — no rebuild needed.
|
|||||||
| Code changes | instant (Metro) | instant (Metro) |
|
| Code changes | instant (Metro) | instant (Metro) |
|
||||||
| Native changes | need new Expo Go release | rebuild APK |
|
| Native changes | need new Expo Go release | rebuild APK |
|
||||||
|
|
||||||
**Phase 0** only uses built-in Expo modules — Expo Go works. **Phase 1** (Pyodide)
|
**Phase 0 and 0.5** only use built-in Expo modules — Expo Go works. **Phase 1**
|
||||||
requires a Development Build because `react-native-webview` is a native module.
|
(Pyodide) and **Phase 4** (MapLibre maps) require a Development Build because
|
||||||
|
`react-native-webview` and `@maplibre/maplibre-react-native` are native modules.
|
||||||
|
|
||||||
The recommended setup from the start is a Development Build so you never hit a wall
|
The preferred path for Phase 1+: connect the phone via USB and run
|
||||||
mid-phase.
|
`npx expo run:android` once. After that, JS changes still update instantly via Metro
|
||||||
|
— no rebuild needed unless you change native code.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -147,25 +149,17 @@ reflect instantly without rebuilding.
|
|||||||
For the emulator, create an AVD in Android Studio with API 33+ and start it before
|
For the emulator, create an AVD in Android Studio with API 33+ and start it before
|
||||||
running the command.
|
running the command.
|
||||||
|
|
||||||
#### Option B: EAS Build (cloud, no Android Studio required)
|
#### Option B: local EAS build (no Android Studio, no external cloud)
|
||||||
|
|
||||||
EAS (Expo Application Services) builds the APK in the cloud. You get a download
|
`eas build --local` runs the entire build pipeline on your own machine (or VPS):
|
||||||
link; install it on your device once.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g eas-cli
|
npm install -g eas-cli
|
||||||
eas login # Expo account needed
|
eas build -p android --profile development --local
|
||||||
eas build -p android --profile development
|
|
||||||
```
|
```
|
||||||
|
|
||||||
After install, start Metro locally:
|
This produces an `.apk` you can transfer to the device via any means (USB, VPS
|
||||||
|
download link, AirDrop). No Expo account or cloud service required.
|
||||||
```bash
|
|
||||||
cd mobile
|
|
||||||
npx expo start --dev-client
|
|
||||||
```
|
|
||||||
|
|
||||||
Shake the device to open the dev menu and enter the Metro URL if needed.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -520,59 +514,60 @@ The token is stored in the `settings` table and sent as
|
|||||||
|
|
||||||
## Implementation plan
|
## Implementation plan
|
||||||
|
|
||||||
### Phase 0 — Foundation
|
### Phase 0 — Foundation ✅
|
||||||
*Goal: app launches, settings can be configured, a BAS JSON file can be picked
|
*Goal: app launches, settings can be configured, a BAS JSON file can be picked
|
||||||
and displayed as an activity card. No extraction yet.*
|
and displayed as an activity card. No extraction yet.*
|
||||||
|
|
||||||
**`mobile/` scaffold:**
|
|
||||||
- `expo init mobile --template expo-template-blank-typescript`
|
|
||||||
- Expo Router with three tabs: **Feed**, **Import**, **Settings**
|
- Expo Router with three tabs: **Feed**, **Import**, **Settings**
|
||||||
- `expo-sqlite` initialised; `activities` and `settings` tables created on first launch
|
- `expo-sqlite` initialised; `activities` and `settings` tables created on first launch
|
||||||
- Settings screen: instance URL and handle fields, saved to `settings` table
|
- Settings screen: instance URL and handle, saved to `settings` table
|
||||||
|
- Import screen: `expo-document-picker`; BAS `.json` files parsed and inserted into feed
|
||||||
|
- Feed screen: activity cards sorted by `started_at`, sport icon, distance, elevation
|
||||||
|
- `GET /api/wheel/version` server endpoint (public, no auth)
|
||||||
|
|
||||||
**Import screen (stub):**
|
**Done when:** App launches, user picks a `.json` BAS file, sees it in the Feed. ✅
|
||||||
- `expo-document-picker` for `.fit`, `.gpx`, `.tcx`, `.json` files
|
|
||||||
- If a `.json` file is picked: parse as BAS detail, insert into `activities` (no timeseries), show in feed
|
|
||||||
- This lets the feed work before Pyodide is wired up
|
|
||||||
|
|
||||||
**Feed screen:**
|
|
||||||
- List of activities from `activities` table, sorted by `started_at`
|
|
||||||
- Each card: sport icon, title, date, distance, elevation
|
|
||||||
|
|
||||||
**Server (one small addition):**
|
|
||||||
- `GET /api/wheel/version` → `{ "version": "0.1.0", "url": "/bincio-0.1.0-py3-none-any.whl" }`
|
|
||||||
- No auth required; the wheel itself is already public
|
|
||||||
|
|
||||||
**Done when:** App launches on a phone, user enters instance URL and handle in
|
|
||||||
Settings, picks a `.json` BAS file, sees it in the Feed.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 1 — Import via Pyodide
|
### Phase 0.5 — Remote feed sync ✅
|
||||||
*Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s, see the full ride
|
*Goal: pull all activities from a remote bincio instance into the local feed.*
|
||||||
with map and chart.*
|
|
||||||
|
- `POST /api/auth/token` — password login returning a Bearer token (stored in
|
||||||
|
SQLite; password forgotten immediately after)
|
||||||
|
- `GET /api/feed` — auth-gated; reads `_merged/index.json` shards and returns
|
||||||
|
all activity summaries as JSON
|
||||||
|
- Settings screen: Connect section (password field + Connect button + status)
|
||||||
|
- Feed screen: **↓ Sync** button and pull-to-refresh; "cloud" badge on remote
|
||||||
|
activities; `syncFeed()` upserts remote summaries without overwriting local imports
|
||||||
|
|
||||||
|
**Done when:** Tap Connect, tap Sync, all instance activities appear in the Feed. ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1 — Local FIT/GPX/TCX extraction via Pyodide
|
||||||
|
*Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s.*
|
||||||
|
|
||||||
|
**Requires a Development Build** (`npx expo run:android` via USB, or
|
||||||
|
`eas build --local`). Expo Go does not support `react-native-webview`.
|
||||||
|
|
||||||
**Extraction engine (`mobile/extraction/`):**
|
**Extraction engine (`mobile/extraction/`):**
|
||||||
- `PyodideWebView.tsx` — hidden `WebView` rendering an inline HTML page that
|
- `PyodideWebView.tsx` — hidden `WebView` rendering an inline HTML page that
|
||||||
bootstraps Pyodide
|
bootstraps Pyodide
|
||||||
- `wheelCache.ts` — on startup, `GET /api/wheel/version`; if version changed,
|
- `wheelCache.ts` — on startup, `GET /api/wheel/version`; if version changed,
|
||||||
download and store wheel in `expo-file-system` app directory
|
download and store wheel in `expo-file-system` app directory; falls back to
|
||||||
|
bundled `assets/bincio.whl` for offline / pre-deploy use
|
||||||
- `extractActivity.ts` — encodes file bytes as base64, sends via `postMessage`,
|
- `extractActivity.ts` — encodes file bytes as base64, sends via `postMessage`,
|
||||||
awaits `{ detail, timeseries, geojson }` response
|
awaits `{ detail, timeseries, geojson }` response
|
||||||
- Loading state: "Warming up extractor…" shown only on very first use
|
- Loading state: "Warming up extractor…" shown only on very first use
|
||||||
|
|
||||||
**Import screen (full):**
|
**Import screen (full):**
|
||||||
- Picks FIT/GPX/TCX, passes to `extractActivity`, stores in SQLite
|
- Picks FIT/GPX/TCX, passes to `extractActivity`, stores result in SQLite
|
||||||
|
(`detail_json`, `timeseries_json`, `geojson` columns)
|
||||||
- Copies original file to `{documentDirectory}/originals/{source_hash}.{ext}`
|
- Copies original file to `{documentDirectory}/originals/{source_hash}.{ext}`
|
||||||
- Duplicate detection via `source_hash` before extraction
|
- Duplicate detection via `source_hash` before extraction
|
||||||
|
|
||||||
**Activity detail screen:**
|
**Done when:** Pick a FIT file from the Karoo rides folder, see full stats in
|
||||||
- Stats grid: distance, moving time, elevation gain/loss, avg speed, avg HR, avg power
|
the Feed within ~5 s, including map and elevation profile.
|
||||||
- Map: MapLibre React Native with the GeoJSON track overlaid
|
|
||||||
- Elevation chart: simple SVG line chart from timeseries data
|
|
||||||
|
|
||||||
**Done when:** Drop a FIT file from a Karoo onto the phone, see the full ride
|
|
||||||
stats, map, and elevation profile within ~5 s.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -596,35 +591,60 @@ Bincio within 5 minutes of connecting to WiFi, with no manual action.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3 — Sync
|
### Phase 3 — Push sync
|
||||||
*Goal: activities recorded on the phone appear on bincio.org after one tap.*
|
*Goal: locally imported activities appear on the remote instance after one tap.*
|
||||||
|
|
||||||
**Server additions:**
|
Auth (Bearer token + Connect UI) is already done in Phase 0.5. Remaining work:
|
||||||
- `POST /api/auth/token` — password login returning a Bearer token (long-lived,
|
|
||||||
stored securely; complements existing cookie auth, does not replace it)
|
**Server:**
|
||||||
|
- `POST /api/upload` accepting a raw FIT/GPX/TCX file with Bearer token auth —
|
||||||
|
same as the existing web upload endpoint but token-gated
|
||||||
|
|
||||||
**App:**
|
**App:**
|
||||||
- Login screen: instance URL + handle + password → stores token
|
- Push button (Settings or Feed header): iterates unsynced local activities
|
||||||
- Sync screen: last sync time, unsynced count, **Push** and **Pull** buttons
|
(`synced_at IS NULL AND origin = 'local'`), uploads original file, marks synced
|
||||||
- Push: iterates unsynced local activities, `POST /api/upload` with original file
|
- Progress indicator per activity; useful for first push with many files
|
||||||
- Pull: fetches `index.json`, downloads missing activities, inserts as `origin = 'remote'`
|
|
||||||
- Progress indicator per activity (useful for first sync with many files)
|
|
||||||
|
|
||||||
**Done when:** Tap **Push**, activities appear on bincio.org with correct stats.
|
**Done when:** Tap **Push**, locally imported activities appear on bincio.org.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4 — Polish *(ongoing)*
|
### Phase 4 — Activity detail: map + elevation chart
|
||||||
|
*Goal: every activity shows a route map and elevation profile.*
|
||||||
|
|
||||||
|
**Requires a Development Build** — `@maplibre/maplibre-react-native` is a native
|
||||||
|
module. Same dev build used for Phase 1 covers Phase 4.
|
||||||
|
|
||||||
|
**Data strategy (on-demand fetch for remote activities):**
|
||||||
|
- Local activities (Phase 1 imports): `geojson` and `timeseries_json` stored in
|
||||||
|
SQLite — map and chart render immediately, no network needed
|
||||||
|
- Remote activities (Phase 0.5 synced): detail screen fetches
|
||||||
|
`GET /api/activity/{id}/geojson` and `GET /api/activity/{id}/timeseries`
|
||||||
|
on first open; both are Bearer-token-gated FastAPI endpoints
|
||||||
|
|
||||||
|
**Server additions:**
|
||||||
|
- `GET /api/activity/{id}/geojson` — reads `_merged/activities/{id}.geojson`
|
||||||
|
- `GET /api/activity/{id}/timeseries` — reads `activities/{id}.timeseries.json`
|
||||||
|
|
||||||
|
**App:**
|
||||||
|
- `@maplibre/maplibre-react-native`: route drawn as GeoJSON LineLayer over a
|
||||||
|
dark CartoDB base map; camera auto-fits track bounding box
|
||||||
|
- `react-native-svg`: elevation area chart from `elevation_m` + `t` arrays;
|
||||||
|
downsampled to ≤300 points; shows min/max elevation labels
|
||||||
|
|
||||||
|
**Done when:** Open any synced or locally imported activity — map and elevation
|
||||||
|
profile are visible within 1 s (local) or after one network round-trip (remote).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5 — Polish *(ongoing)*
|
||||||
|
|
||||||
- **Offline map tiles** — bundle or download an MBTiles file for a region;
|
- **Offline map tiles** — bundle or download an MBTiles file for a region;
|
||||||
MapLibre supports offline tile sources
|
MapLibre supports offline tile sources
|
||||||
- **Batch import** — pick a folder (Strava export, Garmin bulk export); import all
|
- **Batch import** — pick a folder (Strava export, Garmin bulk export); import all
|
||||||
FIT/GPX files found, with progress bar and per-file status
|
FIT/GPX files found, with progress bar and per-file status
|
||||||
- **Share sheet** — on Android, intent filter for incoming `.fit`/`.gpx`/`.tcx`
|
- **Share sheet** — Android intent filter for incoming `.fit`/`.gpx`/`.tcx` files
|
||||||
files from other apps; on iOS, Share Extension already set up in Phase 2
|
- **Re-extract** — button to re-run Pyodide extraction from the stored original file
|
||||||
- **Home screen widget** — last activity summary or weekly km total
|
|
||||||
- **Re-extract** — button in activity detail to re-run Pyodide extraction from
|
|
||||||
the stored original file (picks up algorithm improvements)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+207
-74
@@ -1,11 +1,64 @@
|
|||||||
|
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { useEffect, useState } from 'react';
|
||||||
import { useActivity } from '@/db/queries';
|
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
|
||||||
|
import { useActivity, useSetting } from '@/db/queries';
|
||||||
|
|
||||||
|
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Timeseries = {
|
||||||
|
t: number[];
|
||||||
|
elevation_m: (number | null)[];
|
||||||
|
lat?: (number | null)[] | null;
|
||||||
|
lon?: (number | null)[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Screen ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ActivityScreen() {
|
export default function ActivityScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const row = useActivity(id);
|
const row = useActivity(id);
|
||||||
|
const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? '';
|
||||||
|
const token = useSetting('api_token') ?? '';
|
||||||
|
|
||||||
|
const [geojson, setGeojson] = useState<object | null>(null);
|
||||||
|
const [timeseries, setTimeseries] = useState<Timeseries | null>(null);
|
||||||
|
const [loadingMap, setLoadingMap] = useState(false);
|
||||||
|
const [loadingChart, setLoadingChart] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
if (row.geojson) {
|
||||||
|
setGeojson(JSON.parse(row.geojson));
|
||||||
|
} else if (row.origin === 'remote' && instanceUrl && token) {
|
||||||
|
setLoadingMap(true);
|
||||||
|
fetch(`${instanceUrl}/api/activity/${row.id}/geojson`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data) setGeojson(data); })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoadingMap(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.timeseries_json) {
|
||||||
|
setTimeseries(JSON.parse(row.timeseries_json));
|
||||||
|
} else if (row.origin === 'remote' && instanceUrl && token) {
|
||||||
|
setLoadingChart(true);
|
||||||
|
fetch(`${instanceUrl}/api/activity/${row.id}/timeseries`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data) setTimeseries(data); })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoadingChart(false));
|
||||||
|
}
|
||||||
|
}, [row?.id]);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return (
|
return (
|
||||||
@@ -16,28 +69,13 @@ export default function ActivityScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detail = JSON.parse(row.detail_json);
|
const detail = JSON.parse(row.detail_json);
|
||||||
const km = detail.distance_m != null
|
const km = detail.distance_m != null ? (detail.distance_m / 1000).toFixed(2) : null;
|
||||||
? (detail.distance_m / 1000).toFixed(2)
|
const elev = detail.elevation_gain_m != null ? Math.round(detail.elevation_gain_m) : null;
|
||||||
: null;
|
const elevLoss = detail.elevation_loss_m != null ? Math.round(Math.abs(detail.elevation_loss_m)) : null;
|
||||||
const elev = detail.elevation_gain_m != null
|
const movingTime = detail.moving_time_s != null ? formatDuration(detail.moving_time_s) : null;
|
||||||
? Math.round(detail.elevation_gain_m)
|
const speed = detail.avg_speed_kmh != null ? detail.avg_speed_kmh.toFixed(1) : null;
|
||||||
: null;
|
const hr = detail.avg_hr_bpm != null ? Math.round(detail.avg_hr_bpm) : null;
|
||||||
const elevLoss = detail.elevation_loss_m != null
|
const power = detail.avg_power_w != null ? Math.round(detail.avg_power_w) : null;
|
||||||
? Math.round(Math.abs(detail.elevation_loss_m))
|
|
||||||
: null;
|
|
||||||
const movingTime = detail.moving_time_s != null
|
|
||||||
? formatDuration(detail.moving_time_s)
|
|
||||||
: null;
|
|
||||||
const speed = detail.avg_speed_kmh != null
|
|
||||||
? detail.avg_speed_kmh.toFixed(1)
|
|
||||||
: null;
|
|
||||||
const hr = detail.avg_hr_bpm != null
|
|
||||||
? Math.round(detail.avg_hr_bpm)
|
|
||||||
: null;
|
|
||||||
const power = detail.avg_power_w != null
|
|
||||||
? Math.round(detail.avg_power_w)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const date = new Date(detail.started_at).toLocaleDateString(undefined, {
|
const date = new Date(detail.started_at).toLocaleDateString(undefined, {
|
||||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||||
});
|
});
|
||||||
@@ -52,29 +90,24 @@ export default function ActivityScreen() {
|
|||||||
<Text style={styles.title}>{detail.title}</Text>
|
<Text style={styles.title}>{detail.title}</Text>
|
||||||
<Text style={styles.date}>{date}</Text>
|
<Text style={styles.date}>{date}</Text>
|
||||||
|
|
||||||
{/* Map placeholder — Phase 1 */}
|
{/* Map */}
|
||||||
<View style={styles.mapPlaceholder}>
|
<RouteMap geojson={geojson} loading={loadingMap} />
|
||||||
<Text style={styles.mapPlaceholderText}>Map · Phase 1</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Stats grid */}
|
{/* Stats grid */}
|
||||||
<View style={styles.grid}>
|
<View style={styles.grid}>
|
||||||
{km && <StatCell label="Distance" value={km} unit="km" />}
|
{km && <StatCell label="Distance" value={km} unit="km" />}
|
||||||
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
|
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
|
||||||
{elev != null && <StatCell label="Elevation gain" value={String(elev)} unit="m" />}
|
{elev != null && <StatCell label="Elev gain" value={String(elev)} unit="m" />}
|
||||||
{elevLoss != null && <StatCell label="Elevation loss" value={String(elevLoss)} unit="m" />}
|
{elevLoss != null && <StatCell label="Elev loss" value={String(elevLoss)} unit="m" />}
|
||||||
{speed && <StatCell label="Avg speed" value={speed} unit="km/h"/>}
|
{speed && <StatCell label="Avg speed" value={speed} unit="km/h"/>}
|
||||||
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
|
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
|
||||||
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
|
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Elevation chart placeholder — Phase 1 */}
|
{/* Elevation chart */}
|
||||||
{row.timeseries_json && (
|
<ElevationChart timeseries={timeseries} loading={loadingChart} />
|
||||||
<View style={styles.chartPlaceholder}>
|
|
||||||
<Text style={styles.mapPlaceholderText}>Elevation chart · Phase 1</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
<View style={styles.meta}>
|
<View style={styles.meta}>
|
||||||
<MetaRow label="Source" value={detail.source ?? '—'} />
|
<MetaRow label="Source" value={detail.source ?? '—'} />
|
||||||
<MetaRow label="Device" value={detail.device ?? '—'} />
|
<MetaRow label="Device" value={detail.device ?? '—'} />
|
||||||
@@ -85,6 +118,133 @@ export default function ActivityScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Map ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RouteMap({ geojson, loading }: { geojson: object | null; loading: boolean }) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.mapPlaceholder}>
|
||||||
|
<ActivityIndicator color="#60a5fa" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!geojson) return null;
|
||||||
|
|
||||||
|
const bounds = geoJsonBounds(geojson);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.mapContainer}>
|
||||||
|
<Map
|
||||||
|
style={styles.map}
|
||||||
|
mapStyle={MAP_STYLE}
|
||||||
|
dragPan={false}
|
||||||
|
touchZoom={false}
|
||||||
|
touchPitch={false}
|
||||||
|
touchRotate={false}
|
||||||
|
>
|
||||||
|
{bounds && (
|
||||||
|
<Camera
|
||||||
|
initialViewState={{
|
||||||
|
bounds,
|
||||||
|
padding: { top: 24, bottom: 24, left: 24, right: 24 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
|
||||||
|
<Layer
|
||||||
|
type="line"
|
||||||
|
id="route-line"
|
||||||
|
paint={{ 'line-color': '#60a5fa', 'line-width': 3 }}
|
||||||
|
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
|
||||||
|
/>
|
||||||
|
</GeoJSONSource>
|
||||||
|
</Map>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Elevation chart ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ElevationChart({ timeseries, loading }: { timeseries: Timeseries | null; loading: boolean }) {
|
||||||
|
const W = 340;
|
||||||
|
const H = 100;
|
||||||
|
const PAD = 4;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.chartPlaceholder}>
|
||||||
|
<ActivityIndicator color="#60a5fa" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!timeseries) return null;
|
||||||
|
|
||||||
|
const raw = timeseries.elevation_m;
|
||||||
|
if (!raw || raw.length < 2) return null;
|
||||||
|
|
||||||
|
// Downsample to ≤300 points
|
||||||
|
const step = Math.max(1, Math.floor(raw.length / 300));
|
||||||
|
const times = timeseries.t.filter((_, i) => i % step === 0);
|
||||||
|
const eles = raw.filter((_, i) => i % step === 0).map(v => v ?? 0);
|
||||||
|
|
||||||
|
const minE = Math.min(...eles);
|
||||||
|
const maxE = Math.max(...eles);
|
||||||
|
const range = maxE - minE || 1;
|
||||||
|
const maxT = times[times.length - 1] || 1;
|
||||||
|
|
||||||
|
const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2);
|
||||||
|
const y = (e: number) => PAD + (1 - (e - minE) / range) * (H - PAD * 2);
|
||||||
|
|
||||||
|
const pts = times.map((t, i) => `${x(t).toFixed(1)},${y(eles[i]).toFixed(1)}`);
|
||||||
|
const linePath = `M ${pts.join(' L ')}`;
|
||||||
|
const areaPath = `M ${x(times[0])},${H} L ${pts.join(' L ')} L ${x(maxT)},${H} Z`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<Text style={styles.chartLabel}>{Math.round(maxE)} m</Text>
|
||||||
|
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
<Defs>
|
||||||
|
<LinearGradient id="grad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<Stop offset="0" stopColor="#60a5fa" stopOpacity="0.35" />
|
||||||
|
<Stop offset="1" stopColor="#60a5fa" stopOpacity="0.02" />
|
||||||
|
</LinearGradient>
|
||||||
|
</Defs>
|
||||||
|
<Path d={areaPath} fill="url(#grad)" />
|
||||||
|
<Path d={linePath} fill="none" stroke="#60a5fa" strokeWidth="1.5" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
<Text style={styles.chartLabel}>{Math.round(minE)} m</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Returns [west, south, east, north] per LngLatBounds spec
|
||||||
|
function geoJsonBounds(gj: object): [number, number, number, number] | null {
|
||||||
|
const coords: [number, number][] = [];
|
||||||
|
function collect(obj: unknown) {
|
||||||
|
if (!obj || typeof obj !== 'object') return;
|
||||||
|
const o = obj as Record<string, unknown>;
|
||||||
|
if (o.type === 'Feature') { collect(o.geometry); return; }
|
||||||
|
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
|
||||||
|
if (o.type === 'LineString') { coords.push(...(o.coordinates as [number, number][])); return; }
|
||||||
|
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => coords.push(...c)); return; }
|
||||||
|
}
|
||||||
|
collect(gj);
|
||||||
|
if (!coords.length) return null;
|
||||||
|
const lons = coords.map(c => c[0]);
|
||||||
|
const lats = coords.map(c => c[1]);
|
||||||
|
return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
|
function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.statCell}>
|
<View style={styles.statCell}>
|
||||||
@@ -106,13 +266,7 @@ function MetaRow({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
const s = seconds % 60;
|
|
||||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
||||||
return `${m}:${String(s).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#09090b' },
|
container: { flex: 1, backgroundColor: '#09090b' },
|
||||||
@@ -124,41 +278,20 @@ const styles = StyleSheet.create({
|
|||||||
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
|
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
|
||||||
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 },
|
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 },
|
||||||
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
|
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
|
||||||
mapPlaceholder: {
|
mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' },
|
||||||
height: 200, backgroundColor: '#18181b',
|
map: { flex: 1 },
|
||||||
alignItems: 'center', justifyContent: 'center',
|
mapPlaceholder: { height: 220, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a', marginBottom: 16 },
|
||||||
borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a',
|
chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 12, alignItems: 'flex-start' },
|
||||||
marginBottom: 16,
|
chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 },
|
||||||
},
|
chartLabel: { color: '#3f3f46', fontSize: 10, marginBottom: 2 },
|
||||||
chartPlaceholder: {
|
grid: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, gap: 8, marginBottom: 16 },
|
||||||
height: 120, backgroundColor: '#18181b',
|
statCell: { backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 14, width: '47%' },
|
||||||
alignItems: 'center', justifyContent: 'center',
|
|
||||||
borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
mapPlaceholderText: { color: '#3f3f46', fontSize: 13 },
|
|
||||||
grid: {
|
|
||||||
flexDirection: 'row', flexWrap: 'wrap',
|
|
||||||
paddingHorizontal: 12, gap: 8, marginBottom: 16,
|
|
||||||
},
|
|
||||||
statCell: {
|
|
||||||
backgroundColor: '#18181b', borderRadius: 10,
|
|
||||||
borderWidth: 1, borderColor: '#27272a',
|
|
||||||
padding: 14, width: '47%',
|
|
||||||
},
|
|
||||||
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
|
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
|
||||||
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
|
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
|
||||||
statUnit: { color: '#71717a', fontSize: 13 },
|
statUnit: { color: '#71717a', fontSize: 13 },
|
||||||
statLabel: { color: '#71717a', fontSize: 12 },
|
statLabel: { color: '#71717a', fontSize: 12 },
|
||||||
meta: {
|
meta: { marginHorizontal: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a' },
|
||||||
marginHorizontal: 16, backgroundColor: '#18181b',
|
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||||
borderRadius: 10, borderWidth: 1, borderColor: '#27272a',
|
|
||||||
},
|
|
||||||
metaRow: {
|
|
||||||
flexDirection: 'row', justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 14, paddingVertical: 10,
|
|
||||||
borderBottomWidth: 1, borderBottomColor: '#27272a',
|
|
||||||
},
|
|
||||||
metaLabel: { color: '#71717a', fontSize: 13 },
|
metaLabel: { color: '#71717a', fontSize: 13 },
|
||||||
metaValue: { color: '#a1a1aa', fontSize: 13 },
|
metaValue: { color: '#a1a1aa', fontSize: 13 },
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+157
@@ -26,6 +26,7 @@
|
|||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "~15.8.0",
|
||||||
"react-native-webview": "13.15.0"
|
"react-native-webview": "13.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3555,6 +3556,12 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/bplist-creator": {
|
"node_modules/bplist-creator": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
||||||
@@ -4046,6 +4053,56 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.0.14",
|
||||||
|
"source-map": "^0.6.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-tree/node_modules/source-map": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -4186,6 +4243,61 @@
|
|||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.4.7",
|
"version": "16.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||||
@@ -4254,6 +4366,18 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-editor": {
|
"node_modules/env-editor": {
|
||||||
"version": "0.4.2",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
|
||||||
@@ -6477,6 +6601,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||||
|
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/memoize-one": {
|
"node_modules/memoize-one": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
@@ -7007,6 +7137,18 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nullthrows": {
|
"node_modules/nullthrows": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
||||||
@@ -7768,6 +7910,21 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-svg": {
|
||||||
|
"version": "15.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.8.0.tgz",
|
||||||
|
"integrity": "sha512-KHJzKpgOjwj1qeZzsBjxNdoIgv2zNCO9fVcoq2TEhTRsVV5DGTZ9JzUZwybd7q4giT/H3RdtqC3u44dWdO0Ffw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-tree": "^1.1.3",
|
||||||
|
"warn-once": "0.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-webview": {
|
"node_modules/react-native-webview": {
|
||||||
"version": "13.15.0",
|
"version": "13.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "~15.8.0",
|
||||||
"react-native-webview": "13.15.0",
|
"react-native-webview": "13.15.0",
|
||||||
"@maplibre/maplibre-react-native": "~11.0.0"
|
"@maplibre/maplibre-react-native": "~11.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user