Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| d280edaefd |
77 changed files with 30 additions and 8353 deletions
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"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,7 +4,6 @@ module.exports = {
|
|||
root: true,
|
||||
env: {
|
||||
"react-native/react-native": true,
|
||||
jest: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:prettier/recommended",
|
||||
|
|
@ -38,12 +37,6 @@ module.exports = {
|
|||
},
|
||||
"import/ignore": ["react-native"],
|
||||
"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: {},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -100,15 +100,3 @@ android/app/google-services.json
|
|||
!android/app/google-services.example.json
|
||||
|
||||
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
|
||||
|
|
|
|||
83
README.md
83
README.md
|
|
@ -1,82 +1 @@
|
|||
# Alerte Secours - Le Réflexe qui Sauve
|
||||
|
||||
[](https://liberapay.com/alerte-secours)
|
||||
[](https://buymeacoffee.com/alertesecours)
|
||||
[](https://github.com/sponsors/alerte-secours)
|
||||
|
||||
Une application mobile pour la gestion des alertes et des fonctionnalités liées aux urgences, supportant les plateformes iOS et Android.
|
||||
|
||||
**Site Web Officiel :** [alerte-secours.fr](https://alerte-secours.fr)
|
||||
|
||||
## Aperçu du Projet
|
||||
|
||||
Alerte Secours est une application mobile construite avec React Native qui gère les alertes et les fonctionnalités liées aux urgences. L'application supporte les plateformes iOS et Android et inclut des fonctionnalités telles que :
|
||||
|
||||
- Création et gestion d'alertes avec mises à jour en temps réel
|
||||
- Fonctionnalités basées sur la localisation avec intégration cartographique
|
||||
- Système de chat/messagerie avec salles de discussion spécifiques aux alertes
|
||||
- Authentification via vérification SMS
|
||||
- Liens profonds pour le partage d'alertes
|
||||
- Notifications push
|
||||
|
||||
## Documentation Développeur
|
||||
|
||||
Pour les développeurs souhaitant contribuer au projet ou déployer l'application, consultez la [documentation technique complète](DEVELOPER.md) qui contient :
|
||||
|
||||
- Aperçu du projet et fonctionnalités
|
||||
- Stack technique détaillé
|
||||
- Guide de démarrage rapide
|
||||
- Instructions d'installation (Android/iOS)
|
||||
- Structure du projet
|
||||
- Guide de dépannage
|
||||
- Instructions de build et déploiement
|
||||
|
||||
## Licence
|
||||
|
||||
Alerte Secours est sous licence **DevTheFuture Ethical Use License (DEF License)**. Points clés :
|
||||
|
||||
### Usage à but non lucratif
|
||||
- Licence perpétuelle, libre de redevances et non exclusive pour usage à but non lucratif
|
||||
- Permet l'utilisation, la modification et la distribution à des fins non lucratives
|
||||
|
||||
### Usage commercial
|
||||
- Nécessite l'obtention d'une licence payante
|
||||
- Conditions déterminées par le Concédant (DevTheFuture.org)
|
||||
|
||||
### Restrictions sur les données personnelles
|
||||
- Ne doit pas être utilisé pour monétiser, vendre ou exploiter les données personnelles
|
||||
- Les données personnelles ne peuvent pas être utilisées pour le marketing, la publicité ou l'influence politique
|
||||
- L'agrégation de données n'est autorisée que si c'est une fonctionnalité explicite divulguée aux utilisateurs
|
||||
|
||||
### Restriction concurrentielle
|
||||
- Les concurrents sont interdits d'utiliser le logiciel sans consentement explicite
|
||||
|
||||
Pour le texte complet de la licence, voir [LICENSE.md](LICENSE.md).
|
||||
|
||||
## 💙 Soutenir le projet
|
||||
|
||||
Alerte-Secours est une application mobile citoyenne, librement accessible, sans publicité ni exploitation de données.
|
||||
|
||||
Si vous souhaitez contribuer à son développement, sa maintenance et son indépendance :
|
||||
|
||||
- 🟡 **[Liberapay – Soutien régulier](https://liberapay.com/alerte-secours)**
|
||||
Pour un soutien **récurrent et engagé**. Chaque don contribue à assurer la stabilité du service sur le long terme.
|
||||
|
||||
- ☕ **[Buy Me a Coffee – Don ponctuel](https://buymeacoffee.com/alertesecours)**
|
||||
Pour un **coup de pouce ponctuel**, un café virtuel pour encourager le travail accompli !
|
||||
|
||||
- 🧑💻 **[GitHub Sponsors](https://github.com/sponsors/alerte-secours)**
|
||||
Pour les développeurs et utilisateurs de GitHub : soutenez le projet directement via votre compte.
|
||||
|
||||
## Contribuer
|
||||
|
||||
Directives pour contribuer au projet :
|
||||
|
||||
1. Suivez le style de code et les conventions utilisées dans le projet
|
||||
2. Écrivez des tests pour les nouvelles fonctionnalités
|
||||
3. Mettez à jour la documentation si nécessaire
|
||||
4. Utilisez les configurations ESLint et Prettier
|
||||
|
||||
## Support
|
||||
|
||||
Pour obtenir de l'aide, veuillez ouvrir un ticket sur notre tracker d'issues ou consulter la documentation dans le répertoire `/docs`.
|
||||
Merged into monorepo at [alerte-secours](https://git.devthefuture.org/alerte-secours/alerte-secours)
|
||||
|
|
@ -147,13 +147,6 @@ android {
|
|||
}
|
||||
}
|
||||
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 {
|
||||
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,55 +2,6 @@
|
|||
|
||||
## 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
|
||||
1. Background Notification Fixes:
|
||||
- ✅ Added required Android permissions
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
/** @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)\/.*/,
|
||||
]),
|
||||
sourceExts: [...sentryConfig.resolver.sourceExts, "cjs"],
|
||||
assetExts: [...defaultConfig.resolver.assetExts, "ttf", "db"],
|
||||
assetExts: [...defaultConfig.resolver.assetExts, "ttf"],
|
||||
},
|
||||
server: {
|
||||
enhanceMiddleware: (middleware) => {
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -51,11 +51,7 @@
|
|||
"open:deeplink:ios": "yarn open:deeplink --ios",
|
||||
"open:deeplink": "npx uri-scheme open --android",
|
||||
"screenshot:ios": "scripts/screenshot-ios.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"
|
||||
"screenshot:android": "scripts/screenshot-android.sh"
|
||||
},
|
||||
"customExpoVersioning": {
|
||||
"versionCode": 241,
|
||||
|
|
@ -89,7 +85,6 @@
|
|||
"@mapbox/polyline": "^1.2.1",
|
||||
"@maplibre/maplibre-react-native": "10.0.0-alpha.23",
|
||||
"@notifee/react-native": "^9.1.8",
|
||||
"@op-engineering/op-sqlite": "^15.2.5",
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-community/slider": "4.5.6",
|
||||
|
|
@ -131,7 +126,6 @@
|
|||
"expo-contacts": "~14.2.5",
|
||||
"expo-dev-client": "~5.2.4",
|
||||
"expo-device": "~7.1.4",
|
||||
"expo-file-system": "~18.1.11",
|
||||
"expo-gradle-ext-vars": "^0.1.1",
|
||||
"expo-linear-gradient": "~14.1.5",
|
||||
"expo-linking": "~7.1.7",
|
||||
|
|
@ -141,7 +135,6 @@
|
|||
"expo-secure-store": "~14.2.4",
|
||||
"expo-sensors": "~14.1.4",
|
||||
"expo-splash-screen": "~0.30.10",
|
||||
"expo-sqlite": "^55.0.10",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "~5.0.11",
|
||||
"expo-task-manager": "~13.1.6",
|
||||
|
|
@ -152,7 +145,6 @@
|
|||
"google-libphonenumber": "^3.2.32",
|
||||
"graphql": "^16.10.0",
|
||||
"graphql-ws": "^6.0.4",
|
||||
"h3-js": "^4.4.0",
|
||||
"hash.js": "^1.1.7",
|
||||
"i18next": "^23.2.10",
|
||||
"immer": "^10.0.2",
|
||||
|
|
@ -290,4 +282,4 @@
|
|||
}
|
||||
},
|
||||
"packageManager": "yarn@4.5.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,395 +0,0 @@
|
|||
# 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
|
||||
```
|
||||
|
||||
|
|
@ -1,370 +0,0 @@
|
|||
## 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
2
scripts/dae/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
geodae.json
|
||||
geodae.csv
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: ../../.yarn/releases/yarn-4.5.3.cjs
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
#!/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);
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
#!/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);
|
||||
});
|
||||
|
|
@ -1,451 +0,0 @@
|
|||
#!/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}`);
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,889 +0,0 @@
|
|||
# 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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4 KiB |
|
|
@ -1,71 +0,0 @@
|
|||
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,12 +25,7 @@ export default function AlertSymbolLayer({ level, isDisabled }) {
|
|||
|
||||
return (
|
||||
<Maplibre.SymbolLayer
|
||||
filter={[
|
||||
"all",
|
||||
["==", ["get", "icon"], icon],
|
||||
// Exclude DAE overlay markers (v1: separate non-clustered layer)
|
||||
["!=", ["get", "isDefib"], true],
|
||||
]}
|
||||
filter={["==", ["get", "icon"], icon]}
|
||||
key={key}
|
||||
id={key}
|
||||
belowLayerID={belowLayerID}
|
||||
|
|
|
|||
|
|
@ -5,23 +5,19 @@ import Maplibre from "@maplibre/maplibre-react-native";
|
|||
import markerRed from "~/assets/img/marker-red.png";
|
||||
import markerYellow from "~/assets/img/marker-yellow.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 markerYellowDisabled from "~/assets/img/marker-yellow-disabled.png";
|
||||
import markerGreenDisabled from "~/assets/img/marker-green-disabled.png";
|
||||
import markerOrigin from "~/assets/img/marker-origin.png";
|
||||
import markerDae from "~/assets/img/marker-dae.png";
|
||||
|
||||
const images = {
|
||||
red: markerRed,
|
||||
yellow: markerYellow,
|
||||
green: markerGreen,
|
||||
grey: markerGrey,
|
||||
redDisabled: markerRedDisabled,
|
||||
yellowDisabled: markerYellowDisabled,
|
||||
greenDisabled: markerGreenDisabled,
|
||||
origin: markerOrigin,
|
||||
dae: markerDae,
|
||||
};
|
||||
|
||||
export default function FeatureImages() {
|
||||
|
|
|
|||
|
|
@ -17,12 +17,6 @@ const iconStyle = {
|
|||
iconSize: 0.5,
|
||||
};
|
||||
|
||||
const defibStyle = {
|
||||
iconImage: "dae",
|
||||
iconSize: 0.5,
|
||||
iconAllowOverlap: true,
|
||||
};
|
||||
|
||||
const useStyles = createStyles(({ theme: { colors } }) => ({
|
||||
clusterCount: {
|
||||
textField: "{point_count_abbreviated}",
|
||||
|
|
@ -64,15 +58,6 @@ export default function ShapePoints({ shape, children, ...shapeSourceProps }) {
|
|||
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}
|
||||
</Maplibre.ShapeSource>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
// 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);
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
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
359
src/db/openDb.js
|
|
@ -1,359 +0,0 @@
|
|||
// 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
// 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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
|
||||
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
|
||||
const FOREGROUND_KICK_MIN_INACTIVE_MS = 30_000;
|
||||
const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000;
|
||||
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
|
||||
|
||||
if (
|
||||
|
|
@ -239,15 +239,6 @@ export default function useLatestWithSubscription(
|
|||
if (age < livenessStaleMs) return;
|
||||
|
||||
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;
|
||||
lastLivenessKickAtRef.current = now;
|
||||
|
||||
|
|
@ -285,7 +276,7 @@ export default function useLatestWithSubscription(
|
|||
|
||||
// Escalation policy for repeated consecutive stale kicks.
|
||||
if (
|
||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD + 2 &&
|
||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD &&
|
||||
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||
) {
|
||||
const lastRecovery = wsLastRecoveryDateRef.current
|
||||
|
|
@ -319,7 +310,7 @@ export default function useLatestWithSubscription(
|
|||
// ignore
|
||||
}
|
||||
|
||||
networkActions.triggerReload("transport");
|
||||
networkActions.triggerReload();
|
||||
} else if (
|
||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
|
||||
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
|
||||
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
|
||||
const FOREGROUND_KICK_MIN_INACTIVE_MS = 30_000;
|
||||
const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000;
|
||||
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
|
||||
|
||||
if (
|
||||
|
|
@ -281,15 +281,6 @@ export default function useStreamQueryWithSubscription(
|
|||
});
|
||||
}
|
||||
// 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;
|
||||
lastLivenessKickAtRef.current = now;
|
||||
|
||||
|
|
@ -327,7 +318,7 @@ export default function useStreamQueryWithSubscription(
|
|||
|
||||
// Escalation policy for repeated consecutive stale kicks.
|
||||
if (
|
||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD + 2 &&
|
||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD &&
|
||||
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||
) {
|
||||
const lastRecovery = wsLastRecoveryDateRef.current
|
||||
|
|
@ -361,7 +352,7 @@ export default function useStreamQueryWithSubscription(
|
|||
// ignore
|
||||
}
|
||||
|
||||
networkActions.triggerReload("transport");
|
||||
networkActions.triggerReload();
|
||||
} else if (
|
||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
|
||||
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ import {
|
|||
Dark as NavigationDarkTheme,
|
||||
} from "~/theme/navigation";
|
||||
|
||||
import DaeSuggestModal from "~/containers/DaeSuggestModal";
|
||||
|
||||
// import { navActions } from "~/stores";
|
||||
|
||||
// const linking = {
|
||||
|
|
@ -88,9 +86,6 @@ export default function LayoutProviders({ layoutKey, setLayoutKey, children }) {
|
|||
>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
|
||||
{/* Global persistent modal: mounted outside navigation tree, but can navigate via RootNav ref */}
|
||||
<DaeSuggestModal />
|
||||
</ComposeComponents>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
// 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,8 +7,6 @@ import {
|
|||
} from "@expo/vector-icons";
|
||||
import { useNavigation, CommonActions } from "@react-navigation/native";
|
||||
|
||||
import FontAwesome6 from "@expo/vector-icons/FontAwesome6";
|
||||
|
||||
import DrawerContent from "~/navigation/DrawerNav/DrawerContent";
|
||||
import { useDrawerState } from "~/navigation/Context";
|
||||
import getDefaultDrawerWidth from "~/navigation/DrawerNav/getDefaultDrawerWidth";
|
||||
|
|
@ -25,8 +23,6 @@ import AlertAggListArchived from "~/scenes/AlertAggListArchived";
|
|||
import About from "~/scenes/About";
|
||||
import Contribute from "~/scenes/Contribute";
|
||||
import Location from "~/scenes/Location";
|
||||
import DAEList from "~/scenes/DAEList";
|
||||
import DAEItem from "~/scenes/DAEItem";
|
||||
import Developer from "~/scenes/Developer";
|
||||
import HelpSignal from "~/scenes/HelpSignal";
|
||||
|
||||
|
|
@ -87,7 +83,6 @@ export default React.memo(function DrawerNav() {
|
|||
|
||||
return (
|
||||
<Drawer.Navigator
|
||||
backBehavior="history"
|
||||
drawerContent={(props) => <DrawerContent {...props} />}
|
||||
drawerStyle={{
|
||||
width: getDefaultDrawerWidth(dimensions),
|
||||
|
|
@ -371,27 +366,6 @@ export default React.memo(function DrawerNav() {
|
|||
}}
|
||||
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
|
||||
name="Links"
|
||||
component={Links}
|
||||
|
|
@ -529,14 +503,6 @@ export default React.memo(function DrawerNav() {
|
|||
}}
|
||||
component={SendAlertFinder}
|
||||
/>
|
||||
<Drawer.Screen
|
||||
name="DAEItem"
|
||||
component={DAEItem}
|
||||
options={{
|
||||
hidden: true,
|
||||
unmountOnBlur: true,
|
||||
}}
|
||||
/>
|
||||
{devModeEnabled && (
|
||||
<Drawer.Screen
|
||||
name="Developer"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
|
||||
import { Image } from "react-native";
|
||||
import { useNavigation, CommonActions } from "@react-navigation/native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { HeaderBackButton } from "@react-navigation/elements";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
|
||||
|
|
@ -28,24 +28,7 @@ export default function HeaderLeft(props) {
|
|||
if (canGoBack) {
|
||||
navigation.goBack();
|
||||
} else {
|
||||
// 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");
|
||||
}
|
||||
navigation.navigate(drawerState.topTabPrev || "SendAlert");
|
||||
}
|
||||
}}
|
||||
backImage={() => (
|
||||
|
|
|
|||
|
|
@ -57,11 +57,6 @@ function getHeaderTitle(route) {
|
|||
case "SendAlertFinder":
|
||||
return "Par mot-clé";
|
||||
|
||||
case "DAEList":
|
||||
return "Défibrillateurs";
|
||||
case "DAEItem":
|
||||
return "Défibrillateur";
|
||||
|
||||
case "ConnectivityError":
|
||||
return "Non connecté";
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import getRetryMaxAttempts from "./getRetryMaxAttemps";
|
|||
|
||||
import { createLogger } from "~/lib/logger";
|
||||
import { NETWORK_SCOPES } from "~/lib/logger/scopes";
|
||||
import createCache from "./cache";
|
||||
|
||||
const { useNetworkState, networkActions } = store;
|
||||
|
||||
|
|
@ -29,15 +28,11 @@ const networkProvidersLogger = createLogger({
|
|||
feature: "NetworkProviders",
|
||||
});
|
||||
|
||||
const sharedApolloCache = createCache();
|
||||
|
||||
const initializeNewApolloClient = (reload) => {
|
||||
if (reload) {
|
||||
const { apolloClient } = network;
|
||||
apolloClient.stop();
|
||||
if (apolloClient.cache !== sharedApolloCache) {
|
||||
apolloClient.clearStore();
|
||||
}
|
||||
apolloClient.clearStore();
|
||||
}
|
||||
|
||||
network.apolloClient = createApolloClient({
|
||||
|
|
@ -45,7 +40,6 @@ const initializeNewApolloClient = (reload) => {
|
|||
GRAPHQL_URL: env.GRAPHQL_URL,
|
||||
GRAPHQL_WS_URL: env.GRAPHQL_WS_URL,
|
||||
getRetryMaxAttempts,
|
||||
cache: sharedApolloCache,
|
||||
});
|
||||
};
|
||||
initializeNewApolloClient();
|
||||
|
|
@ -57,62 +51,34 @@ network.oaFilesKy = oaFilesKy;
|
|||
|
||||
export default function NetworkProviders({ children }) {
|
||||
const [key, setKey] = useState(0);
|
||||
const [transportClient, setTransportClient] = useState(
|
||||
() => network.apolloClient,
|
||||
);
|
||||
|
||||
const networkState = useNetworkState([
|
||||
"initialized",
|
||||
"triggerReload",
|
||||
"reloadKind",
|
||||
"transportGeneration",
|
||||
]);
|
||||
const networkState = useNetworkState(["initialized", "triggerReload"]);
|
||||
useEffect(() => {
|
||||
if (networkState.triggerReload) {
|
||||
networkProvidersLogger.debug("Network triggerReload received", {
|
||||
reloadKind: networkState.reloadKind,
|
||||
reloadId: store.getAuthState()?.reloadId,
|
||||
hasUserToken: !!store.getAuthState()?.userToken,
|
||||
});
|
||||
|
||||
const isFullReload = networkState.reloadKind !== "transport";
|
||||
initializeNewApolloClient(true);
|
||||
|
||||
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();
|
||||
}
|
||||
setKey((prevKey) => prevKey + 1);
|
||||
}
|
||||
}, [
|
||||
networkState.triggerReload,
|
||||
networkState.reloadKind,
|
||||
networkState.transportGeneration,
|
||||
]);
|
||||
}, [networkState.triggerReload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (key > 0) {
|
||||
networkProvidersLogger.debug("Network reloaded", {
|
||||
reloadKind: networkState.reloadKind,
|
||||
reloadId: store.getAuthState()?.reloadId,
|
||||
hasUserToken: !!store.getAuthState()?.userToken,
|
||||
});
|
||||
networkActions.onReload();
|
||||
}
|
||||
}, [key, networkState.reloadKind]);
|
||||
}, [key]);
|
||||
|
||||
if (!networkState.initialized) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const providers = [[ApolloProvider, { client: transportClient }]];
|
||||
const providers = [[ApolloProvider, { client: network.apolloClient }]];
|
||||
|
||||
return (
|
||||
<ComposeComponents key={key} components={providers}>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ if (__DEV__ || process.env.NODE_ENV !== "production") {
|
|||
}
|
||||
|
||||
export default function createApolloClient(options) {
|
||||
const cache = options.cache || createCache();
|
||||
const errorLink = createErrorLink(options);
|
||||
const authLink = createAuthLink(options);
|
||||
const cancelLink = createCancelLink();
|
||||
|
|
@ -51,6 +50,8 @@ export default function createApolloClient(options) {
|
|||
httpLink: httpChain,
|
||||
});
|
||||
|
||||
const cache = createCache();
|
||||
|
||||
const apolloClient = new ApolloClient({
|
||||
cache,
|
||||
// connectToDevTools: true, // Enable dev tools for better debugging
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export default function ControlButtons({
|
|||
setZoomLevel,
|
||||
detached,
|
||||
}) {
|
||||
// const styles = useStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
|
|
|
|||
|
|
@ -2,14 +2,6 @@ export default function prioritizeFeatures(features) {
|
|||
return features
|
||||
.filter(({ properties }) => !properties.isUserLocation)
|
||||
.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 (x.cluster && y.cluster) {
|
||||
return x.x_max_level_num < y.x_max_level_num ? 1 : -1;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import Supercluster from "supercluster";
|
|||
import useShallowMemo from "~/hooks/useShallowMemo";
|
||||
import useShallowEffect from "~/hooks/useShallowEffect";
|
||||
import { deepEqual } from "fast-equals";
|
||||
import { useDefibsState } from "~/stores";
|
||||
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
||||
|
||||
export default function useFeatures({
|
||||
clusterFeature,
|
||||
|
|
@ -15,11 +13,6 @@ export default function useFeatures({
|
|||
route,
|
||||
alertCoords,
|
||||
}) {
|
||||
const { showDefibsOnAlertMap, corridorDefibs } = useDefibsState([
|
||||
"showDefibsOnAlertMap",
|
||||
"corridorDefibs",
|
||||
]);
|
||||
|
||||
// Check if we have valid coordinates
|
||||
const hasUserCoords =
|
||||
userCoords && userCoords.longitude !== null && userCoords.latitude !== null;
|
||||
|
|
@ -102,58 +95,15 @@ 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 {
|
||||
type: "FeatureCollection",
|
||||
features,
|
||||
};
|
||||
}, [list, showDefibsOnAlertMap, corridorDefibs]);
|
||||
}, [list]);
|
||||
|
||||
const superCluster = useShallowMemo(() => {
|
||||
const cluster = new Supercluster({ radius: 40, maxZoom: 16 });
|
||||
// Do not cluster defibs in v1
|
||||
const clusterable = featureCollection.features.filter(
|
||||
(f) => !f?.properties?.isDefib,
|
||||
);
|
||||
cluster.load(clusterable);
|
||||
cluster.load(featureCollection.features);
|
||||
return cluster;
|
||||
}, [featureCollection.features]);
|
||||
// console.log({ superCluster: JSON.stringify(superCluster) });
|
||||
|
|
@ -173,15 +123,6 @@ export default function useFeatures({
|
|||
const userCoordinates = [userCoords.longitude, userCoords.latitude];
|
||||
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
|
||||
const isRouteEnding = route?.distance !== 0 || routeCoords?.length === 0;
|
||||
const hasValidAlertCoords =
|
||||
|
|
@ -216,8 +157,6 @@ export default function useFeatures({
|
|||
}, [
|
||||
setShape,
|
||||
clusterFeature,
|
||||
featureCollection.features,
|
||||
showDefibsOnAlertMap,
|
||||
userCoords,
|
||||
hasUserCoords,
|
||||
routeCoords,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useNavigation } from "@react-navigation/native";
|
|||
|
||||
import { ANIMATION_DURATION } from "~/containers/Map/constants";
|
||||
|
||||
import { alertActions, defibsActions } from "~/stores";
|
||||
import { alertActions } from "~/stores";
|
||||
import { createLogger } from "~/lib/logger";
|
||||
import { FEATURE_SCOPES, UI_SCOPES } from "~/lib/logger/scopes";
|
||||
|
||||
|
|
@ -29,12 +29,6 @@ export default function useOnPress({
|
|||
const [feature] = features;
|
||||
const { properties } = feature;
|
||||
|
||||
if (properties?.isDefib && properties?.defib) {
|
||||
defibsActions.setSelectedDefib(properties.defib);
|
||||
navigation.navigate("DAEItem");
|
||||
return;
|
||||
}
|
||||
|
||||
if (properties.cluster) {
|
||||
// center and expand to cluster's points
|
||||
const { current: camera } = cameraRef;
|
||||
|
|
|
|||
|
|
@ -6,18 +6,14 @@ import { MaterialCommunityIcons } from "@expo/vector-icons";
|
|||
import { deepEqual } from "fast-equals";
|
||||
|
||||
import withConnectivity from "~/hoc/withConnectivity";
|
||||
import { useToast } from "~/lib/toast-notifications";
|
||||
|
||||
import {
|
||||
useAlertState,
|
||||
useSessionState,
|
||||
alertActions,
|
||||
useAggregatedMessagesState,
|
||||
useDefibsState,
|
||||
defibsActions,
|
||||
} from "~/stores";
|
||||
import { getCurrentLocation } from "~/location";
|
||||
import { getStoredLocation } from "~/location/storage";
|
||||
|
||||
import alertBigButtonBgMap from "~/assets/img/alert-big-button-bg-map.png";
|
||||
import alertBigButtonBgMapGrey from "~/assets/img/alert-big-button-bg-map-grey.png";
|
||||
|
|
@ -83,107 +79,6 @@ export default withConnectivity(
|
|||
const isSent = userId === sessionUserId;
|
||||
|
||||
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 notifyAround = useCallback(async () => {
|
||||
|
|
@ -503,45 +398,6 @@ export default withConnectivity(
|
|||
</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 && (
|
||||
<MapLinksButton coordinates={alert.location.coordinates} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -70,14 +70,6 @@ export default createStyles(({ wp, hp, scaleText, theme: { colors } }) => ({
|
|||
actionComingHelpButton: {},
|
||||
actionComingHelpText: {},
|
||||
actionComingHelpIcon: {},
|
||||
actionShowDefibsButton: {
|
||||
backgroundColor: colors.blue,
|
||||
},
|
||||
actionShowDefibsButtonActive: {
|
||||
backgroundColor: colors.grey,
|
||||
},
|
||||
actionShowDefibsText: {},
|
||||
actionShowDefibsIcon: {},
|
||||
actionSmsButton: {},
|
||||
actionSmsText: {},
|
||||
actionSmsIcon: {},
|
||||
|
|
|
|||
|
|
@ -1,459 +0,0 @@
|
|||
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",
|
||||
},
|
||||
});
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
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",
|
||||
},
|
||||
});
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
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",
|
||||
},
|
||||
});
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
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,8 +10,7 @@ import {
|
|||
} from "react-native-gesture-handler";
|
||||
import { createStyles, useTheme } from "~/theme";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { format, fr } from "date-fns";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { DELETE_NOTIFICATION, MARK_NOTIFICATION_AS_READ } from "./gql";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -6,19 +6,11 @@ import uuidGenerator from "react-native-uuid";
|
|||
import { phoneCallEmergency } from "~/lib/phone-call";
|
||||
|
||||
import network from "~/network";
|
||||
import {
|
||||
getSessionState,
|
||||
alertActions,
|
||||
defibsActions,
|
||||
useParamsState,
|
||||
} from "~/stores";
|
||||
import { getSessionState, alertActions, useParamsState } from "~/stores";
|
||||
import { getCurrentLocation } from "~/location";
|
||||
|
||||
import useSendAlertSMSToEmergency from "~/hooks/useSendAlertSMSToEmergency";
|
||||
|
||||
import alertsList from "~/misc/alertsList";
|
||||
import subjectSuggestsDefib from "~/utils/dae/subjectSuggestsDefib";
|
||||
|
||||
import { SEND_ALERT_MUTATION } from "./gql";
|
||||
|
||||
export default function useOnSubmit() {
|
||||
|
|
@ -85,13 +77,6 @@ async function onSubmit(args, context) {
|
|||
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 createdAt = new Date().toISOString();
|
||||
|
||||
|
|
@ -140,7 +125,6 @@ async function onSubmit(args, context) {
|
|||
});
|
||||
|
||||
alertActions.setNavAlertCur({ alert });
|
||||
|
||||
navigation.navigate("Main", {
|
||||
screen: "AlertCur",
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -81,5 +81,4 @@ export const STORAGE_KEYS = {
|
|||
EULA_ACCEPTED_SIMPLE: registerAsyncStorageKey("eula_accepted"),
|
||||
EMULATOR_MODE_ENABLED: registerAsyncStorageKey("emulator_mode_enabled"),
|
||||
SENTRY_ENABLED: registerAsyncStorageKey("@sentry_enabled"),
|
||||
DAE_DB_UPDATED_AT: registerAsyncStorageKey("@dae_db_updated_at"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,223 +0,0 @@
|
|||
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,7 +13,6 @@ import params from "./params";
|
|||
import notifications from "./notifications";
|
||||
import permissionWizard from "./permissionWizard";
|
||||
import aggregatedMessages from "./aggregatedMessages";
|
||||
import defibs from "./defibs";
|
||||
|
||||
const store = createStore({
|
||||
tree,
|
||||
|
|
@ -29,7 +28,6 @@ const store = createStore({
|
|||
permissionWizard,
|
||||
notifications,
|
||||
aggregatedMessages,
|
||||
defibs,
|
||||
});
|
||||
|
||||
// console.log("store", JSON.stringify(Object.keys(store), null, 2));
|
||||
|
|
@ -102,9 +100,4 @@ export const {
|
|||
getAggregatedMessagesState,
|
||||
subscribeAggregatedMessagesState,
|
||||
aggregatedMessagesActions,
|
||||
|
||||
useDefibsState,
|
||||
getDefibsState,
|
||||
subscribeDefibsState,
|
||||
defibsActions,
|
||||
} = store;
|
||||
|
|
|
|||
|
|
@ -9,28 +9,20 @@ export default createAtom(({ merge, get }) => {
|
|||
wsLastHeartbeatDate: null,
|
||||
wsLastRecoveryDate: null,
|
||||
triggerReload: false,
|
||||
reloadKind: null,
|
||||
initialized: true,
|
||||
hasInternetConnection: true,
|
||||
transportGeneration: 0,
|
||||
},
|
||||
actions: {
|
||||
triggerReload: (reloadKind = "full") => {
|
||||
triggerReload: () => {
|
||||
merge({
|
||||
initialized: false,
|
||||
triggerReload: true,
|
||||
reloadKind,
|
||||
initialized: reloadKind === "transport" ? true : false,
|
||||
transportGeneration:
|
||||
reloadKind === "transport"
|
||||
? get("transportGeneration") + 1
|
||||
: get("transportGeneration"),
|
||||
});
|
||||
},
|
||||
onReload: () => {
|
||||
merge({
|
||||
initialized: true,
|
||||
triggerReload: false,
|
||||
reloadKind: null,
|
||||
});
|
||||
},
|
||||
WSConnected: () => {
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
/**
|
||||
* @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" };
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// 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,16 +5044,6 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 0.11.0
|
||||
resolution: "@pkgjs/parseargs@npm:0.11.0"
|
||||
|
|
@ -7023,7 +7013,6 @@ __metadata:
|
|||
"@mapbox/polyline": "npm:^1.2.1"
|
||||
"@maplibre/maplibre-react-native": "npm:10.0.0-alpha.23"
|
||||
"@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-community/cli": "npm:^18.0.0"
|
||||
"@react-native-community/netinfo": "npm:^11.4.1"
|
||||
|
|
@ -7105,7 +7094,6 @@ __metadata:
|
|||
expo-secure-store: "npm:~14.2.4"
|
||||
expo-sensors: "npm:~14.1.4"
|
||||
expo-splash-screen: "npm:~0.30.10"
|
||||
expo-sqlite: "npm:^55.0.10"
|
||||
expo-status-bar: "npm:~2.2.3"
|
||||
expo-system-ui: "npm:~5.0.11"
|
||||
expo-task-manager: "npm:~13.1.6"
|
||||
|
|
@ -7116,7 +7104,6 @@ __metadata:
|
|||
google-libphonenumber: "npm:^3.2.32"
|
||||
graphql: "npm:^16.10.0"
|
||||
graphql-ws: "npm:^6.0.4"
|
||||
h3-js: "npm:^4.4.0"
|
||||
hash.js: "npm:^1.1.7"
|
||||
husky: "npm:^9.0.11"
|
||||
i18next: "npm:^23.2.10"
|
||||
|
|
@ -7557,13 +7544,6 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 4.7.2
|
||||
resolution: "axe-core@npm:4.7.2"
|
||||
|
|
@ -10909,19 +10889,6 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 2.2.3
|
||||
resolution: "expo-status-bar@npm:2.2.3"
|
||||
|
|
@ -11993,13 +11960,6 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 4.7.8
|
||||
resolution: "handlebars@npm:4.7.8"
|
||||
|
|
|
|||
Reference in a new issue