diff --git a/Dta.md b/Dta.md new file mode 100644 index 0000000..9134330 --- /dev/null +++ b/Dta.md @@ -0,0 +1,94 @@ +```powershell +┌─────────────────────┐ +│ Utilisateur │ +└─────────┬───────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ HOME │ +│ Présentation du service │ +│ Accès Liste / Carte │ +└─────────┬────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Demande de géolocalisation navigateur │ +└─────────┬───────────────┬───────────────┘ + │ │ + │ Acceptée │ Refusée / erreur + ▼ ▼ +┌──────────────────┐ ┌──────────────────┐ +│ Coordonnées GPS │ │ Fallback : Paris │ +│ utilisateur │ │ (valeur par défaut) +└─────────┬────────┘ └─────────┬────────┘ + │ │ + └──────────┬───────────┘ + ▼ + ┌────────────────────────────────┐ + │ Calcul des établissements │ + │ hospitaliers les plus proches │ + └─────────┬──────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────┐ +│ Agrégateur de données : HospitalWithMock │ +├────────────────────────────────────────────────────┤ +│ • Données Hôpital │ +│ - Nom │ +│ - Téléphone │ +│ - Adresse │ +│ - Coordonnées géographiques │ +│ │ +│ • Données Services (Mock) │ +│ - Accès pompiers │ +│ - Assistante sociale │ +│ - Spécialités médicales │ +│ │ +│ • Données Accessibilité │ +│ - PMR : parking, entrée, toilettes, assises │ +└─────────┬──────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ Exploration │ +└─────────┬───────────┬─────────┘ + │ │ + ▼ ▼ +┌──────────────────┐ ┌────────────────────────────┐ +│ Vue Liste │ │ Carte interactive Leaflet │ +│ Filtrage dynamique│ │ Marqueurs d urgence │ +└─────────┬────────┘ └─────────┬──────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────┐ +│ Recherche & Filtres avancés │ +├─────────────────────────────────────────────┤ +│ • Recherche textuelle (nom hôpital) │ +│ • Spécifications │ +│ - Accès pompiers │ +│ - Assistante sociale │ +│ - Accessibilité PMR │ +│ • Spécialisations médicales (15+) │ +│ - Cardiologie, Rhumatologie, Gynécologie │ +│ - Neurochirurgie, Chirurgie pédiatrique… │ +└─────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────┐ +│ Fiche établissement hospitalier │ +├──────────────────────────────────────┤ +│ • Coordonnées & contact │ +│ • Spécialités médicales │ +│ • Accessibilité PMR │ +│ • Flux d activité (Mock) │ +│ → Indicateur d affluence │ +└─────────┬────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────┐ +│ Prise de décision utilisateur │ +│ Choix de l établissement le plus │ +│ adapté à sa situation │ +└──────────────────────────────────────┘ + +``` \ No newline at end of file diff --git a/client/app/globals.scss b/client/app/globals.scss index be07a3f..db06b8f 100644 --- a/client/app/globals.scss +++ b/client/app/globals.scss @@ -2,12 +2,23 @@ @tailwind components; @tailwind utilities; +:root { + --text-scale: 1; + /* Contraste et lisibilité : focus visible pour navigation clavier */ + --focus-ring: 4px solid #dc2626; + --focus-ring-offset: 2px; +} + * { box-sizing: border-box; padding: 0; margin: 0; } +html { + font-size: calc(100% * var(--text-scale, 1)); +} + html, body { max-width: 100vw; @@ -20,6 +31,11 @@ body { -moz-osx-font-smoothing: grayscale; } +/* Cohérence pour les lecteurs d'écran : éléments décoratifs masqués à l'annonce */ +[aria-hidden="true"] { + @apply pointer-events-none; +} + a { color: inherit; text-decoration: none; diff --git a/client/app/hopitaux/[id]/page.tsx b/client/app/hopitaux/[id]/page.tsx index 88f86bf..5d7f645 100644 --- a/client/app/hopitaux/[id]/page.tsx +++ b/client/app/hopitaux/[id]/page.tsx @@ -139,7 +139,7 @@ export default function HospitalDetailPage({ params }: { params: Promise<{ id: s return ( <>
-
+
@@ -152,7 +152,7 @@ export default function HospitalDetailPage({ params }: { params: Promise<{ id: s return ( <>
-
+
-
+
{ + return sortByRecommendation(filteredHospitals, selectedSpecializations, null); + }, [filteredHospitals, selectedSpecializations]); + return ( <>
-
+

Hôpitaux avec services d'urgence @@ -196,10 +202,37 @@ export default function HopitauxPage() {

)} + {/* En gros c'est un texte qui permet d'affiché un texte qui explique de pourquoi Le site recommandé est mis + en évidence visuellement et textuellement. C'est pour ça que je le laisse en commentaire pour l'instant, mais + je pense que c'est important de le remettre à terme, peut-être en l'intégrant dans une section "Comment ça marche ?" + ou "Pourquoi ce classement ?" + */} + { + /* {!loading && !error && sortedHospitals.length > 0 && ( +
+

+ Critères d'affichage +

+

+ La liste est triée selon : distance, trafic (affluence), spécialités médicales et accessibilité (entrée, parking, toilettes, places assises). Le site recommandé est mis en évidence en premier avec un bandeau « Recommandé ». +

+
+ +
+
+ )} */ + } + {loading && } {error && } {!loading && !error && ( - + )}
diff --git a/client/app/layout.tsx b/client/app/layout.tsx index 30f9e58..f6408fa 100644 --- a/client/app/layout.tsx +++ b/client/app/layout.tsx @@ -1,6 +1,6 @@ import './globals.scss' import { Metadata } from 'next'; - +import TextScaleInit from '@/components/TextScaleInit'; export const metadata: Metadata = { title: { default: 'Quelles Urgences', @@ -23,6 +23,13 @@ export default function RootLayout({ + + + Aller au contenu principal + {children} diff --git a/client/app/map/page.tsx b/client/app/map/page.tsx index 8f598f3..4b1bccb 100644 --- a/client/app/map/page.tsx +++ b/client/app/map/page.tsx @@ -19,7 +19,7 @@ export default function MapPage() {
{/* Desktop: Normal layout */} -
+

Carte des urgences diff --git a/client/app/page.tsx b/client/app/page.tsx index 4a57977..8f92a21 100644 --- a/client/app/page.tsx +++ b/client/app/page.tsx @@ -7,7 +7,7 @@ export default function Home() { return ( <>
-
+
= MIN && n <= MAX) return n; + } + } catch { + // ignore + } + return 1; +} + +export default function AccessibilityBar() { + const [scale, setScale] = useState(() => getStoredScale()); + + useEffect(() => { + document.documentElement.style.setProperty('--text-scale', String(scale)); + try { + localStorage.setItem(STORAGE_KEY, String(scale)); + } catch { + // ignore + } + }, [scale]); + + const increase = () => setScale((s) => Math.min(MAX, s + STEP)); + const decrease = () => setScale((s) => Math.max(MIN, s - STEP)); + + return ( +
+ + + Taille du texte + + +
+ ); +} diff --git a/client/components/TextScaleInit.tsx b/client/components/TextScaleInit.tsx new file mode 100644 index 0000000..0c78447 --- /dev/null +++ b/client/components/TextScaleInit.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useEffect } from 'react'; + +const STORAGE_KEY = 'urgences-text-scale'; +const MIN = 0.9; +const MAX = 1.5; + +function getStoredScale(): number { + if (typeof window === 'undefined') return 1; + try { + const v = localStorage.getItem(STORAGE_KEY); + if (v != null) { + const n = parseFloat(v); + if (n >= MIN && n <= MAX) return n; + } + } catch { + // ignore + } + return 1; +} + +/** Applique la taille de texte sauvegardée au chargement (pour toutes les pages). */ +export default function TextScaleInit() { + useEffect(() => { + document.documentElement.style.setProperty('--text-scale', String(getStoredScale())); + }, []); + return null; +} diff --git a/client/components/hopitaux/HospitalCard.tsx b/client/components/hopitaux/HospitalCard.tsx index 5ee912b..79ffada 100644 --- a/client/components/hopitaux/HospitalCard.tsx +++ b/client/components/hopitaux/HospitalCard.tsx @@ -2,43 +2,72 @@ import Link from "next/link"; import Image from "next/image"; import { HospitalWithMock } from "@/types/api"; -function HospitalCard({ hospital }: { hospital: HospitalWithMock }) { +interface HospitalCardProps { + hospital: HospitalWithMock; + isRecommended?: boolean; +} + +function HospitalCard({ hospital, isRecommended = false }: HospitalCardProps) { const distance = hospital.fields.dist ? (hospital.fields.dist / 1000).toFixed(1) : null; - + const cardLabel = isRecommended + ? `Recommandé : ${hospital.fields.name}. Hôpital avec services d'urgence.` + : `${hospital.fields.name}. Hôpital avec services d'urgence.`; + return ( -
+ {isRecommended && ( +
+ + + Recommandé + + + Ce site est recommandé selon la distance, le trafic, les spécialités et l'accessibilité. + +
+ )}
-

- + {hospital.fields.name}

{distance && ( - + {distance} km )}
{hospital.fields.phone && ( - e.stopPropagation()} + e.stopPropagation()} > - @@ -46,7 +75,7 @@ function HospitalCard({ hospital }: { hospital: HospitalWithMock }) { )} {!hospital.fields.phone && ( -

Aucun numéro de téléphone disponible

+

Aucun numéro de téléphone disponible

)}
); diff --git a/client/components/hopitaux/HospitalList.tsx b/client/components/hopitaux/HospitalList.tsx index 0abcfdc..c2ad9e0 100644 --- a/client/components/hopitaux/HospitalList.tsx +++ b/client/components/hopitaux/HospitalList.tsx @@ -2,7 +2,12 @@ import HospitalCard from "@/components/hopitaux/HospitalCard"; import { HospitalWithMock } from "@/types/api"; import NotFoundData from "@/components/NotFoundData"; -function HospitalList({ hospitals }: { hospitals: HospitalWithMock[] }) { +interface HospitalListProps { + hospitals: HospitalWithMock[]; + recommendedRecordId?: string | null; +} + +function HospitalList({ hospitals, recommendedRecordId = null }: HospitalListProps) { if (hospitals.length === 0) { return ( @@ -10,9 +15,13 @@ function HospitalList({ hospitals }: { hospitals: HospitalWithMock[] }) { } return ( -
- {hospitals.map(hospital => ( - +
+ {hospitals.map((hospital) => ( + ))}
); diff --git a/client/lib/recommendation.ts b/client/lib/recommendation.ts new file mode 100644 index 0000000..3a3081f --- /dev/null +++ b/client/lib/recommendation.ts @@ -0,0 +1,118 @@ +import type { HospitalWithMock, AccessibilityOptions, Professionnal } from "@/types/api"; + +/** Poids des critères dans le score de recommandation (somme = 100) */ +const WEIGHTS = { + distance: 35, + traffic: 20, + specialty: 25, + accessibility: 20, +} as const; + +/** + * Score de distance : plus proche = meilleur. + * dist en mètres. 0–5 km => 100, 5–15 => dégradé, >15 => faible. + */ +function distanceScore(distMeters: number | undefined): number { + if (distMeters == null) return 50; // neutre si pas de distance + const km = distMeters / 1000; + if (km <= 2) return 100; + if (km <= 5) return 100 - (km - 2) * 15; + if (km <= 15) return 55 - (km - 5) * 3; + return Math.max(0, 55 - (km - 5) * 3); +} + +/** + * Score trafic : moins d’affluence = meilleur. + * trafficLevel 0–100 (0 = vide, 100 = saturé). En liste on n’a pas le trafic, donc neutre. + */ +function trafficScore(trafficLevel: number | null | undefined): number { + if (trafficLevel == null) return 70; // neutre quand pas de donnée + return Math.round(100 - trafficLevel); +} + +/** + * Score spécialité : nombre de spécialités sélectionnées présentes. + */ +function specialtyScore( + professionnal: Professionnal | undefined, + selectedSpecializations: string[] +): number { + if (!professionnal || selectedSpecializations.length === 0) return 100; // pas de filtre = neutre max + let match = 0; + for (const key of selectedSpecializations) { + if (professionnal[key as keyof Professionnal]) match++; + } + if (match === 0) return 0; + return Math.round((match / selectedSpecializations.length) * 100); +} + +/** + * Score accessibilité : nombre d’options d’accessibilité (entrée, parking, toilettes, sièges). + */ +function accessibilityScore(opts: AccessibilityOptions | undefined): number { + if (!opts) return 50; + const count = [ + opts.wheelchairAccessibleEntrance, + opts.wheelchairAccessibleParking, + opts.wheelchairAccessibleRestroom, + opts.wheelchairAccessibleSeating, + ].filter(Boolean).length; + return count === 0 ? 30 : 30 + count * 17.5; // 30, 47.5, 65, 82.5, 100 +} + +export interface RecommendationInput { + hospital: HospitalWithMock; + selectedSpecializations: string[]; + /** Niveau d’affluence 0–100 si disponible (sinon non utilisé) */ + trafficLevel?: number | null; +} + +/** + * Calcule un score de recommandation entre 0 et 100 à partir de : + * distance, trafic, spécialité, accessibilité. + */ +export function getRecommendationScore({ + hospital, + selectedSpecializations, + trafficLevel, +}: RecommendationInput): number { + const d = distanceScore(hospital.fields.dist); + const t = trafficScore(trafficLevel); + const s = specialtyScore(hospital.mockData?.professionnal, selectedSpecializations); + const a = accessibilityScore(hospital.accessibilityOptions); + + return Math.round( + (d * WEIGHTS.distance + + t * WEIGHTS.traffic + + s * WEIGHTS.specialty + + a * WEIGHTS.accessibility) / + 100 + ); +} + +/** + * Trie les hôpitaux par score de recommandation (meilleur en premier) + * et retourne la liste triée + l’id du premier (recommandé). + */ +export function sortByRecommendation( + hospitals: HospitalWithMock[], + selectedSpecializations: string[], + trafficByRecordId?: Map | null +): { sorted: HospitalWithMock[]; recommendedRecordId: string | null } { + if (hospitals.length === 0) return { sorted: [], recommendedRecordId: null }; + + const withScore = hospitals.map((h) => ({ + hospital: h, + score: getRecommendationScore({ + hospital: h, + selectedSpecializations, + trafficLevel: trafficByRecordId?.get(h.recordid) ?? null, + }), + })); + + withScore.sort((a, b) => b.score - a.score); + const sorted = withScore.map((x) => x.hospital); + const recommendedRecordId = sorted[0]?.recordid ?? null; + + return { sorted, recommendedRecordId }; +}