diff --git a/.gitignore b/.gitignore index 3d70248ba2..1622beaf01 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -package-lock.json \ No newline at end of file +package-lock.json +!frontend/package-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 31466b54c2..3d8552f084 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,185 @@ -# Final Project +# đŸ–ïž BADA – Find Safe Beaches in Sweden -Replace this readme with your own information about your project. +![React](https://img.shields.io/badge/Frontend-React-61DAFB?logo=react&logoColor=white) +![Express](https://img.shields.io/badge/Backend-Express-000000?logo=express&logoColor=white) +![MongoDB](https://img.shields.io/badge/Database-MongoDB-47A248?logo=mongodb&logoColor=white) +![TypeScript](https://img.shields.io/badge/Code-TypeScript-3178C6?logo=typescript&logoColor=white) +![TailwindCSS](https://img.shields.io/badge/UI-Tailwind_CSS-38B2AC?logo=tailwind-css&logoColor=white) +![MapLibre](https://img.shields.io/badge/Maps-MapLibre-4264FB?logo=openstreetmap&logoColor=white) +![i18next](https://img.shields.io/badge/Translations-i18next-26A69A?logo=i18next&logoColor=white) +![TanStack Query](https://img.shields.io/badge/State-TanStack_Query-FF4154?logo=reactquery&logoColor=white) +![Deployed](https://img.shields.io/badge/Deployed-Vercel-000000?logo=vercel&logoColor=white) -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +**BADA** helps beachgoers and families in Sweden find safe, EU-classified bathing waters with real-time quality updates. +It replaces outdated or clunky websites with a **clean, mobile-friendly experience** where you can browse nearby beaches on a map, check water quality, and save your favourites. -## The problem +[Try it out here](https://badaweb.netlify.app/) (deployed on Netlify) -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +--- -## View it live +## ✹ Features -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +- đŸ—ș **Map of all EU-classified beaches in Sweden** (MapLibre + OpenStreetMap) +- 📍 **Find the nearest beach** using your device’s location +- 🔬 **View water quality, classification, and recent test results** (data from HaV) +- ❀ **Create an account and save favourite beaches** to your profile +- 🌗 **Dark mode** and responsive design (mobile → desktop) +- 🌐 **Multi-language support** (Swedish / English) +- 🔀 **Drag-and-drop sorting for favorites** + +--- + +## 🚀 Tech Stack + +**Frontend** + +- React 18 + Vite + TypeScript +- React Router +- Zustand (global state) +- TanStack Query (server state & caching) +- Tailwind CSS +- i18next (translations) +- MapLibre GL (maps) +- Custom React Hooks (geolocation, dark mode, outside click) + +**Backend** + +- Node.js + Express +- MongoDB + Mongoose +- JWT Authentication +- Zod (validation) +- In-memory caching for HaV API responses + +**External APIs** + +- [HaV Bathing Waters API](https://badplatsen.havochvatten.se/) (official Swedish Agency for Marine and Water Management) +- [MapTiler](https://www.maptiler.com/) (map styles) +- _(Planned)_ OpenWeatherMap for weather and water temperature + +--- + +## 📾 Screenshots + +_(Coming soon)_ + +--- + +## 🛠 Installation & Setup + +Clone the repo and install dependencies: + +```bash +git clone https://github.com/govargas/bada.git +cd bada + +``` + +**Backend** + +```bash +cd backend +cp .env.example .env.local # then fill in your values +npm install +npm run dev + +``` + +Backend runs on http://localhost:3000 + +**Frontend** + +```bash +cd frontend +cp .env.example .env.local # then fill in your values +npm install +npm run dev + +``` + +Frontend runs on http://localhost:5173 + +--- + +## 🔑 Environment Variables + +- See .env.example in both backend/ and frontend/. +- Fill in with your own values (MongoDB Atlas, JWT secret, MapTiler key). + +--- + +## đŸ‘€ Test User Credentials + +- Use these to try the app without registering: + +Email: smoke@test.com + +Password: Test1234 + +This account already has some favourite beaches saved. + +--- + +## 🌍 Deployment + +- Frontend: Deployed on Netlify: https://badaweb.netlify.app/ +- Backend: Deployed on Vercel → https://bada-backend.vercel.app/api/health + +--- + +## ✅ Requirements Checklist + +### Technical Requirements (Grade G) + +- ✅ React frontend +- ✅ Node.js + Express backend +- ✅ MongoDB database +- ✅ Authentication (JWT) +- ✅ React Router navigation +- ✅ Global state management (Zustand) +- ✅ ≄2 external libraries (TanStack Query, MapLibre, react-hook-form, i18next, react-hot-toast, @dnd-kit) +- ✅ Custom React hooks (useGeolocation, useOutsideClose, useDarkMode) +- ✅ Responsive (320px → 1600px+) +- ✅ Accessibility features (ARIA labels, skip navigation, semantic HTML, form labels) +- ✅ Clean Code practices + +### Visual Requirements + +- ✅ Clear structure using box model with consistent margins/paddings +- ✅ Consistent typography across views and breakpoints +- ✅ Cohesive color scheme +- ✅ Mobile-first responsive design +- ✅ Dark mode support +- ✅ Multi-language support (Swedish/English) + +### Grade VG Enhancements + +- ✅ Error Boundaries +- ✅ Toast notifications +- ✅ Reduced motion support +- ✅ Comprehensive documentation +- ✅ Meta tags for SEO + +--- + +## 🧭 Roadmap + +- ✅ Allow notes/tips per beach (e.g. "good for kids") - Planned for future +- Integrate OpenWeatherMap for weather & water temperature +- ✅ Accessibility extras (reduced motion, ARIA live regions) - Implemented +- ✅ Polish with animations and micro-interactions - Toast notifications added +- Filter beaches by classification +- Add user beach photos + +--- + +## 💡 Inspiration & Credits + +- Data from the Swedish Agency for Marine and Water Management (HaV) +- Maps powered by OpenStreetMap + MapTiler +- Built during the Technigo Fullstack JavaScript Bootcamp (2025) + +--- + +## đŸ‘šâ€đŸ’» Author + +Created by Talo Vargas, 2025 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000000..f34be0d1b4 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,17 @@ +# Server +PORT=3000 +NODE_ENV=development + +# Database +MONGODB_URI=mongodb+srv://:@/?retryWrites=true&w=majority + +# Auth +JWT_SECRET=replace-with-a-random-secret + +# CORS +ALLOWED_ORIGIN=http://localhost:5173 + +# HaV API +HAV_BASE_URL=https://badplatsen.havochvatten.se/badplatsen/api +HAV_V2_BASE=https://api.havochvatten.se/bathingwaters/v2 +HAV_USER_AGENT=BADA-App/1.0 (contact: my@email) \ No newline at end of file diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index d1438c9108..0000000000 --- a/backend/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Backend part of Final Project - -This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with. - -## Getting Started - -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file diff --git a/backend/dist/index.d.ts b/backend/dist/index.d.ts new file mode 100644 index 0000000000..72752437ba --- /dev/null +++ b/backend/dist/index.d.ts @@ -0,0 +1,3 @@ +declare const app: import("express-serve-static-core").Express; +export default app; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/backend/dist/index.d.ts.map b/backend/dist/index.d.ts.map new file mode 100644 index 0000000000..80160c130c --- /dev/null +++ b/backend/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAaA,QAAA,MAAM,GAAG,6CAAY,CAAC;AAgFtB,eAAe,GAAG,CAAC"} \ No newline at end of file diff --git a/backend/dist/index.js b/backend/dist/index.js new file mode 100644 index 0000000000..dfc056e263 --- /dev/null +++ b/backend/dist/index.js @@ -0,0 +1,76 @@ +// backend/src/index.ts +console.log("Server booted at", new Date().toISOString()); +import express from "express"; +import cors from "cors"; +import { healthRouter } from "./routes/health.js"; +import { dbCheckRouter } from "./routes/dbCheck.js"; +import { authRouter } from "./routes/auth.js"; +import { requireAuth } from "./middleware/auth.js"; +import { favoritesRouter } from "./routes/favorites.js"; +import { beachesRouter } from "./routes/beaches.js"; +const app = express(); +/** ── CORS (allow comma-separated origins via CORS_ORIGIN or ALLOWED_ORIGIN) ── */ +const allowedOrigins = (process.env.CORS_ORIGIN || + process.env.ALLOWED_ORIGIN || + "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +const corsOptions = { + origin(origin, cb) { + if (!origin) + return cb(null, true); // non-browser clients + if (allowedOrigins.length === 0) + return cb(null, true); // dev fallback + if (allowedOrigins.includes(origin)) + return cb(null, true); + return cb(new Error("Not allowed by CORS")); + }, + credentials: true, + methods: "GET,HEAD,PUT,PATCH,POST,DELETE", +}; +app.use(cors(corsOptions)); +app.use(express.json()); +/** ── TEMP DIAG: env presence (put this BEFORE the 404) ───────────────────── */ +app.get("/api/debug/env", (_req, res) => { + res.json({ + HAV_BASE_URL: !!process.env.HAV_BASE_URL, + HAV_USER_AGENT: !!process.env.HAV_USER_AGENT, + HAV_V2_BASE: !!process.env.HAV_V2_BASE, + ALLOWED_ORIGIN: process.env.ALLOWED_ORIGIN ?? "", + CORS_ORIGIN: process.env.CORS_ORIGIN ?? "", + MONGODB_URI_present: !!process.env.MONGODB_URI, + JWT_SECRET_present: !!process.env.JWT_SECRET, + NODE_ENV: process.env.NODE_ENV ?? "undefined", + PORT: process.env.PORT ?? "undefined", + }); +}); +/** ── Public routers under /api ───────────────────────────────────────────── */ +app.use("/api", healthRouter); +app.use("/api", dbCheckRouter); +app.use("/api", favoritesRouter); +app.use("/api", beachesRouter); +/** ── Auth ───────────────────────────────────────────────────────────────── */ +app.use("/api/auth", authRouter); +app.get("/api/protected/ping", requireAuth, (req, res) => { + res.json({ ok: true, user: req.user }); +}); +app.get("/api/auth/me", requireAuth, (req, res) => { + res.json({ user: req.user }); +}); +/** ── Quick direct health ping ────────────────────────────────────────────── */ +app.get("/api/health-direct", (_req, res) => { + console.log("Hit /api/health-direct"); + res.json({ ok: true, via: "direct" }); +}); +/** ── 404 + error handler (MUST be last) ──────────────────────────────────── */ +app.use((_req, res) => res.status(404).json({ error: "NotFound" })); +app.use((err, _req, res, _next) => { + console.error("[ERROR]", err); + const message = process.env.NODE_ENV !== "production" && err instanceof Error + ? err.message + : undefined; + res.status(500).json({ error: "InternalServerError", message }); +}); +export default app; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/backend/dist/index.js.map b/backend/dist/index.js.map new file mode 100644 index 0000000000..850d89773b --- /dev/null +++ b/backend/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,uBAAuB;AACvB,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC;AAE1D,OAAO,OAAO,MAAM,SAAS,CAAC;AAC9B,OAAO,IAAI,MAAM,MAAM,CAAC;AAExB,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpD,MAAM,GAAG,GAAG,OAAO,EAAE,CAAC;AAEtB,mFAAmF;AACnF,MAAM,cAAc,GAAG,CACrB,OAAO,CAAC,GAAG,CAAC,WAAW;IACvB,OAAO,CAAC,GAAG,CAAC,cAAc;IAC1B,EAAE,CACH;KACE,KAAK,CAAC,GAAG,CAAC;KACV,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;KACpB,MAAM,CAAC,OAAO,CAAC,CAAC;AAEnB,MAAM,WAAW,GAAqB;IACpC,MAAM,CAAC,MAAM,EAAE,EAAE;QACf,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,sBAAsB;QAC1D,IAAI,cAAc,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,eAAe;QACvE,IAAI,cAAc,CAAC,QAAQ,CAAC,MAAM,CAAC;YAAE,OAAO,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAC3D,OAAO,EAAE,CAAC,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAC;IAC9C,CAAC;IACD,WAAW,EAAE,IAAI;IACjB,OAAO,EAAE,gCAAgC;CAC1C,CAAC;AAEF,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC;AAC3B,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,iFAAiF;AACjF,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;IACtC,GAAG,CAAC,IAAI,CAAC;QACP,YAAY,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY;QACxC,cAAc,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc;QAC5C,WAAW,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,WAAW;QACtC,cAAc,EAAE,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,EAAE;QAChD,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,EAAE;QAC1C,mBAAmB,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,WAAW;QAC9C,kBAAkB,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU;QAC5C,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,WAAW;QAC7C,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,WAAW;KACtC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,iFAAiF;AACjF,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;AAC9B,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;AAC/B,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;AAE/B,gFAAgF;AAChF,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,qBAAqB,EAAE,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IACvD,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAG,GAAW,CAAC,IAAI,EAAE,CAAC,CAAC;AAClD,CAAC,CAAC,CAAC;AACH,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAChD,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAG,GAAW,CAAC,IAAI,EAAE,CAAC,CAAC;AACxC,CAAC,CAAC,CAAC;AAEH,iFAAiF;AACjF,GAAG,CAAC,GAAG,CAAC,oBAAoB,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;IAC1C,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;IACtC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC;AACxC,CAAC,CAAC,CAAC;AAEH,iFAAiF;AACjF,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC;AACpE,GAAG,CAAC,GAAG,CACL,CACE,GAAY,EACZ,IAAqB,EACrB,GAAqB,EACrB,KAA2B,EAC3B,EAAE;IACF,OAAO,CAAC,KAAK,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IAC9B,MAAM,OAAO,GACX,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,IAAI,GAAG,YAAY,KAAK;QAC3D,CAAC,CAAC,GAAG,CAAC,OAAO;QACb,CAAC,CAAC,SAAS,CAAC;IAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,OAAO,EAAE,CAAC,CAAC;AAClE,CAAC,CACF,CAAC;AAEF,eAAe,GAAG,CAAC"} \ No newline at end of file diff --git a/backend/dist/lib/cache.d.ts b/backend/dist/lib/cache.d.ts new file mode 100644 index 0000000000..f99b188259 --- /dev/null +++ b/backend/dist/lib/cache.d.ts @@ -0,0 +1,9 @@ +export declare class SimpleCache { + private defaultTtlMs; + private store; + constructor(defaultTtlMs?: number); + get(key: string): T | undefined; + set(key: string, value: T, ttlMs?: number): void; +} +export declare const cache: SimpleCache; +//# sourceMappingURL=cache.d.ts.map \ No newline at end of file diff --git a/backend/dist/lib/cache.d.ts.map b/backend/dist/lib/cache.d.ts.map new file mode 100644 index 0000000000..9446086722 --- /dev/null +++ b/backend/dist/lib/cache.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/lib/cache.ts"],"names":[],"mappings":"AAEA,qBAAa,WAAW;IAGV,OAAO,CAAC,YAAY;IAFhC,OAAO,CAAC,KAAK,CAAiC;gBAE1B,YAAY,SAAS;IAEzC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAUlC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,SAAoB;CAGxD;AAED,eAAO,MAAM,KAAK,aAA0B,CAAC"} \ No newline at end of file diff --git a/backend/dist/lib/cache.js b/backend/dist/lib/cache.js new file mode 100644 index 0000000000..960ed27092 --- /dev/null +++ b/backend/dist/lib/cache.js @@ -0,0 +1,22 @@ +export class SimpleCache { + defaultTtlMs; + store = new Map(); + constructor(defaultTtlMs = 60_000) { + this.defaultTtlMs = defaultTtlMs; + } // default 60s + get(key) { + const hit = this.store.get(key); + if (!hit) + return; + if (Date.now() > hit.expiry) { + this.store.delete(key); + return; + } + return hit.value; + } + set(key, value, ttlMs = this.defaultTtlMs) { + this.store.set(key, { value, expiry: Date.now() + ttlMs }); + } +} +export const cache = new SimpleCache(60_000); // 60s default +//# sourceMappingURL=cache.js.map \ No newline at end of file diff --git a/backend/dist/lib/cache.js.map b/backend/dist/lib/cache.js.map new file mode 100644 index 0000000000..d242e47440 --- /dev/null +++ b/backend/dist/lib/cache.js.map @@ -0,0 +1 @@ +{"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/lib/cache.ts"],"names":[],"mappings":"AAEA,MAAM,OAAO,WAAW;IAGF;IAFZ,KAAK,GAAG,IAAI,GAAG,EAAsB,CAAC;IAE9C,YAAoB,eAAe,MAAM;QAArB,iBAAY,GAAZ,YAAY,CAAS;IAAG,CAAC,CAAC,cAAc;IAE5D,GAAG,CAAI,GAAW;QAChB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAChC,IAAI,CAAC,GAAG;YAAE,OAAO;QACjB,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;YAC5B,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACvB,OAAO;QACT,CAAC;QACD,OAAO,GAAG,CAAC,KAAU,CAAC;IACxB,CAAC;IAED,GAAG,CAAI,GAAW,EAAE,KAAQ,EAAE,KAAK,GAAG,IAAI,CAAC,YAAY;QACrD,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,EAAE,CAAC,CAAC;IAC7D,CAAC;CACF;AAED,MAAM,CAAC,MAAM,KAAK,GAAG,IAAI,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,cAAc"} \ No newline at end of file diff --git a/backend/dist/lib/db.d.ts b/backend/dist/lib/db.d.ts new file mode 100644 index 0000000000..a34a926416 --- /dev/null +++ b/backend/dist/lib/db.d.ts @@ -0,0 +1,3 @@ +import mongoose from "mongoose"; +export declare function connectDB(): Promise; +//# sourceMappingURL=db.d.ts.map \ No newline at end of file diff --git a/backend/dist/lib/db.d.ts.map b/backend/dist/lib/db.d.ts.map new file mode 100644 index 0000000000..cc99ffa183 --- /dev/null +++ b/backend/dist/lib/db.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,UAAU,CAAC;AAahC,wBAAsB,SAAS,6BAmC9B"} \ No newline at end of file diff --git a/backend/dist/lib/db.js b/backend/dist/lib/db.js new file mode 100644 index 0000000000..b92ac881a8 --- /dev/null +++ b/backend/dist/lib/db.js @@ -0,0 +1,33 @@ +import mongoose from "mongoose"; +let cached = global._mongooseCached ?? { + conn: null, + promise: null, +}; +global._mongooseCached = cached; +export async function connectDB() { + if (cached.conn) + return cached.conn; + if (!cached.promise) { + const uri = process.env.MONGODB_URI; + if (!uri) + throw new Error("MONGODB_URI missing"); + mongoose.set("strictQuery", true); + mongoose.set("bufferCommands", false); + cached.promise = mongoose + .connect(uri, { serverSelectionTimeoutMS: 5000 }) + .then((m) => { + cached.conn = m; + // 🔮 Optional debug hooks (remove later) + mongoose.connection.on("connected", () => console.log("[mongo] connected")); + mongoose.connection.on("error", (e) => console.error("[mongo] error", e)); + mongoose.connection.on("disconnected", () => console.warn("[mongo] disconnected")); + return m; + }) + .catch((err) => { + cached.promise = null; + throw err; + }); + } + return cached.promise; +} +//# sourceMappingURL=db.js.map \ No newline at end of file diff --git a/backend/dist/lib/db.js.map b/backend/dist/lib/db.js.map new file mode 100644 index 0000000000..a619785608 --- /dev/null +++ b/backend/dist/lib/db.js.map @@ -0,0 +1 @@ +{"version":3,"file":"db.js","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,UAAU,CAAC;AAOhC,IAAI,MAAM,GAAY,MAAc,CAAC,eAAe,IAAI;IACtD,IAAI,EAAE,IAAI;IACV,OAAO,EAAE,IAAI;CACd,CAAC;AACD,MAAc,CAAC,eAAe,GAAG,MAAM,CAAC;AAEzC,MAAM,CAAC,KAAK,UAAU,SAAS;IAC7B,IAAI,MAAM,CAAC,IAAI;QAAE,OAAO,MAAM,CAAC,IAAI,CAAC;IAEpC,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC;QACpC,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC;QAEjD,QAAQ,CAAC,GAAG,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC;QAClC,QAAQ,CAAC,GAAG,CAAC,gBAAgB,EAAE,KAAK,CAAC,CAAC;QAEtC,MAAM,CAAC,OAAO,GAAG,QAAQ;aACtB,OAAO,CAAC,GAAG,EAAE,EAAE,wBAAwB,EAAE,IAAI,EAAE,CAAC;aAChD,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE;YACV,MAAM,CAAC,IAAI,GAAG,CAAC,CAAC;YAEhB,yCAAyC;YACzC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,WAAW,EAAE,GAAG,EAAE,CACvC,OAAO,CAAC,GAAG,CAAC,mBAAmB,CAAC,CACjC,CAAC;YACF,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,CACpC,OAAO,CAAC,KAAK,CAAC,eAAe,EAAE,CAAC,CAAC,CAClC,CAAC;YACF,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,cAAc,EAAE,GAAG,EAAE,CAC1C,OAAO,CAAC,IAAI,CAAC,sBAAsB,CAAC,CACrC,CAAC;YAEF,OAAO,CAAC,CAAC;QACX,CAAC,CAAC;aACD,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;YACb,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;YACtB,MAAM,GAAG,CAAC;QACZ,CAAC,CAAC,CAAC;IACP,CAAC;IAED,OAAO,MAAM,CAAC,OAAO,CAAC;AACxB,CAAC"} \ No newline at end of file diff --git a/backend/dist/lib/hav.d.ts b/backend/dist/lib/hav.d.ts new file mode 100644 index 0000000000..d5db8757ed --- /dev/null +++ b/backend/dist/lib/hav.d.ts @@ -0,0 +1,7 @@ +/** v1 fetcher (old endpoints like /feature, /detail) */ +export declare function havGet(path: string, ttlMs?: number): Promise; +/** Minimal v2 fetcher (used for monitoring results, etc.) */ +export declare function havV2Get(path: string, init?: RequestInit): Promise; +/** Pull latest sample date (ISO string) from /bathing-waters/{id}/results */ +export declare function getLatestSampleDate(id: string): Promise; +//# sourceMappingURL=hav.d.ts.map \ No newline at end of file diff --git a/backend/dist/lib/hav.d.ts.map b/backend/dist/lib/hav.d.ts.map new file mode 100644 index 0000000000..2b8094856c --- /dev/null +++ b/backend/dist/lib/hav.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"hav.d.ts","sourceRoot":"","sources":["../../src/lib/hav.ts"],"names":[],"mappings":"AAWA,wDAAwD;AACxD,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,SAAgB,gBAgC/D;AAED,6DAA6D;AAC7D,wBAAsB,QAAQ,CAAC,CAAC,EAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE,WAAW,GACjB,OAAO,CAAC,CAAC,CAAC,CAuBZ;AAED,6EAA6E;AAC7E,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAe5E"} \ No newline at end of file diff --git a/backend/dist/lib/hav.js b/backend/dist/lib/hav.js new file mode 100644 index 0000000000..7d05a66ed9 --- /dev/null +++ b/backend/dist/lib/hav.js @@ -0,0 +1,60 @@ +import { cache } from "./cache.js"; +const HAV_BASE_URL = process.env.HAV_BASE_URL; +const HAV_USER_AGENT = process.env.HAV_USER_AGENT || "BADA/1.0"; +const HAV_V2_BASE = process.env.HAV_V2_BASE ?? "https://api.havochvatten.se/bathingwaters/v2"; +function key(path) { + return `hav:${path}`; +} +/** v1 fetcher (old endpoints like /feature, /detail) */ +export async function havGet(path, ttlMs = 5 * 60 * 1000) { + const k = key(`v1:${path}`); + const cached = cache.get(k); + if (cached) + return cached; + const url = `${HAV_BASE_URL}${path}`; + // TEMP: trace outgoing in dev + if (process.env.NODE_ENV !== "production") { + console.log(`[HaV v1] GET ${url}`); + } + const res = await fetch(url, { + headers: { + "User-Agent": HAV_USER_AGENT, + Accept: "application/json", + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + console.warn(`[HaV v1] ${res.status} ${res.statusText} for ${url}: ${text.slice(0, 500)}`); + throw new Error(`HaV v1 error ${res.status} ${res.statusText}`); + } + const data = await res.json(); + cache.set(k, data, ttlMs); + return data; +} +/** Minimal v2 fetcher (used for monitoring results, etc.) */ +export async function havV2Get(path, init) { + const url = `${HAV_V2_BASE}${path}`; + if (process.env.NODE_ENV !== "production") { + console.log(`[HaV v2] GET ${url}`); + } + const res = await fetch(url, { + headers: { Accept: "application/json" }, + ...init, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + console.warn(`[HaV v2] ${res.status} ${res.statusText} for ${url}: ${text.slice(0, 500)}`); + throw new Error(`HaV v2 error ${res.status} ${res.statusText}`); + } + return res.json(); +} +/** Pull latest sample date (ISO string) from /bathing-waters/{id}/results */ +export async function getLatestSampleDate(id) { + const data = await havV2Get(`/bathing-waters/${encodeURIComponent(id)}/results`); + const latest = (data.results ?? []) + .map((r) => r.takenAt) + .filter((d) => !!d) + .sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))[0] ?? null; + return latest; +} +//# sourceMappingURL=hav.js.map \ No newline at end of file diff --git a/backend/dist/lib/hav.js.map b/backend/dist/lib/hav.js.map new file mode 100644 index 0000000000..4333e992b7 --- /dev/null +++ b/backend/dist/lib/hav.js.map @@ -0,0 +1 @@ +{"version":3,"file":"hav.js","sourceRoot":"","sources":["../../src/lib/hav.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAEnC,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,YAAa,CAAC;AAC/C,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,UAAU,CAAC;AAChE,MAAM,WAAW,GACf,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,8CAA8C,CAAC;AAE5E,SAAS,GAAG,CAAC,IAAY;IACvB,OAAO,OAAO,IAAI,EAAE,CAAC;AACvB,CAAC;AAED,wDAAwD;AACxD,MAAM,CAAC,KAAK,UAAU,MAAM,CAAC,IAAY,EAAE,KAAK,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI;IAC9D,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC;IAC5B,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAC5B,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IAE1B,MAAM,GAAG,GAAG,GAAG,YAAY,GAAG,IAAI,EAAE,CAAC;IACrC,8BAA8B;IAC9B,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;QAC1C,OAAO,CAAC,GAAG,CAAC,gBAAgB,GAAG,EAAE,CAAC,CAAC;IACrC,CAAC;IAED,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;QAC3B,OAAO,EAAE;YACP,YAAY,EAAE,cAAc;YAC5B,MAAM,EAAE,kBAAkB;SAC3B;KACF,CAAC,CAAC;IAEH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;QAC9C,OAAO,CAAC,IAAI,CACV,YAAY,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,UAAU,QAAQ,GAAG,KAAK,IAAI,CAAC,KAAK,CAChE,CAAC,EACD,GAAG,CACJ,EAAE,CACJ,CAAC;QACF,MAAM,IAAI,KAAK,CAAC,gBAAgB,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC;IAClE,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAC9B,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;IAC1B,OAAO,IAAI,CAAC;AACd,CAAC;AAED,6DAA6D;AAC7D,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,IAAY,EACZ,IAAkB;IAElB,MAAM,GAAG,GAAG,GAAG,WAAW,GAAG,IAAI,EAAE,CAAC;IACpC,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;QAC1C,OAAO,CAAC,GAAG,CAAC,gBAAgB,GAAG,EAAE,CAAC,CAAC;IACrC,CAAC;IAED,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;QAC3B,OAAO,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE;QACvC,GAAG,IAAI;KACR,CAAC,CAAC;IAEH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;QAC9C,OAAO,CAAC,IAAI,CACV,YAAY,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,UAAU,QAAQ,GAAG,KAAK,IAAI,CAAC,KAAK,CAChE,CAAC,EACD,GAAG,CACJ,EAAE,CACJ,CAAC;QACF,MAAM,IAAI,KAAK,CAAC,gBAAgB,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC;IAClE,CAAC;IAED,OAAO,GAAG,CAAC,IAAI,EAAgB,CAAC;AAClC,CAAC;AAED,6EAA6E;AAC7E,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,EAAU;IAIlD,MAAM,IAAI,GAAG,MAAM,QAAQ,CACzB,mBAAmB,kBAAkB,CAAC,EAAE,CAAC,UAAU,CACpD,CAAC;IAEF,MAAM,MAAM,GACV,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC;SACjB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;SACrB,MAAM,CAAC,CAAC,CAAC,EAAe,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;SAC/B,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;IAE7D,OAAO,MAAM,CAAC;AAChB,CAAC"} \ No newline at end of file diff --git a/backend/dist/lib/havbackup.d.ts b/backend/dist/lib/havbackup.d.ts new file mode 100644 index 0000000000..bf54359ed7 --- /dev/null +++ b/backend/dist/lib/havbackup.d.ts @@ -0,0 +1,2 @@ +export declare function havGet(path: string, ttlMs?: number): Promise; +//# sourceMappingURL=havbackup.d.ts.map \ No newline at end of file diff --git a/backend/dist/lib/havbackup.d.ts.map b/backend/dist/lib/havbackup.d.ts.map new file mode 100644 index 0000000000..f4f2df0fc6 --- /dev/null +++ b/backend/dist/lib/havbackup.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"havbackup.d.ts","sourceRoot":"","sources":["../../src/lib/havbackup.ts"],"names":[],"mappings":"AAWA,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,SAAgB,gBAsB/D"} \ No newline at end of file diff --git a/backend/dist/lib/havbackup.js b/backend/dist/lib/havbackup.js new file mode 100644 index 0000000000..bd71ec8a2b --- /dev/null +++ b/backend/dist/lib/havbackup.js @@ -0,0 +1,29 @@ +import { cache } from "./cache.js"; +const HAV_BASE_URL = process.env.HAV_BASE_URL; +const HAV_USER_AGENT = process.env.HAV_USER_AGENT; +// Helper to build a cache key +function key(path) { + return `hav:${path}`; +} +// Generic fetch wrapper with caching +export async function havGet(path, ttlMs = 5 * 60 * 1000) { + const k = key(path); + const cached = cache.get(k); + if (cached) { + return cached; // ✅ serve from memory if not expired + } + const url = `${HAV_BASE_URL}${path}`; + const res = await fetch(url, { + headers: { + "User-Agent": HAV_USER_AGENT, + Accept: "application/json", + }, + }); + if (!res.ok) { + throw new Error(`HaV API error: ${res.status} ${res.statusText}`); + } + const data = await res.json(); + cache.set(k, data, ttlMs); // ✅ cache result + return data; +} +//# sourceMappingURL=havbackup.js.map \ No newline at end of file diff --git a/backend/dist/lib/havbackup.js.map b/backend/dist/lib/havbackup.js.map new file mode 100644 index 0000000000..d785be71e6 --- /dev/null +++ b/backend/dist/lib/havbackup.js.map @@ -0,0 +1 @@ +{"version":3,"file":"havbackup.js","sourceRoot":"","sources":["../../src/lib/havbackup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAEnC,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,YAAa,CAAC;AAC/C,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,cAAe,CAAC;AAEnD,8BAA8B;AAC9B,SAAS,GAAG,CAAC,IAAY;IACvB,OAAO,OAAO,IAAI,EAAE,CAAC;AACvB,CAAC;AAED,qCAAqC;AACrC,MAAM,CAAC,KAAK,UAAU,MAAM,CAAC,IAAY,EAAE,KAAK,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI;IAC9D,MAAM,CAAC,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC;IACpB,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAC5B,IAAI,MAAM,EAAE,CAAC;QACX,OAAO,MAAM,CAAC,CAAC,qCAAqC;IACtD,CAAC;IAED,MAAM,GAAG,GAAG,GAAG,YAAY,GAAG,IAAI,EAAE,CAAC;IACrC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;QAC3B,OAAO,EAAE;YACP,YAAY,EAAE,cAAc;YAC5B,MAAM,EAAE,kBAAkB;SAC3B;KACF,CAAC,CAAC;IAEH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,kBAAkB,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC;IACpE,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAC9B,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,iBAAiB;IAC5C,OAAO,IAAI,CAAC;AACd,CAAC"} \ No newline at end of file diff --git a/backend/dist/middleware/auth.d.ts b/backend/dist/middleware/auth.d.ts new file mode 100644 index 0000000000..d5a01c36b8 --- /dev/null +++ b/backend/dist/middleware/auth.d.ts @@ -0,0 +1,9 @@ +import { Request, Response, NextFunction } from "express"; +export interface AuthedRequest extends Request { + user: { + sub: string; + email: string; + }; +} +export declare function requireAuth(req: Request, res: Response, next: NextFunction): Response> | undefined; +//# sourceMappingURL=auth.d.ts.map \ No newline at end of file diff --git a/backend/dist/middleware/auth.d.ts.map b/backend/dist/middleware/auth.d.ts.map new file mode 100644 index 0000000000..203ad8f02f --- /dev/null +++ b/backend/dist/middleware/auth.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG1D,MAAM,WAAW,aAAc,SAAQ,OAAO;IAC5C,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CACtC;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,kDAkB1E"} \ No newline at end of file diff --git a/backend/dist/middleware/auth.js b/backend/dist/middleware/auth.js new file mode 100644 index 0000000000..42ded0341d --- /dev/null +++ b/backend/dist/middleware/auth.js @@ -0,0 +1,18 @@ +import jwt from "jsonwebtoken"; +export function requireAuth(req, res, next) { + try { + const header = req.headers.authorization; // "Bearer " + if (!header) + return res.status(401).json({ error: "NoAuthHeader" }); + const [type, token] = header.split(" "); + if (type !== "Bearer" || !token) + return res.status(401).json({ error: "InvalidAuthHeader" }); + const payload = jwt.verify(token, process.env.JWT_SECRET); + req.user = payload; // set strongly-typed user + next(); + } + catch { + return res.status(401).json({ error: "InvalidToken" }); + } +} +//# sourceMappingURL=auth.js.map \ No newline at end of file diff --git a/backend/dist/middleware/auth.js.map b/backend/dist/middleware/auth.js.map new file mode 100644 index 0000000000..7ff30636dc --- /dev/null +++ b/backend/dist/middleware/auth.js.map @@ -0,0 +1 @@ +{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"AACA,OAAO,GAAG,MAAM,cAAc,CAAC;AAM/B,MAAM,UAAU,WAAW,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IACzE,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,mBAAmB;QAC7D,IAAI,CAAC,MAAM;YAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;QAEpE,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACxC,IAAI,IAAI,KAAK,QAAQ,IAAI,CAAC,KAAK;YAC7B,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC,CAAC;QAE9D,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,GAAG,CAAC,UAAW,CAGxD,CAAC;QACD,GAAqB,CAAC,IAAI,GAAG,OAAO,CAAC,CAAC,0BAA0B;QACjE,IAAI,EAAE,CAAC;IACT,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;IACzD,CAAC;AACH,CAAC"} \ No newline at end of file diff --git a/backend/dist/models/Favorite.d.ts b/backend/dist/models/Favorite.d.ts new file mode 100644 index 0000000000..a784f902d4 --- /dev/null +++ b/backend/dist/models/Favorite.d.ts @@ -0,0 +1,38 @@ +import mongoose, { InferSchemaType, Model } from "mongoose"; +declare const favoriteSchema: mongoose.Schema, {}, {}, {}, {}, { + timestamps: true; +}, { + createdAt: NativeDate; + updatedAt: NativeDate; +} & { + userId: mongoose.Types.ObjectId; + beachId: string; + note: string; + order: number; +}, mongoose.Document, {}, mongoose.ResolveSchemaOptions<{ + timestamps: true; +}>> & mongoose.FlatRecord<{ + createdAt: NativeDate; + updatedAt: NativeDate; +} & { + userId: mongoose.Types.ObjectId; + beachId: string; + note: string; + order: number; +}> & { + _id: mongoose.Types.ObjectId; +} & { + __v: number; +}>; +export type FavoriteDoc = InferSchemaType; +export declare const Favorite: Model; +export {}; +//# sourceMappingURL=Favorite.d.ts.map \ No newline at end of file diff --git a/backend/dist/models/Favorite.d.ts.map b/backend/dist/models/Favorite.d.ts.map new file mode 100644 index 0000000000..c1a7659a51 --- /dev/null +++ b/backend/dist/models/Favorite.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"Favorite.d.ts","sourceRoot":"","sources":["../../src/models/Favorite.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,EAAE,EAAiB,eAAe,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAE3E,QAAA,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgBnB,CAAC;AAKF,MAAM,MAAM,WAAW,GAAG,eAAe,CAAC,OAAO,cAAc,CAAC,CAAC;AAEjE,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,WAAW,CAEQ,CAAC"} \ No newline at end of file diff --git a/backend/dist/models/Favorite.js b/backend/dist/models/Favorite.js new file mode 100644 index 0000000000..49447408e0 --- /dev/null +++ b/backend/dist/models/Favorite.js @@ -0,0 +1,20 @@ +import mongoose, { Schema, model } from "mongoose"; +const favoriteSchema = new Schema({ + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + // HaV beach identifier (string) + beachId: { type: String, required: true, index: true }, + // optional user note + note: { type: String, default: "" }, + // for drag & drop ordering (lower = earlier) + order: { type: Number, default: 0 }, +}, { timestamps: true }); +// Prevent duplicates: one user can favorite each beach only once +favoriteSchema.index({ userId: 1, beachId: 1 }, { unique: true }); +export const Favorite = mongoose.models.Favorite ?? + model("Favorite", favoriteSchema); +//# sourceMappingURL=Favorite.js.map \ No newline at end of file diff --git a/backend/dist/models/Favorite.js.map b/backend/dist/models/Favorite.js.map new file mode 100644 index 0000000000..2b3a654f5f --- /dev/null +++ b/backend/dist/models/Favorite.js.map @@ -0,0 +1 @@ +{"version":3,"file":"Favorite.js","sourceRoot":"","sources":["../../src/models/Favorite.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,EAAE,EAAE,MAAM,EAAE,KAAK,EAA0B,MAAM,UAAU,CAAC;AAE3E,MAAM,cAAc,GAAG,IAAI,MAAM,CAC/B;IACE,MAAM,EAAE;QACN,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,QAAQ;QAC3B,GAAG,EAAE,MAAM;QACX,QAAQ,EAAE,IAAI;QACd,KAAK,EAAE,IAAI;KACZ;IACD,gCAAgC;IAChC,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACtD,qBAAqB;IACrB,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;IACnC,6CAA6C;IAC7C,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE;CACpC,EACD,EAAE,UAAU,EAAE,IAAI,EAAE,CACrB,CAAC;AAEF,iEAAiE;AACjE,cAAc,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AAIlE,MAAM,CAAC,MAAM,QAAQ,GAClB,QAAQ,CAAC,MAAM,CAAC,QAA+B;IAChD,KAAK,CAAc,UAAU,EAAE,cAAc,CAAC,CAAC"} \ No newline at end of file diff --git a/backend/dist/models/User.d.ts b/backend/dist/models/User.d.ts new file mode 100644 index 0000000000..17f211810f --- /dev/null +++ b/backend/dist/models/User.d.ts @@ -0,0 +1,32 @@ +import mongoose, { InferSchemaType, Model } from "mongoose"; +declare const userSchema: mongoose.Schema, {}, {}, {}, {}, { + timestamps: true; +}, { + createdAt: NativeDate; + updatedAt: NativeDate; +} & { + email: string; + passwordHash: string; +}, mongoose.Document, {}, mongoose.ResolveSchemaOptions<{ + timestamps: true; +}>> & mongoose.FlatRecord<{ + createdAt: NativeDate; + updatedAt: NativeDate; +} & { + email: string; + passwordHash: string; +}> & { + _id: mongoose.Types.ObjectId; +} & { + __v: number; +}>; +export type UserDoc = InferSchemaType; +export declare const User: Model; +export {}; +//# sourceMappingURL=User.d.ts.map \ No newline at end of file diff --git a/backend/dist/models/User.d.ts.map b/backend/dist/models/User.d.ts.map new file mode 100644 index 0000000000..a1dab3db69 --- /dev/null +++ b/backend/dist/models/User.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"User.d.ts","sourceRoot":"","sources":["../../src/models/User.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,EAAE,EAAiB,eAAe,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAE3E,QAAA,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;EAMf,CAAC;AAGF,MAAM,MAAM,OAAO,GAAG,eAAe,CAAC,OAAO,UAAU,CAAC,CAAC;AAGzD,eAAO,MAAM,IAAI,EAAE,KAAK,CAAC,OAAO,CAEI,CAAC"} \ No newline at end of file diff --git a/backend/dist/models/User.js b/backend/dist/models/User.js new file mode 100644 index 0000000000..62327434e7 --- /dev/null +++ b/backend/dist/models/User.js @@ -0,0 +1,9 @@ +import mongoose, { Schema, model } from "mongoose"; +const userSchema = new Schema({ + email: { type: String, unique: true, required: true, index: true }, + passwordHash: { type: String, required: true }, +}, { timestamps: true }); +// Always return a single, strongly-typed Model (no union) +export const User = mongoose.models.User ?? + model("User", userSchema); +//# sourceMappingURL=User.js.map \ No newline at end of file diff --git a/backend/dist/models/User.js.map b/backend/dist/models/User.js.map new file mode 100644 index 0000000000..fdef33291e --- /dev/null +++ b/backend/dist/models/User.js.map @@ -0,0 +1 @@ +{"version":3,"file":"User.js","sourceRoot":"","sources":["../../src/models/User.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,EAAE,EAAE,MAAM,EAAE,KAAK,EAA0B,MAAM,UAAU,CAAC;AAE3E,MAAM,UAAU,GAAG,IAAI,MAAM,CAC3B;IACE,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IAClE,YAAY,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;CAC/C,EACD,EAAE,UAAU,EAAE,IAAI,EAAE,CACrB,CAAC;AAKF,mEAAmE;AACnE,MAAM,CAAC,MAAM,IAAI,GACd,QAAQ,CAAC,MAAM,CAAC,IAAuB;IACxC,KAAK,CAAU,MAAM,EAAE,UAAU,CAAC,CAAC"} \ No newline at end of file diff --git a/backend/dist/routes/auth.d.ts b/backend/dist/routes/auth.d.ts new file mode 100644 index 0000000000..c8cf2855d0 --- /dev/null +++ b/backend/dist/routes/auth.d.ts @@ -0,0 +1,2 @@ +export declare const authRouter: import("express-serve-static-core").Router; +//# sourceMappingURL=auth.d.ts.map \ No newline at end of file diff --git a/backend/dist/routes/auth.d.ts.map b/backend/dist/routes/auth.d.ts.map new file mode 100644 index 0000000000..4a2c07e384 --- /dev/null +++ b/backend/dist/routes/auth.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,UAAU,4CAAW,CAAC"} \ No newline at end of file diff --git a/backend/dist/routes/auth.js b/backend/dist/routes/auth.js new file mode 100644 index 0000000000..a1aa7c09e9 --- /dev/null +++ b/backend/dist/routes/auth.js @@ -0,0 +1,59 @@ +import { Router } from "express"; +import { z } from "zod"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import { User } from "../models/User.js"; +import { connectDB } from "../lib/db.js"; +export const authRouter = Router(); +authRouter.get("/ping", (_req, res) => res.json({ ok: true, from: "auth" })); +const zCreds = z.object({ + email: z.string().email(), + password: z.string().min(8, "Password must be at least 8 characters"), +}); +// POST /api/auth/register +authRouter.post("/register", async (req, res) => { + try { + await connectDB(); + const parsed = zCreds.safeParse(req.body); + if (!parsed.success) + return res + .status(400) + .json({ error: "InvalidBody", details: parsed.error.flatten() }); + const { email, password } = parsed.data; + const exists = await User.findOne({ email }); + if (exists) + return res.status(409).json({ error: "EmailInUse" }); + const passwordHash = await bcrypt.hash(password, 12); + await User.create({ email, passwordHash }); + return res.status(201).json({ ok: true }); + } + catch (err) { + console.error("REGISTER_ERR", err); + return res.status(500).json({ error: "InternalServerError" }); + } +}); +// POST /api/auth/login +authRouter.post("/login", async (req, res) => { + try { + await connectDB(); + const parsed = zCreds.safeParse(req.body); + if (!parsed.success) + return res + .status(400) + .json({ error: "InvalidBody", details: parsed.error.flatten() }); + const { email, password } = parsed.data; + const user = await User.findOne({ email }); + if (!user) + return res.status(401).json({ error: "InvalidCredentials" }); + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) + return res.status(401).json({ error: "InvalidCredentials" }); + const token = jwt.sign({ sub: String(user._id), email }, process.env.JWT_SECRET, { expiresIn: "7d" }); + return res.json({ token }); + } + catch (err) { + console.error("LOGIN_ERR", err); + return res.status(500).json({ error: "InternalServerError" }); + } +}); +//# sourceMappingURL=auth.js.map \ No newline at end of file diff --git a/backend/dist/routes/auth.js.map b/backend/dist/routes/auth.js.map new file mode 100644 index 0000000000..b0a26bb65c --- /dev/null +++ b/backend/dist/routes/auth.js.map @@ -0,0 +1 @@ +{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,MAAM,MAAM,UAAU,CAAC;AAC9B,OAAO,GAAG,MAAM,cAAc,CAAC;AAC/B,OAAO,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzC,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC;AAEnC,UAAU,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;AAE7E,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC;IACtB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;IACzB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,wCAAwC,CAAC;CACtE,CAAC,CAAC;AAEH,0BAA0B;AAC1B,UAAU,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;IAC9C,IAAI,CAAC;QACH,MAAM,SAAS,EAAE,CAAC;QAElB,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC1C,IAAI,CAAC,MAAM,CAAC,OAAO;YACjB,OAAO,GAAG;iBACP,MAAM,CAAC,GAAG,CAAC;iBACX,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QAErE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC;QAExC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;QAC7C,IAAI,MAAM;YAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,YAAY,EAAE,CAAC,CAAC;QAEjE,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QACrD,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,YAAY,EAAE,CAAC,CAAC;QAE3C,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;QACnC,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,CAAC,CAAC;IAChE,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,uBAAuB;AACvB,UAAU,CAAC,IAAI,CAAC,QAAQ,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3C,IAAI,CAAC;QACH,MAAM,SAAS,EAAE,CAAC;QAElB,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC1C,IAAI,CAAC,MAAM,CAAC,OAAO;YACjB,OAAO,GAAG;iBACP,MAAM,CAAC,GAAG,CAAC;iBACX,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QAErE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC;QAExC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;QAC3C,IAAI,CAAC,IAAI;YAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC,CAAC;QAExE,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;QAC7D,IAAI,CAAC,EAAE;YAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC,CAAC;QAEtE,MAAM,KAAK,GAAG,GAAG,CAAC,IAAI,CACpB,EAAE,GAAG,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,EAChC,OAAO,CAAC,GAAG,CAAC,UAAW,EACvB,EAAE,SAAS,EAAE,IAAI,EAAE,CACpB,CAAC;QACF,OAAO,GAAG,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC;QAChC,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,CAAC,CAAC;IAChE,CAAC;AACH,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/backend/dist/routes/beaches.d.ts b/backend/dist/routes/beaches.d.ts new file mode 100644 index 0000000000..b0f8f18537 --- /dev/null +++ b/backend/dist/routes/beaches.d.ts @@ -0,0 +1,2 @@ +export declare const beachesRouter: import("express-serve-static-core").Router; +//# sourceMappingURL=beaches.d.ts.map \ No newline at end of file diff --git a/backend/dist/routes/beaches.d.ts.map b/backend/dist/routes/beaches.d.ts.map new file mode 100644 index 0000000000..9da2bf7acc --- /dev/null +++ b/backend/dist/routes/beaches.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"beaches.d.ts","sourceRoot":"","sources":["../../src/routes/beaches.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,aAAa,4CAAW,CAAC"} \ No newline at end of file diff --git a/backend/dist/routes/beaches.js b/backend/dist/routes/beaches.js new file mode 100644 index 0000000000..387774b1c7 --- /dev/null +++ b/backend/dist/routes/beaches.js @@ -0,0 +1,49 @@ +import { Router } from "express"; +import { havGet, getLatestSampleDate } from "../lib/hav.js"; +export const beachesRouter = Router(); +/** + * GET /api/beaches + * Proxies HaV "feature" endpoint and returns (likely) GeoJSON. + */ +beachesRouter.get("/beaches", async (_req, res, next) => { + try { + const data = await havGet("/feature/?format=json", 5 * 60 * 1000); // cache 5 min + res.json(data); + } + catch (err) { + next(err); + } +}); +/** + * GET /api/beaches/:id + * Proxies HaV v1 detail and augments with latestSampleDate. + * Prefers v1.sampleDate (epoch ms); falls back to v2 results when needed. + */ +beachesRouter.get("/beaches/:id", async (req, res, next) => { + try { + const { id } = req.params; + // v1 detail + const detail = await havGet(`/detail/${encodeURIComponent(id)}`, 5 * 60 * 1000); + // prefer v1 sampleDate (epoch ms) + let latestSampleDate = null; + const v1Ms = detail?.sampleDate; + if (typeof v1Ms === "number" && isFinite(v1Ms)) { + latestSampleDate = new Date(v1Ms).toISOString(); + } + else { + // fallback to v2 + try { + latestSampleDate = await getLatestSampleDate(id); + } + catch (e) { + // don't fail the whole request if v2 errors; just leave null + console.warn("[/beaches/:id] v2 fallback failed:", e?.message); + } + } + res.json({ ...detail, latestSampleDate }); + } + catch (err) { + next(err); + } +}); +//# sourceMappingURL=beaches.js.map \ No newline at end of file diff --git a/backend/dist/routes/beaches.js.map b/backend/dist/routes/beaches.js.map new file mode 100644 index 0000000000..7631156e61 --- /dev/null +++ b/backend/dist/routes/beaches.js.map @@ -0,0 +1 @@ +{"version":3,"file":"beaches.js","sourceRoot":"","sources":["../../src/routes/beaches.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAE5D,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,EAAE,CAAC;AAEtC;;;GAGG;AACH,aAAa,CAAC,GAAG,CAAC,UAAU,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACtD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,uBAAuB,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,cAAc;QACjF,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC;AAEH;;;;GAIG;AACH,aAAa,CAAC,GAAG,CAAC,cAAc,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACzD,IAAI,CAAC;QACH,MAAM,EAAE,EAAE,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC;QAE1B,YAAY;QACZ,MAAM,MAAM,GAAG,MAAM,MAAM,CACzB,WAAW,kBAAkB,CAAC,EAAE,CAAC,EAAE,EACnC,CAAC,GAAG,EAAE,GAAG,IAAI,CACd,CAAC;QAEF,kCAAkC;QAClC,IAAI,gBAAgB,GAAkB,IAAI,CAAC;QAC3C,MAAM,IAAI,GAAI,MAAc,EAAE,UAAU,CAAC;QACzC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/C,gBAAgB,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC;QAClD,CAAC;aAAM,CAAC;YACN,iBAAiB;YACjB,IAAI,CAAC;gBACH,gBAAgB,GAAG,MAAM,mBAAmB,CAAC,EAAE,CAAC,CAAC;YACnD,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,6DAA6D;gBAC7D,OAAO,CAAC,IAAI,CACV,oCAAoC,EACnC,CAAW,EAAE,OAAO,CACtB,CAAC;YACJ,CAAC;QACH,CAAC;QAED,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;IAC5C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/backend/dist/routes/dbCheck.d.ts b/backend/dist/routes/dbCheck.d.ts new file mode 100644 index 0000000000..a5ff42650d --- /dev/null +++ b/backend/dist/routes/dbCheck.d.ts @@ -0,0 +1,2 @@ +export declare const dbCheckRouter: import("express-serve-static-core").Router; +//# sourceMappingURL=dbCheck.d.ts.map \ No newline at end of file diff --git a/backend/dist/routes/dbCheck.d.ts.map b/backend/dist/routes/dbCheck.d.ts.map new file mode 100644 index 0000000000..5134bd8370 --- /dev/null +++ b/backend/dist/routes/dbCheck.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"dbCheck.d.ts","sourceRoot":"","sources":["../../src/routes/dbCheck.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,aAAa,4CAAW,CAAC"} \ No newline at end of file diff --git a/backend/dist/routes/dbCheck.js b/backend/dist/routes/dbCheck.js new file mode 100644 index 0000000000..f6c3041af6 --- /dev/null +++ b/backend/dist/routes/dbCheck.js @@ -0,0 +1,27 @@ +import { Router } from "express"; +import mongoose from "mongoose"; +import { connectDB } from "../lib/db.js"; +export const dbCheckRouter = Router(); +dbCheckRouter.get("/db-check", async (_req, res) => { + try { + // Ensure we attempt a connection before reporting + await connectDB(); + const state = mongoose.connection.readyState; // 0=disconnected, 1=connected, 2=connecting, 3=disconnecting + res.json({ + ok: state === 1, + state, + message: state === 1 + ? "MongoDB is connected" + : state === 2 + ? "MongoDB is connecting..." + : state === 3 + ? "MongoDB is disconnecting..." + : "MongoDB is disconnected", + }); + } + catch (err) { + console.error("DB check error:", err); + res.status(500).json({ ok: false, error: "DB check failed" }); + } +}); +//# sourceMappingURL=dbCheck.js.map \ No newline at end of file diff --git a/backend/dist/routes/dbCheck.js.map b/backend/dist/routes/dbCheck.js.map new file mode 100644 index 0000000000..539bdc8393 --- /dev/null +++ b/backend/dist/routes/dbCheck.js.map @@ -0,0 +1 @@ +{"version":3,"file":"dbCheck.js","sourceRoot":"","sources":["../../src/routes/dbCheck.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqB,MAAM,SAAS,CAAC;AACpD,OAAO,QAAQ,MAAM,UAAU,CAAC;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzC,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,EAAE,CAAC;AAEtC,aAAa,CAAC,GAAG,CAAC,WAAW,EAAE,KAAK,EAAE,IAAa,EAAE,GAAa,EAAE,EAAE;IACpE,IAAI,CAAC;QACH,kDAAkD;QAClD,MAAM,SAAS,EAAE,CAAC;QAClB,MAAM,KAAK,GAAG,QAAQ,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,6DAA6D;QAE3G,GAAG,CAAC,IAAI,CAAC;YACP,EAAE,EAAE,KAAK,KAAK,CAAC;YACf,KAAK;YACL,OAAO,EACL,KAAK,KAAK,CAAC;gBACT,CAAC,CAAC,sBAAsB;gBACxB,CAAC,CAAC,KAAK,KAAK,CAAC;oBACb,CAAC,CAAC,0BAA0B;oBAC5B,CAAC,CAAC,KAAK,KAAK,CAAC;wBACb,CAAC,CAAC,6BAA6B;wBAC/B,CAAC,CAAC,yBAAyB;SAChC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,iBAAiB,EAAE,GAAG,CAAC,CAAC;QACtC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,iBAAiB,EAAE,CAAC,CAAC;IAChE,CAAC;AACH,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/backend/dist/routes/favorites.d.ts b/backend/dist/routes/favorites.d.ts new file mode 100644 index 0000000000..7a5b8c589d --- /dev/null +++ b/backend/dist/routes/favorites.d.ts @@ -0,0 +1,2 @@ +export declare const favoritesRouter: import("express-serve-static-core").Router; +//# sourceMappingURL=favorites.d.ts.map \ No newline at end of file diff --git a/backend/dist/routes/favorites.d.ts.map b/backend/dist/routes/favorites.d.ts.map new file mode 100644 index 0000000000..0cd13007e2 --- /dev/null +++ b/backend/dist/routes/favorites.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"favorites.d.ts","sourceRoot":"","sources":["../../src/routes/favorites.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,eAAe,4CAAW,CAAC"} \ No newline at end of file diff --git a/backend/dist/routes/favorites.js b/backend/dist/routes/favorites.js new file mode 100644 index 0000000000..0d24b4a957 --- /dev/null +++ b/backend/dist/routes/favorites.js @@ -0,0 +1,146 @@ +import { Router } from "express"; +import { z } from "zod"; +import { Favorite } from "../models/Favorite.js"; +import { requireAuth } from "../middleware/auth.js"; +import { connectDB } from "../lib/db.js"; +import mongoose from "mongoose"; +export const favoritesRouter = Router(); +const zCreate = z.object({ + beachId: z.string().min(1), + note: z.string().max(500).optional(), +}); +// Ensure DB connection for every favorites op +favoritesRouter.use(async (_req, _res, next) => { + try { + await connectDB(); + next(); + } + catch (e) { + next(e); + } +}); +/** + * GET /api/favorites + * List current user's favorites (stable order) + */ +favoritesRouter.get("/favorites", requireAuth, async (req, res) => { + const userId = req.user.sub; + const items = await Favorite.find({ userId }) + .sort({ order: 1, updatedAt: 1, _id: 1 }) // stable sort + .lean(); + res.json(items); +}); +/** + * POST /api/favorites + */ +favoritesRouter.post("/favorites", requireAuth, async (req, res) => { + const parsed = zCreate.safeParse(req.body); + if (!parsed.success) { + // keep the existing error style + // @ts-ignore - z.treeifyError is available in the codebase + return res + .status(400) + .json({ error: "InvalidBody", details: z.treeifyError(parsed.error) }); + } + const { beachId, note } = parsed.data; + const userId = req.user.sub; + try { + const created = await Favorite.create({ + userId, + beachId, + note: note ?? "", + }); + return res.status(201).json(created); + } + catch (err) { + if (err?.code === 11000) { + return res.status(409).json({ error: "AlreadyFavorited" }); + } + throw err; + } +}); +/** + * DELETE /api/favorites/:id + */ +favoritesRouter.delete("/favorites/:id", requireAuth, async (req, res) => { + const userId = req.user.sub; + const id = req.params.id; + if (!id) + return res.status(400).json({ error: "MissingId" }); + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: "InvalidId" }); + } + const deleted = await Favorite.findOneAndDelete({ _id: id, userId }); + if (!deleted) + return res.status(404).json({ error: "NotFound" }); + res.json({ ok: true }); +}); +/** + * DELETE /api/favorites/by-beach/:beachId + */ +favoritesRouter.delete("/favorites/by-beach/:beachId", requireAuth, async (req, res) => { + const userId = req.user.sub; + const { beachId } = req.params; + const deleted = await Favorite.findOneAndDelete({ beachId, userId }); + if (!deleted) + return res.status(404).json({ error: "NotFound" }); + res.json({ ok: true }); +}); +/** + * PATCH /api/favorites/reorder + * Persist custom order. Accepts: { order: string[] } where each string is a beachId. + * Non-owned or unknown ids are ignored. Any favorites not included are appended after. + */ +favoritesRouter.patch("/favorites/reorder", requireAuth, async (req, res) => { + try { + const userId = req.user.sub; + const schema = z.object({ + order: z.array(z.string()).min(0), + }); + const parsed = schema.safeParse(req.body); + if (!parsed.success) { + return res + .status(400) + .json({ error: "InvalidBody", details: parsed.error.flatten() }); + } + // Deduplicate ids while preserving first occurrence order + const seen = new Set(); + const order = parsed.data.order.filter((id) => { + if (seen.has(id)) + return false; + seen.add(id); + return true; + }); + // Fetch user's current favorites + const favs = await Favorite.find({ userId }, { beachId: 1 }).lean(); + const currentIds = new Set(favs.map((f) => f.beachId)); + // Keep only valid (owned) ids, preserve payload order + const sanitized = order.filter((id) => currentIds.has(id)); + // Bulk update for provided ids + const ops = sanitized.map((beachId, idx) => ({ + updateOne: { + filter: { userId, beachId }, + update: { $set: { order: idx } }, + }, + })); + // Append any missing favorites not included in payload + const missing = favs + .map((f) => f.beachId) + .filter((id) => !sanitized.includes(id)); + ops.push(...missing.map((beachId, addIdx) => ({ + updateOne: { + filter: { userId, beachId }, + update: { $set: { order: sanitized.length + addIdx } }, + }, + }))); + if (ops.length > 0) { + await Favorite.bulkWrite(ops, { ordered: false }); + } + return res.status(204).end(); + } + catch (err) { + console.error("REORDER_ERR", err); + return res.status(500).json({ error: "InternalServerError" }); + } +}); +//# sourceMappingURL=favorites.js.map \ No newline at end of file diff --git a/backend/dist/routes/favorites.js.map b/backend/dist/routes/favorites.js.map new file mode 100644 index 0000000000..24bc69e212 --- /dev/null +++ b/backend/dist/routes/favorites.js.map @@ -0,0 +1 @@ +{"version":3,"file":"favorites.js","sourceRoot":"","sources":["../../src/routes/favorites.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,WAAW,EAAiB,MAAM,uBAAuB,CAAC;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,QAAQ,MAAM,UAAU,CAAC;AAGhC,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,EAAE,CAAC;AAExC,MAAM,OAAO,GAAG,CAAC,CAAC,MAAM,CAAC;IACvB,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC1B,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE;CACrC,CAAC,CAAC;AAEH,8CAA8C;AAC9C,eAAe,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE;IAC7C,IAAI,CAAC;QACH,MAAM,SAAS,EAAE,CAAC;QAClB,IAAI,EAAE,CAAC;IACT,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,IAAI,CAAC,CAAC,CAAC,CAAC;IACV,CAAC;AACH,CAAC,CAAC,CAAC;AAEH;;;GAGG;AACH,eAAe,CAAC,GAAG,CAAC,YAAY,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;IAChE,MAAM,MAAM,GAAI,GAAqB,CAAC,IAAI,CAAC,GAAG,CAAC;IAC/C,MAAM,KAAK,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;SAC1C,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,cAAc;SACvD,IAAI,EAAE,CAAC;IACV,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC;AAEH;;GAEG;AACH,eAAe,CAAC,IAAI,CAAC,YAAY,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;IACjE,MAAM,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,gCAAgC;QAChC,2DAA2D;QAC3D,OAAO,GAAG;aACP,MAAM,CAAC,GAAG,CAAC;aACX,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC,CAAC,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC3E,CAAC;IAED,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC;IACtC,MAAM,MAAM,GAAI,GAAqB,CAAC,IAAI,CAAC,GAAG,CAAC;IAE/C,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC;YACpC,MAAM;YACN,OAAO;YACP,IAAI,EAAE,IAAI,IAAI,EAAE;SACjB,CAAC,CAAC;QACH,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACvC,CAAC;IAAC,OAAO,GAAQ,EAAE,CAAC;QAClB,IAAI,GAAG,EAAE,IAAI,KAAK,KAAK,EAAE,CAAC;YACxB,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,kBAAkB,EAAE,CAAC,CAAC;QAC7D,CAAC;QACD,MAAM,GAAG,CAAC;IACZ,CAAC;AACH,CAAC,CAAC,CAAC;AAEH;;GAEG;AACH,eAAe,CAAC,MAAM,CAAC,gBAAgB,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;IACvE,MAAM,MAAM,GAAI,GAAqB,CAAC,IAAI,CAAC,GAAG,CAAC;IAE/C,MAAM,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,EAAwB,CAAC;IAC/C,IAAI,CAAC,EAAE;QAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;IAE7D,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;QACzC,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;IACtD,CAAC;IAED,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,gBAAgB,CAAC,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;IACrE,IAAI,CAAC,OAAO;QAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC;IACjE,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;AACzB,CAAC,CAAC,CAAC;AAEH;;GAEG;AACH,eAAe,CAAC,MAAM,CACpB,8BAA8B,EAC9B,WAAW,EACX,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;IACjB,MAAM,MAAM,GAAI,GAAqB,CAAC,IAAI,CAAC,GAAG,CAAC;IAC/C,MAAM,EAAE,OAAO,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC;IAE/B,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;IACrE,IAAI,CAAC,OAAO;QAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC;IACjE,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;AACzB,CAAC,CACF,CAAC;AAEF;;;;GAIG;AACH,eAAe,CAAC,KAAK,CAAC,oBAAoB,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;IAC1E,IAAI,CAAC;QACH,MAAM,MAAM,GAAI,GAAqB,CAAC,IAAI,CAAC,GAAG,CAAC;QAE/C,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC;YACtB,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;SAClC,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC1C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;YACpB,OAAO,GAAG;iBACP,MAAM,CAAC,GAAG,CAAC;iBACX,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QACrE,CAAC;QAED,0DAA0D;QAC1D,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;QAC/B,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE;YAC5C,IAAI,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBAAE,OAAO,KAAK,CAAC;YAC/B,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YACb,OAAO,IAAI,CAAC;QACd,CAAC,CAAC,CAAC;QAEH,iCAAiC;QACjC,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACpE,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;QAEvD,sDAAsD;QACtD,MAAM,SAAS,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QAE3D,+BAA+B;QAC/B,MAAM,GAAG,GAA4B,SAAS,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;YACpE,SAAS,EAAE;gBACT,MAAM,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE;gBAC3B,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE;aACjC;SACF,CAAC,CAAC,CAAC;QAEJ,uDAAuD;QACvD,MAAM,OAAO,GAAG,IAAI;aACjB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;aACrB,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC;QAE3C,GAAG,CAAC,IAAI,CACN,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;YACnC,SAAS,EAAE;gBACT,MAAM,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE;gBAC3B,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM,EAAE,EAAE;aACvD;SACF,CAAC,CAAC,CACJ,CAAC;QAEF,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACnB,MAAM,QAAQ,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;QACpD,CAAC;QAED,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;IAC/B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC;QAClC,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,CAAC,CAAC;IAChE,CAAC;AACH,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/backend/dist/routes/health.d.ts b/backend/dist/routes/health.d.ts new file mode 100644 index 0000000000..1f40d05b31 --- /dev/null +++ b/backend/dist/routes/health.d.ts @@ -0,0 +1,3 @@ +declare const router: import("express-serve-static-core").Router; +export { router as healthRouter }; +//# sourceMappingURL=health.d.ts.map \ No newline at end of file diff --git a/backend/dist/routes/health.d.ts.map b/backend/dist/routes/health.d.ts.map new file mode 100644 index 0000000000..a085479687 --- /dev/null +++ b/backend/dist/routes/health.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"health.d.ts","sourceRoot":"","sources":["../../src/routes/health.ts"],"names":[],"mappings":"AAEA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAWxB,OAAO,EAAE,MAAM,IAAI,YAAY,EAAE,CAAC"} \ No newline at end of file diff --git a/backend/dist/routes/health.js b/backend/dist/routes/health.js new file mode 100644 index 0000000000..67052ca2a5 --- /dev/null +++ b/backend/dist/routes/health.js @@ -0,0 +1,12 @@ +import { Router } from "express"; +const router = Router(); +router.get("/health", async (_req, res) => { + console.log("Hit /api/health"); + res.json({ + ok: true, + env: process.env.NODE_ENV ?? "development", + timestamp: new Date().toISOString(), + }); +}); +export { router as healthRouter }; +//# sourceMappingURL=health.js.map \ No newline at end of file diff --git a/backend/dist/routes/health.js.map b/backend/dist/routes/health.js.map new file mode 100644 index 0000000000..06d8444251 --- /dev/null +++ b/backend/dist/routes/health.js.map @@ -0,0 +1 @@ +{"version":3,"file":"health.js","sourceRoot":"","sources":["../../src/routes/health.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqB,MAAM,SAAS,CAAC;AAEpD,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC;AAExB,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,EAAE,IAAa,EAAE,GAAa,EAAE,EAAE;IAC3D,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAC/B,GAAG,CAAC,IAAI,CAAC;QACP,EAAE,EAAE,IAAI;QACR,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,aAAa;QAC1C,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACpC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,OAAO,EAAE,MAAM,IAAI,YAAY,EAAE,CAAC"} \ No newline at end of file diff --git a/backend/dist/server.d.ts b/backend/dist/server.d.ts new file mode 100644 index 0000000000..140913316c --- /dev/null +++ b/backend/dist/server.d.ts @@ -0,0 +1,2 @@ +import "dotenv/config"; +//# sourceMappingURL=server.d.ts.map \ No newline at end of file diff --git a/backend/dist/server.d.ts.map b/backend/dist/server.d.ts.map new file mode 100644 index 0000000000..46039523e5 --- /dev/null +++ b/backend/dist/server.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AACA,OAAO,eAAe,CAAC"} \ No newline at end of file diff --git a/backend/dist/server.js b/backend/dist/server.js new file mode 100644 index 0000000000..a15c22b2d3 --- /dev/null +++ b/backend/dist/server.js @@ -0,0 +1,19 @@ +// backend/src/server.ts +import "dotenv/config"; +import app from "./index.js"; +const port = Number(process.env.PORT) || 3000; +app.use((req, _res, next) => { + console.log(`[req] ${req.method} ${req.url}`); + next(); +}); +app.listen(port, () => { + console.log(`API listening on http://localhost:${port}`); + const allowed = (process.env.CORS_ORIGIN || process.env.ALLOWED_ORIGIN || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + console.log(allowed.length + ? `CORS allowed origins: ${allowed.join(", ")}` + : "CORS: no origins configured → permissive (dev)"); +}); +//# sourceMappingURL=server.js.map \ No newline at end of file diff --git a/backend/dist/server.js.map b/backend/dist/server.js.map new file mode 100644 index 0000000000..a96291bc2b --- /dev/null +++ b/backend/dist/server.js.map @@ -0,0 +1 @@ +{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,wBAAwB;AACxB,OAAO,eAAe,CAAC;AACvB,OAAO,GAAG,MAAM,YAAY,CAAC;AAE7B,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC;AAE9C,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE;IAC1B,OAAO,CAAC,GAAG,CAAC,SAAS,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;IAC9C,IAAI,EAAE,CAAC;AACT,CAAC,CAAC,CAAC;AAEH,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,qCAAqC,IAAI,EAAE,CAAC,CAAC;IACzD,MAAM,OAAO,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,EAAE,CAAC;SAC1E,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,OAAO,CAAC,CAAC;IACnB,OAAO,CAAC,GAAG,CACT,OAAO,CAAC,MAAM;QACZ,CAAC,CAAC,yBAAyB,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;QAC/C,CAAC,CAAC,gDAAgD,CACrD,CAAC;AACJ,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f2448..4423c9a289 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,20 +1,32 @@ { - "name": "project-final-backend", + "name": "bada-backend", "version": "1.0.0", - "description": "Server part of final project", + "type": "module", "scripts": { - "start": "babel-node server.js", - "dev": "nodemon server.js --exec babel-node" + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js" }, - "author": "", - "license": "ISC", "dependencies": { - "@babel/core": "^7.17.9", - "@babel/node": "^7.16.8", - "@babel/preset-env": "^7.16.11", + "bcryptjs": "^3.0.2", "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.4.0", - "nodemon": "^3.0.1" + "dotenv": "^17.2.2", + "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.18.1", + "swagger-ui-express": "^5.0.1", + "zod": "^4.1.5" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^20.19.13", + "ts-node-dev": "^2.0.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=18" } -} \ No newline at end of file +} diff --git a/backend/server.js b/backend/server.js deleted file mode 100644 index 070c875189..0000000000 --- a/backend/server.js +++ /dev/null @@ -1,22 +0,0 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; - -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; - -const port = process.env.PORT || 8080; -const app = express(); - -app.use(cors()); -app.use(express.json()); - -app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); - -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000000..b17698c4de --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,94 @@ +// backend/src/index.ts +console.log("Server booted at", new Date().toISOString()); + +import express from "express"; +import cors from "cors"; + +import { healthRouter } from "./routes/health.js"; +import { dbCheckRouter } from "./routes/dbCheck.js"; +import { authRouter } from "./routes/auth.js"; +import { requireAuth } from "./middleware/auth.js"; +import { favoritesRouter } from "./routes/favorites.js"; +import { beachesRouter } from "./routes/beaches.js"; + +const app = express(); + +/** ── CORS (allow comma-separated origins via CORS_ORIGIN or ALLOWED_ORIGIN) ── */ +const allowedOrigins = ( + process.env.CORS_ORIGIN || + process.env.ALLOWED_ORIGIN || + "" +) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +const corsOptions: cors.CorsOptions = { + origin(origin, cb) { + if (!origin) return cb(null, true); // non-browser clients + if (allowedOrigins.length === 0) return cb(null, true); // dev fallback + if (allowedOrigins.includes(origin)) return cb(null, true); + return cb(new Error("Not allowed by CORS")); + }, + credentials: true, + methods: "GET,HEAD,PUT,PATCH,POST,DELETE", +}; + +app.use(cors(corsOptions)); +app.use(express.json()); + +/** ── TEMP DIAG: env presence (put this BEFORE the 404) ───────────────────── */ +app.get("/api/debug/env", (_req, res) => { + res.json({ + HAV_BASE_URL: !!process.env.HAV_BASE_URL, + HAV_USER_AGENT: !!process.env.HAV_USER_AGENT, + HAV_V2_BASE: !!process.env.HAV_V2_BASE, + ALLOWED_ORIGIN: process.env.ALLOWED_ORIGIN ?? "", + CORS_ORIGIN: process.env.CORS_ORIGIN ?? "", + MONGODB_URI_present: !!process.env.MONGODB_URI, + JWT_SECRET_present: !!process.env.JWT_SECRET, + NODE_ENV: process.env.NODE_ENV ?? "undefined", + PORT: process.env.PORT ?? "undefined", + }); +}); + +/** ── Public routers under /api ───────────────────────────────────────────── */ +app.use("/api", healthRouter); +app.use("/api", dbCheckRouter); +app.use("/api", favoritesRouter); +app.use("/api", beachesRouter); + +/** ── Auth ───────────────────────────────────────────────────────────────── */ +app.use("/api/auth", authRouter); +app.get("/api/protected/ping", requireAuth, (req, res) => { + res.json({ ok: true, user: (req as any).user }); +}); +app.get("/api/auth/me", requireAuth, (req, res) => { + res.json({ user: (req as any).user }); +}); + +/** ── Quick direct health ping ────────────────────────────────────────────── */ +app.get("/api/health-direct", (_req, res) => { + console.log("Hit /api/health-direct"); + res.json({ ok: true, via: "direct" }); +}); + +/** ── 404 + error handler (MUST be last) ──────────────────────────────────── */ +app.use((_req, res) => res.status(404).json({ error: "NotFound" })); +app.use( + ( + err: unknown, + _req: express.Request, + res: express.Response, + _next: express.NextFunction + ) => { + console.error("[ERROR]", err); + const message = + process.env.NODE_ENV !== "production" && err instanceof Error + ? err.message + : undefined; + res.status(500).json({ error: "InternalServerError", message }); + } +); + +export default app; diff --git a/backend/src/lib/cache.ts b/backend/src/lib/cache.ts new file mode 100644 index 0000000000..adf5f140e6 --- /dev/null +++ b/backend/src/lib/cache.ts @@ -0,0 +1,23 @@ +type Entry = { value: T; expiry: number }; + +export class SimpleCache { + private store = new Map>(); + + constructor(private defaultTtlMs = 60_000) {} // default 60s + + get(key: string): T | undefined { + const hit = this.store.get(key); + if (!hit) return; + if (Date.now() > hit.expiry) { + this.store.delete(key); + return; + } + return hit.value as T; + } + + set(key: string, value: T, ttlMs = this.defaultTtlMs) { + this.store.set(key, { value, expiry: Date.now() + ttlMs }); + } +} + +export const cache = new SimpleCache(60_000); // 60s default diff --git a/backend/src/lib/db.ts b/backend/src/lib/db.ts new file mode 100644 index 0000000000..b56d3e7942 --- /dev/null +++ b/backend/src/lib/db.ts @@ -0,0 +1,49 @@ +import mongoose from "mongoose"; + +// Cache the connection across hot-reloads / serverless invocations +type Cached = { + conn: typeof mongoose | null; + promise: Promise | null; +}; +let cached: Cached = (global as any)._mongooseCached ?? { + conn: null, + promise: null, +}; +(global as any)._mongooseCached = cached; + +export async function connectDB() { + if (cached.conn) return cached.conn; + + if (!cached.promise) { + const uri = process.env.MONGODB_URI; + if (!uri) throw new Error("MONGODB_URI missing"); + + mongoose.set("strictQuery", true); + mongoose.set("bufferCommands", false); + + cached.promise = mongoose + .connect(uri, { serverSelectionTimeoutMS: 5000 }) + .then((m) => { + cached.conn = m; + + // 🔮 Optional debug hooks (remove later) + mongoose.connection.on("connected", () => + console.log("[mongo] connected") + ); + mongoose.connection.on("error", (e) => + console.error("[mongo] error", e) + ); + mongoose.connection.on("disconnected", () => + console.warn("[mongo] disconnected") + ); + + return m; + }) + .catch((err) => { + cached.promise = null; + throw err; + }); + } + + return cached.promise; +} diff --git a/backend/src/lib/hav.ts b/backend/src/lib/hav.ts new file mode 100644 index 0000000000..23d51c7489 --- /dev/null +++ b/backend/src/lib/hav.ts @@ -0,0 +1,97 @@ +import { cache } from "./cache.js"; + +const HAV_BASE_URL = process.env.HAV_BASE_URL!; +const HAV_USER_AGENT = process.env.HAV_USER_AGENT || "BADA/1.0"; +const HAV_V2_BASE = + process.env.HAV_V2_BASE ?? "https://api.havochvatten.se/bathingwaters/v2"; + +function key(path: string) { + return `hav:${path}`; +} + +/** v1 fetcher (old endpoints like /feature, /detail) */ +export async function havGet(path: string, ttlMs = 5 * 60 * 1000) { + const k = key(`v1:${path}`); + const cached = cache.get(k); + if (cached) return cached; + + const url = `${HAV_BASE_URL}${path}`; + // TEMP: trace outgoing in dev + if (process.env.NODE_ENV !== "production") { + console.log(`[HaV v1] GET ${url}`); + } + + const res = await fetch(url, { + headers: { + "User-Agent": HAV_USER_AGENT, + Accept: "application/json", + }, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + console.warn( + `[HaV v1] ${res.status} ${res.statusText} for ${url}: ${text.slice( + 0, + 500 + )}` + ); + throw new Error(`HaV v1 error ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + cache.set(k, data, ttlMs); + return data; +} + +/** Minimal v2 fetcher (used for monitoring results, etc.) */ +export async function havV2Get( + path: string, + init?: RequestInit +): Promise { + const url = `${HAV_V2_BASE}${path}`; + if (process.env.NODE_ENV !== "production") { + console.log(`[HaV v2] GET ${url}`); + } + + const res = await fetch(url, { + headers: { Accept: "application/json" }, + ...init, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + console.warn( + `[HaV v2] ${res.status} ${res.statusText} for ${url}: ${text.slice( + 0, + 500 + )}` + ); + throw new Error(`HaV v2 error ${res.status} ${res.statusText}`); + } + + return res.json() as Promise; +} + +/** Pull latest sample date (ISO string) from /bathing-waters/{id}/results */ +export async function getLatestSampleDate(id: string): Promise { + const k = key(`v2:results:${id}`); + const cached = cache.get(k); + if (cached !== undefined) return cached; + + type MonitoringResult = { takenAt?: string | null }; + type Results = { results?: MonitoringResult[] }; + + const data = await havV2Get( + `/bathing-waters/${encodeURIComponent(id)}/results` + ); + + const latest = + (data.results ?? []) + .map((r) => r.takenAt) + .filter((d): d is string => !!d) + .sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))[0] ?? null; + + cache.set(k, latest, 5 * 60 * 1000); + return latest; +} diff --git a/backend/src/lib/havbackup.ts b/backend/src/lib/havbackup.ts new file mode 100644 index 0000000000..801c4045bb --- /dev/null +++ b/backend/src/lib/havbackup.ts @@ -0,0 +1,34 @@ +import { cache } from "./cache.js"; + +const HAV_BASE_URL = process.env.HAV_BASE_URL!; +const HAV_USER_AGENT = process.env.HAV_USER_AGENT!; + +// Helper to build a cache key +function key(path: string) { + return `hav:${path}`; +} + +// Generic fetch wrapper with caching +export async function havGet(path: string, ttlMs = 5 * 60 * 1000) { + const k = key(path); + const cached = cache.get(k); + if (cached) { + return cached; // ✅ serve from memory if not expired + } + + const url = `${HAV_BASE_URL}${path}`; + const res = await fetch(url, { + headers: { + "User-Agent": HAV_USER_AGENT, + Accept: "application/json", + }, + }); + + if (!res.ok) { + throw new Error(`HaV API error: ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + cache.set(k, data, ttlMs); // ✅ cache result + return data; +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000000..6c0a21ba11 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,26 @@ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; + +export interface AuthedRequest extends Request { + user: { sub: string; email: string }; // ← non-optional +} + +export function requireAuth(req: Request, res: Response, next: NextFunction) { + try { + const header = req.headers.authorization; // "Bearer " + if (!header) return res.status(401).json({ error: "NoAuthHeader" }); + + const [type, token] = header.split(" "); + if (type !== "Bearer" || !token) + return res.status(401).json({ error: "InvalidAuthHeader" }); + + const payload = jwt.verify(token, process.env.JWT_SECRET!) as { + sub: string; + email: string; + }; + (req as AuthedRequest).user = payload; // set strongly-typed user + next(); + } catch { + return res.status(401).json({ error: "InvalidToken" }); + } +} diff --git a/backend/src/models/Favorite.ts b/backend/src/models/Favorite.ts new file mode 100644 index 0000000000..40b2162b6f --- /dev/null +++ b/backend/src/models/Favorite.ts @@ -0,0 +1,28 @@ +import mongoose, { Schema, model, InferSchemaType, Model } from "mongoose"; + +const favoriteSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + // HaV beach identifier (string) + beachId: { type: String, required: true, index: true }, + // optional user note + note: { type: String, default: "" }, + // for drag & drop ordering (lower = earlier) + order: { type: Number, default: 0 }, + }, + { timestamps: true } +); + +// Prevent duplicates: one user can favorite each beach only once +favoriteSchema.index({ userId: 1, beachId: 1 }, { unique: true }); + +export type FavoriteDoc = InferSchemaType; + +export const Favorite: Model = + (mongoose.models.Favorite as Model) ?? + model("Favorite", favoriteSchema); diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts new file mode 100644 index 0000000000..1e72380f84 --- /dev/null +++ b/backend/src/models/User.ts @@ -0,0 +1,17 @@ +import mongoose, { Schema, model, InferSchemaType, Model } from "mongoose"; + +const userSchema = new Schema( + { + email: { type: String, unique: true, required: true, index: true }, + passwordHash: { type: String, required: true }, + }, + { timestamps: true } +); + +// Infer the document shape from the schema +export type UserDoc = InferSchemaType; + +// Always return a single, strongly-typed Model (no union) +export const User: Model = + (mongoose.models.User as Model) ?? + model("User", userSchema); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000000..b2a0eacd61 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,72 @@ +import { Router } from "express"; +import { z } from "zod"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import { User } from "../models/User.js"; +import { connectDB } from "../lib/db.js"; + +export const authRouter = Router(); + +authRouter.get("/ping", (_req, res) => res.json({ ok: true, from: "auth" })); + +const zCreds = z.object({ + email: z.string().email(), + password: z.string().min(8, "Password must be at least 8 characters"), +}); + +// POST /api/auth/register +authRouter.post("/register", async (req, res) => { + try { + await connectDB(); + + const parsed = zCreds.safeParse(req.body); + if (!parsed.success) + return res + .status(400) + .json({ error: "InvalidBody", details: parsed.error.flatten() }); + + const { email, password } = parsed.data; + + const exists = await User.findOne({ email }); + if (exists) return res.status(409).json({ error: "EmailInUse" }); + + const passwordHash = await bcrypt.hash(password, 12); + await User.create({ email, passwordHash }); + + return res.status(201).json({ ok: true }); + } catch (err) { + console.error("REGISTER_ERR", err); + return res.status(500).json({ error: "InternalServerError" }); + } +}); + +// POST /api/auth/login +authRouter.post("/login", async (req, res) => { + try { + await connectDB(); + + const parsed = zCreds.safeParse(req.body); + if (!parsed.success) + return res + .status(400) + .json({ error: "InvalidBody", details: parsed.error.flatten() }); + + const { email, password } = parsed.data; + + const user = await User.findOne({ email }); + if (!user) return res.status(401).json({ error: "InvalidCredentials" }); + + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) return res.status(401).json({ error: "InvalidCredentials" }); + + const token = jwt.sign( + { sub: String(user._id), email }, + process.env.JWT_SECRET!, + { expiresIn: "7d" } + ); + return res.json({ token }); + } catch (err) { + console.error("LOGIN_ERR", err); + return res.status(500).json({ error: "InternalServerError" }); + } +}); diff --git a/backend/src/routes/beaches.ts b/backend/src/routes/beaches.ts new file mode 100644 index 0000000000..ebbce0900c --- /dev/null +++ b/backend/src/routes/beaches.ts @@ -0,0 +1,56 @@ +import { Router } from "express"; +import { havGet, getLatestSampleDate } from "../lib/hav.js"; + +export const beachesRouter = Router(); + +/** + * GET /api/beaches + * Proxies HaV "feature" endpoint and returns (likely) GeoJSON. + */ +beachesRouter.get("/beaches", async (_req, res, next) => { + try { + const data = await havGet("/feature/?format=json", 5 * 60 * 1000); // cache 5 min + res.json(data); + } catch (err) { + next(err); + } +}); + +/** + * GET /api/beaches/:id + * Proxies HaV v1 detail and augments with latestSampleDate. + * Prefers v1.sampleDate (epoch ms); falls back to v2 results when needed. + */ +beachesRouter.get("/beaches/:id", async (req, res, next) => { + try { + const { id } = req.params; + + // v1 detail + const detail = await havGet( + `/detail/${encodeURIComponent(id)}`, + 5 * 60 * 1000 + ); + + // prefer v1 sampleDate (epoch ms) + let latestSampleDate: string | null = null; + const v1Ms = (detail as any)?.sampleDate; + if (typeof v1Ms === "number" && isFinite(v1Ms)) { + latestSampleDate = new Date(v1Ms).toISOString(); + } else { + // fallback to v2 + try { + latestSampleDate = await getLatestSampleDate(id); + } catch (e) { + // don't fail the whole request if v2 errors; just leave null + console.warn( + "[/beaches/:id] v2 fallback failed:", + (e as Error)?.message + ); + } + } + + res.json({ ...detail, latestSampleDate }); + } catch (err) { + next(err); + } +}); diff --git a/backend/src/routes/dbCheck.ts b/backend/src/routes/dbCheck.ts new file mode 100644 index 0000000000..4893c87614 --- /dev/null +++ b/backend/src/routes/dbCheck.ts @@ -0,0 +1,29 @@ +import { Router, Request, Response } from "express"; +import mongoose from "mongoose"; +import { connectDB } from "../lib/db.js"; + +export const dbCheckRouter = Router(); + +dbCheckRouter.get("/db-check", async (_req: Request, res: Response) => { + try { + // Ensure we attempt a connection before reporting + await connectDB(); + const state = mongoose.connection.readyState; // 0=disconnected, 1=connected, 2=connecting, 3=disconnecting + + res.json({ + ok: state === 1, + state, + message: + state === 1 + ? "MongoDB is connected" + : state === 2 + ? "MongoDB is connecting..." + : state === 3 + ? "MongoDB is disconnecting..." + : "MongoDB is disconnected", + }); + } catch (err) { + console.error("DB check error:", err); + res.status(500).json({ ok: false, error: "DB check failed" }); + } +}); diff --git a/backend/src/routes/favorites.ts b/backend/src/routes/favorites.ts new file mode 100644 index 0000000000..a637587d64 --- /dev/null +++ b/backend/src/routes/favorites.ts @@ -0,0 +1,169 @@ +import { Router } from "express"; +import { z } from "zod"; +import { Favorite } from "../models/Favorite.js"; +import { requireAuth, AuthedRequest } from "../middleware/auth.js"; +import { connectDB } from "../lib/db.js"; +import mongoose from "mongoose"; +import type { AnyBulkWriteOperation } from "mongoose"; + +export const favoritesRouter = Router(); + +const zCreate = z.object({ + beachId: z.string().min(1), + note: z.string().max(500).optional(), +}); + +// Ensure DB connection for every favorites op +favoritesRouter.use(async (_req, _res, next) => { + try { + await connectDB(); + next(); + } catch (e) { + next(e); + } +}); + +/** + * GET /api/favorites + * List current user's favorites (stable order) + */ +favoritesRouter.get("/favorites", requireAuth, async (req, res) => { + const userId = (req as AuthedRequest).user.sub; + const items = await Favorite.find({ userId }) + .sort({ order: 1, updatedAt: 1, _id: 1 }) // stable sort + .lean(); + res.json(items); +}); + +/** + * POST /api/favorites + */ +favoritesRouter.post("/favorites", requireAuth, async (req, res) => { + const parsed = zCreate.safeParse(req.body); + if (!parsed.success) { + // keep the existing error style + // @ts-ignore - z.treeifyError is available in the codebase + return res + .status(400) + .json({ error: "InvalidBody", details: z.treeifyError(parsed.error) }); + } + + const { beachId, note } = parsed.data; + const userId = (req as AuthedRequest).user.sub; + + try { + const created = await Favorite.create({ + userId, + beachId, + note: note ?? "", + }); + return res.status(201).json(created); + } catch (err: any) { + if (err?.code === 11000) { + return res.status(409).json({ error: "AlreadyFavorited" }); + } + throw err; + } +}); + +/** + * DELETE /api/favorites/:id + */ +favoritesRouter.delete("/favorites/:id", requireAuth, async (req, res) => { + const userId = (req as AuthedRequest).user.sub; + + const id = req.params.id as string | undefined; + if (!id) return res.status(400).json({ error: "MissingId" }); + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: "InvalidId" }); + } + + const deleted = await Favorite.findOneAndDelete({ _id: id, userId }); + if (!deleted) return res.status(404).json({ error: "NotFound" }); + res.json({ ok: true }); +}); + +/** + * DELETE /api/favorites/by-beach/:beachId + */ +favoritesRouter.delete( + "/favorites/by-beach/:beachId", + requireAuth, + async (req, res) => { + const userId = (req as AuthedRequest).user.sub; + const { beachId } = req.params; + + const deleted = await Favorite.findOneAndDelete({ beachId, userId }); + if (!deleted) return res.status(404).json({ error: "NotFound" }); + res.json({ ok: true }); + } +); + +/** + * PATCH /api/favorites/reorder + * Persist custom order. Accepts: { order: string[] } where each string is a beachId. + * Non-owned or unknown ids are ignored. Any favorites not included are appended after. + */ +favoritesRouter.patch("/favorites/reorder", requireAuth, async (req, res) => { + try { + const userId = (req as AuthedRequest).user.sub; + + const schema = z.object({ + order: z.array(z.string()).min(0), + }); + + const parsed = schema.safeParse(req.body); + if (!parsed.success) { + return res + .status(400) + .json({ error: "InvalidBody", details: parsed.error.flatten() }); + } + + // Deduplicate ids while preserving first occurrence order + const seen = new Set(); + const order = parsed.data.order.filter((id) => { + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + + // Fetch user's current favorites + const favs = await Favorite.find({ userId }, { beachId: 1 }).lean(); + const currentIds = new Set(favs.map((f) => f.beachId)); + + // Keep only valid (owned) ids, preserve payload order + const sanitized = order.filter((id) => currentIds.has(id)); + + // Bulk update for provided ids + const ops: AnyBulkWriteOperation[] = sanitized.map((beachId, idx) => ({ + updateOne: { + filter: { userId, beachId }, + update: { $set: { order: idx } }, + }, + })); + + // Append any missing favorites not included in payload + const missing = favs + .map((f) => f.beachId) + .filter((id) => !sanitized.includes(id)); + + ops.push( + ...missing.map((beachId, addIdx) => ({ + updateOne: { + filter: { userId, beachId }, + update: { $set: { order: sanitized.length + addIdx } }, + }, + })) + ); + + if (ops.length > 0) { + await Favorite.bulkWrite(ops, { ordered: false }); + } + + return res.status(204).end(); + } catch (err) { + console.error("REORDER_ERR", err); + return res.status(500).json({ error: "InternalServerError" }); + } +}); diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts new file mode 100644 index 0000000000..4b11f1801e --- /dev/null +++ b/backend/src/routes/health.ts @@ -0,0 +1,14 @@ +import { Router, Request, Response } from "express"; + +const router = Router(); + +router.get("/health", async (_req: Request, res: Response) => { + console.log("Hit /api/health"); + res.json({ + ok: true, + env: process.env.NODE_ENV ?? "development", + timestamp: new Date().toISOString(), + }); +}); + +export { router as healthRouter }; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000000..4b6297f28d --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,23 @@ +// backend/src/server.ts +import "dotenv/config"; +import app from "./index.js"; + +const port = Number(process.env.PORT) || 3000; + +app.use((req, _res, next) => { + console.log(`[req] ${req.method} ${req.url}`); + next(); +}); + +app.listen(port, () => { + console.log(`API listening on http://localhost:${port}`); + const allowed = (process.env.CORS_ORIGIN || process.env.ALLOWED_ORIGIN || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + console.log( + allowed.length + ? `CORS allowed origins: ${allowed.join(", ")}` + : "CORS: no origins configured → permissive (dev)" + ); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000000..fafc27f38f --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + /* Output */ + "outDir": "dist", + "rootDir": "src", + + /* Language and Environment */ + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + + /* Type Checking */ + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + /* Source Maps & Declarations */ + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + /* Types */ + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/backend/vercel.json b/backend/vercel.json new file mode 100644 index 0000000000..0ecf1b1b1d --- /dev/null +++ b/backend/vercel.json @@ -0,0 +1,4 @@ +{ + "builds": [{ "src": "src/index.ts", "use": "@vercel/node" }], + "routes": [{ "src": "/api/(.*)", "dest": "src/index.ts" }] +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000000..3c8276379c --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,12 @@ +# Backend API +VITE_API_BASE=http://localhost:3000/api + +# Maptiler (optional: needed for MapLibre styles) +VITE_MAPTILER_KEY=your-maptiler-key + +# Map styles +VITE_MAP_STYLE_LIGHT=https://api.maptiler.com/maps/basic-v2/style.json?key=${VITE_MAPTILER_KEY} +VITE_MAP_STYLE_DARK=https://api.maptiler.com/maps/darkmatter/style.json?key=${VITE_MAPTILER_KEY} + +# Default language +VITE_DEFAULT_LANG=sv \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 5cdb1d9cf3..0000000000 --- a/frontend/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Frontend part of Final Project - -This boilerplate is designed to give you a head start in your React projects, with a focus on understanding the structure and components. As a student of Technigo, you'll find this guide helpful in navigating and utilizing the repository. - -## Getting Started - -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file diff --git a/frontend/dist/_redirects b/frontend/dist/_redirects new file mode 100644 index 0000000000..05916b5e7f --- /dev/null +++ b/frontend/dist/_redirects @@ -0,0 +1,5 @@ +# Don't redirect these files - serve them directly +/robots.txt /robots.txt 200 + +# Catch-all: serve index.html for client-side routing +/* /index.html 200 \ No newline at end of file diff --git a/frontend/dist/assets/main-BSfwhuEJ.css b/frontend/dist/assets/main-BSfwhuEJ.css new file mode 100644 index 0000000000..8c61a3d9f6 --- /dev/null +++ b/frontend/dist/assets/main-BSfwhuEJ.css @@ -0,0 +1 @@ +.maplibregl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0,0,0,0)}.maplibregl-canvas{left:0;position:absolute;top:0}.maplibregl-map:fullscreen{height:100%;width:100%}.maplibregl-canvas-container.maplibregl-interactive,.maplibregl-ctrl-group button.maplibregl-ctrl-compass{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.maplibregl-ctrl-top-left{left:0;top:0}.maplibregl-ctrl-top-right{right:0;top:0}@media (forced-colors:active){.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.maplibregl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;padding:0;width:29px}.maplibregl-ctrl button .maplibregl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (forced-colors:active){.maplibregl-ctrl-icon{background-color:transparent}.maplibregl-ctrl-group button+button{border-top:1px solid ButtonText}}.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-globe .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='none' stroke='%23333' viewBox='0 0 22 22'%3E%3Ccircle cx='11' cy='11' r='8.5'/%3E%3Cpath d='M17.5 11c0 4.819-3.02 8.5-6.5 8.5S4.5 15.819 4.5 11 7.52 2.5 11 2.5s6.5 3.681 6.5 8.5Z'/%3E%3Cpath d='M13.5 11c0 2.447-.331 4.64-.853 6.206-.262.785-.562 1.384-.872 1.777-.314.399-.58.517-.775.517s-.461-.118-.775-.517c-.31-.393-.61-.992-.872-1.777C8.831 15.64 8.5 13.446 8.5 11s.331-4.64.853-6.206c.262-.785.562-1.384.872-1.777.314-.399.58-.517.775-.517s.461.118.775.517c.31.393.61.992.872 1.777.522 1.565.853 3.76.853 6.206Z'/%3E%3Cpath d='M11 7.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138q.07-.058.224-.138c.299-.151.763-.302 1.379-.434C7.378 5.666 9.091 5.5 11 5.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138q-.07.058-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428ZM4.486 6.436ZM11 16.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138 1.3 1.3 0 0 1 .224-.138c.299-.151.763-.302 1.379-.434C7.378 14.666 9.091 14.5 11 14.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138a1.3 1.3 0 0 1-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428Zm-6.514-1.064ZM11 12.5c-2.46 0-4.672-.222-6.255-.574-.796-.177-1.406-.38-1.805-.59a1.5 1.5 0 0 1-.39-.272.3.3 0 0 1-.047-.064.3.3 0 0 1 .048-.064c.066-.073.189-.167.389-.272.399-.21 1.009-.413 1.805-.59C6.328 9.722 8.54 9.5 11 9.5s4.672.222 6.256.574c.795.177 1.405.38 1.804.59.2.105.323.2.39.272a.3.3 0 0 1 .047.064.3.3 0 0 1-.048.064 1.4 1.4 0 0 1-.389.272c-.399.21-1.009.413-1.804.59-1.584.352-3.796.574-6.256.574Zm-8.501-1.51v.002zm0 .018v.002zm17.002.002v-.002zm0-.018v-.002z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-globe-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='none' stroke='%2333b5e5' viewBox='0 0 22 22'%3E%3Ccircle cx='11' cy='11' r='8.5'/%3E%3Cpath d='M17.5 11c0 4.819-3.02 8.5-6.5 8.5S4.5 15.819 4.5 11 7.52 2.5 11 2.5s6.5 3.681 6.5 8.5Z'/%3E%3Cpath d='M13.5 11c0 2.447-.331 4.64-.853 6.206-.262.785-.562 1.384-.872 1.777-.314.399-.58.517-.775.517s-.461-.118-.775-.517c-.31-.393-.61-.992-.872-1.777C8.831 15.64 8.5 13.446 8.5 11s.331-4.64.853-6.206c.262-.785.562-1.384.872-1.777.314-.399.58-.517.775-.517s.461.118.775.517c.31.393.61.992.872 1.777.522 1.565.853 3.76.853 6.206Z'/%3E%3Cpath d='M11 7.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138q.07-.058.224-.138c.299-.151.763-.302 1.379-.434C7.378 5.666 9.091 5.5 11 5.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138q-.07.058-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428ZM4.486 6.436ZM11 16.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138 1.3 1.3 0 0 1 .224-.138c.299-.151.763-.302 1.379-.434C7.378 14.666 9.091 14.5 11 14.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138a1.3 1.3 0 0 1-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428Zm-6.514-1.064ZM11 12.5c-2.46 0-4.672-.222-6.255-.574-.796-.177-1.406-.38-1.805-.59a1.5 1.5 0 0 1-.39-.272.3.3 0 0 1-.047-.064.3.3 0 0 1 .048-.064c.066-.073.189-.167.389-.272.399-.21 1.009-.413 1.805-.59C6.328 9.722 8.54 9.5 11 9.5s4.672.222 6.256.574c.795.177 1.405.38 1.804.59.2.105.323.2.39.272a.3.3 0 0 1 .047.064.3.3 0 0 1-.048.064 1.4 1.4 0 0 1-.389.272c-.399.21-1.009.413-1.804.59-1.584.352-3.796.574-6.256.574Zm-8.501-1.51v.002zm0 .018v.002zm17.002.002v-.002zm0-.018v-.002z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%23333' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%2333b5e5' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23aaa' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-waiting .maplibregl-ctrl-icon{animation:maplibregl-spin 2s linear infinite}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23999' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23666' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}}a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}@media (forced-colors:active){a.maplibregl-ctrl-logo{background-color:transparent;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}@media screen{.maplibregl-ctrl-attrib.maplibregl-compact{background-color:#fff;border-radius:12px;box-sizing:content-box;color:#000;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.maplibregl-ctrl-attrib.maplibregl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact-show,.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner{display:none}.maplibregl-ctrl-attrib-button{background-color:#ffffff80;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;list-style:none}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker{display:none}.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button{left:0}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner{display:block}.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button{background-color:#0000000d}.maplibregl-ctrl-bottom-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;right:0}.maplibregl-ctrl-top-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{right:0;top:0}.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{left:0;top:0}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;left:0}}@media screen and (forced-colors:active){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='%23fff' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.maplibregl-ctrl-scale{background-color:#ffffffbf;border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px}.maplibregl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.maplibregl-popup-tip{border:10px solid transparent;height:0;width:0;z-index:1}.maplibregl-popup-anchor-top .maplibregl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.maplibregl-popup-anchor-top-left .maplibregl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.maplibregl-popup-anchor-top-right .maplibregl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.maplibregl-popup-anchor-bottom .maplibregl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.maplibregl-popup-anchor-left .maplibregl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.maplibregl-popup-anchor-right .maplibregl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.maplibregl-popup-close-button{background-color:transparent;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.maplibregl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px #0000001a;padding:15px 10px;pointer-events:auto;position:relative}.maplibregl-popup-track-pointer *{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-marker{left:0;position:absolute;top:0;transition:opacity .2s;will-change:transform}.maplibregl-user-location-dot,.maplibregl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.maplibregl-user-location-dot:before{animation:maplibregl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.maplibregl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px #00000059;box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}.maplibregl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.maplibregl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}.maplibregl-cooperative-gesture-screen{align-items:center;background:#0006;color:#fff;display:flex;font-size:1.4em;top:0;right:0;bottom:0;left:0;justify-content:center;line-height:1.2;opacity:0;padding:1rem;pointer-events:none;position:absolute;transition:opacity 1s ease 1s;z-index:99999}.maplibregl-cooperative-gesture-screen.maplibregl-show{opacity:1;transition:opacity .05s}.maplibregl-pseudo-fullscreen{height:100%!important;left:0!important;position:fixed!important;top:0!important;width:100%!important;z-index:99999}/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-600:oklch(57.7% .245 27.325);--color-red-950:oklch(25.8% .092 26.042);--color-black:#000;--color-white:#fff;--spacing:.25rem;--breakpoint-lg:64rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-bold:700;--leading-tight:1.25;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0,0,.2,1);--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-surface:#efebe4;--color-surface-muted:#f6f2ec;--color-ink:#1a1a1a;--color-ink-muted:#64686c;--color-border:#cdc8c0;--color-accent:#0a5a82;--color-badge:#e6e4dc;--color-quality-excellent:#1f6fb2;--color-quality-good:#2b8a3e;--color-quality-sufficient:#b08900;--color-quality-poor:#c0392b;--color-quality-unknown:#6b7280;--font-spectral:"Spectral",serif;--font-inter:"Inter",system-ui,sans-serif}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html,body,#root{height:100%}body{background-color:var(--color-surface);font-family:var(--font-inter);color:var(--color-ink);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}h1,h2,h3,h4{font-family:var(--font-spectral);color:var(--color-ink)}a{border-radius:var(--radius-sm);color:var(--color-accent);text-underline-offset:2px}@media (hover:hover){a:hover{text-decoration-line:underline}}a:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:#0a5a8280}@supports (color:color-mix(in lab,red,red)){a:focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-accent)50%,transparent)}}a:focus-visible{--tw-outline-style:none;outline-style:none}}@layer components{.card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-border);background-color:var(--color-surface-muted);--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.badge{align-items:center;gap:calc(var(--spacing)*1);background-color:var(--color-badge);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*1);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-ink);border-radius:3.40282e38px;display:inline-flex}.kpi-excellent{color:var(--color-quality-excellent)}.kpi-good{color:var(--color-quality-good)}.kpi-sufficient{color:var(--color-quality-sufficient)}.kpi-poor{color:var(--color-quality-poor)}.kpi-unknown{color:var(--color-quality-unknown)}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-0{top:calc(var(--spacing)*0)}.top-full{top:100%}.right-0{right:calc(var(--spacing)*0)}.left-0{left:calc(var(--spacing)*0)}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.ml-1{margin-left:calc(var(--spacing)*1)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.h-1{height:calc(var(--spacing)*1)}.h-1\.5{height:calc(var(--spacing)*1.5)}.h-3{height:calc(var(--spacing)*3)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-7{height:calc(var(--spacing)*7)}.h-9{height:calc(var(--spacing)*9)}.h-\[260px\]{height:260px}.h-px{height:1px}.max-h-96{max-height:calc(var(--spacing)*96)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing)*1.5)}.w-1\/2{width:50%}.w-1\/3{width:33.3333%}.w-2\/3{width:66.6667%}.w-3{width:calc(var(--spacing)*3)}.w-6{width:calc(var(--spacing)*6)}.w-40{width:calc(var(--spacing)*40)}.w-56{width:calc(var(--spacing)*56)}.w-60{width:calc(var(--spacing)*60)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-\[200px\]{max-width:200px}.max-w-md{max-width:var(--container-md)}.max-w-screen-lg{max-width:var(--breakpoint-lg)}.min-w-0{min-width:calc(var(--spacing)*0)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.cursor-grab{cursor:grab}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing)*.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-border{border-color:var(--color-border)}.border-red-600{border-color:var(--color-red-600)}.bg-accent{background-color:var(--color-accent)}.bg-border{background-color:var(--color-border)}.bg-ink\/60{background-color:#1a1a1a99}@supports (color:color-mix(in lab,red,red)){.bg-ink\/60{background-color:color-mix(in oklab,var(--color-ink)60%,transparent)}}.bg-surface{background-color:var(--color-surface)}.bg-surface-muted{background-color:var(--color-surface-muted)}.p-0{padding:calc(var(--spacing)*0)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.px-0{padding-inline:calc(var(--spacing)*0)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-6{padding-top:calc(var(--spacing)*6)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.text-center{text-align:center}.text-justify{text-align:justify}.text-left{text-align:left}.font-spectral{font-family:var(--font-spectral)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-\[1\.1\]{--tw-leading:1.1;line-height:1.1}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-\[-0\.06em\]{--tw-tracking:-.06em;letter-spacing:-.06em}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent{color:var(--color-accent)}.text-ink{color:var(--color-ink)}.text-ink-muted{color:var(--color-ink-muted)}.text-red-600{color:var(--color-red-600)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-black\/60{--tw-ring-color:#0009}@supports (color:color-mix(in lab,red,red)){.ring-black\/60{--tw-ring-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.ring-white\/85{--tw-ring-color:#ffffffd9}@supports (color:color-mix(in lab,red,red)){.ring-white\/85{--tw-ring-color:color-mix(in oklab,var(--color-white)85%,transparent)}}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition\!{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events!important;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function))!important;transition-duration:var(--tw-duration,var(--default-transition-duration))!important}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}@media (hover:hover){.hover\:bg-accent\/90:hover{background-color:#0a5a82e6}@supports (color:color-mix(in lab,red,red)){.hover\:bg-accent\/90:hover{background-color:color-mix(in oklab,var(--color-accent)90%,transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-surface:hover{background-color:var(--color-surface)}.hover\:bg-surface-muted:hover{background-color:var(--color-surface-muted)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:not-sr-only:focus{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.focus\:absolute:focus{position:absolute}.focus\:top-4:focus{top:calc(var(--spacing)*4)}.focus\:left-4:focus{left:calc(var(--spacing)*4)}.focus\:z-50:focus{z-index:50}.focus\:rounded:focus{border-radius:.25rem}.focus\:bg-accent:focus{background-color:var(--color-accent)}.focus\:bg-surface-muted:focus{background-color:var(--color-surface-muted)}.focus\:px-4:focus{padding-inline:calc(var(--spacing)*4)}.focus\:py-2:focus{padding-block:calc(var(--spacing)*2)}.focus\:text-white:focus{color:var(--color-white)}.focus\:underline:focus{text-decoration-line:underline}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-accent\/40:focus{--tw-ring-color:#0a5a8266}@supports (color:color-mix(in lab,red,red)){.focus\:ring-accent\/40:focus{--tw-ring-color:color-mix(in oklab,var(--color-accent)40%,transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-accent\/40:focus-visible{--tw-ring-color:#0a5a8266}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-accent\/40:focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-accent)40%,transparent)}}.focus-visible\:ring-accent\/50:focus-visible{--tw-ring-color:#0a5a8280}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-accent\/50:focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-accent)50%,transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:cursor-grabbing:active{cursor:grabbing}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}@media (min-width:40rem){.sm\:max-w-none{max-width:none}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:gap-4{gap:calc(var(--spacing)*4)}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:bg-red-950:hover{background-color:var(--color-red-950)}}}.aspect-square{aspect-ratio:1}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.sr-only:focus{width:auto;height:auto;padding:inherit;margin:inherit;clip:auto;white-space:normal;position:static;overflow:visible}}.maplibregl-map{-webkit-tap-highlight-color:#0000;font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;position:relative;overflow:hidden}.maplibregl-canvas{position:absolute;top:0;left:0}.maplibregl-map:fullscreen{width:100%;height:100%}.maplibregl-ctrl-group button.maplibregl-ctrl-compass{touch-action:none}.maplibregl-canvas-container.maplibregl-interactive,.maplibregl-ctrl-group button.maplibregl-ctrl-compass{cursor:grab;-webkit-user-select:none;user-select:none}.maplibregl-canvas-container.maplibregl-interactive.maplibregl-track-pointer{cursor:pointer}.maplibregl-canvas-container.maplibregl-interactive:active,.maplibregl-ctrl-group button.maplibregl-ctrl-compass:active{cursor:grabbing}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-canvas-container.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:pinch-zoom}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:none}.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures,.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{pointer-events:none;z-index:2;position:absolute}.maplibregl-ctrl-top-left{top:0;left:0}.maplibregl-ctrl-top-right{top:0;right:0}.maplibregl-ctrl-bottom-left{bottom:0;left:0}.maplibregl-ctrl-bottom-right{bottom:0;right:0}.maplibregl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.maplibregl-ctrl-top-left .maplibregl-ctrl{float:left;margin:10px 0 0 10px}.maplibregl-ctrl-top-right .maplibregl-ctrl{float:right;margin:10px 10px 0 0}.maplibregl-ctrl-bottom-left .maplibregl-ctrl{float:left;margin:0 0 10px 10px}.maplibregl-ctrl-bottom-right .maplibregl-ctrl{float:right;margin:0 10px 10px 0}.maplibregl-ctrl-group{background:#fff;border-radius:4px}.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px #0000001a}@media (forced-colors:active){.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px buttontext}}.maplibregl-ctrl-group button{box-sizing:border-box;cursor:pointer;background-color:#0000;border:0;outline:none;width:29px;height:29px;padding:0;display:block}.maplibregl-ctrl-group button+button{border-top:1px solid #ddd}.maplibregl-ctrl button .maplibregl-ctrl-icon{background-position:50%;background-repeat:no-repeat;width:100%;height:100%;display:block}@media (forced-colors:active){.maplibregl-ctrl-icon{background-color:#0000}.maplibregl-ctrl-group button+button{border-top:1px solid buttontext}}.maplibregl-ctrl button::-moz-focus-inner{border:0;padding:0}.maplibregl-ctrl-attrib-button:focus,.maplibregl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl button:disabled{cursor:not-allowed}.maplibregl-ctrl button:disabled .maplibregl-ctrl-icon{opacity:.25}@media (hover:hover){.maplibregl-ctrl button:not(:disabled):hover{background-color:#0000000d}}.maplibregl-ctrl button:not(:disabled):active{background-color:#0000000d}.maplibregl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.maplibregl-ctrl-group button:focus:first-child{border-radius:4px 4px 0 0}.maplibregl-ctrl-group button:focus:last-child{border-radius:0 0 4px 4px}.maplibregl-ctrl-group button:focus:only-child{border-radius:inherit}.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-globe .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='none' stroke='%23333' viewBox='0 0 22 22'%3E%3Ccircle cx='11' cy='11' r='8.5'/%3E%3Cpath d='M17.5 11c0 4.819-3.02 8.5-6.5 8.5S4.5 15.819 4.5 11 7.52 2.5 11 2.5s6.5 3.681 6.5 8.5Z'/%3E%3Cpath d='M13.5 11c0 2.447-.331 4.64-.853 6.206-.262.785-.562 1.384-.872 1.777-.314.399-.58.517-.775.517s-.461-.118-.775-.517c-.31-.393-.61-.992-.872-1.777C8.831 15.64 8.5 13.446 8.5 11s.331-4.64.853-6.206c.262-.785.562-1.384.872-1.777.314-.399.58-.517.775-.517s.461.118.775.517c.31.393.61.992.872 1.777.522 1.565.853 3.76.853 6.206Z'/%3E%3Cpath d='M11 7.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138q.07-.058.224-.138c.299-.151.763-.302 1.379-.434C7.378 5.666 9.091 5.5 11 5.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138q-.07.058-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428ZM4.486 6.436ZM11 16.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138 1.3 1.3 0 0 1 .224-.138c.299-.151.763-.302 1.379-.434C7.378 14.666 9.091 14.5 11 14.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138a1.3 1.3 0 0 1-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428Zm-6.514-1.064ZM11 12.5c-2.46 0-4.672-.222-6.255-.574-.796-.177-1.406-.38-1.805-.59a1.5 1.5 0 0 1-.39-.272.3.3 0 0 1-.047-.064.3.3 0 0 1 .048-.064c.066-.073.189-.167.389-.272.399-.21 1.009-.413 1.805-.59C6.328 9.722 8.54 9.5 11 9.5s4.672.222 6.256.574c.795.177 1.405.38 1.804.59.2.105.323.2.39.272a.3.3 0 0 1 .047.064.3.3 0 0 1-.048.064 1.4 1.4 0 0 1-.389.272c-.399.21-1.009.413-1.804.59-1.584.352-3.796.574-6.256.574Zm-8.501-1.51v.002zm0 .018v.002zm17.002.002v-.002zm0-.018v-.002z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-globe-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='none' stroke='%2333b5e5' viewBox='0 0 22 22'%3E%3Ccircle cx='11' cy='11' r='8.5'/%3E%3Cpath d='M17.5 11c0 4.819-3.02 8.5-6.5 8.5S4.5 15.819 4.5 11 7.52 2.5 11 2.5s6.5 3.681 6.5 8.5Z'/%3E%3Cpath d='M13.5 11c0 2.447-.331 4.64-.853 6.206-.262.785-.562 1.384-.872 1.777-.314.399-.58.517-.775.517s-.461-.118-.775-.517c-.31-.393-.61-.992-.872-1.777C8.831 15.64 8.5 13.446 8.5 11s.331-4.64.853-6.206c.262-.785.562-1.384.872-1.777.314-.399.58-.517.775-.517s.461.118.775.517c.31.393.61.992.872 1.777.522 1.565.853 3.76.853 6.206Z'/%3E%3Cpath d='M11 7.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138q.07-.058.224-.138c.299-.151.763-.302 1.379-.434C7.378 5.666 9.091 5.5 11 5.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138q-.07.058-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428ZM4.486 6.436ZM11 16.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138 1.3 1.3 0 0 1 .224-.138c.299-.151.763-.302 1.379-.434C7.378 14.666 9.091 14.5 11 14.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138a1.3 1.3 0 0 1-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428Zm-6.514-1.064ZM11 12.5c-2.46 0-4.672-.222-6.255-.574-.796-.177-1.406-.38-1.805-.59a1.5 1.5 0 0 1-.39-.272.3.3 0 0 1-.047-.064.3.3 0 0 1 .048-.064c.066-.073.189-.167.389-.272.399-.21 1.009-.413 1.805-.59C6.328 9.722 8.54 9.5 11 9.5s4.672.222 6.256.574c.795.177 1.405.38 1.804.59.2.105.323.2.39.272a.3.3 0 0 1 .047.064.3.3 0 0 1-.048.064 1.4 1.4 0 0 1-.389.272c-.399.21-1.009.413-1.804.59-1.584.352-3.796.574-6.256.574Zm-8.501-1.51v.002zm0 .018v.002zm17.002.002v-.002zm0-.018v-.002z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%23333' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%2333b5e5' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23aaa' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-waiting .maplibregl-ctrl-icon{animation:2s linear infinite maplibregl-spin}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23999' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23666' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}}@keyframes maplibregl-spin{0%{transform:rotate(0)}to{transform:rotate(1turn)}}a.maplibregl-ctrl-logo{cursor:pointer;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;width:88px;height:23px;margin:0 0 -4px -4px;display:block;overflow:hidden}a.maplibregl-ctrl-logo.maplibregl-compact{width:14px}@media (forced-colors:active){a.maplibregl-ctrl-logo{background-color:#0000;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}.maplibregl-ctrl.maplibregl-ctrl-attrib{background-color:#ffffff80;margin:0;padding:0 5px}@media screen{.maplibregl-ctrl-attrib.maplibregl-compact{box-sizing:content-box;color:#000;background-color:#fff;border-radius:12px;min-height:20px;margin:10px;padding:2px 24px 2px 0;position:relative}.maplibregl-ctrl-attrib.maplibregl-compact-show{visibility:visible;padding:2px 28px 2px 8px}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact-show,.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner{display:none}.maplibregl-ctrl-attrib-button{box-sizing:border-box;cursor:pointer;background-color:#ffffff80;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;outline:none;width:24px;height:24px;display:none;position:absolute;top:0;right:0}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;list-style:none}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker{display:none}.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button{left:0}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner{display:block}.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button{background-color:#0000000d}.maplibregl-ctrl-bottom-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;right:0}.maplibregl-ctrl-top-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{top:0;right:0}.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{top:0;left:0}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;left:0}}@media screen and (forced-colors:active){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='%23fff' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.maplibregl-ctrl-attrib a{color:#000000bf;text-decoration:none}.maplibregl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.maplibregl-attrib-empty{display:none}.maplibregl-ctrl-scale{box-sizing:border-box;color:#333;background-color:#ffffffbf;border:2px solid #333;border-top:#333;padding:0 5px;font-size:10px}.maplibregl-popup{pointer-events:none;will-change:transform;display:flex;position:absolute;top:0;left:0}.maplibregl-popup-anchor-top,.maplibregl-popup-anchor-top-left,.maplibregl-popup-anchor-top-right{flex-direction:column}.maplibregl-popup-anchor-bottom,.maplibregl-popup-anchor-bottom-left,.maplibregl-popup-anchor-bottom-right{flex-direction:column-reverse}.maplibregl-popup-anchor-left{flex-direction:row}.maplibregl-popup-anchor-right{flex-direction:row-reverse}.maplibregl-popup-tip{z-index:1;border:10px solid #0000;width:0;height:0}.maplibregl-popup-anchor-top .maplibregl-popup-tip{border-top:none;border-bottom-color:#fff;align-self:center}.maplibregl-popup-anchor-top-left .maplibregl-popup-tip{border-top:none;border-bottom-color:#fff;border-left:none;align-self:flex-start}.maplibregl-popup-anchor-top-right .maplibregl-popup-tip{border-top:none;border-bottom-color:#fff;border-right:none;align-self:flex-end}.maplibregl-popup-anchor-bottom .maplibregl-popup-tip{border-top-color:#fff;border-bottom:none;align-self:center}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip{border-top-color:#fff;border-bottom:none;border-left:none;align-self:flex-start}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip{border-top-color:#fff;border-bottom:none;border-right:none;align-self:flex-end}.maplibregl-popup-anchor-left .maplibregl-popup-tip{border-left:none;border-right-color:#fff;align-self:center}.maplibregl-popup-anchor-right .maplibregl-popup-tip{border-left-color:#fff;border-right:none;align-self:center}.maplibregl-popup-close-button{cursor:pointer;background-color:#0000;border:0;border-radius:0 3px 0 0;position:absolute;top:0;right:0}.maplibregl-popup-close-button:hover{background-color:#0000000d}.maplibregl-popup-content{pointer-events:auto;background:#fff;border-radius:3px;padding:15px 10px;position:relative;box-shadow:0 1px 2px #0000001a}.maplibregl-popup-anchor-top-left .maplibregl-popup-content{border-top-left-radius:0}.maplibregl-popup-anchor-top-right .maplibregl-popup-content{border-top-right-radius:0}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-content{border-bottom-left-radius:0}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-content{border-bottom-right-radius:0}.maplibregl-popup-track-pointer{display:none}.maplibregl-popup-track-pointer *{pointer-events:none;-webkit-user-select:none;user-select:none}.maplibregl-map:hover .maplibregl-popup-track-pointer{display:flex}.maplibregl-map:active .maplibregl-popup-track-pointer{display:none}.maplibregl-marker{will-change:transform;transition:opacity .2s;position:absolute;top:0;left:0}.maplibregl-user-location-dot,.maplibregl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;width:15px;height:15px}.maplibregl-user-location-dot:before{content:"";animation:2s infinite maplibregl-user-location-dot-pulse;position:absolute}.maplibregl-user-location-dot:after{box-sizing:border-box;content:"";border:2px solid #fff;border-radius:50%;width:19px;height:19px;position:absolute;top:-2px;left:-2px;box-shadow:0 0 3px #00000059}@keyframes maplibregl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.maplibregl-user-location-dot-stale{background-color:#aaa}.maplibregl-user-location-dot-stale:after{display:none}.maplibregl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;width:1px;height:1px}.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive,.maplibregl-crosshair .maplibregl-interactive:active{cursor:crosshair}.maplibregl-boxzoom{opacity:.5;background:#fff;border:2px dotted #202020;width:0;height:0;position:absolute;top:0;left:0}.maplibregl-cooperative-gesture-screen{color:#fff;opacity:0;pointer-events:none;z-index:99999;background:#0006;justify-content:center;align-items:center;padding:1rem;font-size:1.4em;line-height:1.2;transition:opacity 1s 1s;display:flex;position:absolute;top:0;right:0;bottom:0;left:0}.maplibregl-cooperative-gesture-screen.maplibregl-show{opacity:1;transition:opacity 50ms}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:none}@media (hover:none),(pointer:coarse){.maplibregl-cooperative-gesture-screen .maplibregl-desktop-message{display:none}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:block}}.maplibregl-pseudo-fullscreen{z-index:99999;width:100%!important;height:100%!important;position:fixed!important;top:0!important;left:0!important}.dark{--color-surface:#161011;--color-surface-muted:#1e1819;--color-ink:#f0ede6;--color-ink-muted:#b8b2aa;--color-border:#40383a;--color-accent:#5ea5d2;--color-badge:#302a2c}@media (prefers-reduced-motion:reduce){*,:before,:after{transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} diff --git a/frontend/dist/assets/main-DNC_d3kL.js b/frontend/dist/assets/main-DNC_d3kL.js new file mode 100644 index 0000000000..7f4a36a17e --- /dev/null +++ b/frontend/dist/assets/main-DNC_d3kL.js @@ -0,0 +1,1048 @@ +var _w=Object.defineProperty;var Yv=r=>{throw TypeError(r)};var vw=(r,a,u)=>a in r?_w(r,a,{enumerable:!0,configurable:!0,writable:!0,value:u}):r[a]=u;var Qv=(r,a,u)=>vw(r,typeof a!="symbol"?a+"":a,u),ky=(r,a,u)=>a.has(r)||Yv("Cannot "+u);var ye=(r,a,u)=>(ky(r,a,"read from private field"),u?u.call(r):a.get(r)),Wt=(r,a,u)=>a.has(r)?Yv("Cannot add the same private member more than once"):a instanceof WeakSet?a.add(r):a.set(r,u),It=(r,a,u,m)=>(ky(r,a,"write to private field"),m?m.call(r,u):a.set(r,u),u),wr=(r,a,u)=>(ky(r,a,"access private method"),u);var lg=(r,a,u,m)=>({set _(b){It(r,a,b,u)},get _(){return ye(r,a,m)}});(function(){const a=document.createElement("link").relList;if(a&&a.supports&&a.supports("modulepreload"))return;for(const b of document.querySelectorAll('link[rel="modulepreload"]'))m(b);new MutationObserver(b=>{for(const P of b)if(P.type==="childList")for(const w of P.addedNodes)w.tagName==="LINK"&&w.rel==="modulepreload"&&m(w)}).observe(document,{childList:!0,subtree:!0});function u(b){const P={};return b.integrity&&(P.integrity=b.integrity),b.referrerPolicy&&(P.referrerPolicy=b.referrerPolicy),b.crossOrigin==="use-credentials"?P.credentials="include":b.crossOrigin==="anonymous"?P.credentials="omit":P.credentials="same-origin",P}function m(b){if(b.ep)return;b.ep=!0;const P=u(b);fetch(b.href,P)}})();function R_(r){return r&&r.__esModule&&Object.prototype.hasOwnProperty.call(r,"default")?r.default:r}var Ey={exports:{}},$p={},Iy={exports:{}},Gr={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Jv;function xw(){if(Jv)return Gr;Jv=1;var r=Symbol.for("react.element"),a=Symbol.for("react.portal"),u=Symbol.for("react.fragment"),m=Symbol.for("react.strict_mode"),b=Symbol.for("react.profiler"),P=Symbol.for("react.provider"),w=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),L=Symbol.for("react.suspense"),N=Symbol.for("react.memo"),Z=Symbol.for("react.lazy"),V=Symbol.iterator;function ee(Ie){return Ie===null||typeof Ie!="object"?null:(Ie=V&&Ie[V]||Ie["@@iterator"],typeof Ie=="function"?Ie:null)}var q={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},_e=Object.assign,fe={};function Pe(Ie,Qe,jt){this.props=Ie,this.context=Qe,this.refs=fe,this.updater=jt||q}Pe.prototype.isReactComponent={},Pe.prototype.setState=function(Ie,Qe){if(typeof Ie!="object"&&typeof Ie!="function"&&Ie!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,Ie,Qe,"setState")},Pe.prototype.forceUpdate=function(Ie){this.updater.enqueueForceUpdate(this,Ie,"forceUpdate")};function Le(){}Le.prototype=Pe.prototype;function Me(Ie,Qe,jt){this.props=Ie,this.context=Qe,this.refs=fe,this.updater=jt||q}var Se=Me.prototype=new Le;Se.constructor=Me,_e(Se,Pe.prototype),Se.isPureReactComponent=!0;var De=Array.isArray,Ye=Object.prototype.hasOwnProperty,ot={current:null},lt={key:!0,ref:!0,__self:!0,__source:!0};function ut(Ie,Qe,jt){var tr,fr={},Kt=null,Lr=null;if(Qe!=null)for(tr in Qe.ref!==void 0&&(Lr=Qe.ref),Qe.key!==void 0&&(Kt=""+Qe.key),Qe)Ye.call(Qe,tr)&&!lt.hasOwnProperty(tr)&&(fr[tr]=Qe[tr]);var sr=arguments.length-2;if(sr===1)fr.children=jt;else if(1>>1,Qe=_t[Ie];if(0>>1;Ieb(fr,Ct))Ktb(Lr,fr)?(_t[Ie]=Lr,_t[Kt]=Ct,Ie=Kt):(_t[Ie]=fr,_t[tr]=Ct,Ie=tr);else if(Ktb(Lr,Ct))_t[Ie]=Lr,_t[Kt]=Ct,Ie=Kt;else break e}}return ft}function b(_t,ft){var Ct=_t.sortIndex-ft.sortIndex;return Ct!==0?Ct:_t.id-ft.id}if(typeof performance=="object"&&typeof performance.now=="function"){var P=performance;r.unstable_now=function(){return P.now()}}else{var w=Date,l=w.now();r.unstable_now=function(){return w.now()-l}}var L=[],N=[],Z=1,V=null,ee=3,q=!1,_e=!1,fe=!1,Pe=typeof setTimeout=="function"?setTimeout:null,Le=typeof clearTimeout=="function"?clearTimeout:null,Me=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function Se(_t){for(var ft=u(N);ft!==null;){if(ft.callback===null)m(N);else if(ft.startTime<=_t)m(N),ft.sortIndex=ft.expirationTime,a(L,ft);else break;ft=u(N)}}function De(_t){if(fe=!1,Se(_t),!_e)if(u(L)!==null)_e=!0,or(Ye);else{var ft=u(N);ft!==null&&Zt(De,ft.startTime-_t)}}function Ye(_t,ft){_e=!1,fe&&(fe=!1,Le(ut),ut=-1),q=!0;var Ct=ee;try{for(Se(ft),V=u(L);V!==null&&(!(V.expirationTime>ft)||_t&&!At());){var Ie=V.callback;if(typeof Ie=="function"){V.callback=null,ee=V.priorityLevel;var Qe=Ie(V.expirationTime<=ft);ft=r.unstable_now(),typeof Qe=="function"?V.callback=Qe:V===u(L)&&m(L),Se(ft)}else m(L);V=u(L)}if(V!==null)var jt=!0;else{var tr=u(N);tr!==null&&Zt(De,tr.startTime-ft),jt=!1}return jt}finally{V=null,ee=Ct,q=!1}}var ot=!1,lt=null,ut=-1,Je=5,Ke=-1;function At(){return!(r.unstable_now()-Ke_t||125<_t?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):Je=0<_t?Math.floor(1e3/_t):5},r.unstable_getCurrentPriorityLevel=function(){return ee},r.unstable_getFirstCallbackNode=function(){return u(L)},r.unstable_next=function(_t){switch(ee){case 1:case 2:case 3:var ft=3;break;default:ft=ee}var Ct=ee;ee=ft;try{return _t()}finally{ee=Ct}},r.unstable_pauseExecution=function(){},r.unstable_requestPaint=function(){},r.unstable_runWithPriority=function(_t,ft){switch(_t){case 1:case 2:case 3:case 4:case 5:break;default:_t=3}var Ct=ee;ee=_t;try{return ft()}finally{ee=Ct}},r.unstable_scheduleCallback=function(_t,ft,Ct){var Ie=r.unstable_now();switch(typeof Ct=="object"&&Ct!==null?(Ct=Ct.delay,Ct=typeof Ct=="number"&&0Ie?(_t.sortIndex=Ct,a(N,_t),u(L)===null&&_t===u(N)&&(fe?(Le(ut),ut=-1):fe=!0,Zt(De,Ct-Ie))):(_t.sortIndex=Qe,a(L,_t),_e||q||(_e=!0,or(Ye))),_t},r.unstable_shouldYield=At,r.unstable_wrapCallback=function(_t){var ft=ee;return function(){var Ct=ee;ee=ft;try{return _t.apply(this,arguments)}finally{ee=Ct}}}})(Ry)),Ry}var ix;function Tw(){return ix||(ix=1,Ay.exports=Sw()),Ay.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var sx;function Pw(){if(sx)return Ta;sx=1;var r=z_(),a=Tw();function u(o){for(var d="https://reactjs.org/docs/error-decoder.html?invariant="+o,x=1;x"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),L=Object.prototype.hasOwnProperty,N=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Z={},V={};function ee(o){return L.call(V,o)?!0:L.call(Z,o)?!1:N.test(o)?V[o]=!0:(Z[o]=!0,!1)}function q(o,d,x,C){if(x!==null&&x.type===0)return!1;switch(typeof d){case"function":case"symbol":return!0;case"boolean":return C?!1:x!==null?!x.acceptsBooleans:(o=o.toLowerCase().slice(0,5),o!=="data-"&&o!=="aria-");default:return!1}}function _e(o,d,x,C){if(d===null||typeof d>"u"||q(o,d,x,C))return!0;if(C)return!1;if(x!==null)switch(x.type){case 3:return!d;case 4:return d===!1;case 5:return isNaN(d);case 6:return isNaN(d)||1>d}return!1}function fe(o,d,x,C,R,O,J){this.acceptsBooleans=d===2||d===3||d===4,this.attributeName=C,this.attributeNamespace=R,this.mustUseProperty=x,this.propertyName=o,this.type=d,this.sanitizeURL=O,this.removeEmptyString=J}var Pe={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(o){Pe[o]=new fe(o,0,!1,o,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(o){var d=o[0];Pe[d]=new fe(d,1,!1,o[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(o){Pe[o]=new fe(o,2,!1,o.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(o){Pe[o]=new fe(o,2,!1,o,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(o){Pe[o]=new fe(o,3,!1,o.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(o){Pe[o]=new fe(o,3,!0,o,null,!1,!1)}),["capture","download"].forEach(function(o){Pe[o]=new fe(o,4,!1,o,null,!1,!1)}),["cols","rows","size","span"].forEach(function(o){Pe[o]=new fe(o,6,!1,o,null,!1,!1)}),["rowSpan","start"].forEach(function(o){Pe[o]=new fe(o,5,!1,o.toLowerCase(),null,!1,!1)});var Le=/[\-:]([a-z])/g;function Me(o){return o[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(o){var d=o.replace(Le,Me);Pe[d]=new fe(d,1,!1,o,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(o){var d=o.replace(Le,Me);Pe[d]=new fe(d,1,!1,o,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(o){var d=o.replace(Le,Me);Pe[d]=new fe(d,1,!1,o,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(o){Pe[o]=new fe(o,1,!1,o.toLowerCase(),null,!1,!1)}),Pe.xlinkHref=new fe("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(o){Pe[o]=new fe(o,1,!1,o.toLowerCase(),null,!0,!0)});function Se(o,d,x,C){var R=Pe.hasOwnProperty(d)?Pe[d]:null;(R!==null?R.type!==0:C||!(2me||R[J]!==O[me]){var Ee=` +`+R[J].replace(" at new "," at ");return o.displayName&&Ee.includes("")&&(Ee=Ee.replace("",o.displayName)),Ee}while(1<=J&&0<=me);break}}}finally{jt=!1,Error.prepareStackTrace=x}return(o=o?o.displayName||o.name:"")?Qe(o):""}function fr(o){switch(o.tag){case 5:return Qe(o.type);case 16:return Qe("Lazy");case 13:return Qe("Suspense");case 19:return Qe("SuspenseList");case 0:case 2:case 15:return o=tr(o.type,!1),o;case 11:return o=tr(o.type.render,!1),o;case 1:return o=tr(o.type,!0),o;default:return""}}function Kt(o){if(o==null)return null;if(typeof o=="function")return o.displayName||o.name||null;if(typeof o=="string")return o;switch(o){case lt:return"Fragment";case ot:return"Portal";case Je:return"Profiler";case ut:return"StrictMode";case Mt:return"Suspense";case Ht:return"SuspenseList"}if(typeof o=="object")switch(o.$$typeof){case At:return(o.displayName||"Context")+".Consumer";case Ke:return(o._context.displayName||"Context")+".Provider";case Rt:var d=o.render;return o=o.displayName,o||(o=d.displayName||d.name||"",o=o!==""?"ForwardRef("+o+")":"ForwardRef"),o;case zr:return d=o.displayName||null,d!==null?d:Kt(o.type)||"Memo";case or:d=o._payload,o=o._init;try{return Kt(o(d))}catch{}}return null}function Lr(o){var d=o.type;switch(o.tag){case 24:return"Cache";case 9:return(d.displayName||"Context")+".Consumer";case 10:return(d._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return o=d.render,o=o.displayName||o.name||"",d.displayName||(o!==""?"ForwardRef("+o+")":"ForwardRef");case 7:return"Fragment";case 5:return d;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Kt(d);case 8:return d===ut?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof d=="function")return d.displayName||d.name||null;if(typeof d=="string")return d}return null}function sr(o){switch(typeof o){case"boolean":case"number":case"string":case"undefined":return o;case"object":return o;default:return""}}function yr(o){var d=o.type;return(o=o.nodeName)&&o.toLowerCase()==="input"&&(d==="checkbox"||d==="radio")}function lr(o){var d=yr(o)?"checked":"value",x=Object.getOwnPropertyDescriptor(o.constructor.prototype,d),C=""+o[d];if(!o.hasOwnProperty(d)&&typeof x<"u"&&typeof x.get=="function"&&typeof x.set=="function"){var R=x.get,O=x.set;return Object.defineProperty(o,d,{configurable:!0,get:function(){return R.call(this)},set:function(J){C=""+J,O.call(this,J)}}),Object.defineProperty(o,d,{enumerable:x.enumerable}),{getValue:function(){return C},setValue:function(J){C=""+J},stopTracking:function(){o._valueTracker=null,delete o[d]}}}}function ci(o){o._valueTracker||(o._valueTracker=lr(o))}function ln(o){if(!o)return!1;var d=o._valueTracker;if(!d)return!0;var x=d.getValue(),C="";return o&&(C=yr(o)?o.checked?"true":"false":o.value),o=C,o!==x?(d.setValue(o),!0):!1}function Vr(o){if(o=o||(typeof document<"u"?document:void 0),typeof o>"u")return null;try{return o.activeElement||o.body}catch{return o.body}}function Nr(o,d){var x=d.checked;return Ct({},d,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:x??o._wrapperState.initialChecked})}function Cn(o,d){var x=d.defaultValue==null?"":d.defaultValue,C=d.checked!=null?d.checked:d.defaultChecked;x=sr(d.value!=null?d.value:x),o._wrapperState={initialChecked:C,initialValue:x,controlled:d.type==="checkbox"||d.type==="radio"?d.checked!=null:d.value!=null}}function jn(o,d){d=d.checked,d!=null&&Se(o,"checked",d,!1)}function Yi(o,d){jn(o,d);var x=sr(d.value),C=d.type;if(x!=null)C==="number"?(x===0&&o.value===""||o.value!=x)&&(o.value=""+x):o.value!==""+x&&(o.value=""+x);else if(C==="submit"||C==="reset"){o.removeAttribute("value");return}d.hasOwnProperty("value")?Zn(o,d.type,x):d.hasOwnProperty("defaultValue")&&Zn(o,d.type,sr(d.defaultValue)),d.checked==null&&d.defaultChecked!=null&&(o.defaultChecked=!!d.defaultChecked)}function Ri(o,d,x){if(d.hasOwnProperty("value")||d.hasOwnProperty("defaultValue")){var C=d.type;if(!(C!=="submit"&&C!=="reset"||d.value!==void 0&&d.value!==null))return;d=""+o._wrapperState.initialValue,x||d===o.value||(o.value=d),o.defaultValue=d}x=o.name,x!==""&&(o.name=""),o.defaultChecked=!!o._wrapperState.initialChecked,x!==""&&(o.name=x)}function Zn(o,d,x){(d!=="number"||Vr(o.ownerDocument)!==o)&&(x==null?o.defaultValue=""+o._wrapperState.initialValue:o.defaultValue!==""+x&&(o.defaultValue=""+x))}var xe=Array.isArray;function Be(o,d,x,C){if(o=o.options,d){d={};for(var R=0;R"+d.valueOf().toString()+"",d=Sr.firstChild;o.firstChild;)o.removeChild(o.firstChild);for(;d.firstChild;)o.appendChild(d.firstChild)}});function K(o,d){if(d){var x=o.firstChild;if(x&&x===o.lastChild&&x.nodeType===3){x.nodeValue=d;return}}o.textContent=d}var X={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},ne=["Webkit","ms","Moz","O"];Object.keys(X).forEach(function(o){ne.forEach(function(d){d=d+o.charAt(0).toUpperCase()+o.substring(1),X[d]=X[o]})});function ue(o,d,x){return d==null||typeof d=="boolean"||d===""?"":x||typeof d!="number"||d===0||X.hasOwnProperty(o)&&X[o]?(""+d).trim():d+"px"}function ve(o,d){o=o.style;for(var x in d)if(d.hasOwnProperty(x)){var C=x.indexOf("--")===0,R=ue(x,d[x],C);x==="float"&&(x="cssFloat"),C?o.setProperty(x,R):o[x]=R}}var Ae=Ct({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Oe(o,d){if(d){if(Ae[o]&&(d.children!=null||d.dangerouslySetInnerHTML!=null))throw Error(u(137,o));if(d.dangerouslySetInnerHTML!=null){if(d.children!=null)throw Error(u(60));if(typeof d.dangerouslySetInnerHTML!="object"||!("__html"in d.dangerouslySetInnerHTML))throw Error(u(61))}if(d.style!=null&&typeof d.style!="object")throw Error(u(62))}}function Re(o,d){if(o.indexOf("-")===-1)return typeof d.is=="string";switch(o){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var qe=null;function nt(o){return o=o.target||o.srcElement||window,o.correspondingUseElement&&(o=o.correspondingUseElement),o.nodeType===3?o.parentNode:o}var et=null,zt=null,Ce=null;function $t(o){if(o=Zl(o)){if(typeof et!="function")throw Error(u(280));var d=o.stateNode;d&&(d=Oc(d),et(o.stateNode,o.type,d))}}function rr(o){zt?Ce?Ce.push(o):Ce=[o]:zt=o}function Dt(){if(zt){var o=zt,d=Ce;if(Ce=zt=null,$t(o),d)for(o=0;o>>=0,o===0?32:31-(Zd(o)/bl|0)|0}var gu=64,yu=4194304;function mc(o){switch(o&-o){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return o&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return o&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return o}}function wl(o,d){var x=o.pendingLanes;if(x===0)return 0;var C=0,R=o.suspendedLanes,O=o.pingedLanes,J=x&268435455;if(J!==0){var me=J&~R;me!==0?C=mc(me):(O&=J,O!==0&&(C=mc(O)))}else J=x&~R,J!==0?C=mc(J):O!==0&&(C=mc(O));if(C===0)return 0;if(d!==0&&d!==C&&(d&R)===0&&(R=C&-C,O=d&-d,R>=O||R===16&&(O&4194240)!==0))return d;if((C&4)!==0&&(C|=x&16),d=o.entangledLanes,d!==0)for(o=o.entanglements,d&=C;0x;x++)d.push(o);return d}function Bo(o,d,x){o.pendingLanes|=d,d!==536870912&&(o.suspendedLanes=0,o.pingedLanes=0),o=o.eventTimes,d=31-ks(d),o[d]=x}function Qf(o,d){var x=o.pendingLanes&~d;o.pendingLanes=d,o.suspendedLanes=0,o.pingedLanes=0,o.expiredLanes&=d,o.mutableReadLanes&=d,o.entangledLanes&=d,d=o.entanglements;var C=o.eventTimes;for(o=o.expirationTimes;0=Al),wc=" ",Mh=!1;function Ah(o,d){switch(o){case"keyup":return Ch.indexOf(d.keyCode)!==-1;case"keydown":return d.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function go(o){return o=o.detail,typeof o=="object"&&"data"in o?o.data:null}var yo=!1;function Rh(o,d){switch(o){case"compositionend":return go(d);case"keypress":return d.which!==32?null:(Mh=!0,wc);case"textInput":return o=d.data,o===wc&&Mh?null:o;default:return null}}function Sc(o,d){if(yo)return o==="compositionend"||!kh&&Ah(o,d)?(o=Is(),$o=kl=Us=null,yo=!1,o):null;switch(o){case"paste":return null;case"keypress":if(!(d.ctrlKey||d.altKey||d.metaKey)||d.ctrlKey&&d.altKey){if(d.char&&1=d)return{node:x,offset:d-o};o=C}e:{for(;x;){if(x.nextSibling){x=x.nextSibling;break e}x=x.parentNode}x=void 0}x=Yd(x)}}function Qd(o,d){return o&&d?o===d?!0:o&&o.nodeType===3?!1:d&&d.nodeType===3?Qd(o,d.parentNode):"contains"in o?o.contains(d):o.compareDocumentPosition?!!(o.compareDocumentPosition(d)&16):!1:!1}function Nh(){for(var o=window,d=Vr();d instanceof o.HTMLIFrameElement;){try{var x=typeof d.contentWindow.location.href=="string"}catch{x=!1}if(x)o=d.contentWindow;else break;d=Vr(o.document)}return d}function Fl(o){var d=o&&o.nodeName&&o.nodeName.toLowerCase();return d&&(d==="input"&&(o.type==="text"||o.type==="search"||o.type==="tel"||o.type==="url"||o.type==="password")||d==="textarea"||o.contentEditable==="true")}function Pu(o){var d=Nh(),x=o.focusedElem,C=o.selectionRange;if(d!==x&&x&&x.ownerDocument&&Qd(x.ownerDocument.documentElement,x)){if(C!==null&&Fl(x)){if(d=C.start,o=C.end,o===void 0&&(o=d),"selectionStart"in x)x.selectionStart=d,x.selectionEnd=Math.min(o,x.value.length);else if(o=(d=x.ownerDocument||document)&&d.defaultView||window,o.getSelection){o=o.getSelection();var R=x.textContent.length,O=Math.min(C.start,R);C=C.end===void 0?O:Math.min(C.end,R),!o.extend&&O>C&&(R=C,C=O,O=R),R=jh(x,O);var J=jh(x,C);R&&J&&(o.rangeCount!==1||o.anchorNode!==R.node||o.anchorOffset!==R.offset||o.focusNode!==J.node||o.focusOffset!==J.offset)&&(d=d.createRange(),d.setStart(R.node,R.offset),o.removeAllRanges(),O>C?(o.addRange(d),o.extend(J.node,J.offset)):(d.setEnd(J.node,J.offset),o.addRange(d)))}}for(d=[],o=x;o=o.parentNode;)o.nodeType===1&&d.push({element:o,left:o.scrollLeft,top:o.scrollTop});for(typeof x.focus=="function"&&x.focus(),x=0;x=document.documentMode,_o=null,La=null,Fa=null,vo=!1;function ps(o,d,x){var C=x.window===x?x.document:x.nodeType===9?x:x.ownerDocument;vo||_o==null||_o!==Vr(C)||(C=_o,"selectionStart"in C&&Fl(C)?C={start:C.selectionStart,end:C.selectionEnd}:(C=(C.ownerDocument&&C.ownerDocument.defaultView||window).getSelection(),C={anchorNode:C.anchorNode,anchorOffset:C.anchorOffset,focusNode:C.focusNode,focusOffset:C.focusOffset}),Fa&&Pc(Fa,C)||(Fa=C,C=Rc(La,"onSelect"),0Yo||(o.current=Au[Yo],Au[Yo]=null,Yo--)}function Or(o,d){Yo++,Au[Yo]=o.current,o.current=d}var gs={},si=ii(gs),zi=ii(!1),So=gs;function Hs(o,d){var x=o.type.contextTypes;if(!x)return gs;var C=o.stateNode;if(C&&C.__reactInternalMemoizedUnmaskedChildContext===d)return C.__reactInternalMemoizedMaskedChildContext;var R={},O;for(O in x)R[O]=d[O];return C&&(o=o.stateNode,o.__reactInternalMemoizedUnmaskedChildContext=d,o.__reactInternalMemoizedMaskedChildContext=R),R}function wi(o){return o=o.childContextTypes,o!=null}function Za(){rn(zi),rn(si)}function Zh(o,d,x){if(si.current!==gs)throw Error(u(168));Or(si,d),Or(zi,x)}function Di(o,d,x){var C=o.stateNode;if(d=d.childContextTypes,typeof C.getChildContext!="function")return x;C=C.getChildContext();for(var R in C)if(!(R in d))throw Error(u(108,Lr(o)||"Unknown",R));return Ct({},x,C)}function Qi(o){return o=(o=o.stateNode)&&o.__reactInternalMemoizedMergedChildContext||gs,So=si.current,Or(si,o),Or(zi,zi.current),!0}function Qo(o,d,x){var C=o.stateNode;if(!C)throw Error(u(169));x?(o=Di(o,d,So),C.__reactInternalMemoizedMergedChildContext=o,rn(zi),rn(si),Or(si,o)):rn(zi),Or(zi,x)}var Hn=null,Ga=!1,Gh=!1;function Gl(o){Hn===null?Hn=[o]:Hn.push(o)}function lp(o){Ga=!0,Gl(o)}function To(){if(!Gh&&Hn!==null){Gh=!0;var o=0,d=qr;try{var x=Hn;for(qr=1;o>=J,R-=J,ts=1<<32-ks(d)+R|x<vr?(gi=dr,dr=null):gi=dr.sibling;var Hr=at(Ve,dr,Ge[vr],bt);if(Hr===null){dr===null&&(dr=gi);break}o&&dr&&Hr.alternate===null&&d(Ve,dr),Fe=O(Hr,Fe,vr),hr===null?Gt=Hr:hr.sibling=Hr,hr=Hr,dr=gi}if(vr===Ge.length)return x(Ve,dr),vn&&_a(Ve,vr),Gt;if(dr===null){for(;vrvr?(gi=dr,dr=null):gi=dr.sibling;var ll=at(Ve,dr,Hr.value,bt);if(ll===null){dr===null&&(dr=gi);break}o&&dr&&ll.alternate===null&&d(Ve,dr),Fe=O(ll,Fe,vr),hr===null?Gt=ll:hr.sibling=ll,hr=ll,dr=gi}if(Hr.done)return x(Ve,dr),vn&&_a(Ve,vr),Gt;if(dr===null){for(;!Hr.done;vr++,Hr=Ge.next())Hr=ct(Ve,Hr.value,bt),Hr!==null&&(Fe=O(Hr,Fe,vr),hr===null?Gt=Hr:hr.sibling=Hr,hr=Hr);return vn&&_a(Ve,vr),Gt}for(dr=C(Ve,dr);!Hr.done;vr++,Hr=Ge.next())Hr=Ot(dr,Ve,vr,Hr.value,bt),Hr!==null&&(o&&Hr.alternate!==null&&dr.delete(Hr.key===null?vr:Hr.key),Fe=O(Hr,Fe,vr),hr===null?Gt=Hr:hr.sibling=Hr,hr=Hr);return o&&dr.forEach(function(dy){return d(Ve,dy)}),vn&&_a(Ve,vr),Gt}function In(Ve,Fe,Ge,bt){if(typeof Ge=="object"&&Ge!==null&&Ge.type===lt&&Ge.key===null&&(Ge=Ge.props.children),typeof Ge=="object"&&Ge!==null){switch(Ge.$$typeof){case Ye:e:{for(var Gt=Ge.key,hr=Fe;hr!==null;){if(hr.key===Gt){if(Gt=Ge.type,Gt===lt){if(hr.tag===7){x(Ve,hr.sibling),Fe=R(hr,Ge.props.children),Fe.return=Ve,Ve=Fe;break e}}else if(hr.elementType===Gt||typeof Gt=="object"&&Gt!==null&&Gt.$$typeof===or&&Du(Gt)===hr.type){x(Ve,hr.sibling),Fe=R(hr,Ge.props),Fe.ref=Wl(Ve,hr,Ge),Fe.return=Ve,Ve=Fe;break e}x(Ve,hr);break}else d(Ve,hr);hr=hr.sibling}Ge.type===lt?(Fe=Yc(Ge.props.children,Ve.mode,bt,Ge.key),Fe.return=Ve,Ve=Fe):(bt=ff(Ge.type,Ge.key,Ge.props,null,Ve.mode,bt),bt.ref=Wl(Ve,Fe,Ge),bt.return=Ve,Ve=bt)}return J(Ve);case ot:e:{for(hr=Ge.key;Fe!==null;){if(Fe.key===hr)if(Fe.tag===4&&Fe.stateNode.containerInfo===Ge.containerInfo&&Fe.stateNode.implementation===Ge.implementation){x(Ve,Fe.sibling),Fe=R(Fe,Ge.children||[]),Fe.return=Ve,Ve=Fe;break e}else{x(Ve,Fe);break}else d(Ve,Fe);Fe=Fe.sibling}Fe=Dp(Ge,Ve.mode,bt),Fe.return=Ve,Ve=Fe}return J(Ve);case or:return hr=Ge._init,In(Ve,Fe,hr(Ge._payload),bt)}if(xe(Ge))return Vt(Ve,Fe,Ge,bt);if(ft(Ge))return Qt(Ve,Fe,Ge,bt);Hl(Ve,Ge)}return typeof Ge=="string"&&Ge!==""||typeof Ge=="number"?(Ge=""+Ge,Fe!==null&&Fe.tag===6?(x(Ve,Fe.sibling),Fe=R(Fe,Ge),Fe.return=Ve,Ve=Fe):(x(Ve,Fe),Fe=zp(Ge,Ve.mode,bt),Fe.return=Ve,Ve=Fe),J(Ve)):x(Ve,Fe)}return In}var Oi=Xh(!0),Bc=Xh(!1),Lt=ii(null),Yt=null,Wa=null,va=null;function Kl(){va=Wa=Yt=null}function fi(o){var d=Lt.current;rn(Lt),o._currentValue=d}function Lu(o,d,x){for(;o!==null;){var C=o.alternate;if((o.childLanes&d)!==d?(o.childLanes|=d,C!==null&&(C.childLanes|=d)):C!==null&&(C.childLanes&d)!==d&&(C.childLanes|=d),o===x)break;o=o.return}}function pi(o,d){Yt=o,va=Wa=null,o=o.dependencies,o!==null&&o.firstContext!==null&&((o.lanes&d)!==0&&(Ds=!0),o.firstContext=null)}function As(o){var d=o._currentValue;if(va!==o)if(o={context:o,memoizedValue:d,next:null},Wa===null){if(Yt===null)throw Error(u(308));Wa=o,Yt.dependencies={lanes:0,firstContext:o}}else Wa=Wa.next=o;return d}var Ks=null;function Yr(o){Ks===null?Ks=[o]:Ks.push(o)}function wn(o,d,x,C){var R=d.interleaved;return R===null?(x.next=x,Yr(d)):(x.next=R.next,R.next=x),d.interleaved=x,Rs(o,C)}function Rs(o,d){o.lanes|=d;var x=o.alternate;for(x!==null&&(x.lanes|=d),x=o,o=o.return;o!==null;)o.childLanes|=d,x=o.alternate,x!==null&&(x.childLanes|=d),x=o,o=o.return;return x.tag===3?x.stateNode:null}var vs=!1;function Co(o){o.updateQueue={baseState:o.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Fu(o,d){o=o.updateQueue,d.updateQueue===o&&(d.updateQueue={baseState:o.baseState,firstBaseUpdate:o.firstBaseUpdate,lastBaseUpdate:o.lastBaseUpdate,shared:o.shared,effects:o.effects})}function rs(o,d){return{eventTime:o,lane:d,tag:0,payload:null,callback:null,next:null}}function ns(o,d,x){var C=o.updateQueue;if(C===null)return null;if(C=C.shared,(Br&2)!==0){var R=C.pending;return R===null?d.next=d:(d.next=R.next,R.next=d),C.pending=d,Rs(o,x)}return R=C.interleaved,R===null?(d.next=d,Yr(C)):(d.next=R.next,R.next=d),C.interleaved=d,Rs(o,x)}function ko(o,d,x){if(d=d.updateQueue,d!==null&&(d=d.shared,(x&4194240)!==0)){var C=d.lanes;C&=o.pendingLanes,x|=C,d.lanes=x,co(o,x)}}function $c(o,d){var x=o.updateQueue,C=o.alternate;if(C!==null&&(C=C.updateQueue,x===C)){var R=null,O=null;if(x=x.firstBaseUpdate,x!==null){do{var J={eventTime:x.eventTime,lane:x.lane,tag:x.tag,payload:x.payload,callback:x.callback,next:null};O===null?R=O=J:O=O.next=J,x=x.next}while(x!==null);O===null?R=O=d:O=O.next=d}else R=O=d;x={baseState:C.baseState,firstBaseUpdate:R,lastBaseUpdate:O,shared:C.shared,effects:C.effects},o.updateQueue=x;return}o=x.lastBaseUpdate,o===null?x.firstBaseUpdate=d:o.next=d,x.lastBaseUpdate=d}function xa(o,d,x,C){var R=o.updateQueue;vs=!1;var O=R.firstBaseUpdate,J=R.lastBaseUpdate,me=R.shared.pending;if(me!==null){R.shared.pending=null;var Ee=me,He=Ee.next;Ee.next=null,J===null?O=He:J.next=He,J=Ee;var dt=o.alternate;dt!==null&&(dt=dt.updateQueue,me=dt.lastBaseUpdate,me!==J&&(me===null?dt.firstBaseUpdate=He:me.next=He,dt.lastBaseUpdate=Ee))}if(O!==null){var ct=R.baseState;J=0,dt=He=Ee=null,me=O;do{var at=me.lane,Ot=me.eventTime;if((C&at)===at){dt!==null&&(dt=dt.next={eventTime:Ot,lane:0,tag:me.tag,payload:me.payload,callback:me.callback,next:null});e:{var Vt=o,Qt=me;switch(at=d,Ot=x,Qt.tag){case 1:if(Vt=Qt.payload,typeof Vt=="function"){ct=Vt.call(Ot,ct,at);break e}ct=Vt;break e;case 3:Vt.flags=Vt.flags&-65537|128;case 0:if(Vt=Qt.payload,at=typeof Vt=="function"?Vt.call(Ot,ct,at):Vt,at==null)break e;ct=Ct({},ct,at);break e;case 2:vs=!0}}me.callback!==null&&me.lane!==0&&(o.flags|=64,at=R.effects,at===null?R.effects=[me]:at.push(me))}else Ot={eventTime:Ot,lane:at,tag:me.tag,payload:me.payload,callback:me.callback,next:null},dt===null?(He=dt=Ot,Ee=ct):dt=dt.next=Ot,J|=at;if(me=me.next,me===null){if(me=R.shared.pending,me===null)break;at=me,me=at.next,at.next=null,R.lastBaseUpdate=at,R.shared.pending=null}}while(!0);if(dt===null&&(Ee=ct),R.baseState=Ee,R.firstBaseUpdate=He,R.lastBaseUpdate=dt,d=R.shared.interleaved,d!==null){R=d;do J|=R.lane,R=R.next;while(R!==d)}else O===null&&(R.shared.lanes=0);Ql|=J,o.lanes=J,o.memoizedState=ct}}function Ou(o,d,x){if(o=d.effects,d.effects=null,o!==null)for(d=0;dx?x:4,o(!0);var C=B.transition;B.transition={};try{o(!1),d()}finally{qr=x,B.transition=C}}function ji(){return $e().memoizedState}function Xs(o,d,x){var C=Ao(o);if(x={lane:C,action:x,hasEagerState:!1,eagerState:null,next:null},Ka(o))Fn(d,x);else if(x=wn(o,d,x,C),x!==null){var R=mi();Ja(x,o,C,R),Nn(x,d,C)}}function Ni(o,d,x){var C=Ao(o),R={lane:C,action:x,hasEagerState:!1,eagerState:null,next:null};if(Ka(o))Fn(d,R);else{var O=o.alternate;if(o.lanes===0&&(O===null||O.lanes===0)&&(O=d.lastRenderedReducer,O!==null))try{var J=d.lastRenderedState,me=O(J,x);if(R.hasEagerState=!0,R.eagerState=me,qs(me,J)){var Ee=d.interleaved;Ee===null?(R.next=R,Yr(d)):(R.next=Ee.next,Ee.next=R),d.interleaved=R;return}}catch{}finally{}x=wn(o,d,R,C),x!==null&&(R=mi(),Ja(x,o,C,R),Nn(x,d,C))}}function Ka(o){var d=o.alternate;return o===$||d!==null&&d===$}function Fn(o,d){ae=se=!0;var x=o.pending;x===null?d.next=d:(d.next=x.next,x.next=d),o.pending=d}function Nn(o,d,x){if((x&4194240)!==0){var C=d.lanes;C&=o.pendingLanes,x|=C,d.lanes=x,co(o,x)}}var Bi={readContext:As,useCallback:le,useContext:le,useEffect:le,useImperativeHandle:le,useInsertionEffect:le,useLayoutEffect:le,useMemo:le,useReducer:le,useRef:le,useState:le,useDebugValue:le,useDeferredValue:le,useTransition:le,useMutableSource:le,useSyncExternalStore:le,useId:le,unstable_isNewReconciler:!1},Bn={readContext:As,useCallback:function(o,d){return ke().memoizedState=[o,d===void 0?null:d],o},useContext:As,useEffect:Dn,useImperativeHandle:function(o,d,x){return x=x!=null?x.concat([o]):null,mr(4194308,4,zs.bind(null,d,o),x)},useLayoutEffect:function(o,d){return mr(4194308,4,o,d)},useInsertionEffect:function(o,d){return mr(4,2,o,d)},useMemo:function(o,d){var x=ke();return d=d===void 0?null:d,o=o(),x.memoizedState=[o,d],o},useReducer:function(o,d,x){var C=ke();return d=x!==void 0?x(d):d,C.memoizedState=C.baseState=d,o={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:o,lastRenderedState:d},C.queue=o,o=o.dispatch=Xs.bind(null,$,o),[C.memoizedState,o]},useRef:function(o){var d=ke();return o={current:o},d.memoizedState=o},useState:Qr,useDebugValue:ba,useDeferredValue:function(o){return ke().memoizedState=o},useTransition:function(){var o=Qr(!1),d=o[0];return o=nl.bind(null,o[1]),ke().memoizedState=o,[d,o]},useMutableSource:function(){},useSyncExternalStore:function(o,d,x){var C=$,R=ke();if(vn){if(x===void 0)throw Error(u(407));x=x()}else{if(x=d(),Yn===null)throw Error(u(349));(j&30)!==0||rt(C,d,x)}R.memoizedState=x;var O={value:x,getSnapshot:d};return R.queue=O,Dn(br.bind(null,C,O,o),[o]),C.flags|=2048,un(9,Ft.bind(null,C,O,x,d),void 0,null),x},useId:function(){var o=ke(),d=Yn.identifierPrefix;if(vn){var x=Fi,C=ts;x=(C&~(1<<32-ks(C)-1)).toString(32)+x,d=":"+d+"R"+x,x=oe++,0<\/script>",o=o.removeChild(o.firstChild)):typeof C.is=="string"?o=J.createElement(x,{is:C.is}):(o=J.createElement(x),x==="select"&&(J=o,C.multiple?J.multiple=!0:C.size&&(J.size=C.size))):o=J.createElementNS(o,x),o[ms]=d,o[Ba]=C,yp(o,d,!1,!1),d.stateNode=o;e:{switch(J=Re(x,C),x){case"dialog":gn("cancel",o),gn("close",o),R=C;break;case"iframe":case"object":case"embed":gn("load",o),R=C;break;case"video":case"audio":for(R=0;RWu&&(d.flags|=128,C=!0,nd(O,!1),d.lanes=4194304)}else{if(!C)if(o=E(J),o!==null){if(d.flags|=128,C=!0,x=o.updateQueue,x!==null&&(d.updateQueue=x,d.flags|=4),nd(O,!0),O.tail===null&&O.tailMode==="hidden"&&!J.alternate&&!vn)return ss(d),null}else 2*kr()-O.renderingStartTime>Wu&&x!==1073741824&&(d.flags|=128,C=!0,nd(O,!1),d.lanes=4194304);O.isBackwards?(J.sibling=d.child,d.child=J):(x=O.last,x!==null?x.sibling=J:d.child=J,O.last=J)}return O.tail!==null?(d=O.tail,O.rendering=d,O.tail=d.sibling,O.renderingStartTime=kr(),d.sibling=null,x=S.current,Or(S,C?x&1|2:x&1),d):(ss(d),null);case 22:case 23:return Ap(),C=d.memoizedState!==null,o!==null&&o.memoizedState!==null!==C&&(d.flags|=8192),C&&(d.mode&1)!==0?(Js&1073741824)!==0&&(ss(d),d.subtreeFlags&6&&(d.flags|=8192)):ss(d),null;case 24:return null;case 25:return null}throw Error(u(156,d.tag))}function ay(o,d){switch(qh(d),d.tag){case 1:return wi(d.type)&&Za(),o=d.flags,o&65536?(d.flags=o&-65537|128,d):null;case 3:return h(),rn(zi),rn(si),z(),o=d.flags,(o&65536)!==0&&(o&128)===0?(d.flags=o&-65537|128,d):null;case 5:return y(d),null;case 13:if(rn(S),o=d.memoizedState,o!==null&&o.dehydrated!==null){if(d.alternate===null)throw Error(u(340));Wr()}return o=d.flags,o&65536?(d.flags=o&-65537|128,d):null;case 19:return rn(S),null;case 4:return h(),null;case 10:return fi(d.type._context),null;case 22:case 23:return Ap(),null;case 24:return null;default:return null}}var $u=!1,Ui=!1,Dm=typeof WeakSet=="function"?WeakSet:Set,Ut=null;function Uu(o,d){var x=o.ref;if(x!==null)if(typeof x=="function")try{x(null)}catch(C){$n(o,d,C)}else x.current=null}function vp(o,d,x){try{x()}catch(C){$n(o,d,C)}}var af=!1;function xp(o,d){if(Vl=bi,o=Nh(),Fl(o)){if("selectionStart"in o)var x={start:o.selectionStart,end:o.selectionEnd};else e:{x=(x=o.ownerDocument)&&x.defaultView||window;var C=x.getSelection&&x.getSelection();if(C&&C.rangeCount!==0){x=C.anchorNode;var R=C.anchorOffset,O=C.focusNode;C=C.focusOffset;try{x.nodeType,O.nodeType}catch{x=null;break e}var J=0,me=-1,Ee=-1,He=0,dt=0,ct=o,at=null;t:for(;;){for(var Ot;ct!==x||R!==0&&ct.nodeType!==3||(me=J+R),ct!==O||C!==0&&ct.nodeType!==3||(Ee=J+C),ct.nodeType===3&&(J+=ct.nodeValue.length),(Ot=ct.firstChild)!==null;)at=ct,ct=Ot;for(;;){if(ct===o)break t;if(at===x&&++He===R&&(me=J),at===O&&++dt===C&&(Ee=J),(Ot=ct.nextSibling)!==null)break;ct=at,at=ct.parentNode}ct=Ot}x=me===-1||Ee===-1?null:{start:me,end:Ee}}else x=null}x=x||{start:0,end:0}}else x=null;for(Na={focusedElem:o,selectionRange:x},bi=!1,Ut=d;Ut!==null;)if(d=Ut,o=d.child,(d.subtreeFlags&1028)!==0&&o!==null)o.return=d,Ut=o;else for(;Ut!==null;){d=Ut;try{var Vt=d.alternate;if((d.flags&1024)!==0)switch(d.tag){case 0:case 11:case 15:break;case 1:if(Vt!==null){var Qt=Vt.memoizedProps,In=Vt.memoizedState,Ve=d.stateNode,Fe=Ve.getSnapshotBeforeUpdate(d.elementType===d.type?Qt:Xn(d.type,Qt),In);Ve.__reactInternalSnapshotBeforeUpdate=Fe}break;case 3:var Ge=d.stateNode.containerInfo;Ge.nodeType===1?Ge.textContent="":Ge.nodeType===9&&Ge.documentElement&&Ge.removeChild(Ge.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(u(163))}}catch(bt){$n(d,d.return,bt)}if(o=d.sibling,o!==null){o.return=d.return,Ut=o;break}Ut=d.return}return Vt=af,af=!1,Vt}function Vu(o,d,x){var C=d.updateQueue;if(C=C!==null?C.lastEffect:null,C!==null){var R=C=C.next;do{if((R.tag&o)===o){var O=R.destroy;R.destroy=void 0,O!==void 0&&vp(d,x,O)}R=R.next}while(R!==C)}}function Io(o,d){if(d=d.updateQueue,d=d!==null?d.lastEffect:null,d!==null){var x=d=d.next;do{if((x.tag&o)===o){var C=x.create;x.destroy=C()}x=x.next}while(x!==d)}}function Gc(o){var d=o.ref;if(d!==null){var x=o.stateNode;switch(o.tag){case 5:o=x;break;default:o=x}typeof d=="function"?d(o):d.current=o}}function Lm(o){var d=o.alternate;d!==null&&(o.alternate=null,Lm(d)),o.child=null,o.deletions=null,o.sibling=null,o.tag===5&&(d=o.stateNode,d!==null&&(delete d[ms],delete d[Ba],delete d[$a],delete d[Mu],delete d[Ua])),o.stateNode=null,o.return=null,o.dependencies=null,o.memoizedProps=null,o.memoizedState=null,o.pendingProps=null,o.stateNode=null,o.updateQueue=null}function Fm(o){return o.tag===5||o.tag===3||o.tag===4}function Om(o){e:for(;;){for(;o.sibling===null;){if(o.return===null||Fm(o.return))return null;o=o.return}for(o.sibling.return=o.return,o=o.sibling;o.tag!==5&&o.tag!==6&&o.tag!==18;){if(o.flags&2||o.child===null||o.tag===4)continue e;o.child.return=o,o=o.child}if(!(o.flags&2))return o.stateNode}}function bp(o,d,x){var C=o.tag;if(C===5||C===6)o=o.stateNode,d?x.nodeType===8?x.parentNode.insertBefore(o,d):x.insertBefore(o,d):(x.nodeType===8?(d=x.parentNode,d.insertBefore(o,x)):(d=x,d.appendChild(o)),x=x._reactRootContainer,x!=null||d.onclick!==null||(d.onclick=Ul));else if(C!==4&&(o=o.child,o!==null))for(bp(o,d,x),o=o.sibling;o!==null;)bp(o,d,x),o=o.sibling}function wp(o,d,x){var C=o.tag;if(C===5||C===6)o=o.stateNode,d?x.insertBefore(o,d):x.appendChild(o);else if(C!==4&&(o=o.child,o!==null))for(wp(o,d,x),o=o.sibling;o!==null;)wp(o,d,x),o=o.sibling}var Vi=null,Qa=!1;function Mo(o,d,x){for(x=x.child;x!==null;)jm(o,d,x),x=x.sibling}function jm(o,d,x){if(ui&&typeof ui.onCommitFiberUnmount=="function")try{ui.onCommitFiberUnmount(Ns,x)}catch{}switch(x.tag){case 5:Ui||Uu(x,d);case 6:var C=Vi,R=Qa;Vi=null,Mo(o,d,x),Vi=C,Qa=R,Vi!==null&&(Qa?(o=Vi,x=x.stateNode,o.nodeType===8?o.parentNode.removeChild(x):o.removeChild(x)):Vi.removeChild(x.stateNode));break;case 18:Vi!==null&&(Qa?(o=Vi,x=x.stateNode,o.nodeType===8?Fc(o.parentNode,x):o.nodeType===1&&Fc(o,x),fs(o)):Fc(Vi,x.stateNode));break;case 4:C=Vi,R=Qa,Vi=x.stateNode.containerInfo,Qa=!0,Mo(o,d,x),Vi=C,Qa=R;break;case 0:case 11:case 14:case 15:if(!Ui&&(C=x.updateQueue,C!==null&&(C=C.lastEffect,C!==null))){R=C=C.next;do{var O=R,J=O.destroy;O=O.tag,J!==void 0&&((O&2)!==0||(O&4)!==0)&&vp(x,d,J),R=R.next}while(R!==C)}Mo(o,d,x);break;case 1:if(!Ui&&(Uu(x,d),C=x.stateNode,typeof C.componentWillUnmount=="function"))try{C.props=x.memoizedProps,C.state=x.memoizedState,C.componentWillUnmount()}catch(me){$n(x,d,me)}Mo(o,d,x);break;case 21:Mo(o,d,x);break;case 22:x.mode&1?(Ui=(C=Ui)||x.memoizedState!==null,Mo(o,d,x),Ui=C):Mo(o,d,x);break;default:Mo(o,d,x)}}function Sp(o){var d=o.updateQueue;if(d!==null){o.updateQueue=null;var x=o.stateNode;x===null&&(x=o.stateNode=new Dm),d.forEach(function(C){var R=pd.bind(null,o,C);x.has(C)||(x.add(C),C.then(R,R))})}}function Ss(o,d){var x=d.deletions;if(x!==null)for(var C=0;CR&&(R=J),C&=~O}if(C=R,C=kr()-C,C=(120>C?120:480>C?480:1080>C?1080:1920>C?1920:3e3>C?3e3:4320>C?4320:1960*Bm(C/1960))-C,10o?16:o,ec===null)var C=!1;else{if(o=ec,ec=null,cd=0,(Br&6)!==0)throw Error(u(331));var R=Br;for(Br|=4,Ut=o.current;Ut!==null;){var O=Ut,J=O.child;if((Ut.flags&16)!==0){var me=O.deletions;if(me!==null){for(var Ee=0;Eekr()-Pp?Kc(o,0):ld|=x),Fs(o,d)}function Hm(o,d){d===0&&((o.mode&1)===0?d=1:(d=yu,yu<<=1,(yu&130023424)===0&&(yu=4194304)));var x=mi();o=Rs(o,d),o!==null&&(Bo(o,d,x),Fs(o,x))}function ly(o){var d=o.memoizedState,x=0;d!==null&&(x=d.retryLane),Hm(o,x)}function pd(o,d){var x=0;switch(o.tag){case 13:var C=o.stateNode,R=o.memoizedState;R!==null&&(x=R.retryLane);break;case 19:C=o.stateNode;break;default:throw Error(u(314))}C!==null&&C.delete(d),Hm(o,x)}var Jn;Jn=function(o,d,x){if(o!==null)if(o.memoizedProps!==d.pendingProps||zi.current)Ds=!0;else{if((o.lanes&x)===0&&(d.flags&128)===0)return Ds=!1,ws(o,d,x);Ds=(o.flags&131072)!==0}else Ds=!1,vn&&(d.flags&1048576)!==0&&Fr(d,kt,d.index);switch(d.lanes=0,d.tag){case 2:var C=d.type;Bu(o,d),o=d.pendingProps;var R=Hs(d,si.current);pi(d,x),R=we(null,d,C,o,R,x);var O=he();return d.flags|=1,typeof R=="object"&&R!==null&&typeof R.render=="function"&&R.$$typeof===void 0?(d.tag=1,d.memoizedState=null,d.updateQueue=null,wi(C)?(O=!0,Qi(d)):O=!1,d.memoizedState=R.state!==null&&R.state!==void 0?R.state:null,Co(d),R.updater=Ys,d.stateNode=R,R._reactInternals=d,Vc(d,C,o,x),d=Nu(null,d,C,!0,O,x)):(d.tag=0,vn&&O&&ql(d),$i(null,d,R,x),d=d.child),d;case 16:C=d.elementType;e:{switch(Bu(o,d),o=d.pendingProps,R=C._init,C=R(C._payload),d.type=C,R=d.tag=Xu(C),o=Xn(C,o),R){case 0:d=fp(null,d,C,o,x);break e;case 1:d=ju(null,d,C,o,x);break e;case 11:d=Em(null,d,C,o,x);break e;case 14:d=rf(null,d,C,Xn(C.type,o),x);break e}throw Error(u(306,C,""))}return d;case 0:return C=d.type,R=d.pendingProps,R=d.elementType===C?R:Xn(C,R),fp(o,d,C,R,x);case 1:return C=d.type,R=d.pendingProps,R=d.elementType===C?R:Xn(C,R),ju(o,d,C,R,x);case 3:e:{if(Jh(d),o===null)throw Error(u(387));C=d.pendingProps,O=d.memoizedState,R=O.element,Fu(o,d),xa(d,C,null,x);var J=d.memoizedState;if(C=J.element,O.isDehydrated)if(O={element:C,isDehydrated:!1,cache:J.cache,pendingSuspenseBoundaries:J.pendingSuspenseBoundaries,transitions:J.transitions},d.updateQueue.baseState=O,d.memoizedState=O,d.flags&256){R=Xa(Error(u(423)),d),d=pp(o,d,C,x,R);break e}else if(C!==R){R=Xa(Error(u(424)),d),d=pp(o,d,C,x,R);break e}else for(ys=tn(d.stateNode.containerInfo.firstChild),Si=d,vn=!0,_s=null,x=Bc(d,null,C,x),d.child=x;x;)x.flags=x.flags&-3|4096,x=x.sibling;else{if(Wr(),C===R){d=Qs(o,d,x);break e}$i(o,d,C,x)}d=d.child}return d;case 5:return p(d),o===null&&jc(d),C=d.type,R=d.pendingProps,O=o!==null?o.memoizedProps:null,J=R.children,Dc(C,R)?J=null:O!==null&&Dc(C,O)&&(d.flags|=32),Zc(o,d),$i(o,d,J,x),d.child;case 6:return o===null&&jc(d),null;case 13:return Im(o,d,x);case 4:return s(d,d.stateNode.containerInfo),C=d.pendingProps,o===null?d.child=Oi(d,null,C,x):$i(o,d,C,x),d.child;case 11:return C=d.type,R=d.pendingProps,R=d.elementType===C?R:Xn(C,R),Em(o,d,C,R,x);case 7:return $i(o,d,d.pendingProps,x),d.child;case 8:return $i(o,d,d.pendingProps.children,x),d.child;case 12:return $i(o,d,d.pendingProps.children,x),d.child;case 10:e:{if(C=d.type._context,R=d.pendingProps,O=d.memoizedProps,J=R.value,Or(Lt,C._currentValue),C._currentValue=J,O!==null)if(qs(O.value,J)){if(O.children===R.children&&!zi.current){d=Qs(o,d,x);break e}}else for(O=d.child,O!==null&&(O.return=d);O!==null;){var me=O.dependencies;if(me!==null){J=O.child;for(var Ee=me.firstContext;Ee!==null;){if(Ee.context===C){if(O.tag===1){Ee=rs(-1,x&-x),Ee.tag=2;var He=O.updateQueue;if(He!==null){He=He.shared;var dt=He.pending;dt===null?Ee.next=Ee:(Ee.next=dt.next,dt.next=Ee),He.pending=Ee}}O.lanes|=x,Ee=O.alternate,Ee!==null&&(Ee.lanes|=x),Lu(O.return,x,d),me.lanes|=x;break}Ee=Ee.next}}else if(O.tag===10)J=O.type===d.type?null:O.child;else if(O.tag===18){if(J=O.return,J===null)throw Error(u(341));J.lanes|=x,me=J.alternate,me!==null&&(me.lanes|=x),Lu(J,x,d),J=O.sibling}else J=O.child;if(J!==null)J.return=O;else for(J=O;J!==null;){if(J===d){J=null;break}if(O=J.sibling,O!==null){O.return=J.return,J=O;break}J=J.return}O=J}$i(o,d,R.children,x),d=d.child}return d;case 9:return R=d.type,C=d.pendingProps.children,pi(d,x),R=As(R),C=C(R),d.flags|=1,$i(o,d,C,x),d.child;case 14:return C=d.type,R=Xn(C,d.pendingProps),R=Xn(C.type,R),rf(o,d,C,R,x);case 15:return hp(o,d,d.type,d.pendingProps,x);case 17:return C=d.type,R=d.pendingProps,R=d.elementType===C?R:Xn(C,R),Bu(o,d),d.tag=1,wi(C)?(o=!0,Qi(d)):o=!1,pi(d,x),is(d,C,R),Vc(d,C,R,x),Nu(null,d,C,!0,o,x);case 19:return gp(o,d,x);case 22:return dp(o,d,x)}throw Error(u(156,d.tag))};function df(o,d){return Ma(o,d)}function Km(o,d,x,C){this.tag=o,this.key=x,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=d,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=C,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function ta(o,d,x,C){return new Km(o,d,x,C)}function md(o){return o=o.prototype,!(!o||!o.isReactComponent)}function Xu(o){if(typeof o=="function")return md(o)?1:0;if(o!=null){if(o=o.$$typeof,o===Rt)return 11;if(o===zr)return 14}return 2}function sl(o,d){var x=o.alternate;return x===null?(x=ta(o.tag,d,o.key,o.mode),x.elementType=o.elementType,x.type=o.type,x.stateNode=o.stateNode,x.alternate=o,o.alternate=x):(x.pendingProps=d,x.type=o.type,x.flags=0,x.subtreeFlags=0,x.deletions=null),x.flags=o.flags&14680064,x.childLanes=o.childLanes,x.lanes=o.lanes,x.child=o.child,x.memoizedProps=o.memoizedProps,x.memoizedState=o.memoizedState,x.updateQueue=o.updateQueue,d=o.dependencies,x.dependencies=d===null?null:{lanes:d.lanes,firstContext:d.firstContext},x.sibling=o.sibling,x.index=o.index,x.ref=o.ref,x}function ff(o,d,x,C,R,O){var J=2;if(C=o,typeof o=="function")md(o)&&(J=1);else if(typeof o=="string")J=5;else e:switch(o){case lt:return Yc(x.children,R,O,d);case ut:J=8,R|=8;break;case Je:return o=ta(12,x,d,R|2),o.elementType=Je,o.lanes=O,o;case Mt:return o=ta(13,x,d,R),o.elementType=Mt,o.lanes=O,o;case Ht:return o=ta(19,x,d,R),o.elementType=Ht,o.lanes=O,o;case Zt:return pf(x,R,O,d);default:if(typeof o=="object"&&o!==null)switch(o.$$typeof){case Ke:J=10;break e;case At:J=9;break e;case Rt:J=11;break e;case zr:J=14;break e;case or:J=16,C=null;break e}throw Error(u(130,o==null?o:typeof o,""))}return d=ta(J,x,d,R),d.elementType=o,d.type=C,d.lanes=O,d}function Yc(o,d,x,C){return o=ta(7,o,C,d),o.lanes=x,o}function pf(o,d,x,C){return o=ta(22,o,C,d),o.elementType=Zt,o.lanes=x,o.stateNode={isHidden:!1},o}function zp(o,d,x){return o=ta(6,o,null,d),o.lanes=x,o}function Dp(o,d,x){return d=ta(4,o.children!==null?o.children:[],o.key,d),d.lanes=x,d.stateNode={containerInfo:o.containerInfo,pendingChildren:null,implementation:o.implementation},d}function cy(o,d,x,C,R){this.tag=d,this.containerInfo=o,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=yc(0),this.expirationTimes=yc(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=yc(0),this.identifierPrefix=C,this.onRecoverableError=R,this.mutableSourceEagerHydrationData=null}function Lp(o,d,x,C,R,O,J,me,Ee){return o=new cy(o,d,x,me,Ee),d===1?(d=1,O===!0&&(d|=8)):d=0,O=ta(3,null,null,d),o.current=O,O.stateNode=o,O.memoizedState={element:C,isDehydrated:x,cache:null,transitions:null,pendingSuspenseBoundaries:null},Co(O),o}function uy(o,d,x){var C=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(a){console.error(a)}}return r(),My.exports=Pw(),My.exports}var ox;function Cw(){if(ox)return cg;ox=1;var r=Y0();return cg.createRoot=r.createRoot,cg.hydrateRoot=r.hydrateRoot,cg}var kw=Cw();const Ew=R_(kw);/** + * react-router v7.9.1 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var lx="popstate";function Iw(r={}){function a(m,b){let{pathname:P,search:w,hash:l}=m.location;return Zy("",{pathname:P,search:w,hash:l},b.state&&b.state.usr||null,b.state&&b.state.key||"default")}function u(m,b){return typeof b=="string"?b:em(b)}return Aw(a,u,null,r)}function Un(r,a){if(r===!1||r===null||typeof r>"u")throw new Error(a)}function yl(r,a){if(!r){typeof console<"u"&&console.warn(a);try{throw new Error(a)}catch{}}}function Mw(){return Math.random().toString(36).substring(2,10)}function cx(r,a){return{usr:r.state,key:r.key,idx:a}}function Zy(r,a,u=null,m){return{pathname:typeof r=="string"?r:r.pathname,search:"",hash:"",...typeof a=="string"?Gf(a):a,state:u,key:a&&a.key||m||Mw()}}function em({pathname:r="/",search:a="",hash:u=""}){return a&&a!=="?"&&(r+=a.charAt(0)==="?"?a:"?"+a),u&&u!=="#"&&(r+=u.charAt(0)==="#"?u:"#"+u),r}function Gf(r){let a={};if(r){let u=r.indexOf("#");u>=0&&(a.hash=r.substring(u),r=r.substring(0,u));let m=r.indexOf("?");m>=0&&(a.search=r.substring(m),r=r.substring(0,m)),r&&(a.pathname=r)}return a}function Aw(r,a,u,m={}){let{window:b=document.defaultView,v5Compat:P=!1}=m,w=b.history,l="POP",L=null,N=Z();N==null&&(N=0,w.replaceState({...w.state,idx:N},""));function Z(){return(w.state||{idx:null}).idx}function V(){l="POP";let Pe=Z(),Le=Pe==null?null:Pe-N;N=Pe,L&&L({action:l,location:fe.location,delta:Le})}function ee(Pe,Le){l="PUSH";let Me=Zy(fe.location,Pe,Le);N=Z()+1;let Se=cx(Me,N),De=fe.createHref(Me);try{w.pushState(Se,"",De)}catch(Ye){if(Ye instanceof DOMException&&Ye.name==="DataCloneError")throw Ye;b.location.assign(De)}P&&L&&L({action:l,location:fe.location,delta:1})}function q(Pe,Le){l="REPLACE";let Me=Zy(fe.location,Pe,Le);N=Z();let Se=cx(Me,N),De=fe.createHref(Me);w.replaceState(Se,"",De),P&&L&&L({action:l,location:fe.location,delta:0})}function _e(Pe){return Rw(Pe)}let fe={get action(){return l},get location(){return r(b,w)},listen(Pe){if(L)throw new Error("A history only accepts one active listener");return b.addEventListener(lx,V),L=Pe,()=>{b.removeEventListener(lx,V),L=null}},createHref(Pe){return a(b,Pe)},createURL:_e,encodeLocation(Pe){let Le=_e(Pe);return{pathname:Le.pathname,search:Le.search,hash:Le.hash}},push:ee,replace:q,go(Pe){return w.go(Pe)}};return fe}function Rw(r,a=!1){let u="http://localhost";typeof window<"u"&&(u=window.location.origin!=="null"?window.location.origin:window.location.href),Un(u,"No window.location.(origin|href) available to create URL");let m=typeof r=="string"?r:em(r);return m=m.replace(/ $/,"%20"),!a&&m.startsWith("//")&&(m=u+m),new URL(m,u)}function Q0(r,a,u="/"){return zw(r,a,u,!1)}function zw(r,a,u,m){let b=typeof a=="string"?Gf(a):a,P=hu(b.pathname||"/",u);if(P==null)return null;let w=J0(r);Dw(w);let l=null;for(let L=0;l==null&&L{let Z={relativePath:N===void 0?w.path||"":N,caseSensitive:w.caseSensitive===!0,childrenIndex:l,route:w};if(Z.relativePath.startsWith("/")){if(!Z.relativePath.startsWith(m)&&L)return;Un(Z.relativePath.startsWith(m),`Absolute route path "${Z.relativePath}" nested under path "${m}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),Z.relativePath=Z.relativePath.slice(m.length)}let V=lu([m,Z.relativePath]),ee=u.concat(Z);w.children&&w.children.length>0&&(Un(w.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${V}".`),J0(w.children,a,ee,V,L)),!(w.path==null&&!w.index)&&a.push({path:V,score:$w(V,w.index),routesMeta:ee})};return r.forEach((w,l)=>{var L;if(w.path===""||!((L=w.path)!=null&&L.includes("?")))P(w,l);else for(let N of eb(w.path))P(w,l,!0,N)}),a}function eb(r){let a=r.split("/");if(a.length===0)return[];let[u,...m]=a,b=u.endsWith("?"),P=u.replace(/\?$/,"");if(m.length===0)return b?[P,""]:[P];let w=eb(m.join("/")),l=[];return l.push(...w.map(L=>L===""?P:[P,L].join("/"))),b&&l.push(...w),l.map(L=>r.startsWith("/")&&L===""?"/":L)}function Dw(r){r.sort((a,u)=>a.score!==u.score?u.score-a.score:Uw(a.routesMeta.map(m=>m.childrenIndex),u.routesMeta.map(m=>m.childrenIndex)))}var Lw=/^:[\w-]+$/,Fw=3,Ow=2,jw=1,Nw=10,Bw=-2,ux=r=>r==="*";function $w(r,a){let u=r.split("/"),m=u.length;return u.some(ux)&&(m+=Bw),a&&(m+=Ow),u.filter(b=>!ux(b)).reduce((b,P)=>b+(Lw.test(P)?Fw:P===""?jw:Nw),m)}function Uw(r,a){return r.length===a.length&&r.slice(0,-1).every((m,b)=>m===a[b])?r[r.length-1]-a[a.length-1]:0}function Vw(r,a,u=!1){let{routesMeta:m}=r,b={},P="/",w=[];for(let l=0;l{if(Z==="*"){let _e=l[ee]||"";w=P.slice(0,P.length-_e.length).replace(/(.)\/+$/,"$1")}const q=l[ee];return V&&!q?N[Z]=void 0:N[Z]=(q||"").replace(/%2F/g,"/"),N},{}),pathname:P,pathnameBase:w,pattern:r}}function Zw(r,a=!1,u=!0){yl(r==="*"||!r.endsWith("*")||r.endsWith("/*"),`Route path "${r}" will be treated as if it were "${r.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${r.replace(/\*$/,"/*")}".`);let m=[],b="^"+r.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(w,l,L)=>(m.push({paramName:l,isOptional:L!=null}),L?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return r.endsWith("*")?(m.push({paramName:"*"}),b+=r==="*"||r==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):u?b+="\\/*$":r!==""&&r!=="/"&&(b+="(?:(?=\\/|$))"),[new RegExp(b,a?void 0:"i"),m]}function Gw(r){try{return r.split("/").map(a=>decodeURIComponent(a).replace(/\//g,"%2F")).join("/")}catch(a){return yl(!1,`The URL path "${r}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${a}).`),r}}function hu(r,a){if(a==="/")return r;if(!r.toLowerCase().startsWith(a.toLowerCase()))return null;let u=a.endsWith("/")?a.length-1:a.length,m=r.charAt(u);return m&&m!=="/"?null:r.slice(u)||"/"}function qw(r,a="/"){let{pathname:u,search:m="",hash:b=""}=typeof r=="string"?Gf(r):r;return{pathname:u?u.startsWith("/")?u:Ww(u,a):a,search:Xw(m),hash:Yw(b)}}function Ww(r,a){let u=a.replace(/\/+$/,"").split("/");return r.split("/").forEach(b=>{b===".."?u.length>1&&u.pop():b!=="."&&u.push(b)}),u.length>1?u.join("/"):"/"}function zy(r,a,u,m){return`Cannot include a '${r}' character in a manually specified \`to.${a}\` field [${JSON.stringify(m)}]. Please separate it out to the \`to.${u}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function Hw(r){return r.filter((a,u)=>u===0||a.route.path&&a.route.path.length>0)}function D_(r){let a=Hw(r);return a.map((u,m)=>m===a.length-1?u.pathname:u.pathnameBase)}function L_(r,a,u,m=!1){let b;typeof r=="string"?b=Gf(r):(b={...r},Un(!b.pathname||!b.pathname.includes("?"),zy("?","pathname","search",b)),Un(!b.pathname||!b.pathname.includes("#"),zy("#","pathname","hash",b)),Un(!b.search||!b.search.includes("#"),zy("#","search","hash",b)));let P=r===""||b.pathname==="",w=P?"/":b.pathname,l;if(w==null)l=u;else{let V=a.length-1;if(!m&&w.startsWith("..")){let ee=w.split("/");for(;ee[0]==="..";)ee.shift(),V-=1;b.pathname=ee.join("/")}l=V>=0?a[V]:"/"}let L=qw(b,l),N=w&&w!=="/"&&w.endsWith("/"),Z=(P||w===".")&&u.endsWith("/");return!L.pathname.endsWith("/")&&(N||Z)&&(L.pathname+="/"),L}var lu=r=>r.join("/").replace(/\/\/+/g,"/"),Kw=r=>r.replace(/\/+$/,"").replace(/^\/*/,"/"),Xw=r=>!r||r==="?"?"":r.startsWith("?")?r:"?"+r,Yw=r=>!r||r==="#"?"":r.startsWith("#")?r:"#"+r;function Qw(r){return r!=null&&typeof r.status=="number"&&typeof r.statusText=="string"&&typeof r.internal=="boolean"&&"data"in r}var tb=["POST","PUT","PATCH","DELETE"];new Set(tb);var Jw=["GET",...tb];new Set(Jw);var qf=te.createContext(null);qf.displayName="DataRouter";var Vg=te.createContext(null);Vg.displayName="DataRouterState";te.createContext(!1);var rb=te.createContext({isTransitioning:!1});rb.displayName="ViewTransition";var e2=te.createContext(new Map);e2.displayName="Fetchers";var t2=te.createContext(null);t2.displayName="Await";var vl=te.createContext(null);vl.displayName="Navigation";var ym=te.createContext(null);ym.displayName="Location";var Fo=te.createContext({outlet:null,matches:[],isDataRoute:!1});Fo.displayName="Route";var F_=te.createContext(null);F_.displayName="RouteError";function r2(r,{relative:a}={}){Un(Wf(),"useHref() may be used only in the context of a component.");let{basename:u,navigator:m}=te.useContext(vl),{hash:b,pathname:P,search:w}=_m(r,{relative:a}),l=P;return u!=="/"&&(l=P==="/"?u:lu([u,P])),m.createHref({pathname:l,search:w,hash:b})}function Wf(){return te.useContext(ym)!=null}function Oo(){return Un(Wf(),"useLocation() may be used only in the context of a component."),te.useContext(ym).location}var nb="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function ib(r){te.useContext(vl).static||te.useLayoutEffect(r)}function Bd(){let{isDataRoute:r}=te.useContext(Fo);return r?_2():n2()}function n2(){Un(Wf(),"useNavigate() may be used only in the context of a component.");let r=te.useContext(qf),{basename:a,navigator:u}=te.useContext(vl),{matches:m}=te.useContext(Fo),{pathname:b}=Oo(),P=JSON.stringify(D_(m)),w=te.useRef(!1);return ib(()=>{w.current=!0}),te.useCallback((L,N={})=>{if(yl(w.current,nb),!w.current)return;if(typeof L=="number"){u.go(L);return}let Z=L_(L,JSON.parse(P),b,N.relative==="path");r==null&&a!=="/"&&(Z.pathname=Z.pathname==="/"?a:lu([a,Z.pathname])),(N.replace?u.replace:u.push)(Z,N.state,N)},[a,u,P,b,r])}var i2=te.createContext(null);function s2(r){let a=te.useContext(Fo).outlet;return a&&te.createElement(i2.Provider,{value:r},a)}function a2(){let{matches:r}=te.useContext(Fo),a=r[r.length-1];return a?a.params:{}}function _m(r,{relative:a}={}){let{matches:u}=te.useContext(Fo),{pathname:m}=Oo(),b=JSON.stringify(D_(u));return te.useMemo(()=>L_(r,JSON.parse(b),m,a==="path"),[r,b,m,a])}function o2(r,a){return sb(r,a)}function sb(r,a,u,m,b){var Me;Un(Wf(),"useRoutes() may be used only in the context of a component.");let{navigator:P}=te.useContext(vl),{matches:w}=te.useContext(Fo),l=w[w.length-1],L=l?l.params:{},N=l?l.pathname:"/",Z=l?l.pathnameBase:"/",V=l&&l.route;{let Se=V&&V.path||"";ab(N,!V||Se.endsWith("*")||Se.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${N}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let ee=Oo(),q;if(a){let Se=typeof a=="string"?Gf(a):a;Un(Z==="/"||((Me=Se.pathname)==null?void 0:Me.startsWith(Z)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${Z}" but pathname "${Se.pathname}" was given in the \`location\` prop.`),q=Se}else q=ee;let _e=q.pathname||"/",fe=_e;if(Z!=="/"){let Se=Z.replace(/^\//,"").split("/");fe="/"+_e.replace(/^\//,"").split("/").slice(Se.length).join("/")}let Pe=Q0(r,{pathname:fe});yl(V||Pe!=null,`No routes matched location "${q.pathname}${q.search}${q.hash}" `),yl(Pe==null||Pe[Pe.length-1].route.element!==void 0||Pe[Pe.length-1].route.Component!==void 0||Pe[Pe.length-1].route.lazy!==void 0,`Matched leaf route at location "${q.pathname}${q.search}${q.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let Le=d2(Pe&&Pe.map(Se=>Object.assign({},Se,{params:Object.assign({},L,Se.params),pathname:lu([Z,P.encodeLocation?P.encodeLocation(Se.pathname).pathname:Se.pathname]),pathnameBase:Se.pathnameBase==="/"?Z:lu([Z,P.encodeLocation?P.encodeLocation(Se.pathnameBase).pathname:Se.pathnameBase])})),w,u,m,b);return a&&Le?te.createElement(ym.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...q},navigationType:"POP"}},Le):Le}function l2(){let r=y2(),a=Qw(r)?`${r.status} ${r.statusText}`:r instanceof Error?r.message:JSON.stringify(r),u=r instanceof Error?r.stack:null,m="rgba(200,200,200, 0.5)",b={padding:"0.5rem",backgroundColor:m},P={padding:"2px 4px",backgroundColor:m},w=null;return console.error("Error handled by React Router default ErrorBoundary:",r),w=te.createElement(te.Fragment,null,te.createElement("p",null,"💿 Hey developer 👋"),te.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",te.createElement("code",{style:P},"ErrorBoundary")," or"," ",te.createElement("code",{style:P},"errorElement")," prop on your route.")),te.createElement(te.Fragment,null,te.createElement("h2",null,"Unexpected Application Error!"),te.createElement("h3",{style:{fontStyle:"italic"}},a),u?te.createElement("pre",{style:b},u):null,w)}var c2=te.createElement(l2,null),u2=class extends te.Component{constructor(r){super(r),this.state={location:r.location,revalidation:r.revalidation,error:r.error}}static getDerivedStateFromError(r){return{error:r}}static getDerivedStateFromProps(r,a){return a.location!==r.location||a.revalidation!=="idle"&&r.revalidation==="idle"?{error:r.error,location:r.location,revalidation:r.revalidation}:{error:r.error!==void 0?r.error:a.error,location:a.location,revalidation:r.revalidation||a.revalidation}}componentDidCatch(r,a){this.props.unstable_onError?this.props.unstable_onError(r,a):console.error("React Router caught the following error during render",r)}render(){return this.state.error!==void 0?te.createElement(Fo.Provider,{value:this.props.routeContext},te.createElement(F_.Provider,{value:this.state.error,children:this.props.component})):this.props.children}};function h2({routeContext:r,match:a,children:u}){let m=te.useContext(qf);return m&&m.static&&m.staticContext&&(a.route.errorElement||a.route.ErrorBoundary)&&(m.staticContext._deepestRenderedBoundaryId=a.route.id),te.createElement(Fo.Provider,{value:r},u)}function d2(r,a=[],u=null,m=null,b=null){if(r==null){if(!u)return null;if(u.errors)r=u.matches;else if(a.length===0&&!u.initialized&&u.matches.length>0)r=u.matches;else return null}let P=r,w=u==null?void 0:u.errors;if(w!=null){let N=P.findIndex(Z=>Z.route.id&&(w==null?void 0:w[Z.route.id])!==void 0);Un(N>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(w).join(",")}`),P=P.slice(0,Math.min(P.length,N+1))}let l=!1,L=-1;if(u)for(let N=0;N=0?P=P.slice(0,L+1):P=[P[0]];break}}}return P.reduceRight((N,Z,V)=>{let ee,q=!1,_e=null,fe=null;u&&(ee=w&&Z.route.id?w[Z.route.id]:void 0,_e=Z.route.errorElement||c2,l&&(L<0&&V===0?(ab("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),q=!0,fe=null):L===V&&(q=!0,fe=Z.route.hydrateFallbackElement||null)));let Pe=a.concat(P.slice(0,V+1)),Le=()=>{let Me;return ee?Me=_e:q?Me=fe:Z.route.Component?Me=te.createElement(Z.route.Component,null):Z.route.element?Me=Z.route.element:Me=N,te.createElement(h2,{match:Z,routeContext:{outlet:N,matches:Pe,isDataRoute:u!=null},children:Me})};return u&&(Z.route.ErrorBoundary||Z.route.errorElement||V===0)?te.createElement(u2,{location:u.location,revalidation:u.revalidation,component:_e,error:ee,children:Le(),routeContext:{outlet:null,matches:Pe,isDataRoute:!0},unstable_onError:m}):Le()},null)}function O_(r){return`${r} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function f2(r){let a=te.useContext(qf);return Un(a,O_(r)),a}function p2(r){let a=te.useContext(Vg);return Un(a,O_(r)),a}function m2(r){let a=te.useContext(Fo);return Un(a,O_(r)),a}function j_(r){let a=m2(r),u=a.matches[a.matches.length-1];return Un(u.route.id,`${r} can only be used on routes that contain a unique "id"`),u.route.id}function g2(){return j_("useRouteId")}function y2(){var m;let r=te.useContext(F_),a=p2("useRouteError"),u=j_("useRouteError");return r!==void 0?r:(m=a.errors)==null?void 0:m[u]}function _2(){let{router:r}=f2("useNavigate"),a=j_("useNavigate"),u=te.useRef(!1);return ib(()=>{u.current=!0}),te.useCallback(async(b,P={})=>{yl(u.current,nb),u.current&&(typeof b=="number"?r.navigate(b):await r.navigate(b,{fromRouteId:a,...P}))},[r,a])}var hx={};function ab(r,a,u){!a&&!hx[r]&&(hx[r]=!0,yl(!1,u))}te.memo(v2);function v2({routes:r,future:a,state:u,unstable_onError:m}){return sb(r,void 0,u,m,a)}function x2({to:r,replace:a,state:u,relative:m}){Un(Wf()," may be used only in the context of a component.");let{static:b}=te.useContext(vl);yl(!b," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:P}=te.useContext(Fo),{pathname:w}=Oo(),l=Bd(),L=L_(r,D_(P),w,m==="path"),N=JSON.stringify(L);return te.useEffect(()=>{l(JSON.parse(N),{replace:a,state:u,relative:m})},[l,N,m,a,u]),null}function b2(r){return s2(r.context)}function io(r){Un(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function w2({basename:r="/",children:a=null,location:u,navigationType:m="POP",navigator:b,static:P=!1}){Un(!Wf(),"You cannot render a inside another . You should never have more than one in your app.");let w=r.replace(/^\/*/,"/"),l=te.useMemo(()=>({basename:w,navigator:b,static:P,future:{}}),[w,b,P]);typeof u=="string"&&(u=Gf(u));let{pathname:L="/",search:N="",hash:Z="",state:V=null,key:ee="default"}=u,q=te.useMemo(()=>{let _e=hu(L,w);return _e==null?null:{location:{pathname:_e,search:N,hash:Z,state:V,key:ee},navigationType:m}},[w,L,N,Z,V,ee,m]);return yl(q!=null,` is not able to match the URL "${L}${N}${Z}" because it does not start with the basename, so the won't render anything.`),q==null?null:te.createElement(vl.Provider,{value:l},te.createElement(ym.Provider,{children:a,value:q}))}function S2({children:r,location:a}){return o2(Gy(r),a)}function Gy(r,a=[]){let u=[];return te.Children.forEach(r,(m,b)=>{if(!te.isValidElement(m))return;let P=[...a,b];if(m.type===te.Fragment){u.push.apply(u,Gy(m.props.children,P));return}Un(m.type===io,`[${typeof m.type=="string"?m.type:m.type.name}] is not a component. All component children of must be a or `),Un(!m.props.index||!m.props.children,"An index route cannot have child routes.");let w={id:m.props.id||P.join("-"),caseSensitive:m.props.caseSensitive,element:m.props.element,Component:m.props.Component,index:m.props.index,path:m.props.path,loader:m.props.loader,action:m.props.action,hydrateFallbackElement:m.props.hydrateFallbackElement,HydrateFallback:m.props.HydrateFallback,errorElement:m.props.errorElement,ErrorBoundary:m.props.ErrorBoundary,hasErrorBoundary:m.props.hasErrorBoundary===!0||m.props.ErrorBoundary!=null||m.props.errorElement!=null,shouldRevalidate:m.props.shouldRevalidate,handle:m.props.handle,lazy:m.props.lazy};m.props.children&&(w.children=Gy(m.props.children,P)),u.push(w)}),u}var bg="get",wg="application/x-www-form-urlencoded";function Zg(r){return r!=null&&typeof r.tagName=="string"}function T2(r){return Zg(r)&&r.tagName.toLowerCase()==="button"}function P2(r){return Zg(r)&&r.tagName.toLowerCase()==="form"}function C2(r){return Zg(r)&&r.tagName.toLowerCase()==="input"}function k2(r){return!!(r.metaKey||r.altKey||r.ctrlKey||r.shiftKey)}function E2(r,a){return r.button===0&&(!a||a==="_self")&&!k2(r)}var ug=null;function I2(){if(ug===null)try{new FormData(document.createElement("form"),0),ug=!1}catch{ug=!0}return ug}var M2=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function Dy(r){return r!=null&&!M2.has(r)?(yl(!1,`"${r}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${wg}"`),null):r}function A2(r,a){let u,m,b,P,w;if(P2(r)){let l=r.getAttribute("action");m=l?hu(l,a):null,u=r.getAttribute("method")||bg,b=Dy(r.getAttribute("enctype"))||wg,P=new FormData(r)}else if(T2(r)||C2(r)&&(r.type==="submit"||r.type==="image")){let l=r.form;if(l==null)throw new Error('Cannot submit a + + ); + } + + if (!items.length) { + return ( +
+

{t("beachesList.noBeachesFound")}

+

+ {t("beachesList.emptyState")} +

+
+ ); + } + + if ((q || mode !== "default") && filtered.length === 0) { + return ( +
+

{t("beachesList.noMatches")}

+

+ {q + ? t("beachesList.emptyState") + : mode === "viewport" + ? t("beachesList.panOrZoom") + : t("beachesList.widenRadius")} +

+
+ ); + } + + /* ---------- UI ---------- */ + return ( +
+ {/* Map */} + ({ + id: b.id, + name: b.name, + lat: b.lat, + lon: b.lon, + }))} + focus={mapFocus} + onMoveEnd={handleMoveEnd} + onFitBounds={handleFitBounds} + /> + + {/* Use current location — full width, now under the map */} +
+ +
+ + {/* List (keep to 50 for UX) */} +
    + {filtered.slice(0, 50).map((b) => ( +
  • + +
    +
    +

    + {b.name} +

    +

    + {b.municipality ?? "—"} ‱ {b.lat.toFixed(4)},{" "} + {b.lon.toFixed(4)} +

    +
    + {typeof b._distanceKm === "number" && ( + + {formatKm(b._distanceKm)} + + )} +
    + +
  • + ))} +
+
+ ); +} + +function CardSkeleton() { + return ( +
+
+
+
+ ); +} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000000..41adacdd29 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,74 @@ +import React, { Component, ErrorInfo, ReactNode } from "react"; +import { Link } from "react-router"; +import i18n from "../i18n"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export default class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("ErrorBoundary caught an error:", error, errorInfo); + } + + public render() { + if (this.state.hasError) { + const t = (key: string) => i18n.t(key); + return ( +
+
+
+

+ {t("errorBoundary.title")} +

+

+ {t("errorBoundary.message")} +

+ {this.state.error && ( +
+ + {t("errorBoundary.technicalDetails")} + +
+                    {this.state.error.toString()}
+                  
+
+ )} +
+ + + {t("errorBoundary.goHome")} + +
+
+
+
+ ); + } + + return this.props.children; + } +} + diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000000..fc0cab9bc9 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,301 @@ +import { useEffect, useRef, useState, useMemo } from "react"; +import { Link, useLocation, useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useUI } from "../store/ui"; +import { useAuth } from "@/store/auth"; +import { useQueryClient, useQuery } from "@tanstack/react-query"; +import { fetchBeaches } from "../api/beaches"; +import MenuIcon from "../assets/menu_icon.svg?react"; +import UserIcon from "../assets/user_icon.svg?react"; + +// Close popovers when clicking outside +function useOutsideClose(onClose: () => void) { + const ref = useRef(null); + useEffect(() => { + function handler(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + } + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [onClose]); + return ref; +} + +// Toggle the .dark class on +function useDarkMode() { + const [isDark, setIsDark] = useState(() => + document.documentElement.classList.contains("dark") + ); + useEffect(() => { + document.documentElement.classList.toggle("dark", isDark); + }, [isDark]); + return { isDark, setIsDark }; +} + +type HeaderProps = { + languageSwitcher?: React.ReactNode; +}; + +export default function Header({ languageSwitcher }: HeaderProps) { + const { t } = useTranslation(); + const location = useLocation(); + const navigate = useNavigate(); + const [menuOpen, setMenuOpen] = useState(false); + const [userOpen, setUserOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + const menuRef = useOutsideClose(() => setMenuOpen(false)); + const userRef = useOutsideClose(() => setUserOpen(false)); + const searchRef = useOutsideClose(() => setSearchOpen(false)); + const { isDark, setIsDark } = useDarkMode(); + + const search = useUI((s) => s.search); + const setSearch = useUI((s) => s.setSearch); + + const { token, clearToken } = useAuth(); + const authed = !!token; + const qc = useQueryClient(); + + // Fetch beaches for search dropdown + const { data: beaches } = useQuery({ + queryKey: ["beaches"], + queryFn: fetchBeaches, + staleTime: 5 * 60 * 1000, + }); + + // Filter beaches based on search + const filteredBeaches = useMemo(() => { + if (!search.trim() || !beaches) return []; + const q = search.trim().toLowerCase(); + return beaches + .filter((b) => { + const hay = `${b.name} ${b.municipality ?? ""}`.toLowerCase(); + return hay.includes(q); + }) + .slice(0, 10); // Limit to 10 results + }, [search, beaches]); + + function handleLogout() { + clearToken(); + qc.clear(); // clear cached queries (favorites, etc.) + setUserOpen(false); + } + + // Handle search input change + function handleSearchChange(value: string) { + setSearch(value); + setSearchOpen(!!value); // Show dropdown when there's input + } + + // Handle beach selection from dropdown + function handleBeachSelect(beachId: string) { + setSearch(""); // Clear search + setSearchOpen(false); + navigate(`/beach/${beachId}`); + } + + return ( + <> + {/* Skip navigation link for accessibility */} + + {t("header.skipToMain")} + +
+
+ {/* Left: Hamburger */} +
+ + + {menuOpen && ( +
+
+ {t("header.menu")} +
+
+
+ {t("header.language")} + {languageSwitcher ?? ( + SV / EN + )} +
+ + {t("nav.whatIsEUBeach")} + + {t("nav.about")} + {t("nav.terms")} + {t("nav.contact")} +
+ )} +
+ + {/* Center: Brand block */} +
+
+ + BADA + +
+ {t("header.euBeaches")} +
+ {t("header.inSweden")} +
+
+
+ + {/* Right: User menu */} +
+ + + {userOpen && ( +
+
+ {authed ? t("nav.account") : t("nav.user")} +
+
+ {!authed ? ( + <> + {t("nav.signIn")} + {t("nav.register")} + + ) : ( + <> + + {t("nav.favoriteBeaches")} + + {t("nav.profile")} + {t("nav.settings")} +
+ + + )} + +
+ )} +
+
+ + {/* Search bar with dropdown */} +
+
+
+ handleSearchChange(e.target.value)} + onFocus={() => search && setSearchOpen(true)} + /> + {search && ( + + )} +
+ + {/* Search results dropdown */} + {searchOpen && search && ( +
+ {filteredBeaches.length > 0 ? ( +
    + {filteredBeaches.map((beach) => ( +
  • + +
  • + ))} +
+ ) : ( +
+ {t("beachesList.noMatches")} +
+ )} + + {/* View all results link */} + {filteredBeaches.length > 0 && ( + <> +
+ { + setSearchOpen(false); + }} + className="block px-4 py-2 text-sm text-accent hover:bg-surface-muted text-center" + > + {t("header.viewAllResults")} + + + )} +
+ )} +
+
+
+ + ); +} + +function MenuLink({ to, children }: { to: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000000..eebf50079d --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from "react-i18next"; + +export default function LanguageSwitcher() { + const { i18n } = useTranslation(); + const current = i18n.language.startsWith("en") ? "en" : "sv"; + + function setLang(lang: "sv" | "en") { + i18n.changeLanguage(lang); + localStorage.setItem("lang", lang); + } + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx new file mode 100644 index 0000000000..79bf806356 --- /dev/null +++ b/frontend/src/components/MapView.tsx @@ -0,0 +1,401 @@ +// frontend/src/components/MapView.tsx +import { useEffect, useRef } from "react"; +import maplibregl, { Map, LngLatBoundsLike } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; + +type Point = { id: string; name: string; lat: number; lon: number }; +type Props = { + points?: Point[]; + /** If provided, the map fits to this center (+ optional radiusKm) */ + focus?: { center: { lon: number; lat: number }; radiusKm?: number }; + /** Fires only on *user*-initiated pan/zoom (not programmatic fits) */ + onMoveEnd?: (args: { + bounds: { west: number; south: number; east: number; north: number }; + center: { lon: number; lat: number }; + zoom: number; + }) => void; + /** Fires after programmatic fits */ + onFitBounds?: (args: { + bounds: { west: number; south: number; east: number; north: number }; + center: { lon: number; lat: number }; + zoom: number; + }) => void; +}; + +const STYLE_LIGHT = + "https://api.maptiler.com/maps/019951a8-6432-7aea-b555-2ac65a59181f/style.json?key=Dh5hFFvt6R7cmui0rEtJ"; +const STYLE_DARK = + "https://api.maptiler.com/maps/019951b2-24e8-7a45-8534-0731061b7984/style.json?key=Dh5hFFvt6R7cmui0rEtJ"; + +function isDark() { + return document.documentElement.classList.contains("dark"); +} +function getAccent() { + const v = getComputedStyle(document.documentElement) + .getPropertyValue("--color-accent") + .trim(); + return v.includes(" ") ? `rgb(${v})` : v || "#0a5a82"; +} +function styleForTheme() { + return isDark() ? STYLE_DARK : STYLE_LIGHT; +} +function getPopupStyles() { + const dark = isDark(); + return { + background: dark ? "#1f2937" : "#ffffff", // gray-800 : white + color: dark ? "#f9fafb" : "#111827", // gray-50 : gray-900 + borderColor: dark ? "#374151" : "#e5e7eb", // gray-700 : gray-200 + }; +} + +// compute bounds from center + radius (km) +function circleBounds( + center: { lon: number; lat: number }, + radiusKm: number +): LngLatBoundsLike { + const lat = center.lat; + const dLat = radiusKm / 111; // ~111 km per deg + const dLon = radiusKm / (111 * Math.cos((lat * Math.PI) / 180)); + const west = center.lon - dLon; + const east = center.lon + dLon; + const south = center.lat - dLat; + const north = center.lat + dLat; + return [ + [west, south], + [east, north], + ]; +} + +export default function MapView({ + points = [], + focus, + onMoveEnd, + onFitBounds, +}: Props) { + const ref = useRef(null); + const mapRef = useRef(null); + const markersRef = useRef([]); + const onMoveEndRef = useRef(onMoveEnd); + onMoveEndRef.current = onMoveEnd; // keep latest callback without re-binding listeners + const onFitBoundsRef = useRef(onFitBounds); + onFitBoundsRef.current = onFitBounds; + + // Init map ONCE + useEffect(() => { + if (!ref.current) return; + + const map = new maplibregl.Map({ + container: ref.current, + style: styleForTheme(), + center: [15, 62], + zoom: 4.3, + minZoom: 3, + maxZoom: 18, // Increased to allow more detailed zoom + dragRotate: false, + pitchWithRotate: false, + attributionControl: { compact: true }, + }); + mapRef.current = map; + + map.once("load", () => setTimeout(() => map.resize(), 0)); + + // Dark/light swap only when theme class changes + const obs = new MutationObserver(() => { + const url = styleForTheme(); + map.setStyle(url); + // Markers survive style swaps; if your style clears layers we can redraw, but + // here we just wait for style to settle so tiles don’t appear blank. + map.once("styledata", () => { + // no-op; we keep existing markers (no flicker) + }); + }); + obs.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + // Only count *user* moves + let userMoving = false; + map.on("movestart", (e) => { + userMoving = !!(e as any).originalEvent; // programmatic fits won't set this + }); + map.on("moveend", () => { + const b = map.getBounds(); + const c = map.getCenter(); + const boundsObj = { + bounds: { + west: b.getWest(), + south: b.getSouth(), + east: b.getEast(), + north: b.getNorth(), + }, + center: { lon: c.lng, lat: c.lat }, + zoom: map.getZoom(), + }; + + if (userMoving) { + // User-initiated move + userMoving = false; + if (onMoveEndRef.current) { + onMoveEndRef.current(boundsObj); + } + } else if (onFitBoundsRef.current) { + // Programmatic fit + onFitBoundsRef.current(boundsObj); + } + }); + + return () => { + obs.disconnect(); + markersRef.current.forEach((m) => m.remove()); + map.remove(); + mapRef.current = null; + }; + }, []); // ← no dependencies (don’t recreate the map) + + // Update markers with clustering support + useEffect(() => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + const accent = getAccent(); + + // Remove old markers + markersRef.current.forEach((m) => m.remove()); + markersRef.current = []; + + // Remove old layers and sources if they exist + if (map.getLayer("clusters")) map.removeLayer("clusters"); + if (map.getLayer("cluster-count")) map.removeLayer("cluster-count"); + if (map.getLayer("unclustered-point-visual")) map.removeLayer("unclustered-point-visual"); + if (map.getLayer("unclustered-point")) map.removeLayer("unclustered-point"); + if (map.getSource("beaches")) map.removeSource("beaches"); + + // Create GeoJSON from points + const geojson: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: points.map((p) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [p.lon, p.lat], + }, + properties: { + id: p.id, + name: p.name, + }, + })), + }; + + // Add source with clustering enabled + map.addSource("beaches", { + type: "geojson", + data: geojson, + cluster: true, + clusterMaxZoom: 17, // Max zoom to cluster points on (increased for better separation) + clusterRadius: 40, // Radius of each cluster (reduced for tighter clustering) + }); + + // Add cluster circle layer + map.addLayer({ + id: "clusters", + type: "circle", + source: "beaches", + filter: ["has", "point_count"], + paint: { + "circle-color": accent, + "circle-radius": [ + "step", + ["get", "point_count"], + 15, // radius for clusters with < 10 points + 10, 20, // radius for clusters with 10-99 points + 30, 25, // radius for clusters with 100+ points + ], + "circle-stroke-width": 2, + "circle-stroke-color": isDark() ? "rgba(0, 0, 0, 0.6)" : "rgba(255, 255, 255, 0.85)", + }, + }); + + // Add cluster count label + map.addLayer({ + id: "cluster-count", + type: "symbol", + source: "beaches", + filter: ["has", "point_count"], + layout: { + "text-field": "{point_count_abbreviated}", + "text-font": ["Noto Sans Regular"], + "text-size": 13, + }, + paint: { + "text-color": "#ffffff", // White text for good contrast on accent color in both themes + "text-halo-color": "rgba(0, 0, 0, 0.3)", // Subtle dark halo for extra contrast + "text-halo-width": 1, + }, + }); + + // Add individual point layer (unclustered) + // Using 22px radius (44px diameter) for proper touch targets + map.addLayer({ + id: "unclustered-point", + type: "circle", + source: "beaches", + filter: ["!", ["has", "point_count"]], + paint: { + "circle-color": accent, + "circle-radius": 22, // 44px diameter for proper touch target (WCAG compliance) + "circle-stroke-width": 2, + "circle-stroke-color": isDark() ? "rgba(0, 0, 0, 0.6)" : "rgba(255, 255, 255, 0.85)", + "circle-opacity": 0.2, // Make it mostly transparent + "circle-stroke-opacity": 1, + }, + }); + + // Add visual marker layer on top (smaller, fully visible) + map.addLayer({ + id: "unclustered-point-visual", + type: "circle", + source: "beaches", + filter: ["!", ["has", "point_count"]], + paint: { + "circle-color": accent, + "circle-radius": 8, // Visual size (16px diameter) + "circle-stroke-width": 2, + "circle-stroke-color": isDark() ? "rgba(0, 0, 0, 0.6)" : "rgba(255, 255, 255, 0.85)", + }, + }); + + // Click handler for clusters - zoom in or show list if can't expand + map.on("click", "clusters", (e) => { + const features = map.queryRenderedFeatures(e.point, { + layers: ["clusters"], + }); + if (!features.length || !features[0].geometry || features[0].geometry.type !== "Point") return; + + const clusterId = features[0].properties?.cluster_id; + const pointCount = features[0].properties?.point_count; + const coordinates = features[0].geometry.coordinates.slice() as [number, number]; + const source = map.getSource("beaches") as maplibregl.GeoJSONSource; + + source.getClusterExpansionZoom(clusterId, (err, zoom) => { + if (err) return; + + // If cluster can't expand further (at max zoom), show popup with all beaches + if (zoom >= map.getMaxZoom() || zoom === map.getZoom()) { + source.getClusterLeaves(clusterId, pointCount || 100, 0, (error, leaves) => { + if (error || !leaves) return; + + const styles = getPopupStyles(); + const beachList = leaves + .map((leaf: any) => { + const props = leaf.properties; + return `${props.name}`; + }) + .join(""); + + const popup = new maplibregl.Popup({ closeButton: true, maxWidth: "300px" }) + .setLngLat(coordinates) + .setHTML(`
${beachList}
`) + .addTo(map); + + // Apply dark mode styles to popup container + const popupEl = popup.getElement(); + if (popupEl) { + const content = popupEl.querySelector(".maplibregl-popup-content"); + if (content) { + (content as HTMLElement).style.backgroundColor = styles.background; + (content as HTMLElement).style.color = styles.color; + } + } + }); + } else { + // Otherwise, zoom in + map.easeTo({ + center: coordinates, + zoom: zoom ?? map.getZoom() + 2, + }); + } + }); + }); + + // Click handler for individual points - show popup + map.on("click", "unclustered-point", (e) => { + if (!e.features?.length || !e.features[0].geometry || e.features[0].geometry.type !== "Point") return; + + const coordinates = e.features[0].geometry.coordinates.slice() as [number, number]; + const { name, id } = e.features[0].properties as { name: string; id: string }; + + // Ensure that if the map is zoomed out such that multiple + // copies of the feature are visible, the popup appears + // over the copy being pointed to. + while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { + coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; + } + + const styles = getPopupStyles(); + const popup = new maplibregl.Popup({ closeButton: false }) + .setLngLat(coordinates) + .setHTML( + `${name}` + ) + .addTo(map); + + // Apply dark mode styles to popup container + const popupEl = popup.getElement(); + if (popupEl) { + const content = popupEl.querySelector(".maplibregl-popup-content"); + if (content) { + (content as HTMLElement).style.backgroundColor = styles.background; + (content as HTMLElement).style.color = styles.color; + } + } + }); + + // Change cursor on hover + map.on("mouseenter", "clusters", () => { + map.getCanvas().style.cursor = "pointer"; + }); + map.on("mouseleave", "clusters", () => { + map.getCanvas().style.cursor = ""; + }); + map.on("mouseenter", "unclustered-point", () => { + map.getCanvas().style.cursor = "pointer"; + }); + map.on("mouseleave", "unclustered-point", () => { + map.getCanvas().style.cursor = ""; + }); + + }, [points]); + + // Fit to focus (center + radius) without flashing; allow closer zoom for small radii + useEffect(() => { + const map = mapRef.current; + if (!map || !focus?.center) return; + + map.stop(); // cancel any ongoing camera animation + + if (focus.radiusKm && focus.radiusKm > 0) { + const r = focus.radiusKm; + + // 🔧 TIGHTEN the fit for small radii + // - Shrink the radius a bit so bounds aren't too wide + // - Reduce padding so more of the view is the actual area of interest + // - Let the map zoom in a bit more + const isSmall = r <= 5; // the 5 km "nearby" case + const effective = isSmall ? r * 0.65 : r; // <— tighten bounds + const padding = isSmall ? 12 : 24; // less padding on small areas + const maxZoom = isSmall ? 17 : 14; // allow closer zoom on small areas (updated for new max) + + const b = circleBounds(focus.center, effective); + map.fitBounds(b, { padding, maxZoom }); + } else { + map.flyTo({ center: [focus.center.lon, focus.center.lat], zoom: 11 }); + } + }, [focus]); + + return ( +
+
+
+ ); +} diff --git a/frontend/src/components/SortableFavorite.tsx b/frontend/src/components/SortableFavorite.tsx new file mode 100644 index 0000000000..e3dfb5066f --- /dev/null +++ b/frontend/src/components/SortableFavorite.tsx @@ -0,0 +1,103 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +type Props = { + id: string; // beachId + name: string; + muni: string; + classificationText: string; + classificationClass: string; // Tailwind badge class + onRemove: () => void; + disabled?: boolean; +}; + +export default function SortableFavorite({ + id, + name, + muni, + classificationText, + classificationClass, + onRemove, + disabled, +}: Props) { + const { t } = useTranslation(); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.85 : 1, + }; + + return ( +
  • +
    +
    + {/* Drag handle */} + {!disabled && ( + + )} + + + {name} + +
    + +
    {muni || "—"}
    + +
    + + {classificationText} + +
    +
    + + {/* Actions - allow wrapping on narrow screens */} +
    + + {t("favorites.view")} + + +
    +
  • + ); +} diff --git a/frontend/src/hooks/useBeaches.ts b/frontend/src/hooks/useBeaches.ts new file mode 100644 index 0000000000..03ef42e153 --- /dev/null +++ b/frontend/src/hooks/useBeaches.ts @@ -0,0 +1,21 @@ +// frontend/src/hooks/useBeaches.ts +import { useQuery } from "@tanstack/react-query"; +import { fetchBeaches, fetchBeach } from "../api/beaches"; +import type { BeachSummary, BeachDetail } from "../types/beaches"; + +export function useBeaches() { + return useQuery({ + queryKey: ["beaches"], + queryFn: fetchBeaches, + staleTime: 5 * 60 * 1000, + }); +} + +export function useBeachDetail(id: string | undefined) { + return useQuery({ + queryKey: ["beach", id], + enabled: !!id, + queryFn: () => fetchBeach(id!), + staleTime: 5 * 60 * 1000, + }); +} diff --git a/frontend/src/hooks/useGeolocation.ts b/frontend/src/hooks/useGeolocation.ts new file mode 100644 index 0000000000..8819d3f6d5 --- /dev/null +++ b/frontend/src/hooks/useGeolocation.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from "react"; + +type Coords = { lat: number; lon: number }; + +export function useGeolocation() { + const [coords, setCoords] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const request = useCallback(() => { + if (!("geolocation" in navigator)) { + setError("Geolocation not supported"); + return; + } + setLoading(true); + setError(null); + + navigator.geolocation.getCurrentPosition( + (pos) => { + setLoading(false); + setCoords({ + lat: pos.coords.latitude, + lon: pos.coords.longitude, + }); + }, + (err) => { + setLoading(false); + setError(err.message || "Failed to get location"); + }, + { + enableHighAccuracy: true, + timeout: 10_000, + maximumAge: 60_000, + } + ); + }, []); + + return { coords, loading, error, request }; +} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 0000000000..89b0aa5577 --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,23 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; + +import sv from "./locales/sv/common.json"; +import en from "./locales/en/common.json"; + +const saved = localStorage.getItem("lang"); +const fallbackLng = "sv"; +const lng = saved || fallbackLng; + +i18n.use(initReactI18next).init({ + resources: { + sv: { common: sv }, + en: { common: en }, + }, + lng, + fallbackLng, + ns: ["common"], + defaultNS: "common", + interpolation: { escapeValue: false }, +}); + +export default i18n; diff --git a/frontend/src/index.css b/frontend/src/index.css index e69de29bb2..25282de35c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -0,0 +1,132 @@ +@import "tailwindcss"; +@import "maplibre-gl/dist/maplibre-gl.css"; + +/* Design tokens (Tailwind v4) */ +@theme { + /* Light theme */ + --color-surface: #efebe4; + --color-surface-muted: #f6f2ec; + --color-ink: #1a1a1a; + --color-ink-muted: #64686c; + --color-border: #cdc8c0; + --color-accent: #0a5a82; + --color-badge: #e6e4dc; + + /* Quality colors */ + --color-quality-excellent: #1f6fb2; + --color-quality-good: #2b8a3e; + --color-quality-sufficient: #b08900; + --color-quality-poor: #c0392b; + --color-quality-unknown: #6b7280; + + /* Fonts */ + --font-spectral: "Spectral", serif; + --font-inter: "Inter", system-ui, sans-serif; +} + +/* Dark theme overrides */ +.dark { + --color-surface: #161011; + --color-surface-muted: #1e1819; + --color-ink: #f0ede6; + --color-ink-muted: #b8b2aa; + --color-border: #40383a; + --color-accent: #5ea5d2; + --color-badge: #302a2c; +} + +/* Base defaults */ +@layer base { + html, + body, + #root { + height: 100%; + } + + body { + @apply bg-surface text-ink antialiased font-inter; + } + + h1, + h2, + h3, + h4 { + @apply font-spectral text-ink; + } + + a { + @apply text-accent underline-offset-2 hover:underline + focus-visible:outline-none focus-visible:ring-2 + focus-visible:ring-accent/50 rounded-sm; + } +} + +/* Reusable bits */ +@layer components { + .card { + @apply bg-surface-muted rounded-2xl shadow border border-border; + } + .badge { + @apply inline-flex items-center gap-1 px-2.5 py-1 rounded-full + text-xs font-medium bg-badge text-ink; + } + + .kpi-excellent { + color: var(--color-quality-excellent); + } + .kpi-good { + color: var(--color-quality-good); + } + .kpi-sufficient { + color: var(--color-quality-sufficient); + } + .kpi-poor { + color: var(--color-quality-poor); + } + .kpi-unknown { + color: var(--color-quality-unknown); + } +} + +@layer utilities { + /* widely supported; falls back to the fixed min-height below */ + .aspect-square { + aspect-ratio: 1 / 1; + } + + /* Screen reader only */ + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + /* Show on focus */ + .sr-only:focus { + position: static; + width: auto; + height: auto; + padding: inherit; + margin: inherit; + overflow: visible; + clip: auto; + white-space: normal; + } +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json new file mode 100644 index 0000000000..520a62a7ad --- /dev/null +++ b/frontend/src/locales/en/common.json @@ -0,0 +1,211 @@ +{ + "appTitle": "BADA", + "beaches": "Beaches", + "loadingBeaches": "Loading beaches
", + "loadError": "Could not load data.", + "back": "Back", + "waterQuality": "Water quality", + "latestYear": "Latest year", + "coordinates": "Coordinates", + "municipality": "Municipality", + "classification": { + "excellent": "Excellent", + "good": "Good", + "sufficient": "Sufficient", + "poor": "Poor", + "unknown": "Unknown", + "inProgress": "Classification in progress", + "notClassified": "Not classified" + }, + "sourceTextNote": "Source text in Swedish", + "header": { + "menu": "Menu", + "language": "Language", + "searchPlaceholder": "Search beaches
", + "clear": "Clear", + "openMenu": "Open menu", + "userMenu": "User menu", + "skipToMain": "Skip to main content", + "euBeaches": "EU Beaches", + "inSweden": "in Sweden", + "viewAllResults": "View all results on map" + }, + "nav": { + "whatIsEUBeach": "What is an EU Beach?", + "about": "About BADA", + "terms": "Terms of Use", + "contact": "Contact", + "favoriteBeaches": "Favourite beaches", + "profile": "Profile", + "settings": "Settings", + "logOut": "Log out", + "signIn": "Sign in", + "register": "Register", + "account": "ACCOUNT", + "user": "USER" + }, + "auth": { + "signInTitle": "Sign in", + "registerTitle": "Register", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "signIn": "Sign in", + "signInProgress": "Signing in
", + "register": "Register", + "registerProgress": "Registering
", + "noAccount": "No account?", + "registerHere": "Register here", + "alreadyHaveAccount": "Already have an account?", + "signInHere": "Sign in here", + "signInSuccess": "Successfully signed in!", + "registerSuccess": "Registration successful! Please sign in.", + "signInFailed": "Login failed", + "registerFailed": "Registration failed", + "emailRequired": "Email is required", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 8 characters", + "confirmPasswordRequired": "Confirm password is required", + "passwordsMustMatch": "Passwords must match", + "invalidEmail": "Invalid email address" + }, + "favorites": { + "title": "Your favorite beaches", + "sortBy": "Sort by:", + "sortOptions": { + "custom": "Custom (drag & drop)", + "name": "Name (A–Z)", + "municipality": "Municipality (A–Z)" + }, + "browseAll": "Browse all beaches", + "empty": "You have no favorite beaches yet", + "addFavorites": "Add favorites from the beach map", + "view": "View", + "remove": "Remove", + "dragToReorder": "Drag to reorder", + "added": "Added to favorites", + "removed": "Removed from favorites" + }, + "beachDetail": { + "addFavorite": "Add favorite", + "removeFavorite": "Remove favorite", + "added": "Added to favorites", + "removed": "Removed from favorites", + "bathInformation": "About this beach", + "latestSampleDate": "Latest sample", + "beach": "Beach", + "algalBloom": "Algal bloom", + "euMotive": "EU motive", + "nutsCode": "NUTS code", + "mail": "Mail:", + "phone": "Phone:", + "website": "Website:", + "pleaseTryAgain": "Please try again.", + "euBad": "EU Beach" + }, + "beachesList": { + "modeDefault": "Show all", + "modeNearby": "Near me", + "modeViewport": "Show viewport", + "gettingLocation": "Getting location
", + "locationError": "Could not get your location", + "requestLocation": "Use current location", + "noBeachesFound": "No beaches found", + "emptyState": "Try adjusting your search filters", + "loadError": "Could not load beaches.", + "pleaseRetry": "Please try again.", + "retry": "Retry", + "noMatches": "No matches here.", + "panOrZoom": "Pan or zoom to a different area.", + "widenRadius": "Try widening the radius." + }, + "theme": { + "darkOn": "Dark theme: on", + "darkOff": "Dark theme: off" + }, + "pages": { + "about": { + "title": "About BADA", + "whatIsBADA": "What is BADA?", + "whatIsBADADesc": "BADA helps beachgoers and families in Sweden find safe, EU-classified bathing waters with real-time quality updates. We replace outdated or clunky websites with a clean, mobile-friendly experience.", + "whatIsBADAMission": "Our mission is to make water quality information accessible to everyone, helping you make informed decisions about where to swim and enjoy Sweden's beautiful coastal waters.", + "dataSource": "Data Source", + "dataSourceDesc": "BADA uses official data from the Swedish Agency for Marine and Water Management (Havs- och Vattenmyndigheten, HaV), which monitors and classifies EU bathing waters across Sweden.", + "dataSourceDirective": "Water quality classifications are based on the European Bathing Water Directive and are updated regularly throughout the bathing season.", + "features": "Features", + "featureMap": "đŸ—ș Interactive map of all EU-classified beaches in Sweden", + "featureLocation": "📍 Find the nearest beach based on your current location", + "featureQuality": "🔬 View water quality, classification, and recent test results", + "featureFavorites": "❀ Save your favorite beaches to your profile", + "featureDarkMode": "🌗 Dark mode and responsive design", + "featureLang": "🌐 Multi-language support (Swedish / English)", + "builtWith": "Built With", + "builtWithDesc": "BADA is built with React, TypeScript, Node.js, Express, and MongoDB. Maps are powered by MapLibre and OpenStreetMap. See our", + "builtWithDesc2": "for more details.", + "githubRepo": "GitHub repository" + }, + "whatIsEUBeach": { + "title": "What is an EU Beach?", + "directive": "EU Bathing Water Directive", + "directiveDesc": "An EU bathing water is a beach or section of a coastline where the European Union's Bathing Water Directive (2006/7/EC) applies. This directive requires member states to monitor and regularly test the water quality at designated bathing sites.", + "directiveDesc2": "In Sweden, the Swedish Agency for Marine and Water Management (HaV) monitors these beaches and classifies them according to water quality standards.", + "classifications": "Water Quality Classifications", + "excellentDesc": "Best water quality. The water meets the highest standards with excellent microbiological quality.", + "goodDesc": "Good water quality. The water meets the standards with good microbiological quality.", + "sufficientDesc": "Sufficient water quality. The water meets the minimum standards.", + "poorDesc": "Poor water quality. Swimming is not recommended. The water does not meet the minimum standards.", + "monitoring": "Monitoring", + "monitoringDesc": "Water quality is monitored throughout the bathing season (typically May to September). Samples are taken regularly and tested for bacteria and other contaminants that could affect human health.", + "monitoringDesc2": "The classification is based on data from the last four bathing seasons to provide a stable and reliable assessment of water quality." + }, + "terms": { + "title": "Terms of Use", + "acceptance": "Acceptance of Terms", + "acceptanceDesc": "By using BADA, you agree to these terms of use. If you do not agree with any part of these terms, please do not use our service.", + "service": "Service Description", + "serviceDesc": "BADA provides information about EU-classified bathing waters in Sweden. We aggregate data from official sources and present it in an accessible format.", + "disclaimer": "Disclaimer", + "disclaimerDesc": "The water quality information provided by BADA is based on official data from the Swedish Agency for Marine and Water Management. While we strive for accuracy, we cannot guarantee the completeness or timeliness of all information.", + "disclaimerDesc2": "BADA is provided \"as is\" without warranty of any kind. Users should exercise their own judgment and discretion when using this information to make decisions about swimming or other water activities.", + "accounts": "User Accounts", + "accountsDesc": "When you create an account, you are responsible for maintaining the confidentiality of your account information and password. You agree to notify us immediately of any unauthorized use of your account.", + "ip": "Intellectual Property", + "ipDesc": "The BADA application and its original content, features, and functionality are owned by the BADA project and are protected by international copyright, trademark, and other intellectual property laws.", + "changes": "Changes to Terms", + "changesDesc": "We reserve the right to modify these terms at any time. We will notify users of any significant changes by posting the new terms on this page.", + "contact": "Contact", + "contactDesc": "If you have questions about these terms, please", + "contactUs": "contact us" + }, + "contact": { + "title": "Contact", + "getInTouch": "Get in Touch", + "getInTouchDesc": "We'd love to hear from you! Whether you have feedback, suggestions, questions, or found a bug, please don't hesitate to reach out.", + "github": "GitHub", + "githubDesc": "For bug reports, feature requests, or code contributions, please visit our", + "githubRepo": "GitHub repository", + "feedback": "Issues & Feedback", + "feedbackDesc": "If you encounter any issues with the application or have suggestions for improvement, please open an issue on GitHub. Your feedback helps us make BADA better.", + "accuracy": "Data Accuracy", + "accuracyDesc": "If you notice any inaccuracies in the water quality data or beach information, please let us know. We rely on official sources, but errors can occur and we'll work to correct them promptly.", + "privacy": "Privacy", + "privacyDesc": "For questions about privacy and data handling, please see our terms of use or contact us through GitHub." + }, + "profile": { + "title": "Your Profile" + }, + "settings": { + "title": "Settings", + "language": "Language", + "languageDescription": "Select your language" + }, + "backToMap": "← Back to map" + }, + "errorBoundary": { + "title": "Oops! Something went wrong", + "message": "We encountered an unexpected error. Please try refreshing the page or return to the homepage.", + "technicalDetails": "Technical details", + "reloadPage": "Reload Page", + "goHome": "Go Home" + } +} diff --git a/frontend/src/locales/sv/common.json b/frontend/src/locales/sv/common.json new file mode 100644 index 0000000000..7e08e7f448 --- /dev/null +++ b/frontend/src/locales/sv/common.json @@ -0,0 +1,211 @@ +{ + "appTitle": "BADA", + "beaches": "Badplatser", + "loadingBeaches": "Laddar badplatser
", + "loadError": "Kunde inte ladda data.", + "back": "Tillbaka", + "waterQuality": "Vattenkvalitet", + "latestYear": "Senaste Ă„r", + "coordinates": "Koordinater", + "municipality": "Kommun", + "classification": { + "excellent": "UtmĂ€rkt kvalitet", + "good": "Bra kvalitet", + "sufficient": "TillfredsstĂ€llande kvalitet", + "poor": "DĂ„lig kvalitet", + "unknown": "OkĂ€nd", + "inProgress": "Klassificering pĂ„gĂ„r", + "notClassified": "Ej klassificerad" + }, + "sourceTextNote": "KĂ€lltext pĂ„ svenska", + "header": { + "menu": "Meny", + "language": "SprĂ„k", + "searchPlaceholder": "Sök badplatser
", + "clear": "Rensa", + "openMenu": "Öppna meny", + "userMenu": "AnvĂ€ndarmenyn", + "skipToMain": "Hoppa till huvudinnehĂ„llet", + "euBeaches": "EU-bad", + "inSweden": "i Sverige", + "viewAllResults": "Visa alla resultat pĂ„ kartan" + }, + "nav": { + "whatIsEUBeach": "Vad Ă€r en EU-badplats?", + "about": "Om BADA", + "terms": "AnvĂ€ndarvillkor", + "contact": "Kontakt", + "favoriteBeaches": "Favoritbadplatser", + "profile": "Profil", + "settings": "InstĂ€llningar", + "logOut": "Logga ut", + "signIn": "Logga in", + "register": "Registrera", + "account": "KONTO", + "user": "ANVÄNDARE" + }, + "auth": { + "signInTitle": "Logga in", + "registerTitle": "Registrera", + "email": "E-post", + "password": "Lösenord", + "confirmPassword": "BekrĂ€fta lösenord", + "signIn": "Logga in", + "signInProgress": "Loggar in
", + "register": "Registrera", + "registerProgress": "Registrerar
", + "noAccount": "Inget konto?", + "registerHere": "Registrera hĂ€r", + "alreadyHaveAccount": "Har du redan ett konto?", + "signInHere": "Logga in hĂ€r", + "signInSuccess": "Inloggad!", + "registerSuccess": "Registrering lyckades! Logga in.", + "signInFailed": "Inloggning misslyckades", + "registerFailed": "Registrering misslyckades", + "emailRequired": "E-post krĂ€vs", + "passwordRequired": "Lösenord krĂ€vs", + "passwordTooShort": "Lösenordet mĂ„ste vara minst 8 tecken", + "confirmPasswordRequired": "BekrĂ€ftelse av lösenord krĂ€vs", + "passwordsMustMatch": "Lösenorden mĂ„ste matcha", + "invalidEmail": "Ogiltig e-postadress" + }, + "favorites": { + "title": "Dina favoritbadplatser", + "sortBy": "Sortera efter:", + "sortOptions": { + "custom": "Egen ordning (dra & slĂ€pp)", + "name": "Namn (A–Ö)", + "municipality": "Kommun (A–Ö)" + }, + "browseAll": "BlĂ€ddra bland alla badplatser", + "empty": "Du har inga favoritbadplatser Ă€nnu", + "addFavorites": "LĂ€gg till favoriter frĂ„n badplatskartan", + "view": "Visa", + "remove": "Ta bort", + "dragToReorder": "Dra för att Ă€ndra ordning", + "added": "Tillagd som favorit", + "removed": "Borttagen frĂ„n favoriter" + }, + "beachDetail": { + "addFavorite": "LĂ€gg till favorit", + "removeFavorite": "Ta bort favorit", + "added": "Tillagd som favorit", + "removed": "Borttagen frĂ„n favoriter", + "bathInformation": "Om badplatsen", + "latestSampleDate": "Senaste provtagning", + "beach": "Badplats", + "algalBloom": "Algblomning", + "euMotive": "EU-motivering", + "nutsCode": "NUTS-kod", + "mail": "E-post:", + "phone": "Telefon:", + "website": "Webbplats:", + "pleaseTryAgain": "VĂ€nligen försök igen.", + "euBad": "EU-bad" + }, + "beachesList": { + "modeDefault": "Visa alla", + "modeNearby": "NĂ€ra mig", + "modeViewport": "Visa i fönster", + "gettingLocation": "HĂ€mtar position
", + "locationError": "Kunde inte hitta din position", + "requestLocation": "AnvĂ€nd min position", + "noBeachesFound": "Inga badplatser hittades", + "emptyState": "Prova att Ă€ndra sökfilter", + "loadError": "Kunde inte ladda badplatser.", + "pleaseRetry": "VĂ€nligen försök igen.", + "retry": "Försök igen", + "noMatches": "Inga trĂ€ffar hĂ€r.", + "panOrZoom": "Panorera eller zooma till ett annat omrĂ„de.", + "widenRadius": "Prova att öka sökradien." + }, + "theme": { + "darkOn": "Mörkt tema: pĂ„", + "darkOff": "Mörkt tema: av" + }, + "pages": { + "about": { + "title": "Om BADA", + "whatIsBADA": "Vad Ă€r BADA?", + "whatIsBADADesc": "BADA hjĂ€lper badgĂ€ster och familjer i Sverige att hitta sĂ€kra, EU-klassade badvatten med uppdateringar i realtid. Vi ersĂ€tter inaktuella eller tunga webbplatser med en ren, mobilanpassad upplevelse.", + "whatIsBADAMission": "VĂ„rt uppdrag Ă€r att göra vattenkvalitetsinformation tillgĂ€nglig för alla, och hjĂ€lpa dig att fatta informerade beslut om var du ska simma och njuta av Sveriges vackra kustvatten.", + "dataSource": "DatakĂ€lla", + "dataSourceDesc": "BADA anvĂ€nder officiell data frĂ„n Havs- och Vattenmyndigheten (HaV), som övervakar och klassar EU-badplatser över hela Sverige.", + "dataSourceDirective": "Vattenkvalitetsklassningar baseras pĂ„ EU:s badvattendirektiv och uppdateras regelbundet under badsĂ€songen.", + "features": "Funktioner", + "featureMap": "đŸ—ș Interaktiv karta över alla EU-klassade badplatser i Sverige", + "featureLocation": "📍 Hitta nĂ€rmaste badplats baserat pĂ„ din nuvarande plats", + "featureQuality": "🔬 Se vattenkvalitet, klassning och senaste provresultat", + "featureFavorites": "❀ Spara dina favoritbadplatser till din profil", + "featureDarkMode": "🌗 Mörkt lĂ€ge och responsiv design", + "featureLang": "🌐 FlersprĂ„kigt stöd (Svenska / Engelska)", + "builtWith": "Byggd med", + "builtWithDesc": "BADA Ă€r byggd med React, TypeScript, Node.js, Express och MongoDB. Kartor drivs av MapLibre och OpenStreetMap. Se vĂ„r", + "builtWithDesc2": "för mer information.", + "githubRepo": "GitHub-repository" + }, + "whatIsEUBeach": { + "title": "Vad Ă€r en EU-badplats?", + "directive": "EU:s badvattendirektiv", + "directiveDesc": "En EU-badplats Ă€r en strand eller del av en kust dĂ€r EU:s badvattendirektiv (2006/7/EG) gĂ€ller. Detta direktiv krĂ€ver att medlemsstaterna övervakar och regelbundet testar vattenkvaliteten vid utsedda badplatser.", + "directiveDesc2": "I Sverige övervakar Havs- och Vattenmyndigheten (HaV) dessa badplatser och klassar dem enligt vattenkvalitetsstandarder.", + "classifications": "Vattenkvalitetsklassningar", + "excellentDesc": "BĂ€st vattenkvalitet. Vattnet uppfyller de högsta standarderna med utmĂ€rkt mikrobiologisk kvalitet.", + "goodDesc": "Bra vattenkvalitet. Vattnet uppfyller standarderna med bra mikrobiologisk kvalitet.", + "sufficientDesc": "TillfredsstĂ€llande vattenkvalitet. Vattnet uppfyller minimistandarderna.", + "poorDesc": "DĂ„lig vattenkvalitet. Simning rekommenderas inte. Vattnet uppfyller inte minimistandarderna.", + "monitoring": "Övervakning", + "monitoringDesc": "Vattenkvaliteten övervakas under hela badsĂ€songen (vanligtvis maj till september). Prover tas regelbundet och testas för bakterier och andra föroreningar som kan pĂ„verka mĂ€nniskors hĂ€lsa.", + "monitoringDesc2": "Klassningen baseras pĂ„ data frĂ„n de senaste fyra badsĂ€songerna för att ge en stabil och tillförlitlig bedömning av vattenkvaliteten." + }, + "terms": { + "title": "AnvĂ€ndarvillkor", + "acceptance": "GodkĂ€nnande av villkor", + "acceptanceDesc": "Genom att anvĂ€nda BADA godkĂ€nner du dessa anvĂ€ndarvillkor. Om du inte hĂ„ller med om nĂ„gon del av dessa villkor, vĂ€nligen anvĂ€nd inte vĂ„r tjĂ€nst.", + "service": "TjĂ€nstebeskrivning", + "serviceDesc": "BADA tillhandahĂ„ller information om EU-klassade badvatten i Sverige. Vi samlar data frĂ„n officiella kĂ€llor och presenterar den i ett tillgĂ€ngligt format.", + "disclaimer": "Villkor", + "disclaimerDesc": "Vattenkvalitetsinformationen som tillhandahĂ„lls av BADA baseras pĂ„ officiella data frĂ„n Havs- och Vattenmyndigheten. Även om vi strĂ€var efter noggrannhet kan vi inte garantera fullstĂ€ndigheten eller aktuellheten av all information.", + "disclaimerDesc2": "BADA tillhandahĂ„lls \"som den Ă€r\" utan garantier av nĂ„got slag. AnvĂ€ndare bör anvĂ€nda sitt eget omdöme och diskretion nĂ€r de anvĂ€nder denna information för att fatta beslut om simning eller andra vattenaktiviteter.", + "accounts": "AnvĂ€ndarkonton", + "accountsDesc": "NĂ€r du skapar ett konto Ă€r du ansvarig för att upprĂ€tthĂ„lla konfidentialiteten för din kontoinformation och ditt lösenord. Du godkĂ€nner att meddela oss omedelbart om obehörig anvĂ€ndning av ditt konto.", + "ip": "Immateriella rĂ€ttigheter", + "ipDesc": "BADA-applikationen och dess ursprungliga innehĂ„ll, funktioner och funktionalitet Ă€gs av BADA-projektet och skyddas av internationella upphovsrĂ€tt, varumĂ€rke och andra immateriella lagar.", + "changes": "Ändringar av villkor", + "changesDesc": "Vi förbehĂ„ller oss rĂ€tten att Ă€ndra dessa villkor nĂ€r som helst. Vi kommer att meddela anvĂ€ndare om betydande Ă€ndringar genom att publicera de nya villkoren pĂ„ denna sida.", + "contact": "Kontakt", + "contactDesc": "Om du har frĂ„gor om dessa villkor, vĂ€nligen", + "contactUs": "kontakta oss" + }, + "contact": { + "title": "Kontakt", + "getInTouch": "Hör av dig", + "getInTouchDesc": "Vi skulle gĂ€rna höra frĂ„n dig! Om du har feedback, förslag, frĂ„gor eller hittat en bugg, tveka inte att kontakta oss.", + "github": "GitHub", + "githubDesc": "För buggrapporter, funktionsförfrĂ„gningar eller kodbidrag, besök vĂ„r", + "githubRepo": "GitHub-repository", + "feedback": "Problem & Feedback", + "feedbackDesc": "Om du stöter pĂ„ nĂ„gra problem med applikationen eller har förslag pĂ„ förbĂ€ttringar, öppna gĂ€rna ett issue pĂ„ GitHub. Din feedback hjĂ€lper oss att göra BADA bĂ€ttre.", + "accuracy": "Datan noggrannhet", + "accuracyDesc": "Om du mĂ€rker nĂ„gra felaktigheter i vattenkvalitetsdata eller badplatsinformation, lĂ„t oss veta. Vi förlitar oss pĂ„ officiella kĂ€llor, men fel kan uppstĂ„ och vi kommer att arbeta för att korrigera dem snabbt.", + "privacy": "Integritet", + "privacyDesc": "För frĂ„gor om integritet och datahantering, se vĂ„ra anvĂ€ndarvillkor eller kontakta oss via GitHub." + }, + "profile": { + "title": "Din profil" + }, + "settings": { + "title": "InstĂ€llningar", + "language": "SprĂ„k", + "languageDescription": "VĂ€lj ditt sprĂ„k" + }, + "backToMap": "← Tillbaka till kartan" + }, + "errorBoundary": { + "title": "Hoppsan! NĂ„got gick fel", + "message": "Vi stötte pĂ„ ett ovĂ€ntat fel. VĂ€nligen försök uppdatera sidan eller Ă„tervĂ€nd till startsidan.", + "technicalDetails": "Tekniska detaljer", + "reloadPage": "Ladda om sidan", + "goHome": "GĂ„ till startsidan" + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx deleted file mode 100644 index 51294f3998..0000000000 --- a/frontend/src/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import { App } from "./App.jsx"; -import "./index.css"; - -ReactDOM.createRoot(document.getElementById("root")).render( - - - -); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000000..d73697d231 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router"; +import { Toaster } from "react-hot-toast"; +import i18n from "./i18n"; +import "./index.css"; +import "maplibre-gl/dist/maplibre-gl.css"; + +const queryClient = new QueryClient(); + +// Set HTML lang attribute based on i18n language +i18n.on("languageChanged", (lng) => { + document.documentElement.lang = lng; +}); + +// Set initial language on mount +document.documentElement.lang = i18n.language; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + + +); diff --git a/frontend/src/pages/AboutPage.tsx b/frontend/src/pages/AboutPage.tsx new file mode 100644 index 0000000000..e7ae7aee9e --- /dev/null +++ b/frontend/src/pages/AboutPage.tsx @@ -0,0 +1,69 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; + +export default function AboutPage() { + const { t } = useTranslation(); + return ( +
    +

    {t("pages.about.title")}

    + +
    +

    {t("pages.about.whatIsBADA")}

    +

    + {t("pages.about.whatIsBADADesc")} +

    +

    + {t("pages.about.whatIsBADAMission")} +

    +
    + +
    +

    {t("pages.about.dataSource")}

    +

    + {t("pages.about.dataSourceDesc")} +

    +

    + {t("pages.about.dataSourceDirective")} +

    +
    + +
    +

    {t("pages.about.features")}

    +
      +
    • {t("pages.about.featureMap")}
    • +
    • {t("pages.about.featureLocation")}
    • +
    • {t("pages.about.featureQuality")}
    • +
    • {t("pages.about.featureFavorites")}
    • +
    • {t("pages.about.featureDarkMode")}
    • +
    • {t("pages.about.featureLang")}
    • +
    +
    + +
    +

    {t("pages.about.builtWith")}

    +

    + {t("pages.about.builtWithDesc")}{" "} + + {t("pages.about.githubRepo")} + {" "} + {t("pages.about.builtWithDesc2")} +

    +
    + +
    + + {t("pages.backToMap")} + +
    +
    + ); +} + diff --git a/frontend/src/pages/BeachDetailPage.tsx b/frontend/src/pages/BeachDetailPage.tsx new file mode 100644 index 0000000000..dc1023e63f --- /dev/null +++ b/frontend/src/pages/BeachDetailPage.tsx @@ -0,0 +1,228 @@ +import { useParams, Link, useNavigate, useLocation } from "react-router"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import toast from "react-hot-toast"; +import { fetchBeach } from "../api/beaches"; +import { formatDate } from "../utils/format"; +import { getClassificationKey } from "../utils/quality"; +import { + useFavorites, + useAddFavorite, + useRemoveFavorite, +} from "../api/favorites"; +import { useAuth } from "@/store/auth"; + +// Map numeric/class text → color class +function qualityClass(q: number | string | undefined) { + if (typeof q === "number") { + switch (q) { + case 1: + return "kpi-excellent"; + case 2: + return "kpi-good"; + case 3: + return "kpi-sufficient"; + case 4: + return "kpi-poor"; + default: + return "kpi-unknown"; + } + } + if (typeof q === "string") { + const s = q.toLowerCase(); + if (s.includes("utmĂ€rkt")) return "kpi-excellent"; + if (s.includes("bra")) return "kpi-good"; + if (s.includes("tillfreds") || s.includes("sufficient")) + return "kpi-sufficient"; + if (s.includes("dĂ„lig") || s.includes("poor")) return "kpi-poor"; + } + return "kpi-unknown"; +} + +export default function BeachDetailPage() { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const location = useLocation(); + const { token } = useAuth(); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["beach", id], + enabled: !!id, + queryFn: () => fetchBeach(id!), + staleTime: 5 * 60 * 1000, + }); + + // --- FAVORITES HOOKS --- + const queryClient = useQueryClient(); + const { data: favorites } = useFavorites(); + const addFav = useAddFavorite(); + const rmFav = useRemoveFavorite(); + const existingFav = favorites?.find((f) => f.beachId === id); + const isFav = !!existingFav; + + if (isLoading) { + return ( +
    +
    +
    +
    +
    +
    +
    +
    + ); + } + + if (isError || !data) { + return ( +
    +
    +

    {t("loadError")}

    +

    + {(error as Error)?.message ?? t("beachDetail.pleaseTryAgain")} +

    + + ← {t("back")} + +
    +
    + ); + } + + const title = data.locationName ?? t("beachDetail.beach"); + const muni = data.locationArea ?? ""; + const qualityNum = data.classification; + const classificationKey = getClassificationKey(qualityNum ?? data.classificationText); + const qualityText = t(classificationKey); + const pillClass = qualityClass(qualityNum ?? data.classificationText); + + const latestSampleLabel = data.latestSampleDate + ? formatDate(data.latestSampleDate, "short") + : "—"; + + async function handleFavoriteClick() { + if (!token) { + navigate("/login", { replace: false, state: { from: location } }); + return; + } + try { + if (isFav) { + await rmFav.mutateAsync({ id: existingFav?._id, beachId: id }); + toast.success(t("favorites.removed")); + } else { + await addFav.mutateAsync(id!); + toast.success(t("favorites.added")); + } + queryClient.invalidateQueries({ queryKey: ["favorites", token] }); + queryClient.invalidateQueries({ queryKey: ["beach", id] }); + } catch (e: any) { + const errorMsg = e?.message ?? t("favorites.removed"); + toast.error(errorMsg); + } + } + + return ( +
    + {/* Heading block */} +
    +

    {title}

    +

    {muni || "—"}

    +
    + + {/* KPI row */} +
    + + {qualityText}{" "} + {data.classificationYear ? `‱ ${data.classificationYear}` : ""} + + {data.euType && {t("beachDetail.euBad")}} +
    + + {/* Meta card */} +
    +
    +
    +
    {t("beachDetail.latestSampleDate")}
    +
    {latestSampleLabel}
    +
    +
    +
    {t("beachDetail.algalBloom")}
    +
    {data.algalText ?? "—"}
    +
    +
    +
    {t("beachDetail.euMotive")}
    +
    {data.euMotive ?? "—"}
    +
    +
    + + {/* Actions row */} +
    + + + + ← {t("back")} + +
    +
    + + {/* Description */} + {data.bathInformation && ( +
    +

    {t("beachDetail.bathInformation")}

    +

    + {data.bathInformation} +

    +
    + )} + + {/* Contact */} + {(data.contactMail || data.contactPhone || data.contactUrl) && ( +
    +

    {t("nav.contact")}

    + {data.contactMail && ( +
    + {t("beachDetail.mail")}{" "} + + {data.contactMail} + +
    + )} + {data.contactPhone &&
    {t("beachDetail.phone")} {data.contactPhone}
    } + {data.contactUrl && ( +
    + {t("beachDetail.website")}{" "} + + {data.contactUrl} + +
    + )} +
    + )} +
    + ); +} diff --git a/frontend/src/pages/ContactPage.tsx b/frontend/src/pages/ContactPage.tsx new file mode 100644 index 0000000000..e1c94d6b10 --- /dev/null +++ b/frontend/src/pages/ContactPage.tsx @@ -0,0 +1,64 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; + +export default function ContactPage() { + const { t } = useTranslation(); + return ( +
    +

    {t("pages.contact.title")}

    + +
    +

    {t("pages.contact.getInTouch")}

    +

    + {t("pages.contact.getInTouchDesc")} +

    +
    + +
    +

    {t("pages.contact.github")}

    +

    + {t("pages.contact.githubDesc")}{" "} + + {t("pages.contact.githubRepo")} + . +

    +
    + +
    +

    {t("pages.contact.feedback")}

    +

    + {t("pages.contact.feedbackDesc")} +

    +
    + +
    +

    {t("pages.contact.accuracy")}

    +

    + {t("pages.contact.accuracyDesc")} +

    +
    + +
    +

    {t("pages.contact.privacy")}

    +

    + {t("pages.contact.privacyDesc")} +

    +
    + +
    + + {t("pages.backToMap")} + +
    +
    + ); +} + diff --git a/frontend/src/pages/FavoritesPage.tsx b/frontend/src/pages/FavoritesPage.tsx new file mode 100644 index 0000000000..de09dc84e4 --- /dev/null +++ b/frontend/src/pages/FavoritesPage.tsx @@ -0,0 +1,275 @@ +import { + useFavorites, + useRemoveFavorite, + type Favorite, + useReorderFavorites, +} from "@/api/favorites"; +import { useBeachDetails } from "@/api/useBeachDetails"; +import { getClassificationKey } from "@/utils/quality"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { useState, useMemo, useEffect } from "react"; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import SortableFavorite from "@/components/SortableFavorite"; + +function qualityClass(q?: number | string) { + if (typeof q === "number") { + return ( + ["kpi-excellent", "kpi-good", "kpi-sufficient", "kpi-poor"][q - 1] ?? + "kpi-unknown" + ); + } + const s = String(q ?? "").toLowerCase(); + if (s.includes("utmÀrkt")) return "kpi-excellent"; + if (s.includes("bra")) return "kpi-good"; + if (s.includes("tillfreds") || s.includes("sufficient")) + return "kpi-sufficient"; + if (s.includes("dÄlig") || s.includes("poor")) return "kpi-poor"; + return "kpi-unknown"; +} + +const SORT_KEY = "favoritesSort"; // 'custom' | 'name' | 'municipality' +const ORDER_KEY = "favoritesOrder:v1"; // stores array of beachIds + +export default function FavoritesPage() { + const { t } = useTranslation(); + const { data: favorites, isLoading, isError, error } = useFavorites(); + const ids = favorites?.map((f) => f.beachId); + const details = useBeachDetails(ids); + const rmFav = useRemoveFavorite(); + const reorderFavs = useReorderFavorites(); + + // --- Sort mode with persistence + const [sortBy, setSortBy] = useState<"custom" | "name" | "municipality">( + () => + (localStorage.getItem(SORT_KEY) as "custom" | "name" | "municipality") ?? + "name" + ); + useEffect(() => { + localStorage.setItem(SORT_KEY, sortBy); + }, [sortBy]); + + // --- Custom order state (array of beachIds), persisted + const [order, setOrder] = useState(() => { + try { + const raw = localStorage.getItem(ORDER_KEY); + return raw ? (JSON.parse(raw) as string[]) : []; + } catch { + return []; + } + }); + // Reconcile order whenever favorites change (add missing, remove gone) + useEffect(() => { + if (!favorites) return; + const favIds = favorites.map((f) => f.beachId); + const known = new Set(order); + const merged = [...order, ...favIds.filter((id) => !known.has(id))]; + const filtered = merged.filter((id) => favIds.includes(id)); + if (JSON.stringify(filtered) !== JSON.stringify(order)) { + setOrder(filtered); + } + }, [favorites]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + localStorage.setItem(ORDER_KEY, JSON.stringify(order)); + }, [order]); + + // --- Enriched items map + const enriched = useMemo(() => { + const map = new Map< + string, + { + fav: Favorite; + name: string; + muni: string; + classification?: number | string; + classificationText: string; + } + >(); + + if (!favorites) return map; + + for (const f of favorites) { + const info = details.byId.get(f.beachId); + const rawClassification = info?.classification ?? info?.classificationText; + const classificationKey = getClassificationKey(rawClassification); + + map.set(f.beachId, { + fav: f, + name: info?.locationName ?? f.beachId, + muni: info?.locationArea ?? "", + classification: rawClassification, + classificationText: t(classificationKey), + }); + } + return map; + }, [favorites, details.byId, t]); + + // --- Items to render (IDs in display order) + const displayIds = useMemo(() => { + if (!favorites) return []; + const favIds = favorites.map((f) => f.beachId); + + if (sortBy === "custom" && order.length) { + return order.filter((id) => favIds.includes(id)); + } + + const list = favIds.slice(); + const collator = new Intl.Collator(undefined, { sensitivity: "base" }); + list.sort((a, b) => { + const A = enriched.get(a); + const B = enriched.get(b); + if (!A || !B) return 0; + if (sortBy === "name") return collator.compare(A.name, B.name); + return collator.compare(A.muni, B.muni); + }); + return list; + }, [favorites, sortBy, order, enriched]); + + // --- DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) + ); + + function onDragEnd(event: DragEndEvent) { + if (sortBy !== "custom") return; + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = order.indexOf(String(active.id)); + const newIndex = order.indexOf(String(over.id)); + if (oldIndex === -1 || newIndex === -1) return; + + const next = arrayMove(order, oldIndex, newIndex); + setOrder(next); + + // Call backend to persist + reorderFavs.mutate(next, { + onError: () => { + // Rollback on error + setOrder(order); + }, + }); + } + + // --- Loading state + if (isLoading) { + return ( +
    +

    {t("favorites.title")}

    +
    + {[...Array(3)].map((_, i) => ( +
    +
    +
    +
    + ))} +
    +
    + ); + } + + // --- Error state + if (isError) { + return ( +
    +

    {t("favorites.title")}

    +

    + {(error as Error)?.message ?? t("loadError")} +

    +
    + ); + } + + // --- Normal render + return ( +
    +
    +

    {t("favorites.title")}

    +
    + + + {t("favorites.browseAll")} + +
    +
    + + {favorites && favorites.length === 0 && ( +
    +

    {t("favorites.empty")}

    +

    + {t("favorites.addFavorites")} +

    + + {t("beachesList.modeDefault")} + +
    + )} + + + +
      + {displayIds.map((id) => { + const item = enriched.get(id); + if (!item) return null; + const { fav, name, muni, classification, classificationText } = + item; + const klass = qualityClass(classification ?? classificationText); + + return ( + + rmFav + .mutateAsync({ id: fav._id, beachId: fav.beachId }) + .then(() => { + setOrder((prev) => prev.filter((x) => x !== id)); + }) + } + /> + ); + })} +
    +
    +
    +
    + ); +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000000..d4cbe35199 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,4 @@ +import BeachesList from "../components/BeachesList"; +export default function HomePage() { + return ; +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000000..e818ecff04 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,114 @@ +import { useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useLocation, Link } from "react-router"; +import toast from "react-hot-toast"; +import { useAuth } from "@/store/auth"; + +type FormData = { + email: string; + password: string; +}; + +export default function LoginPage() { + const { t } = useTranslation(); + const { setToken } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const from = (location.state as any)?.from?.pathname || "/"; + + const schema = useMemo(() => z.object({ + email: z.string().email(t("auth.invalidEmail")), + password: z.string().min(8, t("auth.passwordTooShort")), + }), [t]); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + } = useForm({ + resolver: zodResolver(schema), + }); + + async function onSubmit(values: FormData) { + try { + const res = await fetch(`${import.meta.env.VITE_API_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + const msg = body?.error ?? res.statusText; + setError("root", { message: msg }); + toast.error(msg); + return; + } + + const data = await res.json(); + setToken(data.token); + toast.success(t("auth.signInSuccess")); + navigate(from, { replace: true }); + } catch (err: any) { + const errorMsg = err.message ?? t("auth.signInFailed"); + setError("root", { message: errorMsg }); + toast.error(errorMsg); + } + } + + return ( +
    +

    {t("auth.signInTitle")}

    + +
    + + + {errors.email && ( +

    {errors.email.message}

    + )} +
    + +
    + + + {errors.password && ( +

    {errors.password.message}

    + )} +
    + + {errors.root && ( +

    {errors.root.message}

    + )} + + + + +

    + {t("auth.noAccount")}{" "} + + {t("auth.registerHere")} + +

    +
    + ); +} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000000..259345064d --- /dev/null +++ b/frontend/src/pages/ProfilePage.tsx @@ -0,0 +1,67 @@ +import { Link, useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@/store/auth"; +import { useQueryClient } from "@tanstack/react-query"; + +export default function ProfilePage() { + const { t } = useTranslation(); + const { token, clearToken } = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const handleLogout = () => { + clearToken(); + queryClient.clear(); + navigate("/"); + }; + + return ( +
    +

    {t("pages.profile.title")}

    + +
    +

    Account Information

    +

    + You are currently signed in. Your account allows you to save favorite beaches and + access them from any device. +

    +
    + +
    +

    Your Favorites

    +

    + Manage your saved beaches and keep track of your favorite swimming spots. +

    + + View My Favorites + +
    + +
    +

    Sign Out

    +

    + Sign out of your account. You'll need to sign in again to access your favorites. +

    + +
    + +
    + + {t("pages.backToMap")} + +
    +
    + ); +} + diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000000..6bc31e7597 --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,136 @@ +import { useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslation } from "react-i18next"; +import { useNavigate, Link } from "react-router"; +import toast from "react-hot-toast"; + +type FormData = { + email: string; + password: string; + confirmPassword: string; +}; + +export default function RegisterPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const schema = useMemo(() => z + .object({ + email: z.string().email(t("auth.invalidEmail")), + password: z.string().min(8, t("auth.passwordTooShort")), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: t("auth.passwordsMustMatch"), + path: ["confirmPassword"], + }), [t]); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + } = useForm({ + resolver: zodResolver(schema), + }); + + async function onSubmit(values: FormData) { + try { + const res = await fetch( + `${import.meta.env.VITE_API_BASE}/auth/register`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: values.email, + password: values.password, + }), + } + ); + + if (!res.ok) { + const body = await res.json().catch(() => null); + const msg = body?.error ?? res.statusText; + setError("root", { message: msg }); + toast.error(msg); + return; + } + + toast.success(t("auth.registerSuccess")); + navigate("/login", { replace: true }); + } catch (err: any) { + const errorMsg = err.message ?? t("auth.registerFailed"); + setError("root", { message: errorMsg }); + toast.error(errorMsg); + } + } + + return ( +
    +

    {t("auth.registerTitle")}

    +
    +
    + + + {errors.email && ( +

    {errors.email.message}

    + )} +
    + +
    + + + {errors.password && ( +

    {errors.password.message}

    + )} +
    + +
    + + + {errors.confirmPassword && ( +

    + {errors.confirmPassword.message} +

    + )} +
    + + {errors.root && ( +

    {errors.root.message}

    + )} + + +
    + +

    + {t("auth.alreadyHaveAccount")}{" "} + + {t("auth.signInHere")} + +

    +
    + ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000000..d1bcda743c --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,45 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import LanguageSwitcher from "@/components/LanguageSwitcher"; + +export default function SettingsPage() { + const { t } = useTranslation(); + + return ( +
    +

    Settings

    + +
    +

    Language

    +

    + Choose your preferred language for BADA. +

    + +
    + +
    +

    Dark Mode

    +

    + Toggle dark mode from the user menu in the header. +

    +
    + +
    +

    Notifications

    +

    + Notification preferences will be available in a future update. +

    +
    + +
    + + ← Back to map + +
    +
    + ); +} + diff --git a/frontend/src/pages/TermsPage.tsx b/frontend/src/pages/TermsPage.tsx new file mode 100644 index 0000000000..1a7c75daa8 --- /dev/null +++ b/frontend/src/pages/TermsPage.tsx @@ -0,0 +1,76 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; + +export default function TermsPage() { + const { t } = useTranslation(); + return ( +
    +

    {t("pages.terms.title")}

    + +
    +

    {t("pages.terms.acceptance")}

    +

    + {t("pages.terms.acceptanceDesc")} +

    +
    + +
    +

    {t("pages.terms.service")}

    +

    + {t("pages.terms.serviceDesc")} +

    +
    + +
    +

    {t("pages.terms.disclaimer")}

    +

    + {t("pages.terms.disclaimerDesc")} +

    +

    + {t("pages.terms.disclaimerDesc2")} +

    +
    + +
    +

    {t("pages.terms.accounts")}

    +

    + {t("pages.terms.accountsDesc")} +

    +
    + +
    +

    {t("pages.terms.ip")}

    +

    + {t("pages.terms.ipDesc")} +

    +
    + +
    +

    {t("pages.terms.changes")}

    +

    + {t("pages.terms.changesDesc")} +

    +
    + +
    +

    {t("pages.terms.contact")}

    +

    + {t("pages.terms.contactDesc")}{" "} + + {t("pages.terms.contactUs")} + . +

    +
    + +
    + + {t("pages.backToMap")} + +
    +
    + ); +} + diff --git a/frontend/src/pages/WhatIsEUBeachPage.tsx b/frontend/src/pages/WhatIsEUBeachPage.tsx new file mode 100644 index 0000000000..889b668a2f --- /dev/null +++ b/frontend/src/pages/WhatIsEUBeachPage.tsx @@ -0,0 +1,83 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; + +export default function WhatIsEUBeachPage() { + const { t } = useTranslation(); + return ( +
    +

    {t("pages.whatIsEUBeach.title")}

    + +
    +

    {t("pages.whatIsEUBeach.directive")}

    +

    + {t("pages.whatIsEUBeach.directiveDesc")} +

    +

    + {t("pages.whatIsEUBeach.directiveDesc2")} +

    +
    + +
    +

    {t("pages.whatIsEUBeach.classifications")}

    + +
    +
    +
    + {t("classification.excellent")} +
    +

    + {t("pages.whatIsEUBeach.excellentDesc")} +

    +
    + +
    +
    + {t("classification.good")} +
    +

    + {t("pages.whatIsEUBeach.goodDesc")} +

    +
    + +
    +
    + {t("classification.sufficient")} +
    +

    + {t("pages.whatIsEUBeach.sufficientDesc")} +

    +
    + +
    +
    + {t("classification.poor")} +
    +

    + {t("pages.whatIsEUBeach.poorDesc")} +

    +
    +
    +
    + +
    +

    {t("pages.whatIsEUBeach.monitoring")}

    +

    + {t("pages.whatIsEUBeach.monitoringDesc")} +

    +

    + {t("pages.whatIsEUBeach.monitoringDesc2")} +

    +
    + +
    + + {t("pages.backToMap")} + +
    +
    + ); +} + diff --git a/frontend/src/routes/ProtectedRoute.tsx b/frontend/src/routes/ProtectedRoute.tsx new file mode 100644 index 0000000000..ad60cc44e8 --- /dev/null +++ b/frontend/src/routes/ProtectedRoute.tsx @@ -0,0 +1,10 @@ +import { Navigate, Outlet, useLocation } from "react-router"; +import { isAuthenticated } from "@/store/auth"; + +export default function ProtectedRoute() { + const location = useLocation(); + if (!isAuthenticated()) { + return ; + } + return ; +} diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts new file mode 100644 index 0000000000..ae65c08935 --- /dev/null +++ b/frontend/src/store/auth.ts @@ -0,0 +1,36 @@ +import { create } from "zustand"; + +type AuthState = { + token: string | null; + setToken: (t: string | null) => void; + clearToken: () => void; +}; + +const TOKEN_KEY = "bada_token"; + +export const useAuth = create((set) => { + // hydrate from localStorage on first run + const initialToken = + typeof window !== "undefined" + ? window.localStorage.getItem(TOKEN_KEY) + : null; + + return { + token: initialToken, + setToken: (t) => { + if (t) { + window.localStorage.setItem(TOKEN_KEY, t); + } else { + window.localStorage.removeItem(TOKEN_KEY); + } + set({ token: t }); + }, + clearToken: () => { + window.localStorage.removeItem(TOKEN_KEY); + set({ token: null }); + }, + }; +}); + +export const isAuthenticated = () => !!useAuth.getState().token; +export const TOKEN_STORAGE_KEY = TOKEN_KEY; diff --git a/frontend/src/store/ui.ts b/frontend/src/store/ui.ts new file mode 100644 index 0000000000..a8727b4586 --- /dev/null +++ b/frontend/src/store/ui.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +type UIState = { + search: string; + setSearch: (v: string) => void; +}; + +export const useUI = create((set) => ({ + search: "", + setSearch: (v) => set({ search: v }), +})); diff --git a/frontend/src/types/beaches.ts b/frontend/src/types/beaches.ts new file mode 100644 index 0000000000..e5007a095d --- /dev/null +++ b/frontend/src/types/beaches.ts @@ -0,0 +1,89 @@ +// GeoJSON + domain types for beaches + +export type LngLat = [number, number]; // GeoJSON order: [lon, lat] + +// --- GeoJSON (list) --- +export interface BeachFeatureProperties { + NUTSKOD: string; // stable beach ID + NAMN: string; // name + KMN_NAMN?: string; // municipality (optional) +} + +export interface BeachFeature { + type: "Feature"; + id?: string; + geometry?: { type: "Point"; coordinates: LngLat }; + geometry_name?: string; + properties?: BeachFeatureProperties; +} + +export interface BeachFeatureCollection { + type: "FeatureCollection"; + features: BeachFeature[]; +} + +// --- Simplified list item for UI --- +export interface BeachSummary { + id: string; // NUTSKOD + name: string; // NAMN + municipality: string; // KMN_NAMN (empty string if missing) + lat: number; + lon: number; // canonical longitude + lng?: number; // temporary alias (same as lon) to avoid breaking older code +} + +/** + * Map a GeoJSON feature → BeachSummary. + * Returns null if coordinates are missing/invalid. + */ +export function featureToSummary(f: BeachFeature): BeachSummary | null { + const props = f.properties; + const coords = f.geometry?.coordinates; + if (!props || !coords) return null; + + const [lon, lat] = coords; + if (typeof lat !== "number" || typeof lon !== "number") return null; + + return { + id: props.NUTSKOD ?? f.id ?? "", + name: props.NAMN ?? "OkĂ€nd", + municipality: props.KMN_NAMN ?? "", + lat, + lon, + lng: lon, // alias for any code still using `lng` + }; +} + +// --- Detail shape (plain JSON from /detail/:id) --- +export interface BeachQualityRating { + qualityRating: number; // 1..4 + qualityRatingText: string; // "UtmĂ€rkt kvalitet" | "Bra kvalitet" | ... + ratingYear: number; +} + +export interface BeachDetail { + algalText?: string; + algalValue?: number; + bathInformation?: string; + classification?: number; // 1..4 (current year) + classificationText?: string; // e.g. "Bra kvalitet" + classificationYear?: number; + contactMail?: string; + contactPhone?: string; + contactUrl?: string; + dissuasion?: string[]; + euMotive?: string; + euType?: boolean; + locationArea?: string; + locationName?: string; + nutsCode: string; + qualityRating?: BeachQualityRating[]; + + // âŹ‡ïž Likely sample-date candidates we’ll probe for: + latestSampleDate?: string; + sampleDate?: string; + lastSampleDate?: string; + // sometimes APIs nest: + samples?: Array<{ date?: string; [k: string]: unknown }>; + lastSample?: { date?: string; [k: string]: unknown }; +} diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts new file mode 100644 index 0000000000..55f4047f1a --- /dev/null +++ b/frontend/src/utils/auth.ts @@ -0,0 +1,13 @@ +export const TOKEN_KEY = "BADA_TOKEN"; + +export function getToken(): string { + return localStorage.getItem(TOKEN_KEY) ?? ""; +} + +export function setToken(token: string) { + localStorage.setItem(TOKEN_KEY, token); +} + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY); +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000000..16ae245146 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,34 @@ +/** + * Format a date in a human readable way. + * If input is invalid, return as-is. + * If no input, return em dash. + * + * @param input Date input (string, number, Date) + * @param style "long" (default) or "short" (YYYY-MM-DD) + * @returns Formatted date string + */ + +export function formatDate( + input?: string | number | Date, + style: "long" | "short" = "long" +): string { + if (!input) return "—"; + const d = new Date(input); + if (Number.isNaN(d.getTime())) return String(input); + + if (style === "short") { + return d.toLocaleDateString("sv-SE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } + + const locale = + (typeof navigator !== "undefined" && navigator.language) || "sv-SE"; + return d.toLocaleDateString(locale, { + year: "numeric", + month: "short", + day: "numeric", + }); +} diff --git a/frontend/src/utils/geo.ts b/frontend/src/utils/geo.ts new file mode 100644 index 0000000000..53c845eea7 --- /dev/null +++ b/frontend/src/utils/geo.ts @@ -0,0 +1,21 @@ +export function distanceKm( + a: { lat: number; lon: number }, + b: { lat: number; lon: number } +): number { + const R = 6371; // km + const dLat = ((b.lat - a.lat) * Math.PI) / 180; + const dLon = ((b.lon - a.lon) * Math.PI) / 180; + const lat1 = (a.lat * Math.PI) / 180; + const lat2 = (b.lat * Math.PI) / 180; + + const s = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + + return 2 * R * Math.asin(Math.min(1, Math.sqrt(s))); +} + +export function formatKm(km: number): string { + if (km < 1) return `${Math.round(km * 1000)} m`; + return `${km.toFixed(km < 10 ? 1 : 0)} km`; +} diff --git a/frontend/src/utils/logger.ts b/frontend/src/utils/logger.ts new file mode 100644 index 0000000000..c2800ae698 --- /dev/null +++ b/frontend/src/utils/logger.ts @@ -0,0 +1,7 @@ +const DEBUG = import.meta.env.VITE_DEBUG_API === "true"; + +export function logApi(...args: any[]) { + if (DEBUG) { + console.log("[API]", ...args); + } +} diff --git a/frontend/src/utils/quality.ts b/frontend/src/utils/quality.ts new file mode 100644 index 0000000000..92443bebc5 --- /dev/null +++ b/frontend/src/utils/quality.ts @@ -0,0 +1,101 @@ +/** + * HaV uses numeric classification codes: + * 1 = UtmĂ€rkt kvalitet / Excellent quality + * 2 = Bra kvalitet / Good quality + * 3 = TillfredsstĂ€llande kvalitet / Sufficient quality + * 4 = DĂ„lig kvalitet / Poor quality + */ +export function getQualityLabel( + code: number, + lang: "sv" | "en" = "sv" +): string { + const map: Record = { + 1: { sv: "UtmĂ€rkt kvalitet", en: "Excellent quality" }, + 2: { sv: "Bra kvalitet", en: "Good quality" }, + 3: { sv: "TillfredsstĂ€llande kvalitet", en: "Sufficient quality" }, + 4: { sv: "DĂ„lig kvalitet", en: "Poor quality" }, + }; + const entry = map[code]; + if (!entry) return lang === "sv" ? "OkĂ€nd" : "Unknown"; + return entry[lang]; +} + +// Map detail object to a normalized classification code +export function deriveClassificationCode(detail: { + classification?: number; + qualityRating?: { qualityRating?: number; ratingYear?: number }[]; +}): "excellent" | "good" | "sufficient" | "poor" | "unknown" { + const numeric = + detail.classification ?? + detail.qualityRating?.find((q) => typeof q.qualityRating === "number") + ?.qualityRating; + + switch (numeric) { + case 1: + return "excellent"; + case 2: + return "good"; + case 3: + return "sufficient"; + case 4: + return "poor"; + default: + return "unknown"; + } +} + +/** + * Maps classification text (Swedish from API or numeric) to i18n translation key. + * This ensures classification badges are displayed in the user's selected language. + */ +export function getClassificationKey( + classification?: number | string +): string { + // Handle numeric classification + if (typeof classification === "number") { + switch (classification) { + case 1: + return "classification.excellent"; + case 2: + return "classification.good"; + case 3: + return "classification.sufficient"; + case 4: + return "classification.poor"; + default: + return "classification.unknown"; + } + } + + // Handle text classification (Swedish from API) + if (typeof classification === "string") { + const text = classification.toLowerCase().trim(); + + // Excellent + if (text.includes("utmĂ€rkt")) { + return "classification.excellent"; + } + // Good + if (text.includes("bra")) { + return "classification.good"; + } + // Sufficient + if (text.includes("tillfreds")) { + return "classification.sufficient"; + } + // Poor + if (text.includes("dĂ„lig")) { + return "classification.poor"; + } + // Classification in progress + if (text.includes("klassificering") && text.includes("pĂ„gĂ„r")) { + return "classification.inProgress"; + } + // Not classified + if (text.includes("ej klassificerad") || text.includes("inte klassificerad")) { + return "classification.notClassified"; + } + } + + return "classification.unknown"; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000000..696d44f5de --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,15 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE: string; +} +interface ImportMeta { + readonly env: ImportMetaEnv; +} + +// SVGR React component types for `?react` imports +declare module "*.svg?react" { + import * as React from "react"; + const ReactComponent: React.FC>; + export default ReactComponent; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000..8c716a1077 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["vite/client"], + "baseUrl": "./src", + "paths": { + "@/*": ["*"] + } + }, + "include": ["src"] +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 5a33944a9b..40958d8212 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,7 +1,21 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import svgr from "vite-plugin-svgr"; +import path from "path"; -// https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], -}) + plugins: [react(), svgr()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + publicDir: "public", + build: { + rollupOptions: { + input: { + main: path.resolve(__dirname, "index.html"), + }, + }, + }, +}); diff --git a/package.json b/package.json index 680d190772..6d57fb4096 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,18 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@tanstack/react-query": "^5.87.4", + "react-hook-form": "^7.63.0", + "zod": "^4.1.11" + }, + "devDependencies": { + "autoprefixer": "^10.4.21", + "init": "^0.1.2", + "npx": "^10.2.2", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.13" } -} \ No newline at end of file +}