Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 113d20efe1 | |||
| 43aae4e133 | |||
| 59ccc13836 | |||
| cf00b5cea4 | |||
| 93ba79bfa7 | |||
| 9914bd5276 | |||
| c366f8f9e8 | |||
| 4091f3a44f | |||
| 609ddb47a9 | |||
| 8c05c8ad4b | |||
| 9a4b587853 | |||
| 150f23d7a9 | |||
| 8a25474770 | |||
| 47928ce9f2 | |||
| ec49fef2f3 | |||
| c12e4cfcde | |||
| 751dc4426c | |||
| e3c1ffe0f1 | |||
| 31970b86fc |
76 changed files with 8271 additions and 29 deletions
26
.claude/settings.local.json
Normal file
26
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(cat /home/jo/lab/alerte-secours/apps/as-app/src/biz/*.js)",
|
||||||
|
"Bash(npm install)",
|
||||||
|
"Bash(node csv-to-sqlite.mjs --input ../.data/geodae.csv --output ../src/assets/db/geodae.db)",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT count\\(*\\) FROM defibs; SELECT * FROM defibs LIMIT 3; SELECT count\\(DISTINCT h3\\) FROM defibs;\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT id, nom, latitude, longitude FROM defibs WHERE h3 = ''881fb542d3fffff'' LIMIT 5;\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT h3, count\\(*\\) as cnt FROM defibs GROUP BY h3 ORDER BY cnt DESC LIMIT 5;\")",
|
||||||
|
"Bash(pnpm --version)",
|
||||||
|
"Bash(find /home/jo/lab/alerte-secours/apps/as-app/src/scenes/AlertCur* -type f -name \"*.js\" -o -name \"*.jsx\")",
|
||||||
|
"Bash(find /home/jo/lab/alerte-secours/apps/as-app/src/scenes/SendAlert* -type f -name \"*.js\" -o -name \"*.jsx\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires != '''' ORDER BY horaires LIMIT 80;\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires <> '''' ORDER BY horaires LIMIT 80;\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT count\\(*\\) FROM defibs WHERE horaires <> ''''; SELECT count\\(*\\) FROM defibs WHERE horaires = '''';\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT count\\(DISTINCT horaires\\) FROM defibs WHERE horaires <> '''';\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT horaires, count\\(*\\) as cnt FROM defibs WHERE horaires <> '''' GROUP BY horaires ORDER BY cnt DESC LIMIT 30;\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires <> '''' ORDER BY horaires LIMIT 80 OFFSET 80;\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires LIKE ''Lun%'' AND horaires <> ''Lun-Ven heures ouvrables'' AND horaires <> ''Lun-Sam heures ouvrables'' ORDER BY horaires LIMIT 50;\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires LIKE ''%événements%'' OR horaires LIKE ''%evene%'' ORDER BY horaires LIMIT 20;\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT horaires, count\\(*\\) as cnt FROM defibs WHERE horaires <> '''' GROUP BY horaires ORDER BY cnt DESC LIMIT 50;\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires LIKE ''%heures de nuit%'' LIMIT 10;\")",
|
||||||
|
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT horaires, horaires_std FROM defibs WHERE horaires = ''Lun-Ven heures ouvrables'' LIMIT 1;\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
"react-native/react-native": true,
|
"react-native/react-native": true,
|
||||||
|
jest: true,
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
"plugin:prettier/recommended",
|
"plugin:prettier/recommended",
|
||||||
|
|
@ -37,6 +38,12 @@ module.exports = {
|
||||||
},
|
},
|
||||||
"import/ignore": ["react-native"],
|
"import/ignore": ["react-native"],
|
||||||
"import/resolver": {
|
"import/resolver": {
|
||||||
|
// Ensure ESLint can resolve regular JS packages under Yarn PnP as well.
|
||||||
|
// Without this, some deps (ex: expo-sqlite) may be incorrectly flagged
|
||||||
|
// by import/no-unresolved even though they're present.
|
||||||
|
node: {
|
||||||
|
extensions: [".js", ".jsx", ".ts", ".tsx", ".json"],
|
||||||
|
},
|
||||||
typescript: {},
|
typescript: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -100,3 +100,15 @@ android/app/google-services.json
|
||||||
!android/app/google-services.example.json
|
!android/app/google-services.example.json
|
||||||
|
|
||||||
screenshot-*.png
|
screenshot-*.png
|
||||||
|
|
||||||
|
/.data
|
||||||
|
|
||||||
|
# Geodae preprocessing
|
||||||
|
scripts/dae/node_modules/
|
||||||
|
scripts/dae/.yarn/*
|
||||||
|
!scripts/dae/.yarn/patches
|
||||||
|
!scripts/dae/.yarn/plugins
|
||||||
|
!scripts/dae/.yarn/releases
|
||||||
|
!scripts/dae/.yarn/sdks
|
||||||
|
!scripts/dae/.yarn/versions
|
||||||
|
src/assets/db/*.db
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,13 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
|
// Resolve duplicate native libs shipped by multiple dependencies (e.g. op-sqlite + react-android).
|
||||||
|
// Needed for debug flavors too (merge<Variant>NativeLibs).
|
||||||
|
pickFirsts += [
|
||||||
|
'lib/**/libjsi.so',
|
||||||
|
'lib/**/libreactnative.so',
|
||||||
|
]
|
||||||
|
|
||||||
jniLibs {
|
jniLibs {
|
||||||
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
|
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,55 @@
|
||||||
|
|
||||||
## Recently Completed Features
|
## Recently Completed Features
|
||||||
|
|
||||||
|
### DAE v1 (Tasks 1–9) — 2026-03-06
|
||||||
|
1. Embedded DAE DB + safe open path:
|
||||||
|
- ✅ Embedded DB asset: `src/assets/db/geodae.db`
|
||||||
|
- ✅ Safe open path + repository: `src/db/openDb.js`, `src/db/defibsRepo.js`
|
||||||
|
|
||||||
|
2. Utilities + tests:
|
||||||
|
- ✅ Corridor/geo utils: `src/utils/geo/corridor.js`
|
||||||
|
- ✅ DAE helpers: `src/utils/dae/getDefibAvailability.js`, `src/utils/dae/subjectSuggestsDefib.js`
|
||||||
|
- ✅ Jest config: `jest.config.js`
|
||||||
|
|
||||||
|
3. Store:
|
||||||
|
- ✅ Defibrillators store: `src/stores/defibs.js`
|
||||||
|
|
||||||
|
4. Screens + navigation:
|
||||||
|
- ✅ DAE list + item screens: `src/scenes/DAEList/index.js`, `src/scenes/DAEItem/index.js`
|
||||||
|
- ✅ Navigation wiring: `src/navigation/Drawer.js`, `src/navigation/RootStack.js`
|
||||||
|
|
||||||
|
5. Alert integration:
|
||||||
|
- ✅ Alert overview + map hooks: `src/scenes/AlertCurOverview/index.js`, `src/scenes/AlertCurMap/useFeatures.js`, `src/scenes/AlertCurMap/useOnPress.js`
|
||||||
|
|
||||||
|
6. Persistent suggestion modal:
|
||||||
|
- ✅ `src/containers/DaeSuggestModal/index.js` mounted in `src/layout/LayoutProviders.js`
|
||||||
|
|
||||||
|
7. New asset:
|
||||||
|
- ✅ Marker icon: `src/assets/img/marker-grey.png`
|
||||||
|
|
||||||
|
8. Verification:
|
||||||
|
- ✅ `yarn lint` and `yarn test` passing
|
||||||
|
|
||||||
|
9. Runtime hardening follow-up fixes — 2026-03-07:
|
||||||
|
- ✅ Hermes fix for H3 import:
|
||||||
|
- `src/lib/h3/index.js`
|
||||||
|
- `src/db/defibsRepo.js`
|
||||||
|
- ✅ SQLite backend selection + wrappers (incl. op-sqlite adapter):
|
||||||
|
- `src/db/openDb.js`
|
||||||
|
- `src/db/openDbOpSqlite.js`
|
||||||
|
- `src/db/openDbExpoSqlite.js`
|
||||||
|
- ✅ Embedded DB staging + schema validation:
|
||||||
|
- `src/db/ensureEmbeddedDb.js`
|
||||||
|
- `src/db/validateDbSchema.js`
|
||||||
|
- ✅ Android duplicate native libs packaging fix for op-sqlite:
|
||||||
|
- `android/app/build.gradle`
|
||||||
|
- ✅ Added dependency: `expo-file-system` (`package.json`)
|
||||||
|
- ✅ Tests added:
|
||||||
|
- `src/db/openDbOpSqlite.test.js`
|
||||||
|
- `src/db/ensureEmbeddedDb.test.js`
|
||||||
|
- `src/db/validateDbSchema.test.js`
|
||||||
|
- ✅ Status: confirmed on Android emulator dev client that DAE list loads (no `no such table: defibs`).
|
||||||
|
|
||||||
### Push Notification Improvements
|
### Push Notification Improvements
|
||||||
1. Background Notification Fixes:
|
1. Background Notification Fixes:
|
||||||
- ✅ Added required Android permissions
|
- ✅ Added required Android permissions
|
||||||
|
|
|
||||||
12
jest.config.js
Normal file
12
jest.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/** @type {import('@jest/types').Config.InitialOptions} */
|
||||||
|
module.exports = {
|
||||||
|
testMatch: ["<rootDir>/src/**/*.test.js"],
|
||||||
|
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/e2e/"],
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
"node_modules/(?!(@react-native|react-native|expo)/)",
|
||||||
|
],
|
||||||
|
testEnvironment: "node",
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^~/(.*)$": "<rootDir>/src/$1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -15,7 +15,7 @@ const config = {
|
||||||
/node_modules\/.*\/android\/build\/intermediates\/(library_jni|merged_jni_libs)\/.*/,
|
/node_modules\/.*\/android\/build\/intermediates\/(library_jni|merged_jni_libs)\/.*/,
|
||||||
]),
|
]),
|
||||||
sourceExts: [...sentryConfig.resolver.sourceExts, "cjs"],
|
sourceExts: [...sentryConfig.resolver.sourceExts, "cjs"],
|
||||||
assetExts: [...defaultConfig.resolver.assetExts, "ttf"],
|
assetExts: [...defaultConfig.resolver.assetExts, "ttf", "db"],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
enhanceMiddleware: (middleware) => {
|
enhanceMiddleware: (middleware) => {
|
||||||
|
|
|
||||||
12
package.json
12
package.json
|
|
@ -51,7 +51,11 @@
|
||||||
"open:deeplink:ios": "yarn open:deeplink --ios",
|
"open:deeplink:ios": "yarn open:deeplink --ios",
|
||||||
"open:deeplink": "npx uri-scheme open --android",
|
"open:deeplink": "npx uri-scheme open --android",
|
||||||
"screenshot:ios": "scripts/screenshot-ios.sh",
|
"screenshot:ios": "scripts/screenshot-ios.sh",
|
||||||
"screenshot:android": "scripts/screenshot-android.sh"
|
"screenshot:android": "scripts/screenshot-android.sh",
|
||||||
|
"dae:download": "yarn --cwd scripts/dae download",
|
||||||
|
"dae:json-to-csv": "yarn --cwd scripts/dae json-to-csv",
|
||||||
|
"dae:csv-to-db": "yarn --cwd scripts/dae csv-to-db",
|
||||||
|
"dae:build": "yarn --cwd scripts/dae build"
|
||||||
},
|
},
|
||||||
"customExpoVersioning": {
|
"customExpoVersioning": {
|
||||||
"versionCode": 241,
|
"versionCode": 241,
|
||||||
|
|
@ -85,6 +89,7 @@
|
||||||
"@mapbox/polyline": "^1.2.1",
|
"@mapbox/polyline": "^1.2.1",
|
||||||
"@maplibre/maplibre-react-native": "10.0.0-alpha.23",
|
"@maplibre/maplibre-react-native": "10.0.0-alpha.23",
|
||||||
"@notifee/react-native": "^9.1.8",
|
"@notifee/react-native": "^9.1.8",
|
||||||
|
"@op-engineering/op-sqlite": "^15.2.5",
|
||||||
"@react-native-async-storage/async-storage": "2.1.2",
|
"@react-native-async-storage/async-storage": "2.1.2",
|
||||||
"@react-native-community/netinfo": "^11.4.1",
|
"@react-native-community/netinfo": "^11.4.1",
|
||||||
"@react-native-community/slider": "4.5.6",
|
"@react-native-community/slider": "4.5.6",
|
||||||
|
|
@ -126,6 +131,7 @@
|
||||||
"expo-contacts": "~14.2.5",
|
"expo-contacts": "~14.2.5",
|
||||||
"expo-dev-client": "~5.2.4",
|
"expo-dev-client": "~5.2.4",
|
||||||
"expo-device": "~7.1.4",
|
"expo-device": "~7.1.4",
|
||||||
|
"expo-file-system": "~18.1.11",
|
||||||
"expo-gradle-ext-vars": "^0.1.1",
|
"expo-gradle-ext-vars": "^0.1.1",
|
||||||
"expo-linear-gradient": "~14.1.5",
|
"expo-linear-gradient": "~14.1.5",
|
||||||
"expo-linking": "~7.1.7",
|
"expo-linking": "~7.1.7",
|
||||||
|
|
@ -135,6 +141,7 @@
|
||||||
"expo-secure-store": "~14.2.4",
|
"expo-secure-store": "~14.2.4",
|
||||||
"expo-sensors": "~14.1.4",
|
"expo-sensors": "~14.1.4",
|
||||||
"expo-splash-screen": "~0.30.10",
|
"expo-splash-screen": "~0.30.10",
|
||||||
|
"expo-sqlite": "^55.0.10",
|
||||||
"expo-status-bar": "~2.2.3",
|
"expo-status-bar": "~2.2.3",
|
||||||
"expo-system-ui": "~5.0.11",
|
"expo-system-ui": "~5.0.11",
|
||||||
"expo-task-manager": "~13.1.6",
|
"expo-task-manager": "~13.1.6",
|
||||||
|
|
@ -145,6 +152,7 @@
|
||||||
"google-libphonenumber": "^3.2.32",
|
"google-libphonenumber": "^3.2.32",
|
||||||
"graphql": "^16.10.0",
|
"graphql": "^16.10.0",
|
||||||
"graphql-ws": "^6.0.4",
|
"graphql-ws": "^6.0.4",
|
||||||
|
"h3-js": "^4.4.0",
|
||||||
"hash.js": "^1.1.7",
|
"hash.js": "^1.1.7",
|
||||||
"i18next": "^23.2.10",
|
"i18next": "^23.2.10",
|
||||||
"immer": "^10.0.2",
|
"immer": "^10.0.2",
|
||||||
|
|
@ -282,4 +290,4 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.5.3"
|
"packageManager": "yarn@4.5.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
313
plans/PLAN_DAE-claude.md
Normal file
313
plans/PLAN_DAE-claude.md
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
# DAE (Défibrillateur) Feature - Implementation Plan
|
||||||
|
|
||||||
|
## Common Context Prefix (include at the top of every task prompt)
|
||||||
|
|
||||||
|
```
|
||||||
|
## Project Context - DAE Feature Integration
|
||||||
|
|
||||||
|
You are working on the React Native app "Alerte Secours" at `/home/jo/lab/alerte-secours/apps/as-app/`.
|
||||||
|
Branch: `feat/dae`
|
||||||
|
|
||||||
|
### Architecture Overview
|
||||||
|
|
||||||
|
- **Framework:** React Native with Expo v53
|
||||||
|
- **Navigation:** React Navigation v6 (Stack → Drawer → Tabs)
|
||||||
|
- `RootStack.js` (Stack) → `Drawer.js` (Drawer) → `Main/index.js` (Material Top Tabs)
|
||||||
|
- Each drawer screen can be a standalone scene or use a Bottom Tab Navigator
|
||||||
|
- **State Management:** Zustand v5 with custom atomic wrapper (`~/lib/atomic-zustand`)
|
||||||
|
- Stores defined in `~/stores/index.js`, each atom in `~/stores/{name}.js`
|
||||||
|
- Usage: `const { foo } = useFooState(["foo"])` or `fooActions.doSomething()`
|
||||||
|
- **Maps:** MapLibre React Native (`@maplibre/maplibre-react-native`)
|
||||||
|
- Map components in `~/containers/Map/` (Camera, MapView, FeatureImages, ShapePoints, etc.)
|
||||||
|
- Route calculation via OSRM in `~/scenes/AlertCurMap/routing.js`
|
||||||
|
- Clustering via supercluster in `useFeatures` hooks
|
||||||
|
- **Styling:** React Native Paper v5 + custom `createStyles()`/`useStyles()` from `~/theme`
|
||||||
|
- **Icons:** `@expo/vector-icons` (MaterialCommunityIcons, MaterialIcons, Entypo)
|
||||||
|
- **Forms:** React Hook Form
|
||||||
|
- **GraphQL:** Apollo Client v3
|
||||||
|
- **Module alias:** `~` maps to `src/`
|
||||||
|
|
||||||
|
### Key Existing Patterns
|
||||||
|
|
||||||
|
**Drawer Screen Registration** (`src/navigation/Drawer.js`):
|
||||||
|
- Screens are `<Drawer.Screen name="..." component={...} options={{drawerLabel, drawerIcon, ...}} />`
|
||||||
|
- Hidden screens use `options={{ hidden: true }}`
|
||||||
|
- Menu item sections are organized by index ranges in `DrawerItemList.js` (indices 0-4 = "Alerter", 5-8 = "Mon compte", 9+ = "Infos pratiques")
|
||||||
|
|
||||||
|
**Header Titles** (`src/navigation/RootStack.js`):
|
||||||
|
- `getHeaderTitle(route)` switch-case maps route names to display titles
|
||||||
|
|
||||||
|
**Bottom Tab Navigator Pattern** (reference: `src/scenes/AlertAgg/index.js`):
|
||||||
|
- `createBottomTabNavigator()` with `Tab.Screen` entries
|
||||||
|
- `screenOptions={{ headerShown: false, tabBarLabelStyle: { fontSize: 13 }, lazy: true, unmountOnBlur: true }}`
|
||||||
|
- Tab icons use MaterialCommunityIcons
|
||||||
|
|
||||||
|
**Action Buttons Pattern** (reference: `src/scenes/AlertCurOverview/index.js`):
|
||||||
|
- `<Button mode="contained" icon={() => <MaterialCommunityIcons ... />} style={[styles.actionButton, ...]} onPress={handler}>`
|
||||||
|
|
||||||
|
**Alert Map Navigation Pattern** (reference: `src/scenes/AlertCurMap/index.js`):
|
||||||
|
- Uses route calculation via OSRM, user location tracking, polyline rendering
|
||||||
|
- Camera management via `useMapInit()`, features via `useFeatures()`
|
||||||
|
- Control buttons, routing steps drawer, profile selection (car/bike/pedestrian)
|
||||||
|
|
||||||
|
**Navigation to nested screens:**
|
||||||
|
```js
|
||||||
|
navigation.navigate("Main", {
|
||||||
|
screen: "AlertCur",
|
||||||
|
params: { screen: "AlertCurTab", params: { screen: "AlertCurMap" } },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### DAE Data Layer (already implemented)
|
||||||
|
|
||||||
|
- `src/data/getNearbyDefibs.js` - Main API: `getNearbyDefibs({ lat, lon, radiusMeters, limit, disponible24hOnly })`
|
||||||
|
- `src/db/defibsRepo.js` - SQLite queries using H3 spatial index
|
||||||
|
- `src/db/openDb.js` - SQLite database initialization
|
||||||
|
- `src/assets/db/geodae.db` - Pre-built SQLite database with defibrillator data
|
||||||
|
|
||||||
|
**DefibResult type:**
|
||||||
|
```js
|
||||||
|
{ id, latitude, longitude, nom, adresse, horaires, acces, disponible_24h, distanceMeters }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files You Should Reference for Patterns
|
||||||
|
- Drawer registration: `src/navigation/Drawer.js`
|
||||||
|
- Menu sections: `src/navigation/DrawerNav/DrawerItemList.js`
|
||||||
|
- Header titles: `src/navigation/RootStack.js` (getHeaderTitle)
|
||||||
|
- Bottom tabs: `src/scenes/AlertAgg/index.js`
|
||||||
|
- List view: `src/scenes/AlertAggList/index.js`
|
||||||
|
- Map view: `src/scenes/AlertAggMap/index.js`
|
||||||
|
- Alert navigation map: `src/scenes/AlertCurMap/index.js`
|
||||||
|
- Alert situation view: `src/scenes/AlertCurOverview/index.js`
|
||||||
|
- Alert posting: `src/scenes/SendAlertConfirm/useOnSubmit.js`
|
||||||
|
- Store definition: `src/stores/index.js`
|
||||||
|
- Store atom example: `src/stores/alert.js`
|
||||||
|
- Modal pattern: uses `Portal` + `Modal` from `react-native-paper`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Create the DAE Zustand Store
|
||||||
|
|
||||||
|
**Goal:** Create a Zustand store atom for DAE state management.
|
||||||
|
|
||||||
|
**Create `src/stores/dae.js`:**
|
||||||
|
- State:
|
||||||
|
- `daeList: []` — array of DefibResult objects currently loaded
|
||||||
|
- `daeDisplayEnabled: false` — whether DAE markers should show on alert map
|
||||||
|
- `daeAlertFilter: null` — when set to `{ userCoords, alertCoords }`, filters DAE within 10km of the axis between user and alert
|
||||||
|
- `selectedDae: null` — currently selected DAE item (for DAEItem view)
|
||||||
|
- Actions:
|
||||||
|
- `setDaeList(list)`
|
||||||
|
- `setDaeDisplayEnabled(enabled)`
|
||||||
|
- `setDaeAlertFilter(filter)`
|
||||||
|
- `setSelectedDae(dae)`
|
||||||
|
- `reset()`
|
||||||
|
|
||||||
|
**Update `src/stores/index.js`:**
|
||||||
|
- Import the new `dae` atom
|
||||||
|
- Add it to `createStore()` call
|
||||||
|
- Export `useDaeState`, `getDaeState`, `subscribeDaeState`, `daeActions`
|
||||||
|
|
||||||
|
**Reference:** Follow the exact pattern of `src/stores/alert.js` for atom structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Create DAEItem Scene (Single DAE Detail View)
|
||||||
|
|
||||||
|
**Goal:** Create the DAEItem view that shows details of a single defibrillator with bottom tabs (Infos + Map).
|
||||||
|
|
||||||
|
**Create `src/scenes/DAEItem/index.js`:**
|
||||||
|
- Bottom Tab Navigator with 2 tabs:
|
||||||
|
- `DAEItemInfos` (left, default) — Info tab
|
||||||
|
- `DAEItemMap` (right) — Map tab with navigation to DAE
|
||||||
|
- Follow the `AlertAgg/index.js` tab pattern exactly
|
||||||
|
- Tab icons: `information-outline` for Infos, `map-marker-outline` for Map
|
||||||
|
|
||||||
|
**Create `src/scenes/DAEItemInfos/index.js`:**
|
||||||
|
- Display selected DAE info from `useDaeState(["selectedDae"])`
|
||||||
|
- Show: nom, adresse, horaires, acces, disponible_24h, distance
|
||||||
|
- Use a ScrollView with info lines
|
||||||
|
- Style following the app's pattern (createStyles, useStyles)
|
||||||
|
- Include a "Itinéraire" button that switches to the Map tab
|
||||||
|
|
||||||
|
**Create `src/scenes/DAEItemMap/index.js`:**
|
||||||
|
- Map with route to the DAE location (the DAE is the destination)
|
||||||
|
- Mimic `src/scenes/AlertCurMap/index.js` implementation:
|
||||||
|
- Same OSRM route calculation
|
||||||
|
- Same camera management, polyline rendering
|
||||||
|
- Same control buttons, routing steps
|
||||||
|
- Same profile selection (car/bike/pedestrian)
|
||||||
|
- The destination is `{ latitude: dae.latitude, longitude: dae.longitude }` instead of alert location
|
||||||
|
- Use `useDaeState(["selectedDae"])` to get the DAE coordinates
|
||||||
|
- Reuse as much as possible from the existing map containers (`~/containers/Map/*`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Create DAEList Scene (List + Map of Nearby DAEs)
|
||||||
|
|
||||||
|
**Goal:** Create the DAEList view with bottom tabs showing a list and map of nearby defibrillators.
|
||||||
|
|
||||||
|
**Create `src/scenes/DAEList/index.js`:**
|
||||||
|
- Bottom Tab Navigator with 2 tabs:
|
||||||
|
- `DAEListList` (left, default) — List of DAEs
|
||||||
|
- `DAEListMap` (right) — Map of DAEs
|
||||||
|
- Follow `AlertAgg/index.js` pattern
|
||||||
|
- Tab icons: `format-list-bulleted` for List, `map-marker-outline` for Map
|
||||||
|
|
||||||
|
**Create `src/scenes/DAEListList/index.js`:**
|
||||||
|
- Load DAEs using `getNearbyDefibs()` with user location from `useLocationState`
|
||||||
|
- Parameters: `radiusMeters: 10000` (10km), `limit: 100`
|
||||||
|
- Filter out DAEs not available for current day and hour:
|
||||||
|
- Parse the `horaires` field to determine availability
|
||||||
|
- Keep DAEs where `disponible_24h === 1` (always available)
|
||||||
|
- For others, parse opening hours and check against current day/time
|
||||||
|
- If `horaires` is empty or unparseable, keep the DAE (err on the side of showing)
|
||||||
|
- Sort by `distanceMeters` ascending (nearest first)
|
||||||
|
- Display as a ScrollView/FlatList with rows showing:
|
||||||
|
- DAE name (nom), address (adresse), distance (formatted), availability indicator
|
||||||
|
- On press → set `daeActions.setSelectedDae(dae)` and navigate to `DAEItem`
|
||||||
|
|
||||||
|
**Create `src/scenes/DAEListMap/index.js`:**
|
||||||
|
- Map showing all nearby DAEs as markers
|
||||||
|
- Mimic `src/scenes/AlertAggMap/index.js` pattern for marker clustering and display
|
||||||
|
- Use DAE-specific marker icon (defibrillator icon)
|
||||||
|
- On marker press → set `daeActions.setSelectedDae(dae)` and navigate to `DAEItem`
|
||||||
|
- Camera bounds should fit all visible DAE markers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Register DAEList and DAEItem in Navigation
|
||||||
|
|
||||||
|
**Goal:** Wire up DAEList and DAEItem screens into the app navigation.
|
||||||
|
|
||||||
|
**Update `src/navigation/Drawer.js`:**
|
||||||
|
- Import DAEList and DAEItem scenes
|
||||||
|
- Add `<Drawer.Screen name="DAEList">` with:
|
||||||
|
- `drawerLabel: "Défibrillateurs"`
|
||||||
|
- `drawerIcon`: use `MaterialCommunityIcons` with name `"heart-pulse"` (or `"medical-bag"`)
|
||||||
|
- `unmountOnBlur: true`
|
||||||
|
- Add `<Drawer.Screen name="DAEItem">` with:
|
||||||
|
- `hidden: true` (not shown in drawer menu, navigated to programmatically)
|
||||||
|
- `unmountOnBlur: true`
|
||||||
|
- Place `DAEList` in the "Infos pratiques" section (after existing items, before About or Sheets)
|
||||||
|
|
||||||
|
**Update `src/navigation/DrawerNav/DrawerItemList.js`:**
|
||||||
|
- Adjust the index constants (`index1`, `index2`) if needed to account for the new drawer screen
|
||||||
|
- Ensure DAEList appears in the "Infos pratiques" section
|
||||||
|
|
||||||
|
**Update `src/navigation/RootStack.js`:**
|
||||||
|
- Add cases to `getHeaderTitle()`:
|
||||||
|
- `case "DAEList": return "Défibrillateurs";`
|
||||||
|
- `case "DAEItem": return "Défibrillateur";`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Add "Afficher les défibrillateurs" Button in AlertCurOverview
|
||||||
|
|
||||||
|
**Goal:** Add a button in the Alert Situation view to enable DAE display and navigate to the alert map.
|
||||||
|
|
||||||
|
**Update `src/scenes/AlertCurOverview/index.js`:**
|
||||||
|
- Import `daeActions` from `~/stores`
|
||||||
|
- Import `getNearbyDefibs` from `~/data/getNearbyDefibs`
|
||||||
|
- Import `useLocationState` from `~/stores` (or use `getCurrentLocation`)
|
||||||
|
- Add a new action button "Afficher les défibrillateurs" in the `containerActions` section:
|
||||||
|
- Position it after the "Je viens vous aider" / "Coming help" button area (visible for both sender and receiver, when alert is open)
|
||||||
|
- Icon: `MaterialCommunityIcons` name `"heart-pulse"`
|
||||||
|
- On press:
|
||||||
|
1. Get user coords (from store or `getCurrentLocation()`)
|
||||||
|
2. Get alert coords from `alert.location.coordinates`
|
||||||
|
3. Call `daeActions.setDaeDisplayEnabled(true)`
|
||||||
|
4. Call `daeActions.setDaeAlertFilter({ userCoords: { latitude, longitude }, alertCoords: { latitude: alertLat, longitude: alertLon } })`
|
||||||
|
5. Load DAEs: call `getNearbyDefibs()` with a radius covering the 10km corridor along the axis between user and alert positions, store results via `daeActions.setDaeList(results)`
|
||||||
|
6. Navigate to AlertCurMap: `navigation.navigate("Main", { screen: "AlertCur", params: { screen: "AlertCurTab", params: { screen: "AlertCurMap" } } })`
|
||||||
|
- Show this button for all users (sender and receiver) when the alert is open
|
||||||
|
- Follow the exact same Button pattern as the other action buttons in this file
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Add DAE Markers to AlertCurMap
|
||||||
|
|
||||||
|
**Goal:** Display defibrillator markers on the alert navigation map when enabled.
|
||||||
|
|
||||||
|
**Update `src/scenes/AlertCurMap/index.js`:**
|
||||||
|
- Import `useDaeState` from `~/stores`
|
||||||
|
- Read `daeDisplayEnabled` and `daeList` from the DAE store
|
||||||
|
- When `daeDisplayEnabled` is true, render DAE markers on the map:
|
||||||
|
- Use `Maplibre.PointAnnotation` or `Maplibre.MarkerView` for each DAE
|
||||||
|
- Use a distinct icon/color for DAE markers (different from alert markers) — green with heart-pulse or defibrillator symbol
|
||||||
|
- Show DAE name in a callout/tooltip on marker press
|
||||||
|
- On marker press → set `daeActions.setSelectedDae(dae)` and navigate to `DAEItem`
|
||||||
|
|
||||||
|
**Alternative approach (if using the existing FeatureImages/clustering pattern):**
|
||||||
|
- Create DAE-specific features array from `daeList`
|
||||||
|
- Convert each DAE to a GeoJSON Feature with `Point` geometry
|
||||||
|
- Add a separate ShapeSource + SymbolLayer for DAE features
|
||||||
|
- Use a defibrillator icon image (add to `FeatureImages` or create a DAE-specific one)
|
||||||
|
|
||||||
|
**Important:** DAE markers must coexist with the existing alert marker and route display without interfering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Cardiac Keyword Detection Modal on Alert Posting
|
||||||
|
|
||||||
|
**Goal:** When posting an alert, detect cardiac-related keywords in the subject and show a modal suggesting to search for a defibrillator. The modal must work offline and persist even during navigation.
|
||||||
|
|
||||||
|
**Create `src/components/DAEModal/index.js`:**
|
||||||
|
- A modal component using `Portal` + `Modal` from `react-native-paper`
|
||||||
|
- Props: `visible`, `onDismiss`, `onSearchDAE`
|
||||||
|
- Content:
|
||||||
|
- Title: "Défibrillateur" or relevant header
|
||||||
|
- Brief message about defibrillators nearby
|
||||||
|
- Two buttons:
|
||||||
|
- "Chercher un défibrillateur" (primary, calls `onSearchDAE`)
|
||||||
|
- "Non merci" (secondary, calls `onDismiss`)
|
||||||
|
- The modal must render at a high level in the component tree so it persists across navigations
|
||||||
|
|
||||||
|
**Create `src/utils/string/detectCardiacKeywords.js`:**
|
||||||
|
- Export a function `detectCardiacKeywords(text)` that returns `true` if the text contains cardiac-related keywords
|
||||||
|
- Keywords to match (case-insensitive, with common typos):
|
||||||
|
- "cardiaque", "cardiac", "cardique" (typo)
|
||||||
|
- "coeur", "cœur"
|
||||||
|
- "malaise", "mailaise" (typo), "mallaise" (typo)
|
||||||
|
- "inconscient", "inconsciente"
|
||||||
|
- "évanoui", "évanouie", "evanouie", "evanoui", "évanouis" (typo variant)
|
||||||
|
- "arrêt", "arret" (in context of "arrêt cardiaque")
|
||||||
|
- "défibrillateur", "defibrillateur"
|
||||||
|
- "réanimation", "reanimation"
|
||||||
|
- "massage cardiaque"
|
||||||
|
- "ne respire plus", "respire plus"
|
||||||
|
- Use a regex approach for fuzzy matching
|
||||||
|
|
||||||
|
**Update `src/scenes/SendAlertConfirm/useOnSubmit.js`:**
|
||||||
|
- The keyword detection needs to happen at submission time
|
||||||
|
- However, the modal must show *during* the posting flow (even offline)
|
||||||
|
- Approach:
|
||||||
|
- Instead of modifying `useOnSubmit` directly (since the modal must persist across navigation), manage the modal state via the DAE store or a dedicated state
|
||||||
|
- Add `showDaeModal: false` to the DAE store, and `daeActions.setShowDaeModal(bool)`
|
||||||
|
- In `useOnSubmit.js`, after creating `alertSendAlertInput` but before or right after the mutation call, check `detectCardiacKeywords(subject)`. If true, call `daeActions.setShowDaeModal(true)`
|
||||||
|
- The modal keeps showing even as navigation happens (because it's rendered at app root level via Portal)
|
||||||
|
|
||||||
|
**Mount the DAEModal at app level:**
|
||||||
|
- Update `src/app/AppRoot.js` or `src/layout/Layout.js` to include the `DAEModal` component
|
||||||
|
- The modal reads `showDaeModal` from `useDaeState`
|
||||||
|
- "Chercher un défibrillateur" → `daeActions.setShowDaeModal(false)` + navigate to `DAEList`
|
||||||
|
- "Non merci" → `daeActions.setShowDaeModal(false)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Dependency Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1 (Store) → no dependencies, do first
|
||||||
|
Task 2 (DAEItem) → depends on Task 1
|
||||||
|
Task 3 (DAEList) → depends on Task 1
|
||||||
|
Task 4 (Navigation) → depends on Tasks 2, 3
|
||||||
|
Task 5 (Button in AlertCurOverview) → depends on Tasks 1, 4
|
||||||
|
Task 6 (DAE Markers on AlertCurMap) → depends on Tasks 1, 2, 4
|
||||||
|
Task 7 (Cardiac Modal) → depends on Tasks 1, 3, 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Parallelizable: Tasks 2 and 3 can run in parallel after Task 1.
|
||||||
|
Task 4 should be done after Tasks 2 and 3.
|
||||||
|
Tasks 5, 6, 7 can run in parallel after Task 4.
|
||||||
395
plans/PLAN_DAE-gpt.md
Normal file
395
plans/PLAN_DAE-gpt.md
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
# DAE / Defibrillator integration plan (agent-splittable)
|
||||||
|
|
||||||
|
## Common plan prefix (include this at the top of every coding-agent prompt)
|
||||||
|
|
||||||
|
### Product goal
|
||||||
|
|
||||||
|
Integrate defibrillator (DAE) discovery into the app using the embedded SQLite DB query helper [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37). Provide:
|
||||||
|
|
||||||
|
- A new left-drawer link to a new view `DAEList`.
|
||||||
|
- In an active alert `Situation` view, a button `Afficher les défibrillateurs` that:
|
||||||
|
1) enables DAE display around the **10km corridor around the segment** between user location and alert location,
|
||||||
|
2) then navigates to the alert map.
|
||||||
|
- In alert map, render DAE markers; tapping a DAE opens `DAEItem`.
|
||||||
|
- `DAEList` screen with bottom navigation: `Liste` (default) and `Carte`, showing defibs nearest→farthest around the user, **within 10km**.
|
||||||
|
- `DAEItem` screen with bottom navigation: `Infos` (default) and a map/itinerary to reach the selected DAE (mimic alert routing).
|
||||||
|
- During alert posting, if a cardiac-related keyword is detected in the alert subject, show a **persistent modal** (must remain on top even after redirect to the alert view, and must work offline) with two actions:
|
||||||
|
- `Chercher un défibrillateur` → go to `DAEList`
|
||||||
|
- `Non merci` → dismiss
|
||||||
|
|
||||||
|
### v1 decisions already made
|
||||||
|
|
||||||
|
1) Availability filter: **only** use `disponible_24h === 1` for now; no parsing of `horaires` string yet (later iteration).
|
||||||
|
2) Corridor filter: **within 10km of the user↔alert segment** (not union of circles).
|
||||||
|
3) Location permission denied: use last-known location; if none, show an explanatory empty state (no hard block).
|
||||||
|
|
||||||
|
### Known architecture + relevant anchors in codebase
|
||||||
|
|
||||||
|
- Defib query wrapper: [`src/data/getNearbyDefibs.js`](src/data/getNearbyDefibs.js:1) exports [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37) which calls repo H3 query and falls back to bbox on error.
|
||||||
|
- Repo query + distance rank: [`src/db/defibsRepo.js`](src/db/defibsRepo.js:1) provides [`getNearbyDefibs()`](src/db/defibsRepo.js:62) and [`getNearbyDefibsBbox()`](src/db/defibsRepo.js:159).
|
||||||
|
- Embedded DB bootstrap: [`getDb()`](src/db/openDb.js:11) expects a bundled asset `require('../assets/db/geodae.db')` in [`src/db/openDb.js`](src/db/openDb.js:31). **Note:** current repo listing shows `src/assets/db/` empty, so packaging must be validated.
|
||||||
|
- Drawer navigation screens declared in [`src/navigation/Drawer.js`](src/navigation/Drawer.js:85).
|
||||||
|
- Root stack only defines `Main` and `ConnectivityError` in [`src/navigation/RootStack.js`](src/navigation/RootStack.js:142). Drawer contains “hidden” stack-like screens (e.g. `SendAlertConfirm`) already.
|
||||||
|
- Alert current tabs: `Situation`, `Messages`, `Carte` in [`src/scenes/AlertCur/Tabs.js`](src/scenes/AlertCur/Tabs.js:25).
|
||||||
|
- Alert map uses MapLibre and has route computation (OSRM) already in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:170).
|
||||||
|
- Map features clustering uses Supercluster in [`useFeatures()`](src/scenes/AlertCurMap/useFeatures.js:8) and map press routing in [`useOnPress()`](src/scenes/AlertCurMap/useOnPress.js:17).
|
||||||
|
- Persistent top-layer UI is implemented elsewhere via `react-native-paper` [`Portal`](src/containers/SmsDisclaimerModel/index.js:37) + [`Modal`](src/containers/SmsDisclaimerModel/index.js:38).
|
||||||
|
- Alert posting flow navigates to `AlertCurOverview` in [`onSubmit()`](src/scenes/SendAlertConfirm/useOnSubmit.js:32) after `alertActions.setNavAlertCur()` in [`src/scenes/SendAlertConfirm/useOnSubmit.js`](src/scenes/SendAlertConfirm/useOnSubmit.js:127).
|
||||||
|
|
||||||
|
### Data model (canonical Defib object for UI)
|
||||||
|
|
||||||
|
Base DB row shape (from repo select) is:
|
||||||
|
|
||||||
|
- `id: string`
|
||||||
|
- `latitude: number`
|
||||||
|
- `longitude: number`
|
||||||
|
- `nom: string`
|
||||||
|
- `adresse: string`
|
||||||
|
- `horaires: string` (unused in v1)
|
||||||
|
- `acces: string`
|
||||||
|
- `disponible_24h: 0|1`
|
||||||
|
- plus computed `distanceMeters: number`
|
||||||
|
|
||||||
|
Represent coordinates consistently as:
|
||||||
|
|
||||||
|
- Map coordinates arrays `[lon, lat]` to match existing map usage (see `alert.location.coordinates` in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:157)).
|
||||||
|
|
||||||
|
### Filtering logic requirements
|
||||||
|
|
||||||
|
**Near-user list (DAEList):**
|
||||||
|
|
||||||
|
- Input: user location (current if available, else last-known)
|
||||||
|
- Filter: distance ≤ 10_000m
|
||||||
|
- Sort: nearest→farthest
|
||||||
|
- Availability: if `disponible_24h === 1` keep; else exclude in v1 (until horaires parsing)
|
||||||
|
|
||||||
|
**Alert-axis corridor overlay (Alert map + Situation button):**
|
||||||
|
|
||||||
|
- Input: user location + alert location
|
||||||
|
- Filter: points within `corridorMeters = 10_000` of the line segment user→alert
|
||||||
|
- Also restrict to a sensible max radius around user to limit query size (see “Query strategy” below)
|
||||||
|
|
||||||
|
Corridor math recommendation:
|
||||||
|
|
||||||
|
- Use existing Turf dependency already present in map stack: `@turf/helpers` [`lineString()`](src/scenes/AlertCurMap/index.js:24), `@turf/nearest-point-on-line` [`nearestPointOnLine()`](src/scenes/AlertCurMap/index.js:25), and a distance function (`geolib` or Turf `distance`).
|
||||||
|
|
||||||
|
### Query strategy (performance + offline)
|
||||||
|
|
||||||
|
Use the existing local SQLite query API (no network) via [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37).
|
||||||
|
|
||||||
|
- For near-user list: query radiusMeters = `10_000`, limit = e.g. 200–500 (tune later).
|
||||||
|
- For corridor overlay:
|
||||||
|
- First query a radius around the **midpoint** or around **user** large enough to include the whole segment + corridor.
|
||||||
|
- Practical v1 approach: compute `segmentLengthMeters` and query radius = `segmentLengthMeters/2 + corridorMeters` around the midpoint.
|
||||||
|
- Then apply corridor filter in JS and cap results to a max marker count (e.g. 200) to keep map responsive.
|
||||||
|
|
||||||
|
### Navigation and UX conventions
|
||||||
|
|
||||||
|
- Drawer items come from `<Drawer.Screen>` options in [`src/navigation/Drawer.js`](src/navigation/Drawer.js:100) and are rendered by [`menuItem()`](src/navigation/DrawerNav/menuItem.js:4). Hidden routes should set `options.hidden = true`.
|
||||||
|
- Bottom tab patterns exist (see alert tabs in [`src/scenes/AlertCur/Tabs.js`](src/scenes/AlertCur/Tabs.js:25)).
|
||||||
|
- For “persistent modal on top even after redirect”, implement modal at a global provider level (within [`LayoutProviders`](src/layout/LayoutProviders.js:30) tree) using `Portal` so it survives navigation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Split tasks (agent-ready prompts)
|
||||||
|
|
||||||
|
Each task below is designed to be handed to a coding agent. Include the **Common plan prefix** above in every prompt.
|
||||||
|
|
||||||
|
### Task 1 — Validate embedded DB asset packaging and repo schema assumptions
|
||||||
|
|
||||||
|
**Objective:** Ensure the bundled SQLite `geodae.db` is present and accessible on-device, and confirm schema columns used by [`defibsRepo`](src/db/defibsRepo.js:120) exist.
|
||||||
|
|
||||||
|
**Implementation notes:**
|
||||||
|
|
||||||
|
- [`initDb()`](src/db/openDb.js:18) copies `require('../assets/db/geodae.db')` to documents.
|
||||||
|
- Current workspace shows `src/assets/db/` empty; find where DB is stored or add it.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- App can open DB without throwing at [`require('../assets/db/geodae.db')`](src/db/openDb.js:31).
|
||||||
|
- Query `SELECT ... FROM defibs` works with columns: `id, latitude, longitude, nom, adresse, horaires, acces, disponible_24h` and `h3`.
|
||||||
|
|
||||||
|
**Likely files touched:**
|
||||||
|
|
||||||
|
- [`src/db/openDb.js`](src/db/openDb.js:1)
|
||||||
|
- DB asset under [`src/assets/db/`](src/assets/db:1)
|
||||||
|
- Possibly Expo config / bundling rules (if needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2 — Define and implement defib filtering utilities (10km near-user + 10km corridor)
|
||||||
|
|
||||||
|
**Objective:** Create pure utility functions to:
|
||||||
|
|
||||||
|
- compute query radius for corridor overlay
|
||||||
|
- filter a list of defibs to those inside corridor
|
||||||
|
- normalize coordinates and compute distances
|
||||||
|
|
||||||
|
**Implementation notes:**
|
||||||
|
|
||||||
|
- Prefer reusing Turf already used in map stack (see imports in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:24)).
|
||||||
|
- Keep utilities side-effect free and unit-testable.
|
||||||
|
|
||||||
|
**Suggested exports:**
|
||||||
|
|
||||||
|
- `computeCorridorQueryRadiusMeters({ userLonLat, alertLonLat, corridorMeters })`
|
||||||
|
- `filterDefibsInCorridor({ defibs, userLonLat, alertLonLat, corridorMeters })`
|
||||||
|
- `toLonLat({ latitude, longitude })`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- Given synthetic points, corridor filter behaves as “distance to segment ≤ 10km”.
|
||||||
|
|
||||||
|
**Likely files touched:**
|
||||||
|
|
||||||
|
- New: [`src/utils/geo/defibsCorridor.js`](src/utils/geo/defibsCorridor.js:1)
|
||||||
|
- Possibly reuse existing [`haversine`](src/db/defibsRepo.js:5) logic as reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3 — Add a Defibs store (zustand atom) to manage caching + overlay enablement + modal state
|
||||||
|
|
||||||
|
**Objective:** Add centralized state to avoid repeated DB queries and to coordinate UI across screens.
|
||||||
|
|
||||||
|
**State concerns:**
|
||||||
|
|
||||||
|
- cached near-user defibs list
|
||||||
|
- cached corridor defibs for current alert id
|
||||||
|
- flag `showDefibsOnAlertMap` (or `defibsOverlayEnabledByAlertId`)
|
||||||
|
- selected defib id for `DAEItem`
|
||||||
|
- “DAE suggestion modal” visibility (global)
|
||||||
|
|
||||||
|
**Integration points:**
|
||||||
|
|
||||||
|
- Mirror patterns used by alert store in [`createAtom()`](src/stores/alert.js:6).
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- Other tasks can simply call actions like `defibsActions.loadNearUser()` and `defibsActions.enableCorridorOverlay(alertId)`.
|
||||||
|
|
||||||
|
**Likely files touched:**
|
||||||
|
|
||||||
|
- New: [`src/stores/defibs.js`](src/stores/defibs.js:1)
|
||||||
|
- Update exports/hooks in [`src/stores/index.js`](src/stores/index.js:1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4 — Add navigation routes: `DAEList` in drawer + `DAEItem` as hidden route
|
||||||
|
|
||||||
|
**Objective:** Add new screens to navigation so they can be opened from:
|
||||||
|
|
||||||
|
- left drawer (DAEList)
|
||||||
|
- map marker press (DAEItem)
|
||||||
|
- modal CTA (DAEList)
|
||||||
|
|
||||||
|
**Implementation notes:**
|
||||||
|
|
||||||
|
- Drawer routes are defined in [`src/navigation/Drawer.js`](src/navigation/Drawer.js:85).
|
||||||
|
- If `DAEItem` is a detail view, set `options.hidden = true` (see existing hidden screens around [`SendAlertConfirm`](src/navigation/Drawer.js:490)).
|
||||||
|
- Decide where to place the DAE link in drawer sections:
|
||||||
|
- Sections are sliced by indices in [`src/navigation/DrawerNav/DrawerItemList.js`](src/navigation/DrawerNav/DrawerItemList.js:4). Adding a new Drawer.Screen will shift indices; adjust `index1/index2` or reorder screens.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- A new drawer link navigates to `DAEList`.
|
||||||
|
- `DAEItem` route can be navigated to programmatically.
|
||||||
|
|
||||||
|
**Likely files touched:**
|
||||||
|
|
||||||
|
- [`src/navigation/Drawer.js`](src/navigation/Drawer.js:85)
|
||||||
|
- [`src/navigation/DrawerNav/DrawerItemList.js`](src/navigation/DrawerNav/DrawerItemList.js:1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5 — Situation button on active alert: enable DAE overlay and navigate to alert map
|
||||||
|
|
||||||
|
**Objective:** In `Situation` view for current alert, add button `Afficher les défibrillateurs`.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
1) Determine user coords (prefer current; fall back to last-known as in alert map’s `useLocation` usage in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:83)).
|
||||||
|
2) Query defibs from DB with a computed radius around midpoint.
|
||||||
|
3) Filter to corridor (10km).
|
||||||
|
4) Store result + enable overlay flag.
|
||||||
|
5) Navigate to map tab: `Main → AlertCur → AlertCurTab → AlertCurMap` (same pattern used in [`AlertCurOverview`](src/scenes/AlertCurOverview/index.js:276)).
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- Button exists only when alert has coordinates.
|
||||||
|
- After tap, map opens and defib markers appear.
|
||||||
|
|
||||||
|
**Likely files touched:**
|
||||||
|
|
||||||
|
- [`src/scenes/AlertCurOverview/index.js`](src/scenes/AlertCurOverview/index.js:61)
|
||||||
|
- Store from Task 3
|
||||||
|
- Utilities from Task 2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6 — Alert map: render DAE markers and open `DAEItem` on tap
|
||||||
|
|
||||||
|
**Objective:** Display defib markers as a separate feature layer on alert map.
|
||||||
|
|
||||||
|
**Implementation notes:**
|
||||||
|
|
||||||
|
- Existing feature system creates a GeoJSON FeatureCollection for alerts in [`useFeatures()`](src/scenes/AlertCurMap/useFeatures.js:42).
|
||||||
|
- Add DAE features when overlay is enabled.
|
||||||
|
- Add a new marker icon to map images by extending [`FeatureImages`](src/containers/Map/FeatureImages.js:13).
|
||||||
|
- Update press handler in [`useOnPress()`](src/scenes/AlertCurMap/useOnPress.js:17) to recognize `properties.defib` and navigate to `DAEItem`.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- Markers appear/disappear based on overlay flag.
|
||||||
|
- Tapping a marker navigates to `DAEItem`.
|
||||||
|
- Clustering behavior is acceptable (either include in cluster or keep separate; v1 can skip clustering by rendering as a separate layer).
|
||||||
|
|
||||||
|
**Likely files touched:**
|
||||||
|
|
||||||
|
- [`src/scenes/AlertCurMap/useFeatures.js`](src/scenes/AlertCurMap/useFeatures.js:8)
|
||||||
|
- [`src/scenes/AlertCurMap/useOnPress.js`](src/scenes/AlertCurMap/useOnPress.js:17)
|
||||||
|
- [`src/containers/Map/FeatureImages.js`](src/containers/Map/FeatureImages.js:1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7 — Implement `DAEList` screen with bottom tabs: List (default) + Map
|
||||||
|
|
||||||
|
**Objective:** Create `DAEList` scene with bottom navigation and two tabs:
|
||||||
|
|
||||||
|
- `Liste`: list view nearest→farthest
|
||||||
|
- `Carte`: map view of same defibs
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
- Load near-user defibs within 10km using [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37).
|
||||||
|
- Filter by availability: keep `disponible_24h === 1` only.
|
||||||
|
- If no location available, show empty state with explanation.
|
||||||
|
- Tapping list item navigates to `DAEItem`.
|
||||||
|
|
||||||
|
**Implementation notes:**
|
||||||
|
|
||||||
|
- Follow bottom tab pattern from [`createBottomTabNavigator()`](src/scenes/AlertCur/Tabs.js:3).
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- Drawer link opens `DAEList`.
|
||||||
|
- List is sorted by `distanceMeters`.
|
||||||
|
- Map tab renders markers.
|
||||||
|
|
||||||
|
**Likely files touched:**
|
||||||
|
|
||||||
|
- New: [`src/scenes/DAEList/index.js`](src/scenes/DAEList/index.js:1)
|
||||||
|
- New: [`src/scenes/DAEList/Tabs.js`](src/scenes/DAEList/Tabs.js:1)
|
||||||
|
- Possibly shared list row component
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8 — Implement `DAEItem` screen with bottom tabs: Infos (default) + Go-to map (itinerary)
|
||||||
|
|
||||||
|
**Objective:** Create `DAEItem` detail view for a selected defib.
|
||||||
|
|
||||||
|
**Tabs:**
|
||||||
|
|
||||||
|
- `Infos`: name, address, access, availability badge
|
||||||
|
- `Carte`: show route from user to DAE, mimicking alert routing implementation in [`AlertCurMap`](src/scenes/AlertCurMap/index.js:170)
|
||||||
|
|
||||||
|
**Implementation notes:**
|
||||||
|
|
||||||
|
- Reuse route calculation patterns (OSRM URL building, polyline decode, step list components) from alert map.
|
||||||
|
- Route target is defib coordinates instead of alert coordinates.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- From any entrypoint (alert map marker or list), `DAEItem` shows correct defib.
|
||||||
|
- Itinerary works when online; offline behavior is a clear message (route unavailable offline).
|
||||||
|
|
||||||
|
**Likely files touched:**
|
||||||
|
|
||||||
|
- New: [`src/scenes/DAEItem/index.js`](src/scenes/DAEItem/index.js:1)
|
||||||
|
- New: [`src/scenes/DAEItem/Tabs.js`](src/scenes/DAEItem/Tabs.js:1)
|
||||||
|
- New: [`src/scenes/DAEItem/Map.js`](src/scenes/DAEItem/Map.js:1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9 — Keyword detection while posting an alert + persistent DAE suggestion modal
|
||||||
|
|
||||||
|
**Objective:** Detect cardiac-related terms during alert posting and display a global modal that remains visible even after navigation.
|
||||||
|
|
||||||
|
**Detection requirements:**
|
||||||
|
|
||||||
|
- Keywords include: `cardiaque`, `cardiac` (typos), `coeur`, `malaise`, `inconscient`, `évanoui` etc.
|
||||||
|
- Should run locally/offline.
|
||||||
|
|
||||||
|
**Implementation approach (recommended):**
|
||||||
|
|
||||||
|
- Use fuzzy matching with `Fuse` (already used in [`findAlertTitle()`](src/finders/alertTitle.js:50)) or implement a lightweight normalization + substring/levenshtein.
|
||||||
|
- Trigger detection in the confirm submit flow, before/around navigation in [`onSubmit()`](src/scenes/SendAlertConfirm/useOnSubmit.js:32).
|
||||||
|
- Render modal at app root using `react-native-paper` [`Portal`](src/containers/SmsDisclaimerModel/index.js:37) inside [`LayoutProviders`](src/layout/LayoutProviders.js:51) so it persists across navigation.
|
||||||
|
|
||||||
|
**Modal UI:**
|
||||||
|
|
||||||
|
- Title/text: explain quickly why looking for a DAE matters.
|
||||||
|
- Two buttons:
|
||||||
|
- `Chercher un défibrillateur` → navigate to `DAEList`
|
||||||
|
- `Non merci` → dismiss
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- Modal shows for matching terms even with no internet.
|
||||||
|
- Modal stays visible after redirect to current alert view.
|
||||||
|
|
||||||
|
**Likely files touched:**
|
||||||
|
|
||||||
|
- [`src/scenes/SendAlertConfirm/useOnSubmit.js`](src/scenes/SendAlertConfirm/useOnSubmit.js:1)
|
||||||
|
- New: [`src/containers/DAESuggestModal/index.js`](src/containers/DAESuggestModal/index.js:1)
|
||||||
|
- [`src/layout/LayoutProviders.js`](src/layout/LayoutProviders.js:30)
|
||||||
|
- Store from Task 3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10 — Verification checklist (manual) + minimal automated coverage
|
||||||
|
|
||||||
|
**Objective:** Provide a deterministic checklist and (where feasible) simple automated tests.
|
||||||
|
|
||||||
|
**Manual verification checklist:**
|
||||||
|
|
||||||
|
1) **Drawer**: DAE link visible, opens list.
|
||||||
|
2) **DAEList**:
|
||||||
|
- permission granted → list populated, sorted
|
||||||
|
- permission denied + last-known available → list uses last-known
|
||||||
|
- permission denied + no last-known → empty state
|
||||||
|
3) **Alert Situation button**: enables overlay and opens alert map.
|
||||||
|
4) **Alert map**: DAE markers render; tap → DAEItem.
|
||||||
|
5) **DAEItem routing**: online route works; offline shows message.
|
||||||
|
6) **Keyword modal**:
|
||||||
|
- trigger term in subject → modal shows
|
||||||
|
- redirect to alert occurs and modal remains on top
|
||||||
|
- CTA navigates to DAEList
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- Checklist documented and reproducible.
|
||||||
|
|
||||||
|
**Likely files touched:**
|
||||||
|
|
||||||
|
- New: [`plans/DAE-manual-test-checklist.md`](plans/DAE-manual-test-checklist.md:1)
|
||||||
|
- Optional: e2e tests under [`e2e/`](e2e:1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mermaid overview
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[User posts alert] --> B[Keyword detection]
|
||||||
|
B -->|match| M[Show persistent DAE modal]
|
||||||
|
B -->|no match| C[Navigate to AlertCur Situation]
|
||||||
|
M --> C
|
||||||
|
M -->|Chercher un defibrillateur| L[DAEList]
|
||||||
|
M -->|Non merci| C
|
||||||
|
C --> S[Button Afficher les defibrillateurs]
|
||||||
|
S --> E[Enable overlay in store]
|
||||||
|
E --> MAP[AlertCurMap]
|
||||||
|
MAP -->|tap DAE marker| ITEM[DAEItem]
|
||||||
|
L -->|tap list item| ITEM
|
||||||
|
```
|
||||||
|
|
||||||
370
plans/PLAN_DAE-merged.md
Normal file
370
plans/PLAN_DAE-merged.md
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
## Common plan prefix (include at the top of every coding-agent prompt)
|
||||||
|
|
||||||
|
### Product goal (v1)
|
||||||
|
|
||||||
|
Integrate defibrillator (DAE) discovery into the app using the embedded SQLite DB query helper `getNearbyDefibs()`.
|
||||||
|
|
||||||
|
Deliver:
|
||||||
|
|
||||||
|
1) A new left-drawer link to a new view `DAEList`.
|
||||||
|
2) In an active alert `Situation` view, a button `Afficher les défibrillateurs` that:
|
||||||
|
- enables DAE display inside a 10km corridor around the segment between user location and alert location
|
||||||
|
- navigates to the alert map tab
|
||||||
|
3) On alert map, render DAE markers; tapping a DAE opens `DAEItem`.
|
||||||
|
4) `DAEList` screen with bottom navigation: `Liste` (default) and `Carte`, showing defibs nearest→farthest around the user, within 10km.
|
||||||
|
5) `DAEItem` screen with bottom navigation: `Infos` (default) and `Carte` (map + itinerary to the selected DAE; reuse alert routing patterns).
|
||||||
|
6) During alert posting, if cardiac-related keywords are detected in the alert subject, show a persistent modal (must remain visible even after navigation, and must work offline) with:
|
||||||
|
- `Chercher un défibrillateur` → go to `DAEList`
|
||||||
|
- `Non merci` → dismiss
|
||||||
|
|
||||||
|
### v1 decisions (explicit)
|
||||||
|
|
||||||
|
1) Availability display: use `horaires_std` (structured JSON) to show open/closed/unknown status and schedule details.
|
||||||
|
- `horaires_std` shape: `{ days: number[]|null, slots: {open,close}[]|null, is24h: boolean, businessHours: boolean, nightHours: boolean, events: boolean, notes: string }`
|
||||||
|
- `days`: ISO 8601 day numbers (1=Mon … 7=Sun), `null` if unknown.
|
||||||
|
- `slots`: `[{open:"HH:MM", close:"HH:MM"}]`, `null` if no specific times.
|
||||||
|
- Availability logic (pure function `getDefibAvailability(horaires_std, disponible_24h)` → `{ status: "open"|"closed"|"unknown", label: string }`):
|
||||||
|
- `disponible_24h === 1` → always `"open"`, label `"24h/24 7j/7"`
|
||||||
|
- `is24h && days includes today` → `"open"`
|
||||||
|
- `days` includes today + current time falls within a `slot` → `"open"`
|
||||||
|
- `days` includes today + no slots + `businessHours` → approximate Mon-Fri 8h-18h → `"open"` or `"closed"`
|
||||||
|
- `days` does not include today → `"closed"`
|
||||||
|
- `events === true` → `"unknown"`, label `"Selon événements"`
|
||||||
|
- Else → `"unknown"`
|
||||||
|
- No hard filter: show all defibs regardless of availability, but display status visually (green/red/grey dot or icon).
|
||||||
|
2) Corridor filter: points are kept if distance to the user→alert line segment is ≤ `corridorMeters = 10_000`.
|
||||||
|
3) Alert-map DAE markers: v1 uses a separate, non-clustered layer (do not mix into alert clusters).
|
||||||
|
4) Location permission denied: use last-known location; if none, show an explanatory empty state (no hard block).
|
||||||
|
5) DB open failure: the app must not crash; DAE UI shows an error empty state and overlay remains disabled.
|
||||||
|
6) Keyword detection: normalized-text + regex list (no fuzzy search library) in v1.
|
||||||
|
|
||||||
|
### Relevant anchors in codebase
|
||||||
|
|
||||||
|
- Defib query wrapper: `src/data/getNearbyDefibs.js` exports `getNearbyDefibs()`.
|
||||||
|
- Repo query: `src/db/defibsRepo.js` — SELECTs and JSON-parses `horaires_std`.
|
||||||
|
- Embedded DB bootstrap: `src/db/openDb.js` requires `../assets/db/geodae.db`.
|
||||||
|
- Drawer navigation: `src/navigation/Drawer.js`.
|
||||||
|
- Drawer sections: `src/navigation/DrawerNav/DrawerItemList.js`.
|
||||||
|
- Header titles: `src/navigation/RootStack.js`.
|
||||||
|
- Alert tabs: `src/scenes/AlertCur/Tabs.js`.
|
||||||
|
- Alert map feature pipeline: `src/scenes/AlertCurMap/useFeatures.js` + `src/scenes/AlertCurMap/useOnPress.js`.
|
||||||
|
- Persistent modal pattern: `src/containers/SmsDisclaimerModel/index.js` uses Paper `Portal` + `Modal`.
|
||||||
|
- Alert posting flow: `src/scenes/SendAlertConfirm/useOnSubmit.js`.
|
||||||
|
- Preprocessing pipeline: `scripts/dae/` (geodae-to-csv.js → csv-to-sqlite.mjs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Split tasks (agent-ready)
|
||||||
|
|
||||||
|
### Task 1 — Validate embedded DB asset packaging and schema assumptions
|
||||||
|
|
||||||
|
Objective: ensure the bundled SQLite `geodae.db` is present and accessible on-device, and confirm expected schema columns exist.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `src/db/openDb.js` calls `require('../assets/db/geodae.db')`.
|
||||||
|
- In the current workspace, `src/assets/db/` may be missing; find the DB source and ensure it is bundled for Expo.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- App can open the DB without throwing at the asset require.
|
||||||
|
- Query `SELECT ... FROM defibs` works with columns: `id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h` and `h3`.
|
||||||
|
- `horaires_std` is a JSON string parseable by `JSON.parse()` (already handled in `defibsRepo.js`).
|
||||||
|
- If DB open fails, DAE screens show a non-blocking error empty state and overlay stays disabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2 — Implement geo utilities for corridor filtering + availability helper (pure, unit-testable)
|
||||||
|
|
||||||
|
Objective: create pure utilities to compute the query radius for the corridor overlay, filter defibs to those inside the corridor, and determine real-time availability from `horaires_std`.
|
||||||
|
|
||||||
|
#### 2a — Geo / corridor utilities
|
||||||
|
|
||||||
|
Suggested exports (e.g. `src/utils/geo/corridor.js`):
|
||||||
|
|
||||||
|
- `toLonLat({ latitude, longitude })`
|
||||||
|
- `computeCorridorQueryRadiusMeters({ userLonLat, alertLonLat, corridorMeters })`
|
||||||
|
- `filterDefibsInCorridor({ defibs, userLonLat, alertLonLat, corridorMeters })`
|
||||||
|
|
||||||
|
Implementation guidance:
|
||||||
|
|
||||||
|
- Use the existing Turf usage patterns already present in the alert map stack.
|
||||||
|
- Corridor definition: keep point if distance to the line segment ≤ `corridorMeters`.
|
||||||
|
- Query strategy: query around midpoint with radius `segmentLengthMeters / 2 + corridorMeters`, then apply corridor filter in JS and cap markers (e.g. 200) to keep map responsive.
|
||||||
|
|
||||||
|
#### 2b — Availability helper
|
||||||
|
|
||||||
|
Suggested export (e.g. `src/utils/dae/getDefibAvailability.js`):
|
||||||
|
|
||||||
|
- `getDefibAvailability(horaires_std, disponible_24h, now?)` → `{ status: "open"|"closed"|"unknown", label: string }`
|
||||||
|
|
||||||
|
The `horaires_std` object has shape:
|
||||||
|
```
|
||||||
|
{ days: number[]|null, slots: {open,close}[]|null, is24h, businessHours, nightHours, events, notes }
|
||||||
|
```
|
||||||
|
|
||||||
|
Logic (in priority order):
|
||||||
|
1. `disponible_24h === 1` → `"open"`, label `"24h/24 7j/7"`
|
||||||
|
2. `is24h === true` and `days` includes current ISO day → `"open"`, label `"24h/24"`
|
||||||
|
3. `days !== null` and does not include current ISO day → `"closed"`, label day range from `days`
|
||||||
|
4. `days` includes today and `slots` is non-empty → check if current time falls within any slot → `"open"` or `"closed"` with next open/close time as label
|
||||||
|
5. `businessHours === true` (no explicit slots) → approximate Mon-Fri 08:00-18:00 → `"open"` or `"closed"`
|
||||||
|
6. `nightHours === true` → approximate 20:00-08:00 → `"open"` or `"closed"`
|
||||||
|
7. `events === true` → `"unknown"`, label `"Selon événements"`
|
||||||
|
8. Fallback → `"unknown"`, label from `notes` or `"Horaires non renseignés"`
|
||||||
|
|
||||||
|
The `now` parameter (defaults to `new Date()`) enables deterministic testing.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Given synthetic points, corridor filter is correct and deterministic.
|
||||||
|
- Given synthetic `horaires_std` + fixed `now`, availability status is correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3 — Add a Defibs store (zustand atom) for caching + overlay + selection + modal state
|
||||||
|
|
||||||
|
Objective: add centralized state to avoid repeated DB queries and coordinate UI across screens.
|
||||||
|
|
||||||
|
State:
|
||||||
|
|
||||||
|
- `nearUserDefibs: []`
|
||||||
|
- `corridorDefibs: []` (or keyed by current alert id if needed)
|
||||||
|
- `showDefibsOnAlertMap: false`
|
||||||
|
- `selectedDefib: null`
|
||||||
|
- `showDaeSuggestModal: false`
|
||||||
|
|
||||||
|
Actions (suggested):
|
||||||
|
|
||||||
|
- `loadNearUser({ userLonLat })`
|
||||||
|
- `loadCorridor({ userLonLat, alertLonLat })`
|
||||||
|
- `setShowDefibsOnAlertMap(bool)`
|
||||||
|
- `setSelectedDefib(defib)`
|
||||||
|
- `setShowDaeSuggestModal(bool)`
|
||||||
|
- `reset()`
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Other tasks can call actions without duplicating query/filter logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4 — Create `DAEList` scene with bottom tabs: `Liste` + `Carte`
|
||||||
|
|
||||||
|
Objective: add `DAEList` screen with bottom navigation and two tabs.
|
||||||
|
|
||||||
|
UI specifics (from existing patterns):
|
||||||
|
|
||||||
|
- Follow the app’s bottom-tab pattern.
|
||||||
|
- Tab icons:
|
||||||
|
- Liste: `format-list-bulleted`
|
||||||
|
- Carte: `map-marker-outline`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Get user location (current if possible; else last-known).
|
||||||
|
- Query within 10km using `getNearbyDefibs({ radiusMeters: 10_000 })`.
|
||||||
|
- No hard availability filter: show all defibs. Each row shows real-time availability status via `getDefibAvailability(horaires_std, disponible_24h)`.
|
||||||
|
- Sort by `distanceMeters` ascending.
|
||||||
|
- Empty states:
|
||||||
|
- no location → explain how to enable permission
|
||||||
|
- DB error → explain that DAE database is unavailable
|
||||||
|
|
||||||
|
List row content:
|
||||||
|
- Name (`nom`), distance, address summary.
|
||||||
|
- Availability status indicator: green dot + "Ouvert" / red dot + "Fermé" / grey dot + "Inconnu" (or label from `getDefibAvailability`).
|
||||||
|
- If `horaires_std.notes` is non-empty, show it as a secondary line.
|
||||||
|
|
||||||
|
Carte tab:
|
||||||
|
- Map markers colored by availability status (green/red/grey).
|
||||||
|
|
||||||
|
Interaction:
|
||||||
|
|
||||||
|
- Tap list row or map marker → set `selectedDefib` in store and navigate to `DAEItem`.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Drawer link opens `DAEList` and list is sorted nearest→farthest.
|
||||||
|
- Availability status is displayed per row/marker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5 — Create `DAEItem` scene with bottom tabs: `Infos` + `Carte`
|
||||||
|
|
||||||
|
Objective: create DAE detail view for a selected defib.
|
||||||
|
|
||||||
|
Tab icons:
|
||||||
|
|
||||||
|
- Infos: `information-outline`
|
||||||
|
- Carte: `map-marker-outline`
|
||||||
|
|
||||||
|
Infos tab content:
|
||||||
|
|
||||||
|
- Name (`nom`), address (`adresse`), access (`acces`), distance.
|
||||||
|
- Availability section:
|
||||||
|
- Current status via `getDefibAvailability()` with colored indicator.
|
||||||
|
- Schedule details from `horaires_std`:
|
||||||
|
- If `is24h`: "Disponible 24h/24"
|
||||||
|
- If `days`: show day range (e.g. "Lun-Ven")
|
||||||
|
- If `slots`: show time slots (e.g. "08:00 - 18:00")
|
||||||
|
- If `businessHours`: "Heures ouvrables"
|
||||||
|
- If `nightHours`: "Heures de nuit"
|
||||||
|
- If `events`: "Selon événements"
|
||||||
|
- If `notes`: show notes text
|
||||||
|
- Fallback: show raw `horaires` string if `horaires_std` is null/empty.
|
||||||
|
- Add an `Itinéraire` button that switches to the `Carte` tab.
|
||||||
|
|
||||||
|
Carte tab:
|
||||||
|
|
||||||
|
- Map + itinerary to the defib coordinates.
|
||||||
|
- Reuse alert routing implementation patterns (OSRM fetch etc.).
|
||||||
|
- Offline behavior: show a clear message that routing is unavailable offline.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- From list or alert map marker, `DAEItem` shows the correct selected defib.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6 — Navigation wiring: `DAEList` in drawer + `DAEItem` hidden
|
||||||
|
|
||||||
|
Objective: register new screens and keep drawer sections consistent.
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Add `<Drawer.Screen name='DAEList'>` with label `Défibrillateurs`.
|
||||||
|
- Drawer icon: `MaterialCommunityIcons` name `heart-pulse`.
|
||||||
|
- Add `<Drawer.Screen name='DAEItem'>` hidden (not shown in menu) and only navigated programmatically.
|
||||||
|
- Adjust section indices in `DrawerItemList.js` if adding a screen shifts boundaries.
|
||||||
|
- Add header title cases for `DAEList` and `DAEItem`.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Drawer link appears under the intended section and opens `DAEList`.
|
||||||
|
- `DAEItem` can be navigated to programmatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7 — Add Situation button: enable corridor overlay and navigate to alert map
|
||||||
|
|
||||||
|
Objective: in active alert Situation view, add `Afficher les défibrillateurs`.
|
||||||
|
|
||||||
|
UI specifics:
|
||||||
|
|
||||||
|
- Use the app’s existing action button pattern.
|
||||||
|
- Icon: `MaterialCommunityIcons` name `heart-pulse`.
|
||||||
|
- Position: next to/after existing main actions (align with current layout conventions).
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
1) Get user coords.
|
||||||
|
2) Get alert coords.
|
||||||
|
3) Load corridor defibs using midpoint query radius + corridor filter.
|
||||||
|
4) Store results and set overlay enabled.
|
||||||
|
5) Navigate to map tab using existing nested navigation pattern.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Tap leads to alert map and DAE markers appear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8 — Alert map overlay: render DAE markers (separate layer) and open `DAEItem` on tap
|
||||||
|
|
||||||
|
Objective: add a DAE marker layer to the alert map.
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Extend the existing feature pipeline to include DAE features when overlay is enabled.
|
||||||
|
- Add DAE icon images to map images (3 variants: green/red/grey for open/closed/unknown).
|
||||||
|
- Compute availability via `getDefibAvailability()` to select marker color.
|
||||||
|
- Update press handling to detect DAE features and navigate to `DAEItem`.
|
||||||
|
- v1 does not cluster DAE markers.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Markers appear/disappear based on overlay flag.
|
||||||
|
- Tapping a marker opens `DAEItem`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9 — Keyword detection during alert posting + persistent DAE suggestion modal
|
||||||
|
|
||||||
|
Objective: detect cardiac-related terms in alert subject and display a global modal that remains visible even after navigation.
|
||||||
|
|
||||||
|
Detection (v1):
|
||||||
|
|
||||||
|
- Normalize text: lowercase + remove diacritics.
|
||||||
|
- Regex list covering common terms and typos, e.g.:
|
||||||
|
- cardiaque, cardiac, cardique
|
||||||
|
- coeur, cœur
|
||||||
|
- malaise, mailaise, mallaise
|
||||||
|
- inconscient
|
||||||
|
- evanoui, évanoui (variants)
|
||||||
|
- arret, arrêt (especially arrêt cardiaque)
|
||||||
|
- defibrillateur, défibrillateur
|
||||||
|
- reanimation, réanimation
|
||||||
|
- massage cardiaque
|
||||||
|
- ne respire plus
|
||||||
|
|
||||||
|
Modal:
|
||||||
|
|
||||||
|
- Use Paper `Portal` + `Modal` mounted high in the tree so it persists across navigations.
|
||||||
|
- CTA:
|
||||||
|
- `Chercher un défibrillateur` → dismiss + navigate to `DAEList`
|
||||||
|
- `Non merci` → dismiss
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Modal shows offline.
|
||||||
|
- Modal remains visible after redirect to current alert view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10 — Verification checklist (manual)
|
||||||
|
|
||||||
|
Objective: provide a deterministic checklist.
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
|
||||||
|
1) Drawer: link visible, opens list.
|
||||||
|
2) DAEList:
|
||||||
|
- permission granted → list populated and sorted
|
||||||
|
- permission denied + last-known available → list uses last-known
|
||||||
|
- permission denied + no last-known → empty state
|
||||||
|
- DB missing/unavailable → non-blocking error empty state
|
||||||
|
- each row shows availability status (open/closed/unknown) with colored indicator
|
||||||
|
3) Situation button: enables overlay and opens alert map.
|
||||||
|
4) Alert map: DAE markers render with availability-colored icons; tap → DAEItem.
|
||||||
|
5) DAEItem:
|
||||||
|
- Infos tab shows schedule details from `horaires_std` (days, slots, 24h, notes).
|
||||||
|
- Availability status is prominently displayed.
|
||||||
|
- Routing: online route works; offline shows message.
|
||||||
|
6) Keyword modal:
|
||||||
|
- trigger terms → modal shows
|
||||||
|
- modal persists across navigation
|
||||||
|
- CTA navigates to DAEList
|
||||||
|
7) Availability logic:
|
||||||
|
- 24h/24 defib → shows "open" at any time
|
||||||
|
- Defib with Lun-Ven slots → shows "open" during those hours, "closed" on weekends
|
||||||
|
- Defib with `events` flag → shows "unknown" / "Selon événements"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mermaid overview
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[User posts alert] --> B[Keyword detection]
|
||||||
|
B -->|match| M[Show persistent DAE modal]
|
||||||
|
B -->|no match| C[Navigate to AlertCur Situation]
|
||||||
|
M --> C
|
||||||
|
M -->|Chercher un defibrillateur| L[DAEList]
|
||||||
|
M -->|Non merci| C
|
||||||
|
C --> S[Button Afficher les defibrillateurs]
|
||||||
|
S --> E[Enable overlay in store]
|
||||||
|
E --> MAP[AlertCurMap]
|
||||||
|
MAP -->|tap DAE marker| ITEM[DAEItem]
|
||||||
|
L -->|tap list item| ITEM
|
||||||
|
```
|
||||||
|
|
||||||
2
scripts/dae/.gitignore
vendored
Normal file
2
scripts/dae/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
geodae.json
|
||||||
|
geodae.csv
|
||||||
7
scripts/dae/.yarnrc.yml
Normal file
7
scripts/dae/.yarnrc.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
compressionLevel: mixed
|
||||||
|
|
||||||
|
enableGlobalCache: false
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: ../../.yarn/releases/yarn-4.5.3.cjs
|
||||||
207
scripts/dae/csv-to-sqlite.mjs
Normal file
207
scripts/dae/csv-to-sqlite.mjs
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// CSV-to-SQLite pipeline for defibrillator data with H3 geo-indexing.
|
||||||
|
// Usage: node csv-to-sqlite.mjs --input <path> --output <path> [--h3res 8] [--delimiter auto] [--batchSize 5000]
|
||||||
|
|
||||||
|
import { createReadStream, readFileSync, existsSync, unlinkSync } from "node:fs";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { parse } from "csv-parse";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
const h3 = require("h3-js");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI args
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const { values: args } = parseArgs({
|
||||||
|
options: {
|
||||||
|
input: { type: "string", short: "i" },
|
||||||
|
output: { type: "string", short: "o" },
|
||||||
|
h3res: { type: "string", default: "8" },
|
||||||
|
delimiter: { type: "string", default: "auto" },
|
||||||
|
batchSize: { type: "string", default: "5000" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const INPUT = args.input;
|
||||||
|
const OUTPUT = args.output;
|
||||||
|
const H3_RES = parseInt(args.h3res, 10);
|
||||||
|
const BATCH_SIZE = parseInt(args.batchSize, 10);
|
||||||
|
|
||||||
|
if (!INPUT || !OUTPUT) {
|
||||||
|
console.error("Usage: node csv-to-sqlite.mjs --input <csv> --output <db> [--h3res 8] [--delimiter auto] [--batchSize 5000]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SCHEMA_PATH = resolve(__dirname, "lib", "schema.sql");
|
||||||
|
|
||||||
|
function detectDelimiter(filePath) {
|
||||||
|
// Read first line to detect delimiter
|
||||||
|
const chunk = readFileSync(filePath, { encoding: "utf-8", end: 4096 });
|
||||||
|
const firstLine = chunk.split(/\r?\n/)[0];
|
||||||
|
const commaCount = (firstLine.match(/,/g) || []).length;
|
||||||
|
const semicolonCount = (firstLine.match(/;/g) || []).length;
|
||||||
|
const detected = semicolonCount > commaCount ? ";" : ",";
|
||||||
|
console.log(`Delimiter auto-detected: "${detected}" (commas=${commaCount}, semicolons=${semicolonCount})`);
|
||||||
|
return detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeH3(lat, lon, res) {
|
||||||
|
return h3.latLngToCell(lat, lon, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deterministicId(lat, lon, nom, adresse) {
|
||||||
|
const payload = `${lat}|${lon}|${nom}|${adresse}`;
|
||||||
|
return createHash("sha256").update(payload, "utf-8").digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanFloat(val) {
|
||||||
|
const n = parseFloat(val);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanInt(val) {
|
||||||
|
const n = parseInt(val, 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanStr(val) {
|
||||||
|
return (val ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const delimiter =
|
||||||
|
args.delimiter === "auto" ? detectDelimiter(INPUT) : args.delimiter;
|
||||||
|
|
||||||
|
// Remove existing output to avoid UNIQUE constraint errors on re-run
|
||||||
|
if (existsSync(OUTPUT)) {
|
||||||
|
unlinkSync(OUTPUT);
|
||||||
|
console.log(`Removed existing DB: ${OUTPUT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open database with fast-import PRAGMAs
|
||||||
|
const db = new Database(OUTPUT);
|
||||||
|
db.pragma("journal_mode = OFF");
|
||||||
|
db.pragma("synchronous = OFF");
|
||||||
|
db.pragma("temp_store = MEMORY");
|
||||||
|
db.pragma("cache_size = -64000"); // 64 MB
|
||||||
|
db.pragma("locking_mode = EXCLUSIVE");
|
||||||
|
|
||||||
|
// Create schema
|
||||||
|
const schema = readFileSync(SCHEMA_PATH, "utf-8");
|
||||||
|
db.exec(schema);
|
||||||
|
|
||||||
|
// Prepare insert statement
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO defibs (id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h, h3)
|
||||||
|
VALUES (@id, @latitude, @longitude, @nom, @adresse, @horaires, @horaires_std, @acces, @disponible_24h, @h3)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertMany = db.transaction((rows) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
insert.run(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Streaming CSV parse
|
||||||
|
const parser = createReadStream(INPUT, { encoding: "utf-8" }).pipe(
|
||||||
|
parse({
|
||||||
|
delimiter,
|
||||||
|
columns: true,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
trim: true,
|
||||||
|
relax_column_count: true,
|
||||||
|
bom: true,
|
||||||
|
quote: '"',
|
||||||
|
escape: '"',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let batch = [];
|
||||||
|
let total = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for await (const record of parser) {
|
||||||
|
const lat = cleanFloat(record.latitude);
|
||||||
|
const lon = cleanFloat(record.longitude);
|
||||||
|
|
||||||
|
// Skip rows with invalid coordinates
|
||||||
|
if (lat === null || lon === null || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nom = cleanStr(record.nom);
|
||||||
|
const adresse = cleanStr(record.adresse);
|
||||||
|
const horaires = cleanStr(record.horaires);
|
||||||
|
const acces = cleanStr(record.acces);
|
||||||
|
const disponible_24h = cleanInt(record.disponible_24h);
|
||||||
|
const horaires_std = cleanStr(record.horaires_std) || "{}";
|
||||||
|
const id = deterministicId(lat, lon, nom, adresse);
|
||||||
|
const h3Cell = computeH3(lat, lon, H3_RES);
|
||||||
|
|
||||||
|
batch.push({
|
||||||
|
id,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
nom,
|
||||||
|
adresse,
|
||||||
|
horaires,
|
||||||
|
horaires_std,
|
||||||
|
acces,
|
||||||
|
disponible_24h,
|
||||||
|
h3: h3Cell,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (batch.length >= BATCH_SIZE) {
|
||||||
|
insertMany(batch);
|
||||||
|
total += batch.length;
|
||||||
|
batch = [];
|
||||||
|
process.stdout.write(`\rInserted ${total} rows...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush remaining
|
||||||
|
if (batch.length > 0) {
|
||||||
|
insertMany(batch);
|
||||||
|
total += batch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone: ${total} rows inserted, ${skipped} skipped.`);
|
||||||
|
|
||||||
|
// Restore safe PRAGMAs for the shipped DB
|
||||||
|
db.pragma("journal_mode = DELETE");
|
||||||
|
db.pragma("synchronous = NORMAL");
|
||||||
|
|
||||||
|
// VACUUM to compact
|
||||||
|
db.exec("VACUUM");
|
||||||
|
|
||||||
|
// Final stats
|
||||||
|
const count = db.prepare("SELECT count(*) AS cnt FROM defibs").get();
|
||||||
|
const pageCount = db.pragma("page_count", { simple: true });
|
||||||
|
const pageSize = db.pragma("page_size", { simple: true });
|
||||||
|
const sizeBytes = pageCount * pageSize;
|
||||||
|
console.log(`DB rows: ${count.cnt}`);
|
||||||
|
console.log(`DB size: ${(sizeBytes / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
45
scripts/dae/download-geodae.mjs
Normal file
45
scripts/dae/download-geodae.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Download the GeoDAE JSON file from data.gouv.fr
|
||||||
|
// Source: https://www.data.gouv.fr/datasets/geodae-base-nationale-des-defibrillateurs
|
||||||
|
// Resource ID: 86ea48a0-dd94-4a23-b71c-80d3041d7db2
|
||||||
|
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const RESOURCE_ID = "86ea48a0-dd94-4a23-b71c-80d3041d7db2";
|
||||||
|
const DOWNLOAD_URL = `https://www.data.gouv.fr/api/1/datasets/r/${RESOURCE_ID}`;
|
||||||
|
const OUTPUT = join(__dirname, "geodae.json");
|
||||||
|
|
||||||
|
async function download() {
|
||||||
|
console.log(`Downloading GeoDAE data from data.gouv.fr ...`);
|
||||||
|
console.log(`URL: ${DOWNLOAD_URL}`);
|
||||||
|
|
||||||
|
const response = await fetch(DOWNLOAD_URL, { redirect: "follow" });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Download failed: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = response.headers.get("content-length");
|
||||||
|
if (contentLength) {
|
||||||
|
console.log(
|
||||||
|
`File size: ${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline(response.body, createWriteStream(OUTPUT));
|
||||||
|
|
||||||
|
console.log(`Saved to ${OUTPUT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
download().catch((err) => {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
451
scripts/dae/geodae-to-csv.js
Normal file
451
scripts/dae/geodae-to-csv.js
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { normalizeHoraires } from "./lib/normalize-horaires.mjs";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const INPUT = join(__dirname, "geodae.json");
|
||||||
|
const OUTPUT = join(__dirname, "geodae.csv");
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function escapeCsv(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
// Replace newlines and tabs with spaces to keep one row per entry
|
||||||
|
const str = String(value)
|
||||||
|
.replace(/[\r\n\t]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
if (str.includes('"') || str.includes(",")) {
|
||||||
|
return '"' + str.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_ABBREV = {
|
||||||
|
lundi: "Lun",
|
||||||
|
mardi: "Mar",
|
||||||
|
mercredi: "Mer",
|
||||||
|
jeudi: "Jeu",
|
||||||
|
vendredi: "Ven",
|
||||||
|
samedi: "Sam",
|
||||||
|
dimanche: "Dim",
|
||||||
|
};
|
||||||
|
const DAY_ORDER = [
|
||||||
|
"lundi",
|
||||||
|
"mardi",
|
||||||
|
"mercredi",
|
||||||
|
"jeudi",
|
||||||
|
"vendredi",
|
||||||
|
"samedi",
|
||||||
|
"dimanche",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAY_NAMES_PATTERN =
|
||||||
|
/lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche/i;
|
||||||
|
const DAY_NAMES_EN_PATTERN =
|
||||||
|
/\b(mon|tue|wed|thu|fri|sat|sun)\b|mo-|tu-|we-|th-|fr-|sa-|su-/i;
|
||||||
|
const HOUR_PATTERN = /\d+[h:]\d*|\d+ ?heures?\b/;
|
||||||
|
|
||||||
|
function formatDays(arr) {
|
||||||
|
if (!arr || arr.length === 0) return "";
|
||||||
|
if (arr.length === 1) {
|
||||||
|
const val = arr[0].toLowerCase().trim();
|
||||||
|
if (val === "7j/7") return "7j/7";
|
||||||
|
if (val === "non renseigné" || val === "non renseigne") return "";
|
||||||
|
if (DAY_ABBREV[val]) return DAY_ABBREV[val];
|
||||||
|
return arr[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort days by canonical order
|
||||||
|
const sorted = arr
|
||||||
|
.filter((d) => d != null)
|
||||||
|
.map((d) => d.toLowerCase().trim())
|
||||||
|
.filter((d) => DAY_ORDER.includes(d))
|
||||||
|
.sort((a, b) => DAY_ORDER.indexOf(a) - DAY_ORDER.indexOf(b));
|
||||||
|
|
||||||
|
if (sorted.length === 0) return arr.filter((d) => d != null).join(", ");
|
||||||
|
if (sorted.length === 7) return "7j/7";
|
||||||
|
|
||||||
|
// Detect consecutive range
|
||||||
|
const indices = sorted.map((d) => DAY_ORDER.indexOf(d));
|
||||||
|
const isConsecutive = indices.every(
|
||||||
|
(idx, i) => i === 0 || idx === indices[i - 1] + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isConsecutive && sorted.length >= 2) {
|
||||||
|
return DAY_ABBREV[sorted[0]] + "-" + DAY_ABBREV[sorted[sorted.length - 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted.map((d) => DAY_ABBREV[d] || d).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHours(arr) {
|
||||||
|
if (!arr || arr.length === 0) return "";
|
||||||
|
const cleaned = arr
|
||||||
|
.filter((h) => h != null)
|
||||||
|
.map((h) => h.trim())
|
||||||
|
.filter(
|
||||||
|
(h) =>
|
||||||
|
h &&
|
||||||
|
h.toLowerCase() !== "non renseigné" &&
|
||||||
|
h.toLowerCase() !== "non renseigne",
|
||||||
|
);
|
||||||
|
return cleaned.join(" + ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if always available:
|
||||||
|
* - 7j/7 + 24h/24
|
||||||
|
* - OR public (Extérieur + libre access)
|
||||||
|
*/
|
||||||
|
function isAlwaysAvailable(p) {
|
||||||
|
const is247 = is7j7(p.c_disp_j) && is24h(p.c_disp_h);
|
||||||
|
|
||||||
|
const isExterior =
|
||||||
|
p.c_acc &&
|
||||||
|
(p.c_acc.trim().toLowerCase() === "extérieur" ||
|
||||||
|
p.c_acc.trim().toLowerCase() === "exterieur");
|
||||||
|
const isPublic = isExterior && p.c_acc_lib === true;
|
||||||
|
|
||||||
|
return is247 || isPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
function is7j7(arr) {
|
||||||
|
if (!arr) return false;
|
||||||
|
if (arr.some((d) => d && d.trim() === "7j/7")) return true;
|
||||||
|
const days = arr
|
||||||
|
.filter((d) => d != null)
|
||||||
|
.map((d) => d.toLowerCase().trim())
|
||||||
|
.filter((d) => DAY_ORDER.includes(d));
|
||||||
|
return days.length === 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
function is24h(arr) {
|
||||||
|
if (!arr) return false;
|
||||||
|
return arr.some((h) => h && h.trim() === "24h/24");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a single horaires string, merging days/hours/complement smartly.
|
||||||
|
* Returns empty string if always available.
|
||||||
|
*
|
||||||
|
* Heuristic for complement deduplication:
|
||||||
|
* - If complement contains day names → it already describes the full schedule → use complement only
|
||||||
|
* - Else if complement contains hour patterns (refines "heures ouvrables") → use days + complement
|
||||||
|
* - Else → use days + hours + complement (it's purely additional info)
|
||||||
|
*/
|
||||||
|
function buildHoraires(p) {
|
||||||
|
const days = formatDays(p.c_disp_j);
|
||||||
|
const hours = formatHours(p.c_disp_h);
|
||||||
|
const complt = (p.c_disp_complt || "").replace(/[\r\n\t]+/g, " ").trim();
|
||||||
|
|
||||||
|
if (!complt) {
|
||||||
|
// No complement: just days + hours
|
||||||
|
if (days && hours) return days + " " + hours;
|
||||||
|
return days || hours || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has complement: decide how to merge
|
||||||
|
const hasDayNames =
|
||||||
|
DAY_NAMES_PATTERN.test(complt) || DAY_NAMES_EN_PATTERN.test(complt);
|
||||||
|
const hasHours = HOUR_PATTERN.test(complt);
|
||||||
|
|
||||||
|
if (hasDayNames && hasHours) {
|
||||||
|
// Complement is a detailed per-day schedule (e.g. "Lundi au jeudi : 8h30-18h ...")
|
||||||
|
// Use complement only — it's more specific than the base timetable
|
||||||
|
return complt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasHours) {
|
||||||
|
// Complement specifies actual hours (e.g. "8h-18h")
|
||||||
|
// It refines the vague "heures ouvrables" → use days + complement
|
||||||
|
if (days) return days + " " + complt;
|
||||||
|
return complt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complement is purely additional info (e.g. "Ouvert le dimanche...", "fermeture 31/12")
|
||||||
|
const base = days && hours ? days + " " + hours : days || hours || "";
|
||||||
|
if (base) return base + " ; " + complt;
|
||||||
|
return complt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(p) {
|
||||||
|
const parts = [];
|
||||||
|
let num = (p.c_adr_num || "").trim();
|
||||||
|
let street = (p.c_adr_voie || "")
|
||||||
|
.split("\t")[0] // strip tab-separated cp/city embedded in the field
|
||||||
|
.split("|")[0] // strip pipe-separated cp/city embedded in the field
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Drop invalid numbers: placeholders, decimals, letters, etc.
|
||||||
|
// Valid street numbers: digits with optional dash/slash/space separators (e.g. "62", "62-64", "10 12")
|
||||||
|
if (!/^\d[\d\s\-/]*$/.test(num)) num = "";
|
||||||
|
|
||||||
|
const cp = (p.c_com_cp || "").trim();
|
||||||
|
|
||||||
|
// Drop num when it equals the postal code (data entry mistake)
|
||||||
|
if (num && num === cp) num = "";
|
||||||
|
// Strip parenthesized cp from city name, e.g. "GANAC (09000)" → "GANAC"
|
||||||
|
let city = (p.c_com_nom || "").trim();
|
||||||
|
if (cp && city) {
|
||||||
|
city = city
|
||||||
|
.replace(
|
||||||
|
new RegExp(
|
||||||
|
"\\s*\\(" + cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\)",
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip cp+city already embedded in street field
|
||||||
|
// e.g. "Mont Salomon 38200 Vienne" or "62117 rue de Lambres" when cp matches
|
||||||
|
if (cp && street.includes(cp)) {
|
||||||
|
const cpEscaped = cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
// Trailing: "street 38200 Vienne" → "street"
|
||||||
|
street = street
|
||||||
|
.replace(new RegExp("\\s+" + cpEscaped + "\\s+.*$"), "")
|
||||||
|
.trim();
|
||||||
|
// Leading: "62117 rue de Lambres" → "rue de Lambres"
|
||||||
|
street = street.replace(new RegExp("^" + cpEscaped + "\\s+"), "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (num && street) {
|
||||||
|
// Avoid duplicated number when street already starts with the same number
|
||||||
|
// Handles plain "62 Rue…", ranges "62-64 Rue…", and slashes "62/64 Rue…"
|
||||||
|
const numEscaped = num.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const alreadyHasNum = new RegExp("^" + numEscaped + "(?!\\d)").test(street);
|
||||||
|
if (alreadyHasNum) {
|
||||||
|
parts.push(street);
|
||||||
|
} else {
|
||||||
|
parts.push(num + " " + street);
|
||||||
|
}
|
||||||
|
} else if (street) {
|
||||||
|
parts.push(street);
|
||||||
|
} else if (num) {
|
||||||
|
parts.push(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp && city) {
|
||||||
|
parts.push(cp + " " + city);
|
||||||
|
} else if (city) {
|
||||||
|
parts.push(city);
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAccess(p) {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// Indoor/Outdoor
|
||||||
|
if (p.c_acc) parts.push(p.c_acc.trim());
|
||||||
|
|
||||||
|
// Free access
|
||||||
|
if (p.c_acc_lib === true) parts.push("libre");
|
||||||
|
|
||||||
|
// Floor
|
||||||
|
const floor = (p.c_acc_etg || "").trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
floor &&
|
||||||
|
floor !== "0" &&
|
||||||
|
floor !== "rdc" &&
|
||||||
|
floor !== "rez de chaussee" &&
|
||||||
|
floor !== "rez de chaussée"
|
||||||
|
) {
|
||||||
|
parts.push("étage " + p.c_acc_etg.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complement
|
||||||
|
const complt = (p.c_acc_complt || "").trim();
|
||||||
|
if (complt) parts.push(complt);
|
||||||
|
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getName(p) {
|
||||||
|
const expt = (p.c_expt_rais || "").trim();
|
||||||
|
const nom = (p.c_nom || "").trim();
|
||||||
|
return expt || nom || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
return str
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function passesFilter(p) {
|
||||||
|
// c_etat: accept "Actif" or null, reject "Non identifie"
|
||||||
|
const etat = normalize(p.c_etat);
|
||||||
|
if (etat && etat !== "actif") return false;
|
||||||
|
|
||||||
|
// c_etat_fonct: must be "En fonctionnement"
|
||||||
|
const fonct = normalize(p.c_etat_fonct);
|
||||||
|
if (fonct !== "en fonctionnement") return false;
|
||||||
|
|
||||||
|
// c_etat_valid: must be "validées"
|
||||||
|
const valid = normalize(p.c_etat_valid);
|
||||||
|
if (valid !== "validees") return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if coordinates fall in a plausible French territory.
|
||||||
|
*/
|
||||||
|
function isPlausibleFrance(lat, lon) {
|
||||||
|
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return false;
|
||||||
|
// Metropolitan France
|
||||||
|
if (lat >= 41 && lat <= 52 && lon >= -6 && lon <= 11) return true;
|
||||||
|
// La Réunion
|
||||||
|
if (lat >= -22 && lat <= -20 && lon >= 54 && lon <= 57) return true;
|
||||||
|
// Mayotte
|
||||||
|
if (lat >= -14 && lat <= -12 && lon >= 44 && lon <= 46) return true;
|
||||||
|
// Guadeloupe / Martinique / Saint-Martin / Saint-Barthélemy
|
||||||
|
if (lat >= 14 && lat <= 18 && lon >= -64 && lon <= -60) return true;
|
||||||
|
// Guyane
|
||||||
|
if (lat >= 2 && lat <= 6 && lon >= -55 && lon <= -51) return true;
|
||||||
|
// Nouvelle-Calédonie
|
||||||
|
if (lat >= -23 && lat <= -19 && lon >= 163 && lon <= 169) return true;
|
||||||
|
// Polynésie française
|
||||||
|
if (lat >= -28 && lat <= -7 && lon >= -155 && lon <= -130) return true;
|
||||||
|
// Saint-Pierre-et-Miquelon
|
||||||
|
if (lat >= 46 && lat <= 48 && lon >= -57 && lon <= -55) return true;
|
||||||
|
// Wallis-et-Futuna
|
||||||
|
if (lat >= -15 && lat <= -13 && lon >= -179 && lon <= -176) return true;
|
||||||
|
// TAAF (Kerguelen, Crozet, Amsterdam, etc.)
|
||||||
|
if (lat >= -50 && lat <= -37 && lon >= 50 && lon <= 78) return true;
|
||||||
|
// Clipperton
|
||||||
|
if (lat >= 10 && lat <= 11 && lon >= -110 && lon <= -108) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to fix an out-of-range coordinate by dividing by powers of 10.
|
||||||
|
* Returns the fixed value if it falls in [minValid, maxValid], else null.
|
||||||
|
*/
|
||||||
|
function tryNormalizeCoord(val, limit) {
|
||||||
|
if (Math.abs(val) <= limit) return val;
|
||||||
|
let v = val;
|
||||||
|
while (Math.abs(v) > limit) {
|
||||||
|
v /= 10;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to produce valid WGS84 coordinates from potentially garbled input.
|
||||||
|
* Strategy:
|
||||||
|
* 1. Use properties directly if valid
|
||||||
|
* 2. Fall back to GeoJSON geometry (standard [lon, lat] then swapped)
|
||||||
|
* 3. Try power-of-10 normalization for misplaced decimals
|
||||||
|
*/
|
||||||
|
function fixCoordinates(lat, lon, geometry) {
|
||||||
|
// 1. Already valid WGS84 — trust the source as-is
|
||||||
|
if (Math.abs(lat) <= 90 && Math.abs(lon) <= 180) return { lat, lon };
|
||||||
|
|
||||||
|
// Out of WGS84 range — try to recover using fallbacks + plausibility check
|
||||||
|
|
||||||
|
// 2. Try GeoJSON geometry
|
||||||
|
if (geometry && geometry.coordinates) {
|
||||||
|
let coords = geometry.coordinates;
|
||||||
|
// Flatten nested arrays (MultiPoint, etc.)
|
||||||
|
while (Array.isArray(coords[0])) coords = coords[0];
|
||||||
|
if (coords.length === 2) {
|
||||||
|
const [gLon, gLat] = coords; // GeoJSON = [lon, lat]
|
||||||
|
if (isPlausibleFrance(gLat, gLon)) return { lat: gLat, lon: gLon };
|
||||||
|
// Try swapped (some entries have lat/lon inverted in geometry)
|
||||||
|
if (isPlausibleFrance(gLon, gLat)) return { lat: gLon, lon: gLat };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try power-of-10 normalization for misplaced decimals
|
||||||
|
const fixedLat = tryNormalizeCoord(lat, 90);
|
||||||
|
const fixedLon = tryNormalizeCoord(lon, 180);
|
||||||
|
if (isPlausibleFrance(fixedLat, fixedLon))
|
||||||
|
return { lat: fixedLat, lon: fixedLon };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main ---
|
||||||
|
|
||||||
|
console.log("Reading geodae.json...");
|
||||||
|
const data = JSON.parse(readFileSync(INPUT, "utf-8"));
|
||||||
|
const features = data.features;
|
||||||
|
console.log(`Total features: ${features.length}`);
|
||||||
|
|
||||||
|
const CSV_HEADER = [
|
||||||
|
"latitude",
|
||||||
|
"longitude",
|
||||||
|
"nom",
|
||||||
|
"adresse",
|
||||||
|
"horaires",
|
||||||
|
"horaires_std",
|
||||||
|
"acces",
|
||||||
|
"disponible_24h",
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = [CSV_HEADER.join(",")];
|
||||||
|
let filtered = 0;
|
||||||
|
let kept = 0;
|
||||||
|
let alwaysCount = 0;
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
const p = feature.properties;
|
||||||
|
|
||||||
|
if (!passesFilter(p)) {
|
||||||
|
filtered++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLat = p.c_lat_coor1;
|
||||||
|
const rawLon = p.c_long_coor1;
|
||||||
|
if (rawLat == null || rawLon == null) {
|
||||||
|
filtered++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixed = fixCoordinates(rawLat, rawLon, feature.geometry);
|
||||||
|
if (!fixed) {
|
||||||
|
filtered++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { lat, lon } = fixed;
|
||||||
|
|
||||||
|
const always = isAlwaysAvailable(p);
|
||||||
|
if (always) alwaysCount++;
|
||||||
|
|
||||||
|
const disponible24h = always ? 1 : 0;
|
||||||
|
|
||||||
|
// When always available, leave horaires empty
|
||||||
|
const horaires = always ? "" : buildHoraires(p);
|
||||||
|
|
||||||
|
// Normalize horaires into structured JSON
|
||||||
|
const horairesStd = normalizeHoraires(horaires, disponible24h);
|
||||||
|
|
||||||
|
const row = [
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
escapeCsv(getName(p)),
|
||||||
|
escapeCsv(formatAddress(p)),
|
||||||
|
escapeCsv(horaires),
|
||||||
|
escapeCsv(JSON.stringify(horairesStd)),
|
||||||
|
escapeCsv(formatAccess(p)),
|
||||||
|
disponible24h,
|
||||||
|
];
|
||||||
|
|
||||||
|
rows.push(row.join(","));
|
||||||
|
kept++;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(OUTPUT, rows.join("\n") + "\n", "utf-8");
|
||||||
|
console.log(`Kept: ${kept}, Filtered out: ${filtered}`);
|
||||||
|
console.log(`Always available (24h): ${alwaysCount}`);
|
||||||
|
console.log(`Written to ${OUTPUT}`);
|
||||||
228
scripts/dae/lib/normalize-horaires.mjs
Normal file
228
scripts/dae/lib/normalize-horaires.mjs
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
// Deterministic normalizer for French opening hours (horaires) strings.
|
||||||
|
// Outputs a structured object that a simple JSON parser can consume without heuristics.
|
||||||
|
//
|
||||||
|
// Output shape:
|
||||||
|
// { days: number[]|null, slots: {open,close}[]|null, is24h, businessHours, nightHours, events, notes }
|
||||||
|
//
|
||||||
|
// days: ISO 8601 day numbers (1=Mon … 7=Sun), null if unknown
|
||||||
|
// slots: [{open:"HH:MM", close:"HH:MM"}], null if no specific times
|
||||||
|
// is24h: available 24 hours
|
||||||
|
// businessHours: "heures ouvrables" was specified
|
||||||
|
// nightHours: "heures de nuit" was specified
|
||||||
|
// events: availability depends on events
|
||||||
|
// notes: unparsed/remaining text (seasonal info, conditions, etc.)
|
||||||
|
|
||||||
|
const DAY_MAP = { lun: 1, mar: 2, mer: 3, jeu: 4, ven: 5, sam: 6, dim: 7 };
|
||||||
|
const ALL_DAYS = [1, 2, 3, 4, 5, 6, 7];
|
||||||
|
|
||||||
|
// --- Day prefix extraction ---
|
||||||
|
|
||||||
|
const SEVEN_DAYS_RE = /^7\s*j?\s*[/]\s*7\s*j?/i;
|
||||||
|
const DAY_RANGE_RE =
|
||||||
|
/^(lun|mar|mer|jeu|ven|sam|dim)\s*-\s*(lun|mar|mer|jeu|ven|sam|dim)/i;
|
||||||
|
const DAY_LIST_RE =
|
||||||
|
/^((lun|mar|mer|jeu|ven|sam|dim)(\s*,\s*(lun|mar|mer|jeu|ven|sam|dim))+)/i;
|
||||||
|
const DAY_SINGLE_RE = /^(lun|mar|mer|jeu|ven|sam|dim)\b/i;
|
||||||
|
|
||||||
|
function dayRange(startName, endName) {
|
||||||
|
const start = DAY_MAP[startName.toLowerCase()];
|
||||||
|
const end = DAY_MAP[endName.toLowerCase()];
|
||||||
|
const days = [];
|
||||||
|
let d = start;
|
||||||
|
do {
|
||||||
|
days.push(d);
|
||||||
|
if (d === end) break;
|
||||||
|
d = (d % 7) + 1;
|
||||||
|
} while (days.length <= 7);
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDayPrefix(text) {
|
||||||
|
const m7 = text.match(SEVEN_DAYS_RE);
|
||||||
|
if (m7) return { days: [...ALL_DAYS], end: m7[0].length };
|
||||||
|
|
||||||
|
const mRange = text.match(DAY_RANGE_RE);
|
||||||
|
if (mRange)
|
||||||
|
return {
|
||||||
|
days: dayRange(mRange[1], mRange[2]),
|
||||||
|
end: mRange[0].length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mList = text.match(DAY_LIST_RE);
|
||||||
|
if (mList) {
|
||||||
|
const names = mList[0].split(/\s*,\s*/);
|
||||||
|
return {
|
||||||
|
days: names.map((n) => DAY_MAP[n.trim().toLowerCase()]).filter(Boolean),
|
||||||
|
end: mList[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mSingle = text.match(DAY_SINGLE_RE);
|
||||||
|
if (mSingle)
|
||||||
|
return { days: [DAY_MAP[mSingle[1].toLowerCase()]], end: mSingle[0].length };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Redundant day info stripping ---
|
||||||
|
|
||||||
|
function stripRedundantDays(text) {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
// "7J/7", "7j/7", "7/7", "7j/7j"
|
||||||
|
.replace(/\b7\s*[jJ]?\s*[/]\s*7\s*[jJ]?\b/g, "")
|
||||||
|
// "L au V", "Ma à D" (short abbreviations)
|
||||||
|
.replace(
|
||||||
|
/\b(?:L|Ma|Me|J|V|S|D)\s+(?:au|à)\s+(?:L|Ma|Me|J|V|S|D)\b/gi,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
// "du lundi au dimanche" (full names)
|
||||||
|
.replace(
|
||||||
|
/\b(?:du\s+)?(?:lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\s+(?:au|à)\s+(?:lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b/gi,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
// "L au V" using abbreviated day names from data: "L Ma Me J V S D"
|
||||||
|
.replace(
|
||||||
|
/\b[LMJVSD]\s+(?:au|à)\s+[LMJVSD]\b/gi,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.replace(/^[,;:\-\s]+/, "")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Time slot extraction ---
|
||||||
|
|
||||||
|
function fmtTime(h, m) {
|
||||||
|
const hh = parseInt(h, 10);
|
||||||
|
const mm = parseInt(m || "0", 10);
|
||||||
|
if (hh < 0 || hh > 24 || mm < 0 || mm > 59) return null;
|
||||||
|
return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches: 8h30/17h30, 8h-18h, 08:00-18:00, 8h à 18h, 8h a 18h
|
||||||
|
// IMPORTANT: no \s* between [:h] and (\d{0,2}) — minutes must be adjacent
|
||||||
|
// to the separator, otherwise "8h/12h 14h/17h" would merge into one match.
|
||||||
|
const TIME_RANGE_RE =
|
||||||
|
/(\d{1,2})\s*[:h](\d{0,2})\s*(?:[-/à]|\ba\b)\s*(\d{1,2})\s*[:h](\d{0,2})/g;
|
||||||
|
|
||||||
|
// Matches standalone: 8h30, 14h (minutes adjacent to h)
|
||||||
|
const TIME_POINT_RE = /(\d{1,2})\s*h(\d{0,2})/g;
|
||||||
|
|
||||||
|
function extractTimeSlots(text) {
|
||||||
|
const slots = [];
|
||||||
|
|
||||||
|
// Pass 1: explicit ranges (8h/18h, 8h-18h, 08:00-18:00)
|
||||||
|
const re1 = new RegExp(TIME_RANGE_RE.source, "g");
|
||||||
|
let match;
|
||||||
|
while ((match = re1.exec(text)) !== null) {
|
||||||
|
const open = fmtTime(match[1], match[2]);
|
||||||
|
const close = fmtTime(match[3], match[4]);
|
||||||
|
if (open && close) slots.push({ open, close });
|
||||||
|
}
|
||||||
|
if (slots.length > 0) return slots;
|
||||||
|
|
||||||
|
// Pass 2: pair standalone time points (7h 17h → {07:00, 17:00})
|
||||||
|
const re2 = new RegExp(TIME_POINT_RE.source, "g");
|
||||||
|
const points = [];
|
||||||
|
while ((match = re2.exec(text)) !== null) {
|
||||||
|
const t = fmtTime(match[1], match[2]);
|
||||||
|
if (t) points.push(t);
|
||||||
|
}
|
||||||
|
for (let i = 0; i + 1 < points.length; i += 2) {
|
||||||
|
slots.push({ open: points[i], close: points[i + 1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTimeTokens(text) {
|
||||||
|
return text
|
||||||
|
.replace(
|
||||||
|
/(\d{1,2})\s*[:h](\d{0,2})\s*(?:[-/à]|\ba\b)\s*(\d{1,2})\s*[:h](\d{0,2})/g,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.replace(/(\d{1,2})\s*h(\d{0,2})/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main normalizer ---
|
||||||
|
|
||||||
|
export function normalizeHoraires(raw, disponible24h) {
|
||||||
|
const result = {
|
||||||
|
days: null,
|
||||||
|
slots: null,
|
||||||
|
is24h: disponible24h === 1,
|
||||||
|
businessHours: false,
|
||||||
|
nightHours: false,
|
||||||
|
events: false,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (disponible24h === 1) {
|
||||||
|
result.days = [...ALL_DAYS];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw || raw.trim() === "") return result;
|
||||||
|
|
||||||
|
let text = raw.trim();
|
||||||
|
|
||||||
|
// 1. Extract day prefix
|
||||||
|
const dayPrefix = extractDayPrefix(text);
|
||||||
|
if (dayPrefix) {
|
||||||
|
if (!result.days) result.days = dayPrefix.days;
|
||||||
|
text = text.slice(dayPrefix.end).trim();
|
||||||
|
// Strip leading comma/semicolon + optional modifiers after day prefix
|
||||||
|
text = text.replace(/^[,;]\s*/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. "jours fériés" modifier (informational, strip it)
|
||||||
|
text = text.replace(/,?\s*jours?\s+f[ée]ri[ée]s?\s*/gi, "").trim();
|
||||||
|
|
||||||
|
// 3. 24h/24 detection
|
||||||
|
if (/24\s*h?\s*[/]\s*24\s*h?/i.test(text)) {
|
||||||
|
result.is24h = true;
|
||||||
|
text = text.replace(/24\s*h?\s*[/]\s*24\s*h?/gi, "").trim();
|
||||||
|
if (!result.days) result.days = [...ALL_DAYS];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. "heures ouvrables"
|
||||||
|
if (/heures?\s+ouvrables?/i.test(text)) {
|
||||||
|
result.businessHours = true;
|
||||||
|
text = text.replace(/heures?\s+ouvrables?/gi, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. "heures de nuit"
|
||||||
|
if (/heures?\s+de\s+nuit/i.test(text)) {
|
||||||
|
result.nightHours = true;
|
||||||
|
text = text.replace(/heures?\s+de\s+nuit/gi, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. "événements"
|
||||||
|
if (/[ée]v[éè]nements?/i.test(text)) {
|
||||||
|
result.events = true;
|
||||||
|
text = text.replace(/[ée]v[éè]nements?/gi, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Strip redundant day info (e.g., "7J/7", "L au V")
|
||||||
|
text = stripRedundantDays(text);
|
||||||
|
|
||||||
|
// 8. Extract time slots (max 4 to cover morning+afternoon+evening combos)
|
||||||
|
if (!result.is24h) {
|
||||||
|
const slots = extractTimeSlots(text);
|
||||||
|
if (slots.length > 0) {
|
||||||
|
result.slots = slots.slice(0, 4);
|
||||||
|
text = removeTimeTokens(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Clean remaining text → notes
|
||||||
|
text = text
|
||||||
|
.replace(/^[;,\-/+.\s]+/, "")
|
||||||
|
.replace(/[;,\-/+.\s]+$/, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
if (text) result.notes = text;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
16
scripts/dae/lib/schema.sql
Normal file
16
scripts/dae/lib/schema.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS defibs (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
latitude REAL NOT NULL,
|
||||||
|
longitude REAL NOT NULL,
|
||||||
|
nom TEXT NOT NULL DEFAULT '',
|
||||||
|
adresse TEXT NOT NULL DEFAULT '',
|
||||||
|
horaires TEXT NOT NULL DEFAULT '',
|
||||||
|
horaires_std TEXT NOT NULL DEFAULT '{}',
|
||||||
|
acces TEXT NOT NULL DEFAULT '',
|
||||||
|
disponible_24h INTEGER NOT NULL DEFAULT 0,
|
||||||
|
h3 TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_defibs_h3 ON defibs (h3);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_defibs_latlon ON defibs (latitude, longitude);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_defibs_dispo ON defibs (disponible_24h);
|
||||||
19
scripts/dae/package.json
Normal file
19
scripts/dae/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "geodae-pipeline",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "yarn@4.5.3",
|
||||||
|
"scripts": {
|
||||||
|
"download": "node download-geodae.mjs",
|
||||||
|
"json-to-csv": "node geodae-to-csv.js",
|
||||||
|
"csv-to-db": "node csv-to-sqlite.mjs --input geodae.csv --output ../../src/assets/db/geodae.db",
|
||||||
|
"csv-to-db:semicolon": "node csv-to-sqlite.mjs --input geodae.csv --output ../../src/assets/db/geodae.db --delimiter ';'",
|
||||||
|
"build": "yarn download && yarn json-to-csv && yarn csv-to-db"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.7.0",
|
||||||
|
"csv-parse": "^5.6.0",
|
||||||
|
"h3-js": "^4.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
889
scripts/dae/yarn.lock
Normal file
889
scripts/dae/yarn.lock
Normal file
|
|
@ -0,0 +1,889 @@
|
||||||
|
# This file is generated by running "yarn install" inside your project.
|
||||||
|
# Manual changes might be lost - proceed with caution!
|
||||||
|
|
||||||
|
__metadata:
|
||||||
|
version: 8
|
||||||
|
cacheKey: 10
|
||||||
|
|
||||||
|
"@gar/promise-retry@npm:^1.0.0":
|
||||||
|
version: 1.0.2
|
||||||
|
resolution: "@gar/promise-retry@npm:1.0.2"
|
||||||
|
dependencies:
|
||||||
|
retry: "npm:^0.13.1"
|
||||||
|
checksum: 10/b91326999ce94677cbe91973079eabc689761a93a045f6a2d34d4070e9305b27f6c54e4021688c7080cb14caf89eafa0c0f300af741b94c20d18608bdb66ca46
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@isaacs/fs-minipass@npm:^4.0.0":
|
||||||
|
version: 4.0.1
|
||||||
|
resolution: "@isaacs/fs-minipass@npm:4.0.1"
|
||||||
|
dependencies:
|
||||||
|
minipass: "npm:^7.0.4"
|
||||||
|
checksum: 10/4412e9e6713c89c1e66d80bb0bb5a2a93192f10477623a27d08f228ba0316bb880affabc5bfe7f838f58a34d26c2c190da726e576cdfc18c49a72e89adabdcf5
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@npmcli/agent@npm:^4.0.0":
|
||||||
|
version: 4.0.0
|
||||||
|
resolution: "@npmcli/agent@npm:4.0.0"
|
||||||
|
dependencies:
|
||||||
|
agent-base: "npm:^7.1.0"
|
||||||
|
http-proxy-agent: "npm:^7.0.0"
|
||||||
|
https-proxy-agent: "npm:^7.0.1"
|
||||||
|
lru-cache: "npm:^11.2.1"
|
||||||
|
socks-proxy-agent: "npm:^8.0.3"
|
||||||
|
checksum: 10/1a81573becc60515031accc696e6405e9b894e65c12b98ef4aeee03b5617c41948633159dbf6caf5dde5b47367eeb749bdc7b7dfb21960930a9060a935c6f636
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@npmcli/fs@npm:^5.0.0":
|
||||||
|
version: 5.0.0
|
||||||
|
resolution: "@npmcli/fs@npm:5.0.0"
|
||||||
|
dependencies:
|
||||||
|
semver: "npm:^7.3.5"
|
||||||
|
checksum: 10/4935c7719d17830d0f9fa46c50be17b2a3c945cec61760f6d0909bce47677c42e1810ca673305890f9e84f008ec4d8e841182f371e42100a8159d15f22249208
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"abbrev@npm:^4.0.0":
|
||||||
|
version: 4.0.0
|
||||||
|
resolution: "abbrev@npm:4.0.0"
|
||||||
|
checksum: 10/e2f0c6a6708ad738b3e8f50233f4800de31ad41a6cdc50e0cbe51b76fed69fd0213516d92c15ce1a9985fca71a14606a9be22bf00f8475a58987b9bfb671c582
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2":
|
||||||
|
version: 7.1.4
|
||||||
|
resolution: "agent-base@npm:7.1.4"
|
||||||
|
checksum: 10/79bef167247789f955aaba113bae74bf64aa1e1acca4b1d6bb444bdf91d82c3e07e9451ef6a6e2e35e8f71a6f97ce33e3d855a5328eb9fad1bc3cc4cfd031ed8
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"balanced-match@npm:^4.0.2":
|
||||||
|
version: 4.0.4
|
||||||
|
resolution: "balanced-match@npm:4.0.4"
|
||||||
|
checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"base64-js@npm:^1.3.1":
|
||||||
|
version: 1.5.1
|
||||||
|
resolution: "base64-js@npm:1.5.1"
|
||||||
|
checksum: 10/669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"better-sqlite3@npm:^11.7.0":
|
||||||
|
version: 11.10.0
|
||||||
|
resolution: "better-sqlite3@npm:11.10.0"
|
||||||
|
dependencies:
|
||||||
|
bindings: "npm:^1.5.0"
|
||||||
|
node-gyp: "npm:latest"
|
||||||
|
prebuild-install: "npm:^7.1.1"
|
||||||
|
checksum: 10/5e4c7437c4fe6033335a79c82974d7ab29f33c51c36f48b73e87e087d21578468575de1c56a7badd4f76f17255e25abefddaeacf018e5eeb9e0cb8d6e3e4a5e1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"bindings@npm:^1.5.0":
|
||||||
|
version: 1.5.0
|
||||||
|
resolution: "bindings@npm:1.5.0"
|
||||||
|
dependencies:
|
||||||
|
file-uri-to-path: "npm:1.0.0"
|
||||||
|
checksum: 10/593d5ae975ffba15fbbb4788fe5abd1e125afbab849ab967ab43691d27d6483751805d98cb92f7ac24a2439a8a8678cd0131c535d5d63de84e383b0ce2786133
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"bl@npm:^4.0.3":
|
||||||
|
version: 4.1.0
|
||||||
|
resolution: "bl@npm:4.1.0"
|
||||||
|
dependencies:
|
||||||
|
buffer: "npm:^5.5.0"
|
||||||
|
inherits: "npm:^2.0.4"
|
||||||
|
readable-stream: "npm:^3.4.0"
|
||||||
|
checksum: 10/b7904e66ed0bdfc813c06ea6c3e35eafecb104369dbf5356d0f416af90c1546de3b74e5b63506f0629acf5e16a6f87c3798f16233dcff086e9129383aa02ab55
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"brace-expansion@npm:^5.0.2":
|
||||||
|
version: 5.0.4
|
||||||
|
resolution: "brace-expansion@npm:5.0.4"
|
||||||
|
dependencies:
|
||||||
|
balanced-match: "npm:^4.0.2"
|
||||||
|
checksum: 10/cfd57e20d8ded9578149e47ae4d3fff2b2f78d06b54a32a73057bddff65c8e9b930613f0cbcfefedf12dd117151e19d4da16367d5127c54f3bff02d8a4479bb2
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"buffer@npm:^5.5.0":
|
||||||
|
version: 5.7.1
|
||||||
|
resolution: "buffer@npm:5.7.1"
|
||||||
|
dependencies:
|
||||||
|
base64-js: "npm:^1.3.1"
|
||||||
|
ieee754: "npm:^1.1.13"
|
||||||
|
checksum: 10/997434d3c6e3b39e0be479a80288875f71cd1c07d75a3855e6f08ef848a3c966023f79534e22e415ff3a5112708ce06127277ab20e527146d55c84566405c7c6
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"cacache@npm:^20.0.1":
|
||||||
|
version: 20.0.3
|
||||||
|
resolution: "cacache@npm:20.0.3"
|
||||||
|
dependencies:
|
||||||
|
"@npmcli/fs": "npm:^5.0.0"
|
||||||
|
fs-minipass: "npm:^3.0.0"
|
||||||
|
glob: "npm:^13.0.0"
|
||||||
|
lru-cache: "npm:^11.1.0"
|
||||||
|
minipass: "npm:^7.0.3"
|
||||||
|
minipass-collect: "npm:^2.0.1"
|
||||||
|
minipass-flush: "npm:^1.0.5"
|
||||||
|
minipass-pipeline: "npm:^1.2.4"
|
||||||
|
p-map: "npm:^7.0.2"
|
||||||
|
ssri: "npm:^13.0.0"
|
||||||
|
unique-filename: "npm:^5.0.0"
|
||||||
|
checksum: 10/388a0169970df9d051da30437f93f81b7e91efb570ad0ff2b8fde33279fbe726c1bc8e8e2b9c05053ffb4f563854c73db395e8712e3b62347a1bc4f7fb8899ff
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"chownr@npm:^1.1.1":
|
||||||
|
version: 1.1.4
|
||||||
|
resolution: "chownr@npm:1.1.4"
|
||||||
|
checksum: 10/115648f8eb38bac5e41c3857f3e663f9c39ed6480d1349977c4d96c95a47266fcacc5a5aabf3cb6c481e22d72f41992827db47301851766c4fd77ac21a4f081d
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"chownr@npm:^3.0.0":
|
||||||
|
version: 3.0.0
|
||||||
|
resolution: "chownr@npm:3.0.0"
|
||||||
|
checksum: 10/b63cb1f73d171d140a2ed8154ee6566c8ab775d3196b0e03a2a94b5f6a0ce7777ee5685ca56849403c8d17bd457a6540672f9a60696a6137c7a409097495b82c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"csv-parse@npm:^5.6.0":
|
||||||
|
version: 5.6.0
|
||||||
|
resolution: "csv-parse@npm:5.6.0"
|
||||||
|
checksum: 10/4c82e11f50ae0ccbac2aed716ef2502d0468bf96552083561db789fc0258ee4bb0a30106fcfb2684f153cb4042f0413e0eac3645d5466874803b7ccdeba67ac8
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"debug@npm:4, debug@npm:^4.3.4":
|
||||||
|
version: 4.4.3
|
||||||
|
resolution: "debug@npm:4.4.3"
|
||||||
|
dependencies:
|
||||||
|
ms: "npm:^2.1.3"
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"decompress-response@npm:^6.0.0":
|
||||||
|
version: 6.0.0
|
||||||
|
resolution: "decompress-response@npm:6.0.0"
|
||||||
|
dependencies:
|
||||||
|
mimic-response: "npm:^3.1.0"
|
||||||
|
checksum: 10/d377cf47e02d805e283866c3f50d3d21578b779731e8c5072d6ce8c13cc31493db1c2f6784da9d1d5250822120cefa44f1deab112d5981015f2e17444b763812
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"deep-extend@npm:^0.6.0":
|
||||||
|
version: 0.6.0
|
||||||
|
resolution: "deep-extend@npm:0.6.0"
|
||||||
|
checksum: 10/7be7e5a8d468d6b10e6a67c3de828f55001b6eb515d014f7aeb9066ce36bd5717161eb47d6a0f7bed8a9083935b465bc163ee2581c8b128d29bf61092fdf57a7
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"detect-libc@npm:^2.0.0":
|
||||||
|
version: 2.1.2
|
||||||
|
resolution: "detect-libc@npm:2.1.2"
|
||||||
|
checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1":
|
||||||
|
version: 1.4.5
|
||||||
|
resolution: "end-of-stream@npm:1.4.5"
|
||||||
|
dependencies:
|
||||||
|
once: "npm:^1.4.0"
|
||||||
|
checksum: 10/1e0cfa6e7f49887544e03314f9dfc56a8cb6dde910cbb445983ecc2ff426fc05946df9d75d8a21a3a64f2cecfe1bf88f773952029f46756b2ed64a24e95b1fb8
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"env-paths@npm:^2.2.0":
|
||||||
|
version: 2.2.1
|
||||||
|
resolution: "env-paths@npm:2.2.1"
|
||||||
|
checksum: 10/65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"expand-template@npm:^2.0.3":
|
||||||
|
version: 2.0.3
|
||||||
|
resolution: "expand-template@npm:2.0.3"
|
||||||
|
checksum: 10/588c19847216421ed92befb521767b7018dc88f88b0576df98cb242f20961425e96a92cbece525ef28cc5becceae5d544ae0f5b9b5e2aa05acb13716ca5b3099
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"exponential-backoff@npm:^3.1.1":
|
||||||
|
version: 3.1.3
|
||||||
|
resolution: "exponential-backoff@npm:3.1.3"
|
||||||
|
checksum: 10/ca25962b4bbab943b7c4ed0b5228e263833a5063c65e1cdeac4be9afad350aae5466e8e619b5051f4f8d37b2144a2d6e8fcc771b6cc82934f7dade2f964f652c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"fdir@npm:^6.5.0":
|
||||||
|
version: 6.5.0
|
||||||
|
resolution: "fdir@npm:6.5.0"
|
||||||
|
peerDependencies:
|
||||||
|
picomatch: ^3 || ^4
|
||||||
|
peerDependenciesMeta:
|
||||||
|
picomatch:
|
||||||
|
optional: true
|
||||||
|
checksum: 10/14ca1c9f0a0e8f4f2e9bf4e8551065a164a09545dae548c12a18d238b72e51e5a7b39bd8e5494b56463a0877672d0a6c1ef62c6fa0677db1b0c847773be939b1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"file-uri-to-path@npm:1.0.0":
|
||||||
|
version: 1.0.0
|
||||||
|
resolution: "file-uri-to-path@npm:1.0.0"
|
||||||
|
checksum: 10/b648580bdd893a008c92c7ecc96c3ee57a5e7b6c4c18a9a09b44fb5d36d79146f8e442578bc0e173dc027adf3987e254ba1dfd6e3ec998b7c282873010502144
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"fs-constants@npm:^1.0.0":
|
||||||
|
version: 1.0.0
|
||||||
|
resolution: "fs-constants@npm:1.0.0"
|
||||||
|
checksum: 10/18f5b718371816155849475ac36c7d0b24d39a11d91348cfcb308b4494824413e03572c403c86d3a260e049465518c4f0d5bd00f0371cdfcad6d4f30a85b350d
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"fs-minipass@npm:^3.0.0":
|
||||||
|
version: 3.0.3
|
||||||
|
resolution: "fs-minipass@npm:3.0.3"
|
||||||
|
dependencies:
|
||||||
|
minipass: "npm:^7.0.3"
|
||||||
|
checksum: 10/af143246cf6884fe26fa281621d45cfe111d34b30535a475bfa38dafe343dadb466c047a924ffc7d6b7b18265df4110224ce3803806dbb07173bf2087b648d7f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"geodae-pipeline@workspace:.":
|
||||||
|
version: 0.0.0-use.local
|
||||||
|
resolution: "geodae-pipeline@workspace:."
|
||||||
|
dependencies:
|
||||||
|
better-sqlite3: "npm:^11.7.0"
|
||||||
|
csv-parse: "npm:^5.6.0"
|
||||||
|
h3-js: "npm:^4.2.1"
|
||||||
|
languageName: unknown
|
||||||
|
linkType: soft
|
||||||
|
|
||||||
|
"github-from-package@npm:0.0.0":
|
||||||
|
version: 0.0.0
|
||||||
|
resolution: "github-from-package@npm:0.0.0"
|
||||||
|
checksum: 10/2a091ba07fbce22205642543b4ea8aaf068397e1433c00ae0f9de36a3607baf5bcc14da97fbb798cfca6393b3c402031fca06d8b491a44206d6efef391c58537
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"glob@npm:^13.0.0":
|
||||||
|
version: 13.0.6
|
||||||
|
resolution: "glob@npm:13.0.6"
|
||||||
|
dependencies:
|
||||||
|
minimatch: "npm:^10.2.2"
|
||||||
|
minipass: "npm:^7.1.3"
|
||||||
|
path-scurry: "npm:^2.0.2"
|
||||||
|
checksum: 10/201ad69e5f0aa74e1d8c00a481581f8b8c804b6a4fbfabeeb8541f5d756932800331daeba99b58fb9e4cd67e12ba5a7eba5b82fb476691588418060b84353214
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"graceful-fs@npm:^4.2.6":
|
||||||
|
version: 4.2.11
|
||||||
|
resolution: "graceful-fs@npm:4.2.11"
|
||||||
|
checksum: 10/bf152d0ed1dc159239db1ba1f74fdbc40cb02f626770dcd5815c427ce0688c2635a06ed69af364396da4636d0408fcf7d4afdf7881724c3307e46aff30ca49e2
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"h3-js@npm:^4.2.1":
|
||||||
|
version: 4.4.0
|
||||||
|
resolution: "h3-js@npm:4.4.0"
|
||||||
|
checksum: 10/6db6888f143ed6a1e3ca10506f15c35679afd181e24b71bcdc90259206e3f02637bab38e2a35382d51f17151ea193dfab69c01ff3e31bf0e86abfb1957692576
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"http-cache-semantics@npm:^4.1.1":
|
||||||
|
version: 4.2.0
|
||||||
|
resolution: "http-cache-semantics@npm:4.2.0"
|
||||||
|
checksum: 10/4efd2dfcfeea9d5e88c84af450b9980be8a43c2c8179508b1c57c7b4421c855f3e8efe92fa53e0b3f4a43c85824ada930eabbc306d1b3beab750b6dcc5187693
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"http-proxy-agent@npm:^7.0.0":
|
||||||
|
version: 7.0.2
|
||||||
|
resolution: "http-proxy-agent@npm:7.0.2"
|
||||||
|
dependencies:
|
||||||
|
agent-base: "npm:^7.1.0"
|
||||||
|
debug: "npm:^4.3.4"
|
||||||
|
checksum: 10/d062acfa0cb82beeb558f1043c6ba770ea892b5fb7b28654dbc70ea2aeea55226dd34c02a294f6c1ca179a5aa483c4ea641846821b182edbd9cc5d89b54c6848
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"https-proxy-agent@npm:^7.0.1":
|
||||||
|
version: 7.0.6
|
||||||
|
resolution: "https-proxy-agent@npm:7.0.6"
|
||||||
|
dependencies:
|
||||||
|
agent-base: "npm:^7.1.2"
|
||||||
|
debug: "npm:4"
|
||||||
|
checksum: 10/784b628cbd55b25542a9d85033bdfd03d4eda630fb8b3c9477959367f3be95dc476ed2ecbb9836c359c7c698027fc7b45723a302324433590f45d6c1706e8c13
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"iconv-lite@npm:^0.7.2":
|
||||||
|
version: 0.7.2
|
||||||
|
resolution: "iconv-lite@npm:0.7.2"
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: "npm:>= 2.1.2 < 3.0.0"
|
||||||
|
checksum: 10/24c937b532f868e938386b62410b303b7c767ce3d08dc2829cbe59464d5a26ef86ae5ad1af6b34eec43ddfea39e7d101638644b0178d67262fa87015d59f983a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"ieee754@npm:^1.1.13":
|
||||||
|
version: 1.2.1
|
||||||
|
resolution: "ieee754@npm:1.2.1"
|
||||||
|
checksum: 10/d9f2557a59036f16c282aaeb107832dc957a93d73397d89bbad4eb1130560560eb695060145e8e6b3b498b15ab95510226649a0b8f52ae06583575419fe10fc4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"imurmurhash@npm:^0.1.4":
|
||||||
|
version: 0.1.4
|
||||||
|
resolution: "imurmurhash@npm:0.1.4"
|
||||||
|
checksum: 10/2d30b157a91fe1c1d7c6f653cbf263f039be6c5bfa959245a16d4ee191fc0f2af86c08545b6e6beeb041c56b574d2d5b9f95343d378ab49c0f37394d541e7fc8
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"inherits@npm:^2.0.3, inherits@npm:^2.0.4":
|
||||||
|
version: 2.0.4
|
||||||
|
resolution: "inherits@npm:2.0.4"
|
||||||
|
checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"ini@npm:~1.3.0":
|
||||||
|
version: 1.3.8
|
||||||
|
resolution: "ini@npm:1.3.8"
|
||||||
|
checksum: 10/314ae176e8d4deb3def56106da8002b462221c174ddb7ce0c49ee72c8cd1f9044f7b10cc555a7d8850982c3b9ca96fc212122749f5234bc2b6fb05fb942ed566
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"ip-address@npm:^10.0.1":
|
||||||
|
version: 10.1.0
|
||||||
|
resolution: "ip-address@npm:10.1.0"
|
||||||
|
checksum: 10/a6979629d1ad9c1fb424bc25182203fad739b40225aebc55ec6243bbff5035faf7b9ed6efab3a097de6e713acbbfde944baacfa73e11852bb43989c45a68d79e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"isexe@npm:^4.0.0":
|
||||||
|
version: 4.0.0
|
||||||
|
resolution: "isexe@npm:4.0.0"
|
||||||
|
checksum: 10/2ead327ef596042ef9c9ec5f236b316acfaedb87f4bb61b3c3d574fb2e9c8a04b67305e04733bde52c24d9622fdebd3270aadb632adfbf9cadef88fe30f479e5
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
|
||||||
|
version: 11.2.6
|
||||||
|
resolution: "lru-cache@npm:11.2.6"
|
||||||
|
checksum: 10/91222bbd59f793a0a0ad57789388f06b34ac9bb1613433c1d1810457d09db5cd3ec8943227ce2e1f5d6a0a15d6f1a9f129cb2c49ae9b6b10e82d4965fddecbef
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"make-fetch-happen@npm:^15.0.0":
|
||||||
|
version: 15.0.4
|
||||||
|
resolution: "make-fetch-happen@npm:15.0.4"
|
||||||
|
dependencies:
|
||||||
|
"@gar/promise-retry": "npm:^1.0.0"
|
||||||
|
"@npmcli/agent": "npm:^4.0.0"
|
||||||
|
cacache: "npm:^20.0.1"
|
||||||
|
http-cache-semantics: "npm:^4.1.1"
|
||||||
|
minipass: "npm:^7.0.2"
|
||||||
|
minipass-fetch: "npm:^5.0.0"
|
||||||
|
minipass-flush: "npm:^1.0.5"
|
||||||
|
minipass-pipeline: "npm:^1.2.4"
|
||||||
|
negotiator: "npm:^1.0.0"
|
||||||
|
proc-log: "npm:^6.0.0"
|
||||||
|
ssri: "npm:^13.0.0"
|
||||||
|
checksum: 10/4aa75baab500eff4259f2e1a3e76cf01ab3a3cd750037e4bd7b5e22bc5a60f12cc766b3c45e6288accb5ab609e88de5019a8014e0f96f6594b7b03cb504f4b81
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"mimic-response@npm:^3.1.0":
|
||||||
|
version: 3.1.0
|
||||||
|
resolution: "mimic-response@npm:3.1.0"
|
||||||
|
checksum: 10/7e719047612411fe071332a7498cf0448bbe43c485c0d780046c76633a771b223ff49bd00267be122cedebb897037fdb527df72335d0d0f74724604ca70b37ad
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"minimatch@npm:^10.2.2":
|
||||||
|
version: 10.2.4
|
||||||
|
resolution: "minimatch@npm:10.2.4"
|
||||||
|
dependencies:
|
||||||
|
brace-expansion: "npm:^5.0.2"
|
||||||
|
checksum: 10/aea4874e521c55bb60744685bbffe3d152e5460f84efac3ea936e6bbe2ceba7deb93345fec3f9bb17f7b6946776073a64d40ae32bf5f298ad690308121068a1f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"minimist@npm:^1.2.0, minimist@npm:^1.2.3":
|
||||||
|
version: 1.2.8
|
||||||
|
resolution: "minimist@npm:1.2.8"
|
||||||
|
checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"minipass-collect@npm:^2.0.1":
|
||||||
|
version: 2.0.1
|
||||||
|
resolution: "minipass-collect@npm:2.0.1"
|
||||||
|
dependencies:
|
||||||
|
minipass: "npm:^7.0.3"
|
||||||
|
checksum: 10/b251bceea62090f67a6cced7a446a36f4cd61ee2d5cea9aee7fff79ba8030e416327a1c5aa2908dc22629d06214b46d88fdab8c51ac76bacbf5703851b5ad342
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"minipass-fetch@npm:^5.0.0":
|
||||||
|
version: 5.0.2
|
||||||
|
resolution: "minipass-fetch@npm:5.0.2"
|
||||||
|
dependencies:
|
||||||
|
iconv-lite: "npm:^0.7.2"
|
||||||
|
minipass: "npm:^7.0.3"
|
||||||
|
minipass-sized: "npm:^2.0.0"
|
||||||
|
minizlib: "npm:^3.0.1"
|
||||||
|
dependenciesMeta:
|
||||||
|
iconv-lite:
|
||||||
|
optional: true
|
||||||
|
checksum: 10/4f3f65ea5b20a3a287765ebf21cc73e62031f754944272df2a3039296cc75a8fc2dc50b8a3c4f39ce3ac6e5cc583e8dc664d12c6ab98e0883d263e49f344bc86
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"minipass-flush@npm:^1.0.5":
|
||||||
|
version: 1.0.5
|
||||||
|
resolution: "minipass-flush@npm:1.0.5"
|
||||||
|
dependencies:
|
||||||
|
minipass: "npm:^3.0.0"
|
||||||
|
checksum: 10/56269a0b22bad756a08a94b1ffc36b7c9c5de0735a4dd1ab2b06c066d795cfd1f0ac44a0fcae13eece5589b908ecddc867f04c745c7009be0b566421ea0944cf
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"minipass-pipeline@npm:^1.2.4":
|
||||||
|
version: 1.2.4
|
||||||
|
resolution: "minipass-pipeline@npm:1.2.4"
|
||||||
|
dependencies:
|
||||||
|
minipass: "npm:^3.0.0"
|
||||||
|
checksum: 10/b14240dac0d29823c3d5911c286069e36d0b81173d7bdf07a7e4a91ecdef92cdff4baaf31ea3746f1c61e0957f652e641223970870e2353593f382112257971b
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"minipass-sized@npm:^2.0.0":
|
||||||
|
version: 2.0.0
|
||||||
|
resolution: "minipass-sized@npm:2.0.0"
|
||||||
|
dependencies:
|
||||||
|
minipass: "npm:^7.1.2"
|
||||||
|
checksum: 10/3b89adf64ca705662f77481e278eff5ec0a57aeffb5feba7cc8843722b1e7770efc880f2a17d1d4877b2d7bf227873cd46afb4da44c0fd18088b601ea50f96bb
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"minipass@npm:^3.0.0":
|
||||||
|
version: 3.3.6
|
||||||
|
resolution: "minipass@npm:3.3.6"
|
||||||
|
dependencies:
|
||||||
|
yallist: "npm:^4.0.0"
|
||||||
|
checksum: 10/a5c6ef069f70d9a524d3428af39f2b117ff8cd84172e19b754e7264a33df460873e6eb3d6e55758531580970de50ae950c496256bb4ad3691a2974cddff189f0
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3":
|
||||||
|
version: 7.1.3
|
||||||
|
resolution: "minipass@npm:7.1.3"
|
||||||
|
checksum: 10/175e4d5e20980c3cd316ae82d2c031c42f6c746467d8b1905b51060a0ba4461441a0c25bb67c025fd9617f9a3873e152c7b543c6b5ac83a1846be8ade80dffd6
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"minizlib@npm:^3.0.1, minizlib@npm:^3.1.0":
|
||||||
|
version: 3.1.0
|
||||||
|
resolution: "minizlib@npm:3.1.0"
|
||||||
|
dependencies:
|
||||||
|
minipass: "npm:^7.1.2"
|
||||||
|
checksum: 10/f47365cc2cb7f078cbe7e046eb52655e2e7e97f8c0a9a674f4da60d94fb0624edfcec9b5db32e8ba5a99a5f036f595680ae6fe02a262beaa73026e505cc52f99
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3":
|
||||||
|
version: 0.5.3
|
||||||
|
resolution: "mkdirp-classic@npm:0.5.3"
|
||||||
|
checksum: 10/3f4e088208270bbcc148d53b73e9a5bd9eef05ad2cbf3b3d0ff8795278d50dd1d11a8ef1875ff5aea3fa888931f95bfcb2ad5b7c1061cfefd6284d199e6776ac
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"ms@npm:^2.1.3":
|
||||||
|
version: 2.1.3
|
||||||
|
resolution: "ms@npm:2.1.3"
|
||||||
|
checksum: 10/aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"napi-build-utils@npm:^2.0.0":
|
||||||
|
version: 2.0.0
|
||||||
|
resolution: "napi-build-utils@npm:2.0.0"
|
||||||
|
checksum: 10/69adcdb828481737f1ec64440286013f6479d5b264e24d5439ba795f65293d0bb6d962035de07c65fae525ed7d2fcd0baab6891d8e3734ea792fec43918acf83
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"negotiator@npm:^1.0.0":
|
||||||
|
version: 1.0.0
|
||||||
|
resolution: "negotiator@npm:1.0.0"
|
||||||
|
checksum: 10/b5734e87295324fabf868e36fb97c84b7d7f3156ec5f4ee5bf6e488079c11054f818290fc33804cef7b1ee21f55eeb14caea83e7dafae6492a409b3e573153e5
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"node-abi@npm:^3.3.0":
|
||||||
|
version: 3.87.0
|
||||||
|
resolution: "node-abi@npm:3.87.0"
|
||||||
|
dependencies:
|
||||||
|
semver: "npm:^7.3.5"
|
||||||
|
checksum: 10/3c7beafed49d9486b4bd95166bcf182b26d4aafa63c8620d1b3bd70a740fc256e1789453cfd83653b6efa7d259f0b1a34eaa95a6c62e74974ad99129bc78842f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"node-gyp@npm:latest":
|
||||||
|
version: 12.2.0
|
||||||
|
resolution: "node-gyp@npm:12.2.0"
|
||||||
|
dependencies:
|
||||||
|
env-paths: "npm:^2.2.0"
|
||||||
|
exponential-backoff: "npm:^3.1.1"
|
||||||
|
graceful-fs: "npm:^4.2.6"
|
||||||
|
make-fetch-happen: "npm:^15.0.0"
|
||||||
|
nopt: "npm:^9.0.0"
|
||||||
|
proc-log: "npm:^6.0.0"
|
||||||
|
semver: "npm:^7.3.5"
|
||||||
|
tar: "npm:^7.5.4"
|
||||||
|
tinyglobby: "npm:^0.2.12"
|
||||||
|
which: "npm:^6.0.0"
|
||||||
|
bin:
|
||||||
|
node-gyp: bin/node-gyp.js
|
||||||
|
checksum: 10/4ebab5b77585a637315e969c2274b5520562473fe75de850639a580c2599652fb9f33959ec782ea45a2e149d8f04b548030f472eeeb3dbdf19a7f2ccbc30b908
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"nopt@npm:^9.0.0":
|
||||||
|
version: 9.0.0
|
||||||
|
resolution: "nopt@npm:9.0.0"
|
||||||
|
dependencies:
|
||||||
|
abbrev: "npm:^4.0.0"
|
||||||
|
bin:
|
||||||
|
nopt: bin/nopt.js
|
||||||
|
checksum: 10/56a1ccd2ad711fb5115918e2c96828703cddbe12ba2c3bd00591758f6fa30e6f47dd905c59dbfcf9b773f3a293b45996609fb6789ae29d6bfcc3cf3a6f7d9fda
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"once@npm:^1.3.1, once@npm:^1.4.0":
|
||||||
|
version: 1.4.0
|
||||||
|
resolution: "once@npm:1.4.0"
|
||||||
|
dependencies:
|
||||||
|
wrappy: "npm:1"
|
||||||
|
checksum: 10/cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"p-map@npm:^7.0.2":
|
||||||
|
version: 7.0.4
|
||||||
|
resolution: "p-map@npm:7.0.4"
|
||||||
|
checksum: 10/ef48c3b2e488f31c693c9fcc0df0ef76518cf6426a495cf9486ebbb0fd7f31aef7f90e96f72e0070c0ff6e3177c9318f644b512e2c29e3feee8d7153fcb6782e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"path-scurry@npm:^2.0.2":
|
||||||
|
version: 2.0.2
|
||||||
|
resolution: "path-scurry@npm:2.0.2"
|
||||||
|
dependencies:
|
||||||
|
lru-cache: "npm:^11.0.0"
|
||||||
|
minipass: "npm:^7.1.2"
|
||||||
|
checksum: 10/2b4257422bcb870a4c2d205b3acdbb213a72f5e2250f61c80f79c9d014d010f82bdf8584441612c8e1fa4eb098678f5704a66fa8377d72646bad4be38e57a2c3
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"picomatch@npm:^4.0.3":
|
||||||
|
version: 4.0.3
|
||||||
|
resolution: "picomatch@npm:4.0.3"
|
||||||
|
checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"prebuild-install@npm:^7.1.1":
|
||||||
|
version: 7.1.3
|
||||||
|
resolution: "prebuild-install@npm:7.1.3"
|
||||||
|
dependencies:
|
||||||
|
detect-libc: "npm:^2.0.0"
|
||||||
|
expand-template: "npm:^2.0.3"
|
||||||
|
github-from-package: "npm:0.0.0"
|
||||||
|
minimist: "npm:^1.2.3"
|
||||||
|
mkdirp-classic: "npm:^0.5.3"
|
||||||
|
napi-build-utils: "npm:^2.0.0"
|
||||||
|
node-abi: "npm:^3.3.0"
|
||||||
|
pump: "npm:^3.0.0"
|
||||||
|
rc: "npm:^1.2.7"
|
||||||
|
simple-get: "npm:^4.0.0"
|
||||||
|
tar-fs: "npm:^2.0.0"
|
||||||
|
tunnel-agent: "npm:^0.6.0"
|
||||||
|
bin:
|
||||||
|
prebuild-install: bin.js
|
||||||
|
checksum: 10/1b7e4c00d2750b532a4fc2a83ffb0c5fefa1b6f2ad071896ead15eeadc3255f5babd816949991af083cf7429e375ae8c7d1c51f73658559da36f948a020a3a11
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"proc-log@npm:^6.0.0":
|
||||||
|
version: 6.1.0
|
||||||
|
resolution: "proc-log@npm:6.1.0"
|
||||||
|
checksum: 10/9033f30f168ed5a0991b773d0c50ff88384c4738e9a0a67d341de36bf7293771eed648ab6a0562f62276da12fde91f3bbfc75ffff6e71ad49aafd74fc646be66
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"pump@npm:^3.0.0":
|
||||||
|
version: 3.0.4
|
||||||
|
resolution: "pump@npm:3.0.4"
|
||||||
|
dependencies:
|
||||||
|
end-of-stream: "npm:^1.1.0"
|
||||||
|
once: "npm:^1.3.1"
|
||||||
|
checksum: 10/d043c3e710c56ffd280711e98a94e863ab334f79ea43cee0fb70e1349b2355ffd2ff287c7522e4c960a247699d5b7825f00fa090b85d6179c973be13f78a6c49
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"rc@npm:^1.2.7":
|
||||||
|
version: 1.2.8
|
||||||
|
resolution: "rc@npm:1.2.8"
|
||||||
|
dependencies:
|
||||||
|
deep-extend: "npm:^0.6.0"
|
||||||
|
ini: "npm:~1.3.0"
|
||||||
|
minimist: "npm:^1.2.0"
|
||||||
|
strip-json-comments: "npm:~2.0.1"
|
||||||
|
bin:
|
||||||
|
rc: ./cli.js
|
||||||
|
checksum: 10/5c4d72ae7eec44357171585938c85ce066da8ca79146b5635baf3d55d74584c92575fa4e2c9eac03efbed3b46a0b2e7c30634c012b4b4fa40d654353d3c163eb
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0":
|
||||||
|
version: 3.6.2
|
||||||
|
resolution: "readable-stream@npm:3.6.2"
|
||||||
|
dependencies:
|
||||||
|
inherits: "npm:^2.0.3"
|
||||||
|
string_decoder: "npm:^1.1.1"
|
||||||
|
util-deprecate: "npm:^1.0.1"
|
||||||
|
checksum: 10/d9e3e53193adcdb79d8f10f2a1f6989bd4389f5936c6f8b870e77570853561c362bee69feca2bbb7b32368ce96a85504aa4cedf7cf80f36e6a9de30d64244048
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"retry@npm:^0.13.1":
|
||||||
|
version: 0.13.1
|
||||||
|
resolution: "retry@npm:0.13.1"
|
||||||
|
checksum: 10/6125ec2e06d6e47e9201539c887defba4e47f63471db304c59e4b82fc63c8e89ca06a77e9d34939a9a42a76f00774b2f46c0d4a4cbb3e287268bd018ed69426d
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0":
|
||||||
|
version: 5.2.1
|
||||||
|
resolution: "safe-buffer@npm:5.2.1"
|
||||||
|
checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"safer-buffer@npm:>= 2.1.2 < 3.0.0":
|
||||||
|
version: 2.1.2
|
||||||
|
resolution: "safer-buffer@npm:2.1.2"
|
||||||
|
checksum: 10/7eaf7a0cf37cc27b42fb3ef6a9b1df6e93a1c6d98c6c6702b02fe262d5fcbd89db63320793b99b21cb5348097d0a53de81bd5f4e8b86e20cc9412e3f1cfb4e83
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"semver@npm:^7.3.5":
|
||||||
|
version: 7.7.4
|
||||||
|
resolution: "semver@npm:7.7.4"
|
||||||
|
bin:
|
||||||
|
semver: bin/semver.js
|
||||||
|
checksum: 10/26bdc6d58b29528f4142d29afb8526bc335f4fc04c4a10f2b98b217f277a031c66736bf82d3d3bb354a2f6a3ae50f18fd62b053c4ac3f294a3d10a61f5075b75
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"simple-concat@npm:^1.0.0":
|
||||||
|
version: 1.0.1
|
||||||
|
resolution: "simple-concat@npm:1.0.1"
|
||||||
|
checksum: 10/4d211042cc3d73a718c21ac6c4e7d7a0363e184be6a5ad25c8a1502e49df6d0a0253979e3d50dbdd3f60ef6c6c58d756b5d66ac1e05cda9cacd2e9fc59e3876a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"simple-get@npm:^4.0.0":
|
||||||
|
version: 4.0.1
|
||||||
|
resolution: "simple-get@npm:4.0.1"
|
||||||
|
dependencies:
|
||||||
|
decompress-response: "npm:^6.0.0"
|
||||||
|
once: "npm:^1.3.1"
|
||||||
|
simple-concat: "npm:^1.0.0"
|
||||||
|
checksum: 10/93f1b32319782f78f2f2234e9ce34891b7ab6b990d19d8afefaa44423f5235ce2676aae42d6743fecac6c8dfff4b808d4c24fe5265be813d04769917a9a44f36
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"smart-buffer@npm:^4.2.0":
|
||||||
|
version: 4.2.0
|
||||||
|
resolution: "smart-buffer@npm:4.2.0"
|
||||||
|
checksum: 10/927484aa0b1640fd9473cee3e0a0bcad6fce93fd7bbc18bac9ad0c33686f5d2e2c422fba24b5899c184524af01e11dd2bd051c2bf2b07e47aff8ca72cbfc60d2
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"socks-proxy-agent@npm:^8.0.3":
|
||||||
|
version: 8.0.5
|
||||||
|
resolution: "socks-proxy-agent@npm:8.0.5"
|
||||||
|
dependencies:
|
||||||
|
agent-base: "npm:^7.1.2"
|
||||||
|
debug: "npm:^4.3.4"
|
||||||
|
socks: "npm:^2.8.3"
|
||||||
|
checksum: 10/ee99e1dacab0985b52cbe5a75640be6e604135e9489ebdc3048635d186012fbaecc20fbbe04b177dee434c319ba20f09b3e7dfefb7d932466c0d707744eac05c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"socks@npm:^2.8.3":
|
||||||
|
version: 2.8.7
|
||||||
|
resolution: "socks@npm:2.8.7"
|
||||||
|
dependencies:
|
||||||
|
ip-address: "npm:^10.0.1"
|
||||||
|
smart-buffer: "npm:^4.2.0"
|
||||||
|
checksum: 10/d19366c95908c19db154f329bbe94c2317d315dc933a7c2b5101e73f32a555c84fb199b62174e1490082a593a4933d8d5a9b297bde7d1419c14a11a965f51356
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"ssri@npm:^13.0.0":
|
||||||
|
version: 13.0.1
|
||||||
|
resolution: "ssri@npm:13.0.1"
|
||||||
|
dependencies:
|
||||||
|
minipass: "npm:^7.0.3"
|
||||||
|
checksum: 10/ae560d0378d074006a71b06af71bfbe84a3fe1ac6e16c1f07575f69e670d40170507fe52b21bcc23399429bc6a15f4bc3ea8d9bc88e9dfd7e87de564e6da6a72
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"string_decoder@npm:^1.1.1":
|
||||||
|
version: 1.3.0
|
||||||
|
resolution: "string_decoder@npm:1.3.0"
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: "npm:~5.2.0"
|
||||||
|
checksum: 10/54d23f4a6acae0e93f999a585e673be9e561b65cd4cca37714af1e893ab8cd8dfa52a9e4f58f48f87b4a44918d3a9254326cb80ed194bf2e4c226e2b21767e56
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"strip-json-comments@npm:~2.0.1":
|
||||||
|
version: 2.0.1
|
||||||
|
resolution: "strip-json-comments@npm:2.0.1"
|
||||||
|
checksum: 10/1074ccb63270d32ca28edfb0a281c96b94dc679077828135141f27d52a5a398ef5e78bcf22809d23cadc2b81dfbe345eb5fd8699b385c8b1128907dec4a7d1e1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"tar-fs@npm:^2.0.0":
|
||||||
|
version: 2.1.4
|
||||||
|
resolution: "tar-fs@npm:2.1.4"
|
||||||
|
dependencies:
|
||||||
|
chownr: "npm:^1.1.1"
|
||||||
|
mkdirp-classic: "npm:^0.5.2"
|
||||||
|
pump: "npm:^3.0.0"
|
||||||
|
tar-stream: "npm:^2.1.4"
|
||||||
|
checksum: 10/bdf7e3cb039522e39c6dae3084b1bca8d7bcc1de1906eae4a1caea6a2250d22d26dcc234118bf879b345d91ebf250a744b196e379334a4abcbb109a78db7d3be
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"tar-stream@npm:^2.1.4":
|
||||||
|
version: 2.2.0
|
||||||
|
resolution: "tar-stream@npm:2.2.0"
|
||||||
|
dependencies:
|
||||||
|
bl: "npm:^4.0.3"
|
||||||
|
end-of-stream: "npm:^1.4.1"
|
||||||
|
fs-constants: "npm:^1.0.0"
|
||||||
|
inherits: "npm:^2.0.3"
|
||||||
|
readable-stream: "npm:^3.1.1"
|
||||||
|
checksum: 10/1a52a51d240c118cbcd30f7368ea5e5baef1eac3e6b793fb1a41e6cd7319296c79c0264ccc5859f5294aa80f8f00b9239d519e627b9aade80038de6f966fec6a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"tar@npm:^7.5.4":
|
||||||
|
version: 7.5.10
|
||||||
|
resolution: "tar@npm:7.5.10"
|
||||||
|
dependencies:
|
||||||
|
"@isaacs/fs-minipass": "npm:^4.0.0"
|
||||||
|
chownr: "npm:^3.0.0"
|
||||||
|
minipass: "npm:^7.1.2"
|
||||||
|
minizlib: "npm:^3.1.0"
|
||||||
|
yallist: "npm:^5.0.0"
|
||||||
|
checksum: 10/98ba6421a250b233c36a54f7441647bdfee1ed0b916cd57850259a3602154d996f5b8422f67ef5c8ce77f582ed938054775c2873fc7c901e0c7530ed50febc40
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"tinyglobby@npm:^0.2.12":
|
||||||
|
version: 0.2.15
|
||||||
|
resolution: "tinyglobby@npm:0.2.15"
|
||||||
|
dependencies:
|
||||||
|
fdir: "npm:^6.5.0"
|
||||||
|
picomatch: "npm:^4.0.3"
|
||||||
|
checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"tunnel-agent@npm:^0.6.0":
|
||||||
|
version: 0.6.0
|
||||||
|
resolution: "tunnel-agent@npm:0.6.0"
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: "npm:^5.0.1"
|
||||||
|
checksum: 10/7f0d9ed5c22404072b2ae8edc45c071772affd2ed14a74f03b4e71b4dd1a14c3714d85aed64abcaaee5fec2efc79002ba81155c708f4df65821b444abb0cfade
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"unique-filename@npm:^5.0.0":
|
||||||
|
version: 5.0.0
|
||||||
|
resolution: "unique-filename@npm:5.0.0"
|
||||||
|
dependencies:
|
||||||
|
unique-slug: "npm:^6.0.0"
|
||||||
|
checksum: 10/a5f67085caef74bdd2a6869a200ed5d68d171f5cc38435a836b5fd12cce4e4eb55e6a190298035c325053a5687ed7a3c96f0a91e82215fd14729769d9ac57d9b
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"unique-slug@npm:^6.0.0":
|
||||||
|
version: 6.0.0
|
||||||
|
resolution: "unique-slug@npm:6.0.0"
|
||||||
|
dependencies:
|
||||||
|
imurmurhash: "npm:^0.1.4"
|
||||||
|
checksum: 10/b78ed9d5b01ff465f80975f17387750ed3639909ac487fa82c4ae4326759f6de87c2131c0c39eca4c68cf06c537a8d104fba1dfc8a30308f99bc505345e1eba3
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"util-deprecate@npm:^1.0.1":
|
||||||
|
version: 1.0.2
|
||||||
|
resolution: "util-deprecate@npm:1.0.2"
|
||||||
|
checksum: 10/474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"which@npm:^6.0.0":
|
||||||
|
version: 6.0.1
|
||||||
|
resolution: "which@npm:6.0.1"
|
||||||
|
dependencies:
|
||||||
|
isexe: "npm:^4.0.0"
|
||||||
|
bin:
|
||||||
|
node-which: bin/which.js
|
||||||
|
checksum: 10/dbea77c7d3058bf6c78bf9659d2dce4d2b57d39a15b826b2af6ac2e5a219b99dc8a831b79fdbc453c0598adb4f3f84cf9c2491fd52beb9f5d2dececcad117f68
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"wrappy@npm:1":
|
||||||
|
version: 1.0.2
|
||||||
|
resolution: "wrappy@npm:1.0.2"
|
||||||
|
checksum: 10/159da4805f7e84a3d003d8841557196034155008f817172d4e986bd591f74aa82aa7db55929a54222309e01079a65a92a9e6414da5a6aa4b01ee44a511ac3ee5
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"yallist@npm:^4.0.0":
|
||||||
|
version: 4.0.0
|
||||||
|
resolution: "yallist@npm:4.0.0"
|
||||||
|
checksum: 10/4cb02b42b8a93b5cf50caf5d8e9beb409400a8a4d85e83bb0685c1457e9ac0b7a00819e9f5991ac25ffabb56a78e2f017c1acc010b3a1babfe6de690ba531abd
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"yallist@npm:^5.0.0":
|
||||||
|
version: 5.0.0
|
||||||
|
resolution: "yallist@npm:5.0.0"
|
||||||
|
checksum: 10/1884d272d485845ad04759a255c71775db0fac56308764b4c77ea56a20d56679fad340213054c8c9c9c26fcfd4c4b2a90df993b7e0aaf3cdb73c618d1d1a802a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
BIN
src/assets/img/icon-dae.png
Normal file
BIN
src/assets/img/icon-dae.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
src/assets/img/marker-dae.png
Normal file
BIN
src/assets/img/marker-dae.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
BIN
src/assets/img/marker-grey.png
Normal file
BIN
src/assets/img/marker-grey.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4 KiB |
71
src/containers/DaeSuggestModal/index.js
Normal file
71
src/containers/DaeSuggestModal/index.js
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Button, Modal, Portal } from "react-native-paper";
|
||||||
|
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import { useRootNav } from "~/navigation/Context";
|
||||||
|
import { defibsActions, useDefibsState } from "~/stores";
|
||||||
|
import { useTheme } from "~/theme";
|
||||||
|
|
||||||
|
export default function DaeSuggestModal() {
|
||||||
|
const { showDaeSuggestModal } = useDefibsState(["showDaeSuggestModal"]);
|
||||||
|
const navigationRef = useRootNav();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() => ({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
padding: 20,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
title: { fontSize: 20, fontWeight: "bold" },
|
||||||
|
paragraph: { marginTop: 10, fontSize: 16 },
|
||||||
|
actionsColumn: {
|
||||||
|
marginTop: 18,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors.surface],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
defibsActions.setShowDaeSuggestModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToDaeList = () => {
|
||||||
|
dismiss();
|
||||||
|
|
||||||
|
// DAEList is inside the Drawer navigator which is the RootStack "Main" screen.
|
||||||
|
// Using the root navigation ref makes this modal independent from current route.
|
||||||
|
navigationRef?.current?.navigate("Main", {
|
||||||
|
screen: "DAEList",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<Modal
|
||||||
|
visible={!!showDaeSuggestModal}
|
||||||
|
onDismiss={dismiss}
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>Défibrillateur à proximité</Text>
|
||||||
|
<Text style={styles.paragraph}>
|
||||||
|
En cas d'arrêt cardiaque, un défibrillateur (DAE) à proximité peut
|
||||||
|
sauver une vie.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.actionsColumn}>
|
||||||
|
<Button mode="contained" onPress={goToDaeList}>
|
||||||
|
Chercher un défibrillateur
|
||||||
|
</Button>
|
||||||
|
<Button mode="outlined" onPress={dismiss}>
|
||||||
|
Non merci
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,12 @@ export default function AlertSymbolLayer({ level, isDisabled }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Maplibre.SymbolLayer
|
<Maplibre.SymbolLayer
|
||||||
filter={["==", ["get", "icon"], icon]}
|
filter={[
|
||||||
|
"all",
|
||||||
|
["==", ["get", "icon"], icon],
|
||||||
|
// Exclude DAE overlay markers (v1: separate non-clustered layer)
|
||||||
|
["!=", ["get", "isDefib"], true],
|
||||||
|
]}
|
||||||
key={key}
|
key={key}
|
||||||
id={key}
|
id={key}
|
||||||
belowLayerID={belowLayerID}
|
belowLayerID={belowLayerID}
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,23 @@ import Maplibre from "@maplibre/maplibre-react-native";
|
||||||
import markerRed from "~/assets/img/marker-red.png";
|
import markerRed from "~/assets/img/marker-red.png";
|
||||||
import markerYellow from "~/assets/img/marker-yellow.png";
|
import markerYellow from "~/assets/img/marker-yellow.png";
|
||||||
import markerGreen from "~/assets/img/marker-green.png";
|
import markerGreen from "~/assets/img/marker-green.png";
|
||||||
|
import markerGrey from "~/assets/img/marker-grey.png";
|
||||||
import markerRedDisabled from "~/assets/img/marker-red-disabled.png";
|
import markerRedDisabled from "~/assets/img/marker-red-disabled.png";
|
||||||
import markerYellowDisabled from "~/assets/img/marker-yellow-disabled.png";
|
import markerYellowDisabled from "~/assets/img/marker-yellow-disabled.png";
|
||||||
import markerGreenDisabled from "~/assets/img/marker-green-disabled.png";
|
import markerGreenDisabled from "~/assets/img/marker-green-disabled.png";
|
||||||
import markerOrigin from "~/assets/img/marker-origin.png";
|
import markerOrigin from "~/assets/img/marker-origin.png";
|
||||||
|
import markerDae from "~/assets/img/marker-dae.png";
|
||||||
|
|
||||||
const images = {
|
const images = {
|
||||||
red: markerRed,
|
red: markerRed,
|
||||||
yellow: markerYellow,
|
yellow: markerYellow,
|
||||||
green: markerGreen,
|
green: markerGreen,
|
||||||
|
grey: markerGrey,
|
||||||
redDisabled: markerRedDisabled,
|
redDisabled: markerRedDisabled,
|
||||||
yellowDisabled: markerYellowDisabled,
|
yellowDisabled: markerYellowDisabled,
|
||||||
greenDisabled: markerGreenDisabled,
|
greenDisabled: markerGreenDisabled,
|
||||||
origin: markerOrigin,
|
origin: markerOrigin,
|
||||||
|
dae: markerDae,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FeatureImages() {
|
export default function FeatureImages() {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ const iconStyle = {
|
||||||
iconSize: 0.5,
|
iconSize: 0.5,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defibStyle = {
|
||||||
|
iconImage: "dae",
|
||||||
|
iconSize: 0.5,
|
||||||
|
iconAllowOverlap: true,
|
||||||
|
};
|
||||||
|
|
||||||
const useStyles = createStyles(({ theme: { colors } }) => ({
|
const useStyles = createStyles(({ theme: { colors } }) => ({
|
||||||
clusterCount: {
|
clusterCount: {
|
||||||
textField: "{point_count_abbreviated}",
|
textField: "{point_count_abbreviated}",
|
||||||
|
|
@ -58,6 +64,15 @@ export default function ShapePoints({ shape, children, ...shapeSourceProps }) {
|
||||||
style={iconStyle}
|
style={iconStyle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Defibrillators (DAE) – separate layer (non-clustered) */}
|
||||||
|
<Maplibre.SymbolLayer
|
||||||
|
filter={["==", ["get", "isDefib"], true]}
|
||||||
|
key="points-defib"
|
||||||
|
id="points-defib"
|
||||||
|
aboveLayerID="points-origin"
|
||||||
|
style={defibStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</Maplibre.ShapeSource>
|
</Maplibre.ShapeSource>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
74
src/data/getNearbyDefibs.js
Normal file
74
src/data/getNearbyDefibs.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Final exported function to retrieve nearby defibrillators from the embedded DB.
|
||||||
|
// Usage:
|
||||||
|
// import getNearbyDefibs from "~/data/getNearbyDefibs";
|
||||||
|
// const results = await getNearbyDefibs({ lat: 48.8566, lon: 2.3522, radiusMeters: 1000, limit: 20 });
|
||||||
|
|
||||||
|
import {
|
||||||
|
getNearbyDefibs as queryNearby,
|
||||||
|
getNearbyDefibsBbox,
|
||||||
|
} from "~/db/defibsRepo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DefibResult
|
||||||
|
* @property {string} id
|
||||||
|
* @property {number} latitude
|
||||||
|
* @property {number} longitude
|
||||||
|
* @property {string} nom
|
||||||
|
* @property {string} adresse
|
||||||
|
* @property {string} horaires
|
||||||
|
* @property {string} acces
|
||||||
|
* @property {number} disponible_24h
|
||||||
|
* @property {number} distanceMeters
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve nearby defibrillators, sorted by distance.
|
||||||
|
* Uses H3 spatial index with automatic bbox fallback.
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {number} params.lat - User latitude (WGS84)
|
||||||
|
* @param {number} params.lon - User longitude (WGS84)
|
||||||
|
* @param {number} params.radiusMeters - Search radius in meters
|
||||||
|
* @param {number} params.limit - Maximum number of results
|
||||||
|
* @param {boolean} [params.disponible24hOnly] - Only return 24/7 accessible defibrillators
|
||||||
|
* @param {boolean} [params.progressive] - Progressive H3 ring expansion (saves queries for small radii)
|
||||||
|
* @returns {Promise<DefibResult[]>}
|
||||||
|
*/
|
||||||
|
export default async function getNearbyDefibs({
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radiusMeters,
|
||||||
|
limit,
|
||||||
|
disponible24hOnly = false,
|
||||||
|
progressive = true,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
return await queryNearby({
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radiusMeters,
|
||||||
|
limit,
|
||||||
|
disponible24hOnly,
|
||||||
|
progressive,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback to bbox if H3 fails (e.g. missing h3-js on a platform)
|
||||||
|
console.warn("[DAE_DB] H3 query failed, falling back to bbox raw:", err);
|
||||||
|
console.warn(
|
||||||
|
"[DAE_DB] H3 query failed, falling back to bbox message:",
|
||||||
|
err?.message,
|
||||||
|
);
|
||||||
|
if (err?.stack) {
|
||||||
|
console.warn(
|
||||||
|
`[DAE_DB] H3 query failed, falling back to bbox stack:\n${err.stack}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return getNearbyDefibsBbox({
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radiusMeters,
|
||||||
|
limit,
|
||||||
|
disponible24hOnly,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/db/defibsRepo.js
Normal file
221
src/db/defibsRepo.js
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
// Defibrillator repository — nearby queries with H3 geo-indexing.
|
||||||
|
import { latLngToCell, gridDisk } from "~/lib/h3";
|
||||||
|
|
||||||
|
import { getDbSafe } from "./openDb";
|
||||||
|
import haversine from "~/utils/geo/haversine";
|
||||||
|
|
||||||
|
// H3 average edge lengths in meters per resolution (0..15).
|
||||||
|
const H3_EDGE_M = [
|
||||||
|
1107712, 418676, 158244, 59810, 22606, 8544, 3229, 1220, 461, 174, 65, 24, 9,
|
||||||
|
3, 1, 0.5,
|
||||||
|
];
|
||||||
|
|
||||||
|
const H3_RES = 8;
|
||||||
|
|
||||||
|
// SQLite max variable number is 999 by default; chunk IN() queries accordingly.
|
||||||
|
const SQL_VAR_LIMIT = 900;
|
||||||
|
|
||||||
|
// Compute k (ring size) needed to cover a given radius at a given H3 resolution.
|
||||||
|
function kForRadius(radiusMeters, res = H3_RES) {
|
||||||
|
const edge = H3_EDGE_M[res];
|
||||||
|
// sqrt(3) * edge ≈ diameter between parallel edges of a hexagon
|
||||||
|
return Math.max(1, Math.ceil(radiusMeters / (edge * Math.sqrt(3))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a bounding-box fallback SQL clause + params.
|
||||||
|
function bboxClause(lat, lon, radiusMeters) {
|
||||||
|
// 1 degree latitude ≈ 111_320 m
|
||||||
|
const dLat = radiusMeters / 111_320;
|
||||||
|
// 1 degree longitude shrinks with cos(lat)
|
||||||
|
const dLon = radiusMeters / (111_320 * Math.cos((lat * Math.PI) / 180));
|
||||||
|
return {
|
||||||
|
clause: "latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?",
|
||||||
|
params: [lat - dLat, lat + dLat, lon - dLon, lon + dLon],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Defib
|
||||||
|
* @property {string} id
|
||||||
|
* @property {number} latitude
|
||||||
|
* @property {number} longitude
|
||||||
|
* @property {string} nom
|
||||||
|
* @property {string} adresse
|
||||||
|
* @property {string} horaires
|
||||||
|
* @property {Object} horaires_std
|
||||||
|
* @property {number[]|null} horaires_std.days - ISO 8601 day numbers (1=Mon…7=Sun)
|
||||||
|
* @property {{open:string,close:string}[]|null} horaires_std.slots - Time ranges
|
||||||
|
* @property {boolean} horaires_std.is24h
|
||||||
|
* @property {boolean} horaires_std.businessHours
|
||||||
|
* @property {boolean} horaires_std.nightHours
|
||||||
|
* @property {boolean} horaires_std.events
|
||||||
|
* @property {string} horaires_std.notes
|
||||||
|
* @property {string} acces
|
||||||
|
* @property {number} disponible_24h
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch defibrillators near a given point.
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {number} params.lat - User latitude
|
||||||
|
* @param {number} params.lon - User longitude
|
||||||
|
* @param {number} params.radiusMeters - Search radius in meters
|
||||||
|
* @param {number} params.limit - Max results returned
|
||||||
|
* @param {boolean} [params.disponible24hOnly] - Filter 24/7 accessible only
|
||||||
|
* @param {boolean} [params.progressive] - Enable progressive expansion (k=1,2,3…)
|
||||||
|
* @returns {Promise<(Defib & { distanceMeters: number })[]>}
|
||||||
|
*/
|
||||||
|
export async function getNearbyDefibs({
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radiusMeters,
|
||||||
|
limit,
|
||||||
|
disponible24hOnly = false,
|
||||||
|
progressive = false,
|
||||||
|
}) {
|
||||||
|
const { db, error } = await getDbSafe();
|
||||||
|
if (!db) {
|
||||||
|
throw error || new Error("DAE DB unavailable");
|
||||||
|
}
|
||||||
|
const maxK = kForRadius(radiusMeters);
|
||||||
|
|
||||||
|
if (progressive) {
|
||||||
|
return progressiveSearch(
|
||||||
|
db,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radiusMeters,
|
||||||
|
limit,
|
||||||
|
disponible24hOnly,
|
||||||
|
maxK,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot: compute full disk and query
|
||||||
|
const cells = gridDisk(latLngToCell(lat, lon, H3_RES), maxK);
|
||||||
|
const candidates = await queryCells(db, cells, disponible24hOnly);
|
||||||
|
return rankAndFilter(candidates, lat, lon, radiusMeters, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progressive expansion: start at k=1, expand until enough results or maxK.
|
||||||
|
async function progressiveSearch(
|
||||||
|
db,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radiusMeters,
|
||||||
|
limit,
|
||||||
|
dispo24h,
|
||||||
|
maxK,
|
||||||
|
) {
|
||||||
|
let allCandidates = [];
|
||||||
|
const seenIds = new Set();
|
||||||
|
|
||||||
|
for (let k = 1; k <= maxK; k++) {
|
||||||
|
const cells = gridDisk(latLngToCell(lat, lon, H3_RES), k);
|
||||||
|
const rows = await queryCells(db, cells, dispo24h);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!seenIds.has(row.id)) {
|
||||||
|
seenIds.add(row.id);
|
||||||
|
allCandidates.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early exit: if we already have more candidates than limit, rank and check
|
||||||
|
if (allCandidates.length >= limit) {
|
||||||
|
const ranked = rankAndFilter(
|
||||||
|
allCandidates,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radiusMeters,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
if (ranked.length >= limit) return ranked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rankAndFilter(allCandidates, lat, lon, radiusMeters, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query the DB for rows matching a set of H3 cells, chunking if needed.
|
||||||
|
async function queryCells(db, cells, dispo24h) {
|
||||||
|
if (cells.length === 0) return [];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Chunk cells to stay under SQLite variable limit
|
||||||
|
for (let i = 0; i < cells.length; i += SQL_VAR_LIMIT) {
|
||||||
|
const chunk = cells.slice(i, i + SQL_VAR_LIMIT);
|
||||||
|
const placeholders = chunk.map(() => "?").join(",");
|
||||||
|
|
||||||
|
let sql = `SELECT id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h
|
||||||
|
FROM defibs WHERE h3 IN (${placeholders})`;
|
||||||
|
const params = [...chunk];
|
||||||
|
|
||||||
|
if (dispo24h) {
|
||||||
|
sql += " AND disponible_24h = 1";
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.getAllAsync(sql, params);
|
||||||
|
results.push(...rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse horaires_std JSON string into object.
|
||||||
|
function parseHorairesStd(row) {
|
||||||
|
try {
|
||||||
|
return { ...row, horaires_std: JSON.parse(row.horaires_std) };
|
||||||
|
} catch {
|
||||||
|
return { ...row, horaires_std: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute distance, filter by radius, sort, and limit.
|
||||||
|
function rankAndFilter(candidates, lat, lon, radiusMeters, limit) {
|
||||||
|
const withDist = [];
|
||||||
|
for (const row of candidates) {
|
||||||
|
const distanceMeters = haversine(lat, lon, row.latitude, row.longitude);
|
||||||
|
if (distanceMeters <= radiusMeters) {
|
||||||
|
withDist.push({ ...parseHorairesStd(row), distanceMeters });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withDist.sort((a, b) => a.distanceMeters - b.distanceMeters);
|
||||||
|
return withDist.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bbox fallback — use when H3 is unavailable.
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {number} params.lat
|
||||||
|
* @param {number} params.lon
|
||||||
|
* @param {number} params.radiusMeters
|
||||||
|
* @param {number} params.limit
|
||||||
|
* @param {boolean} [params.disponible24hOnly]
|
||||||
|
* @returns {Promise<(Defib & { distanceMeters: number })[]>}
|
||||||
|
*/
|
||||||
|
export async function getNearbyDefibsBbox({
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radiusMeters,
|
||||||
|
limit,
|
||||||
|
disponible24hOnly = false,
|
||||||
|
}) {
|
||||||
|
const { db, error } = await getDbSafe();
|
||||||
|
if (!db) {
|
||||||
|
throw error || new Error("DAE DB unavailable");
|
||||||
|
}
|
||||||
|
const { clause, params } = bboxClause(lat, lon, radiusMeters);
|
||||||
|
|
||||||
|
let sql = `SELECT id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h
|
||||||
|
FROM defibs WHERE ${clause}`;
|
||||||
|
if (disponible24hOnly) {
|
||||||
|
sql += " AND disponible_24h = 1";
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.getAllAsync(sql, params);
|
||||||
|
return rankAndFilter(rows, lat, lon, radiusMeters, limit);
|
||||||
|
}
|
||||||
123
src/db/ensureEmbeddedDb.js
Normal file
123
src/db/ensureEmbeddedDb.js
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// Ensure the embedded pre-populated geodae.db is available on-device.
|
||||||
|
//
|
||||||
|
// This copies the bundled asset into Expo's SQLite directory:
|
||||||
|
// FileSystem.documentDirectory + 'SQLite/' + DB_NAME
|
||||||
|
//
|
||||||
|
// Both backends (expo-sqlite and op-sqlite) can open the DB from that location.
|
||||||
|
//
|
||||||
|
// IMPORTANT:
|
||||||
|
// - All native requires must stay inside functions so this file can be loaded
|
||||||
|
// in Jest/node without crashing.
|
||||||
|
|
||||||
|
const DEFAULT_DB_NAME = "geodae.db";
|
||||||
|
|
||||||
|
function stripFileScheme(uri) {
|
||||||
|
return typeof uri === "string" && uri.startsWith("file://")
|
||||||
|
? uri.slice("file://".length)
|
||||||
|
: uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} EnsureEmbeddedDbResult
|
||||||
|
* @property {string} dbName
|
||||||
|
* @property {string} sqliteDirUri
|
||||||
|
* @property {string} dbUri
|
||||||
|
* @property {boolean} copied
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the embedded DB asset into the Expo SQLite directory (idempotent).
|
||||||
|
*
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {string} [options.dbName]
|
||||||
|
* @param {any} [options.assetModule] - Optional override for testing.
|
||||||
|
* @param {boolean} [options.overwrite]
|
||||||
|
* @returns {Promise<EnsureEmbeddedDbResult>}
|
||||||
|
*/
|
||||||
|
async function ensureEmbeddedDb(options = {}) {
|
||||||
|
const {
|
||||||
|
dbName = DEFAULT_DB_NAME,
|
||||||
|
assetModule = null,
|
||||||
|
overwrite = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Lazy require: keeps Jest/node stable.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const FileSystemModule = require("expo-file-system");
|
||||||
|
const FileSystem = FileSystemModule?.default ?? FileSystemModule;
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const ExpoAssetModule = require("expo-asset");
|
||||||
|
const ExpoAsset = ExpoAssetModule?.default ?? ExpoAssetModule;
|
||||||
|
const { Asset } = ExpoAsset;
|
||||||
|
|
||||||
|
if (!FileSystem?.documentDirectory) {
|
||||||
|
throw new Error(
|
||||||
|
"[DAE_DB] expo-file-system unavailable (documentDirectory missing) — cannot stage embedded DB",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Asset?.fromModule) {
|
||||||
|
throw new Error(
|
||||||
|
"[DAE_DB] expo-asset unavailable (Asset.fromModule missing) — cannot stage embedded DB",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqliteDirUri = `${FileSystem.documentDirectory}SQLite`;
|
||||||
|
const dbUri = `${sqliteDirUri}/${dbName}`;
|
||||||
|
|
||||||
|
// Ensure SQLite directory exists.
|
||||||
|
const dirInfo = await FileSystem.getInfoAsync(sqliteDirUri);
|
||||||
|
if (!dirInfo.exists) {
|
||||||
|
await FileSystem.makeDirectoryAsync(sqliteDirUri, { intermediates: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInfo = await FileSystem.getInfoAsync(dbUri);
|
||||||
|
const shouldCopy =
|
||||||
|
overwrite ||
|
||||||
|
!fileInfo.exists ||
|
||||||
|
(typeof fileInfo.size === "number" && fileInfo.size === 0);
|
||||||
|
|
||||||
|
if (shouldCopy) {
|
||||||
|
let moduleId = assetModule;
|
||||||
|
if (moduleId == null) {
|
||||||
|
try {
|
||||||
|
// Bundled asset (must exist in repo/build output).
|
||||||
|
// Path is relative to src/db/
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
moduleId = require("../assets/db/geodae.db");
|
||||||
|
} catch (e) {
|
||||||
|
const err = new Error(
|
||||||
|
"[DAE_DB] Embedded DB asset not found at src/assets/db/geodae.db. " +
|
||||||
|
"Run `yarn dae:build` (or ensure the asset is committed) and rebuild the dev client.",
|
||||||
|
);
|
||||||
|
err.cause = e;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = Asset.fromModule(moduleId);
|
||||||
|
await asset.downloadAsync();
|
||||||
|
if (!asset.localUri) {
|
||||||
|
throw new Error(
|
||||||
|
"[DAE_DB] DAE DB asset missing localUri after Asset.downloadAsync()",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensive: expo-asset returns file:// URIs; copyAsync wants URIs.
|
||||||
|
await FileSystem.copyAsync({ from: asset.localUri, to: dbUri });
|
||||||
|
console.warn(
|
||||||
|
"[DAE_DB] Staged embedded geodae.db into SQLite directory:",
|
||||||
|
stripFileScheme(dbUri),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { dbName, sqliteDirUri, dbUri, copied: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dbName, sqliteDirUri, dbUri, copied: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
__esModule: true,
|
||||||
|
DEFAULT_DB_NAME,
|
||||||
|
ensureEmbeddedDb,
|
||||||
|
stripFileScheme,
|
||||||
|
};
|
||||||
98
src/db/ensureEmbeddedDb.test.js
Normal file
98
src/db/ensureEmbeddedDb.test.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
const { ensureEmbeddedDb } = require("./ensureEmbeddedDb");
|
||||||
|
|
||||||
|
describe("db/ensureEmbeddedDb", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("copies asset into documentDirectory/SQLite when file is missing", async () => {
|
||||||
|
const calls = {
|
||||||
|
makeDirectoryAsync: [],
|
||||||
|
copyAsync: [],
|
||||||
|
getInfoAsync: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.doMock(
|
||||||
|
"expo-file-system",
|
||||||
|
() => ({
|
||||||
|
documentDirectory: "file:///docs/",
|
||||||
|
getInfoAsync: jest.fn(async (uri) => {
|
||||||
|
calls.getInfoAsync.push(uri);
|
||||||
|
if (uri === "file:///docs/SQLite") return { exists: false };
|
||||||
|
if (uri === "file:///docs/SQLite/geodae.db") return { exists: false };
|
||||||
|
return { exists: false };
|
||||||
|
}),
|
||||||
|
makeDirectoryAsync: jest.fn(async (uri) => {
|
||||||
|
calls.makeDirectoryAsync.push(uri);
|
||||||
|
}),
|
||||||
|
copyAsync: jest.fn(async (args) => {
|
||||||
|
calls.copyAsync.push(args);
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadAsync = jest.fn(async () => undefined);
|
||||||
|
jest.doMock(
|
||||||
|
"expo-asset",
|
||||||
|
() => ({
|
||||||
|
Asset: {
|
||||||
|
fromModule: jest.fn(() => ({
|
||||||
|
downloadAsync,
|
||||||
|
localUri: "file:///bundle/geodae.db",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await ensureEmbeddedDb({ assetModule: 123 });
|
||||||
|
|
||||||
|
expect(res.dbUri).toBe("file:///docs/SQLite/geodae.db");
|
||||||
|
expect(res.copied).toBe(true);
|
||||||
|
expect(calls.makeDirectoryAsync).toEqual(["file:///docs/SQLite"]);
|
||||||
|
expect(calls.copyAsync).toEqual([
|
||||||
|
{ from: "file:///bundle/geodae.db", to: "file:///docs/SQLite/geodae.db" },
|
||||||
|
]);
|
||||||
|
expect(downloadAsync).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not copy when destination already exists and is non-empty", async () => {
|
||||||
|
const calls = { copyAsync: [] };
|
||||||
|
jest.doMock(
|
||||||
|
"expo-file-system",
|
||||||
|
() => ({
|
||||||
|
documentDirectory: "file:///docs/",
|
||||||
|
getInfoAsync: jest.fn(async (uri) => {
|
||||||
|
if (uri === "file:///docs/SQLite") return { exists: true };
|
||||||
|
if (uri === "file:///docs/SQLite/geodae.db") {
|
||||||
|
return { exists: true, size: 42 };
|
||||||
|
}
|
||||||
|
return { exists: true };
|
||||||
|
}),
|
||||||
|
makeDirectoryAsync: jest.fn(async () => undefined),
|
||||||
|
copyAsync: jest.fn(async (args) => {
|
||||||
|
calls.copyAsync.push(args);
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
jest.doMock(
|
||||||
|
"expo-asset",
|
||||||
|
() => ({
|
||||||
|
Asset: {
|
||||||
|
fromModule: jest.fn(() => ({
|
||||||
|
downloadAsync: jest.fn(async () => undefined),
|
||||||
|
localUri: "file:///bundle/geodae.db",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await ensureEmbeddedDb({ assetModule: 123 });
|
||||||
|
expect(res.copied).toBe(false);
|
||||||
|
expect(calls.copyAsync).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
359
src/db/openDb.js
Normal file
359
src/db/openDb.js
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
// Open the pre-built geodae SQLite database.
|
||||||
|
//
|
||||||
|
// IMPORTANT: This module must not crash at load time when a native SQLite
|
||||||
|
// backend is missing (Hermes: "Cannot find native module 'ExpoSQLite'").
|
||||||
|
//
|
||||||
|
// Strategy:
|
||||||
|
// 1) Prefer @op-engineering/op-sqlite (bare RN) via ./openDbOpSqlite
|
||||||
|
// 2) Fallback to expo-sqlite (Expo) ONLY if it can be required
|
||||||
|
// 3) If nothing works, callers should use getDbSafe() and handle { db: null }
|
||||||
|
|
||||||
|
const DB_NAME = "geodae.db";
|
||||||
|
|
||||||
|
let _dbPromise = null;
|
||||||
|
let _backendPromise = null;
|
||||||
|
let _selectedBackendName = null;
|
||||||
|
|
||||||
|
function describeModuleShape(mod) {
|
||||||
|
const t = typeof mod;
|
||||||
|
const keys =
|
||||||
|
mod && (t === "object" || t === "function") ? Object.keys(mod) : [];
|
||||||
|
return { type: t, keys };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickOpener(mod, name) {
|
||||||
|
// Deterministic picking to reduce CJS/ESM/Metro export-shape ambiguity.
|
||||||
|
// Priority is explicit and matches wrapper contract.
|
||||||
|
const opener =
|
||||||
|
mod?.openDbOpSqlite ??
|
||||||
|
mod?.openDbExpoSqlite ??
|
||||||
|
mod?.openDb ??
|
||||||
|
mod?.default ??
|
||||||
|
mod;
|
||||||
|
|
||||||
|
if (typeof opener === "function") return opener;
|
||||||
|
|
||||||
|
const { type, keys } = describeModuleShape(mod);
|
||||||
|
throw new TypeError(
|
||||||
|
[
|
||||||
|
`Backend module did not export a callable opener (backend=${name}).`,
|
||||||
|
`module typeof=${type} keys=[${keys.join(", ")}].`,
|
||||||
|
`picked typeof=${typeof opener}.`,
|
||||||
|
].join(" "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function getDb() {
|
||||||
|
if (!_dbPromise) {
|
||||||
|
_dbPromise = getDbImpl();
|
||||||
|
}
|
||||||
|
return _dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the current DB connection and clear all cached state.
|
||||||
|
* After calling this, the next `getDb()` / `getDbSafe()` call will re-open
|
||||||
|
* the DB from disk — picking up any file that was swapped in the meantime.
|
||||||
|
*/
|
||||||
|
export function resetDb() {
|
||||||
|
// Close the op-sqlite backend if it was loaded.
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { resetDbOpSqlite } = require("./openDbOpSqlite");
|
||||||
|
if (typeof resetDbOpSqlite === "function") {
|
||||||
|
resetDbOpSqlite();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// op-sqlite not available — nothing to close.
|
||||||
|
}
|
||||||
|
|
||||||
|
_dbPromise = null;
|
||||||
|
_backendPromise = null;
|
||||||
|
_selectedBackendName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-throwing DB opener.
|
||||||
|
*
|
||||||
|
* v1 requirement: DB open failures must not crash the app. Downstream UI can
|
||||||
|
* display an error/empty state and keep overlays disabled.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{ db: import('expo-sqlite').SQLiteDatabase | null, error: Error | null }>}
|
||||||
|
*/
|
||||||
|
export async function getDbSafe() {
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
return { db, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
// Actionable runtime logging — include backend attempts + underlying error/stack.
|
||||||
|
// Keep behavior unchanged: do not crash, keep returning { db: null, error }.
|
||||||
|
const prefix = "[DAE_DB] Failed to open embedded DAE DB";
|
||||||
|
|
||||||
|
const logErrorDetails = (label, err) => {
|
||||||
|
if (!err) {
|
||||||
|
console.warn(`${prefix} ${label} <no error object>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = err?.message;
|
||||||
|
const stack = err?.stack;
|
||||||
|
|
||||||
|
// Log the raw error object first (best for Hermes / native errors).
|
||||||
|
console.warn(`${prefix} ${label} raw:`, err);
|
||||||
|
console.warn(`${prefix} ${label} message:`, msg);
|
||||||
|
if (stack) console.warn(`${prefix} ${label} stack:\n${stack}`);
|
||||||
|
|
||||||
|
const cause = err?.cause;
|
||||||
|
if (cause) {
|
||||||
|
console.warn(`${prefix} ${label} cause raw:`, cause);
|
||||||
|
console.warn(`${prefix} ${label} cause message:`, cause?.message);
|
||||||
|
if (cause?.stack) {
|
||||||
|
console.warn(`${prefix} ${label} cause stack:\n${cause.stack}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Primary error thrown by getDb()/selectBackend.
|
||||||
|
if (_selectedBackendName) {
|
||||||
|
console.warn(`${prefix} selected backend:`, _selectedBackendName);
|
||||||
|
}
|
||||||
|
logErrorDetails("(primary)", error);
|
||||||
|
|
||||||
|
// Nested backend selection errors (attached by selectBackend()).
|
||||||
|
const backends = error?.backends;
|
||||||
|
if (Array.isArray(backends) && backends.length > 0) {
|
||||||
|
for (const entry of backends) {
|
||||||
|
const backend = entry?.backend ?? "<unknown-backend>";
|
||||||
|
console.warn(`${prefix} backend attempted:`, backend);
|
||||||
|
logErrorDetails(`(backend=${backend})`, entry?.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { db: null, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDbImpl() {
|
||||||
|
const backend = await selectBackend();
|
||||||
|
return backend.getDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectBackend() {
|
||||||
|
if (_backendPromise) return _backendPromise;
|
||||||
|
|
||||||
|
_backendPromise = (async () => {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// 1) Prefer op-sqlite backend when available.
|
||||||
|
try {
|
||||||
|
let opBackendModule;
|
||||||
|
try {
|
||||||
|
console.warn(
|
||||||
|
"[DAE_DB] op-sqlite: requiring backend module ./openDbOpSqlite...",
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
opBackendModule = require("./openDbOpSqlite");
|
||||||
|
|
||||||
|
const opModuleType = typeof opBackendModule;
|
||||||
|
const opModuleKeys =
|
||||||
|
opBackendModule &&
|
||||||
|
(typeof opBackendModule === "object" ||
|
||||||
|
typeof opBackendModule === "function")
|
||||||
|
? Object.keys(opBackendModule)
|
||||||
|
: [];
|
||||||
|
console.warn(
|
||||||
|
"[DAE_DB] op-sqlite: require ./openDbOpSqlite success",
|
||||||
|
`type=${opModuleType} keys=[${opModuleKeys.join(", ")}]`,
|
||||||
|
);
|
||||||
|
} catch (requireError) {
|
||||||
|
console.warn(
|
||||||
|
"[DAE_DB] op-sqlite: require ./openDbOpSqlite FAILED:",
|
||||||
|
requireError?.message,
|
||||||
|
);
|
||||||
|
const err = new Error("Failed to require ./openDbOpSqlite");
|
||||||
|
// Preserve the underlying Metro/Hermes resolution failure.
|
||||||
|
err.cause = requireError;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opBackendModule == null) {
|
||||||
|
throw new TypeError(
|
||||||
|
"./openDbOpSqlite required successfully but returned null/undefined",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDbOp = pickOpener(opBackendModule, "op-sqlite");
|
||||||
|
console.warn(
|
||||||
|
"[DAE_DB] op-sqlite: picked opener",
|
||||||
|
`typeof=${typeof openDbOp}`,
|
||||||
|
);
|
||||||
|
const db = await openDbOp(); // validates open + schema
|
||||||
|
if (!db) throw new Error("op-sqlite backend returned a null DB instance");
|
||||||
|
_selectedBackendName = "op-sqlite";
|
||||||
|
return { name: "op-sqlite", getDb: () => db };
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({ backend: "op-sqlite", error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback to expo-sqlite backend ONLY if it can be required.
|
||||||
|
try {
|
||||||
|
const expoBackend = createExpoSqliteBackend();
|
||||||
|
// Validate open; createExpoSqliteBackend() is already safe to call.
|
||||||
|
await expoBackend.getDb();
|
||||||
|
_selectedBackendName = expoBackend?.name ?? "expo-sqlite";
|
||||||
|
return expoBackend;
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({ backend: "expo-sqlite", error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = new Error(
|
||||||
|
"No SQLite backend available (tried: @op-engineering/op-sqlite, expo-sqlite)",
|
||||||
|
);
|
||||||
|
// Attach details for debugging; callers should treat this as non-fatal.
|
||||||
|
// (Avoid AggregateError for broader Hermes compatibility.)
|
||||||
|
err.backends = errors;
|
||||||
|
throw err;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return _backendPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExpoSqliteBackend() {
|
||||||
|
// All requires are inside the factory so a missing ExpoSQLite native module
|
||||||
|
// does not crash at module evaluation time.
|
||||||
|
|
||||||
|
let openDbExpoSqlite;
|
||||||
|
let wrapperModule;
|
||||||
|
try {
|
||||||
|
// Expo SQLite wrapper uses static imports to make Metro/Hermes interop stable.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
wrapperModule = require("./openDbExpoSqlite");
|
||||||
|
const expoModuleType = typeof wrapperModule;
|
||||||
|
const expoModuleKeys =
|
||||||
|
wrapperModule &&
|
||||||
|
(typeof wrapperModule === "object" || typeof wrapperModule === "function")
|
||||||
|
? Object.keys(wrapperModule)
|
||||||
|
: [];
|
||||||
|
console.warn(
|
||||||
|
"[DAE_DB] expo-sqlite: require ./openDbExpoSqlite success",
|
||||||
|
`type=${expoModuleType} keys=[${expoModuleKeys.join(", ")}]`,
|
||||||
|
);
|
||||||
|
openDbExpoSqlite = pickOpener(wrapperModule, "expo-sqlite");
|
||||||
|
} catch (requireError) {
|
||||||
|
const err = new Error("Failed to require ./openDbExpoSqlite");
|
||||||
|
err.cause = requireError;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log what we actually picked (helps confirm Metro export shapes in the wild).
|
||||||
|
if (wrapperModule != null) {
|
||||||
|
console.warn(
|
||||||
|
"[DAE_DB] expo-sqlite: picked opener",
|
||||||
|
`typeof=${typeof openDbExpoSqlite}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _expoDbPromise = null;
|
||||||
|
|
||||||
|
function createLegacyAsyncFacade(legacyDb) {
|
||||||
|
const execSqlAsync = (sql, params = []) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const runner =
|
||||||
|
typeof legacyDb.readTransaction === "function"
|
||||||
|
? legacyDb.readTransaction.bind(legacyDb)
|
||||||
|
: legacyDb.transaction.bind(legacyDb);
|
||||||
|
|
||||||
|
runner((tx) => {
|
||||||
|
tx.executeSql(
|
||||||
|
sql,
|
||||||
|
params,
|
||||||
|
() => resolve(),
|
||||||
|
(_tx, err) => {
|
||||||
|
reject(err);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryAllAsync = (sql, params = []) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const runner =
|
||||||
|
typeof legacyDb.readTransaction === "function"
|
||||||
|
? legacyDb.readTransaction.bind(legacyDb)
|
||||||
|
: legacyDb.transaction.bind(legacyDb);
|
||||||
|
|
||||||
|
runner((tx) => {
|
||||||
|
tx.executeSql(
|
||||||
|
sql,
|
||||||
|
params,
|
||||||
|
(_tx, result) => {
|
||||||
|
const rows = [];
|
||||||
|
const len = result?.rows?.length ?? 0;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
rows.push(result.rows.item(i));
|
||||||
|
}
|
||||||
|
resolve(rows);
|
||||||
|
},
|
||||||
|
(_tx, err) => {
|
||||||
|
reject(err);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Methods used by this repo
|
||||||
|
execAsync(sql) {
|
||||||
|
return execSqlAsync(sql);
|
||||||
|
},
|
||||||
|
getAllAsync(sql, params) {
|
||||||
|
return queryAllAsync(sql, params);
|
||||||
|
},
|
||||||
|
async getFirstAsync(sql, params) {
|
||||||
|
const rows = await queryAllAsync(sql, params);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
},
|
||||||
|
// Keep a reference to the underlying legacy DB for debugging.
|
||||||
|
_legacyDb: legacyDb,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initDbExpo() {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { ensureEmbeddedDb } = require("./ensureEmbeddedDb");
|
||||||
|
// Stage the DB into the Expo SQLite dir before opening.
|
||||||
|
await ensureEmbeddedDb({ dbName: DB_NAME });
|
||||||
|
|
||||||
|
let db;
|
||||||
|
// openDbExpoSqlite() can be async (openDatabaseAsync) or sync (openDatabase).
|
||||||
|
db = await openDbExpoSqlite(DB_NAME);
|
||||||
|
|
||||||
|
// Expo Go / older expo-sqlite: provide an async facade compatible with
|
||||||
|
// the subset of methods used in this repo (execAsync + getAllAsync).
|
||||||
|
if (db && typeof db.execAsync !== "function") {
|
||||||
|
db = createLegacyAsyncFacade(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only optimizations
|
||||||
|
await db.execAsync("PRAGMA journal_mode = WAL");
|
||||||
|
await db.execAsync("PRAGMA cache_size = -8000"); // 8 MB
|
||||||
|
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { assertDbHasTable } = require("./validateDbSchema");
|
||||||
|
await assertDbHasTable(db, "defibs");
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "expo-sqlite",
|
||||||
|
getDb() {
|
||||||
|
if (!_expoDbPromise) {
|
||||||
|
_expoDbPromise = initDbExpo();
|
||||||
|
}
|
||||||
|
return _expoDbPromise;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
87
src/db/openDbExpoSqlite.js
Normal file
87
src/db/openDbExpoSqlite.js
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
// Expo SQLite wrapper (require-safe, Metro/Hermes friendly).
|
||||||
|
//
|
||||||
|
// Requirements from runtime backend selection:
|
||||||
|
// - Must NOT crash at module evaluation time when ExpoSQLite native module is missing.
|
||||||
|
// - Must be safe to load via `require('./openDbExpoSqlite')` under Metro/Hermes.
|
||||||
|
// - Must export a callable `openDbExpoSqlite()` function via CommonJS exports.
|
||||||
|
//
|
||||||
|
// IMPORTANT: Do NOT use top-level `import` from expo-sqlite here.
|
||||||
|
|
||||||
|
function describeKeys(x) {
|
||||||
|
if (!x) return [];
|
||||||
|
const t = typeof x;
|
||||||
|
if (t !== "object" && t !== "function") return [];
|
||||||
|
try {
|
||||||
|
return Object.keys(x);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireExpoSqlite() {
|
||||||
|
// Lazily require so missing native module does not crash at module evaluation time.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const mod = require("expo-sqlite");
|
||||||
|
const candidate = mod?.default ?? mod;
|
||||||
|
|
||||||
|
const openDatabaseAsync =
|
||||||
|
candidate?.openDatabaseAsync ??
|
||||||
|
mod?.openDatabaseAsync ??
|
||||||
|
mod?.default?.openDatabaseAsync;
|
||||||
|
const openDatabase =
|
||||||
|
candidate?.openDatabase ?? mod?.openDatabase ?? mod?.default?.openDatabase;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mod,
|
||||||
|
candidate,
|
||||||
|
openDatabaseAsync,
|
||||||
|
openDatabase,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an expo-sqlite database using whichever API exists.
|
||||||
|
*
|
||||||
|
* @param {string} dbName
|
||||||
|
* @returns {Promise<any>} SQLiteDatabase (new API) or legacy Database (sync open)
|
||||||
|
*/
|
||||||
|
async function openDbExpoSqlite(dbName) {
|
||||||
|
const api = requireExpoSqlite();
|
||||||
|
|
||||||
|
if (typeof api.openDatabaseAsync === "function") {
|
||||||
|
return api.openDatabaseAsync(dbName);
|
||||||
|
}
|
||||||
|
if (typeof api.openDatabase === "function") {
|
||||||
|
// Legacy expo-sqlite API (sync open)
|
||||||
|
return api.openDatabase(dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modKeys = describeKeys(api.mod);
|
||||||
|
const defaultKeys = describeKeys(api.mod?.default);
|
||||||
|
const candidateKeys = describeKeys(api.candidate);
|
||||||
|
|
||||||
|
const err = new TypeError(
|
||||||
|
[
|
||||||
|
"expo-sqlite require() did not expose openDatabaseAsync nor openDatabase.",
|
||||||
|
`module typeof=${typeof api.mod} keys=[${modKeys.join(", ")}].`,
|
||||||
|
`default typeof=${typeof api.mod?.default} keys=[${defaultKeys.join(
|
||||||
|
", ",
|
||||||
|
)}].`,
|
||||||
|
`candidate typeof=${typeof api.candidate} keys=[${candidateKeys.join(
|
||||||
|
", ",
|
||||||
|
)}].`,
|
||||||
|
].join(" "),
|
||||||
|
);
|
||||||
|
err.expoSqliteModuleKeys = modKeys;
|
||||||
|
err.expoSqliteDefaultKeys = defaultKeys;
|
||||||
|
err.expoSqliteCandidateKeys = candidateKeys;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit CommonJS export shape (so require() always returns a non-null object).
|
||||||
|
module.exports = {
|
||||||
|
__esModule: true,
|
||||||
|
openDbExpoSqlite,
|
||||||
|
openDb: openDbExpoSqlite,
|
||||||
|
default: openDbExpoSqlite,
|
||||||
|
};
|
||||||
244
src/db/openDbOpSqlite.js
Normal file
244
src/db/openDbOpSqlite.js
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
// Open the pre-built geodae SQLite database (Bare RN variant).
|
||||||
|
// Requires: @op-engineering/op-sqlite
|
||||||
|
// Install: npm install @op-engineering/op-sqlite
|
||||||
|
// Place geodae.db in:
|
||||||
|
// Android: android/app/src/main/assets/geodae.db
|
||||||
|
// iOS: add geodae.db to Xcode project "Copy Bundle Resources"
|
||||||
|
//
|
||||||
|
// NOTE: This module is intentionally written in CommonJS to make Metro/Hermes
|
||||||
|
// `require('./openDbOpSqlite')` resolution stable across CJS/ESM interop.
|
||||||
|
|
||||||
|
function requireOpSqliteOpen() {
|
||||||
|
// Lazy require to keep this module loadable in Jest/node.
|
||||||
|
// (op-sqlite ships a node/ dist that is ESM and not Jest-CJS friendly.)
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const mod = require("@op-engineering/op-sqlite");
|
||||||
|
const open = mod?.open ?? mod?.default?.open;
|
||||||
|
if (typeof open !== "function") {
|
||||||
|
const keys =
|
||||||
|
mod && (typeof mod === "object" || typeof mod === "function")
|
||||||
|
? Object.keys(mod)
|
||||||
|
: [];
|
||||||
|
throw new TypeError(
|
||||||
|
`[DAE_DB] op-sqlite require() did not expose an open() function (keys=[${keys.join(
|
||||||
|
", ",
|
||||||
|
)}])`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = "geodae.db";
|
||||||
|
|
||||||
|
let _rawDb = null;
|
||||||
|
let _dbPromise = null;
|
||||||
|
|
||||||
|
function describeDbShape(db) {
|
||||||
|
if (!db) return { type: typeof db, keys: [] };
|
||||||
|
const t = typeof db;
|
||||||
|
if (t !== "object" && t !== "function") return { type: t, keys: [] };
|
||||||
|
try {
|
||||||
|
return { type: t, keys: Object.keys(db) };
|
||||||
|
} catch {
|
||||||
|
return { type: t, keys: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapt an op-sqlite DB instance to the async API expected by repo code.
|
||||||
|
*
|
||||||
|
* Required interface (subset of expo-sqlite modern API):
|
||||||
|
* - execAsync(sql)
|
||||||
|
* - getAllAsync(sql, params)
|
||||||
|
* - getFirstAsync(sql, params)
|
||||||
|
*
|
||||||
|
* op-sqlite exposes: execute()/executeAsync() returning { rows: [...] }.
|
||||||
|
*
|
||||||
|
* @param {any} opDb
|
||||||
|
*/
|
||||||
|
function adaptDbToRepoInterface(opDb) {
|
||||||
|
if (!opDb) {
|
||||||
|
throw new TypeError(
|
||||||
|
"[DAE_DB] op-sqlite adapter: DB instance is null/undefined",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: if caller already passes an expo-sqlite-like DB, keep it.
|
||||||
|
if (
|
||||||
|
typeof opDb.execAsync === "function" &&
|
||||||
|
typeof opDb.getAllAsync === "function" &&
|
||||||
|
typeof opDb.getFirstAsync === "function"
|
||||||
|
) {
|
||||||
|
return opDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeAsync =
|
||||||
|
(typeof opDb.executeAsync === "function" && opDb.executeAsync.bind(opDb)) ||
|
||||||
|
(typeof opDb.execute === "function" && opDb.execute.bind(opDb));
|
||||||
|
const executeSync =
|
||||||
|
typeof opDb.executeSync === "function" ? opDb.executeSync.bind(opDb) : null;
|
||||||
|
|
||||||
|
if (!executeAsync && !executeSync) {
|
||||||
|
const shape = describeDbShape(opDb);
|
||||||
|
throw new TypeError(
|
||||||
|
[
|
||||||
|
"[DAE_DB] op-sqlite adapter: cannot adapt DB.",
|
||||||
|
"Expected executeAsync()/execute() or executeSync() methods.",
|
||||||
|
`db typeof=${shape.type} keys=[${shape.keys.join(", ")}]`,
|
||||||
|
].join(" "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runQueryAsync = async (sql, params) => {
|
||||||
|
try {
|
||||||
|
if (executeAsync) {
|
||||||
|
return params != null
|
||||||
|
? await executeAsync(sql, params)
|
||||||
|
: await executeAsync(sql);
|
||||||
|
}
|
||||||
|
// Sync fallback (best effort): wrap in a Promise for repo compatibility.
|
||||||
|
return params != null ? executeSync(sql, params) : executeSync(sql);
|
||||||
|
} catch (e) {
|
||||||
|
// Make it actionable for end users/devs.
|
||||||
|
const err = new Error(
|
||||||
|
`[DAE_DB] Query failed (op-sqlite). ${e?.message ?? String(e)}`,
|
||||||
|
);
|
||||||
|
err.cause = e;
|
||||||
|
err.sql = sql;
|
||||||
|
err.params = params;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async execAsync(sql) {
|
||||||
|
await runQueryAsync(sql);
|
||||||
|
},
|
||||||
|
async getAllAsync(sql, params = []) {
|
||||||
|
const res = await runQueryAsync(sql, params);
|
||||||
|
const rows = res?.rows;
|
||||||
|
// op-sqlite returns rows as array of objects.
|
||||||
|
if (Array.isArray(rows)) return rows;
|
||||||
|
// Defensive: if a driver returns no rows field for non-SELECT.
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
async getFirstAsync(sql, params = []) {
|
||||||
|
const rows = await this.getAllAsync(sql, params);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
},
|
||||||
|
// Keep a reference to the underlying DB for debugging / escape hatches.
|
||||||
|
_opDb: opDb,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDbOpSqlite() {
|
||||||
|
if (_dbPromise) return _dbPromise;
|
||||||
|
|
||||||
|
_dbPromise = (async () => {
|
||||||
|
// Stage the embedded DB in the Expo SQLite dir first.
|
||||||
|
// This prevents op-sqlite from creating/opening an empty DB.
|
||||||
|
let sqliteDirUri;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { ensureEmbeddedDb } = require("./ensureEmbeddedDb");
|
||||||
|
const { sqliteDirUri: dir } = await ensureEmbeddedDb({ dbName: DB_NAME });
|
||||||
|
sqliteDirUri = dir;
|
||||||
|
} catch (e) {
|
||||||
|
const err = new Error(
|
||||||
|
"[DAE_DB] Failed to stage embedded DB before opening (op-sqlite)",
|
||||||
|
);
|
||||||
|
err.cause = e;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: op-sqlite open() params are not identical to expo-sqlite.
|
||||||
|
// Pass only supported keys to avoid native-side strict validation.
|
||||||
|
const open = requireOpSqliteOpen();
|
||||||
|
_rawDb = open({ name: DB_NAME, location: sqliteDirUri });
|
||||||
|
if (!_rawDb) {
|
||||||
|
throw new Error("op-sqlite open() returned a null DB instance");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only-ish optimizations.
|
||||||
|
// Prefer executeSync when available.
|
||||||
|
try {
|
||||||
|
if (typeof _rawDb.executeSync === "function") {
|
||||||
|
_rawDb.executeSync("PRAGMA cache_size = -8000"); // 8 MB
|
||||||
|
_rawDb.executeSync("PRAGMA query_only = ON");
|
||||||
|
} else if (typeof _rawDb.execute === "function") {
|
||||||
|
// Fire-and-forget; adapter methods will still work regardless.
|
||||||
|
_rawDb.execute("PRAGMA cache_size = -8000");
|
||||||
|
_rawDb.execute("PRAGMA query_only = ON");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: keep DB usable even if pragmas fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = adaptDbToRepoInterface(_rawDb);
|
||||||
|
|
||||||
|
// Runtime guard: fail fast with a clear message if adapter didn't produce the expected API.
|
||||||
|
if (
|
||||||
|
!db ||
|
||||||
|
typeof db.execAsync !== "function" ||
|
||||||
|
typeof db.getAllAsync !== "function" ||
|
||||||
|
typeof db.getFirstAsync !== "function"
|
||||||
|
) {
|
||||||
|
const shape = describeDbShape(db);
|
||||||
|
throw new TypeError(
|
||||||
|
[
|
||||||
|
"[DAE_DB] op-sqlite adapter produced an invalid DB facade.",
|
||||||
|
`typeof=${shape.type} keys=[${shape.keys.join(", ")}]`,
|
||||||
|
].join(" "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate schema early to avoid later "no such table" runtime errors.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { assertDbHasTable } = require("./validateDbSchema");
|
||||||
|
await assertDbHasTable(db, "defibs");
|
||||||
|
|
||||||
|
// Helpful for debugging in the wild.
|
||||||
|
try {
|
||||||
|
if (typeof _rawDb.getDbPath === "function") {
|
||||||
|
console.warn("[DAE_DB] op-sqlite opened DB path:", _rawDb.getDbPath());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal.
|
||||||
|
}
|
||||||
|
|
||||||
|
return db;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return _dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the current DB connection and clear cached promises.
|
||||||
|
* After calling this, the next `openDbOpSqlite()` call will re-open the DB.
|
||||||
|
*/
|
||||||
|
function resetDbOpSqlite() {
|
||||||
|
if (_rawDb) {
|
||||||
|
try {
|
||||||
|
if (typeof _rawDb.close === "function") {
|
||||||
|
_rawDb.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: DB may already be closed or in an invalid state.
|
||||||
|
}
|
||||||
|
_rawDb = null;
|
||||||
|
}
|
||||||
|
_dbPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exports (CJS + ESM-ish):
|
||||||
|
// Keep `require('./openDbOpSqlite')` returning a non-null *object* so Metro/Hermes
|
||||||
|
// cannot hand back a nullish / unexpected callable export shape.
|
||||||
|
module.exports = {
|
||||||
|
__esModule: true,
|
||||||
|
openDbOpSqlite,
|
||||||
|
openDb: openDbOpSqlite,
|
||||||
|
default: openDbOpSqlite,
|
||||||
|
resetDbOpSqlite,
|
||||||
|
// Named export for unit tests.
|
||||||
|
adaptDbToRepoInterface,
|
||||||
|
};
|
||||||
32
src/db/openDbOpSqlite.test.js
Normal file
32
src/db/openDbOpSqlite.test.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
const { adaptDbToRepoInterface } = require("./openDbOpSqlite");
|
||||||
|
|
||||||
|
describe("db/openDbOpSqlite adapter", () => {
|
||||||
|
test("creates execAsync/getAllAsync/getFirstAsync from executeAsync", async () => {
|
||||||
|
const executeAsync = jest.fn(async (sql, params) => {
|
||||||
|
if (sql === "SELECT 1") return { rows: [{ a: 1 }] };
|
||||||
|
if (sql === "SELECT empty") return { rows: [] };
|
||||||
|
return { rows: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = adaptDbToRepoInterface({ executeAsync });
|
||||||
|
|
||||||
|
expect(typeof db.execAsync).toBe("function");
|
||||||
|
expect(typeof db.getAllAsync).toBe("function");
|
||||||
|
expect(typeof db.getFirstAsync).toBe("function");
|
||||||
|
|
||||||
|
await db.execAsync("PRAGMA cache_size = -8000");
|
||||||
|
expect(executeAsync).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const rows = await db.getAllAsync("SELECT 1", []);
|
||||||
|
expect(rows).toEqual([{ a: 1 }]);
|
||||||
|
|
||||||
|
const first = await db.getFirstAsync("SELECT empty", []);
|
||||||
|
expect(first).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws a clear error when no execute method exists", () => {
|
||||||
|
expect(() => adaptDbToRepoInterface({})).toThrow(
|
||||||
|
/op-sqlite adapter: cannot adapt DB/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
213
src/db/updateDaeDb.js
Normal file
213
src/db/updateDaeDb.js
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
// Over-the-air DAE database update.
|
||||||
|
//
|
||||||
|
// Downloads a fresh geodae.db from the Minio/S3 bucket, validates it,
|
||||||
|
// swaps the on-device copy, and resets the DB connection so subsequent
|
||||||
|
// queries use the new data.
|
||||||
|
//
|
||||||
|
// IMPORTANT:
|
||||||
|
// - All native requires must stay inside functions so this file can be loaded
|
||||||
|
// in Jest/node without crashing.
|
||||||
|
|
||||||
|
import env from "~/env";
|
||||||
|
import { STORAGE_KEYS } from "~/storage/storageKeys";
|
||||||
|
|
||||||
|
const DB_NAME = "geodae.db";
|
||||||
|
const GEODAE_BUCKET = "geodae";
|
||||||
|
const METADATA_FILE = "metadata.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the public Minio URL for a given bucket/object.
|
||||||
|
* @param {string} object - object key within the geodae bucket
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function geodaeUrl(object) {
|
||||||
|
const base = env.MINIO_URL.replace(/\/+$/, "");
|
||||||
|
return `${base}/${GEODAE_BUCKET}/${object}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} UpdateProgress
|
||||||
|
* @property {number} totalBytesWritten
|
||||||
|
* @property {number} totalBytesExpectedToWrite
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} UpdateResult
|
||||||
|
* @property {boolean} success
|
||||||
|
* @property {boolean} [alreadyUpToDate]
|
||||||
|
* @property {string} [updatedAt]
|
||||||
|
* @property {Error} [error]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and install the latest geodae.db from the server.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {function(UpdateProgress): void} [options.onProgress] - download progress callback
|
||||||
|
* @param {function(string): void} [options.onPhase] - phase change callback ("checking"|"downloading"|"installing")
|
||||||
|
* @returns {Promise<UpdateResult>}
|
||||||
|
*/
|
||||||
|
export async function updateDaeDb({ onProgress, onPhase } = {}) {
|
||||||
|
// Lazy requires to keep Jest/node stable.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const FileSystemModule = require("expo-file-system");
|
||||||
|
const FileSystem = FileSystemModule?.default ?? FileSystemModule;
|
||||||
|
|
||||||
|
const sqliteDirUri = `${FileSystem.documentDirectory}SQLite`;
|
||||||
|
const dbUri = `${sqliteDirUri}/${DB_NAME}`;
|
||||||
|
const tmpUri = `${FileSystem.cacheDirectory}geodae-update-${Date.now()}.db`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Phase 1: Check metadata ──────────────────────────────────────────
|
||||||
|
onPhase?.("checking");
|
||||||
|
|
||||||
|
const metadataUrl = geodaeUrl(METADATA_FILE);
|
||||||
|
const metaResponse = await fetch(metadataUrl);
|
||||||
|
if (!metaResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`[DAE_UPDATE] Failed to fetch metadata: HTTP ${metaResponse.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const metadata = await metaResponse.json();
|
||||||
|
const remoteUpdatedAt = metadata.updatedAt;
|
||||||
|
|
||||||
|
if (!remoteUpdatedAt) {
|
||||||
|
throw new Error("[DAE_UPDATE] Metadata missing updatedAt field");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare with stored last update timestamp
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const memoryAsyncStorageModule = require("~/storage/memoryAsyncStorage");
|
||||||
|
const memoryAsyncStorage =
|
||||||
|
memoryAsyncStorageModule?.default ?? memoryAsyncStorageModule;
|
||||||
|
const storedUpdatedAt = await memoryAsyncStorage.getItem(
|
||||||
|
STORAGE_KEYS.DAE_DB_UPDATED_AT,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
storedUpdatedAt &&
|
||||||
|
new Date(remoteUpdatedAt).getTime() <= new Date(storedUpdatedAt).getTime()
|
||||||
|
) {
|
||||||
|
return { success: true, alreadyUpToDate: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: Download ────────────────────────────────────────────────
|
||||||
|
onPhase?.("downloading");
|
||||||
|
|
||||||
|
const dbUrl = geodaeUrl(DB_NAME);
|
||||||
|
const downloadResumable = FileSystem.createDownloadResumable(
|
||||||
|
dbUrl,
|
||||||
|
tmpUri,
|
||||||
|
{},
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
const downloadResult = await downloadResumable.downloadAsync();
|
||||||
|
|
||||||
|
if (!downloadResult?.uri) {
|
||||||
|
throw new Error("[DAE_UPDATE] Download failed: no URI returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the downloaded file is non-empty
|
||||||
|
const tmpInfo = await FileSystem.getInfoAsync(tmpUri);
|
||||||
|
if (!tmpInfo.exists || tmpInfo.size === 0) {
|
||||||
|
throw new Error("[DAE_UPDATE] Downloaded file is empty or missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: Validate ────────────────────────────────────────────────
|
||||||
|
onPhase?.("installing");
|
||||||
|
|
||||||
|
// Quick validation: open the downloaded DB and check schema
|
||||||
|
// We use the same validation as the main DB opener.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { assertDbHasTable } = require("./validateDbSchema");
|
||||||
|
|
||||||
|
// Try to open the temp DB with op-sqlite for validation
|
||||||
|
let validationDb = null;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const opSqliteMod = require("@op-engineering/op-sqlite");
|
||||||
|
const open = opSqliteMod?.open ?? opSqliteMod?.default?.open;
|
||||||
|
if (typeof open === "function") {
|
||||||
|
// op-sqlite needs the directory and filename separately
|
||||||
|
const tmpDir = tmpUri.substring(0, tmpUri.lastIndexOf("/"));
|
||||||
|
const tmpName = tmpUri.substring(tmpUri.lastIndexOf("/") + 1);
|
||||||
|
validationDb = open({ name: tmpName, location: tmpDir });
|
||||||
|
|
||||||
|
// Wrap for assertDbHasTable compatibility
|
||||||
|
const getAllAsync = async (sql, params = []) => {
|
||||||
|
const exec =
|
||||||
|
typeof validationDb.executeAsync === "function"
|
||||||
|
? validationDb.executeAsync.bind(validationDb)
|
||||||
|
: validationDb.execute?.bind(validationDb);
|
||||||
|
if (!exec) throw new Error("No execute method on validation DB");
|
||||||
|
const res = params.length ? await exec(sql, params) : await exec(sql);
|
||||||
|
return res?.rows ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
await assertDbHasTable({ getAllAsync }, "defibs");
|
||||||
|
}
|
||||||
|
} catch (validationError) {
|
||||||
|
// Clean up temp file
|
||||||
|
try {
|
||||||
|
await FileSystem.deleteAsync(tmpUri, { idempotent: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
const err = new Error("[DAE_UPDATE] Downloaded DB failed validation");
|
||||||
|
err.cause = validationError;
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
// Close validation DB
|
||||||
|
if (validationDb && typeof validationDb.close === "function") {
|
||||||
|
try {
|
||||||
|
validationDb.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 4: Swap ────────────────────────────────────────────────────
|
||||||
|
// IMPORTANT: resetDb() closes the DB and clears cached promises.
|
||||||
|
// No concurrent DB queries should be in flight at this point.
|
||||||
|
// The caller (store action) is the only code path that triggers this,
|
||||||
|
// and it awaits completion before allowing new queries.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { resetDb } = require("./openDb");
|
||||||
|
resetDb();
|
||||||
|
|
||||||
|
// Ensure SQLite directory exists
|
||||||
|
const dirInfo = await FileSystem.getInfoAsync(sqliteDirUri);
|
||||||
|
if (!dirInfo.exists) {
|
||||||
|
await FileSystem.makeDirectoryAsync(sqliteDirUri, {
|
||||||
|
intermediates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the DB file
|
||||||
|
await FileSystem.moveAsync({ from: tmpUri, to: dbUri });
|
||||||
|
|
||||||
|
// Persist the update timestamp
|
||||||
|
await memoryAsyncStorage.setItem(
|
||||||
|
STORAGE_KEYS.DAE_DB_UPDATED_AT,
|
||||||
|
remoteUpdatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
"[DAE_UPDATE] Successfully updated geodae.db to version:",
|
||||||
|
remoteUpdatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, updatedAt: remoteUpdatedAt };
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temp file on any error (FileSystem is in scope from the outer try)
|
||||||
|
try {
|
||||||
|
await FileSystem.deleteAsync(tmpUri, { idempotent: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn("[DAE_UPDATE] Update failed:", error?.message, error);
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/db/validateDbSchema.js
Normal file
34
src/db/validateDbSchema.js
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Schema validation for the embedded geodae.db.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that the embedded DB looks like the pre-populated database.
|
||||||
|
*
|
||||||
|
* This is a cheap query and catches cases where we accidentally opened a new/
|
||||||
|
* empty DB file (which then fails later with "no such table: defibs").
|
||||||
|
*
|
||||||
|
* @param {Object} db
|
||||||
|
* @param {string} [tableName]
|
||||||
|
*/
|
||||||
|
async function assertDbHasTable(db, tableName = "defibs") {
|
||||||
|
if (!db || typeof db.getFirstAsync !== "function") {
|
||||||
|
throw new TypeError(
|
||||||
|
"[DAE_DB] Cannot validate schema: db.getFirstAsync() missing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await db.getFirstAsync(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
|
||||||
|
[tableName],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row || row.name !== tableName) {
|
||||||
|
throw new Error(
|
||||||
|
`[DAE_DB] Embedded DB missing ${tableName} table (likely opened empty DB)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
__esModule: true,
|
||||||
|
assertDbHasTable,
|
||||||
|
};
|
||||||
19
src/db/validateDbSchema.test.js
Normal file
19
src/db/validateDbSchema.test.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
const { assertDbHasTable } = require("./validateDbSchema");
|
||||||
|
|
||||||
|
describe("db/validateDbSchema", () => {
|
||||||
|
test("passes when table exists", async () => {
|
||||||
|
const db = {
|
||||||
|
getFirstAsync: jest.fn(async () => ({ name: "defibs" })),
|
||||||
|
};
|
||||||
|
await expect(assertDbHasTable(db, "defibs")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws a clear error when table is missing", async () => {
|
||||||
|
const db = {
|
||||||
|
getFirstAsync: jest.fn(async () => null),
|
||||||
|
};
|
||||||
|
await expect(assertDbHasTable(db, "defibs")).rejects.toThrow(
|
||||||
|
/missing defibs table/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -128,7 +128,7 @@ export default function useLatestWithSubscription(
|
||||||
|
|
||||||
// Some devices keep the WS transport "connected" after a lock/unlock, but the
|
// Some devices keep the WS transport "connected" after a lock/unlock, but the
|
||||||
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
|
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
|
||||||
const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000;
|
const FOREGROUND_KICK_MIN_INACTIVE_MS = 30_000;
|
||||||
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
|
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -239,6 +239,15 @@ export default function useLatestWithSubscription(
|
||||||
if (age < livenessStaleMs) return;
|
if (age < livenessStaleMs) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const becameInactiveAt = lastBecameInactiveAtRef.current;
|
||||||
|
const inactiveWindowMs = becameInactiveAt ? now - becameInactiveAt : null;
|
||||||
|
if (
|
||||||
|
typeof inactiveWindowMs === "number" &&
|
||||||
|
inactiveWindowMs < livenessStaleMs + 15_000
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (now - lastLivenessKickAtRef.current < livenessStaleMs) return;
|
if (now - lastLivenessKickAtRef.current < livenessStaleMs) return;
|
||||||
lastLivenessKickAtRef.current = now;
|
lastLivenessKickAtRef.current = now;
|
||||||
|
|
||||||
|
|
@ -276,7 +285,7 @@ export default function useLatestWithSubscription(
|
||||||
|
|
||||||
// Escalation policy for repeated consecutive stale kicks.
|
// Escalation policy for repeated consecutive stale kicks.
|
||||||
if (
|
if (
|
||||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD &&
|
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD + 2 &&
|
||||||
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||||
) {
|
) {
|
||||||
const lastRecovery = wsLastRecoveryDateRef.current
|
const lastRecovery = wsLastRecoveryDateRef.current
|
||||||
|
|
@ -310,7 +319,7 @@ export default function useLatestWithSubscription(
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
networkActions.triggerReload();
|
networkActions.triggerReload("transport");
|
||||||
} else if (
|
} else if (
|
||||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
|
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
|
||||||
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ export default function useStreamQueryWithSubscription(
|
||||||
|
|
||||||
// Some devices keep the WS transport "connected" after a lock/unlock, but the
|
// Some devices keep the WS transport "connected" after a lock/unlock, but the
|
||||||
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
|
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
|
||||||
const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000;
|
const FOREGROUND_KICK_MIN_INACTIVE_MS = 30_000;
|
||||||
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
|
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -281,6 +281,15 @@ export default function useStreamQueryWithSubscription(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Avoid spamming resubscribe triggers.
|
// Avoid spamming resubscribe triggers.
|
||||||
|
const becameInactiveAt = lastBecameInactiveAtRef.current;
|
||||||
|
const inactiveWindowMs = becameInactiveAt ? now - becameInactiveAt : null;
|
||||||
|
if (
|
||||||
|
typeof inactiveWindowMs === "number" &&
|
||||||
|
inactiveWindowMs < livenessStaleMs + 15_000
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (now - lastLivenessKickAtRef.current < livenessStaleMs) return;
|
if (now - lastLivenessKickAtRef.current < livenessStaleMs) return;
|
||||||
lastLivenessKickAtRef.current = now;
|
lastLivenessKickAtRef.current = now;
|
||||||
|
|
||||||
|
|
@ -318,7 +327,7 @@ export default function useStreamQueryWithSubscription(
|
||||||
|
|
||||||
// Escalation policy for repeated consecutive stale kicks.
|
// Escalation policy for repeated consecutive stale kicks.
|
||||||
if (
|
if (
|
||||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD &&
|
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD + 2 &&
|
||||||
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||||
) {
|
) {
|
||||||
const lastRecovery = wsLastRecoveryDateRef.current
|
const lastRecovery = wsLastRecoveryDateRef.current
|
||||||
|
|
@ -352,7 +361,7 @@ export default function useStreamQueryWithSubscription(
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
networkActions.triggerReload();
|
networkActions.triggerReload("transport");
|
||||||
} else if (
|
} else if (
|
||||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
|
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
|
||||||
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import {
|
||||||
Dark as NavigationDarkTheme,
|
Dark as NavigationDarkTheme,
|
||||||
} from "~/theme/navigation";
|
} from "~/theme/navigation";
|
||||||
|
|
||||||
|
import DaeSuggestModal from "~/containers/DaeSuggestModal";
|
||||||
|
|
||||||
// import { navActions } from "~/stores";
|
// import { navActions } from "~/stores";
|
||||||
|
|
||||||
// const linking = {
|
// const linking = {
|
||||||
|
|
@ -86,6 +88,9 @@ export default function LayoutProviders({ layoutKey, setLayoutKey, children }) {
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|
||||||
|
{/* Global persistent modal: mounted outside navigation tree, but can navigate via RootNav ref */}
|
||||||
|
<DaeSuggestModal />
|
||||||
</ComposeComponents>
|
</ComposeComponents>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
63
src/lib/h3/index.js
Normal file
63
src/lib/h3/index.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// Hermes-safe H3 wrapper.
|
||||||
|
//
|
||||||
|
// Why this exists:
|
||||||
|
// - `h3-js`'s default entry (`dist/h3-js.js`) is a Node-oriented Emscripten build.
|
||||||
|
// - Metro (React Native) does not reliably honor the package.json `browser` field,
|
||||||
|
// so RN/Hermes may resolve the Node build, which relies on Node Buffer encodings
|
||||||
|
// (e.g. "utf-16le") and crashes under Hermes.
|
||||||
|
//
|
||||||
|
// This wrapper forces the browser bundle when running under Hermes.
|
||||||
|
|
||||||
|
/* eslint-disable global-require */
|
||||||
|
|
||||||
|
function isHermes() {
|
||||||
|
// https://reactnative.dev/docs/hermes
|
||||||
|
return typeof global === "object" && !!global.HermesInternal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsUtf16leTextDecoder() {
|
||||||
|
if (typeof global !== "object" || typeof global.TextDecoder !== "function") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Hermes' built-in TextDecoder historically supports only utf-8.
|
||||||
|
// `h3-js` bundles try to instantiate a UTF-16LE decoder at module init.
|
||||||
|
// If unsupported, Hermes throws: RangeError: Unknown encoding: utf-16le
|
||||||
|
// Detect support and fall back to the non-TextDecoder path when needed.
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new global.TextDecoder("utf-16le");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the choice static at module init so exports are stable.
|
||||||
|
let h3;
|
||||||
|
|
||||||
|
if (isHermes()) {
|
||||||
|
// Force browser bundle (no Node fs/path/Buffer branches).
|
||||||
|
// Additionally, if Hermes' TextDecoder doesn't support utf-16le, temporarily
|
||||||
|
// hide it so h3-js uses its pure-JS decoding fallback instead.
|
||||||
|
const hasUtf16 = supportsUtf16leTextDecoder();
|
||||||
|
const originalTextDecoder = global.TextDecoder;
|
||||||
|
if (!hasUtf16) {
|
||||||
|
global.TextDecoder = undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
h3 = require("h3-js/dist/browser/h3-js");
|
||||||
|
} finally {
|
||||||
|
if (!hasUtf16) {
|
||||||
|
global.TextDecoder = originalTextDecoder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Jest/node tests can keep using the default build.
|
||||||
|
h3 = require("h3-js");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const latLngToCell = h3.latLngToCell;
|
||||||
|
export const gridDisk = h3.gridDisk;
|
||||||
|
|
||||||
|
// Export the full namespace for any other future usage.
|
||||||
|
export default h3;
|
||||||
|
|
@ -7,6 +7,8 @@ import {
|
||||||
} from "@expo/vector-icons";
|
} from "@expo/vector-icons";
|
||||||
import { useNavigation, CommonActions } from "@react-navigation/native";
|
import { useNavigation, CommonActions } from "@react-navigation/native";
|
||||||
|
|
||||||
|
import FontAwesome6 from "@expo/vector-icons/FontAwesome6";
|
||||||
|
|
||||||
import DrawerContent from "~/navigation/DrawerNav/DrawerContent";
|
import DrawerContent from "~/navigation/DrawerNav/DrawerContent";
|
||||||
import { useDrawerState } from "~/navigation/Context";
|
import { useDrawerState } from "~/navigation/Context";
|
||||||
import getDefaultDrawerWidth from "~/navigation/DrawerNav/getDefaultDrawerWidth";
|
import getDefaultDrawerWidth from "~/navigation/DrawerNav/getDefaultDrawerWidth";
|
||||||
|
|
@ -23,6 +25,8 @@ import AlertAggListArchived from "~/scenes/AlertAggListArchived";
|
||||||
import About from "~/scenes/About";
|
import About from "~/scenes/About";
|
||||||
import Contribute from "~/scenes/Contribute";
|
import Contribute from "~/scenes/Contribute";
|
||||||
import Location from "~/scenes/Location";
|
import Location from "~/scenes/Location";
|
||||||
|
import DAEList from "~/scenes/DAEList";
|
||||||
|
import DAEItem from "~/scenes/DAEItem";
|
||||||
import Developer from "~/scenes/Developer";
|
import Developer from "~/scenes/Developer";
|
||||||
import HelpSignal from "~/scenes/HelpSignal";
|
import HelpSignal from "~/scenes/HelpSignal";
|
||||||
|
|
||||||
|
|
@ -83,6 +87,7 @@ export default React.memo(function DrawerNav() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer.Navigator
|
<Drawer.Navigator
|
||||||
|
backBehavior="history"
|
||||||
drawerContent={(props) => <DrawerContent {...props} />}
|
drawerContent={(props) => <DrawerContent {...props} />}
|
||||||
drawerStyle={{
|
drawerStyle={{
|
||||||
width: getDefaultDrawerWidth(dimensions),
|
width: getDefaultDrawerWidth(dimensions),
|
||||||
|
|
@ -366,6 +371,27 @@ export default React.memo(function DrawerNav() {
|
||||||
}}
|
}}
|
||||||
listeners={{}}
|
listeners={{}}
|
||||||
/>
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="DAEList"
|
||||||
|
component={DAEList}
|
||||||
|
options={{
|
||||||
|
drawerLabel: "Défibrillateurs",
|
||||||
|
drawerIcon: ({ focused }) => (
|
||||||
|
<FontAwesome6
|
||||||
|
name="heart-circle-bolt"
|
||||||
|
{...iconProps}
|
||||||
|
{...(focused ? iconFocusedProps : {})}
|
||||||
|
/>
|
||||||
|
// <MaterialCommunityIcons
|
||||||
|
// name="heart-flash"
|
||||||
|
// {...iconProps}
|
||||||
|
// {...(focused ? iconFocusedProps : {})}
|
||||||
|
// />
|
||||||
|
),
|
||||||
|
unmountOnBlur: true,
|
||||||
|
}}
|
||||||
|
listeners={{}}
|
||||||
|
/>
|
||||||
<Drawer.Screen
|
<Drawer.Screen
|
||||||
name="Links"
|
name="Links"
|
||||||
component={Links}
|
component={Links}
|
||||||
|
|
@ -503,6 +529,14 @@ export default React.memo(function DrawerNav() {
|
||||||
}}
|
}}
|
||||||
component={SendAlertFinder}
|
component={SendAlertFinder}
|
||||||
/>
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="DAEItem"
|
||||||
|
component={DAEItem}
|
||||||
|
options={{
|
||||||
|
hidden: true,
|
||||||
|
unmountOnBlur: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{devModeEnabled && (
|
{devModeEnabled && (
|
||||||
<Drawer.Screen
|
<Drawer.Screen
|
||||||
name="Developer"
|
name="Developer"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Image } from "react-native";
|
import { Image } from "react-native";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation, CommonActions } from "@react-navigation/native";
|
||||||
import { HeaderBackButton } from "@react-navigation/elements";
|
import { HeaderBackButton } from "@react-navigation/elements";
|
||||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
|
@ -28,7 +28,24 @@ export default function HeaderLeft(props) {
|
||||||
if (canGoBack) {
|
if (canGoBack) {
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
} else {
|
} else {
|
||||||
navigation.navigate(drawerState.topTabPrev || "SendAlert");
|
// HeaderLeft is rendered by the RootStack which has a single
|
||||||
|
// "Main" screen, so canGoBack is always false here.
|
||||||
|
// Dispatch GO_BACK targeted at the Drawer navigator so it
|
||||||
|
// uses its history-based back behaviour.
|
||||||
|
const rootState = navigation.getState();
|
||||||
|
const drawerNavState = rootState.routes[0]?.state;
|
||||||
|
if (
|
||||||
|
drawerNavState?.key &&
|
||||||
|
drawerNavState.history?.filter((h) => h.type === "route")
|
||||||
|
.length > 1
|
||||||
|
) {
|
||||||
|
navigation.dispatch({
|
||||||
|
...CommonActions.goBack(),
|
||||||
|
target: drawerNavState.key,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
navigation.navigate(drawerState.topTabPrev || "SendAlert");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
backImage={() => (
|
backImage={() => (
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,11 @@ function getHeaderTitle(route) {
|
||||||
case "SendAlertFinder":
|
case "SendAlertFinder":
|
||||||
return "Par mot-clé";
|
return "Par mot-clé";
|
||||||
|
|
||||||
|
case "DAEList":
|
||||||
|
return "Défibrillateurs";
|
||||||
|
case "DAEItem":
|
||||||
|
return "Défibrillateur";
|
||||||
|
|
||||||
case "ConnectivityError":
|
case "ConnectivityError":
|
||||||
return "Non connecté";
|
return "Non connecté";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import getRetryMaxAttempts from "./getRetryMaxAttemps";
|
||||||
|
|
||||||
import { createLogger } from "~/lib/logger";
|
import { createLogger } from "~/lib/logger";
|
||||||
import { NETWORK_SCOPES } from "~/lib/logger/scopes";
|
import { NETWORK_SCOPES } from "~/lib/logger/scopes";
|
||||||
|
import createCache from "./cache";
|
||||||
|
|
||||||
const { useNetworkState, networkActions } = store;
|
const { useNetworkState, networkActions } = store;
|
||||||
|
|
||||||
|
|
@ -28,11 +29,15 @@ const networkProvidersLogger = createLogger({
|
||||||
feature: "NetworkProviders",
|
feature: "NetworkProviders",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sharedApolloCache = createCache();
|
||||||
|
|
||||||
const initializeNewApolloClient = (reload) => {
|
const initializeNewApolloClient = (reload) => {
|
||||||
if (reload) {
|
if (reload) {
|
||||||
const { apolloClient } = network;
|
const { apolloClient } = network;
|
||||||
apolloClient.stop();
|
apolloClient.stop();
|
||||||
apolloClient.clearStore();
|
if (apolloClient.cache !== sharedApolloCache) {
|
||||||
|
apolloClient.clearStore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
network.apolloClient = createApolloClient({
|
network.apolloClient = createApolloClient({
|
||||||
|
|
@ -40,6 +45,7 @@ const initializeNewApolloClient = (reload) => {
|
||||||
GRAPHQL_URL: env.GRAPHQL_URL,
|
GRAPHQL_URL: env.GRAPHQL_URL,
|
||||||
GRAPHQL_WS_URL: env.GRAPHQL_WS_URL,
|
GRAPHQL_WS_URL: env.GRAPHQL_WS_URL,
|
||||||
getRetryMaxAttempts,
|
getRetryMaxAttempts,
|
||||||
|
cache: sharedApolloCache,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
initializeNewApolloClient();
|
initializeNewApolloClient();
|
||||||
|
|
@ -51,34 +57,62 @@ network.oaFilesKy = oaFilesKy;
|
||||||
|
|
||||||
export default function NetworkProviders({ children }) {
|
export default function NetworkProviders({ children }) {
|
||||||
const [key, setKey] = useState(0);
|
const [key, setKey] = useState(0);
|
||||||
|
const [transportClient, setTransportClient] = useState(
|
||||||
|
() => network.apolloClient,
|
||||||
|
);
|
||||||
|
|
||||||
const networkState = useNetworkState(["initialized", "triggerReload"]);
|
const networkState = useNetworkState([
|
||||||
|
"initialized",
|
||||||
|
"triggerReload",
|
||||||
|
"reloadKind",
|
||||||
|
"transportGeneration",
|
||||||
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (networkState.triggerReload) {
|
if (networkState.triggerReload) {
|
||||||
networkProvidersLogger.debug("Network triggerReload received", {
|
networkProvidersLogger.debug("Network triggerReload received", {
|
||||||
|
reloadKind: networkState.reloadKind,
|
||||||
reloadId: store.getAuthState()?.reloadId,
|
reloadId: store.getAuthState()?.reloadId,
|
||||||
hasUserToken: !!store.getAuthState()?.userToken,
|
hasUserToken: !!store.getAuthState()?.userToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isFullReload = networkState.reloadKind !== "transport";
|
||||||
initializeNewApolloClient(true);
|
initializeNewApolloClient(true);
|
||||||
setKey((prevKey) => prevKey + 1);
|
|
||||||
|
if (isFullReload) {
|
||||||
|
setTransportClient(network.apolloClient);
|
||||||
|
setKey((prevKey) => prevKey + 1);
|
||||||
|
} else {
|
||||||
|
setTransportClient(network.apolloClient);
|
||||||
|
networkProvidersLogger.debug("Network transport recovered in place", {
|
||||||
|
reloadId: store.getAuthState()?.reloadId,
|
||||||
|
hasUserToken: !!store.getAuthState()?.userToken,
|
||||||
|
transportGeneration: networkState.transportGeneration,
|
||||||
|
});
|
||||||
|
networkActions.onReload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [networkState.triggerReload]);
|
}, [
|
||||||
|
networkState.triggerReload,
|
||||||
|
networkState.reloadKind,
|
||||||
|
networkState.transportGeneration,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (key > 0) {
|
if (key > 0) {
|
||||||
networkProvidersLogger.debug("Network reloaded", {
|
networkProvidersLogger.debug("Network reloaded", {
|
||||||
|
reloadKind: networkState.reloadKind,
|
||||||
reloadId: store.getAuthState()?.reloadId,
|
reloadId: store.getAuthState()?.reloadId,
|
||||||
hasUserToken: !!store.getAuthState()?.userToken,
|
hasUserToken: !!store.getAuthState()?.userToken,
|
||||||
});
|
});
|
||||||
networkActions.onReload();
|
networkActions.onReload();
|
||||||
}
|
}
|
||||||
}, [key]);
|
}, [key, networkState.reloadKind]);
|
||||||
|
|
||||||
if (!networkState.initialized) {
|
if (!networkState.initialized) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providers = [[ApolloProvider, { client: network.apolloClient }]];
|
const providers = [[ApolloProvider, { client: transportClient }]];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposeComponents key={key} components={providers}>
|
<ComposeComponents key={key} components={providers}>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ if (__DEV__ || process.env.NODE_ENV !== "production") {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function createApolloClient(options) {
|
export default function createApolloClient(options) {
|
||||||
|
const cache = options.cache || createCache();
|
||||||
const errorLink = createErrorLink(options);
|
const errorLink = createErrorLink(options);
|
||||||
const authLink = createAuthLink(options);
|
const authLink = createAuthLink(options);
|
||||||
const cancelLink = createCancelLink();
|
const cancelLink = createCancelLink();
|
||||||
|
|
@ -50,8 +51,6 @@ export default function createApolloClient(options) {
|
||||||
httpLink: httpChain,
|
httpLink: httpChain,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cache = createCache();
|
|
||||||
|
|
||||||
const apolloClient = new ApolloClient({
|
const apolloClient = new ApolloClient({
|
||||||
cache,
|
cache,
|
||||||
// connectToDevTools: true, // Enable dev tools for better debugging
|
// connectToDevTools: true, // Enable dev tools for better debugging
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ export default function ControlButtons({
|
||||||
setZoomLevel,
|
setZoomLevel,
|
||||||
detached,
|
detached,
|
||||||
}) {
|
}) {
|
||||||
// const styles = useStyles();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View
|
<View
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@ export default function prioritizeFeatures(features) {
|
||||||
return features
|
return features
|
||||||
.filter(({ properties }) => !properties.isUserLocation)
|
.filter(({ properties }) => !properties.isUserLocation)
|
||||||
.sort(({ properties: x }, { properties: y }) => {
|
.sort(({ properties: x }, { properties: y }) => {
|
||||||
|
// DAE features should win (easy to tap)
|
||||||
|
if (x.isDefib && !y.isDefib) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (!x.isDefib && y.isDefib) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
// if both cluster priority is given to higher level
|
// if both cluster priority is given to higher level
|
||||||
if (x.cluster && y.cluster) {
|
if (x.cluster && y.cluster) {
|
||||||
return x.x_max_level_num < y.x_max_level_num ? 1 : -1;
|
return x.x_max_level_num < y.x_max_level_num ? 1 : -1;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import Supercluster from "supercluster";
|
||||||
import useShallowMemo from "~/hooks/useShallowMemo";
|
import useShallowMemo from "~/hooks/useShallowMemo";
|
||||||
import useShallowEffect from "~/hooks/useShallowEffect";
|
import useShallowEffect from "~/hooks/useShallowEffect";
|
||||||
import { deepEqual } from "fast-equals";
|
import { deepEqual } from "fast-equals";
|
||||||
|
import { useDefibsState } from "~/stores";
|
||||||
|
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
||||||
|
|
||||||
export default function useFeatures({
|
export default function useFeatures({
|
||||||
clusterFeature,
|
clusterFeature,
|
||||||
|
|
@ -13,6 +15,11 @@ export default function useFeatures({
|
||||||
route,
|
route,
|
||||||
alertCoords,
|
alertCoords,
|
||||||
}) {
|
}) {
|
||||||
|
const { showDefibsOnAlertMap, corridorDefibs } = useDefibsState([
|
||||||
|
"showDefibsOnAlertMap",
|
||||||
|
"corridorDefibs",
|
||||||
|
]);
|
||||||
|
|
||||||
// Check if we have valid coordinates
|
// Check if we have valid coordinates
|
||||||
const hasUserCoords =
|
const hasUserCoords =
|
||||||
userCoords && userCoords.longitude !== null && userCoords.latitude !== null;
|
userCoords && userCoords.longitude !== null && userCoords.latitude !== null;
|
||||||
|
|
@ -95,15 +102,58 @@ export default function useFeatures({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add defibs (DAE) as separate, non-clustered features
|
||||||
|
if (showDefibsOnAlertMap && Array.isArray(corridorDefibs)) {
|
||||||
|
corridorDefibs.forEach((defib) => {
|
||||||
|
const lon = defib.longitude;
|
||||||
|
const lat = defib.latitude;
|
||||||
|
if (
|
||||||
|
lon === null ||
|
||||||
|
lat === null ||
|
||||||
|
lon === undefined ||
|
||||||
|
lat === undefined
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { status } = getDefibAvailability(
|
||||||
|
defib.horaires_std,
|
||||||
|
defib.disponible_24h,
|
||||||
|
);
|
||||||
|
// Only show available defibs on the alert navigation map
|
||||||
|
if (status !== "open") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = `defib:${defib.id}`;
|
||||||
|
|
||||||
|
features.push({
|
||||||
|
type: "Feature",
|
||||||
|
id,
|
||||||
|
properties: {
|
||||||
|
id,
|
||||||
|
defib,
|
||||||
|
isDefib: true,
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [lon, lat],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features,
|
features,
|
||||||
};
|
};
|
||||||
}, [list]);
|
}, [list, showDefibsOnAlertMap, corridorDefibs]);
|
||||||
|
|
||||||
const superCluster = useShallowMemo(() => {
|
const superCluster = useShallowMemo(() => {
|
||||||
const cluster = new Supercluster({ radius: 40, maxZoom: 16 });
|
const cluster = new Supercluster({ radius: 40, maxZoom: 16 });
|
||||||
cluster.load(featureCollection.features);
|
// Do not cluster defibs in v1
|
||||||
|
const clusterable = featureCollection.features.filter(
|
||||||
|
(f) => !f?.properties?.isDefib,
|
||||||
|
);
|
||||||
|
cluster.load(clusterable);
|
||||||
return cluster;
|
return cluster;
|
||||||
}, [featureCollection.features]);
|
}, [featureCollection.features]);
|
||||||
// console.log({ superCluster: JSON.stringify(superCluster) });
|
// console.log({ superCluster: JSON.stringify(superCluster) });
|
||||||
|
|
@ -123,6 +173,15 @@ export default function useFeatures({
|
||||||
const userCoordinates = [userCoords.longitude, userCoords.latitude];
|
const userCoordinates = [userCoords.longitude, userCoords.latitude];
|
||||||
const features = [...clusterFeature];
|
const features = [...clusterFeature];
|
||||||
|
|
||||||
|
// Ensure defibs are always present even if they are not part of the clustered set
|
||||||
|
if (showDefibsOnAlertMap && Array.isArray(featureCollection.features)) {
|
||||||
|
featureCollection.features.forEach((f) => {
|
||||||
|
if (f?.properties?.isDefib) {
|
||||||
|
features.push(f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Only add route line if we have valid route data
|
// Only add route line if we have valid route data
|
||||||
const isRouteEnding = route?.distance !== 0 || routeCoords?.length === 0;
|
const isRouteEnding = route?.distance !== 0 || routeCoords?.length === 0;
|
||||||
const hasValidAlertCoords =
|
const hasValidAlertCoords =
|
||||||
|
|
@ -157,6 +216,8 @@ export default function useFeatures({
|
||||||
}, [
|
}, [
|
||||||
setShape,
|
setShape,
|
||||||
clusterFeature,
|
clusterFeature,
|
||||||
|
featureCollection.features,
|
||||||
|
showDefibsOnAlertMap,
|
||||||
userCoords,
|
userCoords,
|
||||||
hasUserCoords,
|
hasUserCoords,
|
||||||
routeCoords,
|
routeCoords,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useNavigation } from "@react-navigation/native";
|
||||||
|
|
||||||
import { ANIMATION_DURATION } from "~/containers/Map/constants";
|
import { ANIMATION_DURATION } from "~/containers/Map/constants";
|
||||||
|
|
||||||
import { alertActions } from "~/stores";
|
import { alertActions, defibsActions } from "~/stores";
|
||||||
import { createLogger } from "~/lib/logger";
|
import { createLogger } from "~/lib/logger";
|
||||||
import { FEATURE_SCOPES, UI_SCOPES } from "~/lib/logger/scopes";
|
import { FEATURE_SCOPES, UI_SCOPES } from "~/lib/logger/scopes";
|
||||||
|
|
||||||
|
|
@ -29,6 +29,12 @@ export default function useOnPress({
|
||||||
const [feature] = features;
|
const [feature] = features;
|
||||||
const { properties } = feature;
|
const { properties } = feature;
|
||||||
|
|
||||||
|
if (properties?.isDefib && properties?.defib) {
|
||||||
|
defibsActions.setSelectedDefib(properties.defib);
|
||||||
|
navigation.navigate("DAEItem");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (properties.cluster) {
|
if (properties.cluster) {
|
||||||
// center and expand to cluster's points
|
// center and expand to cluster's points
|
||||||
const { current: camera } = cameraRef;
|
const { current: camera } = cameraRef;
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,18 @@ import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { deepEqual } from "fast-equals";
|
import { deepEqual } from "fast-equals";
|
||||||
|
|
||||||
import withConnectivity from "~/hoc/withConnectivity";
|
import withConnectivity from "~/hoc/withConnectivity";
|
||||||
|
import { useToast } from "~/lib/toast-notifications";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useAlertState,
|
useAlertState,
|
||||||
useSessionState,
|
useSessionState,
|
||||||
alertActions,
|
alertActions,
|
||||||
useAggregatedMessagesState,
|
useAggregatedMessagesState,
|
||||||
|
useDefibsState,
|
||||||
|
defibsActions,
|
||||||
} from "~/stores";
|
} from "~/stores";
|
||||||
import { getCurrentLocation } from "~/location";
|
import { getCurrentLocation } from "~/location";
|
||||||
|
import { getStoredLocation } from "~/location/storage";
|
||||||
|
|
||||||
import alertBigButtonBgMap from "~/assets/img/alert-big-button-bg-map.png";
|
import alertBigButtonBgMap from "~/assets/img/alert-big-button-bg-map.png";
|
||||||
import alertBigButtonBgMapGrey from "~/assets/img/alert-big-button-bg-map-grey.png";
|
import alertBigButtonBgMapGrey from "~/assets/img/alert-big-button-bg-map-grey.png";
|
||||||
|
|
@ -79,6 +83,107 @@ export default withConnectivity(
|
||||||
const isSent = userId === sessionUserId;
|
const isSent = userId === sessionUserId;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const { showDefibsOnAlertMap: defibsEnabled } = useDefibsState([
|
||||||
|
"showDefibsOnAlertMap",
|
||||||
|
]);
|
||||||
|
const [loadingDaeCorridor, setLoadingDaeCorridor] = useState(false);
|
||||||
|
|
||||||
|
const toggleDefibsOnAlertMap = useCallback(async () => {
|
||||||
|
if (defibsEnabled) {
|
||||||
|
defibsActions.setShowDefibsOnAlertMap(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingDaeCorridor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingDaeCorridor(true);
|
||||||
|
try {
|
||||||
|
const alertLonLat = alert?.location?.coordinates;
|
||||||
|
const hasAlertLonLat =
|
||||||
|
Array.isArray(alertLonLat) &&
|
||||||
|
alertLonLat.length === 2 &&
|
||||||
|
alertLonLat[0] !== null &&
|
||||||
|
alertLonLat[1] !== null;
|
||||||
|
|
||||||
|
if (!hasAlertLonLat) {
|
||||||
|
toast.show("Position de l'alerte indisponible", {
|
||||||
|
placement: "top",
|
||||||
|
duration: 4000,
|
||||||
|
hideOnPress: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Current coords if possible
|
||||||
|
let coords = await getCurrentLocation();
|
||||||
|
|
||||||
|
// 2) Fallback to last-known coords if needed
|
||||||
|
const hasCoords =
|
||||||
|
coords && coords.latitude !== null && coords.longitude !== null;
|
||||||
|
if (!hasCoords) {
|
||||||
|
const lastKnown = await getStoredLocation();
|
||||||
|
coords = lastKnown?.coords || coords;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFinalCoords =
|
||||||
|
coords && coords.latitude !== null && coords.longitude !== null;
|
||||||
|
if (!hasFinalCoords) {
|
||||||
|
toast.show(
|
||||||
|
"Localisation indisponible : activez la géolocalisation pour afficher les défibrillateurs.",
|
||||||
|
{
|
||||||
|
placement: "top",
|
||||||
|
duration: 6000,
|
||||||
|
hideOnPress: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLonLat = [coords.longitude, coords.latitude];
|
||||||
|
|
||||||
|
const { error } = await defibsActions.loadCorridor({
|
||||||
|
userLonLat,
|
||||||
|
alertLonLat,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
defibsActions.setShowDefibsOnAlertMap(false);
|
||||||
|
toast.show(
|
||||||
|
"Impossible de charger les défibrillateurs (base hors-ligne indisponible).",
|
||||||
|
{
|
||||||
|
placement: "top",
|
||||||
|
duration: 6000,
|
||||||
|
hideOnPress: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
defibsActions.setShowDefibsOnAlertMap(true);
|
||||||
|
|
||||||
|
navigation.navigate("Main", {
|
||||||
|
screen: "AlertCur",
|
||||||
|
params: {
|
||||||
|
screen: "AlertCurTab",
|
||||||
|
params: {
|
||||||
|
screen: "AlertCurMap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
defibsActions.setShowDefibsOnAlertMap(false);
|
||||||
|
toast.show("Erreur lors du chargement des défibrillateurs", {
|
||||||
|
placement: "top",
|
||||||
|
duration: 6000,
|
||||||
|
hideOnPress: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingDaeCorridor(false);
|
||||||
|
}
|
||||||
|
}, [alert, defibsEnabled, loadingDaeCorridor, navigation, toast]);
|
||||||
|
|
||||||
const [notifyAroundMutation] = useMutation(NOTIFY_AROUND_MUTATION);
|
const [notifyAroundMutation] = useMutation(NOTIFY_AROUND_MUTATION);
|
||||||
const notifyAround = useCallback(async () => {
|
const notifyAround = useCallback(async () => {
|
||||||
|
|
@ -398,6 +503,45 @@ export default withConnectivity(
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isOpen && alert.location?.coordinates && (
|
||||||
|
<View
|
||||||
|
key="show-defibs"
|
||||||
|
style={[styles.actionContainer, styles.actionShowDefibs]}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
disabled={loadingDaeCorridor}
|
||||||
|
icon={() => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={
|
||||||
|
loadingDaeCorridor
|
||||||
|
? "loading"
|
||||||
|
: defibsEnabled
|
||||||
|
? "heart-off"
|
||||||
|
: "heart-pulse"
|
||||||
|
}
|
||||||
|
style={[styles.actionIcon, styles.actionShowDefibsIcon]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
style={[
|
||||||
|
styles.actionButton,
|
||||||
|
defibsEnabled
|
||||||
|
? styles.actionShowDefibsButtonActive
|
||||||
|
: styles.actionShowDefibsButton,
|
||||||
|
]}
|
||||||
|
onPress={toggleDefibsOnAlertMap}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.actionText, styles.actionShowDefibsText]}
|
||||||
|
>
|
||||||
|
{defibsEnabled
|
||||||
|
? "Ne plus afficher les défibrillateurs"
|
||||||
|
: "Afficher les défibrillateurs"}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isSent && alert.location?.coordinates && (
|
{!isSent && alert.location?.coordinates && (
|
||||||
<MapLinksButton coordinates={alert.location.coordinates} />
|
<MapLinksButton coordinates={alert.location.coordinates} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,14 @@ export default createStyles(({ wp, hp, scaleText, theme: { colors } }) => ({
|
||||||
actionComingHelpButton: {},
|
actionComingHelpButton: {},
|
||||||
actionComingHelpText: {},
|
actionComingHelpText: {},
|
||||||
actionComingHelpIcon: {},
|
actionComingHelpIcon: {},
|
||||||
|
actionShowDefibsButton: {
|
||||||
|
backgroundColor: colors.blue,
|
||||||
|
},
|
||||||
|
actionShowDefibsButtonActive: {
|
||||||
|
backgroundColor: colors.grey,
|
||||||
|
},
|
||||||
|
actionShowDefibsText: {},
|
||||||
|
actionShowDefibsIcon: {},
|
||||||
actionSmsButton: {},
|
actionSmsButton: {},
|
||||||
actionSmsText: {},
|
actionSmsText: {},
|
||||||
actionSmsIcon: {},
|
actionSmsIcon: {},
|
||||||
|
|
|
||||||
459
src/scenes/DAEItem/Carte.js
Normal file
459
src/scenes/DAEItem/Carte.js
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import Maplibre from "@maplibre/maplibre-react-native";
|
||||||
|
import polyline from "@mapbox/polyline";
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import Drawer from "react-native-drawer";
|
||||||
|
|
||||||
|
import MapView from "~/containers/Map/MapView";
|
||||||
|
import Camera from "~/containers/Map/Camera";
|
||||||
|
import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker";
|
||||||
|
import { DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants";
|
||||||
|
import StepZoomButtonGroup from "~/containers/Map/StepZoomButtonGroup";
|
||||||
|
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import IconTouchTarget from "~/components/IconTouchTarget";
|
||||||
|
import { useTheme } from "~/theme";
|
||||||
|
import { useDefibsState, useNetworkState } from "~/stores";
|
||||||
|
import useLocation from "~/hooks/useLocation";
|
||||||
|
import {
|
||||||
|
osmProfileUrl,
|
||||||
|
profileDefaultModes,
|
||||||
|
} from "~/scenes/AlertCurMap/routing";
|
||||||
|
import { routeToInstructions } from "~/lib/geo/osrmTextInstructions";
|
||||||
|
import {
|
||||||
|
announceForA11yIfScreenReaderEnabled,
|
||||||
|
setA11yFocusAfterInteractions,
|
||||||
|
} from "~/lib/a11y";
|
||||||
|
|
||||||
|
import markerDae from "~/assets/img/marker-dae.png";
|
||||||
|
import RoutingSteps from "~/scenes/AlertCurMap/RoutingSteps";
|
||||||
|
import MapHeadRouting from "~/scenes/AlertCurMap/MapHeadRouting";
|
||||||
|
|
||||||
|
import {
|
||||||
|
STATE_CALCULATING_INIT,
|
||||||
|
STATE_CALCULATING_LOADED,
|
||||||
|
STATE_CALCULATING_LOADING,
|
||||||
|
} from "~/scenes/AlertCurMap/constants";
|
||||||
|
|
||||||
|
|
||||||
|
export default React.memo(function DAEItemCarte() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
|
||||||
|
const { hasInternetConnection } = useNetworkState(["hasInternetConnection"]);
|
||||||
|
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
|
||||||
|
|
||||||
|
const mapRef = useRef();
|
||||||
|
const cameraRef = useRef();
|
||||||
|
const [cameraKey, setCameraKey] = useState(1);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM_LEVEL);
|
||||||
|
const abortControllerRef = useRef(null);
|
||||||
|
|
||||||
|
const refreshCamera = useCallback(() => {
|
||||||
|
setCameraKey(`${Date.now()}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasUserCoords =
|
||||||
|
coords && coords.latitude !== null && coords.longitude !== null;
|
||||||
|
const hasDefibCoords = defib && defib.latitude && defib.longitude;
|
||||||
|
|
||||||
|
const [routeCoords, setRouteCoords] = useState(null);
|
||||||
|
const [routeError, setRouteError] = useState(null);
|
||||||
|
const [loadingRoute, setLoadingRoute] = useState(false);
|
||||||
|
const [route, setRoute] = useState(null);
|
||||||
|
const [calculating, setCalculating] = useState(STATE_CALCULATING_INIT);
|
||||||
|
|
||||||
|
const defaultProfile = "foot";
|
||||||
|
const [profile, setProfile] = useState(defaultProfile);
|
||||||
|
|
||||||
|
// Compute route
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasUserCoords || !hasDefibCoords || !hasInternetConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort any previous request
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
|
const fetchRoute = async () => {
|
||||||
|
setLoadingRoute(true);
|
||||||
|
setCalculating(STATE_CALCULATING_LOADING);
|
||||||
|
setRouteError(null);
|
||||||
|
try {
|
||||||
|
const origin = `${coords.longitude},${coords.latitude}`;
|
||||||
|
const target = `${defib.longitude},${defib.latitude}`;
|
||||||
|
const osrmUrl = osmProfileUrl[profile] || osmProfileUrl.foot;
|
||||||
|
const url = `${osrmUrl}/route/v1/${profile}/${origin};${target}?overview=full&steps=true`;
|
||||||
|
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (result.routes && result.routes.length > 0) {
|
||||||
|
const fetchedRoute = result.routes[0];
|
||||||
|
const decoded = polyline
|
||||||
|
.decode(fetchedRoute.geometry)
|
||||||
|
.map((p) => p.reverse());
|
||||||
|
setRouteCoords(decoded);
|
||||||
|
setRoute(fetchedRoute);
|
||||||
|
setCalculating(STATE_CALCULATING_LOADED);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== "AbortError") {
|
||||||
|
console.warn("Route calculation failed:", err.message);
|
||||||
|
setRouteError(err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingRoute(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRoute();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
hasUserCoords,
|
||||||
|
hasDefibCoords,
|
||||||
|
hasInternetConnection,
|
||||||
|
coords,
|
||||||
|
defib,
|
||||||
|
profile,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Compute instructions from route steps
|
||||||
|
const allSteps = useMemo(() => {
|
||||||
|
if (!route) return [];
|
||||||
|
return route.legs.flatMap((leg) => leg.steps);
|
||||||
|
}, [route]);
|
||||||
|
|
||||||
|
const instructions = useMemo(() => {
|
||||||
|
if (allSteps.length === 0) return [];
|
||||||
|
return routeToInstructions(allSteps);
|
||||||
|
}, [allSteps]);
|
||||||
|
|
||||||
|
const distance = useMemo(
|
||||||
|
() => allSteps.reduce((acc, step) => acc + (step?.distance || 0), 0),
|
||||||
|
[allSteps],
|
||||||
|
);
|
||||||
|
const duration = useMemo(
|
||||||
|
() => allSteps.reduce((acc, step) => acc + (step?.duration || 0), 0),
|
||||||
|
[allSteps],
|
||||||
|
);
|
||||||
|
|
||||||
|
const destinationName = useMemo(() => {
|
||||||
|
if (!route) return defib?.nom || "";
|
||||||
|
const { legs } = route;
|
||||||
|
const lastLeg = legs[legs.length - 1];
|
||||||
|
if (!lastLeg) return defib?.nom || "";
|
||||||
|
const { steps } = lastLeg;
|
||||||
|
const lastStep = steps[steps.length - 1];
|
||||||
|
return lastStep?.name || defib?.nom || "";
|
||||||
|
}, [route, defib]);
|
||||||
|
|
||||||
|
// Stepper drawer state
|
||||||
|
const [stepperIsOpened, setStepperIsOpened] = useState(false);
|
||||||
|
const routingSheetTitleA11yRef = useRef(null);
|
||||||
|
const a11yStepsEntryRef = useRef(null);
|
||||||
|
const mapHeadOpenRef = useRef(null);
|
||||||
|
const mapHeadSeeAllRef = useRef(null);
|
||||||
|
const lastStepsTriggerRef = useRef(null);
|
||||||
|
|
||||||
|
const openStepper = useCallback((triggerRef) => {
|
||||||
|
if (triggerRef) {
|
||||||
|
lastStepsTriggerRef.current = triggerRef;
|
||||||
|
}
|
||||||
|
setStepperIsOpened(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeStepper = useCallback(() => {
|
||||||
|
setStepperIsOpened(false);
|
||||||
|
setA11yFocusAfterInteractions(lastStepsTriggerRef);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stepperOnOpen = useCallback(() => {
|
||||||
|
if (!stepperIsOpened) {
|
||||||
|
setStepperIsOpened(true);
|
||||||
|
}
|
||||||
|
setA11yFocusAfterInteractions(routingSheetTitleA11yRef);
|
||||||
|
announceForA11yIfScreenReaderEnabled("Liste des étapes ouverte");
|
||||||
|
}, [stepperIsOpened]);
|
||||||
|
|
||||||
|
const stepperOnClose = useCallback(() => {
|
||||||
|
if (stepperIsOpened) {
|
||||||
|
setStepperIsOpened(false);
|
||||||
|
}
|
||||||
|
announceForA11yIfScreenReaderEnabled("Liste des étapes fermée");
|
||||||
|
setA11yFocusAfterInteractions(lastStepsTriggerRef);
|
||||||
|
}, [stepperIsOpened]);
|
||||||
|
|
||||||
|
// Defib marker GeoJSON
|
||||||
|
const defibGeoJSON = useMemo(() => {
|
||||||
|
if (!hasDefibCoords) return null;
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [defib.longitude, defib.latitude],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: defib.id,
|
||||||
|
nom: defib.nom || "Défibrillateur",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [defib, hasDefibCoords]);
|
||||||
|
|
||||||
|
// Route line GeoJSON
|
||||||
|
const routeGeoJSON = useMemo(() => {
|
||||||
|
if (!routeCoords || routeCoords.length < 2) return null;
|
||||||
|
return {
|
||||||
|
type: "Feature",
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: routeCoords,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [routeCoords]);
|
||||||
|
|
||||||
|
// Camera bounds to show both user + defib
|
||||||
|
const bounds = useMemo(() => {
|
||||||
|
if (!hasUserCoords || !hasDefibCoords) return null;
|
||||||
|
const lats = [coords.latitude, defib.latitude];
|
||||||
|
const lons = [coords.longitude, defib.longitude];
|
||||||
|
return {
|
||||||
|
ne: [Math.max(...lons), Math.max(...lats)],
|
||||||
|
sw: [Math.min(...lons), Math.min(...lats)],
|
||||||
|
};
|
||||||
|
}, [hasUserCoords, hasDefibCoords, coords, defib]);
|
||||||
|
|
||||||
|
const profileDefaultMode = profileDefaultModes[profile];
|
||||||
|
|
||||||
|
if (!defib) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Offline banner */}
|
||||||
|
{!hasInternetConnection && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.offlineBanner,
|
||||||
|
{ backgroundColor: (colors.error || "#F44336") + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="wifi-off"
|
||||||
|
size={18}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.offlineBannerText,
|
||||||
|
{ color: colors.error || "#F44336" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Hors ligne — l'itinéraire n'est pas disponible
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
type="overlay"
|
||||||
|
tweenHandler={(ratio) => ({
|
||||||
|
main: { opacity: (2 - ratio) / 2 },
|
||||||
|
})}
|
||||||
|
tweenDuration={250}
|
||||||
|
openDrawerOffset={40}
|
||||||
|
open={stepperIsOpened}
|
||||||
|
onOpen={stepperOnOpen}
|
||||||
|
onClose={stepperOnClose}
|
||||||
|
tapToClose
|
||||||
|
negotiatePan
|
||||||
|
content={
|
||||||
|
<RoutingSteps
|
||||||
|
setProfile={setProfile}
|
||||||
|
profile={profile}
|
||||||
|
closeStepper={closeStepper}
|
||||||
|
destinationName={destinationName}
|
||||||
|
distance={distance}
|
||||||
|
duration={duration}
|
||||||
|
instructions={instructions}
|
||||||
|
calculatingState={calculating}
|
||||||
|
titleA11yRef={routingSheetTitleA11yRef}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* A11y entry point for routing steps */}
|
||||||
|
<IconTouchTarget
|
||||||
|
ref={a11yStepsEntryRef}
|
||||||
|
accessibilityLabel="Ouvrir la liste des étapes de l'itinéraire"
|
||||||
|
accessibilityHint="Affiche la destination, la distance, la durée et toutes les étapes sans utiliser la carte."
|
||||||
|
onPress={() => openStepper(a11yStepsEntryRef)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
left: 4,
|
||||||
|
zIndex: 10,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 8,
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="format-list-bulleted"
|
||||||
|
size={24}
|
||||||
|
color={colors.onSurface}
|
||||||
|
/>
|
||||||
|
</IconTouchTarget>
|
||||||
|
|
||||||
|
<MapView
|
||||||
|
mapRef={mapRef}
|
||||||
|
compassViewPosition={1}
|
||||||
|
compassViewMargin={{ x: 10, y: 10 }}
|
||||||
|
>
|
||||||
|
<Camera
|
||||||
|
cameraKey={cameraKey}
|
||||||
|
setCameraKey={setCameraKey}
|
||||||
|
refreshCamera={refreshCamera}
|
||||||
|
cameraRef={cameraRef}
|
||||||
|
followUserLocation={!bounds}
|
||||||
|
followUserMode={
|
||||||
|
bounds
|
||||||
|
? Maplibre.UserTrackingMode.None
|
||||||
|
: Maplibre.UserTrackingMode.Follow
|
||||||
|
}
|
||||||
|
followPitch={0}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
bounds={bounds}
|
||||||
|
detached={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Route line */}
|
||||||
|
{routeGeoJSON && (
|
||||||
|
<Maplibre.ShapeSource id="routeSource" shape={routeGeoJSON}>
|
||||||
|
<Maplibre.LineLayer
|
||||||
|
id="routeLineLayer"
|
||||||
|
style={{
|
||||||
|
lineColor: "rgba(49, 76, 205, 0.84)",
|
||||||
|
lineWidth: 4,
|
||||||
|
lineCap: "round",
|
||||||
|
lineJoin: "round",
|
||||||
|
lineOpacity: 0.84,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Maplibre.ShapeSource>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Maplibre.Images images={{ dae: markerDae }} />
|
||||||
|
|
||||||
|
{/* Defib marker */}
|
||||||
|
{defibGeoJSON && (
|
||||||
|
<Maplibre.ShapeSource id="defibItemSource" shape={defibGeoJSON}>
|
||||||
|
<Maplibre.SymbolLayer
|
||||||
|
id="defibItemSymbol"
|
||||||
|
style={{
|
||||||
|
iconImage: "dae",
|
||||||
|
iconSize: 0.5,
|
||||||
|
iconAllowOverlap: true,
|
||||||
|
textField: ["get", "nom"],
|
||||||
|
textSize: 12,
|
||||||
|
textOffset: [0, 1.8],
|
||||||
|
textAnchor: "top",
|
||||||
|
textMaxWidth: 14,
|
||||||
|
textColor: colors.onSurface,
|
||||||
|
textHaloColor: colors.surface,
|
||||||
|
textHaloWidth: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Maplibre.ShapeSource>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User location */}
|
||||||
|
{isLastKnown && hasUserCoords ? (
|
||||||
|
<LastKnownLocationMarker
|
||||||
|
coordinates={coords}
|
||||||
|
timestamp={lastKnownTimestamp}
|
||||||
|
id="lastKnownLocation_daeItem"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Maplibre.UserLocation visible showsUserHeadingIndicator />
|
||||||
|
)}
|
||||||
|
</MapView>
|
||||||
|
|
||||||
|
{/* Head routing step overlay */}
|
||||||
|
{instructions.length > 0 && (
|
||||||
|
<MapHeadRouting
|
||||||
|
instructions={instructions}
|
||||||
|
distance={distance}
|
||||||
|
profileDefaultMode={profileDefaultMode}
|
||||||
|
openStepper={openStepper}
|
||||||
|
openStepperTriggerRef={mapHeadOpenRef}
|
||||||
|
seeAllStepsTriggerRef={mapHeadSeeAllRef}
|
||||||
|
calculatingState={calculating}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} />
|
||||||
|
|
||||||
|
{/* Route error */}
|
||||||
|
{routeError && !loadingRoute && (
|
||||||
|
<View style={styles.routeErrorOverlay}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.routeErrorText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Impossible de calculer l'itinéraire
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
offlineBanner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
offlineBannerText: {
|
||||||
|
fontSize: 13,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
routeErrorOverlay: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
routeErrorText: {
|
||||||
|
fontSize: 13,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
533
src/scenes/DAEItem/Infos.js
Normal file
533
src/scenes/DAEItem/Infos.js
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { Button, Modal, Portal } from "react-native-paper";
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { getApps, showLocation } from "react-native-map-link";
|
||||||
|
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import { useTheme } from "~/theme";
|
||||||
|
import { useDefibsState } from "~/stores";
|
||||||
|
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
open: "#4CAF50",
|
||||||
|
closed: "#F44336",
|
||||||
|
unknown: "#9E9E9E",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_ICONS = {
|
||||||
|
open: "check-circle",
|
||||||
|
closed: "close-circle",
|
||||||
|
unknown: "help-circle",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_LABELS = {
|
||||||
|
1: "Lundi",
|
||||||
|
2: "Mardi",
|
||||||
|
3: "Mercredi",
|
||||||
|
4: "Jeudi",
|
||||||
|
5: "Vendredi",
|
||||||
|
6: "Samedi",
|
||||||
|
7: "Dimanche",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDistance(meters) {
|
||||||
|
if (meters == null) return "Distance inconnue";
|
||||||
|
if (meters < 1000) return `${Math.round(meters)} m`;
|
||||||
|
return `${(meters / 1000).toFixed(1)} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ icon, label, value, valueStyle }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.infoRow,
|
||||||
|
{ borderBottomColor: colors.outlineVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={icon}
|
||||||
|
size={20}
|
||||||
|
color={colors.primary}
|
||||||
|
style={styles.infoIcon}
|
||||||
|
/>
|
||||||
|
<View style={styles.infoContent}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.infoLabel,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.infoValue, valueStyle]}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleSection({ defib }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const h = defib.horaires_std;
|
||||||
|
|
||||||
|
// If we have structured schedule info, render it
|
||||||
|
if (h && typeof h === "object") {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (h.is24h) {
|
||||||
|
parts.push(
|
||||||
|
<Text key="24h" style={styles.scheduleItem}>
|
||||||
|
Ouvert 24h/24
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h.businessHours) {
|
||||||
|
parts.push(
|
||||||
|
<Text key="bh" style={styles.scheduleItem}>
|
||||||
|
Heures ouvrables (Lun-Ven 08h-18h)
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h.nightHours) {
|
||||||
|
parts.push(
|
||||||
|
<Text key="nh" style={styles.scheduleItem}>
|
||||||
|
Heures de nuit (20h-08h)
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h.events) {
|
||||||
|
parts.push(
|
||||||
|
<Text key="ev" style={styles.scheduleItem}>
|
||||||
|
Selon événements
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(h.days) && h.days.length > 0) {
|
||||||
|
const dayStr = h.days
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((d) => DAY_LABELS[d] || `Jour ${d}`)
|
||||||
|
.join(", ");
|
||||||
|
parts.push(
|
||||||
|
<Text key="days" style={styles.scheduleItem}>
|
||||||
|
Jours : {dayStr}
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(h.slots) && h.slots.length > 0) {
|
||||||
|
const slotsStr = h.slots
|
||||||
|
.map((s) => `${s.open || "?"} – ${s.close || "?"}`)
|
||||||
|
.join(", ");
|
||||||
|
parts.push(
|
||||||
|
<Text key="slots" style={styles.scheduleItem}>
|
||||||
|
Créneaux : {slotsStr}
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h.notes) {
|
||||||
|
parts.push(
|
||||||
|
<Text
|
||||||
|
key="notes"
|
||||||
|
style={[
|
||||||
|
styles.scheduleItem,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{h.notes}
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.scheduleContainer}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="clock-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.primary}
|
||||||
|
style={styles.infoIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.sectionTitle}>Horaires détaillés</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.scheduleParts}>{parts}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to raw horaires string
|
||||||
|
if (defib.horaires) {
|
||||||
|
return (
|
||||||
|
<View style={styles.scheduleContainer}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="clock-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.primary}
|
||||||
|
style={styles.infoIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.sectionTitle}>Horaires</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.scheduleRaw,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{defib.horaires}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function DAEItemInfos() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
|
||||||
|
const [navModalVisible, setNavModalVisible] = useState(false);
|
||||||
|
const [availableApps, setAvailableApps] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const result = await getApps({ alwaysIncludeGoogle: true });
|
||||||
|
setAvailableApps(result);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { status, label: availabilityLabel } = getDefibAvailability(
|
||||||
|
defib?.horaires_std,
|
||||||
|
defib?.disponible_24h,
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusColor = STATUS_COLORS[status];
|
||||||
|
|
||||||
|
const openNavModal = useCallback(() => {
|
||||||
|
setNavModalVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeNavModal = useCallback(() => {
|
||||||
|
setNavModalVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToCarte = useCallback(() => {
|
||||||
|
closeNavModal();
|
||||||
|
navigation.navigate("DAEItemCarte");
|
||||||
|
}, [navigation, closeNavModal]);
|
||||||
|
|
||||||
|
const openExternalApp = useCallback(
|
||||||
|
(app) => {
|
||||||
|
closeNavModal();
|
||||||
|
if (defib?.latitude && defib?.longitude) {
|
||||||
|
showLocation({
|
||||||
|
latitude: defib.latitude,
|
||||||
|
longitude: defib.longitude,
|
||||||
|
app: app.id,
|
||||||
|
naverCallerName:
|
||||||
|
Platform.OS === "ios"
|
||||||
|
? "com.alertesecours.alertesecours"
|
||||||
|
: "com.alertesecours",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[defib, closeNavModal],
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalStyles = useMemo(
|
||||||
|
() => ({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
marginHorizontal: 24,
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
textAlign: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.onSurfaceVariant || colors.grey,
|
||||||
|
textAlign: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginLeft: 16,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
backgroundColor: colors.outlineVariant || colors.grey,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
marginTop: 8,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!defib) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={[styles.container, { backgroundColor: colors.background }]}
|
||||||
|
contentContainerStyle={styles.contentContainer}
|
||||||
|
>
|
||||||
|
{/* Header with availability */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.availabilityCard,
|
||||||
|
{ backgroundColor: statusColor + "12" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={STATUS_ICONS[status]}
|
||||||
|
size={36}
|
||||||
|
color={statusColor}
|
||||||
|
/>
|
||||||
|
<View style={styles.availabilityInfo}>
|
||||||
|
<Text style={[styles.availabilityStatus, { color: statusColor }]}>
|
||||||
|
{status === "open"
|
||||||
|
? "Disponible"
|
||||||
|
: status === "closed"
|
||||||
|
? "Indisponible"
|
||||||
|
: "Disponibilité inconnue"}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.availabilityLabel,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{availabilityLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Basic info */}
|
||||||
|
<InfoRow icon="heart-pulse" label="Nom" value={defib.nom} />
|
||||||
|
<InfoRow icon="map-marker" label="Adresse" value={defib.adresse} />
|
||||||
|
<InfoRow icon="door-open" label="Accès" value={defib.acces} />
|
||||||
|
<InfoRow
|
||||||
|
icon="map-marker-distance"
|
||||||
|
label="Distance"
|
||||||
|
value={formatDistance(defib.distanceMeters)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Schedule section */}
|
||||||
|
<ScheduleSection defib={defib} />
|
||||||
|
|
||||||
|
{/* Itinéraire button */}
|
||||||
|
<View style={styles.itineraireContainer}>
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
onPress={openNavModal}
|
||||||
|
icon={({ size, color }) => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="navigation-variant"
|
||||||
|
size={size}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
style={styles.itineraireButton}
|
||||||
|
contentStyle={styles.itineraireButtonContent}
|
||||||
|
>
|
||||||
|
Itinéraire
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Back to list */}
|
||||||
|
<View style={styles.backContainer}>
|
||||||
|
<Button
|
||||||
|
mode="outlined"
|
||||||
|
onPress={() => navigation.navigate("DAEList")}
|
||||||
|
icon="arrow-left"
|
||||||
|
style={styles.backButton}
|
||||||
|
>
|
||||||
|
Retour à la liste
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Navigation app chooser modal */}
|
||||||
|
<Portal>
|
||||||
|
<Modal
|
||||||
|
visible={navModalVisible}
|
||||||
|
onDismiss={closeNavModal}
|
||||||
|
contentContainerStyle={modalStyles.container}
|
||||||
|
>
|
||||||
|
<Text style={modalStyles.title}>Itinéraire</Text>
|
||||||
|
<Text style={modalStyles.subtitle}>
|
||||||
|
Quelle application souhaitez-vous utiliser ?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* In-app navigation option */}
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={goToCarte}
|
||||||
|
style={modalStyles.option}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="navigation-variant"
|
||||||
|
size={24}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
|
<Text style={modalStyles.optionText}>
|
||||||
|
Naviguer dans l'application
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* External navigation apps */}
|
||||||
|
{availableApps.map((app) => (
|
||||||
|
<React.Fragment key={app.id}>
|
||||||
|
<View style={modalStyles.separator} />
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => openExternalApp(app)}
|
||||||
|
style={modalStyles.option}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="open-in-new"
|
||||||
|
size={24}
|
||||||
|
color={colors.onSurface}
|
||||||
|
/>
|
||||||
|
<Text style={modalStyles.optionText}>{app.name}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
mode="text"
|
||||||
|
onPress={closeNavModal}
|
||||||
|
style={modalStyles.cancelButton}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
|
</Portal>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 32,
|
||||||
|
},
|
||||||
|
availabilityCard: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
availabilityInfo: {
|
||||||
|
marginLeft: 12,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
availabilityStatus: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
availabilityLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
infoIcon: {
|
||||||
|
marginRight: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
infoContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
scheduleContainer: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
scheduleParts: {
|
||||||
|
paddingLeft: 32,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
scheduleItem: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
scheduleRaw: {
|
||||||
|
paddingLeft: 32,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
itineraireContainer: {
|
||||||
|
marginTop: 24,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
itineraireButton: {
|
||||||
|
minWidth: 200,
|
||||||
|
borderRadius: 24,
|
||||||
|
},
|
||||||
|
itineraireButtonContent: {
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
backContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
minWidth: 200,
|
||||||
|
},
|
||||||
|
});
|
||||||
130
src/scenes/DAEItem/index.js
Normal file
130
src/scenes/DAEItem/index.js
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import React from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { Button } from "react-native-paper";
|
||||||
|
|
||||||
|
import { fontFamily, useTheme } from "~/theme";
|
||||||
|
import { useDefibsState } from "~/stores";
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
|
||||||
|
import DAEItemInfos from "./Infos";
|
||||||
|
import DAEItemCarte from "./Carte";
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="heart-off"
|
||||||
|
size={56}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
style={styles.emptyIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>Aucun défibrillateur sélectionné</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Sélectionnez un défibrillateur depuis la liste pour voir ses détails.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
onPress={() => navigation.navigate("DAEList")}
|
||||||
|
style={styles.backButton}
|
||||||
|
icon="arrow-left"
|
||||||
|
>
|
||||||
|
Retour à la liste
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function DAEItem() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const { selectedDefib } = useDefibsState(["selectedDefib"]);
|
||||||
|
|
||||||
|
if (!selectedDefib) {
|
||||||
|
return <EmptyState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: colors.primary,
|
||||||
|
tabBarInactiveTintColor: colors.onSurfaceVariant || colors.grey,
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontFamily,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderTopColor: colors.outlineVariant || colors.grey,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="DAEItemInfos"
|
||||||
|
component={DAEItemInfos}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: "Infos",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="information-outline"
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="DAEItemCarte"
|
||||||
|
component={DAEItemCarte}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: "Carte",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="map-marker-outline"
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
emptyIcon: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
253
src/scenes/DAEList/Carte.js
Normal file
253
src/scenes/DAEList/Carte.js
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import Maplibre from "@maplibre/maplibre-react-native";
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
|
||||||
|
import MapView from "~/containers/Map/MapView";
|
||||||
|
import Camera from "~/containers/Map/Camera";
|
||||||
|
import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker";
|
||||||
|
import { BoundType, DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants";
|
||||||
|
import StepZoomButtonGroup from "~/containers/Map/StepZoomButtonGroup";
|
||||||
|
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import Loader from "~/components/Loader";
|
||||||
|
import { useTheme } from "~/theme";
|
||||||
|
import { defibsActions } from "~/stores";
|
||||||
|
|
||||||
|
import markerDae from "~/assets/img/marker-dae.png";
|
||||||
|
|
||||||
|
import useNearbyDefibs from "./useNearbyDefibs";
|
||||||
|
|
||||||
|
function defibsToGeoJSON(defibs) {
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: defibs.map((d) => {
|
||||||
|
return {
|
||||||
|
type: "Feature",
|
||||||
|
id: d.id,
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [d.longitude, d.latitude],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: d.id,
|
||||||
|
nom: d.nom || "Défibrillateur",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingView({ message }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Loader containerProps={{ style: styles.loaderInner }} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.loadingText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyNoLocation() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="crosshairs-off"
|
||||||
|
size={56}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
style={styles.emptyIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>Localisation indisponible</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Activez la géolocalisation pour afficher les défibrillateurs sur la
|
||||||
|
carte.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function DAEListCarte() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const {
|
||||||
|
defibs,
|
||||||
|
loading,
|
||||||
|
noLocation,
|
||||||
|
hasLocation,
|
||||||
|
isLastKnown,
|
||||||
|
lastKnownTimestamp,
|
||||||
|
coords,
|
||||||
|
} = useNearbyDefibs();
|
||||||
|
|
||||||
|
const mapRef = useRef();
|
||||||
|
const cameraRef = useRef();
|
||||||
|
const [cameraKey, setCameraKey] = useState(1);
|
||||||
|
|
||||||
|
const refreshCamera = useCallback(() => {
|
||||||
|
setCameraKey(`${Date.now()}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasCoords =
|
||||||
|
coords && coords.latitude !== null && coords.longitude !== null;
|
||||||
|
|
||||||
|
// Camera state — simple follow user
|
||||||
|
const [followUserLocation] = useState(true);
|
||||||
|
const [followUserMode] = useState(Maplibre.UserTrackingMode.Follow);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM_LEVEL);
|
||||||
|
|
||||||
|
const geoJSON = useMemo(() => defibsToGeoJSON(defibs), [defibs]);
|
||||||
|
|
||||||
|
const onMarkerPress = useCallback(
|
||||||
|
(e) => {
|
||||||
|
const feature = e?.features?.[0];
|
||||||
|
if (!feature) return;
|
||||||
|
|
||||||
|
const defibId = feature.properties?.id;
|
||||||
|
const defib = defibs.find((d) => d.id === defibId);
|
||||||
|
if (defib) {
|
||||||
|
defibsActions.setSelectedDefib(defib);
|
||||||
|
navigation.navigate("DAEItem");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[defibs, navigation],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (noLocation && !hasLocation) {
|
||||||
|
return <EmptyNoLocation />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waiting for location
|
||||||
|
if (!hasLocation && defibs.length === 0 && !hasCoords) {
|
||||||
|
return <LoadingView message="Recherche de votre position…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading defibs from database
|
||||||
|
if (loading && defibs.length === 0 && !hasCoords) {
|
||||||
|
return (
|
||||||
|
<LoadingView message="Chargement des défibrillateurs à proximité…" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<MapView
|
||||||
|
mapRef={mapRef}
|
||||||
|
compassViewPosition={1}
|
||||||
|
compassViewMargin={{ x: 10, y: 10 }}
|
||||||
|
>
|
||||||
|
<Camera
|
||||||
|
cameraKey={cameraKey}
|
||||||
|
setCameraKey={setCameraKey}
|
||||||
|
refreshCamera={refreshCamera}
|
||||||
|
cameraRef={cameraRef}
|
||||||
|
followUserLocation={followUserLocation}
|
||||||
|
followUserMode={followUserMode}
|
||||||
|
followPitch={0}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
bounds={null}
|
||||||
|
detached={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Maplibre.Images images={{ dae: markerDae }} />
|
||||||
|
|
||||||
|
{geoJSON.features.length > 0 && (
|
||||||
|
<Maplibre.ShapeSource
|
||||||
|
id="defibSource"
|
||||||
|
shape={geoJSON}
|
||||||
|
onPress={onMarkerPress}
|
||||||
|
>
|
||||||
|
<Maplibre.SymbolLayer
|
||||||
|
id="defibSymbolLayer"
|
||||||
|
style={{
|
||||||
|
iconImage: "dae",
|
||||||
|
iconSize: 0.5,
|
||||||
|
iconAllowOverlap: true,
|
||||||
|
textField: ["get", "nom"],
|
||||||
|
textSize: 11,
|
||||||
|
textOffset: [0, 1.5],
|
||||||
|
textAnchor: "top",
|
||||||
|
textMaxWidth: 12,
|
||||||
|
textColor: colors.onSurface,
|
||||||
|
textHaloColor: colors.surface,
|
||||||
|
textHaloWidth: 1,
|
||||||
|
textOptional: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Maplibre.ShapeSource>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLastKnown && hasCoords ? (
|
||||||
|
<LastKnownLocationMarker
|
||||||
|
coordinates={coords}
|
||||||
|
timestamp={lastKnownTimestamp}
|
||||||
|
id="lastKnownLocation_daeList"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Maplibre.UserLocation visible showsUserHeadingIndicator />
|
||||||
|
)}
|
||||||
|
</MapView>
|
||||||
|
<StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
loaderInner: {
|
||||||
|
flex: 0,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 12,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
emptyIcon: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
309
src/scenes/DAEList/DaeUpdateBanner.js
Normal file
309
src/scenes/DAEList/DaeUpdateBanner.js
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import React, { useEffect, useCallback } from "react";
|
||||||
|
import { View, StyleSheet, TouchableOpacity } from "react-native";
|
||||||
|
import { ProgressBar, ActivityIndicator } from "react-native-paper";
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import { useTheme } from "~/theme";
|
||||||
|
import { defibsActions, useDefibsState } from "~/stores";
|
||||||
|
|
||||||
|
function formatDate(isoString) {
|
||||||
|
if (!isoString) return null;
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleDateString("fr-FR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function DaeUpdateBanner() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const {
|
||||||
|
daeUpdateState,
|
||||||
|
daeUpdateProgress,
|
||||||
|
daeUpdateError,
|
||||||
|
daeLastUpdatedAt,
|
||||||
|
} = useDefibsState([
|
||||||
|
"daeUpdateState",
|
||||||
|
"daeUpdateProgress",
|
||||||
|
"daeUpdateError",
|
||||||
|
"daeLastUpdatedAt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Load persisted last-update date on mount
|
||||||
|
useEffect(() => {
|
||||||
|
defibsActions.loadLastDaeUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(() => {
|
||||||
|
defibsActions.triggerDaeUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismissError = useCallback(() => {
|
||||||
|
defibsActions.dismissDaeUpdateError();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
daeUpdateState === "checking" ||
|
||||||
|
daeUpdateState === "downloading" ||
|
||||||
|
daeUpdateState === "installing";
|
||||||
|
|
||||||
|
// Done state
|
||||||
|
if (daeUpdateState === "done") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{ backgroundColor: (colors.primary || "#4CAF50") + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="check-circle-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.primary || "#4CAF50"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.statusText, { color: colors.primary || "#4CAF50" }]}
|
||||||
|
>
|
||||||
|
{"Base de donn\u00e9es mise \u00e0 jour !"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already up-to-date
|
||||||
|
if (daeUpdateState === "up-to-date") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
(colors.onSurfaceVariant || colors.grey || "#666") + "10",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="check-circle-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{"Donn\u00e9es d\u00e9j\u00e0 \u00e0 jour"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (daeUpdateState === "error") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{ backgroundColor: (colors.error || "#F44336") + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="alert-circle-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.errorText, { color: colors.error || "#F44336" }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{daeUpdateError || "Erreur lors de la mise \u00e0 jour"}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleUpdate}
|
||||||
|
style={styles.retryTouch}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="refresh"
|
||||||
|
size={20}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleDismissError}
|
||||||
|
style={styles.dismissTouch}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="close"
|
||||||
|
size={18}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloading state
|
||||||
|
if (daeUpdateState === "downloading") {
|
||||||
|
const pct = Math.round(daeUpdateProgress * 100);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
styles.progressBanner,
|
||||||
|
{ backgroundColor: (colors.primary || "#2196F3") + "10" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.progressHeader}>
|
||||||
|
<ActivityIndicator size={14} color={colors.primary} />
|
||||||
|
<Text
|
||||||
|
style={[styles.statusText, { color: colors.primary || "#2196F3" }]}
|
||||||
|
>
|
||||||
|
{`T\u00e9l\u00e9chargement\u2026 ${pct}%`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ProgressBar
|
||||||
|
progress={daeUpdateProgress}
|
||||||
|
color={colors.primary}
|
||||||
|
style={styles.progressBar}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checking / Installing state
|
||||||
|
if (isActive) {
|
||||||
|
const label =
|
||||||
|
daeUpdateState === "checking"
|
||||||
|
? "V\u00e9rification\u2026"
|
||||||
|
: "Installation\u2026";
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{ backgroundColor: (colors.primary || "#2196F3") + "10" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size={14} color={colors.primary} />
|
||||||
|
<Text
|
||||||
|
style={[styles.statusText, { color: colors.primary || "#2196F3" }]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle state
|
||||||
|
const formattedDate = formatDate(daeLastUpdatedAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
(colors.onSurfaceVariant || colors.grey || "#666") + "08",
|
||||||
|
borderBottomColor: colors.outlineVariant || colors.grey,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="database-sync-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
/>
|
||||||
|
<View style={styles.idleTextContainer}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dateText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formattedDate
|
||||||
|
? `Derni\u00e8re mise \u00e0 jour : ${formattedDate}`
|
||||||
|
: "Donn\u00e9es int\u00e9gr\u00e9es \u00e0 l'application"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleUpdate}
|
||||||
|
style={[
|
||||||
|
styles.updateButton,
|
||||||
|
{ backgroundColor: colors.primary || "#2196F3" },
|
||||||
|
]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="download" size={14} color="#fff" />
|
||||||
|
<Text style={styles.updateButtonText}>{"Mettre \u00e0 jour"}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
banner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
progressBanner: {
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "stretch",
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
progressHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "500",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 12,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
retryTouch: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
dismissTouch: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
idleTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
updateButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
updateButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
166
src/scenes/DAEList/DefibRow.js
Normal file
166
src/scenes/DAEList/DefibRow.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import { TouchableRipple } from "react-native-paper";
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import { useTheme } from "~/theme";
|
||||||
|
import { defibsActions } from "~/stores";
|
||||||
|
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
||||||
|
|
||||||
|
function formatDistance(meters) {
|
||||||
|
if (meters == null) return "";
|
||||||
|
if (meters < 1000) return `${Math.round(meters)} m`;
|
||||||
|
return `${(meters / 1000).toFixed(1)} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
open: "#4CAF50",
|
||||||
|
closed: "#F44336",
|
||||||
|
unknown: "#9E9E9E",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_ICONS = {
|
||||||
|
open: "check-circle",
|
||||||
|
closed: "close-circle",
|
||||||
|
unknown: "help-circle",
|
||||||
|
};
|
||||||
|
|
||||||
|
function DefibRow({ defib }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const { status, label } = getDefibAvailability(
|
||||||
|
defib.horaires_std,
|
||||||
|
defib.disponible_24h,
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusColor = STATUS_COLORS[status];
|
||||||
|
|
||||||
|
const onPress = useCallback(() => {
|
||||||
|
defibsActions.setSelectedDefib(defib);
|
||||||
|
navigation.navigate("DAEItem");
|
||||||
|
}, [defib, navigation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableRipple
|
||||||
|
onPress={onPress}
|
||||||
|
style={[
|
||||||
|
styles.row,
|
||||||
|
{ borderBottomColor: colors.outlineVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${defib.nom || "Défibrillateur"}, ${formatDistance(
|
||||||
|
defib.distanceMeters,
|
||||||
|
)}, ${label}`}
|
||||||
|
accessibilityHint="Ouvrir le détail de ce défibrillateur"
|
||||||
|
>
|
||||||
|
<View style={styles.rowInner}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={STATUS_ICONS[status]}
|
||||||
|
size={28}
|
||||||
|
color={statusColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{defib.nom || "Défibrillateur"}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.address,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{defib.adresse || "Adresse non renseignée"}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.meta}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
{ backgroundColor: statusColor + "20" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.statusText, { color: statusColor }]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.distanceContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="map-marker-distance"
|
||||||
|
size={16}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.distance,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formatDistance(defib.distanceMeters)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableRipple>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(DefibRow);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
rowInner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
marginRight: 12,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
distanceContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minWidth: 50,
|
||||||
|
},
|
||||||
|
distance: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
338
src/scenes/DAEList/Liste.js
Normal file
338
src/scenes/DAEList/Liste.js
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { View, FlatList, StyleSheet } from "react-native";
|
||||||
|
import { Button, Switch } from "react-native-paper";
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import Loader from "~/components/Loader";
|
||||||
|
import { useTheme } from "~/theme";
|
||||||
|
import { defibsActions } from "~/stores";
|
||||||
|
|
||||||
|
import useNearbyDefibs from "./useNearbyDefibs";
|
||||||
|
import DefibRow from "./DefibRow";
|
||||||
|
|
||||||
|
function LoadingView({ message }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Loader containerProps={{ style: styles.loaderInner }} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.loadingText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyNoLocation() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="crosshairs-off"
|
||||||
|
size={56}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
style={styles.emptyIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>Localisation indisponible</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Activez la géolocalisation pour trouver les défibrillateurs à proximité.
|
||||||
|
Vérifiez les paramètres de localisation de votre appareil.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyError({ error, onRetry }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="alert-circle-outline"
|
||||||
|
size={56}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
style={styles.emptyIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>Erreur de chargement</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Impossible de charger les défibrillateurs.{"\n"}
|
||||||
|
Veuillez réessayer ultérieurement.
|
||||||
|
</Text>
|
||||||
|
{onRetry && (
|
||||||
|
<Button mode="contained" onPress={onRetry} style={styles.retryButton}>
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyNoResults() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="heart-pulse"
|
||||||
|
size={56}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
style={styles.emptyIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>Aucun défibrillateur</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Aucun défibrillateur trouvé dans un rayon de 10 km autour de votre
|
||||||
|
position.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyExtractor = (item) => item.id;
|
||||||
|
|
||||||
|
function EmptyNoAvailable({ showUnavailable }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="heart-pulse"
|
||||||
|
size={56}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
style={styles.emptyIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>Aucun défibrillateur disponible</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Aucun défibrillateur actuellement ouvert dans un rayon de 10 km. Activez
|
||||||
|
l'option « Afficher les indisponibles » pour voir tous les
|
||||||
|
défibrillateurs.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvailabilityToggle({ showUnavailable, allCount, filteredCount }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const onToggle = useCallback(() => {
|
||||||
|
defibsActions.setShowUnavailable(!showUnavailable);
|
||||||
|
}, [showUnavailable]);
|
||||||
|
|
||||||
|
const countLabel =
|
||||||
|
!showUnavailable && allCount > filteredCount
|
||||||
|
? ` (${allCount - filteredCount} masqués)`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.toggleRow,
|
||||||
|
{ borderBottomColor: colors.outlineVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.toggleLabelContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="eye-off-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.toggleLabel,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Afficher les indisponibles{countLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch value={showUnavailable} onValueChange={onToggle} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function DAEListListe() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const {
|
||||||
|
defibs,
|
||||||
|
allDefibs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
noLocation,
|
||||||
|
hasLocation,
|
||||||
|
reload,
|
||||||
|
showUnavailable,
|
||||||
|
} = useNearbyDefibs();
|
||||||
|
|
||||||
|
const renderItem = useCallback(({ item }) => <DefibRow defib={item} />, []);
|
||||||
|
|
||||||
|
// No location available
|
||||||
|
if (noLocation && !hasLocation) {
|
||||||
|
return <EmptyNoLocation />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waiting for location
|
||||||
|
if (!hasLocation && allDefibs.length === 0) {
|
||||||
|
return <LoadingView message="Recherche de votre position…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading defibs from database
|
||||||
|
if (loading && allDefibs.length === 0) {
|
||||||
|
return (
|
||||||
|
<LoadingView message="Chargement des défibrillateurs à proximité…" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state (non-blocking if we have stale data)
|
||||||
|
if (error && allDefibs.length === 0) {
|
||||||
|
return <EmptyError error={error} onRetry={reload} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No results at all
|
||||||
|
if (!loading && allDefibs.length === 0 && hasLocation) {
|
||||||
|
return <EmptyNoResults />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has defibs but none available (filtered to empty)
|
||||||
|
const showEmptyAvailable =
|
||||||
|
!loading && defibs.length === 0 && allDefibs.length > 0 && !showUnavailable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
|
{error && allDefibs.length > 0 && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.errorBanner,
|
||||||
|
{ backgroundColor: (colors.error || "#F44336") + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="alert-circle-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.errorBannerText,
|
||||||
|
{ color: colors.error || "#F44336" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Erreur de mise à jour — données potentiellement obsolètes
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<AvailabilityToggle
|
||||||
|
showUnavailable={showUnavailable}
|
||||||
|
allCount={allDefibs.length}
|
||||||
|
filteredCount={defibs.length}
|
||||||
|
/>
|
||||||
|
{showEmptyAvailable ? (
|
||||||
|
<EmptyNoAvailable />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={defibs}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
initialNumToRender={15}
|
||||||
|
maxToRenderPerBatch={10}
|
||||||
|
windowSize={5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
loaderInner: {
|
||||||
|
flex: 0,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 12,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
emptyIcon: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
errorBanner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
errorBannerText: {
|
||||||
|
fontSize: 12,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
toggleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
toggleLabelContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
toggleLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
});
|
||||||
77
src/scenes/DAEList/index.js
Normal file
77
src/scenes/DAEList/index.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import { fontFamily, useTheme } from "~/theme";
|
||||||
|
|
||||||
|
import DAEListListe from "./Liste";
|
||||||
|
import DAEListCarte from "./Carte";
|
||||||
|
import DaeUpdateBanner from "./DaeUpdateBanner";
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
export default React.memo(function DAEList() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<DaeUpdateBanner />
|
||||||
|
<View style={styles.tabContainer}>
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: colors.primary,
|
||||||
|
tabBarInactiveTintColor: colors.onSurfaceVariant || colors.grey,
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontFamily,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderTopColor: colors.outlineVariant || colors.grey,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="DAEListListe"
|
||||||
|
component={DAEListListe}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: "Liste",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="format-list-bulleted"
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="DAEListCarte"
|
||||||
|
component={DAEListCarte}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: "Carte",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="map-marker-outline"
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
tabContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
102
src/scenes/DAEList/useNearbyDefibs.js
Normal file
102
src/scenes/DAEList/useNearbyDefibs.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { useEffect, useRef, useCallback, useMemo, useState } from "react";
|
||||||
|
import useLocation from "~/hooks/useLocation";
|
||||||
|
import { defibsActions, useDefibsState } from "~/stores";
|
||||||
|
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
||||||
|
|
||||||
|
const RADIUS_METERS = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared hook: loads defibs near user and exposes location + loading state.
|
||||||
|
* The results live in the zustand store so both Liste and Carte tabs share them.
|
||||||
|
* By default, only available (open) defibs are returned; toggle showUnavailable to see all.
|
||||||
|
*/
|
||||||
|
export default function useNearbyDefibs() {
|
||||||
|
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
|
||||||
|
const {
|
||||||
|
nearUserDefibs,
|
||||||
|
loadingNearUser,
|
||||||
|
errorNearUser,
|
||||||
|
showUnavailable,
|
||||||
|
daeUpdateState,
|
||||||
|
} = useDefibsState([
|
||||||
|
"nearUserDefibs",
|
||||||
|
"loadingNearUser",
|
||||||
|
"errorNearUser",
|
||||||
|
"showUnavailable",
|
||||||
|
"daeUpdateState",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasLocation =
|
||||||
|
coords && coords.latitude !== null && coords.longitude !== null;
|
||||||
|
|
||||||
|
// Track whether we've already triggered a load for these coords
|
||||||
|
const lastLoadedRef = useRef(null);
|
||||||
|
const [noLocation, setNoLocation] = useState(false);
|
||||||
|
|
||||||
|
const loadDefibs = useCallback(async () => {
|
||||||
|
if (!hasLocation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${coords.latitude.toFixed(4)},${coords.longitude.toFixed(4)}`;
|
||||||
|
if (lastLoadedRef.current === key) {
|
||||||
|
return; // skip duplicate loads for same position
|
||||||
|
}
|
||||||
|
lastLoadedRef.current = key;
|
||||||
|
await defibsActions.loadNearUser({
|
||||||
|
userLonLat: [coords.longitude, coords.latitude],
|
||||||
|
radiusMeters: RADIUS_METERS,
|
||||||
|
});
|
||||||
|
}, [hasLocation, coords]);
|
||||||
|
|
||||||
|
// After a successful DB update, reset the position cache so the next
|
||||||
|
// render re-queries the fresh database.
|
||||||
|
const prevUpdateState = useRef(daeUpdateState);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevUpdateState.current !== "done" && daeUpdateState === "done") {
|
||||||
|
lastLoadedRef.current = null;
|
||||||
|
}
|
||||||
|
prevUpdateState.current = daeUpdateState;
|
||||||
|
}, [daeUpdateState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasLocation) {
|
||||||
|
setNoLocation(false);
|
||||||
|
loadDefibs();
|
||||||
|
}
|
||||||
|
}, [hasLocation, loadDefibs]);
|
||||||
|
|
||||||
|
// After a timeout, if we still have no location, set the flag
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasLocation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!hasLocation) {
|
||||||
|
setNoLocation(true);
|
||||||
|
}
|
||||||
|
}, 8000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [hasLocation]);
|
||||||
|
|
||||||
|
const filteredDefibs = useMemo(() => {
|
||||||
|
if (showUnavailable) return nearUserDefibs;
|
||||||
|
return nearUserDefibs.filter((d) => {
|
||||||
|
const { status } = getDefibAvailability(d.horaires_std, d.disponible_24h);
|
||||||
|
return status === "open";
|
||||||
|
});
|
||||||
|
}, [nearUserDefibs, showUnavailable]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
defibs: filteredDefibs,
|
||||||
|
allDefibs: nearUserDefibs,
|
||||||
|
loading: loadingNearUser,
|
||||||
|
error: errorNearUser,
|
||||||
|
hasLocation,
|
||||||
|
noLocation,
|
||||||
|
isLastKnown,
|
||||||
|
lastKnownTimestamp,
|
||||||
|
coords,
|
||||||
|
reload: loadDefibs,
|
||||||
|
showUnavailable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,8 @@ import {
|
||||||
} from "react-native-gesture-handler";
|
} from "react-native-gesture-handler";
|
||||||
import { createStyles, useTheme } from "~/theme";
|
import { createStyles, useTheme } from "~/theme";
|
||||||
import { useMutation } from "@apollo/client";
|
import { useMutation } from "@apollo/client";
|
||||||
import { format, fr } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { DELETE_NOTIFICATION, MARK_NOTIFICATION_AS_READ } from "./gql";
|
import { DELETE_NOTIFICATION, MARK_NOTIFICATION_AS_READ } from "./gql";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,19 @@ import uuidGenerator from "react-native-uuid";
|
||||||
import { phoneCallEmergency } from "~/lib/phone-call";
|
import { phoneCallEmergency } from "~/lib/phone-call";
|
||||||
|
|
||||||
import network from "~/network";
|
import network from "~/network";
|
||||||
import { getSessionState, alertActions, useParamsState } from "~/stores";
|
import {
|
||||||
|
getSessionState,
|
||||||
|
alertActions,
|
||||||
|
defibsActions,
|
||||||
|
useParamsState,
|
||||||
|
} from "~/stores";
|
||||||
import { getCurrentLocation } from "~/location";
|
import { getCurrentLocation } from "~/location";
|
||||||
|
|
||||||
import useSendAlertSMSToEmergency from "~/hooks/useSendAlertSMSToEmergency";
|
import useSendAlertSMSToEmergency from "~/hooks/useSendAlertSMSToEmergency";
|
||||||
|
|
||||||
|
import alertsList from "~/misc/alertsList";
|
||||||
|
import subjectSuggestsDefib from "~/utils/dae/subjectSuggestsDefib";
|
||||||
|
|
||||||
import { SEND_ALERT_MUTATION } from "./gql";
|
import { SEND_ALERT_MUTATION } from "./gql";
|
||||||
|
|
||||||
export default function useOnSubmit() {
|
export default function useOnSubmit() {
|
||||||
|
|
@ -77,6 +85,13 @@ async function onSubmit(args, context) {
|
||||||
speed,
|
speed,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DAE suggest modal — must run before network call so it works offline.
|
||||||
|
// Show on red alerts unconditionally, or when cardiac keywords are detected.
|
||||||
|
const matchingAlert = alertsList.find((a) => a.title === subject);
|
||||||
|
if (level === "red" || subjectSuggestsDefib(subject, matchingAlert?.desc)) {
|
||||||
|
defibsActions.setShowDaeSuggestModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
const { userId, deviceId } = getSessionState();
|
const { userId, deviceId } = getSessionState();
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
|
|
||||||
|
|
@ -125,6 +140,7 @@ async function onSubmit(args, context) {
|
||||||
});
|
});
|
||||||
|
|
||||||
alertActions.setNavAlertCur({ alert });
|
alertActions.setNavAlertCur({ alert });
|
||||||
|
|
||||||
navigation.navigate("Main", {
|
navigation.navigate("Main", {
|
||||||
screen: "AlertCur",
|
screen: "AlertCur",
|
||||||
params: {
|
params: {
|
||||||
|
|
|
||||||
|
|
@ -81,4 +81,5 @@ export const STORAGE_KEYS = {
|
||||||
EULA_ACCEPTED_SIMPLE: registerAsyncStorageKey("eula_accepted"),
|
EULA_ACCEPTED_SIMPLE: registerAsyncStorageKey("eula_accepted"),
|
||||||
EMULATOR_MODE_ENABLED: registerAsyncStorageKey("emulator_mode_enabled"),
|
EMULATOR_MODE_ENABLED: registerAsyncStorageKey("emulator_mode_enabled"),
|
||||||
SENTRY_ENABLED: registerAsyncStorageKey("@sentry_enabled"),
|
SENTRY_ENABLED: registerAsyncStorageKey("@sentry_enabled"),
|
||||||
|
DAE_DB_UPDATED_AT: registerAsyncStorageKey("@dae_db_updated_at"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
223
src/stores/defibs.js
Normal file
223
src/stores/defibs.js
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { createAtom } from "~/lib/atomic-zustand";
|
||||||
|
|
||||||
|
import getNearbyDefibs from "~/data/getNearbyDefibs";
|
||||||
|
import {
|
||||||
|
computeCorridorQueryRadiusMeters,
|
||||||
|
filterDefibsInCorridor,
|
||||||
|
} from "~/utils/geo/corridor";
|
||||||
|
import { updateDaeDb } from "~/db/updateDaeDb";
|
||||||
|
import memoryAsyncStorage from "~/storage/memoryAsyncStorage";
|
||||||
|
import { STORAGE_KEYS } from "~/storage/storageKeys";
|
||||||
|
|
||||||
|
const DEFAULT_NEAR_USER_RADIUS_M = 10_000;
|
||||||
|
const DEFAULT_CORRIDOR_M = 10_000;
|
||||||
|
const DEFAULT_LIMIT = 200;
|
||||||
|
|
||||||
|
const AUTO_DISMISS_DELAY = 4_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a technical DAE update error into a user-friendly French message.
|
||||||
|
* The raw technical details are already logged via console.warn in updateDaeDb.
|
||||||
|
*/
|
||||||
|
function userFriendlyDaeError(error) {
|
||||||
|
const msg = error?.message || "";
|
||||||
|
if (msg.includes("Network") || msg.includes("network")) {
|
||||||
|
return "Impossible de contacter le serveur. Vérifiez votre connexion internet et réessayez.";
|
||||||
|
}
|
||||||
|
if (msg.includes("HTTP")) {
|
||||||
|
return "Le serveur a rencontré un problème. Veuillez réessayer ultérieurement.";
|
||||||
|
}
|
||||||
|
if (msg.includes("Download failed") || msg.includes("file is empty")) {
|
||||||
|
return "Le téléchargement a échoué. Veuillez réessayer.";
|
||||||
|
}
|
||||||
|
if (msg.includes("failed validation")) {
|
||||||
|
return "Le fichier téléchargé est corrompu. Veuillez réessayer.";
|
||||||
|
}
|
||||||
|
return "La mise à jour a échoué. Veuillez réessayer ultérieurement.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createAtom(({ merge, reset }) => {
|
||||||
|
const actions = {
|
||||||
|
reset,
|
||||||
|
|
||||||
|
setShowDefibsOnAlertMap: (showDefibsOnAlertMap) => {
|
||||||
|
merge({ showDefibsOnAlertMap });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedDefib: (selectedDefib) => {
|
||||||
|
merge({ selectedDefib });
|
||||||
|
},
|
||||||
|
|
||||||
|
setShowDaeSuggestModal: (showDaeSuggestModal) => {
|
||||||
|
merge({ showDaeSuggestModal });
|
||||||
|
},
|
||||||
|
|
||||||
|
setShowUnavailable: (showUnavailable) => {
|
||||||
|
merge({ showUnavailable });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadNearUser: async ({
|
||||||
|
userLonLat,
|
||||||
|
radiusMeters = DEFAULT_NEAR_USER_RADIUS_M,
|
||||||
|
}) => {
|
||||||
|
merge({ loadingNearUser: true, errorNearUser: null });
|
||||||
|
try {
|
||||||
|
const [lon, lat] = userLonLat;
|
||||||
|
const nearUserDefibs = await getNearbyDefibs({
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radiusMeters,
|
||||||
|
limit: DEFAULT_LIMIT,
|
||||||
|
progressive: true,
|
||||||
|
});
|
||||||
|
merge({ nearUserDefibs, loadingNearUser: false });
|
||||||
|
return { defibs: nearUserDefibs, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
merge({
|
||||||
|
nearUserDefibs: [],
|
||||||
|
loadingNearUser: false,
|
||||||
|
errorNearUser: error,
|
||||||
|
});
|
||||||
|
return { defibs: [], error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadCorridor: async ({
|
||||||
|
userLonLat,
|
||||||
|
alertLonLat,
|
||||||
|
corridorMeters = DEFAULT_CORRIDOR_M,
|
||||||
|
}) => {
|
||||||
|
merge({ loadingCorridor: true, errorCorridor: null });
|
||||||
|
try {
|
||||||
|
const radiusMeters = computeCorridorQueryRadiusMeters({
|
||||||
|
userLonLat,
|
||||||
|
alertLonLat,
|
||||||
|
corridorMeters,
|
||||||
|
});
|
||||||
|
|
||||||
|
const midLon = (userLonLat[0] + alertLonLat[0]) / 2;
|
||||||
|
const midLat = (userLonLat[1] + alertLonLat[1]) / 2;
|
||||||
|
|
||||||
|
const candidates = await getNearbyDefibs({
|
||||||
|
lat: midLat,
|
||||||
|
lon: midLon,
|
||||||
|
radiusMeters,
|
||||||
|
limit: DEFAULT_LIMIT,
|
||||||
|
progressive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const corridorDefibs = filterDefibsInCorridor({
|
||||||
|
defibs: candidates,
|
||||||
|
userLonLat,
|
||||||
|
alertLonLat,
|
||||||
|
corridorMeters,
|
||||||
|
}).slice(0, DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
merge({ corridorDefibs, loadingCorridor: false });
|
||||||
|
return { defibs: corridorDefibs, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
merge({
|
||||||
|
corridorDefibs: [],
|
||||||
|
loadingCorridor: false,
|
||||||
|
errorCorridor: error,
|
||||||
|
});
|
||||||
|
return { defibs: [], error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── DAE DB Over-the-Air Update ─────────────────────────────────────
|
||||||
|
|
||||||
|
loadLastDaeUpdate: async () => {
|
||||||
|
try {
|
||||||
|
const stored = await memoryAsyncStorage.getItem(
|
||||||
|
STORAGE_KEYS.DAE_DB_UPDATED_AT,
|
||||||
|
);
|
||||||
|
if (stored) {
|
||||||
|
merge({ daeLastUpdatedAt: stored });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
triggerDaeUpdate: async () => {
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "checking",
|
||||||
|
daeUpdateProgress: 0,
|
||||||
|
daeUpdateError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updateDaeDb({
|
||||||
|
onPhase: (phase) => {
|
||||||
|
merge({ daeUpdateState: phase });
|
||||||
|
},
|
||||||
|
onProgress: ({ totalBytesWritten, totalBytesExpectedToWrite }) => {
|
||||||
|
const progress =
|
||||||
|
totalBytesExpectedToWrite > 0
|
||||||
|
? totalBytesWritten / totalBytesExpectedToWrite
|
||||||
|
: 0;
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "downloading",
|
||||||
|
daeUpdateProgress: progress,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.alreadyUpToDate) {
|
||||||
|
merge({ daeUpdateState: "up-to-date" });
|
||||||
|
setTimeout(() => {
|
||||||
|
merge({ daeUpdateState: "idle" });
|
||||||
|
}, AUTO_DISMISS_DELAY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "error",
|
||||||
|
daeUpdateError: userFriendlyDaeError(result.error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: update stored timestamp and clear loaded defibs
|
||||||
|
// so the next query fetches from the fresh DB.
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "done",
|
||||||
|
daeLastUpdatedAt: result.updatedAt,
|
||||||
|
nearUserDefibs: [],
|
||||||
|
corridorDefibs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
merge({ daeUpdateState: "idle" });
|
||||||
|
}, AUTO_DISMISS_DELAY);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissDaeUpdateError: () => {
|
||||||
|
merge({ daeUpdateState: "idle", daeUpdateError: null });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
nearUserDefibs: [],
|
||||||
|
corridorDefibs: [],
|
||||||
|
showDefibsOnAlertMap: false,
|
||||||
|
selectedDefib: null,
|
||||||
|
showDaeSuggestModal: false,
|
||||||
|
showUnavailable: false,
|
||||||
|
|
||||||
|
loadingNearUser: false,
|
||||||
|
loadingCorridor: false,
|
||||||
|
errorNearUser: null,
|
||||||
|
errorCorridor: null,
|
||||||
|
|
||||||
|
// DAE DB update state
|
||||||
|
daeUpdateState: "idle", // "idle"|"checking"|"downloading"|"installing"|"done"|"error"|"up-to-date"
|
||||||
|
daeUpdateProgress: 0, // 0..1
|
||||||
|
daeUpdateError: null,
|
||||||
|
daeLastUpdatedAt: null,
|
||||||
|
},
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -13,6 +13,7 @@ import params from "./params";
|
||||||
import notifications from "./notifications";
|
import notifications from "./notifications";
|
||||||
import permissionWizard from "./permissionWizard";
|
import permissionWizard from "./permissionWizard";
|
||||||
import aggregatedMessages from "./aggregatedMessages";
|
import aggregatedMessages from "./aggregatedMessages";
|
||||||
|
import defibs from "./defibs";
|
||||||
|
|
||||||
const store = createStore({
|
const store = createStore({
|
||||||
tree,
|
tree,
|
||||||
|
|
@ -28,6 +29,7 @@ const store = createStore({
|
||||||
permissionWizard,
|
permissionWizard,
|
||||||
notifications,
|
notifications,
|
||||||
aggregatedMessages,
|
aggregatedMessages,
|
||||||
|
defibs,
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log("store", JSON.stringify(Object.keys(store), null, 2));
|
// console.log("store", JSON.stringify(Object.keys(store), null, 2));
|
||||||
|
|
@ -100,4 +102,9 @@ export const {
|
||||||
getAggregatedMessagesState,
|
getAggregatedMessagesState,
|
||||||
subscribeAggregatedMessagesState,
|
subscribeAggregatedMessagesState,
|
||||||
aggregatedMessagesActions,
|
aggregatedMessagesActions,
|
||||||
|
|
||||||
|
useDefibsState,
|
||||||
|
getDefibsState,
|
||||||
|
subscribeDefibsState,
|
||||||
|
defibsActions,
|
||||||
} = store;
|
} = store;
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,28 @@ export default createAtom(({ merge, get }) => {
|
||||||
wsLastHeartbeatDate: null,
|
wsLastHeartbeatDate: null,
|
||||||
wsLastRecoveryDate: null,
|
wsLastRecoveryDate: null,
|
||||||
triggerReload: false,
|
triggerReload: false,
|
||||||
|
reloadKind: null,
|
||||||
initialized: true,
|
initialized: true,
|
||||||
hasInternetConnection: true,
|
hasInternetConnection: true,
|
||||||
|
transportGeneration: 0,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
triggerReload: () => {
|
triggerReload: (reloadKind = "full") => {
|
||||||
merge({
|
merge({
|
||||||
initialized: false,
|
|
||||||
triggerReload: true,
|
triggerReload: true,
|
||||||
|
reloadKind,
|
||||||
|
initialized: reloadKind === "transport" ? true : false,
|
||||||
|
transportGeneration:
|
||||||
|
reloadKind === "transport"
|
||||||
|
? get("transportGeneration") + 1
|
||||||
|
: get("transportGeneration"),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onReload: () => {
|
onReload: () => {
|
||||||
merge({
|
merge({
|
||||||
initialized: true,
|
initialized: true,
|
||||||
triggerReload: false,
|
triggerReload: false,
|
||||||
|
reloadKind: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
WSConnected: () => {
|
WSConnected: () => {
|
||||||
|
|
|
||||||
174
src/utils/dae/getDefibAvailability.js
Normal file
174
src/utils/dae/getDefibAvailability.js
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* days: number[]|null,
|
||||||
|
* slots: {open: string, close: string}[]|null,
|
||||||
|
* is24h?: boolean,
|
||||||
|
* businessHours?: boolean,
|
||||||
|
* nightHours?: boolean,
|
||||||
|
* events?: boolean,
|
||||||
|
* notes?: string,
|
||||||
|
* }} HorairesStd
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ status: "open"|"closed"|"unknown", label: string }} DefibAvailability
|
||||||
|
*/
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesSinceMidnight(date) {
|
||||||
|
return date.getHours() * 60 + date.getMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHHMM(str) {
|
||||||
|
if (typeof str !== "string") return null;
|
||||||
|
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(str.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
return Number(m[1]) * 60 + Number(m[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO 8601 day number: 1=Mon ... 7=Sun
|
||||||
|
function isoDayNumber(date) {
|
||||||
|
const js = date.getDay(); // 0=Sun..6=Sat
|
||||||
|
return js === 0 ? 7 : js;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_LABELS = [null, "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
|
||||||
|
|
||||||
|
function daysLabel(days) {
|
||||||
|
if (!Array.isArray(days) || days.length === 0) return "";
|
||||||
|
const uniq = Array.from(new Set(days)).filter((d) => d >= 1 && d <= 7);
|
||||||
|
uniq.sort((a, b) => a - b);
|
||||||
|
if (uniq.length === 1) return DAY_LABELS[uniq[0]];
|
||||||
|
return `${DAY_LABELS[uniq[0]]}-${DAY_LABELS[uniq[uniq.length - 1]]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeFromMinutes(mins) {
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
return `${pad2(h)}:${pad2(m)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinSlot(nowMin, openMin, closeMin) {
|
||||||
|
if (openMin == null || closeMin == null) return false;
|
||||||
|
if (openMin === closeMin) return true;
|
||||||
|
// Cross-midnight slot (e.g. 20:00-08:00)
|
||||||
|
if (closeMin < openMin) {
|
||||||
|
return nowMin >= openMin || nowMin < closeMin;
|
||||||
|
}
|
||||||
|
return nowMin >= openMin && nowMin < closeMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine availability for a given defib schedule.
|
||||||
|
* Priority logic per PLAN_DAE-merged.md.
|
||||||
|
*
|
||||||
|
* @param {HorairesStd|null|undefined} horaires_std
|
||||||
|
* @param {number|boolean|null|undefined} disponible_24h
|
||||||
|
* @param {Date} [now]
|
||||||
|
* @returns {DefibAvailability}
|
||||||
|
*/
|
||||||
|
export function getDefibAvailability(
|
||||||
|
horaires_std,
|
||||||
|
disponible_24h,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
if (disponible_24h === 1 || disponible_24h === true) {
|
||||||
|
return { status: "open", label: "24h/24 7j/7" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {HorairesStd} */
|
||||||
|
const h =
|
||||||
|
horaires_std && typeof horaires_std === "object" ? horaires_std : null;
|
||||||
|
if (!h) {
|
||||||
|
return { status: "unknown", label: "Horaires non renseignés" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = isoDayNumber(now);
|
||||||
|
const nowMin = minutesSinceMidnight(now);
|
||||||
|
|
||||||
|
const days = Array.isArray(h.days) ? h.days : null;
|
||||||
|
const hasToday = Array.isArray(days) ? days.includes(today) : null;
|
||||||
|
|
||||||
|
// 2. is24h + today
|
||||||
|
if (h.is24h === true && hasToday === true) {
|
||||||
|
return { status: "open", label: "24h/24" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. days known and today not included
|
||||||
|
if (Array.isArray(days) && hasToday === false) {
|
||||||
|
const label = daysLabel(days);
|
||||||
|
return { status: "closed", label: label || "Fermé aujourd'hui" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. explicit slots for today
|
||||||
|
if (hasToday === true && Array.isArray(h.slots) && h.slots.length > 0) {
|
||||||
|
let isOpen = false;
|
||||||
|
let nextBoundaryLabel = "";
|
||||||
|
|
||||||
|
for (const slot of h.slots) {
|
||||||
|
const openMin = parseHHMM(slot.open);
|
||||||
|
const closeMin = parseHHMM(slot.close);
|
||||||
|
if (openMin == null || closeMin == null) continue;
|
||||||
|
|
||||||
|
if (isWithinSlot(nowMin, openMin, closeMin)) {
|
||||||
|
isOpen = true;
|
||||||
|
nextBoundaryLabel = `Jusqu'à ${formatTimeFromMinutes(closeMin)}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
return { status: "open", label: nextBoundaryLabel || "Ouvert" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not within any slot: show next opening time if any (same-day).
|
||||||
|
const opens = h.slots
|
||||||
|
.map((s) => parseHHMM(s.open))
|
||||||
|
.filter((m) => typeof m === "number")
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const nextOpen = opens.find((m) => m > nowMin);
|
||||||
|
if (typeof nextOpen === "number") {
|
||||||
|
return {
|
||||||
|
status: "closed",
|
||||||
|
label: `Ouvre à ${formatTimeFromMinutes(nextOpen)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "closed", label: "Fermé" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. business hours approximation (Mon-Fri 08:00-18:00)
|
||||||
|
if (h.businessHours === true) {
|
||||||
|
const isWeekday = today >= 1 && today <= 5;
|
||||||
|
const openMin = 8 * 60;
|
||||||
|
const closeMin = 18 * 60;
|
||||||
|
const isOpen = isWeekday && nowMin >= openMin && nowMin < closeMin;
|
||||||
|
return {
|
||||||
|
status: isOpen ? "open" : "closed",
|
||||||
|
label: isOpen ? "Heures ouvrables" : "Fermé (heures ouvrables)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. night hours approximation (20:00-08:00)
|
||||||
|
if (h.nightHours === true) {
|
||||||
|
const openMin = 20 * 60;
|
||||||
|
const closeMin = 8 * 60;
|
||||||
|
const isOpen = isWithinSlot(nowMin, openMin, closeMin);
|
||||||
|
return {
|
||||||
|
status: isOpen ? "open" : "closed",
|
||||||
|
label: isOpen ? "Heures de nuit" : "Fermé (heures de nuit)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. events
|
||||||
|
if (h.events === true) {
|
||||||
|
return { status: "unknown", label: "Selon événements" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. fallback
|
||||||
|
const notes = typeof h.notes === "string" ? h.notes.trim() : "";
|
||||||
|
return { status: "unknown", label: notes || "Horaires non renseignés" };
|
||||||
|
}
|
||||||
60
src/utils/dae/getDefibAvailability.test.js
Normal file
60
src/utils/dae/getDefibAvailability.test.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { getDefibAvailability } from "./getDefibAvailability";
|
||||||
|
|
||||||
|
function makeLocalDate(y, m, d, hh, mm) {
|
||||||
|
// Note: uses local time on purpose, because getDefibAvailability relies on
|
||||||
|
// Date#getDay() / Date#getHours() which are locale/timezone dependent.
|
||||||
|
return new Date(y, m - 1, d, hh, mm, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dae/getDefibAvailability", () => {
|
||||||
|
test("disponible_24h=1 always open", () => {
|
||||||
|
const res = getDefibAvailability(null, 1, makeLocalDate(2026, 3, 1, 3, 0));
|
||||||
|
expect(res).toEqual({ status: "open", label: "24h/24 7j/7" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is24h + days includes today => open", () => {
|
||||||
|
// 2026-03-02 is Monday (ISO=1)
|
||||||
|
const now = makeLocalDate(2026, 3, 2, 12, 0);
|
||||||
|
const res = getDefibAvailability(
|
||||||
|
{ days: [1], slots: null, is24h: true },
|
||||||
|
0,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("days excludes today => closed", () => {
|
||||||
|
// Monday
|
||||||
|
const now = makeLocalDate(2026, 3, 2, 12, 0);
|
||||||
|
const res = getDefibAvailability({ days: [2, 3], slots: null }, 0, now);
|
||||||
|
expect(res.status).toBe("closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("slots determine open/closed", () => {
|
||||||
|
// Monday 09:00
|
||||||
|
const now = makeLocalDate(2026, 3, 2, 9, 0);
|
||||||
|
const res = getDefibAvailability(
|
||||||
|
{
|
||||||
|
days: [1],
|
||||||
|
slots: [{ open: "08:00", close: "10:00" }],
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("events => unknown", () => {
|
||||||
|
const now = makeLocalDate(2026, 3, 2, 9, 0);
|
||||||
|
const res = getDefibAvailability(
|
||||||
|
{
|
||||||
|
days: null,
|
||||||
|
slots: null,
|
||||||
|
events: true,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(res).toEqual({ status: "unknown", label: "Selon événements" });
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/utils/dae/subjectSuggestsDefib.js
Normal file
77
src/utils/dae/subjectSuggestsDefib.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* v1 keyword detection for cardiac / defibrillator-related alert subjects.
|
||||||
|
*
|
||||||
|
* Requirements:
|
||||||
|
* - normalize: lowercase + remove diacritics
|
||||||
|
* - regex list (no fuzzy lib)
|
||||||
|
* - pure helper with unit tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
function normalizeSubjectText(subject) {
|
||||||
|
if (typeof subject !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// NFD splits accents into combining marks, then we strip them.
|
||||||
|
// Using explicit unicode range for broad RN JS compatibility.
|
||||||
|
return (
|
||||||
|
subject
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
// Common French ligatures that aren't removed by diacritics stripping
|
||||||
|
// (e.g. "cœur" => "coeur").
|
||||||
|
.replace(/œ/g, "oe")
|
||||||
|
.replace(/æ/g, "ae")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: operate on normalized (diacritics-stripped) text.
|
||||||
|
const DEFIB_SUGGESTION_REGEXES = [
|
||||||
|
// Cardiac keywords
|
||||||
|
/\bcardiaqu\w*\b/, // cardiaque, cardiaques...
|
||||||
|
/\bcardiac\w*\b/, // cardiac, cardiacs...
|
||||||
|
/\bcardiqu\w*\b/, // cardique (common typo)
|
||||||
|
/\bcoeur\b/, // coeur (after normalization also matches cœur)
|
||||||
|
|
||||||
|
// Malaise common typos
|
||||||
|
/\bmalaise\b/,
|
||||||
|
/\bmailaise\b/,
|
||||||
|
/\bmallaise\b/,
|
||||||
|
|
||||||
|
// Unconsciousness
|
||||||
|
/\binconscient\w*\b/, // inconscient, inconsciente...
|
||||||
|
/\bevanoui\w*\b/, // evanoui, evanouie, evanouissement...
|
||||||
|
|
||||||
|
// Arrest
|
||||||
|
/\barret\b/, // arret (after normalization also matches arrêt)
|
||||||
|
/\barret\s+cardiaqu\w*\b/, // arrêt cardiaque (strong signal)
|
||||||
|
|
||||||
|
// Defibrillator
|
||||||
|
/\bdefibrillat\w*\b/, // defibrillateur, defibrillation...
|
||||||
|
|
||||||
|
// CPR / resuscitation
|
||||||
|
/\breanimat\w*\b/, // reanimation, reanimer...
|
||||||
|
/\bmassage\s+cardiaqu\w*\b/, // massage cardiaque
|
||||||
|
|
||||||
|
// Not breathing
|
||||||
|
/\bne\s+respire\s+plus\b/,
|
||||||
|
/\brespire\s+plus\b/,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function subjectSuggestsDefib(...texts) {
|
||||||
|
return texts.some((input) => {
|
||||||
|
const text = normalizeSubjectText(input);
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return DEFIB_SUGGESTION_REGEXES.some((re) => re.test(text));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __private__ = {
|
||||||
|
normalizeSubjectText,
|
||||||
|
DEFIB_SUGGESTION_REGEXES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default subjectSuggestsDefib;
|
||||||
65
src/utils/dae/subjectSuggestsDefib.test.js
Normal file
65
src/utils/dae/subjectSuggestsDefib.test.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { subjectSuggestsDefib } from "./subjectSuggestsDefib";
|
||||||
|
|
||||||
|
describe("dae/subjectSuggestsDefib", () => {
|
||||||
|
test("returns false for non-string input", () => {
|
||||||
|
expect(subjectSuggestsDefib(null)).toBe(false);
|
||||||
|
expect(subjectSuggestsDefib(undefined)).toBe(false);
|
||||||
|
expect(subjectSuggestsDefib(123)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches cardiac keywords (case-insensitive)", () => {
|
||||||
|
expect(subjectSuggestsDefib("ARRET CARDIAQUE")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("cardiaque")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("cardiac arrest")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("cardique")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches diacritics variants", () => {
|
||||||
|
expect(subjectSuggestsDefib("Arrêt cardiaque")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("Cœur")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("Évanoui")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("Réanimation")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("Défibrillateur")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches common typos", () => {
|
||||||
|
expect(subjectSuggestsDefib("mallaise")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("mailaise")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches CPR / breathing phrases", () => {
|
||||||
|
expect(subjectSuggestsDefib("massage cardiaque en cours")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("ne respire plus")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("il respire plus")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not match unrelated subject", () => {
|
||||||
|
expect(subjectSuggestsDefib("mal au dos")).toBe(false);
|
||||||
|
expect(subjectSuggestsDefib("panne de voiture")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches when keyword is in second argument (description)", () => {
|
||||||
|
expect(
|
||||||
|
subjectSuggestsDefib(
|
||||||
|
"urgence médicale mortelle",
|
||||||
|
"crise cardiaque, attaque cérébrale, hémorragie importante, blessure grave",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches when keyword is only in subject, not description", () => {
|
||||||
|
expect(subjectSuggestsDefib("arrêt cardiaque", "some other desc")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not match when neither subject nor description has keywords", () => {
|
||||||
|
expect(subjectSuggestsDefib("agression", "violence physique")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null/undefined in multi-arg", () => {
|
||||||
|
expect(subjectSuggestsDefib(null, "cardiaque")).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib("cardiaque", undefined)).toBe(true);
|
||||||
|
expect(subjectSuggestsDefib(null, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
79
src/utils/geo/corridor.js
Normal file
79
src/utils/geo/corridor.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { point, lineString } from "@turf/helpers";
|
||||||
|
import nearestPointOnLine from "@turf/nearest-point-on-line";
|
||||||
|
import distance from "@turf/distance";
|
||||||
|
|
||||||
|
const distanceOpts = { units: "meters", method: "geodesic" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {[number, number]} LonLat
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a {latitude, longitude} object into a Turf-friendly [lon, lat] tuple.
|
||||||
|
*
|
||||||
|
* @param {{ latitude: number, longitude: number }} coords
|
||||||
|
* @returns {LonLat}
|
||||||
|
*/
|
||||||
|
export function toLonLat({ latitude, longitude }) {
|
||||||
|
return [longitude, latitude];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a radius (meters) for a single DB query around the segment midpoint.
|
||||||
|
* Strategy: radius = segmentLength/2 + corridorMeters.
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {LonLat} params.userLonLat
|
||||||
|
* @param {LonLat} params.alertLonLat
|
||||||
|
* @param {number} params.corridorMeters
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function computeCorridorQueryRadiusMeters({
|
||||||
|
userLonLat,
|
||||||
|
alertLonLat,
|
||||||
|
corridorMeters,
|
||||||
|
}) {
|
||||||
|
const segmentMeters = distance(
|
||||||
|
point(userLonLat),
|
||||||
|
point(alertLonLat),
|
||||||
|
distanceOpts,
|
||||||
|
);
|
||||||
|
return Math.max(0, segmentMeters / 2 + corridorMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter defibs to those within a corridor around the user→alert segment.
|
||||||
|
* Corridor definition: distance(point, lineSegment(user→alert)) <= corridorMeters.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {T[]} params.defibs
|
||||||
|
* @param {LonLat} params.userLonLat
|
||||||
|
* @param {LonLat} params.alertLonLat
|
||||||
|
* @param {number} params.corridorMeters
|
||||||
|
* @returns {T[]}
|
||||||
|
*/
|
||||||
|
export function filterDefibsInCorridor({
|
||||||
|
defibs,
|
||||||
|
userLonLat,
|
||||||
|
alertLonLat,
|
||||||
|
corridorMeters,
|
||||||
|
}) {
|
||||||
|
const line = lineString([userLonLat, alertLonLat]);
|
||||||
|
|
||||||
|
const filtered = [];
|
||||||
|
for (const defib of defibs) {
|
||||||
|
const lon = defib.longitude;
|
||||||
|
const lat = defib.latitude;
|
||||||
|
if (typeof lon !== "number" || typeof lat !== "number") continue;
|
||||||
|
|
||||||
|
const p = point([lon, lat]);
|
||||||
|
const snapped = nearestPointOnLine(line, p, distanceOpts);
|
||||||
|
const distToLine = snapped?.properties?.dist;
|
||||||
|
if (typeof distToLine === "number" && distToLine <= corridorMeters) {
|
||||||
|
filtered.push(defib);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
48
src/utils/geo/corridor.test.js
Normal file
48
src/utils/geo/corridor.test.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import {
|
||||||
|
toLonLat,
|
||||||
|
computeCorridorQueryRadiusMeters,
|
||||||
|
filterDefibsInCorridor,
|
||||||
|
} from "./corridor";
|
||||||
|
|
||||||
|
describe("geo/corridor", () => {
|
||||||
|
test("toLonLat returns [lon, lat]", () => {
|
||||||
|
expect(toLonLat({ latitude: 48.1, longitude: 2.2 })).toEqual([2.2, 48.1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("computeCorridorQueryRadiusMeters matches segment/2 + corridor", () => {
|
||||||
|
const user = [0, 0];
|
||||||
|
const alert = [0, 1];
|
||||||
|
const corridorMeters = 10_000;
|
||||||
|
const radius = computeCorridorQueryRadiusMeters({
|
||||||
|
userLonLat: user,
|
||||||
|
alertLonLat: alert,
|
||||||
|
corridorMeters,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1° latitude is ~111km. Half is ~55.5km, plus corridor.
|
||||||
|
expect(radius).toBeGreaterThan(60_000);
|
||||||
|
expect(radius).toBeLessThan(70_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filterDefibsInCorridor keeps points close to the segment", () => {
|
||||||
|
const userLonLat = [0, 0];
|
||||||
|
const alertLonLat = [0, 1];
|
||||||
|
const corridorMeters = 10_000;
|
||||||
|
|
||||||
|
const defibs = [
|
||||||
|
// on the line
|
||||||
|
{ id: "on", latitude: 0.5, longitude: 0 },
|
||||||
|
// ~0.1° lon at lat 0.5 is ~11km => outside 10km
|
||||||
|
{ id: "off", latitude: 0.5, longitude: 0.1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = filterDefibsInCorridor({
|
||||||
|
defibs,
|
||||||
|
userLonLat,
|
||||||
|
alertLonLat,
|
||||||
|
corridorMeters,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filtered.map((d) => d.id).sort()).toEqual(["on"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/utils/geo/haversine.js
Normal file
14
src/utils/geo/haversine.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Haversine distance in meters between two WGS84 points.
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
const EARTH_RADIUS_M = 6_371_000;
|
||||||
|
|
||||||
|
export default function haversine(lat1, lon1, lat2, lon2) {
|
||||||
|
const dLat = (lat2 - lat1) * DEG_TO_RAD;
|
||||||
|
const dLon = (lon2 - lon1) * DEG_TO_RAD;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * DEG_TO_RAD) *
|
||||||
|
Math.cos(lat2 * DEG_TO_RAD) *
|
||||||
|
Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
40
yarn.lock
40
yarn.lock
|
|
@ -5044,6 +5044,16 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@op-engineering/op-sqlite@npm:^15.2.5":
|
||||||
|
version: 15.2.5
|
||||||
|
resolution: "@op-engineering/op-sqlite@npm:15.2.5"
|
||||||
|
peerDependencies:
|
||||||
|
react: "*"
|
||||||
|
react-native: "*"
|
||||||
|
checksum: 10/e37163e99b5959fdb93076a929f1b2b40db8bb981c996a6342262105a3f387a2cf01d95bd4944cfe4c59424603054fbeeda3179184619b417cd6094a3759b037
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@pkgjs/parseargs@npm:^0.11.0":
|
"@pkgjs/parseargs@npm:^0.11.0":
|
||||||
version: 0.11.0
|
version: 0.11.0
|
||||||
resolution: "@pkgjs/parseargs@npm:0.11.0"
|
resolution: "@pkgjs/parseargs@npm:0.11.0"
|
||||||
|
|
@ -7013,6 +7023,7 @@ __metadata:
|
||||||
"@mapbox/polyline": "npm:^1.2.1"
|
"@mapbox/polyline": "npm:^1.2.1"
|
||||||
"@maplibre/maplibre-react-native": "npm:10.0.0-alpha.23"
|
"@maplibre/maplibre-react-native": "npm:10.0.0-alpha.23"
|
||||||
"@notifee/react-native": "npm:^9.1.8"
|
"@notifee/react-native": "npm:^9.1.8"
|
||||||
|
"@op-engineering/op-sqlite": "npm:^15.2.5"
|
||||||
"@react-native-async-storage/async-storage": "npm:2.1.2"
|
"@react-native-async-storage/async-storage": "npm:2.1.2"
|
||||||
"@react-native-community/cli": "npm:^18.0.0"
|
"@react-native-community/cli": "npm:^18.0.0"
|
||||||
"@react-native-community/netinfo": "npm:^11.4.1"
|
"@react-native-community/netinfo": "npm:^11.4.1"
|
||||||
|
|
@ -7094,6 +7105,7 @@ __metadata:
|
||||||
expo-secure-store: "npm:~14.2.4"
|
expo-secure-store: "npm:~14.2.4"
|
||||||
expo-sensors: "npm:~14.1.4"
|
expo-sensors: "npm:~14.1.4"
|
||||||
expo-splash-screen: "npm:~0.30.10"
|
expo-splash-screen: "npm:~0.30.10"
|
||||||
|
expo-sqlite: "npm:^55.0.10"
|
||||||
expo-status-bar: "npm:~2.2.3"
|
expo-status-bar: "npm:~2.2.3"
|
||||||
expo-system-ui: "npm:~5.0.11"
|
expo-system-ui: "npm:~5.0.11"
|
||||||
expo-task-manager: "npm:~13.1.6"
|
expo-task-manager: "npm:~13.1.6"
|
||||||
|
|
@ -7104,6 +7116,7 @@ __metadata:
|
||||||
google-libphonenumber: "npm:^3.2.32"
|
google-libphonenumber: "npm:^3.2.32"
|
||||||
graphql: "npm:^16.10.0"
|
graphql: "npm:^16.10.0"
|
||||||
graphql-ws: "npm:^6.0.4"
|
graphql-ws: "npm:^6.0.4"
|
||||||
|
h3-js: "npm:^4.4.0"
|
||||||
hash.js: "npm:^1.1.7"
|
hash.js: "npm:^1.1.7"
|
||||||
husky: "npm:^9.0.11"
|
husky: "npm:^9.0.11"
|
||||||
i18next: "npm:^23.2.10"
|
i18next: "npm:^23.2.10"
|
||||||
|
|
@ -7544,6 +7557,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"await-lock@npm:^2.2.2":
|
||||||
|
version: 2.2.2
|
||||||
|
resolution: "await-lock@npm:2.2.2"
|
||||||
|
checksum: 10/feb11f36768a8545879ed2d214b46aae484e6564ffa466af9212d5782897203770795cae01f813de04a46f66c0b8ee6bc690a0c435b04e00cad5a18ef0842e25
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"axe-core@npm:^4.6.2":
|
"axe-core@npm:^4.6.2":
|
||||||
version: 4.7.2
|
version: 4.7.2
|
||||||
resolution: "axe-core@npm:4.7.2"
|
resolution: "axe-core@npm:4.7.2"
|
||||||
|
|
@ -10889,6 +10909,19 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"expo-sqlite@npm:^55.0.10":
|
||||||
|
version: 55.0.10
|
||||||
|
resolution: "expo-sqlite@npm:55.0.10"
|
||||||
|
dependencies:
|
||||||
|
await-lock: "npm:^2.2.2"
|
||||||
|
peerDependencies:
|
||||||
|
expo: "*"
|
||||||
|
react: "*"
|
||||||
|
react-native: "*"
|
||||||
|
checksum: 10/abdc55a33d58bf357d895864756f6196c1951dae9013d9ceb2ac2b2051686c916ef431474c9004369bb8e315ef3ce030a8030abe193c3d436a56f4a40ae0584d
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"expo-status-bar@npm:~2.2.3":
|
"expo-status-bar@npm:~2.2.3":
|
||||||
version: 2.2.3
|
version: 2.2.3
|
||||||
resolution: "expo-status-bar@npm:2.2.3"
|
resolution: "expo-status-bar@npm:2.2.3"
|
||||||
|
|
@ -11960,6 +11993,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"h3-js@npm:^4.4.0":
|
||||||
|
version: 4.4.0
|
||||||
|
resolution: "h3-js@npm:4.4.0"
|
||||||
|
checksum: 10/6db6888f143ed6a1e3ca10506f15c35679afd181e24b71bcdc90259206e3f02637bab38e2a35382d51f17151ea193dfab69c01ff3e31bf0e86abfb1957692576
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"handlebars@npm:^4.7.7":
|
"handlebars@npm:^4.7.7":
|
||||||
version: 4.7.8
|
version: 4.7.8
|
||||||
resolution: "handlebars@npm:4.7.8"
|
resolution: "handlebars@npm:4.7.8"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue